add AIAgent think

This commit is contained in:
Timi
2026-09-16 16:15:24 +08:00
parent f8a9c0e649
commit 91938f2427
4 changed files with 307 additions and 18 deletions
+175 -6
View File
@@ -20,10 +20,16 @@ export interface AiCitation {
generatedAt?: string; generatedAt?: string;
} }
export interface AiThinking {
id: number;
content: string;
}
export interface AiChatMessage { export interface AiChatMessage {
id: number; id: number;
role: AiMessageRole; role: AiMessageRole;
content: string; content: string;
thinkingList: AiThinking[];
citationList: AiCitation[]; citationList: AiCitation[];
createdAt: number; createdAt: number;
statusText?: string; statusText?: string;
@@ -37,6 +43,14 @@ export interface UseAiChatOptions {
} }
const conversationExpiredText = "AI 会话不存在或已过期"; const conversationExpiredText = "AI 会话不存在或已过期";
const thinkOpenMarkers = ["<thinking>", "<think>"];
const thinkCloseMarkers = ["</thinking>", "</think>"];
interface AssistantResponseState {
rawText: string;
eventThinkingList: AiThinking[];
taggedThinkingList: AiThinking[];
}
/** 管理门店智能助手的会话、流式消息和取消操作。 */ /** 管理门店智能助手的会话、流式消息和取消操作。 */
export function useAiChat(options: UseAiChatOptions) { export function useAiChat(options: UseAiChatOptions) {
@@ -50,6 +64,7 @@ export function useAiChat(options: UseAiChatOptions) {
const canRetry = computed(() => !!lastError.value && !!lastQuestion.value && !isGenerating.value); const canRetry = computed(() => !!lastError.value && !!lastQuestion.value && !isGenerating.value);
let activeController: AbortController | undefined; let activeController: AbortController | undefined;
let messageSequence = 0; let messageSequence = 0;
const responseStateMap = new Map<number, AssistantResponseState>();
async function sendMessage(message: string): Promise<void> { async function sendMessage(message: string): Promise<void> {
const normalizedMessage = message.trim(); const normalizedMessage = message.trim();
@@ -61,6 +76,7 @@ export function useAiChat(options: UseAiChatOptions) {
id: nextMessageId(), id: nextMessageId(),
role: "user", role: "user",
content: normalizedMessage, content: normalizedMessage,
thinkingList: [],
citationList: [], citationList: [],
createdAt: Date.now() createdAt: Date.now()
}; };
@@ -68,6 +84,7 @@ export function useAiChat(options: UseAiChatOptions) {
id: nextMessageId(), id: nextMessageId(),
role: "assistant", role: "assistant",
content: "", content: "",
thinkingList: [],
citationList: [], citationList: [],
createdAt: Date.now() createdAt: Date.now()
}; };
@@ -106,6 +123,7 @@ export function useAiChat(options: UseAiChatOptions) {
} }
isGenerating.value = false; isGenerating.value = false;
activeToolStatus.value = ""; activeToolStatus.value = "";
responseStateMap.delete(assistantMessage.id);
await scrollToBottom(); await scrollToBottom();
} }
} }
@@ -126,6 +144,7 @@ export function useAiChat(options: UseAiChatOptions) {
activeController?.abort(); activeController?.abort();
activeController = undefined; activeController = undefined;
messages.value = []; messages.value = [];
responseStateMap.clear();
conversationId.value = ""; conversationId.value = "";
isGenerating.value = false; isGenerating.value = false;
activeToolStatus.value = ""; activeToolStatus.value = "";
@@ -161,11 +180,20 @@ export function useAiChat(options: UseAiChatOptions) {
async function handleStreamEvent(event: GaoAiStreamEvent, assistantId: number, hasDoneEvent: boolean): Promise<boolean> { async function handleStreamEvent(event: GaoAiStreamEvent, assistantId: number, hasDoneEvent: boolean): Promise<boolean> {
const payload = parseSseData(event.data); const payload = parseSseData(event.data);
switch (event.event) { switch (event.event) {
case "think":
case "thinking":
appendAssistantThinking(assistantId, extractThinkingText(payload));
return hasDoneEvent;
case "agent_thought":
appendAssistantThinking(assistantId, extractThinkingText(payload), true);
return hasDoneEvent;
case "message": case "message":
appendAssistantText(assistantId, extractText(payload)); appendAssistantResponse(assistantId, extractText(payload));
return hasDoneEvent; return hasDoneEvent;
case "tool_status": case "tool_status":
{
activeToolStatus.value = formatToolStatus(payload); activeToolStatus.value = formatToolStatus(payload);
}
return hasDoneEvent; return hasDoneEvent;
case "citation": case "citation":
appendCitation(assistantId, normalizeCitation(payload)); appendCitation(assistantId, normalizeCitation(payload));
@@ -195,15 +223,58 @@ export function useAiChat(options: UseAiChatOptions) {
}); });
} }
function appendAssistantText(assistantId: number, text: string): void { function appendAssistantResponse(assistantId: number, text: string): void {
if (!text) { if (!text) {
return; return;
} }
const message = messages.value.find(item => item.id === assistantId); const state = getAssistantResponseState(assistantId);
if (!message) { state.rawText = mergeResponseText(state.rawText, text);
const parsedResponse = parseAssistantResponse(state.rawText);
state.taggedThinkingList = parsedResponse.thinkingList.map((content, index) => ({
id: state.taggedThinkingList[index]?.id || nextThinkingId(),
content
}));
updateMessage(assistantId, {
content: parsedResponse.answer,
thinkingList: [...state.eventThinkingList, ...state.taggedThinkingList],
statusText: undefined
});
}
function getAssistantResponseState(assistantId: number): AssistantResponseState {
const currentState = responseStateMap.get(assistantId);
if (currentState) {
return currentState;
}
const nextState: AssistantResponseState = {
rawText: "",
eventThinkingList: [],
taggedThinkingList: []
};
responseStateMap.set(assistantId, nextState);
return nextState;
}
function appendAssistantThinking(assistantId: number, text: string, createSegment = false): void {
if (!text) {
return; return;
} }
updateMessage(assistantId, { content: message.content + text, statusText: undefined }); const state = getAssistantResponseState(assistantId);
const thinkingList = [...state.eventThinkingList];
const lastThinking = thinkingList[thinkingList.length - 1];
if (createSegment || !lastThinking) {
thinkingList.push({ id: nextThinkingId(), content: text });
} else {
thinkingList[thinkingList.length - 1] = {
...lastThinking,
content: lastThinking.content + text
};
}
state.eventThinkingList = thinkingList;
updateMessage(assistantId, {
thinkingList: [...state.eventThinkingList, ...state.taggedThinkingList],
statusText: undefined
});
} }
function appendCitation(assistantId: number, citation?: AiCitation): void { function appendCitation(assistantId: number, citation?: AiCitation): void {
@@ -222,8 +293,10 @@ export function useAiChat(options: UseAiChatOptions) {
} }
function resetAssistantMessage(id: number): void { function resetAssistantMessage(id: number): void {
responseStateMap.delete(id);
updateMessage(id, { updateMessage(id, {
content: "", content: "",
thinkingList: [],
citationList: [], citationList: [],
statusText: undefined, statusText: undefined,
duration: undefined, duration: undefined,
@@ -242,6 +315,7 @@ export function useAiChat(options: UseAiChatOptions) {
function clearMessages(): void { function clearMessages(): void {
messages.value = []; messages.value = [];
responseStateMap.clear();
} }
function nextMessageId(): number { function nextMessageId(): number {
@@ -249,6 +323,13 @@ export function useAiChat(options: UseAiChatOptions) {
return messageSequence; return messageSequence;
} }
let thinkingSequence = 0;
function nextThinkingId(): number {
thinkingSequence += 1;
return thinkingSequence;
}
async function scrollToBottom(): Promise<void> { async function scrollToBottom(): Promise<void> {
await options.scrollToBottom?.(); await options.scrollToBottom?.();
} }
@@ -290,7 +371,91 @@ function extractText(payload: unknown): string {
if (!isRecord(payload)) { if (!isRecord(payload)) {
return ""; return "";
} }
return getString(payload.answer) || getString(payload.text) || getString(payload.content) || ""; return getRawString(payload.answer) || getRawString(payload.text) || getRawString(payload.content) || "";
}
function mergeResponseText(currentText: string, nextText: string): string {
if (currentText && nextText.startsWith(currentText)) {
return nextText;
}
return currentText + nextText;
}
function parseAssistantResponse(value: string): { answer: string; thinkingList: string[] } {
let answer = "";
let thinking = "";
let mode: "answer" | "thinking" = "answer";
let cursor = 0;
const thinkingList: string[] = [];
while (cursor < value.length) {
const marker = findMarker(value.slice(cursor), [...thinkOpenMarkers, ...thinkCloseMarkers]);
if (!marker) {
const rest = value.slice(cursor);
if (mode === "thinking") {
thinking += rest;
} else {
answer += rest;
}
break;
}
const content = value.slice(cursor, cursor + marker.index);
if (mode === "thinking") {
thinking += content;
if (thinking) {
thinkingList.push(thinking);
}
thinking = "";
mode = "answer";
} else if (isThinkOpenMarker(marker.value)) {
answer += content;
mode = "thinking";
} else {
if (content) {
thinkingList.push(content);
}
mode = "answer";
}
cursor += marker.index + marker.value.length;
}
if (mode === "thinking" && thinking) {
thinkingList.push(thinking);
}
return { answer, thinkingList };
}
function findMarker(value: string, markerList: string[]): { index: number; value: string } | undefined {
const lowerValue = value.toLowerCase();
return markerList.reduce<{ index: number; value: string } | undefined>((current, marker) => {
const index = lowerValue.indexOf(marker.toLowerCase());
if (index < 0 || (current && current.index <= index)) {
return current;
}
return { index, value: marker };
}, undefined);
}
function isThinkOpenMarker(value: string): boolean {
return thinkOpenMarkers.some(marker => marker.toLowerCase() === value.toLowerCase());
}
function extractThinkingText(payload: unknown): string {
if (typeof payload === "string") {
return payload === "[DONE]" ? "" : payload;
}
if (!isRecord(payload)) {
return "";
}
return getRawString(payload.thought)
|| getRawString(payload.thinking)
|| getRawString(payload.reasoning)
|| getRawString(payload.observation)
|| getRawString(payload.text)
|| getRawString(payload.content)
|| "";
} }
function formatToolStatus(payload: unknown): string { function formatToolStatus(payload: unknown): string {
@@ -364,6 +529,10 @@ function getString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined; return typeof value === "string" && value.trim() ? value.trim() : undefined;
} }
function getRawString(value: unknown): string {
return typeof value === "string" ? value : "";
}
function getDisplayText(value: unknown): string | undefined { function getDisplayText(value: unknown): string | undefined {
const text = getString(value); const text = getString(value);
if (text) { if (text) {
+3 -3
View File
@@ -4,14 +4,14 @@
<span class="error-text" v-text="lastError" /> <span class="error-text" v-text="lastError" />
<t-button v-if="canRetry" variant="text" size="small" content="重试" @click="emit('retry')" /> <t-button v-if="canRetry" variant="text" size="small" content="重试" @click="emit('retry')" />
</div> </div>
<t-form class="input-wrap glass-white" @submit.prevent="submitMessage"> <t-form class="input-wrap glass-white" @submit="submitMessage">
<t-input <t-input
v-model="draft" v-model="draft"
class="input" class="input"
borderless borderless
:disabled="isGenerating" :disabled="isGenerating"
:maxlength="256" :maxlength="256"
placeholder="问问小糕怎么个事(●'◡'●)…" placeholder="问问小糕怎么个事…"
/> />
<t-button <t-button
v-if="isGenerating" v-if="isGenerating"
@@ -93,9 +93,9 @@ function submitMessage(): void {
gap: .5rem; gap: .5rem;
border: 1px solid var(--td-component-border); border: 1px solid var(--td-component-border);
margin: .5rem 0 0 0;
display: flex; display: flex;
padding: .25rem .25rem .25rem .75rem; padding: .25rem .25rem .25rem .75rem;
margin-top: .5rem;
box-shadow: 0 .5rem 1.5rem rgba(15, 23, 42, .12); box-shadow: 0 .5rem 1.5rem rgba(15, 23, 42, .12);
align-items: center; align-items: center;
border-radius: 1rem; border-radius: 1rem;
+42 -6
View File
@@ -24,12 +24,22 @@
class="message-row" class="message-row"
:class="message.role" :class="message.role"
> >
<div class="message-header">
<div class="avatar"> <div class="avatar">
<t-icon :name="message.role === 'assistant' ? 'robot-2' : 'user'" /> <t-icon :name="message.role === 'assistant' ? 'robot-2' : 'user'" />
</div> </div>
<div class="message-main">
<div class="role-name" v-text="message.role === 'assistant' ? 'AI 小糕' : '店长'" /> <div class="role-name" v-text="message.role === 'assistant' ? 'AI 小糕' : '店长'" />
</div>
<div class="message-main">
<div class="bubble" :class="message.role"> <div class="bubble" :class="message.role">
<template v-if="message.role === 'assistant'">
<ai-chat-thinking
v-for="thinking in message.thinkingList"
:key="thinking.id"
:content="thinking.content"
:is-generating="props.isGenerating && thinking.id === message.thinkingList[message.thinkingList.length - 1]?.id"
/>
</template>
<div v-if="message.content && message.role === 'assistant'" class="content markdown-content"> <div v-if="message.content && message.role === 'assistant'" class="content markdown-content">
<markdown-view :content="message.content" /> <markdown-view :content="message.content" />
</div> </div>
@@ -57,6 +67,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { MarkdownView } from "timi-web"; import { MarkdownView } from "timi-web";
import type { AiChatMessage } from "@/composables/useAiChat"; import type { AiChatMessage } from "@/composables/useAiChat";
import AiChatThinking from "@/pages/ai/components/AiChatThinking.vue";
defineOptions({ defineOptions({
name: "AiChatMessageList" name: "AiChatMessageList"
@@ -124,7 +135,7 @@ defineExpose({
gap: 1rem; gap: 1rem;
flex: 1; flex: 1;
display: flex; display: flex;
padding: .5rem 1rem calc(12rem + env(safe-area-inset-bottom, 0px)); padding: .5rem 1rem calc(6rem + env(safe-area-inset-bottom, 0px));
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
flex-direction: column; flex-direction: column;
@@ -173,10 +184,29 @@ defineExpose({
.message-row { .message-row {
gap: .5rem; gap: .5rem;
display: flex; display: flex;
align-items: flex-start; width: 100%;
align-items: stretch;
flex-direction: column;
&.user { &.user {
flex-direction: row-reverse; .message-header {
justify-content: flex-end;
}
.role-name {
order: 1;
}
.avatar {
order: 2;
}
}
.message-header {
gap: .5rem;
width: 100%;
display: flex;
align-items: center;
} }
.avatar { .avatar {
@@ -196,7 +226,8 @@ defineExpose({
} }
.message-main { .message-main {
max-width: calc(100% - 2.5rem); width: 100%;
max-width: none;
} }
&.user .message-main { &.user .message-main {
@@ -205,18 +236,23 @@ defineExpose({
.role-name { .role-name {
color: var(--td-text-color-secondary); color: var(--td-text-color-secondary);
margin: 0 .25rem .25rem; margin: 0;
font-size: .6875rem; font-size: .6875rem;
} }
.bubble { .bubble {
color: var(--td-text-color-primary); color: var(--td-text-color-primary);
width: 100%;
padding: .75rem; padding: .75rem;
border-radius: .25rem 1rem 1rem 1rem; border-radius: .25rem 1rem 1rem 1rem;
background: var(--ai-bubble); background: var(--ai-bubble);
text-align: left; text-align: left;
box-shadow: 0 .25rem 1rem rgba(15, 23, 42, .05); box-shadow: 0 .25rem 1rem rgba(15, 23, 42, .05);
.thinking + .thinking {
margin-top: .75rem;
}
&.user { &.user {
color: white; color: white;
border-radius: 1rem .25rem 1rem 1rem; border-radius: 1rem .25rem 1rem 1rem;
@@ -0,0 +1,84 @@
<template>
<section class="thinking" aria-label="Agent think">
<div class="thinking-header">
<span>小糕正在思考...</span>
</div>
<div
ref="thinkingContent"
class="thinking-content"
role="log"
aria-live="polite"
>
<span v-text="props.content" />
</div>
</section>
</template>
<script setup lang="ts">
interface Props {
content: string;
isGenerating: boolean;
}
const props = defineProps<Props>();
const thinkingContentRef = useTemplateRef<HTMLElement>("thinkingContent");
// ---------- 自动滚动 ----------
async function scrollToBottom(): Promise<void> {
await nextTick();
const element = thinkingContentRef.value;
if (!element) {
return;
}
element.scrollTop = element.scrollHeight;
}
watch(
[() => props.content, () => props.isGenerating],
async ([content, isGenerating]) => {
if (content && isGenerating) {
await scrollToBottom();
}
},
{ deep: true, flush: "post" }
);
</script>
<style scoped lang="less">
.thinking {
gap: .5rem;
height: clamp(2rem, 24vh, 10rem);
display: flex;
padding: .625rem .75rem;
border: 1px solid var(--td-component-border);
border-radius: .75rem;
background: var(--ai-background);
flex-direction: column;
min-height: 2rem;
max-height: 10rem;
overflow: hidden;
.thinking-header {
gap: .375rem;
display: flex;
align-items: center;
color: var(--td-text-color-secondary);
font-size: .75rem;
line-height: 1rem;
}
.thinking-content {
flex: 1;
padding-right: .25rem;
color: var(--td-text-color-secondary);
font-size: .75rem;
line-height: 1.5;
min-height: 0;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
}
</style>