import { randomUUID } from 'node:crypto'; import { WORKS_SQUARE_CONFIG } from '../api/works-config'; import { proxyAwareFetch } from '../utils/proxy-fetch'; import { getValidWorksSquareAccessToken } from './works-square-session'; const COURSE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; const SHA256_PATTERN = /^[0-9a-f]{64}$/; export type LearningAgentRequest = { courseId: string; contentHash: string; message: string; history?: Array<{ role: 'user' | 'assistant'; content: string }>; anchor?: { sceneId?: string; sceneOrder?: number; sceneTitle?: string; actionIndex?: number; moduleId?: string | null; moduleContentHash?: string; }; }; type Dependencies = { fetchImpl?: typeof fetch; getAccessToken?: typeof getValidWorksSquareAccessToken; apiBaseUrl?: string; }; class LearningAgentHttpError extends Error { constructor(readonly status: number, message: string) { super(message); this.name = 'LearningAgentHttpError'; } } function record(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } function createSseDataParser() { let line = ''; let pendingCarriageReturn = false; let dataLines: string[] = []; const commitLine = (events: string[]) => { if (line === '') { if (dataLines.length > 0) events.push(dataLines.join('\n')); dataLines = []; return; } if (!line.startsWith(':')) { const separator = line.indexOf(':'); const field = separator === -1 ? line : line.slice(0, separator); let value = separator === -1 ? '' : line.slice(separator + 1); if (value.startsWith(' ')) value = value.slice(1); if (field === 'data') dataLines.push(value); } line = ''; }; const push = (chunk: string): string[] => { const events: string[] = []; for (const character of chunk) { if (pendingCarriageReturn) { pendingCarriageReturn = false; commitLine(events); if (character === '\n') continue; } if (character === '\r') { pendingCarriageReturn = true; } else if (character === '\n') { commitLine(events); } else { line += character; } } return events; }; const finish = (): string[] => { const events: string[] = []; if (pendingCarriageReturn) { pendingCarriageReturn = false; commitLine(events); } if (line !== '') commitLine(events); if (dataLines.length > 0) { events.push(dataLines.join('\n')); dataLines = []; } return events; }; return { push, finish }; } export function createLearningAgentClient(dependencies: Dependencies = {}) { const fetchImpl = dependencies.fetchImpl ?? proxyAwareFetch; const getAccessToken = dependencies.getAccessToken ?? getValidWorksSquareAccessToken; const apiBaseUrl = (dependencies.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, ''); const sessions = new Map(); const sessionFlights = new Map>(); async function authorizedJson(path: string, init: RequestInit): Promise> { const token = await getAccessToken({ fetchImpl }); if (!token) throw new Error('请先登录'); const request = (accessToken: string) => fetchImpl(`${apiBaseUrl}${path}`, { ...init, headers: { Accept: 'application/json', 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` }, }); let response = await request(token); if (response.status === 401) { await response.body?.cancel().catch(() => undefined); const refreshed = await getAccessToken({ fetchImpl, forceRefresh: true }); if (refreshed) response = await request(refreshed); } const payload = await response.json().catch(() => null); if (!response.ok) { const detail = record(record(payload).detail); throw new LearningAgentHttpError( response.status, typeof detail.message === 'string' ? detail.message : '助教服务暂时不可用', ); } return record(payload); } async function createSession(bindingKey: string, courseId: string): Promise { const existing = sessions.get(bindingKey); if (existing) return existing; const inFlight = sessionFlights.get(bindingKey); if (inFlight) return inFlight; const pending = authorizedJson('/api/agents/sessions', { method: 'POST', body: JSON.stringify({ client_session_id: `learning-${courseId}-${randomUUID()}`, runtime: 'learning', runtime_version: 'v1', binding: { kind: 'learning-course', key: bindingKey }, }), }).then((session) => { const sessionId = String(session.session_id || ''); if (!sessionId) throw new Error('助教会话创建失败'); sessions.set(bindingKey, sessionId); return sessionId; }).finally(() => sessionFlights.delete(bindingKey)); sessionFlights.set(bindingKey, pending); return pending; } async function runTurn(sessionId: string, input: LearningAgentRequest): Promise<{ text: string }> { const command = await authorizedJson(`/api/agents/sessions/${encodeURIComponent(sessionId)}/commands`, { method: 'POST', body: JSON.stringify({ client_command_id: `turn-${randomUUID()}`, name: 'turn.submit', input: { message: input.message.trim().slice(0, 4000), // Compatibility context for an older runtime. The durable Works // Agent session remains authoritative across turns. history: (input.history ?? []).slice(-8), anchor: input.anchor ?? {}, }, }), }); const runId = String(command.run_id || ''); const ticket = await authorizedJson(`/api/agents/sessions/${encodeURIComponent(sessionId)}/stream-tickets`, { method: 'POST', body: JSON.stringify({ transport: 'sse' }), }); const streamUrl = String(ticket.stream_url || ''); if (!runId || !streamUrl.startsWith('/api/agents/')) throw new Error('助教事件通道创建失败'); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 120_000); let response: Response; try { response = await fetchImpl(`${apiBaseUrl}${streamUrl}`, { headers: { Accept: 'text/event-stream' }, signal: controller.signal }); } catch (error) { clearTimeout(timeout); throw error; } if (!response.ok || !response.body) { clearTimeout(timeout); throw new LearningAgentHttpError(response.status, '助教事件通道不可用'); } const reader = response.body.getReader(); const decoder = new TextDecoder(); const parser = createSseDataParser(); let text = ''; const consume = (data: string): { text?: string; completed?: true } => { if (!data) return {}; const envelope = record(JSON.parse(data)); if (envelope.run_id !== runId) return {}; const payload = record(envelope.payload); if (envelope.type === 'learning.assistant.delta' && typeof payload.delta === 'string') { text += payload.delta; } if (envelope.type === 'learning.assistant.failed') { throw new Error(typeof payload.message === 'string' ? payload.message : '助教回答失败'); } if (envelope.type === 'learning.assistant.completed') { return { text: text.trim(), completed: true }; } return {}; }; try { while (true) { const chunk = await reader.read(); const events = chunk.done ? [...parser.push(decoder.decode()), ...parser.finish()] : parser.push(decoder.decode(chunk.value, { stream: true })); for (const data of events) { const result = consume(data); if (result.completed) { controller.abort(); return { text: result.text ?? '' }; } } if (chunk.done) break; } } finally { clearTimeout(timeout); await reader.cancel().catch(() => undefined); } throw new Error('助教回答意外中断'); } async function ask(input: LearningAgentRequest): Promise<{ text: string }> { if (!COURSE_ID_PATTERN.test(input.courseId) || !SHA256_PATTERN.test(input.contentHash) || !input.message.trim()) throw new Error('助教请求无效'); const bindingKey = `${input.courseId}:${input.contentHash}`; let sessionId = await createSession(bindingKey, input.courseId); try { return await runTurn(sessionId, input); } catch (error) { if (!(error instanceof LearningAgentHttpError) || error.status !== 404) throw error; sessions.delete(bindingKey); sessionId = await createSession(bindingKey, input.courseId); return runTurn(sessionId, input); } } async function reset(courseId?: string, contentHash?: string): Promise { const exactKey = courseId && contentHash ? `${courseId}:${contentHash}` : null; const entries = [...sessions.entries()].filter(([key]) => exactKey ? key === exactKey : courseId ? key.startsWith(`${courseId}:`) : true); for (const [key, sessionId] of entries) { sessions.delete(key); void authorizedJson(`/api/agents/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' }).catch(() => undefined); } } return { ask, reset }; }