Files
projectAIpopular/webapp/src/api/index.ts
T
tzt 747d85c3ba feat(v3): T29 收尾 + T31 dsh 功能对齐
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 测试通过
2026-09-02 00:02:24 +08:00

560 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 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')
}