Tori: AI agent workflow manager - initial implementation

Rust (Axum) + Vue 3 + SQLite. Features:
- Project CRUD REST API with proper error handling
- Per-project agent loop (mpsc + broadcast channels)
- LLM-driven plan generation and replan on user feedback
- SSH command execution with status streaming
- WebSocket real-time updates to frontend
- Four-zone UI: requirement, plan (left), execution (right), comment

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Fam Zheng 2026-02-28 10:36:50 +00:00
parent 1122ab27dd
commit 7edbbee471
43 changed files with 7164 additions and 83 deletions

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Config with secrets
config.yaml
# Rust
/target/
**/*.rs.bk
# Node
web/node_modules/
web/dist/
# Database
*.db
*.db-journal
*.db-wal
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store

3163
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

26
Cargo.toml Normal file
View File

@ -0,0 +1,26 @@
[package]
name = "tori"
version = "0.1.0"
edition = "2021"
[lints.rust]
warnings = "deny"
[lints.clippy]
all = "deny"
[dependencies]
axum = { version = "0.8", features = ["ws"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
tower-http = { version = "0.6", features = ["cors", "fs"] }
reqwest = { version = "0.12", features = ["json", "stream"] }
futures = "0.3"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] }
anyhow = "1"

25
Dockerfile Normal file
View File

@ -0,0 +1,25 @@
# Stage 1: Build frontend
FROM node:22-alpine AS frontend
WORKDIR /app/web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
# Stage 2: Build backend
FROM rust:1.84-alpine AS backend
RUN apk add --no-cache musl-dev
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY src/ ./src/
RUN cargo build --release
# Stage 3: Runtime
FROM alpine:3.21
RUN apk add --no-cache openssh-client ca-certificates
WORKDIR /app
COPY --from=backend /app/target/release/tori .
COPY --from=frontend /app/web/dist ./web/dist/
EXPOSE 3000
CMD ["./tori"]

41
Makefile Normal file
View File

@ -0,0 +1,41 @@
.PHONY: dev dev-backend dev-frontend build build-backend build-frontend clean deploy clippy lint docker-build
# 开发模式:同时启动前后端
dev:
@echo "Starting Tori dev mode..."
$(MAKE) dev-backend &
$(MAKE) dev-frontend &
wait
dev-backend:
cargo run
dev-frontend:
cd web && npm run dev -- --port 5173
# 构建
build: build-frontend build-backend
build-backend:
cargo build --release
build-frontend:
cd web && npm run build
# Docker
docker-build:
docker build -t tori:latest .
# 部署
deploy: docker-build
kubectl apply -f deploy/
# Lint
clippy:
cargo clippy
lint: clippy
clean:
cargo clean
rm -rf web/dist web/node_modules

104
README.md
View File

@ -1,93 +1,31 @@
# tori # Tori — AI Agent 工作流管理器
AI agent 驱动的工作流管理 Web 应用。描述需求AI 规划agent 执行,随时通过 comment 反馈。
## 快速开始
## Getting started ```bash
# 开发模式(前后端同时启动)
make dev
To make it easy for you to get started with GitLab, here's a list of recommended next steps. # 构建生产版本
make build
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! # 部署到 OCI ARM 服务器
make deploy
## Add your files
* [Create](https://docs.gitlab.com/user/project/repository/web_editor/#create-a-file) or [upload](https://docs.gitlab.com/user/project/repository/web_editor/#upload-a-file) files
* [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command:
```
cd existing_repo
git remote add origin https://gitlab.com/famzheng/tori.git
git branch -M main
git push -uf origin main
``` ```
## Integrate with your tools ## 配置
* [Set up project integrations](https://gitlab.com/famzheng/tori/-/settings/integrations) ```bash
cp config.yaml.example config.yaml
# 编辑 config.yaml填入 LLM API key 等
```
## Collaborate with your team ## 技术栈
* [Invite team members and collaborators](https://docs.gitlab.com/user/project/members/) - **后端**: Rust (Axum) + SQLite
* [Create a new merge request](https://docs.gitlab.com/user/project/merge_requests/creating_merge_requests/) - **前端**: Vite + Vue 3 + TypeScript
* [Automatically close issues from merge requests](https://docs.gitlab.com/user/project/issues/managing_issues/#closing-issues-automatically) - **LLM**: OpenAI 兼容 APIRequesty.ai 网关)
* [Enable merge request approvals](https://docs.gitlab.com/user/project/merge_requests/approvals/) - **实时通信**: WebSocket
* [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/) - **远程执行**: SSH
## Test and Deploy
Use the built-in continuous integration in GitLab.
* [Get started with GitLab CI/CD](https://docs.gitlab.com/ci/quick_start/)
* [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/user/application_security/sast/)
* [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/topics/autodevops/requirements/)
* [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/user/clusters/agent/)
* [Set up protected environments](https://docs.gitlab.com/ci/environments/protected_environments/)
***
# Editing this README
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
## Suggestions for a good README
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
## Name
Choose a self-explaining name for your project.
## Description
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
## Badges
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
## Visuals
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
## Installation
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
## Usage
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
## Support
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
## Roadmap
If you have ideas for releases in the future, it is a good idea to list them in the README.
## Contributing
State if you are open to contributions and what your requirements are for accepting them.
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
## Authors and acknowledgment
Show your appreciation to those who have contributed to the project.
## License
For open source projects, say how it is licensed.
## Project status
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.

16
config.yaml.example Normal file
View File

@ -0,0 +1,16 @@
llm:
base_url: "https://router.requesty.ai/v1"
api_key: ""
model: "anthropic/claude-sonnet-4-6-20250514"
ssh:
host: "target-server"
user: "deploy"
key_path: "~/.ssh/id_rsa"
server:
host: "0.0.0.0"
port: 3000
database:
path: "tori.db"

44
deploy/deployment.yaml Normal file
View File

@ -0,0 +1,44 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: tori
spec:
replicas: 1
selector:
matchLabels:
app: tori
template:
metadata:
labels:
app: tori
spec:
containers:
- name: tori
image: tori:latest
ports:
- containerPort: 3000
volumeMounts:
- name: config
mountPath: /app/config.yaml
subPath: config.yaml
- name: data
mountPath: /app/tori.db
volumes:
- name: config
configMap:
name: tori-config
- name: data
persistentVolumeClaim:
claimName: tori-data
---
apiVersion: v1
kind: Service
metadata:
name: tori
spec:
selector:
app: tori
ports:
- port: 80
targetPort: 3000
type: ClusterIP

85
doc/design.md Normal file
View File

@ -0,0 +1,85 @@
# Tori — 产品设计文档
## 概述
Tori 是一个 AI agent 驱动的工作流管理器。类似 ChatGPT 的布局,但管理单元是项目/工作流。
用户描述需求AI 生成计划agent 执行,用户随时通过 comment 提供反馈。
## UI 布局
```
┌──────────┬─────────────────────────────────────────────┐
│ │ ① 需求区(输入/显示) │
│ 项目列表 ├──────────────────────┬──────────────────────┤
│ │ ② Plan │ ③ 执行(右) │
│ > proj-A │ AI 分析 + 步骤列表 │ 步骤状态 + 可折叠日志 │
│ proj-B │ │ │
│ ├──────────────────────┴──────────────────────┤
│ │ ④ Comment5-10行输入区
└──────────┴─────────────────────────────────────────────┘
```
### 关键设计决策
- Plan 和执行**左右并列**,不是上下堆叠
- Comment 区域 5-10 行高
- 侧边栏显示所有项目,点击切换
## Agent 架构
- 每个项目一个 async event loop + mpsc channel
- 用户操作和 SSH 输出都是 channel 中的事件
- Plan → Execute → Replan 循环由事件驱动
- Agent 状态机Idle → Planning → Executing → WaitingForFeedback
## 数据模型
### Project项目
- id, name, description, created_at, updated_at
### Workflow工作流
- id, project_id, requirement需求文本, status, created_at
### PlanStep计划步骤
- id, workflow_id, order, description, status, output
### Comment评论
- id, workflow_id, content, created_at
## API 设计
### REST
- `GET /api/projects` — 列出项目
- `POST /api/projects` — 创建项目
- `GET /api/projects/:id` — 获取项目详情
- `PUT /api/projects/:id` — 更新项目
- `DELETE /api/projects/:id` — 删除项目
- `POST /api/projects/:id/workflows` — 创建工作流(含需求描述)
- `GET /api/projects/:id/workflows` — 列出工作流
- `POST /api/workflows/:id/comments` — 添加评论
### WebSocket
- `GET /ws/:project_id` — 项目的实时更新通道
- Server → Clientplan 更新、执行日志、状态变化
- Client → Server评论、控制命令
## 配置结构
```yaml
llm:
base_url: "https://router.requesty.ai/v1"
api_key: "sk-..."
model: "anthropic/claude-sonnet-4-6-20250514"
ssh:
host: "target-server"
user: "deploy"
key_path: "~/.ssh/id_rsa"
server:
host: "0.0.0.0"
port: 3000
database:
path: "tori.db"
```

5
doc/todo.md Normal file
View File

@ -0,0 +1,5 @@
# Tori 开发 TODO
所有初始 TODO 已完成。待笨笨指示的事项:
- [ ] ARM 部署方式(等笨笨说怎么搞)

520
src/agent.rs Normal file
View File

@ -0,0 +1,520 @@
use std::collections::HashMap;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use sqlx::sqlite::SqlitePool;
use tokio::sync::{mpsc, RwLock, broadcast};
use crate::llm::{LlmClient, ChatMessage};
use crate::ssh::SshExecutor;
use crate::{LlmConfig, SshConfig};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum AgentEvent {
NewRequirement { workflow_id: String, requirement: String },
Comment { workflow_id: String, content: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum WsMessage {
PlanUpdate { workflow_id: String, steps: Vec<PlanStepInfo> },
StepStatusUpdate { step_id: String, status: String, output: String },
WorkflowStatusUpdate { workflow_id: String, status: String },
Error { message: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanStepInfo {
pub order: i32,
pub description: String,
pub command: String,
}
pub struct AgentManager {
agents: RwLock<HashMap<String, mpsc::Sender<AgentEvent>>>,
broadcast: RwLock<HashMap<String, broadcast::Sender<WsMessage>>>,
pool: SqlitePool,
llm_config: LlmConfig,
ssh_config: SshConfig,
}
impl AgentManager {
pub fn new(pool: SqlitePool, llm_config: LlmConfig, ssh_config: SshConfig) -> Arc<Self> {
Arc::new(Self {
agents: RwLock::new(HashMap::new()),
broadcast: RwLock::new(HashMap::new()),
pool,
llm_config,
ssh_config,
})
}
pub async fn get_broadcast(&self, project_id: &str) -> broadcast::Receiver<WsMessage> {
let mut map = self.broadcast.write().await;
let tx = map.entry(project_id.to_string())
.or_insert_with(|| broadcast::channel(64).0);
tx.subscribe()
}
pub async fn send_event(self: &Arc<Self>, project_id: &str, event: AgentEvent) {
let agents = self.agents.read().await;
if let Some(tx) = agents.get(project_id) {
let _ = tx.send(event).await;
} else {
drop(agents);
self.spawn_agent(project_id.to_string()).await;
let agents = self.agents.read().await;
if let Some(tx) = agents.get(project_id) {
let _ = tx.send(event).await;
}
}
}
async fn spawn_agent(self: &Arc<Self>, project_id: String) {
let (tx, rx) = mpsc::channel(32);
self.agents.write().await.insert(project_id.clone(), tx);
let broadcast_tx = {
let mut map = self.broadcast.write().await;
map.entry(project_id.clone())
.or_insert_with(|| broadcast::channel(64).0)
.clone()
};
let pool = self.pool.clone();
let llm_config = self.llm_config.clone();
let ssh_config = self.ssh_config.clone();
tokio::spawn(agent_loop(project_id, rx, broadcast_tx, pool, llm_config, ssh_config));
}
}
async fn agent_loop(
project_id: String,
mut rx: mpsc::Receiver<AgentEvent>,
broadcast_tx: broadcast::Sender<WsMessage>,
pool: SqlitePool,
llm_config: LlmConfig,
ssh_config: SshConfig,
) {
let llm = LlmClient::new(&llm_config);
let ssh = SshExecutor::new(&ssh_config);
tracing::info!("Agent loop started for project {}", project_id);
while let Some(event) = rx.recv().await {
match event {
AgentEvent::NewRequirement { workflow_id, requirement } => {
let _ = broadcast_tx.send(WsMessage::WorkflowStatusUpdate {
workflow_id: workflow_id.clone(),
status: "planning".into(),
});
let plan_result = generate_plan(&llm, &requirement).await;
match plan_result {
Ok(steps) => {
// Save steps to DB
for (i, step) in steps.iter().enumerate() {
let step_id = uuid::Uuid::new_v4().to_string();
let _ = sqlx::query(
"INSERT INTO plan_steps (id, workflow_id, step_order, description, status) VALUES (?, ?, ?, ?, 'pending')"
)
.bind(&step_id)
.bind(&workflow_id)
.bind(i as i32 + 1)
.bind(&step.description)
.execute(&pool)
.await;
}
let _ = broadcast_tx.send(WsMessage::PlanUpdate {
workflow_id: workflow_id.clone(),
steps: steps.clone(),
});
let _ = sqlx::query("UPDATE workflows SET status = 'executing' WHERE id = ?")
.bind(&workflow_id)
.execute(&pool)
.await;
let _ = broadcast_tx.send(WsMessage::WorkflowStatusUpdate {
workflow_id: workflow_id.clone(),
status: "executing".into(),
});
// Execute each step
let db_steps = sqlx::query_as::<_, crate::db::PlanStep>(
"SELECT * FROM plan_steps WHERE workflow_id = ? ORDER BY step_order"
)
.bind(&workflow_id)
.fetch_all(&pool)
.await
.unwrap_or_default();
let mut all_ok = true;
for (i, db_step) in db_steps.iter().enumerate() {
let _ = sqlx::query("UPDATE plan_steps SET status = 'running' WHERE id = ?")
.bind(&db_step.id)
.execute(&pool)
.await;
let _ = broadcast_tx.send(WsMessage::StepStatusUpdate {
step_id: db_step.id.clone(),
status: "running".into(),
output: String::new(),
});
let cmd = &steps[i].command;
if cmd.is_empty() {
let _ = sqlx::query("UPDATE plan_steps SET status = 'done', output = 'Skipped (no command)' WHERE id = ?")
.bind(&db_step.id)
.execute(&pool)
.await;
let _ = broadcast_tx.send(WsMessage::StepStatusUpdate {
step_id: db_step.id.clone(),
status: "done".into(),
output: "Skipped (no command)".into(),
});
continue;
}
match ssh.execute(cmd).await {
Ok(result) => {
let output = if result.stderr.is_empty() {
result.stdout.clone()
} else {
format!("{}\nSTDERR: {}", result.stdout, result.stderr)
};
let status = if result.exit_code == 0 { "done" } else { "failed" };
let _ = sqlx::query("UPDATE plan_steps SET status = ?, output = ? WHERE id = ?")
.bind(status)
.bind(&output)
.bind(&db_step.id)
.execute(&pool)
.await;
let _ = broadcast_tx.send(WsMessage::StepStatusUpdate {
step_id: db_step.id.clone(),
status: status.into(),
output,
});
if result.exit_code != 0 {
all_ok = false;
break;
}
}
Err(e) => {
let _ = sqlx::query("UPDATE plan_steps SET status = 'failed', output = ? WHERE id = ?")
.bind(e.to_string())
.bind(&db_step.id)
.execute(&pool)
.await;
let _ = broadcast_tx.send(WsMessage::StepStatusUpdate {
step_id: db_step.id.clone(),
status: "failed".into(),
output: e.to_string(),
});
all_ok = false;
break;
}
}
}
let final_status = if all_ok { "done" } else { "failed" };
let _ = sqlx::query("UPDATE workflows SET status = ? WHERE id = ?")
.bind(final_status)
.bind(&workflow_id)
.execute(&pool)
.await;
let _ = broadcast_tx.send(WsMessage::WorkflowStatusUpdate {
workflow_id: workflow_id.clone(),
status: final_status.into(),
});
}
Err(e) => {
let _ = broadcast_tx.send(WsMessage::Error {
message: format!("Plan generation failed: {}", e),
});
let _ = sqlx::query("UPDATE workflows SET status = 'failed' WHERE id = ?")
.bind(&workflow_id)
.execute(&pool)
.await;
}
}
}
AgentEvent::Comment { workflow_id, content } => {
tracing::info!("Comment on workflow {}: {}", workflow_id, content);
// Get current workflow and steps for context
let wf = sqlx::query_as::<_, crate::db::Workflow>(
"SELECT * FROM workflows WHERE id = ?",
)
.bind(&workflow_id)
.fetch_optional(&pool)
.await
.ok()
.flatten();
let Some(wf) = wf else { continue };
let current_steps = sqlx::query_as::<_, crate::db::PlanStep>(
"SELECT * FROM plan_steps WHERE workflow_id = ? ORDER BY step_order",
)
.bind(&workflow_id)
.fetch_all(&pool)
.await
.unwrap_or_default();
// Ask LLM to replan
let replan_result =
replan(&llm, &wf.requirement, &current_steps, &content).await;
match replan_result {
Ok(new_steps) => {
// Clear old pending steps, keep done ones
let _ = sqlx::query(
"DELETE FROM plan_steps WHERE workflow_id = ? AND status IN ('pending', 'failed')",
)
.bind(&workflow_id)
.execute(&pool)
.await;
let done_count = sqlx::query_scalar::<_, i32>(
"SELECT COUNT(*) FROM plan_steps WHERE workflow_id = ?",
)
.bind(&workflow_id)
.fetch_one(&pool)
.await
.unwrap_or(0);
for (i, step) in new_steps.iter().enumerate() {
let step_id = uuid::Uuid::new_v4().to_string();
let _ = sqlx::query(
"INSERT INTO plan_steps (id, workflow_id, step_order, description, status) VALUES (?, ?, ?, ?, 'pending')",
)
.bind(&step_id)
.bind(&workflow_id)
.bind(done_count + i as i32 + 1)
.bind(&step.description)
.execute(&pool)
.await;
}
let _ = broadcast_tx.send(WsMessage::PlanUpdate {
workflow_id: workflow_id.clone(),
steps: new_steps.clone(),
});
// Resume execution
let _ = sqlx::query(
"UPDATE workflows SET status = 'executing' WHERE id = ?",
)
.bind(&workflow_id)
.execute(&pool)
.await;
let _ = broadcast_tx.send(WsMessage::WorkflowStatusUpdate {
workflow_id: workflow_id.clone(),
status: "executing".into(),
});
let db_steps = sqlx::query_as::<_, crate::db::PlanStep>(
"SELECT * FROM plan_steps WHERE workflow_id = ? AND status = 'pending' ORDER BY step_order",
)
.bind(&workflow_id)
.fetch_all(&pool)
.await
.unwrap_or_default();
let mut all_ok = true;
for (i, db_step) in db_steps.iter().enumerate() {
let _ = sqlx::query(
"UPDATE plan_steps SET status = 'running' WHERE id = ?",
)
.bind(&db_step.id)
.execute(&pool)
.await;
let _ = broadcast_tx.send(WsMessage::StepStatusUpdate {
step_id: db_step.id.clone(),
status: "running".into(),
output: String::new(),
});
let cmd = if i < new_steps.len() {
&new_steps[i].command
} else {
&String::new()
};
if cmd.is_empty() {
let _ = sqlx::query(
"UPDATE plan_steps SET status = 'done', output = 'Skipped (no command)' WHERE id = ?",
)
.bind(&db_step.id)
.execute(&pool)
.await;
let _ = broadcast_tx.send(WsMessage::StepStatusUpdate {
step_id: db_step.id.clone(),
status: "done".into(),
output: "Skipped (no command)".into(),
});
continue;
}
match ssh.execute(cmd).await {
Ok(result) => {
let output = if result.stderr.is_empty() {
result.stdout.clone()
} else {
format!("{}\nSTDERR: {}", result.stdout, result.stderr)
};
let status = if result.exit_code == 0 {
"done"
} else {
"failed"
};
let _ = sqlx::query(
"UPDATE plan_steps SET status = ?, output = ? WHERE id = ?",
)
.bind(status)
.bind(&output)
.bind(&db_step.id)
.execute(&pool)
.await;
let _ = broadcast_tx.send(WsMessage::StepStatusUpdate {
step_id: db_step.id.clone(),
status: status.into(),
output,
});
if result.exit_code != 0 {
all_ok = false;
break;
}
}
Err(e) => {
let _ = sqlx::query(
"UPDATE plan_steps SET status = 'failed', output = ? WHERE id = ?",
)
.bind(e.to_string())
.bind(&db_step.id)
.execute(&pool)
.await;
let _ = broadcast_tx.send(WsMessage::StepStatusUpdate {
step_id: db_step.id.clone(),
status: "failed".into(),
output: e.to_string(),
});
all_ok = false;
break;
}
}
}
let final_status = if all_ok { "done" } else { "failed" };
let _ = sqlx::query(
"UPDATE workflows SET status = ? WHERE id = ?",
)
.bind(final_status)
.bind(&workflow_id)
.execute(&pool)
.await;
let _ = broadcast_tx.send(WsMessage::WorkflowStatusUpdate {
workflow_id: workflow_id.clone(),
status: final_status.into(),
});
}
Err(e) => {
let _ = broadcast_tx.send(WsMessage::Error {
message: format!("Replan failed: {}", e),
});
}
}
}
}
}
tracing::info!("Agent loop ended for project {}", project_id);
}
async fn generate_plan(llm: &LlmClient, requirement: &str) -> anyhow::Result<Vec<PlanStepInfo>> {
let system_prompt = r#"You are an AI workflow planner. Given a requirement, generate a list of executable steps.
Respond in JSON format only, as an array of objects:
[
{"order": 1, "description": "what this step does", "command": "shell command to execute via SSH"},
...
]
Keep the plan practical and each command should be a single shell command.
If a step doesn't need a shell command (e.g., verification), set command to empty string."#;
let response = llm.chat(vec![
ChatMessage { role: "system".into(), content: system_prompt.into() },
ChatMessage { role: "user".into(), content: requirement.into() },
]).await?;
let json_str = extract_json_array(&response);
let steps: Vec<PlanStepInfo> = serde_json::from_str(json_str)?;
Ok(steps)
}
fn extract_json_array(response: &str) -> &str {
if let Some(start) = response.find('[') {
if let Some(end) = response.rfind(']') {
return &response[start..=end];
}
}
response
}
async fn replan(
llm: &LlmClient,
requirement: &str,
current_steps: &[crate::db::PlanStep],
comment: &str,
) -> anyhow::Result<Vec<PlanStepInfo>> {
let steps_summary: String = current_steps
.iter()
.map(|s| format!(" {}. [{}] {}", s.step_order, s.status, s.description))
.collect::<Vec<_>>()
.join("\n");
let system_prompt = r#"You are an AI workflow planner. The user has provided feedback on an existing plan.
Based on the original requirement, current step statuses, and user feedback, generate ONLY the new/remaining steps that need to be executed.
Do NOT include steps that are already done.
Respond in JSON format only, as an array of objects:
[
{"order": 1, "description": "what this step does", "command": "shell command to execute via SSH"},
...
]
If no new steps are needed (feedback is just informational), return an empty array: []"#;
let user_msg = format!(
"Original requirement:\n{}\n\nCurrent steps:\n{}\n\nUser feedback:\n{}",
requirement, steps_summary, comment
);
let response = llm
.chat(vec![
ChatMessage {
role: "system".into(),
content: system_prompt.into(),
},
ChatMessage {
role: "user".into(),
content: user_msg,
},
])
.await?;
let json_str = extract_json_array(&response);
let steps: Vec<PlanStepInfo> = serde_json::from_str(json_str)?;
Ok(steps)
}

12
src/api/mod.rs Normal file
View File

@ -0,0 +1,12 @@
mod projects;
mod workflows;
use std::sync::Arc;
use axum::Router;
use crate::AppState;
pub fn router(state: Arc<AppState>) -> Router {
Router::new()
.merge(projects::router(state.clone()))
.merge(workflows::router(state))
}

118
src/api/projects.rs Normal file
View File

@ -0,0 +1,118 @@
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::Project;
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 CreateProject {
pub name: String,
#[serde(default)]
pub description: String,
}
#[derive(Deserialize)]
pub struct UpdateProject {
pub name: Option<String>,
pub description: Option<String>,
}
pub fn router(state: Arc<AppState>) -> Router {
Router::new()
.route("/projects", get(list_projects).post(create_project))
.route("/projects/{id}", get(get_project).put(update_project).delete(delete_project))
.with_state(state)
}
async fn list_projects(
State(state): State<Arc<AppState>>,
) -> ApiResult<Vec<Project>> {
sqlx::query_as::<_, Project>("SELECT * FROM projects ORDER BY updated_at DESC")
.fetch_all(&state.db.pool)
.await
.map(Json)
.map_err(db_err)
}
async fn create_project(
State(state): State<Arc<AppState>>,
Json(input): Json<CreateProject>,
) -> ApiResult<Project> {
let id = uuid::Uuid::new_v4().to_string();
sqlx::query_as::<_, Project>(
"INSERT INTO projects (id, name, description) VALUES (?, ?, ?) RETURNING *"
)
.bind(&id)
.bind(&input.name)
.bind(&input.description)
.fetch_one(&state.db.pool)
.await
.map(Json)
.map_err(db_err)
}
async fn get_project(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> ApiResult<Option<Project>> {
sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE id = ?")
.bind(&id)
.fetch_optional(&state.db.pool)
.await
.map(Json)
.map_err(db_err)
}
async fn update_project(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Json(input): Json<UpdateProject>,
) -> ApiResult<Option<Project>> {
if let Some(name) = &input.name {
sqlx::query("UPDATE projects SET name = ?, updated_at = datetime('now') WHERE id = ?")
.bind(name)
.bind(&id)
.execute(&state.db.pool)
.await
.map_err(db_err)?;
}
if let Some(desc) = &input.description {
sqlx::query("UPDATE projects SET description = ?, updated_at = datetime('now') WHERE id = ?")
.bind(desc)
.bind(&id)
.execute(&state.db.pool)
.await
.map_err(db_err)?;
}
sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE id = ?")
.bind(&id)
.fetch_optional(&state.db.pool)
.await
.map(Json)
.map_err(db_err)
}
async fn delete_project(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> ApiResult<bool> {
sqlx::query("DELETE FROM projects WHERE id = ?")
.bind(&id)
.execute(&state.db.pool)
.await
.map(|r| Json(r.rows_affected() > 0))
.map_err(db_err)
}

136
src/api/workflows.rs Normal file
View File

@ -0,0 +1,136 @@
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::agent::AgentEvent;
use crate::db::{Workflow, PlanStep, Comment};
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 CreateWorkflow {
pub requirement: String,
}
#[derive(Deserialize)]
pub struct CreateComment {
pub content: String,
}
pub fn router(state: Arc<AppState>) -> Router {
Router::new()
.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))
.with_state(state)
}
async fn list_workflows(
State(state): State<Arc<AppState>>,
Path(project_id): Path<String>,
) -> ApiResult<Vec<Workflow>> {
sqlx::query_as::<_, Workflow>(
"SELECT * FROM workflows 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_workflow(
State(state): State<Arc<AppState>>,
Path(project_id): Path<String>,
Json(input): Json<CreateWorkflow>,
) -> ApiResult<Workflow> {
let id = uuid::Uuid::new_v4().to_string();
let workflow = sqlx::query_as::<_, Workflow>(
"INSERT INTO workflows (id, project_id, requirement) VALUES (?, ?, ?) RETURNING *"
)
.bind(&id)
.bind(&project_id)
.bind(&input.requirement)
.fetch_one(&state.db.pool)
.await
.map_err(db_err)?;
state.agent_mgr.send_event(&project_id, AgentEvent::NewRequirement {
workflow_id: workflow.id.clone(),
requirement: workflow.requirement.clone(),
}).await;
Ok(Json(workflow))
}
async fn list_steps(
State(state): State<Arc<AppState>>,
Path(workflow_id): Path<String>,
) -> ApiResult<Vec<PlanStep>> {
sqlx::query_as::<_, PlanStep>(
"SELECT * FROM plan_steps WHERE workflow_id = ? ORDER BY step_order"
)
.bind(&workflow_id)
.fetch_all(&state.db.pool)
.await
.map(Json)
.map_err(db_err)
}
async fn list_comments(
State(state): State<Arc<AppState>>,
Path(workflow_id): Path<String>,
) -> ApiResult<Vec<Comment>> {
sqlx::query_as::<_, Comment>(
"SELECT * FROM comments WHERE workflow_id = ? ORDER BY created_at"
)
.bind(&workflow_id)
.fetch_all(&state.db.pool)
.await
.map(Json)
.map_err(db_err)
}
async fn create_comment(
State(state): State<Arc<AppState>>,
Path(workflow_id): Path<String>,
Json(input): Json<CreateComment>,
) -> ApiResult<Comment> {
let id = uuid::Uuid::new_v4().to_string();
let comment = sqlx::query_as::<_, Comment>(
"INSERT INTO comments (id, workflow_id, content) VALUES (?, ?, ?) RETURNING *"
)
.bind(&id)
.bind(&workflow_id)
.bind(&input.content)
.fetch_one(&state.db.pool)
.await
.map_err(db_err)?;
// Notify agent about the comment
if let Ok(Some(wf)) = sqlx::query_as::<_, Workflow>(
"SELECT * FROM workflows WHERE id = ?"
)
.bind(&workflow_id)
.fetch_optional(&state.db.pool)
.await
{
state.agent_mgr.send_event(&wf.project_id, AgentEvent::Comment {
workflow_id: workflow_id.clone(),
content: input.content,
}).await;
}
Ok(Json(comment))
}

106
src/db.rs Normal file
View File

@ -0,0 +1,106 @@
use serde::{Deserialize, Serialize};
use sqlx::sqlite::{SqlitePool, SqlitePoolOptions};
#[derive(Clone)]
pub struct Database {
pub pool: SqlitePool,
}
impl Database {
pub async fn new(path: &str) -> anyhow::Result<Self> {
let url = format!("sqlite:{}?mode=rwc", path);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect(&url)
.await?;
Ok(Self { pool })
}
pub async fn migrate(&self) -> anyhow::Result<()> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS workflows (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
requirement TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS plan_steps (
id TEXT PRIMARY KEY,
workflow_id TEXT NOT NULL REFERENCES workflows(id),
step_order INTEGER NOT NULL,
description TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
output TEXT NOT NULL DEFAULT ''
)"
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS comments (
id TEXT PRIMARY KEY,
workflow_id TEXT NOT NULL REFERENCES workflows(id),
content TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
)
.execute(&self.pool)
.await?;
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Project {
pub id: String,
pub name: String,
pub description: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Workflow {
pub id: String,
pub project_id: String,
pub requirement: String,
pub status: String,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct PlanStep {
pub id: String,
pub workflow_id: String,
pub step_order: i32,
pub description: String,
pub status: String,
pub output: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Comment {
pub id: String,
pub workflow_id: String,
pub content: String,
pub created_at: String,
}

56
src/llm.rs Normal file
View File

@ -0,0 +1,56 @@
use serde::{Deserialize, Serialize};
use crate::LlmConfig;
pub struct LlmClient {
client: reqwest::Client,
config: LlmConfig,
}
#[derive(Debug, Serialize)]
struct ChatRequest {
model: String,
messages: Vec<ChatMessage>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: String,
pub content: String,
}
#[derive(Debug, Deserialize)]
struct ChatResponse {
choices: Vec<Choice>,
}
#[derive(Debug, Deserialize)]
struct Choice {
message: ChatMessage,
}
impl LlmClient {
pub fn new(config: &LlmConfig) -> Self {
Self {
client: reqwest::Client::new(),
config: config.clone(),
}
}
pub async fn chat(&self, messages: Vec<ChatMessage>) -> anyhow::Result<String> {
let resp = self.client
.post(format!("{}/chat/completions", self.config.base_url))
.header("Authorization", format!("Bearer {}", self.config.api_key))
.json(&ChatRequest {
model: self.config.model.clone(),
messages,
})
.send()
.await?
.json::<ChatResponse>()
.await?;
Ok(resp.choices.first()
.map(|c| c.message.content.clone())
.unwrap_or_default())
}
}

90
src/main.rs Normal file
View File

@ -0,0 +1,90 @@
mod api;
mod agent;
mod db;
mod llm;
mod ssh;
mod ws;
use std::sync::Arc;
use axum::Router;
use tower_http::cors::CorsLayer;
use tower_http::services::ServeDir;
pub struct AppState {
pub db: db::Database,
pub config: Config,
pub agent_mgr: Arc<agent::AgentManager>,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct Config {
pub llm: LlmConfig,
pub ssh: SshConfig,
pub server: ServerConfig,
pub database: DatabaseConfig,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct LlmConfig {
pub base_url: String,
pub api_key: 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)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct DatabaseConfig {
pub path: String,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter("tori=debug,tower_http=debug")
.init();
let config_str = std::fs::read_to_string("config.yaml")
.expect("Failed to read config.yaml");
let config: Config = serde_yaml::from_str(&config_str)
.expect("Failed to parse config.yaml");
let database = db::Database::new(&config.database.path).await?;
database.migrate().await?;
let agent_mgr = agent::AgentManager::new(
database.pool.clone(),
config.llm.clone(),
config.ssh.clone(),
);
let state = Arc::new(AppState {
db: database,
config: config.clone(),
agent_mgr: agent_mgr.clone(),
});
let app = Router::new()
.nest("/api", api::router(state))
.nest("/ws", ws::router(agent_mgr))
.fallback_service(ServeDir::new("web/dist"))
.layer(CorsLayer::permissive());
let addr = format!("{}:{}", config.server.host, config.server.port);
tracing::info!("Tori server listening on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await?;
axum::serve(listener, app).await?;
Ok(())
}

36
src/ssh.rs Normal file
View File

@ -0,0 +1,36 @@
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,
}

60
src/ws.rs Normal file
View File

@ -0,0 +1,60 @@
use std::sync::Arc;
use axum::{
extract::{Path, State, WebSocketUpgrade, ws::{Message, WebSocket}},
response::Response,
routing::get,
Router,
};
use futures::{SinkExt, StreamExt};
use crate::agent::{AgentEvent, AgentManager};
pub fn router(agent_mgr: Arc<AgentManager>) -> Router {
Router::new()
.route("/{project_id}", get(ws_handler))
.with_state(agent_mgr)
}
async fn ws_handler(
ws: WebSocketUpgrade,
State(agent_mgr): State<Arc<AgentManager>>,
Path(project_id): Path<String>,
) -> Response {
ws.on_upgrade(move |socket| handle_socket(socket, agent_mgr, project_id))
}
async fn handle_socket(socket: WebSocket, agent_mgr: Arc<AgentManager>, project_id: String) {
let (mut sender, mut receiver) = socket.split();
let mut broadcast_rx = agent_mgr.get_broadcast(&project_id).await;
let send_task = tokio::spawn(async move {
while let Ok(msg) = broadcast_rx.recv().await {
if let Ok(json) = serde_json::to_string(&msg) {
if sender.send(Message::Text(json.into())).await.is_err() {
break;
}
}
}
});
let mgr = agent_mgr.clone();
let pid = project_id.clone();
let recv_task = tokio::spawn(async move {
while let Some(Ok(msg)) = receiver.next().await {
match msg {
Message::Text(text) => {
if let Ok(event) = serde_json::from_str::<AgentEvent>(&text) {
mgr.send_event(&pid, event).await;
}
}
Message::Close(_) => break,
_ => {}
}
}
});
tokio::select! {
_ = send_task => {},
_ = recv_task => {},
}
}

24
web/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

5
web/README.md Normal file
View File

@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

13
web/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1381
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
web/package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.25"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.2",
"@vue/tsconfig": "^0.8.1",
"typescript": "~5.9.3",
"vite": "^7.3.1",
"vue-tsc": "^3.1.5"
}
}

1
web/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

7
web/src/App.vue Normal file
View File

@ -0,0 +1,7 @@
<script setup lang="ts">
import AppLayout from './components/AppLayout.vue'
</script>
<template>
<AppLayout />
</template>

57
web/src/api.ts Normal file
View File

@ -0,0 +1,57 @@
import type { Project, Workflow, PlanStep, Comment } from './types'
const BASE = '/api'
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
headers: { 'Content-Type': 'application/json' },
...options,
})
if (!res.ok) {
const text = await res.text()
throw new Error(`API error ${res.status}: ${text}`)
}
return res.json()
}
export const api = {
listProjects: () => request<Project[]>('/projects'),
createProject: (name: string, description = '') =>
request<Project>('/projects', {
method: 'POST',
body: JSON.stringify({ name, description }),
}),
getProject: (id: string) => request<Project | null>(`/projects/${id}`),
updateProject: (id: string, data: { name?: string; description?: string }) =>
request<Project | null>(`/projects/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
deleteProject: (id: string) =>
request<boolean>(`/projects/${id}`, { method: 'DELETE' }),
listWorkflows: (projectId: string) =>
request<Workflow[]>(`/projects/${projectId}/workflows`),
createWorkflow: (projectId: string, requirement: string) =>
request<Workflow>(`/projects/${projectId}/workflows`, {
method: 'POST',
body: JSON.stringify({ requirement }),
}),
listSteps: (workflowId: string) =>
request<PlanStep[]>(`/workflows/${workflowId}/steps`),
listComments: (workflowId: string) =>
request<Comment[]>(`/workflows/${workflowId}/comments`),
createComment: (workflowId: string, content: string) =>
request<Comment>(`/workflows/${workflowId}/comments`, {
method: 'POST',
body: JSON.stringify({ content }),
}),
}

1
web/src/assets/vue.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@ -0,0 +1,88 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import Sidebar from './Sidebar.vue'
import WorkflowView from './WorkflowView.vue'
import { api } from '../api'
import type { Project } from '../types'
const projects = ref<Project[]>([])
const selectedProjectId = ref('')
const error = ref('')
onMounted(async () => {
try {
projects.value = await api.listProjects()
const first = projects.value[0]
if (first) {
selectedProjectId.value = first.id
}
} catch (e: any) {
error.value = e.message
}
})
function onSelectProject(id: string) {
selectedProjectId.value = id
}
async function onCreateProject() {
const name = prompt('项目名称')
if (!name) return
try {
const project = await api.createProject(name)
projects.value.unshift(project)
selectedProjectId.value = project.id
} catch (e: any) {
error.value = e.message
}
}
</script>
<template>
<div class="app-layout">
<Sidebar
:projects="projects"
:selectedId="selectedProjectId"
@select="onSelectProject"
@create="onCreateProject"
/>
<main class="main-content">
<div v-if="error" class="error-banner">{{ error }}</div>
<div v-if="!selectedProjectId" class="empty-state">
选择或创建一个项目开始
</div>
<WorkflowView v-else :projectId="selectedProjectId" :key="selectedProjectId" />
</main>
</div>
</template>
<style scoped>
.app-layout {
display: flex;
height: 100vh;
overflow: hidden;
}
.main-content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
font-size: 16px;
}
.error-banner {
background: var(--error);
color: #fff;
padding: 8px 16px;
font-size: 13px;
}
</style>

View File

@ -0,0 +1,111 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { Comment } from '../types'
defineProps<{
comments: Comment[]
disabled?: boolean
}>()
const emit = defineEmits<{
submit: [text: string]
}>()
const input = ref('')
function submit() {
const text = input.value.trim()
if (!text) return
emit('submit', text)
input.value = ''
}
</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"
/>
<button class="btn-send" :disabled="disabled" @click="submit">发送</button>
</div>
</div>
</template>
<style scoped>
.comment-section {
background: var(--bg-card);
border-radius: 8px;
border: 1px solid var(--border);
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;
padding: 8px 12px;
align-items: flex-end;
}
.comment-input textarea {
flex: 1;
padding: 8px 10px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
resize: none;
min-height: 100px;
max-height: 200px;
}
.comment-input textarea:focus {
outline: none;
border-color: var(--accent);
}
.btn-send {
background: var(--accent);
color: var(--bg-primary);
font-weight: 600;
padding: 8px 20px;
height: fit-content;
}
.btn-send:hover {
background: var(--accent-hover);
}
</style>

View File

@ -0,0 +1,152 @@
<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 statusLabel(status: string) {
switch (status) {
case 'done': return '完成'
case 'running': return '执行中'
case 'failed': return '失败'
default: return '等待'
}
}
</script>
<template>
<div class="execution-section">
<div class="section-header">
<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>
</div>
<div v-if="expandedSteps.has(step.id) && step.output" class="exec-output">
<pre>{{ step.output }}</pre>
</div>
</div>
<div v-if="!steps.length" class="empty-state">
计划生成后执行进度将显示在这里
</div>
</div>
</div>
</template>
<style scoped>
.execution-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 {
margin-bottom: 12px;
}
.section-header h2 {
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.exec-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.exec-item {
border-radius: 6px;
overflow: hidden;
background: var(--bg-secondary);
}
.exec-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
cursor: pointer;
font-size: 13px;
user-select: none;
}
.exec-header:hover {
background: rgba(255, 255, 255, 0.03);
}
.exec-toggle {
color: var(--text-secondary);
font-size: 11px;
width: 14px;
flex-shrink: 0;
}
.exec-order {
color: var(--text-primary);
font-weight: 500;
flex: 1;
}
.exec-status {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 500;
}
.exec-status.done { background: var(--success); color: #000; }
.exec-status.running { background: var(--accent); color: #000; }
.exec-status.failed { background: var(--error); color: #fff; }
.exec-status.pending { background: var(--pending); color: #fff; }
.exec-output {
padding: 8px 12px;
border-top: 1px solid var(--border);
background: rgba(0, 0, 0, 0.2);
}
.exec-output pre {
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;
}
.empty-state {
color: var(--text-secondary);
font-size: 13px;
text-align: center;
padding: 24px;
}
</style>

View File

@ -0,0 +1,112 @@
<script setup lang="ts">
import type { PlanStep } from '../types'
defineProps<{
steps: PlanStep[]
}>()
function statusIcon(status: string) {
switch (status) {
case 'done': return '✓'
case 'running': return '⟳'
case 'failed': return '✗'
default: return '○'
}
}
</script>
<template>
<div class="plan-section">
<div class="section-header">
<h2>Plan</h2>
</div>
<div class="steps-list">
<div
v-for="step in steps"
:key="step.id"
class="step-item"
:class="step.status"
>
<span class="step-icon">{{ statusIcon(step.status) }}</span>
<span class="step-order">{{ step.step_order }}.</span>
<span class="step-desc">{{ step.description }}</span>
</div>
<div v-if="!steps.length" class="empty-state">
提交需求后AI 将在这里生成计划
</div>
</div>
</div>
</template>
<style scoped>
.plan-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 {
margin-bottom: 12px;
}
.section-header h2 {
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.steps-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.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);
}
.step-item.done { border-left: 3px solid var(--success); }
.step-item.running { border-left: 3px solid var(--accent); background: rgba(79, 195, 247, 0.08); }
.step-item.failed { border-left: 3px solid var(--error); }
.step-item.pending { border-left: 3px solid var(--pending); opacity: 0.7; }
.step-icon {
font-size: 14px;
flex-shrink: 0;
width: 18px;
text-align: center;
}
.step-item.done .step-icon { color: var(--success); }
.step-item.running .step-icon { color: var(--accent); }
.step-item.failed .step-icon { color: var(--error); }
.step-order {
color: var(--text-secondary);
flex-shrink: 0;
}
.step-desc {
color: var(--text-primary);
}
.empty-state {
color: var(--text-secondary);
font-size: 13px;
text-align: center;
padding: 24px;
}
</style>

View File

@ -0,0 +1,120 @@
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{
requirement: string
status: string
}>()
const emit = defineEmits<{
submit: [text: string]
}>()
const input = ref('')
const editing = ref(!props.requirement)
function submit() {
const text = input.value.trim()
if (!text) return
emit('submit', text)
editing.value = false
}
</script>
<template>
<div class="requirement-section">
<div class="section-header">
<h2>需求</h2>
<span v-if="status !== 'pending'" class="status-badge" :class="status">
{{ status }}
</span>
</div>
<div v-if="!editing && requirement" class="requirement-display" @dblclick="editing = true">
{{ requirement }}
</div>
<div v-else class="requirement-input">
<textarea
v-model="input"
placeholder="描述你的需求..."
rows="3"
@keydown.ctrl.enter="submit"
/>
<button class="btn-submit" @click="submit">提交需求</button>
</div>
</div>
</template>
<style scoped>
.requirement-section {
background: var(--bg-card);
border-radius: 8px;
padding: 12px 16px;
border: 1px solid var(--border);
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.section-header h2 {
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 500;
}
.status-badge.planning { background: var(--warning); color: #000; }
.status-badge.executing { background: var(--accent); color: #000; }
.status-badge.done { background: var(--success); color: #000; }
.status-badge.failed { background: var(--error); color: #fff; }
.requirement-display {
font-size: 14px;
line-height: 1.6;
color: var(--text-primary);
cursor: pointer;
}
.requirement-input {
display: flex;
flex-direction: column;
gap: 8px;
}
.requirement-input textarea {
width: 100%;
padding: 10px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
resize: vertical;
}
.requirement-input textarea:focus {
outline: none;
border-color: var(--accent);
}
.btn-submit {
align-self: flex-end;
background: var(--accent);
color: var(--bg-primary);
font-weight: 600;
}
.btn-submit:hover {
background: var(--accent-hover);
}
</style>

View File

@ -0,0 +1,108 @@
<script setup lang="ts">
import type { Project } from '../types'
defineProps<{
projects: Project[]
selectedId: string
}>()
const emit = defineEmits<{
select: [id: string]
create: []
}>()
</script>
<template>
<aside class="sidebar">
<div class="sidebar-header">
<h1 class="logo">Tori</h1>
<button class="btn-new" @click="emit('create')">+ 新项目</button>
</div>
<nav class="project-list">
<div
v-for="project in projects"
:key="project.id"
class="project-item"
:class="{ active: project.id === selectedId }"
@click="emit('select', project.id)"
>
<span class="project-name">{{ project.name }}</span>
<span class="project-time">{{ new Date(project.updated_at).toLocaleDateString() }}</span>
</div>
</nav>
</aside>
</template>
<style scoped>
.sidebar {
width: var(--sidebar-width);
min-width: var(--sidebar-width);
background: var(--bg-secondary);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
padding: 16px;
border-bottom: 1px solid var(--border);
}
.logo {
font-size: 20px;
font-weight: 700;
color: var(--accent);
margin-bottom: 12px;
}
.btn-new {
width: 100%;
padding: 8px;
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px dashed var(--border);
font-size: 13px;
}
.btn-new:hover {
background: var(--accent);
color: var(--bg-primary);
border-style: solid;
}
.project-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.project-item {
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 2px;
}
.project-item:hover {
background: var(--bg-tertiary);
}
.project-item.active {
background: var(--bg-tertiary);
border-left: 3px solid var(--accent);
}
.project-name {
font-size: 14px;
font-weight: 500;
}
.project-time {
font-size: 11px;
color: var(--text-secondary);
}
</style>

View File

@ -0,0 +1,165 @@
<script setup lang="ts">
import { ref, 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 { api } from '../api'
import { connectWs } from '../ws'
import type { Workflow, PlanStep, Comment } from '../types'
import type { WsMessage } from '../ws'
const props = defineProps<{
projectId: string
}>()
const workflow = ref<Workflow | null>(null)
const steps = ref<PlanStep[]>([])
const comments = ref<Comment[]>([])
const error = ref('')
let wsConn: { close: () => void } | null = null
async function loadData() {
try {
const workflows = await api.listWorkflows(props.projectId)
const latest = workflows[0]
if (latest) {
workflow.value = latest
const [s, c] = await Promise.all([
api.listSteps(latest.id),
api.listComments(latest.id),
])
steps.value = s
comments.value = c
} else {
workflow.value = null
steps.value = []
comments.value = []
}
} catch (e: any) {
error.value = e.message
}
}
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
case 'StepStatusUpdate': {
const idx = steps.value.findIndex(s => s.id === msg.step_id)
const existing = steps.value[idx]
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 })
}
}
break
}
case 'WorkflowStatusUpdate':
if (workflow.value && msg.workflow_id === workflow.value.id) {
workflow.value = { ...workflow.value, status: msg.status as any }
}
break
case 'Error':
error.value = msg.message
break
}
}
function setupWs() {
wsConn?.close()
wsConn = connectWs(props.projectId, handleWsMessage)
}
onMounted(() => {
loadData()
setupWs()
})
onUnmounted(() => {
wsConn?.close()
})
watch(() => props.projectId, () => {
loadData()
setupWs()
})
async function onSubmitRequirement(text: string) {
try {
const wf = await api.createWorkflow(props.projectId, text)
workflow.value = wf
steps.value = []
comments.value = []
} catch (e: any) {
error.value = e.message
}
}
async function onSubmitComment(text: string) {
if (!workflow.value) return
try {
const comment = await api.createComment(workflow.value.id, text)
comments.value.push(comment)
} catch (e: any) {
error.value = e.message
}
}
</script>
<template>
<div class="workflow-view">
<div v-if="error" class="error-msg" @click="error = ''">{{ error }}</div>
<RequirementSection
:requirement="workflow?.requirement || ''"
:status="workflow?.status || 'pending'"
@submit="onSubmitRequirement"
/>
<div class="plan-exec-row">
<PlanSection :steps="steps" />
<ExecutionSection :steps="steps" />
</div>
<CommentSection
:comments="comments"
:disabled="!workflow"
@submit="onSubmitComment"
/>
</div>
</template>
<style scoped>
.workflow-view {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 16px;
gap: 12px;
}
.plan-exec-row {
flex: 1;
display: flex;
gap: 12px;
overflow: hidden;
min-height: 0;
}
.error-msg {
background: rgba(239, 83, 80, 0.15);
border: 1px solid var(--error);
color: var(--error);
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
</style>

5
web/src/main.ts Normal file
View File

@ -0,0 +1,5 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')

44
web/src/style.css Normal file
View File

@ -0,0 +1,44 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
: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;
}
html, body, #app {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
}
button {
cursor: pointer;
border: none;
border-radius: 6px;
padding: 8px 16px;
font-size: 14px;
font-family: inherit;
transition: background 0.2s;
}
textarea {
font-family: inherit;
font-size: 14px;
}

31
web/src/types.ts Normal file
View File

@ -0,0 +1,31 @@
export interface Project {
id: string
name: string
description: string
created_at: string
updated_at: string
}
export interface Workflow {
id: string
project_id: string
requirement: string
status: 'pending' | 'planning' | 'executing' | 'done' | 'failed'
created_at: string
}
export interface PlanStep {
id: string
workflow_id: string
step_order: number
description: string
status: 'pending' | 'running' | 'done' | 'failed'
output: string
}
export interface Comment {
id: string
workflow_id: string
content: string
created_at: string
}

69
web/src/ws.ts Normal file
View File

@ -0,0 +1,69 @@
export interface WsPlanUpdate {
type: 'PlanUpdate'
workflow_id: string
steps: { order: number; description: string; command: string }[]
}
export interface WsStepStatusUpdate {
type: 'StepStatusUpdate'
step_id: string
status: string
output: string
}
export interface WsWorkflowStatusUpdate {
type: 'WorkflowStatusUpdate'
workflow_id: string
status: string
}
export interface WsError {
type: 'Error'
message: string
}
export type WsMessage = WsPlanUpdate | WsStepStatusUpdate | WsWorkflowStatusUpdate | WsError
export type WsHandler = (msg: WsMessage) => void
export function connectWs(projectId: string, onMessage: WsHandler): { close: () => void } {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
const url = `${proto}//${location.host}/ws/${projectId}`
let ws: WebSocket | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let closed = false
function connect() {
if (closed) return
ws = new WebSocket(url)
ws.onmessage = (e) => {
try {
const msg: WsMessage = JSON.parse(e.data)
onMessage(msg)
} catch {
// ignore malformed messages
}
}
ws.onclose = () => {
if (!closed) {
reconnectTimer = setTimeout(connect, 2000)
}
}
ws.onerror = () => {
ws?.close()
}
}
connect()
return {
close() {
closed = true
if (reconnectTimer) clearTimeout(reconnectTimer)
ws?.close()
},
}
}

16
web/tsconfig.app.json Normal file
View File

@ -0,0 +1,16 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

7
web/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
web/tsconfig.node.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

15
web/vite.config.ts Normal file
View File

@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': 'http://localhost:3000',
'/ws': {
target: 'ws://localhost:3000',
ws: true,
},
},
},
})