From c70fbc49f0142a802cda3e5846f42078cfc9d5c5 Mon Sep 17 00:00:00 2001 From: Fam Zheng Date: Mon, 9 Mar 2026 15:22:35 +0000 Subject: [PATCH] feat: auto-install Python deps from template pyproject.toml via uv ExternalToolManager.discover() now accepts template root dir, detects pyproject.toml and runs `uv sync` to create a venv. Tool invocation and schema discovery inject the venv PATH/VIRTUAL_ENV so template tools can import declared dependencies without manual installation. --- src/agent.rs | 6 +- src/llm.rs | 2 +- src/template.rs | 3 +- src/tools.rs | 97 +++++++++++++++++++---- web/src/components/PlanSection.vue | 117 ++++++++++++++++++++++++++-- web/src/components/ReportView.vue | 2 +- web/src/components/WorkflowView.vue | 2 +- 7 files changed, 197 insertions(+), 32 deletions(-) diff --git a/src/agent.rs b/src/agent.rs index ab40c78..6d87093 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -698,13 +698,13 @@ fn build_step_tools() -> Vec { }, "required": ["reason"] })), - make_tool("step_done", "完成当前步骤。必须提供摘要。可选声明本步骤的产出物。", serde_json::json!({ + make_tool("step_done", "完成当前步骤。必须提供摘要和产出物列表(无产出物时传空数组)。", serde_json::json!({ "type": "object", "properties": { "summary": { "type": "string", "description": "本步骤的工作摘要" }, "artifacts": { "type": "array", - "description": "本步骤的产出物列表", + "description": "本步骤的产出物列表。无产出物时传空数组 []", "items": { "type": "object", "properties": { @@ -717,7 +717,7 @@ fn build_step_tools() -> Vec { } } }, - "required": ["summary"] + "required": ["summary", "artifacts"] })), tool_kb_search(), tool_kb_read(), diff --git a/src/llm.rs b/src/llm.rs index 8b8e49d..b17100a 100644 --- a/src/llm.rs +++ b/src/llm.rs @@ -94,7 +94,7 @@ impl LlmClient { pub fn new(config: &LlmConfig) -> Self { Self { client: reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(120)) + .timeout(std::time::Duration::from_secs(300)) .connect_timeout(std::time::Duration::from_secs(10)) .build() .expect("Failed to build HTTP client"), diff --git a/src/template.rs b/src/template.rs index 8f868c8..86bc4c2 100644 --- a/src/template.rs +++ b/src/template.rs @@ -550,8 +550,7 @@ impl LoadedTemplate { .await .unwrap_or_default(); - let tools_dir = base.join("tools"); - let external_tools = ExternalToolManager::discover(&tools_dir).await; + let external_tools = ExternalToolManager::discover(base).await; tracing::info!("Template '{}': {} external tools", template_id, external_tools.len()); let kb_dir = base.join("kb"); diff --git a/src/tools.rs b/src/tools.rs index adb8fd7..924877d 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -11,18 +11,73 @@ struct ExternalTool { pub struct ExternalToolManager { tools: HashMap, + venv_bin: Option, } impl ExternalToolManager { - /// Scan a tools/ directory, calling `--print-schema` on each executable to discover tools. - pub async fn discover(tools_dir: &Path) -> Self { + /// Build the PATH string with venv bin prepended (if present). + fn env_path(&self) -> Option { + self.venv_bin.as_ref().map(|venv_bin| { + format!( + "{}:{}", + venv_bin.display(), + std::env::var("PATH").unwrap_or_default() + ) + }) + } + + /// Scan a template directory for external tools and Python dependencies. + /// + /// - Discovers executables in `template_dir/tools/` + /// - If `template_dir/pyproject.toml` exists, runs `uv sync` to create a venv + pub async fn discover(template_dir: &Path) -> Self { let mut tools = HashMap::new(); - let mut entries = match tokio::fs::read_dir(tools_dir).await { - Ok(e) => e, - Err(_) => return Self { tools }, + // --- Python venv setup --- + let venv_bin = if template_dir.join("pyproject.toml").is_file() { + tracing::info!("Found pyproject.toml in {}, running uv sync", template_dir.display()); + let output = tokio::process::Command::new("uv") + .args(["sync", "--project", &template_dir.to_string_lossy(), "--quiet"]) + .output() + .await; + match output { + Ok(o) if o.status.success() => { + let bin = template_dir.join(".venv/bin"); + tracing::info!("uv sync succeeded, venv bin: {}", bin.display()); + Some(bin) + } + Ok(o) => { + tracing::warn!( + "uv sync failed: {}", + String::from_utf8_lossy(&o.stderr) + ); + None + } + Err(e) => { + tracing::warn!("Failed to run uv sync: {}", e); + None + } + } + } else { + None }; + // --- Tool discovery --- + let tools_dir = template_dir.join("tools"); + let mut entries = match tokio::fs::read_dir(&tools_dir).await { + Ok(e) => e, + Err(_) => return Self { tools, venv_bin }, + }; + + // Build PATH with venv for --print-schema calls + let env_path = venv_bin.as_ref().map(|bin| { + format!( + "{}:{}", + bin.display(), + std::env::var("PATH").unwrap_or_default() + ) + }); + while let Ok(Some(entry)) = entries.next_entry().await { let path = entry.path(); @@ -41,12 +96,16 @@ impl ExternalToolManager { continue; } - // Call --print-schema - let output = match tokio::process::Command::new(&path) - .arg("--print-schema") - .output() - .await - { + // Call --print-schema (with venv PATH if available) + let mut cmd = tokio::process::Command::new(&path); + cmd.arg("--print-schema"); + if let Some(ref p) = env_path { + cmd.env("PATH", p); + } + if let Some(ref bin) = venv_bin { + cmd.env("VIRTUAL_ENV", bin.parent().unwrap().display().to_string()); + } + let output = match cmd.output().await { Ok(o) => o, Err(e) => { tracing::warn!("Failed to run --print-schema on {}: {}", path.display(), e); @@ -102,7 +161,7 @@ impl ExternalToolManager { ); } - Self { tools } + Self { tools, venv_bin } } /// Return all discovered Tool definitions for LLM API calls. @@ -122,11 +181,15 @@ impl ExternalToolManager { .get(name) .ok_or_else(|| anyhow::anyhow!("External tool not found: {}", name))?; - let output = tokio::process::Command::new(&tool.path) - .arg(args_json) - .current_dir(workdir) - .output() - .await?; + let mut cmd = tokio::process::Command::new(&tool.path); + cmd.arg(args_json).current_dir(workdir); + if let Some(ref p) = self.env_path() { + cmd.env("PATH", p); + } + if let Some(ref venv_bin) = self.venv_bin { + cmd.env("VIRTUAL_ENV", venv_bin.parent().unwrap().display().to_string()); + } + let output = cmd.output().await?; let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string(); diff --git a/web/src/components/PlanSection.vue b/web/src/components/PlanSection.vue index 278bf78..a300538 100644 --- a/web/src/components/PlanSection.vue +++ b/web/src/components/PlanSection.vue @@ -2,8 +2,11 @@ import { ref } from 'vue' import type { PlanStepInfo } from '../types' -defineProps<{ +const BASE = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/api` + +const props = defineProps<{ steps: PlanStepInfo[] + projectId: string }>() const emit = defineEmits<{ @@ -11,6 +14,9 @@ const emit = defineEmits<{ }>() const expandedSteps = ref>(new Set()) +const expandedArtifact = ref<{ stepOrder: number; path: string } | null>(null) +const artifactContent = ref('') +const artifactLoading = ref(false) function toggleStep(order: number) { if (expandedSteps.value.has(order)) { @@ -33,6 +39,41 @@ function quoteStep(e: Event, step: PlanStepInfo) { e.stopPropagation() emit('quote', `[步骤${step.order}] ${step.description}`) } + +function artifactIcon(type: string) { + switch (type) { + case 'json': return '{ }' + case 'markdown': return 'MD' + default: return '📄' + } +} + +async function toggleArtifact(e: Event, stepOrder: number, path: string) { + e.stopPropagation() + if (expandedArtifact.value?.stepOrder === stepOrder && expandedArtifact.value?.path === path) { + expandedArtifact.value = null + return + } + expandedArtifact.value = { stepOrder, path } + artifactLoading.value = true + artifactContent.value = '' + try { + const res = await fetch(`${BASE}/projects/${props.projectId}/files/${path}`) + if (res.ok) { + artifactContent.value = await res.text() + } else { + artifactContent.value = `Error: ${res.status} ${res.statusText}` + } + } catch (err) { + artifactContent.value = `Error: ${err}` + } finally { + artifactLoading.value = false + } +} + +function isArtifactExpanded(stepOrder: number, path: string) { + return expandedArtifact.value?.stepOrder === stepOrder && expandedArtifact.value?.path === path +}