Files
projectAIpopular/webapp/src/views/AgentView.vue
T
tzt 1ae9ffb159 feat(v3): Web 界面视觉升级(全局设计令牌 + 深色侧边栏壳 + 页面细节打磨)
- style.css 重写为设计系统:色板/圆角/阴影/统一滚动条/焦点环/卡片质感,main.ts 引入
- App.vue:深色渐变侧边栏替换顶部条(品牌区/图标导航/激活高亮/网关状态脚注)
- 六个视图页面背景令牌化,卡片统一阴影;index.html 标题与语言修正
- 功能零改动,纯视觉层
2026-09-01 19:09:29 +08:00

684 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>
<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' }"
@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="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>
<option v-for="e in agentModels" :key="e.id" :value="e.id">{{ e.name }}{{ e.model }}</option>
</select>
<select v-model="selectedExecutorId" class="model-select executor-select"
title="两级模式:大模型拆解/审查,本地小模型执行工具轮">
<option value="">单模型模式规划者全程包办</option>
<option v-for="e in executorCandidates" :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="给智能体一个任务,例如:浏览这个项目,找到入口文件并在 README 里补充运行说明"
: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 === 'phase'" class="ev-phase" :class="'ph-' + ev.phase">
{{ phaseLabel(ev) }}
</div>
<div v-else-if="ev.type === 'message'" class="ev-msg">
<div class="ev-title">
{{ ev.role === 'planner' ? '🧠 规划者' : '🔧 执行者' }}{{ ev.handoff ? `(第 ${ev.handoff} 轮交接)` : '' }}
</div>
<pre class="ev-msgbody" :class="ev.role === 'planner' ? 'msg-planner' : 'msg-executor'">{{ ev.content }}</pre>
</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>
</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 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>
<script setup lang="ts">
import { ref, computed, nextTick, onMounted } from 'vue'
import {
startAgent, getAgentStatus, watchAgent,
listAgentWorkspace, readAgentFile, getPool,
browseFs, getAgentWorkspaces, openWorkspace, updateConfig,
} from '@/api'
import type { AgentEvent, PoolEntry, FsBrowse } 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 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 selectedExecutorId = ref('')
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 executorCandidates = computed(() =>
poolEntries.value
.filter(e => e.enabled && e.backend !== 'mock')
.sort((a, b) => (a.backend === 'llama_server' ? -1 : 0) - (b.backend === 'llama_server' ? -1 : 0)))
function phaseLabel(ev: AgentEvent) {
const m = ev.model ? ` · ${ev.model}` : ''
if (ev.phase === 'plan') return `🧠 规划者 · 拆解任务${m}`
if (ev.phase === 'execute') return `🔧 执行者 · 工具执行(第 ${ev.handoff} 轮交接)${m}`
return `🔍 规划者 · 审查裁决${m}`
}
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 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 }))
}
async function refreshWs() {
loadingWs.value = true
try {
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, 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, selectedRoot.value,
selectedExecutorId.value)
if (init.workspace) selectedRoot.value = init.workspace
_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, 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>
<style scoped>
.agent-view {
display: flex;
height: 100%;
background: var(--c-bg);
}
/* 左侧工作区 */
.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-root {
padding: 6px 14px;
font-size: 11px;
color: #6b7280;
background: var(--c-bg);
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;
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: var(--c-bg);
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: 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;
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-phase {
text-align: center;
font-size: 12px;
font-weight: 600;
border-radius: 12px;
padding: 4px 14px;
margin: 12px auto 8px;
width: fit-content;
max-width: 90%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ph-plan { color: #1e40af; background: #dbeafe; }
.ph-execute { color: #166534; background: #dcfce7; }
.ph-review { color: #7c2d12; background: #ffedd5; }
.ev-msg { margin-bottom: 8px; }
.ev-msgbody {
margin: 4px 0 0;
padding: 8px 12px;
border-radius: 8px;
font-size: 13px;
font-family: inherit;
white-space: pre-wrap;
word-break: break-word;
max-height: 260px;
overflow-y: auto;
}
.msg-planner { background: #eff6ff; color: #1e3a8a; border: 1px solid #bfdbfe; }
.msg-executor { background: #f0fdf4; color: #14532d; border: 1px solid #bbf7d0; }
.executor-select { flex: 1; max-width: 340px; }
.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;
}
.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;
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; }
/* 目录选择器模态 */
.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: var(--c-bg);
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: var(--c-bg);
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>