T29 流式收尾:
- _stream_partial 每次流式调用前复位(防上次成功置位导致本次失败误抛不回退)
T31 dsh(deepseek-harness)功能对齐:
- OpenAICompatChat 重试退避:传输错误/408/429/5xx 指数退避(max_retries=2),4xx 不重试
- web_fetch 工具:公网 http/https 抓取,SSRF 防护(DNS 后拒绝私网/环回/链路本地/NAT64,
512KB/15s/12k 上限,二进制嗅探拒绝),agent.allow_net 开关(默认开)
- 原子写入:write_file/edit_file 临时文件 + os.replace(Windows EPERM 退避)
- 慢工具线程卸载:run_command/web_fetch/search_files 独立线程 + asyncio.sleep 轮询
- search_files:os.walk 修剪依赖目录(替代 rglob 全量物化),不跟随符号链接
- run_command:危险命令黑名单独立拦截 + 显式 COMSPEC/sh 解释器执行
- 重复调用提醒:同工具同参数第 3 次起回喂系统提示 + repeat_warning 事件
- 会话重命名:PATCH /agent/sessions/{sid} + 前端 ✎
- 前端:SettingsView 适配密钥打码(留空保留),SPA 重新构建
- 新增 tests/test_agent_features.py(9 项);全量 318 测试通过
560 lines
17 KiB
TypeScript
560 lines
17 KiB
TypeScript
import axios from 'axios'
|
||
import type {
|
||
ChatInitResponse,
|
||
RunStatus,
|
||
SSEEvent,
|
||
ReviewItem,
|
||
Metrics,
|
||
} from '@/types'
|
||
|
||
const http = axios.create({ timeout: 10_000 })
|
||
|
||
// ── Chat ─────────────────────────────────────────────────────────────────────
|
||
|
||
/** POST /chat:异步提交,立即返回 request_id */
|
||
export async function chat(query: string, domain_group?: string) {
|
||
const { data } = await http.post<ChatInitResponse>('/chat', { query, domain_group })
|
||
return data
|
||
}
|
||
|
||
/** GET /runs/{id}/status:查询任务状态 */
|
||
export async function getRunStatus(requestId: string) {
|
||
const { data } = await http.get<RunStatus>(`/runs/${requestId}/status`)
|
||
return data
|
||
}
|
||
|
||
/** GET /runs/{id}/workspace:获取交流文本全文 */
|
||
export async function getWorkspace(requestId: string) {
|
||
const { data } = await http.get(`/runs/${requestId}/workspace`)
|
||
return data
|
||
}
|
||
|
||
/** SSE /runs/{id}/stream:订阅实时协作事件 */
|
||
export function watchRun(requestId: string) {
|
||
const es = new EventSource(`/runs/${requestId}/stream`)
|
||
return {
|
||
/** 监听器会在组件卸载时自动断开,调用者只需提供 onXxx 回调 */
|
||
subscribe(opts: {
|
||
onWorkspace: (ws: import('@/types').Workspace) => void
|
||
onStatus: (s: string) => void
|
||
onError: (detail: string) => void
|
||
onDone: () => void
|
||
}) {
|
||
es.addEventListener('message', (ev) => {
|
||
const d: SSEEvent = JSON.parse(ev.data)
|
||
if (d.type === 'workspace') opts.onWorkspace(d.workspace)
|
||
else if (d.type === 'status') {
|
||
opts.onStatus(d.value)
|
||
if (d.value === 'done' || d.value === 'failed') opts.onDone()
|
||
}
|
||
else if (d.type === 'error') opts.onError(d.detail)
|
||
})
|
||
es.addEventListener('error', () => {
|
||
// EventSource 自己会重连,这里只记录
|
||
})
|
||
},
|
||
close() { es.close() },
|
||
}
|
||
}
|
||
|
||
// ── 人工检验 ─────────────────────────────────────────────────────────────────
|
||
|
||
export async function listReviews(status?: string) {
|
||
const params = status ? { status } : {}
|
||
const { data } = await http.get<ReviewItem[]>('/review/queue', { params })
|
||
return data
|
||
}
|
||
|
||
export async function submitReview(reviewId: number, verdict: string, correction?: string) {
|
||
const { data } = await http.post(`/review/${reviewId}`, null, {
|
||
params: { verdict, correction },
|
||
})
|
||
return data
|
||
}
|
||
|
||
// ── 指标 ─────────────────────────────────────────────────────────────────────
|
||
|
||
export async function getMetrics() {
|
||
const { data } = await http.get<Metrics>('/api/metrics')
|
||
return data
|
||
}
|
||
|
||
// ── 模型设置 ─────────────────────────────────────────────────────────────────
|
||
|
||
export interface ModelSettings {
|
||
worker: {
|
||
backend: string
|
||
model: string
|
||
base_url: string
|
||
port: number
|
||
temperature: number
|
||
max_fix_attempts: number
|
||
code_timeout_s: number
|
||
}
|
||
architect: {
|
||
model: string
|
||
base_url: string
|
||
api_key: string
|
||
/** 服务端打码标志:true 表示已存 key(返回值只含前 6 位,保存时留空即保留) */
|
||
api_key_set?: boolean
|
||
}
|
||
pipeline: {
|
||
fast_path: boolean
|
||
rounds_cap: number
|
||
api_token_cap: number
|
||
breach_policy: string
|
||
}
|
||
}
|
||
|
||
/** GET /config:读取当前模型设置 */
|
||
export async function getConfig() {
|
||
const { data } = await http.get<ModelSettings>('/config')
|
||
return data
|
||
}
|
||
|
||
/** PUT /config:更新模型设置(部分更新) */
|
||
export async function updateConfig(patch: Partial<ModelSettings>) {
|
||
const { data } = await http.put<ModelSettings>('/config', patch)
|
||
return data
|
||
}
|
||
|
||
/** POST /config/reset:恢复默认设置 */
|
||
export async function resetConfig() {
|
||
const { data } = await http.post<ModelSettings>('/config/reset')
|
||
return data
|
||
}
|
||
|
||
// ── 模型发现 & 连接验证 ───────────────────────────────────────────────────────
|
||
|
||
export interface ModelInfo {
|
||
id: string
|
||
name: string
|
||
}
|
||
|
||
/**
|
||
* GET /config/models?backend=llama_server&base_url=...&api_key=...
|
||
* 返回 { models: ModelInfo[] } 或 { error: string }
|
||
*/
|
||
export async function listModels(
|
||
backend: string,
|
||
base_url: string,
|
||
api_key: string,
|
||
) {
|
||
const params: Record<string, string> = { backend }
|
||
if (base_url) params.base_url = base_url
|
||
if (api_key) params.api_key = api_key
|
||
const { data } = await http.get<{ models?: ModelInfo[]; error?: string }>(
|
||
'/config/models', { params },
|
||
)
|
||
return data
|
||
}
|
||
|
||
/**
|
||
* GET /config/ping?backend=...&base_url=...&api_key=...
|
||
* 返回 { ok: boolean, detail?: string }
|
||
*/
|
||
export async function pingBackend(
|
||
backend: string,
|
||
base_url: string,
|
||
api_key: string,
|
||
) {
|
||
const params: Record<string, string> = { backend }
|
||
if (base_url) params.base_url = base_url
|
||
if (api_key) params.api_key = api_key
|
||
const { data } = await http.get<{ ok: boolean; detail?: string; status_code?: number }>(
|
||
'/config/ping', { params },
|
||
)
|
||
return data
|
||
}
|
||
|
||
// ── llama-server 内置管理 ─────────────────────────────────────────────────────
|
||
|
||
export interface LlamaStatus {
|
||
running: boolean
|
||
pid?: number | null
|
||
model?: string | null
|
||
port?: number | null
|
||
base_url?: string | null
|
||
started_at?: number | null
|
||
error?: string | null
|
||
}
|
||
|
||
export interface LocalModel {
|
||
id: string
|
||
name: string
|
||
size_mb: number
|
||
path: string
|
||
}
|
||
|
||
export interface DownloadProgress {
|
||
url: string
|
||
dest: string
|
||
downloaded_bytes: number
|
||
total_bytes?: number | null
|
||
progress_pct: number
|
||
speed: string
|
||
eta: string
|
||
done: boolean
|
||
error?: string | null
|
||
}
|
||
|
||
/** GET /llama/status */
|
||
export async function getLlamaStatus() {
|
||
const { data } = await http.get<LlamaStatus>('/llama/status')
|
||
return data
|
||
}
|
||
|
||
/** GET /llama/models */
|
||
export async function listLocalModels() {
|
||
const { data } = await http.get<{ models: LocalModel[] }>('/llama/models')
|
||
return data.models
|
||
}
|
||
|
||
/** POST /llama/start */
|
||
export async function startLlama(model: string, port = 8901, ngl = 99, ctx = 4096) {
|
||
const { data } = await http.post<LlamaStatus>('/llama/start', null, {
|
||
params: { model, port, ngl, ctx },
|
||
})
|
||
return data
|
||
}
|
||
|
||
/** POST /llama/stop */
|
||
export async function stopLlama() {
|
||
const { data } = await http.post<{ running: boolean }>('/llama/stop')
|
||
return data
|
||
}
|
||
|
||
/** POST /llama/download */
|
||
export async function downloadModel(url: string, dest?: string) {
|
||
const params: Record<string, string> = { url }
|
||
if (dest) params.dest = dest
|
||
const { data } = await http.post<DownloadProgress>('/llama/download', null, { params })
|
||
return data
|
||
}
|
||
|
||
/** SSE /llama/download/stream?url=... */
|
||
export function watchDownload(url: string) {
|
||
const es = new EventSource(`/llama/download/stream?url=${encodeURIComponent(url)}`)
|
||
return {
|
||
subscribe(opts: { onProgress: (p: DownloadProgress) => void; onDone: (p: DownloadProgress) => void }) {
|
||
es.addEventListener('message', (ev) => {
|
||
const p: DownloadProgress = JSON.parse(ev.data)
|
||
opts.onProgress(p)
|
||
if (p.done) opts.onDone(p)
|
||
})
|
||
es.addEventListener('error', () => { es.close() })
|
||
},
|
||
close() { es.close() },
|
||
}
|
||
}
|
||
|
||
// ── 模型池(多价位异构模型) ─────────────────────────────────────────────────
|
||
|
||
export type PoolTier = 'local' | 'budget' | 'premium'
|
||
export type PoolRole = 'architect' | 'worker' | 'agent'
|
||
|
||
export interface PoolEntry {
|
||
id: string
|
||
name: string
|
||
tier: PoolTier
|
||
backend: 'mock' | 'llama_server' | 'openai'
|
||
base_url: string
|
||
model: string
|
||
api_key: string
|
||
api_key_set: boolean
|
||
price_in: number
|
||
price_out: number
|
||
temperature: number
|
||
max_tokens: number
|
||
enabled: boolean
|
||
}
|
||
|
||
export interface PoolData {
|
||
roles: Record<PoolRole, string>
|
||
entries: PoolEntry[]
|
||
}
|
||
|
||
/** GET /pool:读取模型池(api_key 打码) */
|
||
export async function getPool() {
|
||
const { data } = await http.get<PoolData>('/pool')
|
||
return data
|
||
}
|
||
|
||
/** POST /pool:新增或更新条目(api_key 留空 = 保留原值) */
|
||
export async function upsertPoolEntry(entry: Partial<PoolEntry>) {
|
||
const { data } = await http.post<PoolData>('/pool', entry)
|
||
return data
|
||
}
|
||
|
||
/** DELETE /pool/{id} */
|
||
export async function deletePoolEntry(id: string) {
|
||
const { data } = await http.delete<PoolData>(`/pool/${id}`)
|
||
return data
|
||
}
|
||
|
||
/** PUT /pool/roles:指派角色(空串 = 经典设置) */
|
||
export async function setPoolRoles(roles: Partial<Record<PoolRole, string>>) {
|
||
const { data } = await http.put<PoolData>('/pool/roles', roles)
|
||
return data
|
||
}
|
||
|
||
/** POST /pool/{id}/test:条目连通性测试 */
|
||
export async function testPoolEntry(id: string) {
|
||
const { data } = await http.post<{ ok: boolean; detail?: string }>(`/pool/${id}/test`)
|
||
return data
|
||
}
|
||
|
||
/** GET /pool/{id}/models:条目端点下的可用模型列表 */
|
||
export async function listPoolModels(id: string) {
|
||
const { data } = await http.get<{ models?: ModelInfo[]; error?: string }>(`/pool/${id}/models`)
|
||
return data
|
||
}
|
||
|
||
// ── 智能体(工具调用) ───────────────────────────────────────────────────────
|
||
|
||
export interface AgentEvent {
|
||
type: 'round' | 'tool_call' | 'tool_result' | 'usage' | 'final' | 'phase' | 'message'
|
||
| 'approval_request' | 'approval_decided' | 'delta'
|
||
ts?: number
|
||
round?: number
|
||
name?: string
|
||
arguments?: Record<string, unknown>
|
||
ok?: boolean
|
||
preview?: string
|
||
prompt_tokens?: number
|
||
completion_tokens?: number
|
||
reason?: string
|
||
error?: string
|
||
// 两级模式(D7)
|
||
phase?: 'plan' | 'execute' | 'review'
|
||
handoff?: number
|
||
model?: string
|
||
role?: 'planner' | 'executor'
|
||
content?: string
|
||
// 审批(D9)/ 流式(D10)
|
||
id?: string
|
||
policy?: string
|
||
allowed?: boolean
|
||
note?: string
|
||
text?: string
|
||
}
|
||
|
||
/** POST /agent/{id}/approve:裁决待审批操作(dsh 式 allow-once / deny) */
|
||
export async function approveAgent(requestId: string, approvalId: string, allowed: boolean) {
|
||
const { data } = await http.post<{ ok: boolean }>(`/agent/${requestId}/approve`,
|
||
{ approval_id: approvalId, allowed })
|
||
return data
|
||
}
|
||
|
||
export interface AgentStatus {
|
||
request_id: string
|
||
task: string
|
||
model: string
|
||
state: 'running' | 'done' | 'failed'
|
||
response: string
|
||
rounds: number
|
||
prompt_tokens: number
|
||
completion_tokens: number
|
||
error?: string | null
|
||
started_at?: number
|
||
finished_at?: number
|
||
workspace?: string
|
||
executor_model?: string
|
||
mode?: 'single' | 'dual'
|
||
tool_calls?: number
|
||
}
|
||
|
||
/** POST /agent:提交智能体任务(sessionId 可选:会话式多轮) */
|
||
export async function startAgent(
|
||
task: string, poolId?: string, workspace?: string,
|
||
executorPoolId?: string, sessionId?: string,
|
||
) {
|
||
const { data } = await http.post<{
|
||
request_id: string; status: string; model: string
|
||
workspace?: string; mode?: 'single' | 'dual'; executor_model?: string
|
||
session_id?: string | null
|
||
}>('/agent', {
|
||
task,
|
||
pool_id: poolId || undefined,
|
||
workspace: workspace || undefined,
|
||
executor_pool_id: executorPoolId || undefined,
|
||
session_id: sessionId || undefined,
|
||
})
|
||
return data
|
||
}
|
||
|
||
/** POST /agent/{id}/cancel:停止运行中的智能体任务 */
|
||
export async function cancelAgent(requestId: string) {
|
||
const { data } = await http.post<{ ok: boolean; detail?: string }>(
|
||
`/agent/${requestId}/cancel`)
|
||
return data
|
||
}
|
||
|
||
// ── 智能体会话(dsh 式多轮) ─────────────────────────────────────────────────
|
||
|
||
export interface AgentSessionBrief {
|
||
id: string
|
||
title: string
|
||
workspace?: string
|
||
created_at?: number
|
||
updated_at?: number
|
||
busy?: boolean
|
||
pool_id?: string
|
||
executor_pool_id?: string
|
||
turns: number | unknown[]
|
||
}
|
||
|
||
export interface AgentSessionDetail extends AgentSessionBrief {
|
||
turns: {
|
||
request_id: string
|
||
task: string
|
||
response: string
|
||
state: string
|
||
tool_calls: number
|
||
tokens: number
|
||
error?: string | null
|
||
ts?: number
|
||
}[]
|
||
}
|
||
|
||
/** POST /agent/sessions:创建会话 */
|
||
export async function createAgentSession(workspace?: string, executorPoolId?: string, title?: string) {
|
||
const { data } = await http.post<AgentSessionDetail>('/agent/sessions', {
|
||
title: title || undefined,
|
||
workspace: workspace || undefined,
|
||
executor_pool_id: executorPoolId || undefined,
|
||
})
|
||
return data
|
||
}
|
||
|
||
/** GET /agent/sessions:会话列表 */
|
||
export async function listAgentSessions() {
|
||
const { data } = await http.get<AgentSessionBrief[]>('/agent/sessions')
|
||
return data
|
||
}
|
||
|
||
/** GET /agent/sessions/{sid}:会话详情(含轮次) */
|
||
export async function getAgentSession(sid: string) {
|
||
const { data } = await http.get<AgentSessionDetail>(`/agent/sessions/${sid}`)
|
||
return data
|
||
}
|
||
|
||
/** DELETE /agent/sessions/{sid}:删除会话 */
|
||
export async function deleteAgentSession(sid: string) {
|
||
const { data } = await http.delete<{ ok: boolean }>(`/agent/sessions/${sid}`)
|
||
return data
|
||
}
|
||
|
||
/** PATCH /agent/sessions/{sid}:重命名会话 */
|
||
export async function renameAgentSession(sid: string, title: string) {
|
||
const { data } = await http.patch<AgentSessionDetail>(`/agent/sessions/${sid}`, {
|
||
title,
|
||
})
|
||
return data
|
||
}
|
||
|
||
/** GET /agent/{id}/status */
|
||
export async function getAgentStatus(requestId: string) {
|
||
const { data } = await http.get<AgentStatus>(`/agent/${requestId}/status`)
|
||
return data
|
||
}
|
||
|
||
/** GET /agent/{id}/events:完整事件列表(刷新恢复用) */
|
||
export async function getAgentEvents(requestId: string) {
|
||
const { data } = await http.get<AgentEvent[]>(`/agent/${requestId}/events`)
|
||
return data
|
||
}
|
||
|
||
/** GET /agent/workspace?path=&root=:浏览工作区目录(root 可指定选中工作区) */
|
||
export async function listAgentWorkspace(path = '', root = '') {
|
||
const { data } = await http.get<{
|
||
ok: boolean
|
||
entries?: { name: string; type: string; size?: number }[]
|
||
error?: string
|
||
}>('/agent/workspace', { params: { ...(path ? { path } : {}), ...(root ? { root } : {}) } })
|
||
return data
|
||
}
|
||
|
||
/** GET /agent/file?path=&root=:读取工作区文件 */
|
||
export async function readAgentFile(path: string, root = '') {
|
||
const { data } = await http.get<{ ok: boolean; content: string; truncated: boolean; error?: string }>(
|
||
'/agent/file', { params: { path, ...(root ? { root } : {}) } })
|
||
return data
|
||
}
|
||
|
||
// ── 智能体:工作区选择(参考 deepseek-harness 的打开文件夹体验) ─────────────
|
||
|
||
export interface FsBrowse {
|
||
ok: boolean
|
||
path: string
|
||
parent: string
|
||
dirs: string[]
|
||
error?: string
|
||
}
|
||
|
||
export interface WorkspacesInfo {
|
||
current: string
|
||
recent: string[]
|
||
}
|
||
|
||
/** GET /agent/fs?path=:浏览本地目录(目录选择器,只列子目录) */
|
||
export async function browseFs(path = '') {
|
||
const { data } = await http.get<FsBrowse>('/agent/fs', { params: path ? { path } : {} })
|
||
return data
|
||
}
|
||
|
||
/** GET /agent/workspaces:当前工作区 + 最近列表 */
|
||
export async function getAgentWorkspaces() {
|
||
const { data } = await http.get<WorkspacesInfo>('/agent/workspaces')
|
||
return data
|
||
}
|
||
|
||
/** POST /agent/workspaces:打开(或新建)工作目录并设为当前 */
|
||
export async function openWorkspace(path: string, create = false) {
|
||
const { data } = await http.post<{ ok: boolean; current: string; recent: string[] }>(
|
||
'/agent/workspaces', { path, create })
|
||
return data
|
||
}
|
||
|
||
/** SSE /agent/{id}/stream:订阅智能体过程事件 */
|
||
export function watchAgent(requestId: string) {
|
||
const es = new EventSource(`/agent/${requestId}/stream`)
|
||
let finalSeen = false
|
||
return {
|
||
subscribe(opts: {
|
||
onEvent: (ev: AgentEvent) => void
|
||
onDone: () => void
|
||
}) {
|
||
es.addEventListener('message', (ev) => {
|
||
if (finalSeen) return // final 后的自动重连回放,忽略
|
||
const d: AgentEvent = JSON.parse(ev.data)
|
||
opts.onEvent(d)
|
||
if (d.type === 'final') {
|
||
finalSeen = true
|
||
es.close() // 终态即关闭,防止重连回放重复渲染
|
||
opts.onDone()
|
||
}
|
||
})
|
||
es.addEventListener('error', () => {
|
||
// 连接中断由浏览器自动重连;final 已 seen 时回放会被上方拦截
|
||
})
|
||
},
|
||
close() { es.close() },
|
||
}
|
||
}
|
||
|
||
// ── 辅助:轮询直到完成(用于不需要 SSE 的场景)──────────────────────────────────
|
||
|
||
export async function pollUntilDone(
|
||
requestId: string,
|
||
{ timeout = 60_000, interval = 500 }: { timeout?: number; interval?: number } = {},
|
||
) {
|
||
const t0 = Date.now()
|
||
while (Date.now() - t0 < timeout) {
|
||
const s = await getRunStatus(requestId)
|
||
if (s.status === 'done' || s.status === 'failed') return s
|
||
await new Promise((r) => setTimeout(r, interval))
|
||
}
|
||
throw new Error('poll timeout')
|
||
}
|