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:
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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user