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:
Fam Zheng 2026-03-01 21:33:40 +00:00
parent 837977cd17
commit ee4a5dfc95
7 changed files with 281 additions and 18 deletions

View File

@ -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

View File

@ -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 不需要额外服务,适合单机应用

View File

@ -0,0 +1,7 @@
#!/bin/bash
set -e
# Create venv if not exists (idempotent)
if [ ! -d ".venv" ]; then
uv venv .venv
fi

View File

@ -0,0 +1,5 @@
{
"name": "Web 应用",
"description": "FastAPI + SQLite 的 Web 应用",
"match_hint": "需要前后端、Web 界面、HTTP API、数据库的应用类项目"
}

72
doc/templates.md Normal file
View File

@ -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 自己生成

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();