From 7ea2936436abe74f64bc12876f2ce90af76be446 Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Mon, 19 Oct 2026 12:12:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20=E7=94=A8=20MCP=20=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E6=9B=BF=E6=8D=A2=E5=86=85=E7=BD=AE=20AI=20Agent=20(#?= =?UTF-8?q?319)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在现有 HTTP 服务挂载 /mcp,让外部 AI 经共享 API Key 调用原分辨率截图、批量键鼠、ATX/WOL 和虚拟介质八个工具。所有调用共用有界 FIFO 和全局限流,复用现有采集管线与硬件控制器。 移除内置模型调用、网页 Agent 面板及旧配置,设置页提供可持续查看的 Key、统一的地址/密钥复制样式。补齐 DEB 的 libudev1 运行依赖。 验证:583 项 Rust 库测试、前端生产构建、ARMv7 设备视觉操作与介质/WOL 实测,以及默认 Release 构建和 DEB 包校验通过。 Refs #319 --- Cargo.lock | 174 ++- Cargo.toml | 5 +- build/package-deb.sh | 2 +- src/atx/mod.rs | 2 +- src/atx/wol.rs | 33 + src/computer_use/actions.rs | 218 ---- src/computer_use/manager.rs | 983 --------------- src/computer_use/mod.rs | 6 - src/computer_use/openai.rs | 1218 ------------------- src/config/schema/computer_use.rs | 47 - src/config/schema/mcp.rs | 43 + src/config/schema/mod.rs | 7 +- src/config/store.rs | 62 +- src/hid/mod.rs | 97 +- src/lib.rs | 4 +- src/mcp/actions.rs | 332 +++++ src/mcp/executor.rs | 430 +++++++ src/mcp/hardware.rs | 328 +++++ src/mcp/keyboard.rs | 185 +++ src/mcp/mod.rs | 395 ++++++ src/mcp/tools.rs | 246 ++++ src/runtime/builder.rs | 3 - src/state.rs | 28 +- src/video/mod.rs | 2 + src/video/pipeline/dmabuf.rs | 16 + src/video/pipeline/mod.rs | 1 + src/video/pipeline/shared.rs | 21 +- src/video/snapshot.rs | 271 +++++ src/video/stream_manager.rs | 16 + src/video/streamer.rs | 5 + src/web/handlers/atx_api.rs | 20 +- src/web/handlers/computer_use.rs | 64 - src/web/handlers/config/mod.rs | 3 +- src/web/handlers/mcp.rs | 82 ++ src/web/handlers/mod.rs | 4 +- src/web/routes.rs | 13 +- web/src/api/index.ts | 66 +- web/src/components/ActionBar.vue | 38 +- web/src/components/ComputerUseSheet.vue | 460 ------- web/src/components/ConsoleHeaderActions.vue | 10 +- web/src/components/McpSettingsCard.vue | 93 ++ web/src/composables/useComputerUseSocket.ts | 108 -- web/src/composables/useFeatureVisibility.ts | 3 +- web/src/i18n/en-US.ts | 100 +- web/src/i18n/zh-CN.ts | 100 +- web/src/style.css | 7 - web/src/types/computerUseTimeline.ts | 16 - web/src/types/generated.ts | 68 +- web/src/views/ConsoleView.vue | 176 +-- web/src/views/SettingsView.vue | 12 +- 50 files changed, 2910 insertions(+), 3713 deletions(-) delete mode 100644 src/computer_use/actions.rs delete mode 100644 src/computer_use/manager.rs delete mode 100644 src/computer_use/mod.rs delete mode 100644 src/computer_use/openai.rs delete mode 100644 src/config/schema/computer_use.rs create mode 100644 src/config/schema/mcp.rs create mode 100644 src/mcp/actions.rs create mode 100644 src/mcp/executor.rs create mode 100644 src/mcp/hardware.rs create mode 100644 src/mcp/keyboard.rs create mode 100644 src/mcp/mod.rs create mode 100644 src/mcp/tools.rs create mode 100644 src/video/snapshot.rs delete mode 100644 src/web/handlers/computer_use.rs create mode 100644 src/web/handlers/mcp.rs delete mode 100644 web/src/components/ComputerUseSheet.vue create mode 100644 web/src/components/McpSettingsCard.vue delete mode 100644 web/src/composables/useComputerUseSocket.ts delete mode 100644 web/src/types/computerUseTimeline.ts diff --git a/Cargo.lock b/Cargo.lock index 238a9644..80826144 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -719,6 +719,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] @@ -1063,7 +1064,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a707ceda8652f6c7624f2be725652e9524c815bf3b9d55a0b2320be2303f9c11" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "syn 2.0.119", @@ -1076,8 +1077,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core 0.24.1", + "darling_macro 0.24.1", ] [[package]] @@ -1094,17 +1105,41 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.6", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", "quote", "syn 2.0.119", ] +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core 0.24.1", + "quote", + "syn 3.0.6", +] + [[package]] name = "dary_heap" version = "0.3.9" @@ -1298,6 +1333,12 @@ dependencies = [ "x509-parser 0.16.0", ] +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "ecdsa" version = "0.16.9" @@ -2070,6 +2111,8 @@ checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -2879,6 +2922,7 @@ dependencies = [ "rand 0.10.3", "rcgen 0.14.10", "reqwest", + "rmcp", "rtp", "rtsp-types", "rust-embed", @@ -3049,6 +3093,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pem" version = "3.0.6" @@ -3438,6 +3488,26 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "regex" version = "1.13.1" @@ -3574,6 +3644,51 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" +[[package]] +name = "rmcp" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab5023804cbe0304cbe06197ac16e645a0a0d0ffa2953bf735c15c2fa0213350" +dependencies = [ + "async-trait", + "base64 0.23.1", + "bytes", + "chrono", + "futures", + "http", + "http-body", + "http-body-util", + "indexmap", + "pastey", + "pin-project-lite", + "rand 0.10.3", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp-macros" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1da498700030856ba63114bceefa30ba21f5e278cb975e5b9de3529fe842b40" +dependencies = [ + "darling 0.24.1", + "proc-macro2", + "quote", + "serde_json", + "syn 3.0.6", +] + [[package]] name = "rtcp" version = "0.17.2" @@ -3738,6 +3853,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.6", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -3839,6 +3980,17 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "serde_json" version = "1.0.151" @@ -4166,6 +4318,19 @@ dependencies = [ "url", ] +[[package]] +name = "sse-stream" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4563,6 +4728,7 @@ dependencies = [ "futures-util", "http", "http-body", + "http-body-util", "pin-project-lite", "tower", "tower-layer", diff --git a/Cargo.toml b/Cargo.toml index 27d03a16..629815c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ desktop = [ "dep:tokio", "dep:tokio-util", "dep:axum", + "dep:rmcp", "dep:axum-extra", "dep:tower-http", "dep:sqlx", @@ -82,7 +83,9 @@ tokio-util = { version = "0.7", features = ["rt"], optional = true } # Web framework axum = { version = "0.8", features = ["ws", "multipart", "tokio"], optional = true } axum-extra = { version = "0.12", features = ["cookie"], optional = true } -tower-http = { version = "0.6", features = ["cors", "trace", "set-header"], optional = true } +tower-http = { version = "0.6", features = ["cors", "trace", "set-header", "limit"], optional = true } + +rmcp = { version = "3.5.1", default-features = false, features = ["server", "macros", "schemars", "transport-streamable-http-server"], optional = true } # Database - Use bundled SQLite for static linking sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "sqlite-bundled"], optional = true } diff --git a/build/package-deb.sh b/build/package-deb.sh index 1515ed9e..eb56fed4 100755 --- a/build/package-deb.sh +++ b/build/package-deb.sh @@ -125,7 +125,7 @@ EOF chmod 755 "$PKG_DIR/DEBIAN/prerm" # Create control file - BASE_DEPS="libc6 (>= 2.36), libgcc-s1, libstdc++6, libasound2t64 (>= 1.1) | libasound2 (>= 1.1), libdrm2 (>= 2.4)" + BASE_DEPS="libc6 (>= 2.36), libgcc-s1, libstdc++6, libasound2t64 (>= 1.1) | libasound2 (>= 1.1), libdrm2 (>= 2.4), libudev1" AMD64_DEPS="libva2 (>= 2.0), libva-drm2 (>= 2.10), libva-x11-2 (>= 2.10), libmfx-gen1.2 (>= 22.0) | libmfx1 (>= 21.1), libx11-6 (>= 1.6), libxcb1 (>= 1.14), i965-va-driver-shaders (>= 2.4), intel-media-va-driver-non-free (>= 21.1)" DEPS="$BASE_DEPS" if [ "$DEB_ARCH" = "amd64" ]; then diff --git a/src/atx/mod.rs b/src/atx/mod.rs index 5ab879ae..381ba91f 100644 --- a/src/atx/mod.rs +++ b/src/atx/mod.rs @@ -27,7 +27,7 @@ pub use types::{ ActiveLevel, AtxAction, AtxDevices, AtxDriverType, AtxInputBinding, AtxKeyConfig, AtxOutputBinding, AtxPowerRequest, AtxState, HddStatus, PowerStatus, LCUS_RELAY_MAX_CHANNEL, }; -pub use wol::send_wol; +pub use wol::{normalize_wol_mac_address, send_wol}; #[cfg(any(unix, test))] fn hidraw_uevent_is_usb_relay(uevent: &str) -> bool { diff --git a/src/atx/wol.rs b/src/atx/wol.rs index 04a4366a..6bf670f4 100644 --- a/src/atx/wol.rs +++ b/src/atx/wol.rs @@ -9,6 +9,28 @@ use crate::error::{AppError, Result}; const MAGIC_PACKET_SIZE: usize = 102; +/// Validate and normalize the MAC formats accepted by the web and MCP APIs. +pub fn normalize_wol_mac_address(mac_address: &str) -> Result { + let normalized = mac_address.trim().replace('-', ":"); + let normalized = if normalized.len() == 12 && normalized.bytes().all(|b| b.is_ascii_hexdigit()) + { + normalized + .as_bytes() + .chunks(2) + .map(|b| std::str::from_utf8(b).unwrap()) + .collect::>() + .join(":") + } else { + normalized + }; + let mac = parse_mac_address(&normalized)?; + Ok(mac + .iter() + .map(|byte| format!("{byte:02X}")) + .collect::>() + .join(":")) +} + fn parse_mac_address(mac: &str) -> Result<[u8; 6]> { let mac = mac.trim().to_uppercase(); let parts: Vec<&str> = if mac.contains(':') { @@ -139,6 +161,17 @@ mod tests { assert!(parse_mac_address("AA:BB:CC:DD:EE:GG").is_err()); } + #[test] + fn normalizes_web_and_mcp_mac_formats() { + for value in [" aa-bb-cc-dd-ee-ff ", "aabbccddeeff", "AA:BB:CC:DD:EE:FF"] { + assert_eq!( + normalize_wol_mac_address(value).unwrap(), + "AA:BB:CC:DD:EE:FF" + ); + } + assert!(normalize_wol_mac_address("invalid").is_err()); + } + #[test] fn test_build_magic_packet() { let mac = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]; diff --git a/src/computer_use/actions.rs b/src/computer_use/actions.rs deleted file mode 100644 index bc570d6c..00000000 --- a/src/computer_use/actions.rs +++ /dev/null @@ -1,218 +0,0 @@ -use serde::{Deserialize, Serialize}; -use typeshare::typeshare; - -#[typeshare] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ComputerUseSessionStatus { - Idle, - WaitingScreenshot, - Thinking, - Executing, - Completed, - Failed, - Stopped, -} - -#[typeshare] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ComputerUseButton { - Left, - Middle, - Right, -} - -impl Default for ComputerUseButton { - fn default() -> Self { - Self::Left - } -} - -// Kept out of typeshare because these internally tagged enums are consumed by -// manually maintained frontend types and changing serde shape would break WS/API. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum ComputerUseAction { - Click { - x: u32, - y: u32, - #[serde(default)] - button: ComputerUseButton, - }, - DoubleClick { - x: u32, - y: u32, - #[serde(default)] - button: ComputerUseButton, - }, - Move { - x: u32, - y: u32, - }, - Drag { - path: Vec, - #[serde(default)] - button: ComputerUseButton, - }, - Scroll { - x: u32, - y: u32, - #[serde(default)] - dx: i32, - #[serde(default)] - dy: i32, - }, - Type { - text: String, - }, - Keypress { - keys: Vec, - }, - Wait { - ms: u64, - }, - Screenshot, -} - -#[typeshare] -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -pub struct ComputerUsePoint { - pub x: u32, - pub y: u32, -} - -#[typeshare] -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ComputerUseScreenshot { - pub data_url: String, - pub width: u32, - pub height: u32, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "role", rename_all = "snake_case")] -pub enum ComputerUseConversationMessage { - User { text: String }, - Assistant { text: String }, -} - -#[typeshare] -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ComputerUseStartRequest { - pub prompt: String, - #[serde(default)] - pub continue_conversation: bool, - pub client_id: String, -} - -#[typeshare] -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ComputerUseConfigResponse { - pub enabled: bool, - pub base_url: String, - pub model: String, - pub api_key_configured: bool, - pub api_key_source: String, -} - -#[typeshare] -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ComputerUseConfigUpdate { - pub enabled: Option, - pub base_url: Option, - pub model: Option, - #[serde(alias = "openai_api_key")] - pub api_key: Option, - #[serde(alias = "clear_openai_api_key")] - pub clear_api_key: Option, -} - -#[typeshare] -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ComputerUseSessionSummary { - pub id: Option, - pub status: ComputerUseSessionStatus, - pub prompt: Option, - pub step: u32, - pub last_error: Option, - pub final_message: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum ComputerUseWsClientMessage { - ScreenshotResult { - request_id: String, - screenshot: ComputerUseScreenshot, - }, - ScreenshotError { - request_id: String, - message: String, - }, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum ComputerUseWsServerMessage { - SessionUpdated { session: ComputerUseSessionSummary }, - ScreenshotRequested { request_id: String }, - ScreenshotCaptured { screenshot: ComputerUseScreenshot }, - StepStarted { step: u32 }, - ReasoningDelta { delta: String }, - ReasoningCompleted { failed: bool }, - ActionsExecuted { actions: Vec }, - Error { message: String }, -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn computer_use_action_keeps_flat_tagged_json_shape() { - let action = ComputerUseAction::Click { - x: 10, - y: 20, - button: ComputerUseButton::Left, - }; - - assert_eq!( - serde_json::to_value(action).unwrap(), - json!({ - "type": "click", - "x": 10, - "y": 20, - "button": "left" - }) - ); - } - - #[test] - fn computer_use_ws_message_keeps_flat_tagged_json_shape() { - let message = ComputerUseWsServerMessage::ScreenshotRequested { - request_id: "req-1".to_string(), - }; - - assert_eq!( - serde_json::to_value(message).unwrap(), - json!({ - "type": "screenshot_requested", - "request_id": "req-1" - }) - ); - } - - #[test] - fn config_update_accepts_legacy_api_key_names() { - let update: ComputerUseConfigUpdate = serde_json::from_value(json!({ - "openai_api_key": "legacy-key", - "clear_openai_api_key": true - })) - .unwrap(); - - assert_eq!(update.api_key.as_deref(), Some("legacy-key")); - assert_eq!(update.clear_api_key, Some(true)); - } -} diff --git a/src/computer_use/manager.rs b/src/computer_use/manager.rs deleted file mode 100644 index 5df288f8..00000000 --- a/src/computer_use/manager.rs +++ /dev/null @@ -1,983 +0,0 @@ -use std::sync::Arc; -use std::time::Duration; - -use axum::extract::ws::{Message, WebSocket}; -use futures::{SinkExt, StreamExt}; -use tokio::sync::{broadcast, oneshot, watch, Mutex}; -use tokio::task::JoinHandle; -use uuid::Uuid; - -use super::actions::*; -use super::openai::{normalize_data_url, OpenAiComputerProvider}; -use crate::config::ConfigStore; -use crate::error::{AppError, Result}; -use crate::hid::{ - CanonicalKey, HidController, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, - MouseEvent, -}; - -const SCREENSHOT_TIMEOUT: Duration = Duration::from_secs(10); -const KEY_DELAY: Duration = Duration::from_millis(35); -const ACTION_DELAY: Duration = Duration::from_millis(120); -const STOPPED_MESSAGE: &str = "Computer use task was stopped"; - -#[derive(Clone)] -pub struct ComputerUseManager { - config: ConfigStore, - hid: Arc, - state: Arc>, - event_tx: broadcast::Sender, - screenshot_tx: broadcast::Sender, -} - -struct ManagerState { - session: ComputerUseSessionSummary, - conversation: Vec, - screenshot_waiter: Option, - stop_tx: Option>, - cancel_tx: Option>, - task: Option>, -} - -struct ScreenshotWaiter { - request_id: String, - client_id: String, - tx: oneshot::Sender>, -} - -#[derive(Debug, Clone)] -struct ScreenshotRequest { - request_id: String, - client_id: String, -} - -impl ComputerUseManager { - pub fn new(config: ConfigStore, hid: Arc) -> Arc { - let (event_tx, _) = broadcast::channel(128); - let (screenshot_tx, _) = broadcast::channel(8); - Arc::new(Self { - config, - hid, - state: Arc::new(Mutex::new(ManagerState { - session: empty_session(), - conversation: Vec::new(), - screenshot_waiter: None, - stop_tx: None, - cancel_tx: None, - task: None, - })), - event_tx, - screenshot_tx, - }) - } - - pub fn config_response(&self) -> ComputerUseConfigResponse { - let config = self.config.get(); - let key_env = cua_api_key_env(); - let key_db = config - .computer_use - .api_key - .as_ref() - .filter(|key| !key.is_empty()); - ComputerUseConfigResponse { - enabled: config.computer_use.enabled, - base_url: cua_base_url_env().unwrap_or_else(|| config.computer_use.base_url.clone()), - model: config.computer_use.model.clone(), - api_key_configured: key_env.is_some() || key_db.is_some(), - api_key_source: if key_env.is_some() { - "env".to_string() - } else if key_db.is_some() { - "config".to_string() - } else { - "none".to_string() - }, - } - } - - pub async fn update_config( - &self, - req: ComputerUseConfigUpdate, - ) -> Result { - if let Some(base_url) = req - .base_url - .as_ref() - .filter(|base_url| !base_url.trim().is_empty()) - { - validate_endpoint_url(base_url)?; - } - - self.config - .update(|config| { - if let Some(enabled) = req.enabled { - config.computer_use.enabled = enabled; - } - if let Some(model) = req.model.as_ref().filter(|model| !model.trim().is_empty()) { - config.computer_use.model = model.trim().to_string(); - } - if let Some(base_url) = req - .base_url - .as_ref() - .filter(|base_url| !base_url.trim().is_empty()) - { - config.computer_use.base_url = base_url.trim().to_string(); - } - if req.clear_api_key.unwrap_or(false) { - config.computer_use.api_key = None; - } - if let Some(key) = req.api_key.as_ref() { - config.computer_use.api_key = if key.trim().is_empty() { - None - } else { - Some(key.trim().to_string()) - }; - } - }) - .await?; - - Ok(self.config_response()) - } - - pub async fn summary(&self) -> ComputerUseSessionSummary { - self.state.lock().await.session.clone() - } - - pub async fn start( - self: &Arc, - req: ComputerUseStartRequest, - ) -> Result { - let app_config = self.config.get(); - let config = app_config.computer_use.clone(); - if !config.enabled { - return Err(AppError::BadRequest("Computer use is disabled".to_string())); - } - if req.prompt.trim().is_empty() { - return Err(AppError::BadRequest("Task prompt is required".to_string())); - } - let client_id = req.client_id.trim(); - if client_id.is_empty() { - return Err(AppError::BadRequest( - "Computer use client_id is required".to_string(), - )); - } - let client_id = client_id.to_string(); - let hid = self.hid.snapshot().await; - if !hid.initialized || !hid.supports_absolute_mouse { - return Err(AppError::BadRequest( - "Computer use requires an initialized absolute mouse HID backend".to_string(), - )); - } - - let api_key = cua_api_key_env() - .or(config.api_key.clone()) - .ok_or_else(|| { - AppError::BadRequest("Computer Use API key is not configured".to_string()) - })?; - let base_url = cua_base_url_env().unwrap_or_else(|| config.base_url.clone()); - validate_endpoint_url(&base_url)?; - - let mut state = self.state.lock().await; - if matches!( - state.session.status, - ComputerUseSessionStatus::WaitingScreenshot - | ComputerUseSessionStatus::Thinking - | ComputerUseSessionStatus::Executing - ) { - return Err(AppError::BadRequest( - "A computer use session is already running".to_string(), - )); - } - - if let Some(handle) = state.task.take() { - handle.abort(); - } - if !req.continue_conversation { - state.conversation.clear(); - } - let conversation = state.conversation.clone(); - state - .conversation - .push(ComputerUseConversationMessage::User { - text: req.prompt.trim().to_string(), - }); - - let (stop_tx, stop_rx) = oneshot::channel(); - let (cancel_tx, cancel_rx) = watch::channel(false); - let session_id = Uuid::new_v4().to_string(); - state.session = ComputerUseSessionSummary { - id: Some(session_id), - status: ComputerUseSessionStatus::Thinking, - prompt: Some(req.prompt.trim().to_string()), - step: 0, - last_error: None, - final_message: None, - }; - state.stop_tx = Some(stop_tx); - state.cancel_tx = Some(cancel_tx); - let summary = state.session.clone(); - drop(state); - - self.publish_session().await; - let manager = self.clone(); - let prompt = req.prompt.trim().to_string(); - let model = config.model.clone(); - let handle = tokio::spawn(async move { - manager - .run_loop( - prompt, - api_key, - base_url, - model, - conversation, - client_id, - cancel_rx, - stop_rx, - ) - .await; - }); - - self.state.lock().await.task = Some(handle); - Ok(summary) - } - - pub async fn stop(&self) -> Result { - let mut state = self.state.lock().await; - if let Some(tx) = state.stop_tx.take() { - let _ = tx.send(()); - } - if let Some(tx) = state.cancel_tx.take() { - let _ = tx.send(true); - } - if let Some(waiter) = state.screenshot_waiter.take() { - drop(waiter.tx); - } - state.session.status = ComputerUseSessionStatus::Stopped; - drop(state); - let _ = self.hid.reset().await; - self.publish_session().await; - Ok(self.summary().await) - } - - pub async fn submit_screenshot( - &self, - client_id: &str, - request_id: String, - mut screenshot: ComputerUseScreenshot, - ) -> Result<()> { - if screenshot.width == 0 || screenshot.height == 0 { - return Err(AppError::BadRequest( - "Screenshot dimensions are invalid".to_string(), - )); - } - screenshot.data_url = normalize_data_url(&screenshot.data_url)?; - - let mut state = self.state.lock().await; - let Some(waiter) = state.screenshot_waiter.take() else { - return Ok(()); - }; - if waiter.request_id != request_id || waiter.client_id != client_id { - state.screenshot_waiter = Some(waiter); - return Ok(()); - } - let _ = waiter.tx.send(Ok(screenshot)); - Ok(()) - } - - async fn submit_screenshot_error(&self, client_id: &str, request_id: String, message: String) { - let mut state = self.state.lock().await; - let Some(waiter) = state.screenshot_waiter.take() else { - return; - }; - if waiter.request_id != request_id || waiter.client_id != client_id { - state.screenshot_waiter = Some(waiter); - return; - } - let message: String = message.chars().take(300).collect(); - let _ = waiter.tx.send(Err(AppError::ServiceUnavailable(format!( - "Screenshot capture failed: {}", - if message.trim().is_empty() { - "client did not provide an error" - } else { - message.trim() - } - )))); - } - - pub async fn handle_socket(self: Arc, socket: WebSocket, client_id: Option) { - let (mut sender, mut receiver) = socket.split(); - let mut event_rx = self.event_tx.subscribe(); - let client_id = client_id - .as_deref() - .map(str::trim) - .filter(|client_id| !client_id.is_empty()) - .map(str::to_string) - .unwrap_or_else(|| Uuid::new_v4().to_string()); - let mut screenshot_rx = self.screenshot_tx.subscribe(); - - let _ = sender - .send(Message::Text( - serde_json::to_string(&ComputerUseWsServerMessage::SessionUpdated { - session: self.summary().await, - }) - .unwrap_or_default() - .into(), - )) - .await; - - loop { - tokio::select! { - Ok(event) = event_rx.recv() => { - if let Ok(text) = serde_json::to_string(&event) { - if sender.send(Message::Text(text.into())).await.is_err() { - break; - } - } - } - Ok(req) = screenshot_rx.recv() => { - if req.client_id != client_id { - continue; - } - let event = ComputerUseWsServerMessage::ScreenshotRequested { request_id: req.request_id }; - if let Ok(text) = serde_json::to_string(&event) { - if sender.send(Message::Text(text.into())).await.is_err() { - break; - } - } - } - msg = receiver.next() => { - match msg { - Some(Ok(Message::Text(text))) => { - match serde_json::from_str::(&text) { - Ok(ComputerUseWsClientMessage::ScreenshotResult { request_id, screenshot }) => { - let _ = self.submit_screenshot(&client_id, request_id, screenshot).await; - } - Ok(ComputerUseWsClientMessage::ScreenshotError { request_id, message }) => { - self.submit_screenshot_error(&client_id, request_id, message).await; - } - Err(_) => {} - } - } - Some(Ok(Message::Close(_))) | None => break, - Some(Err(_)) => break, - _ => {} - } - } - } - } - } - - async fn run_loop( - &self, - prompt: String, - api_key: String, - base_url: String, - model: String, - conversation: Vec, - client_id: String, - cancel_rx: watch::Receiver, - mut stop_rx: oneshot::Receiver<()>, - ) { - let provider = OpenAiComputerProvider::new(api_key, base_url, model); - let mut latest_screenshot: Option = None; - let mut action_history: Vec = Vec::new(); - let mut step = 0_u32; - - loop { - step = step.saturating_add(1); - self.set_status(ComputerUseSessionStatus::Thinking, step, None) - .await; - let response = tokio::select! { - _ = &mut stop_rx => { - let _ = self.event_tx.send(ComputerUseWsServerMessage::ReasoningCompleted { - failed: true, - }); - self.set_stopped().await; - return; - } - response = provider.next_actions( - &prompt, - &conversation, - &action_history, - latest_screenshot.as_ref(), - |delta| { - let _ = self.event_tx.send(ComputerUseWsServerMessage::ReasoningDelta { - delta: delta.to_string(), - }); - }, - ) => response, - }; - - let response = match response { - Ok(response) => { - let _ = self - .event_tx - .send(ComputerUseWsServerMessage::ReasoningCompleted { failed: false }); - response - } - Err(err) => { - let _ = self - .event_tx - .send(ComputerUseWsServerMessage::ReasoningCompleted { failed: true }); - self.fail(&err.to_string()).await; - return; - } - }; - - if *cancel_rx.borrow() { - self.set_stopped().await; - return; - } - - if response.done { - self.complete(response.message).await; - return; - } - - let executable = &response.actions[..response.actions.len().saturating_sub(1)]; - action_history.push(format!( - "Step {step}: {}", - serde_json::to_string(&response.actions).unwrap_or_else(|_| "[]".to_string()) - )); - if !executable.is_empty() { - let Some(screenshot) = latest_screenshot.as_ref() else { - self.fail("Computer Use protocol error: actions require a screenshot") - .await; - return; - }; - self.set_status(ComputerUseSessionStatus::Executing, step, None) - .await; - if let Err(err) = self - .execute_actions( - executable, - screenshot.width, - screenshot.height, - cancel_rx.clone(), - ) - .await - { - if *cancel_rx.borrow() { - self.set_stopped().await; - } else { - self.fail(&err.to_string()).await; - } - return; - } - let _ = self - .event_tx - .send(ComputerUseWsServerMessage::ActionsExecuted { - actions: executable.to_vec(), - }); - } - - self.set_status(ComputerUseSessionStatus::WaitingScreenshot, step, None) - .await; - let screenshot = tokio::select! { - _ = &mut stop_rx => { - self.set_stopped().await; - return; - } - screenshot = self.request_screenshot(&client_id) => screenshot, - }; - let screenshot = match screenshot { - Ok(screenshot) => screenshot, - Err(err) => { - self.fail(&err.to_string()).await; - return; - } - }; - let _ = self - .event_tx - .send(ComputerUseWsServerMessage::ScreenshotCaptured { - screenshot: screenshot.clone(), - }); - latest_screenshot = Some(screenshot); - } - } - - async fn request_screenshot(&self, client_id: &str) -> Result { - let request_id = Uuid::new_v4().to_string(); - let (tx, rx) = oneshot::channel(); - { - let mut state = self.state.lock().await; - state.screenshot_waiter = Some(ScreenshotWaiter { - request_id: request_id.clone(), - client_id: client_id.to_string(), - tx, - }); - } - let _ = self.screenshot_tx.send(ScreenshotRequest { - request_id, - client_id: client_id.to_string(), - }); - let reply = tokio::time::timeout(SCREENSHOT_TIMEOUT, rx) - .await - .map_err(|_| { - AppError::ServiceUnavailable("Timed out waiting for screenshot".to_string()) - })? - .map_err(|_| { - AppError::ServiceUnavailable("Screenshot request was cancelled".to_string()) - })?; - reply - } - - async fn execute_actions( - &self, - actions: &[ComputerUseAction], - width: u32, - height: u32, - mut cancel_rx: watch::Receiver, - ) -> Result<()> { - for action in actions { - if *cancel_rx.borrow() { - return Err(stopped_error()); - } - match action { - ComputerUseAction::Click { x, y, button } => { - self.move_abs(*x, *y, width, height).await?; - self.mouse_button(*button, true).await?; - let click_result = sleep_or_cancel(KEY_DELAY, &mut cancel_rx).await; - self.mouse_button(*button, false).await?; - click_result?; - } - ComputerUseAction::DoubleClick { x, y, button } => { - for _ in 0..2 { - self.move_abs(*x, *y, width, height).await?; - self.mouse_button(*button, true).await?; - let click_result = sleep_or_cancel(KEY_DELAY, &mut cancel_rx).await; - self.mouse_button(*button, false).await?; - click_result?; - sleep_or_cancel(KEY_DELAY, &mut cancel_rx).await?; - } - } - ComputerUseAction::Move { x, y } => self.move_abs(*x, *y, width, height).await?, - ComputerUseAction::Drag { path, button } => { - if let Some(first) = path.first() { - self.move_abs(first.x, first.y, width, height).await?; - self.mouse_button(*button, true).await?; - let drag_result = async { - for point in path.iter().skip(1) { - sleep_or_cancel(KEY_DELAY, &mut cancel_rx).await?; - self.move_abs(point.x, point.y, width, height).await?; - } - Result::<()>::Ok(()) - } - .await; - self.mouse_button(*button, false).await?; - drag_result?; - } - } - ComputerUseAction::Scroll { x, y, dy, .. } => { - self.move_abs(*x, *y, width, height).await?; - let ticks = ((*dy).clamp(-1200, 1200) / 120).clamp(-10, 10); - let ticks = if ticks == 0 { dy.signum() } else { ticks }; - for _ in 0..ticks.abs() { - if *cancel_rx.borrow() { - return Err(stopped_error()); - } - self.hid - .send_mouse(MouseEvent::scroll(if ticks > 0 { 1 } else { -1 })) - .await?; - } - } - ComputerUseAction::Type { text } => self.type_text(text, &mut cancel_rx).await?, - ComputerUseAction::Keypress { keys } => self.keypress(keys, &mut cancel_rx).await?, - ComputerUseAction::Wait { ms } => { - sleep_or_cancel(Duration::from_millis((*ms).min(5000)), &mut cancel_rx).await? - } - ComputerUseAction::Screenshot => {} - } - sleep_or_cancel(ACTION_DELAY, &mut cancel_rx).await?; - } - Ok(()) - } - - async fn move_abs(&self, x: u32, y: u32, width: u32, height: u32) -> Result<()> { - let hid_x = ((x.min(width.saturating_sub(1)) as f64 / width.max(1) as f64) * 32767.0) - .round() as i32; - let hid_y = ((y.min(height.saturating_sub(1)) as f64 / height.max(1) as f64) * 32767.0) - .round() as i32; - self.hid - .send_mouse(MouseEvent::move_abs(hid_x, hid_y)) - .await - } - - async fn mouse_button(&self, button: ComputerUseButton, down: bool) -> Result<()> { - let button = match button { - ComputerUseButton::Left => MouseButton::Left, - ComputerUseButton::Middle => MouseButton::Middle, - ComputerUseButton::Right => MouseButton::Right, - }; - let event = if down { - MouseEvent::button_down(button) - } else { - MouseEvent::button_up(button) - }; - self.hid.send_mouse(event).await - } - - async fn type_text(&self, text: &str, cancel_rx: &mut watch::Receiver) -> Result<()> { - for ch in text.chars() { - if *cancel_rx.borrow() { - return Err(stopped_error()); - } - let (key, mods) = char_to_key(ch).ok_or_else(|| { - AppError::BadRequest(format!( - "Cannot type unsupported character {ch:?} through HID keyboard mapping" - )) - })?; - self.key_down_up(key, mods, cancel_rx).await?; - } - Ok(()) - } - - async fn keypress(&self, keys: &[String], cancel_rx: &mut watch::Receiver) -> Result<()> { - let mut mods = KeyboardModifiers::default(); - let mut key = None; - for item in keys { - match item.to_lowercase().as_str() { - "ctrl" | "control" | "controlleft" => mods.left_ctrl = true, - "shift" | "shiftleft" => mods.left_shift = true, - "alt" | "altleft" => mods.left_alt = true, - "meta" | "win" | "cmd" | "super" => mods.left_meta = true, - other => key = key_name_to_canonical(other), - } - } - if let Some(key) = key { - self.key_down_up(key, mods, cancel_rx).await?; - } - Ok(()) - } - - async fn key_down_up( - &self, - key: CanonicalKey, - mods: KeyboardModifiers, - cancel_rx: &mut watch::Receiver, - ) -> Result<()> { - self.hid - .send_keyboard(KeyboardEvent { - event_type: KeyEventType::Down, - key, - modifiers: mods, - }) - .await?; - let key_result = sleep_or_cancel(KEY_DELAY, cancel_rx).await; - self.hid - .send_keyboard(KeyboardEvent { - event_type: KeyEventType::Up, - key, - modifiers: KeyboardModifiers::default(), - }) - .await?; - key_result - } - - async fn publish_session(&self) { - let _ = self - .event_tx - .send(ComputerUseWsServerMessage::SessionUpdated { - session: self.summary().await, - }); - } - - async fn set_status(&self, status: ComputerUseSessionStatus, step: u32, error: Option) { - { - let mut state = self.state.lock().await; - state.session.status = status; - state.session.step = step; - state.session.last_error = error; - } - if matches!(status, ComputerUseSessionStatus::Thinking) { - let _ = self - .event_tx - .send(ComputerUseWsServerMessage::StepStarted { step }); - } - self.publish_session().await; - } - - async fn complete(&self, message: Option) { - { - let mut state = self.state.lock().await; - if let Some(message) = message.as_ref().filter(|message| !message.is_empty()) { - state - .conversation - .push(ComputerUseConversationMessage::Assistant { - text: message.clone(), - }); - } - state.session.status = ComputerUseSessionStatus::Completed; - state.session.final_message = message; - state.stop_tx = None; - } - self.publish_session().await; - let _ = self.hid.reset().await; - } - - async fn fail(&self, message: &str) { - { - let mut state = self.state.lock().await; - state.session.status = ComputerUseSessionStatus::Failed; - state.session.last_error = Some(message.to_string()); - state.stop_tx = None; - } - let _ = self.event_tx.send(ComputerUseWsServerMessage::Error { - message: message.to_string(), - }); - self.publish_session().await; - let _ = self.hid.reset().await; - } - - async fn set_stopped(&self) { - { - let mut state = self.state.lock().await; - state.session.status = ComputerUseSessionStatus::Stopped; - state.stop_tx = None; - } - self.publish_session().await; - let _ = self.hid.reset().await; - } -} - -async fn sleep_or_cancel(duration: Duration, cancel_rx: &mut watch::Receiver) -> Result<()> { - if *cancel_rx.borrow() { - return Err(stopped_error()); - } - tokio::select! { - _ = tokio::time::sleep(duration) => Ok(()), - changed = cancel_rx.changed() => { - match changed { - Ok(()) if *cancel_rx.borrow() => { - Err(stopped_error()) - } - Ok(()) => Ok(()), - Err(_) => Err(stopped_error()), - } - } - } -} - -fn stopped_error() -> AppError { - AppError::BadRequest(STOPPED_MESSAGE.to_string()) -} - -fn empty_session() -> ComputerUseSessionSummary { - ComputerUseSessionSummary { - id: None, - status: ComputerUseSessionStatus::Idle, - prompt: None, - step: 0, - last_error: None, - final_message: None, - } -} - -fn cua_api_key_env() -> Option { - std::env::var("ONE_KVM_CUA_API_KEY") - .ok() - .filter(|key| !key.trim().is_empty()) - .or_else(|| { - std::env::var("OPENAI_API_KEY") - .ok() - .filter(|key| !key.trim().is_empty()) - }) -} - -fn cua_base_url_env() -> Option { - std::env::var("ONE_KVM_CUA_BASE_URL") - .ok() - .filter(|url| !url.trim().is_empty()) - .or_else(|| { - std::env::var("ONE_KVM_OPENAI_BASE_URL") - .ok() - .filter(|url| !url.trim().is_empty()) - }) -} - -fn validate_endpoint_url(url: &str) -> Result<()> { - let trimmed = url.trim(); - if !(trimmed.starts_with("https://") || trimmed.starts_with("http://")) { - return Err(AppError::BadRequest( - "API URL must be a complete http(s) endpoint".to_string(), - )); - } - if trimmed.ends_with('/') { - return Err(AppError::BadRequest( - "API URL must include the full endpoint path without a trailing slash".to_string(), - )); - } - if !trimmed.contains("/responses") && !trimmed.contains("/chat/completions") { - return Err(AppError::BadRequest( - "API URL must include /responses or /chat/completions".to_string(), - )); - } - Ok(()) -} - -fn char_to_key(ch: char) -> Option<(CanonicalKey, KeyboardModifiers)> { - let mut mods = KeyboardModifiers::default(); - let key = match ch { - 'a'..='z' => key_name_to_canonical(&ch.to_string())?, - 'A'..='Z' => { - mods.left_shift = true; - key_name_to_canonical(&ch.to_ascii_lowercase().to_string())? - } - '0' => CanonicalKey::Digit0, - '1' => CanonicalKey::Digit1, - '2' => CanonicalKey::Digit2, - '3' => CanonicalKey::Digit3, - '4' => CanonicalKey::Digit4, - '5' => CanonicalKey::Digit5, - '6' => CanonicalKey::Digit6, - '7' => CanonicalKey::Digit7, - '8' => CanonicalKey::Digit8, - '9' => CanonicalKey::Digit9, - ' ' => CanonicalKey::Space, - '\n' => CanonicalKey::Enter, - '-' => CanonicalKey::Minus, - '_' => { - mods.left_shift = true; - CanonicalKey::Minus - } - '=' => CanonicalKey::Equal, - '+' => { - mods.left_shift = true; - CanonicalKey::Equal - } - '.' => CanonicalKey::Period, - ',' => CanonicalKey::Comma, - '/' => CanonicalKey::Slash, - '?' => { - mods.left_shift = true; - CanonicalKey::Slash - } - ';' => CanonicalKey::Semicolon, - ':' => { - mods.left_shift = true; - CanonicalKey::Semicolon - } - '\'' => CanonicalKey::Quote, - '"' => { - mods.left_shift = true; - CanonicalKey::Quote - } - '[' => CanonicalKey::BracketLeft, - '{' => { - mods.left_shift = true; - CanonicalKey::BracketLeft - } - ']' => CanonicalKey::BracketRight, - '}' => { - mods.left_shift = true; - CanonicalKey::BracketRight - } - '\\' => CanonicalKey::Backslash, - '|' => { - mods.left_shift = true; - CanonicalKey::Backslash - } - '`' => CanonicalKey::Backquote, - '~' => { - mods.left_shift = true; - CanonicalKey::Backquote - } - '!' => { - mods.left_shift = true; - CanonicalKey::Digit1 - } - '@' => { - mods.left_shift = true; - CanonicalKey::Digit2 - } - '#' => { - mods.left_shift = true; - CanonicalKey::Digit3 - } - '$' => { - mods.left_shift = true; - CanonicalKey::Digit4 - } - '%' => { - mods.left_shift = true; - CanonicalKey::Digit5 - } - '^' => { - mods.left_shift = true; - CanonicalKey::Digit6 - } - '&' => { - mods.left_shift = true; - CanonicalKey::Digit7 - } - '*' => { - mods.left_shift = true; - CanonicalKey::Digit8 - } - '(' => { - mods.left_shift = true; - CanonicalKey::Digit9 - } - ')' => { - mods.left_shift = true; - CanonicalKey::Digit0 - } - _ => return None, - }; - Some((key, mods)) -} - -fn key_name_to_canonical(name: &str) -> Option { - match name.trim().to_lowercase().as_str() { - "a" => Some(CanonicalKey::KeyA), - "b" => Some(CanonicalKey::KeyB), - "c" => Some(CanonicalKey::KeyC), - "d" => Some(CanonicalKey::KeyD), - "e" => Some(CanonicalKey::KeyE), - "f" => Some(CanonicalKey::KeyF), - "g" => Some(CanonicalKey::KeyG), - "h" => Some(CanonicalKey::KeyH), - "i" => Some(CanonicalKey::KeyI), - "j" => Some(CanonicalKey::KeyJ), - "k" => Some(CanonicalKey::KeyK), - "l" => Some(CanonicalKey::KeyL), - "m" => Some(CanonicalKey::KeyM), - "n" => Some(CanonicalKey::KeyN), - "o" => Some(CanonicalKey::KeyO), - "p" => Some(CanonicalKey::KeyP), - "q" => Some(CanonicalKey::KeyQ), - "r" => Some(CanonicalKey::KeyR), - "s" => Some(CanonicalKey::KeyS), - "t" => Some(CanonicalKey::KeyT), - "u" => Some(CanonicalKey::KeyU), - "v" => Some(CanonicalKey::KeyV), - "w" => Some(CanonicalKey::KeyW), - "x" => Some(CanonicalKey::KeyX), - "y" => Some(CanonicalKey::KeyY), - "z" => Some(CanonicalKey::KeyZ), - "enter" | "return" => Some(CanonicalKey::Enter), - "escape" | "esc" => Some(CanonicalKey::Escape), - "backspace" => Some(CanonicalKey::Backspace), - "tab" => Some(CanonicalKey::Tab), - "space" => Some(CanonicalKey::Space), - "delete" | "del" => Some(CanonicalKey::Delete), - "arrowup" | "up" => Some(CanonicalKey::ArrowUp), - "arrowdown" | "down" => Some(CanonicalKey::ArrowDown), - "arrowleft" | "left" => Some(CanonicalKey::ArrowLeft), - "arrowright" | "right" => Some(CanonicalKey::ArrowRight), - "home" => Some(CanonicalKey::Home), - "end" => Some(CanonicalKey::End), - "pageup" => Some(CanonicalKey::PageUp), - "pagedown" => Some(CanonicalKey::PageDown), - "f1" => Some(CanonicalKey::F1), - "f2" => Some(CanonicalKey::F2), - "f3" => Some(CanonicalKey::F3), - "f4" => Some(CanonicalKey::F4), - "f5" => Some(CanonicalKey::F5), - "f6" => Some(CanonicalKey::F6), - "f7" => Some(CanonicalKey::F7), - "f8" => Some(CanonicalKey::F8), - "f9" => Some(CanonicalKey::F9), - "f10" => Some(CanonicalKey::F10), - "f11" => Some(CanonicalKey::F11), - "f12" => Some(CanonicalKey::F12), - _ => None, - } -} diff --git a/src/computer_use/mod.rs b/src/computer_use/mod.rs deleted file mode 100644 index 3107a497..00000000 --- a/src/computer_use/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -mod actions; -mod manager; -mod openai; - -pub use actions::*; -pub use manager::*; diff --git a/src/computer_use/openai.rs b/src/computer_use/openai.rs deleted file mode 100644 index a6866c82..00000000 --- a/src/computer_use/openai.rs +++ /dev/null @@ -1,1218 +0,0 @@ -use std::time::{Duration, Instant}; - -use base64::{engine::general_purpose::STANDARD, Engine as _}; -use futures::StreamExt; -use reqwest::header::{AUTHORIZATION, CONTENT_TYPE}; -use serde_json::{json, Value}; - -use super::actions::{ - ComputerUseAction, ComputerUseButton, ComputerUseConversationMessage, ComputerUsePoint, - ComputerUseScreenshot, -}; -use crate::error::{AppError, Result}; - -const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); -const REQUEST_TIMEOUT: Duration = Duration::from_secs(300); -const REASONING_FLUSH_INTERVAL: Duration = Duration::from_millis(50); -const ERROR_SNIPPET_LIMIT: usize = 800; - -const COMPUTER_USE_SYSTEM_PROMPT: &str = r#"You control a physical remote computer through One-KVM, an IP-KVM system. -You have no DOM access, clipboard access, shell, or direct system APIs. You can only observe the remote host through screenshots and interact through these mouse and HID keyboard actions: click, double_click, move, drag, scroll, type, keypress, wait, and screenshot. click supports left, right, and middle buttons. - -Return exactly one JSON object using this shape: -{"done":boolean,"message":string|null,"actions":[{"type":"click","x":0,"y":0,"button":"left"},{"type":"double_click","x":0,"y":0,"button":"left"},{"type":"move","x":0,"y":0},{"type":"drag","path":[{"x":0,"y":0}],"button":"left"},{"type":"scroll","x":0,"y":0,"dx":0,"dy":0},{"type":"type","text":"ASCII text"},{"type":"keypress","keys":["ctrl","l"]},{"type":"wait","ms":500},{"type":"screenshot"}]} - -On the first turn there is no screenshot. Request it with done=false and actions=[{"type":"screenshot"}]. Coordinates must be based on the latest screenshot. When the interface may have changed, explicitly request a new screenshot. Every done=false action batch must contain exactly one screenshot action, and it must be last. Actions before it are executed in order, then One-KVM captures the new screenshot. When the task is complete, return done=true with no actions and put the final response in message. - -Keyboard type sends printable US-keyboard ASCII only. Never put Chinese or other non-ASCII characters in type. Do not assume the remote input method state: inspect focus and language state before typing, switch it manually when needed, and request a screenshot to confirm. To enter Chinese, switch the remote host to a Chinese input method, type pinyin as ASCII, and choose candidates from visible feedback. - -One-KVM does not add approval gates for reboot, shutdown, firmware, credentials, payments, or other sensitive operations. Follow the user's task directly; the model provider may still enforce its own policies."#; - -pub struct OpenAiComputerProvider { - client: reqwest::Client, - api_key: String, - endpoint_url: String, - model: String, -} - -#[derive(Debug)] -pub struct OpenAiComputerResponse { - pub done: bool, - pub actions: Vec, - pub message: Option, - pub reasoning: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum EndpointKind { - Responses, - ChatCompletions, -} - -impl EndpointKind { - fn label(self) -> &'static str { - match self { - Self::Responses => "responses", - Self::ChatCompletions => "chat/completions", - } - } -} - -impl OpenAiComputerProvider { - pub fn new(api_key: String, endpoint_url: String, model: String) -> Self { - let client = reqwest::Client::builder() - .connect_timeout(CONNECT_TIMEOUT) - .timeout(REQUEST_TIMEOUT) - .build() - .expect("failed to build Computer Use HTTP client"); - Self { - client, - api_key, - endpoint_url, - model, - } - } - - pub async fn next_actions( - &self, - prompt: &str, - conversation: &[ComputerUseConversationMessage], - action_history: &[String], - screenshot: Option<&ComputerUseScreenshot>, - mut on_reasoning: F, - ) -> Result - where - F: FnMut(&str), - { - let kind = endpoint_kind(&self.endpoint_url)?; - let request_text = request_context(prompt, conversation, action_history, screenshot); - let body = match kind { - EndpointKind::ChatCompletions => chat_body(&self.model, &request_text, screenshot), - EndpointKind::Responses => responses_body(&self.model, &request_text, screenshot), - }; - - let response = self - .client - .post(self.endpoint_url.trim()) - .header(AUTHORIZATION, format!("Bearer {}", self.api_key)) - .header(CONTENT_TYPE, "application/json") - .json(&body) - .send() - .await - .map_err(|err| self.request_error(kind, &err.to_string()))?; - - let status = response.status(); - let content_type = response - .headers() - .get(CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or("unknown") - .to_string(); - - if !status.is_success() { - let raw = response.bytes().await.map_err(|err| { - self.response_error( - kind, - status.as_u16(), - &content_type, - None, - &err.to_string(), - "", - ) - })?; - let text = String::from_utf8_lossy(&raw); - let provider_message = serde_json::from_slice::(&raw) - .ok() - .and_then(|value| { - value - .pointer("/error/message") - .and_then(Value::as_str) - .map(str::to_string) - }) - .unwrap_or_else(|| "provider request failed".to_string()); - return Err(self.response_error( - kind, - status.as_u16(), - &content_type, - None, - &provider_message, - &text, - )); - } - - let mut result = if content_type - .to_ascii_lowercase() - .contains("text/event-stream") - { - self.parse_stream(kind, response, &content_type, &mut on_reasoning) - .await? - } else { - let raw = response.bytes().await.map_err(|err| { - self.response_error( - kind, - status.as_u16(), - &content_type, - None, - &err.to_string(), - "", - ) - })?; - self.parse_json_response(kind, &raw, &content_type, &mut on_reasoning)? - }; - - validate_protocol(&result, screenshot.is_some()) - .map_err(|message| self.response_error(kind, 200, &content_type, None, &message, ""))?; - if result.reasoning.as_deref() == Some("") { - result.reasoning = None; - } - Ok(result) - } - - async fn parse_stream( - &self, - kind: EndpointKind, - response: reqwest::Response, - content_type: &str, - on_reasoning: &mut F, - ) -> Result - where - F: FnMut(&str), - { - let mut stream = response.bytes_stream(); - let mut decoder = SseDecoder::default(); - let mut output = String::new(); - let mut reasoning = ReasoningCollector::new(on_reasoning); - let mut saw_event = false; - let mut last_event_type: Option = None; - - while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(|err| { - reasoning.flush(); - self.response_error( - kind, - 200, - content_type, - last_event_type.as_deref(), - &format!("stream interrupted: {err}"), - "", - ) - })?; - for event in decoder.push(&chunk) { - saw_event = true; - if event.data.trim().is_empty() || event.data.trim() == "[DONE]" { - continue; - } - let parsed_event_type = serde_json::from_str::(&event.data) - .ok() - .and_then(|value| value.get("type")?.as_str().map(str::to_string)); - let event_type = event.event.clone().or(parsed_event_type); - last_event_type = event_type.clone(); - if let Err(err) = self.consume_stream_event( - kind, - &event, - event_type.as_deref(), - &mut output, - &mut reasoning, - content_type, - ) { - reasoning.flush(); - return Err(err); - } - } - } - for event in decoder.finish() { - if event.data.trim().is_empty() || event.data.trim() == "[DONE]" { - continue; - } - saw_event = true; - let parsed_event_type = serde_json::from_str::(&event.data) - .ok() - .and_then(|value| value.get("type")?.as_str().map(str::to_string)); - let event_type = event.event.clone().or(parsed_event_type); - last_event_type = event_type.clone(); - if let Err(err) = self.consume_stream_event( - kind, - &event, - event_type.as_deref(), - &mut output, - &mut reasoning, - content_type, - ) { - reasoning.flush(); - return Err(err); - } - } - reasoning.flush(); - - if !saw_event { - return Err(self.response_error( - kind, - 200, - content_type, - last_event_type.as_deref(), - "stream ended without SSE events", - "", - )); - } - let reasoning_text = reasoning.into_text(); - self.parse_protocol_output( - kind, - &output, - reasoning_text, - content_type, - last_event_type.as_deref(), - ) - } - - fn consume_stream_event( - &self, - kind: EndpointKind, - event: &SseEvent, - event_type: Option<&str>, - output: &mut String, - reasoning: &mut ReasoningCollector<'_, F>, - content_type: &str, - ) -> Result<()> - where - F: FnMut(&str), - { - let value: Value = serde_json::from_str(&event.data).map_err(|err| { - self.response_error( - kind, - 200, - content_type, - event_type, - &format!( - "SSE JSON was invalid at line {}, column {}", - err.line(), - err.column() - ), - &event.data, - ) - })?; - if let Some(message) = value.pointer("/error/message").and_then(Value::as_str) { - return Err(self.response_error( - kind, - 200, - content_type, - event_type, - message, - &event.data, - )); - } - - match kind { - EndpointKind::ChatCompletions => { - if let Some(delta) = value - .pointer("/choices/0/delta/reasoning_content") - .and_then(Value::as_str) - { - reasoning.push(delta); - } - if let Some(delta) = value - .pointer("/choices/0/delta/content") - .and_then(Value::as_str) - { - output.push_str(delta); - } - } - EndpointKind::Responses => match event_type - .or_else(|| value.get("type").and_then(Value::as_str)) - .unwrap_or_default() - { - "response.output_text.delta" => { - if let Some(delta) = value.get("delta").and_then(Value::as_str) { - output.push_str(delta); - } - } - "response.reasoning_summary_text.delta" | "response.reasoning_summary.delta" => { - if let Some(delta) = value.get("delta").and_then(Value::as_str) { - reasoning.push(delta); - } - } - "error" | "response.failed" => { - let message = value - .pointer("/response/error/message") - .or_else(|| value.pointer("/error/message")) - .and_then(Value::as_str) - .unwrap_or("Responses stream failed"); - return Err(self.response_error( - kind, - 200, - content_type, - event_type, - message, - &event.data, - )); - } - _ => {} - }, - } - Ok(()) - } - - fn parse_json_response( - &self, - kind: EndpointKind, - raw: &[u8], - content_type: &str, - on_reasoning: &mut F, - ) -> Result - where - F: FnMut(&str), - { - let value: Value = serde_json::from_slice(raw).map_err(|err| { - self.response_error( - kind, - 200, - content_type, - None, - &format!( - "response JSON was invalid at line {}, column {}", - err.line(), - err.column() - ), - &String::from_utf8_lossy(raw), - ) - })?; - let (output, reasoning) = match kind { - EndpointKind::ChatCompletions => { - let output = value - .pointer("/choices/0/message/content") - .and_then(chat_content_text) - .ok_or_else(|| { - self.response_error( - kind, - 200, - content_type, - None, - "response had no message content", - &String::from_utf8_lossy(raw), - ) - })?; - let reasoning = value - .pointer("/choices/0/message/reasoning_content") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(); - (output, reasoning) - } - EndpointKind::Responses => collect_responses_json(&value), - }; - if !reasoning.is_empty() { - on_reasoning(&reasoning); - } - self.parse_protocol_output(kind, &output, reasoning, content_type, None) - } - - fn parse_protocol_output( - &self, - kind: EndpointKind, - output: &str, - reasoning: String, - content_type: &str, - event_type: Option<&str>, - ) -> Result { - let parsed = parse_json_object_text(output).map_err(|message| { - self.response_error(kind, 200, content_type, event_type, &message, output) - })?; - let done = parsed.get("done").and_then(Value::as_bool).ok_or_else(|| { - self.response_error( - kind, - 200, - content_type, - event_type, - "action response field done was not a boolean", - output, - ) - })?; - let actions = parse_actions_array(&parsed).map_err(|message| { - self.response_error(kind, 200, content_type, event_type, &message, output) - })?; - let message = parsed - .get("message") - .and_then(Value::as_str) - .filter(|message| !message.trim().is_empty()) - .map(str::to_string); - Ok(OpenAiComputerResponse { - done, - actions, - message, - reasoning: (!reasoning.is_empty()).then_some(reasoning), - }) - } - - fn request_error(&self, kind: EndpointKind, detail: &str) -> AppError { - AppError::ServiceUnavailable(format!( - "Computer Use provider request failed: interface={}, model={}, endpoint={}, detail={}", - kind.label(), - self.model, - redact_endpoint(&self.endpoint_url), - sanitize(detail, &self.api_key), - )) - } - - fn response_error( - &self, - kind: EndpointKind, - status: u16, - content_type: &str, - event_type: Option<&str>, - detail: &str, - snippet: &str, - ) -> AppError { - let snippet = sanitize(snippet, &self.api_key); - AppError::ServiceUnavailable(format!( - "Computer Use provider error: interface={}, model={}, endpoint={}, HTTP {}, content-type={}, event={}, detail={}, response={}", - kind.label(), - self.model, - redact_endpoint(&self.endpoint_url), - status, - content_type, - event_type.unwrap_or("none"), - sanitize(detail, &self.api_key), - if snippet.is_empty() { "(empty)" } else { &snippet }, - )) - } -} - -fn chat_body(model: &str, request_text: &str, screenshot: Option<&ComputerUseScreenshot>) -> Value { - let user_content = if let Some(screenshot) = screenshot { - json!([ - {"type": "text", "text": request_text}, - {"type": "image_url", "image_url": {"url": screenshot.data_url}} - ]) - } else { - json!(request_text) - }; - json!({ - "model": model, - "stream": true, - "messages": [ - {"role": "system", "content": COMPUTER_USE_SYSTEM_PROMPT}, - {"role": "user", "content": user_content} - ] - }) -} - -fn responses_body( - model: &str, - request_text: &str, - screenshot: Option<&ComputerUseScreenshot>, -) -> Value { - let mut content = vec![json!({"type": "input_text", "text": request_text})]; - if let Some(screenshot) = screenshot { - content.push(json!({ - "type": "input_image", - "image_url": screenshot.data_url, - "detail": "original" - })); - } - json!({ - "model": model, - "stream": true, - "instructions": COMPUTER_USE_SYSTEM_PROMPT, - "reasoning": {"summary": "auto"}, - "input": [{"role": "user", "content": content}] - }) -} - -fn request_context( - prompt: &str, - conversation: &[ComputerUseConversationMessage], - action_history: &[String], - screenshot: Option<&ComputerUseScreenshot>, -) -> String { - let conversation = conversation_history_text(conversation); - let actions = compact_action_history(action_history); - let screen = screenshot - .map(|image| { - format!( - "{}x{} (latest screenshot attached)", - image.width, image.height - ) - }) - .unwrap_or_else(|| "unavailable (request screenshot first)".to_string()); - format!( - "Previous conversation:\n{}\n\nCurrent task:\n{}\n\nAction history:\n{}\n\nVisual context: {}\nReturn only the action JSON object.", - if conversation.is_empty() { "(none)" } else { &conversation }, - prompt, - if actions.is_empty() { "(none)" } else { &actions }, - screen, - ) -} - -fn conversation_history_text(conversation: &[ComputerUseConversationMessage]) -> String { - conversation - .iter() - .map(|message| match message { - ComputerUseConversationMessage::User { text } => format!("User: {text}"), - ComputerUseConversationMessage::Assistant { text } => format!("Assistant: {text}"), - }) - .collect::>() - .join("\n") -} - -fn compact_action_history(history: &[String]) -> String { - const MAX_CHARS: usize = 12_000; - let mut selected = Vec::new(); - let mut chars = 0; - for item in history.iter().rev() { - let size = item.chars().count() + 1; - if chars + size > MAX_CHARS && !selected.is_empty() { - break; - } - selected.push(item.as_str()); - chars += size; - } - selected.reverse(); - selected.join("\n") -} - -fn endpoint_kind(url: &str) -> Result { - let url = url.trim().to_ascii_lowercase(); - if url.contains("/chat/completions") { - Ok(EndpointKind::ChatCompletions) - } else if url.contains("/responses") { - Ok(EndpointKind::Responses) - } else { - Err(AppError::BadRequest( - "API URL must include /responses or /chat/completions".to_string(), - )) - } -} - -fn chat_content_text(value: &Value) -> Option { - if let Some(text) = value.as_str() { - return Some(text.to_string()); - } - value.as_array().map(|parts| { - parts - .iter() - .filter_map(|part| part.get("text").and_then(Value::as_str)) - .collect::>() - .join("\n") - }) -} - -fn collect_responses_json(value: &Value) -> (String, String) { - let mut output = String::new(); - let mut reasoning = String::new(); - if let Some(text) = value.get("output_text").and_then(Value::as_str) { - output.push_str(text); - } - for item in value - .get("output") - .and_then(Value::as_array) - .into_iter() - .flatten() - { - match item.get("type").and_then(Value::as_str).unwrap_or_default() { - "message" => { - for part in item - .get("content") - .and_then(Value::as_array) - .into_iter() - .flatten() - { - if let Some(text) = part.get("text").and_then(Value::as_str) { - output.push_str(text); - } - } - } - "reasoning" => { - for part in item - .get("summary") - .and_then(Value::as_array) - .into_iter() - .flatten() - { - if let Some(text) = part.get("text").and_then(Value::as_str) { - reasoning.push_str(text); - } - } - } - _ => {} - } - } - (output, reasoning) -} - -fn parse_json_object_text(text: &str) -> std::result::Result { - let trimmed = text.trim(); - let unwrapped = trimmed - .strip_prefix("```json") - .or_else(|| trimmed.strip_prefix("```")) - .and_then(|text| text.strip_suffix("```")) - .map(str::trim) - .unwrap_or(trimmed); - let json_text = if unwrapped.starts_with('{') { - unwrapped - } else { - let start = unwrapped - .find('{') - .ok_or_else(|| "action response was not a JSON object".to_string())?; - let end = unwrapped - .rfind('}') - .ok_or_else(|| "action response was not a JSON object".to_string())?; - &unwrapped[start..=end] - }; - serde_json::from_str(json_text).map_err(|err| { - format!( - "action JSON was invalid at line {}, column {}: {}", - err.line(), - err.column(), - err - ) - }) -} - -fn validate_protocol( - response: &OpenAiComputerResponse, - has_screenshot: bool, -) -> std::result::Result<(), String> { - if response.done { - if !response.actions.is_empty() { - return Err("done=true requires actions=[]".to_string()); - } - return Ok(()); - } - let screenshot_positions = response - .actions - .iter() - .enumerate() - .filter_map(|(index, action)| { - matches!(action, ComputerUseAction::Screenshot).then_some(index) - }) - .collect::>(); - if screenshot_positions.len() != 1 || screenshot_positions[0] + 1 != response.actions.len() { - return Err( - "done=false requires exactly one screenshot action in the final position".to_string(), - ); - } - if !has_screenshot && response.actions.len() != 1 { - return Err( - "coordinate or keyboard actions are not allowed before the first screenshot" - .to_string(), - ); - } - Ok(()) -} - -fn parse_actions_array(value: &Value) -> std::result::Result, String> { - let actions = value - .get("actions") - .ok_or_else(|| "action response was missing actions".to_string())? - .as_array() - .ok_or_else(|| "action response field actions was not an array".to_string())?; - actions.iter().map(parse_action).collect() -} - -fn parse_action(value: &Value) -> std::result::Result { - let action_type = value - .get("type") - .and_then(Value::as_str) - .ok_or_else(|| "action was missing type".to_string())?; - let click_alias = match action_type { - "left_click" => Some(ComputerUseButton::Left), - "right_click" => Some(ComputerUseButton::Right), - "middle_click" => Some(ComputerUseButton::Middle), - _ => None, - }; - if let Some(button) = click_alias { - return Ok(ComputerUseAction::Click { - x: required_u32(value, "x", action_type)?, - y: required_u32(value, "y", action_type)?, - button, - }); - } - match action_type { - "click" => Ok(ComputerUseAction::Click { - x: required_u32(value, "x", action_type)?, - y: required_u32(value, "y", action_type)?, - button: parse_button(value.get("button")), - }), - "double_click" | "doubleClick" => Ok(ComputerUseAction::DoubleClick { - x: required_u32(value, "x", action_type)?, - y: required_u32(value, "y", action_type)?, - button: parse_button(value.get("button")), - }), - "move" | "move_mouse" => Ok(ComputerUseAction::Move { - x: required_u32(value, "x", action_type)?, - y: required_u32(value, "y", action_type)?, - }), - "drag" => { - let path = value - .get("path") - .and_then(Value::as_array) - .ok_or_else(|| "drag action was missing path array".to_string())?; - let path = path - .iter() - .map(|point| { - Ok(ComputerUsePoint { - x: required_u32(point, "x", action_type)?, - y: required_u32(point, "y", action_type)?, - }) - }) - .collect::, String>>()?; - if path.is_empty() { - return Err("drag action had an empty path".to_string()); - } - Ok(ComputerUseAction::Drag { - path, - button: parse_button(value.get("button")), - }) - } - "scroll" => Ok(ComputerUseAction::Scroll { - x: required_u32(value, "x", action_type)?, - y: required_u32(value, "y", action_type)?, - dx: value_i32(value, "dx") - .or_else(|| value_i32(value, "scroll_x")) - .unwrap_or(0), - dy: value_i32(value, "dy") - .or_else(|| value_i32(value, "scroll_y")) - .unwrap_or(0), - }), - "type" => { - let text = value - .get("text") - .and_then(Value::as_str) - .unwrap_or_default(); - if !text.is_ascii() { - return Err( - "type action contains non-ASCII text; use the remote input method".to_string(), - ); - } - Ok(ComputerUseAction::Type { - text: text.to_string(), - }) - } - "keypress" | "key_press" => Ok(ComputerUseAction::Keypress { - keys: value - .get("keys") - .and_then(Value::as_array) - .map(|keys| { - keys.iter() - .filter_map(Value::as_str) - .map(str::to_string) - .collect() - }) - .or_else(|| { - value - .get("key") - .and_then(Value::as_str) - .map(|key| vec![key.to_string()]) - }) - .unwrap_or_default(), - }), - "wait" => Ok(ComputerUseAction::Wait { - ms: value - .get("ms") - .or_else(|| value.get("duration")) - .and_then(Value::as_u64) - .unwrap_or(500), - }), - "screenshot" => Ok(ComputerUseAction::Screenshot), - _ => Err(format!("unsupported computer action type: {action_type}")), - } -} - -fn parse_button(value: Option<&Value>) -> ComputerUseButton { - match value.and_then(Value::as_str).unwrap_or("left") { - "right" => ComputerUseButton::Right, - "middle" => ComputerUseButton::Middle, - _ => ComputerUseButton::Left, - } -} - -fn required_u32(value: &Value, key: &str, action_type: &str) -> std::result::Result { - let raw = value - .get(key) - .and_then(Value::as_u64) - .ok_or_else(|| format!("{action_type} action was missing numeric {key}"))?; - u32::try_from(raw).map_err(|_| format!("{action_type} action field {key} was out of range")) -} - -fn value_i32(value: &Value, key: &str) -> Option { - value - .get(key) - .and_then(Value::as_i64) - .and_then(|value| i32::try_from(value).ok()) -} - -#[derive(Debug, Default)] -struct SseDecoder { - buffer: Vec, - event_name: Option, - data_lines: Vec, -} - -#[derive(Debug, PartialEq, Eq)] -struct SseEvent { - event: Option, - data: String, -} - -impl SseDecoder { - fn push(&mut self, chunk: &[u8]) -> Vec { - self.buffer.extend_from_slice(chunk); - let mut events = Vec::new(); - while let Some(position) = self.buffer.iter().position(|byte| *byte == b'\n') { - let mut line = self.buffer.drain(..=position).collect::>(); - line.pop(); - if line.last() == Some(&b'\r') { - line.pop(); - } - self.consume_line(&String::from_utf8_lossy(&line), &mut events); - } - events - } - - fn finish(&mut self) -> Vec { - let mut events = Vec::new(); - if !self.buffer.is_empty() { - let line = std::mem::take(&mut self.buffer); - self.consume_line(&String::from_utf8_lossy(&line), &mut events); - } - self.dispatch(&mut events); - events - } - - fn consume_line(&mut self, line: &str, events: &mut Vec) { - if line.is_empty() { - self.dispatch(events); - return; - } - if line.starts_with(':') { - return; - } - let (field, value) = line.split_once(':').unwrap_or((line, "")); - let value = value.strip_prefix(' ').unwrap_or(value); - match field { - "event" => self.event_name = Some(value.to_string()), - "data" => self.data_lines.push(value.to_string()), - _ => {} - } - } - - fn dispatch(&mut self, events: &mut Vec) { - if self.event_name.is_none() && self.data_lines.is_empty() { - return; - } - events.push(SseEvent { - event: self.event_name.take(), - data: std::mem::take(&mut self.data_lines).join("\n"), - }); - } -} - -struct ReasoningCollector<'a, F: FnMut(&str)> { - callback: &'a mut F, - full: String, - pending: String, - last_flush: Instant, -} - -impl<'a, F: FnMut(&str)> ReasoningCollector<'a, F> { - fn new(callback: &'a mut F) -> Self { - Self { - callback, - full: String::new(), - pending: String::new(), - last_flush: Instant::now(), - } - } - - fn push(&mut self, delta: &str) { - self.full.push_str(delta); - self.pending.push_str(delta); - if self.last_flush.elapsed() >= REASONING_FLUSH_INTERVAL { - self.flush(); - } - } - - fn flush(&mut self) { - if !self.pending.is_empty() { - (self.callback)(&self.pending); - self.pending.clear(); - } - self.last_flush = Instant::now(); - } - - fn into_text(self) -> String { - self.full - } -} - -fn redact_endpoint(endpoint: &str) -> String { - let endpoint = endpoint.split('?').next().unwrap_or(endpoint).trim(); - if let Ok(mut url) = reqwest::Url::parse(endpoint) { - let _ = url.set_username(""); - let _ = url.set_password(None); - return url.to_string().trim_end_matches('/').to_string(); - } - endpoint.to_string() -} - -fn sanitize(value: &str, api_key: &str) -> String { - let mut output = if api_key.is_empty() { - value.to_string() - } else { - value.replace(api_key, "[REDACTED_API_KEY]") - }; - loop { - let Some(start) = output.find("data:image/") else { - break; - }; - let end = output[start..] - .find(|ch: char| ch == '"' || ch == '\'' || ch.is_whitespace()) - .map(|offset| start + offset) - .unwrap_or(output.len()); - output.replace_range(start..end, "[REDACTED_IMAGE]"); - } - output.chars().take(ERROR_SNIPPET_LIMIT).collect() -} - -pub fn normalize_data_url(data_url: &str) -> Result { - if !data_url.starts_with("data:image/") { - return Err(AppError::BadRequest( - "Screenshot must be an image data URL".to_string(), - )); - } - let Some((_, data)) = data_url.split_once(',') else { - return Err(AppError::BadRequest( - "Invalid screenshot data URL".to_string(), - )); - }; - STANDARD - .decode(data) - .map_err(|_| AppError::BadRequest("Screenshot is not valid base64".to_string()))?; - Ok(data_url.to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn test_provider(endpoint: &str) -> OpenAiComputerProvider { - OpenAiComputerProvider::new( - "secret".to_string(), - endpoint.to_string(), - "model".to_string(), - ) - } - - #[test] - fn sse_decoder_handles_fragmentation_crlf_multiline_and_empty_events() { - let input = b": keepalive\r\nevent: sample\r\ndata: {\"a\":\r\ndata: 1}\r\n\r\n\r\ndata: [DONE]\n\n"; - let mut decoder = SseDecoder::default(); - let mut events = Vec::new(); - for byte in input { - events.extend(decoder.push(&[*byte])); - } - events.extend(decoder.finish()); - assert_eq!( - events, - vec![ - SseEvent { - event: Some("sample".to_string()), - data: "{\"a\":\n1}".to_string(), - }, - SseEvent { - event: None, - data: "[DONE]".to_string(), - } - ] - ); - } - - #[test] - fn click_aliases_reuse_click_action() { - for (name, expected) in [ - ("left_click", ComputerUseButton::Left), - ("right_click", ComputerUseButton::Right), - ("middle_click", ComputerUseButton::Middle), - ] { - let action = parse_action(&json!({"type": name, "x": 4, "y": 8})).unwrap(); - assert!(matches!( - action, - ComputerUseAction::Click { x: 4, y: 8, button } if button == expected - )); - } - } - - #[test] - fn protocol_requires_first_screenshot_and_terminal_screenshot() { - let first = OpenAiComputerResponse { - done: false, - actions: vec![ComputerUseAction::Screenshot], - message: None, - reasoning: None, - }; - assert!(validate_protocol(&first, false).is_ok()); - - let invalid = OpenAiComputerResponse { - done: false, - actions: vec![ - ComputerUseAction::Click { - x: 1, - y: 2, - button: ComputerUseButton::Left, - }, - ComputerUseAction::Screenshot, - ], - message: None, - reasoning: None, - }; - assert!(validate_protocol(&invalid, false).is_err()); - assert!(validate_protocol(&invalid, true).is_ok()); - - let duplicate_screenshot = OpenAiComputerResponse { - done: false, - actions: vec![ComputerUseAction::Screenshot, ComputerUseAction::Screenshot], - message: None, - reasoning: None, - }; - assert!(validate_protocol(&duplicate_screenshot, true).is_err()); - - let done_with_actions = OpenAiComputerResponse { - done: true, - actions: vec![ComputerUseAction::Screenshot], - message: Some("done".to_string()), - reasoning: None, - }; - assert!(validate_protocol(&done_with_actions, true).is_err()); - } - - #[test] - fn invalid_json_reports_line_and_column() { - let error = parse_json_object_text("{\n \"done\": nope\n}").unwrap_err(); - assert!(error.contains("line 2, column")); - } - - #[test] - fn sanitization_removes_api_keys_and_images() { - let value = sanitize( - "key=secret image=data:image/png;base64,AAAA and more", - "secret", - ); - assert!(!value.contains("secret")); - assert!(!value.contains("AAAA")); - } - - #[test] - fn chat_sse_collects_reasoning_and_action_json() { - let provider = test_provider("https://example.test/v1/chat/completions"); - let mut output = String::new(); - let mut received_reasoning = String::new(); - let mut callback = |delta: &str| received_reasoning.push_str(delta); - let mut reasoning = ReasoningCollector::new(&mut callback); - let event = SseEvent { - event: None, - data: json!({ - "choices": [{"delta": { - "reasoning_content": "checking", - "content": "{\"done\":false,\"message\":null,\"actions\":[{\"type\":\"screenshot\"}]}" - }}] - }) - .to_string(), - }; - provider - .consume_stream_event( - EndpointKind::ChatCompletions, - &event, - None, - &mut output, - &mut reasoning, - "text/event-stream", - ) - .unwrap(); - reasoning.flush(); - drop(reasoning); - assert_eq!(received_reasoning, "checking"); - assert!(output.contains("\"screenshot\"")); - } - - #[test] - fn responses_sse_collects_output_and_reasoning_summary() { - let provider = test_provider("https://example.test/v1/responses"); - let mut output = String::new(); - let mut received_reasoning = String::new(); - let mut callback = |delta: &str| received_reasoning.push_str(delta); - let mut reasoning = ReasoningCollector::new(&mut callback); - for (event_type, delta) in [ - ("response.reasoning_summary_text.delta", "looking"), - ( - "response.output_text.delta", - "{\"done\":true,\"message\":\"done\",\"actions\":[]}", - ), - ] { - let event = SseEvent { - event: Some(event_type.to_string()), - data: json!({"type": event_type, "delta": delta}).to_string(), - }; - provider - .consume_stream_event( - EndpointKind::Responses, - &event, - Some(event_type), - &mut output, - &mut reasoning, - "text/event-stream", - ) - .unwrap(); - } - reasoning.flush(); - drop(reasoning); - assert_eq!(received_reasoning, "looking"); - assert!(output.contains("\"done\":true")); - } - - #[test] - fn ordinary_chat_json_is_a_streaming_fallback() { - let provider = test_provider("https://example.test/v1/chat/completions"); - let raw = json!({ - "choices": [{"message": { - "reasoning_content": "summary", - "content": "{\"done\":true,\"message\":\"ok\",\"actions\":[]}" - }}] - }) - .to_string(); - let mut deltas = String::new(); - let response = provider - .parse_json_response( - EndpointKind::ChatCompletions, - raw.as_bytes(), - "application/json", - &mut |delta| deltas.push_str(delta), - ) - .unwrap(); - assert!(response.done); - assert_eq!(response.message.as_deref(), Some("ok")); - assert_eq!(deltas, "summary"); - } - - #[test] - fn request_bodies_use_plain_text_and_optional_images_only() { - let chat_first = chat_body("model", "task", None).to_string(); - let responses_first = responses_body("model", "task", None).to_string(); - for body in [&chat_first, &responses_first] { - assert!(!body.contains("data:image/")); - assert!(!body.contains("computer_call_output")); - assert!(!body.contains("previous_response_id")); - assert!(!body.contains("\"tools\"")); - } - - let screenshot = ComputerUseScreenshot { - data_url: "data:image/png;base64,AAAA".to_string(), - width: 1280, - height: 720, - }; - assert!(chat_body("model", "task", Some(&screenshot)) - .to_string() - .contains("image_url")); - assert!(responses_body("model", "task", Some(&screenshot)) - .to_string() - .contains("input_image")); - } -} diff --git a/src/config/schema/computer_use.rs b/src/config/schema/computer_use.rs deleted file mode 100644 index a21c4da7..00000000 --- a/src/config/schema/computer_use.rs +++ /dev/null @@ -1,47 +0,0 @@ -use serde::{Deserialize, Serialize}; -use typeshare::typeshare; - -#[typeshare] -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(default)] -pub struct ComputerUseConfig { - pub enabled: bool, - pub base_url: String, - pub model: String, - #[typeshare(skip)] - #[serde(alias = "openai_api_key")] - pub api_key: Option, -} - -impl Default for ComputerUseConfig { - fn default() -> Self { - Self { - enabled: false, - base_url: "https://api.openai.com/v1/responses".to_string(), - model: "gpt-5.5".to_string(), - api_key: None, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn legacy_openai_api_key_migrates_to_generic_key() { - let config: ComputerUseConfig = serde_json::from_value(serde_json::json!({ - "enabled": true, - "provider": "openai", - "base_url": "https://example.test/v1/chat/completions", - "model": "vision-model", - "openai_api_key": "legacy-key", - "max_steps": 30, - "timeout_seconds": 600 - })) - .unwrap(); - - assert_eq!(config.api_key.as_deref(), Some("legacy-key")); - assert_eq!(config.model, "vision-model"); - } -} diff --git a/src/config/schema/mcp.rs b/src/config/schema/mcp.rs new file mode 100644 index 00000000..be890e73 --- /dev/null +++ b/src/config/schema/mcp.rs @@ -0,0 +1,43 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +#[typeshare] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct McpConfig { + pub enabled: bool, + pub requests_per_second: u32, + #[typeshare(skip)] + pub api_key: Option, + #[typeshare(skip)] + pub api_key_hash: Option, +} +impl Default for McpConfig { + fn default() -> Self { + Self { + enabled: false, + requests_per_second: 5, + api_key: None, + api_key_hash: None, + } + } +} + +#[cfg(test)] +mod tests { + #[test] + fn mcp_defaults_are_disabled_and_have_no_model_credentials() { + let config: super::super::AppConfig = serde_json::from_value( + serde_json::json!({"computer_use":{"enabled":true,"api_key":"old-model-key"}}), + ) + .unwrap(); + assert!(!config.mcp.enabled); + assert!(config.mcp.api_key_hash.is_none()); + assert!(config.mcp.api_key.is_none()); + assert_eq!(config.mcp.requests_per_second, 5); + assert!(serde_json::to_value(&config) + .unwrap() + .get("computer_use") + .is_none()); + } +} diff --git a/src/config/schema/mod.rs b/src/config/schema/mod.rs index c20e40e3..edaa230b 100644 --- a/src/config/schema/mod.rs +++ b/src/config/schema/mod.rs @@ -6,8 +6,8 @@ pub use crate::rustdesk::config::RustDeskConfig; mod atx; mod common; -mod computer_use; mod hid; +mod mcp; mod otg_network; mod stream; mod uac; @@ -16,8 +16,8 @@ mod web; pub use atx::*; pub use common::*; -pub use computer_use::*; pub use hid::*; +pub use mcp::*; pub use otg_network::*; pub use stream::*; pub use uac::*; @@ -39,7 +39,7 @@ pub struct AppConfig { pub audio: AudioConfig, pub stream: StreamConfig, pub web: WebConfig, - pub computer_use: ComputerUseConfig, + pub mcp: McpConfig, pub extensions: ExtensionsConfig, pub rustdesk: RustDeskConfig, pub vnc: VncConfig, @@ -60,6 +60,7 @@ impl AppConfig { self.hid.mouse_absolute = false; } self.atx.normalize(); + self.mcp.requests_per_second = self.mcp.requests_per_second.clamp(1, 100); } pub fn apply_platform_defaults(&mut self) { diff --git a/src/config/store.rs b/src/config/store.rs index b8324a04..6419b297 100644 --- a/src/config/store.rs +++ b/src/config/store.rs @@ -27,9 +27,9 @@ impl ConfigStore { } pub async fn load(&self) -> Result<()> { - let (mut config, removed_legacy_totp) = Self::load_config(&self.pool).await?; + let (mut config, removed_legacy_fields) = Self::load_config(&self.pool).await?; config.enforce_invariants(); - if removed_legacy_totp { + if removed_legacy_fields { Self::save_config_to_db(&self.pool, &config).await?; } self.cache.store(Arc::new(config)); @@ -46,7 +46,9 @@ impl ConfigStore { Some((json,)) => { let mut value: serde_json::Value = serde_json::from_str(&json).map_err(|e| AppError::Config(e.to_string()))?; - let mut removed = false; + let mut removed = value + .as_object_mut() + .is_some_and(|object| object.remove("computer_use").is_some()); if let Some(auth) = value .get_mut("auth") .and_then(|value| value.as_object_mut()) @@ -175,6 +177,60 @@ mod tests { assert_eq!(config.web.http_port, 9000); } + #[tokio::test] + async fn mcp_key_remains_readable_after_config_reload() { + let dir = tempdir().unwrap(); + let db = DatabasePool::new(&dir.path().join("mcp-key.db")) + .await + .unwrap(); + db.init_schema().await.unwrap(); + let store = ConfigStore::new(db.clone_pool()); + store.load().await.unwrap(); + let key = crate::mcp::generate_key(); + store + .update(|config| { + config.mcp.enabled = true; + config.mcp.api_key_hash = Some(crate::mcp::hash_key(&key)); + config.mcp.api_key = Some(key.clone()); + }) + .await + .unwrap(); + let reloaded = ConfigStore::new(db.clone_pool()); + reloaded.load().await.unwrap(); + assert_eq!(reloaded.get().mcp.api_key.as_deref(), Some(key.as_str())); + assert_eq!( + reloaded.get().mcp.api_key_hash, + store.get().mcp.api_key_hash + ); + } + + #[tokio::test] + async fn loading_legacy_agent_cleans_persisted_model_credentials() { + let dir = tempdir().unwrap(); + let db = DatabasePool::new(&dir.path().join("legacy-agent.db")) + .await + .unwrap(); + db.init_schema().await.unwrap(); + let value = + serde_json::json!({"computer_use":{"enabled":true,"api_key":"legacy-model-secret"}}); + sqlx::query("INSERT INTO config (key, value) VALUES ('app_config', ?1)") + .bind(value.to_string()) + .execute(db.pool()) + .await + .unwrap(); + let store = ConfigStore::new(db.clone_pool()); + store.load().await.unwrap(); + assert!(!store.get().mcp.enabled); + assert!(store.get().mcp.api_key_hash.is_none()); + let (persisted,): (String,) = + sqlx::query_as("SELECT value FROM config WHERE key = 'app_config'") + .fetch_one(db.pool()) + .await + .unwrap(); + assert!(!persisted.contains("legacy-model-secret")); + assert!(!persisted.contains("computer_use")); + } + #[tokio::test] async fn failed_watchdog_persistence_does_not_update_cache() { let dir = tempdir().unwrap(); diff --git a/src/hid/mod.rs b/src/hid/mod.rs index 980159b9..f815a1c9 100644 --- a/src/hid/mod.rs +++ b/src/hid/mod.rs @@ -120,6 +120,10 @@ enum QueuedHidEvent { Mouse(MouseEvent), Consumer(ConsumerEvent), Reset, + Confirmed( + Box, + tokio::sync::oneshot::Sender>, + ), } pub struct HidController { @@ -313,6 +317,36 @@ impl HidController { self.enqueue_event(QueuedHidEvent::Consumer(event)).await } + /// MCP uses acknowledged events so timing starts after the hardware send. + pub async fn send_keyboard_confirmed(&self, event: KeyboardEvent) -> Result<()> { + self.send_confirmed(QueuedHidEvent::Keyboard(event)).await + } + + pub async fn send_mouse_confirmed(&self, event: MouseEvent) -> Result<()> { + self.send_confirmed(QueuedHidEvent::Mouse(event)).await + } + + pub async fn reset_confirmed(&self) -> Result<()> { + self.send_confirmed(QueuedHidEvent::Reset).await + } + + async fn send_confirmed(&self, event: QueuedHidEvent) -> Result<()> { + if !self.backend_available.load(Ordering::Acquire) { + return Err(AppError::ServiceUnavailable( + "HID backend not available".into(), + )); + } + let (tx, rx) = tokio::sync::oneshot::channel(); + self.enqueue_event(QueuedHidEvent::Confirmed(Box::new(event), tx)) + .await?; + tokio::time::timeout(Duration::from_secs(3), rx) + .await + .map_err(|_| AppError::ServiceUnavailable("HID hardware send timed out".into()))? + .map_err(|_| { + AppError::ServiceUnavailable("HID event discarded before sending".into()) + })? + } + pub async fn bluetooth_status(&self) -> Result { let backend = self .backend @@ -625,6 +659,18 @@ impl HidController { } } +#[cfg(test)] +pub(crate) async fn test_controller(backend: Arc) -> Arc { + #[cfg(unix)] + let controller = Arc::new(HidController::new(HidBackendType::None, None)); + #[cfg(not(unix))] + let controller = Arc::new(HidController::new(HidBackendType::None)); + *controller.backend.write().await = Some(backend); + controller.sync_runtime_state_from_backend().await; + controller.start_event_worker().await; + controller +} + async fn apply_backend_runtime_state( backend_type: &Arc>, runtime_state: &Arc>, @@ -645,35 +691,34 @@ async fn process_hid_event( event: QueuedHidEvent, backend: &Arc>>>, ) { - let backend_opt = backend.read().await.clone(); - let backend = match backend_opt { - Some(b) => b, - None => return, + let (event, reply) = match event { + QueuedHidEvent::Confirmed(event, reply) => (*event, Some(reply)), + event => (event, None), }; - - let backend_for_send = backend.clone(); - let result = tokio::task::spawn_blocking(move || { - futures::executor::block_on(async move { - match event { - QueuedHidEvent::Keyboard(ev) => backend_for_send.send_keyboard(ev).await, - QueuedHidEvent::Mouse(ev) => backend_for_send.send_mouse(ev).await, - QueuedHidEvent::Consumer(ev) => backend_for_send.send_consumer(ev).await, - QueuedHidEvent::Reset => backend_for_send.reset().await, - } + let backend = backend.read().await.clone(); + let result = match backend { + Some(backend) => tokio::task::spawn_blocking(move || { + futures::executor::block_on(async move { + match event { + QueuedHidEvent::Keyboard(ev) => backend.send_keyboard(ev).await, + QueuedHidEvent::Mouse(ev) => backend.send_mouse(ev).await, + QueuedHidEvent::Consumer(ev) => backend.send_consumer(ev).await, + QueuedHidEvent::Reset => backend.reset().await, + QueuedHidEvent::Confirmed(..) => unreachable!("nested confirmed HID event"), + } + }) }) - }) - .await; - - let result = match result { - Ok(r) => r, - Err(_) => return, + .await + .unwrap_or_else(|_| Err(AppError::ServiceUnavailable("HID worker failed".into()))), + None => Err(AppError::ServiceUnavailable( + "HID backend not available".into(), + )), }; - - match result { - Ok(_) => {} - Err(e) => { - warn!("HID event processing failed: {}", e); - } + if let Err(error) = &result { + warn!("HID event processing failed: {}", error); + } + if let Some(reply) = reply { + let _ = reply.send(result); } } diff --git a/src/lib.rs b/src/lib.rs index a8b8fa93..d4d5d7aa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,8 +10,6 @@ pub mod audio; #[cfg(feature = "desktop")] pub mod auth; #[cfg(feature = "desktop")] -pub mod computer_use; -#[cfg(feature = "desktop")] pub mod config; #[cfg(feature = "desktop")] pub mod db; @@ -25,6 +23,8 @@ pub mod events; pub mod extensions; #[cfg(feature = "desktop")] pub mod hid; +#[cfg(feature = "desktop")] +pub mod mcp; #[cfg(all(unix, feature = "desktop"))] pub mod msd; #[cfg(all(unix, feature = "desktop"))] diff --git a/src/mcp/actions.rs b/src/mcp/actions.rs new file mode 100644 index 00000000..930d3a39 --- /dev/null +++ b/src/mcp/actions.rs @@ -0,0 +1,332 @@ +use super::keyboard::{char_to_key, key_name_to_canonical}; +use crate::hid::{ + CanonicalKey, HidController, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, + MouseEvent, +}; +use rmcp::schemars::{self, JsonSchema}; +use serde::Deserialize; +use std::time::Duration; + +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema, Default)] +#[serde(rename_all = "snake_case")] +pub enum Button { + #[default] + Left, + Middle, + Right, +} +impl From - - -

{{ t('computerUse.title') }}

-
- - +
@@ -661,10 +637,6 @@ const hasRightOverflow = computed(() => { {{ t('actionbar.webTerminal') }} - - {{ t('computerUse.title') }} - - @@ -752,8 +724,6 @@ const hasRightOverflow = computed(() => { - -
diff --git a/web/src/components/ComputerUseSheet.vue b/web/src/components/ComputerUseSheet.vue deleted file mode 100644 index 4879b72f..00000000 --- a/web/src/components/ComputerUseSheet.vue +++ /dev/null @@ -1,460 +0,0 @@ - - -