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.
This commit is contained in:
Fam Zheng 2026-03-09 15:22:35 +00:00
parent fa800b1601
commit c70fbc49f0
7 changed files with 197 additions and 32 deletions

View File

@ -698,13 +698,13 @@ fn build_step_tools() -> Vec<Tool> {
}, },
"required": ["reason"] "required": ["reason"]
})), })),
make_tool("step_done", "完成当前步骤。必须提供摘要。可选声明本步骤的产出物", serde_json::json!({ make_tool("step_done", "完成当前步骤。必须提供摘要和产出物列表(无产出物时传空数组)", serde_json::json!({
"type": "object", "type": "object",
"properties": { "properties": {
"summary": { "type": "string", "description": "本步骤的工作摘要" }, "summary": { "type": "string", "description": "本步骤的工作摘要" },
"artifacts": { "artifacts": {
"type": "array", "type": "array",
"description": "本步骤的产出物列表", "description": "本步骤的产出物列表。无产出物时传空数组 []",
"items": { "items": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -717,7 +717,7 @@ fn build_step_tools() -> Vec<Tool> {
} }
} }
}, },
"required": ["summary"] "required": ["summary", "artifacts"]
})), })),
tool_kb_search(), tool_kb_search(),
tool_kb_read(), tool_kb_read(),

View File

@ -94,7 +94,7 @@ impl LlmClient {
pub fn new(config: &LlmConfig) -> Self { pub fn new(config: &LlmConfig) -> Self {
Self { Self {
client: reqwest::Client::builder() 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)) .connect_timeout(std::time::Duration::from_secs(10))
.build() .build()
.expect("Failed to build HTTP client"), .expect("Failed to build HTTP client"),

View File

@ -550,8 +550,7 @@ impl LoadedTemplate {
.await .await
.unwrap_or_default(); .unwrap_or_default();
let tools_dir = base.join("tools"); let external_tools = ExternalToolManager::discover(base).await;
let external_tools = ExternalToolManager::discover(&tools_dir).await;
tracing::info!("Template '{}': {} external tools", template_id, external_tools.len()); tracing::info!("Template '{}': {} external tools", template_id, external_tools.len());
let kb_dir = base.join("kb"); let kb_dir = base.join("kb");

View File

@ -11,18 +11,73 @@ struct ExternalTool {
pub struct ExternalToolManager { pub struct ExternalToolManager {
tools: HashMap<String, ExternalTool>, tools: HashMap<String, ExternalTool>,
venv_bin: Option<PathBuf>,
} }
impl ExternalToolManager { impl ExternalToolManager {
/// Scan a tools/ directory, calling `--print-schema` on each executable to discover tools. /// Build the PATH string with venv bin prepended (if present).
pub async fn discover(tools_dir: &Path) -> Self { fn env_path(&self) -> Option<String> {
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 tools = HashMap::new();
let mut entries = match tokio::fs::read_dir(tools_dir).await { // --- Python venv setup ---
Ok(e) => e, let venv_bin = if template_dir.join("pyproject.toml").is_file() {
Err(_) => return Self { tools }, 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 { while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path(); let path = entry.path();
@ -41,12 +96,16 @@ impl ExternalToolManager {
continue; continue;
} }
// Call --print-schema // Call --print-schema (with venv PATH if available)
let output = match tokio::process::Command::new(&path) let mut cmd = tokio::process::Command::new(&path);
.arg("--print-schema") cmd.arg("--print-schema");
.output() if let Some(ref p) = env_path {
.await 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, Ok(o) => o,
Err(e) => { Err(e) => {
tracing::warn!("Failed to run --print-schema on {}: {}", path.display(), 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. /// Return all discovered Tool definitions for LLM API calls.
@ -122,11 +181,15 @@ impl ExternalToolManager {
.get(name) .get(name)
.ok_or_else(|| anyhow::anyhow!("External tool not found: {}", name))?; .ok_or_else(|| anyhow::anyhow!("External tool not found: {}", name))?;
let output = tokio::process::Command::new(&tool.path) let mut cmd = tokio::process::Command::new(&tool.path);
.arg(args_json) cmd.arg(args_json).current_dir(workdir);
.current_dir(workdir) if let Some(ref p) = self.env_path() {
.output() cmd.env("PATH", p);
.await?; }
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 stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string();

View File

@ -2,8 +2,11 @@
import { ref } from 'vue' import { ref } from 'vue'
import type { PlanStepInfo } from '../types' import type { PlanStepInfo } from '../types'
defineProps<{ const BASE = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/api`
const props = defineProps<{
steps: PlanStepInfo[] steps: PlanStepInfo[]
projectId: string
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@ -11,6 +14,9 @@ const emit = defineEmits<{
}>() }>()
const expandedSteps = ref<Set<number>>(new Set()) const expandedSteps = ref<Set<number>>(new Set())
const expandedArtifact = ref<{ stepOrder: number; path: string } | null>(null)
const artifactContent = ref<string>('')
const artifactLoading = ref(false)
function toggleStep(order: number) { function toggleStep(order: number) {
if (expandedSteps.value.has(order)) { if (expandedSteps.value.has(order)) {
@ -33,6 +39,41 @@ function quoteStep(e: Event, step: PlanStepInfo) {
e.stopPropagation() e.stopPropagation()
emit('quote', `[步骤${step.order}] ${step.description}`) 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
}
</script> </script>
<template> <template>
@ -60,9 +101,24 @@ function quoteStep(e: Event, step: PlanStepInfo) {
{{ step.command }} {{ step.command }}
</div> </div>
<div v-if="step.artifacts?.length" class="step-artifacts"> <div v-if="step.artifacts?.length" class="step-artifacts">
<span v-for="a in step.artifacts" :key="a.path" class="artifact-tag"> <button
📄 {{ a.name }} <span class="artifact-type">{{ a.artifact_type }}</span> v-for="a in step.artifacts"
</span> :key="a.path"
class="artifact-tag"
:class="{ active: isArtifactExpanded(step.order, a.path) }"
@click="toggleArtifact($event, step.order, a.path)"
:title="a.description || a.path"
>
<span class="artifact-icon">{{ artifactIcon(a.artifact_type) }}</span>
<span class="artifact-name">{{ a.name }}</span>
</button>
<div
v-if="expandedArtifact && step.artifacts.some(a => isArtifactExpanded(step.order, a.path))"
class="artifact-content"
>
<div v-if="artifactLoading" class="artifact-loading">加载中...</div>
<pre v-else>{{ artifactContent }}</pre>
</div>
</div> </div>
</div> </div>
<div v-if="!steps.length" class="empty-state"> <div v-if="!steps.length" class="empty-state">
@ -209,12 +265,59 @@ function quoteStep(e: Event, step: PlanStepInfo) {
background: var(--bg-tertiary); background: var(--bg-tertiary);
padding: 2px 8px; padding: 2px 8px;
border-radius: 4px; border-radius: 4px;
border: 1px solid transparent;
cursor: pointer;
transition: all 0.15s;
} }
.artifact-type { .artifact-tag:hover {
font-size: 10px; border-color: var(--accent);
color: var(--accent); color: var(--accent);
opacity: 0.8; }
.artifact-tag.active {
border-color: var(--accent);
background: rgba(79, 195, 247, 0.12);
color: var(--accent);
}
.artifact-icon {
font-size: 10px;
font-weight: 600;
opacity: 0.7;
}
.artifact-name {
font-weight: 500;
}
.artifact-content {
width: 100%;
margin-top: 4px;
border-radius: 4px;
background: var(--bg-card);
border: 1px solid var(--border);
overflow: hidden;
}
.artifact-content pre {
margin: 0;
padding: 8px 12px;
font-size: 11px;
line-height: 1.5;
color: var(--text-primary);
overflow-x: auto;
max-height: 400px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
.artifact-loading {
padding: 12px;
text-align: center;
color: var(--text-secondary);
font-size: 12px;
} }
.empty-state { .empty-state {

View File

@ -40,12 +40,12 @@ onMounted(async () => {
try { try {
const res = await api.getReport(props.workflowId) const res = await api.getReport(props.workflowId)
html.value = await marked.parse(res.report) html.value = await marked.parse(res.report)
await renderMermaid()
} catch (e: any) { } catch (e: any) {
error.value = e.message error.value = e.message
} finally { } finally {
loading.value = false loading.value = false
} }
await renderMermaid()
}) })
</script> </script>

View File

@ -180,7 +180,7 @@ async function onSubmitComment(text: string) {
@submit="onSubmitRequirement" @submit="onSubmitRequirement"
/> />
<div class="plan-exec-row"> <div class="plan-exec-row">
<PlanSection :steps="planSteps" @quote="addQuote" /> <PlanSection :steps="planSteps" :projectId="projectId" @quote="addQuote" />
<div class="right-panel"> <div class="right-panel">
<div class="tab-bar"> <div class="tab-bar">
<button class="tab-btn" :class="{ active: rightTab === 'log' }" @click="rightTab = 'log'">日志</button> <button class="tab-btn" :class="{ active: rightTab === 'log' }" @click="rightTab = 'log'">日志</button>