mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 02:01:57 +00:00
f887cdc30a
- 设置 → 通知:渠道多一种格式「邮件」,邮件服务没启用时不列;个人邮件渠道不填地址,写「发到账号邮箱 …」(账号没有邮箱照实写 发不出去),全局、应用的填收件地址;投递记录里邮件成功写「已交给邮件服务器」、悬停说明退信不追踪;新增「邮件服务」一节 (管理员改、搭建者只看:口令留空不改、可清除,发测试邮件要先保存)和「每天的待办汇总」的发送时间 - 设置 → 偏好设置的「应用」一节:「允许建分享链接」开关(缺省关,写明关掉时已有的一并打不开) - 应用编辑 → 岗位与授权:分享链接的管理(选页面、始终最新 / 固定这一期、有效期 1–90 天、允许嵌入;链接只显示这一次; 列出状态、到期、打开次数,撤销先确认);开关关着时不给新建,已有的照列能撤销;个人应用写明不能分享 - /s 分享页:不进外壳(App 在 /s 上整个跳过认人、取目录、心跳、读主题,请求层在 /s 上也不送去登录页),key 从 # 后面读、 放进请求头;同一个 ViewRenderer 渲染、操作全禁用;印章常显(「数据的来历」逐个列视图的印章);点数字说明出处在下载的 报告里;页脚「只读 · 由 X 于 … 分享 · … 到期」;撤销、到期、关了开关、链接不完整按服务端的话写明;没允许嵌入的分享在别的 页面的 iframe 里不显示内容、给「在新窗口打开」 - ViewRenderer 加 onEvidence:点数字交给调用方(分享页),不开在线证据面板 - check-copy 加「快照链接」一条(总方案 X10:写「分享链接」),带自检
1253 lines
69 KiB
TypeScript
1253 lines
69 KiB
TypeScript
import type {
|
||
ActivateSnapshotBody, ActivateSnapshotOut, AiPreview, CatalogAssistantEvent, CatalogAssistantRequest, CatalogDetail, CatalogDraftJob,
|
||
CatalogImpact, CatalogImportReport, CatalogList, CatalogNotes, CatalogPatchPreview, CatalogPatchSubmit, CatalogProfileJob, CatalogProfileTaskDetail,
|
||
CatalogReviewAction, CatalogReviewBatchItem, CatalogReviewBatchResult, CatalogReviewKind, CatalogReviewProgress, CatalogReviewQueue,
|
||
CatalogScopeAdd, CatalogSearch, CatalogTableUsage, CommitOut, CurrentRecipe, EditPreview, IntrospectJob, IntrospectScope,
|
||
IntrospectScopeRules,
|
||
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, DataSourceName, 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 type { UnlistedRuns } from '../typesApp'
|
||
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, dispositionName } from './bundle'
|
||
import { compare } from './compare'
|
||
import { inquiry } from './inquiry'
|
||
import { schedules } from './schedules'
|
||
import { whatif } from './whatif'
|
||
import { reportExport } from './reportExport'
|
||
// 应用平台:各自的接口文件,这里只挂上去
|
||
import { apps, ledger } from './apps'
|
||
import { actions, tasks } from './tasks'
|
||
import { auth, identity } from './auth'
|
||
import { accessKeys } from './accessKeys'
|
||
import { capabilities, endpoints, notify } from './capabilities'
|
||
// 普通用户建应用(typesBlueprint.ts 是接口约定)
|
||
import { blueprints, files, personal, templates } from './blueprints'
|
||
|
||
// 请求层搬到了 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)
|
||
|
||
/** 数据目录的接口前缀:/datasources/{id}/catalog */
|
||
const catalogBase = (sourceId: string) => `/datasources/${encodeURIComponent(sourceId)}/catalog`
|
||
|
||
/**
|
||
* 上传表格被退回、要用户先选怎么处理(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):只看不存,表结构缓存和同步时间都没动。配置里的那个 schema 刷新了对象索引(stored),别的 schema
|
||
* 只看不存;table_count 是对象索引里的对象数,scope 是按探查范围的规则数的命中
|
||
*/
|
||
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[]
|
||
/** 老服务端没有下面几项 */
|
||
stored?: boolean
|
||
scope?: { hits: number; cap: number; in_scope: number; out_of_scope: number; over: boolean } & Record<string, number | boolean>
|
||
index_method?: string
|
||
index_capped?: boolean
|
||
}
|
||
|
||
/** 探查结构(后台任务)的回执、任务进度、停止:任务和数据源此刻的样子 */
|
||
export interface IntrospectOut {
|
||
job: IntrospectJob
|
||
source: DataSource
|
||
}
|
||
|
||
/** 一张表的结构化列信息(和给模型看的 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}`),
|
||
/**
|
||
* origin:新工作流出自哪次运行、哪段对话、哪一轮(问数据的「在画布里打开」带上,next-round 02 做法 9)。服务端核对运行
|
||
* 读得到、对话用得了、这一轮属于这段对话,对不上回 422(workflow_origin_invalid)
|
||
*/
|
||
create: (body: {
|
||
name: string; description?: string; graph?: GraphSpec; tags?: string[]
|
||
origin?: { run_id: string; conversation_id?: string; turn_id?: 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`),
|
||
/** 退役一个已发布的版本(A1):当前发布版 409;返回受影响的应用。只有管理员(关登录时所有人) */
|
||
retire: (id: string, v: number, note?: string) =>
|
||
post<{ retired: boolean; affected_apps: { app_id: string; name: string; app_version: number }[] }>(
|
||
`/workflows/${id}/versions/${v}/retire`, { note: note ?? null }),
|
||
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),
|
||
/**
|
||
* 只有名字和说明(D-c):开了登录、管理员允许使用者问数据时,使用者的问数据页按它列数据源(完整列表只给搭建者)。
|
||
* 形状按 {items} 或数组两种都认
|
||
*/
|
||
names: async (opts?: RequestOptions): Promise<DataSourceName[]> => {
|
||
const raw = await get<DataSourceName[] | { items: DataSourceName[] }>('/datasources/names', opts)
|
||
return Array.isArray(raw) ? raw : raw?.items ?? []
|
||
},
|
||
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 })
|
||
},
|
||
/**
|
||
* 探查结构:后台任务(202,回 {job, source})。规则命中超过上限 409 scope_too_large(带 hits、cap);同源已有探查任务
|
||
* 409 introspect_busy;对象清单都拿不到 400 introspect_failed(失败照实记进了表结构缓存)。真探查只按配置里的 schema
|
||
*/
|
||
introspect: (id: string) => post<IntrospectOut>(`/datasources/${encodeURIComponent(id)}/introspect`, {}),
|
||
introspectJob: (id: string, jobId: string, opts?: RequestOptions) =>
|
||
get<IntrospectOut>(`/datasources/${encodeURIComponent(id)}/introspect-jobs/${encodeURIComponent(jobId)}`, opts),
|
||
activeIntrospectJob: (id: string, opts?: RequestOptions) =>
|
||
get<{ job: IntrospectJob | null }>(`/datasources/${encodeURIComponent(id)}/introspect-jobs/active`, opts),
|
||
/** 停止探查:排队的直接停,进行中的停在当前这一批之后,读完的表照常更新进表结构。已经结束的 409 introspect_finished */
|
||
cancelIntrospect: (id: string, jobId: string) =>
|
||
post<IntrospectOut>(`/datasources/${encodeURIComponent(id)}/introspect-jobs/${encodeURIComponent(jobId)}/cancel`, {}),
|
||
/** 探查范围:规则、命中数(对着存着的对象索引数,不连库)、一律在范围内的表、按需加入的留痕 */
|
||
introspectScope: (id: string, opts?: RequestOptions) =>
|
||
get<IntrospectScope>(`/datasources/${encodeURIComponent(id)}/introspect-scope`, opts),
|
||
/** 按改了一半的规则数命中(不存、不连库) */
|
||
previewIntrospectScope: (id: string, scope: IntrospectScopeRules, opts?: RequestOptions) =>
|
||
request<IntrospectScope>(`/datasources/${encodeURIComponent(id)}/introspect-scope/preview`,
|
||
{ method: 'POST', body: JSON.stringify({ scope }), signal: opts?.signal }),
|
||
/** 保存探查范围的规则:下一次探查时生效。规则不合规 422 scope_invalid */
|
||
saveIntrospectScope: (id: string, scope: IntrospectScopeRules) =>
|
||
put<IntrospectScope>(`/datasources/${encodeURIComponent(id)}/introspect-scope`, { scope }),
|
||
/** 换个 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 时再请起草模型起草,带上数据源的起草要求和这次的 note)。
|
||
* 不给 tables 时按使用次数取前 20 张;表结构里没有的表照样建进任务、记 skipped。人工确认、驳回过的项不动。
|
||
* 409:同一个数据源已有排队、进行中的任务(code 为 catalog_draft_busy,改去取 activeDraftJob);起草模型用不了
|
||
* (catalog_draft_model_unavailable,不建任务);没有表结构
|
||
*/
|
||
createDraftJob: (sourceId: string, body: { tables?: string[]; use_model: boolean; note?: string }) =>
|
||
post<{ job: CatalogDraftJob }>(`${catalogBase(sourceId)}/draft-jobs`, body),
|
||
/** 排队或进行中的那个起草任务;没有为 null */
|
||
activeDraftJob: (sourceId: string, opts?: RequestOptions) =>
|
||
get<{ job: CatalogDraftJob | null }>(`${catalogBase(sourceId)}/draft-jobs/active`, opts),
|
||
/** 最近建的一个起草任务(任何状态);没有为 null */
|
||
latestDraftJob: (sourceId: string, opts?: RequestOptions) =>
|
||
get<{ job: CatalogDraftJob | null }>(`${catalogBase(sourceId)}/draft-jobs/latest`, opts),
|
||
/** 一个起草任务的当前状态;不存在或不属于这个数据源回 404 */
|
||
draftJob: (sourceId: string, jobId: string, opts?: RequestOptions) =>
|
||
get<{ job: CatalogDraftJob }>(`${catalogBase(sourceId)}/draft-jobs/${encodeURIComponent(jobId)}`, opts),
|
||
/**
|
||
* 停止起草:排队的直接停;进行中的停掉正在进行的模型调用,没开始和没写完的表记已停止,已经写进目录的不回滚。
|
||
* 已经结束的回 409(catalog_draft_finished)
|
||
*/
|
||
cancelDraftJob: (sourceId: string, jobId: string) =>
|
||
post<{ job: CatalogDraftJob }>(`${catalogBase(sourceId)}/draft-jobs/${encodeURIComponent(jobId)}/cancel`),
|
||
/**
|
||
* 只重试失败的表:新任务只含原任务里失败、部分完成、已停止的表,用模型和补充说明沿用,起草要求取数据源现在的。
|
||
* 没有可重试的表 422;有进行中的任务 409(catalog_draft_busy);和建任务一样,起草模型用不了(原任务用模型时)
|
||
* 409 catalog_draft_model_unavailable、没有表结构 409
|
||
*/
|
||
retryDraftJob: (sourceId: string, jobId: string) =>
|
||
post<{ job: CatalogDraftJob }>(`${catalogBase(sourceId)}/draft-jobs/${encodeURIComponent(jobId)}/retry`),
|
||
/** 存数据源的起草要求(去首尾空白,最多 2000 字,空串清除)。上传表格建的源存在它自己的数据源上 */
|
||
putGuidance: (sourceId: string, guidance: string) =>
|
||
put<{ guidance: string | null }>(`${catalogBase(sourceId)}/guidance`, { guidance }),
|
||
/** 在对象索引里按名字、注释找表(含探查范围外的);清单只列范围内的,范围外的靠它找到再加入 */
|
||
search: (sourceId: string, q: string, opts?: RequestOptions) =>
|
||
get<CatalogSearch>(`${catalogBase(sourceId)}/search${qs({ q, limit: 50 })}`, opts),
|
||
/** 把范围外的表加进探查范围:当场读结构,并记进探查范围的「显式列出」 */
|
||
addToScope: (sourceId: string, tables: string[]) => post<CatalogScopeAdd>(`${catalogBase(sourceId)}/scope/add`, { tables }),
|
||
/**
|
||
* 剖析任务(后台进行:逐表对业务库发只读查询,一条一条发,每张表一结束就写进目录)。剖析会对业务库发查询,默认关闭,
|
||
* 按数据源开启。和同一个数据源的起草任务按建立顺序排队(起草在排队、进行中时剖析建成 queued、waiting_for 为 draft)
|
||
*/
|
||
profileJobs: {
|
||
/**
|
||
* 建任务:tables 非空时只剖析这些表(最多 500 张,按给的顺序,表结构里没有、视图、没有可剖析的项照样建进任务、记跳过),
|
||
* 否则按 scope(缺省 needed:需要剖析的表;all:全部可剖析的表)由服务端挑。202。
|
||
* 409 带机读码:profile_disabled、datasource_inactive、schema_missing、snapshot_tampered、profile_busy(这个源已有排队、
|
||
* 进行中的剖析任务,detail 写明进行到第几张)、profile_nothing(按范围挑出 0 张表,detail 写明原因和建议)
|
||
*/
|
||
create: (sourceId: string, body: { tables?: string[]; scope?: 'needed' | 'all' }) =>
|
||
post<{ job: CatalogProfileJob }>(`${catalogBase(sourceId)}/profile-jobs`, body),
|
||
/** 排队或进行中的那个剖析任务;没有为 null */
|
||
active: (sourceId: string, opts?: RequestOptions) =>
|
||
get<{ job: CatalogProfileJob | null }>(`${catalogBase(sourceId)}/profile-jobs/active`, opts),
|
||
/** 最近建的一个剖析任务(任何状态);没有为 null */
|
||
latest: (sourceId: string, opts?: RequestOptions) =>
|
||
get<{ job: CatalogProfileJob | null }>(`${catalogBase(sourceId)}/profile-jobs/latest`, opts),
|
||
/** 一个剖析任务的当前状态;不属于这个数据源回 404 */
|
||
get: (sourceId: string, jobId: string, opts?: RequestOptions) =>
|
||
get<{ job: CatalogProfileJob }>(`${catalogBase(sourceId)}/profile-jobs/${encodeURIComponent(jobId)}`, opts),
|
||
/** 任务里一张表的完整结果:检查清单、发现、跳过的项、日期范围、发过的查询。找不到 404 */
|
||
task: (sourceId: string, jobId: string, seq: number, opts?: RequestOptions) =>
|
||
get<{ task: CatalogProfileTaskDetail }>(
|
||
`${catalogBase(sourceId)}/profile-jobs/${encodeURIComponent(jobId)}/tasks/${encodeURIComponent(String(seq))}`, opts),
|
||
/**
|
||
* 停止:排队的直接停;进行中的不再等正在执行的那条查询(数据库按单条时限截止它),这张表已做完的项照常写进目录、
|
||
* 没做的保留旧结论,后面的表记已停止。收尾写完才回停止之后的状态;已经结束的回 409(catalog_profile_finished)
|
||
*/
|
||
cancel: (sourceId: string, jobId: string) =>
|
||
post<{ job: CatalogProfileJob }>(`${catalogBase(sourceId)}/profile-jobs/${encodeURIComponent(jobId)}/cancel`),
|
||
/**
|
||
* 重试没做完的表:新任务只含原任务里失败、部分完成、已停止的表(按原顺序)。202;没有可重试的表 422;
|
||
* 这个源已有排队、进行中的剖析任务 409(profile_busy)
|
||
*/
|
||
retry: (sourceId: string, jobId: string) =>
|
||
post<{ job: CatalogProfileJob }>(`${catalogBase(sourceId)}/profile-jobs/${encodeURIComponent(jobId)}/retry`),
|
||
},
|
||
// ---- 审阅队列(只列推断项,按表的使用次数、这一项对数字的影响和来源排好)
|
||
/** 一页待确认项。kind:全部 / 影响数字的项 / 名称和含义;table 只看这一张表 */
|
||
reviewQueue: (sourceId: string, params: { limit?: number; offset?: number; kind?: CatalogReviewKind; table?: string | null },
|
||
opts?: RequestOptions) =>
|
||
get<CatalogReviewQueue>(`${catalogBase(sourceId)}/review-queue${qs({ limit: 50, offset: 0, kind: 'all', ...params })}`, opts),
|
||
/**
|
||
* 成批确认、驳回、撤销审阅(最多 200 条):逐表按 if_version 乐观锁,一张表冲突不影响别的表,同一张表的几条一次写入。
|
||
* 逐条回结果:成功的带这张表写入后的版本,冲突、不合法的带原因和机读码
|
||
*/
|
||
reviewBatch: (sourceId: string, items: CatalogReviewBatchItem[]) =>
|
||
post<{ results: CatalogReviewBatchResult[] }>(`${catalogBase(sourceId)}/review/batch`, { items }),
|
||
/** 审阅进度:使用次数前 top_n 张表、全部表、影响数字的项 */
|
||
reviewProgress: (sourceId: string, opts?: RequestOptions) =>
|
||
get<CatalogReviewProgress>(`${catalogBase(sourceId)}/review-progress`, opts),
|
||
/** 用过这张表的运行,新的在前 */
|
||
usage: (sourceId: string, table: string, limit = 20, opts?: RequestOptions) =>
|
||
get<CatalogTableUsage>(`${catalogBase(sourceId)}/${encodeURIComponent(table)}/usage${qs({ limit })}`, opts),
|
||
// ---- 导出导入
|
||
/**
|
||
* 导出这个数据源的数据目录(GET /catalog/export,附件):回来的是文件本身和服务端给的文件名,没给就是 null,由调用方
|
||
* 按数据源名补。原样存下,不解析:导入时交回的就是这份
|
||
*/
|
||
exportFile: async (sourceId: string): Promise<{ blob: Blob; name: string | null }> => {
|
||
const path = `${catalogBase(sourceId)}/export`
|
||
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)
|
||
return { blob: await res.blob(), name: dispositionName(res.headers.get('Content-Disposition')) }
|
||
},
|
||
/** 导入的预览(只读):哪些表对上了,各有几项新增、更新、保留、冲突、跳过,冲突逐条列出 */
|
||
importPreview: (sourceId: string, body: { content: unknown; include_guidance: boolean }) =>
|
||
post<CatalogImportReport>(`${catalogBase(sourceId)}/import/preview`, body),
|
||
/**
|
||
* 导入:规则同预览,逐表乐观锁,一张表失败不影响别的。有排队、进行中的起草任务时 409(catalog_draft_busy),剖析任务时
|
||
* 409(profile_busy);
|
||
* 文件格式不对 422(detail 是一句中文)
|
||
*/
|
||
importCatalog: (sourceId: string, body: { content: unknown; include_guidance: boolean }) =>
|
||
post<CatalogImportReport>(`${catalogBase(sourceId)}/import`, body),
|
||
},
|
||
|
||
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
|
||
/** 按运行来源筛(X3);none 是来源未记录的老运行 */
|
||
origin_kind?: string
|
||
}) => get<Run[]>(`/runs${qs(params)}`),
|
||
/**
|
||
* 同样的筛选下,列表因为「系统派生的运行默认不列」而没列出的次数(评测、口径对账、口径召回,next-round X1)。按来源
|
||
* 筛了的什么都不藏,total 为 0。老后端没有这个接口(404),调用方当作 0
|
||
*/
|
||
unlisted: (params?: {
|
||
workflow_id?: string; status?: RunStatus | RunStatus[] | string; run_class?: 'formal' | 'exploratory'
|
||
q?: string; origin_kind?: string
|
||
}) => get<UnlistedRuns>(`/runs/unlisted${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
|
||
/** 运行来源(X3):客户端只能声明 chat、canvas、launcher、api(不传按 api 记),其余由服务端写 */
|
||
origin?: 'chat' | 'canvas' | 'launcher' | 'api'
|
||
/**
|
||
* 问数据的运行属于哪段对话、哪一轮(只在 origin 是 chat 时收,next-round 02 做法 9):服务端核对后写进运行来源、在封存
|
||
* 范围内。对话、轮次对不上回 422(origin_ref_invalid)
|
||
*/
|
||
origin_ref?: { conversation_id: string; turn_id: string }
|
||
}) => 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'),
|
||
/** 按存着的邮件服务设置发一封测试邮件,发到管理员自己的账号邮箱(管理员;关登录、没有邮箱时 422) */
|
||
mailTest: () => post<{ ok: boolean; message: string }>('/settings/mail/test'),
|
||
},
|
||
|
||
// ---- 工具 ----
|
||
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 }),
|
||
/** from:问数据回答上点「做成应用」时带上这次运行出自哪段对话的哪一轮,记进新工作流的来源 */
|
||
fromRun: (runId: string, name?: string, from?: { conversation_id?: string | null; turn_id?: string | null }) =>
|
||
post<any>('/copilot/from-run', { run_id: runId, name, ...(from ?? {}) }),
|
||
getModel: () => get<CopilotModel>('/copilot/model'),
|
||
setModel: (body: { provider?: string | null; model?: string | null }) =>
|
||
put<CopilotModel>('/copilot/model', body),
|
||
/** 跑完之后复核一次。干净的运行后端直接返回 verdict='ok',不调模型 */
|
||
/** attempt:这一轮已经自动试了几次(从 0 数),服务端据此决定给不给 retry_instruction。老服务端不认它,忽略 */
|
||
review: (body: { run_id: string; question: string; attempt?: number }) =>
|
||
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,
|
||
reportExport,
|
||
|
||
// ---- 应用平台(typesApp.ts 是接口约定) ----
|
||
apps,
|
||
ledger,
|
||
tasks,
|
||
actions,
|
||
auth,
|
||
identity,
|
||
// 访问密钥与服务账号(下一轮第 2 波)
|
||
accessKeys,
|
||
capabilities,
|
||
endpoints,
|
||
notify,
|
||
|
||
// ---- 普通用户建应用(typesBlueprint.ts 是接口约定) ----
|
||
blueprints,
|
||
templates,
|
||
files,
|
||
personal,
|
||
}
|
||
|
||
/**
|
||
* 流式生成工作流: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()
|
||
}
|
||
|
||
/** 表助手一轮结束时的情况:error 是给人看的那句话(开流前被拒时带状态码和机读码);aborted 是点了停止 */
|
||
export interface CatalogAssistantEnd {
|
||
error?: string
|
||
status?: number
|
||
code?: string
|
||
aborted?: boolean
|
||
}
|
||
|
||
/**
|
||
* 表助手的一轮(POST /datasources/{id}/catalog/{table}/assistant):回答是事件流,每个事件一行 data: <JSON>,到一个回调
|
||
* 一个(text 增量、query 只读查询、proposal 修改提案、done、error)。返回取消函数,就是「停止回答」。
|
||
*
|
||
* 开流之前就被拒(表不在 404、起草模型用不了 409 catalog_draft_model_unavailable、请求太长 422)时 onEnd 带服务端那句话、
|
||
* 状态码和机读码;流到一半断了和一开始就连不上一样,说无法连接服务。认不出的行(心跳、半截)跳过
|
||
*/
|
||
export function streamCatalogAssistant(
|
||
sourceId: string,
|
||
table: string,
|
||
body: CatalogAssistantRequest,
|
||
onEvent: (event: CatalogAssistantEvent) => void,
|
||
onEnd: (end: CatalogAssistantEnd) => void,
|
||
): () => void {
|
||
const controller = new AbortController()
|
||
const path = `${catalogBase(sourceId)}/${encodeURIComponent(table)}/assistant`
|
||
const emit = (line: string) => {
|
||
const text = line.replace(/\r$/, '')
|
||
if (!text.startsWith('data:')) return
|
||
const payload = text.slice(5).trim()
|
||
if (!payload) return
|
||
try {
|
||
const event = parseBody(payload)
|
||
if (event && typeof event === 'object' && typeof event.type === 'string') onEvent(event as CatalogAssistantEvent)
|
||
} catch { /* 认不出的一行,跳过 */ }
|
||
}
|
||
void (async () => {
|
||
try {
|
||
const res = await fetch(BASE + path, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream', ...actorHeader() },
|
||
body: JSON.stringify(body),
|
||
signal: controller.signal,
|
||
})
|
||
if (!res.ok || !res.body) {
|
||
const err = failure(res.status, res.statusText, await res.text().catch(() => ''), path)
|
||
onEnd(err.kind === 'network' ? { error: NETWORK_MESSAGE } : { error: err.message, status: res.status, code: err.code })
|
||
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')) >= 0) {
|
||
emit(buffer.slice(0, idx))
|
||
buffer = buffer.slice(idx + 1)
|
||
}
|
||
}
|
||
buffer += decoder.decode()
|
||
if (buffer) emit(buffer)
|
||
onEnd({})
|
||
} catch (e: any) {
|
||
if (e?.name === 'AbortError') { onEnd({ aborted: true }); return }
|
||
if (e instanceof TypeError) {
|
||
report(false, new ApiError(0, NETWORK_MESSAGE, { kind: 'network', raw: String(e) }))
|
||
onEnd({ error: NETWORK_MESSAGE })
|
||
return
|
||
}
|
||
onEnd({ error: e?.message ?? NETWORK_MESSAGE })
|
||
}
|
||
})()
|
||
return () => controller.abort()
|
||
}
|
||
|
||
/**
|
||
* 订阅一次运行的事件流。
|
||
*
|
||
* 先补历史再接实时由后端保证,这里负责断线重连——重连时带上已收到的最大 seq。
|
||
*
|
||
* `after` 必须由调用方给:lastSeq 是这次调用的闭包局部量,跨调用会归零。
|
||
* 审批恢复时会重新 attachRun,不传 after 就是 after=0,后端按 `seq > after`
|
||
* 把整条历史再推一遍,而消费方多半是无条件追加——事件就这么重复累积了。
|
||
* 已经有事件在手的场景(重新接上一条运行)应当传当前最大 seq。
|
||
*
|
||
* 连不上 WebSocket 时退回轮询(应用平台 D-c):握手连续 WS_GIVE_UP 次都没打开(服务端按运行判权限,拒绝时握手
|
||
* 直接断开;代理不转发 WebSocket 也是这样),改为每 POLL_MS 取一次 /runs/{id}/events?after= 和运行状态,运行不在
|
||
* 进行中时照 stream.end 的样子收尾。第一批是补的历史,同 WebSocket 的回放一样标 replay
|
||
*/
|
||
const WS_GIVE_UP = 2
|
||
const POLL_MS = 1500
|
||
/** 不在进行中的状态:同服务端 api/runs._SETTLED,另加服务重启挂起的 suspended(轮询时没有后续事件会来) */
|
||
const SETTLED_STATUS = new Set(['succeeded', 'failed', 'cancelled', 'interrupted', 'suspended'])
|
||
|
||
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
|
||
/** 握手连续失败(一次都没打开过就断了)几次 */
|
||
let refused = 0
|
||
let pollTimer: ReturnType<typeof setTimeout> | undefined
|
||
|
||
const finish = (status?: string) => {
|
||
if (closed) return
|
||
closed = true
|
||
onClose?.({ status })
|
||
}
|
||
|
||
const poll = async (first: boolean) => {
|
||
if (closed) return
|
||
let delay = POLL_MS
|
||
try {
|
||
const events = await api.runs.events(runId, lastSeq)
|
||
if (closed) return
|
||
for (const event of events) {
|
||
if (event.seq) lastSeq = Math.max(lastSeq, event.seq)
|
||
onEvent(first ? { ...event, replay: true } : event)
|
||
}
|
||
// 这一批没有新事件时才看状态:运行刚结束时最后几条事件和状态可能前后脚落库,先把事件收完
|
||
if (!events.length) {
|
||
const run = await api.runs.get(runId)
|
||
if (closed) return
|
||
if (SETTLED_STATUS.has(String(run.status))) {
|
||
// 状态落了、最后几条事件可能刚落库:再补一次再收尾
|
||
const rest = await api.runs.events(runId, lastSeq).catch(() => [] as RunEvent[])
|
||
for (const event of rest) {
|
||
if (event.seq) lastSeq = Math.max(lastSeq, event.seq)
|
||
onEvent(event)
|
||
}
|
||
finish(run.status)
|
||
return
|
||
}
|
||
}
|
||
retry = 0
|
||
} catch {
|
||
// 断线、服务重启:退避重试,最多等 10 秒
|
||
retry += 1
|
||
delay = Math.min(POLL_MS * 2 ** retry, 10_000)
|
||
}
|
||
if (!closed) pollTimer = setTimeout(() => void poll(false), delay)
|
||
}
|
||
|
||
const connect = () => {
|
||
if (closed) return
|
||
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
|
||
let opened = false
|
||
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') {
|
||
socket?.close()
|
||
// 新后端放在 data.status,老后端放在顶层 status
|
||
finish(event.data?.status ?? event.status)
|
||
return
|
||
}
|
||
if (event.seq) lastSeq = Math.max(lastSeq, event.seq)
|
||
onEvent(event)
|
||
}
|
||
socket.onclose = () => {
|
||
if (closed) return
|
||
if (!opened) refused += 1
|
||
if (refused >= WS_GIVE_UP) {
|
||
socket = null
|
||
void poll(true)
|
||
return
|
||
}
|
||
// 指数退避重连,最多等 5 秒
|
||
retry += 1
|
||
setTimeout(connect, Math.min(300 * 2 ** retry, 5000))
|
||
}
|
||
socket.onopen = () => {
|
||
opened = true
|
||
refused = 0
|
||
retry = 0
|
||
report(true)
|
||
}
|
||
}
|
||
connect()
|
||
|
||
return () => {
|
||
closed = true
|
||
clearTimeout(pollTimer)
|
||
socket?.close()
|
||
}
|
||
}
|