feat: integrate pixel teacher presence and resilient classroom preview
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user