feat(frontend): 完成Agent运行与Trace页面
This commit is contained in:
@@ -0,0 +1,118 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useAgentStore } from '@/stores/agent'
|
||||||
|
import { useProviderStore } from '@/stores/provider'
|
||||||
|
import { useSkillStore } from '@/stores/skill'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const agentStore = useAgentStore()
|
||||||
|
const providerStore = useProviderStore()
|
||||||
|
const skillStore = useSkillStore()
|
||||||
|
const pageError = ref('')
|
||||||
|
const form = reactive({
|
||||||
|
input: '', provider_id: 'mock', model: 'mock-1', skill_id: '', max_steps: 10,
|
||||||
|
tool_timeout_seconds: 30, run_timeout_seconds: 300, token_budget: 8000,
|
||||||
|
allow_network: false, max_concurrent_tools: 1, allowed_tools: [] as string[],
|
||||||
|
})
|
||||||
|
|
||||||
|
const models = computed(() => providerStore.modelsByProvider[form.provider_id] ?? [])
|
||||||
|
const isNewRun = computed(() => !route.params.runId)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await Promise.all([providerStore.loadProviders(), skillStore.loadSkills(), agentStore.loadTools()])
|
||||||
|
await providerStore.loadModels(form.provider_id)
|
||||||
|
} catch (error) { pageError.value = error instanceof Error ? error.message : 'Agent 配置加载失败' }
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => route.params.runId, async (runId) => {
|
||||||
|
if (typeof runId !== 'string') return
|
||||||
|
try { await agentStore.loadRun(runId) } catch (error) { pageError.value = error instanceof Error ? error.message : 'Run 加载失败' }
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
watch(() => form.provider_id, async (providerId) => {
|
||||||
|
try { await providerStore.loadModels(providerId); form.model = models.value[0]?.model_id ?? '' } catch { /* page keeps current selection */ }
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleTool(name: string) {
|
||||||
|
const index = form.allowed_tools.indexOf(name)
|
||||||
|
if (index >= 0) form.allowed_tools.splice(index, 1)
|
||||||
|
else form.allowed_tools.push(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRun() {
|
||||||
|
pageError.value = ''
|
||||||
|
try {
|
||||||
|
const run = await agentStore.createRun({
|
||||||
|
input: form.input, provider_id: form.provider_id, model: form.model,
|
||||||
|
skill_id: form.skill_id || undefined, allowed_tools: form.allowed_tools,
|
||||||
|
max_steps: form.max_steps, tool_timeout_seconds: form.tool_timeout_seconds,
|
||||||
|
run_timeout_seconds: form.run_timeout_seconds, token_budget: form.token_budget,
|
||||||
|
allow_network: form.allow_network, max_concurrent_tools: form.max_concurrent_tools,
|
||||||
|
})
|
||||||
|
await router.replace({ name: 'agent', params: { runId: run.run_id } })
|
||||||
|
} catch (error) { pageError.value = error instanceof Error ? error.message : 'Run 创建失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventText(data: Record<string, unknown>) {
|
||||||
|
return String(data.text ?? data.message ?? data.code ?? '')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="feature-page agent-page">
|
||||||
|
<header class="feature-header"><div><h1>{{ isNewRun ? '创建 Agent Run' : 'Agent Trace' }}</h1><p>配置执行边界,并实时查看模型、工具和权限事件。</p></div>
|
||||||
|
<button v-if="!isNewRun" class="button-secondary" @click="router.push({ name: 'agent' })">新建 Run</button></header>
|
||||||
|
<div v-if="pageError || agentStore.error" class="error-banner">{{ pageError || agentStore.error }}</div>
|
||||||
|
<form v-if="isNewRun" class="panel run-form" @submit.prevent="createRun">
|
||||||
|
<div class="field"><label>任务</label><textarea v-model="form.input" class="textarea" required placeholder="描述希望 Agent 完成的任务" /></div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field"><label>Provider</label><select v-model="form.provider_id" class="select"><option v-for="p in providerStore.enabledProviders" :key="p.provider_id" :value="p.provider_id">{{ p.name }}</option></select></div>
|
||||||
|
<div class="field"><label>Model</label><select v-model="form.model" class="select"><option v-for="m in models" :key="m.model_id" :value="m.model_id">{{ m.name }}</option></select></div>
|
||||||
|
<div class="field"><label>Skill</label><select v-model="form.skill_id" class="select"><option value="">不使用 Skill</option><option v-for="s in skillStore.readySkills" :key="s.skill_id" :value="s.skill_id">{{ s.name }}</option></select></div>
|
||||||
|
<div class="field"><label>最大步骤</label><input v-model.number="form.max_steps" class="input" type="number" min="1" max="100" /></div>
|
||||||
|
<div class="field"><label>Tool Timeout(秒)</label><input v-model.number="form.tool_timeout_seconds" class="input" type="number" min="1" /></div>
|
||||||
|
<div class="field"><label>Run Timeout(秒)</label><input v-model.number="form.run_timeout_seconds" class="input" type="number" min="1" /></div>
|
||||||
|
<div class="field"><label>Token Budget</label><input v-model.number="form.token_budget" class="input" type="number" min="1" /></div>
|
||||||
|
<div class="field"><label>最大并发工具</label><input v-model.number="form.max_concurrent_tools" class="input" type="number" min="1" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>允许的 Tool</label><div class="tool-grid"><label v-for="tool in agentStore.tools" :key="tool.name" class="tool-option"><input type="checkbox" :checked="form.allowed_tools.includes(tool.name)" @change="toggleTool(tool.name)" /><span><strong>{{ tool.name }}</strong><small>{{ tool.description }}</small></span></label></div></div>
|
||||||
|
<label class="network"><input v-model="form.allow_network" type="checkbox" /> 允许本次 Run 调用网络工具</label>
|
||||||
|
<div class="inline-actions"><button class="button-primary" :disabled="agentStore.isCreating || !form.input.trim()">{{ agentStore.isCreating ? '创建中…' : '创建并运行' }}</button></div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div v-else class="trace-layout">
|
||||||
|
<div class="panel run-summary"><div><span class="badge info">{{ agentStore.activeRun?.status }}</span><h2>{{ agentStore.activeRunId }}</h2></div><div class="inline-actions"><span>步骤 {{ agentStore.currentStep }} / {{ agentStore.activeRun?.max_steps }}</span><button v-if="agentStore.isRunning" class="button-danger" @click="agentStore.cancelRun(agentStore.activeRunId!)">取消运行</button></div></div>
|
||||||
|
<div class="timeline">
|
||||||
|
<article v-for="event in agentStore.events" :key="event.sequence" class="event-card item-card">
|
||||||
|
<div class="event-head"><span class="badge" :class="{ success: event.event === 'RunCompleted', error: event.event === 'RunFailed', warning: event.event === 'PermissionRequired' }">{{ event.event }}</span><span>#{{ event.sequence }} · {{ new Date(event.timestamp).toLocaleTimeString() }}</span></div>
|
||||||
|
<p v-if="eventText(event.data)" class="event-text">{{ eventText(event.data) }}</p>
|
||||||
|
<pre v-if="['ToolCall', 'ToolResult', 'Citation'].includes(event.event)">{{ JSON.stringify(event.data, null, 2) }}</pre>
|
||||||
|
</article>
|
||||||
|
<div v-if="!agentStore.events.length" class="empty-state"><div><strong>等待 Trace</strong><p>事件连接建立后将在这里实时显示。</p></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="agentStore.permissionRequest" class="modal-backdrop">
|
||||||
|
<div class="modal"><span class="badge warning">权限确认</span><h2>{{ agentStore.permissionRequest.tool_name }}</h2><p>{{ agentStore.permissionRequest.impact }}</p><p class="subtle">权限:{{ agentStore.permissionRequest.permission }}</p><pre>{{ JSON.stringify(agentStore.permissionRequest.parameters, null, 2) }}</pre><div class="inline-actions permission-actions"><button class="button-primary" @click="agentStore.respondPermission('allow', 'once')">仅本次允许</button><button class="button-secondary" @click="agentStore.respondPermission('allow', 'session')">本次会话允许</button><button class="button-danger" @click="agentStore.respondPermission('deny')">拒绝</button></div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.run-form { display: grid; gap: var(--space-xl); max-width: 980px; }
|
||||||
|
.tool-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: var(--space-sm); }
|
||||||
|
.tool-option { display: flex; gap: var(--space-sm); padding: var(--space-sm); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); }
|
||||||
|
.tool-option small { display: block; color: var(--color-text-secondary); }
|
||||||
|
.network { display: flex; gap: var(--space-sm); }
|
||||||
|
.trace-layout { display: grid; gap: var(--space-lg); }
|
||||||
|
.run-summary, .event-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); }
|
||||||
|
.run-summary h2 { margin-top: var(--space-sm); font-family: var(--font-ui-mono); font-size: var(--font-size-lg); }
|
||||||
|
.timeline { display: grid; gap: var(--space-md); }
|
||||||
|
.event-head { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
|
||||||
|
.event-text { margin-top: var(--space-md); white-space: pre-wrap; line-height: var(--line-height-relaxed); }
|
||||||
|
pre { margin-top: var(--space-md); max-height: 260px; overflow: auto; padding: var(--space-md); border-radius: var(--radius-md); background: var(--color-background-secondary); font-family: var(--font-ui-mono); font-size: var(--font-size-xs); white-space: pre-wrap; user-select: text; }
|
||||||
|
.permission-actions { margin-top: var(--space-lg); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAgentStore } from '@/stores/agent'
|
||||||
|
|
||||||
|
const agentStore = useAgentStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try { await agentStore.loadRuns() } catch (reason) { error.value = reason instanceof Error ? reason.message : 'Run 列表加载失败' }
|
||||||
|
})
|
||||||
|
|
||||||
|
function selectRun(runId: string) { void router.push({ name: 'agent', params: { runId } }) }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="sidebar-panel">
|
||||||
|
<button class="button-primary new-button" @click="router.push({ name: 'agent' })">+ 新建 Run</button>
|
||||||
|
<p v-if="error" class="subtle error-text">{{ error }}</p>
|
||||||
|
<div class="sidebar-list">
|
||||||
|
<button v-for="run in agentStore.sortedRuns" :key="run.run_id" class="sidebar-list-item run-item"
|
||||||
|
:class="{ active: agentStore.activeRunId === run.run_id }" @click="selectRun(run.run_id)">
|
||||||
|
<span class="badge" :class="{ success: run.status === 'completed', error: run.status === 'failed', warning: run.status === 'waiting_permission' }">{{ run.status }}</span>
|
||||||
|
<strong>{{ run.run_id.slice(0, 12) }}</strong><small>{{ run.started_at ? new Date(run.started_at).toLocaleString() : '等待开始' }}</small>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.new-button { width: 100%; margin-bottom: var(--space-md); }
|
||||||
|
.run-item { display: grid; gap: 3px; width: 100%; text-align: left; }
|
||||||
|
.run-item .badge { justify-self: start; }
|
||||||
|
.run-item small { color: var(--color-text-tertiary); }
|
||||||
|
.error-text { margin-bottom: var(--space-sm); color: var(--color-error); }
|
||||||
|
</style>
|
||||||
@@ -3,6 +3,7 @@ import { ref, computed } from 'vue'
|
|||||||
import type { AgentRun, AgentEvent, ToolDefinition, PermissionRequest, ToolCall } from '@/contracts'
|
import type { AgentRun, AgentEvent, ToolDefinition, PermissionRequest, ToolCall } from '@/contracts'
|
||||||
import { mockAgentRuns, mockAgentEvents, mockTools, mockPermissionRequest } from '@/services/agentService'
|
import { mockAgentRuns, mockAgentEvents, mockTools, mockPermissionRequest } from '@/services/agentService'
|
||||||
import * as agentService from '@/services/agentService'
|
import * as agentService from '@/services/agentService'
|
||||||
|
import type { SseClient } from '@/services/sseClient'
|
||||||
|
|
||||||
export const useAgentStore = defineStore('agent', () => {
|
export const useAgentStore = defineStore('agent', () => {
|
||||||
const runs = ref<AgentRun[]>(mockAgentRuns)
|
const runs = ref<AgentRun[]>(mockAgentRuns)
|
||||||
@@ -13,6 +14,8 @@ export const useAgentStore = defineStore('agent', () => {
|
|||||||
const isRunning = ref(false)
|
const isRunning = ref(false)
|
||||||
const permissionRequest = ref<PermissionRequest | null>(null)
|
const permissionRequest = ref<PermissionRequest | null>(null)
|
||||||
const toolCalls = ref<ToolCall[]>([])
|
const toolCalls = ref<ToolCall[]>([])
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
let eventStream: SseClient | null = null
|
||||||
|
|
||||||
const activeRun = computed(() =>
|
const activeRun = computed(() =>
|
||||||
runs.value.find((r) => r.run_id === activeRunId.value) || null
|
runs.value.find((r) => r.run_id === activeRunId.value) || null
|
||||||
@@ -37,79 +40,87 @@ export const useAgentStore = defineStore('agent', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadRun(runId: string) {
|
async function loadRun(runId: string) {
|
||||||
|
eventStream?.cancel()
|
||||||
activeRunId.value = runId
|
activeRunId.value = runId
|
||||||
events.value = mockAgentEvents.filter((e) => e.run_id === runId)
|
const run = await agentService.getAgentRun(runId)
|
||||||
|
const existingIndex = runs.value.findIndex((item) => item.run_id === runId)
|
||||||
|
if (existingIndex >= 0) runs.value[existingIndex] = run
|
||||||
|
else runs.value.unshift(run)
|
||||||
|
events.value = []
|
||||||
toolCalls.value = []
|
toolCalls.value = []
|
||||||
for (const evt of events.value) {
|
subscribe(runId)
|
||||||
if (evt.event === 'ToolCall') {
|
}
|
||||||
const data = evt.data as any
|
|
||||||
toolCalls.value.push({
|
function processEvent(event: AgentEvent) {
|
||||||
tool_call_id: data.tool_call_id,
|
if (events.value.some((item) => item.run_id === event.run_id && item.sequence === event.sequence)) return
|
||||||
name: data.name,
|
events.value.push(event)
|
||||||
parameters: data.parameters,
|
events.value.sort((a, b) => a.sequence - b.sequence)
|
||||||
status: data.status || 'completed',
|
const data = event.data
|
||||||
started_at: evt.timestamp,
|
if (event.event === 'ToolCall') {
|
||||||
})
|
toolCalls.value.push({
|
||||||
} else if (evt.event === 'ToolResult') {
|
tool_call_id: String(data.tool_call_id ?? ''),
|
||||||
const data = evt.data as any
|
name: String(data.name ?? 'unknown'),
|
||||||
const tc = toolCalls.value.find((t) => t.tool_call_id === data.tool_call_id)
|
parameters: (data.arguments ?? {}) as Record<string, unknown>,
|
||||||
if (tc) {
|
status: 'running',
|
||||||
tc.status = data.status
|
started_at: event.timestamp,
|
||||||
tc.result = data.result
|
})
|
||||||
tc.completed_at = evt.timestamp
|
} else if (event.event === 'ToolResult') {
|
||||||
}
|
const toolCall = toolCalls.value.find((item) => item.tool_call_id === data.tool_call_id)
|
||||||
|
if (toolCall) {
|
||||||
|
toolCall.status = data.success ? 'completed' : 'error'
|
||||||
|
toolCall.result = data.output == null ? undefined : JSON.stringify(data.output)
|
||||||
|
toolCall.error_code = data.error_code == null ? undefined : String(data.error_code)
|
||||||
|
toolCall.error_message = data.error_message == null ? undefined : String(data.error_message)
|
||||||
|
toolCall.completed_at = event.timestamp
|
||||||
}
|
}
|
||||||
|
} else if (event.event === 'PermissionRequired') {
|
||||||
|
const call = (data.tool_call ?? {}) as Record<string, unknown>
|
||||||
|
permissionRequest.value = {
|
||||||
|
request_id: String(data.request_id ?? ''),
|
||||||
|
run_id: event.run_id,
|
||||||
|
tool_name: String(call.name ?? 'unknown'),
|
||||||
|
permission: String(data.permission ?? ''),
|
||||||
|
parameters: (call.arguments ?? {}) as Record<string, unknown>,
|
||||||
|
impact: '该工具需要获得权限后才能继续执行。',
|
||||||
|
}
|
||||||
|
} else if (['RunCompleted', 'RunFailed', 'RunCancelled'].includes(event.event)) {
|
||||||
|
isRunning.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function subscribe(runId: string) {
|
||||||
|
eventStream?.cancel()
|
||||||
|
isRunning.value = true
|
||||||
|
error.value = null
|
||||||
|
eventStream = agentService.streamAgentEvents(runId, {
|
||||||
|
onEvent: processEvent,
|
||||||
|
onError(streamError) { error.value = streamError.message; isRunning.value = false },
|
||||||
|
onDone() { isRunning.value = false; eventStream = null },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function createRun(request: agentService.CreateAgentRunRequest) {
|
async function createRun(request: agentService.CreateAgentRunRequest) {
|
||||||
isCreating.value = true
|
isCreating.value = true
|
||||||
try {
|
try {
|
||||||
const run = await agentService.createAgentRun(request)
|
const run = await agentService.createAgentRun(request)
|
||||||
runs.value.unshift(run)
|
runs.value.unshift(run)
|
||||||
activeRunId.value = run.run_id
|
activeRunId.value = run.run_id
|
||||||
events.value = [{
|
events.value = []
|
||||||
event: 'RunStarted',
|
toolCalls.value = []
|
||||||
sequence: 1,
|
subscribe(run.run_id)
|
||||||
run_id: run.run_id,
|
|
||||||
data: { input: request.input },
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
}]
|
|
||||||
isRunning.value = true
|
|
||||||
// Mock events streaming
|
|
||||||
simulateRun(run.run_id)
|
|
||||||
return run
|
return run
|
||||||
} finally {
|
} finally {
|
||||||
isCreating.value = false
|
isCreating.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function simulateRun(runId: string) {
|
|
||||||
const runEvents: AgentEvent[] = [
|
|
||||||
{ event: 'ThinkingDelta', sequence: 2, run_id: runId, data: { text: '我需要先搜索相关笔记...' }, timestamp: new Date().toISOString() },
|
|
||||||
{ event: 'ToolCall', sequence: 3, run_id: runId, data: { tool_call_id: 'tc-mock-1', name: 'notes.search', parameters: { query: '红黑树', limit: 5 }, status: 'running' }, timestamp: new Date().toISOString() },
|
|
||||||
{ event: 'ToolResult', sequence: 4, run_id: runId, data: { tool_call_id: 'tc-mock-1', name: 'notes.search', status: 'completed', result: '找到 5 条相关结果' }, timestamp: new Date().toISOString() },
|
|
||||||
{ event: 'TextDelta', sequence: 5, run_id: runId, data: { text: '根据你的笔记,以下是...' }, timestamp: new Date().toISOString() },
|
|
||||||
{ event: 'RunCompleted', sequence: 6, run_id: runId, data: { message: 'Task completed successfully' }, timestamp: new Date().toISOString() },
|
|
||||||
]
|
|
||||||
let idx = 0
|
|
||||||
const push = () => {
|
|
||||||
if (idx >= runEvents.length) {
|
|
||||||
isRunning.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
events.value.push(runEvents[idx])
|
|
||||||
idx++
|
|
||||||
setTimeout(push, 800)
|
|
||||||
}
|
|
||||||
setTimeout(push, 500)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cancelRun(runId: string) {
|
async function cancelRun(runId: string) {
|
||||||
await agentService.cancelAgentRun(runId)
|
await agentService.cancelAgentRun(runId)
|
||||||
const run = runs.value.find((r) => r.run_id === runId)
|
const run = runs.value.find((r) => r.run_id === runId)
|
||||||
if (run) run.status = 'cancelled'
|
if (run) run.status = 'cancelled'
|
||||||
isRunning.value = false
|
isRunning.value = false
|
||||||
|
eventStream?.cancel()
|
||||||
|
eventStream = null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function respondPermission(decision: 'allow' | 'deny', scope: 'once' | 'session' = 'once') {
|
async function respondPermission(decision: 'allow' | 'deny', scope: 'once' | 'session' = 'once') {
|
||||||
@@ -134,6 +145,7 @@ export const useAgentStore = defineStore('agent', () => {
|
|||||||
isRunning,
|
isRunning,
|
||||||
permissionRequest,
|
permissionRequest,
|
||||||
toolCalls,
|
toolCalls,
|
||||||
|
error,
|
||||||
currentStep,
|
currentStep,
|
||||||
loadTools,
|
loadTools,
|
||||||
loadRuns,
|
loadRuns,
|
||||||
|
|||||||
Reference in New Issue
Block a user