mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:31:58 +00:00
界面与交互全面翻修(前两波):运行态有了 HUD 和时间轴,终态会收尾,全站令牌、组件、文案统一
来自 2026-09 的 UI/UX 审查(142 条)。本提交是前两波,第三波(记录页接入时间轴、跨页互链、check-ui 更新、全站扫尾)另行提交。 地基 - 设计令牌:全局规则和 .btn/.field/.chip 放进 CSS 层,Tailwind 工具类重新生效,选中态、开启态不再被吞掉;字号阶梯; 辅助文字在所有底色上 >= 4.5:1;状态色专用,节点类型色与之拉开色相;「跟随系统」真的跟随,首帧不闪深色;新字标与 favicon - 基础组件:confirmDialog / promptDialog 取代原生对话框;toast 出错不自动消失、可复制、读屏可播报;离线横幅与断线恢复; 九种剪影的状态徽标;唯一一份状态文案(lib/status)、格式化(lib/format)、术语(lib/terms)、报错人话(lib/errors) - 运行态内核:事件折成航迹(run/trace.ts),可投影到任意时刻;取消 / 失败 / 服务重启后统一收尾,不再永远转圈; run.status 随事件回写;token 事件不再堆进 events - 后端:协作成员逐个完成即发事件,调度者决策有事件;时长分执行 / 墙钟 / 等人;API 时间带时区; 中断的运行也发 stream.end;运行列表可筛选翻页;删除运行 / 工作流有保护;运行默认值接通;可测未保存的配置; 报错说清发生了什么、为什么、怎么办 功能 - 画布:状态四重编码、固定高度遥测槽、按缩放三档、运行胶囊与 HUD、航迹时间轴与回放、执行轨迹边、 等待闸门、跟随执行、正式运行只读、一次性时刻(开场点亮、回扫、阻断级联、出具印章) - 编辑:撤销重做、版本历史、问题面板可定位、三栏可收起、模板与表达式字段区分、变量血缘、快捷键、带搜索的选择器 - 回答流 / 问数据:贴底才跟随滚动、工件下钻、出具横幅列出缺口、答案可复制导出、按会话的忙闲、 答案不再在 2000 字处截断、重启后这一轮会收尾 - 记录页:待审批 / 运行中 / 失败页签、筛选翻页、实时接流、排错路径指到节点、封存凭证常驻 - 管理页:新的「数据」页、写操作失败都有反馈、弹窗里先测连接、统一持久的连接状态 - 外壳:导航改版、⌘K 命令面板、? 快捷键帮助、404、页面标题与 favicon 状态点、后台提醒 验证(沙箱):tsc 干净,pytest 579 passed,14 个检查脚本中 13 个全绿;check-ui 剩 1 项旧断言(按旧日期格式找运行行),第三波更新。
This commit is contained in:
+276
-48
@@ -1,50 +1,218 @@
|
||||
import type {
|
||||
Approval, Conversation, ConversationDetail, ConversationTurn, GraphSpec,
|
||||
KbDocument, MemoryItem, Provider, ReviewResult, Run, RunEvent, Skill, ToolInfo,
|
||||
ValidationIssue, VarIssue, Variable, Workflow,
|
||||
KbDocument, MemoryItem, Provider, ReviewResult, Run, RunEvent, RunStatus, Skill, ToolInfo,
|
||||
ValidationIssue, VarIssue, Variable, Workflow, WorkflowVersion,
|
||||
} from '../types'
|
||||
|
||||
const BASE = '/api'
|
||||
|
||||
/**
|
||||
* network:请求根本没到后端(没起、在重启、代理断了、超时没回)。
|
||||
* http:后端回了错误状态码,message 是后端的 detail。
|
||||
*/
|
||||
export type ApiErrorKind = 'network' | 'http'
|
||||
|
||||
export const NETWORK_MESSAGE = '连不上后端服务(可能没启动或正在重启),稍后重试'
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
kind: ApiErrorKind
|
||||
/** 后端 detail 的原样(字符串、校验错误数组都可能) */
|
||||
detail?: unknown
|
||||
/** 原始报错文本,给「技术细节」和复制用 */
|
||||
raw?: string
|
||||
/** 超时断开时等了多久 */
|
||||
timeoutMs?: number
|
||||
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
opts?: { kind?: ApiErrorKind; detail?: unknown; raw?: string; timeoutMs?: number },
|
||||
) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接状态的旁听者:任何一次请求够着了后端(不管状态码)就报 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> {
|
||||
try {
|
||||
const actor = localStorage.getItem('agentlab_actor')
|
||||
return actor ? { 'X-Actor': actor } : {}
|
||||
// 请求头只能是 Latin-1:中文署名原样放进去,fetch 直接抛错、整个请求发不出去。
|
||||
// 后端 runs.actor_of 解码,纯 ASCII 的老署名编码前后一样
|
||||
return actor ? { 'X-Actor': encodeURIComponent(actor) } : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(BASE + path, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init?.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
|
||||
...actorHeader(),
|
||||
...init?.headers,
|
||||
},
|
||||
})
|
||||
if (!res.ok) {
|
||||
let detail = `${res.status} ${res.statusText}`
|
||||
try {
|
||||
const body = await res.json()
|
||||
// FastAPI 的校验错误是数组,直接 String() 会变成 [object Object]
|
||||
detail = typeof body.detail === 'string' ? body.detail : JSON.stringify(body.detail ?? body)
|
||||
} catch {
|
||||
/* 响应体不是 JSON,保留状态行 */
|
||||
}
|
||||
throw new ApiError(res.status, detail)
|
||||
/** FastAPI 的 422 是 [{loc, msg, type}] 数组,直接 String() 会变成 [object Object] */
|
||||
function describeDetail(detail: unknown): string {
|
||||
if (typeof detail === 'string') return detail
|
||||
if (Array.isArray(detail)) {
|
||||
const parts = detail.map((d: any) => {
|
||||
const loc = Array.isArray(d?.loc) ? d.loc.filter((x: unknown) => x !== 'body' && x !== 'query').join('.') : ''
|
||||
return loc ? `${loc}:${d?.msg ?? ''}` : String(d?.msg ?? JSON.stringify(d))
|
||||
})
|
||||
return `提交的内容不符合要求:${parts.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) {
|
||||
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)) {
|
||||
const err = new ApiError(res.status, NETWORK_MESSAGE, {
|
||||
kind: 'network', raw: `${res.status} ${res.statusText}${text ? `\n${text.slice(0, 500)}` : ''}`,
|
||||
})
|
||||
report(false, err)
|
||||
throw err
|
||||
}
|
||||
report(true)
|
||||
if (isJson) {
|
||||
const detail = body?.detail ?? body
|
||||
throw new ApiError(res.status, describeDetail(detail), {
|
||||
detail, raw: typeof body?.raw === 'string' ? body.raw : `${res.status} ${text.slice(0, 2000)}`,
|
||||
})
|
||||
}
|
||||
const message = res.status >= 500
|
||||
? `后端出错了(${res.status}),详情看服务日志`
|
||||
: `请求没有成功(${res.status} ${res.statusText})`
|
||||
throw new ApiError(res.status, message, { raw: `${res.status} ${res.statusText}\n${text.slice(0, 2000)}` })
|
||||
}
|
||||
report(true)
|
||||
if (res.status === 204) return undefined as T
|
||||
return res.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据源测连接的结果。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>
|
||||
}
|
||||
|
||||
/** 拼查询串: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}` : ''
|
||||
}
|
||||
|
||||
const get = <T>(p: string) => request<T>(p)
|
||||
const post = <T>(p: string, body?: unknown) =>
|
||||
request<T>(p, { method: 'POST', body: JSON.stringify(body ?? {}) })
|
||||
@@ -55,7 +223,8 @@ const put = <T>(p: string, body: unknown) =>
|
||||
const del = (p: string) => request<void>(p, { method: 'DELETE' })
|
||||
|
||||
export const api = {
|
||||
health: () => get<{ status: string }>('/health'),
|
||||
/** 心跳。带超时:后端卡死(连得上但不回)时也要能判成断开,而不是永远等下去 */
|
||||
health: (timeoutMs = 5000) => request<{ status: string; service?: string }>('/health', { timeoutMs }),
|
||||
system: () => get<any>('/system'),
|
||||
|
||||
// ---- 工作流 ----
|
||||
@@ -68,7 +237,9 @@ export const api = {
|
||||
patch<Workflow>(`/workflows/${id}`, body),
|
||||
remove: (id: string) => del(`/workflows/${id}`),
|
||||
duplicate: (id: string) => post<Workflow>(`/workflows/${id}/duplicate`),
|
||||
versions: (id: string) => get<any[]>(`/workflows/${id}/versions`),
|
||||
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[] }>(
|
||||
@@ -87,7 +258,9 @@ export const api = {
|
||||
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<any>(`/datasources/${id}/test`, {}),
|
||||
test: (id: string) => post<ConnectionTest>(`/datasources/${id}/test`, {}),
|
||||
/** 测一份还没保存的配置,只测连接不落库。编辑时带上 id,后端可以沿用已存的密码 */
|
||||
testConfig: (body: any) => post<ConnectionTest>('/datasources/test', body),
|
||||
/** 上传 Excel / CSV,变成一个可以用 SQL 查的数据源。同名就地替换 */
|
||||
uploadTable: (file: File, body: { name: string; description?: string; header_row?: number }) => {
|
||||
const form = new FormData()
|
||||
@@ -108,13 +281,18 @@ export const api = {
|
||||
},
|
||||
|
||||
runs: {
|
||||
list: (params?: { workflow_id?: string; status?: string; limit?: number }) => {
|
||||
const q = new URLSearchParams(
|
||||
Object.entries(params ?? {}).filter(([, v]) => v != null) as [string, string][],
|
||||
)
|
||||
return get<Run[]>(`/runs${q.toString() ? `?${q}` : ''}`)
|
||||
},
|
||||
/**
|
||||
* 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
|
||||
@@ -132,11 +310,15 @@ export const api = {
|
||||
/** 核对事件流和封存时的清单哈希:事后被改过、删过、插过都会对不上 */
|
||||
verify: (id: string) => get<{ sealed: boolean; ok: boolean | null; message: string }>(
|
||||
`/runs/${id}/verify`),
|
||||
remove: (id: string) => del(`/runs/${id}`),
|
||||
/** 运行中的删不了(409);封存过的正式运行要 force,否则也是 409,detail 写明后果 */
|
||||
remove: (id: string, opts?: { force?: boolean }) =>
|
||||
del(`/runs/${id}${opts?.force ? '?force=true' : ''}`),
|
||||
},
|
||||
|
||||
approvals: {
|
||||
list: (status = 'pending') => get<Approval[]>(`/approvals?status=${status}`),
|
||||
/** 老写法 list('pending') 照旧可用。status 可逗号分隔,'all' 取全部 */
|
||||
list: (params: string | { status?: string; run_id?: string; limit?: number } = 'pending') =>
|
||||
get<Approval[]>(`/approvals${qs(typeof params === 'string' ? { status: params } : { status: 'pending', ...params })}`),
|
||||
decide: (id: string, body: { approved: boolean; note?: string; value?: any; args?: any }) =>
|
||||
post<Run>(`/approvals/${id}/decide`, body),
|
||||
},
|
||||
@@ -149,7 +331,14 @@ export const api = {
|
||||
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<any>(`/providers/${id}/test`, body),
|
||||
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'),
|
||||
@@ -159,8 +348,9 @@ export const api = {
|
||||
// ---- 工具 ----
|
||||
tools: {
|
||||
list: () => get<ToolInfo[]>('/tools'),
|
||||
run: (name: string, args: Record<string, any>) =>
|
||||
post<any>(`/tools/${name}/run`, { args }),
|
||||
/** 危险工具要 confirm,否则后端回 409,detail 写明它会做什么 */
|
||||
run: (name: string, args: Record<string, any>, opts?: { confirm?: boolean }) =>
|
||||
post<any>(`/tools/${name}/run`, opts?.confirm ? { args, confirm: true } : { args }),
|
||||
},
|
||||
customTools: {
|
||||
list: () => get<any[]>('/custom-tools'),
|
||||
@@ -191,8 +381,19 @@ export const api = {
|
||||
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') =>
|
||||
get<any>(`/memory/search?q=${encodeURIComponent(q)}&scope=${scope}`),
|
||||
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}`),
|
||||
},
|
||||
@@ -208,7 +409,11 @@ export const api = {
|
||||
return request<KbDocument>(`/kb/upload?collection=${collection}`, { method: 'POST', body: form })
|
||||
},
|
||||
search: (q: string, collection?: string, alpha = 0.5) =>
|
||||
get<any>(`/kb/search?q=${encodeURIComponent(q)}&alpha=${alpha}${collection ? `&collection=${collection}` : ''}`),
|
||||
get<any & { alpha?: number }>(`/kb/search?q=${encodeURIComponent(q)}&alpha=${alpha}${collection ? `&collection=${collection}` : ''}`),
|
||||
/** 上传框能收哪些文件。以后端解析器为准,免得界面放行了后端必拒的格式 */
|
||||
formats: () => get<{
|
||||
extensions: string[]; text: string[]; tabular: string[]; legacy: string[]; accept: string
|
||||
}>('/kb/formats'),
|
||||
/** 一份文档被切成了什么样。检索不准时第一个该看的就是它 */
|
||||
document: (id: string) => get<{
|
||||
document: KbDocument
|
||||
@@ -224,6 +429,8 @@ export const api = {
|
||||
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<{
|
||||
@@ -321,11 +528,22 @@ export function streamCopilot(
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!res.ok || !res.body) {
|
||||
let detail = `${res.status} ${res.statusText}`
|
||||
try { detail = (await res.json()).detail ?? detail } catch { /* keep */ }
|
||||
onEnd(String(detail))
|
||||
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)
|
||||
: res.status >= 500 ? `后端出错了(${res.status}),详情看服务日志`
|
||||
: `请求没有成功(${res.status} ${res.statusText})`)
|
||||
return
|
||||
}
|
||||
report(true)
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
@@ -345,8 +563,14 @@ export function streamCopilot(
|
||||
}
|
||||
onEnd()
|
||||
} catch (e: any) {
|
||||
if (e?.name !== 'AbortError') onEnd(e?.message ?? '连接中断')
|
||||
else onEnd()
|
||||
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()
|
||||
@@ -365,7 +589,9 @@ export function streamCopilot(
|
||||
export function streamRun(
|
||||
runId: string,
|
||||
onEvent: (event: RunEvent) => void,
|
||||
onClose?: () => void,
|
||||
// stream.end 带着运行的最终状态:连到一条已经不在进行中的运行(含 interrupted)
|
||||
// 时,客户端靠它知道不会再有事件,不能一直当它还在跑
|
||||
onClose?: (end?: { status?: RunStatus | string }) => void,
|
||||
after = 0,
|
||||
): () => void {
|
||||
let socket: WebSocket | null = null
|
||||
@@ -379,11 +605,12 @@ export function streamRun(
|
||||
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 }
|
||||
const event = JSON.parse(ev.data) as RunEvent & { type: string; status?: string }
|
||||
if (event.type === 'stream.end') {
|
||||
closed = true
|
||||
socket?.close()
|
||||
onClose?.()
|
||||
// 新后端放在 data.status,老后端放在顶层 status
|
||||
onClose?.({ status: event.data?.status ?? event.status })
|
||||
return
|
||||
}
|
||||
if (event.seq) lastSeq = Math.max(lastSeq, event.seq)
|
||||
@@ -397,6 +624,7 @@ export function streamRun(
|
||||
}
|
||||
socket.onopen = () => {
|
||||
retry = 0
|
||||
report(true)
|
||||
}
|
||||
}
|
||||
connect()
|
||||
|
||||
Reference in New Issue
Block a user