Files
openmaic/OpenMAIC/components/scene-renderers/pbl/v2/use-instructor-stream.ts
2026-08-16 14:58:47 +08:00

411 lines
15 KiB
TypeScript

'use client';
/**
* PBL v2 — Instructor + evaluator stream chaining hook.
*
* Encapsulates the fetch + SSE parsing + project_patch application
* loop AND the PR 6.6 evaluator chain. Lives in one hook because
* the chain is naturally driven by the Instructor stream's advance
* patch — splitting them across two hooks would force the chat
* component to thread state between them.
*
* Streaming contract:
* - One in-flight stream at a time (further sends are ignored when
* `streaming` is true; the UI disables Send while streaming).
* - Live tokens accumulate into `draftAssistant` for inline render.
* - `project_patch` events are applied to a working clone of the
* project; the final clone is published via `onProjectChange`
* once the whole chain closes.
* - On error the latest project clone is still pushed (any patches
* applied before the failure are kept), and `error` is surfaced.
*
* Evaluator chain (PR 6.6):
* When the Instructor stream yields an `advance` patch with any of
* `shouldEvaluateTask` / `shouldEvaluateMilestone` /
* `shouldEvaluateFinal` set, we collect those flags but do NOT
* open the evaluator stream yet — we let the Instructor finish
* first (force-advance may emit more patches). Once the Instructor
* stream closes, we open evaluator streams in this order:
*
* task → milestone → final
*
* Each is a fresh SSE round-trip to /api/pbl/v2/evaluate; they
* never interleave. Token tail of each stream goes into the same
* `draftAssistant` setter so the UI just sees a continuous wait;
* the `status` field tells the UI what kind of wait it is so the
* label can flip (see chat.tsx).
*
* After a *task* eval finishes, we feed its score into the
* adaptive proficiency engine via `trackSubmissionScore`
* (PR 6 D4-A). Milestone / final do NOT feed the engine — their
* stars are vibes, not measurements.
*/
import { useCallback, useRef, useState } from 'react';
import type { PBLProjectV2 } from '@/lib/pbl/v2/types';
import type { PBLSSEEvent } from '@/lib/pbl/v2/api/sse';
import { trackSubmissionScore } from '@/lib/pbl/v2/operations/runtime/dynamic-signals';
import { normalizeProjectRuntime } from '@/lib/pbl/v2/operations/kernel/progress';
import { getCurrentModelConfig } from '@/lib/utils/model-config';
import { createLogger } from '@/lib/logger';
import { applyInstructorEvent } from './apply-instructor-event';
const log = createLogger('PBL v2 InstructorStream');
interface RunOptions {
endpoint: '/api/pbl/v2/instructor' | '/api/pbl/v2/open-task' | '/api/pbl/v2/simulator';
body: Record<string, unknown>;
/** Override the starting project clone. Use when the caller has
* applied an optimistic local mutation (e.g. user message) right
* before invoking `run` — passing the already-mutated project here
* avoids a stale-ref race between React render and the SSE fetch. */
initialProject?: PBLProjectV2;
}
export type StreamStatus = 'idle' | 'instructor' | 'eval-task' | 'eval-milestone' | 'eval-final';
export interface StreamDisplayState {
readonly status: StreamStatus;
readonly draftAssistant: string;
readonly streamCommittedOutput: boolean;
}
/** SCENARIO ONLY. Which sub-phase of a Simulator turn is generating, so
* the chat shows the right loading indicator. null for ordinary streams. */
export type SimPhase = 'narration' | 'character' | null;
interface UseInstructorStream {
streaming: boolean;
status: StreamStatus;
draftAssistant: string;
streamCommittedOutput: boolean;
error: string | null;
simPhase: SimPhase;
run: (options: RunOptions) => Promise<{ ok: boolean; project: PBLProjectV2 }>;
clearError: () => void;
}
interface EvalChainTriggers {
task: boolean;
milestone: boolean;
final: boolean;
microtaskId?: string;
}
/**
* @param onStreamingChange Optional reporter for "a stream is in flight",
* called with `true` when a run starts and `false` when it settles. Unlike
* the local `streaming` state, this fires even after the owning component
* unmounts (the in-flight `run` promise keeps executing), so a parent that
* outlives the chat (e.g. the Hero ↔ workspace container) can keep showing
* the "thinking…" indicator across a remount. The closure target must be a
* stable setter owned by a component that does NOT unmount with the chat.
*/
export function useInstructorStream(
project: PBLProjectV2,
onProjectChange: (next: PBLProjectV2) => void,
onStreamingChange?: (active: boolean) => void,
): UseInstructorStream {
const [streaming, setStreaming] = useState(false);
const [status, setStatus] = useState<StreamStatus>('idle');
const [draftAssistant, setDraftAssistant] = useState('');
const [streamCommittedOutput, setStreamCommittedOutput] = useState(false);
const [error, setError] = useState<string | null>(null);
const [simPhase, setSimPhase] = useState<SimPhase>(null);
const projectRef = useRef<PBLProjectV2>(project);
projectRef.current = project;
// Synchronous re-entrancy lock. `streaming` is React state and only updates
// on the NEXT render, so two callers in the SAME effect-flush (e.g. the
// empty-thread auto-greeting and the scenario stage-opener firing together
// when a fresh roleplay act has an empty Simulator thread) would both pass
// the `streaming` check and double-fire the opener. A ref flips immediately,
// so the second synchronous call is rejected.
const runningRef = useRef(false);
const run = useCallback(
async ({ endpoint, body, initialProject }: RunOptions) => {
if (runningRef.current || streaming) return { ok: false, project: projectRef.current };
runningRef.current = true;
setError(null);
setDraftAssistant('');
setStreamCommittedOutput(false);
setStreaming(true);
setStatus('instructor');
setSimPhase(null);
onStreamingChange?.(true);
let workingProject: PBLProjectV2 = structuredClone(initialProject ?? projectRef.current);
let ok = true;
if (normalizeProjectRuntime(workingProject)) {
onProjectChange(workingProject);
}
// Evaluator triggers collected mid-stream, acted on after.
const chain: EvalChainTriggers = { task: false, milestone: false, final: false };
let lastPhase: StreamStatus = 'instructor';
try {
workingProject = await runOneStream({
endpoint,
body: { project: workingProject, ...body },
startingProject: workingProject,
setDraftAssistant,
onProjectUpdated: onProjectChange,
onSimPhase: setSimPhase,
onPatch: (patch) => {
if (patch.kind === 'message' || patch.kind === 'evaluation') {
setStreamCommittedOutput(true);
}
if (patch.kind !== 'advance') return;
if (patch.shouldEvaluateTask) {
chain.task = true;
chain.microtaskId = patch.microtaskId;
}
if (patch.shouldEvaluateMilestone) {
chain.milestone = true;
if (!chain.microtaskId) chain.microtaskId = patch.microtaskId;
}
if (patch.shouldEvaluateFinal) {
chain.final = true;
if (!chain.microtaskId) chain.microtaskId = patch.microtaskId;
}
},
});
// ---- Evaluator chain: task → milestone → final ----
if (chain.task && chain.microtaskId) {
const milestoneId = findMilestoneIdForMicrotask(workingProject, chain.microtaskId);
if (milestoneId) {
lastPhase = 'eval-task';
setStatus('eval-task');
setDraftAssistant('');
setStreamCommittedOutput(false);
workingProject = await runOneStream({
endpoint: '/api/pbl/v2/evaluate',
body: {
project: workingProject,
kind: 'task',
milestoneId,
microtaskId: chain.microtaskId,
},
startingProject: workingProject,
setDraftAssistant,
onProjectUpdated: onProjectChange,
onPatch: (patch) => {
if (patch.kind === 'evaluation') setStreamCommittedOutput(true);
},
});
// PR 6 D4-A: fold task score into adaptive engine.
const newest = workingProject.evaluations[workingProject.evaluations.length - 1];
if (newest && newest.kind === 'task' && typeof newest.score === 'number') {
trackSubmissionScore(workingProject, newest.score);
}
}
}
if (chain.milestone) {
const milestoneId = chain.microtaskId
? findMilestoneIdForMicrotask(workingProject, chain.microtaskId)
: undefined;
if (milestoneId) {
lastPhase = 'eval-milestone';
setStatus('eval-milestone');
setDraftAssistant('');
setStreamCommittedOutput(false);
workingProject = await runOneStream({
endpoint: '/api/pbl/v2/evaluate',
body: { project: workingProject, kind: 'milestone', milestoneId },
startingProject: workingProject,
setDraftAssistant,
onProjectUpdated: onProjectChange,
onPatch: (patch) => {
if (patch.kind === 'evaluation') setStreamCommittedOutput(true);
},
});
}
}
if (chain.final) {
lastPhase = 'eval-final';
setStatus('eval-final');
setDraftAssistant('');
setStreamCommittedOutput(false);
workingProject = await runOneStream({
endpoint: '/api/pbl/v2/evaluate',
body: { project: workingProject, kind: 'final' },
startingProject: workingProject,
setDraftAssistant,
onProjectUpdated: onProjectChange,
onPatch: (patch) => {
if (patch.kind === 'evaluation') setStreamCommittedOutput(true);
},
});
}
} catch (e) {
ok = false;
const msg = e instanceof Error ? e.message : String(e);
log.warn(`Stream chain failed at phase=${lastPhase}: ${msg}`);
setError(msg);
} finally {
runningRef.current = false;
setStreaming(false);
setStatus('idle');
setDraftAssistant('');
setSimPhase(null);
onStreamingChange?.(false);
onProjectChange(workingProject);
}
return { ok, project: workingProject };
},
[streaming, onProjectChange, onStreamingChange],
);
const clearError = useCallback(() => setError(null), []);
return {
streaming,
status,
draftAssistant,
streamCommittedOutput,
error,
simPhase,
run,
clearError,
};
}
export interface OneStreamArgs {
endpoint: string;
body: Record<string, unknown>;
startingProject: PBLProjectV2;
setDraftAssistant: (fn: (prev: string) => string) => void;
onPatch?: (patch: Extract<PBLSSEEvent, { type: 'project_patch' }>['patch']) => void;
onProjectUpdated?: (next: PBLProjectV2) => void;
onSimPhase?: (phase: SimPhase) => void;
}
export async function runOneStream(args: OneStreamArgs): Promise<PBLProjectV2> {
const { endpoint, body, startingProject, setDraftAssistant, onPatch } = args;
let workingProject = startingProject;
const modelConfig = getCurrentModelConfig();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-model': modelConfig.modelString,
'x-api-key': modelConfig.apiKey,
};
if (modelConfig.baseUrl) headers['x-base-url'] = modelConfig.baseUrl;
if (modelConfig.providerType) headers['x-provider-type'] = modelConfig.providerType;
// PBL Planner already reads `x-user-locale` from this header for
// generation-time language lock; the evaluator route does NOT
// need it (the project already carries `language`) but forwarding
// it unconditionally is cheap and future-proof for routes that
// may grow to depend on the UI locale.
try {
const stored = localStorage.getItem('locale');
if (stored) headers['x-user-locale'] = stored;
} catch {
// localStorage unavailable; skip silently.
}
const res = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify(body),
});
if (!res.body) throw new Error('Response has no readable body.');
if (!res.ok) {
const text = await res.text();
throw new Error(`HTTP ${res.status}: ${text.slice(0, 400)}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const frames = buffer.split('\n\n');
buffer = frames.pop() ?? '';
for (const frame of frames) {
const parsed = parseSSEFrame(frame);
if (!parsed) continue;
assertNotStreamError(parsed);
if (parsed.type === 'sim_phase') {
args.onSimPhase?.(parsed.phase);
continue;
}
if (parsed.type === 'project_patch' && onPatch) onPatch(parsed.patch);
workingProject = applyInstructorEvent(parsed, workingProject, setDraftAssistant);
if (parsed.type === 'project_patch') {
args.onProjectUpdated?.(workingProject);
}
}
}
return workingProject;
} finally {
reader.cancel().catch(() => {
// Reader already closed; ignore.
});
}
}
export function assertNotStreamError(event: PBLSSEEvent): void {
if (event.type === 'error') {
throw new Error(`${event.code}: ${event.message}`);
}
}
/**
* Whether a stream `error` frame should be tolerated (degraded) rather than
* abort the flow.
*
* #593: `EMPTY_LLM_OUTPUT` is a soft "the instructor produced no new content
* this turn — retry" hint, meaningful for a live chat turn. The post-submission
* flow chains a best-effort instructor REACTION turn (`streamStatus`
* 'instructor') after a task evaluation that already succeeded and is recorded.
* If that reaction goes empty, the correct outcome is "no wrap-up bubble", NOT
* "评测失败" — the evaluation stands. So only that specific soft signal on the
* reaction stream is tolerated; real failures (LLM_ERROR / STREAM_ERROR) and ANY
* error on the evaluation streams (eval-task / eval-milestone / eval-final)
* remain fatal. Pure so it can be unit-tested.
*/
export function isToleratedReactionStreamError(
streamStatus: StreamStatus,
event: PBLSSEEvent,
): boolean {
return (
event.type === 'error' && streamStatus === 'instructor' && event.code === 'EMPTY_LLM_OUTPUT'
);
}
export function findMilestoneIdForMicrotask(
project: PBLProjectV2,
microtaskId: string,
): string | undefined {
for (const ms of project.milestones) {
if (ms.microtasks.some((t) => t.id === microtaskId)) return ms.id;
}
return undefined;
}
function parseSSEFrame(frame: string): PBLSSEEvent | null {
const lines = frame.split('\n');
let eventName = '';
let dataLine = '';
for (const line of lines) {
if (line.startsWith('event: ')) eventName = line.slice(7).trim();
else if (line.startsWith('data: ')) dataLine = line.slice(6);
}
if (!eventName || !dataLine) return null;
try {
return { type: eventName, ...JSON.parse(dataLine) } as PBLSSEEvent;
} catch {
return null;
}
}