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"]
})),
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<Tool> {
}
}
},
"required": ["summary"]
"required": ["summary", "artifacts"]
})),
tool_kb_search(),
tool_kb_read(),

View File

@ -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"),

View File

@ -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");

View File

@ -11,18 +11,73 @@ struct ExternalTool {
pub struct ExternalToolManager {
tools: HashMap<String, ExternalTool>,
venv_bin: Option<PathBuf>,
}
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<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 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();

View File

@ -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<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) {
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
}
</script>
<template>
@ -60,9 +101,24 @@ function quoteStep(e: Event, step: PlanStepInfo) {
{{ step.command }}
</div>
<div v-if="step.artifacts?.length" class="step-artifacts">
<span v-for="a in step.artifacts" :key="a.path" class="artifact-tag">
📄 {{ a.name }} <span class="artifact-type">{{ a.artifact_type }}</span>
</span>
<button
v-for="a in step.artifacts"
: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 v-if="!steps.length" class="empty-state">
@ -209,12 +265,59 @@ function quoteStep(e: Event, step: PlanStepInfo) {
background: var(--bg-tertiary);
padding: 2px 8px;
border-radius: 4px;
border: 1px solid transparent;
cursor: pointer;
transition: all 0.15s;
}
.artifact-type {
font-size: 10px;
.artifact-tag:hover {
border-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 {

View File

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

View File

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