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