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:
Fam Zheng 2026-03-17 01:57:57 +00:00
parent 186d882f35
commit 63f0582f54
26 changed files with 2338 additions and 106 deletions

3
.gitignore vendored
View File

@ -20,5 +20,8 @@ web/dist/
*.swp
*.swo
# Vue language server output
*.vue.js
# OS
.DS_Store

64
Cargo.lock generated
View File

@ -73,6 +73,7 @@ dependencies = [
"matchit",
"memchr",
"mime",
"multer",
"percent-encoding",
"pin-project-lite",
"serde_core",
@ -108,6 +109,29 @@ dependencies = [
"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]]
name = "base64"
version = "0.22.1"
@ -207,6 +231,17 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "core-foundation-sys"
version = "0.8.7"
@ -326,6 +361,15 @@ dependencies = [
"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]]
name = "equivalent"
version = "1.0.2"
@ -1057,6 +1101,23 @@ dependencies = [
"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]]
name = "nix"
version = "0.29.0"
@ -2204,6 +2265,8 @@ version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"axum-extra",
"base64",
"chrono",
"futures",
"jsonwebtoken",
@ -2215,6 +2278,7 @@ dependencies = [
"serde_json",
"serde_yaml",
"sqlx",
"time",
"tokio",
"tokio-util",
"tower-http",

View File

@ -10,7 +10,7 @@ warnings = "deny"
all = "deny"
[dependencies]
axum = { version = "0.8", features = ["ws"] }
axum = { version = "0.8", features = ["ws", "multipart"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@ -29,3 +29,6 @@ tokio-util = { version = "0.7", features = ["io"] }
nix = { version = "0.29", features = ["signal"] }
pulldown-cmark = "0.12"
jsonwebtoken = "9"
axum-extra = { version = "0.10", features = ["cookie"] }
base64 = "0.22"
time = "0.3"

View File

@ -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:
@ -36,6 +36,15 @@ 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:
cargo clean
rm -rf web/dist web/node_modules

View File

@ -31,6 +31,23 @@ spec:
env:
- name: RUST_LOG
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:
- name: data
hostPath:
@ -70,6 +87,17 @@ spec:
name: tori
port:
number: 80
- host: tori.euphon.cloud
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: tori
port:
number: 80
tls:
- hosts:
- tori.oci.euphon.net
- tori.euphon.cloud

View File

@ -1,25 +1,23 @@
#!/bin/bash
# Deploy tori to OCI k3s cluster
# Run from local machine: scripts/deploy.sh
# Deploy tori to k3s cluster (local kubectl)
set -euo pipefail
OCI_HOST="oci"
OCI_DIR="~/src/tori"
IMAGE="registry.oci.euphon.net/tori:latest"
echo "==> Syncing project to OCI..."
rsync -az --exclude target --exclude node_modules --exclude .git --exclude web/dist . "${OCI_HOST}:${OCI_DIR}/"
echo "==> Building Rust binary..."
cargo build --release --target aarch64-unknown-linux-musl
echo "==> Building Rust binary on OCI..."
ssh "$OCI_HOST" "source ~/.cargo/env && cd $OCI_DIR && \
cargo build --release --target aarch64-unknown-linux-musl"
echo "==> Building Docker image..."
docker build -t "$IMAGE" .
echo "==> Building and deploying on OCI..."
ssh "$OCI_HOST" "cd $OCI_DIR && \
docker build -t $IMAGE . && \
docker push $IMAGE && \
kubectl apply -f deploy/ && \
kubectl rollout restart deployment/tori -n tori && \
kubectl rollout status deployment/tori -n tori"
echo "==> Pushing image..."
docker push "$IMAGE"
echo "==> Applying manifests..."
kubectl apply -f deploy/
echo "==> Rolling out..."
kubectl rollout restart deployment/tori -n tori
kubectl rollout status deployment/tori -n tori
echo "==> Done!"

View File

@ -10,6 +10,7 @@ use crate::llm::{LlmClient, ChatMessage, Tool, ToolFunction};
use crate::exec::LocalExecutor;
use crate::template::{self, LoadedTemplate};
use crate::tools::ExternalToolManager;
use crate::worker::WorkerManager;
use crate::LlmConfig;
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>,
kb: Option<Arc<crate::kb::KbManager>>,
jwt_private_key_path: Option<String>,
pub worker_mgr: Arc<WorkerManager>,
}
impl AgentManager {
@ -89,6 +91,7 @@ impl AgentManager {
template_repo: Option<crate::TemplateRepoConfig>,
kb: Option<Arc<crate::kb::KbManager>>,
jwt_private_key_path: Option<String>,
worker_mgr: Arc<WorkerManager>,
) -> Arc<Self> {
Arc::new(Self {
agents: RwLock::new(HashMap::new()),
@ -100,6 +103,7 @@ impl AgentManager {
template_repo,
kb,
jwt_private_key_path,
worker_mgr,
})
}
@ -755,6 +759,19 @@ fn build_step_tools() -> Vec<Tool> {
})),
tool_kb_search(),
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));
}
"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
name if external_tools.as_ref().is_some_and(|e| e.has_tool(name)) => {
let _ = broadcast_tx.send(WsMessage::ActivityUpdate {

View File

@ -1,29 +1,51 @@
use std::sync::Arc;
use axum::{
extract::State,
extract::{Query, Request, State},
http::StatusCode,
response::IntoResponse,
routing::post,
middleware::Next,
response::{IntoResponse, Redirect, Response},
routing::{get, post},
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 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)]
struct TokenResponse {
struct EcTokenResponse {
token: String,
expires_in: u64,
}
#[derive(Deserialize)]
struct TokenRequest {
/// Subject claim (e.g. "oseng", "tori-agent")
struct EcTokenRequest {
#[serde(default = "default_sub")]
sub: String,
/// Token validity in seconds (default: 300)
#[serde(default = "default_ttl")]
ttl_secs: u64,
}
@ -37,7 +59,7 @@ fn default_ttl() -> u64 {
}
#[derive(Serialize)]
struct Claims {
struct EcClaims {
sub: String,
iat: usize,
exp: usize,
@ -45,7 +67,7 @@ struct Claims {
async fn generate_token(
State(state): State<Arc<AppState>>,
Json(body): Json<TokenRequest>,
Json(body): Json<EcTokenRequest>,
) -> impl IntoResponse {
let privkey_pem = match &state.config.jwt_private_key {
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 claims = Claims {
let claims = EcClaims {
sub: body.sub,
iat: now,
exp: now + body.ttl_secs as usize,
@ -77,7 +99,7 @@ async fn generate_token(
let header = Header::new(Algorithm::ES256);
match encode(&header, &claims, &key) {
Ok(token) => Json(TokenResponse {
Ok(token) => Json(EcTokenResponse {
token,
expires_in: body.ttl_secs,
}).into_response(),
@ -88,8 +110,276 @@ async fn generate_token(
}
}
// --- Google OAuth ---
pub fn router(state: Arc<AppState>) -> Router {
Router::new()
.route("/login", get(login))
.route("/callback", get(callback))
.route("/me", get(me))
.route("/logout", post(logout))
.route("/token", post(generate_token))
.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
View 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(),
}
}

View File

@ -1,10 +1,12 @@
mod auth;
pub mod auth;
mod chat;
mod files;
mod kb;
pub mod obj;
mod projects;
mod settings;
mod timers;
mod workers;
mod workflows;
use std::sync::Arc;
@ -13,7 +15,7 @@ use axum::{
extract::{Path, State, Request},
http::StatusCode,
response::{IntoResponse, Response},
routing::{get, any},
routing::any,
Json, Router,
};
@ -34,8 +36,8 @@ pub fn router(state: Arc<AppState>) -> Router {
.merge(kb::router(state.clone()))
.merge(settings::router(state.clone()))
.merge(chat::router(state.clone()))
.merge(auth::router(state.clone()))
.route("/projects/{id}/files/{*path}", get(serve_project_file))
.merge(workers::router(state.clone()))
.merge(files::router())
.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))
}
@ -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 {
use pulldown_cmark::{Parser, Options, html};

View File

@ -4,10 +4,16 @@ use axum::{
routing::get,
Json, Router,
};
use axum::http::Extensions;
use serde::Deserialize;
use crate::AppState;
use crate::db::Project;
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)]
pub struct CreateProject {
@ -31,8 +37,13 @@ pub fn router(state: Arc<AppState>) -> Router {
async fn list_projects(
State(state): State<Arc<AppState>>,
ext: Extensions,
) -> ApiResult<Vec<Project>> {
sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE deleted = 0 ORDER BY updated_at DESC")
let uid = owner_id(&ext);
sqlx::query_as::<_, Project>(
"SELECT * FROM projects WHERE deleted = 0 AND (owner_id = ? OR owner_id = '') ORDER BY updated_at DESC"
)
.bind(uid)
.fetch_all(&state.db.pool)
.await
.map(Json)
@ -41,15 +52,18 @@ async fn list_projects(
async fn create_project(
State(state): State<Arc<AppState>>,
ext: Extensions,
Json(input): Json<CreateProject>,
) -> ApiResult<Project> {
let id = uuid::Uuid::new_v4().to_string();
let uid = owner_id(&ext);
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(&input.name)
.bind(&input.description)
.bind(uid)
.fetch_one(&state.db.pool)
.await
.map(Json)
@ -58,10 +72,15 @@ async fn create_project(
async fn get_project(
State(state): State<Arc<AppState>>,
ext: Extensions,
Path(id): Path<String>,
) -> ApiResult<Option<Project>> {
sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE id = ?")
let uid = owner_id(&ext);
sqlx::query_as::<_, Project>(
"SELECT * FROM projects WHERE id = ? AND (owner_id = ? OR owner_id = '')"
)
.bind(&id)
.bind(uid)
.fetch_optional(&state.db.pool)
.await
.map(Json)
@ -70,27 +89,34 @@ async fn get_project(
async fn update_project(
State(state): State<Arc<AppState>>,
ext: Extensions,
Path(id): Path<String>,
Json(input): Json<UpdateProject>,
) -> ApiResult<Option<Project>> {
let uid = owner_id(&ext);
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(&id)
.bind(uid)
.execute(&state.db.pool)
.await
.map_err(db_err)?;
}
if let Some(desc) = &input.description {
sqlx::query("UPDATE projects SET description = ?, updated_at = datetime('now') WHERE id = ?")
sqlx::query("UPDATE projects SET description = ?, updated_at = datetime('now') WHERE id = ? AND (owner_id = ? OR owner_id = '')")
.bind(desc)
.bind(&id)
.bind(uid)
.execute(&state.db.pool)
.await
.map_err(db_err)?;
}
sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE id = ?")
sqlx::query_as::<_, Project>(
"SELECT * FROM projects WHERE id = ? AND (owner_id = ? OR owner_id = '')"
)
.bind(&id)
.bind(uid)
.fetch_optional(&state.db.pool)
.await
.map(Json)
@ -99,11 +125,15 @@ async fn update_project(
async fn delete_project(
State(state): State<Arc<AppState>>,
ext: Extensions,
Path(id): Path<String>,
) -> ApiResult<bool> {
// Soft delete: mark as deleted in DB
let result = sqlx::query("UPDATE projects SET deleted = 1, updated_at = datetime('now') WHERE id = ? AND deleted = 0")
let uid = owner_id(&ext);
let result = sqlx::query(
"UPDATE projects SET deleted = 1, updated_at = datetime('now') WHERE id = ? AND deleted = 0 AND (owner_id = ? OR owner_id = '')"
)
.bind(&id)
.bind(uid)
.execute(&state.db.pool)
.await
.map_err(db_err)?;

17
src/api/workers.rs Normal file
View 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)
}

View File

@ -73,6 +73,13 @@ impl Database {
.execute(&self.pool)
.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
sqlx::query(
"CREATE TABLE IF NOT EXISTS kb_articles (
@ -215,6 +222,19 @@ impl Database {
.execute(&self.pool)
.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(
"CREATE TABLE IF NOT EXISTS step_artifacts (
id TEXT PRIMARY KEY,
@ -242,6 +262,8 @@ pub struct Project {
pub updated_at: String,
#[serde(default)]
pub deleted: bool,
#[serde(default)]
pub owner_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]

View File

@ -8,7 +8,9 @@ pub mod state;
mod template;
mod timer;
mod tools;
mod worker;
mod ws;
mod ws_worker;
use std::sync::Arc;
use axum::Router;
@ -22,6 +24,7 @@ pub struct AppState {
pub agent_mgr: Arc<agent::AgentManager>,
pub kb: Option<Arc<kb::KbManager>>,
pub obj_root: String,
pub auth: Option<api::auth::AuthConfig>,
}
#[derive(Debug, Clone, serde::Deserialize)]
@ -102,12 +105,15 @@ async fn main() -> anyhow::Result<()> {
template::ensure_repo_ready(repo_cfg).await;
}
let worker_mgr = worker::WorkerManager::new();
let agent_mgr = agent::AgentManager::new(
database.pool.clone(),
config.llm.clone(),
config.template_repo.clone(),
kb_arc.clone(),
config.jwt_private_key.clone(),
worker_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 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 {
db: database,
config: config.clone(),
agent_mgr: agent_mgr.clone(),
kb: kb_arc,
obj_root: obj_root.clone(),
auth: auth_config,
});
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()))
.route("/api/obj/", axum::routing::get({
let r = obj_root;
move || api::obj::root_listing(r)
}))
.nest("/ws/tori/workers", ws_worker::router(worker_mgr))
.nest("/ws/tori", ws::router(agent_mgr))
.nest_service("/tori", ServeDir::new("web/dist").fallback(ServeFile::new("web/dist/index.html")))
.route("/", axum::routing::get(|| async {

View File

@ -6,6 +6,8 @@
- read_file / write_file / list_files文件操作
- start_service / stop_service管理后台服务
- kb_search / kb_read搜索和读取知识库
- list_workers列出已注册的远程 worker 节点及其硬件/软件信息
- execute_on_worker(worker, script, timeout):在远程 worker 上执行脚本
- update_scratchpad记录本步骤内的中间状态步骤结束后丢弃精华写进 summary
- ask_user向用户提问暂停执行等待用户回复
- step_done**完成当前步骤时必须调用**,提供本步骤的工作摘要
@ -32,4 +34,22 @@
- 后台服务访问:/api/projects/{project_id}/app/(启动命令需监听 0.0.0.0:$PORT
- 【重要】应用通过反向代理访问,前端 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
View 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
View 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;
}

View File

@ -1,7 +1,46 @@
<script setup lang="ts">
import { ref, onMounted } from '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>
<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>
<style scoped>
.loading {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.loading-text {
color: var(--text-secondary);
font-size: 14px;
}
</style>

View File

@ -5,8 +5,16 @@ const BASE = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/api`
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
...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) {
const text = await res.text()
throw new Error(`API error ${res.status}: ${text}`)
@ -112,6 +120,8 @@ export const api = {
body: JSON.stringify({ messages }),
}),
listWorkers: () => request<WorkerInfo[]>('/workers'),
getSettings: () => request<Record<string, string>>('/settings'),
putSetting: (key: string, value: string) =>
@ -120,3 +130,33 @@ export const api = {
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`,
}

View File

@ -7,9 +7,13 @@ import ReportView from './ReportView.vue'
import CreateForm from './CreateForm.vue'
import KbEditor from './KbEditor.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'
const props = defineProps<{ user: AuthUser }>()
const emit = defineEmits<{ logout: [] }>()
const projects = ref<Project[]>([])
const selectedProjectId = ref('')
const reportWorkflowId = ref('')
@ -17,11 +21,13 @@ const error = ref('')
const creating = ref(false)
const showKb = ref(false)
const showObj = ref(false)
const showWorkers = ref(false)
const kbArticles = ref<KbArticleSummary[]>([])
const selectedArticleId = ref('')
const appTitle = ref('')
const chatOpen = ref(false)
const showSettings = ref(false)
const showUserMenu = ref(false)
const editingTitle = ref(false)
const titleInput = ref('')
@ -32,6 +38,7 @@ const isReportPage = computed(() => !!reportWorkflowId.value)
const currentPageTitle = computed(() => {
if (showKb.value) return 'Knowledge Base'
if (showObj.value) return 'Object Storage'
if (showWorkers.value) return 'Workers'
if (selectedProjectId.value) {
const p = projects.value.find(p => p.id === selectedProjectId.value)
return p?.name || ''
@ -52,28 +59,32 @@ function onSaveTitle() {
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
if (basePath && path.startsWith(basePath)) {
path = path.slice(basePath.length) || '/'
}
const reportMatch = path.match(/^\/report\/([^/]+)/)
if (reportMatch) return { projectId: '', reportId: reportMatch[1] ?? '', kb: false, obj: false }
if (path.startsWith('/kb')) return { projectId: '', reportId: '', kb: true, obj: false }
if (path.startsWith('/obj')) return { projectId: '', reportId: '', kb: false, obj: true }
if (reportMatch) return { projectId: '', reportId: reportMatch[1] ?? '', kb: false, obj: false, workers: 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, workers: false }
if (path.startsWith('/workers')) return { projectId: '', reportId: '', kb: false, obj: false, workers: true }
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() {
const { projectId, reportId, kb, obj } = parseUrl()
const { projectId, reportId, kb, obj, workers } = parseUrl()
if (kb) {
onOpenKb()
} else if (obj) {
onOpenObj()
} else if (workers) {
onOpenWorkers()
} else {
showKb.value = false
showObj.value = false
showWorkers.value = false
selectedArticleId.value = ''
selectedProjectId.value = projectId
reportWorkflowId.value = reportId
@ -87,11 +98,13 @@ onMounted(async () => {
if (appTitle.value) document.title = appTitle.value
projects.value = await api.listProjects()
const { projectId, reportId, kb, obj } = parseUrl()
const { projectId, reportId, kb, obj, workers } = parseUrl()
if (kb) {
onOpenKb()
} else if (obj) {
onOpenObj()
} else if (workers) {
onOpenWorkers()
} else if (reportId) {
reportWorkflowId.value = reportId
} 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() {
try {
const article = await api.createArticle('新文章')
@ -247,6 +272,7 @@ function onArticleSaved(id: string, title: string, updatedAt: string) {
function goHome() {
showKb.value = false
showObj.value = false
showWorkers.value = false
selectedArticleId.value = ''
creating.value = false
if (projects.value[0]) {
@ -292,11 +318,31 @@ function goHome() {
</div>
<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; onOpenWorkers()">Workers</button>
</div>
</div>
<button class="header-btn" @click="chatOpen = !chatOpen" :title="chatOpen ? 'Close chat' : 'Open chat'">
💬
</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>
</header>
<div class="app-body">
@ -320,8 +366,11 @@ function goHome() {
/>
<main class="main-content">
<div v-if="error" class="error-banner" @click="error = ''">{{ error }}</div>
<WorkersView
v-if="showWorkers"
/>
<ObjBrowser
v-if="showObj"
v-else-if="showObj"
@close="onCloseObj"
/>
<KbEditor
@ -554,6 +603,32 @@ function goHome() {
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 {
width: var(--chat-sidebar-width, 360px);
flex-shrink: 0;

View 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 &amp; 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>

View 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>

View 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>

View File

@ -5,6 +5,7 @@ import PlanSection from './PlanSection.vue'
import ExecutionSection from './ExecutionSection.vue'
import CommentSection from './CommentSection.vue'
import TimerSection from './TimerSection.vue'
import FileBrowser from './FileBrowser.vue'
import { api } from '../api'
import { connectWs } from '../ws'
import type { Workflow, ExecutionLogEntry, PlanStepInfo, Comment, LlmCallLogEntry } from '../types'
@ -26,7 +27,7 @@ const llmCalls = ref<LlmCallLogEntry[]>([])
const quotes = ref<string[]>([])
const currentActivity = 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)
function addQuote(text: string) {
@ -184,6 +185,7 @@ async function onSubmitComment(text: string) {
<div class="right-panel">
<div class="tab-bar">
<button class="tab-btn" :class="{ active: rightTab === 'log' }" @click="rightTab = 'log'">日志</button>
<button class="tab-btn" :class="{ active: rightTab === 'files' }" @click="rightTab = 'files'">文件</button>
<button class="tab-btn" :class="{ active: rightTab === 'timers' }" @click="rightTab = 'timers'">定时任务</button>
</div>
<ExecutionSection
@ -198,6 +200,10 @@ async function onSubmitComment(text: string) {
:currentActivity="currentActivity"
@quote="addQuote"
/>
<FileBrowser
v-show="rightTab === 'files'"
:projectId="projectId"
/>
<TimerSection
v-show="rightTab === 'timers'"
:projectId="projectId"

182
worker/tori-worker.py Executable file
View 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()

View 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