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
+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>