From 07f1f285b60d54d47fd65ed03867645eb32ccd61 Mon Sep 17 00:00:00 2001 From: Fam Zheng Date: Sat, 7 Mar 2026 16:24:56 +0000 Subject: [PATCH] feat: multi-branch template scanning from git repo + manual template selection - Rewrite template.rs to scan all remote branches via git commands (git fetch/branch -r/ls-tree/git show/git archive) - Add manual template picker dropdown in CreateForm UI - Remove sentence-transformers/embed.py from Dockerfile (separate container) - Clean up Gitea API approach, use local git repo instead - Add chat panel and sidebar layout improvements --- src/agent.rs | 52 +++- src/api/chat.rs | 53 ++++ src/api/mod.rs | 2 + src/api/workflows.rs | 9 + src/main.rs | 10 + src/template.rs | 415 ++++++++++++++++++++++++------ src/timer.rs | 1 + web/src/api.ts | 15 +- web/src/components/AppLayout.vue | 348 +++++++++++++++++++++---- web/src/components/ChatPanel.vue | 216 ++++++++++++++++ web/src/components/CreateForm.vue | 51 +++- web/src/components/Sidebar.vue | 172 ------------- web/src/style.css | 2 + web/src/types.ts | 5 + 14 files changed, 1030 insertions(+), 321 deletions(-) create mode 100644 src/api/chat.rs create mode 100644 web/src/components/ChatPanel.vue diff --git a/src/agent.rs b/src/agent.rs index 63801c9..ced19df 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -22,7 +22,7 @@ pub struct ServiceInfo { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum AgentEvent { - NewRequirement { workflow_id: String, requirement: String }, + NewRequirement { workflow_id: String, requirement: String, template_id: Option }, Comment { workflow_id: String, content: String }, } @@ -172,7 +172,7 @@ async fn agent_loop( while let Some(event) = rx.recv().await { match event { - AgentEvent::NewRequirement { workflow_id, requirement } => { + AgentEvent::NewRequirement { workflow_id, requirement, template_id: forced_template } => { tracing::info!("Processing new requirement for workflow {}", workflow_id); // Generate project title in background (don't block the agent loop) { @@ -206,18 +206,48 @@ async fn agent_loop( .await; // Template selection + workspace setup - let template_id = template::select_template(&llm, &requirement).await; + let template_id = if forced_template.is_some() { + tracing::info!("Using forced template: {:?}", forced_template); + forced_template + } else { + template::select_template(&llm, &requirement).await + }; let loaded_template = 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) = template::apply_template(tid, &workdir).await { - tracing::error!("Failed to apply template {}: {}", tid, e); - } - match LoadedTemplate::load(tid).await { - Ok(t) => Some(t), - Err(e) => { - tracing::error!("Failed to load template {}: {}", tid, e); - None + + if template::is_repo_template(tid) { + // Repo template: extract from git then load + match template::extract_repo_template(tid).await { + Ok(template_dir) => { + if let Err(e) = template::apply_template(&template_dir, &workdir).await { + tracing::error!("Failed to apply repo template {}: {}", tid, e); + } + match LoadedTemplate::load_from_dir(tid, &template_dir).await { + Ok(t) => Some(t), + Err(e) => { + tracing::error!("Failed to load repo template {}: {}", tid, e); + None + } + } + } + Err(e) => { + tracing::error!("Failed to extract repo template {}: {}", tid, e); + None + } + } + } else { + // Local built-in template + let template_dir = std::path::Path::new(template::templates_dir()).join(tid); + if let Err(e) = template::apply_template(&template_dir, &workdir).await { + tracing::error!("Failed to apply template {}: {}", tid, e); + } + match LoadedTemplate::load(tid).await { + Ok(t) => Some(t), + Err(e) => { + tracing::error!("Failed to load template {}: {}", tid, e); + None + } } } } else { diff --git a/src/api/chat.rs b/src/api/chat.rs new file mode 100644 index 0000000..2bc91fe --- /dev/null +++ b/src/api/chat.rs @@ -0,0 +1,53 @@ +use std::sync::Arc; +use axum::{ + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, + routing::post, + Json, Router, +}; +use serde::Deserialize; + +use crate::llm::{ChatMessage, LlmClient}; +use crate::AppState; + +#[derive(Deserialize)] +struct ChatRequest { + messages: Vec, +} + +#[derive(Deserialize)] +struct SimpleChatMessage { + role: String, + content: String, +} + +pub fn router(state: Arc) -> Router { + Router::new() + .route("/chat", post(chat)) + .with_state(state) +} + +async fn chat( + State(state): State>, + Json(input): Json, +) -> Result, Response> { + let llm = LlmClient::new(&state.config.llm); + let messages: Vec = input + .messages + .into_iter() + .map(|m| ChatMessage { + role: m.role, + content: Some(m.content), + tool_calls: None, + tool_call_id: None, + }) + .collect(); + + let reply = llm.chat(messages).await.map_err(|e| { + tracing::error!("Chat LLM error: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() + })?; + + Ok(Json(serde_json::json!({ "reply": reply }))) +} diff --git a/src/api/mod.rs b/src/api/mod.rs index cf5cfd3..f31634d 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,3 +1,4 @@ +mod chat; mod kb; pub mod obj; mod projects; @@ -31,6 +32,7 @@ pub fn router(state: Arc) -> Router { .merge(timers::router(state.clone())) .merge(kb::router(state.clone())) .merge(settings::router(state.clone())) + .merge(chat::router(state.clone())) .route("/projects/{id}/files/{*path}", get(serve_project_file)) .route("/projects/{id}/app/{*path}", any(proxy_to_service).with_state(state.clone())) .route("/projects/{id}/app/", any(proxy_to_service_root).with_state(state)) diff --git a/src/api/workflows.rs b/src/api/workflows.rs index 13fe1a0..ef68bb2 100644 --- a/src/api/workflows.rs +++ b/src/api/workflows.rs @@ -11,6 +11,7 @@ use crate::AppState; use crate::agent::{AgentEvent, PlanStepInfo}; use crate::db::{Workflow, ExecutionLogEntry, Comment, LlmCallLogEntry}; use crate::state::AgentState; +use crate::template; use super::{ApiResult, db_err}; #[derive(serde::Serialize)] @@ -21,6 +22,8 @@ struct ReportResponse { #[derive(Deserialize)] pub struct CreateWorkflow { pub requirement: String, + #[serde(default)] + pub template_id: Option, } #[derive(Deserialize)] @@ -36,6 +39,7 @@ pub fn router(state: Arc) -> Router { .route("/workflows/{id}/report", get(get_report)) .route("/workflows/{id}/plan", get(get_plan)) .route("/workflows/{id}/llm-calls", get(list_llm_calls)) + .route("/templates", get(list_templates)) .with_state(state) } @@ -72,6 +76,7 @@ async fn create_workflow( state.agent_mgr.send_event(&project_id, AgentEvent::NewRequirement { workflow_id: workflow.id.clone(), requirement: workflow.requirement.clone(), + template_id: input.template_id, }).await; Ok(Json(workflow)) @@ -191,3 +196,7 @@ async fn list_llm_calls( .map(Json) .map_err(db_err) } + +async fn list_templates() -> Json> { + Json(template::list_all_templates().await) +} diff --git a/src/main.rs b/src/main.rs index a9084a4..f38b0cc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,6 +29,15 @@ pub struct Config { pub llm: LlmConfig, pub server: ServerConfig, pub database: DatabaseConfig, + #[serde(default)] + pub template_repo: Option, +} + +#[derive(Debug, Clone, serde::Deserialize)] +pub struct TemplateRepoConfig { + pub gitea_url: String, + pub owner: String, + pub repo: String, } #[derive(Debug, Clone, serde::Deserialize)] @@ -147,6 +156,7 @@ async fn resume_workflows(pool: SqlitePool, agent_mgr: Arc) agent_mgr.send_event(&project_id, agent::AgentEvent::NewRequirement { workflow_id, requirement, + template_id: None, }).await; } } diff --git a/src/template.rs b/src/template.rs index a31db10..1cda522 100644 --- a/src/template.rs +++ b/src/template.rs @@ -1,4 +1,4 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; use serde::Deserialize; @@ -6,6 +6,7 @@ use crate::llm::{ChatMessage, LlmClient}; use crate::tools::ExternalToolManager; #[derive(Debug, Deserialize)] +#[allow(dead_code)] pub struct TemplateInfo { pub name: String, pub description: String, @@ -21,7 +22,16 @@ pub struct LoadedTemplate { pub kb_files: Vec<(String, String)>, } -pub fn templates_dir() -> &'static str { +#[derive(Debug, Clone, serde::Serialize)] +pub struct TemplateListItem { + pub id: String, + pub name: String, + pub description: String, +} + +// --- Template directories --- + +fn builtin_dir() -> &'static str { if Path::new("/app/templates").is_dir() { "/app/templates" } else { @@ -29,45 +39,295 @@ pub fn templates_dir() -> &'static str { } } -/// Scan available templates and ask LLM to pick one (or none). -pub 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, - }; +fn repo_dir() -> &'static str { + if Path::new("/app/oseng-templates").is_dir() { + "/app/oseng-templates" + } else { + "oseng-templates" + } +} - let mut templates: Vec<(String, TemplateInfo)> = Vec::new(); - while let Ok(Some(entry)) = entries.next_entry().await { - // Use metadata() instead of file_type() to follow symlinks - let is_dir = tokio::fs::metadata(entry.path()) - .await - .map(|m| m.is_dir()) - .unwrap_or(false); - if !is_dir { - 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)); +/// For backward compat. +pub fn templates_dir() -> &'static str { + builtin_dir() +} + +// --- Scanning --- + +/// List all templates from both built-in and repo (all branches). +pub async fn list_all_templates() -> Vec { + let mut items = Vec::new(); + + // 1. Built-in templates (flat: each top-level dir with template.json) + let builtin = Path::new(builtin_dir()); + if let Ok(mut entries) = tokio::fs::read_dir(builtin).await { + while let Ok(Some(entry)) = entries.next_entry().await { + let is_dir = tokio::fs::metadata(entry.path()) + .await + .map(|m| m.is_dir()) + .unwrap_or(false); + if !is_dir { + 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) { + items.push(TemplateListItem { + id, + name: info.name, + description: info.description, + }); + } } } } - if templates.is_empty() { + // 2. Repo templates (all branches, find INSTRUCTIONS.md via git ls-tree) + let repo = Path::new(repo_dir()); + if repo.join(".git").is_dir() { + items.extend(scan_repo_all_branches(repo).await); + } + + items.sort_by(|a, b| a.id.cmp(&b.id)); + items +} + +/// Scan all branches in a git repo for directories containing INSTRUCTIONS.md. +/// Template ID = "{branch}/{path}" e.g. "main/simple-npi", "fam/oncall/network-latency". +async fn scan_repo_all_branches(repo: &Path) -> Vec { + let mut items = Vec::new(); + + // First: git fetch --all to get latest branches + let _ = tokio::process::Command::new("git") + .args(["fetch", "--all", "--prune", "-q"]) + .current_dir(repo) + .output() + .await; + + // List all remote branches: "origin/main", "origin/fam/foo" etc. + let output = match tokio::process::Command::new("git") + .args(["branch", "-r", "--format=%(refname:short)"]) + .current_dir(repo) + .output() + .await + { + Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(), + _ => return items, + }; + + for line in output.lines() { + let line = line.trim(); + if line.is_empty() || line.contains("HEAD") { + continue; + } + // line = "origin/main" or "origin/fam/feature" + let branch = match line.strip_prefix("origin/") { + Some(b) => b, + None => line, + }; + + // git ls-tree -r --name-only origin/branch + let tree_output = match tokio::process::Command::new("git") + .args(["ls-tree", "-r", "--name-only", line]) + .current_dir(repo) + .output() + .await + { + Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(), + _ => continue, + }; + + for file_path in tree_output.lines() { + let file_path = file_path.trim(); + if !file_path.ends_with("INSTRUCTIONS.md") { + continue; + } + + // Template dir path (relative to repo root) + let template_path = if file_path == "INSTRUCTIONS.md" { + "" + } else { + file_path.trim_end_matches("/INSTRUCTIONS.md") + }; + + let template_id = if template_path.is_empty() { + branch.to_string() + } else { + format!("{}/{}", branch, template_path) + }; + + // Try to read template.json via git show + let (name, description) = read_git_file_json(repo, line, template_path).await; + + items.push(TemplateListItem { + id: template_id.clone(), + name: name.unwrap_or_else(|| template_id.clone()), + description: description.unwrap_or_default(), + }); + } + } + + items +} + +/// Read template.json from a git ref via `git show`. +async fn read_git_file_json( + repo: &Path, + ref_name: &str, + template_path: &str, +) -> (Option, Option) { + let file = if template_path.is_empty() { + "template.json".to_string() + } else { + format!("{}/template.json", template_path) + }; + + let output = match tokio::process::Command::new("git") + .args(["show", &format!("{}:{}", ref_name, file)]) + .current_dir(repo) + .output() + .await + { + Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(), + _ => return (None, None), + }; + + match serde_json::from_str::(&output) { + Ok(info) => (Some(info.name), Some(info.description)), + Err(_) => (None, None), + } +} + +/// Extract a template from a git branch to a local directory. +/// Uses `git archive` to extract the template subtree. +pub async fn extract_repo_template(template_id: &str) -> anyhow::Result { + let repo = Path::new(repo_dir()); + let dest = PathBuf::from("/tmp/tori-repo-templates").join(template_id); + let _ = tokio::fs::remove_dir_all(&dest).await; + tokio::fs::create_dir_all(&dest).await?; + + // Parse template_id: "main/simple-npi" → branch="main", path="simple-npi" + // But branch could be multi-segment: "fam/feature" → need to find split point + // Strategy: try each split, check if "origin/{prefix}" is a valid ref + let (ref_name, template_path) = resolve_branch_and_path(repo, template_id).await + .ok_or_else(|| anyhow::anyhow!("Cannot resolve template: {}", template_id))?; + + // git archive | tar -x -C --strip-components=N + let strip = if template_path.is_empty() { + 0 + } else { + template_path.matches('/').count() + 1 + }; + + let archive_args = if template_path.is_empty() { + vec!["archive".to_string(), ref_name.clone()] + } else { + vec!["archive".to_string(), ref_name.clone(), template_path.clone()] + }; + + let git_archive = tokio::process::Command::new("git") + .args(&archive_args) + .current_dir(repo) + .stdout(std::process::Stdio::piped()) + .spawn()?; + + let child_stdout = git_archive.stdout.unwrap(); + let stdio: std::process::Stdio = child_stdout.try_into() + .map_err(|e| anyhow::anyhow!("Failed to convert stdout: {:?}", e))?; + + let mut tar_args = vec!["-x".to_string(), "-C".to_string(), dest.to_string_lossy().to_string()]; + if strip > 0 { + tar_args.push(format!("--strip-components={}", strip)); + } + + let tar_output = tokio::process::Command::new("tar") + .args(&tar_args) + .stdin(stdio) + .output() + .await?; + + if !tar_output.status.success() { + anyhow::bail!( + "tar extract failed: {}", + String::from_utf8_lossy(&tar_output.stderr) + ); + } + + // Make scripts in tools/ executable + let tools_dir = dest.join("tools"); + if tools_dir.is_dir() { + if let Ok(mut entries) = tokio::fs::read_dir(&tools_dir).await { + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + if path.is_file() { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = tokio::fs::metadata(&path).await { + let mut perms = meta.permissions(); + perms.set_mode(perms.mode() | 0o111); + let _ = tokio::fs::set_permissions(&path, perms).await; + } + } + } + } + } + } + + tracing::info!("Extracted repo template '{}' to {}", template_id, dest.display()); + Ok(dest) +} + +/// Given a template_id like "main/simple-npi" or "fam/feature/oncall", +/// figure out which part is the branch and which is the path. +async fn resolve_branch_and_path(repo: &Path, template_id: &str) -> Option<(String, String)> { + let parts: Vec<&str> = template_id.splitn(10, '/').collect(); + + // Try progressively longer branch names + for i in 1..parts.len() + 1 { + let candidate_branch = parts[..i].join("/"); + let ref_name = format!("origin/{}", candidate_branch); + + let output = tokio::process::Command::new("git") + .args(["rev-parse", "--verify", &ref_name]) + .current_dir(repo) + .output() + .await; + + if let Ok(o) = output { + if o.status.success() { + let path = if i < parts.len() { + parts[i..].join("/") + } else { + String::new() + }; + return Some((ref_name, path)); + } + } + } + + None +} + +/// Check if a template is from the repo (vs built-in). +pub fn is_repo_template(template_id: &str) -> bool { + // Built-in templates are flat names without '/' + // Repo templates always have branch/path format + template_id.contains('/') +} + +// --- LLM template selection --- + +pub async fn select_template(llm: &LlmClient, requirement: &str) -> Option { + let all = list_all_templates().await; + if all.is_empty() { return None; } - let listing: String = templates + let listing: String = all .iter() - .map(|(id, info)| { - format!( - "- id: {}\n 名称: {}\n 描述: {}\n 适用场景: {}", - id, info.name, info.description, info.match_hint - ) - }) + .map(|t| format!("- id: {}\n 名称: {}\n 描述: {}", t.id, t.name, t.description)) .collect::>() .join("\n"); @@ -86,34 +346,26 @@ pub async fn select_template(llm: &LlmClient, requirement: &str) -> Option>()); + answer, all.iter().map(|t| t.id.as_str()).collect::>()); if answer == "none" { return None; } - // Verify the answer matches an actual template ID - let result = templates - .iter() - .find(|(id, _)| id == &answer) - .map(|(id, _)| id.clone()); - if result.is_none() { - tracing::warn!("Template selection: LLM returned '{}' which doesn't match any template ID", answer); - } - result + all.iter().find(|t| t.id == answer).map(|t| t.id.clone()) } -/// Copy template contents to workdir (excluding template.json, tools/, kb/). -pub 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); +// --- Template loading --- + +/// Copy template contents to workdir (excluding template.json, tools/, kb/, INSTRUCTIONS.md). +pub async fn apply_template(template_dir: &Path, workdir: &str) -> anyhow::Result<()> { + if !template_dir.is_dir() { + anyhow::bail!("Template directory not found: {}", template_dir.display()); } - copy_dir_recursive(&src, Path::new(workdir)).await + copy_dir_recursive(template_dir, Path::new(workdir)).await } -/// Recursively copy directory contents, skipping template.json/tools/kb at the top level. async fn copy_dir_recursive(src: &Path, dst: &Path) -> anyhow::Result<()> { - let mut stack: Vec<(std::path::PathBuf, std::path::PathBuf, bool)> = + let mut stack: Vec<(PathBuf, PathBuf, bool)> = vec![(src.to_path_buf(), dst.to_path_buf(), true)]; while let Some((src_dir, dst_dir, top_level)) = stack.pop() { @@ -123,8 +375,11 @@ async fn copy_dir_recursive(src: &Path, dst: &Path) -> anyhow::Result<()> { let name = entry.file_name(); let name_str = name.to_string_lossy(); - // Skip metadata/convention dirs at top level - if top_level && (name_str == "template.json" || name_str == "tools" || name_str == "kb") + if top_level + && (name_str == "template.json" + || name_str == "tools" + || name_str == "kb" + || name_str == "INSTRUCTIONS.md") { continue; } @@ -143,41 +398,37 @@ async fn copy_dir_recursive(src: &Path, dst: &Path) -> anyhow::Result<()> { } impl LoadedTemplate { - /// Load a template: discover tools, read KB files, read instructions. - pub async fn load(template_id: &str) -> anyhow::Result { - let base = Path::new(templates_dir()).join(template_id); + pub async fn load_from_dir(template_id: &str, base: &Path) -> anyhow::Result { if !base.is_dir() { - anyhow::bail!("Template directory not found: {}", template_id); + anyhow::bail!("Template directory not found: {}", base.display()); } - // Read template.json let meta_path = base.join("template.json"); - let meta_data = tokio::fs::read_to_string(&meta_path).await?; - let info: TemplateInfo = serde_json::from_str(&meta_data)?; + let info = if let Ok(data) = tokio::fs::read_to_string(&meta_path).await { + serde_json::from_str::(&data).unwrap_or_else(|_| TemplateInfo { + name: template_id.to_string(), + description: String::new(), + match_hint: String::new(), + }) + } else { + TemplateInfo { + name: template_id.to_string(), + description: String::new(), + match_hint: String::new(), + } + }; - // Read INSTRUCTIONS.md - let instructions_path = base.join("INSTRUCTIONS.md"); - let instructions = tokio::fs::read_to_string(&instructions_path) + let instructions = tokio::fs::read_to_string(base.join("INSTRUCTIONS.md")) .await .unwrap_or_default(); - // Discover external tools let tools_dir = base.join("tools"); let external_tools = ExternalToolManager::discover(&tools_dir).await; - tracing::info!( - "Template '{}': discovered {} external tools", - template_id, - external_tools.len() - ); + tracing::info!("Template '{}': {} external tools", template_id, external_tools.len()); - // Scan KB files let kb_dir = base.join("kb"); let kb_files = scan_kb_files(&kb_dir).await; - tracing::info!( - "Template '{}': found {} KB files", - template_id, - kb_files.len() - ); + tracing::info!("Template '{}': {} KB files", template_id, kb_files.len()); Ok(Self { id: template_id.to_string(), @@ -187,10 +438,13 @@ impl LoadedTemplate { kb_files, }) } + + pub async fn load(template_id: &str) -> anyhow::Result { + let base = Path::new(builtin_dir()).join(template_id); + Self::load_from_dir(template_id, &base).await + } } -/// Scan kb/ directory for .md files. Returns (title, content) pairs. -/// Title is extracted from the first `# heading` line, or falls back to the filename. async fn scan_kb_files(kb_dir: &Path) -> Vec<(String, String)> { let mut results = Vec::new(); @@ -201,18 +455,12 @@ async fn scan_kb_files(kb_dir: &Path) -> Vec<(String, String)> { while let Ok(Some(entry)) = entries.next_entry().await { let path = entry.path(); - - // Resolve symlinks let real_path = match tokio::fs::canonicalize(&path).await { Ok(p) => p, Err(_) => path.clone(), }; - // Only process .md files - let ext = real_path - .extension() - .and_then(|e| e.to_str()) - .unwrap_or(""); + let ext = real_path.extension().and_then(|e| e.to_str()).unwrap_or(""); if ext != "md" { continue; } @@ -225,7 +473,6 @@ async fn scan_kb_files(kb_dir: &Path) -> Vec<(String, String)> { } }; - // Extract title: first `# heading` line, or filename without extension let title = content .lines() .find(|l| l.starts_with("# ")) diff --git a/src/timer.rs b/src/timer.rs index aa9cb49..ebdab7e 100644 --- a/src/timer.rs +++ b/src/timer.rs @@ -68,6 +68,7 @@ async fn check_timers(pool: &SqlitePool, agent_mgr: &Arc) -> anyho agent_mgr.send_event(&timer.project_id, AgentEvent::NewRequirement { workflow_id, requirement: timer.requirement.clone(), + template_id: None, }).await; } diff --git a/web/src/api.ts b/web/src/api.ts index 2d5227d..67d0054 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,4 +1,4 @@ -import type { Project, Workflow, ExecutionLogEntry, Comment, Timer, KbArticle, KbArticleSummary, PlanStepInfo, LlmCallLogEntry } from './types' +import type { Project, Workflow, ExecutionLogEntry, Comment, Timer, KbArticle, KbArticleSummary, PlanStepInfo, LlmCallLogEntry, ChatMessage } from './types' const BASE = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/api` @@ -37,12 +37,15 @@ export const api = { listWorkflows: (projectId: string) => request(`/projects/${projectId}/workflows`), - createWorkflow: (projectId: string, requirement: string) => + createWorkflow: (projectId: string, requirement: string, templateId?: string) => request(`/projects/${projectId}/workflows`, { method: 'POST', - body: JSON.stringify({ requirement }), + body: JSON.stringify({ requirement, template_id: templateId || undefined }), }), + listTemplates: () => + request<{ id: string; name: string; description: string }[]>('/templates'), + listSteps: (workflowId: string) => request(`/workflows/${workflowId}/steps`), @@ -103,6 +106,12 @@ export const api = { deleteArticle: (id: string) => request(`/kb/articles/${id}`, { method: 'DELETE' }), + chat: (messages: ChatMessage[]) => + request<{ reply: string }>('/chat', { + method: 'POST', + body: JSON.stringify({ messages }), + }), + getSettings: () => request>('/settings'), putSetting: (key: string, value: string) => diff --git a/web/src/components/AppLayout.vue b/web/src/components/AppLayout.vue index 130b927..9c1a65f 100644 --- a/web/src/components/AppLayout.vue +++ b/web/src/components/AppLayout.vue @@ -1,6 +1,7 @@ @@ -282,10 +361,175 @@ function onArticleSaved(id: string, title: string, updatedAt: string) { .app-layout { display: flex; + flex-direction: column; height: 100vh; overflow: hidden; } +.app-header { + height: var(--header-height); + min-height: var(--header-height); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + background: var(--bg-primary); + border-bottom: 1px solid var(--border); +} + +.header-left { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.header-title { + font-size: 15px; + font-weight: 700; + color: var(--accent); + cursor: pointer; + white-space: nowrap; +} + +.header-title:hover { + opacity: 0.8; +} + +.header-sep { + color: var(--text-secondary); + font-size: 14px; +} + +.header-page { + font-size: 14px; + color: var(--text-primary); + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.header-right { + display: flex; + align-items: center; + gap: 4px; +} + +.header-btn { + width: 32px; + height: 32px; + padding: 0; + background: transparent; + border: none; + border-radius: 6px; + font-size: 16px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--text-secondary); +} + +.header-btn:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.header-settings-wrapper { + position: relative; +} + +.header-settings-menu { + position: absolute; + top: 100%; + right: 0; + margin-top: 4px; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 8px; + padding: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + min-width: 200px; + z-index: 200; +} + +.settings-item-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; +} + +.settings-label { + font-size: 12px; + color: var(--text-secondary); + white-space: nowrap; +} + +.settings-value { + flex: 1; + text-align: right; + background: none; + border: none; + color: var(--text-primary); + font-size: 13px; + cursor: pointer; + padding: 2px 4px; + border-radius: 4px; +} + +.settings-value:hover { + background: var(--bg-tertiary); +} + +.settings-input { + flex: 1; + padding: 4px 8px; + font-size: 13px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-primary); + outline: none; +} + +.settings-input:focus { + border-color: var(--accent); +} + +.settings-save { + padding: 4px 8px; + font-size: 12px; + background: var(--accent); + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.settings-item { + width: 100%; + padding: 8px 12px; + background: none; + border: none; + color: var(--text-primary); + font-size: 13px; + text-align: left; + cursor: pointer; + border-radius: 6px; +} + +.settings-item:hover { + background: var(--bg-tertiary); +} + +.app-body { + flex: 1; + display: flex; + overflow: hidden; +} + .main-content { flex: 1; overflow: hidden; @@ -309,4 +553,12 @@ function onArticleSaved(id: string, title: string, updatedAt: string) { font-size: 13px; cursor: pointer; } + +.chat-sidebar { + width: var(--chat-sidebar-width, 360px); + flex-shrink: 0; + border-left: 1px solid var(--border); + background: var(--bg-secondary); + overflow: hidden; +} diff --git a/web/src/components/ChatPanel.vue b/web/src/components/ChatPanel.vue new file mode 100644 index 0000000..1aed361 --- /dev/null +++ b/web/src/components/ChatPanel.vue @@ -0,0 +1,216 @@ + + +