Files
AgentLab/frontend/src/api/client.ts
T
ThearchyHelios db84e576c0 模型接入报错收尾(第 1 轮复核,前端):反方审稿的模型一栏不再多出一句相反的说明;检查器读工作流默认设置里的模型;助手栏保存失败时退回原样并说明、「当前」只写在跟随默认上、原因的去向写「上方」;接入「启用」的说明另起一行;落到演示模型的修复候选加醒目标签
- 检查器:模型一栏的留空说明(modelDefault)只用于节点定义里的模型栏。反方审稿的「反方模型」自己画、只读节点自己的
  配置、留空跟随设置,以前全图默认点名了接入时也被套上「留空则使用接入「X」的默认模型」,和它自己的说明相反;工作流
  默认设置写了模型时,空选项和说明写「使用工作流默认设置的模型「X」」(后端执行时取的就是它,也不报「没有指定模型」),
  不再说成某个接入的默认模型;
- 助手栏「助手使用的模型」:重新选择时保存失败,下拉、原因、齿轮上的红点退回选之前的样子(服务端存着的没变),下面写
  「保存失败,助手使用的模型未更改」,收起再展开后不再留着;只认最后一次选择的回包;选了具体模型时「跟随默认」不写
  「当前」(那时服务端给的 effective_model 是选的那个模型);下拉下面的原因用服务端的 panel_error;
- 设置页接入编辑框:停用的后果另起一行写在复选框下面(aria-describedby 接上,读屏名只是「启用」),不再和「启用」并排
  放在 label 里把它挤成上下两个字;说法收紧为「停用后,该接入的模型不再出现在下拉列表中,指定它或其模型的节点和设置也
  无法调用」——调不了以后怎样(报错、转人工审批、记未裁判、记缺口)由各组自己那句说,不笼统写「运行时报错」;
- 发布前检查:候选带 demo 标记时,选项名旁加提醒色的「演示模型」标签,说明里那半句照旧(normalizeFix 留下这个标记)。
  types.ts 的 PublishFix 选项加可选的 demo 字段。

检查:check-studio 那一节加反方审稿不套说明(全图默认点名接入、写了模型两种)、工作流默认设置带模型时的空选项和说明、
保存失败退回原样与收起再展开、选具体模型不写「当前」、原因写「上方」;check-manage 量「启用」只占一行(1440、390)、
说明不在 label 里、读屏名只是「启用」、说明在弹窗里;check-publish 的伪造门禁跟上「无法改用其他接入:」与 demo 标记,
加一节演示标签(两个候选各一个、提醒色而非淡色、和选项名同一行、390 宽在弹窗里)。新加的检查项对着改动前的界面跑过,
都会不通过。
2026-10-06 19:43:44 +08:00

897 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
ActivateSnapshotBody, ActivateSnapshotOut, AiPreview, CatalogDetail, CatalogDraftOut, CatalogImpact, CatalogList, CatalogNotes,
CatalogPatchPreview, CatalogPatchSubmit, CatalogProfileOut, CatalogReviewAction, CommitOut, CurrentRecipe, EditPreview,
EditRequest, ImportRecord,
ManifestOut, PurgeRawBody, PurgeRawOut, QuestionAnswer, Recipe, RedraftRulesOut, RemovePeriodBody, RemovePeriodOut,
RevokeAcceptanceBody, RevokeAcceptanceOut, SnapshotOut, Staging,
} from '../types'
import type {
Approval, AutofixResult, Conversation, ConversationDetail, ConversationTurn, CustomTool, DataSource, EvidenceGraph,
EvidenceAudit, EvidenceJudgeResult, EvidenceProvenance, EvidenceSegmentDetail, GraphSpec, KbDocument, MemoryItem, Provider, PublishCheck, PublishLevel, ReviewResult, Run,
RunEvent, RunStatus, Skill, ToolChange, ToolInfo, ToolTrust, UpgradeResult, UploadDecision, UploadMixedColumn, UploadResult,
UploadShapeReason, ValidationIssue, VarIssue, Variable, Workflow, WorkflowVersion,
} from '../types'
import {
ApiError, BASE, NETWORK_MESSAGE, actorHeader, codeOf, del, describeDetail, failure, get, isGatewayFailure, parseBody,
patch, post, put, qs, report, request, upload, type RequestOptions, type UploadOptions,
} from './http'
import { bundle } from './bundle'
import { compare } from './compare'
import { inquiry } from './inquiry'
import { schedules } from './schedules'
import { whatif } from './whatif'
// 请求层搬到了 http.ts(见那边的说明)。原先从这里导出的照旧从这里导出,调用方不用改;
// 请求函数也一并转出,给不在 api/ 下、又想直接发请求的地方用。api/ 下的命名空间文件请直接从 http.ts 导入
export {
ApiError, NETWORK_MESSAGE, del, get, onConnectivity, parseBody, patch, post, put, qs, request, upload,
} from './http'
export type { ApiErrorKind, RequestOptions, UploadOptions, UploadProgress } from './http'
const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
/**
* 上传表格被退回、要用户先选怎么处理(422 带 decision)时,取出那份决定;别的错误返回 null。
*
* 只认界面会画的两种(mixed、shape),而且逐项校验形状:后端以后多一种决定、或者字段对不上,
* 宁可当普通报错弹出那句话(detail 本身就是给人看的),也不画一个空的选择框让人照着点
*/
export function uploadDecision(e: unknown): UploadDecision | null {
if (!(e instanceof ApiError) || e.status !== 422 || !isObj(e.decision)) return null
const { kind, details } = e.decision as { kind?: unknown; details?: unknown }
if (!isObj(details)) return null
if (kind === 'mixed' && mixedColumns(details.columns)) {
return { kind, details: { columns: details.columns as unknown as UploadMixedColumn[] } }
}
if (kind === 'shape' && Array.isArray(details.reasons) && details.reasons.length
&& details.reasons.every((r) => isObj(r) && typeof r.message === 'string')) {
// 预告的混合列只是告知:形状对不上就不画,结构问题照常问
const mixed = mixedColumns(details.mixed) ? details.mixed as unknown as UploadMixedColumn[] : undefined
return {
kind,
details: {
reasons: (details.reasons as Record<string, unknown>[]).map((r) => ({
...r, cells: Array.isArray(r.cells) ? r.cells.map(String) : [],
}) as unknown as UploadShapeReason),
...(mixed ? { mixed, mixed_complete: details.mixed_complete !== false } : {}),
},
}
}
return null
}
/** 混合列清单的形状对不对:非空数组,每项有列名和取值数组 */
const mixedColumns = (v: unknown): boolean => Array.isArray(v) && v.length > 0
&& v.every((c) => isObj(c) && typeof c.column === 'string' && Array.isArray(c.values))
/**
* 数据源测连接的结果。ok=false 时 error 是一句人话、hint 是怎么办、detail 是
* 驱动的原始报错(放进「技术细节」)。
*/
export interface ConnectionTest {
ok: boolean
elapsed_ms?: number
url?: string
error?: string
hint?: string
detail?: string
[key: string]: any
}
/** 模型接入测试的结果,失败时的字段同 ConnectionTest */
export interface ProviderTest {
ok: boolean
latency_ms?: number
model?: string | null
reply?: string
usage?: Record<string, any>
error?: string
hint?: string
detail?: string
[key: string]: any
}
/**
* 助手当前用哪个模型(GET / PUT /api/copilot/model)。provider、model 是存着的设定;存的接入不存在、已停用(或只存了
* 模型名、提供它的接入已停用)时实际什么都用不了:effective_* 为空,error 是和生成时报的同一句。老后端不给 error
*/
export interface CopilotModel {
configured: boolean
provider: string | null
model: string | null
effective_provider: string | null
effective_model: string | null
error?: string | null
/** 同一件事写在助手栏「助手使用的模型」那一格下面的说法:去向写「上方」,不把人指回这个下拉框本身。老后端不给 */
panel_error?: string | null
}
/** 还没保存的模型接入配置。带 id 表示在编辑已有的那个,api_key 留空沿用已存的 */
export interface ProviderDraft {
id?: string
name?: string
kind: string
base_url?: string | null
api_key?: string | null
models?: { id: string; label?: string; context?: number; pricing?: any }[]
default_model?: string | null
extra?: Record<string, any>
}
/** 探查结构的预览(dry_run):只看不存,缓存和同步时间都没动 */
export interface IntrospectPreview {
dry_run: true
/** 这次探的是哪个 schema,'' 是默认 schema */
schema: string
table_count: number
/** 带 schema 前缀的对象全名,最多 200 个 */
tables: string[]
truncated: boolean
total: number
schema_error: string
available_schemas: string[]
}
/** 一张表的结构化列信息(和给模型看的 detail 文本出自同一份缓存) */
export interface SchemaColumn {
name: string
type: string
pk: boolean
not_null: boolean
comment: string | null
}
export interface TableSchema {
table: string
/** 给模型看的那段文本 */
detail: string
/** 缓存里找没找到这张表。老后端没有这个字段:只有明确的 false 才算找不到 */
found?: boolean
qualified: string | null
kind: 'table' | 'view' | null
comment: string | null
/** 老后端没有,只有 detail */
columns?: SchemaColumn[]
}
/** 知识库检索的一条命中 */
export interface KbHit {
chunk_id: string
document_id: string
title: string
ordinal: number
content: string
score: number
signals?: Record<string, number>
/** 向量、关键词两路各贡献了多少分,两者之和等于 score。老后端不给 */
contrib?: { vector: number; keyword: number }
[key: string]: any
}
export interface KbSearchResult {
query: string
collection: string | null
results: KbHit[]
/** 这次检索降级了(比如退回纯关键词)的说明 */
degraded: string[]
/** 实际用的混合权重 */
alpha?: number
[key: string]: any
}
/**
* 后台重建索引的进度。phase:chunks(知识库切块)→ index(倒排,没有分批进度)→
* memories(长期记忆)→ done。total / done 是切块加记忆的合计
*/
export interface ReindexJob {
id: string
state: 'running' | 'done' | 'failed'
phase: 'chunks' | 'index' | 'memories' | 'done'
collection: string | null
total: number
done: number
chunks: { total: number; done: number }
memories: { total: number; done: number }
started_at: string
finished_at: string | null
error: string | null
hint: string | null
result: { reindexed: number; memories_reindexed: number; embedder: string; [key: string]: any } | null
}
/** runs.list 的 workflow_id 传它:只看没保存成工作流的那些运行(问数据页、画布上的临时图) */
export const UNSAVED_WORKFLOW_ID = '__none__'
export const api = {
/** 心跳。带超时:后端卡死(连得上但不回)时也要能判成断开,而不是永远等下去 */
health: (timeoutMs = 5000) => request<{ status: string; service?: string }>('/health', { timeoutMs }),
system: () => get<any>('/system'),
// ---- 工作流 ----
workflows: {
list: (opts?: RequestOptions) => get<Workflow[]>('/workflows', opts),
get: (id: string) => get<Workflow>(`/workflows/${id}`),
create: (body: { name: string; description?: string; graph?: GraphSpec; tags?: string[] }) =>
post<Workflow>('/workflows', body),
update: (id: string, body: Partial<Workflow> & { note?: string }) =>
patch<Workflow>(`/workflows/${id}`, body),
remove: (id: string) => del(`/workflows/${id}`),
duplicate: (id: string) => post<Workflow>(`/workflows/${id}/duplicate`),
versions: (id: string) => get<WorkflowVersion[]>(`/workflows/${id}/versions`),
/** 某个版本的完整快照(含 graph)。正式运行的输入字段要从已发布版本取,不能取画布 */
version: (id: string, v: number) => get<WorkflowVersion>(`/workflows/${id}/versions/${v}`),
restore: (id: string, v: number) => post<Workflow>(`/workflows/${id}/versions/${v}/restore`),
publish: (id: string, level: 'published' | 'governed', version?: number) =>
post<{ ok: boolean; level?: string; version?: number; issues: any[] }>(
`/workflows/${id}/publish`, { level, version }),
validate: (graph: GraphSpec) =>
post<{ ok: boolean; issues: ValidationIssue[] }>('/workflows/validate', { graph }),
/**
* 发布前检查:validate 和发布门禁的并集,和真正发布同一套口径,另给每条问题的修复。
* 只读。不带 graph 就查库里的草稿。老后端没有这个接口(404),调用方按「不支持」处理
*/
publishCheck: (id: string, body: { level: PublishLevel; graph?: GraphSpec }, opts?: RequestOptions) =>
request<PublishCheck>(`/workflows/${id}/publish-check`,
{ method: 'POST', body: JSON.stringify(body), ...opts }),
/** 按选中的修复出一份预览(整张图 + 逐项改动)。只读:不存草稿、不发布。assist 时会调模型,可能要几十秒 */
autofix: (id: string, body: {
level: PublishLevel; graph?: GraphSpec; apply: string[]; choices?: Record<string, unknown>; assist?: boolean
}, opts?: RequestOptions) =>
request<AutofixResult>(`/workflows/${id}/autofix`, { method: 'POST', body: JSON.stringify(body), ...opts }),
/** 这张图里有哪些变量、谁产出、谁引用。纯静态分析,不需要跑过 */
variables: (graph: GraphSpec) =>
post<{ variables: Variable[]; issues: VarIssue[] }>('/workflows/variables', { graph }),
},
// ---- 运行 ----
datasources: {
list: (opts?: RequestOptions) => get<DataSource[]>('/datasources', opts),
kinds: () => get<any>('/datasources/kinds'),
create: (body: any) => post<any>('/datasources', body),
update: (id: string, body: any) => patch<any>(`/datasources/${id}`, body),
remove: (id: string) => del(`/datasources/${id}`),
test: (id: string) => post<ConnectionTest>(`/datasources/${id}/test`, {}),
/** 测一份还没保存的配置,只测连接不落库。编辑时带上 id,后端可以沿用已存的密码 */
testConfig: (body: any) => post<ConnectionTest>('/datasources/test', body),
/**
* 上传 Excel / CSV,变成一个可以用 SQL 查的数据源。同名发布新版本替换。onProgress 报字节进度。
*
* 解析器要用户先拍板时回 422,错误上带着 decision(用 uploadDecision 取);用户选完,带上
* 答案重传:mixed='null' 把数字列里的非数字存为空值,raw_mode=true 按原样导入(未规整)
*/
uploadTable: (file: File, body: {
name: string; description?: string; header_row?: number; mixed?: 'reject' | 'null'; raw_mode?: boolean
}, opts?: UploadOptions) => {
const form = new FormData()
form.append('file', file)
form.append('name', body.name)
form.append('description', body.description ?? '')
form.append('header_row', String(body.header_row ?? 1))
form.append('mixed', body.mixed ?? 'reject')
form.append('raw_mode', body.raw_mode ? 'true' : 'false')
return opts?.onProgress
? upload<UploadResult>('/datasources/upload', form, opts)
: request<UploadResult>('/datasources/upload', { method: 'POST', body: form, signal: opts?.signal })
},
introspect: (id: string, schema?: string) =>
post<any>(`/datasources/${id}/introspect${schema ? `?schema=${encodeURIComponent(schema)}` : ''}`, {}),
/** 换个 schema 看看:只返回探到的结果,不覆盖缓存(Copilot 看到的还是原来那份) */
previewSchema: (id: string, schema?: string) =>
post<IntrospectPreview>(`/datasources/${id}/introspect${qs({ schema, dry_run: 'true' })}`, {}),
schema: (id: string, table?: string) =>
get<any>(`/datasources/${id}/schema${table ? `?table=${encodeURIComponent(table)}` : ''}`),
/** 一张表的结构,带结构化的列信息,不用再去解析 detail 文本 */
tableSchema: (id: string, table: string) =>
get<TableSchema>(`/datasources/${id}/schema?table=${encodeURIComponent(table)}`),
},
/**
* 按配方导入(期 2)。暂存区(staging)是一次还没提交的导入:起草、回答问题、改配方、试运行都在它上面做,
* 提交(commit)成功之前不动数据源的当前版本。错误按 ApiError.code 分支(staging_closed、trial_required……)
*/
tableImports: {
/** 选文件开始一次导入(首次,或同名的简单导入切换为按配方导入)。带字节进度 */
stage: (file: File, body: { name: string; description?: string }, opts?: UploadOptions) => {
const form = new FormData()
form.append('file', file)
form.append('name', body.name)
form.append('description', body.description ?? '')
return upload<Staging>('/datasources/imports/stage', form, opts)
},
get: (id: string) => get<Staging>(`/datasources/imports/${encodeURIComponent(id)}`),
/** 已选的全部回答(服务端从起点配方按问题顺序重放)。needs_reason 的选项要带 reason */
answers: (id: string, answers: Record<string, QuestionAnswer>) =>
post<Staging>(`/datasources/imports/${encodeURIComponent(id)}/answers`, { answers }),
/**
* 整份配方替换工作配方:有静态校验问题也照存,只是不能试运行。期 3:仍然成立的回答保留(不成立的进
* answers_dropped),撤销栈清空、之前的修改标「已被覆盖」。origin 只在采用「按规则重新起草」的结果时给
* 'rules_redraft'(服务端核对配方哈希,对不上回 409 redraft_mismatch);不给时请求体与期 2 相同
*/
putRecipe: (id: string, recipe: Recipe, origin?: 'rules_redraft') =>
put<Staging>(`/datasources/imports/${encodeURIComponent(id)}/recipe`, origin ? { recipe, origin } : { recipe }),
/** 将要发给模型的全文(同意框里给人看的就是这一份) */
draftAiPreview: (id: string) => get<AiPreview>(`/datasources/imports/${encodeURIComponent(id)}/draft-ai/preview`),
/** 用户同意后才调:preview_sha256 与服务端重算的不一致时回 409 ai_preview_stale */
draftAi: (id: string, previewSha256: string) =>
post<Staging>(`/datasources/imports/${encodeURIComponent(id)}/draft-ai`, { consent: true, preview_sha256: previewSha256 }),
trial: (id: string, body: { context_inputs?: Record<string, { start: string; end: string }>; signed_by?: string | null } = {}) =>
post<Staging>(`/datasources/imports/${encodeURIComponent(id)}/trial`, body),
commit: (id: string, body: {
trial_id: string; confirmations: string[]; acceptances: { check_id: string; reason: string }[]; signed_by?: string | null
}) => post<CommitOut>(`/datasources/imports/${encodeURIComponent(id)}/commit`, body),
discard: (id: string) => del(`/datasources/imports/${encodeURIComponent(id)}`),
/** 上传新一期:按当前配方自动试运行,回来时已带回执和差异卡。带字节进度 */
reupload: (sourceId: string, file: File, opts?: UploadOptions) => {
const form = new FormData()
form.append('file', file)
return upload<Staging>(`/datasources/${encodeURIComponent(sourceId)}/reupload`, form, opts)
},
/** 修改配方(不换文件):用当前导入的原件、以当前配方为工作配方 */
redraft: (sourceId: string) => post<Staging>(`/datasources/${encodeURIComponent(sourceId)}/redraft`, {}),
recipe: (sourceId: string) => get<CurrentRecipe>(`/datasources/${encodeURIComponent(sourceId)}/recipe`),
/**
* 期 3:修复或框选的预览(不改工作配方)。body 是 {fix: {id, option, reason?}} 或 {selection: {sheet, ref, as,
* options?}} 二选一,seq 原样回传(只采用最新一次的回包)。ok=false 时照样 200,原因在 problems
*/
editPreview: (id: string, body: EditRequest) =>
post<EditPreview>(`/datasources/imports/${encodeURIComponent(id)}/edits/preview`, body),
/**
* 期 3:应用预览过的修复或框选。选项和理由必须与预览时逐字相同,expected_sha256 是预览返回的
* recipe_sha256_after:对不上回 409 edit_stale(需要重新预览);提议已不在当前列表里回 409 fix_stale
*/
editApply: (id: string, body: EditRequest & { expected_sha256: string; signed_by?: string | null }) =>
post<Staging>(`/datasources/imports/${encodeURIComponent(id)}/edits/apply`, body),
/** 期 3:撤销上一次修改。没有可撤销的回 409 nothing_to_undo;之后配方又被改过回 409 undo_stale */
editUndo: (id: string) => post<Staging>(`/datasources/imports/${encodeURIComponent(id)}/edits/undo`, {}),
/** 期 3:按规则重新起草(不调用模型、不改工作配方),名字对齐到现行配方;「采用」走 putRecipe(…, 'rules_redraft') */
redraftRules: (id: string) =>
post<RedraftRulesOut>(`/datasources/imports/${encodeURIComponent(id)}/redraft-rules`, {}),
},
/**
* 期 3 的版本页(数据源卡片上的「版本」,P3-SPEC 第 7 节):版本列表、启用旧版本、清单、移除一期、作废接受、
* 导入记录与清除原件。写操作都要 confirm: true;启用、移除、作废还要带界面看到的当前版本 id
* (expected_current_snapshot_id),别人在这期间改过当前版本时回 409 base_changed。错误按 ApiError.code 分支
*/
versions: {
/** 最近 50 个版本,当前版本排第一。手工源返回空列表 */
snapshots: (sourceId: string) => get<SnapshotOut[]>(`/datasources/${encodeURIComponent(sourceId)}/snapshots`),
activate: (sourceId: string, snapshotId: string, body: ActivateSnapshotBody) =>
post<ActivateSnapshotOut>(
`/datasources/${encodeURIComponent(sourceId)}/snapshots/${encodeURIComponent(snapshotId)}/activate`, body),
snapshotManifest: (sourceId: string, snapshotId: string) =>
get<ManifestOut>(`/datasources/${encodeURIComponent(sourceId)}/snapshots/${encodeURIComponent(snapshotId)}/manifest`),
importManifest: (sourceId: string, importId: string) =>
get<ManifestOut>(`/datasources/${encodeURIComponent(sourceId)}/imports/${encodeURIComponent(importId)}/manifest`),
/** 从当前版本中移除这一期(累积模式;配方和导入模式不变) */
removePeriod: (sourceId: string, importId: string, body: RemovePeriodBody) =>
post<RemovePeriodOut>(`/datasources/${encodeURIComponent(sourceId)}/imports/${encodeURIComponent(importId)}/remove`, body),
/** 撤回这一期(作废接受,不可恢复):替换模式回滚到 revoke_plan 的目标,累积模式移除这一期 */
revokeAcceptance: (sourceId: string, importId: string, body: RevokeAcceptanceBody) =>
post<RevokeAcceptanceOut>(
`/datasources/${encodeURIComponent(sourceId)}/imports/${encodeURIComponent(importId)}/revoke-acceptance`, body),
/** 全部导入记录,新的在前(含已被替换的) */
imports: (sourceId: string) => get<ImportRecord[]>(`/datasources/${encodeURIComponent(sourceId)}/imports`),
/** 清除一次导入的原件(同一份内容的其他导入一并清除,引用它的未完成导入一并放弃)。理由必填 */
purgeRaw: (sourceId: string, importId: string, body: PurgeRawBody) =>
post<PurgeRawOut>(`/datasources/${encodeURIComponent(sourceId)}/imports/${encodeURIComponent(importId)}/purge-raw`, body),
},
/**
* 业务数据目录(每个数据源一份,按表存)。写操作都带读到的版本(if_version,这张表还没有目录时为 0):别人在这期间
* 改过时回 409,detail 是给人看的话(「表「x」的数据目录刚被修改过,请重新载入后再提交」);格式不对回 422;源或表
* 不存在回 404。署名走请求头 X-Actor(request 统一带上)
*/
dataCatalog: {
/** 表清单:表结构里的每张表各一行,使用次数多的在前;没有表结构时 tables 为空、schema_note 写明原因 */
list: (sourceId: string, opts?: RequestOptions) =>
get<CatalogList>(`/datasources/${encodeURIComponent(sourceId)}/catalog`, opts),
/** 单表目录连同表结构 */
get: (sourceId: string, table: string, opts?: RequestOptions) =>
get<CatalogDetail>(`/datasources/${encodeURIComponent(sourceId)}/catalog/${encodeURIComponent(table)}`, opts),
/** 整份提交:值改过的项、新填的项记为人工填写、已确认;提交里没有的项删掉 */
put: (sourceId: string, table: string, notes: CatalogNotes, ifVersion: number) =>
put<CatalogDetail>(`/datasources/${encodeURIComponent(sourceId)}/catalog/${encodeURIComponent(table)}`,
{ notes, if_version: ifVersion }),
/** 单项审阅。path:表级项写字段名(grain);列级项 columns.<列名>.<字段>;关系 relations.<编号> */
review: (sourceId: string, table: string, body: { path: string; action: CatalogReviewAction; if_version: number }) =>
post<CatalogDetail>(`/datasources/${encodeURIComponent(sourceId)}/catalog/${encodeURIComponent(table)}/review`, body),
/** 影响面:引用这张表的已发布、受管模板(直接引用 / 可能涉及,合并查询按输入追溯) */
impact: (sourceId: string, table: string, opts?: RequestOptions) =>
get<CatalogImpact>(`/datasources/${encodeURIComponent(sourceId)}/catalog/${encodeURIComponent(table)}/impact`, opts),
/** 目录修改提案的预览:对着当前目录重算改前、改后,不写库(保存遇到 409 后重新载入用) */
patchPreview: (sourceId: string, table: string, changes: CatalogPatchSubmit[]) =>
post<CatalogPatchPreview>(
`/datasources/${encodeURIComponent(sourceId)}/catalog/${encodeURIComponent(table)}/patch/preview`, { changes }),
/** 保存目录修改提案:改动过的项记为人工填写、已确认。版本不符 409,有一项不合法 422 */
patch: (sourceId: string, table: string, changes: CatalogPatchSubmit[], ifVersion: number) =>
post<CatalogDetail>(`/datasources/${encodeURIComponent(sourceId)}/catalog/${encodeURIComponent(table)}/patch`,
{ changes, if_version: ifVersion }),
/**
* 同步起草(注释、外键、命名推断;use_model 时再请助手的模型起草)。不给 tables 时按使用次数取前 20 张。
* 人工确认、驳回过的项不动。没有表结构时回 409
*/
draft: (sourceId: string, body: { tables?: string[]; use_model: boolean }, opts?: RequestOptions) =>
request<CatalogDraftOut>(`/datasources/${encodeURIComponent(sourceId)}/catalog/draft`,
{ method: 'POST', body: JSON.stringify(body), ...opts }),
/**
* 同步剖析:对业务库发少量只读查询,核对关系、取码值候选,结论写进目录。不给 tables 时按使用次数取前 10 张
* 有待核实关系的表。没开启、数据源停用、没有表结构、同一个数据源正在剖析、快照被改动时回 409(detail 是一句中文)
*/
profile: (sourceId: string, body: { tables?: string[] }, opts?: RequestOptions) =>
request<CatalogProfileOut>(`/datasources/${encodeURIComponent(sourceId)}/catalog/profile`,
{ method: 'POST', body: JSON.stringify(body), ...opts }),
},
runs: {
/**
* status 可以给多个(逗号连接);q 按工作流名模糊匹配;before 是翻页游标
* (上一页最后一条的 created_at);limit 上限 200。老后端不认的参数会被忽略。
*/
list: (params?: {
workflow_id?: string; status?: RunStatus | RunStatus[] | string; run_class?: 'formal' | 'exploratory'
q?: string; limit?: number; before?: string
}) => get<Run[]>(`/runs${qs(params)}`),
get: (id: string) => get<Run>(`/runs/${id}`),
/** 这次运行实际跑的那张图(运行时的快照),不是工作流现在的样子 */
graph: (id: string) => get<{ graph: GraphSpec; workflow_id: string | null; version: number | null }>(
`/runs/${id}/graph`),
start: (body: {
workflow_id?: string; graph?: GraphSpec; input?: Record<string, any>
memory_scope?: string; collection?: string
run_class?: 'formal' | 'exploratory'; version?: number
}) => post<Run>('/runs', body),
artifacts: (id: string) => get<any[]>(`/runs/${id}/artifacts`),
/** 从失败的节点接着跑。graph 只能带改过配置的同一张图,结构必须一致 */
continue: (id: string, graph?: GraphSpec | null) =>
post<Run>(`/runs/${id}/continue`, { graph: graph ?? null }),
// 等审批的运行直接收成 cancelled;在跑的要等引擎收尾,先回 stopping
cancel: (id: string) => post<{ ok: boolean; status?: 'stopping' | 'cancelled' }>(`/runs/${id}/cancel`),
/**
* 回复审批。approvalId 指明回复的是哪一条(一次运行里可能有几条同时等着)。
* 审批卡的「始终允许」走这里:response 多带 always,/approvals/{id}/decide 的请求体没有这一项
*/
resume: (id: string, response: any, approvalId?: string) =>
post<Run>(`/runs/${id}/resume`, approvalId ? { response, approval_id: approvalId } : { response }),
events: (id: string, after = 0) => get<RunEvent[]>(`/runs/${id}/events?after=${after}`),
state: (id: string) => get<any>(`/runs/${id}/state`),
history: (id: string) => get<any[]>(`/runs/${id}/history`),
/** 核对事件流和封存时的清单哈希:事后被改过、删过、插过都会对不上 */
verify: (id: string) => get<{ sealed: boolean; ok: boolean | null; message: string }>(
`/runs/${id}/verify`),
/** 运行中的删不了(409);封存过的正式运行要 force,否则也是 409,detail 写明后果 */
remove: (id: string, opts?: { force?: boolean }) =>
del(`/runs/${id}${opts?.force ? '?force=true' : ''}`),
},
approvals: {
/** 老写法 list('pending') 照旧可用。status 可逗号分隔,'all' 取全部 */
list: (params: string | { status?: string; run_id?: string; limit?: number } = 'pending', opts?: RequestOptions) =>
get<Approval[]>(`/approvals${qs(typeof params === 'string' ? { status: params } : { status: 'pending', ...params })}`, opts),
decide: (id: string, body: { approved: boolean; note?: string; value?: any; args?: any }) =>
post<Run>(`/approvals/${id}/decide`, body),
},
// ---- 设置 ----
providers: {
list: (opts?: RequestOptions) => get<Provider[]>('/providers', opts),
catalog: () => get<any>('/providers/catalog'),
create: (body: any) => post<Provider>('/providers', body),
update: (id: string, body: any) => patch<Provider>(`/providers/${id}`, body),
remove: (id: string) => del(`/providers/${id}`),
test: (id: string, body: { model?: string; prompt?: string }) =>
post<ProviderTest>(`/providers/${id}/test`, body),
/** 测一份还没保存的接入配置,不落库。model 不填用 default_model */
testConfig: (body: ProviderDraft & { model?: string | null; prompt?: string }) =>
post<ProviderTest>('/providers/test', body),
/** 问这个接入点有哪些模型(带鉴权调 /v1/models),省得手敲模型 id */
models: (body: ProviderDraft) =>
post<{ ok: boolean; models: string[]; url?: string; error?: string; hint?: string; detail?: string }>(
'/providers/models', body),
},
settings: {
get: () => get<Record<string, any>>('/settings'),
put: (values: Record<string, any>) => put<Record<string, any>>('/settings', { values }),
/** 今天(本地日期)结论句裁判花了多少:设置页放在每日上限旁边。只读,计数只有裁判自己记 */
judgeSpend: () => get<{ date?: string; usd?: number; calls?: number; unpriced_calls?: number
daily_max_usd?: number | null }>('/settings/judge/spend'),
},
// ---- 工具 ----
tools: {
list: (opts?: RequestOptions) => get<ToolInfo[]>('/tools', opts),
/** 危险工具要 confirm,否则后端回 409,detail 写明它会做什么 */
run: (name: string, args: Record<string, any>, opts?: { confirm?: boolean }) =>
post<any>(`/tools/${name}/run`, opts?.confirm ? { args, confirm: true } : { args }),
/** MCP / 自定义工具的信任档。key 是 ToolInfo.trust_key;设回 ask 等于删掉这一条 */
setTrust: (key: string, trust: ToolTrust) =>
put<{ key: string; trust: ToolTrust }>('/tools/trust', { key, trust }),
},
customTools: {
/** 每行带 problem:库里存着的参数定义写坏了时是一句中文,绑了它的节点一定失败 */
list: () => get<CustomTool[]>('/custom-tools'),
/** 参数定义写坏了回 422,detail 是一句中文(ApiError.message),显示在参数字段下面 */
create: (body: any) => post<CustomTool>('/custom-tools', body),
update: (id: string, body: any) => patch<CustomTool>(`/custom-tools/${id}`, body),
remove: (id: string) => del(`/custom-tools/${id}`),
test: (id: string, args: Record<string, any>) => post<any>(`/custom-tools/${id}/test`, { args }),
/** 试跑一份还没保存的配置,不落库。失败时回 {ok:false, error, hint, detail, duration_ms} */
testDraft: (body: {
name?: string; kind: string; parameters?: Record<string, any>; config?: Record<string, any>
args?: Record<string, any>
}) => post<any>('/custom-tools/test', body),
},
mcp: {
list: () => get<any[]>('/mcp/servers'),
create: (body: any) => post<any>('/mcp/servers', body),
update: (id: string, body: any) => patch<any>(`/mcp/servers/${id}`, body),
remove: (id: string) => del(`/mcp/servers/${id}`),
probe: (id: string) => post<any>(`/mcp/servers/${id}/probe`),
refresh: () => post<any>('/mcp/refresh'),
},
// ---- 沙箱 ----
sandbox: {
health: () => get<any>('/sandbox/health'),
exec: (body: { code: string; language?: string; timeout?: number; network?: boolean; memory_mb?: number }) =>
post<any>('/sandbox/exec', body),
},
// ---- 记忆 / 知识库 / Skill ----
memory: {
scopes: () => get<{ scope: string; count: number }[]>('/memory/scopes'),
list: (scope?: string) => get<MemoryItem[]>(`/memory${scope ? `?scope=${scope}` : ''}`),
add: (body: { content: string; scope?: string; kind?: string; importance?: number }) =>
post<MemoryItem>('/memory', body),
search: (q: string, scope = 'default', opts?: { peek?: boolean; limit?: number }) =>
get<any>(`/memory/search${qs({ q, scope, peek: opts?.peek ? 'true' : undefined, limit: opts?.limit })}`),
/**
* 回忆调试:默认 peek,只看不计数。真实召回会给命中项 use_count += 1 并影响
* 下次排序,调试台点几下就会把「被召回 N 次」抬高
*/
recall: (q: string, opts?: { scope?: string; peek?: boolean; limit?: number }) =>
get<any>(`/memory/search${qs({
q, scope: opts?.scope ?? 'default', peek: (opts?.peek ?? true) ? 'true' : undefined, limit: opts?.limit,
})}`),
/** 原地修改,保留来源和召回记录 */
update: (id: string, body: { content?: string; importance?: number; kind?: string }) =>
patch<MemoryItem>(`/memory/${id}`, body),
remove: (id: string) => del(`/memory/${id}`),
clearScope: (scope: string) => del(`/memory/scope/${scope}`),
},
kb: {
collections: (opts?: RequestOptions) =>
get<{ collection: string; documents: number; chunks: number }[]>('/kb/collections', opts),
documents: (collection?: string) =>
get<KbDocument[]>(`/kb/documents${collection ? `?collection=${collection}` : ''}`),
ingest: (body: { collection?: string; title?: string; content: string; source?: string }) =>
post<KbDocument>('/kb/documents', body),
/**
* 上传一份文档。给了 onProgress 就报真实的字节进度(XHR);字节发完以后是后端
* 在解析、切块,那段没有进度,调用方写「处理中」,不要把条拉满冒充完成
*/
upload: (file: File, collection = 'default', opts?: UploadOptions) => {
const form = new FormData()
form.append('file', file)
const path = `/kb/upload?collection=${encodeURIComponent(collection)}`
return opts?.onProgress
? upload<KbDocument>(path, form, opts)
: request<KbDocument>(path, { method: 'POST', body: form, signal: opts?.signal })
},
search: (q: string, collection?: string, alpha = 0.5) =>
get<KbSearchResult>(`/kb/search?q=${encodeURIComponent(q)}&alpha=${alpha}${collection ? `&collection=${encodeURIComponent(collection)}` : ''}`),
/** 上传框能收哪些文件。以后端解析器为准,免得界面放行了后端必拒的格式 */
formats: () => get<{
extensions: string[]; text: string[]; tabular: string[]; legacy: string[]; accept: string
}>('/kb/formats'),
/** 一份文档被切成了什么样。检索不准时第一个该看的就是它 */
document: (id: string) => get<{
document: KbDocument
chunks: {
id: string; ordinal: number; content: string; truncated: boolean
chars: number; token_len: number; embed_model: string; has_vector: boolean
}[]
}>(`/kb/documents/${id}`),
remove: (id: string) => del(`/kb/documents/${id}`),
embedding: (collection?: string) => get<{
embedder: string; dim: number; configured: boolean
kind: string; model: string; base_url: string
stale_chunks: number; stale_memories: number; unindexed_chunks: number
// has_semantics 看的是实际在用的那个;fallback = 配了语义模型却退回了本地哈希
has_semantics: boolean; fallback: boolean; fallback_reason: string
/** 运行时 kb_search 实际用的混合权重。调试台的滑块初值要跟它一致 */
default_alpha?: number
}>(`/kb/embedding${collection ? `?collection=${collection}` : ''}`),
setEmbedding: (body: { kind: string; model?: string; base_url?: string }) =>
put<{
embedder: string; dim: number
stale_chunks: number; stale_memories: number
}>('/kb/embedding', body),
probeEmbedding: (baseUrl: string) =>
get<{ base_url: string; models: string[] }>(
`/kb/embedding/probe?base_url=${encodeURIComponent(baseUrl)}`),
/** 等做完再返回。几千段配远端 embedding 要好几分钟,界面上用 startReindex */
reindex: (collection?: string) =>
post<{ reindexed: number; memories_reindexed: number; embedder: string }>(
`/kb/reindex${qs({ collection })}`),
/**
* 后台重建,立刻返回任务;进度用 reindexStatus 轮询。已经有一次在跑时回 409,
* detail 写着进度
*/
startReindex: (collection?: string) =>
post<ReindexJob>(`/kb/reindex${qs({ collection, background: 'true' })}`),
/** 最近一次重建的进度。进程里只记最近一次,还没重建过是 {state:'idle'} */
reindexStatus: () => get<ReindexJob | { state: 'idle' }>('/kb/reindex'),
},
skills: {
list: (opts?: RequestOptions) => get<Skill[]>('/skills', opts),
create: (body: Partial<Skill>) => post<Skill>('/skills', body),
update: (id: string, body: Partial<Skill>) => patch<Skill>(`/skills/${id}`, body),
remove: (id: string) => del(`/skills/${id}`),
},
// ---- 治理 ----
governance: {
toolUsage: (workflowId: string) =>
get<any>(`/governance/tool-usage?workflow_id=${workflowId}`),
clusters: () => get<any>('/governance/exploratory-clusters'),
},
artifact: (id: string) => get<{ id: string; content: any }>(`/artifacts/${id}`),
// ---- 可点击证据 ----
// 只认封存范围内的事件追得到的工件(后端 api/evidence.py)。老后端没有这两个接口,调用方按 404 降级
evidence: {
/** 整次运行的证据图:模式(cited / legacy_contract / none)、封存状态、报告和证据清单 */
graph: (runId: string, opts?: RequestOptions) =>
get<EvidenceGraph>(`/runs/${encodeURIComponent(runId)}/evidence`, opts),
/**
* 点开一个片段:所在的句子、指标步骤(原式、代入式、输入、复算)、封存状态。一次运行
* 里有好几份报告时 report 给报告节点 id(片段 id 每份文档各自从 s0 数起),不给取成果标注的那份
*/
segment: (runId: string, segmentId: string, opts?: RequestOptions & { report?: string }) =>
get<EvidenceSegmentDetail>(
`/runs/${encodeURIComponent(runId)}/evidence/segments/${encodeURIComponent(segmentId)}${qs({ report: opts?.report })}`,
opts),
/**
* 审计表:每个有状态的片段按状态分组(有出处 / 无证据 / 可疑实体 / 旧运行猜测),附上封存核对的结果。
* groups 只要这几组(none、suspicious…)
*/
audit: (runId: string, opts?: RequestOptions & { groups?: string[] }) =>
get<EvidenceAudit>(`/runs/${encodeURIComponent(runId)}/evidence/audit${qs({ groups: opts?.groups?.join(',') || undefined })}`, opts),
/**
* 按需裁判(四期,只在探索运行里有):请模型判断这几句结论。答复是 evidence.judged 事件的载荷
* (判定、花费、触顶的上限),判定标 post_seal:落在封存之后,不改封存的文档。正式运行回 409。
* 一次运行里有好几份报告时 report 给报告节点 id
*/
judge: (runId: string, body: { units: string[]; report?: string }) =>
post<EvidenceJudgeResult>(`/runs/${encodeURIComponent(runId)}/evidence/judge`, body),
/**
* 推断的来源(期 4):上传表格的一个单元格来自原表的哪一格、这次查询查的是哪一版数据、相关核对。按需算,
* 推断不出来不是错误(200,结论在 status、reason、alert 里)。只在片段接口的查询步骤带 provenance: true 时请求;
* 老服务端没有这个接口,回 404 且没有 code。report 同片段接口
*/
provenance: (runId: string, segmentId: string, opts?: RequestOptions & { report?: string }) =>
get<EvidenceProvenance>(
`/runs/${encodeURIComponent(runId)}/evidence/segments/${encodeURIComponent(segmentId)}/provenance${qs({ report: opts?.report })}`,
opts),
/**
* 导出审计表:?format=json / csv 作为附件下载。回来的是文件本身和后端给的文件名
* (Content-Disposition;没给就用 evidence-<运行号前 8 位>.<格式>)
*/
auditExport: async (runId: string, format: 'json' | 'csv', groups?: string[]): Promise<{ blob: Blob; name: string }> => {
const path = `/runs/${encodeURIComponent(runId)}/evidence/audit${qs({ format, groups: groups?.join(',') || undefined })}`
let res: Response
try {
res = await fetch(BASE + path, { headers: actorHeader() })
} catch (e: any) {
const err = new ApiError(0, NETWORK_MESSAGE, { kind: 'network', raw: `${e?.name ?? 'Error'}: ${e?.message ?? e}` })
report(false, err)
throw err
}
if (!res.ok) throw failure(res.status, res.statusText, await res.text().catch(() => ''), path)
report(true)
const named = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(res.headers.get('Content-Disposition') ?? '')?.[1]
return { blob: await res.blob(), name: named ? decodeURIComponent(named) : `evidence-${runId.slice(0, 8)}.${format}` }
},
},
// ---- 会话 ----
conversations: {
/** includeArchived:连回收站里的一起取(每行的 archived 区分),回收站视图要它 */
list: (kind: 'chat' | 'canvas' = 'chat', workflowId?: string, opts?: { includeArchived?: boolean }) =>
get<Conversation[]>(
`/conversations?kind=${kind}`
+ (workflowId ? `&workflow_id=${encodeURIComponent(workflowId)}` : '')
+ (opts?.includeArchived ? '&include_archived=true' : '')),
create: (body: { kind?: 'chat' | 'canvas'; workflow_id?: string; title?: string }) =>
post<ConversationDetail>('/conversations', body),
get: (id: string) => get<ConversationDetail>(`/conversations/${id}`),
update: (id: string, body: { title?: string; archived?: boolean }) =>
patch<Conversation>(`/conversations/${id}`, body),
remove: (id: string) => del(`/conversations/${id}`),
startTurn: (id: string, question: string) =>
post<ConversationTurn>(`/conversations/${id}/turns`, { question }),
patchTurn: (id: string, turnId: string, body: Partial<ConversationTurn>) =>
patch<ConversationTurn>(`/conversations/${id}/turns/${turnId}`, body),
},
// ---- Copilot ----
copilot: {
generate: (body: {
instruction: string; base_graph?: GraphSpec | null
conversation_id?: string | null
/** 这一轮只查这几个数据源(id 或名字)。不传或空表示不限 */
datasource_ids?: string[] | null
}) =>
post<{
graph: GraphSpec; explanation: string; issues: ValidationIssue[]
layout?: { mode: 'keep' | 'full'; placed: string[] }
/** 改图前后都在、工具绑定却变了的节点和成员。老后端不给 */
tool_changes?: ToolChange[]
}>('/copilot/generate', body),
explain: (graph: GraphSpec) => post<{ explanation: string }>('/copilot/explain', { graph }),
layout: (graph: GraphSpec) => post<GraphSpec>('/copilot/layout', { graph }),
fromRun: (runId: string, name?: string) =>
post<any>('/copilot/from-run', { run_id: runId, name }),
getModel: () => get<CopilotModel>('/copilot/model'),
setModel: (body: { provider?: string | null; model?: string | null }) =>
put<CopilotModel>('/copilot/model', body),
/** 跑完之后复核一次。干净的运行后端直接返回 verdict='ok',不调模型 */
review: (body: { run_id: string; question: string }) =>
post<ReviewResult>('/copilot/review', body),
/**
* 一键升级为可追溯结构:先按确定的规则改写(llm 换成报告撰写、问数据的图插入报告节点……),
* assist 时再交给 Copilot 改语义层。level 是复核门禁用的发布级别(受管的图,新写出的报告节点按受管的要求配)。
* 只给预览,不改库。老后端没有这个接口(404),调用方按「不支持」处理
*/
upgradeEvidence: (body: { graph: GraphSpec; assist?: boolean; level?: PublishLevel }, opts?: RequestOptions) =>
request<UpgradeResult>('/copilot/upgrade-evidence', { method: 'POST', body: JSON.stringify(body), ...opts }),
},
// ---- 复核套件:各功能的接口在自己的文件里(api/<名字>.ts),这里只挂上去 ----
schedules,
compare,
bundle,
inquiry,
whatif,
}
/**
* 流式生成工作流:SSE 逐操作回调,返回取消函数。
* 每个操作(add_node / add_edge / …)到达即回调,画布边收边长。
*/
export function streamCopilot(
body: {
instruction: string
base_graph?: GraphSpec | null
provider?: string | null
model?: string | null
/** answer = 用户在问问题(问数据页),build = 用户在描述流程(画布)*/
intent?: 'build' | 'answer'
/** 属于哪次对话。带上它,这一轮才知道前面聊过什么 */
conversation_id?: string | null
/** 这一轮只查这几个数据源(id 或名字)。不传或空表示不限 */
datasource_ids?: string[] | null
},
// final 操作里带 tool_changes(ToolChange[]):改图回执要显式列出工具绑定的变化
onOp: (op: any) => void,
// 开流之前就被拒(4xx/5xx)时第二个参数带状态码和后端的机读码({detail, code}),调用方按码分支
onEnd: (error?: string, info?: { status?: number; code?: string }) => void,
): () => void {
const controller = new AbortController()
void (async () => {
try {
const res = await fetch(`${BASE}/copilot/generate-stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...actorHeader() },
body: JSON.stringify(body),
signal: controller.signal,
})
if (!res.ok || !res.body) {
const text = await res.text().catch(() => '')
let body: any
let isJson = false
try { body = JSON.parse(text); isJson = true } catch { /* 响应体不是 JSON */ }
if (isGatewayFailure(res.status, text, isJson)) {
report(false, new ApiError(res.status, NETWORK_MESSAGE, { kind: 'network' }))
onEnd(NETWORK_MESSAGE)
return
}
report(true)
onEnd(isJson ? describeDetail(body?.detail ?? body, '/copilot/generate-stream')
: res.status >= 500 ? `服务端错误(${res.status}),详情请查看服务日志`
: `请求失败(HTTP ${res.status})`, { status: res.status, code: isJson ? codeOf(body) : undefined })
return
}
report(true)
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
for (;;) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let idx: number
while ((idx = buffer.indexOf('\n\n')) >= 0) {
const frame = buffer.slice(0, idx)
buffer = buffer.slice(idx + 2)
if (!frame.startsWith('data: ')) continue
try {
onOp(parseBody(frame.slice(6)))
} catch { /* 半截帧,忽略 */ }
}
}
onEnd()
} catch (e: any) {
if (e?.name === 'AbortError') { onEnd(); return }
// 流到一半断了和一开始就连不上,对用户是一回事:后端够不着了
if (e instanceof TypeError) {
report(false, new ApiError(0, NETWORK_MESSAGE, { kind: 'network', raw: String(e) }))
onEnd(NETWORK_MESSAGE)
return
}
onEnd(e?.message ?? '连接中断,请重试')
}
})()
return () => controller.abort()
}
/**
* 订阅一次运行的事件流。
*
* 先补历史再接实时由后端保证,这里负责断线重连——重连时带上已收到的最大 seq。
*
* `after` 必须由调用方给:lastSeq 是这次调用的闭包局部量,跨调用会归零。
* 审批恢复时会重新 attachRun,不传 after 就是 after=0,后端按 `seq > after`
* 把整条历史再推一遍,而消费方多半是无条件追加——事件就这么重复累积了。
* 已经有事件在手的场景(重新接上一条运行)应当传当前最大 seq。
*/
export function streamRun(
runId: string,
onEvent: (event: RunEvent) => void,
// stream.end 带着运行的最终状态:连到一条已经不在进行中的运行(含 interrupted)
// 时,客户端靠它知道不会再有事件,不能一直当它还在跑
onClose?: (end?: { status?: RunStatus | string }) => void,
after = 0,
): () => void {
let socket: WebSocket | null = null
let closed = false
let lastSeq = after
let retry = 0
const connect = () => {
if (closed) return
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
socket = new WebSocket(`${proto}://${location.host}/api/runs/${runId}/stream?after=${lastSeq}`)
socket.onmessage = (ev) => {
const event = parseBody(ev.data) as RunEvent & { type: string; status?: string }
if (event.type === 'stream.end') {
closed = true
socket?.close()
// 新后端放在 data.status,老后端放在顶层 status
onClose?.({ status: event.data?.status ?? event.status })
return
}
if (event.seq) lastSeq = Math.max(lastSeq, event.seq)
onEvent(event)
}
socket.onclose = () => {
if (closed) return
// 指数退避重连,最多等 5 秒
retry += 1
setTimeout(connect, Math.min(300 * 2 ** retry, 5000))
}
socket.onopen = () => {
retry = 0
report(true)
}
}
connect()
return () => {
closed = true
socket?.close()
}
}