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:
parent
837977cd17
commit
ee4a5dfc95
@ -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
|
||||
|
||||
31
app-templates/webapp/INSTRUCTIONS.md
Normal file
31
app-templates/webapp/INSTRUCTIONS.md
Normal 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 不需要额外服务,适合单机应用
|
||||
7
app-templates/webapp/scripts/setup.sh
Executable file
7
app-templates/webapp/scripts/setup.sh
Executable file
@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Create venv if not exists (idempotent)
|
||||
if [ ! -d ".venv" ]; then
|
||||
uv venv .venv
|
||||
fi
|
||||
5
app-templates/webapp/template.json
Normal file
5
app-templates/webapp/template.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Web 应用",
|
||||
"description": "FastAPI + SQLite 的 Web 应用",
|
||||
"match_hint": "需要前后端、Web 界面、HTTP API、数据库的应用类项目"
|
||||
}
|
||||
72
doc/templates.md
Normal file
72
doc/templates.md
Normal 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 自己生成
|
||||
175
src/agent.rs
175
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,13 +149,135 @@ 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 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 !std::path::Path::new(&venv_path).exists() {
|
||||
if !Path::new(&venv_path).exists() {
|
||||
let _ = exec.execute("uv venv .venv", workdir).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn agent_loop(
|
||||
project_id: String,
|
||||
@ -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());
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user