Files
makelore/electron/services/learning-agent-client.ts
inman 01bee3188b
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
feat: integrate learning module
2026-08-16 20:52:16 +08:00

191 lines
7.6 KiB
TypeScript

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<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
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<string, string>();
const sessionFlights = new Map<string, Promise<string>>();
async function authorizedJson(path: string, init: RequestInit): Promise<Record<string, unknown>> {
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<string> {
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();
let buffer = '';
let text = '';
try {
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
const frames = buffer.split('\n\n');
buffer = frames.pop() || '';
for (const frame of frames) {
const data = frame.split('\n').find((line) => line.startsWith('data:'))?.slice(5).trim();
if (!data) continue;
const envelope = record(JSON.parse(data));
if (envelope.run_id !== runId) continue;
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') {
controller.abort();
return { text: text.trim() };
}
}
}
} 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<void> {
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 };
}