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:
parent
e2d5a6a7eb
commit
2df4e12d30
16
Cargo.lock
generated
16
Cargo.lock
generated
@ -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",
|
||||
|
||||
@ -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"] }
|
||||
|
||||
@ -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/
|
||||
|
||||
@ -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
|
||||
|
||||
15
doc/todo.md
15
doc/todo.md
@ -1,5 +1,14 @@
|
||||
# Tori 开发 TODO
|
||||
context compaction
|
||||
|
||||
所有初始 TODO 已完成。待笨笨指示的事项:
|
||||
rag / kb
|
||||
|
||||
- [ ] ARM 部署方式(等笨笨说怎么搞)
|
||||
template
|
||||
|
||||
---
|
||||
|
||||
## 代码啰嗦/可精简
|
||||
|
||||
- **agent.rs**:`NewRequirement` 与 `Comment` 分支里「设 final_status → 更新 DB status → broadcast WorkflowStatusUpdate → 查 all_steps → generate_report → 更新 report → broadcast ReportReady」几乎相同,可抽成共用函数(如 `finish_workflow_and_report`);venv 创建/检查(create_dir_all + .venv 存在 + uv venv)两处重复,可抽成 helper。
|
||||
- **api/**:`projects.rs`、`workflows.rs`、`timers.rs` 里 `db_err` 与 `ApiResult<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 减少重复。
|
||||
@ -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 && \
|
||||
|
||||
1278
src/agent.rs
1278
src/agent.rs
File diff suppressed because it is too large
Load Diff
107
src/api/mod.rs
107
src/api/mod.rs
@ -1,12 +1,115 @@
|
||||
mod projects;
|
||||
mod timers;
|
||||
mod workflows;
|
||||
|
||||
use std::sync::Arc;
|
||||
use axum::Router;
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Path, State, Request},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, any},
|
||||
Router,
|
||||
};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
pub fn router(state: Arc<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
147
src/api/timers.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
61
src/db.rs
61
src/db.rs
@ -47,6 +47,7 @@ impl Database {
|
||||
workflow_id TEXT NOT NULL REFERENCES workflows(id),
|
||||
step_order INTEGER NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
command TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
output TEXT NOT NULL DEFAULT ''
|
||||
)"
|
||||
@ -65,6 +66,49 @@ impl Database {
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
// Migration: add report column to workflows
|
||||
let _ = sqlx::query(
|
||||
"ALTER TABLE workflows ADD COLUMN report TEXT NOT NULL DEFAULT ''"
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await;
|
||||
|
||||
// Migration: add created_at to plan_steps
|
||||
let _ = sqlx::query(
|
||||
"ALTER TABLE plan_steps ADD COLUMN created_at TEXT NOT NULL DEFAULT ''"
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await;
|
||||
|
||||
// Migration: add kind to plan_steps ('plan' or 'log')
|
||||
let _ = sqlx::query(
|
||||
"ALTER TABLE plan_steps ADD COLUMN kind TEXT NOT NULL DEFAULT 'log'"
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await;
|
||||
|
||||
// Migration: add plan_step_id to plan_steps (log entries reference their parent plan step)
|
||||
let _ = sqlx::query(
|
||||
"ALTER TABLE plan_steps ADD COLUMN plan_step_id TEXT NOT NULL DEFAULT ''"
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS timers (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
name TEXT NOT NULL,
|
||||
interval_secs INTEGER NOT NULL,
|
||||
requirement TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
last_run_at TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)"
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@ -85,6 +129,7 @@ pub struct Workflow {
|
||||
pub requirement: String,
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
pub report: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
@ -93,8 +138,12 @@ pub struct PlanStep {
|
||||
pub workflow_id: String,
|
||||
pub step_order: i32,
|
||||
pub description: String,
|
||||
pub command: String,
|
||||
pub status: String,
|
||||
pub output: String,
|
||||
pub created_at: String,
|
||||
pub kind: String,
|
||||
pub plan_step_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
@ -104,3 +153,15 @@ pub struct Comment {
|
||||
pub content: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Timer {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub name: String,
|
||||
pub interval_secs: i64,
|
||||
pub requirement: String,
|
||||
pub enabled: bool,
|
||||
pub last_run_at: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
40
src/exec.rs
Normal file
40
src/exec.rs
Normal 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,
|
||||
}
|
||||
96
src/llm.rs
96
src/llm.rs
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
18
src/main.rs
18
src/main.rs
@ -2,13 +2,14 @@ mod api;
|
||||
mod agent;
|
||||
mod db;
|
||||
mod llm;
|
||||
mod ssh;
|
||||
mod exec;
|
||||
mod timer;
|
||||
mod ws;
|
||||
|
||||
use std::sync::Arc;
|
||||
use axum::Router;
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tower_http::services::ServeDir;
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
|
||||
pub struct AppState {
|
||||
pub db: db::Database,
|
||||
@ -19,7 +20,6 @@ pub struct AppState {
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
pub struct Config {
|
||||
pub llm: LlmConfig,
|
||||
pub ssh: SshConfig,
|
||||
pub server: ServerConfig,
|
||||
pub database: DatabaseConfig,
|
||||
}
|
||||
@ -31,13 +31,6 @@ pub struct LlmConfig {
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
pub struct SshConfig {
|
||||
pub host: String,
|
||||
pub user: String,
|
||||
pub key_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
pub struct ServerConfig {
|
||||
pub host: String,
|
||||
@ -66,9 +59,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
let agent_mgr = agent::AgentManager::new(
|
||||
database.pool.clone(),
|
||||
config.llm.clone(),
|
||||
config.ssh.clone(),
|
||||
);
|
||||
|
||||
timer::start_timer_runner(database.pool.clone(), agent_mgr.clone());
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
db: database,
|
||||
config: config.clone(),
|
||||
@ -78,7 +72,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
let app = Router::new()
|
||||
.nest("/api", api::router(state))
|
||||
.nest("/ws", ws::router(agent_mgr))
|
||||
.fallback_service(ServeDir::new("web/dist"))
|
||||
.fallback_service(ServeDir::new("web/dist").fallback(ServeFile::new("web/dist/index.html")))
|
||||
.layer(CorsLayer::permissive());
|
||||
|
||||
let addr = format!("{}:{}", config.server.host, config.server.port);
|
||||
|
||||
36
src/ssh.rs
36
src/ssh.rs
@ -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
75
src/timer.rs
Normal 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
1125
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -9,6 +9,8 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"marked": "^17.0.3",
|
||||
"mermaid": "^11.12.3",
|
||||
"vue": "^3.5.25"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -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' }),
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
142
web/src/components/CreateForm.vue
Normal file
142
web/src/components/CreateForm.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
232
web/src/components/ReportView.vue
Normal file
232
web/src/components/ReportView.vue
Normal 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">← 返回</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>
|
||||
@ -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 {
|
||||
|
||||
317
web/src/components/TimerSection.vue
Normal file
317
web/src/components/TimerSection.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user