feat(agent): control reasoning visibility

This commit is contained in:
2026-07-17 14:05:54 +08:00
parent bfaf2ebf67
commit a879dc2ff1
18 changed files with 368 additions and 51 deletions

View File

@@ -150,9 +150,16 @@ async function send(message: string, complete: (success: boolean) => void) {
currentAssistant().content += chunk;
if (followingOutput.value) await messageList.value?.scrollToBottom();
},
async (statusMessage, statusType) => {
async (chunk) => {
currentAssistant().reasoning = (currentAssistant().reasoning || "") + chunk;
if (followingOutput.value) await messageList.value?.scrollToBottom();
},
async (statusMessage, statusType, reasoningVisible) => {
if (statusType === "queued") currentAssistant().content = statusMessage || "当前请求较多,正在排队中。";
if (statusType === "generating" && !hasContent) currentAssistant().content = "";
if (statusType === "generating") {
currentAssistant().showReasoning = reasoningVisible;
if (!hasContent) currentAssistant().content = "";
}
if (followingOutput.value) await messageList.value?.scrollToBottom();
},
activeAbortController.value.signal,
@@ -256,6 +263,7 @@ function toUiMessage(message: ApiMessage): DisplayMessage {
id: String(message.id),
role: message.role,
content: message.content,
showReasoning: message.role === "assistant" && /<think(?:\s[^>]*)?>/i.test(message.content),
createdAt: message.created_at,
streaming: message.message_status === "GENERATING",
};

View File

@@ -7,58 +7,63 @@ const props = defineProps<{
messageId: string;
role: "user" | "assistant";
content: string;
reasoning?: string;
showReasoning?: boolean;
createdAt: string;
streaming?: boolean;
}>();
const markdown = new MarkdownIt({ breaks: true, html: false, linkify: true });
const parsed = computed(() => splitReasoning(props.content));
const answer = computed(() => {
if (props.role === "user") return props.content;
return stripStreamingReasoning(props.content).replace(/^\s+/, "");
return parsed.value.answer.replace(/^\s+/, "");
});
const reasoning = computed(() => props.reasoning || parsed.value.reasoning);
const hasAnswer = computed(() => answer.value.trim().length > 0);
const renderedContent = computed(() => {
if (props.role !== "assistant") return answer.value;
return hasAnswer.value ? markdown.render(answer.value) : "";
});
function stripStreamingReasoning(content: string) {
function splitReasoning(content: string) {
const lower = content.toLowerCase();
let visible = "";
let answer = "";
let reasoning = "";
let cursor = 0;
let reasoningDepth = 0;
let depth = 0;
while (cursor < content.length) {
const tagStart = content.indexOf("<", cursor);
if (tagStart === -1) {
if (reasoningDepth === 0) visible += content.slice(cursor);
if (depth) reasoning += content.slice(cursor);
else answer += content.slice(cursor);
break;
}
if (reasoningDepth === 0) visible += content.slice(cursor, tagStart);
const text = content.slice(cursor, tagStart);
if (depth) reasoning += text;
else answer += text;
const tail = lower.slice(tagStart);
const openTag = tail.match(/^<think(?:\s[^>]*)?>/);
if (openTag) {
reasoningDepth += 1;
depth += 1;
cursor = tagStart + openTag[0].length;
continue;
}
const closeTag = tail.match(/^<\/think\s*>/);
if (closeTag) {
reasoningDepth = Math.max(0, reasoningDepth - 1);
depth = Math.max(0, depth - 1);
cursor = tagStart + closeTag[0].length;
continue;
}
if ("<think".startsWith(tail) || "</think>".startsWith(tail)) break;
if (reasoningDepth === 0) visible += "<";
if (depth) reasoning += "<";
else answer += "<";
cursor = tagStart + 1;
}
return visible;
return { answer, reasoning };
}
const displayTime = computed(() => {
// 消息服务返回的是 UTC 无时区字符串,补齐时区后再按用户本地时间展示。
const normalized = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(props.createdAt) ? props.createdAt : `${props.createdAt}Z`;
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) return "";
@@ -73,6 +78,10 @@ const displayTime = computed(() => {
</div>
<div class="message-bubble">
<template v-if="role === 'assistant'">
<details v-if="showReasoning && reasoning.trim()" class="reasoning-panel" :open="streaming">
<summary>思考过程</summary>
<div class="reasoning-content">{{ reasoning }}</div>
</details>
<div v-if="renderedContent" class="message-content markdown-content" v-html="renderedContent"></div>
<div v-if="streaming && !renderedContent" class="generation-state">
<span></span><span></span><span></span>

View File

@@ -7,6 +7,8 @@ export interface DisplayMessage {
id: string;
role: "user" | "assistant";
content: string;
reasoning?: string;
showReasoning?: boolean;
createdAt: string;
streaming?: boolean;
}
@@ -57,6 +59,8 @@ defineExpose({ scrollToBottom, scrollToMessage });
:message-id="message.id"
:role="message.role"
:content="message.content"
:reasoning="message.reasoning"
:show-reasoning="message.showReasoning"
:created-at="message.createdAt"
:streaming="message.streaming"
/>

View File

@@ -83,7 +83,8 @@ export async function streamChat(
sessionId: number,
message: string,
onChunk: (chunk: string) => void,
onStatus?: (message: string, type: string) => void,
onReasoning?: (chunk: string) => void,
onStatus?: (message: string, type: string, reasoningVisible: boolean) => void,
signal?: AbortSignal,
) {
const token = getToken();
@@ -114,15 +115,21 @@ export async function streamChat(
if (!dataLines.length) continue;
const payload = dataLines.map((line) => line.slice(5).trimStart()).join("\n").trim();
if (payload === "[DONE]") return;
const parsed = JSON.parse(payload) as { type?: string; content?: string; message?: string };
const parsed = JSON.parse(payload) as {
type?: string;
content?: string;
message?: string;
reasoningVisible?: boolean;
};
if (parsed.type === "queued" || parsed.type === "generating") {
onStatus?.(parsed.message || "", parsed.type);
onStatus?.(parsed.message || "", parsed.type, Boolean(parsed.reasoningVisible));
continue;
}
if (parsed.type === "error") {
throw new Error(parsed.message || "AI 回复失败");
}
if (parsed.content) onChunk(parsed.content);
if (parsed.type === "reasoning" && parsed.content) onReasoning?.(parsed.content);
if (parsed.type === "content" && parsed.content) onChunk(parsed.content);
}
}
}

View File

@@ -818,6 +818,9 @@ textarea:focus-visible {
padding: 0 10px 10px;
color: #5d7169;
font-size: 13px;
line-height: 1.65;
white-space: pre-wrap;
word-break: break-word;
}
.markdown-content > :first-child {