// API 客户端(API-first:UI 只是第一个客户端)与 SSE 流客户端(游标重放 + 终态对账)。 // 认证:服务端开启 API key 时经 auth.fetch/withKey 注入凭证(嵌入式档无认证则透传)。 import { auth } from './auth' import type { Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse, AgentProfile, SettingDesc, StreamEvent, PlanSnapshot, TodoList, UploadInfo, PromptModule, ActivityCard, } from './contracts' const BASE = '' async function json(res: Response): Promise { if (!res.ok) { const text = await res.text() throw new Error(`${res.status}: ${text}`) } return res.json() as Promise } export const api = { listProjects: () => auth.fetch(`${BASE}/api/projects`).then(r => json(r)), createProject: (name: string) => auth.fetch(`${BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json(r)), renameProject: (id: string, name: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json(r)), deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }), listConversations: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`).then(r => json(r)), // 跨项目任务列表(方案 A 侧栏):默认仅未归档;includeArchived 含归档。 listTasks: (includeArchived?: boolean) => auth.fetch(`${BASE}/api/conversations${includeArchived ? '?archived=1' : ''}`).then(r => json(r)), setConversationFlags: (id: string, flags: { pinned?: boolean; archived?: boolean; allowCrossRead?: boolean; crossPrivate?: boolean }) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(flags) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }), me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean }>(r)), // ---- 认证与会话(管理员发放制,R2)---- login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => { if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`) return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean }> }), logout: () => auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' }), changePassword: (current: string, next: string) => auth.fetch(`${BASE}/api/auth/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current, next }) }).then(async r => { if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`) return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean }> }), listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }[]>(r)), createAdminUser: (owner: string, name: string, password: string, isAdmin: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, name, password, isAdmin }) }).then(r => json<{ owner: string; mustChangePassword: boolean }>(r)), resetAdminUserPassword: (owner: string, password: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) }).then(r => json<{ owner: string; mustChangePassword: boolean }>(r)), createConversation: (projectId: string, title?: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title ?? '' }) }).then(r => json(r)), deleteConversation: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'DELETE' }), setConversationProfile: (id: string, profileId: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId }) }).then(r => json(r)), conversationDetail: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`).then(r => json<{ conversation: Conversation; messages: Message[]; runs: Run[]; activeHitl: HITLCard[]; lastSeq: number }>(r)), sendMessage: (convId: string, text: string, attachmentIds: string[]) => auth.fetch(`${BASE}/api/conversations/${convId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, attachmentIds }) }) .then(async r => { if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`) return r.json() as Promise<{ message: Message; run?: Run; command?: string; reply?: string; conversation?: Conversation }> }), cancelRun: (runId: string) => auth.fetch(`${BASE}/api/runs/${runId}/cancel`, { method: 'POST' }), runDetail: (id: string) => auth.fetch(`${BASE}/api/runs/${id}`).then(r => json<{ run: Run; children: Run[] }>(r)), resolveHITL: (cardId: string, resp: Partial) => auth.fetch(`${BASE}/api/hitl/${cardId}/resolve`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(resp) }) .then(r => json<{ resolved: boolean; pivoted: boolean; pivotText: string }>(r)), listCards: (conversationId: string) => auth.fetch(`${BASE}/api/conversations/${conversationId}/cards`).then(r => json<{ cards: ActivityCard[] }>(r)).then(d => d.cards ?? []), cardAction: (cardId: string, actionId: string, freeText?: string) => auth.fetch(`${BASE}/api/cards/${cardId}/actions/${encodeURIComponent(actionId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ freeText }) }) .then(r => json<{ card: ActivityCard; receipt?: Message }>(r)), cardPin: (cardId: string, pinned: boolean) => auth.fetch(`${BASE}/api/cards/${cardId}/pin`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pinned }) }).then(r => json<{ card: ActivityCard }>(r)), cardDismiss: (cardId: string) => auth.fetch(`${BASE}/api/cards/${cardId}/dismiss`, { method: 'POST' }).then(r => json<{ card: ActivityCard }>(r)), listProjectCards: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/cards`).then(r => json<{ cards: ActivityCard[] }>(r)).then(d => d.cards ?? []), cardToProject: (cardId: string) => auth.fetch(`${BASE}/api/cards/${cardId}/to_project`, { method: 'POST' }).then(r => json<{ card: ActivityCard; origin: ActivityCard }>(r)), upload: (projectId: string, file: File) => { const fd = new FormData() fd.append('file', file) return auth.fetch(`${BASE}/api/projects/${projectId}/uploads`, { method: 'POST', body: fd }).then(r => json(r)) }, listArtifacts: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/artifacts`).then(r => json(r)), artifactContentUrl: (id: string) => auth.withKey(`${BASE}/api/artifacts/${id}/content`), // 下载直链:后端按 ?download=1 回 Content-Disposition: attachment(RFC 5987 文件名)。 artifactDownloadUrl: (id: string) => { const url = auth.withKey(`${BASE}/api/artifacts/${id}/content`) return url.includes('?') ? `${url}&download=1` : `${url}?download=1` }, saveArtifactContent: (id: string, content: string) => auth.fetch(`${BASE}/api/artifacts/${id}/content`, { method: 'PUT', body: content }), deleteArtifact: (id: string) => auth.fetch(`${BASE}/api/artifacts/${id}`, { method: 'DELETE' }), getPlan: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/plan`).then(r => r.ok ? r.json() : null) as Promise, getTodos: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/todos`).then(r => r.ok ? r.json() : null) as Promise, listProfiles: () => auth.fetch(`${BASE}/api/profiles`).then(r => json(r)), listPromptModules: () => auth.fetch(`${BASE}/api/prompts`).then(r => json(r)), saveProfile: (p: AgentProfile) => auth.fetch(`${BASE}/api/profiles`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(p) }).then(r => { if (!r.ok) throw new Error(r.statusText); return r.json() }), deleteProfile: (id: string) => auth.fetch(`${BASE}/api/profiles/${id}`, { method: 'DELETE' }), getSettings: (projectId?: string) => auth.fetch(`${BASE}/api/settings${projectId ? '?project=' + projectId : ''}`).then(r => json<{ schema: SettingDesc[]; defaults: Record; user: Record; project: Record }>(r)), putUserSettings: (values: Record) => auth.fetch(`${BASE}/api/settings/user`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }), putProjectSettings: (projectId: string, values: Record) => auth.fetch(`${BASE}/api/settings/project/${projectId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }), listTools: () => auth.fetch(`${BASE}/api/tools`).then(r => r.json()), listPlugins: () => auth.fetch(`${BASE}/api/plugins`).then(r => r.json()), reloadPlugins: () => auth.fetch(`${BASE}/api/plugins/reload`, { method: 'POST' }).then(r => r.json()), listCommands: () => auth.fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name?: string; id?: string; title?: string; description?: string; danger?: string }[]>, listWorkspaces: () => auth.fetch(`${BASE}/api/workspaces`).then(r => r.json()) as Promise<{ id: string; title: string; icon?: string; shortcut?: string; badgeTool?: string; body?: any[]; refreshTool?: string }[]>, } // ---- SSE 客户端:Last-Event-ID 游标续传;断线自动重连 ---- export interface SSEHandlers { onEvent: (e: StreamEvent) => void onOpen?: () => void onError?: (err: Error) => void } export function connectEvents(conversationId: string, since: number, handlers: SSEHandlers, projectId?: string): { close: () => void } { let closed = false let es: EventSource | null = null const connect = () => { if (closed) return // 项目流(CF3/D50):scope=project 订阅项目频道(伪会话键,信封带 ProjectID)。 const url = projectId ? `${BASE}/api/events?scope=project&projectId=${encodeURIComponent(projectId)}&since=${since}` : `${BASE}/api/events?conversationId=${encodeURIComponent(conversationId)}&since=${since}` es = new EventSource(auth.withKey(url)) es.onopen = () => handlers.onOpen?.() es.onerror = () => { if (closed) return // EventSource 自动重连(带 Last-Event-ID);此处仅上报。 handlers.onError?.(new Error('sse reconnecting')) } es.onmessage = ev => handle('message', ev.data) // 同时监听具名事件与默认(服务端 event: 字段)。 const types = ['message.delta', 'thinking.delta', 'tool.call', 'tool.result', 'run.phase', 'hitl.request', 'hitl.resolved', 'plan.update', 'todo.update', 'agent.spawn', 'agent.progress', 'agent.result', 'artifact.created', 'artifact.updated', 'run.completed', 'run.failed', 'error', 'card.created', 'card.updated', 'card.status', 'card.resolved'] for (const t of types) es.addEventListener(t, ev => handle(t, (ev as MessageEvent).data)) } const handle = (_t: string, data: string) => { try { const e = JSON.parse(data) as StreamEvent since = Math.max(since, e.seq) handlers.onEvent(e) } catch { /* 忽略坏帧 */ } } connect() return { close: () => { closed = true; es?.close() } } }