feat: add Google OAuth, remote worker system, and file browser
- Google OAuth login with JWT session cookies, per-user project isolation - Remote worker registration via WebSocket, execute_on_worker/list_workers agent tools - File browser UI in workflow view, file upload/download API - Deploy script switched to local build, added tori.euphon.cloud ingress
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -20,5 +20,8 @@ web/dist/
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
|
||||||
|
# Vue language server output
|
||||||
|
*.vue.js
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
64
Cargo.lock
generated
64
Cargo.lock
generated
@@ -73,6 +73,7 @@ dependencies = [
|
|||||||
"matchit",
|
"matchit",
|
||||||
"memchr",
|
"memchr",
|
||||||
"mime",
|
"mime",
|
||||||
|
"multer",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
@@ -108,6 +109,29 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum-extra"
|
||||||
|
version = "0.10.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96"
|
||||||
|
dependencies = [
|
||||||
|
"axum",
|
||||||
|
"axum-core",
|
||||||
|
"bytes",
|
||||||
|
"cookie",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"mime",
|
||||||
|
"pin-project-lite",
|
||||||
|
"rustversion",
|
||||||
|
"serde_core",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
@@ -207,6 +231,17 @@ version = "0.9.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cookie"
|
||||||
|
version = "0.18.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
||||||
|
dependencies = [
|
||||||
|
"percent-encoding",
|
||||||
|
"time",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation-sys"
|
name = "core-foundation-sys"
|
||||||
version = "0.8.7"
|
version = "0.8.7"
|
||||||
@@ -326,6 +361,15 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "encoding_rs"
|
||||||
|
version = "0.8.35"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -1057,6 +1101,23 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "multer"
|
||||||
|
version = "3.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"encoding_rs",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"httparse",
|
||||||
|
"memchr",
|
||||||
|
"mime",
|
||||||
|
"spin",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nix"
|
name = "nix"
|
||||||
version = "0.29.0"
|
version = "0.29.0"
|
||||||
@@ -2204,6 +2265,8 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
"axum-extra",
|
||||||
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
"futures",
|
"futures",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
@@ -2215,6 +2278,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ warnings = "deny"
|
|||||||
all = "deny"
|
all = "deny"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = { version = "0.8", features = ["ws"] }
|
axum = { version = "0.8", features = ["ws", "multipart"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
@@ -29,3 +29,6 @@ tokio-util = { version = "0.7", features = ["io"] }
|
|||||||
nix = { version = "0.29", features = ["signal"] }
|
nix = { version = "0.29", features = ["signal"] }
|
||||||
pulldown-cmark = "0.12"
|
pulldown-cmark = "0.12"
|
||||||
jsonwebtoken = "9"
|
jsonwebtoken = "9"
|
||||||
|
axum-extra = { version = "0.10", features = ["cookie"] }
|
||||||
|
base64 = "0.22"
|
||||||
|
time = "0.3"
|
||||||
|
|||||||
11
Makefile
11
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: dev dev-backend dev-frontend build build-backend build-frontend clean deploy clippy lint docker-build
|
.PHONY: dev dev-backend dev-frontend build build-backend build-frontend clean deploy clippy lint docker-build deploy-worker-i7
|
||||||
|
|
||||||
# 开发模式:同时启动前后端
|
# 开发模式:同时启动前后端
|
||||||
dev:
|
dev:
|
||||||
@@ -36,6 +36,15 @@ clippy:
|
|||||||
|
|
||||||
lint: clippy
|
lint: clippy
|
||||||
|
|
||||||
|
# Worker 部署
|
||||||
|
deploy-worker-i7:
|
||||||
|
@echo "==> Deploying tori-worker to i7..."
|
||||||
|
ssh i7 "mkdir -p ~/tori-worker ~/.config/systemd/user"
|
||||||
|
scp worker/tori-worker.py i7:~/tori-worker/tori-worker.py
|
||||||
|
scp worker/tori-worker.service i7:~/.config/systemd/user/tori-worker.service
|
||||||
|
ssh i7 "chmod +x ~/tori-worker/tori-worker.py && systemctl --user daemon-reload && systemctl --user enable --now tori-worker"
|
||||||
|
@echo "==> Done! Check status: ssh i7 'systemctl --user status tori-worker'"
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
cargo clean
|
cargo clean
|
||||||
rm -rf web/dist web/node_modules
|
rm -rf web/dist web/node_modules
|
||||||
|
|||||||
@@ -31,6 +31,23 @@ spec:
|
|||||||
env:
|
env:
|
||||||
- name: RUST_LOG
|
- name: RUST_LOG
|
||||||
value: "info"
|
value: "info"
|
||||||
|
- name: GOOGLE_CLIENT_ID
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: tori-auth
|
||||||
|
key: google-client-id
|
||||||
|
- name: GOOGLE_CLIENT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: tori-auth
|
||||||
|
key: google-client-secret
|
||||||
|
- name: JWT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: tori-auth
|
||||||
|
key: jwt-secret
|
||||||
|
- name: PUBLIC_URL
|
||||||
|
value: "https://tori.euphon.cloud"
|
||||||
volumes:
|
volumes:
|
||||||
- name: data
|
- name: data
|
||||||
hostPath:
|
hostPath:
|
||||||
@@ -70,6 +87,17 @@ spec:
|
|||||||
name: tori
|
name: tori
|
||||||
port:
|
port:
|
||||||
number: 80
|
number: 80
|
||||||
|
- host: tori.euphon.cloud
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: tori
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
tls:
|
tls:
|
||||||
- hosts:
|
- hosts:
|
||||||
- tori.oci.euphon.net
|
- tori.oci.euphon.net
|
||||||
|
- tori.euphon.cloud
|
||||||
|
|||||||
@@ -1,25 +1,23 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Deploy tori to OCI k3s cluster
|
# Deploy tori to k3s cluster (local kubectl)
|
||||||
# Run from local machine: scripts/deploy.sh
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
OCI_HOST="oci"
|
|
||||||
OCI_DIR="~/src/tori"
|
|
||||||
IMAGE="registry.oci.euphon.net/tori:latest"
|
IMAGE="registry.oci.euphon.net/tori:latest"
|
||||||
|
|
||||||
echo "==> Syncing project to OCI..."
|
echo "==> Building Rust binary..."
|
||||||
rsync -az --exclude target --exclude node_modules --exclude .git --exclude web/dist . "${OCI_HOST}:${OCI_DIR}/"
|
cargo build --release --target aarch64-unknown-linux-musl
|
||||||
|
|
||||||
echo "==> Building Rust binary on OCI..."
|
echo "==> Building Docker image..."
|
||||||
ssh "$OCI_HOST" "source ~/.cargo/env && cd $OCI_DIR && \
|
docker build -t "$IMAGE" .
|
||||||
cargo build --release --target aarch64-unknown-linux-musl"
|
|
||||||
|
|
||||||
echo "==> Building and deploying on OCI..."
|
echo "==> Pushing image..."
|
||||||
ssh "$OCI_HOST" "cd $OCI_DIR && \
|
docker push "$IMAGE"
|
||||||
docker build -t $IMAGE . && \
|
|
||||||
docker push $IMAGE && \
|
echo "==> Applying manifests..."
|
||||||
kubectl apply -f deploy/ && \
|
kubectl apply -f deploy/
|
||||||
kubectl rollout restart deployment/tori -n tori && \
|
|
||||||
kubectl rollout status deployment/tori -n tori"
|
echo "==> Rolling out..."
|
||||||
|
kubectl rollout restart deployment/tori -n tori
|
||||||
|
kubectl rollout status deployment/tori -n tori
|
||||||
|
|
||||||
echo "==> Done!"
|
echo "==> Done!"
|
||||||
|
|||||||
63
src/agent.rs
63
src/agent.rs
@@ -10,6 +10,7 @@ use crate::llm::{LlmClient, ChatMessage, Tool, ToolFunction};
|
|||||||
use crate::exec::LocalExecutor;
|
use crate::exec::LocalExecutor;
|
||||||
use crate::template::{self, LoadedTemplate};
|
use crate::template::{self, LoadedTemplate};
|
||||||
use crate::tools::ExternalToolManager;
|
use crate::tools::ExternalToolManager;
|
||||||
|
use crate::worker::WorkerManager;
|
||||||
use crate::LlmConfig;
|
use crate::LlmConfig;
|
||||||
|
|
||||||
use crate::state::{AgentState, AgentPhase, Artifact, Step, StepStatus, StepResult, StepResultStatus, check_scratchpad_size};
|
use crate::state::{AgentState, AgentPhase, Artifact, Step, StepStatus, StepResult, StepResultStatus, check_scratchpad_size};
|
||||||
@@ -80,6 +81,7 @@ pub struct AgentManager {
|
|||||||
template_repo: Option<crate::TemplateRepoConfig>,
|
template_repo: Option<crate::TemplateRepoConfig>,
|
||||||
kb: Option<Arc<crate::kb::KbManager>>,
|
kb: Option<Arc<crate::kb::KbManager>>,
|
||||||
jwt_private_key_path: Option<String>,
|
jwt_private_key_path: Option<String>,
|
||||||
|
pub worker_mgr: Arc<WorkerManager>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AgentManager {
|
impl AgentManager {
|
||||||
@@ -89,6 +91,7 @@ impl AgentManager {
|
|||||||
template_repo: Option<crate::TemplateRepoConfig>,
|
template_repo: Option<crate::TemplateRepoConfig>,
|
||||||
kb: Option<Arc<crate::kb::KbManager>>,
|
kb: Option<Arc<crate::kb::KbManager>>,
|
||||||
jwt_private_key_path: Option<String>,
|
jwt_private_key_path: Option<String>,
|
||||||
|
worker_mgr: Arc<WorkerManager>,
|
||||||
) -> Arc<Self> {
|
) -> Arc<Self> {
|
||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
agents: RwLock::new(HashMap::new()),
|
agents: RwLock::new(HashMap::new()),
|
||||||
@@ -100,6 +103,7 @@ impl AgentManager {
|
|||||||
template_repo,
|
template_repo,
|
||||||
kb,
|
kb,
|
||||||
jwt_private_key_path,
|
jwt_private_key_path,
|
||||||
|
worker_mgr,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -755,6 +759,19 @@ fn build_step_tools() -> Vec<Tool> {
|
|||||||
})),
|
})),
|
||||||
tool_kb_search(),
|
tool_kb_search(),
|
||||||
tool_kb_read(),
|
tool_kb_read(),
|
||||||
|
make_tool("list_workers", "列出所有已注册的远程 worker 节点及其硬件/软件信息(CPU、内存、GPU、OS、内核)。", serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {}
|
||||||
|
})),
|
||||||
|
make_tool("execute_on_worker", "在指定的远程 worker 上执行脚本。脚本以 bash 执行。可以通过 HTTP 访问项目文件:GET/POST /api/obj/{project_id}/files/{path}", serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"worker": { "type": "string", "description": "Worker 名称(从 list_workers 获取)" },
|
||||||
|
"script": { "type": "string", "description": "要执行的 bash 脚本内容" },
|
||||||
|
"timeout": { "type": "integer", "description": "超时秒数(默认 300)", "default": 300 }
|
||||||
|
},
|
||||||
|
"required": ["worker", "script"]
|
||||||
|
})),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1500,6 +1517,52 @@ async fn run_step_loop(
|
|||||||
step_chat_history.push(ChatMessage::tool_result(&tc.id, &result));
|
step_chat_history.push(ChatMessage::tool_result(&tc.id, &result));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"list_workers" => {
|
||||||
|
let _ = broadcast_tx.send(WsMessage::ActivityUpdate {
|
||||||
|
workflow_id: workflow_id.to_string(),
|
||||||
|
activity: format!("步骤 {} — 列出 Workers", step_order),
|
||||||
|
});
|
||||||
|
let workers = mgr.worker_mgr.list().await;
|
||||||
|
let result = if workers.is_empty() {
|
||||||
|
"没有已注册的 worker。".to_string()
|
||||||
|
} else {
|
||||||
|
let items: Vec<String> = workers.iter().map(|(name, info)| {
|
||||||
|
format!("- {} (cpu={}, mem={}, gpu={}, os={}, kernel={})",
|
||||||
|
name, info.cpu, info.memory, info.gpu, info.os, info.kernel)
|
||||||
|
}).collect();
|
||||||
|
format!("已注册的 workers:\n{}", items.join("\n"))
|
||||||
|
};
|
||||||
|
log_execution(pool, broadcast_tx, workflow_id, step_order, "list_workers", "", &result, "done").await;
|
||||||
|
step_chat_history.push(ChatMessage::tool_result(&tc.id, &result));
|
||||||
|
}
|
||||||
|
|
||||||
|
"execute_on_worker" => {
|
||||||
|
let worker_name = args.get("worker").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let script = args.get("script").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let timeout = args.get("timeout").and_then(|v| v.as_u64()).unwrap_or(300);
|
||||||
|
let _ = broadcast_tx.send(WsMessage::ActivityUpdate {
|
||||||
|
workflow_id: workflow_id.to_string(),
|
||||||
|
activity: format!("步骤 {} — 在 {} 上执行脚本", step_order, worker_name),
|
||||||
|
});
|
||||||
|
let result = match mgr.worker_mgr.execute(worker_name, script, timeout).await {
|
||||||
|
Ok(wr) => {
|
||||||
|
let mut out = String::new();
|
||||||
|
out.push_str(&format!("exit_code: {}\n", wr.exit_code));
|
||||||
|
if !wr.stdout.is_empty() {
|
||||||
|
out.push_str(&format!("stdout:\n{}\n", truncate_str(&wr.stdout, 8192)));
|
||||||
|
}
|
||||||
|
if !wr.stderr.is_empty() {
|
||||||
|
out.push_str(&format!("stderr:\n{}\n", truncate_str(&wr.stderr, 4096)));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
Err(e) => format!("Error: {}", e),
|
||||||
|
};
|
||||||
|
let status = if result.starts_with("Error:") { "failed" } else { "done" };
|
||||||
|
log_execution(pool, broadcast_tx, workflow_id, step_order, "execute_on_worker", &tc.function.arguments, &result, status).await;
|
||||||
|
step_chat_history.push(ChatMessage::tool_result(&tc.id, &result));
|
||||||
|
}
|
||||||
|
|
||||||
// External tools
|
// External tools
|
||||||
name if external_tools.as_ref().is_some_and(|e| e.has_tool(name)) => {
|
name if external_tools.as_ref().is_some_and(|e| e.has_tool(name)) => {
|
||||||
let _ = broadcast_tx.send(WsMessage::ActivityUpdate {
|
let _ = broadcast_tx.send(WsMessage::ActivityUpdate {
|
||||||
|
|||||||
314
src/api/auth.rs
314
src/api/auth.rs
@@ -1,29 +1,51 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::State,
|
extract::{Query, Request, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::IntoResponse,
|
middleware::Next,
|
||||||
routing::post,
|
response::{IntoResponse, Redirect, Response},
|
||||||
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use jsonwebtoken::{encode, EncodingKey, Header, Algorithm};
|
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||||
|
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Algorithm, Validation};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
|
const COOKIE_NAME: &str = "tori_session";
|
||||||
|
const CSRF_COOKIE: &str = "tori_session_csrf";
|
||||||
|
const COOKIE_PATH: &str = "/";
|
||||||
|
const SESSION_SECS: i64 = 7 * 86400;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AuthConfig {
|
||||||
|
pub google_client_id: String,
|
||||||
|
pub google_client_secret: String,
|
||||||
|
pub jwt_secret: String,
|
||||||
|
pub public_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct Claims {
|
||||||
|
pub sub: String,
|
||||||
|
pub email: String,
|
||||||
|
pub exp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- EC key token generation (for agent/API use) ---
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct TokenResponse {
|
struct EcTokenResponse {
|
||||||
token: String,
|
token: String,
|
||||||
expires_in: u64,
|
expires_in: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct TokenRequest {
|
struct EcTokenRequest {
|
||||||
/// Subject claim (e.g. "oseng", "tori-agent")
|
|
||||||
#[serde(default = "default_sub")]
|
#[serde(default = "default_sub")]
|
||||||
sub: String,
|
sub: String,
|
||||||
/// Token validity in seconds (default: 300)
|
|
||||||
#[serde(default = "default_ttl")]
|
#[serde(default = "default_ttl")]
|
||||||
ttl_secs: u64,
|
ttl_secs: u64,
|
||||||
}
|
}
|
||||||
@@ -37,7 +59,7 @@ fn default_ttl() -> u64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct Claims {
|
struct EcClaims {
|
||||||
sub: String,
|
sub: String,
|
||||||
iat: usize,
|
iat: usize,
|
||||||
exp: usize,
|
exp: usize,
|
||||||
@@ -45,7 +67,7 @@ struct Claims {
|
|||||||
|
|
||||||
async fn generate_token(
|
async fn generate_token(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(body): Json<TokenRequest>,
|
Json(body): Json<EcTokenRequest>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let privkey_pem = match &state.config.jwt_private_key {
|
let privkey_pem = match &state.config.jwt_private_key {
|
||||||
Some(path) => match std::fs::read_to_string(path) {
|
Some(path) => match std::fs::read_to_string(path) {
|
||||||
@@ -69,7 +91,7 @@ async fn generate_token(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let now = chrono::Utc::now().timestamp() as usize;
|
let now = chrono::Utc::now().timestamp() as usize;
|
||||||
let claims = Claims {
|
let claims = EcClaims {
|
||||||
sub: body.sub,
|
sub: body.sub,
|
||||||
iat: now,
|
iat: now,
|
||||||
exp: now + body.ttl_secs as usize,
|
exp: now + body.ttl_secs as usize,
|
||||||
@@ -77,7 +99,7 @@ async fn generate_token(
|
|||||||
|
|
||||||
let header = Header::new(Algorithm::ES256);
|
let header = Header::new(Algorithm::ES256);
|
||||||
match encode(&header, &claims, &key) {
|
match encode(&header, &claims, &key) {
|
||||||
Ok(token) => Json(TokenResponse {
|
Ok(token) => Json(EcTokenResponse {
|
||||||
token,
|
token,
|
||||||
expires_in: body.ttl_secs,
|
expires_in: body.ttl_secs,
|
||||||
}).into_response(),
|
}).into_response(),
|
||||||
@@ -88,8 +110,276 @@ async fn generate_token(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Google OAuth ---
|
||||||
|
|
||||||
pub fn router(state: Arc<AppState>) -> Router {
|
pub fn router(state: Arc<AppState>) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
|
.route("/login", get(login))
|
||||||
|
.route("/callback", get(callback))
|
||||||
|
.route("/me", get(me))
|
||||||
|
.route("/logout", post(logout))
|
||||||
.route("/token", post(generate_token))
|
.route("/token", post(generate_token))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_cookie(name: &str, value: String, max_age_secs: i64) -> Cookie<'static> {
|
||||||
|
let mut c = Cookie::new(name.to_owned(), value);
|
||||||
|
c.set_path(COOKIE_PATH);
|
||||||
|
c.set_http_only(true);
|
||||||
|
c.set_secure(true);
|
||||||
|
c.set_same_site(SameSite::Lax);
|
||||||
|
c.set_max_age(Some(time::Duration::seconds(max_age_secs)));
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_cookie(name: &str) -> Cookie<'static> {
|
||||||
|
build_cookie(name, String::new(), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn login(State(state): State<Arc<AppState>>) -> Response {
|
||||||
|
let auth = match &state.auth {
|
||||||
|
Some(a) => a,
|
||||||
|
None => return (StatusCode::SERVICE_UNAVAILABLE, "Auth not configured").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let csrf = uuid::Uuid::new_v4().to_string();
|
||||||
|
let redirect_uri = format!("{}/tori/api/auth/callback", auth.public_url);
|
||||||
|
let url = format!(
|
||||||
|
"https://accounts.google.com/o/oauth2/v2/auth?\
|
||||||
|
client_id={}&redirect_uri={}&response_type=code&\
|
||||||
|
scope=openid%20email%20profile&access_type=online&state={}",
|
||||||
|
pct_encode(&auth.google_client_id),
|
||||||
|
pct_encode(&redirect_uri),
|
||||||
|
pct_encode(&csrf),
|
||||||
|
);
|
||||||
|
|
||||||
|
let jar = CookieJar::new().add(build_cookie(CSRF_COOKIE, csrf, 300));
|
||||||
|
(jar, Redirect::temporary(&url)).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CallbackParams {
|
||||||
|
code: String,
|
||||||
|
state: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct TokenResponse {
|
||||||
|
id_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct GoogleUserInfo {
|
||||||
|
sub: String,
|
||||||
|
email: String,
|
||||||
|
#[serde(default)]
|
||||||
|
name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
picture: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn callback(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Query(params): Query<CallbackParams>,
|
||||||
|
) -> Response {
|
||||||
|
let auth = match &state.auth {
|
||||||
|
Some(a) => a,
|
||||||
|
None => return (StatusCode::SERVICE_UNAVAILABLE, "Auth not configured").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// CSRF check
|
||||||
|
match jar.get(CSRF_COOKIE) {
|
||||||
|
Some(c) if c.value() == params.state => {}
|
||||||
|
_ => return (StatusCode::BAD_REQUEST, "Invalid state parameter").into_response(),
|
||||||
|
}
|
||||||
|
|
||||||
|
let redirect_uri = format!("{}/tori/api/auth/callback", auth.public_url);
|
||||||
|
|
||||||
|
// Exchange code for token
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let token_res = client
|
||||||
|
.post("https://oauth2.googleapis.com/token")
|
||||||
|
.form(&[
|
||||||
|
("code", params.code.as_str()),
|
||||||
|
("client_id", &auth.google_client_id),
|
||||||
|
("client_secret", &auth.google_client_secret),
|
||||||
|
("redirect_uri", &redirect_uri),
|
||||||
|
("grant_type", "authorization_code"),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let token_body: TokenResponse = match token_res {
|
||||||
|
Ok(r) if r.status().is_success() => match r.json().await {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => return (StatusCode::BAD_GATEWAY, format!("Token parse error: {}", e)).into_response(),
|
||||||
|
},
|
||||||
|
Ok(r) => {
|
||||||
|
let body = r.text().await.unwrap_or_default();
|
||||||
|
tracing::error!("Google token exchange failed: {}", body);
|
||||||
|
return (StatusCode::BAD_GATEWAY, "Google token exchange failed").into_response();
|
||||||
|
}
|
||||||
|
Err(e) => return (StatusCode::BAD_GATEWAY, format!("Token request failed: {}", e)).into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let id_token = match token_body.id_token {
|
||||||
|
Some(t) => t,
|
||||||
|
None => return (StatusCode::BAD_GATEWAY, "No id_token in response").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Decode id_token payload (no verification needed - just received from Google over HTTPS)
|
||||||
|
let user_info = match decode_google_id_token(&id_token) {
|
||||||
|
Some(u) => u,
|
||||||
|
None => return (StatusCode::BAD_GATEWAY, "Failed to decode id_token").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Upsert user
|
||||||
|
let user_id = format!("google:{}", user_info.sub);
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"INSERT INTO users (id, email, name, picture)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
email = excluded.email,
|
||||||
|
name = excluded.name,
|
||||||
|
picture = excluded.picture,
|
||||||
|
last_login_at = datetime('now')"
|
||||||
|
)
|
||||||
|
.bind(&user_id)
|
||||||
|
.bind(&user_info.email)
|
||||||
|
.bind(&user_info.name)
|
||||||
|
.bind(&user_info.picture)
|
||||||
|
.execute(&state.db.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
tracing::info!("User logged in: {} ({})", user_info.email, user_id);
|
||||||
|
|
||||||
|
// Sign JWT
|
||||||
|
let exp = chrono::Utc::now().timestamp() + SESSION_SECS;
|
||||||
|
let claims = Claims {
|
||||||
|
sub: user_id,
|
||||||
|
email: user_info.email,
|
||||||
|
exp,
|
||||||
|
};
|
||||||
|
let token = match encode(
|
||||||
|
&Header::default(),
|
||||||
|
&claims,
|
||||||
|
&EncodingKey::from_secret(auth.jwt_secret.as_bytes()),
|
||||||
|
) {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("JWT error: {}", e)).into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let jar = CookieJar::new()
|
||||||
|
.add(build_cookie(COOKIE_NAME, token, SESSION_SECS))
|
||||||
|
.add(clear_cookie(CSRF_COOKIE));
|
||||||
|
(jar, Redirect::temporary("/tori/")).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn me(State(state): State<Arc<AppState>>, jar: CookieJar) -> Response {
|
||||||
|
let auth = match &state.auth {
|
||||||
|
Some(a) => a,
|
||||||
|
None => return (StatusCode::SERVICE_UNAVAILABLE, "Auth not configured").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let claims = match extract_claims(&jar, &auth.jwt_secret) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return StatusCode::UNAUTHORIZED.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct UserInfo {
|
||||||
|
id: String,
|
||||||
|
email: String,
|
||||||
|
name: String,
|
||||||
|
picture: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let user: Option<UserInfo> = sqlx::query_as::<_, (String, String, String, String)>(
|
||||||
|
"SELECT id, email, name, picture FROM users WHERE id = ?"
|
||||||
|
)
|
||||||
|
.bind(&claims.sub)
|
||||||
|
.fetch_optional(&state.db.pool)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|(id, email, name, picture)| UserInfo { id, email, name, picture });
|
||||||
|
|
||||||
|
match user {
|
||||||
|
Some(u) => Json(u).into_response(),
|
||||||
|
None => StatusCode::UNAUTHORIZED.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn logout(jar: CookieJar) -> impl IntoResponse {
|
||||||
|
(jar.add(clear_cookie(COOKIE_NAME)), StatusCode::OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Middleware ---
|
||||||
|
|
||||||
|
pub async fn require_auth(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
mut req: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
let auth = match &state.auth {
|
||||||
|
Some(a) => a,
|
||||||
|
None => return next.run(req).await, // auth not configured, pass through
|
||||||
|
};
|
||||||
|
|
||||||
|
match extract_claims(&jar, &auth.jwt_secret) {
|
||||||
|
Some(claims) => {
|
||||||
|
req.extensions_mut().insert(claims);
|
||||||
|
next.run(req).await
|
||||||
|
}
|
||||||
|
None => StatusCode::UNAUTHORIZED.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
fn extract_claims(jar: &CookieJar, jwt_secret: &str) -> Option<Claims> {
|
||||||
|
let token = jar.get(COOKIE_NAME)?.value().to_string();
|
||||||
|
let key = DecodingKey::from_secret(jwt_secret.as_bytes());
|
||||||
|
let mut validation = Validation::default();
|
||||||
|
validation.validate_exp = true;
|
||||||
|
decode::<Claims>(&token, &key, &validation)
|
||||||
|
.ok()
|
||||||
|
.map(|d| d.claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_google_id_token(id_token: &str) -> Option<GoogleUserInfo> {
|
||||||
|
let parts: Vec<&str> = id_token.split('.').collect();
|
||||||
|
if parts.len() != 3 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let padded = match parts[1].len() % 4 {
|
||||||
|
2 => format!("{}==", parts[1]),
|
||||||
|
3 => format!("{}=", parts[1]),
|
||||||
|
_ => parts[1].to_string(),
|
||||||
|
};
|
||||||
|
let payload = base64_decode_url_safe(&padded)?;
|
||||||
|
serde_json::from_slice(&payload).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base64_decode_url_safe(input: &str) -> Option<Vec<u8>> {
|
||||||
|
let standard = input.replace('-', "+").replace('_', "/");
|
||||||
|
use base64::Engine;
|
||||||
|
base64::engine::general_purpose::STANDARD.decode(&standard).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pct_encode(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
for b in s.bytes() {
|
||||||
|
match b {
|
||||||
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||||
|
out.push(b as char);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
out.push_str(&format!("%{:02X}", b));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|||||||
258
src/api/files.rs
Normal file
258
src/api/files.rs
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Multipart, Path},
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
routing::get,
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
fn workspace_root() -> &'static str {
|
||||||
|
if std::path::Path::new("/app/data/workspaces").is_dir() {
|
||||||
|
"/app/data/workspaces"
|
||||||
|
} else {
|
||||||
|
"data/workspaces"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_path(project_id: &str, rel: &str) -> Result<PathBuf, Response> {
|
||||||
|
let base = PathBuf::from(workspace_root()).join(project_id);
|
||||||
|
let full = base.join(rel);
|
||||||
|
// Prevent path traversal
|
||||||
|
if rel.contains("..") {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, "Invalid path").into_response());
|
||||||
|
}
|
||||||
|
Ok(full)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct FileEntry {
|
||||||
|
name: String,
|
||||||
|
is_dir: bool,
|
||||||
|
size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn router() -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route(
|
||||||
|
"/projects/{id}/files",
|
||||||
|
get(list_root).post(upload_root).patch(mkdir_root),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/projects/{id}/files/{*path}",
|
||||||
|
get(get_file)
|
||||||
|
.post(upload_file)
|
||||||
|
.put(rename_file)
|
||||||
|
.delete(delete_file)
|
||||||
|
.patch(mkdir),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_dir(dir: PathBuf) -> Result<Json<Vec<FileEntry>>, Response> {
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
// Return empty list if directory doesn't exist yet
|
||||||
|
let mut rd = match tokio::fs::read_dir(&dir).await {
|
||||||
|
Ok(rd) => rd,
|
||||||
|
Err(_) => return Ok(Json(entries)),
|
||||||
|
};
|
||||||
|
while let Ok(Some(e)) = rd.next_entry().await {
|
||||||
|
let meta = match e.metadata().await {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
entries.push(FileEntry {
|
||||||
|
name: e.file_name().to_string_lossy().to_string(),
|
||||||
|
is_dir: meta.is_dir(),
|
||||||
|
size: meta.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
entries.sort_by(|a, b| {
|
||||||
|
b.is_dir.cmp(&a.is_dir).then(a.name.cmp(&b.name))
|
||||||
|
});
|
||||||
|
Ok(Json(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_root(Path(project_id): Path<String>) -> Result<Json<Vec<FileEntry>>, Response> {
|
||||||
|
let dir = resolve_path(&project_id, "")?;
|
||||||
|
list_dir(dir).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_file(
|
||||||
|
Path((project_id, file_path)): Path<(String, String)>,
|
||||||
|
) -> Response {
|
||||||
|
let full = match resolve_path(&project_id, &file_path) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => return e,
|
||||||
|
};
|
||||||
|
|
||||||
|
// If it's a directory, list contents
|
||||||
|
if full.is_dir() {
|
||||||
|
return match list_dir(full).await {
|
||||||
|
Ok(j) => j.into_response(),
|
||||||
|
Err(e) => e,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise serve the file
|
||||||
|
match tokio::fs::read(&full).await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
let mime = mime_guess::from_path(&full)
|
||||||
|
.first_or_octet_stream()
|
||||||
|
.to_string();
|
||||||
|
let filename = full
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("file");
|
||||||
|
(
|
||||||
|
[
|
||||||
|
(axum::http::header::CONTENT_TYPE, mime),
|
||||||
|
(
|
||||||
|
axum::http::header::CONTENT_DISPOSITION,
|
||||||
|
format!("attachment; filename=\"{}\"", filename),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
bytes,
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
Err(_) => (StatusCode::NOT_FOUND, "File not found").into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn do_upload(project_id: &str, rel_dir: &str, mut multipart: Multipart) -> Response {
|
||||||
|
let dir = match resolve_path(project_id, rel_dir) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => return e,
|
||||||
|
};
|
||||||
|
if let Err(e) = tokio::fs::create_dir_all(&dir).await {
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, format!("mkdir failed: {}", e)).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut count = 0u32;
|
||||||
|
while let Ok(Some(field)) = multipart.next_field().await {
|
||||||
|
let filename: String = match field.file_name() {
|
||||||
|
Some(f) => f.to_string(),
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
if filename.contains("..") || filename.contains('/') {
|
||||||
|
return (StatusCode::BAD_REQUEST, "Invalid filename").into_response();
|
||||||
|
}
|
||||||
|
let data = match field.bytes().await {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => return (StatusCode::BAD_REQUEST, format!("Read error: {}", e)).into_response(),
|
||||||
|
};
|
||||||
|
let dest = dir.join(&filename);
|
||||||
|
if let Err(e) = tokio::fs::write(&dest, &data).await {
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, format!("Write error: {}", e)).into_response();
|
||||||
|
}
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Json(serde_json::json!({ "uploaded": count })).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upload_root(
|
||||||
|
Path(project_id): Path<String>,
|
||||||
|
multipart: Multipart,
|
||||||
|
) -> Response {
|
||||||
|
do_upload(&project_id, "", multipart).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upload_file(
|
||||||
|
Path((project_id, file_path)): Path<(String, String)>,
|
||||||
|
multipart: Multipart,
|
||||||
|
) -> Response {
|
||||||
|
do_upload(&project_id, &file_path, multipart).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct RenameInput {
|
||||||
|
new_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rename_file(
|
||||||
|
Path((project_id, file_path)): Path<(String, String)>,
|
||||||
|
Json(input): Json<RenameInput>,
|
||||||
|
) -> Response {
|
||||||
|
if input.new_name.contains("..") || input.new_name.contains('/') {
|
||||||
|
return (StatusCode::BAD_REQUEST, "Invalid new name").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let src = match resolve_path(&project_id, &file_path) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => return e,
|
||||||
|
};
|
||||||
|
|
||||||
|
let dst = src
|
||||||
|
.parent()
|
||||||
|
.unwrap_or(&src)
|
||||||
|
.join(&input.new_name);
|
||||||
|
|
||||||
|
match tokio::fs::rename(&src, &dst).await {
|
||||||
|
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Rename failed: {}", e)).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct MkdirInput {
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mkdir(
|
||||||
|
Path((project_id, file_path)): Path<(String, String)>,
|
||||||
|
Json(input): Json<MkdirInput>,
|
||||||
|
) -> Response {
|
||||||
|
if input.name.contains("..") || input.name.contains('/') {
|
||||||
|
return (StatusCode::BAD_REQUEST, "Invalid directory name").into_response();
|
||||||
|
}
|
||||||
|
let parent = match resolve_path(&project_id, &file_path) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => return e,
|
||||||
|
};
|
||||||
|
let dir = parent.join(&input.name);
|
||||||
|
match tokio::fs::create_dir_all(&dir).await {
|
||||||
|
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("mkdir failed: {}", e)).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mkdir_root(
|
||||||
|
Path(project_id): Path<String>,
|
||||||
|
Json(input): Json<MkdirInput>,
|
||||||
|
) -> Response {
|
||||||
|
if input.name.contains("..") || input.name.contains('/') {
|
||||||
|
return (StatusCode::BAD_REQUEST, "Invalid directory name").into_response();
|
||||||
|
}
|
||||||
|
let parent = match resolve_path(&project_id, "") {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => return e,
|
||||||
|
};
|
||||||
|
let dir = parent.join(&input.name);
|
||||||
|
match tokio::fs::create_dir_all(&dir).await {
|
||||||
|
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("mkdir failed: {}", e)).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_file(
|
||||||
|
Path((project_id, file_path)): Path<(String, String)>,
|
||||||
|
) -> Response {
|
||||||
|
let full = match resolve_path(&project_id, &file_path) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => return e,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = if full.is_dir() {
|
||||||
|
tokio::fs::remove_dir_all(&full).await
|
||||||
|
} else {
|
||||||
|
tokio::fs::remove_file(&full).await
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Delete failed: {}", e)).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
mod auth;
|
pub mod auth;
|
||||||
mod chat;
|
mod chat;
|
||||||
|
mod files;
|
||||||
mod kb;
|
mod kb;
|
||||||
pub mod obj;
|
pub mod obj;
|
||||||
mod projects;
|
mod projects;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod timers;
|
mod timers;
|
||||||
|
mod workers;
|
||||||
mod workflows;
|
mod workflows;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -13,7 +15,7 @@ use axum::{
|
|||||||
extract::{Path, State, Request},
|
extract::{Path, State, Request},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::{get, any},
|
routing::any,
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,8 +36,8 @@ pub fn router(state: Arc<AppState>) -> Router {
|
|||||||
.merge(kb::router(state.clone()))
|
.merge(kb::router(state.clone()))
|
||||||
.merge(settings::router(state.clone()))
|
.merge(settings::router(state.clone()))
|
||||||
.merge(chat::router(state.clone()))
|
.merge(chat::router(state.clone()))
|
||||||
.merge(auth::router(state.clone()))
|
.merge(workers::router(state.clone()))
|
||||||
.route("/projects/{id}/files/{*path}", get(serve_project_file))
|
.merge(files::router())
|
||||||
.route("/projects/{id}/app/{*path}", any(proxy_to_service).with_state(state.clone()))
|
.route("/projects/{id}/app/{*path}", any(proxy_to_service).with_state(state.clone()))
|
||||||
.route("/projects/{id}/app/", any(proxy_to_service_root).with_state(state))
|
.route("/projects/{id}/app/", any(proxy_to_service_root).with_state(state))
|
||||||
}
|
}
|
||||||
@@ -103,40 +105,6 @@ async fn proxy_impl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn serve_project_file(
|
|
||||||
Path((project_id, file_path)): Path<(String, String)>,
|
|
||||||
) -> Response {
|
|
||||||
let full_path = std::path::PathBuf::from("/app/data/workspaces")
|
|
||||||
.join(&project_id)
|
|
||||||
.join(&file_path);
|
|
||||||
|
|
||||||
// Prevent path traversal
|
|
||||||
if file_path.contains("..") {
|
|
||||||
return (StatusCode::BAD_REQUEST, "Invalid path").into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
match tokio::fs::read(&full_path).await {
|
|
||||||
Ok(bytes) => {
|
|
||||||
// Render markdown files as HTML
|
|
||||||
if full_path.extension().is_some_and(|e| e == "md") {
|
|
||||||
let md = String::from_utf8_lossy(&bytes);
|
|
||||||
let html = render_markdown_page(&md, &file_path);
|
|
||||||
return (
|
|
||||||
[(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8".to_string())],
|
|
||||||
html,
|
|
||||||
).into_response();
|
|
||||||
}
|
|
||||||
let mime = mime_guess::from_path(&full_path)
|
|
||||||
.first_or_octet_stream()
|
|
||||||
.to_string();
|
|
||||||
(
|
|
||||||
[(axum::http::header::CONTENT_TYPE, mime)],
|
|
||||||
bytes,
|
|
||||||
).into_response()
|
|
||||||
}
|
|
||||||
Err(_) => (StatusCode::NOT_FOUND, "File not found").into_response(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_markdown_page(markdown: &str, title: &str) -> String {
|
fn render_markdown_page(markdown: &str, title: &str) -> String {
|
||||||
use pulldown_cmark::{Parser, Options, html};
|
use pulldown_cmark::{Parser, Options, html};
|
||||||
|
|||||||
@@ -4,10 +4,16 @@ use axum::{
|
|||||||
routing::get,
|
routing::get,
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
|
use axum::http::Extensions;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use crate::db::Project;
|
use crate::db::Project;
|
||||||
use super::{ApiResult, db_err};
|
use super::{ApiResult, db_err};
|
||||||
|
use super::auth::Claims;
|
||||||
|
|
||||||
|
fn owner_id(ext: &Extensions) -> &str {
|
||||||
|
ext.get::<Claims>().map(|c| c.sub.as_str()).unwrap_or("")
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct CreateProject {
|
pub struct CreateProject {
|
||||||
@@ -31,25 +37,33 @@ pub fn router(state: Arc<AppState>) -> Router {
|
|||||||
|
|
||||||
async fn list_projects(
|
async fn list_projects(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
|
ext: Extensions,
|
||||||
) -> ApiResult<Vec<Project>> {
|
) -> ApiResult<Vec<Project>> {
|
||||||
sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE deleted = 0 ORDER BY updated_at DESC")
|
let uid = owner_id(&ext);
|
||||||
.fetch_all(&state.db.pool)
|
sqlx::query_as::<_, Project>(
|
||||||
.await
|
"SELECT * FROM projects WHERE deleted = 0 AND (owner_id = ? OR owner_id = '') ORDER BY updated_at DESC"
|
||||||
.map(Json)
|
)
|
||||||
.map_err(db_err)
|
.bind(uid)
|
||||||
|
.fetch_all(&state.db.pool)
|
||||||
|
.await
|
||||||
|
.map(Json)
|
||||||
|
.map_err(db_err)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_project(
|
async fn create_project(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
|
ext: Extensions,
|
||||||
Json(input): Json<CreateProject>,
|
Json(input): Json<CreateProject>,
|
||||||
) -> ApiResult<Project> {
|
) -> ApiResult<Project> {
|
||||||
let id = uuid::Uuid::new_v4().to_string();
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let uid = owner_id(&ext);
|
||||||
sqlx::query_as::<_, Project>(
|
sqlx::query_as::<_, Project>(
|
||||||
"INSERT INTO projects (id, name, description) VALUES (?, ?, ?) RETURNING *"
|
"INSERT INTO projects (id, name, description, owner_id) VALUES (?, ?, ?, ?) RETURNING *"
|
||||||
)
|
)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.bind(&input.name)
|
.bind(&input.name)
|
||||||
.bind(&input.description)
|
.bind(&input.description)
|
||||||
|
.bind(uid)
|
||||||
.fetch_one(&state.db.pool)
|
.fetch_one(&state.db.pool)
|
||||||
.await
|
.await
|
||||||
.map(Json)
|
.map(Json)
|
||||||
@@ -58,55 +72,71 @@ async fn create_project(
|
|||||||
|
|
||||||
async fn get_project(
|
async fn get_project(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
|
ext: Extensions,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> ApiResult<Option<Project>> {
|
) -> ApiResult<Option<Project>> {
|
||||||
sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE id = ?")
|
let uid = owner_id(&ext);
|
||||||
.bind(&id)
|
sqlx::query_as::<_, Project>(
|
||||||
.fetch_optional(&state.db.pool)
|
"SELECT * FROM projects WHERE id = ? AND (owner_id = ? OR owner_id = '')"
|
||||||
.await
|
)
|
||||||
.map(Json)
|
.bind(&id)
|
||||||
.map_err(db_err)
|
.bind(uid)
|
||||||
|
.fetch_optional(&state.db.pool)
|
||||||
|
.await
|
||||||
|
.map(Json)
|
||||||
|
.map_err(db_err)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_project(
|
async fn update_project(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
|
ext: Extensions,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Json(input): Json<UpdateProject>,
|
Json(input): Json<UpdateProject>,
|
||||||
) -> ApiResult<Option<Project>> {
|
) -> ApiResult<Option<Project>> {
|
||||||
|
let uid = owner_id(&ext);
|
||||||
if let Some(name) = &input.name {
|
if let Some(name) = &input.name {
|
||||||
sqlx::query("UPDATE projects SET name = ?, updated_at = datetime('now') WHERE id = ?")
|
sqlx::query("UPDATE projects SET name = ?, updated_at = datetime('now') WHERE id = ? AND (owner_id = ? OR owner_id = '')")
|
||||||
.bind(name)
|
.bind(name)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
|
.bind(uid)
|
||||||
.execute(&state.db.pool)
|
.execute(&state.db.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(db_err)?;
|
.map_err(db_err)?;
|
||||||
}
|
}
|
||||||
if let Some(desc) = &input.description {
|
if let Some(desc) = &input.description {
|
||||||
sqlx::query("UPDATE projects SET description = ?, updated_at = datetime('now') WHERE id = ?")
|
sqlx::query("UPDATE projects SET description = ?, updated_at = datetime('now') WHERE id = ? AND (owner_id = ? OR owner_id = '')")
|
||||||
.bind(desc)
|
.bind(desc)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
|
.bind(uid)
|
||||||
.execute(&state.db.pool)
|
.execute(&state.db.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(db_err)?;
|
.map_err(db_err)?;
|
||||||
}
|
}
|
||||||
sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE id = ?")
|
sqlx::query_as::<_, Project>(
|
||||||
.bind(&id)
|
"SELECT * FROM projects WHERE id = ? AND (owner_id = ? OR owner_id = '')"
|
||||||
.fetch_optional(&state.db.pool)
|
)
|
||||||
.await
|
.bind(&id)
|
||||||
.map(Json)
|
.bind(uid)
|
||||||
.map_err(db_err)
|
.fetch_optional(&state.db.pool)
|
||||||
|
.await
|
||||||
|
.map(Json)
|
||||||
|
.map_err(db_err)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_project(
|
async fn delete_project(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
|
ext: Extensions,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> ApiResult<bool> {
|
) -> ApiResult<bool> {
|
||||||
// Soft delete: mark as deleted in DB
|
let uid = owner_id(&ext);
|
||||||
let result = sqlx::query("UPDATE projects SET deleted = 1, updated_at = datetime('now') WHERE id = ? AND deleted = 0")
|
let result = sqlx::query(
|
||||||
.bind(&id)
|
"UPDATE projects SET deleted = 1, updated_at = datetime('now') WHERE id = ? AND deleted = 0 AND (owner_id = ? OR owner_id = '')"
|
||||||
.execute(&state.db.pool)
|
)
|
||||||
.await
|
.bind(&id)
|
||||||
.map_err(db_err)?;
|
.bind(uid)
|
||||||
|
.execute(&state.db.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
|
||||||
if result.rows_affected() == 0 {
|
if result.rows_affected() == 0 {
|
||||||
return Ok(Json(false));
|
return Ok(Json(false));
|
||||||
|
|||||||
17
src/api/workers.rs
Normal file
17
src/api/workers.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use axum::{extract::State, routing::get, Json, Router};
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::worker::WorkerInfo;
|
||||||
|
|
||||||
|
async fn list_workers(State(state): State<Arc<AppState>>) -> Json<Vec<WorkerInfo>> {
|
||||||
|
let workers = state.agent_mgr.worker_mgr.list().await;
|
||||||
|
let entries: Vec<WorkerInfo> = workers.into_iter().map(|(_, info)| info).collect();
|
||||||
|
Json(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn router(state: Arc<AppState>) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/workers", get(list_workers))
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
22
src/db.rs
22
src/db.rs
@@ -73,6 +73,13 @@ impl Database {
|
|||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
// Migration: add owner_id column to projects
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"ALTER TABLE projects ADD COLUMN owner_id TEXT NOT NULL DEFAULT ''"
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
// KB tables
|
// KB tables
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"CREATE TABLE IF NOT EXISTS kb_articles (
|
"CREATE TABLE IF NOT EXISTS kb_articles (
|
||||||
@@ -215,6 +222,19 @@ impl Database {
|
|||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
picture TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
last_login_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)"
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"CREATE TABLE IF NOT EXISTS step_artifacts (
|
"CREATE TABLE IF NOT EXISTS step_artifacts (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -242,6 +262,8 @@ pub struct Project {
|
|||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub deleted: bool,
|
pub deleted: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub owner_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
|||||||
38
src/main.rs
38
src/main.rs
@@ -8,7 +8,9 @@ pub mod state;
|
|||||||
mod template;
|
mod template;
|
||||||
mod timer;
|
mod timer;
|
||||||
mod tools;
|
mod tools;
|
||||||
|
mod worker;
|
||||||
mod ws;
|
mod ws;
|
||||||
|
mod ws_worker;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
@@ -22,6 +24,7 @@ pub struct AppState {
|
|||||||
pub agent_mgr: Arc<agent::AgentManager>,
|
pub agent_mgr: Arc<agent::AgentManager>,
|
||||||
pub kb: Option<Arc<kb::KbManager>>,
|
pub kb: Option<Arc<kb::KbManager>>,
|
||||||
pub obj_root: String,
|
pub obj_root: String,
|
||||||
|
pub auth: Option<api::auth::AuthConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Deserialize)]
|
||||||
@@ -102,12 +105,15 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
template::ensure_repo_ready(repo_cfg).await;
|
template::ensure_repo_ready(repo_cfg).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let worker_mgr = worker::WorkerManager::new();
|
||||||
|
|
||||||
let agent_mgr = agent::AgentManager::new(
|
let agent_mgr = agent::AgentManager::new(
|
||||||
database.pool.clone(),
|
database.pool.clone(),
|
||||||
config.llm.clone(),
|
config.llm.clone(),
|
||||||
config.template_repo.clone(),
|
config.template_repo.clone(),
|
||||||
kb_arc.clone(),
|
kb_arc.clone(),
|
||||||
config.jwt_private_key.clone(),
|
config.jwt_private_key.clone(),
|
||||||
|
worker_mgr.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
timer::start_timer_runner(database.pool.clone(), agent_mgr.clone());
|
timer::start_timer_runner(database.pool.clone(), agent_mgr.clone());
|
||||||
@@ -117,21 +123,51 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
let obj_root = std::env::var("OBJ_ROOT").unwrap_or_else(|_| "/data/obj".to_string());
|
let obj_root = std::env::var("OBJ_ROOT").unwrap_or_else(|_| "/data/obj".to_string());
|
||||||
|
|
||||||
|
let auth_config = match (
|
||||||
|
std::env::var("GOOGLE_CLIENT_ID"),
|
||||||
|
std::env::var("GOOGLE_CLIENT_SECRET"),
|
||||||
|
) {
|
||||||
|
(Ok(client_id), Ok(client_secret)) => {
|
||||||
|
let jwt_secret = std::env::var("JWT_SECRET")
|
||||||
|
.unwrap_or_else(|_| uuid::Uuid::new_v4().to_string());
|
||||||
|
let public_url = std::env::var("PUBLIC_URL")
|
||||||
|
.unwrap_or_else(|_| "https://tori.euphon.cloud".to_string());
|
||||||
|
tracing::info!("Google OAuth enabled (public_url={})", public_url);
|
||||||
|
Some(api::auth::AuthConfig {
|
||||||
|
google_client_id: client_id,
|
||||||
|
google_client_secret: client_secret,
|
||||||
|
jwt_secret,
|
||||||
|
public_url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
tracing::warn!("GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET not set, auth disabled");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let state = Arc::new(AppState {
|
let state = Arc::new(AppState {
|
||||||
db: database,
|
db: database,
|
||||||
config: config.clone(),
|
config: config.clone(),
|
||||||
agent_mgr: agent_mgr.clone(),
|
agent_mgr: agent_mgr.clone(),
|
||||||
kb: kb_arc,
|
kb: kb_arc,
|
||||||
obj_root: obj_root.clone(),
|
obj_root: obj_root.clone(),
|
||||||
|
auth: auth_config,
|
||||||
});
|
});
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.nest("/tori/api", api::router(state))
|
// Auth routes are public
|
||||||
|
.nest("/tori/api/auth", api::auth::router(state.clone()))
|
||||||
|
// Protected API routes
|
||||||
|
.nest("/tori/api", api::router(state.clone())
|
||||||
|
.layer(axum::middleware::from_fn_with_state(state.clone(), api::auth::require_auth))
|
||||||
|
)
|
||||||
.nest("/api/obj", api::obj::router(obj_root.clone()))
|
.nest("/api/obj", api::obj::router(obj_root.clone()))
|
||||||
.route("/api/obj/", axum::routing::get({
|
.route("/api/obj/", axum::routing::get({
|
||||||
let r = obj_root;
|
let r = obj_root;
|
||||||
move || api::obj::root_listing(r)
|
move || api::obj::root_listing(r)
|
||||||
}))
|
}))
|
||||||
|
.nest("/ws/tori/workers", ws_worker::router(worker_mgr))
|
||||||
.nest("/ws/tori", ws::router(agent_mgr))
|
.nest("/ws/tori", ws::router(agent_mgr))
|
||||||
.nest_service("/tori", ServeDir::new("web/dist").fallback(ServeFile::new("web/dist/index.html")))
|
.nest_service("/tori", ServeDir::new("web/dist").fallback(ServeFile::new("web/dist/index.html")))
|
||||||
.route("/", axum::routing::get(|| async {
|
.route("/", axum::routing::get(|| async {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
- read_file / write_file / list_files:文件操作
|
- read_file / write_file / list_files:文件操作
|
||||||
- start_service / stop_service:管理后台服务
|
- start_service / stop_service:管理后台服务
|
||||||
- kb_search / kb_read:搜索和读取知识库
|
- kb_search / kb_read:搜索和读取知识库
|
||||||
|
- list_workers:列出已注册的远程 worker 节点及其硬件/软件信息
|
||||||
|
- execute_on_worker(worker, script, timeout):在远程 worker 上执行脚本
|
||||||
- update_scratchpad:记录本步骤内的中间状态(步骤结束后丢弃,精华写进 summary)
|
- update_scratchpad:记录本步骤内的中间状态(步骤结束后丢弃,精华写进 summary)
|
||||||
- ask_user:向用户提问,暂停执行等待用户回复
|
- ask_user:向用户提问,暂停执行等待用户回复
|
||||||
- step_done:**完成当前步骤时必须调用**,提供本步骤的工作摘要
|
- step_done:**完成当前步骤时必须调用**,提供本步骤的工作摘要
|
||||||
@@ -32,4 +34,22 @@
|
|||||||
- 后台服务访问:/api/projects/{project_id}/app/(启动命令需监听 0.0.0.0:$PORT)
|
- 后台服务访问:/api/projects/{project_id}/app/(启动命令需监听 0.0.0.0:$PORT)
|
||||||
- 【重要】应用通过反向代理访问,前端 HTML/JS 中的 fetch/XHR 请求必须使用相对路径(如 fetch('todos')),绝对不能用 / 开头的路径(如 fetch('/todos')),否则会 404
|
- 【重要】应用通过反向代理访问,前端 HTML/JS 中的 fetch/XHR 请求必须使用相对路径(如 fetch('todos')),绝对不能用 / 开头的路径(如 fetch('/todos')),否则会 404
|
||||||
|
|
||||||
|
## 远程 Worker
|
||||||
|
|
||||||
|
可以通过 `list_workers` 查看所有已注册的远程 worker,然后用 `execute_on_worker` 在指定 worker 上执行脚本。适用于需要特定硬件(如 GPU)或在远程环境执行任务的场景。
|
||||||
|
|
||||||
|
**重要**:
|
||||||
|
- 在 worker 上执行脚本时,可以通过 obj API 访问项目文件:
|
||||||
|
- 下载文件:`curl https://tori.euphon.cloud/api/obj/{project_id}/files/{path}`
|
||||||
|
- 上传文件:`curl -X POST -F 'files=@output.txt' https://tori.euphon.cloud/api/obj/{project_id}/files/`
|
||||||
|
- Python 脚本会自动通过 `uv run --script` 执行,支持 PEP 723 内联依赖声明:
|
||||||
|
```python
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.10"
|
||||||
|
# dependencies = ["requests", "pandas"]
|
||||||
|
# ///
|
||||||
|
import requests, pandas as pd
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
请使用中文回复。
|
请使用中文回复。
|
||||||
|
|||||||
133
src/worker.rs
Normal file
133
src/worker.rs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Information reported by a worker on registration.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct WorkerInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub cpu: String,
|
||||||
|
pub memory: String,
|
||||||
|
pub gpu: String,
|
||||||
|
pub os: String,
|
||||||
|
pub kernel: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A registered worker with a channel for sending scripts to execute.
|
||||||
|
struct Worker {
|
||||||
|
pub info: WorkerInfo,
|
||||||
|
pub tx: tokio::sync::mpsc::Sender<WorkerRequest>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct WorkerRequest {
|
||||||
|
pub job_id: String,
|
||||||
|
pub script: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct WorkerResult {
|
||||||
|
pub job_id: String,
|
||||||
|
pub exit_code: i32,
|
||||||
|
pub stdout: String,
|
||||||
|
pub stderr: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manages all connected workers.
|
||||||
|
pub struct WorkerManager {
|
||||||
|
workers: RwLock<HashMap<String, Worker>>,
|
||||||
|
/// Pending job results, keyed by job_id.
|
||||||
|
results: RwLock<HashMap<String, tokio::sync::oneshot::Sender<WorkerResult>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkerManager {
|
||||||
|
pub fn new() -> Arc<Self> {
|
||||||
|
Arc::new(Self {
|
||||||
|
workers: RwLock::new(HashMap::new()),
|
||||||
|
results: RwLock::new(HashMap::new()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a new worker. Returns a receiver for job requests.
|
||||||
|
pub async fn register(
|
||||||
|
&self,
|
||||||
|
name: String,
|
||||||
|
info: WorkerInfo,
|
||||||
|
) -> tokio::sync::mpsc::Receiver<WorkerRequest> {
|
||||||
|
let (tx, rx) = tokio::sync::mpsc::channel(16);
|
||||||
|
tracing::info!("Worker registered: {} (cpu={}, mem={}, gpu={}, os={}, kernel={})",
|
||||||
|
name, info.cpu, info.memory, info.gpu, info.os, info.kernel);
|
||||||
|
self.workers.write().await.insert(name, Worker { info, tx });
|
||||||
|
rx
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a worker.
|
||||||
|
pub async fn unregister(&self, name: &str) {
|
||||||
|
self.workers.write().await.remove(name);
|
||||||
|
tracing::info!("Worker unregistered: {}", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all connected workers.
|
||||||
|
pub async fn list(&self) -> Vec<(String, WorkerInfo)> {
|
||||||
|
self.workers
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.map(|(name, w)| (name.clone(), w.info.clone()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Submit a script to a worker and wait for the result.
|
||||||
|
pub async fn execute(
|
||||||
|
&self,
|
||||||
|
worker_name: &str,
|
||||||
|
script: &str,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> Result<WorkerResult, String> {
|
||||||
|
let job_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
// Find the worker and send the request
|
||||||
|
let tx = {
|
||||||
|
let workers = self.workers.read().await;
|
||||||
|
let worker = workers
|
||||||
|
.get(worker_name)
|
||||||
|
.ok_or_else(|| format!("Worker '{}' not found", worker_name))?;
|
||||||
|
worker.tx.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let (result_tx, result_rx) = tokio::sync::oneshot::channel();
|
||||||
|
self.results.write().await.insert(job_id.clone(), result_tx);
|
||||||
|
|
||||||
|
let req = WorkerRequest {
|
||||||
|
job_id: job_id.clone(),
|
||||||
|
script: script.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
tx.send(req).await.map_err(|_| {
|
||||||
|
format!("Worker '{}' disconnected", worker_name)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Wait for result with timeout
|
||||||
|
match tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(timeout_secs),
|
||||||
|
result_rx,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(result)) => Ok(result),
|
||||||
|
Ok(Err(_)) => Err("Worker channel closed unexpectedly".into()),
|
||||||
|
Err(_) => {
|
||||||
|
self.results.write().await.remove(&job_id);
|
||||||
|
Err(format!("Execution timed out after {}s", timeout_secs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called when a worker sends back a result.
|
||||||
|
pub async fn report_result(&self, result: WorkerResult) {
|
||||||
|
if let Some(tx) = self.results.write().await.remove(&result.job_id) {
|
||||||
|
let _ = tx.send(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/ws_worker.rs
Normal file
104
src/ws_worker.rs
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{State, WebSocketUpgrade, ws::{Message, WebSocket}},
|
||||||
|
response::Response,
|
||||||
|
routing::get,
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use futures::{SinkExt, StreamExt};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::worker::{WorkerInfo, WorkerManager, WorkerResult};
|
||||||
|
|
||||||
|
pub fn router(mgr: Arc<WorkerManager>) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(ws_handler))
|
||||||
|
.with_state(mgr)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ws_handler(
|
||||||
|
ws: WebSocketUpgrade,
|
||||||
|
State(mgr): State<Arc<WorkerManager>>,
|
||||||
|
) -> Response {
|
||||||
|
ws.on_upgrade(move |socket| handle_worker_socket(socket, mgr))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
enum WorkerMessage {
|
||||||
|
#[serde(rename = "register")]
|
||||||
|
Register { info: WorkerInfo },
|
||||||
|
#[serde(rename = "result")]
|
||||||
|
Result(WorkerResult),
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_worker_socket(socket: WebSocket, mgr: Arc<WorkerManager>) {
|
||||||
|
let (mut sender, mut receiver) = socket.split();
|
||||||
|
|
||||||
|
// First message must be registration
|
||||||
|
let (name, mut job_rx) = loop {
|
||||||
|
match receiver.next().await {
|
||||||
|
Some(Ok(Message::Text(text))) => {
|
||||||
|
match serde_json::from_str::<WorkerMessage>(&text) {
|
||||||
|
Ok(WorkerMessage::Register { info }) => {
|
||||||
|
let name = info.name.clone();
|
||||||
|
let rx = mgr.register(name.clone(), info).await;
|
||||||
|
// Ack
|
||||||
|
let ack = serde_json::json!({ "type": "registered", "name": &name });
|
||||||
|
let _ = sender.send(Message::Text(ack.to_string().into())).await;
|
||||||
|
break (name, rx);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let _ = sender.send(Message::Text(
|
||||||
|
r#"{"type":"error","message":"First message must be register"}"#.into(),
|
||||||
|
)).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Ok(Message::Close(_))) | None => return,
|
||||||
|
_ => continue,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Main loop: forward jobs to worker, receive results
|
||||||
|
let name_clone = name.clone();
|
||||||
|
let mgr_clone = mgr.clone();
|
||||||
|
|
||||||
|
// Task: send jobs from job_rx to the WebSocket
|
||||||
|
let send_task = tokio::spawn(async move {
|
||||||
|
while let Some(req) = job_rx.recv().await {
|
||||||
|
let msg = serde_json::json!({
|
||||||
|
"type": "execute",
|
||||||
|
"job_id": req.job_id,
|
||||||
|
"script": req.script,
|
||||||
|
});
|
||||||
|
if sender.send(Message::Text(msg.to_string().into())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Task: receive results from the WebSocket
|
||||||
|
let recv_task = tokio::spawn(async move {
|
||||||
|
while let Some(Ok(msg)) = receiver.next().await {
|
||||||
|
match msg {
|
||||||
|
Message::Text(text) => {
|
||||||
|
if let Ok(WorkerMessage::Result(result)) = serde_json::from_str(&text) {
|
||||||
|
mgr_clone.report_result(result).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::Close(_) => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = send_task => {},
|
||||||
|
_ = recv_task => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
mgr.unregister(&name_clone).await;
|
||||||
|
}
|
||||||
@@ -1,7 +1,46 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
import AppLayout from './components/AppLayout.vue'
|
import AppLayout from './components/AppLayout.vue'
|
||||||
|
import LoginPage from './components/LoginPage.vue'
|
||||||
|
import { auth, type AuthUser } from './api'
|
||||||
|
|
||||||
|
const authed = ref<boolean | null>(null)
|
||||||
|
const user = ref<AuthUser | null>(null)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
user.value = await auth.me()
|
||||||
|
authed.value = true
|
||||||
|
} catch {
|
||||||
|
authed.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onLogout() {
|
||||||
|
await auth.logout()
|
||||||
|
authed.value = false
|
||||||
|
user.value = null
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout />
|
<div v-if="authed === null" class="loading">
|
||||||
|
<span class="loading-text">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<LoginPage v-else-if="!authed" />
|
||||||
|
<AppLayout v-else :user="user!" @logout="onLogout" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.loading {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -5,8 +5,16 @@ const BASE = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/api`
|
|||||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`${BASE}${path}`, {
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
...options,
|
...options,
|
||||||
})
|
})
|
||||||
|
if (res.status === 401) {
|
||||||
|
const basePath = import.meta.env.BASE_URL.replace(/\/$/, '')
|
||||||
|
if (!window.location.pathname.endsWith('/login')) {
|
||||||
|
window.location.href = `${basePath}/login`
|
||||||
|
}
|
||||||
|
throw new Error('Not authenticated')
|
||||||
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
throw new Error(`API error ${res.status}: ${text}`)
|
throw new Error(`API error ${res.status}: ${text}`)
|
||||||
@@ -112,6 +120,8 @@ export const api = {
|
|||||||
body: JSON.stringify({ messages }),
|
body: JSON.stringify({ messages }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
listWorkers: () => request<WorkerInfo[]>('/workers'),
|
||||||
|
|
||||||
getSettings: () => request<Record<string, string>>('/settings'),
|
getSettings: () => request<Record<string, string>>('/settings'),
|
||||||
|
|
||||||
putSetting: (key: string, value: string) =>
|
putSetting: (key: string, value: string) =>
|
||||||
@@ -120,3 +130,33 @@ export const api = {
|
|||||||
body: JSON.stringify({ value }),
|
body: JSON.stringify({ value }),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WorkerInfo {
|
||||||
|
name: string
|
||||||
|
cpu: string
|
||||||
|
memory: string
|
||||||
|
gpu: string
|
||||||
|
os: string
|
||||||
|
kernel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
picture: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTH_BASE = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/api/auth`
|
||||||
|
|
||||||
|
export const auth = {
|
||||||
|
me: async (): Promise<AuthUser> => {
|
||||||
|
const res = await fetch(`${AUTH_BASE}/me`, { credentials: 'same-origin' })
|
||||||
|
if (!res.ok) throw new Error('Not authenticated')
|
||||||
|
return res.json()
|
||||||
|
},
|
||||||
|
logout: async (): Promise<void> => {
|
||||||
|
await fetch(`${AUTH_BASE}/logout`, { method: 'POST', credentials: 'same-origin' })
|
||||||
|
},
|
||||||
|
loginUrl: `${AUTH_BASE}/login`,
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,9 +7,13 @@ import ReportView from './ReportView.vue'
|
|||||||
import CreateForm from './CreateForm.vue'
|
import CreateForm from './CreateForm.vue'
|
||||||
import KbEditor from './KbEditor.vue'
|
import KbEditor from './KbEditor.vue'
|
||||||
import ObjBrowser from './ObjBrowser.vue'
|
import ObjBrowser from './ObjBrowser.vue'
|
||||||
import { api } from '../api'
|
import WorkersView from './WorkersView.vue'
|
||||||
|
import { api, type AuthUser } from '../api'
|
||||||
import type { Project, KbArticleSummary } from '../types'
|
import type { Project, KbArticleSummary } from '../types'
|
||||||
|
|
||||||
|
const props = defineProps<{ user: AuthUser }>()
|
||||||
|
const emit = defineEmits<{ logout: [] }>()
|
||||||
|
|
||||||
const projects = ref<Project[]>([])
|
const projects = ref<Project[]>([])
|
||||||
const selectedProjectId = ref('')
|
const selectedProjectId = ref('')
|
||||||
const reportWorkflowId = ref('')
|
const reportWorkflowId = ref('')
|
||||||
@@ -17,11 +21,13 @@ const error = ref('')
|
|||||||
const creating = ref(false)
|
const creating = ref(false)
|
||||||
const showKb = ref(false)
|
const showKb = ref(false)
|
||||||
const showObj = ref(false)
|
const showObj = ref(false)
|
||||||
|
const showWorkers = ref(false)
|
||||||
const kbArticles = ref<KbArticleSummary[]>([])
|
const kbArticles = ref<KbArticleSummary[]>([])
|
||||||
const selectedArticleId = ref('')
|
const selectedArticleId = ref('')
|
||||||
const appTitle = ref('')
|
const appTitle = ref('')
|
||||||
const chatOpen = ref(false)
|
const chatOpen = ref(false)
|
||||||
const showSettings = ref(false)
|
const showSettings = ref(false)
|
||||||
|
const showUserMenu = ref(false)
|
||||||
const editingTitle = ref(false)
|
const editingTitle = ref(false)
|
||||||
const titleInput = ref('')
|
const titleInput = ref('')
|
||||||
|
|
||||||
@@ -32,6 +38,7 @@ const isReportPage = computed(() => !!reportWorkflowId.value)
|
|||||||
const currentPageTitle = computed(() => {
|
const currentPageTitle = computed(() => {
|
||||||
if (showKb.value) return 'Knowledge Base'
|
if (showKb.value) return 'Knowledge Base'
|
||||||
if (showObj.value) return 'Object Storage'
|
if (showObj.value) return 'Object Storage'
|
||||||
|
if (showWorkers.value) return 'Workers'
|
||||||
if (selectedProjectId.value) {
|
if (selectedProjectId.value) {
|
||||||
const p = projects.value.find(p => p.id === selectedProjectId.value)
|
const p = projects.value.find(p => p.id === selectedProjectId.value)
|
||||||
return p?.name || ''
|
return p?.name || ''
|
||||||
@@ -52,28 +59,32 @@ function onSaveTitle() {
|
|||||||
editingTitle.value = false
|
editingTitle.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseUrl(): { projectId: string; reportId: string; kb: boolean; obj: boolean } {
|
function parseUrl(): { projectId: string; reportId: string; kb: boolean; obj: boolean; workers: boolean } {
|
||||||
let path = location.pathname
|
let path = location.pathname
|
||||||
if (basePath && path.startsWith(basePath)) {
|
if (basePath && path.startsWith(basePath)) {
|
||||||
path = path.slice(basePath.length) || '/'
|
path = path.slice(basePath.length) || '/'
|
||||||
}
|
}
|
||||||
const reportMatch = path.match(/^\/report\/([^/]+)/)
|
const reportMatch = path.match(/^\/report\/([^/]+)/)
|
||||||
if (reportMatch) return { projectId: '', reportId: reportMatch[1] ?? '', kb: false, obj: false }
|
if (reportMatch) return { projectId: '', reportId: reportMatch[1] ?? '', kb: false, obj: false, workers: false }
|
||||||
if (path.startsWith('/kb')) return { projectId: '', reportId: '', kb: true, obj: false }
|
if (path.startsWith('/kb')) return { projectId: '', reportId: '', kb: true, obj: false, workers: false }
|
||||||
if (path.startsWith('/obj')) return { projectId: '', reportId: '', kb: false, obj: true }
|
if (path.startsWith('/obj')) return { projectId: '', reportId: '', kb: false, obj: true, workers: false }
|
||||||
|
if (path.startsWith('/workers')) return { projectId: '', reportId: '', kb: false, obj: false, workers: true }
|
||||||
const projectMatch = path.match(/^\/projects\/([^/]+)/)
|
const projectMatch = path.match(/^\/projects\/([^/]+)/)
|
||||||
return { projectId: projectMatch?.[1] ?? '', reportId: '', kb: false, obj: false }
|
return { projectId: projectMatch?.[1] ?? '', reportId: '', kb: false, obj: false, workers: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPopState() {
|
function onPopState() {
|
||||||
const { projectId, reportId, kb, obj } = parseUrl()
|
const { projectId, reportId, kb, obj, workers } = parseUrl()
|
||||||
if (kb) {
|
if (kb) {
|
||||||
onOpenKb()
|
onOpenKb()
|
||||||
} else if (obj) {
|
} else if (obj) {
|
||||||
onOpenObj()
|
onOpenObj()
|
||||||
|
} else if (workers) {
|
||||||
|
onOpenWorkers()
|
||||||
} else {
|
} else {
|
||||||
showKb.value = false
|
showKb.value = false
|
||||||
showObj.value = false
|
showObj.value = false
|
||||||
|
showWorkers.value = false
|
||||||
selectedArticleId.value = ''
|
selectedArticleId.value = ''
|
||||||
selectedProjectId.value = projectId
|
selectedProjectId.value = projectId
|
||||||
reportWorkflowId.value = reportId
|
reportWorkflowId.value = reportId
|
||||||
@@ -87,11 +98,13 @@ onMounted(async () => {
|
|||||||
if (appTitle.value) document.title = appTitle.value
|
if (appTitle.value) document.title = appTitle.value
|
||||||
|
|
||||||
projects.value = await api.listProjects()
|
projects.value = await api.listProjects()
|
||||||
const { projectId, reportId, kb, obj } = parseUrl()
|
const { projectId, reportId, kb, obj, workers } = parseUrl()
|
||||||
if (kb) {
|
if (kb) {
|
||||||
onOpenKb()
|
onOpenKb()
|
||||||
} else if (obj) {
|
} else if (obj) {
|
||||||
onOpenObj()
|
onOpenObj()
|
||||||
|
} else if (workers) {
|
||||||
|
onOpenWorkers()
|
||||||
} else if (reportId) {
|
} else if (reportId) {
|
||||||
reportWorkflowId.value = reportId
|
reportWorkflowId.value = reportId
|
||||||
} else if (projectId && projects.value.some(p => p.id === projectId)) {
|
} else if (projectId && projects.value.some(p => p.id === projectId)) {
|
||||||
@@ -203,6 +216,18 @@ function onCloseObj() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onOpenWorkers() {
|
||||||
|
showWorkers.value = true
|
||||||
|
showKb.value = false
|
||||||
|
showObj.value = false
|
||||||
|
selectedProjectId.value = ''
|
||||||
|
creating.value = false
|
||||||
|
if (location.pathname !== `${basePath}/workers`) {
|
||||||
|
history.pushState(null, '', `${basePath}/workers`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async function onCreateArticle() {
|
async function onCreateArticle() {
|
||||||
try {
|
try {
|
||||||
const article = await api.createArticle('新文章')
|
const article = await api.createArticle('新文章')
|
||||||
@@ -247,6 +272,7 @@ function onArticleSaved(id: string, title: string, updatedAt: string) {
|
|||||||
function goHome() {
|
function goHome() {
|
||||||
showKb.value = false
|
showKb.value = false
|
||||||
showObj.value = false
|
showObj.value = false
|
||||||
|
showWorkers.value = false
|
||||||
selectedArticleId.value = ''
|
selectedArticleId.value = ''
|
||||||
creating.value = false
|
creating.value = false
|
||||||
if (projects.value[0]) {
|
if (projects.value[0]) {
|
||||||
@@ -292,11 +318,31 @@ function goHome() {
|
|||||||
</div>
|
</div>
|
||||||
<button class="settings-item" @click="showSettings = false; onOpenKb()">Knowledge Base</button>
|
<button class="settings-item" @click="showSettings = false; onOpenKb()">Knowledge Base</button>
|
||||||
<button class="settings-item" @click="showSettings = false; onOpenObj()">Object Storage</button>
|
<button class="settings-item" @click="showSettings = false; onOpenObj()">Object Storage</button>
|
||||||
|
<button class="settings-item" @click="showSettings = false; onOpenWorkers()">Workers</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="header-btn" @click="chatOpen = !chatOpen" :title="chatOpen ? 'Close chat' : 'Open chat'">
|
<button class="header-btn" @click="chatOpen = !chatOpen" :title="chatOpen ? 'Close chat' : 'Open chat'">
|
||||||
💬
|
💬
|
||||||
</button>
|
</button>
|
||||||
|
<div class="header-settings-wrapper">
|
||||||
|
<img
|
||||||
|
v-if="props.user.picture"
|
||||||
|
:src="props.user.picture"
|
||||||
|
class="header-avatar"
|
||||||
|
:title="props.user.email"
|
||||||
|
@click="showUserMenu = !showUserMenu"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
/>
|
||||||
|
<span v-else class="header-avatar-placeholder" @click="showUserMenu = !showUserMenu" :title="props.user.email">
|
||||||
|
{{ props.user.email[0]?.toUpperCase() }}
|
||||||
|
</span>
|
||||||
|
<div v-if="showUserMenu" class="header-settings-menu">
|
||||||
|
<div class="settings-item-row">
|
||||||
|
<span class="settings-label">{{ props.user.name || props.user.email }}</span>
|
||||||
|
</div>
|
||||||
|
<button class="settings-item" @click="showUserMenu = false; emit('logout')">Sign out</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="app-body">
|
<div class="app-body">
|
||||||
@@ -320,8 +366,11 @@ function goHome() {
|
|||||||
/>
|
/>
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<div v-if="error" class="error-banner" @click="error = ''">{{ error }}</div>
|
<div v-if="error" class="error-banner" @click="error = ''">{{ error }}</div>
|
||||||
|
<WorkersView
|
||||||
|
v-if="showWorkers"
|
||||||
|
/>
|
||||||
<ObjBrowser
|
<ObjBrowser
|
||||||
v-if="showObj"
|
v-else-if="showObj"
|
||||||
@close="onCloseObj"
|
@close="onCloseObj"
|
||||||
/>
|
/>
|
||||||
<KbEditor
|
<KbEditor
|
||||||
@@ -554,6 +603,32 @@ function goHome() {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-avatar:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-avatar-placeholder {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-sidebar {
|
.chat-sidebar {
|
||||||
width: var(--chat-sidebar-width, 360px);
|
width: var(--chat-sidebar-width, 360px);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|||||||
574
web/src/components/FileBrowser.vue
Normal file
574
web/src/components/FileBrowser.vue
Normal file
@@ -0,0 +1,574 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ projectId: string }>()
|
||||||
|
|
||||||
|
const BASE = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/api`
|
||||||
|
|
||||||
|
interface FileEntry {
|
||||||
|
name: string
|
||||||
|
is_dir: boolean
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const cwd = ref<string[]>([])
|
||||||
|
const entries = ref<FileEntry[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const renamingItem = ref('')
|
||||||
|
const renameValue = ref('')
|
||||||
|
const mkdirMode = ref(false)
|
||||||
|
const mkdirName = ref('')
|
||||||
|
const uploading = ref(false)
|
||||||
|
const uploadProgress = ref(0) // 0-100
|
||||||
|
const uploadSpeed = ref('') // e.g. "2.3 MB/s"
|
||||||
|
const uploadEta = ref('') // e.g. "12s"
|
||||||
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const cwdPath = computed(() => cwd.value.join('/'))
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => {
|
||||||
|
const parts = [{ name: 'workspace', path: '' }]
|
||||||
|
let acc = ''
|
||||||
|
for (const p of cwd.value) {
|
||||||
|
acc = acc ? `${acc}/${p}` : p
|
||||||
|
parts.push({ name: p, path: acc })
|
||||||
|
}
|
||||||
|
return parts
|
||||||
|
})
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const path = cwdPath.value
|
||||||
|
const url = path
|
||||||
|
? `${BASE}/projects/${props.projectId}/files/${path}`
|
||||||
|
: `${BASE}/projects/${props.projectId}/files`
|
||||||
|
const res = await fetch(url, { credentials: 'same-origin' })
|
||||||
|
if (!res.ok) throw new Error(`${res.status}`)
|
||||||
|
entries.value = await res.json()
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
entries.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.projectId, () => { cwd.value = []; load() }, { immediate: true })
|
||||||
|
|
||||||
|
function enter(name: string) {
|
||||||
|
cwd.value = [...cwd.value, name]
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function goTo(path: string) {
|
||||||
|
cwd.value = path ? path.split('/') : []
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number) {
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadUrl(name: string) {
|
||||||
|
const path = cwdPath.value ? `${cwdPath.value}/${name}` : name
|
||||||
|
return `${BASE}/projects/${props.projectId}/files/${path}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSpeed(bytesPerSec: number): string {
|
||||||
|
if (bytesPerSec < 1024) return `${bytesPerSec.toFixed(0)} B/s`
|
||||||
|
if (bytesPerSec < 1024 * 1024) return `${(bytesPerSec / 1024).toFixed(1)} KB/s`
|
||||||
|
return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEta(secs: number): string {
|
||||||
|
if (secs < 60) return `${Math.ceil(secs)}s`
|
||||||
|
if (secs < 3600) return `${Math.floor(secs / 60)}m ${Math.ceil(secs % 60)}s`
|
||||||
|
return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadFiles(fileList: FileList) {
|
||||||
|
uploading.value = true
|
||||||
|
uploadProgress.value = 0
|
||||||
|
uploadSpeed.value = ''
|
||||||
|
uploadEta.value = ''
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
const form = new FormData()
|
||||||
|
for (const f of fileList) {
|
||||||
|
form.append('files', f, f.name)
|
||||||
|
}
|
||||||
|
const path = cwdPath.value
|
||||||
|
const url = path
|
||||||
|
? `${BASE}/projects/${props.projectId}/files/${path}`
|
||||||
|
: `${BASE}/projects/${props.projectId}/files`
|
||||||
|
|
||||||
|
const xhr = new XMLHttpRequest()
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
xhr.upload.addEventListener('progress', (ev) => {
|
||||||
|
if (ev.lengthComputable && ev.total > 0) {
|
||||||
|
uploadProgress.value = Math.round((ev.loaded / ev.total) * 100)
|
||||||
|
const elapsed = (Date.now() - startTime) / 1000
|
||||||
|
if (elapsed > 0.3) {
|
||||||
|
const bps = ev.loaded / elapsed
|
||||||
|
uploadSpeed.value = formatSpeed(bps)
|
||||||
|
const remaining = ev.total - ev.loaded
|
||||||
|
uploadEta.value = bps > 0 ? formatEta(remaining / bps) : ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
xhr.addEventListener('load', () => {
|
||||||
|
uploading.value = false
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
load()
|
||||||
|
} else {
|
||||||
|
error.value = xhr.responseText || `Upload failed (${xhr.status})`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
xhr.addEventListener('error', () => {
|
||||||
|
uploading.value = false
|
||||||
|
error.value = 'Upload failed (network error)'
|
||||||
|
})
|
||||||
|
|
||||||
|
xhr.open('POST', url)
|
||||||
|
xhr.withCredentials = true
|
||||||
|
xhr.send(form)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileInput(ev: Event) {
|
||||||
|
const input = ev.target as HTMLInputElement
|
||||||
|
if (input.files && input.files.length > 0) {
|
||||||
|
uploadFiles(input.files)
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRename(oldName: string) {
|
||||||
|
if (!renameValue.value.trim() || renameValue.value === oldName) {
|
||||||
|
renamingItem.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const path = cwdPath.value ? `${cwdPath.value}/${oldName}` : oldName
|
||||||
|
const res = await fetch(`${BASE}/projects/${props.projectId}/files/${path}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ new_name: renameValue.value.trim() }),
|
||||||
|
credentials: 'same-origin',
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(await res.text())
|
||||||
|
renamingItem.value = ''
|
||||||
|
load()
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(name: string, isDir: boolean) {
|
||||||
|
const label = isDir ? 'folder' : 'file'
|
||||||
|
if (!confirm(`Delete ${label} "${name}"?`)) return
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const path = cwdPath.value ? `${cwdPath.value}/${name}` : name
|
||||||
|
const res = await fetch(`${BASE}/projects/${props.projectId}/files/${path}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(await res.text())
|
||||||
|
load()
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onMkdir() {
|
||||||
|
const name = mkdirName.value.trim()
|
||||||
|
if (!name) { mkdirMode.value = false; return }
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const path = cwdPath.value
|
||||||
|
const url = path
|
||||||
|
? `${BASE}/projects/${props.projectId}/files/${path}`
|
||||||
|
: `${BASE}/projects/${props.projectId}/files`
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
credentials: 'same-origin',
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(await res.text())
|
||||||
|
mkdirMode.value = false
|
||||||
|
mkdirName.value = ''
|
||||||
|
load()
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRename(name: string) {
|
||||||
|
renamingItem.value = name
|
||||||
|
renameValue.value = name
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(ev: DragEvent) {
|
||||||
|
ev.preventDefault()
|
||||||
|
if (ev.dataTransfer?.files && ev.dataTransfer.files.length > 0) {
|
||||||
|
uploadFiles(ev.dataTransfer.files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="fb" @dragover.prevent @drop="onDrop">
|
||||||
|
<div class="fb-toolbar">
|
||||||
|
<div class="fb-breadcrumb">
|
||||||
|
<span
|
||||||
|
v-for="(b, i) in breadcrumbs"
|
||||||
|
:key="b.path"
|
||||||
|
class="fb-crumb"
|
||||||
|
@click="goTo(b.path)"
|
||||||
|
>
|
||||||
|
<span v-if="i > 0" class="fb-sep">/</span>
|
||||||
|
{{ b.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="fb-actions">
|
||||||
|
<button class="fb-btn" @click="mkdirMode = true" title="New folder">+ Folder</button>
|
||||||
|
<button class="fb-btn" @click="fileInputRef?.click()" :disabled="uploading" title="Upload files">Upload</button>
|
||||||
|
<input ref="fileInputRef" type="file" multiple style="display:none" @change="onFileInput" />
|
||||||
|
<button class="fb-btn fb-btn-icon" @click="load()" title="Refresh">↻</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="uploading" class="fb-upload-progress">
|
||||||
|
<div class="fb-progress-bar">
|
||||||
|
<div class="fb-progress-fill" :style="{ width: uploadProgress + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
<span class="fb-progress-text">
|
||||||
|
{{ uploadProgress }}%
|
||||||
|
<template v-if="uploadSpeed"> · {{ uploadSpeed }}</template>
|
||||||
|
<template v-if="uploadEta"> · ETA {{ uploadEta }}</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="fb-error" @click="error = ''">{{ error }}</div>
|
||||||
|
|
||||||
|
<div v-if="mkdirMode" class="fb-mkdir">
|
||||||
|
<input
|
||||||
|
v-model="mkdirName"
|
||||||
|
class="fb-input"
|
||||||
|
placeholder="Folder name"
|
||||||
|
@keyup.enter="onMkdir"
|
||||||
|
@keyup.escape="mkdirMode = false"
|
||||||
|
@vue:mounted="($event: any) => $event.el.focus()"
|
||||||
|
/>
|
||||||
|
<button class="fb-btn" @click="onMkdir">Create</button>
|
||||||
|
<button class="fb-btn" @click="mkdirMode = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="fb-loading">Loading...</div>
|
||||||
|
|
||||||
|
<div v-else-if="entries.length === 0" class="fb-empty">
|
||||||
|
Empty directory — drag & drop files here to upload
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table v-else class="fb-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="fb-th-name">Name</th>
|
||||||
|
<th class="fb-th-size">Size</th>
|
||||||
|
<th class="fb-th-actions"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="e in entries" :key="e.name" class="fb-row">
|
||||||
|
<td class="fb-cell-name">
|
||||||
|
<template v-if="renamingItem === e.name">
|
||||||
|
<input
|
||||||
|
v-model="renameValue"
|
||||||
|
class="fb-input fb-input-inline"
|
||||||
|
@keyup.enter="onRename(e.name)"
|
||||||
|
@keyup.escape="renamingItem = ''"
|
||||||
|
@vue:mounted="($event: any) => $event.el.focus()"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span v-if="e.is_dir" class="fb-icon">📁</span>
|
||||||
|
<span v-else class="fb-icon">📄</span>
|
||||||
|
<a
|
||||||
|
v-if="e.is_dir"
|
||||||
|
class="fb-link"
|
||||||
|
@click.prevent="enter(e.name)"
|
||||||
|
href="#"
|
||||||
|
>{{ e.name }}</a>
|
||||||
|
<a
|
||||||
|
v-else
|
||||||
|
class="fb-link"
|
||||||
|
:href="downloadUrl(e.name)"
|
||||||
|
target="_blank"
|
||||||
|
>{{ e.name }}</a>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
<td class="fb-cell-size">{{ e.is_dir ? '-' : formatSize(e.size) }}</td>
|
||||||
|
<td class="fb-cell-actions">
|
||||||
|
<button class="fb-btn-sm" @click="startRename(e.name)" title="Rename">✏️</button>
|
||||||
|
<button class="fb-btn-sm" @click="onDelete(e.name, e.is_dir)" title="Delete">🗑</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fb {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 13px;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-crumb {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-crumb:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-sep {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-btn {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-btn:hover {
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-btn-icon {
|
||||||
|
padding: 4px 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-upload-progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-progress-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-progress-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-error {
|
||||||
|
background: var(--error);
|
||||||
|
color: #fff;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-mkdir {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-input {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-input-inline {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-loading, .fb-empty {
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-table thead {
|
||||||
|
display: table;
|
||||||
|
width: 100%;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-table tbody {
|
||||||
|
display: block;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: calc(100vh - 200px);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-table tr {
|
||||||
|
display: table;
|
||||||
|
width: 100%;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-row {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-row:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-cell-name {
|
||||||
|
padding: 6px 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-cell-size {
|
||||||
|
padding: 6px 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-cell-actions {
|
||||||
|
padding: 6px 8px;
|
||||||
|
width: 80px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-th-name { width: auto; }
|
||||||
|
.fb-th-size { width: 100px; }
|
||||||
|
.fb-th-actions { width: 80px; }
|
||||||
|
|
||||||
|
.fb-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-link {
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-link:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-btn-sm {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-btn-sm:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
78
web/src/components/LoginPage.vue
Normal file
78
web/src/components/LoginPage.vue
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { auth } from '../api'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="login-page">
|
||||||
|
<div class="login-card">
|
||||||
|
<h1 class="login-title">Tori</h1>
|
||||||
|
<p class="login-subtitle">Sign in to continue</p>
|
||||||
|
<a :href="auth.loginUrl" class="google-btn">
|
||||||
|
<svg class="google-icon" viewBox="0 0 24 24" width="18" height="18">
|
||||||
|
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/>
|
||||||
|
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||||
|
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||||
|
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||||
|
</svg>
|
||||||
|
Sign in with Google
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-page {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 48px 40px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0 0 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.google-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 24px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.google-btn:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.google-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
178
web/src/components/WorkersView.vue
Normal file
178
web/src/components/WorkersView.vue
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { api, type WorkerInfo } from '../api'
|
||||||
|
|
||||||
|
const workers = ref<WorkerInfo[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
workers.value = await api.listWorkers()
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="workers-view">
|
||||||
|
<div class="workers-header">
|
||||||
|
<h2 class="workers-title">Workers</h2>
|
||||||
|
<button class="workers-refresh" @click="load" :disabled="loading">↻</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="workers-error" @click="error = ''">{{ error }}</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="workers-loading">Loading...</div>
|
||||||
|
|
||||||
|
<div v-else-if="workers.length === 0" class="workers-empty">
|
||||||
|
No workers registered
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="workers-grid">
|
||||||
|
<div v-for="w in workers" :key="w.name" class="worker-card">
|
||||||
|
<div class="worker-name">
|
||||||
|
<span class="worker-dot"></span>
|
||||||
|
{{ w.name }}
|
||||||
|
</div>
|
||||||
|
<div class="worker-details">
|
||||||
|
<div class="worker-row">
|
||||||
|
<span class="worker-label">CPU</span>
|
||||||
|
<span class="worker-value">{{ w.cpu }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="worker-row">
|
||||||
|
<span class="worker-label">Memory</span>
|
||||||
|
<span class="worker-value">{{ w.memory }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="worker-row">
|
||||||
|
<span class="worker-label">GPU</span>
|
||||||
|
<span class="worker-value">{{ w.gpu }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="worker-row">
|
||||||
|
<span class="worker-label">OS</span>
|
||||||
|
<span class="worker-value">{{ w.os }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="worker-row">
|
||||||
|
<span class="worker-label">Kernel</span>
|
||||||
|
<span class="worker-value">{{ w.kernel }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.workers-view {
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workers-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workers-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workers-refresh {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workers-refresh:hover {
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workers-error {
|
||||||
|
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;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workers-loading, .workers-empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 40px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workers-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #4caf50;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-details {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 6px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
min-width: 60px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-value {
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,6 +5,7 @@ import PlanSection from './PlanSection.vue'
|
|||||||
import ExecutionSection from './ExecutionSection.vue'
|
import ExecutionSection from './ExecutionSection.vue'
|
||||||
import CommentSection from './CommentSection.vue'
|
import CommentSection from './CommentSection.vue'
|
||||||
import TimerSection from './TimerSection.vue'
|
import TimerSection from './TimerSection.vue'
|
||||||
|
import FileBrowser from './FileBrowser.vue'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
import { connectWs } from '../ws'
|
import { connectWs } from '../ws'
|
||||||
import type { Workflow, ExecutionLogEntry, PlanStepInfo, Comment, LlmCallLogEntry } from '../types'
|
import type { Workflow, ExecutionLogEntry, PlanStepInfo, Comment, LlmCallLogEntry } from '../types'
|
||||||
@@ -26,7 +27,7 @@ const llmCalls = ref<LlmCallLogEntry[]>([])
|
|||||||
const quotes = ref<string[]>([])
|
const quotes = ref<string[]>([])
|
||||||
const currentActivity = ref('')
|
const currentActivity = ref('')
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const rightTab = ref<'log' | 'timers'>('log')
|
const rightTab = ref<'log' | 'timers' | 'files'>('log')
|
||||||
const commentRef = ref<InstanceType<typeof CommentSection> | null>(null)
|
const commentRef = ref<InstanceType<typeof CommentSection> | null>(null)
|
||||||
|
|
||||||
function addQuote(text: string) {
|
function addQuote(text: string) {
|
||||||
@@ -184,6 +185,7 @@ async function onSubmitComment(text: string) {
|
|||||||
<div class="right-panel">
|
<div class="right-panel">
|
||||||
<div class="tab-bar">
|
<div class="tab-bar">
|
||||||
<button class="tab-btn" :class="{ active: rightTab === 'log' }" @click="rightTab = 'log'">日志</button>
|
<button class="tab-btn" :class="{ active: rightTab === 'log' }" @click="rightTab = 'log'">日志</button>
|
||||||
|
<button class="tab-btn" :class="{ active: rightTab === 'files' }" @click="rightTab = 'files'">文件</button>
|
||||||
<button class="tab-btn" :class="{ active: rightTab === 'timers' }" @click="rightTab = 'timers'">定时任务</button>
|
<button class="tab-btn" :class="{ active: rightTab === 'timers' }" @click="rightTab = 'timers'">定时任务</button>
|
||||||
</div>
|
</div>
|
||||||
<ExecutionSection
|
<ExecutionSection
|
||||||
@@ -198,6 +200,10 @@ async function onSubmitComment(text: string) {
|
|||||||
:currentActivity="currentActivity"
|
:currentActivity="currentActivity"
|
||||||
@quote="addQuote"
|
@quote="addQuote"
|
||||||
/>
|
/>
|
||||||
|
<FileBrowser
|
||||||
|
v-show="rightTab === 'files'"
|
||||||
|
:projectId="projectId"
|
||||||
|
/>
|
||||||
<TimerSection
|
<TimerSection
|
||||||
v-show="rightTab === 'timers'"
|
v-show="rightTab === 'timers'"
|
||||||
:projectId="projectId"
|
:projectId="projectId"
|
||||||
|
|||||||
182
worker/tori-worker.py
Executable file
182
worker/tori-worker.py
Executable file
@@ -0,0 +1,182 @@
|
|||||||
|
#!/usr/bin/env -S uv run --script
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.10"
|
||||||
|
# dependencies = ["websockets"]
|
||||||
|
# ///
|
||||||
|
"""tori-worker: connects to Tori server via WebSocket, reports hardware info, executes scripts."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import websockets
|
||||||
|
|
||||||
|
|
||||||
|
def get_cpu_info() -> str:
|
||||||
|
"""Get CPU model name."""
|
||||||
|
try:
|
||||||
|
with open("/proc/cpuinfo") as f:
|
||||||
|
for line in f:
|
||||||
|
if line.startswith("model name"):
|
||||||
|
return line.split(":", 1)[1].strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return platform.processor() or platform.machine()
|
||||||
|
|
||||||
|
|
||||||
|
def get_memory_info() -> str:
|
||||||
|
"""Get total memory."""
|
||||||
|
try:
|
||||||
|
with open("/proc/meminfo") as f:
|
||||||
|
for line in f:
|
||||||
|
if line.startswith("MemTotal"):
|
||||||
|
kb = int(line.split()[1])
|
||||||
|
gb = kb / (1024 * 1024)
|
||||||
|
return f"{gb:.1f} GB"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def get_gpu_info() -> str:
|
||||||
|
"""Get GPU info via nvidia-smi if available."""
|
||||||
|
nvidia_smi = shutil.which("nvidia-smi")
|
||||||
|
if nvidia_smi:
|
||||||
|
try:
|
||||||
|
out = subprocess.check_output(
|
||||||
|
[nvidia_smi, "--query-gpu=name,memory.total", "--format=csv,noheader,nounits"],
|
||||||
|
timeout=5, text=True
|
||||||
|
).strip()
|
||||||
|
gpus = []
|
||||||
|
for line in out.splitlines():
|
||||||
|
parts = [p.strip() for p in line.split(",")]
|
||||||
|
if len(parts) >= 2:
|
||||||
|
gpus.append(f"{parts[0]} ({parts[1]} MiB)")
|
||||||
|
else:
|
||||||
|
gpus.append(parts[0])
|
||||||
|
return "; ".join(gpus)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "none"
|
||||||
|
|
||||||
|
|
||||||
|
def get_worker_info(name: str) -> dict:
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"cpu": get_cpu_info(),
|
||||||
|
"memory": get_memory_info(),
|
||||||
|
"gpu": get_gpu_info(),
|
||||||
|
"os": f"{platform.system()} {platform.release()}",
|
||||||
|
"kernel": platform.release(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_script(script: str, timeout: int = 300) -> dict:
|
||||||
|
"""Execute a bash script and return result.
|
||||||
|
|
||||||
|
If the script starts with a Python shebang or `# /// script` (uv inline metadata),
|
||||||
|
it's written as .py and run via `uv run --script`. Otherwise it's run as bash.
|
||||||
|
"""
|
||||||
|
is_python = script.lstrip().startswith(("#!/usr/bin/env python", "# /// script", "#!/usr/bin/python", "import ", "from "))
|
||||||
|
suffix = ".py" if is_python else ".sh"
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=suffix, delete=False) as f:
|
||||||
|
f.write(script)
|
||||||
|
f.flush()
|
||||||
|
script_path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
if is_python:
|
||||||
|
cmd = ["uv", "run", "--script", script_path]
|
||||||
|
else:
|
||||||
|
cmd = ["bash", script_path]
|
||||||
|
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
proc.kill()
|
||||||
|
await proc.wait()
|
||||||
|
return {
|
||||||
|
"job_id": "",
|
||||||
|
"exit_code": -1,
|
||||||
|
"stdout": "",
|
||||||
|
"stderr": f"Script timed out after {timeout}s",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"job_id": "",
|
||||||
|
"exit_code": proc.returncode,
|
||||||
|
"stdout": stdout.decode(errors="replace"),
|
||||||
|
"stderr": stderr.decode(errors="replace"),
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
os.unlink(script_path)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_worker(server_url: str, name: str):
|
||||||
|
info = get_worker_info(name)
|
||||||
|
print(f"Worker info: {json.dumps(info, indent=2)}")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
print(f"Connecting to {server_url} ...")
|
||||||
|
async with websockets.connect(server_url) as ws:
|
||||||
|
# Register
|
||||||
|
reg_msg = json.dumps({"type": "register", "info": info})
|
||||||
|
await ws.send(reg_msg)
|
||||||
|
|
||||||
|
ack = json.loads(await ws.recv())
|
||||||
|
if ack.get("type") == "registered":
|
||||||
|
print(f"Registered as '{ack.get('name')}'")
|
||||||
|
else:
|
||||||
|
print(f"Unexpected ack: {ack}")
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Main loop: receive jobs, execute, send results
|
||||||
|
async for message in ws:
|
||||||
|
msg = json.loads(message)
|
||||||
|
if msg.get("type") == "execute":
|
||||||
|
job_id = msg["job_id"]
|
||||||
|
script = msg["script"]
|
||||||
|
print(f"Executing job {job_id}: {script[:80]}...")
|
||||||
|
|
||||||
|
result = await execute_script(script)
|
||||||
|
result["job_id"] = job_id
|
||||||
|
result["type"] = "result"
|
||||||
|
|
||||||
|
await ws.send(json.dumps(result))
|
||||||
|
print(f"Job {job_id} done (exit={result['exit_code']})")
|
||||||
|
|
||||||
|
except (websockets.exceptions.ConnectionClosed, ConnectionRefusedError, OSError) as e:
|
||||||
|
print(f"Connection lost ({e}), reconnecting in 5s...")
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Tori Worker")
|
||||||
|
parser.add_argument("--server", default="wss://tori.euphon.cloud/ws/tori/workers",
|
||||||
|
help="WebSocket server URL")
|
||||||
|
parser.add_argument("--name", default=platform.node(),
|
||||||
|
help="Worker name (default: hostname)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
print(f"Starting tori-worker '{args.name}' -> {args.server}")
|
||||||
|
asyncio.run(run_worker(args.server, args.name))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
14
worker/tori-worker.service
Normal file
14
worker/tori-worker.service
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Tori Worker
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=%h/.local/bin/uv run --script %h/tori-worker/tori-worker.py --server wss://tori.euphon.cloud/ws/tori/workers
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
Reference in New Issue
Block a user