From 362cb4332f8f5c91e5b4c1dbfb0d75391a9a5924 Mon Sep 17 00:00:00 2001 From: 2910410219 <2910410219@qq.com> Date: Fri, 14 Aug 2026 15:35:03 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=BB=E9=A2=98=E5=92=8C=E6=A0=B7=E5=BC=8F?= =?UTF-8?q?=E4=BB=A5=E5=8F=8A=E5=8A=A8=E7=94=BB=E8=BF=87=E6=B8=A1=E7=9A=84?= =?UTF-8?q?=E9=A6=96=E9=A1=B5=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/home/components/ChatList.vue | 62 +++++---- src/views/home/components/InputBar.vue | 113 +++++++++++------ src/views/home/components/MainContent.vue | 66 ++++++---- src/views/home/components/Sidebar.vue | 92 +++++++------- src/views/home/index.vue | 145 +++++++++++++++++++--- src/views/home/utils/wsMessage.ts | 57 +++++++-- 6 files changed, 382 insertions(+), 153 deletions(-) diff --git a/src/views/home/components/ChatList.vue b/src/views/home/components/ChatList.vue index e14c50a..ce31b1e 100644 --- a/src/views/home/components/ChatList.vue +++ b/src/views/home/components/ChatList.vue @@ -150,8 +150,7 @@ watch( display: flex; flex-direction: column; justify-content: flex-start; - gap: 16px; - padding: 28px 0 10px; + padding: 8px 0 16px; scrollbar-width: none; &::-webkit-scrollbar { display: none; } } @@ -167,10 +166,18 @@ watch( } } +/* 节奏化消息间距:AI↔AI 24,用户↔AI 16 */ +.message-row + .message-row { + margin-top: 16px; +} +.message-row:not(.is-user) + .message-row:not(.is-user) { + margin-top: 24px; +} + .bubble-wrap { display: flex; flex-direction: column; - gap: 5px; + gap: 4px; max-width: 100%; align-items: flex-start; @@ -183,7 +190,7 @@ watch( .bubble { font-size: 15px; - line-height: 1.75; + line-height: 1.7; padding: 0; border-radius: 0; word-break: break-word; @@ -192,12 +199,18 @@ watch( border: none; box-shadow: none; + /* AI 消息:无背景卡片,纯文本左对齐(DeepSeek 式) */ + .message-row:not(.is-user) & { + width: 100%; + max-width: 880px; + } + .message-row.is-user & { width: fit-content; - max-width: 85%; - padding: 10px 14px; - border-radius: 16px 16px 4px 16px; - background: #d3f5d8; + max-width: 72%; + padding: 10px 16px; + border-radius: 18px 18px 6px 18px; + background: #e8ecff; color: #1f2328; box-shadow: none; } @@ -223,17 +236,17 @@ watch( display: inline-flex; align-items: center; justify-content: center; - width: 28px; - height: 28px; + width: 30px; + height: 30px; border: none; - border-radius: 6px; + border-radius: 8px; background: transparent; color: #9ca3af; cursor: pointer; transition: background 0.15s, color 0.15s; &:hover { - background: #f1f2f4; + background: #eef0f3; color: #4b5563; } @@ -247,10 +260,10 @@ watch( display: inline-flex; align-items: center; gap: 5px; - margin-top: 10px; - padding: 5px 12px; + margin-top: 8px; + padding: 7px 14px; border: 1px solid #fecaca; - border-radius: 8px; + border-radius: 10px; background: #fef2f2; color: #dc2626; font-size: 12px; @@ -271,10 +284,12 @@ watch( /* 思考区:DeepSeek 风格折叠块 */ .thinking-block { - margin: 2px 0 10px; - background: #f5f5f6; - border-radius: 10px; + margin: 0 0 12px; + background: #f5f6f7; + border: 1px solid #eef0f3; + border-radius: 12px; overflow: hidden; + max-width: 880px; } .thinking-toggle { @@ -282,7 +297,7 @@ watch( align-items: center; gap: 6px; width: 100%; - padding: 8px 12px; + padding: 9px 14px; border: none; background: transparent; font-size: 13px; @@ -321,7 +336,7 @@ watch( } .thinking-content { - padding: 0 12px 12px; + padding: 0 14px 14px; animation: thinking-fade-in 0.2s ease; } @@ -332,7 +347,7 @@ watch( font-style: italic; white-space: pre-wrap; word-break: break-word; - border-top: 1px solid #ececee; + border-top: 1px solid #eef0f3; padding-top: 10px; } @@ -350,9 +365,10 @@ watch( /* 回答区:Markdown 渲染(DeepSeek 风格排版) */ .answer-block { white-space: normal; - line-height: 1.75; + line-height: 1.7; font-size: 15px; word-break: break-word; + max-width: 880px; & > :first-child { margin-top: 0; @@ -550,7 +566,7 @@ watch( .time { font-size: 11px; color: #8f9aae; - padding: 4px 6px; + padding: 4px 2px; .message-row.is-user & { text-align: right; diff --git a/src/views/home/components/InputBar.vue b/src/views/home/components/InputBar.vue index 1b13cef..f58ba5d 100644 --- a/src/views/home/components/InputBar.vue +++ b/src/views/home/components/InputBar.vue @@ -2,13 +2,15 @@
-
-
- - {{ commonWorkflows.find((w) => w.id === selectedWorkflowId)?.name }} - + +
+
+ + {{ commonWorkflows.find((w) => w.id === selectedWorkflowId)?.name }} + +
-
+ -
- -
+ +
+ +
+
@@ -72,6 +76,7 @@ import { getWorkflowList } from '/@/api/settings/creation'; interface Props { sendDisabled?: boolean; workflowLocked?: boolean; + hideShortcuts?: boolean; } interface Emits { @@ -90,6 +95,7 @@ interface Workflow { const props = withDefaults(defineProps(), { sendDisabled: false, workflowLocked: false, + hideShortcuts: false, }); const emit = defineEmits(); const message = ref(''); @@ -167,21 +173,56 @@ onMounted(() => { fetchWorkflows(); }); +// —— 胶囊高度动画:JS 测量实际高度,让 max-height 与内容高度精确匹配, +// 进入时从 0 展开、离开时收缩到 0,配合 CSS transition 消除底部高度突变 —— +const onCapsuleEnter = (el: Element) => { + const e = el as HTMLElement; + e.style.maxHeight = '0px'; + requestAnimationFrame(() => { + e.style.maxHeight = e.scrollHeight + 'px'; + }); +}; +const onCapsuleAfterEnter = (el: Element) => { + (el as HTMLElement).style.maxHeight = ''; +}; +const onCapsuleLeave = (el: Element) => { + const e = el as HTMLElement; + e.style.maxHeight = e.scrollHeight + 'px'; + requestAnimationFrame(() => { + e.style.maxHeight = '0px'; + }); +}; +const onCapsuleAfterLeave = (el: Element) => { + (el as HTMLElement).style.maxHeight = ''; +}; + defineExpose({ resetAll, clearWorkflow, selectWorkflow, fetchWorkflows, selectedWorkflowId, commonWorkflows }); \ No newline at end of file diff --git a/src/views/home/components/Sidebar.vue b/src/views/home/components/Sidebar.vue index b0a50aa..11b7cb6 100644 --- a/src/views/home/components/Sidebar.vue +++ b/src/views/home/components/Sidebar.vue @@ -139,8 +139,8 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data); .sidebar { width: 240px; flex-shrink: 0; - background: #f8fafc; - border-right: 1px solid #e2e8f0; + background: #f7f8fa; + border-right: 1px solid #e5e8ee; display: flex; flex-direction: column; height: 100%; @@ -152,20 +152,27 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data); display: flex; align-items: center; justify-content: space-between; - padding: 16px 12px 12px; - border-bottom: 1px solid #e2e8f0; + padding: 16px 14px 14px; flex-shrink: 0; } .brand-logo { display: flex; align-items: center; - gap: 8px; + gap: 9px; } .brand-icon { - font-size: 20px; - color: #2563eb; + width: 30px; + height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 9px; + background: linear-gradient(135deg, #2563eb, #4f8bff); + color: #fff; + font-size: 16px; + box-shadow: 0 2px 8px rgba(37, 99, 235, 0.25); } .brand-name { @@ -194,18 +201,17 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data); } &:hover { - background: #e2e8f0; + background: #e8ebf0; color: #0f172a; } } -/* Tab 切换 */ +/* Tab 切换(圆角胶囊) */ .sidebar-tabs { display: flex; - padding: 8px 8px 0; + padding: 12px 12px 8px; gap: 4px; flex-shrink: 0; - border-bottom: 1px solid #e2e8f0; } .tab-btn { @@ -213,26 +219,24 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data); display: inline-flex; align-items: center; justify-content: center; - gap: 5px; - height: 34px; + gap: 6px; + height: 36px; border: none; - border-radius: 8px 8px 0 0; + border-radius: 10px; background: transparent; color: #64748b; - font-size: 12px; + font-size: 13px; font-weight: 500; cursor: pointer; transition: background 0.15s, color 0.15s; outline: none; - border-bottom: 2px solid transparent; - margin-bottom: -1px; .el-icon { - font-size: 14px; + font-size: 15px; } &:hover:not(.active) { - background: #f1f5f9; + background: #eef0f3; color: #334155; } @@ -240,7 +244,7 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data); color: #2563eb; font-weight: 600; background: #fff; - border-bottom-color: #2563eb; + box-shadow: 0 1px 4px rgba(15, 23, 42, 0.06); } } @@ -248,7 +252,7 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data); .panel { flex: 1; overflow-y: auto; - padding: 8px 8px 12px; + padding: 4px 8px 12px; scrollbar-width: none; &::-webkit-scrollbar { @@ -273,14 +277,14 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data); display: flex; align-items: center; gap: 8px; - padding: 8px; - border-radius: 8px; + padding: 10px; + border-radius: 10px; cursor: pointer; transition: background 0.15s; - margin-bottom: 1px; + margin-bottom: 2px; &:hover { - background: #f1f5f9; + background: #eef0f3; .item-del-btn { opacity: 1; @@ -288,7 +292,7 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data); } &.active { - background: #eff6ff; + background: #e6f0ff; .item-icon { color: #2563eb; @@ -325,17 +329,17 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data); .item-time { font-size: 11px; color: #94a3b8; - margin-top: 1px; + margin-top: 2px; } .item-del-btn { display: inline-flex; align-items: center; justify-content: center; - width: 22px; - height: 22px; + width: 24px; + height: 24px; border: none; - border-radius: 5px; + border-radius: 6px; background: transparent; color: #94a3b8; cursor: pointer; @@ -365,7 +369,7 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data); font-size: 11px; font-weight: 600; color: #94a3b8; - padding: 6px 4px 2px; + padding: 8px 6px 4px; letter-spacing: 0.4px; text-transform: uppercase; } @@ -374,13 +378,13 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data); display: flex; align-items: center; gap: 6px; - padding: 5px 8px 5px 24px; - border-radius: 6px; + padding: 7px 10px 7px 24px; + border-radius: 8px; cursor: pointer; transition: background 0.15s; &:hover { - background: #f1f5f9; + background: #eef0f3; .file-actions { opacity: 1; @@ -412,14 +416,6 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data); } .file-action-btn { - &--del { - color: #ef4444; - - &:hover { - background: #fee2e2; - color: #dc2626; - } - } border: none; background: transparent; color: #3b82f6; @@ -433,7 +429,17 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data); &:hover { background: #dbeafe; } + + &--del { + color: #ef4444; + + &:hover { + background: #fee2e2; + color: #dc2626; + } + } } + .status-badge { font-size: 11px; padding: 1px 8px; @@ -449,8 +455,6 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data); animation: pulse 1.5s ease-in-out infinite; } - - @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } diff --git a/src/views/home/index.vue b/src/views/home/index.vue index 762d14b..732885d 100644 --- a/src/views/home/index.vue +++ b/src/views/home/index.vue @@ -24,7 +24,7 @@ @retry="handleRetry" @workflow-select="handlePlaceWorkflowSelect" /> - +
@@ -62,7 +62,7 @@ import TemplateCompleteDialog from './components/TemplateCompleteDialog.vue'; import { applyHomeFormValues } from './utils/flowDsl'; import { getChatModel } from '/@/api/settings/modelConfigV2'; import { openWsExecute } from './utils/wsExecute'; -import { parseWsMessage, getDelta, getAnswer, getErrorText } from './utils/wsMessage'; +import { parseWsMessage, getDelta, getAnswer, getErrorText, getToolCallName, getToolResultText } from './utils/wsMessage'; import { getSessionMessages } from '/@/api/settings/session'; import type { ExecutionTreeItem } from '/@/api/settings/creation'; import { @@ -300,6 +300,9 @@ const currentMessages = computed(() => { return sessionMessages.value.get(id) || []; }); +// 占位态:无工作流且无消息(MainContent 显示引导页)时隐藏输入框快捷胶囊,避免与占位卡片重复 +const isPlaceholder = computed(() => !selectedWorkflowDetail.value && currentMessages.value.length === 0); + const getSessionId = () => { return `session_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; }; @@ -573,11 +576,18 @@ const runWorkflow = async (sid: string, sessionId: string, message: string, mc: systemPrompt: '', flowContent: updatedFlowContent, onMessage: (raw) => { - let data: any = raw; - if (typeof raw === 'string') { - try { data = JSON.parse(raw); } catch { data = raw; } + // 优先按 ReAct 结构化事件判定完成/失败 + const msg = parseWsMessage(raw); + if (msg) { + if (msg.type === 'error') { + finishExec(false, getErrorText(msg)); + } else if (msg.type === 'answer') { + finishExec(true); + } + return; } - const str = typeof data === 'string' ? data : JSON.stringify(data || ''); + // 非 JSON 文本:保留旧完成标志兜底 + const str = typeof raw === 'string' ? raw : JSON.stringify(raw || ''); if (/done|finish|complete|end|success/i.test(str)) { finishExec(true); } @@ -609,9 +619,79 @@ const runChat = async (sid: string, sessionId: string, message: string) => { let failed = false; // 思考/生成过程计时起点(收到首帧时开始) let thinkStart = 0; + + // —— 思考区打字机限速渲染(约 30 字/秒:每 100ms 消费 3 字符)—— + // reasoning_chunk / tool_call / tool_result 先进队列,按固定速率逐字上屏, + // 避免后端推流太快导致思考过程看不清;回答区(answer_chunk)不受此限制。 + let twBuffer = ''; + let twTimer: number | null = null; + const TW_CHARS_PER_TICK = 3; + + const pushThinking = (text: string) => { + // 若当前仍是占位提示,先清掉占位,由打字机逐字填充 + const list = sessionMessages.value.get(sid); + const aiIdx = list ? list.findIndex((m) => m.id === aiMsgId) : -1; + const ai = aiIdx >= 0 && list ? list[aiIdx] : null; + if (ai && ai.thinking === '思考中…') { + ai.thinking = ''; + saveChatCache(sid, list || []); + } + twBuffer += text; + startTypewriter(); + }; + + const startTypewriter = () => { + if (twTimer != null) return; + twTimer = window.setInterval(() => { + if (!twBuffer) { + stopTypewriter(); + return; + } + const list = sessionMessages.value.get(sid); + const aiIdx = list ? list.findIndex((m) => m.id === aiMsgId) : -1; + const ai = aiIdx >= 0 && list ? list[aiIdx] : null; + if (!ai) { + stopTypewriter(); + return; + } + const take = Math.min(TW_CHARS_PER_TICK, twBuffer.length); + ai.thinking = (ai.thinking || '') + twBuffer.slice(0, take); + twBuffer = twBuffer.slice(take); + ai.loading = false; + saveChatCache(sid, list || []); + }, 100); + }; + + const stopTypewriter = () => { + if (twTimer != null) { + window.clearInterval(twTimer); + twTimer = null; + } + }; + + const flushThinking = () => { + stopTypewriter(); + if (!twBuffer) return; + const list = sessionMessages.value.get(sid); + const aiIdx = list ? list.findIndex((m) => m.id === aiMsgId) : -1; + const ai = aiIdx >= 0 && list ? list[aiIdx] : null; + if (ai) { + ai.thinking = (ai.thinking || '') + twBuffer; + saveChatCache(sid, list || []); + } + twBuffer = ''; + }; + const finishChat = (success: boolean, errorMsg?: string) => { if (done) return; done = true; + // 结束前清理打字机:成功则把队列剩余思考一次性补全,失败则丢弃未渲染内容 + stopTypewriter(); + if (success) { + flushThinking(); + } else { + twBuffer = ''; + } const list = sessionMessages.value.get(sid); const aiIdx = list ? list.findIndex((m) => m.id === aiMsgId) : -1; if (!success) { @@ -678,8 +758,8 @@ const runChat = async (sid: string, sessionId: string, message: string) => { const aiIdx = list ? list.findIndex((m) => m.id === aiMsgId) : -1; const ai = aiIdx >= 0 && list ? list[aiIdx] : null; - // 思考步骤:思考区为空时给出占位提示(虚化) - if (msg.type === 'agent_step') { + // 模型思考(step 开始):思考区为空时给出占位提示(虚化),并记录计时起点 + if (msg.type === 'model_call') { if (ai && !ai.thinking && !ai.content) { ai.loading = false; ai.thinking = '思考中…'; @@ -687,19 +767,46 @@ const runChat = async (sid: string, sessionId: string, message: string) => { if (!thinkStart) thinkStart = Date.now(); return; } - // 流式 token:思考区逐字累加(虚化显示) - if (msg.type === 'agent_token') { + // 思考内容增量:进入打字机队列,限速逐字上屏 + if (msg.type === 'reasoning_chunk') { const delta = getDelta(msg); - if (ai && delta) { - ai.loading = false; - ai.thinking = ai.thinking && ai.thinking !== '思考中…' ? ai.thinking + delta : delta; - saveChatCache(sid, list || []); + if (delta) { + pushThinking(delta); } if (!thinkStart) thinkStart = Date.now(); return; } - // 作答完成:回答区输出完整答案,记录思考用时并结束 - if (msg.type === 'agent_answer') { + // 模型请求调用工具:思考区记录工具调用过程(ReAct 步骤,走打字机队列保持顺序) + if (msg.type === 'tool_call') { + const tool = getToolCallName(msg); + if (tool) { + const hasPrev = !!(ai && ai.thinking && ai.thinking !== '思考中…') || !!twBuffer; + pushThinking((hasPrev ? '\n' : '') + `📎 调用工具:${tool}`); + } + if (!thinkStart) thinkStart = Date.now(); + return; + } + // 工具返回结果:思考区记录工具返回摘要(ReAct 步骤,走打字机队列保持顺序) + if (msg.type === 'tool_result') { + const result = getToolResultText(msg); + if (result) { + const hasPrev = !!(ai && ai.thinking && ai.thinking !== '思考中…') || !!twBuffer; + pushThinking((hasPrev ? '\n' : '') + `↳ 工具返回:${result}`); + } + return; + } + // 回答内容增量:回答区逐 chunk 追加(Markdown 渲染) + if (msg.type === 'answer_chunk') { + const delta = getDelta(msg); + if (ai && delta) { + ai.loading = false; + ai.content = (ai.content || '') + delta; + saveChatCache(sid, list || []); + } + return; + } + // 最终回答:回答区输出完整答案(覆盖流式内容),记录思考用时并结束 + if (msg.type === 'answer') { const answer = getAnswer(msg); if (ai && answer) { ai.loading = false; @@ -710,12 +817,12 @@ const runChat = async (sid: string, sessionId: string, message: string) => { finishChat(true); return; } - // 错误:展示错误信息并结束 + // 出错:展示错误信息并结束 if (msg.type === 'error') { finishChat(false, getErrorText(msg)); return; } - // 其余(ack / agent / 未知)忽略 + // 其余(ack / 未知)忽略 }, onError: () => { failed = true; @@ -866,7 +973,7 @@ onMounted(() => { bottom: 0; display: flex; overflow: hidden; - background: linear-gradient(180deg, #f7f7f8 0%, #ffffff 100%); + background: #f7f8fa; } .main-wrapper { diff --git a/src/views/home/utils/wsMessage.ts b/src/views/home/utils/wsMessage.ts index 62f03ea..74470a4 100644 --- a/src/views/home/utils/wsMessage.ts +++ b/src/views/home/utils/wsMessage.ts @@ -1,11 +1,12 @@ // ===== 首页 WebSocket 流式消息解析工具 ===== -// 服务端推送统一为 JSON 文本帧: -// {"type":"ack","message":"WebSocket连接成功"} 连接/流程确认(message 为提示文案) -// {"type":"agent","payload":{"modelId":"...","question":"..."}} agent 启动信息 -// {"type":"agent_step","message":"模型思考中","data":{"maxStep":15,"step":1}} 思考步骤进度 -// {"type":"agent_token","message":"思考中","data":{"delta":"你"}} 流式 token 增量 -// {"type":"agent_answer","message":"作答完成","data":{"answer":"..."}} 作答完成(最终答案) -// {"type":"error","message":"..."} 执行失败 +// 服务端推送统一为 JSON 文本帧(后端 ReAct 事件协议): +// {"type":"model_call","message":"模型思考","data":{"step":1,"maxStep":15}} 模型思考(step 开始) +// {"type":"tool_call","message":"调用工具","data":{"tool":"web_search","arguments":{}}} 模型请求调用工具 +// {"type":"tool_result","message":"工具返回","data":{"result":"..."}} 工具返回结果 +// {"type":"reasoning_chunk","message":"思考中","data":{"delta":"你"}} 思考内容增量(逐 chunk) +// {"type":"answer_chunk","message":"回答中","data":{"delta":"你"}} 回答内容增量(逐 chunk) +// {"type":"answer","message":"作答完成","data":{"answer":"..."}} 最终回答 +// {"type":"error","message":"..."} 执行失败 // 完成/失败判定均以 type 为准,不再用正则猜测文本。 export interface WsStreamMessage { @@ -15,6 +16,17 @@ export interface WsStreamMessage { payload?: any; } +/** 后端 ReAct WebSocket 事件类型常量 */ +export const WsEventType = { + ModelCall: 'model_call', // 模型思考(step 开始) + ToolCall: 'tool_call', // 模型请求调用工具 + ToolResult: 'tool_result', // 工具返回结果 + Answer: 'answer', // 最终回答 + AnswerChunk: 'answer_chunk', // 回答内容增量(逐 chunk) + ReasoningChunk: 'reasoning_chunk', // 思考内容增量(逐 chunk) + Error: 'error', // 出错 +} as const; + /** * 解析服务端推送帧。 * 非 JSON、无 type 的帧返回 null(调用方忽略)。 @@ -32,17 +44,17 @@ export function parseWsMessage(raw: any): WsStreamMessage | null { return data; } -/** agent_token → data.delta 流式增量 */ +/** answer_chunk / reasoning_chunk → data.delta 流式增量 */ export function getDelta(msg: WsStreamMessage): string { return typeof msg?.data?.delta === 'string' ? msg.data.delta : ''; } -/** agent_answer → data.answer 最终答案 */ +/** answer → data.answer 最终答案 */ export function getAnswer(msg: WsStreamMessage): string { return typeof msg?.data?.answer === 'string' ? msg.data.answer : ''; } -/** agent_step → { step, maxStep },缺失时 maxStep 为 0 */ +/** model_call → { step, maxStep },缺失时 maxStep 为 0 */ export function getStepInfo(msg: WsStreamMessage): { step: number; maxStep: number } | null { const step = msg?.data?.step; if (typeof step !== 'number') return null; @@ -50,6 +62,31 @@ export function getStepInfo(msg: WsStreamMessage): { step: number; maxStep: numb return { step, maxStep: typeof maxStep === 'number' ? maxStep : 0 }; } +/** tool_call → 工具名称(tool / toolName / name 依次尝试,缺失返回空串) */ +export function getToolCallName(msg: WsStreamMessage): string { + const d = msg?.data; + if (!d || typeof d !== 'object') return ''; + const name = d.tool ?? d.toolName ?? d.name; + return typeof name === 'string' ? name : ''; +} + +/** tool_result → 工具返回文本(result / output / content / message 依次尝试,对象则序列化) */ +export function getToolResultText(msg: WsStreamMessage): string { + const d = msg?.data; + if (typeof d === 'string') return d; + if (!d || typeof d !== 'object') return ''; + const raw = d.result ?? d.output ?? d.content ?? d.message; + if (typeof raw === 'string') return raw; + if (raw != null) { + try { + return JSON.stringify(raw); + } catch { + return ''; + } + } + return ''; +} + /** error → 错误文案(message 优先,其次 data.message / data.error / data.msg) */ export function getErrorText(msg: WsStreamMessage): string { if (typeof msg?.message === 'string' && msg.message) return msg.message;