feat(desktop): merge tested desktop updates without release packaging

This commit is contained in:
yanghang committed 2026-06-22 19:06:11 +08:00
1 parent 8fd05cf8c4
commit de00ede0cf
23 files changed
+2018 -243

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
# Keep platform-specific scripts with safe line endings.
*.sh text eol=lf
*.command text eol=lf
*.ps1 text eol=crlf
*.bat text eol=crlf
+3 -3
View File
@@ -64,7 +64,7 @@ LLM_PROVIDERS = [
'type': 'native_oai',
'template': {
'name': 'deepseek', 'apikey': 'sk-<your-deepseek-key>',
'apibase': 'https://api.deepseek.com', 'model': 'deepseek-v4-flash',
'apibase': 'https://api.deepseek.com', 'model': 'deepseek-v4-pro',
'api_mode': 'chat_completions', 'reasoning_effort': 'high',
},
'key_hint': '在 https://platform.deepseek.com/api_keys 获取',
@@ -102,11 +102,11 @@ LLM_PROVIDERS = [
'template': {
'name': 'qwen', 'apikey': 'sk-<your-dashscope-key>',
'apibase': 'https://dashscope.aliyuncs.com/compatible-mode/v1',
'model': 'qwen3.5-plus',
'model': 'qwen3.6-max-preview',
'api_mode': 'chat_completions',
},
'key_hint': '在 https://bailian.console.aliyun.com/ 获取 API Key',
'model_choices': ['qwen3.5-plus', 'qwen3-coder-plus'],
'model_choices': ['qwen3.6-max-preview', 'qwen3.5-plus', 'qwen3-coder-plus'],
'extra_fields': [
{
'key': '_endpoint', 'label': '选择端点',
+28
View File
@@ -18,6 +18,30 @@ PORT = 8900
HTML_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "conductor.html")
def _desktop_llm_no() -> Optional[int]:
"""Read the model index the user picked in the desktop UI.
Persisted by the bridge at ~/.ga_desktop_settings.json under ui.llmNo.
Returns None when unavailable, so callers keep the agent's default model."""
try:
from pathlib import Path
doc = json.loads((Path.home() / ".ga_desktop_settings.json").read_text(encoding="utf-8"))
no = (doc.get("ui") or {}).get("llmNo")
return int(no) if no is not None else None
except Exception:
return None
def _apply_desktop_model(agent: "GenericAgent") -> None:
"""Switch a freshly built agent to the desktop-selected model (if any)."""
no = _desktop_llm_no()
if no is None:
return
try:
agent.next_llm(int(no))
except Exception as e:
print(f"[conductor] failed to apply desktop model #{no}: {e}", file=sys.stderr)
@asynccontextmanager
async def lifespan(app: FastAPI):
# 服务启动(事件循环已就绪):捕获 loop 供工作线程跨线程推 WS 广播,并起主agent
@@ -197,6 +221,7 @@ class SubagentPool:
agent.inc_out = True
agent.verbose = False
agent.no_print = True
_apply_desktop_model(agent)
th = start_agent_runner(agent, f"subagent-{sid}")
state = SubAgentState(id=sid, agent=agent, prompt=prompt, status="running", thread=th)
with self.lock: self.subagents[sid] = state
@@ -346,6 +371,9 @@ API: {base};先requests,GET /readme查用法,GET /chat读未读对话,GE
break
try:
prompt = self._build_prompt(events)
# Follow the desktop-selected model live: re-read before each task
# so switching models in the UI takes effect without restarting.
_apply_desktop_model(self.agent)
dq = self.agent.put_task(prompt, source="conductor")
self._drain(dq, events)
except Exception as e: print(f"Conductor error: {e}")
+221
View File
@@ -47,6 +47,28 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "ashpd"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
dependencies = [
"async-fs",
"async-net",
"enumflags2",
"futures-channel",
"futures-util",
"rand",
"raw-window-handle",
"serde",
"serde_repr",
"url",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"zbus",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -85,6 +107,17 @@ dependencies = [
"slab",
]
[[package]]
name = "async-fs"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
dependencies = [
"async-lock",
"blocking",
"futures-lite",
]
[[package]]
name = "async-io"
version = "2.6.0"
@@ -114,6 +147,17 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "async-net"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7"
dependencies = [
"async-io",
"blocking",
"futures-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
@@ -767,6 +811,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "dlib"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
dependencies = [
"libloading",
]
[[package]]
name = "dlopen2"
version = "0.8.2"
@@ -805,6 +858,12 @@ dependencies = [
"tendril",
]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dpi"
version = "0.1.2"
@@ -1135,6 +1194,7 @@ name = "ga-desktop"
version = "0.1.0"
dependencies = [
"dirs 5.0.1",
"rfd",
"serde",
"serde_json",
"tauri",
@@ -2515,6 +2575,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "pollster"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -2530,6 +2596,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
@@ -2638,6 +2713,35 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -2758,6 +2862,30 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfd"
version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed"
dependencies = [
"ashpd",
"block2",
"dispatch2",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"pollster",
"raw-window-handle",
"urlencoding",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.59.0",
]
[[package]]
name = "rustc-hash"
version = "2.1.2"
@@ -2852,6 +2980,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "scoped-tls"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -4005,6 +4139,12 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "urlpattern"
version = "0.3.0"
@@ -4218,6 +4358,66 @@ dependencies = [
"semver",
]
[[package]]
name = "wayland-backend"
version = "0.3.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d"
dependencies = [
"cc",
"downcast-rs",
"rustix",
"scoped-tls",
"smallvec",
"wayland-sys",
]
[[package]]
name = "wayland-client"
version = "0.31.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144"
dependencies = [
"bitflags 2.11.1",
"rustix",
"wayland-backend",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols"
version = "0.32.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
dependencies = [
"bitflags 2.11.1",
"wayland-backend",
"wayland-client",
"wayland-scanner",
]
[[package]]
name = "wayland-scanner"
version = "0.31.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
dependencies = [
"proc-macro2",
"quick-xml",
"quote",
]
[[package]]
name = "wayland-sys"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
dependencies = [
"dlib",
"log",
"pkg-config",
]
[[package]]
name = "web-sys"
version = "0.3.98"
@@ -5094,6 +5294,26 @@ dependencies = [
"zvariant",
]
[[package]]
name = "zerocopy"
version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "zerofrom"
version = "0.1.8"
@@ -5163,6 +5383,7 @@ dependencies = [
"endi",
"enumflags2",
"serde",
"url",
"winnow 1.0.3",
"zvariant_derive",
"zvariant_utils",
+1
View File
@@ -12,6 +12,7 @@ tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dirs = "5"
rfd = "0.15"
[lib]
name = "ga_desktop_lib"
+22
View File
@@ -1,3 +1,25 @@
use std::process::Command;
fn main() {
// Build identity used to decide whether a bridge already holding :14168 belongs to THIS
// build. commit hash + build timestamp → distinct on every build, even when the human
// version in tauri.conf.json is unchanged (so same-version re-publishes still take over
// a stale bridge). The bridge reports this back via GET /services/identity.
let commit = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "nogit".to_string());
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
println!("cargo:rustc-env=GA_BUILD_ID={}-{}", commit, stamp);
// Re-run when the checked-out commit changes so the id stays fresh.
println!("cargo:rerun-if-changed=../../../.git/HEAD");
tauri_build::build()
}
@@ -2,6 +2,9 @@
"identifier": "default",
"description": "Default capabilities for all windows",
"windows": ["main", "setup"],
"remote": {
"urls": ["http://127.0.0.1:14168", "http://localhost:14168"]
},
"permissions": [
"core:default",
"core:window:default",
@@ -10,6 +13,12 @@
"core:window:allow-set-focus",
"core:window:allow-close",
"core:window:allow-get-all-windows",
"core:webview:default"
"core:webview:default",
"allow-start-bridge",
"allow-start-bridge-with-config",
"allow-get-config",
"allow-export-mykey",
"allow-shortcut-should-ask",
"allow-shortcut-decide"
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 23 KiB

@@ -0,0 +1,29 @@
[[permission]]
identifier = "allow-start-bridge"
description = "Spawn bridge from the services panel when bridge was stopped."
commands.allow = ["start_bridge"]
[[permission]]
identifier = "allow-start-bridge-with-config"
description = "Spawn bridge from setup with explicit python/project paths."
commands.allow = ["start_bridge_with_config"]
[[permission]]
identifier = "allow-get-config"
description = "Read saved bridge spawn configuration."
commands.allow = ["get_config"]
[[permission]]
identifier = "allow-export-mykey"
description = "Save mykey.py via native file dialog."
commands.allow = ["export_mykey"]
[[permission]]
identifier = "allow-shortcut-should-ask"
description = "Check whether to show the first-run desktop-shortcut prompt."
commands.allow = ["shortcut_should_ask"]
[[permission]]
identifier = "allow-shortcut-decide"
description = "Persist the user's desktop-shortcut choice and create it if enabled."
commands.allow = ["shortcut_decide"]
+698 -134
View File
@@ -1,4 +1,5 @@
use std::process::{Command, Child};
use std::process::{Command, Child, Stdio};
use std::io::{BufRead, BufReader};
use std::sync::Mutex;
use std::net::TcpStream;
use std::time::{Duration, Instant};
@@ -20,19 +21,68 @@ fn project_root() -> PathBuf {
.to_path_buf()
}
fn find_bridge_script() -> PathBuf {
// exe is at frontends/GenericAgent.exe
// bridge is at frontends/desktop_bridge.py
std::env::current_exe()
.expect("cannot get exe path")
.parent().expect("cannot get exe dir")
.join("desktop_bridge.py")
/// Directory next to which a self-contained bundle keeps its runtime/ folder.
/// Windows: the exe's folder. Linux: the .AppImage's folder ($APPIMAGE) when launched as an
/// AppImage (current_exe would otherwise point inside the read-only squashfs mount).
/// macOS portable package: the folder containing GenericAgent.app and runtime/.
fn bundle_anchor_dir() -> Option<PathBuf> {
#[cfg(not(windows))]
{
if let Some(p) = std::env::var_os("APPIMAGE") {
if let Some(d) = PathBuf::from(p).parent() {
return Some(d.to_path_buf());
}
}
}
let exe = std::env::current_exe().ok()?;
#[cfg(target_os = "macos")]
{
// current_exe() inside a bundle is:
// <package>/GenericAgent.app/Contents/MacOS/GenericAgent
// Prefer the standard macOS layout where runtime is embedded in the app:
// GenericAgent.app/Contents/Resources/runtime/app/agentmain.py
// Fall back to the old portable layout for compatibility:
// <package>/runtime/app/agentmain.py
let mut d = exe.parent();
while let Some(dir) = d {
if dir.extension().and_then(|s| s.to_str()) == Some("app") {
let resources = dir.join("Contents").join("Resources");
if resources.join("runtime").join("app").join("agentmain.py").exists() {
return Some(resources);
}
if let Some(parent) = dir.parent() {
return Some(parent.to_path_buf());
}
}
d = dir.parent();
}
}
Some(exe.parent()?.to_path_buf())
}
/// Embedded interpreter inside the bundle's runtime/python (base python, before venv).
fn bundle_python() -> Option<PathBuf> {
let root = bundle_root()?;
#[cfg(windows)]
let p = root.join("python").join("python.exe");
#[cfg(not(windows))]
let p = root.join("python").join("bin").join("python3");
if p.exists() { Some(p) } else { None }
}
/// Find python executable:
/// 1. .portable/uv-python/ 下找 python.exe (Windows) 或 python3 (Unix)
/// 2. Fallback to system PATH
/// 1. The embedded bundle python (runtime/python) — deps are installed directly into it
/// (no venv), and its path is resolved relative to the bundle anchor at runtime, so the
/// package stays relocatable (moving the folder doesn't break absolute venv paths).
/// 2. .portable/uv-python/ 下找 python.exe (Windows) 或 python3 (Unix)
/// 3. Fallback to system PATH
fn find_python() -> String {
if let Some(p) = bundle_python() {
return p.to_string_lossy().to_string();
}
let root = project_root();
let portable_python_dir = root.join(".portable").join("uv-python");
@@ -69,11 +119,20 @@ fn find_python() -> String {
{ "python3".to_string() }
}
/// Find project directory by searching upward from exe for agentmain.py
/// Find the project directory (folder containing agentmain.py).
/// Bundle layout: <exe dir>/runtime/app/agentmain.py. Dev layout: walk up from the exe.
fn find_project_dir() -> Option<String> {
// Bundle layout: source tucked under <anchor>/runtime/app/
if let Some(anchor) = bundle_anchor_dir() {
let app = anchor.join("runtime").join("app");
if app.join("agentmain.py").exists() {
return Some(app.to_string_lossy().to_string());
}
}
// Dev/source layout: walk up to 8 levels from the exe location.
let exe = std::env::current_exe().ok()?;
let mut dir = exe.parent();
// Walk up to 8 levels from exe location
let mut dir = Some(exe.parent()?);
for _ in 0..8 {
match dir {
Some(d) => {
@@ -95,12 +154,212 @@ fn settings_path() -> PathBuf {
.join(".ga_desktop_settings.json")
}
/// Read config from settings file, or auto-discover and save
/// Read the settings file as a JSON object (empty object when missing/unparseable).
fn read_settings() -> serde_json::Map<String, serde_json::Value> {
let path = settings_path();
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(serde_json::Value::Object(m)) = serde_json::from_str(&content) {
return m;
}
}
serde_json::Map::new()
}
/// Merge `updates` into the existing settings file and write it back, preserving any keys
/// we don't touch. The old code rewrote the file with only python_path/project_dir, which
/// would silently drop sibling keys like `desktop_shortcut`. Always go through here.
fn merge_settings(updates: serde_json::Value) {
let mut obj = read_settings();
if let serde_json::Value::Object(m) = updates {
for (k, v) in m {
obj.insert(k, v);
}
}
let val = serde_json::Value::Object(obj);
if let Ok(text) = serde_json::to_string_pretty(&val) {
let _ = std::fs::write(settings_path(), text);
}
}
/// Desktop-shortcut preference stored in settings under `desktop_shortcut`.
/// None = never asked (first run)
/// Some(true)/Some(false) = user's remembered choice.
fn read_shortcut_pref() -> Option<bool> {
read_settings().get("desktop_shortcut").and_then(|v| v.as_bool())
}
fn write_shortcut_pref(enabled: bool) {
merge_settings(serde_json::json!({ "desktop_shortcut": enabled }));
}
/// Create (or overwrite) a desktop shortcut pointing at the CURRENT exe. Overwriting on every
/// enabled launch is what makes the portable bundle relocatable: move the folder, relaunch, and
/// the shortcut is rewritten to the new path. Windows-only (uses a .lnk via WScript.Shell).
#[cfg(windows)]
fn ensure_desktop_shortcut() {
let Ok(exe) = std::env::current_exe() else { return; };
let Some(desktop) = dirs::desktop_dir() else { return; };
let lnk = desktop.join("GenericAgent.lnk");
let work_dir = exe.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| exe.clone());
let exe_s = exe.to_string_lossy().replace('\'', "''");
let lnk_s = lnk.to_string_lossy().replace('\'', "''");
let work_s = work_dir.to_string_lossy().replace('\'', "''");
// Build the shortcut via WScript.Shell COM, consistent with the existing powershell usage
// elsewhere in this file. No extra crate needed.
let script = format!(
"$ws = New-Object -ComObject WScript.Shell; \
$sc = $ws.CreateShortcut('{lnk}'); \
$sc.TargetPath = '{exe}'; \
$sc.WorkingDirectory = '{work}'; \
$sc.IconLocation = '{exe}'; \
$sc.Save()",
lnk = lnk_s, exe = exe_s, work = work_s
);
let mut cmd = Command::new("powershell.exe");
cmd.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &script]);
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
let _ = cmd.status();
}
#[cfg(target_os = "linux")]
fn ensure_desktop_shortcut() {
// Launch target: the AppImage path when running as one, else the current exe. Writing the
// current path on every enabled launch keeps a relocated bundle's launcher valid.
let Some(target) = std::env::var_os("APPIMAGE").map(PathBuf::from)
.or_else(|| std::env::current_exe().ok()) else { return; };
let exec = target.to_string_lossy().replace('"', "");
// Linux .desktop Icon= needs an image file (or themed name), not the AppImage path. The CI
// ships GenericAgent.png next to the AppImage; fall back to a generic themed icon otherwise.
let icon = bundle_anchor_dir()
.map(|d| d.join("GenericAgent.png"))
.filter(|p| p.exists())
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| "application-x-executable".to_string());
let entry = format!(
"[Desktop Entry]\nType=Application\nName=GenericAgent\nComment=GenericAgent Desktop\n\
Exec=\"{exec}\"\nIcon={icon}\nTerminal=false\nCategories=Utility;Development;\n",
exec = exec, icon = icon
);
let write_desktop = |path: &std::path::Path| {
if std::fs::write(path, &entry).is_ok() {
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755));
}
};
if let Some(home) = dirs::home_dir() {
let apps = home.join(".local/share/applications");
let _ = std::fs::create_dir_all(&apps);
write_desktop(&apps.join("GenericAgent.desktop"));
}
if let Some(desktop) = dirs::desktop_dir() {
let _ = std::fs::create_dir_all(&desktop);
let f = desktop.join("GenericAgent.desktop");
write_desktop(&f);
// GNOME marks unknown launchers "untrusted"; flag ours so it runs on double-click. Best effort.
let _ = Command::new("gio")
.args(["set", &f.to_string_lossy(), "metadata::trusted", "true"])
.status();
}
}
#[cfg(target_os = "macos")]
fn ensure_desktop_shortcut() {
// The .app is the launchable unit; drop a symlink to it on the Desktop.
let Ok(exe) = std::env::current_exe() else { return; };
let mut app: Option<PathBuf> = None;
let mut d = exe.parent();
while let Some(dir) = d {
if dir.extension().and_then(|s| s.to_str()) == Some("app") { app = Some(dir.to_path_buf()); break; }
d = dir.parent();
}
let (Some(app), Some(desktop)) = (app, dirs::desktop_dir()) else { return; };
let link = desktop.join("GenericAgent.app");
let _ = std::fs::remove_file(&link);
let _ = std::os::unix::fs::symlink(&app, &link);
}
#[cfg(all(not(windows), not(target_os = "linux"), not(target_os = "macos")))]
fn ensure_desktop_shortcut() {}
/// First-run shortcut handling for portable bundles (all platforms). Self-heals the shortcut
/// path on every enabled launch (cheap, no UI). The first-run ASK is driven by the frontend
/// (see the `shortcut_should_ask` / `shortcut_decide` commands): a native dialog from this
/// background startup thread has no parent window and gets buried behind the main window on
/// first launch, so the prompt is owned by the web UI instead, which always renders on top.
fn maybe_setup_shortcut() {
if bundle_root().is_none() {
return;
}
// Only self-heal when the user already opted in. Never prompt here.
if read_shortcut_pref() == Some(true) {
ensure_desktop_shortcut();
}
}
/// Frontend asks whether to show the first-run "create desktop shortcut?" prompt.
/// True only on a portable bundle whose preference has never been set.
#[tauri::command]
fn shortcut_should_ask() -> bool {
bundle_root().is_some() && read_shortcut_pref().is_none()
}
/// Frontend reports the user's choice. Persists it and creates the shortcut when enabled.
#[tauri::command]
fn shortcut_decide(create: bool) {
write_shortcut_pref(create);
if create {
ensure_desktop_shortcut();
}
}
/// True when this binary is running from inside a macOS .app bundle (packaged build).
/// Used to refuse stale ~/.ga_desktop_settings.json that could point at an old checkout
/// when App Translocation hides our own runtime/ from current_exe().
#[cfg(target_os = "macos")]
fn running_inside_app_bundle() -> bool {
std::env::current_exe()
.ok()
.map(|p| {
p.components().any(|c| {
c.as_os_str().to_string_lossy().ends_with(".app")
})
})
.unwrap_or(false)
}
/// Read config from settings file, or auto-discover and save.
/// Self-contained bundles always prefer their own runtime/app over stale user settings,
/// otherwise an old ~/.ga_desktop_settings.json can silently point the UI at a different checkout.
pub fn get_or_discover_config() -> (String, String) {
let path = settings_path();
// Try reading existing settings
if path.exists() {
if bundle_root().is_some() {
let python = find_python();
let project = find_project_dir().unwrap_or_default();
if !python.is_empty() && !project.is_empty() {
merge_settings(serde_json::json!({
"python_path": python,
"project_dir": project
}));
return (python, project);
}
}
// Try reading existing settings.
// On macOS, a packaged .app must never trust ~/.ga_desktop_settings.json: App
// Translocation can run the bundle from a random read-only copy where bundle_root()
// fails to see our own runtime/, and an old settings file would then silently point
// the bridge at a previously installed checkout. In that case fall through to
// auto-discovery (which still resolves the bundle via .app-relative search below).
#[cfg(target_os = "macos")]
let trust_settings = !running_inside_app_bundle();
#[cfg(not(target_os = "macos"))]
let trust_settings = true;
if trust_settings && path.exists() {
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
let python = val.get("python_path")
@@ -124,16 +383,254 @@ pub fn get_or_discover_config() -> (String, String) {
// Save discovered config
if !python.is_empty() && !project.is_empty() {
let json = serde_json::json!({
merge_settings(serde_json::json!({
"python_path": python,
"project_dir": project
});
let _ = std::fs::write(&path, serde_json::to_string_pretty(&json).unwrap());
}));
}
(python, project)
}
/// Self-contained bundle support dir: holds python/, wheels/, install_windows.ps1 and app/.
/// Typical portable layout keeps only the exe (+README) at the top level and tucks everything
/// else under <exe dir>/runtime/. Returns None when this is not a bundle (e.g. dev build).
fn bundle_root() -> Option<PathBuf> {
let runtime = bundle_anchor_dir()?.join("runtime");
if runtime.join("app").join("agentmain.py").exists() {
return Some(runtime);
}
None
}
/// Marker written after a successful offline prepare. Lives under runtime/ so it travels
/// with the bundle: a relocated folder stays "prepared" (deps live in the embedded python,
/// which is itself relocatable) and won't re-run prepare.
fn prepared_marker() -> Option<PathBuf> {
Some(bundle_root()?.join(".prepared"))
}
/// True when this is a self-contained bundle whose python env has not been prepared yet
/// (embedded python present but deps not yet installed into it).
fn needs_first_run_prepare(project_dir: &str) -> bool {
if project_dir.is_empty() { return false; }
bundle_python().is_some() && prepared_marker().map(|m| !m.exists()).unwrap_or(false)
}
/// Clear env vars a host launcher injects pointing at its own runtime. The Linux AppImage exports
/// PYTHONHOME/PYTHONPATH (-> bundled python crashes with "No module named 'encodings'") and
/// LD_LIBRARY_PATH (-> wrong shared libs). Our bundled python / prepare / bridge must run clean.
fn sanitize_bundle_env(cmd: &mut Command) {
cmd.env_remove("PYTHONHOME");
cmd.env_remove("PYTHONPATH");
cmd.env_remove("LD_LIBRARY_PATH");
// Stamp the bridge we spawn with this build's id so a later app launch can tell whether the
// bridge holding :14168 is ours (see bridge_identity_matches / GET /services/identity).
cmd.env("GA_BUILD_ID", env!("GA_BUILD_ID"));
}
/// Run the offline prepare (install_windows.ps1 -Mode PrepareOnly) using bundled python + wheels.
/// Streams the script's stdout and forwards GAPROGRESS markers to `report(pct, message)`.
/// Blocking; intended to run on a background thread. Writes ~/.ga_desktop_settings.json.
fn run_offline_prepare(project_dir: &str, report: &dyn Fn(i32, &str)) -> Result<(), String> {
let root = bundle_root().ok_or("cannot locate bundle root")?;
let wheels = root.join("wheels");
#[cfg(windows)]
let (script, py) = (
root.join("install_windows.ps1"),
root.join("python").join("python.exe"),
);
#[cfg(target_os = "macos")]
let (script, py) = (
root.join("install_macos.sh"),
root.join("python").join("bin").join("python3"),
);
#[cfg(all(not(windows), not(target_os = "macos")))]
let (script, py) = (
root.join("install_linux.sh"),
root.join("python").join("bin").join("python3"),
);
if !script.exists() || !py.exists() || !wheels.exists() {
return Err(format!("prepare resources missing under {:?}", root));
}
#[cfg(windows)]
let mut cmd = {
let mut c = Command::new("powershell.exe");
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"])
.arg(&script)
.arg("-PythonPath").arg(&py)
.arg("-ProjectDir").arg(project_dir)
.arg("-WheelDir").arg(&wheels)
.arg("-ExtraPipPackages").arg("fastapi uvicorn websockets")
// -NoVenv: install deps straight into the embedded python (no venv) so the
// bundle is relocatable. See prepared_marker / find_python.
.args(["-Mode", "PrepareOnly", "-SkipNpmInstall", "-NoVenv"]);
c
};
#[cfg(not(windows))]
let mut cmd = {
let mut c = Command::new("bash");
c.arg(&script)
.arg("--python-path").arg(&py)
.arg("--project-dir").arg(project_dir)
.arg("--wheel-dir").arg(&wheels)
.arg("--extra-packages").arg("fastapi uvicorn websockets")
// --no-venv: install deps straight into the embedded python (no venv) so the
// bundle is relocatable. See prepared_marker / find_python.
.args(["--mode", "PrepareOnly", "--no-venv"]);
c
};
cmd.stdout(Stdio::piped()).stderr(Stdio::null());
sanitize_bundle_env(&mut cmd);
#[cfg(windows)]
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
let mut child = cmd.spawn().map_err(|e| format!("failed to launch prepare: {}", e))?;
// Forward the script's ASCII progress keys to the loading window, which localizes them
// (window.gaProgress maps key -> zh/en by navigator.language).
if let Some(out) = child.stdout.take() {
for line in BufReader::new(out).lines().flatten() {
if let Some(key) = line.trim().strip_prefix("GAPROGRESS|") {
match key.trim() {
"venv" => report(15, "venv"),
"deps" => report(45, "deps"),
"done" => report(90, "done"),
_ => {}
}
}
}
}
let status = child.wait().map_err(|e| format!("prepare wait failed: {}", e))?;
if !status.success() {
return Err(format!("prepare exited with status {:?}", status.code()));
}
// Record success so later launches (and relocated copies) skip the prepare step.
if let Some(marker) = prepared_marker() {
let _ = std::fs::write(&marker, b"ok\n");
}
Ok(())
}
/// GET /services/identity from a running bridge; returns the parsed JSON (or None when the
/// endpoint is absent — i.e. an older/foreign bridge).
fn bridge_reported_identity() -> Option<serde_json::Value> {
use std::io::{Read, Write};
let mut stream = TcpStream::connect_timeout(
&"127.0.0.1:14168".parse().unwrap(),
Duration::from_millis(800),
).ok()?;
let _ = stream.set_read_timeout(Some(Duration::from_millis(800)));
let _ = stream.set_write_timeout(Some(Duration::from_millis(600)));
let req = b"GET /services/identity HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n";
stream.write_all(req).ok()?;
let mut buf = Vec::new();
let _ = stream.read_to_end(&mut buf);
let text = String::from_utf8_lossy(&buf);
let body = text.split("\r\n\r\n").nth(1)?;
serde_json::from_str(body.trim()).ok()
}
fn norm_path(p: &str) -> String {
std::fs::canonicalize(p)
.map(|c| c.to_string_lossy().to_string())
.unwrap_or_else(|_| p.to_string())
}
/// A running bridge is "ours" only when it serves the same install path AND was spawned by the
/// same build. The build id (commit+timestamp, see build.rs) changes on every build, so an
/// in-place upgrade or a same-version re-publish still counts as a different bridge → take over.
/// An old bridge with no /identity (None) or no build_id field ("") never matches → taken over.
fn bridge_identity_matches(project_dir: &str) -> bool {
let Some(id) = bridge_reported_identity() else { return false; };
let reported_root = id.get("ga_root").and_then(|v| v.as_str()).unwrap_or("");
let reported_build = id.get("build_id").and_then(|v| v.as_str()).unwrap_or("");
if reported_build != env!("GA_BUILD_ID") {
return false;
}
let (a, b) = (norm_path(reported_root), norm_path(project_dir));
#[cfg(windows)]
{ a.eq_ignore_ascii_case(&b) }
#[cfg(not(windows))]
{ a == b }
}
/// Last resort when a stale bridge ignores POST /services/bridge/exit (e.g. an old build with
/// no such endpoint): force-kill whatever process is listening on :14168 so the new bridge can
/// bind it. Only called after an identity mismatch, so we never kill a bridge that is ours.
fn force_free_bridge_port() {
#[cfg(windows)]
{
// netstat -ano: last column is the PID for the :14168 LISTENING row.
if let Ok(out) = Command::new("netstat").args(["-ano", "-p", "tcp"]).output() {
let text = String::from_utf8_lossy(&out.stdout);
for line in text.lines() {
if line.contains(":14168") && line.to_uppercase().contains("LISTENING") {
if let Some(pid) = line.split_whitespace().last() {
let mut c = Command::new("taskkill");
c.args(["/F", "/PID", pid]);
c.creation_flags(0x08000000);
let _ = c.status();
}
}
}
}
}
#[cfg(not(windows))]
{
// lsof prints the listening PIDs; kill -9 each.
if let Ok(out) = Command::new("lsof").args(["-ti", "tcp:14168", "-sTCP:LISTEN"]).output() {
for pid in String::from_utf8_lossy(&out.stdout).split_whitespace() {
let _ = Command::new("kill").args(["-9", pid]).status();
}
}
}
}
fn request_bridge_shutdown() {
use std::io::{Read, Write};
let Ok(mut stream) = TcpStream::connect_timeout(
&"127.0.0.1:14168".parse().unwrap(),
Duration::from_millis(800),
) else {
return;
};
let _ = stream.set_read_timeout(Some(Duration::from_millis(600)));
let _ = stream.set_write_timeout(Some(Duration::from_millis(600)));
let req = b"POST /services/bridge/exit HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
let _ = stream.write_all(req);
let _ = stream.read(&mut [0u8; 512]);
}
fn takeover_stale_bridge(project_dir: &str) {
if project_dir.is_empty() || !is_bridge_running() {
return;
}
if bridge_identity_matches(project_dir) {
return;
}
eprintln!("[tauri] a different/stale bridge holds 127.0.0.1:14168; taking over");
request_bridge_shutdown();
let start = Instant::now();
while is_bridge_running() && start.elapsed() < Duration::from_secs(10) {
thread::sleep(Duration::from_millis(200));
}
// Old bridges have no /services/bridge/exit endpoint and ignore the request above — if the
// port is still held, force-kill the listener so our fresh bridge can bind it.
if is_bridge_running() {
eprintln!("[tauri] stale bridge did not exit; force-freeing :14168");
force_free_bridge_port();
let start = Instant::now();
while is_bridge_running() && start.elapsed() < Duration::from_secs(5) {
thread::sleep(Duration::from_millis(200));
}
}
}
fn is_bridge_running() -> bool {
TcpStream::connect(("127.0.0.1", 14168)).is_ok()
}
@@ -149,83 +646,28 @@ fn wait_for_port(port: u16, timeout: Duration) -> bool {
false
}
fn start_bridge() {
let script = find_bridge_script();
if !script.exists() {
eprintln!("[tauri] bridge script not found: {:?}", script);
return;
}
let python = find_python();
eprintln!("[tauri] using python: {}", python);
let show_console = std::env::args().any(|a| a == "--console");
let mut cmd = Command::new(&python);
cmd.arg(&script)
.current_dir(script.parent().unwrap());
#[cfg(windows)]
if !show_console {
const CREATE_NO_WINDOW: u32 = 0x08000000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
match cmd.spawn() {
Ok(child) => {
eprintln!("[tauri] started bridge PID={}", child.id());
*BRIDGE_PROCESS.lock().unwrap() = Some(child);
}
Err(e) => {
eprintln!("[tauri] failed to start bridge: {} (python={})", e, python);
return;
}
}
if !wait_for_port(14168, Duration::from_secs(15)) {
eprintln!("[tauri] WARNING: bridge did not become ready within 15s");
}
}
fn ensure_bridge_running() {
fn spawn_bridge_process(python_path: &str, project_dir: &str) -> Result<(), String> {
if is_bridge_running() {
eprintln!("[tauri] bridge already running on 127.0.0.1:14168; reusing it");
return;
return Ok(());
}
start_bridge();
let py = PathBuf::from(python_path);
let dir = PathBuf::from(project_dir);
let script = dir.join("frontends").join("desktop_bridge.py");
if !script.exists() {
return Err(format!("desktop_bridge.py not found at {:?}", script));
}
let mut cmd = Command::new(&py);
cmd.arg(&script).current_dir(&dir);
sanitize_bundle_env(&mut cmd);
#[cfg(windows)]
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
let child = cmd.spawn().map_err(|e| format!("Failed to spawn: {}", e))?;
*BRIDGE_PROCESS.lock().unwrap() = Some(child);
Ok(())
}
#[tauri::command]
fn start_bridge_with_config(app_handle: tauri::AppHandle, python_path: String, project_dir: String) -> Result<(), String> {
// Save to settings
let path = settings_path();
let obj = serde_json::json!({"python_path": python_path, "project_dir": project_dir});
std::fs::write(&path, serde_json::to_string_pretty(&obj).unwrap())
.map_err(|e| format!("Failed to write settings: {}", e))?;
// Start bridge only if it is not already accepting connections.
if !is_bridge_running() {
let py = PathBuf::from(&python_path);
let dir = PathBuf::from(&project_dir);
let script = dir.join("frontends").join("desktop_bridge.py");
if !script.exists() {
return Err(format!("desktop_bridge.py not found at {:?}", script));
}
let mut cmd = Command::new(&py);
cmd.arg(&script).current_dir(&dir);
#[cfg(windows)]
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
let child = cmd.spawn().map_err(|e| format!("Failed to spawn: {}", e))?;
*BRIDGE_PROCESS.lock().unwrap() = Some(child);
}
// Wait for port
if !wait_for_port(14168, Duration::from_secs(20)) {
return Err("Bridge did not become ready within 20s".into());
}
// Navigate main window to bridge URL after the bridge is ready, then show it.
fn show_bridge_window(app_handle: &tauri::AppHandle) {
if let Some(main_win) = app_handle.get_webview_window("main") {
let url = tauri::Url::parse("http://127.0.0.1:14168/").unwrap();
let _ = main_win.navigate(url);
@@ -235,7 +677,32 @@ fn start_bridge_with_config(app_handle: tauri::AppHandle, python_path: String, p
if let Some(setup_win) = app_handle.get_webview_window("setup") {
let _ = setup_win.hide();
}
}
#[tauri::command]
fn start_bridge_with_config(app_handle: tauri::AppHandle, python_path: String, project_dir: String) -> Result<(), String> {
// Save to settings (merge so sibling keys like desktop_shortcut survive).
merge_settings(serde_json::json!({"python_path": python_path, "project_dir": project_dir}));
spawn_bridge_process(&python_path, &project_dir)?;
// Wait for port
if !wait_for_port(14168, Duration::from_secs(20)) {
return Err("Bridge did not become ready within 20s".into());
}
show_bridge_window(&app_handle);
Ok(())
}
#[tauri::command]
fn start_bridge(app_handle: tauri::AppHandle) -> Result<(), String> {
let (python_path, project_dir) = get_or_discover_config();
spawn_bridge_process(&python_path, &project_dir)?;
if !wait_for_port(14168, Duration::from_secs(20)) {
return Err("Bridge did not become ready within 20s".into());
}
show_bridge_window(&app_handle);
Ok(())
}
@@ -244,15 +711,37 @@ fn get_config() -> (String, String) {
get_or_discover_config()
}
#[tauri::command]
fn export_mykey(content: String) -> Result<Option<String>, String> {
let path = rfd::FileDialog::new()
.set_file_name("mykey.py")
.add_filter("Python", &["py"])
.save_file();
match path {
Some(p) => {
std::fs::write(&p, content.as_bytes()).map_err(|e| e.to_string())?;
Ok(Some(p.to_string_lossy().into_owned()))
}
None => Ok(None),
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let args: Vec<String> = std::env::args().collect();
let no_autostart = args.iter().any(|a| a == "--no-autostart");
let dev_mode = args.iter().any(|a| a == "--dev");
let project_dir = find_project_dir().unwrap_or_default();
let needs_prepare = needs_first_run_prepare(&project_dir);
takeover_stale_bridge(&project_dir);
let bridge_ok = is_bridge_running();
let mut spawned_bridge = false;
if !bridge_ok && !no_autostart {
// Skip the early spawn when a first-run prepare is required (no venv yet);
// the setup thread prepares the env first and then starts the bridge.
if !bridge_ok && !no_autostart && !needs_prepare {
// Try to start bridge with saved/discovered config
let (py_str, dir_str) = get_or_discover_config();
let dir = PathBuf::from(&dir_str);
@@ -260,6 +749,7 @@ pub fn run() {
if script.exists() {
let mut cmd = Command::new(&py_str);
cmd.arg(&script).current_dir(&dir);
sanitize_bundle_env(&mut cmd);
#[cfg(windows)]
cmd.creation_flags(0x08000000);
if let Ok(child) = cmd.spawn() {
@@ -277,55 +767,129 @@ pub fn run() {
let _ = w.set_focus();
}
}))
.invoke_handler(tauri::generate_handler![start_bridge_with_config, get_config])
.invoke_handler(tauri::generate_handler![start_bridge_with_config, start_bridge, get_config, export_mykey, shortcut_should_ask, shortcut_decide])
.setup(move |app| {
let bridge_wait = if spawned_bridge {
Duration::from_secs(20)
} else {
Duration::from_secs(2)
};
let bridge_ready = wait_for_port(14168, bridge_wait);
if bridge_ready {
// Navigate to bridge HTTP only after it is ready; the window starts on loading.html
// so WebView never caches an early "connection refused" error page.
if let Some(w) = app.get_webview_window("main") {
let url = tauri::Url::parse("http://127.0.0.1:14168/").unwrap();
let _ = w.navigate(url);
if dev_mode {
w.open_devtools();
} else {
// Disable F5/F12/Ctrl+R/right-click in production
let _ = w.eval(r#"
document.addEventListener('keydown', function(e) {
if (e.key === 'F12' || e.key === 'F5' ||
(e.ctrlKey && e.key === 'r') ||
(e.ctrlKey && e.shiftKey && e.key === 'I')) {
e.preventDefault();
}
});
document.addEventListener('contextmenu', function(e) {
e.preventDefault();
});
"#);
}
let _ = w.show();
}
} else {
// Show setup window
if let Some(w) = app.get_webview_window("setup") {
if dev_mode {
w.open_devtools();
}
let _ = w.show();
}
// Show the loading window immediately so the first-run prepare isn't a blank screen.
// The window starts on loading.html (a local page), so no "connection refused" flash.
if let Some(w) = app.get_webview_window("main") {
let _ = w.show();
}
let handle = app.handle().clone();
let project_dir = project_dir.clone();
thread::spawn(move || {
// Progress reporter: push status into the loading window (window.gaProgress).
let main_win = handle.get_webview_window("main");
let report = |pct: i32, msg: &str| {
if let Some(w) = &main_win {
let js = format!(
"window.gaProgress && window.gaProgress({}, {})",
pct,
serde_json::to_string(msg).unwrap_or_else(|_| "\"\"".to_string())
);
let _ = w.eval(&js);
}
};
// First-run (self-contained bundle): prepare the embedded python env offline,
// then start the bridge with the freshly created venv.
if needs_prepare {
report(5, "start");
if let Err(e) = run_offline_prepare(&project_dir, &report) {
eprintln!("[tauri] first-run prepare failed: {}", e);
if let Some(sw) = handle.get_webview_window("setup") { let _ = sw.show(); }
if let Some(mw) = handle.get_webview_window("main") { let _ = mw.hide(); }
return;
}
report(95, "starting");
if !is_bridge_running() {
let (py_str, dir_str) = get_or_discover_config();
let dir = PathBuf::from(&dir_str);
let script = dir.join("frontends").join("desktop_bridge.py");
if script.exists() {
let mut cmd = Command::new(&py_str);
cmd.arg(&script).current_dir(&dir);
sanitize_bundle_env(&mut cmd);
#[cfg(windows)]
cmd.creation_flags(0x08000000);
if let Ok(child) = cmd.spawn() {
*BRIDGE_PROCESS.lock().unwrap() = Some(child);
}
}
}
}
// First run (prepare) and cold bridge start may take a while; allow up to 60s.
let wait = if needs_prepare || spawned_bridge {
Duration::from_secs(60)
} else {
Duration::from_secs(2)
};
let bridge_ready = wait_for_port(14168, wait);
if bridge_ready {
// The bridge auto-starts conductor + scheduler itself (on_startup), so we do
// NOT probe their ports here: that would self-detect the bridge's own
// just-started extras and falsely report "ports busy".
if !wait_for_port(14168, Duration::from_secs(15)) {
eprintln!("[tauri] bridge not reachable before navigate");
if let Some(w) = &main_win {
let msg = "无法连接 bridge (127.0.0.1:14168),请关闭程序后重试。";
let js = format!(
"alert({})",
serde_json::to_string(msg).unwrap_or_else(|_| "\"\"".to_string())
);
let _ = w.eval(&js);
}
return;
}
// Navigate to the bridge HTTP only after it is ready.
if let Some(w) = handle.get_webview_window("main") {
if let Ok(url) = tauri::Url::parse("http://127.0.0.1:14168/") {
let _ = w.navigate(url);
}
if dev_mode {
w.open_devtools();
} else {
// Disable F5/F12/Ctrl+R/right-click in production
let _ = w.eval(r#"
document.addEventListener('keydown', function(e) {
if (e.key === 'F12' || e.key === 'F5' ||
(e.ctrlKey && e.key === 'r') ||
(e.ctrlKey && e.shiftKey && e.key === 'I')) {
e.preventDefault();
}
});
document.addEventListener('contextmenu', function(e) {
e.preventDefault();
});
"#);
}
let _ = w.show();
let _ = w.set_focus();
}
if let Some(sw) = handle.get_webview_window("setup") { let _ = sw.hide(); }
// App is up and reachable: ask-once / self-heal the desktop shortcut.
// Runs last so it never blocks the loading/navigation path.
maybe_setup_shortcut();
} else {
// Bridge never came up -> let the user fix paths in the setup window.
if let Some(sw) = handle.get_webview_window("setup") {
if dev_mode { sw.open_devtools(); }
let _ = sw.show();
}
if let Some(mw) = handle.get_webview_window("main") { let _ = mw.hide(); }
}
});
Ok(())
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { .. } = event {
let label = window.label();
if label == "main" {
// Main closed -> exit app
// Persistent backend: closing the window does NOT stop the bridge or its
// services, so relaunching attaches to the warm backend on 14168.
window.app_handle().exit(0);
} else if label == "setup" {
// Setup closed -> exit if main is not visible
@@ -17,6 +17,7 @@
"width": 1280,
"height": 800,
"resizable": true,
"maximized": true,
"visible": false,
"url": "loading.html"
},
+548 -49
View File
@@ -50,6 +50,8 @@ const gaServiceStore = {
get: (id) => _serviceById[id],
};
let bridgeUiOffline = false;
/* ═══════════════ Bridge 适配(HTTP 命令 + WS 状态) ═══════════════ */
(function initGaBridge() {
const listeners = new Map();
@@ -85,6 +87,13 @@ const gaServiceStore = {
emit('service-state', msg);
}
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
const tauriInvoke = (name, args = {}) => {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) throw new Error('Tauri IPC is not available');
return invoke(name, args);
};
async function http(path, options = {}) {
const headers = Object.assign({}, options.headers || {});
const init = Object.assign({}, options, { headers });
@@ -105,6 +114,23 @@ const gaServiceStore = {
return data;
}
async function waitBridgeStatus(timeoutMs = 20000) {
const deadline = Date.now() + timeoutMs;
let lastErr = null;
while (Date.now() < deadline) {
try {
const status = await http('/status');
wsRetries = 0;
connectWs();
return status;
} catch (err) {
lastErr = err;
await sleep(350);
}
}
throw lastErr || new Error('Bridge did not become ready');
}
function connectWs() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
if (wsRetryTimer) { clearTimeout(wsRetryTimer); wsRetryTimer = null; }
@@ -187,6 +213,7 @@ const gaServiceStore = {
return http(`/services/logs?id=${encodeURIComponent(id)}&tail=${encodeURIComponent(tail)}`);
}
case 'services/panel': return http('/services/panel');
case 'services/bridge/exit': return http('/services/bridge/exit', { method: 'POST' });
case 'services/mykey/get': return http('/services/mykey');
case 'services/mykey/save': return http('/services/mykey', { method: 'POST', body: params || {} });
case 'app/path/selectGaRoot': return http('/config');
@@ -213,10 +240,35 @@ const gaServiceStore = {
return res;
}
async function spawnBridge() {
connectWs();
try {
const status = await http('/status');
bridgeUiOffline = false;
return status;
} catch (_) {
await tauriInvoke('start_bridge');
const status = await waitBridgeStatus();
bridgeUiOffline = false;
return status;
}
}
async function exitBridge() {
const res = await rpc('services/bridge/exit');
cachedBridgeReady = null;
if (ws) {
try { ws.close(); } catch (_) {}
}
return res;
}
window.ga = {
platform: navigator.platform.toLowerCase().includes('mac') ? 'darwin' : 'win32',
startBridge: async () => { connectWs(); return http('/status'); },
spawnBridge,
stopBridge: async () => ({ ok: true }),
exitBridge,
checkStatus: () => rpc('app/status', {}),
getConfig: () => rpc('app/config/get', {}),
saveConfig: (cfg) => rpc('app/config/save', cfg || {}),
@@ -230,6 +282,8 @@ const gaServiceStore = {
getServicePanel: () => rpc('services/panel', {}),
getMykeyContent: () => rpc('services/mykey/get', {}),
saveMykeyContent: (content) => rpc('services/mykey/save', { content }),
tauriInvoke,
setBridgeUiOffline: (offline) => { bridgeUiOffline = !!offline; },
pollSession: (sessionId, afterId = 0) => rpc('session/poll', { sessionId, afterId }),
rpc,
onBridgeMessage: (cb) => on('bridge-message', cb),
@@ -278,7 +332,8 @@ const I18N = {
'customPreset.removeTitle': '删除',
'customPreset.editTitle': '编辑',
'builtinPreset.restoreBtn': '恢复默认预设',
'set.appearance': '外观', 'set.plainUi': '素色', 'set.fontSize': '聊天字号', 'set.lang': '语言', 'set.model': '模型', 'set.addModel': '添加模型',
'set.appearance': '外观', 'set.plainUi': '素色', 'set.fontSize': '聊天字号', 'set.lang': '语言', 'set.model': '模型', 'set.addModel': '添加模型', 'set.features': '功能', 'set.importMykey': '导入已有模型配置(mykey.py)', 'set.exportMykey': '导出当前模型配置', 'set.serviceManager': '后台服务管理',
'shortcut.askConfirm': '是否在桌面创建 GenericAgent 快捷方式?',
'appearance.light': '浅色', 'appearance.dark': '深色',
'set.noModels': '暂无模型,点击下方添加',
'lang.zh': '简体中文', 'lang.en': 'English',
@@ -286,10 +341,11 @@ const I18N = {
'model.apikey': 'API Key', 'model.apikeyPh': 'sk-...', 'model.apikeyKeep': '留空则保持原 Key 不变',
'model.apibase': 'API 地址', 'model.apibasePh': 'https://.../v1/messages',
'model.protocol': '协议', 'model.protocolPick': '请选择…', 'model.protocolOai': 'OpenAI 兼容 (chat/completions)', 'model.protocolClaude': 'Anthropic (Claude /v1/messages)',
'model.stream': '响应方式', 'model.streamOn': '流式', 'model.streamOff': '非流式',
'model.model': '模型', 'model.modelPh': 'model 参数名',
'model.modelHint': '须与中转站/官方文档中的 model 字段完全一致',
'model.retries': '重试 (次)', 'model.connTimeout': '连接超时 (s)', 'model.readTimeout': '读取超时 (s)',
'model.save': '保存', 'common.cancel': '取消', 'common.edit': '编辑', 'common.delete': '删除',
'model.save': '保存', 'common.cancel': '取消', 'common.confirm': '确认', 'common.edit': '编辑', 'common.delete': '删除',
'pq.title': '快速接入官方模型', 'pq.sub': '填好 API Key 即可使用', 'pq.toggle': '展开 / 收起',
'pq.deepseekDesc': '官方 API · OpenAI 兼容', 'pq.qwenDesc': '通义千问 · 阿里云百炼',
'guide.step1': '点击下方链接,登录后创建并复制 API Key',
@@ -300,6 +356,10 @@ const I18N = {
'err.modelSave': '保存失败', 'err.modelRequired': '请填写模型、API Key 和 API 地址',
'err.modelDelete': '删除失败', 'err.modelDeleteLast': '至少保留一个模型',
'confirm.modelDelete': '确定删除该模型配置?',
'model.aggregation': '渠道组(自动故障转移)', 'model.aggregationShort': '渠道组', 'model.aggregationDesc': '按顺序尝试,失败自动切换到下一个',
'model.emptyMixin': '尚未加入模型',
'model.addToMixin': '加入渠道组', 'model.inMixin': '已在渠道组', 'model.removeFromMixin': '移出渠道组', 'model.alreadyInMixin': '已在渠道组中', 'model.dragReorder': '拖拽调整顺序',
'err.mixinFailed': '操作失败',
'page.services.title': '后台服务', 'page.services.sub': 'IM 消息通道与后台进程,集中查看、启停与日志',
'page.channels.title': '消息通道', 'page.channels.sub': '后台 IM 进程:列表、启停与日志(同 hub.pyw)',
'page.status.title': '状态面板', 'page.status.sub': 'hub.pyw 管理的后台进程/服务,集中查看与启停',
@@ -384,14 +444,17 @@ const I18N = {
'ch.loading': '加载中…', 'ch.empty': '未发现 IM 进程脚本',
'ch.logEmpty': '暂无日志',
'err.channelLoad': '加载失败', 'err.channelStart': '启动失败', 'err.channelStop': '停止失败',
'err.mykeyImport': '导入模型配置失败',
'err.mykeyExport': '导出模型配置失败',
'err.channelNotConfigured': '请先在 mykey.py 中配置该平台',
'sys.channelStarted': '已启动', 'sys.channelStopped': '已停止',
'modal.channelLogs': '进程日志',
'modal.mykeyConfig': 'mykey.py 配置',
'sys.configSaved': '配置已保存',
'st.starting': '启动中…', 'st.stopping': '停止中…',
'st.online': '在线', 'st.offline': '离线', 'st.error': '错误', 'st.running': '运行', 'st.abnormal': '异常',
'act.configure': '配置', 'act.logs': '日志', 'act.restart': '重启', 'act.stop': '停止', 'act.start': '启动',
'sys.mykeyImported': '模型配置已导入',
'sys.mykeyExported': '模型配置已导出',
'st.starting': '启动中…', 'st.stopping': '停止中…', 'st.online': '在线', 'st.offline': '离线', 'st.error': '错误', 'st.running': '运行', 'st.abnormal': '异常',
'act.configure': '配置', 'act.logs': '日志', 'act.restart': '重启', 'act.stop': '停止', 'act.start': '启动', 'act.exit': '退出',
'act.copy': '复制', 'act.copied': '已复制', 'act.copyTex': 'TeX', 'act.send': '发送',
'proc.imbotWechat': 'imbot · 微信', 'proc.imbotDing': 'imbot · 钉钉', 'proc.scheduler': '定时任务调度', 'proc.conductor': '指挥家',
'cm.scheduling': '调度中', 'cm.running': '执行中', 'cm.idleSt': '空闲',
@@ -442,7 +505,8 @@ const I18N = {
'customPreset.removeTitle': 'Delete',
'customPreset.editTitle': 'Edit',
'builtinPreset.restoreBtn': 'Restore defaults',
'set.appearance': 'Appearance', 'set.plainUi': 'Plain', 'set.fontSize': 'Chat font size', 'set.lang': 'Language', 'set.model': 'Model', 'set.addModel': 'Add model',
'set.appearance': 'Appearance', 'set.plainUi': 'Plain', 'set.fontSize': 'Chat font size', 'set.lang': 'Language', 'set.model': 'Model', 'set.addModel': 'Add model', 'set.features': 'Features', 'set.importMykey': 'Import model config (mykey.py)', 'set.exportMykey': 'Export current model config', 'set.serviceManager': 'Service manager',
'shortcut.askConfirm': 'Create a desktop shortcut for GenericAgent?',
'appearance.light': 'Light', 'appearance.dark': 'Dark',
'set.noModels': 'No models yet — add one below',
'lang.zh': '简体中文', 'lang.en': 'English',
@@ -450,10 +514,11 @@ const I18N = {
'model.apikey': 'API Key', 'model.apikeyPh': 'sk-...', 'model.apikeyKeep': 'Leave blank to keep the current key',
'model.apibase': 'API base URL', 'model.apibasePh': 'https://.../v1/messages',
'model.protocol': 'Protocol', 'model.protocolPick': 'Select…', 'model.protocolOai': 'OpenAI-compatible (chat/completions)', 'model.protocolClaude': 'Anthropic (Claude /v1/messages)',
'model.stream': 'Response', 'model.streamOn': 'Stream', 'model.streamOff': 'Non-stream',
'model.model': 'Model', 'model.modelPh': 'model parameter name',
'model.modelHint': 'Must match the model field in your provider docs exactly',
'model.retries': 'Retries (×)', 'model.connTimeout': 'Connect (s)', 'model.readTimeout': 'Read (s)',
'model.save': 'Save', 'common.cancel': 'Cancel', 'common.edit': 'Edit', 'common.delete': 'Delete',
'model.save': 'Save', 'common.cancel': 'Cancel', 'common.confirm': 'Confirm', 'common.edit': 'Edit', 'common.delete': 'Delete',
'pq.title': 'Quick connect a model', 'pq.sub': 'Add your API key to get started', 'pq.toggle': 'Expand / collapse',
'pq.deepseekDesc': 'Official API · OpenAI-compatible', 'pq.qwenDesc': 'Tongyi Qwen · Aliyun Bailian',
'guide.step1': 'Open the link, sign in, then create & copy your API key',
@@ -464,6 +529,10 @@ const I18N = {
'err.modelSave': 'Save failed', 'err.modelRequired': 'Model, API Key and base URL are required',
'err.modelDelete': 'Delete failed', 'err.modelDeleteLast': 'At least one model is required',
'confirm.modelDelete': 'Delete this model profile?',
'model.aggregation': 'Channel group (auto failover)', 'model.aggregationShort': 'Channel group', 'model.aggregationDesc': 'Tries in order, switches to the next on failure',
'model.emptyMixin': 'No models added yet',
'model.addToMixin': 'Add to channel', 'model.inMixin': 'In channel', 'model.removeFromMixin': 'Remove from channel', 'model.alreadyInMixin': 'Already in the channel', 'model.dragReorder': 'Drag to reorder',
'err.mixinFailed': 'Operation failed',
'page.services.title': 'Services', 'page.services.sub': 'IM channels and background processes — view, start/stop, logs',
'page.channels.title': 'Channels', 'page.channels.sub': 'Background IM processes: list, start/stop, logs (hub.pyw style)',
'page.status.title': 'Status', 'page.status.sub': 'Background processes/services managed by hub.pyw',
@@ -548,14 +617,17 @@ const I18N = {
'ch.loading': 'Loading…', 'ch.empty': 'No IM process scripts found',
'ch.logEmpty': 'No log output yet',
'err.channelLoad': 'Failed to load', 'err.channelStart': 'Start failed', 'err.channelStop': 'Stop failed',
'err.mykeyImport': 'Failed to import model config',
'err.mykeyExport': 'Failed to export model config',
'err.channelNotConfigured': 'Configure this platform in mykey.py first',
'sys.channelStarted': 'Started', 'sys.channelStopped': 'Stopped',
'modal.channelLogs': 'Process logs',
'modal.mykeyConfig': 'mykey.py',
'sys.configSaved': 'Configuration saved',
'st.starting': 'Starting…', 'st.stopping': 'Stopping…',
'st.online': 'Online', 'st.offline': 'Offline', 'st.error': 'Error', 'st.running': 'Running', 'st.abnormal': 'Error',
'act.configure': 'Configure', 'act.logs': 'Logs', 'act.restart': 'Restart', 'act.stop': 'Stop', 'act.start': 'Start',
'sys.mykeyImported': 'Model config imported',
'sys.mykeyExported': 'Model config exported',
'st.starting': 'Starting…', 'st.stopping': 'Stopping…', 'st.online': 'Online', 'st.offline': 'Offline', 'st.error': 'Error', 'st.running': 'Running', 'st.abnormal': 'Error',
'act.configure': 'Configure', 'act.logs': 'Logs', 'act.restart': 'Restart', 'act.stop': 'Stop', 'act.start': 'Start', 'act.exit': 'Exit',
'act.copy': 'Copy', 'act.copied': 'Copied', 'act.copyTex': 'TeX', 'act.send': 'Send',
'proc.imbotWechat': 'imbot · WeChat', 'proc.imbotDing': 'imbot · DingTalk', 'proc.scheduler': 'Scheduler', 'proc.conductor': 'Conductor',
'cm.scheduling': 'Scheduling', 'cm.running': 'Running', 'cm.idleSt': 'Idle',
@@ -671,6 +743,11 @@ function applyI18n() {
});
document.querySelectorAll('[data-i18n-title]').forEach(el => { el.setAttribute('title', t(el.dataset.i18nTitle)); });
renderLangList();
// 语言切换后重算激活模型 chip 文案;若当前会话已有渠道组运行态模型,保留运行态而非退回首选项
const _ap = (state.modelProfiles || []).find(p => (p.id ?? 0) === state.llmNo);
if (_ap) state.modelName = modelDisplayName(_ap);
if (_ap?.kind === 'mixin' && state.liveModel?.sessionId === state.activeId) applyLiveModel(state.liveModel);
else if (typeof updateModelChip === 'function') updateModelChip();
window.gaRefreshModelGuide?.();
window.collabRetranslate?.();
syncAskUserUi();
@@ -847,11 +924,85 @@ const closeModals = () => document.querySelectorAll('.modal').forEach(m => {
m.querySelectorAll('.field-limit-hint').forEach(h => h.style.display = 'none');
});
const bindClick = (id, fn) => { const el = document.getElementById(id); if (el) el.addEventListener('click', fn); };
function openServiceManagerFromSettings() {
closeModals();
gaGoPage('services');
setSvcTab('status');
void loadStatusPanel();
}
bindClick('add-model-btn', (e) => {
e.stopPropagation();
openAddModelForm();
});
bindClick('settings-btn', (e) => { e.stopPropagation(); openSettings(); });
bindClick('settings-services-btn', (e) => { e.stopPropagation(); openServiceManagerFromSettings(); });
const importMykeyInput = document.getElementById('import-mykey-input');
async function importMykeyFromFile(file) {
if (!file) return;
const text = await file.text();
if (!text.trim()) throw new Error(t('err.mykeyImport'));
await window.ga.saveMykeyContent(text);
await loadModelProfiles();
}
bindClick('import-mykey-btn', (e) => {
e.stopPropagation();
if (importMykeyInput) importMykeyInput.click();
});
if (importMykeyInput) {
importMykeyInput.addEventListener('change', async () => {
const file = importMykeyInput.files && importMykeyInput.files[0];
importMykeyInput.value = '';
if (!file) return;
try {
await importMykeyFromFile(file);
showChanToast(t('sys.mykeyImported'), '', 'ok');
} catch (err) {
showChanToast(t('err.mykeyImport'), err.message || String(err), 'err');
}
});
}
async function exportMykeyToDir() {
const res = await window.ga.getMykeyContent();
const content = (res && res.content) ? String(res.content) : '';
if (!content.trim()) throw new Error(t('err.mykeyExport'));
// WebView2:独立缓存 + 无目录选择/下载;走 Tauri 原生另存为
if (window.__TAURI__?.core?.invoke) {
const path = await window.ga.tauriInvoke('export_mykey', { content });
if (!path) return;
showChanToast(t('sys.mykeyExported'), path, 'ok');
return;
}
if (typeof window.showDirectoryPicker === 'function') {
const dir = await window.showDirectoryPicker();
const handle = await dir.getFileHandle('mykey.py', { create: true });
const writable = await handle.createWritable();
await writable.write(content);
await writable.close();
showChanToast(t('sys.mykeyExported'), '', 'ok');
return;
}
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'mykey.py';
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
showChanToast(t('sys.mykeyExported'), '', 'ok');
}
bindClick('export-mykey-btn', async (e) => {
e.stopPropagation();
try {
await exportMykeyToDir();
} catch (err) {
if (err && (err.name === 'AbortError' || err.code === 20)) return;
showChanToast(t('err.mykeyExport'), err.message || String(err), 'err');
}
});
// 侧边栏「快速接入」:点击官方模型按钮 → 打开预填好的添加模型表单
const pqEl = document.getElementById('provider-quickstart');
if (pqEl) pqEl.addEventListener('click', (e) => {
@@ -899,6 +1050,49 @@ document.querySelectorAll('.modal').forEach(m =>
}));
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModals(); });
function showConfirmDialog({ title, message, okText, okKind = 'primary', cancelText } = {}) {
const modal = document.getElementById('confirm-modal');
if (!modal) return Promise.resolve(false);
const titleEl = document.getElementById('confirm-title');
const msgEl = document.getElementById('confirm-message');
const okBtn = document.getElementById('confirm-ok');
const cancelBtn = document.getElementById('confirm-cancel');
if (titleEl) titleEl.textContent = title || t('common.confirm');
if (msgEl) msgEl.textContent = message || '';
if (cancelBtn) cancelBtn.textContent = cancelText || t('common.cancel');
if (okBtn) {
okBtn.textContent = okText || t('common.confirm');
okBtn.classList.toggle('danger', okKind === 'danger');
okBtn.classList.toggle('primary', okKind !== 'danger');
}
modal.hidden = false;
return new Promise(resolve => {
let done = false;
const finish = (yes) => {
if (done) return;
done = true;
modal.hidden = true;
cleanup();
resolve(yes);
};
const onOk = (e) => { e.preventDefault(); e.stopPropagation(); finish(true); };
const onCancel = (e) => { e.preventDefault(); e.stopPropagation(); finish(false); };
const onClose = (e) => { if (e.target.closest('[data-close]')) finish(false); };
const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); finish(false); } };
const cleanup = () => {
okBtn?.removeEventListener('click', onOk);
cancelBtn?.removeEventListener('click', onCancel);
modal.removeEventListener('click', onClose, true);
document.removeEventListener('keydown', onKey, true);
};
okBtn?.addEventListener('click', onOk);
cancelBtn?.addEventListener('click', onCancel);
modal.addEventListener('click', onClose, true);
document.addEventListener('keydown', onKey, true);
okBtn?.focus();
});
}
/* ═══════════════ Markdown ═══════════════ */
if (typeof marked !== 'undefined') {
marked.setOptions({ gfm: true, breaks: true, mangle: false, headerIds: false });
@@ -2906,10 +3100,22 @@ function applyPollResult(sess, result) {
for (const msg of (result.messages || [])) upsert(sess, msg, false);
const busy = result.status === 'running' || !!result.partial;
setBusy(sess, busy);
if (isActive(sess)) applyPlanPayload(sess, result.plan);
if (isActive(sess)) {
applyPlanPayload(sess, result.plan);
applyLiveModel(result.model, sess);
}
return busy;
}
/** 渠道组随故障转移变化时,用运行态当前子模型刷新 chip(非渠道组/无 agent 时不动,保持静态显示) */
function applyLiveModel(live, sess = activeSess()) {
const selected = (state.modelProfiles || []).find(p => (p.id ?? 0) === state.llmNo);
if (!selected || selected.kind !== 'mixin' || !live || !live.isMixin || !live.current) return;
state.liveModel = { ...live, sessionId: sess?.id || state.activeId };
const label = `${t('model.aggregationShort')}${lang === 'en' ? ' (' : '('}${profileLabel(live.current) || live.current}${lang === 'en' ? ')' : ')'}`;
if (state.modelName !== label) { state.modelName = label; updateModelChip(); }
}
/** hydrate 批量灌历史,避免逐条 appendMessage 触发全量重绘 */
function hydrateHistoryMessages(sess, messages) {
const r = rt(sess);
@@ -3200,6 +3406,15 @@ function showError(text) {
if (sess) { const m = { role: 'error', content: text }; sess.messages.push(m); appendMessage(sess, m); }
else console.error(text);
}
let _toastTimer = null;
function showToast(text) {
let el = document.getElementById('ga-toast');
if (!el) { el = document.createElement('div'); el.id = 'ga-toast'; el.className = 'ga-toast'; document.body.appendChild(el); }
el.textContent = text;
el.classList.add('show');
clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => el.classList.remove('show'), 1800);
}
async function handleSlash(cmd) {
const name = cmd.slice(1).split(/\s+/)[0];
switch (name) {
@@ -3255,13 +3470,116 @@ function updateModelChip() {
if (modelNameEl) modelNameEl.textContent = name;
if (collabModelNameEl) collabModelNameEl.textContent = name;
}
function modelDisplayName(p, fallbackName) {
if (p && p.kind === 'mixin') {
// 静态回退显示「渠道组(首选模型名)」;运行后由 applyLiveModel 切到真实当前子模型。
const primary = (p.members || [])[0];
if (!primary) return t('model.aggregation');
const open = lang === 'en' ? ' (' : '(', close = lang === 'en' ? ')' : ')';
return `${t('model.aggregationShort')}${open}${profileLabel(primary) || primary}${close}`;
}
return profileLabel(fallbackName ?? (p && p.name)) || (fallbackName ?? (p && p.name)) || null;
}
async function selectModel(id, name) {
state.llmNo = id;
state.modelName = profileLabel(name) || name || null;
state.liveModel = null;
const p = (state.modelProfiles || []).find(x => (x.id ?? 0) === id);
state.modelName = modelDisplayName(p, name);
updateModelChip();
renderSettingsModels();
await persistUiPrefs();
}
async function addToMixin(id) {
try {
const res = await bridgeFetch(`/model-profiles/${id}/mixin`, { method: 'POST', body: {} });
if (res?.ok === false || res?.error) throw new Error(res.error || t('err.mixinFailed'));
state.modelProfiles = normalizeProfiles(res.profiles || []);
renderSettingsModels();
} catch (ex) { showChanToast(t('err.mixinFailed'), ex.message || '', 'err'); }
}
async function removeFromMixin(id) {
try {
const res = await bridgeFetch(`/model-profiles/${id}/mixin`, { method: 'DELETE', body: {} });
if (res?.ok === false || res?.error) throw new Error(res.error || t('err.mixinFailed'));
state.modelProfiles = normalizeProfiles(res.profiles || []);
renderSettingsModels();
} catch (ex) { showChanToast(t('err.mixinFailed'), ex.message || '', 'err'); }
}
async function reorderMixin(members) {
try {
const res = await bridgeFetch('/model-profiles/mixin/order', { method: 'PUT', body: { members } });
if (res?.ok === false || res?.error) throw new Error(res.error || t('err.mixinFailed'));
state.modelProfiles = normalizeProfiles(res.profiles || []);
renderSettingsModels();
const active = state.modelProfiles.find(p => (p.id ?? 0) === state.llmNo);
if (active) { state.modelName = modelDisplayName(active); updateModelChip(); }
} catch (ex) { showChanToast(t('err.mixinFailed'), ex.message || '', 'err'); renderSettingsModels(); }
}
function flipReorder(container, mutate) {
const rows = [...container.querySelectorAll('.model-member:not(.dragging)')];
const first = new Map(rows.map(el => [el, el.getBoundingClientRect()]));
mutate();
rows.forEach(el => {
const a = first.get(el), b = el.getBoundingClientRect();
if (!a) return;
const dx = a.left - b.left, dy = a.top - b.top;
if (!dx && !dy) return;
el.style.transition = 'none';
el.style.transform = `translate(${dx}px, ${dy}px)`;
requestAnimationFrame(() => {
el.style.transition = 'transform .14s cubic-bezier(.2,.8,.2,1)';
el.style.transform = '';
});
});
}
function bindMixinDrag(body, members) {
let drag = null;
const clear = () => {
body.querySelectorAll('.model-member').forEach(x => x.classList.remove('dragging', 'drag-over'));
document.body.classList.remove('mixin-dragging');
};
body.addEventListener('pointerdown', (e) => {
const handle = e.target.closest('.model-member-drag');
if (!handle || e.button !== 0) return;
const row = handle.closest('.model-member');
if (!row) return;
e.preventDefault();
handle.setPointerCapture?.(e.pointerId);
drag = { handle, row, name: row.dataset.member, order: [...members], original: [...members], pointerId: e.pointerId, over: null };
row.classList.add('dragging');
document.body.classList.add('mixin-dragging');
});
body.addEventListener('pointermove', (e) => {
if (!drag) return;
const over = document.elementFromPoint(e.clientX, e.clientY)?.closest('.model-member');
if (!over || !body.contains(over) || over === drag.row) return;
const rect = over.getBoundingClientRect();
const after = e.clientY > rect.top + rect.height / 2;
const overKey = `${over.dataset.member}:${after ? 'after' : 'before'}`;
if (overKey === drag.over) return;
drag.over = overKey;
const from = drag.order.indexOf(drag.name), overIdx = drag.order.indexOf(over.dataset.member);
if (from < 0 || overIdx < 0) return;
const [moved] = drag.order.splice(from, 1);
let insertAt = drag.order.indexOf(over.dataset.member) + (after ? 1 : 0);
drag.order.splice(insertAt, 0, moved);
flipReorder(body, () => {
if (after) body.insertBefore(drag.row, over.nextSibling);
else body.insertBefore(drag.row, over);
});
});
const finish = (e) => {
if (!drag) return;
drag.handle.releasePointerCapture?.(drag.pointerId);
const changed = JSON.stringify(drag.order) !== JSON.stringify(drag.original);
const next = drag.order;
drag = null;
clear();
if (changed) reorderMixin(next);
};
body.addEventListener('pointerup', finish);
body.addEventListener('pointercancel', finish);
}
const MODEL_ACT_EDIT = GA_ICON('pencilSimple');
const MODEL_ACT_DEL = GA_ICON('trash');
let editingModelId = null;
@@ -3284,7 +3602,7 @@ const PROVIDER_PRESETS = {
deepseek: {
label: 'DeepSeek', descKey: 'pq.deepseekDesc',
protocol: 'oai', apibase: 'https://api.deepseek.com/v1',
model: 'deepseek-chat', name: 'DeepSeek',
model: 'deepseek-v4-pro', name: 'DeepSeek',
keyUrl: 'https://platform.deepseek.com/api_keys',
color: '#4D6BFE', tint: 'rgba(77,107,254,.12)',
logo: '<svg viewBox="0 0 24 24" fill="#4D6BFE" xmlns="http://www.w3.org/2000/svg"><path d="M23.748 4.651c-.254-.124-.364.113-.512.233-.051.04-.094.09-.137.137-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.155-.708-.311-.955-.65-.172-.24-.219-.509-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.094.172.187.129.323-.082.28-.18.553-.266.833-.055.179-.137.218-.328.14a5.5 5.5 0 0 1-1.737-1.179c-.857-.828-1.631-1.743-2.597-2.46a12 12 0 0 0-.689-.47c-.985-.957.13-1.743.387-1.836.27-.098.094-.433-.778-.428-.872.003-1.67.295-2.687.685a3 3 0 0 1-.465.136 9.6 9.6 0 0 0-2.883-.101c-1.885.21-3.39 1.1-4.497 2.622C.082 8.776-.231 10.854.152 13.02c.403 2.284 1.568 4.175 3.36 5.653 1.857 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.132-.284 4.994-1.86.47.234.962.328 1.78.398.629.058 1.235-.031 1.705-.129.735-.155.684-.836.418-.961-2.155-1.004-1.682-.595-2.112-.926 1.095-1.295 2.768-3.598 3.284-6.733.05-.346.115-.834.108-1.114-.004-.171.035-.238.23-.257a4.2 4.2 0 0 0 1.545-.475c1.397-.763 1.96-2.016 2.093-3.517.02-.23-.004-.467-.247-.588M11.58 18.168c-2.088-1.642-3.101-2.183-3.52-2.16-.39.024-.32.472-.234.763.09.288.207.487.371.74.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.168-1.361-.801-2.5-1.86-3.301-3.306-.775-1.393-1.225-2.888-1.299-4.482-.02-.385.094-.522.477-.592a4.7 4.7 0 0 1 1.53-.038c2.131.311 3.946 1.264 5.467 2.774.868.86 1.525 1.887 2.202 2.89.72 1.066 1.494 2.082 2.48 2.915.348.291.626.513.892.677-.802.09-2.14.109-3.055-.615zm1.001-6.44a.306.306 0 0 1 .415-.287.3.3 0 0 1 .113.074.3.3 0 0 1 .086.214c0 .17-.136.307-.308.307a.303.303 0 0 1-.306-.307m3.11 1.596c-.2.081-.4.151-.591.16a1.25 1.25 0 0 1-.798-.254c-.274-.23-.47-.358-.551-.758a1.7 1.7 0 0 1 .015-.588c.07-.327-.007-.537-.238-.727-.188-.156-.426-.199-.689-.199a.6.6 0 0 1-.254-.078.253.253 0 0 1-.114-.358 1 1 0 0 1 .192-.21c.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.392.451.462.576.685.915.176.264.336.536.446.848.066.194-.02.353-.25.45"/></svg>',
@@ -3292,7 +3610,7 @@ const PROVIDER_PRESETS = {
qwen: {
label: '通义千问', descKey: 'pq.qwenDesc',
protocol: 'oai', apibase: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
model: 'qwen-plus', name: '通义千问',
model: 'qwen3.6-max-preview', name: '通义千问',
keyUrl: 'https://bailian.console.aliyun.com/?apiKey=1',
color: '#615CED', tint: 'rgba(97,92,237,.12)',
logo: '<svg viewBox="0 0 24 24" fill="#615CED" xmlns="http://www.w3.org/2000/svg"><path d="M23.919 14.545 20.817 9.17l1.47-2.544a.56.56 0 0 0 0-.566l-1.633-2.83a.57.57 0 0 0-.49-.283h-6.207L12.487.402a.57.57 0 0 0-.49-.284H8.732a.56.56 0 0 0-.49.284L5.139 5.775h-2.94a.56.56 0 0 0-.49.284L.077 8.887a.56.56 0 0 0 0 .567L3.18 14.83l-1.47 2.545a.56.56 0 0 0 0 .566l1.634 2.83a.57.57 0 0 0 .49.283h6.205l1.47 2.545a.57.57 0 0 0 .49.284h3.266a.57.57 0 0 0 .49-.284l3.104-5.375h2.94a.57.57 0 0 0 .49-.283l1.634-2.828a.55.55 0 0 0-.004-.568M8.733.686l1.634 2.828-1.634 2.828H21.8L20.164 9.17H7.425L5.63 6.06Zm1.306 19.801-6.205-.002 1.634-2.83h3.265L2.201 6.344h3.267q3.182 5.517 6.367 11.032zm10.124-5.66L18.53 12l-6.532 11.315-1.634-2.83c2.129-3.673 4.25-7.351 6.373-11.028h3.592l3.102 5.374z"/></svg>',
@@ -3381,17 +3699,21 @@ async function openEditModelForm(id) {
const pv = /claude/i.test(p.varName || '') ? 'claude' : 'oai';
const pr = form.querySelector(`input[name="protocol"][value="${pv}"]`);
if (pr) pr.checked = true;
// 回填流式开关(默认流式)
const sv = (p.stream === false) ? 'false' : 'true';
const sr = form.querySelector(`input[name="stream"][value="${sv}"]`);
if (sr) sr.checked = true;
}
setModelApikeyMode(false);
openModal('add-model-modal');
applyI18n();
} catch (ex) {
alert(ex.message || t('err.modelSave'));
showChanToast(t('err.modelSave'), ex.message || '', 'err');
}
}
async function deleteModel(id, name) {
const label = profileLabel(name) || name || ('#' + id);
if (!confirm(`${t('confirm.modelDelete')}\n${label}`)) return;
if (!(await showConfirmDialog({ title: t('common.delete'), message: `${t('confirm.modelDelete')}\n${label}`, okText: t('common.delete'), okKind: 'danger' }))) return;
try {
const res = await bridgeFetch(`/model-profiles/${id}`, { method: 'DELETE', body: {} });
if (res?.ok === false || res?.error) throw new Error(res.error || t('err.modelDelete'));
@@ -3409,7 +3731,7 @@ async function deleteModel(id, name) {
renderSettingsModels();
} catch (ex) {
const msg = ex.message || '';
alert(msg.includes('last profile') ? t('err.modelDeleteLast') : (msg || t('err.modelDelete')));
showChanToast(msg.includes('last profile') ? t('err.modelDeleteLast') : t('err.modelDelete'), msg.includes('last profile') ? '' : msg, 'err');
}
}
function renderSettingsModels() {
@@ -3417,26 +3739,79 @@ function renderSettingsModels() {
if (!box) return;
box.innerHTML = '';
const list = state.modelProfiles || [];
if (!list.length) {
const mixin = list.find(p => p.kind === 'mixin');
const natives = list.filter(p => p.kind !== 'mixin');
const byName = new Map(natives.map(p => [p.name, p]));
// ── 渠道组(自动故障转移):可展开组;本身也可被选为激活模型 ──
if (mixin) {
const gid = mixin.id ?? 0;
const members = mixin.members || [];
const expanded = state.mixinExpanded !== false; // 默认展开
const group = document.createElement('div');
group.className = 'model-group';
const head = document.createElement('label');
head.className = 'model-row model-row--mixin' + (state.llmNo === gid ? ' sel' : '');
head.innerHTML = `<input type="radio" name="model-pick"${state.llmNo === gid ? ' checked' : ''}><span class="model-mixin-caret" data-act="toggle">${GA_ICON(expanded ? 'caretDown' : 'caretRight')}</span><span class="model-row-name">${escapeHtml(t('model.aggregation'))}</span>`;
head.querySelector('[data-act="toggle"]').addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); state.mixinExpanded = !expanded; renderSettingsModels(); });
head.addEventListener('click', (e) => { if (e.target.closest('[data-act="toggle"]')) return; e.preventDefault(); selectModel(gid, mixin.name); });
group.appendChild(head);
if (expanded) {
const body = document.createElement('div');
body.className = 'model-mixin-body';
if (!members.length) {
const em = document.createElement('div');
em.className = 'model-mixin-empty'; em.textContent = t('model.emptyMixin');
body.appendChild(em);
} else {
members.forEach((mName, i) => {
const mp = byName.get(mName);
const row = document.createElement('div');
row.className = 'model-member';
row.dataset.member = mName;
row.innerHTML = `<button type="button" class="model-member-drag" data-act="drag" title="${escapeHtml(t('model.dragReorder'))}" aria-label="${escapeHtml(t('model.dragReorder'))}"><span class="grip-dot"></span></button><span class="model-member-name">${escapeHtml(profileLabel(mName) || mName)}</span><button type="button" class="model-act model-act-del" data-act="unmix" title="${escapeHtml(t('model.removeFromMixin'))}">${GA_ICON('x')}</button>`;
row.querySelector('[data-act="unmix"]').addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); if (mp) removeFromMixin(mp.id ?? 0); });
body.appendChild(row);
});
bindMixinDrag(body, members);
}
group.appendChild(body);
}
box.appendChild(group);
}
// ── 独立模型(聚合渠道组已自带分隔,这里不再单列标题)──
if (!natives.length) {
const empty = document.createElement('div');
empty.className = 'set-empty'; empty.textContent = t('set.noModels');
box.appendChild(empty); return;
}
for (const p of list) {
const id = p.id ?? 0;
const label = profileLabel(p.name) || p.name || ('#' + id);
const row = document.createElement('label');
row.className = 'model-row' + (state.llmNo === id ? ' sel' : '');
row.innerHTML = `<input type="radio" name="model-pick"${state.llmNo === id ? ' checked' : ''}><span class="model-row-name">${escapeHtml(label)}</span><span class="model-row-actions"><button type="button" class="model-act" data-act="edit" title="${escapeHtml(t('common.edit'))}">${MODEL_ACT_EDIT}</button><button type="button" class="model-act model-act-del" data-act="delete" title="${escapeHtml(t('common.delete'))}">${MODEL_ACT_DEL}</button></span>`;
row.querySelector('[data-act="edit"]').addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); openEditModelForm(id); });
row.querySelector('[data-act="delete"]').addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); deleteModel(id, p.name); });
row.addEventListener('click', (e) => {
if (e.target.closest('.model-row-actions')) return;
e.preventDefault();
selectModel(id, p.name);
});
box.appendChild(row);
box.appendChild(empty);
} else {
for (const p of natives) {
const id = p.id ?? 0;
const label = profileLabel(p.name) || p.name || ('#' + id);
const row = document.createElement('label');
row.className = 'model-row' + (state.llmNo === id ? ' sel' : '');
// 独立列表按钮统一为「加入渠道组」(➕);移除只在渠道组展开区做。
// 已在渠道组的,按钮仍是「加入」,但点击只提示「已在渠道组中」,并用 is-in 给个淡淡的视觉区分。
const mixToggle = !mixin ? '' : `<button type="button" class="model-act model-act-addmix${p.inMixin ? ' is-in' : ''}" data-act="addmix" title="${escapeHtml(p.inMixin ? t('model.alreadyInMixin') : t('model.addToMixin'))}">${GA_ICON('plus')}</button>`;
row.innerHTML = `<input type="radio" name="model-pick"${state.llmNo === id ? ' checked' : ''}><span class="model-row-name">${escapeHtml(label)}</span><span class="model-row-actions">${mixToggle}<button type="button" class="model-act" data-act="edit" title="${escapeHtml(t('common.edit'))}">${MODEL_ACT_EDIT}</button><button type="button" class="model-act model-act-del" data-act="delete" title="${escapeHtml(t('common.delete'))}">${MODEL_ACT_DEL}</button></span>`;
row.querySelector('[data-act="edit"]').addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); openEditModelForm(id); });
row.querySelector('[data-act="delete"]').addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); deleteModel(id, p.name); });
const addBtn = row.querySelector('[data-act="addmix"]');
if (addBtn) addBtn.addEventListener('click', (e) => {
e.stopPropagation(); e.preventDefault();
if (p.inMixin) showToast(t('model.alreadyInMixin'));
else addToMixin(id);
});
row.addEventListener('click', (e) => {
if (e.target.closest('.model-row-actions')) return;
e.preventDefault();
selectModel(id, p.name);
});
box.appendChild(row);
}
}
applyI18n();
}
function openSettings() {
openModal('settings-modal');
@@ -3454,7 +3829,7 @@ async function loadModelProfiles() {
const active = state.modelProfiles.find(p => p.active) || state.modelProfiles[0];
if (active) {
state.llmNo = active.id ?? 0;
state.modelName = profileLabel(active.name) || active.name || null;
state.modelName = modelDisplayName(active);
}
updateModelChip();
renderSettingsModels();
@@ -3469,7 +3844,8 @@ function renderModelMenu(menuEl) {
const rows = list.map((p, i) => {
const no = (p.id ?? i);
const isActive = (state.llmNo === no) ? ' active' : '';
return `<div class="ga-menu-item${isActive}" data-llmno="${no}">${escapeHtml(p.name || '')}</div>`;
const label = (isActive && p.kind === 'mixin' && state.modelName) ? state.modelName : modelDisplayName(p);
return `<div class="ga-menu-item${isActive}" data-llmno="${no}">${escapeHtml(label || '')}</div>`;
});
menuEl.innerHTML = rows.join('');
applyI18n();
@@ -3565,7 +3941,7 @@ async function loadBridgeConfig() {
const p = state.modelProfiles.find(x => (x.id ?? 0) === cfg.llmNo);
if (p) {
state.llmNo = cfg.llmNo;
state.modelName = profileLabel(p.name) || p.name || null;
state.modelName = modelDisplayName(p);
updateModelChip();
renderSettingsModels();
}
@@ -4030,6 +4406,7 @@ window.ga.onBridgeClosed(() => {
/* ═══════════════ Token 用量页 ═══════════════ */
const tokTbody = document.getElementById('tok-tbody');
const tokTable = document.getElementById('tok-table');
const tokPager = document.getElementById('tok-pager');
const tokSince = document.getElementById('tok-since');
const tokUntil = document.getElementById('tok-until');
@@ -4245,8 +4622,8 @@ if (tokTabs) tokTabs.addEventListener('click', e => {
btn.classList.add('active');
_tokTab = btn.dataset.tab;
_tokPage = 0;
if (_tokTab === 'conductor') { if (tokFilter) tokFilter.style.display = 'none'; if (tokStatRow) tokStatRow.style.display = 'none'; loadConductorTokens(); }
else { if (tokFilter) tokFilter.style.display = ''; if (tokStatRow) tokStatRow.style.display = ''; loadTokenPage(); }
if (_tokTab === 'conductor') { if (tokFilter) tokFilter.style.display = 'none'; if (tokStatRow) tokStatRow.style.display = 'none'; if (tokTable) tokTable.classList.add('tok-table--conductor'); loadConductorTokens(); }
else { if (tokFilter) tokFilter.style.display = ''; if (tokStatRow) tokStatRow.style.display = ''; if (tokTable) tokTable.classList.remove('tok-table--conductor'); loadTokenPage(); }
});
async function loadConductorTokens() {
@@ -4273,7 +4650,7 @@ async function loadConductorTokens() {
const hIn = hist.input + curIn, hOut = hist.output + curOut, hCc = hist.cacheCreate + curCc, hCr = hist.cacheRead + curCr, hCost = hist.cost + curCost;
if (!tokTbody) return;
const tip = t('tok.condTip');
const _ci = GA_ICON('gitFork');
const _ci = GA_ICON('gitFork', 'tok-cond-ico');
const hCacheBase = hIn + hCr + hCc;
const hCacheRate = hCacheBase > 0 ? (hCr / hCacheBase * 100).toFixed(1) + '%' : '0%';
const curCacheBase = curIn + curCr + curCc;
@@ -4831,6 +5208,20 @@ function showChanToast(title, detail, kind) {
// Preset form: 每字段独立内联提示(标题/Prompt 各自独立,互不串扰)
bindFieldInlineLimit(document.getElementById('cp-title'));
bindFieldInlineLimit(document.getElementById('cp-prompt'));
// First-run desktop-shortcut prompt (Windows portable bundle only). Driven from the web UI
// so the dialog always renders on top — a native dialog from the Rust startup thread had no
// parent window and got buried behind the main window on first launch.
maybeAskDesktopShortcut();
}
async function maybeAskDesktopShortcut() {
try {
const should = await window.ga.tauriInvoke('shortcut_should_ask');
if (!should) return;
const create = await showConfirmDialog({ title: t('common.confirm'), message: t('shortcut.askConfirm'), okText: t('common.confirm') });
await window.ga.tauriInvoke('shortcut_decide', { create });
} catch (_) { /* not in tauri / not a bundle — ignore */ }
}
if (document.readyState === 'loading') {
@@ -4980,10 +5371,49 @@ if (chanConfigSave) {
/* ═══════════════ 状态面板(复用 ServiceManager + 启停/日志) ═══════════════ */
const statusListEl = document.getElementById('status-list');
const BRIDGE_SERVICE_ID = '__bridge__';
const EXTRA_SERVICE_IDS = new Set(['frontends/conductor.py', 'reflect/scheduler.py']);
function bridgeOfflinePanelServices() {
return [
{
id: BRIDGE_SERVICE_ID,
name: `bridge (:${BRIDGE_PORT})`,
status: 'offline',
running: false,
pid: null,
memMb: null,
cpuPct: null,
managed: false,
},
{
id: 'frontends/conductor.py',
name: 'frontends/conductor.py',
status: 'offline',
running: false,
pid: null,
memMb: null,
cpuPct: null,
managed: false,
bridgeOffline: true,
},
{
id: 'reflect/scheduler.py',
name: 'reflect/scheduler.py',
status: 'offline',
running: false,
pid: null,
memMb: null,
cpuPct: null,
managed: false,
bridgeOffline: true,
},
];
}
function statusDisplayName(s) {
if (!s) return '';
if (s.id === '__bridge__') return s.name || 'bridge';
if (s.id === BRIDGE_SERVICE_ID) return s.name || 'bridge';
if (s.id === 'reflect/scheduler.py') return t('proc.scheduler');
if (s.id === 'frontends/conductor.py') return t('proc.conductor');
return channelDisplayName(s);
@@ -5005,10 +5435,26 @@ function renderStatusPanel(services) {
const stClass = channelStatusClass(s.status || 'offline');
const running = !!s.running;
const managed = s.managed !== false;
let acts = `<button type="button" class="link-btn link sm" data-act="logs"></button>`;
if (managed) {
if (running) acts += `<button type="button" class="link-btn link sm" data-act="restart"></button>`;
acts += `<button type="button" class="sw-mini${running ? ' on' : ''}" data-act="toggle" aria-pressed="${running}"><i></i></button>`;
const isBridge = s.id === BRIDGE_SERVICE_ID;
const isExtra = EXTRA_SERVICE_IDS.has(s.id);
let acts = '';
if (isBridge) {
if (running) {
acts += `<button type="button" class="link-btn link sm" data-act="logs"></button>`;
acts += `<button type="button" class="link-btn link sm" data-act="bridge-exit"></button>`;
} else {
acts += `<button type="button" class="link-btn link sm" data-act="bridge-start"></button>`;
}
} else if (!s.bridgeOffline) {
acts += `<button type="button" class="link-btn link sm" data-act="logs"></button>`;
if (managed) {
if (running) acts += `<button type="button" class="link-btn link sm" data-act="restart"></button>`;
if (isExtra) {
acts += `<button type="button" class="link-btn link sm${running ? ' on' : ''}" data-act="toggle" aria-pressed="${running}"></button>`;
} else {
acts += `<button type="button" class="sw-mini${running ? ' on' : ''}" data-act="toggle" aria-pressed="${running}"><i></i></button>`;
}
}
}
row.innerHTML = `
<b class="st-name"></b>
@@ -5025,14 +5471,30 @@ function renderStatusPanel(services) {
if (logBtn) logBtn.textContent = t('act.logs');
const rstBtn = row.querySelector('[data-act="restart"]');
if (rstBtn) rstBtn.textContent = t('act.restart');
const startBridgeBtn = row.querySelector('[data-act="bridge-start"]');
if (startBridgeBtn) startBridgeBtn.textContent = t('act.start');
const exitBridgeBtn = row.querySelector('[data-act="bridge-exit"]');
if (exitBridgeBtn) exitBridgeBtn.textContent = t('act.exit');
const textToggleBtn = row.querySelector('.link-btn[data-act="toggle"]');
if (textToggleBtn) textToggleBtn.textContent = running ? t('act.exit') : t('act.start');
statusListEl.appendChild(row);
}
}
async function loadStatusPanel() {
if (!statusListEl) return;
const res = await window.ga.getServicePanel();
renderStatusPanel(res.services || []);
if (bridgeUiOffline) {
renderStatusPanel(bridgeOfflinePanelServices());
return;
}
try {
const res = await window.ga.getServicePanel();
renderStatusPanel(res.services || []);
} catch (_) {
window.ga.setBridgeUiOffline(true);
gaServiceStore.applySnapshot(bridgeOfflinePanelServices());
renderStatusPanel(bridgeOfflinePanelServices());
}
}
async function restartService(id) {
@@ -5057,6 +5519,40 @@ if (statusListEl) {
openChannelLogs(id);
return;
}
if (act === 'bridge-start') {
if (_chanBusy) return;
_chanBusy = true;
actEl.disabled = true;
try {
await window.ga.spawnBridge();
await loadStatusPanel();
showChanToast(t('sys.channelStarted') + ' · bridge', '', 'ok');
} catch (err) {
showChanToast(t('err.channelStart') + ' · bridge', err.message || String(err), 'err');
} finally {
_chanBusy = false;
actEl.disabled = false;
}
return;
}
if (act === 'bridge-exit') {
if (_chanBusy) return;
_chanBusy = true;
actEl.disabled = true;
try {
window.ga.setBridgeUiOffline(true);
gaServiceStore.applySnapshot(bridgeOfflinePanelServices());
renderStatusPanel(bridgeOfflinePanelServices());
await window.ga.exitBridge();
showChanToast(t('sys.channelStopped') + ' · bridge', '', 'ok');
} catch (err) {
showChanToast(t('err.channelStop') + ' · bridge', err.message || String(err), 'err');
} finally {
_chanBusy = false;
actEl.disabled = false;
}
return;
}
if (act === 'restart') {
if (_chanBusy) return;
_chanBusy = true;
@@ -5081,7 +5577,10 @@ if (statusListEl) {
gaServiceStore.onServices((list) => {
if (isSvcTab('channels')) renderChannelList(list);
if (isSvcTab('status')) loadStatusPanel();
if (isSvcTab('status')) {
if (bridgeUiOffline) renderStatusPanel(bridgeOfflinePanelServices());
else loadStatusPanel();
}
});
if (chanListEl) {
chanListEl.addEventListener('click', async (e) => {
@@ -5701,4 +6200,4 @@ function bindComposerInRoot(root, opts) {
input.addEventListener('input', update);
update();
});
})();
})();
+44 -4
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<title>GenericAgent</title>
<link rel="stylesheet" href="assets/fonts/fonts.css?v=2">
<link rel="stylesheet" href="styles.css?v=161">
<link rel="stylesheet" href="styles.css?v=181">
<!-- KaTeX: LaTeX 公式渲染 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css" crossorigin="anonymous">
<!-- highlight.js: 代码高亮 -->
@@ -387,7 +387,6 @@
<tbody id="tok-tbody"></tbody>
</table>
<div class="tok-pager" id="tok-pager"></div>
<p style="color:var(--muted);font-size:12px;margin-top:12px;text-align:center" data-i18n="tok.disclaimer"></p>
</div>
</section>
@@ -484,6 +483,12 @@
<label class="seg-opt"><input type="radio" name="protocol" value="claude" required><span data-i18n="model.protocolClaude"></span></label>
</div>
</div>
<div class="model-form-field"><span class="field-label"><span data-i18n="model.stream"></span></span>
<div class="seg-group" role="radiogroup">
<label class="seg-opt"><input type="radio" name="stream" value="true" checked><span data-i18n="model.streamOn"></span></label>
<label class="seg-opt"><input type="radio" name="stream" value="false"><span data-i18n="model.streamOff"></span></label>
</div>
</div>
<label><span data-i18n="model.name"></span><input name="name" data-i18n-ph="model.namePh" data-optional-ph maxlength="50"></label>
<div class="model-form-row">
<label class="model-form-mini"><span data-i18n="model.retries"></span><input name="max_retries" type="text" inputmode="numeric" pattern="[0-9]*" min="1" max="20" value="5"></label>
@@ -500,6 +505,25 @@
</div>
</div>
<div class="modal" id="confirm-modal" hidden>
<div class="modal-backdrop" data-close></div>
<div class="modal-card confirm-card" role="dialog" aria-modal="true" aria-labelledby="confirm-title">
<div class="modal-head">
<div class="modal-title" id="confirm-title"></div>
<button class="modal-x" data-close data-i18n-title="common.close" title="">
<span data-ga-icon="x"></span>
</button>
</div>
<div class="modal-body confirm-body">
<div class="confirm-message" id="confirm-message"></div>
<div class="confirm-actions">
<button type="button" class="set-btn ghost" id="confirm-cancel"></button>
<button type="button" class="set-btn primary" id="confirm-ok"></button>
</div>
</div>
</div>
</div>
<!-- 自定义预设 弹窗 -->
<div class="modal" id="custom-preset-modal" hidden>
<div class="modal-backdrop" data-close></div>
@@ -606,7 +630,7 @@
<div class="set-sec-t" data-i18n="set.lang"></div>
<div class="model-list lang-list" id="lang-list"></div>
</div>
<div>
<div class="set-block">
<div class="set-sec-t" data-i18n="set.model"></div>
<div class="model-list" id="model-list"></div>
<button class="set-btn" id="add-model-btn" type="button">
@@ -614,6 +638,22 @@
<span data-i18n="set.addModel"></span>
</button>
</div>
<div class="set-block">
<div class="set-sec-t" data-i18n="set.features"></div>
<button class="set-btn" id="import-mykey-btn" type="button">
<span data-ga-icon="fileText"></span>
<span data-i18n="set.importMykey"></span>
</button>
<input type="file" id="import-mykey-input" accept=".py,text/plain" hidden>
<button class="set-btn set-btn-follow" id="export-mykey-btn" type="button">
<span data-ga-icon="folderSimple"></span>
<span data-i18n="set.exportMykey"></span>
</button>
<button class="set-btn set-btn-follow" id="settings-services-btn" type="button">
<span data-ga-icon="broadcast"></span>
<span data-i18n="set.serviceManager"></span>
</button>
</div>
</div>
</div>
</div>
@@ -634,6 +674,6 @@
<!-- highlight.js -->
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js" crossorigin="anonymous"></script>
<script src="phosphor-icons.js?v=2"></script>
<script src="app.js?v=191"></script>
<script src="app.js?v=208"></script>
</body>
</html>
+46 -4
View File
@@ -1,14 +1,56 @@
<!DOCTYPE html>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>GenericAgent</title>
<style>
:root{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text","Helvetica Neue",Helvetica,Arial,sans-serif;background:#fff;color:#111}
:root{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text","Segoe UI","Microsoft YaHei UI","Helvetica Neue",Helvetica,Arial,sans-serif;background:#fff;color:#111}
@media(prefers-color-scheme:dark){:root{background:#1a1a1a;color:#eee}}
html,body{height:100%;margin:0;display:flex;align-items:center;justify-content:center}
.card{text-align:center;opacity:.78}.spinner{width:28px;height:28px;margin:0 auto 14px;border:3px solid #9994;border-top-color:currentColor;border-radius:50%;animation:spin 1s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
.card{text-align:center;opacity:.85;width:280px}
.spinner{width:28px;height:28px;margin:0 auto 14px;border:3px solid #9994;border-top-color:currentColor;border-radius:50%;animation:spin 1s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
#ga-status{font-size:13px;line-height:1.5}
.bar{display:none;width:240px;height:6px;margin:14px auto 0;background:#9993;border-radius:99px;overflow:hidden}
.bar.show{display:block}
#ga-bar{height:100%;width:0;background:currentColor;border-radius:99px;transition:width .3s ease}
#ga-bar.indet{width:35%;animation:indet 1.1s ease-in-out infinite}
@keyframes indet{0%{margin-left:-35%}100%{margin-left:100%}}
</style>
<script>
// Loading window shows before the main UI, on a different origin than the app, so it can't
// read the app's saved language; fall back to the system/browser locale.
var GA_ZH = (navigator.language || '').toLowerCase().indexOf('zh') === 0;
var GA_MSG = {
starting_app: GA_ZH ? '正在启动 GenericAgent…' : 'Starting GenericAgent…',
start: GA_ZH ? '首次启动,正在准备运行环境…' : 'First run: preparing the runtime…',
venv: GA_ZH ? '正在创建运行环境…' : 'Creating the runtime environment…',
deps: GA_ZH ? '正在安装依赖…' : 'Installing dependencies…',
done: GA_ZH ? '依赖安装完成' : 'Dependencies installed',
starting: GA_ZH ? '正在启动服务…' : 'Starting services…'
};
window.gaProgress = function(pct, key){
var s = document.getElementById('ga-status');
if (s) s.textContent = GA_MSG[key] || key;
var bar = document.querySelector('.bar');
var fill = document.getElementById('ga-bar');
if (bar) bar.classList.add('show');
if (fill){
if (pct < 0){ fill.classList.add('indet'); }
else { fill.classList.remove('indet'); fill.style.width = Math.max(0, Math.min(100, pct)) + '%'; }
}
};
document.addEventListener('DOMContentLoaded', function(){
var s = document.getElementById('ga-status');
if (s) s.textContent = GA_MSG.starting_app;
});
</script>
</head>
<body><div class="card"><div class="spinner"></div><div>Starting GenericAgent…</div></div></body>
<body>
<div class="card">
<div class="spinner"></div>
<div id="ga-status">正在启动 GenericAgent…</div>
<div class="bar"><div id="ga-bar"></div></div>
</div>
</body>
</html>
+84 -8
View File
@@ -16,7 +16,7 @@
:root{
--font-sans:-apple-system,BlinkMacSystemFont,system-ui,"Segoe UI Variable","Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei UI","Microsoft YaHei",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",emoji;
--font-brand:"GA Azonix",var(--font-sans);
--font-mono:"GA JetBrains Mono",ui-monospace,"SFMono-Regular",Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;
--font-mono:"GA JetBrains Mono",ui-monospace,"SFMono-Regular",Menlo,Monaco,Consolas,"Liberation Mono","Courier New","PingFang SC","Hiragino Sans GB","Microsoft YaHei UI","Microsoft YaHei",monospace;
--font-num:var(--font-mono);
--font-num-variant:lining-nums tabular-nums;
--chat-col-w:760px;
@@ -61,14 +61,16 @@
--ok-strong:#2f9e54;
--ok-bg:#e9f7ee;
--ok-ring:#39a85a33;
/* 墨绿:发送键专用 */
--ink-green:#1f6f53;
--ink-green-hover:#175a44;
/* 发送键专用:浅色=黑色,深色在 dark 块里覆盖为深蓝 */
--ink-green:#1a1a1a;
--ink-green-hover:#000000;
--danger:#d64545;
--danger-dot:#e23b3b;
--danger-text:#d3514f;
--danger-bg:#fdeceb;
--danger-ring:#e23b3b38;
--confirm-primary-bg:#1a1a1a;
--confirm-primary-fg:#ffffff;
--success:var(--ok-strong);
--success-bg:var(--ok-bg);
--success-ring:var(--ok-ring);
@@ -159,6 +161,8 @@ html[data-appearance="dark"]{
--danger-text:#ff7b72;
--danger-bg:#2d1515;
--danger-ring:#f8514938;
--confirm-primary-bg:#e6edf3;
--confirm-primary-fg:#161b22;
--overlay:#010409a6;
--shadow:0 1px 2px #01040966, 0 8px 24px #01040966;
--shadow-pop:0 8px 24px #01040999;
@@ -172,6 +176,9 @@ html[data-appearance="dark"]{
--fold-result-bg:#15241a;
--divider-base:#569cd6;
--divider:var(--divider-base);
/* 发送键:深色用鲜亮深蓝 */
--ink-green:#2559d8;
--ink-green-hover:#3a6ef0;
}
html[data-appearance="dark"][data-theme]{
--accent-bg:color-mix(in srgb, var(--accent) 22%, var(--panel));
@@ -194,6 +201,7 @@ body{ background:var(--bg); color:var(--txt); font-size:13px; overflow:hidden; f
.modal[hidden]{ display:none; }
/* 添加模型是从「配置」里二级弹出,需盖在配置之上 */
#add-model-modal{ z-index:600; }
#confirm-modal{ z-index:700; }
.modal-backdrop{ position:absolute; inset:0; background:var(--overlay); }
.modal-card{
position:relative; width:420px; max-width:92vw; max-height:86vh; overflow:hidden;
@@ -326,14 +334,15 @@ body{ background:var(--bg); color:var(--txt); font-size:13px; overflow:hidden; f
.chat-font-scale-max{ grid-column:11; justify-self:end; }
/* 颜色色盘已删除(只保留灰色亮色主题) */
.model-list{ display:flex; flex-direction:column; gap:4px; margin-bottom:10px; max-height:160px; overflow:auto; }
.model-list{ display:flex; flex-direction:column; gap:4px; margin-bottom:10px; max-height:340px; overflow:auto; }
.model-row{
display:flex; align-items:center; gap:10px; padding:8px 10px;
border:1px solid var(--line); border-radius:4px; background:var(--field-bg);
cursor:pointer; font-size:13px; color:var(--txt-2); transition:.12s;
}
.model-row:hover{ background:var(--line-soft); border-color:var(--line-strong); }
.model-row.sel{ background:var(--accent-bg); border-color:var(--accent); color:var(--accent); font-weight:500; }
/* 选中态:文字用强调色 --txt-strong(而非偏灰的 --accent),保证选中后只会更清晰、不会变淡 */
.model-row.sel{ background:var(--accent-bg); border-color:var(--accent); color:var(--txt-strong); }
.model-row input{ accent-color:var(--accent); flex:0 0 auto; pointer-events:none; }
.flag{ width:20px; height:14px; flex:0 0 auto; border-radius:2px; box-shadow:0 0 0 1px var(--line); display:inline-block; }
.model-row-name{ flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
@@ -351,6 +360,46 @@ body{ background:var(--bg); color:var(--txt); font-size:13px; overflow:hidden; f
.model-act-del:hover{ color:var(--danger-text); }
.model-act svg{ width:14px; height:14px; pointer-events:none; }
.set-empty{ font-size:12px; color:var(--muted); padding:6px 0; }
/* 聚合渠道分组:与独立模型同款行样式,仅多一个折叠箭头,且紧贴下方独立模型 */
.model-group{ display:flex; flex-direction:column; gap:4px; }
.model-mixin-caret{ display:flex; align-items:center; justify-content:center; flex:0 0 auto; height:28px; color:var(--muted); cursor:pointer; }
/* 渠道组是中文标签而非代码标识符,用界面 sans 字体,避免落到等宽字体的中文回退 */
.model-row--mixin .model-row-name{ font-family:var(--font-sans); }
.model-mixin-caret svg{ width:13px; height:13px; pointer-events:none; }
.model-mixin-body{ display:flex; flex-direction:column; gap:3px; margin:2px 0 2px 14px; padding-left:10px; border-left:1px dashed var(--line-strong); }
.model-mixin-empty{ font-size:12px; color:var(--muted); padding:4px 2px; }
.model-member{ display:flex; align-items:center; gap:8px; padding:5px 8px; border-radius:4px; font-size:12px; color:var(--txt-2); background:var(--field-bg); border:1px solid var(--line); }
.model-member:hover{ background:var(--line-soft); }
.model-member.dragging{ opacity:.78; box-shadow:0 3px 12px rgba(0,0,0,.12); }
.model-member-drag{
width:16px; height:20px; flex:0 0 auto; border:none; background:transparent; color:var(--muted);
border-radius:4px; display:flex; align-items:center; justify-content:center; padding:0;
cursor:grab; touch-action:none; user-select:none;
}
.model-member-drag:active{ cursor:grabbing; }
.grip-dot{
width:2px; height:2px; border-radius:50%; background:currentColor;
box-shadow:0 -4px 0 currentColor, 0 4px 0 currentColor, 5px -4px 0 currentColor, 5px 0 0 currentColor, 5px 4px 0 currentColor;
transform:translateX(-2.5px); opacity:.68;
}
.model-member:hover .model-member-drag{ color:var(--txt-2); background:var(--hover-dim); }
.mixin-dragging{ cursor:grabbing; }
.model-member-name{ flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.model-member .model-act{ width:22px; height:22px; opacity:.6; }
.model-member:hover .model-act{ opacity:1; }
.model-act-addmix:hover{ color:var(--accent); }
/* 已在渠道组:➕ 仍是「加入」按钮,但给个淡淡的 accent 提示,点击只弹「已在渠道组中」 */
.model-act-addmix.is-in{ color:var(--accent); opacity:.5; }
.model-act-addmix.is-in:hover{ opacity:.8; }
/* 轻提示 toast(屏幕顶部)*/
.ga-toast{
position:fixed; left:50%; top:72px; transform:translateX(-50%) translateY(-8px);
background:var(--card); color:var(--txt); border:1px solid var(--line-strong);
border-radius:6px; padding:10px 18px; font-size:13px; font-family:var(--font-sans);
box-shadow:0 8px 28px rgba(0,0,0,.24); opacity:0; pointer-events:none;
transition:opacity .18s, transform .18s; z-index:9999;
}
.ga-toast.show{ opacity:1; transform:translateX(-50%) translateY(0); }
.set-btn{
width:100%; display:flex; align-items:center; gap:8px;
border:1px solid var(--line); background:var(--card); color:var(--txt-2);
@@ -359,9 +408,12 @@ body{ background:var(--bg); color:var(--txt); font-size:13px; overflow:hidden; f
}
.set-btn svg{ width:15px; height:15px; flex:0 0 auto; color:var(--accent); }
.set-btn:hover{ background:var(--line-soft); border-color:var(--line-strong); }
.set-btn.primary{ background:var(--accent); color:var(--on-accent); border-color:var(--accent); }
.set-btn.primary{ background:var(--confirm-primary-bg); color:var(--confirm-primary-fg); border-color:var(--confirm-primary-bg); }
.set-btn.primary:hover{ filter:brightness(1.05); }
.set-btn.danger{ background:var(--danger-bg); color:var(--danger-text); border-color:var(--danger-ring); }
.set-btn.danger:hover{ background:var(--danger-bg); border-color:var(--danger-text); }
.set-btn.ghost{ background:transparent; }
.set-btn-follow{ margin-top:8px; }
.model-form-body{ min-height:0; }
.model-form{ padding:18px; display:flex; flex-direction:column; gap:12px; }
.model-form label{ display:flex; flex-direction:column; gap:5px; font-size:12px; font-weight:500; color:var(--txt-2); }
@@ -571,7 +623,13 @@ body{ background:var(--bg); color:var(--txt); font-size:13px; overflow:hidden; f
display:flex; align-items:center; justify-content:center; cursor:pointer;
}
.model-guide-copy:hover{ color:var(--accent); border-color:var(--accent); }
.model-guide-copy svg{ width:14px; height:14px; display:block; }
.model-guide-tip{ margin:8px 0 0; font-size:11px; color:var(--muted); line-height:1.35; }
.confirm-card{ width:360px; }
.confirm-body{ min-height:0; padding:18px; }
.confirm-message{ white-space:pre-wrap; color:var(--txt-2); font-size:13px; line-height:1.55; }
.confirm-actions{ display:flex; gap:10px; margin-top:18px; }
.confirm-actions .set-btn{ flex:1; justify-content:center; }
/* ============ 中间主区 ============ */
.main{
@@ -1109,12 +1167,20 @@ span.flatpickr-weekday{ color:var(--muted) !important; font-size:11px; }
.tok-table td:nth-child(n+2):nth-child(-n+5){ width:80px; }
.tok-table th:nth-child(6),
.tok-table td:nth-child(6){ width:85px; }
/* Conductor tab 只有两行汇总: 用自动列宽铺满,数字列均匀分布,不让首列吃掉所有空间 */
.tok-table--conductor{ table-layout:auto; }
.tok-table--conductor th:first-child, .tok-table--conductor td:first-child{ overflow:visible; white-space:nowrap; }
.tok-table--conductor th:nth-child(n+2):nth-child(-n+5),
.tok-table--conductor td:nth-child(n+2):nth-child(-n+5),
.tok-table--conductor th:nth-child(6),
.tok-table--conductor td:nth-child(6){ width:auto; }
.tok-row-session{ cursor:pointer; }
.tok-row-session:hover td{ background:var(--hover-dim); }
.tok-row-session td:first-child::before{ content:'▶'; margin-right:6px; font-size:10px; color:var(--muted2); transition:transform .15s; display:inline-block; }
.tok-row-session.open td:first-child::before{ transform:rotate(90deg); }
.tok-row-conductor{ cursor:default; }
.tok-row-conductor td:first-child::before{ content:''; margin:0; }
.tok-cond-ico{ width:1em; height:1em; vertical-align:-0.15em; margin-right:5px; }
.tok-row-conductor:hover td{ background:var(--hover-dim); }
.tok-detail td{ padding:5px 12px 5px 32px; font-size:11px; color:var(--muted); border-bottom:1px solid var(--line-soft); background:var(--field-bg); }
.tok-detail td:not(:first-child){ text-align:right; }
@@ -1660,7 +1726,17 @@ html[data-appearance="dark"] .plan-item--warn .plan-item-text{ color:#f0a020; }
font-variant-numeric:var(--font-num-variant);
}
.ga-menu-item:hover{ background:var(--hover-2); }
.ga-menu-item.active{ color:var(--accent); }
.ga-menu-item.active{ color:var(--txt-strong); } /* 选中项用强调色而非发灰的 --accent,避免变淡 */
/* 浅色模式:模型下拉里选中项仅靠文字色区分太弱——加背景 + 加粗,一眼可辨 */
html[data-appearance="light"] .ga-menu-item.active{
background:color-mix(in srgb, var(--accent) 16%, var(--panel));
font-weight:700;
}
/* 深色模式:同样处理 */
html[data-appearance="dark"] .ga-menu-item.active{
background:color-mix(in srgb, var(--accent) 22%, var(--panel));
font-weight:700;
}
.composer-menu .ga-menu-item{
display:flex; align-items:center; gap:8px;
}
+255 -22
View File
@@ -25,6 +25,9 @@ HTTP API:
GET /services/panel
GET /services/mykey
POST /services/mykey body: {"content":"..."}
POST /services/stop-extras stop conductor + scheduler (127.0.0.1 only)
POST /services/start-extras start conductor + scheduler (127.0.0.1 only)
POST /services/bridge/exit stop managed services, then exit bridge (127.0.0.1 only)
WS API (state sync):
GET /ws -> on connect sends services.snapshot; service.changed on updates
@@ -33,8 +36,8 @@ WS API (state sync):
"""
from __future__ import annotations
import asyncio, contextlib, importlib, json, os, re, subprocess, sys
from collections import deque
import asyncio, atexit, contextlib, importlib, json, os, re, subprocess, sys
from collections import Counter, deque
import threading, time, traceback, uuid
from dataclasses import dataclass, field
from pathlib import Path
@@ -268,14 +271,27 @@ class AgentManager:
apikey = str(data.get("apikey") or "").strip() or str((existing or {}).get("apikey") or "").strip()
if require_key and not apikey:
raise ValueError("apikey is required")
cfg: Dict[str, Any] = {"apikey": apikey, "apibase": apibase, "model": model}
if name := str(data.get("name") or "").strip():
cfg["name"] = name
# 从 existing 起步:保留表单未覆盖的高级字段(proxy / temperature / api_mode /
# reasoning_effort / fake_cc_system_prompt / thinking_type …),避免 GUI 编辑时丢失
cfg: Dict[str, Any] = dict(existing or {})
cfg.update({"apikey": apikey, "apibase": apibase, "model": model})
if "name" in data:
name = str(data.get("name") or "").strip()
if name:
cfg["name"] = name
else:
cfg.pop("name", None)
for k in ("max_retries", "connect_timeout", "read_timeout"):
if data.get(k) is not None and str(data.get(k)).strip() != "":
cfg[k] = int(data[k])
elif existing and k in existing:
cfg[k] = existing[k]
# 流式开关:默认 True 不写(保持 mykey 干净),仅显式非流式才落 'stream': False
if "stream" in data:
s = data["stream"]
stream = s if isinstance(s, bool) else str(s).strip().lower() not in ("false", "0", "no", "off")
if stream:
cfg.pop("stream", None)
else:
cfg["stream"] = False
return cfg
def _save_mykey_text(self, text: str) -> list:
@@ -319,7 +335,9 @@ class AgentManager:
def get_model_profile(self, profile_id: int) -> dict:
var, cfg = self._profile_at(profile_id)
ks = ("model", "apibase", "apikey", "name", "max_retries", "connect_timeout", "read_timeout")
return {"id": profile_id, "varName": var, **{k: cfg.get(k, d) for k, d in zip(ks, ("", "", "", "", 5, 15, 300))}}
out = {"id": profile_id, "varName": var, **{k: cfg.get(k, d) for k, d in zip(ks, ("", "", "", "", 5, 15, 300))}}
out["stream"] = cfg.get("stream", True)
return out
def update_model_profile(self, profile_id: int, data: dict) -> dict:
var, existing = self._profile_at(profile_id)
@@ -330,8 +348,17 @@ class AgentManager:
def delete_model_profile(self, profile_id: int) -> dict:
if len(self._profile_keys()) <= 1:
raise ValueError("cannot delete the last profile")
var, _ = self._profile_at(profile_id)
profiles = self._save_mykey_text(self._patch_var_block(self._mykey_file().read_text(encoding="utf-8"), var).rstrip() + "\n")
var, cfg = self._profile_at(profile_id)
text = self._patch_var_block(self._mykey_file().read_text(encoding="utf-8"), var).rstrip() + "\n"
# 顺手把它从聚合渠道里摘掉,避免 llm_nos 残留指向已删除的模型(会让 Mixin 构建失败)
name = str(cfg.get("name") or cfg.get("model") or "").strip()
keys, mk = self._mykey_vars()
mvar, mcfg = self._mixin_entry(keys, mk)
if mcfg and mvar is not None and name in [str(m) for m in (mcfg.get("llm_nos") or [])]:
mcfg = {**mcfg, "llm_nos": [str(m) for m in (mcfg.get("llm_nos") or []) if str(m) != name]}
if self._find_var_block_span(text, mvar):
text = self._patch_var_block(text, mvar, mcfg)
profiles = self._save_mykey_text(text)
return {"profileId": profile_id, "profiles": profiles}
def ensure_ga_import_path(self) -> Path:
@@ -358,16 +385,131 @@ class AgentManager:
with contextlib.suppress(Exception):
os.chdir(old_cwd)
def list_model_profiles(self):
@staticmethod
def _base_display_name(var: str, cfg: Optional[dict]) -> str:
c = cfg or {}
return str(c.get("name") or c.get("model") or var)
def _mykey_vars(self):
"""(keys, mk):mykey 里的模型变量名(按定义顺序,与 agentmain.llmclients 索引
一一对齐)和原始 dict。过滤规则与 _profile_keys / load_llm_sessions 完全一致,
因此 id == llmclients 下标,前端选中 llmNo 能正确激活对应 client。"""
self._mykey_file() # 确保 mykey.py 存在(首次从模板生成空配置),否则全新安装时
# reload_mykeys 找不到 mykey 会返回空,空聚合渠道就不显示了
self.ensure_ga_import_path()
from llmcore import reload_mykeys
mk = reload_mykeys()[0]
keys = [k for k in mk if any(x in k for x in ("api", "config", "cookie"))]
return keys, mk
def _mixin_entry(self, keys, mk):
"""返回 (mixin_var, mixin_cfg_dict) 或 (None, None)。单一主聚合渠道,只取第一个。"""
for k in keys:
if "mixin" in k and isinstance(mk.get(k), dict):
return k, dict(mk[k])
return None, None
def list_model_profiles(self):
"""直接读 mykey.py 结构(不依赖能否成功构建出 client),这样空聚合渠道、
未填 key 的模型也能如实展示。聚合渠道(kind=mixin)带 members;基本模型
(kind=native)带 inMixin/group。"""
try:
agentmain = importlib.import_module("agentmain")
agent = agentmain.GenericAgent()
if hasattr(agent, "list_llms"):
return [{"id": i, "name": name, "active": active} for i, name, active in agent.list_llms()]
keys, mk = self._mykey_vars()
except Exception as e:
print(f"get model profiles failed: {e}", file=sys.stderr)
return []
return []
_, mcfg = self._mixin_entry(keys, mk)
members = [str(m) for m in (mcfg.get("llm_nos") or [])] if mcfg else []
active = self.config.get("llmNo", 0)
out = []
for i, k in enumerate(keys):
cfg = mk.get(k) if isinstance(mk.get(k), dict) else {}
if "mixin" in k:
out.append({"id": i, "varName": k, "kind": "mixin", "name": "",
"members": members, "active": i == active})
else:
name = self._base_display_name(k, cfg)
out.append({"id": i, "varName": k, "kind": "native", "name": name,
"model": cfg.get("model", ""),
"group": "native" if "native" in k else "std",
"inMixin": name in members, "active": i == active})
return out
def add_to_mixin(self, profile_id: int) -> dict:
"""把一个基本模型加入主聚合渠道:把它的 name 追加进 mixin_config['llm_nos']。
坑1:校验 Native 一致性(聚合内必须全 Native 或全非 Native)。
坑2:加入前若该模型没有显式 name,先把 name 写进它的配置块(保证引用稳定)。"""
var, cfg = self._profile_at(profile_id) # 对 mixin 会抛错(只接受 native)
name = str(cfg.get("name") or cfg.get("model") or "").strip()
if not name:
raise ValueError("this model needs a name or model before joining the channel")
keys, mk = self._mykey_vars()
mvar, mcfg = self._mixin_entry(keys, mk)
new_is_native = "native" in var
name2var = {self._base_display_name(k, mk.get(k) if isinstance(mk.get(k), dict) else {}): k
for k in keys if "mixin" not in k}
existing = [str(m) for m in (mcfg.get("llm_nos") or [])] if mcfg else []
for m in existing:
mv = name2var.get(m)
if mv is not None and ("native" in mv) != new_is_native:
raise ValueError("aggregation channel requires all-Native or all-non-Native models")
text = self._mykey_file().read_text(encoding="utf-8")
if not cfg.get("name"):
text = self._patch_var_block(text, var, {**cfg, "name": name})
if mcfg is None:
mcfg, mvar, existing = {"llm_nos": [], "max_retries": 10, "base_delay": 0.5}, "mixin_config", []
if name not in existing:
existing.append(name)
mcfg = {**mcfg, "llm_nos": existing}
if self._find_var_block_span(text, mvar):
text = self._patch_var_block(text, mvar, mcfg)
else:
text = text.rstrip() + f"\n{mvar} = {self._format_py_dict(mcfg)}\n"
return {"profiles": self._save_mykey_text(text)}
def remove_from_mixin(self, profile_id: int) -> dict:
"""把一个基本模型移出主聚合渠道。"""
var, cfg = self._profile_at(profile_id)
name = str(cfg.get("name") or cfg.get("model") or "").strip()
keys, mk = self._mykey_vars()
mvar, mcfg = self._mixin_entry(keys, mk)
if not mcfg or mvar is None:
return {"profiles": self.list_model_profiles()}
members = [str(m) for m in (mcfg.get("llm_nos") or []) if str(m) != name]
mcfg = {**mcfg, "llm_nos": members}
text = self._patch_var_block(self._mykey_file().read_text(encoding="utf-8"), mvar, mcfg)
return {"profiles": self._save_mykey_text(text)}
def reorder_mixin(self, members: list) -> dict:
"""按前端拖拽后的顺序重写主渠道组 llm_nos。只接受当前成员的重排,不增删。"""
keys, mk = self._mykey_vars()
mvar, mcfg = self._mixin_entry(keys, mk)
if not mcfg or mvar is None:
raise ValueError("mixin channel not found")
old = [str(m) for m in (mcfg.get("llm_nos") or [])]
new = [str(m) for m in (members or [])]
if len(new) != len(old) or Counter(new) != Counter(old):
raise ValueError("reorder must contain the same channel members")
if new == old:
return {"profiles": self.list_model_profiles()}
mcfg = {**mcfg, "llm_nos": new}
text = self._patch_var_block(self._mykey_file().read_text(encoding="utf-8"), mvar, mcfg)
return {"profiles": self._save_mykey_text(text)}
@staticmethod
def _live_model(sess: Session) -> Optional[dict]:
"""该会话 agent 当前真正在用的模型(渠道组会随故障转移变化)。
agent 还没建(没跑过 turn)时返回 None,前端回退到静态显示。"""
ag = getattr(sess, "agent", None)
if ag is None:
return None
try:
back = ag.llmclient.backend
if "Mixin" in type(back).__name__:
return {"current": back.current_name, "isMixin": True}
return {"current": back.name, "isMixin": False}
except Exception:
return None
def snapshot(self, sess: Session, include_messages: bool = True) -> dict:
out = {
@@ -382,6 +524,7 @@ class AgentManager:
"msgSeq": sess.msg_seq,
"pinned": sess.pinned,
"untitled": sess.untitled,
"model": self._live_model(sess),
}
if include_messages:
out["messages"] = list(sess.messages)
@@ -587,6 +730,7 @@ class AgentManager:
"msgSeq": sess.msg_seq,
"updatedAt": sess.updated_at,
"lastError": sess.last_error,
"model": self._live_model(sess),
}
def plan_snapshot(self, sid: str) -> dict:
@@ -917,10 +1061,10 @@ class ServiceManager:
self._notify(sid, err=err)
return {"ok": False, "error": "not_configured", "service": self._state(sid, err=err)}
self.buffers[sid] = deque(maxlen=500)
env = {**os.environ, "PYTHONUNBUFFERED": "1"}
env = {**os.environ, "PYTHONUNBUFFERED": "1", "PYTHONIOENCODING": "utf-8"}
kw: Dict[str, Any] = dict(
cwd=str(self.ga_root), stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1, env=env,
text=True, encoding="utf-8", errors="replace", bufsize=1, env=env,
)
if sys.platform == "win32":
kw["creationflags"] = subprocess.CREATE_NO_WINDOW
@@ -946,6 +1090,11 @@ class ServiceManager:
tag = f"exception {type(e).__name__}: {e}"
print(f"[autostart] {sid}: {tag}", file=sys.stderr)
def stop_all_extras(self) -> None:
for sid in sorted(set(self._catalog) - set(self._im_catalog)):
with contextlib.suppress(Exception):
self.stop_service(sid)
def stop_service(self, sid: str) -> dict:
if sid not in self._catalog:
raise KeyError(sid)
@@ -974,6 +1123,14 @@ class ServiceManager:
services = ServiceManager(str(DEFAULT_GA_ROOT), hub.emit)
def _bridge_shutdown_services() -> None:
with contextlib.suppress(Exception):
services.stop_all_extras()
atexit.register(_bridge_shutdown_services)
def emit_session_state(sess: Session, state_name: str):
hub.emit({
"type": "session-state",
@@ -1019,7 +1176,7 @@ async def ws_handler(request):
def cors_headers():
return {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,POST,DELETE,OPTIONS",
"Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
}
@@ -1125,6 +1282,32 @@ async def model_profiles_handler(request):
return json_ok({"ok": False, "error": str(e)}, status=500)
async def mixin_handler(request):
"""聚合渠道成员管理:POST 加入 / DELETE 移出 主聚合渠道。"""
try:
profile_id = int(request.match_info.get("id"))
if request.method == "POST":
return json_ok({"ok": True, **manager.add_to_mixin(profile_id)})
if request.method == "DELETE":
return json_ok({"ok": True, **manager.remove_from_mixin(profile_id)})
return json_ok({"ok": False, "error": "method not allowed"}, status=405)
except ValueError as e:
return json_ok({"ok": False, "error": str(e)}, status=400)
except Exception as e:
return json_ok({"ok": False, "error": str(e)}, status=500)
async def mixin_order_handler(request):
"""渠道组成员拖拽排序:PUT {members:[name,...]}。"""
try:
data = await read_json(request)
return json_ok({"ok": True, **manager.reorder_mixin(data.get("members") or [])})
except ValueError as e:
return json_ok({"ok": False, "error": str(e)}, status=400)
except Exception as e:
return json_ok({"ok": False, "error": str(e)}, status=500)
async def list_sessions_handler(request):
with manager.lock:
sessions = [manager.snapshot(s, include_messages=False) for s in manager.sessions.values()]
@@ -1447,9 +1630,11 @@ async def mykey_save_handler(request):
content = data.get("content")
if content is None:
return json_ok({"ok": False, "error": "missing_content"}, status=400)
target = _mykey_file()
target.write_text(str(content), encoding="utf-8")
return json_ok({"ok": True, "path": str(target)})
try:
profiles = manager._save_mykey_text(str(content))
except Exception as e:
return json_ok({"ok": False, "error": str(e)}, status=400)
return json_ok({"ok": True, "path": str(manager._mykey_file()), "profiles": profiles})
async def service_start_handler(request):
@@ -1483,6 +1668,43 @@ async def service_panel_handler(request):
return json_ok({"services": services.list_panel_state()})
def _is_local_peer(peer: str) -> bool:
p = (peer or "").strip()
return p in ("127.0.0.1", "::1") or p.startswith("::ffff:127.0.0.1")
async def stop_extras_handler(request):
if not _is_local_peer(request.remote or ""):
return json_ok({"ok": False, "error": "forbidden"}, status=403)
services.stop_all_extras()
return json_ok({"ok": True})
async def start_extras_handler(request):
if not _is_local_peer(request.remote or ""):
return json_ok({"ok": False, "error": "forbidden"}, status=403)
services.autostart_extras()
return json_ok({"ok": True})
async def identity_handler(request):
return json_ok({"ga_root": str(DEFAULT_GA_ROOT), "app_dir": str(APP_DIR), "pid": os.getpid(),
"build_id": os.environ.get("GA_BUILD_ID", "")})
def _exit_bridge() -> None:
with contextlib.suppress(Exception):
services.stop_all_extras()
threading.Timer(0.4, lambda: os._exit(0)).start()
async def bridge_exit_handler(request):
if not _is_local_peer(request.remote or ""):
return json_ok({"ok": False, "error": "forbidden"}, status=403)
_exit_bridge()
return json_ok({"ok": True})
async def token_stats_handler(request):
try:
sys.path.insert(0, str(APP_DIR)) if str(APP_DIR) not in sys.path else None
@@ -1538,6 +1760,9 @@ def create_app():
app.router.add_post("/config", save_config_handler)
app.router.add_get("/model-profiles", model_profiles_handler)
app.router.add_post("/model-profiles", model_profiles_handler)
app.router.add_put("/model-profiles/mixin/order", mixin_order_handler)
app.router.add_post("/model-profiles/{id}/mixin", mixin_handler)
app.router.add_delete("/model-profiles/{id}/mixin", mixin_handler)
app.router.add_get("/model-profiles/{id}", model_profiles_handler)
app.router.add_put("/model-profiles/{id}", model_profiles_handler)
app.router.add_delete("/model-profiles/{id}", model_profiles_handler)
@@ -1564,6 +1789,10 @@ def create_app():
app.router.add_get("/services/panel", service_panel_handler)
app.router.add_get("/services/mykey", mykey_get_handler)
app.router.add_post("/services/mykey", mykey_save_handler)
app.router.add_post("/services/stop-extras", stop_extras_handler)
app.router.add_post("/services/start-extras", start_extras_handler)
app.router.add_get("/services/identity", identity_handler)
app.router.add_post("/services/bridge/exit", bridge_exit_handler)
# Serve static frontend (desktop/static/)
static_dir = APP_DIR / "desktop" / "static"
@@ -1581,7 +1810,11 @@ def create_app():
hub.loop = asyncio.get_running_loop()
services.autostart_extras()
async def on_shutdown(app):
services.stop_all_extras()
app.on_startup.append(on_startup)
app.on_shutdown.append(on_shutdown)
return app
+2
View File
@@ -953,6 +953,8 @@ class MixinSession:
def primary(self): return self._sessions[0]
@property
def model(self): return getattr(self._sessions[self._cur_idx], 'model', None)
@property
def current_name(self): return getattr(self._sessions[self._cur_idx], 'name', None)
def _pick(self):
if self._cur_idx and time.time() - self._switched_at > self._spring_sec: self._cur_idx = 0
return self._cur_idx
+21 -18
View File
@@ -128,7 +128,8 @@
# NativeOAISession 可以混用)或者全不是 Native,不能 Native 与非 Native 混。
# 请你按需
mixin_config = {
'llm_nos': ['gpt-native'], # 按优先级排列;Claude 与 GPT 混用
'llm_nos': [], # 默认空:桌面端会显示一个空的「渠道组(自动故障转移)」,添加模型后
# 在设置里用 ➕ 把基本模型加进来即可(也可在此手填名字)
# 'llm_nos': ['cc-relay-1', 'cc-relay-2', 'gpt-native'], # 按优先级排列;Claude 与 GPT 混用,注意: 启用时需要启用'cc-relay-1', 'cc-relay-2'配置!
'max_retries': 10, # int;整个 rotation 的总重试次数上限
'base_delay': 0.5, # float 秒;指数退避起始延迟(retry n 时延迟≈base_delay * 2^n)
@@ -285,23 +286,25 @@ mixin_config = {
# 适合 GPT/o 系列、Gemini 或任何 OAI 兼容且支持原生 tool 字段的模型。
# 和 NativeClaudeSession 共用大部分逻辑(继承关系),只是请求走 OAI 协议。
native_oai_config = {
'name': 'gpt-native', # /llms 显示名 & mixin 引用名
'apikey': 'sk-<your-openai-key>', # Bearer 鉴权
'apibase': 'https://api.openai.com/v1', # 补齐到 /v1/chat/completions
'model': 'gpt-5.4', # gpt-5/o 系列
'api_mode': 'chat_completions', # 'chat_completions'(默认)|'responses'
# 'reasoning_effort': 'high', # none|minimal|low|medium|high|xhigh
# chat_completions → payload.reasoning_effort
# responses → payload.reasoning.effort
'max_retries': 3, # int 默认 1
'connect_timeout': 10, # int 秒 默认 5(最小 1)
'read_timeout': 120, # int 秒 默认 30(最小 5)
# 'temperature': 1.0, # float 默认 1.0
# 'max_tokens': 8192, # int 默认 8192
# 'proxy': 'http://127.0.0.1:2082', # 可选单 session HTTP 代理
# 'context_win': 16000, # int 默认 24000;历史裁剪阈值
}
# 默认整段注释:新装时「独立模型」列表为空。在桌面端「添加模型」填好 apikey 后会
# 自动生成同类 native_oai_config 变量;或取消下面注释手动填。
# native_oai_config = {
# 'name': 'gpt-native', # /llms 显示名 & mixin 引用名
# 'apikey': 'sk-<your-openai-key>', # Bearer 鉴权
# 'apibase': 'https://api.openai.com/v1', # 补齐到 /v1/chat/completions
# 'model': 'gpt-5.4', # gpt-5/o 系列
# 'api_mode': 'chat_completions', # 'chat_completions'(默认)|'responses'
# # 'reasoning_effort': 'high', # none|minimal|low|medium|high|xhigh
# # chat_completions → payload.reasoning_effort
# # responses → payload.reasoning.effort
# 'max_retries': 3, # int 默认 1
# 'connect_timeout': 10, # int 秒 默认 5(最小 1)
# 'read_timeout': 120, # int 秒 默认 30(最小 5)
# # 'temperature': 1.0, # float 默认 1.0
# # 'max_tokens': 8192, # int 默认 8192
# # 'proxy': 'http://127.0.0.1:2082', # 可选单 session HTTP 代理
# # 'context_win': 16000, # int 默认 24000;历史裁剪阈值
# }
# ── 也可以走 Responses API ──────────────────────────────────────────────────
# 对接 OpenAI /v1/responses 端点。reasoning_effort 会以 reasoning.effort