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>
This commit is contained in:
2026-03-02 09:16:51 +00:00
parent 46424cfbc4
commit 0a8eee0285
14 changed files with 601 additions and 26 deletions

View File

@@ -1,4 +1,4 @@
import type { Project, Workflow, ExecutionLogEntry, Comment, Timer, KbArticle, KbArticleSummary } from './types'
import type { Project, Workflow, ExecutionLogEntry, Comment, Timer, KbArticle, KbArticleSummary, PlanStepInfo, LlmCallLogEntry } from './types'
const BASE = '/api'
@@ -58,6 +58,12 @@ export const api = {
getReport: (workflowId: string) =>
request<{ report: string }>(`/workflows/${workflowId}/report`),
listPlanSteps: (workflowId: string) =>
request<PlanStepInfo[]>(`/workflows/${workflowId}/plan`),
listLlmCalls: (workflowId: string) =>
request<LlmCallLogEntry[]>(`/workflows/${workflowId}/llm-calls`),
listTimers: (projectId: string) =>
request<Timer[]>(`/projects/${projectId}/timers`),

View File

@@ -1,21 +1,35 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, nextTick } from 'vue'
const props = defineProps<{
disabled?: boolean
quotes: string[]
}>()
const emit = defineEmits<{
submit: [text: string]
removeQuote: [index: number]
}>()
const input = ref('')
const textareaRef = ref<HTMLTextAreaElement | null>(null)
function submit() {
if (props.disabled) return
const text = input.value.trim()
if (!text) return
emit('submit', text)
if (!text && !props.quotes.length) return
// Build final text: quotes as block references, then user text
let final = ''
for (const q of props.quotes) {
final += `> ${q}\n`
}
if (props.quotes.length && text) {
final += '\n'
}
final += text
emit('submit', final.trim())
input.value = ''
}
@@ -25,18 +39,33 @@ function onKeydown(e: KeyboardEvent) {
submit()
}
}
function focusInput() {
nextTick(() => {
textareaRef.value?.focus()
})
}
defineExpose({ focusInput })
</script>
<template>
<div class="comment-section">
<div v-if="quotes.length" class="quotes-bar">
<div v-for="(q, i) in quotes" :key="i" class="quote-chip">
<span class="quote-text">{{ q.length > 60 ? q.slice(0, 60) + '...' : q }}</span>
<button class="quote-remove" @click="emit('removeQuote', i)" title="移除引用">&times;</button>
</div>
</div>
<div class="comment-input">
<textarea
ref="textareaRef"
v-model="input"
placeholder="输入反馈或调整指令... (Ctrl+Enter 发送)"
:placeholder="quotes.length ? '添加你的评论...' : '输入反馈或调整指令... (Ctrl+Enter 发送)'"
rows="3"
@keydown="onKeydown"
/>
<button class="btn-send" :disabled="disabled || !input.trim()" @click="submit">发送</button>
<button class="btn-send" :disabled="disabled || (!input.trim() && !quotes.length)" @click="submit">发送</button>
</div>
</div>
</template>
@@ -49,6 +78,53 @@ function onKeydown(e: KeyboardEvent) {
overflow: hidden;
}
.quotes-bar {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 8px 12px 0;
}
.quote-chip {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
padding: 3px 8px;
background: rgba(79, 195, 247, 0.12);
border: 1px solid rgba(79, 195, 247, 0.3);
border-radius: 4px;
font-size: 11px;
line-height: 1.3;
}
.quote-text {
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.quote-remove {
flex-shrink: 0;
width: 16px;
height: 16px;
padding: 0;
border: none;
background: none;
color: var(--text-secondary);
font-size: 14px;
line-height: 16px;
cursor: pointer;
border-radius: 3px;
opacity: 0.6;
}
.quote-remove:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.1);
}
.comment-input {
display: flex;
gap: 8px;

View File

@@ -1,18 +1,41 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import type { ExecutionLogEntry, Comment } from '../types'
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
@@ -60,11 +83,31 @@ function toolLabel(name: string): string {
}
}
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'
type: 'requirement' | 'entry' | 'comment' | 'report' | 'llm-call'
time: string
entry?: ExecutionLogEntry
llmCall?: LlmCallLogEntry
text?: string
}
@@ -83,6 +126,12 @@ const logItems = computed(() => {
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
@@ -94,7 +143,7 @@ const logItems = computed(() => {
const result: LogItem[] = []
let lastWasEntry = false
for (const item of items) {
if (item.type === 'entry') {
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: '' })
@@ -126,6 +175,10 @@ watch(logItems, () => {
<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">
@@ -141,6 +194,30 @@ watch(logItems, () => {
<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)">
@@ -149,6 +226,9 @@ watch(logItems, () => {
<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">
@@ -179,6 +259,9 @@ watch(logItems, () => {
.section-header {
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: space-between;
}
.section-header h2 {
@@ -189,6 +272,20 @@ watch(logItems, () => {
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;
@@ -280,6 +377,34 @@ watch(logItems, () => {
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;
@@ -345,4 +470,80 @@ watch(logItems, () => {
.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>

View File

@@ -6,6 +6,10 @@ defineProps<{
steps: PlanStepInfo[]
}>()
const emit = defineEmits<{
quote: [text: string]
}>()
const expandedSteps = ref<Set<number>>(new Set())
function toggleStep(order: number) {
@@ -24,6 +28,11 @@ function statusIcon(status?: string) {
default: return '○'
}
}
function quoteStep(e: Event, step: PlanStepInfo) {
e.stopPropagation()
emit('quote', `[步骤${step.order}] ${step.description}`)
}
</script>
<template>
@@ -42,6 +51,9 @@ function statusIcon(status?: string) {
<span class="step-icon">{{ statusIcon(step.status) }}</span>
<span class="step-order">{{ step.order }}.</span>
<span class="step-title">{{ step.description }}</span>
<button class="quote-btn" @click="quoteStep($event, step)" 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 v-if="step.command" class="step-toggle">{{ expandedSteps.has(step.order) ? '' : '' }}</span>
</div>
<div v-if="step.command && expandedSteps.has(step.order)" class="step-detail">
@@ -135,6 +147,33 @@ function statusIcon(status?: string) {
flex: 1;
}
.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;
}
.step-header:hover .quote-btn {
opacity: 0.5;
}
.quote-btn:hover {
opacity: 1 !important;
background: rgba(79, 195, 247, 0.15);
color: var(--accent);
}
.step-toggle {
color: var(--text-secondary);
font-size: 11px;

View File

@@ -37,14 +37,14 @@ function submit() {
</span>
</div>
<div v-if="!editing && requirement" class="requirement-display">
<span>{{ requirement }}</span>
<pre class="requirement-text">{{ requirement }}</pre>
<button class="btn-edit" @click="editing = true; input = requirement">编辑</button>
</div>
<div v-else class="requirement-input">
<textarea
v-model="input"
placeholder="描述你的需求..."
rows="3"
placeholder="描述你的需求... (支持多行Ctrl+Enter 提交)"
rows="8"
@keydown.ctrl.enter="submit"
/>
<button class="btn-submit" @click="submit">提交需求</button>
@@ -89,7 +89,7 @@ function submit() {
.requirement-display {
display: flex;
align-items: baseline;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
font-size: 14px;
@@ -97,6 +97,18 @@ function submit() {
color: var(--text-primary);
}
.requirement-text {
flex: 1;
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
white-space: pre-wrap;
word-break: break-word;
max-height: 120px;
overflow-y: auto;
}
.btn-edit {
flex-shrink: 0;
padding: 4px 10px;
@@ -125,6 +137,10 @@ function submit() {
border-radius: 6px;
color: var(--text-primary);
resize: vertical;
min-height: 120px;
max-height: 300px;
font-size: 14px;
line-height: 1.6;
}
.requirement-input textarea:focus {

View File

@@ -7,7 +7,7 @@ import CommentSection from './CommentSection.vue'
import TimerSection from './TimerSection.vue'
import { api } from '../api'
import { connectWs } from '../ws'
import type { Workflow, ExecutionLogEntry, PlanStepInfo, Comment } from '../types'
import type { Workflow, ExecutionLogEntry, PlanStepInfo, Comment, LlmCallLogEntry } from '../types'
import type { WsMessage } from '../ws'
const props = defineProps<{
@@ -22,8 +22,23 @@ const workflow = ref<Workflow | null>(null)
const logEntries = ref<ExecutionLogEntry[]>([])
const planSteps = ref<PlanStepInfo[]>([])
const comments = ref<Comment[]>([])
const llmCalls = ref<LlmCallLogEntry[]>([])
const quotes = ref<string[]>([])
const error = ref('')
const rightTab = ref<'log' | 'timers'>('log')
const commentRef = ref<InstanceType<typeof CommentSection> | null>(null)
function addQuote(text: string) {
// Avoid duplicate
if (!quotes.value.includes(text)) {
quotes.value.push(text)
}
commentRef.value?.focusInput()
}
function removeQuote(index: number) {
quotes.value.splice(index, 1)
}
let wsConn: { close: () => void } | null = null
@@ -33,17 +48,22 @@ async function loadData() {
const latest = workflows[0]
if (latest) {
workflow.value = latest
const [entries, c] = await Promise.all([
const [entries, c, plan, lc] = await Promise.all([
api.listSteps(latest.id),
api.listComments(latest.id),
api.listPlanSteps(latest.id),
api.listLlmCalls(latest.id),
])
logEntries.value = entries
comments.value = c
planSteps.value = plan
llmCalls.value = lc
} else {
workflow.value = null
logEntries.value = []
planSteps.value = []
comments.value = []
llmCalls.value = []
}
} catch (e: any) {
error.value = e.message
@@ -87,6 +107,11 @@ function handleWsMessage(msg: WsMessage) {
case 'ProjectUpdate':
emit('projectUpdate', msg.project_id, msg.name)
break
case 'LlmCallLog':
if (workflow.value && msg.workflow_id === workflow.value.id) {
llmCalls.value = [...llmCalls.value, msg.entry]
}
break
case 'Error':
error.value = msg.message
break
@@ -119,6 +144,7 @@ async function onSubmitRequirement(text: string) {
logEntries.value = []
planSteps.value = []
comments.value = []
llmCalls.value = []
} catch (e: any) {
error.value = e.message
}
@@ -129,6 +155,7 @@ async function onSubmitComment(text: string) {
try {
const comment = await api.createComment(workflow.value.id, text)
comments.value.push(comment)
quotes.value = []
} catch (e: any) {
error.value = e.message
}
@@ -144,7 +171,7 @@ async function onSubmitComment(text: string) {
@submit="onSubmitRequirement"
/>
<div class="plan-exec-row">
<PlanSection :steps="planSteps" />
<PlanSection :steps="planSteps" @quote="addQuote" />
<div class="right-panel">
<div class="tab-bar">
<button class="tab-btn" :class="{ active: rightTab === 'log' }" @click="rightTab = 'log'">日志</button>
@@ -154,10 +181,12 @@ async function onSubmitComment(text: string) {
v-show="rightTab === 'log'"
:entries="logEntries"
:comments="comments"
:llmCalls="llmCalls"
:requirement="workflow?.requirement || ''"
:createdAt="workflow?.created_at || ''"
:workflowStatus="workflow?.status || 'pending'"
:workflowId="workflow?.id || ''"
@quote="addQuote"
/>
<TimerSection
v-show="rightTab === 'timers'"
@@ -166,8 +195,11 @@ async function onSubmitComment(text: string) {
</div>
</div>
<CommentSection
ref="commentRef"
:disabled="!workflow"
:quotes="quotes"
@submit="onSubmitComment"
@removeQuote="removeQuote"
/>
</div>
</template>

View File

@@ -63,3 +63,18 @@ export interface Timer {
last_run_at: string
created_at: string
}
export interface LlmCallLogEntry {
id: string
workflow_id: string
step_order: number
phase: string
messages_count: number
tools_count: number
tool_calls: string
text_response: string
prompt_tokens: number | null
completion_tokens: number | null
latency_ms: number
created_at: string
}

View File

@@ -34,12 +34,18 @@ export interface WsProjectUpdate {
name: string
}
export interface WsLlmCallLog {
type: 'LlmCallLog'
workflow_id: string
entry: import('./types').LlmCallLogEntry
}
export interface WsError {
type: 'Error'
message: string
}
export type WsMessage = WsPlanUpdate | WsStepStatusUpdate | WsWorkflowStatusUpdate | WsRequirementUpdate | WsReportReady | WsProjectUpdate | WsError
export type WsMessage = WsPlanUpdate | WsStepStatusUpdate | WsWorkflowStatusUpdate | WsRequirementUpdate | WsReportReady | WsProjectUpdate | WsLlmCallLog | WsError
export type WsHandler = (msg: WsMessage) => void