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:
Fam Zheng 2026-03-02 08:54:43 +00:00
parent 7f6dafeab6
commit 46424cfbc4
16 changed files with 910 additions and 992 deletions

View File

@ -1,392 +1,86 @@
# Agent Runtime 设计 # Agent Context 构建
## Context 管理现状与设计 AgentState 如何变成 LLM API call 的 messages 数组。定义见 `src/state.rs`
## 现状 ## AgentState
当前没有做 context 长度限制,存在超过 model token limit 的风险。
### 已有的缓解机制
1. **Phase transition 时 clear**`step_messages` 在 planning→executing 和 step→step 切换时会 `clear()`,避免跨阶段累积
2. **单条 tool output 截断**bash 输出限制 8000 bytesread_file 超长时也截断
3. **Step context 摘要**:已完成步骤只保留 summary`step_summaries`),不带完整输出
### 风险场景
- 一个 execution step 内 tool call 轮次过多(反复 bash、read_file`step_messages` 无限增长
- 每轮 LLM 的 assistant message + tool result 都 push 进 `step_messages`,没有上限
- 最终整个 messages 数组超过模型 context window
## 方案设计
### 策略:滑动窗口 + 早期消息摘要
`step_messages` 长度超过阈值时,保留最近 N 轮完整对话,早期的 tool call/result 对折叠为一条摘要消息。
```
[system prompt]
[user: step context]
[summary of early tool interactions] ← 压缩后的历史
[recent assistant + tool messages] ← 完整保留最近 N 轮
```
### 具体实现
1. **Token 估算**用字符数粗估1 token ≈ 3-4 chars 中英混合),不需要精确 tokenizer
2. **阈值**:可配置,默认如 80000 chars约 20k-25k tokens给 system prompt 和 response 留余量
3. **压缩触发**:每次构建 messages 时检查总长度,超过阈值则压缩
4. **压缩方式**
- 简单版:直接丢弃早期 tool call/result 对,替换为 `[已执行 N 次工具调用,最近结果见下文]`
- 进阶版:用 LLM 生成摘要(额外一次 API 调用,但质量更好)
5. **不压缩的部分**system prompt、user context、最近 2-3 轮完整交互
### 实现位置
`run_agent_loop` 中构建 messages 之后、调用 LLM 之前,插入压缩逻辑:
```rust ```rust
// agent.rs run_agent_loop 内,约 L706-L725 struct AgentState {
let (mut messages, tools) = match &state.phase { ... }; phase: AgentPhase, // Planning | Executing { step } | Completed
steps: Vec<Step>, // 执行计划,每个 step 有 status + optional summary
// 压缩 context current_step_chat_history: Vec<ChatMessage>, // 当前步骤内的多轮对话step 切换时 clear
compact_messages(&mut messages, MAX_CONTEXT_CHARS); scratchpad: String, // LLM 的跨步骤工作区
```
`compact_messages` 函数:从前往后扫描,保留 system/user 头部,计算总长度,超限时将早期 assistant+tool 消息替换为摘要。
---
## 执行中用户反馈处理 (Plan-Centric Feedback)
### 核心原则
Agent loop 是 **以 plan 为中心线性推进** 的。用户反馈的处理不应该是简单的"中断/不中断"二元决策,而应该根据反馈落在 plan 的哪个位置来决定行为:
```
Plan: [step1 ✓] [step2 ✓] [step3 🔄 executing] [step4 ○] [step5 ○]
当前执行位置
用户反馈 → 影响哪里?
├─ 影响已完成部分 (step1/step2) → 回退中断当前执行replan from affected step
├─ 改了需求本身 → 回退:中断当前执行,全部 replan
├─ 影响未完成部分 (step4/step5) → 前进:不中断,调整后续 plan steps
└─ 影响当前步骤 (step3) → 视情况:注入到当前 step context 或中断重做
```
### 与"中断"的关系
"中断"只是一个实现手段,不是目的。真正的决策是:
1. **反馈是否影响当前或已完成的工作?** → 必须中断(继续执行是浪费)
2. **反馈只影响未来的步骤?** → 不中断,修改 plan 里的 pending steps执行到那里时自然生效
这意味着 cancel token 之类的机制仍然需要(作为中断的执行手段),但决策逻辑是 plan-aware 的。
### 决策流程
```
用户 comment 到达
├─ workflow idle (done/failed)
│ └─ 当前逻辑:以 comment 为 context 重新执行
└─ workflow executing (step N)
├─ 1. 分类 LLM 调用(轻量,不阻塞执行)
│ 输入:原始需求、当前 plan、当前步骤进度、用户 comment
│ 输出:{ impact: "past" | "current" | "future" | "requirement_change" }
├─ impact = "past" 或 "requirement_change"
│ → cancel 当前执行
│ → replan带上已完成步骤的摘要 + 用户反馈)
├─ impact = "current"
│ → 注入到 step_messages当前步骤的 context
│ → agent 在下一次 LLM 调用时自然看到
│ → 如果反馈是否定性的("这样不行"cancel 当前步骤,重做
└─ impact = "future"
→ 修改 plan 中 pending stepsupdate/insert/delete
→ 不中断当前执行
→ 执行到对应步骤时自然生效
```
### 实现要点
#### 1. Plan 作为可变数据结构
当前 plan steps 存在 DB 里,但 agent 执行时是线性推进的,没有"修改后续步骤"的能力。需要:
- plan steps 支持运行时增删改(不仅是状态更新)
- agent 每执行完一步,从 DB 重新读 plan而不是用内存快照这样外部修改能被感知
#### 2. 并发:分类 LLM 与执行 LLM 并行
分类调用不应该阻塞执行。用 `tokio::spawn` 做分类,分类结果通过 channel 或 shared state 回传。如果分类结果是"需要中断",设置 cancel token。
```rust
// 伪代码
tokio::spawn(async move {
let impact = classify_feedback(&llm, &plan, current_step, &comment).await;
match impact {
Impact::Past | Impact::RequirementChange => {
cancel_token.cancel();
// replan 会在 cancel 生效后由主循环触发
}
Impact::Current => {
// 注入到 step context
step_messages_tx.send(comment).await;
}
Impact::Future => {
// 调用 LLM 修改后续 plan steps
replan_future_steps(&llm, &plan, current_step, &comment).await;
}
}
});
```
#### 3. 中断后的 Context 衔接
中断执行后重启时,需要构建的 context
- 已完成步骤的摘要(已有 `step_summaries` 机制)
- 被中断步骤的部分执行记录
- 用户反馈内容
- "请根据用户反馈重新规划剩余步骤"的指令
这和上面 context 压缩的滑动窗口方案互补——中断后重启本质上就是一次强制的 context 压缩。
### 与 context 压缩的统一视角
两个问题其实是同一件事的两面:
| | Context 压缩 | 用户反馈 |
|---|---|---|
| 触发条件 | token 数超阈值 | 用户发了 comment |
| 本质操作 | 摘要化早期历史,保留近期 | 根据反馈位置,决定哪些历史需要保留/丢弃/重做 |
| 共享机制 | step_summaries、compact_messages | 同上 + plan 的增删改 |
两者都需要一个健壮的"摘要化已完成工作"的基础能力。先把这个基础能力做好,上面两个场景都能受益。
### Plan-Centric Context 优先级
Context 管理也应该围绕 plan 展开。Plan 本身是结构化的、紧凑的,基本不会超。撑爆 context 的是执行日志(一个 step 内反复 tool call 的历史)。这就有了清晰的层级和主次:
```
优先级(从高到低,压缩时从低往高砍):
1. System prompt + 需求 ← 不可压缩,恒定大小
2. Plan 全貌(步骤标题 + 状态) ← 不可压缩O(步骤数) 很短
3. 用户 comments ← 保留原文,通常很短
4. 当前步骤的最近 N 轮 tool call ← 完整保留agent 需要看到最近的执行结果)
5. 当前步骤的早期 tool call ← 第一优先压缩目标,摘要化
6. 已完成步骤的执行摘要 ← 已经是一句话/步骤,极紧凑
```
关键洞察:**Plan 是骨架,执行日志是血肉。** 骨架永远保留,血肉按新鲜度裁剪。这和人类记忆一样——你记得"今天做了什么"plan但不记得每一步的具体命令输出执行日志
压缩算法因此变得很简单:
1. 算总 token 估计
2. 如果超阈值,从"当前步骤的早期 tool call"开始砍,替换为 `[前 N 次工具调用已折叠,关键结果:...]`
3. 如果还超,压缩已完成步骤的摘要(合并多步为一段)
4. Plan 和 system prompt 永远不动
---
## 状态管理、并发与一致性
### 系统中的参与者和他们各自的"世界观"
一致性问题的本质是:多个参与者对同一份状态有各自的视图,这些视图会因为延迟和并发而产生分歧。
| 参与者 | 看到的世界 | 更新方式 | 延迟 |
|---|---|---|---|
| **Agent loop** | 内存 `AgentState` | 直接修改 | 0权威源 |
| **LLM** | 上一次调用时传入的 messages | 每次调用时重建 | 一次 LLM 调用的时间(数秒) |
| **DB** | 持久化的 plan_steps, workflow.status | agent 写入 | 取决于 agent 何时 flush |
| **前端** | 最近一次 WS 推送 / API 响应 | WS broadcast | 网络延迟 + WS 推送时机 |
| **用户** | 看着前端 + 自己的判断 | 发 comment | 人类反应时间(秒~分钟) |
问题出在哪:
```
时间线:
t0 agent 开始执行 step 3
t1 前端显示 step 3 runningWS 推送)
t2 用户看到 step 3觉得方向不对开始打字
t3 agent 完成 step 3开始 step 4
t4 前端显示 step 4 running
t5 用户发出 comment"step 3 不对,应该这样做"
→ 但此时 step 3 已经做完了step 4 在跑了
→ 用户的反馈是基于 t1 时的世界观
```
这种"用户看到的是过去"是不可消除的(人类反应时间),关键是系统怎么处理这个 gap。
### 核心设计:统一状态 + 单写者
#### 状态 Schema
一个 project 的完整运行时状态 = 一个 `ProjectState` 对象:
```rust
struct ProjectState {
workflow_id: String,
requirement: String,
status: WorkflowStatus, // Idle / Executing / Done / Failed
plan: Vec<PlanStep>, // 有序步骤,每步有 status
current_step: Option<usize>, // 正在执行哪一步
step_summaries: Vec<StepSummary>, // 已完成步骤的紧凑摘要
step_messages: Vec<ChatMessage>, // 当前步骤的 tool call 历史(临时,会膨胀)
memo: String, // agent 备忘
iteration: u32, // LLM 调用轮次
cancel: CancellationToken, // 中断信号
} }
``` ```
`Arc<RwLock<ProjectState>>` 持有 整个结构体 `Serialize/Deserialize`,可 JSON 直接存 DB。
#### 单写者规则 ## Step 生命周期
**只有 agent loop 修改 `ProjectState`。** 没有例外。
其他所有参与者反馈分类器、API handler、WS handler都是读者或事件发送者
- 想看状态 → read lockclone 出快照,立即释放
- 想改状态 → 发 event 到 agent loop 的 channel由 agent loop 决定怎么改
这彻底消除了写-写竞争。不需要事务,不需要 CAS不需要冲突解决。
#### 数据流
``` ```
用户 comment ─→ API handler ─→ mpsc channel ─→ agent loop (唯一写者) Pending → Running → Done(summary) 或 Failed
write lock ──→ ProjectState
write DB (flush)
broadcast WS ──→ 前端
``` ```
### 一致性分析:三对关系 summary 在 step 完成时由 LLM 填入,作为后续步骤的压缩上下文。
#### 1. 内存 ↔ DB 一致性 ## Messages 组装
当前问题:`AgentState`(内存)和 `plan_steps`DB是两套独立数据各管各的。 ### Planning 阶段
**解法:内存是 masterDB 是 checkpoint。** ```
[ system(planning_prompt), user(requirement), ...current_step_chat_history ]
```
- Agent loop 修改 `ProjectState`(内存),在关键节点 flush 到 DB ### Executing 阶段
- 前端 API 读内存read lock不读 DB运行时
- DB 只在重启恢复时读
Flush 时机(从密到疏): ```
| 事件 | 写 DB | 原因 | [ system(execution_prompt), user(step_context), ...current_step_chat_history ]
```
`step_context``build_step_context()` 拼接:
```
## 需求
{requirement}
## 计划概览
1. 分析代码结构 done
2. 实现核心逻辑 >> current
3. 写测试 FAILED
4. 集成测试
## 当前步骤(步骤 2
标题:实现核心逻辑
描述:...
## 已完成步骤摘要 ← 从 steps 中 filter Done取 summary
- 步骤 1: ...
## Scratchpad ← LLM 自己维护的跨步骤笔记
...
```
## 持久化
### DB 表
- **agent_state_snapshots** — step 切换时 insert 一行 AgentState JSON 快照(追加,不覆盖,保留历史)。恢复时取最新一行。
- **execution_log** — tool call 的输入输出记录(不可变历史),前端展示 + report 生成用。
Plan 步骤只从 AgentState JSON 读,不再单独写表。
### Executing 阶段 text response
LLM 返回纯文本时不隐含"workflow 结束",写 execution_log 显示给用户。只有显式调 `advance_step` 才推进步骤。
## 待做Context 压缩
当前无长度限制,`current_step_chat_history` 在单步 tool call 轮次过多时会无限增长。
压缩按优先级从低到高砍:
| 优先级 | 内容 | 处理 |
|---|---|---| |---|---|---|
| workflow.status 变更 | 立即 | 重启恢复必需 | | 高 | system prompt / requirement / plan 概览 | 不压缩 |
| plan 变更(新建/修改步骤) | 立即 | 重启恢复 + 持久记录 | | 中 | 当前步骤最近 N 轮 tool call | 完整保留 |
| 步骤完成summary 产出) | 立即 | 重启恢复的关键数据 | | 低 | 当前步骤早期 tool call | 替换为摘要 |
| step_messages 增长 | 不写 | 临时数据,中断时才摘要化 |
| memo 更新 | 步骤切换时 | 丢了问题不大 |
重启恢复 = 从 DB 重建 `ProjectState``step_messages` 为空丢失可接受summaries 提供足够上下文)。
#### 2. 后端 ↔ 前端 一致性
前端通过两个渠道获取状态:
- **WS 推送**agent loop 每次修改状态后 broadcast
- **API 拉取**:前端 mount 时、切换 project 时
一致性模型:**最终一致**。前端可能落后几百毫秒,但不会出现永久分歧。
需要保证的不变量:
- WS 推送的顺序和状态变更顺序一致(单写者天然保证)
- 前端收到 WS 消息后,可以安全地 patch 本地状态(不需要全量刷新)
- 如果 WS 断连,重连后拉一次全量状态即可恢复
#### 3. Agent loop ↔ LLM ↔ 用户反馈 一致性
这是最微妙的。三方的时间线:
```
Agent loop: ──[step3 执行中]──[step3 完成]──[step4 开始]──...
LLM: ──[thinking...]──[返回 tool calls]──
用户: ──[看到 step3]──[打字中...]──[发送 comment]──
```
三个视角可能同时看到不同的状态。处理方式:
**a) LLM 的"冻结视角"**
每次 LLM 调用时,传入的 messages 是一个 point-in-time 快照。LLM 返回的 tool calls 是基于那个快照的。在 LLM 思考期间,状态可能已经变了(比如用户发了 comment但 LLM 不知道。
这没关系——LLM 返回后agent loop 先检查 cancel flag再决定是否执行 tool calls。如果 cancel 了,丢弃 LLM 的响应,不浪费。
**b) 用户反馈的"过时性"**
用户发 comment 时看到的可能是几秒前的状态。反馈里提到的"step 3"可能已经完成了。
处理反馈分类器拿当前快照read lock对比用户反馈和当前进度判断反馈是关于
- 已完成的步骤 → 需要 revert/redo
- 当前步骤 → 注入或中断
- 未来步骤 → 修改 plan
- 需求级别 → 全部 replan
分类结果作为 event 发给 agent loop。Agent loop 收到时再次基于最新状态做最终决策(双重检查)。
**c) 反馈分类器的并发**
反馈分类器 `tokio::spawn` 出去,和 agent loop 并行:
```
Agent loop: ──[step4 执行中]──────────[step4 完成]──[处理 feedback event]──
Feedback classifier: ──[拿 snapshot]──[LLM 分类...]──[发 event]─┘
```
分类器拿的 snapshot 可能已经过时了(分类期间 agent 又推进了一步。但因为分类结果是建议性的agent loop 收到后基于最新状态做最终决策,所以不会出错。最坏情况:建议无效,忽略。
### Agent loop 内部的事件处理
Agent loop 不再是"拿 event → 跑完整个 run_agent_loop → 拿下一个 event"。而是在 tool call 循环内部也能感知新事件:
```rust
for iteration in 0..MAX_ITERATIONS {
// 1. 检查中断
if state.cancel.is_cancelled() {
// 摘要化当前 step_messages存 DB
break;
}
// 2. 检查是否有 pending feedback events非阻塞
while let Ok(event) = feedback_rx.try_recv() {
handle_feedback(&mut state, event);
// 可能修改了 plan、设置了 cancel、注入了 step_messages
}
// 3. 构建 messages调 LLM
let messages = build_messages(&state);
let response = llm.chat(messages).await?;
// 4. 执行 tool calls更新 state
// ...
}
```
关键:`feedback_rx.try_recv()` 是非阻塞的。在每次 LLM 调用之间检查一次。如果有 feedback立即在 agent loop 内处理(修改 plan / 设置 cancel / 注入 context然后继续循环。
这保持了**单写者**——所有状态变更都在这个循环里发生。
### 实现顺序
1. **定义 `ProjectState`** — 集中状态,`Arc<RwLock<_>>`。Agent loop 内部用起来,行为不变。
2. **DB flush 策略** — 关键节点写 DBstatus 变更、步骤完成),前端 API 改为读内存。
3. **Step 摘要持久化** — 步骤完成时写 summary 到 DB重启时重建 ProjectState。
4. **Context 压缩** — step_messages 滑动窗口。
5. **CancellationToken + feedback channel** — agent loop 内部 `try_recv`,单写者处理反馈。
6. **Feedback 分类器**`tokio::spawn`read lock 拿快照LLM 分类,结果发回 agent loop。

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,7 @@ use axum::{
use serde::Deserialize; use serde::Deserialize;
use crate::AppState; use crate::AppState;
use crate::agent::AgentEvent; use crate::agent::AgentEvent;
use crate::db::{Workflow, PlanStep, Comment}; use crate::db::{Workflow, ExecutionLogEntry, Comment};
use super::{ApiResult, db_err}; use super::{ApiResult, db_err};
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
@ -77,9 +77,9 @@ async fn create_workflow(
async fn list_steps( async fn list_steps(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(workflow_id): Path<String>, Path(workflow_id): Path<String>,
) -> ApiResult<Vec<PlanStep>> { ) -> ApiResult<Vec<ExecutionLogEntry>> {
sqlx::query_as::<_, PlanStep>( sqlx::query_as::<_, ExecutionLogEntry>(
"SELECT * FROM plan_steps WHERE workflow_id = ? ORDER BY step_order" "SELECT * FROM execution_log WHERE workflow_id = ? ORDER BY created_at"
) )
.bind(&workflow_id) .bind(&workflow_id)
.fetch_all(&state.db.pool) .fetch_all(&state.db.pool)

View File

@ -41,20 +41,6 @@ impl Database {
.execute(&self.pool) .execute(&self.pool)
.await?; .await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS plan_steps (
id TEXT PRIMARY KEY,
workflow_id TEXT NOT NULL REFERENCES workflows(id),
step_order INTEGER NOT NULL,
description TEXT NOT NULL,
command TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
output TEXT NOT NULL DEFAULT ''
)"
)
.execute(&self.pool)
.await?;
sqlx::query( sqlx::query(
"CREATE TABLE IF NOT EXISTS comments ( "CREATE TABLE IF NOT EXISTS comments (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@ -73,27 +59,6 @@ impl Database {
.execute(&self.pool) .execute(&self.pool)
.await; .await;
// Migration: add created_at to plan_steps
let _ = sqlx::query(
"ALTER TABLE plan_steps ADD COLUMN created_at TEXT NOT NULL DEFAULT ''"
)
.execute(&self.pool)
.await;
// Migration: add kind to plan_steps ('plan' or 'log')
let _ = sqlx::query(
"ALTER TABLE plan_steps ADD COLUMN kind TEXT NOT NULL DEFAULT 'log'"
)
.execute(&self.pool)
.await;
// Migration: add plan_step_id to plan_steps (log entries reference their parent plan step)
let _ = sqlx::query(
"ALTER TABLE plan_steps ADD COLUMN plan_step_id TEXT NOT NULL DEFAULT ''"
)
.execute(&self.pool)
.await;
// Migration: add deleted column to projects // Migration: add deleted column to projects
let _ = sqlx::query( let _ = sqlx::query(
"ALTER TABLE projects ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0" "ALTER TABLE projects ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0"
@ -165,6 +130,34 @@ impl Database {
.await; .await;
} }
// New tables: agent_state_snapshots + execution_log
sqlx::query(
"CREATE TABLE IF NOT EXISTS agent_state_snapshots (
id TEXT PRIMARY KEY,
workflow_id TEXT NOT NULL REFERENCES workflows(id),
step_order INTEGER NOT NULL,
state_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS execution_log (
id TEXT PRIMARY KEY,
workflow_id TEXT NOT NULL REFERENCES workflows(id),
step_order INTEGER NOT NULL,
tool_name TEXT NOT NULL,
tool_input TEXT NOT NULL DEFAULT '',
output TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'running',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
)
.execute(&self.pool)
.await?;
sqlx::query( sqlx::query(
"CREATE TABLE IF NOT EXISTS timers ( "CREATE TABLE IF NOT EXISTS timers (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@ -206,17 +199,15 @@ pub struct Workflow {
} }
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct PlanStep { pub struct ExecutionLogEntry {
pub id: String, pub id: String,
pub workflow_id: String, pub workflow_id: String,
pub step_order: i32, pub step_order: i32,
pub description: String, pub tool_name: String,
pub command: String, pub tool_input: String,
pub status: String,
pub output: String, pub output: String,
pub status: String,
pub created_at: String, pub created_at: String,
pub kind: String,
pub plan_step_id: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]

View File

@ -4,6 +4,7 @@ mod db;
mod kb; mod kb;
mod llm; mod llm;
mod exec; mod exec;
mod state;
mod timer; mod timer;
mod ws; mod ws;

25
src/prompts/execution.md Normal file
View File

@ -0,0 +1,25 @@
你是一个 AI 智能体,正处于【执行阶段】。请专注完成当前步骤的任务。
可用工具:
- execute执行 shell 命令
- read_file / write_file / list_files文件操作
- start_service / stop_service管理后台服务
- update_requirement更新项目需求
- advance_step完成当前步骤并进入下一步必须提供摘要
- update_scratchpad保存跨步骤持久化的关键信息
工作流程:
1. 阅读下方的「当前步骤」描述
2. 使用工具执行所需操作
3. 完成后调用 advance_step(summary=...) 推进到下一步
4. 最后一步完成后,直接回复简要总结(不调用工具)即可结束
环境信息:
- 工作目录是独立的项目工作区Python venv 已预先激活(.venv/
- 使用 `uv add <包名>``pip install <包名>` 安装依赖
- 静态文件访问:/api/projects/{project_id}/files/{filename}
- 后台服务访问:/api/projects/{project_id}/app/(启动命令需监听 0.0.0.0:$PORT
- 【重要】应用通过反向代理访问,前端 HTML/JS 中的 fetch/XHR 请求必须使用相对路径(如 fetch('todos')),绝对不能用 / 开头的路径(如 fetch('/todos')),否则会 404
- 知识库工具kb_search(query) 搜索相关片段kb_read() 读取全文
请使用中文回复。

32
src/prompts/feedback.md Normal file
View File

@ -0,0 +1,32 @@
# 用户反馈处理
你是项目 `{project_id}` 的 AI 执行引擎。用户对当前执行计划提交了反馈。
## 你的任务
1. 分析用户反馈的意图
2. 决定是否需要修改计划
## 当前计划
{plan_state}
## 用户反馈
{feedback}
## 工具
- **revise_plan**:修改执行计划。提供完整的步骤列表(包括不需要修改的步骤)。
- 已完成且不需要重做的步骤:保持 title 和 description 不变
- 需要重做的步骤:修改 description 以反映新需求
- 系统自动处理缓存description 未变的已完成步骤保留成果,**第一个 description 变化的步骤及其后续所有步骤**会重新执行
- 你也可以增删步骤
- 如果反馈只是补充信息、不需要改计划,直接用文字回复即可(不调用工具)
## 规则
- 不要为了强制重跑而无意义地改 description。只在执行内容真正需要调整时才改
- 可以在 description 中融入反馈信息,让执行步骤能看到用户的补充说明
- 如果用户的反馈改变了整体方向,大胆重新规划

28
src/prompts/planning.md Normal file
View File

@ -0,0 +1,28 @@
你是一个 AI 智能体,正处于【规划阶段】。你拥有一个独立的工作区目录。
你的任务:
1. 仔细分析用户的需求
2. 使用 list_files 和 read_file 检查工作区的现有状态
3. 制定一个高层执行计划,调用 update_plan 提交
计划要求:
- 每个步骤应是一个逻辑阶段(如"搭建环境"、"实现后端 API"),而非具体命令
- 每个步骤包含简短标题和详细描述
- 步骤数量合理(通常 3-8 步)
调用 update_plan 后,系统会自动进入执行阶段。
环境信息:
- 工作目录是独立的项目工作区Python venv 已预先激活(.venv/
- 可用工具bash、git、curl、uv
- 静态文件访问:/api/projects/{project_id}/files/{filename}
- 后台服务访问:/api/projects/{project_id}/app/(反向代理,路径会被转发到应用的 /
【重要】反向代理注意事项:
- 用户通过 /api/projects/{project_id}/app/ 访问应用,请求被代理到应用的 / 路径
- 因此前端 HTML 中的所有 API 请求必须使用【不带开头 / 的相对路径】
- 正确示例fetch('todos') 或 fetch('./todos') 错误示例fetch('/todos') 或 fetch('/api/todos')
- HTML 中的 <base> 标签不需要设置,只要不用绝对路径就行
- 知识库工具kb_search(query) 搜索相关片段kb_read() 读取全文
请使用中文回复。

14
src/prompts/report.md Normal file
View File

@ -0,0 +1,14 @@
你是一个技术报告撰写者。请生成一份简洁的 Markdown 报告,总结工作流的执行结果。
报告应包含:
1. 标题和简要总结
2. 关键结果和产出(从步骤输出中提取重要信息)
3. 如果启动了 Web 应用/服务start_service在报告顶部醒目标出应用访问地址`/api/projects/{project_id}/app/`
4. 生成的文件(如果有),引用地址为:`/api/projects/{project_id}/files/{filename}`
5. 遇到的问题(如果有步骤失败)
格式要求:
- 简洁明了,重点是结果而非过程
- 使用 Markdown 格式(标题、代码块、表格、列表)
- 需要可视化时,使用 ```mermaid 代码块绘制 Mermaid 图表
- 使用中文撰写

205
src/state.rs Normal file
View File

@ -0,0 +1,205 @@
use serde::{Deserialize, Serialize};
use crate::llm::ChatMessage;
// --- Agent phase state machine ---
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum AgentPhase {
Planning,
Executing { step: i32 },
Completed,
}
// --- Step ---
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StepStatus {
Pending,
Running,
Done,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Step {
pub order: i32,
pub title: String,
pub description: String,
pub status: StepStatus,
/// 完成后由 LLM 填入的一句话摘要
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
/// 用户针对此步骤的反馈
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub user_feedbacks: Vec<String>,
#[serde(default)]
pub db_id: String,
}
// --- Core state ---
/// Agent 运行时的完整状态。整个结构体可以 JSON 序列化后直接存 DB。
///
/// 同时也是构建 LLM API call messages 的数据源:
///
/// Planning 阶段:
/// [ system(planning_prompt), user(requirement), ...current_step_chat_history ]
///
/// Executing 阶段:
/// [ system(execution_prompt), user(step_context), ...current_step_chat_history ]
///
/// step_context = requirement + plan 概览 + 当前步骤详情 + 已完成摘要 + scratchpad
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentState {
/// 当前阶段
pub phase: AgentPhase,
/// LLM 生成的执行计划
pub steps: Vec<Step>,
/// 当前步骤内的多轮对话历史assistant + tool result
/// 直接 extend 到 messages 尾部。在 step 切换时 clear。
pub current_step_chat_history: Vec<ChatMessage>,
/// LLM 的跨步骤工作区,由 agent 自己读写step 切换时保留
pub scratchpad: String,
}
impl AgentState {
pub fn new() -> Self {
Self {
phase: AgentPhase::Planning,
steps: Vec::new(),
current_step_chat_history: Vec::new(),
scratchpad: String::new(),
}
}
/// 当前正在执行的步骤号Planning/Completed 时返回 0。
pub fn current_step(&self) -> i32 {
match &self.phase {
AgentPhase::Executing { step } => *step,
_ => 0,
}
}
/// Docker-build-cache 风格的 plan diff。
/// 比较 (title, description)user_feedbacks 不参与比较。
/// 第一个 mismatch 开始,该步骤及后续全部 invalidate → Pending。
pub fn apply_plan_diff(&mut self, new_steps: Vec<Step>) {
let old = &self.steps;
let mut result = Vec::new();
let mut invalidated = false;
for (i, new) in new_steps.into_iter().enumerate() {
if !invalidated {
if let Some(old_step) = old.get(i) {
if old_step.title == new.title && old_step.description == new.description {
// Cache hit: keep old status/summary, take new user_feedbacks
result.push(Step {
user_feedbacks: new.user_feedbacks,
..old_step.clone()
});
continue;
}
}
// Cache miss or new step — invalidate from here
invalidated = true;
}
result.push(Step {
status: StepStatus::Pending,
summary: None,
..new
});
}
self.steps = result;
}
/// 找到第一个需要执行的步骤 (Pending 或 Running)。
/// 全部 Done 时返回 None。
pub fn first_actionable_step(&self) -> Option<i32> {
self.steps.iter()
.find(|s| matches!(s.status, StepStatus::Pending | StepStatus::Running))
.map(|s| s.order)
}
/// 构建 Executing 阶段的 user message
/// requirement + plan 概览 + 当前步骤详情 + 已完成摘要 + scratchpad
pub fn build_step_context(&self, requirement: &str) -> String {
let mut ctx = String::new();
// 需求
ctx.push_str("## 需求\n");
ctx.push_str(requirement);
ctx.push_str("\n\n");
// 计划概览
ctx.push_str("## 计划概览\n");
let cur = self.current_step();
for s in &self.steps {
let marker = match s.status {
StepStatus::Done => " done",
StepStatus::Running => " >> current",
StepStatus::Failed => " FAILED",
StepStatus::Pending => "",
};
ctx.push_str(&format!("{}. {}{}\n", s.order, s.title, marker));
}
ctx.push('\n');
// 当前步骤详情
if let Some(s) = self.steps.iter().find(|s| s.order == cur) {
ctx.push_str(&format!("## 当前步骤(步骤 {}\n", cur));
ctx.push_str(&format!("标题:{}\n", s.title));
ctx.push_str(&format!("描述:{}\n", s.description));
if !s.user_feedbacks.is_empty() {
ctx.push_str("\n用户反馈:\n");
for fb in &s.user_feedbacks {
ctx.push_str(&format!("- {}\n", fb));
}
}
ctx.push('\n');
}
// 已完成步骤摘要
let done: Vec<_> = self.steps.iter()
.filter(|s| matches!(s.status, StepStatus::Done))
.collect();
if !done.is_empty() {
ctx.push_str("## 已完成步骤摘要\n");
for s in done {
let summary = s.summary.as_deref().unwrap_or("(no summary)");
ctx.push_str(&format!("- 步骤 {}: {}\n", s.order, summary));
}
ctx.push('\n');
}
// 备忘录
if !self.scratchpad.is_empty() {
ctx.push_str("## 备忘录\n");
ctx.push_str(&self.scratchpad);
ctx.push('\n');
}
ctx
}
/// 构建传给 LLM 的完整 messages 数组。
pub fn build_messages(&self, system_prompt: &str, requirement: &str) -> Vec<ChatMessage> {
let mut msgs = vec![ChatMessage::system(system_prompt)];
match &self.phase {
AgentPhase::Planning => {
msgs.push(ChatMessage::user(requirement));
}
AgentPhase::Executing { .. } => {
msgs.push(ChatMessage::user(&self.build_step_context(requirement)));
}
AgentPhase::Completed => {}
}
msgs.extend(self.current_step_chat_history.clone());
msgs
}
}

View File

@ -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' const BASE = '/api'
@ -44,7 +44,7 @@ export const api = {
}), }),
listSteps: (workflowId: string) => listSteps: (workflowId: string) =>
request<PlanStep[]>(`/workflows/${workflowId}/steps`), request<ExecutionLogEntry[]>(`/workflows/${workflowId}/steps`),
listComments: (workflowId: string) => listComments: (workflowId: string) =>
request<Comment[]>(`/workflows/${workflowId}/comments`), request<Comment[]>(`/workflows/${workflowId}/comments`),

View File

@ -1,10 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue' import { ref, computed, watch, nextTick } from 'vue'
import type { PlanStep, Comment } from '../types' import type { ExecutionLogEntry, Comment } from '../types'
const props = defineProps<{ const props = defineProps<{
steps: PlanStep[] entries: ExecutionLogEntry[]
planSteps: PlanStep[]
comments: Comment[] comments: Comment[]
requirement: string requirement: string
createdAt: string createdAt: string
@ -12,15 +11,6 @@ const props = defineProps<{
workflowId: string 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 scrollContainer = ref<HTMLElement | null>(null)
const userScrolledUp = ref(false) const userScrolledUp = ref(false)
@ -30,13 +20,13 @@ function onScroll() {
userScrolledUp.value = el.scrollTop + el.clientHeight < el.scrollHeight - 80 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) { function toggleEntry(id: string) {
if (expandedSteps.value.has(id)) { if (expandedEntries.value.has(id)) {
expandedSteps.value.delete(id) expandedEntries.value.delete(id)
} else { } 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 id: string
type: 'requirement' | 'step' | 'comment' | 'report' type: 'requirement' | 'entry' | 'comment' | 'report'
time: string time: string
step?: PlanStep entry?: ExecutionLogEntry
text?: string text?: string
} }
const logEntries = computed(() => { const logItems = computed(() => {
const entries: LogEntry[] = [] const items: LogItem[] = []
// Requirement
if (props.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 e of props.entries) {
for (const step of props.steps) { items.push({ id: e.id, type: 'entry', entry: e, time: e.created_at || '' })
entries.push({ id: step.id, type: 'step', step, time: step.created_at || '' })
} }
// Comments
for (const c of props.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 items.sort((a, b) => {
entries.sort((a, b) => {
if (!a.time && !b.time) return 0 if (!a.time && !b.time) return 0
if (!a.time) return -1 if (!a.time) return -1
if (!b.time) return 1 if (!b.time) return 1
return a.time.localeCompare(b.time) 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')) { if (props.workflowId && (props.workflowStatus === 'done' || props.workflowStatus === 'failed')) {
const result: LogEntry[] = [] const result: LogItem[] = []
let lastWasStep = false let lastWasEntry = false
for (const entry of entries) { for (const item of items) {
if (entry.type === 'step') { if (item.type === 'entry') {
lastWasStep = true lastWasEntry = true
} else if (lastWasStep && (entry.type === 'comment' || entry.type === 'requirement')) { } else if (lastWasEntry && (item.type === 'comment' || item.type === 'requirement')) {
// Insert report link before this comment/requirement
result.push({ id: `report-${result.length}`, type: 'report', time: '' }) result.push({ id: `report-${result.length}`, type: 'report', time: '' })
lastWasStep = false lastWasEntry = false
} else { } else {
lastWasStep = false lastWasEntry = false
} }
result.push(entry) result.push(item)
} }
// Final report link at the end if last entry was a step if (lastWasEntry) {
if (lastWasStep) {
result.push({ id: 'report-final', type: 'report', time: '' }) result.push({ id: 'report-final', type: 'report', time: '' })
} }
return result return result
} }
return entries return items
}) })
watch(logEntries, () => { watch(logItems, () => {
if (userScrolledUp.value) return if (userScrolledUp.value) return
nextTick(() => { nextTick(() => {
const el = scrollContainer.value const el = scrollContainer.value
@ -134,37 +128,38 @@ watch(logEntries, () => {
<h2>日志</h2> <h2>日志</h2>
</div> </div>
<div class="exec-list"> <div class="exec-list">
<template v-for="entry in logEntries" :key="entry.id"> <template v-for="item in logItems" :key="item.id">
<!-- User message (requirement or comment) --> <!-- User message -->
<div v-if="entry.type === 'requirement' || entry.type === 'comment'" class="log-user"> <div v-if="item.type === 'requirement' || item.type === 'comment'" class="log-user">
<span class="log-time" v-if="entry.time">{{ formatTime(entry.time) }}</span> <span class="log-time" v-if="item.time">{{ formatTime(item.time) }}</span>
<span class="log-tag">{{ entry.type === 'requirement' ? '需求' : '反馈' }}</span> <span class="log-tag">{{ item.type === 'requirement' ? '需求' : '反馈' }}</span>
<span class="log-text">{{ entry.text }}</span> <span class="log-text">{{ item.text }}</span>
</div> </div>
<!-- Report link --> <!-- 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> <a :href="'/report/' + workflowId" class="report-link" target="_blank">查看报告 </a>
</div> </div>
<!-- Step --> <!-- Execution log entry -->
<div v-else-if="entry.step" class="exec-item" :class="entry.step.status"> <div v-else-if="item.entry" class="exec-item" :class="item.entry.status">
<div class="exec-header" @click="toggleStep(entry.step!.id)"> <div class="exec-header" @click="toggleEntry(item.entry!.id)">
<span class="exec-time" v-if="entry.time">{{ formatTime(entry.time) }}</span> <span class="exec-time" v-if="item.time">{{ formatTime(item.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 v-if="item.entry.step_order > 0" class="step-badge">{{ item.entry.step_order }}</span>
<span class="exec-toggle">{{ expandedSteps.has(entry.step!.id) ? '▾' : '▸' }}</span> <span class="exec-toggle">{{ expandedEntries.has(item.entry!.id) ? '▾' : '▸' }}</span>
<span class="exec-desc">{{ entry.step.description }}</span> <span class="exec-tool">{{ toolLabel(item.entry.tool_name) }}</span>
<span class="exec-status" :class="entry.step.status">{{ statusLabel(entry.step.status) }}</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>
<div v-if="expandedSteps.has(entry.step!.id)" class="exec-detail"> <div v-if="expandedEntries.has(item.entry!.id)" class="exec-detail">
<div v-if="entry.step.command" class="exec-command"> <div v-if="item.entry.tool_input && item.entry.tool_name !== 'text_response'" class="exec-command">
<code>$ {{ entry.step.command }}</code> <code>{{ item.entry.tool_input }}</code>
</div> </div>
<pre v-if="entry.step.output">{{ entry.step.output }}</pre> <pre v-if="item.entry.output">{{ item.entry.output }}</pre>
</div> </div>
</div> </div>
</template> </template>
<div v-if="!steps.length && !requirement" class="empty-state"> <div v-if="!entries.length && !requirement" class="empty-state">
提交需求后日志将显示在这里 提交需求后日志将显示在这里
</div> </div>
</div> </div>
@ -269,10 +264,20 @@ watch(logEntries, () => {
flex-shrink: 0; flex-shrink: 0;
} }
.exec-tool {
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
flex-shrink: 0;
}
.exec-desc { .exec-desc {
color: var(--text-primary); color: var(--text-primary);
font-weight: 500; font-weight: 500;
flex: 1; flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.exec-status { .exec-status {
@ -285,7 +290,6 @@ watch(logEntries, () => {
.exec-status.done { background: var(--success); color: #fff; } .exec-status.done { background: var(--success); color: #fff; }
.exec-status.running { background: var(--accent); color: #fff; } .exec-status.running { background: var(--accent); color: #fff; }
.exec-status.failed { background: var(--error); color: #fff; } .exec-status.failed { background: var(--error); color: #fff; }
.exec-status.pending { background: var(--pending); color: #fff; }
.exec-detail { .exec-detail {
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
@ -301,6 +305,8 @@ watch(logEntries, () => {
font-family: 'JetBrains Mono', 'Fira Code', monospace; font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 12px; font-size: 12px;
color: var(--accent); color: var(--accent);
white-space: pre-wrap;
word-break: break-all;
} }
.exec-detail pre { .exec-detail pre {

View File

@ -1,22 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import type { PlanStep } from '../types' import type { PlanStepInfo } from '../types'
defineProps<{ defineProps<{
steps: PlanStep[] steps: PlanStepInfo[]
}>() }>()
const expandedSteps = ref<Set<string>>(new Set()) const expandedSteps = ref<Set<number>>(new Set())
function toggleStep(id: string) { function toggleStep(order: number) {
if (expandedSteps.value.has(id)) { if (expandedSteps.value.has(order)) {
expandedSteps.value.delete(id) expandedSteps.value.delete(order)
} else { } else {
expandedSteps.value.add(id) expandedSteps.value.add(order)
} }
} }
function statusIcon(status: string) { function statusIcon(status?: string) {
switch (status) { switch (status) {
case 'done': return '✓' case 'done': return '✓'
case 'running': return '⟳' case 'running': return '⟳'
@ -34,17 +34,17 @@ function statusIcon(status: string) {
<div class="steps-list"> <div class="steps-list">
<div <div
v-for="step in steps" v-for="step in steps"
:key="step.id" :key="step.order"
class="step-item" 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-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 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>
<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 }} {{ step.command }}
</div> </div>
</div> </div>

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <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 RequirementSection from './RequirementSection.vue'
import PlanSection from './PlanSection.vue' import PlanSection from './PlanSection.vue'
import ExecutionSection from './ExecutionSection.vue' import ExecutionSection from './ExecutionSection.vue'
@ -7,7 +7,7 @@ import CommentSection from './CommentSection.vue'
import TimerSection from './TimerSection.vue' import TimerSection from './TimerSection.vue'
import { api } from '../api' import { api } from '../api'
import { connectWs } from '../ws' import { connectWs } from '../ws'
import type { Workflow, PlanStep, Comment } from '../types' import type { Workflow, ExecutionLogEntry, PlanStepInfo, Comment } from '../types'
import type { WsMessage } from '../ws' import type { WsMessage } from '../ws'
const props = defineProps<{ const props = defineProps<{
@ -19,14 +19,12 @@ const emit = defineEmits<{
}>() }>()
const workflow = ref<Workflow | null>(null) const workflow = ref<Workflow | null>(null)
const steps = ref<PlanStep[]>([]) const logEntries = ref<ExecutionLogEntry[]>([])
const planSteps = ref<PlanStepInfo[]>([])
const comments = ref<Comment[]>([]) const comments = ref<Comment[]>([])
const error = ref('') const error = ref('')
const rightTab = ref<'log' | 'timers'>('log') 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 let wsConn: { close: () => void } | null = null
async function loadData() { async function loadData() {
@ -35,15 +33,16 @@ async function loadData() {
const latest = workflows[0] const latest = workflows[0]
if (latest) { if (latest) {
workflow.value = latest workflow.value = latest
const [s, c] = await Promise.all([ const [entries, c] = await Promise.all([
api.listSteps(latest.id), api.listSteps(latest.id),
api.listComments(latest.id), api.listComments(latest.id),
]) ])
steps.value = s logEntries.value = entries
comments.value = c comments.value = c
} else { } else {
workflow.value = null workflow.value = null
steps.value = [] logEntries.value = []
planSteps.value = []
comments.value = [] comments.value = []
} }
} catch (e: any) { } catch (e: any) {
@ -55,18 +54,18 @@ function handleWsMessage(msg: WsMessage) {
switch (msg.type) { switch (msg.type) {
case 'PlanUpdate': case 'PlanUpdate':
if (workflow.value && msg.workflow_id === workflow.value.id) { 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 break
case 'StepStatusUpdate': { case 'StepStatusUpdate': {
const idx = steps.value.findIndex(s => s.id === msg.step_id) // New execution log entry just refetch the list
const existing = steps.value[idx] if (workflow.value) {
if (existing) { api.listSteps(workflow.value.id).then(entries => { logEntries.value = entries })
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 })
}
} }
break break
} }
@ -117,7 +116,8 @@ async function onSubmitRequirement(text: string) {
try { try {
const wf = await api.createWorkflow(props.projectId, text) const wf = await api.createWorkflow(props.projectId, text)
workflow.value = wf workflow.value = wf
steps.value = [] logEntries.value = []
planSteps.value = []
comments.value = [] comments.value = []
} catch (e: any) { } catch (e: any) {
error.value = e.message error.value = e.message
@ -152,8 +152,7 @@ async function onSubmitComment(text: string) {
</div> </div>
<ExecutionSection <ExecutionSection
v-show="rightTab === 'log'" v-show="rightTab === 'log'"
:steps="logSteps" :entries="logEntries"
:planSteps="planSteps"
:comments="comments" :comments="comments"
:requirement="workflow?.requirement || ''" :requirement="workflow?.requirement || ''"
:createdAt="workflow?.created_at || ''" :createdAt="workflow?.created_at || ''"

View File

@ -15,17 +15,22 @@ export interface Workflow {
report: string report: string
} }
export interface PlanStep { export interface ExecutionLogEntry {
id: string id: string
workflow_id: string workflow_id: string
step_order: number 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 description: string
command: string command: string
status: 'pending' | 'running' | 'done' | 'failed' status?: 'pending' | 'running' | 'done' | 'failed'
output: string
created_at: string
kind: 'plan' | 'log'
plan_step_id: string
} }
export interface Comment { export interface Comment {

View File

@ -1,7 +1,7 @@
export interface WsPlanUpdate { export interface WsPlanUpdate {
type: 'PlanUpdate' type: 'PlanUpdate'
workflow_id: string workflow_id: string
steps: { order: number; description: string; command: string }[] steps: { order: number; description: string; command: string; status?: string }[]
} }
export interface WsStepStatusUpdate { export interface WsStepStatusUpdate {