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

@@ -2,11 +2,14 @@ import { realpath } from 'node:fs/promises';
import type { IncomingMessage, ServerResponse } from 'node:http';
import type { AgentBrowserBounds, AgentBrowserFaultShape } from '../../../shared/agent-browser';
import { normalizeCodingProjectPath } from '../../coding-projects/project-store';
import { CodingWorkPreviewService } from '../../coding-runtime/work-preview';
import type { HostApiContext } from '../context';
import { hasRendererCapability } from '../renderer-capability';
import { parseJsonBody, sendJson } from '../route-utils';
type AgentBrowserBody = {
conversation_id?: unknown;
request_id?: unknown;
project_id?: unknown;
project_path?: unknown;
url?: unknown;
@@ -28,6 +31,23 @@ type AgentBrowserBody = {
max_bytes?: unknown;
};
const workPreviewServices = new WeakMap<HostApiContext, CodingWorkPreviewService>();
function workPreviewService(ctx: HostApiContext): CodingWorkPreviewService | undefined {
if (!ctx.codingProducts || !ctx.agentBrowser) return undefined;
let preview = workPreviewServices.get(ctx);
if (!preview) {
preview = new CodingWorkPreviewService({
browser: ctx.agentBrowser,
conversations: ctx.codingProducts.conversations,
runtime: ctx.codingProducts.runtime,
ensureActive: (target) => ensureProjectStillActive(ctx, target),
});
workPreviewServices.set(ctx, preview);
}
return preview;
}
class AgentBrowserRouteError extends Error {
constructor(
readonly code: AgentBrowserFaultShape['code'],
@@ -236,6 +256,27 @@ export async function handleAgentBrowserRoutes(
try {
const service = requireService(ctx);
if (url.pathname === '/api/agent-browser/ensure-work' && req.method === 'POST') {
requireRendererPresentation(req);
const body = await readBody(req);
const project = await resolveActiveProject(ctx, undefined, body.project_id, true);
const requestId = nonEmptyString(body.request_id);
if (!requestId || !/^[a-zA-Z0-9-]{1,80}$/.test(requestId)) {
throw new AgentBrowserRouteError('INVALID_REQUEST', '作品请求无效。');
}
if (!ctx.codingProducts) throw new AgentBrowserRouteError('CLOSED', '本地编程服务暂时不可用。', 503);
const preview = workPreviewService(ctx)!;
const release = ctx.lifecycle?.acquireLease({ id: `work-preview:${project.id}:${requestId}`, kind: 'work-preview' });
try {
const result = await preview.ensure(project, requestId, nonEmptyString(body.conversation_id));
await ensureProjectStillActive(ctx, project);
sendJson(res, 200, { success: true, ...result });
} finally {
release?.();
}
return true;
}
if (url.pathname === '/api/agent-browser/state' && req.method === 'GET') {
const project = await resolveActiveProject(
ctx,
@@ -272,6 +313,7 @@ export async function handleAgentBrowserRoutes(
...(body.inject_project_data === true ? { injectProjectData: true } : {}),
});
await ensureProjectStillActive(ctx, project);
workPreviewService(ctx)?.remember(project, browser);
emitState(ctx, 'agent-browser:show', browser);
sendJson(res, 200, { success: true, browser });
return true;
@@ -318,6 +360,7 @@ export async function handleAgentBrowserRoutes(
url: nonEmptyString(body.url),
});
await ensureProjectStillActive(ctx, project);
workPreviewService(ctx)?.remember(project, browser);
emitState(ctx, 'agent-browser:state', browser);
sendJson(res, 200, { success: true, browser });
return true;

View File

@@ -8,7 +8,7 @@ import {
} from '../route-utils';
import { TeacherError } from '../../coding-teacher/config-client';
import type { TeacherScope } from '../../coding-teacher/service';
import type { TeacherSend } from '../../../shared/coding-teacher';
import type { TeacherCheckInInput, TeacherSend } from '../../../shared/coding-teacher';
import { takeTeacherPreviewRevision } from '../../main/app-deep-link';
export async function handleCodingTeacherRoutes(
@@ -26,11 +26,12 @@ export async function handleCodingTeacherRoutes(
const projectTopics = url.pathname.match(
/^\/api\/coding\/projects\/([^/]+)\/(teacher|friend)-topics(?:\/([^/]+))?(?:\/(messages|events|save|requests\/([^/]+)\/cancel))?$/
);
const checkIn = url.pathname.match(/^\/api\/coding\/projects\/([^/]+)\/teacher-check-in$/);
const role = projectTopics?.[2] === 'friend' || url.pathname === '/api/coding/friend/config' ? 'friend' : 'teacher';
const config = url.pathname === '/api/coding/teacher/config' || url.pathname === '/api/coding/friend/config';
const draft = url.pathname === '/api/coding/teacher-preview';
const pending = url.pathname === '/api/coding/teacher-preview/pending-link';
if (!source && !preview && !projectTopics && !config && !draft && !pending) return false;
if (!source && !preview && !projectTopics && !checkIn && !config && !draft && !pending) return false;
if ((config || draft || pending) && req.method !== 'GET') {
sendJson(res, 405, { error: '不支持此操作。' });
return true;
@@ -45,6 +46,14 @@ export async function handleCodingTeacherRoutes(
return true;
}
try {
if (checkIn) {
if (req.method !== 'POST') sendJson(res, 405, { error: '不支持此操作。' });
else sendJson(res, 200, await service.checkIn(
{ projectId: decodeURIComponent(checkIn[1]), sourceId: 'project', role: 'teacher' },
await parseJsonBody<TeacherCheckInInput>(req)
));
return true;
}
if (config && req.method === 'GET') {
sendJson(res, 200, await service.definition(role));
return true;
@@ -74,7 +83,7 @@ export async function handleCodingTeacherRoutes(
return true;
}
if (id && !action && req.method === 'GET') {
sendJson(res, 200, await service.read(scope, id));
sendJson(res, 200, await service.read(scope, id, url.searchParams.get('select') !== 'false'));
return true;
}
if (id && action === 'messages' && req.method === 'POST') {

View File

@@ -1,9 +1,12 @@
import type { ConversationNode } from './contracts';
import { OPEN_WORK_PROMPT } from '../../shared/coding-work-preview';
import { CONTINUE_CODING_PROMPT } from '../../shared/coding-recovery';
/** Only real user messages name a conversation; no model call or tool output. */
export function firstMessageTitle(node: ConversationNode): string | null {
if (node.kind !== 'message' || node.role !== 'user' || node.status !== 'complete') return null;
const text = node.blocks.flatMap((block) => block.kind === 'text' ? [block.text] : []).join('\n').trim();
if (text === OPEN_WORK_PROMPT || text === CONTINUE_CODING_PROMPT) return null;
if (text.startsWith('/')) return null;
const firstLine = text.split(/\r?\n/)[0].replace(/\s+/g, ' ').trim();
return [...firstLine].slice(0, 32).join('')

View File

@@ -3,10 +3,11 @@ import { isAIGatewayUserContextMissing } from '../../../shared/ai-gateway-error-
import { getAIGatewayErrorKind } from '../../../shared/ai-gateway-error-kind';
export function projectPiProviderFailure(message: unknown): CodingRuntimePublicError {
if (typeof message === 'string' && isAIGatewayUserContextMissing(message)) {
if (typeof message === 'string' && (isAIGatewayUserContextMissing(message)
|| message.toLowerCase().includes('works square login session is missing or expired'))) {
return {
code: 'CODING_PROVIDER_AUTH_REQUIRED',
message: '模型服务身份上下文无效,请重试;若仍失败请重新登录。',
message: '登录已失效,请重新登录后继续。',
recoverable: true,
};
}

View File

@@ -321,8 +321,10 @@ function reconcileLiveIds(
if (node.kind === 'message') {
const live = liveNodes.find((candidate) => candidate.kind === 'message'
&& !used.has(candidate.id)
&& (candidate.sourceEntryId === node.sourceEntryId
|| messageSignature(candidate) === messageSignature(node)));
&& Boolean(node.sourceEntryId) && candidate.sourceEntryId === node.sourceEntryId)
?? liveNodes.find((candidate) => candidate.kind === 'message'
&& !used.has(candidate.id) && !candidate.sourceEntryId
&& messageSignature(candidate) === messageSignature(node));
if (!live || live.kind !== 'message') return node;
used.add(live.id);
return {
@@ -383,12 +385,16 @@ export async function projectPiSessionSnapshot(
const path = activePath(response.entries, response.leafId);
const durableNodes = await projectEntries(path, input);
const nodes = reconcileLiveIds(durableNodes, input.snapshot.nodes);
const preserveTerminalRun = input.workerGeneration === input.snapshot.cursor.workerGeneration
&& Boolean(input.snapshot.run.runId)
&& (input.snapshot.run.status === 'error'
|| (input.snapshot.run.status === 'idle' && input.snapshot.run.terminalReason !== undefined));
return {
...structuredClone(input.snapshot),
nodes,
run: state.isStreaming === true
? { ...structuredClone(input.snapshot.run), status: 'running' }
: { status: 'idle' },
: preserveTerminalRun ? structuredClone(input.snapshot.run) : { status: 'idle' },
queue: { items: [] },
context: projectedContext(input, state),
pendingInteractions: [],

View File

@@ -0,0 +1,148 @@
import type { AgentBrowserService } from '../api/context';
import type { CodingConversationService } from './conversation-service';
import type { CodingConversationRuntime } from './contracts';
import { OPEN_WORK_PROMPT, type WorkPreviewResult } from '../../shared/coding-work-preview';
import { codingRecovery } from '../../shared/coding-recovery';
import type { AgentBrowserSnapshot } from '../../shared/agent-browser';
type Project = { id: string; path: string };
type Attempt = {
requestId: string;
conversationId?: string;
submitted?: boolean;
runId?: string;
submittedAt?: number;
result?: WorkPreviewResult;
};
/** Only probe a known loopback work address; never scan ports or follow redirects. */
export async function workPageResponds(address: string): Promise<boolean> {
try {
const url = new URL(address);
if (!['http:', 'https:'].includes(url.protocol)
|| !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)
|| url.username || url.password) return false;
const response = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(2_000) });
await response.body?.cancel();
return response.ok;
} catch {
return false;
}
}
export class CodingWorkPreviewService {
private readonly attempts = new Map<string, Attempt>();
private readonly flights = new Map<string, Promise<WorkPreviewResult>>();
// Native views are disposable during background sleep; the running project
// server and its verified address have a separate lifetime.
private readonly pages = new Map<string, { path: string; url: string }>();
constructor(private readonly deps: {
browser: Pick<AgentBrowserService, 'getSnapshot' | 'open'>;
conversations: Pick<CodingConversationService, 'getConversation' | 'listConversations' | 'createConversation' | 'getSnapshot' | 'acceptPrompt'>;
runtime: Pick<CodingConversationRuntime, 'getDiagnostics'>;
ensureActive(project: Project): Promise<void>;
responds?(url: string): Promise<boolean>;
}) {}
remember(project: Project, snapshot: AgentBrowserSnapshot): void {
if (snapshot.projectId !== project.id || !snapshot.url || !snapshot.browserId
|| snapshot.state !== 'attached' || snapshot.error) return;
this.pages.delete(project.id);
if (this.pages.size >= 100) this.pages.delete(this.pages.keys().next().value!);
this.pages.set(project.id, { path: project.path, url: snapshot.url });
this.attempts.delete(project.id);
// A successful manual/Agent open supersedes a slow older startup check.
this.flights.delete(project.id);
}
async ensure(project: Project, requestId: string, conversationId?: string): Promise<WorkPreviewResult> {
await this.deps.ensureActive(project);
// Serialize browser checks as well as prompt submission for each project.
const existing = this.flights.get(project.id);
if (existing) return existing;
const flight = this.check(project, requestId, conversationId);
this.flights.set(project.id, flight);
try { return await flight; }
finally { if (this.flights.get(project.id) === flight) this.flights.delete(project.id); }
}
private async check(project: Project, requestId: string, conversationId?: string): Promise<WorkPreviewResult> {
const { browser, conversations } = this.deps;
const snapshot = await browser.getSnapshot(project.path);
const responds = this.deps.responds ?? workPageResponds;
const remembered = this.pages.get(project.id);
const address = snapshot.projectId === project.id && snapshot.url
? snapshot.url
: remembered?.path === project.path ? remembered.url : undefined;
if (address && await responds(address)) {
await this.deps.ensureActive(project);
const ready = snapshot.projectId === project.id && snapshot.url === address
&& snapshot.browserId && snapshot.state === 'attached' && !snapshot.error
? snapshot
: await browser.open({ projectId: project.id, projectPath: project.path, url: address, visible: false });
await this.deps.ensureActive(project);
this.remember(project, ready);
return { status: 'ready', browser: ready };
}
if (this.pages.has(project.id) && this.pages.get(project.id) !== remembered) return this.check(project, requestId, conversationId);
this.pages.delete(project.id);
await this.deps.ensureActive(project);
let attempt = this.attempts.get(project.id);
if (attempt?.submitted && attempt.conversationId) {
const current = await conversations.getSnapshot(attempt.conversationId);
if (this.pages.has(project.id)) return this.check(project, requestId, conversationId);
if (!['idle', 'error'].includes(current.run.status)) {
return { status: 'starting', conversation: await conversations.getConversation(attempt.conversationId) };
}
if (attempt.runId && current.run.runId !== attempt.runId && Date.now() - attempt.submittedAt! < 15_000) {
return { status: 'starting', conversation: await conversations.getConversation(attempt.conversationId) };
}
// The accepted action settled without opening a reachable work page.
const errorCode = current.run.error?.code;
attempt.result ??= { status: 'failed', errorCode, message: codingRecovery(errorCode, true).message };
}
if (attempt?.result && attempt.requestId === requestId) return attempt.result;
if (!attempt || attempt.result || attempt.requestId !== requestId) {
attempt = { requestId, conversationId };
// Keep only a bounded number of inactive project attempts.
if (this.attempts.size >= 100) this.attempts.delete(this.attempts.keys().next().value!);
this.attempts.set(project.id, attempt);
}
const list = await conversations.listConversations(project.id);
const projectIds = new Set(list.map(({ id }) => id));
const busy = this.deps.runtime.getDiagnostics().workers.some((worker) => (
projectIds.has(worker.conversationId) && ['starting', 'queued', 'running'].includes(worker.stage)
));
if (busy) return { status: 'waiting' };
let conversation = attempt.conversationId
? await conversations.getConversation(attempt.conversationId)
: list.find((item) => !item.archivedAt);
await this.deps.ensureActive(project);
conversation ??= await conversations.createConversation({ projectId: project.id, title: '新对话' });
attempt.conversationId = conversation.id;
const current = await conversations.getSnapshot(conversation.id);
if (this.pages.has(project.id)) return this.check(project, requestId, conversationId);
if (!['idle', 'error'].includes(current.run.status)) return { status: 'waiting', conversation };
await this.deps.ensureActive(project);
// Mark before dispatch: uncertain acceptance must never cause automatic resubmission.
attempt.submitted = true;
attempt.submittedAt = Date.now();
try {
const acceptance = await conversations.acceptPrompt({
conversationId: conversation.id,
clientRequestId: `work-preview-${attempt.requestId}`,
mode: 'prompt',
text: OPEN_WORK_PROMPT,
});
attempt.runId = acceptance.runId;
} catch (error) {
const errorCode = error && typeof error === 'object' && 'code' in error && typeof error.code === 'string'
? error.code : 'CODING_REQUEST_UNCERTAIN';
attempt.result = { status: 'failed', conversation, errorCode, message: codingRecovery(errorCode, true).message };
return attempt.result;
}
return { status: 'starting', conversation };
}
}

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,
}))