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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{A as e,D as t,G as n,I as r,L as i,N as a,O as o,P as s,V as c,W as l,j as u,k as d,s as f,t as p}from"./index-BLEch8mk.js";var m={class:`metrics-view`},h={key:0,class:`loading`},g={key:1,class:`error`},_={class:`card-grid`},v={class:`metric-card`},y={class:`kv-list`},b={class:`metric-card`},x={class:`kv-list`},S={key:0,class:`metric-card highlight`},C={class:`kv-list`},w={key:0},T={key:1},E={key:1,class:`metric-card`},D={class:`by-model`},O={class:`mono`},k={key:2,class:`metric-card review-card`},A={class:`review-stats`},j={class:`stat-item`},M={class:`stat-num`},N={class:`stat-item`},P={class:`stat-num`},F={key:0,class:`progress-wrap`},I={class:`review-rate`},L={class:`raw-json`},R=p(a({__name:`MetricsView`,setup(a){let p=c(null),R=c(!1),z=c(``),B=o(()=>p.value?.v2?.by_model||null);async function V(){R.value=!0,z.value=``;try{p.value=await f()}catch(e){z.value=e instanceof Error?e.message:`指标加载失败,请检查后端服务`}finally{R.value=!1}}return s(V),(a,o)=>(r(),u(`div`,m,[d(`header`,{class:`metrics-header`},[o[0]||=d(`h2`,null,`系统指标`,-1),d(`button`,{class:`refresh`,onClick:V},`🔄 刷新`)]),R.value?(r(),u(`div`,h,`加载中…`)):z.value?(r(),u(`div`,g,n(z.value),1)):p.value?(r(),u(t,{key:2},[d(`div`,_,[d(`div`,v,[o[1]||=d(`h3`,null,`路由器(v1`,-1),d(`div`,y,[(r(!0),u(t,null,i(p.value.router,(e,i)=>(r(),u(t,{key:i},[d(`span`,null,n(i),1),d(`b`,null,n(e),1)],64))),128))])]),d(`div`,b,[o[2]||=d(`h3`,null,`缓存`,-1),d(`div`,x,[(r(!0),u(t,null,i(p.value.cache,(e,i)=>(r(),u(t,{key:i},[d(`span`,null,n(i),1),d(`b`,null,n(e),1)],64))),128))])]),p.value.v2?(r(),u(`div`,S,[o[3]||=d(`h3`,null,`协作管线(v2`,-1),d(`div`,C,[(r(!0),u(t,null,i(p.value.v2,(i,a)=>(r(),u(t,{key:a},[a===`by_model`?e(``,!0):(r(),u(`span`,w,n(a),1)),a===`by_model`?e(``,!0):(r(),u(`b`,T,n(i),1))],64))),128))])])):e(``,!0),B.value&&Object.keys(B.value).length?(r(),u(`div`,E,[o[5]||=d(`h3`,null,`按模型分账(token / 成本)`,-1),d(`table`,D,[o[4]||=d(`thead`,null,[d(`tr`,null,[d(`th`,null,`模型`),d(`th`,null,`次数`),d(`th`,null,``),d(`th`,null,``),d(`th`,null,`成本 $`)])],-1),d(`tbody`,null,[(r(!0),u(t,null,i(B.value,(e,t)=>(r(),u(`tr`,{key:t},[d(`td`,O,n(t),1),d(`td`,null,n(e.requests),1),d(`td`,null,n(e.input_tokens),1),d(`td`,null,n(e.output_tokens),1),d(`td`,null,n(e.cost_est_usd),1)]))),128))])]),o[6]||=d(`p`,{class:`hint`},`单价来自模型池条目($/1M tokens);经典设置下的模型成本不计入。`,-1)])):e(``,!0),p.value.review?(r(),u(`div`,k,[o[9]||=d(`h3`,null,`人工检验`,-1),d(`div`,A,[d(`div`,j,[d(`span`,M,n(p.value.review.pending),1),o[7]||=d(`span`,{class:`stat-label`},`待审核`,-1)]),d(`div`,N,[d(`span`,P,n(p.value.review.total),1),o[8]||=d(`span`,{class:`stat-label`},`总提交`,-1)])]),p.value.review.total>0?(r(),u(`div`,F,[d(`div`,{class:`reviewed-bar`,style:l({width:`${(p.value.review.total-p.value.review.pending)/p.value.review.total*100}%`})},null,4)])):e(``,!0),d(`p`,I,` 通过率: `+n(((p.value.review.total-p.value.review.pending)/p.value.review.total*100).toFixed(1))+`% `,1)])):e(``,!0)]),d(`details`,L,[o[10]||=d(`summary`,null,`原始 JSON`,-1),d(`pre`,null,n(JSON.stringify(p.value,null,2)),1)])],64)):e(``,!0)]))}}),[[`__scopeId`,`data-v-ba641559`]]);export{R as default};
@@ -1 +0,0 @@
.metrics-view[data-v-51f92136]{height:100%;padding:20px 24px;overflow-y:auto}.metrics-header[data-v-51f92136]{justify-content:space-between;align-items:center;margin-bottom:20px;display:flex}.metrics-header h2[data-v-51f92136]{margin:0;font-size:20px}.refresh[data-v-51f92136]{cursor:pointer;background:#fff;border:1px solid #d1d5db;border-radius:6px;padding:6px 14px}.loading[data-v-51f92136],.error[data-v-51f92136]{text-align:center;color:#9ca3af;padding:40px}.error[data-v-51f92136]{color:#dc2626}.card-grid[data-v-51f92136]{grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;margin-bottom:24px;display:grid}.metric-card[data-v-51f92136]{background:#fff;border:1px solid #e5e7eb;border-radius:10px;padding:16px}.metric-card.highlight[data-v-51f92136]{background:#eff6ff;border-color:#2563eb}.metric-card h3[data-v-51f92136]{color:#374151;margin:0 0 12px;font-size:14px}.kv-list[data-v-51f92136]{grid-template-columns:1fr 1fr;gap:6px 12px;font-size:13px;display:grid}.kv-list span[data-v-51f92136]{color:#6b7280}.kv-list b[data-v-51f92136]{color:#111;text-align:right}.review-card[data-v-51f92136]{grid-column:span 2}.review-stats[data-v-51f92136]{gap:24px;margin-bottom:12px;display:flex}.stat-item[data-v-51f92136]{flex-direction:column;align-items:center;display:flex}.stat-num[data-v-51f92136]{color:#2563eb;font-size:28px;font-weight:700}.stat-label[data-v-51f92136]{color:#6b7280;font-size:12px}.progress-wrap[data-v-51f92136]{background:#e5e7eb;border-radius:99px;height:8px;margin-bottom:6px;overflow:hidden}.reviewed-bar[data-v-51f92136]{background:#16a34a;height:100%;transition:width .5s}.review-rate[data-v-51f92136]{color:#6b7280;margin:0;font-size:13px}.raw-json[data-v-51f92136]{background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px}.raw-json summary[data-v-51f92136]{cursor:pointer;color:#6b7280;padding:10px 14px;font-size:13px}.raw-json pre[data-v-51f92136]{white-space:pre-wrap;border-top:1px solid #e5e7eb;margin:0;padding:10px 14px;font-size:12px}
@@ -0,0 +1 @@
.metrics-view[data-v-ba641559]{height:100%;padding:20px 24px;overflow-y:auto}.by-model[data-v-ba641559]{border-collapse:collapse;width:100%;font-size:12px}.by-model th[data-v-ba641559],.by-model td[data-v-ba641559]{text-align:left;border-bottom:1px solid #f3f4f6;padding:4px 8px}.by-model th[data-v-ba641559]{color:#6b7280;font-weight:600}.by-model td.mono[data-v-ba641559]{font-family:ui-monospace,Consolas,monospace}.hint[data-v-ba641559]{color:#9ca3af;margin-top:8px;font-size:11px}.metrics-header[data-v-ba641559]{justify-content:space-between;align-items:center;margin-bottom:20px;display:flex}.metrics-header h2[data-v-ba641559]{margin:0;font-size:20px}.refresh[data-v-ba641559]{cursor:pointer;background:#fff;border:1px solid #d1d5db;border-radius:6px;padding:6px 14px}.loading[data-v-ba641559],.error[data-v-ba641559]{text-align:center;color:#9ca3af;padding:40px}.error[data-v-ba641559]{color:#dc2626}.card-grid[data-v-ba641559]{grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;margin-bottom:24px;display:grid}.metric-card[data-v-ba641559]{background:#fff;border:1px solid #e5e7eb;border-radius:10px;padding:16px}.metric-card.highlight[data-v-ba641559]{background:#eff6ff;border-color:#2563eb}.metric-card h3[data-v-ba641559]{color:#374151;margin:0 0 12px;font-size:14px}.kv-list[data-v-ba641559]{grid-template-columns:1fr 1fr;gap:6px 12px;font-size:13px;display:grid}.kv-list span[data-v-ba641559]{color:#6b7280}.kv-list b[data-v-ba641559]{color:#111;text-align:right}.review-card[data-v-ba641559]{grid-column:span 2}.review-stats[data-v-ba641559]{gap:24px;margin-bottom:12px;display:flex}.stat-item[data-v-ba641559]{flex-direction:column;align-items:center;display:flex}.stat-num[data-v-ba641559]{color:#2563eb;font-size:28px;font-weight:700}.stat-label[data-v-ba641559]{color:#6b7280;font-size:12px}.progress-wrap[data-v-ba641559]{background:#e5e7eb;border-radius:99px;height:8px;margin-bottom:6px;overflow:hidden}.reviewed-bar[data-v-ba641559]{background:#16a34a;height:100%;transition:width .5s}.review-rate[data-v-ba641559]{color:#6b7280;margin:0;font-size:13px}.raw-json[data-v-ba641559]{background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px}.raw-json summary[data-v-ba641559]{cursor:pointer;color:#6b7280;padding:10px 14px;font-size:13px}.raw-json pre[data-v-ba641559]{white-space:pre-wrap;border-top:1px solid #e5e7eb;margin:0;padding:10px 14px;font-size:12px}
@@ -1 +0,0 @@
import{A as e,C as t,D as n,F as r,O as i,R as a,T as o,j as s,o as c,t as l,w as u,x as d,z as f}from"./index-BnlAnYlB.js";var p={class:`metrics-view`},m={key:0,class:`loading`},h={key:1,class:`error`},g={class:`card-grid`},_={class:`metric-card`},v={class:`kv-list`},y={class:`metric-card`},b={class:`kv-list`},x={key:0,class:`metric-card highlight`},S={class:`kv-list`},C={key:1,class:`metric-card review-card`},w={class:`review-stats`},T={class:`stat-item`},E={class:`stat-num`},D={class:`stat-item`},O={class:`stat-num`},k={key:0,class:`progress-wrap`},A={class:`review-rate`},j={class:`raw-json`},M=l(n({__name:`MetricsView`,setup(n){let l=r(null),M=r(!1),N=r(``);async function P(){M.value=!0,N.value=``;try{l.value=await c()}catch(e){N.value=e instanceof Error?e.message:`指标加载失败,请检查后端服务`}finally{M.value=!1}}return i(P),(n,r)=>(e(),o(`div`,p,[t(`header`,{class:`metrics-header`},[r[0]||=t(`h2`,null,`系统指标`,-1),t(`button`,{class:`refresh`,onClick:P},`🔄 刷新`)]),M.value?(e(),o(`div`,m,`加载中…`)):N.value?(e(),o(`div`,h,f(N.value),1)):l.value?(e(),o(d,{key:2},[t(`div`,g,[t(`div`,_,[r[1]||=t(`h3`,null,`路由器(v1`,-1),t(`div`,v,[(e(!0),o(d,null,s(l.value.router,(n,r)=>(e(),o(d,{key:r},[t(`span`,null,f(r),1),t(`b`,null,f(n),1)],64))),128))])]),t(`div`,y,[r[2]||=t(`h3`,null,`缓存`,-1),t(`div`,b,[(e(!0),o(d,null,s(l.value.cache,(n,r)=>(e(),o(d,{key:r},[t(`span`,null,f(r),1),t(`b`,null,f(n),1)],64))),128))])]),l.value.v2?(e(),o(`div`,x,[r[3]||=t(`h3`,null,`协作管线(v2`,-1),t(`div`,S,[(e(!0),o(d,null,s(l.value.v2,(n,r)=>(e(),o(d,{key:r},[t(`span`,null,f(r),1),t(`b`,null,f(n),1)],64))),128))])])):u(``,!0),l.value.review?(e(),o(`div`,C,[r[6]||=t(`h3`,null,`人工检验`,-1),t(`div`,w,[t(`div`,T,[t(`span`,E,f(l.value.review.pending),1),r[4]||=t(`span`,{class:`stat-label`},`待审核`,-1)]),t(`div`,D,[t(`span`,O,f(l.value.review.total),1),r[5]||=t(`span`,{class:`stat-label`},`总提交`,-1)])]),l.value.review.total>0?(e(),o(`div`,k,[t(`div`,{class:`reviewed-bar`,style:a({width:`${(l.value.review.total-l.value.review.pending)/l.value.review.total*100}%`})},null,4)])):u(``,!0),t(`p`,A,` 通过率: `+f(((l.value.review.total-l.value.review.pending)/l.value.review.total*100).toFixed(1))+`% `,1)])):u(``,!0)]),t(`details`,j,[r[7]||=t(`summary`,null,`原始 JSON`,-1),t(`pre`,null,f(JSON.stringify(l.value,null,2)),1)])],64)):u(``,!0)]))}}),[[`__scopeId`,`data-v-51f92136`]]);export{M as default};
@@ -0,0 +1 @@
import{A as e,D as t,E as n,G as r,I as i,L as a,M as o,N as s,O as c,P as l,U as u,V as d,f,j as p,k as m,t as h,v as g,z as _}from"./index-BLEch8mk.js";var v={class:`review-view`},y={class:`review-header`},b={class:`controls`},x={key:0,class:`loading`},S={key:1,class:`error`},C={key:2,class:`queue-list`},w={key:0,class:`empty`},T={class:`card-header`},E={class:`card-id`},D={class:`tags`},O={class:`date`},k={class:`query-block`},A={class:`response-block`},j={key:0,class:`actions`},M=[`onUpdate:modelValue`],N={class:`btn-row`},P=[`onClick`],F=[`onClick`],I={key:1,class:`correction`},L=h(s({__name:`ReviewView`,setup(s){let h=d([]),L=d(!1),R=d(``),z=d(`pending`),B=d({}),V=c(()=>z.value===`all`?h.value:h.value.filter(e=>e.verdict===z.value));async function H(){L.value=!0,R.value=``;try{h.value=await f()}catch(e){R.value=e instanceof Error?e.message:String(e)}finally{L.value=!1}}async function U(e,t){try{await g(e,t,B.value[e]||void 0),await H()}catch(e){R.value=e instanceof Error?e.message:String(e)}}return l(H),(s,c)=>(i(),p(`div`,v,[m(`header`,y,[c[4]||=m(`h2`,null,`人工检验队列`,-1),m(`div`,b,[m(`button`,{class:u({active:z.value===`all`}),onClick:c[0]||=e=>z.value=`all`},`全部`,2),m(`button`,{class:u({active:z.value===`pending`}),onClick:c[1]||=e=>z.value=`pending`},`待审核`,2),m(`button`,{class:u({active:z.value===`approved`}),onClick:c[2]||=e=>z.value=`approved`},`已通过`,2),m(`button`,{class:u({active:z.value===`rejected`}),onClick:c[3]||=e=>z.value=`rejected`},`已拒绝`,2),m(`button`,{class:`refresh-btn`,onClick:H},`🔄 刷新`)])]),L.value?(i(),p(`div`,x,`加载中…`)):R.value?(i(),p(`div`,S,r(R.value),1)):(i(),p(`div`,C,[V.value.length?e(``,!0):(i(),p(`div`,w,`队列为空。`)),(i(!0),p(t,null,a(V.value,s=>(i(),p(`div`,{key:s.id,class:`review-card`},[m(`div`,T,[m(`span`,E,`#`+r(s.id),1),m(`span`,{class:u([`verdict-badge`,s.verdict])},r(s.verdict),3),m(`span`,D,[(i(!0),p(t,null,a(s.tags,e=>(i(),p(`span`,{key:e,class:`tag`},r(e),1))),128))]),m(`span`,O,r(s.created_at),1)]),m(`div`,k,[c[5]||=m(`strong`,null,`Query`,-1),o(r(s.query),1)]),m(`div`,A,[c[6]||=m(`strong`,null,`Response`,-1),m(`pre`,null,r(s.response),1)]),s.verdict===`pending`?(i(),p(`div`,j,[_(m(`textarea`,{"onUpdate:modelValue":e=>B.value[s.id]=e,placeholder:`修正意见(可选)`,rows:`2`},null,8,M),[[n,B.value[s.id]]]),m(`div`,N,[m(`button`,{class:`approve`,onClick:e=>U(s.id,`approved`)},`✅ 通过`,8,P),m(`button`,{class:`reject`,onClick:e=>U(s.id,`rejected`)},`❌ 拒绝`,8,F)])])):s.correction?(i(),p(`div`,I,[c[7]||=m(`strong`,null,`修正:`,-1),o(r(s.correction),1)])):e(``,!0)]))),128))]))]))}}),[[`__scopeId`,`data-v-19c16eff`]]);export{L as default};
@@ -1 +0,0 @@
import{A as e,C as t,D as n,E as r,F as i,L as a,N as o,O as s,S as c,T as l,b as u,j as d,l as f,m as p,t as m,w as h,x as g,z as _}from"./index-BnlAnYlB.js";var v={class:`review-view`},y={class:`review-header`},b={class:`controls`},x={key:0,class:`loading`},S={key:1,class:`error`},C={key:2,class:`queue-list`},w={key:0,class:`empty`},T={class:`card-header`},E={class:`card-id`},D={class:`tags`},O={class:`date`},k={class:`query-block`},A={class:`response-block`},j={key:0,class:`actions`},M=[`onUpdate:modelValue`],N={class:`btn-row`},P=[`onClick`],F=[`onClick`],I={key:1,class:`correction`},L=m(n({__name:`ReviewView`,setup(n){let m=i([]),L=i(!1),R=i(``),z=i(`pending`),B=i({}),V=c(()=>z.value===`all`?m.value:m.value.filter(e=>e.verdict===z.value));async function H(){L.value=!0,R.value=``;try{m.value=await f()}catch(e){R.value=e instanceof Error?e.message:String(e)}finally{L.value=!1}}async function U(e,t){try{await p(e,t,B.value[e]||void 0),await H()}catch(e){R.value=e instanceof Error?e.message:String(e)}}return s(H),(n,i)=>(e(),l(`div`,v,[t(`header`,y,[i[4]||=t(`h2`,null,`人工检验队列`,-1),t(`div`,b,[t(`button`,{class:a({active:z.value===`all`}),onClick:i[0]||=e=>z.value=`all`},`全部`,2),t(`button`,{class:a({active:z.value===`pending`}),onClick:i[1]||=e=>z.value=`pending`},`待审核`,2),t(`button`,{class:a({active:z.value===`approved`}),onClick:i[2]||=e=>z.value=`approved`},`已通过`,2),t(`button`,{class:a({active:z.value===`rejected`}),onClick:i[3]||=e=>z.value=`rejected`},`已拒绝`,2),t(`button`,{class:`refresh-btn`,onClick:H},`🔄 刷新`)])]),L.value?(e(),l(`div`,x,`加载中…`)):R.value?(e(),l(`div`,S,_(R.value),1)):(e(),l(`div`,C,[V.value.length?h(``,!0):(e(),l(`div`,w,`队列为空。`)),(e(!0),l(g,null,d(V.value,n=>(e(),l(`div`,{key:n.id,class:`review-card`},[t(`div`,T,[t(`span`,E,`#`+_(n.id),1),t(`span`,{class:a([`verdict-badge`,n.verdict])},_(n.verdict),3),t(`span`,D,[(e(!0),l(g,null,d(n.tags,t=>(e(),l(`span`,{key:t,class:`tag`},_(t),1))),128))]),t(`span`,O,_(n.created_at),1)]),t(`div`,k,[i[5]||=t(`strong`,null,`Query`,-1),r(_(n.query),1)]),t(`div`,A,[i[6]||=t(`strong`,null,`Response`,-1),t(`pre`,null,_(n.response),1)]),n.verdict===`pending`?(e(),l(`div`,j,[o(t(`textarea`,{"onUpdate:modelValue":e=>B.value[n.id]=e,placeholder:`修正意见(可选)`,rows:`2`},null,8,M),[[u,B.value[n.id]]]),t(`div`,N,[t(`button`,{class:`approve`,onClick:e=>U(n.id,`approved`)},`✅ 通过`,8,P),t(`button`,{class:`reject`,onClick:e=>U(n.id,`rejected`)},`❌ 拒绝`,8,F)])])):n.correction?(e(),l(`div`,I,[i[7]||=t(`strong`,null,`修正:`,-1),r(_(n.correction),1)])):h(``,!0)]))),128))]))]))}}),[[`__scopeId`,`data-v-19c16eff`]]);export{L as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webapp</title>
<script type="module" crossorigin src="/static/assets/index-BnlAnYlB.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-G8RWfJ2Y.css">
<script type="module" crossorigin src="/static/assets/index-BLEch8mk.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-CLgISH8m.css">
</head>
<body>
<div id="app"></div>
+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;