add AIAgent think
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user