- webapp/api:getProxyStats/getProxyLedger/listProxyStudents/createStudent/ topup/issueKey/revokeKey 封装 - ProxyView.vue 三卡片:① 命中率-毛利看板(h_g/h_p/综合/收入/成本/毛利/分桶) ② 学生与 key 管理(建学生/签发(明文仅显一次警示)/充值/选中签发) ③ 用量流水表(时间/模型/状态徽标/收入/成本/毛利) - App.vue NAV + router 注册 /proxy;npm run build 产物更新 - 全量 423 passed
641 lines
20 KiB
TypeScript
641 lines
20 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() },
|
||
}
|
||
}
|
||
|
||
// ── 代理层管理面(T-P7) ─────────────────────────────────────────────────────
|
||
|
||
export interface ProxyStats {
|
||
requests: number
|
||
h_g: number
|
||
h_p: number
|
||
revenue_milli: number
|
||
cost_milli: number
|
||
margin_milli: number
|
||
by_bucket?: Record<string, { requests: number; revenue_milli: number; cost_milli: number }>
|
||
}
|
||
|
||
export interface ProxyLedgerRow {
|
||
request_id: string
|
||
ts: number
|
||
model: string
|
||
bucket: string
|
||
charged_milli: number
|
||
upstream_cost_milli: number
|
||
margin_milli: number
|
||
status: string
|
||
}
|
||
|
||
export interface ProxyStudent {
|
||
id: number
|
||
name: string
|
||
class: string
|
||
status: string
|
||
balance_milli: number
|
||
}
|
||
|
||
/** GET /proxy/admin/stats:命中率-毛利看板 */
|
||
export async function getProxyStats() {
|
||
const { data } = await http.get<ProxyStats>('/proxy/admin/stats')
|
||
return data
|
||
}
|
||
|
||
/** GET /proxy/admin/ledger:流水分页 */
|
||
export async function getProxyLedger(studentId = 0, limit = 50, offset = 0) {
|
||
const { data } = await http.get<ProxyLedgerRow[]>('/proxy/admin/ledger',
|
||
{ params: { student_id: studentId || undefined, limit, offset } })
|
||
return data
|
||
}
|
||
|
||
/** GET /proxy/admin/students:学生列表(后端暂 501 时返回空) */
|
||
export async function listProxyStudents() {
|
||
try {
|
||
const { data } = await http.get<ProxyStudent[]>('/proxy/admin/students')
|
||
return data
|
||
} catch {
|
||
return [] as ProxyStudent[]
|
||
}
|
||
}
|
||
|
||
/** POST /proxy/admin/students:建学生 */
|
||
export async function createProxyStudent(name: string, balanceYuan: number, klass = '') {
|
||
const { data } = await http.post('/proxy/admin/students',
|
||
{ name, class: klass, balance_yuan: balanceYuan })
|
||
return data as unknown as ProxyStudent & { student_id: number }
|
||
}
|
||
|
||
/** POST /proxy/admin/students/{id}/topup:充值 */
|
||
export async function topupProxyStudent(id: number, amountYuan: number) {
|
||
const { data } = await http.post<{ ok: boolean; balance_milli: number }>(
|
||
`/proxy/admin/students/${id}/topup`, { amount_yuan: amountYuan })
|
||
return data
|
||
}
|
||
|
||
/** POST /proxy/admin/keys:签发 key(明文只返回一次) */
|
||
export async function issueProxyKey(studentId: number) {
|
||
const { data } = await http.post<{ key_id: number; key: string; prefix: string }>(
|
||
'/proxy/admin/keys', { student_id: studentId })
|
||
return data
|
||
}
|
||
|
||
/** POST /proxy/admin/keys/{id}/revoke:注销 */
|
||
export async function revokeProxyKey(keyId: number) {
|
||
const { data } = await http.post<{ ok: boolean }>(`/proxy/admin/keys/${keyId}/revoke`)
|
||
return data
|
||
}
|
||
|
||
// ── 辅助:轮询直到完成(用于不需要 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')
|
||
}
|