refactor: rename wait_for_approval to ask_user
More general-purpose user intervention tool — not just approve/reject, but any question or input request. Renames across Rust backend, Vue frontend, prompts, and status strings. Tool: wait_for_approval → ask_user (param: reason → question) Status: WaitingApproval → WaitingUser, waiting_approval → waiting_user Enum: NeedsApproval → NeedsInput
This commit is contained in:
parent
dae99d307a
commit
f2fa721ef0
35
Cargo.lock
generated
35
Cargo.lock
generated
@ -496,6 +496,15 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getopts"
|
||||||
|
version = "0.2.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-width",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@ -1217,6 +1226,25 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pulldown-cmark"
|
||||||
|
version = "0.12.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"getopts",
|
||||||
|
"memchr",
|
||||||
|
"pulldown-cmark-escape",
|
||||||
|
"unicase",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pulldown-cmark-escape"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn"
|
name = "quinn"
|
||||||
version = "0.11.9"
|
version = "0.11.9"
|
||||||
@ -2081,6 +2109,7 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"nix",
|
"nix",
|
||||||
|
"pulldown-cmark",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@ -2274,6 +2303,12 @@ version = "0.1.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-xid"
|
name = "unicode-xid"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
|
|||||||
@ -27,3 +27,4 @@ anyhow = "1"
|
|||||||
mime_guess = "2"
|
mime_guess = "2"
|
||||||
tokio-util = { version = "0.7", features = ["io"] }
|
tokio-util = { version = "0.7", features = ["io"] }
|
||||||
nix = { version = "0.29", features = ["signal"] }
|
nix = { version = "0.29", features = ["signal"] }
|
||||||
|
pulldown-cmark = "0.12"
|
||||||
|
|||||||
56
src/agent.rs
56
src/agent.rs
@ -56,7 +56,7 @@ pub fn plan_infos_from_state(state: &AgentState) -> Vec<PlanStepInfo> {
|
|||||||
let status = match s.status {
|
let status = match s.status {
|
||||||
StepStatus::Pending => "pending",
|
StepStatus::Pending => "pending",
|
||||||
StepStatus::Running => "running",
|
StepStatus::Running => "running",
|
||||||
StepStatus::WaitingApproval => "waiting_approval",
|
StepStatus::WaitingUser => "waiting_user",
|
||||||
StepStatus::Done => "done",
|
StepStatus::Done => "done",
|
||||||
StepStatus::Failed => "failed",
|
StepStatus::Failed => "failed",
|
||||||
};
|
};
|
||||||
@ -428,18 +428,18 @@ async fn agent_loop(
|
|||||||
.and_then(|json| serde_json::from_str::<AgentState>(&json).ok())
|
.and_then(|json| serde_json::from_str::<AgentState>(&json).ok())
|
||||||
.unwrap_or_else(AgentState::new);
|
.unwrap_or_else(AgentState::new);
|
||||||
|
|
||||||
// Resume directly if: workflow is failed/done/waiting_approval,
|
// Resume directly if: workflow is failed/done/waiting_user,
|
||||||
// OR if state snapshot has a WaitingApproval step (e.g. after pod restart)
|
// OR if state snapshot has a WaitingUser step (e.g. after pod restart)
|
||||||
let has_waiting_step = state.steps.iter().any(|s| matches!(s.status, StepStatus::WaitingApproval));
|
let has_waiting_step = state.steps.iter().any(|s| matches!(s.status, StepStatus::WaitingUser));
|
||||||
let is_resuming = wf.status == "failed" || wf.status == "done"
|
let is_resuming = wf.status == "failed" || wf.status == "done"
|
||||||
|| wf.status == "waiting_approval" || has_waiting_step;
|
|| wf.status == "waiting_user" || has_waiting_step;
|
||||||
if is_resuming {
|
if is_resuming {
|
||||||
// Reset Failed/WaitingApproval steps so they get re-executed
|
// Reset Failed/WaitingUser steps so they get re-executed
|
||||||
for step in &mut state.steps {
|
for step in &mut state.steps {
|
||||||
if matches!(step.status, StepStatus::Failed) {
|
if matches!(step.status, StepStatus::Failed) {
|
||||||
step.status = StepStatus::Pending;
|
step.status = StepStatus::Pending;
|
||||||
}
|
}
|
||||||
if matches!(step.status, StepStatus::WaitingApproval) {
|
if matches!(step.status, StepStatus::WaitingUser) {
|
||||||
// Mark as Running so it continues (not re-plans)
|
// Mark as Running so it continues (not re-plans)
|
||||||
step.status = StepStatus::Running;
|
step.status = StepStatus::Running;
|
||||||
}
|
}
|
||||||
@ -483,7 +483,7 @@ async fn agent_loop(
|
|||||||
}
|
}
|
||||||
state.phase = AgentPhase::Executing { step: next };
|
state.phase = AgentPhase::Executing { step: next };
|
||||||
// Only clear chat history when advancing to a new step;
|
// Only clear chat history when advancing to a new step;
|
||||||
// keep it when resuming the same step after wait_for_approval
|
// keep it when resuming the same step after ask_user
|
||||||
if !was_same_step {
|
if !was_same_step {
|
||||||
state.current_step_chat_history.clear();
|
state.current_step_chat_history.clear();
|
||||||
}
|
}
|
||||||
@ -722,12 +722,12 @@ fn build_step_tools() -> Vec<Tool> {
|
|||||||
},
|
},
|
||||||
"required": ["content"]
|
"required": ["content"]
|
||||||
})),
|
})),
|
||||||
make_tool("wait_for_approval", "暂停执行,等待用户确认后继续。用于关键决策点。", serde_json::json!({
|
make_tool("ask_user", "向用户提问,暂停执行等待用户回复。用于需要用户输入、确认或决策的场景。", serde_json::json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"reason": { "type": "string", "description": "说明为什么需要用户确认" }
|
"question": { "type": "string", "description": "要向用户提出的问题或需要确认的内容" }
|
||||||
},
|
},
|
||||||
"required": ["reason"]
|
"required": ["question"]
|
||||||
})),
|
})),
|
||||||
make_tool("step_done", "完成当前步骤。必须提供摘要和产出物列表(无产出物时传空数组)。", serde_json::json!({
|
make_tool("step_done", "完成当前步骤。必须提供摘要和产出物列表(无产出物时传空数组)。", serde_json::json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@ -827,7 +827,7 @@ fn build_feedback_prompt(project_id: &str, state: &AgentState, feedback: &str) -
|
|||||||
let status = match s.status {
|
let status = match s.status {
|
||||||
StepStatus::Done => " [done]",
|
StepStatus::Done => " [done]",
|
||||||
StepStatus::Running => " [running]",
|
StepStatus::Running => " [running]",
|
||||||
StepStatus::WaitingApproval => " [waiting]",
|
StepStatus::WaitingUser => " [waiting]",
|
||||||
StepStatus::Failed => " [FAILED]",
|
StepStatus::Failed => " [FAILED]",
|
||||||
StepStatus::Pending => "",
|
StepStatus::Pending => "",
|
||||||
};
|
};
|
||||||
@ -1326,8 +1326,8 @@ async fn run_step_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
"wait_for_approval" => {
|
"ask_user" => {
|
||||||
let reason = args["reason"].as_str().unwrap_or("等待确认");
|
let reason = args["question"].as_str().unwrap_or("等待确认");
|
||||||
let _ = broadcast_tx.send(WsMessage::ActivityUpdate {
|
let _ = broadcast_tx.send(WsMessage::ActivityUpdate {
|
||||||
workflow_id: workflow_id.to_string(),
|
workflow_id: workflow_id.to_string(),
|
||||||
activity: format!("步骤 {} — 等待用户确认: {}", step_order, reason),
|
activity: format!("步骤 {} — 等待用户确认: {}", step_order, reason),
|
||||||
@ -1336,18 +1336,18 @@ async fn run_step_loop(
|
|||||||
// Broadcast waiting status
|
// Broadcast waiting status
|
||||||
let _ = broadcast_tx.send(WsMessage::PlanUpdate {
|
let _ = broadcast_tx.send(WsMessage::PlanUpdate {
|
||||||
workflow_id: workflow_id.to_string(),
|
workflow_id: workflow_id.to_string(),
|
||||||
steps: plan_infos_from_state_with_override(step_order, "waiting_approval",
|
steps: plan_infos_from_state_with_override(step_order, "waiting_user",
|
||||||
pool, workflow_id).await,
|
pool, workflow_id).await,
|
||||||
});
|
});
|
||||||
let _ = broadcast_tx.send(WsMessage::WorkflowStatusUpdate {
|
let _ = broadcast_tx.send(WsMessage::WorkflowStatusUpdate {
|
||||||
workflow_id: workflow_id.to_string(),
|
workflow_id: workflow_id.to_string(),
|
||||||
status: "waiting_approval".into(),
|
status: "waiting_user".into(),
|
||||||
});
|
});
|
||||||
let _ = sqlx::query("UPDATE workflows SET status = 'waiting_approval' WHERE id = ?")
|
let _ = sqlx::query("UPDATE workflows SET status = 'waiting_user' WHERE id = ?")
|
||||||
.bind(workflow_id)
|
.bind(workflow_id)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await;
|
.await;
|
||||||
log_execution(pool, broadcast_tx, workflow_id, step_order, "wait_for_approval", reason, reason, "waiting").await;
|
log_execution(pool, broadcast_tx, workflow_id, step_order, "ask_user", reason, reason, "waiting").await;
|
||||||
|
|
||||||
tracing::info!("[workflow {}] Step {} waiting for approval: {}", workflow_id, step_order, reason);
|
tracing::info!("[workflow {}] Step {} waiting for approval: {}", workflow_id, step_order, reason);
|
||||||
|
|
||||||
@ -1370,7 +1370,7 @@ async fn run_step_loop(
|
|||||||
|
|
||||||
if approval_content.starts_with("rejected:") {
|
if approval_content.starts_with("rejected:") {
|
||||||
let reason = approval_content.strip_prefix("rejected:").unwrap_or("").trim();
|
let reason = approval_content.strip_prefix("rejected:").unwrap_or("").trim();
|
||||||
log_execution(pool, broadcast_tx, workflow_id, step_order, "wait_for_approval", "rejected", reason, "failed").await;
|
log_execution(pool, broadcast_tx, workflow_id, step_order, "ask_user", "rejected", reason, "failed").await;
|
||||||
step_chat_history.push(ChatMessage::tool_result(&tc.id, &format!("用户拒绝: {}", reason)));
|
step_chat_history.push(ChatMessage::tool_result(&tc.id, &format!("用户拒绝: {}", reason)));
|
||||||
step_done_result = Some(StepResult {
|
step_done_result = Some(StepResult {
|
||||||
status: StepResultStatus::Failed { error: format!("用户终止: {}", reason) },
|
status: StepResultStatus::Failed { error: format!("用户终止: {}", reason) },
|
||||||
@ -1582,7 +1582,7 @@ async fn run_step_loop(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Helper to get plan step infos with a status override for a specific step.
|
/// Helper to get plan step infos with a status override for a specific step.
|
||||||
/// Used during wait_for_approval in the step sub-loop where we don't have
|
/// Used during ask_user in the step sub-loop where we don't have
|
||||||
/// mutable access to the AgentState.
|
/// mutable access to the AgentState.
|
||||||
async fn plan_infos_from_state_with_override(
|
async fn plan_infos_from_state_with_override(
|
||||||
step_order: i32,
|
step_order: i32,
|
||||||
@ -1609,7 +1609,7 @@ async fn plan_infos_from_state_with_override(
|
|||||||
match s.status {
|
match s.status {
|
||||||
StepStatus::Pending => "pending",
|
StepStatus::Pending => "pending",
|
||||||
StepStatus::Running => "running",
|
StepStatus::Running => "running",
|
||||||
StepStatus::WaitingApproval => "waiting_approval",
|
StepStatus::WaitingUser => "waiting_user",
|
||||||
StepStatus::Done => "done",
|
StepStatus::Done => "done",
|
||||||
StepStatus::Failed => "failed",
|
StepStatus::Failed => "failed",
|
||||||
}.to_string()
|
}.to_string()
|
||||||
@ -1731,9 +1731,9 @@ async fn run_agent_loop(
|
|||||||
});
|
});
|
||||||
let _ = broadcast_tx.send(WsMessage::WorkflowStatusUpdate {
|
let _ = broadcast_tx.send(WsMessage::WorkflowStatusUpdate {
|
||||||
workflow_id: workflow_id.to_string(),
|
workflow_id: workflow_id.to_string(),
|
||||||
status: "waiting_approval".into(),
|
status: "waiting_user".into(),
|
||||||
});
|
});
|
||||||
let _ = sqlx::query("UPDATE workflows SET status = 'waiting_approval' WHERE id = ?")
|
let _ = sqlx::query("UPDATE workflows SET status = 'waiting_user' WHERE id = ?")
|
||||||
.bind(workflow_id)
|
.bind(workflow_id)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await;
|
.await;
|
||||||
@ -1896,11 +1896,11 @@ async fn run_agent_loop(
|
|||||||
save_state_snapshot(pool, workflow_id, step_order, &state).await;
|
save_state_snapshot(pool, workflow_id, step_order, &state).await;
|
||||||
return Err(anyhow::anyhow!("Step {} failed: {}", step_order, error));
|
return Err(anyhow::anyhow!("Step {} failed: {}", step_order, error));
|
||||||
}
|
}
|
||||||
StepResultStatus::NeedsApproval { message: _ } => {
|
StepResultStatus::NeedsInput { message: _ } => {
|
||||||
// This shouldn't normally happen since wait_for_approval is handled inside
|
// This shouldn't normally happen since ask_user is handled inside
|
||||||
// run_step_loop, but handle gracefully
|
// run_step_loop, but handle gracefully
|
||||||
if let Some(s) = state.steps.iter_mut().find(|s| s.order == step_order) {
|
if let Some(s) = state.steps.iter_mut().find(|s| s.order == step_order) {
|
||||||
s.status = StepStatus::WaitingApproval;
|
s.status = StepStatus::WaitingUser;
|
||||||
}
|
}
|
||||||
save_state_snapshot(pool, workflow_id, step_order, &state).await;
|
save_state_snapshot(pool, workflow_id, step_order, &state).await;
|
||||||
continue;
|
continue;
|
||||||
@ -1934,7 +1934,7 @@ async fn run_agent_loop(
|
|||||||
let marker = match s.status {
|
let marker = match s.status {
|
||||||
StepStatus::Done => " [done]",
|
StepStatus::Done => " [done]",
|
||||||
StepStatus::Running => " [running]",
|
StepStatus::Running => " [running]",
|
||||||
StepStatus::WaitingApproval => " [waiting]",
|
StepStatus::WaitingUser => " [waiting]",
|
||||||
StepStatus::Failed => " [FAILED]",
|
StepStatus::Failed => " [FAILED]",
|
||||||
StepStatus::Pending => "",
|
StepStatus::Pending => "",
|
||||||
};
|
};
|
||||||
@ -2238,7 +2238,7 @@ mod tests {
|
|||||||
let names: Vec<&str> = tools.iter().map(|t| t.function.name.as_str()).collect();
|
let names: Vec<&str> = tools.iter().map(|t| t.function.name.as_str()).collect();
|
||||||
for expected in &["execute", "read_file", "write_file", "list_files",
|
for expected in &["execute", "read_file", "write_file", "list_files",
|
||||||
"start_service", "stop_service", "update_scratchpad",
|
"start_service", "stop_service", "update_scratchpad",
|
||||||
"wait_for_approval", "kb_search", "kb_read"] {
|
"ask_user", "kb_search", "kb_read"] {
|
||||||
assert!(names.contains(expected), "{} must be in step tools", expected);
|
assert!(names.contains(expected), "{} must be in step tools", expected);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
111
src/api/mod.rs
111
src/api/mod.rs
@ -115,6 +115,15 @@ async fn serve_project_file(
|
|||||||
|
|
||||||
match tokio::fs::read(&full_path).await {
|
match tokio::fs::read(&full_path).await {
|
||||||
Ok(bytes) => {
|
Ok(bytes) => {
|
||||||
|
// Render markdown files as HTML
|
||||||
|
if full_path.extension().is_some_and(|e| e == "md") {
|
||||||
|
let md = String::from_utf8_lossy(&bytes);
|
||||||
|
let html = render_markdown_page(&md, &file_path);
|
||||||
|
return (
|
||||||
|
[(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8".to_string())],
|
||||||
|
html,
|
||||||
|
).into_response();
|
||||||
|
}
|
||||||
let mime = mime_guess::from_path(&full_path)
|
let mime = mime_guess::from_path(&full_path)
|
||||||
.first_or_octet_stream()
|
.first_or_octet_stream()
|
||||||
.to_string();
|
.to_string();
|
||||||
@ -127,3 +136,105 @@ async fn serve_project_file(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_markdown_page(markdown: &str, title: &str) -> String {
|
||||||
|
use pulldown_cmark::{Parser, Options, html};
|
||||||
|
let mut opts = Options::empty();
|
||||||
|
opts.insert(Options::ENABLE_TABLES);
|
||||||
|
opts.insert(Options::ENABLE_STRIKETHROUGH);
|
||||||
|
opts.insert(Options::ENABLE_TASKLISTS);
|
||||||
|
let parser = Parser::new_ext(markdown, opts);
|
||||||
|
let mut body = String::new();
|
||||||
|
html::push_html(&mut body, parser);
|
||||||
|
|
||||||
|
format!(r#"<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>{title}</title>
|
||||||
|
<style>
|
||||||
|
:root {{
|
||||||
|
--text-primary: #1a1a2e;
|
||||||
|
--text-secondary: #6b7280;
|
||||||
|
--bg-primary: #ffffff;
|
||||||
|
--bg-secondary: #f7f8fa;
|
||||||
|
--border: #e2e5ea;
|
||||||
|
--accent: #2563eb;
|
||||||
|
--error: #dc2626;
|
||||||
|
}}
|
||||||
|
body {{
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}}
|
||||||
|
.page {{
|
||||||
|
max-width: 860px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 32px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}}
|
||||||
|
.toolbar {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}}
|
||||||
|
.toolbar a {{
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}}
|
||||||
|
.toolbar a:hover {{ text-decoration: underline; }}
|
||||||
|
.toolbar .title {{
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}}
|
||||||
|
.body {{ line-height: 1.7; font-size: 15px; }}
|
||||||
|
.body h1 {{ font-size: 24px; font-weight: 700; margin: 0 0 16px; padding-bottom: 8px; border-bottom: 2px solid var(--border); }}
|
||||||
|
.body h2 {{ font-size: 20px; font-weight: 600; margin: 24px 0 12px; }}
|
||||||
|
.body h3 {{ font-size: 16px; font-weight: 600; margin: 20px 0 8px; }}
|
||||||
|
.body p {{ margin: 0 0 12px; }}
|
||||||
|
.body ul, .body ol {{ margin: 0 0 12px; padding-left: 24px; }}
|
||||||
|
.body li {{ margin-bottom: 4px; }}
|
||||||
|
.body pre {{
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}}
|
||||||
|
.body code {{ font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 13px; }}
|
||||||
|
.body :not(pre) > code {{ background: var(--bg-secondary); padding: 2px 6px; border-radius: 4px; }}
|
||||||
|
.body table {{ width: 100%; border-collapse: collapse; margin: 0 0 12px; font-size: 14px; }}
|
||||||
|
.body th, .body td {{ border: 1px solid var(--border); padding: 8px 12px; text-align: left; }}
|
||||||
|
.body th {{ background: var(--bg-secondary); font-weight: 600; }}
|
||||||
|
.body blockquote {{ border-left: 3px solid var(--accent); padding-left: 16px; margin: 0 0 12px; color: var(--text-secondary); }}
|
||||||
|
.body a {{ color: var(--accent); text-decoration: none; }}
|
||||||
|
.body a:hover {{ text-decoration: underline; }}
|
||||||
|
.body img {{ max-width: 100%; border-radius: 6px; }}
|
||||||
|
.body hr {{ border: none; border-top: 1px solid var(--border); margin: 20px 0; }}
|
||||||
|
.body input[type="checkbox"] {{ margin-right: 6px; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<div class="toolbar">
|
||||||
|
<a href="javascript:history.back()">← 返回</a>
|
||||||
|
<span class="title">{title}</span>
|
||||||
|
</div>
|
||||||
|
<div class="body">{body}</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>"#, title = title, body = body)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
- start_service / stop_service:管理后台服务
|
- start_service / stop_service:管理后台服务
|
||||||
- kb_search / kb_read:搜索和读取知识库
|
- kb_search / kb_read:搜索和读取知识库
|
||||||
- update_scratchpad:记录本步骤内的中间状态(步骤结束后丢弃,精华写进 summary)
|
- update_scratchpad:记录本步骤内的中间状态(步骤结束后丢弃,精华写进 summary)
|
||||||
- wait_for_approval:暂停执行等待用户确认
|
- ask_user:向用户提问,暂停执行等待用户回复
|
||||||
- step_done:**完成当前步骤时必须调用**,提供本步骤的工作摘要
|
- step_done:**完成当前步骤时必须调用**,提供本步骤的工作摘要
|
||||||
|
|
||||||
## 工作流程
|
## 工作流程
|
||||||
@ -21,7 +21,7 @@
|
|||||||
- **专注当前步骤**,不做超出范围的事
|
- **专注当前步骤**,不做超出范围的事
|
||||||
- 完成后**必须**调用 step_done(summary),summary 应简洁概括本步骤做了什么、结果如何
|
- 完成后**必须**调用 step_done(summary),summary 应简洁概括本步骤做了什么、结果如何
|
||||||
- 完成步骤时,用 `step_done` 的 `artifacts` 参数声明本步骤产出的文件。每个产出物需要 name、path、type (file/json/markdown)
|
- 完成步骤时,用 `step_done` 的 `artifacts` 参数声明本步骤产出的文件。每个产出物需要 name、path、type (file/json/markdown)
|
||||||
- 需要用户确认时使用 wait_for_approval(reason)
|
- 需要用户确认或输入时使用 ask_user(question)
|
||||||
- update_scratchpad 用于记录本步骤内的中间状态,是工作记忆而非日志,只保留当前有用的信息
|
- update_scratchpad 用于记录本步骤内的中间状态,是工作记忆而非日志,只保留当前有用的信息
|
||||||
|
|
||||||
## 环境信息
|
## 环境信息
|
||||||
|
|||||||
12
src/state.rs
12
src/state.rs
@ -15,7 +15,7 @@ pub struct StepResult {
|
|||||||
pub enum StepResultStatus {
|
pub enum StepResultStatus {
|
||||||
Done,
|
Done,
|
||||||
Failed { error: String },
|
Failed { error: String },
|
||||||
NeedsApproval { message: String },
|
NeedsInput { message: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check scratchpad size. Limit: ~8K tokens ≈ 24K bytes.
|
/// Check scratchpad size. Limit: ~8K tokens ≈ 24K bytes.
|
||||||
@ -50,7 +50,7 @@ pub enum AgentPhase {
|
|||||||
pub enum StepStatus {
|
pub enum StepStatus {
|
||||||
Pending,
|
Pending,
|
||||||
Running,
|
Running,
|
||||||
WaitingApproval,
|
WaitingUser,
|
||||||
Done,
|
Done,
|
||||||
Failed,
|
Failed,
|
||||||
}
|
}
|
||||||
@ -183,7 +183,7 @@ impl AgentState {
|
|||||||
/// 全部 Done 时返回 None。
|
/// 全部 Done 时返回 None。
|
||||||
pub fn first_actionable_step(&self) -> Option<i32> {
|
pub fn first_actionable_step(&self) -> Option<i32> {
|
||||||
self.steps.iter()
|
self.steps.iter()
|
||||||
.find(|s| matches!(s.status, StepStatus::Pending | StepStatus::Running | StepStatus::WaitingApproval))
|
.find(|s| matches!(s.status, StepStatus::Pending | StepStatus::Running | StepStatus::WaitingUser))
|
||||||
.map(|s| s.order)
|
.map(|s| s.order)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -204,7 +204,7 @@ impl AgentState {
|
|||||||
let marker = match s.status {
|
let marker = match s.status {
|
||||||
StepStatus::Done => " done",
|
StepStatus::Done => " done",
|
||||||
StepStatus::Running => " >> current",
|
StepStatus::Running => " >> current",
|
||||||
StepStatus::WaitingApproval => " ⏳ waiting",
|
StepStatus::WaitingUser => " ⏳ waiting",
|
||||||
StepStatus::Failed => " FAILED",
|
StepStatus::Failed => " FAILED",
|
||||||
StepStatus::Pending => "",
|
StepStatus::Pending => "",
|
||||||
};
|
};
|
||||||
@ -421,11 +421,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn first_actionable_finds_waiting_approval() {
|
fn first_actionable_finds_waiting_user() {
|
||||||
let state = AgentState {
|
let state = AgentState {
|
||||||
phase: AgentPhase::Executing { step: 1 },
|
phase: AgentPhase::Executing { step: 1 },
|
||||||
steps: vec![
|
steps: vec![
|
||||||
make_step(1, "A", "a", StepStatus::WaitingApproval),
|
make_step(1, "A", "a", StepStatus::WaitingUser),
|
||||||
make_step(2, "B", "b", StepStatus::Pending),
|
make_step(2, "B", "b", StepStatus::Pending),
|
||||||
],
|
],
|
||||||
current_step_chat_history: Vec::new(),
|
current_step_chat_history: Vec::new(),
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { ref, nextTick } from 'vue'
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
quotes: string[]
|
quotes: string[]
|
||||||
waitingApproval?: boolean
|
waitingUser?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@ -66,9 +66,9 @@ defineExpose({ focusInput })
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="comment-section" :class="{ 'waiting-approval': waitingApproval }">
|
<div class="comment-section" :class="{ 'waiting-user': waitingUser }">
|
||||||
<div v-if="waitingApproval" class="approval-banner">
|
<div v-if="waitingUser" class="approval-banner">
|
||||||
⏳ Agent 正在等待你的确认
|
⏳ Agent 正在等待你的回复
|
||||||
</div>
|
</div>
|
||||||
<div v-if="quotes.length" class="quotes-bar">
|
<div v-if="quotes.length" class="quotes-bar">
|
||||||
<div v-for="(q, i) in quotes" :key="i" class="quote-chip">
|
<div v-for="(q, i) in quotes" :key="i" class="quote-chip">
|
||||||
@ -80,11 +80,11 @@ defineExpose({ focusInput })
|
|||||||
<textarea
|
<textarea
|
||||||
ref="textareaRef"
|
ref="textareaRef"
|
||||||
v-model="input"
|
v-model="input"
|
||||||
:placeholder="waitingApproval ? '可选:附加反馈或修改意见...' : (quotes.length ? '添加你的评论...' : '输入反馈或调整指令... (Ctrl+Enter 发送)')"
|
:placeholder="waitingUser ? '输入回复或反馈...' : (quotes.length ? '添加你的评论...' : '输入反馈或调整指令... (Ctrl+Enter 发送)')"
|
||||||
rows="3"
|
rows="3"
|
||||||
@keydown="onKeydown"
|
@keydown="onKeydown"
|
||||||
/>
|
/>
|
||||||
<div v-if="waitingApproval" class="approval-buttons">
|
<div v-if="waitingUser" class="approval-buttons">
|
||||||
<button class="btn-approve" @click="approve">继续执行</button>
|
<button class="btn-approve" @click="approve">继续执行</button>
|
||||||
<button class="btn-reject" @click="reject">终止</button>
|
<button class="btn-reject" @click="reject">终止</button>
|
||||||
</div>
|
</div>
|
||||||
@ -101,7 +101,7 @@ defineExpose({ focusInput })
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-section.waiting-approval {
|
.comment-section.waiting-user {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 1px var(--accent), 0 0 12px rgba(79, 195, 247, 0.15);
|
box-shadow: 0 0 0 1px var(--accent), 0 0 12px rgba(79, 195, 247, 0.15);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -65,7 +65,7 @@ const workflowStatusLabel = computed(() => {
|
|||||||
case 'executing': return '执行中'
|
case 'executing': return '执行中'
|
||||||
case 'done': return '已完成'
|
case 'done': return '已完成'
|
||||||
case 'failed': return '失败'
|
case 'failed': return '失败'
|
||||||
case 'waiting_approval': return '等待确认'
|
case 'waiting_user': return '等待用户'
|
||||||
default: return props.workflowStatus
|
default: return props.workflowStatus
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -364,7 +364,7 @@ watch(logItems, () => {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-badge.waiting_approval {
|
.workflow-badge.waiting_user {
|
||||||
background: #ff9800;
|
background: #ff9800;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -208,7 +208,7 @@ async function onSubmitComment(text: string) {
|
|||||||
ref="commentRef"
|
ref="commentRef"
|
||||||
:disabled="!workflow"
|
:disabled="!workflow"
|
||||||
:quotes="quotes"
|
:quotes="quotes"
|
||||||
:waitingApproval="workflow?.status === 'waiting_approval'"
|
:waitingUser="workflow?.status === 'waiting_user'"
|
||||||
@submit="onSubmitComment"
|
@submit="onSubmitComment"
|
||||||
@removeQuote="removeQuote"
|
@removeQuote="removeQuote"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -10,7 +10,7 @@ export interface Workflow {
|
|||||||
id: string
|
id: string
|
||||||
project_id: string
|
project_id: string
|
||||||
requirement: string
|
requirement: string
|
||||||
status: 'pending' | 'planning' | 'executing' | 'waiting_approval' | 'done' | 'failed'
|
status: 'pending' | 'planning' | 'executing' | 'waiting_user' | 'done' | 'failed'
|
||||||
created_at: string
|
created_at: string
|
||||||
report: string
|
report: string
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user