Why: D49/D51 — aggregated project view (project cards + other conversations' pending) so todos survive conversation switches; D50 — subscribe the project channel via scope=project. Evidence: api endpoints (listProjectCards/cardToProject, card.* named SSE events — also fixes live card events previously dropped); store projectCards/inboxScope/applyProjectEvent/cardToProject with badge combining both scopes; Workbench scope tabs + grouped ProjectInbox; styles for scope control. Impact: existing conversation inbox behavior unchanged; badge now counts both scopes. Refs: documents proposal 2026-09-30-card-stream-artifact-unify-project-inbox.md (D48-D51).
158 lines
12 KiB
TypeScript
158 lines
12 KiB
TypeScript
// 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<T>(res: Response): Promise<T> {
|
||
if (!res.ok) {
|
||
const text = await res.text()
|
||
throw new Error(`${res.status}: ${text}`)
|
||
}
|
||
return res.json() as Promise<T>
|
||
}
|
||
|
||
export const api = {
|
||
listProjects: () => auth.fetch(`${BASE}/api/projects`).then(r => json<Project[]>(r)),
|
||
createProject: (name: string) => auth.fetch(`${BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(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<Project>(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<Conversation[]>(r)),
|
||
// 跨项目任务列表(方案 A 侧栏):默认仅未归档;includeArchived 含归档。
|
||
listTasks: (includeArchived?: boolean) => auth.fetch(`${BASE}/api/conversations${includeArchived ? '?archived=1' : ''}`).then(r => json<Conversation[]>(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<Conversation>(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<Conversation>(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<HITLResponse>) =>
|
||
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<UploadInfo>(r))
|
||
},
|
||
listArtifacts: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/artifacts`).then(r => json<Artifact[]>(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<PlanSnapshot | null>,
|
||
getTodos: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/todos`).then(r => r.ok ? r.json() : null) as Promise<TodoList | null>,
|
||
|
||
listProfiles: () => auth.fetch(`${BASE}/api/profiles`).then(r => json<AgentProfile[]>(r)),
|
||
listPromptModules: () => auth.fetch(`${BASE}/api/prompts`).then(r => json<PromptModule[]>(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<string, unknown>; user: Record<string, unknown>; project: Record<string, unknown> }>(r)),
|
||
putUserSettings: (values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/user`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }),
|
||
putProjectSettings: (projectId: string, values: Record<string, unknown>) => 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() } }
|
||
}
|