feat(desktop): merge tested desktop updates without release packaging
No files matched your search
@@ -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
|
||||
@@ -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': '选择端点',
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 19 KiB |
|
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"]
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
})();
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||