Merge Yuxi teacher support with classroom discussions
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
鲨鱼辣椒
2026-09-24 10:27:36 +08:00
34 changed files with 2333 additions and 105 deletions

View File

@@ -22,6 +22,7 @@ import {
teacherAvailability,
teacherVersion,
teacherPreview,
teacherCatalog,
TeacherError,
type TeacherAccount,
} from './config-client';
@@ -29,6 +30,7 @@ import { TeacherTopicStore, teacherTopicId } from './store';
import { compileTeacherContext } from './context';
import { prepareTeacherModel } from './model-runner';
import { consultationDefinition } from './consultation-role';
import { prepareCloudTeacher } from './cloud-runner';
import { readTeacherSource } from './source-reader';
import { parseTeacherSuggestions } from './suggestions';
import { applyDiscussionReply, discussionInstructions, editDiscussion, validateDiscussionContext } from './discussion';
@@ -53,6 +55,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 {
@@ -93,6 +97,9 @@ export class CodingTeacherService {
: null;
return { ...status, definition: published ? consultationDefinition(published.payload, role) : null };
}
async catalog() {
return (this.options.catalog ?? teacherCatalog)(await this.account());
}
private async scopedStore(
account: TeacherAccount,
scope: TeacherScope
@@ -144,7 +151,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);
@@ -160,6 +168,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 = consultationDefinition(selected.definition, scope.role ?? 'teacher');
version = selected.version;
} else {
const status = await (this.options.availability ?? teacherAvailability)(account);
if (!status.enabled || !status.published_version)
@@ -346,7 +362,11 @@ export class CodingTeacherService {
const discussionContext = validateDiscussionContext(topic, input.discussion);
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', '老师已停用,历史仍可查看。');
@@ -395,22 +415,40 @@ 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
);
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,
(scope.role ?? 'teacher') === 'teacher' ? access : undefined,
{ finalOnly: structuredReply || intent === 'suggestions' });
if (checkIn) this.assertAccount(account);
const compiled = compileTeacherContext(
topic.definition,
source,
topic.requests,
isCloud ? [] : topic.requests,
input.text,
references,
model.inputLimit,
intent,
structuredReply ? discussionInstructions(topic, discussionContext) : undefined
structuredReply ? discussionInstructions(topic, discussionContext) : undefined,
scope.projectId !== 'preview'
);
// Reading context and resolving model credentials can yield while a source
// is being deleted. Project consultations must recheck the actual source.
@@ -439,6 +477,7 @@ export class CodingTeacherService {
sourceCapturedAt: source.capturedAt,
includedSourceMessageIds: compiled.includedSourceMessageIds,
omittedMessages: compiled.omittedMessages,
truncatedMessages: compiled.truncatedMessages,
status: 'preparing' as const,
response: '',
};