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:
@@ -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(),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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");
|
||||
|
||||
97
src/tools.rs
97
src/tools.rs
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user