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", "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]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -1257,7 +1269,7 @@ dependencies = [
"once_cell", "once_cell",
"socket2", "socket2",
"tracing", "tracing",
"windows-sys 0.52.0", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -2067,6 +2079,8 @@ dependencies = [
"axum", "axum",
"chrono", "chrono",
"futures", "futures",
"mime_guess",
"nix",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",

View File

@@ -24,3 +24,5 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
anyhow = "1" anyhow = "1"
mime_guess = "2"
nix = { version = "0.29", features = ["signal"] }

View File

@@ -8,7 +8,10 @@ RUN npm run build
# Stage 2: Runtime # Stage 2: Runtime
FROM alpine:3.21 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 WORKDIR /app
COPY target/aarch64-unknown-linux-musl/release/tori . COPY target/aarch64-unknown-linux-musl/release/tori .
COPY --from=frontend /app/web/dist ./web/dist/ COPY --from=frontend /app/web/dist ./web/dist/

View File

@@ -3,19 +3,6 @@ kind: Namespace
metadata: metadata:
name: tori 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 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
@@ -46,8 +33,9 @@ spec:
value: "info" value: "info"
volumes: volumes:
- name: data - name: data
persistentVolumeClaim: hostPath:
claimName: tori-data path: /data/tori
type: DirectoryOrCreate
--- ---
apiVersion: v1 apiVersion: v1
kind: Service 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" OCI_DIR="~/src/tori"
IMAGE="registry.oci.euphon.net/tori:latest" IMAGE="registry.oci.euphon.net/tori:latest"
echo "==> Syncing config.yaml to OCI..." echo "==> Syncing project to OCI..."
rsync -az config.yaml "${OCI_HOST}:${OCI_DIR}/config.yaml" rsync -az --exclude target --exclude node_modules --exclude .git --exclude web/dist . "${OCI_HOST}:${OCI_DIR}/"
echo "==> Pushing code to OCI..." echo "==> Building Rust binary on OCI..."
git push origin main ssh "$OCI_HOST" "source ~/.cargo/env && cd $OCI_DIR && \
ssh "$OCI_HOST" "cd $OCI_DIR && git pull" cargo build --release --target aarch64-unknown-linux-musl"
echo "==> Building and deploying on OCI..." echo "==> Building and deploying on OCI..."
ssh "$OCI_HOST" "cd $OCI_DIR && \ 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 projects;
mod timers;
mod workflows; mod workflows;
use std::sync::Arc; 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; use crate::AppState;
pub fn router(state: Arc<AppState>) -> Router { pub fn router(state: Arc<AppState>) -> Router {
Router::new() Router::new()
.merge(projects::router(state.clone())) .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::agent::AgentEvent;
use crate::db::{Workflow, PlanStep, Comment}; use crate::db::{Workflow, PlanStep, Comment};
#[derive(serde::Serialize)]
struct ReportResponse {
report: String,
}
type ApiResult<T> = Result<Json<T>, Response>; type ApiResult<T> = Result<Json<T>, Response>;
fn db_err(e: sqlx::Error) -> 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("/projects/{id}/workflows", get(list_workflows).post(create_workflow))
.route("/workflows/{id}/steps", get(list_steps)) .route("/workflows/{id}/steps", get(list_steps))
.route("/workflows/{id}/comments", get(list_comments).post(create_comment)) .route("/workflows/{id}/comments", get(list_comments).post(create_comment))
.route("/workflows/{id}/report", get(get_report))
.with_state(state) .with_state(state)
} }
@@ -134,3 +140,22 @@ async fn create_comment(
Ok(Json(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), workflow_id TEXT NOT NULL REFERENCES workflows(id),
step_order INTEGER NOT NULL, step_order INTEGER NOT NULL,
description TEXT NOT NULL, description TEXT NOT NULL,
command TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending', status TEXT NOT NULL DEFAULT 'pending',
output TEXT NOT NULL DEFAULT '' output TEXT NOT NULL DEFAULT ''
)" )"
@@ -65,6 +66,49 @@ impl Database {
.execute(&self.pool) .execute(&self.pool)
.await?; .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(()) Ok(())
} }
} }
@@ -85,6 +129,7 @@ pub struct Workflow {
pub requirement: String, pub requirement: String,
pub status: String, pub status: String,
pub created_at: String, pub created_at: String,
pub report: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
@@ -93,8 +138,12 @@ pub struct PlanStep {
pub workflow_id: String, pub workflow_id: String,
pub step_order: i32, pub step_order: i32,
pub description: String, pub description: String,
pub command: String,
pub status: String, pub status: String,
pub output: String, pub output: String,
pub created_at: String,
pub kind: String,
pub plan_step_id: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
@@ -104,3 +153,15 @@ pub struct Comment {
pub content: String, pub content: String,
pub created_at: 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 { struct ChatRequest {
model: String, model: String,
messages: Vec<ChatMessage>, messages: Vec<ChatMessage>,
#[serde(skip_serializing_if = "Vec::is_empty")]
tools: Vec<Tool>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage { pub struct ChatMessage {
pub role: String, 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)] #[derive(Debug, Deserialize)]
struct ChatResponse { pub struct ChatResponse {
choices: Vec<Choice>, pub choices: Vec<ChatChoice>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct Choice { pub struct ChatChoice {
message: ChatMessage, pub message: ChatMessage,
#[allow(dead_code)]
pub finish_reason: Option<String>,
} }
impl LlmClient { 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> { pub async fn chat(&self, messages: Vec<ChatMessage>) -> anyhow::Result<String> {
let resp = self.client let resp = self.chat_with_tools(messages, &[]).await?;
.post(format!("{}/chat/completions", self.config.base_url)) 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)) .header("Authorization", format!("Bearer {}", self.config.api_key))
.json(&ChatRequest { .json(&ChatRequest {
model: self.config.model.clone(), model: self.config.model.clone(),
messages, messages,
tools: tools.to_vec(),
}) })
.send() .send()
.await?
.json::<ChatResponse>()
.await?; .await?;
Ok(resp.choices.first() let status = http_resp.status();
.map(|c| c.message.content.clone()) if !status.is_success() {
.unwrap_or_default()) 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 agent;
mod db; mod db;
mod llm; mod llm;
mod ssh; mod exec;
mod timer;
mod ws; mod ws;
use std::sync::Arc; use std::sync::Arc;
use axum::Router; use axum::Router;
use tower_http::cors::CorsLayer; use tower_http::cors::CorsLayer;
use tower_http::services::ServeDir; use tower_http::services::{ServeDir, ServeFile};
pub struct AppState { pub struct AppState {
pub db: db::Database, pub db: db::Database,
@@ -19,7 +20,6 @@ pub struct AppState {
#[derive(Debug, Clone, serde::Deserialize)] #[derive(Debug, Clone, serde::Deserialize)]
pub struct Config { pub struct Config {
pub llm: LlmConfig, pub llm: LlmConfig,
pub ssh: SshConfig,
pub server: ServerConfig, pub server: ServerConfig,
pub database: DatabaseConfig, pub database: DatabaseConfig,
} }
@@ -31,13 +31,6 @@ pub struct LlmConfig {
pub model: String, 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)] #[derive(Debug, Clone, serde::Deserialize)]
pub struct ServerConfig { pub struct ServerConfig {
pub host: String, pub host: String,
@@ -66,9 +59,10 @@ async fn main() -> anyhow::Result<()> {
let agent_mgr = agent::AgentManager::new( let agent_mgr = agent::AgentManager::new(
database.pool.clone(), database.pool.clone(),
config.llm.clone(), config.llm.clone(),
config.ssh.clone(),
); );
timer::start_timer_runner(database.pool.clone(), agent_mgr.clone());
let state = Arc::new(AppState { let state = Arc::new(AppState {
db: database, db: database,
config: config.clone(), config: config.clone(),
@@ -78,7 +72,7 @@ async fn main() -> anyhow::Result<()> {
let app = Router::new() let app = Router::new()
.nest("/api", api::router(state)) .nest("/api", api::router(state))
.nest("/ws", ws::router(agent_mgr)) .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()); .layer(CorsLayer::permissive());
let addr = format!("{}:{}", config.server.host, config.server.port); 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" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"marked": "^17.0.3",
"mermaid": "^11.12.3",
"vue": "^3.5.25" "vue": "^3.5.25"
}, },
"devDependencies": { "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' const BASE = '/api'
@@ -54,4 +54,25 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify({ content }), 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"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted, onUnmounted, computed } from 'vue'
import Sidebar from './Sidebar.vue' import Sidebar from './Sidebar.vue'
import WorkflowView from './WorkflowView.vue' import WorkflowView from './WorkflowView.vue'
import ReportView from './ReportView.vue'
import CreateForm from './CreateForm.vue'
import { api } from '../api' import { api } from '../api'
import type { Project } from '../types' import type { Project } from '../types'
const projects = ref<Project[]>([]) const projects = ref<Project[]>([])
const selectedProjectId = ref('') const selectedProjectId = ref('')
const reportWorkflowId = ref('')
const error = 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 () => { onMounted(async () => {
try { try {
projects.value = await api.listProjects() projects.value = await api.listProjects()
const first = projects.value[0] const { projectId, reportId } = parseUrl()
if (first) { if (reportId) {
selectedProjectId.value = first.id 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) { } catch (e: any) {
error.value = e.message error.value = e.message
} }
window.addEventListener('popstate', onPopState)
})
onUnmounted(() => {
window.removeEventListener('popstate', onPopState)
}) })
function onSelectProject(id: string) { function onSelectProject(id: string) {
selectedProjectId.value = id selectedProjectId.value = id
reportWorkflowId.value = ''
creating.value = false
history.pushState(null, '', `/projects/${id}`)
} }
async function onCreateProject() { function onStartCreate() {
const name = prompt('项目名称') creating.value = true
if (!name) return selectedProjectId.value = ''
history.pushState(null, '', '/')
}
async function onConfirmCreate(req: string) {
try { try {
const project = await api.createProject(name) const project = await api.createProject('新项目')
projects.value.unshift(project) projects.value.unshift(project)
await api.createWorkflow(project.id, req)
creating.value = false
selectedProjectId.value = project.id selectedProjectId.value = project.id
history.pushState(null, '', `/projects/${project.id}`)
} catch (e: any) { } catch (e: any) {
error.value = e.message 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> </script>
<template> <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 <Sidebar
:projects="projects" :projects="projects"
:selectedId="selectedProjectId" :selectedId="selectedProjectId"
@select="onSelectProject" @select="onSelectProject"
@create="onCreateProject" @create="onStartCreate"
/> />
<main class="main-content"> <main class="main-content">
<div v-if="error" class="error-banner">{{ error }}</div> <div v-if="error" class="error-banner" @click="error = ''">{{ error }}</div>
<div v-if="!selectedProjectId" class="empty-state"> <div v-if="creating" class="empty-state">
<CreateForm @submit="onConfirmCreate" @cancel="creating = false" />
</div>
<div v-else-if="!selectedProjectId" class="empty-state">
选择或创建一个项目开始 选择或创建一个项目开始
</div> </div>
<WorkflowView v-else :projectId="selectedProjectId" :key="selectedProjectId" /> <WorkflowView
v-else
:projectId="selectedProjectId"
:key="selectedProjectId"
@projectUpdate="onProjectUpdate"
/>
</main> </main>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.report-fullpage {
height: 100vh;
overflow-y: auto;
}
.app-layout { .app-layout {
display: flex; display: flex;
height: 100vh; height: 100vh;
@@ -84,5 +144,6 @@ async function onCreateProject() {
color: #fff; color: #fff;
padding: 8px 16px; padding: 8px 16px;
font-size: 13px; font-size: 13px;
cursor: pointer;
} }
</style> </style>

View File

@@ -1,9 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import type { Comment } from '../types'
defineProps<{ const props = defineProps<{
comments: Comment[]
disabled?: boolean disabled?: boolean
}>() }>()
@@ -14,29 +12,31 @@ const emit = defineEmits<{
const input = ref('') const input = ref('')
function submit() { function submit() {
if (props.disabled) return
const text = input.value.trim() const text = input.value.trim()
if (!text) return if (!text) return
emit('submit', text) emit('submit', text)
input.value = '' input.value = ''
} }
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
submit()
}
}
</script> </script>
<template> <template>
<div class="comment-section"> <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"> <div class="comment-input">
<textarea <textarea
v-model="input" v-model="input"
placeholder="输入反馈或调整指令... (Ctrl+Enter 发送)" placeholder="输入反馈或调整指令... (Ctrl+Enter 发送)"
rows="5" rows="3"
@keydown.ctrl.enter="submit" @keydown="onKeydown"
/> />
<button class="btn-send" :disabled="disabled" @click="submit">发送</button> <button class="btn-send" :disabled="disabled || !input.trim()" @click="submit">发送</button>
</div> </div>
</div> </div>
</template> </template>
@@ -49,30 +49,6 @@ function submit() {
overflow: hidden; 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 { .comment-input {
display: flex; display: flex;
gap: 8px; 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"> <script setup lang="ts">
import { ref } from 'vue' import { ref, computed, watch, nextTick } from 'vue'
import type { PlanStep } from '../types' import type { PlanStep, Comment } from '../types'
defineProps<{ const props = defineProps<{
steps: PlanStep[] 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()) const expandedSteps = ref<Set<string>>(new Set())
function toggleStep(id: string) { 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) { function statusLabel(status: string) {
switch (status) { switch (status) {
case 'done': return '完成' case 'done': return '完成'
@@ -24,31 +58,114 @@ function statusLabel(status: string) {
default: return '等待' 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> </script>
<template> <template>
<div class="execution-section"> <div class="execution-section" ref="scrollContainer" @scroll="onScroll">
<div class="section-header"> <div class="section-header">
<h2>执行</h2> <h2>日志</h2>
</div> </div>
<div class="exec-list"> <div class="exec-list">
<div <template v-for="entry in logEntries" :key="entry.id">
v-for="step in steps" <!-- User message (requirement or comment) -->
:key="step.id" <div v-if="entry.type === 'requirement' || entry.type === 'comment'" class="log-user">
class="exec-item" <span class="log-time" v-if="entry.time">{{ formatTime(entry.time) }}</span>
:class="step.status" <span class="log-tag">{{ entry.type === 'requirement' ? '需求' : '反馈' }}</span>
> <span class="log-text">{{ entry.text }}</span>
<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>
</div> </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> </div>
</div>
<div v-if="!steps.length" class="empty-state"> <!-- 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>
</template>
<div v-if="!steps.length && !requirement" class="empty-state">
提交需求后日志将显示在这里
</div> </div>
</div> </div>
</div> </div>
@@ -83,6 +200,35 @@ function statusLabel(status: string) {
gap: 4px; 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 { .exec-item {
border-radius: 6px; border-radius: 6px;
overflow: hidden; overflow: hidden;
@@ -100,7 +246,20 @@ function statusLabel(status: string) {
} }
.exec-header:hover { .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 { .exec-toggle {
@@ -110,7 +269,7 @@ function statusLabel(status: string) {
flex-shrink: 0; flex-shrink: 0;
} }
.exec-order { .exec-desc {
color: var(--text-primary); color: var(--text-primary);
font-weight: 500; font-weight: 500;
flex: 1; flex: 1;
@@ -123,24 +282,36 @@ function statusLabel(status: string) {
font-weight: 500; font-weight: 500;
} }
.exec-status.done { background: var(--success); color: #000; } .exec-status.done { background: var(--success); color: #fff; }
.exec-status.running { background: var(--accent); color: #000; } .exec-status.running { background: var(--accent); color: #fff; }
.exec-status.failed { background: var(--error); color: #fff; } .exec-status.failed { background: var(--error); color: #fff; }
.exec-status.pending { background: var(--pending); color: #fff; } .exec-status.pending { background: var(--pending); color: #fff; }
.exec-output { .exec-detail {
padding: 8px 12px;
border-top: 1px solid var(--border); 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-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 12px; font-size: 12px;
line-height: 1.5; line-height: 1.5;
color: var(--text-primary); color: var(--text-primary);
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all; word-break: break-all;
margin: 0;
} }
.empty-state { .empty-state {
@@ -149,4 +320,23 @@ function statusLabel(status: string) {
text-align: center; text-align: center;
padding: 24px; 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> </style>

View File

@@ -1,10 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'
import type { PlanStep } from '../types' import type { PlanStep } from '../types'
defineProps<{ defineProps<{
steps: PlanStep[] 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) { function statusIcon(status: string) {
switch (status) { switch (status) {
case 'done': return '✓' case 'done': return '✓'
@@ -18,7 +29,7 @@ function statusIcon(status: string) {
<template> <template>
<div class="plan-section"> <div class="plan-section">
<div class="section-header"> <div class="section-header">
<h2>Plan</h2> <h2>计划</h2>
</div> </div>
<div class="steps-list"> <div class="steps-list">
<div <div
@@ -27,12 +38,18 @@ function statusIcon(status: string) {
class="step-item" class="step-item"
:class="step.status" :class="step.status"
> >
<span class="step-icon">{{ statusIcon(step.status) }}</span> <div class="step-header" @click="step.command ? toggleStep(step.id) : undefined">
<span class="step-order">{{ step.step_order }}.</span> <span class="step-icon">{{ statusIcon(step.status) }}</span>
<span class="step-desc">{{ step.description }}</span> <span class="step-order">{{ step.step_order }}.</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>
<div v-if="!steps.length" class="empty-state"> <div v-if="!steps.length" class="empty-state">
提交需求后AI 将在这里生成计划 AI 将在这里展示执行计划
</div> </div>
</div> </div>
</div> </div>
@@ -68,14 +85,9 @@ function statusIcon(status: string) {
} }
.step-item { .step-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 10px;
border-radius: 6px; border-radius: 6px;
font-size: 13px;
line-height: 1.5;
background: var(--bg-secondary); background: var(--bg-secondary);
overflow: hidden;
} }
.step-item.done { border-left: 3px solid var(--success); } .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.failed { border-left: 3px solid var(--error); }
.step-item.pending { border-left: 3px solid var(--pending); opacity: 0.7; } .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 { .step-icon {
font-size: 14px; font-size: 14px;
flex-shrink: 0; flex-shrink: 0;
@@ -99,8 +129,24 @@ function statusIcon(status: string) {
flex-shrink: 0; flex-shrink: 0;
} }
.step-desc { .step-title {
color: var(--text-primary); 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 { .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"> <script setup lang="ts">
import { ref } from 'vue' import { ref, watch } from 'vue'
const props = defineProps<{ const props = defineProps<{
requirement: string requirement: string
@@ -13,6 +13,13 @@ const emit = defineEmits<{
const input = ref('') const input = ref('')
const editing = ref(!props.requirement) const editing = ref(!props.requirement)
// 当 requirement 从外部更新(如 loadData 完成),自动退出编辑模式
watch(() => props.requirement, (val) => {
if (val && editing.value && !input.value.trim()) {
editing.value = false
}
})
function submit() { function submit() {
const text = input.value.trim() const text = input.value.trim()
if (!text) return if (!text) return
@@ -29,8 +36,9 @@ function submit() {
{{ status }} {{ status }}
</span> </span>
</div> </div>
<div v-if="!editing && requirement" class="requirement-display" @dblclick="editing = true"> <div v-if="!editing && requirement" class="requirement-display">
{{ requirement }} <span>{{ requirement }}</span>
<button class="btn-edit" @click="editing = true; input = requirement">编辑</button>
</div> </div>
<div v-else class="requirement-input"> <div v-else class="requirement-input">
<textarea <textarea
@@ -80,10 +88,27 @@ function submit() {
.status-badge.failed { background: var(--error); color: #fff; } .status-badge.failed { background: var(--error); color: #fff; }
.requirement-display { .requirement-display {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
font-size: 14px; font-size: 14px;
line-height: 1.6; line-height: 1.6;
color: var(--text-primary); 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 { .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"> <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 RequirementSection from './RequirementSection.vue'
import PlanSection from './PlanSection.vue' import PlanSection from './PlanSection.vue'
import ExecutionSection from './ExecutionSection.vue' import ExecutionSection from './ExecutionSection.vue'
import CommentSection from './CommentSection.vue' import CommentSection from './CommentSection.vue'
import TimerSection from './TimerSection.vue'
import { api } from '../api' import { api } from '../api'
import { connectWs } from '../ws' import { connectWs } from '../ws'
import type { Workflow, PlanStep, Comment } from '../types' import type { Workflow, PlanStep, Comment } from '../types'
@@ -13,10 +14,18 @@ const props = defineProps<{
projectId: string projectId: string
}>() }>()
const emit = defineEmits<{
projectUpdate: [projectId: string, name: string]
}>()
const workflow = ref<Workflow | null>(null) const workflow = ref<Workflow | null>(null)
const steps = ref<PlanStep[]>([]) const steps = ref<PlanStep[]>([])
const comments = ref<Comment[]>([]) const comments = ref<Comment[]>([])
const error = ref('') 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 let wsConn: { close: () => void } | null = null
@@ -46,7 +55,6 @@ function handleWsMessage(msg: WsMessage) {
switch (msg.type) { switch (msg.type) {
case 'PlanUpdate': case 'PlanUpdate':
if (workflow.value && msg.workflow_id === workflow.value.id) { 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 }) api.listSteps(workflow.value.id).then(s => { steps.value = s })
} }
break break
@@ -56,7 +64,6 @@ function handleWsMessage(msg: WsMessage) {
if (existing) { if (existing) {
steps.value[idx] = { ...existing, status: msg.status as PlanStep['status'], output: msg.output } steps.value[idx] = { ...existing, status: msg.status as PlanStep['status'], output: msg.output }
} else { } else {
// New step, reload
if (workflow.value) { if (workflow.value) {
api.listSteps(workflow.value.id).then(s => { steps.value = s }) 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 } workflow.value = { ...workflow.value, status: msg.status as any }
} }
break 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': case 'Error':
error.value = msg.message error.value = msg.message
break break
@@ -124,11 +144,29 @@ async function onSubmitComment(text: string) {
@submit="onSubmitRequirement" @submit="onSubmitRequirement"
/> />
<div class="plan-exec-row"> <div class="plan-exec-row">
<PlanSection :steps="steps" /> <PlanSection :steps="planSteps" />
<ExecutionSection :steps="steps" /> <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> </div>
<CommentSection <CommentSection
:comments="comments"
:disabled="!workflow" :disabled="!workflow"
@submit="onSubmitComment" @submit="onSubmitComment"
/> />
@@ -162,4 +200,40 @@ async function onSubmitComment(text: string) {
font-size: 13px; font-size: 13px;
cursor: pointer; 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> </style>

View File

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

View File

@@ -12,6 +12,7 @@ export interface Workflow {
requirement: string requirement: string
status: 'pending' | 'planning' | 'executing' | 'done' | 'failed' status: 'pending' | 'planning' | 'executing' | 'done' | 'failed'
created_at: string created_at: string
report: string
} }
export interface PlanStep { export interface PlanStep {
@@ -19,8 +20,12 @@ export interface PlanStep {
workflow_id: string workflow_id: string
step_order: number step_order: number
description: string description: string
command: string
status: 'pending' | 'running' | 'done' | 'failed' status: 'pending' | 'running' | 'done' | 'failed'
output: string output: string
created_at: string
kind: 'plan' | 'log'
plan_step_id: string
} }
export interface Comment { export interface Comment {
@@ -29,3 +34,14 @@ export interface Comment {
content: string content: string
created_at: 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 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 { export interface WsError {
type: 'Error' type: 'Error'
message: string 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 export type WsHandler = (msg: WsMessage) => void