chore(core): slim the core — remove dead modules, move desktop features to Tauri, cut goals/todos to tinyagents (#5125)

This commit is contained in:
Steven Enamakel
2026-07-24 03:15:26 +03:00
committed by GitHub
parent e780277e75
commit 25254002b7
354 changed files with 5066 additions and 39296 deletions
+1 -3
View File
@@ -443,11 +443,9 @@ jobs:
set -euo pipefail
EXPECTED=$(cat <<'EOF'
core/all_tests.rs
core/autocomplete_cli_adapter.rs
core/cli_tests.rs
core/jsonrpc_tests.rs
core/legacy_aliases.rs
core/runtime/context.rs
core/runtime/services.rs
openhuman/agent/harness/builtin_definitions.rs
openhuman/agent/harness/definition_tests.rs
@@ -465,7 +463,7 @@ jobs:
openhuman/x402/stub.rs
EOF
)
ACTUAL=$(grep -rlE '#\[cfg\((not\()?feature = "(voice|media|web3|meet|mcp|skills|flows|channels|desktop-automation)"' src --include='*.rs' \
ACTUAL=$(grep -rlE '#\[cfg\((not\()?feature = "(voice|media|web3|meet|mcp|skills|flows|channels)"' src --include='*.rs' \
| xargs grep -lE '#\[test\]|#\[tokio::test\]|fn .*_test' 2>/dev/null | sed 's|^src/||' | sort -u)
if ! diff <(echo "$EXPECTED" | sed 's/^ *//' | sort -u) <(echo "$ACTUAL"); then
echo "::error::Gated-test file set changed. Update the EXPECTED allowlist in the rust-feature-gate-smoke lane, and extend the scoped 'cargo test' filter if the new module can carry an ungated-assert regression (see #5022)."
+2 -3
View File
@@ -177,7 +177,7 @@ Embedded provider webviews **must not** grow new JS injection. No new `.js` unde
### Domain layout (`src/openhuman/`)
~130 domain directories — authoritative list: `ls -d src/openhuman/*/`. Major families: agent (`agent`, `agent_experience`, `agent_meetings`, `agent_memory`, `agent_orchestration`, `agent_registry`, `agent_tool_policy`, `agentbox`, `orchestration`), memory (`memory`, `memory_archivist`, `memory_conversations`, `memory_diff`, `memory_goals`, `memory_queue`, `memory_search`, `memory_sources`, `memory_store`, `memory_sync`, `memory_tools`, `memory_tree`, `tinycortex`), skills/flows (`skills`, `skill_registry`, `skill_runtime`, `flows`, `tinyflows`, `tinyagents`, `rhai_workflows`), inference/AI (`inference`, `model_council`, `council_registry`, `embeddings`, `routing`), MCP (`mcp_audit`, `mcp_client`, `mcp_registry`, `mcp_server`), runtimes (`runtime_node`, `runtime_python`, `runtime_python_server`, `javascript`, `sandbox`, `cwd_jail`), channels/webviews (`channels`, `webview_accounts`, `webview_apis`, `webview_notifications`, `whatsapp_data`), meet (`meet`, `meet_agent`), web3 (`wallet`, `web3`, `x402`, `tokenjuice`), plus platform domains (`about_app`, `approval`, `config`, `cron`, `credentials`, `keyring`, `security`, `threads`, `tools`, `update`, `voice`, …).
~130 domain directories — authoritative list: `ls -d src/openhuman/*/`. Major families: agent (`agent`, `agent_experience`, `agent_meetings`, `agent_memory`, `agent_orchestration`, `agent_registry`, `agent_tool_policy`, `agentbox`, `orchestration`), memory (`memory`, `memory_archivist`, `memory_conversations`, `memory_diff`, `memory_goals`, `memory_queue`, `memory_search`, `memory_sources`, `memory_store`, `memory_sync`, `memory_tools`, `memory_tree`, `tinycortex`), skills/flows (`skills`, `skill_registry`, `skill_runtime`, `flows`, `tinyflows`, `tinyagents`, `rhai_workflows`), inference/AI (`inference`, `embeddings`, `routing`), MCP (`mcp_audit`, `mcp_client`, `mcp_registry`, `mcp_server`), runtimes (`runtime_node`, `runtime_python`, `runtime_python_server`, `javascript`, `sandbox`, `cwd_jail`), channels/webviews (`channels`, `whatsapp_data`), meet (`meet`, `meet_agent`), web3 (`wallet`, `web3`, `x402`, `tokenjuice`), plus platform domains (`about_app`, `approval`, `config`, `cron`, `credentials`, `keyring`, `security`, `threads`, `tools`, `update`, `voice`, …).
**Skills runtime**: the QuickJS per-skill VM engine is gone. `src/openhuman/skills/` holds skill metadata/tool descriptors; execution of installed `SKILL.md` workflows lives in `src/openhuman/skill_runtime/` (starts/cancels runs, hosts the `skill_executor` agent, reuses `runtime_node`/`runtime_python`).
@@ -256,12 +256,11 @@ GGML_NATIVE=OFF cargo check --manifest-path Cargo.toml \
**`meet` gate (#4800)** — uses all three module patterns, one per domain, chosen by the rule *"does always-compiled code reach a non-registration symbol in here?"*:
- **`meet` → leaf-gate.** `#[cfg(feature = "meet")] pub mod meet;` outright. Its only outside reference is the registration site in `src/core/all.rs`, so no facade is needed (same shape as `audio_toolkit`).
- **`meet_agent`facade + carve-out, no stub file.** Every submodule is gated **except `wav`** (below). Nothing outside the Meet domain calls the gated submodules.
- **`meet_agent`leaf-gate.** Every submodule (including `wav`) is `#[cfg(feature = "meet")]`. Nothing outside the Meet domain calls into it. (`wav` was formerly ungated for the always-on `desktop_companion` STT path; that domain was removed, so the carve-out is gone and `wav` gates with the rest.)
- **`agent_meetings` → facade + stub.** Three always-compiled call sites reach in — the heartbeat planner (`calendar::handle_calendar_meeting_candidate`) and two subscriber registrations (`core::jsonrpc`, `channels::runtime::startup`) — so `src/openhuman/agent_meetings/stub.rs` supplies no-op equivalents and those callers need no `#[cfg]`.
**No deps to shed (do not re-litigate).** Unlike `voice`, this gate drops **zero** dependencies — the Meet domains have no exclusive crates. `meet_agent::wav` is a hand-rolled 79-line RIFF writer with no `use` statements, written precisely so Meet never needed `hound` (which `voice` already owns and sheds). The dependency shed was pre-paid; this gate's value is compile-time surface and binary size, not the dep tree.
**⚠ The `wav` carve-out is load-bearing.** `meet_agent::wav::pack_pcm16le_mono_wav` is called by `desktop_companion::pipeline::stt`, which is `DomainGroup::Platform` and compiled in **every** build. `pub mod wav;` must stay **ungated** so that call site keeps its real implementation — it costs nothing, since `wav.rs` is dependency-free. If someone gates it, the `--no-default-features` build fails loudly at `desktop_companion`; that failure is correct. Do **not** "fix" it by stubbing `pack_pcm16le_mono_wav` — that trades a compile error for green CI while silently corrupting desktop-companion STT (the STT backend would receive a malformed WAV). Revert the cfg instead.
**Both-ways tests.** `src/core/all_tests.rs` pins the gate in both directions (`meet_controllers_registered_when_feature_on` / `meet_controllers_absent_when_feature_off`). The negative half is the one that proves the gate removes anything. Note CI's smoke lane runs `cargo check` only and never compiles test code, so a disabled-build **test** break is invisible to it — run `cargo test --lib --no-default-features --features tokenjuice-treesitter core::all::tests` locally after touching any gated surface.
Generated
-110
View File
@@ -4763,7 +4763,6 @@ dependencies = [
"tracing-appender",
"tracing-log",
"tracing-subscriber",
"uiautomation",
"unicode-width",
"url",
"urlencoding",
@@ -8010,29 +8009,6 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "uiautomation"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c68495a701b9f2f21f29353ac446f0d27dd0d7ce97aa9ccf9061bca0446cd744"
dependencies = [
"chrono",
"uiautomation_derive",
"windows 0.62.2",
"windows-core 0.62.2",
]
[[package]]
name = "uiautomation_derive"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffcc4d404aa1c03a848f95cf5feadc3e63946d7f095bf388770b85550093d388"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "uint"
version = "0.9.5"
@@ -8886,27 +8862,6 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections",
"windows-core 0.62.2",
"windows-future",
"windows-numerics",
]
[[package]]
name = "windows-collections"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core 0.62.2",
]
[[package]]
name = "windows-core"
version = "0.54.0"
@@ -8942,30 +8897,6 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement 0.60.2",
"windows-interface 0.59.3",
"windows-link",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]]
name = "windows-future"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core 0.62.2",
"windows-link",
"windows-threading",
]
[[package]]
name = "windows-implement"
version = "0.57.0"
@@ -8988,17 +8919,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "windows-interface"
version = "0.57.0"
@@ -9021,33 +8941,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core 0.62.2",
"windows-link",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
@@ -9213,15 +9112,6 @@ dependencies = [
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
+2 -30
View File
@@ -318,23 +318,16 @@ reqwest = { version = "0.12", default-features = false, features = ["native-tls"
# AppContainer / process-jail backend in `openhuman::cwd_jail`.
# Feature list mirrors the Win32 surface used by cwd_jail/windows.rs:
# AppContainer profile APIs, ACL editing, STARTUPINFOEXW process spawn,
# and the GENERIC_* file access masks. `Win32_System_Com` is used by the
# UIA accessibility backend (`accessibility::uia_interact`) to initialise COM
# on the worker thread before creating the UI Automation client.
# and the GENERIC_* file access masks.
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_Security_Authorization",
"Win32_Security_Isolation",
"Win32_Storage_FileSystem",
"Win32_System_Com",
"Win32_System_Memory",
"Win32_System_Threading",
] }
# Microsoft UI Automation (UIA) bindings — the Windows backend for the
# `ax_interact` tool (`accessibility::uia_interact`). Safe Rust wrappers over
# the UIA COM API; the Windows analogue of the macOS AXUIElement Swift helper.
uiautomation = { version = "0.25", optional = true }
[target.'cfg(not(windows))'.dependencies]
# macOS / Linux: keep rustls + Mozilla webpki-roots — the historical
@@ -387,7 +380,7 @@ tokio = { version = "1", features = ["test-util"] }
proptest = "1"
[features]
default = ["tokenjuice-treesitter", "inference", "voice", "web3", "media", "documents", "meet", "skills", "flows", "mcp", "crash-reporting", "http-server", "desktop-automation", "channels", "tui", "medulla-local"]
default = ["tokenjuice-treesitter", "inference", "voice", "web3", "media", "documents", "meet", "skills", "flows", "mcp", "crash-reporting", "http-server", "channels", "tui", "medulla-local"]
# HTTP + Socket.IO server transport (#5048): the `/rpc` JSON-RPC endpoint and
# its auth middleware/CORS layer (`core::jsonrpc`, `core::auth`), the `/v1`
# OpenAI-compatible router (`inference::http`), the ad-hoc static-dir file
@@ -564,27 +557,6 @@ mcp = []
# the crash-reporting feature" error.
crash-reporting = ["dep:sentry"]
# Desktop-automation cluster (#5049): the five modules that read/drive the local
# desktop UI — `openhuman::accessibility` (macOS AX / Windows UIA FFI middleware),
# `openhuman::screen_intelligence` (capture + vision loop), `openhuman::autocomplete`
# (inline completion), `openhuman::desktop_companion` (Clicky-style loop), and the
# `computer` agent-tool family (`ax_interact` / `automate` / mouse / keyboard).
# Default-ON — the desktop app always ships with these. Slim / headless builds opt
# out via `--no-default-features --features "<explicit list without desktop-automation>"`,
# which drops the exclusive `uiautomation` (Windows UI Automation COM bindings)
# dependency. Composes with the runtime `DomainSet::desktop_automation` flag: the
# feature narrows the compile-time surface, `DomainSet` gates it at runtime.
#
# CARVE-OUT: the inert type modules stay compiled in BOTH builds —
# `accessibility::types` (incl. the `GlobeHotkey*` structs), `autocomplete::types`
# (`AutocompleteStatus`), and `screen_intelligence::types` (`AccessibilityStatus`,
# `CaptureImageRefResult`) — because always-on callers (`text_input`, `voice`,
# `app_state`) name them. Only behaviour is gated; the stubs re-export the one real
# type definition. See AGENTS.md "skills gate — the type carve-out".
#
# NOTE: only `uiautomation` is exclusive and thus shed. `enigo` is shared with the
# `voice` domain; `rdev` / `arboard` are voice-owned — none of those are dropped here.
desktop-automation = ["dep:uiautomation"]
# Tabbed terminal UI: the `openhuman tui` (alias `chat`) CLI subcommand, a
# ratatui/crossterm front-end for logs, orchestrator chat, curated configuration,
# and account settings. Default-ON for the standalone `openhuman-core` binary, but
-1
View File
@@ -293,7 +293,6 @@ if should_run_suite "notifications"; then
run "test/e2e/specs/memory-roundtrip.spec.ts" "memory-roundtrip" "notifications"
run "test/e2e/specs/coding-session-memory.spec.ts" "coding-session-memory" "notifications"
run "test/e2e/specs/cron-jobs-flow.spec.ts" "cron-jobs" "notifications"
run "test/e2e/specs/autocomplete-flow.spec.ts" "autocomplete" "notifications"
_mini_summary "notifications"
fi
+7 -110
View File
@@ -12,6 +12,7 @@ dependencies = [
"block2 0.6.2",
"cef",
"chrono",
"cpal",
"directories 5.0.1",
"env_logger",
"futures-util",
@@ -28,6 +29,7 @@ dependencies = [
"openhuman",
"parking_lot",
"rand 0.9.4",
"regex",
"reqwest 0.12.28",
"resvg",
"rfd",
@@ -53,6 +55,7 @@ dependencies = [
"tokio-util",
"toml 0.8.2",
"url",
"uuid 1.23.1",
"windows-sys 0.59.0",
]
@@ -5619,7 +5622,6 @@ dependencies = [
"tracing-appender",
"tracing-log",
"tracing-subscriber",
"uiautomation",
"url",
"urlencoding",
"uuid 1.23.1",
@@ -9656,29 +9658,6 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "uiautomation"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c68495a701b9f2f21f29353ac446f0d27dd0d7ce97aa9ccf9061bca0446cd744"
dependencies = [
"chrono",
"uiautomation_derive",
"windows 0.62.2",
"windows-core 0.62.2",
]
[[package]]
name = "uiautomation_derive"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffcc4d404aa1c03a848f95cf5feadc3e63946d7f095bf388770b85550093d388"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "uint"
version = "0.9.5"
@@ -10509,23 +10488,11 @@ version = "0.61.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
dependencies = [
"windows-collections 0.2.0",
"windows-collections",
"windows-core 0.61.2",
"windows-future 0.2.1",
"windows-future",
"windows-link 0.1.3",
"windows-numerics 0.2.0",
]
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections 0.3.2",
"windows-core 0.62.2",
"windows-future 0.3.2",
"windows-numerics 0.3.1",
"windows-numerics",
]
[[package]]
@@ -10537,15 +10504,6 @@ dependencies = [
"windows-core 0.61.2",
]
[[package]]
name = "windows-collections"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core 0.62.2",
]
[[package]]
name = "windows-core"
version = "0.54.0"
@@ -10594,19 +10552,6 @@ dependencies = [
"windows-strings 0.4.2",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement 0.60.2",
"windows-interface 0.59.3",
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]]
name = "windows-future"
version = "0.2.1"
@@ -10615,18 +10560,7 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
dependencies = [
"windows-core 0.61.2",
"windows-link 0.1.3",
"windows-threading 0.1.0",
]
[[package]]
name = "windows-future"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core 0.62.2",
"windows-link 0.2.1",
"windows-threading 0.2.1",
"windows-threading",
]
[[package]]
@@ -10717,16 +10651,6 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core 0.62.2",
"windows-link 0.2.1",
]
[[package]]
name = "windows-registry"
version = "0.5.3"
@@ -10765,15 +10689,6 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-strings"
version = "0.1.0"
@@ -10793,15 +10708,6 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-sys"
version = "0.45.0"
@@ -10928,15 +10834,6 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-version"
version = "0.1.7"
+11 -3
View File
@@ -107,9 +107,18 @@ sentry = { version = "0.47.0", default-features = false, features = ["backtrace"
# Used by the imessage_scanner module.
anyhow = "1.0"
# SQLite for the shell-side whatsapp_data store (relocated from the core). All
# platforms — the store persists on Windows/Linux/macOS. Pinned to match the
# core crate so a single bundled SQLite is linked. (The macOS-only iMessage
# scanner also uses rusqlite; this general dep covers it too.)
rusqlite = { version = "=0.40.0", features = ["bundled"] }
parking_lot = "0.12"
chrono = "0.4"
async-trait = "0.1"
# Desktop companion: native mic capture + POINT-tag parsing + session ids.
cpal = "0.15"
regex = "1"
uuid = { version = "1", features = ["v4"] }
# Mascot fake-camera pipeline (meet_call): rasterizes the OpenHuman
# mascot SVG to a PNG once, converts it to a YUV420 Y4M frame, and
# points CEF's `--use-file-for-fake-video-capture` flag at the cached
@@ -177,7 +186,6 @@ openhuman_core = { path = "../..", package = "openhuman", default-features = fal
# http://127.0.0.1:<port>/rpc, so it REQUIRES the HTTP + Socket.IO transport
# (#5048). Enforced by the HTTP_SERVER_COMPILED_IN compile assert in lib.rs.
"http-server",
"desktop-automation",
"medulla-local",
] }
tinyjuice = { version = "0.2.1", default-features = false }
@@ -189,8 +197,8 @@ nix = { version = "0.29", default-features = false, features = ["hostname", "sig
objc2 = "0.6"
objc2-app-kit = "0.3.2"
mac-notification-sys = "0.6"
# iMessage scanner reads ~/Library/Messages/chat.db read-only on macOS.
rusqlite = { version = "=0.40.0", features = ["bundled"] }
# iMessage scanner reads ~/Library/Messages/chat.db read-only on macOS
# (rusqlite is declared once in the general [dependencies] table above).
objc2-user-notifications = "0.3.2"
block2 = "0.6.2"
objc2-foundation = { version = "0.3.2", features = ["NSTimer", "block2"] }
@@ -73,6 +73,30 @@ allow = [
"unregister_ptt_hotkey",
"show_ptt_overlay",
# =========================
# DESKTOP COMPANION
# =========================
# The desktop companion moved from core RPC into the Tauri shell. Its
# settings panel and hotkey bridge invoke these commands directly, so each
# one must be present in the main-window capability.
"register_companion_hotkey",
"unregister_companion_hotkey",
"companion_activate",
"companion_start_session",
"companion_stop_session",
"companion_status",
"companion_config_get",
"companion_config_set",
# =========================
# STRUCTURED WHATSAPP DATA
# =========================
# The SQLite store moved from the core into the Tauri shell. Intelligence
# views now query it through these read-only invoke commands.
"whatsapp_data_list_chats",
"whatsapp_data_list_messages",
"whatsapp_data_search_messages",
# =========================
# ACCOUNT WEBVIEW
# =========================
+303
View File
@@ -0,0 +1,303 @@
//! Native microphone capture for the desktop companion (shell-side).
//!
//! This is the "shell captures audio natively" half of the migration: while a
//! companion session is Listening we open the default input device with `cpal`,
//! downmix to mono `i16`, and resample to ~16 kHz PCM for upload to the core STT
//! endpoint (`openhuman.voice_transcribe_bytes`). We deliberately do **not**
//! depend on the core voice listener for capture.
//!
//! `cpal::Stream` is `!Send` on several platforms, so the stream is built and
//! owned by a dedicated std thread; the audio callback accumulates samples into
//! a shared buffer and the thread holds the stream alive until `stop()`.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use log::{debug, info, warn};
const LOG_PREFIX: &str = "[companion_audio]";
/// Target sample rate for STT upload. Whisper works at 16 kHz mono.
pub const TARGET_SAMPLE_RATE: u32 = 16_000;
/// A live microphone capture. Dropping without `stop()` still tears the stream
/// down (via the `running` flag + thread join in `Drop`).
pub struct MicCapture {
running: Arc<AtomicBool>,
buffer: Arc<Mutex<Vec<i16>>>,
device_rate: u32,
handle: Option<JoinHandle<()>>,
}
impl MicCapture {
/// Open the default input device and begin capturing mono `i16` at the
/// device's native rate. Returns once the stream is playing (or an error).
pub fn start() -> Result<Self, String> {
let running = Arc::new(AtomicBool::new(true));
let buffer: Arc<Mutex<Vec<i16>>> = Arc::new(Mutex::new(Vec::new()));
let (ready_tx, ready_rx) = mpsc::channel::<Result<u32, String>>();
let thread_running = running.clone();
let thread_buffer = buffer.clone();
let handle = std::thread::Builder::new()
.name("companion-mic".into())
.spawn(move || {
run_capture_thread(thread_running, thread_buffer, ready_tx);
})
.map_err(|e| format!("failed to spawn mic thread: {e}"))?;
let device_rate = match ready_rx.recv() {
Ok(Ok(rate)) => rate,
Ok(Err(e)) => {
running.store(false, Ordering::SeqCst);
let _ = handle.join();
return Err(e);
}
Err(_) => {
running.store(false, Ordering::SeqCst);
let _ = handle.join();
return Err("mic capture thread exited before signalling ready".into());
}
};
info!("{LOG_PREFIX} capture started device_rate={device_rate}");
Ok(Self {
running,
buffer,
device_rate,
handle: Some(handle),
})
}
/// Stop capture and return the collected PCM resampled to
/// [`TARGET_SAMPLE_RATE`] (mono `i16`).
pub fn stop(mut self) -> (Vec<i16>, u32) {
self.running.store(false, Ordering::SeqCst);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
let raw = std::mem::take(&mut *self.buffer.lock().unwrap());
debug!(
"{LOG_PREFIX} capture stopped raw_samples={} device_rate={}",
raw.len(),
self.device_rate
);
let resampled = resample_linear(&raw, self.device_rate, TARGET_SAMPLE_RATE);
(resampled, TARGET_SAMPLE_RATE)
}
}
impl Drop for MicCapture {
fn drop(&mut self) {
self.running.store(false, Ordering::SeqCst);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
}
/// Body of the capture thread: build the input stream, signal readiness with the
/// device sample rate, then keep the stream alive until told to stop.
fn run_capture_thread(
running: Arc<AtomicBool>,
buffer: Arc<Mutex<Vec<i16>>>,
ready_tx: mpsc::Sender<Result<u32, String>>,
) {
let host = cpal::default_host();
let device = match host.default_input_device() {
Some(d) => d,
None => {
let _ = ready_tx.send(Err("no default input device".into()));
return;
}
};
let config = match device.default_input_config() {
Ok(c) => c,
Err(e) => {
let _ = ready_tx.send(Err(format!("no default input config: {e}")));
return;
}
};
let device_rate = config.sample_rate().0;
let channels = config.channels() as usize;
let sample_format = config.sample_format();
let stream_config: cpal::StreamConfig = config.into();
let err_fn = |e| warn!("{LOG_PREFIX} input stream error: {e}");
let build = |buf: Arc<Mutex<Vec<i16>>>| -> Result<cpal::Stream, cpal::BuildStreamError> {
match sample_format {
cpal::SampleFormat::F32 => device.build_input_stream(
&stream_config,
move |data: &[f32], _| {
let mut guard = buf.lock().unwrap();
for frame in data.chunks(channels.max(1)) {
let sum: f32 = frame.iter().copied().sum();
let mono = sum / channels.max(1) as f32;
guard.push((mono.clamp(-1.0, 1.0) * i16::MAX as f32) as i16);
}
},
err_fn,
None,
),
cpal::SampleFormat::I16 => device.build_input_stream(
&stream_config,
move |data: &[i16], _| {
let mut guard = buf.lock().unwrap();
for frame in data.chunks(channels.max(1)) {
let sum: i32 = frame.iter().map(|&s| s as i32).sum();
guard.push((sum / channels.max(1) as i32) as i16);
}
},
err_fn,
None,
),
cpal::SampleFormat::U16 => device.build_input_stream(
&stream_config,
move |data: &[u16], _| {
let mut guard = buf.lock().unwrap();
for frame in data.chunks(channels.max(1)) {
let sum: i32 = frame.iter().map(|&s| s as i32 - 32768).sum();
guard.push((sum / channels.max(1) as i32) as i16);
}
},
err_fn,
None,
),
other => Err(cpal::BuildStreamError::BackendSpecific {
err: cpal::BackendSpecificError {
description: format!("unsupported sample format: {other:?}"),
},
}),
}
};
let stream = match build(buffer) {
Ok(s) => s,
Err(e) => {
let _ = ready_tx.send(Err(format!("failed to build input stream: {e}")));
return;
}
};
if let Err(e) = stream.play() {
let _ = ready_tx.send(Err(format!("failed to start input stream: {e}")));
return;
}
let _ = ready_tx.send(Ok(device_rate));
// Keep the stream alive on this thread until stop() flips the flag.
while running.load(Ordering::SeqCst) {
std::thread::sleep(std::time::Duration::from_millis(50));
}
drop(stream);
debug!("{LOG_PREFIX} capture thread exiting");
}
/// Naive linear-interpolation resampler for mono `i16`. Adequate for speech STT
/// upload; whisper is robust to modest resampling artefacts.
fn resample_linear(input: &[i16], from_rate: u32, to_rate: u32) -> Vec<i16> {
if input.is_empty() || from_rate == 0 || to_rate == 0 {
return Vec::new();
}
if from_rate == to_rate {
return input.to_vec();
}
let ratio = to_rate as f64 / from_rate as f64;
let out_len = ((input.len() as f64) * ratio).round() as usize;
let mut out = Vec::with_capacity(out_len);
for i in 0..out_len {
let src = i as f64 / ratio;
let idx = src.floor() as usize;
let frac = src - idx as f64;
let a = input[idx.min(input.len() - 1)] as f64;
let b = input[(idx + 1).min(input.len() - 1)] as f64;
out.push((a + (b - a) * frac).round() as i16);
}
out
}
/// Pack mono `i16` little-endian PCM into a minimal RIFF/WAVE container.
///
/// The core STT endpoint (`openhuman.voice_transcribe_bytes`) takes container
/// bytes + an `extension`, not raw PCM — and the shell must not call the
/// `meet`-gated `meet_agent::wav` packer, so we ship this tiny dependency-free
/// RIFF writer here.
pub fn pack_wav_pcm16le_mono(samples: &[i16], sample_rate: u32) -> Vec<u8> {
let num_samples = samples.len() as u32;
let bytes_per_sample = 2u32;
let byte_rate = sample_rate * bytes_per_sample; // mono
let data_len = num_samples * bytes_per_sample;
let riff_len = 36 + data_len;
let mut out = Vec::with_capacity(44 + data_len as usize);
out.extend_from_slice(b"RIFF");
out.extend_from_slice(&riff_len.to_le_bytes());
out.extend_from_slice(b"WAVE");
// fmt chunk
out.extend_from_slice(b"fmt ");
out.extend_from_slice(&16u32.to_le_bytes()); // PCM fmt chunk size
out.extend_from_slice(&1u16.to_le_bytes()); // audio format = PCM
out.extend_from_slice(&1u16.to_le_bytes()); // channels = mono
out.extend_from_slice(&sample_rate.to_le_bytes());
out.extend_from_slice(&byte_rate.to_le_bytes());
out.extend_from_slice(&(bytes_per_sample as u16).to_le_bytes()); // block align
out.extend_from_slice(&16u16.to_le_bytes()); // bits per sample
// data chunk
out.extend_from_slice(b"data");
out.extend_from_slice(&data_len.to_le_bytes());
for &s in samples {
out.extend_from_slice(&s.to_le_bytes());
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resample_identity_when_rates_match() {
let input = vec![1i16, 2, 3, 4];
assert_eq!(resample_linear(&input, 16000, 16000), input);
}
#[test]
fn resample_downsamples_length() {
let input = vec![0i16; 48_000];
let out = resample_linear(&input, 48_000, 16_000);
assert_eq!(out.len(), 16_000);
}
#[test]
fn resample_empty_is_empty() {
assert!(resample_linear(&[], 48_000, 16_000).is_empty());
}
#[test]
fn wav_header_is_44_bytes_plus_data() {
let samples = vec![0i16, 1, -1, 32767, -32768];
let wav = pack_wav_pcm16le_mono(&samples, 16_000);
assert_eq!(&wav[0..4], b"RIFF");
assert_eq!(&wav[8..12], b"WAVE");
assert_eq!(&wav[36..40], b"data");
assert_eq!(wav.len(), 44 + samples.len() * 2);
// data length field matches
let data_len = u32::from_le_bytes([wav[40], wav[41], wav[42], wav[43]]);
assert_eq!(data_len as usize, samples.len() * 2);
}
#[test]
fn wav_sample_rate_field_roundtrips() {
let wav = pack_wav_pcm16le_mono(&[0i16; 4], 16_000);
let rate = u32::from_le_bytes([wav[24], wav[25], wav[26], wav[27]]);
assert_eq!(rate, 16_000);
}
}
+82
View File
@@ -0,0 +1,82 @@
//! Frontend event delivery for the desktop companion.
//!
//! State changes are emitted as a Tauri event `companion://state_changed`
//! with a camelCase payload (`{ sessionId, state, previousState }`) for the
//! main renderer, and forwarded through the embedded core's transport-only
//! Socket.IO seam for the native notch WKWebView. The session state machine lives
//! deep below any `#[tauri::command]` boundary (it is driven from the
//! pipeline), so we stash the `AppHandle` in a process-global `OnceLock`
//! (mirroring `cdp::in_process::set_cef_app_handle`) and read it from the
//! emit helper.
use std::sync::OnceLock;
use log::debug;
use tauri::{AppHandle, Emitter};
use super::types::{CompanionState, CompanionStateChangedEvent};
const LOG_PREFIX: &str = "[companion]";
/// Tauri event name the frontend companion slice + overlay/notch listen to.
pub const STATE_CHANGED_EVENT: &str = "companion://state_changed";
/// Process-global `AppHandle`, set once from `Builder::setup`.
static COMPANION_APP_HANDLE: OnceLock<AppHandle<crate::AppRuntime>> = OnceLock::new();
#[cfg(test)]
static RECORDED_EVENTS: std::sync::Mutex<Vec<CompanionStateChangedEvent>> =
std::sync::Mutex::new(Vec::new());
/// Store the app handle so the pipeline/session can emit frontend events from
/// outside a command context. Idempotent — a second write is ignored.
pub fn set_app_handle(app: AppHandle<crate::AppRuntime>) {
if COMPANION_APP_HANDLE.set(app).is_err() {
debug!("{LOG_PREFIX} set_app_handle called more than once — ignoring");
}
}
/// Emit a `companion://state_changed` event to the frontend.
///
/// Fire-and-forget: if the handle is not yet installed (e.g. in unit tests) or
/// the emit fails, we log and move on rather than failing the turn.
pub fn emit_state_changed(session_id: &str, state: CompanionState, previous: CompanionState) {
debug!("{LOG_PREFIX} state_changed session={session_id} {previous} -> {state}");
let payload = CompanionStateChangedEvent {
session_id: session_id.to_string(),
state,
previous_state: previous,
};
#[cfg(test)]
RECORDED_EVENTS
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.push(payload.clone());
let Some(app) = COMPANION_APP_HANDLE.get() else {
debug!("{LOG_PREFIX} no app handle installed — state change not delivered to frontend");
return;
};
if let Err(e) = app.emit(STATE_CHANGED_EVENT, payload) {
debug!("{LOG_PREFIX} emit {STATE_CHANGED_EVENT} failed: {e}");
}
// The native notch WKWebView has no Tauri IPC bridge; it connects directly
// to the embedded core over Socket.IO. Preserve the legacy snake_case
// payload on that transport without moving companion behavior back to core.
let socket_payload = serde_json::json!({
"session_id": session_id,
"state": state,
"previous_state": previous,
});
let delivered = openhuman_core::core::socketio::publish_companion_state_changed(socket_payload);
debug!("{LOG_PREFIX} queued companion state for {delivered} Socket.IO bridge subscriber(s)");
}
#[cfg(test)]
pub(crate) fn take_recorded_events() -> Vec<CompanionStateChangedEvent> {
std::mem::take(
&mut *RECORDED_EVENTS
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()),
)
}
+339
View File
@@ -0,0 +1,339 @@
//! Desktop companion — Clicky-style interaction loop (shell-side).
//!
//! Migrated out of the Rust core (`src/openhuman/desktop_companion`) into the
//! Tauri shell. Ties hotkey activation, **native** microphone capture, screen
//! context, LLM reasoning, speech synthesis, and visual pointing into one
//! product experience:
//!
//! - `audio` — native `cpal` mic capture (mono i16 PCM ~16 kHz)
//! - `session` — session lifecycle + state machine (idle→listening→…→idle)
//! - `pipeline` — one interaction turn (STT → LLM → TTS → pointing)
//! - `pointing` — `[POINT:x,y:label:screenN]` tag parsing + monitor mapping
//! - `events` — `companion://state_changed` Tauri event delivery
//!
//! STT/TTS run in the embedded core over JSON-RPC; the LLM turn runs shell-side
//! against the backend using creds fetched from core. The five Tauri commands
//! below mirror the five RPC methods the old core `desktop_companion` exposed.
pub mod audio;
pub mod events;
pub mod pipeline;
pub mod pointing;
pub mod session;
pub mod types;
use log::{debug, info, warn};
use parking_lot::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};
use tauri::{AppHandle, Manager};
use tokio_util::sync::CancellationToken;
use crate::AppRuntime;
use audio::MicCapture;
use pointing::ScreenGeometry;
use types::*;
const LOG_PREFIX: &str = "[companion]";
/// Process-global companion configuration (persisted only in-memory for now —
/// mirrors the old core stub, but round-trips get/set instead of erroring).
static CONFIG: Mutex<Option<CompanionConfig>> = Mutex::new(None);
/// The in-flight mic capture, if the companion is currently Listening. Used to
/// implement both tap-to-talk and push-to-talk activation.
static ACTIVE_CAPTURE: Mutex<Option<MicCapture>> = Mutex::new(None);
/// Cancellation handle for the currently running STT/LLM/TTS turn.
///
/// A new capture cancels the previous turn before entering `Listening`, so
/// stale turn cleanup cannot race the interrupting utterance. The monotonic id
/// lets a completed old task avoid clearing a newer turn's token.
struct ActiveTurn {
id: u64,
cancel: CancellationToken,
}
static ACTIVE_TURN: Mutex<Option<ActiveTurn>> = Mutex::new(None);
static NEXT_TURN_ID: AtomicU64 = AtomicU64::new(1);
static SESSION_LIFECYCLE: Mutex<()> = Mutex::new(());
/// Install the app handle so the session/pipeline can emit frontend events from
/// outside a command context. Call once from `Builder::setup`.
pub fn setup(app: &AppHandle<AppRuntime>) {
events::set_app_handle(app.clone());
info!("{LOG_PREFIX} setup complete");
}
pub(super) fn current_config() -> CompanionConfig {
CONFIG.lock().clone().unwrap_or_default()
}
// ── Tauri commands (mirror the 5 former core RPC methods) ──────────────
/// Start a desktop companion session with explicit consent.
#[tauri::command]
pub(crate) async fn companion_start_session(
app: AppHandle<AppRuntime>,
consent: bool,
) -> Result<StartCompanionSessionResult, String> {
debug!("{LOG_PREFIX} companion_start_session consent={consent}");
let _lifecycle = SESSION_LIFECYCLE.lock();
cleanup_expired_session(&app);
let ttl_secs = current_config().ttl_secs;
session::start_session(&StartCompanionSessionParams {
consent,
ttl_secs: Some(ttl_secs),
})
}
/// Stop the active desktop companion session.
#[tauri::command]
pub(crate) async fn companion_stop_session(
app: AppHandle<AppRuntime>,
) -> Result<StopCompanionSessionResult, String> {
debug!("{LOG_PREFIX} companion_stop_session");
let _lifecycle = SESSION_LIFECYCLE.lock();
stop_session_resources();
let result = session::stop_session(&StopCompanionSessionParams {
reason: Some("user_requested".into()),
})?;
crate::companion_commands::unregister_companion_hotkey_for_app(&app)?;
Ok(result)
}
fn stop_session_resources() {
cancel_active_turn();
if let Some(capture) = ACTIVE_CAPTURE.lock().take() {
let _ = capture.stop();
}
}
fn cleanup_expired_session(app: &AppHandle<AppRuntime>) {
if session::session_is_expired() {
stop_session_resources();
let _ = session::expire_session_if_needed();
if let Err(error) = crate::companion_commands::unregister_companion_hotkey_for_app(app) {
warn!("{LOG_PREFIX} failed to unregister expired session hotkey: {error}");
}
}
}
/// Get the current desktop companion session status.
#[tauri::command]
pub(crate) async fn companion_status(
app: AppHandle<AppRuntime>,
) -> Result<CompanionSessionStatus, String> {
let _lifecycle = SESSION_LIFECYCLE.lock();
cleanup_expired_session(&app);
Ok(session::session_status())
}
/// Get the current desktop companion configuration.
#[tauri::command]
pub(crate) async fn companion_config_get() -> Result<CompanionConfig, String> {
Ok(current_config())
}
/// Update the desktop companion configuration.
#[tauri::command]
pub(crate) async fn companion_config_set(
config: CompanionConfig,
) -> Result<CompanionConfig, String> {
debug!(
"{LOG_PREFIX} companion_config_set hotkey={} mode={}",
config.hotkey, config.activation_mode
);
*CONFIG.lock() = Some(config.clone());
Ok(config)
}
// ── Activation driven by the hotkey / companion_activate ──────────────
/// Handle a global-hotkey press according to the configured activation mode.
///
/// Push mode begins capture on press; tap mode toggles capture on each press.
pub fn handle_hotkey_pressed(app: AppHandle<AppRuntime>) {
if current_config()
.activation_mode
.eq_ignore_ascii_case("push")
{
start_capture(&app);
} else {
handle_activation(app);
}
}
/// Handle a global-hotkey release according to the configured activation mode.
///
/// Only push mode consumes releases. Tap mode intentionally waits for the next
/// press, preserving toggle behavior.
pub fn handle_hotkey_released(app: AppHandle<AppRuntime>) {
if current_config()
.activation_mode
.eq_ignore_ascii_case("push")
{
finish_capture_and_run(app);
}
}
/// Handle a companion activation (hotkey press or the `companion_activate`
/// command). Tap-to-talk: the first activation while a session is active starts
/// native mic capture (Listening); the next activation stops capture and spawns
/// the interaction turn.
pub fn handle_activation(app: AppHandle<AppRuntime>) {
if ACTIVE_CAPTURE.lock().is_some() {
finish_capture_and_run(app);
} else {
start_capture(&app);
}
}
fn start_capture(app: &AppHandle<AppRuntime>) {
let _lifecycle = SESSION_LIFECYCLE.lock();
cleanup_expired_session(app);
let status = session::session_status();
if !status.active {
debug!("{LOG_PREFIX} activation ignored — no active session");
return;
}
let mut cap = ACTIVE_CAPTURE.lock();
if cap.is_some() {
debug!("{LOG_PREFIX} capture start ignored — already listening");
return;
}
// Interrupt any previous STT/LLM/TTS task before the new capture owns the
// Listening state. The pipeline observes this token between every phase.
cancel_active_turn();
// A cancelled pre-STT turn can still own Listening after its capture was
// consumed. Retire that state while holding the capture lock so its later
// cleanup cannot race a newly-started microphone capture.
session::finish_listening_turn();
if let Err(e) = session::transition_state(CompanionState::Listening, None) {
warn!("{LOG_PREFIX} could not enter Listening: {e}");
return;
}
match MicCapture::start() {
Ok(c) => {
*cap = Some(c);
info!("{LOG_PREFIX} listening — mic capture started");
}
Err(e) => {
warn!("{LOG_PREFIX} mic capture failed to start: {e}");
session::recover_after_turn_error(format!("mic capture failed: {e}"));
}
}
}
fn finish_capture_and_run(app: AppHandle<AppRuntime>) {
let _lifecycle = SESSION_LIFECYCLE.lock();
let Some(capture) = ACTIVE_CAPTURE.lock().take() else {
debug!("{LOG_PREFIX} capture stop ignored — not listening");
return;
};
let (samples, rate) = capture.stop();
info!("{LOG_PREFIX} captured {} samples @ {rate}Hz", samples.len());
let screens = monitors_geometry(&app);
let (turn_id, cancel) = register_active_turn();
tauri::async_runtime::spawn(async move {
let result = pipeline::run_audio_turn(&samples, rate, &screens, cancel.clone()).await;
if let Err(e) = result {
if cancel.is_cancelled() {
debug!("{LOG_PREFIX} interrupted companion turn stopped: {e}");
} else {
warn!("{LOG_PREFIX} companion turn failed: {e}");
session::recover_after_turn_error(e);
}
}
clear_active_turn(turn_id);
});
}
pub(super) fn finish_listening_without_active_capture() {
let capture = ACTIVE_CAPTURE.lock();
if capture.is_none() {
session::finish_listening_turn();
}
}
fn register_active_turn() -> (u64, CancellationToken) {
let id = NEXT_TURN_ID.fetch_add(1, Ordering::Relaxed);
let cancel = CancellationToken::new();
let mut active = ACTIVE_TURN.lock();
if let Some(previous) = active.take() {
previous.cancel.cancel();
}
*active = Some(ActiveTurn {
id,
cancel: cancel.clone(),
});
(id, cancel)
}
fn cancel_active_turn() {
if let Some(active) = ACTIVE_TURN.lock().take() {
active.cancel.cancel();
}
}
fn clear_active_turn(id: u64) {
let mut active = ACTIVE_TURN.lock();
if active.as_ref().map(|turn| turn.id) == Some(id) {
active.take();
}
}
/// Enumerate connected monitors as [`ScreenGeometry`] for POINT-tag coordinate
/// mapping. Best-effort — returns an empty vec if no window/monitor info is
/// available (pointing then falls back to raw coordinates).
fn monitors_geometry(app: &AppHandle<AppRuntime>) -> Vec<ScreenGeometry> {
let Some(window) = app.get_webview_window("main") else {
debug!("{LOG_PREFIX} no main window — cannot enumerate monitors");
return Vec::new();
};
let Ok(monitors) = window.available_monitors() else {
return Vec::new();
};
monitors
.iter()
.enumerate()
.map(|(index, m)| {
let pos = m.position();
let size = m.size();
ScreenGeometry {
index,
x: pos.x as f64,
y: pos.y as f64,
width: size.width as f64,
height: size.height as f64,
}
})
.collect()
}
#[cfg(test)]
mod turn_cancellation_tests {
use super::*;
#[test]
fn interrupt_cancels_in_flight_turn_and_old_completion_cannot_clear_new_token() {
cancel_active_turn();
let (old_id, old_token) = register_active_turn();
cancel_active_turn();
assert!(old_token.is_cancelled());
let (new_id, new_token) = register_active_turn();
clear_active_turn(old_id);
assert!(!new_token.is_cancelled());
assert_eq!(
ACTIVE_TURN.lock().as_ref().map(|turn| turn.id),
Some(new_id)
);
cancel_active_turn();
assert!(new_token.is_cancelled());
}
}
@@ -1,38 +1,43 @@
//! Companion interaction pipeline: STT → screen context → LLM → TTS → pointing.
//!
//! Orchestrates a single interaction turn for the desktop companion. Reuses
//! the same cloud STT, LLM, and TTS backends that `meet_agent::brain` uses,
//! but adds screenshot + foreground-app context and POINT-tag parsing for
//! visual pointing.
//! Orchestrates a single interaction turn for the desktop companion. Migrated
//! from the former core `desktop_companion::pipeline`, re-plumbed for the shell:
//!
//! - **STT** and **TTS** are performed by the embedded core over JSON-RPC
//! (`openhuman.voice_stt_dispatch` / `openhuman.voice_tts_dispatch`) via
//! [`crate::core_rpc::call_core_rpc`].
//! - The **LLM turn** runs shell-side: it fetches the backend session token
//! (`openhuman.auth_get_session_token`) and effective API URL
//! (`openhuman.config_resolve_api_url`) from core, then POSTs
//! `/openai/v1/chat/completions` directly with `reqwest`.
//! - Audio is captured natively by the shell (`super::audio`) and packed into a
//! shell-local RIFF/WAVE container for upload (no dependency on the
//! `meet`-gated `meet_agent::wav`).
//!
//! The pipeline is cancellable via [`tokio_util::sync::CancellationToken`] so
//! the Tauri shell can interrupt mid-turn (e.g. user presses hotkey again
//! during Speaking).
//! the shell can interrupt mid-turn (e.g. user presses the hotkey again during
//! Speaking).
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use std::time::Duration;
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use log::{debug, info, warn};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tokio_util::sync::CancellationToken;
use super::handoff::{self, HandoffEvent};
use super::pointing::{self, PointTarget, PointingParseResult, ScreenGeometry};
use super::session;
use super::types::*;
const LOG_PREFIX: &str = "[companion_pipeline]";
/// Maximum tokens for the companion LLM reply. Longer than meet_agent (220)
/// because the companion can give richer answers when not constrained to
/// live-meeting brevity.
/// Maximum tokens for the companion LLM reply.
const REPLY_MAX_TOKENS: u32 = 512;
/// Rolling conversation context window (number of turns).
const CONTEXT_WINDOW: usize = 20;
/// ElevenLabs TTS model for companion speech.
const TTS_MODEL_ID: &str = "eleven_turbo_v2_5";
/// Result of a single companion interaction turn.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TurnResult {
@@ -42,10 +47,8 @@ pub struct TurnResult {
pub response_text: String,
/// Parsed pointing targets from the LLM response.
pub targets: Vec<PointTarget>,
/// Whether TTS audio was synthesized and enqueued.
/// Whether TTS audio was synthesized.
pub tts_synthesized: bool,
/// Provider-surface handoff events detected in the response.
pub handoff_events: Vec<HandoffEvent>,
/// Whether the turn was cancelled before completion.
pub cancelled: bool,
}
@@ -53,8 +56,7 @@ pub struct TurnResult {
/// Run a text-input companion turn (no STT needed — the user typed their query).
///
/// **Precondition**: the session must already be in `Listening` state. The
/// caller (e.g. Tauri hotkey bridge or `companion_activate` RPC) is
/// responsible for the `Idle → Listening` transition before invoking this.
/// caller is responsible for the `Idle → Listening` transition before invoking.
///
/// Transitions: Listening → Thinking → Speaking/Pointing → Idle.
pub async fn run_text_turn(
@@ -80,8 +82,12 @@ pub async fn run_text_turn(
let history = session::conversation_history();
let history_window = tail_history(&history, CONTEXT_WINDOW);
// Screen context (best-effort — skip if unavailable).
let screen_context = gather_screen_context().await;
// Foreground app/window context (best-effort and user-configurable).
let screen_context = if super::current_config().include_app_context {
gather_screen_context().await
} else {
None
};
if cancel.is_cancelled() {
return Ok(cancelled_result(trimmed));
@@ -91,6 +97,9 @@ pub async fn run_text_turn(
let raw_reply = match llm_companion(trimmed, &history_window, screen_context.as_deref()).await {
Ok(reply) => reply,
Err(err) => {
if cancel.is_cancelled() {
return Ok(cancelled_result(trimmed));
}
warn!("{LOG_PREFIX} LLM failed err={err}");
session::transition_state(CompanionState::Error, Some(format!("LLM failure: {err}")))?;
return Err(format!("LLM failure: {err}"));
@@ -113,12 +122,6 @@ pub async fn run_text_turn(
targets.len()
);
// Check for provider-surface handoff opportunities.
let handoff_events = handoff::check_handoff(&clean_text);
if !handoff_events.is_empty() {
debug!("{LOG_PREFIX} handoff events={}", handoff_events.len());
}
// Record conversation turns.
let now_ms = chrono::Utc::now().timestamp_millis();
let _ = session::push_conversation_turn(ConversationTurn {
@@ -138,8 +141,8 @@ pub async fn run_text_turn(
let tts_ok = if !clean_text.is_empty() && !cancel.is_cancelled() {
session::transition_state(CompanionState::Speaking, None)?;
match tts(&clean_text).await {
Ok(_samples) => {
debug!("{LOG_PREFIX} TTS synthesized samples={}", _samples.len());
Ok(()) => {
debug!("{LOG_PREFIX} TTS synthesized");
true
}
Err(err) => {
@@ -161,14 +164,13 @@ pub async fn run_text_turn(
}
// Back to idle.
let _ = session::transition_state(CompanionState::Idle, None);
session::finish_turn();
let result = TurnResult {
transcript: trimmed.to_string(),
response_text: clean_text,
targets,
tts_synthesized: tts_ok,
handoff_events,
cancelled: false,
};
@@ -178,9 +180,7 @@ pub async fn run_text_turn(
/// Run a full audio-input companion turn: STT → screen context → LLM → TTS → pointing.
///
/// **Precondition**: the session must already be in `Listening` state. The
/// caller (e.g. Tauri hotkey bridge or `companion_activate` RPC) is
/// responsible for the `Idle → Listening` transition before invoking this.
/// **Precondition**: the session must already be in `Listening` state.
///
/// Transitions: Listening → Thinking → Speaking/Pointing → Idle.
pub async fn run_audio_turn(
@@ -190,7 +190,12 @@ pub async fn run_audio_turn(
cancel: CancellationToken,
) -> Result<TurnResult, String> {
if audio_samples.is_empty() {
return Err("no audio samples".into());
if cancel.is_cancelled() {
return Ok(cancelled_result(""));
}
let message = "no audio samples".to_string();
session::recover_after_turn_error(message.clone());
return Err(message);
}
info!(
@@ -205,20 +210,25 @@ pub async fn run_audio_turn(
// STT.
let transcript = match stt(audio_samples, sample_rate).await {
Ok(text) if cancel.is_cancelled() => {
return Ok(cancelled_result(text.trim()));
}
Ok(text) if text.trim().is_empty() => {
info!("{LOG_PREFIX} STT returned empty transcript, skipping turn");
let _ = session::transition_state(CompanionState::Idle, None);
super::finish_listening_without_active_capture();
return Ok(TurnResult {
transcript: String::new(),
response_text: String::new(),
targets: Vec::new(),
tts_synthesized: false,
handoff_events: Vec::new(),
cancelled: false,
});
}
Ok(text) => text,
Err(err) => {
if cancel.is_cancelled() {
return Ok(cancelled_result(""));
}
warn!("{LOG_PREFIX} STT failed err={err}");
session::transition_state(CompanionState::Error, Some(format!("STT failure: {err}")))?;
return Err(format!("STT failure: {err}"));
@@ -231,22 +241,79 @@ pub async fn run_audio_turn(
run_text_turn(&transcript, screens, cancel).await
}
// ─── Real adapters ──────────────────────────────────────────────────
// ─── Core-RPC / backend adapters ────────────────────────────────────
/// Transcribe audio samples to text via cloud STT.
/// Transcribe audio samples through the core's configured STT provider.
///
/// The dispatch endpoint accepts base64 container bytes, so we pack the mono
/// PCM into a shell-local WAV container first.
async fn stt(samples: &[i16], sample_rate: u32) -> Result<String, String> {
use crate::openhuman::voice::cloud_transcribe::{transcribe_cloud, CloudTranscribeOptions};
let wav = super::audio::pack_wav_pcm16le_mono(samples, sample_rate);
let params = stt_dispatch_params(&wav);
let result = crate::core_rpc::call_core_rpc("openhuman.voice_stt_dispatch", params).await?;
debug!(
"{LOG_PREFIX} stt provider={:?}",
result.get("provider").and_then(|p| p.as_str())
);
result
.get("text")
.and_then(|t| t.as_str())
.map(|s| s.to_string())
.ok_or_else(|| format!("unexpected transcribe response: {result}"))
}
let config = crate::openhuman::config::ops::load_config_with_timeout().await?;
let wav_bytes = crate::openhuman::meet_agent::wav::pack_pcm16le_mono_wav(samples, sample_rate);
let audio_b64 = B64.encode(&wav_bytes);
let opts = CloudTranscribeOptions {
mime_type: Some("audio/wav".to_string()),
file_name: Some("companion.wav".to_string()),
..Default::default()
};
let outcome = transcribe_cloud(&config, &audio_b64, &opts).await?;
Ok(outcome.value.text.clone())
fn stt_dispatch_params(wav: &[u8]) -> Value {
json!({
"audio_base64": BASE64_STANDARD.encode(wav),
"mime_type": "audio/wav",
"file_name": "companion.wav",
})
}
/// Synthesize speech through the core's configured TTS provider.
///
/// The shell only needs to know synthesis succeeded to hold the Speaking
/// state; native playback remains a follow-up.
async fn tts(text: &str) -> Result<(), String> {
let params = tts_dispatch_params(text);
let result = crate::core_rpc::call_core_rpc("openhuman.voice_tts_dispatch", params).await?;
debug!(
"{LOG_PREFIX} tts audio_mime={:?}",
result.get("audio_mime").and_then(|mime| mime.as_str())
);
Ok(())
}
fn tts_dispatch_params(text: &str) -> Value {
json!({ "text": text })
}
/// Fetch the backend session token + effective API base URL from core so the
/// shell-side LLM turn can authenticate against `/openai/v1/chat/completions`.
async fn fetch_backend_creds() -> Result<(String, String), String> {
let token_v = crate::core_rpc::call_core_rpc("openhuman.auth_get_session_token", json!({}))
.await
.map_err(|e| format!("fetch session token: {e}"))?;
let token = token_v
.get("token")
.and_then(|t| t.as_str())
.map(str::trim)
.filter(|t| !t.is_empty())
.ok_or_else(|| "no backend session token".to_string())?
.to_string();
let url_v = crate::core_rpc::call_core_rpc("openhuman.config_resolve_api_url", json!({}))
.await
.map_err(|e| format!("resolve api url: {e}"))?;
let backend_url = url_v
.get("api_url")
.and_then(|u| u.as_str())
.map(str::trim)
.filter(|u| !u.is_empty())
.ok_or_else(|| "no backend api_url".to_string())?
.to_string();
Ok((token, backend_url))
}
/// System prompt for the desktop companion LLM.
@@ -268,34 +335,21 @@ your response will be spoken aloud via TTS.\n\
";
/// Build a chat-completions request with screen context and conversation
/// history, then return the assistant's reply.
/// history, then return the assistant's reply. Runs shell-side against the
/// backend using creds fetched from core.
async fn llm_companion(
prompt: &str,
history: &[&ConversationTurn],
screen_context: Option<&str>,
) -> Result<String, String> {
use crate::api::config::effective_backend_api_url;
use crate::api::jwt::get_session_token;
use crate::api::BackendOAuthClient;
use reqwest::Method;
let (token, backend_url) = fetch_backend_creds().await?;
let endpoint = companion_chat_endpoint(&backend_url);
let config = crate::openhuman::config::ops::load_config_with_timeout().await?;
let token = get_session_token(&config)
.map_err(|e| e.to_string())?
.filter(|t| !t.trim().is_empty())
.ok_or_else(|| "no backend session token".to_string())?;
let api_url = effective_backend_api_url(&config.api_url);
let client = BackendOAuthClient::new(&api_url).map_err(|e| e.to_string())?;
let mut messages: Vec<Value> = Vec::with_capacity(history.len() + 3);
let mut messages: Vec<Value> = Vec::with_capacity(history.len() + 2);
// System prompt with optional screen context.
let system = if let Some(ctx) = screen_context {
format!(
"{COMPANION_SYSTEM_PROMPT}\n\
Current screen context:\n{ctx}"
)
format!("{COMPANION_SYSTEM_PROMPT}\nCurrent screen context:\n{ctx}")
} else {
COMPANION_SYSTEM_PROMPT.to_string()
};
@@ -316,68 +370,51 @@ async fn llm_companion(
"messages": messages,
});
// `flatten_authed_error` maps the typed `BackendApiError::Unauthorized`
// (expected session-lapse 401) onto the `SESSION_EXPIRED` sentinel so the
// JSON-RPC layer classifies it as session expiry and skips Sentry, matching
// the #3384 team/billing pattern and the voice TTS fix (TAURI-RUST-8X1).
// The previous `e.to_string()` leaked every lapsed-session companion-voice
// 401 to Sentry as a hard error; every other error keeps its `{e:#}` chain.
let raw = client
.authed_json(
&token,
Method::POST,
"/openai/v1/chat/completions",
Some(body),
)
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(60))
.build()
.map_err(|e| format!("build http client: {e}"))?;
let resp = client
.post(&endpoint)
.header("Content-Type", "application/json")
.header("Authorization", format!("Bearer {token}"))
.json(&body)
.send()
.await
.map_err(crate::api::flatten_authed_error)?;
.map_err(|e| format!("chat completions request failed: {e}"))?;
let status = resp.status();
let raw: Value = resp
.json()
.await
.map_err(|e| format!("chat completions: invalid JSON response: {e}"))?;
if !status.is_success() {
return Err(format!("chat completions returned {status}: {raw}"));
}
extract_chat_completion_text(&raw)
.ok_or_else(|| format!("unexpected chat completions response: {raw}"))
}
/// Synthesize speech from text via cloud TTS. Returns raw PCM16LE samples.
async fn tts(text: &str) -> Result<Vec<i16>, String> {
use crate::openhuman::voice::reply_speech::{synthesize_reply, ReplySpeechOptions};
let config = crate::openhuman::config::ops::load_config_with_timeout().await?;
let voice_settings = json!({
"stability": 0.4,
"similarity_boost": 0.75,
"style": 0.35,
"use_speaker_boost": true,
});
let opts = ReplySpeechOptions {
output_format: Some("pcm_16000".to_string()),
model_id: Some(TTS_MODEL_ID.to_string()),
voice_settings: Some(voice_settings),
..Default::default()
};
let outcome = synthesize_reply(&config, text, &opts).await?;
let pcm_bytes = B64
.decode(outcome.value.audio_base64.as_bytes())
.map_err(|e| format!("decode tts base64: {e}"))?;
if pcm_bytes.len() % 2 != 0 {
return Err(format!("odd byte length from tts: {}", pcm_bytes.len()));
}
Ok(pcm_bytes
.chunks_exact(2)
.map(|c| i16::from_le_bytes([c[0], c[1]]))
.collect())
fn companion_chat_endpoint(backend_url: &str) -> String {
format!(
"{}/openai/v1/chat/completions",
backend_url.trim_end_matches('/')
)
}
/// Gather screen context (foreground app info) as a text summary.
/// Returns `None` if screen intelligence is unavailable.
/// Gather foreground app/window context as a text summary for the LLM.
///
/// The accessibility backbone remains in the embedded core after desktop
/// controls moved out, so the shell can reuse its public, read-only foreground
/// query without restoring any automation commands.
async fn gather_screen_context() -> Option<String> {
#[cfg(target_os = "macos")]
{
let context = crate::openhuman::accessibility::foreground_context();
context.map(|ctx| {
format!(
"App: {} | Window: {}",
ctx.app_name.as_deref().unwrap_or("unknown"),
ctx.window_title.as_deref().unwrap_or("unknown"),
)
openhuman_core::openhuman::accessibility::foreground_context().map(|ctx| {
format_foreground_context(ctx.app_name.as_deref(), ctx.window_title.as_deref())
})
}
#[cfg(not(target_os = "macos"))]
@@ -386,6 +423,15 @@ async fn gather_screen_context() -> Option<String> {
}
}
#[cfg(any(target_os = "macos", test))]
fn format_foreground_context(app_name: Option<&str>, window_title: Option<&str>) -> String {
format!(
"App: {} | Window: {}",
app_name.unwrap_or("unknown"),
window_title.unwrap_or("unknown"),
)
}
fn extract_chat_completion_text(raw: &Value) -> Option<String> {
raw.get("choices")
.and_then(|c| c.as_array())
@@ -404,14 +450,13 @@ fn tail_history(history: &[ConversationTurn], n: usize) -> Vec<&ConversationTurn
fn cancelled_result(transcript: &str) -> TurnResult {
// Restore session to Idle so it doesn't stay stuck in Thinking/Speaking.
let _ = session::transition_state(CompanionState::Idle, None);
session::finish_turn();
info!("{LOG_PREFIX} turn cancelled, restored session to Idle");
TurnResult {
transcript: transcript.to_string(),
response_text: String::new(),
targets: Vec::new(),
tts_synthesized: false,
handoff_events: Vec::new(),
cancelled: true,
}
}
@@ -2,12 +2,12 @@
//!
//! These tests exercise the pipeline's orchestration logic — state
//! transitions, cancellation, conversation history, and POINT-tag
//! integration. Real STT/LLM/TTS calls are not made; the pipeline
//! falls back to stubs in a test environment (no backend token).
//! integration. Real STT/LLM/TTS calls are not made; the pipeline's
//! network calls fail fast in a test environment (no embedded core).
use super::*;
use crate::openhuman::desktop_companion::pointing::ScreenGeometry;
use crate::openhuman::desktop_companion::session;
use crate::companion::pointing::ScreenGeometry;
use crate::companion::session;
/// Serialize tests that touch the process-global session state. Shared with
/// `session_tests` via `session::lock_test_state()` so transitions in one test
@@ -75,7 +75,6 @@ fn cancelled_result_has_correct_fields() {
assert!(r.response_text.is_empty());
assert!(r.targets.is_empty());
assert!(!r.tts_synthesized);
assert!(r.handoff_events.is_empty());
assert!(r.cancelled);
}
@@ -104,6 +103,31 @@ fn extract_chat_completion_text_malformed() {
assert_eq!(extract_chat_completion_text(&json!(42)), None);
}
#[test]
fn stt_dispatch_params_encode_wav_for_provider_dispatch() {
assert_eq!(
stt_dispatch_params(b"wav"),
json!({
"audio_base64": "d2F2",
"mime_type": "audio/wav",
"file_name": "companion.wav",
})
);
}
#[test]
fn tts_dispatch_params_defer_provider_selection_to_core() {
assert_eq!(tts_dispatch_params("hello"), json!({ "text": "hello" }));
}
#[test]
fn companion_chat_endpoint_joins_only_the_backend_path() {
assert_eq!(
companion_chat_endpoint("https://api.tinyhumans.ai/"),
"https://api.tinyhumans.ai/openai/v1/chat/completions"
);
}
// ── Text turn tests ──────────────────────────────────────────────────
#[tokio::test]
@@ -148,6 +172,9 @@ async fn audio_turn_rejects_empty_samples() {
let result = run_audio_turn(&[], 16_000, &single_screen(), cancel).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("no audio"));
let status = session::session_status();
assert_eq!(status.state, CompanionState::Idle);
assert_eq!(status.last_error.as_deref(), Some("no audio samples"));
session::reset_for_test();
}
@@ -156,10 +183,22 @@ async fn audio_turn_rejects_empty_samples() {
#[tokio::test]
async fn gather_screen_context_returns_option() {
let ctx = gather_screen_context().await;
// Just verify it doesn't panic — value depends on platform.
// Just verify it doesn't panic — value depends on platform/capability.
let _ = ctx;
}
#[test]
fn foreground_context_summary_preserves_app_and_window() {
assert_eq!(
format_foreground_context(Some("Safari"), Some("OpenHuman")),
"App: Safari | Window: OpenHuman"
);
assert_eq!(
format_foreground_context(None, None),
"App: unknown | Window: unknown"
);
}
// ── System prompt ────────────────────────────────────────────────────
#[test]
@@ -4,6 +4,9 @@
//! response text (Clicky convention). This module extracts those tags,
//! maps screen-relative coordinates to absolute desktop coordinates
//! using monitor geometry, and strips the tags from the display text.
//!
//! Migrated verbatim from the former core `desktop_companion::pointing`
//! (the parsing logic has no core dependencies — it is pure regex + math).
use log::debug;
use serde::{Deserialize, Serialize};
@@ -1,4 +1,4 @@
//! Companion session lifecycle and state machine.
//! Companion session lifecycle and state machine (shell-side).
//!
//! A companion session represents a single period of desktop companion
//! activity. It owns the state machine (idle → listening → thinking →
@@ -6,14 +6,20 @@
//!
//! Only one session may be active at a time. Sessions are created with
//! explicit user consent and can be stopped manually or via TTL expiry.
//!
//! Migrated from the former core `desktop_companion::session`. The only
//! behavioural change: state transitions emit the `companion://state_changed`
//! Tauri event (`super::events`) instead of the old Socket.IO broadcast bus,
//! and the core `event_bus` session-lifecycle publishes are dropped (the
//! frontend now derives session-active purely from state transitions).
use log::{debug, info, warn};
use parking_lot::Mutex;
use super::bus;
use super::events;
use super::types::*;
const LOG_PREFIX: &str = "[desktop_companion]";
const LOG_PREFIX: &str = "[companion]";
/// Maximum number of conversation turns retained per session.
const MAX_CONVERSATION_TURNS: usize = 50;
@@ -28,6 +34,7 @@ struct CompanionSessionInner {
state: CompanionState,
started_at_ms: i64,
expires_at_ms: Option<i64>,
#[allow(dead_code)]
ttl_secs: u64,
conversation: Vec<ConversationTurn>,
last_error: Option<String>,
@@ -92,14 +99,6 @@ pub fn start_session(
*guard = Some(inner);
drop(guard);
// Publish session-started event for Socket.IO bridge / subscribers.
crate::core::event_bus::publish_global(
crate::core::event_bus::DomainEvent::CompanionSessionStarted {
session_id: session_id.clone(),
ttl_secs,
},
);
Ok(StartCompanionSessionResult {
session_id,
state: CompanionState::Idle,
@@ -123,15 +122,11 @@ pub fn stop_session(
info!(
"{LOG_PREFIX} stopping session id={session_id} reason={reason} turns={turn_count}",
);
let previous = inner.state;
drop(guard);
crate::core::event_bus::publish_global(
crate::core::event_bus::DomainEvent::CompanionSessionEnded {
session_id,
reason: reason.clone(),
turn_count,
},
);
if previous != CompanionState::Idle {
events::emit_state_changed(&session_id, CompanionState::Idle, previous);
}
Ok(StopCompanionSessionResult {
stopped: true,
@@ -148,48 +143,48 @@ pub fn stop_session(
}
}
/// Return whether the active session has exceeded its TTL.
pub fn session_is_expired() -> bool {
let guard = ACTIVE_SESSION.lock();
guard
.as_ref()
.and_then(|inner| inner.expires_at_ms)
.is_some_and(|expires_at_ms| chrono::Utc::now().timestamp_millis() >= expires_at_ms)
}
/// Atomically remove the active session only when its TTL has elapsed.
pub fn expire_session_if_needed() -> bool {
let mut guard = ACTIVE_SESSION.lock();
let Some(inner) = guard.as_ref() else {
return false;
};
let expired = inner
.expires_at_ms
.is_some_and(|expires_at_ms| chrono::Utc::now().timestamp_millis() >= expires_at_ms);
if !expired {
return false;
}
let stale = guard.take().expect("active session checked above");
let session_id = stale.id.clone();
let previous = stale.state;
let turn_count = stale.conversation.len();
drop(guard);
info!("{LOG_PREFIX} auto-expiring stale session id={session_id} turns={turn_count}");
if previous != CompanionState::Idle {
events::emit_state_changed(&session_id, CompanionState::Idle, previous);
}
true
}
/// Get the current session status.
pub fn session_status() -> CompanionSessionStatus {
let mut guard = ACTIVE_SESSION.lock();
let guard = ACTIVE_SESSION.lock();
match guard.as_ref() {
Some(inner) => {
let now_ms = chrono::Utc::now().timestamp_millis();
let remaining_ms = inner.expires_at_ms.map(|exp| (exp - now_ms).max(0));
// Auto-expire if TTL exceeded.
// Clear inline (guard.take) instead of calling stop_session() to
// avoid a TOCTOU race where another thread starts a new session
// between drop(guard) and the stop_session() call.
if let Some(remaining) = remaining_ms {
if remaining == 0 {
let stale = guard.take().expect("checked is_some");
let stale_id = stale.id.clone();
let turn_count = stale.conversation.len();
drop(stale);
drop(guard);
info!(
"{LOG_PREFIX} auto-expiring stale session id={stale_id} turns={turn_count}"
);
crate::core::event_bus::publish_global(
crate::core::event_bus::DomainEvent::CompanionSessionEnded {
session_id: stale_id,
reason: "ttl_expired".into(),
turn_count,
},
);
return CompanionSessionStatus {
active: false,
state: CompanionState::Idle,
session_id: None,
started_at_ms: None,
expires_at_ms: None,
remaining_ms: None,
turn_count: 0,
last_error: Some("session expired".into()),
};
}
}
CompanionSessionStatus {
active: true,
state: inner.state,
@@ -217,7 +212,8 @@ pub fn session_status() -> CompanionSessionStatus {
/// Transition the companion to a new state.
///
/// Returns the previous state, or an error if no session is active or the
/// transition is invalid.
/// transition is invalid. Emits `companion://state_changed` to the frontend on
/// success.
pub fn transition_state(
new_state: CompanionState,
message: Option<String>,
@@ -253,20 +249,70 @@ pub fn transition_state(
inner.last_error = message.clone();
}
// Publish the state change event.
let session_id = inner.id.clone();
drop(guard);
bus::publish_state_changed(CompanionStateChangedEvent {
session_id,
state: new_state,
previous_state: previous,
message,
});
events::emit_state_changed(&session_id, new_state, previous);
Ok(previous)
}
/// Record a failed turn and return the active session to `Idle` so the next
/// activation can start a fresh capture.
pub fn recover_after_turn_error(message: String) {
let status = session_status();
if !status.active {
return;
}
if status.state != CompanionState::Error {
let _ = transition_state(CompanionState::Error, Some(message));
}
let _ = transition_state(CompanionState::Idle, None);
}
/// Finish a turn without clobbering a newly-started interrupting capture.
///
/// The state check and update share the session lock, closing the race where an
/// old cancelled task could observe `Thinking`, a new capture could enter
/// `Listening`, and the old task could then reset that fresh capture to `Idle`.
pub fn finish_turn() {
let mut guard = ACTIVE_SESSION.lock();
let Some(inner) = guard.as_mut() else {
return;
};
if matches!(
inner.state,
CompanionState::Idle | CompanionState::Listening
) {
return;
}
let previous = inner.state;
if !is_valid_transition(previous, CompanionState::Idle) {
return;
}
inner.state = CompanionState::Idle;
let session_id = inner.id.clone();
drop(guard);
events::emit_state_changed(&session_id, CompanionState::Idle, previous);
}
/// Return a pre-STT/empty-transcript turn from `Listening` to `Idle`.
///
/// The caller must ensure no newer microphone capture owns `Listening`.
pub fn finish_listening_turn() {
let mut guard = ACTIVE_SESSION.lock();
let Some(inner) = guard.as_mut() else {
return;
};
if inner.state != CompanionState::Listening {
return;
}
inner.state = CompanionState::Idle;
let session_id = inner.id.clone();
drop(guard);
events::emit_state_changed(&session_id, CompanionState::Idle, CompanionState::Listening);
}
/// Add a conversation turn to the session history.
pub fn push_conversation_turn(turn: ConversationTurn) -> Result<(), String> {
let mut guard = ACTIVE_SESSION.lock();
@@ -8,8 +8,10 @@ use super::*;
fn with_clean_session<F: FnOnce()>(f: F) {
let _lock = lock_test_state();
reset_for_test();
let _ = events::take_recorded_events();
f();
reset_for_test();
let _ = events::take_recorded_events();
}
fn start_default_session() -> StartCompanionSessionResult {
@@ -84,6 +86,27 @@ fn stop_session_succeeds() {
});
}
#[test]
fn stop_session_emits_idle_from_active_state() {
with_clean_session(|| {
let session = start_default_session();
transition_state(CompanionState::Listening, None).unwrap();
let _ = events::take_recorded_events();
let result = stop_session(&StopCompanionSessionParams {
reason: Some("test".into()),
})
.unwrap();
assert!(result.stopped);
let events = events::take_recorded_events();
assert_eq!(events.len(), 1);
assert_eq!(events[0].session_id, session.session_id);
assert_eq!(events[0].previous_state, CompanionState::Listening);
assert_eq!(events[0].state, CompanionState::Idle);
});
}
#[test]
fn stop_session_with_no_active_session() {
with_clean_session(|| {
@@ -220,6 +243,19 @@ fn transition_error_to_idle() {
});
}
#[test]
fn failed_turn_recovers_to_idle_and_preserves_error() {
with_clean_session(|| {
let _s = start_default_session();
transition_state(CompanionState::Listening, None).unwrap();
recover_after_turn_error("no audio samples".into());
let status = session_status();
assert_eq!(status.state, CompanionState::Idle);
assert_eq!(status.last_error.as_deref(), Some("no audio samples"));
});
}
#[test]
fn transition_speaking_to_listening_interrupt() {
with_clean_session(|| {
@@ -232,6 +268,32 @@ fn transition_speaking_to_listening_interrupt() {
});
}
#[test]
fn cancelled_old_turn_does_not_reset_interrupting_capture() {
with_clean_session(|| {
let _s = start_default_session();
transition_state(CompanionState::Listening, None).unwrap();
transition_state(CompanionState::Thinking, None).unwrap();
transition_state(CompanionState::Speaking, None).unwrap();
transition_state(CompanionState::Listening, None).unwrap();
finish_turn();
assert_eq!(session_status().state, CompanionState::Listening);
});
}
#[test]
fn pre_stt_listening_turn_can_return_to_idle() {
with_clean_session(|| {
let _s = start_default_session();
transition_state(CompanionState::Listening, None).unwrap();
finish_listening_turn();
assert_eq!(session_status().state, CompanionState::Idle);
});
}
#[test]
fn transition_invalid_idle_to_speaking() {
with_clean_session(|| {
@@ -343,6 +405,29 @@ fn start_session_auto_expires_stale_session() {
});
}
#[test]
fn expire_session_emits_idle_from_active_state() {
with_clean_session(|| {
let session = start_session(&StartCompanionSessionParams {
consent: true,
ttl_secs: Some(1),
})
.unwrap();
transition_state(CompanionState::Thinking, None).unwrap_err();
transition_state(CompanionState::Listening, None).unwrap();
let _ = events::take_recorded_events();
std::thread::sleep(std::time::Duration::from_millis(1100));
assert!(expire_session_if_needed());
assert!(!session_status().active);
let events = events::take_recorded_events();
assert_eq!(events.len(), 1);
assert_eq!(events[0].session_id, session.session_id);
assert_eq!(events[0].previous_state, CompanionState::Listening);
assert_eq!(events[0].state, CompanionState::Idle);
});
}
#[test]
fn start_session_ttl_overflow_guard() {
with_clean_session(|| {
@@ -364,15 +449,3 @@ fn start_session_ttl_overflow_guard() {
);
});
}
// ── is_active helper ──────────────────────────────────────────────────
#[test]
fn companion_state_is_active() {
assert!(!CompanionState::Idle.is_active());
assert!(CompanionState::Listening.is_active());
assert!(CompanionState::Thinking.is_active());
assert!(CompanionState::Speaking.is_active());
assert!(CompanionState::Pointing.is_active());
assert!(!CompanionState::Error.is_active());
}
@@ -1,4 +1,10 @@
//! Shared types for the desktop companion session.
//! Shared types for the desktop companion session (shell-side).
//!
//! Migrated from the former core `desktop_companion::types`. The only
//! behavioural change is the state-changed event: it is now delivered to the
//! frontend as a Tauri event with a **camelCase** payload
//! (`{ sessionId, state, previousState }`) instead of the old Socket.IO
//! `companion:state_changed` snake_case payload.
use serde::{Deserialize, Serialize};
@@ -21,13 +27,6 @@ pub enum CompanionState {
Error,
}
impl CompanionState {
/// Returns `true` for states that represent an active interaction turn.
pub fn is_active(self) -> bool {
!matches!(self, Self::Idle | Self::Error)
}
}
impl std::fmt::Display for CompanionState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
@@ -143,13 +142,13 @@ pub struct StopCompanionSessionResult {
pub reason: Option<String>,
}
/// Event emitted when companion state changes (for Socket.IO bridge).
/// Payload of the `companion://state_changed` Tauri event delivered to the
/// frontend. Serialized **camelCase** to match the JS companion slice /
/// overlay contract (`{ sessionId, state, previousState }`).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CompanionStateChangedEvent {
pub session_id: String,
pub state: CompanionState,
pub previous_state: CompanionState,
/// Optional human-readable message (e.g. error details, response text).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
+21 -5
View File
@@ -40,12 +40,17 @@ pub(crate) async fn register_companion_hotkey(
let register_shortcut = |variant: &str| -> Result<(), String> {
let app_clone = app.clone();
app.global_shortcut()
.on_shortcut(variant, move |_app, _sc, event| {
if event.state == ShortcutState::Pressed {
.on_shortcut(variant, move |_app, _sc, event| match event.state {
ShortcutState::Pressed => {
debug!("[companion] hotkey pressed — emitting companion://activate");
if let Err(e) = app_clone.emit("companion://activate", ()) {
warn!("[companion] emit failed: {e}");
}
crate::companion::handle_hotkey_pressed(app_clone.clone());
}
ShortcutState::Released => {
debug!("[companion] hotkey released");
crate::companion::handle_hotkey_released(app_clone.clone());
}
})
.map_err(|e| format!("Failed to register shortcut '{variant}': {e}"))
@@ -100,8 +105,9 @@ pub(crate) async fn register_companion_hotkey(
}
/// Unregister the global companion hotkey (if any).
#[tauri::command]
pub(crate) async fn unregister_companion_hotkey(app: AppHandle<AppRuntime>) -> Result<(), String> {
pub(crate) fn unregister_companion_hotkey_for_app(
app: &AppHandle<AppRuntime>,
) -> Result<(), String> {
info!("[companion] unregister_companion_hotkey: called");
let state = app.state::<CompanionHotkeyState>();
let mut guard = state.0.lock().unwrap();
@@ -127,10 +133,20 @@ pub(crate) async fn unregister_companion_hotkey(app: AppHandle<AppRuntime>) -> R
Ok(())
}
/// Tauri command wrapper around the shared lifecycle cleanup.
#[tauri::command]
pub(crate) async fn unregister_companion_hotkey(app: AppHandle<AppRuntime>) -> Result<(), String> {
unregister_companion_hotkey_for_app(&app)
}
/// Programmatic companion activation (e.g. from a "Test" button in settings).
#[tauri::command]
pub(crate) async fn companion_activate(app: AppHandle<AppRuntime>) -> Result<(), String> {
info!("[companion] companion_activate: called");
app.emit("companion://activate", ())
.map_err(|e| format!("Failed to emit companion://activate: {e}"))
.map_err(|e| format!("Failed to emit companion://activate: {e}"))?;
// Programmatic activation is a toggle regardless of the configured hotkey
// mode because a button has no corresponding release event.
crate::companion::handle_activation(app.clone());
Ok(())
}
+68 -3
View File
@@ -80,6 +80,19 @@ pub(crate) async fn relay_http_rpc(
url: String,
token: Option<String>,
body: String,
) -> Result<RelayHttpResponse, String> {
post_json_rpc(&url, token.as_deref(), body).await
}
/// Transport core of [`relay_http_rpc`]: POST a JSON body to `url` with an
/// optional bearer, returning the upstream status + body verbatim. Factored out
/// so in-process shell callers (e.g. the desktop companion pipeline) can reach
/// the local core over the same path the renderer's relay uses, without going
/// through the Tauri command boundary.
pub(crate) async fn post_json_rpc(
url: &str,
token: Option<&str>,
body: String,
) -> Result<RelayHttpResponse, String> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
@@ -87,16 +100,16 @@ pub(crate) async fn relay_http_rpc(
.map_err(|e| format!("failed to build HTTP client: {e}"))?;
let mut builder = client
.post(&url)
.post(url)
.header("Content-Type", "application/json")
.body(body);
let bearer = relay_bearer_header(token.as_deref());
let bearer = relay_bearer_header(token);
if let Some(value) = bearer.as_deref() {
builder = builder.header("Authorization", value);
}
let safe_url = redact_url_for_log(&url);
let safe_url = redact_url_for_log(url);
log::debug!(
"[core_rpc][relay] POST {safe_url} (auth={})",
bearer.is_some()
@@ -118,6 +131,58 @@ pub(crate) async fn relay_http_rpc(
Ok(RelayHttpResponse { status, body: text })
}
/// Invoke a core JSON-RPC method against the embedded local core, returning the
/// inner result value. Handles building the JSON-RPC envelope, applying the
/// per-launch bearer, and unwrapping the `{ "result": <v>, "logs": [...] }`
/// `RpcOutcome` envelope some core handlers emit.
pub(crate) async fn call_core_rpc(
method: &str,
params: serde_json::Value,
) -> Result<serde_json::Value, String> {
let url = core_rpc_url_value();
let token = crate::core_process::current_rpc_token();
let envelope = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": params,
});
let resp = post_json_rpc(&url, token.as_deref(), envelope.to_string()).await?;
if !(200..300).contains(&resp.status) {
return Err(format!("core rpc {method} returned status {}", resp.status));
}
let parsed: serde_json::Value = serde_json::from_str(&resp.body)
.map_err(|e| format!("core rpc {method}: invalid JSON response: {e}"))?;
if let Some(err) = parsed.get("error") {
if !err.is_null() {
return Err(format!("core rpc {method} error: {err}"));
}
}
let result = parsed
.get("result")
.cloned()
.ok_or_else(|| format!("core rpc {method}: response has no result field"))?;
Ok(unwrap_rpc_outcome(result))
}
/// Unwrap the `{ "result": <value>, "logs": [...] }` `RpcOutcome` envelope that
/// logged core handlers wrap their payload in; passes bare values through
/// unchanged.
fn unwrap_rpc_outcome(value: serde_json::Value) -> serde_json::Value {
if let Some(obj) = value.as_object() {
if obj.len() == 2
&& obj.contains_key("result")
&& obj.get("logs").map(|l| l.is_array()).unwrap_or(false)
{
return obj
.get("result")
.cloned()
.unwrap_or(serde_json::Value::Null);
}
}
value
}
#[cfg(test)]
mod tests {
use super::relay_bearer_header;
+21 -42
View File
@@ -57,6 +57,7 @@ mod cef_singleton_wait;
#[cfg(any(target_os = "macos", target_os = "linux", test))]
mod cef_stale_reap;
mod claude_code;
mod companion;
mod companion_commands;
mod core_process;
mod core_rpc;
@@ -100,6 +101,7 @@ mod telegram_scanner;
mod webview_accounts;
mod webview_apis;
mod wechat_scanner;
mod whatsapp_data;
mod whatsapp_scanner;
mod window_state;
mod workspace_paths;
@@ -3121,6 +3123,16 @@ pub fn run() {
// requires) from generic `<R: Runtime>` call sites.
cdp::set_cef_app_handle(app.handle().clone());
// Install the app handle for the desktop companion so its session
// state machine can emit `companion://state_changed` events.
companion::setup(app.handle());
// Structured WhatsApp Web data store lives shell-side. Register the
// in-process native handlers so the core agent tools (list/search)
// and the scanner ingest path can reach the SQLite store over the
// native request bus. No handler = graceful degradation core-side.
whatsapp_data::register_native_handlers();
#[cfg(windows)]
{
// `register_all` writes HKCU\Software\Classes\openhuman so the
@@ -3484,48 +3496,6 @@ pub fn run() {
// here would flash the pill on every launch even with always-on
// listening disabled (the default).
// Synthetic-input main-thread executor. enigo's macOS keyboard-layout
// lookup (TSMGetInputSourceProperty) MUST run on the app main thread
// or it traps (`_dispatch_assert_queue_fail`/EXC_BREAKPOINT) and
// crashes the CEF host (Change 1.15, confirmed via crash report). The
// keyboard/mouse tools run on tokio workers, so they dispatch their
// enigo ops here via the native registry; we run each on the real
// main thread through `run_on_main_thread`.
{
use openhuman_core::core::event_bus::register_native_global;
use openhuman_core::openhuman::tools::{
MainThreadInputOp, INPUT_ON_MAIN_THREAD_METHOD,
};
let input_app = app.handle().clone();
register_native_global::<MainThreadInputOp, Result<String, String>, _, _>(
INPUT_ON_MAIN_THREAD_METHOD,
move |req| {
let input_app = input_app.clone();
async move {
let (tx, rx) = tokio::sync::oneshot::channel();
let run = req.run;
input_app
.run_on_main_thread(move || {
// Catch an enigo FFI panic so it can't unwind
// across the app main thread (which would be
// UB / abort). Convert it to a clean Err.
let result = std::panic::catch_unwind(
std::panic::AssertUnwindSafe(run),
)
.unwrap_or_else(|_| {
Err("synthetic input panicked on the main thread".to_string())
});
let _ = tx.send(result);
})
.map_err(|e| format!("run_on_main_thread dispatch failed: {e}"))?;
rx.await
.map_err(|_| "main-thread input op was cancelled".to_string())
}
},
);
log::info!("[computer] registered main-thread synthetic-input executor");
}
// Tray icon setup moved to RunEvent::Ready (see below) — GTK is only
// initialized after the event loop starts, so we must delay tray creation
// until the Ready event fires. Creating the tray here would panic on
@@ -3845,6 +3815,10 @@ pub fn run() {
// and the Save-As fallback needs it there (CodeRabbit on #4127).
artifact_commands::save_artifact_via_dialog,
artifact_commands::download_artifact_to_downloads,
// Structured WhatsApp data (store lives shell-side).
whatsapp_data::whatsapp_data_list_chats,
whatsapp_data::whatsapp_data_list_messages,
whatsapp_data::whatsapp_data_search_messages,
check_core_update,
apply_core_update,
check_app_update,
@@ -3904,6 +3878,11 @@ pub fn run() {
companion_commands::register_companion_hotkey,
companion_commands::unregister_companion_hotkey,
companion_commands::companion_activate,
companion::companion_start_session,
companion::companion_stop_session,
companion::companion_status,
companion::companion_config_get,
companion::companion_config_set,
mcp_commands::mcp_resolve_binary_path,
mcp_commands::mcp_open_client_config,
loopback_oauth::start_loopback_oauth_listener,
+3 -4
View File
@@ -1145,10 +1145,9 @@ impl From<&NotificationBypassPrefs> for NotificationBypassPrefsPayload {
}
/// Title prefix applied to every OS toast fired from an embedded webview.
/// Matches `openhuman_core::webview_notifications::OPENHUMAN_TITLE_PREFIX`
/// — kept inline here so the shell crate doesn't take a build-time dep on
/// the core library. Disambiguates from natively-installed apps (Slack,
/// Discord, Telegram desktop) firing the same message twice.
/// Owned by the shell crate so it doesn't take a build-time dep on the core
/// library. Disambiguates from natively-installed apps (Slack, Discord,
/// Telegram desktop) firing the same message twice.
const OPENHUMAN_TITLE_PREFIX: &str = "OpenHuman: ";
fn slack_scanner_enabled() -> bool {
+91
View File
@@ -0,0 +1,91 @@
//! Process-global WhatsApp data store singleton.
//!
//! One workspace-bound `WhatsAppDataStore` is active at a time, shared by
//! native handlers, scanners, and Tauri commands. When the active workspace
//! changes without relaunching the shell, the singleton is reopened for the new
//! path so user data cannot leak across sessions.
//!
//! # Usage
//!
//! ```ignore
//! // At startup:
//! whatsapp_data::global::init(workspace_dir)?;
//!
//! // In RPC handlers:
//! let store = whatsapp_data::global::store()?;
//! ```
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use super::store::WhatsAppDataStore;
/// Shared, thread-safe reference to the store.
pub type WhatsAppDataStoreRef = Arc<WhatsAppDataStore>;
// `RwLock<Option<…>>` rather than `OnceLock` so tests can swap workspaces
// between runs (each test uses its own temp dir; without reset, the second
// test would attach to a dropped sqlite path). Production callers still get
// strict idempotency: `init` is a no-op once a store is set.
struct WorkspaceStore {
workspace_dir: PathBuf,
store: WhatsAppDataStoreRef,
}
static GLOBAL_STORE: RwLock<Option<WorkspaceStore>> = RwLock::new(None);
/// Initialise the global store for `workspace_dir`.
///
/// Reuses the current instance only when it is bound to the same workspace.
/// A different path atomically replaces it with a freshly opened store.
pub fn init(workspace_dir: PathBuf) -> Result<WhatsAppDataStoreRef, String> {
let mut guard = GLOBAL_STORE
.write()
.map_err(|e| format!("[whatsapp_data:global] write lock poisoned: {e}"))?;
if let Some(existing) = guard
.as_ref()
.filter(|entry| same_workspace(&entry.workspace_dir, &workspace_dir))
{
log::debug!("[whatsapp_data:global] already initialised");
return Ok(Arc::clone(&existing.store));
}
log::info!(
"[whatsapp_data:global] opening store workspace={}",
workspace_dir.display()
);
let store = Arc::new(
WhatsAppDataStore::new(&workspace_dir)
.map_err(|e| format!("[whatsapp_data] store init failed: {e}"))?,
);
*guard = Some(WorkspaceStore {
workspace_dir,
store: Arc::clone(&store),
});
Ok(store)
}
fn same_workspace(current: &Path, requested: &Path) -> bool {
current == requested
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn init_reuses_same_workspace_and_reopens_when_workspace_changes() {
let first_dir = tempfile::tempdir().unwrap();
let second_dir = tempfile::tempdir().unwrap();
let first = init(first_dir.path().to_path_buf()).unwrap();
let same = init(first_dir.path().to_path_buf()).unwrap();
assert!(Arc::ptr_eq(&first, &same));
let second = init(second_dir.path().to_path_buf()).unwrap();
assert!(!Arc::ptr_eq(&first, &second));
assert!(second_dir
.path()
.join("whatsapp_data/whatsapp_data.db")
.exists());
}
}
+145
View File
@@ -0,0 +1,145 @@
//! Shell-side structured WhatsApp Web data store (relocated from the core).
//!
//! Owns the SQLite persistence (`store`), the ingest + list/search business
//! logic (`ops`), the busy/corrupt retry layer (`sqlite_retry`), and a
//! process-global store singleton (`global`). The DB lives at
//! `<workspace_dir>/whatsapp_data/whatsapp_data.db` — the same workspace the
//! core resolves via [`Config`], so the file stays on the internal-path denylist
//! (`security::policy` `WORKSPACE_INTERNAL_DIRS`) and agent tools cannot write it.
//!
//! Two callers reach this store:
//!
//! - **The scanner** (`whatsapp_scanner`) writes via the `whatsapp_data.ingest`
//! native request (see [`register_native_handlers`]).
//! - **The core agent tools** (`openhuman::whatsapp_data::tools`) query via the
//! `whatsapp_data.{list_chats,list_messages,search_messages}` native requests.
//! - **The frontend** reads via the Tauri commands
//! [`whatsapp_data_list_chats`] / [`whatsapp_data_list_messages`] /
//! [`whatsapp_data_search_messages`].
//!
//! The shared DTOs (request/response/row types) are defined once in the core
//! crate (`openhuman_core::openhuman::whatsapp_data::types`) so both sides agree
//! on a single definition and the native-request `TypeId` checks line up.
mod global;
mod ops;
mod sqlite_retry;
mod store;
use std::sync::Arc;
use openhuman_core::openhuman::whatsapp_data::methods;
use openhuman_core::openhuman::whatsapp_data::types::{
IngestRequest, IngestResult, ListChatsRequest, ListMessagesRequest, SearchMessagesRequest,
WhatsAppChat, WhatsAppMessage,
};
use store::WhatsAppDataStore;
/// Lazily open (or return) the shell's whatsapp_data store, bound to the active
/// OpenHuman workspace dir.
///
/// The store is process-global so the scanner, native query handlers, and Tauri
/// commands share one write lock and recovery lifecycle. Every call resolves
/// the active workspace; [`global::init`] reuses the store only when that path
/// still matches, and atomically reopens it after user/reset workspace changes.
pub async fn ensure_store() -> Result<Arc<WhatsAppDataStore>, String> {
let cfg = openhuman_core::openhuman::config::Config::load_or_init()
.await
.map_err(|e| format!("[whatsapp_data] config load failed: {e:#}"))?;
log::debug!(
"[whatsapp_data] ensuring shell store (workspace={})",
cfg.workspace_dir.display()
);
global::init(cfg.workspace_dir.clone())
}
/// Register the in-process native request handlers that bridge the core (agent
/// tools + scanner) to this shell store. Call once during Tauri `setup`.
///
/// Keyed by the method-name constants the core owns
/// (`openhuman_core::openhuman::whatsapp_data::methods`) so the two sides never
/// drift on the string key.
pub fn register_native_handlers() {
use openhuman_core::core::event_bus::register_native_global;
register_native_global::<ListChatsRequest, Vec<WhatsAppChat>, _, _>(
methods::LIST_CHATS,
|req| async move {
let store = ensure_store().await?;
ops::list_chats(&store, req).map_err(|e| format!("{e:#}"))
},
);
register_native_global::<ListMessagesRequest, Vec<WhatsAppMessage>, _, _>(
methods::LIST_MESSAGES,
|req| async move {
let store = ensure_store().await?;
ops::list_messages(&store, req).map_err(|e| format!("{e:#}"))
},
);
register_native_global::<SearchMessagesRequest, Vec<WhatsAppMessage>, _, _>(
methods::SEARCH_MESSAGES,
|req| async move {
let store = ensure_store().await?;
ops::search_messages(&store, req).map_err(|e| format!("{e:#}"))
},
);
register_native_global::<IngestRequest, IngestResult, _, _>(
methods::INGEST,
|req| async move {
let store = ensure_store().await?;
// `{e:#}` renders the full anyhow chain so the underlying SQLite
// cause (locked / malformed / FK) survives to the scanner's log.
ops::ingest(&store, req).map_err(|e| format!("[whatsapp_data] ingest failed: {e:#}"))
},
);
log::info!(
"[whatsapp_data] registered shell native handlers (list_chats / list_messages / search_messages / ingest)"
);
}
// ── Tauri commands (frontend read surface) ───────────────────────────────────
/// List locally-stored WhatsApp chats. Frontend replacement for the former
/// `openhuman.whatsapp_data_list_chats` core RPC.
#[tauri::command]
pub async fn whatsapp_data_list_chats(req: ListChatsRequest) -> Result<Vec<WhatsAppChat>, String> {
log::debug!(
"[whatsapp_data][cmd] list_chats has_account={} limit={:?} offset={:?}",
req.account_id.is_some(),
req.limit,
req.offset
);
let store = ensure_store().await?;
ops::list_chats(&store, req).map_err(|e| format!("[whatsapp_data] list_chats failed: {e:#}"))
}
/// List messages for a chat. Frontend replacement for the former
/// `openhuman.whatsapp_data_list_messages` core RPC.
#[tauri::command]
pub async fn whatsapp_data_list_messages(
req: ListMessagesRequest,
) -> Result<Vec<WhatsAppMessage>, String> {
log::debug!(
"[whatsapp_data][cmd] list_messages has_account={} (chat redacted)",
req.account_id.is_some()
);
let store = ensure_store().await?;
ops::list_messages(&store, req)
.map_err(|e| format!("[whatsapp_data] list_messages failed: {e:#}"))
}
/// Full-text search over stored WhatsApp messages. Frontend-facing companion to
/// the agent's `whatsapp_data_search_messages` tool.
#[tauri::command]
pub async fn whatsapp_data_search_messages(
req: SearchMessagesRequest,
) -> Result<Vec<WhatsAppMessage>, String> {
log::debug!(
"[whatsapp_data][cmd] search_messages has_account={} has_chat={}",
req.account_id.is_some(),
req.chat_id.is_some()
);
let store = ensure_store().await?;
ops::search_messages(&store, req)
.map_err(|e| format!("[whatsapp_data] search_messages failed: {e:#}"))
}
@@ -5,12 +5,10 @@
use anyhow::Result;
use crate::openhuman::whatsapp_data::{
store::WhatsAppDataStore,
types::{
IngestRequest, IngestResult, ListChatsRequest, ListMessagesRequest, SearchMessagesRequest,
WhatsAppChat, WhatsAppMessage,
},
use super::store::WhatsAppDataStore;
use openhuman_core::openhuman::whatsapp_data::types::{
IngestRequest, IngestResult, ListChatsRequest, ListMessagesRequest, SearchMessagesRequest,
WhatsAppChat, WhatsAppMessage,
};
/// Number of seconds in 90 days — the auto-prune horizon.
@@ -90,7 +88,7 @@ pub fn search_messages(
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::whatsapp_data::types::{ChatMeta, IngestMessage};
use openhuman_core::openhuman::whatsapp_data::types::{ChatMeta, IngestMessage};
use std::collections::HashMap;
use tempfile::tempdir;
@@ -1,7 +1,6 @@
//! SQLite busy/locked detection and retry-with-backoff for WhatsApp data writes.
//!
//! Modelled on [`crate::openhuman::memory_queue::worker::is_sqlite_busy`] —
//! the configured `busy_timeout` absorbs short waits inside rusqlite; this layer
//! The configured `busy_timeout` absorbs short waits inside rusqlite; this layer
//! catches residual `SQLITE_BUSY` / `SQLITE_LOCKED` after that window.
use std::thread;
@@ -41,16 +40,13 @@ pub fn is_sqlite_busy(err: &anyhow::Error) -> bool {
/// re-opens the dead file, re-hits the error, and re-reports to Sentry —
/// turning one corrupt file into an escalating flood (Sentry TAURI-RUST-KNH:
/// 1,813 events from a single host). Detecting it here is what lets the store
/// drive its quarantine + rebuild recovery
/// ([`WhatsAppDataStore::recover_corrupt_db`](crate::openhuman::whatsapp_data::store::WhatsAppDataStore))
/// instead of retrying forever.
/// drive its quarantine + rebuild recovery instead of retrying forever.
///
/// Matching on the error **code** is rusqlite-version-stable. The text
/// fallback covers the case where the rusqlite error was flattened to a plain
/// `anyhow!` string across `.context()` layers — SQLite renders these as
/// "database disk image is malformed" (code 11) and "file is not a database"
/// (code 26). The `"disk image is malformed"` substring matches both the bare
/// and `Error code 11: The database disk image is malformed` envelope shapes.
/// (code 26).
pub fn is_sqlite_corrupt(err: &anyhow::Error) -> bool {
if let Some(rusqlite::Error::SqliteFailure(sqlite_err, _)) =
err.downcast_ref::<rusqlite::Error>()
@@ -161,10 +157,6 @@ mod tests {
assert!(is_sqlite_corrupt(&anyhow::Error::from(raw)));
}
/// The rusqlite error sits under the `[whatsapp_data] ingest failed:` +
/// `upsert wa_chat …` context layers when it bubbles out of the store; the
/// downcast must still find the `DatabaseCorrupt` code (regression guard:
/// don't rely on the top-level error type).
#[test]
fn is_sqlite_corrupt_matches_through_context_layers() {
let raw = rusqlite::Error::SqliteFailure(
@@ -180,8 +172,6 @@ mod tests {
assert!(is_sqlite_corrupt(&wrapped));
}
/// Text fallback: the exact flattened Sentry string (TAURI-RUST-KNH) must
/// classify even when no rusqlite error is available to downcast.
#[test]
fn is_sqlite_corrupt_text_fallback() {
let err = anyhow::anyhow!(
@@ -191,8 +181,6 @@ mod tests {
assert!(is_sqlite_corrupt(&err));
}
/// Busy/locked and constraint violations must NOT be swallowed as
/// corruption — quarantining on those would destroy a perfectly good DB.
#[test]
fn is_sqlite_corrupt_does_not_match_busy_or_constraint() {
let busy = rusqlite::Error::SqliteFailure(
@@ -13,10 +13,8 @@ use std::sync::Mutex;
use anyhow::{Context, Result};
use rusqlite::{params, Connection};
use crate::openhuman::whatsapp_data::sqlite_retry::{
is_sqlite_corrupt, retry_on_sqlite_busy, BUSY_TIMEOUT,
};
use crate::openhuman::whatsapp_data::types::{
use super::sqlite_retry::{is_sqlite_corrupt, retry_on_sqlite_busy, BUSY_TIMEOUT};
use openhuman_core::openhuman::whatsapp_data::types::{
ChatMeta, IngestMessage, ListChatsRequest, ListMessagesRequest, SearchMessagesRequest,
WhatsAppChat, WhatsAppMessage,
};
@@ -233,7 +231,7 @@ impl WhatsAppDataStore {
// latch the scanner's 230s poll re-hits the wedged DB and re-pages on
// every tick (TAURI-RUST-KNH: 1,813 events from one host).
if !CORRUPT_REPORTED.swap(true, Ordering::Relaxed) {
crate::core::observability::report_error(
openhuman_core::core::observability::report_error(
err,
"whatsapp_data",
"ingest_corrupt",
@@ -5,10 +5,10 @@
//! for one `account_id` must never appear in queries for a different account.
use super::super::sqlite_retry::BUSY_TIMEOUT;
use super::super::types::{
use super::WhatsAppDataStore;
use openhuman_core::openhuman::whatsapp_data::types::{
ChatMeta, IngestMessage, ListChatsRequest, ListMessagesRequest, SearchMessagesRequest,
};
use super::WhatsAppDataStore;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::mpsc;
+26 -57
View File
@@ -1050,11 +1050,12 @@ async fn post_whatsapp_data_ingest(
})
.unwrap_or_default();
// Split messages into chunks to stay well under the HTTP body size limit.
// Chats are sent only with the first batch (upserts are idempotent).
// Split messages into chunks, preserving the historical batching/dedup
// behavior: chats are sent only with the first batch (upserts are
// idempotent), and the store's 90-day prune runs per ingest call. The
// store now lives in the shell, so each batch dispatches over the
// in-process native request bus instead of an HTTP JSON-RPC POST.
const BATCH_SIZE: usize = 500;
let empty_chats = Value::Object(serde_json::Map::new());
let url = crate::core_rpc::core_rpc_url_value();
// Build at least one batch even when messages is empty (chats-only upsert).
let chunks: Vec<&[Value]> = if messages.is_empty() {
@@ -1077,65 +1078,33 @@ async fn post_whatsapp_data_ingest(
let batch_chats = if batch_idx == 0 {
Value::Object(chats_param.clone())
} else {
empty_chats.clone()
Value::Object(serde_json::Map::new())
};
let params = json!({
"account_id": account_id,
"chats": batch_chats,
"messages": chunk,
});
let body = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "openhuman.whatsapp_data_ingest",
"params": params,
});
let mut last_err = String::new();
let mut succeeded = false;
for attempt in 1u8..=2 {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.build()
.map_err(|e| format!("http client: {e}"))?;
let req = crate::core_rpc::apply_auth(client.post(&url))
.map_err(|e| format!("prepare {url}: {e}"))?;
let send_result = req.json(&body).send().await;
match send_result {
Err(e) if e.is_connect() || e.is_timeout() => {
last_err = format!("POST {url}: {e}");
if attempt < 2 {
log::debug!(
"[wa][{}] whatsapp_data_ingest connect error batch={}/{} attempt {}: {}",
account_id,
batch_idx + 1,
total_batches,
attempt,
last_err
);
tokio::time::sleep(Duration::from_millis(500)).await;
}
continue;
}
Err(e) => return Err(format!("POST {url}: {e}")),
Ok(resp) => {
let status = resp.status();
if !status.is_success() {
let body_text = resp.text().await.unwrap_or_default();
return Err(format!("{status}: {body_text}"));
}
let v: Value = resp.json().await.map_err(|e| format!("decode: {e}"))?;
if let Some(err) = v.get("error") {
return Err(format!("rpc error: {err}"));
}
succeeded = true;
break;
}
}
}
if !succeeded {
return Err(last_err);
}
// Deserialize into the shared core DTO and dispatch to the shell store's
// native handler registered in `whatsapp_data::register_native_handlers`.
let req: openhuman_core::openhuman::whatsapp_data::types::IngestRequest =
serde_json::from_value(params)
.map_err(|e| format!("build ingest request (batch {}): {e}", batch_idx + 1))?;
openhuman_core::core::event_bus::request_native_global::<
openhuman_core::openhuman::whatsapp_data::types::IngestRequest,
openhuman_core::openhuman::whatsapp_data::types::IngestResult,
>(
openhuman_core::openhuman::whatsapp_data::methods::INGEST,
req,
)
.await
.map_err(|e| {
format!(
"whatsapp_data ingest batch {}/{}: {e}",
batch_idx + 1,
total_batches
)
})?;
}
log::debug!(
@@ -1,650 +0,0 @@
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { CouncilDefinition } from '../../services/api/councilRegistryApi';
import type { CouncilMemberResult, ModelCouncilResult } from '../../services/api/modelCouncilApi';
import ModelCouncilTab from './ModelCouncilTab';
const mockListCouncils = vi.fn();
const mockUpsertCouncil = vi.fn();
const mockDeleteCouncil = vi.fn();
const mockAnswerMember = vi.fn();
const mockSynthesizeCouncil = vi.fn();
const mockLoadAISettings = vi.fn();
const mockLoadLocalProviderSnapshot = vi.fn();
const mockListProviderModels = vi.fn();
const mockDispatch = vi.fn();
const mockState = {
agentProfiles: {
profiles: [
{
id: 'default',
name: 'Default Agent',
description: 'Default',
agentId: 'openhuman.default',
modelOverride: 'profile-model',
builtIn: true,
},
{
id: 'critic',
name: 'Critic',
description: 'Finds gaps',
agentId: 'critic-agent',
modelOverride: 'critic-model',
builtIn: false,
},
],
activeProfileId: 'default',
status: 'idle',
error: null,
},
};
vi.mock('../../services/api/modelCouncilApi', () => ({
modelCouncilApi: {
answerMember: (...args: unknown[]) => mockAnswerMember(...args),
synthesizeCouncil: (...args: unknown[]) => mockSynthesizeCouncil(...args),
},
}));
vi.mock('../../services/api/councilRegistryApi', () => ({
councilRegistryApi: {
list: (...args: unknown[]) => mockListCouncils(...args),
upsert: (...args: unknown[]) => mockUpsertCouncil(...args),
delete: (...args: unknown[]) => mockDeleteCouncil(...args),
},
}));
vi.mock('../../services/api/aiSettingsApi', () => ({
loadAISettings: (...args: unknown[]) => mockLoadAISettings(...args),
loadLocalProviderSnapshot: (...args: unknown[]) => mockLoadLocalProviderSnapshot(...args),
listProviderModels: (...args: unknown[]) => mockListProviderModels(...args),
}));
vi.mock('../../store/hooks', () => ({
useAppDispatch: () => mockDispatch,
useAppSelector: (selector: (state: typeof mockState) => unknown) => selector(mockState),
}));
vi.mock('../../features/human/Mascot', () => ({
RiveMascot: ({ face }: { face?: string }) => <div data-testid="rive-mascot" data-face={face} />,
getMascotPalette: () => ({ bodyFill: '#F7D145', neckShadowColor: '#B23C05' }),
hexToArgbInt: () => 0xfff7d145,
}));
const RESULT: ModelCouncilResult = {
question: 'What is the capital of France?',
members: [
{ model: 'gpt-5.2', response: 'Paris is the capital.', error: null },
{ model: 'critic-model', response: null, error: 'rate limited' },
],
chair_model: 'claude-opus-4-8',
synthesis: 'Both that answered agree: Paris. One seat failed.',
};
const DEFAULT_MEMBERS: CouncilMemberResult[] = [
{ model: 'reasoning-v1', response: 'Paris is the capital.', error: null },
{ model: 'reasoning-v1', response: 'France uses Paris as its capital.', error: null },
{ model: 'reasoning-v1', response: 'The answer is Paris.', error: null },
];
const DEFAULT_COUNCIL: CouncilDefinition = {
id: 'default-council',
name: 'Default council',
description: 'Balanced analyst, builder, and skeptic jury.',
jury_count: 3,
debate_rounds: 3,
seats: [
{
id: 0,
mode: 'default',
profile_id: '',
name: 'Analyst',
model: 'reasoning-v1',
brief: 'Evidence, assumptions, and risk.',
},
{
id: 1,
mode: 'default',
profile_id: '',
name: 'Builder',
model: 'reasoning-v1',
brief: 'Practical implementation path.',
},
{
id: 2,
mode: 'default',
profile_id: '',
name: 'Skeptic',
model: 'reasoning-v1',
brief: 'Failure modes and missing context.',
},
],
judge: { mode: 'default', profile_id: '', name: 'Chief Judge', model: 'reasoning-v1' },
shared_reasoning: [
'# Shared reasoning',
'- Claims the council agrees on:',
'- Open disagreements:',
'- Evidence or constraints to preserve:',
'- Judge synthesis notes:',
].join('\n'),
created_at_ms: 1,
updated_at_ms: 1,
};
const fillQuestion = () => {
fireEvent.change(screen.getByLabelText('Question'), {
target: { value: 'What is the capital of France?' },
});
};
const mockProgressiveSuccess = (members: CouncilMemberResult[] = DEFAULT_MEMBERS) => {
mockAnswerMember.mockImplementation(async ({ model }: { model: string }) => {
const index = mockAnswerMember.mock.calls.length - 1;
return members[index] ?? { model, response: `answer ${index + 1}`, error: null };
});
mockSynthesizeCouncil.mockResolvedValue(RESULT);
};
const renderCouncilList = async () => {
render(<ModelCouncilTab />);
await screen.findByRole('button', { name: 'Open council' });
};
const renderOpenCouncil = async () => {
await renderCouncilList();
fireEvent.click(screen.getByRole('button', { name: 'Open council' }));
await screen.findByLabelText('Question');
};
const renderEditCouncil = async () => {
await renderOpenCouncil();
fireEvent.click(screen.getByRole('button', { name: 'Edit current council' }));
await screen.findByLabelText('Council name');
};
const saveCouncilSettings = async () => {
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Save council' }));
});
await screen.findByLabelText('Question');
};
describe('ModelCouncilTab', () => {
beforeEach(() => {
mockListCouncils.mockReset();
mockUpsertCouncil.mockReset();
mockDeleteCouncil.mockReset();
mockAnswerMember.mockReset();
mockSynthesizeCouncil.mockReset();
mockLoadAISettings.mockReset();
mockLoadLocalProviderSnapshot.mockReset();
mockListProviderModels.mockReset();
mockDispatch.mockReset();
mockListCouncils.mockResolvedValue([DEFAULT_COUNCIL]);
mockUpsertCouncil.mockImplementation(async council => ({
...council,
id: council.id || 'saved',
}));
mockDeleteCouncil.mockResolvedValue(true);
mockLoadAISettings.mockResolvedValue({
cloudProviders: [
{
id: 'openai-id',
slug: 'openai',
label: 'OpenAI',
endpoint: 'https://api.openai.com/v1',
auth_style: 'bearer',
has_api_key: true,
},
{
id: 'anthropic-id',
slug: 'anthropic',
label: 'Anthropic',
endpoint: 'https://api.anthropic.com/v1',
auth_style: 'anthropic',
has_api_key: false,
},
],
routing: {},
});
mockLoadLocalProviderSnapshot.mockResolvedValue({
status: null,
diagnostics: null,
presets: null,
installedModels: [{ name: 'llama3.2:latest', chat_capable: true }],
});
mockListProviderModels.mockImplementation(async (provider: string) => {
if (provider === 'openhuman') return [{ id: 'managed-reasoning' }];
if (provider === 'openai') return [{ id: 'gpt-4o' }, { id: 'gpt-4o-mini' }];
return [];
});
});
it('renders the council list first, then opens the default council', async () => {
await renderCouncilList();
expect(screen.getByText('Councils')).toBeInTheDocument();
expect(screen.getByText('Default council')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Open council' }));
await screen.findByLabelText('Question');
expect(screen.getByText('Default council')).toBeInTheDocument();
expect(screen.queryByText('Council settings')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Debate turns')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Shared reasoning file')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Convene council' })).toBeInTheDocument();
});
it('allows the default council to be deleted from the persisted registry', async () => {
await renderCouncilList();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Delete Default council' }));
});
expect(mockDeleteCouncil).toHaveBeenCalledWith('default-council');
expect(screen.queryByText('Default council')).not.toBeInTheDocument();
expect(screen.getByText('No councils yet. Add one to get started.')).toBeInTheDocument();
});
it('uses the jury count setting to resize the roster up to five', async () => {
await renderEditCouncil();
expect(screen.queryByLabelText('Question')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Convene council' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '5' }));
expect(screen.getAllByTestId('rive-mascot')).toHaveLength(5);
expect(screen.getAllByText('Juror 5')).toHaveLength(2);
expect(screen.getByLabelText('Juror 5 name')).toBeInTheDocument();
});
it('disables Convene until a question is filled because seats and judge have defaults', async () => {
await renderOpenCouncil();
const run = screen.getByRole('button', { name: 'Convene council' });
expect(run).toBeDisabled();
fillQuestion();
expect(run).not.toBeDisabled();
});
it('shows mascot deliberation and agent thoughts while the council is running', async () => {
let resolveFirst: (value: CouncilMemberResult) => void = () => {};
let resolveSecond: (value: CouncilMemberResult) => void = () => {};
let resolveThird: (value: CouncilMemberResult) => void = () => {};
let resolveSynthesis: (value: ModelCouncilResult) => void = () => {};
mockAnswerMember
.mockImplementation(async ({ model }: { model: string }) => ({
model,
response: `follow-up thought ${mockAnswerMember.mock.calls.length}`,
error: null,
}))
.mockReturnValueOnce(
new Promise<CouncilMemberResult>(resolve => {
resolveFirst = resolve;
})
)
.mockReturnValueOnce(
new Promise<CouncilMemberResult>(resolve => {
resolveSecond = resolve;
})
)
.mockReturnValueOnce(
new Promise<CouncilMemberResult>(resolve => {
resolveThird = resolve;
})
);
mockSynthesizeCouncil.mockReturnValueOnce(
new Promise<ModelCouncilResult>(resolve => {
resolveSynthesis = resolve;
})
);
await renderOpenCouncil();
fillQuestion();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Convene council' }));
});
expect(screen.getByText('Council deliberation')).toBeInTheDocument();
expect(screen.getAllByText('Thinking')).toHaveLength(3);
expect(screen.getByText('Judge')).toBeInTheDocument();
expect(
screen.getByText(/Waiting for juror answers, then reading the shared reasoning file/)
).toBeInTheDocument();
expect(screen.getAllByTestId('rive-mascot')).toHaveLength(4);
expect(screen.getAllByTestId('rive-mascot')[0]).toHaveAttribute('data-face', 'thinking');
await act(async () => {
resolveFirst({
model: 'reasoning-v1',
response: 'First juror live thought: Paris.',
error: null,
});
});
expect(screen.getByText('First juror live thought: Paris.')).toBeInTheDocument();
expect(screen.getByText('Round 1')).toBeInTheDocument();
expect(screen.getAllByText('Thinking')).toHaveLength(2);
expect(screen.getByText('Answered')).toBeInTheDocument();
expect(screen.getByText('Judge')).toBeInTheDocument();
await act(async () => {
resolveSecond({ model: 'reasoning-v1', response: 'Second juror agrees.', error: null });
resolveThird({ model: 'reasoning-v1', response: 'Third juror agrees.', error: null });
});
await waitFor(() => {
expect(screen.getByText('Synthesizing')).toBeInTheDocument();
});
await act(async () => {
resolveSynthesis(RESULT);
});
await waitFor(() => {
expect(screen.queryByText('Council deliberation')).not.toBeInTheDocument();
});
});
it('streams failed juror status without blocking other juror thoughts', async () => {
let resolveFirst: (value: CouncilMemberResult) => void = () => {};
let resolveSecond: (value: CouncilMemberResult) => void = () => {};
let resolveThird: (value: CouncilMemberResult) => void = () => {};
mockAnswerMember
.mockImplementation(async ({ model }: { model: string }) => ({
model,
response: `follow-up answer ${mockAnswerMember.mock.calls.length}`,
error: null,
}))
.mockReturnValueOnce(
new Promise<CouncilMemberResult>(resolve => {
resolveFirst = resolve;
})
)
.mockReturnValueOnce(
new Promise<CouncilMemberResult>(resolve => {
resolveSecond = resolve;
})
)
.mockReturnValueOnce(
new Promise<CouncilMemberResult>(resolve => {
resolveThird = resolve;
})
);
mockSynthesizeCouncil.mockResolvedValueOnce(RESULT);
await renderOpenCouncil();
fillQuestion();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Convene council' }));
});
await act(async () => {
resolveFirst({ model: 'reasoning-v1', response: null, error: 'rate limited' });
});
expect(screen.getByText('rate limited')).toBeInTheDocument();
expect(screen.getByText('Failed')).toBeInTheDocument();
expect(screen.getAllByText('Thinking')).toHaveLength(2);
await act(async () => {
resolveSecond({ model: 'reasoning-v1', response: 'Second juror answer.', error: null });
resolveThird({ model: 'reasoning-v1', response: 'Third juror answer.', error: null });
});
await waitFor(() => {
expect(mockSynthesizeCouncil).toHaveBeenCalledWith({
question: expect.any(String),
members: [
{
model: 'reasoning-v1',
response: expect.stringContaining('[failed: rate limited]'),
error: null,
},
{
model: 'reasoning-v1',
response: expect.stringContaining('Second juror answer.'),
error: null,
},
{
model: 'reasoning-v1',
response: expect.stringContaining('Third juror answer.'),
error: null,
},
],
chair_model: 'reasoning-v1',
});
});
});
it('appends juror turns to the shared scratchpad before the next debate round', async () => {
mockAnswerMember.mockImplementation(async ({ model }: { model: string }) => ({
model,
response: `round ${mockAnswerMember.mock.calls.length} update`,
error: null,
}));
mockSynthesizeCouncil.mockResolvedValueOnce(RESULT);
await renderOpenCouncil();
fillQuestion();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Convene council' }));
});
await waitFor(() => {
expect(mockSynthesizeCouncil).toHaveBeenCalled();
});
expect(mockAnswerMember.mock.calls[3][0].question).toContain('Round 1 updates');
expect(mockAnswerMember.mock.calls[3][0].question).toContain('round 1 update');
});
it('lets a juror model be selected from routing hints', async () => {
mockProgressiveSuccess();
await renderEditCouncil();
fireEvent.click(screen.getByLabelText('Member model 1'));
expect(screen.getByRole('dialog', { name: 'Member model 1' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Reasoning/ }));
await saveCouncilSettings();
fillQuestion();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Convene council' }));
});
expect(mockAnswerMember.mock.calls.map(call => call[0].model).slice(0, 3)).toEqual([
'hint:reasoning',
'reasoning-v1',
'reasoning-v1',
]);
});
it('enables provider and model dropdowns only after choosing Custom', async () => {
await renderEditCouncil();
fireEvent.click(screen.getByLabelText('Member model 1'));
const dialog = screen.getByRole('dialog', { name: 'Member model 1' });
const providerSelect = within(dialog).getByLabelText('Model provider');
const modelSelect = within(dialog).getByLabelText('Model id');
expect(providerSelect).toBeDisabled();
expect(modelSelect).toBeDisabled();
fireEvent.click(within(dialog).getByRole('button', { name: /Provider \+ model/ }));
await waitFor(() => expect(providerSelect).not.toBeDisabled());
expect(
within(providerSelect).getByRole('option', { name: 'Managed (openhuman)' })
).toBeInTheDocument();
expect(
within(providerSelect).getByRole('option', { name: 'OpenAI (openai)' })
).toBeInTheDocument();
expect(
within(providerSelect).queryByRole('option', { name: 'Anthropic (anthropic)' })
).not.toBeInTheDocument();
await waitFor(() => expect(modelSelect).not.toBeDisabled());
expect(
within(modelSelect).getByRole('option', { name: 'managed-reasoning' })
).toBeInTheDocument();
fireEvent.change(providerSelect, { target: { value: 'openai' } });
await waitFor(() => {
expect(within(modelSelect).getByRole('option', { name: 'gpt-4o' })).toBeInTheDocument();
});
fireEvent.change(modelSelect, { target: { value: 'gpt-4o' } });
fireEvent.click(within(dialog).getByRole('button', { name: 'Use provider model' }));
expect(screen.getByLabelText('Member model 1')).toHaveTextContent('openai:gpt-4o');
});
it('lets a council seat use a saved profile and submits that profile model', async () => {
mockProgressiveSuccess();
await renderEditCouncil();
const firstSeat = screen.getByLabelText('Juror 1 name').closest('article');
expect(firstSeat).not.toBeNull();
fireEvent.click(within(firstSeat as HTMLElement).getByRole('tab', { name: 'Profile' }));
fireEvent.change(screen.getByLabelText('Juror 1 profile'), { target: { value: 'critic' } });
await saveCouncilSettings();
fillQuestion();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Convene council' }));
});
expect(mockAnswerMember.mock.calls.map(call => call[0].model)).toEqual([
'critic-model',
'reasoning-v1',
'reasoning-v1',
'critic-model',
'reasoning-v1',
'reasoning-v1',
'critic-model',
'reasoning-v1',
'reasoning-v1',
]);
expect(mockSynthesizeCouncil).toHaveBeenCalledWith({
question: expect.stringContaining('shared_reasoning.md'),
members: expect.any(Array),
chair_model: 'reasoning-v1',
});
expect(mockAnswerMember.mock.calls[0][0].question).toContain('User question:');
expect(mockAnswerMember.mock.calls[0][0].question).toContain('What is the capital of France?');
expect(mockAnswerMember.mock.calls[0][0].question).toContain('Debate round 1 of 3.');
expect(mockAnswerMember.mock.calls[8][0].question).toContain('Debate round 3 of 3.');
});
it('lets the judge agent use a saved profile unless a model override is typed', async () => {
mockProgressiveSuccess();
await renderEditCouncil();
fireEvent.change(screen.getByLabelText('Judge agent'), { target: { value: 'profile' } });
fireEvent.change(screen.getByLabelText('Judge profile'), { target: { value: 'critic' } });
await saveCouncilSettings();
fillQuestion();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Convene council' }));
});
expect(mockAnswerMember.mock.calls.map(call => call[0].model)).toEqual([
'reasoning-v1',
'reasoning-v1',
'reasoning-v1',
'reasoning-v1',
'reasoning-v1',
'reasoning-v1',
'reasoning-v1',
'reasoning-v1',
'reasoning-v1',
]);
expect(mockSynthesizeCouncil).toHaveBeenCalledWith({
question: expect.any(String),
members: expect.any(Array),
chair_model: 'critic-model',
});
});
it('renders member answers side-by-side + the synthesis', async () => {
mockProgressiveSuccess(RESULT.members);
await renderOpenCouncil();
fillQuestion();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Convene council' }));
});
await waitFor(() => {
expect(screen.getByText('Council results')).toBeInTheDocument();
});
expect(screen.getByText('Paris is the capital.')).toBeInTheDocument();
expect(screen.getByText('rate limited')).toBeInTheDocument();
expect(screen.getByText('Answered')).toBeInTheDocument();
expect(screen.getByText('Failed')).toBeInTheDocument();
expect(
screen.getByText('Both that answered agree: Paris. One seat failed.')
).toBeInTheDocument();
expect(screen.getByText('by claude-opus-4-8')).toBeInTheDocument();
expect(screen.getByText('Debate usage')).toBeInTheDocument();
expect(screen.getByText('Total')).toBeInTheDocument();
});
it('renders council markdown instead of showing raw markdown markers', async () => {
mockProgressiveSuccess([
{ model: 'reasoning-v1', response: '**Paris** is the capital.', error: null },
{ model: 'reasoning-v1', response: '- France\n- Paris', error: null },
{ model: 'reasoning-v1', response: '`Paris` remains the answer.', error: null },
]);
mockSynthesizeCouncil.mockResolvedValueOnce({
...RESULT,
members: [
{ model: 'reasoning-v1', response: '**Paris** is the capital.', error: null },
{ model: 'reasoning-v1', response: '- France\n- Paris', error: null },
],
synthesis: '## Consensus\n\nThe answer is **Paris**.',
});
await renderOpenCouncil();
fillQuestion();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Convene council' }));
});
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Consensus' })).toBeInTheDocument();
});
const results = screen.getByText('Council results').closest('section');
expect(results).not.toBeNull();
expect(screen.getAllByText('Paris').some(node => node.tagName.toLowerCase() === 'strong')).toBe(
true
);
expect(within(results as HTMLElement).queryByText(/\*\*Paris\*\*/)).not.toBeInTheDocument();
});
it('surfaces an error alert when the council run fails', async () => {
mockAnswerMember.mockResolvedValue({
model: 'reasoning-v1',
response: null,
error: 'downstream',
});
mockSynthesizeCouncil.mockRejectedValueOnce(new Error('all member models failed to respond'));
await renderOpenCouncil();
fillQuestion();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Convene council' }));
});
await waitFor(() => {
const alert = screen.getByRole('alert');
expect(alert.textContent).toMatch(/all member models failed to respond/);
});
expect(screen.queryByText('Council results')).not.toBeInTheDocument();
});
});
File diff suppressed because it is too large Load Diff
@@ -58,7 +58,7 @@ describe('oauthAuthReadiness', () => {
activeMode: 'os_keyring',
backendName: 'os',
},
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
runtime: { screenIntelligence: null, localAi: null, service: null },
},
teams: [],
teamMembersById: {},
@@ -125,7 +125,7 @@ describe('oauthAuthReadiness', () => {
activeMode: 'os_keyring',
backendName: 'os',
},
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
runtime: { screenIntelligence: null, localAi: null, service: null },
},
teams: [],
teamMembersById: {},
@@ -21,8 +21,6 @@ export type SettingsRoute =
| 'account'
| 'cron-jobs'
| 'screen-intelligence'
| 'desktop-agent'
| 'autocomplete'
| 'privacy'
| 'billing'
| 'team'
@@ -35,7 +33,6 @@ export type SettingsRoute =
| 'recovery-phrase'
| 'wallet-balances'
| 'screen-awareness-debug'
| 'autocomplete-debug'
| 'local-model-debug'
| 'notifications'
| 'notification-routing'
@@ -117,7 +117,6 @@ export const SETTINGS_NAV_ICONS: Record<string, ReactNode> = {
)
),
'screen-awareness-debug': icon(stroke('M3 5h18v12H3zM8 21h8m-4-4v4')),
'autocomplete-debug': icon(stroke('M4 6h16M4 10h10M4 14h7m3 4h3m0 0l-2-2m2 2l-2 2')),
// Automation & Integrations
tasks: icon(
stroke(
@@ -1,779 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import {
type AcceptedCompletion,
type AutocompleteConfig,
type AutocompleteStatus,
isTauri,
openhumanAutocompleteAccept,
openhumanAutocompleteClearHistory,
openhumanAutocompleteCurrent,
openhumanAutocompleteDebugFocus,
openhumanAutocompleteHistory,
openhumanAutocompleteSetStyle,
openhumanAutocompleteStart,
openhumanAutocompleteStatus,
openhumanAutocompleteStop,
openhumanGetConfig,
} from '../../../utils/tauriCommands';
import Button from '../../ui/Button';
import Input from '../../ui/Input';
import { SettingsSection, SettingsStatusLine, SettingsTextArea } from '../controls';
import SettingsPanel from '../layout/SettingsPanel';
const DEFAULT_CONFIG: AutocompleteConfig = {
enabled: false,
debounce_ms: 120,
max_chars: 384,
style_preset: 'balanced',
style_instructions: null,
style_examples: [],
disabled_apps: [],
accept_with_tab: true,
overlay_ttl_ms: 1100,
};
const MAX_LOG_ENTRIES = 200;
const parseAutocompleteConfig = (raw: unknown): AutocompleteConfig => {
if (!raw || typeof raw !== 'object') {
return DEFAULT_CONFIG;
}
const value = raw as Record<string, unknown>;
return {
enabled: typeof value.enabled === 'boolean' ? value.enabled : DEFAULT_CONFIG.enabled,
debounce_ms:
typeof value.debounce_ms === 'number' ? value.debounce_ms : DEFAULT_CONFIG.debounce_ms,
max_chars: typeof value.max_chars === 'number' ? value.max_chars : DEFAULT_CONFIG.max_chars,
style_preset:
typeof value.style_preset === 'string' ? value.style_preset : DEFAULT_CONFIG.style_preset,
style_instructions:
typeof value.style_instructions === 'string' ? value.style_instructions : null,
style_examples: Array.isArray(value.style_examples)
? value.style_examples.filter((entry): entry is string => typeof entry === 'string')
: DEFAULT_CONFIG.style_examples,
disabled_apps: Array.isArray(value.disabled_apps)
? value.disabled_apps.filter((entry): entry is string => typeof entry === 'string')
: DEFAULT_CONFIG.disabled_apps,
accept_with_tab:
typeof value.accept_with_tab === 'boolean'
? value.accept_with_tab
: DEFAULT_CONFIG.accept_with_tab,
overlay_ttl_ms:
typeof value.overlay_ttl_ms === 'number'
? value.overlay_ttl_ms
: DEFAULT_CONFIG.overlay_ttl_ms,
};
};
const AutocompleteDebugPanel = () => {
const { t } = useT();
// Status & loading
const [status, setStatus] = useState<AutocompleteStatus | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
// Advanced settings form state (dev-facing fields only)
const [debounceMs, setDebounceMs] = useState<string>(String(DEFAULT_CONFIG.debounce_ms));
const [maxChars, setMaxChars] = useState<string>(String(DEFAULT_CONFIG.max_chars));
const [overlayTtlMs, setOverlayTtlMs] = useState<string>(String(DEFAULT_CONFIG.overlay_ttl_ms));
const [styleInstructions, setStyleInstructions] = useState<string>('');
const [styleExamplesText, setStyleExamplesText] = useState<string>('');
// Test section
const [contextOverride, setContextOverride] = useState<string>('');
const [focusDebug, setFocusDebug] = useState<string>('');
// Live logs
const [logs, setLogs] = useState<string[]>([]);
const isMountedRef = useRef(true);
const previousStatusRef = useRef<AutocompleteStatus | null>(null);
// Personalization history
const [historyEntries, setHistoryEntries] = useState<AcceptedCompletion[]>([]);
const [isHistoryLoading, setIsHistoryLoading] = useState(false);
const [isClearingHistory, setIsClearingHistory] = useState(false);
// -------------------------------------------------------------------------
// Logging helpers
// -------------------------------------------------------------------------
const appendLogs = (entries: string[]) => {
if (entries.length === 0) return;
if (!isMountedRef.current) return;
const now = new Date();
const stamp = `${now.toLocaleTimeString()}.${String(now.getMilliseconds()).padStart(3, '0')}`;
setLogs(current =>
[...current, ...entries.map(entry => `${stamp} ${entry}`)].slice(-MAX_LOG_ENTRIES)
);
};
const appendUiLog = (entry: string) => {
appendLogs([`[ui-flow] ${entry}`]);
};
const trackStatusChanges = (next: AutocompleteStatus) => {
const previous = previousStatusRef.current;
if (!previous) {
previousStatusRef.current = next;
appendLogs([
`[runtime] phase=${next.phase} running=${next.running ? 'yes' : 'no'} enabled=${next.enabled ? 'yes' : 'no'}`,
]);
return;
}
const nextEntries: string[] = [];
if (next.phase !== previous.phase) {
nextEntries.push(`phase ${previous.phase} -> ${next.phase}`);
}
if ((next.last_error ?? '') !== (previous.last_error ?? '') && next.last_error) {
nextEntries.push(`error: ${next.last_error}`);
}
if (
(next.suggestion?.value ?? '') !== (previous.suggestion?.value ?? '') &&
next.suggestion?.value
) {
nextEntries.push(`suggestion ready: "${next.suggestion.value}"`);
}
if (nextEntries.length > 0) {
appendLogs(nextEntries);
}
previousStatusRef.current = next;
};
// -------------------------------------------------------------------------
// Data loading
// -------------------------------------------------------------------------
const load = async () => {
if (!isTauri()) return;
setIsLoading(true);
setError(null);
try {
const [statusResponse, configResponse] = await Promise.all([
openhumanAutocompleteStatus(),
openhumanGetConfig(),
]);
setStatus(statusResponse.result);
trackStatusChanges(statusResponse.result);
appendLogs(statusResponse.logs);
const config = parseAutocompleteConfig(
(configResponse.result.config as Record<string, unknown> | undefined)?.autocomplete
);
setDebounceMs(String(config.debounce_ms));
setMaxChars(String(config.max_chars));
setOverlayTtlMs(String(config.overlay_ttl_ms));
setStyleInstructions(config.style_instructions ?? '');
setStyleExamplesText(config.style_examples.join('\n'));
} catch (err) {
setError(
err instanceof Error ? err.message : t('settings.autocomplete.debug.loadSettingsFailed')
);
} finally {
setIsLoading(false);
}
};
const loadHistory = async (): Promise<AcceptedCompletion[]> => {
if (!isTauri()) return [];
if (!isMountedRef.current) return [];
setIsHistoryLoading(true);
try {
const response = await openhumanAutocompleteHistory({ limit: 20 });
if (!isMountedRef.current) return response.result.entries;
setHistoryEntries(response.result.entries);
return response.result.entries;
} catch {
// Non-critical — silently ignore
return [];
} finally {
if (isMountedRef.current) {
setIsHistoryLoading(false);
}
}
};
useEffect(() => {
isMountedRef.current = true;
void load();
void loadHistory();
return () => {
isMountedRef.current = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// -------------------------------------------------------------------------
// Status polling
// -------------------------------------------------------------------------
const refreshStatus = async (showSpinner = false) => {
if (!isTauri()) return null;
if (showSpinner) {
setIsLoading(true);
setError(null);
}
try {
const response = await openhumanAutocompleteStatus();
if (!isMountedRef.current) return response.result;
setStatus(response.result);
trackStatusChanges(response.result);
if (showSpinner) {
appendLogs(response.logs);
}
return response.result;
} catch (err) {
const msg =
err instanceof Error ? err.message : t('settings.autocomplete.debug.refreshStatusFailed');
appendUiLog(`refresh status failed: ${msg}`);
setError(msg);
return null;
} finally {
if (showSpinner) {
setIsLoading(false);
}
}
};
useEffect(() => {
if (!isTauri()) return;
const intervalId = window.setInterval(() => {
void refreshStatus();
}, 1200);
return () => window.clearInterval(intervalId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// -------------------------------------------------------------------------
// Runtime controls
// -------------------------------------------------------------------------
const start = async () => {
if (!isTauri()) return;
setError(null);
setMessage(null);
try {
const debounce = Number(debounceMs);
appendUiLog(`start requested (debounce=${String(debounce)}ms)`);
const response = await openhumanAutocompleteStart({
debounce_ms: Number.isFinite(debounce) ? Math.min(Math.max(debounce, 50), 2000) : 120,
});
appendLogs(response.logs);
const latestStatus = await refreshStatus();
if (response.result.started) {
setMessage(t('autocomplete.started'));
} else if (latestStatus?.enabled === false) {
setMessage(t('settings.autocomplete.debug.disabledInSettings'));
} else if (latestStatus?.running) {
setMessage(t('settings.autocomplete.debug.alreadyRunning'));
} else {
setMessage(t('settings.autocomplete.debug.didNotStart'));
}
} catch (err) {
const msg = err instanceof Error ? err.message : t('settings.autocomplete.debug.startFailed');
appendUiLog(`start failed: ${msg}`);
setError(msg);
}
};
const stop = async () => {
if (!isTauri()) return;
setError(null);
setMessage(null);
try {
appendUiLog('stop requested');
const response = await openhumanAutocompleteStop({ reason: 'manual_stop_from_settings' });
appendLogs(response.logs);
const latestStatus = await refreshStatus();
setMessage(t('autocomplete.stopped'));
if (latestStatus?.running) {
appendUiLog('runtime still reports running after stop');
}
} catch (err) {
const msg = err instanceof Error ? err.message : t('settings.autocomplete.debug.stopFailed');
appendUiLog(`stop failed: ${msg}`);
setError(msg);
}
};
// -------------------------------------------------------------------------
// Test actions
// -------------------------------------------------------------------------
const testCurrent = async () => {
if (!isTauri()) return;
setError(null);
setMessage(null);
try {
appendUiLog(
contextOverride.trim()
? `get suggestion requested (override chars=${String(contextOverride.trim().length)})`
: 'get suggestion requested (focused app context)'
);
const response = await openhumanAutocompleteCurrent({
context: contextOverride.trim() || undefined,
});
appendLogs(response.logs);
setMessage(
response.result.suggestion?.value
? t('settings.autocomplete.debug.suggestionPrefix').replace(
'{value}',
response.result.suggestion.value
)
: t('settings.autocomplete.debug.noSuggestionReturned')
);
await refreshStatus();
} catch (err) {
const msg =
err instanceof Error ? err.message : t('settings.autocomplete.debug.fetchSuggestionFailed');
appendUiLog(`get suggestion failed: ${msg}`);
setError(msg);
}
};
const waitForAcceptedHistoryEntry = async (acceptedValue?: string | null) => {
if (!acceptedValue) {
await loadHistory();
return;
}
const normalized = acceptedValue.trim();
if (!normalized) {
await loadHistory();
return;
}
const maxAttempts = 6;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const entries = await loadHistory();
const found = entries.some(entry => entry.suggestion.trim() === normalized);
if (found) {
return;
}
if (attempt < maxAttempts - 1) {
await new Promise(resolve => globalThis.setTimeout(resolve, 180));
}
if (!isMountedRef.current) {
return;
}
}
};
const acceptSuggestion = async () => {
if (!isTauri()) return;
setError(null);
setMessage(null);
try {
appendUiLog('accept suggestion requested');
const response = await openhumanAutocompleteAccept({
suggestion: status?.suggestion?.value ?? undefined,
skip_apply: true,
});
if (!isMountedRef.current) return;
appendLogs(response.logs);
if (response.result.accepted && response.result.value) {
setMessage(
t('settings.autocomplete.debug.acceptedPrefix').replace('{value}', response.result.value)
);
} else {
setMessage(response.result.reason ?? t('settings.autocomplete.debug.noSuggestionApplied'));
}
await refreshStatus();
await waitForAcceptedHistoryEntry(response.result.value);
} catch (err) {
const msg =
err instanceof Error ? err.message : t('settings.autocomplete.debug.acceptFailed');
appendUiLog(`accept failed: ${msg}`);
setError(msg);
}
};
const debugFocus = async () => {
if (!isTauri()) return;
setError(null);
try {
appendUiLog('debug focus requested');
const response = await openhumanAutocompleteDebugFocus();
appendLogs(response.logs);
setFocusDebug(JSON.stringify(response.result, null, 2));
if (response.result) {
appendUiLog(
`focus app=${response.result.app_name ?? 'n/a'} role=${response.result.role ?? 'n/a'} chars=${String(response.result.context.length)}`
);
} else {
appendUiLog('focus debug returned no focused element');
}
} catch (err) {
const msg =
err instanceof Error
? err.message
: t('settings.autocomplete.debug.inspectFocusedElementFailed');
appendUiLog(`debug focus failed: ${msg}`);
setError(msg);
}
};
// -------------------------------------------------------------------------
// Advanced settings save
// -------------------------------------------------------------------------
const saveAdvancedConfig = async () => {
if (!isTauri()) return;
setIsSaving(true);
setError(null);
setMessage(null);
try {
appendUiLog('saving advanced autocomplete settings');
const debounce = Number(debounceMs);
const max = Number(maxChars);
const ttl = Number(overlayTtlMs);
const response = await openhumanAutocompleteSetStyle({
debounce_ms: Number.isFinite(debounce) ? Math.min(Math.max(debounce, 50), 2000) : 120,
max_chars: Number.isFinite(max) ? Math.min(Math.max(max, 32), 1200) : 384,
overlay_ttl_ms: Number.isFinite(ttl) ? Math.min(Math.max(ttl, 300), 10000) : 1100,
style_instructions: styleInstructions.trim() || undefined,
style_examples: styleExamplesText
.split('\n')
.map(entry => entry.trim())
.filter(Boolean),
});
setDebounceMs(String(response.result.config.debounce_ms));
setMaxChars(String(response.result.config.max_chars));
setOverlayTtlMs(String(response.result.config.overlay_ttl_ms));
setStyleInstructions(response.result.config.style_instructions ?? '');
setStyleExamplesText(response.result.config.style_examples.join('\n'));
appendLogs(response.logs);
setMessage(t('autocomplete.settingsSaved'));
await refreshStatus();
} catch (err) {
const msg =
err instanceof Error
? err.message
: t('settings.autocomplete.debug.saveAdvancedSettingsFailed');
appendUiLog(`save advanced settings failed: ${msg}`);
setError(msg);
} finally {
setIsSaving(false);
}
};
// -------------------------------------------------------------------------
// History controls
// -------------------------------------------------------------------------
const clearHistory = async () => {
if (!isTauri()) return;
setIsClearingHistory(true);
try {
await openhumanAutocompleteClearHistory();
setHistoryEntries([]);
} catch (err) {
setError(
err instanceof Error ? err.message : t('settings.autocomplete.debug.clearHistoryFailed')
);
} finally {
setIsClearingHistory(false);
}
};
const clearLogs = () => {
setLogs([]);
previousStatusRef.current = status;
};
// -------------------------------------------------------------------------
// Render
// -------------------------------------------------------------------------
return (
<SettingsPanel description={t('settings.developerMenu.autocomplete.desc')}>
{/* ------------------------------------------------------------------ */}
{/* Runtime section */}
{/* ------------------------------------------------------------------ */}
<SettingsSection title={t('settings.autocomplete.appFilter.runtime')}>
<div className="px-4 py-3 space-y-3">
<div className="text-sm text-content space-y-1">
<div>
{t('settings.autocomplete.appFilter.platformSupported')}:{' '}
{status?.platform_supported ? t('common.yes') : t('common.no')}
</div>
<div>
{t('settings.autocomplete.appFilter.enabled')}:{' '}
{status?.enabled ? t('common.yes') : t('common.no')}
</div>
<div>
{t('settings.autocomplete.appFilter.running')}:{' '}
{status?.running ? t('common.yes') : t('common.no')}
</div>
<div>
{t('settings.autocomplete.appFilter.phase')}:{' '}
{status?.phase ?? t('settings.autocomplete.shared.unknown')}
</div>
<div>
{t('settings.autocomplete.appFilter.debounce')}:{' '}
{`${String(status?.debounce_ms ?? 0)}ms`}
</div>
<div>
{t('settings.autocomplete.appFilter.model')}:{' '}
{status?.model_id ?? t('settings.autocomplete.shared.notApplicable')}
</div>
<div>
{t('settings.autocomplete.appFilter.app')}:{' '}
{status?.app_name ?? t('settings.autocomplete.shared.notApplicable')}
</div>
<div>
{t('settings.autocomplete.appFilter.lastError')}:{' '}
{status?.last_error ?? t('settings.autocomplete.shared.none')}
</div>
<div>
{t('settings.autocomplete.appFilter.currentSuggestion')}:{' '}
{status?.suggestion?.value ?? t('settings.autocomplete.shared.none')}
</div>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => void refreshStatus(true)}
disabled={isLoading}>
{isLoading
? t('settings.autocomplete.appFilter.refreshing')
: t('settings.autocomplete.appFilter.refreshStatus')}
</Button>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => void start()}
disabled={!status?.platform_supported || Boolean(status?.running)}>
{t('autocomplete.start')}
</Button>
<Button
type="button"
variant="secondary"
tone="danger"
size="sm"
onClick={() => void stop()}
disabled={!status?.running}>
{t('autocomplete.stop')}
</Button>
</div>
</div>
</SettingsSection>
{/* ------------------------------------------------------------------ */}
{/* Test section */}
{/* ------------------------------------------------------------------ */}
<SettingsSection title={t('settings.autocomplete.appFilter.test')}>
<div className="px-4 py-3 space-y-3">
<div className="space-y-1">
<div className="text-xs text-content-muted">
{t('settings.autocomplete.appFilter.contextOverride')}
</div>
<SettingsTextArea
value={contextOverride}
onChange={event => setContextOverride(event.target.value)}
rows={3}
aria-label={t('settings.autocomplete.appFilter.contextOverride')}
/>
</div>
<div className="flex gap-2">
<Button type="button" variant="secondary" size="sm" onClick={() => void testCurrent()}>
{t('settings.autocomplete.appFilter.getSuggestion')}
</Button>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => void acceptSuggestion()}>
{t('settings.autocomplete.appFilter.acceptSuggestion')}
</Button>
<Button type="button" variant="secondary" size="sm" onClick={() => void debugFocus()}>
{t('settings.autocomplete.appFilter.debugFocus')}
</Button>
</div>
{focusDebug && (
<pre className="max-h-48 overflow-auto rounded-xl border border-line bg-surface-muted p-2 text-xs text-content">
{focusDebug}
</pre>
)}
</div>
</SettingsSection>
{/* ------------------------------------------------------------------ */}
{/* Live Logs section */}
{/* ------------------------------------------------------------------ */}
<SettingsSection title={t('settings.autocomplete.appFilter.liveLogs')}>
<div className="px-4 py-3 space-y-3">
<div className="flex justify-end">
<Button type="button" variant="secondary" size="xs" onClick={clearLogs}>
{t('common.clear')}
</Button>
</div>
{/* Bespoke log-stream display — kept intact */}
<pre className="max-h-56 overflow-auto rounded-xl border border-line bg-surface-muted p-2 text-xs text-content">
{logs.length > 0 ? logs.join('\n') : t('settings.autocomplete.appFilter.noLogs')}
</pre>
</div>
</SettingsSection>
{/* ------------------------------------------------------------------ */}
{/* Advanced settings */}
{/* ------------------------------------------------------------------ */}
<SettingsSection title={t('autocomplete.advancedSettings')}>
<div className="px-4 py-3 space-y-3">
<label className="flex items-center justify-between rounded-xl border border-line bg-surface-muted px-3 py-2">
<span className="text-sm text-content">
{t('settings.autocomplete.completionStyle.debounce')}
</span>
<Input
type="number"
inputSize="sm"
min={50}
max={2000}
step={10}
value={debounceMs}
onChange={event => setDebounceMs(event.target.value)}
className="w-28"
aria-label={t('settings.autocomplete.completionStyle.debounce')}
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-line bg-surface-muted px-3 py-2">
<span className="text-sm text-content">
{t('settings.autocomplete.completionStyle.maxChars')}
</span>
<Input
type="number"
inputSize="sm"
min={32}
max={1200}
step={8}
value={maxChars}
onChange={event => setMaxChars(event.target.value)}
className="w-28"
aria-label={t('settings.autocomplete.completionStyle.maxChars')}
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-line bg-surface-muted px-3 py-2">
<span className="text-sm text-content">
{t('settings.autocomplete.completionStyle.overlayTtl')}
</span>
<Input
type="number"
inputSize="sm"
min={300}
max={10000}
step={100}
value={overlayTtlMs}
onChange={event => setOverlayTtlMs(event.target.value)}
className="w-28"
aria-label={t('settings.autocomplete.completionStyle.overlayTtl')}
/>
</label>
<div className="space-y-1">
<div className="text-xs text-content-muted">
{t('settings.autocomplete.completionStyle.styleInstructions')}
</div>
<SettingsTextArea
value={styleInstructions}
onChange={event => setStyleInstructions(event.target.value)}
rows={3}
aria-label={t('settings.autocomplete.completionStyle.styleInstructions')}
/>
</div>
<div className="space-y-1">
<div className="text-xs text-content-muted">
{t('settings.autocomplete.completionStyle.styleExamples')}
</div>
<SettingsTextArea
value={styleExamplesText}
onChange={event => setStyleExamplesText(event.target.value)}
rows={3}
aria-label={t('settings.autocomplete.completionStyle.styleExamples')}
/>
</div>
<Button
type="button"
variant="primary"
size="sm"
onClick={() => void saveAdvancedConfig()}
disabled={isSaving}>
{isSaving ? t('autocomplete.saving') : t('common.save')}
</Button>
</div>
</SettingsSection>
{/* ------------------------------------------------------------------ */}
{/* Personalization History */}
{/* ------------------------------------------------------------------ */}
<SettingsSection title={t('settings.autocomplete.completionStyle.personalizationHistory')}>
<div className="px-4 py-3 space-y-3">
<div className="flex justify-end">
<Button
type="button"
variant="secondary"
tone="danger"
size="xs"
onClick={() => void clearHistory()}
disabled={isClearingHistory || historyEntries.length === 0}>
{isClearingHistory
? t('settings.autocomplete.completionStyle.clearing')
: t('settings.autocomplete.completionStyle.clearHistory')}
</Button>
</div>
<p className="text-xs text-content-muted">
{isHistoryLoading
? t('common.loading')
: historyEntries.length === 0
? t('settings.autocomplete.completionStyle.noHistory')
: (historyEntries.length === 1
? t('settings.autocomplete.completionStyle.acceptedCompletion')
: t('settings.autocomplete.completionStyle.acceptedCompletions')
).replace('{count}', String(historyEntries.length))}
</p>
{/* Bespoke history list — kept intact */}
{historyEntries.length > 0 && (
<div className="max-h-48 overflow-y-auto rounded-xl border border-line bg-surface-muted p-2 space-y-1">
{historyEntries.map((entry, idx) => (
<div
key={`${String(entry.timestamp_ms)}-${String(idx)}`}
className="flex flex-col gap-0.5 rounded-lg bg-surface px-2 py-1.5 text-xs border border-line-subtle">
<div className="flex items-center gap-2 text-content-muted">
<span className="shrink-0">
{new Date(entry.timestamp_ms).toLocaleString()}
</span>
{entry.app_name && (
<span className="rounded bg-surface-subtle px-1 text-content-muted">
{entry.app_name}
</span>
)}
</div>
<div className="flex items-baseline gap-1 text-content truncate">
<span className="shrink-0 text-content-muted"></span>
<span className="truncate text-content-muted">{entry.context.slice(-40)}</span>
<span className="shrink-0 text-content-muted"></span>
<span className="font-medium text-primary-500 truncate">
{entry.suggestion}
</span>
</div>
</div>
))}
</div>
)}
</div>
</SettingsSection>
{/* ------------------------------------------------------------------ */}
{/* Feedback messages */}
{/* ------------------------------------------------------------------ */}
<SettingsStatusLine saving={false} savedNote={message} error={error} savingLabel="" />
</SettingsPanel>
);
};
export default AutocompleteDebugPanel;
@@ -1,396 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import {
type AutocompleteConfig,
type AutocompleteStatus,
isTauri,
openhumanAutocompleteSetStyle,
openhumanAutocompleteStart,
openhumanAutocompleteStatus,
openhumanAutocompleteStop,
openhumanGetConfig,
} from '../../../utils/tauriCommands';
import Button from '../../ui/Button';
import {
SettingsNumberField,
SettingsRow,
SettingsSection,
SettingsSelect,
SettingsStatusLine,
SettingsSwitch,
SettingsTextArea,
} from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import SettingsPanel from '../layout/SettingsPanel';
const DEFAULT_CONFIG: AutocompleteConfig = {
enabled: false,
debounce_ms: 120,
max_chars: 384,
style_preset: 'balanced',
style_instructions: null,
style_examples: [],
disabled_apps: [],
accept_with_tab: true,
overlay_ttl_ms: 1100,
};
const parseAutocompleteConfig = (raw: unknown): AutocompleteConfig => {
if (!raw || typeof raw !== 'object') {
return DEFAULT_CONFIG;
}
const value = raw as Record<string, unknown>;
return {
enabled: typeof value.enabled === 'boolean' ? value.enabled : DEFAULT_CONFIG.enabled,
debounce_ms:
typeof value.debounce_ms === 'number' ? value.debounce_ms : DEFAULT_CONFIG.debounce_ms,
max_chars: typeof value.max_chars === 'number' ? value.max_chars : DEFAULT_CONFIG.max_chars,
style_preset:
typeof value.style_preset === 'string' ? value.style_preset : DEFAULT_CONFIG.style_preset,
style_instructions:
typeof value.style_instructions === 'string' ? value.style_instructions : null,
style_examples: Array.isArray(value.style_examples)
? value.style_examples.filter((entry): entry is string => typeof entry === 'string')
: DEFAULT_CONFIG.style_examples,
disabled_apps: Array.isArray(value.disabled_apps)
? value.disabled_apps.filter((entry): entry is string => typeof entry === 'string')
: DEFAULT_CONFIG.disabled_apps,
accept_with_tab:
typeof value.accept_with_tab === 'boolean'
? value.accept_with_tab
: DEFAULT_CONFIG.accept_with_tab,
overlay_ttl_ms:
typeof value.overlay_ttl_ms === 'number'
? value.overlay_ttl_ms
: DEFAULT_CONFIG.overlay_ttl_ms,
};
};
const AutocompletePanel = () => {
const { t } = useT();
const { navigateToSettings } = useSettingsNavigation();
const [status, setStatus] = useState<AutocompleteStatus | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [enabled, setEnabled] = useState<boolean>(DEFAULT_CONFIG.enabled);
const [stylePreset, setStylePreset] = useState<string>(DEFAULT_CONFIG.style_preset);
const [disabledAppsText, setDisabledAppsText] = useState<string>(
DEFAULT_CONFIG.disabled_apps.join('\n')
);
const [acceptWithTab, setAcceptWithTab] = useState<boolean>(DEFAULT_CONFIG.accept_with_tab);
// Tuning fields are kept as raw input strings so the user can clear a field
// mid-edit (e.g. 800 → "" → 512) without it snapping to the minimum on every
// keystroke; they are parsed and clamped to safe values at save time.
const [debounceMs, setDebounceMs] = useState<string>(String(DEFAULT_CONFIG.debounce_ms));
const [maxChars, setMaxChars] = useState<string>(String(DEFAULT_CONFIG.max_chars));
const [overlayTtlMs, setOverlayTtlMs] = useState<string>(String(DEFAULT_CONFIG.overlay_ttl_ms));
const fullConfigRef = useRef<AutocompleteConfig>(DEFAULT_CONFIG);
const [configLoaded, setConfigLoaded] = useState(false);
const load = async () => {
if (!isTauri()) return;
setError(null);
try {
const [statusResponse, configResponse] = await Promise.all([
openhumanAutocompleteStatus(),
openhumanGetConfig(),
]);
setStatus(statusResponse.result);
const config = parseAutocompleteConfig(
(configResponse.result.config as Record<string, unknown> | undefined)?.autocomplete
);
fullConfigRef.current = config;
setConfigLoaded(true);
setEnabled(config.enabled);
setStylePreset(config.style_preset);
setDisabledAppsText(config.disabled_apps.join('\n'));
setAcceptWithTab(config.accept_with_tab);
setDebounceMs(String(config.debounce_ms));
setMaxChars(String(config.max_chars));
setOverlayTtlMs(String(config.overlay_ttl_ms));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load autocomplete settings');
}
};
useEffect(() => {
void load();
}, []);
const refreshStatus = async () => {
if (!isTauri()) return;
try {
const response = await openhumanAutocompleteStatus();
setStatus(response.result);
} catch {
// Non-critical
}
};
useEffect(() => {
if (!isTauri()) return;
const intervalId = window.setInterval(() => {
void refreshStatus();
}, 1200);
return () => window.clearInterval(intervalId);
}, []);
const saveConfig = async () => {
if (!isTauri()) return;
setIsSaving(true);
setError(null);
setMessage(null);
try {
const prev = fullConfigRef.current;
// Parse + clamp the raw input strings only at save time so intermediate
// empty/partial values are allowed while typing.
const parsedDebounce = Math.max(0, Math.trunc(Number(debounceMs)) || 0);
const parsedMaxChars = Math.max(1, Math.trunc(Number(maxChars)) || DEFAULT_CONFIG.max_chars);
const parsedOverlayTtl = Math.max(0, Math.trunc(Number(overlayTtlMs)) || 0);
const response = await openhumanAutocompleteSetStyle({
enabled,
debounce_ms: parsedDebounce,
max_chars: parsedMaxChars,
style_preset: stylePreset.trim() || 'balanced',
style_instructions: prev.style_instructions ?? undefined,
style_examples: prev.style_examples,
disabled_apps: disabledAppsText
.split('\n')
.map(entry => entry.trim())
.filter(Boolean),
accept_with_tab: acceptWithTab,
overlay_ttl_ms: parsedOverlayTtl,
});
fullConfigRef.current = response.result.config;
setEnabled(response.result.config.enabled);
setStylePreset(response.result.config.style_preset);
setDisabledAppsText(response.result.config.disabled_apps.join('\n'));
setAcceptWithTab(response.result.config.accept_with_tab);
setDebounceMs(String(response.result.config.debounce_ms));
setMaxChars(String(response.result.config.max_chars));
setOverlayTtlMs(String(response.result.config.overlay_ttl_ms));
setMessage(t('autocomplete.settingsSaved'));
await refreshStatus();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save autocomplete settings');
} finally {
setIsSaving(false);
}
};
const start = async () => {
if (!isTauri()) return;
setError(null);
setMessage(null);
try {
const response = await openhumanAutocompleteStart({
debounce_ms: fullConfigRef.current.debounce_ms,
});
await refreshStatus();
if (response.result.started) {
setMessage(t('autocomplete.started'));
} else {
setMessage(t('autocomplete.didNotStart'));
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to start autocomplete');
}
};
const stop = async () => {
if (!isTauri()) return;
setError(null);
setMessage(null);
try {
await openhumanAutocompleteStop({ reason: 'manual_stop_from_settings' });
await refreshStatus();
setMessage(t('autocomplete.stopped'));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to stop autocomplete');
}
};
return (
<SettingsPanel>
{/* ── Settings ──────────────────────────────────────────────── */}
<SettingsSection title={t('autocomplete.settings')}>
<SettingsRow
label={t('common.enabled')}
control={
<SettingsSwitch
id="autocomplete-enabled"
checked={enabled}
onCheckedChange={setEnabled}
aria-label={t('common.enabled')}
/>
}
/>
<SettingsRow
label={t('autocomplete.acceptWithTab')}
control={
<SettingsSwitch
id="autocomplete-accept-with-tab"
checked={acceptWithTab}
onCheckedChange={setAcceptWithTab}
aria-label={t('autocomplete.acceptWithTab')}
/>
}
/>
<SettingsRow
htmlFor="autocomplete-style-preset"
label={t('autocomplete.stylePreset')}
control={
<SettingsSelect
id="autocomplete-style-preset"
value={stylePreset}
onChange={e => setStylePreset(e.target.value)}>
<option value="balanced">{t('autocomplete.style.balanced')}</option>
<option value="concise">{t('autocomplete.style.concise')}</option>
<option value="formal">{t('autocomplete.style.formal')}</option>
<option value="casual">{t('autocomplete.style.casual')}</option>
<option value="custom">{t('autocomplete.style.custom')}</option>
</SettingsSelect>
}
/>
<SettingsRow
htmlFor="autocomplete-disabled-apps"
label={t('autocomplete.disabledApps')}
stacked
control={
<SettingsTextArea
id="autocomplete-disabled-apps"
value={disabledAppsText}
onChange={e => setDisabledAppsText(e.target.value)}
rows={3}
/>
}
/>
<SettingsRow
label={t('autocomplete.debounceMs')}
control={
<SettingsNumberField
id="autocomplete-debounce-ms-field"
value={debounceMs}
onChange={setDebounceMs}
onCommit={() => void saveConfig()}
unit="ms"
min={0}
max={5000}
aria-label={t('autocomplete.debounceMs')}
data-testid="autocomplete-debounce-ms"
/>
}
/>
<SettingsRow
label={t('autocomplete.maxChars')}
control={
<SettingsNumberField
id="autocomplete-max-chars-field"
value={maxChars}
onChange={setMaxChars}
onCommit={() => void saveConfig()}
unit={t('autocomplete.chars')}
min={1}
max={8192}
aria-label={t('autocomplete.maxChars')}
data-testid="autocomplete-max-chars"
/>
}
/>
<SettingsRow
label={t('autocomplete.overlayTtlMs')}
control={
<SettingsNumberField
id="autocomplete-overlay-ttl-ms-field"
value={overlayTtlMs}
onChange={setOverlayTtlMs}
onCommit={() => void saveConfig()}
unit="ms"
min={0}
max={30000}
aria-label={t('autocomplete.overlayTtlMs')}
data-testid="autocomplete-overlay-ttl-ms"
/>
}
/>
<div className="flex items-center gap-2 px-4 py-3">
<Button
type="button"
variant="primary"
size="sm"
onClick={() => void saveConfig()}
disabled={isSaving || !configLoaded}>
{isSaving ? t('autocomplete.saving') : t('autocomplete.saveSettings')}
</Button>
</div>
</SettingsSection>
{/* ── Runtime ────────────────────────────────────────────────── */}
<SettingsSection title={t('autocomplete.runtime')}>
<div className="px-4 py-3 text-sm text-content-secondary space-y-1">
<div>
{t('autocomplete.running')}: {status?.running ? t('common.yes') : t('common.no')}
</div>
<div>
{t('common.enabled')}: {status?.enabled ? t('common.yes') : t('common.no')}
</div>
</div>
<div className="flex gap-2 px-4 pb-3">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => void start()}
disabled={!configLoaded || !status?.platform_supported || Boolean(status?.running)}>
{t('autocomplete.start')}
</Button>
<Button
type="button"
variant="secondary"
tone="danger"
size="sm"
onClick={() => void stop()}
disabled={!status?.running}>
{t('autocomplete.stop')}
</Button>
</div>
</SettingsSection>
{/* ── Status messages ─────────────────────────────────────────── */}
<SettingsStatusLine
saving={isSaving}
savedNote={message}
error={error}
savingLabel={t('autocomplete.saving')}
/>
{/* ── Advanced link ────────────────────────────────────────────── */}
<Button
type="button"
variant="tertiary"
size="xs"
onClick={() => navigateToSettings('autocomplete-debug')}
trailingIcon={
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
}>
{t('autocomplete.advancedSettings')}
</Button>
</SettingsPanel>
);
};
export default AutocompletePanel;
@@ -1,7 +1,7 @@
import { invoke } from '@tauri-apps/api/core';
import { useCallback, useEffect, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { callCoreRpc } from '../../../services/coreRpcClient';
import type {
CompanionConfig,
CompanionSessionStatus,
@@ -9,6 +9,7 @@ import type {
StopCompanionSessionResult,
} from '../../../store/companionSlice';
import { useAppSelector } from '../../../store/hooks';
import { isTauri } from '../../../utils/tauriCommands/common';
import Button from '../../ui/Button';
import { SettingsRow, SettingsSection, SettingsStatusLine } from '../controls';
import SettingsPanel from '../layout/SettingsPanel';
@@ -25,10 +26,9 @@ const CompanionPanel = () => {
const [error, setError] = useState<string | null>(null);
const fetchStatus = useCallback(async () => {
if (!isTauri()) return;
try {
const result = await callCoreRpc<CompanionSessionStatus>({
method: 'openhuman.companion_status',
});
const result = await invoke<CompanionSessionStatus>('companion_status');
setStatus(result);
setError(null);
} catch (err) {
@@ -37,10 +37,9 @@ const CompanionPanel = () => {
}, []);
const fetchConfig = useCallback(async () => {
if (!isTauri()) return;
try {
const result = await callCoreRpc<CompanionConfig>({
method: 'openhuman.companion_config_get',
});
const result = await invoke<CompanionConfig>('companion_config_get');
setConfig(result);
} catch {
// Config fetch is best-effort — defaults shown if unavailable.
@@ -66,12 +65,20 @@ const CompanionPanel = () => {
setIsStarting(true);
setError(null);
try {
await callCoreRpc<StartCompanionSessionResult>({
method: 'openhuman.companion_start_session',
params: { consent: true, ttl_secs: config?.ttl_secs ?? 3600 },
});
await invoke<StartCompanionSessionResult>('companion_start_session', { consent: true });
try {
await invoke<void>('register_companion_hotkey', {
shortcut: config?.hotkey ?? 'ctrl+space',
});
} catch (registrationError) {
// A session without its advertised shortcut cannot be used. Roll it
// back so the panel never reports a live but unreachable companion.
await invoke<StopCompanionSessionResult>('companion_stop_session').catch(() => undefined);
throw registrationError;
}
await fetchStatus();
} catch (err) {
await fetchStatus();
setError(err instanceof Error ? err.message : String(err));
} finally {
setIsStarting(false);
@@ -82,10 +89,7 @@ const CompanionPanel = () => {
setIsStopping(true);
setError(null);
try {
await callCoreRpc<StopCompanionSessionResult>({
method: 'openhuman.companion_stop_session',
params: { reason: 'user_requested' },
});
await invoke<StopCompanionSessionResult>('companion_stop_session');
await fetchStatus();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
@@ -1,407 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import {
fetchScreenIntelligenceStatus,
refreshScreenIntelligencePermissionsWithRestart,
requestScreenIntelligencePermission,
} from '../../../features/screen-intelligence/api';
import { useT } from '../../../lib/i18n/I18nContext';
import {
type AccessibilityPermissionKind,
type AccessibilityPermissionState,
type AccessibilityStatus,
openhumanGetAutonomySettings,
openhumanGetVoiceServerSettings,
openhumanUpdateAutonomySettings,
openhumanUpdateVoiceServerSettings,
syncNotchVisibility,
} from '../../../utils/tauriCommands';
import Button from '../../ui/Button';
import {
SettingsBadge,
type SettingsBadgeVariant,
SettingsRow,
SettingsSection,
SettingsSwitch,
} from '../controls';
import SettingsPanel from '../layout/SettingsPanel';
/**
* Desktop Agent setup panel Settings Features Desktop Agent.
*
* Two things in one place, no new backend:
* 1. Check + grant the four OS permissions the desktop agent needs (Microphone,
* Accessibility, Screen Recording, Input Monitoring) reuses the existing
* screen-intelligence permission RPCs.
* 2. "Let the agent act without asking" a seamless-mode toggle that grants Full
* access and auto-approves the desktop-control tools so the agent runs them
* without an in-app approval card (reuses the autonomy RPCs).
*/
/**
* The four permissions the desktop agent depends on, in setup order.
*
* Accessibility / Screen Recording / Input Monitoring intentionally reuse the
* existing `settings.screenIntel.permissions.*` labels (same OS permissions as the
* Screen Intelligence panel) to avoid duplicate translations; only Microphone needs
* a desktop-agent-specific key.
*/
const PERMISSIONS: ReadonlyArray<{ kind: AccessibilityPermissionKind; labelKey: string }> = [
{ kind: 'microphone', labelKey: 'settings.desktopAgent.microphone' },
{ kind: 'accessibility', labelKey: 'settings.screenIntel.permissions.accessibility' },
{ kind: 'screen_recording', labelKey: 'settings.screenIntel.permissions.screenRecording' },
{ kind: 'input_monitoring', labelKey: 'settings.screenIntel.permissions.inputMonitoring' },
];
/**
* Desktop-control tools auto-approved by seamless mode so the agent actuates apps
* without an approval card. Deliberately excludes shell / file / network / install
* tools those keep prompting. Matched by exact tool name in `autonomy.auto_approve`.
*/
const SEAMLESS_TOOLS = ['automate', 'ax_interact', 'launch_app', 'keyboard', 'mouse'] as const;
const STATE_VARIANT: Record<AccessibilityPermissionState, SettingsBadgeVariant> = {
granted: 'success',
denied: 'danger',
unknown: 'warning',
unsupported: 'neutral',
};
const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
const stateFor = (
status: AccessibilityStatus | null,
kind: AccessibilityPermissionKind
): AccessibilityPermissionState => status?.permissions?.[kind] ?? 'unknown';
const DesktopAgentPanel = () => {
const { t } = useT();
const [status, setStatus] = useState<AccessibilityStatus | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [requestingKind, setRequestingKind] = useState<AccessibilityPermissionKind | null>(null);
const [isRestarting, setIsRestarting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [restartSummary, setRestartSummary] = useState<string | null>(null);
// Seamless mode (auto-approve allowlist). Null until the autonomy settings load.
const [autoApprove, setAutoApprove] = useState<string[] | null>(null);
const [isUpdatingSeamless, setIsUpdatingSeamless] = useState(false);
// Always-on listening (relocated from the Voice panel). Null until loaded.
const [alwaysOn, setAlwaysOn] = useState<boolean | null>(null);
const [isUpdatingAlwaysOn, setIsUpdatingAlwaysOn] = useState(false);
const refresh = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
setStatus(await fetchScreenIntelligenceStatus());
} catch (err) {
setError(errorMessage(err));
} finally {
setIsLoading(false);
}
}, []);
const loadAutonomy = useCallback(async () => {
setError(null);
try {
const resp = await openhumanGetAutonomySettings();
setAutoApprove(resp.result.auto_approve ?? []);
} catch (err) {
setError(errorMessage(err));
}
}, []);
const loadVoice = useCallback(async () => {
setError(null);
try {
const resp = await openhumanGetVoiceServerSettings();
setAlwaysOn(resp.result.always_on_enabled);
} catch (err) {
setError(errorMessage(err));
}
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
void refresh();
void loadAutonomy();
void loadVoice();
}, [refresh, loadAutonomy, loadVoice]);
const toggleAlwaysOn = useCallback(
async (next: boolean) => {
// Guard against rapid re-toggles racing and committing out of order.
if (isUpdatingAlwaysOn) return;
const previous = alwaysOn;
setIsUpdatingAlwaysOn(true);
setAlwaysOn(next); // optimistic flip
setError(null);
try {
await openhumanUpdateVoiceServerSettings({ always_on_enabled: next });
} catch (err) {
// Persist failed — revert the optimistic flip and surface the error.
setAlwaysOn(previous);
setError(errorMessage(err));
setIsUpdatingAlwaysOn(false);
return;
}
try {
// The notch pill is the always-on listening HUD: show on, hide off.
// Persistence already succeeded, so keep the UI state even if this fails.
await syncNotchVisibility(next);
} catch (err) {
setError(errorMessage(err));
} finally {
setIsUpdatingAlwaysOn(false);
}
},
[alwaysOn, isUpdatingAlwaysOn]
);
const seamlessOn =
autoApprove !== null && SEAMLESS_TOOLS.every(tool => autoApprove.includes(tool));
const toggleSeamless = useCallback(
async (next: boolean) => {
const current = autoApprove ?? [];
setIsUpdatingSeamless(true);
setError(null);
try {
if (next) {
// Grant Full access (satisfies app_control_enabled) + auto-approve the
// desktop tools + drop the task-plan approval prompt.
const merged = Array.from(new Set([...current, ...SEAMLESS_TOOLS]));
await openhumanUpdateAutonomySettings({
level: 'full',
require_task_plan_approval: false,
auto_approve: merged,
});
} else {
// Remove only the desktop tools + restore the plan-approval prompt. Leave
// the autonomy tier as-is (managed in Settings → Agent Access).
const seamlessSet = new Set<string>(SEAMLESS_TOOLS);
const pruned = current.filter(tool => !seamlessSet.has(tool));
await openhumanUpdateAutonomySettings({
require_task_plan_approval: true,
auto_approve: pruned,
});
}
await loadAutonomy();
} catch (err) {
setError(errorMessage(err));
} finally {
setIsUpdatingSeamless(false);
}
},
[autoApprove, loadAutonomy]
);
const grant = useCallback(async (kind: AccessibilityPermissionKind) => {
setRequestingKind(kind);
setError(null);
setRestartSummary(null);
try {
setStatus(await requestScreenIntelligencePermission(kind));
} catch (err) {
setError(errorMessage(err));
} finally {
setRequestingKind(null);
}
}, []);
const restartAndRecheck = useCallback(async () => {
setIsRestarting(true);
setError(null);
setRestartSummary(null);
try {
const result = await refreshScreenIntelligencePermissionsWithRestart(status);
setStatus(result.status);
setRestartSummary(result.restartSummary);
} catch (err) {
setError(errorMessage(err));
} finally {
setIsRestarting(false);
}
}, [status]);
const busy = isLoading || isRestarting || requestingKind !== null;
const actionable = PERMISSIONS.map(p => stateFor(status, p.kind)).filter(
s => s === 'denied' || s === 'unknown'
);
const allGranted =
status !== null &&
PERMISSIONS.every(p => {
const s = stateFor(status, p.kind);
return s === 'granted' || s === 'unsupported';
});
return (
<SettingsPanel>
<div
data-testid="desktop-agent-beta-notice"
className="rounded-xl border border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-300">
{t('settings.desktopAgent.beta')}
</div>
<p className="text-sm text-content-secondary">{t('settings.desktopAgent.description')}</p>
<SettingsSection title={t('settings.screenIntel.permissions.title')}>
{PERMISSIONS.map(({ kind, labelKey }) => {
const state = stateFor(status, kind);
const unsupported = state === 'unsupported';
const canGrant = state === 'denied' || state === 'unknown';
return (
<SettingsRow
key={kind}
data-testid={`desktop-agent-perm-${kind}`}
label={t(labelKey)}
control={
<div className="flex items-center gap-2">
<SettingsBadge variant={STATE_VARIANT[state]}>{state}</SettingsBadge>
{unsupported ? (
<span className="text-xs text-content-muted">
{t('settings.desktopAgent.notRequiredOnOs')}
</span>
) : canGrant ? (
<Button
variant="secondary"
size="sm"
data-testid={`desktop-agent-grant-${kind}`}
onClick={() => void grant(kind)}
disabled={busy}>
{requestingKind === kind
? t('settings.screenIntel.permissions.requesting')
: t('settings.desktopAgent.grant')}
</Button>
) : null}
</div>
}
/>
);
})}
</SettingsSection>
<SettingsSection title={t('settings.desktopAgent.seamless.title')}>
<SettingsRow
htmlFor="desktop-agent-seamless"
label={t('settings.desktopAgent.seamless.label')}
description={t('settings.desktopAgent.seamless.description')}
control={
<SettingsSwitch
id="desktop-agent-seamless"
data-testid="desktop-agent-seamless-toggle"
checked={seamlessOn}
disabled={autoApprove === null || isUpdatingSeamless}
onCheckedChange={next => void toggleSeamless(next)}
aria-label={t('settings.desktopAgent.seamless.label')}
/>
}
/>
</SettingsSection>
<p className="text-xs text-content-muted -mt-2">{t('settings.desktopAgent.seamless.note')}</p>
<SettingsSection title={t('voice.debug.alwaysOn')}>
<SettingsRow
htmlFor="desktop-agent-always-on"
label={t('voice.debug.alwaysOn')}
description={t('voice.debug.alwaysOnDesc')}
control={
<SettingsSwitch
id="desktop-agent-always-on"
data-testid="voice-always-on-toggle"
checked={alwaysOn ?? false}
disabled={alwaysOn === null || isUpdatingAlwaysOn}
onCheckedChange={next => void toggleAlwaysOn(next)}
aria-label={t('voice.debug.alwaysOn')}
/>
}
/>
</SettingsSection>
<div
data-testid="desktop-agent-wake-word-hint"
className="-mt-2 flex items-center gap-3 rounded-xl border border-primary-200 dark:border-primary-500/30 bg-primary-50 dark:bg-primary-500/10 px-3.5 py-2.5">
<svg
className="h-5 w-5 flex-shrink-0 text-primary-500 dark:text-primary-300"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
viewBox="0 0 24 24"
aria-hidden="true">
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z"
/>
</svg>
<p className="text-xs leading-relaxed text-primary-800 dark:text-primary-200">
{t('settings.desktopAgent.wakeWordHint')}
</p>
</div>
{allGranted ? (
<div
data-testid="desktop-agent-all-granted"
className="rounded-xl border border-sage-300 dark:border-sage-500/40 bg-sage-50 dark:bg-sage-500/10 p-3 text-sm text-sage-700 dark:text-sage-300">
{t('settings.desktopAgent.allGranted')}
</div>
) : (
<div className="rounded-xl border border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-300">
{t('settings.screenIntel.permissions.grantHint')}
{status?.permission_check_process_path ? (
<p className="opacity-75 text-xs mt-1">
{t('settings.screenIntel.permissions.macosAppliesPrivacy')}{' '}
<span className="font-mono break-all text-content-secondary">
{status.permission_check_process_path}
</span>
</p>
) : null}
</div>
)}
{restartSummary ? (
<div className="rounded-xl border border-sage-300 dark:border-sage-500/40 bg-sage-50 dark:bg-sage-500/10 p-3 text-sm text-sage-700 dark:text-sage-300">
{restartSummary}
</div>
) : null}
{error ? (
<div
data-testid="desktop-agent-error"
className="rounded-xl border border-coral-300 dark:border-coral-500/40 bg-coral-50 dark:bg-coral-500/10 p-3 text-sm text-coral-600 dark:text-coral-300">
{error}
</div>
) : null}
<div className="flex flex-wrap gap-2">
<Button
variant="secondary"
size="md"
data-testid="desktop-agent-recheck"
onClick={() => void refresh()}
disabled={busy}>
{isLoading
? t('settings.screenIntel.permissions.refreshing')
: t('settings.desktopAgent.recheck')}
</Button>
{actionable.length > 0 ? (
<button
type="button"
data-testid="desktop-agent-restart"
onClick={() => void restartAndRecheck()}
disabled={busy}
className="rounded-lg border border-amber-400 bg-amber-50 dark:bg-amber-500/10 px-3 py-2 text-sm text-amber-700 dark:text-amber-300 disabled:opacity-50">
{isRestarting
? t('settings.screenIntel.permissions.restartingCore')
: t('settings.desktopAgent.restartAndRecheck')}
</button>
) : null}
</div>
</SettingsPanel>
);
};
export default DesktopAgentPanel;
@@ -137,22 +137,6 @@ const modelsDebugGroup: DevGroup = {
</svg>
),
},
{
id: 'autocomplete-debug',
titleKey: 'settings.developerMenu.autocomplete.title',
descriptionKey: 'settings.developerMenu.autocomplete.desc',
route: 'autocomplete-debug',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 10h10M4 14h7m3 4h3m0 0l-2-2m2 2l-2 2"
/>
</svg>
),
},
],
};
@@ -21,8 +21,10 @@ import {
} from '../../../services/api/voiceSettingsApi';
import {
openhumanGetVoiceServerSettings,
openhumanUpdateVoiceServerSettings,
openhumanVoiceSetProviders,
openhumanVoiceStatus,
syncNotchVisibility,
type VoiceProvidersSnapshot,
type VoiceServerSettings,
type VoiceStatus,
@@ -35,6 +37,7 @@ import {
SettingsSection,
SettingsSelect,
SettingsStatusLine,
SettingsSwitch,
SettingsTextField,
} from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -111,6 +114,7 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
const [savedSttProvider, setSavedSttProvider] = useState<string>('');
const [savedTtsProvider, setSavedTtsProvider] = useState<string>('');
const [isSavingRouting, setIsSavingRouting] = useState(false);
const [isUpdatingAlwaysOn, setIsUpdatingAlwaysOn] = useState(false);
const [sttModel, setSttModel] = useState<string>('');
const [ttsVoice, setTtsVoice] = useState<string>('');
const [elevenlabsVoiceId, setElevenlabsVoiceId] = useState<string>('JBFqnCBsd6RMkjVDRZzb');
@@ -359,6 +363,40 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
}
}, [sttProvider, ttsProvider, persistProviders, t]);
const toggleAlwaysOn = useCallback(
async (next: boolean) => {
if (!settings || isUpdatingAlwaysOn) return;
const previous = settings.always_on_enabled;
setIsUpdatingAlwaysOn(true);
setError(null);
setNotice(null);
setSettings(current => (current ? { ...current, always_on_enabled: next } : current));
try {
await openhumanUpdateVoiceServerSettings({ always_on_enabled: next });
setSavedSettings(current => (current ? { ...current, always_on_enabled: next } : current));
setNotice(t('voice.settingsSaved'));
} catch (err) {
setSettings(current => (current ? { ...current, always_on_enabled: previous } : current));
setError(err instanceof Error ? err.message : t('voice.failedToSaveSettings'));
setIsUpdatingAlwaysOn(false);
return;
}
try {
// The notch is the always-on listening HUD. Persistence has already
// succeeded, so a window-sync error must not roll the setting back.
await syncNotchVisibility(next);
} catch (err) {
setError(err instanceof Error ? err.message : t('voice.failedToSaveSettings'));
} finally {
setIsUpdatingAlwaysOn(false);
}
},
[isUpdatingAlwaysOn, settings, t]
);
/**
* Enable an external voice provider chip using the inline key form.
* Called after the user enters an API key and clicks Save.
@@ -567,7 +605,23 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
description={embedded ? undefined : t('pages.settings.ai.voiceDesc')}
leading={embedded ? undefined : <SettingsBackButton onBack={navigateBack} />}>
<div className={embedded ? 'space-y-5' : 'p-4 space-y-5'}>
{/* Always-on listening moved to Settings → Features → Desktop Agent. */}
<SettingsSection title={t('voice.debug.alwaysOn')}>
<SettingsRow
htmlFor="voice-always-on"
label={t('voice.debug.alwaysOn')}
description={t('voice.debug.alwaysOnDesc')}
control={
<SettingsSwitch
id="voice-always-on"
data-testid="voice-always-on-toggle"
checked={settings?.always_on_enabled ?? false}
disabled={!settings || isUpdatingAlwaysOn}
onCheckedChange={next => void toggleAlwaysOn(next)}
aria-label={t('voice.debug.alwaysOn')}
/>
}
/>
</SettingsSection>
{/* ─── Section 1: Voice Provider Chips ─────────────────────────── */}
{/* Provider chips are intentional bespoke UI — kept as-is. */}
@@ -1319,7 +1373,7 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
{/* Status line */}
<SettingsStatusLine
saving={isSavingProviders || isSavingRouting}
saving={isSavingProviders || isSavingRouting || isUpdatingAlwaysOn}
savedNote={notice}
error={error}
savingLabel={t('common.loading')}
@@ -1,566 +0,0 @@
/**
* AutocompleteDebugPanel coverage tests.
*
* Target uncovered lines (from diff-cover report):
* 497,501,505,509,513,517,521,525,529,537,539,547-548,555,574,584,591,594,598,
* 618,639,655,671,682,693,702,704,719-721,727,729,731,737,739-740,747
*
* The i18n mock returns the full key as-is (key-passthrough).
* Status values are rendered inline with labels; use container queries or
* partial-text selectors for split-text-node elements.
*/
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// ---------------------------------------------------------------------------
// Module mocks
// ---------------------------------------------------------------------------
const {
mockIsTauri,
mockAutocompleteStatus,
mockGetConfig,
mockAutocompleteStart,
mockAutocompleteStop,
mockAutocompleteCurrent,
mockAutocompleteAccept,
mockAutocompleteDebugFocus,
mockAutocompleteSetStyle,
mockAutocompleteHistory,
mockAutocompleteClearHistory,
} = vi.hoisted(() => ({
mockIsTauri: vi.fn(() => true),
mockAutocompleteStatus: vi.fn(),
mockGetConfig: vi.fn(),
mockAutocompleteStart: vi.fn(),
mockAutocompleteStop: vi.fn(),
mockAutocompleteCurrent: vi.fn(),
mockAutocompleteAccept: vi.fn(),
mockAutocompleteDebugFocus: vi.fn(),
mockAutocompleteSetStyle: vi.fn(),
mockAutocompleteHistory: vi.fn(),
mockAutocompleteClearHistory: vi.fn(),
}));
vi.mock('../../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) }));
vi.mock('../../hooks/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }),
}));
vi.mock('../components/SettingsHeader', () => ({ default: () => null }));
vi.mock('../../../../utils/tauriCommands', () => ({
isTauri: mockIsTauri,
openhumanAutocompleteStatus: mockAutocompleteStatus,
openhumanGetConfig: mockGetConfig,
openhumanAutocompleteStart: mockAutocompleteStart,
openhumanAutocompleteStop: mockAutocompleteStop,
openhumanAutocompleteCurrent: mockAutocompleteCurrent,
openhumanAutocompleteAccept: mockAutocompleteAccept,
openhumanAutocompleteDebugFocus: mockAutocompleteDebugFocus,
openhumanAutocompleteSetStyle: mockAutocompleteSetStyle,
openhumanAutocompleteHistory: mockAutocompleteHistory,
openhumanAutocompleteClearHistory: mockAutocompleteClearHistory,
}));
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Assert that text appears anywhere in the document body.
* Use for status-row values that are rendered as bare text nodes inside a div
* that also contains the label and colon getByText can't isolate those.
*/
const expectInBody = (text: string) => expect(document.body.textContent).toContain(text);
const baseStatus = () => ({
phase: 'idle',
running: false,
enabled: true,
platform_supported: true,
debounce_ms: 120,
model_id: 'claude-haiku',
app_name: 'TextEdit',
last_error: null,
suggestion: null,
});
const baseConfig = () => ({
result: {
config: {
autocomplete: {
enabled: true,
debounce_ms: 150,
max_chars: 400,
style_preset: 'balanced',
style_instructions: 'be concise',
style_examples: ['example one', 'example two'],
disabled_apps: [],
accept_with_tab: true,
overlay_ttl_ms: 1100,
},
},
},
logs: [],
});
const statusResponse = (overrides: Record<string, unknown> = {}) => ({
result: { ...baseStatus(), ...overrides },
logs: ['[runtime] phase=idle'],
});
async function renderPanel() {
const { default: AutocompleteDebugPanel } = await import('../AutocompleteDebugPanel');
return render(<AutocompleteDebugPanel />);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('AutocompleteDebugPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsTauri.mockReturnValue(true);
mockAutocompleteStatus.mockResolvedValue(statusResponse());
mockGetConfig.mockResolvedValue(baseConfig());
mockAutocompleteHistory.mockResolvedValue({ result: { entries: [] }, logs: [] });
mockAutocompleteStart.mockResolvedValue({ result: { started: true }, logs: [] });
mockAutocompleteStop.mockResolvedValue({ result: {}, logs: [] });
mockAutocompleteCurrent.mockResolvedValue({
result: { suggestion: { value: 'hello world' } },
logs: [],
});
mockAutocompleteAccept.mockResolvedValue({
result: { accepted: true, value: 'hello world' },
logs: [],
});
mockAutocompleteDebugFocus.mockResolvedValue({
result: { app_name: 'TestApp', role: 'textField', context: 'some text here' },
logs: [],
});
mockAutocompleteSetStyle.mockResolvedValue({
result: {
config: {
debounce_ms: 150,
max_chars: 400,
overlay_ttl_ms: 1100,
style_instructions: 'be concise',
style_examples: ['example one'],
},
},
logs: [],
});
mockAutocompleteClearHistory.mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
// ── Runtime section: section renders (lines 492530) ──────────────────────
it('renders runtime section heading (line 492)', async () => {
await renderPanel();
// The SettingsSection title is a unique text node, not split
await waitFor(() =>
expect(screen.getByText('settings.autocomplete.appFilter.runtime')).toBeInTheDocument()
);
});
it('renders platform_supported row with yes value (lines 496-498)', async () => {
await renderPanel();
// Status values are text nodes inside a div that also holds the label.
// Use body.textContent checks inside waitFor to avoid split-node matching issues.
await waitFor(() => {
expectInBody('settings.autocomplete.appFilter.platformSupported');
expectInBody('common.yes');
});
});
it('renders common.no for false values (lines 497, 501, 505)', async () => {
mockAutocompleteStatus.mockResolvedValue(
statusResponse({ platform_supported: false, running: false, enabled: false })
);
await renderPanel();
await waitFor(() => {
expectInBody('common.no');
});
});
it('renders phase value from status (line 509)', async () => {
mockAutocompleteStatus.mockResolvedValue(statusResponse({ phase: 'composing' }));
await renderPanel();
await waitFor(() => expectInBody('composing'));
});
it('renders model_id from status (line 517)', async () => {
mockAutocompleteStatus.mockResolvedValue(statusResponse({ model_id: 'gpt-4o' }));
await renderPanel();
await waitFor(() => expectInBody('gpt-4o'));
});
it('shows notApplicable when model_id is null (line 517)', async () => {
mockAutocompleteStatus.mockResolvedValue(statusResponse({ model_id: null }));
await renderPanel();
await waitFor(() => expectInBody('settings.autocomplete.shared.notApplicable'));
});
it('renders app_name from status (line 521)', async () => {
mockAutocompleteStatus.mockResolvedValue(statusResponse({ app_name: 'CodeEditor' }));
await renderPanel();
await waitFor(() => expectInBody('CodeEditor'));
});
it('renders last_error value from status (line 525)', async () => {
mockAutocompleteStatus.mockResolvedValue(statusResponse({ last_error: 'context_limit' }));
await renderPanel();
await waitFor(() => expectInBody('context_limit'));
});
it('shows none when last_error is null (line 525)', async () => {
mockAutocompleteStatus.mockResolvedValue(statusResponse({ last_error: null }));
await renderPanel();
// none appears for both last_error=null and suggestion=null
await waitFor(() => expectInBody('settings.autocomplete.shared.none'));
});
it('renders suggestion value from status (line 529)', async () => {
mockAutocompleteStatus.mockResolvedValue(
statusResponse({ suggestion: { value: 'suggested completion text' } })
);
await renderPanel();
await waitFor(() => expectInBody('suggested completion text'));
});
// ── Refresh status button (line 537) ──────────────────────────────────────
it('calls refreshStatus when Refresh Status button clicked (line 537)', async () => {
await renderPanel();
await waitFor(() => screen.getByText('settings.autocomplete.appFilter.refreshStatus'));
fireEvent.click(screen.getByText('settings.autocomplete.appFilter.refreshStatus'));
await waitFor(() => expect(mockAutocompleteStatus).toHaveBeenCalledTimes(2));
});
// ── Start / Stop buttons (lines 539, 547-548, 555) ────────────────────────
it('calls start when Start button clicked (line 547)', async () => {
await renderPanel();
await waitFor(() => screen.getByText('autocomplete.start'));
fireEvent.click(screen.getByText('autocomplete.start'));
await waitFor(() => expect(mockAutocompleteStart).toHaveBeenCalled());
});
it('shows autocomplete.started after successful start', async () => {
await renderPanel();
await waitFor(() => screen.getByText('autocomplete.start'));
fireEvent.click(screen.getByText('autocomplete.start'));
await waitFor(() => expect(screen.getByText('autocomplete.started')).toBeInTheDocument());
});
it('calls stop when Stop button clicked (line 555)', async () => {
mockAutocompleteStatus.mockResolvedValue(statusResponse({ running: true }));
await renderPanel();
await waitFor(() => screen.getByText('autocomplete.stop'));
fireEvent.click(screen.getByText('autocomplete.stop'));
await waitFor(() => expect(mockAutocompleteStop).toHaveBeenCalled());
});
it('shows alreadyRunning when start returns not-started (line 263)', async () => {
mockAutocompleteStart.mockResolvedValue({ result: { started: false }, logs: [] });
// refreshStatus after start will show running=true
let callCount = 0;
mockAutocompleteStatus.mockImplementation(() => {
callCount++;
return Promise.resolve(statusResponse({ running: callCount > 1, enabled: true }));
});
await renderPanel();
await waitFor(() => screen.getByText('autocomplete.start'));
fireEvent.click(screen.getByText('autocomplete.start'));
await waitFor(() => expectInBody('settings.autocomplete.debug.alreadyRunning'));
});
it('shows disabledInSettings when start fails and enabled=false (line 261)', async () => {
mockAutocompleteStart.mockResolvedValue({ result: { started: false }, logs: [] });
let callCount = 0;
mockAutocompleteStatus.mockImplementation(() => {
callCount++;
// After first load: enabled=true. After start attempt: enabled=false
return Promise.resolve(statusResponse({ running: false, enabled: callCount === 1 }));
});
await renderPanel();
await waitFor(() => screen.getByText('autocomplete.start'));
fireEvent.click(screen.getByText('autocomplete.start'));
await waitFor(() => expectInBody('settings.autocomplete.debug.disabledInSettings'));
});
// ── Test section: getSuggestion (lines 574, 584) ──────────────────────────
it('getSuggestion → shows suggestionPrefix (line 574)', async () => {
await renderPanel();
await waitFor(() => screen.getByText('settings.autocomplete.appFilter.getSuggestion'));
fireEvent.click(screen.getByText('settings.autocomplete.appFilter.getSuggestion'));
await waitFor(() =>
expect(screen.getByText('settings.autocomplete.debug.suggestionPrefix')).toBeInTheDocument()
);
});
it('getSuggestion with null result → shows noSuggestionReturned (line 584)', async () => {
mockAutocompleteCurrent.mockResolvedValue({ result: { suggestion: null }, logs: [] });
await renderPanel();
await waitFor(() => screen.getByText('settings.autocomplete.appFilter.getSuggestion'));
fireEvent.click(screen.getByText('settings.autocomplete.appFilter.getSuggestion'));
await waitFor(() =>
expect(
screen.getByText('settings.autocomplete.debug.noSuggestionReturned')
).toBeInTheDocument()
);
});
// ── Test section: acceptSuggestion (lines 591, 594) ──────────────────────
it('acceptSuggestion accepted=true → shows acceptedPrefix (line 591)', async () => {
await renderPanel();
await waitFor(() => screen.getByText('settings.autocomplete.appFilter.acceptSuggestion'));
fireEvent.click(screen.getByText('settings.autocomplete.appFilter.acceptSuggestion'));
await waitFor(() =>
expect(screen.getByText('settings.autocomplete.debug.acceptedPrefix')).toBeInTheDocument()
);
});
it('acceptSuggestion accepted=false with reason → shows reason (line 594)', async () => {
mockAutocompleteAccept.mockResolvedValue({
result: { accepted: false, reason: 'no suggestion pending', value: null },
logs: [],
});
await renderPanel();
await waitFor(() => screen.getByText('settings.autocomplete.appFilter.acceptSuggestion'));
fireEvent.click(screen.getByText('settings.autocomplete.appFilter.acceptSuggestion'));
await waitFor(() => expect(screen.getByText('no suggestion pending')).toBeInTheDocument());
});
it('acceptSuggestion accepted=false with no reason → noSuggestionApplied (line 594)', async () => {
mockAutocompleteAccept.mockResolvedValue({
result: { accepted: false, reason: null, value: null },
logs: [],
});
await renderPanel();
await waitFor(() => screen.getByText('settings.autocomplete.appFilter.acceptSuggestion'));
fireEvent.click(screen.getByText('settings.autocomplete.appFilter.acceptSuggestion'));
await waitFor(() =>
expect(
screen.getByText('settings.autocomplete.debug.noSuggestionApplied')
).toBeInTheDocument()
);
});
// ── debugFocus (line 598) ──────────────────────────────────────────────────
it('debugFocus renders JSON pre with app_name content (line 598)', async () => {
await renderPanel();
await waitFor(() => screen.getByText('settings.autocomplete.appFilter.debugFocus'));
fireEvent.click(screen.getByText('settings.autocomplete.appFilter.debugFocus'));
// JSON.stringify result is rendered in a <pre>; check body textContent
await waitFor(() => expectInBody('"app_name"'));
});
it('debugFocus null result → focusDebug shows null JSON (line 598)', async () => {
mockAutocompleteDebugFocus.mockResolvedValue({ result: null, logs: [] });
await renderPanel();
await waitFor(() => screen.getByText('settings.autocomplete.appFilter.debugFocus'));
fireEvent.click(screen.getByText('settings.autocomplete.appFilter.debugFocus'));
await waitFor(() => expect(mockAutocompleteDebugFocus).toHaveBeenCalled());
// null result → focusDebug = "null", no app_name key in output
await waitFor(() => expect(document.body.textContent).not.toContain('"app_name"'));
});
// ── Live Logs section (line 618) ──────────────────────────────────────────
it('renders noLogs placeholder before any action (line 618)', async () => {
await renderPanel();
await waitFor(() =>
expect(screen.getByText('settings.autocomplete.appFilter.noLogs')).toBeInTheDocument()
);
});
it('clear logs button empties log display (line 618)', async () => {
await renderPanel();
// Perform an action to produce logs
await waitFor(() => screen.getByText('settings.autocomplete.appFilter.getSuggestion'));
fireEvent.click(screen.getByText('settings.autocomplete.appFilter.getSuggestion'));
await waitFor(() => expect(mockAutocompleteCurrent).toHaveBeenCalled());
fireEvent.click(screen.getByText('common.clear'));
// After clear, noLogs placeholder appears
expect(screen.getByText('settings.autocomplete.appFilter.noLogs')).toBeInTheDocument();
});
// ── Advanced settings form (lines 639, 655, 671) ──────────────────────────
it('loads debounce_ms from config (line 639)', async () => {
await renderPanel();
await waitFor(() => {
const input = screen.getByRole('spinbutton', {
name: 'settings.autocomplete.completionStyle.debounce',
}) as HTMLInputElement;
expect(input.value).toBe('150');
});
});
it('loads max_chars from config (line 655)', async () => {
await renderPanel();
await waitFor(() => {
const input = screen.getByRole('spinbutton', {
name: 'settings.autocomplete.completionStyle.maxChars',
}) as HTMLInputElement;
expect(input.value).toBe('400');
});
});
it('loads overlay_ttl_ms from config (line 671)', async () => {
await renderPanel();
await waitFor(() => {
const input = screen.getByRole('spinbutton', {
name: 'settings.autocomplete.completionStyle.overlayTtl',
}) as HTMLInputElement;
expect(input.value).toBe('1100');
});
});
it('updates debounce and calls setStyle on save (lines 682, 693)', async () => {
await renderPanel();
await waitFor(() =>
screen.getByRole('spinbutton', { name: 'settings.autocomplete.completionStyle.debounce' })
);
const debounceInput = screen.getByRole('spinbutton', {
name: 'settings.autocomplete.completionStyle.debounce',
});
fireEvent.change(debounceInput, { target: { value: '200' } });
fireEvent.click(screen.getByText('common.save'));
await waitFor(() => expect(mockAutocompleteSetStyle).toHaveBeenCalled());
expect(mockAutocompleteSetStyle).toHaveBeenCalledWith(
expect.objectContaining({ debounce_ms: 200 })
);
});
it('shows autocomplete.settingsSaved after successful save (line 702)', async () => {
await renderPanel();
await waitFor(() => screen.getByText('common.save'));
fireEvent.click(screen.getByText('common.save'));
await waitFor(() => expect(screen.getByText('autocomplete.settingsSaved')).toBeInTheDocument());
});
// ── History section (lines 719-721, 727, 729, 731, 737, 739-740, 747) ─────
it('renders noHistory when entries list empty (line 727)', async () => {
await renderPanel();
await waitFor(() =>
expect(
screen.getByText('settings.autocomplete.completionStyle.noHistory')
).toBeInTheDocument()
);
});
it('renders acceptedCompletion (singular) for 1 entry (line 729)', async () => {
mockAutocompleteHistory.mockResolvedValue({
result: {
entries: [
{
timestamp_ms: 1700000000000,
app_name: 'TextEdit',
context: 'context here',
suggestion: 'my suggestion',
},
],
},
logs: [],
});
await renderPanel();
await waitFor(() =>
expect(
screen.getByText('settings.autocomplete.completionStyle.acceptedCompletion')
).toBeInTheDocument()
);
});
it('renders acceptedCompletions (plural) and entry rows for 2 entries (lines 731, 737, 739-740)', async () => {
mockAutocompleteHistory.mockResolvedValue({
result: {
entries: [
{
timestamp_ms: 1700000000000,
app_name: 'TestEditor',
context: 'some context text ending here',
suggestion: 'suggested completion',
},
{
timestamp_ms: 1700000001000,
app_name: null,
context: 'another context',
suggestion: 'another suggestion',
},
],
},
logs: [],
});
await renderPanel();
await waitFor(() =>
expect(
screen.getByText('settings.autocomplete.completionStyle.acceptedCompletions')
).toBeInTheDocument()
);
expect(screen.getByText('suggested completion')).toBeInTheDocument();
expect(screen.getByText('another suggestion')).toBeInTheDocument();
expect(screen.getByText('TestEditor')).toBeInTheDocument();
});
it('clear history button disabled when entries empty (line 719)', async () => {
await renderPanel();
await waitFor(() => screen.getByText('settings.autocomplete.completionStyle.clearHistory'));
const btn = screen
.getByText('settings.autocomplete.completionStyle.clearHistory')
.closest('button') as HTMLButtonElement;
expect(btn.disabled).toBe(true);
});
it('clears history entries on button click (lines 719-721, 747)', async () => {
mockAutocompleteHistory.mockResolvedValue({
result: {
entries: [
{
timestamp_ms: 1700000000000,
app_name: 'TestApp',
context: 'some context',
suggestion: 'a suggestion',
},
],
},
logs: [],
});
await renderPanel();
await waitFor(() => expect(screen.getByText('a suggestion')).toBeInTheDocument());
const clearBtn = screen
.getByText('settings.autocomplete.completionStyle.clearHistory')
.closest('button') as HTMLButtonElement;
expect(clearBtn.disabled).toBe(false);
fireEvent.click(clearBtn);
await waitFor(() => expect(mockAutocompleteClearHistory).toHaveBeenCalled());
await waitFor(() =>
expect(
screen.getByText('settings.autocomplete.completionStyle.noHistory')
).toBeInTheDocument()
);
});
});
@@ -1,385 +0,0 @@
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../../../test/test-utils';
import {
type AutocompleteConfig,
type AutocompleteStatus,
type CommandResponse,
type ConfigSnapshot,
isTauri,
openhumanAutocompleteSetStyle,
openhumanAutocompleteStart,
openhumanAutocompleteStatus,
openhumanAutocompleteStop,
openhumanGetConfig,
} from '../../../../utils/tauriCommands';
import AutocompletePanel from '../AutocompletePanel';
vi.mock('../../../../utils/tauriCommands', () => ({
isTauri: vi.fn(() => true),
openhumanAutocompleteAccept: vi.fn(),
openhumanAutocompleteClearHistory: vi.fn(),
openhumanAutocompleteCurrent: vi.fn(),
openhumanAutocompleteDebugFocus: vi.fn(),
openhumanAutocompleteHistory: vi.fn(),
openhumanAutocompleteSetStyle: vi.fn(),
openhumanAutocompleteStart: vi.fn(),
openhumanAutocompleteStatus: vi.fn(),
openhumanAutocompleteStop: vi.fn(),
openhumanGetConfig: vi.fn(),
}));
type RuntimeHarness = { status: AutocompleteStatus; config: AutocompleteConfig };
const makeConfigSnapshot = (config: AutocompleteConfig): CommandResponse<ConfigSnapshot> => ({
result: {
config: { autocomplete: config },
workspace_dir: '/tmp/openhuman-e2e',
config_path: '/tmp/openhuman-e2e/config.toml',
},
logs: [],
});
const cloneStatus = (status: AutocompleteStatus): AutocompleteStatus => ({
...status,
suggestion: status.suggestion ? { ...status.suggestion } : null,
});
describe('AutocompletePanel (simplified)', () => {
let runtime: RuntimeHarness;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(isTauri).mockReturnValue(true);
runtime = {
status: {
platform_supported: true,
enabled: true,
running: false,
phase: 'idle',
debounce_ms: 120,
model_id: 'gemma3:4b-it-qat',
app_name: 'OpenHuman',
last_error: null,
updated_at_ms: Date.now(),
suggestion: null,
},
config: {
enabled: true,
debounce_ms: 120,
max_chars: 384,
style_preset: 'balanced',
style_instructions: null,
style_examples: [],
disabled_apps: [],
accept_with_tab: true,
overlay_ttl_ms: 1100,
},
};
vi.mocked(openhumanAutocompleteStatus).mockImplementation(async () => ({
result: cloneStatus(runtime.status),
logs: [],
}));
vi.mocked(openhumanGetConfig).mockImplementation(async () =>
makeConfigSnapshot(runtime.config)
);
vi.mocked(openhumanAutocompleteSetStyle).mockImplementation(async params => {
runtime.config = {
...runtime.config,
...params,
style_instructions: params.style_instructions ?? runtime.config.style_instructions,
style_examples: params.style_examples ?? runtime.config.style_examples,
disabled_apps: params.disabled_apps ?? runtime.config.disabled_apps,
};
runtime.status.enabled = runtime.config.enabled;
return { result: { config: { ...runtime.config } }, logs: [] };
});
vi.mocked(openhumanAutocompleteStart).mockImplementation(async () => {
if (!runtime.config.enabled) {
return { result: { started: false }, logs: [] };
}
runtime.status.running = true;
runtime.status.phase = 'idle';
return { result: { started: true }, logs: [] };
});
vi.mocked(openhumanAutocompleteStop).mockImplementation(async () => {
runtime.status.running = false;
runtime.status.phase = 'idle';
runtime.status.suggestion = null;
return { result: { stopped: true }, logs: [] };
});
});
it('shows user-facing settings and can save style preset changes', async () => {
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
await screen.findByText('Style Preset');
// Verify user-facing controls are present
expect(screen.getByText('Enabled')).toBeInTheDocument();
expect(screen.getByText('Accept With Tab')).toBeInTheDocument();
expect(screen.getByText('Style Preset')).toBeInTheDocument();
// Verify runtime status section shows
await waitFor(() => {
expect(screen.getByText('Running: No')).toBeInTheDocument();
});
// Change style preset and save using the labeled select
const presetSelect = screen.getByRole('combobox', { name: 'Style Preset' });
fireEvent.change(presetSelect, { target: { value: 'concise' } });
fireEvent.click(screen.getByRole('button', { name: 'Save Settings' }));
await waitFor(() => {
expect(openhumanAutocompleteSetStyle).toHaveBeenCalledWith(
expect.objectContaining({ style_preset: 'concise', accept_with_tab: true })
);
});
expect(await screen.findByText('Autocomplete settings saved.')).toBeInTheDocument();
});
it('can start and stop the autocomplete runtime', async () => {
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
await screen.findByText('Style Preset');
// Wait for status to load
await waitFor(() => {
expect(screen.getByText('Running: No')).toBeInTheDocument();
});
// Start
fireEvent.click(screen.getByRole('button', { name: 'Start' }));
await waitFor(() => {
expect(openhumanAutocompleteStart).toHaveBeenCalled();
});
await waitFor(() => {
expect(screen.getByText('Autocomplete started.')).toBeInTheDocument();
});
// Stop
fireEvent.click(screen.getByRole('button', { name: 'Stop' }));
await waitFor(() => {
expect(openhumanAutocompleteStop).toHaveBeenCalled();
});
await waitFor(() => {
expect(screen.getByText('Autocomplete stopped.')).toBeInTheDocument();
});
});
it('preserves advanced settings when saving from the simplified panel', async () => {
runtime.config.debounce_ms = 500;
runtime.config.max_chars = 800;
runtime.config.overlay_ttl_ms = 2000;
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
await screen.findByText('Style Preset');
// Wait for config to load
await waitFor(() => {
expect(screen.getByText('Running: No')).toBeInTheDocument();
});
// Toggle enabled off via SettingsSwitch (role="switch")
const enabledSwitch = screen.getByRole('switch', { name: 'Enabled' });
fireEvent.click(enabledSwitch);
fireEvent.click(screen.getByRole('button', { name: 'Save Settings' }));
await waitFor(() => {
expect(openhumanAutocompleteSetStyle).toHaveBeenCalledWith(
expect.objectContaining({
enabled: false,
debounce_ms: 500,
max_chars: 800,
overlay_ttl_ms: 2000,
})
);
});
});
it('shows the Advanced settings link', async () => {
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
await screen.findByText('Style Preset');
expect(screen.getByText('Advanced settings')).toBeInTheDocument();
});
it('seeds the tuning inputs from config and saves edited values', async () => {
runtime.config.debounce_ms = 500;
runtime.config.max_chars = 800;
runtime.config.overlay_ttl_ms = 2000;
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
await screen.findByText('Style Preset');
// SettingsNumberField wraps the input in a div; find the inner spinbutton
const debounceWrapper = await screen.findByTestId('autocomplete-debounce-ms');
const maxCharsWrapper = screen.getByTestId('autocomplete-max-chars');
const overlayTtlWrapper = screen.getByTestId('autocomplete-overlay-ttl-ms');
const debounce = within(debounceWrapper).getByRole('spinbutton') as HTMLInputElement;
const maxChars = within(maxCharsWrapper).getByRole('spinbutton') as HTMLInputElement;
const overlayTtl = within(overlayTtlWrapper).getByRole('spinbutton') as HTMLInputElement;
// Seeded from loaded config.
await waitFor(() => expect(debounce.value).toBe('500'));
expect(maxChars.value).toBe('800');
expect(overlayTtl.value).toBe('2000');
// Edit and save.
fireEvent.change(debounce, { target: { value: '250' } });
fireEvent.change(maxChars, { target: { value: '512' } });
fireEvent.change(overlayTtl, { target: { value: '900' } });
fireEvent.click(screen.getByRole('button', { name: 'Save Settings' }));
await waitFor(() => {
expect(openhumanAutocompleteSetStyle).toHaveBeenCalledWith(
expect.objectContaining({ debounce_ms: 250, max_chars: 512, overlay_ttl_ms: 900 })
);
});
});
it('allows clearing a tuning field mid-edit and clamps to safe minimums at save', async () => {
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
await screen.findByText('Style Preset');
const maxCharsWrapper = await screen.findByTestId('autocomplete-max-chars');
const debounceWrapper = screen.getByTestId('autocomplete-debounce-ms');
const maxChars = within(maxCharsWrapper).getByRole('spinbutton') as HTMLInputElement;
const debounce = within(debounceWrapper).getByRole('spinbutton') as HTMLInputElement;
// Intermediate empty / zero states are preserved while typing (no snap).
fireEvent.change(maxChars, { target: { value: '' } });
expect(maxChars.value).toBe('');
fireEvent.change(maxChars, { target: { value: '0' } });
expect(maxChars.value).toBe('0');
fireEvent.change(debounce, { target: { value: '' } });
expect(debounce.value).toBe('');
// Clamping happens at save: max_chars -> >= 1, debounce -> >= 0.
fireEvent.click(screen.getByRole('button', { name: 'Save Settings' }));
await waitFor(() => {
expect(openhumanAutocompleteSetStyle).toHaveBeenCalledWith(
expect.objectContaining({ max_chars: 384, debounce_ms: 0 })
);
});
});
// ─── Disabled apps textarea ───────────────────────────────────────────────
it('seeds the disabled-apps textarea from config and saves changes', async () => {
runtime.config.disabled_apps = ['Slack', 'Zoom'];
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
await screen.findByText('Style Preset');
const textarea = (await screen.findByRole('textbox', {
name: /disabled apps/i,
})) as HTMLTextAreaElement;
await waitFor(() => expect(textarea.value).toContain('Slack'));
expect(textarea.value).toContain('Zoom');
// Edit the textarea
fireEvent.change(textarea, { target: { value: 'Teams\nDiscord' } });
expect(textarea.value).toBe('Teams\nDiscord');
// Save includes updated disabled_apps
fireEvent.click(screen.getByRole('button', { name: 'Save Settings' }));
await waitFor(() => {
expect(openhumanAutocompleteSetStyle).toHaveBeenCalledWith(
expect.objectContaining({ disabled_apps: ['Teams', 'Discord'] })
);
});
});
// ─── onCommit callbacks on number fields ──────────────────────────────────
it('committing a debounce value via Enter triggers saveConfig', async () => {
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
await screen.findByText('Style Preset');
const debounceWrapper = await screen.findByTestId('autocomplete-debounce-ms');
const debounce = within(debounceWrapper).getByRole('spinbutton') as HTMLInputElement;
fireEvent.change(debounce, { target: { value: '300' } });
// onCommit fires when SettingsNumberField calls its onCommit prop; simulate by
// pressing Enter which SettingsNumberField forwards to onCommit.
fireEvent.keyDown(debounce, { key: 'Enter' });
await waitFor(() => {
expect(openhumanAutocompleteSetStyle).toHaveBeenCalledWith(
expect.objectContaining({ debounce_ms: 300 })
);
});
});
it('committing a max-chars value triggers saveConfig', async () => {
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
await screen.findByText('Style Preset');
const maxCharsWrapper = await screen.findByTestId('autocomplete-max-chars');
const maxChars = within(maxCharsWrapper).getByRole('spinbutton') as HTMLInputElement;
fireEvent.change(maxChars, { target: { value: '512' } });
fireEvent.keyDown(maxChars, { key: 'Enter' });
await waitFor(() => {
expect(openhumanAutocompleteSetStyle).toHaveBeenCalledWith(
expect.objectContaining({ max_chars: 512 })
);
});
});
it('committing an overlay-ttl value triggers saveConfig', async () => {
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
await screen.findByText('Style Preset');
const overlayWrapper = await screen.findByTestId('autocomplete-overlay-ttl-ms');
const overlayTtl = within(overlayWrapper).getByRole('spinbutton') as HTMLInputElement;
fireEvent.change(overlayTtl, { target: { value: '2500' } });
fireEvent.keyDown(overlayTtl, { key: 'Enter' });
await waitFor(() => {
expect(openhumanAutocompleteSetStyle).toHaveBeenCalledWith(
expect.objectContaining({ overlay_ttl_ms: 2500 })
);
});
});
// ─── didNotStart branch ───────────────────────────────────────────────────
it('shows "did not start" message when autocomplete start returns started=false', async () => {
vi.mocked(openhumanAutocompleteStart).mockResolvedValueOnce({
result: { started: false },
logs: [],
});
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
await screen.findByText('Style Preset');
await waitFor(() => expect(screen.getByText('Running: No')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Start' }));
await waitFor(() => expect(openhumanAutocompleteStart).toHaveBeenCalled());
await waitFor(() =>
expect(screen.getByText(/did not start|autocomplete did not/i)).toBeInTheDocument()
);
});
});
@@ -1,12 +1,15 @@
import { invoke } from '@tauri-apps/api/core';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { callCoreRpc } from '../../../../services/coreRpcClient';
import { renderWithProviders } from '../../../../test/test-utils';
import CompanionPanel from '../CompanionPanel';
vi.mock('../../../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }));
// The panel guards its calls behind isTauri(); force it on in the test env.
vi.mock('../../../../utils/tauriCommands/common', () => ({ isTauri: () => true }));
vi.mock('../../hooks/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({
@@ -15,7 +18,7 @@ vi.mock('../../hooks/useSettingsNavigation', () => ({
}),
}));
const callCoreRpcMock = callCoreRpc as unknown as ReturnType<typeof vi.fn>;
const invokeMock = invoke as unknown as ReturnType<typeof vi.fn>;
const mockStatus = {
active: false,
@@ -38,10 +41,10 @@ const mockConfig = {
beforeEach(() => {
vi.clearAllMocks();
callCoreRpcMock.mockImplementation(async ({ method }: { method: string }) => {
if (method === 'openhuman.companion_status') return mockStatus;
if (method === 'openhuman.companion_config_get') return mockConfig;
throw new Error(`unmocked method: ${method}`);
invokeMock.mockImplementation(async (cmd: string) => {
if (cmd === 'companion_status') return mockStatus;
if (cmd === 'companion_config_get') return mockConfig;
throw new Error(`unmocked command: ${cmd}`);
});
});
@@ -55,8 +58,8 @@ describe('CompanionPanel', () => {
});
it('renders active state when session is active', async () => {
callCoreRpcMock.mockImplementation(async ({ method }: { method: string }) => {
if (method === 'openhuman.companion_status')
invokeMock.mockImplementation(async (cmd: string) => {
if (cmd === 'companion_status')
return {
...mockStatus,
active: true,
@@ -65,8 +68,8 @@ describe('CompanionPanel', () => {
turn_count: 3,
remaining_ms: 300000,
};
if (method === 'openhuman.companion_config_get') return mockConfig;
throw new Error(`unmocked method: ${method}`);
if (cmd === 'companion_config_get') return mockConfig;
throw new Error(`unmocked command: ${cmd}`);
});
renderWithProviders(<CompanionPanel />);
@@ -75,7 +78,7 @@ describe('CompanionPanel', () => {
});
});
it('calls companion_start_session when start button clicked', async () => {
it('starts a session and registers its configured hotkey', async () => {
const user = userEvent.setup();
renderWithProviders(<CompanionPanel />);
@@ -83,21 +86,28 @@ describe('CompanionPanel', () => {
expect(screen.getByText('Start Session')).toBeInTheDocument();
});
callCoreRpcMock.mockImplementation(async ({ method }: { method: string }) => {
if (method === 'openhuman.companion_start_session')
invokeMock.mockImplementation(async (cmd: string) => {
if (cmd === 'companion_start_session')
return { session_id: 'new-sess', state: 'idle', expires_at_ms: null };
if (method === 'openhuman.companion_status') return mockStatus;
if (method === 'openhuman.companion_config_get') return mockConfig;
throw new Error(`unmocked method: ${method}`);
if (cmd === 'register_companion_hotkey') return undefined;
if (cmd === 'companion_status') return mockStatus;
if (cmd === 'companion_config_get') return mockConfig;
throw new Error(`unmocked command: ${cmd}`);
});
await user.click(screen.getByText('Start Session'));
await waitFor(() => {
expect(callCoreRpcMock).toHaveBeenCalledWith(
expect.objectContaining({ method: 'openhuman.companion_start_session' })
);
expect(invokeMock).toHaveBeenCalledWith('companion_start_session', { consent: true });
expect(invokeMock).toHaveBeenCalledWith('register_companion_hotkey', {
shortcut: mockConfig.hotkey,
});
});
const calls = invokeMock.mock.calls.map(([command]) => command);
expect(calls.indexOf('companion_start_session')).toBeLessThan(
calls.indexOf('register_companion_hotkey')
);
});
it('shows error when start session fails', async () => {
@@ -108,11 +118,11 @@ describe('CompanionPanel', () => {
expect(screen.getByText('Start Session')).toBeInTheDocument();
});
callCoreRpcMock.mockImplementation(async ({ method }: { method: string }) => {
if (method === 'openhuman.companion_start_session') throw new Error('consent required');
if (method === 'openhuman.companion_status') return mockStatus;
if (method === 'openhuman.companion_config_get') return mockConfig;
throw new Error(`unmocked method: ${method}`);
invokeMock.mockImplementation(async (cmd: string) => {
if (cmd === 'companion_start_session') throw new Error('consent required');
if (cmd === 'companion_status') return mockStatus;
if (cmd === 'companion_config_get') return mockConfig;
throw new Error(`unmocked command: ${cmd}`);
});
await user.click(screen.getByText('Start Session'));
@@ -122,6 +132,32 @@ describe('CompanionPanel', () => {
});
});
it('stops the new session when hotkey registration fails', async () => {
const user = userEvent.setup();
renderWithProviders(<CompanionPanel />);
await waitFor(() => {
expect(screen.getByText('Start Session')).toBeInTheDocument();
});
invokeMock.mockImplementation(async (cmd: string) => {
if (cmd === 'companion_start_session')
return { session_id: 'new-sess', state: 'idle', expires_at_ms: null };
if (cmd === 'register_companion_hotkey') throw new Error('shortcut unavailable');
if (cmd === 'companion_stop_session') return { stopped: true, reason: 'user_requested' };
if (cmd === 'companion_status') return mockStatus;
if (cmd === 'companion_config_get') return mockConfig;
throw new Error(`unmocked command: ${cmd}`);
});
await user.click(screen.getByText('Start Session'));
await waitFor(() => {
expect(screen.getByText('shortcut unavailable')).toBeInTheDocument();
expect(invokeMock).toHaveBeenCalledWith('companion_stop_session');
});
});
it('displays config values', async () => {
renderWithProviders(<CompanionPanel />);
await waitFor(() => {
@@ -134,17 +170,15 @@ describe('CompanionPanel', () => {
it('calls companion_status on mount', async () => {
renderWithProviders(<CompanionPanel />);
await waitFor(() => {
expect(callCoreRpcMock).toHaveBeenCalledWith(
expect.objectContaining({ method: 'openhuman.companion_status' })
);
expect(invokeMock).toHaveBeenCalledWith('companion_status');
});
});
it('shows error when companion_status fetch fails', async () => {
callCoreRpcMock.mockImplementation(async ({ method }: { method: string }) => {
if (method === 'openhuman.companion_status') throw new Error('rpc down');
if (method === 'openhuman.companion_config_get') return mockConfig;
throw new Error(`unmocked method: ${method}`);
invokeMock.mockImplementation(async (cmd: string) => {
if (cmd === 'companion_status') throw new Error('rpc down');
if (cmd === 'companion_config_get') return mockConfig;
throw new Error(`unmocked command: ${cmd}`);
});
renderWithProviders(<CompanionPanel />);
await waitFor(() => {
@@ -160,14 +194,14 @@ describe('CompanionPanel', () => {
session_id: 'sess-active',
};
let currentStatus: typeof mockStatus | typeof activeStatus = activeStatus;
callCoreRpcMock.mockImplementation(async ({ method }: { method: string }) => {
if (method === 'openhuman.companion_status') return currentStatus;
if (method === 'openhuman.companion_config_get') return mockConfig;
if (method === 'openhuman.companion_stop_session') {
invokeMock.mockImplementation(async (cmd: string) => {
if (cmd === 'companion_status') return currentStatus;
if (cmd === 'companion_config_get') return mockConfig;
if (cmd === 'companion_stop_session') {
currentStatus = mockStatus;
return { stopped: true, reason: 'user_requested' };
}
throw new Error(`unmocked method: ${method}`);
throw new Error(`unmocked command: ${cmd}`);
});
const user = userEvent.setup();
@@ -179,9 +213,7 @@ describe('CompanionPanel', () => {
await user.click(screen.getByText('Stop Session'));
await waitFor(() => {
expect(callCoreRpcMock).toHaveBeenCalledWith(
expect.objectContaining({ method: 'openhuman.companion_stop_session' })
);
expect(invokeMock).toHaveBeenCalledWith('companion_stop_session');
});
});
@@ -192,11 +224,11 @@ describe('CompanionPanel', () => {
state: 'speaking' as const,
session_id: 'sess-active',
};
callCoreRpcMock.mockImplementation(async ({ method }: { method: string }) => {
if (method === 'openhuman.companion_status') return activeStatus;
if (method === 'openhuman.companion_config_get') return mockConfig;
if (method === 'openhuman.companion_stop_session') throw new Error('cannot stop');
throw new Error(`unmocked method: ${method}`);
invokeMock.mockImplementation(async (cmd: string) => {
if (cmd === 'companion_status') return activeStatus;
if (cmd === 'companion_config_get') return mockConfig;
if (cmd === 'companion_stop_session') throw new Error('cannot stop');
throw new Error(`unmocked command: ${cmd}`);
});
const user = userEvent.setup();
@@ -213,12 +245,12 @@ describe('CompanionPanel', () => {
});
it('renders "Disabled" when capture_screen and include_app_context are false', async () => {
callCoreRpcMock.mockImplementation(async ({ method }: { method: string }) => {
if (method === 'openhuman.companion_status') return mockStatus;
if (method === 'openhuman.companion_config_get') {
invokeMock.mockImplementation(async (cmd: string) => {
if (cmd === 'companion_status') return mockStatus;
if (cmd === 'companion_config_get') {
return { ...mockConfig, capture_screen: false, include_app_context: false };
}
throw new Error(`unmocked method: ${method}`);
throw new Error(`unmocked command: ${cmd}`);
});
renderWithProviders(<CompanionPanel />);
await waitFor(() => {
@@ -1,278 +0,0 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
fetchScreenIntelligenceStatus,
refreshScreenIntelligencePermissionsWithRestart,
requestScreenIntelligencePermission,
} from '../../../../features/screen-intelligence/api';
import {
type AccessibilityPermissionState,
type AccessibilityStatus,
openhumanGetAutonomySettings,
openhumanGetVoiceServerSettings,
openhumanUpdateAutonomySettings,
openhumanUpdateVoiceServerSettings,
syncNotchVisibility,
} from '../../../../utils/tauriCommands';
import DesktopAgentPanel from '../DesktopAgentPanel';
vi.mock('../../../../features/screen-intelligence/api', () => ({
fetchScreenIntelligenceStatus: vi.fn(),
requestScreenIntelligencePermission: vi.fn(),
refreshScreenIntelligencePermissionsWithRestart: vi.fn(),
}));
vi.mock('../../../../utils/tauriCommands', async importOriginal => {
const actual = await importOriginal<typeof import('../../../../utils/tauriCommands')>();
return {
...actual,
openhumanGetAutonomySettings: vi.fn(),
openhumanUpdateAutonomySettings: vi.fn(),
openhumanGetVoiceServerSettings: vi.fn(),
openhumanUpdateVoiceServerSettings: vi.fn(),
syncNotchVisibility: vi.fn(),
};
});
function mockVoice(alwaysOn: boolean) {
vi.mocked(openhumanGetVoiceServerSettings).mockResolvedValue({
result: { always_on_enabled: alwaysOn },
logs: [],
} as unknown as Awaited<ReturnType<typeof openhumanGetVoiceServerSettings>>);
}
const SEAMLESS_TOOLS = ['automate', 'ax_interact', 'launch_app', 'keyboard', 'mouse'];
function mockAutonomy(autoApprove: string[]) {
vi.mocked(openhumanGetAutonomySettings).mockResolvedValue({
result: { auto_approve: autoApprove },
logs: [],
} as unknown as Awaited<ReturnType<typeof openhumanGetAutonomySettings>>);
}
type Perms = {
microphone: AccessibilityPermissionState;
accessibility: AccessibilityPermissionState;
screen_recording: AccessibilityPermissionState;
input_monitoring: AccessibilityPermissionState;
};
function makeStatus(permissions: Perms): AccessibilityStatus {
return {
platform_supported: true,
permissions,
permission_check_process_path: '/tmp/openhuman-core',
} as unknown as AccessibilityStatus;
}
function renderPanel() {
render(
<MemoryRouter initialEntries={['/settings/desktop-agent']}>
<DesktopAgentPanel />
</MemoryRouter>
);
}
describe('DesktopAgentPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
mockAutonomy([]);
mockVoice(false);
vi.mocked(openhumanUpdateAutonomySettings).mockResolvedValue(
{} as unknown as Awaited<ReturnType<typeof openhumanUpdateAutonomySettings>>
);
vi.mocked(openhumanUpdateVoiceServerSettings).mockResolvedValue(
{} as unknown as Awaited<ReturnType<typeof openhumanUpdateVoiceServerSettings>>
);
vi.mocked(syncNotchVisibility).mockResolvedValue(undefined);
});
it('renders a row per permission with the right grant affordance', async () => {
vi.mocked(fetchScreenIntelligenceStatus).mockResolvedValue(
makeStatus({
microphone: 'granted',
accessibility: 'denied',
screen_recording: 'unknown',
input_monitoring: 'unsupported',
})
);
renderPanel();
// Granted permission → badge, no grant button.
const micRow = await screen.findByTestId('desktop-agent-perm-microphone');
expect(within(micRow).getByText('granted')).toBeInTheDocument();
expect(screen.queryByTestId('desktop-agent-grant-microphone')).not.toBeInTheDocument();
// Denied + unknown → grant buttons.
expect(screen.getByTestId('desktop-agent-grant-accessibility')).toBeInTheDocument();
expect(screen.getByTestId('desktop-agent-grant-screen_recording')).toBeInTheDocument();
// Unsupported → no grant button (muted "not required" instead).
expect(screen.queryByTestId('desktop-agent-grant-input_monitoring')).not.toBeInTheDocument();
const inputRow = screen.getByTestId('desktop-agent-perm-input_monitoring');
expect(within(inputRow).getByText('Not required on this OS')).toBeInTheDocument();
});
it('requests the permission and reflects the new status when Grant is clicked', async () => {
vi.mocked(fetchScreenIntelligenceStatus).mockResolvedValue(
makeStatus({
microphone: 'granted',
accessibility: 'denied',
screen_recording: 'granted',
input_monitoring: 'unsupported',
})
);
vi.mocked(requestScreenIntelligencePermission).mockResolvedValue(
makeStatus({
microphone: 'granted',
accessibility: 'granted',
screen_recording: 'granted',
input_monitoring: 'unsupported',
})
);
renderPanel();
const grantBtn = await screen.findByTestId('desktop-agent-grant-accessibility');
fireEvent.click(grantBtn);
await waitFor(() =>
expect(requestScreenIntelligencePermission).toHaveBeenCalledWith('accessibility')
);
// Once granted, the grant button disappears and the all-granted banner shows.
await waitFor(() =>
expect(screen.queryByTestId('desktop-agent-grant-accessibility')).not.toBeInTheDocument()
);
expect(screen.getByTestId('desktop-agent-all-granted')).toBeInTheDocument();
});
it('surfaces an error when a permission request fails', async () => {
vi.mocked(fetchScreenIntelligenceStatus).mockResolvedValue(
makeStatus({
microphone: 'denied',
accessibility: 'granted',
screen_recording: 'granted',
input_monitoring: 'unsupported',
})
);
vi.mocked(requestScreenIntelligencePermission).mockRejectedValue(
new Error('permission request blew up')
);
renderPanel();
fireEvent.click(await screen.findByTestId('desktop-agent-grant-microphone'));
const errorBox = await screen.findByTestId('desktop-agent-error');
expect(errorBox).toHaveTextContent('permission request blew up');
// refresh-with-restart is unrelated to a failed grant.
expect(refreshScreenIntelligencePermissionsWithRestart).not.toHaveBeenCalled();
});
describe('seamless mode (act without asking)', () => {
beforeEach(() => {
vi.mocked(fetchScreenIntelligenceStatus).mockResolvedValue(
makeStatus({
microphone: 'granted',
accessibility: 'granted',
screen_recording: 'granted',
input_monitoring: 'unsupported',
})
);
});
it('reflects the auto-approve allowlist: off when the desktop tools are absent', async () => {
mockAutonomy([]);
renderPanel();
const toggle = await screen.findByTestId('desktop-agent-seamless-toggle');
await waitFor(() => expect(toggle).toHaveAttribute('aria-checked', 'false'));
});
it('reflects the auto-approve allowlist: on when all desktop tools are present', async () => {
mockAutonomy([...SEAMLESS_TOOLS]);
renderPanel();
const toggle = await screen.findByTestId('desktop-agent-seamless-toggle');
await waitFor(() => expect(toggle).toHaveAttribute('aria-checked', 'true'));
});
it('enabling grants Full access, drops plan approval, and auto-approves the desktop tools', async () => {
mockAutonomy([]);
renderPanel();
const toggle = await screen.findByTestId('desktop-agent-seamless-toggle');
await waitFor(() => expect(toggle).toHaveAttribute('aria-checked', 'false'));
fireEvent.click(toggle);
await waitFor(() => expect(openhumanUpdateAutonomySettings).toHaveBeenCalledTimes(1));
const payload = vi.mocked(openhumanUpdateAutonomySettings).mock.calls[0][0];
expect(payload).toMatchObject({ level: 'full', require_task_plan_approval: false });
expect(payload.auto_approve).toEqual(expect.arrayContaining(SEAMLESS_TOOLS));
});
it('disabling removes the desktop tools and restores plan approval, keeping other allows', async () => {
mockAutonomy([...SEAMLESS_TOOLS, 'shell']);
renderPanel();
const toggle = await screen.findByTestId('desktop-agent-seamless-toggle');
await waitFor(() => expect(toggle).toHaveAttribute('aria-checked', 'true'));
fireEvent.click(toggle);
await waitFor(() => expect(openhumanUpdateAutonomySettings).toHaveBeenCalledTimes(1));
const payload = vi.mocked(openhumanUpdateAutonomySettings).mock.calls[0][0];
expect(payload).toMatchObject({ require_task_plan_approval: true });
expect(payload.auto_approve).toEqual(['shell']);
// Disabling must not downgrade the tier.
expect(payload.level).toBeUndefined();
});
});
describe('always-on listening (relocated from Voice)', () => {
beforeEach(() => {
vi.mocked(fetchScreenIntelligenceStatus).mockResolvedValue(
makeStatus({
microphone: 'granted',
accessibility: 'granted',
screen_recording: 'granted',
input_monitoring: 'unsupported',
})
);
});
it('reflects the persisted always-on flag', async () => {
mockVoice(true);
renderPanel();
const toggle = await screen.findByTestId('voice-always-on-toggle');
await waitFor(() => expect(toggle).toHaveAttribute('aria-checked', 'true'));
});
it('persists the flag and syncs the notch when toggled on', async () => {
mockVoice(false);
renderPanel();
const toggle = await screen.findByTestId('voice-always-on-toggle');
await waitFor(() => expect(toggle).toHaveAttribute('aria-checked', 'false'));
fireEvent.click(toggle);
await waitFor(() =>
expect(openhumanUpdateVoiceServerSettings).toHaveBeenCalledWith({ always_on_enabled: true })
);
await waitFor(() => expect(syncNotchVisibility).toHaveBeenCalledWith(true));
});
it('reverts and never touches the notch when the update fails', async () => {
mockVoice(false);
vi.mocked(openhumanUpdateVoiceServerSettings).mockRejectedValueOnce(new Error('rpc down'));
renderPanel();
const toggle = await screen.findByTestId('voice-always-on-toggle');
await waitFor(() => expect(toggle).toHaveAttribute('aria-checked', 'false'));
fireEvent.click(toggle);
await waitFor(() => expect(toggle).toHaveAttribute('aria-checked', 'false'));
expect(syncNotchVisibility).not.toHaveBeenCalled();
});
});
});
@@ -97,7 +97,7 @@ const setCoreSession = ({
activeMode: 'os_keyring',
backendName: 'os',
},
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
runtime: { screenIntelligence: null, localAi: null, service: null },
},
teams: [],
teamMembersById: {},
@@ -210,6 +210,35 @@ describe('VoicePanel', () => {
vi.useRealTimers();
});
it('persists always-on listening and syncs the notch indicator', async () => {
renderWithProviders(<VoicePanel />);
const toggle = await screen.findByTestId('voice-always-on-toggle');
expect(toggle).toHaveAttribute('aria-checked', 'false');
fireEvent.click(toggle);
await waitFor(() =>
expect(openhumanUpdateVoiceServerSettings).toHaveBeenCalledWith({ always_on_enabled: true })
);
await waitFor(() => expect(syncNotchVisibility).toHaveBeenCalledWith(true));
expect(toggle).toHaveAttribute('aria-checked', 'true');
});
it('restores the always-on toggle when persistence fails', async () => {
vi.mocked(openhumanUpdateVoiceServerSettings).mockRejectedValueOnce(
new Error('settings unavailable')
);
renderWithProviders(<VoicePanel />);
const toggle = await screen.findByTestId('voice-always-on-toggle');
fireEvent.click(toggle);
await screen.findByText('settings unavailable');
expect(toggle).toHaveAttribute('aria-checked', 'false');
expect(syncNotchVisibility).not.toHaveBeenCalled();
});
// ─── Voice Routing Section ──────────────────────────────────────────────
it('renders the STT and TTS provider dropdowns defaulting to cloud', async () => {
@@ -529,8 +558,6 @@ describe('VoicePanel', () => {
await waitFor(() => expect(screen.getByText('core offline')).toBeInTheDocument());
});
// Always-on listening toggle moved to DesktopAgentPanel (see its test).
// ─── STT / TTS Test buttons ────────────────────────────────────────────────
it('clicking Test STT calls testVoiceProvider and shows success result', async () => {
@@ -1,168 +0,0 @@
import { useT } from '../../../../lib/i18n/I18nContext';
import type { AutocompleteStatus } from '../../../../utils/tauriCommands';
import Button from '../../../ui/Button';
interface AppFilterSectionProps {
status: AutocompleteStatus | null;
isLoading: boolean;
contextOverride: string;
focusDebug: string;
logs: string[];
message: string | null;
error: string | null;
onSetContextOverride: (value: string) => void;
onRefreshStatus: () => void;
onStart: () => void;
onStop: () => void;
onTestCurrent: () => void;
onAcceptSuggestion: () => void;
onDebugFocus: () => void;
onClearLogs: () => void;
}
const AppFilterSection = ({
status,
isLoading,
contextOverride,
focusDebug,
logs,
message,
error,
onSetContextOverride,
onRefreshStatus,
onStart,
onStop,
onTestCurrent,
onAcceptSuggestion,
onDebugFocus,
onClearLogs,
}: AppFilterSectionProps) => {
const { t } = useT();
return (
<>
<section className="rounded-2xl border border-line bg-surface p-4 space-y-3">
<h3 className="text-sm font-semibold text-content">
{t('settings.autocomplete.appFilter.runtime')}
</h3>
<div className="text-sm text-content space-y-1">
<div>
{t('settings.autocomplete.appFilter.platformSupported')}:{' '}
{status?.platform_supported ? t('common.yes') : t('common.no')}
</div>
<div>
{t('settings.autocomplete.appFilter.enabled')}:{' '}
{status?.enabled ? t('common.yes') : t('common.no')}
</div>
<div>
{t('settings.autocomplete.appFilter.running')}:{' '}
{status?.running ? t('common.yes') : t('common.no')}
</div>
<div>
{t('settings.autocomplete.appFilter.phase')}:{' '}
{status?.phase ?? t('settings.autocomplete.shared.unknown')}
</div>
<div>
{t('settings.autocomplete.appFilter.debounce')}:{' '}
{`${String(status?.debounce_ms ?? 0)}ms`}
</div>
<div>
{t('settings.autocomplete.appFilter.model')}:{' '}
{status?.model_id ?? t('settings.autocomplete.shared.notApplicable')}
</div>
<div>
{t('settings.autocomplete.appFilter.app')}:{' '}
{status?.app_name ?? t('settings.autocomplete.shared.notApplicable')}
</div>
<div>
{t('settings.autocomplete.appFilter.lastError')}:{' '}
{status?.last_error ?? t('settings.autocomplete.shared.none')}
</div>
<div>
{t('settings.autocomplete.appFilter.currentSuggestion')}:{' '}
{status?.suggestion?.value ?? t('settings.autocomplete.shared.none')}
</div>
</div>
<div className="flex gap-2">
<Button variant="secondary" size="md" onClick={onRefreshStatus} disabled={isLoading}>
{isLoading
? t('settings.autocomplete.appFilter.refreshing')
: t('settings.autocomplete.appFilter.refreshStatus')}
</Button>
<button
type="button"
onClick={onStart}
disabled={!status?.platform_supported || Boolean(status?.running)}
className="rounded-lg border border-green-500/60 bg-green-50 dark:bg-green-500/10 px-3 py-2 text-sm text-green-700 dark:text-green-300 disabled:opacity-50">
{t('autocomplete.start')}
</button>
<Button
variant="secondary"
tone="danger"
size="md"
onClick={onStop}
disabled={!status?.running}>
{t('autocomplete.stop')}
</Button>
</div>
</section>
<section className="rounded-2xl border border-line bg-surface p-4 space-y-3">
<h3 className="text-sm font-semibold text-content">
{t('settings.autocomplete.appFilter.test')}
</h3>
<div className="space-y-1">
<div className="text-xs text-content-secondary">
{t('settings.autocomplete.appFilter.contextOverride')}
</div>
<textarea
value={contextOverride}
onChange={event => onSetContextOverride(event.target.value)}
rows={3}
className="w-full rounded border border-line bg-surface-muted p-2 text-xs text-content"
/>
</div>
<div className="flex gap-2">
<Button variant="primary" size="md" onClick={onTestCurrent}>
{t('settings.autocomplete.appFilter.getSuggestion')}
</Button>
<button
type="button"
onClick={onAcceptSuggestion}
className="rounded-lg border border-emerald-500/60 bg-emerald-50 dark:bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-300">
{t('settings.autocomplete.appFilter.acceptSuggestion')}
</button>
<button
type="button"
onClick={onDebugFocus}
className="rounded-lg border border-amber-500/60 bg-amber-50 dark:bg-amber-500/10 px-3 py-2 text-sm text-amber-700 dark:text-amber-300">
{t('settings.autocomplete.appFilter.debugFocus')}
</button>
</div>
{focusDebug && (
<pre className="max-h-48 overflow-auto rounded-xl border border-line bg-surface-muted p-2 text-xs text-content">
{focusDebug}
</pre>
)}
</section>
<section className="rounded-2xl border border-line bg-surface p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-content">
{t('settings.autocomplete.appFilter.liveLogs')}
</h3>
<Button variant="secondary" size="sm" onClick={onClearLogs}>
{t('common.clear')}
</Button>
</div>
<pre className="max-h-56 overflow-auto rounded-xl border border-line bg-surface-muted p-2 text-xs text-content">
{logs.length > 0 ? logs.join('\n') : t('settings.autocomplete.appFilter.noLogs')}
</pre>
</section>
{message && <div className="text-xs text-green-700 dark:text-green-300">{message}</div>}
{error && <div className="text-xs text-red-600 dark:text-red-300">{error}</div>}
</>
);
};
export default AppFilterSection;
@@ -1,227 +0,0 @@
import { useT } from '../../../../lib/i18n/I18nContext';
import type { AcceptedCompletion } from '../../../../utils/tauriCommands';
import Button from '../../../ui/Button';
interface CompletionStyleSectionProps {
enabled: boolean;
debounceMs: string;
maxChars: string;
stylePreset: string;
styleInstructions: string;
styleExamplesText: string;
disabledAppsText: string;
acceptWithTab: boolean;
overlayTtlMs: string;
isSaving: boolean;
historyEntries: AcceptedCompletion[];
isHistoryLoading: boolean;
isClearingHistory: boolean;
onSetEnabled: (value: boolean) => void;
onSetDebounceMs: (value: string) => void;
onSetMaxChars: (value: string) => void;
onSetStylePreset: (value: string) => void;
onSetStyleInstructions: (value: string) => void;
onSetStyleExamplesText: (value: string) => void;
onSetDisabledAppsText: (value: string) => void;
onSetAcceptWithTab: (value: boolean) => void;
onSetOverlayTtlMs: (value: string) => void;
onSaveConfig: () => void;
onClearHistory: () => void;
}
const CompletionStyleSection = ({
enabled,
debounceMs,
maxChars,
stylePreset,
styleInstructions,
styleExamplesText,
disabledAppsText,
acceptWithTab,
overlayTtlMs,
isSaving,
historyEntries,
isHistoryLoading,
isClearingHistory,
onSetEnabled,
onSetDebounceMs,
onSetMaxChars,
onSetStylePreset,
onSetStyleInstructions,
onSetStyleExamplesText,
onSetDisabledAppsText,
onSetAcceptWithTab,
onSetOverlayTtlMs,
onSaveConfig,
onClearHistory,
}: CompletionStyleSectionProps) => {
const { t } = useT();
return (
<>
<section className="rounded-2xl border border-line bg-surface p-4 space-y-3">
<h3 className="text-sm font-semibold text-content">{t('autocomplete.settings')}</h3>
<label className="flex items-center justify-between rounded-xl border border-line bg-surface-muted px-3 py-2">
<span className="text-sm text-content">
{t('settings.autocomplete.completionStyle.enabled')}
</span>
<input
type="checkbox"
checked={enabled}
onChange={event => onSetEnabled(event.target.checked)}
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-line bg-surface-muted px-3 py-2">
<span className="text-sm text-content">{t('autocomplete.acceptWithTab')}</span>
<input
type="checkbox"
checked={acceptWithTab}
onChange={event => onSetAcceptWithTab(event.target.checked)}
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-line bg-surface-muted px-3 py-2">
<span className="text-sm text-content">
{t('settings.autocomplete.completionStyle.debounce')}
</span>
<input
type="number"
min={50}
max={2000}
step={10}
value={debounceMs}
onChange={event => onSetDebounceMs(event.target.value)}
className="w-28 rounded border border-line-strong bg-surface px-2 py-1 text-xs text-content"
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-line bg-surface-muted px-3 py-2">
<span className="text-sm text-content">
{t('settings.autocomplete.completionStyle.maxChars')}
</span>
<input
type="number"
min={32}
max={1200}
step={8}
value={maxChars}
onChange={event => onSetMaxChars(event.target.value)}
className="w-28 rounded border border-line-strong bg-surface px-2 py-1 text-xs text-content"
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-line bg-surface-muted px-3 py-2">
<span className="text-sm text-content">
{t('settings.autocomplete.completionStyle.overlayTtl')}
</span>
<input
type="number"
min={300}
max={10000}
step={100}
value={overlayTtlMs}
onChange={event => onSetOverlayTtlMs(event.target.value)}
className="w-28 rounded border border-line-strong bg-surface px-2 py-1 text-xs text-content"
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-line bg-surface-muted px-3 py-2">
<span className="text-sm text-content">{t('autocomplete.stylePreset')}</span>
<select
value={stylePreset}
onChange={event => onSetStylePreset(event.target.value)}
className="rounded border border-line-strong bg-surface px-2 py-1 text-xs text-content">
<option value="balanced">{t('autocomplete.style.balanced')}</option>
<option value="concise">{t('autocomplete.style.concise')}</option>
<option value="formal">{t('autocomplete.style.formal')}</option>
<option value="casual">{t('autocomplete.style.casual')}</option>
<option value="custom">{t('autocomplete.style.custom')}</option>
</select>
</label>
<div className="space-y-1">
<div className="text-xs text-content-secondary">
{t('settings.autocomplete.completionStyle.styleInstructions')}
</div>
<textarea
value={styleInstructions}
onChange={event => onSetStyleInstructions(event.target.value)}
rows={3}
className="w-full rounded border border-line bg-surface-muted p-2 text-xs text-content"
/>
</div>
<div className="space-y-1">
<div className="text-xs text-content-secondary">
{t('settings.autocomplete.completionStyle.styleExamples')}
</div>
<textarea
value={styleExamplesText}
onChange={event => onSetStyleExamplesText(event.target.value)}
rows={3}
className="w-full rounded border border-line bg-surface-muted p-2 text-xs text-content"
/>
</div>
<div className="space-y-1">
<div className="text-xs text-content-secondary">{t('autocomplete.disabledApps')}</div>
<textarea
value={disabledAppsText}
onChange={event => onSetDisabledAppsText(event.target.value)}
rows={3}
className="w-full rounded border border-line bg-surface-muted p-2 text-xs text-content"
/>
</div>
<Button variant="primary" size="sm" onClick={onSaveConfig} disabled={isSaving}>
{isSaving ? t('autocomplete.saving') : t('autocomplete.saveSettings')}
</Button>
</section>
<section className="rounded-2xl border border-line bg-surface p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-content">
{t('settings.autocomplete.completionStyle.personalizationHistory')}
</h3>
<Button
variant="secondary"
tone="danger"
size="sm"
onClick={onClearHistory}
disabled={isClearingHistory || historyEntries.length === 0}>
{isClearingHistory
? t('settings.autocomplete.completionStyle.clearing')
: t('settings.autocomplete.completionStyle.clearHistory')}
</Button>
</div>
<p className="text-xs text-content-muted">
{isHistoryLoading
? t('common.loading')
: historyEntries.length === 0
? t('settings.autocomplete.completionStyle.noHistory')
: (historyEntries.length === 1
? t('settings.autocomplete.completionStyle.acceptedCompletion')
: t('settings.autocomplete.completionStyle.acceptedCompletions')
).replace('{count}', String(historyEntries.length))}
</p>
{historyEntries.length > 0 && (
<div className="max-h-48 overflow-y-auto rounded-xl border border-line bg-surface-muted p-2 space-y-1">
{historyEntries.map((entry, idx) => (
<div
key={`${String(entry.timestamp_ms)}-${String(idx)}`}
className="flex flex-col gap-0.5 rounded-lg bg-surface px-2 py-1.5 text-xs border border-line-subtle">
<div className="flex items-center gap-2 text-content-muted">
<span className="shrink-0">{new Date(entry.timestamp_ms).toLocaleString()}</span>
{entry.app_name && (
<span className="rounded bg-surface-subtle px-1 text-content-secondary">
{entry.app_name}
</span>
)}
</div>
<div className="flex items-baseline gap-1 text-content truncate">
<span className="shrink-0 text-content-faint"></span>
<span className="truncate text-content-muted">{entry.context.slice(-40)}</span>
<span className="shrink-0 text-content-faint"></span>
<span className="font-medium text-primary-500 truncate">{entry.suggestion}</span>
</div>
</div>
))}
</div>
)}
</section>
</>
);
};
export default CompletionStyleSection;
@@ -11,8 +11,6 @@ import AgentEditorPage from './panels/AgentEditorPage';
import AgentsPanel from './panels/AgentsPanel';
import AppearancePanel from './panels/AppearancePanel';
import ApprovalHistoryPanel from './panels/ApprovalHistoryPanel';
import AutocompleteDebugPanel from './panels/AutocompleteDebugPanel';
import AutocompletePanel from './panels/AutocompletePanel';
import BillingPanel from './panels/BillingPanel';
import CoreConnectionPanel from './panels/CoreConnectionPanel';
import CronJobsPanel from './panels/CronJobsPanel';
@@ -143,15 +141,10 @@ export function settingsRouteElements(): ReactNode {
path="screen-intelligence"
element={<Navigate to="/connections?tab=screen-intelligence" replace />}
/>
<Route
path="desktop-agent"
element={<Navigate to="/connections?tab=desktop-agent" replace />}
/>
<Route path="tools" element={wrapSettingsPage(<ToolsPanel />)} />
<Route path="companion" element={<Navigate to="/connections?tab=companion" replace />} />
{/* Meeting settings moved to the Connections page (meetings tab). */}
<Route path="meetings" element={<Navigate to="/connections?tab=meetings" replace />} />
<Route path="autocomplete" element={wrapSettingsPage(<AutocompletePanel />)} />
{/* ── System ──────────────────────────────────────────────── */}
{/* Core connection promotes cloud-mode remote-core config into a
@@ -191,7 +184,6 @@ export function settingsRouteElements(): ReactNode {
path="screen-awareness-debug"
element={wrapSettingsPage(<ScreenAwarenessDebugPanel />)}
/>
<Route path="autocomplete-debug" element={wrapSettingsPage(<AutocompleteDebugPanel />)} />
{/* Voice Debug page retired. */}
<Route path="voice-debug" element={<SettingsRedirect to="/settings/developer-options" />} />
{/* Local Model Debug is a chip on the Connections → LLM page. */}
@@ -459,23 +459,6 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [
section: 'features',
searchKeywords: ['screen', 'awareness', 'vision', 'capture'],
},
{
// Surfaced on the Connections page (Desktop group); route redirects there.
id: 'desktop-agent',
titleKey: 'settings.desktopAgent.title',
descriptionKey: 'settings.desktopAgent.description',
section: 'features',
searchKeywords: [
'desktop',
'agent',
'automation',
'permissions',
'microphone',
'accessibility',
'screen recording',
'input monitoring',
],
},
{
id: 'tools',
titleKey: 'pages.settings.features.tools',
@@ -640,14 +623,6 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [
devOnly: true,
navGroup: 'agentsAutonomy',
},
{
id: 'autocomplete-debug',
titleKey: 'settings.developerMenu.autocomplete.title',
descriptionKey: 'settings.developerMenu.autocomplete.desc',
section: 'developer',
devOnly: true,
navGroup: 'modelsInference',
},
// Build Info (about page alias in dev menu)
{
id: 'build-info',
@@ -676,14 +651,6 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [
navOrder: 4,
highlight: true,
},
{
// autocomplete: hidden per #717 (route retained for re-enable).
id: 'autocomplete',
titleKey: 'settings.developerMenu.autocomplete.title',
section: 'developer',
hiddenDeepLink: true,
devOnly: true,
},
{
// search: web search engine settings (Brave / Google / Tavily provider).
// Surfaced on the Connections page (Intelligence group); route kept for
@@ -1,139 +0,0 @@
/**
* Text Auto-Complete setup/enable modal.
*
* Simple enable flow: shows current state, lets user enable with one click,
* and shows a success confirmation matching the UX of the Screen
* Intelligence setup modal.
*/
import { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useT } from '../../lib/i18n/I18nContext';
import { useCoreState } from '../../providers/CoreStateProvider';
import {
openhumanAutocompleteSetStyle,
openhumanAutocompleteStart,
} from '../../utils/tauriCommands/autocomplete';
import { settingsNavState } from '../settings/modal/settingsOverlay';
import Button from '../ui/Button';
import {
SetupNotice,
SetupSettingRow,
SetupSuccess,
SkillSetupModalShell,
} from './SkillSetupPrimitives';
type Step = 'enable' | 'success';
interface Props {
onClose: () => void;
}
export default function AutocompleteSetupModal({ onClose }: Props) {
const navigate = useNavigate();
const location = useLocation();
const { t } = useT();
const { snapshot, refresh } = useCoreState();
const status = snapshot.runtime.autocomplete;
const [step, setStep] = useState<Step>('enable');
const [isEnabling, setIsEnabling] = useState(false);
const [enableError, setEnableError] = useState<string | null>(null);
const handleEnable = async () => {
setIsEnabling(true);
setEnableError(null);
try {
// Enable in config
await openhumanAutocompleteSetStyle({ enabled: true });
// Start the service
await openhumanAutocompleteStart();
await refresh();
setStep('success');
} catch (error) {
setEnableError(
error instanceof Error ? error.message : t('skills.setup.autocomplete.enableError')
);
} finally {
setIsEnabling(false);
}
};
const handleGoToSettings = () => {
onClose();
navigate('/settings/autocomplete', settingsNavState(location));
};
return (
<SkillSetupModalShell
onClose={onClose}
title={t('skills.setup.autocomplete.title')}
titleId="ac-setup-title"
subtitle={
<>
{step === 'enable' && t('skills.setup.autocomplete.stepEnable')}
{step === 'success' && t('skills.setup.autocomplete.stepSuccess')}
</>
}
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M4 7h16M4 12h10m-10 5h7m10 0l3 3m0 0l3-3m-3 3v-8"
/>
</svg>
}>
{/* ─── Enable step ─── */}
{step === 'enable' && (
<div className="space-y-4">
<p className="text-xs text-content-muted leading-relaxed">
{t('skills.setup.autocomplete.description')}
</p>
{!status?.platform_supported && status !== null && (
<SetupNotice tone="amber">{t('skills.setup.autocomplete.notSupported')}</SetupNotice>
)}
<div className="space-y-2">
<SetupSettingRow
label={t('skills.setup.autocomplete.stylePreset')}
value={t('skills.setup.autocomplete.stylePresetValue')}
/>
<SetupSettingRow label={t('skills.setup.autocomplete.acceptKey')} value="Tab" mono />
<SetupSettingRow
label={t('skills.setup.autocomplete.debounce')}
value={`${status?.debounce_ms ?? 120}ms`}
/>
</div>
{enableError && <SetupNotice tone="coral">{enableError}</SetupNotice>}
<Button
variant="primary"
size="lg"
onClick={() => void handleEnable()}
disabled={isEnabling || (status !== null && !status.platform_supported)}
className="w-full">
{isEnabling
? t('skills.setup.autocomplete.enabling')
: t('skills.setup.autocomplete.enableBtn')}
</Button>
</div>
)}
{/* ─── Success step ─── */}
{step === 'success' && (
<SetupSuccess
title={t('skills.setup.autocomplete.activeTitle')}
description={t('skills.setup.autocomplete.activeDesc')}
settingsLabel={t('skills.setup.autocomplete.customizeSettings')}
finishLabel={t('common.finish')}
onSettings={handleGoToSettings}
onFinish={onClose}
/>
)}
</SkillSetupModalShell>
);
}
@@ -1542,7 +1542,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
data-testid={`run-stop-${r.run_id}`}
onClick={() => void handleStopRun(r.run_id)}
leadingIcon={<span aria-hidden="true"></span>}>
{t('autocomplete.stop')}
{t('orchestration.detail.stop')}
</Button>
</div>
)}
@@ -1,84 +0,0 @@
import { renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { useCoreState } from '../../../providers/CoreStateProvider';
import { useAutocompleteSkillStatus } from '../useAutocompleteSkillStatus';
vi.mock('../../../providers/CoreStateProvider', () => ({ useCoreState: vi.fn() }));
type AutocompleteRuntime = {
platform_supported: boolean;
running: boolean;
enabled: boolean;
last_error?: string | null;
};
function mockSnapshot(autocomplete: AutocompleteRuntime | null): void {
vi.mocked(useCoreState).mockReturnValue({ snapshot: { runtime: { autocomplete } } } as ReturnType<
typeof useCoreState
>);
}
describe('useAutocompleteSkillStatus (5.2 — autocomplete settings status)', () => {
it('returns offline + Enable CTA when no runtime status is available yet', () => {
mockSnapshot(null);
const { result } = renderHook(() => useAutocompleteSkillStatus());
expect(result.current.connectionStatus).toBe('offline');
expect(result.current.statusLabel).toBe('Offline');
expect(result.current.ctaLabel).toBe('Enable');
expect(result.current.platformUnsupported).toBe(false);
});
it('returns Unsupported when the platform reports the runtime is unsupported', () => {
mockSnapshot({ platform_supported: false, running: false, enabled: false });
const { result } = renderHook(() => useAutocompleteSkillStatus());
expect(result.current.connectionStatus).toBe('offline');
expect(result.current.statusLabel).toBe('Unsupported');
expect(result.current.ctaLabel).toBe('Details');
expect(result.current.platformUnsupported).toBe(true);
});
it('returns Active + Manage CTA when the runtime is running (overrides stale errors)', () => {
mockSnapshot({
platform_supported: true,
running: true,
enabled: true,
last_error: 'stale: should not surface',
});
const { result } = renderHook(() => useAutocompleteSkillStatus());
expect(result.current.connectionStatus).toBe('connected');
expect(result.current.statusLabel).toBe('Active');
expect(result.current.ctaLabel).toBe('Manage');
});
it('returns Error + Retry CTA when not running and an error is present', () => {
mockSnapshot({
platform_supported: true,
running: false,
enabled: true,
last_error: 'permission denied',
});
const { result } = renderHook(() => useAutocompleteSkillStatus());
expect(result.current.connectionStatus).toBe('error');
expect(result.current.statusLabel).toBe('Error');
expect(result.current.ctaLabel).toBe('Retry');
expect(result.current.ctaVariant).toBe('amber');
});
it('returns Enabled (disconnected) + Manage when enabled but not running and no error', () => {
mockSnapshot({ platform_supported: true, running: false, enabled: true, last_error: null });
const { result } = renderHook(() => useAutocompleteSkillStatus());
expect(result.current.connectionStatus).toBe('disconnected');
expect(result.current.statusLabel).toBe('Enabled');
expect(result.current.ctaLabel).toBe('Manage');
});
it('returns Disabled + Enable CTA when not enabled and not running', () => {
mockSnapshot({ platform_supported: true, running: false, enabled: false, last_error: null });
const { result } = renderHook(() => useAutocompleteSkillStatus());
expect(result.current.connectionStatus).toBe('offline');
expect(result.current.statusLabel).toBe('Disabled');
expect(result.current.ctaLabel).toBe('Enable');
expect(result.current.ctaVariant).toBe('sage');
});
});
@@ -1,55 +0,0 @@
/**
* Derives a skill-card-friendly status for Text Auto-Complete,
* matching the state vocabulary used by third-party skills (Gmail, etc.).
*/
import { useMemo } from 'react';
import { useCoreState } from '../../providers/CoreStateProvider';
import {
activeStatus,
enabledStatus,
errorStatus,
offlineStatus,
type SkillCardStatusDescriptor,
unsupportedStatus,
} from '../skills/skillCardStatus';
export interface AutocompleteSkillStatus extends SkillCardStatusDescriptor {
/** True when the platform doesn't support autocomplete. */
platformUnsupported: boolean;
}
export function useAutocompleteSkillStatus(): AutocompleteSkillStatus {
const { snapshot } = useCoreState();
const status = snapshot.runtime.autocomplete;
return useMemo(() => {
// No status yet (core not ready or not in Tauri)
if (!status) {
return { ...offlineStatus(), platformUnsupported: false };
}
if (!status.platform_supported) {
return { ...unsupportedStatus(), platformUnsupported: true };
}
// Running — fully active (checked before error so a stale last_error
// doesn't mask a successfully running service)
if (status.running) {
return { ...activeStatus(), platformUnsupported: false };
}
// Error state (only when not running)
if (status.last_error) {
return { ...errorStatus(), platformUnsupported: false };
}
// Enabled in config but not running
if (status.enabled) {
return { ...enabledStatus(), platformUnsupported: false };
}
// Not enabled
return { ...offlineStatus('Disabled'), platformUnsupported: false };
}, [status]);
}
@@ -41,11 +41,7 @@ import {
handleComposerSlashCommand,
} from '../../features/conversations/composerSendDecision';
import { useMemorySyncActive } from '../../features/conversations/hooks/useBackgroundActivity';
import {
buildAcceptedInlineCompletion,
formatResetTime,
getInlineCompletionSuffix,
} from '../../features/conversations/utils/format';
import { formatResetTime } from '../../features/conversations/utils/format';
import {
GENERAL_TAB_VALUE,
isThreadVisibleInTab,
@@ -117,10 +113,7 @@ import { CHAT_ATTACHMENTS_ENABLED } from '../../utils/config';
import { PRICING_URL } from '../../utils/links';
import { openUrl } from '../../utils/openUrl';
import {
isTauri,
notifyOverlaySttState,
openhumanAutocompleteAccept,
openhumanAutocompleteCurrent,
openhumanVoiceStatus,
openhumanVoiceTranscribeBytes,
openhumanVoiceTts,
@@ -130,8 +123,6 @@ import { ThreadList } from './threadList/ThreadList';
const CHAT_MODEL_HINT = 'hint:chat';
type InputMode = 'text' | 'voice';
type ReplyMode = 'text' | 'voice';
const AUTOCOMPLETE_POLL_DEBOUNCE_MS = 320;
const AUTOCOMPLETE_MIN_CONTEXT_CHARS = 3;
const debug = debugFactory('conversations');
interface ConversationsProps {
@@ -272,7 +263,6 @@ const Conversations = ({
// task/worker threads have dedicated surfaces (Intelligence, Tasks board).
const selectedLabel = GENERAL_TAB_VALUE;
const [threadSearch, setThreadSearch] = useState('');
const [inlineSuggestionValue, setInlineSuggestionValue] = useState('');
const [sendError, setSendError] = useState<ChatSendError | null>(null);
const [attachError, setAttachError] = useState<ChatSendError | null>(null);
const [sendAdvisory, setSendAdvisory] = useState<string | null>(null);
@@ -457,8 +447,6 @@ const Conversations = ({
const audioChunksRef = useRef<Blob[]>([]);
const replyAudioRef = useRef<HTMLAudioElement | null>(null);
const lastSpokenMessageIdRef = useRef<string | null>(null);
const autocompleteDebounceRef = useRef<number | null>(null);
const autocompleteRequestSeqRef = useRef(0);
// Per-thread silence timers. Each in-flight turn gets its own 120s safety
// timer keyed by thread id, so concurrent turns on different threads don't
// share (and clobber) a single timeout.
@@ -822,45 +810,6 @@ const Conversations = ({
inferenceHeartbeatByThread,
]);
useEffect(() => {
if (
!isTauri() ||
!rustChat ||
inputMode !== 'text' ||
selectedThreadActive ||
inputValue.trim().length < AUTOCOMPLETE_MIN_CONTEXT_CHARS
) {
setInlineSuggestionValue('');
return;
}
if (autocompleteDebounceRef.current !== null) {
window.clearTimeout(autocompleteDebounceRef.current);
}
autocompleteDebounceRef.current = window.setTimeout(() => {
const requestSeq = autocompleteRequestSeqRef.current + 1;
autocompleteRequestSeqRef.current = requestSeq;
void openhumanAutocompleteCurrent({ context: inputValue })
.then(response => {
if (autocompleteRequestSeqRef.current !== requestSeq) return;
setInlineSuggestionValue(response.result.suggestion?.value ?? '');
})
.catch(() => {
if (autocompleteRequestSeqRef.current !== requestSeq) return;
setInlineSuggestionValue('');
});
}, AUTOCOMPLETE_POLL_DEBOUNCE_MS);
return () => {
if (autocompleteDebounceRef.current !== null) {
window.clearTimeout(autocompleteDebounceRef.current);
autocompleteDebounceRef.current = null;
}
};
}, [selectedThreadActive, inputValue, inputMode, rustChat]);
useEffect(() => {
return () => {
mediaRecorderRef.current?.stop();
@@ -1587,9 +1536,6 @@ const Conversations = ({
if (restored.length > 0) {
debug('[chat] esc interrupt: restored prompt len=%d', restored.length);
setInputValue(restored);
// Drop any stale inline ghost-completion so it doesn't reappear over
// the freshly restored prompt.
setInlineSuggestionValue('');
window.requestAnimationFrame(() => {
const ta = textInputRef.current;
if (!ta) return;
@@ -1601,43 +1547,6 @@ const Conversations = ({
return;
}
const inlineSuffix = getInlineCompletionSuffix(inputValue, inlineSuggestionValue);
const textarea = e.currentTarget;
const caretAtEnd =
textarea.selectionStart === inputValue.length && textarea.selectionEnd === inputValue.length;
const tryAcceptInlineSuggestion = () => {
const nextValue = buildAcceptedInlineCompletion(inputValue, inlineSuffix);
if (!nextValue || nextValue === inputValue) return false;
setInputValue(nextValue);
setInlineSuggestionValue('');
if (isTauri()) {
void openhumanAutocompleteAccept({ suggestion: nextValue, skip_apply: true }).catch(() => {
// Keep local UX smooth even if accept RPC fails.
});
}
return true;
};
if (
e.key === 'Tab' &&
!e.shiftKey &&
!e.altKey &&
!e.ctrlKey &&
!e.metaKey &&
inlineSuffix.length > 0 &&
caretAtEnd
) {
e.preventDefault();
tryAcceptInlineSuggestion();
return;
}
if (e.key === 'ArrowRight' && inlineSuffix.length > 0 && caretAtEnd) {
e.preventDefault();
tryAcceptInlineSuggestion();
return;
}
// Cmd/Ctrl+Enter sends a PARALLEL branch when the selected thread already
// has a turn in flight (otherwise it behaves like a normal send).
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
@@ -1712,7 +1621,6 @@ const Conversations = ({
const selectedInterruptedAssistant = selectedThreadId
? (interruptedAssistantByThread[selectedThreadId] ?? null)
: null;
const inlineCompletionSuffix = getInlineCompletionSuffix(inputValue, inlineSuggestionValue);
// Blocks all composer interaction while a turn is in-flight or Rust chat is unavailable.
// isSending: the *selected* thread is in-flight (drives selected-thread UI only).
const composerInteractionBlocked = isComposerInteractionBlocked({
@@ -2309,7 +2217,7 @@ const Conversations = ({
attachError={attachError}
onSwitchToMicCloud={() => setComposerOverride('mic-cloud')}
handleInputKeyDown={handleInputKeyDown}
inlineCompletionSuffix={inlineCompletionSuffix}
inlineCompletionSuffix=""
isComposingTextRef={isComposingTextRef}
maxAttachments={ATTACHMENT_MAX_IMAGES + ATTACHMENT_MAX_FILES}
// Empty → no native `accept` filter (it greys valid files on
@@ -18,7 +18,7 @@ function makeSnapshot(overrides: Partial<CoreAppSnapshot> = {}): CoreAppSnapshot
activeMode: 'os_keyring',
backendName: 'os',
},
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
runtime: { screenIntelligence: null, localAi: null, service: null },
...overrides,
};
}
+1 -3
View File
@@ -1,7 +1,6 @@
import type { User } from '../../types/api';
import type { TeamInvite, TeamMember, TeamWithRole } from '../../types/team';
import type { AccessibilityStatus } from '../../utils/tauriCommands/accessibility';
import type { AutocompleteStatus } from '../../utils/tauriCommands/autocomplete';
import type { LocalAiStatus } from '../../utils/tauriCommands/localAi';
import type { ServiceStatus } from '../../utils/tauriCommands/service';
@@ -35,7 +34,6 @@ export interface CoreLocalState {
export interface CoreRuntimeSnapshot {
screenIntelligence: AccessibilityStatus | null;
localAi: LocalAiStatus | null;
autocomplete: AutocompleteStatus | null;
service: ServiceStatus | null;
}
@@ -96,7 +94,7 @@ const emptySnapshot: CoreAppSnapshot = {
activeMode: 'os_keyring',
backendName: 'os',
},
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
runtime: { screenIntelligence: null, localAi: null, service: null },
};
let currentState: CoreState = {
+22 -260
View File
@@ -3,6 +3,28 @@ import type { TranslationMap } from './types';
// Arabic (العربية) translations. Keys mirror en.ts; missing/
// English-identical values fall back to English via I18nContext.resolveEn().
const messages: TranslationMap = {
'connections.header.companion': 'رفيق سطح مكتب عائم للمساعدة السريعة والدائمة.',
'settings.assistant.desktopCompanion': 'المرافق لسطح المكتب',
'settings.assistant.desktopCompanionDesc': 'وضع المرافق الدائم مع اختصار شريط النظام',
'pages.settings.features.desktopCompanion': 'الرفيق المكتبي',
'pages.settings.features.desktopCompanionDesc': 'مساعد صوتي يدرك الشاشة: يستمع ويرى ويتحدث ويشير',
'settings.companion.title': 'رفيق سطح المكتب',
'settings.companion.session': 'الجلسة',
'settings.companion.activeLabel': 'نشط',
'settings.companion.inactiveStatus': 'غير نشط',
'settings.companion.stopping': 'جارٍ الإيقاف…',
'settings.companion.stopSession': 'إيقاف الجلسة',
'settings.companion.starting': 'جارٍ البدء…',
'settings.companion.startSession': 'بدء الجلسة',
'settings.companion.sessionId': 'معرف الجلسة',
'settings.companion.turns': 'الجولات',
'settings.companion.remaining': 'المتبقي',
'settings.companion.configuration': 'التكوين',
'settings.companion.hotkey': 'مفتاح التشغيل السريع',
'settings.companion.activationMode': 'وضع التنشيط',
'settings.companion.sessionTtl': 'مدة صلاحية الجلسة',
'settings.companion.screenCapture': 'لقطة الشاشة',
'settings.companion.appContext': 'سياق التطبيق',
// Share cards (#5006).
'share.button': 'مشاركة',
'share.modalTitle': 'شارك هذه اللحظة',
@@ -88,8 +110,6 @@ const messages: TranslationMap = {
'connections.header.search': 'اضبط البحث على الويب لوكيلك.',
'connections.header.wallet': 'رصيد محفظتك المحلية وعبارة الاسترداد الخاصة بك.',
'connections.header.screen': 'دع وكيلك يرى شاشتك ليقدم مساعدة سياقية في الوقت المناسب.',
'connections.header.desktopAgent': 'دع وكيلك يفتح التطبيقات ويتحكم في سطح مكتبك نيابةً عنك.',
'connections.header.companion': 'رفيق سطح مكتب عائم للمساعدة السريعة والدائمة.',
'connections.header.composioKey': 'أضف مفتاح API الخاص بـ Composio لفتح مئات تكاملات التطبيقات.',
'connections.llm.apiKeys': 'مفاتيح API والتوجيه',
'connections.welcome.nav': 'مرحبًا',
@@ -723,8 +743,6 @@ const messages: TranslationMap = {
'settings.assistant.backgroundActivityDesc': 'تحكم في مدى نشاط مساعدك في الخلفية',
'settings.assistant.screenAwareness': 'الوعي بالشاشة',
'settings.assistant.screenAwarenessDesc': 'اسمح للمساعد برؤية النافذة النشطة',
'settings.assistant.desktopCompanion': 'المرافق لسطح المكتب',
'settings.assistant.desktopCompanionDesc': 'وضع المرافق الدائم مع اختصار شريط النظام',
'settings.assistant.permissions': 'الأذونات',
'settings.assistant.permissionsDesc': 'اختر ما يمكن للمساعد فعله والمكان الذي يمكنه العمل فيه',
'settings.privacySecurity.privacy': 'الخصوصية',
@@ -2467,28 +2485,6 @@ const messages: TranslationMap = {
'في ألعاب وضع ملء الشاشة الحصري لن يظهر التراكب: ستسمع التنبيه الصوتي فقط. انتقل إلى وضع ملء الشاشة بلا إطار لرؤية التراكب.',
'pttOverlay.listening': 'يستمع…',
'pttOverlay.idle': 'في الانتظار',
'autocomplete.title': 'الإكمال التلقائي',
'autocomplete.settings': 'الإعدادات',
'autocomplete.acceptWithTab': 'قبول بـ Tab',
'autocomplete.stylePreset': 'نمط ضبط مسبق',
'autocomplete.style.balanced': 'متوازن',
'autocomplete.style.concise': 'موجز',
'autocomplete.style.formal': 'رسمي',
'autocomplete.style.casual': 'غير رسمي',
'autocomplete.style.custom': 'مخصص',
'autocomplete.disabledApps': 'التطبيقات المعطّلة (رمز حزمة/تطبيق واحد في كل سطر)',
'autocomplete.saveSettings': 'حفظ الإعدادات',
'autocomplete.saving': 'جارٍ الحفظ…',
'autocomplete.runtime': 'بيئة التشغيل',
'autocomplete.running': 'يعمل',
'autocomplete.start': 'تشغيل',
'autocomplete.stop': 'إيقاف',
'autocomplete.settingsSaved': 'تم حفظ إعدادات الإكمال التلقائي.',
'autocomplete.started': 'تم تشغيل الإكمال التلقائي.',
'autocomplete.didNotStart': 'لم يبدأ الإكمال التلقائي. تحقق مما إذا كان مفعّلاً.',
'autocomplete.stopped': 'تم إيقاف الإكمال التلقائي.',
'autocomplete.advancedSettings': 'إعدادات متقدمة',
'autocomplete.debugTitle': 'تصحيح الإكمال التلقائي',
'chat.agentChat': 'محادثة الوكيل',
'chat.overrides': 'التجاوزات',
'chat.model': 'النموذج',
@@ -4389,8 +4385,6 @@ const messages: TranslationMap = {
'pages.settings.aiSection.title': 'الذكاء الاصطناعي',
'settings.developerMenu.composio.title': 'Composio',
'settings.developerMenu.composio.desc': 'وضع التوجيه ومشغلات التكامل وأرشيف محفوظات التشغيل.',
'pages.settings.features.desktopCompanion': 'الرفيق المكتبي',
'pages.settings.features.desktopCompanionDesc': 'مساعد صوتي يدرك الشاشة: يستمع ويرى ويتحدث ويشير',
'pages.settings.features.messagingChannels': 'قنوات المراسلة',
'pages.settings.features.messagingChannelsDesc': 'وصف قنوات المراسلة',
'pages.settings.features.notifications': 'الإشعارات',
@@ -4803,60 +4797,6 @@ const messages: TranslationMap = {
'settings.ai.workload.changeModel': 'تغيير النموذج',
'settings.ai.workload.chooseModel': 'اختر النموذج',
'settings.ai.provider.ollama': 'Ollama',
'settings.autocomplete.appFilter.acceptSuggestion': 'قبول الاقتراح',
'settings.autocomplete.appFilter.app': 'التطبيق',
'settings.autocomplete.appFilter.currentSuggestion': 'الاقتراح الحالي',
'settings.autocomplete.appFilter.contextOverride': 'تجاوز السياق (اختياري)',
'settings.autocomplete.appFilter.debounce': 'رفض',
'settings.autocomplete.appFilter.debugFocus': 'تصحيح التركيز',
'settings.autocomplete.appFilter.enabled': 'ممكّن',
'settings.autocomplete.appFilter.getSuggestion': 'الحصول على اقتراح',
'settings.autocomplete.appFilter.lastError': 'الخطأ الأخير',
'settings.autocomplete.appFilter.liveLogs': 'السجلات المباشرة',
'settings.autocomplete.appFilter.model': 'النموذج',
'settings.autocomplete.appFilter.noLogs': ') :',
'settings.autocomplete.appFilter.phase': 'المرحلة',
'settings.autocomplete.appFilter.platformSupported': 'النظام الأساسي المدعوم',
'settings.autocomplete.appFilter.refreshStatus': 'جارٍ التحديث…',
'settings.autocomplete.appFilter.refreshing': 'جارٍ التحديث…',
'settings.autocomplete.appFilter.running': 'قيد التشغيل',
'settings.autocomplete.appFilter.runtime': 'بيئة التشغيل',
'settings.autocomplete.appFilter.test': 'اختبار',
'settings.autocomplete.completionStyle.acceptedCompletion':
'تم تخزين {count} إكمال مقبول: يُستخدم لتخصيص الاقتراحات المستقبلية.',
'settings.autocomplete.completionStyle.acceptedCompletions':
'تم تخزين {count} إكمالات مقبولة: تُستخدم لتخصيص الاقتراحات المستقبلية.',
'settings.autocomplete.completionStyle.clearHistory': 'جارٍ المسح…',
'settings.autocomplete.completionStyle.clearing': 'جارٍ المسح…',
'settings.autocomplete.completionStyle.debounce': 'تأخير (ms)',
'settings.autocomplete.completionStyle.enabled': 'مفعّل',
'settings.autocomplete.completionStyle.maxChars': 'الحد الأقصى للأحرف',
'settings.autocomplete.completionStyle.noHistory':
'لا توجد إكمالات مقبولة بعد. اقبل الاقتراحات بـ Tab للبدء بالتخصيص.',
'settings.autocomplete.completionStyle.overlayTtl': 'مدة ظهور التراكب (ms)',
'settings.autocomplete.completionStyle.personalizationHistory': 'سجل التخصيص',
'settings.autocomplete.completionStyle.styleExamples': 'أمثلة الأسلوب (مثال لكل سطر)',
'settings.autocomplete.completionStyle.styleInstructions': 'تعليمات الأسلوب',
'settings.autocomplete.debug.acceptedPrefix': 'المقبول: {value}',
'settings.autocomplete.debug.acceptFailed': 'فشل قبول الاقتراح',
'settings.autocomplete.debug.alreadyRunning': 'الإكمال التلقائي قيد التشغيل بالفعل.',
'settings.autocomplete.debug.clearHistoryFailed': 'فشل في مسح السجل',
'settings.autocomplete.debug.didNotStart': 'لم يبدأ الإكمال التلقائي.',
'settings.autocomplete.debug.disabledInSettings':
'تم تعطيل الإكمال التلقائي في الإعدادات. تمكينه وحفظه أولا.',
'settings.autocomplete.debug.fetchSuggestionFailed': 'فشل جلب الاقتراح الحالي',
'settings.autocomplete.debug.inspectFocusedElementFailed': 'فشل فحص العنصر الذي تم التركيز عليه',
'settings.autocomplete.debug.loadSettingsFailed': 'فشل تحميل إعدادات الإكمال التلقائي',
'settings.autocomplete.debug.noSuggestionApplied': 'لم يتم تطبيق أي اقتراح.',
'settings.autocomplete.debug.noSuggestionReturned': 'لم يتم إرجاع أي اقتراح.',
'settings.autocomplete.debug.refreshStatusFailed': 'فشل في تحديث حالة الإكمال التلقائي',
'settings.autocomplete.debug.saveAdvancedSettingsFailed': 'فشل في حفظ الإعدادات المتقدمة',
'settings.autocomplete.debug.startFailed': 'فشل في بدء الإكمال التلقائي',
'settings.autocomplete.debug.stopFailed': 'فشل في إيقاف الإكمال التلقائي',
'settings.autocomplete.debug.suggestionPrefix': 'الاقتراح: {value}',
'settings.autocomplete.shared.none': 'لا يوجد',
'settings.autocomplete.shared.notApplicable': 'غير متاح',
'settings.autocomplete.shared.unknown': 'غير معروف',
'settings.billing.autoRecharge.addAmount': 'أضف هذا المبلغ',
'settings.billing.autoRecharge.addCard': 'إضافة بطاقة',
'settings.billing.autoRecharge.amountHint': 'تلميح المبلغ',
@@ -4969,23 +4909,6 @@ const messages: TranslationMap = {
'settings.composio.saveErrorNoKey': 'فشل الحفظ. الوضع المباشر يتطلب مفتاح API غير فارغ.',
'settings.composio.saving': 'جارٍ الحفظ…',
'settings.composio.switching': 'جارٍ التبديل…',
'settings.companion.title': 'رفيق سطح المكتب',
'settings.companion.session': 'الجلسة',
'settings.companion.activeLabel': 'نشط',
'settings.companion.inactiveStatus': 'غير نشط',
'settings.companion.stopping': 'إيقاف...',
'settings.companion.stopSession': 'إيقاف الجلسة',
'settings.companion.starting': 'البدء...',
'settings.companion.startSession': 'بدء الجلسة',
'settings.companion.sessionId': 'معرف الجلسة',
'settings.companion.turns': 'يتحول إلى',
'settings.companion.remaining': 'تكوين',
'settings.companion.configuration': 'المتبقي',
'settings.companion.hotkey': 'مفتاح التشغيل السريع',
'settings.companion.activationMode': 'وضع التنشيط',
'settings.companion.sessionTtl': 'جلسة TTL',
'settings.companion.screenCapture': 'لقطة الشاشة',
'settings.companion.appContext': 'سياق التطبيق',
'settings.cron.jobs.commandRequired': 'الأمر مطلوب لمهام shell',
'settings.cron.jobs.createJob': 'مهمة مجدولة جديدة',
'settings.cron.jobs.created': 'تم إنشاء المهمة',
@@ -5402,9 +5325,6 @@ const messages: TranslationMap = {
'settings.developerMenu.mcpServer.desc': 'تكوين عملاء MCP الخارجيين للاتصال بـ OpenHuman',
'settings.developerMenu.autonomy.title': 'استقلالية الوكيل',
'settings.developerMenu.autonomy.desc': 'حدود معدل إجراءات الأدوات وعتبات الأمان',
'settings.developerMenu.autocomplete.title': 'الإكمال التلقائي',
'settings.developerMenu.autocomplete.desc':
'إعدادات الإكمال التلقائي المدعوم بالذكاء الاصطناعي ولوحة التصحيح',
'settings.developerMenu.voiceDebug.title': 'الصوت (تصحيح)',
'settings.developerMenu.voiceDebug.desc': 'حالة وقت التشغيل لإملاء الصوت وإعدادات التصحيح',
'settings.mcpServer.title': 'Xqx0x',
@@ -5852,22 +5772,6 @@ const messages: TranslationMap = {
'settings.modelHealth.modal.apply': 'استبدال التطبيق',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
'settings.desktopAgent.title': 'وكيل سطح المكتب',
'settings.desktopAgent.description': 'تحقّق من الأذونات التي يحتاجها وكيل سطح المكتب وامنحها.',
'settings.desktopAgent.microphone': 'الميكروفون',
'settings.desktopAgent.grant': 'منح',
'settings.desktopAgent.notRequiredOnOs': 'غير مطلوب على هذا النظام',
'settings.desktopAgent.allGranted': 'كل شيء جاهز: يمتلك وكيل سطح المكتب جميع الأذونات اللازمة.',
'settings.desktopAgent.recheck': 'إعادة الفحص',
'settings.desktopAgent.restartAndRecheck': 'إعادة تشغيل النواة وإعادة الفحص',
'settings.desktopAgent.seamless.title': 'إجراءات الوكيل',
'settings.desktopAgent.seamless.label': 'دع الوكيل يتصرّف دون طلب الإذن',
'settings.desktopAgent.seamless.description':
'امنح الوصول الكامل واعتمد إجراءات سطح المكتب تلقائيًا (فتح التطبيقات والتحكّم في واجهاتها) حتى ينفّذها الوكيل دون مطالبة بالموافقة.',
'settings.desktopAgent.seamless.note':
'إجراءات سطح المكتب وحدها هي التي تتخطّى المطالبة: أما تنفيذ أوامر الصدفة وتحرير الملفات وطلبات الشبكة وتثبيت البرامج فما زالت تطلب الإذن. إيقاف هذا الخيار يوقف الاعتماد التلقائي لإجراءات سطح المكتب لكنه يترك مستوى الوصول دون تغيير.',
'settings.desktopAgent.beta': 'تجريبي: وكيل سطح المكتب تجريبي وقد لا يعمل دائمًا كما هو متوقّع.',
'settings.desktopAgent.wakeWordHint': 'قل ”Hey Tiny“ قبل أمرك لتنبيه الوكيل.',
'settings.screenIntel.permissions.accessibility': 'إمكانية الوصول',
'settings.screenIntel.permissions.grantHint': 'تلميح المنح',
'settings.screenIntel.permissions.inputMonitoring': 'مراقبة الإدخال',
@@ -6138,21 +6042,6 @@ const messages: TranslationMap = {
'skills.resource.tree.templates': 'القوالب',
'skills.resource.tree.empty': 'لا توجد موارد مجمّعة.',
'skills.search.placeholder': 'بحث',
'skills.setup.autocomplete.acceptKey': 'مفتاح القبول',
'skills.setup.autocomplete.activeDesc': 'وصف النشاط',
'skills.setup.autocomplete.activeTitle': 'الإكمال التلقائي نشط',
'skills.setup.autocomplete.customizeSettings': 'تخصيص الإعدادات',
'skills.setup.autocomplete.debounce': 'التأخير',
'skills.setup.autocomplete.description': 'الوصف',
'skills.setup.autocomplete.enableBtn': 'جارٍ التفعيل...',
'skills.setup.autocomplete.enableError': 'فشل تفعيل الإكمال التلقائي',
'skills.setup.autocomplete.enabling': 'جارٍ التفعيل...',
'skills.setup.autocomplete.notSupported': 'غير مدعوم',
'skills.setup.autocomplete.stepEnable': 'تفعيل الإكمالات المضمّنة',
'skills.setup.autocomplete.stepSuccess': 'جاهز للانطلاق',
'skills.setup.autocomplete.stylePreset': 'نمط ضبط مسبق',
'skills.setup.autocomplete.stylePresetValue': 'متوازن (قابل للضبط لاحقًا)',
'skills.setup.autocomplete.title': 'الإكمال التلقائي للنص',
'skills.setup.screenIntel.activeDesc': 'وصف النشاط',
'skills.setup.screenIntel.activeTitle': 'ذكاء الشاشة مفعّل',
'skills.setup.screenIntel.advancedSettings': 'إعدادات متقدمة',
@@ -6622,9 +6511,6 @@ const messages: TranslationMap = {
'chat.files.error.resolve_failed': 'تعذّر تحديد الملف. حاول مرة أخرى.',
'chat.files.error.download_failed': 'فشل التنزيل. حاول مرة أخرى.',
'chat.files.error.delete_failed': 'تعذّر حذف الملف. حاول مرة أخرى.',
'autocomplete.debounceMs': 'مهلة الإدخال (مللي ثانية)',
'autocomplete.maxChars': 'أقصى عدد لأحرف السياق',
'autocomplete.overlayTtlMs': 'مهلة الطبقة (مللي ثانية)',
'memory.tab.council': 'Council',
'activity.tabs.automations': 'أتمتة',
'activity.tabs.automationsDescription':
@@ -6659,130 +6545,6 @@ const messages: TranslationMap = {
'intelligence.agents.tier.chat': 'دردشة',
'intelligence.agents.tier.reasoning': 'استدلال',
'intelligence.agents.tier.worker': 'عامل',
'modelCouncil.title': 'مجلس النماذج',
'modelCouncil.intro':
'اطرح سؤالاً واحصل على إجابات مستقلة من عدة نماذج بالتوازي، ثم يوّلف نموذج الرئيس نقاط الاتفاق والاختلاف وما يضيفه كل نموذج.',
'modelCouncil.listTitle': 'Councils',
'modelCouncil.listIntro':
'اختر مجلسًا محفوظًا، أو عدّل وكلاءه وقاضيه، أو أنشئ مجلسًا جديدًا لنوع محدد من القرارات.',
'modelCouncil.addCouncil': 'مجلس جديد',
'modelCouncil.openCouncil': 'فتح المجلس',
'modelCouncil.editCouncil': 'تعديل المجلس',
'modelCouncil.editCurrentCouncil': 'تعديل المجلس الحالي',
'modelCouncil.editCouncilAria': 'تعديل {name}',
'modelCouncil.deleteCouncilAria': 'حذف {name}',
'modelCouncil.backToCouncils': 'العودة إلى المجالس',
'modelCouncil.saveCouncil': 'حفظ المجلس',
'modelCouncil.savingCouncil': 'Saving…',
'modelCouncil.cancelEdit': 'Cancel',
'modelCouncil.loadingCouncils': 'جارٍ تحميل المجالس…',
'modelCouncil.noCouncils': 'لا توجد مجالس بعد. أضف واحدًا للبدء.',
'modelCouncil.noCouncilDescription': 'بدون وصف',
'modelCouncil.registryErrorPrefix': 'فشل سجل المجالس:',
'modelCouncil.councilNameLabel': 'اسم المجلس',
'modelCouncil.councilDescriptionLabel': 'Description',
'modelCouncil.councilDescriptionPlaceholder': 'متى يُستخدم هذا المجلس',
'modelCouncil.selectModel': 'Select',
'modelCouncil.modelPickerHelp': 'اختر تلميح توجيه أو حدد نموذج مزود بدقة.',
'modelCouncil.closeModelPicker': 'Close',
'modelCouncil.hint.chat': 'محادثة',
'modelCouncil.hint.code': 'الشيفرة',
'modelCouncil.hint.default': 'افتراضي',
'modelCouncil.hint.reasoning': 'الاستدلال',
'modelCouncil.hint.summarize': 'تلخيص',
'modelCouncil.modelPickerHints': 'Hints',
'modelCouncil.modelPickerProviderModel': 'المزود + النموذج',
'modelCouncil.modelProviderLabel': 'مزود النموذج',
'modelCouncil.modelIdLabel': 'معرّف النموذج',
'modelCouncil.useProviderModel': 'استخدام نموذج المزود',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'اطرح سؤالاً على المجلس…',
'modelCouncil.membersLabel': 'نماذج الأعضاء',
'modelCouncil.maxMembersNote': 'حتى {max}',
'modelCouncil.memberPlaceholder': 'مثال: gpt-5.2',
'modelCouncil.memberAria': 'نموذج العضو {n}',
'modelCouncil.removeMemberAria': 'إزالة نموذج العضو {n}',
'modelCouncil.addMember': '+ إضافة نموذج',
'modelCouncil.settingsTitle': 'إعدادات المجلس',
'modelCouncil.settingsSummary': '{count} محلفين · القاضي: {judge}',
'modelCouncil.juryCountLabel': 'عدد المحلفين',
'modelCouncil.debateRoundsLabel': 'جولات النقاش',
'modelCouncil.debateRoundsHelp':
'يستمر كل محلّف في النقاش عبر جولات قبل أن يكتب القاضي التوليف النهائي.',
'modelCouncil.judgeAgentLabel': 'وكيل القاضي',
'modelCouncil.defaultJudge': 'القاضي الافتراضي',
'modelCouncil.savedProfile': 'ملف محفوظ',
'modelCouncil.customAgent': 'وكيل مخصص',
'modelCouncil.judgeProfileLabel': 'ملف القاضي',
'modelCouncil.chooseProfile': 'اختر ملفا',
'modelCouncil.judgeNameLabel': 'اسم القاضي',
'modelCouncil.judgeNamePlaceholder': 'القاضي الرئيسي',
'modelCouncil.rosterHeading': 'قائمة المجلس',
'modelCouncil.rosterHelp':
'يمكن لكل محلف أن يرث الوكيل الافتراضي، أو يستخدم ملفا محفوظا، أو يصبح ملفا مخصصا لهذا التشغيل.',
'modelCouncil.loadingProfiles': 'جار تحميل الملفات…',
'modelCouncil.jurorLabel': 'محلف {n}',
'modelCouncil.profileModeLabel': 'مصدر الملف',
'modelCouncil.mode.default': 'افتراضي',
'modelCouncil.mode.profile': 'ملف',
'modelCouncil.mode.custom': 'مخصص',
'modelCouncil.memberProfileAria': 'ملف المحلف {n}',
'modelCouncil.memberNameAria': 'اسم المحلف {n}',
'modelCouncil.memberNamePlaceholder': 'اسم الوكيل',
'modelCouncil.profileModelPlaceholder': 'تجاوز نموذج الملف',
'modelCouncil.memberBriefAria': 'موجز المحلف {n}',
'modelCouncil.memberBriefPlaceholder': 'منظور هذا المحلف',
'modelCouncil.jurorFallback': 'محلف',
'modelCouncil.sharedReasoningLabel': 'ملف التفكير المشترك',
'modelCouncil.sharedReasoningHelp':
'يعمل المجلس على هذه المسودة المشتركة قبل أن يكتب القاضي الخلاصة.',
'modelCouncil.liveScratchpadHelp':
'تُضاف تحديثات المحلّفين هنا بعد كل جولة، ثم تُغذّى في الجولة التالية.',
'modelCouncil.liveScratchpadBadge': 'Live',
'modelCouncil.scratchpadRoundHeading': 'تحديثات الجولة {round}',
'modelCouncil.scratchpadNoResponse': 'لا توجد استجابة',
'modelCouncil.chairLabel': 'نموذج الرئيس',
'modelCouncil.chairPlaceholder': 'مثال: claude-opus-4-8',
'modelCouncil.chairHelp': 'يقرأ الرئيس كل إجابة من الأعضاء ويكتب التوليف.',
'modelCouncil.run': 'انعقاد المجلس',
'modelCouncil.running': 'Convening…',
'modelCouncil.runningHint': 'جارٍ استعلام الأعضاء بالتوازي، ثم التوليف…',
'modelCouncil.deliberationHeading': 'مداولات المجلس',
'modelCouncil.deliberationHelp':
'تُظهر الرموز تفكير كل وكيل أثناء استخدام ملف الاستدلال المشترك.',
'modelCouncil.debateRoundInstruction':
'ردّ على المحلّفين الآخرين. حافظ على الخلافات، عدّل رأيك عند الضرورة، واجعل استنتاجك الحالي صريحًا.',
'modelCouncil.debateFinalInstruction':
'هذه جولتك الأخيرة كمحلّف. توافق حيث تدعم الأدلة، أشر إلى الخلافات المتبقية، وقدّم للقاضي استنتاجًا واضحًا.',
'modelCouncil.roundLabel': 'الجولة {round}',
'modelCouncil.currentRoundThinking': 'متابعة الجولة التالية…',
'modelCouncil.thinkingBadge': 'Thinking',
'modelCouncil.thinkingWithBrief': 'العمل من هذا المنظور: {brief}',
'modelCouncil.thought.evidence':
'فحص الأدلة والافتراضات وما يحتويه ملف الاستدلال المشترك بالفعل.',
'modelCouncil.thought.plan': 'رسم المسار العملي وأين قد يتعثر التنفيذ.',
'modelCouncil.thought.risk': 'البحث عن أنماط الفشل والسياق المفقود والاستنتاجات الهشة.',
'modelCouncil.thought.tradeoffs': 'مقارنة المفاضلات قبل تقديم موقف موجز للقاضي.',
'modelCouncil.thought.synthesis': 'إعداد رؤية مميزة ليتمكن القاضي من مقارنة الخلافات الحقيقية.',
'modelCouncil.judgeWaitingBadge': 'Judge',
'modelCouncil.judgeWaitingThought':
'بانتظار إجابات المحلّفين، ثم قراءة ملف الاستدلال المشترك للتوليف.',
'modelCouncil.judgeSynthesizingBadge': 'Synthesizing',
'modelCouncil.judgeSynthesizingThought':
'جارٍ قراءة أفكار المحلّفين المباشرة وكتابة التوليف النهائي للمجلس.',
'modelCouncil.errorPrefix': 'فشل المجلس:',
'modelCouncil.resultsHeading': 'نتائج المجلس',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'بواسطة {model}',
'modelCouncil.usageHeading': 'استخدام النقاش',
'modelCouncil.usageEstimated':
'عدد الرموز المقدّر من مطالبات النقاش والاستجابات. يتطلب التسعير الفعلي استخدام المزود من RPC الخاص بالمجلس.',
'modelCouncil.usageEstimatedBadge': 'رموز مقدّرة',
'modelCouncil.usageInputTokens': 'Input',
'modelCouncil.usageOutputTokens': 'Output',
'modelCouncil.usageTotalTokens': 'Total',
'graphCohesion.brokerBadge': 'وسيط',
'graphCohesion.brokerTitle':
'ثقب بنيوي: جيران هذا الكيان غير مترابطين فيما بينهم: وهو الرابط الوحيد بينهم.',
+23 -271
View File
@@ -3,6 +3,29 @@ import type { TranslationMap } from './types';
// Bengali (বাংলা) translations. Keys mirror en.ts; missing/
// English-identical values fall back to English via I18nContext.resolveEn().
const messages: TranslationMap = {
'connections.header.companion': 'দ্রুত, সর্বদা-সক্রিয় সহায়তার জন্য একটি ভাসমান ডেস্কটপ সঙ্গী।',
'settings.assistant.desktopCompanion': 'ডেস্কটপ সঙ্গী',
'settings.assistant.desktopCompanionDesc': 'সিস্টেম ট্রে শর্টকাট সহ সর্বদা-চালু সঙ্গী মোড',
'pages.settings.features.desktopCompanion': 'ডেস্কটপ কম্প্যানিয়ন',
'pages.settings.features.desktopCompanionDesc':
'স্ক্রিন সচেতনতা সহ ভয়েস সহকারী: শোনে, দেখে, কথা বলে, নির্দেশ করে',
'settings.companion.title': 'ডেস্কটপ সঙ্গী',
'settings.companion.session': 'সেশন',
'settings.companion.activeLabel': 'সক্রিয়',
'settings.companion.inactiveStatus': 'নিষ্ক্রিয়',
'settings.companion.stopping': 'থামানো হচ্ছে…',
'settings.companion.stopSession': 'সেশন বন্ধ করুন',
'settings.companion.starting': 'শুরু হচ্ছে...',
'settings.companion.startSession': 'সেশন শুরু করুন',
'settings.companion.sessionId': 'সেশন আইডি',
'settings.companion.turns': 'টার্ন',
'settings.companion.remaining': 'অবশিষ্ট',
'settings.companion.configuration': 'কনফিগারেশন',
'settings.companion.hotkey': 'হটকি',
'settings.companion.activationMode': 'সক্রিয়করণ মোড',
'settings.companion.sessionTtl': 'সেশন TTL',
'settings.companion.screenCapture': 'স্ক্রিন ক্যাপচার',
'settings.companion.appContext': 'অ্যাপ প্রসঙ্গ',
// Share cards (#5006).
'share.button': 'শেয়ার',
'share.modalTitle': 'এই মুহূর্তটি শেয়ার করুন',
@@ -90,9 +113,6 @@ const messages: TranslationMap = {
'connections.header.wallet': 'আপনার স্থানীয় ওয়ালেটের ব্যালেন্স এবং পুনরুদ্ধার বাক্যাংশ।',
'connections.header.screen':
'সময়োপযোগী, প্রাসঙ্গিক সহায়তা দিতে আপনার এজেন্টকে আপনার স্ক্রিন দেখতে দিন।',
'connections.header.desktopAgent':
'আপনার এজেন্টকে অ্যাপ খুলতে এবং আপনার হয়ে আপনার ডেস্কটপ নিয়ন্ত্রণ করতে দিন।',
'connections.header.companion': 'দ্রুত, সর্বদা-সক্রিয় সহায়তার জন্য একটি ভাসমান ডেস্কটপ সঙ্গী।',
'connections.header.composioKey':
'শত শত অ্যাপ ইন্টিগ্রেশন আনলক করতে একটি Composio API কী যোগ করুন।',
'connections.llm.apiKeys': 'API কী ও রাউটিং',
@@ -742,8 +762,6 @@ const messages: TranslationMap = {
'আপনার সহকারী পটভূমিতে কতটা সক্রিয় তা নিয়ন্ত্রণ করুন',
'settings.assistant.screenAwareness': 'স্ক্রিন সচেতনতা',
'settings.assistant.screenAwarenessDesc': 'সহকারীকে আপনার সক্রিয় উইন্ডো দেখতে দিন',
'settings.assistant.desktopCompanion': 'ডেস্কটপ সঙ্গী',
'settings.assistant.desktopCompanionDesc': 'সিস্টেম ট্রে শর্টকাট সহ সর্বদা-চালু সঙ্গী মোড',
'settings.assistant.permissions': 'অনুমতি',
'settings.assistant.permissionsDesc': 'সহকারী কী করতে পারে এবং কোথায় কাজ করতে পারে তা বেছে নিন',
'settings.privacySecurity.privacy': 'গোপনীয়তা',
@@ -2530,28 +2548,6 @@ const messages: TranslationMap = {
'এক্সক্লুসিভ ফুলস্ক্রিন গেমে ওভারলে রেন্ডার হবে না: আপনি শুধু সাউন্ড সংকেতটি শুনতে পাবেন। ওভারলে দেখতে বর্ডারলেস ফুলস্ক্রিনে যান।',
'pttOverlay.listening': 'শুনছে…',
'pttOverlay.idle': 'অপেক্ষায়',
'autocomplete.title': 'অটোকমপ্লিট',
'autocomplete.settings': 'সেটিংস',
'autocomplete.acceptWithTab': 'Tab দিয়ে গ্রহণ করুন',
'autocomplete.stylePreset': 'স্টাইল প্রিসেট',
'autocomplete.style.balanced': 'ব্যালান্সড',
'autocomplete.style.concise': 'সংক্ষিপ্ত',
'autocomplete.style.formal': 'আনুষ্ঠানিক',
'autocomplete.style.casual': 'অনানুষ্ঠানিক',
'autocomplete.style.custom': 'কাস্টম',
'autocomplete.disabledApps': 'নিষ্ক্রিয় অ্যাপ (প্রতি লাইনে একটি বান্ডেল/অ্যাপ টোকেন)',
'autocomplete.saveSettings': 'সেটিংস সংরক্ষণ করুন',
'autocomplete.saving': 'সংরক্ষণ হচ্ছে…',
'autocomplete.runtime': 'রানটাইম',
'autocomplete.running': 'চলছে',
'autocomplete.start': 'শুরু করুন',
'autocomplete.stop': 'বন্ধ করুন',
'autocomplete.settingsSaved': 'অটোকমপ্লিট সেটিংস সংরক্ষিত।',
'autocomplete.started': 'অটোকমপ্লিট শুরু হয়েছে।',
'autocomplete.didNotStart': 'অটোকমপ্লিট শুরু হয়নি। এটি সক্রিয় কিনা পরীক্ষা করুন।',
'autocomplete.stopped': 'অটোকমপ্লিট বন্ধ হয়েছে।',
'autocomplete.advancedSettings': 'অ্যাডভান্সড সেটিংস',
'autocomplete.debugTitle': 'অটোকমপ্লিট ডিবাগ',
'chat.agentChat': 'এজেন্ট চ্যাট',
'chat.overrides': 'ওভাররাইড',
'chat.model': 'মডেল',
@@ -4502,9 +4498,6 @@ const messages: TranslationMap = {
'pages.settings.aiSection.title': 'AI',
'settings.developerMenu.composio.title': 'Composio',
'settings.developerMenu.composio.desc': 'রাউটিং মোড, ইন্টিগ্রেশন ট্রিগার, এবং ট্রিগার ইতিহাস।',
'pages.settings.features.desktopCompanion': 'ডেস্কটপ কম্প্যানিয়ন',
'pages.settings.features.desktopCompanionDesc':
'স্ক্রিন সচেতনতা সহ ভয়েস সহকারী: শোনে, দেখে, কথা বলে, নির্দেশ করে',
'pages.settings.features.messagingChannels': 'মেসেজিং চ্যানেল',
'pages.settings.features.messagingChannelsDesc': 'মেসেজিং চ্যানেলের বিবরণ',
'pages.settings.features.notifications': 'বিজ্ঞপ্তি',
@@ -4919,63 +4912,6 @@ const messages: TranslationMap = {
'settings.ai.workload.changeModel': 'কোনো মডেল নির্বাচিত হয়নি',
'settings.ai.workload.chooseModel': 'মডেল পরিবর্তন করুন [[I18N_SEP_92731] মডেল চয়ন করুন',
'settings.ai.provider.ollama': 'Ollama',
'settings.autocomplete.appFilter.acceptSuggestion': 'পরামর্শ গ্রহণ করুন',
'settings.autocomplete.appFilter.app': 'অ্যাপ',
'settings.autocomplete.appFilter.currentSuggestion': 'বর্তমান পরামর্শ',
'settings.autocomplete.appFilter.contextOverride': 'কন্টেক্সট ওভাররাইড (ঐচ্ছিক)',
'settings.autocomplete.appFilter.debounce': 'ডিবাউন্স',
'settings.autocomplete.appFilter.debugFocus': 'ডিবাগ ফোকাস',
'settings.autocomplete.appFilter.enabled': 'সক্ষম',
'settings.autocomplete.appFilter.getSuggestion': 'পরামর্শ পান',
'settings.autocomplete.appFilter.lastError': 'শেষ ত্রুটি',
'settings.autocomplete.appFilter.liveLogs': 'লাইভ লগ',
'settings.autocomplete.appFilter.model': 'মডেল',
'settings.autocomplete.appFilter.noLogs': ') :',
'settings.autocomplete.appFilter.phase': 'ফেজ',
'settings.autocomplete.appFilter.platformSupported': 'প্ল্যাটফর্ম সমর্থিত',
'settings.autocomplete.appFilter.refreshStatus': 'রিফ্রেশ হচ্ছে…',
'settings.autocomplete.appFilter.refreshing': 'রিফ্রেশ হচ্ছে…',
'settings.autocomplete.appFilter.running': 'চলমান',
'settings.autocomplete.appFilter.runtime': 'রানটাইম',
'settings.autocomplete.appFilter.test': 'পরীক্ষা',
'settings.autocomplete.completionStyle.acceptedCompletion':
'{count}টি গৃহীত completion সংরক্ষিত: ভবিষ্যৎ পরামর্শ ব্যক্তিগতকরণে ব্যবহৃত হয়।',
'settings.autocomplete.completionStyle.acceptedCompletions':
'{count}টি গৃহীত completion সংরক্ষিত: ভবিষ্যৎ পরামর্শ ব্যক্তিগতকরণে ব্যবহৃত হয়।',
'settings.autocomplete.completionStyle.clearHistory': 'পরিষ্কার হচ্ছে…',
'settings.autocomplete.completionStyle.clearing': 'পরিষ্কার হচ্ছে…',
'settings.autocomplete.completionStyle.debounce': 'ডিবাউন্স (ms)',
'settings.autocomplete.completionStyle.enabled': 'সক্রিয়',
'settings.autocomplete.completionStyle.maxChars': 'সর্বোচ্চ অক্ষর',
'settings.autocomplete.completionStyle.noHistory':
'এখনো কোনো গৃহীত কমপ্লিশন নেই। ব্যক্তিগতকৃত করা শুরু করতে Tab দিয়ে পরামর্শ গ্রহণ করুন।',
'settings.autocomplete.completionStyle.overlayTtl': 'ওভারলে TTL (ms)',
'settings.autocomplete.completionStyle.personalizationHistory': 'ব্যক্তিগতকরণ ইতিহাস',
'settings.autocomplete.completionStyle.styleExamples': 'স্টাইল উদাহরণ (প্রতি লাইনে একটি)',
'settings.autocomplete.completionStyle.styleInstructions': 'স্টাইল নির্দেশনা',
'settings.autocomplete.debug.acceptedPrefix': 'গৃহীত: {value}',
'settings.autocomplete.debug.acceptFailed': 'প্রস্তাবনা গ্রহণ করতে ব্যর্থ হয়েছে',
'settings.autocomplete.debug.alreadyRunning': 'স্বয়ংসম্পূর্ণ ইতিমধ্যেই চলছে৷',
'settings.autocomplete.debug.clearHistoryFailed': 'ইতিহাস সাফ করতে ব্যর্থ হয়েছে',
'settings.autocomplete.debug.didNotStart': 'স্বয়ংসম্পূর্ণ শুরু হয়নি৷',
'settings.autocomplete.debug.disabledInSettings':
'সেটিংসে স্বয়ংসম্পূর্ণ অক্ষম করা আছে। এটি সক্রিয় করুন এবং প্রথমে সংরক্ষণ করুন।',
'settings.autocomplete.debug.fetchSuggestionFailed': 'বর্তমান প্রস্তাবনা আনতে ব্যর্থ হয়েছে',
'settings.autocomplete.debug.inspectFocusedElementFailed':
'ফোকাস করা উপাদান পরিদর্শন করতে ব্যর্থ হয়েছে',
'settings.autocomplete.debug.loadSettingsFailed': 'স্বয়ংসম্পূর্ণ সেটিংস লোড করতে ব্যর্থ হয়েছে',
'settings.autocomplete.debug.noSuggestionApplied': 'কোন পরামর্শ প্রয়োগ করা হয়নি.',
'settings.autocomplete.debug.noSuggestionReturned': 'কোনো পরামর্শ ফেরত দেওয়া হয়নি৷',
'settings.autocomplete.debug.refreshStatusFailed':
'স্বয়ংসম্পূর্ণ স্থিতি রিফ্রেশ করতে ব্যর্থ হয়েছে',
'settings.autocomplete.debug.saveAdvancedSettingsFailed':
'উন্নত সেটিংস সংরক্ষণ করতে ব্যর্থ হয়েছে',
'settings.autocomplete.debug.startFailed': 'স্বয়ংসম্পূর্ণ শুরু করতে ব্যর্থ হয়েছে',
'settings.autocomplete.debug.stopFailed': 'স্বয়ংসম্পূর্ণ বন্ধ করতে ব্যর্থ হয়েছে',
'settings.autocomplete.debug.suggestionPrefix': 'সাজেশন: {value}',
'settings.autocomplete.shared.none': 'কোনটিই',
'settings.autocomplete.shared.notApplicable': 'n/a',
'settings.autocomplete.shared.unknown': 'অজানা',
'settings.billing.autoRecharge.addAmount': 'এই পরিমাণ যোগ করুন',
'settings.billing.autoRecharge.addCard': 'কার্ড যোগ করুন',
'settings.billing.autoRecharge.amountHint': 'পরিমাণ হিন্ট',
@@ -5089,23 +5025,6 @@ const messages: TranslationMap = {
'settings.composio.saveErrorNoKey': 'সংরক্ষণ ব্যর্থ। Direct মোডের জন্য একটি API key প্রয়োজন।',
'settings.composio.saving': 'সংরক্ষণ হচ্ছে…',
'settings.composio.switching': 'স্যুইচ হচ্ছে…',
'settings.companion.title': 'ডেস্কটপ সঙ্গী',
'settings.companion.session': 'সেশন',
'settings.companion.activeLabel': 'সক্রিয়',
'settings.companion.inactiveStatus': 'নিষ্ক্রিয়',
'settings.companion.stopping': 'টপিং...',
'settings.companion.stopSession': 'স্টপ সেশন',
'settings.companion.starting': 'শুরু হচ্ছে...',
'settings.companion.startSession': 'শুরু হচ্ছে সেশন',
'settings.companion.sessionId': 'সেশন আইডি',
'settings.companion.turns': '92 টার্ন',
'settings.companion.remaining': 'অবশিষ্ট',
'settings.companion.configuration': 'কনফিগারেশন',
'settings.companion.hotkey': 'হটকি',
'settings.companion.activationMode': 'সক্রিয়করণ মোড',
'settings.companion.sessionTtl': 'সেশন TTL',
'settings.companion.screenCapture': 'স্ক্রিন ক্যাপচার',
'settings.companion.appContext': 'অ্যাপ প্রসঙ্গ',
'settings.cron.jobs.commandRequired': 'শেল জবের জন্য কমান্ড আবশ্যক',
'settings.cron.jobs.createJob': 'নতুন নির্ধারিত জব',
'settings.cron.jobs.created': 'জব তৈরি হয়েছে',
@@ -5528,8 +5447,6 @@ const messages: TranslationMap = {
'বাহ্যিক MCP ক্লায়েন্টগুলিকে OpenHuman-এ সংযুক্ত করতে কনফিগার করুন',
'settings.developerMenu.autonomy.title': 'এজেন্ট স্বায়ত্তশাসন',
'settings.developerMenu.autonomy.desc': 'টুল অ্যাকশনের রেট সীমা এবং নিরাপত্তা থ্রেশহোল্ড',
'settings.developerMenu.autocomplete.title': 'স্বয়ংসম্পূর্ণ',
'settings.developerMenu.autocomplete.desc': 'AI ইনলাইন স্বয়ংসম্পূর্ণ সেটিংস এবং ডিবাগ প্যানেল',
'settings.developerMenu.voiceDebug.title': 'ভয়েস (ডিবাগ)',
'settings.developerMenu.voiceDebug.desc': 'ভয়েস ডিক্টেশন রানটাইম স্ট্যাটাস এবং ডিবাগ সেটিংস',
'settings.mcpServer.title':
@@ -5989,25 +5906,6 @@ const messages: TranslationMap = {
'settings.modelHealth.modal.apply': '& বদলাও',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
'settings.desktopAgent.title': 'ডেস্কটপ এজেন্ট',
'settings.desktopAgent.description':
'ডেস্কটপ এজেন্টের প্রয়োজনীয় অনুমতিগুলি যাচাই করুন ও মঞ্জুর করুন।',
'settings.desktopAgent.microphone': 'মাইক্রোফোন',
'settings.desktopAgent.grant': 'মঞ্জুর করুন',
'settings.desktopAgent.notRequiredOnOs': 'এই অপারেটিং সিস্টেমে প্রয়োজন নেই',
'settings.desktopAgent.allGranted':
'সব প্রস্তুত: ডেস্কটপ এজেন্টের কাছে প্রয়োজনীয় সব অনুমতি রয়েছে।',
'settings.desktopAgent.recheck': 'পুনরায় যাচাই',
'settings.desktopAgent.restartAndRecheck': 'কোর পুনরায় চালু করে পুনরায় যাচাই করুন',
'settings.desktopAgent.seamless.title': 'এজেন্টের কাজ',
'settings.desktopAgent.seamless.label': 'এজেন্টকে অনুমতি না চেয়ে কাজ করতে দিন',
'settings.desktopAgent.seamless.description':
'সম্পূর্ণ অ্যাক্সেস দিন এবং ডেস্কটপের কাজ (অ্যাপ খোলা, অ্যাপের ইন্টারফেস নিয়ন্ত্রণ) স্বয়ংক্রিয়ভাবে অনুমোদন করুন, যাতে এজেন্ট অনুমোদনের অনুরোধ ছাড়াই সেগুলি চালায়।',
'settings.desktopAgent.seamless.note':
'শুধু ডেস্কটপের কাজই অনুরোধ এড়িয়ে যায়: শেল কমান্ড চালানো, ফাইল সম্পাদনা, নেটওয়ার্ক অনুরোধ এবং সফটওয়্যার ইনস্টল করা এখনও অনুমতি চায়। এটি বন্ধ করলে ডেস্কটপের কাজের স্বয়ংক্রিয় অনুমোদন থামে, তবে অ্যাক্সেসের স্তর অপরিবর্তিত থাকে।',
'settings.desktopAgent.beta':
'বিটা: ডেস্কটপ এজেন্ট পরীক্ষামূলক এবং সবসময় প্রত্যাশিতভাবে কাজ নাও করতে পারে।',
'settings.desktopAgent.wakeWordHint': 'এজেন্টকে জাগাতে আপনার আদেশের আগে ”Hey Tiny” বলুন।',
'settings.screenIntel.permissions.accessibility': 'অ্যাক্সেসিবিলিটি',
'settings.screenIntel.permissions.grantHint': 'গ্রান্ট হিন্ট',
'settings.screenIntel.permissions.inputMonitoring': 'ইনপুট মনিটরিং',
@@ -6283,21 +6181,6 @@ const messages: TranslationMap = {
'skills.resource.tree.templates': 'টেমপ্লেট',
'skills.resource.tree.empty': 'কোনো বান্ডেলড রিসোর্স নেই।',
'skills.search.placeholder': 'প্লেসহোল্ডার',
'skills.setup.autocomplete.acceptKey': 'অ্যাক্সেপ্ট কী',
'skills.setup.autocomplete.activeDesc': 'সক্রিয় বিবরণ',
'skills.setup.autocomplete.activeTitle': 'অটো-কমপ্লিট সক্রিয়',
'skills.setup.autocomplete.customizeSettings': 'সেটিংস কাস্টমাইজ করুন',
'skills.setup.autocomplete.debounce': 'ডিবাউন্স',
'skills.setup.autocomplete.description': 'বিবরণ',
'skills.setup.autocomplete.enableBtn': 'সক্রিয় হচ্ছে...',
'skills.setup.autocomplete.enableError': 'অটোকমপ্লিট সক্রিয় করতে ব্যর্থ',
'skills.setup.autocomplete.enabling': 'সক্রিয় হচ্ছে...',
'skills.setup.autocomplete.notSupported': 'সমর্থিত নয়',
'skills.setup.autocomplete.stepEnable': 'ইনলাইন কমপ্লিশন সক্রিয় করুন',
'skills.setup.autocomplete.stepSuccess': 'যেতে প্রস্তুত',
'skills.setup.autocomplete.stylePreset': 'স্টাইল প্রিসেট',
'skills.setup.autocomplete.stylePresetValue': 'ব্যালান্সড (পরে কনফিগারযোগ্য)',
'skills.setup.autocomplete.title': 'টেক্সট অটো-কমপ্লিট',
'skills.setup.screenIntel.activeDesc': 'সক্রিয় বিবরণ',
'skills.setup.screenIntel.activeTitle': 'স্ক্রিন ইন্টেলিজেন্স সক্রিয়',
'skills.setup.screenIntel.advancedSettings': 'অ্যাডভান্সড সেটিংস',
@@ -6777,9 +6660,6 @@ const messages: TranslationMap = {
'chat.files.error.resolve_failed': 'আর্টিফ্যাক্ট সমাধান করা যায়নি। আবার চেষ্টা করুন।',
'chat.files.error.download_failed': 'ডাউনলোড ব্যর্থ হয়েছে। আবার চেষ্টা করুন।',
'chat.files.error.delete_failed': 'ফাইল মোছা যায়নি। আবার চেষ্টা করুন।',
'autocomplete.debounceMs': 'ডিবাউন্স (মিলিসেকেন্ড)',
'autocomplete.maxChars': 'সর্বোচ্চ প্রসঙ্গ অক্ষর',
'autocomplete.overlayTtlMs': 'ওভারলে টাইমআউট (মিলিসেকেন্ড)',
'memory.tab.council': 'Council',
'activity.tabs.automations': 'অটোমেশন',
'activity.tabs.automationsDescription':
@@ -6814,134 +6694,6 @@ const messages: TranslationMap = {
'intelligence.agents.tier.chat': 'চ্যাট',
'intelligence.agents.tier.reasoning': 'যুক্তি',
'intelligence.agents.tier.worker': 'ওয়ার্কার',
'modelCouncil.title': 'মডেল পরিষদ',
'modelCouncil.intro':
'একটি প্রশ্ন করুন, সমান্তরালে একাধিক মডেল থেকে স্বতন্ত্র উত্তর পান, তারপর চেয়ার মডেল সংশ্লেষণ করে কোথায় তারা একমত, কোথায় দ্বিমত এবং প্রতিটি কী অনন্যভাবে যোগ করে।',
'modelCouncil.listTitle': 'Councils',
'modelCouncil.listIntro':
'সংরক্ষিত পরিষদ চয়ন করুন, এর এজেন্ট ও বিচারক সম্পাদনা করুন, বা নির্দিষ্ট ধরনের সিদ্ধান্তের জন্য নতুন পরিষদ তৈরি করুন।',
'modelCouncil.addCouncil': 'নতুন পরিষদ',
'modelCouncil.openCouncil': 'পরিষদ খুলুন',
'modelCouncil.editCouncil': 'পরিষদ সম্পাদনা করুন',
'modelCouncil.editCurrentCouncil': 'বর্তমান পরিষদ সম্পাদনা করুন',
'modelCouncil.editCouncilAria': '{name} সম্পাদনা করুন',
'modelCouncil.deleteCouncilAria': '{name} মুছুন',
'modelCouncil.backToCouncils': 'পরিষদে ফিরে যান',
'modelCouncil.saveCouncil': 'পরিষদ সংরক্ষণ করুন',
'modelCouncil.savingCouncil': 'Saving…',
'modelCouncil.cancelEdit': 'Cancel',
'modelCouncil.loadingCouncils': 'পরিষদ লোড হচ্ছে…',
'modelCouncil.noCouncils': 'এখনো কোনো পরিষদ নেই। শুরু করতে একটি যোগ করুন।',
'modelCouncil.noCouncilDescription': 'কোনো বিবরণ নেই',
'modelCouncil.registryErrorPrefix': 'পরিষদ রেজিস্ট্রি ব্যর্থ:',
'modelCouncil.councilNameLabel': 'পরিষদের নাম',
'modelCouncil.councilDescriptionLabel': 'Description',
'modelCouncil.councilDescriptionPlaceholder': 'এই পরিষদ কখন ব্যবহার করবেন',
'modelCouncil.selectModel': 'Select',
'modelCouncil.modelPickerHelp':
'রাউটিং ইঙ্গিত নির্বাচন করুন বা সুনির্দিষ্ট প্রদানকারী মডেল পিন করুন।',
'modelCouncil.closeModelPicker': 'Close',
'modelCouncil.hint.chat': 'চ্যাট',
'modelCouncil.hint.code': 'কোড',
'modelCouncil.hint.default': 'ডিফল্ট',
'modelCouncil.hint.reasoning': 'যুক্তি',
'modelCouncil.hint.summarize': 'সারসংক্ষেপ',
'modelCouncil.modelPickerHints': 'Hints',
'modelCouncil.modelPickerProviderModel': 'প্রদানকারী + মডেল',
'modelCouncil.modelProviderLabel': 'মডেল প্রদানকারী',
'modelCouncil.modelIdLabel': 'মডেল আইডি',
'modelCouncil.useProviderModel': 'প্রদানকারী মডেল ব্যবহার করুন',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'পরিষদকে একটি প্রশ্ন করুন…',
'modelCouncil.membersLabel': 'সদস্য মডেল',
'modelCouncil.maxMembersNote': 'সর্বোচ্চ {max}',
'modelCouncil.memberPlaceholder': 'যেমন gpt-5.2',
'modelCouncil.memberAria': 'সদস্য মডেল {n}',
'modelCouncil.removeMemberAria': 'সদস্য মডেল {n} সরান',
'modelCouncil.addMember': '+ মডেল যোগ করুন',
'modelCouncil.settingsTitle': 'কাউন্সিল সেটিংস',
'modelCouncil.settingsSummary': '{count} জন জুরর · বিচারক: {judge}',
'modelCouncil.juryCountLabel': 'জুরর সংখ্যা',
'modelCouncil.debateRoundsLabel': 'বিতর্কের রাউন্ড',
'modelCouncil.debateRoundsHelp':
'বিচারক চূড়ান্ত সংশ্লেষণ লেখার আগে প্রতিটি জুরি একাধিক রাউন্ডে বিতর্ক করে।',
'modelCouncil.judgeAgentLabel': 'বিচারক এজেন্ট',
'modelCouncil.defaultJudge': 'ডিফল্ট বিচারক',
'modelCouncil.savedProfile': 'সংরক্ষিত প্রোফাইল',
'modelCouncil.customAgent': 'কাস্টম এজেন্ট',
'modelCouncil.judgeProfileLabel': 'বিচারক প্রোফাইল',
'modelCouncil.chooseProfile': 'একটি প্রোফাইল বেছে নিন',
'modelCouncil.judgeNameLabel': 'বিচারকের নাম',
'modelCouncil.judgeNamePlaceholder': 'প্রধান বিচারক',
'modelCouncil.rosterHeading': 'কাউন্সিল তালিকা',
'modelCouncil.rosterHelp':
'প্রতিটি জুরর ডিফল্ট এজেন্ট নিতে পারে, সংরক্ষিত প্রোফাইল ব্যবহার করতে পারে, অথবা এই রানের জন্য কাস্টম প্রোফাইল হতে পারে।',
'modelCouncil.loadingProfiles': 'প্রোফাইল লোড হচ্ছে…',
'modelCouncil.jurorLabel': 'জুরর {n}',
'modelCouncil.profileModeLabel': 'প্রোফাইল উৎস',
'modelCouncil.mode.default': 'ডিফল্ট',
'modelCouncil.mode.profile': 'প্রোফাইল',
'modelCouncil.mode.custom': 'কাস্টম',
'modelCouncil.memberProfileAria': 'জুরর {n} প্রোফাইল',
'modelCouncil.memberNameAria': 'জুরর {n} নাম',
'modelCouncil.memberNamePlaceholder': 'এজেন্টের নাম',
'modelCouncil.profileModelPlaceholder': 'প্রোফাইল মডেল ওভাররাইড করুন',
'modelCouncil.memberBriefAria': 'জুরর {n} ব্রিফ',
'modelCouncil.memberBriefPlaceholder': 'এই জুররের দৃষ্টিভঙ্গি',
'modelCouncil.jurorFallback': 'জুরর',
'modelCouncil.sharedReasoningLabel': 'শেয়ার করা reasoning ফাইল',
'modelCouncil.sharedReasoningHelp':
'বিচারক সংশ্লেষ লেখার আগে কাউন্সিল এই শেয়ার করা খসড়ায় কাজ করে।',
'modelCouncil.liveScratchpadHelp':
'প্রতিটি রাউন্ডের পর জুরি আপডেট এখানে যোগ হয়, তারপর পরবর্তী রাউন্ডে ব্যবহৃত হয়।',
'modelCouncil.liveScratchpadBadge': 'Live',
'modelCouncil.scratchpadRoundHeading': 'রাউন্ড {round} আপডেট',
'modelCouncil.scratchpadNoResponse': 'কোনো প্রতিক্রিয়া নেই',
'modelCouncil.chairLabel': 'চেয়ার মডেল',
'modelCouncil.chairPlaceholder': 'যেমন claude-opus-4-8',
'modelCouncil.chairHelp': 'চেয়ার প্রতিটি সদস্যের উত্তর পড়ে এবং সংশ্লেষণ লেখে।',
'modelCouncil.run': 'পরিষদ আহ্বান করুন',
'modelCouncil.running': 'Convening…',
'modelCouncil.runningHint': 'সমান্তরালে সদস্যদের জিজ্ঞাসা করা হচ্ছে, তারপর সংশ্লেষণ…',
'modelCouncil.deliberationHeading': 'পরিষদ আলোচনা',
'modelCouncil.deliberationHelp':
'ভাগ করা যুক্তি ফাইল ব্যবহার হওয়ার সময় প্রতিটি এজেন্টের চিন্তার অবস্থা মাসকট দেখায়।',
'modelCouncil.debateRoundInstruction':
'অন্য জুরিদের উত্তর দিন। মতানৈক্য বজায় রাখুন, যুক্তিসঙ্গত হলে আপনার দৃষ্টিভঙ্গি সংশোধন করুন এবং আপনার বর্তমান সিদ্ধান্ত স্পষ্ট রাখুন।',
'modelCouncil.debateFinalInstruction':
'এটি আপনার শেষ জুরি পালা। যেখানে প্রমাণ সমর্থন করে সেখানে সম্মত হন, অবশিষ্ট মতানৈক্য চিহ্নিত করুন এবং বিচারককে একটি স্পষ্ট সিদ্ধান্ত দিন।',
'modelCouncil.roundLabel': 'রাউন্ড {round}',
'modelCouncil.currentRoundThinking': 'পরবর্তী রাউন্ড চালিয়ে যাচ্ছে…',
'modelCouncil.thinkingBadge': 'Thinking',
'modelCouncil.thinkingWithBrief': 'এই দৃষ্টিভঙ্গি থেকে কাজ করছে: {brief}',
'modelCouncil.thought.evidence':
'প্রমাণ, অনুমান এবং ভাগ করা যুক্তি ফাইলে ইতোমধ্যে কী আছে তা পরীক্ষা করছে।',
'modelCouncil.thought.plan':
'ব্যবহারিক পথ এবং কোথায় কার্যকরণ আটকে যেতে পারে তার রূপরেখা তৈরি করছে।',
'modelCouncil.thought.risk':
'ব্যর্থতার ধরন, অনুপস্থিত প্রসঙ্গ এবং ভঙ্গুর সিদ্ধান্তের সন্ধান করছে।',
'modelCouncil.thought.tradeoffs': 'বিচারককে সংক্ষিপ্ত অবস্থান দেওয়ার আগে ট্রেডঅফ তুলনা করছে।',
'modelCouncil.thought.synthesis':
'বিচারক প্রকৃত মতানৈক্য তুলনা করতে পারে এমন একটি স্বতন্ত্র দৃষ্টিভঙ্গি প্রস্তুত করছে।',
'modelCouncil.judgeWaitingBadge': 'Judge',
'modelCouncil.judgeWaitingThought':
'জুরিদের উত্তরের জন্য অপেক্ষা করছে, তারপর সংশ্লেষণের জন্য ভাগ করা যুক্তি ফাইল পড়বে।',
'modelCouncil.judgeSynthesizingBadge': 'Synthesizing',
'modelCouncil.judgeSynthesizingThought':
'জুরিদের লাইভ চিন্তা পড়ছে এবং পরিষদের চূড়ান্ত সংশ্লেষণ লিখছে।',
'modelCouncil.errorPrefix': 'পরিষদ ব্যর্থ:',
'modelCouncil.resultsHeading': 'পরিষদ ফলাফল',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': '{model} দ্বারা',
'modelCouncil.usageHeading': 'বিতর্ক ব্যবহার',
'modelCouncil.usageEstimated':
'বিতর্কের প্রম্পট ও প্রতিক্রিয়া থেকে আনুমানিক টোকেন সংখ্যা। প্রকৃত মূল্য নির্ধারণের জন্য পরিষদ RPC থেকে প্রদানকারী ব্যবহার প্রয়োজন।',
'modelCouncil.usageEstimatedBadge': 'আনুমানিক টোকেন',
'modelCouncil.usageInputTokens': 'Input',
'modelCouncil.usageOutputTokens': 'Output',
'modelCouncil.usageTotalTokens': 'Total',
'graphCohesion.brokerBadge': 'ব্রোকার',
'graphCohesion.brokerTitle':
'কাঠামোগত ছিদ্র: এই সত্তার প্রতিবেশীরা একে অপরের সাথে যুক্ত নয়: এটিই তাদের মধ্যে একমাত্র সংযোগ।',
+25 -284
View File
@@ -3,6 +3,31 @@ import type { TranslationMap } from './types';
// German (Deutsch) translations. Keys mirror en.ts; missing/
// English-identical values fall back to English via I18nContext.resolveEn().
const messages: TranslationMap = {
'connections.header.companion':
'Ein schwebender Desktop-Begleiter für schnelle, jederzeit verfügbare Hilfe.',
'settings.assistant.desktopCompanion': 'Desktop-Begleiter',
'settings.assistant.desktopCompanionDesc':
'Immer aktiver Begleiter-Modus mit System-Tray-Shortcut',
'pages.settings.features.desktopCompanion': 'Desktop-Begleiter',
'pages.settings.features.desktopCompanionDesc':
'Sprachassistent mit Bildschirmerkennung hört zu, sieht, spricht, zeigt',
'settings.companion.title': 'Desktop-Begleiter',
'settings.companion.session': 'Sitzung',
'settings.companion.activeLabel': 'Aktiv',
'settings.companion.inactiveStatus': 'Inaktiv',
'settings.companion.stopping': 'Wird gestoppt…',
'settings.companion.stopSession': 'Sitzung beenden',
'settings.companion.starting': 'Wird gestartet…',
'settings.companion.startSession': 'Sitzung starten',
'settings.companion.sessionId': 'Sitzungs-ID',
'settings.companion.turns': 'Interaktionen',
'settings.companion.remaining': 'Verbleibend',
'settings.companion.configuration': 'Konfiguration',
'settings.companion.hotkey': 'Hotkey',
'settings.companion.activationMode': 'Aktivierungsmodus',
'settings.companion.sessionTtl': 'Sitzungs-TTL',
'settings.companion.screenCapture': 'Bildschirmaufnahme',
'settings.companion.appContext': 'App-Kontext',
// Share cards (#5006).
'share.button': 'Teilen',
'share.modalTitle': 'Diesen Moment teilen',
@@ -101,10 +126,6 @@ const messages: TranslationMap = {
'Das Guthaben Ihrer lokalen Wallet und Ihre Wiederherstellungsphrase.',
'connections.header.screen':
'Lassen Sie Ihren Agenten Ihren Bildschirm sehen, um zeitnahe, kontextbezogene Hilfe zu bieten.',
'connections.header.desktopAgent':
'Lassen Sie Ihren Agenten Apps öffnen und Ihren Desktop in Ihrem Namen steuern.',
'connections.header.companion':
'Ein schwebender Desktop-Begleiter für schnelle, jederzeit verfügbare Hilfe.',
'connections.header.composioKey':
'Fügen Sie einen Composio-API-Schlüssel hinzu, um Hunderte von App-Integrationen freizuschalten.',
'connections.llm.apiKeys': 'API-Schlüssel & Routing',
@@ -773,9 +794,6 @@ const messages: TranslationMap = {
'Steuern, wie aktiv Ihr Assistent im Hintergrund arbeitet',
'settings.assistant.screenAwareness': 'Bildschirmbewusstsein',
'settings.assistant.screenAwarenessDesc': 'Dem Assistenten das aktive Fenster zeigen',
'settings.assistant.desktopCompanion': 'Desktop-Begleiter',
'settings.assistant.desktopCompanionDesc':
'Immer aktiver Begleiter-Modus mit System-Tray-Shortcut',
'settings.assistant.permissions': 'Berechtigungen',
'settings.assistant.permissionsDesc': 'Wähle, was der Assistent tun darf und wo er arbeiten kann',
'settings.privacySecurity.privacy': 'Datenschutz',
@@ -2605,29 +2623,6 @@ const messages: TranslationMap = {
'In exklusivem Vollbild rendert das Overlay nicht: du hörst nur den Hinweiston. Wechsle zu randlosem Vollbild für das Overlay.',
'pttOverlay.listening': 'Höre zu…',
'pttOverlay.idle': 'Bereit',
'autocomplete.title': 'Automatische Vervollständigung',
'autocomplete.settings': 'Einstellungen',
'autocomplete.acceptWithTab': 'Mit Tab akzeptieren',
'autocomplete.stylePreset': 'Stilvoreinstellung',
'autocomplete.style.balanced': 'Ausgewogen',
'autocomplete.style.concise': 'Prägnant',
'autocomplete.style.formal': 'Formell',
'autocomplete.style.casual': 'Lässig',
'autocomplete.style.custom': 'Benutzerdefiniert',
'autocomplete.disabledApps': 'Deaktivierte Apps (ein Bundle/App-Token pro Zeile)',
'autocomplete.saveSettings': 'Einstellungen speichern',
'autocomplete.saving': 'Sparen…',
'autocomplete.runtime': 'Laufzeit',
'autocomplete.running': 'Laufen',
'autocomplete.start': 'Starten',
'autocomplete.stop': 'Stopp',
'autocomplete.settingsSaved': 'Autovervollständigungseinstellungen gespeichert.',
'autocomplete.started': 'Die automatische Vervollständigung wurde gestartet.',
'autocomplete.didNotStart':
'Die automatische Vervollständigung wurde nicht gestartet. Prüfe, ob es aktiviert ist.',
'autocomplete.stopped': 'Die automatische Vervollständigung wurde gestoppt.',
'autocomplete.advancedSettings': 'Erweiterte Einstellungen',
'autocomplete.debugTitle': 'Autocomplete-Debug',
'chat.agentChat': 'Agenten-Chat',
'chat.overrides': 'Überschreibt',
'chat.model': 'Modell',
@@ -4630,9 +4625,6 @@ const messages: TranslationMap = {
'settings.developerMenu.composio.title': 'Composio',
'settings.developerMenu.composio.desc':
'Routing-Modus, Integrations-Trigger und Trigger-Verlaufsarchiv.',
'pages.settings.features.desktopCompanion': 'Desktop-Begleiter',
'pages.settings.features.desktopCompanionDesc':
'Sprachassistent mit Bildschirmerkennung hört zu, sieht, spricht, zeigt',
'pages.settings.features.messagingChannels': 'Messaging-Kanäle',
'pages.settings.features.messagingChannelsDesc': 'Nachrichtenkanäle, Abschn',
'pages.settings.features.notifications': 'Benachrichtigungen',
@@ -5058,68 +5050,6 @@ const messages: TranslationMap = {
'settings.ai.workload.changeModel': 'Modell ändern',
'settings.ai.workload.chooseModel': 'Modell auswählen',
'settings.ai.provider.ollama': 'Ollama',
'settings.autocomplete.appFilter.acceptSuggestion': 'Vorschlag annehmen',
'settings.autocomplete.appFilter.app': 'App',
'settings.autocomplete.appFilter.currentSuggestion': 'Aktueller Vorschlag',
'settings.autocomplete.appFilter.contextOverride': 'Kontextüberschreibung (optional)',
'settings.autocomplete.appFilter.debounce': 'Entprellen',
'settings.autocomplete.appFilter.debugFocus': 'Debug-Fokus',
'settings.autocomplete.appFilter.enabled': 'Aktiviert',
'settings.autocomplete.appFilter.getSuggestion': 'Hol dir einen Vorschlag',
'settings.autocomplete.appFilter.lastError': 'Letzter Fehler',
'settings.autocomplete.appFilter.liveLogs': 'Live-Protokolle',
'settings.autocomplete.appFilter.model': 'Modell',
'settings.autocomplete.appFilter.noLogs': '):',
'settings.autocomplete.appFilter.phase': 'Phase',
'settings.autocomplete.appFilter.platformSupported': 'Unterstützte Plattform',
'settings.autocomplete.appFilter.refreshStatus': 'Erfrischend…',
'settings.autocomplete.appFilter.refreshing': 'Erfrischend…',
'settings.autocomplete.appFilter.running': 'Wird ausgeführt',
'settings.autocomplete.appFilter.runtime': 'Laufzeit',
'settings.autocomplete.appFilter.test': 'Testen',
'settings.autocomplete.completionStyle.acceptedCompletion':
'{count} akzeptierter Abschluss gespeichert wird zur Personalisierung zukünftiger Vorschläge verwendet.',
'settings.autocomplete.completionStyle.acceptedCompletions':
'{count} akzeptierte Vervollständigungen werden gespeichert werden zur Personalisierung zukünftiger Vorschläge verwendet.',
'settings.autocomplete.completionStyle.clearHistory': 'Löschen…',
'settings.autocomplete.completionStyle.clearing': 'Löschen…',
'settings.autocomplete.completionStyle.debounce': 'Entprellung (ms)',
'settings.autocomplete.completionStyle.enabled': 'Aktiviert',
'settings.autocomplete.completionStyle.maxChars': 'Max. Zeichen',
'settings.autocomplete.completionStyle.noHistory':
'Noch keine akzeptierten Abschlüsse. Akzeptiere Vorschläge mit der Tabulatortaste, um mit der Personalisierung zu beginnen.',
'settings.autocomplete.completionStyle.overlayTtl': 'Overlay-TTL (ms)',
'settings.autocomplete.completionStyle.personalizationHistory': 'Personalisierungsverlauf',
'settings.autocomplete.completionStyle.styleExamples': 'Stilbeispiele (eines pro Zeile)',
'settings.autocomplete.completionStyle.styleInstructions': 'Stilanweisungen',
'settings.autocomplete.debug.acceptedPrefix': 'Akzeptiert: {value}',
'settings.autocomplete.debug.acceptFailed': 'Vorschlag konnte nicht angenommen werden',
'settings.autocomplete.debug.alreadyRunning': 'Autocomplete läuft bereits.',
'settings.autocomplete.debug.clearHistoryFailed': 'Der Verlauf konnte nicht gelöscht werden.',
'settings.autocomplete.debug.didNotStart':
'Die automatische Vervollständigung wurde nicht gestartet.',
'settings.autocomplete.debug.disabledInSettings':
'Die automatische Vervollständigung ist in den Einstellungen deaktiviert. Aktivieren Sie es und speichern Sie es zunächst.',
'settings.autocomplete.debug.fetchSuggestionFailed':
'Der aktuelle Vorschlag konnte nicht abgerufen werden.',
'settings.autocomplete.debug.inspectFocusedElementFailed':
'Das fokussierte Element konnte nicht überprüft werden.',
'settings.autocomplete.debug.loadSettingsFailed':
'Die Einstellungen für die automatische Vervollständigung konnten nicht geladen werden.',
'settings.autocomplete.debug.noSuggestionApplied': 'Es wurde kein Vorschlag angewendet.',
'settings.autocomplete.debug.noSuggestionReturned': 'Kein Vorschlag zurückgegeben.',
'settings.autocomplete.debug.refreshStatusFailed':
'Der Status der automatischen Vervollständigung konnte nicht aktualisiert werden.',
'settings.autocomplete.debug.saveAdvancedSettingsFailed':
'Die erweiterten Einstellungen konnten nicht gespeichert werden.',
'settings.autocomplete.debug.startFailed':
'Die automatische Vervollständigung konnte nicht gestartet werden.',
'settings.autocomplete.debug.stopFailed':
'Die automatische Vervollständigung konnte nicht gestoppt werden.',
'settings.autocomplete.debug.suggestionPrefix': 'Vorschlag: {value}',
'settings.autocomplete.shared.none': 'Keine',
'settings.autocomplete.shared.notApplicable': 'n/a',
'settings.autocomplete.shared.unknown': 'Unbekannt',
'settings.billing.autoRecharge.addAmount': 'Füge diesen Betrag hinzu',
'settings.billing.autoRecharge.addCard': 'Karte hinzufügen',
'settings.billing.autoRecharge.amountHint': 'Betragshinweis',
@@ -5236,23 +5166,6 @@ const messages: TranslationMap = {
'Speichern fehlgeschlagen. Der Direktmodus erfordert einen nicht leeren Schlüssel API.',
'settings.composio.saving': 'Sparen…',
'settings.composio.switching': 'Wechseln…',
'settings.companion.title': 'Desktop-Begleiter',
'settings.companion.session': 'Sitzung',
'settings.companion.activeLabel': 'Aktiv',
'settings.companion.inactiveStatus': 'Inaktiv',
'settings.companion.stopping': 'Wird gestoppt…',
'settings.companion.stopSession': 'Sitzung beenden',
'settings.companion.starting': 'Wird gestartet…',
'settings.companion.startSession': 'Sitzung starten',
'settings.companion.sessionId': 'Sitzungs-ID',
'settings.companion.turns': 'Drehungen',
'settings.companion.remaining': 'Verbleibende',
'settings.companion.configuration': 'Konfiguration',
'settings.companion.hotkey': 'Hotkey',
'settings.companion.activationMode': 'Aktivierungsmodus',
'settings.companion.sessionTtl': 'Sitzungs-TTL',
'settings.companion.screenCapture': 'Bildschirmaufnahme',
'settings.companion.appContext': 'App-Kontext',
'settings.cron.jobs.commandRequired': 'Bei Shell-Jobs ist ein Befehl erforderlich',
'settings.cron.jobs.createJob': 'Neuer geplanter Job',
'settings.cron.jobs.created': 'Job erstellt',
@@ -5687,9 +5600,6 @@ const messages: TranslationMap = {
'settings.developerMenu.autonomy.title': 'Agent-Autonomie',
'settings.developerMenu.autonomy.desc':
'Aktionsraten-Limits und Sicherheitsschwellen für Werkzeuge',
'settings.developerMenu.autocomplete.title': 'Autovervollständigung',
'settings.developerMenu.autocomplete.desc':
'KI-Inline-Autovervollständigung Einstellungen und Debug-Panel',
'settings.developerMenu.voiceDebug.title': 'Sprache (Debug)',
'settings.developerMenu.voiceDebug.desc':
'Laufzeitstatus und Debug-Einstellungen für die Sprachdiktierung',
@@ -6161,26 +6071,6 @@ const messages: TranslationMap = {
'settings.modelHealth.modal.apply': 'Ersatz anwenden',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
'settings.desktopAgent.title': 'Desktop-Agent',
'settings.desktopAgent.description':
'Überprüfen und erteilen Sie die Berechtigungen, die der Desktop-Agent benötigt.',
'settings.desktopAgent.microphone': 'Mikrofon',
'settings.desktopAgent.grant': 'Erlauben',
'settings.desktopAgent.notRequiredOnOs': 'Auf diesem Betriebssystem nicht erforderlich',
'settings.desktopAgent.allGranted':
'Alles bereit der Desktop-Agent hat alle erforderlichen Berechtigungen.',
'settings.desktopAgent.recheck': 'Erneut prüfen',
'settings.desktopAgent.restartAndRecheck': 'Kern neu starten und erneut prüfen',
'settings.desktopAgent.seamless.title': 'Agentenaktionen',
'settings.desktopAgent.seamless.label': 'Den Agenten ohne Nachfrage handeln lassen',
'settings.desktopAgent.seamless.description':
'Vollzugriff erteilen und Desktop-Aktionen (Apps öffnen, App-Oberflächen steuern) automatisch genehmigen, damit der Agent sie ohne Bestätigungsabfrage ausführt.',
'settings.desktopAgent.seamless.note':
'Nur Desktop-Aktionen überspringen die Abfrage: das Ausführen von Shell-Befehlen, das Bearbeiten von Dateien, Netzwerkanfragen und das Installieren von Software fragen weiterhin nach. Wird dies deaktiviert, endet die automatische Genehmigung von Desktop-Aktionen, die Zugriffsstufe bleibt jedoch unverändert.',
'settings.desktopAgent.beta':
'Beta der Desktop-Agent ist experimentell und funktioniert möglicherweise nicht immer wie erwartet.',
'settings.desktopAgent.wakeWordHint':
'Sagen Sie „Hey Tiny“ vor Ihrem Befehl, um den Agenten zu wecken.',
'settings.screenIntel.permissions.accessibility': 'Barrierefreiheit',
'settings.screenIntel.permissions.grantHint': 'Grant-Hinweis',
'settings.screenIntel.permissions.inputMonitoring': 'Eingabeüberwachung',
@@ -6459,22 +6349,6 @@ const messages: TranslationMap = {
'skills.resource.tree.templates': 'Vorlagen',
'skills.resource.tree.empty': 'Keine gebündelten Ressourcen.',
'skills.search.placeholder': 'Platzhalter',
'skills.setup.autocomplete.acceptKey': 'Schlüssel akzeptieren',
'skills.setup.autocomplete.activeDesc': 'Inline-Vervollständigungen laufen und sind bereit.',
'skills.setup.autocomplete.activeTitle': 'Die automatische Vervollständigung ist aktiv',
'skills.setup.autocomplete.customizeSettings': 'Passe die Einstellungen an',
'skills.setup.autocomplete.debounce': 'Entprellen',
'skills.setup.autocomplete.description': 'Beschreibung',
'skills.setup.autocomplete.enableBtn': 'Aktivieren...',
'skills.setup.autocomplete.enableError':
'Die automatische Vervollständigung konnte nicht aktiviert werden',
'skills.setup.autocomplete.enabling': 'Aktivieren...',
'skills.setup.autocomplete.notSupported': 'Nicht unterstützt',
'skills.setup.autocomplete.stepEnable': 'Aktiviere Inline-Vervollständigungen',
'skills.setup.autocomplete.stepSuccess': 'Bereit zu gehen',
'skills.setup.autocomplete.stylePreset': 'Stilvoreinstellung',
'skills.setup.autocomplete.stylePresetValue': 'Ausgewogen (später konfigurierbar)',
'skills.setup.autocomplete.title': 'Automatische Textvervollständigung',
'skills.setup.screenIntel.activeDesc':
'Screen Intelligence läuft und liest dein aktives Fenster.',
'skills.setup.screenIntel.activeTitle': 'Bildschirmintelligenz ist aktiviert',
@@ -6969,9 +6843,6 @@ const messages: TranslationMap = {
'Das Artefakt konnte nicht aufgelöst werden. Bitte erneut versuchen.',
'chat.files.error.download_failed': 'Download fehlgeschlagen. Bitte erneut versuchen.',
'chat.files.error.delete_failed': 'Datei konnte nicht gelöscht werden. Bitte erneut versuchen.',
'autocomplete.debounceMs': 'Entprellung (ms)',
'autocomplete.maxChars': 'Maximale Kontextzeichen',
'autocomplete.overlayTtlMs': 'Overlay-Timeout (ms)',
'memory.tab.council': 'Council',
'activity.tabs.automations': 'Automatisierungen',
'activity.tabs.automationsDescription':
@@ -7006,136 +6877,6 @@ const messages: TranslationMap = {
'intelligence.agents.tier.chat': 'Chat',
'intelligence.agents.tier.reasoning': 'Reasoning',
'intelligence.agents.tier.worker': 'Worker',
'modelCouncil.title': 'Modell-Rat',
'modelCouncil.intro':
'Stellen Sie eine Frage, erhalten Sie unabhängige Antworten von mehreren Modellen parallel, dann synthesiert das Vorsitz-Modell Übereinstimmungen, Unterschiede und einzigartige Beiträge.',
'modelCouncil.listTitle': 'Councils',
'modelCouncil.listIntro':
'Wählen Sie einen gespeicherten Rat, bearbeiten Sie seine Agenten und den Richter, oder erstellen Sie einen neuen Rat für eine bestimmte Art von Entscheidung.',
'modelCouncil.addCouncil': 'Neuer Rat',
'modelCouncil.openCouncil': 'Rat öffnen',
'modelCouncil.editCouncil': 'Rat bearbeiten',
'modelCouncil.editCurrentCouncil': 'Aktuellen Rat bearbeiten',
'modelCouncil.editCouncilAria': '{name} bearbeiten',
'modelCouncil.deleteCouncilAria': '{name} löschen',
'modelCouncil.backToCouncils': 'Zurück zu den Räten',
'modelCouncil.saveCouncil': 'Rat speichern',
'modelCouncil.savingCouncil': 'Saving…',
'modelCouncil.cancelEdit': 'Cancel',
'modelCouncil.loadingCouncils': 'Räte werden geladen…',
'modelCouncil.noCouncils': 'Noch keine Räte. Fügen Sie einen hinzu, um zu beginnen.',
'modelCouncil.noCouncilDescription': 'Keine Beschreibung',
'modelCouncil.registryErrorPrefix': 'Rats-Registry fehlgeschlagen:',
'modelCouncil.councilNameLabel': 'Ratsname',
'modelCouncil.councilDescriptionLabel': 'Description',
'modelCouncil.councilDescriptionPlaceholder': 'Wann diesen Rat verwenden',
'modelCouncil.selectModel': 'Select',
'modelCouncil.modelPickerHelp':
'Wählen Sie einen Routing-Hinweis oder pinnen Sie ein genaues Anbietermodell.',
'modelCouncil.closeModelPicker': 'Close',
'modelCouncil.hint.chat': 'Chat',
'modelCouncil.hint.code': 'Code',
'modelCouncil.hint.default': 'Standard',
'modelCouncil.hint.reasoning': 'Schlussfolgern',
'modelCouncil.hint.summarize': 'Zusammenfassung',
'modelCouncil.modelPickerHints': 'Hints',
'modelCouncil.modelPickerProviderModel': 'Anbieter + Modell',
'modelCouncil.modelProviderLabel': 'Modellanbieter',
'modelCouncil.modelIdLabel': 'Modell-ID',
'modelCouncil.useProviderModel': 'Anbietermodell verwenden',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Stellen Sie dem Rat eine Frage…',
'modelCouncil.membersLabel': 'Mitgliedsmodelle',
'modelCouncil.maxMembersNote': 'bis zu {max}',
'modelCouncil.memberPlaceholder': 'z. B. gpt-5.2',
'modelCouncil.memberAria': 'Mitgliedsmodell {n}',
'modelCouncil.removeMemberAria': 'Mitgliedsmodell {n} entfernen',
'modelCouncil.addMember': '+ Modell hinzufügen',
'modelCouncil.settingsTitle': 'Council-Einstellungen',
'modelCouncil.settingsSummary': '{count} Juroren · Richter: {judge}',
'modelCouncil.juryCountLabel': 'Anzahl der Juroren',
'modelCouncil.debateRoundsLabel': 'Debattenrunden',
'modelCouncil.debateRoundsHelp':
'Jeder Juror debattiert über mehrere Runden, bevor der Richter die endgültige Synthese verfasst.',
'modelCouncil.judgeAgentLabel': 'Richter-Agent',
'modelCouncil.defaultJudge': 'Standardrichter',
'modelCouncil.savedProfile': 'Gespeichertes Profil',
'modelCouncil.customAgent': 'Eigener Agent',
'modelCouncil.judgeProfileLabel': 'Richterprofil',
'modelCouncil.chooseProfile': 'Profil auswählen',
'modelCouncil.judgeNameLabel': 'Name des Richters',
'modelCouncil.judgeNamePlaceholder': 'Vorsitzender Richter',
'modelCouncil.rosterHeading': 'Council-Besetzung',
'modelCouncil.rosterHelp':
'Jeder Juror kann den Standardagenten erben, ein gespeichertes Profil nutzen oder für diesen Lauf ein eigenes Profil werden.',
'modelCouncil.loadingProfiles': 'Profile werden geladen…',
'modelCouncil.jurorLabel': 'Juror {n}',
'modelCouncil.profileModeLabel': 'Profilquelle',
'modelCouncil.mode.default': 'Standard',
'modelCouncil.mode.profile': 'Profil',
'modelCouncil.mode.custom': 'Eigen',
'modelCouncil.memberProfileAria': 'Profil von Juror {n}',
'modelCouncil.memberNameAria': 'Name von Juror {n}',
'modelCouncil.memberNamePlaceholder': 'Agentenname',
'modelCouncil.profileModelPlaceholder': 'Profilmodell überschreiben',
'modelCouncil.memberBriefAria': 'Briefing für Juror {n}',
'modelCouncil.memberBriefPlaceholder': 'Perspektive dieses Jurors',
'modelCouncil.jurorFallback': 'Juror',
'modelCouncil.sharedReasoningLabel': 'Gemeinsame Reasoning-Datei',
'modelCouncil.sharedReasoningHelp':
'Der Council arbeitet an diesem gemeinsamen Entwurf, bevor der Richter die Synthese schreibt.',
'modelCouncil.liveScratchpadHelp':
'Juroren-Updates werden hier nach jeder Runde angefügt und in die nächste Runde eingespeist.',
'modelCouncil.liveScratchpadBadge': 'Live',
'modelCouncil.scratchpadRoundHeading': 'Runde-{round}-Updates',
'modelCouncil.scratchpadNoResponse': 'Keine Antwort',
'modelCouncil.chairLabel': 'Vorsitz-Modell',
'modelCouncil.chairPlaceholder': 'z. B. claude-opus-4-8',
'modelCouncil.chairHelp':
'Der Vorsitzende liest jede Mitgliederantwort und verfasst die Synthese.',
'modelCouncil.run': 'Rat einberufen',
'modelCouncil.running': 'Convening…',
'modelCouncil.runningHint': 'Mitglieder werden parallel abgefragt, dann Synthese…',
'modelCouncil.deliberationHeading': 'Rats-Beratung',
'modelCouncil.deliberationHelp':
'Maskottchen zeigen die Denkphase jedes Agenten, während die gemeinsame Argumentationsdatei aktiv ist.',
'modelCouncil.debateRoundInstruction':
'Antworten Sie auf die anderen Juroren. Bewahren Sie Meinungsverschiedenheiten, revidieren Sie Ihre Sicht wenn nötig und formulieren Sie Ihr aktuelles Fazit deutlich.',
'modelCouncil.debateFinalInstruction':
'Dies ist Ihre letzte Juroren-Runde. Konvergieren Sie, wo die Beweise es stützen, benennen Sie verbleibende Meinungsverschiedenheiten und geben Sie dem Richter ein klares Fazit.',
'modelCouncil.roundLabel': 'Runde {round}',
'modelCouncil.currentRoundThinking': 'Nächste Runde wird fortgesetzt…',
'modelCouncil.thinkingBadge': 'Thinking',
'modelCouncil.thinkingWithBrief': 'Arbeite aus dieser Perspektive: {brief}',
'modelCouncil.thought.evidence':
'Prüfe Beweise, Annahmen und was die gemeinsame Argumentationsdatei bereits enthält.',
'modelCouncil.thought.plan':
'Skizziere den praktischen Weg und wo die Umsetzung scheitern könnte.',
'modelCouncil.thought.risk':
'Suche nach Fehlermodi, fehlendem Kontext und brüchigen Schlussfolgerungen.',
'modelCouncil.thought.tradeoffs':
'Vergleiche Abwägungen, bevor ich dem Richter eine knappe Position übergebe.',
'modelCouncil.thought.synthesis':
'Bereite eine eigenständige Sicht vor, damit der Richter echte Meinungsverschiedenheiten vergleichen kann.',
'modelCouncil.judgeWaitingBadge': 'Judge',
'modelCouncil.judgeWaitingThought':
'Warte auf Juroren-Antworten, dann lese die gemeinsame Argumentationsdatei zur Synthese.',
'modelCouncil.judgeSynthesizingBadge': 'Synthesizing',
'modelCouncil.judgeSynthesizingThought':
'Lese die Live-Gedanken der Juroren und verfasse die endgültige Rats-Synthese.',
'modelCouncil.errorPrefix': 'Rat fehlgeschlagen:',
'modelCouncil.resultsHeading': 'Rats-Ergebnisse',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'von {model}',
'modelCouncil.usageHeading': 'Debatten-Nutzung',
'modelCouncil.usageEstimated':
'Geschätzte Token-Anzahl aus Debatten-Prompts und -Antworten. Die echte Preisgestaltung erfordert Anbieternutzung aus dem Rats-RPC.',
'modelCouncil.usageEstimatedBadge': 'Geschätzte Token',
'modelCouncil.usageInputTokens': 'Input',
'modelCouncil.usageOutputTokens': 'Output',
'modelCouncil.usageTotalTokens': 'Total',
'graphCohesion.brokerBadge': 'Broker',
'graphCohesion.brokerTitle':
'Strukturelles Loch: Die Nachbarn dieser Entität sind nicht miteinander verbunden: sie ist die einzige Verknüpfung zwischen ihnen.',
+23 -265
View File
@@ -1,6 +1,29 @@
import type { TranslationMap } from './types';
const en: TranslationMap = {
'settings.assistant.desktopCompanion': 'Desktop companion',
'settings.assistant.desktopCompanionDesc': 'Always-on companion mode with a system-tray shortcut',
'connections.header.companion': 'A floating desktop companion for quick, always-on help.',
'pages.settings.features.desktopCompanion': 'Desktop Companion',
'pages.settings.features.desktopCompanionDesc':
'Voice assistant with screen awareness: listens, sees, speaks, points',
'settings.companion.title': 'Desktop Companion',
'settings.companion.session': 'Session',
'settings.companion.activeLabel': 'Active',
'settings.companion.inactiveStatus': 'Inactive',
'settings.companion.stopping': 'Stopping…',
'settings.companion.stopSession': 'Stop Session',
'settings.companion.starting': 'Starting…',
'settings.companion.startSession': 'Start Session',
'settings.companion.sessionId': 'Session ID',
'settings.companion.turns': 'Turns',
'settings.companion.remaining': 'Remaining',
'settings.companion.configuration': 'Configuration',
'settings.companion.hotkey': 'Hotkey',
'settings.companion.activationMode': 'Activation Mode',
'settings.companion.sessionTtl': 'Session TTL',
'settings.companion.screenCapture': 'Screen Capture',
'settings.companion.appContext': 'App Context',
'skills.recallCalendar.title': 'Google Calendar',
'skills.recallCalendar.description': 'Auto-join Google Meet calls via Recall.ai',
// Navigation
@@ -499,8 +522,6 @@ const en: TranslationMap = {
'Control how actively your assistant works in the background',
'settings.assistant.screenAwareness': 'Screen awareness',
'settings.assistant.screenAwarenessDesc': 'Let the assistant see your active window',
'settings.assistant.desktopCompanion': 'Desktop companion',
'settings.assistant.desktopCompanionDesc': 'Always-on companion mode with a system-tray shortcut',
'settings.assistant.permissions': 'Permissions',
'settings.assistant.permissionsDesc': 'Choose what the assistant can do and where it can work',
@@ -909,9 +930,6 @@ const en: TranslationMap = {
'connections.header.search': 'Configure web search for your agent.',
'connections.header.wallet': 'Your local wallet balance and recovery phrase.',
'connections.header.screen': 'Let your agent see your screen to offer timely, in-context help.',
'connections.header.desktopAgent':
'Let your agent open apps and control your desktop on your behalf.',
'connections.header.companion': 'A floating desktop companion for quick, always-on help.',
'connections.header.composioKey':
'Add a Composio API key to unlock hundreds of app integrations.',
'connections.llm.apiKeys': 'API keys & routing',
@@ -1008,133 +1026,6 @@ const en: TranslationMap = {
'intelligence.agents.tier.chat': 'Chat',
'intelligence.agents.tier.reasoning': 'Reasoning',
'intelligence.agents.tier.worker': 'Worker',
'modelCouncil.title': 'Model Council',
'modelCouncil.intro':
'Ask one question, get independent answers from several models in parallel, then a chair model synthesizes where they agree, where they disagree, and what each uniquely adds.',
'modelCouncil.listTitle': 'Councils',
'modelCouncil.listIntro':
'Choose a saved council, edit its agents and judge, or create a new council for a specific kind of decision.',
'modelCouncil.addCouncil': 'New council',
'modelCouncil.openCouncil': 'Open council',
'modelCouncil.editCouncil': 'Edit council',
'modelCouncil.editCurrentCouncil': 'Edit current council',
'modelCouncil.editCouncilAria': 'Edit {name}',
'modelCouncil.deleteCouncilAria': 'Delete {name}',
'modelCouncil.backToCouncils': 'Back to councils',
'modelCouncil.saveCouncil': 'Save council',
'modelCouncil.savingCouncil': 'Saving…',
'modelCouncil.cancelEdit': 'Cancel',
'modelCouncil.loadingCouncils': 'Loading councils…',
'modelCouncil.noCouncils': 'No councils yet. Add one to get started.',
'modelCouncil.noCouncilDescription': 'No description',
'modelCouncil.registryErrorPrefix': 'Council registry failed:',
'modelCouncil.councilNameLabel': 'Council name',
'modelCouncil.councilDescriptionLabel': 'Description',
'modelCouncil.councilDescriptionPlaceholder': 'When to use this council',
'modelCouncil.selectModel': 'Select',
'modelCouncil.modelPickerHelp': 'Select a routing hint or pin an exact provider model.',
'modelCouncil.closeModelPicker': 'Close',
'modelCouncil.hint.chat': 'Chat',
'modelCouncil.hint.code': 'Code',
'modelCouncil.hint.default': 'Default',
'modelCouncil.hint.reasoning': 'Reasoning',
'modelCouncil.hint.summarize': 'Summarize',
'modelCouncil.modelPickerHints': 'Hints',
'modelCouncil.modelPickerProviderModel': 'Provider + model',
'modelCouncil.modelProviderLabel': 'Model provider',
'modelCouncil.modelIdLabel': 'Model id',
'modelCouncil.useProviderModel': 'Use provider model',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
'modelCouncil.membersLabel': 'Member models',
'modelCouncil.maxMembersNote': 'up to {max}',
'modelCouncil.memberPlaceholder': 'e.g. gpt-5.2',
'modelCouncil.memberAria': 'Member model {n}',
'modelCouncil.removeMemberAria': 'Remove member model {n}',
'modelCouncil.addMember': '+ Add model',
'modelCouncil.settingsTitle': 'Council settings',
'modelCouncil.settingsSummary': '{count} jurors · judge: {judge}',
'modelCouncil.juryCountLabel': 'Jury count',
'modelCouncil.debateRoundsLabel': 'Debate turns',
'modelCouncil.debateRoundsHelp':
'Each juror keeps debating across turns before the judge writes the final synthesis.',
'modelCouncil.judgeAgentLabel': 'Judge agent',
'modelCouncil.defaultJudge': 'Default judge',
'modelCouncil.savedProfile': 'Saved profile',
'modelCouncil.customAgent': 'Custom agent',
'modelCouncil.judgeProfileLabel': 'Judge profile',
'modelCouncil.chooseProfile': 'Choose a profile',
'modelCouncil.judgeNameLabel': 'Judge name',
'modelCouncil.judgeNamePlaceholder': 'Chief Judge',
'modelCouncil.rosterHeading': 'Council roster',
'modelCouncil.rosterHelp':
'Each juror can inherit the default agent, use a saved profile, or become a custom profile for this run.',
'modelCouncil.loadingProfiles': 'Loading profiles…',
'modelCouncil.jurorLabel': 'Juror {n}',
'modelCouncil.profileModeLabel': 'Profile source',
'modelCouncil.mode.default': 'Default',
'modelCouncil.mode.profile': 'Profile',
'modelCouncil.mode.custom': 'Custom',
'modelCouncil.memberProfileAria': 'Juror {n} profile',
'modelCouncil.memberNameAria': 'Juror {n} name',
'modelCouncil.memberNamePlaceholder': 'Agent name',
'modelCouncil.profileModelPlaceholder': 'Override profile model',
'modelCouncil.memberBriefAria': 'Juror {n} brief',
'modelCouncil.memberBriefPlaceholder': 'Perspective for this juror',
'modelCouncil.jurorFallback': 'Juror',
'modelCouncil.sharedReasoningLabel': 'Shared reasoning file',
'modelCouncil.sharedReasoningHelp':
'The council works against this shared scratchpad before the judge writes the synthesis.',
'modelCouncil.liveScratchpadHelp':
'Juror updates are appended here after each round, then fed into the next round.',
'modelCouncil.liveScratchpadBadge': 'Live',
'modelCouncil.scratchpadRoundHeading': 'Round {round} updates',
'modelCouncil.scratchpadNoResponse': 'No response',
'modelCouncil.chairLabel': 'Chair model',
'modelCouncil.chairPlaceholder': 'e.g. claude-opus-4-8',
'modelCouncil.chairHelp': 'The chair reads every member answer and writes the synthesis.',
'modelCouncil.run': 'Convene council',
'modelCouncil.running': 'Convening…',
'modelCouncil.runningHint': 'Querying members in parallel, then synthesizing…',
'modelCouncil.deliberationHeading': 'Council deliberation',
'modelCouncil.deliberationHelp':
'Mascots show each agent thinking while the shared reasoning file is in play.',
'modelCouncil.debateRoundInstruction':
'Respond to the other jurors so far. Preserve disagreements, revise your view when warranted, and keep your current conclusion explicit.',
'modelCouncil.debateFinalInstruction':
'This is your final juror turn. Converge where the evidence supports it, call out remaining disagreement, and hand the judge a clear conclusion.',
'modelCouncil.roundLabel': 'Round {round}',
'modelCouncil.currentRoundThinking': 'Continuing the next turn…',
'modelCouncil.thinkingBadge': 'Thinking',
'modelCouncil.thinkingWithBrief': 'Working from this perspective: {brief}',
'modelCouncil.thought.evidence':
'Checking evidence, assumptions, and what the shared reasoning file already captures.',
'modelCouncil.thought.plan': 'Sketching the practical path and where execution could get stuck.',
'modelCouncil.thought.risk':
'Looking for failure modes, missing context, and brittle conclusions.',
'modelCouncil.thought.tradeoffs':
'Comparing tradeoffs before handing the judge a concise position.',
'modelCouncil.thought.synthesis':
'Preparing a distinct view so the judge can compare real disagreement.',
'modelCouncil.judgeWaitingBadge': 'Judge',
'modelCouncil.judgeWaitingThought':
'Waiting for juror answers, then reading the shared reasoning file to synthesize.',
'modelCouncil.judgeSynthesizingBadge': 'Synthesizing',
'modelCouncil.judgeSynthesizingThought':
'Reading the live juror thoughts and writing the final council synthesis.',
'modelCouncil.errorPrefix': 'Council failed:',
'modelCouncil.resultsHeading': 'Council results',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'by {model}',
'modelCouncil.usageHeading': 'Debate usage',
'modelCouncil.usageEstimated':
'Estimated token counts from debate prompts and responses. Real pricing requires provider usage from the council RPC.',
'modelCouncil.usageEstimatedBadge': 'Estimated tokens',
'modelCouncil.usageInputTokens': 'Input',
'modelCouncil.usageOutputTokens': 'Output',
'modelCouncil.usageTotalTokens': 'Total',
'memory.analyzeNow': 'Analyze Now',
'namespaceOverview.title': 'Namespace Overview',
'namespaceOverview.intro':
@@ -2777,31 +2668,6 @@ const en: TranslationMap = {
'pttOverlay.idle': 'Idle',
// Autocomplete
'autocomplete.title': 'Autocomplete',
'autocomplete.settings': 'Settings',
'autocomplete.acceptWithTab': 'Accept With Tab',
'autocomplete.stylePreset': 'Style Preset',
'autocomplete.style.balanced': 'Balanced',
'autocomplete.style.concise': 'Concise',
'autocomplete.style.formal': 'Formal',
'autocomplete.style.casual': 'Casual',
'autocomplete.style.custom': 'Custom',
'autocomplete.disabledApps': 'Disabled Apps (one bundle/app token per line)',
'autocomplete.debounceMs': 'Debounce (ms)',
'autocomplete.maxChars': 'Max context characters',
'autocomplete.overlayTtlMs': 'Overlay timeout (ms)',
'autocomplete.saveSettings': 'Save Settings',
'autocomplete.saving': 'Saving…',
'autocomplete.runtime': 'Runtime',
'autocomplete.running': 'Running',
'autocomplete.start': 'Start',
'autocomplete.stop': 'Stop',
'autocomplete.settingsSaved': 'Autocomplete settings saved.',
'autocomplete.started': 'Autocomplete started.',
'autocomplete.didNotStart': 'Autocomplete did not start. Check if it is enabled.',
'autocomplete.stopped': 'Autocomplete stopped.',
'autocomplete.advancedSettings': 'Advanced settings',
'autocomplete.debugTitle': 'Autocomplete Debug',
// Chat
'chat.agentChat': 'Agent Chat',
@@ -5221,9 +5087,6 @@ const en: TranslationMap = {
'settings.developerMenu.composio.title': 'Composio',
'settings.developerMenu.composio.desc':
'Routing mode, integration triggers, and trigger history archive.',
'pages.settings.features.desktopCompanion': 'Desktop Companion',
'pages.settings.features.desktopCompanionDesc':
'Voice assistant with screen awareness: listens, sees, speaks, points',
'pages.settings.features.messagingChannels': 'Messaging channels',
'pages.settings.features.messagingChannelsDesc': 'Messaging channels desc',
'pages.settings.features.notifications': 'Notifications',
@@ -5641,60 +5504,6 @@ const en: TranslationMap = {
'settings.ai.workload.changeModel': 'Change Model',
'settings.ai.workload.chooseModel': 'Choose Model',
'settings.ai.provider.ollama': 'Ollama',
'settings.autocomplete.appFilter.acceptSuggestion': 'Accept suggestion',
'settings.autocomplete.appFilter.app': 'App',
'settings.autocomplete.appFilter.currentSuggestion': 'Current suggestion',
'settings.autocomplete.appFilter.contextOverride': 'Context Override (optional)',
'settings.autocomplete.appFilter.debounce': 'Debounce',
'settings.autocomplete.appFilter.debugFocus': 'Debug focus',
'settings.autocomplete.appFilter.enabled': 'Enabled',
'settings.autocomplete.appFilter.getSuggestion': 'Get suggestion',
'settings.autocomplete.appFilter.lastError': 'Last error',
'settings.autocomplete.appFilter.liveLogs': 'Live Logs',
'settings.autocomplete.appFilter.model': 'Model',
'settings.autocomplete.appFilter.noLogs': 'No logs yet.',
'settings.autocomplete.appFilter.phase': 'Phase',
'settings.autocomplete.appFilter.platformSupported': 'Platform supported',
'settings.autocomplete.appFilter.refreshStatus': 'Refresh Status',
'settings.autocomplete.appFilter.refreshing': 'Refreshing…',
'settings.autocomplete.appFilter.running': 'Running',
'settings.autocomplete.appFilter.runtime': 'Runtime',
'settings.autocomplete.appFilter.test': 'Test',
'settings.autocomplete.completionStyle.acceptedCompletion':
'{count} accepted completion stored: used to personalize future suggestions.',
'settings.autocomplete.completionStyle.acceptedCompletions':
'{count} accepted completions stored: used to personalize future suggestions.',
'settings.autocomplete.completionStyle.clearHistory': 'Clear History',
'settings.autocomplete.completionStyle.clearing': 'Clearing…',
'settings.autocomplete.completionStyle.debounce': 'Debounce (ms)',
'settings.autocomplete.completionStyle.enabled': 'Enabled',
'settings.autocomplete.completionStyle.maxChars': 'Max Chars',
'settings.autocomplete.completionStyle.noHistory':
'No accepted completions yet. Accept suggestions with Tab to start personalising.',
'settings.autocomplete.completionStyle.overlayTtl': 'Overlay TTL (ms)',
'settings.autocomplete.completionStyle.personalizationHistory': 'Personalization History',
'settings.autocomplete.completionStyle.styleExamples': 'Style Examples (one per line)',
'settings.autocomplete.completionStyle.styleInstructions': 'Style Instructions',
'settings.autocomplete.debug.acceptedPrefix': 'Accepted: {value}',
'settings.autocomplete.debug.acceptFailed': 'Failed to accept suggestion',
'settings.autocomplete.debug.alreadyRunning': 'Autocomplete is already running.',
'settings.autocomplete.debug.clearHistoryFailed': 'Failed to clear history',
'settings.autocomplete.debug.didNotStart': 'Autocomplete did not start.',
'settings.autocomplete.debug.disabledInSettings':
'Autocomplete is disabled in settings. Enable it and save first.',
'settings.autocomplete.debug.fetchSuggestionFailed': 'Failed to fetch current suggestion',
'settings.autocomplete.debug.inspectFocusedElementFailed': 'Failed to inspect focused element',
'settings.autocomplete.debug.loadSettingsFailed': 'Failed to load autocomplete settings',
'settings.autocomplete.debug.noSuggestionApplied': 'No suggestion was applied.',
'settings.autocomplete.debug.noSuggestionReturned': 'No suggestion returned.',
'settings.autocomplete.debug.refreshStatusFailed': 'Failed to refresh autocomplete status',
'settings.autocomplete.debug.saveAdvancedSettingsFailed': 'Failed to save advanced settings',
'settings.autocomplete.debug.startFailed': 'Failed to start autocomplete',
'settings.autocomplete.debug.stopFailed': 'Failed to stop autocomplete',
'settings.autocomplete.debug.suggestionPrefix': 'Suggestion: {value}',
'settings.autocomplete.shared.none': 'None',
'settings.autocomplete.shared.notApplicable': 'n/a',
'settings.autocomplete.shared.unknown': 'Unknown',
'settings.billing.autoRecharge.addAmount': 'Add this amount',
'settings.billing.autoRecharge.addCard': 'Add card',
'settings.billing.autoRecharge.amountHint': 'Will be charged to your default payment method.',
@@ -5809,23 +5618,6 @@ const en: TranslationMap = {
'settings.composio.saveErrorNoKey': 'Failed to save. Direct mode requires a non-empty API key.',
'settings.composio.saving': 'Saving…',
'settings.composio.switching': 'Switching…',
'settings.companion.title': 'Desktop Companion',
'settings.companion.session': 'Session',
'settings.companion.activeLabel': 'Active',
'settings.companion.inactiveStatus': 'Inactive',
'settings.companion.stopping': 'Stopping…',
'settings.companion.stopSession': 'Stop Session',
'settings.companion.starting': 'Starting…',
'settings.companion.startSession': 'Start Session',
'settings.companion.sessionId': 'Session ID',
'settings.companion.turns': 'Turns',
'settings.companion.remaining': 'Remaining',
'settings.companion.configuration': 'Configuration',
'settings.companion.hotkey': 'Hotkey',
'settings.companion.activationMode': 'Activation Mode',
'settings.companion.sessionTtl': 'Session TTL',
'settings.companion.screenCapture': 'Screen Capture',
'settings.companion.appContext': 'App Context',
'settings.cron.jobs.commandRequired': 'Command is required for shell jobs',
'settings.cron.jobs.created': 'Job created',
'settings.cron.jobs.createJob': 'New Scheduled Job',
@@ -6248,8 +6040,6 @@ const en: TranslationMap = {
'settings.developerMenu.autonomy.title': 'Agent autonomy',
'settings.developerMenu.autonomy.desc': 'Tool action rate limits and safety thresholds',
// Tools & Capabilities group: autocomplete and voice debug (doc § Tools & Capabilities)
'settings.developerMenu.autocomplete.title': 'Autocomplete',
'settings.developerMenu.autocomplete.desc': 'AI inline autocomplete settings and debug panel',
'settings.developerMenu.voiceDebug.title': 'Voice (debug)',
'settings.developerMenu.voiceDebug.desc': 'Voice dictation runtime status and debug settings',
'settings.mcpServer.title': 'MCP Server',
@@ -6708,23 +6498,6 @@ const en: TranslationMap = {
'settings.modelHealth.modal.apply': 'Apply Replacement',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
'settings.desktopAgent.title': 'Desktop Agent',
'settings.desktopAgent.beta':
'Beta: the desktop agent is experimental and may not always work as expected.',
'settings.desktopAgent.wakeWordHint': 'Say “Hey Tiny” before your command to wake the agent.',
'settings.desktopAgent.description': 'Check and grant the permissions the desktop agent needs.',
'settings.desktopAgent.microphone': 'Microphone',
'settings.desktopAgent.grant': 'Grant',
'settings.desktopAgent.notRequiredOnOs': 'Not required on this OS',
'settings.desktopAgent.allGranted': 'All set: the desktop agent has every permission it needs.',
'settings.desktopAgent.recheck': 'Recheck',
'settings.desktopAgent.restartAndRecheck': 'Restart core & recheck',
'settings.desktopAgent.seamless.title': 'Agent actions',
'settings.desktopAgent.seamless.label': 'Let the agent act without asking',
'settings.desktopAgent.seamless.description':
'Grant Full access and auto-approve desktop actions (opening apps, controlling app interfaces) so the agent runs them without an approval prompt.',
'settings.desktopAgent.seamless.note':
'Only desktop actions skip the prompt: running shell commands, editing files, network requests, and installing software still ask. Turning this off stops auto-approving desktop actions but leaves the access level unchanged.',
'settings.screenIntel.permissions.accessibility': 'Accessibility',
'settings.screenIntel.permissions.grantHint':
'Grant these permissions in System Settings, then restart the core.',
@@ -7010,21 +6783,6 @@ const en: TranslationMap = {
'skills.resource.tree.scripts': 'Scripts',
'skills.resource.tree.templates': 'Templates',
'skills.search.placeholder': 'Search skills…',
'skills.setup.autocomplete.acceptKey': 'Accept key',
'skills.setup.autocomplete.activeDesc': 'Inline completions are running and ready.',
'skills.setup.autocomplete.activeTitle': 'Auto-Complete is Active',
'skills.setup.autocomplete.customizeSettings': 'Customize settings',
'skills.setup.autocomplete.debounce': 'Debounce',
'skills.setup.autocomplete.description': 'AI-powered inline text completions as you type.',
'skills.setup.autocomplete.enableBtn': 'Enabling...',
'skills.setup.autocomplete.enableError': 'Failed to enable autocomplete',
'skills.setup.autocomplete.enabling': 'Enabling...',
'skills.setup.autocomplete.notSupported': 'Not supported',
'skills.setup.autocomplete.stepEnable': 'Enable inline completions',
'skills.setup.autocomplete.stepSuccess': 'Ready to go',
'skills.setup.autocomplete.stylePreset': 'Style preset',
'skills.setup.autocomplete.stylePresetValue': 'Balanced (configurable later)',
'skills.setup.autocomplete.title': 'Text Auto-Complete',
'skills.setup.screenIntel.activeDesc':
'Screen Intelligence is running and reading your active window.',
'skills.setup.screenIntel.activeTitle': 'Screen Intelligence is Enabled',
+25 -277
View File
@@ -3,6 +3,31 @@ import type { TranslationMap } from './types';
// Spanish (Español) translations. Keys mirror en.ts; missing/
// English-identical values fall back to English via I18nContext.resolveEn().
const messages: TranslationMap = {
'connections.header.companion':
'Un compañero de escritorio flotante para ayuda rápida y siempre disponible.',
'settings.assistant.desktopCompanion': 'Compañero de escritorio',
'settings.assistant.desktopCompanionDesc':
'Modo compañero siempre activo con acceso directo en la bandeja del sistema',
'pages.settings.features.desktopCompanion': 'Acompañante de escritorio',
'pages.settings.features.desktopCompanionDesc':
'Asistente de voz con conciencia de pantalla: escucha, ve, habla, señala',
'settings.companion.title': 'Compañero de escritorio',
'settings.companion.session': 'Sesión',
'settings.companion.activeLabel': 'Activo',
'settings.companion.inactiveStatus': 'Inactivo',
'settings.companion.stopping': 'Parando…',
'settings.companion.stopSession': 'Detener sesión',
'settings.companion.starting': 'Empezando…',
'settings.companion.startSession': 'Iniciar sesión',
'settings.companion.sessionId': 'ID de sesión',
'settings.companion.turns': 'Turnos',
'settings.companion.remaining': 'Restante',
'settings.companion.configuration': 'Configuración',
'settings.companion.hotkey': 'Atajo de teclado',
'settings.companion.activationMode': 'Modo de activación',
'settings.companion.sessionTtl': 'TTL de sesión',
'settings.companion.screenCapture': 'Captura de pantalla',
'settings.companion.appContext': 'Contexto de la aplicación',
// Share cards (#5006).
'share.button': 'Compartir',
'share.modalTitle': 'Comparte este momento',
@@ -93,10 +118,6 @@ const messages: TranslationMap = {
'connections.header.wallet': 'El saldo de tu monedero local y tu frase de recuperación.',
'connections.header.screen':
'Deja que tu agente vea tu pantalla para ofrecerte ayuda oportuna y en contexto.',
'connections.header.desktopAgent':
'Deja que tu agente abra apps y controle tu escritorio en tu nombre.',
'connections.header.companion':
'Un compañero de escritorio flotante para ayuda rápida y siempre disponible.',
'connections.header.composioKey':
'Añade una clave de API de Composio para desbloquear cientos de integraciones de apps.',
'connections.llm.apiKeys': 'Claves de API y enrutamiento',
@@ -755,9 +776,6 @@ const messages: TranslationMap = {
'Controla qué tan activo trabaja tu asistente en segundo plano',
'settings.assistant.screenAwareness': 'Conciencia de pantalla',
'settings.assistant.screenAwarenessDesc': 'Permite al asistente ver tu ventana activa',
'settings.assistant.desktopCompanion': 'Compañero de escritorio',
'settings.assistant.desktopCompanionDesc':
'Modo compañero siempre activo con acceso directo en la bandeja del sistema',
'settings.assistant.permissions': 'Permisos',
'settings.assistant.permissionsDesc': 'Elige qué puede hacer el asistente y dónde puede trabajar',
'settings.privacySecurity.privacy': 'Privacidad',
@@ -2576,28 +2594,6 @@ const messages: TranslationMap = {
'En juegos a pantalla completa exclusiva el panel no se mostrará: solo oirás el aviso sonoro. Cambia a pantalla completa sin bordes para ver el panel.',
'pttOverlay.listening': 'Escuchando…',
'pttOverlay.idle': 'En espera',
'autocomplete.title': 'Autocompletado',
'autocomplete.settings': 'Configuración',
'autocomplete.acceptWithTab': 'Aceptar con Tab',
'autocomplete.stylePreset': 'Estilo predeterminado',
'autocomplete.style.balanced': 'Equilibrado',
'autocomplete.style.concise': 'Conciso',
'autocomplete.style.formal': 'formales',
'autocomplete.style.casual': 'Informal',
'autocomplete.style.custom': 'Personalizado',
'autocomplete.disabledApps': 'Apps desactivadas (un bundle/token de app por línea)',
'autocomplete.saveSettings': 'Guardar configuración',
'autocomplete.saving': 'Guardando…',
'autocomplete.runtime': 'Tiempo de ejecución',
'autocomplete.running': 'Ejecutándose',
'autocomplete.start': 'Iniciar',
'autocomplete.stop': 'Detener',
'autocomplete.settingsSaved': 'Configuración de autocompletado guardada.',
'autocomplete.started': 'Autocompletado iniciado.',
'autocomplete.didNotStart': 'El autocompletado no inició. Verifica si está habilitado.',
'autocomplete.stopped': 'Autocompletado detenido.',
'autocomplete.advancedSettings': 'Configuración avanzada',
'autocomplete.debugTitle': 'Depuración de autocompletado',
'chat.agentChat': 'Chat con agente',
'chat.overrides': 'Anulaciones',
'chat.model': 'Modelo',
@@ -4578,9 +4574,6 @@ const messages: TranslationMap = {
'settings.developerMenu.composio.title': 'Composio',
'settings.developerMenu.composio.desc':
'Modo de enrutamiento, activadores de integración y archivo de historial de activadores.',
'pages.settings.features.desktopCompanion': 'Acompañante de escritorio',
'pages.settings.features.desktopCompanionDesc':
'Asistente de voz con conciencia de pantalla: escucha, ve, habla, señala',
'pages.settings.features.messagingChannels': 'Canales de mensajería',
'pages.settings.features.messagingChannelsDesc': 'Descripción de canales de mensajería',
'pages.settings.features.notifications': 'Notificaciones',
@@ -5006,65 +4999,6 @@ const messages: TranslationMap = {
'settings.ai.workload.changeModel': 'Cambiar modelo',
'settings.ai.workload.chooseModel': 'Elige modelo',
'settings.ai.provider.ollama': 'Ollama',
'settings.autocomplete.appFilter.acceptSuggestion': 'Aceptar sugerencia',
'settings.autocomplete.appFilter.app': 'Aplicación',
'settings.autocomplete.appFilter.currentSuggestion': 'Sugerencia actual',
'settings.autocomplete.appFilter.contextOverride': 'Anulación de contexto (opcional)',
'settings.autocomplete.appFilter.debounce': 'rebote',
'settings.autocomplete.appFilter.debugFocus': 'Foco de depuración',
'settings.autocomplete.appFilter.enabled': 'Habilitado',
'settings.autocomplete.appFilter.getSuggestion': 'Obtener sugerencia',
'settings.autocomplete.appFilter.lastError': 'último error',
'settings.autocomplete.appFilter.liveLogs': 'Registros en vivo',
'settings.autocomplete.appFilter.model': 'modelo',
'settings.autocomplete.appFilter.noLogs': '):',
'settings.autocomplete.appFilter.phase': 'Fase',
'settings.autocomplete.appFilter.platformSupported': 'Plataforma compatible',
'settings.autocomplete.appFilter.refreshStatus': 'Actualizando…',
'settings.autocomplete.appFilter.refreshing': 'Actualizando…',
'settings.autocomplete.appFilter.running': 'corriendo',
'settings.autocomplete.appFilter.runtime': 'Tiempo de ejecución',
'settings.autocomplete.appFilter.test': 'Probar',
'settings.autocomplete.completionStyle.acceptedCompletion':
'{count} completado aceptado almacenado: se usa para personalizar futuras sugerencias.',
'settings.autocomplete.completionStyle.acceptedCompletions':
'{count} completados aceptados almacenados: se usan para personalizar futuras sugerencias.',
'settings.autocomplete.completionStyle.clearHistory': 'Limpiando…',
'settings.autocomplete.completionStyle.clearing': 'Limpiando…',
'settings.autocomplete.completionStyle.debounce': 'Rebote (ms)',
'settings.autocomplete.completionStyle.enabled': 'Activado',
'settings.autocomplete.completionStyle.maxChars': 'Máx. caracteres',
'settings.autocomplete.completionStyle.noHistory':
'Sin completados aceptados aún. Acepta sugerencias con Tab para empezar a personalizar.',
'settings.autocomplete.completionStyle.overlayTtl': 'TTL de overlay (ms)',
'settings.autocomplete.completionStyle.personalizationHistory': 'Historial de personalización',
'settings.autocomplete.completionStyle.styleExamples': 'Ejemplos de estilo (uno por línea)',
'settings.autocomplete.completionStyle.styleInstructions': 'Instrucciones de estilo',
'settings.autocomplete.debug.acceptedPrefix': 'Aceptado: {value}',
'settings.autocomplete.debug.acceptFailed': 'No se pudo aceptar la sugerencia',
'settings.autocomplete.debug.alreadyRunning':
'La función de autocompletar ya se está ejecutando.',
'settings.autocomplete.debug.clearHistoryFailed': 'No se pudo borrar el historial',
'settings.autocomplete.debug.didNotStart': 'La función de autocompletar no se inició.',
'settings.autocomplete.debug.disabledInSettings':
'Autocompletar está deshabilitado en la configuración. Habilítelo y guarde primero.',
'settings.autocomplete.debug.fetchSuggestionFailed': 'No se pudo recuperar la sugerencia actual',
'settings.autocomplete.debug.inspectFocusedElementFailed':
'No se pudo inspeccionar el elemento enfocado',
'settings.autocomplete.debug.loadSettingsFailed':
'No se pudo cargar la configuración de autocompletar',
'settings.autocomplete.debug.noSuggestionApplied': 'No se aplicó ninguna sugerencia.',
'settings.autocomplete.debug.noSuggestionReturned': 'No se devolvió ninguna sugerencia.',
'settings.autocomplete.debug.refreshStatusFailed':
'No se pudo actualizar el estado de autocompletar',
'settings.autocomplete.debug.saveAdvancedSettingsFailed':
'No se pudo guardar la configuración avanzada',
'settings.autocomplete.debug.startFailed': 'No se pudo iniciar el autocompletado',
'settings.autocomplete.debug.stopFailed': 'No se pudo detener el autocompletado',
'settings.autocomplete.debug.suggestionPrefix': 'Sugerencia: {value}',
'settings.autocomplete.shared.none': 'Ninguno',
'settings.autocomplete.shared.notApplicable': 'n/a',
'settings.autocomplete.shared.unknown': 'Desconocido',
'settings.billing.autoRecharge.addAmount': 'Agregar esta cantidad',
'settings.billing.autoRecharge.addCard': 'Agregar tarjeta',
'settings.billing.autoRecharge.amountHint': 'Sugerencia de cantidad',
@@ -5180,23 +5114,6 @@ const messages: TranslationMap = {
'Error al guardar. El modo Directo requiere una clave API no vacía.',
'settings.composio.saving': 'Guardando…',
'settings.composio.switching': 'Cambiando…',
'settings.companion.title': 'Compañero de escritorio',
'settings.companion.session': 'Sesión',
'settings.companion.activeLabel': 'Activo',
'settings.companion.inactiveStatus': 'Inactivo',
'settings.companion.stopping': 'Parando…',
'settings.companion.stopSession': 'Detener sesión',
'settings.companion.starting': 'Empezando…',
'settings.companion.startSession': 'Iniciar sesión',
'settings.companion.sessionId': 'ID de sesión',
'settings.companion.turns': 'vueltas',
'settings.companion.remaining': 'restante',
'settings.companion.configuration': 'Configuración',
'settings.companion.hotkey': 'tecla de acceso rápido',
'settings.companion.activationMode': 'Modo de activación',
'settings.companion.sessionTtl': 'TTL de sesión',
'settings.companion.screenCapture': 'Captura de pantalla',
'settings.companion.appContext': 'Contexto de la aplicación',
'settings.cron.jobs.commandRequired': 'El comando es obligatorio para los trabajos de shell',
'settings.cron.jobs.createJob': 'Nuevo trabajo programado',
'settings.cron.jobs.created': 'Trabajo creado',
@@ -5626,9 +5543,6 @@ const messages: TranslationMap = {
'settings.developerMenu.autonomy.title': 'Autonomía del agente',
'settings.developerMenu.autonomy.desc':
'Límites de frecuencia de acciones de herramientas y umbrales de seguridad',
'settings.developerMenu.autocomplete.title': 'Autocompletar',
'settings.developerMenu.autocomplete.desc':
'Configuración de autocompletado en línea con IA y panel de depuración',
'settings.developerMenu.voiceDebug.title': 'Voz (depuración)',
'settings.developerMenu.voiceDebug.desc':
'Estado del tiempo de ejecución del dictado de voz y configuración de depuración',
@@ -6104,25 +6018,6 @@ const messages: TranslationMap = {
'settings.modelHealth.modal.apply': 'Aplicar reemplazo',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
'settings.desktopAgent.title': 'Agente de escritorio',
'settings.desktopAgent.description':
'Comprueba y concede los permisos que necesita el agente de escritorio.',
'settings.desktopAgent.microphone': 'Micrófono',
'settings.desktopAgent.grant': 'Conceder',
'settings.desktopAgent.notRequiredOnOs': 'No es necesario en este sistema operativo',
'settings.desktopAgent.allGranted':
'Todo listo: el agente de escritorio tiene todos los permisos que necesita.',
'settings.desktopAgent.recheck': 'Volver a comprobar',
'settings.desktopAgent.restartAndRecheck': 'Reiniciar el núcleo y volver a comprobar',
'settings.desktopAgent.seamless.title': 'Acciones del agente',
'settings.desktopAgent.seamless.label': 'Deja que el agente actúe sin preguntar',
'settings.desktopAgent.seamless.description':
'Concede acceso completo y aprueba automáticamente las acciones de escritorio (abrir aplicaciones, controlar sus interfaces) para que el agente las ejecute sin pedir confirmación.',
'settings.desktopAgent.seamless.note':
'Solo las acciones de escritorio omiten la confirmación: ejecutar comandos de shell, editar archivos, peticiones de red e instalar software siguen preguntando. Al desactivarlo se deja de aprobar automáticamente las acciones de escritorio, pero el nivel de acceso no cambia.',
'settings.desktopAgent.beta':
'Beta: el agente de escritorio es experimental y puede no funcionar siempre como se espera.',
'settings.desktopAgent.wakeWordHint': 'Di “Hey Tiny” antes de tu orden para activar el agente.',
'settings.screenIntel.permissions.accessibility': 'Accesibilidad',
'settings.screenIntel.permissions.grantHint': 'Sugerencia de permiso',
'settings.screenIntel.permissions.inputMonitoring': 'Monitoreo de entrada',
@@ -6401,21 +6296,6 @@ const messages: TranslationMap = {
'skills.resource.tree.templates': 'Plantillas',
'skills.resource.tree.empty': 'Sin recursos incluidos.',
'skills.search.placeholder': 'Marcador de posición',
'skills.setup.autocomplete.acceptKey': 'Tecla de aceptación',
'skills.setup.autocomplete.activeDesc': 'Descripción activa',
'skills.setup.autocomplete.activeTitle': 'Autocompletado está activo',
'skills.setup.autocomplete.customizeSettings': 'Personalizar configuración',
'skills.setup.autocomplete.debounce': 'rebote',
'skills.setup.autocomplete.description': 'Descripción',
'skills.setup.autocomplete.enableBtn': 'Activando...',
'skills.setup.autocomplete.enableError': 'No se pudo activar el autocompletado',
'skills.setup.autocomplete.enabling': 'Activando...',
'skills.setup.autocomplete.notSupported': 'No compatible',
'skills.setup.autocomplete.stepEnable': 'Activar completados en línea',
'skills.setup.autocomplete.stepSuccess': 'Listo para usar',
'skills.setup.autocomplete.stylePreset': 'Estilo predeterminado',
'skills.setup.autocomplete.stylePresetValue': 'Equilibrado (configurable después)',
'skills.setup.autocomplete.title': 'Autocompletado de texto',
'skills.setup.screenIntel.activeDesc': 'Descripción activa',
'skills.setup.screenIntel.activeTitle': 'Inteligencia de pantalla está activada',
'skills.setup.screenIntel.advancedSettings': 'Configuración avanzada',
@@ -6911,9 +6791,6 @@ const messages: TranslationMap = {
'chat.files.error.resolve_failed': 'No se pudo resolver el artefacto. Inténtalo de nuevo.',
'chat.files.error.download_failed': 'La descarga falló. Inténtalo de nuevo.',
'chat.files.error.delete_failed': 'No se pudo eliminar el archivo. Inténtalo de nuevo.',
'autocomplete.debounceMs': 'Retardo (ms)',
'autocomplete.maxChars': 'Máximo de caracteres de contexto',
'autocomplete.overlayTtlMs': 'Tiempo de espera de superposición (ms)',
'memory.tab.council': 'Council',
'activity.tabs.automations': 'Automatizaciones',
'activity.tabs.automationsDescription':
@@ -6948,135 +6825,6 @@ const messages: TranslationMap = {
'intelligence.agents.tier.chat': 'Chat',
'intelligence.agents.tier.reasoning': 'Razonamiento',
'intelligence.agents.tier.worker': 'Trabajador',
'modelCouncil.title': 'Consejo de modelos',
'modelCouncil.intro':
'Haz una pregunta, obtén respuestas independientes de varios modelos en paralelo, y luego el modelo presidente sintetiza dónde coinciden, dónde discrepan y qué aporta cada uno.',
'modelCouncil.listTitle': 'Consejos',
'modelCouncil.listIntro':
'Elige un consejo guardado, edita sus agentes y juez, o crea uno nuevo para un tipo específico de decisión.',
'modelCouncil.addCouncil': 'Nuevo consejo',
'modelCouncil.openCouncil': 'Abrir consejo',
'modelCouncil.editCouncil': 'Editar consejo',
'modelCouncil.editCurrentCouncil': 'Editar consejo actual',
'modelCouncil.editCouncilAria': 'Editar {name}',
'modelCouncil.deleteCouncilAria': 'Eliminar {name}',
'modelCouncil.backToCouncils': 'Volver a los consejos',
'modelCouncil.saveCouncil': 'Guardar consejo',
'modelCouncil.savingCouncil': 'Guardando…',
'modelCouncil.cancelEdit': 'Cancelar',
'modelCouncil.loadingCouncils': 'Cargando consejos…',
'modelCouncil.noCouncils': 'Aún no hay consejos. Agrega uno para empezar.',
'modelCouncil.noCouncilDescription': 'Sin descripción',
'modelCouncil.registryErrorPrefix': 'El registro del consejo falló:',
'modelCouncil.councilNameLabel': 'Nombre del consejo',
'modelCouncil.councilDescriptionLabel': 'Descripción',
'modelCouncil.councilDescriptionPlaceholder': 'Cuándo usar este consejo',
'modelCouncil.selectModel': 'Seleccionar',
'modelCouncil.modelPickerHelp':
'Selecciona una pista de enrutamiento o fija un modelo de proveedor exacto.',
'modelCouncil.closeModelPicker': 'Cerrar',
'modelCouncil.hint.chat': 'Chat',
'modelCouncil.hint.code': 'Código',
'modelCouncil.hint.default': 'Predeterminado',
'modelCouncil.hint.reasoning': 'Razonamiento',
'modelCouncil.hint.summarize': 'Resumen',
'modelCouncil.modelPickerHints': 'Sugerencias',
'modelCouncil.modelPickerProviderModel': 'Proveedor + modelo',
'modelCouncil.modelProviderLabel': 'Proveedor del modelo',
'modelCouncil.modelIdLabel': 'ID del modelo',
'modelCouncil.useProviderModel': 'Usar modelo del proveedor',
'modelCouncil.questionLabel': 'Pregunta',
'modelCouncil.questionPlaceholder': 'Haz una pregunta al consejo…',
'modelCouncil.membersLabel': 'Modelos miembros',
'modelCouncil.maxMembersNote': 'hasta {max}',
'modelCouncil.memberPlaceholder': 'ej. gpt-5.2',
'modelCouncil.memberAria': 'Modelo miembro {n}',
'modelCouncil.removeMemberAria': 'Eliminar modelo miembro {n}',
'modelCouncil.addMember': '+ Agregar modelo',
'modelCouncil.settingsTitle': 'Configuración del consejo',
'modelCouncil.settingsSummary': '{count} jurados · juez: {judge}',
'modelCouncil.juryCountLabel': 'Número de jurados',
'modelCouncil.debateRoundsLabel': 'Rondas de debate',
'modelCouncil.debateRoundsHelp':
'Cada jurado sigue debatiendo en rondas antes de que el juez escriba la síntesis final.',
'modelCouncil.judgeAgentLabel': 'Agente juez',
'modelCouncil.defaultJudge': 'Juez predeterminado',
'modelCouncil.savedProfile': 'Perfil guardado',
'modelCouncil.customAgent': 'Agente personalizado',
'modelCouncil.judgeProfileLabel': 'Perfil del juez',
'modelCouncil.chooseProfile': 'Elige un perfil',
'modelCouncil.judgeNameLabel': 'Nombre del juez',
'modelCouncil.judgeNamePlaceholder': 'Juez principal',
'modelCouncil.rosterHeading': 'Lista del consejo',
'modelCouncil.rosterHelp':
'Cada jurado puede heredar el agente predeterminado, usar un perfil guardado o convertirse en un perfil personalizado para esta ejecución.',
'modelCouncil.loadingProfiles': 'Cargando perfiles…',
'modelCouncil.jurorLabel': 'Jurado {n}',
'modelCouncil.profileModeLabel': 'Origen del perfil',
'modelCouncil.mode.default': 'Predeterminado',
'modelCouncil.mode.profile': 'Perfil',
'modelCouncil.mode.custom': 'Personalizado',
'modelCouncil.memberProfileAria': 'Perfil del jurado {n}',
'modelCouncil.memberNameAria': 'Nombre del jurado {n}',
'modelCouncil.memberNamePlaceholder': 'Nombre del agente',
'modelCouncil.profileModelPlaceholder': 'Sobrescribir modelo del perfil',
'modelCouncil.memberBriefAria': 'Brief del jurado {n}',
'modelCouncil.memberBriefPlaceholder': 'Perspectiva de este jurado',
'modelCouncil.jurorFallback': 'Jurado',
'modelCouncil.sharedReasoningLabel': 'Archivo de razonamiento compartido',
'modelCouncil.sharedReasoningHelp':
'El consejo trabaja sobre este borrador compartido antes de que el juez escriba la síntesis.',
'modelCouncil.liveScratchpadHelp':
'Las actualizaciones de los jurados se agregan aquí después de cada ronda y se usan en la siguiente.',
'modelCouncil.liveScratchpadBadge': 'Live',
'modelCouncil.scratchpadRoundHeading': 'Actualizaciones de la ronda {round}',
'modelCouncil.scratchpadNoResponse': 'Sin respuesta',
'modelCouncil.chairLabel': 'Modelo presidente',
'modelCouncil.chairPlaceholder': 'ej. claude-opus-4-8',
'modelCouncil.chairHelp':
'El presidente lee cada respuesta de los miembros y redacta la síntesis.',
'modelCouncil.run': 'Convocar consejo',
'modelCouncil.running': 'Convocando…',
'modelCouncil.runningHint': 'Consultando miembros en paralelo, luego sintetizando…',
'modelCouncil.deliberationHeading': 'Deliberación del consejo',
'modelCouncil.deliberationHelp':
'Las mascotas muestran a cada agente pensando mientras el archivo de razonamiento compartido está en uso.',
'modelCouncil.debateRoundInstruction':
'Responde a los otros jurados. Mantén los desacuerdos, revisa tu postura cuando sea justificado y deja tu conclusión actual explícita.',
'modelCouncil.debateFinalInstruction':
'Esta es tu última ronda como jurado. Converge donde la evidencia lo respalde, señala desacuerdos restantes y dale al juez una conclusión clara.',
'modelCouncil.roundLabel': 'Ronda {round}',
'modelCouncil.currentRoundThinking': 'Continuando la siguiente ronda…',
'modelCouncil.thinkingBadge': 'Pensando',
'modelCouncil.thinkingWithBrief': 'Trabajando desde esta perspectiva: {brief}',
'modelCouncil.thought.evidence':
'Revisando evidencia, suposiciones y lo que el archivo de razonamiento compartido ya contiene.',
'modelCouncil.thought.plan': 'Trazando el camino práctico y dónde la ejecución podría atascarse.',
'modelCouncil.thought.risk':
'Buscando modos de fallo, contexto faltante y conclusiones frágiles.',
'modelCouncil.thought.tradeoffs':
'Comparando compromisos antes de entregar al juez una posición concisa.',
'modelCouncil.thought.synthesis':
'Preparando una vista distinta para que el juez compare desacuerdos reales.',
'modelCouncil.judgeWaitingBadge': 'Juez',
'modelCouncil.judgeWaitingThought':
'Esperando las respuestas de los jurados, luego leyendo el archivo de razonamiento compartido para sintetizar.',
'modelCouncil.judgeSynthesizingBadge': 'Sintetizando',
'modelCouncil.judgeSynthesizingThought':
'Leyendo los pensamientos en vivo de los jurados y redactando la síntesis final del consejo.',
'modelCouncil.errorPrefix': 'El consejo falló:',
'modelCouncil.resultsHeading': 'Resultados del consejo',
'modelCouncil.memberAnswered': 'Respondido',
'modelCouncil.memberFailed': 'Fallido',
'modelCouncil.synthesisHeading': 'Síntesis',
'modelCouncil.synthesisBy': 'por {model}',
'modelCouncil.usageHeading': 'Uso del debate',
'modelCouncil.usageEstimated':
'Conteos de tokens estimados de los prompts y respuestas del debate. El precio real requiere el uso del proveedor desde el RPC del consejo.',
'modelCouncil.usageEstimatedBadge': 'Tokens estimados',
'modelCouncil.usageInputTokens': 'Input',
'modelCouncil.usageOutputTokens': 'Output',
'modelCouncil.usageTotalTokens': 'Total',
'graphCohesion.brokerBadge': 'intermediario',
'graphCohesion.brokerTitle':
'Hueco estructural: los vecinos de esta entidad no están conectados entre sí: ella es el único enlace entre ellos.',
+25 -278
View File
@@ -3,6 +3,31 @@ import type { TranslationMap } from './types';
// French (Français) translations. Keys mirror en.ts; missing/
// English-identical values fall back to English via I18nContext.resolveEn().
const messages: TranslationMap = {
'connections.header.companion':
'Un compagnon de bureau flottant pour une aide rapide et toujours disponible.',
'settings.assistant.desktopCompanion': 'Compagnon de bureau',
'settings.assistant.desktopCompanionDesc':
'Mode compagnon toujours actif avec raccourci dans la barre système',
'pages.settings.features.desktopCompanion': 'Compagnon de bureau',
'pages.settings.features.desktopCompanionDesc':
"Assistant vocal avec conscience de l'écran: écoute, voit, parle, pointe",
'settings.companion.title': 'Compagnon de bureau',
'settings.companion.session': 'Session',
'settings.companion.activeLabel': 'Actif',
'settings.companion.inactiveStatus': 'Inactif',
'settings.companion.stopping': 'Arrêt…',
'settings.companion.stopSession': 'Arrêter la session',
'settings.companion.starting': 'Démarrage…',
'settings.companion.startSession': 'Démarrer la session',
'settings.companion.sessionId': 'ID de session',
'settings.companion.turns': 'Interactions',
'settings.companion.remaining': 'Restant',
'settings.companion.configuration': 'Configuration',
'settings.companion.hotkey': 'Raccourci clavier',
'settings.companion.activationMode': "Mode d'activation",
'settings.companion.sessionTtl': 'Durée de vie de la session',
'settings.companion.screenCapture': "Capture d'écran",
'settings.companion.appContext': "Contexte de l'application",
// Share cards (#5006).
'share.button': 'Partager',
'share.modalTitle': 'Partagez ce moment',
@@ -99,10 +124,6 @@ const messages: TranslationMap = {
'Le solde de votre portefeuille local et votre phrase de récupération.',
'connections.header.screen':
'Laissez votre agent voir votre écran pour offrir une aide opportune et contextuelle.',
'connections.header.desktopAgent':
'Laissez votre agent ouvrir des applications et contrôler votre bureau en votre nom.',
'connections.header.companion':
'Un compagnon de bureau flottant pour une aide rapide et toujours disponible.',
'connections.header.composioKey':
'Ajoutez une clé API Composio pour débloquer des centaines dintégrations dapplications.',
'connections.llm.apiKeys': 'Clés API et routage',
@@ -765,9 +786,6 @@ const messages: TranslationMap = {
"Contrôler l'activité en arrière-plan de votre assistant",
'settings.assistant.screenAwareness': "Conscience de l'écran",
'settings.assistant.screenAwarenessDesc': "Permettre à l'assistant de voir votre fenêtre active",
'settings.assistant.desktopCompanion': 'Compagnon de bureau',
'settings.assistant.desktopCompanionDesc':
'Mode compagnon toujours actif avec raccourci dans la barre système',
'settings.assistant.permissions': 'Autorisations',
'settings.assistant.permissionsDesc':
"Choisissez ce que l'assistant peut faire et où il peut travailler",
@@ -2599,28 +2617,6 @@ const messages: TranslationMap = {
'En plein écran exclusif des jeux, la surcouche ne saffichera pas: tu entendras seulement le son. Passe en plein écran sans bordures pour voir la surcouche.',
'pttOverlay.listening': 'À l’écoute…',
'pttOverlay.idle': 'En attente',
'autocomplete.title': 'Autocomplétion',
'autocomplete.settings': 'Paramètres',
'autocomplete.acceptWithTab': 'Accepter avec Tab',
'autocomplete.stylePreset': 'Préréglage de style',
'autocomplete.style.balanced': 'Équilibré',
'autocomplete.style.concise': 'Concis',
'autocomplete.style.formal': 'Formel',
'autocomplete.style.casual': 'Décontracté',
'autocomplete.style.custom': 'Personnalisé',
'autocomplete.disabledApps': "Apps désactivées (un identifiant d'app par ligne)",
'autocomplete.saveSettings': 'Enregistrer les paramètres',
'autocomplete.saving': 'Enregistrement…',
'autocomplete.runtime': 'Runtime',
'autocomplete.running': 'En cours',
'autocomplete.start': 'Démarrer',
'autocomplete.stop': 'Arrêter',
'autocomplete.settingsSaved': "Paramètres d'autocomplétion enregistrés.",
'autocomplete.started': 'Autocomplétion démarrée.',
'autocomplete.didNotStart': "L'autocomplétion n'a pas démarré. Vérifie qu'elle est activée.",
'autocomplete.stopped': 'Autocomplétion arrêtée.',
'autocomplete.advancedSettings': 'Paramètres avancés',
'autocomplete.debugTitle': "Débogage de l'autocomplétion",
'chat.agentChat': "Chat avec l'agent",
'chat.overrides': 'Remplacements',
'chat.model': 'Modèle',
@@ -4607,9 +4603,6 @@ const messages: TranslationMap = {
'settings.developerMenu.composio.title': 'Composio',
'settings.developerMenu.composio.desc':
"Mode de routage, déclencheurs d'intégration et archive de l'historique des déclencheurs.",
'pages.settings.features.desktopCompanion': 'Compagnon de bureau',
'pages.settings.features.desktopCompanionDesc':
"Assistant vocal avec conscience de l'écran: écoute, voit, parle, pointe",
'pages.settings.features.messagingChannels': 'Canaux de messagerie',
'pages.settings.features.messagingChannelsDesc': 'Description des canaux de messagerie',
'pages.settings.features.notifications': 'Notifications',
@@ -5035,66 +5028,6 @@ const messages: TranslationMap = {
'settings.ai.workload.changeModel': 'Changer de modèle',
'settings.ai.workload.chooseModel': 'Choisir un modèle',
'settings.ai.provider.ollama': 'Ollama',
'settings.autocomplete.appFilter.acceptSuggestion': 'Accepter la suggestion',
'settings.autocomplete.appFilter.app': 'Application',
'settings.autocomplete.appFilter.currentSuggestion': 'Suggestion actuelle',
'settings.autocomplete.appFilter.contextOverride': 'Remplacement du contexte (optionnel)',
'settings.autocomplete.appFilter.debounce': 'Anti-rebond',
'settings.autocomplete.appFilter.debugFocus': 'Focus de débogage',
'settings.autocomplete.appFilter.enabled': 'Activé',
'settings.autocomplete.appFilter.getSuggestion': 'Obtenir une suggestion',
'settings.autocomplete.appFilter.lastError': 'Dernière erreur',
'settings.autocomplete.appFilter.liveLogs': 'Journaux en direct',
'settings.autocomplete.appFilter.model': 'Modèle',
'settings.autocomplete.appFilter.noLogs': '):',
'settings.autocomplete.appFilter.phase': 'Phase',
'settings.autocomplete.appFilter.platformSupported': 'Plateforme prise en charge',
'settings.autocomplete.appFilter.refreshStatus': 'Actualisation…',
'settings.autocomplete.appFilter.refreshing': 'Actualisation…',
'settings.autocomplete.appFilter.running': "En cours d'exécution",
'settings.autocomplete.appFilter.runtime': 'Exécution',
'settings.autocomplete.appFilter.test': 'Tester',
'settings.autocomplete.completionStyle.acceptedCompletion':
'{count} complétion acceptée stockée: utilisée pour personnaliser les suggestions futures.',
'settings.autocomplete.completionStyle.acceptedCompletions':
'{count} complétions acceptées stockées: utilisées pour personnaliser les suggestions futures.',
'settings.autocomplete.completionStyle.clearHistory': 'Effacement…',
'settings.autocomplete.completionStyle.clearing': 'Effacement…',
'settings.autocomplete.completionStyle.debounce': 'Délai anti-rebond (ms)',
'settings.autocomplete.completionStyle.enabled': 'Activé',
'settings.autocomplete.completionStyle.maxChars': 'Nombre max de caractères',
'settings.autocomplete.completionStyle.noHistory':
'Pas encore de complétions acceptées. Accepte des suggestions avec Tab pour commencer à personnaliser.',
'settings.autocomplete.completionStyle.overlayTtl': "Durée de vie de l'overlay (ms)",
'settings.autocomplete.completionStyle.personalizationHistory': 'Historique de personnalisation',
'settings.autocomplete.completionStyle.styleExamples': 'Exemples de style (un par ligne)',
'settings.autocomplete.completionStyle.styleInstructions': 'Instructions de style',
'settings.autocomplete.debug.acceptedPrefix': 'Acceptée : {value}',
'settings.autocomplete.debug.acceptFailed': "Échec de l'acceptation de la suggestion",
'settings.autocomplete.debug.alreadyRunning':
"La saisie semi-automatique est déjà en cours d'exécution.",
'settings.autocomplete.debug.clearHistoryFailed': "Échec de l'effacement de l'historique.",
'settings.autocomplete.debug.didNotStart': "La saisie semi-automatique n'a pas démarré.",
'settings.autocomplete.debug.disabledInSettings':
"La saisie semi-automatique est désactivée dans les paramètres. Activez-le et enregistrez d'abord.",
'settings.autocomplete.debug.fetchSuggestionFailed':
'Échec de la récupération de la suggestion actuelle',
'settings.autocomplete.debug.inspectFocusedElementFailed':
"Échec de l'inspection de l'élément ciblé",
'settings.autocomplete.debug.loadSettingsFailed':
'Échec du chargement des paramètres de saisie semi-automatique',
'settings.autocomplete.debug.noSuggestionApplied': "Aucune suggestion n'a été appliquée.",
'settings.autocomplete.debug.noSuggestionReturned': 'Aucune suggestion renvoyée.',
'settings.autocomplete.debug.refreshStatusFailed':
"Échec de l'actualisation de l'état de la saisie semi-automatique",
'settings.autocomplete.debug.saveAdvancedSettingsFailed':
"Échec de l'enregistrement des paramètres avancés",
'settings.autocomplete.debug.startFailed': 'Échec du démarrage de la saisie semi-automatique',
'settings.autocomplete.debug.stopFailed': "Échec de l'arrêt de la saisie semi-automatique",
'settings.autocomplete.debug.suggestionPrefix': 'Suggestion : {value}',
'settings.autocomplete.shared.none': 'Aucune',
'settings.autocomplete.shared.notApplicable': 'n/a',
'settings.autocomplete.shared.unknown': 'Inconnu',
'settings.billing.autoRecharge.addAmount': 'Ajouter ce montant',
'settings.billing.autoRecharge.addCard': 'Ajouter une carte',
'settings.billing.autoRecharge.amountHint': 'Indice de montant',
@@ -5211,23 +5144,6 @@ const messages: TranslationMap = {
"Échec de l'enregistrement. Le mode Direct nécessite une clé API non vide.",
'settings.composio.saving': 'Enregistrement…',
'settings.composio.switching': 'Bascule en cours…',
'settings.companion.title': 'Compagnon de bureau',
'settings.companion.session': 'Session',
'settings.companion.activeLabel': 'Actif',
'settings.companion.inactiveStatus': 'Inactif',
'settings.companion.stopping': 'Arrêt…',
'settings.companion.stopSession': 'Arrêter la session',
'settings.companion.starting': 'Démarrage…',
'settings.companion.startSession': 'Démarrer la session',
'settings.companion.sessionId': 'ID de session',
'settings.companion.turns': 'Tours',
'settings.companion.remaining': 'Configuration',
'settings.companion.configuration': 'restante',
'settings.companion.hotkey': 'Raccourci clavier',
'settings.companion.activationMode': "Mode d'activation",
'settings.companion.sessionTtl': 'Durée de vie de la session',
'settings.companion.screenCapture': "Capture d'écran",
'settings.companion.appContext': "Contexte de l'application",
'settings.cron.jobs.commandRequired': 'La commande est requise pour les tâches shell',
'settings.cron.jobs.createJob': 'Nouvelle tâche planifiée',
'settings.cron.jobs.created': 'Tâche créée',
@@ -5659,9 +5575,6 @@ const messages: TranslationMap = {
'settings.developerMenu.autonomy.title': 'Autonomie de lagent',
'settings.developerMenu.autonomy.desc':
'Limites de fréquence des actions des outils et seuils de sécurité',
'settings.developerMenu.autocomplete.title': 'Autocomplétion',
'settings.developerMenu.autocomplete.desc':
'Paramètres dautocomplétion IA en ligne et panneau de débogage',
'settings.developerMenu.voiceDebug.title': 'Voix (débogage)',
'settings.developerMenu.voiceDebug.desc':
'État dexécution de la dictée vocale et paramètres de débogage',
@@ -6137,26 +6050,6 @@ const messages: TranslationMap = {
'settings.modelHealth.modal.apply': 'Appliquer le remplacement',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
'settings.desktopAgent.title': 'Agent de bureau',
'settings.desktopAgent.description':
'Vérifiez et accordez les autorisations dont lagent de bureau a besoin.',
'settings.desktopAgent.microphone': 'Microphone',
'settings.desktopAgent.grant': 'Accorder',
'settings.desktopAgent.notRequiredOnOs': 'Non requis sur ce système dexploitation',
'settings.desktopAgent.allGranted':
'Tout est prêt : lagent de bureau dispose de toutes les autorisations nécessaires.',
'settings.desktopAgent.recheck': 'Revérifier',
'settings.desktopAgent.restartAndRecheck': 'Redémarrer le cœur et revérifier',
'settings.desktopAgent.seamless.title': 'Actions de lagent',
'settings.desktopAgent.seamless.label': 'Laisser lagent agir sans demander',
'settings.desktopAgent.seamless.description':
'Accordez laccès complet et approuvez automatiquement les actions de bureau (ouvrir des applications, contrôler leurs interfaces) afin que lagent les exécute sans demande de confirmation.',
'settings.desktopAgent.seamless.note':
'Seules les actions de bureau évitent la demande: exécuter des commandes shell, modifier des fichiers, les requêtes réseau et linstallation de logiciels demandent toujours. Désactiver ceci arrête lapprobation automatique des actions de bureau mais laisse le niveau daccès inchangé.',
'settings.desktopAgent.beta':
'Bêta: lagent de bureau est expérimental et peut ne pas toujours fonctionner comme prévu.',
'settings.desktopAgent.wakeWordHint':
'Dites « Hey Tiny » avant votre commande pour réveiller lagent.',
'settings.screenIntel.permissions.accessibility': 'Accessibilité',
'settings.screenIntel.permissions.grantHint': "Indice d'autorisation",
'settings.screenIntel.permissions.inputMonitoring': 'Surveillance des entrées',
@@ -6435,21 +6328,6 @@ const messages: TranslationMap = {
'skills.resource.tree.templates': 'Modèles',
'skills.resource.tree.empty': 'Aucune ressource groupée.',
'skills.search.placeholder': 'Espace réservé',
'skills.setup.autocomplete.acceptKey': "Touche d'acceptation",
'skills.setup.autocomplete.activeDesc': 'Description active',
'skills.setup.autocomplete.activeTitle': "L'autocomplétion est active",
'skills.setup.autocomplete.customizeSettings': 'Personnaliser les paramètres',
'skills.setup.autocomplete.debounce': 'Anti-rebond',
'skills.setup.autocomplete.description': 'Description',
'skills.setup.autocomplete.enableBtn': 'Activation…',
'skills.setup.autocomplete.enableError': "Échec de l'activation de l'autocomplétion",
'skills.setup.autocomplete.enabling': 'Activation…',
'skills.setup.autocomplete.notSupported': 'Non pris en charge',
'skills.setup.autocomplete.stepEnable': 'Activer les complétions en ligne',
'skills.setup.autocomplete.stepSuccess': "Prêt à l'emploi",
'skills.setup.autocomplete.stylePreset': 'Préréglage de style',
'skills.setup.autocomplete.stylePresetValue': 'Équilibré (configurable plus tard)',
'skills.setup.autocomplete.title': 'Autocomplétion de texte',
'skills.setup.screenIntel.activeDesc': 'Description active',
'skills.setup.screenIntel.activeTitle': "L'intelligence d'écran est activée",
'skills.setup.screenIntel.advancedSettings': 'Paramètres avancés',
@@ -6946,9 +6824,6 @@ const messages: TranslationMap = {
'chat.files.error.resolve_failed': 'Impossible de résoudre lartefact. Réessayez.',
'chat.files.error.download_failed': 'Échec du téléchargement. Réessayez.',
'chat.files.error.delete_failed': 'Impossible de supprimer le fichier. Réessayez.',
'autocomplete.debounceMs': 'Anti-rebond (ms)',
'autocomplete.maxChars': 'Caractères de contexte maximum',
'autocomplete.overlayTtlMs': "Délai d'affichage (ms)",
'memory.tab.council': 'Council',
'activity.tabs.automations': 'Automatisations',
'activity.tabs.automationsDescription':
@@ -6983,134 +6858,6 @@ const messages: TranslationMap = {
'intelligence.agents.tier.chat': 'Chat',
'intelligence.agents.tier.reasoning': 'Raisonnement',
'intelligence.agents.tier.worker': 'Travailleur',
'modelCouncil.title': 'Conseil de modèles',
'modelCouncil.intro':
"Posez une question, obtenez des réponses indépendantes de plusieurs modèles en parallèle, puis le modèle président synthétise les points d'accord, de désaccord et les apports uniques de chacun.",
'modelCouncil.listTitle': 'Councils',
'modelCouncil.listIntro':
'Choisissez un conseil sauvegardé, modifiez ses agents et son juge, ou créez-en un nouveau pour un type de décision spécifique.',
'modelCouncil.addCouncil': 'Nouveau conseil',
'modelCouncil.openCouncil': 'Ouvrir le conseil',
'modelCouncil.editCouncil': 'Modifier le conseil',
'modelCouncil.editCurrentCouncil': 'Modifier le conseil actuel',
'modelCouncil.editCouncilAria': 'Modifier {name}',
'modelCouncil.deleteCouncilAria': 'Supprimer {name}',
'modelCouncil.backToCouncils': 'Retour aux conseils',
'modelCouncil.saveCouncil': 'Enregistrer le conseil',
'modelCouncil.savingCouncil': 'Saving…',
'modelCouncil.cancelEdit': 'Cancel',
'modelCouncil.loadingCouncils': 'Chargement des conseils…',
'modelCouncil.noCouncils': 'Aucun conseil pour le moment. Ajoutez-en un pour commencer.',
'modelCouncil.noCouncilDescription': 'Aucune description',
'modelCouncil.registryErrorPrefix': 'Le registre du conseil a échoué :',
'modelCouncil.councilNameLabel': 'Nom du conseil',
'modelCouncil.councilDescriptionLabel': 'Description',
'modelCouncil.councilDescriptionPlaceholder': 'Quand utiliser ce conseil',
'modelCouncil.selectModel': 'Select',
'modelCouncil.modelPickerHelp':
'Sélectionnez un indice de routage ou épinglez un modèle fournisseur exact.',
'modelCouncil.closeModelPicker': 'Close',
'modelCouncil.hint.chat': 'Conversation',
'modelCouncil.hint.code': 'Code',
'modelCouncil.hint.default': 'Par défaut',
'modelCouncil.hint.reasoning': 'Raisonnement',
'modelCouncil.hint.summarize': 'Résumé',
'modelCouncil.modelPickerHints': 'Hints',
'modelCouncil.modelPickerProviderModel': 'Fournisseur + modèle',
'modelCouncil.modelProviderLabel': 'Fournisseur du modèle',
'modelCouncil.modelIdLabel': 'ID du modèle',
'modelCouncil.useProviderModel': 'Utiliser le modèle du fournisseur',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Posez une question au conseil…',
'modelCouncil.membersLabel': 'Modèles membres',
'modelCouncil.maxMembersNote': "jusqu'à {max}",
'modelCouncil.memberPlaceholder': 'ex. gpt-5.2',
'modelCouncil.memberAria': 'Modèle membre {n}',
'modelCouncil.removeMemberAria': 'Supprimer le modèle membre {n}',
'modelCouncil.addMember': '+ Ajouter un modèle',
'modelCouncil.settingsTitle': 'Paramètres du conseil',
'modelCouncil.settingsSummary': '{count} jurés · juge : {judge}',
'modelCouncil.juryCountLabel': 'Nombre de jurés',
'modelCouncil.debateRoundsLabel': 'Tours de débat',
'modelCouncil.debateRoundsHelp':
'Chaque juré débat sur plusieurs tours avant que le juge ne rédige la synthèse finale.',
'modelCouncil.judgeAgentLabel': 'Agent juge',
'modelCouncil.defaultJudge': 'Juge par défaut',
'modelCouncil.savedProfile': 'Profil enregistré',
'modelCouncil.customAgent': 'Agent personnalisé',
'modelCouncil.judgeProfileLabel': 'Profil du juge',
'modelCouncil.chooseProfile': 'Choisir un profil',
'modelCouncil.judgeNameLabel': 'Nom du juge',
'modelCouncil.judgeNamePlaceholder': 'Juge principal',
'modelCouncil.rosterHeading': 'Composition du conseil',
'modelCouncil.rosterHelp':
'Chaque juré peut hériter de lagent par défaut, utiliser un profil enregistré ou devenir un profil personnalisé pour cette exécution.',
'modelCouncil.loadingProfiles': 'Chargement des profils…',
'modelCouncil.jurorLabel': 'Juré {n}',
'modelCouncil.profileModeLabel': 'Source du profil',
'modelCouncil.mode.default': 'Défaut',
'modelCouncil.mode.profile': 'Profil',
'modelCouncil.mode.custom': 'Personnalisé',
'modelCouncil.memberProfileAria': 'Profil du juré {n}',
'modelCouncil.memberNameAria': 'Nom du juré {n}',
'modelCouncil.memberNamePlaceholder': 'Nom de lagent',
'modelCouncil.profileModelPlaceholder': 'Remplacer le modèle du profil',
'modelCouncil.memberBriefAria': 'Brief du juré {n}',
'modelCouncil.memberBriefPlaceholder': 'Perspective de ce juré',
'modelCouncil.jurorFallback': 'Juré',
'modelCouncil.sharedReasoningLabel': 'Fichier de raisonnement partagé',
'modelCouncil.sharedReasoningHelp':
'Le conseil travaille sur ce brouillon partagé avant que le juge rédige la synthèse.',
'modelCouncil.liveScratchpadHelp':
'Les mises à jour des jurés sont ajoutées ici après chaque tour et alimentent le tour suivant.',
'modelCouncil.liveScratchpadBadge': 'Live',
'modelCouncil.scratchpadRoundHeading': 'Mises à jour du tour {round}',
'modelCouncil.scratchpadNoResponse': 'Aucune réponse',
'modelCouncil.chairLabel': 'Modèle président',
'modelCouncil.chairPlaceholder': 'ex. claude-opus-4-8',
'modelCouncil.chairHelp': 'Le président lit chaque réponse des membres et rédige la synthèse.',
'modelCouncil.run': 'Convoquer le conseil',
'modelCouncil.running': 'Convening…',
'modelCouncil.runningHint': 'Interrogation des membres en parallèle, puis synthèse…',
'modelCouncil.deliberationHeading': 'Délibération du conseil',
'modelCouncil.deliberationHelp':
"Les mascottes montrent chaque agent en réflexion pendant que le fichier de raisonnement partagé est en cours d'utilisation.",
'modelCouncil.debateRoundInstruction':
'Répondez aux autres jurés. Préservez les désaccords, révisez votre avis si justifié et gardez votre conclusion actuelle explicite.',
'modelCouncil.debateFinalInstruction':
"C'est votre dernier tour de juré. Convergez là où les preuves le justifient, signalez les désaccords restants et donnez au juge une conclusion claire.",
'modelCouncil.roundLabel': 'Tour {round}',
'modelCouncil.currentRoundThinking': 'Poursuite du tour suivant…',
'modelCouncil.thinkingBadge': 'Thinking',
'modelCouncil.thinkingWithBrief': 'Travail depuis cette perspective : {brief}',
'modelCouncil.thought.evidence':
'Vérification des preuves, des hypothèses et du contenu déjà capturé dans le fichier de raisonnement partagé.',
'modelCouncil.thought.plan': 'Esquisse du chemin pratique et des points de blocage possibles.',
'modelCouncil.thought.risk':
'Recherche de modes de défaillance, de contexte manquant et de conclusions fragiles.',
'modelCouncil.thought.tradeoffs':
'Comparaison des compromis avant de remettre au juge une position concise.',
'modelCouncil.thought.synthesis':
"Préparation d'un point de vue distinct pour que le juge puisse comparer les vrais désaccords.",
'modelCouncil.judgeWaitingBadge': 'Judge',
'modelCouncil.judgeWaitingThought':
'En attente des réponses des jurés, puis lecture du fichier de raisonnement partagé pour la synthèse.',
'modelCouncil.judgeSynthesizingBadge': 'Synthesizing',
'modelCouncil.judgeSynthesizingThought':
'Lecture des réflexions en direct des jurés et rédaction de la synthèse finale du conseil.',
'modelCouncil.errorPrefix': 'Le conseil a échoué :',
'modelCouncil.resultsHeading': 'Résultats du conseil',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'par {model}',
'modelCouncil.usageHeading': 'Utilisation du débat',
'modelCouncil.usageEstimated':
"Estimations de tokens issus des prompts et réponses du débat. La tarification réelle nécessite les données d'utilisation du fournisseur via le RPC du conseil.",
'modelCouncil.usageEstimatedBadge': 'Tokens estimés',
'modelCouncil.usageInputTokens': 'Input',
'modelCouncil.usageOutputTokens': 'Output',
'modelCouncil.usageTotalTokens': 'Total',
'graphCohesion.brokerBadge': 'courtier',
'graphCohesion.brokerTitle':
'Trou structurel : les voisins de cette entité ne sont pas connectés entre eux: elle est le seul lien entre eux.',
+23 -267
View File
@@ -3,6 +3,29 @@ import type { TranslationMap } from './types';
// Hindi (हिन्दी) translations. Keys mirror en.ts; missing/
// English-identical values fall back to English via I18nContext.resolveEn().
const messages: TranslationMap = {
'connections.header.companion': 'त्वरित, हमेशा उपलब्ध मदद के लिए एक फ़्लोटिंग डेस्कटॉप साथी।',
'settings.assistant.desktopCompanion': 'डेस्कटॉप साथी',
'settings.assistant.desktopCompanionDesc': 'सिस्टम ट्रे शॉर्टकट के साथ हमेशा-चालू साथी मोड',
'pages.settings.features.desktopCompanion': 'डेस्कटॉप कंपैनियन',
'pages.settings.features.desktopCompanionDesc':
'स्क्रीन जागरूकता के साथ वॉयस सहायक: सुनता है, देखता है, बोलता है, इशारा करता है',
'settings.companion.title': 'डेस्कटॉप साथी',
'settings.companion.session': 'सत्र',
'settings.companion.activeLabel': 'सक्रिय',
'settings.companion.inactiveStatus': 'निष्क्रिय',
'settings.companion.stopping': 'रुक रहा हूँ...',
'settings.companion.stopSession': 'सत्र रोकें',
'settings.companion.starting': 'शुरू हो रहा है...',
'settings.companion.startSession': 'सत्र प्रारंभ करें',
'settings.companion.sessionId': 'सत्र आईडी',
'settings.companion.turns': 'टर्न',
'settings.companion.remaining': 'शेष',
'settings.companion.configuration': 'विन्यास',
'settings.companion.hotkey': 'हॉटकी',
'settings.companion.activationMode': 'सक्रियण मोड',
'settings.companion.sessionTtl': 'सत्र टीटीएल',
'settings.companion.screenCapture': 'स्क्रीन कैप्चर',
'settings.companion.appContext': 'ऐप संदर्भ',
// Share cards (#5006).
'share.button': 'साझा करें',
'share.modalTitle': 'इस पल को साझा करें',
@@ -90,9 +113,6 @@ const messages: TranslationMap = {
'connections.header.wallet': 'आपके स्थानीय वॉलेट का बैलेंस और रिकवरी वाक्यांश।',
'connections.header.screen':
'समय पर, संदर्भ के अनुरूप मदद देने के लिए अपने एजेंट को अपनी स्क्रीन देखने दें।',
'connections.header.desktopAgent':
'अपने एजेंट को ऐप्स खोलने और आपकी ओर से आपके डेस्कटॉप को नियंत्रित करने दें।',
'connections.header.companion': 'त्वरित, हमेशा उपलब्ध मदद के लिए एक फ़्लोटिंग डेस्कटॉप साथी।',
'connections.header.composioKey':
'सैकड़ों ऐप एकीकरण अनलॉक करने के लिए एक Composio API कुंजी जोड़ें।',
'connections.llm.apiKeys': 'API कुंजियाँ और रूटिंग',
@@ -742,8 +762,6 @@ const messages: TranslationMap = {
'नियंत्रित करें कि आपका सहायक पृष्ठभूमि में कितना सक्रिय काम करे',
'settings.assistant.screenAwareness': 'स्क्रीन जागरूकता',
'settings.assistant.screenAwarenessDesc': 'सहायक को आपकी सक्रिय विंडो देखने दें',
'settings.assistant.desktopCompanion': 'डेस्कटॉप साथी',
'settings.assistant.desktopCompanionDesc': 'सिस्टम ट्रे शॉर्टकट के साथ हमेशा-चालू साथी मोड',
'settings.assistant.permissions': 'अनुमतियाँ',
'settings.assistant.permissionsDesc': 'चुनें कि सहायक क्या कर सकता है और कहाँ काम कर सकता है',
'settings.privacySecurity.privacy': 'गोपनीयता',
@@ -2525,28 +2543,6 @@ const messages: TranslationMap = {
'एक्सक्लूसिव फुलस्क्रीन गेम्स में ओवरले प्रदर्शित नहीं होगा: आपको केवल चाइम सुनाई देगा। ओवरले देखने के लिए बॉर्डरलेस फुलस्क्रीन पर जाएँ।',
'pttOverlay.listening': 'सुन रहा है…',
'pttOverlay.idle': 'निष्क्रिय',
'autocomplete.title': 'ऑटोकम्पलीट',
'autocomplete.settings': 'सेटिंग्स',
'autocomplete.acceptWithTab': 'Tab से एक्सेप्ट करें',
'autocomplete.stylePreset': 'स्टाइल प्रीसेट',
'autocomplete.style.balanced': 'बैलेंस्ड',
'autocomplete.style.concise': 'संक्षिप्त',
'autocomplete.style.formal': 'फॉर्मल',
'autocomplete.style.casual': 'कैज़ुअल',
'autocomplete.style.custom': 'कस्टम',
'autocomplete.disabledApps': 'डिसेबल ऐप्स (एक बंडल/ऐप टोकन प्रति लाइन)',
'autocomplete.saveSettings': 'सेटिंग्स सेव करें',
'autocomplete.saving': 'सेव हो रहा है…',
'autocomplete.runtime': 'रनटाइम',
'autocomplete.running': 'चल रहा है',
'autocomplete.start': 'शुरू करें',
'autocomplete.stop': 'रोकें',
'autocomplete.settingsSaved': 'ऑटोकम्पलीट सेटिंग्स सेव हो गईं।',
'autocomplete.started': 'ऑटोकम्पलीट शुरू हो गया।',
'autocomplete.didNotStart': 'ऑटोकम्पलीट शुरू नहीं हुआ। चेक करें कि यह चालू है।',
'autocomplete.stopped': 'ऑटोकम्पलीट रुक गया।',
'autocomplete.advancedSettings': 'एडवांस्ड सेटिंग्स',
'autocomplete.debugTitle': 'ऑटोकम्पलीट डिबग',
'chat.agentChat': 'एजेंट चैट',
'chat.overrides': 'ओवरराइड्स',
'chat.model': 'मॉडल',
@@ -4498,9 +4494,6 @@ const messages: TranslationMap = {
'pages.settings.aiSection.title': 'AI',
'settings.developerMenu.composio.title': 'Composio',
'settings.developerMenu.composio.desc': 'रूटिंग मोड, एकीकरण ट्रिगर, और ट्रिगर इतिहास संग्रह।',
'pages.settings.features.desktopCompanion': 'डेस्कटॉप कंपैनियन',
'pages.settings.features.desktopCompanionDesc':
'स्क्रीन जागरूकता के साथ वॉयस सहायक: सुनता है, देखता है, बोलता है, इशारा करता है',
'pages.settings.features.messagingChannels': 'मैसेजिंग चैनल',
'pages.settings.features.messagingChannelsDesc': 'मैसेजिंग चैनल विवरण',
'pages.settings.features.notifications': 'नोटिफिकेशन',
@@ -4917,61 +4910,6 @@ const messages: TranslationMap = {
'settings.ai.workload.changeModel': 'मॉडल बदलें',
'settings.ai.workload.chooseModel': 'मॉडल चुनें',
'settings.ai.provider.ollama': 'Ollama',
'settings.autocomplete.appFilter.acceptSuggestion': 'सुझाव एक्सेप्ट करें',
'settings.autocomplete.appFilter.app': 'ऐप',
'settings.autocomplete.appFilter.currentSuggestion': 'वर्तमान सुझाव',
'settings.autocomplete.appFilter.contextOverride': 'कॉन्टेक्स्ट ओवरराइड (वैकल्पिक)',
'settings.autocomplete.appFilter.debounce': 'बहस',
'settings.autocomplete.appFilter.debugFocus': 'डिबग फोकस',
'settings.autocomplete.appFilter.enabled': 'सक्षम',
'settings.autocomplete.appFilter.getSuggestion': 'सुझाव पाएं',
'settings.autocomplete.appFilter.lastError': 'अंतिम त्रुटि',
'settings.autocomplete.appFilter.liveLogs': 'लाइव लॉग्स',
'settings.autocomplete.appFilter.model': 'मॉडल',
'settings.autocomplete.appFilter.noLogs': ') :',
'settings.autocomplete.appFilter.phase': 'चरण',
'settings.autocomplete.appFilter.platformSupported': 'मंच समर्थित',
'settings.autocomplete.appFilter.refreshStatus': 'रिफ्रेश हो रहा है…',
'settings.autocomplete.appFilter.refreshing': 'रिफ्रेश हो रहा है…',
'settings.autocomplete.appFilter.running': 'चल रहा है',
'settings.autocomplete.appFilter.runtime': 'रनटाइम',
'settings.autocomplete.appFilter.test': 'टेस्ट',
'settings.autocomplete.completionStyle.acceptedCompletion':
'{count} स्वीकृत कम्प्लीशन संग्रहीत: भविष्य के सुझावों को निजीकृत करने के लिए उपयोग किया गया।',
'settings.autocomplete.completionStyle.acceptedCompletions':
'{count} स्वीकृत कम्प्लीशन संग्रहीत: भविष्य के सुझावों को निजीकृत करने के लिए उपयोग किया गया।',
'settings.autocomplete.completionStyle.clearHistory': 'क्लियर हो रहा है…',
'settings.autocomplete.completionStyle.clearing': 'क्लियर हो रहा है…',
'settings.autocomplete.completionStyle.debounce': 'डिबाउंस (ms)',
'settings.autocomplete.completionStyle.enabled': 'चालू है',
'settings.autocomplete.completionStyle.maxChars': 'मैक्स कैरेक्टर',
'settings.autocomplete.completionStyle.noHistory':
'अभी कोई एक्सेप्टेड कम्पलीशन नहीं। पर्सनलाइज़ेशन शुरू करने के लिए Tab से सुझाव एक्सेप्ट करें।',
'settings.autocomplete.completionStyle.overlayTtl': 'ओवरले TTL (ms)',
'settings.autocomplete.completionStyle.personalizationHistory': 'पर्सनलाइज़ेशन हिस्ट्री',
'settings.autocomplete.completionStyle.styleExamples': 'स्टाइल उदाहरण (एक प्रति लाइन)',
'settings.autocomplete.completionStyle.styleInstructions': 'स्टाइल इंस्ट्रक्शन',
'settings.autocomplete.debug.acceptedPrefix': 'स्वीकृत: {value}',
'settings.autocomplete.debug.acceptFailed': 'सुझाव स्वीकार करने में विफल',
'settings.autocomplete.debug.alreadyRunning': 'स्वत: पूर्ण पहले से ही चल रहा है.',
'settings.autocomplete.debug.clearHistoryFailed': 'इतिहास साफ़ करने में विफल',
'settings.autocomplete.debug.didNotStart': 'स्वत: पूर्ण प्रारंभ नहीं हुआ.',
'settings.autocomplete.debug.disabledInSettings':
'सेटिंग्स में स्वत: पूर्ण अक्षम है. इसे इनेबल करें और पहले सेव करें।',
'settings.autocomplete.debug.fetchSuggestionFailed': 'वर्तमान सुझाव लाने में विफल',
'settings.autocomplete.debug.inspectFocusedElementFailed':
'केंद्रित तत्व का निरीक्षण करने में विफल',
'settings.autocomplete.debug.loadSettingsFailed': 'स्वतः पूर्ण सेटिंग लोड करने में विफल',
'settings.autocomplete.debug.noSuggestionApplied': 'कोई सुझाव लागू नहीं किया गया.',
'settings.autocomplete.debug.noSuggestionReturned': 'कोई सुझाव वापस नहीं आया.',
'settings.autocomplete.debug.refreshStatusFailed': 'स्वतः पूर्ण स्थिति ताज़ा करने में विफल',
'settings.autocomplete.debug.saveAdvancedSettingsFailed': 'उन्नत सेटिंग्स सहेजने में विफल',
'settings.autocomplete.debug.startFailed': 'स्वतः पूर्ण प्रारंभ करने में विफल',
'settings.autocomplete.debug.stopFailed': 'स्वतः पूर्णता रोकने में विफल',
'settings.autocomplete.debug.suggestionPrefix': 'सुझाव: {value}',
'settings.autocomplete.shared.none': 'कोई नहीं',
'settings.autocomplete.shared.notApplicable': 'एन/ए',
'settings.autocomplete.shared.unknown': 'अज्ञात',
'settings.billing.autoRecharge.addAmount': 'यह राशि जोड़ें',
'settings.billing.autoRecharge.addCard': 'कार्ड जोड़ें',
'settings.billing.autoRecharge.amountHint': 'राशि संकेत',
@@ -5088,23 +5026,6 @@ const messages: TranslationMap = {
'सहेजने में विफल। डायरेक्ट मोड के लिए गैर-रिक्त API कुंजी आवश्यक है।',
'settings.composio.saving': 'सेव हो रहा है…',
'settings.composio.switching': 'स्विच हो रहा है…',
'settings.companion.title': 'डेस्कटॉप साथी',
'settings.companion.session': 'सत्र',
'settings.companion.activeLabel': 'सक्रिय',
'settings.companion.inactiveStatus': 'निष्क्रिय',
'settings.companion.stopping': 'रुक रहा हूँ...',
'settings.companion.stopSession': 'सत्र रोकें',
'settings.companion.starting': 'शुरू हो रहा है...',
'settings.companion.startSession': 'सत्र प्रारंभ करें',
'settings.companion.sessionId': 'सत्र आईडी',
'settings.companion.turns': 'बदल जाता है',
'settings.companion.remaining': 'शेष',
'settings.companion.configuration': 'विन्यास',
'settings.companion.hotkey': 'हॉटकी',
'settings.companion.activationMode': 'सक्रियण मोड',
'settings.companion.sessionTtl': 'सत्र टीटीएल',
'settings.companion.screenCapture': 'स्क्रीन कैप्चर',
'settings.companion.appContext': 'ऐप संदर्भ',
'settings.cron.jobs.commandRequired': 'शेल जॉब के लिए कमांड आवश्यक है',
'settings.cron.jobs.createJob': 'नई शेड्यूल्ड जॉब',
'settings.cron.jobs.created': 'जॉब बनाई गई',
@@ -5527,8 +5448,6 @@ const messages: TranslationMap = {
'OpenHuman से कनेक्ट करने के लिए बाहरी MCP क्लाइंट को कॉन्फ़िगर करें',
'settings.developerMenu.autonomy.title': 'एजेंट स्वायत्तता',
'settings.developerMenu.autonomy.desc': 'टूल क्रिया दर सीमाएँ और सुरक्षा सीमाएँ',
'settings.developerMenu.autocomplete.title': 'स्वतः पूर्ण',
'settings.developerMenu.autocomplete.desc': 'AI इनलाइन स्वतः-पूर्ण सेटिंग्स और डिबग पैनल',
'settings.developerMenu.voiceDebug.title': 'वॉइस (डिबग)',
'settings.developerMenu.voiceDebug.desc': 'वॉइस डिक्टेशन रनटाइम स्थिति और डिबग सेटिंग्स',
'settings.mcpServer.title': 'MCP सर्वर',
@@ -5985,24 +5904,6 @@ const messages: TranslationMap = {
'settings.modelHealth.modal.apply': 'प्रतिस्थापन लागू करें',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
'settings.desktopAgent.title': 'डेस्कटॉप एजेंट',
'settings.desktopAgent.description':
'डेस्कटॉप एजेंट को जिन अनुमतियों की आवश्यकता है उन्हें जाँचें और प्रदान करें।',
'settings.desktopAgent.microphone': 'माइक्रोफ़ोन',
'settings.desktopAgent.grant': 'प्रदान करें',
'settings.desktopAgent.notRequiredOnOs': 'इस ऑपरेटिंग सिस्टम पर आवश्यक नहीं',
'settings.desktopAgent.allGranted': 'सब तैयार: डेस्कटॉप एजेंट के पास सभी आवश्यक अनुमतियाँ हैं।',
'settings.desktopAgent.recheck': 'पुनः जाँचें',
'settings.desktopAgent.restartAndRecheck': 'कोर पुनः आरंभ करें और पुनः जाँचें',
'settings.desktopAgent.seamless.title': 'एजेंट की कार्रवाइयाँ',
'settings.desktopAgent.seamless.label': 'एजेंट को बिना पूछे कार्य करने दें',
'settings.desktopAgent.seamless.description':
'पूर्ण पहुँच दें और डेस्कटॉप की कार्रवाइयों (ऐप खोलना, ऐप के इंटरफ़ेस को नियंत्रित करना) को स्वतः स्वीकृत करें, ताकि एजेंट उन्हें अनुमोदन के अनुरोध के बिना चलाए।',
'settings.desktopAgent.seamless.note':
'केवल डेस्कटॉप की कार्रवाइयाँ ही अनुरोध को छोड़ती हैं: शेल कमांड चलाना, फ़ाइलें संपादित करना, नेटवर्क अनुरोध और सॉफ़्टवेयर इंस्टॉल करना अब भी पूछते हैं। इसे बंद करने पर डेस्कटॉप कार्रवाइयों की स्वतः स्वीकृति रुक जाती है, पर पहुँच का स्तर अपरिवर्तित रहता है।',
'settings.desktopAgent.beta':
'बीटा: डेस्कटॉप एजेंट प्रायोगिक है और हमेशा अपेक्षित रूप से काम नहीं कर सकता।',
'settings.desktopAgent.wakeWordHint': 'एजेंट को जगाने के लिए अपने आदेश से पहले ”Hey Tiny” कहें।',
'settings.screenIntel.permissions.accessibility': 'एक्सेसिबिलिटी',
'settings.screenIntel.permissions.grantHint': 'स्वीकृति संकेत',
'settings.screenIntel.permissions.inputMonitoring': 'इनपुट मॉनिटरिंग',
@@ -6278,21 +6179,6 @@ const messages: TranslationMap = {
'skills.resource.tree.templates': 'टेम्पलेट',
'skills.resource.tree.empty': 'कोई बंडल्ड रिसोर्स नहीं।',
'skills.search.placeholder': 'प्लेसहोल्डर',
'skills.setup.autocomplete.acceptKey': 'स्वीकार कुंजी',
'skills.setup.autocomplete.activeDesc': 'सक्रिय विवरण',
'skills.setup.autocomplete.activeTitle': 'Auto-Complete एक्टिव है',
'skills.setup.autocomplete.customizeSettings': 'सेटिंग्स कस्टमाइज़ करें',
'skills.setup.autocomplete.debounce': 'डिबाउंस',
'skills.setup.autocomplete.description': 'विवरण',
'skills.setup.autocomplete.enableBtn': 'चालू हो रहा है...',
'skills.setup.autocomplete.enableError': 'ऑटोकम्पलीट चालू नहीं हो पाया',
'skills.setup.autocomplete.enabling': 'चालू हो रहा है...',
'skills.setup.autocomplete.notSupported': 'सपोर्टेड नहीं',
'skills.setup.autocomplete.stepEnable': 'इनलाइन कम्पलीशन चालू करें',
'skills.setup.autocomplete.stepSuccess': 'तैयार है',
'skills.setup.autocomplete.stylePreset': 'स्टाइल प्रीसेट',
'skills.setup.autocomplete.stylePresetValue': 'Balanced (बाद में बदल सकते हैं)',
'skills.setup.autocomplete.title': 'टेक्स्ट Auto-Complete',
'skills.setup.screenIntel.activeDesc': 'सक्रिय विवरण',
'skills.setup.screenIntel.activeTitle': 'Screen Intelligence चालू है',
'skills.setup.screenIntel.advancedSettings': 'एडवांस्ड सेटिंग्स',
@@ -6773,9 +6659,6 @@ const messages: TranslationMap = {
'chat.files.error.resolve_failed': 'आर्टिफैक्ट को हल नहीं किया जा सका। पुनः प्रयास करें।',
'chat.files.error.download_failed': 'डाउनलोड विफल रहा। पुनः प्रयास करें।',
'chat.files.error.delete_failed': 'फ़ाइल हटाई नहीं जा सकी। पुनः प्रयास करें।',
'autocomplete.debounceMs': 'डिबाउंस (ms)',
'autocomplete.maxChars': 'अधिकतम संदर्भ वर्ण',
'autocomplete.overlayTtlMs': 'ओवरले समय-समाप्ति (ms)',
'memory.tab.council': 'Council',
'activity.tabs.automations': 'स्वचालन',
'activity.tabs.automationsDescription':
@@ -6810,133 +6693,6 @@ const messages: TranslationMap = {
'intelligence.agents.tier.chat': 'चैट',
'intelligence.agents.tier.reasoning': 'तर्क',
'intelligence.agents.tier.worker': 'वर्कर',
'modelCouncil.title': 'मॉडल परिषद',
'modelCouncil.intro':
'एक प्रश्न पूछें, कई मॉडलों से समानांतर में स्वतंत्र उत्तर प्राप्त करें, फिर अध्यक्ष मॉडल संश्लेषित करता है कि वे कहाँ सहमत हैं, कहाँ असहमत हैं, और प्रत्येक क्या अद्वितीय जोड़ता है।',
'modelCouncil.listTitle': 'काउंसिल',
'modelCouncil.listIntro':
'सहेजी गई परिषद चुनें, उसके एजेंट और न्यायाधीश संपादित करें, या किसी विशिष्ट निर्णय के लिए नई परिषद बनाएँ।',
'modelCouncil.addCouncil': 'नई परिषद',
'modelCouncil.openCouncil': 'परिषद खोलें',
'modelCouncil.editCouncil': 'परिषद संपादित करें',
'modelCouncil.editCurrentCouncil': 'वर्तमान परिषद संपादित करें',
'modelCouncil.editCouncilAria': '{name} संपादित करें',
'modelCouncil.deleteCouncilAria': '{name} हटाएँ',
'modelCouncil.backToCouncils': 'परिषदों पर वापस जाएँ',
'modelCouncil.saveCouncil': 'परिषद सहेजें',
'modelCouncil.savingCouncil': 'Saving…',
'modelCouncil.cancelEdit': 'Cancel',
'modelCouncil.loadingCouncils': 'परिषदें लोड हो रही हैं…',
'modelCouncil.noCouncils': 'अभी तक कोई परिषद नहीं। शुरू करने के लिए एक जोड़ें।',
'modelCouncil.noCouncilDescription': 'कोई विवरण नहीं',
'modelCouncil.registryErrorPrefix': 'परिषद रजिस्ट्री विफल:',
'modelCouncil.councilNameLabel': 'परिषद का नाम',
'modelCouncil.councilDescriptionLabel': 'Description',
'modelCouncil.councilDescriptionPlaceholder': 'इस परिषद का उपयोग कब करें',
'modelCouncil.selectModel': 'Select',
'modelCouncil.modelPickerHelp': 'रूटिंग संकेत चुनें या सटीक प्रदाता मॉडल पिन करें।',
'modelCouncil.closeModelPicker': 'Close',
'modelCouncil.hint.chat': 'चैट',
'modelCouncil.hint.code': 'कोड',
'modelCouncil.hint.default': 'डिफ़ॉल्ट',
'modelCouncil.hint.reasoning': 'तर्क',
'modelCouncil.hint.summarize': 'सारांश',
'modelCouncil.modelPickerHints': 'Hints',
'modelCouncil.modelPickerProviderModel': 'प्रदाता + मॉडल',
'modelCouncil.modelProviderLabel': 'मॉडल प्रदाता',
'modelCouncil.modelIdLabel': 'मॉडल आईडी',
'modelCouncil.useProviderModel': 'प्रदाता मॉडल का उपयोग करें',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'परिषद से प्रश्न पूछें…',
'modelCouncil.membersLabel': 'सदस्य मॉडल',
'modelCouncil.maxMembersNote': 'अधिकतम {max}',
'modelCouncil.memberPlaceholder': 'उदा. gpt-5.2',
'modelCouncil.memberAria': 'सदस्य मॉडल {n}',
'modelCouncil.removeMemberAria': 'सदस्य मॉडल {n} हटाएँ',
'modelCouncil.addMember': '+ मॉडल जोड़ें',
'modelCouncil.settingsTitle': 'काउंसिल सेटिंग्स',
'modelCouncil.settingsSummary': '{count} जूरी · जज: {judge}',
'modelCouncil.juryCountLabel': 'जूरी संख्या',
'modelCouncil.debateRoundsLabel': 'बहस के दौर',
'modelCouncil.debateRoundsHelp':
'न्यायाधीश द्वारा अंतिम संश्लेषण लिखने से पहले प्रत्येक जूरी कई दौरों में बहस करता है।',
'modelCouncil.judgeAgentLabel': 'जज एजेंट',
'modelCouncil.defaultJudge': 'डिफ़ॉल्ट जज',
'modelCouncil.savedProfile': 'सहेजी गई प्रोफ़ाइल',
'modelCouncil.customAgent': 'कस्टम एजेंट',
'modelCouncil.judgeProfileLabel': 'जज प्रोफ़ाइल',
'modelCouncil.chooseProfile': 'प्रोफ़ाइल चुनें',
'modelCouncil.judgeNameLabel': 'जज का नाम',
'modelCouncil.judgeNamePlaceholder': 'मुख्य जज',
'modelCouncil.rosterHeading': 'काउंसिल रोस्टर',
'modelCouncil.rosterHelp':
'हर जूरर डिफ़ॉल्ट एजेंट अपना सकता है, सहेजी प्रोफ़ाइल इस्तेमाल कर सकता है, या इस रन के लिए कस्टम प्रोफ़ाइल बन सकता है।',
'modelCouncil.loadingProfiles': 'प्रोफ़ाइल लोड हो रही हैं…',
'modelCouncil.jurorLabel': 'जूरर {n}',
'modelCouncil.profileModeLabel': 'प्रोफ़ाइल स्रोत',
'modelCouncil.mode.default': 'डिफ़ॉल्ट',
'modelCouncil.mode.profile': 'प्रोफ़ाइल',
'modelCouncil.mode.custom': 'कस्टम',
'modelCouncil.memberProfileAria': 'जूरर {n} प्रोफ़ाइल',
'modelCouncil.memberNameAria': 'जूरर {n} नाम',
'modelCouncil.memberNamePlaceholder': 'एजेंट नाम',
'modelCouncil.profileModelPlaceholder': 'प्रोफ़ाइल मॉडल ओवरराइड करें',
'modelCouncil.memberBriefAria': 'जूरर {n} ब्रीफ',
'modelCouncil.memberBriefPlaceholder': 'इस जूरर का दृष्टिकोण',
'modelCouncil.jurorFallback': 'जूरर',
'modelCouncil.sharedReasoningLabel': 'साझा रीजनिंग फ़ाइल',
'modelCouncil.sharedReasoningHelp':
'जज के संश्लेषण से पहले काउंसिल इस साझा ड्राफ़्ट पर काम करती है।',
'modelCouncil.liveScratchpadHelp':
'प्रत्येक दौर के बाद जूरी अपडेट यहाँ जोड़े जाते हैं, फिर अगले दौर में प्रयुक्त होते हैं।',
'modelCouncil.liveScratchpadBadge': 'Live',
'modelCouncil.scratchpadRoundHeading': 'दौर {round} अपडेट',
'modelCouncil.scratchpadNoResponse': 'कोई प्रतिक्रिया नहीं',
'modelCouncil.chairLabel': 'अध्यक्ष मॉडल',
'modelCouncil.chairPlaceholder': 'उदा. claude-opus-4-8',
'modelCouncil.chairHelp': 'अध्यक्ष हर सदस्य का उत्तर पढ़ता है और संश्लेषण लिखता है।',
'modelCouncil.run': 'परिषद बुलाएँ',
'modelCouncil.running': 'Convening…',
'modelCouncil.runningHint': 'सदस्यों से समानांतर में पूछताछ कर रहे हैं, फिर संश्लेषण…',
'modelCouncil.deliberationHeading': 'परिषद विचार-विमर्श',
'modelCouncil.deliberationHelp':
'मैस्कॉट दिखाते हैं कि साझा तर्क फ़ाइल उपयोग में होने पर प्रत्येक एजेंट सोच रहा है।',
'modelCouncil.debateRoundInstruction':
'अन्य जूरी सदस्यों को जवाब दें। असहमतियाँ बनाए रखें, जब उचित हो तो अपना दृष्टिकोण संशोधित करें, और अपना वर्तमान निष्कर्ष स्पष्ट रखें।',
'modelCouncil.debateFinalInstruction':
'यह आपका अंतिम जूरी दौर है। जहाँ साक्ष्य समर्थन करता है वहाँ सहमत हों, शेष असहमति बताएँ, और न्यायाधीश को स्पष्ट निष्कर्ष दें।',
'modelCouncil.roundLabel': 'दौर {round}',
'modelCouncil.currentRoundThinking': 'अगला दौर जारी रख रहे हैं…',
'modelCouncil.thinkingBadge': 'Thinking',
'modelCouncil.thinkingWithBrief': 'इस दृष्टिकोण से काम कर रहे हैं: {brief}',
'modelCouncil.thought.evidence':
'साक्ष्य, धारणाओं और साझा तर्क फ़ाइल में पहले से मौजूद सामग्री की जाँच कर रहे हैं।',
'modelCouncil.thought.plan': 'व्यावहारिक मार्ग और संभावित बाधाओं की रूपरेखा बना रहे हैं।',
'modelCouncil.thought.risk':
'विफलता पैटर्न, लापता संदर्भ और कमज़ोर निष्कर्षों की तलाश कर रहे हैं।',
'modelCouncil.thought.tradeoffs':
'न्यायाधीश को संक्षिप्त स्थिति देने से पहले समझौतों की तुलना कर रहे हैं।',
'modelCouncil.thought.synthesis':
'एक विशिष्ट दृष्टिकोण तैयार कर रहे हैं ताकि न्यायाधीश वास्तविक असहमति की तुलना कर सके।',
'modelCouncil.judgeWaitingBadge': 'Judge',
'modelCouncil.judgeWaitingThought':
'जूरी के उत्तरों की प्रतीक्षा कर रहे हैं, फिर संश्लेषण के लिए साझा तर्क फ़ाइल पढ़ेंगे।',
'modelCouncil.judgeSynthesizingBadge': 'Synthesizing',
'modelCouncil.judgeSynthesizingThought':
'जूरी की लाइव विचारधारा पढ़ रहे हैं और अंतिम परिषद संश्लेषण लिख रहे हैं।',
'modelCouncil.errorPrefix': 'परिषद विफल:',
'modelCouncil.resultsHeading': 'परिषद परिणाम',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': '{model} द्वारा',
'modelCouncil.usageHeading': 'बहस उपयोग',
'modelCouncil.usageEstimated':
'बहस प्रॉम्प्ट और प्रतिक्रियाओं से अनुमानित टोकन गणना। वास्तविक मूल्य निर्धारण के लिए परिषद RPC से प्रदाता उपयोग आवश्यक है।',
'modelCouncil.usageEstimatedBadge': 'अनुमानित टोकन',
'modelCouncil.usageInputTokens': 'Input',
'modelCouncil.usageOutputTokens': 'Output',
'modelCouncil.usageTotalTokens': 'Total',
'graphCohesion.brokerBadge': 'ब्रोकर',
'graphCohesion.brokerTitle':
'संरचनात्मक छेद: इस इकाई के पड़ोसी आपस में नहीं जुड़े: यह उनके बीच एकमात्र कड़ी है।',
+25 -271
View File
@@ -3,6 +3,31 @@ import type { TranslationMap } from './types';
// Indonesian (Bahasa Indonesia) translations. Keys mirror en.ts; missing/
// English-identical values fall back to English via I18nContext.resolveEn().
const messages: TranslationMap = {
'connections.header.companion':
'Pendamping desktop mengambang untuk bantuan cepat yang selalu tersedia.',
'settings.assistant.desktopCompanion': 'Pendamping desktop',
'settings.assistant.desktopCompanionDesc':
'Mode pendamping selalu aktif dengan pintasan baki sistem',
'pages.settings.features.desktopCompanion': 'Pendamping Desktop',
'pages.settings.features.desktopCompanionDesc':
'Asisten suara dengan kesadaran layar: mendengar, melihat, berbicara, menunjuk',
'settings.companion.title': 'Pendamping Desktop',
'settings.companion.session': 'Sesi',
'settings.companion.activeLabel': 'Aktif',
'settings.companion.inactiveStatus': 'Tidak aktif',
'settings.companion.stopping': 'Berhenti…',
'settings.companion.stopSession': 'Hentikan Sesi',
'settings.companion.starting': 'Mulai…',
'settings.companion.startSession': 'Mulai Sesi',
'settings.companion.sessionId': 'ID Sesi',
'settings.companion.turns': 'Giliran',
'settings.companion.remaining': 'Tersisa',
'settings.companion.configuration': 'Konfigurasi',
'settings.companion.hotkey': 'Tombol Pintas',
'settings.companion.activationMode': 'Mode Aktivasi',
'settings.companion.sessionTtl': 'TTL Sesi',
'settings.companion.screenCapture': 'Tangkapan Layar',
'settings.companion.appContext': 'Konteks Aplikasi',
// Share cards (#5006).
'share.button': 'Bagikan',
'share.modalTitle': 'Bagikan momen ini',
@@ -93,10 +118,6 @@ const messages: TranslationMap = {
'connections.header.wallet': 'Saldo dompet lokal Anda dan frasa pemulihan Anda.',
'connections.header.screen':
'Biarkan agen Anda melihat layar Anda untuk memberikan bantuan tepat waktu dan sesuai konteks.',
'connections.header.desktopAgent':
'Biarkan agen Anda membuka aplikasi dan mengendalikan desktop Anda atas nama Anda.',
'connections.header.companion':
'Pendamping desktop mengambang untuk bantuan cepat yang selalu tersedia.',
'connections.header.composioKey':
'Tambahkan kunci API Composio untuk membuka ratusan integrasi aplikasi.',
'connections.llm.apiKeys': 'Kunci API & perutean',
@@ -748,9 +769,6 @@ const messages: TranslationMap = {
'Kontrol seberapa aktif asisten Anda bekerja di latar belakang',
'settings.assistant.screenAwareness': 'Kesadaran layar',
'settings.assistant.screenAwarenessDesc': 'Biarkan asisten melihat jendela aktif Anda',
'settings.assistant.desktopCompanion': 'Pendamping desktop',
'settings.assistant.desktopCompanionDesc':
'Mode pendamping selalu aktif dengan pintasan baki sistem',
'settings.assistant.permissions': 'Izin',
'settings.assistant.permissionsDesc':
'Pilih apa yang dapat dilakukan asisten dan di mana ia dapat bekerja',
@@ -2539,28 +2557,6 @@ const messages: TranslationMap = {
'Pada game layar penuh eksklusif, lapisan tidak akan tampil: kamu hanya akan mendengar nada. Beralihlah ke layar penuh tanpa bingkai untuk melihat lapisan.',
'pttOverlay.listening': 'Mendengarkan…',
'pttOverlay.idle': 'Siaga',
'autocomplete.title': 'Pelengkap Otomatis',
'autocomplete.settings': 'Pengaturan',
'autocomplete.acceptWithTab': 'Terima dengan Tab',
'autocomplete.stylePreset': 'Preset Gaya',
'autocomplete.style.balanced': 'Seimbang',
'autocomplete.style.concise': 'Ringkas',
'autocomplete.style.formal': 'Resmi',
'autocomplete.style.casual': 'Santai',
'autocomplete.style.custom': 'Kustom',
'autocomplete.disabledApps': 'Aplikasi yang Dinonaktifkan (satu bundle/token aplikasi per baris)',
'autocomplete.saveSettings': 'Simpan Pengaturan',
'autocomplete.saving': 'Menyimpan...',
'autocomplete.runtime': 'Waktu Proses',
'autocomplete.running': 'Berjalan',
'autocomplete.start': 'Mulai',
'autocomplete.stop': 'Hentikan',
'autocomplete.settingsSaved': 'Pengaturan pelengkap otomatis tersimpan.',
'autocomplete.started': 'Pelengkap otomatis dimulai.',
'autocomplete.didNotStart': 'Pelengkap otomatis tidak dimulai. Periksa apakah sudah diaktifkan.',
'autocomplete.stopped': 'Pelengkap otomatis dihentikan.',
'autocomplete.advancedSettings': 'Pengaturan lanjutan',
'autocomplete.debugTitle': 'Debug Pelengkap Otomatis',
'chat.agentChat': 'Chat Agen',
'chat.overrides': 'Penggantian',
'chat.model': 'Model',
@@ -4517,9 +4513,6 @@ const messages: TranslationMap = {
'settings.developerMenu.composio.title': 'Composio',
'settings.developerMenu.composio.desc':
'Mode perutean, pemicu integrasi, dan arsip riwayat pemicu.',
'pages.settings.features.desktopCompanion': 'Pendamping Desktop',
'pages.settings.features.desktopCompanionDesc':
'Asisten suara dengan kesadaran layar: mendengar, melihat, berbicara, menunjuk',
'pages.settings.features.messagingChannels': 'Kanal pesan',
'pages.settings.features.messagingChannelsDesc': 'Deskripsi kanal pesan',
'pages.settings.features.notifications': 'Notifikasi',
@@ -4940,61 +4933,6 @@ const messages: TranslationMap = {
'settings.ai.workload.changeModel': 'Ubah Model',
'settings.ai.workload.chooseModel': 'Pilih Model',
'settings.ai.provider.ollama': 'Ollama',
'settings.autocomplete.appFilter.acceptSuggestion': 'Terima saran',
'settings.autocomplete.appFilter.app': 'Aplikasi',
'settings.autocomplete.appFilter.currentSuggestion': 'Saran saat ini',
'settings.autocomplete.appFilter.contextOverride': 'Penggantian Konteks (opsional)',
'settings.autocomplete.appFilter.debounce': 'Debounce',
'settings.autocomplete.appFilter.debugFocus': 'Debug fokus',
'settings.autocomplete.appFilter.enabled': 'Diaktifkan',
'settings.autocomplete.appFilter.getSuggestion': 'Dapatkan saran',
'settings.autocomplete.appFilter.lastError': 'Kesalahan terakhir',
'settings.autocomplete.appFilter.liveLogs': 'Log Langsung',
'settings.autocomplete.appFilter.model': 'Model AI',
'settings.autocomplete.appFilter.noLogs': ') :',
'settings.autocomplete.appFilter.phase': 'Fase',
'settings.autocomplete.appFilter.platformSupported': 'Platform didukung',
'settings.autocomplete.appFilter.refreshStatus': 'Menyegarkan...',
'settings.autocomplete.appFilter.refreshing': 'Menyegarkan...',
'settings.autocomplete.appFilter.running': 'Berjalan',
'settings.autocomplete.appFilter.runtime': 'Waktu proses',
'settings.autocomplete.appFilter.test': 'Tes',
'settings.autocomplete.completionStyle.acceptedCompletion':
'{count} pelengkapan diterima tersimpan: digunakan untuk mempersonalisasi saran berikutnya.',
'settings.autocomplete.completionStyle.acceptedCompletions':
'{count} pelengkapan diterima tersimpan: digunakan untuk mempersonalisasi saran berikutnya.',
'settings.autocomplete.completionStyle.clearHistory': 'Membersihkan...',
'settings.autocomplete.completionStyle.clearing': 'Membersihkan...',
'settings.autocomplete.completionStyle.debounce': 'Tunda input (ms)',
'settings.autocomplete.completionStyle.enabled': 'Diaktifkan',
'settings.autocomplete.completionStyle.maxChars': 'Maks Karakter',
'settings.autocomplete.completionStyle.noHistory':
'Belum ada pelengkapan yang diterima. Terima saran dengan Tab untuk mulai mempersonalisasi.',
'settings.autocomplete.completionStyle.overlayTtl': 'TTL Overlay (ms)',
'settings.autocomplete.completionStyle.personalizationHistory': 'Riwayat Personalisasi',
'settings.autocomplete.completionStyle.styleExamples': 'Contoh Gaya (satu per baris)',
'settings.autocomplete.completionStyle.styleInstructions': 'Instruksi Gaya',
'settings.autocomplete.debug.acceptedPrefix': 'Diterima: {value}',
'settings.autocomplete.debug.acceptFailed': 'Gagal menerima saran',
'settings.autocomplete.debug.alreadyRunning': 'Pelengkapan otomatis sudah berjalan.',
'settings.autocomplete.debug.clearHistoryFailed': 'Gagal menghapus riwayat',
'settings.autocomplete.debug.didNotStart': 'Pelengkapan otomatis tidak dimulai.',
'settings.autocomplete.debug.disabledInSettings':
'Pelengkapan otomatis dinonaktifkan di pengaturan. Aktifkan dan simpan terlebih dahulu.',
'settings.autocomplete.debug.fetchSuggestionFailed': 'Gagal mengambil saran saat ini',
'settings.autocomplete.debug.inspectFocusedElementFailed': 'Gagal memeriksa elemen fokus',
'settings.autocomplete.debug.loadSettingsFailed': 'Gagal memuat pengaturan pelengkapan otomatis',
'settings.autocomplete.debug.noSuggestionApplied': 'Tidak ada saran yang diterapkan.',
'settings.autocomplete.debug.noSuggestionReturned': 'Tidak ada saran yang dikembalikan.',
'settings.autocomplete.debug.refreshStatusFailed':
'Gagal menyegarkan status pelengkapan otomatis',
'settings.autocomplete.debug.saveAdvancedSettingsFailed': 'Gagal menyimpan pengaturan lanjutan',
'settings.autocomplete.debug.startFailed': 'Gagal memulai pelengkapan otomatis',
'settings.autocomplete.debug.stopFailed': 'Gagal menghentikan pelengkapan otomatis',
'settings.autocomplete.debug.suggestionPrefix': 'Saran: {value}',
'settings.autocomplete.shared.none': 'Tidak ada',
'settings.autocomplete.shared.notApplicable': 'n/a',
'settings.autocomplete.shared.unknown': 'Tidak diketahui',
'settings.billing.autoRecharge.addAmount': 'Tambahkan jumlah ini',
'settings.billing.autoRecharge.addCard': 'Tambah kartu',
'settings.billing.autoRecharge.amountHint': 'Petunjuk jumlah',
@@ -5111,23 +5049,6 @@ const messages: TranslationMap = {
'Gagal menyimpan. Mode Direct memerlukan API key yang tidak kosong.',
'settings.composio.saving': 'Menyimpan...',
'settings.composio.switching': 'Mengalihkan…',
'settings.companion.title': 'Perusahaan Desktop',
'settings.companion.session': 'Sesi',
'settings.companion.activeLabel': 'Aktif',
'settings.companion.inactiveStatus': 'Tidak aktif',
'settings.companion.stopping': 'Berhenti…',
'settings.companion.stopSession': 'Hentikan Sesi',
'settings.companion.starting': 'Mulai…',
'settings.companion.startSession': 'Mulai Sesi',
'settings.companion.sessionId': 'ID Sesi',
'settings.companion.turns': 'Putaran',
'settings.companion.remaining': 'Tersisa',
'settings.companion.configuration': 'Konfigurasi',
'settings.companion.hotkey': 'Tombol Pintas',
'settings.companion.activationMode': 'Mode Aktivasi',
'settings.companion.sessionTtl': 'Sesi TTL',
'settings.companion.screenCapture': 'Tangkapan Layar',
'settings.companion.appContext': 'Konteks Aplikasi',
'settings.cron.jobs.commandRequired': 'Perintah wajib diisi untuk tugas shell',
'settings.cron.jobs.createJob': 'Tugas Terjadwal Baru',
'settings.cron.jobs.created': 'Tugas dibuat',
@@ -5553,9 +5474,6 @@ const messages: TranslationMap = {
'Konfigurasikan klien MCP eksternal untuk terhubung ke OpenHuman',
'settings.developerMenu.autonomy.title': 'Otonomi agen',
'settings.developerMenu.autonomy.desc': 'Batas laju aksi alat dan ambang keamanan',
'settings.developerMenu.autocomplete.title': 'Pelengkap otomatis',
'settings.developerMenu.autocomplete.desc':
'Pengaturan pelengkap otomatis AI inline dan panel debug',
'settings.developerMenu.voiceDebug.title': 'Suara (debug)',
'settings.developerMenu.voiceDebug.desc': 'Status runtime dikte suara dan pengaturan debug',
'settings.mcpServer.title': 'Server MCP',
@@ -6015,25 +5933,6 @@ const messages: TranslationMap = {
'settings.modelHealth.modal.apply': 'Terapkan Penggantian',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
'settings.desktopAgent.title': 'Agen Desktop',
'settings.desktopAgent.description': 'Periksa dan berikan izin yang dibutuhkan agen desktop.',
'settings.desktopAgent.microphone': 'Mikrofon',
'settings.desktopAgent.grant': 'Berikan',
'settings.desktopAgent.notRequiredOnOs': 'Tidak diperlukan di sistem operasi ini',
'settings.desktopAgent.allGranted':
'Semua siap: agen desktop memiliki semua izin yang dibutuhkan.',
'settings.desktopAgent.recheck': 'Periksa ulang',
'settings.desktopAgent.restartAndRecheck': 'Mulai ulang inti dan periksa ulang',
'settings.desktopAgent.seamless.title': 'Tindakan agen',
'settings.desktopAgent.seamless.label': 'Biarkan agen bertindak tanpa bertanya',
'settings.desktopAgent.seamless.description':
'Berikan akses penuh dan setujui otomatis tindakan desktop (membuka aplikasi, mengendalikan antarmuka aplikasi) agar agen menjalankannya tanpa permintaan persetujuan.',
'settings.desktopAgent.seamless.note':
'Hanya tindakan desktop yang melewati permintaan: menjalankan perintah shell, menyunting berkas, permintaan jaringan, dan memasang perangkat lunak tetap meminta izin. Mematikan ini menghentikan persetujuan otomatis tindakan desktop, tetapi tingkat akses tidak berubah.',
'settings.desktopAgent.beta':
'Beta: agen desktop masih eksperimental dan mungkin tidak selalu bekerja seperti yang diharapkan.',
'settings.desktopAgent.wakeWordHint':
'Ucapkan “Hey Tiny” sebelum perintah Anda untuk membangunkan agen.',
'settings.screenIntel.permissions.accessibility': 'Aksesibilitas',
'settings.screenIntel.permissions.grantHint': 'Petunjuk izin',
'settings.screenIntel.permissions.inputMonitoring': 'Pemantauan Input',
@@ -6309,21 +6208,6 @@ const messages: TranslationMap = {
'skills.resource.tree.templates': 'Templat',
'skills.resource.tree.empty': 'Tidak ada sumber daya bundel.',
'skills.search.placeholder': 'Teks placeholder',
'skills.setup.autocomplete.acceptKey': 'Kunci terima',
'skills.setup.autocomplete.activeDesc': 'Deskripsi aktif',
'skills.setup.autocomplete.activeTitle': 'Auto-Complete Aktif',
'skills.setup.autocomplete.customizeSettings': 'Sesuaikan pengaturan',
'skills.setup.autocomplete.debounce': 'Tunda input',
'skills.setup.autocomplete.description': 'Deskripsi',
'skills.setup.autocomplete.enableBtn': 'Mengaktifkan...',
'skills.setup.autocomplete.enableError': 'Gagal mengaktifkan pelengkap otomatis',
'skills.setup.autocomplete.enabling': 'Mengaktifkan...',
'skills.setup.autocomplete.notSupported': 'Tidak didukung',
'skills.setup.autocomplete.stepEnable': 'Aktifkan pelengkap otomatis',
'skills.setup.autocomplete.stepSuccess': 'Siap digunakan',
'skills.setup.autocomplete.stylePreset': 'Preset gaya',
'skills.setup.autocomplete.stylePresetValue': 'Seimbang (dapat dikonfigurasi nanti)',
'skills.setup.autocomplete.title': 'Auto-Complete Teks',
'skills.setup.screenIntel.activeDesc': 'Deskripsi aktif',
'skills.setup.screenIntel.activeTitle': 'Kecerdasan Layar Diaktifkan',
'skills.setup.screenIntel.advancedSettings': 'Pengaturan lanjutan',
@@ -6805,9 +6689,6 @@ const messages: TranslationMap = {
'chat.files.error.resolve_failed': 'Tidak dapat memuat artefak. Coba lagi.',
'chat.files.error.download_failed': 'Unduhan gagal. Coba lagi.',
'chat.files.error.delete_failed': 'Tidak bisa menghapus file. Coba lagi.',
'autocomplete.debounceMs': 'Debounce (md)',
'autocomplete.maxChars': 'Karakter konteks maks',
'autocomplete.overlayTtlMs': 'Batas waktu overlay (md)',
'memory.tab.council': 'Council',
'activity.tabs.automations': 'Otomatisasi',
'activity.tabs.automationsDescription':
@@ -6842,133 +6723,6 @@ const messages: TranslationMap = {
'intelligence.agents.tier.chat': 'Chat',
'intelligence.agents.tier.reasoning': 'Penalaran',
'intelligence.agents.tier.worker': 'Pekerja',
'modelCouncil.title': 'Dewan Model',
'modelCouncil.intro':
'Ajukan satu pertanyaan, dapatkan jawaban independen dari beberapa model secara paralel, lalu model ketua mensintesis di mana mereka sepakat, tidak sepakat, dan apa yang ditambahkan masing-masing secara unik.',
'modelCouncil.listTitle': 'Councils',
'modelCouncil.listIntro':
'Pilih dewan tersimpan, edit agen dan hakimnya, atau buat dewan baru untuk jenis keputusan tertentu.',
'modelCouncil.addCouncil': 'Dewan baru',
'modelCouncil.openCouncil': 'Buka dewan',
'modelCouncil.editCouncil': 'Edit dewan',
'modelCouncil.editCurrentCouncil': 'Edit dewan saat ini',
'modelCouncil.editCouncilAria': 'Edit {name}',
'modelCouncil.deleteCouncilAria': 'Hapus {name}',
'modelCouncil.backToCouncils': 'Kembali ke daftar dewan',
'modelCouncil.saveCouncil': 'Simpan dewan',
'modelCouncil.savingCouncil': 'Saving…',
'modelCouncil.cancelEdit': 'Cancel',
'modelCouncil.loadingCouncils': 'Memuat dewan…',
'modelCouncil.noCouncils': 'Belum ada dewan. Tambahkan satu untuk memulai.',
'modelCouncil.noCouncilDescription': 'Tanpa deskripsi',
'modelCouncil.registryErrorPrefix': 'Registri dewan gagal:',
'modelCouncil.councilNameLabel': 'Nama dewan',
'modelCouncil.councilDescriptionLabel': 'Description',
'modelCouncil.councilDescriptionPlaceholder': 'Kapan menggunakan dewan ini',
'modelCouncil.selectModel': 'Select',
'modelCouncil.modelPickerHelp': 'Pilih petunjuk routing atau pin model penyedia yang tepat.',
'modelCouncil.closeModelPicker': 'Close',
'modelCouncil.hint.chat': 'Obrolan',
'modelCouncil.hint.code': 'Kode',
'modelCouncil.hint.default': 'Bawaan',
'modelCouncil.hint.reasoning': 'Penalaran',
'modelCouncil.hint.summarize': 'Ringkasan',
'modelCouncil.modelPickerHints': 'Hints',
'modelCouncil.modelPickerProviderModel': 'Penyedia + model',
'modelCouncil.modelProviderLabel': 'Penyedia model',
'modelCouncil.modelIdLabel': 'ID model',
'modelCouncil.useProviderModel': 'Gunakan model penyedia',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Ajukan pertanyaan ke dewan…',
'modelCouncil.membersLabel': 'Model anggota',
'modelCouncil.maxMembersNote': 'hingga {max}',
'modelCouncil.memberPlaceholder': 'mis. gpt-5.2',
'modelCouncil.memberAria': 'Model anggota {n}',
'modelCouncil.removeMemberAria': 'Hapus model anggota {n}',
'modelCouncil.addMember': '+ Tambah model',
'modelCouncil.settingsTitle': 'Pengaturan dewan',
'modelCouncil.settingsSummary': '{count} juri · hakim: {judge}',
'modelCouncil.juryCountLabel': 'Jumlah juri',
'modelCouncil.debateRoundsLabel': 'Putaran debat',
'modelCouncil.debateRoundsHelp':
'Setiap juri terus berdebat lintas putaran sebelum hakim menulis sintesis akhir.',
'modelCouncil.judgeAgentLabel': 'Agen hakim',
'modelCouncil.defaultJudge': 'Hakim default',
'modelCouncil.savedProfile': 'Profil tersimpan',
'modelCouncil.customAgent': 'Agen kustom',
'modelCouncil.judgeProfileLabel': 'Profil hakim',
'modelCouncil.chooseProfile': 'Pilih profil',
'modelCouncil.judgeNameLabel': 'Nama hakim',
'modelCouncil.judgeNamePlaceholder': 'Hakim utama',
'modelCouncil.rosterHeading': 'Daftar dewan',
'modelCouncil.rosterHelp':
'Setiap juri dapat mewarisi agen default, memakai profil tersimpan, atau menjadi profil kustom untuk run ini.',
'modelCouncil.loadingProfiles': 'Memuat profil…',
'modelCouncil.jurorLabel': 'Juri {n}',
'modelCouncil.profileModeLabel': 'Sumber profil',
'modelCouncil.mode.default': 'Default',
'modelCouncil.mode.profile': 'Profil',
'modelCouncil.mode.custom': 'Kustom',
'modelCouncil.memberProfileAria': 'Profil juri {n}',
'modelCouncil.memberNameAria': 'Nama juri {n}',
'modelCouncil.memberNamePlaceholder': 'Nama agen',
'modelCouncil.profileModelPlaceholder': 'Timpa model profil',
'modelCouncil.memberBriefAria': 'Brief juri {n}',
'modelCouncil.memberBriefPlaceholder': 'Perspektif juri ini',
'modelCouncil.jurorFallback': 'Juri',
'modelCouncil.sharedReasoningLabel': 'File penalaran bersama',
'modelCouncil.sharedReasoningHelp':
'Dewan bekerja pada draf bersama ini sebelum hakim menulis sintesis.',
'modelCouncil.liveScratchpadHelp':
'Pembaruan juri ditambahkan di sini setelah setiap putaran, lalu dimasukkan ke putaran berikutnya.',
'modelCouncil.liveScratchpadBadge': 'Live',
'modelCouncil.scratchpadRoundHeading': 'Pembaruan putaran {round}',
'modelCouncil.scratchpadNoResponse': 'Tidak ada respons',
'modelCouncil.chairLabel': 'Model ketua',
'modelCouncil.chairPlaceholder': 'mis. claude-opus-4-8',
'modelCouncil.chairHelp': 'Ketua membaca setiap jawaban anggota dan menulis sintesis.',
'modelCouncil.run': 'Kumpulkan dewan',
'modelCouncil.running': 'Convening…',
'modelCouncil.runningHint': 'Menanyai anggota secara paralel, lalu mensintesis…',
'modelCouncil.deliberationHeading': 'Deliberasi dewan',
'modelCouncil.deliberationHelp':
'Maskot menunjukkan setiap agen sedang berpikir saat file penalaran bersama sedang digunakan.',
'modelCouncil.debateRoundInstruction':
'Tanggapi juri lainnya. Pertahankan ketidaksepakatan, revisi pandangan Anda jika beralasan, dan tetap eksplisitkan kesimpulan Anda saat ini.',
'modelCouncil.debateFinalInstruction':
'Ini adalah putaran juri terakhir Anda. Konvergensi di mana bukti mendukung, sebutkan ketidaksepakatan yang tersisa, dan berikan hakim kesimpulan yang jelas.',
'modelCouncil.roundLabel': 'Putaran {round}',
'modelCouncil.currentRoundThinking': 'Melanjutkan putaran berikutnya…',
'modelCouncil.thinkingBadge': 'Thinking',
'modelCouncil.thinkingWithBrief': 'Bekerja dari perspektif ini: {brief}',
'modelCouncil.thought.evidence':
'Memeriksa bukti, asumsi, dan apa yang sudah tercatat dalam file penalaran bersama.',
'modelCouncil.thought.plan': 'Merancang jalur praktis dan di mana eksekusi bisa terhambat.',
'modelCouncil.thought.risk':
'Mencari modus kegagalan, konteks yang hilang, dan kesimpulan yang rapuh.',
'modelCouncil.thought.tradeoffs':
'Membandingkan pertukaran sebelum memberikan hakim posisi yang ringkas.',
'modelCouncil.thought.synthesis':
'Menyiapkan pandangan berbeda agar hakim dapat membandingkan ketidaksepakatan nyata.',
'modelCouncil.judgeWaitingBadge': 'Judge',
'modelCouncil.judgeWaitingThought':
'Menunggu jawaban juri, lalu membaca file penalaran bersama untuk sintesis.',
'modelCouncil.judgeSynthesizingBadge': 'Synthesizing',
'modelCouncil.judgeSynthesizingThought':
'Membaca pemikiran langsung juri dan menulis sintesis akhir dewan.',
'modelCouncil.errorPrefix': 'Dewan gagal:',
'modelCouncil.resultsHeading': 'Hasil dewan',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'oleh {model}',
'modelCouncil.usageHeading': 'Penggunaan debat',
'modelCouncil.usageEstimated':
'Perkiraan jumlah token dari prompt dan respons debat. Harga sebenarnya memerlukan penggunaan penyedia dari RPC dewan.',
'modelCouncil.usageEstimatedBadge': 'Estimasi token',
'modelCouncil.usageInputTokens': 'Input',
'modelCouncil.usageOutputTokens': 'Output',
'modelCouncil.usageTotalTokens': 'Total',
'graphCohesion.brokerBadge': 'broker',
'graphCohesion.brokerTitle':
'Lubang struktural: tetangga entitas ini tidak saling terhubung: entitas inilah satu-satunya tautan di antara mereka.',
+25 -276
View File
@@ -3,6 +3,31 @@ import type { TranslationMap } from './types';
// Italian (Italiano) translations. Keys mirror en.ts; missing/
// English-identical values fall back to English via I18nContext.resolveEn().
const messages: TranslationMap = {
'connections.header.companion':
'Un compagno desktop fluttuante per un aiuto rapido e sempre disponibile.',
'settings.assistant.desktopCompanion': 'Compagno desktop',
'settings.assistant.desktopCompanionDesc':
'Modalità compagno sempre attiva con scorciatoia nella barra di sistema',
'pages.settings.features.desktopCompanion': 'Companion Desktop',
'pages.settings.features.desktopCompanionDesc':
'Assistente vocale con consapevolezza dello schermo: ascolta, vede, parla, indica',
'settings.companion.title': 'Compagno da scrivania',
'settings.companion.session': 'Sessione',
'settings.companion.activeLabel': 'Attivo',
'settings.companion.inactiveStatus': 'Inattivo',
'settings.companion.stopping': 'Arresto…',
'settings.companion.stopSession': 'Interrompi sessione',
'settings.companion.starting': 'Avvio…',
'settings.companion.startSession': 'Avvia sessione',
'settings.companion.sessionId': 'ID sessione',
'settings.companion.turns': 'Turni',
'settings.companion.remaining': 'Rimanenti',
'settings.companion.configuration': 'Configurazione',
'settings.companion.hotkey': 'Tasto di scelta rapida',
'settings.companion.activationMode': 'Modalità di attivazione',
'settings.companion.sessionTtl': 'Sessione TTL',
'settings.companion.screenCapture': 'Acquisizione schermata',
'settings.companion.appContext': 'Contesto app',
// Share cards (#5006).
'share.button': 'Condividi',
'share.modalTitle': 'Condividi questo momento',
@@ -94,10 +119,6 @@ const messages: TranslationMap = {
'connections.header.wallet': 'Il saldo del tuo wallet locale e la tua frase di recupero.',
'connections.header.screen':
'Lascia che il tuo agente veda il tuo schermo per offrire un aiuto tempestivo e contestuale.',
'connections.header.desktopAgent':
'Lascia che il tuo agente apra le app e controlli il tuo desktop per tuo conto.',
'connections.header.companion':
'Un compagno desktop fluttuante per un aiuto rapido e sempre disponibile.',
'connections.header.composioKey':
'Aggiungi una chiave API Composio per sbloccare centinaia di integrazioni di app.',
'connections.llm.apiKeys': 'Chiavi API e instradamento',
@@ -759,9 +780,6 @@ const messages: TranslationMap = {
'Controlla quanto attivamente il tuo assistente lavora in background',
'settings.assistant.screenAwareness': 'Consapevolezza dello schermo',
'settings.assistant.screenAwarenessDesc': "Consenti all'assistente di vedere la finestra attiva",
'settings.assistant.desktopCompanion': 'Compagno desktop',
'settings.assistant.desktopCompanionDesc':
'Modalità compagno sempre attiva con scorciatoia nella barra di sistema',
'settings.assistant.permissions': 'Autorizzazioni',
'settings.assistant.permissionsDesc': "Scegli cosa può fare l'assistente e dove può lavorare",
'settings.privacySecurity.privacy': 'Privacy',
@@ -2576,28 +2594,6 @@ const messages: TranslationMap = {
'Nei giochi a schermo intero esclusivo il riquadro non verrà mostrato: sentirai solo il segnale acustico. Passa allo schermo intero senza bordi per vedere il riquadro.',
'pttOverlay.listening': 'In ascolto…',
'pttOverlay.idle': 'In attesa',
'autocomplete.title': 'Autocompletamento',
'autocomplete.settings': 'Impostazioni',
'autocomplete.acceptWithTab': 'Accetta con Tab',
'autocomplete.stylePreset': 'Preset di stile',
'autocomplete.style.balanced': 'Bilanciato',
'autocomplete.style.concise': 'Conciso',
'autocomplete.style.formal': 'Formale',
'autocomplete.style.casual': 'Informale',
'autocomplete.style.custom': 'Personalizzato',
'autocomplete.disabledApps': 'App disabilitate (un bundle/app token per riga)',
'autocomplete.saveSettings': 'Salva impostazioni',
'autocomplete.saving': 'Salvataggio…',
'autocomplete.runtime': 'Runtime',
'autocomplete.running': 'In esecuzione',
'autocomplete.start': 'Avvia',
'autocomplete.stop': 'Ferma',
'autocomplete.settingsSaved': 'Impostazioni di autocompletamento salvate.',
'autocomplete.started': 'Autocompletamento avviato.',
'autocomplete.didNotStart': 'Autocompletamento non avviato. Verifica che sia abilitato.',
'autocomplete.stopped': 'Autocompletamento fermato.',
'autocomplete.advancedSettings': 'Impostazioni avanzate',
'autocomplete.debugTitle': 'Debug autocompletamento',
'chat.agentChat': 'Chat agente',
'chat.overrides': 'Override',
'chat.model': 'Modello',
@@ -4575,9 +4571,6 @@ const messages: TranslationMap = {
'settings.developerMenu.composio.title': 'Composio',
'settings.developerMenu.composio.desc':
'Modalità di routing, trigger di integrazione e archivio cronologico dei trigger.',
'pages.settings.features.desktopCompanion': 'Companion Desktop',
'pages.settings.features.desktopCompanionDesc':
'Assistente vocale con consapevolezza dello schermo: ascolta, vede, parla, indica',
'pages.settings.features.messagingChannels': 'Canali di messaggistica',
'pages.settings.features.messagingChannelsDesc': 'Descrizione canali di messaggistica',
'pages.settings.features.notifications': 'Notifiche',
@@ -4998,65 +4991,6 @@ const messages: TranslationMap = {
'settings.ai.workload.changeModel': 'Cambia modello',
'settings.ai.workload.chooseModel': 'Scegli modello',
'settings.ai.provider.ollama': 'Ollama',
'settings.autocomplete.appFilter.acceptSuggestion': 'Accetta suggerimento',
'settings.autocomplete.appFilter.app': 'App',
'settings.autocomplete.appFilter.currentSuggestion': 'Suggerimento corrente',
'settings.autocomplete.appFilter.contextOverride': 'Override contesto (opzionale)',
'settings.autocomplete.appFilter.debounce': 'Antirimbalzo',
'settings.autocomplete.appFilter.debugFocus': 'Focus debug',
'settings.autocomplete.appFilter.enabled': 'Abilitato',
'settings.autocomplete.appFilter.getSuggestion': 'Ottieni suggerimento',
'settings.autocomplete.appFilter.lastError': 'Ultimo errore',
'settings.autocomplete.appFilter.liveLogs': 'Log live',
'settings.autocomplete.appFilter.model': 'Modello',
'settings.autocomplete.appFilter.noLogs': 'Nessun log',
'settings.autocomplete.appFilter.phase': 'Fase',
'settings.autocomplete.appFilter.platformSupported': 'Piattaforma supportata',
'settings.autocomplete.appFilter.refreshStatus': 'Aggiornamento…',
'settings.autocomplete.appFilter.refreshing': 'Aggiornamento…',
'settings.autocomplete.appFilter.running': 'In esecuzione',
'settings.autocomplete.appFilter.runtime': 'Tempo di esecuzione',
'settings.autocomplete.appFilter.test': 'Test',
'settings.autocomplete.completionStyle.acceptedCompletion':
'{count} completamento accettato memorizzato: usato per personalizzare i futuri suggerimenti.',
'settings.autocomplete.completionStyle.acceptedCompletions':
'{count} completamenti accettati memorizzati: usati per personalizzare i futuri suggerimenti.',
'settings.autocomplete.completionStyle.clearHistory': 'Cancellazione…',
'settings.autocomplete.completionStyle.clearing': 'Cancellazione…',
'settings.autocomplete.completionStyle.debounce': 'Rimbalzo (ms)',
'settings.autocomplete.completionStyle.enabled': 'Abilitato',
'settings.autocomplete.completionStyle.maxChars': 'Caratteri max',
'settings.autocomplete.completionStyle.noHistory':
'Nessun completamento accettato. Accetta i suggerimenti con Tab per iniziare a personalizzare.',
'settings.autocomplete.completionStyle.overlayTtl': 'TTL overlay (ms)',
'settings.autocomplete.completionStyle.personalizationHistory': 'Cronologia personalizzazione',
'settings.autocomplete.completionStyle.styleExamples': 'Esempi di stile (uno per riga)',
'settings.autocomplete.completionStyle.styleInstructions': 'Istruzioni di stile',
'settings.autocomplete.debug.acceptedPrefix': 'Accettato: {value}',
'settings.autocomplete.debug.acceptFailed': 'Impossibile accettare il suggerimento',
'settings.autocomplete.debug.alreadyRunning': 'Il completamento automatico è già in esecuzione.',
'settings.autocomplete.debug.clearHistoryFailed': 'Impossibile cancellare la cronologia.',
'settings.autocomplete.debug.didNotStart': 'Il completamento automatico non è stato avviato.',
'settings.autocomplete.debug.disabledInSettings':
'Il completamento automatico è disabilitato nelle impostazioni. Abilitalo e salva prima.',
'settings.autocomplete.debug.fetchSuggestionFailed':
'Impossibile recuperare il suggerimento corrente',
'settings.autocomplete.debug.inspectFocusedElementFailed':
"Impossibile controllare l'elemento selezionato",
'settings.autocomplete.debug.loadSettingsFailed':
'Impossibile caricare le impostazioni di completamento automatico',
'settings.autocomplete.debug.noSuggestionApplied': 'Nessun suggerimento è stato applicato.',
'settings.autocomplete.debug.noSuggestionReturned': 'Nessun suggerimento restituito.',
'settings.autocomplete.debug.refreshStatusFailed':
'Impossibile aggiornare lo stato del completamento automatico',
'settings.autocomplete.debug.saveAdvancedSettingsFailed':
'Impossibile salvare le impostazioni avanzate',
'settings.autocomplete.debug.startFailed': 'Impossibile avviare il completamento automatico',
'settings.autocomplete.debug.stopFailed': 'Impossibile arrestare il completamento automatico',
'settings.autocomplete.debug.suggestionPrefix': 'Suggerimento: {value}',
'settings.autocomplete.shared.none': 'Nessuno',
'settings.autocomplete.shared.notApplicable': 'n/a',
'settings.autocomplete.shared.unknown': 'Sconosciuto',
'settings.billing.autoRecharge.addAmount': 'Aggiungi questo importo',
'settings.billing.autoRecharge.addCard': 'Aggiungi carta',
'settings.billing.autoRecharge.amountHint': 'Suggerimento importo',
@@ -5174,23 +5108,6 @@ const messages: TranslationMap = {
'Salvataggio fallito. La modalità Diretta richiede una chiave API non vuota.',
'settings.composio.saving': 'Salvataggio…',
'settings.composio.switching': 'Passaggio…',
'settings.companion.title': 'Compagno da scrivania',
'settings.companion.session': 'Sessione',
'settings.companion.activeLabel': 'Attivo',
'settings.companion.inactiveStatus': 'Inattivo',
'settings.companion.stopping': 'Arresto…',
'settings.companion.stopSession': 'Interrompi sessione',
'settings.companion.starting': 'Avvio…',
'settings.companion.startSession': 'Avvia sessione',
'settings.companion.sessionId': 'ID sessione',
'settings.companion.turns': 'Turni',
'settings.companion.remaining': 'Rimanenti',
'settings.companion.configuration': 'Configurazione',
'settings.companion.hotkey': 'Tasto di scelta rapida',
'settings.companion.activationMode': 'Modalità di attivazione',
'settings.companion.sessionTtl': 'Sessione TTL',
'settings.companion.screenCapture': 'Acquisizione schermata',
'settings.companion.appContext': 'Contesto app',
'settings.cron.jobs.commandRequired': 'Il comando è obbligatorio per i job shell',
'settings.cron.jobs.createJob': 'Nuovo job pianificato',
'settings.cron.jobs.created': 'Job creato',
@@ -5619,9 +5536,6 @@ const messages: TranslationMap = {
'settings.developerMenu.autonomy.title': 'Autonomia agente',
'settings.developerMenu.autonomy.desc':
'Limiti di frequenza delle azioni degli strumenti e soglie di sicurezza',
'settings.developerMenu.autocomplete.title': 'Completamento automatico',
'settings.developerMenu.autocomplete.desc':
'Impostazioni del completamento automatico AI in linea e pannello di debug',
'settings.developerMenu.voiceDebug.title': 'Voce (debug)',
'settings.developerMenu.voiceDebug.desc':
'Stato di runtime della dettatura vocale e impostazioni di debug',
@@ -6094,25 +6008,6 @@ const messages: TranslationMap = {
'settings.modelHealth.modal.apply': 'Applica sostituzione',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
'settings.desktopAgent.title': 'Agente desktop',
'settings.desktopAgent.description':
'Verifica e concedi le autorizzazioni necessarie allagente desktop.',
'settings.desktopAgent.microphone': 'Microfono',
'settings.desktopAgent.grant': 'Concedi',
'settings.desktopAgent.notRequiredOnOs': 'Non necessario su questo sistema operativo',
'settings.desktopAgent.allGranted':
'Tutto pronto: lagente desktop dispone di tutte le autorizzazioni necessarie.',
'settings.desktopAgent.recheck': 'Verifica di nuovo',
'settings.desktopAgent.restartAndRecheck': 'Riavvia il core e verifica di nuovo',
'settings.desktopAgent.seamless.title': 'Azioni dellagente',
'settings.desktopAgent.seamless.label': 'Lascia che lagente agisca senza chiedere',
'settings.desktopAgent.seamless.description':
'Concedi laccesso completo e approva automaticamente le azioni desktop (aprire app, controllarne le interfacce) così lagente le esegue senza una richiesta di conferma.',
'settings.desktopAgent.seamless.note':
'Solo le azioni desktop saltano la richiesta: eseguire comandi shell, modificare file, le richieste di rete e linstallazione di software continuano a chiedere. Disattivando questa opzione si interrompe lapprovazione automatica delle azioni desktop, ma il livello di accesso resta invariato.',
'settings.desktopAgent.beta':
'Beta: lagente desktop è sperimentale e potrebbe non funzionare sempre come previsto.',
'settings.desktopAgent.wakeWordHint': 'Di “Hey Tiny” prima del comando per attivare lagente.',
'settings.screenIntel.permissions.accessibility': 'Accessibilità',
'settings.screenIntel.permissions.grantHint': 'Suggerimento concessione',
'settings.screenIntel.permissions.inputMonitoring': 'Monitoraggio input',
@@ -6389,21 +6284,6 @@ const messages: TranslationMap = {
'skills.resource.tree.templates': 'Modelli',
'skills.resource.tree.empty': 'Nessuna risorsa inclusa.',
'skills.search.placeholder': 'Segnaposto',
'skills.setup.autocomplete.acceptKey': 'Tasto di accettazione',
'skills.setup.autocomplete.activeDesc': 'Descrizione attivo',
'skills.setup.autocomplete.activeTitle': 'Autocompletamento attivo',
'skills.setup.autocomplete.customizeSettings': 'Personalizza impostazioni',
'skills.setup.autocomplete.debounce': 'Antirimbalzo',
'skills.setup.autocomplete.description': 'Descrizione',
'skills.setup.autocomplete.enableBtn': 'Abilitazione...',
'skills.setup.autocomplete.enableError': 'Abilitazione autocompletamento fallita',
'skills.setup.autocomplete.enabling': 'Abilitazione...',
'skills.setup.autocomplete.notSupported': 'Non supportato',
'skills.setup.autocomplete.stepEnable': 'Abilita completamenti inline',
'skills.setup.autocomplete.stepSuccess': 'Pronto',
'skills.setup.autocomplete.stylePreset': 'Preset di stile',
'skills.setup.autocomplete.stylePresetValue': 'Bilanciato (configurabile in seguito)',
'skills.setup.autocomplete.title': 'Autocompletamento testo',
'skills.setup.screenIntel.activeDesc': 'Descrizione attivo',
'skills.setup.screenIntel.activeTitle': 'Screen Intelligence abilitato',
'skills.setup.screenIntel.advancedSettings': 'Impostazioni avanzate',
@@ -6898,9 +6778,6 @@ const messages: TranslationMap = {
'chat.files.error.resolve_failed': 'Impossibile risolvere lartefatto. Riprova.',
'chat.files.error.download_failed': 'Download non riuscito. Riprova.',
'chat.files.error.delete_failed': 'Impossibile eliminare il file. Riprova.',
'autocomplete.debounceMs': 'Ritardo (ms)',
'autocomplete.maxChars': 'Caratteri massimi di contesto',
'autocomplete.overlayTtlMs': 'Timeout overlay (ms)',
'memory.tab.council': 'Council',
'activity.tabs.automations': 'Automazioni',
'activity.tabs.automationsDescription':
@@ -6935,134 +6812,6 @@ const messages: TranslationMap = {
'intelligence.agents.tier.chat': 'Chat',
'intelligence.agents.tier.reasoning': 'Ragionamento',
'intelligence.agents.tier.worker': 'Worker',
'modelCouncil.title': 'Consiglio dei modelli',
'modelCouncil.intro':
'Fai una domanda, ottieni risposte indipendenti da più modelli in parallelo, poi il modello presidente sintetizza dove concordano, dove divergono e cosa aggiunge ciascuno di unico.',
'modelCouncil.listTitle': 'Councils',
'modelCouncil.listIntro':
'Scegli un consiglio salvato, modifica i suoi agenti e il giudice, oppure creane uno nuovo per un tipo specifico di decisione.',
'modelCouncil.addCouncil': 'Nuovo consiglio',
'modelCouncil.openCouncil': 'Apri consiglio',
'modelCouncil.editCouncil': 'Modifica consiglio',
'modelCouncil.editCurrentCouncil': 'Modifica consiglio attuale',
'modelCouncil.editCouncilAria': 'Modifica {name}',
'modelCouncil.deleteCouncilAria': 'Elimina {name}',
'modelCouncil.backToCouncils': 'Torna ai consigli',
'modelCouncil.saveCouncil': 'Salva consiglio',
'modelCouncil.savingCouncil': 'Saving…',
'modelCouncil.cancelEdit': 'Cancel',
'modelCouncil.loadingCouncils': 'Caricamento dei consigli…',
'modelCouncil.noCouncils': 'Nessun consiglio ancora. Aggiungine uno per iniziare.',
'modelCouncil.noCouncilDescription': 'Nessuna descrizione',
'modelCouncil.registryErrorPrefix': 'Registro del consiglio fallito:',
'modelCouncil.councilNameLabel': 'Nome del consiglio',
'modelCouncil.councilDescriptionLabel': 'Description',
'modelCouncil.councilDescriptionPlaceholder': 'Quando usare questo consiglio',
'modelCouncil.selectModel': 'Select',
'modelCouncil.modelPickerHelp':
'Seleziona un suggerimento di routing o fissa un modello fornitore esatto.',
'modelCouncil.closeModelPicker': 'Close',
'modelCouncil.hint.chat': 'Chat',
'modelCouncil.hint.code': 'Codice',
'modelCouncil.hint.default': 'Predefinito',
'modelCouncil.hint.reasoning': 'Ragionamento',
'modelCouncil.hint.summarize': 'Riassunto',
'modelCouncil.modelPickerHints': 'Hints',
'modelCouncil.modelPickerProviderModel': 'Fornitore + modello',
'modelCouncil.modelProviderLabel': 'Fornitore del modello',
'modelCouncil.modelIdLabel': 'ID modello',
'modelCouncil.useProviderModel': 'Usa modello del fornitore',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Fai una domanda al consiglio…',
'modelCouncil.membersLabel': 'Modelli membri',
'modelCouncil.maxMembersNote': 'fino a {max}',
'modelCouncil.memberPlaceholder': 'es. gpt-5.2',
'modelCouncil.memberAria': 'Modello membro {n}',
'modelCouncil.removeMemberAria': 'Rimuovi modello membro {n}',
'modelCouncil.addMember': '+ Aggiungi modello',
'modelCouncil.settingsTitle': 'Impostazioni del consiglio',
'modelCouncil.settingsSummary': '{count} giurati · giudice: {judge}',
'modelCouncil.juryCountLabel': 'Numero di giurati',
'modelCouncil.debateRoundsLabel': 'Turni di dibattito',
'modelCouncil.debateRoundsHelp':
'Ogni giurato continua a dibattere per più turni prima che il giudice scriva la sintesi finale.',
'modelCouncil.judgeAgentLabel': 'Agente giudice',
'modelCouncil.defaultJudge': 'Giudice predefinito',
'modelCouncil.savedProfile': 'Profilo salvato',
'modelCouncil.customAgent': 'Agente personalizzato',
'modelCouncil.judgeProfileLabel': 'Profilo del giudice',
'modelCouncil.chooseProfile': 'Scegli un profilo',
'modelCouncil.judgeNameLabel': 'Nome del giudice',
'modelCouncil.judgeNamePlaceholder': 'Giudice capo',
'modelCouncil.rosterHeading': 'Roster del consiglio',
'modelCouncil.rosterHelp':
'Ogni giurato può ereditare lagente predefinito, usare un profilo salvato o diventare un profilo personalizzato per questa esecuzione.',
'modelCouncil.loadingProfiles': 'Caricamento profili…',
'modelCouncil.jurorLabel': 'Giurato {n}',
'modelCouncil.profileModeLabel': 'Origine del profilo',
'modelCouncil.mode.default': 'Predefinito',
'modelCouncil.mode.profile': 'Profilo',
'modelCouncil.mode.custom': 'Personalizzato',
'modelCouncil.memberProfileAria': 'Profilo del giurato {n}',
'modelCouncil.memberNameAria': 'Nome del giurato {n}',
'modelCouncil.memberNamePlaceholder': 'Nome agente',
'modelCouncil.profileModelPlaceholder': 'Sovrascrivi modello del profilo',
'modelCouncil.memberBriefAria': 'Brief del giurato {n}',
'modelCouncil.memberBriefPlaceholder': 'Prospettiva di questo giurato',
'modelCouncil.jurorFallback': 'Giurato',
'modelCouncil.sharedReasoningLabel': 'File di ragionamento condiviso',
'modelCouncil.sharedReasoningHelp':
'Il consiglio lavora su questa bozza condivisa prima che il giudice scriva la sintesi.',
'modelCouncil.liveScratchpadHelp':
'Gli aggiornamenti dei giurati vengono aggiunti qui dopo ogni turno e alimentano il turno successivo.',
'modelCouncil.liveScratchpadBadge': 'Live',
'modelCouncil.scratchpadRoundHeading': 'Aggiornamenti turno {round}',
'modelCouncil.scratchpadNoResponse': 'Nessuna risposta',
'modelCouncil.chairLabel': 'Modello presidente',
'modelCouncil.chairPlaceholder': 'es. claude-opus-4-8',
'modelCouncil.chairHelp': 'Il presidente legge ogni risposta dei membri e scrive la sintesi.',
'modelCouncil.run': 'Convoca consiglio',
'modelCouncil.running': 'Convening…',
'modelCouncil.runningHint': 'Interrogazione dei membri in parallelo, poi sintesi…',
'modelCouncil.deliberationHeading': 'Deliberazione del consiglio',
'modelCouncil.deliberationHelp':
'Le mascotte mostrano ogni agente che riflette mentre il file di ragionamento condiviso è in uso.',
'modelCouncil.debateRoundInstruction':
'Rispondi agli altri giurati. Mantieni i disaccordi, rivedi la tua posizione quando giustificato e mantieni esplicita la tua conclusione attuale.',
'modelCouncil.debateFinalInstruction':
'Questo è il tuo ultimo turno da giurato. Convergi dove le prove lo supportano, segnala i disaccordi rimanenti e fornisci al giudice una conclusione chiara.',
'modelCouncil.roundLabel': 'Turno {round}',
'modelCouncil.currentRoundThinking': 'Prosecuzione del turno successivo…',
'modelCouncil.thinkingBadge': 'Thinking',
'modelCouncil.thinkingWithBrief': 'Lavoro da questa prospettiva: {brief}',
'modelCouncil.thought.evidence':
'Verifica delle prove, delle ipotesi e di quanto il file di ragionamento condiviso già contiene.',
'modelCouncil.thought.plan': 'Delineazione del percorso pratico e dei possibili punti di blocco.',
'modelCouncil.thought.risk':
'Ricerca di modalità di fallimento, contesto mancante e conclusioni fragili.',
'modelCouncil.thought.tradeoffs':
'Confronto dei compromessi prima di consegnare al giudice una posizione concisa.',
'modelCouncil.thought.synthesis':
'Preparazione di un punto di vista distinto affinché il giudice possa confrontare i veri disaccordi.',
'modelCouncil.judgeWaitingBadge': 'Judge',
'modelCouncil.judgeWaitingThought':
'In attesa delle risposte dei giurati, poi lettura del file di ragionamento condiviso per la sintesi.',
'modelCouncil.judgeSynthesizingBadge': 'Synthesizing',
'modelCouncil.judgeSynthesizingThought':
'Lettura dei pensieri in tempo reale dei giurati e stesura della sintesi finale del consiglio.',
'modelCouncil.errorPrefix': 'Il consiglio è fallito:',
'modelCouncil.resultsHeading': 'Risultati del consiglio',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'di {model}',
'modelCouncil.usageHeading': 'Utilizzo del dibattito',
'modelCouncil.usageEstimated':
'Conteggio stimato dei token da prompt e risposte del dibattito. Il prezzo effettivo richiede i dati di utilizzo del fornitore tramite RPC del consiglio.',
'modelCouncil.usageEstimatedBadge': 'Token stimati',
'modelCouncil.usageInputTokens': 'Input',
'modelCouncil.usageOutputTokens': 'Output',
'modelCouncil.usageTotalTokens': 'Total',
'graphCohesion.brokerBadge': 'broker',
'graphCohesion.brokerTitle':
"Buco strutturale: i vicini di questa entità non sono collegati tra loro: essa è l'unico collegamento tra loro.",
+24 -263
View File
@@ -3,6 +3,30 @@ import type { TranslationMap } from './types';
// Korean (한국어) translations. Keys mirror en.ts; missing/
// English-identical values fall back to English via I18nContext.resolveEn().
const messages: TranslationMap = {
'connections.header.companion': '빠르고 항상 켜져 있는 도움을 위한 떠 있는 데스크톱 동반자.',
'settings.assistant.desktopCompanion': '데스크탑 동반자',
'settings.assistant.desktopCompanionDesc':
'시스템 트레이 단축키가 있는 항상 활성화된 동반자 모드',
'pages.settings.features.desktopCompanion': '데스크탑 동반자',
'pages.settings.features.desktopCompanionDesc':
'화면 인식 기능이 있는 음성 도우미: 듣고, 보고, 말하고, 가리킵니다.',
'settings.companion.title': '데스크톱 컴패니언',
'settings.companion.session': '세션',
'settings.companion.activeLabel': '활성',
'settings.companion.inactiveStatus': '비활성',
'settings.companion.stopping': '중지 중…',
'settings.companion.stopSession': '세션 중지',
'settings.companion.starting': '시작 중…',
'settings.companion.startSession': '세션 시작',
'settings.companion.sessionId': '세션 ID',
'settings.companion.turns': '턴',
'settings.companion.remaining': '남음',
'settings.companion.configuration': '설정',
'settings.companion.hotkey': '단축키',
'settings.companion.activationMode': '활성화 모드',
'settings.companion.sessionTtl': '세션 TTL',
'settings.companion.screenCapture': '화면 캡처',
'settings.companion.appContext': '앱 컨텍스트',
// Share cards (#5006).
'share.button': '공유',
'share.modalTitle': '이 순간을 공유하세요',
@@ -89,9 +113,6 @@ const messages: TranslationMap = {
'connections.header.wallet': '로컬 지갑 잔액과 복구 문구입니다.',
'connections.header.screen':
'에이전트가 화면을 볼 수 있게 하여 시의적절하고 맥락에 맞는 도움을 제공하세요.',
'connections.header.desktopAgent':
'에이전트가 당신을 대신해 앱을 열고 데스크톱을 제어하게 하세요.',
'connections.header.companion': '빠르고 항상 켜져 있는 도움을 위한 떠 있는 데스크톱 동반자.',
'connections.header.composioKey':
'Composio API 키를 추가하여 수백 개의 앱 통합을 잠금 해제하세요.',
'connections.llm.apiKeys': 'API 키 및 라우팅',
@@ -735,9 +756,6 @@ const messages: TranslationMap = {
'어시스턴트가 백그라운드에서 얼마나 활발히 작동하는지 제어',
'settings.assistant.screenAwareness': '화면 인식',
'settings.assistant.screenAwarenessDesc': '어시스턴트가 활성 창을 볼 수 있도록 허용',
'settings.assistant.desktopCompanion': '데스크탑 동반자',
'settings.assistant.desktopCompanionDesc':
'시스템 트레이 단축키가 있는 항상 활성화된 동반자 모드',
'settings.assistant.permissions': '권한',
'settings.assistant.permissionsDesc':
'어시스턴트가 무엇을 할 수 있고 어디서 작업할 수 있는지 선택하세요',
@@ -2500,28 +2518,6 @@ const messages: TranslationMap = {
'전용 전체 화면 게임에서는 오버레이가 표시되지 않습니다: 알림음만 들립니다. 오버레이를 보려면 테두리 없는 전체 화면으로 전환하세요.',
'pttOverlay.listening': '듣는 중…',
'pttOverlay.idle': '대기 중',
'autocomplete.title': '자동 완성',
'autocomplete.settings': '설정',
'autocomplete.acceptWithTab': 'Tab으로 수락',
'autocomplete.stylePreset': '스타일 프리셋',
'autocomplete.style.balanced': '균형',
'autocomplete.style.concise': '간결',
'autocomplete.style.formal': '격식',
'autocomplete.style.casual': '캐주얼',
'autocomplete.style.custom': '사용자 지정',
'autocomplete.disabledApps': '비활성화된 앱(줄마다 하나의 번들/앱 토큰)',
'autocomplete.saveSettings': '설정 저장',
'autocomplete.saving': '저장 중…',
'autocomplete.runtime': '런타임',
'autocomplete.running': '실행 중',
'autocomplete.start': '시작',
'autocomplete.stop': '중지',
'autocomplete.settingsSaved': '자동 완성 설정이 저장되었습니다.',
'autocomplete.started': '자동 완성이 시작되었습니다.',
'autocomplete.didNotStart': '자동 완성이 시작되지 않았습니다. 활성화되어 있는지 확인하세요.',
'autocomplete.stopped': '자동 완성이 중지되었습니다.',
'autocomplete.advancedSettings': '고급 설정',
'autocomplete.debugTitle': '자동 완성 디버그',
'chat.agentChat': '에이전트 채팅',
'chat.overrides': '재정의',
'chat.model': '모델',
@@ -4448,9 +4444,6 @@ const messages: TranslationMap = {
'pages.settings.aiSection.title': 'AI',
'settings.developerMenu.composio.title': 'Composio',
'settings.developerMenu.composio.desc': '라우팅 모드, 통합 트리거 및 트리거 기록 아카이브.',
'pages.settings.features.desktopCompanion': '데스크탑 동반자',
'pages.settings.features.desktopCompanionDesc':
'화면 인식 기능이 있는 음성 도우미: 듣고, 보고, 말하고, 가리킵니다.',
'pages.settings.features.messagingChannels': '메시징 채널',
'pages.settings.features.messagingChannelsDesc': '메시징 채널 설명',
'pages.settings.features.notifications': '알림',
@@ -4864,60 +4857,6 @@ const messages: TranslationMap = {
'settings.ai.workload.changeModel': '모델 변경',
'settings.ai.workload.chooseModel': '모델 선택',
'settings.ai.provider.ollama': 'Ollama',
'settings.autocomplete.appFilter.acceptSuggestion': '제안 수락',
'settings.autocomplete.appFilter.app': '앱',
'settings.autocomplete.appFilter.currentSuggestion': '현재 제안',
'settings.autocomplete.appFilter.contextOverride': '컨텍스트 재정의(선택 사항)',
'settings.autocomplete.appFilter.debounce': '디바운스',
'settings.autocomplete.appFilter.debugFocus': '포커스 디버그',
'settings.autocomplete.appFilter.enabled': '활성화됨',
'settings.autocomplete.appFilter.getSuggestion': '제안 받기',
'settings.autocomplete.appFilter.lastError': '마지막 오류',
'settings.autocomplete.appFilter.liveLogs': '실시간 로그',
'settings.autocomplete.appFilter.model': '모델',
'settings.autocomplete.appFilter.noLogs': ') :',
'settings.autocomplete.appFilter.phase': '단계',
'settings.autocomplete.appFilter.platformSupported': '플랫폼 지원',
'settings.autocomplete.appFilter.refreshStatus': '새로고침 중…',
'settings.autocomplete.appFilter.refreshing': '새로고침 중…',
'settings.autocomplete.appFilter.running': '실행 중',
'settings.autocomplete.appFilter.runtime': '런타임',
'settings.autocomplete.appFilter.test': '테스트',
'settings.autocomplete.completionStyle.acceptedCompletion':
'수락된 완성 {count}개가 저장됨: 향후 제안을 개인화하는 데 사용됩니다.',
'settings.autocomplete.completionStyle.acceptedCompletions':
'수락된 완성 {count}개가 저장됨: 향후 제안을 개인화하는 데 사용됩니다.',
'settings.autocomplete.completionStyle.clearHistory': '지우는 중…',
'settings.autocomplete.completionStyle.clearing': '지우는 중…',
'settings.autocomplete.completionStyle.debounce': '디바운스(ms)',
'settings.autocomplete.completionStyle.enabled': '활성화됨',
'settings.autocomplete.completionStyle.maxChars': '최대 문자 수',
'settings.autocomplete.completionStyle.noHistory':
'아직 수락된 완성이 없습니다. Tab으로 제안을 수락하여 개인화를 시작하세요.',
'settings.autocomplete.completionStyle.overlayTtl': '오버레이 TTL(ms)',
'settings.autocomplete.completionStyle.personalizationHistory': '개인화 기록',
'settings.autocomplete.completionStyle.styleExamples': '스타일 예시(줄마다 하나씩)',
'settings.autocomplete.completionStyle.styleInstructions': '스타일 지침',
'settings.autocomplete.debug.acceptedPrefix': '수락됨: {value}',
'settings.autocomplete.debug.acceptFailed': '제안을 수락하지 못했습니다.',
'settings.autocomplete.debug.alreadyRunning': '자동 완성이 이미 실행 중입니다.',
'settings.autocomplete.debug.clearHistoryFailed': '기록을 지우지 못했습니다.',
'settings.autocomplete.debug.didNotStart': '자동 완성이 시작되지 않았습니다.',
'settings.autocomplete.debug.disabledInSettings':
'설정에서 자동완성이 비활성화되어 있습니다. 활성화하고 먼저 저장하십시오.',
'settings.autocomplete.debug.fetchSuggestionFailed': '현재 제안을 가져오지 못했습니다.',
'settings.autocomplete.debug.inspectFocusedElementFailed': '중점 요소를 검사하지 못했습니다.',
'settings.autocomplete.debug.loadSettingsFailed': '자동 완성 설정을 로드하지 못했습니다.',
'settings.autocomplete.debug.noSuggestionApplied': '제안이 적용되지 않았습니다.',
'settings.autocomplete.debug.noSuggestionReturned': '반환된 제안이 없습니다.',
'settings.autocomplete.debug.refreshStatusFailed': '자동 완성 상태를 새로 고치지 못했습니다.',
'settings.autocomplete.debug.saveAdvancedSettingsFailed': '고급 설정을 저장하지 못했습니다.',
'settings.autocomplete.debug.startFailed': '자동 완성을 시작하지 못했습니다.',
'settings.autocomplete.debug.stopFailed': '자동 완성을 중지하지 못했습니다.',
'settings.autocomplete.debug.suggestionPrefix': '제안: {value}',
'settings.autocomplete.shared.none': '없음',
'settings.autocomplete.shared.notApplicable': '해당 없음',
'settings.autocomplete.shared.unknown': '알 수 없음',
'settings.billing.autoRecharge.addAmount': '이 금액 추가',
'settings.billing.autoRecharge.addCard': '카드 추가',
'settings.billing.autoRecharge.amountHint': '금액 안내',
@@ -5030,23 +4969,6 @@ const messages: TranslationMap = {
'저장에 실패했습니다. Direct 모드에는 비어 있지 않은 API 키가 필요합니다.',
'settings.composio.saving': '저장 중…',
'settings.composio.switching': '전환 중…',
'settings.companion.title': '데스크톱 컴패니언',
'settings.companion.session': '세션',
'settings.companion.activeLabel': '활성',
'settings.companion.inactiveStatus': '비활성',
'settings.companion.stopping': '중지 중…',
'settings.companion.stopSession': '중지 세션',
'settings.companion.starting': '시작 중…',
'settings.companion.startSession': '세션 시작',
'settings.companion.sessionId': '세션 ID',
'settings.companion.turns': '회전',
'settings.companion.remaining': '남음',
'settings.companion.configuration': '구성',
'settings.companion.hotkey': '단축키',
'settings.companion.activationMode': '활성화 모드',
'settings.companion.sessionTtl': '세션 TTL',
'settings.companion.screenCapture': '화면 캡처',
'settings.companion.appContext': '앱 컨텍스트',
'settings.cron.jobs.commandRequired': '셸 작업에는 명령어가 필요합니다',
'settings.cron.jobs.createJob': '새 예약 작업',
'settings.cron.jobs.created': '작업이 생성되었습니다',
@@ -5461,8 +5383,6 @@ const messages: TranslationMap = {
'settings.developerMenu.mcpServer.desc': 'OpenHuman에 연결하도록 외부 MCP 클라이언트 구성',
'settings.developerMenu.autonomy.title': '에이전트 자율성',
'settings.developerMenu.autonomy.desc': '도구 작업 속도 제한 및 안전 임계값',
'settings.developerMenu.autocomplete.title': '자동 완성',
'settings.developerMenu.autocomplete.desc': 'AI 인라인 자동 완성 설정 및 디버그 패널',
'settings.developerMenu.voiceDebug.title': '음성 (디버그)',
'settings.developerMenu.voiceDebug.desc': '음성 받아쓰기 런타임 상태 및 디버그 설정',
'settings.mcpServer.title': 'MCP 서버',
@@ -5916,23 +5836,6 @@ const messages: TranslationMap = {
'settings.modelHealth.modal.apply': '교체 적용',
'settings.modelHealth.tag.cheaper': '더 저렴함',
'settings.modelHealth.tag.better': '더 좋음',
'settings.desktopAgent.title': '데스크톱 에이전트',
'settings.desktopAgent.description': '데스크톱 에이전트에 필요한 권한을 확인하고 허용하세요.',
'settings.desktopAgent.microphone': '마이크',
'settings.desktopAgent.grant': '허용',
'settings.desktopAgent.notRequiredOnOs': '이 운영 체제에서는 필요하지 않음',
'settings.desktopAgent.allGranted': '모두 완료: 데스크톱 에이전트에 필요한 모든 권한이 있습니다.',
'settings.desktopAgent.recheck': '다시 확인',
'settings.desktopAgent.restartAndRecheck': '코어 재시작 후 다시 확인',
'settings.desktopAgent.seamless.title': '에이전트 작업',
'settings.desktopAgent.seamless.label': '에이전트가 묻지 않고 실행하도록 허용',
'settings.desktopAgent.seamless.description':
'전체 접근을 허용하고 데스크톱 작업(앱 열기, 앱 인터페이스 제어)을 자동 승인하여 에이전트가 승인 요청 없이 실행하도록 합니다.',
'settings.desktopAgent.seamless.note':
'데스크톱 작업만 확인을 건너뜁니다: 셸 명령 실행, 파일 편집, 네트워크 요청, 소프트웨어 설치는 여전히 확인을 요청합니다. 이 기능을 끄면 데스크톱 작업의 자동 승인이 중단되지만 접근 수준은 그대로 유지됩니다.',
'settings.desktopAgent.beta':
'베타: 데스크톱 에이전트는 실험적 기능이며 항상 기대대로 작동하지 않을 수 있습니다.',
'settings.desktopAgent.wakeWordHint': '에이전트를 깨우려면 명령 앞에 “Hey Tiny”라고 말하세요.',
'settings.screenIntel.permissions.accessibility': '접근성',
'settings.screenIntel.permissions.grantHint': '권한 허용 안내',
'settings.screenIntel.permissions.inputMonitoring': '입력 모니터링',
@@ -6205,21 +6108,6 @@ const messages: TranslationMap = {
'skills.resource.tree.templates': '템플릿',
'skills.resource.tree.empty': '번들 리소스가 없습니다.',
'skills.search.placeholder': '플레이스홀더',
'skills.setup.autocomplete.acceptKey': '수락 키',
'skills.setup.autocomplete.activeDesc': '활성 설명',
'skills.setup.autocomplete.activeTitle': '자동 완성이 활성화됨',
'skills.setup.autocomplete.customizeSettings': '설정 사용자 지정',
'skills.setup.autocomplete.debounce': '디바운스',
'skills.setup.autocomplete.description': '설명',
'skills.setup.autocomplete.enableBtn': '활성화 중...',
'skills.setup.autocomplete.enableError': '자동 완성을 활성화하지 못했습니다',
'skills.setup.autocomplete.enabling': '활성화 중...',
'skills.setup.autocomplete.notSupported': '지원되지 않음',
'skills.setup.autocomplete.stepEnable': '인라인 완성 활성화',
'skills.setup.autocomplete.stepSuccess': '사용 준비 완료',
'skills.setup.autocomplete.stylePreset': '스타일 프리셋',
'skills.setup.autocomplete.stylePresetValue': '균형(나중에 구성 가능)',
'skills.setup.autocomplete.title': '텍스트 자동 완성',
'skills.setup.screenIntel.activeDesc': '활성 설명',
'skills.setup.screenIntel.activeTitle': '화면 인텔리전스가 활성화됨',
'skills.setup.screenIntel.advancedSettings': '고급 설정',
@@ -6695,9 +6583,6 @@ const messages: TranslationMap = {
'chat.files.error.resolve_failed': '아티팩트를 해석할 수 없습니다. 다시 시도해주세요.',
'chat.files.error.download_failed': '다운로드에 실패했습니다. 다시 시도해주세요.',
'chat.files.error.delete_failed': '파일을 삭제하지 못했습니다. 다시 시도해주세요.',
'autocomplete.debounceMs': '디바운스 (ms)',
'autocomplete.maxChars': '최대 컨텍스트 문자 수',
'autocomplete.overlayTtlMs': '오버레이 시간 초과 (ms)',
'memory.tab.council': 'Council',
'activity.tabs.automations': '자동화',
'activity.tabs.automationsDescription':
@@ -6732,130 +6617,6 @@ const messages: TranslationMap = {
'intelligence.agents.tier.chat': '채팅',
'intelligence.agents.tier.reasoning': '추론',
'intelligence.agents.tier.worker': '작업자',
'modelCouncil.title': '모델 평의회',
'modelCouncil.intro':
'질문 하나를 하면 여러 모델에서 동시에 독립적인 답변을 받고, 의장 모델이 합의점, 이견, 각 모델의 고유한 기여를 종합합니다.',
'modelCouncil.listTitle': 'Councils',
'modelCouncil.listIntro':
'저장된 평의회를 선택하거나, 에이전트와 판사를 편집하거나, 특정 유형의 결정을 위해 새 평의회를 만드세요.',
'modelCouncil.addCouncil': '새 평의회',
'modelCouncil.openCouncil': '평의회 열기',
'modelCouncil.editCouncil': '평의회 편집',
'modelCouncil.editCurrentCouncil': '현재 평의회 편집',
'modelCouncil.editCouncilAria': '{name} 편집',
'modelCouncil.deleteCouncilAria': '{name} 삭제',
'modelCouncil.backToCouncils': '평의회 목록으로 돌아가기',
'modelCouncil.saveCouncil': '평의회 저장',
'modelCouncil.savingCouncil': 'Saving…',
'modelCouncil.cancelEdit': 'Cancel',
'modelCouncil.loadingCouncils': '평의회 로드 중…',
'modelCouncil.noCouncils': '아직 평의회가 없습니다. 하나를 추가하여 시작하세요.',
'modelCouncil.noCouncilDescription': '설명 없음',
'modelCouncil.registryErrorPrefix': '평의회 레지스트리 실패:',
'modelCouncil.councilNameLabel': '평의회 이름',
'modelCouncil.councilDescriptionLabel': 'Description',
'modelCouncil.councilDescriptionPlaceholder': '이 평의회를 사용할 시기',
'modelCouncil.selectModel': 'Select',
'modelCouncil.modelPickerHelp': '라우팅 힌트를 선택하거나 정확한 공급자 모델을 고정하세요.',
'modelCouncil.closeModelPicker': 'Close',
'modelCouncil.hint.chat': '채팅',
'modelCouncil.hint.code': '코드',
'modelCouncil.hint.default': '기본값',
'modelCouncil.hint.reasoning': '추론',
'modelCouncil.hint.summarize': '요약',
'modelCouncil.modelPickerHints': 'Hints',
'modelCouncil.modelPickerProviderModel': '공급자 + 모델',
'modelCouncil.modelProviderLabel': '모델 공급자',
'modelCouncil.modelIdLabel': '모델 ID',
'modelCouncil.useProviderModel': '공급자 모델 사용',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': '평의회에 질문하세요…',
'modelCouncil.membersLabel': '구성원 모델',
'modelCouncil.maxMembersNote': '최대 {max}개',
'modelCouncil.memberPlaceholder': '예: gpt-5.2',
'modelCouncil.memberAria': '구성원 모델 {n}',
'modelCouncil.removeMemberAria': '구성원 모델 {n} 제거',
'modelCouncil.addMember': '+ 모델 추가',
'modelCouncil.settingsTitle': '위원회 설정',
'modelCouncil.settingsSummary': '배심원 {count}명 · 판사: {judge}',
'modelCouncil.juryCountLabel': '배심원 수',
'modelCouncil.debateRoundsLabel': '토론 라운드',
'modelCouncil.debateRoundsHelp':
'각 배심원은 판사가 최종 종합 의견을 작성하기 전에 여러 라운드에 걸쳐 토론합니다.',
'modelCouncil.judgeAgentLabel': '판사 에이전트',
'modelCouncil.defaultJudge': '기본 판사',
'modelCouncil.savedProfile': '저장된 프로필',
'modelCouncil.customAgent': '사용자 지정 에이전트',
'modelCouncil.judgeProfileLabel': '판사 프로필',
'modelCouncil.chooseProfile': '프로필 선택',
'modelCouncil.judgeNameLabel': '판사 이름',
'modelCouncil.judgeNamePlaceholder': '수석 판사',
'modelCouncil.rosterHeading': '위원회 명단',
'modelCouncil.rosterHelp':
'각 배심원은 기본 에이전트를 상속하거나 저장된 프로필을 사용하거나 이번 실행용 사용자 지정 프로필이 될 수 있습니다.',
'modelCouncil.loadingProfiles': '프로필 불러오는 중…',
'modelCouncil.jurorLabel': '배심원 {n}',
'modelCouncil.profileModeLabel': '프로필 출처',
'modelCouncil.mode.default': '기본',
'modelCouncil.mode.profile': '프로필',
'modelCouncil.mode.custom': '사용자 지정',
'modelCouncil.memberProfileAria': '배심원 {n} 프로필',
'modelCouncil.memberNameAria': '배심원 {n} 이름',
'modelCouncil.memberNamePlaceholder': '에이전트 이름',
'modelCouncil.profileModelPlaceholder': '프로필 모델 재정의',
'modelCouncil.memberBriefAria': '배심원 {n} 브리프',
'modelCouncil.memberBriefPlaceholder': '이 배심원의 관점',
'modelCouncil.jurorFallback': '배심원',
'modelCouncil.sharedReasoningLabel': '공유 추론 파일',
'modelCouncil.sharedReasoningHelp':
'판사가 종합문을 쓰기 전에 위원회가 이 공유 초안으로 작업합니다.',
'modelCouncil.liveScratchpadHelp':
'각 라운드 후 배심원 업데이트가 여기에 추가되어 다음 라운드에 반영됩니다.',
'modelCouncil.liveScratchpadBadge': 'Live',
'modelCouncil.scratchpadRoundHeading': '라운드 {round} 업데이트',
'modelCouncil.scratchpadNoResponse': '응답 없음',
'modelCouncil.chairLabel': '의장 모델',
'modelCouncil.chairPlaceholder': '예: claude-opus-4-8',
'modelCouncil.chairHelp': '의장은 모든 구성원의 답변을 읽고 종합 의견을 작성합니다.',
'modelCouncil.run': '평의회 소집',
'modelCouncil.running': 'Convening…',
'modelCouncil.runningHint': '구성원에게 동시 질의 중, 이후 종합…',
'modelCouncil.deliberationHeading': '평의회 심의',
'modelCouncil.deliberationHelp':
'마스코트가 공유 추론 파일 사용 중 각 에이전트의 사고 상태를 보여줍니다.',
'modelCouncil.debateRoundInstruction':
'다른 배심원들에게 응답하세요. 이견을 유지하고, 근거가 있을 때 견해를 수정하며, 현재 결론을 명시적으로 유지하세요.',
'modelCouncil.debateFinalInstruction':
'이것은 마지막 배심원 라운드입니다. 증거가 뒷받침하는 곳에서 수렴하고, 남은 이견을 지적하며, 판사에게 명확한 결론을 제시하세요.',
'modelCouncil.roundLabel': '라운드 {round}',
'modelCouncil.currentRoundThinking': '다음 라운드 진행 중…',
'modelCouncil.thinkingBadge': 'Thinking',
'modelCouncil.thinkingWithBrief': '이 관점에서 작업 중: {brief}',
'modelCouncil.thought.evidence': '증거, 가정 및 공유 추론 파일에 이미 담긴 내용을 확인 중입니다.',
'modelCouncil.thought.plan': '실질적인 경로와 실행이 막힐 수 있는 지점을 구상 중입니다.',
'modelCouncil.thought.risk': '실패 모드, 누락된 맥락, 취약한 결론을 탐색 중입니다.',
'modelCouncil.thought.tradeoffs':
'판사에게 간결한 입장을 전달하기 전에 트레이드오프를 비교 중입니다.',
'modelCouncil.thought.synthesis':
'판사가 실제 이견을 비교할 수 있도록 고유한 견해를 준비 중입니다.',
'modelCouncil.judgeWaitingBadge': 'Judge',
'modelCouncil.judgeWaitingThought': '배심원 답변을 기다린 후 공유 추론 파일을 읽어 종합합니다.',
'modelCouncil.judgeSynthesizingBadge': 'Synthesizing',
'modelCouncil.judgeSynthesizingThought':
'배심원의 실시간 사고를 읽고 최종 평의회 종합 의견을 작성 중입니다.',
'modelCouncil.errorPrefix': '평의회 실패:',
'modelCouncil.resultsHeading': '평의회 결과',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': '{model} 작성',
'modelCouncil.usageHeading': '토론 사용량',
'modelCouncil.usageEstimated':
'토론 프롬프트와 응답에서 추정된 토큰 수. 실제 가격은 평의회 RPC의 공급자 사용량이 필요합니다.',
'modelCouncil.usageEstimatedBadge': '추정 토큰',
'modelCouncil.usageInputTokens': 'Input',
'modelCouncil.usageOutputTokens': 'Output',
'modelCouncil.usageTotalTokens': 'Total',
'graphCohesion.brokerBadge': '브로커',
'graphCohesion.brokerTitle':
'구조적 공백: 이 엔티티의 이웃들은 서로 연결되어 있지 않습니다: 이들 사이의 유일한 연결고리입니다.',
+25 -277
View File
@@ -3,6 +3,31 @@ import type { TranslationMap } from './types';
// Polish (Polski) translations. Keys mirror en.ts; missing/
// English-identical values fall back to English via I18nContext.resolveEn().
const messages: TranslationMap = {
'connections.header.companion':
'Pływający towarzysz pulpitu do szybkiej, zawsze dostępnej pomocy.',
'settings.assistant.desktopCompanion': 'Towarzysz pulpitu',
'settings.assistant.desktopCompanionDesc':
'Tryb towarzyski zawsze aktywny ze skrótem w zasobniku systemowym',
'pages.settings.features.desktopCompanion': 'Towarzysz pulpitu',
'pages.settings.features.desktopCompanionDesc':
'Asystent głosowy ze świadomością ekranu: słucha, widzi, mówi, wskazuje',
'settings.companion.title': 'Towarzysz komputerowy',
'settings.companion.session': 'Sesja',
'settings.companion.activeLabel': 'Aktywna',
'settings.companion.inactiveStatus': 'Nieaktywna',
'settings.companion.stopping': 'Zatrzymywanie…',
'settings.companion.stopSession': 'Zatrzymaj sesję',
'settings.companion.starting': 'Uruchamianie…',
'settings.companion.startSession': 'Uruchom sesję',
'settings.companion.sessionId': 'ID sesji',
'settings.companion.turns': 'Tury',
'settings.companion.remaining': 'Pozostało',
'settings.companion.configuration': 'Konfiguracja',
'settings.companion.hotkey': 'Skrót klawiszowy',
'settings.companion.activationMode': 'Tryb aktywacji',
'settings.companion.sessionTtl': 'TTL sesji',
'settings.companion.screenCapture': 'Przechwytywanie ekranu',
'settings.companion.appContext': 'Kontekst aplikacji',
// Share cards (#5006).
'share.button': 'Udostępnij',
'share.modalTitle': 'Udostępnij tę chwilę',
@@ -92,10 +117,6 @@ const messages: TranslationMap = {
'connections.header.wallet': 'Saldo Twojego lokalnego portfela i fraza odzyskiwania.',
'connections.header.screen':
'Pozwól agentowi widzieć Twój ekran, aby oferować pomoc w odpowiednim czasie i kontekście.',
'connections.header.desktopAgent':
'Pozwól agentowi otwierać aplikacje i sterować Twoim pulpitem w Twoim imieniu.',
'connections.header.companion':
'Pływający towarzysz pulpitu do szybkiej, zawsze dostępnej pomocy.',
'connections.header.composioKey':
'Dodaj klucz API Composio, aby odblokować setki integracji aplikacji.',
'connections.llm.apiKeys': 'Klucze API i routing',
@@ -754,9 +775,6 @@ const messages: TranslationMap = {
'settings.assistant.backgroundActivityDesc': 'Kontroluj, jak aktywnie asystent pracuje w tle',
'settings.assistant.screenAwareness': 'Świadomość ekranu',
'settings.assistant.screenAwarenessDesc': 'Pozwól asystentowi widzieć aktywne okno',
'settings.assistant.desktopCompanion': 'Towarzysz pulpitu',
'settings.assistant.desktopCompanionDesc':
'Tryb towarzyski zawsze aktywny ze skrótem w zasobniku systemowym',
'settings.assistant.permissions': 'Uprawnienia',
'settings.assistant.permissionsDesc': 'Wybierz, co może robić asystent i gdzie może pracować',
'settings.privacySecurity.privacy': 'Prywatność',
@@ -2562,29 +2580,6 @@ const messages: TranslationMap = {
'W grach na wyłącznym pełnym ekranie nakładka nie zostanie wyświetlona: usłyszysz tylko sygnał dźwiękowy. Przełącz na pełny ekran bez ramki, aby zobaczyć nakładkę.',
'pttOverlay.listening': 'Słucham…',
'pttOverlay.idle': 'Gotowy',
'autocomplete.title': 'Autouzupełnianie',
'autocomplete.settings': 'Ustawienia',
'autocomplete.acceptWithTab': 'Akceptuj Tabem',
'autocomplete.stylePreset': 'Styl',
'autocomplete.style.balanced': 'Zrównoważony',
'autocomplete.style.concise': 'Zwięzły',
'autocomplete.style.formal': 'Formalny',
'autocomplete.style.casual': 'Swobodny',
'autocomplete.style.custom': 'Własny',
'autocomplete.disabledApps':
'Wyłączone aplikacje (jeden identyfikator pakietu/aplikacji w wierszu)',
'autocomplete.saveSettings': 'Zapisz ustawienia',
'autocomplete.saving': 'Zapisywanie…',
'autocomplete.runtime': 'Środowisko',
'autocomplete.running': 'Działa',
'autocomplete.start': 'Uruchom',
'autocomplete.stop': 'Zatrzymaj',
'autocomplete.settingsSaved': 'Ustawienia autouzupełniania zapisane.',
'autocomplete.started': 'Autouzupełnianie uruchomione.',
'autocomplete.didNotStart': 'Autouzupełnianie nie wystartowało. Sprawdź, czy jest włączone.',
'autocomplete.stopped': 'Autouzupełnianie zatrzymane.',
'autocomplete.advancedSettings': 'Ustawienia zaawansowane',
'autocomplete.debugTitle': 'Debug autouzupełniania',
'chat.agentChat': 'Czat z agentem',
'chat.overrides': 'Nadpisania',
'chat.model': 'Model',
@@ -4565,9 +4560,6 @@ const messages: TranslationMap = {
'settings.developerMenu.composio.title': 'Composio',
'settings.developerMenu.composio.desc':
'Tryb trasowania, wyzwalacze integracji i archiwum historii wyzwalaczy.',
'pages.settings.features.desktopCompanion': 'Towarzysz pulpitu',
'pages.settings.features.desktopCompanionDesc':
'Asystent głosowy ze świadomością ekranu: słucha, widzi, mówi, wskazuje',
'pages.settings.features.messagingChannels': 'Kanały komunikacji',
'pages.settings.features.messagingChannelsDesc':
'Konfiguruj kanały takie jak Telegram, Discord i Slack',
@@ -4994,64 +4986,6 @@ const messages: TranslationMap = {
'settings.ai.workload.changeModel': 'Zmień model',
'settings.ai.workload.chooseModel': 'Wybierz model',
'settings.ai.provider.ollama': 'Ollama',
'settings.autocomplete.appFilter.acceptSuggestion': 'Akceptuj sugestię',
'settings.autocomplete.appFilter.app': 'Aplikacja',
'settings.autocomplete.appFilter.currentSuggestion': 'Bieżąca sugestia',
'settings.autocomplete.appFilter.contextOverride': 'Nadpisanie kontekstu (opcjonalne)',
'settings.autocomplete.appFilter.debounce': 'Opóźnienie',
'settings.autocomplete.appFilter.debugFocus': 'Debug fokusa',
'settings.autocomplete.appFilter.enabled': 'Włączone',
'settings.autocomplete.appFilter.getSuggestion': 'Pobierz sugestię',
'settings.autocomplete.appFilter.lastError': 'Ostatni błąd',
'settings.autocomplete.appFilter.liveLogs': 'Dziennik na żywo',
'settings.autocomplete.appFilter.model': 'Model',
'settings.autocomplete.appFilter.noLogs': ') :',
'settings.autocomplete.appFilter.phase': 'Faza',
'settings.autocomplete.appFilter.platformSupported': 'Platforma obsługiwana',
'settings.autocomplete.appFilter.refreshStatus': 'Odświeżanie…',
'settings.autocomplete.appFilter.refreshing': 'Odświeżanie…',
'settings.autocomplete.appFilter.running': 'Działa',
'settings.autocomplete.appFilter.runtime': 'Środowisko',
'settings.autocomplete.appFilter.test': 'Testuj',
'settings.autocomplete.completionStyle.acceptedCompletion':
'Zapisano {count} zaakceptowane uzupełnienie: używane do personalizacji przyszłych sugestii.',
'settings.autocomplete.completionStyle.acceptedCompletions':
'Zapisano {count} zaakceptowanych uzupełnień: używane do personalizacji przyszłych sugestii.',
'settings.autocomplete.completionStyle.clearHistory': 'Czyszczenie…',
'settings.autocomplete.completionStyle.clearing': 'Czyszczenie…',
'settings.autocomplete.completionStyle.debounce': 'Opóźnienie debounce (ms)',
'settings.autocomplete.completionStyle.enabled': 'Włączone',
'settings.autocomplete.completionStyle.maxChars': 'Maks. znaków',
'settings.autocomplete.completionStyle.noHistory':
'Brak zaakceptowanych uzupełnień. Akceptuj sugestie Tabem, aby rozpocząć personalizację.',
'settings.autocomplete.completionStyle.overlayTtl': 'TTL nakładki (ms)',
'settings.autocomplete.completionStyle.personalizationHistory': 'Historia personalizacji',
'settings.autocomplete.completionStyle.styleExamples': 'Przykłady stylu (jeden w wierszu)',
'settings.autocomplete.completionStyle.styleInstructions': 'Instrukcje stylu',
'settings.autocomplete.debug.acceptedPrefix': 'Zaakceptowano: {value}',
'settings.autocomplete.debug.acceptFailed': 'Nie udało się zaakceptować sugestii',
'settings.autocomplete.debug.alreadyRunning': 'Autouzupełnianie już działa.',
'settings.autocomplete.debug.clearHistoryFailed': 'Nie udało się wyczyścić historii',
'settings.autocomplete.debug.didNotStart': 'Autouzupełnianie nie wystartowało.',
'settings.autocomplete.debug.disabledInSettings':
'Autouzupełnianie jest wyłączone w ustawieniach. Włącz je i zapisz najpierw.',
'settings.autocomplete.debug.fetchSuggestionFailed': 'Nie udało się pobrać bieżącej sugestii',
'settings.autocomplete.debug.inspectFocusedElementFailed':
'Nie udało się zbadać aktywnego elementu',
'settings.autocomplete.debug.loadSettingsFailed':
'Nie udało się wczytać ustawień autouzupełniania',
'settings.autocomplete.debug.noSuggestionApplied': 'Nie zastosowano sugestii.',
'settings.autocomplete.debug.noSuggestionReturned': 'Nie zwrócono sugestii.',
'settings.autocomplete.debug.refreshStatusFailed':
'Nie udało się odświeżyć statusu autouzupełniania',
'settings.autocomplete.debug.saveAdvancedSettingsFailed':
'Nie udało się zapisać ustawień zaawansowanych',
'settings.autocomplete.debug.startFailed': 'Nie udało się uruchomić autouzupełniania',
'settings.autocomplete.debug.stopFailed': 'Nie udało się zatrzymać autouzupełniania',
'settings.autocomplete.debug.suggestionPrefix': 'Sugestia: {value}',
'settings.autocomplete.shared.none': 'Brak',
'settings.autocomplete.shared.notApplicable': 'nd.',
'settings.autocomplete.shared.unknown': 'Nieznane',
'settings.billing.autoRecharge.addAmount': 'Dodaj tę kwotę',
'settings.billing.autoRecharge.addCard': 'Dodaj kartę',
'settings.billing.autoRecharge.amountHint': 'Minimalna kwota doładowania to równowartość 5 USD.',
@@ -5171,23 +5105,6 @@ const messages: TranslationMap = {
'Nie udało się zapisać. Tryb bezpośredni wymaga niepustego klucza API.',
'settings.composio.saving': 'Zapisywanie…',
'settings.composio.switching': 'Przełączanie…',
'settings.companion.title': 'Towarzysz komputerowy',
'settings.companion.session': 'Sesja',
'settings.companion.activeLabel': 'Aktywna',
'settings.companion.inactiveStatus': 'Nieaktywna',
'settings.companion.stopping': 'Zatrzymywanie…',
'settings.companion.stopSession': 'Zatrzymaj sesję',
'settings.companion.starting': 'Uruchamianie…',
'settings.companion.startSession': 'Uruchom sesję',
'settings.companion.sessionId': 'ID sesji',
'settings.companion.turns': 'Tury',
'settings.companion.remaining': 'Pozostało',
'settings.companion.configuration': 'Konfiguracja',
'settings.companion.hotkey': 'Skrót klawiszowy',
'settings.companion.activationMode': 'Tryb aktywacji',
'settings.companion.sessionTtl': 'TTL sesji',
'settings.companion.screenCapture': 'Przechwytywanie ekranu',
'settings.companion.appContext': 'Kontekst aplikacji',
'settings.cron.jobs.commandRequired': 'Polecenie jest wymagane dla zadań typu shell',
'settings.cron.jobs.createJob': 'Nowe zaplanowane zadanie',
'settings.cron.jobs.created': 'Utworzono zadanie',
@@ -5614,8 +5531,6 @@ const messages: TranslationMap = {
'Skonfiguruj zewnętrznych klientów MCP do połączenia z OpenHumanem',
'settings.developerMenu.autonomy.title': 'Autonomia agenta',
'settings.developerMenu.autonomy.desc': 'Limity szybkości akcji narzędzi i progi bezpieczeństwa',
'settings.developerMenu.autocomplete.title': 'Autouzupełnianie',
'settings.developerMenu.autocomplete.desc': 'Ustawienia autouzupełniania AI i panel debugowania',
'settings.developerMenu.voiceDebug.title': 'Głos (debugowanie)',
'settings.developerMenu.voiceDebug.desc':
'Status środowiska uruchomieniowego dyktowania głosowego i ustawienia debugowania',
@@ -6079,25 +5994,6 @@ const messages: TranslationMap = {
'settings.modelHealth.modal.apply': 'Zastosuj zamianę',
'settings.modelHealth.tag.cheaper': 'TAŃSZY',
'settings.modelHealth.tag.better': 'LEPSZY',
'settings.desktopAgent.title': 'Agent pulpitu',
'settings.desktopAgent.description':
'Sprawdź i przyznaj uprawnienia wymagane przez agenta pulpitu.',
'settings.desktopAgent.microphone': 'Mikrofon',
'settings.desktopAgent.grant': 'Przyznaj',
'settings.desktopAgent.notRequiredOnOs': 'Niewymagane w tym systemie operacyjnym',
'settings.desktopAgent.allGranted':
'Wszystko gotowe: agent pulpitu ma wszystkie wymagane uprawnienia.',
'settings.desktopAgent.recheck': 'Sprawdź ponownie',
'settings.desktopAgent.restartAndRecheck': 'Uruchom ponownie rdzeń i sprawdź ponownie',
'settings.desktopAgent.seamless.title': 'Działania agenta',
'settings.desktopAgent.seamless.label': 'Pozwól agentowi działać bez pytania',
'settings.desktopAgent.seamless.description':
'Przyznaj pełny dostęp i automatycznie zatwierdzaj działania na pulpicie (otwieranie aplikacji, sterowanie ich interfejsami), aby agent wykonywał je bez prośby o zatwierdzenie.',
'settings.desktopAgent.seamless.note':
'Tylko działania na pulpicie pomijają prośbę: uruchamianie poleceń powłoki, edycja plików, żądania sieciowe i instalowanie oprogramowania nadal pytają. Wyłączenie tego zatrzymuje automatyczne zatwierdzanie działań na pulpicie, ale poziom dostępu pozostaje bez zmian.',
'settings.desktopAgent.beta':
'Beta: agent pulpitu jest eksperymentalny i może nie zawsze działać zgodnie z oczekiwaniami.',
'settings.desktopAgent.wakeWordHint': 'Powiedz „Hey Tiny” przed poleceniem, aby obudzić agenta.',
'settings.screenIntel.permissions.accessibility': 'Dostępność',
'settings.screenIntel.permissions.grantHint': 'System otworzy okno żądania uprawnień.',
'settings.screenIntel.permissions.inputMonitoring': 'Monitorowanie wejścia',
@@ -6374,23 +6270,6 @@ const messages: TranslationMap = {
'skills.resource.tree.templates': 'Szablony',
'skills.resource.tree.empty': 'Brak dołączonych zasobów.',
'skills.search.placeholder': 'Szukaj umiejętności...',
'skills.setup.autocomplete.acceptKey': 'Klawisz akceptacji',
'skills.setup.autocomplete.activeDesc':
'Autouzupełnianie działa. Akceptuj sugestie klawiszem skrótu.',
'skills.setup.autocomplete.activeTitle': 'Autouzupełnianie jest aktywne',
'skills.setup.autocomplete.customizeSettings': 'Dostosuj ustawienia',
'skills.setup.autocomplete.debounce': 'Debounce',
'skills.setup.autocomplete.description':
'Sugestie wierszowe podczas pisania w polach tekstowych.',
'skills.setup.autocomplete.enableBtn': 'Włączanie...',
'skills.setup.autocomplete.enableError': 'Nie udało się włączyć autouzupełniania',
'skills.setup.autocomplete.enabling': 'Włączanie...',
'skills.setup.autocomplete.notSupported': 'Nieobsługiwane',
'skills.setup.autocomplete.stepEnable': 'Włącz uzupełnienia wierszowe',
'skills.setup.autocomplete.stepSuccess': 'Gotowe',
'skills.setup.autocomplete.stylePreset': 'Preset stylu',
'skills.setup.autocomplete.stylePresetValue': 'Zrównoważony (konfigurowalny później)',
'skills.setup.autocomplete.title': 'Autouzupełnianie tekstu',
'skills.setup.screenIntel.activeDesc': 'Inteligencja ekranu działa. Wybrane okna są obserwowane.',
'skills.setup.screenIntel.activeTitle': 'Inteligencja ekranu jest włączona',
'skills.setup.screenIntel.advancedSettings': 'Ustawienia zaawansowane',
@@ -6845,9 +6724,6 @@ const messages: TranslationMap = {
'settings.agents.editor.toolsDone': 'Done',
'settings.agents.editor.builtInReadonly':
'Wbudowanych agentów nie można edytować. Możesz je włączyć, wyłączyć lub zresetować z poziomu listy agentów.',
'autocomplete.debounceMs': 'Opóźnienie (ms)',
'autocomplete.maxChars': 'Maksymalna liczba znaków kontekstu',
'autocomplete.overlayTtlMs': 'Limit czasu nakładki (ms)',
'memory.tab.council': 'Council',
'activity.tabs.automations': 'Automatyzacje',
'activity.tabs.automationsDescription':
@@ -6882,134 +6758,6 @@ const messages: TranslationMap = {
'intelligence.agents.tier.chat': 'Czat',
'intelligence.agents.tier.reasoning': 'Rozumowanie',
'intelligence.agents.tier.worker': 'Wykonawca',
'modelCouncil.title': 'Rada modeli',
'modelCouncil.intro':
'Zadaj jedno pytanie, uzyskaj niezależne odpowiedzi od kilku modeli równolegle, a następnie model przewodniczącego syntetyzuje, w czym się zgadzają, gdzie się różnią i co każdy wnosi unikalnie.',
'modelCouncil.listTitle': 'Councils',
'modelCouncil.listIntro':
'Wybierz zapisaną radę, edytuj jej agentów i sędziego lub utwórz nową radę dla konkretnego rodzaju decyzji.',
'modelCouncil.addCouncil': 'Nowa rada',
'modelCouncil.openCouncil': 'Otwórz radę',
'modelCouncil.editCouncil': 'Edytuj radę',
'modelCouncil.editCurrentCouncil': 'Edytuj bieżącą radę',
'modelCouncil.editCouncilAria': 'Edytuj {name}',
'modelCouncil.deleteCouncilAria': 'Usuń {name}',
'modelCouncil.backToCouncils': 'Powrót do rad',
'modelCouncil.saveCouncil': 'Zapisz radę',
'modelCouncil.savingCouncil': 'Saving…',
'modelCouncil.cancelEdit': 'Cancel',
'modelCouncil.loadingCouncils': 'Ładowanie rad…',
'modelCouncil.noCouncils': 'Brak rad. Dodaj jedną, aby rozpocząć.',
'modelCouncil.noCouncilDescription': 'Brak opisu',
'modelCouncil.registryErrorPrefix': 'Rejestr rady nie powiódł się:',
'modelCouncil.councilNameLabel': 'Nazwa rady',
'modelCouncil.councilDescriptionLabel': 'Description',
'modelCouncil.councilDescriptionPlaceholder': 'Kiedy używać tej rady',
'modelCouncil.selectModel': 'Select',
'modelCouncil.modelPickerHelp':
'Wybierz wskazówkę routingu lub przypnij dokładny model dostawcy.',
'modelCouncil.closeModelPicker': 'Close',
'modelCouncil.hint.chat': 'Czat',
'modelCouncil.hint.code': 'Kod',
'modelCouncil.hint.default': 'Domyślny',
'modelCouncil.hint.reasoning': 'Rozumowanie',
'modelCouncil.hint.summarize': 'Podsumowanie',
'modelCouncil.modelPickerHints': 'Hints',
'modelCouncil.modelPickerProviderModel': 'Dostawca + model',
'modelCouncil.modelProviderLabel': 'Dostawca modelu',
'modelCouncil.modelIdLabel': 'ID modelu',
'modelCouncil.useProviderModel': 'Użyj modelu dostawcy',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Zadaj radzie pytanie…',
'modelCouncil.membersLabel': 'Modele członków',
'modelCouncil.maxMembersNote': 'do {max}',
'modelCouncil.memberPlaceholder': 'np. gpt-5.2',
'modelCouncil.memberAria': 'Model członka {n}',
'modelCouncil.removeMemberAria': 'Usuń model członka {n}',
'modelCouncil.addMember': '+ Dodaj model',
'modelCouncil.settingsTitle': 'Ustawienia rady',
'modelCouncil.settingsSummary': '{count} jurorów · sędzia: {judge}',
'modelCouncil.juryCountLabel': 'Liczba jurorów',
'modelCouncil.debateRoundsLabel': 'Rundy debaty',
'modelCouncil.debateRoundsHelp':
'Każdy ławnik debatuje przez wiele rund, zanim sędzia napisze końcową syntezę.',
'modelCouncil.judgeAgentLabel': 'Agent sędzia',
'modelCouncil.defaultJudge': 'Domyślny sędzia',
'modelCouncil.savedProfile': 'Zapisany profil',
'modelCouncil.customAgent': 'Niestandardowy agent',
'modelCouncil.judgeProfileLabel': 'Profil sędziego',
'modelCouncil.chooseProfile': 'Wybierz profil',
'modelCouncil.judgeNameLabel': 'Imię sędziego',
'modelCouncil.judgeNamePlaceholder': 'Główny sędzia',
'modelCouncil.rosterHeading': 'Skład rady',
'modelCouncil.rosterHelp':
'Każdy juror może dziedziczyć domyślnego agenta, użyć zapisanego profilu albo stać się profilem niestandardowym dla tego uruchomienia.',
'modelCouncil.loadingProfiles': 'Ładowanie profili…',
'modelCouncil.jurorLabel': 'Ławnik {n}',
'modelCouncil.profileModeLabel': 'Źródło profilu',
'modelCouncil.mode.default': 'Domyślny',
'modelCouncil.mode.profile': 'Profil',
'modelCouncil.mode.custom': 'Własny',
'modelCouncil.memberProfileAria': 'Profil jurora {n}',
'modelCouncil.memberNameAria': 'Imię jurora {n}',
'modelCouncil.memberNamePlaceholder': 'Nazwa agenta',
'modelCouncil.profileModelPlaceholder': 'Nadpisz model profilu',
'modelCouncil.memberBriefAria': 'Brief jurora {n}',
'modelCouncil.memberBriefPlaceholder': 'Perspektywa tego jurora',
'modelCouncil.jurorFallback': 'Juror',
'modelCouncil.sharedReasoningLabel': 'Wspólny plik rozumowania',
'modelCouncil.sharedReasoningHelp':
'Rada pracuje na tym wspólnym szkicu, zanim sędzia napisze syntezę.',
'modelCouncil.liveScratchpadHelp':
'Aktualizacje ławników są tutaj dodawane po każdej rundzie i zasilają następną rundę.',
'modelCouncil.liveScratchpadBadge': 'Live',
'modelCouncil.scratchpadRoundHeading': 'Aktualizacje rundy {round}',
'modelCouncil.scratchpadNoResponse': 'Brak odpowiedzi',
'modelCouncil.chairLabel': 'Model przewodniczącego',
'modelCouncil.chairPlaceholder': 'np. claude-opus-4-8',
'modelCouncil.chairHelp': 'Przewodniczący czyta każdą odpowiedź członków i pisze syntezę.',
'modelCouncil.run': 'Zwołaj radę',
'modelCouncil.running': 'Convening…',
'modelCouncil.runningHint': 'Odpytywanie członków równolegle, następnie synteza…',
'modelCouncil.deliberationHeading': 'Obrady rady',
'modelCouncil.deliberationHelp':
'Maskotki pokazują myślenie każdego agenta, gdy wspólny plik rozumowania jest w użyciu.',
'modelCouncil.debateRoundInstruction':
'Odpowiedz innym ławnikom. Zachowaj rozbieżności, zrewiduj swój pogląd, gdy to uzasadnione, i utrzymaj swój aktualny wniosek w sposób jawny.',
'modelCouncil.debateFinalInstruction':
'To Twoja ostatnia runda ławnika. Zbieżność tam, gdzie dowody to wspierają, wskaż pozostałe rozbieżności i przekaż sędziemu jasny wniosek.',
'modelCouncil.roundLabel': 'Runda {round}',
'modelCouncil.currentRoundThinking': 'Kontynuowanie następnej rundy…',
'modelCouncil.thinkingBadge': 'Thinking',
'modelCouncil.thinkingWithBrief': 'Praca z tej perspektywy: {brief}',
'modelCouncil.thought.evidence':
'Sprawdzanie dowodów, założeń i tego, co wspólny plik rozumowania już zawiera.',
'modelCouncil.thought.plan':
'Szkicowanie praktycznej ścieżki i miejsc, gdzie realizacja może utknąć.',
'modelCouncil.thought.risk': 'Szukanie trybów awarii, brakującego kontekstu i kruchych wniosków.',
'modelCouncil.thought.tradeoffs':
'Porównywanie kompromisów przed przekazaniem sędziemu zwięzłego stanowiska.',
'modelCouncil.thought.synthesis':
'Przygotowanie odrębnego punktu widzenia, aby sędzia mógł porównać rzeczywiste rozbieżności.',
'modelCouncil.judgeWaitingBadge': 'Judge',
'modelCouncil.judgeWaitingThought':
'Oczekiwanie na odpowiedzi ławników, następnie czytanie wspólnego pliku rozumowania w celu syntezy.',
'modelCouncil.judgeSynthesizingBadge': 'Synthesizing',
'modelCouncil.judgeSynthesizingThought':
'Czytanie myśli ławników na żywo i pisanie końcowej syntezy rady.',
'modelCouncil.errorPrefix': 'Rada nie powiodła się:',
'modelCouncil.resultsHeading': 'Wyniki rady',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'przez {model}',
'modelCouncil.usageHeading': 'Użycie debaty',
'modelCouncil.usageEstimated':
'Szacunkowa liczba tokenów z promptów i odpowiedzi debaty. Rzeczywista wycena wymaga danych o użyciu dostawcy z RPC rady.',
'modelCouncil.usageEstimatedBadge': 'Szacunkowe tokeny',
'modelCouncil.usageInputTokens': 'Input',
'modelCouncil.usageOutputTokens': 'Output',
'modelCouncil.usageTotalTokens': 'Total',
'graphCohesion.brokerBadge': 'broker',
'graphCohesion.brokerTitle':
'Dziura strukturalna: sąsiedzi tej encji nie są ze sobą połączeni: jest ona jedynym łącznikiem między nimi.',
+25 -275
View File
@@ -3,6 +3,31 @@ import type { TranslationMap } from './types';
// Portuguese (Português) translations. Keys mirror en.ts; missing/
// English-identical values fall back to English via I18nContext.resolveEn().
const messages: TranslationMap = {
'connections.header.companion':
'Um companheiro de ambiente de trabalho flutuante para ajuda rápida e sempre disponível.',
'settings.assistant.desktopCompanion': 'Companheiro de área de trabalho',
'settings.assistant.desktopCompanionDesc':
'Modo companheiro sempre ativo com atalho na bandeja do sistema',
'pages.settings.features.desktopCompanion': 'Companion Desktop',
'pages.settings.features.desktopCompanionDesc':
'Assistente de voz com consciência da tela: escuta, vê, fala, aponta',
'settings.companion.title': 'Companheiro de Área de Trabalho',
'settings.companion.session': 'Sessão',
'settings.companion.activeLabel': 'Ativo',
'settings.companion.inactiveStatus': 'Inativo',
'settings.companion.stopping': 'Parando…',
'settings.companion.stopSession': 'Interromper sessão',
'settings.companion.starting': 'Iniciando…',
'settings.companion.startSession': 'Iniciar sessão',
'settings.companion.sessionId': 'ID da sessão',
'settings.companion.turns': 'Turnos',
'settings.companion.remaining': 'Restante',
'settings.companion.configuration': 'Configuração',
'settings.companion.hotkey': 'Tecla de atalho',
'settings.companion.activationMode': 'Modo de ativação',
'settings.companion.sessionTtl': 'Sessão TTL',
'settings.companion.screenCapture': 'Captura de tela',
'settings.companion.appContext': 'Contexto do aplicativo',
// Share cards (#5006).
'share.button': 'Partilhar',
'share.modalTitle': 'Partilhe este momento',
@@ -91,10 +116,6 @@ const messages: TranslationMap = {
'connections.header.wallet': 'O saldo da sua carteira local e a sua frase de recuperação.',
'connections.header.screen':
'Deixe o seu agente ver o seu ecrã para oferecer ajuda oportuna e contextual.',
'connections.header.desktopAgent':
'Deixe o seu agente abrir aplicações e controlar o seu ambiente de trabalho em seu nome.',
'connections.header.companion':
'Um companheiro de ambiente de trabalho flutuante para ajuda rápida e sempre disponível.',
'connections.header.composioKey':
'Adicione uma chave de API do Composio para desbloquear centenas de integrações de aplicações.',
'connections.llm.apiKeys': 'Chaves de API e encaminhamento',
@@ -750,9 +771,6 @@ const messages: TranslationMap = {
'Controle o quão ativamente seu assistente trabalha em segundo plano',
'settings.assistant.screenAwareness': 'Consciência de tela',
'settings.assistant.screenAwarenessDesc': 'Permitir que o assistente veja sua janela ativa',
'settings.assistant.desktopCompanion': 'Companheiro de área de trabalho',
'settings.assistant.desktopCompanionDesc':
'Modo companheiro sempre ativo com atalho na bandeja do sistema',
'settings.assistant.permissions': 'Permissões',
'settings.assistant.permissionsDesc':
'Escolha o que o assistente pode fazer e onde pode trabalhar',
@@ -2574,28 +2592,6 @@ const messages: TranslationMap = {
'Em jogos no modo de ecrã inteiro exclusivo a sobreposição não será apresentada: só ouvirá o aviso sonoro. Mude para ecrã inteiro sem margens para ver a sobreposição.',
'pttOverlay.listening': 'A escutar…',
'pttOverlay.idle': 'Inativo',
'autocomplete.title': 'Autocompletar',
'autocomplete.settings': 'Configurações',
'autocomplete.acceptWithTab': 'Aceitar com Tab',
'autocomplete.stylePreset': 'Predefinição de Estilo',
'autocomplete.style.balanced': 'Equilibrado',
'autocomplete.style.concise': 'Conciso',
'autocomplete.style.formal': 'Formal',
'autocomplete.style.casual': 'Casual',
'autocomplete.style.custom': 'Personalizado',
'autocomplete.disabledApps': 'Apps Desativados (um bundle/token de app por linha)',
'autocomplete.saveSettings': 'Salvar Configurações',
'autocomplete.saving': 'Salvando…',
'autocomplete.runtime': 'Tempo de execução',
'autocomplete.running': 'Rodando',
'autocomplete.start': 'Iniciar',
'autocomplete.stop': 'Parar',
'autocomplete.settingsSaved': 'Configurações de autocompletar salvas.',
'autocomplete.started': 'Autocompletar iniciado.',
'autocomplete.didNotStart': 'O autocompletar não iniciou. Verifique se está ativado.',
'autocomplete.stopped': 'Autocompletar parado.',
'autocomplete.advancedSettings': 'Configurações avançadas',
'autocomplete.debugTitle': 'Depuração de Autocompletar',
'chat.agentChat': 'Chat com Agente',
'chat.overrides': 'Substituições',
'chat.model': 'Modelo',
@@ -4565,9 +4561,6 @@ const messages: TranslationMap = {
'settings.developerMenu.composio.title': 'Composio',
'settings.developerMenu.composio.desc':
'Modo de roteamento, gatilhos de integração e arquivo de histórico de gatilhos.',
'pages.settings.features.desktopCompanion': 'Companion Desktop',
'pages.settings.features.desktopCompanionDesc':
'Assistente de voz com consciência da tela: escuta, vê, fala, aponta',
'pages.settings.features.messagingChannels': 'Canais de mensagens',
'pages.settings.features.messagingChannelsDesc': 'Descrição dos canais de mensagens',
'pages.settings.features.notifications': 'Notificações',
@@ -4991,64 +4984,6 @@ const messages: TranslationMap = {
'settings.ai.workload.changeModel': 'Alterar modelo',
'settings.ai.workload.chooseModel': 'Escolher modelo',
'settings.ai.provider.ollama': 'Ollama',
'settings.autocomplete.appFilter.acceptSuggestion': 'Aceitar sugestão',
'settings.autocomplete.appFilter.app': 'Aplicativo',
'settings.autocomplete.appFilter.currentSuggestion': 'Sugestão atual',
'settings.autocomplete.appFilter.contextOverride': 'Substituição de Contexto (opcional)',
'settings.autocomplete.appFilter.debounce': 'Desacelerar',
'settings.autocomplete.appFilter.debugFocus': 'Foco de depuração',
'settings.autocomplete.appFilter.enabled': 'Habilitado',
'settings.autocomplete.appFilter.getSuggestion': 'Obter sugestão',
'settings.autocomplete.appFilter.lastError': 'Último erro',
'settings.autocomplete.appFilter.liveLogs': 'Logs ao Vivo',
'settings.autocomplete.appFilter.model': 'Modelo',
'settings.autocomplete.appFilter.noLogs': '):',
'settings.autocomplete.appFilter.phase': 'Fase',
'settings.autocomplete.appFilter.platformSupported': 'Plataforma suportada',
'settings.autocomplete.appFilter.refreshStatus': 'Atualizando…',
'settings.autocomplete.appFilter.refreshing': 'Atualizando…',
'settings.autocomplete.appFilter.running': 'Em execução',
'settings.autocomplete.appFilter.runtime': 'Tempo de execução',
'settings.autocomplete.appFilter.test': 'Testar',
'settings.autocomplete.completionStyle.acceptedCompletion':
'{count} complemento aceito armazenado: usado para personalizar sugestões futuras.',
'settings.autocomplete.completionStyle.acceptedCompletions':
'{count} complementos aceitos armazenados: usados para personalizar sugestões futuras.',
'settings.autocomplete.completionStyle.clearHistory': 'Limpando…',
'settings.autocomplete.completionStyle.clearing': 'Limpando…',
'settings.autocomplete.completionStyle.debounce': 'Atraso de resposta (ms)',
'settings.autocomplete.completionStyle.enabled': 'Ativado',
'settings.autocomplete.completionStyle.maxChars': 'Máx. de Caracteres',
'settings.autocomplete.completionStyle.noHistory':
'Nenhuma conclusão aceita ainda. Aceite sugestões com Tab para começar a personalizar.',
'settings.autocomplete.completionStyle.overlayTtl': 'TTL do Overlay (ms)',
'settings.autocomplete.completionStyle.personalizationHistory': 'Histórico de Personalização',
'settings.autocomplete.completionStyle.styleExamples': 'Exemplos de Estilo (um por linha)',
'settings.autocomplete.completionStyle.styleInstructions': 'Instruções de Estilo',
'settings.autocomplete.debug.acceptedPrefix': 'Aceito: {value}',
'settings.autocomplete.debug.acceptFailed': 'Falha ao aceitar sugestão',
'settings.autocomplete.debug.alreadyRunning': 'O preenchimento automático já está em execução.',
'settings.autocomplete.debug.clearHistoryFailed': 'Falha ao limpar o histórico',
'settings.autocomplete.debug.didNotStart': 'O preenchimento automático não foi iniciado.',
'settings.autocomplete.debug.disabledInSettings':
'O preenchimento automático está desativado nas configurações. Habilite-o e salve primeiro.',
'settings.autocomplete.debug.fetchSuggestionFailed': 'Falha ao buscar a sugestão atual',
'settings.autocomplete.debug.inspectFocusedElementFailed':
'Falha ao inspecionar o elemento em foco',
'settings.autocomplete.debug.loadSettingsFailed':
'Falha ao carregar as configurações de preenchimento automático',
'settings.autocomplete.debug.noSuggestionApplied': 'Nenhuma sugestão foi aplicada.',
'settings.autocomplete.debug.noSuggestionReturned': 'Nenhuma sugestão foi retornada.',
'settings.autocomplete.debug.refreshStatusFailed':
'Falha ao atualizar o status do preenchimento automático',
'settings.autocomplete.debug.saveAdvancedSettingsFailed':
'Falha ao salvar as configurações avançadas',
'settings.autocomplete.debug.startFailed': 'Falha ao iniciar o preenchimento automático',
'settings.autocomplete.debug.stopFailed': 'Falha ao interromper o preenchimento automático',
'settings.autocomplete.debug.suggestionPrefix': 'Sugestão: {value}',
'settings.autocomplete.shared.none': 'Nenhum',
'settings.autocomplete.shared.notApplicable': 'n/a',
'settings.autocomplete.shared.unknown': 'Desconhecido',
'settings.billing.autoRecharge.addAmount': 'Adicionar este valor',
'settings.billing.autoRecharge.addCard': 'Adicionar cartão',
'settings.billing.autoRecharge.amountHint': 'Dica de valor',
@@ -5164,23 +5099,6 @@ const messages: TranslationMap = {
'Falha ao salvar. O modo Direto requer uma chave de API não vazia.',
'settings.composio.saving': 'Salvando…',
'settings.composio.switching': 'Alternando…',
'settings.companion.title': 'Companheiro de Área de Trabalho',
'settings.companion.session': 'Sessão',
'settings.companion.activeLabel': 'Ativo',
'settings.companion.inactiveStatus': 'Inativo',
'settings.companion.stopping': 'Parando…',
'settings.companion.stopSession': 'Interromper sessão',
'settings.companion.starting': 'Iniciando…',
'settings.companion.startSession': 'Iniciar sessão',
'settings.companion.sessionId': 'ID da sessão',
'settings.companion.turns': 'Turnos',
'settings.companion.remaining': 'Restante',
'settings.companion.configuration': 'Configuração',
'settings.companion.hotkey': 'Tecla de atalho',
'settings.companion.activationMode': 'Modo de ativação',
'settings.companion.sessionTtl': 'Sessão TTL',
'settings.companion.screenCapture': 'Captura de tela',
'settings.companion.appContext': 'Contexto do aplicativo',
'settings.cron.jobs.commandRequired': 'O comando é obrigatório para tarefas shell',
'settings.cron.jobs.createJob': 'Nova tarefa agendada',
'settings.cron.jobs.created': 'Tarefa criada',
@@ -5609,9 +5527,6 @@ const messages: TranslationMap = {
'settings.developerMenu.autonomy.title': 'Autonomia do agente',
'settings.developerMenu.autonomy.desc':
'Limites de taxa de ações de ferramentas e limites de segurança',
'settings.developerMenu.autocomplete.title': 'Preenchimento automático',
'settings.developerMenu.autocomplete.desc':
'Configurações de preenchimento automático de IA embutido e painel de depuração',
'settings.developerMenu.voiceDebug.title': 'Voz (depuração)',
'settings.developerMenu.voiceDebug.desc':
'Status de tempo de execução do ditado de voz e configurações de depuração',
@@ -6085,26 +6000,6 @@ const messages: TranslationMap = {
'settings.modelHealth.modal.apply': 'Aplicar Substituição',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
'settings.desktopAgent.title': 'Agente de desktop',
'settings.desktopAgent.description':
'Verifique e conceda as permissões necessárias para o agente de desktop.',
'settings.desktopAgent.microphone': 'Microfone',
'settings.desktopAgent.grant': 'Conceder',
'settings.desktopAgent.notRequiredOnOs': 'Não necessário neste sistema operacional',
'settings.desktopAgent.allGranted':
'Tudo pronto: o agente de desktop tem todas as permissões necessárias.',
'settings.desktopAgent.recheck': 'Verificar novamente',
'settings.desktopAgent.restartAndRecheck': 'Reiniciar o núcleo e verificar novamente',
'settings.desktopAgent.seamless.title': 'Ações do agente',
'settings.desktopAgent.seamless.label': 'Deixe o agente agir sem perguntar',
'settings.desktopAgent.seamless.description':
'Conceda acesso total e aprove automaticamente as ações de área de trabalho (abrir aplicativos, controlar suas interfaces) para que o agente as execute sem pedir confirmação.',
'settings.desktopAgent.seamless.note':
'Apenas as ações de área de trabalho ignoram a confirmação: executar comandos de shell, editar arquivos, solicitações de rede e instalar software continuam perguntando. Desativar isto interrompe a aprovação automática das ações de área de trabalho, mas mantém o nível de acesso inalterado.',
'settings.desktopAgent.beta':
'Beta: o agente de desktop é experimental e pode nem sempre funcionar como esperado.',
'settings.desktopAgent.wakeWordHint':
'Diga “Hey Tiny” antes do seu comando para ativar o agente.',
'settings.screenIntel.permissions.accessibility': 'Acessibilidade',
'settings.screenIntel.permissions.grantHint': 'Dica de concessão',
'settings.screenIntel.permissions.inputMonitoring': 'Monitoramento de Entrada',
@@ -6380,21 +6275,6 @@ const messages: TranslationMap = {
'skills.resource.tree.templates': 'Modelos',
'skills.resource.tree.empty': 'Sem recursos incluídos.',
'skills.search.placeholder': 'Espaço reservado',
'skills.setup.autocomplete.acceptKey': 'Tecla de aceitar',
'skills.setup.autocomplete.activeDesc': 'Descrição ativo',
'skills.setup.autocomplete.activeTitle': 'Auto-Complete está Ativo',
'skills.setup.autocomplete.customizeSettings': 'Personalizar configurações',
'skills.setup.autocomplete.debounce': 'Debounce',
'skills.setup.autocomplete.description': 'Descrição',
'skills.setup.autocomplete.enableBtn': 'Ativando...',
'skills.setup.autocomplete.enableError': 'Falha ao ativar autocompletar',
'skills.setup.autocomplete.enabling': 'Ativando...',
'skills.setup.autocomplete.notSupported': 'Não suportado',
'skills.setup.autocomplete.stepEnable': 'Ativar conclusões inline',
'skills.setup.autocomplete.stepSuccess': 'Pronto para usar',
'skills.setup.autocomplete.stylePreset': 'Predefinição de estilo',
'skills.setup.autocomplete.stylePresetValue': 'Equilibrado (configurável depois)',
'skills.setup.autocomplete.title': 'Auto-Complete de Texto',
'skills.setup.screenIntel.activeDesc': 'Descrição ativo',
'skills.setup.screenIntel.activeTitle': 'Inteligência de Tela está Ativada',
'skills.setup.screenIntel.advancedSettings': 'Configurações avançadas',
@@ -6883,9 +6763,6 @@ const messages: TranslationMap = {
'chat.files.error.resolve_failed': 'Não foi possível resolver o artefato. Tente novamente.',
'chat.files.error.download_failed': 'O download falhou. Tente novamente.',
'chat.files.error.delete_failed': 'Não foi possível excluir o arquivo. Tente novamente.',
'autocomplete.debounceMs': 'Atraso (ms)',
'autocomplete.maxChars': 'Máximo de caracteres de contexto',
'autocomplete.overlayTtlMs': 'Tempo limite da sobreposição (ms)',
'memory.tab.council': 'Council',
'activity.tabs.automations': 'Automações',
'activity.tabs.automationsDescription':
@@ -6920,133 +6797,6 @@ const messages: TranslationMap = {
'intelligence.agents.tier.chat': 'Chat',
'intelligence.agents.tier.reasoning': 'Raciocínio',
'intelligence.agents.tier.worker': 'Trabalhador',
'modelCouncil.title': 'Conselho de Modelos',
'modelCouncil.intro':
'Faça uma pergunta, obtenha respostas independentes de vários modelos em paralelo, e então o modelo presidente sintetiza onde concordam, onde discordam e o que cada um adiciona de único.',
'modelCouncil.listTitle': 'Councils',
'modelCouncil.listIntro':
'Escolha um conselho salvo, edite seus agentes e juiz, ou crie um novo para um tipo específico de decisão.',
'modelCouncil.addCouncil': 'Novo conselho',
'modelCouncil.openCouncil': 'Abrir conselho',
'modelCouncil.editCouncil': 'Editar conselho',
'modelCouncil.editCurrentCouncil': 'Editar conselho atual',
'modelCouncil.editCouncilAria': 'Editar {name}',
'modelCouncil.deleteCouncilAria': 'Excluir {name}',
'modelCouncil.backToCouncils': 'Voltar aos conselhos',
'modelCouncil.saveCouncil': 'Salvar conselho',
'modelCouncil.savingCouncil': 'Saving…',
'modelCouncil.cancelEdit': 'Cancel',
'modelCouncil.loadingCouncils': 'Carregando conselhos…',
'modelCouncil.noCouncils': 'Nenhum conselho ainda. Adicione um para começar.',
'modelCouncil.noCouncilDescription': 'Sem descrição',
'modelCouncil.registryErrorPrefix': 'O registro do conselho falhou:',
'modelCouncil.councilNameLabel': 'Nome do conselho',
'modelCouncil.councilDescriptionLabel': 'Description',
'modelCouncil.councilDescriptionPlaceholder': 'Quando usar este conselho',
'modelCouncil.selectModel': 'Select',
'modelCouncil.modelPickerHelp':
'Selecione uma dica de roteamento ou fixe um modelo de provedor exato.',
'modelCouncil.closeModelPicker': 'Close',
'modelCouncil.hint.chat': 'Bate-papo',
'modelCouncil.hint.code': 'Código',
'modelCouncil.hint.default': 'Padrão',
'modelCouncil.hint.reasoning': 'Raciocínio',
'modelCouncil.hint.summarize': 'Resumo',
'modelCouncil.modelPickerHints': 'Hints',
'modelCouncil.modelPickerProviderModel': 'Provedor + modelo',
'modelCouncil.modelProviderLabel': 'Provedor do modelo',
'modelCouncil.modelIdLabel': 'ID do modelo',
'modelCouncil.useProviderModel': 'Usar modelo do provedor',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Faça uma pergunta ao conselho…',
'modelCouncil.membersLabel': 'Modelos membros',
'modelCouncil.maxMembersNote': 'até {max}',
'modelCouncil.memberPlaceholder': 'ex. gpt-5.2',
'modelCouncil.memberAria': 'Modelo membro {n}',
'modelCouncil.removeMemberAria': 'Remover modelo membro {n}',
'modelCouncil.addMember': '+ Adicionar modelo',
'modelCouncil.settingsTitle': 'Configurações do conselho',
'modelCouncil.settingsSummary': '{count} jurados · juiz: {judge}',
'modelCouncil.juryCountLabel': 'Quantidade de jurados',
'modelCouncil.debateRoundsLabel': 'Rodadas de debate',
'modelCouncil.debateRoundsHelp':
'Cada jurado continua debatendo por várias rodadas antes de o juiz escrever a síntese final.',
'modelCouncil.judgeAgentLabel': 'Agente juiz',
'modelCouncil.defaultJudge': 'Juiz padrão',
'modelCouncil.savedProfile': 'Perfil salvo',
'modelCouncil.customAgent': 'Agente personalizado',
'modelCouncil.judgeProfileLabel': 'Perfil do juiz',
'modelCouncil.chooseProfile': 'Escolha um perfil',
'modelCouncil.judgeNameLabel': 'Nome do juiz',
'modelCouncil.judgeNamePlaceholder': 'Juiz principal',
'modelCouncil.rosterHeading': 'Lista do conselho',
'modelCouncil.rosterHelp':
'Cada jurado pode herdar o agente padrão, usar um perfil salvo ou virar um perfil personalizado para esta execução.',
'modelCouncil.loadingProfiles': 'Carregando perfis…',
'modelCouncil.jurorLabel': 'Jurado {n}',
'modelCouncil.profileModeLabel': 'Origem do perfil',
'modelCouncil.mode.default': 'Padrão',
'modelCouncil.mode.profile': 'Perfil',
'modelCouncil.mode.custom': 'Personalizado',
'modelCouncil.memberProfileAria': 'Perfil do jurado {n}',
'modelCouncil.memberNameAria': 'Nome do jurado {n}',
'modelCouncil.memberNamePlaceholder': 'Nome do agente',
'modelCouncil.profileModelPlaceholder': 'Substituir modelo do perfil',
'modelCouncil.memberBriefAria': 'Brief do jurado {n}',
'modelCouncil.memberBriefPlaceholder': 'Perspectiva deste jurado',
'modelCouncil.jurorFallback': 'Jurado',
'modelCouncil.sharedReasoningLabel': 'Arquivo de raciocínio compartilhado',
'modelCouncil.sharedReasoningHelp':
'O conselho trabalha neste rascunho compartilhado antes de o juiz escrever a síntese.',
'modelCouncil.liveScratchpadHelp':
'As atualizações dos jurados são adicionadas aqui após cada rodada e alimentam a rodada seguinte.',
'modelCouncil.liveScratchpadBadge': 'Live',
'modelCouncil.scratchpadRoundHeading': 'Atualizações da rodada {round}',
'modelCouncil.scratchpadNoResponse': 'Sem resposta',
'modelCouncil.chairLabel': 'Modelo presidente',
'modelCouncil.chairPlaceholder': 'ex. claude-opus-4-8',
'modelCouncil.chairHelp': 'O presidente lê cada resposta dos membros e escreve a síntese.',
'modelCouncil.run': 'Convocar conselho',
'modelCouncil.running': 'Convening…',
'modelCouncil.runningHint': 'Consultando membros em paralelo, depois sintetizando…',
'modelCouncil.deliberationHeading': 'Deliberação do conselho',
'modelCouncil.deliberationHelp':
'Os mascotes mostram cada agente pensando enquanto o arquivo de raciocínio compartilhado está em uso.',
'modelCouncil.debateRoundInstruction':
'Responda aos outros jurados. Preserve as divergências, revise seu ponto de vista quando justificado e mantenha sua conclusão atual explícita.',
'modelCouncil.debateFinalInstruction':
'Esta é sua última rodada como jurado. Convirja onde as evidências sustentam, aponte divergências restantes e entregue ao juiz uma conclusão clara.',
'modelCouncil.roundLabel': 'Rodada {round}',
'modelCouncil.currentRoundThinking': 'Continuando a próxima rodada…',
'modelCouncil.thinkingBadge': 'Thinking',
'modelCouncil.thinkingWithBrief': 'Trabalhando a partir desta perspectiva: {brief}',
'modelCouncil.thought.evidence':
'Verificando evidências, suposições e o que o arquivo de raciocínio compartilhado já contém.',
'modelCouncil.thought.plan': 'Esboçando o caminho prático e onde a execução pode travar.',
'modelCouncil.thought.risk': 'Procurando modos de falha, contexto ausente e conclusões frágeis.',
'modelCouncil.thought.tradeoffs':
'Comparando compensações antes de entregar ao juiz uma posição concisa.',
'modelCouncil.thought.synthesis':
'Preparando uma visão distinta para que o juiz compare as divergências reais.',
'modelCouncil.judgeWaitingBadge': 'Judge',
'modelCouncil.judgeWaitingThought':
'Aguardando as respostas dos jurados, depois lendo o arquivo de raciocínio compartilhado para sintetizar.',
'modelCouncil.judgeSynthesizingBadge': 'Synthesizing',
'modelCouncil.judgeSynthesizingThought':
'Lendo os pensamentos ao vivo dos jurados e escrevendo a síntese final do conselho.',
'modelCouncil.errorPrefix': 'O conselho falhou:',
'modelCouncil.resultsHeading': 'Resultados do conselho',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'por {model}',
'modelCouncil.usageHeading': 'Uso do debate',
'modelCouncil.usageEstimated':
'Contagens estimadas de tokens dos prompts e respostas do debate. A precificação real requer o uso do provedor via RPC do conselho.',
'modelCouncil.usageEstimatedBadge': 'Tokens estimados',
'modelCouncil.usageInputTokens': 'Input',
'modelCouncil.usageOutputTokens': 'Output',
'modelCouncil.usageTotalTokens': 'Total',
'graphCohesion.brokerBadge': 'intermediador',
'graphCohesion.brokerTitle':
'Buraco estrutural: os vizinhos desta entidade não estão conectados entre si: ela é a única ligação entre eles.',
+25 -274
View File
@@ -3,6 +3,31 @@ import type { TranslationMap } from './types';
// Russian (Русский) translations. Keys mirror en.ts; missing/
// English-identical values fall back to English via I18nContext.resolveEn().
const messages: TranslationMap = {
'connections.header.companion':
'Плавающий помощник на рабочем столе для быстрой, всегда доступной помощи.',
'settings.assistant.desktopCompanion': 'Компаньон рабочего стола',
'settings.assistant.desktopCompanionDesc':
'Режим постоянного компаньона с ярлыком в системном лотке',
'pages.settings.features.desktopCompanion': 'Десктоп-спутник',
'pages.settings.features.desktopCompanionDesc':
'Голосовой ассистент с распознаванием экрана: слушает, видит, говорит, указывает',
'settings.companion.title': 'Помощник на рабочем столе',
'settings.companion.session': 'Сеанс',
'settings.companion.activeLabel': 'Активен',
'settings.companion.inactiveStatus': 'Неактивен',
'settings.companion.stopping': 'Остановка…',
'settings.companion.stopSession': 'Остановить сеанс',
'settings.companion.starting': 'Запуск…',
'settings.companion.startSession': 'Запустить сеанс',
'settings.companion.sessionId': 'Идентификатор сеанса',
'settings.companion.turns': 'Ходы',
'settings.companion.remaining': 'Осталось',
'settings.companion.configuration': 'Конфигурация',
'settings.companion.hotkey': 'Горячая клавиша',
'settings.companion.activationMode': 'Режим активации',
'settings.companion.sessionTtl': 'TTL сеанса',
'settings.companion.screenCapture': 'Снимок экрана',
'settings.companion.appContext': 'Контекст приложения',
// Share cards (#5006).
'share.button': 'Поделиться',
'share.modalTitle': 'Поделитесь этим моментом',
@@ -89,10 +114,6 @@ const messages: TranslationMap = {
'connections.header.wallet': 'Баланс вашего локального кошелька и фраза восстановления.',
'connections.header.screen':
'Позвольте агенту видеть ваш экран, чтобы предлагать своевременную контекстную помощь.',
'connections.header.desktopAgent':
'Позвольте агенту открывать приложения и управлять вашим рабочим столом от вашего имени.',
'connections.header.companion':
'Плавающий помощник на рабочем столе для быстрой, всегда доступной помощи.',
'connections.header.composioKey':
'Добавьте ключ API Composio, чтобы разблокировать сотни интеграций приложений.',
'connections.llm.apiKeys': 'Ключи API и маршрутизация',
@@ -748,9 +769,6 @@ const messages: TranslationMap = {
'Управление тем, насколько активно ассистент работает в фоне',
'settings.assistant.screenAwareness': 'Осведомлённость об экране',
'settings.assistant.screenAwarenessDesc': 'Разрешить ассистенту видеть активное окно',
'settings.assistant.desktopCompanion': 'Компаньон рабочего стола',
'settings.assistant.desktopCompanionDesc':
'Режим постоянного компаньона с ярлыком в системном лотке',
'settings.assistant.permissions': 'Разрешения',
'settings.assistant.permissionsDesc':
'Выберите, что может делать помощник и где он может работать',
@@ -2549,28 +2567,6 @@ const messages: TranslationMap = {
'В играх с эксклюзивным полноэкранным режимом наложение не отобразится: вы услышите только звуковой сигнал. Переключитесь на оконный полноэкранный режим, чтобы видеть наложение.',
'pttOverlay.listening': 'Слушаю…',
'pttOverlay.idle': 'Ожидание',
'autocomplete.title': 'Автодополнение',
'autocomplete.settings': 'Настройки',
'autocomplete.acceptWithTab': 'Принять с помощью Tab',
'autocomplete.stylePreset': 'Стиль',
'autocomplete.style.balanced': 'Сбалансированный',
'autocomplete.style.concise': 'Краткий',
'autocomplete.style.formal': 'Официальный',
'autocomplete.style.casual': 'Неформальный',
'autocomplete.style.custom': 'Пользовательский',
'autocomplete.disabledApps': 'Отключённые приложения (по одному bundle/токену на строку)',
'autocomplete.saveSettings': 'Сохранить настройки',
'autocomplete.saving': 'Сохранение…',
'autocomplete.runtime': 'Среда выполнения',
'autocomplete.running': 'Работает',
'autocomplete.start': 'Запустить',
'autocomplete.stop': 'Остановить',
'autocomplete.settingsSaved': 'Настройки автодополнения сохранены.',
'autocomplete.started': 'Автодополнение запущено.',
'autocomplete.didNotStart': 'Автодополнение не запустилось. Проверь, включено ли оно.',
'autocomplete.stopped': 'Автодополнение остановлено.',
'autocomplete.advancedSettings': 'Дополнительные настройки',
'autocomplete.debugTitle': 'Отладка автодополнения',
'chat.agentChat': 'Чат с агентом',
'chat.overrides': 'Переопределения',
'chat.model': 'Модель',
@@ -4543,9 +4539,6 @@ const messages: TranslationMap = {
'settings.developerMenu.composio.title': 'Composio',
'settings.developerMenu.composio.desc':
'Режим маршрутизации, триггеры интеграции и архив истории триггеров.',
'pages.settings.features.desktopCompanion': 'Десктоп-спутник',
'pages.settings.features.desktopCompanionDesc':
'Голосовой ассистент с распознаванием экрана: слушает, видит, говорит, указывает',
'pages.settings.features.messagingChannels': 'Каналы мессенджеров',
'pages.settings.features.messagingChannelsDesc': 'Описание каналов сообщений',
'pages.settings.features.notifications': 'Уведомления',
@@ -4967,63 +4960,6 @@ const messages: TranslationMap = {
'settings.ai.workload.changeModel': 'Изменить модель',
'settings.ai.workload.chooseModel': 'Выберите модель',
'settings.ai.provider.ollama': 'Ollama',
'settings.autocomplete.appFilter.acceptSuggestion': 'Принять предложение',
'settings.autocomplete.appFilter.app': 'Приложение',
'settings.autocomplete.appFilter.currentSuggestion': 'Текущее предложение',
'settings.autocomplete.appFilter.contextOverride': 'Переопределение контекста (необязательно)',
'settings.autocomplete.appFilter.debounce': 'Отладка',
'settings.autocomplete.appFilter.debugFocus': 'Отладка фокуса',
'settings.autocomplete.appFilter.enabled': 'Включено',
'settings.autocomplete.appFilter.getSuggestion': 'Получить предложение',
'settings.autocomplete.appFilter.lastError': 'Последняя ошибка',
'settings.autocomplete.appFilter.liveLogs': 'Логи в реальном времени',
'settings.autocomplete.appFilter.model': 'Модель',
'settings.autocomplete.appFilter.noLogs': '):',
'settings.autocomplete.appFilter.phase': 'Фаза',
'settings.autocomplete.appFilter.platformSupported': 'Платформа поддерживается',
'settings.autocomplete.appFilter.refreshStatus': 'Обновление…',
'settings.autocomplete.appFilter.refreshing': 'Обновление…',
'settings.autocomplete.appFilter.running': 'Выполняется',
'settings.autocomplete.appFilter.runtime': 'Среда выполнения',
'settings.autocomplete.appFilter.test': 'Тест',
'settings.autocomplete.completionStyle.acceptedCompletion':
'Сохранено {count} принятое автозаполнение: используется для персонализации будущих подсказок.',
'settings.autocomplete.completionStyle.acceptedCompletions':
'Сохранено {count} принятых автозаполнений: используется для персонализации будущих подсказок.',
'settings.autocomplete.completionStyle.clearHistory': 'Очистка…',
'settings.autocomplete.completionStyle.clearing': 'Очистка…',
'settings.autocomplete.completionStyle.debounce': 'Задержка (ms)',
'settings.autocomplete.completionStyle.enabled': 'Включено',
'settings.autocomplete.completionStyle.maxChars': 'Макс. символов',
'settings.autocomplete.completionStyle.noHistory':
'Принятых предложений пока нет. Принимай предложения с помощью Tab, чтобы начать персонализацию.',
'settings.autocomplete.completionStyle.overlayTtl': 'Время отображения (ms)',
'settings.autocomplete.completionStyle.personalizationHistory': 'История персонализации',
'settings.autocomplete.completionStyle.styleExamples': 'Примеры стиля (по одному на строку)',
'settings.autocomplete.completionStyle.styleInstructions': 'Инструкции по стилю',
'settings.autocomplete.debug.acceptedPrefix': 'Принято: {value}',
'settings.autocomplete.debug.acceptFailed': 'Не удалось принять предложение',
'settings.autocomplete.debug.alreadyRunning': 'Автозаполнение уже запущено.',
'settings.autocomplete.debug.clearHistoryFailed': 'Не удалось очистить историю.',
'settings.autocomplete.debug.didNotStart': 'Автозаполнение не запустилось.',
'settings.autocomplete.debug.disabledInSettings':
'Автозаполнение отключено в настройках. Включите его и сначала сохраните.',
'settings.autocomplete.debug.fetchSuggestionFailed': 'Не удалось получить текущее предложение.',
'settings.autocomplete.debug.inspectFocusedElementFailed':
'Не удалось проверить элемент в фокусе.',
'settings.autocomplete.debug.loadSettingsFailed':
'Не удалось загрузить настройки автозаполнения.',
'settings.autocomplete.debug.noSuggestionApplied': 'Предложение не было применено.',
'settings.autocomplete.debug.noSuggestionReturned': 'Ни одно предложение не вернулось.',
'settings.autocomplete.debug.refreshStatusFailed': 'Не удалось обновить статус автозаполнения.',
'settings.autocomplete.debug.saveAdvancedSettingsFailed':
'Не удалось сохранить дополнительные настройки.',
'settings.autocomplete.debug.startFailed': 'Не удалось запустить автозаполнение.',
'settings.autocomplete.debug.stopFailed': 'Не удалось остановить автозаполнение.',
'settings.autocomplete.debug.suggestionPrefix': 'Предложение: {value}',
'settings.autocomplete.shared.none': 'Нет',
'settings.autocomplete.shared.notApplicable': 'н/д',
'settings.autocomplete.shared.unknown': 'Неизвестно',
'settings.billing.autoRecharge.addAmount': 'Добавить эту сумму',
'settings.billing.autoRecharge.addCard': 'Добавить карту',
'settings.billing.autoRecharge.amountHint': 'Подсказка по сумме',
@@ -5139,23 +5075,6 @@ const messages: TranslationMap = {
'Не удалось сохранить. Прямой режим требует непустой API-ключ.',
'settings.composio.saving': 'Сохранение…',
'settings.composio.switching': 'Переключение…',
'settings.companion.title': 'Настольный компаньон',
'settings.companion.session': 'Сеанс',
'settings.companion.activeLabel': 'Активен',
'settings.companion.inactiveStatus': 'Неактивен',
'settings.companion.stopping': 'Остановка…',
'settings.companion.stopSession': 'Остановить сеанс',
'settings.companion.starting': 'Начало…',
'settings.companion.startSession': 'Начало сеанса',
'settings.companion.sessionId': 'Идентификатор сеанса',
'settings.companion.turns': 'Выключает',
'settings.companion.remaining': 'Оставшуюся',
'settings.companion.configuration': 'конфигурацию',
'settings.companion.hotkey': 'Горячая клавиша',
'settings.companion.activationMode': 'Режим активации',
'settings.companion.sessionTtl': 'TTL сеанса',
'settings.companion.screenCapture': 'Снимок экрана',
'settings.companion.appContext': 'Контекст приложения',
'settings.cron.jobs.commandRequired': 'Для shell-задач требуется команда',
'settings.cron.jobs.createJob': 'Новая запланированная задача',
'settings.cron.jobs.created': 'Задача создана',
@@ -5583,9 +5502,6 @@ const messages: TranslationMap = {
'settings.developerMenu.autonomy.title': 'Автономия агента',
'settings.developerMenu.autonomy.desc':
'Ограничения частоты действий инструментов и пороги безопасности',
'settings.developerMenu.autocomplete.title': 'Автодополнение',
'settings.developerMenu.autocomplete.desc':
'Настройки встроенного автодополнения ИИ и панель отладки',
'settings.developerMenu.voiceDebug.title': 'Голос (отладка)',
'settings.developerMenu.voiceDebug.desc':
'Состояние среды выполнения голосового диктовки и настройки отладки',
@@ -6051,26 +5967,6 @@ const messages: TranslationMap = {
'settings.modelHealth.modal.apply': 'Применить замену',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
'settings.desktopAgent.title': 'Агент рабочего стола',
'settings.desktopAgent.description':
'Проверьте и предоставьте разрешения, необходимые агенту рабочего стола.',
'settings.desktopAgent.microphone': 'Микрофон',
'settings.desktopAgent.grant': 'Предоставить',
'settings.desktopAgent.notRequiredOnOs': 'Не требуется в этой операционной системе',
'settings.desktopAgent.allGranted':
'Всё готово: у агента рабочего стола есть все необходимые разрешения.',
'settings.desktopAgent.recheck': 'Проверить снова',
'settings.desktopAgent.restartAndRecheck': 'Перезапустить ядро и проверить снова',
'settings.desktopAgent.seamless.title': 'Действия агента',
'settings.desktopAgent.seamless.label': 'Разрешить агенту действовать без запроса',
'settings.desktopAgent.seamless.description':
'Предоставьте полный доступ и автоматически одобряйте действия на рабочем столе (открытие приложений, управление их интерфейсами), чтобы агент выполнял их без запроса на подтверждение.',
'settings.desktopAgent.seamless.note':
'Запрос пропускают только действия на рабочем столе: выполнение команд оболочки, редактирование файлов, сетевые запросы и установка программ по-прежнему запрашивают подтверждение. Отключение этой опции прекращает автоматическое одобрение действий на рабочем столе, но уровень доступа остаётся прежним.',
'settings.desktopAgent.beta':
'Бета: агент рабочего стола является экспериментальным и может не всегда работать как ожидается.',
'settings.desktopAgent.wakeWordHint':
'Скажите «Hey Tiny» перед командой, чтобы разбудить агента.',
'settings.screenIntel.permissions.accessibility': 'Доступность',
'settings.screenIntel.permissions.grantHint': 'Подсказка по предоставлению',
'settings.screenIntel.permissions.inputMonitoring': 'Мониторинг ввода',
@@ -6343,21 +6239,6 @@ const messages: TranslationMap = {
'skills.resource.tree.templates': 'Шаблоны',
'skills.resource.tree.empty': 'Встроенных ресурсов нет.',
'skills.search.placeholder': 'Поиск',
'skills.setup.autocomplete.acceptKey': 'Клавиша принятия',
'skills.setup.autocomplete.activeDesc': 'Активно',
'skills.setup.autocomplete.activeTitle': 'Автодополнение активно',
'skills.setup.autocomplete.customizeSettings': 'Настроить параметры',
'skills.setup.autocomplete.debounce': 'Задержка',
'skills.setup.autocomplete.description': 'Описание',
'skills.setup.autocomplete.enableBtn': 'Включение...',
'skills.setup.autocomplete.enableError': 'Не удалось включить автодополнение',
'skills.setup.autocomplete.enabling': 'Включение...',
'skills.setup.autocomplete.notSupported': 'Не поддерживается',
'skills.setup.autocomplete.stepEnable': 'Включить встроенные подсказки',
'skills.setup.autocomplete.stepSuccess': 'Готово к работе',
'skills.setup.autocomplete.stylePreset': 'Пресет стиля',
'skills.setup.autocomplete.stylePresetValue': 'Сбалансированный (можно изменить позже)',
'skills.setup.autocomplete.title': 'Автодополнение текста',
'skills.setup.screenIntel.activeDesc': 'Активно',
'skills.setup.screenIntel.activeTitle': 'Интеллект экрана включён',
'skills.setup.screenIntel.advancedSettings': 'Дополнительные настройки',
@@ -6849,9 +6730,6 @@ const messages: TranslationMap = {
'chat.files.error.resolve_failed': 'Не удалось получить артефакт. Попробуйте ещё раз.',
'chat.files.error.download_failed': 'Не удалось загрузить файл. Попробуйте ещё раз.',
'chat.files.error.delete_failed': 'Не удалось удалить файл. Попробуйте ещё раз.',
'autocomplete.debounceMs': 'Задержка (мс)',
'autocomplete.maxChars': 'Макс. символов контекста',
'autocomplete.overlayTtlMs': 'Тайм-аут наложения (мс)',
'memory.tab.council': 'Council',
'activity.tabs.automations': 'Автоматизации',
'activity.tabs.automationsDescription':
@@ -6886,133 +6764,6 @@ const messages: TranslationMap = {
'intelligence.agents.tier.chat': 'Чат',
'intelligence.agents.tier.reasoning': 'Рассуждение',
'intelligence.agents.tier.worker': 'Исполнитель',
'modelCouncil.title': 'Совет моделей',
'modelCouncil.intro':
'Задайте один вопрос, получите независимые ответы от нескольких моделей параллельно, затем модель-председатель синтезирует, в чём они согласны, где расходятся и что уникально привносит каждая.',
'modelCouncil.listTitle': 'Councils',
'modelCouncil.listIntro':
'Выберите сохранённый совет, отредактируйте его агентов и судью или создайте новый для определённого типа решений.',
'modelCouncil.addCouncil': 'Новый совет',
'modelCouncil.openCouncil': 'Открыть совет',
'modelCouncil.editCouncil': 'Редактировать совет',
'modelCouncil.editCurrentCouncil': 'Редактировать текущий совет',
'modelCouncil.editCouncilAria': 'Редактировать {name}',
'modelCouncil.deleteCouncilAria': 'Удалить {name}',
'modelCouncil.backToCouncils': 'Назад к советам',
'modelCouncil.saveCouncil': 'Сохранить совет',
'modelCouncil.savingCouncil': 'Saving…',
'modelCouncil.cancelEdit': 'Cancel',
'modelCouncil.loadingCouncils': 'Загрузка советов…',
'modelCouncil.noCouncils': 'Советов пока нет. Добавьте один, чтобы начать.',
'modelCouncil.noCouncilDescription': 'Без описания',
'modelCouncil.registryErrorPrefix': 'Реестр советов не удался:',
'modelCouncil.councilNameLabel': 'Название совета',
'modelCouncil.councilDescriptionLabel': 'Description',
'modelCouncil.councilDescriptionPlaceholder': 'Когда использовать этот совет',
'modelCouncil.selectModel': 'Select',
'modelCouncil.modelPickerHelp':
'Выберите подсказку маршрутизации или закрепите точную модель провайдера.',
'modelCouncil.closeModelPicker': 'Close',
'modelCouncil.hint.chat': 'Чат',
'modelCouncil.hint.code': 'Код',
'modelCouncil.hint.default': 'По умолчанию',
'modelCouncil.hint.reasoning': 'Рассуждение',
'modelCouncil.hint.summarize': 'Сводка',
'modelCouncil.modelPickerHints': 'Hints',
'modelCouncil.modelPickerProviderModel': 'Провайдер + модель',
'modelCouncil.modelProviderLabel': 'Провайдер модели',
'modelCouncil.modelIdLabel': 'ID модели',
'modelCouncil.useProviderModel': 'Использовать модель провайдера',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Задайте вопрос совету…',
'modelCouncil.membersLabel': 'Модели участников',
'modelCouncil.maxMembersNote': 'до {max}',
'modelCouncil.memberPlaceholder': 'напр. gpt-5.2',
'modelCouncil.memberAria': 'Модель участника {n}',
'modelCouncil.removeMemberAria': 'Удалить модель участника {n}',
'modelCouncil.addMember': '+ Добавить модель',
'modelCouncil.settingsTitle': 'Настройки совета',
'modelCouncil.settingsSummary': '{count} присяжных · судья: {judge}',
'modelCouncil.juryCountLabel': 'Число присяжных',
'modelCouncil.debateRoundsLabel': 'Раунды дебатов',
'modelCouncil.debateRoundsHelp':
'Каждый присяжный продолжает дебатировать в нескольких раундах, прежде чем судья напишет итоговый синтез.',
'modelCouncil.judgeAgentLabel': 'Агент-судья',
'modelCouncil.defaultJudge': 'Судья по умолчанию',
'modelCouncil.savedProfile': 'Сохраненный профиль',
'modelCouncil.customAgent': 'Пользовательский агент',
'modelCouncil.judgeProfileLabel': 'Профиль судьи',
'modelCouncil.chooseProfile': 'Выберите профиль',
'modelCouncil.judgeNameLabel': 'Имя судьи',
'modelCouncil.judgeNamePlaceholder': 'Главный судья',
'modelCouncil.rosterHeading': 'Состав совета',
'modelCouncil.rosterHelp':
'Каждый присяжный может наследовать агента по умолчанию, использовать сохраненный профиль или стать пользовательским профилем для этого запуска.',
'modelCouncil.loadingProfiles': 'Загрузка профилей…',
'modelCouncil.jurorLabel': 'Присяжный {n}',
'modelCouncil.profileModeLabel': 'Источник профиля',
'modelCouncil.mode.default': 'По умолчанию',
'modelCouncil.mode.profile': 'Профиль',
'modelCouncil.mode.custom': 'Свой',
'modelCouncil.memberProfileAria': 'Профиль присяжного {n}',
'modelCouncil.memberNameAria': 'Имя присяжного {n}',
'modelCouncil.memberNamePlaceholder': 'Имя агента',
'modelCouncil.profileModelPlaceholder': 'Переопределить модель профиля',
'modelCouncil.memberBriefAria': 'Бриф присяжного {n}',
'modelCouncil.memberBriefPlaceholder': 'Перспектива этого присяжного',
'modelCouncil.jurorFallback': 'Присяжный',
'modelCouncil.sharedReasoningLabel': 'Общий файл рассуждений',
'modelCouncil.sharedReasoningHelp':
'Совет работает с этим общим черновиком до того, как судья пишет синтез.',
'modelCouncil.liveScratchpadHelp':
'Обновления присяжных добавляются сюда после каждого раунда и используются в следующем.',
'modelCouncil.liveScratchpadBadge': 'Live',
'modelCouncil.scratchpadRoundHeading': 'Обновления раунда {round}',
'modelCouncil.scratchpadNoResponse': 'Нет ответа',
'modelCouncil.chairLabel': 'Модель председателя',
'modelCouncil.chairPlaceholder': 'напр. claude-opus-4-8',
'modelCouncil.chairHelp': 'Председатель читает каждый ответ участников и пишет синтез.',
'modelCouncil.run': 'Созвать совет',
'modelCouncil.running': 'Convening…',
'modelCouncil.runningHint': 'Опрос участников параллельно, затем синтез…',
'modelCouncil.deliberationHeading': 'Совещание совета',
'modelCouncil.deliberationHelp':
'Маскоты показывают мыслительный процесс каждого агента во время использования общего файла рассуждений.',
'modelCouncil.debateRoundInstruction':
'Ответьте другим присяжным. Сохраняйте разногласия, пересматривайте свою позицию при наличии оснований и явно формулируйте текущее заключение.',
'modelCouncil.debateFinalInstruction':
'Это ваш последний раунд в качестве присяжного. Сойдитесь там, где доказательства это поддерживают, укажите оставшиеся разногласия и дайте судье чёткое заключение.',
'modelCouncil.roundLabel': 'Раунд {round}',
'modelCouncil.currentRoundThinking': 'Продолжение следующего раунда…',
'modelCouncil.thinkingBadge': 'Thinking',
'modelCouncil.thinkingWithBrief': 'Работа с данной перспективы: {brief}',
'modelCouncil.thought.evidence':
'Проверка доказательств, допущений и того, что общий файл рассуждений уже содержит.',
'modelCouncil.thought.plan':
'Набросок практического пути и возможных затруднений при реализации.',
'modelCouncil.thought.risk': 'Поиск режимов сбоя, недостающего контекста и хрупких выводов.',
'modelCouncil.thought.tradeoffs': 'Сравнение компромиссов перед передачей судье краткой позиции.',
'modelCouncil.thought.synthesis':
'Подготовка отдельной точки зрения, чтобы судья мог сравнить реальные разногласия.',
'modelCouncil.judgeWaitingBadge': 'Judge',
'modelCouncil.judgeWaitingThought':
'Ожидание ответов присяжных, затем чтение общего файла рассуждений для синтеза.',
'modelCouncil.judgeSynthesizingBadge': 'Synthesizing',
'modelCouncil.judgeSynthesizingThought':
'Чтение мыслей присяжных в реальном времени и написание итогового синтеза совета.',
'modelCouncil.errorPrefix': 'Совет не удался:',
'modelCouncil.resultsHeading': 'Результаты совета',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'от {model}',
'modelCouncil.usageHeading': 'Использование дебатов',
'modelCouncil.usageEstimated':
'Оценочное количество токенов из промптов и ответов дебатов. Реальная стоимость требует данных об использовании провайдера из RPC совета.',
'modelCouncil.usageEstimatedBadge': 'Оценочные токены',
'modelCouncil.usageInputTokens': 'Input',
'modelCouncil.usageOutputTokens': 'Output',
'modelCouncil.usageTotalTokens': 'Total',
'graphCohesion.brokerBadge': 'брокер',
'graphCohesion.brokerTitle':
'Структурная дыра: соседи этой сущности не связаны друг с другом: она единственная связь между ними.',
+23 -251
View File
@@ -3,6 +3,29 @@ import type { TranslationMap } from './types';
// Simplified Chinese (简体中文) translations. Keys mirror en.ts; missing/
// English-identical values fall back to English via I18nContext.resolveEn().
const messages: TranslationMap = {
'connections.header.companion': '一个悬浮的桌面伙伴,随时提供快速帮助。',
'settings.assistant.desktopCompanion': '桌面伴侣',
'settings.assistant.desktopCompanionDesc': '带有系统托盘快捷方式的常驻伴侣模式',
'pages.settings.features.desktopCompanion': '桌面伴侣',
'pages.settings.features.desktopCompanionDesc':
'具有屏幕感知能力的语音助手:倾听、观看、说话、指向',
'settings.companion.title': '桌面伴侣',
'settings.companion.session': '会话',
'settings.companion.activeLabel': '活跃',
'settings.companion.inactiveStatus': '不活跃',
'settings.companion.stopping': '正在停止…',
'settings.companion.stopSession': '停止会话',
'settings.companion.starting': '正在启动…',
'settings.companion.startSession': '开始会话',
'settings.companion.sessionId': '会话ID',
'settings.companion.turns': '轮次',
'settings.companion.remaining': '剩余',
'settings.companion.configuration': '配置',
'settings.companion.hotkey': '热键',
'settings.companion.activationMode': '激活模式',
'settings.companion.sessionTtl': '会话有效期',
'settings.companion.screenCapture': '屏幕截图',
'settings.companion.appContext': '应用程序上下文',
// Share cards (#5006).
'share.button': '分享',
'share.modalTitle': '分享这一刻',
@@ -86,8 +109,6 @@ const messages: TranslationMap = {
'connections.header.search': '为你的智能体配置网络搜索。',
'connections.header.wallet': '你的本地钱包余额和恢复短语。',
'connections.header.screen': '让你的智能体查看你的屏幕,以便提供及时、贴合上下文的帮助。',
'connections.header.desktopAgent': '让你的智能体代表你打开应用并控制你的桌面。',
'connections.header.companion': '一个悬浮的桌面伙伴,随时提供快速帮助。',
'connections.header.composioKey': '添加 Composio API 密钥,解锁数百个应用集成。',
'connections.llm.apiKeys': 'API 密钥与路由',
'connections.welcome.nav': '欢迎',
@@ -704,8 +725,6 @@ const messages: TranslationMap = {
'settings.assistant.backgroundActivityDesc': '控制助手在后台工作的活跃程度',
'settings.assistant.screenAwareness': '屏幕感知',
'settings.assistant.screenAwarenessDesc': '允许助手查看您的活动窗口',
'settings.assistant.desktopCompanion': '桌面伴侣',
'settings.assistant.desktopCompanionDesc': '带有系统托盘快捷方式的常驻伴侣模式',
'settings.assistant.permissions': '权限',
'settings.assistant.permissionsDesc': '选择助手可以做什么以及可以在哪里工作',
'settings.privacySecurity.privacy': '隐私',
@@ -2383,28 +2402,6 @@ const messages: TranslationMap = {
'在独占式全屏游戏中悬浮层不会显示:你只会听到提示音。切换到无边框全屏即可看到悬浮层。',
'pttOverlay.listening': '正在聆听…',
'pttOverlay.idle': '空闲',
'autocomplete.title': '自动补全',
'autocomplete.settings': '设置',
'autocomplete.acceptWithTab': 'Tab 键接受',
'autocomplete.stylePreset': '风格预设',
'autocomplete.style.balanced': '均衡',
'autocomplete.style.concise': '简洁',
'autocomplete.style.formal': '正式',
'autocomplete.style.casual': '随意',
'autocomplete.style.custom': '自定义',
'autocomplete.disabledApps': '禁用的应用(每行一个包名/应用令牌)',
'autocomplete.saveSettings': '保存设置',
'autocomplete.saving': '保存中…',
'autocomplete.runtime': '运行时',
'autocomplete.running': '运行中',
'autocomplete.start': '启动',
'autocomplete.stop': '停止',
'autocomplete.settingsSaved': '自动补全设置已保存。',
'autocomplete.started': '自动补全已启动。',
'autocomplete.didNotStart': '自动补全未能启动。请检查是否已启用。',
'autocomplete.stopped': '自动补全已停止。',
'autocomplete.advancedSettings': '高级设置',
'autocomplete.debugTitle': '自动补全调试',
'chat.agentChat': '智能体对话',
'chat.overrides': '覆盖设置',
'chat.model': '模型',
@@ -4255,9 +4252,6 @@ const messages: TranslationMap = {
'pages.settings.aiSection.title': 'AI',
'settings.developerMenu.composio.title': 'Composio',
'settings.developerMenu.composio.desc': '路由模式、集成触发器和触发器历史存档。',
'pages.settings.features.desktopCompanion': '桌面伴侣',
'pages.settings.features.desktopCompanionDesc':
'具有屏幕感知能力的语音助手:倾听、观看、说话、指向',
'pages.settings.features.messagingChannels': '消息渠道',
'pages.settings.features.messagingChannelsDesc': '配置消息渠道和集成',
'pages.settings.features.notifications': '通知',
@@ -4651,59 +4645,6 @@ const messages: TranslationMap = {
'settings.ai.workload.changeModel': '改变模型',
'settings.ai.workload.chooseModel': '选择型号',
'settings.ai.provider.ollama': 'Ollama',
'settings.autocomplete.appFilter.acceptSuggestion': '接受建议',
'settings.autocomplete.appFilter.app': '应用程序',
'settings.autocomplete.appFilter.currentSuggestion': '当前建议',
'settings.autocomplete.appFilter.contextOverride': '上下文覆盖(可选)',
'settings.autocomplete.appFilter.debounce': '去抖动',
'settings.autocomplete.appFilter.debugFocus': '调试焦点',
'settings.autocomplete.appFilter.enabled': '启用',
'settings.autocomplete.appFilter.getSuggestion': '获取建议',
'settings.autocomplete.appFilter.lastError': '最后一个错误',
'settings.autocomplete.appFilter.liveLogs': '实时日志',
'settings.autocomplete.appFilter.model': '型号',
'settings.autocomplete.appFilter.noLogs': '无日志',
'settings.autocomplete.appFilter.phase': '阶段',
'settings.autocomplete.appFilter.platformSupported': '支持平台',
'settings.autocomplete.appFilter.refreshStatus': '刷新中…',
'settings.autocomplete.appFilter.refreshing': '刷新中…',
'settings.autocomplete.appFilter.running': '跑步',
'settings.autocomplete.appFilter.runtime': '运行时',
'settings.autocomplete.appFilter.test': '测试',
'settings.autocomplete.completionStyle.acceptedCompletion':
'已存储接受的补全记录,用于个性化后续建议。',
'settings.autocomplete.completionStyle.acceptedCompletions':
'已存储接受的补全记录,用于个性化后续建议。',
'settings.autocomplete.completionStyle.clearHistory': '清除中…',
'settings.autocomplete.completionStyle.clearing': '清除中…',
'settings.autocomplete.completionStyle.debounce': '防抖(ms',
'settings.autocomplete.completionStyle.enabled': '已启用',
'settings.autocomplete.completionStyle.maxChars': '最大字符数',
'settings.autocomplete.completionStyle.noHistory':
'暂无接受的补全记录。用 Tab 键接受建议以开始个性化。',
'settings.autocomplete.completionStyle.overlayTtl': '浮层显示时长(ms',
'settings.autocomplete.completionStyle.personalizationHistory': '个性化历史',
'settings.autocomplete.completionStyle.styleExamples': '风格示例(每行一条)',
'settings.autocomplete.completionStyle.styleInstructions': '风格说明',
'settings.autocomplete.debug.acceptedPrefix': '已接受:{value}',
'settings.autocomplete.debug.acceptFailed': '未能接受建议',
'settings.autocomplete.debug.alreadyRunning': '自动完成功能已在运行。',
'settings.autocomplete.debug.clearHistoryFailed': '清除历史记录失败',
'settings.autocomplete.debug.didNotStart': '自动完成功能未启动。',
'settings.autocomplete.debug.disabledInSettings': '设置中禁用自动完成功能。首先启用它并保存。',
'settings.autocomplete.debug.fetchSuggestionFailed': '无法获取当前建议',
'settings.autocomplete.debug.inspectFocusedElementFailed': '无法检查聚焦元素',
'settings.autocomplete.debug.loadSettingsFailed': '无法加载自动完成设置',
'settings.autocomplete.debug.noSuggestionApplied': '没有应用任何建议。',
'settings.autocomplete.debug.noSuggestionReturned': '没有返回任何建议。',
'settings.autocomplete.debug.refreshStatusFailed': '无法刷新自动完成状态',
'settings.autocomplete.debug.saveAdvancedSettingsFailed': '无法保存高级设置',
'settings.autocomplete.debug.startFailed': '无法启动自动完成',
'settings.autocomplete.debug.stopFailed': '无法停止自动完成',
'settings.autocomplete.debug.suggestionPrefix': '建议:{value}',
'settings.autocomplete.shared.none': '无',
'settings.autocomplete.shared.notApplicable': '不适用',
'settings.autocomplete.shared.unknown': '未知',
'settings.billing.autoRecharge.addAmount': '充入此金额',
'settings.billing.autoRecharge.addCard': '添加银行卡',
'settings.billing.autoRecharge.amountHint': '最低充值金额',
@@ -4815,23 +4756,6 @@ const messages: TranslationMap = {
'settings.composio.saveErrorNoKey': '保存失败。直连模式需要非空的 API 密钥。',
'settings.composio.saving': '保存中…',
'settings.composio.switching': '切换中…',
'settings.companion.title': '桌面伴侣',
'settings.companion.session': '会议',
'settings.companion.activeLabel': '活跃',
'settings.companion.inactiveStatus': '不活跃',
'settings.companion.stopping': '停止…',
'settings.companion.stopSession': '停止会话',
'settings.companion.starting': '开始…',
'settings.companion.startSession': '开始会话',
'settings.companion.sessionId': '会话ID',
'settings.companion.turns': '转弯',
'settings.companion.remaining': '剩余',
'settings.companion.configuration': '配置',
'settings.companion.hotkey': '热键',
'settings.companion.activationMode': '激活模式',
'settings.companion.sessionTtl': '会话生存时间',
'settings.companion.screenCapture': '屏幕截图',
'settings.companion.appContext': '应用程序上下文',
'settings.cron.jobs.commandRequired': 'Shell 任务必须填写命令',
'settings.cron.jobs.createJob': '新建定时任务',
'settings.cron.jobs.created': '任务已创建',
@@ -5231,8 +5155,6 @@ const messages: TranslationMap = {
'settings.developerMenu.mcpServer.desc': '配置外部 MCP 客户端以连接到 OpenHuman',
'settings.developerMenu.autonomy.title': '智能体自主权',
'settings.developerMenu.autonomy.desc': '工具操作速率限制和安全阈值',
'settings.developerMenu.autocomplete.title': '自动补全',
'settings.developerMenu.autocomplete.desc': 'AI 内联自动补全设置和调试面板',
'settings.developerMenu.voiceDebug.title': '语音(调试)',
'settings.developerMenu.voiceDebug.desc': '语音听写运行时状态和调试设置',
'settings.mcpServer.title': 'MCP 服务器',
@@ -5665,22 +5587,6 @@ const messages: TranslationMap = {
'settings.modelHealth.modal.apply': '应用替换',
'settings.modelHealth.tag.cheaper': '更便宜',
'settings.modelHealth.tag.better': '更好',
'settings.desktopAgent.title': '桌面智能体',
'settings.desktopAgent.description': '检查并授予桌面智能体所需的权限。',
'settings.desktopAgent.microphone': '麦克风',
'settings.desktopAgent.grant': '授予',
'settings.desktopAgent.notRequiredOnOs': '此操作系统无需此权限',
'settings.desktopAgent.allGranted': '全部就绪:桌面智能体已拥有所需的全部权限。',
'settings.desktopAgent.recheck': '重新检查',
'settings.desktopAgent.restartAndRecheck': '重启内核并重新检查',
'settings.desktopAgent.seamless.title': '智能体操作',
'settings.desktopAgent.seamless.label': '让智能体无需询问即可执行',
'settings.desktopAgent.seamless.description':
'授予完全访问权限并自动批准桌面操作(打开应用、控制应用界面),使智能体无需确认提示即可执行。',
'settings.desktopAgent.seamless.note':
'只有桌面操作会跳过提示:运行 shell 命令、编辑文件、网络请求和安装软件仍会询问。关闭此选项会停止自动批准桌面操作,但访问级别保持不变。',
'settings.desktopAgent.beta': '测试版:桌面智能体为实验性功能,可能并不总是按预期工作。',
'settings.desktopAgent.wakeWordHint': '在指令前说“Hey Tiny”来唤醒智能体。',
'settings.screenIntel.permissions.accessibility': '辅助功能',
'settings.screenIntel.permissions.grantHint': '在系统设置中授权后,点击下方刷新',
'settings.screenIntel.permissions.inputMonitoring': '输入监控',
@@ -5943,21 +5849,6 @@ const messages: TranslationMap = {
'skills.resource.tree.templates': '模板',
'skills.resource.tree.empty': '无捆绑资源。',
'skills.search.placeholder': '搜索技能...',
'skills.setup.autocomplete.acceptKey': '接受键',
'skills.setup.autocomplete.activeDesc': '自动补全已激活,正在为你提供智能建议',
'skills.setup.autocomplete.activeTitle': '自动补全已激活',
'skills.setup.autocomplete.customizeSettings': '自定义设置',
'skills.setup.autocomplete.debounce': '防抖',
'skills.setup.autocomplete.description': '在你输入时提供智能文本建议',
'skills.setup.autocomplete.enableBtn': '启用中...',
'skills.setup.autocomplete.enableError': '启用自动补全失败',
'skills.setup.autocomplete.enabling': '启用中...',
'skills.setup.autocomplete.notSupported': '不支持',
'skills.setup.autocomplete.stepEnable': '启用内联补全',
'skills.setup.autocomplete.stepSuccess': '准备就绪',
'skills.setup.autocomplete.stylePreset': '风格预设',
'skills.setup.autocomplete.stylePresetValue': '均衡(稍后可配置)',
'skills.setup.autocomplete.title': '文本自动补全',
'skills.setup.screenIntel.activeDesc': '屏幕智能已激活,正在感知你的屏幕',
'skills.setup.screenIntel.activeTitle': '屏幕智能已启用',
'skills.setup.screenIntel.advancedSettings': '高级设置',
@@ -6408,9 +6299,6 @@ const messages: TranslationMap = {
'chat.files.error.resolve_failed': '无法解析 artifact。请重试。',
'chat.files.error.download_failed': '下载失败。请重试。',
'chat.files.error.delete_failed': '无法删除文件。请重试。',
'autocomplete.debounceMs': '防抖 (毫秒)',
'autocomplete.maxChars': '最大上下文字符数',
'autocomplete.overlayTtlMs': '覆盖层超时 (ms)',
'memory.tab.council': 'Council',
'activity.tabs.automations': '自动化',
'activity.tabs.automationsDescription': '可复用、可运行的流程:目标及达成目标的步骤。',
@@ -6443,122 +6331,6 @@ const messages: TranslationMap = {
'intelligence.agents.tier.chat': '聊天',
'intelligence.agents.tier.reasoning': '推理',
'intelligence.agents.tier.worker': '工作者',
'modelCouncil.title': '模型委员会',
'modelCouncil.intro':
'提出一个问题,同时从多个模型获取独立回答,然后由主席模型综合各方共识、分歧以及各自的独特贡献。',
'modelCouncil.listTitle': 'Councils',
'modelCouncil.listIntro': '选择已保存的委员会,编辑其智能体和法官,或创建新委员会用于特定决策。',
'modelCouncil.addCouncil': '新建委员会',
'modelCouncil.openCouncil': '打开委员会',
'modelCouncil.editCouncil': '编辑委员会',
'modelCouncil.editCurrentCouncil': '编辑当前委员会',
'modelCouncil.editCouncilAria': '编辑 {name}',
'modelCouncil.deleteCouncilAria': '删除 {name}',
'modelCouncil.backToCouncils': '返回委员会列表',
'modelCouncil.saveCouncil': '保存委员会',
'modelCouncil.savingCouncil': 'Saving…',
'modelCouncil.cancelEdit': 'Cancel',
'modelCouncil.loadingCouncils': '正在加载委员会…',
'modelCouncil.noCouncils': '暂无委员会。添加一个开始使用。',
'modelCouncil.noCouncilDescription': '无描述',
'modelCouncil.registryErrorPrefix': '委员会注册表失败:',
'modelCouncil.councilNameLabel': '委员会名称',
'modelCouncil.councilDescriptionLabel': 'Description',
'modelCouncil.councilDescriptionPlaceholder': '何时使用此委员会',
'modelCouncil.selectModel': 'Select',
'modelCouncil.modelPickerHelp': '选择路由提示或指定精确的供应商模型。',
'modelCouncil.closeModelPicker': 'Close',
'modelCouncil.hint.chat': '聊天',
'modelCouncil.hint.code': '代码',
'modelCouncil.hint.default': '默认',
'modelCouncil.hint.reasoning': '推理',
'modelCouncil.hint.summarize': '总结',
'modelCouncil.modelPickerHints': 'Hints',
'modelCouncil.modelPickerProviderModel': '供应商 + 模型',
'modelCouncil.modelProviderLabel': '模型供应商',
'modelCouncil.modelIdLabel': '模型 ID',
'modelCouncil.useProviderModel': '使用供应商模型',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': '向委员会提问…',
'modelCouncil.membersLabel': '成员模型',
'modelCouncil.maxMembersNote': '最多 {max} 个',
'modelCouncil.memberPlaceholder': '例如 gpt-5.2',
'modelCouncil.memberAria': '成员模型 {n}',
'modelCouncil.removeMemberAria': '移除成员模型 {n}',
'modelCouncil.addMember': '+ 添加模型',
'modelCouncil.settingsTitle': '委员会设置',
'modelCouncil.settingsSummary': '{count} 名陪审员 · 法官:{judge}',
'modelCouncil.juryCountLabel': '陪审员数量',
'modelCouncil.debateRoundsLabel': '辩论轮次',
'modelCouncil.debateRoundsHelp': '每位陪审员在法官撰写最终综合意见前进行多轮辩论。',
'modelCouncil.judgeAgentLabel': '法官代理',
'modelCouncil.defaultJudge': '默认法官',
'modelCouncil.savedProfile': '已保存配置',
'modelCouncil.customAgent': '自定义代理',
'modelCouncil.judgeProfileLabel': '法官配置',
'modelCouncil.chooseProfile': '选择配置',
'modelCouncil.judgeNameLabel': '法官名称',
'modelCouncil.judgeNamePlaceholder': '首席法官',
'modelCouncil.rosterHeading': '委员会名单',
'modelCouncil.rosterHelp':
'每位陪审员都可以继承默认代理、使用已保存配置,或在本次运行中成为自定义配置。',
'modelCouncil.loadingProfiles': '正在加载配置…',
'modelCouncil.jurorLabel': '陪审员 {n}',
'modelCouncil.profileModeLabel': '配置来源',
'modelCouncil.mode.default': '默认',
'modelCouncil.mode.profile': '配置',
'modelCouncil.mode.custom': '自定义',
'modelCouncil.memberProfileAria': '陪审员 {n} 配置',
'modelCouncil.memberNameAria': '陪审员 {n} 名称',
'modelCouncil.memberNamePlaceholder': '代理名称',
'modelCouncil.profileModelPlaceholder': '覆盖配置模型',
'modelCouncil.memberBriefAria': '陪审员 {n} 简报',
'modelCouncil.memberBriefPlaceholder': '这位陪审员的视角',
'modelCouncil.jurorFallback': '陪审员',
'modelCouncil.sharedReasoningLabel': '共享推理文件',
'modelCouncil.sharedReasoningHelp': '委员会先围绕这个共享草稿工作,再由法官撰写综合结论。',
'modelCouncil.liveScratchpadHelp': '每轮结束后陪审员的更新会追加到此处,然后作为下一轮的输入。',
'modelCouncil.liveScratchpadBadge': 'Live',
'modelCouncil.scratchpadRoundHeading': '第 {round} 轮更新',
'modelCouncil.scratchpadNoResponse': '无回复',
'modelCouncil.chairLabel': '主席模型',
'modelCouncil.chairPlaceholder': '例如 claude-opus-4-8',
'modelCouncil.chairHelp': '主席阅读每位成员的回答并撰写综合意见。',
'modelCouncil.run': '召集委员会',
'modelCouncil.running': 'Convening…',
'modelCouncil.runningHint': '正在并行查询成员,然后综合…',
'modelCouncil.deliberationHeading': '委员会审议',
'modelCouncil.deliberationHelp': '头像显示每个智能体在共享推理文件使用期间的思考状态。',
'modelCouncil.debateRoundInstruction':
'回应其他陪审员的观点。保留分歧,在有根据时修正您的观点,并明确表达您当前的结论。',
'modelCouncil.debateFinalInstruction':
'这是您最后一轮陪审发言。在证据支持的地方达成一致,指出仍存在的分歧,并向法官给出清晰结论。',
'modelCouncil.roundLabel': '第 {round} 轮',
'modelCouncil.currentRoundThinking': '正在继续下一轮…',
'modelCouncil.thinkingBadge': 'Thinking',
'modelCouncil.thinkingWithBrief': '基于此视角工作:{brief}',
'modelCouncil.thought.evidence': '正在检查证据、假设以及共享推理文件中已有的内容。',
'modelCouncil.thought.plan': '正在规划实际路径以及可能遇到的障碍。',
'modelCouncil.thought.risk': '正在寻找失败模式、缺失的上下文和脆弱的结论。',
'modelCouncil.thought.tradeoffs': '在向法官给出简明立场前比较权衡。',
'modelCouncil.thought.synthesis': '正在准备独特观点,以便法官比较真正的分歧。',
'modelCouncil.judgeWaitingBadge': 'Judge',
'modelCouncil.judgeWaitingThought': '正在等待陪审员回答,然后阅读共享推理文件进行综合。',
'modelCouncil.judgeSynthesizingBadge': 'Synthesizing',
'modelCouncil.judgeSynthesizingThought': '正在阅读陪审员的实时想法并撰写最终委员会综合意见。',
'modelCouncil.errorPrefix': '委员会失败:',
'modelCouncil.resultsHeading': '委员会结果',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': '由 {model}',
'modelCouncil.usageHeading': '辩论用量',
'modelCouncil.usageEstimated':
'辩论提示和回复的预估令牌数。实际定价需要委员会 RPC 提供的供应商用量。',
'modelCouncil.usageEstimatedBadge': '预估令牌',
'modelCouncil.usageInputTokens': 'Input',
'modelCouncil.usageOutputTokens': 'Output',
'modelCouncil.usageTotalTokens': 'Total',
'graphCohesion.brokerBadge': '经纪者',
'graphCohesion.brokerTitle': '结构洞:该实体的邻居彼此之间没有连接:它是它们之间唯一的链接。',
'graphCohesion.colCohesion': '凝聚度',
+27 -13
View File
@@ -23,6 +23,7 @@
*
* There is **no** demo loop the overlay is entirely event-driven.
*/
import { listen } from '@tauri-apps/api/event';
import {
currentMonitor,
getCurrentWindow,
@@ -96,10 +97,9 @@ interface OverlayAttentionPayload {
}
interface CompanionStateChangedPayload {
session_id?: string;
sessionId?: string;
state?: string;
previous_state?: string;
message?: string;
previousState?: string;
}
/**
@@ -327,10 +327,9 @@ export default function OverlayApp() {
}
if (state === 'error') {
setMode('companion');
const trimmed = payload?.message?.trim();
setBubble({
id: `companion-error-${Date.now()}`,
text: trimmed ? `\u201C${trimmed}\u201D` : `\u201C${t('overlay.companion.error')}\u201D`,
text: `\u201C${t('overlay.companion.error')}\u201D`,
tone: 'neutral',
compact: true,
});
@@ -385,7 +384,6 @@ export default function OverlayApp() {
socket.on('dictation:toggle', handleDictationToggle);
socket.on('dictation:transcription', handleDictationTranscription);
socket.on('overlay:attention', handleAttention);
socket.on('companion:state_changed', handleCompanionStateChanged);
socket.connect();
} catch (err) {
@@ -403,13 +401,29 @@ export default function OverlayApp() {
}
clearDismissTimer();
};
}, [
clearDismissTimer,
handleAttention,
handleCompanionStateChanged,
handleDictationToggle,
handleDictationTranscription,
]);
}, [clearDismissTimer, handleAttention, handleDictationToggle, handleDictationTranscription]);
// ── Companion state via Tauri event ────────────────────────────────────
// The desktop companion now lives shell-side and emits a
// `companion://state_changed` Tauri event (camelCase payload) rather than the
// old core Socket.IO `companion:state_changed` broadcast.
useEffect(() => {
let disposed = false;
let unlisten: (() => void) | null = null;
void listen<CompanionStateChangedPayload>('companion://state_changed', event => {
handleCompanionStateChanged(event.payload);
}).then(fn => {
if (disposed) {
fn();
return;
}
unlisten = fn;
});
return () => {
disposed = true;
unlisten?.();
};
}, [handleCompanionStateChanged]);
// ── Poll voice server status as fallback sync ─────────────────────────
// Socket events are the primary state driver, but if an event is missed
-51
View File
@@ -20,7 +20,6 @@ import MeetingsPage from '../components/meetings/MeetingsPage';
import { SettingsLayoutProvider } from '../components/settings/layout/SettingsLayoutContext';
import CompanionPanel from '../components/settings/panels/CompanionPanel';
import ComposioPanel from '../components/settings/panels/ComposioPanel';
import DesktopAgentPanel from '../components/settings/panels/DesktopAgentPanel';
import EmbeddingsPanel from '../components/settings/panels/EmbeddingsPanel';
import LlmConnectionsPanel from '../components/settings/panels/LlmConnectionsPanel';
import MeetingSettingsPanel from '../components/settings/panels/MeetingSettingsPanel';
@@ -29,7 +28,6 @@ import SearchPanel from '../components/settings/panels/SearchPanel';
import UsagePanel from '../components/settings/panels/UsagePanel';
import VoicePanel from '../components/settings/panels/VoicePanel';
import WalletPanel from '../components/settings/panels/WalletPanel';
import AutocompleteSetupModal from '../components/skills/AutocompleteSetupModal';
import ScreenIntelligenceSetupModal from '../components/skills/ScreenIntelligenceSetupModal';
import UnifiedSkillCard from '../components/skills/SkillCard';
import { SKILL_CATEGORY_ORDER, type SkillCategory } from '../components/skills/skillCategories';
@@ -43,7 +41,6 @@ import SkillSearchBar from '../components/skills/SkillSearchBar';
import SkillsExplorerTab from '../components/skills/SkillsExplorerTab';
import VoiceSetupModal from '../components/skills/VoiceSetupModal';
import BetaBanner from '../components/ui/BetaBanner';
import { useAutocompleteSkillStatus } from '../features/autocomplete/useAutocompleteSkillStatus';
import { useScreenIntelligenceSkillStatus } from '../features/screen-intelligence/useScreenIntelligenceSkillStatus';
import { useVoiceSkillStatus } from '../features/voice/useVoiceSkillStatus';
import { useChannelDefinitions } from '../hooks/useChannelDefinitions';
@@ -459,7 +456,6 @@ type ConnectionsTab =
| 'composio-key'
| 'wallet'
| 'screen-intelligence'
| 'desktop-agent'
| 'companion';
/**
@@ -495,10 +491,6 @@ const INTELLIGENCE_HEADERS: Partial<Record<ConnectionsTab, { titleKey: string; d
titleKey: 'pages.settings.features.screenAwareness',
descKey: 'connections.header.screen',
},
'desktop-agent': {
titleKey: 'settings.desktopAgent.title',
descKey: 'connections.header.desktopAgent',
},
companion: {
titleKey: 'pages.settings.features.desktopCompanion',
descKey: 'connections.header.companion',
@@ -518,7 +510,6 @@ const INTELLIGENCE_TABS: ReadonlySet<ConnectionsTab> = new Set<ConnectionsTab>([
'composio-key',
'wallet',
'screen-intelligence',
'desktop-agent',
'companion',
]);
@@ -550,7 +541,6 @@ export default function Skills() {
raw === 'composio-key' ||
raw === 'wallet' ||
raw === 'screen-intelligence' ||
raw === 'desktop-agent' ||
raw === 'companion'
)
return raw;
@@ -620,10 +610,8 @@ export default function Skills() {
null
);
const [screenIntelligenceModalOpen, setScreenIntelligenceModalOpen] = useState(false);
const [autocompleteModalOpen, setAutocompleteModalOpen] = useState(false);
const [voiceModalOpen, setVoiceModalOpen] = useState(false);
const screenIntelligenceStatus = useScreenIntelligenceSkillStatus();
const autocompleteStatus = useAutocompleteSkillStatus();
const voiceStatus = useVoiceSkillStatus();
const [toasts, setToasts] = useState<ToastNotification[]>([]);
@@ -917,33 +905,6 @@ export default function Skills() {
/>
);
}
if (item.id === 'text-autocomplete') {
return (
<UnifiedSkillCard
key={item.id}
icon={item.icon}
title={item.name}
description={item.description}
statusLabel={autocompleteStatus.statusLabel}
statusColor={autocompleteStatus.statusColor}
ctaLabel={autocompleteStatus.ctaLabel}
ctaVariant={autocompleteStatus.ctaVariant}
testId={`skill-row-${item.id}`}
ctaTestId={`skill-install-${item.id}`}
onCtaClick={() => {
if (
autocompleteStatus.platformUnsupported ||
autocompleteStatus.connectionStatus === 'connected' ||
autocompleteStatus.connectionStatus === 'disconnected'
) {
navigate(item.route!);
return;
}
setAutocompleteModalOpen(true);
}}
/>
);
}
if (item.id === 'voice-stt') {
return (
<UnifiedSkillCard
@@ -1064,13 +1025,6 @@ export default function Skills() {
label: t('pages.settings.features.screenAwareness'),
icon: navIcon('M3 5h18v12H3zM8 21h8m-4-4v4'),
},
{
value: 'desktop-agent',
label: t('settings.desktopAgent.title'),
icon: navIcon(
'M9 17v2m6-2v2M5 5h14a1 1 0 011 1v8a1 1 0 01-1 1H5a1 1 0 01-1-1V6a1 1 0 011-1zm4 4l-2 2 2 2m6-4l2 2-2 2'
),
},
{
value: 'companion',
label: t('pages.settings.features.desktopCompanion'),
@@ -1213,7 +1167,6 @@ export default function Skills() {
{activeTab === 'usage' && <UsagePanel />}
{activeTab === 'composio-key' && <ComposioPanel />}
{activeTab === 'screen-intelligence' && <ScreenIntelligencePanel />}
{activeTab === 'desktop-agent' && <DesktopAgentPanel />}
{activeTab === 'companion' && <CompanionPanel />}
</SettingsLayoutProvider>
</div>
@@ -1487,10 +1440,6 @@ export default function Skills() {
/>
)}
{autocompleteModalOpen && (
<AutocompleteSetupModal onClose={() => setAutocompleteModalOpen(false)} />
)}
{voiceModalOpen && (
<VoiceSetupModal onClose={() => setVoiceModalOpen(false)} skillStatus={voiceStatus} />
)}
@@ -151,10 +151,6 @@ vi.mock('../../hooks/useStickToBottom', () => ({
useStickToBottom: vi.fn(() => ({ containerRef: { current: null }, endRef: { current: null } })),
}));
vi.mock('../../features/autocomplete/useAutocompleteSkillStatus', () => ({
useAutocompleteSkillStatus: vi.fn(() => ({ status: 'idle', skills: [] })),
}));
vi.mock('../../utils/openUrl', () => ({ openUrl: vi.fn() }));
// The composer gates image attachments on the resolved model's vision capability
@@ -157,11 +157,6 @@ vi.mock('../../hooks/useStickToBottom', () => ({
useStickToBottom: vi.fn(() => ({ containerRef: { current: null }, endRef: { current: null } })),
}));
// useAutocompleteSkillStatus may make API calls; stub it.
vi.mock('../../features/autocomplete/useAutocompleteSkillStatus', () => ({
useAutocompleteSkillStatus: vi.fn(() => ({ status: 'idle', skills: [] })),
}));
// openUrl uses Tauri; stub it.
vi.mock('../../utils/openUrl', () => ({ openUrl: vi.fn() }));
@@ -124,10 +124,6 @@ vi.mock('../../hooks/useStickToBottom', () => ({
useStickToBottom: vi.fn(() => ({ containerRef: { current: null }, endRef: { current: null } })),
}));
vi.mock('../../features/autocomplete/useAutocompleteSkillStatus', () => ({
useAutocompleteSkillStatus: vi.fn(() => ({ status: 'idle', skills: [] })),
}));
vi.mock('../../utils/openUrl', () => ({ openUrl: vi.fn() }));
vi.mock('../../lib/coreState/store', () => ({
@@ -121,7 +121,7 @@ async function setupLayout(onboardingTasks: unknown = null) {
activeMode: 'os_keyring',
backendName: 'os',
},
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
runtime: { screenIntelligence: null, localAi: null, service: null },
},
isBootstrapping: false,
isReady: true,
-1
View File
@@ -243,7 +243,6 @@ function normalizeSnapshot(
runtime: {
screenIntelligence: result.runtime?.screenIntelligence ?? null,
localAi: result.runtime?.localAi ?? null,
autocomplete: result.runtime?.autocomplete ?? null,
service: result.runtime?.service ?? null,
},
};
+20
View File
@@ -1,6 +1,8 @@
import type { UnlistenFn } from '@tauri-apps/api/event';
import { useEffect, useRef } from 'react';
import { useDaemonLifecycle } from '../hooks/useDaemonLifecycle';
import { subscribeCompanionStateChanged } from '../services/companionEvents';
import { callCoreRpc } from '../services/coreRpcClient';
import { socketService } from '../services/socketService';
import { setBackend, setCore } from '../store/connectivitySlice';
@@ -87,6 +89,24 @@ const SocketProvider = ({ children }: { children: React.ReactNode }) => {
}
}, [token]);
// Bridge the shell's `companion://state_changed` Tauri events into the
// companion Redux slice (replaces the old core Socket.IO companion bridge).
useEffect(() => {
let unlisten: UnlistenFn | null = null;
let disposed = false;
void subscribeCompanionStateChanged().then(fn => {
if (disposed) {
fn();
return;
}
unlisten = fn;
});
return () => {
disposed = true;
unlisten?.();
};
}, []);
// Cleanup on unmount only
useEffect(() => {
return () => {
@@ -44,12 +44,7 @@ function makeSnapshot(overrides: {
chatOnboardingCompleted: false,
analyticsEnabled: false,
localState: {},
runtime: {
screenIntelligence: null as never,
localAi: null as never,
autocomplete: null as never,
service: null as never,
},
runtime: { screenIntelligence: null as never, localAi: null as never, service: null as never },
};
}
@@ -82,7 +77,7 @@ function resetCoreStateStore() {
activeMode: 'os_keyring',
backendName: 'os',
},
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
runtime: { screenIntelligence: null, localAi: null, service: null },
},
teams: [],
teamMembersById: {},
@@ -37,12 +37,7 @@ function makeSnapshot(overrides: {
chatOnboardingCompleted: false,
analyticsEnabled: false,
localState: {},
runtime: {
screenIntelligence: null as never,
localAi: null as never,
autocomplete: null as never,
service: null as never,
},
runtime: { screenIntelligence: null as never, localAi: null as never, service: null as never },
};
}
@@ -109,7 +104,7 @@ function resetCoreStateStore() {
activeMode: 'os_keyring',
backendName: 'os',
},
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
runtime: { screenIntelligence: null, localAi: null, service: null },
},
teams: [],
teamMembersById: {},
@@ -1,7 +1,19 @@
import { describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { parseCompanionStateChangedEvent } from '../socketService';
import {
COMPANION_STATE_CHANGED_EVENT,
parseCompanionStateChangedEvent,
subscribeCompanionStateChanged,
} from '../companionEvents';
const mocks = vi.hoisted(() => ({ dispatch: vi.fn(), isTauri: vi.fn(), listen: vi.fn() }));
vi.mock('@tauri-apps/api/event', () => ({ listen: mocks.listen }));
vi.mock('../../store/index', () => ({ store: { dispatch: mocks.dispatch } }));
vi.mock('../../utils/tauriCommands/common', () => ({ isTauri: mocks.isTauri }));
// The companion state change now arrives as a Tauri `companion://state_changed`
// event with a camelCase payload `{ sessionId, state, previousState }`.
describe('parseCompanionStateChangedEvent', () => {
it('returns null for non-object inputs', () => {
expect(parseCompanionStateChangedEvent(null)).toBeNull();
@@ -10,53 +22,76 @@ describe('parseCompanionStateChangedEvent', () => {
expect(parseCompanionStateChangedEvent('listening')).toBeNull();
});
it('returns null when session_id is missing or non-string', () => {
it('returns null when sessionId is missing or non-string', () => {
expect(parseCompanionStateChangedEvent({ state: 'listening' })).toBeNull();
expect(parseCompanionStateChangedEvent({ session_id: 42, state: 'listening' })).toBeNull();
expect(parseCompanionStateChangedEvent({ sessionId: 42, state: 'listening' })).toBeNull();
});
it('returns null when state is missing or not in the enum', () => {
expect(parseCompanionStateChangedEvent({ session_id: 's1' })).toBeNull();
expect(parseCompanionStateChangedEvent({ session_id: 's1', state: 'unknown' })).toBeNull();
expect(parseCompanionStateChangedEvent({ session_id: 's1', state: 7 })).toBeNull();
expect(parseCompanionStateChangedEvent({ sessionId: 's1' })).toBeNull();
expect(parseCompanionStateChangedEvent({ sessionId: 's1', state: 'unknown' })).toBeNull();
expect(parseCompanionStateChangedEvent({ sessionId: 's1', state: 7 })).toBeNull();
});
it('accepts a valid payload and round-trips all fields', () => {
const event = parseCompanionStateChangedEvent({
session_id: 'sess-1',
sessionId: 'sess-1',
state: 'speaking',
previous_state: 'thinking',
message: 'all good',
});
expect(event).toEqual({
session_id: 'sess-1',
state: 'speaking',
previous_state: 'thinking',
message: 'all good',
previousState: 'thinking',
});
expect(event).toEqual({ sessionId: 'sess-1', state: 'speaking', previousState: 'thinking' });
});
it("defaults previous_state to 'idle' when missing or invalid", () => {
const missing = parseCompanionStateChangedEvent({ session_id: 's', state: 'listening' });
expect(missing?.previous_state).toBe('idle');
it("defaults previousState to 'idle' when missing or invalid", () => {
const missing = parseCompanionStateChangedEvent({ sessionId: 's', state: 'listening' });
expect(missing?.previousState).toBe('idle');
const invalid = parseCompanionStateChangedEvent({
session_id: 's',
sessionId: 's',
state: 'listening',
previous_state: 'banana',
previousState: 'banana',
});
expect(invalid?.previous_state).toBe('idle');
});
it('strips non-string message values', () => {
const event = parseCompanionStateChangedEvent({ session_id: 's', state: 'error', message: 42 });
expect(event?.message).toBeUndefined();
expect(invalid?.previousState).toBe('idle');
});
it('accepts every valid state value', () => {
for (const state of ['idle', 'listening', 'thinking', 'speaking', 'pointing', 'error']) {
const event = parseCompanionStateChangedEvent({ session_id: 's', state });
const event = parseCompanionStateChangedEvent({ sessionId: 's', state });
expect(event?.state).toBe(state);
}
});
});
describe('subscribeCompanionStateChanged', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.isTauri.mockReturnValue(true);
});
it('drops invalid shell events and dispatches valid state changes', async () => {
let handler: ((event: { payload: unknown }) => void) | undefined;
const unlisten = vi.fn();
mocks.listen.mockImplementation(
async (_eventName: string, listener: (event: { payload: unknown }) => void) => {
handler = listener;
return unlisten;
}
);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
await expect(subscribeCompanionStateChanged()).resolves.toBe(unlisten);
expect(mocks.listen).toHaveBeenCalledWith(COMPANION_STATE_CHANGED_EVENT, expect.any(Function));
handler?.({ payload: { sessionId: 'session-1', state: 'invalid' } });
expect(warn).toHaveBeenCalledWith('[companion] state_changed dropped — invalid payload shape');
expect(mocks.dispatch).not.toHaveBeenCalled();
handler?.({
payload: { sessionId: 'session-1', state: 'speaking', previousState: 'thinking' },
});
expect(mocks.dispatch).toHaveBeenCalledWith({
type: 'companion/setCompanionState',
payload: { sessionId: 'session-1', state: 'speaking', previousState: 'thinking' },
});
});
});
@@ -1,89 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { type CouncilDefinition, councilRegistryApi } from './councilRegistryApi';
const mockCallCoreRpc = vi.fn();
vi.mock('../coreRpcClient', () => ({
callCoreRpc: (...args: unknown[]) => mockCallCoreRpc(...args),
}));
const COUNCIL: CouncilDefinition = {
id: 'default-council',
name: 'Default council',
description: 'Balanced analyst, builder, and skeptic jury.',
jury_count: 3,
debate_rounds: 3,
seats: [
{
id: 0,
mode: 'default',
profile_id: '',
name: 'Analyst',
model: 'reasoning-v1',
brief: 'Evidence, assumptions, and risk.',
},
],
judge: { mode: 'default', profile_id: '', name: 'Chief Judge', model: 'reasoning-v1' },
shared_reasoning: '# Shared reasoning',
created_at_ms: 1,
updated_at_ms: 2,
};
describe('councilRegistryApi', () => {
beforeEach(() => {
mockCallCoreRpc.mockReset();
});
it('lists councils and unwraps the core envelope', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: [COUNCIL], logs: ['listed'] });
await expect(councilRegistryApi.list()).resolves.toEqual([COUNCIL]);
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.council_registry_list',
params: {},
});
});
it('gets a council by id and accepts a bare core result', async () => {
mockCallCoreRpc.mockResolvedValueOnce(COUNCIL);
await expect(councilRegistryApi.get('default-council')).resolves.toEqual(COUNCIL);
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.council_registry_get',
params: { id: 'default-council' },
});
});
it('preserves null from get when the council is missing', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: null, logs: ['missing'] });
await expect(councilRegistryApi.get('missing')).resolves.toBeNull();
});
it('upserts a council through the registry controller', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: COUNCIL, logs: ['saved'] });
await expect(councilRegistryApi.upsert(COUNCIL)).resolves.toEqual(COUNCIL);
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.council_registry_upsert',
params: { council: COUNCIL },
});
});
it('deletes a council and returns the persisted deletion result', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: true, logs: ['deleted'] });
await expect(councilRegistryApi.delete('default-council')).resolves.toBe(true);
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.council_registry_delete',
params: { id: 'default-council' },
});
});
it('propagates registry RPC failures', async () => {
mockCallCoreRpc.mockRejectedValueOnce(new Error('registry unavailable'));
await expect(councilRegistryApi.list()).rejects.toThrow('registry unavailable');
});
});
@@ -1,87 +0,0 @@
import debug from 'debug';
import { callCoreRpc } from '../coreRpcClient';
const log = debug('council-registry:api');
export type CouncilSeatMode = 'default' | 'profile' | 'custom';
export interface CouncilSeatDefinition {
id: number;
mode: CouncilSeatMode;
profile_id: string;
name: string;
model: string;
brief: string;
}
export interface CouncilJudgeDefinition {
mode: CouncilSeatMode;
profile_id: string;
name: string;
model: string;
}
export interface CouncilDefinition {
id: string;
name: string;
description: string;
jury_count: number;
debate_rounds: number;
seats: CouncilSeatDefinition[];
judge: CouncilJudgeDefinition;
shared_reasoning: string;
created_at_ms: number;
updated_at_ms: number;
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function unwrapEnvelope<T>(payload: unknown): T {
const record = asRecord(payload);
if (record && 'result' in record && 'logs' in record && Array.isArray(record.logs)) {
return record.result as T;
}
return payload as T;
}
export const councilRegistryApi = {
list: async (): Promise<CouncilDefinition[]> => {
log('list councils');
const payload = await callCoreRpc<unknown>({
method: 'openhuman.council_registry_list',
params: {},
});
return unwrapEnvelope<CouncilDefinition[]>(payload);
},
get: async (id: string): Promise<CouncilDefinition | null> => {
log('get council id=%s', id);
const payload = await callCoreRpc<unknown>({
method: 'openhuman.council_registry_get',
params: { id },
});
return unwrapEnvelope<CouncilDefinition | null>(payload);
},
upsert: async (council: CouncilDefinition): Promise<CouncilDefinition> => {
log('upsert council id=%s name=%s', council.id, council.name);
const payload = await callCoreRpc<unknown>({
method: 'openhuman.council_registry_upsert',
params: { council },
});
return unwrapEnvelope<CouncilDefinition>(payload);
},
delete: async (id: string): Promise<boolean> => {
log('delete council id=%s', id);
const payload = await callCoreRpc<unknown>({
method: 'openhuman.council_registry_delete',
params: { id },
});
return unwrapEnvelope<boolean>(payload);
},
};
@@ -1,155 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { modelCouncilApi, type ModelCouncilResult, unwrapCouncilEnvelope } from './modelCouncilApi';
const mockCallCoreRpc = vi.fn();
vi.mock('../coreRpcClient', () => ({
callCoreRpc: (...args: unknown[]) => mockCallCoreRpc(...args),
}));
const RESULT: ModelCouncilResult = {
question: 'What is the capital of France?',
members: [
{ model: 'model-a', response: 'Paris.', error: null },
{ model: 'model-b', response: null, error: 'rate limited' },
],
chair_model: 'chair-model',
synthesis: 'Both agree the capital is Paris (one seat failed).',
};
describe('unwrapCouncilEnvelope', () => {
it('unwraps the { result, logs } CLI envelope', () => {
expect(unwrapCouncilEnvelope({ result: RESULT, logs: ['done'] })).toEqual(RESULT);
});
it('passes a bare result through unchanged', () => {
expect(unwrapCouncilEnvelope(RESULT)).toEqual(RESULT);
});
it('passes through an object with a result field but no logs (not a real envelope)', () => {
// Only `{ result, logs }` (logs array present) is treated as the CLI
// envelope. A bare `{ result }` is returned unchanged, not unwrapped.
const notAnEnvelope = { result: RESULT } as unknown;
expect(unwrapCouncilEnvelope(notAnEnvelope)).toEqual({ result: RESULT });
});
it('does NOT unwrap a null result (guards against { result: null, logs } crashing the UI)', () => {
// A partial-error envelope with a null result must not be unwrapped to
// `null` — that would crash the component on `result.members`. The guard
// returns the payload as-is so the caller surfaces it as a malformed shape.
const withNull = { result: null, logs: ['boom'] } as unknown;
expect(unwrapCouncilEnvelope(withNull)).toEqual({ result: null, logs: ['boom'] });
});
});
describe('modelCouncilApi.runCouncil', () => {
beforeEach(() => {
mockCallCoreRpc.mockReset();
});
it('calls openhuman.model_council_run with the params + a long timeout', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: RESULT, logs: ['ok'] });
const out = await modelCouncilApi.runCouncil({
question: 'What is the capital of France?',
member_models: ['model-a', 'model-b'],
chair_model: 'chair-model',
temperature: 0.4,
});
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.model_council_run',
params: {
question: 'What is the capital of France?',
member_models: ['model-a', 'model-b'],
chair_model: 'chair-model',
temperature: 0.4,
},
timeoutMs: 180_000,
});
expect(out).toEqual(RESULT);
});
it('returns the unwrapped result when the core wraps it in an envelope', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: RESULT, logs: ['done'] });
const out = await modelCouncilApi.runCouncil({
question: 'q',
member_models: ['a'],
chair_model: 'c',
});
expect(out.members).toHaveLength(2);
expect(out.synthesis).toContain('Paris');
});
it('returns a bare result unchanged when no envelope is present', async () => {
mockCallCoreRpc.mockResolvedValueOnce(RESULT);
const out = await modelCouncilApi.runCouncil({
question: 'q',
member_models: ['a'],
chair_model: 'c',
});
expect(out).toEqual(RESULT);
});
it('propagates errors from the RPC layer', async () => {
mockCallCoreRpc.mockRejectedValueOnce(new Error('all member models failed'));
await expect(
modelCouncilApi.runCouncil({ question: 'q', member_models: ['a'], chair_model: 'c' })
).rejects.toThrow('all member models failed');
});
});
describe('modelCouncilApi.answerMember', () => {
beforeEach(() => {
mockCallCoreRpc.mockReset();
});
it('calls openhuman.model_council_answer_member with a long timeout', async () => {
const member = { model: 'reasoning-v1', response: 'Paris.', error: null };
mockCallCoreRpc.mockResolvedValueOnce({ result: member, logs: ['answered'] });
await expect(
modelCouncilApi.answerMember({
question: 'What is the capital of France?',
model: 'reasoning-v1',
temperature: 0.2,
})
).resolves.toEqual(member);
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.model_council_answer_member',
params: {
question: 'What is the capital of France?',
model: 'reasoning-v1',
temperature: 0.2,
},
timeoutMs: 180_000,
});
});
});
describe('modelCouncilApi.synthesizeCouncil', () => {
beforeEach(() => {
mockCallCoreRpc.mockReset();
});
it('calls openhuman.model_council_synthesize and unwraps the result', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: RESULT, logs: ['synthesized'] });
await expect(
modelCouncilApi.synthesizeCouncil({
question: 'What is the capital of France?',
members: RESULT.members,
chair_model: 'chair-model',
temperature: 0.1,
})
).resolves.toEqual(RESULT);
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.model_council_synthesize',
params: {
question: 'What is the capital of France?',
members: RESULT.members,
chair_model: 'chair-model',
temperature: 0.1,
},
timeoutMs: 180_000,
});
});
});
-134
View File
@@ -1,134 +0,0 @@
/**
* Typed RPC wrapper for the Model Council domain.
*
* Calls `openhuman.model_council_run` runs a question through several
* "member" models in parallel, then a "chair" model synthesizes their
* answers (surfacing agreement / disagreement / unique insight).
*
* The core returns the result inside a `{ result, logs }` CLI envelope
* (the handler attaches a completion log), so `runCouncil` unwraps it
* defensively and returns the bare `ModelCouncilResult`.
*/
import debug from 'debug';
import { callCoreRpc } from '../coreRpcClient';
const log = debug('model-council:api');
/** One member model's contribution. `response` and `error` are mutually exclusive. */
export interface CouncilMemberResult {
/** The model id this seat ran. */
model: string;
/** The model's answer, or `null` if the call failed. */
response: string | null;
/** The failure message, or `null` on success. */
error: string | null;
}
/** Full council result: every member's answer plus the chair synthesis. */
export interface ModelCouncilResult {
question: string;
members: CouncilMemberResult[];
chair_model: string;
synthesis: string;
}
export interface RunCouncilParams {
question: string;
/** Member model ids or "default" seats to consult (repeated seats preserved; capped server-side). */
member_models: string[];
/** Model id that synthesizes the member answers. */
chair_model: string;
/** Optional sampling temperature applied to every call. */
temperature?: number;
}
export interface RunCouncilMemberParams {
question: string;
model: string;
temperature?: number;
}
export interface SynthesizeCouncilParams {
question: string;
members: CouncilMemberResult[];
chair_model: string;
temperature?: number;
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
/**
* Unwrap the core's `{ result, logs }` CLI envelope. Returns the inner
* `result` when the envelope shape is present, otherwise passes the payload
* through unchanged so the caller is correct whether or not the handler
* attached logs.
*/
export function unwrapCouncilEnvelope(payload: unknown): ModelCouncilResult {
const record = asRecord(payload);
if (
record &&
'result' in record &&
record.result != null &&
'logs' in record &&
Array.isArray(record.logs)
) {
return record.result as ModelCouncilResult;
}
return payload as ModelCouncilResult;
}
export const modelCouncilApi = {
answerMember: async (params: RunCouncilMemberParams): Promise<CouncilMemberResult> => {
log('answer member question=%s model=%s', params.question.slice(0, 40), params.model);
const payload = await callCoreRpc<unknown>({
method: 'openhuman.model_council_answer_member',
params,
timeoutMs: 180_000,
});
return unwrapCouncilEnvelope(payload) as unknown as CouncilMemberResult;
},
synthesizeCouncil: async (params: SynthesizeCouncilParams): Promise<ModelCouncilResult> => {
log(
'synthesize question=%s members=%d chair=%s',
params.question.slice(0, 40),
params.members.length,
params.chair_model
);
const payload = await callCoreRpc<unknown>({
method: 'openhuman.model_council_synthesize',
params,
timeoutMs: 180_000,
});
return unwrapCouncilEnvelope(payload);
},
runCouncil: async (params: RunCouncilParams): Promise<ModelCouncilResult> => {
log(
'run question=%s members=%o chair=%s',
params.question.slice(0, 40),
params.member_models,
params.chair_model
);
const payload = await callCoreRpc<unknown>({
method: 'openhuman.model_council_run',
params,
// Member calls run in parallel but each is a full model round-trip plus
// a synthesis pass — give it well beyond the default 30s before timing out.
timeoutMs: 180_000,
});
const result = unwrapCouncilEnvelope(payload);
log(
'run done: %d members, synthesis %d chars',
result.members?.length ?? 0,
result.synthesis?.length ?? 0
);
return result;
},
};
+65
View File
@@ -0,0 +1,65 @@
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import {
type CompanionState,
type CompanionStateChangedEvent,
setCompanionState,
} from '../store/companionSlice';
import { store } from '../store/index';
import { isTauri } from '../utils/tauriCommands/common';
/**
* Companion state changes are now delivered by the Tauri shell as a
* `companion://state_changed` event (camelCase payload
* `{ sessionId, state, previousState }`), replacing the old core Socket.IO
* `companion:state_changed` bridge. This module parses that payload and pumps
* it into the companion Redux slice.
*/
export const COMPANION_STATE_CHANGED_EVENT = 'companion://state_changed';
const COMPANION_STATES: ReadonlySet<string> = new Set([
'idle',
'listening',
'thinking',
'speaking',
'pointing',
'error',
]);
/**
* Validate + normalize a raw `companion://state_changed` payload into a typed
* event, or `null` if the shape is invalid. Tolerant of a missing/invalid
* `previousState` (defaults to `'idle'`).
*/
export function parseCompanionStateChangedEvent(value: unknown): CompanionStateChangedEvent | null {
if (!value || typeof value !== 'object') return null;
const obj = value as Record<string, unknown>;
if (typeof obj.sessionId !== 'string') return null;
if (typeof obj.state !== 'string' || !COMPANION_STATES.has(obj.state)) return null;
const previousState =
typeof obj.previousState === 'string' && COMPANION_STATES.has(obj.previousState)
? (obj.previousState as CompanionState)
: 'idle';
return { sessionId: obj.sessionId, state: obj.state as CompanionState, previousState };
}
/**
* Subscribe to the shell's companion state-changed events and dispatch them into
* the companion slice. No-op (resolves to a noop unlisten) outside Tauri.
*/
export async function subscribeCompanionStateChanged(): Promise<UnlistenFn> {
if (!isTauri()) {
return () => {};
}
return listen(COMPANION_STATE_CHANGED_EVENT, event => {
const parsed = parseCompanionStateChangedEvent(event.payload);
if (!parsed) {
console.warn('[companion] state_changed dropped — invalid payload shape');
return;
}
store.dispatch(setCompanionState(parsed));
});
}
+1 -1
View File
@@ -22,7 +22,7 @@ function makeSnapshotResult(overrides: Record<string, unknown> = {}) {
activeMode: 'os_keyring',
backendName: 'os',
},
runtime: { screenIntelligence: {}, localAi: {}, autocomplete: {}, service: {} },
runtime: { screenIntelligence: {}, localAi: {}, service: {} },
...overrides,
};
}
-2
View File
@@ -1,7 +1,6 @@
import type { User } from '../types/api';
import type { TeamInvite, TeamMember, TeamWithRole } from '../types/team';
import type { AccessibilityStatus } from '../utils/tauriCommands/accessibility';
import type { AutocompleteStatus } from '../utils/tauriCommands/autocomplete';
import type { LocalAiStatus } from '../utils/tauriCommands/localAi';
import type { ServiceStatus } from '../utils/tauriCommands/service';
import { callCoreRpc } from './coreRpcClient';
@@ -61,7 +60,6 @@ interface AppStateSnapshotResult {
runtime: {
screenIntelligence: AccessibilityStatus;
localAi: LocalAiStatus;
autocomplete: AutocompleteStatus;
service: ServiceStatus;
};
/**
-42
View File
@@ -15,7 +15,6 @@ import {
setBackendMeetTranscript,
} from '../store/backendMeetSlice';
import { upsertChannelConnection } from '../store/channelConnectionsSlice';
import { type CompanionStateChangedEvent, setCompanionState } from '../store/companionSlice';
import { setBackend } from '../store/connectivitySlice';
import { resetForUser, setSocketIdForUser, setStatusForUser } from '../store/socketSlice';
import type { ChannelAuthMode, ChannelConnectionStatus, ChannelType } from '../types/channels';
@@ -104,35 +103,6 @@ function normalizeChannelConnectionUpdatePayload(
};
}
const COMPANION_STATES: ReadonlySet<string> = new Set([
'idle',
'listening',
'thinking',
'speaking',
'pointing',
'error',
]);
export function parseCompanionStateChangedEvent(value: unknown): CompanionStateChangedEvent | null {
if (!value || typeof value !== 'object') return null;
const obj = value as Record<string, unknown>;
if (typeof obj.session_id !== 'string') return null;
if (typeof obj.state !== 'string' || !COMPANION_STATES.has(obj.state)) return null;
const previous =
typeof obj.previous_state === 'string' && COMPANION_STATES.has(obj.previous_state)
? (obj.previous_state as CompanionStateChangedEvent['previous_state'])
: 'idle';
const message = typeof obj.message === 'string' ? obj.message : undefined;
return {
session_id: obj.session_id,
state: obj.state as CompanionStateChangedEvent['state'],
previous_state: previous,
message,
};
}
function getSocketUserId(): string {
return getCoreStateSnapshot().snapshot?.auth?.userId ?? '__pending__';
}
@@ -420,18 +390,6 @@ class SocketService {
);
});
// Companion state change events — dispatch into the companion Redux slice
// so settings panel and other UI can react to session lifecycle.
this.socket.on('companion:state_changed', (data: unknown) => {
const event = parseCompanionStateChangedEvent(data);
if (!event) {
socketWarn('companion:state_changed dropped — invalid payload shape');
return;
}
socketLog('companion:state_changed → %s', event.state);
store.dispatch(setCompanionState(event));
});
// Permanent user-config / billing failures surfaced from background jobs
// (e.g. cron) — core broadcasts these to the "system" room as a
// metadata-only `user_error` event carrying a stable kind token in
@@ -29,7 +29,7 @@ function makeCoreState(token: string | null, userId: string | null = null): Core
activeMode: 'os_keyring',
backendName: 'os',
},
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
runtime: { screenIntelligence: null, localAi: null, service: null },
},
teams: [],
teamMembersById: {},
+5 -11
View File
@@ -31,7 +31,7 @@ describe('companionSlice', () => {
it('setCompanionState updates state, sessionId, and sessionActive', () => {
const state = companionReducer(
initialState,
setCompanionState({ session_id: 'sess-1', state: 'listening', previous_state: 'idle' })
setCompanionState({ sessionId: 'sess-1', state: 'listening', previousState: 'idle' })
);
expect(state.state).toBe('listening');
expect(state.sessionId).toBe('sess-1');
@@ -47,24 +47,18 @@ describe('companionSlice', () => {
};
const state = companionReducer(
active,
setCompanionState({ session_id: 'sess-1', state: 'idle', previous_state: 'speaking' })
setCompanionState({ sessionId: 'sess-1', state: 'idle', previousState: 'speaking' })
);
expect(state.sessionActive).toBe(false);
expect(state.state).toBe('idle');
});
it('setCompanionState to error stores message', () => {
it('setCompanionState to error clears sessionActive and keeps state', () => {
const state = companionReducer(
initialState,
setCompanionState({
session_id: 'sess-1',
state: 'error',
previous_state: 'thinking',
message: 'LLM timeout',
})
setCompanionState({ sessionId: 'sess-1', state: 'error', previousState: 'thinking' })
);
expect(state.state).toBe('error');
expect(state.lastError).toBe('LLM timeout');
expect(state.sessionActive).toBe(false);
});
@@ -72,7 +66,7 @@ describe('companionSlice', () => {
const withError: CompanionSliceState = { ...initialState, lastError: 'old failure' };
const state = companionReducer(
withError,
setCompanionState({ session_id: 'sess-1', state: 'listening', previous_state: 'error' })
setCompanionState({ sessionId: 'sess-1', state: 'listening', previousState: 'error' })
);
expect(state.lastError).toBeNull();
expect(state.state).toBe('listening');
+12 -11
View File
@@ -2,7 +2,7 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import { resetUserScopedState } from './resetActions';
// ── Types matching Rust `desktop_companion/types.rs` ─────────────────
// ── Types matching the Tauri-side companion module ───────────────────
/** Matches `CompanionState` enum (serde rename_all = "snake_case"). */
export type CompanionState = 'idle' | 'listening' | 'thinking' | 'speaking' | 'pointing' | 'error';
@@ -16,15 +16,18 @@ export interface CompanionConfig {
include_app_context: boolean;
}
/** Matches `CompanionStateChangedEvent` — Socket.IO payload. */
/**
* Matches the `companion://state_changed` Tauri event payload
* (`CompanionStateChangedEvent`, serialized camelCase). Replaces the old
* Socket.IO `companion:state_changed` snake_case payload.
*/
export interface CompanionStateChangedEvent {
session_id: string;
sessionId: string;
state: CompanionState;
previous_state: CompanionState;
message?: string;
previousState: CompanionState;
}
/** Matches `CompanionSessionStatus` — RPC response. */
/** Matches `CompanionSessionStatus` — command response (snake_case). */
export interface CompanionSessionStatus {
active: boolean;
state: CompanionState;
@@ -76,11 +79,9 @@ const companionSlice = createSlice({
setCompanionState(state, action: PayloadAction<CompanionStateChangedEvent>) {
const event = action.payload;
state.state = event.state;
state.sessionId = event.session_id;
state.sessionId = event.sessionId;
state.sessionActive = event.state !== 'idle' && event.state !== 'error';
if (event.state === 'error') {
if (event.message) state.lastError = event.message;
} else {
if (event.state !== 'error') {
// Clear stale error once the session recovers to a healthy state.
state.lastError = null;
}
@@ -111,7 +112,7 @@ export const { setCompanionState, setSessionActive, setConfig, setLastError, cle
companionSlice.actions;
// ── Selectors ────────────────────────────────────────────────────────
// Selectors tolerate a missing `companion` slice so consumers (e.g. BottomTabBar)
// Selectors tolerate a missing `companion` slice so consumers (e.g. SidebarNav)
// don't crash inside test harnesses that mock the store without this slice.
type MaybeCompanionRoot = { companion?: CompanionSliceState };
+1 -5
View File
@@ -1,5 +1,5 @@
/**
* Shared Vitest mocks for screen-intelligence / autocomplete / voice status hooks.
* Shared Vitest mocks for screen-intelligence / voice status hooks.
* Import this module first in Skills page tests so `Skills` does not require `CoreStateProvider`.
*/
import { vi } from 'vitest';
@@ -22,10 +22,6 @@ vi.mock('../features/screen-intelligence/useScreenIntelligenceSkillStatus', () =
}),
}));
vi.mock('../features/autocomplete/useAutocompleteSkillStatus', () => ({
useAutocompleteSkillStatus: () => ({ ...offlineStatusBase, platformUnsupported: false }),
}));
vi.mock('../features/voice/useVoiceSkillStatus', () => ({
useVoiceSkillStatus: () => ({
...offlineStatusBase,
-216
View File
@@ -1,216 +0,0 @@
/**
* Autocomplete commands.
*/
import { callCoreRpc } from '../../services/coreRpcClient';
import { CommandResponse, isTauri } from './common';
export interface AutocompleteSuggestion {
value: string;
confidence: number;
}
export interface AutocompleteStatus {
platform_supported: boolean;
enabled: boolean;
running: boolean;
phase: string;
debounce_ms: number;
model_id: string;
app_name?: string | null;
last_error?: string | null;
updated_at_ms?: number | null;
suggestion?: AutocompleteSuggestion | null;
}
export interface AutocompleteStartParams {
debounce_ms?: number;
}
export interface AutocompleteStartResult {
started: boolean;
}
export interface AutocompleteStopParams {
reason?: string;
}
export interface AutocompleteStopResult {
stopped: boolean;
}
export interface AutocompleteCurrentParams {
context?: string;
}
export interface AutocompleteCurrentResult {
app_name?: string | null;
context: string;
suggestion?: AutocompleteSuggestion | null;
}
export interface AutocompleteDebugFocusResult {
app_name?: string | null;
role?: string | null;
context: string;
selected_text?: string | null;
raw_error?: string | null;
}
export interface AutocompleteAcceptParams {
suggestion?: string;
/** When true, skip applying text via accessibility (caller already inserted it). */
skip_apply?: boolean;
}
export interface AutocompleteAcceptResult {
accepted: boolean;
applied: boolean;
value?: string | null;
reason?: string | null;
}
export interface AutocompleteSetStyleParams {
enabled?: boolean;
debounce_ms?: number;
max_chars?: number;
style_preset?: string;
style_instructions?: string;
style_examples?: string[];
disabled_apps?: string[];
accept_with_tab?: boolean;
overlay_ttl_ms?: number;
}
export interface AutocompleteConfig {
enabled: boolean;
debounce_ms: number;
max_chars: number;
style_preset: string;
style_instructions?: string | null;
style_examples: string[];
disabled_apps: string[];
accept_with_tab: boolean;
overlay_ttl_ms: number;
}
export interface AutocompleteSetStyleResult {
config: AutocompleteConfig;
}
export interface AcceptedCompletion {
context: string;
suggestion: string;
app_name?: string | null;
timestamp_ms: number;
}
export interface AutocompleteHistoryResult {
entries: AcceptedCompletion[];
}
export interface AutocompleteClearHistoryResult {
cleared: number;
}
export async function openhumanAutocompleteStatus(): Promise<CommandResponse<AutocompleteStatus>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await callCoreRpc<CommandResponse<AutocompleteStatus>>({
method: 'openhuman.autocomplete_status',
});
}
export async function openhumanAutocompleteStart(
params?: AutocompleteStartParams
): Promise<CommandResponse<AutocompleteStartResult>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await callCoreRpc<CommandResponse<AutocompleteStartResult>>({
method: 'openhuman.autocomplete_start',
params: params ?? {},
});
}
export async function openhumanAutocompleteStop(
params?: AutocompleteStopParams
): Promise<CommandResponse<AutocompleteStopResult>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await callCoreRpc<CommandResponse<AutocompleteStopResult>>({
method: 'openhuman.autocomplete_stop',
params: params ?? {},
});
}
export async function openhumanAutocompleteCurrent(
params?: AutocompleteCurrentParams
): Promise<CommandResponse<AutocompleteCurrentResult>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await callCoreRpc<CommandResponse<AutocompleteCurrentResult>>({
method: 'openhuman.autocomplete_current',
params: params ?? {},
});
}
export async function openhumanAutocompleteDebugFocus(): Promise<
CommandResponse<AutocompleteDebugFocusResult>
> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await callCoreRpc<CommandResponse<AutocompleteDebugFocusResult>>({
method: 'openhuman.autocomplete_debug_focus',
});
}
export async function openhumanAutocompleteAccept(
params?: AutocompleteAcceptParams
): Promise<CommandResponse<AutocompleteAcceptResult>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await callCoreRpc<CommandResponse<AutocompleteAcceptResult>>({
method: 'openhuman.autocomplete_accept',
params: params ?? {},
});
}
export async function openhumanAutocompleteSetStyle(
params: AutocompleteSetStyleParams
): Promise<CommandResponse<AutocompleteSetStyleResult>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await callCoreRpc<CommandResponse<AutocompleteSetStyleResult>>({
method: 'openhuman.autocomplete_set_style',
params,
});
}
export async function openhumanAutocompleteHistory(params?: {
limit?: number;
}): Promise<CommandResponse<AutocompleteHistoryResult>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await callCoreRpc<CommandResponse<AutocompleteHistoryResult>>({
method: 'openhuman.autocomplete_history',
params: params ?? {},
});
}
export async function openhumanAutocompleteClearHistory(): Promise<
CommandResponse<AutocompleteClearHistoryResult>
> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await callCoreRpc<CommandResponse<AutocompleteClearHistoryResult>>({
method: 'openhuman.autocomplete_clear_history',
});
}
-1
View File
@@ -17,7 +17,6 @@ export * from './taskSources';
export * from './heartbeat';
export * from './service';
export * from './accessibility';
export * from './autocomplete';
export * from './voice';
export * from './aboutApp';
export * from './workspacePaths';
+23 -43
View File
@@ -4,7 +4,7 @@
import { beforeEach, describe, expect, type Mock, test, vi } from 'vitest';
import { callCoreRpc } from '../../services/coreRpcClient';
import { isTauri } from './common';
import { isTauri, safeInvoke } from './common';
import {
aiListMemoryFiles,
memoryLearnAll,
@@ -15,10 +15,11 @@ import {
} from './memory';
vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
vi.mock('./common', () => ({ isTauri: vi.fn(() => true) }));
vi.mock('./common', () => ({ isTauri: vi.fn(() => true), safeInvoke: vi.fn() }));
const mockCallCoreRpc = callCoreRpc as Mock;
const mockIsTauri = isTauri as Mock;
const mockSafeInvoke = safeInvoke as Mock;
beforeEach(() => {
vi.clearAllMocks();
@@ -162,43 +163,30 @@ describe('whatsappListChats', () => {
test('throws when not in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
await expect(whatsappListChats()).rejects.toThrow('Not running in Tauri');
expect(mockCallCoreRpc).not.toHaveBeenCalled();
expect(mockSafeInvoke).not.toHaveBeenCalled();
});
test('calls correct RPC method with provided params', async () => {
mockCallCoreRpc.mockResolvedValueOnce([]);
test('invokes the shell command with provided params wrapped in req', async () => {
mockSafeInvoke.mockResolvedValueOnce([]);
await whatsappListChats({ limit: 10 });
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.whatsapp_data_list_chats',
params: { limit: 10 },
});
expect(mockSafeInvoke).toHaveBeenCalledWith('whatsapp_data_list_chats', { req: { limit: 10 } });
});
test('uses empty params object when none provided', async () => {
mockCallCoreRpc.mockResolvedValueOnce([]);
test('uses empty req object when no params provided', async () => {
mockSafeInvoke.mockResolvedValueOnce([]);
await whatsappListChats();
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.whatsapp_data_list_chats',
params: {},
});
expect(mockSafeInvoke).toHaveBeenCalledWith('whatsapp_data_list_chats', { req: {} });
});
test('returns array directly when response is already an array', async () => {
test('returns the chat array from the command', async () => {
const chats = [{ chat_id: 'c1', display_name: 'Direct' }];
mockCallCoreRpc.mockResolvedValueOnce(chats);
mockSafeInvoke.mockResolvedValueOnce(chats);
const result = await whatsappListChats();
expect(result).toBe(chats);
});
test('extracts result field from wrapped response envelope', async () => {
const chats = [{ chat_id: 'c2', display_name: 'Wrapped' }];
mockCallCoreRpc.mockResolvedValueOnce({ result: chats, logs: [] });
const result = await whatsappListChats();
expect(result).toEqual(chats);
});
test('returns empty array when wrapped response has no result field', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ logs: [] });
test('returns empty array when the command yields a nullish result', async () => {
mockSafeInvoke.mockResolvedValueOnce(undefined);
const result = await whatsappListChats();
expect(result).toEqual([]);
});
@@ -208,34 +196,26 @@ describe('whatsappListMessages', () => {
test('throws when not in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
await expect(whatsappListMessages({ chat_id: 'c1' })).rejects.toThrow('Not running in Tauri');
expect(mockCallCoreRpc).not.toHaveBeenCalled();
expect(mockSafeInvoke).not.toHaveBeenCalled();
});
test('calls correct RPC method with required chat_id param', async () => {
mockCallCoreRpc.mockResolvedValueOnce([]);
test('invokes the shell command with required chat_id in req', async () => {
mockSafeInvoke.mockResolvedValueOnce([]);
await whatsappListMessages({ chat_id: 'c1', limit: 50 });
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.whatsapp_data_list_messages',
params: { chat_id: 'c1', limit: 50 },
expect(mockSafeInvoke).toHaveBeenCalledWith('whatsapp_data_list_messages', {
req: { chat_id: 'c1', limit: 50 },
});
});
test('returns array directly when response is already an array', async () => {
test('returns the message array from the command', async () => {
const msgs = [{ message_id: 'm1', body: 'hello' }];
mockCallCoreRpc.mockResolvedValueOnce(msgs);
mockSafeInvoke.mockResolvedValueOnce(msgs);
const result = await whatsappListMessages({ chat_id: 'c1' });
expect(result).toBe(msgs);
});
test('extracts result field from wrapped response envelope', async () => {
const msgs = [{ message_id: 'm2', body: 'world' }];
mockCallCoreRpc.mockResolvedValueOnce({ result: msgs, logs: [] });
const result = await whatsappListMessages({ chat_id: 'c1' });
expect(result).toEqual(msgs);
});
test('returns empty array when wrapped response has no result field', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ logs: [] });
test('returns empty array when the command yields a nullish result', async () => {
mockSafeInvoke.mockResolvedValueOnce(undefined);
const result = await whatsappListMessages({ chat_id: 'c1' });
expect(result).toEqual([]);
});
+16 -14
View File
@@ -2,7 +2,7 @@
* Memory subsystem commands.
*/
import { callCoreRpc } from '../../services/coreRpcClient';
import { isTauri } from './common';
import { isTauri, safeInvoke } from './common';
export interface MemoryDebugDocument {
documentId: string;
@@ -377,7 +377,13 @@ export interface WhatsAppMessage {
source: string;
}
/** List WhatsApp chats from the local store (scanner-populated). */
/**
* List WhatsApp chats from the local store (scanner-populated).
*
* The whatsapp_data store lives in the Tauri shell (relocated from the Rust
* core), so this reads it through the `whatsapp_data_list_chats` Tauri command
* rather than a core RPC. The command returns the row array directly.
*/
export async function whatsappListChats(params?: {
account_id?: string;
limit?: number;
@@ -386,15 +392,14 @@ export async function whatsappListChats(params?: {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
const resp = await callCoreRpc<{ result?: WhatsAppChat[]; logs?: string[] } | WhatsAppChat[]>({
method: 'openhuman.whatsapp_data_list_chats',
params: params ?? {},
});
if (Array.isArray(resp)) return resp;
return (resp as { result?: WhatsAppChat[] }).result ?? [];
const resp = await safeInvoke<WhatsAppChat[]>('whatsapp_data_list_chats', { req: params ?? {} });
return resp ?? [];
}
/** List messages for a chat from the local store. */
/**
* List messages for a chat from the local shell-side store via the
* `whatsapp_data_list_messages` Tauri command.
*/
export async function whatsappListMessages(params: {
chat_id: string;
account_id?: string;
@@ -404,9 +409,6 @@ export async function whatsappListMessages(params: {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
const resp = await callCoreRpc<
{ result?: WhatsAppMessage[]; logs?: string[] } | WhatsAppMessage[]
>({ method: 'openhuman.whatsapp_data_list_messages', params });
if (Array.isArray(resp)) return resp;
return (resp as { result?: WhatsAppMessage[] }).result ?? [];
const resp = await safeInvoke<WhatsAppMessage[]>('whatsapp_data_list_messages', { req: params });
return resp ?? [];
}
-26
View File
@@ -28,32 +28,6 @@ export const TOOL_CATALOG: ToolDefinition[] = [
defaultEnabled: true,
rustToolNames: ['shell'],
},
{
id: 'launch_app',
displayName: 'Launch Applications',
description: 'Open apps on your desktop by name (e.g. Music, Spotify, Safari).',
category: 'System',
defaultEnabled: true,
rustToolNames: ['launch_app'],
},
{
id: 'ax_interact',
displayName: 'App UI Control',
description:
'Interact with desktop app UI by element label via the platform accessibility API — click buttons, type in fields, without needing screen coordinates.',
category: 'System',
defaultEnabled: true,
rustToolNames: ['ax_interact'],
},
{
id: 'automate',
displayName: 'App Automation',
description:
'Accomplish a multi-step goal in an app in one go (e.g. "play a song in Music", "message someone in Slack") — the agent drives the UI step by step.',
category: 'System',
defaultEnabled: true,
rustToolNames: ['automate'],
},
{
id: 'git_operations',
displayName: 'Git Operations',
@@ -1,98 +0,0 @@
import { waitForApp, waitForAppReady } from '../helpers/app-helpers';
import { triggerAuthDeepLinkBypass } from '../helpers/deep-link-helpers';
import {
textExists,
waitForText,
waitForWebView,
waitForWindowVisible,
} from '../helpers/element-helpers';
import { supportsExecuteScript } from '../helpers/platform';
import { completeOnboardingIfVisible, navigateViaHash } from '../helpers/shared-flows';
import { startMockServer, stopMockServer } from '../mock-server';
/**
* Autocomplete settings panel smoke spec narrow scope.
*
* What this spec proves: the AutocompletePanel mounts under /settings,
* the skill-status pill renders one of the canonical labels surfaced by
* `useAutocompleteSkillStatus`, and the matching CTA renders. That is
* the entire claim this spec does NOT exercise:
* - 5.2.1 inline suggestion generation (requires real keystrokes inside
* a third-party text field + macOS Accessibility + Input Monitoring
* TCC grants see manual smoke checklist #971)
* - 5.2.2 debounce timing (covered by the Vitest hook test in
* `app/src/features/autocomplete/__tests__/useAutocompleteSkillStatus.test.tsx`
* for the status surface; debounce of the engine itself is a Rust
* unit test concern)
* - 5.2.3 acceptance trigger (manual smoke + Rust unit)
*
* The coverage matrix downgrades 5.2.1 / 5.2.3 to 🟡 to reflect this.
*
* Mac2 skipped Settings sidebar label mapping not yet exposed to Appium.
*/
function stepLog(message: string, context?: unknown): void {
const stamp = new Date().toISOString();
if (context === undefined) {
console.log(`[AutocompleteFlowE2E][${stamp}] ${message}`);
return;
}
console.log(`[AutocompleteFlowE2E][${stamp}] ${message}`, JSON.stringify(context, null, 2));
}
describe('Autocomplete settings panel smoke', () => {
before(async function beforeSuite() {
this.timeout(90_000);
if (!supportsExecuteScript()) {
stepLog('Skipping suite on Mac2 — Settings sidebar not mapped');
this.skip();
}
stepLog('starting mock server');
await startMockServer();
stepLog('waiting for app');
await waitForApp();
stepLog('triggering auth bypass deep link');
await triggerAuthDeepLinkBypass('e2e-autocomplete-flow');
await waitForWindowVisible(25_000);
await waitForWebView(15_000);
await waitForAppReady(15_000);
await completeOnboardingIfVisible('[AutocompleteFlowE2E]');
});
after(async () => {
stepLog('stopping mock server');
await stopMockServer();
});
it('mounts the autocomplete settings panel and renders status', async function () {
this.timeout(90_000);
stepLog('navigating to /settings/autocomplete');
await navigateViaHash('/settings/autocomplete');
// Panel chrome — at least one of the skill-status labels rendered by
// useAutocompleteSkillStatus must show. Status text is one of:
// Active / Offline / Error / Unsupported.
await waitForText('Auto', 15_000);
const statusVisible =
(await textExists('Active')) ||
(await textExists('Offline')) ||
(await textExists('Error')) ||
(await textExists('Unsupported'));
expect(statusVisible).toBe(true);
});
it('renders an Enable / Manage / Retry CTA driven by skill status', async () => {
// Re-establish route state so this case is runnable in isolation; do not
// depend on the previous `it` having navigated to /settings/autocomplete.
stepLog('navigating to /settings/autocomplete (independent setup)');
await navigateViaHash('/settings/autocomplete');
await waitForText('Auto', 15_000);
const ctaVisible =
(await textExists('Enable')) ||
(await textExists('Manage')) ||
(await textExists('Retry')) ||
(await textExists('Details'));
expect(ctaVisible).toBe(true);
});
});

Some files were not shown because too many files have changed in this diff Show More