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:
parent
1122ab27dd
commit
7edbbee471
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
3163
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
Cargo.toml
Normal file
26
Cargo.toml
Normal 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
25
Dockerfile
Normal 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
41
Makefile
Normal 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
104
README.md
@ -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 兼容 API(Requesty.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
16
config.yaml.example
Normal 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
44
deploy/deployment.yaml
Normal 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
85
doc/design.md
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
# Tori — 产品设计文档
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
Tori 是一个 AI agent 驱动的工作流管理器。类似 ChatGPT 的布局,但管理单元是项目/工作流。
|
||||||
|
用户描述需求,AI 生成计划,agent 执行,用户随时通过 comment 提供反馈。
|
||||||
|
|
||||||
|
## UI 布局
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────┬─────────────────────────────────────────────┐
|
||||||
|
│ │ ① 需求区(输入/显示) │
|
||||||
|
│ 项目列表 ├──────────────────────┬──────────────────────┤
|
||||||
|
│ │ ② Plan(左) │ ③ 执行(右) │
|
||||||
|
│ > proj-A │ AI 分析 + 步骤列表 │ 步骤状态 + 可折叠日志 │
|
||||||
|
│ proj-B │ │ │
|
||||||
|
│ ├──────────────────────┴──────────────────────┤
|
||||||
|
│ │ ④ Comment(5-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 → Client:plan 更新、执行日志、状态变化
|
||||||
|
- 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
5
doc/todo.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# Tori 开发 TODO
|
||||||
|
|
||||||
|
所有初始 TODO 已完成。待笨笨指示的事项:
|
||||||
|
|
||||||
|
- [ ] ARM 部署方式(等笨笨说怎么搞)
|
||||||
520
src/agent.rs
Normal file
520
src/agent.rs
Normal 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, ¤t_steps, &content).await;
|
||||||
|
|
||||||
|
match replan_result {
|
||||||
|
Ok(new_steps) => {
|
||||||
|
// Clear old pending steps, keep done ones
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"DELETE FROM plan_steps WHERE workflow_id = ? AND status IN ('pending', 'failed')",
|
||||||
|
)
|
||||||
|
.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
12
src/api/mod.rs
Normal 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
118
src/api/projects.rs
Normal 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
136
src/api/workflows.rs
Normal 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
106
src/db.rs
Normal 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
56
src/llm.rs
Normal 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
90
src/main.rs
Normal 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
36
src/ssh.rs
Normal 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
60
src/ws.rs
Normal 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
24
web/.gitignore
vendored
Normal 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
5
web/README.md
Normal 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
13
web/index.html
Normal 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
1381
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
web/package.json
Normal file
22
web/package.json
Normal 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
1
web/public/vite.svg
Normal 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
7
web/src/App.vue
Normal 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
57
web/src/api.ts
Normal 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
1
web/src/assets/vue.svg
Normal 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 |
88
web/src/components/AppLayout.vue
Normal file
88
web/src/components/AppLayout.vue
Normal 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>
|
||||||
111
web/src/components/CommentSection.vue
Normal file
111
web/src/components/CommentSection.vue
Normal 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>
|
||||||
152
web/src/components/ExecutionSection.vue
Normal file
152
web/src/components/ExecutionSection.vue
Normal 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>
|
||||||
112
web/src/components/PlanSection.vue
Normal file
112
web/src/components/PlanSection.vue
Normal 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>
|
||||||
120
web/src/components/RequirementSection.vue
Normal file
120
web/src/components/RequirementSection.vue
Normal 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>
|
||||||
108
web/src/components/Sidebar.vue
Normal file
108
web/src/components/Sidebar.vue
Normal 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>
|
||||||
165
web/src/components/WorkflowView.vue
Normal file
165
web/src/components/WorkflowView.vue
Normal 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
5
web/src/main.ts
Normal 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
44
web/src/style.css
Normal 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
31
web/src/types.ts
Normal 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
69
web/src/ws.ts
Normal 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
16
web/tsconfig.app.json
Normal 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
7
web/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
web/tsconfig.node.json
Normal file
26
web/tsconfig.node.json
Normal 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
15
web/vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user