Agent loop state machine refactor, unified LLM interface, and UI improvements

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 22:35:33 +00:00
parent e2d5a6a7eb
commit 2df4e12d30
31 changed files with 3924 additions and 571 deletions

16
Cargo.lock generated
View File

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

View File

@@ -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"] }

View File

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

View File

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

View File

@@ -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<T>` 定义重复,可提到 `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 减少重复。

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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<AppState>) -> 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<Arc<AppState>>,
Path(project_id): Path<String>,
req: Request<Body>,
) -> Response {
proxy_impl(&state, &project_id, "/", req).await
}
async fn proxy_to_service(
State(state): State<Arc<AppState>>,
Path((project_id, path)): Path<(String, String)>,
req: Request<Body>,
) -> Response {
proxy_impl(&state, &project_id, &format!("/{}", path), req).await
}
async fn proxy_impl(
state: &AppState,
project_id: &str,
path: &str,
req: Request<Body>,
) -> 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(),
}
}

147
src/api/timers.rs Normal file
View File

@@ -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<T> = Result<Json<T>, 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<String>,
pub interval_secs: Option<i64>,
pub requirement: Option<String>,
pub enabled: Option<bool>,
}
pub fn router(state: Arc<AppState>) -> 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<Arc<AppState>>,
Path(project_id): Path<String>,
) -> ApiResult<Vec<Timer>> {
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<Arc<AppState>>,
Path(project_id): Path<String>,
Json(input): Json<CreateTimer>,
) -> ApiResult<Timer> {
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<Arc<AppState>>,
Path(timer_id): Path<String>,
) -> ApiResult<Timer> {
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<Arc<AppState>>,
Path(timer_id): Path<String>,
Json(input): Json<UpdateTimer>,
) -> ApiResult<Timer> {
// 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<Arc<AppState>>,
Path(timer_id): Path<String>,
) -> Result<StatusCode, Response> {
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())
}
}

View File

@@ -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<T> = Result<Json<T>, Response>;
fn db_err(e: sqlx::Error) -> Response {
@@ -33,6 +38,7 @@ pub fn router(state: Arc<AppState>) -> 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<Arc<AppState>>,
Path(workflow_id): Path<String>,
) -> Result<Json<ReportResponse>, 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()),
}
}

View File

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

40
src/exec.rs Normal file
View File

@@ -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<ExecResult> {
// 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,
}

View File

@@ -10,22 +10,73 @@ pub struct LlmClient {
struct ChatRequest {
model: String,
messages: Vec<ChatMessage>,
#[serde(skip_serializing_if = "Vec::is_empty")]
tools: Vec<Tool>,
}
#[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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
}
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<Choice>,
pub struct ChatResponse {
pub choices: Vec<ChatChoice>,
}
#[derive(Debug, Deserialize)]
struct Choice {
message: ChatMessage,
pub struct ChatChoice {
pub message: ChatMessage,
#[allow(dead_code)]
pub finish_reason: Option<String>,
}
impl LlmClient {
@@ -36,21 +87,42 @@ impl LlmClient {
}
}
/// Simple chat without tools — returns content string
pub async fn chat(&self, messages: Vec<ChatMessage>) -> anyhow::Result<String> {
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<ChatMessage>, tools: &[Tool]) -> anyhow::Result<ChatResponse> {
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::<ChatResponse>()
.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)
}
}

View File

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

View File

@@ -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<SshResult> {
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,
}

75
src/timer.rs Normal file
View File

@@ -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<AgentManager>) {
tokio::spawn(timer_loop(pool, agent_mgr));
}
async fn timer_loop(pool: SqlitePool, agent_mgr: Arc<AgentManager>) {
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<AgentManager>) -> 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(())
}

1125
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,8 @@
"preview": "vite preview"
},
"dependencies": {
"marked": "^17.0.3",
"mermaid": "^11.12.3",
"vue": "^3.5.25"
},
"devDependencies": {

View File

@@ -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<Timer[]>(`/projects/${projectId}/timers`),
createTimer: (projectId: string, data: { name: string; interval_secs: number; requirement: string }) =>
request<Timer>(`/projects/${projectId}/timers`, {
method: 'POST',
body: JSON.stringify(data),
}),
updateTimer: (timerId: string, data: { name?: string; interval_secs?: number; requirement?: string; enabled?: boolean }) =>
request<Timer>(`/timers/${timerId}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
deleteTimer: (timerId: string) =>
request<void>(`/timers/${timerId}`, { method: 'DELETE' }),
}

View File

@@ -1,62 +1,122 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, onUnmounted, computed } from 'vue'
import Sidebar from './Sidebar.vue'
import WorkflowView from './WorkflowView.vue'
import ReportView from './ReportView.vue'
import CreateForm from './CreateForm.vue'
import { api } from '../api'
import type { Project } from '../types'
const projects = ref<Project[]>([])
const selectedProjectId = ref('')
const reportWorkflowId = ref('')
const error = ref('')
const creating = ref(false)
const isReportPage = computed(() => !!reportWorkflowId.value)
function parseUrl(): { projectId: string; reportId: string } {
const reportMatch = location.pathname.match(/^\/report\/([^/]+)/)
if (reportMatch) return { projectId: '', reportId: reportMatch[1] ?? '' }
const projectMatch = location.pathname.match(/^\/projects\/([^/]+)/)
return { projectId: projectMatch?.[1] ?? '', reportId: '' }
}
function onPopState() {
const { projectId, reportId } = parseUrl()
selectedProjectId.value = projectId
reportWorkflowId.value = reportId
}
onMounted(async () => {
try {
projects.value = await api.listProjects()
const first = projects.value[0]
if (first) {
selectedProjectId.value = first.id
const { projectId, reportId } = parseUrl()
if (reportId) {
reportWorkflowId.value = reportId
} else if (projectId && projects.value.some(p => p.id === projectId)) {
selectedProjectId.value = projectId
} else if (projects.value[0]) {
selectedProjectId.value = projects.value[0].id
history.replaceState(null, '', `/projects/${projects.value[0].id}`)
}
} catch (e: any) {
error.value = e.message
}
window.addEventListener('popstate', onPopState)
})
onUnmounted(() => {
window.removeEventListener('popstate', onPopState)
})
function onSelectProject(id: string) {
selectedProjectId.value = id
reportWorkflowId.value = ''
creating.value = false
history.pushState(null, '', `/projects/${id}`)
}
async function onCreateProject() {
const name = prompt('项目名称')
if (!name) return
function onStartCreate() {
creating.value = true
selectedProjectId.value = ''
history.pushState(null, '', '/')
}
async function onConfirmCreate(req: string) {
try {
const project = await api.createProject(name)
const project = await api.createProject('新项目')
projects.value.unshift(project)
await api.createWorkflow(project.id, req)
creating.value = false
selectedProjectId.value = project.id
history.pushState(null, '', `/projects/${project.id}`)
} catch (e: any) {
error.value = e.message
}
}
function onProjectUpdate(projectId: string, name: string) {
const p = projects.value.find(p => p.id === projectId)
if (p) p.name = name
}
</script>
<template>
<div class="app-layout">
<div v-if="isReportPage" class="report-fullpage">
<ReportView :workflowId="reportWorkflowId" :key="reportWorkflowId" />
</div>
<div v-else class="app-layout">
<Sidebar
:projects="projects"
:selectedId="selectedProjectId"
@select="onSelectProject"
@create="onCreateProject"
@create="onStartCreate"
/>
<main class="main-content">
<div v-if="error" class="error-banner">{{ error }}</div>
<div v-if="!selectedProjectId" class="empty-state">
<div v-if="error" class="error-banner" @click="error = ''">{{ error }}</div>
<div v-if="creating" class="empty-state">
<CreateForm @submit="onConfirmCreate" @cancel="creating = false" />
</div>
<div v-else-if="!selectedProjectId" class="empty-state">
选择或创建一个项目开始
</div>
<WorkflowView v-else :projectId="selectedProjectId" :key="selectedProjectId" />
<WorkflowView
v-else
:projectId="selectedProjectId"
:key="selectedProjectId"
@projectUpdate="onProjectUpdate"
/>
</main>
</div>
</template>
<style scoped>
.report-fullpage {
height: 100vh;
overflow-y: auto;
}
.app-layout {
display: flex;
height: 100vh;
@@ -84,5 +144,6 @@ async function onCreateProject() {
color: #fff;
padding: 8px 16px;
font-size: 13px;
cursor: pointer;
}
</style>

View File

@@ -1,9 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { Comment } from '../types'
defineProps<{
comments: Comment[]
const props = defineProps<{
disabled?: boolean
}>()
@@ -14,29 +12,31 @@ const emit = defineEmits<{
const input = ref('')
function submit() {
if (props.disabled) return
const text = input.value.trim()
if (!text) return
emit('submit', text)
input.value = ''
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
submit()
}
}
</script>
<template>
<div class="comment-section">
<div class="comments-display" v-if="comments.length">
<div v-for="comment in comments" :key="comment.id" class="comment-item">
<span class="comment-time">{{ new Date(comment.created_at).toLocaleTimeString() }}</span>
<span class="comment-text">{{ comment.content }}</span>
</div>
</div>
<div class="comment-input">
<textarea
v-model="input"
placeholder="输入反馈或调整指令... (Ctrl+Enter 发送)"
rows="5"
@keydown.ctrl.enter="submit"
rows="3"
@keydown="onKeydown"
/>
<button class="btn-send" :disabled="disabled" @click="submit">发送</button>
<button class="btn-send" :disabled="disabled || !input.trim()" @click="submit">发送</button>
</div>
</div>
</template>
@@ -49,30 +49,6 @@ function submit() {
overflow: hidden;
}
.comments-display {
max-height: 100px;
overflow-y: auto;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
}
.comment-item {
display: flex;
gap: 8px;
padding: 4px 0;
font-size: 13px;
}
.comment-time {
color: var(--text-secondary);
font-size: 11px;
flex-shrink: 0;
}
.comment-text {
color: var(--text-primary);
}
.comment-input {
display: flex;
gap: 8px;

View File

@@ -0,0 +1,142 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const emit = defineEmits<{
submit: [requirement: string]
cancel: []
}>()
const requirement = ref('')
const inputEl = ref<HTMLTextAreaElement>()
const examples = [
{ label: 'Todo 应用', text: '做一个 Todo List 应用:前端展示任务列表(支持添加、完成、删除),后端 FastAPI 提供增删改查 REST API数据存 SQLite。完成后用 curl 跑一遍 E2E 测试验证所有接口正常。' },
{ label: '贪吃蛇+排行榜', text: '做一个贪吃蛇游戏网站,前端用 HTML/JS后端用 FastAPI 存储排行榜,支持提交分数和查看 Top10' },
{ label: '抓取豆瓣 Top250', text: '用 Python 抓取豆瓣电影 Top250 并生成分析报告' },
]
onMounted(() => inputEl.value?.focus())
function onSubmit() {
const text = requirement.value.trim()
if (text) emit('submit', text)
}
</script>
<template>
<div class="create-form">
<h2>输入你的需求</h2>
<div class="create-examples">
<span
v-for="ex in examples"
:key="ex.label"
class="example-tag"
@click="requirement = ex.text"
>{{ ex.label }}</span>
</div>
<textarea
ref="inputEl"
v-model="requirement"
class="create-textarea"
placeholder="描述你想让 AI 做什么..."
rows="4"
@keydown.ctrl.enter="onSubmit"
@keydown.meta.enter="onSubmit"
/>
<div class="create-hint">Ctrl+Enter 提交</div>
<div class="create-actions">
<button class="btn-cancel" @click="emit('cancel')">取消</button>
<button class="btn-confirm" @click="onSubmit" :disabled="!requirement.trim()">开始</button>
</div>
</div>
</template>
<style scoped>
.create-form {
display: flex;
flex-direction: column;
gap: 12px;
width: 480px;
}
.create-form h2 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.create-examples {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.example-tag {
padding: 4px 10px;
font-size: 12px;
background: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border);
border-radius: 12px;
cursor: pointer;
transition: all 0.15s;
}
.example-tag:hover {
color: var(--accent);
border-color: var(--accent);
background: rgba(79, 195, 247, 0.08);
}
.create-textarea {
padding: 10px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 14px;
font-family: inherit;
outline: none;
resize: vertical;
min-height: 80px;
}
.create-textarea:focus {
border-color: var(--accent);
}
.create-hint {
font-size: 12px;
color: var(--text-secondary);
text-align: right;
}
.create-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.btn-cancel {
padding: 8px 16px;
background: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
}
.btn-confirm {
padding: 8px 16px;
background: var(--accent);
color: var(--bg-primary);
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
}
.btn-confirm:disabled {
opacity: 0.4;
}
</style>

View File

@@ -1,11 +1,35 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { PlanStep } from '../types'
import { ref, computed, watch, nextTick } from 'vue'
import type { PlanStep, Comment } from '../types'
defineProps<{
const props = defineProps<{
steps: PlanStep[]
planSteps: PlanStep[]
comments: Comment[]
requirement: string
createdAt: string
workflowStatus: string
workflowId: string
}>()
// Map plan step id -> step_order for showing badge
const planStepOrderMap = computed(() => {
const m: Record<string, number> = {}
for (const ps of props.planSteps) {
m[ps.id] = ps.step_order
}
return m
})
const scrollContainer = ref<HTMLElement | null>(null)
const userScrolledUp = ref(false)
function onScroll() {
const el = scrollContainer.value
if (!el) return
userScrolledUp.value = el.scrollTop + el.clientHeight < el.scrollHeight - 80
}
const expandedSteps = ref<Set<string>>(new Set())
function toggleStep(id: string) {
@@ -16,6 +40,16 @@ function toggleStep(id: string) {
}
}
function formatTime(t: string): string {
if (!t) return ''
try {
const d = new Date(t.includes('T') ? t : t + 'Z')
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
} catch {
return ''
}
}
function statusLabel(status: string) {
switch (status) {
case 'done': return '完成'
@@ -24,31 +58,114 @@ function statusLabel(status: string) {
default: return '等待'
}
}
interface LogEntry {
id: string
type: 'requirement' | 'step' | 'comment' | 'report'
time: string
step?: PlanStep
text?: string
}
const logEntries = computed(() => {
const entries: LogEntry[] = []
// Requirement
if (props.requirement) {
entries.push({ id: 'req', type: 'requirement', text: props.requirement, time: props.createdAt || '0' })
}
// Steps
for (const step of props.steps) {
entries.push({ id: step.id, type: 'step', step, time: step.created_at || '' })
}
// Comments
for (const c of props.comments) {
entries.push({ id: c.id, type: 'comment', text: c.content, time: c.created_at })
}
// Sort by time, preserving order for entries without timestamps
entries.sort((a, b) => {
if (!a.time && !b.time) return 0
if (!a.time) return -1
if (!b.time) return 1
return a.time.localeCompare(b.time)
})
// Insert report links: after each contiguous block of steps that ends before a comment/requirement
if (props.workflowId && (props.workflowStatus === 'done' || props.workflowStatus === 'failed')) {
const result: LogEntry[] = []
let lastWasStep = false
for (const entry of entries) {
if (entry.type === 'step') {
lastWasStep = true
} else if (lastWasStep && (entry.type === 'comment' || entry.type === 'requirement')) {
// Insert report link before this comment/requirement
result.push({ id: `report-${result.length}`, type: 'report', time: '' })
lastWasStep = false
} else {
lastWasStep = false
}
result.push(entry)
}
// Final report link at the end if last entry was a step
if (lastWasStep) {
result.push({ id: 'report-final', type: 'report', time: '' })
}
return result
}
return entries
})
watch(logEntries, () => {
if (userScrolledUp.value) return
nextTick(() => {
const el = scrollContainer.value
if (el) el.scrollTop = el.scrollHeight
})
})
</script>
<template>
<div class="execution-section">
<div class="execution-section" ref="scrollContainer" @scroll="onScroll">
<div class="section-header">
<h2>执行</h2>
<h2>日志</h2>
</div>
<div class="exec-list">
<div
v-for="step in steps"
:key="step.id"
class="exec-item"
:class="step.status"
>
<div class="exec-header" @click="toggleStep(step.id)">
<span class="exec-toggle">{{ expandedSteps.has(step.id) ? '▾' : '▸' }}</span>
<span class="exec-order">步骤 {{ step.step_order }}</span>
<span class="exec-status" :class="step.status">{{ statusLabel(step.status) }}</span>
<template v-for="entry in logEntries" :key="entry.id">
<!-- User message (requirement or comment) -->
<div v-if="entry.type === 'requirement' || entry.type === 'comment'" class="log-user">
<span class="log-time" v-if="entry.time">{{ formatTime(entry.time) }}</span>
<span class="log-tag">{{ entry.type === 'requirement' ? '需求' : '反馈' }}</span>
<span class="log-text">{{ entry.text }}</span>
</div>
<div v-if="expandedSteps.has(step.id) && step.output" class="exec-output">
<pre>{{ step.output }}</pre>
<!-- Report link -->
<div v-else-if="entry.type === 'report'" class="report-link-bar">
<a :href="'/report/' + workflowId" class="report-link" target="_blank">查看报告 </a>
</div>
<!-- Step -->
<div v-else-if="entry.step" class="exec-item" :class="entry.step.status">
<div class="exec-header" @click="toggleStep(entry.step!.id)">
<span class="exec-time" v-if="entry.time">{{ formatTime(entry.time) }}</span>
<span v-if="entry.step.plan_step_id && planStepOrderMap[entry.step.plan_step_id]" class="step-badge">{{ planStepOrderMap[entry.step.plan_step_id] }}</span>
<span class="exec-toggle">{{ expandedSteps.has(entry.step!.id) ? '▾' : '▸' }}</span>
<span class="exec-desc">{{ entry.step.description }}</span>
<span class="exec-status" :class="entry.step.status">{{ statusLabel(entry.step.status) }}</span>
</div>
<div v-if="expandedSteps.has(entry.step!.id)" class="exec-detail">
<div v-if="entry.step.command" class="exec-command">
<code>$ {{ entry.step.command }}</code>
</div>
<pre v-if="entry.step.output">{{ entry.step.output }}</pre>
</div>
</div>
<div v-if="!steps.length" class="empty-state">
计划生成后执行进度将显示在这里
</template>
<div v-if="!steps.length && !requirement" class="empty-state">
提交需求后日志将显示在这里
</div>
</div>
</div>
@@ -83,6 +200,35 @@ function statusLabel(status: string) {
gap: 4px;
}
.log-user {
display: flex;
align-items: baseline;
gap: 8px;
padding: 8px 10px;
border-radius: 6px;
background: var(--bg-secondary);
font-size: 13px;
}
.log-time, .exec-time {
font-size: 10px;
color: var(--text-secondary);
flex-shrink: 0;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
opacity: 0.7;
}
.log-tag {
font-size: 11px;
font-weight: 600;
color: var(--accent);
flex-shrink: 0;
}
.log-text {
color: var(--text-primary);
}
.exec-item {
border-radius: 6px;
overflow: hidden;
@@ -100,7 +246,20 @@ function statusLabel(status: string) {
}
.exec-header:hover {
background: rgba(255, 255, 255, 0.03);
background: var(--bg-tertiary);
}
.step-badge {
font-size: 10px;
font-weight: 700;
min-width: 18px;
height: 18px;
line-height: 18px;
text-align: center;
border-radius: 9px;
background: var(--accent);
color: var(--bg-primary);
flex-shrink: 0;
}
.exec-toggle {
@@ -110,7 +269,7 @@ function statusLabel(status: string) {
flex-shrink: 0;
}
.exec-order {
.exec-desc {
color: var(--text-primary);
font-weight: 500;
flex: 1;
@@ -123,24 +282,36 @@ function statusLabel(status: string) {
font-weight: 500;
}
.exec-status.done { background: var(--success); color: #000; }
.exec-status.running { background: var(--accent); color: #000; }
.exec-status.done { background: var(--success); color: #fff; }
.exec-status.running { background: var(--accent); color: #fff; }
.exec-status.failed { background: var(--error); color: #fff; }
.exec-status.pending { background: var(--pending); color: #fff; }
.exec-output {
padding: 8px 12px;
.exec-detail {
border-top: 1px solid var(--border);
background: rgba(0, 0, 0, 0.2);
background: var(--bg-tertiary);
}
.exec-output pre {
.exec-command {
padding: 6px 12px;
border-bottom: 1px solid var(--border);
}
.exec-command code {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 12px;
color: var(--accent);
}
.exec-detail pre {
padding: 8px 12px;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 12px;
line-height: 1.5;
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-all;
margin: 0;
}
.empty-state {
@@ -149,4 +320,23 @@ function statusLabel(status: string) {
text-align: center;
padding: 24px;
}
.report-link-bar {
margin: 4px 0;
padding: 10px 12px;
background: var(--bg-secondary);
border-radius: 6px;
text-align: center;
}
.report-link {
color: var(--accent);
font-size: 13px;
font-weight: 600;
text-decoration: none;
}
.report-link:hover {
text-decoration: underline;
}
</style>

View File

@@ -1,10 +1,21 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { PlanStep } from '../types'
defineProps<{
steps: PlanStep[]
}>()
const expandedSteps = ref<Set<string>>(new Set())
function toggleStep(id: string) {
if (expandedSteps.value.has(id)) {
expandedSteps.value.delete(id)
} else {
expandedSteps.value.add(id)
}
}
function statusIcon(status: string) {
switch (status) {
case 'done': return '✓'
@@ -18,7 +29,7 @@ function statusIcon(status: string) {
<template>
<div class="plan-section">
<div class="section-header">
<h2>Plan</h2>
<h2>计划</h2>
</div>
<div class="steps-list">
<div
@@ -27,12 +38,18 @@ function statusIcon(status: string) {
class="step-item"
:class="step.status"
>
<div class="step-header" @click="step.command ? toggleStep(step.id) : undefined">
<span class="step-icon">{{ statusIcon(step.status) }}</span>
<span class="step-order">{{ step.step_order }}.</span>
<span class="step-desc">{{ step.description }}</span>
<span class="step-title">{{ step.description }}</span>
<span v-if="step.command" class="step-toggle">{{ expandedSteps.has(step.id) ? '' : '' }}</span>
</div>
<div v-if="step.command && expandedSteps.has(step.id)" class="step-detail">
{{ step.command }}
</div>
</div>
<div v-if="!steps.length" class="empty-state">
提交需求后AI 将在这里生成计划
AI 将在这里展示执行计划
</div>
</div>
</div>
@@ -68,14 +85,9 @@ function statusIcon(status: string) {
}
.step-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 10px;
border-radius: 6px;
font-size: 13px;
line-height: 1.5;
background: var(--bg-secondary);
overflow: hidden;
}
.step-item.done { border-left: 3px solid var(--success); }
@@ -83,6 +95,24 @@ function statusIcon(status: string) {
.step-item.failed { border-left: 3px solid var(--error); }
.step-item.pending { border-left: 3px solid var(--pending); opacity: 0.7; }
.step-header {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 10px;
font-size: 13px;
line-height: 1.5;
cursor: default;
}
.step-header:has(.step-toggle) {
cursor: pointer;
}
.step-header:has(.step-toggle):hover {
background: var(--bg-tertiary);
}
.step-icon {
font-size: 14px;
flex-shrink: 0;
@@ -99,8 +129,24 @@ function statusIcon(status: string) {
flex-shrink: 0;
}
.step-desc {
.step-title {
color: var(--text-primary);
font-weight: 500;
flex: 1;
}
.step-toggle {
color: var(--text-secondary);
font-size: 11px;
flex-shrink: 0;
}
.step-detail {
padding: 6px 10px 10px 44px;
font-size: 12px;
line-height: 1.6;
color: var(--text-secondary);
border-top: 1px solid var(--border);
}
.empty-state {

View File

@@ -0,0 +1,232 @@
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { marked } from 'marked'
import mermaid from 'mermaid'
import { api } from '../api'
const props = defineProps<{
workflowId: string
}>()
const html = ref('')
const loading = ref(true)
const error = ref('')
mermaid.initialize({
startOnLoad: false,
theme: 'default',
})
async function renderMermaid() {
await nextTick()
const els = document.querySelectorAll('.report-body pre code.language-mermaid')
for (let i = 0; i < els.length; i++) {
const el = els[i] as HTMLElement
const pre = el.parentElement!
const source = el.textContent || ''
try {
const { svg } = await mermaid.render(`mermaid-${i}`, source)
const div = document.createElement('div')
div.className = 'mermaid-chart'
div.innerHTML = svg
pre.replaceWith(div)
} catch {
// leave as code block on failure
}
}
}
onMounted(async () => {
try {
const res = await api.getReport(props.workflowId)
html.value = await marked.parse(res.report)
await renderMermaid()
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
})
</script>
<template>
<div class="report-page">
<div class="report-toolbar">
<a href="/" class="back-link">&larr; 返回</a>
<span class="report-title">执行报告</span>
</div>
<div v-if="loading" class="report-loading">加载中...</div>
<div v-else-if="error" class="report-error">{{ error }}</div>
<div v-else class="report-body" v-html="html"></div>
</div>
</template>
<style scoped>
.report-page {
max-width: 860px;
margin: 0 auto;
padding: 24px 32px;
min-height: 100vh;
}
.report-toolbar {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.back-link {
color: var(--accent);
text-decoration: none;
font-size: 14px;
font-weight: 500;
}
.back-link:hover {
text-decoration: underline;
}
.report-title {
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.report-loading,
.report-error {
text-align: center;
padding: 48px;
color: var(--text-secondary);
font-size: 14px;
}
.report-error {
color: var(--error);
}
</style>
<style>
/* Unscoped styles for rendered markdown */
.report-body {
line-height: 1.7;
color: var(--text-primary);
font-size: 15px;
}
.report-body h1 {
font-size: 24px;
font-weight: 700;
margin: 0 0 16px;
padding-bottom: 8px;
border-bottom: 2px solid var(--border);
}
.report-body h2 {
font-size: 20px;
font-weight: 600;
margin: 24px 0 12px;
}
.report-body h3 {
font-size: 16px;
font-weight: 600;
margin: 20px 0 8px;
}
.report-body p {
margin: 0 0 12px;
}
.report-body ul,
.report-body ol {
margin: 0 0 12px;
padding-left: 24px;
}
.report-body li {
margin-bottom: 4px;
}
.report-body pre {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px 16px;
overflow-x: auto;
margin: 0 0 12px;
font-size: 13px;
line-height: 1.5;
}
.report-body code {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 13px;
}
.report-body :not(pre) > code {
background: var(--bg-secondary);
padding: 2px 6px;
border-radius: 4px;
font-size: 13px;
}
.report-body table {
width: 100%;
border-collapse: collapse;
margin: 0 0 12px;
font-size: 14px;
}
.report-body th,
.report-body td {
border: 1px solid var(--border);
padding: 8px 12px;
text-align: left;
}
.report-body th {
background: var(--bg-secondary);
font-weight: 600;
}
.report-body blockquote {
border-left: 3px solid var(--accent);
padding-left: 16px;
margin: 0 0 12px;
color: var(--text-secondary);
}
.report-body a {
color: var(--accent);
text-decoration: none;
}
.report-body a:hover {
text-decoration: underline;
}
.report-body .mermaid-chart {
margin: 16px 0;
text-align: center;
}
.report-body .mermaid-chart svg {
max-width: 100%;
}
.report-body img {
max-width: 100%;
border-radius: 6px;
}
.report-body hr {
border: none;
border-top: 1px solid var(--border);
margin: 20px 0;
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, watch } from 'vue'
const props = defineProps<{
requirement: string
@@ -13,6 +13,13 @@ const emit = defineEmits<{
const input = ref('')
const editing = ref(!props.requirement)
// 当 requirement 从外部更新(如 loadData 完成),自动退出编辑模式
watch(() => props.requirement, (val) => {
if (val && editing.value && !input.value.trim()) {
editing.value = false
}
})
function submit() {
const text = input.value.trim()
if (!text) return
@@ -29,8 +36,9 @@ function submit() {
{{ status }}
</span>
</div>
<div v-if="!editing && requirement" class="requirement-display" @dblclick="editing = true">
{{ requirement }}
<div v-if="!editing && requirement" class="requirement-display">
<span>{{ requirement }}</span>
<button class="btn-edit" @click="editing = true; input = requirement">编辑</button>
</div>
<div v-else class="requirement-input">
<textarea
@@ -80,10 +88,27 @@ function submit() {
.status-badge.failed { background: var(--error); color: #fff; }
.requirement-display {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
font-size: 14px;
line-height: 1.6;
color: var(--text-primary);
cursor: pointer;
}
.btn-edit {
flex-shrink: 0;
padding: 4px 10px;
font-size: 12px;
background: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border);
}
.btn-edit:hover {
color: var(--accent);
border-color: var(--accent);
}
.requirement-input {

View File

@@ -0,0 +1,317 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '../api'
import type { Timer } from '../types'
const props = defineProps<{
projectId: string
}>()
const timers = ref<Timer[]>([])
const showForm = ref(false)
const formName = ref('')
const formInterval = ref(300)
const formRequirement = ref('')
const error = ref('')
onMounted(async () => {
await loadTimers()
})
async function loadTimers() {
try {
timers.value = await api.listTimers(props.projectId)
} catch (e: any) {
error.value = e.message
}
}
async function onCreate() {
const name = formName.value.trim()
const req = formRequirement.value.trim()
if (!name || !req) return
try {
const t = await api.createTimer(props.projectId, {
name,
interval_secs: formInterval.value,
requirement: req,
})
timers.value.unshift(t)
showForm.value = false
formName.value = ''
formInterval.value = 300
formRequirement.value = ''
} catch (e: any) {
error.value = e.message
}
}
async function onToggle(timer: Timer) {
try {
const updated = await api.updateTimer(timer.id, { enabled: !timer.enabled })
const idx = timers.value.findIndex(t => t.id === timer.id)
if (idx >= 0) timers.value[idx] = updated
} catch (e: any) {
error.value = e.message
}
}
async function onDelete(timer: Timer) {
try {
await api.deleteTimer(timer.id)
timers.value = timers.value.filter(t => t.id !== timer.id)
} catch (e: any) {
error.value = e.message
}
}
function formatInterval(secs: number): string {
if (secs < 3600) return `${Math.round(secs / 60)}分钟`
if (secs < 86400) return `${Math.round(secs / 3600)}小时`
return `${Math.round(secs / 86400)}`
}
</script>
<template>
<div class="timer-section">
<div class="section-header">
<h2>定时任务</h2>
<button class="btn-add" @click="showForm = !showForm">{{ showForm ? '取消' : '+ 新建' }}</button>
</div>
<div v-if="error" class="timer-error" @click="error = ''">{{ error }}</div>
<div v-if="showForm" class="timer-form">
<input v-model="formName" class="form-input" placeholder="任务名称" />
<div class="interval-row">
<label>间隔</label>
<select v-model="formInterval" class="form-select">
<option :value="60">1 分钟</option>
<option :value="300">5 分钟</option>
<option :value="600">10 分钟</option>
<option :value="1800">30 分钟</option>
<option :value="3600">1 小时</option>
<option :value="21600">6 小时</option>
<option :value="43200">12 小时</option>
<option :value="86400">1 </option>
</select>
</div>
<textarea v-model="formRequirement" class="form-textarea" placeholder="执行需求(和创建 workflow 一样)" rows="2" />
<button class="btn-create" @click="onCreate" :disabled="!formName.trim() || !formRequirement.trim()">创建</button>
</div>
<div class="timer-list">
<div v-for="timer in timers" :key="timer.id" class="timer-item" :class="{ disabled: !timer.enabled }">
<div class="timer-info">
<span class="timer-name">{{ timer.name }}</span>
<span class="timer-interval">{{ formatInterval(timer.interval_secs) }}</span>
</div>
<div class="timer-req">{{ timer.requirement }}</div>
<div class="timer-actions">
<button class="btn-toggle" :class="{ on: timer.enabled }" @click="onToggle(timer)">
{{ timer.enabled ? '已启用' : '已停用' }}
</button>
<button class="btn-delete" @click="onDelete(timer)">删除</button>
</div>
</div>
<div v-if="!timers.length && !showForm" class="empty-state">暂无定时任务</div>
</div>
</div>
</template>
<style scoped>
.timer-section {
flex: 1;
background: var(--bg-card);
border-radius: 8px;
padding: 12px 16px;
border: 1px solid var(--border);
overflow-y: auto;
min-width: 0;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.section-header h2 {
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.btn-add {
font-size: 12px;
padding: 4px 10px;
background: var(--bg-secondary);
color: var(--accent);
border: 1px solid var(--border);
font-weight: 500;
}
.btn-add:hover {
background: var(--bg-tertiary);
}
.timer-error {
background: rgba(239, 83, 80, 0.15);
border: 1px solid var(--error);
color: var(--error);
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
margin-bottom: 8px;
}
.timer-form {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 10px;
padding: 10px;
background: var(--bg-secondary);
border-radius: 6px;
}
.form-input,
.form-textarea,
.form-select {
padding: 7px 10px;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 5px;
color: var(--text-primary);
font-size: 13px;
font-family: inherit;
outline: none;
}
.form-input:focus,
.form-textarea:focus {
border-color: var(--accent);
}
.form-textarea {
resize: vertical;
min-height: 40px;
}
.interval-row {
display: flex;
align-items: center;
gap: 8px;
}
.interval-row label {
font-size: 13px;
color: var(--text-secondary);
flex-shrink: 0;
}
.form-select {
flex: 1;
}
.btn-create {
align-self: flex-end;
padding: 6px 16px;
background: var(--accent);
color: #fff;
font-size: 13px;
font-weight: 500;
}
.btn-create:disabled {
opacity: 0.4;
}
.timer-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.timer-item {
padding: 8px 10px;
background: var(--bg-secondary);
border-radius: 6px;
}
.timer-item.disabled {
opacity: 0.5;
}
.timer-info {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.timer-name {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.timer-interval {
font-size: 11px;
color: var(--text-secondary);
background: var(--bg-tertiary);
padding: 1px 6px;
border-radius: 8px;
}
.timer-req {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.timer-actions {
display: flex;
gap: 6px;
}
.btn-toggle {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background: var(--pending);
color: #fff;
}
.btn-toggle.on {
background: var(--success);
}
.btn-delete {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.btn-delete:hover {
background: var(--error);
color: #fff;
}
.empty-state {
color: var(--text-secondary);
font-size: 12px;
text-align: center;
padding: 12px;
}
</style>

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import RequirementSection from './RequirementSection.vue'
import PlanSection from './PlanSection.vue'
import ExecutionSection from './ExecutionSection.vue'
import CommentSection from './CommentSection.vue'
import TimerSection from './TimerSection.vue'
import { api } from '../api'
import { connectWs } from '../ws'
import type { Workflow, PlanStep, Comment } from '../types'
@@ -13,10 +14,18 @@ const props = defineProps<{
projectId: string
}>()
const emit = defineEmits<{
projectUpdate: [projectId: string, name: string]
}>()
const workflow = ref<Workflow | null>(null)
const steps = ref<PlanStep[]>([])
const comments = ref<Comment[]>([])
const error = ref('')
const rightTab = ref<'log' | 'timers'>('log')
const planSteps = computed(() => steps.value.filter(s => s.kind === 'plan'))
const logSteps = computed(() => steps.value.filter(s => s.kind === 'log'))
let wsConn: { close: () => void } | null = null
@@ -46,7 +55,6 @@ function handleWsMessage(msg: WsMessage) {
switch (msg.type) {
case 'PlanUpdate':
if (workflow.value && msg.workflow_id === workflow.value.id) {
// Reload steps from API to get full DB records
api.listSteps(workflow.value.id).then(s => { steps.value = s })
}
break
@@ -56,7 +64,6 @@ function handleWsMessage(msg: WsMessage) {
if (existing) {
steps.value[idx] = { ...existing, status: msg.status as PlanStep['status'], output: msg.output }
} else {
// New step, reload
if (workflow.value) {
api.listSteps(workflow.value.id).then(s => { steps.value = s })
}
@@ -68,6 +75,19 @@ function handleWsMessage(msg: WsMessage) {
workflow.value = { ...workflow.value, status: msg.status as any }
}
break
case 'RequirementUpdate':
if (workflow.value && msg.workflow_id === workflow.value.id) {
workflow.value = { ...workflow.value, requirement: msg.requirement }
}
break
case 'ReportReady':
if (workflow.value && msg.workflow_id === workflow.value.id) {
workflow.value = { ...workflow.value, status: workflow.value.status }
}
break
case 'ProjectUpdate':
emit('projectUpdate', msg.project_id, msg.name)
break
case 'Error':
error.value = msg.message
break
@@ -124,11 +144,29 @@ async function onSubmitComment(text: string) {
@submit="onSubmitRequirement"
/>
<div class="plan-exec-row">
<PlanSection :steps="steps" />
<ExecutionSection :steps="steps" />
<PlanSection :steps="planSteps" />
<div class="right-panel">
<div class="tab-bar">
<button class="tab-btn" :class="{ active: rightTab === 'log' }" @click="rightTab = 'log'">日志</button>
<button class="tab-btn" :class="{ active: rightTab === 'timers' }" @click="rightTab = 'timers'">定时任务</button>
</div>
<ExecutionSection
v-show="rightTab === 'log'"
:steps="logSteps"
:planSteps="planSteps"
:comments="comments"
:requirement="workflow?.requirement || ''"
:createdAt="workflow?.created_at || ''"
:workflowStatus="workflow?.status || 'pending'"
:workflowId="workflow?.id || ''"
/>
<TimerSection
v-show="rightTab === 'timers'"
:projectId="projectId"
/>
</div>
</div>
<CommentSection
:comments="comments"
:disabled="!workflow"
@submit="onSubmitComment"
/>
@@ -162,4 +200,40 @@ async function onSubmitComment(text: string) {
font-size: 13px;
cursor: pointer;
}
.right-panel {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.tab-bar {
display: flex;
gap: 2px;
margin-bottom: 8px;
flex-shrink: 0;
}
.tab-btn {
padding: 6px 14px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 6px 6px 0 0;
letter-spacing: 0.3px;
}
.tab-btn.active {
color: var(--accent);
background: var(--bg-card);
border-bottom-color: var(--bg-card);
}
.tab-btn:hover:not(.active) {
background: var(--bg-tertiary);
}
</style>

View File

@@ -6,19 +6,19 @@
:root {
--sidebar-width: 240px;
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-tertiary: #0f3460;
--bg-card: #1e2a4a;
--text-primary: #e0e0e0;
--text-secondary: #a0a0b0;
--accent: #4fc3f7;
--accent-hover: #29b6f6;
--border: #2a3a5e;
--success: #66bb6a;
--warning: #ffa726;
--error: #ef5350;
--pending: #78909c;
--bg-primary: #ffffff;
--bg-secondary: #f7f8fa;
--bg-tertiary: #eef0f4;
--bg-card: #ffffff;
--text-primary: #1a1a2e;
--text-secondary: #6b7280;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--border: #e2e5ea;
--success: #16a34a;
--warning: #d97706;
--error: #dc2626;
--pending: #9ca3af;
}
html, body, #app {

View File

@@ -12,6 +12,7 @@ export interface Workflow {
requirement: string
status: 'pending' | 'planning' | 'executing' | 'done' | 'failed'
created_at: string
report: string
}
export interface PlanStep {
@@ -19,8 +20,12 @@ export interface PlanStep {
workflow_id: string
step_order: number
description: string
command: string
status: 'pending' | 'running' | 'done' | 'failed'
output: string
created_at: string
kind: 'plan' | 'log'
plan_step_id: string
}
export interface Comment {
@@ -29,3 +34,14 @@ export interface Comment {
content: string
created_at: string
}
export interface Timer {
id: string
project_id: string
name: string
interval_secs: number
requirement: string
enabled: boolean
last_run_at: string
created_at: string
}

View File

@@ -17,12 +17,29 @@ export interface WsWorkflowStatusUpdate {
status: string
}
export interface WsRequirementUpdate {
type: 'RequirementUpdate'
workflow_id: string
requirement: string
}
export interface WsReportReady {
type: 'ReportReady'
workflow_id: string
}
export interface WsProjectUpdate {
type: 'ProjectUpdate'
project_id: string
name: string
}
export interface WsError {
type: 'Error'
message: string
}
export type WsMessage = WsPlanUpdate | WsStepStatusUpdate | WsWorkflowStatusUpdate | WsError
export type WsMessage = WsPlanUpdate | WsStepStatusUpdate | WsWorkflowStatusUpdate | WsRequirementUpdate | WsReportReady | WsProjectUpdate | WsError
export type WsHandler = (msg: WsMessage) => void