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

@@ -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;');