Refactor agent runtime: state machine, feedback processing, execution log
- Add state.rs with AgentState/Step/StepStatus/AgentPhase as single source of truth - Extract prompts to markdown files loaded via include_str! - Replace plan_steps table with execution_log + agent_state_snapshots - Implement user feedback processing with docker-build-cache plan diff: load snapshot → LLM revise_plan → diff (title, description) → invalidate from first mismatch → resume - run_agent_loop accepts optional initial_state for mid-execution resume - Broadcast plan step status (done/running/pending) to frontend on step transitions - Rewrite frontend types/components to match new API (ExecutionLogEntry, PlanStepInfo with status) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { Project, Workflow, PlanStep, Comment, Timer, KbArticle, KbArticleSummary } from './types'
|
||||
import type { Project, Workflow, ExecutionLogEntry, Comment, Timer, KbArticle, KbArticleSummary } from './types'
|
||||
|
||||
const BASE = '/api'
|
||||
|
||||
@@ -44,7 +44,7 @@ export const api = {
|
||||
}),
|
||||
|
||||
listSteps: (workflowId: string) =>
|
||||
request<PlanStep[]>(`/workflows/${workflowId}/steps`),
|
||||
request<ExecutionLogEntry[]>(`/workflows/${workflowId}/steps`),
|
||||
|
||||
listComments: (workflowId: string) =>
|
||||
request<Comment[]>(`/workflows/${workflowId}/comments`),
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import type { PlanStep, Comment } from '../types'
|
||||
import type { ExecutionLogEntry, Comment } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
steps: PlanStep[]
|
||||
planSteps: PlanStep[]
|
||||
entries: ExecutionLogEntry[]
|
||||
comments: Comment[]
|
||||
requirement: string
|
||||
createdAt: string
|
||||
@@ -12,15 +11,6 @@ const props = defineProps<{
|
||||
workflowId: string
|
||||
}>()
|
||||
|
||||
// Map plan step id -> step_order for showing badge
|
||||
const planStepOrderMap = computed(() => {
|
||||
const m: Record<string, number> = {}
|
||||
for (const ps of props.planSteps) {
|
||||
m[ps.id] = ps.step_order
|
||||
}
|
||||
return m
|
||||
})
|
||||
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
const userScrolledUp = ref(false)
|
||||
|
||||
@@ -30,13 +20,13 @@ function onScroll() {
|
||||
userScrolledUp.value = el.scrollTop + el.clientHeight < el.scrollHeight - 80
|
||||
}
|
||||
|
||||
const expandedSteps = ref<Set<string>>(new Set())
|
||||
const expandedEntries = ref<Set<string>>(new Set())
|
||||
|
||||
function toggleStep(id: string) {
|
||||
if (expandedSteps.value.has(id)) {
|
||||
expandedSteps.value.delete(id)
|
||||
function toggleEntry(id: string) {
|
||||
if (expandedEntries.value.has(id)) {
|
||||
expandedEntries.value.delete(id)
|
||||
} else {
|
||||
expandedSteps.value.add(id)
|
||||
expandedEntries.value.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,67 +49,71 @@ function statusLabel(status: string) {
|
||||
}
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
interface LogItem {
|
||||
id: string
|
||||
type: 'requirement' | 'step' | 'comment' | 'report'
|
||||
type: 'requirement' | 'entry' | 'comment' | 'report'
|
||||
time: string
|
||||
step?: PlanStep
|
||||
entry?: ExecutionLogEntry
|
||||
text?: string
|
||||
}
|
||||
|
||||
const logEntries = computed(() => {
|
||||
const entries: LogEntry[] = []
|
||||
const logItems = computed(() => {
|
||||
const items: LogItem[] = []
|
||||
|
||||
// Requirement
|
||||
if (props.requirement) {
|
||||
entries.push({ id: 'req', type: 'requirement', text: props.requirement, time: props.createdAt || '0' })
|
||||
items.push({ id: 'req', type: 'requirement', text: props.requirement, time: props.createdAt || '0' })
|
||||
}
|
||||
|
||||
// Steps
|
||||
for (const step of props.steps) {
|
||||
entries.push({ id: step.id, type: 'step', step, time: step.created_at || '' })
|
||||
for (const e of props.entries) {
|
||||
items.push({ id: e.id, type: 'entry', entry: e, time: e.created_at || '' })
|
||||
}
|
||||
|
||||
// Comments
|
||||
for (const c of props.comments) {
|
||||
entries.push({ id: c.id, type: 'comment', text: c.content, time: c.created_at })
|
||||
items.push({ id: c.id, type: 'comment', text: c.content, time: c.created_at })
|
||||
}
|
||||
|
||||
// Sort by time, preserving order for entries without timestamps
|
||||
entries.sort((a, b) => {
|
||||
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)
|
||||
})
|
||||
|
||||
// Insert report links: after each contiguous block of steps that ends before a comment/requirement
|
||||
if (props.workflowId && (props.workflowStatus === 'done' || props.workflowStatus === 'failed')) {
|
||||
const result: LogEntry[] = []
|
||||
let lastWasStep = false
|
||||
for (const entry of entries) {
|
||||
if (entry.type === 'step') {
|
||||
lastWasStep = true
|
||||
} else if (lastWasStep && (entry.type === 'comment' || entry.type === 'requirement')) {
|
||||
// Insert report link before this comment/requirement
|
||||
const result: LogItem[] = []
|
||||
let lastWasEntry = false
|
||||
for (const item of items) {
|
||||
if (item.type === 'entry') {
|
||||
lastWasEntry = true
|
||||
} else if (lastWasEntry && (item.type === 'comment' || item.type === 'requirement')) {
|
||||
result.push({ id: `report-${result.length}`, type: 'report', time: '' })
|
||||
lastWasStep = false
|
||||
lastWasEntry = false
|
||||
} else {
|
||||
lastWasStep = false
|
||||
lastWasEntry = false
|
||||
}
|
||||
result.push(entry)
|
||||
result.push(item)
|
||||
}
|
||||
// Final report link at the end if last entry was a step
|
||||
if (lastWasStep) {
|
||||
if (lastWasEntry) {
|
||||
result.push({ id: 'report-final', type: 'report', time: '' })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return entries
|
||||
return items
|
||||
})
|
||||
|
||||
watch(logEntries, () => {
|
||||
watch(logItems, () => {
|
||||
if (userScrolledUp.value) return
|
||||
nextTick(() => {
|
||||
const el = scrollContainer.value
|
||||
@@ -134,37 +128,38 @@ watch(logEntries, () => {
|
||||
<h2>日志</h2>
|
||||
</div>
|
||||
<div class="exec-list">
|
||||
<template v-for="entry in logEntries" :key="entry.id">
|
||||
<!-- User message (requirement or comment) -->
|
||||
<div v-if="entry.type === 'requirement' || entry.type === 'comment'" class="log-user">
|
||||
<span class="log-time" v-if="entry.time">{{ formatTime(entry.time) }}</span>
|
||||
<span class="log-tag">{{ entry.type === 'requirement' ? '需求' : '反馈' }}</span>
|
||||
<span class="log-text">{{ entry.text }}</span>
|
||||
<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="entry.type === 'report'" class="report-link-bar">
|
||||
<div v-else-if="item.type === 'report'" class="report-link-bar">
|
||||
<a :href="'/report/' + workflowId" class="report-link" target="_blank">查看报告 →</a>
|
||||
</div>
|
||||
|
||||
<!-- Step -->
|
||||
<div v-else-if="entry.step" class="exec-item" :class="entry.step.status">
|
||||
<div class="exec-header" @click="toggleStep(entry.step!.id)">
|
||||
<span class="exec-time" v-if="entry.time">{{ formatTime(entry.time) }}</span>
|
||||
<span v-if="entry.step.plan_step_id && planStepOrderMap[entry.step.plan_step_id]" class="step-badge">{{ planStepOrderMap[entry.step.plan_step_id] }}</span>
|
||||
<span class="exec-toggle">{{ expandedSteps.has(entry.step!.id) ? '▾' : '▸' }}</span>
|
||||
<span class="exec-desc">{{ entry.step.description }}</span>
|
||||
<span class="exec-status" :class="entry.step.status">{{ statusLabel(entry.step.status) }}</span>
|
||||
<!-- 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>
|
||||
<span class="exec-status" :class="item.entry.status">{{ statusLabel(item.entry.status) }}</span>
|
||||
</div>
|
||||
<div v-if="expandedSteps.has(entry.step!.id)" class="exec-detail">
|
||||
<div v-if="entry.step.command" class="exec-command">
|
||||
<code>$ {{ entry.step.command }}</code>
|
||||
<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="entry.step.output">{{ entry.step.output }}</pre>
|
||||
<pre v-if="item.entry.output">{{ item.entry.output }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="!steps.length && !requirement" class="empty-state">
|
||||
<div v-if="!entries.length && !requirement" class="empty-state">
|
||||
提交需求后,日志将显示在这里
|
||||
</div>
|
||||
</div>
|
||||
@@ -269,10 +264,20 @@ watch(logEntries, () => {
|
||||
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;
|
||||
}
|
||||
|
||||
.exec-status {
|
||||
@@ -285,7 +290,6 @@ watch(logEntries, () => {
|
||||
.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-status.pending { background: var(--pending); color: #fff; }
|
||||
|
||||
.exec-detail {
|
||||
border-top: 1px solid var(--border);
|
||||
@@ -301,6 +305,8 @@ watch(logEntries, () => {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.exec-detail pre {
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { PlanStep } from '../types'
|
||||
import type { PlanStepInfo } from '../types'
|
||||
|
||||
defineProps<{
|
||||
steps: PlanStep[]
|
||||
steps: PlanStepInfo[]
|
||||
}>()
|
||||
|
||||
const expandedSteps = ref<Set<string>>(new Set())
|
||||
const expandedSteps = ref<Set<number>>(new Set())
|
||||
|
||||
function toggleStep(id: string) {
|
||||
if (expandedSteps.value.has(id)) {
|
||||
expandedSteps.value.delete(id)
|
||||
function toggleStep(order: number) {
|
||||
if (expandedSteps.value.has(order)) {
|
||||
expandedSteps.value.delete(order)
|
||||
} else {
|
||||
expandedSteps.value.add(id)
|
||||
expandedSteps.value.add(order)
|
||||
}
|
||||
}
|
||||
|
||||
function statusIcon(status: string) {
|
||||
function statusIcon(status?: string) {
|
||||
switch (status) {
|
||||
case 'done': return '✓'
|
||||
case 'running': return '⟳'
|
||||
@@ -34,17 +34,17 @@ function statusIcon(status: string) {
|
||||
<div class="steps-list">
|
||||
<div
|
||||
v-for="step in steps"
|
||||
:key="step.id"
|
||||
:key="step.order"
|
||||
class="step-item"
|
||||
:class="step.status"
|
||||
:class="step.status || 'pending'"
|
||||
>
|
||||
<div class="step-header" @click="step.command ? toggleStep(step.id) : undefined">
|
||||
<div class="step-header" @click="step.command ? toggleStep(step.order) : undefined">
|
||||
<span class="step-icon">{{ statusIcon(step.status) }}</span>
|
||||
<span class="step-order">{{ step.step_order }}.</span>
|
||||
<span class="step-order">{{ step.order }}.</span>
|
||||
<span class="step-title">{{ step.description }}</span>
|
||||
<span v-if="step.command" class="step-toggle">{{ expandedSteps.has(step.id) ? '▾' : '▸' }}</span>
|
||||
<span v-if="step.command" class="step-toggle">{{ expandedSteps.has(step.order) ? '▾' : '▸' }}</span>
|
||||
</div>
|
||||
<div v-if="step.command && expandedSteps.has(step.id)" class="step-detail">
|
||||
<div v-if="step.command && expandedSteps.has(step.order)" class="step-detail">
|
||||
{{ step.command }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import RequirementSection from './RequirementSection.vue'
|
||||
import PlanSection from './PlanSection.vue'
|
||||
import ExecutionSection from './ExecutionSection.vue'
|
||||
@@ -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, PlanStep, Comment } from '../types'
|
||||
import type { Workflow, ExecutionLogEntry, PlanStepInfo, Comment } from '../types'
|
||||
import type { WsMessage } from '../ws'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -19,14 +19,12 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const workflow = ref<Workflow | null>(null)
|
||||
const steps = ref<PlanStep[]>([])
|
||||
const logEntries = ref<ExecutionLogEntry[]>([])
|
||||
const planSteps = ref<PlanStepInfo[]>([])
|
||||
const comments = ref<Comment[]>([])
|
||||
const error = ref('')
|
||||
const rightTab = ref<'log' | 'timers'>('log')
|
||||
|
||||
const planSteps = computed(() => steps.value.filter(s => s.kind === 'plan'))
|
||||
const logSteps = computed(() => steps.value.filter(s => s.kind === 'log'))
|
||||
|
||||
let wsConn: { close: () => void } | null = null
|
||||
|
||||
async function loadData() {
|
||||
@@ -35,15 +33,16 @@ async function loadData() {
|
||||
const latest = workflows[0]
|
||||
if (latest) {
|
||||
workflow.value = latest
|
||||
const [s, c] = await Promise.all([
|
||||
const [entries, c] = await Promise.all([
|
||||
api.listSteps(latest.id),
|
||||
api.listComments(latest.id),
|
||||
])
|
||||
steps.value = s
|
||||
logEntries.value = entries
|
||||
comments.value = c
|
||||
} else {
|
||||
workflow.value = null
|
||||
steps.value = []
|
||||
logEntries.value = []
|
||||
planSteps.value = []
|
||||
comments.value = []
|
||||
}
|
||||
} catch (e: any) {
|
||||
@@ -55,18 +54,18 @@ function handleWsMessage(msg: WsMessage) {
|
||||
switch (msg.type) {
|
||||
case 'PlanUpdate':
|
||||
if (workflow.value && msg.workflow_id === workflow.value.id) {
|
||||
api.listSteps(workflow.value.id).then(s => { steps.value = s })
|
||||
planSteps.value = msg.steps.map(s => ({
|
||||
order: s.order,
|
||||
description: s.description,
|
||||
command: s.command,
|
||||
status: s.status as PlanStepInfo['status'],
|
||||
}))
|
||||
}
|
||||
break
|
||||
case 'StepStatusUpdate': {
|
||||
const idx = steps.value.findIndex(s => s.id === msg.step_id)
|
||||
const existing = steps.value[idx]
|
||||
if (existing) {
|
||||
steps.value[idx] = { ...existing, status: msg.status as PlanStep['status'], output: msg.output }
|
||||
} else {
|
||||
if (workflow.value) {
|
||||
api.listSteps(workflow.value.id).then(s => { steps.value = s })
|
||||
}
|
||||
// New execution log entry — just refetch the list
|
||||
if (workflow.value) {
|
||||
api.listSteps(workflow.value.id).then(entries => { logEntries.value = entries })
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -117,7 +116,8 @@ async function onSubmitRequirement(text: string) {
|
||||
try {
|
||||
const wf = await api.createWorkflow(props.projectId, text)
|
||||
workflow.value = wf
|
||||
steps.value = []
|
||||
logEntries.value = []
|
||||
planSteps.value = []
|
||||
comments.value = []
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
@@ -152,8 +152,7 @@ async function onSubmitComment(text: string) {
|
||||
</div>
|
||||
<ExecutionSection
|
||||
v-show="rightTab === 'log'"
|
||||
:steps="logSteps"
|
||||
:planSteps="planSteps"
|
||||
:entries="logEntries"
|
||||
:comments="comments"
|
||||
:requirement="workflow?.requirement || ''"
|
||||
:createdAt="workflow?.created_at || ''"
|
||||
|
||||
@@ -15,17 +15,22 @@ export interface Workflow {
|
||||
report: string
|
||||
}
|
||||
|
||||
export interface PlanStep {
|
||||
export interface ExecutionLogEntry {
|
||||
id: string
|
||||
workflow_id: string
|
||||
step_order: number
|
||||
tool_name: string
|
||||
tool_input: string
|
||||
output: string
|
||||
status: 'running' | 'done' | 'failed'
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface PlanStepInfo {
|
||||
order: number
|
||||
description: string
|
||||
command: string
|
||||
status: 'pending' | 'running' | 'done' | 'failed'
|
||||
output: string
|
||||
created_at: string
|
||||
kind: 'plan' | 'log'
|
||||
plan_step_id: string
|
||||
status?: 'pending' | 'running' | 'done' | 'failed'
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export interface WsPlanUpdate {
|
||||
type: 'PlanUpdate'
|
||||
workflow_id: string
|
||||
steps: { order: number; description: string; command: string }[]
|
||||
steps: { order: number; description: string; command: string; status?: string }[]
|
||||
}
|
||||
|
||||
export interface WsStepStatusUpdate {
|
||||
|
||||
Reference in New Issue
Block a user