diff --git a/Cargo.lock b/Cargo.lock index cc2b364..60e5e87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -496,6 +496,15 @@ dependencies = [ "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]] name = "getrandom" version = "0.2.17" @@ -1217,6 +1226,25 @@ dependencies = [ "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]] name = "quinn" version = "0.11.9" @@ -2081,6 +2109,7 @@ dependencies = [ "futures", "mime_guess", "nix", + "pulldown-cmark", "reqwest", "serde", "serde_json", @@ -2274,6 +2303,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" diff --git a/Cargo.toml b/Cargo.toml index 9ed2677..9e71f9b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,3 +27,4 @@ anyhow = "1" mime_guess = "2" tokio-util = { version = "0.7", features = ["io"] } nix = { version = "0.29", features = ["signal"] } +pulldown-cmark = "0.12" diff --git a/src/agent.rs b/src/agent.rs index 73c6dd3..f3d56c8 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -56,7 +56,7 @@ pub fn plan_infos_from_state(state: &AgentState) -> Vec { let status = match s.status { StepStatus::Pending => "pending", StepStatus::Running => "running", - StepStatus::WaitingApproval => "waiting_approval", + StepStatus::WaitingUser => "waiting_user", StepStatus::Done => "done", StepStatus::Failed => "failed", }; @@ -428,18 +428,18 @@ async fn agent_loop( .and_then(|json| serde_json::from_str::(&json).ok()) .unwrap_or_else(AgentState::new); - // Resume directly if: workflow is failed/done/waiting_approval, - // OR if state snapshot has a WaitingApproval step (e.g. after pod restart) - let has_waiting_step = state.steps.iter().any(|s| matches!(s.status, StepStatus::WaitingApproval)); + // Resume directly if: workflow is failed/done/waiting_user, + // 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::WaitingUser)); 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 { - // Reset Failed/WaitingApproval steps so they get re-executed + // Reset Failed/WaitingUser steps so they get re-executed for step in &mut state.steps { if matches!(step.status, StepStatus::Failed) { 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) step.status = StepStatus::Running; } @@ -483,7 +483,7 @@ async fn agent_loop( } state.phase = AgentPhase::Executing { step: next }; // 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 { state.current_step_chat_history.clear(); } @@ -722,12 +722,12 @@ fn build_step_tools() -> Vec { }, "required": ["content"] })), - make_tool("wait_for_approval", "暂停执行,等待用户确认后继续。用于关键决策点。", serde_json::json!({ + make_tool("ask_user", "向用户提问,暂停执行等待用户回复。用于需要用户输入、确认或决策的场景。", serde_json::json!({ "type": "object", "properties": { - "reason": { "type": "string", "description": "说明为什么需要用户确认" } + "question": { "type": "string", "description": "要向用户提出的问题或需要确认的内容" } }, - "required": ["reason"] + "required": ["question"] })), make_tool("step_done", "完成当前步骤。必须提供摘要和产出物列表(无产出物时传空数组)。", serde_json::json!({ "type": "object", @@ -827,7 +827,7 @@ fn build_feedback_prompt(project_id: &str, state: &AgentState, feedback: &str) - let status = match s.status { StepStatus::Done => " [done]", StepStatus::Running => " [running]", - StepStatus::WaitingApproval => " [waiting]", + StepStatus::WaitingUser => " [waiting]", StepStatus::Failed => " [FAILED]", StepStatus::Pending => "", }; @@ -1326,8 +1326,8 @@ async fn run_step_loop( } } - "wait_for_approval" => { - let reason = args["reason"].as_str().unwrap_or("等待确认"); + "ask_user" => { + let reason = args["question"].as_str().unwrap_or("等待确认"); let _ = broadcast_tx.send(WsMessage::ActivityUpdate { workflow_id: workflow_id.to_string(), activity: format!("步骤 {} — 等待用户确认: {}", step_order, reason), @@ -1336,18 +1336,18 @@ async fn run_step_loop( // Broadcast waiting status let _ = broadcast_tx.send(WsMessage::PlanUpdate { 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, }); let _ = broadcast_tx.send(WsMessage::WorkflowStatusUpdate { 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) .execute(pool) .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); @@ -1370,7 +1370,7 @@ async fn run_step_loop( if approval_content.starts_with("rejected:") { 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_done_result = Some(StepResult { 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. -/// 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. async fn plan_infos_from_state_with_override( step_order: i32, @@ -1609,7 +1609,7 @@ async fn plan_infos_from_state_with_override( match s.status { StepStatus::Pending => "pending", StepStatus::Running => "running", - StepStatus::WaitingApproval => "waiting_approval", + StepStatus::WaitingUser => "waiting_user", StepStatus::Done => "done", StepStatus::Failed => "failed", }.to_string() @@ -1731,9 +1731,9 @@ async fn run_agent_loop( }); let _ = broadcast_tx.send(WsMessage::WorkflowStatusUpdate { 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) .execute(pool) .await; @@ -1896,11 +1896,11 @@ async fn run_agent_loop( save_state_snapshot(pool, workflow_id, step_order, &state).await; return Err(anyhow::anyhow!("Step {} failed: {}", step_order, error)); } - StepResultStatus::NeedsApproval { message: _ } => { - // This shouldn't normally happen since wait_for_approval is handled inside + StepResultStatus::NeedsInput { message: _ } => { + // This shouldn't normally happen since ask_user is handled inside // run_step_loop, but handle gracefully 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; continue; @@ -1934,7 +1934,7 @@ async fn run_agent_loop( let marker = match s.status { StepStatus::Done => " [done]", StepStatus::Running => " [running]", - StepStatus::WaitingApproval => " [waiting]", + StepStatus::WaitingUser => " [waiting]", StepStatus::Failed => " [FAILED]", StepStatus::Pending => "", }; @@ -2238,7 +2238,7 @@ mod tests { let names: Vec<&str> = tools.iter().map(|t| t.function.name.as_str()).collect(); for expected in &["execute", "read_file", "write_file", "list_files", "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); } } diff --git a/src/api/mod.rs b/src/api/mod.rs index f31634d..8bf8157 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -115,6 +115,15 @@ async fn serve_project_file( match tokio::fs::read(&full_path).await { 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) .first_or_octet_stream() .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#" + + + + +{title} + + + +
+
+ ← 返回 + {title} +
+
{body}
+
+ +"#, title = title, body = body) +} + diff --git a/src/prompts/step_execution.md b/src/prompts/step_execution.md index 2ca6537..f7f28f2 100644 --- a/src/prompts/step_execution.md +++ b/src/prompts/step_execution.md @@ -7,7 +7,7 @@ - start_service / stop_service:管理后台服务 - kb_search / kb_read:搜索和读取知识库 - update_scratchpad:记录本步骤内的中间状态(步骤结束后丢弃,精华写进 summary) -- wait_for_approval:暂停执行等待用户确认 +- ask_user:向用户提问,暂停执行等待用户回复 - step_done:**完成当前步骤时必须调用**,提供本步骤的工作摘要 ## 工作流程 @@ -21,7 +21,7 @@ - **专注当前步骤**,不做超出范围的事 - 完成后**必须**调用 step_done(summary),summary 应简洁概括本步骤做了什么、结果如何 - 完成步骤时,用 `step_done` 的 `artifacts` 参数声明本步骤产出的文件。每个产出物需要 name、path、type (file/json/markdown) -- 需要用户确认时使用 wait_for_approval(reason) +- 需要用户确认或输入时使用 ask_user(question) - update_scratchpad 用于记录本步骤内的中间状态,是工作记忆而非日志,只保留当前有用的信息 ## 环境信息 diff --git a/src/state.rs b/src/state.rs index 160be5d..fdca8c6 100644 --- a/src/state.rs +++ b/src/state.rs @@ -15,7 +15,7 @@ pub struct StepResult { pub enum StepResultStatus { Done, Failed { error: String }, - NeedsApproval { message: String }, + NeedsInput { message: String }, } /// Check scratchpad size. Limit: ~8K tokens ≈ 24K bytes. @@ -50,7 +50,7 @@ pub enum AgentPhase { pub enum StepStatus { Pending, Running, - WaitingApproval, + WaitingUser, Done, Failed, } @@ -183,7 +183,7 @@ impl AgentState { /// 全部 Done 时返回 None。 pub fn first_actionable_step(&self) -> Option { 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) } @@ -204,7 +204,7 @@ impl AgentState { let marker = match s.status { StepStatus::Done => " done", StepStatus::Running => " >> current", - StepStatus::WaitingApproval => " ⏳ waiting", + StepStatus::WaitingUser => " ⏳ waiting", StepStatus::Failed => " FAILED", StepStatus::Pending => "", }; @@ -421,11 +421,11 @@ mod tests { } #[test] - fn first_actionable_finds_waiting_approval() { + fn first_actionable_finds_waiting_user() { let state = AgentState { phase: AgentPhase::Executing { step: 1 }, steps: vec![ - make_step(1, "A", "a", StepStatus::WaitingApproval), + make_step(1, "A", "a", StepStatus::WaitingUser), make_step(2, "B", "b", StepStatus::Pending), ], current_step_chat_history: Vec::new(), diff --git a/web/src/components/CommentSection.vue b/web/src/components/CommentSection.vue index 0af5948..3a4c679 100644 --- a/web/src/components/CommentSection.vue +++ b/web/src/components/CommentSection.vue @@ -4,7 +4,7 @@ import { ref, nextTick } from 'vue' const props = defineProps<{ disabled?: boolean quotes: string[] - waitingApproval?: boolean + waitingUser?: boolean }>() const emit = defineEmits<{ @@ -66,9 +66,9 @@ defineExpose({ focusInput })