fix: render consultation replies and typed tool activity

This commit is contained in:
2026-09-24 14:36:20 +08:00
parent a628860144
commit 97837cef90
19 changed files with 432 additions and 35 deletions

View File

@@ -0,0 +1,32 @@
import type { TeacherToolActivity } from '../../shared/coding-teacher';
export interface ToolActivityUpdate {
id: string;
name?: string;
status: TeacherToolActivity['status'];
}
function object(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
}
/** Project Yuxi's typed tool events; arguments, outputs and internal errors never enter the UI. */
export function cloudToolActivity(chunk: Record<string, unknown>, runId: string): ToolActivityUpdate | undefined {
const event = object(chunk.stream_event);
if (event?.type === 'tool_call' || event?.type === 'tool_call_delta') {
if (typeof event.tool_call_id !== 'string' || !event.tool_call_id || typeof event.name !== 'string' || !event.name) return;
return { id: runId + ':' + event.tool_call_id, name: event.name, status: 'running' };
}
const custom = object(chunk.event);
if (chunk.status !== 'stream_event' || custom?.method !== 'tools') return;
const data = object(custom.data);
if (!data || typeof data.tool_call_id !== 'string' || !data.tool_call_id) return;
if (data.event !== 'tool-started' && data.event !== 'tool-finished' && data.event !== 'tool-error') return;
const output = object(data.output);
return {
id: runId + ':' + data.tool_call_id,
...(typeof data.tool_name === 'string' && data.tool_name ? { name: data.tool_name } : {}),
status: data.event === 'tool-started' ? 'running'
: data.event === 'tool-error' || data.error || output?.status === 'error' || output?.status === 'failed' ? 'failed' : 'completed',
};
}

View File

@@ -1,6 +1,7 @@
import { setTimeout as delay } from 'node:timers/promises';
import type { PublicUsage } from '../../shared/coding-conversation-contracts';
import type { TeacherTopic } from '../../shared/coding-teacher';
import type { TeacherTopic, TeacherToolActivity } from '../../shared/coding-teacher';
import { cloudToolActivity, type ToolActivityUpdate } from './cloud-activity';
import { proxyAwareFetch } from '../utils/proxy-fetch';
import {
assertTeacherAccount,
@@ -163,7 +164,8 @@ export function prepareCloudTeacher(
access: TeacherReadAccess,
onProgress: (text: string) => void,
saveRequestId: (id: string) => Promise<void>,
transport: TeacherCloudTransport = teacherCloudTransport(account)
transport: TeacherCloudTransport = teacherCloudTransport(account),
onToolActivity: (activity: TeacherToolActivity) => void = () => undefined,
) {
const tools = createTeacherReadTools(access);
return {
@@ -203,6 +205,17 @@ export function prepareCloudTeacher(
const bounded = AbortSignal.any([signal, AbortSignal.timeout(60 * 60 * 1000)]);
let questionId: string | undefined;
let completed = false;
const activities = new Map<string, TeacherToolActivity>();
const reportActivity = (activity: ToolActivityUpdate) => {
const previous = activities.get(activity.id);
// Replayed starts/deltas cannot put a finished tool back into a spinner.
if (previous && (previous.status !== 'running'
|| (previous.status === activity.status && (!activity.name || activity.name === previous.name)))) return;
// Finished/error events carry the call id but need not repeat its name.
const merged = { ...activity, name: activity.name ?? previous?.name ?? '' };
activities.set(activity.id, merged);
onToolActivity(merged);
};
try {
// 重启后本地快照已经丢失,先停止旧请求,再以新的问题建立读取作用域。
const previous = topic.requests.filter((item) => item.id !== requestId).at(-1);
@@ -291,10 +304,13 @@ export function prepareCloudTeacher(
const call = object(raw);
if (typeof call.tool_call_id !== 'string' || typeof call.name !== 'string')
throw new TeacherError(502, 'teacher_protocol_invalid', '智能体读取请求无效。');
reportActivity({ id: runId + ':' + call.tool_call_id, name: call.name, status: 'running' });
const result = await tools.executeResult(call.name, JSON.stringify(call.arguments), bounded);
results.push({
tool_call_id: call.tool_call_id,
...(await tools.executeResult(call.name, JSON.stringify(call.arguments), bounded)),
...result,
});
reportActivity({ id: runId + ':' + call.tool_call_id, name: call.name, status: result.status === 'success' ? 'completed' : 'failed' });
}
// POST 重试使用完全相同的结果,文件变化也不会导致重复续接或不同输入。
const resumed = await transport.json(
@@ -310,7 +326,9 @@ export function prepareCloudTeacher(
continue;
}
if (view.status === 'completed') {
const output = typeof view.output === 'string' ? view.output : '';
if (typeof view.output !== 'string')
throw new TeacherError(502, 'teacher_protocol_invalid', '智能体返回的正文格式不受支持,请重试。');
const output = view.output;
// Structured UI replies must contain only the final answer. A cloud
// run can stream a preamble or draft before reading and continuing.
if (structuredReply) onText(output);
@@ -349,6 +367,8 @@ export function prepareCloudTeacher(
? [payload.chunk]
: []) {
const chunk = object(item);
const activity = cloudToolActivity(chunk, runId);
if (activity) reportActivity(activity);
const event = chunk.stream_event ? object(chunk.stream_event) : {};
if (event.type === 'message_delta' && typeof event.content === 'string') {
if (typeof event.message_id === 'string' && event.message_id !== messageId) {

View File

@@ -58,7 +58,11 @@ export function applyDiscussionReply(topic: TeacherTopic, request: TeacherReques
const parsed = parseTeacherDiscussionReply(raw);
request.response = parsed.reply;
request.suggestedQuestions = parsed.quickReplies;
if (parsed.toolError) request.discussionError = parsed.toolError;
if (parsed.toolError) {
request.discussionError = parsed.toolError;
// Keep inspectable evidence instead of replacing the only copy with an error.
request.unparsedResponse = raw;
}
if (!parsed.tool) return;
const current = topic.discussion;
if (!current) {

View File

@@ -444,6 +444,14 @@ export class CodingTeacherService {
}, async cloudRequestId => {
topic.requests.at(-1)!.cloudRequestId = cloudRequestId;
await store.save(topic);
}, undefined, activity => {
const current = topic.requests.at(-1)!;
const activities = current.toolActivity ??= [];
const index = activities.findIndex(item => item.id === activity.id);
if (index < 0) activities.push(activity);
else activities[index] = activity;
topic.revision++;
this.events.emit(key, structuredClone(topic));
})
: await (this.options.prepareModel ?? prepareTeacherModel)(account, topic.definition,
access,