feat(teacher): stream Yuxi reasoning and answer previews

This commit is contained in:
2026-09-29 18:18:56 +08:00
parent f443f3bc66
commit 8ccc4650a2
12 changed files with 364 additions and 42 deletions

View File

@@ -0,0 +1,44 @@
# Task: Stream Yuxi teacher reasoning and answers in Makelore
## Identity
- Task ID: 20260929-teacher-stream-f00acb6f
- Mode: Feature
- Branch: codex/20260929-teacher-stream-f00acb6f-teacher-stream
- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260929-teacher-stream-f00acb6f
- Base commit: f443f3bc668dfc6c9df111506d739d343ee82bba
- Owner: codex
- Status: Ready for Integration
## Scope
- Stream Yuxi main-thread reasoning and answer previews through Main and the teacher panel, preserving final reply validation, project isolation and tool continuation.
## Intent And Constraints
- Concurrent/Planning Gates Passed: official scripts established the exact feature identity above. Loaded entry, task, positioning, integrated state, teacher ADR, architecture/domain/success and relevant indexes; peer task records were read only. Historical placeholder scopes remain unknown; adjacent completed teacher/observer/packaging work has no conflicting dependency. No subagents.
- User explicitly requests both reasoning and results streamed. This revises the old exclusion of reasoning from visible teacher chat, while keeping it separate from answers, tools and future model context. Main remains transport owner; no provider configuration or server mutation.
- Plan: verify the committed Yuxi event contract; add incremental prose projection and separate reasoning stream; cover continuation, final reconciliation, cancellation, persistence and visible streaming; run focused tests, typecheck, lint/build and available Electron UI fixture.
## Outcome
- Implemented separate main-thread reasoning deltas and replaceable answer previews through the existing Main-owned stream. Yuxi committed chat_service/agent_run_service confirm message_delta reasoning_content (and retained additional_reasoning_content) is already transported; no Yuxi or WS changes were needed.
- Partial reply JSON is decoded into prose without requiring its closing string/object or exposing quick-reply fields. Final output remains authoritative, and a new message/tool continuation resets the preview while retaining question reasoning. Terminal event draining retains reasoning from fast completed/interrupted Runs. Child events only advance the cursor.
- The panel expands real reasoning while running, streams answer Markdown independently, folds reasoning when finished and exposes quick replies only after completion. Cancellation/failure preserves received previews and original bytes; saved reasoning is separate from follow-up model context and bring-back answer text. Old suggestion records without the current presentation marker keep their existing raw-output hiding behavior.
- README updated; no shared canonical project-memory writes. Implementation is isolated from the primary checkout and other tasks; no merge, push, deployment, package or installation.
## Verification
- 322 distinct relevant unit cases passed across eight files, in focused batches: cloud runner, service, reply parser/streaming/history, conversation store and both panel suites. Coverage includes incomplete escapes/fences, live preview, terminal reconciliation, main-thread filtering, cursor continuation, tool-read reasoning, cancellation, disk reload, context exclusion and historical raw-JSON hiding.
- pnpm run typecheck passed; scoped ESLint passed; final pnpm run build:vite passed (Renderer/Main/Preload/utility).
- New Electron fixture test `teacher reasoning and answer stream separately before the final response` passed. Inspected teacher-streaming.png: separate visible reasoning/prose while stop remains available, then collapse/reopen and quick replies. This uses deterministic Host API/SSE fixtures, not a production provider.
- Extended Main typecheck reports 66 diagnostics, matching the documented existing count; none mention the modified teacher files. Standard project typecheck passes. No full unit suite or live paid-provider/installed-client acceptance was claimed.
- git diff --check passed; final task-aware documentation drift gate run before handoff.
## Follow-ups
- Integrate this branch and ship an updated Makelore client to affect installed users. Verify production Yuxi/model actually emits reasoning when enabled; models without reasoning events still stream their answer normally. This task did not alter cloud model/reasoning configuration.
## Promotion Candidates
- Targets: coding-teacher ADR, architecture/domain and integrated state. Proposal: explicitly document independent live main-thread reasoning and replaceable answer previews, with final answer reconciliation and final-only quick replies; keep reasoning out of tools, follow-up context and bring-back text. Evidence: committed Yuxi producer contract, the focused unit cases, Electron fixture and production compile above. Future impact: streaming/progress, cancellation and history consumers preserve these separate fields. Conflict: intentionally supersedes the earlier exclusion of visible reasoning, as directly authorized by this user request; no additional product-direction confirmation is needed. Promote only under Integration Gate.

View File

@@ -177,10 +177,11 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p
- 新的单会话入口停止旧版前端定时跟进派发;项目主动观察由独立功能衔接,不能重新创建可见话题。未下发目录的旧版入口仍保留原跟进合同:前端每 5 分钟请求一次智能体跟进;窗口隐藏、来源归档、主对话正在执行、智能体正在回复或学生正在智能体栏写草稿时延后。Main 再检查在线启停、来源归属、项目级冷却和已完成文本指纹,未变上下文按下述十五分钟冷却处理。跟进沿用运营模型、已发布 Skills 与当前智能体话题,生成符合所选配置的具体建议或引导;以主动智能体发言持久化,不伪造学生消息。模型调用沿用智能体计费规则。
- 进入项目时,智能体头像旁先显示运营发布的欢迎语气泡,不调用模型、不声称已检查项目;点击智能体进入咨询时立即收起欢迎语,并按账号/项目记住已查看,在同一项目关闭、重新打开或刷新后不重复弹出;主动收起也保留同样记录。真实的未读主动建议优先替换欢迎语,打开咨询不会自动将这些消息标为已读。主动发言在头像旁直接显示气泡,不抢焦点、不盖住作品;作品区按气泡与原生预览的实际交叠范围避让,已有工具栏高度计入计算,气泡位于独立智能体栏时不额外留白,窗口/分栏尺寸变化和收起气泡时实时归还空间;“和智能体聊聊”接回同一段咨询,“等会儿聊”收起并保留历史。已有未处理气泡时不堆叠新邀请;咨询已展开时仍显示真实未读主动消息气泡,直到学生主动点击查看或收起,期间的其他回复不会吞掉未读邀请。五分钟检查计时在刷新和项目切换后保留;智能体咨询里的新讨论也算进展,即使操作对话没有变化也会继续跟进;上下文完全未变时放缓到十五分钟一次,不永久停止。事件连接丢失时,每十五秒只读同步未完成回复,收到结果后停止轮询,不重复调用模型。失败如实提示,不以本地文案替代模型回复。已接受请求恢复使用原身份,退出账号和来源删除仍沿用 Main 中止边界。
- 智能体支持自由提问;输入框为空时,底部显示暖黄色小按钮“继续看看👀”,输入文字后收起,清空后重现,发送按钮保持原位。只有学生点击才调用模型,程序说明学生主动求助且尚未提出具体问题,智能体结合可用上下文按云端配置回应。点击快捷回复会主动开始讨论;回复下方不再附加固定的二次求助链接;失败或中断后,可点暖黄色小按钮“继续看看👀”重试。这些快捷求助保留已有输入草稿,网络结果不确定时复用请求身份,已确认终态的请求不重复执行;解析失败可重新求助,不用固定问题伪装模型结果。服务端下发的推荐问题只填入草稿,由用户确认发送。界面不提供独立示范页面、“记一下”、共识或自动待办,智能体的回复下不再展示“我去试一试”和“复制”按钮,学生通过已有的操作对话/作品入口继续创作。
- 老师咨询只展示老师正文和可点击的快捷回复卡片,学生也可在“和老师聊聊”输入框自由输入。点击卡片原样发送该回复,保留当前输入草稿;旧话题的数据不会恢复想法板、结构图、流程图或对照表及其操作。
- 老师咨询展示流式思考过程、老师正文和可点击的快捷回复卡片,学生也可在“和老师聊聊”输入框自由输入。点击卡片原样发送该回复,保留当前输入草稿;旧话题的数据不会恢复想法板、结构图、流程图或对照表及其操作。
- 客户端只提供入口事实、可用上下文和界面协议。讨论切入点、教学方式、是否提问、正文长度、前缀和表达风格由所选智能体的云端配置决定;不在“继续看看👀”、引导开口或后台检查时附加教学策略。当前新回复允许 0–3 条快捷回复,没有时用空数组,不要求凑满。
- Main 对咨询回复统一使用 `{reply, quickReplies}`,本地读取工具的前言和云端非最终片段不当作最终回答;完整解析后展示正文和卡片,主动进展提醒仍直接展示正文。普通 Markdown、JSON 数据和代码示例保持正文。兼容读取旧 `{intro, questions}` 和带 `tool` 的回复,只提取正文与快捷回复,不恢复或更新组件。新输出超过 3 条快捷回复时保留完整正文、提示格式错误并保留原始回答,不展示超额卡片或静默截取;历史中已有的更多卡片在读取与后续保存时仍原样保留。独立资源上限只用于防护,不做旧式字数截断;格式错误的原文默认折叠、字面显示,不再次送进模型。
- Main 对咨询回复统一使用 `{reply, quickReplies}`,本地读取工具的前言和云端非最终片段不当作最终回答;完整解析后确认最终正文和卡片,主动进展提醒仍直接展示正文。普通 Markdown、JSON 数据和代码示例保持正文。兼容读取旧 `{intro, questions}` 和带 `tool` 的回复,只提取正文与快捷回复,不恢复或更新组件。新输出超过 3 条快捷回复时保留完整正文、提示格式错误并保留原始回答,不展示超额卡片或静默截取;历史中已有的更多卡片在读取与后续保存时仍原样保留。独立资源上限只用于防护,不做旧式字数截断;格式错误的原文默认折叠、字面显示,不再次送进模型。
- 回复中的引号或换行格式有误时,仅在正文边界明确、修复后整体合法的情况下恢复完整文字,不把半句话当成完整回答。附加建议损坏时仍展示完整正文;正文无法确认完整时显示“这次回复未能完整显示”,由学生点击“重新回答”,沿用原问题、引用与来源;持续聊天须回到原项目和操作对话后重试,新一轮按当前发布版本执行,旧话题仍使用绑定版本,保留当前草稿,连续点击不重复发送。打开聊天或旧话题会从保留的原文中在本地恢复可确认的正文,不调用模型,不因显示修复而改写磁盘记录;原始内容继续折叠保留。不完整的回答和建议不进入后续模型上下文。
- Yuxi 老师回复中的主线程思考过程与正文实时分开显示:思考区在生成期间展开,结束后折叠并可重新查看;正文在结构化回复尚未结束时逐步显示,不展示 JSON 包装或未完成的快捷回复。工具读取后的新消息替换临时正文,完成时按最终结果校准,快捷回复在完成解析后出现。停止或失败时保留已收到的思考与部分正文,恢复历史不会重新调用模型;思考不进入后续提问上下文或带回主会话的正文。仅展示 Yuxi 实际发出的推理字段,未开启或不提供思考的模型不显示虚构过程。
- 咨询正文支持 Markdown 标题、列表、表格、代码围栏、HTTP(S) 链接/图片和数学公式,长代码与表格在栏内横向滚动,不执行 HTML。云端正文维持 string 合同,未知对象不会猜测转成回答。工具活动只从云端主线程的类型化工具事件及 Main 本地读取过程获得,按本次问题/工具调用身份合并名称和状态,跨云端暂停、续接仍只计一次;暂停读取不算失败,状态以实际读取结果或问题终态为准,单独折叠显示;参数、结果原文和内部错误不混入回复,思考内容不作为正文。客户端仅保存自己实际收到的工具状态,不能补回旧历史或断线期间已经过期的事件。
- 云端咨询在 local_context 声明 read_protocol=2;需先部署配套 Yuxi API 和 worker,再升级客户端。云端持久累计读取字节及批次,每轮告知模型剩余额度;预算耗尽后消费最后一批结果,并以 tool_choice=none 要求根据现有证据形成答案和说明缺口。Main 限制实际返回量并拒绝第十三批读取,区分读取达到上限、上下文失效及格式无效。服务端对未声明协议的已安装旧客户端保留原工具参数与六批边界。
- 智能体人设、职责、提示词和 Skills 由发布配置决定。Main 不追加固定教学基线、不生成“小麦”、不清空所选智能体的 Skills,只添加真实工具能力、上下文边界和本轮界面协议。智能体名称叫“老师”“朋友”或“代码顾问”不改变调用路径或权限。

View File

@@ -39,6 +39,13 @@ export interface TeacherCloudTransport {
): Promise<void>;
}
export interface TeacherCloudStreamUpdate {
/** Current assistant message snapshot, replaced across messages and Runs. */
response?: string;
/** New reasoning text, accumulated across the question's tool continuations. */
reasoning?: string;
}
export function teacherCloudTransport(account: TeacherAccount): TeacherCloudTransport {
let session: TeacherSession | undefined;
const fetchCloud = async (path: string, body?: unknown, signal?: AbortSignal) => {
@@ -181,6 +188,7 @@ export function prepareCloudTeacher(
saveRequestId: (id: string) => Promise<void>,
transport: TeacherCloudTransport = teacherCloudTransport(account),
onToolActivity: (activity: TeacherToolActivity) => void = () => undefined,
onStream: (update: TeacherCloudStreamUpdate) => void = () => undefined,
) {
const tools = createTeacherReadTools(access);
return {
@@ -281,6 +289,44 @@ export function prepareCloudTeacher(
readBytes = 0;
let runText = '',
messageId = '';
let reasoningMessage = '';
const readEvents = async (threadId: string) => {
await transport.events(
'/runs/' + encodeURIComponent(runId) + '/events?after_seq=' + encodeURIComponent(cursor),
bounded,
(_event, envelope, id) => {
if (id) cursor = id;
// Parent Runs include child events; only the main thread is visible.
if (envelope.thread_id !== threadId) return;
const payload = envelope.payload ? object(envelope.payload) : {};
for (const item of Array.isArray(payload.items) ? payload.items : payload.chunk ? [payload.chunk] : []) {
const chunk = object(item);
const activity = cloudToolActivity(chunk, requestId);
if (activity) reportActivity(activity);
const event = chunk.stream_event ? object(chunk.stream_event) : {};
if (event.type !== 'message_delta') continue;
if (typeof event.message_id === 'string' && event.message_id !== messageId) {
if (!structuredReply && messageId && runText) onText('\n\n');
messageId = event.message_id;
runText = '';
if (structuredReply) onStream({ response: '' });
}
const reasoning = typeof event.reasoning_content === 'string' ? event.reasoning_content
: typeof event.additional_reasoning_content === 'string' ? event.additional_reasoning_content : '';
if (reasoning) {
const identity = runId + ':' + messageId;
onStream({ reasoning: (reasoningMessage && reasoningMessage !== identity ? '\n\n' : '') + reasoning });
reasoningMessage = identity;
}
if (typeof event.content === 'string') {
runText += event.content;
if (structuredReply) onStream({ response: runText });
else onText(event.content);
}
}
}
);
};
while (Date.now() < deadline) {
bounded.throwIfAborted();
access.assertCurrent();
@@ -289,11 +335,18 @@ export function prepareCloudTeacher(
undefined,
bounded
);
// A fast run/continuation may already be terminal before our first GET.
// Drain its retained events before moving on so reasoning is not lost.
if (view.status !== 'running' && view.status !== 'pending' && typeof view.thread_id === 'string') {
try { await readEvents(view.thread_id); }
catch { bounded.throwIfAborted(); access.assertCurrent(); }
}
if (view.continued_run_id) {
runId = identifier(view.continued_run_id);
cursor = '0-0';
runText = '';
messageId = '';
if (structuredReply) onStream({ response: '' });
continue;
}
if (view.status === 'interrupted') {
@@ -346,6 +399,7 @@ export function prepareCloudTeacher(
cursor = '0-0';
runText = '';
messageId = '';
if (structuredReply) onStream({ response: '' });
onProgress('智能体正在继续思考…');
continue;
}
@@ -353,8 +407,8 @@ export function prepareCloudTeacher(
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.
// Commit only the final answer; onStream carries replaceable previews
// from messages that may precede a tool read or another draft.
if (structuredReply) onText(output);
else if (output.startsWith(runText)) onText(output.slice(runText.length));
else if (output) onText('\n\n' + output);
@@ -374,39 +428,7 @@ export function prepareCloudTeacher(
const threadId = identifier(view.thread_id);
onProgress('智能体正在思考…');
try {
await transport.events(
'/runs/' +
encodeURIComponent(runId) +
'/events?after_seq=' +
encodeURIComponent(cursor),
bounded,
(_event, envelope, id) => {
if (id) cursor = id;
// Yuxi 的父 Run 也包含子线程事件,智能体正文只接收云端主线程文本。
if (envelope.thread_id !== threadId) return;
const payload = envelope.payload ? object(envelope.payload) : {};
for (const item of Array.isArray(payload.items)
? payload.items
: payload.chunk
? [payload.chunk]
: []) {
const chunk = object(item);
// A checkpoint continuation changes Run, not the logical tool call.
const activity = cloudToolActivity(chunk, requestId);
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) {
if (!structuredReply && messageId && runText) onText('\n\n');
messageId = event.message_id;
runText = '';
}
runText += event.content;
if (!structuredReply) onText(event.content);
}
}
}
);
await readEvents(threadId);
} catch (error) {
bounded.throwIfAborted();
access.assertCurrent();

View File

@@ -36,6 +36,7 @@ import { prepareTeacherModel } from './model-runner';
import { prepareCloudTeacher } from './cloud-runner';
import { readTeacherSource } from './source-reader';
import { applyTeacherReply, teacherReplyInstructions } from './reply';
import { previewTeacherReply } from '../../shared/teacher-reply';
import { subscribeWorksSquareSession } from '../services/works-square-session';
export interface TeacherScope {
@@ -560,6 +561,7 @@ export class CodingTeacherService {
...(store instanceof TeacherConversationStore ? { archive: await store.history() } : {}),
assertCurrent: () => this.assertAccount(account),
};
let streamedResponse = '';
const model = isCloud && access
? (this.options.prepareCloud ?? prepareCloudTeacher)(account, topic, input.requestId, access,
(progress) => {
@@ -578,6 +580,16 @@ export class CodingTeacherService {
else activities[index] = activity;
topic.revision++;
this.events.emit(key, structuredClone(topic));
}, update => {
this.assertAccount(account);
const current = topic.requests.find(request => request.id === input.requestId)!;
if (update.reasoning) current.reasoning = (current.reasoning ?? '') + update.reasoning;
if (update.response !== undefined) {
streamedResponse = update.response;
current.response = previewTeacherReply(streamedResponse);
}
topic.revision++;
this.events.emit(key, structuredClone(topic));
})
: await (this.options.prepareModel ?? prepareTeacherModel)(account, topic.definition,
access,
@@ -671,10 +683,9 @@ export class CodingTeacherService {
: '智能体回复失败,已保留本次问题与收到的内容。';
} finally {
// Keep received bytes inert even when an aborted runner resolves normally.
if (structuredReply && current.status !== 'completed' && structuredText)
current.unparsedResponse = structuredText;
if (structuredReply && current.status !== 'completed' && (structuredText || streamedResponse))
current.unparsedResponse = structuredText || streamedResponse;
if (intent === 'suggestions' && current.status !== 'completed') {
current.response = '';
delete current.suggestedQuestions;
}
topic.updatedAt = new Date().toISOString();

View File

@@ -94,6 +94,8 @@ export interface TeacherRequest {
/** Original answer retained for failed or repaired parsing; never fed back as context. */
unparsedResponse?: string;
toolActivity?: TeacherToolActivity[];
/** Provider-emitted main-thread reasoning, separate from the answer/context. */
reasoning?: string;
intent?: TeacherRequestIntent;
sourceConversationId?: string;

View File

@@ -224,6 +224,44 @@ export function isTeacherReplyEnvelope(raw: string): boolean {
return withinBytes(raw, MAX_TEACHER_REPLY_BYTES) && candidates(raw.trim()).length > 0;
}
/** Preview received prose only; final validation still belongs to parseTeacherReply. */
export function previewTeacherReply(raw: string): string {
if (!withinBytes(raw, MAX_TEACHER_REPLY_BYTES)) return '';
const text = raw.trimStart();
const found = candidates(text);
if (!found.length) {
// Wait for enough of an opening JSON/fence to identify the reply envelope.
if (/^\{/.test(text)) {
try { JSON.parse(text); } catch { return ''; }
}
if (/^(`{1,3}|~{1,3})([^\n]*)$/.test(text)
|| /^(?:```|~~~)(?:json|makelore-teacher(?:-reply|-discussion)?)?\s*\n\s*\{?[^:]*$/i.test(text)) return '';
return text;
}
if (found.length !== 1) return '';
const candidate = found[0];
const scanned = fields(candidate.content);
if (scanned.duplicate || scanned.proseStart === undefined) return '';
const start = scanned.proseStart;
if (candidate.content[start] !== '"') return '';
let end = start + 1;
while (end < candidate.content.length) {
const char = candidate.content[end];
if (char === '"') break;
if (char === '\\') {
const size = candidate.content[end + 1] === 'u' ? 6 : 2;
if (end + size > candidate.content.length) break;
end += size;
} else end++;
}
try {
// Incomplete escapes remain buffered, never displayed as raw protocol text.
const prose: string = JSON.parse('"' + candidate.content.slice(start + 1, end) + '"');
return [text.slice(0, candidate.start).trim(), prose.replace(/[\uD800-\uDBFF]$/, '')]
.filter(Boolean).join('\n\n');
} catch { return ''; }
}
function projectReply(envelope: Record<string, unknown>): TeacherReply {
const reply = own(envelope, 'reply') ? envelope.reply : envelope.intro;
const suggestions = own(envelope, 'quickReplies') ? envelope.quickReplies : envelope.questions;

View File

@@ -530,6 +530,10 @@ function ProjectTeacherChatPanel({
: <div className="flex justify-end"><p className="consultation-user max-w-[92%] whitespace-pre-wrap break-words rounded-2xl rounded-br-sm px-4 py-3 text-[13px] leading-7">{request.text}</p></div>}
{request.references.length > 0 && <details className="text-xs text-muted-foreground"><summary>本轮引用 {request.references.length} 项</summary>{request.references.map((ref, index) => <pre className="whitespace-pre-wrap" key={index}>{ref.text}</pre>)}</details>}
<div className="flex items-center gap-2 text-[11px] text-muted-foreground"><TeacherAvatar definition={definition} className="h-6 w-6 rounded-lg" />{definition?.name ?? label}</div>
{request.reasoning && <details open={['running', 'preparing'].includes(request.status)} className="min-w-0 text-xs text-muted-foreground" data-testid="teacher-reasoning">
<summary className="cursor-pointer">思考过程</summary>
<div className="mt-2 whitespace-pre-wrap break-words border-l-2 pl-3 leading-6 [overflow-wrap:anywhere]" aria-label="智能体思考过程">{request.reasoning}</div>
</details>}
{!!request.toolActivity?.length && <details className="text-xs text-muted-foreground">
<summary className="cursor-pointer">使用了 {request.toolActivity.length} 项工具</summary>
<ul className="mt-2 space-y-2" aria-label="智能体工具活动">
@@ -545,10 +549,10 @@ function ProjectTeacherChatPanel({
{request.intent !== 'check-in' && request.text.trim() && <button type="button" disabled={helpUnavailable || !retryScopeMatches(request)} onClick={() => void send({ text: request.text, intent: request.intent }, request)} className="consultation-retry">重新回答</button>}
{!retryScopeMatches(request) && <p className="text-xs text-muted-foreground">回到原项目和操作对话后,可以重新回答。</p>}
</div> : request.intent === 'suggestions' ? <>
{request.response && (request.status === 'completed' || request.presentation === 'reply-v1') && <TeacherReply text={request.response} />}
{['running', 'preparing'].includes(request.status)
? <p role="status" className="flex items-center gap-2 text-[13px] leading-7 text-muted-foreground"><Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />我看看你最近做到了哪里…</p>
: request.status === 'completed' && <>
<TeacherReply text={request.response} />
<div className="consultation-follow-ups" aria-label="可以和智能体聊的问题">
{request.suggestedQuestions?.map((question, index) => <button key={`${request.id}:${index}`} type="button" disabled={helpUnavailable} onClick={() => void send({ text: question })} className="consultation-follow-up"><span className="min-w-0 flex-1">{question}</span><ChevronRight className="h-4 w-4 shrink-0 opacity-70" aria-hidden="true" /></button>)}
</div>

View File

@@ -26,6 +26,8 @@ type ConsultationReply = {
suggestedQuestions: string[];
replyParseError?: string;
unparsedResponse?: string;
status?: 'running' | 'completed';
reasoning?: string;
};
type TeacherCatalogFixture = {
@@ -2575,3 +2577,56 @@ test('work failure offers a useful recovery and retains the student draft after
expect(new Set(opens.map((item) => item.body?.request_id)).size).toBe(2);
expect(requests.filter((item) => item.path.endsWith('/prompt'))).toHaveLength(0);
});
test('teacher reasoning and answer stream separately before the final response', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(app);
const connection = await page.evaluate(async () => ({
token: await window.electron.ipcRenderer.invoke('hostapi:token') as string,
baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string,
}));
await installCodingFirstChatHost(app, connection, true, false, false, undefined, false, {
response: '', suggestedQuestions: [], status: 'running', reasoning: '先观察项目代码。',
});
await settleSnapshot(app);
await disableCodingEventSource(page);
try {
await page.reload(); page = await getStableWindow(app);
await page.getByTestId('ai-module-option-programming').click();
await page.evaluate(() => { window.location.hash = '/chat'; });
await page.getByRole('button', { name: '与代码智能体聊天', exact: true }).click();
const teacher = page.getByTestId('teacher-chat-panel');
await teacher.getByRole('textbox', { name: '向智能体提问' }).fill('帮我看看项目');
await teacher.getByRole('button', { name: '提问', exact: true }).click();
const reasoning = teacher.getByTestId('teacher-reasoning');
await expect(reasoning).toHaveAttribute('open');
await expect(reasoning.getByText('先观察项目代码。', { exact: true })).toBeVisible();
await expect(teacher.getByTestId('teacher-reply')).toHaveCount(0);
const send = (await readState(app)).captured.find(item => item.path.endsWith('/messages') && item.method === 'POST')!;
const current = await page.evaluate(async path => {
const result = await window.electron.ipcRenderer.invoke('hostapi:fetch', { path, method: 'GET' }) as {
data: { json: { topic: import('../../shared/coding-teacher').TeacherTopic } };
};
return result.data.json.topic;
}, send.path.replace(/\/messages$/, ''));
current.requests[0].reasoning += '再检查交互。'; current.revision++;
await emitCodingEvent(page, 'snapshot', current);
await expect(reasoning).toContainText('先观察项目代码。再检查交互。');
current.requests[0].response = '可以先'; current.revision++;
await emitCodingEvent(page, 'snapshot', current);
await expect(teacher.getByTestId('teacher-reply')).toHaveText('可以先');
await expect(teacher.getByRole('button', { name: '停止回复', exact: true })).toBeVisible();
current.requests[0].response = '可以先观察玩家反馈。'; current.revision++;
await emitCodingEvent(page, 'snapshot', current);
await expect(teacher.getByTestId('teacher-reply')).toHaveText('可以先观察玩家反馈。');
await teacher.screenshot({ path: test.info().outputPath('teacher-streaming.png') });
current.requests[0].status = 'completed'; current.requests[0].suggestedQuestions = ['怎么观察?']; current.revision++;
await emitCodingEvent(page, 'snapshot', current);
await expect(reasoning).not.toHaveAttribute('open');
await reasoning.locator('summary').click();
await expect(reasoning.getByLabel('智能体思考过程')).toBeVisible();
await expect(teacher.getByRole('button', { name: '怎么观察?', exact: true })).toBeVisible();
await expect(teacher.getByTestId('teacher-reply')).not.toContainText('检查交互');
expect((await readState(app)).captured.filter(item => item.path.endsWith('/messages') && item.method === 'POST')).toHaveLength(1);
} finally { await releaseSnapshot(app); }
});

View File

@@ -759,6 +759,7 @@ it.each(['suggestions', 'reply-v1', 'discussion-v1'] as const)(
let firstReads = 0, resumedReads = 0;
const text = vi.fn();
const activity = vi.fn();
const stream = vi.fn();
const transport: TeacherCloudTransport = {
json: vi.fn(async (url) => {
if (url === '/questions') return { request_id: requestId, run_id: 'before-read' };
@@ -783,18 +784,31 @@ it.each(['suggestions', 'reply-v1', 'discussion-v1'] as const)(
payload: { items: [{ stream_event: { type: 'message_delta', message_id: id, content } }] },
});
if (url.includes('/before-read/')) {
accept('message', { thread_id: 'teacher-thread', payload: { items: [{ stream_event: {
type: 'message_delta', message_id: 'preamble', reasoning_content: '需要先读取项目。',
} }] } }, '0-1');
accept('message', message('teacher-thread', 'preamble', '我先检查重力。'), '1-0');
accept('message', message('teacher-thread', 'draft', '{"reply":"读取前的草案"}'), '2-0');
} else {
accept('message', message('child-thread', 'child', '子线程内容'), '1-0');
accept('message', { thread_id: 'teacher-thread', payload: { items: [{ stream_event: {
type: 'message_delta', message_id: 'final', reasoning_content: '已经获得项目证据。',
} }] } }, '1-1');
accept('message', message('teacher-thread', 'final', finalOutput.slice(0, 12)), '2-0');
expect(stream).toHaveBeenLastCalledWith({ response: finalOutput.slice(0, 12) });
expect(text).not.toHaveBeenCalled();
}
}),
};
await prepareCloudTeacher(
f.account, f.topic, requestId, f.access, f.progress, f.saveRequest, transport, activity
f.account, f.topic, requestId, f.access, f.progress, f.saveRequest, transport, activity, stream
).run([{ role: 'user', content: '一起讨论' }], new AbortController().signal, text);
expect(text.mock.calls).toEqual([[finalOutput]]);
expect(stream.mock.calls).toContainEqual([{ response: '' }]);
expect(stream.mock.calls).toContainEqual([{ response: finalOutput.slice(0, 12) }]);
expect(stream.mock.calls.flatMap(([update]) => update.reasoning ?? []).join(''))
.toBe('需要先读取项目。\n\n已经获得项目证据。');
expect(JSON.stringify(stream.mock.calls)).not.toContain('子线程内容');
expect(activity.mock.calls).toEqual([
[{ id: requestId + ':file', name: 'read_project_file', status: 'running' }],
[{ id: requestId + ':file', name: 'read_project_file', status: 'completed' }],
@@ -870,3 +884,38 @@ it('keeps only the cloud main-thread answer while advancing past child events on
expect(transport.events).toHaveBeenCalledTimes(2);
expect(streamUrls[1]).toContain('after_seq=2-0');
});
it('streams main-thread reasoning through reconnect and drains a completed run before finalizing', async () => {
const f = await fixture();
f.topic.requests.push({ id: requestId, text: '检查', references: [], createdAt: 'now',
sourceCursor: f.access.source.cursor, sourceCapturedAt: 'now', includedSourceMessageIds: [],
omittedMessages: 0, status: 'running', response: '', presentation: 'reply-v1' });
let reads = 0;
const stream = vi.fn(), text = vi.fn();
const transport: TeacherCloudTransport = {
json: vi.fn(async url => url === '/questions' ? { request_id: requestId, run_id: 'one' }
: ++reads === 1 ? { status: 'running', thread_id: 'teacher-thread' }
: { status: 'completed', thread_id: 'teacher-thread', output: '{"reply":"最后正文","quickReplies":[]}' }),
events: vi.fn(async (url, _signal, accept) => {
const event = (thread_id: string, message_id: string, fields: Record<string, string>) => ({
thread_id, payload: { chunk: { stream_event: { type: 'message_delta', message_id, ...fields } } },
});
if (url.endsWith('0-0')) {
accept('message', event('teacher-thread', 'answer', { reasoning_content: '先看代码。' }), '1-0');
expect(stream).toHaveBeenLastCalledWith({ reasoning: '先看代码。' });
accept('message', event('child-thread', 'child', { reasoning_content: 'child-private' }), '2-0');
throw new Error('stream interrupted');
}
expect(url).toContain('after_seq=2-0');
accept('message', event('teacher-thread', 'answer', { additional_reasoning_content: '再确认结果。' }), '3-0');
accept('message', event('teacher-thread', 'answer', { content: '{"reply":"最后' }), '4-0');
expect(stream).toHaveBeenLastCalledWith({ response: '{"reply":"最后' });
expect(text).not.toHaveBeenCalled();
}),
};
await prepareCloudTeacher(f.account, f.topic, requestId, f.access, f.progress, f.saveRequest,
transport, undefined, stream).run([], new AbortController().signal, text);
expect(stream.mock.calls.flatMap(([update]) => update.reasoning ?? []).join('')).toBe('先看代码。再确认结果。');
expect(text.mock.calls).toEqual([['{"reply":"最后正文","quickReplies":[]}']]);
expect(transport.events).toHaveBeenCalledTimes(2);
});

View File

@@ -1633,3 +1633,36 @@ describe('project consultations with selected cloud teachers', () => {
await vi.waitFor(async () => expect((await f.service.read(scope, topic.id)).requests[0].status).toBe('completed'));
});
});
it.each(['completed', 'cancelled'] as const)('persists streamed reasoning and prose separately when %s', async terminal => {
const f = await fixture({ cloudTeacher: true, mockCloud: true });
const scope = { ...f.scope, sourceId: 'project' };
const topic = await f.service.create(scope, undefined, undefined, 9);
f.replyWith(terminal === 'completed' ? '{"reply":"最终答案","quickReplies":["继续"]}' : '');
const requestId = crypto.randomUUID();
const seen: TeacherTopic[] = [];
const unsubscribe = await f.service.subscribe(scope, topic.id, next => seen.push(next));
await f.service.send(scope, topic.id, { requestId, text: '检查项目' });
const update = f.prepareCloud.mock.calls[0][8] as (value: { response?: string; reasoning?: string }) => void;
update({ reasoning: '先分析当前代码。' });
update({ response: '{"reply":"正在生成' });
const live = await f.service.read(scope, topic.id);
expect(live.requests[0]).toMatchObject({ status: 'running', reasoning: '先分析当前代码。', response: '正在生成' });
expect(seen.at(-1)!.requests[0].response).toBe('正在生成');
update({ response: '' });
expect((await f.service.read(scope, topic.id)).requests[0].response).toBe('');
update({ response: '{"reply":"新的正文' });
if (terminal === 'cancelled') await f.service.cancel(scope, topic.id, requestId);
else f.finish();
await vi.waitFor(async () => expect((await f.service.read(scope, topic.id)).requests[0].status).toBe(terminal));
unsubscribe();
const restarted = await f.restart();
const saved = (await restarted.read(scope, topic.id)).requests[0];
expect(saved.reasoning).toBe('先分析当前代码。');
expect(saved.response).toBe(terminal === 'completed' ? '最终答案' : '新的正文');
if (terminal === 'cancelled') expect(saved.unparsedResponse).toBe('{"reply":"新的正文');
const nextId = crypto.randomUUID();
await restarted.send(scope, topic.id, { requestId: nextId, text: '接着解释' });
expect(JSON.stringify(f.run.mock.calls.at(-1)![0])).not.toContain('先分析当前代码。');
await restarted.cancel(scope, topic.id, nextId);
});

View File

@@ -180,3 +180,28 @@ describe('continuous agent panel', () => {
expect(api.sendConversation).not.toHaveBeenCalled();
});
});
it.each([undefined, 'suggestions'] as const)('updates reasoning and answer independently before completion (%s)', async intent => {
let current = topic([{ ...request('streaming'), intent, presentation: 'reply-v1', status: 'running', response: '' }]);
api.conversation.mockResolvedValue({ topic: current, before: null });
api.seenConversation.mockImplementation(async () => current);
await mount();
await waitFor(() => expect(api.events).toHaveBeenCalled());
const emit = async (patch: Partial<TeacherRequest>) => {
current = { ...current, revision: current.revision + 1, requests: [{ ...current.requests[0], ...patch }] };
await act(async () => stream.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify(current) })));
};
await emit({ reasoning: '先读项目。' });
expect(screen.getByTestId('teacher-reasoning')).toHaveAttribute('open');
expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('先读项目。');
await emit({ reasoning: '先读项目。再分析玩法。', response: '正在生成正文' });
expect(screen.getByTestId('teacher-reply')).toHaveTextContent('正在生成正文');
expect(screen.getByTestId('teacher-reply')).not.toHaveTextContent('再分析玩法');
expect(screen.getByRole('button', { name: '停止回复' })).toBeEnabled();
await emit({ status: 'completed', response: '完整正文', suggestedQuestions: ['再讲一个例子'] });
expect(screen.getByTestId('teacher-reasoning')).not.toHaveAttribute('open');
expect(screen.getByTestId('teacher-reply')).toHaveTextContent('完整正文');
expect(screen.getByRole('button', { name: '再讲一个例子' })).toBeEnabled();
fireEvent.click(screen.getByText('思考过程'));
expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('再分析玩法。');
});

View File

@@ -0,0 +1,38 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import { previewTeacherReply } from '../../shared/teacher-reply';
describe('teacher streaming prose', () => {
it('decodes text before the envelope or string closes, withholding incomplete escapes', () => {
const reply = '先看 "代码"。\n路径 C:\\game\n🍎';
const raw = JSON.stringify({ reply, quickReplies: ['下一步'], tool: { reply: 'never-visible' } });
const previews = Array.from({ length: raw.length + 1 }, (_, i) => previewTeacherReply(raw.slice(0, i)));
for (const preview of previews) {
expect(reply.startsWith(preview)).toBe(true);
expect(preview).not.toContain('quickReplies');
}
expect(previews[raw.indexOf('代码') + 2]).toBe('先看 "代码');
expect(previews.at(-1)).toBe(reply);
expect(previewTeacherReply('{"reply":"你好\\u4')).toBe('你好');
expect(previewTeacherReply('{"reply":"你好\\u4e16')).toBe('你好世');
});
it.each(['```json\n', '```JSON\r\n', '~~~json\n', '```makelore-teacher-reply\n', '```\n'])(
'streams fenced replies without leaking the protocol (%s)', fence => {
const raw = fence + '{"quickReplies":[],"reply":"逐步回答';
for (let i = 0; i <= raw.length; i++) {
const preview = previewTeacherReply(raw.slice(0, i));
expect('逐步回答'.startsWith(preview)).toBe(true);
}
expect(previewTeacherReply(raw)).toBe('逐步回答');
},
);
it('accepts plain text and completed ordinary JSON while excluding nested retired payloads', () => {
expect(previewTeacherReply('## 标题\n\n逐步说明')).toBe('## 标题\n\n逐步说明');
expect(previewTeacherReply('{"count":1}')).toBe('{"count":1}');
expect(previewTeacherReply('{"tool":{"reply":"不要显示"},"reply":"正文')).toBe('正文');
expect(previewTeacherReply('{"intro":"旧版正文","questions":[')).toBe('旧版正文');
expect(previewTeacherReply('{"reply":"正文","quickReplies":["隐藏')).toBe('正文');
});
});