App-templates: LLM auto-selects project template based on user requirement

- Add webapp template (FastAPI + SQLite) with INSTRUCTIONS.md and setup.sh
- select_template() scans templates, asks LLM to match; apply_template() copies to workspace
- ensure_workspace() runs setup.sh if present, otherwise falls back to default venv
- INSTRUCTIONS.md injected into planning and execution prompts
- Fix pre-existing clippy warning in kb.rs (filter_map → map)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 21:33:40 +00:00
parent 837977cd17
commit ee4a5dfc95
7 changed files with 281 additions and 18 deletions

View File

@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicU16, Ordering};
use serde::{Deserialize, Serialize};
@@ -148,11 +149,133 @@ impl AgentManager {
}
}
async fn ensure_venv(exec: &LocalExecutor, workdir: &str) {
// --- Template system ---
#[derive(Debug, Deserialize)]
struct TemplateInfo {
name: String,
description: String,
match_hint: String,
}
fn templates_dir() -> &'static str {
if Path::new("/app/templates").is_dir() {
"/app/templates"
} else {
"app-templates"
}
}
/// Scan available templates and ask LLM to pick one (or none).
async fn select_template(llm: &LlmClient, requirement: &str) -> Option<String> {
let base = Path::new(templates_dir());
let mut entries = match tokio::fs::read_dir(base).await {
Ok(e) => e,
Err(_) => return None,
};
let mut templates: Vec<(String, TemplateInfo)> = Vec::new();
while let Ok(Some(entry)) = entries.next_entry().await {
if !entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false) {
continue;
}
let id = entry.file_name().to_string_lossy().to_string();
let meta_path = entry.path().join("template.json");
if let Ok(data) = tokio::fs::read_to_string(&meta_path).await {
if let Ok(info) = serde_json::from_str::<TemplateInfo>(&data) {
templates.push((id, info));
}
}
}
if templates.is_empty() {
return None;
}
let listing: String = templates
.iter()
.map(|(id, info)| format!("- id: {}\n 名称: {}\n 描述: {}\n 适用场景: {}", id, info.name, info.description, info.match_hint))
.collect::<Vec<_>>()
.join("\n");
let prompt = format!(
"以下是可用的项目模板:\n{}\n\n用户需求:{}\n\n选择最匹配的模板 ID如果都不合适则回复 none。只回复模板 ID 或 none不要其他内容。",
listing, requirement
);
let response = llm
.chat(vec![
ChatMessage::system("你是一个模板选择助手。根据用户需求选择最合适的项目模板。只回复模板 ID 或 none。"),
ChatMessage::user(&prompt),
])
.await
.ok()?;
let answer = response.trim().to_lowercase();
if answer == "none" {
return None;
}
// Verify the answer matches an actual template ID
templates.iter().find(|(id, _)| id == &answer).map(|(id, _)| id.clone())
}
/// Copy template contents to workdir (excluding template.json).
async fn apply_template(template_id: &str, workdir: &str) -> anyhow::Result<()> {
let src = Path::new(templates_dir()).join(template_id);
if !src.is_dir() {
anyhow::bail!("Template directory not found: {}", template_id);
}
copy_dir_recursive(&src, Path::new(workdir)).await
}
/// Recursively copy directory contents, skipping template.json at the top level.
async fn copy_dir_recursive(src: &Path, dst: &Path) -> anyhow::Result<()> {
// Use a stack to avoid async recursion
let mut stack: Vec<(std::path::PathBuf, std::path::PathBuf, bool)> =
vec![(src.to_path_buf(), dst.to_path_buf(), true)];
while let Some((src_dir, dst_dir, top_level)) = stack.pop() {
tokio::fs::create_dir_all(&dst_dir).await?;
let mut entries = tokio::fs::read_dir(&src_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if top_level && name_str == "template.json" {
continue;
}
let src_path = entry.path();
let dst_path = dst_dir.join(&name);
if entry.file_type().await?.is_dir() {
stack.push((src_path, dst_path, false));
} else {
tokio::fs::copy(&src_path, &dst_path).await?;
}
}
}
Ok(())
}
/// Read INSTRUCTIONS.md from workdir if it exists.
async fn read_instructions(workdir: &str) -> String {
let path = format!("{}/INSTRUCTIONS.md", workdir);
tokio::fs::read_to_string(&path).await.unwrap_or_default()
}
async fn ensure_workspace(exec: &LocalExecutor, workdir: &str) {
let _ = tokio::fs::create_dir_all(workdir).await;
let venv_path = format!("{}/.venv", workdir);
if !std::path::Path::new(&venv_path).exists() {
let _ = exec.execute("uv venv .venv", workdir).await;
let setup_script = format!("{}/scripts/setup.sh", workdir);
if Path::new(&setup_script).exists() {
tracing::info!("Running setup.sh in {}", workdir);
let _ = exec.execute("bash scripts/setup.sh", workdir).await;
} else {
let venv_path = format!("{}/.venv", workdir);
if !Path::new(&venv_path).exists() {
let _ = exec.execute("uv venv .venv", workdir).await;
}
}
}
@@ -205,15 +328,26 @@ async fn agent_loop(
.execute(&pool)
.await;
// Ensure workspace and venv exist
ensure_venv(&exec, &workdir).await;
// Template selection + workspace setup
let template_id = select_template(&llm, &requirement).await;
if let Some(ref tid) = template_id {
tracing::info!("Template selected for workflow {}: {}", workflow_id, tid);
let _ = tokio::fs::create_dir_all(&workdir).await;
if let Err(e) = apply_template(tid, &workdir).await {
tracing::error!("Failed to apply template {}: {}", tid, e);
}
}
ensure_workspace(&exec, &workdir).await;
let _ = tokio::fs::write(format!("{}/requirement.md", workdir), &requirement).await;
let instructions = read_instructions(&workdir).await;
tracing::info!("Starting agent loop for workflow {}", workflow_id);
// Run tool-calling agent loop
let result = run_agent_loop(
&llm, &exec, &pool, &broadcast_tx,
&project_id, &workflow_id, &requirement, &workdir, &mgr,
&instructions,
).await;
let final_status = if result.is_ok() { "done" } else { "failed" };
@@ -269,8 +403,8 @@ async fn agent_loop(
let Some(wf) = wf else { continue };
// Ensure venv exists for comment re-runs too
ensure_venv(&exec, &workdir).await;
// Ensure workspace exists for comment re-runs too
ensure_workspace(&exec, &workdir).await;
// Clear old plan steps (keep log entries for history)
let _ = sqlx::query("DELETE FROM plan_steps WHERE workflow_id = ? AND kind = 'plan'")
@@ -297,9 +431,12 @@ async fn agent_loop(
wf.requirement, content
);
let instructions = read_instructions(&workdir).await;
let result = run_agent_loop(
&llm, &exec, &pool, &broadcast_tx,
&project_id, &workflow_id, &combined, &workdir, &mgr,
&instructions,
).await;
let final_status = if result.is_ok() { "done" } else { "failed" };
@@ -477,8 +614,8 @@ fn build_execution_tools() -> Vec<Tool> {
]
}
fn build_planning_prompt(project_id: &str) -> String {
format!(
fn build_planning_prompt(project_id: &str, instructions: &str) -> String {
let mut prompt = format!(
"你是一个 AI 智能体,正处于【规划阶段】。你拥有一个独立的工作区目录。\n\
\n\
你的任务:\n\
@@ -508,11 +645,15 @@ fn build_planning_prompt(project_id: &str) -> String {
\n\
请使用中文回复。",
project_id,
)
);
if !instructions.is_empty() {
prompt.push_str(&format!("\n\n## 项目模板指令\n\n{}", instructions));
}
prompt
}
fn build_execution_prompt(project_id: &str) -> String {
format!(
fn build_execution_prompt(project_id: &str, instructions: &str) -> String {
let mut prompt = format!(
"你是一个 AI 智能体,正处于【执行阶段】。请专注完成当前步骤的任务。\n\
\n\
可用工具:\n\
@@ -539,7 +680,11 @@ fn build_execution_prompt(project_id: &str) -> String {
\n\
请使用中文回复。",
project_id,
)
);
if !instructions.is_empty() {
prompt.push_str(&format!("\n\n## 项目模板指令\n\n{}", instructions));
}
prompt
}
fn build_step_context(state: &AgentState, requirement: &str) -> String {
@@ -697,6 +842,7 @@ async fn execute_tool(
// --- Tool-calling agent loop (state machine) ---
#[allow(clippy::too_many_arguments)]
async fn run_agent_loop(
llm: &LlmClient,
exec: &LocalExecutor,
@@ -707,6 +853,7 @@ async fn run_agent_loop(
requirement: &str,
workdir: &str,
mgr: &Arc<AgentManager>,
instructions: &str,
) -> anyhow::Result<()> {
let planning_tools = build_planning_tools();
let execution_tools = build_execution_tools();
@@ -731,7 +878,7 @@ async fn run_agent_loop(
let (messages, tools) = match &state.phase {
AgentPhase::Planning => {
let mut msgs = vec![
ChatMessage::system(&build_planning_prompt(project_id)),
ChatMessage::system(&build_planning_prompt(project_id, instructions)),
ChatMessage::user(requirement),
];
msgs.extend(state.step_messages.clone());
@@ -740,7 +887,7 @@ async fn run_agent_loop(
AgentPhase::Executing { .. } => {
let step_ctx = build_step_context(&state, requirement);
let mut msgs = vec![
ChatMessage::system(&build_execution_prompt(project_id)),
ChatMessage::system(&build_execution_prompt(project_id, instructions)),
ChatMessage::user(&step_ctx),
];
msgs.extend(state.step_messages.clone());

View File

@@ -95,10 +95,10 @@ impl KbManager {
// Compute cosine similarity and rank
let mut scored: Vec<(f32, String, String, String)> = rows
.into_iter()
.filter_map(|(title, content, blob, article_title)| {
.map(|(title, content, blob, article_title)| {
let emb = bytes_to_embedding(&blob);
let score = cosine_similarity(&query_vec, &emb);
Some((score, title, content, article_title))
(score, title, content, article_title)
})
.collect();