diff --git a/.project-docs/30-worklog/tasks/20260923-yuxi-teachers-client-53ba33e2.md b/.project-docs/30-worklog/tasks/20260923-yuxi-teachers-client-53ba33e2.md new file mode 100644 index 00000000..76ba1a1e --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260923-yuxi-teachers-client-53ba33e2.md @@ -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. diff --git a/README.md b/README.md index 46903785..fa396837 100644 --- a/README.md +++ b/README.md @@ -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 完成。 ## 运行内核与项目数据 diff --git a/electron/api/routes/coding-teacher.ts b/electron/api/routes/coding-teacher.ts index 2c36d4e9..8638b15a 100644 --- a/electron/api/routes/coding-teacher.ts +++ b/electron/api/routes/coding-teacher.ts @@ -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') { diff --git a/electron/coding-teacher/cloud-runner.ts b/electron/coding-teacher/cloud-runner.ts new file mode 100644 index 00000000..aaf52ef6 --- /dev/null +++ b/electron/coding-teacher/cloud-runner.ts @@ -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; +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; + events( + path: string, + signal: AbortSignal, + accept: (event: string, data: Json, id: string) => void + ): Promise; +} + +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( + 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, + 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 { + 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); + } + } + }, + }; +} diff --git a/electron/coding-teacher/config-client.ts b/electron/coding-teacher/config-client.ts index c1a5f13f..405feda1 100644 --- a/electron/coding-teacher/config-client.ts +++ b/electron/coding-teacher/config-client.ts @@ -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( account: TeacherAccount, - pathname: string + pathname: string, + method: 'GET' | 'POST' = 'GET' ): Promise { assertTeacherAccount(account); const token = await getValidWorksSquareAccessToken(); @@ -36,6 +37,7 @@ export async function teacherCloudRequest( 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 { } export const teacherAvailability = (account: TeacherAccount) => teacherCloudRequest(account, '/api/coding-teacher/config'); +export const teacherCatalog = (account: TeacherAccount) => + teacherCloudRequest(account, '/api/coding-teacher/teachers'); export const teacherVersion = (account: TeacherAccount, version: number) => teacherCloudRequest<{ version: number; payload: TeacherDefinition }>( account, diff --git a/electron/coding-teacher/read-tools.ts b/electron/coding-teacher/read-tools.ts index f9960dfb..e7e924c2 100644 --- a/electron/coding-teacher/read-tools.ts +++ b/electron/coding-teacher/read-tools.ts @@ -56,9 +56,14 @@ export function createTeacherReadTools(access: TeacherReadAccess) { return { definitions: teacherReadToolDefinitions, async execute(name: string, rawArguments: string, signal: AbortSignal, maxBytes = 2400): Promise { + 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; 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 }; }, }; } diff --git a/electron/coding-teacher/service.ts b/electron/coding-teacher/service.ts index 8e7126ca..491b2839 100644 --- a/electron/coding-teacher/service.ts +++ b/electron/coding-teacher/service.ts @@ -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; } 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 { 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, diff --git a/shared/coding-teacher.ts b/shared/coding-teacher.ts index d68828b9..0a4d9452 100644 --- a/shared/coding-teacher.ts +++ b/shared/coding-teacher.ts @@ -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; diff --git a/src/lib/coding-teacher.ts b/src/lib/coding-teacher.ts index a98bddb6..688e34b7 100644 --- a/src/lib/coding-teacher.ts +++ b/src/lib/coding-teacher.ts @@ -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('/api/coding/teacher/teachers'), config: () => hostApiFetch( '/api/coding/teacher/config' @@ -21,10 +23,10 @@ export const teacherApi = { '/api/coding/teacher-preview?draftRevision=' + draftRevision ), list: (base: string) => hostApiFetch(base), - create: (base: string, draftRevision?: number, sampleContext?: string) => + create: (base: string, draftRevision?: number, sampleContext?: string, teacherVersion?: number) => hostApiFetch(base, { method: 'POST', - body: JSON.stringify({ draftRevision, sampleContext }), + body: JSON.stringify({ draftRevision, sampleContext, teacherVersion }), }), read: (base: string, id: string) => hostApiFetch(base + '/' + encodeURIComponent(id)), diff --git a/src/pages/Chat/TeacherChatPanel.tsx b/src/pages/Chat/TeacherChatPanel.tsx index a570dcb4..d7b47cb5 100644 --- a/src/pages/Chat/TeacherChatPanel.tsx +++ b/src/pages/Chat/TeacherChatPanel.tsx @@ -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([]); + const [selectedTeacherVersion, setSelectedTeacherVersion] = useState(); const pending = useRef(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 (