feat(v3): T19-T20 模型池设置 UI + 智能体页 + 指标分账卡

- 设置页「模型池」区块:三角色指派下拉、条目表格(档位徽标/单价/启用/测试/编辑/删除)、
  内联添加表单(模型列表探测 datalist,api_key 留空保留原值)
- 新增「🤖 智能体」页 /agent:任务输入 + 池模型选择、工具调用时间轴
  (round/tool_call/tool_result/usage/final)、左侧工作区文件浏览与预览
- 指标页「按模型分账」卡片(v2.by_model 表格)
- 修复:SSE final 后 EventSource 自动重连回放导致的事件重复渲染(终态即关闭+回放拦截)
This commit is contained in:
tzt
2026-09-01 08:51:52 +08:00
parent 374dc765c2
commit 501058243b
22 changed files with 903 additions and 24 deletions
+360
View File
@@ -0,0 +1,360 @@
<template>
<div class="agent-view">
<!-- 工作区文件 -->
<aside class="ws-panel">
<div class="ws-head">
<h3>📁 工作区</h3>
<button class="btn-refresh" :disabled="loadingWs" @click="refreshWs">
{{ loadingWs ? '' : '🔄' }}
</button>
</div>
<ul class="ws-list">
<li v-for="f in wsFiles" :key="f.name"
:class="{ dir: f.type === 'dir' }"
@click="f.type === 'file' && openFile(f.name)">
<span class="ws-name">{{ f.name }}</span>
<span v-if="f.type === 'file'" class="ws-size">{{ fmtSize(f.size) }}</span>
</li>
<li v-if="!wsFiles.length && !loadingWs" class="ws-empty">智能体写入的文件会出现在这里</li>
</ul>
<div v-if="filePreview" class="file-preview">
<div class="fp-head">
<span class="fp-name">{{ filePreview.path }}</span>
<button class="btn-refresh" @click="filePreview = null"></button>
</div>
<pre class="fp-body">{{ filePreview.content }}<template v-if="filePreview.truncated">已截断</template></pre>
</div>
</aside>
<!-- 任务与过程 -->
<main class="agent-main">
<header class="agent-head">
<h2>🤖 智能体</h2>
<p class="sub">像编程助手一样操作工作区文件列目录 / 读文件 / 写文件全过程实时可视化</p>
</header>
<div class="task-bar">
<select v-model="selectedPoolId" class="model-select">
<option value="">默认模型模型池 Agent 角色 / Architect 设置</option>
<option v-for="e in agentModels" :key="e.id" :value="e.id">{{ e.name }}{{ e.model }}</option>
</select>
</div>
<div class="task-bar">
<textarea v-model="task" class="task-input" rows="3"
placeholder="给智能体一个任务,例如:查看工作区里有哪些文件,并写一个 hello.py 输出九九乘法表"
:disabled="running" />
<button class="btn-run" :disabled="running || !task.trim()" @click="run">
{{ running ? '运行中' : ' 运行' }}
</button>
</div>
<div v-if="statusLine" :class="['status-line', statusClass]">{{ statusLine }}</div>
<!-- 过程时间轴 -->
<div class="timeline" ref="timelineEl">
<template v-for="(ev, i) in events" :key="i">
<div v-if="ev.type === 'round'" class="ev-round"> {{ ev.round }} </div>
<div v-else-if="ev.type === 'tool_call'" class="ev-card">
<div class="ev-title">🛠 调用工具 <b>{{ ev.name }}</b></div>
<pre class="ev-args">{{ pretty(ev.arguments) }}</pre>
</div>
<div v-else-if="ev.type === 'tool_result'" class="ev-card"
:class="ev.ok ? 'res-ok' : 'res-err'">
<div class="ev-title">{{ ev.ok ? '✅' : '❌' }} 结果{{ ev.name }}</div>
<pre class="ev-args">{{ short(ev.preview) }}</pre>
</div>
<div v-else-if="ev.type === 'usage'" class="ev-usage">
tokens{{ ev.prompt_tokens }} / {{ ev.completion_tokens }}
</div>
<div v-else-if="ev.type === 'final'" class="ev-final">
<div class="ev-title">
{{ ev.reason === 'answer' ? '🏁 最终答复' : '⚠️ 结束(' + reasonLabel(ev.reason) + '' }}
</div>
<pre v-if="finalResponse" class="ev-answer">{{ finalResponse }}</pre>
<div v-if="ev.error" class="ev-error">{{ ev.error }}</div>
</div>
</template>
<div v-if="running" class="ev-round"> 智能体正在工作</div>
<div v-if="!events.length && !running" class="empty-hint">
输入任务并运行每一步工具调用都会实时显示在这里
</div>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, onMounted } from 'vue'
import {
startAgent, getAgentStatus, watchAgent,
listAgentWorkspace, readAgentFile, getPool,
} from '@/api'
import type { AgentEvent, PoolEntry } from '@/api'
const task = ref('')
const running = ref(false)
const events = ref<AgentEvent[]>([])
const statusInfo = ref<{ state: string; error?: string | null } | null>(null)
const selectedPoolId = ref('')
const poolEntries = ref<PoolEntry[]>([])
const wsFiles = ref<{ name: string; type: string; size?: number }[]>([])
const loadingWs = ref(false)
const filePreview = ref<{ path: string; content: string; truncated: boolean } | null>(null)
const timelineEl = ref<HTMLElement | null>(null)
let _watch: ReturnType<typeof watchAgent> | null = null
const agentModels = computed(() => poolEntries.value.filter(e => e.enabled))
const finalResponse = computed(() => ((statusInfo.value as any)?.response || ''))
const statusLine = computed(() => {
if (running.value) return '● 运行中'
const s = statusInfo.value
if (!s) return ''
if (s.state === 'done') return '✅ 已完成'
if (s.state === 'failed') return '❌ 失败' + (s.error ? '' + s.error : '')
return ''
})
const statusClass = computed(() => {
if (running.value) return 'st-running'
return statusInfo.value?.state === 'done' ? 'st-ok' : 'st-err'
})
function reasonLabel(r?: string) {
return ({ max_rounds: '轮次达上限', token_cap: 'token 熔断', error: '出错' } as Record<string, string>)[r || ''] || r
}
function pretty(o: unknown) { return JSON.stringify(o, null, 2) ?? '' }
function short(s?: string) { return (s || '').length > 600 ? s!.slice(0, 600) + '…' : (s || '') }
function fmtSize(n?: number) {
if (n == null) return ''
if (n >= 1e6) return (n / 1e6).toFixed(1) + ' MB'
if (n >= 1e3) return (n / 1e3).toFixed(1) + ' KB'
return n + ' B'
}
function scrollToBottom() {
nextTick(() => timelineEl.value?.scrollTo({ top: timelineEl.value.scrollHeight }))
}
async function refreshWs() {
loadingWs.value = true
try {
const r = await listAgentWorkspace('')
wsFiles.value = r.entries || []
} catch { wsFiles.value = [] } finally { loadingWs.value = false }
}
async function openFile(name: string) {
try {
const r = await readAgentFile(name)
if (r.ok) filePreview.value = { path: name, content: r.content, truncated: r.truncated }
} catch { /* ignore */ }
}
async function run() {
if (!task.value.trim() || running.value) return
running.value = true
events.value = []
statusInfo.value = null
try {
const init = await startAgent(task.value.trim(), selectedPoolId.value)
_watch = watchAgent(init.request_id)
_watch.subscribe({
onEvent: (ev) => {
events.value.push(ev)
scrollToBottom()
},
onDone: async () => {
running.value = false
try { statusInfo.value = await getAgentStatus(init.request_id) } catch { /* ignore */ }
refreshWs()
},
})
} catch (e: any) {
running.value = false
statusInfo.value = { state: 'failed', error: e?.response?.data?.detail || e?.message || String(e) }
}
}
onMounted(async () => {
refreshWs()
try {
const pool = await getPool()
poolEntries.value = pool.entries
selectedPoolId.value = pool.roles.agent || ''
} catch { /* ignore */ }
})
</script>
<style scoped>
.agent-view {
display: flex;
height: 100%;
background: #f9fafb;
}
/* 左侧工作区 */
.ws-panel {
width: 260px;
flex-shrink: 0;
border-right: 1px solid #e5e7eb;
background: #fff;
display: flex;
flex-direction: column;
overflow: hidden;
}
.ws-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 14px;
border-bottom: 1px solid #f3f4f6;
}
.ws-head h3 { font-size: 14px; color: #1f2937; }
.ws-list { list-style: none; overflow-y: auto; flex: 1; }
.ws-list li {
display: flex;
justify-content: space-between;
padding: 6px 14px;
font-size: 13px;
cursor: pointer;
color: #374151;
}
.ws-list li:hover { background: #eff6ff; }
.ws-list li.dir { color: #92400e; font-weight: 500; cursor: default; }
.ws-empty { color: #9ca3af; cursor: default; font-size: 12px; }
.ws-empty:hover { background: transparent; }
.ws-size { color: #9ca3af; font-size: 11px; }
.file-preview {
border-top: 1px solid #e5e7eb;
max-height: 45%;
display: flex;
flex-direction: column;
}
.fp-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 14px;
background: #f9fafb;
font-size: 12px;
font-weight: 600;
color: #374151;
}
.fp-body {
margin: 0;
padding: 10px 14px;
overflow: auto;
font-size: 12px;
font-family: ui-monospace, Consolas, monospace;
white-space: pre-wrap;
word-break: break-all;
color: #111827;
}
/* 右侧主区 */
.agent-main { flex: 1; display: flex; flex-direction: column; padding: 20px 24px; min-width: 0; }
.agent-head { margin-bottom: 12px; }
.agent-head h2 { font-size: 20px; font-weight: 700; color: #111827; }
.sub { font-size: 13px; color: #6b7280; margin-top: 2px; }
.task-bar { display: flex; gap: 10px; margin-bottom: 10px; align-items: stretch; }
.model-select {
border: 1px solid #d1d5db;
border-radius: 6px;
padding: 7px 10px;
font-size: 13px;
background: #fff;
max-width: 420px;
}
.task-input {
flex: 1;
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 10px 12px;
font-size: 14px;
font-family: inherit;
resize: vertical;
outline: none;
}
.task-input:focus { border-color: #2563eb; box-shadow: 0 0 0 2px #2563eb26; }
.btn-run {
color: #fff;
background: #2563eb;
border: none;
border-radius: 8px;
padding: 0 22px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
align-self: stretch;
}
.btn-run:hover:not(:disabled) { background: #1d4ed8; }
.btn-run:disabled { background: #93c5fd; cursor: not-allowed; }
.status-line { font-size: 13px; font-weight: 600; margin-bottom: 10px; }
.st-running { color: #2563eb; }
.st-ok { color: #16a34a; }
.st-err { color: #dc2626; }
.timeline {
flex: 1;
overflow-y: auto;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 16px;
}
.ev-round {
text-align: center;
color: #9ca3af;
font-size: 12px;
margin: 10px 0;
}
.ev-card {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 8px;
background: #fafafa;
}
.ev-card.res-ok { border-left: 3px solid #16a34a; }
.ev-card.res-err { border-left: 3px solid #dc2626; }
.ev-title { font-size: 13px; color: #1f2937; margin-bottom: 6px; }
.ev-args {
margin: 0;
font-size: 12px;
font-family: ui-monospace, Consolas, monospace;
white-space: pre-wrap;
word-break: break-all;
color: #374151;
max-height: 200px;
overflow-y: auto;
}
.ev-usage { text-align: right; color: #9ca3af; font-size: 11px; margin: 4px 0; }
.ev-final {
border: 1px solid #bfdbfe;
background: #eff6ff;
border-radius: 8px;
padding: 12px 14px;
margin-top: 10px;
}
.ev-answer {
margin: 6px 0 0;
font-size: 14px;
white-space: pre-wrap;
word-break: break-word;
color: #111827;
font-family: inherit;
}
.ev-error { color: #dc2626; font-size: 13px; margin-top: 6px; }
.empty-hint { text-align: center; color: #9ca3af; font-size: 13px; margin-top: 40px; }
</style>