From ce10295e891e5cb37c031cb7bde41c5e61af553b Mon Sep 17 00:00:00 2001 From: wuxu Date: Sun, 26 Apr 2026 12:46:58 +0800 Subject: [PATCH] =?UTF-8?q?add:=20=E7=A7=BB=E9=99=A4=E9=99=A4=E4=BA=86=20d?= =?UTF-8?q?esktop=20=E4=BB=A5=E5=A4=96=E7=9A=84=E6=89=80=E6=9C=89=E7=89=88?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 - src-tauri/Cargo.lock | 129 +- src-tauri/Cargo.toml | 9 - src-tauri/src/app_state.rs | 6 - src-tauri/src/bin/codeg_server.rs | 144 -- src-tauri/src/chat_channel/backends/lark.rs | 672 -------- src-tauri/src/chat_channel/backends/mod.rs | 65 - .../src/chat_channel/backends/telegram.rs | 360 ---- src-tauri/src/chat_channel/backends/weixin.rs | 798 --------- .../src/chat_channel/command_dispatcher.rs | 234 --- .../src/chat_channel/command_handlers.rs | 133 -- src-tauri/src/chat_channel/error.rs | 39 - .../src/chat_channel/event_subscriber.rs | 215 --- src-tauri/src/chat_channel/i18n.rs | 1466 ----------------- src-tauri/src/chat_channel/manager.rs | 339 ---- .../src/chat_channel/message_formatter.rs | 68 - src-tauri/src/chat_channel/mod.rs | 14 - src-tauri/src/chat_channel/scheduler.rs | 160 -- src-tauri/src/chat_channel/session_bridge.rs | 79 - .../src/chat_channel/session_commands.rs | 799 --------- .../chat_channel/session_event_subscriber.rs | 727 -------- src-tauri/src/chat_channel/traits.rs | 53 - src-tauri/src/chat_channel/types.rs | 155 -- src-tauri/src/commands/chat_channel.rs | 597 ------- src-tauri/src/commands/mod.rs | 2 - src-tauri/src/commands/project_boot.rs | 184 --- src-tauri/src/db/entities/chat_channel.rs | 31 - .../db/entities/chat_channel_message_log.rs | 33 - .../entities/chat_channel_sender_context.rs | 35 - src-tauri/src/db/entities/mod.rs | 3 - src-tauri/src/db/entities/prelude.rs | 3 - .../chat_channel_message_log_service.rs | 70 - .../src/db/service/chat_channel_service.rs | 99 -- src-tauri/src/db/service/mod.rs | 3 - .../src/db/service/sender_context_service.rs | 101 -- src-tauri/src/keyring_store.rs | 50 - src-tauri/src/lib.rs | 82 +- src-tauri/src/models/chat_channel.rs | 67 - src-tauri/src/models/mod.rs | 2 - src-tauri/src/web/handlers/chat_channel.rs | 274 --- src-tauri/src/web/handlers/mod.rs | 2 - src-tauri/src/web/handlers/project_boot.rs | 49 - src-tauri/src/web/mod.rs | 1 - src-tauri/src/web/router.rs | 106 -- src/app/commit/page.tsx | 140 -- src/app/login/page.tsx | 93 -- src/app/merge/page.tsx | 140 -- src/app/project-boot/page.tsx | 41 - src/app/push/layout.tsx | 8 - src/app/push/page.tsx | 135 -- src/app/settings/chat-channels/page.tsx | 5 - src/app/stash/page.tsx | 117 -- .../sidebar-conversation-list.tsx | 23 - .../layout/aux-panel-file-tree-tab.tsx | 33 - .../layout/aux-panel-git-changes-tab.tsx | 54 - .../layout/aux-panel-git-log-tab.tsx | 30 - src/components/layout/branch-dropdown.tsx | 92 +- src/components/layout/commit-dialog.tsx | 1319 --------------- src/components/layout/conflict-dialog.tsx | 258 --- src/components/layout/new-folder-dropdown.tsx | 22 +- src/components/layout/push-workspace.tsx | 724 -------- src/components/layout/stash-dialog.tsx | 117 -- src/components/layout/unstash-dialog.tsx | 539 ------ src/components/merge/conflict-parser.ts | 102 -- src/components/merge/merge-diff.ts | 355 ---- src/components/merge/merge-workspace.tsx | 311 ---- .../merge/three-pane-merge-editor.tsx | 769 --------- src/components/merge/use-sync-scroll.ts | 44 - .../project-boot/project-boot-workspace.tsx | 23 - .../project-boot/shadcn/constants.ts | 273 --- .../shadcn/create-project-dialog.tsx | 372 ----- .../shadcn/shadcn-config-panel.tsx | 162 -- .../project-boot/shadcn/shadcn-launcher.tsx | 103 -- .../project-boot/shadcn/shadcn-preview.tsx | 51 - .../settings/add-chat-channel-dialog.tsx | 254 --- .../settings/channel-commands-tab.tsx | 127 -- .../settings/channel-events-tab.tsx | 99 -- src/components/settings/channel-list-tab.tsx | 418 ----- src/components/settings/channel-other-tab.tsx | 91 - .../settings/chat-channel-settings.tsx | 48 - .../settings/edit-chat-channel-dialog.tsx | 231 --- src/components/settings/settings-shell.tsx | 22 +- .../settings/weixin-qrcode-dialog.tsx | 214 --- src/lib/transport/detect.ts | 9 +- src/lib/transport/index.ts | 24 +- src/lib/transport/web-transport.ts | 127 -- src/lib/unified-diff-generator.ts | 88 +- 87 files changed, 114 insertions(+), 16553 deletions(-) delete mode 100644 src-tauri/src/bin/codeg_server.rs delete mode 100644 src-tauri/src/chat_channel/backends/lark.rs delete mode 100644 src-tauri/src/chat_channel/backends/mod.rs delete mode 100644 src-tauri/src/chat_channel/backends/telegram.rs delete mode 100644 src-tauri/src/chat_channel/backends/weixin.rs delete mode 100644 src-tauri/src/chat_channel/command_dispatcher.rs delete mode 100644 src-tauri/src/chat_channel/command_handlers.rs delete mode 100644 src-tauri/src/chat_channel/error.rs delete mode 100644 src-tauri/src/chat_channel/event_subscriber.rs delete mode 100644 src-tauri/src/chat_channel/i18n.rs delete mode 100644 src-tauri/src/chat_channel/manager.rs delete mode 100644 src-tauri/src/chat_channel/message_formatter.rs delete mode 100644 src-tauri/src/chat_channel/mod.rs delete mode 100644 src-tauri/src/chat_channel/scheduler.rs delete mode 100644 src-tauri/src/chat_channel/session_bridge.rs delete mode 100644 src-tauri/src/chat_channel/session_commands.rs delete mode 100644 src-tauri/src/chat_channel/session_event_subscriber.rs delete mode 100644 src-tauri/src/chat_channel/traits.rs delete mode 100644 src-tauri/src/chat_channel/types.rs delete mode 100644 src-tauri/src/commands/chat_channel.rs delete mode 100644 src-tauri/src/commands/project_boot.rs delete mode 100644 src-tauri/src/db/entities/chat_channel.rs delete mode 100644 src-tauri/src/db/entities/chat_channel_message_log.rs delete mode 100644 src-tauri/src/db/entities/chat_channel_sender_context.rs delete mode 100644 src-tauri/src/db/service/chat_channel_message_log_service.rs delete mode 100644 src-tauri/src/db/service/chat_channel_service.rs delete mode 100644 src-tauri/src/db/service/sender_context_service.rs delete mode 100644 src-tauri/src/models/chat_channel.rs delete mode 100644 src-tauri/src/web/handlers/chat_channel.rs delete mode 100644 src-tauri/src/web/handlers/project_boot.rs delete mode 100644 src/app/commit/page.tsx delete mode 100644 src/app/login/page.tsx delete mode 100644 src/app/merge/page.tsx delete mode 100644 src/app/project-boot/page.tsx delete mode 100644 src/app/push/layout.tsx delete mode 100644 src/app/push/page.tsx delete mode 100644 src/app/settings/chat-channels/page.tsx delete mode 100644 src/app/stash/page.tsx delete mode 100644 src/components/layout/commit-dialog.tsx delete mode 100644 src/components/layout/conflict-dialog.tsx delete mode 100644 src/components/layout/push-workspace.tsx delete mode 100644 src/components/layout/stash-dialog.tsx delete mode 100644 src/components/layout/unstash-dialog.tsx delete mode 100644 src/components/merge/conflict-parser.ts delete mode 100644 src/components/merge/merge-diff.ts delete mode 100644 src/components/merge/merge-workspace.tsx delete mode 100644 src/components/merge/three-pane-merge-editor.tsx delete mode 100644 src/components/merge/use-sync-scroll.ts delete mode 100644 src/components/project-boot/project-boot-workspace.tsx delete mode 100644 src/components/project-boot/shadcn/constants.ts delete mode 100644 src/components/project-boot/shadcn/create-project-dialog.tsx delete mode 100644 src/components/project-boot/shadcn/shadcn-config-panel.tsx delete mode 100644 src/components/project-boot/shadcn/shadcn-launcher.tsx delete mode 100644 src/components/project-boot/shadcn/shadcn-preview.tsx delete mode 100644 src/components/settings/add-chat-channel-dialog.tsx delete mode 100644 src/components/settings/channel-commands-tab.tsx delete mode 100644 src/components/settings/channel-events-tab.tsx delete mode 100644 src/components/settings/channel-list-tab.tsx delete mode 100644 src/components/settings/channel-other-tab.tsx delete mode 100644 src/components/settings/chat-channel-settings.tsx delete mode 100644 src/components/settings/edit-chat-channel-dialog.tsx delete mode 100644 src/components/settings/weixin-qrcode-dialog.tsx delete mode 100644 src/lib/transport/web-transport.ts diff --git a/package.json b/package.json index 4413207..2db845c 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,6 @@ "build": "next build", "lint": "eslint", "tauri": "tauri", - "server:build": "cd src-tauri && cargo build --release --bin codeg-server --no-default-features", - "server:dev": "cd src-tauri && cargo run --bin codeg-server --no-default-features", "postinstall": "node -e \"const fs=require('fs');fs.cpSync('node_modules/monaco-editor/min/vs','public/vs',{recursive:true,force:true});const p='public/vs/loader.js';fs.writeFileSync(p,fs.readFileSync(p,'utf8').replace(/\\n\\/\\/# sourceMappingURL=.*/,''))\"" }, "dependencies": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 87ce5a4..0d9d596 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -386,7 +386,7 @@ dependencies = [ "sha1", "sync_wrapper", "tokio", - "tokio-tungstenite 0.28.0", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -615,12 +615,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" -[[package]] -name = "byteorder-lite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" - [[package]] name = "bytes" version = "1.11.1" @@ -866,7 +860,6 @@ dependencies = [ "flate2", "futures", "futures-util", - "image", "include_dir", "junction", "keyring", @@ -874,9 +867,6 @@ dependencies = [ "mac-notification-sys", "notify", "portable-pty", - "prost", - "qrcode", - "rand 0.8.5", "regex", "reqwest 0.12.28", "sacp", @@ -897,7 +887,6 @@ dependencies = [ "tauri-plugin-window-state", "thiserror 2.0.18", "tokio", - "tokio-tungstenite 0.26.2", "toml 0.8.2", "tower-http", "urlencoding", @@ -2476,7 +2465,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" dependencies = [ "byteorder", - "png 0.17.16", + "png", ] [[package]] @@ -2587,19 +2576,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "image" -version = "0.25.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" -dependencies = [ - "bytemuck", - "byteorder-lite", - "moxcms", - "num-traits", - "png 0.18.1", -] - [[package]] name = "include_dir" version = "0.7.4" @@ -3249,16 +3225,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "moxcms" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" -dependencies = [ - "num-traits", - "pxfm", -] - [[package]] name = "muda" version = "0.17.1" @@ -3274,7 +3240,7 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation", "once_cell", - "png 0.17.16", + "png", "serde", "thiserror 2.0.18", "windows-sys 0.60.2", @@ -4182,19 +4148,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "png" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" -dependencies = [ - "bitflags 2.10.0", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - [[package]] name = "polling" version = "3.11.0" @@ -4373,29 +4326,6 @@ dependencies = [ "yansi", ] -[[package]] -name = "prost" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" -dependencies = [ - "bytes", - "prost-derive", -] - -[[package]] -name = "prost-derive" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" -dependencies = [ - "anyhow", - "itertools", - "proc-macro2", - "quote", - "syn 2.0.114", -] - [[package]] name = "ptr_meta" version = "0.1.4" @@ -4416,21 +4346,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "pxfm" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" - -[[package]] -name = "qrcode" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" -dependencies = [ - "image", -] - [[package]] name = "quick-xml" version = "0.37.5" @@ -6360,7 +6275,7 @@ dependencies = [ "ico", "json-patch", "plist", - "png 0.17.16", + "png", "proc-macro2", "quote", "semver", @@ -6856,20 +6771,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-tungstenite" -version = "0.26.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" -dependencies = [ - "futures-util", - "log", - "native-tls", - "tokio", - "tokio-native-tls", - "tungstenite 0.26.2", -] - [[package]] name = "tokio-tungstenite" version = "0.28.0" @@ -6879,7 +6780,7 @@ dependencies = [ "futures-util", "log", "tokio", - "tungstenite 0.28.0", + "tungstenite", ] [[package]] @@ -7111,7 +7012,7 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation", "once_cell", - "png 0.17.16", + "png", "serde", "thiserror 2.0.18", "windows-sys 0.60.2", @@ -7123,24 +7024,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "tungstenite" -version = "0.26.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "native-tls", - "rand 0.9.2", - "sha1", - "thiserror 2.0.18", - "utf-8", -] - [[package]] name = "tungstenite" version = "0.28.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 31a16a1..4f81b5f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -34,10 +34,6 @@ tauri-runtime = [ name = "codeg" path = "src/main.rs" -[[bin]] -name = "codeg-server" -path = "src/bin/codeg_server.rs" -required-features = [] [build-dependencies] tauri-build = { version = "2", features = [], optional = true } @@ -78,12 +74,7 @@ which = "7" keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"], optional = true } axum = { version = "0.8", features = ["ws"] } tower-http = { version = "0.6", features = ["fs", "cors"] } -tokio-tungstenite = { version = "0.26", features = ["native-tls"] } futures-util = "0.3" -prost = "0.13" -rand = "0.8" -qrcode = "0.14" -image = { version = "0.25", default-features = false, features = ["png"] } include_dir = "0.7" sha2 = "0.10" diff --git a/src-tauri/src/app_state.rs b/src-tauri/src/app_state.rs index e9ddfc8..fc241e1 100644 --- a/src-tauri/src/app_state.rs +++ b/src-tauri/src/app_state.rs @@ -2,7 +2,6 @@ use std::path::PathBuf; use std::sync::Arc; use crate::acp::manager::ConnectionManager; -use crate::chat_channel::manager::ChatChannelManager; use crate::db::AppDatabase; use crate::terminal::manager::TerminalManager; use crate::web::event_bridge::{EventEmitter, WebEventBroadcaster}; @@ -16,7 +15,6 @@ pub struct AppState { pub emitter: EventEmitter, pub data_dir: PathBuf, pub web_server_state: WebServerState, - pub chat_channel_manager: ChatChannelManager, } pub fn default_connection_manager() -> ConnectionManager { @@ -26,7 +24,3 @@ pub fn default_connection_manager() -> ConnectionManager { pub fn default_terminal_manager() -> TerminalManager { TerminalManager::new() } - -pub fn default_chat_channel_manager() -> ChatChannelManager { - ChatChannelManager::new() -} diff --git a/src-tauri/src/bin/codeg_server.rs b/src-tauri/src/bin/codeg_server.rs deleted file mode 100644 index e6f9885..0000000 --- a/src-tauri/src/bin/codeg_server.rs +++ /dev/null @@ -1,144 +0,0 @@ -use std::path::PathBuf; -use std::sync::Arc; - -use codeg_lib::app_state::AppState; -use codeg_lib::web::event_bridge::{EventEmitter, WebEventBroadcaster}; -use codeg_lib::web::{ - find_static_dir_standalone, generate_random_token, get_local_addresses, WebServerState, -}; - -fn main() { - // Support --version flag - let args: Vec = std::env::args().collect(); - if args.iter().any(|a| a == "--version" || a == "-V") { - println!("{}", env!("CARGO_PKG_VERSION")); - return; - } - - // PATH initialisation MUST happen before the tokio runtime is created. - // std::env::set_var is not thread-safe (unsafe in Rust edition 2024); - // #[tokio::main] would spawn worker threads before we reach this point. - codeg_lib::process::ensure_node_in_path(); - codeg_lib::process::ensure_user_npm_prefix_in_path(); - - tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .expect("Failed to build tokio runtime") - .block_on(async_main()); -} - -async fn async_main() { - // Sweep stale ACP binary cache trash (rename-aside fallback artifacts). - // Detached OS thread: cannot block startup, panics are caught and dropped, - // errors are silenced, no subprocesses spawned. - std::thread::spawn(|| { - let _ = std::panic::catch_unwind(|| { - codeg_lib::sweep_acp_binary_trash(); - }); - }); - - let port: u16 = std::env::var("CODEG_PORT") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(3080); - let host = std::env::var("CODEG_HOST").unwrap_or_else(|_| "0.0.0.0".to_string()); - let token = std::env::var("CODEG_TOKEN").unwrap_or_else(|_| generate_random_token()); - let data_dir = std::env::var("CODEG_DATA_DIR") - .map(PathBuf::from) - .unwrap_or_else(|_| default_data_dir()); - let static_dir_env = std::env::var("CODEG_STATIC_DIR").ok(); - - let static_dir = find_static_dir_standalone(static_dir_env.as_deref()); - let app_version = env!("CARGO_PKG_VERSION"); - - eprintln!("[SERVER] codeg-server v{}", app_version); - eprintln!("[SERVER] Data directory: {}", data_dir.display()); - eprintln!("[SERVER] Static directory: {}", static_dir.display()); - - // Initialize database - let db = codeg_lib::db::init_database(&data_dir, app_version) - .await - .expect("Failed to initialize database"); - - // Create shared broadcaster - let broadcaster = Arc::new(WebEventBroadcaster::new()); - let emitter = EventEmitter::WebOnly(broadcaster.clone()); - - // Build AppState - let state = Arc::new(AppState { - db, - connection_manager: codeg_lib::app_state::default_connection_manager(), - terminal_manager: codeg_lib::app_state::default_terminal_manager(), - event_broadcaster: broadcaster, - emitter, - data_dir, - web_server_state: WebServerState::new(), - chat_channel_manager: codeg_lib::app_state::default_chat_channel_manager(), - }); - - // Install bundled expert skills into the central store - // (`~/.codeg/skills/`). Runs in the background; failures are logged - // but non-fatal. - tokio::spawn(async move { - let report = codeg_lib::commands::experts::ensure_central_experts_installed().await; - if !report.errors.is_empty() { - eprintln!( - "[Experts] install finished with {} error(s): {:?}", - report.errors.len(), - report.errors - ); - } else { - eprintln!( - "[Experts] install ok: installed={} updated={} pending_review={}", - report.installed_count, - report.updated_count, - report.pending_user_review.len() - ); - } - }); - - // Start chat channel background tasks (event subscriber, command dispatcher, scheduler, auto-connect) - state - .chat_channel_manager - .start_background( - state.event_broadcaster.clone(), - state.db.conn.clone(), - state.connection_manager.clone_ref(), - state.emitter.clone(), - ) - .await; - - // Build router - let router = codeg_lib::web::router::build_router(state, token.clone(), static_dir); - - // Bind - let addr = format!("{}:{}", host, port); - let listener = tokio::net::TcpListener::bind(&addr) - .await - .unwrap_or_else(|e| { - eprintln!("[SERVER] Failed to bind {}: {}", addr, e); - std::process::exit(1); - }); - - let actual_port = listener.local_addr().map(|a| a.port()).unwrap_or(port); - let addresses = get_local_addresses(actual_port); - - eprintln!("[SERVER] Token: {}", token); - eprintln!("[SERVER] Listening on:"); - for addr in &addresses { - eprintln!(" {}", addr); - } - - // Start serving - if let Err(e) = axum::serve(listener, router).await { - eprintln!("[SERVER] Server error: {}", e); - std::process::exit(1); - } -} - -fn default_data_dir() -> PathBuf { - dirs::data_dir() - .map(|d| d.join("codeg")) - .unwrap_or_else(|| PathBuf::from(".codeg-data")) -} diff --git a/src-tauri/src/chat_channel/backends/lark.rs b/src-tauri/src/chat_channel/backends/lark.rs deleted file mode 100644 index 7dd4c41..0000000 --- a/src-tauri/src/chat_channel/backends/lark.rs +++ /dev/null @@ -1,672 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use async_trait::async_trait; -use futures_util::{SinkExt, StreamExt}; -use prost::Message as ProstMessage; -use serde::{Deserialize, Serialize}; -use tokio::sync::{mpsc, Mutex, RwLock}; -use tokio_tungstenite::tungstenite; - -use crate::chat_channel::error::ChatChannelError; -use crate::chat_channel::traits::ChatChannelBackend; -use crate::chat_channel::types::*; - -const FEISHU_BASE_URL: &str = "https://open.feishu.cn"; -const TOKEN_REFRESH_MARGIN_SECS: u64 = 300; - -// ── Lark WebSocket protobuf Frame (pbbp2) ── -// Source: larksuite/oapi-sdk-go ws/pbbp2.pb.go - -const FRAME_METHOD_CONTROL: i32 = 0; // Ping/Pong -const FRAME_METHOD_DATA: i32 = 1; // Event/Card - -#[derive(Clone, PartialEq, ProstMessage)] -struct Frame { - #[prost(uint64, tag = 1)] - seq_id: u64, - #[prost(uint64, tag = 2)] - log_id: u64, - #[prost(int32, tag = 3)] - service: i32, - #[prost(int32, tag = 4)] - method: i32, - #[prost(message, repeated, tag = 5)] - headers: Vec, - #[prost(string, tag = 6)] - payload_encoding: String, - #[prost(string, tag = 7)] - payload_type: String, - #[prost(bytes = "vec", tag = 8)] - payload: Vec, - #[prost(string, tag = 9)] - log_id_new: String, -} - -#[derive(Clone, PartialEq, ProstMessage)] -struct FrameHeader { - #[prost(string, tag = 1)] - key: String, - #[prost(string, tag = 2)] - value: String, -} - -impl Frame { - fn get_header(&self, key: &str) -> Option<&str> { - self.headers - .iter() - .find(|h| h.key == key) - .map(|h| h.value.as_str()) - } - - fn set_header(&mut self, key: &str, value: &str) { - if let Some(h) = self.headers.iter_mut().find(|h| h.key == key) { - h.value = value.to_string(); - } else { - self.headers.push(FrameHeader { - key: key.to_string(), - value: value.to_string(), - }); - } - } -} - -// ── Lark REST API types ── - -#[derive(Deserialize)] -struct TenantAccessTokenResponse { - code: i32, - msg: String, - tenant_access_token: Option, - expire: Option, -} - -#[derive(Serialize)] -struct SendMessageRequest { - receive_id: String, - msg_type: String, - content: String, -} - -#[derive(Deserialize)] -struct SendMessageResponse { - code: i32, - msg: String, - data: Option, -} - -#[derive(Deserialize)] -struct SendMessageData { - message_id: Option, -} - -#[derive(Deserialize)] -struct WsConnectResponse { - code: i32, - msg: String, - data: Option, -} - -#[derive(Deserialize)] -struct WsConnectData { - #[serde(rename = "URL")] - url: Option, -} - -// ── Token cache ── - -struct TokenCache { - token: String, - expires_at: Instant, -} - -// ── Multi-part frame cache ── - -struct PartialMessage { - parts: HashMap>, - total: i32, - created_at: Instant, -} - -/// TTL for partial message reassembly entries. Prevents unbounded memory growth -/// if a multi-part message never completes (network issue, Lark SDK bug, etc). -const PARTIAL_MSG_TTL_SECS: u64 = 60; - -// ── LarkBackend ── - -pub struct LarkBackend { - app_id: String, - app_secret: String, - chat_id: String, - channel_id: i32, - client: reqwest::Client, - token_cache: Arc>>, - status: Arc>, - shutdown_tx: Arc>>>, -} - -impl LarkBackend { - pub fn new(channel_id: i32, app_id: String, app_secret: String, chat_id: String) -> Self { - Self { - app_id, - app_secret, - chat_id, - channel_id, - client: reqwest::Client::builder() - .connect_timeout(Duration::from_secs(10)) - .timeout(Duration::from_secs(30)) - .build() - .unwrap_or_default(), - token_cache: Arc::new(RwLock::new(None)), - status: Arc::new(Mutex::new(ChannelConnectionStatus::Disconnected)), - shutdown_tx: Arc::new(Mutex::new(None)), - } - } - - async fn get_tenant_access_token(&self) -> Result { - { - let cache = self.token_cache.read().await; - if let Some(cached) = cache.as_ref() { - if cached.expires_at > Instant::now() { - return Ok(cached.token.clone()); - } - } - } - - let resp = self - .client - .post(format!( - "{}/open-apis/auth/v3/tenant_access_token/internal", - FEISHU_BASE_URL - )) - .json(&serde_json::json!({ - "app_id": self.app_id, - "app_secret": self.app_secret, - })) - .send() - .await - .map_err(|e| ChatChannelError::AuthenticationFailed(e.to_string()))?; - - let result: TenantAccessTokenResponse = resp - .json() - .await - .map_err(|e| ChatChannelError::AuthenticationFailed(e.to_string()))?; - - if result.code != 0 { - return Err(ChatChannelError::AuthenticationFailed(format!( - "code={}, msg={}", - result.code, result.msg - ))); - } - - let token = result - .tenant_access_token - .ok_or_else(|| ChatChannelError::AuthenticationFailed("No token in response".into()))?; - let expire_secs = result.expire.unwrap_or(7200); - - let expires_at = Instant::now() - + Duration::from_secs(expire_secs.saturating_sub(TOKEN_REFRESH_MARGIN_SECS)); - *self.token_cache.write().await = Some(TokenCache { - token: token.clone(), - expires_at, - }); - - Ok(token) - } - - async fn send_lark_message( - &self, - msg_type: &str, - content: &str, - ) -> Result { - let token = self.get_tenant_access_token().await?; - - let resp = self - .client - .post(format!( - "{}/open-apis/im/v1/messages?receive_id_type=chat_id", - FEISHU_BASE_URL - )) - .header("Authorization", format!("Bearer {}", token)) - .json(&SendMessageRequest { - receive_id: self.chat_id.clone(), - msg_type: msg_type.to_string(), - content: content.to_string(), - }) - .send() - .await - .map_err(|e| ChatChannelError::SendFailed(e.to_string()))?; - - let result: SendMessageResponse = resp - .json() - .await - .map_err(|e| ChatChannelError::SendFailed(e.to_string()))?; - - if result.code != 0 { - return Err(ChatChannelError::SendFailed(format!( - "code={}, msg={}", - result.code, result.msg - ))); - } - - let message_id = result.data.and_then(|d| d.message_id).unwrap_or_default(); - Ok(SentMessageId(message_id)) - } - - async fn start_ws_receiver( - &self, - command_tx: mpsc::Sender, - ) -> Result<(), ChatChannelError> { - // Verify we can get a WS URL before spawning the background task - let _ = fetch_ws_url(&self.client, &self.app_id, &self.app_secret).await?; - - let (shutdown_tx, mut shutdown_rx) = tokio::sync::watch::channel(false); - *self.shutdown_tx.lock().await = Some(shutdown_tx); - - let channel_id = self.channel_id; - let status = self.status.clone(); - let app_id = self.app_id.clone(); - let app_secret = self.app_secret.clone(); - let client = self.client.clone(); - - tokio::spawn(async move { - let mut retry_count = 0u32; - - loop { - if *shutdown_rx.borrow() { - break; - } - - let ws_url = match fetch_ws_url(&client, &app_id, &app_secret).await { - Ok(url) => url, - Err(e) => { - eprintln!("[Lark] failed to get WS endpoint: {e}"); - *status.lock().await = ChannelConnectionStatus::Error; - let delay = Duration::from_secs((2u64).pow(retry_count.min(5))); - retry_count += 1; - tokio::select! { - _ = tokio::time::sleep(delay) => continue, - _ = shutdown_rx.changed() => break, - } - } - }; - - let ws_result = tokio_tungstenite::connect_async(&ws_url).await; - let ws_stream = match ws_result { - Ok((stream, _)) => { - *status.lock().await = ChannelConnectionStatus::Connected; - retry_count = 0; - eprintln!("[Lark] WebSocket connected"); - stream - } - Err(e) => { - eprintln!("[Lark] WebSocket connect failed: {e}"); - *status.lock().await = ChannelConnectionStatus::Error; - let delay = Duration::from_secs((2u64).pow(retry_count.min(5))); - retry_count += 1; - tokio::select! { - _ = tokio::time::sleep(delay) => continue, - _ = shutdown_rx.changed() => break, - } - } - }; - - let (mut write, mut read) = ws_stream.split(); - let mut partial_msgs: HashMap = HashMap::new(); - let mut last_partial_cleanup = Instant::now(); - - loop { - tokio::select! { - msg = read.next() => { - match msg { - Some(Ok(tungstenite::Message::Binary(data))) => { - match Frame::decode(data.as_ref()) { - Ok(frame) => { - let frame_type = frame.get_header("type").unwrap_or("").to_string(); - - if frame.method == FRAME_METHOD_CONTROL { - // Control frame: ping → respond with pong - if frame_type == "ping" { - let mut pong = frame.clone(); - // Clear type header and set to pong - pong.set_header("type", "pong"); - pong.payload = Vec::new(); - let mut buf = Vec::new(); - if pong.encode(&mut buf).is_ok() { - let _ = write.send(tungstenite::Message::Binary(buf.into())).await; - } - } - } else if frame.method == FRAME_METHOD_DATA && frame_type == "event" { - let start = Instant::now(); - - // Multi-part reassembly - let msg_id = frame.get_header("message_id").unwrap_or("").to_string(); - let sum: i32 = frame.get_header("sum").and_then(|s| s.parse().ok()).unwrap_or(1); - let seq: i32 = frame.get_header("seq").and_then(|s| s.parse().ok()).unwrap_or(0); - - // Evict stale partial messages to prevent unbounded memory growth - if last_partial_cleanup.elapsed() > Duration::from_secs(PARTIAL_MSG_TTL_SECS) { - partial_msgs.retain(|_, pm| pm.created_at.elapsed() < Duration::from_secs(PARTIAL_MSG_TTL_SECS)); - last_partial_cleanup = Instant::now(); - } - - let full_payload = if sum <= 1 { - Some(frame.payload.clone()) - } else { - let entry = partial_msgs.entry(msg_id.clone()).or_insert_with(|| PartialMessage { - parts: HashMap::new(), - total: sum, - created_at: Instant::now(), - }); - entry.parts.insert(seq, frame.payload.clone()); - if entry.parts.len() as i32 >= entry.total { - // All parts received — reassemble in order - let mut combined = Vec::new(); - for i in 0..entry.total { - if let Some(part) = entry.parts.get(&i) { - combined.extend_from_slice(part); - } - } - partial_msgs.remove(&msg_id); - Some(combined) - } else { - None // Still waiting for more parts - } - }; - - if let Some(payload_bytes) = full_payload { - // Process event - if let Ok(payload_str) = std::str::from_utf8(&payload_bytes) { - if let Ok(event) = serde_json::from_str::(payload_str) { - handle_lark_event(&event, channel_id, &command_tx).await; - } else { - eprintln!("[Lark] event payload is not valid JSON"); - } - } - - // Send acknowledgment: echo frame back with {"code":200} - let elapsed_ms = start.elapsed().as_millis(); - let mut ack = frame.clone(); - ack.payload = br#"{"code":200}"#.to_vec(); - ack.set_header("biz_rt", &elapsed_ms.to_string()); - let mut buf = Vec::new(); - if ack.encode(&mut buf).is_ok() { - let _ = write.send(tungstenite::Message::Binary(buf.into())).await; - } - } - } - } - Err(e) => { - eprintln!("[Lark] protobuf decode error: {e}, len={}", data.len()); - } - } - } - Some(Ok(tungstenite::Message::Ping(data))) => { - let _ = write.send(tungstenite::Message::Pong(data)).await; - } - Some(Ok(tungstenite::Message::Close(_))) | None => { - eprintln!("[Lark] WebSocket closed, will reconnect"); - break; - } - Some(Err(e)) => { - eprintln!("[Lark] WebSocket error: {e}"); - break; - } - _ => {} - } - } - _ = shutdown_rx.changed() => { - let _ = write.close().await; - *status.lock().await = ChannelConnectionStatus::Disconnected; - return; - } - } - } - - *status.lock().await = ChannelConnectionStatus::Connecting; - let delay = Duration::from_secs(3); - tokio::select! { - _ = tokio::time::sleep(delay) => {}, - _ = shutdown_rx.changed() => break, - } - } - - *status.lock().await = ChannelConnectionStatus::Disconnected; - }); - - Ok(()) - } -} - -async fn handle_lark_event( - event: &serde_json::Value, - channel_id: i32, - command_tx: &mpsc::Sender, -) { - let event_type = event - .pointer("/header/event_type") - .and_then(|v| v.as_str()) - .unwrap_or(""); - - if event_type == "im.message.receive_v1" { - let msg_type = event - .pointer("/event/message/message_type") - .and_then(|v| v.as_str()) - .unwrap_or(""); - - if msg_type != "text" { - return; - } - - // Group chat filtering: only process if bot is mentioned - let chat_type = event - .pointer("/event/message/chat_type") - .and_then(|v| v.as_str()) - .unwrap_or("p2p"); - - if chat_type == "group" { - let mentions = event - .pointer("/event/message/mentions") - .and_then(|v| v.as_array()); - if mentions.is_none() || mentions.unwrap().is_empty() { - return; // No mentions in group chat, ignore - } - } - - let content_str = event - .pointer("/event/message/content") - .and_then(|v| v.as_str()) - .unwrap_or(""); - - // Content is JSON string: {"text":"actual message"} - let text = serde_json::from_str::(content_str) - .ok() - .and_then(|v| v.get("text").and_then(|t| t.as_str()).map(String::from)) - .unwrap_or_default(); - - if text.is_empty() { - return; - } - - // Strip mention placeholders (e.g. "@_user_1") from text - let clean_text = strip_lark_mentions(&text, event); - - if clean_text.is_empty() { - return; - } - - let sender_id = event - .pointer("/event/sender/sender_id/open_id") - .and_then(|v| v.as_str()) - .unwrap_or("unknown") - .to_string(); - - eprintln!("[Lark] incoming message from {}: {}", sender_id, clean_text); - - let _ = command_tx - .send(IncomingCommand { - channel_id, - sender_id, - command_text: clean_text, - metadata: event.clone(), - }) - .await; - } -} - -/// Strip Lark mention placeholders (e.g. `@_user_1`) from the message text. -fn strip_lark_mentions(text: &str, event: &serde_json::Value) -> String { - let mut result = text.to_string(); - if let Some(mentions) = event - .pointer("/event/message/mentions") - .and_then(|v| v.as_array()) - { - for mention in mentions { - if let Some(key) = mention.get("key").and_then(|v| v.as_str()) { - result = result.replace(key, ""); - } - } - } - result.trim().to_string() -} - -/// Fetch a fresh WebSocket endpoint URL from Feishu. -async fn fetch_ws_url( - client: &reqwest::Client, - app_id: &str, - app_secret: &str, -) -> Result { - let resp = client - .post(format!("{}/callback/ws/endpoint", FEISHU_BASE_URL)) - .json(&serde_json::json!({ - "AppID": app_id, - "AppSecret": app_secret, - })) - .send() - .await - .map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?; - - let ws_resp: WsConnectResponse = resp - .json() - .await - .map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?; - - if ws_resp.code != 0 { - return Err(ChatChannelError::ConnectionFailed(format!( - "WS connect failed: code={}, msg={}", - ws_resp.code, ws_resp.msg - ))); - } - - ws_resp - .data - .and_then(|d| d.url) - .ok_or_else(|| ChatChannelError::ConnectionFailed("No WebSocket URL returned".into())) -} - -#[async_trait] -impl ChatChannelBackend for LarkBackend { - fn channel_type(&self) -> ChannelType { - ChannelType::Lark - } - - async fn start( - &self, - command_tx: mpsc::Sender, - ) -> Result<(), ChatChannelError> { - *self.status.lock().await = ChannelConnectionStatus::Connecting; - self.get_tenant_access_token().await?; - *self.status.lock().await = ChannelConnectionStatus::Connected; - - if let Err(e) = self.start_ws_receiver(command_tx).await { - eprintln!("[Lark] WebSocket receiver failed to start: {e}"); - } - - Ok(()) - } - - async fn stop(&self) -> Result<(), ChatChannelError> { - if let Some(tx) = self.shutdown_tx.lock().await.take() { - let _ = tx.send(true); - } - *self.status.lock().await = ChannelConnectionStatus::Disconnected; - Ok(()) - } - - async fn status(&self) -> ChannelConnectionStatus { - *self.status.lock().await - } - - async fn send_message(&self, text: &str) -> Result { - let content = serde_json::json!({ "text": text }).to_string(); - self.send_lark_message("text", &content).await - } - - async fn send_rich_message( - &self, - message: &RichMessage, - ) -> Result { - let card = build_lark_card(message); - let content = serde_json::to_string(&card) - .map_err(|e| ChatChannelError::SendFailed(e.to_string()))?; - self.send_lark_message("interactive", &content).await - } - - async fn test_connection(&self) -> Result<(), ChatChannelError> { - self.get_tenant_access_token().await?; - Ok(()) - } -} - -fn build_lark_card(msg: &RichMessage) -> serde_json::Value { - let header_color = match msg.level { - MessageLevel::Info => "blue", - MessageLevel::Warning => "orange", - MessageLevel::Error => "red", - }; - - let title = msg.title.as_deref().unwrap_or("Codeg"); - - let mut elements: Vec = Vec::new(); - - if !msg.body.is_empty() { - elements.push(serde_json::json!({ - "tag": "markdown", - "content": msg.body, - })); - } - - if !msg.fields.is_empty() { - let field_elements: Vec = msg - .fields - .iter() - .map(|(k, v)| { - serde_json::json!({ - "is_short": true, - "text": { - "tag": "lark_md", - "content": format!("**{}**\n{}", k, v), - } - }) - }) - .collect(); - - elements.push(serde_json::json!({ - "tag": "div", - "fields": field_elements, - })); - } - - serde_json::json!({ - "config": { "wide_screen_mode": true }, - "header": { - "title": { - "tag": "plain_text", - "content": title, - }, - "template": header_color, - }, - "elements": elements, - }) -} diff --git a/src-tauri/src/chat_channel/backends/mod.rs b/src-tauri/src/chat_channel/backends/mod.rs deleted file mode 100644 index cbf0a72..0000000 --- a/src-tauri/src/chat_channel/backends/mod.rs +++ /dev/null @@ -1,65 +0,0 @@ -pub mod lark; -pub mod telegram; -pub mod weixin; - -use super::error::ChatChannelError; -use super::traits::ChatChannelBackend; -use super::types::*; - -/// Factory function to create a backend instance from channel type, config, and token. -/// Eliminates duplicated match blocks across connect, test, and auto-connect paths. -pub fn create_backend( - channel_id: i32, - channel_type: ChannelType, - config: &serde_json::Value, - token: String, -) -> Result, ChatChannelError> { - match channel_type { - ChannelType::Telegram => { - let cfg: TelegramConfig = serde_json::from_value(config.clone()).map_err(|e| { - ChatChannelError::ConfigurationInvalid(format!("Invalid Telegram config: {e}")) - })?; - if cfg.chat_id.is_empty() { - return Err(ChatChannelError::ConfigurationInvalid( - "chat_id is required".into(), - )); - } - Ok(Box::new(telegram::TelegramBackend::new( - channel_id, - token, - cfg.chat_id, - ))) - } - ChannelType::Weixin => { - let cfg: WeixinConfig = serde_json::from_value(config.clone()).map_err(|e| { - ChatChannelError::ConfigurationInvalid(format!("Invalid Weixin config: {e}")) - })?; - if cfg.base_url.is_empty() { - return Err(ChatChannelError::ConfigurationInvalid( - "base_url is required".into(), - )); - } - Ok(Box::new(weixin::WeixinBackend::new( - channel_id, - token, - cfg.base_url, - ))) - } - ChannelType::Lark => { - let cfg: LarkConfig = serde_json::from_value(config.clone()).map_err(|e| { - ChatChannelError::ConfigurationInvalid(format!("Invalid Lark config: {e}")) - })?; - if cfg.app_id.is_empty() || cfg.chat_id.is_empty() { - return Err(ChatChannelError::ConfigurationInvalid( - "app_id and chat_id are required".into(), - )); - } - Ok(Box::new(lark::LarkBackend::new( - channel_id, - cfg.app_id, - token, - cfg.chat_id, - ))) - } - } -} diff --git a/src-tauri/src/chat_channel/backends/telegram.rs b/src-tauri/src/chat_channel/backends/telegram.rs deleted file mode 100644 index 2d763ef..0000000 --- a/src-tauri/src/chat_channel/backends/telegram.rs +++ /dev/null @@ -1,360 +0,0 @@ -use std::sync::Arc; -use std::time::Duration; - -use async_trait::async_trait; -use tokio::sync::{mpsc, Mutex}; - -use crate::chat_channel::error::ChatChannelError; -use crate::chat_channel::traits::ChatChannelBackend; -use crate::chat_channel::types::*; - -pub struct TelegramBackend { - bot_token: String, - chat_id: String, - client: reqwest::Client, - status: Arc>, - channel_id: i32, - shutdown_tx: Arc>>>, -} - -impl TelegramBackend { - pub fn new(channel_id: i32, bot_token: String, chat_id: String) -> Self { - Self { - bot_token, - chat_id, - client: reqwest::Client::builder() - .connect_timeout(Duration::from_secs(10)) - .timeout(Duration::from_secs(60)) - .build() - .unwrap_or_default(), - status: Arc::new(Mutex::new(ChannelConnectionStatus::Disconnected)), - channel_id, - shutdown_tx: Arc::new(Mutex::new(None)), - } - } - - fn api_url(&self, method: &str) -> String { - format!("https://api.telegram.org/bot{}/{}", self.bot_token, method) - } - - async fn send_text( - &self, - text: &str, - parse_mode: Option<&str>, - ) -> Result { - let mut body = serde_json::json!({ - "chat_id": self.chat_id, - "text": text, - }); - if let Some(mode) = parse_mode { - body["parse_mode"] = serde_json::Value::String(mode.to_string()); - } - - let resp = self - .client - .post(self.api_url("sendMessage")) - .json(&body) - .send() - .await - .map_err(|e| ChatChannelError::SendFailed(e.to_string()))?; - - let result: serde_json::Value = resp - .json() - .await - .map_err(|e| ChatChannelError::SendFailed(e.to_string()))?; - - if result.get("ok").and_then(|v| v.as_bool()) != Some(true) { - let desc = result - .get("description") - .and_then(|v| v.as_str()) - .unwrap_or("unknown error"); - return Err(ChatChannelError::SendFailed(desc.to_string())); - } - - let message_id = result - .pointer("/result/message_id") - .and_then(|v| v.as_i64()) - .map(|id| id.to_string()) - .unwrap_or_default(); - - Ok(SentMessageId(message_id)) - } -} - -#[async_trait] -impl ChatChannelBackend for TelegramBackend { - fn channel_type(&self) -> ChannelType { - ChannelType::Telegram - } - - async fn start( - &self, - command_tx: mpsc::Sender, - ) -> Result<(), ChatChannelError> { - *self.status.lock().await = ChannelConnectionStatus::Connecting; - - // Verify bot token and extract bot username for group @mention filtering - let resp = self - .client - .get(self.api_url("getMe")) - .send() - .await - .map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?; - - let me_body: serde_json::Value = resp - .json() - .await - .map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?; - - if me_body.get("ok").and_then(|v| v.as_bool()) != Some(true) { - *self.status.lock().await = ChannelConnectionStatus::Error; - return Err(ChatChannelError::AuthenticationFailed( - "Invalid bot token".to_string(), - )); - } - - let bot_username = me_body - .pointer("/result/username") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_lowercase(); - - *self.status.lock().await = ChannelConnectionStatus::Connected; - - // Start long-polling loop - let (shutdown_tx, mut shutdown_rx) = tokio::sync::watch::channel(false); - *self.shutdown_tx.lock().await = Some(shutdown_tx); - - let client = self.client.clone(); - let bot_token = self.bot_token.clone(); - let channel_id = self.channel_id; - let status = self.status.clone(); - - tokio::spawn(async move { - let mut offset: i64 = 0; - loop { - if *shutdown_rx.borrow() { - break; - } - - let url = format!( - "https://api.telegram.org/bot{}/getUpdates?timeout=30&offset={}", - bot_token, offset - ); - - let result = tokio::select! { - r = client.get(&url).send() => r, - _ = shutdown_rx.changed() => break, - }; - - match result { - Ok(resp) => { - // Recover from error state after successful poll - { - let mut s = status.lock().await; - if *s == ChannelConnectionStatus::Error { - *s = ChannelConnectionStatus::Connected; - } - } - - if let Ok(body) = resp.json::().await { - if let Some(updates) = body.get("result").and_then(|r| r.as_array()) { - if !updates.is_empty() { - eprintln!("[Telegram] got {} update(s)", updates.len()); - } - for update in updates { - if let Some(uid) = - update.get("update_id").and_then(|u| u.as_i64()) - { - offset = uid + 1; - } - if let Some(text) = - update.pointer("/message/text").and_then(|t| t.as_str()) - { - // Group chat filtering: only process if @bot is mentioned - let chat_type = update - .pointer("/message/chat/type") - .and_then(|v| v.as_str()) - .unwrap_or("private"); - - if (chat_type == "group" || chat_type == "supergroup") - && !bot_username.is_empty() - { - let at_bot = format!("@{}", bot_username); - if !text.to_lowercase().contains(&at_bot) { - eprintln!("[Telegram] skipped group msg without @bot: {text}"); - continue; - } - } - - // Strip @bot_username from command text (case-insensitive) - let clean_text = strip_bot_mention(text, &bot_username); - - let sender_id = update - .pointer("/message/from/id") - .and_then(|i| i.as_i64()) - .map(|i| i.to_string()) - .unwrap_or_default(); - eprintln!("[Telegram] dispatching: {clean_text}"); - let send_result = command_tx - .send(IncomingCommand { - channel_id, - sender_id, - command_text: clean_text, - metadata: update.clone(), - }) - .await; - if let Err(e) = send_result { - eprintln!("[Telegram] command_tx.send failed: {e}"); - } - } else { - eprintln!("[Telegram] update without /message/text"); - } - } - } - } else { - eprintln!("[Telegram] failed to parse response body"); - } - } - Err(e) => { - eprintln!("[Telegram] polling error: {e}"); - *status.lock().await = ChannelConnectionStatus::Error; - tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; - } - } - } - *status.lock().await = ChannelConnectionStatus::Disconnected; - }); - - Ok(()) - } - - async fn stop(&self) -> Result<(), ChatChannelError> { - if let Some(tx) = self.shutdown_tx.lock().await.take() { - let _ = tx.send(true); - } - *self.status.lock().await = ChannelConnectionStatus::Disconnected; - Ok(()) - } - - async fn status(&self) -> ChannelConnectionStatus { - *self.status.lock().await - } - - async fn send_message(&self, text: &str) -> Result { - self.send_text(text, None).await - } - - async fn send_rich_message( - &self, - message: &RichMessage, - ) -> Result { - let markdown_text = format_telegram_markdown(message); - let result = self.send_text(&markdown_text, Some("MarkdownV2")).await; - - match result { - Ok(id) => Ok(id), - Err(e) => { - // MarkdownV2 failed — fall back to plain text - eprintln!("[Telegram] MarkdownV2 send failed: {e}, retrying as plain text"); - let plain_text = message.to_plain_text(); - self.send_text(&plain_text, None).await - } - } - } - - async fn test_connection(&self) -> Result<(), ChatChannelError> { - let resp = self - .client - .get(self.api_url("getMe")) - .send() - .await - .map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?; - - let body: serde_json::Value = resp - .json() - .await - .map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?; - - if body.get("ok").and_then(|v| v.as_bool()) == Some(true) { - Ok(()) - } else { - let desc = body - .get("description") - .and_then(|v| v.as_str()) - .unwrap_or("Invalid bot token"); - Err(ChatChannelError::AuthenticationFailed(desc.to_string())) - } - } -} - -/// Strip `@bot_username` from text (case-insensitive). -/// Handles Telegram convention: `/command@botname args` → `/command args` -fn strip_bot_mention(text: &str, bot_username: &str) -> String { - if bot_username.is_empty() { - return text.to_string(); - } - let at_bot = format!("@{}", bot_username); - let text_lower = text.to_lowercase(); - let at_bot_lower = at_bot.to_lowercase(); - if let Some(pos) = text_lower.find(&at_bot_lower) { - let mut result = String::with_capacity(text.len()); - result.push_str(&text[..pos]); - result.push_str(&text[pos + at_bot.len()..]); - result.trim().to_string() - } else { - text.to_string() - } -} - -fn format_telegram_markdown(msg: &RichMessage) -> String { - let mut text = String::new(); - - let level_emoji = match msg.level { - MessageLevel::Info => "ℹ️", - MessageLevel::Warning => "⚠️", - MessageLevel::Error => "❌", - }; - - if let Some(title) = &msg.title { - text.push_str(&format!("{} *{}*\n", level_emoji, escape_markdown(title))); - } - - text.push_str(&escape_markdown(&msg.body)); - - if !msg.fields.is_empty() { - text.push('\n'); - for (key, value) in &msg.fields { - text.push_str(&format!( - "\n*{}*: {}", - escape_markdown(key), - escape_markdown(value) - )); - } - } - - text -} - -fn escape_markdown(text: &str) -> String { - // Backslash must be escaped first to avoid double-escaping - text.replace('\\', "\\\\") - .replace('_', "\\_") - .replace('*', "\\*") - .replace('[', "\\[") - .replace(']', "\\]") - .replace('(', "\\(") - .replace(')', "\\)") - .replace('~', "\\~") - .replace('`', "\\`") - .replace('>', "\\>") - .replace('#', "\\#") - .replace('+', "\\+") - .replace('-', "\\-") - .replace('=', "\\=") - .replace('|', "\\|") - .replace('{', "\\{") - .replace('}', "\\}") - .replace('.', "\\.") - .replace('!', "\\!") -} diff --git a/src-tauri/src/chat_channel/backends/weixin.rs b/src-tauri/src/chat_channel/backends/weixin.rs deleted file mode 100644 index cf68442..0000000 --- a/src-tauri/src/chat_channel/backends/weixin.rs +++ /dev/null @@ -1,798 +0,0 @@ -use std::sync::Arc; -use std::time::Duration; - -use async_trait::async_trait; -use base64::{engine::general_purpose::STANDARD as B64, Engine}; -use rand::Rng; -use reqwest::header::{HeaderMap, HeaderValue}; -use serde::{Deserialize, Serialize}; -use tokio::sync::{mpsc, Mutex}; - -use crate::chat_channel::error::ChatChannelError; -use crate::chat_channel::traits::ChatChannelBackend; -use crate::chat_channel::types::*; - -const ILINK_BASE_URL: &str = "https://ilinkai.weixin.qq.com"; -const ILINK_CHANNEL_VERSION: &str = "1.0.2"; -/// Maximum number of messages buffered while context_token is expired. -const MAX_PENDING_MESSAGES: usize = 50; - -/// Shared HTTP client for QR code auth requests (avoids re-creating TLS state). -fn qr_client() -> reqwest::Client { - use std::sync::OnceLock; - static CLIENT: OnceLock = OnceLock::new(); - CLIENT - .get_or_init(|| { - reqwest::Client::builder() - .connect_timeout(Duration::from_secs(10)) - .timeout(Duration::from_secs(15)) - .build() - .unwrap_or_default() - }) - .clone() -} - -// ── QR code auth types (public, used by commands) ── - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WeixinQrcodeInfo { - pub qrcode_id: String, - pub qrcode_img_content: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WeixinQrcodeStatus { - pub status: String, - /// bot_token and base_url are consumed by the _core command layer and - /// stripped before the response reaches the frontend. - #[serde(skip_serializing_if = "Option::is_none")] - pub bot_token: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub base_url: Option, -} - -/// Frontend-safe subset of [`WeixinQrcodeStatus`] — no credentials. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WeixinQrcodeStatusPublic { - pub status: String, -} - -struct SendRequest<'a> { - client: &'a reqwest::Client, - base_url: &'a str, - bot_token: &'a str, - wechat_uin: &'a str, - to_user_id: &'a str, - context_token: &'a str, - text: &'a str, - reply_context: &'a Mutex>, - pending_messages: &'a Mutex>, -} - -// ── QR code auth functions (called before backend exists) ── - -pub async fn weixin_get_qrcode() -> Result { - let client = qr_client(); - let resp = client - .get(format!("{ILINK_BASE_URL}/ilink/bot/get_bot_qrcode")) - .query(&[("bot_type", "3")]) - .send() - .await - .map_err(|e| ChatChannelError::ConnectionFailed(format!("QR code request failed: {e}")))?; - - let body: serde_json::Value = resp - .json() - .await - .map_err(|e| ChatChannelError::ConnectionFailed(format!("QR code parse failed: {e}")))?; - - let qrcode_id = body - .get("qrcode") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_string(); - let raw_img = body - .get("qrcode_img_content") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_string(); - - if qrcode_id.is_empty() { - return Err(ChatChannelError::ConnectionFailed( - "Empty qrcode in response".into(), - )); - } - - // If the image content is a URL, try to fetch the actual image bytes. - // If the URL points to an HTML SPA (which renders the QR code via JS), - // generate the QR code ourselves — the SPA simply encodes the page URL. - let qrcode_img_content = if raw_img.starts_with("http://") || raw_img.starts_with("https://") { - match fetch_image_as_data_uri(&client, &raw_img).await { - Ok(data_uri) => data_uri, - Err(_) => { - eprintln!("[Weixin] URL is an SPA page, generating QR code from URL"); - generate_qrcode_data_uri(&raw_img)? - } - } - } else { - raw_img - }; - - Ok(WeixinQrcodeInfo { - qrcode_id, - qrcode_img_content, - }) -} - -/// Fetch an image from a URL and return it as a `data:;base64,...` string. -/// -/// Returns an error if the URL points to an HTML page (SPA) rather than a -/// raw image — the caller will generate a QR code from the URL instead. -async fn fetch_image_as_data_uri( - client: &reqwest::Client, - url: &str, -) -> Result { - let resp = client - .get(url) - .header( - reqwest::header::USER_AGENT, - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", - ) - .header(reqwest::header::REFERER, ILINK_BASE_URL) - .send() - .await - .map_err(|e| ChatChannelError::ConnectionFailed(format!("Image fetch failed: {e}")))?; - - let content_type = resp - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or("image/png") - .to_string(); - - if content_type.contains("text/html") || content_type.contains("text/plain") { - return Err(ChatChannelError::ConnectionFailed( - "QR code URL is an SPA page".into(), - )); - } - - let bytes = resp - .bytes() - .await - .map_err(|e| ChatChannelError::ConnectionFailed(format!("Image read failed: {e}")))?; - - if bytes.is_empty() { - return Err(ChatChannelError::ConnectionFailed( - "Empty image response".into(), - )); - } - let b64 = B64.encode(&bytes); - let mime = content_type.split(';').next().unwrap_or("image/png").trim(); - Ok(format!("data:{mime};base64,{b64}")) -} - -/// Generate a QR code image encoding the given text and return as a PNG data URI. -/// -/// The iLink QR page is a SPA that renders `window.location.href` as a QR code. -/// We replicate that logic server-side so the frontend can display it directly. -fn generate_qrcode_data_uri(content: &str) -> Result { - use image::{codecs::png::PngEncoder, ImageEncoder, Luma}; - use qrcode::QrCode; - - let code = QrCode::new(content.as_bytes()).map_err(|e| { - ChatChannelError::ConnectionFailed(format!("QR code generation failed: {e}")) - })?; - - let img = code - .render::>() - .quiet_zone(true) - .min_dimensions(250, 250) - .build(); - let (w, h) = (img.width(), img.height()); - - let mut png_buf: Vec = Vec::new(); - PngEncoder::new(&mut png_buf) - .write_image(img.as_raw(), w, h, image::ExtendedColorType::L8) - .map_err(|e| ChatChannelError::ConnectionFailed(format!("PNG encoding failed: {e}")))?; - - let b64 = B64.encode(&png_buf); - Ok(format!("data:image/png;base64,{b64}")) -} - -pub async fn weixin_check_qrcode(qrcode: &str) -> Result { - let client = qr_client(); - let resp = client - .get(format!("{ILINK_BASE_URL}/ilink/bot/get_qrcode_status")) - .query(&[("qrcode", qrcode)]) - .send() - .await - .map_err(|e| { - ChatChannelError::ConnectionFailed(format!("QR status request failed: {e}")) - })?; - - let body: serde_json::Value = resp - .json() - .await - .map_err(|e| ChatChannelError::ConnectionFailed(format!("QR status parse failed: {e}")))?; - - let status = body - .get("status") - .and_then(|v| v.as_str()) - .unwrap_or("waiting") - .to_string(); - - let bot_token = body - .get("bot_token") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let base_url = body - .get("baseurl") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - Ok(WeixinQrcodeStatus { - status, - bot_token, - base_url, - }) -} - -// ── Backend implementation ── - -struct WeixinReplyContext { - to_user_id: String, - context_token: String, - expired: bool, -} - -pub struct WeixinBackend { - bot_token: String, - base_url: String, - client: reqwest::Client, - status: Arc>, - channel_id: i32, - shutdown_tx: Arc>>>, - reply_context: Arc>>, - /// Messages that failed due to expired context_token, resend on next refresh. - pending_messages: Arc>>, - /// Stable X-WECHAT-UIN value for this backend instance. - wechat_uin: String, -} - -impl WeixinBackend { - pub fn new(channel_id: i32, bot_token: String, base_url: String) -> Self { - let uin_raw = rand::thread_rng().gen::().to_string(); - let wechat_uin = B64.encode(uin_raw.as_bytes()); - - Self { - bot_token, - base_url, - client: reqwest::Client::builder() - .connect_timeout(Duration::from_secs(10)) - .timeout(Duration::from_secs(45)) - .build() - .unwrap_or_default(), - status: Arc::new(Mutex::new(ChannelConnectionStatus::Disconnected)), - channel_id, - shutdown_tx: Arc::new(Mutex::new(None)), - reply_context: Arc::new(Mutex::new(None)), - pending_messages: Arc::new(Mutex::new(Vec::new())), - wechat_uin, - } - } - - fn build_headers(bot_token: &str, wechat_uin: &str) -> HeaderMap { - let mut headers = HeaderMap::new(); - headers.insert("Content-Type", HeaderValue::from_static("application/json")); - headers.insert( - "AuthorizationType", - HeaderValue::from_static("ilink_bot_token"), - ); - - if let Ok(val) = HeaderValue::from_str(wechat_uin) { - headers.insert("X-WECHAT-UIN", val); - } - - let bearer = format!("Bearer {bot_token}"); - if let Ok(val) = HeaderValue::from_str(&bearer) { - headers.insert("Authorization", val); - } - - headers - } - - /// Build the JSON body for the iLink sendmessage API. - fn build_send_body(to_user_id: &str, context_token: &str, text: &str) -> serde_json::Value { - serde_json::json!({ - "msg": { - "from_user_id": "", - "to_user_id": to_user_id, - "client_id": format!("codeg-{}", uuid::Uuid::new_v4()), - "message_type": 2, - "message_state": 2, - "context_token": context_token, - "item_list": [{ - "type": 1, - "text_item": { "text": text } - }] - }, - "base_info": { "channel_version": ILINK_CHANNEL_VERSION } - }) - } - - /// Send a message via the iLink API and handle the response. - /// Returns `Ok(true)` if sent, `Ok(false)` if buffered due to expired context. - async fn do_send(req: SendRequest<'_>) -> Result { - let body = Self::build_send_body(req.to_user_id, req.context_token, req.text); - let url = format!("{}/ilink/bot/sendmessage", req.base_url); - - let resp = req - .client - .post(&url) - .headers(Self::build_headers(req.bot_token, req.wechat_uin)) - .json(&body) - .send() - .await - .map_err(|e| ChatChannelError::SendFailed(e.to_string()))?; - - let status_code = resp.status(); - let resp_text = resp.text().await.unwrap_or_default(); - - if !status_code.is_success() { - return Err(ChatChannelError::SendFailed(format!( - "HTTP {status_code}: {resp_text}" - ))); - } - - // Check for ret errors in response (e.g. -2 = context expired) - if let Ok(resp_json) = serde_json::from_str::(&resp_text) { - if let Some(ret) = resp_json.get("ret").and_then(|v| v.as_i64()) { - if ret != 0 { - let errmsg = resp_json - .get("errmsg") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - eprintln!("[Weixin] sendmessage ret={ret}, errmsg={errmsg}"); - - if ret == -2 { - // Context token expired — mark stale and buffer - if let Some(ref mut c) = *req.reply_context.lock().await { - c.expired = true; - } - let mut buf = req.pending_messages.lock().await; - if buf.len() < MAX_PENDING_MESSAGES { - buf.push(req.text.to_string()); - } - eprintln!("[Weixin] context_token expired (ret=-2), buffered message"); - return Ok(false); - } - - return Err(ChatChannelError::SendFailed(format!("ret={ret}: {errmsg}"))); - } - } - } - - Ok(true) - } - - async fn send_text(&self, text: &str) -> Result { - // Extract context data under lock, then release - let (to_user_id, context_token, expired) = { - let guard = self.reply_context.lock().await; - let ctx = guard.as_ref().ok_or_else(|| { - ChatChannelError::SendFailed( - "No active WeChat conversation context. A user must message the bot first." - .into(), - ) - })?; - ( - ctx.to_user_id.clone(), - ctx.context_token.clone(), - ctx.expired, - ) - }; - - // If context is expired, buffer the message for resend on next refresh - if expired { - eprintln!( - "[Weixin] context expired, buffering message (len={})", - text.len() - ); - let mut buf = self.pending_messages.lock().await; - if buf.len() < MAX_PENDING_MESSAGES { - buf.push(text.to_string()); - } else { - eprintln!("[Weixin] pending buffer full, dropping message"); - } - return Ok(SentMessageId(String::new())); - } - - eprintln!( - "[Weixin] sendmessage to={to_user_id}, context_token_len={}, text_len={}", - context_token.len(), - text.len() - ); - - Self::do_send(SendRequest { - client: &self.client, - base_url: &self.base_url, - bot_token: &self.bot_token, - wechat_uin: &self.wechat_uin, - to_user_id: &to_user_id, - context_token: &context_token, - text, - reply_context: &self.reply_context, - pending_messages: &self.pending_messages, - }) - .await?; - - Ok(SentMessageId(String::new())) - } -} - -#[async_trait] -impl ChatChannelBackend for WeixinBackend { - fn channel_type(&self) -> ChannelType { - ChannelType::Weixin - } - - async fn start( - &self, - command_tx: mpsc::Sender, - ) -> Result<(), ChatChannelError> { - *self.status.lock().await = ChannelConnectionStatus::Connecting; - - eprintln!( - "[Weixin] start: base_url={}, token_len={}", - self.base_url, - self.bot_token.len() - ); - - // Verify auth by doing a quick getupdates with empty cursor - let verify_body = serde_json::json!({ - "get_updates_buf": "", - "base_info": { "channel_version": ILINK_CHANNEL_VERSION } - }); - let url = format!("{}/ilink/bot/getupdates", self.base_url); - eprintln!("[Weixin] verify POST {url}"); - - let resp = self - .client - .post(&url) - .headers(Self::build_headers(&self.bot_token, &self.wechat_uin)) - .json(&verify_body) - .send() - .await - .map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?; - - let status_code = resp.status(); - let resp_text = resp - .text() - .await - .map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?; - - eprintln!("[Weixin] verify response status={status_code}, body={resp_text}"); - - let verify_result: serde_json::Value = serde_json::from_str(&resp_text) - .map_err(|e| ChatChannelError::ConnectionFailed(format!("JSON parse failed: {e}")))?; - - // iLink API auth failures come back as `{"errcode":-14,"errmsg":"session timeout"}` - // (no `ret` field). Treat any non-zero errcode as authentication failure. - if let Some(errcode) = verify_result.get("errcode").and_then(|v| v.as_i64()) { - if errcode != 0 { - let errmsg = verify_result - .get("errmsg") - .and_then(|v| v.as_str()) - .unwrap_or("unknown error"); - return Err(ChatChannelError::AuthenticationFailed(format!( - "Weixin verification failed (errcode={errcode}): {errmsg}" - ))); - } - } - - let ret = verify_result.get("ret").and_then(|v| v.as_i64()); - - // Check for known auth-failure codes - if ret == Some(-14) { - return Err(ChatChannelError::AuthenticationFailed( - "Session expired (ret=-14), please re-authenticate".into(), - )); - } - - // The iLink API may omit the `ret` field or return non-zero on the first - // call. Always extract the cursor if present — it's needed for polling. - let initial_cursor = verify_result - .get("get_updates_buf") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - - if let Some(r) = ret { - if r != 0 { - eprintln!( - "[Weixin] verify returned ret={r}, but got cursor len={}", - initial_cursor.len() - ); - } - } - - *self.status.lock().await = ChannelConnectionStatus::Connected; - - // Start long-polling loop - let (shutdown_tx, mut shutdown_rx) = tokio::sync::watch::channel(false); - *self.shutdown_tx.lock().await = Some(shutdown_tx); - - let client = self.client.clone(); - let bot_token = self.bot_token.clone(); - let base_url = self.base_url.clone(); - let wechat_uin = self.wechat_uin.clone(); - let channel_id = self.channel_id; - let status = self.status.clone(); - let reply_context = self.reply_context.clone(); - let pending_messages = self.pending_messages.clone(); - - tokio::spawn(async move { - let mut cursor = initial_cursor; - let mut consecutive_errors: u32 = 0; - - loop { - if *shutdown_rx.borrow() { - break; - } - - let body = serde_json::json!({ - "get_updates_buf": cursor, - "base_info": { "channel_version": ILINK_CHANNEL_VERSION } - }); - - let result = tokio::select! { - r = client - .post(format!("{base_url}/ilink/bot/getupdates")) - .headers(WeixinBackend::build_headers(&bot_token, &wechat_uin)) - .json(&body) - .send() => r, - _ = shutdown_rx.changed() => break, - }; - - match result { - Ok(resp) => { - // Recover from error state after successful poll - consecutive_errors = 0; - { - let mut s = status.lock().await; - if *s == ChannelConnectionStatus::Error { - *s = ChannelConnectionStatus::Connected; - } - } - - if let Ok(body) = resp.json::().await { - let ret = body.get("ret").and_then(|v| v.as_i64()); - - // Always update cursor if present - if let Some(new_cursor) = - body.get("get_updates_buf").and_then(|v| v.as_str()) - { - if !new_cursor.is_empty() { - cursor = new_cursor.to_string(); - } - } - - // If ret is explicitly non-zero (not just missing), log it - if let Some(r) = ret { - if r != 0 { - eprintln!("[Weixin] getupdates ret={r}"); - } - // Session expired — pause and wait for re-auth - if r == -14 { - eprintln!("[Weixin] session expired (ret=-14), pausing 30s"); - *status.lock().await = ChannelConnectionStatus::Error; - tokio::time::sleep(Duration::from_secs(30)).await; - continue; - } - } - - // Process messages - if let Some(msgs) = body.get("msgs").and_then(|v| v.as_array()) { - if !msgs.is_empty() { - eprintln!("[Weixin] got {} message(s)", msgs.len()); - } - for msg in msgs { - // Only handle user messages (message_type=1), - // skip bot echo (message_type=2) - let msg_type = msg.get("message_type").and_then(|v| v.as_i64()); - if msg_type != Some(1) { - continue; - } - - // Extract text from type=1 (text) or type=3 (voice-to-text) - let text = msg - .get("item_list") - .and_then(|v| v.as_array()) - .and_then(|items| { - items.iter().find_map(|item| { - let t = - item.get("type").and_then(|v| v.as_i64())?; - match t { - 1 => item - .pointer("/text_item/text") - .and_then(|v| v.as_str()), - 3 => item - .pointer("/voice_item/text") - .and_then(|v| v.as_str()), - _ => None, - } - }) - }); - - let text = match text { - Some(t) if !t.is_empty() => t, - _ => { - eprintln!("[Weixin] skipped non-text message"); - continue; - } - }; - - let from_user_id = msg - .get("from_user_id") - .and_then(|v| v.as_str()) - .unwrap_or_default(); - let context_token = msg - .get("context_token") - .and_then(|v| v.as_str()) - .unwrap_or_default(); - - // Store reply context for outbound messages - // Single lock scope to avoid TOCTOU - if !from_user_id.is_empty() && !context_token.is_empty() { - let was_expired = { - let mut guard = reply_context.lock().await; - let was = - guard.as_ref().map(|c| c.expired).unwrap_or(false); - *guard = Some(WeixinReplyContext { - to_user_id: from_user_id.to_string(), - context_token: context_token.to_string(), - expired: false, - }); - was - }; - - // Resend buffered messages with fresh context - if was_expired { - let buffered: Vec = - pending_messages.lock().await.drain(..).collect(); - if !buffered.is_empty() { - eprintln!( - "[Weixin] context refreshed, resending {} buffered message(s)", - buffered.len() - ); - for pending_text in &buffered { - let ok = WeixinBackend::do_send(SendRequest { - client: &client, - base_url: &base_url, - bot_token: &bot_token, - wechat_uin: &wechat_uin, - to_user_id: from_user_id, - context_token, - text: pending_text, - reply_context: &reply_context, - pending_messages: &pending_messages, - }) - .await; - if let Err(e) = ok { - eprintln!("[Weixin] resend error: {e}"); - // Re-buffer remaining on hard error - let mut buf = pending_messages.lock().await; - if buf.len() < MAX_PENDING_MESSAGES { - buf.push(pending_text.clone()); - } - } - // If do_send returned Ok(false), it - // already re-buffered internally. - } - } - } - } - - eprintln!("[Weixin] dispatching: {text}"); - let send_result = command_tx - .send(IncomingCommand { - channel_id, - sender_id: from_user_id.to_string(), - command_text: text.to_string(), - metadata: msg.clone(), - }) - .await; - if let Err(e) = send_result { - eprintln!("[Weixin] command_tx.send failed: {e}"); - } - } - } - } else { - eprintln!("[Weixin] failed to parse response body"); - } - } - Err(e) => { - consecutive_errors += 1; - eprintln!("[Weixin] polling error ({consecutive_errors}): {e}"); - *status.lock().await = ChannelConnectionStatus::Error; - // Exponential backoff: 5s, 10s, 20s, capped at 30s - let delay = - std::cmp::min(5 * 2u64.saturating_pow(consecutive_errors - 1), 30); - tokio::time::sleep(Duration::from_secs(delay)).await; - } - } - } - *status.lock().await = ChannelConnectionStatus::Disconnected; - }); - - Ok(()) - } - - async fn stop(&self) -> Result<(), ChatChannelError> { - if let Some(tx) = self.shutdown_tx.lock().await.take() { - let _ = tx.send(true); - } - *self.status.lock().await = ChannelConnectionStatus::Disconnected; - Ok(()) - } - - async fn status(&self) -> ChannelConnectionStatus { - *self.status.lock().await - } - - async fn send_message(&self, text: &str) -> Result { - self.send_text(text).await - } - - async fn send_rich_message( - &self, - message: &RichMessage, - ) -> Result { - let plain_text = message.to_plain_text(); - self.send_text(&plain_text).await - } - - async fn test_connection(&self) -> Result<(), ChatChannelError> { - let body = serde_json::json!({ - "get_updates_buf": "", - "base_info": { "channel_version": ILINK_CHANNEL_VERSION } - }); - - let url = format!("{}/ilink/bot/getupdates", self.base_url); - let resp = self - .client - .post(&url) - .headers(Self::build_headers(&self.bot_token, &self.wechat_uin)) - .json(&body) - .send() - .await - .map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?; - - let status_code = resp.status(); - let resp_text = resp - .text() - .await - .map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?; - - eprintln!("[Weixin] test_connection: status={status_code}, body={resp_text}"); - - let resp_json: serde_json::Value = serde_json::from_str(&resp_text) - .map_err(|e| ChatChannelError::ConnectionFailed(format!("Not valid JSON: {e}")))?; - - if !status_code.is_success() { - return Err(ChatChannelError::AuthenticationFailed(format!( - "HTTP {status_code}" - ))); - } - - // Check for known auth-failure codes - if let Some(ret) = resp_json.get("ret").and_then(|v| v.as_i64()) { - if ret == -14 { - return Err(ChatChannelError::AuthenticationFailed( - "Session expired (ret=-14)".into(), - )); - } - } - - Ok(()) - } -} diff --git a/src-tauri/src/chat_channel/command_dispatcher.rs b/src-tauri/src/chat_channel/command_dispatcher.rs deleted file mode 100644 index a2df03a..0000000 --- a/src-tauri/src/chat_channel/command_dispatcher.rs +++ /dev/null @@ -1,234 +0,0 @@ -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use sea_orm::DatabaseConnection; -use tokio::sync::{mpsc, Mutex}; -use tokio::task::JoinHandle; - -use super::command_handlers; -use super::i18n::{self, Lang}; -use super::manager::ChatChannelManager; -use super::session_bridge::SessionBridge; -use super::session_commands; -use super::types::IncomingCommand; -use crate::acp::manager::ConnectionManager; -use crate::db::service::{app_metadata_service, chat_channel_message_log_service}; -use crate::web::event_bridge::EventEmitter; - -const COMMAND_PREFIX_KEY: &str = "chat_command_prefix"; -const DEFAULT_COMMAND_PREFIX: &str = "/"; -const MESSAGE_LANGUAGE_KEY: &str = "chat_message_language"; -/// How often to refresh cached config from DB. -const CONFIG_CACHE_TTL_SECS: u64 = 30; - -struct CommandConfigCache { - prefix: String, - lang: Lang, - last_refresh: Instant, -} - -impl CommandConfigCache { - fn new() -> Self { - Self { - prefix: DEFAULT_COMMAND_PREFIX.to_string(), - lang: Lang::default(), - // Force refresh on first use - last_refresh: Instant::now() - Duration::from_secs(CONFIG_CACHE_TTL_SECS + 1), - } - } - - async fn refresh_if_needed(&mut self, db: &DatabaseConnection) { - if self.last_refresh.elapsed() < Duration::from_secs(CONFIG_CACHE_TTL_SECS) { - return; - } - - if let Ok(Some(val)) = app_metadata_service::get_value(db, COMMAND_PREFIX_KEY).await { - self.prefix = val; - } - if let Ok(Some(val)) = app_metadata_service::get_value(db, MESSAGE_LANGUAGE_KEY).await { - self.lang = Lang::from_str_lossy(&val); - } - - self.last_refresh = Instant::now(); - } -} - -pub fn spawn_command_dispatcher( - mut command_rx: mpsc::Receiver, - manager: ChatChannelManager, - db_conn: DatabaseConnection, - conn_mgr: ConnectionManager, - emitter: EventEmitter, - bridge: Arc>, -) -> JoinHandle<()> { - tokio::spawn(async move { - let mut config = CommandConfigCache::new(); - - while let Some(cmd) = command_rx.recv().await { - let text = cmd.command_text.trim(); - eprintln!( - "[ChatChannel] received command from channel={} sender={}: {:?}", - cmd.channel_id, cmd.sender_id, text - ); - - // Log inbound command - let _ = chat_channel_message_log_service::create_log( - &db_conn, - cmd.channel_id, - "inbound", - "command_query", - text, - "sent", - None, - ) - .await; - - config.refresh_if_needed(&db_conn).await; - - let response = dispatch_command( - text, - &config.prefix, - &db_conn, - &manager, - &conn_mgr, - &emitter, - &bridge, - cmd.channel_id, - &cmd.sender_id, - config.lang, - ) - .await; - - eprintln!( - "[ChatChannel] dispatch result: title={:?}, body_len={}", - response.title, - response.body.len() - ); - - // Send response back via the same channel - let send_result = manager.send_to_channel(cmd.channel_id, &response).await; - let (status, error_detail) = match &send_result { - Ok(_) => ("sent", None), - Err(e) => { - eprintln!( - "[ChatChannel] failed to send response for {:?} to channel {}: {e}", - text, cmd.channel_id - ); - ("failed", Some(e.to_string())) - } - }; - - let _ = chat_channel_message_log_service::create_log( - &db_conn, - cmd.channel_id, - "outbound", - "command_response", - &response.to_plain_text(), - status, - error_detail, - ) - .await; - } - }) -} - -#[allow(clippy::too_many_arguments)] -async fn dispatch_command( - text: &str, - prefix: &str, - db: &DatabaseConnection, - manager: &ChatChannelManager, - conn_mgr: &ConnectionManager, - emitter: &EventEmitter, - bridge: &Arc>, - channel_id: i32, - sender_id: &str, - lang: Lang, -) -> super::types::RichMessage { - // Strip prefix; if text doesn't start with it, try as follow-up - let without_prefix = match text.strip_prefix(prefix) { - Some(rest) => rest, - None => { - // Check if sender has an active session for follow-up - let has_session = { - let guard = bridge.lock().await; - guard.find_by_sender(channel_id, sender_id).is_some() - }; - if has_session { - return session_commands::handle_followup(session_commands::FollowupRequest { - db, - text, - channel_id, - sender_id, - conn_mgr, - bridge, - lang, - prefix, - }) - .await; - } - return command_handlers::handle_help(prefix, lang); - } - }; - - let parts: Vec<&str> = without_prefix.splitn(2, ' ').collect(); - let command = parts[0].to_lowercase(); - let args = parts.get(1).map(|s| s.trim()).unwrap_or(""); - - match command.as_str() { - // Existing commands - "search" => { - if args.is_empty() { - super::types::RichMessage::info(i18n::search_usage(lang, prefix)) - .with_title(i18n::invalid_args_title(lang)) - } else { - command_handlers::handle_search(db, args, lang).await - } - } - "today" => command_handlers::handle_today(db, lang).await, - "status" => command_handlers::handle_status(manager, lang).await, - "help" | "start" => command_handlers::handle_help(prefix, lang), - - // Session commands - "folder" => { - session_commands::handle_folder(db, args, channel_id, sender_id, lang, prefix).await - } - "agent" => { - session_commands::handle_agent(db, args, channel_id, sender_id, lang, prefix).await - } - "task" | "do" => { - session_commands::handle_task( - db, args, channel_id, sender_id, conn_mgr, emitter, bridge, lang, prefix, - ) - .await - } - "sessions" => { - session_commands::handle_sessions(db, channel_id, sender_id, lang, prefix).await - } - "resume" => { - session_commands::handle_resume( - db, args, channel_id, sender_id, conn_mgr, emitter, bridge, lang, prefix, - ) - .await - } - "cancel" => { - session_commands::handle_cancel(db, channel_id, sender_id, conn_mgr, bridge, lang).await - } - "approve" => { - let always = args.eq_ignore_ascii_case("always"); - session_commands::handle_permission_response( - true, always, db, channel_id, sender_id, conn_mgr, bridge, lang, - ) - .await - } - "deny" => { - session_commands::handle_permission_response( - false, false, db, channel_id, sender_id, conn_mgr, bridge, lang, - ) - .await - } - - _ => super::types::RichMessage::info(i18n::unknown_command(lang, prefix, &command)) - .with_title(i18n::unknown_command_title(lang)), - } -} diff --git a/src-tauri/src/chat_channel/command_handlers.rs b/src-tauri/src/chat_channel/command_handlers.rs deleted file mode 100644 index f0b3521..0000000 --- a/src-tauri/src/chat_channel/command_handlers.rs +++ /dev/null @@ -1,133 +0,0 @@ -use chrono::Utc; -use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, QuerySelect}; - -use super::i18n::{self, Lang}; -use super::manager::ChatChannelManager; -use super::types::{MessageLevel, RichMessage}; -use crate::db::entities::conversation; - -pub async fn handle_search(db: &DatabaseConnection, keyword: &str, lang: Lang) -> RichMessage { - let matched = match conversation::Entity::find() - .filter(conversation::Column::DeletedAt.is_null()) - .filter(conversation::Column::Title.contains(keyword)) - .order_by_desc(conversation::Column::CreatedAt) - .limit(10) - .all(db) - .await - { - Ok(rows) => rows, - Err(e) => { - return RichMessage { - title: Some(i18n::query_failed_title(lang).to_string()), - body: e.to_string(), - fields: Vec::new(), - level: MessageLevel::Error, - }; - } - }; - - if matched.is_empty() { - return RichMessage::info(i18n::search_no_results(lang, keyword)) - .with_title(i18n::search_results_title(lang)); - } - - let mut body = String::new(); - for conv in &matched { - let title = conv.title.as_deref().unwrap_or(i18n::untitled(lang)); - let agent = &conv.agent_type; - let time = conv.created_at.format("%m-%d %H:%M"); - body.push_str(&format!("#{} [{}] {} ({})\n", conv.id, agent, title, time,)); - } - - RichMessage::info(body.trim_end()).with_title(i18n::search_results_count_title( - lang, - keyword, - matched.len(), - )) -} - -pub async fn handle_today(db: &DatabaseConnection, lang: Lang) -> RichMessage { - let now = Utc::now(); - let today_start = now.date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc(); - - let rows = match conversation::Entity::find() - .filter(conversation::Column::DeletedAt.is_null()) - .filter(conversation::Column::CreatedAt.gte(today_start)) - .order_by_desc(conversation::Column::CreatedAt) - .all(db) - .await - { - Ok(rows) => rows, - Err(e) => { - return RichMessage { - title: Some(i18n::query_failed_title(lang).to_string()), - body: e.to_string(), - fields: Vec::new(), - level: MessageLevel::Error, - }; - } - }; - - if rows.is_empty() { - return RichMessage::info(i18n::no_activity_today(lang)) - .with_title(i18n::today_activity_title(lang)); - } - - // Group by agent_type - let mut by_agent: std::collections::HashMap = std::collections::HashMap::new(); - let mut titles: Vec = Vec::new(); - for conv in &rows { - *by_agent.entry(conv.agent_type.clone()).or_insert(0) += 1; - if let Some(t) = &conv.title { - if titles.len() < 5 { - titles.push(t.clone()); - } - } - } - - let mut body = i18n::total_sessions(lang, rows.len() as u32); - body.push_str(&format!("\n\n{}", i18n::by_agent_label(lang))); - for (agent, count) in &by_agent { - body.push_str(&format!("\n {}", i18n::agent_count(lang, agent, *count))); - } - - if !titles.is_empty() { - body.push_str(&format!("\n\n{}", i18n::recent_activity_label(lang))); - for t in &titles { - body.push_str(&format!("\n • {t}")); - } - } - - RichMessage::info(body).with_title(i18n::today_activity_date_title( - lang, - &now.format("%Y-%m-%d").to_string(), - )) -} - -pub async fn handle_status(manager: &ChatChannelManager, lang: Lang) -> RichMessage { - let statuses = manager.get_status().await; - if statuses.is_empty() { - return RichMessage::info(i18n::no_active_channels(lang)) - .with_title(i18n::channel_status_title(lang)); - } - - let mut body = String::new(); - for s in &statuses { - let icon = match s.status.as_str() { - "connected" => "●", - "connecting" => "◎", - "error" => "✗", - _ => "○", - }; - body.push_str(&format!( - "{} {} [{}] - {}\n", - icon, s.name, s.channel_type, s.status - )); - } - - RichMessage::info(body.trim_end()).with_title(i18n::channel_status_title(lang)) -} - -pub fn handle_help(prefix: &str, lang: Lang) -> RichMessage { - RichMessage::info(i18n::help_body(lang, prefix)).with_title(i18n::help_title(lang)) -} diff --git a/src-tauri/src/chat_channel/error.rs b/src-tauri/src/chat_channel/error.rs deleted file mode 100644 index 0d6b5e7..0000000 --- a/src-tauri/src/chat_channel/error.rs +++ /dev/null @@ -1,39 +0,0 @@ -use crate::app_error::AppCommandError; - -#[derive(Debug, thiserror::Error)] -pub enum ChatChannelError { - #[error("connection failed: {0}")] - ConnectionFailed(String), - #[error("send failed: {0}")] - SendFailed(String), - #[error("authentication failed: {0}")] - AuthenticationFailed(String), - #[error("configuration invalid: {0}")] - ConfigurationInvalid(String), - #[error("not connected")] - NotConnected, - #[error("already connected")] - AlreadyConnected, - #[error("channel not found: {0}")] - NotFound(i32), - #[error("{0}")] - Other(String), -} - -impl From for AppCommandError { - fn from(err: ChatChannelError) -> Self { - match &err { - ChatChannelError::NotFound(_) => AppCommandError::not_found(err.to_string()), - ChatChannelError::AuthenticationFailed(_) => { - AppCommandError::authentication_failed(err.to_string()) - } - ChatChannelError::ConfigurationInvalid(_) => { - AppCommandError::configuration_invalid(err.to_string()) - } - ChatChannelError::ConnectionFailed(_) | ChatChannelError::SendFailed(_) => { - AppCommandError::network(err.to_string()) - } - _ => AppCommandError::task_execution_failed(err.to_string()), - } - } -} diff --git a/src-tauri/src/chat_channel/event_subscriber.rs b/src-tauri/src/chat_channel/event_subscriber.rs deleted file mode 100644 index b520aef..0000000 --- a/src-tauri/src/chat_channel/event_subscriber.rs +++ /dev/null @@ -1,215 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use sea_orm::DatabaseConnection; -use tokio::task::JoinHandle; - -use super::i18n::Lang; -use super::manager::ChatChannelManager; -use super::message_formatter; -use super::types::RichMessage; -use crate::db::service::{ - app_metadata_service, chat_channel_message_log_service, chat_channel_service, -}; -use crate::web::event_bridge::WebEventBroadcaster; - -/// Minimum interval between pushes for the same event type per channel (debounce). -const DEBOUNCE_SECS: u64 = 5; -/// How often to refresh cached config from DB. -const CONFIG_CACHE_TTL_SECS: u64 = 30; - -const MESSAGE_LANGUAGE_KEY: &str = "chat_message_language"; -const EVENT_FILTER_KEY: &str = "chat_event_filter"; - -struct CachedChannel { - id: i32, - event_filter_json: Option, -} - -struct EventConfigCache { - lang: Lang, - global_filter: Option>, - enabled_channels: Vec, - last_refresh: Instant, -} - -impl EventConfigCache { - fn new() -> Self { - Self { - lang: Lang::default(), - global_filter: None, - enabled_channels: Vec::new(), - // Force refresh on first use - last_refresh: Instant::now() - Duration::from_secs(CONFIG_CACHE_TTL_SECS + 1), - } - } - - async fn refresh_if_needed(&mut self, db: &DatabaseConnection) { - if self.last_refresh.elapsed() < Duration::from_secs(CONFIG_CACHE_TTL_SECS) { - return; - } - - if let Ok(Some(val)) = app_metadata_service::get_value(db, MESSAGE_LANGUAGE_KEY).await { - self.lang = Lang::from_str_lossy(&val); - } - - // Parse as Option> so JSON "null" → None (intentional, not accidental) - self.global_filter = app_metadata_service::get_value(db, EVENT_FILTER_KEY) - .await - .ok() - .flatten() - .and_then(|json| { - serde_json::from_str::>>(&json) - .ok() - .flatten() - }); - - if let Ok(channels) = chat_channel_service::list_enabled(db).await { - self.enabled_channels = channels - .into_iter() - .map(|ch| CachedChannel { - id: ch.id, - event_filter_json: ch.event_filter_json, - }) - .collect(); - } - - self.last_refresh = Instant::now(); - } -} - -pub fn spawn_event_subscriber( - broadcaster: Arc, - manager: ChatChannelManager, - db_conn: DatabaseConnection, -) -> JoinHandle<()> { - tokio::spawn(async move { - let mut rx = broadcaster.subscribe(); - let mut last_push: HashMap<(i32, String), Instant> = HashMap::new(); - let mut config = EventConfigCache::new(); - - loop { - let event = match rx.recv().await { - Ok(e) => e, - Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { - eprintln!("[ChatChannel] event subscriber lagged by {n} messages"); - continue; - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => { - eprintln!("[ChatChannel] event broadcaster closed, stopping subscriber"); - break; - } - }; - - config.refresh_if_needed(&db_conn).await; - - // Prune stale debounce entries - last_push.retain(|_, t| t.elapsed() < Duration::from_secs(DEBOUNCE_SECS * 2)); - - if let Some((event_type, msg)) = - parse_event(&event.channel, event.payload.as_ref(), config.lang) - { - // Global event filter check - if let Some(filter) = &config.global_filter { - if !filter.contains(&event_type) { - continue; - } - } - - for ch in &config.enabled_channels { - // Per-channel event filter - if let Some(filter_json) = &ch.event_filter_json { - if let Ok(filter) = serde_json::from_str::>(filter_json) { - if !filter.contains(&event_type) { - continue; - } - } - } - - // Debounce: skip if same event type was pushed to this channel recently - let key = (ch.id, event_type.clone()); - let now = Instant::now(); - if let Some(last) = last_push.get(&key) { - if now.duration_since(*last) < Duration::from_secs(DEBOUNCE_SECS) { - continue; - } - } - - // Send - let send_result = manager.send_to_channel(ch.id, &msg).await; - let (status, error_detail) = match &send_result { - Ok(_) => { - // Only update debounce timestamp on success - last_push.insert(key, now); - ("sent", None) - } - Err(e) => ("failed", Some(e.to_string())), - }; - - let _ = chat_channel_message_log_service::create_log( - &db_conn, - ch.id, - "outbound", - "event_push", - &msg.to_plain_text(), - status, - error_detail, - ) - .await; - } - } - } - }) -} - -fn parse_event( - channel: &str, - payload: &serde_json::Value, - lang: Lang, -) -> Option<(String, RichMessage)> { - match channel { - "acp://event" => parse_acp_event(payload, lang), - _ => None, - } -} - -fn parse_acp_event(payload: &serde_json::Value, lang: Lang) -> Option<(String, RichMessage)> { - let event_type = payload.get("type")?.as_str()?; - - match event_type { - "turn_complete" => { - let stop_reason = payload - .get("stop_reason") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - // Only push for end_turn, not for intermediate completions - if stop_reason != "end_turn" { - return None; - } - let agent_type = payload - .get("agent_type") - .and_then(|v| v.as_str()) - .unwrap_or("Unknown Agent"); - Some(( - "turn_complete".to_string(), - message_formatter::format_turn_complete(agent_type, stop_reason, lang), - )) - } - "error" => { - let agent_type = payload - .get("agent_type") - .and_then(|v| v.as_str()) - .unwrap_or("Unknown Agent"); - let message = payload - .get("message") - .and_then(|v| v.as_str()) - .unwrap_or("Unknown error"); - Some(( - "error".to_string(), - message_formatter::format_agent_error(agent_type, message, lang), - )) - } - _ => None, - } -} diff --git a/src-tauri/src/chat_channel/i18n.rs b/src-tauri/src/chat_channel/i18n.rs deleted file mode 100644 index 482a326..0000000 --- a/src-tauri/src/chat_channel/i18n.rs +++ /dev/null @@ -1,1466 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum Lang { - #[default] - En, - ZhCn, - ZhTw, - Ja, - Ko, - Es, - De, - Fr, - Pt, - Ar, -} - -impl Lang { - pub fn from_str_lossy(s: &str) -> Self { - match s { - "en" => Lang::En, - "zh-cn" | "zh-CN" | "zh_CN" => Lang::ZhCn, - "zh-tw" | "zh-TW" | "zh_TW" => Lang::ZhTw, - "ja" => Lang::Ja, - "ko" => Lang::Ko, - "es" => Lang::Es, - "de" => Lang::De, - "fr" => Lang::Fr, - "pt" => Lang::Pt, - "ar" => Lang::Ar, - _ => Lang::En, - } - } -} - -// ── Event messages ── - -pub fn turn_complete_title(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "会话完成", - Lang::ZhTw => "對話完成", - Lang::Ja => "セッション完了", - Lang::Ko => "세션 완료", - Lang::Es => "Sesión completada", - Lang::De => "Sitzung abgeschlossen", - Lang::Fr => "Session terminée", - Lang::Pt => "Sessão concluída", - Lang::Ar => "اكتملت الجلسة", - Lang::En => "Turn Complete", - } -} - -pub fn turn_complete_body(lang: Lang, agent_type: &str) -> String { - match lang { - Lang::ZhCn => format!("{agent_type} 会话已完成"), - Lang::ZhTw => format!("{agent_type} 對話已完成"), - Lang::Ja => format!("{agent_type} セッションが完了しました"), - Lang::Ko => format!("{agent_type} 세션이 완료되었습니다"), - Lang::Es => format!("{agent_type} sesión completada"), - Lang::De => format!("{agent_type} Sitzung abgeschlossen"), - Lang::Fr => format!("Session {agent_type} terminée"), - Lang::Pt => format!("Sessão {agent_type} concluída"), - Lang::Ar => format!("اكتملت جلسة {agent_type}"), - Lang::En => format!("{agent_type} session completed"), - } -} - -pub fn stop_reason_label(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "结束原因", - Lang::ZhTw => "結束原因", - Lang::Ja => "終了理由", - Lang::Ko => "종료 사유", - Lang::Es => "Motivo de fin", - Lang::De => "Beendigungsgrund", - Lang::Fr => "Raison de fin", - Lang::Pt => "Motivo do término", - Lang::Ar => "سبب الانتهاء", - Lang::En => "Stop Reason", - } -} - -pub fn stop_reason_end_turn(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "正常结束", - Lang::ZhTw => "正常結束", - Lang::Ja => "正常終了", - Lang::Ko => "정상 종료", - Lang::Es => "Finalizado", - Lang::De => "Normal beendet", - Lang::Fr => "Terminé normalement", - Lang::Pt => "Finalizado", - Lang::Ar => "انتهى بشكل طبيعي", - Lang::En => "Completed", - } -} - -pub fn stop_reason_cancelled(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "已取消", - Lang::ZhTw => "已取消", - Lang::Ja => "キャンセル", - Lang::Ko => "취소됨", - Lang::Es => "Cancelado", - Lang::De => "Abgebrochen", - Lang::Fr => "Annulé", - Lang::Pt => "Cancelado", - Lang::Ar => "تم الإلغاء", - Lang::En => "Cancelled", - } -} - -pub fn agent_error_title(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "代理错误", - Lang::ZhTw => "代理錯誤", - Lang::Ja => "エージェントエラー", - Lang::Ko => "에이전트 오류", - Lang::Es => "Error del agente", - Lang::De => "Agent-Fehler", - Lang::Fr => "Erreur de l'agent", - Lang::Pt => "Erro do agente", - Lang::Ar => "خطأ في الوكيل", - Lang::En => "Agent Error", - } -} - -pub fn agent_error_body(lang: Lang, agent_type: &str) -> String { - match lang { - Lang::ZhCn => format!("{agent_type} 发生错误"), - Lang::ZhTw => format!("{agent_type} 發生錯誤"), - Lang::Ja => format!("{agent_type} でエラーが発生しました"), - Lang::Ko => format!("{agent_type}에서 오류 발생"), - Lang::Es => format!("{agent_type} encontró un error"), - Lang::De => format!("{agent_type} hat einen Fehler"), - Lang::Fr => format!("{agent_type} a rencontré une erreur"), - Lang::Pt => format!("{agent_type} encontrou um erro"), - Lang::Ar => format!("حدث خطأ في {agent_type}"), - Lang::En => format!("{agent_type} encountered an error"), - } -} - -pub fn error_message_label(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "错误信息", - Lang::ZhTw => "錯誤訊息", - Lang::Ja => "エラーメッセージ", - Lang::Ko => "오류 메시지", - Lang::Es => "Mensaje de error", - Lang::De => "Fehlermeldung", - Lang::Fr => "Message d'erreur", - Lang::Pt => "Mensagem de erro", - Lang::Ar => "رسالة الخطأ", - Lang::En => "Error Message", - } -} - -// ── Daily report ── - -pub fn daily_report_title(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "每日编码报告", - Lang::ZhTw => "每日編碼報告", - Lang::Ja => "日次コーディングレポート", - Lang::Ko => "일일 코딩 보고서", - Lang::Es => "Informe diario de codificación", - Lang::De => "Täglicher Coding-Bericht", - Lang::Fr => "Rapport de codage quotidien", - Lang::Pt => "Relatório diário de codificação", - Lang::Ar => "تقرير البرمجة اليومي", - Lang::En => "Daily Coding Report", - } -} - -pub fn daily_report_summary(lang: Lang, date: &str) -> String { - match lang { - Lang::ZhCn => format!("今日编码活动汇总 ({date})"), - Lang::ZhTw => format!("今日編碼活動匯總 ({date})"), - Lang::Ja => format!("本日のコーディング活動まとめ ({date})"), - Lang::Ko => format!("오늘의 코딩 활동 요약 ({date})"), - Lang::Es => format!("Resumen de actividad de codificación ({date})"), - Lang::De => format!("Coding-Aktivitätszusammenfassung ({date})"), - Lang::Fr => format!("Résumé de l'activité de codage ({date})"), - Lang::Pt => format!("Resumo da atividade de codificação ({date})"), - Lang::Ar => format!("ملخص نشاط البرمجة ({date})"), - Lang::En => format!("Daily coding activity summary ({date})"), - } -} - -pub fn total_sessions(lang: Lang, count: u32) -> String { - match lang { - Lang::ZhCn => format!("会话总数: {count}"), - Lang::ZhTw => format!("對話總數: {count}"), - Lang::Ja => format!("セッション合計: {count}"), - Lang::Ko => format!("총 세션: {count}"), - Lang::Es => format!("Total de sesiones: {count}"), - Lang::De => format!("Sitzungen gesamt: {count}"), - Lang::Fr => format!("Sessions totales : {count}"), - Lang::Pt => format!("Total de sessões: {count}"), - Lang::Ar => format!("إجمالي الجلسات: {count}"), - Lang::En => format!("Total sessions: {count}"), - } -} - -pub fn by_agent_label(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "按代理分布:", - Lang::ZhTw => "按代理分佈:", - Lang::Ja => "エージェント別:", - Lang::Ko => "에이전트별:", - Lang::Es => "Por agente:", - Lang::De => "Nach Agent:", - Lang::Fr => "Par agent :", - Lang::Pt => "Por agente:", - Lang::Ar => "حسب الوكيل:", - Lang::En => "By agent:", - } -} - -pub fn agent_session_count(lang: Lang, agent: &str, count: u32) -> String { - match lang { - Lang::ZhCn => format!("{agent} - {count} 个会话"), - Lang::ZhTw => format!("{agent} - {count} 個對話"), - Lang::Ja => format!("{agent} - {count} セッション"), - Lang::Ko => format!("{agent} - {count}개 세션"), - Lang::Es => format!("{agent} - {count} sesiones"), - Lang::De => format!("{agent} - {count} Sitzungen"), - Lang::Fr => format!("{agent} - {count} sessions"), - Lang::Pt => format!("{agent} - {count} sessões"), - Lang::Ar => format!("{agent} - {count} جلسات"), - Lang::En => format!("{agent} - {count} sessions"), - } -} - -pub fn projects_label(lang: Lang, projects: &str) -> String { - match lang { - Lang::ZhCn => format!("涉及项目: {projects}"), - Lang::ZhTw => format!("涉及專案: {projects}"), - Lang::Ja => format!("関連プロジェクト: {projects}"), - Lang::Ko => format!("관련 프로젝트: {projects}"), - Lang::Es => format!("Proyectos: {projects}"), - Lang::De => format!("Projekte: {projects}"), - Lang::Fr => format!("Projets : {projects}"), - Lang::Pt => format!("Projetos: {projects}"), - Lang::Ar => format!("المشاريع: {projects}"), - Lang::En => format!("Projects: {projects}"), - } -} - -pub fn key_activities_label(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "主要活动:", - Lang::ZhTw => "主要活動:", - Lang::Ja => "主な活動:", - Lang::Ko => "주요 활동:", - Lang::Es => "Actividades clave:", - Lang::De => "Wichtige Aktivitäten:", - Lang::Fr => "Activités principales :", - Lang::Pt => "Atividades principais:", - Lang::Ar => "الأنشطة الرئيسية:", - Lang::En => "Key activities:", - } -} - -// ── Command responses ── - -pub fn query_failed_title(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "查询失败", - Lang::ZhTw => "查詢失敗", - Lang::Ja => "クエリ失敗", - Lang::Ko => "조회 실패", - Lang::Es => "Error de consulta", - Lang::De => "Abfrage fehlgeschlagen", - Lang::Fr => "Échec de la requête", - Lang::Pt => "Falha na consulta", - Lang::Ar => "فشل الاستعلام", - Lang::En => "Query Failed", - } -} - -pub fn untitled(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "(无标题)", - Lang::ZhTw => "(無標題)", - Lang::Ja => "(無題)", - Lang::Ko => "(제목 없음)", - Lang::Es => "(Sin título)", - Lang::De => "(Ohne Titel)", - Lang::Fr => "(Sans titre)", - Lang::Pt => "(Sem título)", - Lang::Ar => "(بدون عنوان)", - Lang::En => "(Untitled)", - } -} - -pub fn search_no_results(lang: Lang, keyword: &str) -> String { - match lang { - Lang::ZhCn => format!("未找到包含 \"{keyword}\" 的会话"), - Lang::ZhTw => format!("未找到包含 \"{keyword}\" 的對話"), - Lang::Ja => format!("\"{keyword}\" を含むセッションが見つかりません"), - Lang::Ko => format!("\"{keyword}\"을(를) 포함하는 대화를 찾을 수 없습니다"), - Lang::Es => format!("No se encontraron conversaciones con \"{keyword}\""), - Lang::De => format!("Keine Sitzungen mit \"{keyword}\" gefunden"), - Lang::Fr => format!("Aucune session trouvée avec \"{keyword}\""), - Lang::Pt => format!("Nenhuma sessão encontrada com \"{keyword}\""), - Lang::Ar => format!("لم يتم العثور على جلسات تحتوي على \"{keyword}\""), - Lang::En => format!("No conversations found matching \"{keyword}\""), - } -} - -pub fn search_results_title(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "搜索结果", - Lang::ZhTw => "搜尋結果", - Lang::Ja => "検索結果", - Lang::Ko => "검색 결과", - Lang::Es => "Resultados", - Lang::De => "Suchergebnisse", - Lang::Fr => "Résultats", - Lang::Pt => "Resultados", - Lang::Ar => "نتائج البحث", - Lang::En => "Search Results", - } -} - -pub fn search_results_count_title(lang: Lang, keyword: &str, count: usize) -> String { - match lang { - Lang::ZhCn => format!("搜索 \"{keyword}\" - {count} 条结果"), - Lang::ZhTw => format!("搜尋 \"{keyword}\" - {count} 條結果"), - Lang::Ja => format!("\"{keyword}\" の検索 - {count} 件"), - Lang::Ko => format!("\"{keyword}\" 검색 - {count}건"), - Lang::Es => format!("Buscar \"{keyword}\" - {count} resultados"), - Lang::De => format!("Suche \"{keyword}\" - {count} Ergebnisse"), - Lang::Fr => format!("Recherche \"{keyword}\" - {count} résultats"), - Lang::Pt => format!("Busca \"{keyword}\" - {count} resultados"), - Lang::Ar => format!("بحث \"{keyword}\" - {count} نتائج"), - Lang::En => format!("Search \"{keyword}\" - {count} results"), - } -} - -pub fn no_activity_today(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "今日暂无编码活动", - Lang::ZhTw => "今日暫無編碼活動", - Lang::Ja => "本日のコーディング活動はありません", - Lang::Ko => "오늘 코딩 활동이 없습니다", - Lang::Es => "Sin actividad de codificación hoy", - Lang::De => "Heute keine Coding-Aktivität", - Lang::Fr => "Aucune activité de codage aujourd'hui", - Lang::Pt => "Nenhuma atividade de codificação hoje", - Lang::Ar => "لا يوجد نشاط برمجة اليوم", - Lang::En => "No coding activity today", - } -} - -pub fn today_activity_title(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "今日活动", - Lang::ZhTw => "今日活動", - Lang::Ja => "本日の活動", - Lang::Ko => "오늘의 활동", - Lang::Es => "Actividad de hoy", - Lang::De => "Heutige Aktivität", - Lang::Fr => "Activité du jour", - Lang::Pt => "Atividade de hoje", - Lang::Ar => "نشاط اليوم", - Lang::En => "Today's Activity", - } -} - -pub fn today_activity_date_title(lang: Lang, date: &str) -> String { - match lang { - Lang::ZhCn => format!("今日活动 ({date})"), - Lang::ZhTw => format!("今日活動 ({date})"), - Lang::Ja => format!("本日の活動 ({date})"), - Lang::Ko => format!("오늘의 활동 ({date})"), - Lang::Es => format!("Actividad de hoy ({date})"), - Lang::De => format!("Heutige Aktivität ({date})"), - Lang::Fr => format!("Activité du jour ({date})"), - Lang::Pt => format!("Atividade de hoje ({date})"), - Lang::Ar => format!("نشاط اليوم ({date})"), - Lang::En => format!("Today's Activity ({date})"), - } -} - -pub fn agent_count(lang: Lang, agent: &str, count: u32) -> String { - match lang { - Lang::ZhCn => format!("{agent} - {count} 个"), - Lang::ZhTw => format!("{agent} - {count} 個"), - Lang::Ja => format!("{agent} - {count} 件"), - Lang::Ko => format!("{agent} - {count}개"), - Lang::Es | Lang::De | Lang::Fr | Lang::Pt | Lang::Ar | Lang::En => { - format!("{agent} - {count}") - } - } -} - -pub fn recent_activity_label(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "最近活动:", - Lang::ZhTw => "最近活動:", - Lang::Ja => "最近の活動:", - Lang::Ko => "최근 활동:", - Lang::Es => "Actividad reciente:", - Lang::De => "Letzte Aktivität:", - Lang::Fr => "Activité récente :", - Lang::Pt => "Atividade recente:", - Lang::Ar => "النشاط الأخير:", - Lang::En => "Recent activity:", - } -} - -pub fn no_active_channels(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "暂无活跃渠道", - Lang::ZhTw => "暫無活躍頻道", - Lang::Ja => "アクティブなチャンネルなし", - Lang::Ko => "활성 채널 없음", - Lang::Es => "Sin canales activos", - Lang::De => "Keine aktiven Kanäle", - Lang::Fr => "Aucun canal actif", - Lang::Pt => "Nenhum canal ativo", - Lang::Ar => "لا توجد قنوات نشطة", - Lang::En => "No active channels", - } -} - -pub fn channel_status_title(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "渠道状态", - Lang::ZhTw => "頻道狀態", - Lang::Ja => "チャンネル状況", - Lang::Ko => "채널 상태", - Lang::Es => "Estado de canales", - Lang::De => "Kanalstatus", - Lang::Fr => "Statut des canaux", - Lang::Pt => "Status dos canais", - Lang::Ar => "حالة القنوات", - Lang::En => "Channel Status", - } -} - -pub fn help_title(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "Codeg Bot 帮助", - Lang::ZhTw => "Codeg Bot 幫助", - Lang::Ja => "Codeg Bot ヘルプ", - Lang::Ko => "Codeg Bot 도움말", - Lang::Es => "Ayuda de Codeg Bot", - Lang::De => "Codeg Bot Hilfe", - Lang::Fr => "Aide Codeg Bot", - Lang::Pt => "Ajuda do Codeg Bot", - Lang::Ar => "مساعدة Codeg Bot", - Lang::En => "Codeg Bot Help", - } -} - -pub fn help_body(lang: Lang, prefix: &str) -> String { - match lang { - Lang::ZhCn => format!( - "{prefix}folder - 选择工作目录\n\ - {prefix}agent - 选择 Agent\n\ - {prefix}task <描述> - 创建会话并执行任务\n\ - {prefix}sessions - 当前目录的活跃会话\n\ - {prefix}resume [ID] - 最近会话 / 恢复指定会话\n\ - {prefix}cancel - 取消当前任务\n\ - {prefix}approve [always] - 批准权限请求\n\ - {prefix}deny - 拒绝权限请求\n\ - \n\ - {prefix}search <关键词> - 搜索会话\n\ - {prefix}today - 今日活动汇总\n\ - {prefix}status - 渠道连接状态\n\ - {prefix}help - 显示帮助\n\ - \n\ - 有活跃会话时,直接发文本即可继续对话" - ), - Lang::ZhTw => format!( - "{prefix}folder - 選擇工作目錄\n\ - {prefix}agent - 選擇 Agent\n\ - {prefix}task <描述> - 建立對話並執行任務\n\ - {prefix}sessions - 當前目錄的活躍對話\n\ - {prefix}resume [ID] - 最近對話 / 恢復指定對話\n\ - {prefix}cancel - 取消當前任務\n\ - {prefix}approve [always] - 批准權限請求\n\ - {prefix}deny - 拒絕權限請求\n\ - \n\ - {prefix}search <關鍵字> - 搜尋對話\n\ - {prefix}today - 今日活動匯總\n\ - {prefix}status - 頻道連線狀態\n\ - {prefix}help - 顯示幫助\n\ - \n\ - 有活躍對話時,直接發文字即可繼續對話" - ), - Lang::Ja => format!( - "{prefix}folder - 作業フォルダを選択\n\ - {prefix}agent - エージェントを選択\n\ - {prefix}task <説明> - セッションを作成してタスクを実行\n\ - {prefix}sessions - フォルダ内のアクティブセッション\n\ - {prefix}resume [ID] - 最近のセッション / セッションを再開\n\ - {prefix}cancel - 現在のタスクをキャンセル\n\ - {prefix}approve [always] - 権限を承認\n\ - {prefix}deny - 権限を拒否\n\ - \n\ - {prefix}search <キーワード> - セッション検索\n\ - {prefix}today - 本日の活動まとめ\n\ - {prefix}status - チャンネル接続状況\n\ - {prefix}help - ヘルプを表示\n\ - \n\ - セッションがアクティブな場合、テキストを送信するだけで会話を続けられます" - ), - Lang::Ko => format!( - "{prefix}folder - 작업 폴더 선택\n\ - {prefix}agent - 에이전트 선택\n\ - {prefix}task <설명> - 세션 생성 및 작업 실행\n\ - {prefix}sessions - 폴더 내 활성 세션\n\ - {prefix}resume [ID] - 최근 대화 / 세션 재개\n\ - {prefix}cancel - 현재 작업 취소\n\ - {prefix}approve [always] - 권한 승인\n\ - {prefix}deny - 권한 거부\n\ - \n\ - {prefix}search <키워드> - 대화 검색\n\ - {prefix}today - 오늘의 활동 요약\n\ - {prefix}status - 채널 연결 상태\n\ - {prefix}help - 도움말 표시\n\ - \n\ - 세션이 활성화된 경우 텍스트를 보내면 대화를 계속할 수 있습니다" - ), - Lang::Es => format!( - "{prefix}folder - Seleccionar carpeta de trabajo\n\ - {prefix}agent - Seleccionar agente\n\ - {prefix}task - Crear sesion y ejecutar tarea\n\ - {prefix}sessions - Sesiones activas en la carpeta\n\ - {prefix}resume [ID] - Recientes / reanudar una sesion\n\ - {prefix}cancel - Cancelar tarea actual\n\ - {prefix}approve [always] - Aprobar permiso\n\ - {prefix}deny - Denegar permiso\n\ - \n\ - {prefix}search - Buscar conversaciones\n\ - {prefix}today - Resumen de hoy\n\ - {prefix}status - Estado de canales\n\ - {prefix}help - Mostrar ayuda\n\ - \n\ - Cuando hay una sesion activa, simplemente escriba texto para continuar" - ), - Lang::De => format!( - "{prefix}folder - Arbeitsordner auswahlen\n\ - {prefix}agent - Agent auswahlen\n\ - {prefix}task - Sitzung erstellen und Aufgabe ausfuhren\n\ - {prefix}sessions - Aktive Sitzungen im Ordner\n\ - {prefix}resume [ID] - Neueste Sitzungen / Sitzung fortsetzen\n\ - {prefix}cancel - Aktuelle Aufgabe abbrechen\n\ - {prefix}approve [always] - Berechtigung genehmigen\n\ - {prefix}deny - Berechtigung verweigern\n\ - \n\ - {prefix}search - Sitzungen suchen\n\ - {prefix}today - Heutige Zusammenfassung\n\ - {prefix}status - Kanalstatus\n\ - {prefix}help - Hilfe anzeigen\n\ - \n\ - Bei aktiver Sitzung einfach Text eingeben, um das Gesprach fortzusetzen" - ), - Lang::Fr => format!( - "{prefix}folder - Selectionner le dossier de travail\n\ - {prefix}agent - Selectionner l'agent\n\ - {prefix}task - Creer une session et executer une tache\n\ - {prefix}sessions - Sessions actives dans le dossier\n\ - {prefix}resume [ID] - Sessions recentes / reprendre une session\n\ - {prefix}cancel - Annuler la tache en cours\n\ - {prefix}approve [always] - Approuver la permission\n\ - {prefix}deny - Refuser la permission\n\ - \n\ - {prefix}search - Rechercher des sessions\n\ - {prefix}today - Resume du jour\n\ - {prefix}status - Statut des canaux\n\ - {prefix}help - Afficher l'aide\n\ - \n\ - Lorsqu'une session est active, envoyez du texte pour continuer la conversation" - ), - Lang::Pt => format!( - "{prefix}folder - Selecionar pasta de trabalho\n\ - {prefix}agent - Selecionar agente\n\ - {prefix}task - Criar sessao e executar tarefa\n\ - {prefix}sessions - Sessoes ativas na pasta\n\ - {prefix}resume [ID] - Recentes / retomar uma sessao\n\ - {prefix}cancel - Cancelar tarefa atual\n\ - {prefix}approve [always] - Aprovar permissao\n\ - {prefix}deny - Negar permissao\n\ - \n\ - {prefix}search - Buscar sessoes\n\ - {prefix}today - Resumo de hoje\n\ - {prefix}status - Status dos canais\n\ - {prefix}help - Mostrar ajuda\n\ - \n\ - Quando uma sessao esta ativa, basta digitar texto para continuar a conversa" - ), - Lang::Ar => format!( - "{prefix}folder - اختيار مجلد العمل\n\ - {prefix}agent - اختيار الوكيل\n\ - {prefix}task <وصف> - انشاء جلسة وتنفيذ مهمة\n\ - {prefix}sessions - الجلسات النشطة في المجلد\n\ - {prefix}resume [ID] - الجلسات الاخيرة / استئناف جلسة\n\ - {prefix}cancel - الغاء المهمة الحالية\n\ - {prefix}approve [always] - الموافقة على الاذن\n\ - {prefix}deny - رفض الاذن\n\ - \n\ - {prefix}search <كلمة> - البحث في الجلسات\n\ - {prefix}today - ملخص اليوم\n\ - {prefix}status - حالة القنوات\n\ - {prefix}help - عرض المساعدة\n\ - \n\ - عندما تكون الجلسة نشطة، ارسل نصا لمتابعة المحادثة" - ), - Lang::En => format!( - "{prefix}folder - Select working folder\n\ - {prefix}agent - Select agent\n\ - {prefix}task - Create session & run task\n\ - {prefix}sessions - Active sessions in folder\n\ - {prefix}resume [ID] - Recent conversations / resume a session\n\ - {prefix}cancel - Cancel current task\n\ - {prefix}approve [always] - Approve permission\n\ - {prefix}deny - Deny permission\n\ - \n\ - {prefix}search - Search conversations\n\ - {prefix}today - Today's activity summary\n\ - {prefix}status - Channel connection status\n\ - {prefix}help - Show help\n\ - \n\ - When a session is active, just type text to continue the conversation" - ), - } -} - -// ── Command dispatcher messages ── - -pub fn invalid_args_title(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "参数错误", - Lang::ZhTw => "參數錯誤", - Lang::Ja => "引数エラー", - Lang::Ko => "인수 오류", - Lang::Es => "Argumentos inválidos", - Lang::De => "Ungültige Argumente", - Lang::Fr => "Arguments invalides", - Lang::Pt => "Argumentos inválidos", - Lang::Ar => "وسيطات غير صالحة", - Lang::En => "Invalid Arguments", - } -} - -pub fn search_usage(lang: Lang, prefix: &str) -> String { - match lang { - Lang::ZhCn => format!("用法: {prefix}search <关键词>"), - Lang::ZhTw => format!("用法: {prefix}search <關鍵字>"), - Lang::Ja => format!("使い方: {prefix}search <キーワード>"), - Lang::Ko => format!("사용법: {prefix}search <키워드>"), - Lang::Es => format!("Uso: {prefix}search "), - Lang::De => format!("Verwendung: {prefix}search "), - Lang::Fr => format!("Utilisation : {prefix}search "), - Lang::Pt => format!("Uso: {prefix}search "), - Lang::Ar => format!("الاستخدام: {prefix}search <كلمة>"), - Lang::En => format!("Usage: {prefix}search "), - } -} - -pub fn unknown_command(lang: Lang, prefix: &str, command: &str) -> String { - match lang { - Lang::ZhCn => format!("未知命令: {prefix}{command}\n输入 {prefix}help 查看可用命令"), - Lang::ZhTw => format!("未知命令: {prefix}{command}\n輸入 {prefix}help 查看可用命令"), - Lang::Ja => format!("不明なコマンド: {prefix}{command}\n{prefix}help でヘルプを表示"), - Lang::Ko => format!("알 수 없는 명령: {prefix}{command}\n{prefix}help 로 도움말 보기"), - Lang::Es => format!( - "Comando desconocido: {prefix}{command}\nEscriba {prefix}help para ver los comandos" - ), - Lang::De => { - format!("Unbekannter Befehl: {prefix}{command}\n{prefix}help für Hilfe eingeben") - } - Lang::Fr => { - format!("Commande inconnue : {prefix}{command}\nTapez {prefix}help pour l'aide") - } - Lang::Pt => { - format!("Comando desconhecido: {prefix}{command}\nDigite {prefix}help para ajuda") - } - Lang::Ar => format!("أمر غير معروف: {prefix}{command}\nاكتب {prefix}help لعرض المساعدة"), - Lang::En => { - format!("Unknown command: {prefix}{command}\nType {prefix}help for available commands") - } - } -} - -pub fn unknown_command_title(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "未知命令", - Lang::ZhTw => "未知命令", - Lang::Ja => "不明なコマンド", - Lang::Ko => "알 수 없는 명령", - Lang::Es => "Comando desconocido", - Lang::De => "Unbekannter Befehl", - Lang::Fr => "Commande inconnue", - Lang::Pt => "Comando desconhecido", - Lang::Ar => "أمر غير معروف", - Lang::En => "Unknown Command", - } -} - -// ── Session command messages ── - -// Folder (/folder) -pub fn folder_title(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "工作目录", - Lang::ZhTw => "工作目錄", - Lang::Ja => "作業フォルダ", - Lang::Ko => "작업 폴더", - Lang::Es => "Carpeta de trabajo", - Lang::De => "Arbeitsordner", - Lang::Fr => "Dossier de travail", - Lang::Pt => "Pasta de trabalho", - Lang::Ar => "مجلد العمل", - Lang::En => "Working Folder", - } -} - -pub fn no_folders_found(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "没有找到项目目录。", - Lang::ZhTw => "沒有找到專案目錄。", - Lang::Ja => "フォルダが見つかりません。", - Lang::Ko => "폴더를 찾을 수 없습니다.", - Lang::Es => "No se encontraron carpetas.", - Lang::De => "Keine Ordner gefunden.", - Lang::Fr => "Aucun dossier trouvé.", - Lang::Pt => "Nenhuma pasta encontrada.", - Lang::Ar => "لم يتم العثور على مجلدات.", - Lang::En => "No folders found.", - } -} - -pub fn folder_select_hint(lang: Lang, prefix: &str) -> String { - match lang { - Lang::ZhCn => format!("回复 {prefix}folder <数字> 选择目录。"), - Lang::ZhTw => format!("回覆 {prefix}folder <數字> 選擇目錄。"), - Lang::Ja => format!("{prefix}folder <番号> で選択してください。"), - Lang::Ko => format!("{prefix}folder <번호>로 선택하세요."), - Lang::Es => format!("Responde {prefix}folder para seleccionar."), - Lang::De => format!("Antworte {prefix}folder zur Auswahl."), - Lang::Fr => format!("Répondez {prefix}folder pour sélectionner."), - Lang::Pt => format!("Responda {prefix}folder para selecionar."), - Lang::Ar => format!("أجب بـ {prefix}folder <رقم> للاختيار."), - Lang::En => format!("Reply {prefix}folder to select."), - } -} - -pub fn index_starts_from_one(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "序号从 1 开始。", - Lang::ZhTw => "序號從 1 開始。", - Lang::Ja => "インデックスは 1 から始まります。", - Lang::Ko => "인덱스는 1부터 시작합니다.", - Lang::Es => "El índice empieza desde 1.", - Lang::De => "Index beginnt bei 1.", - Lang::Fr => "L'index commence à 1.", - Lang::Pt => "O índice começa em 1.", - Lang::Ar => "يبدأ الفهرس من 1.", - Lang::En => "Index starts from 1.", - } -} - -pub fn folder_index_out_of_range(lang: Lang, prefix: &str) -> String { - match lang { - Lang::ZhCn => format!("序号超出范围,请使用 {prefix}folder 查看列表。"), - Lang::ZhTw => format!("序號超出範圍,請使用 {prefix}folder 查看列表。"), - Lang::Ja => { - format!("インデックスが範囲外です。{prefix}folder でリストを確認してください。") - } - Lang::Ko => format!("인덱스가 범위를 벗어났습니다. {prefix}folder로 목록을 확인하세요."), - Lang::Es => format!("Índice fuera de rango. Usa {prefix}folder para ver la lista."), - Lang::De => { - format!("Index außerhalb des Bereichs. {prefix}folder verwenden, um aufzulisten.") - } - Lang::Fr => format!("Index hors limites. Utilisez {prefix}folder pour lister."), - Lang::Pt => format!("Índice fora de intervalo. Use {prefix}folder para listar."), - Lang::Ar => format!("الفهرس خارج النطاق. استخدم {prefix}folder لعرض القائمة."), - Lang::En => format!("Index out of range. Use {prefix}folder to list."), - } -} - -pub fn folder_selected_title(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "已选择目录", - Lang::ZhTw => "已選擇目錄", - Lang::Ja => "フォルダを選択しました", - Lang::Ko => "폴더 선택됨", - Lang::Es => "Carpeta seleccionada", - Lang::De => "Ordner ausgewählt", - Lang::Fr => "Dossier sélectionné", - Lang::Pt => "Pasta selecionada", - Lang::Ar => "تم اختيار المجلد", - Lang::En => "Folder Selected", - } -} - -pub fn folder_not_found(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "目录不存在。", - Lang::ZhTw => "目錄不存在。", - Lang::Ja => "フォルダが見つかりません。", - Lang::Ko => "폴더를 찾을 수 없습니다.", - Lang::Es => "Carpeta no encontrada.", - Lang::De => "Ordner nicht gefunden.", - Lang::Fr => "Dossier introuvable.", - Lang::Pt => "Pasta não encontrada.", - Lang::Ar => "المجلد غير موجود.", - Lang::En => "Folder not found.", - } -} - -pub fn folder_not_found_with_hint(lang: Lang, prefix: &str) -> String { - match lang { - Lang::ZhCn => format!("目录不存在,请使用 {prefix}folder 重新选择。"), - Lang::ZhTw => format!("目錄不存在,請使用 {prefix}folder 重新選擇。"), - Lang::Ja => format!("フォルダが見つかりません。{prefix}folder で選択してください。"), - Lang::Ko => format!("폴더를 찾을 수 없습니다. {prefix}folder로 선택하세요."), - Lang::Es => format!("Carpeta no encontrada. Usa {prefix}folder para seleccionar."), - Lang::De => format!("Ordner nicht gefunden. {prefix}folder verwenden, um auszuwählen."), - Lang::Fr => format!("Dossier introuvable. Utilisez {prefix}folder pour sélectionner."), - Lang::Pt => format!("Pasta não encontrada. Use {prefix}folder para selecionar."), - Lang::Ar => format!("المجلد غير موجود. استخدم {prefix}folder للاختيار."), - Lang::En => format!("Folder not found. Use {prefix}folder to select."), - } -} - -pub fn no_folder_selected(lang: Lang, prefix: &str) -> String { - match lang { - Lang::ZhCn => format!("未选择工作目录,请先使用 {prefix}folder 选择。"), - Lang::ZhTw => format!("未選擇工作目錄,請先使用 {prefix}folder 選擇。"), - Lang::Ja => { - format!("フォルダが選択されていません。先に {prefix}folder を使用してください。") - } - Lang::Ko => format!("폴더가 선택되지 않았습니다. 먼저 {prefix}folder를 사용하세요."), - Lang::Es => format!("Ninguna carpeta seleccionada. Usa {prefix}folder primero."), - Lang::De => format!("Kein Ordner ausgewählt. Zuerst {prefix}folder verwenden."), - Lang::Fr => format!("Aucun dossier sélectionné. Utilisez d'abord {prefix}folder."), - Lang::Pt => format!("Nenhuma pasta selecionada. Use {prefix}folder primeiro."), - Lang::Ar => format!("لم يتم اختيار مجلد. استخدم {prefix}folder أولاً."), - Lang::En => format!("No folder selected. Use {prefix}folder first."), - } -} - -// Agent (/agent) -pub fn agent_title(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "选择 Agent", - Lang::ZhTw => "選擇 Agent", - Lang::Ja => "エージェント選択", - Lang::Ko => "에이전트 선택", - Lang::Es => "Selección de agente", - Lang::De => "Agent-Auswahl", - Lang::Fr => "Sélection d'agent", - Lang::Pt => "Seleção de agente", - Lang::Ar => "اختيار الوكيل", - Lang::En => "Agent Selection", - } -} - -pub fn agent_select_hint(lang: Lang, prefix: &str) -> String { - match lang { - Lang::ZhCn => format!("回复 {prefix}agent <数字> 或 {prefix}agent <名称> 选择。"), - Lang::ZhTw => format!("回覆 {prefix}agent <數字> 或 {prefix}agent <名稱> 選擇。"), - Lang::Ja => { - format!("{prefix}agent <番号> または {prefix}agent <名前> で選択してください。") - } - Lang::Ko => format!("{prefix}agent <번호> 또는 {prefix}agent <이름>으로 선택하세요."), - Lang::Es => { - format!("Responde {prefix}agent o {prefix}agent para seleccionar.") - } - Lang::De => { - format!("Antworte {prefix}agent oder {prefix}agent zur Auswahl.") - } - Lang::Fr => { - format!("Répondez {prefix}agent ou {prefix}agent pour sélectionner.") - } - Lang::Pt => { - format!("Responda {prefix}agent ou {prefix}agent para selecionar.") - } - Lang::Ar => format!("أجب بـ {prefix}agent <رقم> أو {prefix}agent <اسم> للاختيار."), - Lang::En => format!("Reply {prefix}agent or {prefix}agent to select."), - } -} - -pub fn agent_index_out_of_range(lang: Lang, prefix: &str) -> String { - match lang { - Lang::ZhCn => format!("序号超出范围,请使用 {prefix}agent 查看列表。"), - Lang::ZhTw => format!("序號超出範圍,請使用 {prefix}agent 查看列表。"), - Lang::Ja => format!("インデックスが範囲外です。{prefix}agent でリストを確認してください。"), - Lang::Ko => format!("인덱스가 범위를 벗어났습니다. {prefix}agent로 목록을 확인하세요."), - Lang::Es => format!("Índice fuera de rango. Usa {prefix}agent para ver la lista."), - Lang::De => { - format!("Index außerhalb des Bereichs. {prefix}agent verwenden, um aufzulisten.") - } - Lang::Fr => format!("Index hors limites. Utilisez {prefix}agent pour lister."), - Lang::Pt => format!("Índice fora de intervalo. Use {prefix}agent para listar."), - Lang::Ar => format!("الفهرس خارج النطاق. استخدم {prefix}agent لعرض القائمة."), - Lang::En => format!("Index out of range. Use {prefix}agent to list."), - } -} - -pub fn agent_selected_title(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "已选择 Agent", - Lang::ZhTw => "已選擇 Agent", - Lang::Ja => "エージェントを選択しました", - Lang::Ko => "에이전트 선택됨", - Lang::Es => "Agente seleccionado", - Lang::De => "Agent ausgewählt", - Lang::Fr => "Agent sélectionné", - Lang::Pt => "Agente selecionado", - Lang::Ar => "تم اختيار الوكيل", - Lang::En => "Agent Selected", - } -} - -pub fn unknown_agent_label(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "未知 Agent: ", - Lang::ZhTw => "未知 Agent: ", - Lang::Ja => "不明なエージェント: ", - Lang::Ko => "알 수 없는 에이전트: ", - Lang::Es => "Agente desconocido: ", - Lang::De => "Unbekannter Agent: ", - Lang::Fr => "Agent inconnu : ", - Lang::Pt => "Agente desconhecido: ", - Lang::Ar => "وكيل غير معروف: ", - Lang::En => "Unknown agent: ", - } -} - -// Task (/task) -pub fn task_usage(lang: Lang, prefix: &str) -> String { - match lang { - Lang::ZhCn => format!("用法: {prefix}task <任务描述>"), - Lang::ZhTw => format!("用法: {prefix}task <任務描述>"), - Lang::Ja => format!("使い方: {prefix}task <タスク説明>"), - Lang::Ko => format!("사용법: {prefix}task <작업 설명>"), - Lang::Es => format!("Uso: {prefix}task "), - Lang::De => format!("Verwendung: {prefix}task "), - Lang::Fr => format!("Usage : {prefix}task "), - Lang::Pt => format!("Uso: {prefix}task "), - Lang::Ar => format!("الاستخدام: {prefix}task <الوصف>"), - Lang::En => format!("Usage: {prefix}task "), - } -} - -pub fn no_agent_selected(lang: Lang, prefix: &str) -> String { - match lang { - Lang::ZhCn => format!("未选择 Agent,请先使用 {prefix}agent 选择,或在工作目录上设置默认 Agent。"), - Lang::ZhTw => format!("未選擇 Agent,請先使用 {prefix}agent 選擇,或在工作目錄上設定預設 Agent。"), - Lang::Ja => format!("エージェントが選択されていません。{prefix}agent で選択するか、フォルダにデフォルトエージェントを設定してください。"), - Lang::Ko => format!("에이전트가 선택되지 않았습니다. {prefix}agent로 선택하거나 폴더에 기본 에이전트를 설정하세요."), - Lang::Es => format!("Ningún agente seleccionado. Usa {prefix}agent para elegir uno o define uno por defecto en la carpeta."), - Lang::De => format!("Kein Agent ausgewählt. {prefix}agent verwenden oder Standard im Ordner festlegen."), - Lang::Fr => format!("Aucun agent sélectionné. Utilisez {prefix}agent ou définissez un agent par défaut sur le dossier."), - Lang::Pt => format!("Nenhum agente selecionado. Use {prefix}agent para escolher ou defina um padrão na pasta."), - Lang::Ar => format!("لم يتم اختيار وكيل. استخدم {prefix}agent لاختيار واحد أو حدد وكيلًا افتراضيًا للمجلد."), - Lang::En => format!("No agent selected. Use {prefix}agent to pick one or set a default on the folder."), - } -} - -pub fn failed_to_start_agent_label(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "启动 Agent 失败: ", - Lang::ZhTw => "啟動 Agent 失敗: ", - Lang::Ja => "エージェントの起動に失敗しました: ", - Lang::Ko => "에이전트 시작 실패: ", - Lang::Es => "Error al iniciar el agente: ", - Lang::De => "Agent konnte nicht gestartet werden: ", - Lang::Fr => "Échec du démarrage de l'agent : ", - Lang::Pt => "Falha ao iniciar o agente: ", - Lang::Ar => "فشل بدء الوكيل: ", - Lang::En => "Failed to start agent: ", - } -} - -pub fn task_started_title(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "任务已启动", - Lang::ZhTw => "任務已啟動", - Lang::Ja => "タスク開始", - Lang::Ko => "작업 시작됨", - Lang::Es => "Tarea iniciada", - Lang::De => "Aufgabe gestartet", - Lang::Fr => "Tâche démarrée", - Lang::Pt => "Tarefa iniciada", - Lang::Ar => "تم بدء المهمة", - Lang::En => "Task Started", - } -} - -// Sessions (/sessions) -pub fn sessions_title(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "会话列表", - Lang::ZhTw => "對話列表", - Lang::Ja => "セッション一覧", - Lang::Ko => "세션 목록", - Lang::Es => "Sesiones", - Lang::De => "Sitzungen", - Lang::Fr => "Sessions", - Lang::Pt => "Sessões", - Lang::Ar => "الجلسات", - Lang::En => "Sessions", - } -} - -pub fn no_active_sessions_in_folder(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "当前目录没有进行中的会话。", - Lang::ZhTw => "當前目錄沒有進行中的對話。", - Lang::Ja => "このフォルダにアクティブなセッションはありません。", - Lang::Ko => "이 폴더에 활성 세션이 없습니다.", - Lang::Es => "No hay sesiones activas en esta carpeta.", - Lang::De => "Keine aktiven Sitzungen in diesem Ordner.", - Lang::Fr => "Aucune session active dans ce dossier.", - Lang::Pt => "Nenhuma sessão ativa nesta pasta.", - Lang::Ar => "لا توجد جلسات نشطة في هذا المجلد.", - Lang::En => "No active sessions in this folder.", - } -} - -pub fn sessions_resume_hint(lang: Lang, prefix: &str) -> String { - match lang { - Lang::ZhCn => format!("回复 {prefix}resume <会话ID> 继续会话。"), - Lang::ZhTw => format!("回覆 {prefix}resume <對話ID> 繼續對話。"), - Lang::Ja => format!("{prefix}resume で続行してください。"), - Lang::Ko => format!("{prefix}resume 로 계속하세요."), - Lang::Es => format!("Responde {prefix}resume para continuar."), - Lang::De => format!("Antworte {prefix}resume zum Fortfahren."), - Lang::Fr => format!("Répondez {prefix}resume pour continuer."), - Lang::Pt => format!("Responda {prefix}resume para continuar."), - Lang::Ar => format!("أجب بـ {prefix}resume <المعرف> للاستمرار."), - Lang::En => format!("Reply {prefix}resume to continue."), - } -} - -// Resume (/resume) -pub fn conversation_not_found(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "会话不存在。", - Lang::ZhTw => "對話不存在。", - Lang::Ja => "会話が見つかりません。", - Lang::Ko => "대화를 찾을 수 없습니다.", - Lang::Es => "Conversación no encontrada.", - Lang::De => "Konversation nicht gefunden.", - Lang::Fr => "Conversation introuvable.", - Lang::Pt => "Conversa não encontrada.", - Lang::Ar => "المحادثة غير موجودة.", - Lang::En => "Conversation not found.", - } -} - -pub fn session_resumed_title(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "会话已恢复", - Lang::ZhTw => "對話已恢復", - Lang::Ja => "セッション再開", - Lang::Ko => "세션 재개됨", - Lang::Es => "Sesión reanudada", - Lang::De => "Sitzung fortgesetzt", - Lang::Fr => "Session reprise", - Lang::Pt => "Sessão retomada", - Lang::Ar => "تم استئناف الجلسة", - Lang::En => "Session Resumed", - } -} - -pub fn no_conversations_found(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "暂无会话记录。", - Lang::ZhTw => "暫無對話記錄。", - Lang::Ja => "会話記録がありません。", - Lang::Ko => "대화 기록이 없습니다.", - Lang::Es => "No hay conversaciones.", - Lang::De => "Keine Konversationen vorhanden.", - Lang::Fr => "Aucune conversation trouvée.", - Lang::Pt => "Nenhuma conversa encontrada.", - Lang::Ar => "لا توجد محادثات.", - Lang::En => "No conversations found.", - } -} - -pub fn recent_conversations_title(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "最近会话", - Lang::ZhTw => "最近對話", - Lang::Ja => "最近の会話", - Lang::Ko => "최근 대화", - Lang::Es => "Conversaciones recientes", - Lang::De => "Letzte Konversationen", - Lang::Fr => "Conversations récentes", - Lang::Pt => "Conversas recentes", - Lang::Ar => "المحادثات الأخيرة", - Lang::En => "Recent Conversations", - } -} - -pub fn recent_resume_hint(lang: Lang, prefix: &str) -> String { - match lang { - Lang::ZhCn => format!("回复 {prefix}resume <会话ID> 恢复会话。"), - Lang::ZhTw => format!("回覆 {prefix}resume <對話ID> 恢復對話。"), - Lang::Ja => format!("{prefix}resume でセッションを再開してください。"), - Lang::Ko => format!("{prefix}resume 로 세션을 재개하세요."), - Lang::Es => format!("Responde {prefix}resume para reanudar una sesión."), - Lang::De => format!("Antworte {prefix}resume zum Fortsetzen einer Sitzung."), - Lang::Fr => format!("Répondez {prefix}resume pour reprendre une session."), - Lang::Pt => format!("Responda {prefix}resume para retomar uma sessão."), - Lang::Ar => format!("أجب بـ {prefix}resume <المعرف> لاستئناف الجلسة."), - Lang::En => format!("Reply {prefix}resume to resume a session."), - } -} - -// Cancel (/cancel) -pub fn no_active_session_to_cancel(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "没有进行中的任务可取消。", - Lang::ZhTw => "沒有進行中的任務可取消。", - Lang::Ja => "キャンセルできるアクティブなセッションはありません。", - Lang::Ko => "취소할 활성 세션이 없습니다.", - Lang::Es => "No hay sesión activa para cancelar.", - Lang::De => "Keine aktive Sitzung zum Abbrechen.", - Lang::Fr => "Aucune session active à annuler.", - Lang::Pt => "Nenhuma sessão ativa para cancelar.", - Lang::Ar => "لا توجد جلسة نشطة للإلغاء.", - Lang::En => "No active session to cancel.", - } -} - -pub fn task_cancelled_body(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "当前任务已取消。", - Lang::ZhTw => "當前任務已取消。", - Lang::Ja => "現在のタスクをキャンセルしました。", - Lang::Ko => "현재 작업이 취소되었습니다.", - Lang::Es => "La tarea actual ha sido cancelada.", - Lang::De => "Aktuelle Aufgabe wurde abgebrochen.", - Lang::Fr => "La tâche en cours a été annulée.", - Lang::Pt => "A tarefa atual foi cancelada.", - Lang::Ar => "تم إلغاء المهمة الحالية.", - Lang::En => "Current task has been cancelled.", - } -} - -pub fn task_cancelled_title(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "任务已取消", - Lang::ZhTw => "任務已取消", - Lang::Ja => "タスクをキャンセルしました", - Lang::Ko => "작업 취소됨", - Lang::Es => "Tarea cancelada", - Lang::De => "Aufgabe abgebrochen", - Lang::Fr => "Tâche annulée", - Lang::Pt => "Tarefa cancelada", - Lang::Ar => "تم إلغاء المهمة", - Lang::En => "Task Cancelled", - } -} - -// Permission (/approve, /deny) -pub fn no_active_session(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "没有活跃的会话。", - Lang::ZhTw => "沒有活躍的對話。", - Lang::Ja => "アクティブなセッションがありません。", - Lang::Ko => "활성 세션이 없습니다.", - Lang::Es => "No hay sesión activa.", - Lang::De => "Keine aktive Sitzung.", - Lang::Fr => "Aucune session active.", - Lang::Pt => "Nenhuma sessão ativa.", - Lang::Ar => "لا توجد جلسة نشطة.", - Lang::En => "No active session.", - } -} - -pub fn no_active_session_found(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "未找到活跃的会话。", - Lang::ZhTw => "未找到活躍的對話。", - Lang::Ja => "アクティブなセッションが見つかりません。", - Lang::Ko => "활성 세션을 찾을 수 없습니다.", - Lang::Es => "No se encontró sesión activa.", - Lang::De => "Keine aktive Sitzung gefunden.", - Lang::Fr => "Aucune session active trouvée.", - Lang::Pt => "Nenhuma sessão ativa encontrada.", - Lang::Ar => "لم يتم العثور على جلسة نشطة.", - Lang::En => "No active session found.", - } -} - -pub fn no_pending_permission(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "没有待处理的权限请求。", - Lang::ZhTw => "沒有待處理的權限請求。", - Lang::Ja => "保留中の権限要求はありません。", - Lang::Ko => "대기 중인 권한 요청이 없습니다.", - Lang::Es => "No hay solicitudes de permiso pendientes.", - Lang::De => "Keine ausstehende Berechtigungsanfrage.", - Lang::Fr => "Aucune demande d'autorisation en attente.", - Lang::Pt => "Nenhuma solicitação de permissão pendente.", - Lang::Ar => "لا توجد طلبات أذونات معلقة.", - Lang::En => "No pending permission request.", - } -} - -pub fn no_valid_permission_option(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "未找到有效的权限选项。", - Lang::ZhTw => "未找到有效的權限選項。", - Lang::Ja => "有効な権限オプションが見つかりません。", - Lang::Ko => "유효한 권한 옵션을 찾을 수 없습니다.", - Lang::Es => "No se encontró una opción de permiso válida.", - Lang::De => "Keine gültige Berechtigungsoption gefunden.", - Lang::Fr => "Aucune option d'autorisation valide trouvée.", - Lang::Pt => "Nenhuma opção de permissão válida encontrada.", - Lang::Ar => "لم يتم العثور على خيار أذونات صالح.", - Lang::En => "No valid permission option found.", - } -} - -pub fn failed_permission_response_label(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "权限响应失败: ", - Lang::ZhTw => "權限回應失敗: ", - Lang::Ja => "権限応答に失敗しました: ", - Lang::Ko => "권한 응답 실패: ", - Lang::Es => "Error al responder al permiso: ", - Lang::De => "Berechtigungsantwort fehlgeschlagen: ", - Lang::Fr => "Échec de la réponse à l'autorisation : ", - Lang::Pt => "Falha ao responder à permissão: ", - Lang::Ar => "فشل الاستجابة للإذن: ", - Lang::En => "Failed to respond to permission: ", - } -} - -pub fn approved_label(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "已批准", - Lang::ZhTw => "已批准", - Lang::Ja => "承認済み", - Lang::Ko => "승인됨", - Lang::Es => "Aprobado", - Lang::De => "Genehmigt", - Lang::Fr => "Approuvé", - Lang::Pt => "Aprovado", - Lang::Ar => "تمت الموافقة", - Lang::En => "Approved", - } -} - -pub fn denied_label(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "已拒绝", - Lang::ZhTw => "已拒絕", - Lang::Ja => "拒否", - Lang::Ko => "거부됨", - Lang::Es => "Denegado", - Lang::De => "Abgelehnt", - Lang::Fr => "Refusé", - Lang::Pt => "Negado", - Lang::Ar => "تم الرفض", - Lang::En => "Denied", - } -} - -pub fn auto_approve_enabled(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "已启用自动批准。", - Lang::ZhTw => "已啟用自動批准。", - Lang::Ja => "このセッションで自動承認を有効にしました。", - Lang::Ko => "이 세션에 자동 승인을 활성화했습니다.", - Lang::Es => "Aprobación automática activada para esta sesión.", - Lang::De => "Automatische Genehmigung für diese Sitzung aktiviert.", - Lang::Fr => "Approbation automatique activée pour cette session.", - Lang::Pt => "Aprovação automática ativada para esta sessão.", - Lang::Ar => "تم تفعيل الموافقة التلقائية لهذه الجلسة.", - Lang::En => "Auto-approve enabled for this session.", - } -} - -pub fn permission_response_title(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "权限响应", - Lang::ZhTw => "權限回應", - Lang::Ja => "権限応答", - Lang::Ko => "권한 응답", - Lang::Es => "Respuesta de permiso", - Lang::De => "Berechtigungsantwort", - Lang::Fr => "Réponse d'autorisation", - Lang::Pt => "Resposta de permissão", - Lang::Ar => "استجابة الإذن", - Lang::En => "Permission Response", - } -} - -// Follow-up -pub fn no_active_session_use_task(lang: Lang, prefix: &str) -> String { - match lang { - Lang::ZhCn => format!("没有活跃的会话,请使用 {prefix}task 开始新任务。"), - Lang::ZhTw => format!("沒有活躍的對話,請使用 {prefix}task 開始新任務。"), - Lang::Ja => { - format!("アクティブなセッションがありません。{prefix}task で開始してください。") - } - Lang::Ko => format!("활성 세션이 없습니다. {prefix}task로 시작하세요."), - Lang::Es => format!("No hay sesión activa. Usa {prefix}task para iniciar una."), - Lang::De => format!("Keine aktive Sitzung. {prefix}task zum Starten verwenden."), - Lang::Fr => format!("Aucune session active. Utilisez {prefix}task pour en démarrer une."), - Lang::Pt => format!("Nenhuma sessão ativa. Use {prefix}task para iniciar uma."), - Lang::Ar => format!("لا توجد جلسة نشطة. استخدم {prefix}task لبدء واحدة."), - Lang::En => format!("No active session. Use {prefix}task to start one."), - } -} - -pub fn session_connection_lost(lang: Lang, prefix: &str) -> String { - match lang { - Lang::ZhCn => format!("会话连接已断开,请使用 {prefix}task 开始新任务。"), - Lang::ZhTw => format!("對話連線已斷開,請使用 {prefix}task 開始新任務。"), - Lang::Ja => { - format!("セッション接続が切断されました。{prefix}task で新しく開始してください。") - } - Lang::Ko => format!("세션 연결이 끊어졌습니다. {prefix}task로 새로 시작하세요."), - Lang::Es => format!("Conexión de sesión perdida. Usa {prefix}task para iniciar una nueva."), - Lang::De => { - format!("Sitzungsverbindung verloren. {prefix}task für neue Sitzung verwenden.") - } - Lang::Fr => format!( - "Connexion de session perdue. Utilisez {prefix}task pour en démarrer une nouvelle." - ), - Lang::Pt => format!("Conexão da sessão perdida. Use {prefix}task para iniciar uma nova."), - Lang::Ar => format!("انقطع اتصال الجلسة. استخدم {prefix}task لبدء جلسة جديدة."), - Lang::En => format!("Session connection lost. Use {prefix}task to start a new one."), - } -} - -pub fn failed_to_send_message_label(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "发送消息失败: ", - Lang::ZhTw => "發送訊息失敗: ", - Lang::Ja => "メッセージの送信に失敗しました: ", - Lang::Ko => "메시지 전송 실패: ", - Lang::Es => "Error al enviar el mensaje: ", - Lang::De => "Nachricht konnte nicht gesendet werden: ", - Lang::Fr => "Échec de l'envoi du message : ", - Lang::Pt => "Falha ao enviar mensagem: ", - Lang::Ar => "فشل إرسال الرسالة: ", - Lang::En => "Failed to send message: ", - } -} - -pub fn message_sent(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "消息已发送。", - Lang::ZhTw => "訊息已發送。", - Lang::Ja => "メッセージを送信しました。", - Lang::Ko => "메시지를 보냈습니다.", - Lang::Es => "Mensaje enviado.", - Lang::De => "Nachricht gesendet.", - Lang::Fr => "Message envoyé.", - Lang::Pt => "Mensagem enviada.", - Lang::Ar => "تم إرسال الرسالة.", - Lang::En => "Message sent.", - } -} - -// Internal error labels -pub fn failed_to_list_folders_label(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "列出目录失败: ", - Lang::ZhTw => "列出目錄失敗: ", - Lang::Ja => "フォルダ一覧の取得に失敗しました: ", - Lang::Ko => "폴더 목록 조회 실패: ", - Lang::Es => "Error al listar carpetas: ", - Lang::De => "Auflisten der Ordner fehlgeschlagen: ", - Lang::Fr => "Échec de la liste des dossiers : ", - Lang::Pt => "Falha ao listar pastas: ", - Lang::Ar => "فشل عرض المجلدات: ", - Lang::En => "Failed to list folders: ", - } -} - -pub fn failed_to_add_folder_label(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "添加目录失败: ", - Lang::ZhTw => "新增目錄失敗: ", - Lang::Ja => "フォルダの追加に失敗しました: ", - Lang::Ko => "폴더 추가 실패: ", - Lang::Es => "Error al agregar carpeta: ", - Lang::De => "Ordner konnte nicht hinzugefügt werden: ", - Lang::Fr => "Échec de l'ajout du dossier : ", - Lang::Pt => "Falha ao adicionar pasta: ", - Lang::Ar => "فشل إضافة المجلد: ", - Lang::En => "Failed to add folder: ", - } -} - -pub fn failed_to_load_context_label(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "加载上下文失败: ", - Lang::ZhTw => "載入上下文失敗: ", - Lang::Ja => "コンテキストの読み込みに失敗しました: ", - Lang::Ko => "컨텍스트 로드 실패: ", - Lang::Es => "Error al cargar contexto: ", - Lang::De => "Kontext konnte nicht geladen werden: ", - Lang::Fr => "Échec du chargement du contexte : ", - Lang::Pt => "Falha ao carregar contexto: ", - Lang::Ar => "فشل تحميل السياق: ", - Lang::En => "Failed to load context: ", - } -} - -pub fn failed_to_create_conversation_label(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "创建会话失败: ", - Lang::ZhTw => "建立對話失敗: ", - Lang::Ja => "会話の作成に失敗しました: ", - Lang::Ko => "대화 생성 실패: ", - Lang::Es => "Error al crear conversación: ", - Lang::De => "Konversation konnte nicht erstellt werden: ", - Lang::Fr => "Échec de la création de la conversation : ", - Lang::Pt => "Falha ao criar conversa: ", - Lang::Ar => "فشل إنشاء المحادثة: ", - Lang::En => "Failed to create conversation: ", - } -} - -pub fn failed_to_list_sessions_label(lang: Lang) -> &'static str { - match lang { - Lang::ZhCn => "列出会话失败: ", - Lang::ZhTw => "列出對話失敗: ", - Lang::Ja => "セッション一覧の取得に失敗しました: ", - Lang::Ko => "세션 목록 조회 실패: ", - Lang::Es => "Error al listar sesiones: ", - Lang::De => "Auflisten der Sitzungen fehlgeschlagen: ", - Lang::Fr => "Échec de la liste des sessions : ", - Lang::Pt => "Falha ao listar sessões: ", - Lang::Ar => "فشل عرض الجلسات: ", - Lang::En => "Failed to list sessions: ", - } -} - -// ── Session progress messages ── - -pub fn agent_responding(lang: Lang, agent_label: &str) -> String { - match lang { - Lang::ZhCn => format!("{agent_label} 正在响应中..."), - Lang::ZhTw => format!("{agent_label} 正在回應中..."), - Lang::Ja => format!("{agent_label} が応答中..."), - Lang::Ko => format!("{agent_label} 응답 중..."), - Lang::Es => format!("{agent_label} respondiendo..."), - Lang::De => format!("{agent_label} antwortet..."), - Lang::Fr => format!("{agent_label} en cours de réponse..."), - Lang::Pt => format!("{agent_label} respondendo..."), - // FSI/PDI (U+2068/U+2069) isolate Latin agent name inside the Arabic RTL run so - // bidi reordering stays predictable across Telegram/Lark/WeiXin clients. - Lang::Ar => format!("\u{2068}{agent_label}\u{2069} يستجيب..."), - Lang::En => format!("{agent_label} is responding..."), - } -} diff --git a/src-tauri/src/chat_channel/manager.rs b/src-tauri/src/chat_channel/manager.rs deleted file mode 100644 index 44ff2ed..0000000 --- a/src-tauri/src/chat_channel/manager.rs +++ /dev/null @@ -1,339 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use sea_orm::DatabaseConnection; -use tokio::sync::{mpsc, Mutex}; - -use super::error::ChatChannelError; -use super::session_bridge::SessionBridge; -use super::traits::ChatChannelBackend; -use super::types::*; -use crate::acp::manager::ConnectionManager; -use crate::web::event_bridge::{EventEmitter, WebEventBroadcaster}; - -struct ActiveChannel { - id: i32, - name: String, - channel_type: ChannelType, - backend: Arc, -} - -/// Inner state shared across clones. -struct Inner { - channels: Mutex>, - command_tx: mpsc::Sender, - command_rx: Mutex>>, - broadcaster: Mutex>>, -} - -pub struct ChatChannelManager { - inner: Arc, -} - -impl Default for ChatChannelManager { - fn default() -> Self { - Self::new() - } -} - -impl ChatChannelManager { - pub fn new() -> Self { - let (command_tx, command_rx) = mpsc::channel(256); - Self { - inner: Arc::new(Inner { - channels: Mutex::new(HashMap::new()), - command_tx, - command_rx: Mutex::new(Some(command_rx)), - broadcaster: Mutex::new(None), - }), - } - } - - /// Shallow clone sharing the same state (like ConnectionManager::clone_ref). - pub fn clone_ref(&self) -> Self { - Self { - inner: self.inner.clone(), - } - } - - pub fn command_sender(&self) -> mpsc::Sender { - self.inner.command_tx.clone() - } - - /// Take the command receiver (can only be called once, at startup). - pub async fn take_command_receiver(&self) -> Option> { - self.inner.command_rx.lock().await.take() - } - - /// Emit a status change event to the frontend via broadcaster. - async fn emit_status_event(&self, channel_id: i32, status: &str) { - if let Some(broadcaster) = self.inner.broadcaster.lock().await.as_ref() { - broadcaster.send( - "chat-channel://status", - &serde_json::json!({ - "channel_id": channel_id, - "status": status, - }), - ); - } - } - - pub async fn add_channel( - &self, - id: i32, - name: String, - channel_type: ChannelType, - backend: Box, - ) -> Result<(), ChatChannelError> { - let backend: Arc = Arc::from(backend); - - // Stop existing channel if present (prevents task leak on duplicate connect) - let old = self.inner.channels.lock().await.remove(&id); - if let Some(existing) = old { - let _ = existing.backend.stop().await; - } - - let command_tx = self.inner.command_tx.clone(); - backend.start(command_tx).await?; - - let channel = ActiveChannel { - id, - name, - channel_type, - backend, - }; - - self.inner.channels.lock().await.insert(id, channel); - self.emit_status_event(id, "connected").await; - Ok(()) - } - - pub async fn remove_channel(&self, id: i32) -> Result<(), ChatChannelError> { - let removed = self.inner.channels.lock().await.remove(&id); - if let Some(channel) = removed { - channel.backend.stop().await?; - self.emit_status_event(id, "disconnected").await; - } - Ok(()) - } - - pub async fn stop_all(&self) { - let drained: Vec = { - let mut channels = self.inner.channels.lock().await; - channels.drain().map(|(_, ch)| ch).collect() - }; - for channel in drained { - let _ = channel.backend.stop().await; - } - } - - pub async fn send_to_channel( - &self, - channel_id: i32, - message: &RichMessage, - ) -> Result { - let backend = { - let channels = self.inner.channels.lock().await; - channels - .get(&channel_id) - .ok_or(ChatChannelError::NotFound(channel_id))? - .backend - .clone() - }; - backend.send_rich_message(message).await - } - - pub async fn send_to_all(&self, message: &RichMessage) { - let backends: Vec> = { - let channels = self.inner.channels.lock().await; - channels.values().map(|ch| ch.backend.clone()).collect() - }; - for backend in backends { - let _ = backend.send_rich_message(message).await; - } - } - - pub async fn get_status(&self) -> Vec { - let entries: Vec<(i32, String, String, Arc)> = { - let channels = self.inner.channels.lock().await; - channels - .values() - .map(|ch| { - ( - ch.id, - ch.name.clone(), - ch.channel_type.to_string(), - ch.backend.clone(), - ) - }) - .collect() - }; - let mut result = Vec::with_capacity(entries.len()); - for (id, name, ct, backend) in entries { - let status = backend.status().await; - result.push(crate::models::ChannelStatusInfo { - channel_id: id, - name, - channel_type: ct, - status: serde_json::to_value(status) - .ok() - .and_then(|v| v.as_str().map(String::from)) - .unwrap_or_else(|| "unknown".to_string()), - }); - } - result - } - - pub async fn test_channel(&self, id: i32) -> Result<(), ChatChannelError> { - let backend = { - let channels = self.inner.channels.lock().await; - channels - .get(&id) - .ok_or(ChatChannelError::NotFound(id))? - .backend - .clone() - }; - backend.test_connection().await - } - - pub async fn is_connected(&self, id: i32) -> bool { - let backend = { - let channels = self.inner.channels.lock().await; - channels.get(&id).map(|ch| ch.backend.clone()) - }; - if let Some(b) = backend { - b.status().await == ChannelConnectionStatus::Connected - } else { - false - } - } - - /// Start background tasks (event subscriber + command dispatcher) and - /// auto-connect all enabled channels from DB. - pub async fn start_background( - &self, - broadcaster: Arc, - db_conn: DatabaseConnection, - conn_mgr: ConnectionManager, - emitter: EventEmitter, - ) { - // Store broadcaster for status event emission - *self.inner.broadcaster.lock().await = Some(broadcaster.clone()); - - let db_conn2 = db_conn.clone(); - - // Create shared session bridge - let bridge = Arc::new(Mutex::new(SessionBridge::new())); - - // Spawn event subscriber - let manager_for_events = self.clone_ref(); - super::event_subscriber::spawn_event_subscriber( - broadcaster.clone(), - manager_for_events, - db_conn.clone(), - ); - - // Spawn session event subscriber (ACP event routing to channels) - let manager_for_session_events = self.clone_ref(); - super::session_event_subscriber::spawn_session_event_subscriber( - broadcaster, - bridge.clone(), - manager_for_session_events, - conn_mgr.clone_ref(), - db_conn.clone(), - ); - - // Spawn command dispatcher - if let Some(command_rx) = self.take_command_receiver().await { - eprintln!("[ChatChannel] command dispatcher started"); - let manager_for_cmds = self.clone_ref(); - super::command_dispatcher::spawn_command_dispatcher( - command_rx, - manager_for_cmds, - db_conn.clone(), - conn_mgr, - emitter, - bridge, - ); - } else { - eprintln!("[ChatChannel] WARNING: command_rx already taken, dispatcher NOT started"); - } - - // Spawn daily report scheduler - let manager_for_scheduler = self.clone_ref(); - super::scheduler::spawn_daily_report_scheduler(manager_for_scheduler, db_conn.clone()); - - // Auto-connect enabled channels - self.auto_connect_channels(&db_conn2).await; - } - - async fn auto_connect_channels(&self, db_conn: &DatabaseConnection) { - let channels = match crate::db::service::chat_channel_service::list_enabled(db_conn).await { - Ok(c) => c, - Err(e) => { - eprintln!("[ChatChannel] failed to load enabled channels: {e}"); - return; - } - }; - - for ch in channels { - let channel_type: ChannelType = - match serde_json::from_value(serde_json::Value::String(ch.channel_type.clone())) { - Ok(t) => t, - Err(_) => { - eprintln!( - "[ChatChannel] unknown channel type '{}' for '{}' (id={}), skipping", - ch.channel_type, ch.name, ch.id - ); - continue; - } - }; - - let config: serde_json::Value = match serde_json::from_str(&ch.config_json) { - Ok(v) => v, - Err(e) => { - eprintln!( - "[ChatChannel] invalid config for '{}' (id={}): {e}, skipping", - ch.name, ch.id - ); - continue; - } - }; - - let token = match crate::keyring_store::get_channel_token(ch.id) { - Some(t) => t, - None => { - eprintln!( - "[ChatChannel] no token found for '{}' (id={}), skipping auto-connect", - ch.name, ch.id - ); - continue; - } - }; - - let backend = match super::backends::create_backend(ch.id, channel_type, &config, token) - { - Ok(b) => b, - Err(e) => { - eprintln!( - "[ChatChannel] failed to create backend for '{}' (id={}): {e}", - ch.name, ch.id - ); - continue; - } - }; - - if let Err(e) = self - .add_channel(ch.id, ch.name.clone(), channel_type, backend) - .await - { - eprintln!( - "[ChatChannel] failed to auto-connect '{}' (id={}): {e}", - ch.name, ch.id - ); - } else { - eprintln!("[ChatChannel] auto-connected '{}' (id={})", ch.name, ch.id); - } - } - } -} diff --git a/src-tauri/src/chat_channel/message_formatter.rs b/src-tauri/src/chat_channel/message_formatter.rs deleted file mode 100644 index aae24c2..0000000 --- a/src-tauri/src/chat_channel/message_formatter.rs +++ /dev/null @@ -1,68 +0,0 @@ -use super::i18n::{self, Lang}; -use super::types::{MessageLevel, RichMessage}; - -pub fn format_turn_complete(agent_type: &str, stop_reason: &str, lang: Lang) -> RichMessage { - let reason = match stop_reason { - "end_turn" => i18n::stop_reason_end_turn(lang), - "cancelled" => i18n::stop_reason_cancelled(lang), - _ => stop_reason, - }; - RichMessage::info(i18n::turn_complete_body(lang, agent_type)) - .with_title(i18n::turn_complete_title(lang)) - .with_field(i18n::stop_reason_label(lang), reason) -} - -pub fn format_agent_error(agent_type: &str, message: &str, lang: Lang) -> RichMessage { - RichMessage { - title: Some(i18n::agent_error_title(lang).to_string()), - body: i18n::agent_error_body(lang, agent_type), - fields: vec![( - i18n::error_message_label(lang).to_string(), - message.to_string(), - )], - level: MessageLevel::Error, - } -} - -pub struct DailyReportData { - pub date: String, - pub conversations_by_agent: Vec<(String, u32)>, - pub total_conversations: u32, - pub projects_involved: Vec, - pub key_activities: Vec, -} - -pub fn format_daily_report(report: &DailyReportData, lang: Lang) -> RichMessage { - let mut body = i18n::daily_report_summary(lang, &report.date); - - body.push_str(&format!( - "\n\n{}", - i18n::total_sessions(lang, report.total_conversations) - )); - - if !report.conversations_by_agent.is_empty() { - body.push_str(&format!("\n\n{}", i18n::by_agent_label(lang))); - for (agent, count) in &report.conversations_by_agent { - body.push_str(&format!( - "\n {}", - i18n::agent_session_count(lang, agent, *count) - )); - } - } - - if !report.projects_involved.is_empty() { - body.push_str(&format!( - "\n\n{}", - i18n::projects_label(lang, &report.projects_involved.join(", ")) - )); - } - - if !report.key_activities.is_empty() { - body.push_str(&format!("\n\n{}", i18n::key_activities_label(lang))); - for activity in &report.key_activities { - body.push_str(&format!("\n • {}", activity)); - } - } - - RichMessage::info(body).with_title(i18n::daily_report_title(lang)) -} diff --git a/src-tauri/src/chat_channel/mod.rs b/src-tauri/src/chat_channel/mod.rs deleted file mode 100644 index 87aa7d4..0000000 --- a/src-tauri/src/chat_channel/mod.rs +++ /dev/null @@ -1,14 +0,0 @@ -pub mod backends; -pub mod command_dispatcher; -pub mod command_handlers; -pub mod error; -pub mod event_subscriber; -pub mod i18n; -pub mod manager; -pub mod message_formatter; -pub mod scheduler; -pub mod session_bridge; -pub mod session_commands; -pub mod session_event_subscriber; -pub mod traits; -pub mod types; diff --git a/src-tauri/src/chat_channel/scheduler.rs b/src-tauri/src/chat_channel/scheduler.rs deleted file mode 100644 index 94d36a6..0000000 --- a/src-tauri/src/chat_channel/scheduler.rs +++ /dev/null @@ -1,160 +0,0 @@ -use std::collections::HashSet; - -use chrono::{Local, NaiveDate, Timelike, Utc}; -use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder}; -use tokio::task::JoinHandle; - -use super::i18n::Lang; -use super::manager::ChatChannelManager; -use super::message_formatter::{self, DailyReportData}; -use crate::db::entities::conversation; -use crate::db::service::{ - app_metadata_service, chat_channel_message_log_service, chat_channel_service, -}; - -const MESSAGE_LANGUAGE_KEY: &str = "chat_message_language"; -/// Days to retain message logs before cleanup. -const LOG_RETENTION_DAYS: i64 = 30; - -pub fn spawn_daily_report_scheduler( - manager: ChatChannelManager, - db_conn: DatabaseConnection, -) -> JoinHandle<()> { - tokio::spawn(async move { - let mut sent_today: HashSet<(i32, NaiveDate)> = HashSet::new(); - let mut last_cleanup_date: Option = None; - - loop { - tokio::time::sleep(tokio::time::Duration::from_secs(60)).await; - - let now = Local::now(); - let today = now.date_naive(); - let current_time = format!("{:02}:{:02}", now.hour(), now.minute()); - - // Clean up old entries from sent_today - sent_today.retain(|(_, date)| *date == today); - - // Periodic log cleanup: once per day - if last_cleanup_date != Some(today) { - last_cleanup_date = Some(today); - let cutoff = Utc::now() - chrono::Duration::days(LOG_RETENTION_DAYS); - match chat_channel_message_log_service::cleanup_old_logs(&db_conn, cutoff).await { - Ok(n) if n > 0 => { - eprintln!("[ChatChannel] cleaned up {n} old message logs"); - } - Err(e) => { - eprintln!("[ChatChannel] log cleanup failed: {e}"); - } - _ => {} - } - } - - let channels = match chat_channel_service::list_enabled(&db_conn).await { - Ok(c) => c, - Err(e) => { - eprintln!("[ChatChannel] scheduler: failed to list channels: {e}"); - continue; - } - }; - - for ch in &channels { - if !ch.daily_report_enabled { - continue; - } - - let report_time = ch.daily_report_time.as_deref().unwrap_or("18:00"); - - if current_time != report_time { - continue; - } - - let key = (ch.id, today); - if sent_today.contains(&key) { - continue; - } - - let lang = load_lang(&db_conn).await; - - // Generate and send report - let report = generate_daily_report(&db_conn).await; - let message = message_formatter::format_daily_report(&report, lang); - - let send_result = manager.send_to_channel(ch.id, &message).await; - let (status, error_detail) = match &send_result { - Ok(_) => ("sent", None), - Err(e) => ("failed", Some(e.to_string())), - }; - - let _ = chat_channel_message_log_service::create_log( - &db_conn, - ch.id, - "outbound", - "daily_report", - &message.to_plain_text(), - status, - error_detail, - ) - .await; - - sent_today.insert(key); - } - } - }) -} - -async fn load_lang(db: &DatabaseConnection) -> Lang { - app_metadata_service::get_value(db, MESSAGE_LANGUAGE_KEY) - .await - .ok() - .flatten() - .map(|v| Lang::from_str_lossy(&v)) - .unwrap_or_default() -} - -async fn generate_daily_report(db: &DatabaseConnection) -> DailyReportData { - let now = Utc::now(); - let today_start = now.date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc(); - - let rows = conversation::Entity::find() - .filter(conversation::Column::DeletedAt.is_null()) - .filter(conversation::Column::CreatedAt.gte(today_start)) - .order_by_desc(conversation::Column::CreatedAt) - .all(db) - .await - .unwrap_or_default(); - - let mut by_agent: std::collections::HashMap = std::collections::HashMap::new(); - let mut folder_ids: HashSet = HashSet::new(); - let mut activities: Vec = Vec::new(); - - for conv in &rows { - *by_agent.entry(conv.agent_type.clone()).or_insert(0) += 1; - folder_ids.insert(conv.folder_id); - if let Some(title) = &conv.title { - if activities.len() < 10 { - activities.push(title.clone()); - } - } - } - - // Resolve folder names - let mut project_names: Vec = Vec::new(); - for fid in &folder_ids { - if let Ok(Some(folder)) = crate::db::entities::folder::Entity::find_by_id(*fid) - .one(db) - .await - { - project_names.push(folder.name); - } - } - - let conversations_by_agent: Vec<(String, u32)> = by_agent.into_iter().collect(); - - DailyReportData { - date: now.format("%Y-%m-%d").to_string(), - total_conversations: rows.len() as u32, - conversations_by_agent, - projects_involved: project_names, - key_activities: activities, - } -} diff --git a/src-tauri/src/chat_channel/session_bridge.rs b/src-tauri/src/chat_channel/session_bridge.rs deleted file mode 100644 index aaba69b..0000000 --- a/src-tauri/src/chat_channel/session_bridge.rs +++ /dev/null @@ -1,79 +0,0 @@ -use std::collections::HashMap; -use std::time::Instant; - -use crate::acp::types::PermissionOptionInfo; -use crate::chat_channel::types::SentMessageId; -use crate::models::agent::AgentType; - -pub struct PendingPermission { - pub request_id: String, - pub tool_description: String, - pub options: Vec, - pub sent_message_id: Option, -} - -pub struct ActiveSession { - pub channel_id: i32, - pub sender_id: String, - pub conversation_id: i32, - pub connection_id: String, - pub agent_type: AgentType, - pub content_buffer: String, - pub tool_calls: Vec, - /// Stores raw_input by tool_call_id for detail extraction on completion. - pub tool_call_inputs: HashMap, - pub last_flushed: Instant, - pub pending_prompt: Option, - pub permission_pending: Option, -} - -#[derive(Default)] -pub struct SessionBridge { - sessions: HashMap, -} - -impl SessionBridge { - pub fn new() -> Self { - Self::default() - } - - pub fn register(&mut self, connection_id: String, session: ActiveSession) { - self.sessions.insert(connection_id, session); - } - - pub fn remove(&mut self, connection_id: &str) -> Option { - self.sessions.remove(connection_id) - } - - pub fn get(&self, connection_id: &str) -> Option<&ActiveSession> { - self.sessions.get(connection_id) - } - - pub fn get_mut(&mut self, connection_id: &str) -> Option<&mut ActiveSession> { - self.sessions.get_mut(connection_id) - } - - pub fn find_by_sender(&self, channel_id: i32, sender_id: &str) -> Option<&ActiveSession> { - self.sessions - .values() - .find(|s| s.channel_id == channel_id && s.sender_id == sender_id) - } - - pub fn find_by_sender_mut( - &mut self, - channel_id: i32, - sender_id: &str, - ) -> Option<&mut ActiveSession> { - self.sessions - .values_mut() - .find(|s| s.channel_id == channel_id && s.sender_id == sender_id) - } - - pub fn all_sessions(&self) -> impl Iterator { - self.sessions.values() - } - - pub fn all_sessions_mut(&mut self) -> impl Iterator { - self.sessions.values_mut() - } -} diff --git a/src-tauri/src/chat_channel/session_commands.rs b/src-tauri/src/chat_channel/session_commands.rs deleted file mode 100644 index d7984fd..0000000 --- a/src-tauri/src/chat_channel/session_commands.rs +++ /dev/null @@ -1,799 +0,0 @@ -use std::collections::BTreeMap; -use std::sync::Arc; -use std::time::Instant; - -use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, QuerySelect}; -use tokio::sync::Mutex; - -use super::i18n::{self, Lang}; -use super::session_bridge::{ActiveSession, SessionBridge}; -use super::types::{MessageLevel, RichMessage}; -use crate::acp::manager::ConnectionManager; -use crate::acp::registry::all_acp_agents; -use crate::acp::types::PromptInputBlock; -use crate::db::entities::conversation; -use crate::db::service::{conversation_service, folder_service, sender_context_service}; -use crate::models::agent::AgentType; -use crate::web::event_bridge::EventEmitter; - -pub struct FollowupRequest<'a> { - pub db: &'a DatabaseConnection, - pub text: &'a str, - pub channel_id: i32, - pub sender_id: &'a str, - pub conn_mgr: &'a ConnectionManager, - pub bridge: &'a Arc>, - pub lang: Lang, - pub prefix: &'a str, -} - -// ── /folder ── - -pub async fn handle_folder( - db: &DatabaseConnection, - args: &str, - channel_id: i32, - sender_id: &str, - lang: Lang, - prefix: &str, -) -> RichMessage { - if args.is_empty() { - return list_folders(db, channel_id, sender_id, lang, prefix).await; - } - - // Try parse as index (1-based) - if let Ok(idx) = args.parse::() { - return select_folder_by_index(db, idx, channel_id, sender_id, lang, prefix).await; - } - - // Treat as path - select_folder_by_path(db, args, channel_id, sender_id, lang).await -} - -async fn list_folders( - db: &DatabaseConnection, - channel_id: i32, - sender_id: &str, - lang: Lang, - prefix: &str, -) -> RichMessage { - let folders = match folder_service::list_folders(db).await { - Ok(f) => f, - Err(e) => { - return RichMessage::error(format!("{}{e}", i18n::failed_to_list_folders_label(lang))); - } - }; - - if folders.is_empty() { - return RichMessage::info(i18n::no_folders_found(lang)) - .with_title(i18n::folder_title(lang)); - } - - let ctx = sender_context_service::get_or_create(db, channel_id, sender_id) - .await - .ok(); - - let mut body = String::new(); - for (i, f) in folders.iter().take(10).enumerate() { - let current = ctx - .as_ref() - .and_then(|c| c.current_folder_id) - .map(|id| id == f.id) - .unwrap_or(false); - let marker = if current { " [*]" } else { "" }; - body.push_str(&format!("{}. {}{} ({})\n", i + 1, f.name, marker, f.path)); - } - - body.push_str(&format!("\n{}", i18n::folder_select_hint(lang, prefix))); - - RichMessage::info(body.trim_end()).with_title(i18n::folder_title(lang)) -} - -async fn select_folder_by_index( - db: &DatabaseConnection, - idx: usize, - channel_id: i32, - sender_id: &str, - lang: Lang, - prefix: &str, -) -> RichMessage { - if idx == 0 { - return RichMessage::info(i18n::index_starts_from_one(lang)); - } - - let folders = match folder_service::list_folders(db).await { - Ok(f) => f, - Err(e) => { - return RichMessage::error(format!("{}{e}", i18n::failed_to_list_folders_label(lang))); - } - }; - - let Some(folder) = folders.get(idx - 1) else { - return RichMessage::info(i18n::folder_index_out_of_range(lang, prefix)); - }; - - let _ = sender_context_service::update_folder(db, channel_id, sender_id, Some(folder.id)).await; - - RichMessage::info(format!("{} ({})", folder.name, folder.path)) - .with_title(i18n::folder_selected_title(lang)) -} - -async fn select_folder_by_path( - db: &DatabaseConnection, - path: &str, - channel_id: i32, - sender_id: &str, - lang: Lang, -) -> RichMessage { - let entry = match folder_service::add_folder(db, path).await { - Ok(e) => e, - Err(e) => { - return RichMessage::error(format!("{}{e}", i18n::failed_to_add_folder_label(lang))); - } - }; - - let _ = sender_context_service::update_folder(db, channel_id, sender_id, Some(entry.id)).await; - - RichMessage::info(format!("{} ({})", entry.name, entry.path)) - .with_title(i18n::folder_selected_title(lang)) -} - -// ── /agent ── - -pub async fn handle_agent( - db: &DatabaseConnection, - args: &str, - channel_id: i32, - sender_id: &str, - lang: Lang, - prefix: &str, -) -> RichMessage { - if args.is_empty() { - return list_agents(db, channel_id, sender_id, lang, prefix).await; - } - - // Try parse as index - if let Ok(idx) = args.parse::() { - return select_agent_by_index(db, idx, channel_id, sender_id, lang, prefix).await; - } - - // Try parse as agent type name - select_agent_by_name(db, args, channel_id, sender_id, lang).await -} - -async fn list_agents( - db: &DatabaseConnection, - channel_id: i32, - sender_id: &str, - lang: Lang, - prefix: &str, -) -> RichMessage { - let agents = all_acp_agents(); - let ctx = sender_context_service::get_or_create(db, channel_id, sender_id) - .await - .ok(); - - let mut body = String::new(); - for (i, at) in agents.iter().enumerate() { - let at_str = agent_type_to_string(*at); - let current = ctx - .as_ref() - .and_then(|c| c.current_agent_type.as_deref()) - .map(|s| s == at_str) - .unwrap_or(false); - let marker = if current { " [*]" } else { "" }; - body.push_str(&format!("{}. {}{}\n", i + 1, at, marker)); - } - - body.push_str(&format!("\n{}", i18n::agent_select_hint(lang, prefix))); - - RichMessage::info(body.trim_end()).with_title(i18n::agent_title(lang)) -} - -async fn select_agent_by_index( - db: &DatabaseConnection, - idx: usize, - channel_id: i32, - sender_id: &str, - lang: Lang, - prefix: &str, -) -> RichMessage { - let agents = all_acp_agents(); - if idx == 0 || idx > agents.len() { - return RichMessage::info(i18n::agent_index_out_of_range(lang, prefix)); - } - - let at = agents[idx - 1]; - let at_str = agent_type_to_string(at); - let _ = sender_context_service::update_agent(db, channel_id, sender_id, Some(at_str)).await; - - RichMessage::info(at.to_string()).with_title(i18n::agent_selected_title(lang)) -} - -async fn select_agent_by_name( - db: &DatabaseConnection, - name: &str, - channel_id: i32, - sender_id: &str, - lang: Lang, -) -> RichMessage { - let at = match parse_agent_type(name) { - Some(a) => a, - None => { - return RichMessage::info(format!("{}{}", i18n::unknown_agent_label(lang), name)); - } - }; - - let at_str = agent_type_to_string(at); - let _ = sender_context_service::update_agent(db, channel_id, sender_id, Some(at_str)).await; - - RichMessage::info(at.to_string()).with_title(i18n::agent_selected_title(lang)) -} - -// ── /task ── - -#[allow(clippy::too_many_arguments)] -pub async fn handle_task( - db: &DatabaseConnection, - task_description: &str, - channel_id: i32, - sender_id: &str, - conn_mgr: &ConnectionManager, - emitter: &EventEmitter, - bridge: &Arc>, - lang: Lang, - prefix: &str, -) -> RichMessage { - if task_description.is_empty() { - return RichMessage::info(i18n::task_usage(lang, prefix)); - } - - // 1. Load sender context - let ctx = match sender_context_service::get_or_create(db, channel_id, sender_id).await { - Ok(c) => c, - Err(e) => { - return RichMessage::error(format!("{}{e}", i18n::failed_to_load_context_label(lang))); - } - }; - - let folder_id = match ctx.current_folder_id { - Some(id) => id, - None => { - return RichMessage::info(i18n::no_folder_selected(lang, prefix)); - } - }; - - // 2. Get folder info - let folder = match folder_service::get_folder_by_id(db, folder_id).await { - Ok(Some(f)) => f, - _ => { - return RichMessage::info(i18n::folder_not_found_with_hint(lang, prefix)); - } - }; - - // 3. Resolve agent type - let agent_type = match resolve_agent_type(&ctx.current_agent_type, &folder.default_agent_type) { - Some(at) => at, - None => { - return RichMessage::info(i18n::no_agent_selected(lang, prefix)); - } - }; - - // 4. Create conversation record - let conv = match conversation_service::create( - db, - folder_id, - agent_type, - Some(truncate_title(task_description)), - folder.git_branch.clone(), - ) - .await - { - Ok(c) => c, - Err(e) => { - return RichMessage::error(format!( - "{}{e}", - i18n::failed_to_create_conversation_label(lang) - )); - } - }; - - // 5. Spawn ACP agent - let owner_label = format!("chat_channel:{}:{}", channel_id, sender_id); - let connection_id = match conn_mgr - .spawn_agent( - agent_type, - Some(folder.path.clone()), - None, - BTreeMap::new(), - owner_label, - emitter.clone(), - ) - .await - { - Ok(id) => id, - Err(e) => { - // Clean up the conversation record - let _ = conversation_service::update_status( - db, - conv.id, - conversation::ConversationStatus::Cancelled, - ) - .await; - return RichMessage::error(format!("{}{e}", i18n::failed_to_start_agent_label(lang))); - } - }; - - // 6. Register in bridge (prompt will be sent after SessionStarted event) - { - let session = ActiveSession { - channel_id, - sender_id: sender_id.to_string(), - conversation_id: conv.id, - connection_id: connection_id.clone(), - agent_type, - content_buffer: String::new(), - tool_calls: Vec::new(), - tool_call_inputs: std::collections::HashMap::new(), - last_flushed: Instant::now(), - pending_prompt: Some(task_description.to_string()), - permission_pending: None, - }; - bridge.lock().await.register(connection_id.clone(), session); - } - - // 7. Update sender context - let _ = sender_context_service::update_session( - db, - channel_id, - sender_id, - Some(conv.id), - Some(connection_id), - ) - .await; - - RichMessage::info(format!("[{}] #{} @ {}", agent_type, conv.id, folder.name,)) - .with_title(i18n::task_started_title(lang)) -} - -// ── /sessions ── - -pub async fn handle_sessions( - db: &DatabaseConnection, - channel_id: i32, - sender_id: &str, - lang: Lang, - prefix: &str, -) -> RichMessage { - let ctx = match sender_context_service::get_or_create(db, channel_id, sender_id).await { - Ok(c) => c, - Err(e) => { - return RichMessage::error(format!("{}{e}", i18n::failed_to_load_context_label(lang))); - } - }; - - let folder_id = match ctx.current_folder_id { - Some(id) => id, - None => { - return RichMessage::info(i18n::no_folder_selected(lang, prefix)); - } - }; - - let folder = match folder_service::get_folder_by_id(db, folder_id).await { - Ok(Some(f)) => f, - _ => { - return RichMessage::info(i18n::folder_not_found(lang)); - } - }; - - let convs = match conversation_service::list_by_folder( - db, - folder_id, - None, - None, - None, - Some("in_progress".to_string()), - ) - .await - { - Ok(c) => c, - Err(e) => { - return RichMessage::error(format!("{}{e}", i18n::failed_to_list_sessions_label(lang))); - } - }; - - if convs.is_empty() { - return RichMessage::info(i18n::no_active_sessions_in_folder(lang)).with_title(format!( - "{} - {}", - i18n::sessions_title(lang), - folder.name - )); - } - - let mut body = String::new(); - for (i, c) in convs.iter().take(10).enumerate() { - let title = c.title.as_deref().unwrap_or("(untitled)"); - let current = ctx - .current_conversation_id - .map(|id| id == c.id) - .unwrap_or(false); - let marker = if current { " [*]" } else { "" }; - body.push_str(&format!( - "{}. [{}] {} (#{}){} \n", - i + 1, - c.agent_type, - title, - c.id, - marker, - )); - } - - body.push_str(&format!("\n{}", i18n::sessions_resume_hint(lang, prefix))); - - RichMessage::info(body.trim_end()).with_title(format!( - "{} - {}", - i18n::sessions_title(lang), - folder.name - )) -} - -// ── /resume ── - -#[allow(clippy::too_many_arguments)] -pub async fn handle_resume( - db: &DatabaseConnection, - args: &str, - channel_id: i32, - sender_id: &str, - conn_mgr: &ConnectionManager, - emitter: &EventEmitter, - bridge: &Arc>, - lang: Lang, - prefix: &str, -) -> RichMessage { - if args.is_empty() { - return list_recent_sessions(db, lang, prefix).await; - } - - let conversation_id: i32 = match args.parse() { - Ok(id) => id, - Err(_) => { - return list_recent_sessions(db, lang, prefix).await; - } - }; - - let conv = match conversation_service::get_by_id(db, conversation_id).await { - Ok(c) => c, - Err(_) => { - return RichMessage::info(i18n::conversation_not_found(lang)); - } - }; - - let folder = match folder_service::get_folder_by_id(db, conv.folder_id).await { - Ok(Some(f)) => f, - _ => { - return RichMessage::info(i18n::folder_not_found(lang)); - } - }; - - // Spawn agent with session_id for resume - let owner_label = format!("chat_channel:{}:{}", channel_id, sender_id); - let connection_id = match conn_mgr - .spawn_agent( - conv.agent_type, - Some(folder.path.clone()), - conv.external_id.clone(), - BTreeMap::new(), - owner_label, - emitter.clone(), - ) - .await - { - Ok(id) => id, - Err(e) => { - return RichMessage::error(format!("{}{e}", i18n::failed_to_start_agent_label(lang))); - } - }; - - // Register in bridge (no pending prompt for resume) - { - let session = ActiveSession { - channel_id, - sender_id: sender_id.to_string(), - conversation_id: conv.id, - connection_id: connection_id.clone(), - agent_type: conv.agent_type, - content_buffer: String::new(), - tool_calls: Vec::new(), - tool_call_inputs: std::collections::HashMap::new(), - last_flushed: Instant::now(), - pending_prompt: None, - permission_pending: None, - }; - bridge.lock().await.register(connection_id.clone(), session); - } - - // Update sender context - let _ = sender_context_service::update_session( - db, - channel_id, - sender_id, - Some(conv.id), - Some(connection_id), - ) - .await; - let _ = sender_context_service::update_folder(db, channel_id, sender_id, Some(conv.folder_id)) - .await; - - let title = conv.title.as_deref().unwrap_or("(untitled)"); - RichMessage::info(format!( - "[{}] #{} {} @ {}", - conv.agent_type, conv.id, title, folder.name, - )) - .with_title(i18n::session_resumed_title(lang)) -} - -// ── /cancel ── - -pub async fn handle_cancel( - db: &DatabaseConnection, - channel_id: i32, - sender_id: &str, - conn_mgr: &ConnectionManager, - bridge: &Arc>, - lang: Lang, -) -> RichMessage { - let ctx = match sender_context_service::get_or_create(db, channel_id, sender_id).await { - Ok(c) => c, - Err(e) => { - return RichMessage::error(format!("{}{e}", i18n::failed_to_load_context_label(lang))); - } - }; - - let connection_id = match &ctx.current_connection_id { - Some(id) => id.clone(), - None => { - return RichMessage::info(i18n::no_active_session_to_cancel(lang)); - } - }; - - // Cancel the ACP connection - let _ = conn_mgr.cancel(&connection_id).await; - - // Remove from bridge - bridge.lock().await.remove(&connection_id); - - // Update conversation status - if let Some(conv_id) = ctx.current_conversation_id { - let _ = conversation_service::update_status( - db, - conv_id, - conversation::ConversationStatus::Cancelled, - ) - .await; - } - - // Clear session from context - let _ = sender_context_service::clear_session(db, channel_id, sender_id).await; - - RichMessage::info(i18n::task_cancelled_body(lang)).with_title(i18n::task_cancelled_title(lang)) -} - -// ── /approve, /deny ── - -#[allow(clippy::too_many_arguments)] -pub async fn handle_permission_response( - approve: bool, - always: bool, - db: &DatabaseConnection, - channel_id: i32, - sender_id: &str, - conn_mgr: &ConnectionManager, - bridge: &Arc>, - lang: Lang, -) -> RichMessage { - let ctx = match sender_context_service::get_or_create(db, channel_id, sender_id).await { - Ok(c) => c, - Err(e) => { - return RichMessage::error(format!("{}{e}", i18n::failed_to_load_context_label(lang))); - } - }; - - let connection_id = match &ctx.current_connection_id { - Some(id) => id.clone(), - None => { - return RichMessage::info(i18n::no_active_session(lang)); - } - }; - - let pending = { - let mut bridge_guard = bridge.lock().await; - let session = match bridge_guard.get_mut(&connection_id) { - Some(s) => s, - None => { - return RichMessage::info(i18n::no_active_session_found(lang)); - } - }; - session.permission_pending.take() - }; - - let pending = match pending { - Some(p) => p, - None => { - return RichMessage::info(i18n::no_pending_permission(lang)); - } - }; - - // Find the appropriate option_id - let option_id = if approve { - pending - .options - .iter() - .find(|o| o.kind == "allow" || o.kind == "allowForSession") - .or_else(|| pending.options.first()) - .map(|o| o.option_id.clone()) - } else { - pending - .options - .iter() - .find(|o| o.kind == "deny") - .or_else(|| pending.options.last()) - .map(|o| o.option_id.clone()) - }; - - let Some(option_id) = option_id else { - return RichMessage::info(i18n::no_valid_permission_option(lang)); - }; - - if let Err(e) = conn_mgr - .respond_permission(&connection_id, &pending.request_id, &option_id) - .await - { - return RichMessage::error(format!( - "{}{e}", - i18n::failed_permission_response_label(lang) - )); - } - - // Update auto_approve if requested - if always && approve { - let _ = sender_context_service::update_auto_approve(db, channel_id, sender_id, true).await; - } - - let action = if approve { - i18n::approved_label(lang) - } else { - i18n::denied_label(lang) - }; - - let mut msg = RichMessage::info(format!("{}: {}", action, pending.tool_description)); - if always && approve { - msg = msg.with_field("", i18n::auto_approve_enabled(lang)); - } - msg.with_title(i18n::permission_response_title(lang)) -} - -// ── follow-up (non-command text) ── - -pub async fn handle_followup(req: FollowupRequest<'_>) -> RichMessage { - let ctx = - match sender_context_service::get_or_create(req.db, req.channel_id, req.sender_id).await { - Ok(c) => c, - Err(e) => { - return RichMessage::error(format!( - "{}{e}", - i18n::failed_to_load_context_label(req.lang) - )); - } - }; - - let connection_id = match &ctx.current_connection_id { - Some(id) => id.clone(), - None => { - return RichMessage::info(i18n::no_active_session_use_task(req.lang, req.prefix)); - } - }; - - // Check connection exists in bridge - { - let bridge_guard = req.bridge.lock().await; - if bridge_guard.get(&connection_id).is_none() { - // Connection lost, clear context - drop(bridge_guard); - let _ = - sender_context_service::clear_session(req.db, req.channel_id, req.sender_id).await; - return RichMessage::info(i18n::session_connection_lost(req.lang, req.prefix)); - } - } - - // Send prompt to agent - let blocks = vec![PromptInputBlock::Text { - text: req.text.to_string(), - }]; - - if let Err(e) = req.conn_mgr.send_prompt(&connection_id, blocks).await { - // Connection may have died - req.bridge.lock().await.remove(&connection_id); - let _ = sender_context_service::clear_session(req.db, req.channel_id, req.sender_id).await; - return RichMessage::error(format!( - "{}{e}", - i18n::failed_to_send_message_label(req.lang) - )); - } - - RichMessage::info(i18n::message_sent(req.lang)) -} - -// ── /resume (list recent) ── - -async fn list_recent_sessions(db: &DatabaseConnection, lang: Lang, prefix: &str) -> RichMessage { - let recent = match conversation::Entity::find() - .filter(conversation::Column::DeletedAt.is_null()) - .order_by_desc(conversation::Column::CreatedAt) - .limit(10) - .all(db) - .await - { - Ok(rows) => rows, - Err(e) => { - return RichMessage { - title: Some(i18n::query_failed_title(lang).to_string()), - body: e.to_string(), - fields: Vec::new(), - level: MessageLevel::Error, - }; - } - }; - - if recent.is_empty() { - return RichMessage::info(i18n::no_conversations_found(lang)) - .with_title(i18n::recent_conversations_title(lang)); - } - - let mut body = String::new(); - for conv in &recent { - let title = conv.title.as_deref().unwrap_or(i18n::untitled(lang)); - let agent = &conv.agent_type; - let time = conv.created_at.format("%m-%d %H:%M"); - body.push_str(&format!("#{} [{}] {} ({})\n", conv.id, agent, title, time,)); - } - - body.push_str(&format!("\n{}", i18n::recent_resume_hint(lang, prefix))); - - RichMessage::info(body.trim_end()).with_title(i18n::recent_conversations_title(lang)) -} - -// ── Helpers ── - -fn agent_type_to_string(at: AgentType) -> String { - serde_json::to_value(at) - .ok() - .and_then(|v| v.as_str().map(String::from)) - .unwrap_or_default() -} - -fn parse_agent_type(name: &str) -> Option { - let normalized = name.to_lowercase().replace([' ', '-'], "_"); - serde_json::from_value(serde_json::Value::String(normalized)).ok() -} - -fn resolve_agent_type( - sender_agent: &Option, - folder_default: &Option, -) -> Option { - if let Some(ref at_str) = sender_agent { - if let Some(at) = parse_agent_type(at_str) { - return Some(at); - } - } - folder_default.as_ref().copied() -} - -fn truncate_title(s: &str) -> String { - if s.chars().count() <= 80 { - s.to_string() - } else { - let truncated: String = s.chars().take(77).collect(); - format!("{truncated}...") - } -} diff --git a/src-tauri/src/chat_channel/session_event_subscriber.rs b/src-tauri/src/chat_channel/session_event_subscriber.rs deleted file mode 100644 index 0a1c922..0000000 --- a/src-tauri/src/chat_channel/session_event_subscriber.rs +++ /dev/null @@ -1,727 +0,0 @@ -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use sea_orm::DatabaseConnection; -use tokio::sync::Mutex; -use tokio::task::JoinHandle; - -use super::i18n::Lang; -use super::session_bridge::{PendingPermission, SessionBridge}; -use super::types::{MessageLevel, RichMessage}; -use crate::acp::manager::ConnectionManager; -use crate::acp::types::PromptInputBlock; -use crate::db::service::{app_metadata_service, conversation_service, sender_context_service}; -use crate::web::event_bridge::WebEventBroadcaster; - -use super::manager::ChatChannelManager; - -const FLUSH_INTERVAL_SECS: u64 = 10; -const BUFFER_FLUSH_THRESHOLD: usize = 500; -const MAX_MESSAGE_LEN: usize = 2000; -const MESSAGE_LANGUAGE_KEY: &str = "chat_message_language"; -const COMMAND_PREFIX_KEY: &str = "chat_command_prefix"; -const DEFAULT_COMMAND_PREFIX: &str = "/"; - -pub fn spawn_session_event_subscriber( - broadcaster: Arc, - bridge: Arc>, - manager: ChatChannelManager, - conn_mgr: ConnectionManager, - db_conn: DatabaseConnection, -) -> JoinHandle<()> { - let mut rx = broadcaster.subscribe(); - - tokio::spawn(async move { - let mut last_heartbeat = Instant::now(); - - loop { - tokio::select! { - result = rx.recv() => { - let event = match result { - Ok(e) => e, - Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { - eprintln!("[SessionEventSub] lagged {n} events"); - continue; - } - Err(_) => break, - }; - - if event.channel == "acp://event" { - handle_acp_event_payload( - event.payload.as_ref(), - &bridge, - &manager, - &conn_mgr, - &db_conn, - ) - .await; - } - } - _ = tokio::time::sleep(Duration::from_secs(FLUSH_INTERVAL_SECS)) => { - if last_heartbeat.elapsed() >= Duration::from_secs(FLUSH_INTERVAL_SECS) { - flush_progress(&bridge, &manager, &db_conn).await; - last_heartbeat = Instant::now(); - } - } - } - } - }) -} - -async fn get_lang(db: &DatabaseConnection) -> Lang { - app_metadata_service::get_value(db, MESSAGE_LANGUAGE_KEY) - .await - .ok() - .flatten() - .map(|v| Lang::from_str_lossy(&v)) - .unwrap_or_default() -} - -async fn get_prefix(db: &DatabaseConnection) -> String { - app_metadata_service::get_value(db, COMMAND_PREFIX_KEY) - .await - .ok() - .flatten() - .unwrap_or_else(|| DEFAULT_COMMAND_PREFIX.to_string()) -} - -async fn handle_acp_event_payload( - payload: &serde_json::Value, - bridge: &Arc>, - manager: &ChatChannelManager, - conn_mgr: &ConnectionManager, - db: &DatabaseConnection, -) { - let event_type = match payload.get("type").and_then(|v| v.as_str()) { - Some(t) => t, - None => return, - }; - let connection_id = match payload.get("connection_id").and_then(|v| v.as_str()) { - Some(id) => id, - None => return, - }; - - match event_type { - "session_started" => { - let session_id = payload - .get("session_id") - .and_then(|v| v.as_str()) - .unwrap_or(""); - - let mut guard = bridge.lock().await; - if let Some(session) = guard.get_mut(connection_id) { - let _ = conversation_service::update_external_id( - db, - session.conversation_id, - session_id.to_string(), - ) - .await; - - if let Some(prompt_text) = session.pending_prompt.take() { - let blocks = vec![PromptInputBlock::Text { text: prompt_text }]; - if let Err(e) = conn_mgr.send_prompt(connection_id, blocks).await { - eprintln!("[SessionEventSub] failed to send pending prompt: {e}"); - let channel_id = session.channel_id; - let msg = RichMessage::error(format!("Failed to send task: {e}")); - let _ = manager.send_to_channel(channel_id, &msg).await; - } - } - } - } - - "content_delta" => { - let text = payload.get("text").and_then(|v| v.as_str()).unwrap_or(""); - - // Collect flush info under the lock, then release before any IO. - let flush_info: Option<(i32, String, Option)> = { - let mut guard = bridge.lock().await; - match guard.get_mut(connection_id) { - Some(session) => { - session.content_buffer.push_str(text); - if session.content_buffer.len() >= BUFFER_FLUSH_THRESHOLD - && session.last_flushed.elapsed() >= Duration::from_secs(2) - { - session.last_flushed = Instant::now(); - Some(( - session.channel_id, - session.agent_type.to_string(), - session.tool_calls.last().cloned(), - )) - } else { - None - } - } - None => None, - } - }; - - if let Some((channel_id, agent_label, last_tool)) = flush_info { - let lang = get_lang(db).await; - let mut status = super::i18n::agent_responding(lang, &agent_label); - if let Some(tool) = last_tool { - status.push_str(&format!(" | {tool}")); - } - let msg = RichMessage::info(status); - let _ = manager.send_to_channel(channel_id, &msg).await; - } - } - - "tool_call" => { - let title = payload - .get("title") - .and_then(|v| v.as_str()) - .unwrap_or("tool"); - let tool_call_id = payload - .get("tool_call_id") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let raw_input = payload.get("raw_input").and_then(|v| v.as_str()); - - let mut guard = bridge.lock().await; - if let Some(session) = guard.get_mut(connection_id) { - // Store title for progress indicator; store raw_input for later - session.tool_calls.push(title.to_string()); - if let Some(input) = raw_input { - session - .tool_call_inputs - .insert(tool_call_id.to_string(), input.to_string()); - } - } - } - - "tool_call_update" => { - let title = payload.get("title").and_then(|v| v.as_str()); - let status = payload.get("status").and_then(|v| v.as_str()); - let tool_call_id = payload - .get("tool_call_id") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let raw_input = payload.get("raw_input").and_then(|v| v.as_str()); - - let mut guard = bridge.lock().await; - if let Some(session) = guard.get_mut(connection_id) { - // Accumulate raw_input if newly available - if let Some(input) = raw_input { - session - .tool_call_inputs - .insert(tool_call_id.to_string(), input.to_string()); - } - - if status == Some("completed") { - let stored_input = session.tool_call_inputs.remove(tool_call_id); - let effective_title = title.unwrap_or("tool"); - let input_ref = stored_input.as_deref().or(raw_input); - let detail = format_tool_call_detail(effective_title, input_ref); - let channel_id = session.channel_id; - drop(guard); - - let msg = RichMessage::info(format!(">> {detail}")); - let _ = manager.send_to_channel(channel_id, &msg).await; - } - } - } - - "permission_request" => { - let request_id = payload - .get("request_id") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let tool_call = payload - .get("tool_call") - .cloned() - .unwrap_or(serde_json::Value::Null); - let options: Vec = payload - .get("options") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .unwrap_or_default(); - - let mut guard = bridge.lock().await; - if let Some(session) = guard.get_mut(connection_id) { - let channel_id = session.channel_id; - let sender_id = session.sender_id.clone(); - - let auto_approve = - sender_context_service::get_or_create(db, channel_id, &sender_id) - .await - .map(|ctx| ctx.auto_approve) - .unwrap_or(false); - - if auto_approve { - let option_id = options - .iter() - .find(|o| o.kind == "allow" || o.kind == "allowForSession") - .or_else(|| options.first()) - .map(|o| o.option_id.clone()); - - drop(guard); - - if let Some(oid) = option_id { - let _ = conn_mgr - .respond_permission(connection_id, request_id, &oid) - .await; - } - return; - } - - let tool_title = tool_call - .get("title") - .and_then(|v| v.as_str()) - .or_else(|| tool_call.get("tool_name").and_then(|v| v.as_str())) - .unwrap_or("Unknown tool"); - - // Extract detail from rawInput / raw_input in the tool_call object - let raw_input_str = tool_call - .get("rawInput") - .or_else(|| tool_call.get("raw_input")) - .and_then(|v| match v { - serde_json::Value::String(s) => Some(s.clone()), - serde_json::Value::Null => None, - other => Some(other.to_string()), - }); - let tool_desc = format_tool_call_detail(tool_title, raw_input_str.as_deref()); - - session.permission_pending = Some(PendingPermission { - request_id: request_id.to_string(), - tool_description: tool_desc.clone(), - options, - sent_message_id: None, - }); - - drop(guard); - - let lang = get_lang(db).await; - let prefix = get_prefix(db).await; - let body = match lang { - Lang::ZhCn | Lang::ZhTw => { - format!("Agent 请求权限: {tool_desc}\n\n{prefix}approve 批准 | {prefix}deny 拒绝 | {prefix}approve always 自动批准") - } - _ => { - format!("Agent requests permission: {tool_desc}\n\n{prefix}approve | {prefix}deny | {prefix}approve always") - } - }; - - let msg = RichMessage { - title: Some(match lang { - Lang::ZhCn | Lang::ZhTw => "权限请求".to_string(), - _ => "Permission Request".to_string(), - }), - body, - fields: Vec::new(), - level: MessageLevel::Warning, - }; - let _ = manager.send_to_channel(channel_id, &msg).await; - } - } - - "turn_complete" => { - let stop_reason = payload - .get("stop_reason") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - let agent_type = payload - .get("agent_type") - .and_then(|v| v.as_str()) - .unwrap_or("Unknown"); - - let mut guard = bridge.lock().await; - if let Some(session) = guard.get_mut(connection_id) { - let channel_id = session.channel_id; - let conv_id = session.conversation_id; - let content = std::mem::take(&mut session.content_buffer); - let tool_count = session.tool_calls.len(); - session.tool_calls.clear(); - session.last_flushed = Instant::now(); - drop(guard); - - let lang = get_lang(db).await; - let body = format_completion(&content, tool_count, lang); - - let msg = RichMessage::info(body) - .with_title(match lang { - Lang::ZhCn | Lang::ZhTw => "任务完成", - _ => "Turn Complete", - }) - .with_field("Agent", agent_type) - .with_field( - match lang { - Lang::ZhCn | Lang::ZhTw => "结束原因", - _ => "Stop Reason", - }, - localize_stop_reason(stop_reason, lang), - ); - - let _ = manager.send_to_channel(channel_id, &msg).await; - - if stop_reason == "end_turn" { - let _ = conversation_service::update_status( - db, - conv_id, - crate::db::entities::conversation::ConversationStatus::Completed, - ) - .await; - } - } - } - - "error" => { - let message = payload - .get("message") - .and_then(|v| v.as_str()) - .unwrap_or("Unknown error"); - let agent_type = payload - .get("agent_type") - .and_then(|v| v.as_str()) - .unwrap_or("Unknown"); - - let mut guard = bridge.lock().await; - if let Some(session) = guard.remove(connection_id) { - let channel_id = session.channel_id; - let sender_id = session.sender_id.clone(); - let conv_id = session.conversation_id; - drop(guard); - - let lang = get_lang(db).await; - let msg = RichMessage { - title: Some(match lang { - Lang::ZhCn | Lang::ZhTw => "Agent 错误".to_string(), - _ => "Agent Error".to_string(), - }), - body: format!("[{agent_type}] {message}"), - fields: Vec::new(), - level: MessageLevel::Error, - }; - let _ = manager.send_to_channel(channel_id, &msg).await; - - let _ = conversation_service::update_status( - db, - conv_id, - crate::db::entities::conversation::ConversationStatus::Cancelled, - ) - .await; - let _ = sender_context_service::clear_session(db, channel_id, &sender_id).await; - } - } - - "status_changed" => { - let status = payload.get("status").and_then(|v| v.as_str()).unwrap_or(""); - - if status == "disconnected" || status == "error" { - let mut guard = bridge.lock().await; - if let Some(session) = guard.remove(connection_id) { - let channel_id = session.channel_id; - let sender_id = session.sender_id.clone(); - drop(guard); - - let _ = sender_context_service::clear_session(db, channel_id, &sender_id).await; - } - } - } - - _ => {} - } -} - -async fn flush_progress( - bridge: &Arc>, - manager: &ChatChannelManager, - db: &DatabaseConnection, -) { - let lang = get_lang(db).await; - let updates: Vec<(i32, String)> = { - let mut guard = bridge.lock().await; - let mut out = Vec::new(); - for session in guard.all_sessions_mut() { - if !session.content_buffer.is_empty() - && session.last_flushed.elapsed() >= Duration::from_secs(FLUSH_INTERVAL_SECS) - { - session.last_flushed = Instant::now(); - let last_tool = session.tool_calls.last().cloned(); - let agent_label = session.agent_type.to_string(); - let mut status = super::i18n::agent_responding(lang, &agent_label); - if let Some(tool) = last_tool { - status.push_str(&format!(" | {tool}")); - } - out.push((session.channel_id, status)); - } - } - out - }; - - for (channel_id, text) in updates { - let msg = RichMessage::info(text); - let _ = manager.send_to_channel(channel_id, &msg).await; - } -} - -fn format_completion(content: &str, tool_count: usize, lang: Lang) -> String { - if content.is_empty() { - return match lang { - Lang::ZhCn | Lang::ZhTw => format!("(无文本输出, {tool_count} 次工具调用)"), - _ => format!("(No text output, {tool_count} tool calls)"), - }; - } - - if content.len() <= MAX_MESSAGE_LEN { - let mut body = content.to_string(); - if tool_count > 0 { - body.push_str(&format!( - "\n\n[{} {}]", - tool_count, - match lang { - Lang::ZhCn | Lang::ZhTw => "次工具调用", - _ => "tool calls", - } - )); - } - return body; - } - - // Truncate long content (use char boundaries to avoid panic on multi-byte) - let head_end = content - .char_indices() - .nth(500) - .map(|(i, _)| i) - .unwrap_or(content.len()); - let head = &content[..head_end]; - let tail_start = content - .char_indices() - .rev() - .nth(499) - .map(|(i, _)| i) - .unwrap_or(0); - let tail = &content[tail_start..]; - - match lang { - Lang::ZhCn | Lang::ZhTw => { - format!( - "{head}\n\n...\n\n{tail}\n\n[完整回复: {} 字符, {tool_count} 次工具调用]", - content.len() - ) - } - _ => { - format!( - "{head}\n\n...\n\n{tail}\n\n[Full response: {} chars, {tool_count} tool calls]", - content.len() - ) - } - } -} - -fn localize_stop_reason(reason: &str, lang: Lang) -> String { - match lang { - Lang::ZhCn => match reason { - "end_turn" => "正常结束", - "cancelled" => "已取消", - "max_tokens" => "达到最大长度", - "stop_sequence" => "遇到停止序列", - "error" => "错误", - "timeout" => "超时", - other => other, - }, - Lang::ZhTw => match reason { - "end_turn" => "正常結束", - "cancelled" => "已取消", - "max_tokens" => "達到最大長度", - "stop_sequence" => "遇到停止序列", - "error" => "錯誤", - "timeout" => "逾時", - other => other, - }, - Lang::Ja => match reason { - "end_turn" => "正常終了", - "cancelled" => "キャンセル", - "max_tokens" => "最大トークン数到達", - "stop_sequence" => "停止シーケンス", - "error" => "エラー", - "timeout" => "タイムアウト", - other => other, - }, - Lang::Ko => match reason { - "end_turn" => "정상 종료", - "cancelled" => "취소됨", - "max_tokens" => "최대 길이 도달", - "stop_sequence" => "정지 시퀀스", - "error" => "오류", - "timeout" => "시간 초과", - other => other, - }, - Lang::Es => match reason { - "end_turn" => "Finalizado", - "cancelled" => "Cancelado", - "max_tokens" => "Longitud máxima alcanzada", - "error" => "Error", - "timeout" => "Tiempo agotado", - other => other, - }, - Lang::De => match reason { - "end_turn" => "Abgeschlossen", - "cancelled" => "Abgebrochen", - "max_tokens" => "Maximale Länge erreicht", - "error" => "Fehler", - "timeout" => "Zeitüberschreitung", - other => other, - }, - Lang::Fr => match reason { - "end_turn" => "Terminé", - "cancelled" => "Annulé", - "max_tokens" => "Longueur maximale atteinte", - "error" => "Erreur", - "timeout" => "Délai dépassé", - other => other, - }, - Lang::Pt => match reason { - "end_turn" => "Concluído", - "cancelled" => "Cancelado", - "max_tokens" => "Comprimento máximo atingido", - "error" => "Erro", - "timeout" => "Tempo esgotado", - other => other, - }, - Lang::Ar => match reason { - "end_turn" => "اكتمل", - "cancelled" => "ملغى", - "max_tokens" => "تم بلوغ الحد الأقصى", - "error" => "خطأ", - "timeout" => "انتهت المهلة", - other => other, - }, - Lang::En => match reason { - "end_turn" => "Completed", - "cancelled" => "Cancelled", - "max_tokens" => "Max length reached", - "stop_sequence" => "Stop sequence", - "error" => "Error", - "timeout" => "Timeout", - other => other, - }, - } - .to_string() -} - -/// Extract a concise detail string from a tool call's `raw_input` JSON. -/// -/// Returns a formatted string like `"Read: src/main.rs"` or `"Bash: npm test"`. -/// Falls back to the original title if no detail can be extracted. -fn format_tool_call_detail(title: &str, raw_input: Option<&str>) -> String { - let parsed = raw_input.and_then(|s| serde_json::from_str::(s).ok()); - - let normalized_title = title.to_lowercase().replace([' ', '-'], "_"); - - if let Some(ref obj) = parsed { - // File operations: read, edit, write, delete - if let Some(path) = obj - .get("file_path") - .or_else(|| obj.get("path")) - .or_else(|| obj.get("notebook_path")) - .and_then(|v| v.as_str()) - { - let short = short_path(path); - let label = match normalized_title.as_str() { - s if s.contains("write") => "Write", - s if s.contains("edit") || s.contains("change") || s.contains("update") => "Edit", - s if s.contains("delete") => "Delete", - _ => "Read", - }; - return format!("{label}: {short}"); - } - - // Bash / shell commands - if let Some(cmd) = obj - .get("command") - .or_else(|| obj.get("cmd")) - .and_then(|v| v.as_str()) - { - let short = truncate_str(cmd.lines().next().unwrap_or(cmd), 80); - return format!("Bash: {short}"); - } - - // Grep / search - if let Some(pattern) = obj.get("pattern").and_then(|v| v.as_str()) { - let path = obj.get("path").and_then(|v| v.as_str()); - return if let Some(p) = path { - format!( - "Grep: \"{}\" in {}", - truncate_str(pattern, 40), - short_path(p) - ) - } else { - format!("Grep: \"{}\"", truncate_str(pattern, 60)) - }; - } - - // Glob - if let Some(pat) = obj.get("glob").and_then(|v| v.as_str()) { - return format!("Glob: {pat}"); - } - - // Agent / task - if obj.get("subagent_type").is_some() - || obj.get("task_id").is_some() - || obj.get("subject").is_some() - { - let desc = obj - .get("description") - .or_else(|| obj.get("subject")) - .or_else(|| obj.get("prompt")) - .and_then(|v| v.as_str()); - if let Some(d) = desc { - return format!("Agent: {}", truncate_str(d, 60)); - } - } - - // Web fetch - if let Some(url) = obj.get("url").and_then(|v| v.as_str()) { - return format!("Fetch: {}", truncate_str(url, 80)); - } - - // Web search - if let Some(query) = obj.get("query").and_then(|v| v.as_str()) { - return format!("Search: {}", truncate_str(query, 60)); - } - - // TodoWrite - if obj.get("todos").is_some() { - return "TodoWrite".to_string(); - } - } - - // Fallback: if raw_input is a plain string (e.g. a bare command), use it directly - if let Some(raw) = raw_input { - if !raw.starts_with('{') && !raw.starts_with('[') { - let short = truncate_str(raw.lines().next().unwrap_or(raw), 80); - if normalized_title.contains("bash") - || normalized_title.contains("shell") - || normalized_title.contains("exec") - { - return format!("Bash: {short}"); - } - } - } - - title.to_string() -} - -fn short_path(path: &str) -> &str { - // Show last 2 path components at most, or the full path if short enough - if path.len() <= 60 { - return path; - } - let parts: Vec<&str> = path.rsplitn(3, '/').collect(); - if parts.len() >= 2 { - // e.g. "src/main.rs" from "/very/long/path/src/main.rs" - let tail = &path[path.len() - parts[0].len() - parts[1].len() - 1..]; - if tail.len() < path.len() { - return tail; - } - } - path -} - -fn truncate_str(s: &str, max: usize) -> String { - if s.chars().count() <= max { - s.to_string() - } else { - let truncated: String = s.chars().take(max.saturating_sub(3)).collect(); - format!("{truncated}...") - } -} diff --git a/src-tauri/src/chat_channel/traits.rs b/src-tauri/src/chat_channel/traits.rs deleted file mode 100644 index 4ea5e83..0000000 --- a/src-tauri/src/chat_channel/traits.rs +++ /dev/null @@ -1,53 +0,0 @@ -use async_trait::async_trait; -use tokio::sync::mpsc; - -use super::error::ChatChannelError; -use super::types::*; - -#[async_trait] -pub trait ChatChannelBackend: Send + Sync + 'static { - fn channel_type(&self) -> ChannelType; - - /// Start the receiving loop. `command_tx` forwards incoming IM messages - /// to the central command dispatcher. - async fn start( - &self, - command_tx: mpsc::Sender, - ) -> Result<(), ChatChannelError>; - - /// Stop the backend connection gracefully. - async fn stop(&self) -> Result<(), ChatChannelError>; - - /// Current connection status. - async fn status(&self) -> ChannelConnectionStatus; - - /// Send a plain text message. - async fn send_message(&self, text: &str) -> Result; - - /// Send a rich/structured message (Telegram Markdown / Lark Card). - async fn send_rich_message( - &self, - message: &RichMessage, - ) -> Result; - - /// [Phase 2] Send an interactive message with action buttons. - /// Default implementation degrades to send_rich_message. - async fn send_interactive_message( - &self, - message: &InteractiveMessage, - ) -> Result { - self.send_rich_message(&message.to_rich_fallback()).await - } - - /// [Phase 2] Update an already-sent message (e.g., permission status change). - async fn update_message( - &self, - _message_id: &SentMessageId, - _message: &RichMessage, - ) -> Result<(), ChatChannelError> { - Ok(()) - } - - /// Test the connection (used by "Test Connection" button in UI). - async fn test_connection(&self) -> Result<(), ChatChannelError>; -} diff --git a/src-tauri/src/chat_channel/types.rs b/src-tauri/src/chat_channel/types.rs deleted file mode 100644 index 7370749..0000000 --- a/src-tauri/src/chat_channel/types.rs +++ /dev/null @@ -1,155 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ChannelType { - Lark, - Telegram, - Weixin, -} - -// ── Per-channel strong typed configs ── - -#[derive(Debug, Clone, Deserialize)] -pub struct TelegramConfig { - pub chat_id: String, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct LarkConfig { - pub app_id: String, - pub chat_id: String, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct WeixinConfig { - pub base_url: String, -} - -impl std::fmt::Display for ChannelType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ChannelType::Lark => write!(f, "lark"), - ChannelType::Telegram => write!(f, "telegram"), - ChannelType::Weixin => write!(f, "weixin"), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ChannelConnectionStatus { - Connected, - Connecting, - Disconnected, - Error, -} - -#[derive(Debug, Clone)] -pub struct SentMessageId(pub String); - -pub struct IncomingCommand { - pub channel_id: i32, - pub sender_id: String, - pub command_text: String, - pub metadata: serde_json::Value, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum MessageLevel { - Info, - Warning, - Error, -} - -#[derive(Debug, Clone)] -pub struct RichMessage { - pub title: Option, - pub body: String, - pub fields: Vec<(String, String)>, - pub level: MessageLevel, -} - -impl RichMessage { - pub fn info(body: impl Into) -> Self { - Self { - title: None, - body: body.into(), - fields: Vec::new(), - level: MessageLevel::Info, - } - } - - pub fn error(body: impl Into) -> Self { - Self { - title: None, - body: body.into(), - fields: Vec::new(), - level: MessageLevel::Error, - } - } - - pub fn with_title(mut self, title: impl Into) -> Self { - self.title = Some(title.into()); - self - } - - pub fn with_field(mut self, key: impl Into, value: impl Into) -> Self { - self.fields.push((key.into(), value.into())); - self - } - - pub fn to_plain_text(&self) -> String { - let mut text = String::new(); - if let Some(title) = &self.title { - text.push_str(title); - text.push('\n'); - } - text.push_str(&self.body); - for (key, value) in &self.fields { - text.push_str(&format!("\n{}: {}", key, value)); - } - text - } -} - -// ── Phase 2 forward-compatible types ── - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ButtonStyle { - Primary, - Danger, - Default, -} - -#[derive(Debug, Clone)] -pub struct MessageButton { - pub id: String, - pub label: String, - pub style: ButtonStyle, -} - -#[derive(Debug, Clone)] -pub struct InteractiveMessage { - pub base: RichMessage, - pub buttons: Vec, - pub callback_context: serde_json::Value, -} - -impl InteractiveMessage { - pub fn to_rich_fallback(&self) -> RichMessage { - let mut msg = self.base.clone(); - if !self.buttons.is_empty() { - let button_text: Vec = self - .buttons - .iter() - .map(|b| format!("[{}]", b.label)) - .collect(); - msg.body - .push_str(&format!("\n\n{}", button_text.join(" "))); - } - msg - } -} diff --git a/src-tauri/src/commands/chat_channel.rs b/src-tauri/src/commands/chat_channel.rs deleted file mode 100644 index 4b24efb..0000000 --- a/src-tauri/src/commands/chat_channel.rs +++ /dev/null @@ -1,597 +0,0 @@ -use crate::app_error::AppCommandError; -use crate::chat_channel::backends::weixin::{WeixinQrcodeInfo, WeixinQrcodeStatusPublic}; -use crate::chat_channel::manager::ChatChannelManager; -use crate::chat_channel::types::ChannelType; -use crate::db::service::{chat_channel_message_log_service, chat_channel_service}; -use crate::db::AppDatabase; -use crate::models::chat_channel::{ChannelStatusInfo, ChatChannelInfo, ChatChannelMessageLogInfo}; - -// --------------------------------------------------------------------------- -// Shared core functions (used by both Tauri commands and web handlers) -// --------------------------------------------------------------------------- - -pub async fn list_chat_channels_core( - db: &AppDatabase, -) -> Result, AppCommandError> { - let rows = chat_channel_service::list_all(&db.conn) - .await - .map_err(AppCommandError::from)?; - Ok(rows.into_iter().map(ChatChannelInfo::from).collect()) -} - -pub async fn create_chat_channel_core( - db: &AppDatabase, - name: String, - channel_type: String, - config_json: String, - enabled: bool, - daily_report_enabled: bool, - daily_report_time: Option, -) -> Result { - // Validate channel_type - let _: ChannelType = serde_json::from_value(serde_json::Value::String(channel_type.clone())) - .map_err(|_| { - AppCommandError::invalid_input(format!("Invalid channel type: {channel_type}")) - })?; - - let model = chat_channel_service::create( - &db.conn, - name, - channel_type, - config_json, - enabled, - daily_report_enabled, - daily_report_time, - ) - .await - .map_err(AppCommandError::from)?; - Ok(ChatChannelInfo::from(model)) -} - -#[allow(clippy::too_many_arguments)] -pub async fn update_chat_channel_core( - db: &AppDatabase, - id: i32, - name: Option, - enabled: Option, - config_json: Option, - event_filter_json: Option>, - daily_report_enabled: Option, - daily_report_time: Option>, -) -> Result { - let model = chat_channel_service::update( - &db.conn, - id, - name, - enabled, - config_json, - event_filter_json, - daily_report_enabled, - daily_report_time, - ) - .await - .map_err(AppCommandError::from)?; - Ok(ChatChannelInfo::from(model)) -} - -pub async fn delete_chat_channel_core( - db: &AppDatabase, - manager: &ChatChannelManager, - id: i32, -) -> Result<(), AppCommandError> { - // Disconnect running backend before deleting from DB (prevents orphaned task) - let _ = manager.remove_channel(id).await; - chat_channel_service::delete(&db.conn, id) - .await - .map_err(AppCommandError::from)?; - let _ = crate::keyring_store::delete_channel_token(id); - Ok(()) -} - -pub async fn connect_chat_channel_core( - db: &AppDatabase, - manager: &ChatChannelManager, - id: i32, -) -> Result<(), AppCommandError> { - let model = chat_channel_service::get_by_id(&db.conn, id) - .await - .map_err(AppCommandError::from)? - .ok_or_else(|| AppCommandError::not_found(format!("Chat channel {id} not found")))?; - - let channel_type: ChannelType = serde_json::from_value(serde_json::Value::String( - model.channel_type.clone(), - )) - .map_err(|_| { - AppCommandError::configuration_invalid(format!( - "Invalid channel type: {}", - model.channel_type - )) - })?; - - let config: serde_json::Value = serde_json::from_str(&model.config_json).map_err(|e| { - AppCommandError::configuration_invalid("Invalid config JSON").with_detail(e.to_string()) - })?; - - let token = crate::keyring_store::get_channel_token(id).ok_or_else(|| { - eprintln!("[connect_chat_channel] channel {id}: Token not set in keyring"); - AppCommandError::configuration_missing("Token not set") - })?; - - eprintln!( - "[connect_chat_channel] channel {id}: creating {channel_type} backend, config={}", - model.config_json - ); - - let backend = crate::chat_channel::backends::create_backend(id, channel_type, &config, token) - .map_err(AppCommandError::from)?; - - manager - .add_channel(id, model.name, channel_type, backend) - .await - .map_err(|e| { - eprintln!("[connect_chat_channel] channel {id}: add_channel failed: {e}"); - AppCommandError::from(e) - })?; - - eprintln!("[connect_chat_channel] channel {id}: connected successfully"); - Ok(()) -} - -pub async fn test_chat_channel_core(db: &AppDatabase, id: i32) -> Result<(), AppCommandError> { - let model = chat_channel_service::get_by_id(&db.conn, id) - .await - .map_err(AppCommandError::from)? - .ok_or_else(|| AppCommandError::not_found(format!("Chat channel {id} not found")))?; - - let channel_type: ChannelType = serde_json::from_value(serde_json::Value::String( - model.channel_type.clone(), - )) - .map_err(|_| { - AppCommandError::configuration_invalid(format!( - "Invalid channel type: {}", - model.channel_type - )) - })?; - - let config: serde_json::Value = serde_json::from_str(&model.config_json).map_err(|e| { - AppCommandError::configuration_invalid("Invalid config JSON").with_detail(e.to_string()) - })?; - - let token = crate::keyring_store::get_channel_token(id) - .ok_or_else(|| AppCommandError::configuration_missing("Token not set"))?; - - let backend = crate::chat_channel::backends::create_backend(id, channel_type, &config, token) - .map_err(AppCommandError::from)?; - - backend - .test_connection() - .await - .map_err(AppCommandError::from)?; - - Ok(()) -} - -pub fn save_chat_channel_token_core(channel_id: i32, token: &str) -> Result<(), AppCommandError> { - crate::keyring_store::set_channel_token(channel_id, token) - .map_err(|e| AppCommandError::io_error("Failed to save token").with_detail(e)) -} - -pub fn get_chat_channel_has_token_core(channel_id: i32) -> Result { - Ok(crate::keyring_store::get_channel_token(channel_id).is_some()) -} - -pub fn delete_chat_channel_token_core(channel_id: i32) -> Result<(), AppCommandError> { - crate::keyring_store::delete_channel_token(channel_id) - .map_err(|e| AppCommandError::io_error("Failed to delete token").with_detail(e)) -} - -pub async fn disconnect_chat_channel_core( - manager: &ChatChannelManager, - id: i32, -) -> Result<(), AppCommandError> { - manager - .remove_channel(id) - .await - .map_err(AppCommandError::from)?; - Ok(()) -} - -pub async fn get_chat_channel_status_core( - manager: &ChatChannelManager, -) -> Result, AppCommandError> { - Ok(manager.get_status().await) -} - -pub async fn list_chat_channel_messages_core( - db: &AppDatabase, - channel_id: i32, - limit: Option, - offset: Option, -) -> Result, AppCommandError> { - let limit = limit.unwrap_or(50); - let offset = offset.unwrap_or(0); - let rows = - chat_channel_message_log_service::list_by_channel(&db.conn, channel_id, limit, offset) - .await - .map_err(AppCommandError::from)?; - Ok(rows - .into_iter() - .map(ChatChannelMessageLogInfo::from) - .collect()) -} - -const COMMAND_PREFIX_KEY: &str = "chat_command_prefix"; -const DEFAULT_COMMAND_PREFIX: &str = "/"; - -pub async fn get_chat_command_prefix_core(db: &AppDatabase) -> Result { - let val = crate::db::service::app_metadata_service::get_value(&db.conn, COMMAND_PREFIX_KEY) - .await - .map_err(AppCommandError::from)?; - Ok(val.unwrap_or_else(|| DEFAULT_COMMAND_PREFIX.to_string())) -} - -pub async fn set_chat_command_prefix_core( - db: &AppDatabase, - prefix: String, -) -> Result<(), AppCommandError> { - let trimmed = prefix.trim(); - if trimmed.is_empty() || trimmed.len() > 3 || trimmed.chars().any(|c| c.is_alphanumeric()) { - return Err(AppCommandError::invalid_input( - "Prefix must be 1-3 non-alphanumeric characters", - )); - } - crate::db::service::app_metadata_service::upsert_value(&db.conn, COMMAND_PREFIX_KEY, trimmed) - .await - .map_err(AppCommandError::from)?; - Ok(()) -} - -const MESSAGE_LANGUAGE_KEY: &str = "chat_message_language"; - -pub async fn get_chat_message_language_core(db: &AppDatabase) -> Result { - let val = crate::db::service::app_metadata_service::get_value(&db.conn, MESSAGE_LANGUAGE_KEY) - .await - .map_err(AppCommandError::from)?; - Ok(val.unwrap_or_else(|| "en".to_string())) -} - -pub async fn set_chat_message_language_core( - db: &AppDatabase, - language: String, -) -> Result<(), AppCommandError> { - // Validate language code - let valid = [ - "en", "zh-cn", "zh-tw", "ja", "ko", "es", "de", "fr", "pt", "ar", - ]; - let lang_lower = language.to_lowercase(); - if !valid.contains(&lang_lower.as_str()) { - return Err(AppCommandError::invalid_input(format!( - "Unsupported language: {language}. Supported: {}", - valid.join(", ") - ))); - } - crate::db::service::app_metadata_service::upsert_value( - &db.conn, - MESSAGE_LANGUAGE_KEY, - &lang_lower, - ) - .await - .map_err(AppCommandError::from)?; - Ok(()) -} - -const EVENT_FILTER_KEY: &str = "chat_event_filter"; - -pub async fn get_chat_event_filter_core( - db: &AppDatabase, -) -> Result>, AppCommandError> { - let val = crate::db::service::app_metadata_service::get_value(&db.conn, EVENT_FILTER_KEY) - .await - .map_err(AppCommandError::from)?; - match val { - Some(json) => { - // Parse as Option> to correctly handle stored "null" - let filter: Option> = serde_json::from_str(&json) - .map_err(|e| AppCommandError::invalid_input(e.to_string()))?; - Ok(filter) - } - None => Ok(None), - } -} - -pub async fn set_chat_event_filter_core( - db: &AppDatabase, - filter: Option>, -) -> Result<(), AppCommandError> { - match filter { - Some(arr) => { - let json = serde_json::to_string(&arr) - .map_err(|e| AppCommandError::invalid_input(e.to_string()))?; - crate::db::service::app_metadata_service::upsert_value( - &db.conn, - EVENT_FILTER_KEY, - &json, - ) - .await - .map_err(AppCommandError::from)?; - } - None => { - // null means all events enabled — remove the key - crate::db::service::app_metadata_service::upsert_value( - &db.conn, - EVENT_FILTER_KEY, - "null", - ) - .await - .map_err(AppCommandError::from)?; - } - } - Ok(()) -} - -// --------------------------------------------------------------------------- -// WeChat QR code auth -// --------------------------------------------------------------------------- - -pub async fn weixin_get_qrcode_core() -> Result { - crate::chat_channel::backends::weixin::weixin_get_qrcode() - .await - .map_err(AppCommandError::from) -} - -pub async fn weixin_check_qrcode_core( - db: &AppDatabase, - channel_id: i32, - qrcode: &str, -) -> Result { - let result = crate::chat_channel::backends::weixin::weixin_check_qrcode(qrcode) - .await - .map_err(AppCommandError::from)?; - - // On confirmed: save token + update config with base_url - if result.status == "confirmed" { - eprintln!( - "[Weixin] QR confirmed for channel {channel_id}, bot_token={}, base_url={}", - result - .bot_token - .as_deref() - .map(|t| if t.len() > 8 { &t[..8] } else { t }) - .unwrap_or("None"), - result.base_url.as_deref().unwrap_or("None"), - ); - if let Some(ref token) = result.bot_token { - save_chat_channel_token_core(channel_id, token)?; - eprintln!("[Weixin] Token saved for channel {channel_id}"); - } else { - eprintln!( - "[Weixin] WARNING: No bot_token in confirmed response for channel {channel_id}" - ); - } - if let Some(ref base_url) = result.base_url { - let config_json = serde_json::json!({ "base_url": base_url }).to_string(); - update_chat_channel_core( - db, - channel_id, - None, - None, - Some(config_json), - None, - None, - None, - ) - .await?; - eprintln!("[Weixin] Config updated with base_url for channel {channel_id}"); - } - } - - // Return only the status — never expose bot_token to the frontend - Ok(WeixinQrcodeStatusPublic { - status: result.status, - }) -} - -// --------------------------------------------------------------------------- -// Tauri commands (use tauri::State for injection) -// --------------------------------------------------------------------------- - -#[cfg(feature = "tauri-runtime")] -#[tauri::command] -pub async fn list_chat_channels( - db: tauri::State<'_, AppDatabase>, -) -> Result, AppCommandError> { - list_chat_channels_core(&db).await -} - -#[cfg(feature = "tauri-runtime")] -#[tauri::command] -pub async fn create_chat_channel( - db: tauri::State<'_, AppDatabase>, - name: String, - channel_type: String, - config_json: String, - enabled: bool, - daily_report_enabled: bool, - daily_report_time: Option, -) -> Result { - create_chat_channel_core( - &db, - name, - channel_type, - config_json, - enabled, - daily_report_enabled, - daily_report_time, - ) - .await -} - -#[allow(clippy::too_many_arguments)] -#[cfg(feature = "tauri-runtime")] -#[tauri::command] -pub async fn update_chat_channel( - db: tauri::State<'_, AppDatabase>, - id: i32, - name: Option, - enabled: Option, - config_json: Option, - event_filter_json: Option>, - daily_report_enabled: Option, - daily_report_time: Option>, -) -> Result { - update_chat_channel_core( - &db, - id, - name, - enabled, - config_json, - event_filter_json, - daily_report_enabled, - daily_report_time, - ) - .await -} - -#[cfg(feature = "tauri-runtime")] -#[tauri::command] -pub async fn delete_chat_channel( - db: tauri::State<'_, AppDatabase>, - manager: tauri::State<'_, ChatChannelManager>, - id: i32, -) -> Result<(), AppCommandError> { - delete_chat_channel_core(&db, &manager, id).await -} - -#[cfg(feature = "tauri-runtime")] -#[tauri::command] -pub async fn save_chat_channel_token( - channel_id: i32, - token: String, -) -> Result<(), AppCommandError> { - save_chat_channel_token_core(channel_id, &token) -} - -#[cfg(feature = "tauri-runtime")] -#[tauri::command] -pub async fn get_chat_channel_has_token(channel_id: i32) -> Result { - get_chat_channel_has_token_core(channel_id) -} - -#[cfg(feature = "tauri-runtime")] -#[tauri::command] -pub async fn delete_chat_channel_token(channel_id: i32) -> Result<(), AppCommandError> { - delete_chat_channel_token_core(channel_id) -} - -#[cfg(feature = "tauri-runtime")] -#[tauri::command] -pub async fn connect_chat_channel( - db: tauri::State<'_, AppDatabase>, - manager: tauri::State<'_, ChatChannelManager>, - id: i32, -) -> Result<(), AppCommandError> { - connect_chat_channel_core(&db, &manager, id).await -} - -#[cfg(feature = "tauri-runtime")] -#[tauri::command] -pub async fn disconnect_chat_channel( - manager: tauri::State<'_, ChatChannelManager>, - id: i32, -) -> Result<(), AppCommandError> { - disconnect_chat_channel_core(&manager, id).await -} - -#[cfg(feature = "tauri-runtime")] -#[tauri::command] -pub async fn test_chat_channel( - db: tauri::State<'_, AppDatabase>, - id: i32, -) -> Result<(), AppCommandError> { - test_chat_channel_core(&db, id).await -} - -#[cfg(feature = "tauri-runtime")] -#[tauri::command] -pub async fn get_chat_channel_status( - manager: tauri::State<'_, ChatChannelManager>, -) -> Result, AppCommandError> { - get_chat_channel_status_core(&manager).await -} - -#[cfg(feature = "tauri-runtime")] -#[tauri::command] -pub async fn list_chat_channel_messages( - db: tauri::State<'_, AppDatabase>, - channel_id: i32, - limit: Option, - offset: Option, -) -> Result, AppCommandError> { - list_chat_channel_messages_core(&db, channel_id, limit, offset).await -} - -#[cfg(feature = "tauri-runtime")] -#[tauri::command] -pub async fn get_chat_command_prefix( - db: tauri::State<'_, AppDatabase>, -) -> Result { - get_chat_command_prefix_core(&db).await -} - -#[cfg(feature = "tauri-runtime")] -#[tauri::command] -pub async fn set_chat_command_prefix( - db: tauri::State<'_, AppDatabase>, - prefix: String, -) -> Result<(), AppCommandError> { - set_chat_command_prefix_core(&db, prefix).await -} - -#[cfg(feature = "tauri-runtime")] -#[tauri::command] -pub async fn get_chat_event_filter( - db: tauri::State<'_, AppDatabase>, -) -> Result>, AppCommandError> { - get_chat_event_filter_core(&db).await -} - -#[cfg(feature = "tauri-runtime")] -#[tauri::command] -pub async fn set_chat_event_filter( - db: tauri::State<'_, AppDatabase>, - filter: Option>, -) -> Result<(), AppCommandError> { - set_chat_event_filter_core(&db, filter).await -} - -#[cfg(feature = "tauri-runtime")] -#[tauri::command] -pub async fn get_chat_message_language( - db: tauri::State<'_, AppDatabase>, -) -> Result { - get_chat_message_language_core(&db).await -} - -#[cfg(feature = "tauri-runtime")] -#[tauri::command] -pub async fn set_chat_message_language( - db: tauri::State<'_, AppDatabase>, - language: String, -) -> Result<(), AppCommandError> { - set_chat_message_language_core(&db, language).await -} - -#[cfg(feature = "tauri-runtime")] -#[tauri::command] -pub async fn weixin_get_qrcode() -> Result { - weixin_get_qrcode_core().await -} - -#[cfg(feature = "tauri-runtime")] -#[tauri::command] -pub async fn weixin_check_qrcode( - db: tauri::State<'_, AppDatabase>, - channel_id: i32, - qrcode: String, -) -> Result { - weixin_check_qrcode_core(&db, channel_id, &qrcode).await -} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index d3cd999..e362f1c 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,5 +1,4 @@ pub mod acp; -pub mod chat_channel; pub mod conversations; pub mod experts; pub mod folder_commands; @@ -8,7 +7,6 @@ pub mod mcp; pub mod model_provider; #[cfg(feature = "tauri-runtime")] pub mod notification; -pub mod project_boot; pub mod quick_messages; pub mod system_settings; pub mod terminal; diff --git a/src-tauri/src/commands/project_boot.rs b/src-tauri/src/commands/project_boot.rs deleted file mode 100644 index b71cd63..0000000 --- a/src-tauri/src/commands/project_boot.rs +++ /dev/null @@ -1,184 +0,0 @@ -use serde::Serialize; -use std::path::PathBuf; - -use crate::app_error::AppCommandError; - -// --------------------------------------------------------------------------- -// Package manager detection -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Serialize)] -pub struct PackageManagerInfo { - pub name: String, - pub installed: bool, - pub version: Option, -} - -async fn detect_one(name: &str) -> PackageManagerInfo { - let program = match name { - "bun" => "bun", - "pnpm" => "pnpm", - "yarn" => "yarn", - _ => "npm", - }; - - let result = crate::process::tokio_command(program) - .arg("--version") - .output() - .await; - - match result { - Ok(output) if output.status.success() => { - let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); - PackageManagerInfo { - name: name.to_string(), - installed: true, - version: Some(version), - } - } - _ => PackageManagerInfo { - name: name.to_string(), - installed: false, - version: None, - }, - } -} - -#[cfg_attr(feature = "tauri-runtime", tauri::command)] -pub async fn detect_package_manager(name: String) -> PackageManagerInfo { - detect_one(&name).await -} - -// --------------------------------------------------------------------------- -// Project creation -// --------------------------------------------------------------------------- - -#[cfg_attr(feature = "tauri-runtime", tauri::command)] -pub async fn create_shadcn_project( - project_name: String, - template: String, - preset_code: String, - package_manager: String, - target_dir: String, -) -> Result { - let project_name = project_name.trim().to_string(); - let template = template.trim().to_string(); - let preset_code = preset_code.trim().to_string(); - let package_manager = package_manager.trim().to_string(); - let target_dir = target_dir.trim().to_string(); - - if project_name.is_empty() { - return Err(AppCommandError::invalid_input("Project name is required")); - } - if template.is_empty() { - return Err(AppCommandError::invalid_input("Template is required")); - } - if target_dir.is_empty() { - return Err(AppCommandError::invalid_input( - "Target directory is required", - )); - } - - let full_path = PathBuf::from(&target_dir).join(&project_name); - let full_path_str = full_path.to_string_lossy().to_string(); - - // Check if directory already exists and is non-empty - if full_path.exists() { - let is_empty = full_path - .read_dir() - .map(|mut entries| entries.next().is_none()) - .unwrap_or(false); - if !is_empty { - return Err(AppCommandError::already_exists( - "Target directory already exists and is not empty", - )); - } - } - - // Determine the command based on package manager - let (program, prefix_args): (&str, Vec<&str>) = match package_manager.as_str() { - "pnpm" => ("pnpm", vec!["dlx"]), - "yarn" => ("yarn", vec!["dlx"]), - "bun" => ("bunx", vec![]), - _ => ("npx", vec![]), - }; - - let mut cmd = crate::process::tokio_command(program); - cmd.args(&prefix_args); - cmd.args([ - "shadcn@latest", - "init", - "-n", - &project_name, - "-t", - &template, - "-p", - &preset_code, - "-y", - ]); - cmd.current_dir(&target_dir); - - // Log the full command for debugging - let cmd_display = format!( - "{} {} shadcn@latest init -n {} -t {} -p {} -y (cwd={})", - program, - prefix_args.join(" "), - project_name, - template, - preset_code, - target_dir - ); - eprintln!("[ProjectBoot] executing: {cmd_display}"); - - let output = cmd.output().await.map_err(|e| { - eprintln!("[ProjectBoot] spawn error: {e}"); - if e.kind() == std::io::ErrorKind::NotFound { - AppCommandError::dependency_missing(format!( - "{program} is not installed. Please install Node.js first." - )) - } else { - AppCommandError::external_command( - "Failed to execute project creation command", - e.to_string(), - ) - } - })?; - - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - - eprintln!( - "[ProjectBoot] exit={} stdout_len={} stderr_len={}", - output.status, - stdout.len(), - stderr.len() - ); - if !stdout.is_empty() { - eprintln!("[ProjectBoot] stdout: {stdout}"); - } - if !stderr.is_empty() { - eprintln!("[ProjectBoot] stderr: {stderr}"); - } - - if !output.status.success() { - let mut detail = String::new(); - if !stderr.is_empty() { - detail.push_str(&stderr); - } - if !stdout.is_empty() { - if !detail.is_empty() { - detail.push('\n'); - } - detail.push_str(&stdout); - } - if detail.is_empty() { - detail = format!("Command exited with status: {}", output.status); - } - return Err(AppCommandError::external_command( - "Project creation command failed", - detail, - )); - } - - Ok(full_path_str) -} diff --git a/src-tauri/src/db/entities/chat_channel.rs b/src-tauri/src/db/entities/chat_channel.rs deleted file mode 100644 index 1162d74..0000000 --- a/src-tauri/src/db/entities/chat_channel.rs +++ /dev/null @@ -1,31 +0,0 @@ -use sea_orm::entity::prelude::*; - -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] -#[sea_orm(table_name = "chat_channel")] -pub struct Model { - #[sea_orm(primary_key)] - pub id: i32, - pub name: String, - pub channel_type: String, - pub enabled: bool, - pub config_json: String, - pub event_filter_json: Option, - pub daily_report_enabled: bool, - pub daily_report_time: Option, - pub created_at: DateTimeUtc, - pub updated_at: DateTimeUtc, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation { - #[sea_orm(has_many = "super::chat_channel_message_log::Entity")] - MessageLogs, -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::MessageLogs.def() - } -} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/chat_channel_message_log.rs b/src-tauri/src/db/entities/chat_channel_message_log.rs deleted file mode 100644 index 1a351c7..0000000 --- a/src-tauri/src/db/entities/chat_channel_message_log.rs +++ /dev/null @@ -1,33 +0,0 @@ -use sea_orm::entity::prelude::*; - -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] -#[sea_orm(table_name = "chat_channel_message_log")] -pub struct Model { - #[sea_orm(primary_key)] - pub id: i32, - pub channel_id: i32, - pub direction: String, - pub message_type: String, - pub content_preview: String, - pub status: String, - pub error_detail: Option, - pub created_at: DateTimeUtc, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation { - #[sea_orm( - belongs_to = "super::chat_channel::Entity", - from = "Column::ChannelId", - to = "super::chat_channel::Column::Id" - )] - ChatChannel, -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::ChatChannel.def() - } -} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/chat_channel_sender_context.rs b/src-tauri/src/db/entities/chat_channel_sender_context.rs deleted file mode 100644 index fdd99bf..0000000 --- a/src-tauri/src/db/entities/chat_channel_sender_context.rs +++ /dev/null @@ -1,35 +0,0 @@ -use sea_orm::entity::prelude::*; - -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] -#[sea_orm(table_name = "chat_channel_sender_context")] -pub struct Model { - #[sea_orm(primary_key)] - pub id: i32, - pub channel_id: i32, - pub sender_id: String, - pub current_folder_id: Option, - pub current_agent_type: Option, - pub current_conversation_id: Option, - pub current_connection_id: Option, - pub auto_approve: bool, - pub created_at: DateTimeUtc, - pub updated_at: DateTimeUtc, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation { - #[sea_orm( - belongs_to = "super::chat_channel::Entity", - from = "Column::ChannelId", - to = "super::chat_channel::Column::Id" - )] - ChatChannel, -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::ChatChannel.def() - } -} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/mod.rs b/src-tauri/src/db/entities/mod.rs index 89d5b6b..f4b1d13 100644 --- a/src-tauri/src/db/entities/mod.rs +++ b/src-tauri/src/db/entities/mod.rs @@ -1,8 +1,5 @@ pub mod agent_setting; pub mod app_metadata; -pub mod chat_channel; -pub mod chat_channel_message_log; -pub mod chat_channel_sender_context; pub mod conversation; pub mod folder; pub mod folder_command; diff --git a/src-tauri/src/db/entities/prelude.rs b/src-tauri/src/db/entities/prelude.rs index 59ca8cc..304720f 100644 --- a/src-tauri/src/db/entities/prelude.rs +++ b/src-tauri/src/db/entities/prelude.rs @@ -2,9 +2,6 @@ pub use super::agent_setting::Entity as AgentSetting; pub use super::app_metadata::Entity as AppMetadata; -pub use super::chat_channel::Entity as ChatChannel; -pub use super::chat_channel_message_log::Entity as ChatChannelMessageLog; -pub use super::chat_channel_sender_context::Entity as ChatChannelSenderContext; pub use super::conversation::Entity as Conversation; pub use super::folder::Entity as Folder; pub use super::folder_command::Entity as FolderCommand; diff --git a/src-tauri/src/db/service/chat_channel_message_log_service.rs b/src-tauri/src/db/service/chat_channel_message_log_service.rs deleted file mode 100644 index 78331fd..0000000 --- a/src-tauri/src/db/service/chat_channel_message_log_service.rs +++ /dev/null @@ -1,70 +0,0 @@ -use chrono::Utc; -use sea_orm::prelude::DateTimeUtc; -use sea_orm::{ - ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, DatabaseConnection, EntityTrait, - QueryFilter, QueryOrder, Set, -}; - -use crate::db::entities::chat_channel_message_log; -use crate::db::error::DbError; - -pub async fn create_log( - conn: &DatabaseConnection, - channel_id: i32, - direction: &str, - message_type: &str, - content_preview: &str, - status: &str, - error_detail: Option, -) -> Result<(), DbError> { - let active = chat_channel_message_log::ActiveModel { - id: NotSet, - channel_id: Set(channel_id), - direction: Set(direction.to_string()), - message_type: Set(message_type.to_string()), - content_preview: Set(truncate_preview(content_preview)), - status: Set(status.to_string()), - error_detail: Set(error_detail), - created_at: Set(Utc::now()), - }; - active.insert(conn).await?; - Ok(()) -} - -pub async fn list_by_channel( - conn: &DatabaseConnection, - channel_id: i32, - limit: u64, - offset: u64, -) -> Result, DbError> { - use sea_orm::PaginatorTrait; - Ok(chat_channel_message_log::Entity::find() - .filter(chat_channel_message_log::Column::ChannelId.eq(channel_id)) - .order_by_desc(chat_channel_message_log::Column::CreatedAt) - .paginate(conn, limit) - .fetch_page(offset / limit) - .await?) -} - -pub async fn cleanup_old_logs( - conn: &DatabaseConnection, - older_than: DateTimeUtc, -) -> Result { - let result = chat_channel_message_log::Entity::delete_many() - .filter(chat_channel_message_log::Column::CreatedAt.lt(older_than)) - .exec(conn) - .await?; - Ok(result.rows_affected) -} - -fn truncate_preview(s: &str) -> String { - if s.len() <= 200 { - s.to_string() - } else { - let mut end = 200; - while !s.is_char_boundary(end) && end > 0 { - end -= 1; - } - format!("{}...", &s[..end]) - } -} diff --git a/src-tauri/src/db/service/chat_channel_service.rs b/src-tauri/src/db/service/chat_channel_service.rs deleted file mode 100644 index 55a3640..0000000 --- a/src-tauri/src/db/service/chat_channel_service.rs +++ /dev/null @@ -1,99 +0,0 @@ -use chrono::Utc; -use sea_orm::{ - ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, DatabaseConnection, EntityTrait, - IntoActiveModel, QueryFilter, QueryOrder, Set, -}; - -use crate::db::entities::chat_channel; -use crate::db::error::DbError; - -pub async fn create( - conn: &DatabaseConnection, - name: String, - channel_type: String, - config_json: String, - enabled: bool, - daily_report_enabled: bool, - daily_report_time: Option, -) -> Result { - let now = Utc::now(); - let active = chat_channel::ActiveModel { - id: NotSet, - name: Set(name), - channel_type: Set(channel_type), - enabled: Set(enabled), - config_json: Set(config_json), - event_filter_json: Set(None), - daily_report_enabled: Set(daily_report_enabled), - daily_report_time: Set(daily_report_time), - created_at: Set(now), - updated_at: Set(now), - }; - Ok(active.insert(conn).await?) -} - -#[allow(clippy::too_many_arguments)] -pub async fn update( - conn: &DatabaseConnection, - id: i32, - name: Option, - enabled: Option, - config_json: Option, - event_filter_json: Option>, - daily_report_enabled: Option, - daily_report_time: Option>, -) -> Result { - let model = chat_channel::Entity::find_by_id(id) - .one(conn) - .await? - .ok_or_else(|| DbError::Migration(format!("chat channel not found: {id}")))?; - - let mut active = model.into_active_model(); - if let Some(v) = name { - active.name = Set(v); - } - if let Some(v) = enabled { - active.enabled = Set(v); - } - if let Some(v) = config_json { - active.config_json = Set(v); - } - if let Some(v) = event_filter_json { - active.event_filter_json = Set(v); - } - if let Some(v) = daily_report_enabled { - active.daily_report_enabled = Set(v); - } - if let Some(v) = daily_report_time { - active.daily_report_time = Set(v); - } - active.updated_at = Set(Utc::now()); - Ok(active.update(conn).await?) -} - -pub async fn delete(conn: &DatabaseConnection, id: i32) -> Result<(), DbError> { - chat_channel::Entity::delete_by_id(id).exec(conn).await?; - Ok(()) -} - -pub async fn get_by_id( - conn: &DatabaseConnection, - id: i32, -) -> Result, DbError> { - Ok(chat_channel::Entity::find_by_id(id).one(conn).await?) -} - -pub async fn list_all(conn: &DatabaseConnection) -> Result, DbError> { - Ok(chat_channel::Entity::find() - .order_by_asc(chat_channel::Column::Id) - .all(conn) - .await?) -} - -pub async fn list_enabled(conn: &DatabaseConnection) -> Result, DbError> { - Ok(chat_channel::Entity::find() - .filter(chat_channel::Column::Enabled.eq(true)) - .order_by_asc(chat_channel::Column::Id) - .all(conn) - .await?) -} diff --git a/src-tauri/src/db/service/mod.rs b/src-tauri/src/db/service/mod.rs index be73479..2ddc534 100644 --- a/src-tauri/src/db/service/mod.rs +++ b/src-tauri/src/db/service/mod.rs @@ -1,12 +1,9 @@ pub mod agent_setting_service; pub mod app_metadata_service; -pub mod chat_channel_message_log_service; -pub mod chat_channel_service; pub mod conversation_service; pub mod folder_command_service; pub mod folder_service; pub mod import_service; pub mod model_provider_service; pub mod quick_message_service; -pub mod sender_context_service; pub mod tab_service; diff --git a/src-tauri/src/db/service/sender_context_service.rs b/src-tauri/src/db/service/sender_context_service.rs deleted file mode 100644 index 97aae58..0000000 --- a/src-tauri/src/db/service/sender_context_service.rs +++ /dev/null @@ -1,101 +0,0 @@ -use chrono::Utc; -use sea_orm::{ - ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, DatabaseConnection, EntityTrait, - IntoActiveModel, QueryFilter, Set, -}; - -use crate::db::entities::chat_channel_sender_context; -use crate::db::error::DbError; - -pub async fn get_or_create( - conn: &DatabaseConnection, - channel_id: i32, - sender_id: &str, -) -> Result { - let existing = chat_channel_sender_context::Entity::find() - .filter(chat_channel_sender_context::Column::ChannelId.eq(channel_id)) - .filter(chat_channel_sender_context::Column::SenderId.eq(sender_id)) - .one(conn) - .await?; - - if let Some(model) = existing { - return Ok(model); - } - - let now = Utc::now(); - let active = chat_channel_sender_context::ActiveModel { - id: NotSet, - channel_id: Set(channel_id), - sender_id: Set(sender_id.to_string()), - current_folder_id: Set(None), - current_agent_type: Set(None), - current_conversation_id: Set(None), - current_connection_id: Set(None), - auto_approve: Set(false), - created_at: Set(now), - updated_at: Set(now), - }; - Ok(active.insert(conn).await?) -} - -pub async fn update_folder( - conn: &DatabaseConnection, - channel_id: i32, - sender_id: &str, - folder_id: Option, -) -> Result { - let model = get_or_create(conn, channel_id, sender_id).await?; - let mut active = model.into_active_model(); - active.current_folder_id = Set(folder_id); - active.updated_at = Set(Utc::now()); - Ok(active.update(conn).await?) -} - -pub async fn update_agent( - conn: &DatabaseConnection, - channel_id: i32, - sender_id: &str, - agent_type: Option, -) -> Result { - let model = get_or_create(conn, channel_id, sender_id).await?; - let mut active = model.into_active_model(); - active.current_agent_type = Set(agent_type); - active.updated_at = Set(Utc::now()); - Ok(active.update(conn).await?) -} - -pub async fn update_session( - conn: &DatabaseConnection, - channel_id: i32, - sender_id: &str, - conversation_id: Option, - connection_id: Option, -) -> Result { - let model = get_or_create(conn, channel_id, sender_id).await?; - let mut active = model.into_active_model(); - active.current_conversation_id = Set(conversation_id); - active.current_connection_id = Set(connection_id); - active.updated_at = Set(Utc::now()); - Ok(active.update(conn).await?) -} - -pub async fn clear_session( - conn: &DatabaseConnection, - channel_id: i32, - sender_id: &str, -) -> Result { - update_session(conn, channel_id, sender_id, None, None).await -} - -pub async fn update_auto_approve( - conn: &DatabaseConnection, - channel_id: i32, - sender_id: &str, - auto_approve: bool, -) -> Result { - let model = get_or_create(conn, channel_id, sender_id).await?; - let mut active = model.into_active_model(); - active.auto_approve = Set(auto_approve); - active.updated_at = Set(Utc::now()); - Ok(active.update(conn).await?) -} diff --git a/src-tauri/src/keyring_store.rs b/src-tauri/src/keyring_store.rs index 041d1ee..4dabba8 100644 --- a/src-tauri/src/keyring_store.rs +++ b/src-tauri/src/keyring_store.rs @@ -5,9 +5,6 @@ fn token_key(account_id: &str) -> String { format!("github-token:{}", account_id) } -fn channel_token_key(channel_id: i32) -> String { - format!("chat-channel:{}", channel_id) -} // ── Tauri mode: OS keyring ── @@ -91,50 +88,3 @@ pub fn delete_token(account_id: &str) -> Result<(), String> { write_tokens(&tokens) } -// ── Chat channel token helpers ── -// Reuse the same storage mechanism (keyring or file) with a different key prefix. - -#[cfg(feature = "tauri-runtime")] -pub fn set_channel_token(channel_id: i32, token: &str) -> Result<(), String> { - let entry = keyring::Entry::new(SERVICE_NAME, &channel_token_key(channel_id)) - .map_err(|e| format!("keyring init error: {e}"))?; - entry - .set_password(token) - .map_err(|e| format!("keyring set error: {e}")) -} - -#[cfg(feature = "tauri-runtime")] -pub fn get_channel_token(channel_id: i32) -> Option { - let entry = keyring::Entry::new(SERVICE_NAME, &channel_token_key(channel_id)).ok()?; - entry.get_password().ok() -} - -#[cfg(feature = "tauri-runtime")] -pub fn delete_channel_token(channel_id: i32) -> Result<(), String> { - let entry = keyring::Entry::new(SERVICE_NAME, &channel_token_key(channel_id)) - .map_err(|e| format!("keyring init error: {e}"))?; - match entry.delete_credential() { - Ok(()) => Ok(()), - Err(keyring::Error::NoEntry) => Ok(()), - Err(e) => Err(format!("keyring delete error: {e}")), - } -} - -#[cfg(not(feature = "tauri-runtime"))] -pub fn set_channel_token(channel_id: i32, token: &str) -> Result<(), String> { - let mut tokens = read_tokens(); - tokens.insert(channel_token_key(channel_id), token.to_string()); - write_tokens(&tokens) -} - -#[cfg(not(feature = "tauri-runtime"))] -pub fn get_channel_token(channel_id: i32) -> Option { - read_tokens().get(&channel_token_key(channel_id)).cloned() -} - -#[cfg(not(feature = "tauri-runtime"))] -pub fn delete_channel_token(channel_id: i32) -> Result<(), String> { - let mut tokens = read_tokens(); - tokens.remove(&channel_token_key(channel_id)); - write_tokens(&tokens) -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7201960..e632c89 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,7 +1,6 @@ mod acp; mod app_error; pub mod app_state; -pub mod chat_channel; pub mod commands; pub mod db; pub mod git_credential; @@ -30,11 +29,10 @@ mod tauri_app { use std::sync::atomic::{AtomicBool, Ordering}; use crate::acp::manager::ConnectionManager; - use crate::chat_channel::manager::ChatChannelManager; use crate::commands::{ - acp as acp_commands, chat_channel as chat_channel_commands, conversations, + acp as acp_commands, conversations, experts as experts_commands, folder_commands, folders, mcp as mcp_commands, - model_provider as model_provider_commands, notification, project_boot, + model_provider as model_provider_commands, notification, quick_messages as quick_messages_commands, system_settings, terminal as terminal_commands, version_control, windows, workspace_state as workspace_state_commands, @@ -106,10 +104,7 @@ mod tauri_app { .plugin(tauri_plugin_notification::init()) .manage(ConnectionManager::new()) .manage(TerminalManager::new()) - .manage(ChatChannelManager::new()) .manage(windows::SettingsWindowState::new()) - .manage(windows::CommitWindowState::new()) - .manage(windows::MergeWindowState::new()) .manage(web::WebServerState::new()) .manage(std::sync::Arc::new( web::event_bridge::WebEventBroadcaster::new(), @@ -170,22 +165,7 @@ mod tauri_app { } }); - // Start chat channel background tasks - { - let ccm = app.state::(); - let broadcaster = - app.state::>(); - let db_conn = app.state::().conn.clone(); - let ccm_ref = ccm.clone_ref(); - let br = broadcaster.inner().clone(); - let cm = app.state::().clone_ref(); - let emitter = web::event_bridge::EventEmitter::Tauri(app.handle().clone()); - tauri::async_runtime::spawn(async move { - ccm_ref.start_background(br, db_conn, cm, emitter).await; - }); - } - - // Single-window workspace: ensure the main window exists. +// Single-window workspace: ensure the main window exists. // Workspace state (open folders, opened tabs, active tab) is // restored by the frontend via `list_open_folder_details` / // `list_opened_tabs` inside the main window. @@ -217,35 +197,6 @@ mod tauri_app { } } - if label.starts_with("commit-") - && matches!( - event, - tauri::WindowEvent::CloseRequested { .. } | tauri::WindowEvent::Destroyed - ) - { - let app = window.app_handle(); - if let Some(state) = app.try_state::() { - windows::restore_window_after_commit(app, &state, &label); - } - } - - if label.starts_with("merge-") - && matches!( - event, - tauri::WindowEvent::CloseRequested { .. } | tauri::WindowEvent::Destroyed - ) - { - let app = window.app_handle(); - if let Some(state) = app.try_state::() { - windows::restore_window_after_merge(app, &state, &label); - } - let app_clone = window.app_handle().clone(); - let label_clone = label.clone(); - tauri::async_runtime::spawn(async move { - windows::cleanup_dangling_merge(&app_clone, &label_clone).await; - }); - } - if label == "main" && matches!(event, tauri::WindowEvent::CloseRequested { .. }) { let app = window.app_handle(); if let Some(cm) = app.try_state::() { @@ -352,16 +303,9 @@ mod tauri_app { folders::git_log, folders::git_commit_branches, windows::open_folder_window, - windows::open_commit_window, windows::open_settings_window, - windows::open_merge_window, - windows::open_stash_window, - windows::open_push_window, - windows::open_project_boot_window, windows::update_traffic_light_position, windows::update_appearance_mode, - project_boot::detect_package_manager, - project_boot::create_shadcn_project, system_settings::get_system_proxy_settings, system_settings::update_system_proxy_settings, system_settings::get_system_language_settings, @@ -440,26 +384,6 @@ mod tauri_app { mcp_commands::mcp_set_server_apps, mcp_commands::mcp_remove_server, notification::send_notification, - chat_channel_commands::list_chat_channels, - chat_channel_commands::create_chat_channel, - chat_channel_commands::update_chat_channel, - chat_channel_commands::delete_chat_channel, - chat_channel_commands::save_chat_channel_token, - chat_channel_commands::get_chat_channel_has_token, - chat_channel_commands::delete_chat_channel_token, - chat_channel_commands::connect_chat_channel, - chat_channel_commands::disconnect_chat_channel, - chat_channel_commands::test_chat_channel, - chat_channel_commands::get_chat_channel_status, - chat_channel_commands::list_chat_channel_messages, - chat_channel_commands::get_chat_command_prefix, - chat_channel_commands::set_chat_command_prefix, - chat_channel_commands::get_chat_event_filter, - chat_channel_commands::set_chat_event_filter, - chat_channel_commands::get_chat_message_language, - chat_channel_commands::set_chat_message_language, - chat_channel_commands::weixin_get_qrcode, - chat_channel_commands::weixin_check_qrcode, model_provider_commands::list_model_providers, model_provider_commands::create_model_provider, model_provider_commands::update_model_provider, diff --git a/src-tauri/src/models/chat_channel.rs b/src-tauri/src/models/chat_channel.rs deleted file mode 100644 index a73924c..0000000 --- a/src-tauri/src/models/chat_channel.rs +++ /dev/null @@ -1,67 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChatChannelInfo { - pub id: i32, - pub name: String, - pub channel_type: String, - pub enabled: bool, - pub config_json: String, - pub event_filter_json: Option, - pub daily_report_enabled: bool, - pub daily_report_time: Option, - pub created_at: String, - pub updated_at: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChannelStatusInfo { - pub channel_id: i32, - pub name: String, - pub channel_type: String, - pub status: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChatChannelMessageLogInfo { - pub id: i32, - pub channel_id: i32, - pub direction: String, - pub message_type: String, - pub content_preview: String, - pub status: String, - pub error_detail: Option, - pub created_at: String, -} - -impl From for ChatChannelInfo { - fn from(m: crate::db::entities::chat_channel::Model) -> Self { - Self { - id: m.id, - name: m.name, - channel_type: m.channel_type, - enabled: m.enabled, - config_json: m.config_json, - event_filter_json: m.event_filter_json, - daily_report_enabled: m.daily_report_enabled, - daily_report_time: m.daily_report_time, - created_at: m.created_at.to_rfc3339(), - updated_at: m.updated_at.to_rfc3339(), - } - } -} - -impl From for ChatChannelMessageLogInfo { - fn from(m: crate::db::entities::chat_channel_message_log::Model) -> Self { - Self { - id: m.id, - channel_id: m.channel_id, - direction: m.direction, - message_type: m.message_type, - content_preview: m.content_preview, - status: m.status, - error_detail: m.error_detail, - created_at: m.created_at.to_rfc3339(), - } - } -} diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 96f3078..7622a7a 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1,5 +1,4 @@ pub mod agent; -pub mod chat_channel; pub mod conversation; pub mod folder; pub mod message; @@ -9,7 +8,6 @@ pub mod system; pub use agent::AgentType; #[allow(unused_imports)] -pub use chat_channel::{ChannelStatusInfo, ChatChannelInfo, ChatChannelMessageLogInfo}; pub use conversation::{ AgentConversationCount, AgentStats, ConversationDetail, ConversationSummary, DbConversationDetail, DbConversationSummary, FolderInfo, ImportResult, SessionStats, diff --git a/src-tauri/src/web/handlers/chat_channel.rs b/src-tauri/src/web/handlers/chat_channel.rs deleted file mode 100644 index b1b92f3..0000000 --- a/src-tauri/src/web/handlers/chat_channel.rs +++ /dev/null @@ -1,274 +0,0 @@ -use std::sync::Arc; - -use axum::{extract::Extension, Json}; -use serde::Deserialize; - -use crate::app_error::AppCommandError; -use crate::app_state::AppState; -use crate::chat_channel::backends::weixin::{WeixinQrcodeInfo, WeixinQrcodeStatusPublic}; -use crate::commands::chat_channel as cc_commands; -use crate::models::chat_channel::{ChannelStatusInfo, ChatChannelInfo, ChatChannelMessageLogInfo}; - -// --------------------------------------------------------------------------- -// Param structs -// --------------------------------------------------------------------------- - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateChatChannelParams { - pub name: String, - pub channel_type: String, - pub config_json: String, - pub enabled: bool, - pub daily_report_enabled: bool, - pub daily_report_time: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdateChatChannelParams { - pub id: i32, - pub name: Option, - pub enabled: Option, - pub config_json: Option, - pub event_filter_json: Option>, - pub daily_report_enabled: Option, - pub daily_report_time: Option>, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ChannelIdParams { - pub id: i32, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SaveTokenParams { - pub channel_id: i32, - pub token: String, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ChannelIdOnlyParams { - pub channel_id: i32, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ListMessagesParams { - pub channel_id: i32, - pub limit: Option, - pub offset: Option, -} - -// --------------------------------------------------------------------------- -// Handlers -// --------------------------------------------------------------------------- - -pub async fn list_chat_channels( - Extension(state): Extension>, -) -> Result>, AppCommandError> { - let result = cc_commands::list_chat_channels_core(&state.db).await?; - Ok(Json(result)) -} - -pub async fn create_chat_channel( - Extension(state): Extension>, - Json(params): Json, -) -> Result, AppCommandError> { - let result = cc_commands::create_chat_channel_core( - &state.db, - params.name, - params.channel_type, - params.config_json, - params.enabled, - params.daily_report_enabled, - params.daily_report_time, - ) - .await?; - Ok(Json(result)) -} - -pub async fn update_chat_channel( - Extension(state): Extension>, - Json(params): Json, -) -> Result, AppCommandError> { - let result = cc_commands::update_chat_channel_core( - &state.db, - params.id, - params.name, - params.enabled, - params.config_json, - params.event_filter_json, - params.daily_report_enabled, - params.daily_report_time, - ) - .await?; - Ok(Json(result)) -} - -pub async fn delete_chat_channel( - Extension(state): Extension>, - Json(params): Json, -) -> Result, AppCommandError> { - cc_commands::delete_chat_channel_core(&state.db, &state.chat_channel_manager, params.id) - .await?; - Ok(Json(())) -} - -pub async fn save_chat_channel_token( - Json(params): Json, -) -> Result, AppCommandError> { - cc_commands::save_chat_channel_token_core(params.channel_id, ¶ms.token)?; - Ok(Json(())) -} - -pub async fn get_chat_channel_has_token( - Json(params): Json, -) -> Result, AppCommandError> { - let has = cc_commands::get_chat_channel_has_token_core(params.channel_id)?; - Ok(Json(has)) -} - -pub async fn delete_chat_channel_token( - Json(params): Json, -) -> Result, AppCommandError> { - cc_commands::delete_chat_channel_token_core(params.channel_id)?; - Ok(Json(())) -} - -pub async fn connect_chat_channel( - Extension(state): Extension>, - Json(params): Json, -) -> Result, AppCommandError> { - cc_commands::connect_chat_channel_core(&state.db, &state.chat_channel_manager, params.id) - .await?; - Ok(Json(())) -} - -pub async fn disconnect_chat_channel( - Extension(state): Extension>, - Json(params): Json, -) -> Result, AppCommandError> { - cc_commands::disconnect_chat_channel_core(&state.chat_channel_manager, params.id).await?; - Ok(Json(())) -} - -pub async fn test_chat_channel( - Extension(state): Extension>, - Json(params): Json, -) -> Result, AppCommandError> { - cc_commands::test_chat_channel_core(&state.db, params.id).await?; - Ok(Json(())) -} - -pub async fn get_chat_channel_status( - Extension(state): Extension>, -) -> Result>, AppCommandError> { - let result = cc_commands::get_chat_channel_status_core(&state.chat_channel_manager).await?; - Ok(Json(result)) -} - -pub async fn list_chat_channel_messages( - Extension(state): Extension>, - Json(params): Json, -) -> Result>, AppCommandError> { - let result = cc_commands::list_chat_channel_messages_core( - &state.db, - params.channel_id, - params.limit, - params.offset, - ) - .await?; - Ok(Json(result)) -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SetCommandPrefixParams { - pub prefix: String, -} - -pub async fn get_chat_command_prefix( - Extension(state): Extension>, -) -> Result, AppCommandError> { - let result = cc_commands::get_chat_command_prefix_core(&state.db).await?; - Ok(Json(result)) -} - -pub async fn set_chat_command_prefix( - Extension(state): Extension>, - Json(params): Json, -) -> Result, AppCommandError> { - cc_commands::set_chat_command_prefix_core(&state.db, params.prefix).await?; - Ok(Json(())) -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SetEventFilterParams { - pub filter: Option>, -} - -pub async fn get_chat_event_filter( - Extension(state): Extension>, -) -> Result>>, AppCommandError> { - let result = cc_commands::get_chat_event_filter_core(&state.db).await?; - Ok(Json(result)) -} - -pub async fn set_chat_event_filter( - Extension(state): Extension>, - Json(params): Json, -) -> Result, AppCommandError> { - cc_commands::set_chat_event_filter_core(&state.db, params.filter).await?; - Ok(Json(())) -} - -pub async fn get_chat_message_language( - Extension(state): Extension>, -) -> Result, AppCommandError> { - let result = cc_commands::get_chat_message_language_core(&state.db).await?; - Ok(Json(result)) -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SetMessageLanguageParams { - pub language: String, -} - -pub async fn set_chat_message_language( - Extension(state): Extension>, - Json(params): Json, -) -> Result, AppCommandError> { - cc_commands::set_chat_message_language_core(&state.db, params.language).await?; - Ok(Json(())) -} - -// --------------------------------------------------------------------------- -// WeChat QR code auth -// --------------------------------------------------------------------------- - -pub async fn weixin_get_qrcode() -> Result, AppCommandError> { - let result = cc_commands::weixin_get_qrcode_core().await?; - Ok(Json(result)) -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct WeixinCheckQrcodeParams { - pub channel_id: i32, - pub qrcode: String, -} - -pub async fn weixin_check_qrcode( - Extension(state): Extension>, - Json(params): Json, -) -> Result, AppCommandError> { - let result = - cc_commands::weixin_check_qrcode_core(&state.db, params.channel_id, ¶ms.qrcode).await?; - Ok(Json(result)) -} diff --git a/src-tauri/src/web/handlers/mod.rs b/src-tauri/src/web/handlers/mod.rs index 172cb0a..fcc92fa 100644 --- a/src-tauri/src/web/handlers/mod.rs +++ b/src-tauri/src/web/handlers/mod.rs @@ -1,5 +1,4 @@ pub mod acp; -pub mod chat_channel; pub mod conversations; mod error; pub mod experts; @@ -9,7 +8,6 @@ pub mod folders; pub mod git; pub mod mcp; pub mod model_provider; -pub mod project_boot; pub mod quick_messages; pub mod system_settings; pub mod terminal; diff --git a/src-tauri/src/web/handlers/project_boot.rs b/src-tauri/src/web/handlers/project_boot.rs deleted file mode 100644 index b7d4a85..0000000 --- a/src-tauri/src/web/handlers/project_boot.rs +++ /dev/null @@ -1,49 +0,0 @@ -use axum::Json; -use serde::Deserialize; - -use crate::app_error::AppCommandError; -use crate::commands::project_boot as pb_commands; - -// --------------------------------------------------------------------------- -// Param structs -// --------------------------------------------------------------------------- - -#[derive(Deserialize)] -pub struct DetectPackageManagerParams { - pub name: String, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateShadcnProjectParams { - pub project_name: String, - pub template: String, - pub preset_code: String, - pub package_manager: String, - pub target_dir: String, -} - -// --------------------------------------------------------------------------- -// Handlers -// --------------------------------------------------------------------------- - -pub async fn detect_package_manager( - Json(params): Json, -) -> Json { - let info = pb_commands::detect_package_manager(params.name).await; - Json(info) -} - -pub async fn create_shadcn_project( - Json(params): Json, -) -> Result, AppCommandError> { - let result = pb_commands::create_shadcn_project( - params.project_name, - params.template, - params.preset_code, - params.package_manager, - params.target_dir, - ) - .await?; - Ok(Json(result)) -} diff --git a/src-tauri/src/web/mod.rs b/src-tauri/src/web/mod.rs index 0d725ff..4d90c53 100644 --- a/src-tauri/src/web/mod.rs +++ b/src-tauri/src/web/mod.rs @@ -458,7 +458,6 @@ pub async fn start_web_server( emitter: crate::web::event_bridge::EventEmitter::Tauri(app.clone()), data_dir: app.path().app_data_dir().unwrap_or_default(), web_server_state: WebServerState::new(), // placeholder; not used by handlers - chat_channel_manager: crate::app_state::default_chat_channel_manager(), }); let router = router::build_router(app_state, token.clone(), static_dir); diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index 804c8ba..bc849ba 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -150,22 +150,6 @@ pub fn build_router(state: Arc, token: String, static_dir: std::path:: "/open_settings_window", post(handlers::folders::open_settings_window), ) - .route( - "/open_commit_window", - post(handlers::folders::open_commit_window), - ) - .route( - "/open_merge_window", - post(handlers::folders::open_merge_window), - ) - .route( - "/open_stash_window", - post(handlers::folders::open_stash_window), - ) - .route( - "/open_push_window", - post(handlers::folders::open_push_window), - ) // ─── Git (pure) ─── .route("/git_status", post(handlers::git::git_status)) .route("/git_init", post(handlers::git::git_init)) @@ -507,15 +491,6 @@ pub fn build_router(state: Arc, token: String, static_dir: std::path:: "/experts_open_central_dir", post(handlers::experts::experts_open_central_dir), ) - // ─── Project boot ─── - .route( - "/detect_package_manager", - post(handlers::project_boot::detect_package_manager), - ) - .route( - "/create_shadcn_project", - post(handlers::project_boot::create_shadcn_project), - ) // ─── Web Server ─── .route( "/get_web_server_status", @@ -533,87 +508,6 @@ pub fn build_router(state: Arc, token: String, static_dir: std::path:: "/check_app_update", post(handlers::web_server::check_app_update), ) - // ─── Chat Channels ─── - .route( - "/list_chat_channels", - post(handlers::chat_channel::list_chat_channels), - ) - .route( - "/create_chat_channel", - post(handlers::chat_channel::create_chat_channel), - ) - .route( - "/update_chat_channel", - post(handlers::chat_channel::update_chat_channel), - ) - .route( - "/delete_chat_channel", - post(handlers::chat_channel::delete_chat_channel), - ) - .route( - "/save_chat_channel_token", - post(handlers::chat_channel::save_chat_channel_token), - ) - .route( - "/get_chat_channel_has_token", - post(handlers::chat_channel::get_chat_channel_has_token), - ) - .route( - "/delete_chat_channel_token", - post(handlers::chat_channel::delete_chat_channel_token), - ) - .route( - "/connect_chat_channel", - post(handlers::chat_channel::connect_chat_channel), - ) - .route( - "/disconnect_chat_channel", - post(handlers::chat_channel::disconnect_chat_channel), - ) - .route( - "/test_chat_channel", - post(handlers::chat_channel::test_chat_channel), - ) - .route( - "/get_chat_channel_status", - post(handlers::chat_channel::get_chat_channel_status), - ) - .route( - "/list_chat_channel_messages", - post(handlers::chat_channel::list_chat_channel_messages), - ) - .route( - "/get_chat_command_prefix", - post(handlers::chat_channel::get_chat_command_prefix), - ) - .route( - "/set_chat_command_prefix", - post(handlers::chat_channel::set_chat_command_prefix), - ) - .route( - "/get_chat_event_filter", - post(handlers::chat_channel::get_chat_event_filter), - ) - .route( - "/set_chat_event_filter", - post(handlers::chat_channel::set_chat_event_filter), - ) - .route( - "/get_chat_message_language", - post(handlers::chat_channel::get_chat_message_language), - ) - .route( - "/set_chat_message_language", - post(handlers::chat_channel::set_chat_message_language), - ) - .route( - "/weixin_get_qrcode", - post(handlers::chat_channel::weixin_get_qrcode), - ) - .route( - "/weixin_check_qrcode", - post(handlers::chat_channel::weixin_check_qrcode), - ) // ─── Model Providers ─── .route( "/list_model_providers", diff --git a/src/app/commit/page.tsx b/src/app/commit/page.tsx deleted file mode 100644 index ec80782..0000000 --- a/src/app/commit/page.tsx +++ /dev/null @@ -1,140 +0,0 @@ -"use client" - -import { Suspense, useCallback, useEffect, useState } from "react" -import { useSearchParams } from "next/navigation" -import { useTranslations } from "next-intl" -const getCurrentWindow = async () => { - const m = await import("@tauri-apps/api/window") - return m.getCurrentWindow() -} -import { Loader2 } from "lucide-react" -import { CommitWorkspace } from "@/components/layout/commit-dialog" -import { AppTitleBar } from "@/components/layout/app-title-bar" -import { AppToaster } from "@/components/ui/app-toaster" -import { getFolder } from "@/lib/api" -import { toErrorMessage } from "@/lib/app-error" -import type { FolderDetail } from "@/lib/types" -import { GitCredentialProvider } from "@/contexts/git-credential-context" - -const TOAST_DURATION_MS = 6000 - -interface FolderLoadState { - loadedId: number | null - folder: FolderDetail | null - error: string | null -} - -function CommitPageInner() { - const t = useTranslations("CommitPage") - const searchParams = useSearchParams() - const [state, setState] = useState({ - loadedId: null, - folder: null, - error: null, - }) - - const folderId = Number(searchParams.get("folderId") ?? "0") - const normalizedFolderId = Number.isFinite(folderId) ? folderId : 0 - const hasValidFolderId = normalizedFolderId > 0 - const loading = hasValidFolderId && state.loadedId !== normalizedFolderId - const folder = state.loadedId === normalizedFolderId ? state.folder : null - const error = state.loadedId === normalizedFolderId ? state.error : null - - const closeWindow = useCallback(async () => { - try { - const win = await getCurrentWindow() - await win.close() - } catch (err) { - console.error("[CommitPage] failed to close window:", err) - } - }, []) - - useEffect(() => { - if (!hasValidFolderId) return - - let cancelled = false - - getFolder(normalizedFolderId) - .then((detail) => { - if (!cancelled) { - setState({ - loadedId: normalizedFolderId, - folder: detail, - error: null, - }) - } - }) - .catch((err) => { - if (!cancelled) { - setState({ - loadedId: normalizedFolderId, - folder: null, - error: toErrorMessage(err), - }) - } - }) - - return () => { - cancelled = true - } - }, [hasValidFolderId, normalizedFolderId]) - - const pageTitle = folder ? `${t("title")} · ${folder.name}` : t("title") - - useEffect(() => { - document.title = `${pageTitle} - codeg` - }, [pageTitle]) - - return ( - -
- - {t("title")} - {hasValidFolderId && folder ? ` · ${folder.name}` : ""} -
- } - /> - -
- {!hasValidFolderId ? ( -
- {t("invalidFolderId")} -
- ) : loading ? ( -
- - {t("loadingRepo")} -
- ) : error ? ( -
- {error} -
- ) : folder ? ( - - ) : null} -
- - - -
- ) -} - -export default function CommitPage() { - return ( - - - - ) -} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx deleted file mode 100644 index 4d7d913..0000000 --- a/src/app/login/page.tsx +++ /dev/null @@ -1,93 +0,0 @@ -"use client" - -import { useEffect, useState } from "react" -import { useRouter } from "next/navigation" -import { isDesktop } from "@/lib/platform" - -export default function LoginPage() { - const router = useRouter() - const [token, setToken] = useState("") - const [error, setError] = useState("") - const [loading, setLoading] = useState(false) - - useEffect(() => { - document.title = "Login - codeg" - }, []) - - // Desktop users skip login entirely - if (isDesktop()) { - router.replace("/workspace") - return null - } - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault() - setError("") - setLoading(true) - - try { - // Validate token by calling a lightweight API endpoint - const res = await fetch("/api/health", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: "{}", - }) - - if (res.ok) { - localStorage.setItem("codeg_token", token) - router.replace("/workspace") - } else if (res.status === 401) { - setError("Token 无效,请检查后重试") - } else { - setError(`连接失败 (HTTP ${res.status})`) - } - } catch { - setError("无法连接到服务器") - } finally { - setLoading(false) - } - } - - return ( -
-
-
-

Codeg

-

- 输入访问 Token 以连接到桌面端 -

-
- -
-
- setToken(e.target.value)} - placeholder="Access Token" - autoFocus - className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base md:text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" - /> -
- - {error &&

{error}

} - - -
- -

- Token 可在桌面端 设置 → Web 服务 中获取 -

-
-
- ) -} diff --git a/src/app/merge/page.tsx b/src/app/merge/page.tsx deleted file mode 100644 index 0827fed..0000000 --- a/src/app/merge/page.tsx +++ /dev/null @@ -1,140 +0,0 @@ -"use client" - -import { Suspense, useCallback, useEffect, useState } from "react" -import { useSearchParams } from "next/navigation" -import { useTranslations } from "next-intl" -const getCurrentWindow = async () => { - const m = await import("@tauri-apps/api/window") - return m.getCurrentWindow() -} -import { Loader2 } from "lucide-react" -import { MergeWorkspace } from "@/components/merge/merge-workspace" -import { AppTitleBar } from "@/components/layout/app-title-bar" -import { AppToaster } from "@/components/ui/app-toaster" -import { getFolder } from "@/lib/api" -import type { FolderDetail } from "@/lib/types" - -const TOAST_DURATION_MS = 6000 - -interface FolderLoadState { - loadedId: number | null - folder: FolderDetail | null - error: string | null -} - -function MergePageInner() { - const t = useTranslations("MergePage") - const searchParams = useSearchParams() - const [state, setState] = useState({ - loadedId: null, - folder: null, - error: null, - }) - - const folderId = Number(searchParams.get("folderId") ?? "0") - const operation = searchParams.get("operation") ?? "merge" - const upstreamCommit = searchParams.get("upstreamCommit") ?? undefined - const normalizedFolderId = Number.isFinite(folderId) ? folderId : 0 - const hasValidFolderId = normalizedFolderId > 0 - const loading = hasValidFolderId && state.loadedId !== normalizedFolderId - const folder = state.loadedId === normalizedFolderId ? state.folder : null - const error = state.loadedId === normalizedFolderId ? state.error : null - - const closeWindow = useCallback(async () => { - try { - const win = await getCurrentWindow() - await win.close() - } catch (err) { - console.error("[MergePage] failed to close window:", err) - } - }, []) - - useEffect(() => { - if (!hasValidFolderId) return - - let cancelled = false - - getFolder(normalizedFolderId) - .then((detail) => { - if (!cancelled) { - setState({ - loadedId: normalizedFolderId, - folder: detail, - error: null, - }) - } - }) - .catch((err) => { - if (!cancelled) { - setState({ - loadedId: normalizedFolderId, - folder: null, - error: String(err), - }) - } - }) - - return () => { - cancelled = true - } - }, [hasValidFolderId, normalizedFolderId]) - - const pageTitle = folder ? `${t("title")} · ${folder.name}` : t("title") - - useEffect(() => { - document.title = `${pageTitle} - codeg` - }, [pageTitle]) - - return ( -
- - {t("title")} - {hasValidFolderId && folder ? ` · ${folder.name}` : ""} -
- } - /> - -
- {!hasValidFolderId ? ( -
- {t("invalidFolderId")} -
- ) : loading ? ( -
- - {t("loadingRepo")} -
- ) : error ? ( -
- {error} -
- ) : folder ? ( - - ) : null} -
- - - - ) -} - -export default function MergePage() { - return ( - - - - ) -} diff --git a/src/app/project-boot/page.tsx b/src/app/project-boot/page.tsx deleted file mode 100644 index 1777ffa..0000000 --- a/src/app/project-boot/page.tsx +++ /dev/null @@ -1,41 +0,0 @@ -"use client" - -import { Suspense, useEffect } from "react" -import { useTranslations } from "next-intl" -import { AppTitleBar } from "@/components/layout/app-title-bar" -import { AppToaster } from "@/components/ui/app-toaster" -import { ProjectBootWorkspace } from "@/components/project-boot/project-boot-workspace" - -function ProjectBootPageInner() { - const t = useTranslations("ProjectBoot") - - useEffect(() => { - document.title = `${t("title")} - codeg` - }, [t]) - - return ( -
- - {t("title")} -
- } - /> - -
- -
- - - - ) -} - -export default function ProjectBootPage() { - return ( - - - - ) -} diff --git a/src/app/push/layout.tsx b/src/app/push/layout.tsx deleted file mode 100644 index 080185f..0000000 --- a/src/app/push/layout.tsx +++ /dev/null @@ -1,8 +0,0 @@ -"use client" - -import type { ReactNode } from "react" -import { GitCredentialProvider } from "@/contexts/git-credential-context" - -export default function PushLayout({ children }: { children: ReactNode }) { - return {children} -} diff --git a/src/app/push/page.tsx b/src/app/push/page.tsx deleted file mode 100644 index 72ecee1..0000000 --- a/src/app/push/page.tsx +++ /dev/null @@ -1,135 +0,0 @@ -"use client" - -import { Suspense, useCallback, useEffect, useState } from "react" -import { useSearchParams } from "next/navigation" -import { useTranslations } from "next-intl" -const getCurrentWindow = async () => { - const m = await import("@tauri-apps/api/window") - return m.getCurrentWindow() -} -import { Loader2 } from "lucide-react" -import { PushWorkspace } from "@/components/layout/push-workspace" -import { AppTitleBar } from "@/components/layout/app-title-bar" -import { AppToaster } from "@/components/ui/app-toaster" -import { getFolder } from "@/lib/api" -import type { FolderDetail } from "@/lib/types" - -const TOAST_DURATION_MS = 6000 - -interface FolderLoadState { - loadedId: number | null - folder: FolderDetail | null - error: string | null -} - -function PushPageInner() { - const t = useTranslations("Folder.pushWindow") - const searchParams = useSearchParams() - const [state, setState] = useState({ - loadedId: null, - folder: null, - error: null, - }) - - const closeWindow = useCallback(async () => { - try { - const win = await getCurrentWindow() - await win.close() - } catch (err) { - console.error("[PushPage] failed to close window:", err) - } - }, []) - - const folderId = Number(searchParams.get("folderId") ?? "0") - const normalizedFolderId = Number.isFinite(folderId) ? folderId : 0 - const hasValidFolderId = normalizedFolderId > 0 - const loading = hasValidFolderId && state.loadedId !== normalizedFolderId - const folder = state.loadedId === normalizedFolderId ? state.folder : null - const error = state.loadedId === normalizedFolderId ? state.error : null - - useEffect(() => { - if (!hasValidFolderId) return - - let cancelled = false - - getFolder(normalizedFolderId) - .then((detail) => { - if (!cancelled) { - setState({ - loadedId: normalizedFolderId, - folder: detail, - error: null, - }) - } - }) - .catch((err) => { - if (!cancelled) { - setState({ - loadedId: normalizedFolderId, - folder: null, - error: String(err), - }) - } - }) - - return () => { - cancelled = true - } - }, [hasValidFolderId, normalizedFolderId]) - - const pageTitle = folder ? `${t("title")} · ${folder.name}` : t("title") - - useEffect(() => { - document.title = `${pageTitle} - codeg` - }, [pageTitle]) - - return ( -
- - {t("title")} - {hasValidFolderId && folder ? ` · ${folder.name}` : ""} -
- } - /> - -
- {!hasValidFolderId ? ( -
- Invalid folder ID -
- ) : loading ? ( -
- -
- ) : error ? ( -
- {error} -
- ) : folder ? ( - - ) : null} -
- - - - ) -} - -export default function PushPage() { - return ( - - - - ) -} diff --git a/src/app/settings/chat-channels/page.tsx b/src/app/settings/chat-channels/page.tsx deleted file mode 100644 index e57dae0..0000000 --- a/src/app/settings/chat-channels/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { ChatChannelSettings } from "@/components/settings/chat-channel-settings" - -export default function SettingsChatChannelsPage() { - return -} diff --git a/src/app/stash/page.tsx b/src/app/stash/page.tsx deleted file mode 100644 index acbf714..0000000 --- a/src/app/stash/page.tsx +++ /dev/null @@ -1,117 +0,0 @@ -"use client" - -import { Suspense, useEffect, useState } from "react" -import { useSearchParams } from "next/navigation" -import { useTranslations } from "next-intl" -import { Loader2 } from "lucide-react" -import { StashWorkspace } from "@/components/layout/unstash-dialog" -import { AppTitleBar } from "@/components/layout/app-title-bar" -import { AppToaster } from "@/components/ui/app-toaster" -import { getFolder } from "@/lib/api" -import type { FolderDetail } from "@/lib/types" - -const TOAST_DURATION_MS = 6000 - -interface FolderLoadState { - loadedId: number | null - folder: FolderDetail | null - error: string | null -} - -function StashPageInner() { - const t = useTranslations("Folder.branchDropdown.unstashDialog") - const searchParams = useSearchParams() - const [state, setState] = useState({ - loadedId: null, - folder: null, - error: null, - }) - - const folderId = Number(searchParams.get("folderId") ?? "0") - const normalizedFolderId = Number.isFinite(folderId) ? folderId : 0 - const hasValidFolderId = normalizedFolderId > 0 - const loading = hasValidFolderId && state.loadedId !== normalizedFolderId - const folder = state.loadedId === normalizedFolderId ? state.folder : null - const error = state.loadedId === normalizedFolderId ? state.error : null - - useEffect(() => { - if (!hasValidFolderId) return - - let cancelled = false - - getFolder(normalizedFolderId) - .then((detail) => { - if (!cancelled) { - setState({ - loadedId: normalizedFolderId, - folder: detail, - error: null, - }) - } - }) - .catch((err) => { - if (!cancelled) { - setState({ - loadedId: normalizedFolderId, - folder: null, - error: String(err), - }) - } - }) - - return () => { - cancelled = true - } - }, [hasValidFolderId, normalizedFolderId]) - - const pageTitle = folder ? `${t("title")} · ${folder.name}` : t("title") - - useEffect(() => { - document.title = `${pageTitle} - codeg` - }, [pageTitle]) - - return ( -
- - {t("title")} - {hasValidFolderId && folder ? ` · ${folder.name}` : ""} -
- } - /> - -
- {!hasValidFolderId ? ( -
- Invalid folder ID -
- ) : loading ? ( -
- -
- ) : error ? ( -
- {error} -
- ) : folder ? ( - - ) : null} -
- - - - ) -} - -export default function StashPage() { - return ( - - - - ) -} diff --git a/src/components/conversations/sidebar-conversation-list.tsx b/src/components/conversations/sidebar-conversation-list.tsx index 4620bd6..e30983e 100644 --- a/src/components/conversations/sidebar-conversation-list.tsx +++ b/src/components/conversations/sidebar-conversation-list.tsx @@ -24,7 +24,6 @@ import { Loader2, Palette, Plus, - Rocket, XCircle, } from "lucide-react" import { useActiveFolder } from "@/contexts/active-folder-context" @@ -34,7 +33,6 @@ import { useTaskContext } from "@/contexts/task-context" import { useZoomLevel } from "@/hooks/use-appearance" import { importLocalConversations, - openProjectBootWindow, updateConversationTitle, updateConversationStatus, updateFolderColor, @@ -964,14 +962,6 @@ export function SidebarConversationList({ [openFolder] ) - const handleProjectBoot = useCallback(() => { - openProjectBootWindow().catch((err) => { - console.error( - "[SidebarConversationList] failed to open project boot:", - err - ) - }) - }, []) const showEmptyWorkspaceActions = folders.length === 0 && conversations.length === 0 @@ -1016,15 +1006,6 @@ export function SidebarConversationList({ {tFolderDropdown("cloneRepository")} - ) : ( @@ -1122,10 +1103,6 @@ export function SidebarConversationList({ {tFolderDropdown("cloneRepository")} - - - {tFolderDropdown("projectBoot")} - )} diff --git a/src/components/layout/aux-panel-file-tree-tab.tsx b/src/components/layout/aux-panel-file-tree-tab.tsx index 69eeb96..c63c5a3 100644 --- a/src/components/layout/aux-panel-file-tree-tab.tsx +++ b/src/components/layout/aux-panel-file-tree-tab.tsx @@ -32,7 +32,6 @@ import { gitStatus, readFileForEdit, readFilePreview, - openCommitWindow, renameFileTreeEntry, saveFileCopy, } from "@/lib/api" @@ -444,7 +443,6 @@ interface RenderNodeProps { onOpenFilePreview: (path: string) => void onOpenFileDiff: (path: string) => void onOpenDirDiff: (path: string) => void - onOpenCommitWindow: () => void onRequestCompareWithBranch: (target: FileActionTarget) => void onRequestRollback: (target: FileActionTarget) => void onOpenDirInTerminal: (dirPath: string, fileName: string) => Promise @@ -470,7 +468,6 @@ function RenderNode({ onOpenFilePreview, onOpenFileDiff, onOpenDirDiff, - onOpenCommitWindow, onRequestCompareWithBranch, onRequestRollback, onOpenDirInTerminal, @@ -563,12 +560,6 @@ function RenderNode({ {t("git")} - onOpenCommitWindow()} - disabled={isGitMenuDisabled} - > - {t("actions.commitCode")} - onRequestAddToVcs(node)} disabled={ @@ -690,7 +681,6 @@ function RenderNode({ onOpenFilePreview={onOpenFilePreview} onOpenFileDiff={onOpenFileDiff} onOpenDirDiff={onOpenDirDiff} - onOpenCommitWindow={onOpenCommitWindow} onRequestCompareWithBranch={onRequestCompareWithBranch} onRequestRollback={onRequestRollback} onOpenDirInTerminal={onOpenDirInTerminal} @@ -729,12 +719,6 @@ function RenderNode({ {t("git")} - onOpenCommitWindow()} - disabled={isGitMenuDisabled} - > - {t("actions.commitCode")} - onRequestAddToVcs(node)} disabled={isGitMenuDisabled} @@ -1342,16 +1326,6 @@ export function FileTreeTab() { [createTerminalInDirectory, t] ) - const handleOpenCommitWindow = useCallback(() => { - if (!folder) return - openCommitWindow(folder.id).catch((error) => { - const message = error instanceof Error ? error.message : String(error) - toast.error(t("toasts.openCommitWindowFailed"), { - description: message, - }) - }) - }, [folder, t]) - const handleRequestCreate = useCallback( (parentPath: string, kind: "file" | "dir") => { setCreateParentPath(parentPath) @@ -2182,7 +2156,6 @@ export function FileTreeTab() { mode: "overview", }) }} - onOpenCommitWindow={handleOpenCommitWindow} onRequestCompareWithBranch={ handleRequestCompareWithBranch } @@ -2218,12 +2191,6 @@ export function FileTreeTab() { {t("git")} - handleOpenCommitWindow()} - disabled={!gitEnabled} - > - {t("actions.commitCode")} - void handleAddToVcs(rootTarget)} disabled={!gitEnabled} diff --git a/src/components/layout/aux-panel-git-changes-tab.tsx b/src/components/layout/aux-panel-git-changes-tab.tsx index 9c00f60..c4c64b7 100644 --- a/src/components/layout/aux-panel-git-changes-tab.tsx +++ b/src/components/layout/aux-panel-git-changes-tab.tsx @@ -45,7 +45,6 @@ import { gitAddFiles, gitRollbackFile, gitStatus, - openCommitWindow, } from "@/lib/api" import { joinFsPath } from "@/lib/path-utils" import { emitAttachFileToSession } from "@/lib/session-attachment-events" @@ -561,15 +560,6 @@ export function GitChangesTab() { setExpandedUntrackedPaths(new Set()) }, [allUntrackedDirectoryPaths, untrackedCanExpand]) - const handleOpenCommitWindow = useCallback(() => { - if (!folder) return - openCommitWindow(folder.id).catch((error) => { - const message = error instanceof Error ? error.message : String(error) - toast.error(t("toasts.openCommitWindowFailed"), { - description: message, - }) - }) - }, [folder, t]) const handleAttachToSession = useCallback( (relativePath: string) => { if (!activeSessionTabId || !folder?.path) return @@ -845,13 +835,6 @@ export function GitChangesTab() { - { - handleOpenCommitWindow() - }} - > - {t("actions.commitCode")} - { void openWorkingTreeDiff(node.path, { mode: "overview" }) @@ -928,13 +911,6 @@ export function GitChangesTab() { - { - handleOpenCommitWindow() - }} - > - {t("actions.commitCode")} - { @@ -983,7 +959,6 @@ export function GitChangesTab() { [ canAttachToSession, handleAttachToSession, - handleOpenCommitWindow, handleRequestDelete, handleRequestRollback, openFilePreview, @@ -1017,13 +992,6 @@ export function GitChangesTab() { - { - handleOpenCommitWindow() - }} - > - {t("actions.commitCode")} - { void openWorkingTreeDiff(node.path, { mode: "overview" }) @@ -1096,13 +1064,6 @@ export function GitChangesTab() { - { - handleOpenCommitWindow() - }} - > - {t("actions.commitCode")} - { void openFilePreview(file.path) @@ -1155,7 +1116,6 @@ export function GitChangesTab() { [ canAttachToSession, handleAttachToSession, - handleOpenCommitWindow, handleAddToVcs, handleRequestDelete, handleRequestRollback, @@ -1265,13 +1225,6 @@ export function GitChangesTab() { - { - handleOpenCommitWindow() - }} - > - {t("actions.commitCode")} - { void openWorkingTreeDiff(".", { @@ -1374,13 +1327,6 @@ export function GitChangesTab() { - { - handleOpenCommitWindow() - }} - > - {t("actions.commitCode")} - { void openWorkingTreeDiff(".", { diff --git a/src/components/layout/aux-panel-git-log-tab.tsx b/src/components/layout/aux-panel-git-log-tab.tsx index ffdb270..d768df7 100644 --- a/src/components/layout/aux-panel-git-log-tab.tsx +++ b/src/components/layout/aux-panel-git-log-tab.tsx @@ -21,7 +21,6 @@ import { GitCompare, RefreshCw, RotateCcw, - Upload, } from "lucide-react" import { Commit, @@ -88,7 +87,6 @@ import { gitLog, gitNewBranch, gitReset, - openPushWindow, } from "@/lib/api" import type { GitBranchList, @@ -1385,20 +1383,6 @@ export function GitLogTab() { {tCommon("refresh")} - { - if (!folder) return - openPushWindow(folder.id).catch((err) => { - const msg = toErrorMessage(err) - toast.error(t("toasts.openPushWindowFailed"), { - description: msg, - }) - }) - }} - > - - {tCommon("push")} - ) @@ -1415,20 +1399,6 @@ export function GitLogTab() { {tCommon("refresh")} - { - if (!folder) return - openPushWindow(folder.id).catch((err) => { - const msg = toErrorMessage(err) - toast.error(t("toasts.openPushWindowFailed"), { - description: msg, - }) - }) - }} - > - - {tCommon("push")} - diff --git a/src/components/layout/branch-dropdown.tsx b/src/components/layout/branch-dropdown.tsx index 749c8d5..6329ff1 100644 --- a/src/components/layout/branch-dropdown.tsx +++ b/src/components/layout/branch-dropdown.tsx @@ -2,8 +2,6 @@ import { useState, useRef, useCallback, useMemo, useEffect } from "react" import { - ArchiveRestore, - Archive, ArrowDownToLine, ChevronDown, ChevronRight, @@ -11,7 +9,6 @@ import { FolderOpen, GitBranch, GitBranchPlus, - GitCommitHorizontal, GitFork, GitMerge, GitPullRequestArrow, @@ -19,7 +16,6 @@ import { Loader2, RefreshCw, Trash2, - Upload, } from "lucide-react" import { useTranslations } from "next-intl" import { toast } from "sonner" @@ -73,16 +69,11 @@ import { gitRebase, gitDeleteBranch, gitDeleteRemoteBranch, - openCommitWindow, - openPushWindow, - openStashWindow, } from "@/lib/api" import { openFileDialog, subscribe } from "@/lib/platform" import { RemoteManageDialog } from "@/components/layout/remote-manage-dialog" -import { ConflictDialog } from "@/components/layout/conflict-dialog" -import { StashDialog } from "@/components/layout/stash-dialog" import { toErrorMessage } from "@/lib/app-error" -import type { GitBranchList, GitConflictInfo } from "@/lib/types" +import type { GitBranchList } from "@/lib/types" import { useActiveFolder } from "@/contexts/active-folder-context" import { useAppWorkspace } from "@/contexts/app-workspace-context" import { useTaskContext } from "@/contexts/task-context" @@ -146,8 +137,6 @@ export function BranchDropdown() { const [worktreeBranchName, setWorktreeBranchName] = useState("") const [worktreePath, setWorktreePath] = useState("") const [manageRemotesOpen, setManageRemotesOpen] = useState(false) - const [stashDialogOpen, setStashDialogOpen] = useState(false) - const [conflictInfo, setConflictInfo] = useState(null) const taskSeq = useRef(0) const worktreeBranchSet = useMemo( @@ -361,7 +350,6 @@ export function BranchDropdown() { () => gitMerge(folderPath, branchName), (result) => { if (result.conflict?.has_conflicts) { - setConflictInfo(result.conflict) return false } if (result.merged_commits === 0) { @@ -377,7 +365,6 @@ export function BranchDropdown() { () => gitRebase(folderPath, branchName), (result) => { if (result.conflict?.has_conflicts) { - setConflictInfo(result.conflict) return false } return undefined @@ -618,7 +605,6 @@ export function BranchDropdown() { }), (result) => { if (result.conflict?.has_conflicts) { - setConflictInfo(result.conflict) return false } if (result.updated_files === 0) { @@ -649,41 +635,6 @@ export function BranchDropdown() { - - { - if (!folderId) return - setDropdownOpen(false) - openCommitWindow(folderId).catch((err) => { - const title = t("toasts.openCommitWindowFailed") - const msg = toErrorMessage(err) - pushAlert("error", title, msg) - toast.error(title, { description: msg }) - }) - }} - > - - {t("openCommitWindow")} - - { - if (!folderId) return - setDropdownOpen(false) - openPushWindow(folderId).catch((err) => { - const title = t("toasts.openPushWindowFailed") - const msg = toErrorMessage(err) - pushAlert("error", title, msg) - toast.error(title, { description: msg }) - }) - }} - > - - {t("pushCode")} - - - - - { - setDropdownOpen(false) - setStashDialogOpen(true) - }} - > - - {t("stashChanges")} - - { - if (!folderId) return - openStashWindow(folderId).catch((err) => { - const msg = toErrorMessage(err) - pushAlert("error", t("stashPop"), msg) - }) - }} - > - - {t("stashPop")} - - - loadAllBranches()} /> - - setConflictInfo(null)} - onResolved={refresh} - /> - - setStashDialogOpen(false)} - onStashed={refresh} - /> ) } diff --git a/src/components/layout/commit-dialog.tsx b/src/components/layout/commit-dialog.tsx deleted file mode 100644 index b621e9e..0000000 --- a/src/components/layout/commit-dialog.tsx +++ /dev/null @@ -1,1319 +0,0 @@ -"use client" - -import { useCallback, useEffect, useMemo, useRef, useState } from "react" -import { Check, ChevronDown, ChevronRight, Loader2, Upload } from "lucide-react" -import { useTranslations } from "next-intl" -import { Button } from "@/components/ui/button" -import { ScrollArea } from "@/components/ui/scroll-area" -import { Textarea } from "@/components/ui/textarea" -import { - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, -} from "@/components/ui/resizable" -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "@/components/ui/context-menu" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog" -import { - FileTree, - FileTreeFile, - FileTreeFolder, -} from "@/components/ai-elements/file-tree" -import { - gitAddFiles, - gitCommit, - gitPush, - gitRollbackFile, - gitShowFile, - gitStatus, - deleteFileTreeEntry, - readFilePreview, -} from "@/lib/api" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { - useGitCredential, - type GitRemoteHint, -} from "@/contexts/git-credential-context" -import type { GitStatusEntry } from "@/lib/types" -import { cn } from "@/lib/utils" -import { toast } from "sonner" -import { DiffViewer } from "@/components/diff/diff-viewer" -import { languageFromPath } from "@/lib/language-detect" -import { toErrorMessage } from "@/lib/app-error" - -interface CommitWorkspaceProps { - folderPath: string - folderId?: number | null - onCommitted?: () => void - onCancel?: () => void -} - -interface TreeFileNode { - kind: "file" - name: string - path: string - entry: GitStatusEntry -} - -interface TreeDirNode { - kind: "dir" - name: string - path: string - children: TreeNode[] -} - -type TreeNode = TreeFileNode | TreeDirNode - -const UNTRACKED_STATUS = "??" -const DEFAULT_LEFT_PANE_WIDTH = 420 -const MIN_LEFT_PANE_WIDTH = 320 -const MIN_RIGHT_PANE_WIDTH = 360 - -function clamp(value: number, min: number, max: number): number { - return Math.max(min, Math.min(max, value)) -} - -function toPercent(pixels: number, totalPixels: number): number { - if (totalPixels <= 0) return 0 - return (pixels / totalPixels) * 100 -} - -function buildFileTree(entries: GitStatusEntry[]): TreeNode[] { - type BuildDir = { - name: string - path: string - dirs: Map - files: TreeFileNode[] - } - - const root: BuildDir = { - name: "", - path: "", - dirs: new Map(), - files: [], - } - - for (const entry of entries) { - const parts = entry.file.split("/").filter(Boolean) - if (parts.length === 0) continue - - let current = root - let currentPath = "" - - for (let i = 0; i < parts.length; i += 1) { - const part = parts[i] - const isLeaf = i === parts.length - 1 - currentPath = currentPath ? `${currentPath}/${part}` : part - - if (isLeaf) { - current.files.push({ - kind: "file", - name: part, - path: currentPath, - entry, - }) - } else { - const found = current.dirs.get(part) - if (found) { - current = found - } else { - const next: BuildDir = { - name: part, - path: currentPath, - dirs: new Map(), - files: [], - } - current.dirs.set(part, next) - current = next - } - } - } - } - - function sortNodes(nodes: TreeNode[]) { - return nodes.sort((a, b) => { - if (a.kind !== b.kind) return a.kind === "dir" ? -1 : 1 - return a.name.localeCompare(b.name) - }) - } - - function toNodes(dir: BuildDir): TreeNode[] { - const dirs: TreeNode[] = Array.from(dir.dirs.values()).map((child) => ({ - kind: "dir", - name: child.name, - path: child.path, - children: toNodes(child), - })) - - return sortNodes([...dirs, ...dir.files]) - } - - return toNodes(root) -} - -/** Collect all file paths under a tree node (recursive). */ -function collectFilePaths(node: TreeNode): string[] { - if (node.kind === "file") return [node.path] - return node.children.flatMap(collectFilePaths) -} - -/** Depth-first traversal to find the first file node (matches visual order). */ -function findFirstFile(nodes: TreeNode[]): string | undefined { - for (const node of nodes) { - if (node.kind === "file") return node.path - const found = findFirstFile(node.children) - if (found) return found - } - return undefined -} - -function collectDirPaths(entries: GitStatusEntry[]) { - const paths = new Set() - - for (const entry of entries) { - const parts = entry.file.split("/").filter(Boolean) - if (parts.length < 2) continue - - let currentPath = "" - for (let i = 0; i < parts.length - 1; i += 1) { - currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i] - paths.add(currentPath) - } - } - - return paths -} - -interface ConfirmState { - open: boolean - title: string - description: string - action: (() => void) | null - variant: "default" | "destructive" -} - -const CONFIRM_INITIAL: ConfirmState = { - open: false, - title: "", - description: "", - action: null, - variant: "default", -} - -export function CommitWorkspace({ - folderPath, - folderId, - onCommitted, - onCancel, -}: CommitWorkspaceProps) { - const t = useTranslations("Folder.commitDialog") - const tCommon = useTranslations("Folder.common") - const { withCredentialRetry } = useGitCredential() - const [entries, setEntries] = useState([]) - const containerRef = useRef(null) - const [containerWidth, setContainerWidth] = useState(0) - const [selected, setSelected] = useState>(new Set()) - const [diffOriginal, setDiffOriginal] = useState("") - const [diffModified, setDiffModified] = useState("") - const [diffLanguage, setDiffLanguage] = useState("plaintext") - const [diffFile, setDiffFile] = useState(null) - const messageRef = useRef("") - const [hasMessage, setHasMessage] = useState(false) - const [messageInputKey, setMessageInputKey] = useState(0) - const [loadingStatus, setLoadingStatus] = useState(false) - const [loadingDiff, setLoadingDiff] = useState(false) - const [committing, setCommitting] = useState(false) - const [error, setError] = useState(null) - const [untrackedOpen, setUntrackedOpen] = useState(false) - const [expandedTrackedDirs, setExpandedTrackedDirs] = useState>( - new Set() - ) - const [expandedUntrackedDirs, setExpandedUntrackedDirs] = useState< - Set - >(new Set()) - const [confirm, setConfirm] = useState(CONFIRM_INITIAL) - - // Use refs to track mutable values without causing callback recreation - const diffFileRef = useRef(diffFile) - diffFileRef.current = diffFile - const entriesRef = useRef(entries) - entriesRef.current = entries - - const folderName = useMemo(() => { - const parts = folderPath.replace(/[/\\]+$/, "").split(/[/\\]/) - return parts[parts.length - 1] || folderPath - }, [folderPath]) - - const trackedEntries = useMemo( - () => entries.filter((entry) => entry.status !== UNTRACKED_STATUS), - [entries] - ) - const untrackedEntries = useMemo( - () => entries.filter((entry) => entry.status === UNTRACKED_STATUS), - [entries] - ) - const trackedTree = useMemo( - () => buildFileTree(trackedEntries), - [trackedEntries] - ) - const untrackedTree = useMemo( - () => buildFileTree(untrackedEntries), - [untrackedEntries] - ) - const filePathSet = useMemo( - () => new Set(entries.map((entry) => entry.file)), - [entries] - ) - const trackedFiles = useMemo( - () => trackedEntries.map((entry) => entry.file), - [trackedEntries] - ) - const untrackedFiles = useMemo( - () => untrackedEntries.map((entry) => entry.file), - [untrackedEntries] - ) - - // Shared diff loading logic — extracted to avoid duplication - const loadDiff = useCallback( - async (file: string, allEntries?: GitStatusEntry[]) => { - if (!folderPath) return - setDiffFile(file) - setDiffLanguage(languageFromPath(file)) - setLoadingDiff(true) - setDiffOriginal("") - setDiffModified("") - - try { - const statusSource = allEntries ?? entriesRef.current - const isUntracked = - statusSource.find((e) => e.file === file)?.status === UNTRACKED_STATUS - - const [originalContent, modifiedContent] = await Promise.all([ - isUntracked - ? Promise.resolve("") - : gitShowFile(folderPath, file).catch(() => ""), - readFilePreview(folderPath, file) - .then((r) => r.content) - .catch(() => ""), - ]) - - setDiffOriginal(originalContent) - setDiffModified(modifiedContent) - } catch { - setDiffOriginal("") - setDiffModified("") - } finally { - setLoadingDiff(false) - } - }, - [folderPath] - ) - - const loadStatus = useCallback(async () => { - if (!folderPath) return - setLoadingStatus(true) - setError(null) - try { - const result = await gitStatus(folderPath, true) - setEntries(result) - const tracked = result.filter( - (entry) => entry.status !== UNTRACKED_STATUS - ) - const untracked = result.filter( - (entry) => entry.status === UNTRACKED_STATUS - ) - setSelected(new Set(tracked.map((entry) => entry.file))) - const trackedDirs = collectDirPaths(tracked) - trackedDirs.add(folderName) - setExpandedTrackedDirs(trackedDirs) - const untrackedDirs = collectDirPaths(untracked) - untrackedDirs.add(folderName) - setExpandedUntrackedDirs(untrackedDirs) - - // Auto-select the first file in visual tree order for diff preview - const firstFile = - findFirstFile(buildFileTree(tracked)) ?? - findFirstFile(buildFileTree(untracked)) - if (firstFile) { - await loadDiff(firstFile, result) - } - } catch (err) { - setError(toErrorMessage(err)) - setEntries([]) - setExpandedTrackedDirs(new Set()) - setExpandedUntrackedDirs(new Set()) - } finally { - setLoadingStatus(false) - } - }, [folderPath, folderName, loadDiff]) - - useEffect(() => { - if (!folderPath) return - setDiffOriginal("") - setDiffModified("") - setDiffLanguage("plaintext") - setDiffFile(null) - messageRef.current = "" - setHasMessage(false) - setMessageInputKey((key) => key + 1) - setUntrackedOpen(false) - void loadStatus() - }, [folderPath, loadStatus]) - - const handleViewDiff = useCallback( - (file: string) => { - if (!folderPath || diffFileRef.current === file) return - void loadDiff(file) - }, - [folderPath, loadDiff] - ) - - const toggleFile = useCallback((file: string) => { - setSelected((prev) => { - const next = new Set(prev) - if (next.has(file)) { - next.delete(file) - } else { - next.add(file) - } - return next - }) - }, []) - - const toggleAll = useCallback(() => { - setSelected((prev) => { - if (prev.size === entries.length) { - return new Set() - } - return new Set(entries.map((entry) => entry.file)) - }) - }, [entries]) - - const toggleGroup = useCallback((files: string[]) => { - setSelected((prev) => { - const next = new Set(prev) - const allInGroupSelected = files.every((file) => next.has(file)) - if (allInGroupSelected) { - files.forEach((file) => next.delete(file)) - } else { - files.forEach((file) => next.add(file)) - } - return next - }) - }, []) - - const handleSelectPath = useCallback( - (path: string) => { - if (!filePathSet.has(path)) return - handleViewDiff(path) - }, - [filePathSet, handleViewDiff] - ) - - const handleCommit = useCallback( - async (andPush?: boolean) => { - const commitMessage = messageRef.current.trim() - if (!commitMessage || selected.size === 0 || !folderPath) return - setCommitting(true) - setError(null) - try { - const result = await gitCommit( - folderPath, - commitMessage, - Array.from(selected), - folderId - ) - toast.success(t("toasts.commitCompleted"), { - description: t("toasts.committedFiles", { - count: result.committed_files, - }), - }) - - if (andPush) { - try { - const hint: GitRemoteHint = { folderPath } - await withCredentialRetry( - (creds) => gitPush(folderPath, null, creds, folderId), - hint - ) - } catch (pushErr) { - toast.error(t("toasts.pushFailed"), { - description: toErrorMessage(pushErr), - }) - return - } - } - - onCommitted?.() - } catch (err) { - setError(toErrorMessage(err)) - } finally { - setCommitting(false) - } - }, - [folderId, folderPath, onCommitted, selected, t, withCredentialRetry] - ) - - // --- Context menu actions --- - - const handleAddToVcs = useCallback( - async (file: string) => { - if (!folderPath) return - try { - await gitAddFiles(folderPath, [file]) - toast.success(t("toasts.addedToVcs"), { description: file }) - void loadStatus() - } catch (err) { - toast.error(t("toasts.addToVcsFailed"), { - description: toErrorMessage(err), - }) - } - }, - [folderPath, loadStatus, t] - ) - - const handleDeleteFile = useCallback( - (file: string) => { - setConfirm({ - open: true, - title: t("confirm.deleteTitle"), - description: t("confirm.deleteDescription", { file }), - variant: "destructive", - action: () => { - void (async () => { - if (!folderPath) return - try { - await deleteFileTreeEntry(folderPath, file) - toast.success(t("toasts.fileDeleted"), { description: file }) - // If deleted file was being viewed, clear the diff - if (diffFileRef.current === file) { - setDiffFile(null) - setDiffOriginal("") - setDiffModified("") - } - setSelected((prev) => { - if (!prev.has(file)) return prev - const next = new Set(prev) - next.delete(file) - return next - }) - void loadStatus() - } catch (err) { - toast.error(t("toasts.deleteFailed"), { - description: toErrorMessage(err), - }) - } - })() - }, - }) - }, - [folderPath, loadStatus, t] - ) - - const handleRollbackFile = useCallback( - (file: string) => { - setConfirm({ - open: true, - title: t("confirm.rollbackTitle"), - description: t("confirm.rollbackDescription", { file }), - variant: "destructive", - action: () => { - void (async () => { - if (!folderPath) return - try { - await gitRollbackFile(folderPath, file) - toast.success(t("toasts.fileRolledBack"), { description: file }) - if (diffFileRef.current === file) { - setDiffFile(null) - setDiffOriginal("") - setDiffModified("") - } - setSelected((prev) => { - if (!prev.has(file)) return prev - const next = new Set(prev) - next.delete(file) - return next - }) - void loadStatus() - } catch (err) { - toast.error(t("toasts.rollbackFailed"), { - description: toErrorMessage(err), - }) - } - })() - }, - }) - }, - [folderPath, loadStatus, t] - ) - - const handleRollbackDir = useCallback( - (dirPath: string, files: string[], displayName?: string) => { - const label = displayName ?? dirPath - setConfirm({ - open: true, - title: t("confirm.rollbackTitle"), - description: t("confirm.rollbackDirDescription", { dir: label }), - variant: "destructive", - action: () => { - void (async () => { - if (!folderPath) return - try { - await gitRollbackFile(folderPath, dirPath) - toast.success(t("toasts.dirRolledBack"), { - description: label, - }) - if (diffFileRef.current && files.includes(diffFileRef.current)) { - setDiffFile(null) - setDiffOriginal("") - setDiffModified("") - } - setSelected((prev) => { - const next = new Set(prev) - files.forEach((f) => next.delete(f)) - return next - }) - void loadStatus() - } catch (err) { - toast.error(t("toasts.rollbackFailed"), { - description: toErrorMessage(err), - }) - } - })() - }, - }) - }, - [folderPath, loadStatus, t] - ) - - const handleDeleteDir = useCallback( - (dirPath: string, files: string[], displayName?: string) => { - const label = displayName ?? dirPath - setConfirm({ - open: true, - title: t("confirm.deleteTitle"), - description: t("confirm.deleteDirDescription", { dir: label }), - variant: "destructive", - action: () => { - void (async () => { - if (!folderPath) return - try { - await deleteFileTreeEntry(folderPath, dirPath) - toast.success(t("toasts.dirDeleted"), { - description: label, - }) - if (diffFileRef.current && files.includes(diffFileRef.current)) { - setDiffFile(null) - setDiffOriginal("") - setDiffModified("") - } - setSelected((prev) => { - const next = new Set(prev) - files.forEach((f) => next.delete(f)) - return next - }) - void loadStatus() - } catch (err) { - toast.error(t("toasts.deleteFailed"), { - description: toErrorMessage(err), - }) - } - })() - }, - }) - }, - [folderPath, loadStatus, t] - ) - - const handleAddDirToVcs = useCallback( - async (dirPath: string, files: string[], displayName?: string) => { - if (!folderPath) return - const label = displayName ?? dirPath - try { - await gitAddFiles(folderPath, files) - toast.success(t("toasts.addedToVcs"), { description: label }) - void loadStatus() - } catch (err) { - toast.error(t("toasts.addToVcsFailed"), { - description: toErrorMessage(err), - }) - } - }, - [folderPath, loadStatus, t] - ) - - const closeConfirm = useCallback(() => { - setConfirm(CONFIRM_INITIAL) - }, []) - - const confirmActionRef = useRef(confirm.action) - confirmActionRef.current = confirm.action - - const executeConfirmAction = useCallback(() => { - confirmActionRef.current?.() - setConfirm(CONFIRM_INITIAL) - }, []) - - const allSelected = useMemo( - () => entries.length > 0 && selected.size === entries.length, - [entries.length, selected.size] - ) - const trackedAllSelected = useMemo( - () => - trackedFiles.length > 0 && - trackedFiles.every((file) => selected.has(file)), - [trackedFiles, selected] - ) - const untrackedAllSelected = useMemo( - () => - untrackedFiles.length > 0 && - untrackedFiles.every((file) => selected.has(file)), - [untrackedFiles, selected] - ) - - const handleMessageChange = useCallback( - (e: React.ChangeEvent) => { - const nextValue = e.target.value - messageRef.current = nextValue - const nextHasMessage = nextValue.trim().length > 0 - setHasMessage((prev) => (prev === nextHasMessage ? prev : nextHasMessage)) - }, - [] - ) - - useEffect(() => { - const container = containerRef.current - if (!container) return - - const updateWidth = (next: number) => { - setContainerWidth((prev) => (Math.abs(prev - next) < 1 ? prev : next)) - } - - updateWidth(container.clientWidth) - const observer = new ResizeObserver((entries) => { - updateWidth(entries[0]?.contentRect.width ?? container.clientWidth) - }) - - observer.observe(container) - return () => { - observer.disconnect() - } - }, []) - - const safeContainerWidth = - containerWidth > 0 - ? containerWidth - : DEFAULT_LEFT_PANE_WIDTH + MIN_RIGHT_PANE_WIDTH + 240 - const leftMinSize = clamp( - toPercent(MIN_LEFT_PANE_WIDTH, safeContainerWidth), - 5, - 95 - ) - const rightMinSize = clamp( - toPercent(MIN_RIGHT_PANE_WIDTH, safeContainerWidth), - 5, - 95 - ) - const leftMaxSize = Math.max(leftMinSize, 100 - rightMinSize) - const leftDefaultSize = clamp( - toPercent(DEFAULT_LEFT_PANE_WIDTH, safeContainerWidth), - leftMinSize, - leftMaxSize - ) - - // --- Render helpers for file tree nodes --- - - const renderTrackedNode = useCallback( - function renderNode(node: TreeNode): React.ReactNode { - if (node.kind === "dir") { - const dirFiles = collectFilePaths(node) - const hasNonDeleted = node.children.some( - (child) => - child.kind === "file" && - child.entry.status !== " D" && - child.entry.status !== "D" - ) - return ( - - - - {node.children.map(renderNode)} - - - - {hasNonDeleted && ( - handleRollbackDir(node.path, dirFiles)} - > - {t("actions.rollback")} - - )} - handleDeleteDir(node.path, dirFiles)} - > - {tCommon("delete")} - - - - ) - } - - const isDeleted = node.entry.status === " D" || node.entry.status === "D" - - return ( - - - - - - - {node.entry.status} - - - - - {!isDeleted && ( - handleRollbackFile(node.path)}> - {t("actions.rollback")} - - )} - handleDeleteFile(node.path)} - > - {tCommon("delete")} - - - - ) - }, - [ - selected, - toggleFile, - handleViewDiff, - handleRollbackFile, - handleRollbackDir, - handleDeleteFile, - handleDeleteDir, - t, - tCommon, - ] - ) - - const renderUntrackedNode = useCallback( - function renderNode(node: TreeNode): React.ReactNode { - if (node.kind === "dir") { - const dirFiles = collectFilePaths(node) - return ( - - - - {node.children.map(renderNode)} - - - - { - void handleAddDirToVcs(node.path, dirFiles) - }} - > - {t("actions.addToVcs")} - - - handleDeleteDir(node.path, dirFiles)} - > - {tCommon("delete")} - - - - ) - } - - return ( - - - - - - - - - handleAddToVcs(node.path)}> - {t("actions.addToVcs")} - - - handleDeleteFile(node.path)} - > - {tCommon("delete")} - - - - ) - }, - [ - selected, - toggleFile, - handleViewDiff, - handleAddToVcs, - handleAddDirToVcs, - handleDeleteFile, - handleDeleteDir, - t, - tCommon, - ] - ) - - const toggleTrackedGroup = useCallback( - () => toggleGroup(trackedFiles), - [toggleGroup, trackedFiles] - ) - const toggleUntrackedGroup = useCallback( - () => toggleGroup(untrackedFiles), - [toggleGroup, untrackedFiles] - ) - const toggleUntrackedOpen = useCallback( - () => setUntrackedOpen((open) => !open), - [] - ) - - return ( -
- - -
- {error && ( -
- {error} -
- )} - -
- - - {loadingStatus - ? t("loading") - : t("selectionCount", { - selected: selected.size, - total: entries.length, - })} - -
- -
- - {entries.length === 0 && !loadingStatus ? ( -
- {t("emptyFiles")} -
- ) : ( -
- {trackedEntries.length > 0 && ( -
-
- - - {t("trackedChanges", { - count: trackedEntries.length, - })} - -
- - - - - {trackedTree.map(renderTrackedNode)} - - - - - handleRollbackDir( - ".", - trackedFiles, - folderName - ) - } - > - {t("actions.rollback")} - - - handleDeleteDir(".", trackedFiles, folderName) - } - > - {tCommon("delete")} - - - - -
- )} - - {untrackedEntries.length > 0 && ( -
-
- - -
- {untrackedOpen && ( - - - - - {untrackedTree.map(renderUntrackedNode)} - - - - { - void handleAddDirToVcs( - ".", - untrackedFiles, - folderName - ) - }} - > - {t("actions.addToVcs")} - - - - handleDeleteDir( - ".", - untrackedFiles, - folderName - ) - } - > - {tCommon("delete")} - - - - - )} -
- )} -
- )} -
-
- -
-
- {t("commitMessage")} -
-