feat: show plan diff in execution log when revise_plan is called

- apply_plan_diff now returns a YAML unified diff string
- Pure Rust LCS diff implementation (no external dependency)
- revise_plan logs the diff to execution log with ```diff fencing
- Frontend renders diff with green/red syntax highlighting
This commit is contained in:
Fam Zheng
2026-03-10 19:03:47 +00:00
parent 978af45d5f
commit 63bbbae17c
3 changed files with 104 additions and 3 deletions

View File

@@ -1124,7 +1124,7 @@ async fn process_feedback(
}).collect(); }).collect();
// Apply docker-cache diff // Apply docker-cache diff
state.apply_plan_diff(new_steps); let diff = state.apply_plan_diff(new_steps);
// Broadcast updated plan // Broadcast updated plan
let _ = broadcast_tx.send(WsMessage::PlanUpdate { let _ = broadcast_tx.send(WsMessage::PlanUpdate {
@@ -1134,6 +1134,10 @@ async fn process_feedback(
tracing::info!("[workflow {}] Plan revised via feedback. First actionable: {:?}", tracing::info!("[workflow {}] Plan revised via feedback. First actionable: {:?}",
workflow_id, state.first_actionable_step()); workflow_id, state.first_actionable_step());
// Log the diff so frontend can show what changed
let diff_display = format!("```diff\n{}\n```", diff);
log_execution(pool, broadcast_tx, workflow_id, 0, "revise_plan", "计划变更", &diff_display, "done").await;
} }
} }
} else { } else {

View File

@@ -130,7 +130,23 @@ impl AgentState {
/// Docker-build-cache 风格的 plan diff。 /// Docker-build-cache 风格的 plan diff。
/// 比较 (title, description)user_feedbacks 不参与比较。 /// 比较 (title, description)user_feedbacks 不参与比较。
/// 第一个 mismatch 开始,该步骤及后续全部 invalidate → Pending。 /// 第一个 mismatch 开始,该步骤及后续全部 invalidate → Pending。
pub fn apply_plan_diff(&mut self, new_steps: Vec<Step>) { /// Apply docker-cache style diff. Returns a unified-diff string (YAML format)
/// showing what changed, for logging in the frontend.
pub fn apply_plan_diff(&mut self, new_steps: Vec<Step>) -> String {
// Serialize old/new plans to YAML for diff (only title + description)
let to_yaml = |steps: &[Step]| -> String {
let items: Vec<serde_json::Value> = steps.iter().map(|s| {
serde_json::json!({
"step": s.order,
"title": s.title,
"description": s.description,
})
}).collect();
serde_yaml::to_string(&items).unwrap_or_default()
};
let old_yaml = to_yaml(&self.steps);
let new_yaml = to_yaml(&new_steps);
let old = &self.steps; let old = &self.steps;
let mut result = Vec::new(); let mut result = Vec::new();
let mut invalidated = false; let mut invalidated = false;
@@ -158,6 +174,9 @@ impl AgentState {
} }
self.steps = result; self.steps = result;
// Generate unified diff
diff_strings(&old_yaml, &new_yaml)
} }
/// 找到第一个需要执行的步骤 (Pending 或 Running)。 /// 找到第一个需要执行的步骤 (Pending 或 Running)。
@@ -255,6 +274,51 @@ impl AgentState {
} }
} }
/// Simple line-by-line unified diff (no external dependency).
/// Uses longest common subsequence to produce a clean diff.
fn diff_strings(old: &str, new: &str) -> String {
let old_lines: Vec<&str> = old.lines().collect();
let new_lines: Vec<&str> = new.lines().collect();
if old_lines == new_lines {
return String::from("(no changes)");
}
// LCS table
let m = old_lines.len();
let n = new_lines.len();
let mut dp = vec![vec![0u32; n + 1]; m + 1];
for i in 1..=m {
for j in 1..=n {
dp[i][j] = if old_lines[i - 1] == new_lines[j - 1] {
dp[i - 1][j - 1] + 1
} else {
dp[i - 1][j].max(dp[i][j - 1])
};
}
}
// Backtrack to produce diff lines
let mut result = Vec::new();
let (mut i, mut j) = (m, n);
while i > 0 || j > 0 {
if i > 0 && j > 0 && old_lines[i - 1] == new_lines[j - 1] {
result.push(format!(" {}", old_lines[i - 1]));
i -= 1;
j -= 1;
} else if j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j]) {
result.push(format!("+{}", new_lines[j - 1]));
j -= 1;
} else {
result.push(format!("-{}", old_lines[i - 1]));
i -= 1;
}
}
result.reverse();
result.join("\n")
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@@ -131,6 +131,25 @@ function formatLatency(ms: number): string {
return ms + 'ms' return ms + 'ms'
} }
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function isDiffOutput(output: string): boolean {
return output.startsWith('```diff\n')
}
function renderDiff(output: string): string {
// Strip ```diff fences
const inner = output.replace(/^```diff\n/, '').replace(/\n```$/, '')
return inner.split('\n').map(line => {
const esc = escapeHtml(line)
if (line.startsWith('+')) return `<span class="diff-add">${esc}</span>`
if (line.startsWith('-')) return `<span class="diff-del">${esc}</span>`
return esc
}).join('\n')
}
function parseToolCalls(json: string): { name: string; arguments_preview: string }[] { function parseToolCalls(json: string): { name: string; arguments_preview: string }[] {
try { try {
return JSON.parse(json) return JSON.parse(json)
@@ -275,7 +294,7 @@ watch(logItems, () => {
<div v-if="item.entry.tool_input && item.entry.tool_name !== 'text_response'" class="exec-command"> <div v-if="item.entry.tool_input && item.entry.tool_name !== 'text_response'" class="exec-command">
<code>{{ item.entry.tool_input }}</code> <code>{{ item.entry.tool_input }}</code>
</div> </div>
<pre v-if="item.entry.output">{{ item.entry.output }}</pre> <pre v-if="item.entry.output" :class="{ 'diff-output': isDiffOutput(item.entry.output) }" v-html="isDiffOutput(item.entry.output) ? renderDiff(item.entry.output) : escapeHtml(item.entry.output)"></pre>
</div> </div>
</div> </div>
</template> </template>
@@ -665,4 +684,18 @@ watch(logItems, () => {
font-family: 'JetBrains Mono', 'Fira Code', monospace; font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 10px; font-size: 10px;
} }
pre.diff-output .diff-add {
color: #22c55e;
background: rgba(34, 197, 94, 0.1);
display: inline-block;
width: 100%;
}
pre.diff-output .diff-del {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
display: inline-block;
width: 100%;
}
</style> </style>