feat(v3): T19-T20 模型池设置 UI + 智能体页 + 指标分账卡

- 设置页「模型池」区块:三角色指派下拉、条目表格(档位徽标/单价/启用/测试/编辑/删除)、
  内联添加表单(模型列表探测 datalist,api_key 留空保留原值)
- 新增「🤖 智能体」页 /agent:任务输入 + 池模型选择、工具调用时间轴
  (round/tool_call/tool_result/usage/final)、左侧工作区文件浏览与预览
- 指标页「按模型分账」卡片(v2.by_model 表格)
- 修复:SSE final 后 EventSource 自动重连回放导致的事件重复渲染(终态即关闭+回放拦截)
This commit is contained in:
tzt
2026-09-01 08:51:52 +08:00
parent 374dc765c2
commit 501058243b
22 changed files with 903 additions and 24 deletions
+1
View File
@@ -6,6 +6,7 @@
<div class="nav-links">
<router-link to="/chat">💬 对话</router-link>
<router-link to="/collaboration">🔄 协作</router-link>
<router-link to="/agent">🤖 智能体</router-link>
<router-link to="/review">🔍 检验</router-link>
<router-link to="/metrics">📊 指标</router-link>
<router-link to="/settings"> 设置</router-link>
+155
View File
@@ -246,6 +246,161 @@ export function watchDownload(url: string) {
}
}
// ── 模型池(多价位异构模型) ─────────────────────────────────────────────────
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'
ts?: number
round?: number
name?: string
arguments?: Record<string, unknown>
ok?: boolean
preview?: string
prompt_tokens?: number
completion_tokens?: number
reason?: string
error?: string
}
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
}
/** POST /agent:提交智能体任务 */
export async function startAgent(task: string, poolId?: string) {
const { data } = await http.post<{ request_id: string; status: string; model: string }>(
'/agent', { task, pool_id: poolId || undefined })
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=...:浏览工作区目录 */
export async function listAgentWorkspace(path = '') {
const { data } = await http.get<{
ok: boolean
entries?: { name: string; type: string; size?: number }[]
error?: string
}>('/agent/workspace', { params: path ? { path } : {} })
return data
}
/** GET /agent/file?path=...:读取工作区文件 */
export async function readAgentFile(path: string) {
const { data } = await http.get<{ ok: boolean; content: string; truncated: boolean; error?: string }>(
'/agent/file', { params: { path } })
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(
+6
View File
@@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router'
import ChatView from '@/views/ChatView.vue'
import AgentView from '@/views/AgentView.vue'
// Vite 构建时 base=/static/,但 FastAPI 在 /metrics 等路径提供 SPA(不在 /static/ 下),
// 所以 history 固定用 '/',避免 Vue Router 把 /metrics 当作 /static/metrics 解析导致路由不匹配。
@@ -20,6 +21,11 @@ const router = createRouter({
name: 'collaboration',
component: () => import('@/views/CollaborationView.vue'),
},
{
path: '/agent',
name: 'agent',
component: AgentView,
},
{
path: '/review',
name: 'review',
+360
View File
@@ -0,0 +1,360 @@
<template>
<div class="agent-view">
<!-- 工作区文件 -->
<aside class="ws-panel">
<div class="ws-head">
<h3>📁 工作区</h3>
<button class="btn-refresh" :disabled="loadingWs" @click="refreshWs">
{{ loadingWs ? '' : '🔄' }}
</button>
</div>
<ul class="ws-list">
<li v-for="f in wsFiles" :key="f.name"
:class="{ dir: f.type === 'dir' }"
@click="f.type === 'file' && openFile(f.name)">
<span class="ws-name">{{ f.name }}</span>
<span v-if="f.type === 'file'" class="ws-size">{{ fmtSize(f.size) }}</span>
</li>
<li v-if="!wsFiles.length && !loadingWs" class="ws-empty">智能体写入的文件会出现在这里</li>
</ul>
<div v-if="filePreview" class="file-preview">
<div class="fp-head">
<span class="fp-name">{{ filePreview.path }}</span>
<button class="btn-refresh" @click="filePreview = null"></button>
</div>
<pre class="fp-body">{{ filePreview.content }}<template v-if="filePreview.truncated">已截断</template></pre>
</div>
</aside>
<!-- 任务与过程 -->
<main class="agent-main">
<header class="agent-head">
<h2>🤖 智能体</h2>
<p class="sub">像编程助手一样操作工作区文件列目录 / 读文件 / 写文件全过程实时可视化</p>
</header>
<div class="task-bar">
<select v-model="selectedPoolId" class="model-select">
<option value="">默认模型模型池 Agent 角色 / Architect 设置</option>
<option v-for="e in agentModels" :key="e.id" :value="e.id">{{ e.name }}{{ e.model }}</option>
</select>
</div>
<div class="task-bar">
<textarea v-model="task" class="task-input" rows="3"
placeholder="给智能体一个任务,例如:查看工作区里有哪些文件,并写一个 hello.py 输出九九乘法表"
:disabled="running" />
<button class="btn-run" :disabled="running || !task.trim()" @click="run">
{{ running ? '运行中' : ' 运行' }}
</button>
</div>
<div v-if="statusLine" :class="['status-line', statusClass]">{{ statusLine }}</div>
<!-- 过程时间轴 -->
<div class="timeline" ref="timelineEl">
<template v-for="(ev, i) in events" :key="i">
<div v-if="ev.type === 'round'" class="ev-round"> {{ ev.round }} </div>
<div v-else-if="ev.type === 'tool_call'" class="ev-card">
<div class="ev-title">🛠 调用工具 <b>{{ ev.name }}</b></div>
<pre class="ev-args">{{ pretty(ev.arguments) }}</pre>
</div>
<div v-else-if="ev.type === 'tool_result'" class="ev-card"
:class="ev.ok ? 'res-ok' : 'res-err'">
<div class="ev-title">{{ ev.ok ? '✅' : '❌' }} 结果{{ ev.name }}</div>
<pre class="ev-args">{{ short(ev.preview) }}</pre>
</div>
<div v-else-if="ev.type === 'usage'" class="ev-usage">
tokens{{ ev.prompt_tokens }} / {{ ev.completion_tokens }}
</div>
<div v-else-if="ev.type === 'final'" class="ev-final">
<div class="ev-title">
{{ ev.reason === 'answer' ? '🏁 最终答复' : '⚠️ 结束(' + reasonLabel(ev.reason) + '' }}
</div>
<pre v-if="finalResponse" class="ev-answer">{{ finalResponse }}</pre>
<div v-if="ev.error" class="ev-error">{{ ev.error }}</div>
</div>
</template>
<div v-if="running" class="ev-round"> 智能体正在工作</div>
<div v-if="!events.length && !running" class="empty-hint">
输入任务并运行每一步工具调用都会实时显示在这里
</div>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, onMounted } from 'vue'
import {
startAgent, getAgentStatus, watchAgent,
listAgentWorkspace, readAgentFile, getPool,
} from '@/api'
import type { AgentEvent, PoolEntry } from '@/api'
const task = ref('')
const running = ref(false)
const events = ref<AgentEvent[]>([])
const statusInfo = ref<{ state: string; error?: string | null } | null>(null)
const selectedPoolId = ref('')
const poolEntries = ref<PoolEntry[]>([])
const wsFiles = ref<{ name: string; type: string; size?: number }[]>([])
const loadingWs = ref(false)
const filePreview = ref<{ path: string; content: string; truncated: boolean } | null>(null)
const timelineEl = ref<HTMLElement | null>(null)
let _watch: ReturnType<typeof watchAgent> | null = null
const agentModels = computed(() => poolEntries.value.filter(e => e.enabled))
const finalResponse = computed(() => ((statusInfo.value as any)?.response || ''))
const statusLine = computed(() => {
if (running.value) return '● 运行中'
const s = statusInfo.value
if (!s) return ''
if (s.state === 'done') return '✅ 已完成'
if (s.state === 'failed') return '❌ 失败' + (s.error ? '' + s.error : '')
return ''
})
const statusClass = computed(() => {
if (running.value) return 'st-running'
return statusInfo.value?.state === 'done' ? 'st-ok' : 'st-err'
})
function reasonLabel(r?: string) {
return ({ max_rounds: '轮次达上限', token_cap: 'token 熔断', error: '出错' } as Record<string, string>)[r || ''] || r
}
function pretty(o: unknown) { return JSON.stringify(o, null, 2) ?? '' }
function short(s?: string) { return (s || '').length > 600 ? s!.slice(0, 600) + '…' : (s || '') }
function fmtSize(n?: number) {
if (n == null) return ''
if (n >= 1e6) return (n / 1e6).toFixed(1) + ' MB'
if (n >= 1e3) return (n / 1e3).toFixed(1) + ' KB'
return n + ' B'
}
function scrollToBottom() {
nextTick(() => timelineEl.value?.scrollTo({ top: timelineEl.value.scrollHeight }))
}
async function refreshWs() {
loadingWs.value = true
try {
const r = await listAgentWorkspace('')
wsFiles.value = r.entries || []
} catch { wsFiles.value = [] } finally { loadingWs.value = false }
}
async function openFile(name: string) {
try {
const r = await readAgentFile(name)
if (r.ok) filePreview.value = { path: name, content: r.content, truncated: r.truncated }
} catch { /* ignore */ }
}
async function run() {
if (!task.value.trim() || running.value) return
running.value = true
events.value = []
statusInfo.value = null
try {
const init = await startAgent(task.value.trim(), selectedPoolId.value)
_watch = watchAgent(init.request_id)
_watch.subscribe({
onEvent: (ev) => {
events.value.push(ev)
scrollToBottom()
},
onDone: async () => {
running.value = false
try { statusInfo.value = await getAgentStatus(init.request_id) } catch { /* ignore */ }
refreshWs()
},
})
} catch (e: any) {
running.value = false
statusInfo.value = { state: 'failed', error: e?.response?.data?.detail || e?.message || String(e) }
}
}
onMounted(async () => {
refreshWs()
try {
const pool = await getPool()
poolEntries.value = pool.entries
selectedPoolId.value = pool.roles.agent || ''
} catch { /* ignore */ }
})
</script>
<style scoped>
.agent-view {
display: flex;
height: 100%;
background: #f9fafb;
}
/* 左侧工作区 */
.ws-panel {
width: 260px;
flex-shrink: 0;
border-right: 1px solid #e5e7eb;
background: #fff;
display: flex;
flex-direction: column;
overflow: hidden;
}
.ws-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 14px;
border-bottom: 1px solid #f3f4f6;
}
.ws-head h3 { font-size: 14px; color: #1f2937; }
.ws-list { list-style: none; overflow-y: auto; flex: 1; }
.ws-list li {
display: flex;
justify-content: space-between;
padding: 6px 14px;
font-size: 13px;
cursor: pointer;
color: #374151;
}
.ws-list li:hover { background: #eff6ff; }
.ws-list li.dir { color: #92400e; font-weight: 500; cursor: default; }
.ws-empty { color: #9ca3af; cursor: default; font-size: 12px; }
.ws-empty:hover { background: transparent; }
.ws-size { color: #9ca3af; font-size: 11px; }
.file-preview {
border-top: 1px solid #e5e7eb;
max-height: 45%;
display: flex;
flex-direction: column;
}
.fp-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 14px;
background: #f9fafb;
font-size: 12px;
font-weight: 600;
color: #374151;
}
.fp-body {
margin: 0;
padding: 10px 14px;
overflow: auto;
font-size: 12px;
font-family: ui-monospace, Consolas, monospace;
white-space: pre-wrap;
word-break: break-all;
color: #111827;
}
/* 右侧主区 */
.agent-main { flex: 1; display: flex; flex-direction: column; padding: 20px 24px; min-width: 0; }
.agent-head { margin-bottom: 12px; }
.agent-head h2 { font-size: 20px; font-weight: 700; color: #111827; }
.sub { font-size: 13px; color: #6b7280; margin-top: 2px; }
.task-bar { display: flex; gap: 10px; margin-bottom: 10px; align-items: stretch; }
.model-select {
border: 1px solid #d1d5db;
border-radius: 6px;
padding: 7px 10px;
font-size: 13px;
background: #fff;
max-width: 420px;
}
.task-input {
flex: 1;
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 10px 12px;
font-size: 14px;
font-family: inherit;
resize: vertical;
outline: none;
}
.task-input:focus { border-color: #2563eb; box-shadow: 0 0 0 2px #2563eb26; }
.btn-run {
color: #fff;
background: #2563eb;
border: none;
border-radius: 8px;
padding: 0 22px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
align-self: stretch;
}
.btn-run:hover:not(:disabled) { background: #1d4ed8; }
.btn-run:disabled { background: #93c5fd; cursor: not-allowed; }
.status-line { font-size: 13px; font-weight: 600; margin-bottom: 10px; }
.st-running { color: #2563eb; }
.st-ok { color: #16a34a; }
.st-err { color: #dc2626; }
.timeline {
flex: 1;
overflow-y: auto;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 16px;
}
.ev-round {
text-align: center;
color: #9ca3af;
font-size: 12px;
margin: 10px 0;
}
.ev-card {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 8px;
background: #fafafa;
}
.ev-card.res-ok { border-left: 3px solid #16a34a; }
.ev-card.res-err { border-left: 3px solid #dc2626; }
.ev-title { font-size: 13px; color: #1f2937; margin-bottom: 6px; }
.ev-args {
margin: 0;
font-size: 12px;
font-family: ui-monospace, Consolas, monospace;
white-space: pre-wrap;
word-break: break-all;
color: #374151;
max-height: 200px;
overflow-y: auto;
}
.ev-usage { text-align: right; color: #9ca3af; font-size: 11px; margin: 4px 0; }
.ev-final {
border: 1px solid #bfdbfe;
background: #eff6ff;
border-radius: 8px;
padding: 12px 14px;
margin-top: 10px;
}
.ev-answer {
margin: 6px 0 0;
font-size: 14px;
white-space: pre-wrap;
word-break: break-word;
color: #111827;
font-family: inherit;
}
.ev-error { color: #dc2626; font-size: 13px; margin-top: 6px; }
.empty-hint { text-align: center; color: #9ca3af; font-size: 13px; margin-top: 40px; }
</style>
+40 -2
View File
@@ -33,11 +33,31 @@
<h3>协作管线v2</h3>
<div class="kv-list">
<template v-for="(v, k) in data.v2" :key="k">
<span>{{ k }}</span><b>{{ v }}</b>
<span v-if="k !== 'by_model'">{{ k }}</span>
<b v-if="k !== 'by_model'">{{ v }}</b>
</template>
</div>
</div>
<div v-if="byModel && Object.keys(byModel).length" class="metric-card">
<h3>按模型分账token / 成本</h3>
<table class="by-model">
<thead>
<tr><th>模型</th><th>次数</th><th></th><th></th><th>成本 $</th></tr>
</thead>
<tbody>
<tr v-for="(b, m) in byModel" :key="m">
<td class="mono">{{ m }}</td>
<td>{{ b.requests }}</td>
<td>{{ b.input_tokens }}</td>
<td>{{ b.output_tokens }}</td>
<td>{{ b.cost_est_usd }}</td>
</tr>
</tbody>
</table>
<p class="hint">单价来自模型池条目$/1M tokens经典设置下的模型成本不计入</p>
</div>
<div v-if="data.review" class="metric-card review-card">
<h3>人工检验</h3>
<div class="review-stats">
@@ -75,7 +95,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { getMetrics } from '@/api'
import type { Metrics } from '@/types'
@@ -83,6 +103,18 @@ const data = ref<Metrics | null>(null)
const loading = ref(false)
const error = ref('')
interface ModelBucket {
requests: number
input_tokens: number
output_tokens: number
cost_est_usd: number
}
const byModel = computed(() => {
const v2 = data.value?.v2 as Record<string, unknown> | undefined
return (v2?.by_model as Record<string, ModelBucket>) || null
})
async function load() {
loading.value = true
error.value = ''
@@ -102,6 +134,12 @@ onMounted(load)
<style scoped>
.metrics-view { padding: 20px 24px; height: 100%; overflow-y: auto; }
.by-model { width: 100%; border-collapse: collapse; font-size: 12px; }
.by-model th, .by-model td { text-align: left; padding: 4px 8px; border-bottom: 1px solid #f3f4f6; }
.by-model th { color: #6b7280; font-weight: 600; }
.by-model td.mono { font-family: ui-monospace, Consolas, monospace; }
.hint { color: #9ca3af; font-size: 11px; margin-top: 8px; }
.metrics-header {
display: flex;
align-items: center;
+321 -2
View File
@@ -5,6 +5,137 @@
<p class="subtitle">配置小模型Worker与大模型Architect参数保存后实时生效</p>
</header>
<!-- 🗄 模型池多价位异构模型 -->
<section class="settings-section">
<div class="section-title">
<span>🗄 模型池多价位异构模型</span>
<div class="title-right">
<button class="btn-refresh" @click="addPoolEntry"> 添加模型</button>
</div>
</div>
<div class="section-body">
<!-- 角色指派池条目 -> 系统角色 -->
<div class="role-bar">
<div class="role-item" v-for="role in ROLE_DEFS" :key="role.key">
<label>{{ role.label }}</label>
<select v-model="poolRoles[role.key]" @change="saveRoles">
<option value="">经典设置下方表单</option>
<option v-for="e in enabledEntries" :key="e.id" :value="e.id">{{ e.name }}</option>
</select>
</div>
</div>
<p class="role-hint">
按价位组织模型本地 llama.cpp 为零成本档低价 API 干批量活旗舰 API 做决策
随时切换保存后管线自动重建
</p>
<!-- 条目列表 -->
<table class="pool-table" v-if="pool.entries.length">
<thead>
<tr>
<th>名称</th><th>档位</th><th>后端</th><th>模型</th>
<th>单价 $/1M/</th><th>启用</th><th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="e in pool.entries" :key="e.id">
<td>{{ e.name }}</td>
<td><span :class="['tier-badge', 'tier-' + e.tier]">{{ tierLabel(e.tier) }}</span></td>
<td>{{ e.backend }}</td>
<td class="mono">{{ e.model || '—' }}</td>
<td>{{ e.price_in }} / {{ e.price_out }}</td>
<td><input type="checkbox" v-model="e.enabled" @change="toggleEnabled(e)" /></td>
<td class="row-actions">
<button class="btn-refresh" :disabled="testingId === e.id" @click="testEntry(e)">
{{ testingId === e.id ? '' : '测试' }}
</button>
<button class="btn-refresh" @click="editEntry(e)">编辑</button>
<button class="btn-refresh danger" @click="removeEntry(e)">删除</button>
</td>
</tr>
</tbody>
</table>
<div v-else class="no-models">
池为空可添加本地 llama.cpp 模型零成本档低价 API旗舰 API 随时指派给 Architect / Worker / Agent
</div>
<div v-if="poolTestMsg" :class="['field-hint', poolTestOk ? 'ok' : 'warn']">{{ poolTestMsg }}</div>
<!-- 内联编辑表单 -->
<div v-if="editing" class="pool-edit">
<div class="pool-edit-title">{{ editingIsNew ? '添加模型' : '编辑模型' }}</div>
<div class="form-grid">
<div class="field">
<label>名称</label>
<input v-model="editing.name" placeholder="如:DeepSeek 旗舰 / 本地 Qwen4B" />
</div>
<div class="field">
<label>档位</label>
<select v-model="editing.tier">
<option value="local">local本地 / 零边际成本</option>
<option value="budget">budget低价 API</option>
<option value="premium">premium旗舰 API</option>
</select>
</div>
<div class="field">
<label>后端</label>
<select v-model="editing.backend">
<option value="openai">openai / 兼容 API</option>
<option value="llama_server">llama_server内置本地</option>
<option value="mock">mock模拟</option>
</select>
</div>
<div class="field">
<label>
模型
<button class="btn-refresh btn-inline" :disabled="fetchingPoolModels"
@click="fetchPoolModelList">
{{ fetchingPoolModels ? '' : '🔄 刷新列表' }}
</button>
</label>
<input v-model="editing.model" list="pool-model-options" placeholder="模型名" />
<datalist id="pool-model-options">
<option v-for="m in poolModelOptions" :key="m.id" :value="m.id" />
</datalist>
</div>
<div class="field full-width" v-if="editing.backend !== 'mock'">
<label>API 地址</label>
<input v-model="editing.base_url" placeholder="https://api.deepseek.com 或 http://127.0.0.1:8901/v1" />
</div>
<div class="field" v-if="editing.backend === 'openai'">
<label>API Key{{ editing.api_key_set ? '(已设置,留空保留)' : '' }}</label>
<input v-model="editing.api_key" type="password" autocomplete="off"
placeholder="sk-…" />
</div>
<div class="field">
<label>输入单价 $/1M</label>
<input v-model.number="editing.price_in" type="number" min="0" step="0.01" />
</div>
<div class="field">
<label>输出单价 $/1M</label>
<input v-model.number="editing.price_out" type="number" min="0" step="0.01" />
</div>
<div class="field">
<label>Temperature</label>
<input v-model.number="editing.temperature" type="number" min="0" max="2" step="0.05" />
</div>
<div class="field">
<label>Max Tokens</label>
<input v-model.number="editing.max_tokens" type="number" min="256" max="32768" />
</div>
</div>
<div class="pool-edit-actions">
<label class="checkbox-label">
<input type="checkbox" v-model="editing.enabled" /> 启用
</label>
<button class="btn-primary" :disabled="savingEntry" @click="saveEntry">
{{ savingEntry ? '保存中' : '保存条目' }}
</button>
<button class="btn-secondary" @click="editing = null">取消</button>
</div>
</div>
</div>
</section>
<!-- 🔧 小模型 (Worker) -->
<section class="settings-section">
<div class="section-title">
@@ -279,14 +410,143 @@ import {
getConfig, updateConfig, resetConfig, listModels, pingBackend,
getLlamaStatus, listLocalModels, startLlama, stopLlama,
downloadModel, watchDownload,
getPool, upsertPoolEntry, deletePoolEntry, setPoolRoles, testPoolEntry, listPoolModels,
} from '@/api'
import type {
ModelSettings, ModelInfo, LlamaStatus, LocalModel,
PoolData, PoolEntry, PoolRole,
} from '@/api'
import type { ModelSettings, ModelInfo, LlamaStatus, LocalModel } from '@/api'
// ── 状态 ──────────────────────────────────────────────────────────────────
const saving = ref(false)
const loadError = ref('')
const saveMsg = ref<{ ok: boolean; msg: string } | null>(null)
// 模型池
const ROLE_DEFS: { key: PoolRole; label: string }[] = [
{ key: 'architect', label: '🧠 Architect(决策/终审)' },
{ key: 'worker', label: '🔧 Worker(实现/自验证)' },
{ key: 'agent', label: '🤖 Agent(智能体工具)' },
]
const TIER_LABELS: Record<string, string> = {
local: '本地/零成本', budget: '低价 API', premium: '旗舰 API',
}
const pool = ref<PoolData>({ roles: { architect: '', worker: '', agent: '' }, entries: [] })
const poolRoles = reactive<Record<PoolRole, string>>({ architect: '', worker: '', agent: '' })
const editing = ref<Partial<PoolEntry> | null>(null)
const editingIsNew = ref(false)
const testingId = ref('')
const poolTestMsg = ref('')
const poolTestOk = ref(false)
const savingEntry = ref(false)
const fetchingPoolModels = ref(false)
const poolModelOptions = ref<ModelInfo[]>([])
const enabledEntries = computed(() => pool.value.entries.filter(e => e.enabled))
function tierLabel(t: string) { return TIER_LABELS[t] ?? t }
async function loadPool() {
try {
pool.value = await getPool()
poolRoles.architect = pool.value.roles.architect || ''
poolRoles.worker = pool.value.roles.worker || ''
poolRoles.agent = pool.value.roles.agent || ''
} catch { /* 池接口不可用时静默(老后端兼容) */ }
}
async function saveRoles() {
try {
pool.value = await setPoolRoles({ ...poolRoles })
saveMsg.value = { ok: true, msg: '✅ 角色已更新,管线已重建' }
} catch (e: any) {
saveMsg.value = { ok: false, msg: '❌ 角色指派失败:' + (e?.response?.data?.detail || e?.message || e) }
await loadPool()
}
setTimeout(() => (saveMsg.value = null), 3000)
}
function addPoolEntry() {
editingIsNew.value = true
editing.value = {
name: '', tier: 'budget', backend: 'openai', base_url: '', model: '',
api_key: '', price_in: 0.1, price_out: 0.1, temperature: 0.3,
max_tokens: 4096, enabled: true,
}
}
function editEntry(e: PoolEntry) {
editingIsNew.value = false
editing.value = { ...e, api_key: '' } // key 留空 = 保留服务端原值
}
async function saveEntry() {
if (!editing.value) return
savingEntry.value = true
try {
pool.value = await upsertPoolEntry(editing.value)
editing.value = null
saveMsg.value = { ok: true, msg: '✅ 模型条目已保存' }
} catch (e: any) {
saveMsg.value = { ok: false, msg: '❌ 保存失败:' + (e?.response?.data?.detail || e?.message || e) }
} finally {
savingEntry.value = false
setTimeout(() => (saveMsg.value = null), 3000)
}
}
async function removeEntry(e: PoolEntry) {
if (!confirm(`删除模型条目「${e.name}」?相关角色指派会一并清空。`)) return
pool.value = await deletePoolEntry(e.id)
}
async function toggleEnabled(e: PoolEntry) {
// key 传空串 = 保留原值(列表里的 key 是打码值,不能回传)
pool.value = await upsertPoolEntry({ ...e, api_key: '' })
}
async function testEntry(e: PoolEntry) {
testingId.value = e.id
poolTestMsg.value = ''
try {
const r = await testPoolEntry(e.id)
poolTestOk.value = r.ok
poolTestMsg.value = r.ok ? `${e.name} 连接正常` : `${e.name}: ${r.detail || '连接失败'}`
} catch (err: any) {
poolTestOk.value = false
poolTestMsg.value = '❌ ' + (err?.message || String(err))
} finally {
testingId.value = ''
}
}
async function fetchPoolModelList() {
if (!editing.value) return
fetchingPoolModels.value = true
try {
let result: { models?: ModelInfo[]; error?: string }
if (!editingIsNew.value && editing.value.id) {
result = await listPoolModels(editing.value.id)
} else if (editing.value.backend === 'mock') {
result = { models: [{ id: 'mock', name: 'mock(内置模拟)' }] }
} else {
result = await listModels(
editing.value.backend || 'openai',
editing.value.base_url || '',
editing.value.api_key || '')
}
if (result.error) {
poolTestOk.value = false
poolTestMsg.value = result.error
poolModelOptions.value = []
} else {
poolModelOptions.value = result.models || []
}
} finally {
fetchingPoolModels.value = false
}
}
// llama-server
const llamaStatus = ref<LlamaStatus>({ running: false })
const startingLlama = ref(false)
@@ -610,12 +870,13 @@ function _safeAssign(target: any, source: any) {
async function load() {
loadError.value = ''
try {
// 并行加载配置 + llama 状态
// 并行加载配置 + llama 状态 + 模型池
const [cfg, llama] = await Promise.all([
getConfig(),
loadLlamaStatus(),
])
llamaStatus.value = llama
loadPool()
// 安全填充表单(防止 API 返回 null 导致响应式丢失)
if (cfg.worker) _safeAssign(form.worker, cfg.worker)
@@ -940,6 +1201,64 @@ onUnmounted(() => { _dlSSE?.close() })
.field-hint { font-size: 11px; }
.field-hint.warn { color: #d97706; }
.field-hint.ok { color: #16a34a; }
/* 模型池 */
.role-bar {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 8px;
}
.role-item { display: flex; flex-direction: column; gap: 4px; }
.role-item label { font-size: 12px; font-weight: 600; color: #374151; }
.role-item select {
border: 1px solid #d1d5db;
border-radius: 6px;
padding: 6px 8px;
font-size: 13px;
background: #fff;
}
.role-hint { color: #9ca3af; font-size: 12px; margin-bottom: 12px; }
.pool-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.pool-table th, .pool-table td {
text-align: left;
padding: 7px 10px;
border-bottom: 1px solid #f3f4f6;
white-space: nowrap;
}
.pool-table th { color: #6b7280; font-weight: 600; font-size: 12px; background: #f9fafb; }
.pool-table td.mono { font-family: ui-monospace, Consolas, monospace; font-size: 12px; }
.row-actions { display: flex; gap: 6px; }
.row-actions .btn-refresh.danger { color: #dc2626; }
.row-actions .btn-refresh.danger:hover:not(:disabled) { background: #fee2e2; }
.tier-badge {
display: inline-block;
border-radius: 10px;
padding: 1px 8px;
font-size: 11px;
font-weight: 600;
}
.tier-local { color: #166534; background: #dcfce7; }
.tier-budget { color: #1e40af; background: #dbeafe; }
.tier-premium { color: #7c2d12; background: #ffedd5; }
.pool-edit {
margin-top: 14px;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 14px;
background: #fafafa;
}
.pool-edit-title { font-size: 13px; font-weight: 600; color: #374151; margin-bottom: 10px; }
.pool-edit-actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
}
.btn-refresh {
background: #f3f4f6;