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;
}
export interface AiThinking {
id: number;
content: string;
}
export interface AiChatMessage {
id: number;
role: AiMessageRole;
content: string;
thinkingList: AiThinking[];
citationList: AiCitation[];
createdAt: number;
statusText?: string;
@@ -37,6 +43,14 @@ export interface UseAiChatOptions {
}
const conversationExpiredText = "AI 会话不存在或已过期";
const thinkOpenMarkers = ["<thinking>", "<think>"];
const thinkCloseMarkers = ["</thinking>", "</think>"];
interface AssistantResponseState {
rawText: string;
eventThinkingList: AiThinking[];
taggedThinkingList: AiThinking[];
}
/** 管理门店智能助手的会话、流式消息和取消操作。 */
export function useAiChat(options: UseAiChatOptions) {
@@ -50,6 +64,7 @@ export function useAiChat(options: UseAiChatOptions) {
const canRetry = computed(() => !!lastError.value && !!lastQuestion.value && !isGenerating.value);
let activeController: AbortController | undefined;
let messageSequence = 0;
const responseStateMap = new Map<number, AssistantResponseState>();
async function sendMessage(message: string): Promise<void> {
const normalizedMessage = message.trim();
@@ -61,6 +76,7 @@ export function useAiChat(options: UseAiChatOptions) {
id: nextMessageId(),
role: "user",
content: normalizedMessage,
thinkingList: [],
citationList: [],
createdAt: Date.now()
};
@@ -68,6 +84,7 @@ export function useAiChat(options: UseAiChatOptions) {
id: nextMessageId(),
role: "assistant",
content: "",
thinkingList: [],
citationList: [],
createdAt: Date.now()
};
@@ -106,6 +123,7 @@ export function useAiChat(options: UseAiChatOptions) {
}
isGenerating.value = false;
activeToolStatus.value = "";
responseStateMap.delete(assistantMessage.id);
await scrollToBottom();
}
}
@@ -126,6 +144,7 @@ export function useAiChat(options: UseAiChatOptions) {
activeController?.abort();
activeController = undefined;
messages.value = [];
responseStateMap.clear();
conversationId.value = "";
isGenerating.value = false;
activeToolStatus.value = "";
@@ -161,11 +180,20 @@ export function useAiChat(options: UseAiChatOptions) {
async function handleStreamEvent(event: GaoAiStreamEvent, assistantId: number, hasDoneEvent: boolean): Promise<boolean> {
const payload = parseSseData(event.data);
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":
appendAssistantText(assistantId, extractText(payload));
appendAssistantResponse(assistantId, extractText(payload));
return hasDoneEvent;
case "tool_status":
{
activeToolStatus.value = formatToolStatus(payload);
}
return hasDoneEvent;
case "citation":
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) {
return;
}
const message = messages.value.find(item => item.id === assistantId);
if (!message) {
const state = getAssistantResponseState(assistantId);
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;
}
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 {
@@ -222,8 +293,10 @@ export function useAiChat(options: UseAiChatOptions) {
}
function resetAssistantMessage(id: number): void {
responseStateMap.delete(id);
updateMessage(id, {
content: "",
thinkingList: [],
citationList: [],
statusText: undefined,
duration: undefined,
@@ -242,6 +315,7 @@ export function useAiChat(options: UseAiChatOptions) {
function clearMessages(): void {
messages.value = [];
responseStateMap.clear();
}
function nextMessageId(): number {
@@ -249,6 +323,13 @@ export function useAiChat(options: UseAiChatOptions) {
return messageSequence;
}
let thinkingSequence = 0;
function nextThinkingId(): number {
thinkingSequence += 1;
return thinkingSequence;
}
async function scrollToBottom(): Promise<void> {
await options.scrollToBottom?.();
}
@@ -290,7 +371,91 @@ function extractText(payload: unknown): string {
if (!isRecord(payload)) {
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 {
@@ -364,6 +529,10 @@ function getString(value: unknown): string | 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 {
const text = getString(value);
if (text) {
+3 -3
View File
@@ -4,14 +4,14 @@
<span class="error-text" v-text="lastError" />
<t-button v-if="canRetry" variant="text" size="small" content="重试" @click="emit('retry')" />
</div>
<t-form class="input-wrap glass-white" @submit.prevent="submitMessage">
<t-form class="input-wrap glass-white" @submit="submitMessage">
<t-input
v-model="draft"
class="input"
borderless
:disabled="isGenerating"
:maxlength="256"
placeholder="问问小糕怎么个事(●'◡'●)…"
placeholder="问问小糕怎么个事…"
/>
<t-button
v-if="isGenerating"
@@ -93,9 +93,9 @@ function submitMessage(): void {
gap: .5rem;
border: 1px solid var(--td-component-border);
margin: .5rem 0 0 0;
display: flex;
padding: .25rem .25rem .25rem .75rem;
margin-top: .5rem;
box-shadow: 0 .5rem 1.5rem rgba(15, 23, 42, .12);
align-items: center;
border-radius: 1rem;
+42 -6
View File
@@ -24,12 +24,22 @@
class="message-row"
:class="message.role"
>
<div class="message-header">
<div class="avatar">
<t-icon :name="message.role === 'assistant' ? 'robot-2' : 'user'" />
</div>
<div class="message-main">
<div class="role-name" v-text="message.role === 'assistant' ? 'AI 小糕' : '店长'" />
</div>
<div class="message-main">
<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">
<markdown-view :content="message.content" />
</div>
@@ -57,6 +67,7 @@
<script setup lang="ts">
import { MarkdownView } from "timi-web";
import type { AiChatMessage } from "@/composables/useAiChat";
import AiChatThinking from "@/pages/ai/components/AiChatThinking.vue";
defineOptions({
name: "AiChatMessageList"
@@ -124,7 +135,7 @@ defineExpose({
gap: 1rem;
flex: 1;
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;
overflow-y: auto;
flex-direction: column;
@@ -173,10 +184,29 @@ defineExpose({
.message-row {
gap: .5rem;
display: flex;
align-items: flex-start;
width: 100%;
align-items: stretch;
flex-direction: column;
&.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 {
@@ -196,7 +226,8 @@ defineExpose({
}
.message-main {
max-width: calc(100% - 2.5rem);
width: 100%;
max-width: none;
}
&.user .message-main {
@@ -205,18 +236,23 @@ defineExpose({
.role-name {
color: var(--td-text-color-secondary);
margin: 0 .25rem .25rem;
margin: 0;
font-size: .6875rem;
}
.bubble {
color: var(--td-text-color-primary);
width: 100%;
padding: .75rem;
border-radius: .25rem 1rem 1rem 1rem;
background: var(--ai-bubble);
text-align: left;
box-shadow: 0 .25rem 1rem rgba(15, 23, 42, .05);
.thinking + .thinking {
margin-top: .75rem;
}
&.user {
color: white;
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>