From ee4a5dfc951f9198447c306bce95d2848aa19bf9 Mon Sep 17 00:00:00 2001 From: Fam Zheng Date: Sun, 1 Mar 2026 21:33:40 +0000 Subject: [PATCH] App-templates: LLM auto-selects project template based on user requirement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Dockerfile | 1 + app-templates/webapp/INSTRUCTIONS.md | 31 +++++ app-templates/webapp/scripts/setup.sh | 7 + app-templates/webapp/template.json | 5 + doc/templates.md | 72 +++++++++++ src/agent.rs | 179 +++++++++++++++++++++++--- src/kb.rs | 4 +- 7 files changed, 281 insertions(+), 18 deletions(-) create mode 100644 app-templates/webapp/INSTRUCTIONS.md create mode 100755 app-templates/webapp/scripts/setup.sh create mode 100644 app-templates/webapp/template.json create mode 100644 doc/templates.md diff --git a/Dockerfile b/Dockerfile index f3c6cfa..0539d5b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,7 @@ WORKDIR /app COPY target/aarch64-unknown-linux-musl/release/tori . COPY --from=frontend /app/web/dist ./web/dist/ COPY scripts/embed.py ./scripts/ +COPY app-templates/ ./templates/ COPY config.yaml . EXPOSE 3000 diff --git a/app-templates/webapp/INSTRUCTIONS.md b/app-templates/webapp/INSTRUCTIONS.md new file mode 100644 index 0000000..f3bb879 --- /dev/null +++ b/app-templates/webapp/INSTRUCTIONS.md @@ -0,0 +1,31 @@ +# 项目模板:Web 应用 (FastAPI + SQLite) + +## 技术栈 + +- **后端**:Python FastAPI +- **数据库**:SQLite(通过 aiosqlite 异步访问) +- **前端**:单页 HTML + 原生 JavaScript(内嵌在 Python 中或作为静态文件) +- **包管理**:uv + +## 项目结构约定 + +``` +app.py # FastAPI 主应用(入口) +database.py # 数据库初始化和模型(可选,小项目可放在 app.py) +static/ # 静态文件目录(可选) +``` + +## 关键约定 + +1. **入口文件**:`app.py`,FastAPI 应用实例命名为 `app` +2. **启动命令**:`uvicorn app:app --host 0.0.0.0 --port $PORT` +3. **数据库**:使用 SQLite,数据库文件放在工作区根目录(如 `data.db`) +4. **依赖安装**:使用 `uv add <包名>` 安装依赖 +5. **前端**:优先使用单文件 HTML,通过 FastAPI 的 `HTMLResponse` 返回或放在 `static/` 目录 +6. **API 路径**:应用通过反向代理 `/api/projects/{project_id}/app/` 访问,前端 JS 中的 fetch 必须使用相对路径(如 `fetch('items')`,不要用 `fetch('/items')`) + +## 注意事项 + +- venv 已在 `.venv/` 中预先创建,直接使用即可 +- 不要使用 `pip install`,使用 `uv add` 管理依赖 +- SQLite 不需要额外服务,适合单机应用 diff --git a/app-templates/webapp/scripts/setup.sh b/app-templates/webapp/scripts/setup.sh new file mode 100755 index 0000000..6518589 --- /dev/null +++ b/app-templates/webapp/scripts/setup.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -e + +# Create venv if not exists (idempotent) +if [ ! -d ".venv" ]; then + uv venv .venv +fi diff --git a/app-templates/webapp/template.json b/app-templates/webapp/template.json new file mode 100644 index 0000000..d143490 --- /dev/null +++ b/app-templates/webapp/template.json @@ -0,0 +1,5 @@ +{ + "name": "Web 应用", + "description": "FastAPI + SQLite 的 Web 应用", + "match_hint": "需要前后端、Web 界面、HTTP API、数据库的应用类项目" +} diff --git a/doc/templates.md b/doc/templates.md new file mode 100644 index 0000000..d3d194c --- /dev/null +++ b/doc/templates.md @@ -0,0 +1,72 @@ +# App Templates(项目模板) + +## 概述 + +预置的项目目录模板。创建项目时,LLM 根据用户需求自动选择合适的模板(或不用模板),将模板内容复制到工作区,给 agent 明确的技术栈约束和起点。 + +用户不需要手动选模板——只写需求,模板选择在后端透明完成。 + +## 目录结构 + +``` +app-templates/ +└── webapp/ # 模板 ID = 目录名 + ├── template.json # 元信息(不会复制到工作区) + ├── INSTRUCTIONS.md # 注入 agent prompt 的指令 + └── scripts/ + └── setup.sh # 工作区初始化脚本 +``` + +## 模板文件说明 + +### template.json + +```json +{ + "name": "Web 应用", + "description": "FastAPI + SQLite 的 Web 应用", + "match_hint": "需要前后端、Web 界面、HTTP API、数据库的应用类项目" +} +``` + +- `name` / `description`: 人类可读的描述 +- `match_hint`: LLM 判断是否匹配时的依据 + +### INSTRUCTIONS.md + +复制到工作区后,agent 每次 LLM 调用时会读取并追加到 system prompt 末尾(规划和执行阶段都会注入)。内容是技术栈约定、项目结构、启动方式等。 + +### scripts/setup.sh + +工作区初始化脚本,在 `ensure_workspace` 阶段执行。如果工作区没有此文件,走默认逻辑(`uv venv .venv`)。应幂等。 + +## 选择流程 + +``` +用户输入需求 + → select_template() + 1. 扫描 templates_dir() 下所有子目录的 template.json + 2. 构造 prompt,列出所有模板的 {id, name, description, match_hint} + 3. LLM 返回模板 ID 或 "none" + → apply_template() # 如果选中了模板 + 复制模板目录到工作区(排除 template.json) + → ensure_workspace() + 检测 scripts/setup.sh → 有则执行,无则默认 venv + → run_agent_loop() + 读取 INSTRUCTIONS.md,注入 planning/execution prompt +``` + +## 路径 + +- 生产环境(Docker): `/app/templates/` +- 本地开发 fallback: `app-templates/` + +`Dockerfile` 中 `COPY app-templates/ ./templates/`。 + +## 添加新模板 + +1. 在 `app-templates/` 下建子目录,目录名即模板 ID +2. 创建 `template.json`(必须有 name, description, match_hint) +3. 创建 `INSTRUCTIONS.md`(agent 指令) +4. 可选:创建 `scripts/setup.sh`(初始化脚本,需 `chmod +x`) +5. 不要放代码骨架——让 agent 根据需求 + INSTRUCTIONS.md 自己生成 diff --git a/src/agent.rs b/src/agent.rs index dc6e594..3f0d2a9 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -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 { + 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::(&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::>() + .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 { ] } -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, + 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()); diff --git a/src/kb.rs b/src/kb.rs index 0a6624d..bf8ba4c 100644 --- a/src/kb.rs +++ b/src/kb.rs @@ -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();