fix: render consultation replies and typed tool activity
This commit is contained in:
32
electron/coding-teacher/cloud-activity.ts
Normal file
32
electron/coding-teacher/cloud-activity.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user