Commit 6ce494ae by luoqi

fix(assistant): 工具报错终止 loading + callId 精确匹配 + 生成中指示

三个交互问题:
1. 工具 execute 抛错(如模型传错 patientId)时,AI SDK 发的是 tool-error 分片,
   而 controller 只处理了 tool-result → 前端该步骤永远停在 running(右侧转圈不停)。
   现转发 tool_error → 前端标记 status='error'(显示"出错"),loading 终止。
2. 事件带 toolCallId,前端 findToolIdx 优先按 callId 匹配(退化到"最后一个 running"),
   并行/多工具调用不再串位。
3. 加"生成中…"指示(GeneratingHint):流式中且最后一块不是增长的文字时显示——
   覆盖"工具已返回、artifact 整段 HTML 还在生成"的空档,避免界面看起来卡住。

验证:强制假 patientId → tool_call 与 tool_error 同 callId,步骤正确收尾。两端 tsc 0。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 46b60bec
......@@ -58,10 +58,20 @@ export class AssistantController {
send({ type: 'text', text: (p.text as string) ?? (p.delta as string) ?? '' });
break;
case 'tool-call':
send({ type: 'tool_call', tool: p.toolName, args: p.input });
send({ type: 'tool_call', id: p.toolCallId, tool: p.toolName, args: p.input });
break;
case 'tool-result':
send({ type: 'tool_result', tool: p.toolName, result: p.output });
send({ type: 'tool_result', id: p.toolCallId, tool: p.toolName, result: p.output });
break;
case 'tool-error':
// 工具 execute 抛错(如越权 patientId)→ SDK 发 tool-error 分片(流不中断,模型会自纠)。
// 必须转发,否则前端该步骤永远停在 running(右侧转圈不停)。
send({
type: 'tool_error',
id: p.toolCallId,
tool: p.toolName,
error: p.error instanceof Error ? p.error.message : String(p.error),
});
break;
case 'error':
send({ type: 'error', error: String(p.error) });
......
......@@ -267,7 +267,17 @@ function ArtifactView({ artifact }: { artifact: Artifact }) {
);
}
function MessageView({ message }: { message: ChatMessage }) {
// 生成中指示:覆盖"工具已返回但下一段(文字/artifact)还在整段生成"的空档。
function GeneratingHint() {
return (
<div className="flex items-center gap-1.5 text-[12px] text-slate-400">
<Loader2 className="h-3.5 w-3.5 animate-spin text-teal-500" />
<span>生成中…</span>
</div>
);
}
function MessageView({ message, streaming }: { message: ChatMessage; streaming?: boolean }) {
if (message.role === 'user') {
return (
<div className="flex justify-end gap-2.5">
......@@ -277,17 +287,19 @@ function MessageView({ message }: { message: ChatMessage }) {
</div>
);
}
// 流式中:若最后一块不是正在增长的文字(即在调工具/生成 artifact 的空档),补一个"生成中"。
const last = message.blocks[message.blocks.length - 1];
const showHint = streaming && (!last || last.kind !== 'text');
return (
<div className="flex gap-2.5">
<span className="mt-0.5 inline-flex h-7 w-7 flex-none items-center justify-center rounded-full bg-teal-600 text-white">
<Bot className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1 space-y-2.5 pt-0.5">
{message.blocks.length === 0 ? (
<Loader2 className="h-4 w-4 animate-spin text-slate-300" />
) : (
message.blocks.map((b, i) => <BlockView key={i} block={b} />)
)}
{message.blocks.map((b, i) => (
<BlockView key={i} block={b} />
))}
{showHint && <GeneratingHint />}
</div>
</div>
);
......@@ -423,7 +435,13 @@ export function AssistantChat() {
</div>
</div>
) : (
messages.map((m) => <MessageView key={m.id} message={m} />)
messages.map((m, i) => (
<MessageView
key={m.id}
message={m}
streaming={status === 'streaming' && i === messages.length - 1 && m.role === 'assistant'}
/>
))
)}
</div>
</div>
......
......@@ -7,6 +7,8 @@ import { useAuthStore } from '@/stores/auth-store';
/** 一步工具调用(Claude 式透明步骤:看到调了哪个工具、入参、返回数据)。 */
export interface ToolStep {
id: string;
/** 后端 toolCallId,用于把 tool_result/tool_error 精确匹配回对应步骤(支持并行/多调用)。 */
callId?: string;
tool: string;
args: unknown;
result?: unknown;
......@@ -40,6 +42,19 @@ export type ChatStatus = 'idle' | 'streaming' | 'error';
let _id = 0;
const nextId = () => `m${Date.now()}_${_id++}`;
/** 把 tool_result/tool_error 匹配回对应工具步骤:优先按 callId,退化到"最后一个 running"。 */
function findToolIdx(blocks: Block[], callId: string | undefined): number {
if (callId) {
const i = blocks.findIndex((b) => b.kind === 'tool' && b.step.callId === callId);
if (i !== -1) return i;
}
for (let i = blocks.length - 1; i >= 0; i--) {
const b = blocks[i];
if (b && b.kind === 'tool' && b.step.status === 'running') return i;
}
return -1;
}
/** 把一条消息压平成后端要的 {role, content} 文本(工具块不回传,模型自行重新决策)。 */
function toApiMessage(m: ChatMessage): { role: 'user' | 'assistant'; content: string } | null {
const text = m.blocks
......@@ -120,6 +135,7 @@ export function useAssistantChat() {
kind: 'tool',
step: {
id: nextId(),
callId: evt.id ? String(evt.id) : undefined,
tool: String(evt.tool ?? '工具'),
args: evt.args,
status: 'running',
......@@ -129,11 +145,8 @@ export function useAssistantChat() {
break;
case 'tool_result':
patch((blocks) => {
const idx = [...blocks]
.reverse()
.findIndex((b) => b.kind === 'tool' && b.step.status === 'running');
if (idx === -1) return blocks;
const realIdx = blocks.length - 1 - idx;
const realIdx = findToolIdx(blocks, evt.id ? String(evt.id) : undefined);
if (realIdx === -1) return blocks;
const b = blocks[realIdx] as Extract<Block, { kind: 'tool' }>;
const updated: Block = {
kind: 'tool',
......@@ -142,6 +155,18 @@ export function useAssistantChat() {
return blocks.map((x, i) => (i === realIdx ? updated : x));
});
break;
case 'tool_error':
patch((blocks) => {
const realIdx = findToolIdx(blocks, evt.id ? String(evt.id) : undefined);
if (realIdx === -1) return blocks;
const b = blocks[realIdx] as Extract<Block, { kind: 'tool' }>;
const updated: Block = {
kind: 'tool',
step: { ...b.step, status: 'error', error: String(evt.error ?? '工具调用出错') },
};
return blocks.map((x, i) => (i === realIdx ? updated : x));
});
break;
case 'error':
appendText(`\n\n⚠️ 出错:${String(evt.error)}`);
break;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment