320 lines
27 KiB
TypeScript
320 lines
27 KiB
TypeScript
// API 客户端(API-first:UI 只是第一个客户端)与 SSE 流客户端(游标重放 + 终态对账)。
|
||
// 认证:服务端开启 API key 时经 auth.fetch/withKey 注入凭证(嵌入式档无认证则透传)。
|
||
import { auth } from './auth'
|
||
import { rdmCommandProjectId, rdmProjectCommandError } from './rdmManagement'
|
||
import type {
|
||
Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse,
|
||
AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule,
|
||
ActivityCard, SurfaceMeta, SurfaceSnapshot, WorkCardAction,
|
||
} from './contracts'
|
||
|
||
const BASE = ''
|
||
|
||
/** 管理区声明分区(WP3-I3/KTD4,backend 专属端点类型):编译插件经
|
||
* Registry.AddAdminZone 声明;入口不做前端权限合并,区内数据能力由组件
|
||
* 自身命令面 fail-closed。 */
|
||
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
|
||
|
||
/** 账户链路(2026-10-13 恢复合同 + 2026-10-14 current_required 加固):
|
||
* passwordChangeMode 由服务端从会话派生;grant_setup 免当前密码,
|
||
* current_required 必须校验当前密码。 */
|
||
export type PasswordChangeMode = 'grant_setup' | 'current_required'
|
||
|
||
/** 恢复提案(属主确认制):管理员只能提案,确认须用提案前已存在的合格完整密码会话。 */
|
||
export interface RecoveryProposal {
|
||
id: string; userId: string; owner: string
|
||
status: 'pending' | 'confirmed' | 'completed' | 'revoked' | 'expired'
|
||
version: number; generation: number; createdBy: string
|
||
createdAt: string; expiresAt: string
|
||
}
|
||
|
||
/** 账号资料与档案视图(backend accountProfileJSON 契约)。 */
|
||
export interface AccountProfile { name: string; phone: string; email: string; staffNo: string; department: string; notes: string }
|
||
export interface AccountRecord {
|
||
userId: string; owner: string; profile: AccountProfile; profileVersion: number
|
||
enabled?: boolean; stateVersion?: number; isAdmin?: boolean; passwordSet?: boolean
|
||
initializationState?: string; initializationGeneration?: number
|
||
}
|
||
|
||
export interface ExecEnvView {
|
||
configured: boolean
|
||
root?: string
|
||
python?: string
|
||
base?: string
|
||
layers?: string[]
|
||
requests?: { id: string; conversationId: string; owner: string; spec: string; reason: string; status: string; layerId: string; detail: string; approver: string }[]
|
||
bindings?: { conversationId: string; baseId: string; extensions: string[] | null; isolated: string }[]
|
||
}
|
||
|
||
async function checked(res: Response): Promise<Response> {
|
||
if (!res.ok) {
|
||
const text = await res.text()
|
||
throw new Error(`${res.status}: ${text}`)
|
||
}
|
||
return res
|
||
}
|
||
|
||
async function json<T>(res: Response): Promise<T> {
|
||
await checked(res)
|
||
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' }).then(checked),
|
||
|
||
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}`) }),
|
||
setPermissionMode: (id: string, permissionMode: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ permissionMode }) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
|
||
checkpoints: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/checkpoints?messageId=${encodeURIComponent(messageId)}`).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)),
|
||
rewindFiles: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messageId }) }).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)),
|
||
/** 会话内页面目录:扁平 SurfaceMeta(无 identity/spec),不可直接写入 surfaces 投影。 */
|
||
surfaceList: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
|
||
submitSurfaceAction: (id: string, body: { actionId: string; revision: number; requestId: string; data: Record<string, unknown> }) =>
|
||
auth.fetch(`${BASE}/api/surfaces/${id}/actions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json<any>(r)),
|
||
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
|
||
/** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */
|
||
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<SurfaceSnapshot>(r)),
|
||
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; passwordChangeMode?: PasswordChangeMode; isAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: 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()}`)
|
||
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string }
|
||
auth.beginSession()
|
||
return me
|
||
}),
|
||
// 一次性登录 Key 兑换(恢复/激活):请求只带 Key,身份由服务端绑定记录决定;
|
||
// 成功建立 set_password_only 受限会话(HttpOnly cookie),明文 Key 不落任何存储。
|
||
redeemLoginKey: (key: string) => auth.fetch(`${BASE}/api/auth/login-key/redeem`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) }).then(async r => {
|
||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string }
|
||
auth.beginSession()
|
||
return me
|
||
}),
|
||
logout: () => {
|
||
const request = auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' })
|
||
auth.clear()
|
||
return request
|
||
},
|
||
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; reloginRequired: boolean }>
|
||
}),
|
||
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number; profileVersion: number; initializationState: string }[]>(r)),
|
||
// 管理员建号:可提供初始密码;省略密码时由服务端生成并仅返回一次。
|
||
createAdminUser: (owner: string, name: string, password?: string) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name }, ...(password ? { password } : {}) }) }).then(r => json<{ userId: string; owner: string; passwordSet: boolean; mustChangePassword: boolean; initializationState: string; initialPassword?: string }>(r)),
|
||
adminResetUserPassword: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }).then(r => json<{ owner: string; temporaryPassword: string; mustChangePassword: boolean }>(r)),
|
||
// ---- 管理员:一次性登录 Key(HTTP 签发端点已被恢复提案契约替换为 410,客户端方法随之移除;
|
||
// list/revoke 仍有效,供恢复提案视图复用;兑换入口不变)----
|
||
adminListLoginKeys: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`).then(r => json<{ keys: { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }[] }>(r)).then(d => d.keys ?? []),
|
||
adminRevokeLoginKey: (owner: string, id: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys/${encodeURIComponent(id)}`, { method: 'DELETE' }).then(r => json<{ ok: boolean }>(r)),
|
||
// ---- 管理员:恢复提案(属主确认制,替换已 410 的直接签发 Key 入口)----
|
||
createAdminRecoveryProposal: (userId: string, expectedStateVersion: number, idempotencyKey: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, idempotencyKey }) }).then(r => json<RecoveryProposal>(r)),
|
||
listAdminRecoveryProposals: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals`).then(r => json<{ proposals: RecoveryProposal[] }>(r)).then(d => d.proposals ?? []),
|
||
adminRevokeRecoveryProposal: (userId: string, id: string, expectedVersion: number) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals/${encodeURIComponent(id)}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion }) }).then(r => json<RecoveryProposal>(r)),
|
||
// ---- 本人:资料与恢复提案确认(须完整密码会话)----
|
||
meProfile: () => auth.fetch(`${BASE}/api/me/profile`).then(r => json<AccountRecord>(r)),
|
||
patchMeProfile: (expectedProfileVersion: number, patch: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/me/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json<AccountRecord>(r)),
|
||
listMyRecoveryProposals: () => auth.fetch(`${BASE}/api/me/recovery-proposals`).then(r => json<{ proposals: RecoveryProposal[] }>(r)).then(d => d.proposals ?? []),
|
||
getMyRecoveryProposal: (id: string) => auth.fetch(`${BASE}/api/me/recovery-proposals/${encodeURIComponent(id)}`).then(r => json<RecoveryProposal>(r)),
|
||
confirmMyRecoveryProposal: (id: string, expectedVersion: number) => auth.fetch(`${BASE}/api/auth/recovery-proposals/${encodeURIComponent(id)}/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion }) }).then(r => json<{ proposal: RecoveryProposal; grant?: string }>(r)),
|
||
rotateMyRecoveryGrant: (id: string, expectedVersion: number, expectedGeneration: number) => auth.fetch(`${BASE}/api/auth/recovery-proposals/${encodeURIComponent(id)}/rotate-grant`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion, expectedGeneration }) }).then(r => json<{ proposal: RecoveryProposal; grant?: string }>(r)),
|
||
adminExecEnv: () => auth.fetch(`${BASE}/api/admin/exec-env`).then(r => json<ExecEnvView>(r)),
|
||
adminExecEnvDecision: (id: string, allow: boolean, isolate: boolean) => auth.fetch(`${BASE}/api/admin/exec-env/requests/${encodeURIComponent(id)}/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ allow, isolate }) }).then(r => json<{ status: string; detail?: string; layerId?: string }>(r)),
|
||
adminExecEnvRebase: () => auth.fetch(`${BASE}/api/admin/exec-env/rebase`, { method: 'POST' }).then(r => json<{ base: string }>(r)),
|
||
adminExecEnvGC: () => auth.fetch(`${BASE}/api/admin/exec-env/gc`, { method: 'POST' }).then(r => json<{ removed: string[] | null }>(r)),
|
||
adminExecEnvWheel: (file: File, conversationId: string, reason: string) => {
|
||
const body = new FormData()
|
||
body.set('wheel', file)
|
||
body.set('conversationId', conversationId)
|
||
body.set('reason', reason)
|
||
return auth.fetch(`${BASE}/api/admin/exec-env/wheels`, { method: 'POST', body }).then(r => json<{ file: string; outcome: { status: string; requestId?: string; detail?: string } }>(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; resumeSeq?: number }>(r)),
|
||
|
||
runEventsPage: (convId: string, runId: string, after: number, signal?: AbortSignal) =>
|
||
auth.fetch(`${BASE}/api/conversations/${encodeURIComponent(convId)}/runs/${encodeURIComponent(runId)}/events?after=${after}&limit=500`, { signal })
|
||
.then(r => json<{ events: StreamEvent[] }>(r)),
|
||
|
||
sendMessage: (convId: string, text: string, attachmentIds: string[], requestId?: string, taskRef?: { cardId: string; resultVersion: number }) =>
|
||
auth.fetch(`${BASE}/api/conversations/${convId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, attachmentIds, requestId,
|
||
...(taskRef ? { taskRef: { cardId: taskRef.cardId, resultVersion: taskRef.resultVersion } } : {}) }) })
|
||
.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, payload?: string | WorkCardAction) =>
|
||
auth.fetch(`${BASE}/api/cards/${cardId}/actions/${encodeURIComponent(actionId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(typeof payload === 'object' ? payload : { freeText: payload }) })
|
||
.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) }).then(checked),
|
||
// P2 系统管理面:受管项(models/tavily/telemetry/运行基础设施)的唯一写入面。
|
||
getSystemSettings: () => auth.fetch(`${BASE}/api/settings/system`).then(r =>
|
||
json<{ schema: SettingDesc[]; defaults: Record<string, unknown>; system: Record<string, unknown> }>(r)),
|
||
putSystemSettings: (values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/system`, { 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()),
|
||
adminZoneSections: () => auth.fetch(`${BASE}/api/admin-zone/sections`).then(r => r.json()) as Promise<AdminZoneSection[]>,
|
||
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; enabled?: boolean }[]>,
|
||
setSessionNote: (id: string, sessionNote: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionNote }) }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
|
||
excludeMessage: (id: string, excluded: boolean) => auth.fetch(`${BASE}/api/messages/${id}/exclude`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ excluded }) }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
|
||
selectVariant: (id: string) => auth.fetch(`${BASE}/api/messages/${id}/variant`, { method: 'POST' }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
|
||
regenerate: (convId: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/regenerate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messageId }) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
|
||
listSecrets: () => auth.fetch(`${BASE}/api/secrets`).then(r => json<{ id: string; service: string; label: string; active: boolean; mask: string }[]>(r)),
|
||
createSecret: (service: string, label: string, value: string) => auth.fetch(`${BASE}/api/secrets`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ service, label, value }) }).then(r => json(r)),
|
||
activateSecret: (id: string) => auth.fetch(`${BASE}/api/secrets/${id}/activate`, { method: 'POST' }),
|
||
deleteSecret: (id: string) => auth.fetch(`${BASE}/api/secrets/${id}`, { method: 'DELETE' }),
|
||
listContextEntries: () => auth.fetch(`${BASE}/api/context-entries`).then(r => json<{ id: string; keys: string[]; secondary?: string; logic: string; content: string; constant: boolean; scanDepth: number; budgetChars: number; enabled: boolean }[]>(r)),
|
||
saveContextEntry: (body: Record<string, unknown>) => auth.fetch(`${BASE}/api/context-entries`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json(r)),
|
||
deleteContextEntry: (id: string) => auth.fetch(`${BASE}/api/context-entries/${id}`, { method: 'DELETE' }),
|
||
listPromptCommands: () => auth.fetch(`${BASE}/api/prompt-commands`).then(r => json<{ id: string; name: string; title: string; template: string }[]>(r)),
|
||
savePromptCommand: (body: { name: string; title: string; template: string }) => auth.fetch(`${BASE}/api/prompt-commands`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json(r)),
|
||
deletePromptCommand: (id: string) => auth.fetch(`${BASE}/api/prompt-commands/${id}`, { method: 'DELETE' }),
|
||
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 }[]>,
|
||
|
||
// ---- U4:RDM 命令总线封装(POST /api/commands/{id},同 RDM web 的 command 路由) ----
|
||
// receipt 解包:rejected 抛错;confirm-card 抛提示(审计面只读命令不应触发)。
|
||
rdmProjectCommand: async <T = any>(project: Project, id: string, params: Record<string, unknown> = {}): Promise<T> => {
|
||
const error = rdmProjectCommandError(project, id)
|
||
if (error) throw new Error(error)
|
||
return api.rdmCommand<T>(id, { ...params, projectId: rdmCommandProjectId(project) })
|
||
},
|
||
rdmCommand: <T = any>(id: string, params: Record<string, unknown> = {}): Promise<T> =>
|
||
auth.fetch(`${BASE}/api/commands/${encodeURIComponent(id)}`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ params }),
|
||
}).then(r => json<{ receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T } | T>(r))
|
||
.then(out => {
|
||
const envelope = out as { receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T }
|
||
if (envelope?.receipt?.state === 'rejected') throw new Error(envelope.receipt.error || envelope.receipt.message || '命令被拒绝')
|
||
if (envelope?.card) throw new Error('高危命令已生成确认卡——请在对话中确认执行')
|
||
if (envelope && envelope.payload !== undefined) return envelope.payload
|
||
return out as T
|
||
}),
|
||
}
|
||
|
||
// ---- SSE 客户端:Last-Event-ID 游标续传;断线自动重连 ----
|
||
|
||
export interface SSEHandlers {
|
||
onEvent: (e: StreamEvent) => void
|
||
onOpen?: () => void
|
||
onError?: (err: Error) => void
|
||
}
|
||
|
||
// 契约里每一种事件都要有监听。漏掉一种时 tsc 会报缺字段。
|
||
const streamEventListeners: Record<StreamEventType, true> = {
|
||
'message.delta': true,
|
||
'thinking.delta': true,
|
||
'tool.call.delta': true,
|
||
'tool.call': true,
|
||
'tool.output': true,
|
||
'tool.result': true,
|
||
'run.phase': true,
|
||
'run.check': true,
|
||
'hitl.request': true,
|
||
'hitl.resolved': true,
|
||
'plan.update': true,
|
||
'todo.update': true,
|
||
'agent.spawn': true,
|
||
'agent.progress': true,
|
||
'agent.result': true,
|
||
'artifact.created': true,
|
||
'artifact.updated': true,
|
||
'run.completed': true,
|
||
'run.failed': true,
|
||
'error': true,
|
||
'card.created': true,
|
||
'card.updated': true,
|
||
'card.status': true,
|
||
'card.resolved': true, 'surface.updated': true,
|
||
}
|
||
|
||
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 = Object.keys(streamEventListeners) as StreamEventType[]
|
||
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() } }
|
||
}
|