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:
442
doc/context.md
442
doc/context.md
@@ -1,392 +1,86 @@
|
||||
# Agent Runtime 设计
|
||||
# Agent Context 构建
|
||||
|
||||
## Context 管理现状与设计
|
||||
AgentState 如何变成 LLM API call 的 messages 数组。定义见 `src/state.rs`。
|
||||
|
||||
## 现状
|
||||
|
||||
当前没有做 context 长度限制,存在超过 model token limit 的风险。
|
||||
|
||||
### 已有的缓解机制
|
||||
|
||||
1. **Phase transition 时 clear**:`step_messages` 在 planning→executing 和 step→step 切换时会 `clear()`,避免跨阶段累积
|
||||
2. **单条 tool output 截断**:bash 输出限制 8000 bytes,read_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 之前,插入压缩逻辑:
|
||||
## AgentState
|
||||
|
||||
```rust
|
||||
// agent.rs run_agent_loop 内,约 L706-L725
|
||||
let (mut messages, tools) = match &state.phase { ... };
|
||||
|
||||
// 压缩 context
|
||||
compact_messages(&mut messages, MAX_CONTEXT_CHARS);
|
||||
```
|
||||
|
||||
`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 steps(update/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 running(WS 推送)
|
||||
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, // 中断信号
|
||||
struct AgentState {
|
||||
phase: AgentPhase, // Planning | Executing { step } | Completed
|
||||
steps: Vec<Step>, // 执行计划,每个 step 有 status + optional summary
|
||||
current_step_chat_history: Vec<ChatMessage>, // 当前步骤内的多轮对话,step 切换时 clear
|
||||
scratchpad: String, // LLM 的跨步骤工作区
|
||||
}
|
||||
```
|
||||
|
||||
用 `Arc<RwLock<ProjectState>>` 持有。
|
||||
整个结构体 `Serialize/Deserialize`,可 JSON 直接存 DB。
|
||||
|
||||
#### 单写者规则
|
||||
|
||||
**只有 agent loop 修改 `ProjectState`。** 没有例外。
|
||||
|
||||
其他所有参与者(反馈分类器、API handler、WS handler)都是读者或事件发送者:
|
||||
|
||||
- 想看状态 → read lock,clone 出快照,立即释放
|
||||
- 想改状态 → 发 event 到 agent loop 的 channel,由 agent loop 决定怎么改
|
||||
|
||||
这彻底消除了写-写竞争。不需要事务,不需要 CAS,不需要冲突解决。
|
||||
|
||||
#### 数据流
|
||||
## Step 生命周期
|
||||
|
||||
```
|
||||
用户 comment ─→ API handler ─→ mpsc channel ─→ agent loop (唯一写者)
|
||||
│
|
||||
write lock ──→ ProjectState
|
||||
│
|
||||
write DB (flush)
|
||||
│
|
||||
broadcast WS ──→ 前端
|
||||
Pending → Running → Done(summary) 或 Failed
|
||||
```
|
||||
|
||||
### 一致性分析:三对关系
|
||||
summary 在 step 完成时由 LLM 填入,作为后续步骤的压缩上下文。
|
||||
|
||||
#### 1. 内存 ↔ DB 一致性
|
||||
## Messages 组装
|
||||
|
||||
当前问题:`AgentState`(内存)和 `plan_steps`(DB)是两套独立数据,各管各的。
|
||||
### Planning 阶段
|
||||
|
||||
**解法:内存是 master,DB 是 checkpoint。**
|
||||
```
|
||||
[ system(planning_prompt), user(requirement), ...current_step_chat_history ]
|
||||
```
|
||||
|
||||
- Agent loop 修改 `ProjectState`(内存),在关键节点 flush 到 DB
|
||||
- 前端 API 读内存(read lock),不读 DB(运行时)
|
||||
- DB 只在重启恢复时读
|
||||
### Executing 阶段
|
||||
|
||||
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 变更 | 立即 | 重启恢复必需 |
|
||||
| plan 变更(新建/修改步骤) | 立即 | 重启恢复 + 持久记录 |
|
||||
| 步骤完成(summary 产出) | 立即 | 重启恢复的关键数据 |
|
||||
| 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 策略** — 关键节点写 DB(status 变更、步骤完成),前端 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。
|
||||
| 高 | system prompt / requirement / plan 概览 | 不压缩 |
|
||||
| 中 | 当前步骤最近 N 轮 tool call | 完整保留 |
|
||||
| 低 | 当前步骤早期 tool call | 替换为摘要 |
|
||||
|
||||
842
src/agent.rs
842
src/agent.rs
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@ use axum::{
|
||||
use serde::Deserialize;
|
||||
use crate::AppState;
|
||||
use crate::agent::AgentEvent;
|
||||
use crate::db::{Workflow, PlanStep, Comment};
|
||||
use crate::db::{Workflow, ExecutionLogEntry, Comment};
|
||||
use super::{ApiResult, db_err};
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
@@ -77,9 +77,9 @@ async fn create_workflow(
|
||||
async fn list_steps(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(workflow_id): Path<String>,
|
||||
) -> ApiResult<Vec<PlanStep>> {
|
||||
sqlx::query_as::<_, PlanStep>(
|
||||
"SELECT * FROM plan_steps WHERE workflow_id = ? ORDER BY step_order"
|
||||
) -> ApiResult<Vec<ExecutionLogEntry>> {
|
||||
sqlx::query_as::<_, ExecutionLogEntry>(
|
||||
"SELECT * FROM execution_log WHERE workflow_id = ? ORDER BY created_at"
|
||||
)
|
||||
.bind(&workflow_id)
|
||||
.fetch_all(&state.db.pool)
|
||||
|
||||
73
src/db.rs
73
src/db.rs
@@ -41,20 +41,6 @@ impl Database {
|
||||
.execute(&self.pool)
|
||||
.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(
|
||||
"CREATE TABLE IF NOT EXISTS comments (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -73,27 +59,6 @@ impl Database {
|
||||
.execute(&self.pool)
|
||||
.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
|
||||
let _ = sqlx::query(
|
||||
"ALTER TABLE projects ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0"
|
||||
@@ -165,6 +130,34 @@ impl Database {
|
||||
.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(
|
||||
"CREATE TABLE IF NOT EXISTS timers (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -206,17 +199,15 @@ pub struct Workflow {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct PlanStep {
|
||||
pub struct ExecutionLogEntry {
|
||||
pub id: String,
|
||||
pub workflow_id: String,
|
||||
pub step_order: i32,
|
||||
pub description: String,
|
||||
pub command: String,
|
||||
pub status: String,
|
||||
pub tool_name: String,
|
||||
pub tool_input: String,
|
||||
pub output: String,
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
pub kind: String,
|
||||
pub plan_step_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
|
||||
@@ -4,6 +4,7 @@ mod db;
|
||||
mod kb;
|
||||
mod llm;
|
||||
mod exec;
|
||||
mod state;
|
||||
mod timer;
|
||||
mod ws;
|
||||
|
||||
|
||||
25
src/prompts/execution.md
Normal file
25
src/prompts/execution.md
Normal 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
32
src/prompts/feedback.md
Normal 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
28
src/prompts/planning.md
Normal 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
14
src/prompts/report.md
Normal 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
205
src/state.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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