- Add llm_call_log table and per-call timing/token tracking in agent loop
- New GET /workflows/{id}/plan endpoint to restore plan from snapshots on page load
- New GET /workflows/{id}/llm-calls endpoint + WS LlmCallLog broadcast
- Parse Usage from LLM API response (prompt_tokens, completion_tokens)
- Detailed mode toggle in execution log showing LLM call cards with phase/tokens/latency
- Quote-to-feedback: hover quote buttons on plan steps and log entries, multi-quote chips in comment input
- Requirement input: larger textarea, multi-line display with pre-wrap and scroll
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
550 lines
14 KiB
Vue
550 lines
14 KiB
Vue
<script setup lang="ts">
|
||
import { ref, computed, watch, nextTick } from 'vue'
|
||
import type { ExecutionLogEntry, Comment, LlmCallLogEntry } from '../types'
|
||
|
||
const props = defineProps<{
|
||
entries: ExecutionLogEntry[]
|
||
comments: Comment[]
|
||
llmCalls: LlmCallLogEntry[]
|
||
requirement: string
|
||
createdAt: string
|
||
workflowStatus: string
|
||
workflowId: string
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
quote: [text: string]
|
||
}>()
|
||
|
||
function quoteEntry(e: Event, entry: ExecutionLogEntry) {
|
||
e.stopPropagation()
|
||
const label = toolLabel(entry.tool_name)
|
||
const preview = entry.tool_name === 'text_response'
|
||
? entry.output.slice(0, 80)
|
||
: entry.tool_input.slice(0, 80)
|
||
emit('quote', `[${label}] ${preview}`)
|
||
}
|
||
|
||
function quoteLlmCall(e: Event, lc: LlmCallLogEntry) {
|
||
e.stopPropagation()
|
||
const preview = lc.text_response
|
||
? lc.text_response.slice(0, 80)
|
||
: `${lc.phase} (${lc.messages_count} msgs)`
|
||
emit('quote', `[LLM] ${preview}`)
|
||
}
|
||
|
||
const scrollContainer = ref<HTMLElement | null>(null)
|
||
const userScrolledUp = ref(false)
|
||
const detailedMode = ref(false)
|
||
|
||
function onScroll() {
|
||
const el = scrollContainer.value
|
||
if (!el) return
|
||
userScrolledUp.value = el.scrollTop + el.clientHeight < el.scrollHeight - 80
|
||
}
|
||
|
||
const expandedEntries = ref<Set<string>>(new Set())
|
||
|
||
function toggleEntry(id: string) {
|
||
if (expandedEntries.value.has(id)) {
|
||
expandedEntries.value.delete(id)
|
||
} else {
|
||
expandedEntries.value.add(id)
|
||
}
|
||
}
|
||
|
||
function formatTime(t: string): string {
|
||
if (!t) return ''
|
||
try {
|
||
const d = new Date(t.includes('T') ? t : t + 'Z')
|
||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||
} catch {
|
||
return ''
|
||
}
|
||
}
|
||
|
||
function statusLabel(status: string) {
|
||
switch (status) {
|
||
case 'done': return '完成'
|
||
case 'running': return '执行中'
|
||
case 'failed': return '失败'
|
||
default: return '等待'
|
||
}
|
||
}
|
||
|
||
function toolLabel(name: string): string {
|
||
switch (name) {
|
||
case 'execute': return '$'
|
||
case 'read_file': return 'Read'
|
||
case 'write_file': return 'Write'
|
||
case 'list_files': return 'List'
|
||
case 'text_response': return 'AI'
|
||
default: return name
|
||
}
|
||
}
|
||
|
||
function formatTokens(n: number | null): string {
|
||
if (n === null || n === undefined) return '-'
|
||
if (n >= 1000) return (n / 1000).toFixed(1) + 'k'
|
||
return n.toString()
|
||
}
|
||
|
||
function formatLatency(ms: number): string {
|
||
if (ms >= 1000) return (ms / 1000).toFixed(1) + 's'
|
||
return ms + 'ms'
|
||
}
|
||
|
||
function parseToolCalls(json: string): { name: string; arguments_preview: string }[] {
|
||
try {
|
||
return JSON.parse(json)
|
||
} catch {
|
||
return []
|
||
}
|
||
}
|
||
|
||
interface LogItem {
|
||
id: string
|
||
type: 'requirement' | 'entry' | 'comment' | 'report' | 'llm-call'
|
||
time: string
|
||
entry?: ExecutionLogEntry
|
||
llmCall?: LlmCallLogEntry
|
||
text?: string
|
||
}
|
||
|
||
const logItems = computed(() => {
|
||
const items: LogItem[] = []
|
||
|
||
if (props.requirement) {
|
||
items.push({ id: 'req', type: 'requirement', text: props.requirement, time: props.createdAt || '0' })
|
||
}
|
||
|
||
for (const e of props.entries) {
|
||
items.push({ id: e.id, type: 'entry', entry: e, time: e.created_at || '' })
|
||
}
|
||
|
||
for (const c of props.comments) {
|
||
items.push({ id: c.id, type: 'comment', text: c.content, time: c.created_at })
|
||
}
|
||
|
||
if (detailedMode.value) {
|
||
for (const lc of props.llmCalls) {
|
||
items.push({ id: 'llm-' + lc.id, type: 'llm-call', llmCall: lc, time: lc.created_at || '' })
|
||
}
|
||
}
|
||
|
||
items.sort((a, b) => {
|
||
if (!a.time && !b.time) return 0
|
||
if (!a.time) return -1
|
||
if (!b.time) return 1
|
||
return a.time.localeCompare(b.time)
|
||
})
|
||
|
||
if (props.workflowId && (props.workflowStatus === 'done' || props.workflowStatus === 'failed')) {
|
||
const result: LogItem[] = []
|
||
let lastWasEntry = false
|
||
for (const item of items) {
|
||
if (item.type === 'entry' || item.type === 'llm-call') {
|
||
lastWasEntry = true
|
||
} else if (lastWasEntry && (item.type === 'comment' || item.type === 'requirement')) {
|
||
result.push({ id: `report-${result.length}`, type: 'report', time: '' })
|
||
lastWasEntry = false
|
||
} else {
|
||
lastWasEntry = false
|
||
}
|
||
result.push(item)
|
||
}
|
||
if (lastWasEntry) {
|
||
result.push({ id: 'report-final', type: 'report', time: '' })
|
||
}
|
||
return result
|
||
}
|
||
|
||
return items
|
||
})
|
||
|
||
watch(logItems, () => {
|
||
if (userScrolledUp.value) return
|
||
nextTick(() => {
|
||
const el = scrollContainer.value
|
||
if (el) el.scrollTop = el.scrollHeight
|
||
})
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="execution-section" ref="scrollContainer" @scroll="onScroll">
|
||
<div class="section-header">
|
||
<h2>日志</h2>
|
||
<label class="detail-toggle">
|
||
<input type="checkbox" v-model="detailedMode" />
|
||
<span>详细</span>
|
||
</label>
|
||
</div>
|
||
<div class="exec-list">
|
||
<template v-for="item in logItems" :key="item.id">
|
||
<!-- User message -->
|
||
<div v-if="item.type === 'requirement' || item.type === 'comment'" class="log-user">
|
||
<span class="log-time" v-if="item.time">{{ formatTime(item.time) }}</span>
|
||
<span class="log-tag">{{ item.type === 'requirement' ? '需求' : '反馈' }}</span>
|
||
<span class="log-text">{{ item.text }}</span>
|
||
</div>
|
||
|
||
<!-- Report link -->
|
||
<div v-else-if="item.type === 'report'" class="report-link-bar">
|
||
<a :href="'/report/' + workflowId" class="report-link" target="_blank">查看报告 →</a>
|
||
</div>
|
||
|
||
<!-- LLM Call card (detailed mode) -->
|
||
<div v-else-if="item.type === 'llm-call' && item.llmCall" class="llm-call-card" @click="toggleEntry(item.id)">
|
||
<div class="llm-call-header">
|
||
<span class="exec-time" v-if="item.time">{{ formatTime(item.time) }}</span>
|
||
<span class="llm-badge">LLM</span>
|
||
<span class="llm-phase">{{ item.llmCall.phase }}</span>
|
||
<span class="llm-meta">{{ item.llmCall.messages_count }} msgs</span>
|
||
<span class="llm-meta">{{ formatTokens(item.llmCall.prompt_tokens) }} → {{ formatTokens(item.llmCall.completion_tokens) }}</span>
|
||
<span class="llm-meta">{{ formatLatency(item.llmCall.latency_ms) }}</span>
|
||
<button class="quote-btn" @click="quoteLlmCall($event, item.llmCall!)" title="引用到反馈">
|
||
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M6.32 3.2A5.6 5.6 0 0 0 1.6 8.8v3.6h3.6a2.4 2.4 0 0 0 2.4-2.4 2.4 2.4 0 0 0-2.4-2.4H3.84A3.36 3.36 0 0 1 6.32 3.2ZM14.4 3.2A5.6 5.6 0 0 0 9.68 8.8v3.6h3.6a2.4 2.4 0 0 0 2.4-2.4 2.4 2.4 0 0 0-2.4-2.4h-1.36A3.36 3.36 0 0 1 14.4 3.2Z"/></svg>
|
||
</button>
|
||
</div>
|
||
<div v-if="item.llmCall.text_response" class="llm-text-response">
|
||
{{ item.llmCall.text_response.length > 200 ? item.llmCall.text_response.slice(0, 200) + '...' : item.llmCall.text_response }}
|
||
</div>
|
||
<div v-if="expandedEntries.has(item.id)" class="llm-call-detail">
|
||
<div v-for="(tc, i) in parseToolCalls(item.llmCall.tool_calls)" :key="i" class="llm-tc-item">
|
||
<span class="llm-tc-name">{{ tc.name }}</span>
|
||
<span class="llm-tc-args">{{ tc.arguments_preview }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Execution log entry -->
|
||
<div v-else-if="item.entry" class="exec-item" :class="item.entry.status">
|
||
<div class="exec-header" @click="toggleEntry(item.entry!.id)">
|
||
<span class="exec-time" v-if="item.time">{{ formatTime(item.time) }}</span>
|
||
<span v-if="item.entry.step_order > 0" class="step-badge">{{ item.entry.step_order }}</span>
|
||
<span class="exec-toggle">{{ expandedEntries.has(item.entry!.id) ? '▾' : '▸' }}</span>
|
||
<span class="exec-tool">{{ toolLabel(item.entry.tool_name) }}</span>
|
||
<span class="exec-desc">{{ item.entry.tool_name === 'text_response' ? item.entry.output.slice(0, 80) : item.entry.tool_input.slice(0, 80) }}</span>
|
||
<button class="quote-btn" @click="quoteEntry($event, item.entry!)" title="引用到反馈">
|
||
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M6.32 3.2A5.6 5.6 0 0 0 1.6 8.8v3.6h3.6a2.4 2.4 0 0 0 2.4-2.4 2.4 2.4 0 0 0-2.4-2.4H3.84A3.36 3.36 0 0 1 6.32 3.2ZM14.4 3.2A5.6 5.6 0 0 0 9.68 8.8v3.6h3.6a2.4 2.4 0 0 0 2.4-2.4 2.4 2.4 0 0 0-2.4-2.4h-1.36A3.36 3.36 0 0 1 14.4 3.2Z"/></svg>
|
||
</button>
|
||
<span class="exec-status" :class="item.entry.status">{{ statusLabel(item.entry.status) }}</span>
|
||
</div>
|
||
<div v-if="expandedEntries.has(item.entry!.id)" class="exec-detail">
|
||
<div v-if="item.entry.tool_input && item.entry.tool_name !== 'text_response'" class="exec-command">
|
||
<code>{{ item.entry.tool_input }}</code>
|
||
</div>
|
||
<pre v-if="item.entry.output">{{ item.entry.output }}</pre>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<div v-if="!entries.length && !requirement" class="empty-state">
|
||
提交需求后,日志将显示在这里
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.execution-section {
|
||
flex: 1;
|
||
background: var(--bg-card);
|
||
border-radius: 8px;
|
||
padding: 12px 16px;
|
||
border: 1px solid var(--border);
|
||
overflow-y: auto;
|
||
min-width: 0;
|
||
}
|
||
|
||
.section-header {
|
||
margin-bottom: 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.section-header h2 {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--text-secondary);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.detail-toggle {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 11px;
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
|
||
.detail-toggle input {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.exec-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.log-user {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 8px;
|
||
padding: 8px 10px;
|
||
border-radius: 6px;
|
||
background: var(--bg-secondary);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.log-time, .exec-time {
|
||
font-size: 10px;
|
||
color: var(--text-secondary);
|
||
flex-shrink: 0;
|
||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.log-tag {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: var(--accent);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.log-text {
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.exec-item {
|
||
border-radius: 6px;
|
||
overflow: hidden;
|
||
background: var(--bg-secondary);
|
||
}
|
||
|
||
.exec-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 10px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
user-select: none;
|
||
}
|
||
|
||
.exec-header:hover {
|
||
background: var(--bg-tertiary);
|
||
}
|
||
|
||
.step-badge {
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
min-width: 18px;
|
||
height: 18px;
|
||
line-height: 18px;
|
||
text-align: center;
|
||
border-radius: 9px;
|
||
background: var(--accent);
|
||
color: var(--bg-primary);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.exec-toggle {
|
||
color: var(--text-secondary);
|
||
font-size: 11px;
|
||
width: 14px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.exec-tool {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: var(--text-secondary);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.exec-desc {
|
||
color: var(--text-primary);
|
||
font-weight: 500;
|
||
flex: 1;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.quote-btn {
|
||
flex-shrink: 0;
|
||
width: 22px;
|
||
height: 22px;
|
||
padding: 0;
|
||
border: none;
|
||
background: none;
|
||
color: var(--text-secondary);
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
opacity: 0;
|
||
transition: opacity 0.15s;
|
||
}
|
||
|
||
.exec-header:hover .quote-btn,
|
||
.llm-call-header:hover .quote-btn {
|
||
opacity: 0.5;
|
||
}
|
||
|
||
.quote-btn:hover {
|
||
opacity: 1 !important;
|
||
background: rgba(79, 195, 247, 0.15);
|
||
color: var(--accent);
|
||
}
|
||
|
||
.exec-status {
|
||
font-size: 11px;
|
||
padding: 2px 8px;
|
||
border-radius: 10px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.exec-status.done { background: var(--success); color: #fff; }
|
||
.exec-status.running { background: var(--accent); color: #fff; }
|
||
.exec-status.failed { background: var(--error); color: #fff; }
|
||
|
||
.exec-detail {
|
||
border-top: 1px solid var(--border);
|
||
background: var(--bg-tertiary);
|
||
}
|
||
|
||
.exec-command {
|
||
padding: 6px 12px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.exec-command code {
|
||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||
font-size: 12px;
|
||
color: var(--accent);
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.exec-detail pre {
|
||
padding: 8px 12px;
|
||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
color: var(--text-primary);
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
margin: 0;
|
||
}
|
||
|
||
.empty-state {
|
||
color: var(--text-secondary);
|
||
font-size: 13px;
|
||
text-align: center;
|
||
padding: 24px;
|
||
}
|
||
|
||
.report-link-bar {
|
||
margin: 4px 0;
|
||
padding: 10px 12px;
|
||
background: var(--bg-secondary);
|
||
border-radius: 6px;
|
||
text-align: center;
|
||
}
|
||
|
||
.report-link {
|
||
color: var(--accent);
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
text-decoration: none;
|
||
}
|
||
|
||
.report-link:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
/* LLM Call Card */
|
||
.llm-call-card {
|
||
border-radius: 6px;
|
||
overflow: hidden;
|
||
background: var(--bg-secondary);
|
||
border-left: 3px solid var(--accent);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.llm-call-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px 10px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.llm-badge {
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
padding: 1px 6px;
|
||
border-radius: 8px;
|
||
background: var(--accent);
|
||
color: var(--bg-primary);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.llm-phase {
|
||
font-weight: 600;
|
||
color: var(--text-secondary);
|
||
font-size: 11px;
|
||
}
|
||
|
||
.llm-meta {
|
||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||
font-size: 10px;
|
||
color: var(--text-secondary);
|
||
opacity: 0.8;
|
||
}
|
||
|
||
.llm-text-response {
|
||
padding: 4px 10px 6px;
|
||
font-size: 12px;
|
||
color: var(--text-primary);
|
||
opacity: 0.85;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.llm-call-detail {
|
||
border-top: 1px solid var(--border);
|
||
padding: 6px 10px;
|
||
background: var(--bg-tertiary);
|
||
}
|
||
|
||
.llm-tc-item {
|
||
display: flex;
|
||
gap: 8px;
|
||
padding: 2px 0;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.llm-tc-name {
|
||
font-weight: 600;
|
||
color: var(--accent);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.llm-tc-args {
|
||
color: var(--text-secondary);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||
font-size: 10px;
|
||
}
|
||
</style>
|