feat: integrate pixel teacher presence and resilient classroom preview
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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('')
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
148
electron/coding-runtime/work-preview.ts
Normal file
148
electron/coding-runtime/work-preview.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)!;
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user