From 2df4e12d30b94ad48659b844e060580cd9104cca Mon Sep 17 00:00:00 2001 From: Fam Zheng Date: Sat, 28 Feb 2026 22:35:33 +0000 Subject: [PATCH] Agent loop state machine refactor, unified LLM interface, and UI improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite agent loop as Planning→Executing(N)→Completed state machine with per-step context isolation to prevent token explosion - Split tools and prompts by phase (planning vs execution) - Add advance_step/save_memo tools for step transitions and cross-step memory - Unify LLM interface: remove duplicate types, single chat_with_tools path - Add UTF-8 safe truncation (truncate_str) to prevent panics on Chinese text - Extract CreateForm component, add auto-scroll to execution log - Add report generation with app access URL, non-blocking title generation - Add timer system, file serving, app proxy, exec module - Update Dockerfile with uv, deployment config Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 16 +- Cargo.toml | 2 + Dockerfile | 5 +- deploy/deployment.yaml | 18 +- doc/todo.md | 15 +- scripts/deploy.sh | 10 +- src/agent.rs | 1352 +++++++++++++++------ src/api/mod.rs | 107 +- src/api/timers.rs | 147 +++ src/api/workflows.rs | 25 + src/db.rs | 61 + src/exec.rs | 40 + src/llm.rs | 96 +- src/main.rs | 18 +- src/ssh.rs | 36 - src/timer.rs | 75 ++ web/package-lock.json | 1125 ++++++++++++++++- web/package.json | 2 + web/src/api.ts | 23 +- web/src/components/AppLayout.vue | 87 +- web/src/components/CommentSection.vue | 48 +- web/src/components/CreateForm.vue | 142 +++ web/src/components/ExecutionSection.vue | 246 +++- web/src/components/PlanSection.vue | 70 +- web/src/components/ReportView.vue | 232 ++++ web/src/components/RequirementSection.vue | 33 +- web/src/components/TimerSection.vue | 317 +++++ web/src/components/WorkflowView.vue | 86 +- web/src/style.css | 26 +- web/src/types.ts | 16 + web/src/ws.ts | 19 +- 31 files changed, 3924 insertions(+), 571 deletions(-) create mode 100644 src/api/timers.rs create mode 100644 src/exec.rs delete mode 100644 src/ssh.rs create mode 100644 src/timer.rs create mode 100644 web/src/components/CreateForm.vue create mode 100644 web/src/components/ReportView.vue create mode 100644 web/src/components/TimerSection.vue diff --git a/Cargo.lock b/Cargo.lock index 0df8131..778f640 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1024,6 +1024,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1257,7 +1269,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -2067,6 +2079,8 @@ dependencies = [ "axum", "chrono", "futures", + "mime_guess", + "nix", "reqwest", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 2dcbb46..cbff9d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,3 +24,5 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v4"] } anyhow = "1" +mime_guess = "2" +nix = { version = "0.29", features = ["signal"] } diff --git a/Dockerfile b/Dockerfile index 85e2d5d..dfdf015 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,10 @@ RUN npm run build # Stage 2: Runtime FROM alpine:3.21 -RUN apk add --no-cache ca-certificates +RUN apk add --no-cache ca-certificates curl bash +RUN curl -LsSf https://astral.sh/uv/install.sh | sh +ENV PATH="/root/.local/bin:$PATH" +RUN mkdir -p /app/data/workspaces WORKDIR /app COPY target/aarch64-unknown-linux-musl/release/tori . COPY --from=frontend /app/web/dist ./web/dist/ diff --git a/deploy/deployment.yaml b/deploy/deployment.yaml index b81d9ef..34bb7e5 100644 --- a/deploy/deployment.yaml +++ b/deploy/deployment.yaml @@ -3,19 +3,6 @@ kind: Namespace metadata: name: tori --- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: tori-data - namespace: tori -spec: - accessModes: - - ReadWriteOnce - storageClassName: local-path - resources: - requests: - storage: 1Gi ---- apiVersion: apps/v1 kind: Deployment metadata: @@ -46,8 +33,9 @@ spec: value: "info" volumes: - name: data - persistentVolumeClaim: - claimName: tori-data + hostPath: + path: /data/tori + type: DirectoryOrCreate --- apiVersion: v1 kind: Service diff --git a/doc/todo.md b/doc/todo.md index ec38cb7..225800d 100644 --- a/doc/todo.md +++ b/doc/todo.md @@ -1,5 +1,14 @@ -# Tori 开发 TODO +context compaction -所有初始 TODO 已完成。待笨笨指示的事项: +rag / kb -- [ ] ARM 部署方式(等笨笨说怎么搞) +template + +--- + +## 代码啰嗦/可精简 + +- **agent.rs**:`NewRequirement` 与 `Comment` 分支里「设 final_status → 更新 DB status → broadcast WorkflowStatusUpdate → 查 all_steps → generate_report → 更新 report → broadcast ReportReady」几乎相同,可抽成共用函数(如 `finish_workflow_and_report`);venv 创建/检查(create_dir_all + .venv 存在 + uv venv)两处重复,可抽成 helper。 +- **api/**:`projects.rs`、`workflows.rs`、`timers.rs` 里 `db_err` 与 `ApiResult` 定义重复,可提到 `api/mod.rs` 或公共模块。 +- **WorkflowView.vue**:`handleWsMessage` 里多处 `workflow.value && msg.workflow_id === workflow.value.id`,可先取 `const wf = workflow.value` 并统一判断;`ReportReady` 分支里 `workflow.value = { ...workflow.value, status: workflow.value.status }` 无实际效果,可删或改成真正刷新。 +- **PlanSection.vue / ExecutionSection.vue**:都有 `expandedSteps`(Set)、`toggleStep`、以及 status→icon/label 的映射,可考虑抽成 composable 或共享 util 减少重复。 \ No newline at end of file diff --git a/scripts/deploy.sh b/scripts/deploy.sh index c9a2643..6d60bca 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -7,12 +7,12 @@ OCI_HOST="oci" OCI_DIR="~/src/tori" IMAGE="registry.oci.euphon.net/tori:latest" -echo "==> Syncing config.yaml to OCI..." -rsync -az config.yaml "${OCI_HOST}:${OCI_DIR}/config.yaml" +echo "==> Syncing project to OCI..." +rsync -az --exclude target --exclude node_modules --exclude .git --exclude web/dist . "${OCI_HOST}:${OCI_DIR}/" -echo "==> Pushing code to OCI..." -git push origin main -ssh "$OCI_HOST" "cd $OCI_DIR && git pull" +echo "==> Building Rust binary on OCI..." +ssh "$OCI_HOST" "source ~/.cargo/env && cd $OCI_DIR && \ + cargo build --release --target aarch64-unknown-linux-musl" echo "==> Building and deploying on OCI..." ssh "$OCI_HOST" "cd $OCI_DIR && \ diff --git a/src/agent.rs b/src/agent.rs index 2e8e97f..7f6f843 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -1,12 +1,58 @@ use std::collections::HashMap; use std::sync::Arc; +use std::sync::atomic::{AtomicU16, Ordering}; use serde::{Deserialize, Serialize}; use sqlx::sqlite::SqlitePool; use tokio::sync::{mpsc, RwLock, broadcast}; -use crate::llm::{LlmClient, ChatMessage}; -use crate::ssh::SshExecutor; -use crate::{LlmConfig, SshConfig}; +use crate::llm::{LlmClient, ChatMessage, Tool, ToolFunction}; +use crate::exec::LocalExecutor; +use crate::LlmConfig; + +// --- State machine types --- + +#[derive(Debug, Clone)] +enum AgentPhase { + Planning, + Executing { step: i32 }, + Completed, +} + +#[derive(Debug, Clone)] +enum PlanItemStatus { + Pending, + Running, + Done, +} + +#[derive(Debug, Clone)] +struct PlanItem { + order: i32, + title: String, + description: String, + status: PlanItemStatus, + db_id: String, +} + +#[derive(Debug, Clone)] +struct StepSummary { + order: i32, + summary: String, +} + +struct AgentState { + phase: AgentPhase, + plan: Vec, + step_summaries: Vec, + step_messages: Vec, + memo: String, + log_step_order: i32, +} + +pub struct ServiceInfo { + pub port: u16, + pub pid: u32, +} #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] @@ -21,6 +67,9 @@ pub enum WsMessage { PlanUpdate { workflow_id: String, steps: Vec }, StepStatusUpdate { step_id: String, status: String, output: String }, WorkflowStatusUpdate { workflow_id: String, status: String }, + RequirementUpdate { workflow_id: String, requirement: String }, + ReportReady { workflow_id: String }, + ProjectUpdate { project_id: String, name: String }, Error { message: String }, } @@ -34,22 +83,32 @@ pub struct PlanStepInfo { pub struct AgentManager { agents: RwLock>>, broadcast: RwLock>>, + pub services: RwLock>, + next_port: AtomicU16, pool: SqlitePool, llm_config: LlmConfig, - ssh_config: SshConfig, } impl AgentManager { - pub fn new(pool: SqlitePool, llm_config: LlmConfig, ssh_config: SshConfig) -> Arc { + pub fn new(pool: SqlitePool, llm_config: LlmConfig) -> Arc { Arc::new(Self { agents: RwLock::new(HashMap::new()), broadcast: RwLock::new(HashMap::new()), + services: RwLock::new(HashMap::new()), + next_port: AtomicU16::new(9100), pool, llm_config, - ssh_config, }) } + pub fn allocate_port(&self) -> u16 { + self.next_port.fetch_add(1, Ordering::Relaxed) + } + + pub async fn get_service_port(&self, project_id: &str) -> Option { + self.services.read().await.get(project_id).map(|s| s.port) + } + pub async fn get_broadcast(&self, project_id: &str) -> broadcast::Receiver { let mut map = self.broadcast.write().await; let tx = map.entry(project_id.to_string()) @@ -82,10 +141,8 @@ impl AgentManager { .clone() }; - let pool = self.pool.clone(); - let llm_config = self.llm_config.clone(); - let ssh_config = self.ssh_config.clone(); - tokio::spawn(agent_loop(project_id, rx, broadcast_tx, pool, llm_config, ssh_config)); + let mgr = Arc::clone(self); + tokio::spawn(agent_loop(project_id, rx, broadcast_tx, mgr)); } } @@ -93,161 +150,108 @@ async fn agent_loop( project_id: String, mut rx: mpsc::Receiver, broadcast_tx: broadcast::Sender, - pool: SqlitePool, - llm_config: LlmConfig, - ssh_config: SshConfig, + mgr: Arc, ) { + let pool = mgr.pool.clone(); + let llm_config = mgr.llm_config.clone(); let llm = LlmClient::new(&llm_config); - let ssh = SshExecutor::new(&ssh_config); + let exec = LocalExecutor::new(); + let workdir = format!("/app/data/workspaces/{}", project_id); tracing::info!("Agent loop started for project {}", project_id); while let Some(event) = rx.recv().await { match event { AgentEvent::NewRequirement { workflow_id, requirement } => { + tracing::info!("Processing new requirement for workflow {}", workflow_id); + // Generate project title in background (don't block the agent loop) + { + let title_llm = LlmClient::new(&llm_config); + let title_pool = pool.clone(); + let title_btx = broadcast_tx.clone(); + let title_pid = project_id.clone(); + let title_req = requirement.clone(); + tokio::spawn(async move { + if let Ok(title) = generate_title(&title_llm, &title_req).await { + let _ = sqlx::query("UPDATE projects SET name = ? WHERE id = ?") + .bind(&title) + .bind(&title_pid) + .execute(&title_pool) + .await; + let _ = title_btx.send(WsMessage::ProjectUpdate { + project_id: title_pid, + name: title, + }); + } + }); + } + let _ = broadcast_tx.send(WsMessage::WorkflowStatusUpdate { workflow_id: workflow_id.clone(), - status: "planning".into(), + status: "executing".into(), + }); + let _ = sqlx::query("UPDATE workflows SET status = 'executing' WHERE id = ?") + .bind(&workflow_id) + .execute(&pool) + .await; + + // Ensure workspace and venv exist + 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 _ = tokio::fs::write(format!("{}/requirement.md", workdir), &requirement).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, + ).await; + + let final_status = if result.is_ok() { "done" } else { "failed" }; + tracing::info!("Agent loop finished for workflow {}, status: {}", workflow_id, final_status); + if let Err(e) = &result { + tracing::error!("Agent error for workflow {}: {}", workflow_id, e); + let _ = broadcast_tx.send(WsMessage::Error { + message: format!("Agent error: {}", e), + }); + } + + let _ = sqlx::query("UPDATE workflows SET status = ? WHERE id = ?") + .bind(final_status) + .bind(&workflow_id) + .execute(&pool) + .await; + let _ = broadcast_tx.send(WsMessage::WorkflowStatusUpdate { + workflow_id: workflow_id.clone(), + status: final_status.into(), }); - let plan_result = generate_plan(&llm, &requirement).await; + // Generate report from recorded steps + let all_steps = sqlx::query_as::<_, crate::db::PlanStep>( + "SELECT * FROM plan_steps WHERE workflow_id = ? ORDER BY step_order" + ) + .bind(&workflow_id) + .fetch_all(&pool) + .await + .unwrap_or_default(); - match plan_result { - Ok(steps) => { - // Save steps to DB - for (i, step) in steps.iter().enumerate() { - let step_id = uuid::Uuid::new_v4().to_string(); - let _ = sqlx::query( - "INSERT INTO plan_steps (id, workflow_id, step_order, description, status) VALUES (?, ?, ?, ?, 'pending')" - ) - .bind(&step_id) - .bind(&workflow_id) - .bind(i as i32 + 1) - .bind(&step.description) - .execute(&pool) - .await; - } - - let _ = broadcast_tx.send(WsMessage::PlanUpdate { - workflow_id: workflow_id.clone(), - steps: steps.clone(), - }); - - let _ = sqlx::query("UPDATE workflows SET status = 'executing' WHERE id = ?") - .bind(&workflow_id) - .execute(&pool) - .await; - - let _ = broadcast_tx.send(WsMessage::WorkflowStatusUpdate { - workflow_id: workflow_id.clone(), - status: "executing".into(), - }); - - // Execute each step - let db_steps = sqlx::query_as::<_, crate::db::PlanStep>( - "SELECT * FROM plan_steps WHERE workflow_id = ? ORDER BY step_order" - ) + if let Ok(report) = generate_report(&llm, &requirement, &all_steps, &project_id).await { + let _ = sqlx::query("UPDATE workflows SET report = ? WHERE id = ?") + .bind(&report) .bind(&workflow_id) - .fetch_all(&pool) - .await - .unwrap_or_default(); - - let mut all_ok = true; - for (i, db_step) in db_steps.iter().enumerate() { - let _ = sqlx::query("UPDATE plan_steps SET status = 'running' WHERE id = ?") - .bind(&db_step.id) - .execute(&pool) - .await; - - let _ = broadcast_tx.send(WsMessage::StepStatusUpdate { - step_id: db_step.id.clone(), - status: "running".into(), - output: String::new(), - }); - - let cmd = &steps[i].command; - if cmd.is_empty() { - let _ = sqlx::query("UPDATE plan_steps SET status = 'done', output = 'Skipped (no command)' WHERE id = ?") - .bind(&db_step.id) - .execute(&pool) - .await; - let _ = broadcast_tx.send(WsMessage::StepStatusUpdate { - step_id: db_step.id.clone(), - status: "done".into(), - output: "Skipped (no command)".into(), - }); - continue; - } - - match ssh.execute(cmd).await { - Ok(result) => { - let output = if result.stderr.is_empty() { - result.stdout.clone() - } else { - format!("{}\nSTDERR: {}", result.stdout, result.stderr) - }; - let status = if result.exit_code == 0 { "done" } else { "failed" }; - - let _ = sqlx::query("UPDATE plan_steps SET status = ?, output = ? WHERE id = ?") - .bind(status) - .bind(&output) - .bind(&db_step.id) - .execute(&pool) - .await; - let _ = broadcast_tx.send(WsMessage::StepStatusUpdate { - step_id: db_step.id.clone(), - status: status.into(), - output, - }); - - if result.exit_code != 0 { - all_ok = false; - break; - } - } - Err(e) => { - let _ = sqlx::query("UPDATE plan_steps SET status = 'failed', output = ? WHERE id = ?") - .bind(e.to_string()) - .bind(&db_step.id) - .execute(&pool) - .await; - let _ = broadcast_tx.send(WsMessage::StepStatusUpdate { - step_id: db_step.id.clone(), - status: "failed".into(), - output: e.to_string(), - }); - all_ok = false; - break; - } - } - } - - let final_status = if all_ok { "done" } else { "failed" }; - let _ = sqlx::query("UPDATE workflows SET status = ? WHERE id = ?") - .bind(final_status) - .bind(&workflow_id) - .execute(&pool) - .await; - let _ = broadcast_tx.send(WsMessage::WorkflowStatusUpdate { - workflow_id: workflow_id.clone(), - status: final_status.into(), - }); - } - Err(e) => { - let _ = broadcast_tx.send(WsMessage::Error { - message: format!("Plan generation failed: {}", e), - }); - let _ = sqlx::query("UPDATE workflows SET status = 'failed' WHERE id = ?") - .bind(&workflow_id) - .execute(&pool) - .await; - } + .execute(&pool) + .await; + let _ = broadcast_tx.send(WsMessage::ReportReady { + workflow_id: workflow_id.clone(), + }); } } AgentEvent::Comment { workflow_id, content } => { tracing::info!("Comment on workflow {}: {}", workflow_id, content); - // Get current workflow and steps for context let wf = sqlx::query_as::<_, crate::db::Workflow>( "SELECT * FROM workflows WHERE id = ?", ) @@ -259,180 +263,78 @@ async fn agent_loop( let Some(wf) = wf else { continue }; - let current_steps = sqlx::query_as::<_, crate::db::PlanStep>( - "SELECT * FROM plan_steps WHERE workflow_id = ? ORDER BY step_order", + // Ensure venv exists for comment re-runs too + 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; + } + + // Clear old plan steps (keep log entries for history) + let _ = sqlx::query("DELETE FROM plan_steps WHERE workflow_id = ? AND kind = 'plan'") + .bind(&workflow_id) + .execute(&pool) + .await; + let _ = broadcast_tx.send(WsMessage::PlanUpdate { + workflow_id: workflow_id.clone(), + steps: vec![], + }); + + // Re-run agent loop with comment as additional context + let _ = broadcast_tx.send(WsMessage::WorkflowStatusUpdate { + workflow_id: workflow_id.clone(), + status: "executing".into(), + }); + let _ = sqlx::query("UPDATE workflows SET status = 'executing' WHERE id = ?") + .bind(&workflow_id) + .execute(&pool) + .await; + + let combined = format!( + "原始需求:\n{}\n\n用户反馈:\n{}\n\n请处理用户的反馈。如果反馈改变了目标方向,请使用 update_requirement 更新需求。继续在同一工作区中工作。", + wf.requirement, content + ); + + let result = run_agent_loop( + &llm, &exec, &pool, &broadcast_tx, + &project_id, &workflow_id, &combined, &workdir, &mgr, + ).await; + + let final_status = if result.is_ok() { "done" } else { "failed" }; + if let Err(e) = &result { + let _ = broadcast_tx.send(WsMessage::Error { + message: format!("Agent error: {}", e), + }); + } + + let _ = sqlx::query("UPDATE workflows SET status = ? WHERE id = ?") + .bind(final_status) + .bind(&workflow_id) + .execute(&pool) + .await; + let _ = broadcast_tx.send(WsMessage::WorkflowStatusUpdate { + workflow_id: workflow_id.clone(), + status: final_status.into(), + }); + + // Regenerate report + let all_steps = sqlx::query_as::<_, crate::db::PlanStep>( + "SELECT * FROM plan_steps WHERE workflow_id = ? ORDER BY step_order" ) .bind(&workflow_id) .fetch_all(&pool) .await .unwrap_or_default(); - // Ask LLM to replan - let replan_result = - replan(&llm, &wf.requirement, ¤t_steps, &content).await; - - match replan_result { - Ok(new_steps) => { - // Clear old pending steps, keep done ones - let _ = sqlx::query( - "DELETE FROM plan_steps WHERE workflow_id = ? AND status IN ('pending', 'failed')", - ) + if let Ok(report) = generate_report(&llm, &wf.requirement, &all_steps, &project_id).await { + let _ = sqlx::query("UPDATE workflows SET report = ? WHERE id = ?") + .bind(&report) .bind(&workflow_id) .execute(&pool) .await; - - let done_count = sqlx::query_scalar::<_, i32>( - "SELECT COUNT(*) FROM plan_steps WHERE workflow_id = ?", - ) - .bind(&workflow_id) - .fetch_one(&pool) - .await - .unwrap_or(0); - - for (i, step) in new_steps.iter().enumerate() { - let step_id = uuid::Uuid::new_v4().to_string(); - let _ = sqlx::query( - "INSERT INTO plan_steps (id, workflow_id, step_order, description, status) VALUES (?, ?, ?, ?, 'pending')", - ) - .bind(&step_id) - .bind(&workflow_id) - .bind(done_count + i as i32 + 1) - .bind(&step.description) - .execute(&pool) - .await; - } - - let _ = broadcast_tx.send(WsMessage::PlanUpdate { - workflow_id: workflow_id.clone(), - steps: new_steps.clone(), - }); - - // Resume execution - let _ = sqlx::query( - "UPDATE workflows SET status = 'executing' WHERE id = ?", - ) - .bind(&workflow_id) - .execute(&pool) - .await; - - let _ = broadcast_tx.send(WsMessage::WorkflowStatusUpdate { - workflow_id: workflow_id.clone(), - status: "executing".into(), - }); - - let db_steps = sqlx::query_as::<_, crate::db::PlanStep>( - "SELECT * FROM plan_steps WHERE workflow_id = ? AND status = 'pending' ORDER BY step_order", - ) - .bind(&workflow_id) - .fetch_all(&pool) - .await - .unwrap_or_default(); - - let mut all_ok = true; - for (i, db_step) in db_steps.iter().enumerate() { - let _ = sqlx::query( - "UPDATE plan_steps SET status = 'running' WHERE id = ?", - ) - .bind(&db_step.id) - .execute(&pool) - .await; - - let _ = broadcast_tx.send(WsMessage::StepStatusUpdate { - step_id: db_step.id.clone(), - status: "running".into(), - output: String::new(), - }); - - let cmd = if i < new_steps.len() { - &new_steps[i].command - } else { - &String::new() - }; - - if cmd.is_empty() { - let _ = sqlx::query( - "UPDATE plan_steps SET status = 'done', output = 'Skipped (no command)' WHERE id = ?", - ) - .bind(&db_step.id) - .execute(&pool) - .await; - let _ = broadcast_tx.send(WsMessage::StepStatusUpdate { - step_id: db_step.id.clone(), - status: "done".into(), - output: "Skipped (no command)".into(), - }); - continue; - } - - match ssh.execute(cmd).await { - Ok(result) => { - let output = if result.stderr.is_empty() { - result.stdout.clone() - } else { - format!("{}\nSTDERR: {}", result.stdout, result.stderr) - }; - let status = if result.exit_code == 0 { - "done" - } else { - "failed" - }; - - let _ = sqlx::query( - "UPDATE plan_steps SET status = ?, output = ? WHERE id = ?", - ) - .bind(status) - .bind(&output) - .bind(&db_step.id) - .execute(&pool) - .await; - let _ = broadcast_tx.send(WsMessage::StepStatusUpdate { - step_id: db_step.id.clone(), - status: status.into(), - output, - }); - - if result.exit_code != 0 { - all_ok = false; - break; - } - } - Err(e) => { - let _ = sqlx::query( - "UPDATE plan_steps SET status = 'failed', output = ? WHERE id = ?", - ) - .bind(e.to_string()) - .bind(&db_step.id) - .execute(&pool) - .await; - let _ = broadcast_tx.send(WsMessage::StepStatusUpdate { - step_id: db_step.id.clone(), - status: "failed".into(), - output: e.to_string(), - }); - all_ok = false; - break; - } - } - } - - let final_status = if all_ok { "done" } else { "failed" }; - let _ = sqlx::query( - "UPDATE workflows SET status = ? WHERE id = ?", - ) - .bind(final_status) - .bind(&workflow_id) - .execute(&pool) - .await; - let _ = broadcast_tx.send(WsMessage::WorkflowStatusUpdate { - workflow_id: workflow_id.clone(), - status: final_status.into(), - }); - } - Err(e) => { - let _ = broadcast_tx.send(WsMessage::Error { - message: format!("Replan failed: {}", e), - }); - } + let _ = broadcast_tx.send(WsMessage::ReportReady { + workflow_id: workflow_id.clone(), + }); } } } @@ -441,80 +343,794 @@ async fn agent_loop( tracing::info!("Agent loop ended for project {}", project_id); } -async fn generate_plan(llm: &LlmClient, requirement: &str) -> anyhow::Result> { - let system_prompt = r#"You are an AI workflow planner. Given a requirement, generate a list of executable steps. +// --- Tool definitions --- -Respond in JSON format only, as an array of objects: -[ - {"order": 1, "description": "what this step does", "command": "shell command to execute via SSH"}, - ... -] - -Keep the plan practical and each command should be a single shell command. -If a step doesn't need a shell command (e.g., verification), set command to empty string."#; - - let response = llm.chat(vec![ - ChatMessage { role: "system".into(), content: system_prompt.into() }, - ChatMessage { role: "user".into(), content: requirement.into() }, - ]).await?; - - let json_str = extract_json_array(&response); - let steps: Vec = serde_json::from_str(json_str)?; - Ok(steps) +fn make_tool(name: &str, description: &str, parameters: serde_json::Value) -> Tool { + Tool { + tool_type: "function".into(), + function: ToolFunction { + name: name.into(), + description: description.into(), + parameters, + }, + } } -fn extract_json_array(response: &str) -> &str { - if let Some(start) = response.find('[') { - if let Some(end) = response.rfind(']') { - return &response[start..=end]; +fn tool_read_file() -> Tool { + make_tool("read_file", "读取工作区中的文件内容", serde_json::json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "工作区内的相对路径" } + }, + "required": ["path"] + })) +} + +fn tool_list_files() -> Tool { + make_tool("list_files", "列出工作区目录中的文件和子目录", serde_json::json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "工作区内的相对路径,默认为根目录" } + } + })) +} + +fn build_planning_tools() -> Vec { + vec![ + make_tool("update_plan", "设置高层执行计划。分析需求后调用此工具提交计划。每个步骤应是一个逻辑阶段(不是具体命令),包含简短标题和详细描述。调用后自动进入执行阶段。", serde_json::json!({ + "type": "object", + "properties": { + "steps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { "type": "string", "description": "步骤标题,简短概括(如'搭建环境')" }, + "description": { "type": "string", "description": "详细描述,说明具体要做什么、为什么" } + }, + "required": ["title", "description"] + }, + "description": "高层计划步骤列表" + } + }, + "required": ["steps"] + })), + tool_list_files(), + tool_read_file(), + ] +} + +fn build_execution_tools() -> Vec { + vec![ + make_tool("execute", "在工作区目录中执行 shell 命令", serde_json::json!({ + "type": "object", + "properties": { + "command": { "type": "string", "description": "要执行的 shell 命令" } + }, + "required": ["command"] + })), + tool_read_file(), + make_tool("write_file", "在工作区中写入文件(自动创建父目录)", serde_json::json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "工作区内的相对路径" }, + "content": { "type": "string", "description": "要写入的文件内容" } + }, + "required": ["path", "content"] + })), + tool_list_files(), + make_tool("start_service", "启动后台服务进程(如 FastAPI 应用)。系统会自动分配端口并通过环境变量 PORT 传入。服务启动后可通过 /api/projects/{project_id}/app/ 访问。注意:启动命令应监听 0.0.0.0:$PORT。", serde_json::json!({ + "type": "object", + "properties": { + "command": { "type": "string", "description": "启动命令,如 'uvicorn main:app --host 0.0.0.0 --port $PORT'" } + }, + "required": ["command"] + })), + make_tool("stop_service", "停止当前项目正在运行的后台服务进程。", serde_json::json!({ + "type": "object", + "properties": {} + })), + make_tool("update_requirement", "更新项目需求描述。当用户反馈改变了目标方向时使用,新文本会替换原有需求。", serde_json::json!({ + "type": "object", + "properties": { + "requirement": { "type": "string", "description": "更新后的需求描述" } + }, + "required": ["requirement"] + })), + make_tool("advance_step", "完成当前步骤并进入下一步。必须提供当前步骤的工作摘要,摘要会传递给后续步骤作为上下文。", serde_json::json!({ + "type": "object", + "properties": { + "summary": { "type": "string", "description": "当前步骤的工作摘要(将传递给后续步骤)" } + }, + "required": ["summary"] + })), + make_tool("save_memo", "保存备忘录信息,后续所有步骤的上下文中都会包含此内容。用于记录跨步骤需要的关键信息(如文件路径、配置项、重要发现)。", serde_json::json!({ + "type": "object", + "properties": { + "content": { "type": "string", "description": "要保存的备忘录内容" } + }, + "required": ["content"] + })), + ] +} + +fn build_planning_prompt(project_id: &str) -> String { + format!( + "你是一个 AI 智能体,正处于【规划阶段】。你拥有一个独立的工作区目录。\n\ + \n\ + 你的任务:\n\ + 1. 仔细分析用户的需求\n\ + 2. 使用 list_files 和 read_file 检查工作区的现有状态\n\ + 3. 制定一个高层执行计划,调用 update_plan 提交\n\ + \n\ + 计划要求:\n\ + - 每个步骤应是一个逻辑阶段(如'搭建环境'、'实现后端 API'),而非具体命令\n\ + - 每个步骤包含简短标题和详细描述\n\ + - 步骤数量合理(通常 3-8 步)\n\ + \n\ + 调用 update_plan 后,系统会自动进入执行阶段。\n\ + \n\ + 环境信息:\n\ + - 工作目录是独立的项目工作区,Python venv 已预先激活(.venv/)\n\ + - 可用工具:bash、git、curl、uv\n\ + - 静态文件访问:/api/projects/{0}/files/{{filename}}\n\ + - 后台服务访问:/api/projects/{0}/app/\n\ + - 如果要构建 Web 应用,推荐 FastAPI + 前端 HTML,API 请求用相对路径 /api/projects/{0}/app/...\n\ + \n\ + 请使用中文回复。", + project_id, + ) +} + +fn build_execution_prompt(project_id: &str) -> String { + format!( + "你是一个 AI 智能体,正处于【执行阶段】。请专注完成当前步骤的任务。\n\ + \n\ + 可用工具:\n\ + - execute:执行 shell 命令\n\ + - read_file / write_file / list_files:文件操作\n\ + - start_service / stop_service:管理后台服务\n\ + - update_requirement:更新项目需求\n\ + - advance_step:完成当前步骤并进入下一步(必须提供摘要)\n\ + - save_memo:保存备忘录(跨步骤持久化的关键信息)\n\ + \n\ + 工作流程:\n\ + 1. 阅读下方的「当前步骤」描述\n\ + 2. 使用工具执行所需操作\n\ + 3. 完成后调用 advance_step(summary=...) 推进到下一步\n\ + 4. 最后一步完成后,直接回复简要总结(不调用工具)即可结束\n\ + \n\ + 环境信息:\n\ + - 工作目录是独立的项目工作区,Python venv 已预先激活(.venv/)\n\ + - 使用 `uv add <包名>` 或 `pip install <包名>` 安装依赖\n\ + - 静态文件访问:/api/projects/{0}/files/{{filename}}\n\ + - 后台服务访问:/api/projects/{0}/app/(启动命令需监听 0.0.0.0:$PORT)\n\ + \n\ + 请使用中文回复。", + project_id, + ) +} + +fn build_step_context(state: &AgentState, requirement: &str) -> String { + let mut ctx = String::new(); + + // Requirement + ctx.push_str("## 需求\n"); + ctx.push_str(requirement); + ctx.push_str("\n\n"); + + // Plan overview + ctx.push_str("## 计划概览\n"); + let current_step = match &state.phase { + AgentPhase::Executing { step } => *step, + _ => 0, + }; + for item in &state.plan { + let marker = match item.status { + PlanItemStatus::Done => " done", + PlanItemStatus::Running => " >> current", + PlanItemStatus::Pending => "", + }; + ctx.push_str(&format!("{}. {}{}\n", item.order, item.title, marker)); + } + ctx.push('\n'); + + // Current step detail + if let Some(item) = state.plan.iter().find(|p| p.order == current_step) { + ctx.push_str(&format!("## 当前步骤(步骤 {})\n", current_step)); + ctx.push_str(&format!("标题:{}\n", item.title)); + ctx.push_str(&format!("描述:{}\n\n", item.description)); + } + + // Completed step summaries + if !state.step_summaries.is_empty() { + ctx.push_str("## 已完成步骤摘要\n"); + for s in &state.step_summaries { + ctx.push_str(&format!("- 步骤 {}: {}\n", s.order, s.summary)); + } + ctx.push('\n'); + } + + // Memo + if !state.memo.is_empty() { + ctx.push_str("## 备忘录\n"); + ctx.push_str(&state.memo); + ctx.push('\n'); + } + + ctx +} + +// --- Helpers --- + +/// Truncate a string at a char boundary, returning at most `max_bytes` bytes. +fn truncate_str(s: &str, max_bytes: usize) -> &str { + if s.len() <= max_bytes { + return s; + } + let mut end = max_bytes; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + &s[..end] +} + +// --- Tool execution --- + +async fn execute_tool( + name: &str, + arguments: &str, + workdir: &str, + exec: &LocalExecutor, +) -> String { + let args: serde_json::Value = serde_json::from_str(arguments).unwrap_or_default(); + + match name { + "execute" => { + let cmd = args["command"].as_str().unwrap_or(""); + match exec.execute(cmd, workdir).await { + Ok(r) => { + let mut out = r.stdout; + if !r.stderr.is_empty() { + out.push_str("\nSTDERR: "); + out.push_str(&r.stderr); + } + if r.exit_code != 0 { + out.push_str(&format!("\n[exit code: {}]", r.exit_code)); + } + if out.len() > 8000 { + let truncated = truncate_str(&out, 8000).to_string(); + out = truncated; + out.push_str("\n...(truncated)"); + } + out + } + Err(e) => format!("Error: {}", e), + } + } + "read_file" => { + let path = args["path"].as_str().unwrap_or(""); + if path.contains("..") { + return "Error: path traversal not allowed".into(); + } + let full = std::path::PathBuf::from(workdir).join(path); + match tokio::fs::read_to_string(&full).await { + Ok(content) => { + if content.len() > 8000 { + format!("{}...(truncated, {} bytes total)", truncate_str(&content, 8000), content.len()) + } else { + content + } + } + Err(e) => format!("Error: {}", e), + } + } + "write_file" => { + let path = args["path"].as_str().unwrap_or(""); + let content = args["content"].as_str().unwrap_or(""); + if path.contains("..") { + return "Error: path traversal not allowed".into(); + } + let full = std::path::PathBuf::from(workdir).join(path); + if let Some(parent) = full.parent() { + let _ = tokio::fs::create_dir_all(parent).await; + } + match tokio::fs::write(&full, content).await { + Ok(()) => format!("Written {} bytes to {}", content.len(), path), + Err(e) => format!("Error: {}", e), + } + } + "list_files" => { + let path = args["path"].as_str().unwrap_or("."); + if path.contains("..") { + return "Error: path traversal not allowed".into(); + } + let full = std::path::PathBuf::from(workdir).join(path); + match tokio::fs::read_dir(&full).await { + Ok(mut entries) => { + let mut items = Vec::new(); + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name().to_string_lossy().to_string(); + let is_dir = entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false); + items.push(if is_dir { format!("{}/", name) } else { name }); + } + items.sort(); + if items.is_empty() { "(empty directory)".into() } else { items.join("\n") } + } + Err(e) => format!("Error: {}", e), + } + } + _ => format!("Unknown tool: {}", name), + } +} + +// --- Tool-calling agent loop (state machine) --- + +async fn run_agent_loop( + llm: &LlmClient, + exec: &LocalExecutor, + pool: &SqlitePool, + broadcast_tx: &broadcast::Sender, + project_id: &str, + workflow_id: &str, + requirement: &str, + workdir: &str, + mgr: &Arc, +) -> anyhow::Result<()> { + let planning_tools = build_planning_tools(); + let execution_tools = build_execution_tools(); + + let mut state = AgentState { + phase: AgentPhase::Planning, + plan: Vec::new(), + step_summaries: Vec::new(), + step_messages: Vec::new(), + memo: String::new(), + log_step_order: sqlx::query_scalar::<_, i32>( + "SELECT COALESCE(MAX(step_order), 0) FROM plan_steps WHERE workflow_id = ?" + ) + .bind(workflow_id) + .fetch_one(pool) + .await + .unwrap_or(0), + }; + + for iteration in 0..80 { + // Build messages and select tools based on current phase + let (messages, tools) = match &state.phase { + AgentPhase::Planning => { + let mut msgs = vec![ + ChatMessage::system(&build_planning_prompt(project_id)), + ChatMessage::user(requirement), + ]; + msgs.extend(state.step_messages.clone()); + (msgs, &planning_tools) + } + AgentPhase::Executing { .. } => { + let step_ctx = build_step_context(&state, requirement); + let mut msgs = vec![ + ChatMessage::system(&build_execution_prompt(project_id)), + ChatMessage::user(&step_ctx), + ]; + msgs.extend(state.step_messages.clone()); + (msgs, &execution_tools) + } + AgentPhase::Completed => break, + }; + + tracing::info!("[workflow {}] LLM call #{} phase={:?} msgs={}", workflow_id, iteration + 1, state.phase, messages.len()); + let response = match llm.chat_with_tools(messages, tools).await { + Ok(r) => r, + Err(e) => { + tracing::error!("[workflow {}] LLM call failed: {}", workflow_id, e); + return Err(e); + } + }; + + let choice = response.choices.into_iter().next() + .ok_or_else(|| anyhow::anyhow!("No response from LLM"))?; + + // Add assistant message to step-local history + state.step_messages.push(choice.message.clone()); + + if let Some(tool_calls) = &choice.message.tool_calls { + tracing::info!("[workflow {}] Tool calls: {}", workflow_id, + tool_calls.iter().map(|tc| tc.function.name.as_str()).collect::>().join(", ")); + + let mut phase_transition = false; + + for tc in tool_calls { + if phase_transition { + // Give dummy results for remaining tool calls after a transition + state.step_messages.push(ChatMessage::tool_result(&tc.id, "(skipped: phase transition)")); + continue; + } + + let args: serde_json::Value = serde_json::from_str(&tc.function.arguments).unwrap_or_default(); + + match tc.function.name.as_str() { + "update_plan" => { + let raw_steps = args["steps"].as_array().cloned().unwrap_or_default(); + + // Clear old plan steps in DB + let _ = sqlx::query("DELETE FROM plan_steps WHERE workflow_id = ? AND kind = 'plan'") + .bind(workflow_id) + .execute(pool) + .await; + + let mut plan_infos = Vec::new(); + state.plan.clear(); + + for (i, item) in raw_steps.iter().enumerate() { + let sid = uuid::Uuid::new_v4().to_string(); + let order = (i + 1) as i32; + let title = item["title"].as_str().unwrap_or(item.as_str().unwrap_or("")).to_string(); + let detail = item["description"].as_str().unwrap_or("").to_string(); + + let _ = sqlx::query( + "INSERT INTO plan_steps (id, workflow_id, step_order, description, command, status, created_at, kind) VALUES (?, ?, ?, ?, ?, 'pending', datetime('now'), 'plan')" + ) + .bind(&sid) + .bind(workflow_id) + .bind(order) + .bind(&title) + .bind(&detail) + .execute(pool) + .await; + + plan_infos.push(PlanStepInfo { order, description: title.clone(), command: detail.clone() }); + state.plan.push(PlanItem { + order, + title, + description: detail, + status: PlanItemStatus::Pending, + db_id: sid, + }); + } + + let _ = broadcast_tx.send(WsMessage::PlanUpdate { + workflow_id: workflow_id.to_string(), + steps: plan_infos, + }); + + // Transition: Planning → Executing(1) + if let Some(first) = state.plan.first_mut() { + first.status = PlanItemStatus::Running; + let _ = sqlx::query("UPDATE plan_steps SET status = 'running' WHERE id = ?") + .bind(&first.db_id) + .execute(pool) + .await; + let _ = broadcast_tx.send(WsMessage::StepStatusUpdate { + step_id: first.db_id.clone(), + status: "running".into(), + output: String::new(), + }); + } + + state.step_messages.clear(); + state.phase = AgentPhase::Executing { step: 1 }; + phase_transition = true; + tracing::info!("[workflow {}] Plan set ({} steps), entering Executing(1)", workflow_id, state.plan.len()); + } + + "advance_step" => { + let summary = args["summary"].as_str().unwrap_or("").to_string(); + let current_step = match &state.phase { + AgentPhase::Executing { step } => *step, + _ => 0, + }; + + // Mark current step done + if let Some(item) = state.plan.iter_mut().find(|p| p.order == current_step) { + item.status = PlanItemStatus::Done; + let _ = sqlx::query("UPDATE plan_steps SET status = 'done' WHERE id = ?") + .bind(&item.db_id) + .execute(pool) + .await; + let _ = broadcast_tx.send(WsMessage::StepStatusUpdate { + step_id: item.db_id.clone(), + status: "done".into(), + output: String::new(), + }); + } + + state.step_summaries.push(StepSummary { + order: current_step, + summary, + }); + + // Move to next step or complete + let next_step = current_step + 1; + if let Some(next_item) = state.plan.iter_mut().find(|p| p.order == next_step) { + next_item.status = PlanItemStatus::Running; + let _ = sqlx::query("UPDATE plan_steps SET status = 'running' WHERE id = ?") + .bind(&next_item.db_id) + .execute(pool) + .await; + let _ = broadcast_tx.send(WsMessage::StepStatusUpdate { + step_id: next_item.db_id.clone(), + status: "running".into(), + output: String::new(), + }); + state.phase = AgentPhase::Executing { step: next_step }; + tracing::info!("[workflow {}] Advanced to step {}", workflow_id, next_step); + } else { + state.phase = AgentPhase::Completed; + tracing::info!("[workflow {}] All steps completed", workflow_id); + } + + state.step_messages.clear(); + phase_transition = true; + } + + "save_memo" => { + let content = args["content"].as_str().unwrap_or(""); + if !state.memo.is_empty() { + state.memo.push('\n'); + } + state.memo.push_str(content); + state.step_messages.push(ChatMessage::tool_result(&tc.id, "备忘录已保存。")); + } + + "update_requirement" => { + let new_req = args["requirement"].as_str().unwrap_or(""); + let _ = sqlx::query("UPDATE workflows SET requirement = ? WHERE id = ?") + .bind(new_req) + .bind(workflow_id) + .execute(pool) + .await; + let _ = tokio::fs::write(format!("{}/requirement.md", workdir), new_req).await; + let _ = broadcast_tx.send(WsMessage::RequirementUpdate { + workflow_id: workflow_id.to_string(), + requirement: new_req.to_string(), + }); + state.step_messages.push(ChatMessage::tool_result(&tc.id, "需求已更新。")); + } + + "start_service" => { + let cmd = args["command"].as_str().unwrap_or(""); + { + let mut services = mgr.services.write().await; + if let Some(old) = services.remove(project_id) { + let _ = nix::sys::signal::kill( + nix::unistd::Pid::from_raw(old.pid as i32), + nix::sys::signal::Signal::SIGTERM, + ); + } + } + let port = mgr.allocate_port(); + let cmd_with_port = cmd.replace("$PORT", &port.to_string()); + let venv_bin = format!("{}/.venv/bin", workdir); + let path_env = match std::env::var("PATH") { + Ok(p) => format!("{}:{}", venv_bin, p), + Err(_) => venv_bin, + }; + match tokio::process::Command::new("sh") + .arg("-c") + .arg(&cmd_with_port) + .current_dir(workdir) + .env("PORT", port.to_string()) + .env("PATH", &path_env) + .env("VIRTUAL_ENV", format!("{}/.venv", workdir)) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + { + Ok(child) => { + let pid = child.id().unwrap_or(0); + mgr.services.write().await.insert(project_id.to_string(), ServiceInfo { port, pid }); + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + let url = format!("/api/projects/{}/app/", project_id); + state.step_messages.push(ChatMessage::tool_result( + &tc.id, + &format!("服务已启动,端口 {},访问地址:{}", port, url), + )); + } + Err(e) => { + state.step_messages.push(ChatMessage::tool_result(&tc.id, &format!("启动失败:{}", e))); + } + } + } + + "stop_service" => { + let mut services = mgr.services.write().await; + if let Some(svc) = services.remove(project_id) { + let _ = nix::sys::signal::kill( + nix::unistd::Pid::from_raw(svc.pid as i32), + nix::sys::signal::Signal::SIGTERM, + ); + state.step_messages.push(ChatMessage::tool_result(&tc.id, "服务已停止。")); + } else { + state.step_messages.push(ChatMessage::tool_result(&tc.id, "当前没有运行中的服务。")); + } + } + + // IO tools: execute, read_file, write_file, list_files + _ => { + let current_plan_step_id = match &state.phase { + AgentPhase::Executing { step } => { + state.plan.iter().find(|p| p.order == *step).map(|p| p.db_id.clone()).unwrap_or_default() + } + _ => String::new(), + }; + + state.log_step_order += 1; + let step_id = uuid::Uuid::new_v4().to_string(); + + let description = match tc.function.name.as_str() { + "execute" => format!("$ {}", args["command"].as_str().unwrap_or("?")), + "read_file" => format!("Read: {}", args["path"].as_str().unwrap_or("?")), + "write_file" => format!("Write: {}", args["path"].as_str().unwrap_or("?")), + "list_files" => format!("List: {}", args["path"].as_str().unwrap_or(".")), + other => other.to_string(), + }; + + let _ = sqlx::query( + "INSERT INTO plan_steps (id, workflow_id, step_order, description, command, status, created_at, plan_step_id) VALUES (?, ?, ?, ?, ?, 'running', datetime('now'), ?)" + ) + .bind(&step_id) + .bind(workflow_id) + .bind(state.log_step_order) + .bind(&description) + .bind(&tc.function.arguments) + .bind(¤t_plan_step_id) + .execute(pool) + .await; + + let _ = broadcast_tx.send(WsMessage::StepStatusUpdate { + step_id: step_id.clone(), + status: "running".into(), + output: String::new(), + }); + + let result = execute_tool(&tc.function.name, &tc.function.arguments, workdir, exec).await; + let status = if result.starts_with("Error:") { "failed" } else { "done" }; + + let _ = sqlx::query("UPDATE plan_steps SET status = ?, output = ? WHERE id = ?") + .bind(status) + .bind(&result) + .bind(&step_id) + .execute(pool) + .await; + + let _ = broadcast_tx.send(WsMessage::StepStatusUpdate { + step_id: step_id.clone(), + status: status.into(), + output: result.clone(), + }); + + state.step_messages.push(ChatMessage::tool_result(&tc.id, &result)); + } + } + } + + // If phase transitioned, the step_messages were cleared; loop continues with fresh context + if phase_transition { + continue; + } + } else { + // No tool calls — LLM sent a text response + let content = choice.message.content.as_deref().unwrap_or("(no content)"); + tracing::info!("[workflow {}] LLM text response: {}", workflow_id, truncate_str(content, 200)); + + match &state.phase { + AgentPhase::Executing { step } => { + // Text response during execution = current step done, workflow complete + let current_step = *step; + if let Some(item) = state.plan.iter_mut().find(|p| p.order == current_step) { + item.status = PlanItemStatus::Done; + let _ = sqlx::query("UPDATE plan_steps SET status = 'done' WHERE id = ?") + .bind(&item.db_id) + .execute(pool) + .await; + let _ = broadcast_tx.send(WsMessage::StepStatusUpdate { + step_id: item.db_id.clone(), + status: "done".into(), + output: String::new(), + }); + } + state.phase = AgentPhase::Completed; + } + AgentPhase::Planning => { + // LLM might be thinking/analyzing before calling update_plan; continue + } + AgentPhase::Completed => break, + } + } + + if matches!(state.phase, AgentPhase::Completed) { + break; } } - response + + // Cleanup: mark any remaining running steps as done + for item in &state.plan { + if matches!(item.status, PlanItemStatus::Running) { + let _ = sqlx::query("UPDATE plan_steps SET status = 'done' WHERE id = ?") + .bind(&item.db_id) + .execute(pool) + .await; + let _ = broadcast_tx.send(WsMessage::StepStatusUpdate { + step_id: item.db_id.clone(), + status: "done".into(), + output: String::new(), + }); + } + } + + Ok(()) } -async fn replan( +async fn generate_report( llm: &LlmClient, requirement: &str, - current_steps: &[crate::db::PlanStep], - comment: &str, -) -> anyhow::Result> { - let steps_summary: String = current_steps + steps: &[crate::db::PlanStep], + project_id: &str, +) -> anyhow::Result { + let steps_detail: String = steps .iter() - .map(|s| format!(" {}. [{}] {}", s.step_order, s.status, s.description)) + .map(|s| { + let output_preview = if s.output.len() > 2000 { + format!("{}...(truncated)", truncate_str(&s.output, 2000)) + } else { + s.output.clone() + }; + format!( + "### Step {} [{}]: {}\nCommand: `{}`\nOutput:\n```\n{}\n```\n", + s.step_order, s.status, s.description, s.command, output_preview + ) + }) .collect::>() .join("\n"); - let system_prompt = r#"You are an AI workflow planner. The user has provided feedback on an existing plan. -Based on the original requirement, current step statuses, and user feedback, generate ONLY the new/remaining steps that need to be executed. -Do NOT include steps that are already done. - -Respond in JSON format only, as an array of objects: -[ - {"order": 1, "description": "what this step does", "command": "shell command to execute via SSH"}, - ... -] - -If no new steps are needed (feedback is just informational), return an empty array: []"#; - - let user_msg = format!( - "Original requirement:\n{}\n\nCurrent steps:\n{}\n\nUser feedback:\n{}", - requirement, steps_summary, comment + let system_prompt = format!( + "你是一个技术报告撰写者。请生成一份简洁的 Markdown 报告,总结工作流的执行结果。\n\ + \n\ + 报告应包含:\n\ + 1. 标题和简要总结\n\ + 2. 关键结果和产出(从步骤输出中提取重要信息)\n\ + 3. 如果启动了 Web 应用/服务(start_service),在报告顶部醒目标出应用访问地址:`/api/projects/{0}/app/`\n\ + 4. 生成的文件(如果有),引用地址为:`/api/projects/{0}/files/{{{{filename}}}}`\n\ + 5. 遇到的问题(如果有步骤失败)\n\ + \n\ + 格式要求:\n\ + - 简洁明了,重点是结果而非过程\n\ + - 使用 Markdown 格式(标题、代码块、表格、列表)\n\ + - 需要可视化时,使用 ```mermaid 代码块绘制 Mermaid 图表\n\ + - 使用中文撰写", + project_id, ); - let response = llm + let user_msg = format!( + "需求:\n{}\n\n执行详情:\n{}", + requirement, steps_detail + ); + + let report = llm .chat(vec![ - ChatMessage { - role: "system".into(), - content: system_prompt.into(), - }, - ChatMessage { - role: "user".into(), - content: user_msg, - }, + ChatMessage::system(&system_prompt), + ChatMessage::user(&user_msg), ]) .await?; - let json_str = extract_json_array(&response); - let steps: Vec = serde_json::from_str(json_str)?; - Ok(steps) + Ok(report) +} + +async fn generate_title(llm: &LlmClient, requirement: &str) -> anyhow::Result { + let response = llm + .chat(vec![ + ChatMessage::system("为给定的需求生成一个简短的项目标题(最多15个汉字)。只回复标题本身,不要加任何其他内容。使用中文。"), + ChatMessage::user(requirement), + ]) + .await?; + + let mut title = response.trim().trim_matches('"').to_string(); + // Hard limit: if LLM returns garbage, take only the first line, max 80 chars + if let Some(first_line) = title.lines().next() { + title = first_line.to_string(); + } + if title.len() > 80 { + title = truncate_str(&title, 80).to_string(); + } + Ok(title) } diff --git a/src/api/mod.rs b/src/api/mod.rs index 75f2c2c..1e17a74 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,12 +1,115 @@ mod projects; +mod timers; mod workflows; use std::sync::Arc; -use axum::Router; +use axum::{ + body::Body, + extract::{Path, State, Request}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{get, any}, + Router, +}; + use crate::AppState; pub fn router(state: Arc) -> Router { Router::new() .merge(projects::router(state.clone())) - .merge(workflows::router(state)) + .merge(workflows::router(state.clone())) + .merge(timers::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)) } + +async fn proxy_to_service_root( + State(state): State>, + Path(project_id): Path, + req: Request, +) -> Response { + proxy_impl(&state, &project_id, "/", req).await +} + +async fn proxy_to_service( + State(state): State>, + Path((project_id, path)): Path<(String, String)>, + req: Request, +) -> Response { + proxy_impl(&state, &project_id, &format!("/{}", path), req).await +} + +async fn proxy_impl( + state: &AppState, + project_id: &str, + path: &str, + req: Request, +) -> Response { + let port = match state.agent_mgr.get_service_port(project_id).await { + Some(p) => p, + None => return (StatusCode::SERVICE_UNAVAILABLE, "服务未启动").into_response(), + }; + + let query = req.uri().query().map(|q| format!("?{}", q)).unwrap_or_default(); + let url = format!("http://127.0.0.1:{}{}{}", port, path, query); + + let client = reqwest::Client::new(); + let method = req.method().clone(); + let headers = req.headers().clone(); + let body_bytes = match axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024).await { + Ok(b) => b, + Err(_) => return (StatusCode::BAD_REQUEST, "请求体过大").into_response(), + }; + + let mut upstream_req = client.request(method, &url); + for (key, val) in headers.iter() { + if key != "host" { + upstream_req = upstream_req.header(key, val); + } + } + upstream_req = upstream_req.body(body_bytes); + + match upstream_req.send().await { + Ok(resp) => { + let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); + let resp_headers = resp.headers().clone(); + let body = resp.bytes().await.unwrap_or_default(); + let mut response = (status, body).into_response(); + for (key, val) in resp_headers.iter() { + if let Ok(name) = axum::http::header::HeaderName::from_bytes(key.as_ref()) { + response.headers_mut().insert(name, val.clone()); + } + } + response + } + Err(_) => (StatusCode::BAD_GATEWAY, "无法连接到后端服务").into_response(), + } +} + +async fn serve_project_file( + Path((project_id, file_path)): Path<(String, String)>, +) -> Response { + let full_path = std::path::PathBuf::from("/app/data/workspaces") + .join(&project_id) + .join(&file_path); + + // Prevent path traversal + if file_path.contains("..") { + return (StatusCode::BAD_REQUEST, "Invalid path").into_response(); + } + + match tokio::fs::read(&full_path).await { + Ok(bytes) => { + let mime = mime_guess::from_path(&full_path) + .first_or_octet_stream() + .to_string(); + ( + [(axum::http::header::CONTENT_TYPE, mime)], + bytes, + ).into_response() + } + Err(_) => (StatusCode::NOT_FOUND, "File not found").into_response(), + } +} + diff --git a/src/api/timers.rs b/src/api/timers.rs new file mode 100644 index 0000000..9f0df41 --- /dev/null +++ b/src/api/timers.rs @@ -0,0 +1,147 @@ +use std::sync::Arc; +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::get, + Json, Router, +}; +use serde::Deserialize; +use crate::AppState; +use crate::db::Timer; + +type ApiResult = Result, Response>; + +fn db_err(e: sqlx::Error) -> Response { + tracing::error!("Database error: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() +} + +#[derive(Deserialize)] +pub struct CreateTimer { + pub name: String, + pub interval_secs: i64, + pub requirement: String, +} + +#[derive(Deserialize)] +pub struct UpdateTimer { + pub name: Option, + pub interval_secs: Option, + pub requirement: Option, + pub enabled: Option, +} + +pub fn router(state: Arc) -> Router { + Router::new() + .route("/projects/{id}/timers", get(list_timers).post(create_timer)) + .route("/timers/{id}", get(get_timer).put(update_timer).delete(delete_timer)) + .with_state(state) +} + +async fn list_timers( + State(state): State>, + Path(project_id): Path, +) -> ApiResult> { + sqlx::query_as::<_, Timer>( + "SELECT * FROM timers WHERE project_id = ? ORDER BY created_at DESC" + ) + .bind(&project_id) + .fetch_all(&state.db.pool) + .await + .map(Json) + .map_err(db_err) +} + +async fn create_timer( + State(state): State>, + Path(project_id): Path, + Json(input): Json, +) -> ApiResult { + if input.interval_secs < 60 { + return Err((StatusCode::BAD_REQUEST, "Minimum interval is 60 seconds").into_response()); + } + + let id = uuid::Uuid::new_v4().to_string(); + sqlx::query_as::<_, Timer>( + "INSERT INTO timers (id, project_id, name, interval_secs, requirement) VALUES (?, ?, ?, ?, ?) RETURNING *" + ) + .bind(&id) + .bind(&project_id) + .bind(&input.name) + .bind(input.interval_secs) + .bind(&input.requirement) + .fetch_one(&state.db.pool) + .await + .map(Json) + .map_err(db_err) +} + +async fn get_timer( + State(state): State>, + Path(timer_id): Path, +) -> ApiResult { + sqlx::query_as::<_, Timer>("SELECT * FROM timers WHERE id = ?") + .bind(&timer_id) + .fetch_optional(&state.db.pool) + .await + .map_err(db_err)? + .map(Json) + .ok_or_else(|| (StatusCode::NOT_FOUND, "Timer not found").into_response()) +} + +async fn update_timer( + State(state): State>, + Path(timer_id): Path, + Json(input): Json, +) -> ApiResult { + // Fetch existing + let existing = sqlx::query_as::<_, Timer>("SELECT * FROM timers WHERE id = ?") + .bind(&timer_id) + .fetch_optional(&state.db.pool) + .await + .map_err(db_err)?; + + let Some(existing) = existing else { + return Err((StatusCode::NOT_FOUND, "Timer not found").into_response()); + }; + + let name = input.name.unwrap_or(existing.name); + let interval_secs = input.interval_secs.unwrap_or(existing.interval_secs); + let requirement = input.requirement.unwrap_or(existing.requirement); + let enabled = input.enabled.unwrap_or(existing.enabled); + + if interval_secs < 60 { + return Err((StatusCode::BAD_REQUEST, "Minimum interval is 60 seconds").into_response()); + } + + sqlx::query_as::<_, Timer>( + "UPDATE timers SET name = ?, interval_secs = ?, requirement = ?, enabled = ? WHERE id = ? RETURNING *" + ) + .bind(&name) + .bind(interval_secs) + .bind(&requirement) + .bind(enabled) + .bind(&timer_id) + .fetch_one(&state.db.pool) + .await + .map(Json) + .map_err(db_err) +} + +async fn delete_timer( + State(state): State>, + Path(timer_id): Path, +) -> Result { + let result = sqlx::query("DELETE FROM timers WHERE id = ?") + .bind(&timer_id) + .execute(&state.db.pool) + .await + .map_err(db_err)?; + + if result.rows_affected() > 0 { + Ok(StatusCode::NO_CONTENT) + } else { + Err((StatusCode::NOT_FOUND, "Timer not found").into_response()) + } +} diff --git a/src/api/workflows.rs b/src/api/workflows.rs index 270d7ca..3f6e800 100644 --- a/src/api/workflows.rs +++ b/src/api/workflows.rs @@ -11,6 +11,11 @@ use crate::AppState; use crate::agent::AgentEvent; use crate::db::{Workflow, PlanStep, Comment}; +#[derive(serde::Serialize)] +struct ReportResponse { + report: String, +} + type ApiResult = Result, Response>; fn db_err(e: sqlx::Error) -> Response { @@ -33,6 +38,7 @@ pub fn router(state: Arc) -> Router { .route("/projects/{id}/workflows", get(list_workflows).post(create_workflow)) .route("/workflows/{id}/steps", get(list_steps)) .route("/workflows/{id}/comments", get(list_comments).post(create_comment)) + .route("/workflows/{id}/report", get(get_report)) .with_state(state) } @@ -134,3 +140,22 @@ async fn create_comment( Ok(Json(comment)) } + +async fn get_report( + State(state): State>, + Path(workflow_id): Path, +) -> Result, Response> { + let wf = sqlx::query_as::<_, Workflow>( + "SELECT * FROM workflows WHERE id = ?" + ) + .bind(&workflow_id) + .fetch_optional(&state.db.pool) + .await + .map_err(db_err)?; + + match wf { + Some(w) if !w.report.is_empty() => Ok(Json(ReportResponse { report: w.report })), + Some(_) => Err((StatusCode::NOT_FOUND, "Report not yet generated").into_response()), + None => Err((StatusCode::NOT_FOUND, "Workflow not found").into_response()), + } +} diff --git a/src/db.rs b/src/db.rs index 2794a75..31d0eed 100644 --- a/src/db.rs +++ b/src/db.rs @@ -47,6 +47,7 @@ impl Database { workflow_id TEXT NOT NULL REFERENCES workflows(id), step_order INTEGER NOT NULL, description TEXT NOT NULL, + command TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'pending', output TEXT NOT NULL DEFAULT '' )" @@ -65,6 +66,49 @@ impl Database { .execute(&self.pool) .await?; + // Migration: add report column to workflows + let _ = sqlx::query( + "ALTER TABLE workflows ADD COLUMN report TEXT NOT NULL DEFAULT ''" + ) + .execute(&self.pool) + .await; + + // Migration: add created_at to plan_steps + let _ = sqlx::query( + "ALTER TABLE plan_steps ADD COLUMN created_at TEXT NOT NULL DEFAULT ''" + ) + .execute(&self.pool) + .await; + + // Migration: add kind to plan_steps ('plan' or 'log') + let _ = sqlx::query( + "ALTER TABLE plan_steps ADD COLUMN kind TEXT NOT NULL DEFAULT 'log'" + ) + .execute(&self.pool) + .await; + + // Migration: add plan_step_id to plan_steps (log entries reference their parent plan step) + let _ = sqlx::query( + "ALTER TABLE plan_steps ADD COLUMN plan_step_id TEXT NOT NULL DEFAULT ''" + ) + .execute(&self.pool) + .await; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS timers ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id), + name TEXT NOT NULL, + interval_secs INTEGER NOT NULL, + requirement TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + last_run_at TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )" + ) + .execute(&self.pool) + .await?; + Ok(()) } } @@ -85,6 +129,7 @@ pub struct Workflow { pub requirement: String, pub status: String, pub created_at: String, + pub report: String, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] @@ -93,8 +138,12 @@ pub struct PlanStep { pub workflow_id: String, pub step_order: i32, pub description: String, + pub command: String, pub status: String, pub output: String, + pub created_at: String, + pub kind: String, + pub plan_step_id: String, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] @@ -104,3 +153,15 @@ pub struct Comment { pub content: String, pub created_at: String, } + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Timer { + pub id: String, + pub project_id: String, + pub name: String, + pub interval_secs: i64, + pub requirement: String, + pub enabled: bool, + pub last_run_at: String, + pub created_at: String, +} diff --git a/src/exec.rs b/src/exec.rs new file mode 100644 index 0000000..370c65c --- /dev/null +++ b/src/exec.rs @@ -0,0 +1,40 @@ +pub struct LocalExecutor; + +impl LocalExecutor { + pub fn new() -> Self { + Self + } + + pub async fn execute(&self, command: &str, workdir: &str) -> anyhow::Result { + // Ensure workdir exists + tokio::fs::create_dir_all(workdir).await?; + + // Prepend venv bin to PATH so `python3`/`pip` resolve to venv + let venv_bin = format!("{}/.venv/bin", workdir); + let path = match std::env::var("PATH") { + Ok(p) => format!("{}:{}", venv_bin, p), + Err(_) => venv_bin, + }; + + let output = tokio::process::Command::new("sh") + .arg("-c") + .arg(command) + .current_dir(workdir) + .env("PATH", &path) + .env("VIRTUAL_ENV", format!("{}/.venv", workdir)) + .output() + .await?; + + Ok(ExecResult { + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + exit_code: output.status.code().unwrap_or(-1), + }) + } +} + +pub struct ExecResult { + pub stdout: String, + pub stderr: String, + pub exit_code: i32, +} diff --git a/src/llm.rs b/src/llm.rs index 081dcc4..e99ce08 100644 --- a/src/llm.rs +++ b/src/llm.rs @@ -10,22 +10,73 @@ pub struct LlmClient { struct ChatRequest { model: String, messages: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + tools: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChatMessage { pub role: String, - pub content: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_calls: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option, +} + +impl ChatMessage { + pub fn system(content: &str) -> Self { + Self { role: "system".into(), content: Some(content.into()), tool_calls: None, tool_call_id: None } + } + + pub fn user(content: &str) -> Self { + Self { role: "user".into(), content: Some(content.into()), tool_calls: None, tool_call_id: None } + } + + pub fn tool_result(tool_call_id: &str, content: &str) -> Self { + Self { role: "tool".into(), content: Some(content.into()), tool_calls: None, tool_call_id: Some(tool_call_id.into()) } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Tool { + #[serde(rename = "type")] + pub tool_type: String, + pub function: ToolFunction, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolFunction { + pub name: String, + pub description: String, + pub parameters: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCall { + pub id: String, + #[serde(rename = "type")] + pub call_type: String, + pub function: ToolCallFunction, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCallFunction { + pub name: String, + pub arguments: String, } #[derive(Debug, Deserialize)] -struct ChatResponse { - choices: Vec, +pub struct ChatResponse { + pub choices: Vec, } #[derive(Debug, Deserialize)] -struct Choice { - message: ChatMessage, +pub struct ChatChoice { + pub message: ChatMessage, + #[allow(dead_code)] + pub finish_reason: Option, } impl LlmClient { @@ -36,21 +87,42 @@ impl LlmClient { } } + /// Simple chat without tools — returns content string pub async fn chat(&self, messages: Vec) -> anyhow::Result { - let resp = self.client - .post(format!("{}/chat/completions", self.config.base_url)) + let resp = self.chat_with_tools(messages, &[]).await?; + Ok(resp.choices.into_iter().next() + .and_then(|c| c.message.content) + .unwrap_or_default()) + } + + /// Chat with tool definitions — returns full response for tool-calling loop + pub async fn chat_with_tools(&self, messages: Vec, tools: &[Tool]) -> anyhow::Result { + let url = format!("{}/chat/completions", self.config.base_url); + tracing::debug!("LLM request to {} model={} messages={} tools={}", url, self.config.model, messages.len(), tools.len()); + let http_resp = self.client + .post(&url) .header("Authorization", format!("Bearer {}", self.config.api_key)) .json(&ChatRequest { model: self.config.model.clone(), messages, + tools: tools.to_vec(), }) .send() - .await? - .json::() .await?; - Ok(resp.choices.first() - .map(|c| c.message.content.clone()) - .unwrap_or_default()) + let status = http_resp.status(); + if !status.is_success() { + let body = http_resp.text().await.unwrap_or_default(); + tracing::error!("LLM API error {}: {}", status, &body[..body.len().min(500)]); + anyhow::bail!("LLM API error {}: {}", status, body); + } + + let body = http_resp.text().await?; + let resp: ChatResponse = serde_json::from_str(&body).map_err(|e| { + tracing::error!("LLM response parse error: {}. Body: {}", e, &body[..body.len().min(500)]); + anyhow::anyhow!("Failed to parse LLM response: {}", e) + })?; + + Ok(resp) } } diff --git a/src/main.rs b/src/main.rs index 9493dd5..88a9f24 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,13 +2,14 @@ mod api; mod agent; mod db; mod llm; -mod ssh; +mod exec; +mod timer; mod ws; use std::sync::Arc; use axum::Router; use tower_http::cors::CorsLayer; -use tower_http::services::ServeDir; +use tower_http::services::{ServeDir, ServeFile}; pub struct AppState { pub db: db::Database, @@ -19,7 +20,6 @@ pub struct AppState { #[derive(Debug, Clone, serde::Deserialize)] pub struct Config { pub llm: LlmConfig, - pub ssh: SshConfig, pub server: ServerConfig, pub database: DatabaseConfig, } @@ -31,13 +31,6 @@ pub struct LlmConfig { pub model: String, } -#[derive(Debug, Clone, serde::Deserialize)] -pub struct SshConfig { - pub host: String, - pub user: String, - pub key_path: String, -} - #[derive(Debug, Clone, serde::Deserialize)] pub struct ServerConfig { pub host: String, @@ -66,9 +59,10 @@ async fn main() -> anyhow::Result<()> { let agent_mgr = agent::AgentManager::new( database.pool.clone(), config.llm.clone(), - config.ssh.clone(), ); + timer::start_timer_runner(database.pool.clone(), agent_mgr.clone()); + let state = Arc::new(AppState { db: database, config: config.clone(), @@ -78,7 +72,7 @@ async fn main() -> anyhow::Result<()> { let app = Router::new() .nest("/api", api::router(state)) .nest("/ws", ws::router(agent_mgr)) - .fallback_service(ServeDir::new("web/dist")) + .fallback_service(ServeDir::new("web/dist").fallback(ServeFile::new("web/dist/index.html"))) .layer(CorsLayer::permissive()); let addr = format!("{}:{}", config.server.host, config.server.port); diff --git a/src/ssh.rs b/src/ssh.rs deleted file mode 100644 index 64ea256..0000000 --- a/src/ssh.rs +++ /dev/null @@ -1,36 +0,0 @@ -use crate::SshConfig; - -pub struct SshExecutor { - config: SshConfig, -} - -impl SshExecutor { - pub fn new(config: &SshConfig) -> Self { - Self { - config: config.clone(), - } - } - - pub async fn execute(&self, command: &str) -> anyhow::Result { - let output = tokio::process::Command::new("ssh") - .arg("-i") - .arg(&self.config.key_path) - .arg("-o").arg("StrictHostKeyChecking=no") - .arg(format!("{}@{}", self.config.user, self.config.host)) - .arg(command) - .output() - .await?; - - Ok(SshResult { - stdout: String::from_utf8_lossy(&output.stdout).to_string(), - stderr: String::from_utf8_lossy(&output.stderr).to_string(), - exit_code: output.status.code().unwrap_or(-1), - }) - } -} - -pub struct SshResult { - pub stdout: String, - pub stderr: String, - pub exit_code: i32, -} diff --git a/src/timer.rs b/src/timer.rs new file mode 100644 index 0000000..aa9cb49 --- /dev/null +++ b/src/timer.rs @@ -0,0 +1,75 @@ +use std::sync::Arc; +use sqlx::sqlite::SqlitePool; +use crate::agent::{AgentEvent, AgentManager}; +use crate::db::Timer; + +pub fn start_timer_runner(pool: SqlitePool, agent_mgr: Arc) { + tokio::spawn(timer_loop(pool, agent_mgr)); +} + +async fn timer_loop(pool: SqlitePool, agent_mgr: Arc) { + tracing::info!("Timer runner started"); + + loop { + tokio::time::sleep(tokio::time::Duration::from_secs(30)).await; + + if let Err(e) = check_timers(&pool, &agent_mgr).await { + tracing::error!("Timer check error: {}", e); + } + } +} + +async fn check_timers(pool: &SqlitePool, agent_mgr: &Arc) -> anyhow::Result<()> { + let timers = sqlx::query_as::<_, Timer>( + "SELECT * FROM timers WHERE enabled = 1" + ) + .fetch_all(pool) + .await?; + + let now = chrono::Utc::now(); + + for timer in timers { + let due = if timer.last_run_at.is_empty() { + true + } else if let Ok(last) = chrono::NaiveDateTime::parse_from_str(&timer.last_run_at, "%Y-%m-%d %H:%M:%S") { + let last_utc = last.and_utc(); + let elapsed = now.signed_duration_since(last_utc).num_seconds(); + elapsed >= timer.interval_secs + } else { + true + }; + + if !due { + continue; + } + + tracing::info!("Timer '{}' fired for project {}", timer.name, timer.project_id); + + // Update last_run_at + let now_str = now.format("%Y-%m-%d %H:%M:%S").to_string(); + let _ = sqlx::query("UPDATE timers SET last_run_at = ? WHERE id = ?") + .bind(&now_str) + .bind(&timer.id) + .execute(pool) + .await; + + // Create a workflow for this timer + let workflow_id = uuid::Uuid::new_v4().to_string(); + let _ = sqlx::query( + "INSERT INTO workflows (id, project_id, requirement) VALUES (?, ?, ?)" + ) + .bind(&workflow_id) + .bind(&timer.project_id) + .bind(&timer.requirement) + .execute(pool) + .await; + + // Send event to agent + agent_mgr.send_event(&timer.project_id, AgentEvent::NewRequirement { + workflow_id, + requirement: timer.requirement.clone(), + }).await; + } + + Ok(()) +} diff --git a/web/package-lock.json b/web/package-lock.json index dd047d5..26e813a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,6 +8,8 @@ "name": "web", "version": "0.0.0", "dependencies": { + "marked": "^17.0.3", + "mermaid": "^11.12.3", "vue": "^3.5.25" }, "devDependencies": { @@ -19,6 +21,18 @@ "vue-tsc": "^3.1.5" } }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -61,6 +75,45 @@ "node": ">=6.9.0" } }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==" + }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.2.tgz", + "integrity": "sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==", + "dependencies": { + "@chevrotain/gast": "11.1.2", + "@chevrotain/types": "11.1.2", + "lodash-es": "4.17.23" + } + }, + "node_modules/@chevrotain/gast": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.1.2.tgz", + "integrity": "sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==", + "dependencies": { + "@chevrotain/types": "11.1.2", + "lodash-es": "4.17.23" + } + }, + "node_modules/@chevrotain/regexp-to-ast": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.1.2.tgz", + "integrity": "sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw==" + }, + "node_modules/@chevrotain/types": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==" + }, + "node_modules/@chevrotain/utils": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.1.2.tgz", + "integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -477,11 +530,34 @@ "node": ">=18" } }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==" + }, + "node_modules/@iconify/utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.0.tgz", + "integrity": "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "mlly": "^1.8.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" }, + "node_modules/@mermaid-js/parser": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.0.tgz", + "integrity": "sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw==", + "dependencies": { + "langium": "^4.0.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.2", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", @@ -813,12 +889,239 @@ "win32" ] }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==" + }, "node_modules/@types/node": { "version": "24.11.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.0.tgz", @@ -828,6 +1131,12 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "optional": true + }, "node_modules/@vitejs/plugin-vue": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.4.tgz", @@ -994,17 +1303,551 @@ } } }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/alien-signals": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz", "integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==", "dev": true }, + "node_modules/chevrotain": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.2.tgz", + "integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==", + "dependencies": { + "@chevrotain/cst-dts-gen": "11.1.2", + "@chevrotain/gast": "11.1.2", + "@chevrotain/regexp-to-ast": "11.1.2", + "@chevrotain/types": "11.1.2", + "@chevrotain/utils": "11.1.2", + "lodash-es": "4.17.23" + } + }, + "node_modules/chevrotain-allstar": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz", + "integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==", + "dependencies": { + "lodash-es": "^4.17.21" + }, + "peerDependencies": { + "chevrotain": "^11.0.0" + } + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==" + }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "dependencies": { + "layout-base": "^1.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" }, + "node_modules/cytoscape": { + "version": "3.33.1", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", + "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.13.tgz", + "integrity": "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==" + }, + "node_modules/delaunator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", + "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/dompurify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/entities": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", @@ -1093,6 +1936,84 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==" + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/katex": { + "version": "0.16.33", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.33.tgz", + "integrity": "sha512-q3N5u+1sY9Bu7T4nlXoiRBXWfwSefNGoKeOwekV+gw0cAXQlz2Ww6BLcmBxVDeXBMUDQv6fK5bcNaJLxob3ZQA==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "engines": { + "node": ">= 12" + } + }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, + "node_modules/langium": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/langium/-/langium-4.2.1.tgz", + "integrity": "sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ==", + "dependencies": { + "chevrotain": "~11.1.1", + "chevrotain-allstar": "~0.3.1", + "vscode-languageserver": "~9.0.1", + "vscode-languageserver-textdocument": "~1.0.11", + "vscode-uri": "~3.1.0" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==" + }, + "node_modules/lodash-es": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1101,6 +2022,66 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "17.0.3", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.3.tgz", + "integrity": "sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A==", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/mermaid": { + "version": "11.12.3", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.3.tgz", + "integrity": "sha512-wN5ZSgJQIC+CHJut9xaKWsknLxaFBwCPwPkGTSUYrTiHORWvpT8RxGk849HPnpUAQ+/9BPRqYb80jTpearrHzQ==", + "dependencies": { + "@braintree/sanitize-url": "^7.1.1", + "@iconify/utils": "^3.0.1", + "@mermaid-js/parser": "^1.0.0", + "@types/d3": "^7.4.3", + "cytoscape": "^3.29.3", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.13", + "dayjs": "^1.11.18", + "dompurify": "^3.2.5", + "katex": "^0.16.22", + "khroma": "^2.1.0", + "lodash-es": "^4.17.23", + "marked": "^16.2.1", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0" + } + }, + "node_modules/mermaid/node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/mlly": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", + "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "dependencies": { + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" + } + }, "node_modules/muggle-string": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", @@ -1124,12 +2105,27 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==" + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", "dev": true }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1147,6 +2143,30 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -1174,6 +2194,11 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==" + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -1218,6 +2243,27 @@ "fsevents": "~2.3.2" } }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1226,6 +2272,19 @@ "node": ">=0.10.0" } }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -1242,6 +2301,14 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/ts-dedent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", + "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "engines": { + "node": ">=6.10" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1255,12 +2322,29 @@ "node": ">=14.17" } }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==" + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", @@ -1335,11 +2419,48 @@ } } }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==" + }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", - "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", - "dev": true + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==" }, "node_modules/vue": { "version": "3.5.29", diff --git a/web/package.json b/web/package.json index eb5aef6..10c82a2 100644 --- a/web/package.json +++ b/web/package.json @@ -9,6 +9,8 @@ "preview": "vite preview" }, "dependencies": { + "marked": "^17.0.3", + "mermaid": "^11.12.3", "vue": "^3.5.25" }, "devDependencies": { diff --git a/web/src/api.ts b/web/src/api.ts index b75fba9..1c8bdfd 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,4 +1,4 @@ -import type { Project, Workflow, PlanStep, Comment } from './types' +import type { Project, Workflow, PlanStep, Comment, Timer } from './types' const BASE = '/api' @@ -54,4 +54,25 @@ export const api = { method: 'POST', body: JSON.stringify({ content }), }), + + getReport: (workflowId: string) => + request<{ report: string }>(`/workflows/${workflowId}/report`), + + listTimers: (projectId: string) => + request(`/projects/${projectId}/timers`), + + createTimer: (projectId: string, data: { name: string; interval_secs: number; requirement: string }) => + request(`/projects/${projectId}/timers`, { + method: 'POST', + body: JSON.stringify(data), + }), + + updateTimer: (timerId: string, data: { name?: string; interval_secs?: number; requirement?: string; enabled?: boolean }) => + request(`/timers/${timerId}`, { + method: 'PUT', + body: JSON.stringify(data), + }), + + deleteTimer: (timerId: string) => + request(`/timers/${timerId}`, { method: 'DELETE' }), } diff --git a/web/src/components/AppLayout.vue b/web/src/components/AppLayout.vue index 4448f0f..cdb084f 100644 --- a/web/src/components/AppLayout.vue +++ b/web/src/components/AppLayout.vue @@ -1,62 +1,122 @@ diff --git a/web/src/components/CommentSection.vue b/web/src/components/CommentSection.vue index 007c8c4..3052200 100644 --- a/web/src/components/CommentSection.vue +++ b/web/src/components/CommentSection.vue @@ -1,9 +1,7 @@