Files
AgentLab/frontend/src/api/client.ts
T
ThearchyHelios 62aac187d8 工具审批三档、智能体护栏取代固定 12 步、检查脚本分道并行
MCP / 自定义工具的审批三档(tools/trust.py)
- 平台看不出这两类工具做什么,以前审批关卡一律当它们安全、照跑不误。现在默认每次调用都等审批;
  工具页可以逐个改成「始终允许 · 门控把关」或「始终允许」,审批卡上的「始终允许」等于前者
- 门控把关:每次调用先由门控模型审一遍工具说明、参数和节点任务;判可疑、出错、超时、答不成格式,
  一律交还给人,不会变成放行。判定包进 checkpoint,审批恢复重放时不再问一遍
- agent 节点、工具节点、协作团队三处关卡都接上;团队成员停不下来等人,门控没放行的调用不执行
- 每次运行在发起时快照档位(runs.tool_trust),恢复和接着跑沿用同一份;正式运行不看档位,一律等审批;
  升级前发起的运行照旧不问——它们可能正停在某个审批上,多问一次答复就会对错号
- 接口:GET /api/tools 带 trust / trust_key,PUT /api/tools/trust;设置新增门控模型;新事件 tool.gated
- 界面:工具页三选一、审批卡「始终允许」、设置页门控模型、运行记录解码 tool.gated;
  顺手修了 MCP 工具详情 404、工具审批被驳回却显示成放行

智能体护栏(engine/guards.py),取代固定的「最多 12 步」
- 步数只留兜底:默认 100,设置里可改;以前画布自动写进节点的 12 按「没填」处理
- 同一个工具、同样的参数第二次不再执行,把上次的结果交还给它;连续 3 步拿不到新信息就收尾
- 每个节点的令牌预算(默认 200 万)和金额预算(默认不限),都可以在设置里改成不限
- 上下文用到窗口的 60% 先压缩早期的工具结果,到 85% 就收尾;临近任何一个上限先提醒模型一次
- 下游结构化校验失败时写明是上游哪个 agent 提前收了尾;复核信号带上收尾原因,
  问数据页只在真是步数用满时才给「放宽步数重跑」
- 上限在发起时快照(runs.agent_limits);升级前发起的运行一字不改走旧逻辑

检查脚本
- check-all 默认 4 道并行:量时间最多的两项互不重叠;并行时没过的最后单独重跑一遍,重跑才过的
  单列成「偶发」。16 项全量从约 21 分钟降到约 7 分钟(加上一次重跑约 10 分钟);--lanes 1 回到串行

顺带修的
- 设置接口里的 limits 改为只读:库里存着旧版设置页写进去的一份(max_agent_steps: 25),从不生效,
  却盖住了真实上限,问数据页照着错的数许诺
- test_kb 里嵌入模型那条测试不再真连 api.openai.com(代理慢时一条要等近 4 分钟)
- check-stream 读滚动容器前先等它挂上,并行跑时不再偶发

验证(沙箱):tsc 干净,生产构建通过,pytest 1655 passed(两遍),check-all 16 项检查、3562 项全过;
新增后端测试 53 条,关键逻辑做了 22 处变异验证、全部抓到。
2026-09-28 11:47:45 +08:00

882 lines
39 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 {
Approval, Conversation, ConversationDetail, ConversationTurn, CustomTool, DataSource, EvidenceGraph,
EvidenceSegmentDetail, GraphSpec, KbDocument, MemoryItem, Provider, ReviewResult, Run, RunEvent, RunStatus,
Skill, ToolChange, ToolInfo, ToolTrust, ValidationIssue, VarIssue, Variable, Workflow, WorkflowVersion,
} from '../types'
import { localActor } from '../lib/actor'
import { VALIDATION_TITLE, describeValidation } from '../lib/validation'
const BASE = '/api'
/**
* network:请求根本没到后端(没起、在重启、代理断了、超时没回)。
* http:后端回了错误状态码,message 是后端的 detail。
*/
export type ApiErrorKind = 'network' | 'http'
export const NETWORK_MESSAGE = '连不上后端服务(可能没启动或正在重启),稍后重试'
export class ApiError extends Error {
kind: ApiErrorKind
/** 后端 detail 的原样(字符串、校验错误数组都可能) */
detail?: unknown
/** 原始报错文本,给「技术细节」和复制用 */
raw?: string
/** 超时断开时等了多久 */
timeoutMs?: number
/**
* 后端的机读码(app/api/coded.py 的 {detail, code},比如 datasource_scope_empty)。
* detail 是给人看的话,随时可能改写;要按错误种类分支的认这个
*/
code?: string
constructor(
public status: number,
message: string,
opts?: { kind?: ApiErrorKind; detail?: unknown; raw?: string; timeoutMs?: number; code?: string },
) {
super(message)
this.name = 'ApiError'
this.kind = opts?.kind ?? (status === 0 ? 'network' : 'http')
this.detail = opts?.detail
this.raw = opts?.raw
this.timeoutMs = opts?.timeoutMs
this.code = opts?.code
}
}
/**
* 连接状态的旁听者:任何一次请求够着了后端(不管状态码)就报 true,网络层失败
* 报 false。catalog 靠它维护全站的「后端连没连上」,不用另起一路轮询去猜。
*/
type ConnectivityListener = (reachable: boolean, error?: ApiError) => void
const connectivity = new Set<ConnectivityListener>()
export function onConnectivity(listener: ConnectivityListener): () => void {
connectivity.add(listener)
return () => { connectivity.delete(listener) }
}
function report(reachable: boolean, error?: ApiError) {
for (const l of connectivity) {
try { l(reachable, error) } catch { /* 旁听者出错不能拖垮请求本身 */ }
}
}
function actorHeader(): Record<string, string> {
const actor = localActor()
// 请求头只能是 Latin-1:中文署名原样放进去,fetch 直接抛错、整个请求发不出去。
// 后端 runs.actor_of 解码,纯 ASCII 的老署名编码前后一样
return actor ? { 'X-Actor': encodeURIComponent(actor) } : {}
}
/**
* FastAPI 的 422 是 [{loc, msg, type}] 数组:直接 String() 是 [object Object],照拼 msg
* 又是 pydantic 的英文原文。按 type 翻成中文(lib/validation),path 用来查表单上的叫法
*/
function describeDetail(detail: unknown, path?: string): string {
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) return `${VALIDATION_TITLE}:${describeValidation(detail, path).join(';')}`
try { return JSON.stringify(detail) } catch { return String(detail) }
}
/**
* 网关类失败(代理够不着后端)也算网络失败:vite 代理连不上后端时回 500 且响应体
* 为空,nginx 回 502/503/504 的 HTML。后端自己抛的 HTTPException 一定带 JSON detail。
*/
function isGatewayFailure(status: number, bodyText: string, isJson: boolean): boolean {
if (isJson) return false
if (status === 502 || status === 503 || status === 504) return true
return status === 500 && !bodyText.trim()
}
async function request<T>(path: string, init?: RequestInit & { timeoutMs?: number }): Promise<T> {
const { timeoutMs, ...rest } = init ?? {}
// 自己的超时和调用方的取消要分开:调用方取消照旧抛 AbortError(没人想看到
// 「连不上后端」),只有我们自己掐断的才算网络失败
let timer: ReturnType<typeof setTimeout> | undefined
let timedOut = false
let signal = rest.signal
if (timeoutMs) {
const ctrl = new AbortController()
timer = setTimeout(() => { timedOut = true; ctrl.abort() }, timeoutMs)
rest.signal?.addEventListener('abort', () => ctrl.abort())
signal = ctrl.signal
}
let res: Response
try {
res = await fetch(BASE + path, {
...rest,
signal,
headers: {
...(rest.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
...actorHeader(),
...rest.headers,
},
})
} catch (e: any) {
if (timedOut) {
const err = new ApiError(0, `后端 ${Math.round(timeoutMs! / 1000)} 秒没有响应,稍后重试`,
{ kind: 'network', raw: `timeout after ${timeoutMs}ms: ${path}`, timeoutMs })
report(false, err)
throw err
}
if (e?.name === 'AbortError') throw e
// fetch 的网络失败是 TypeError(Chrome「Failed to fetch」、Safari「Load failed」),
// 原文对用户毫无意义,收进 raw
const err = new ApiError(0, NETWORK_MESSAGE, { kind: 'network', raw: `${e?.name ?? 'Error'}: ${e?.message ?? e}` })
report(false, err)
throw err
} finally {
if (timer) clearTimeout(timer)
}
if (!res.ok) throw failure(res.status, res.statusText, await res.text().catch(() => ''), path)
report(true)
if (res.status === 204) return undefined as T
return res.json()
}
/** 后端 {detail, code} 里的机读码;没有就是 undefined */
const codeOf = (body: unknown): string | undefined => {
const code = body && typeof body === 'object' ? (body as { code?: unknown }).code : undefined
return typeof code === 'string' && code ? code : undefined
}
/** 非 2xx 的响应 → ApiError,顺带报告连接状态。fetch 和 XHR 两条路共用 */
function failure(status: number, statusText: string, text: string, path?: string): ApiError {
let body: any
let isJson = false
try { body = JSON.parse(text); isJson = true } catch { /* 响应体不是 JSON */ }
if (isGatewayFailure(status, text, isJson)) {
const err = new ApiError(status, NETWORK_MESSAGE, {
kind: 'network', raw: `${status} ${statusText}${text ? `\n${text.slice(0, 500)}` : ''}`,
})
report(false, err)
return err
}
report(true)
if (isJson) {
const detail = body?.detail ?? body
return new ApiError(status, describeDetail(detail, path), {
detail, raw: typeof body?.raw === 'string' ? body.raw : `${status} ${text.slice(0, 2000)}`,
code: codeOf(body),
})
}
const message = status >= 500
? `后端出错了(${status}),详情看服务日志`
: `请求没有成功(${status} ${statusText})`
return new ApiError(status, message, { raw: `${status} ${statusText}\n${text.slice(0, 2000)}` })
}
/** 上传进度。字节发完(sent)之后还要等后端解析、切块,那段没有进度可报 */
export interface UploadProgress {
/** 已经发出去的字节 */
loaded: number
/** 总字节;浏览器算不出来时为 null,这时只能画已发多少,不画百分比 */
total: number | null
/** 字节已经全部发完,在等后端处理 */
sent: boolean
}
export interface UploadOptions {
onProgress?: (p: UploadProgress) => void
signal?: AbortSignal
}
/**
* 带字节进度的上传。fetch 拿不到上传进度,只能用 XHR;报错、连接状态、署名头和
* request 走同一套,调用方看到的 ApiError 没有区别。取消照旧抛 AbortError。
*/
function upload<T>(path: string, form: FormData, opts?: UploadOptions): Promise<T> {
return new Promise<T>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('POST', BASE + path)
for (const [k, v] of Object.entries(actorHeader())) xhr.setRequestHeader(k, v)
const onProgress = opts?.onProgress
if (onProgress) {
// 进度事件里的字节数含 multipart 的边界和表头,比文件本身略大;发完那一下
// 沿用它的总数,前后两个数才对得上
let total: number | null = null
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) total = e.total
onProgress({ loaded: e.loaded, total, sent: false })
}
xhr.upload.onload = () => {
const bytes = total ?? [...form.values()].reduce((n, v) => n + (v instanceof Blob ? v.size : 0), 0)
onProgress({ loaded: bytes, total: bytes || null, sent: true })
}
}
const abort = () => xhr.abort()
opts?.signal?.addEventListener('abort', abort)
const cleanup = () => opts?.signal?.removeEventListener('abort', abort)
xhr.onload = () => {
cleanup()
if (xhr.status < 200 || xhr.status >= 300) {
reject(failure(xhr.status, xhr.statusText, xhr.responseText ?? '', path))
return
}
report(true)
if (xhr.status === 204 || !xhr.responseText) { resolve(undefined as T); return }
try {
resolve(JSON.parse(xhr.responseText))
} catch {
reject(new ApiError(xhr.status, '后端回的内容读不懂', { raw: xhr.responseText.slice(0, 2000) }))
}
}
xhr.onerror = () => {
cleanup()
const err = new ApiError(0, NETWORK_MESSAGE, { kind: 'network', raw: `XMLHttpRequest error: POST ${path}` })
report(false, err)
reject(err)
}
xhr.onabort = () => {
cleanup()
reject(new DOMException('上传已取消', 'AbortError'))
}
if (opts?.signal?.aborted) { xhr.abort(); return }
xhr.send(form)
})
}
/**
* 数据源测连接的结果。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
}
/** 还没保存的模型接入配置。带 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__'
/** 拼查询串:null / undefined / 空串跳过,数组用逗号连起来 */
function qs(params?: Record<string, unknown>): string {
if (!params) return ''
const q = new URLSearchParams()
for (const [k, v] of Object.entries(params)) {
if (v == null || v === '') continue
if (Array.isArray(v)) {
if (v.length) q.set(k, v.join(','))
} else {
q.set(k, String(v))
}
}
const s = q.toString()
return s ? `?${s}` : ''
}
/**
* 单次请求的选项。timeoutMs 到点就掐断并按网络失败抛出(带 timeoutMs):连得上但
* 卡着不回的接口(MCP 服务不应答时的 /tools)不能让调用方永远等下去,也不该一直
* 占着浏览器对同一主机的那几条连接
*/
export interface RequestOptions {
timeoutMs?: number
signal?: AbortSignal
}
const get = <T>(p: string, opts?: RequestOptions) => request<T>(p, opts)
const post = <T>(p: string, body?: unknown) =>
request<T>(p, { method: 'POST', body: JSON.stringify(body ?? {}) })
const patch = <T>(p: string, body: unknown) =>
request<T>(p, { method: 'PATCH', body: JSON.stringify(body) })
const put = <T>(p: string, body: unknown) =>
request<T>(p, { method: 'PUT', body: JSON.stringify(body) })
const del = (p: string) => request<void>(p, { method: 'DELETE' })
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 }),
/** 这张图里有哪些变量、谁产出、谁引用。纯静态分析,不需要跑过 */
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 报字节进度 */
uploadTable: (file: File, body: { name: string; description?: string; header_row?: number }, 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))
type Out = {
source: any; replaced: boolean
tables: { name: string; sheet: string; rows: number
columns: { name: string; type: string }[] }[]
}
return opts?.onProgress
? upload<Out>('/datasources/upload', form, opts)
: request<Out>('/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)}`),
},
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 }),
},
// ---- 工具 ----
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),
},
// ---- 会话 ----
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<{
configured: boolean; provider: string | null; model: string | null
effective_provider: string | null; effective_model: string | null
}>('/copilot/model'),
setModel: (body: { provider?: string | null; model?: string | null }) =>
put<any>('/copilot/model', body),
/** 跑完之后复核一次。干净的运行后端直接返回 verdict='ok',不调模型 */
review: (body: { run_id: string; question: string }) =>
post<ReviewResult>('/copilot/review', body),
},
}
/**
* 流式生成工作流: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}),详情看服务日志`
: `请求没有成功(${res.status} ${res.statusText})`, { 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(JSON.parse(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 = JSON.parse(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()
}
}