feat(v3): T24 智能体工作区选择 UI + harness 风格工具卡

- 工作区选择栏(当前目录徽标 + 选择目录按钮 + 允许执行命令开关)
- 目录选择器模态:盘符→逐级浏览、上级、手输路径、不存在可新建、最近工作区快选
- edit_file 渲染为红/绿 diff 卡;run_command 渲染 $ 命令卡;文件面板随所选目录切换
This commit is contained in:
tzt
2026-09-01 10:16:58 +08:00
parent 8358302002
commit da5dfb0ef3
11 changed files with 331 additions and 38 deletions
File diff suppressed because one or more lines are too long
@@ -1 +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};
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-Bfb_lSiW.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 +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};
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-Bfb_lSiW.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};
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-BLEch8mk.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-CLgISH8m.css">
<script type="module" crossorigin src="/static/assets/index-Bfb_lSiW.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-zGyQjwum.css">
</head>
<body>
<div id="app"></div>
+45 -10
View File
@@ -336,12 +336,13 @@ export interface AgentStatus {
error?: string | null
started_at?: number
finished_at?: number
workspace?: string
}
/** 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 })
/** POST /agent:提交智能体任务(workspace 可选:选中工作目录) */
export async function startAgent(task: string, poolId?: string, workspace?: string) {
const { data } = await http.post<{ request_id: string; status: string; model: string; workspace?: string }>(
'/agent', { task, pool_id: poolId || undefined, workspace: workspace || undefined })
return data
}
@@ -357,20 +358,54 @@ export async function getAgentEvents(requestId: string) {
return data
}
/** GET /agent/workspace?path=...:浏览工作区目录 */
export async function listAgentWorkspace(path = '') {
/** GET /agent/workspace?path=&root=:浏览工作区目录root 可指定选中工作区) */
export async function listAgentWorkspace(path = '', root = '') {
const { data } = await http.get<{
ok: boolean
entries?: { name: string; type: string; size?: number }[]
error?: string
}>('/agent/workspace', { params: path ? { path } : {} })
}>('/agent/workspace', { params: { ...(path ? { path } : {}), ...(root ? { root } : {}) } })
return data
}
/** GET /agent/file?path=...:读取工作区文件 */
export async function readAgentFile(path: string) {
/** GET /agent/file?path=&root=:读取工作区文件 */
export async function readAgentFile(path: string, root = '') {
const { data } = await http.get<{ ok: boolean; content: string; truncated: boolean; error?: string }>(
'/agent/file', { params: { path } })
'/agent/file', { params: { path, ...(root ? { root } : {}) } })
return data
}
// ── 智能体:工作区选择(参考 deepseek-harness 的打开文件夹体验) ─────────────
export interface FsBrowse {
ok: boolean
path: string
parent: string
dirs: string[]
error?: string
}
export interface WorkspacesInfo {
current: string
recent: string[]
}
/** GET /agent/fs?path=:浏览本地目录(目录选择器,只列子目录) */
export async function browseFs(path = '') {
const { data } = await http.get<FsBrowse>('/agent/fs', { params: path ? { path } : {} })
return data
}
/** GET /agent/workspaces:当前工作区 + 最近列表 */
export async function getAgentWorkspaces() {
const { data } = await http.get<WorkspacesInfo>('/agent/workspaces')
return data
}
/** POST /agent/workspaces:打开(或新建)工作目录并设为当前 */
export async function openWorkspace(path: string, create = false) {
const { data } = await http.post<{ ok: boolean; current: string; recent: string[] }>(
'/agent/workspaces', { path, create })
return data
}
+268 -10
View File
@@ -8,6 +8,7 @@
{{ loadingWs ? '' : '🔄' }}
</button>
</div>
<div class="ws-root" :title="selectedRoot">{{ rootName(selectedRoot) }}</div>
<ul class="ws-list">
<li v-for="f in wsFiles" :key="f.name"
:class="{ dir: f.type === 'dir' }"
@@ -15,7 +16,7 @@
<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>
<li v-if="!wsFiles.length && !loadingWs" class="ws-empty">智能体写的文件会出现在这里</li>
</ul>
<div v-if="filePreview" class="file-preview">
<div class="fp-head">
@@ -30,9 +31,19 @@
<main class="agent-main">
<header class="agent-head">
<h2>🤖 智能体</h2>
<p class="sub">像编程助手一样操作工作区文件列目录 / 文件 / 文件程实时可视化</p>
<p class="sub">像编程助手一样操作你选择的工作目录列目录 / / / 精确编辑 / 搜索 / 执行命令全程实时可视化</p>
</header>
<!-- 工作区选择栏 -->
<div class="ws-bar">
<span class="ws-current" :title="selectedRoot">📁 {{ rootName(selectedRoot) || '未选择' }}</span>
<button class="btn-refresh" @click="openPicker">选择目录</button>
<label class="shell-toggle" title="开启后智能体可执行 shell 命令(run_command),在工作区内运行">
<input type="checkbox" v-model="allowShell" @change="toggleShell" />
允许执行命令
</label>
</div>
<div class="task-bar">
<select v-model="selectedPoolId" class="model-select">
<option value="">默认模型模型池 Agent 角色 / Architect 设置</option>
@@ -41,7 +52,7 @@
</div>
<div class="task-bar">
<textarea v-model="task" class="task-input" rows="3"
placeholder="给智能体一个任务,例如:查看工作区里有哪些文件,并写一个 hello.py 输出九九乘法表"
placeholder="给智能体一个任务,例如:浏览这个项目,找到入口文件并在 README 里补充运行说明"
:disabled="running" />
<button class="btn-run" :disabled="running || !task.trim()" @click="run">
{{ running ? '运行中' : ' 运行' }}
@@ -55,6 +66,17 @@
<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' && ev.name === 'edit_file'" class="ev-card">
<div class="ev-title"> 精确编辑 <b>{{ (ev.arguments as any)?.path }}</b></div>
<pre class="diff-old">- {{ (ev.arguments as any)?.old_string }}</pre>
<pre class="diff-new">+ {{ (ev.arguments as any)?.new_string }}</pre>
</div>
<div v-else-if="ev.type === 'tool_call' && ev.name === 'run_command'" class="ev-card">
<div class="ev-title"> 执行命令</div>
<pre class="ev-args">$ {{ (ev.arguments as any)?.command }}</pre>
</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>
@@ -81,10 +103,45 @@
<div v-if="running" class="ev-round"> 智能体正在工作</div>
<div v-if="!events.length && !running" class="empty-hint">
输入任务并运行每一步工具调用都会实时显示在这里
选择工作目录输入任务并运行每一步工具调用都会实时显示在这里
</div>
</div>
</main>
<!-- 目录选择器模态 -->
<div v-if="pickerOpen" class="modal-mask" @click.self="pickerOpen = false">
<div class="modal">
<div class="modal-title">选择工作目录</div>
<div class="crumb">{{ browse.path || '(此电脑 — 点击选择盘符)' }}</div>
<div class="dir-list">
<div v-if="browse.parent" class="dir-item up" @click="gotoDir(browse.parent)">
上级{{ browse.parent }}
</div>
<div v-for="d in browse.dirs" :key="d" class="dir-item" @click="gotoDir(joinDir(browse.path, d))">
📂 {{ d }}
</div>
<div v-if="!browse.dirs.length" class="dir-item empty">无子目录可直接点下方"打开此目录"</div>
</div>
<div class="path-row">
<input v-model="manualPath" placeholder="或直接输入绝对路径,如 E:\my-project" />
<label class="create-toggle">
<input type="checkbox" v-model="createIfMissing" /> 不存在则新建
</label>
</div>
<div v-if="recent.length" class="recent-row">
<span class="recent-label">最近</span>
<button v-for="w in recent" :key="w" class="recent-chip" :title="w"
@click="chooseDir(w)">{{ rootName(w) }}</button>
</div>
<div v-if="pickerMsg" class="picker-msg">{{ pickerMsg }}</div>
<div class="modal-actions">
<button class="btn-primary" @click="chooseDir(manualPath.trim() || browse.path)">
打开此目录
</button>
<button class="btn-secondary" @click="pickerOpen = false">取消</button>
</div>
</div>
</div>
</div>
</template>
@@ -93,8 +150,9 @@ import { ref, computed, nextTick, onMounted } from 'vue'
import {
startAgent, getAgentStatus, watchAgent,
listAgentWorkspace, readAgentFile, getPool,
browseFs, getAgentWorkspaces, openWorkspace, updateConfig,
} from '@/api'
import type { AgentEvent, PoolEntry } from '@/api'
import type { AgentEvent, PoolEntry, FsBrowse } from '@/api'
const task = ref('')
const running = ref(false)
@@ -103,6 +161,16 @@ const statusInfo = ref<{ state: string; error?: string | null } | null>(null)
const selectedPoolId = ref('')
const poolEntries = ref<PoolEntry[]>([])
// 工作区选择
const selectedRoot = ref('')
const recent = ref<string[]>([])
const pickerOpen = ref(false)
const pickerMsg = ref('')
const browse = ref<FsBrowse>({ ok: true, path: '', parent: '', dirs: [] })
const manualPath = ref('')
const createIfMissing = ref(false)
const allowShell = ref(false)
const wsFiles = ref<{ name: string; type: string; size?: number }[]>([])
const loadingWs = ref(false)
const filePreview = ref<{ path: string; content: string; truncated: boolean } | null>(null)
@@ -138,6 +206,16 @@ function fmtSize(n?: number) {
if (n >= 1e3) return (n / 1e3).toFixed(1) + ' KB'
return n + ' B'
}
function rootName(p: string) {
if (!p) return ''
const parts = p.replace(/[\\/]+$/, '').split(/[\\/]/)
return parts[parts.length - 1] || p
}
function joinDir(p: string, d: string) {
if (!p) return d
const sep = p.includes('\\') ? '\\' : '/'
return p.endsWith('\\') || p.endsWith('/') ? p + d : p + sep + d
}
function scrollToBottom() {
nextTick(() => timelineEl.value?.scrollTo({ top: timelineEl.value.scrollHeight }))
@@ -146,25 +224,65 @@ function scrollToBottom() {
async function refreshWs() {
loadingWs.value = true
try {
const r = await listAgentWorkspace('')
const r = await listAgentWorkspace('', selectedRoot.value)
wsFiles.value = r.entries || []
} catch { wsFiles.value = [] } finally { loadingWs.value = false }
}
async function openFile(name: string) {
try {
const r = await readAgentFile(name)
const r = await readAgentFile(name, selectedRoot.value)
if (r.ok) filePreview.value = { path: name, content: r.content, truncated: r.truncated }
} catch { /* ignore */ }
}
// ── 目录选择器 ──────────────────────────────────────────────────────────
async function openPicker() {
pickerOpen.value = true
pickerMsg.value = ''
manualPath.value = selectedRoot.value
try {
browse.value = await browseFs(selectedRoot.value && '')
} catch { /* ignore */ }
}
async function gotoDir(p: string) {
try {
const r = await browseFs(p)
if (r.ok) { browse.value = r; manualPath.value = r.path }
else pickerMsg.value = r.error || '路径不可用'
} catch { pickerMsg.value = '浏览失败' }
}
async function chooseDir(p: string) {
p = (p || '').trim()
if (!p) { pickerMsg.value = '请先选择或输入目录路径'; return }
try {
const r = await openWorkspace(p, createIfMissing.value)
selectedRoot.value = r.current
recent.value = r.recent
pickerOpen.value = false
refreshWs()
} catch (e: any) {
pickerMsg.value = e?.response?.data?.detail || e?.message || String(e)
}
}
async function toggleShell() {
try {
await updateConfig({ agent: { allow_shell: allowShell.value } } as any)
} catch { allowShell.value = !allowShell.value } // 失败回滚
}
// ── 运行 ────────────────────────────────────────────────────────────────
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)
const init = await startAgent(task.value.trim(), selectedPoolId.value, selectedRoot.value)
if (init.workspace) selectedRoot.value = init.workspace
_watch = watchAgent(init.request_id)
_watch.subscribe({
onEvent: (ev) => {
@@ -186,9 +304,12 @@ async function run() {
onMounted(async () => {
refreshWs()
try {
const pool = await getPool()
const [pool, ws] = await Promise.all([getPool(), getAgentWorkspaces()])
poolEntries.value = pool.entries
selectedPoolId.value = pool.roles.agent || ''
selectedRoot.value = ws.current || ''
recent.value = ws.recent || []
refreshWs()
} catch { /* ignore */ }
})
</script>
@@ -218,6 +339,18 @@ onMounted(async () => {
border-bottom: 1px solid #f3f4f6;
}
.ws-head h3 { font-size: 14px; color: #1f2937; }
.ws-root {
padding: 6px 14px;
font-size: 11px;
color: #6b7280;
background: #f9fafb;
border-bottom: 1px solid #f3f4f6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
direction: rtl; /* 长路径优先显示末尾(项目名) */
text-align: left;
}
.ws-list { list-style: none; overflow-y: auto; flex: 1; }
.ws-list li {
display: flex;
@@ -262,10 +395,39 @@ onMounted(async () => {
/* 右侧主区 */
.agent-main { flex: 1; display: flex; flex-direction: column; padding: 20px 24px; min-width: 0; }
.agent-head { margin-bottom: 12px; }
.agent-head { margin-bottom: 10px; }
.agent-head h2 { font-size: 20px; font-weight: 700; color: #111827; }
.sub { font-size: 13px; color: #6b7280; margin-top: 2px; }
.ws-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 8px 12px;
}
.ws-current {
font-size: 13px;
font-weight: 600;
color: #1e40af;
max-width: 420px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.shell-toggle {
margin-left: auto;
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #6b7280;
cursor: pointer;
}
.task-bar { display: flex; gap: 10px; margin-bottom: 10px; align-items: stretch; }
.model-select {
border: 1px solid #d1d5db;
@@ -339,6 +501,19 @@ onMounted(async () => {
max-height: 200px;
overflow-y: auto;
}
.diff-old, .diff-new {
margin: 0 0 4px;
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
font-family: ui-monospace, Consolas, monospace;
white-space: pre-wrap;
word-break: break-all;
max-height: 140px;
overflow-y: auto;
}
.diff-old { background: #fef2f2; color: #b91c1c; }
.diff-new { background: #f0fdf4; color: #15803d; }
.ev-usage { text-align: right; color: #9ca3af; font-size: 11px; margin: 4px 0; }
.ev-final {
border: 1px solid #bfdbfe;
@@ -357,4 +532,87 @@ onMounted(async () => {
}
.ev-error { color: #dc2626; font-size: 13px; margin-top: 6px; }
.empty-hint { text-align: center; color: #9ca3af; font-size: 13px; margin-top: 40px; }
/* 目录选择器模态 */
.modal-mask {
position: fixed;
inset: 0;
background: #0006;
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
width: 620px;
max-width: 92vw;
max-height: 80vh;
display: flex;
flex-direction: column;
background: #fff;
border-radius: 12px;
padding: 18px;
box-shadow: 0 10px 40px #0003;
}
.modal-title { font-size: 16px; font-weight: 700; color: #111827; margin-bottom: 10px; }
.crumb {
font-size: 12px;
color: #6b7280;
background: #f9fafb;
border: 1px solid #f3f4f6;
border-radius: 6px;
padding: 6px 10px;
margin-bottom: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dir-list {
flex: 1;
min-height: 200px;
max-height: 320px;
overflow-y: auto;
border: 1px solid #f3f4f6;
border-radius: 6px;
}
.dir-item {
padding: 7px 12px;
font-size: 13px;
color: #374151;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dir-item:hover { background: #eff6ff; }
.dir-item.up { color: #2563eb; font-weight: 500; }
.dir-item.empty { color: #9ca3af; cursor: default; }
.dir-item.empty:hover { background: transparent; }
.path-row { display: flex; gap: 10px; align-items: center; margin-top: 10px; }
.path-row input[type="text"], .path-row input:not([type]) {
flex: 1;
border: 1px solid #d1d5db;
border-radius: 6px;
padding: 7px 10px;
font-size: 13px;
}
.create-toggle { display: flex; align-items: center; gap: 5px; font-size: 12px; color: #6b7280; cursor: pointer; white-space: nowrap; }
.recent-row { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 10px; }
.recent-label { font-size: 12px; color: #9ca3af; }
.recent-chip {
border: 1px solid #e5e7eb;
background: #f9fafb;
border-radius: 12px;
padding: 2px 10px;
font-size: 12px;
color: #374151;
cursor: pointer;
max-width: 180px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.recent-chip:hover { background: #eff6ff; border-color: #bfdbfe; }
.picker-msg { color: #d97706; font-size: 12px; margin-top: 8px; }
.modal-actions { display: flex; gap: 10px; margin-top: 14px; }
</style>