feat: integrate pixel teacher presence and resilient classroom preview

This commit is contained in:
鲨鱼辣椒
2026-09-23 18:23:53 +08:00
parent 12d800ec51
commit 7951cca700
60 changed files with 3313 additions and 180 deletions

View File

@@ -61,8 +61,10 @@ export function compileTeacherContext(
].join('\n\n'),
};
const current: TeacherModelMessage = {
role: 'user',
content: [
role: intent === 'check-in' ? 'system' : 'user',
content: intent === 'check-in'
? '本轮是老师定时主动关心,不是学生提问。依据来源操作对话中已完成的文字和老师咨询历史,自然地说一段简短中文关心、具体建议或思考引导,约 120 字,最多问一个问题,不要求学生立即回答。只围绕已有证据,不重复刚说过的内容,不整理待办、不替学生作决定;没有实际看到或操作作品,不能假装看到了画面、运行或试玩过作品。直接说给学生听,不提定时检查、系统触发等技术过程。'
: [
...references.map(
(ref) =>
'明确引用' +
@@ -97,13 +99,13 @@ export function compileTeacherContext(
},
]
: []),
...exchanges.flatMap((request) => [
{
...exchanges.flatMap((request): TeacherModelMessage[] => [
...(request.intent === 'check-in' ? [] : [{
role: 'user' as const,
content: [...request.references.map((ref) => '明确引用:\n' + ref.text), request.text].join(
'\n\n'
),
},
}]),
{
role: 'assistant' as const,
content: [
@@ -121,8 +123,7 @@ export function compileTeacherContext(
omitted++;
}
while (estimateTeacherTokens(build()) > maxInputTokens && exchanges.length > 1) {
exchanges.shift();
omitted += 2;
omitted += exchanges.shift()?.intent === 'check-in' ? 1 : 2;
}
const messages = build();
if (estimateTeacherTokens(messages) > maxInputTokens)

View File

@@ -1,4 +1,4 @@
import { randomUUID } from 'node:crypto';
import { createHash, randomUUID } from 'node:crypto';
import { readdir, rm } from 'node:fs/promises';
import path from 'node:path';
import { EventEmitter } from 'node:events';
@@ -6,12 +6,15 @@ import type { CodingProjectService } from '../coding-projects/project-service';
import type { CodingConversationRuntime } from '../coding-runtime/contracts';
import type {
ConsultationRole,
TeacherCheckInInput,
TeacherCheckInResult,
TeacherDefinition,
TeacherReference,
TeacherSend,
TeacherSourceContext,
TeacherTopic,
} from '../../shared/coding-teacher';
import { TEACHER_CHECK_IN_INTERVAL_MS, TEACHER_UNCHANGED_CHECK_IN_INTERVAL_MS } from '../../shared/coding-teacher';
import {
currentTeacherAccount,
assertTeacherAccount,
@@ -55,7 +58,7 @@ export class CodingTeacherService {
private readonly tails = new Map<string, Promise<unknown>>();
private readonly active = new Map<
string,
{ account: TeacherAccount; controller: AbortController; sourceId?: string; projectId: string }
{ account: TeacherAccount; controller: AbortController; sourceId?: string; projectId: string; role: ConsultationRole }
>();
private readonly finishes = new Map<string, Promise<void>>();
private readonly deletingSources = new Set<string>();
@@ -119,6 +122,9 @@ export class CodingTeacherService {
private key(account: TeacherAccount, scope: TeacherScope, id: string) {
return account.id + ':' + scope.projectId + ':' + scope.sourceId + ':' + (scope.role ?? 'teacher') + ':' + id;
}
private acceptanceKey(account: TeacherAccount, scope: TeacherScope) {
return account.id + ':' + scope.projectId + ':teacher-acceptance';
}
private async serialize<T>(key: string, operation: () => Promise<T>): Promise<T> {
const previous = this.tails.get(key) ?? Promise.resolve();
const next = previous.catch(() => undefined).then(operation);
@@ -196,23 +202,102 @@ export class CodingTeacherService {
throw new TeacherError(404, 'teacher_topic_not_found', '老师话题不存在。');
return { store, topic };
}
async read(scope: TeacherScope, id: string) {
async read(scope: TeacherScope, id: string, select = true) {
const account = await this.account();
const { store, topic } = await this.readOwned(account, scope, id);
await store.select(id);
if (select) await store.select(id);
return structuredClone(topic);
}
async send(scope: TeacherScope, id: string, input: TeacherSend): Promise<TeacherTopic> {
// Background turns must pass the project-wide cooldown and source checks.
if (input.intent === 'check-in')
throw new TeacherError(422, 'teacher_intent_invalid', '主动关心只能由项目老师检查发起。');
const account = await this.account();
if (scope.projectId !== 'preview' && (scope.role ?? 'teacher') === 'teacher')
return await this.serialize(this.acceptanceKey(account, scope), () => this.sendRequest(account, scope, id, input));
return await this.sendRequest(account, scope, id, input);
}
async checkIn(scope: TeacherScope, input: TeacherCheckInInput): Promise<TeacherCheckInResult> {
if (scope.projectId === 'preview' || scope.sourceId !== 'project' || (scope.role ?? 'teacher') !== 'teacher')
throw new TeacherError(422, 'teacher_intent_invalid', '主动关心只适用于项目里的老师。');
teacherTopicId(input.requestId);
teacherTopicId(input.sourceConversationId);
const account = await this.account();
return await this.serialize(this.acceptanceKey(account, scope), async () => {
this.assertAccount(account);
const store = await this.scopedStore(account, scope);
const list = await store.list();
const topics = await Promise.all(list.items.map(async (item) => (await this.readOwned(account, scope, item.id)).topic));
for (const topic of topics) {
const existing = topic.requests.find((request) => request.id === input.requestId);
if (!existing) continue;
if (existing.intent !== 'check-in' || existing.sourceConversationId !== input.sourceConversationId)
throw new TeacherError(409, 'teacher_request_conflict', '同一请求标识不能用于不同问题。');
this.assertAccount(account);
return { topic: structuredClone(topic) };
}
if ([...this.active.values()].some((run) => run.account.id === account.id
&& run.projectId === scope.projectId && run.role === 'teacher')
|| topics.some((topic) => topic.requests.some((request) => ['preparing', 'running'].includes(request.status))))
return { topic: null, skipped: 'busy' };
const checks = topics.flatMap((topic) => topic.requests.filter((request) => request.intent === 'check-in'));
if (checks.some((request) => Date.now() - Date.parse(request.createdAt) < TEACHER_CHECK_IN_INTERVAL_MS))
return { topic: null, skipped: 'cooldown' };
if (this.deletingSources.has(scope.projectId + ':' + input.sourceConversationId))
throw new TeacherError(404, 'teacher_source_not_found', '来源会话已删除。');
const project = await this.options.projects.getProject(scope.projectId);
const conversation = await this.options.projects.conversationStore(project.path).get(input.sourceConversationId);
if (!conversation)
throw new TeacherError(404, 'teacher_source_not_found', '来源会话不属于当前项目。');
if (conversation.archivedAt) return { topic: null, skipped: 'archived' };
const available = await (this.options.availability ?? teacherAvailability)(account);
this.assertAccount(account);
if (!available.enabled || !available.published_version) return { topic: null, skipped: 'disabled' };
const source = await (this.options.readSource?.({ ...scope, sourceId: input.sourceConversationId })
?? readTeacherSource(this.options.projects, this.options.runtime, this.options.userDataDir, scope.projectId, input.sourceConversationId));
this.assertAccount(account);
const selectedTopic = topics.find((candidate) => candidate.id === list.lastSelectedTopicId);
// Student discussion is progress too; proactive replies themselves must not
// change this digest and cause another identical check-in five minutes later.
const discussion = (selectedTopic?.requests ?? [])
.filter((request) => request.intent !== 'check-in' && request.status === 'completed')
.map(({ id, text, response }) => [id, text, response])
.sort((a, b) => a[0].localeCompare(b[0]));
if (!source.messages.some((message) => message.text.trim()) && discussion.length === 0)
return { topic: null, skipped: 'no-context' };
const fingerprint = createHash('sha256')
.update(JSON.stringify({ source: source.messages.map(({ id, role, text }) => [id, role, text]), discussion }))
.digest('hex');
if (checks.some((request) => request.sourceConversationId === input.sourceConversationId
&& request.status === 'completed'
&& request.checkInSourceFingerprint === fingerprint
&& Date.now() - Date.parse(request.createdAt) < TEACHER_UNCHANGED_CHECK_IN_INTERVAL_MS))
return { topic: null, skipped: 'unchanged' };
const topic = selectedTopic ?? await this.create(scope);
return {
topic: await this.sendRequest(account, scope, topic.id, {
...input, intent: 'check-in', text: '',
}, { source, fingerprint }),
};
});
}
private async sendRequest(
account: TeacherAccount,
scope: TeacherScope,
id: string,
input: TeacherSend,
checkIn?: { source: TeacherSourceContext; fingerprint: string }
): Promise<TeacherTopic> {
teacherTopicId(input.requestId);
const intent = input.intent === undefined ? 'question' : input.intent;
if (!['question', 'suggestions', 'guided-help'].includes(intent))
if (!['question', 'suggestions', 'guided-help', 'check-in'].includes(intent) || (intent === 'check-in' && !checkIn))
throw new TeacherError(422, 'teacher_intent_invalid', '提问方式无效,请重新打开老师后再试。');
if (intent !== 'question' && (
scope.projectId === 'preview' || scope.sourceId !== 'project' || (scope.role ?? 'teacher') !== 'teacher'
))
throw new TeacherError(422, 'teacher_intent_invalid', '这种提问方式只适用于项目里的老师。');
if (input.sourceConversationId !== undefined) teacherTopicId(input.sourceConversationId);
if (typeof input.text !== 'string' || !input.text.trim() || input.text.length > 6000)
if (typeof input.text !== 'string' || (intent !== 'check-in' && !input.text.trim()) || input.text.length > 6000)
throw new TeacherError(422, 'teacher_question_invalid', '请输入 1–6000 字的问题。');
const refs = input.references ?? [];
if (
@@ -228,8 +313,7 @@ export class CodingTeacherService {
refs.reduce((n, ref) => n + ref.text.length, 0) > 12000
)
throw new TeacherError(422, 'teacher_reference_invalid', '引用内容无效或超过 12000 字。');
const account = await this.account(),
key = this.key(account, scope, id);
const key = this.key(account, scope, id);
return await this.serialize(key, async () => {
const { store, topic } = await this.readOwned(account, scope, id);
const existing = topic.requests.find((request) => request.id === input.requestId);
@@ -262,11 +346,14 @@ export class CodingTeacherService {
throw new TeacherError(404, 'teacher_source_not_found', '来源会话已删除。');
if (scope.sourceId === 'project' && sourceId) {
const project = await this.options.projects.getProject(scope.projectId);
if (!(await this.options.projects.conversationStore(project.path).get(sourceId)))
const conversation = await this.options.projects.conversationStore(project.path).get(sourceId);
if (!conversation)
throw new TeacherError(404, 'teacher_source_not_found', '来源会话不属于当前项目。');
if (checkIn && conversation.archivedAt)
throw new TeacherError(409, 'teacher_source_archived', '来源会话已归档。');
}
const source: TeacherSourceContext =
scope.projectId === 'preview'
checkIn ? checkIn.source : scope.projectId === 'preview'
? {
messages: topic.sampleContext
? [{ id: 'preview', role: 'user', text: topic.sampleContext }]
@@ -304,6 +391,7 @@ export class CodingTeacherService {
account,
topic.definition
);
if (checkIn) this.assertAccount(account);
const compiled = compileTeacherContext(
topic.definition,
source,
@@ -317,10 +405,20 @@ export class CodingTeacherService {
// is being deleted. Project consultations must recheck the actual source.
if (sourceId && this.deletingSources.has(scope.projectId + ':' + sourceId))
throw new TeacherError(404, 'teacher_source_not_found', '来源会话已删除。');
if (checkIn && sourceId) {
const project = await this.options.projects.getProject(scope.projectId);
const conversation = await this.options.projects.conversationStore(project.path).get(sourceId);
if (!conversation)
throw new TeacherError(404, 'teacher_source_not_found', '来源会话已删除。');
if (conversation.archivedAt)
throw new TeacherError(409, 'teacher_source_archived', '来源会话已归档。');
this.assertAccount(account);
}
const request = {
id: input.requestId,
intent,
...(input.sourceConversationId ? { sourceConversationId: input.sourceConversationId } : {}),
...(checkIn ? { checkInSourceFingerprint: checkIn.fingerprint } : {}),
text: input.text,
references,
createdAt: new Date().toISOString(),
@@ -345,7 +443,7 @@ export class CodingTeacherService {
// than leaving a permanently preparing request in the project topic.
if (sourceId && this.deletingSources.has(scope.projectId + ':' + sourceId))
controller.abort();
this.active.set(key, { account, controller, sourceId, projectId: scope.projectId });
this.active.set(key, { account, controller, sourceId, projectId: scope.projectId, role: scope.role ?? 'teacher' });
const release = this.options.acquireLease?.(key) ?? (() => undefined);
const finish = async () => {
const current = topic.requests.at(-1)!;

View File

@@ -33,7 +33,8 @@ export class TeacherTopicStore {
const items = topics
.map((topic) => ({
id: topic.id,
title: topic.requests[0]?.text.slice(0, 32) || '新话题',
title: topic.requests.find((request) => request.intent !== 'check-in')?.text.slice(0, 32)
|| (topic.requests.some((request) => request.intent === 'check-in') ? '和老师聊聊' : '新话题'),
updatedAt: topic.updatedAt,
version: topic.version,
}))