tori/web/src/components/ExecutionSection.vue
Fam Zheng 0a8eee0285 LLM call logging, plan persistence API, quote-to-feedback UX, requirement input improvements
- 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>
2026-03-02 09:16:51 +00:00

550 lines
14 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.

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