feat(teacher): 连接 Yuxi 老师并提供项目与会话只读工具

This commit is contained in:
2026-09-23 09:56:11 +08:00
parent 1f2ad3fb3a
commit e35a13079e
14 changed files with 886 additions and 30 deletions

View File

@@ -0,0 +1,42 @@
# Task: Connect coding teachers to Yuxi with local project tools
## Identity
- Task ID: 20260923-yuxi-teachers-client-53ba33e2
- Mode: Feature
- Branch: codex/20260923-yuxi-teachers-client-53ba33e2-yuxi-teachers-client
- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260923-yuxi-teachers-client-53ba33e2
- Base commit: 1f2ad3fb3a71b8b35fabb5b319cd3b54b4c2ebbe
- Owner: codex
- Status: Ready for Integration
## Scope
- Implement the accepted Yuxi coding-teacher design across Yuxi runtime, Works Square distribution and MakeLore Main/local context. This record owns the ml repository changes only. User explicitly confirmed student-account billing on 2026-09-23.
## Intent And Constraints
- Official check/start/status passed in this isolated managed feature worktree, with matching identity/base/branch. Planning Gate passed after required entry/task/memory/architecture/decision and peer scope reads. Primary checkouts, foreign documents and canonical project memory remain untouched.
- Peer task-record scope reads: WS 268, Yuxi 43, MakeLore 105, all readable. Incomplete historical scopes remain unknown; no concrete conflicting dependency found. Yuxi template positioning is supplemented by ARCHITECTURE and current code. WS teacher no-tools text is stale relative to the merged Main read tools.
- No subagents without user consent. No production deployment, source push, or real paid model requests. Ordinary personal agents keep creator billing. Teacher guidance remains read-only, user-initiated and bound to the selected local project/Pi conversation; no proactive monitoring or local command execution.
- Implementation sequence: establish native cloud client-tool wait/results/continuation with focused unit plus real HTTP/PG/worker evidence; connect published Yuxi teachers to Operations distribution and student entitlement/billing; connect Main context/read dispatch and teacher UI; finish three-end regression, builds, documentation and review. Existing local teacher histories retain a supported read path.
## Outcome
- Implemented teacher selection for new topics, Main-owned scoped Yuxi sessions/SSE, local project/Pi snapshot binding, complete structured read results and native cloud continuation. Reconnect follows persisted continuation and cursor; original question ID supports cancellation even if submission acknowledgment is lost.
- Main enforces existing project read boundaries; frozen public Pi messages and current teacher topic texts can be read by ID. Legacy teacher histories retain their original path. Disabled teacher topics remain readable without borrowing another teacher's availability. Cloud topic headers reflect the selected teacher, including implicit topic creation on first send.
- Updated README with runtime, configuration/version, local read, cancellation and billing behavior. Implementation and local evidence complete. The user-authorized fresh read-only reviewer independently reviewed all three repositories and returned PASS with no reproducible defects. Prepared for integration; no merge, push, deployment, installer release or paid-model calls.
## Verification
- Typecheck passed. All 50 relevant tests across coding-teacher, model, read-tools, UI and cloud runner passed. Cloud runner tests use real temporary project files and captured public messages, asserting all result IDs/content, stale-context rejection, saved continuation, lost acknowledgment cancellation and SSE replay/partial final-answer repair. Service integration also validates native Yuxi topics with no local prompt use cloud credentials, local reads and saved cloud request/response rather than the local model loop.
- Electron E2E project teacher side chat passed against actual built window with explicit Host API fixtures, including choosing a non-default teacher and bringing its response back to the main draft without submitting. Production Renderer/Main/Preload build passed. Focused ESLint and git diff whitespace checks passed.
- Yuxi real HTTP/PG/worker integration separately verifies cloud interruption/continuation; WS verifies student wallet. No real paid provider or deployed three-service topology call.
## Follow-ups
- Integrate the three paired task branches together when requested. Deployment, real provider billing, remote MCP and production three-service/one-api topology validation remain a separate release step. Retain task worktrees and branches for that handoff.
## Promotion Candidates
- Target: accepted teacher/cloud-agent architecture and domain documents. Proposal: Yuxi teacher definition/runtime, WS selection/authorization, Main project/Pi read tools; teacher payer is the student while other personal agents keep creator payer. Evidence: user accepted design/implementation and explicitly selected student billing. Future impact: removes duplicate teacher configuration ownership and changes teacher cloud history boundary. Conflicts: old teacher Main-only/no-cloud-history decision is intentionally superseded by accepted scope; feature task records facts, Integration Gate promotes after delivery. No additional product confirmation needed for the accepted scope.

View File

@@ -156,10 +156,11 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p
### 云端编程老师
- 主会话标题栏的“问老师”打开右侧独立聊天。老师按运营发布的提示词、文本教学 Skills、模型和原生思考选项回答,并按预算读取当前主会话已完成分支的文本与用户明确引用。主会话和当前老师话题中的长消息保留最近问题与回答的首尾节选,面板会提示节选;老师可按消息 ID 读取本轮捕获的完整文本。Main 直接调用既有计费网关,不创建 Pi worker。
- 老师可按需只读当前项目的目录与 UTF-8 代码文件(包括 `.makelore/project.json` 项目配置),复用 Main 的项目路径边界和 256 KiB 文件预览上限,按行读取并标明截断。读取结果经同一计费网关送给老师,单轮最多六批读取后生成回答;累计用量涵盖所有模型请求。不能修改文件或执行命令,也不能读取 `.makelore` 内其他账号/会话记录或 `.git` 内部数据。每次读取与后续模型请求都检查账号和取消状态;运营试聊不提供项目读取工具。
- 老师话题按当前云端账号、项目和来源会话保存,创建时固定发布版本;每轮发送重新检查启停。关闭面板或切换会话不取消已接受的问题,退出账号会中止。请求先落盘再调用模型,同一请求标识不重复发送,重启后未完成请求标记中断。
- 回答可复制或追加到主输入框草稿,由用户决定发送;归档来源需先恢复。删除来源会话会停止并删除关联老师记录。运营草稿试聊使用 `niancode://coding-teacher/preview?draft_revision=N` 打开独立示例上下文,不读取学生项目,要求桌面当前账号具有运营权限。
- “问老师”打开右侧聊天。运营从 Yuxi 选择并下发多位老师,指定默认老师;客户端可为新话题选择老师。提示词、Skills、知识库和模型配置由 Yuxi 管理,运营显式同步后生成新版本,新话题使用新版本,已有话题保留原版本。Yuxi 原生执行负责模型和云端资源;老师模型费用由当前学生的编程词元点数支付,个人 Agents 模块仍由创建者付费。老师不要求学生开通个人云智能体模块。
- Main 为每次提问绑定账号、项目和 Pi 当前已完成分支,提供 `list_project_files`、`read_project_file`、`read_conversation` 三个只读工具。Yuxi 通过持久中断下发整批调用,Main 返回配对结果后继续同一问题。文件按读取时的内容提供;会话原文来自本轮捕获的公开消息,不上传 Pi 原始日志或思考。可读取 `.makelore/project.json`,其余 `.makelore` 记录与 `.git` 内部数据不可读,也不提供本地写入或命令执行。
- 文件预览上限 256 KiB;工具默认每次 60 行,最多 100 行、2400 字节,并说明节选或错误。每个问题最多六批本地读取,续接保留原截止时间和学生付款人。长消息可按消息 ID 读取本轮原文;云端保存老师对话及收到的片段,完整本地工程仍保留在客户端。
- 话题按账号、项目和来源会话保存在本机,云端使用学生隔离的对应线程。关闭面板或切换会话不取消已接受的问题;停止、退出账号会停止本地读取并请求取消云端原问题。断线按事件游标和持久状态恢复,重复提交沿用原问题和工具批次;重启后未完成问题标记中断,下次提问先停止旧问题并重新绑定本地上下文。停用老师后历史仍可查看。旧版本地老师话题保留原执行与历史读取路径。
- 回答可复制或追加到主输入框草稿,由用户决定发送;归档来源需先恢复。删除来源会话会停止并删除本机关联老师记录。运营菜单只提供老师列表、选择下发、同步、默认和启停操作,提示词编辑在 Yuxi 完成。
## 运行内核与项目数据

View File

@@ -24,10 +24,11 @@ export async function handleCodingTeacherRoutes(
/^\/api\/coding\/teacher-preview\/topics(?:\/([^/]+))?(?:\/(messages|events|save|requests\/([^/]+)\/cancel))?$/
);
const config = url.pathname === '/api/coding/teacher/config';
const catalog = url.pathname === '/api/coding/teacher/teachers';
const draft = url.pathname === '/api/coding/teacher-preview';
const pending = url.pathname === '/api/coding/teacher-preview/pending-link';
if (!source && !preview && !config && !draft && !pending) return false;
if ((config || draft || pending) && req.method !== 'GET') {
if (!source && !preview && !config && !catalog && !draft && !pending) return false;
if ((config || catalog || draft || pending) && req.method !== 'GET') {
sendJson(res, 405, { error: '不支持此操作。' });
return true;
}
@@ -41,6 +42,10 @@ export async function handleCodingTeacherRoutes(
return true;
}
try {
if (catalog) {
sendJson(res, 200, await service.catalog());
return true;
}
if (config && req.method === 'GET') {
sendJson(res, 200, await service.definition());
return true;
@@ -63,8 +68,8 @@ export async function handleCodingTeacherRoutes(
return true;
}
if (!id && req.method === 'POST') {
const body = await parseJsonBody<{ draftRevision?: number; sampleContext?: string }>(req);
sendJson(res, 201, await service.create(scope, body.draftRevision, body.sampleContext));
const body = await parseJsonBody<{ draftRevision?: number; sampleContext?: string; teacherVersion?: number }>(req);
sendJson(res, 201, await service.create(scope, body.draftRevision, body.sampleContext, body.teacherVersion));
return true;
}
if (id && !action && req.method === 'GET') {

View File

@@ -0,0 +1,363 @@
import { setTimeout as delay } from 'node:timers/promises';
import type { PublicUsage } from '../../shared/coding-conversation-contracts';
import type { TeacherTopic } from '../../shared/coding-teacher';
import { proxyAwareFetch } from '../utils/proxy-fetch';
import {
assertTeacherAccount,
teacherCloudRequest,
TeacherError,
type TeacherAccount,
} from './config-client';
import type { TeacherModelMessage } from './context';
import { createTeacherReadTools, type TeacherReadAccess } from './read-tools';
interface TeacherSession {
access_token: string;
expires_at: number;
api_base_url: string;
scope: string;
}
type Json = Record<string, unknown>;
function object(value: unknown): Json {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new TeacherError(502, 'teacher_protocol_invalid', '老师服务返回的数据无效。');
return value as Json;
}
function identifier(value: unknown): string {
if (typeof value !== 'string' || !value)
throw new TeacherError(502, 'teacher_protocol_invalid', '老师服务未返回有效的运行标识。');
return value;
}
export interface TeacherCloudTransport {
json(path: string, body?: unknown, signal?: AbortSignal): Promise<Json>;
events(
path: string,
signal: AbortSignal,
accept: (event: string, data: Json, id: string) => void
): Promise<void>;
}
export function teacherCloudTransport(account: TeacherAccount): TeacherCloudTransport {
let session: TeacherSession | undefined;
const fetchCloud = async (path: string, body?: unknown, signal?: AbortSignal) => {
// 退出账号后只允许用已经持有的短凭据停止原问题,不能再读状态或发送内容。
const stopping = /^\/questions\/[^/]+\/cancel$/.test(path) && body !== undefined;
for (let attempt = 0; attempt < 3; attempt++) {
if (!stopping || !session) assertTeacherAccount(account);
signal?.throwIfAborted();
if (!session || (!stopping && session.expires_at * 1000 < Date.now() + 10000)) {
session = await teacherCloudRequest<TeacherSession>(
account,
'/api/coding-teacher/session',
'POST'
);
if (
session.scope !== 'makelore-teachers' ||
typeof session.access_token !== 'string' ||
!session.access_token ||
!Number.isFinite(session.expires_at) ||
session.expires_at * 1000 <= Date.now() ||
typeof session.api_base_url !== 'string' ||
!/^https?:\/\//.test(session.api_base_url)
) {
throw new TeacherError(502, 'teacher_session_invalid', '老师接入凭据无效,请重新连接。');
}
}
if (!stopping) assertTeacherAccount(account);
try {
const response = await proxyAwareFetch(
session.api_base_url.replace(/\/+$/, '') + '/api/makelore/teachers' + path,
{
method: body === undefined ? 'GET' : 'POST',
headers: {
Authorization: 'Bearer ' + session.access_token,
'Content-Type': 'application/json',
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(45000)]),
}
);
if (!stopping) assertTeacherAccount(account);
if (response.status === 401 && attempt < 2) {
await response.body?.cancel();
session = undefined;
continue;
}
if (response.status >= 500 && attempt < 2) {
await response.body?.cancel();
await delay(500, undefined, { signal });
continue;
}
if (!response.ok) {
const data = object(await response.json());
const detail = data.detail;
const message =
typeof detail === 'string'
? detail
: detail && typeof detail === 'object'
? object(detail).message
: undefined;
throw new TeacherError(
response.status,
'teacher_cloud_failed',
typeof message === 'string' ? message : '老师服务暂不可用,请稍后重试。'
);
}
return response;
} catch (error) {
signal?.throwIfAborted();
if (error instanceof TeacherError || attempt === 2) throw error;
await delay(500, undefined, { signal });
}
}
throw new TeacherError(502, 'teacher_connection_failed', '连接老师失败。');
};
return {
async json(path, body, signal) {
const response = await fetchCloud(path, body, signal);
const data = object(await response.json());
if (!path.endsWith('/cancel')) assertTeacherAccount(account);
return data;
},
async events(path, signal, accept) {
const response = await fetchCloud(path, undefined, signal);
if (!response.body)
throw new TeacherError(502, 'teacher_stream_missing', '老师回复连接不可用。');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const next = await reader.read();
if (next.done) break;
assertTeacherAccount(account);
signal.throwIfAborted();
buffer += decoder.decode(next.value, { stream: true }).replaceAll('\r', '');
let end: number;
while ((end = buffer.indexOf('\n\n')) >= 0) {
const block = buffer.slice(0, end);
buffer = buffer.slice(end + 2);
let event = 'message',
id = '';
const data: string[] = [];
for (const line of block.split('\n')) {
if (line.startsWith('event:')) event = line.slice(6).trim();
else if (line.startsWith('id:')) id = line.slice(3).trim();
else if (line.startsWith('data:')) data.push(line.slice(5).trimStart());
}
if (data.length) accept(event, object(JSON.parse(data.join('\n'))), id);
}
}
} finally {
await reader.cancel().catch(() => undefined);
reader.releaseLock();
}
},
};
}
export function prepareCloudTeacher(
account: TeacherAccount,
topic: TeacherTopic,
requestId: string,
access: TeacherReadAccess,
onProgress: (text: string) => void,
saveRequestId: (id: string) => Promise<void>,
transport: TeacherCloudTransport = teacherCloudTransport(account)
) {
const tools = createTeacherReadTools(access);
return {
inputLimit: topic.definition.limits.max_input_tokens,
async run(
messages: TeacherModelMessage[],
signal: AbortSignal,
onText: (delta: string) => void
): Promise<PublicUsage | undefined> {
const localContext = {
id: requestId,
scope: { project_id: topic.projectId, source_session_id: topic.sourceConversationId },
tools: tools.definitions.map((item) => item.function.name),
};
const deadline = Date.now() + 60 * 60 * 1000;
const bounded = AbortSignal.any([signal, AbortSignal.timeout(60 * 60 * 1000)]);
let questionId: string | undefined;
let completed = false;
try {
// 重启后本地快照已经丢失,先停止旧请求,再以新的问题建立读取作用域。
const previous = topic.requests.filter((item) => item.id !== requestId).at(-1);
if (previous && previous.status !== 'completed') {
try {
await transport.json(
'/questions/' + encodeURIComponent(previous.id) + '/cancel',
{},
bounded
);
} catch (error) {
if (!(error instanceof TeacherError && error.status === 404)) throw error;
}
}
let queued = await transport.json(
'/questions',
{
teacher_version: topic.version,
thread_id: topic.id,
request_id: requestId,
query: messages
.filter((item) => item.role === 'user')
.map((item) => item.content)
.join('\n\n'),
local_context: localContext,
},
bounded
);
questionId = identifier(queued.request_id);
await saveRequestId(questionId);
while (!queued.run_id) {
if (!['queued', 'pending'].includes(String(queued.status)))
throw new TeacherError(
409,
'teacher_request_stopped',
'老师提问未能启动,请重新提问。'
);
onProgress('正在等待老师…');
await delay(800, undefined, { signal: bounded });
queued = await transport.json(
'/requests/' + encodeURIComponent(questionId),
undefined,
bounded
);
}
let runId = identifier(queued.run_id),
cursor = '0-0',
rounds = 0;
let runText = '',
messageId = '';
while (Date.now() < deadline) {
bounded.throwIfAborted();
access.assertCurrent();
const view = await transport.json(
'/runs/' + encodeURIComponent(runId),
undefined,
bounded
);
if (view.continued_run_id) {
runId = identifier(view.continued_run_id);
cursor = '0-0';
runText = '';
messageId = '';
continue;
}
if (view.status === 'interrupted') {
const pending = object(view.interrupt);
if (pending.source !== 'client_read_tools')
throw new TeacherError(
409,
'teacher_interaction_unsupported',
'老师请求了当前面板不支持的交互,请联系运营调整该智能体。'
);
if (
pending.context_id !== requestId ||
!Array.isArray(pending.calls) ||
!pending.calls.length ||
pending.calls.length > 32 ||
++rounds > 6
)
throw new TeacherError(
409,
'teacher_context_expired',
'老师读取请求已失效,请重新提问。'
);
onProgress('正在读取项目与会话…');
const results = [];
for (const raw of pending.calls) {
const call = object(raw);
if (typeof call.tool_call_id !== 'string' || typeof call.name !== 'string')
throw new TeacherError(502, 'teacher_protocol_invalid', '老师读取请求无效。');
results.push({
tool_call_id: call.tool_call_id,
...(await tools.executeResult(call.name, JSON.stringify(call.arguments), bounded)),
});
}
// POST 重试使用完全相同的结果,文件变化也不会导致重复续接或不同输入。
const resumed = await transport.json(
'/runs/' + encodeURIComponent(runId) + '/tool-results',
{ context_id: requestId, results },
bounded
);
runId = identifier(resumed.run_id);
cursor = '0-0';
runText = '';
messageId = '';
onProgress('老师正在继续思考…');
continue;
}
if (view.status === 'completed') {
const output = typeof view.output === 'string' ? view.output : '';
if (output.startsWith(runText)) onText(output.slice(runText.length));
else if (output) onText('\n\n' + output);
completed = true;
onProgress('');
// Yuxi 的账本记录每个模型调用;不把线程累计 token 当作本问题费用。
return undefined;
}
if (['failed', 'cancelled'].includes(String(view.status))) {
const detail = view.error ? object(view.error).message : undefined;
throw new TeacherError(
409,
'teacher_run_failed',
typeof detail === 'string' ? detail : '老师回复已停止。'
);
}
onProgress('老师正在思考…');
try {
await transport.events(
'/runs/' +
encodeURIComponent(runId) +
'/events?after_seq=' +
encodeURIComponent(cursor),
bounded,
(_event, envelope, id) => {
if (id) cursor = id;
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 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 (messageId && runText) onText('\n\n');
messageId = event.message_id;
runText = '';
}
runText += event.content;
onText(event.content);
}
}
}
);
} catch (error) {
bounded.throwIfAborted();
access.assertCurrent();
if (error instanceof TeacherError && error.status < 500) throw error;
onProgress('连接中断,正在恢复老师回复…');
}
await delay(400, undefined, { signal: bounded });
}
throw new TeacherError(408, 'teacher_question_expired', '本次老师提问已超时,请重新提问。');
} finally {
if (!completed) {
await transport
.json(
'/questions/' + encodeURIComponent(requestId) + '/cancel',
{},
AbortSignal.timeout(10000)
)
.catch(() => undefined);
}
}
},
};
}

View File

@@ -6,7 +6,7 @@ import {
isCurrentWorksSquareAccountBinding,
type WorksSquareAccountBinding,
} from '../services/works-square-session';
import type { TeacherAvailability, TeacherDefinition } from '../../shared/coding-teacher';
import type { TeacherAvailability, TeacherDefinition, TeacherCatalog } from '../../shared/coding-teacher';
export class TeacherError extends Error {
constructor(
@@ -27,7 +27,8 @@ export function assertTeacherAccount(account: TeacherAccount) {
}
export async function teacherCloudRequest<T>(
account: TeacherAccount,
pathname: string
pathname: string,
method: 'GET' | 'POST' = 'GET'
): Promise<T> {
assertTeacherAccount(account);
const token = await getValidWorksSquareAccessToken();
@@ -36,6 +37,7 @@ export async function teacherCloudRequest<T>(
const response = await proxyAwareFetch(
WORKS_SQUARE_CONFIG.apiBaseUrl.replace(/\/+$/, '') + pathname,
{
method,
headers: { Authorization: 'Bearer ' + token },
signal: AbortSignal.timeout(30000),
}
@@ -69,6 +71,8 @@ export async function currentTeacherAccount(): Promise<TeacherAccount> {
}
export const teacherAvailability = (account: TeacherAccount) =>
teacherCloudRequest<TeacherAvailability>(account, '/api/coding-teacher/config');
export const teacherCatalog = (account: TeacherAccount) =>
teacherCloudRequest<TeacherCatalog>(account, '/api/coding-teacher/teachers');
export const teacherVersion = (account: TeacherAccount, version: number) =>
teacherCloudRequest<{ version: number; payload: TeacherDefinition }>(
account,

View File

@@ -56,9 +56,14 @@ export function createTeacherReadTools(access: TeacherReadAccess) {
return {
definitions: teacherReadToolDefinitions,
async execute(name: string, rawArguments: string, signal: AbortSignal, maxBytes = 2400): Promise<string> {
return (await this.executeResult(name, rawArguments, signal, maxBytes)).content;
},
async executeResult(name: string, rawArguments: string, signal: AbortSignal, maxBytes = 2400): Promise<{ status: 'success' | 'error'; content: string; truncated: boolean }> {
signal.throwIfAborted();
access.assertCurrent();
let result: string;
let status: 'success' | 'error' = 'success';
let truncated = false;
try {
const args = JSON.parse(rawArguments) as Record<string, unknown>;
if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('Expected an object.');
@@ -71,6 +76,7 @@ export function createTeacherReadTools(access: TeacherReadAccess) {
}
case 'read_project_file': {
const file = await files.content(access.projectPath, projectPath(args.path));
truncated = file.truncated;
result = file.path + '\n' + lines(file.content, args)
+ (file.truncated ? '\n[File exceeds the 256 KiB text preview limit; only its beginning is available.]' : '');
break;
@@ -90,13 +96,15 @@ export function createTeacherReadTools(access: TeacherReadAccess) {
throw new Error('Only list_project_files, read_project_file and read_conversation are available.');
}
} catch (error) {
status = 'error';
// Keep local OS paths and unrelated application data out of model errors.
result = 'Read failed. Check the relative path, message id and line range. '
+ (error instanceof SyntaxError ? 'Tool arguments must be valid JSON.' : 'Only current-project text files and current-conversation messages are available.');
}
signal.throwIfAborted();
access.assertCurrent();
return excerptTeacherText(result, maxBytes);
const content = excerptTeacherText(result, maxBytes);
return { status, content, truncated: truncated || content !== result };
},
};
}

View File

@@ -17,12 +17,14 @@ import {
teacherAvailability,
teacherVersion,
teacherPreview,
teacherCatalog,
TeacherError,
type TeacherAccount,
} from './config-client';
import { TeacherTopicStore, teacherTopicId } from './store';
import { compileTeacherContext } from './context';
import { prepareTeacherModel } from './model-runner';
import { prepareCloudTeacher } from './cloud-runner';
import { readTeacherSource } from './source-reader';
import { subscribeWorksSquareSession } from '../services/works-square-session';
@@ -44,6 +46,8 @@ export interface TeacherServiceOptions {
version?: typeof teacherVersion;
preview?: typeof teacherPreview;
prepareModel?: typeof prepareTeacherModel;
prepareCloud?: typeof prepareCloudTeacher;
catalog?: typeof teacherCatalog;
readSource?(scope: TeacherScope): Promise<TeacherSourceContext>;
}
export class CodingTeacherService {
@@ -84,6 +88,9 @@ export class CodingTeacherService {
: null;
return { ...status, definition: published?.payload ?? null };
}
async catalog() {
return (this.options.catalog ?? teacherCatalog)(await this.account());
}
private async scopedStore(
account: TeacherAccount,
scope: TeacherScope
@@ -132,7 +139,8 @@ export class CodingTeacherService {
async create(
scope: TeacherScope,
draftRevision?: number,
sampleContext = ''
sampleContext = '',
teacherVersionNumber?: number,
): Promise<TeacherTopic> {
const account = await this.account();
const store = await this.scopedStore(account, scope);
@@ -148,6 +156,14 @@ export class CodingTeacherService {
definition = (await (this.options.preview ?? teacherPreview)(account, draftRevision!))
.payload;
version = 0;
} else if (teacherVersionNumber !== undefined) {
if (!Number.isSafeInteger(teacherVersionNumber) || teacherVersionNumber < 1)
throw new TeacherError(422, 'teacher_version_invalid', '老师版本无效。');
const catalog = await (this.options.catalog ?? teacherCatalog)(account);
const selected = catalog.items.find(item => item.version === teacherVersionNumber);
if (!selected) throw new TeacherError(409, 'teacher_disabled', '该老师暂未开放,请刷新后选择。');
definition = selected.definition;
version = selected.version;
} else {
const status = await (this.options.availability ?? teacherAvailability)(account);
if (!status.enabled || !status.published_version)
@@ -236,7 +252,11 @@ export class CodingTeacherService {
throw new TeacherError(409, 'teacher_topic_busy', '请等待当前回复完成,或先停止。');
if (topic.draftRevision) {
await (this.options.preview ?? teacherPreview)(account, topic.draftRevision);
} else {
} else if (topic.definition.runtime !== 'yuxi' && topic.definition.config_id) {
const catalog = await (this.options.catalog ?? teacherCatalog)(account);
if (!catalog.items.some(item => item.teacher_id === topic.definition.config_id))
throw new TeacherError(409, 'teacher_disabled', '老师已停用,历史仍可查看。');
} else if (topic.definition.runtime !== 'yuxi') {
const available = await (this.options.availability ?? teacherAvailability)(account);
if (!available.enabled)
throw new TeacherError(409, 'teacher_disabled', '老师已停用,历史仍可查看。');
@@ -272,22 +292,31 @@ export class CodingTeacherService {
);
return structuredClone(ref);
});
if (!topic.definition.system_prompt.trim())
const isCloud = topic.definition.runtime === 'yuxi';
if (!isCloud && !topic.definition.system_prompt.trim())
throw new TeacherError(422, 'teacher_definition_invalid', '请先配置老师的系统提示词。');
const model = await (this.options.prepareModel ?? prepareTeacherModel)(
account,
topic.definition,
scope.projectId === 'preview' ? undefined : {
const access = scope.projectId === 'preview' ? undefined : {
projectPath: (await this.options.projects.getProject(scope.projectId)).path,
source,
history: topic.requests,
assertCurrent: () => this.assertAccount(account),
}
);
};
const model = isCloud && access
? (this.options.prepareCloud ?? prepareCloudTeacher)(account, topic, input.requestId, access,
(progress) => {
const current = topic.requests.at(-1)!;
if (current.progress === progress) return;
current.progress = progress; topic.revision++;
this.events.emit(key, structuredClone(topic));
}, async cloudRequestId => {
topic.requests.at(-1)!.cloudRequestId = cloudRequestId;
await store.save(topic);
})
: await (this.options.prepareModel ?? prepareTeacherModel)(account, topic.definition, access);
const compiled = compileTeacherContext(
topic.definition,
source,
topic.requests,
isCloud ? [] : topic.requests,
input.text,
references,
model.inputLimit,

View File

@@ -1,6 +1,9 @@
import type { PublicUsage } from './coding-conversation-contracts';
export interface TeacherDefinition {
config_id?: string;
runtime?: 'local' | 'yuxi';
yuxi?: { agent_slug: string; agent_version: number };
schema_version: 1;
teacher_id: 'coding-teacher';
name: string;
@@ -29,6 +32,9 @@ export interface TeacherAvailability {
published_version: number | null;
revision: number;
}
export interface TeacherCatalog {
items: Array<{ teacher_id: string; version: number; definition: TeacherDefinition; is_default: boolean }>;
}
export interface TeacherReference {
kind: 'message' | 'code';
text: string;
@@ -67,6 +73,8 @@ export interface TeacherRequest {
response: string;
error?: string;
usage?: PublicUsage;
cloudRequestId?: string;
progress?: string;
}
export interface TeacherTopic {
revision: number;

View File

@@ -1,6 +1,7 @@
import { hostApiFetch, createHostEventSource, ensureHostApiToken } from './host-api';
import type {
TeacherAvailability,
TeacherCatalog,
TeacherDefinition,
TeacherSend,
TeacherTopic,
@@ -12,6 +13,7 @@ export function teacherTopicsPath(projectId: string, sourceId: string) {
: `/api/coding/projects/${encodeURIComponent(projectId)}/conversations/${encodeURIComponent(sourceId)}/teacher-topics`;
}
export const teacherApi = {
catalog: () => hostApiFetch<TeacherCatalog>('/api/coding/teacher/teachers'),
config: () =>
hostApiFetch<TeacherAvailability & { definition: TeacherDefinition | null }>(
'/api/coding/teacher/config'
@@ -21,10 +23,10 @@ export const teacherApi = {
'/api/coding/teacher-preview?draftRevision=' + draftRevision
),
list: (base: string) => hostApiFetch<TeacherTopicList>(base),
create: (base: string, draftRevision?: number, sampleContext?: string) =>
create: (base: string, draftRevision?: number, sampleContext?: string, teacherVersion?: number) =>
hostApiFetch<TeacherTopic>(base, {
method: 'POST',
body: JSON.stringify({ draftRevision, sampleContext }),
body: JSON.stringify({ draftRevision, sampleContext, teacherVersion }),
}),
read: (base: string, id: string) =>
hostApiFetch<TeacherTopic>(base + '/' + encodeURIComponent(id)),

View File

@@ -3,6 +3,7 @@ import { MessageCircle, Plus, X } from 'lucide-react';
import { teacherApi, teacherTopicsPath } from '@/lib/coding-teacher';
import type {
TeacherDefinition,
TeacherCatalog,
TeacherReference,
TeacherSend,
TeacherTopic,
@@ -36,6 +37,9 @@ export function TeacherChatPanel({
const [error, setError] = useState('');
const [busy, setBusy] = useState(true);
const [enabled, setEnabled] = useState(true);
const [legacyEnabled, setLegacyEnabled] = useState(true);
const [teachers, setTeachers] = useState<TeacherCatalog['items']>([]);
const [selectedTeacherVersion, setSelectedTeacherVersion] = useState<number>();
const pending = useRef<TeacherSend | null>(null);
const generation = useRef(0);
const topicRef = useRef(topic);
@@ -66,6 +70,17 @@ export function TeacherChatPanel({
if (alive) {
setDefinition(config.definition);
setEnabled(config.enabled);
setLegacyEnabled(config.enabled);
}
if (!draftRevision) {
const catalog = await teacherApi.catalog();
if (alive) {
setTeachers(catalog.items);
const selected = catalog.items.find((item) => item.is_default) ?? catalog.items[0];
setSelectedTeacherVersion(selected?.version);
setEnabled(catalog.items.length > 0);
if (selected) setDefinition(selected.definition);
}
}
} catch (e) {
if (alive) setError(e instanceof Error ? e.message : '老师配置暂不可用');
@@ -155,7 +170,12 @@ export function TeacherChatPanel({
setBusy(true);
setError('');
try {
const next = await teacherApi.create(base, draftRevision, sampleContext);
const next = await teacherApi.create(
base,
draftRevision,
sampleContext,
selectedTeacherVersion
);
if (version === generation.current) {
consume(next);
setDefinition(next.definition);
@@ -177,9 +197,15 @@ export function TeacherChatPanel({
let current = topicRef.current;
try {
if (!current) {
current = await teacherApi.create(base, draftRevision, sampleContext);
current = await teacherApi.create(
base,
draftRevision,
sampleContext,
selectedTeacherVersion
);
if (version !== generation.current) return;
consume(current);
setDefinition(current.definition);
}
const previous = pending.current;
const input =
@@ -206,6 +232,11 @@ export function TeacherChatPanel({
const running = topic?.requests.find(
(request) => request.status === 'preparing' || request.status === 'running'
);
const topicEnabled = !topic
? enabled
: topic.definition.config_id
? teachers.some((item) => item.teacher_id === topic.definition.config_id)
: legacyEnabled;
return (
<aside
className="flex h-full min-h-0 w-full flex-col border-l border-border bg-background"
@@ -243,6 +274,25 @@ export function TeacherChatPanel({
</button>
)}
</header>
{!draftRevision && teachers.length > 0 && (
<label className="mx-3 mt-2 text-xs text-muted-foreground">
新话题使用的老师
<select
aria-label="新话题使用的老师"
value={selectedTeacherVersion ?? ''}
disabled={busy}
onChange={(event) => setSelectedTeacherVersion(Number(event.target.value))}
className="mt-1 w-full rounded border bg-background p-2 text-foreground"
>
{teachers.map((item) => (
<option key={item.teacher_id} value={item.version}>
{item.definition.name}
{item.is_default ? '(默认)' : ''}
</option>
))}
</select>
</label>
)}
{topics.items.length > 0 && (
<select
aria-label="老师话题"
@@ -293,6 +343,11 @@ export function TeacherChatPanel({
{request.response ||
(['running', 'preparing'].includes(request.status) ? '老师正在思考…' : '')}
</div>
{request.progress && ['running', 'preparing'].includes(request.status) && (
<p role="status" className="text-xs text-muted-foreground">
{request.progress}
</p>
)}
{request.omittedMessages > 0 && (
<p className="text-xs text-muted-foreground">
本轮上下文已省略 {request.omittedMessages} 条较早消息。
@@ -300,7 +355,9 @@ export function TeacherChatPanel({
)}
{(request.truncatedMessages ?? 0) > 0 && (
<p className="text-xs text-muted-foreground">
{draftRevision ? '较长的上下文已节选。' : '较长的上下文已节选,老师可按需读取原文。'}
{draftRevision
? '较长的上下文已节选。'
: '较长的上下文已节选,老师可按需读取原文。'}
</p>
)}
{request.error && <p className="text-xs text-destructive">{request.error}</p>}
@@ -338,7 +395,7 @@ export function TeacherChatPanel({
</button>
</div>
)}
{!enabled && (
{!topicEnabled && (
<p className="px-4 text-xs text-muted-foreground">老师暂未开放,历史仍可查看。</p>
)}
<form
@@ -385,7 +442,7 @@ export function TeacherChatPanel({
) : (
<button
type="submit"
disabled={busy || !enabled || !text.trim()}
disabled={busy || !topicEnabled || !text.trim()}
className="rounded-lg bg-brand px-4 py-2 text-xs text-white disabled:opacity-40"
>
{busy ? '正在发送…' : '提问'}

View File

@@ -447,6 +447,10 @@ async function installCodingFirstChatHost(
});
const teacherDefinition = {schema_version:1,teacher_id:'coding-teacher',name:'代码老师',description:'',avatar_id:'avatar-01',welcome_message:'一起理解代码',suggested_questions:['解释当前代码'],system_prompt:'教学',skills:[],model:{model_id:'model-a',reasoning_choice:{mode:'default'}},limits:{max_input_tokens:8000,max_output_tokens:1500}};
const teachers = [
{ teacher_id: 'teacher-code', version: 1, is_default: true, definition: { ...teacherDefinition, config_id: 'teacher-code', runtime: 'yuxi', system_prompt: '', yuxi: { agent_slug: 'code', agent_version: 2 } } },
{ teacher_id: 'teacher-algorithm', version: 9, is_default: false, definition: { ...teacherDefinition, config_id: 'teacher-algorithm', runtime: 'yuxi', name: '算法老师', system_prompt: '', yuxi: { agent_slug: 'algorithm', agent_version: 4 } } },
];
let teacherTopic: Record<string, unknown> | null = null;
ipcMain.removeHandler('hostapi:fetch');
ipcMain.handle('hostapi:fetch', async (
@@ -521,9 +525,11 @@ async function installCodingFirstChatHost(
}
if (path === '/api/coding/teacher/config') return respond({enabled:true,revision:1,published_version:1,definition:teacherDefinition});
if (path === '/api/coding/teacher/teachers') return respond({ items: teachers });
if (path.endsWith('/teacher-topics') && method === 'GET') return respond({items:teacherTopic?[{id:teacherTopic.id,title:'老师话题'}]:[],lastSelectedTopicId:teacherTopic?.id??null});
if (path.endsWith('/teacher-topics') && method === 'POST') {
teacherTopic={schemaVersion:1,revision:0,id:'teacher-topic',accountId:'e2e',projectId:project.id,sourceConversationId:conversation.id,definition:teacherDefinition,version:1,createdAt:now,updatedAt:now,requests:[]};
const selected = teachers.find(item => item.version === body?.teacherVersion) ?? teachers[0];
teacherTopic={schemaVersion:1,revision:0,id:'teacher-topic',accountId:'e2e',projectId:project.id,sourceConversationId:conversation.id,definition:selected.definition,version:selected.version,createdAt:now,updatedAt:now,requests:[]};
return respond(teacherTopic,201);
}
if (path.endsWith('/teacher-topics/teacher-topic/messages') && method === 'POST') {
@@ -1723,15 +1729,18 @@ test('project teacher side chat returns advice to the coding draft without submi
const teacher=page.getByTestId('teacher-chat-panel');
await expect(teacher.getByText('一起理解代码')).toBeVisible();
await expect(teacher.getByText('结合当前会话和项目文件答疑')).toBeVisible();
await teacher.getByRole('combobox', { name: '新话题使用的老师' }).selectOption('9');
await teacher.getByRole('textbox',{name:'向老师提问'}).fill('帮我理解当前代码');
await teacher.getByRole('button',{name:'提问',exact:true}).click();
await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
await expect(teacher.getByRole('heading', { name: '算法老师' })).toBeVisible();
await expect(teacher.getByText('较长的上下文已节选,老师可按需读取原文。')).toBeVisible();
await teacher.getByRole('button',{name:'带回主会话草稿'}).click();
await expect(composer).toHaveValue('保留我的草稿\n\n先理解状态如何随点击变化,再修改代码。');
const requests=(await readState(electronApp)).captured;
expect(requests.filter(item=>item.path.endsWith('/prompt')&&item.method==='POST')).toHaveLength(0);
expect(requests.filter(item=>item.path.endsWith('/messages')&&item.method==='POST')).toHaveLength(1);
expect(requests.find(item=>item.path.endsWith('/teacher-topics')&&item.method==='POST')?.body?.teacherVersion).toBe(9);
await page.screenshot({path:test.info().outputPath('project-teacher-side-chat.png')});
await teacher.getByRole('button',{name:'关闭老师'}).click();await expect(teacher).toHaveCount(0);
await page.getByRole('button',{name:'问老师',exact:true}).click();

View File

@@ -0,0 +1,287 @@
// @vitest-environment node
import { afterEach, expect, it, vi } from 'vitest';
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import {
prepareCloudTeacher,
type TeacherCloudTransport,
} from '../../electron/coding-teacher/cloud-runner';
import { TeacherError } from '../../electron/coding-teacher/config-client';
import type { TeacherTopic } from '../../shared/coding-teacher';
const roots: string[] = [];
afterEach(async () => {
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true });
});
const requestId = '11111111-1111-4111-8111-111111111111';
async function fixture() {
const projectPath = await mkdtemp(path.join(tmpdir(), 'cloud-teacher-'));
roots.push(projectPath);
await mkdir(path.join(projectPath, 'src'));
await writeFile(path.join(projectPath, 'src/game.ts'), 'const gravity = 0.6;');
const topic: TeacherTopic = {
id: '22222222-2222-4222-8222-222222222222',
revision: 0,
schemaVersion: 1,
accountId: 'student',
projectId: 'project',
sourceConversationId: 'pi-session',
version: 9,
createdAt: 'now',
updatedAt: 'now',
requests: [],
definition: {
runtime: 'yuxi',
schema_version: 1,
teacher_id: 'coding-teacher',
name: '老师',
description: '',
avatar_id: 'avatar-01',
welcome_message: '',
suggested_questions: [],
system_prompt: '',
skills: [],
model: { model_id: 'deepseek-flash', reasoning_choice: { mode: 'default' } },
limits: { max_input_tokens: 8000, max_output_tokens: 4096 },
yuxi: { agent_slug: 'teacher', agent_version: 4 },
},
};
const assertCurrent = vi.fn();
const access = {
projectPath,
assertCurrent,
source: {
messages: [{ id: 'pi-message', role: 'user' as const, text: '创建小游戏\n调整重力' }],
cursor: { workerGeneration: 1, seq: 3 },
capturedAt: 'now',
},
};
const account = { id: 'student', binding: { accountKey: 'student', epoch: 1 } };
const progress = vi.fn(),
saveRequest = vi.fn();
return { topic, access, account, progress, saveRequest };
}
it('returns all three local reads with matching ids, then displays the resumed reply', async () => {
const f = await fixture();
const results: unknown[] = [];
const transport: TeacherCloudTransport = {
json: vi.fn(async (url, body) => {
if (url === '/questions') {
expect(body).toMatchObject({
teacher_version: 9,
request_id: requestId,
thread_id: f.topic.id,
local_context: {
id: requestId,
scope: { project_id: 'project', source_session_id: 'pi-session' },
},
});
expect(JSON.stringify(body)).not.toContain(f.access.projectPath);
return { request_id: 'cloud-request', run_id: 'run-1' };
}
if (url === '/runs/run-1')
return {
status: 'interrupted',
interrupt: {
source: 'client_read_tools',
context_id: requestId,
calls: [
{ tool_call_id: 'ls', name: 'list_project_files', arguments: { path: '.' } },
{
tool_call_id: 'file',
name: 'read_project_file',
arguments: { path: 'src/game.ts' },
},
{
tool_call_id: 'chat',
name: 'read_conversation',
arguments: { message_id: 'pi-message', start_line: 2, line_count: 1 },
},
],
},
};
if (url.endsWith('/tool-results')) {
results.push(body);
return { run_id: 'run-2' };
}
if (url === '/runs/run-2')
return { status: 'completed', output: '建议调整 src/game.ts:1 的重力。' };
throw new Error('unexpected ' + url);
}),
events: vi.fn(),
};
const model = prepareCloudTeacher(
f.account,
f.topic,
requestId,
f.access,
f.progress,
f.saveRequest,
transport
);
const text = vi.fn();
await model.run(
[
{ role: 'system', content: '旧本地提示词不能上传' },
{ role: 'user', content: '项目有问题吗?' },
],
new AbortController().signal,
text
);
expect(results).toEqual([
{
context_id: requestId,
results: [
expect.objectContaining({
tool_call_id: 'ls',
status: 'success',
content: expect.stringContaining('src/'),
}),
expect.objectContaining({
tool_call_id: 'file',
status: 'success',
content: expect.stringContaining('const gravity = 0.6'),
}),
expect.objectContaining({
tool_call_id: 'chat',
status: 'success',
content: expect.stringContaining('2: 调整重力'),
}),
],
},
]);
expect(f.saveRequest).toHaveBeenCalledWith('cloud-request');
expect(text).toHaveBeenCalledWith('建议调整 src/game.ts:1 的重力。');
expect(f.progress).toHaveBeenCalledWith('正在读取项目与会话…');
});
it('rejects a stale context before reading or returning any project data', async () => {
const f = await fixture();
const transport: TeacherCloudTransport = {
events: vi.fn(),
json: vi.fn(async (url) => {
if (url === '/questions') return { request_id: 'question', run_id: 'one' };
if (url.endsWith('/cancel')) return { status: 'cancelled' };
return {
status: 'interrupted',
interrupt: {
source: 'client_read_tools',
context_id: 'another-question',
calls: [{ name: 'read_project_file', arguments: { path: 'src/game.ts' } }],
},
};
}),
};
const run = prepareCloudTeacher(
f.account,
f.topic,
requestId,
f.access,
f.progress,
f.saveRequest,
transport
);
await expect(
run.run([{ role: 'user', content: '检查代码' }], new AbortController().signal, vi.fn())
).rejects.toThrow('读取请求已失效');
expect(transport.json).not.toHaveBeenCalledWith(
expect.stringContaining('/tool-results'),
expect.anything(),
expect.anything()
);
expect(transport.json).toHaveBeenCalledWith(
'/questions/' + requestId + '/cancel',
{},
expect.anything()
);
});
it('follows a saved continuation without executing an old tool batch again', async () => {
const f = await fixture();
const transport: TeacherCloudTransport = {
events: vi.fn(),
json: vi.fn(async (url) => {
if (url === '/questions') return { request_id: 'question', run_id: 'one' };
if (url === '/runs/one') return { status: 'interrupted', continued_run_id: 'two' };
return { status: 'completed', output: '恢复后的回答' };
}),
};
const text = vi.fn();
await prepareCloudTeacher(
f.account,
f.topic,
requestId,
f.access,
f.progress,
f.saveRequest,
transport
).run([{ role: 'user', content: '继续' }], new AbortController().signal, text);
expect(text).toHaveBeenCalledWith('恢复后的回答');
expect(transport.json).toHaveBeenCalledTimes(3);
});
it('stops by the original question id even when submission acknowledgment is lost', async () => {
const f = await fixture();
const transport: TeacherCloudTransport = {
events: vi.fn(),
json: vi.fn(async (url) => {
if (url === '/questions') throw new TeacherError(502, 'connection', '连接中断');
return { status: 'cancelled' };
}),
};
await expect(
prepareCloudTeacher(
f.account,
f.topic,
requestId,
f.access,
f.progress,
f.saveRequest,
transport
).run([{ role: 'user', content: '检查' }], new AbortController().signal, vi.fn())
).rejects.toThrow('连接中断');
expect(transport.json).toHaveBeenCalledWith(
'/questions/' + requestId + '/cancel',
{},
expect.anything()
);
});
it('reconnects from the last cursor and fills the final message after an earlier assistant preamble', async () => {
const f = await fixture();
let reads = 0,
streams = 0;
const text: string[] = [];
const transport: TeacherCloudTransport = {
json: vi.fn(async (url) => {
if (url === '/questions') return { request_id: 'question', run_id: 'one' };
return ++reads < 3 ? { status: 'running' } : { status: 'completed', output: '最终建议' };
}),
events: vi.fn(async (url, _signal, accept) => {
const message = (id: string, content: string) => ({
payload: { items: [{ stream_event: { type: 'message_delta', message_id: id, content } }] },
});
if (++streams === 1) {
accept('message', message('preamble', '我先检查。'), '1-0');
throw new Error('connection interrupted');
}
expect(url).toContain('after_seq=1-0');
accept('message', message('answer', '最终'), '2-0');
}),
};
await prepareCloudTeacher(
f.account,
f.topic,
requestId,
f.access,
f.progress,
f.saveRequest,
transport
).run([{ role: 'user', content: '检查' }], new AbortController().signal, (delta) =>
text.push(delta)
);
expect(text.join('')).toBe('我先检查。\n\n最终建议');
expect(f.progress).toHaveBeenCalledWith('连接中断,正在恢复老师回复…');
});

View File

@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TeacherChatPanel } from '@/pages/Chat/TeacherChatPanel';
import type { TeacherDefinition, TeacherTopic } from '../../shared/coding-teacher';
const api = vi.hoisted(() => ({
catalog: vi.fn(),
config: vi.fn(),
preview: vi.fn(),
list: vi.fn(),
@@ -52,6 +53,7 @@ beforeEach(() => {
vi.resetAllMocks();
streams = new Map();
api.config.mockResolvedValue({ enabled: true, published_version: 1, revision: 1, definition });
api.catalog.mockResolvedValue({ items: [{ teacher_id: 'one', version: 1, definition, is_default: true }] });
api.list.mockResolvedValue({
items: [
{ id: 'first', title: '第一个' },

View File

@@ -56,7 +56,7 @@ afterEach(async () => {
await Promise.all(services.splice(0).map((service) => service.dispose()));
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
async function fixture({ durableSource = false, sourceContext = context, liveModel = false } = {}) {
async function fixture({ durableSource = false, sourceContext = context, liveModel = false, cloudTeacher = false } = {}) {
const root = await mkdtemp(path.join(tmpdir(), 'coding-teacher-'));
roots.push(root);
const projects = new CodingProjectService(
@@ -100,6 +100,9 @@ async function fixture({ durableSource = false, sourceContext = context, liveMod
if (!accountCurrent) throw new TeacherError(401, 'teacher_account_changed', '账号变化');
},
availability: async () => ({ enabled, published_version: version, revision: version }),
catalog: cloudTeacher ? async () => ({ items: [{ teacher_id: 'cloud-teacher', version: 9, is_default: true,
definition: { ...definition, config_id: 'cloud-teacher', runtime: 'yuxi' as const, system_prompt: '', skills: [],
yuxi: { agent_slug: 'teacher', agent_version: 2 } } }] }) : undefined,
version: async (_account, v) => ({
version: v,
payload: { ...definition, name: '老师 v' + v },
@@ -132,6 +135,42 @@ async function fixture({ durableSource = false, sourceContext = context, liveMod
};
}
describe('cloud coding teacher', () => {
it('wires the Yuxi topic through scoped credentials and local tools without a local prompt or model loop', async () => {
const f = await fixture({ cloudTeacher: true });
await writeFile(path.join(f.created.project.path, 'counter.ts'), 'let count = 42;');
vi.spyOn(teacherCloud, 'assertTeacherAccount').mockReturnValue(undefined);
vi.spyOn(teacherCloud, 'teacherCloudRequest').mockResolvedValue({
scope: 'makelore-teachers', access_token: 'teacher-test', api_base_url: 'https://teacher.invalid',
expires_at: Math.floor(Date.now() / 1000) + 120,
});
const id = '22222222-2222-4222-8222-222222222222';
const requests: Array<{ path: string; body: unknown }> = [];
vi.spyOn(teacherTransport, 'proxyAwareFetch').mockImplementation(async (url, init) => {
const pathname = new URL(String(url)).pathname.replace('/api/makelore/teachers', '');
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
requests.push({ path: pathname, body });
if (pathname === '/questions') return Response.json({ request_id: 'cloud-question', run_id: 'parent' });
if (pathname === '/runs/parent') return Response.json({ status: 'interrupted', interrupt: {
source: 'client_read_tools', context_id: id, calls: [{ tool_call_id: 'read-counter', name: 'read_project_file', arguments: { path: 'counter.ts' } }],
} });
if (pathname.endsWith('/tool-results')) return Response.json({ run_id: 'continued' });
if (pathname === '/runs/continued') return Response.json({ status: 'completed', output: '计数器从 42 开始。' });
throw new Error('unexpected cloud call: ' + pathname);
});
try {
const topic = await f.service.create(f.scope, undefined, undefined, 9);
await f.service.send(f.scope, topic.id, { requestId: id, text: '解释项目里的计数器' });
await vi.waitFor(async () => expect((await f.service.read(f.scope, topic.id)).requests[0].status).toBe('completed'));
const saved = (await f.service.read(f.scope, topic.id)).requests[0];
expect(saved.response).toBe('计数器从 42 开始。');
expect(saved.cloudRequestId).toBe('cloud-question');
expect(JSON.stringify(requests[0].body)).toContain('创建计数器');
expect(requests.find(item => item.path.endsWith('/tool-results'))?.body).toMatchObject({
context_id: id, results: [{ tool_call_id: 'read-counter', status: 'success', content: expect.stringContaining('let count = 42;') }],
});
expect(f.run).not.toHaveBeenCalled();
} finally { vi.restoreAllMocks(); }
});
it('connects the scoped service, source context, file tools and model continuation', async () => {
const f = await fixture({ liveModel: true });
await writeFile(path.join(f.created.project.path, 'counter.ts'), 'let count = 42;');