diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..b3ebfcb8 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore index f1cfcd11..21f7494d 100644 --- a/.gitignore +++ b/.gitignore @@ -143,6 +143,9 @@ frontends/conductor_im_plugins/config.local.json **/__pycache__/ .claude/ + # Project Mode SOP !memory/project_mode_sop.md +# 本地 bug / 局限报告草稿(交给维护者参考,不进仓库) +/BUG_*.md diff --git a/assets/configure_mykey.py b/assets/configure_mykey.py index 058b9fb9..b9ed19d7 100644 --- a/assets/configure_mykey.py +++ b/assets/configure_mykey.py @@ -88,7 +88,7 @@ LLM_PROVIDERS = [ 'type': 'native_oai', 'template': { 'name': 'deepseek', 'apikey': 'sk-', - '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 获取', @@ -126,11 +126,11 @@ LLM_PROVIDERS = [ 'template': { 'name': 'qwen', 'apikey': 'sk-', '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': '选择端点', diff --git a/assets/tools_schema.json b/assets/tools_schema.json index 7a8b604c..01078937 100644 --- a/assets/tools_schema.json +++ b/assets/tools_schema.json @@ -60,10 +60,10 @@ }}, {"type": "function", "function": { "name": "ask_user", - "description": "Interrupt task to ask user when needing decisions, extra info, or facing unresolvable blockers", + "description": "Interrupt task to ask user when needing decisions, extra info, or facing unresolvable blockers. For multiple questions, use one call and put all in question—do not call repeatedly.", "parameters": {"type": "object", "properties": { - "question": {"type": "string", "description": "Question for the user"}, - "candidates": {"type": "array", "items": {"type": "string"}, "description": "Optional quick-select choices for the user"}}} + "question": {"type": "string", "description": "Question for the user. For multiple questions, one per line (e.g. \"1. …\" / \"2. …\" or \"Q1: …\"); put A/B/C options under each question in this field. Do not cram multiple questions on one line. candidates is for single-question quick picks only."}, + "candidates": {"type": "array", "items": {"type": "string"}, "description": "Optional quick-select choices for a single question only. Leave empty for multi-question prompts; put per-question options in question instead."}}} }}, {"type": "function", "function": { "name": "start_long_term_update", diff --git a/assets/tools_schema_cn.json b/assets/tools_schema_cn.json index a34c19d7..0b70328c 100644 --- a/assets/tools_schema_cn.json +++ b/assets/tools_schema_cn.json @@ -60,10 +60,10 @@ }}, {"type": "function", "function": { "name": "ask_user", - "description": "当需要用户决策、提供额外信息或遇到无法自动解决的阻碍时,调用此工具中断任务并提问", + "description": "当需要用户决策、提供额外信息或遇到无法自动解决的阻碍时,调用此工具中断任务并提问。多题应一次调用、全部写在 question 里,不要多次调用。", "parameters": {"type": "object", "properties": { - "question": {"type": "string", "description": "向用户提出的明确问题"}, - "candidates": {"type": "array", "items": {"type": "string"}, "description": "提供给用户的可选快捷选项列表"}}} + "question": {"type": "string", "description": "向用户提出的明确问题。多题时每题独占一行(如「1. …」「2. …」或「问题1: …」),每题下的选项用 A. B. C. 写在 question 内;不要用一行挤多题。candidates 仅用于单题快捷选项。"}, + "candidates": {"type": "array", "items": {"type": "string"}, "description": "单题时的可选快捷选项(纯文字)。多题时留空,各题选项写在 question 里,勿把多题选项平铺到此数组。"}}} }}, {"type": "function", "function": { "name": "start_long_term_update", diff --git a/frontends/conductor.html b/frontends/conductor.html index 287c69a8..5380d5c8 100644 --- a/frontends/conductor.html +++ b/frontends/conductor.html @@ -467,6 +467,45 @@ sendBtn.addEventListener('click', send); inputEl.addEventListener('keydown', e=>{ if(e.key==='Enter' && !e.shiftKey && !e.isComposing && e.keyCode!==229){ e.preventDefault(); send(); } }); + // Input length limit (10000 chars) + const MAX_LEN = 10000; + let limitToast = null; + + function showLimitToast(msg) { + if (limitToast) return; // already showing, don't spam + limitToast = document.createElement('div'); + limitToast.textContent = msg; + limitToast.style.cssText = 'position:fixed;top:24px;left:50%;transform:translateX(-50%);background:#dc2626;color:#fff;padding:10px 24px;border-radius:8px;font-size:14px;z-index:9999;box-shadow:0 4px 12px rgba(0,0,0,.15);transition:opacity .3s'; + document.body.appendChild(limitToast); + setTimeout(() => { + if (limitToast) { limitToast.style.opacity = '0'; } + setTimeout(() => { if (limitToast) { limitToast.remove(); limitToast = null; } }, 300); + }, 3000); + } + + function checkLimit() { + if (inputEl.value.length > MAX_LEN) { + inputEl.value = inputEl.value.slice(0, MAX_LEN); + showLimitToast('已达字数上限(' + MAX_LEN + ')'); + } + } + + inputEl.addEventListener('paste', e => { + e.preventDefault(); + const text = (e.clipboardData || window.clipboardData).getData('text') || ''; + const remaining = MAX_LEN - inputEl.value.length + (inputEl.selectionEnd - inputEl.selectionStart); + const insert = text.slice(0, Math.max(0, remaining)); + const start = inputEl.selectionStart; + const end = inputEl.selectionEnd; + inputEl.value = inputEl.value.slice(0, start) + insert + inputEl.value.slice(end); + inputEl.setSelectionRange(start + insert.length, start + insert.length); + if (text.length > remaining) { + showLimitToast('已达字数上限(' + MAX_LEN + ')'); + } + }); + + inputEl.addEventListener('input', checkLimit); + /* 接通后端: hello 推送 cards/chat/log 快照, approval 由 /approval 推送 */ connect(); diff --git a/frontends/conductor.py b/frontends/conductor.py index ed9ad580..092744e5 100644 --- a/frontends/conductor.py +++ b/frontends/conductor.py @@ -5,6 +5,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi.responses import FileResponse, PlainTextResponse, JSONResponse +from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -17,17 +18,43 @@ 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 global main_loop main_loop = asyncio.get_running_loop() + import cost_tracker; cost_tracker.install() conductor.start() threading.Thread(target=im_poll_loop, name="im-poller", daemon=True).start() yield app = FastAPI(title="Conductor", lifespan=lifespan) +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) class ChatIn(BaseModel): msg: str @@ -114,8 +141,8 @@ async def broadcast(payload: dict): def push_cards(): schedule_broadcast({"type": "subagents", "items": pool.snapshot()}) -def add_chat(msg: str, role: str = "conductor"): - item = {"id": short_id(), "role": role, "msg": msg, "ts": now_ms(), "read": role != "user"} +def add_chat(msg: str, role: str = "conductor", files: list = None, images: list = None): + item = {"id": short_id(), "role": role, "msg": msg, "ts": now_ms(), "read": role != "user", "files": files or [], "images": images or []} chat_messages.append(item) if len(chat_messages) > 200: del chat_messages[:-200] schedule_broadcast({"type": "chat", "item": item}) @@ -194,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 +374,9 @@ API: {base};requests,GET /readme查用法,GET /chat读未读对话,GET / 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}") @@ -381,6 +412,11 @@ def im_poll_loop(): last_fire[name] = now conductor.notify({"type": "im_signal", "source": name}) +@app.get("/token-stats") +def conductor_token_stats(): + import cost_tracker + return {"records": [{"thread": k, "input": v.input, "output": v.output, "cacheCreate": v.cache_create, "cacheRead": v.cache_read} for k, v in cost_tracker.all_trackers().items()]} + @app.get("/") def index(): return FileResponse(HTML_PATH) @@ -462,17 +498,22 @@ async def websocket(ws: WebSocket): await ws.accept() ws_clients.add(ws) try: - await ws.send_json({"type": "hello", "subagents": pool.snapshot(), "chat": chat_messages, "log": conductor.log}) + running = any(s.status == "running" for s in pool.subagents.values()) + await ws.send_json({"type": "hello", "subagents": pool.snapshot(), "chat": chat_messages, "log": conductor.log, "running": running}) while True: data = await ws.receive_json() msg = (data.get("msg") or "").strip() if not msg: continue - add_chat(msg, role="user") + add_chat(msg, role="user", files=data.get("files") or [], images=data.get("images") or []) conductor.notify({"type": "user_message", "msg": msg}) except WebSocketDisconnect: pass finally: ws_clients.discard(ws) if __name__ == "__main__": - import uvicorn, webbrowser, threading - threading.Timer(1.0, lambda: webbrowser.open(f"http://{HOST}:{PORT}")).start() + import uvicorn + # bridge 自启 conductor 时传 --no-browser:不在用户浏览器里弹一个独立 conductor UI, + # 用户从桌面版「指挥家」页直接连过来即可。手动跑 conductor.py(没带 flag)保持原行为。 + if "--no-browser" not in sys.argv: + import webbrowser, threading + threading.Timer(1.0, lambda: webbrowser.open(f"http://{HOST}:{PORT}")).start() uvicorn.run("conductor:app", host=HOST, port=PORT, reload=False) diff --git a/frontends/desktop/src-tauri/Cargo.lock b/frontends/desktop/src-tauri/Cargo.lock index 5108f2d3..72a180b5 100644 --- a/frontends/desktop/src-tauri/Cargo.lock +++ b/frontends/desktop/src-tauri/Cargo.lock @@ -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", diff --git a/frontends/desktop/src-tauri/Cargo.toml b/frontends/desktop/src-tauri/Cargo.toml index 297f8e1d..17e13de9 100644 --- a/frontends/desktop/src-tauri/Cargo.toml +++ b/frontends/desktop/src-tauri/Cargo.toml @@ -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" diff --git a/frontends/desktop/src-tauri/build.rs b/frontends/desktop/src-tauri/build.rs index d860e1e6..97ab5de7 100644 --- a/frontends/desktop/src-tauri/build.rs +++ b/frontends/desktop/src-tauri/build.rs @@ -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() } diff --git a/frontends/desktop/src-tauri/capabilities/default.json b/frontends/desktop/src-tauri/capabilities/default.json index 509107c5..cf96e341 100644 --- a/frontends/desktop/src-tauri/capabilities/default.json +++ b/frontends/desktop/src-tauri/capabilities/default.json @@ -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" ] } diff --git a/frontends/desktop/src-tauri/icons/128x128.png b/frontends/desktop/src-tauri/icons/128x128.png index 7182b4cb..cfb5e7f9 100644 Binary files a/frontends/desktop/src-tauri/icons/128x128.png and b/frontends/desktop/src-tauri/icons/128x128.png differ diff --git a/frontends/desktop/src-tauri/icons/128x128@2x.png b/frontends/desktop/src-tauri/icons/128x128@2x.png index 8b46700a..e9c94494 100644 Binary files a/frontends/desktop/src-tauri/icons/128x128@2x.png and b/frontends/desktop/src-tauri/icons/128x128@2x.png differ diff --git a/frontends/desktop/src-tauri/icons/32x32.png b/frontends/desktop/src-tauri/icons/32x32.png index ffd452b6..63a15c78 100644 Binary files a/frontends/desktop/src-tauri/icons/32x32.png and b/frontends/desktop/src-tauri/icons/32x32.png differ diff --git a/frontends/desktop/src-tauri/icons/icon.icns b/frontends/desktop/src-tauri/icons/icon.icns index 92218b9a..7fe7a9d3 100644 Binary files a/frontends/desktop/src-tauri/icons/icon.icns and b/frontends/desktop/src-tauri/icons/icon.icns differ diff --git a/frontends/desktop/src-tauri/icons/icon.ico b/frontends/desktop/src-tauri/icons/icon.ico index 671544e9..f52ee357 100644 Binary files a/frontends/desktop/src-tauri/icons/icon.ico and b/frontends/desktop/src-tauri/icons/icon.ico differ diff --git a/frontends/desktop/src-tauri/icons/icon.png b/frontends/desktop/src-tauri/icons/icon.png index e210843b..dad73e81 100644 Binary files a/frontends/desktop/src-tauri/icons/icon.png and b/frontends/desktop/src-tauri/icons/icon.png differ diff --git a/frontends/desktop/src-tauri/permissions/bridge-commands.toml b/frontends/desktop/src-tauri/permissions/bridge-commands.toml new file mode 100644 index 00000000..cf78df39 --- /dev/null +++ b/frontends/desktop/src-tauri/permissions/bridge-commands.toml @@ -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"] diff --git a/frontends/desktop/src-tauri/src/lib.rs b/frontends/desktop/src-tauri/src/lib.rs index 853c9805..69ab0e3e 100644 --- a/frontends/desktop/src-tauri/src/lib.rs +++ b/frontends/desktop/src-tauri/src/lib.rs @@ -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 { + #[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: + // /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: + // /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 { + 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: /runtime/app/agentmain.py. Dev layout: walk up from the exe. fn find_project_dir() -> Option { + // Bundle layout: source tucked under /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 { + 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 { + 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 = 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::(&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 /runtime/. Returns None when this is not a bundle (e.g. dev build). +fn bundle_root() -> Option { + 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 { + 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 { + 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, 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 = 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 diff --git a/frontends/desktop/src-tauri/tauri.conf.json b/frontends/desktop/src-tauri/tauri.conf.json index 1bb06aea..5681ffa1 100644 --- a/frontends/desktop/src-tauri/tauri.conf.json +++ b/frontends/desktop/src-tauri/tauri.conf.json @@ -17,6 +17,7 @@ "width": 1280, "height": 800, "resizable": true, + "maximized": true, "visible": false, "url": "loading.html" }, diff --git a/frontends/desktop/static/app.js b/frontends/desktop/static/app.js index 79b33dbb..b6ad62ba 100644 --- a/frontends/desktop/static/app.js +++ b/frontends/desktop/static/app.js @@ -1,2118 +1,6203 @@ -window.process = window.process || { platform: navigator.platform.toLowerCase().includes('mac') ? 'darwin' : 'win32' }; -// GenericAgent Desktop — Renderer Logic -// Handles UI state, sessions, streaming, slash commands. - +// GenericAgent 桌面版 —— bridge 适配 + 业务 UI(HTTP 命令 / WS 状态 / i18n)。 +// 文案全部走 i18n:静态用 data-i18n / data-i18n-ph / data-i18n-title, +// 动态用 t(key)。dev 标注层与发给 agent 的预设 prompt 不进 UI 字典。 'use strict'; -// ─── State ──────────────────────────────────────────────────────────────── -const state = { - sessions: new Map(), // localSessionId -> { id, bridgeSessionId, title, messages: [], cwd, config, diagnostics } - activeId: null, - bridgeReady: false, - defaultConfig: { theme: 'auto', llmNo: 0, gaRoot: '' }, - modelProfiles: [], - restartingBridge: false, - bridgeNoticeMessage: null, - mykeyReady: true, - runtimeBySessionId: new Map(), -}; +/* ═══════════════ 端口/URL 常量 ═══════════════ + bridge / conductor 的端口和 origin 都集中在这里。要换端口、要切同源、 + 要让 bridge 代理 conductor —— 改这一块即可,下面所有 URL 引用全都跟着走。 + *_ORIGIN 不带尾巴 path,调用方自己拼 "/sessions" "/ws" 等。 */ +const BRIDGE_PORT = 14168; +const CONDUCTOR_PORT = 8900; +const BRIDGE_ORIGIN = `${location.protocol}//${location.hostname}:${BRIDGE_PORT}`; +const BRIDGE_WS_ORIGIN = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.hostname}:${BRIDGE_PORT}`; +const CONDUCTOR_ORIGIN = `${location.protocol}//${location.hostname}:${CONDUCTOR_PORT}`; +const CONDUCTOR_WS_ORIGIN = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.hostname}:${CONDUCTOR_PORT}`; -// Helper: get config/diagnostics for the active session (or defaults) -function getActiveConfig() { - const sess = state.sessions.get(state.activeId); - return sess ? sess.config : state.defaultConfig; -} -function getActiveDiagnostics() { - const sess = state.sessions.get(state.activeId); - return sess ? sess.diagnostics : []; +/* ═══════════════ 进程状态 store ═══════════════ */ +const _serviceById = {}; +const _serviceListeners = new Set(); + +function _serviceList() { + return Object.values(_serviceById).sort((a, b) => String(a.id).localeCompare(String(b.id))); } -// ─── DOM refs ───────────────────────────────────────────────────────────── -const $ = (id) => document.getElementById(id); -const messagesEl = $('messages'); -const inputEl = $('input'); -const sendBtn = $('send-btn'); -const sessionListEl = $('session-list'); -const sessionTitleEl = $('session-title'); -const statusBadge = $('status-badge'); -const statusText = $('status-text'); -const settingsModal = $('settings-modal'); -const errorBanner = $('error-banner'); -const diagnosticsPanel = $('diagnostics-panel'); -const diagnosticsLogEl = $('diagnostics-log'); - - -// ─── Diagnostics ───────────────────────────────────────────────────────── -const MAX_DIAGNOSTICS = 200; - -function diagnosticText(payload) { - if (payload == null) return ''; - if (typeof payload === 'string') return payload; - if (payload instanceof Error) return payload.stack || payload.message; - try { - return JSON.stringify(payload); - } catch (_) { - return String(payload); +function _serviceNotify() { + const items = _serviceList(); + for (const cb of _serviceListeners) { + try { cb(items, _serviceById); } catch (e) { console.error('[service-store]', e); } } } -function addDiagnostic(level, message, payload) { - const ts = new Date().toISOString(); - const detail = diagnosticText(payload); - const diags = getActiveDiagnostics(); - diags.push({ ts, level, message, detail }); - if (diags.length > MAX_DIAGNOSTICS) diags.shift(); - renderDiagnostics(); +const gaServiceStore = { + applySnapshot(services) { + for (const k of Object.keys(_serviceById)) delete _serviceById[k]; + for (const s of services || []) { + if (s && s.id) _serviceById[s.id] = s; + } + _serviceNotify(); + }, + applyChanged(service) { + if (service && service.id) _serviceById[service.id] = service; + _serviceNotify(); + }, + onServices(cb) { + _serviceListeners.add(cb); + cb(_serviceList(), _serviceById); + return () => _serviceListeners.delete(cb); + }, + list: _serviceList, + get: (id) => _serviceById[id], +}; + +let bridgeUiOffline = false; + +/* ═══════════════ Bridge 适配(HTTP 命令 + WS 状态) ═══════════════ */ +(function initGaBridge() { + const listeners = new Map(); + let ws = null; + let cachedBridgeReady = null; + let wsRetries = 0; + let wsRetryTimer = null; + const bridgeBase = BRIDGE_ORIGIN; + const wsUrl = `${BRIDGE_WS_ORIGIN}/ws`; + + function on(channel, cb) { + if (typeof cb !== 'function') return () => {}; + if (!listeners.has(channel)) listeners.set(channel, new Set()); + listeners.get(channel).add(cb); + if (channel === 'bridge-ready' && cachedBridgeReady) { + try { cb(cachedBridgeReady); } catch (err) { console.error('[ga bridge] replay bridge-ready', err); } + } + return () => listeners.get(channel)?.delete(cb); + } + + function emit(channel, payload) { + if (channel === 'bridge-ready') cachedBridgeReady = payload; + const set = listeners.get(channel); + if (!set) return; + for (const cb of Array.from(set)) { + try { cb(payload); } catch (err) { console.error('[ga bridge]', channel, err); } + } + } + + function handleServiceWs(msg) { + if (msg.type === 'services.snapshot') gaServiceStore.applySnapshot(msg.services); + else if (msg.type === 'service.changed') gaServiceStore.applyChanged(msg.service); + 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 }); + if (init.body && typeof init.body !== 'string') { + headers['Content-Type'] = headers['Content-Type'] || 'application/json'; + init.body = JSON.stringify(init.body); + } + const res = await fetch(`${bridgeBase}${path}`, init); + const text = await res.text(); + let data = null; + try { data = text ? JSON.parse(text) : {}; } catch (_) { data = { raw: text }; } + if (!res.ok) { + const err = new Error((data && (data.error || data.message)) || `${res.status} ${res.statusText}`); + err.status = res.status; + err.data = data; + throw err; + } + 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; } + try { + ws = new WebSocket(wsUrl); + ws.addEventListener('open', () => { wsRetries = 0; emit('bridge-log', 'WS connected'); }); + ws.addEventListener('message', (ev) => { + let msg; + try { msg = JSON.parse(ev.data); } catch (_) { return; } + if (msg.type === 'bridge-ready') emit('bridge-ready', msg); + else if (msg.type === 'services.snapshot' || msg.type === 'service.changed') handleServiceWs(msg); + else if (msg.type === 'session-state') emit('bridge-notification', msg); + else if (msg.type === 'bridge-log') emit('bridge-log', msg.payload || msg); + else if (msg.type === 'bridge-error') emit('bridge-error', msg.payload || msg); + }); + ws.addEventListener('close', () => { emit('bridge-closed', { reason: 'ws-closed' }); scheduleWsReconnect(); }); + ws.addEventListener('error', () => emit('bridge-error', { type: 'ws-error', message: 'WebSocket error' })); + } catch (err) { + emit('bridge-error', { type: 'ws-error', message: err.message || String(err) }); + scheduleWsReconnect(); + } + } + + /* WS 自动重连(指数退避,封顶 30s)。手机浏览器后台会被 OS 掐 WS, + 不重连的话回到前台还是死连接。`visibilitychange` 那一段是回前台立刻重连。 */ + function scheduleWsReconnect() { + if (wsRetryTimer) clearTimeout(wsRetryTimer); + if (typeof document !== 'undefined' && document.hidden) return; // 后台等回前台再连 + const delay = Math.min(30000, 1000 * Math.pow(2, wsRetries)); + wsRetries++; + wsRetryTimer = setTimeout(() => { wsRetryTimer = null; connectWs(); }, delay); + } + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { + if (!document.hidden && (!ws || ws.readyState >= WebSocket.CLOSING)) { + wsRetries = 0; connectWs(); + } + }); + } + + async function rpc(method, params = {}) { + switch (method) { + case 'app/status': return http('/status'); + case 'app/config/get': return http('/config'); + case 'app/config/save': return http('/config', { method: 'POST', body: params || {} }); + case 'get/model-profiles': return http('/model-profiles'); + case 'session/new': return http('/session/new', { method: 'POST', body: params || {} }); + case 'session/prompt': { + const sid = params.sessionId || params.id || params.bridgeSessionId; + if (!sid) throw new Error('session/prompt missing sessionId'); + return http(`/session/${encodeURIComponent(sid)}/prompt`, { method: 'POST', body: params || {} }); + } + case 'session/poll': { + const sid = params.sessionId || params.id || params.bridgeSessionId; + if (!sid) throw new Error('session/poll missing sessionId'); + const after = params.afterId ?? params.after ?? 0; + const limit = params.limit ?? 200; + return http(`/session/${encodeURIComponent(sid)}/messages?after=${encodeURIComponent(after)}&limit=${encodeURIComponent(limit)}`); + } + case 'session/cancel': { + const sid = params.sessionId || params.id || params.bridgeSessionId; + if (!sid) throw new Error('session/cancel missing sessionId'); + return http(`/session/${encodeURIComponent(sid)}/cancel`, { method: 'POST', body: params || {} }); + } + case 'app/path/open': return http('/path/open', { method: 'POST', body: params || {} }); + case 'services/start': { + const id = params.id; + if (!id) throw new Error('services/start missing id'); + return http('/services/start', { method: 'POST', body: { id } }); + } + case 'services/stop': { + const id = params.id; + if (!id) throw new Error('services/stop missing id'); + return http('/services/stop', { method: 'POST', body: { id } }); + } + case 'services/logs': { + const id = params.id; + if (!id) throw new Error('services/logs missing id'); + const tail = params.tail ?? 200; + 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'); + case 'list_continuable_sessions': return { sessions: [] }; + case 'restore_session': throw new Error('restore_session is not implemented in web2 bridge'); + default: throw new Error(`Unknown RPC method: ${method}`); + } + } + + async function startService(id) { + try { + const res = await rpc('services/start', { id }); + if (res.service) gaServiceStore.applyChanged(res.service); + return res; + } catch (e) { + if (e.data && e.data.service) gaServiceStore.applyChanged(e.data.service); + throw e; + } + } + + async function stopService(id) { + const res = await rpc('services/stop', { id }); + if (res.service) gaServiceStore.applyChanged(res.service); + 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 || {}), + getModelProfiles: () => rpc('get/model-profiles', {}), + selectGaRoot: () => rpc('app/path/selectGaRoot', {}), + openMykeyTemplate: () => rpc('app/path/open', { kind: 'mykeyTemplate' }), + openMykey: () => rpc('app/path/open', { kind: 'mykey' }), + startService, + stopService, + getServiceLogs: (id, tail = 200) => rpc('services/logs', { id, tail }), + 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), + onBridgeNotification: (cb) => on('bridge-notification', cb), + onBridgeError: (cb) => on('bridge-error', cb), + onBridgeClosed: (cb) => on('bridge-closed', cb), + onBridgeReady: (cb) => on('bridge-ready', cb), + onBridgeLog: (cb) => on('bridge-log', cb), + onServiceState: (cb) => on('service-state', cb), + onOpenSearch: (cb) => on('open-search', cb), + }; + + connectWs(); + http('/status').then(status => emit('bridge-ready', status)) + .catch(err => emit('bridge-error', { type: 'http-error', message: err.message || String(err) })); +})(); + +/* ═══════════════ i18n ═══════════════ */ +const I18N = { + zh: { + 'app.title': 'GenericAgent 桌面版', + 'brand.sub': '桌面终端', + 'nav.chat': '聊天', 'nav.services': '后台服务', 'nav.channels': '消息通道', 'nav.status': '状态面板', + 'nav.collab': '指挥家', 'nav.token': '用量', + 'foot.settings': '配置', 'foot.ver': 'GenericAgent · 桌面版', + 'chat.startTitle': '开始对话', 'chat.startSub': '直接输入,或点预设功能一键启动', + 'preset.butler.t': '指挥家', 'preset.butler.d': '复杂任务自动拆解,只需查看进度和简报', + 'preset.plan.t': 'Plan 模式', 'preset.plan.d': '加载 Plan SOP,按探索→规划→执行→验证流程', + 'preset.goal.t': 'Goal 模式', 'preset.goal.d': '设定目标,自主完成', + 'preset.autonomous.t': '自主行动', 'preset.autonomous.d': '按 SOP 规划/执行任务,产出报告(reflect/autonomous.py 同源)', + 'preset.hive.t': 'Hive 协作', 'preset.hive.d': '多 worker 协同攻坚', + 'preset.review.t': '深度复核', 'preset.review.d': '挑刺式质量把关', + 'preset.findwork.t': '找点事做', 'preset.findwork.d': '分析当前情况,推荐一批让你感兴趣的 TODO', + 'preset.mine.t': '我的·周报', 'preset.mine.d': '自定义:抓本周提交并写周报', + 'preset.add.t': '自定义', 'preset.add.d': '任意一句话存为功能', + 'composer.placeholder': 'GA 能帮你做些什么?', + 'search.placeholder': '搜索会话…', 'conv.new': '新对话', + 'ctx.pin': '置顶', 'ctx.unpin': '取消置顶', 'ctx.rename': '重命名', 'ctx.del': '删除', + 'common.close': '关闭', 'common.more': '更多', 'common.optional': '选填', 'common.save': '保存', + 'modal.preset': '预设功能', 'modal.addModel': '添加模型', 'modal.editModel': '编辑模型', 'modal.settings': '配置', + 'modal.customPreset': '自定义预设', + 'modal.editCustomPreset': '编辑任务', + 'customPreset.titlePh': '标题,例如「写周报」', + 'customPreset.promptPh': 'Prompt 内容,发送时会作为消息提交', + 'customPreset.empty': '标题和 Prompt 不能为空', + 'customPreset.removeTitle': '删除', + 'customPreset.editTitle': '编辑', + 'builtinPreset.restoreBtn': '恢复默认预设', + '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', + 'model.name': '备注', 'model.namePh': '会显示在模型列表', + '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.confirm': '确认', 'common.edit': '编辑', 'common.delete': '删除', + 'pq.title': '快速接入官方模型', 'pq.sub': '填好 API Key 即可使用', 'pq.toggle': '展开 / 收起', + 'pq.deepseekDesc': '官方 API · OpenAI 兼容', 'pq.qwenDesc': '通义千问 · 阿里云百炼', + 'guide.step1': '点击下方链接,登录后创建并复制 API Key', + 'guide.step2': '把 Key 粘贴到下方「API Key」输入框', + 'guide.step3': '点击保存,即可在模型列表中选用', + 'guide.prefillTip': '已为你预填 API 地址、协议与模型,可按需修改', + 'guide.getKey': '获取 {name} 的 API Key', 'guide.copy': '复制链接', 'guide.copied': '链接已复制', + '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 管理的后台进程/服务,集中查看与启停', + 'page.collab.title': '指挥家', 'page.collab.sub': '交代目标,自动拆活与跟进', + 'collab.progressTitle': '分工进度', + 'collab.progressEmpty': '还没有任务在执行。告诉指挥家你的目标后,这里会显示拆分后的处理进度。', + 'collab.placeholder': '请对指挥家描述你想完成的目标', + 'collab.guideTitle': '把要完成的事告诉指挥家', + 'collab.guideWhen': '适合需要多步处理、要花一些时间才能完成的目标。日常聊天和快问快答,请用左侧「聊天」。', + 'collab.guideStep1t': '描述目标', + 'collab.guideStep1d': '在聊天框里写下你想做的事,发给指挥家', + 'collab.guideStep2t': '自动拆解', + 'collab.guideStep2d': '指挥家自动拆解、分配任务,实时监督和调度', + 'collab.guideStep3t': '交付摘要', + 'collab.guideStep3d': '指挥家根据执行状态,呈上任务简报', + 'collab.guideStep4t': '随时调整', + 'collab.guideStep4d': '随时补充要求或细节,指挥家都会处理', + 'collab.chipProgress': '现在进展如何?', + 'collab.chipPause': '先暂停当前任务', + 'collab.chipSummary': '总结一下目前的结果', + 'collab.showProgressTitle': '查看分工进度', + 'collab.statRunning': '进行中', + 'collab.statDone': '已完成', + 'collab.plusMenu': '更多操作', + 'collab.switchMode': '切换模式', + 'collab.typing': '指挥家正在处理', + 'collab.offline': '无法连接指挥家服务,请确认后端已启动。', + 'collab.retry': '重试', + 'collab.reconnect': '连接断开,正在重连… 已保留上次任务进度。', + 'collab.reconnectIn': '{n} 秒后重试', + 'collab.stRunning': '执行中', 'collab.stReported': '已回报', 'collab.stPaused': '已暂停', + 'collab.stFailed': '遇到问题', 'collab.stTerminated': '已终止', + 'collab.summaryRunning': '正在处理中…', 'collab.summaryWait': '等待回报', + 'collab.taskFallback': '任务 {n}', + 'collab.timeJust': '刚刚', + 'collab.timeSec': '{n} 秒前', + 'collab.timeMin': '{n} 分钟前', + 'collab.timeHr': '{n} 小时前', + 'collab.timeDay': '{n} 天前', + 'page.token.title': '用量', 'page.token.sub': '每会话与累计用量及缓存率', + 'status.connecting': '正在连接…', 'status.ready': '服务在线', 'status.running': '处理中', + 'status.disconnected': '服务离线', 'status.stopped': '已停止', 'status.idle': '待命', + 'conv.emptyList': '暂无会话,点「+ 新对话」开始', 'conv.defaultTitle': '新对话', + 'err.bridge': '服务未响应', 'err.newSession': '新建会话失败', 'err.poll': '轮询失败', 'err.stop': '停止失败', + 'err.interruptTimeout': '等待上一轮停止超时,请稍后再试', + 'sys.interruptPrev.hint': '已停止上一轮,正在处理新消息', + 'chat.interrupting': '正在停止上一轮…', + 'chat.sessionLoading': '正在加载会话…', + 'sys.stopRequested': '已请求停止', + 'slash.help': '可用命令:\n/new 新会话 /clear 清屏 /stop 停止 /settings 设置', + 'slash.unknown': '未知命令', + 'upload.hint': '上传文件:选择 / 拖拽 / 粘贴', + 'upload.button': '上传文件', + 'upload.tooLarge': '文件过大或数量超限', 'upload.empty': '跳过空文件', + 'upload.failed': '上传失败', + 'err.charLimit': '已达字数上限({n}),发送时将自动截断', 'err.charLimitReached': '已达字数上限({n})', 'err.numMax': '不能超过 {n}', + 'file.openFailed': '无法打开文件', + 'file.kindGeneric': '文件', + 'file.kindDoc': '文档', + 'file.kindSheet': '表格', + 'file.kindSlide': '幻灯片', + 'file.kindCode': '代码', + 'file.kindArchive': '压缩包', + 'file.kindAudio': '音频', + 'file.kindVideo': '视频', + 'upload.removeTitle': '移除', + 'upload.dropHint': '松开以上传文件', + 'lightbox.closeTitle': '关闭', + 'fold.thinking': '思考', 'fold.tool': '工具调用', 'fold.toolResult': '工具结果', 'fold.llm': 'LLM Running', 'fold.turn': '第 {n} 轮', + 'plan.header': '计划 ({done}/{total})', 'plan.complete': '✓ 计划完成 ({n}/{n})', + 'plan.running': '计划执行中', 'plan.completeTitle': '计划完成', + 'plan.placeholder': '计划模式已激活', 'plan.waiting': '等待写入 {path} …', 'plan.overflow': '还有 {n} 项', + 'plan.current': '当前', 'plan.collapse': '收起', 'plan.expand': '展开', 'plan.details': '详情', + 'plan.capsuleRunning': '运行中', 'plan.capsuleComplete': '已完成', + 'timing.elapsed': '已运行 {t}', + 'model.auto': '自动选择', + 'model.menuLabel': '选择模型', + 'chip.plan': 'Plan', + 'chip.auto': 'Auto', + 'ch.wechat': '微信', 'ch.wecom': '企业微信', 'ch.lark': '飞书', 'ch.dingtalk': '钉钉', + 'ch.qq': 'QQ', 'ch.telegram': 'Telegram', 'ch.discord': 'Discord', + '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': '配置已保存', + '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': '空闲', + 'cm.master': '已派 3 子任务', 'cm.w1': '子任务:抓取数据', 'cm.w2': '子任务:复核结果', 'cm.sub': '等待派单', + 'tok.total': '累计', 'tok.cost': '缓存率', 'tok.today': '今日', 'tok.tabAll': '聊天', 'tok.tabConductor': '指挥家', 'tok.condTotal': '指挥家累计', 'tok.condCurrent': '指挥家本次', 'tok.condTip': '指挥家消耗不计入聊天累计', 'tok.condOffline': '指挥家服务离线', 'tok.disclaimer': '不同 API 网站的计费价格可能会有差异,请以实际网站为准。', + 'tok.colSession': '会话', 'tok.colIn': '输入', 'tok.colOut': '输出', 'tok.colCacheW': '缓存写入', 'tok.colCache': '缓存读取', 'tok.colCost': '成本', + 'tok.from': '从', 'tok.to': '到', 'tok.reset': '重置', 'tok.noData': '暂无记录', 'tok.deleted': '此会话已删除', + 'tok.pricingUnknown': '⚠ 此模型计费规则尚未明确,按默认估算', + 'tok.priceInput': '输入: $', 'tok.priceOutput': '输出: $', + 'tok.priceCacheW': '缓存写入: $', 'tok.priceCacheR': '缓存读取: $', + 'presetPrompt.goal': '进入 Goal 模式:读 L3 goal mode SOP,自主达成我接下来描述的目标。', + 'presetPrompt.plan': '进入 Plan 模式:先读 memory/plan_sop.md,按其中「探索→规划→执行→验证」流程,等我接下来描述要做的任务。', + 'presetPrompt.autonomous': '🤖 进入自主行动模式:阅读 memory/autonomous_operation_sop.md,按 SOP 选取或规划任务,独立执行并产出报告。', + 'presetPrompt.hive': '启动 Goal Hive 模式:按 hive SOP 拉起多个 worker 协同完成我接下来的目标。', + 'presetPrompt.review': '进入监察者模式:对刚才的产出严格挑刺、逐项复核并报告问题。', + 'presetPrompt.findwork': '按照自主行动的规划部分,充分分析我的情况,给我生成一批 TODO,务必让我感兴趣。', + 'presetPrompt.mine': '抓取本周的 git 提交并写一份周报。', + 'ask.banner': 'GA 等你回答', + 'ask.replyHint': '在下方输入框回复', + 'ask.placeholderOpen': '在此输入你的回答… (Enter 发送)', + }, + en: { + 'app.title': 'GenericAgent Desktop', + 'brand.sub': 'Desktop terminal', + 'nav.chat': 'Chat', 'nav.services': 'Services', 'nav.channels': 'Channels', 'nav.status': 'Status', + 'nav.collab': 'Conductor', 'nav.token': 'Usage', + 'foot.settings': 'Settings', 'foot.ver': 'GenericAgent · Desktop', + 'chat.startTitle': 'Start a conversation', 'chat.startSub': 'Type a message, or pick a preset', + 'preset.butler.t': 'Conductor', 'preset.butler.d': 'Auto-decompose complex tasks; just check progress and briefings', + 'preset.plan.t': 'Plan mode', 'preset.plan.d': 'Load Plan SOP — explore→plan→execute→verify', + 'preset.goal.t': 'Goal mode', 'preset.goal.d': 'Set a goal, run autonomously', + 'preset.autonomous.t': 'Autonomous mode', 'preset.autonomous.d': 'Plan/execute tasks per SOP and produce reports (same as reflect/autonomous.py)', + 'preset.hive.t': 'Hive', 'preset.hive.d': 'Multi-worker collaboration', + 'preset.review.t': 'Deep review', 'preset.review.d': 'Strict quality check', + 'preset.findwork.t': 'Find me work', 'preset.findwork.d': 'Analyze my context and suggest a batch of interesting TODOs', + 'preset.mine.t': 'My · Weekly', 'preset.mine.d': 'Custom: weekly report from commits', + 'preset.add.t': 'Custom', 'preset.add.d': 'Save any prompt as a function', + 'composer.placeholder': 'What can GA do for you?', + 'search.placeholder': 'Search chats…', 'conv.new': 'New chat', + 'ctx.pin': 'Pin', 'ctx.unpin': 'Unpin', 'ctx.rename': 'Rename', 'ctx.del': 'Delete', + 'common.close': 'Close', 'common.more': 'More', 'common.optional': 'Optional', 'common.save': 'Save', + 'modal.preset': 'Presets', 'modal.addModel': 'Add model', 'modal.editModel': 'Edit model', 'modal.settings': 'Settings', + 'modal.customPreset': 'Custom preset', + 'modal.editCustomPreset': 'Edit task', + 'customPreset.titlePh': 'Title, e.g. "Weekly report"', + 'customPreset.promptPh': 'Prompt body — sent as the message when clicked', + 'customPreset.empty': 'Title and Prompt cannot be empty', + '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.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', + 'model.name': 'Note', 'model.namePh': 'Shown in the model list', + '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.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', + 'guide.step2': 'Paste the key into the “API Key” field below', + 'guide.step3': 'Click Save — then pick it from the model list', + 'guide.prefillTip': 'API base, protocol and model are pre-filled — edit if needed', + 'guide.getKey': 'Get your {name} API key', 'guide.copy': 'Copy link', 'guide.copied': 'Link copied', + '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', + 'page.collab.title': 'Conductor', 'page.collab.sub': 'Describe a goal — split, delegate, and follow up', + 'collab.progressTitle': 'Progress', + 'collab.progressEmpty': 'No tasks running yet. After you describe a goal to Conductor, split tasks will appear here.', + 'collab.placeholder': 'Describe the goal you want to accomplish', + 'collab.guideTitle': 'Tell Conductor what you want done', + 'collab.guideWhen': 'Best for multi-step goals that take a while. For everyday chat and quick questions, use Chat in the sidebar.', + 'collab.guideStep1t': 'Describe your goal', + 'collab.guideStep1d': 'Write what you want done in the chat box and send it to Conductor', + 'collab.guideStep2t': 'Auto breakdown', + 'collab.guideStep2d': 'Conductor breaks down, assigns, monitors, and coordinates', + 'collab.guideStep3t': 'Summary', + 'collab.guideStep3d': 'Conductor delivers a briefing based on execution status', + 'collab.guideStep4t': 'Adjust anytime', + 'collab.guideStep4d': 'Add requirements or details anytime — Conductor handles them', + 'collab.chipProgress': 'How is it going?', + 'collab.chipPause': 'Pause current tasks', + 'collab.chipSummary': 'Summarize progress so far', + 'collab.showProgressTitle': 'View task progress', + 'collab.statRunning': 'Running', + 'collab.statDone': 'Done', + 'collab.plusMenu': 'More actions', + 'collab.switchMode': 'Switch mode', + 'collab.typing': 'Conductor is working', + 'collab.offline': 'Cannot reach the service. Make sure the backend is running.', + 'collab.retry': 'Retry', + 'collab.reconnect': 'Disconnected — reconnecting… Your last progress is kept.', + 'collab.reconnectIn': 'Retry in {n}s', + 'collab.stRunning': 'Running', 'collab.stReported': 'Reported', 'collab.stPaused': 'Paused', + 'collab.stFailed': 'Issue', 'collab.stTerminated': 'Ended', + 'collab.summaryRunning': 'Working…', 'collab.summaryWait': 'Awaiting report', + 'collab.taskFallback': 'Task {n}', + 'collab.timeJust': 'just now', + 'collab.timeSec': '{n}s ago', + 'collab.timeMin': '{n}m ago', + 'collab.timeHr': '{n}h ago', + 'collab.timeDay': '{n}d ago', + 'page.token.title': 'Usage', 'page.token.sub': 'Per-session and total usage & cache rate', + 'status.connecting': 'Connecting…', 'status.ready': 'Service online', 'status.running': 'Working…', + 'status.disconnected': 'Service offline', 'status.stopped': 'Stopped', 'status.idle': 'Standby', + 'conv.emptyList': 'No chats yet — click “+ New chat”', 'conv.defaultTitle': 'New chat', + 'err.bridge': 'Service not responding', 'err.newSession': 'Failed to create session', 'err.poll': 'Polling failed', 'err.stop': 'Stop failed', + 'err.interruptTimeout': 'Timed out waiting for the previous reply to stop — try again', + 'sys.interruptPrev.hint': 'Previous reply stopped — processing new message', + 'chat.interrupting': 'Stopping previous reply…', + 'chat.sessionLoading': 'Loading conversation…', + 'sys.stopRequested': 'Stop requested', + 'slash.help': 'Commands:\n/new new chat /clear clear /stop stop /settings settings', + 'slash.unknown': 'Unknown command', + 'upload.hint': 'Upload file: pick / drag / paste', + 'upload.button': 'Upload file', + 'upload.tooLarge': 'File too large or limit reached', 'upload.empty': 'Skipped empty file', + 'upload.failed': 'Upload failed', + 'err.charLimit': 'Character limit reached ({n}), text will be truncated on send', 'err.charLimitReached': 'Character limit reached ({n})', 'err.numMax': 'Cannot exceed {n}', + 'file.openFailed': 'Cannot open file', + 'file.kindGeneric': 'File', + 'file.kindDoc': 'Document', + 'file.kindSheet': 'Spreadsheet', + 'file.kindSlide': 'Slides', + 'file.kindCode': 'Code', + 'file.kindArchive': 'Archive', + 'file.kindAudio': 'Audio', + 'file.kindVideo': 'Video', + 'upload.removeTitle': 'Remove', + 'upload.dropHint': 'Drop to upload files', + 'lightbox.closeTitle': 'Close', + 'fold.thinking': 'Thinking', 'fold.tool': 'Tool call', 'fold.toolResult': 'Tool result', 'fold.llm': 'LLM Running', 'fold.turn': 'Turn {n}', + 'plan.header': 'Plan ({done}/{total})', 'plan.complete': '✓ Plan complete ({n}/{n})', + 'plan.running': 'Running plan', 'plan.completeTitle': 'Plan complete', + 'plan.placeholder': 'Plan mode activated', 'plan.waiting': 'waiting for {path} …', 'plan.overflow': '+{n} more', + 'plan.current': 'Now', 'plan.collapse': 'Collapse', 'plan.expand': 'Expand', 'plan.details': 'Details', + 'plan.capsuleRunning': 'Running', 'plan.capsuleComplete': 'Done', + 'timing.elapsed': 'Elapsed {t}', + 'model.auto': 'Auto', + 'model.menuLabel': 'Select model', + 'chip.plan': 'Plan', + 'chip.auto': 'Auto', + 'ch.wechat': 'WeChat', 'ch.wecom': 'WeCom', 'ch.lark': 'Lark', 'ch.dingtalk': 'DingTalk', + 'ch.qq': 'QQ', 'ch.telegram': 'Telegram', 'ch.discord': 'Discord', + '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', + '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', + 'cm.master': 'Dispatched 3 subtasks', 'cm.w1': 'Subtask: fetch data', 'cm.w2': 'Subtask: review results', 'cm.sub': 'Waiting for tasks', + 'tok.total': 'Total', 'tok.cost': 'Cache rate', 'tok.today': 'Today', 'tok.tabAll': 'Chat', 'tok.tabConductor': 'Conductor', 'tok.condTotal': 'Conductor Total', 'tok.condCurrent': 'Conductor Current', 'tok.condTip': 'Conductor usage is not included in chat totals', 'tok.condOffline': 'Service offline', 'tok.disclaimer': 'Pricing may vary by API provider. Please refer to the actual website.', + 'tok.colSession': 'Session', 'tok.colIn': 'Input', 'tok.colOut': 'Output', 'tok.colCacheW': 'Cache write', 'tok.colCache': 'Cache read', 'tok.colCost': 'Cost', + 'tok.from': 'From', 'tok.to': 'To', 'tok.reset': 'Reset', 'tok.noData': 'No records', 'tok.deleted': 'Session deleted', + 'tok.pricingUnknown': '⚠ Pricing not confirmed, using defaults', + 'tok.priceInput': 'Input: $', 'tok.priceOutput': 'Output: $', + 'tok.priceCacheW': 'Cache write: $', 'tok.priceCacheR': 'Cache read: $', + 'presetPrompt.goal': 'Enter Goal mode: read the L3 goal-mode SOP and autonomously achieve the goal I describe next.', + 'presetPrompt.plan': 'Enter Plan mode: first read memory/plan_sop.md, follow its explore→plan→execute→verify flow, and wait for the task I describe next.', + 'presetPrompt.autonomous': '🤖 Enter autonomous mode: read memory/autonomous_operation_sop.md, follow the SOP to pick or plan a task, execute independently, and produce a report.', + 'presetPrompt.hive': 'Start Goal Hive mode: per the hive SOP, spawn multiple workers to collaboratively achieve the goal I describe next.', + 'presetPrompt.review': 'Enter reviewer mode: strictly scrutinize the previous output, review item by item and report issues.', + 'presetPrompt.findwork': 'Following the autonomous planning section, analyze my situation thoroughly and generate a batch of TODOs that genuinely interest me.', + 'presetPrompt.mine': 'Collect this week\'s git commits and write a weekly report.', + 'ask.banner': 'GA is waiting for your answer', + 'ask.replyHint': 'Reply in the input below', + 'ask.placeholderOpen': 'Type your answer here… (Enter to send)', + }, +}; +const LANGS = ['zh', 'en']; +const STORE = { lang: 'ga_lang', theme: 'ga_theme', appearance: 'ga_appearance', plain: 'ga_plain', fontSize: 'ga_font_size', llmNo: 'ga_llm_no' }; +const APPEARANCE_IDS = ['light', 'dark']; +const CHAT_FONT_MIN = 10; +const CHAT_FONT_MAX = 20; +const CHAT_FONT_DEFAULT = 14; +const CHAT_FONT_LEGACY = { sm: 12, md: 14, lg: 16 }; +const HLJS_THEME_BASE = 'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/'; + +function normalizeChatFontSize(value) { + if (typeof value === 'string' && CHAT_FONT_LEGACY[value]) return CHAT_FONT_LEGACY[value]; + const n = parseInt(value, 10); + if (Number.isFinite(n)) return Math.min(CHAT_FONT_MAX, Math.max(CHAT_FONT_MIN, n)); + return CHAT_FONT_DEFAULT; } -function formatDiagnostics() { - const diags = getActiveDiagnostics(); - if (diags.length === 0) return 'No diagnostics yet.'; - return diags.map((entry) => { - const suffix = entry.detail ? `\n ${entry.detail}` : ''; - return `[${entry.ts}] ${entry.level.toUpperCase()} ${entry.message}${suffix}`; +function bootUiFromDom() { + const root = document.documentElement; + const out = { lang: 'zh', theme: '1', appearance: 'light', plainUi: false, chatFontSize: CHAT_FONT_DEFAULT }; + if (root.lang === 'en') out.lang = 'en'; + if (root.dataset.theme) out.theme = root.dataset.theme; + if (APPEARANCE_IDS.includes(root.dataset.appearance)) out.appearance = root.dataset.appearance; + if (out.appearance === 'light' && root.dataset.plain === '1') out.plainUi = true; + if (root.dataset.chatFont) out.chatFontSize = normalizeChatFontSize(root.dataset.chatFont); + return out; +} +let { lang, theme, appearance, plainUi, chatFontSize } = bootUiFromDom(); + +function syncHljsTheme() { + const link = document.getElementById('hljs-theme'); + if (link) link.href = HLJS_THEME_BASE + (appearance === 'dark' ? 'github-dark.min.css' : 'github.min.css'); + document.querySelectorAll('.bubble.md pre code').forEach(block => { + if (typeof hljs !== 'undefined') hljs.highlightElement(block); + }); +} + +/** 服务端 ui 落盘后的本地镜像,仅供 index.html 内联脚本首帧防闪;不是真相源。 */ +function syncBootCache() { + localStorage.setItem(STORE.lang, lang); + localStorage.setItem(STORE.theme, theme); + localStorage.setItem(STORE.appearance, appearance); + localStorage.setItem(STORE.fontSize, String(chatFontSize)); + if (plainUi) localStorage.setItem(STORE.plain, '1'); + else localStorage.removeItem(STORE.plain); + localStorage.setItem(STORE.llmNo, String(state.llmNo)); +} +async function persistUiPrefs() { + try { + await window.ga.saveConfig({ + config: { lang, theme, appearance, plain: plainUi, llmNo: state.llmNo, fontSize: chatFontSize }, + }); + syncBootCache(); + } catch (_) {} +} +const bridgeHost = () => BRIDGE_ORIGIN; +async function bridgeFetch(path, opts = {}) { + const headers = { ...(opts.headers || {}) }; + const init = { ...opts, headers }; + if (init.body && typeof init.body !== 'string') { + headers['Content-Type'] = 'application/json'; + init.body = JSON.stringify(init.body); + } + const res = await fetch(`${bridgeHost()}${path}`, init); + let data = {}; + try { data = await res.json(); } catch (_) {} + if (!res.ok) throw new Error(data.error || data.message || res.statusText); + return data; +} +function t(key) { return (I18N[lang] && I18N[lang][key]) || (I18N.zh[key]) || key; } +window.gaT = t; +document.addEventListener('collab:running-count', e => { + const b = document.getElementById('collab-badge'); + if (!b) return; + const n = e.detail?.count || 0; + b.hidden = !n; + b.textContent = n ? (n > 9 ? '9+' : String(n)) : ''; +}); +function optionalPh(key) { + const sep = (lang === 'en') ? ', ' : ','; + return `${t('common.optional')}${sep}${t(key)}`; +} +function applyI18n() { + document.documentElement.lang = (lang === 'en') ? 'en' : 'zh-CN'; + document.title = t('app.title'); + document.querySelectorAll('[data-i18n]').forEach(el => { el.textContent = t(el.dataset.i18n); }); + document.querySelectorAll('[data-i18n-ph]').forEach(el => { + const phKey = el.dataset.i18nPh; + const val = el.hasAttribute('data-optional-ph') ? optionalPh(phKey) : t(phKey); + if (el.isContentEditable) el.setAttribute('data-ph', val); // contenteditable 用 :empty::before 显示 + else el.setAttribute('placeholder', val); + }); + 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(); +} +// 语言对应国旗 SVG(en 用美国旗,按要求) +const FLAGS = { + zh: '', + en: '', +}; +function renderLangList() { + const box = document.getElementById('lang-list'); + if (!box) return; + box.innerHTML = ''; + LANGS.forEach(code => { + const row = document.createElement('label'); + row.className = 'model-row' + (lang === code ? ' sel' : ''); + row.innerHTML = `${FLAGS[code] || ''}${escapeHtml(t('lang.' + code))}`; + row.addEventListener('click', (e) => { e.preventDefault(); selectLang(code); }); + box.appendChild(row); + }); +} +function selectLang(code) { + if (!LANGS.includes(code) || lang === code) return; + lang = code; + applyI18n(); + renderSessionList(); + refreshStatusLabel(); + updateModelChip(); + renderSettingsModels(); + if (typeof renderAllPresets === 'function') renderAllPresets(); + if (isServicesPageActive()) refreshServicesPanel(); + void persistUiPrefs(); +} +function syncChatFontSegments(value) { + document.querySelectorAll('.chat-font-seg').forEach(el => { + const v = parseInt(el.dataset.value, 10); + el.classList.toggle('on', v <= value); + el.classList.toggle('cur', v === value); + }); + const stepper = document.getElementById('chat-font-stepper'); + if (stepper) { + stepper.setAttribute('aria-valuenow', String(value)); + stepper.setAttribute('aria-valuetext', `${value}px`); + } +} +function chatFontFromPointer(clientX) { + const segs = document.getElementById('chat-font-segments'); + if (!segs) return chatFontSize; + const rect = segs.getBoundingClientRect(); + const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)); + return CHAT_FONT_MIN + Math.round(ratio * (CHAT_FONT_MAX - CHAT_FONT_MIN)); +} +function initChatFontStepper() { + const segs = document.getElementById('chat-font-segments'); + if (!segs || segs.childElementCount) return; + for (let i = CHAT_FONT_MIN; i <= CHAT_FONT_MAX; i++) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'chat-font-seg'; + btn.dataset.value = String(i); + btn.tabIndex = -1; + btn.addEventListener('click', (e) => { + e.stopPropagation(); + applyChatFontSize(i); + }); + segs.appendChild(btn); + } + const stepper = document.getElementById('chat-font-stepper'); + if (!stepper || stepper.dataset.bound) return; + stepper.dataset.bound = '1'; + let dragging = false; + const pick = (clientX, persist) => applyChatFontSize(chatFontFromPointer(clientX), { persist }); + stepper.addEventListener('pointerdown', (e) => { + if (e.button !== 0) return; + dragging = true; + stepper.setPointerCapture(e.pointerId); + pick(e.clientX, false); + }); + stepper.addEventListener('pointermove', (e) => { + if (!dragging) return; + pick(e.clientX, false); + }); + const endDrag = (e, persist) => { + if (!dragging) return; + dragging = false; + try { stepper.releasePointerCapture(e.pointerId); } catch (_) {} + pick(e.clientX, persist); + }; + stepper.addEventListener('pointerup', (e) => endDrag(e, true)); + stepper.addEventListener('pointercancel', (e) => endDrag(e, false)); + stepper.addEventListener('keydown', (e) => { + if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { + e.preventDefault(); + applyChatFontSize(chatFontSize - 1); + } else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { + e.preventDefault(); + applyChatFontSize(chatFontSize + 1); + } else if (e.key === 'Home') { + e.preventDefault(); + applyChatFontSize(CHAT_FONT_MIN); + } else if (e.key === 'End') { + e.preventDefault(); + applyChatFontSize(CHAT_FONT_MAX); + } + }); +} +function applyChatFontSize(size, { persist } = { persist: true }) { + chatFontSize = normalizeChatFontSize(size); + document.documentElement.dataset.chatFont = String(chatFontSize); + document.documentElement.style.setProperty('--chat-font', `${chatFontSize}px`); + const label = document.getElementById('chat-font-value'); + if (label) label.textContent = `${chatFontSize}px`; + syncChatFontSegments(chatFontSize); + if (persist) void persistUiPrefs(); +} +function applyTheme(id, { persist } = { persist: true }) { + // 主题选色已下线,只保留灰色亮色主题(--accent 在 styles.css 里硬编码)。 + // 函数保留可调用,只把 dataset.theme 固定到 '1' 兼容旧 localStorage。 + theme = '1'; + document.documentElement.dataset.theme = '1'; + if (persist) void persistUiPrefs(); +} +function syncPlainSwitch() { + const row = document.getElementById('plain-ui-row'); + const sw = document.getElementById('plain-ui-switch'); + if (!row || !sw) return; + const show = appearance === 'light'; + row.hidden = !show; + sw.setAttribute('aria-checked', plainUi ? 'true' : 'false'); +} +function applyAppearance(nextApp, nextPlain, { persist } = { persist: true }) { + appearance = APPEARANCE_IDS.includes(nextApp) ? nextApp : 'light'; + if (appearance === 'light') plainUi = !!nextPlain; + else plainUi = false; + document.documentElement.dataset.appearance = appearance; + if (plainUi) document.documentElement.dataset.plain = '1'; + else delete document.documentElement.dataset.plain; + document.querySelectorAll('#appearance-seg .appear-card').forEach(el => { + const on = el.dataset.appearance === appearance; + el.classList.toggle('sel', on); + el.setAttribute('aria-checked', on ? 'true' : 'false'); + }); + syncPlainSwitch(); + syncHljsTheme(); + if (persist) void persistUiPrefs(); +} + +/* ═══════════════ 侧边栏导航 ═══════════════ */ +const nav = document.getElementById('nav'); +const pages = document.querySelectorAll('#pages .page'); +let currentPage = 'chat'; +function gaGoPage(key) { + const item = nav?.querySelector(`.nav-item[data-page="${key}"]`); + if (!item) return; + currentPage = key; + nav.querySelectorAll('.nav-item').forEach(n => n.classList.toggle('active', n === item)); + pages.forEach(p => p.classList.toggle('active', p.dataset.page === key)); + renderSessionList(); + window.gaSetActiveFileComposer?.(key === 'collab' ? 'collab' : 'chat'); + if (key === 'collab') window.collabInit?.(); +} +window.gaGoPage = gaGoPage; +nav.addEventListener('click', (e) => { + const item = e.target.closest('.nav-item'); + if (!item) return; + gaGoPage(item.dataset.page); +}); + +/* ═══════════════ 弹窗开关 ═══════════════ */ +const openModal = (id) => { const m = document.getElementById(id); if (m) m.hidden = false; }; +window.gaOpenModal = openModal; +const closeModals = () => document.querySelectorAll('.modal').forEach(m => { + m.hidden = true; + 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) => { + const btn = e.target.closest('.pq-btn[data-provider]'); + if (!btn) return; + e.preventDefault(); e.stopPropagation(); + openAddModelFormForProvider(btn.dataset.provider); +}); +// 「快速接入」卡片折叠/展开(向下箭头),状态记忆到 localStorage +const pqToggle = document.getElementById('pq-toggle'); +if (pqEl && pqToggle) { + const applyPq = (collapsed) => { + pqEl.classList.toggle('collapsed', collapsed); + pqToggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); + }; + let pqCollapsed = false; + try { pqCollapsed = localStorage.getItem('ga_pq_collapsed') === '1'; } catch (_) {} + applyPq(pqCollapsed); + const togglePq = (e) => { + if (e) e.stopPropagation(); + pqCollapsed = !pqEl.classList.contains('collapsed'); + applyPq(pqCollapsed); + try { localStorage.setItem('ga_pq_collapsed', pqCollapsed ? '1' : '0'); } catch (_) {} + }; + pqToggle.addEventListener('click', togglePq); + pqToggle.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); togglePq(); } + }); +} +// 接入指引:复制获取 API Key 的链接 +bindClick('model-guide-copy', (e) => { + e.preventDefault(); e.stopPropagation(); + const link = document.getElementById('model-guide-link'); + const url = link ? link.href : ''; + if (!url || !navigator.clipboard) return; + navigator.clipboard.writeText(url).then(() => showChanToast(t('guide.copied'), '', 'ok')).catch(() => {}); +}); +bindClick('preset-btn', (e) => { e.stopPropagation(); openModal('preset-modal'); }); +document.querySelectorAll('.modal').forEach(m => + m.addEventListener('click', (e) => { + if (e.target.closest('[data-close]')) { + m.hidden = true; + m.querySelectorAll('.field-limit-hint').forEach(h => h.style.display = 'none'); + } + })); +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 }); +} +const ALLOWED_URI_RE = /^(https?:|mailto:|tel:|#|\/)/i; +function escapeHtml(s) { + const d = document.createElement('div'); d.textContent = String(s == null ? '' : s); return d.innerHTML; +} +/** GA list_llms 形如 SessionClass/备注;桌面 UI 只展示 / 后一段 */ +function profileLabel(name) { + const s = String(name || ''); + const i = s.indexOf('/'); + return (i >= 0 ? s.slice(i + 1) : s).trim(); +} +function normalizeProfiles(list) { + return (list || []).map(p => ({ ...p, name: profileLabel(p.name) || p.name })); +} +function sanitizeMarkdown(html) { + const tpl = document.createElement('template'); + tpl.innerHTML = String(html); + const blocked = new Set(['SCRIPT','STYLE','IFRAME','OBJECT','EMBED','LINK','META','BASE','FORM','INPUT','BUTTON']); + const walker = document.createTreeWalker(tpl.content, NodeFilter.SHOW_ELEMENT); + const rmv = []; + while (walker.nextNode()) { + const el = walker.currentNode; + if (blocked.has(el.tagName)) { rmv.push(el); continue; } + for (const attr of Array.from(el.attributes)) { + const n = attr.name.toLowerCase(), v = attr.value.trim(); + if (n.startsWith('on') || n === 'srcdoc') { el.removeAttribute(attr.name); continue; } + if ((n === 'href' || n === 'src' || n === 'xlink:href') && v && !ALLOWED_URI_RE.test(v)) el.removeAttribute(attr.name); + } + if (el.tagName === 'A') { el.setAttribute('rel','noopener noreferrer'); el.setAttribute('target','_blank'); } + } + rmv.forEach(el => el.remove()); + return tpl.innerHTML; +} +/* ═══════════════ LaTeX 保护 (PR移植) ═══════════════ */ +const _latexSlots = []; +function protectLatex(text) { + _latexSlots.length = 0; + // 先保护代码围栏和行内代码,避免其中的 $ \( \[ 被误匹配 + const _codeSlots = []; + // 代码围栏 ```...``` + text = text.replace(/```[\s\S]*?```/g, (m) => { + const id = _codeSlots.length; + _codeSlots.push(m); + return `\x00CODE:${id}\x00`; + }); + // 行内代码 `...` + text = text.replace(/`[^`\n]+`/g, (m) => { + const id = _codeSlots.length; + _codeSlots.push(m); + return `\x00CODE:${id}\x00`; + }); + // 块级 \[...\] + text = text.replace(/\\\[([\s\S]+?)\\\]/g, (_, expr) => { + const id = _latexSlots.length; + _latexSlots.push({ expr: expr.trim(), display: true }); + return ``; + }); + // 块级 $$...$$ + text = text.replace(/\$\$([\s\S]+?)\$\$/g, (_, expr) => { + const id = _latexSlots.length; + _latexSlots.push({ expr: expr.trim(), display: true }); + return ``; + }); + // 行内 \(...\) + text = text.replace(/\\\(([\s\S]+?)\\\)/g, (_, expr) => { + const id = _latexSlots.length; + _latexSlots.push({ expr: expr.trim(), display: false }); + return ``; + }); + // 行内 $...$(不贪婪,排除 $$ 和转义) + text = text.replace(/(? { + const id = _latexSlots.length; + _latexSlots.push({ expr: expr.trim(), display: false }); + return ``; + }); + // 恢复代码占位符 + text = text.replace(/\x00CODE:(\d+)\x00/g, (_, i) => _codeSlots[Number(i)]); + return text; +} +function restoreLatex(html) { + if (!_latexSlots.length) return html; + return html.replace(//g, (_, i) => { + const slot = _latexSlots[Number(i)]; + if (!slot) return ''; + if (typeof katex === 'undefined') { + return slot.display ? `
${escapeHtml(slot.expr)}
` + : `${escapeHtml(slot.expr)}`; + } + try { + const rendered = katex.renderToString(slot.expr, { displayMode: slot.display, throwOnError: false }); + return slot.display ? `
${rendered}
` + : `${rendered}`; + } catch (_) { return escapeHtml(slot.expr); } + }); +} + +function renderMarkdown(text) { + if (typeof marked === 'undefined') return escapeHtml(text).replace(/\n/g, '
'); + try { + const protected_ = protectLatex(String(text || '')); + let html = sanitizeMarkdown(marked.parse(protected_)); + html = restoreLatex(html); + // TUI 风格代码块:包装 pre>code 为 .code-block 容器 + 语言头 + html = html.replace(/
]*>([\s\S]*?)<\/code><\/pre>/g,
+      (_, lang, body) => {
+        const label = lang || 'code';
+        return `
${escapeHtml(label)}
${body}
`; + }); + return html; + } catch (_) { return escapeHtml(text); } +} +/** + * Agent 流协议(与 agent_loop.py / continue_cmd 一致)按行解析: + * - 工具调用:🛠️ 行 + 开围栏行 `` `{n}text `` + 正文 + 闭围栏行(仅 `{n},取区间内最后一行) + * - 工具结果:开围栏行 `` `{n} ``(n≥5)+ 正文 + 同长度闭围栏行 + */ +function parseAgentFenceLine(line) { + const m = /^[ \t]*(`{3,})([^\n`]*)[ \t]*$/.exec(line ?? ''); + if (!m) return null; + return { ticks: m[1].length, tag: m[2] }; +} + +function isAgentStructureBoundaryLine(line, opts) { + if (/^🛠️ Tool:/.test(line)) return true; + // 工具「结果」区内:5 反引号是开/闭围栏,不能当边界(否则闭围栏会被当成下一结构 → 拆出多个空「工具结果」) + if (!opts || !opts.forToolResult) { + const f = parseAgentFenceLine(line); + if (f && f.ticks >= 5 && f.tag === '') return true; + } + if (/^\*\*LLM Running \(Turn \d+\)/.test(line)) return true; + if (/^/i.test(line)) return true; + return false; +} + +function indexOfNextAgentStructureLine(lines, from, opts) { + for (let i = from; i < lines.length; i++) { + if (isAgentStructureBoundaryLine(lines[i], opts)) return i; + } + return lines.length; +} + +function lastFenceCloseLineIndex(lines, from, toExclusive, tickCount) { + let last = -1; + for (let i = from; i < toExclusive; i++) { + const f = parseAgentFenceLine(lines[i]); + if (f && f.ticks === tickCount && f.tag === '') last = i; + } + return last; +} + +function parseToolCallBlock(lines, i) { + const m = /^🛠️ Tool: `([^`]+)`/.exec(lines[i] || ''); + if (!m) return null; + const open = parseAgentFenceLine(lines[i + 1]); + if (!open || open.tag !== 'text') return null; + const bodyStart = i + 2; + const zoneEnd = indexOfNextAgentStructureLine(lines, bodyStart); + const closeIdx = lastFenceCloseLineIndex(lines, bodyStart, zoneEnd, open.ticks); + if (closeIdx < 0) return null; + return { + name: m[1], + body: lines.slice(bodyStart, closeIdx).join('\n'), + nextLine: closeIdx + 1, + }; +} + +function parseToolResultBlock(lines, i) { + const open = parseAgentFenceLine(lines[i]); + if (!open || open.ticks < 5 || open.tag !== '') return null; + const bodyStart = i + 1; + const zoneEnd = indexOfNextToolResultZoneEnd(lines, bodyStart); + const closeIdx = lastFenceCloseLineIndex(lines, bodyStart, zoneEnd, open.ticks); + if (closeIdx < 0) return null; + return { + body: lines.slice(bodyStart, closeIdx).join('\n'), + nextLine: closeIdx + 1, + }; +} + +/** 工具结果区 zone:不把 5 反引号围栏行当边界(见 isAgentStructureBoundaryLine) */ +function indexOfNextToolResultZoneEnd(lines, from) { + return indexOfNextAgentStructureLine(lines, from, { forToolResult: true }); +} + +/** 流式未闭合工具调用(对齐 TUI _safe_pos:末尾 in-flight 🛠️ 块) */ +function parseInFlightToolCall(lines, i) { + if (parseToolCallBlock(lines, i)) return null; + const m = /^🛠️ Tool: `([^`]+)`/.exec(lines[i] || ''); + if (!m) return null; + const open = parseAgentFenceLine(lines[i + 1]); + let bodyStart; + let zoneEnd; + if (open && open.tag === 'text') { + bodyStart = i + 2; + zoneEnd = indexOfNextAgentStructureLine(lines, bodyStart); + if (lastFenceCloseLineIndex(lines, bodyStart, zoneEnd, open.ticks) >= 0) return null; + } else { + bodyStart = i + 1; + zoneEnd = lines.length; + for (let j = i + 1; j < lines.length; j++) { + if (isAgentStructureBoundaryLine(lines[j])) { zoneEnd = j; break; } + } + } + return { + name: m[1], + body: lines.slice(bodyStart, zoneEnd).join('\n'), + nextLine: zoneEnd, + inFlight: true, + }; +} + +/** 流式未闭合工具结果(5 反引号围栏未到) */ +function parseInFlightToolResult(lines, i) { + if (parseToolResultBlock(lines, i)) return null; + const open = parseAgentFenceLine(lines[i]); + if (!open || open.ticks < 5 || open.tag !== '') return null; + const bodyStart = i + 1; + const zoneEnd = indexOfNextToolResultZoneEnd(lines, bodyStart); + if (lastFenceCloseLineIndex(lines, bodyStart, zoneEnd, open.ticks) >= 0) return null; + return { + body: lines.slice(bodyStart, zoneEnd).join('\n'), + nextLine: zoneEnd, + inFlight: true, + }; +} + +/** 将 agent 协议块替换为占位符,其余行原样保留给 Markdown */ +function foldAgentProtocolBlocks(body, { onTool, onResult }) { + const lines = String(body || '').split('\n'); + const out = []; + let proseFrom = 0; + let i = 0; + + const flushProse = (until) => { + if (until <= proseFrom) return; + out.push(lines.slice(proseFrom, until).join('\n')); + proseFrom = until; + }; + + while (i < lines.length) { + const tool = parseToolCallBlock(lines, i); + if (tool) { + flushProse(i); + out.push(onTool(tool.name, tool.body)); + i = tool.nextLine; + proseFrom = i; + continue; + } + const result = parseToolResultBlock(lines, i); + if (result) { + flushProse(i); + out.push(onResult(result.body)); + i = result.nextLine; + proseFrom = i; + continue; + } + const liveTool = parseInFlightToolCall(lines, i); + if (liveTool) { + flushProse(i); + out.push(onTool(liveTool.name, liveTool.body, { inFlight: true })); + i = liveTool.nextLine; + proseFrom = i; + continue; + } + const liveResult = parseInFlightToolResult(lines, i); + if (liveResult) { + flushProse(i); + out.push(onResult(liveResult.body, { inFlight: true })); + i = liveResult.nextLine; + proseFrom = i; + continue; + } + i++; + } + flushProse(lines.length); + return out.join(''); +} + +function extractAskUserToolJson(content) { + const lines = String(content || '').split('\n'); + for (let i = 0; i < lines.length; i++) { + const block = parseToolCallBlock(lines, i); + if (block && block.name === 'ask_user') return block.body; + } + return null; +} + +// ============================================================================ +// [turn_segs 数据结构层] —— 单轮渲染的纯函数,供 append-only draft 与静态消息复用 +// 设计:每个函数自包含(内部自建 folds/asks 占位栈,渲染完即还原),轮间零共享状态。 +// renderTurnBody(body) : 单轮原文 → 该轮内层HTML(块级折叠thinking/tool/result) +// renderTurnFold(body, turnIndex) : 单轮原文 → 旧轮的
折叠壳(内部下标0起,标题显示1起+summary副标题) +// 结构化 turn_segs 渲染使用的纯函数:折叠工具块、ask_user 与轮摘要。 +// ============================================================================ +// 去除 GenericAgent 轮次分隔标记;turn_segs 已结构化,不应展示原始 marker +function stripTurnMarker(body) { + return String(body || '') + .replace(/^\s*\**LLM Running \(Turn \d+\) \.\.\.\**\s*/i, ''); +} + +function renderTurnBody(body) { + // 自包含:每次调用独立的占位栈,渲染完立即还原,无跨调用共享状态 + const folds = []; + const asks = []; + const stash = (label, b, cls, opts) => { + folds.push({ label, body: b, cls: cls || '', open: !!(opts && opts.open) }); + return `\n\n§§FOLD:${folds.length - 1}§§\n\n`; + }; + const stashAsk = (data) => { asks.push(data); return `\n\n§§ASK:${asks.length - 1}§§\n\n`; }; + let s = stripTurnMarker(body); + s = s.replace(/[\s\S]*?<\/thinking>/gi, m => stash(t('fold.thinking'), m.replace(/<\/?thinking>/gi, ''), 'fold-thinking')); + s = foldAgentProtocolBlocks(s, { + onTool(name, json, meta) { + if (name === 'ask_user' && !meta?.inFlight) { + const data = parseAskUserJson(json); + if (data && normalizeAskUserData(data)) return stashAsk(data); + } + const live = !!meta?.inFlight; + return stash(`${t('fold.tool')}: ${name}${live ? ' …' : ''}`, json, + live ? 'fold-tool fold-tool-live' : 'fold-tool', { open: live }); + }, + onResult(b, meta) { + const live = !!meta?.inFlight; + return stash(`${t('fold.toolResult')}${live ? ' …' : ''}`, b, + live ? 'fold-result fold-tool-live' : 'fold-result', { open: live }); + }, + }); + s = s.replace(/[\s\S]*?<\/function_calls>/gi, m => stash(t('fold.tool'), m, 'fold-tool')); + s = s.replace(/[\s\S]*?<\/function_results>/gi, m => stash(t('fold.toolResult'), m, 'fold-result')); + s = s.replace(/([\s\S]*?)<\/summary>/gi, (_, inner) => `
${inner}
`); + let html = renderMarkdown(s); + // 还原占位符 + html = html + .replace(/§§ASK:(\d+)§§/g, (_, i) => { + const data = asks[Number(i)]; + return data ? renderAskUserNotice(data) : ''; + }) + .replace(/§§FOLD:(\d+)§§/g, (_, i) => { + const f = folds[Number(i)]; + if (!f) return ''; + const openAttr = f.open ? ' open' : ''; + return `
${escapeHtml(f.label)}
${escapeHtml(f.body)}
`; + }); + return html; +} + +// 抽出该轮首个 文本作为折叠头副标题;无则回退提取工具名列表 +function extractTurnSummaryPure(raw) { + const m = /([\s\S]*?)<\/summary>/i.exec(raw || ''); + if (m) return m[1].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); + const tools = []; + const toolRe = /🛠️\s*Tool:\s*`([^`]+)`/g; + let tm; + while ((tm = toolRe.exec(raw || '')) !== null) { + if (!tools.includes(tm[1])) tools.push(tm[1]); + } + return tools.length ? tools.join(', ') : ''; +} + +// 旧轮折叠壳:内部 turnIndex 从 0 起;UI 标题显示为 1 起。调用方一律传内部下标。 +function turnDisplayNo(turnIndex) { + return Math.max(0, Number(turnIndex) || 0) + 1; +} +function renderTurnFold(body, turnIndex) { + const raw = stripTurnMarker(body); + const sum = extractTurnSummaryPure(raw); + const bodyForRender = sum ? raw.replace(/[\s\S]*?<\/summary>\s*/i, '') : raw; + const inner = renderTurnBody(bodyForRender); + const turnLabel = t('fold.turn').replace('{n}', turnDisplayNo(turnIndex)); + const head = sum + ? `${escapeHtml(turnLabel)}:${escapeHtml(sum)}` + : escapeHtml(turnLabel); + return `
${head}${inner}
`; +} + +function lastInflightBlockBody(body) { + const lines = String(body || '').split('\n'); + let last = null; + for (let i = 0; i < lines.length; i++) { + const liveTool = parseInFlightToolCall(lines, i); + if (liveTool?.inFlight) { last = { body: liveTool.body }; i = liveTool.nextLine - 1; continue; } + const closedTool = parseToolCallBlock(lines, i); + if (closedTool) { last = null; i = closedTool.nextLine - 1; continue; } + const liveRes = parseInFlightToolResult(lines, i); + if (liveRes?.inFlight) { last = { body: liveRes.body }; i = liveRes.nextLine - 1; continue; } + const closedRes = parseToolResultBlock(lines, i); + if (closedRes) { last = null; i = closedRes.nextLine - 1; continue; } + } + return last; +} + +function tryPatchInflightToolDom(curEl, body, prevBody) { + if (!prevBody || body.length < prevBody.length || !body.startsWith(prevBody)) return false; + if (!curEl.querySelector('details.fold-tool-live')) return false; + const prevBlock = lastInflightBlockBody(prevBody); + const curBlock = lastInflightBlockBody(body); + if (!curBlock || !prevBlock || !curBlock.body.startsWith(prevBlock.body)) return false; + const liveFolds = curEl.querySelectorAll('details.fold-tool-live'); + const pre = liveFolds[liveFolds.length - 1]?.querySelector('.fold-pre'); + if (!pre) return false; + pre.textContent = curBlock.body; + return true; +} + +function parseAskUserJson(raw) { + if (raw == null) return null; + const txt = String(raw).trim(); + if (!txt) return null; + try { return JSON.parse(txt); } catch (_) {} + try { + let out = ''; + let inStr = false; + let esc = false; + for (let i = 0; i < txt.length; i++) { + const c = txt[i]; + if (esc) { out += c; esc = false; continue; } + if (c === '\\') { out += c; esc = true; continue; } + if (c === '"') { inStr = !inStr; out += c; continue; } + if (inStr) { + if (c === '\n') out += '\\n'; + else if (c === '\r') out += '\\r'; + else if (c === '\t') out += '\\t'; + else if (c.charCodeAt(0) < 0x20) out += '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'); + else out += c; + } else out += c; + } + return JSON.parse(out); + } catch (_) {} + return null; +} + +function normalizeAskUserData(data) { + const raw = data || {}; + const question = String(raw.question || '').trim(); + if (!question) return null; + const cs = raw.candidates || []; + const candidates = Array.isArray(cs) + ? cs.map(x => String(x == null ? '' : x)).filter(x => x.trim()) + : []; + return { question, candidates }; +} + +/** 格式化 ask_user 题干:编号与正文同行;无空行时在 2./3. 前分段 */ +function formatAskUserQuestion(text) { + let s = String(text || '').trim(); + if (!s) return s; + // 「1.\n正文」→「1. 正文」 + s = s.replace(/^(\d+[.、::)])\s*\n+\s*/gm, '$1 '); + s = s.replace(/(\n)(\d+[.、::)])\s*\n+\s*/g, '$1$2 '); + s = s.replace(/(\n|^)(问题\s*\d+\s*[::.、)]?)\s*\n+\s*/gi, '$1$2 '); + // 题与题之间:尚无空行时,仅在 2./3. 前插入空行(不动 1. 与题干) + if (!/\n\s*\n/.test(s)) { + s = s.replace(/(\S)\s+(?=问题\s*[2-9]\d*\s*[::.、)]?\s*)/gi, '$1\n\n'); + s = s.replace(/(\S)\s+(?=[2-9]\d*[.、::)]\s+\S)/g, '$1\n\n'); + } + return boldAskQuestionLines(s); +} + +function boldAskQuestionLines(text) { + return String(text || '').split('\n').map(line => { + const t = line.trim(); + if (!t || /^\*\*.+\*\*$/.test(t)) return line; + if (/^\d+[.、::)]\s+\S/.test(t)) return '**' + t + '**'; + if (/^问题\s*\d+/i.test(t)) return '**' + t + '**'; + if (/[??]\s*$/.test(t) && !/^[A-Da-d][.)]\s/.test(t)) return '**' + t + '**'; + return line; }).join('\n'); } -function renderDiagnostics() { - if (diagnosticsLogEl) diagnosticsLogEl.textContent = formatDiagnostics(); +function markAskOptionHtml(html) { + let out = String(html || ''); + out = out.replace(/

([^<]*[A-Da-d][.)]\s[^<]*)<\/p>/gi, '

$1

'); + out = out.replace(/()\s*([A-Da-d][.)]\s[^<]+)/gi, '$2'); + return out; } -function openDiagnostics() { - renderDiagnostics(); - diagnosticsPanel.classList.remove('hidden'); +/** 预览模式:true = 始终显示 candidates;false = 题干已含选项/多题时不重复渲染底部列表 */ +const ASK_USER_ALWAYS_SHOW_CANDIDATES = false; + +/** 题干已含选项/多题,或 candidates 无法与题干对应时,不再重复渲染底部列表 */ +function shouldShowAskCandidates(item) { + if (!item || !item.candidates.length) return false; + if (ASK_USER_ALWAYS_SHOW_CANDIDATES) return true; + const q = item.question; + if (/两个问题|多个问题|两道|两题/.test(q)) return false; + if ((q.match(/问题\s*\d/gi) || []).length >= 2) return false; + if ((q.match(/^[ \t]*\d+[.、::)]\s+/gm) || []).length >= 2) return false; + if ((q.match(/^[ \t]*[A-Da-d][.)]\s/mg) || []).length >= 2) return false; + const comboN = item.candidates.filter(c => /\d+[A-Da-d]\s*\+\s*\d+[A-Da-d]/i.test(c)).length; + if (comboN >= Math.max(1, Math.ceil(item.candidates.length * 0.5))) return false; + // 题干里有多道问句,却把全部选项平铺在 candidates → 无法区分归属,不展示 + const qMarks = (q.match(/[??]/g) || []).length; + if (qMarks >= 2 && item.candidates.length > 4) return false; + return true; } -function closeDiagnostics() { - diagnosticsPanel.classList.add('hidden'); + +function renderAskUserNotice(data) { + const item = normalizeAskUserData(data); + if (!item) return ''; + // 单题与多题统一处理:多题的选项本就内联在题干里;单题的选项放在 candidates 里, + // 这里把它折叠进题干,按同样的 A./B./C. 内联方式渲染,不再单独画一个编号列表。 + const question = foldAskCandidates(item); + const qHtml = markAskOptionHtml(renderMarkdown(formatAskUserQuestion(question))); + return `
+
+ ${escapeHtml(t('ask.banner'))} + + ${escapeHtml(t('ask.replyHint'))} +
+ ${qHtml ? `
${qHtml}
` : ''} +
`; } -async function copyDiagnostics() { - const text = formatDiagnostics(); - try { - await navigator.clipboard.writeText(text); - addDiagnostic('info', 'Diagnostics copied to clipboard'); - } catch (err) { - addDiagnostic('error', 'Failed to copy diagnostics', err); - showError('Failed to copy diagnostics: ' + (err.message || err), null, null, { skipDiagnostic: true }); +/** 单题的 candidates 折叠进题干(统一成 A./B./C. 内联选项);多题或无法对应时原样返回题干 */ +function foldAskCandidates(item) { + if (!shouldShowAskCandidates(item)) return item.question; + const opts = item.candidates.map((c, j) => { + const label = String(c).replace(/^\s*(?:[A-Za-z]|\d{1,2})\s*[.)、::]\s*/, '').trim(); + return `${String.fromCharCode(65 + j)}. ${label}`; + }).join('\n'); + // 用单换行(而非空行)拼进题干,让题干+选项渲染成同一个

,每个选项都跟在
后面 — + // 与多题内联选项走完全一致的 .ask-option-line 缩进,避免首项 A 贴左边、B/C/D 缩进的错位。 + return item.question.replace(/\s+$/, '') + '\n' + opts; +} + +function askUserPlaceholder(item) { + // 单题与多题统一:都用自由作答提示,不再针对单题单独显示「输入 1/2/3 选择」 + return t('ask.placeholderOpen'); +} + +function assistantStructuredText(msg) { + if (!msg || msg.role !== 'assistant') return ''; + if (Array.isArray(msg.turn_segs) && msg.turn_segs.length) return msg.turn_segs.join('\n'); + return typeof msg.content === 'string' ? msg.content : ''; +} + +function getPendingAskUser(sess) { + if (!sess || rt(sess).busy) return null; + const msgs = sess.messages || []; + let lastAskIdx = -1; + let askData = null; + for (let i = msgs.length - 1; i >= 0; i--) { + if (msgs[i].role !== 'assistant') continue; + const json = extractAskUserToolJson(assistantStructuredText(msgs[i])); + if (json != null) { + lastAskIdx = i; + askData = normalizeAskUserData(parseAskUserJson(json)); + break; + } } + if (!askData) return null; + const replied = msgs.slice(lastAskIdx + 1).some(m => m.role === 'user'); + return replied ? null : askData; } -function clearDiagnostics() { - const sess = state.sessions.get(state.activeId); - if (sess) sess.diagnostics = []; - renderDiagnostics(); -} - -// ─── Markdown ───────────────────────────────────────────────────────────── -if (typeof marked !== 'undefined') { - marked.setOptions({ - gfm: true, - breaks: true, - mangle: false, - headerIds: false +function syncAskUserUi() { + const sess = activeSess(); + const pending = sess ? getPendingAskUser(sess) : null; + const notices = [...document.querySelectorAll('.ask-user-notice')]; + notices.forEach((el, i) => { + const isLast = i === notices.length - 1; + el.classList.toggle('is-active', !!pending && isLast); + el.classList.toggle('is-answered', !pending || !isLast); }); + if (inputEl) inputEl.setAttribute('data-ph', pending ? askUserPlaceholder(pending) : t('composer.placeholder')); // contenteditable 用 data-ph(无 placeholder 属性) + if (composerEl) composerEl.classList.toggle('is-awaiting-answer', !!pending); } -const ALLOWED_URI_RE = /^(https?:|mailto:|tel:|#|\/)/i; +/* ═══════════════ 渲染后增强 (PR移植) ═══════════════ */ +/* ───────────── 统一复制 SVG Icon ───────────── */ +// Phosphor 图标助手:把 window.gaIcon(name) 包一层,给动态渲染的 UI 用,与静态 [data-ga-icon] 保持一致 +const GA_ICON = (name, className = '') => (typeof window.gaIcon === 'function' ? window.gaIcon(name, className) : ''); +const SVG_COPY_ICON = GA_ICON('copy'); +const SVG_CHECK_ICON = GA_ICON('check'); -function renderMarkdown(text) { - if (typeof marked === 'undefined') { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - } - try { - return sanitizeMarkdown(marked.parse(text)); - } catch (e) { - return escapeHtml(text); - } -} - -function sanitizeMarkdown(html) { - const template = document.createElement('template'); - template.innerHTML = String(html); - const blockedTags = new Set(['SCRIPT', 'STYLE', 'IFRAME', 'OBJECT', 'EMBED', 'LINK', 'META', 'BASE', 'FORM', 'INPUT', 'BUTTON']); - const walker = document.createTreeWalker(template.content, NodeFilter.SHOW_ELEMENT); - const removals = []; - while (walker.nextNode()) { - const el = walker.currentNode; - if (blockedTags.has(el.tagName)) { - removals.push(el); - continue; +function postRenderEnhance(containerEl) { + if (!containerEl) return; + // 代码高亮 + 复制按钮(.code-block 容器已自带头部复制按钮,跳过) + containerEl.querySelectorAll('pre code').forEach(block => { + if (typeof hljs !== 'undefined') hljs.highlightElement(block); + if (block.closest('.code-block')) return; // TUI 风格容器已有复制按钮 + if (!block.parentElement.querySelector('.code-copy-btn')) { + const btn = document.createElement('button'); + btn.className = 'code-copy-btn'; btn.innerHTML = SVG_COPY_ICON; + btn.title = t('act.copy'); + btn.onclick = () => { + navigator.clipboard.writeText(block.textContent).then(() => { + btn.innerHTML = SVG_CHECK_ICON; setTimeout(() => btn.innerHTML = SVG_COPY_ICON, 1500); + }); + }; + block.parentElement.style.position = 'relative'; + block.parentElement.appendChild(btn); } - for (const attr of Array.from(el.attributes)) { - const name = attr.name.toLowerCase(); - const value = attr.value.trim(); - if (name.startsWith('on') || name === 'srcdoc') { - el.removeAttribute(attr.name); - continue; - } - if ((name === 'href' || name === 'src' || name === 'xlink:href') && value && !ALLOWED_URI_RE.test(value)) { - el.removeAttribute(attr.name); - } - } - if (el.tagName === 'A') { - el.setAttribute('rel', 'noopener noreferrer'); - el.setAttribute('target', '_blank'); - } - } - for (const el of removals) el.remove(); - return template.innerHTML; -} - - -function detectStructuredKind(line) { - const trimmed = String(line || '').trim(); - const m = trimmed.match(/^(TOOL_RECALL|TOOL_REQUEST|TOOL_RESPONSE|COWORK|TUNR|TURN|ACTION|OBSERVATION|THOUGHT|TOOL)[\s:_-]*(.*)$/i); - if (m) return { kind: m[1].toUpperCase(), rest: (m[2] || '').trim() }; - - // GenericAgent's ACP bridge currently streams tool calls/results as plain - // assistant text, not as ACP `tool_call` notifications. Recognize the real - // XML-ish markers so streamed code_run/file_read/etc. blocks are folded. - if (/^]*>/i.test(trimmed) || /^]*\bname=["'][^"']+["'][^>]*>/i.test(trimmed)) { - return { kind: 'TOOL_CALL', rest: trimmed }; - } - if (/^]*>/i.test(trimmed) || /^]*>/i.test(trimmed)) { - return { kind: 'TOOL_RESULT', rest: trimmed }; - } - return null; -} - -function isStructuredClosingLine(line, kind, textSoFar) { - const trimmed = String(line || '').trim(); - const block = String(textSoFar || ''); - if (kind === 'TOOL_CALL') { - if (/^<\/function_calls>$/i.test(trimmed)) return true; - // Single-invoke streams may omit the wrapper. - return /^<\/invoke>$/i.test(trimmed) && !/^\s*$/i.test(trimmed)) return true; - // Single-result streams may omit the wrapper. - return /^<\/result>$/i.test(trimmed) && !/^\s* tag content only - const summaryMatch = raw.match(/

\s*([\s\S]*?)\s*<\/summary>/i); - if (summaryMatch) { - const line = summaryMatch[1].trim().split('\n')[0] || kind; - return line.length > 96 ? line.slice(0, 96) + '…' : line; - } - // No summary tag: show kind only (no body text leakage) - if (kind === 'LLM_RUNNING') return 'LLM Running'; - return kind; -} - -const LLM_RUNNING_MARKER_RE = /(\**LLM Running \(Turn \d+\) \.\.\.\**)/g; - -function splitLLMRunningSegments(raw) { - const placeholders = []; - const protect = value => { - placeholders.push(value); - return `\u0000PH${placeholders.length - 1}\u0000`; - }; - let safe = String(raw || '').replace(/`{4,}[\s\S]*?`{4,}/g, protect); - safe = safe.replace(/`{4,}[^`][\s\S]*$/g, protect); - const restore = value => String(value || '').replace(/\u0000PH(\d+)\u0000/g, (_, i) => placeholders[Number(i)] || ''); - const parts = safe.split(LLM_RUNNING_MARKER_RE).map(restore); - if (parts.length < 4) return null; - const segments = []; - if (parts[0] && parts[0].trim()) segments.push({ kind: 'agent_message_chunk', text: parts[0].trimEnd() }); - const turns = []; - for (let i = 1; i < parts.length; i += 2) { - turns.push({ marker: parts[i] || '', content: parts[i + 1] || '' }); - } - turns.forEach((turn, idx) => { - const text = `${turn.marker}${turn.content}`.trimEnd(); - if (!text) return; - // Match Streamlit: historical/intermediate LLM Running turns are folded; - // the latest turn remains plain so final answers are not hidden by default. - segments.push({ kind: idx < turns.length - 1 ? 'LLM_RUNNING' : 'agent_message_chunk', text }); }); - return segments.length ? segments : null; -} - -function splitStructuredSegments(text) { - const raw = String(text || ''); - const llmSegments = splitLLMRunningSegments(raw); - if (llmSegments) return llmSegments; - const lines = raw.split(/\r?\n/); - const segments = []; - let buf = []; - let kind = 'agent_message_chunk'; - let inFence = false; - const flush = () => { - if (!buf.length) return; - segments.push({ kind, text: buf.join('\n').trimEnd() }); - buf = []; - }; - for (const line of lines) { - const fence = /^\s*```/.test(line); - const hit = !inFence ? detectStructuredKind(line) : null; - if (hit && hit.kind !== kind) { - flush(); - kind = hit.kind; - buf.push(line); - } else { - buf.push(line); - } - if (!inFence && kind !== 'agent_message_chunk' && isStructuredClosingLine(line, kind, buf.join('\n'))) { - flush(); - kind = 'agent_message_chunk'; - } - if (fence) inFence = !inFence; - } - flush(); - return segments.length ? segments : [{ kind: 'agent_message_chunk', text: raw }]; -} - -function hasUnfencedStructuredMarker(text) { - let inFence = false; - for (const line of String(text || '').split(/\r?\n/)) { - const fence = /^\s*```/.test(line); - if (!inFence && detectStructuredKind(line)) return true; - if (fence) inFence = !inFence; - } - return false; -} - -function shouldFoldSegment(kind, text) { - return kind !== 'agent_message_chunk' || hasUnfencedStructuredMarker(text); -} - -function getNowMs() { - if (typeof performance !== 'undefined' && typeof performance.now === 'function') return Math.round(performance.now()); - return Date.now(); -} - -function formatDuration(ms) { - const value = Number(ms); - if (!Number.isFinite(value) || value < 0) return ''; - if (value < 1000) return `${Math.max(1, Math.round(value))}ms`; - if (value < 60000) return `${(value / 1000).toFixed(value < 10000 ? 1 : 0)}s`; - const minutes = Math.floor(value / 60000); - const seconds = Math.round((value % 60000) / 1000); - return `${minutes}m ${seconds}s`; -} - -function formatTaskElapsed(ms, ended) { - const totalSeconds = Math.max(0, Math.floor(Number(ms) / 1000)); - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = totalSeconds % 60; - const parts = []; - if (hours) parts.push(`${hours}h`); - if (hours || minutes) parts.push(`${minutes}min`); - parts.push(`${seconds}s`); - const elapsed = parts.join(' '); - if (ended) return `Done ✓ ${elapsed}`; - const spinner = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'; - const frame = spinner[Math.floor(Date.now() / 1000) % spinner.length]; - return `${frame} ${elapsed}`; -} - -function getSessionRuntime(sess) { - if (!sess) return null; - const sessionId = sess.id; - let runtime = state.runtimeBySessionId.get(sessionId); - if (!runtime) { - runtime = { - busy: false, - currentTurnEl: null, - lastMessageType: null, - taskStartedAt: 0, - taskTimerId: null, - assistantDraft: null, - + // TUI 代码块头部复制按钮绑定 + containerEl.querySelectorAll('.code-block-copy').forEach(btn => { + if (btn.dataset.bound) return; + btn.dataset.bound = '1'; + btn.onclick = () => { + const code = btn.closest('.code-block').querySelector('code'); + if (!code) return; + navigator.clipboard.writeText(code.textContent.trim()).then(() => { + btn.textContent = '\u2713'; + setTimeout(() => { btn.textContent = '\u29C9'; }, 1500); + }); }; - state.runtimeBySessionId.set(sessionId, runtime); + }); + // KaTeX 复制按钮 + containerEl.querySelectorAll('.katex-block').forEach(el => { + if (el.querySelector('.latex-copy-btn')) return; + const src = el.querySelector('annotation[encoding="application/x-tex"]'); + if (!src) return; + const btn = document.createElement('button'); + btn.className = 'latex-copy-btn'; btn.textContent = '\u29C9'; + btn.title = t('act.copyTex'); + btn.onclick = () => { + navigator.clipboard.writeText(src.textContent).then(() => { + btn.textContent = '\u2713'; setTimeout(() => btn.textContent = '\u29C9', 1500); + }); + }; + el.style.position = 'relative'; + el.appendChild(btn); + }); + syncAskUserUi(); +} + + +/* ═══════════════ 状态 ═══════════════ */ +const state = { + sessions: new Map(), activeId: null, bridgeReady: false, + llmNo: 0, modelProfiles: [], modelName: null, + runtime: new Map(), + pendingFiles: [], + fileSeq: 0, +}; +function rt(sess) { + let r = state.runtime.get(sess.id); + if (!r) { r = { polling:false, busy:false, lastId:0, seen:new Set(), draftEl:null, draftSegs:null, draftTurn:0, taskStartedAt:null, taskEndedAt:null, taskTimerId:null, planCompleteAt:null, planLostAt:null, planHoldItems:[], planLastPayload:null, planLastComplete:false, planHideTimer:null, planDismissedComplete:false, planCollapsed:false, planShowAll:false }; state.runtime.set(sess.id, r); } + return r; +} +const activeSess = () => state.sessions.get(state.activeId) || null; +const isActive = (sess) => sess && sess.id === state.activeId; + +function saveSessions() {} +function patchSession(sess, fields) { + if (!sess.bridgeSessionId) return; + fetch(`${BRIDGE_ORIGIN}/session/${encodeURIComponent(sess.bridgeSessionId)}`, { + method: 'PATCH', headers: {'Content-Type':'application/json'}, body: JSON.stringify(fields) + }).catch(() => {}); +} +async function loadSessions() { + try { + const res = await fetch(`${BRIDGE_ORIGIN}/sessions`); + const data = await res.json(); + if (!data.sessions) return; + for (const s of data.sessions) { + state.sessions.set(s.id, { + id: s.id, bridgeSessionId: s.id, title: s.title, + messages: [], untitled: s.untitled ?? true, + pinned: s.pinned ?? false, lastActiveTs: s.updatedAt || s.createdAt + }); + } + // 刷新后固定恢复「上次正在看的会话」(前端持久化的 ga_active),而不是 bridge 的 + // activeSessionId(=最近更新的会话,会随后台会话变动而跳来跳去)。没有有效的已存 + // 会话则置空 → 显示「新会话」空态,由用户自己点选。 + const savedActive = localStorage.getItem('ga_active'); + state.activeId = (savedActive && state.sessions.has(savedActive)) ? savedActive : null; + } catch (_) {} +} + +/* ═══════════════ DOM refs ═══════════════ */ +const chatPage = document.querySelector('.page[data-page="chat"]'); +const msgArea = chatPage.querySelector('.msg-area'); +const chatStart = msgArea.querySelector('.chat-start'); +const inputEl = document.getElementById('chat-input'); +const sendBtn = document.getElementById('send-btn'); +const planBarEl = document.getElementById('plan-bar'); +const composerEl = document.getElementById('chat-composer'); +const msgLoading = document.getElementById('msg-loading'); +const sessionLoadingEl = document.getElementById('session-loading'); +const MIN_MSG_LOADING_MS = 450; +const HYDRATE_LOADING_TIMEOUT_MS = 10000; +const POLL_MSG_LIMIT = 200; +const PLAN_LOST_GRACE_MS = 1500; // tuiapp_v2._PLAN_LOST_GRACE_SEC +const PLAN_COMPLETE_GRACE_MS = 3000; // tuiapp_v2._PLAN_GRACE_SEC + +function isPlanPresetPrompt(text) { + const p = String(text || '').toLowerCase(); + return p.includes('plan_sop') || p.includes('plan 模式') || p.includes('plan mode'); +} +let _submitInFlight = false; +const runToggle = document.getElementById('run-toggle'); +const chatStatus = pageStatusBar(runToggle); +const runLabel = runToggle?.querySelector('.rs-label'); +const convListEl = document.querySelector('.conv-list'); +const newConvBtn = document.querySelector('.new-conv'); +const searchInput = document.querySelector('.search input'); +const rpResize = document.getElementById('rp-resize'); +const rpPanel = document.getElementById('rightpanel'); +const bodyEl = document.querySelector('.body'); +/* 每个页面的 page-top 各自挂一对 hamburger / 会话 按钮(.pt-sb-toggle / .pt-rp-toggle), + 全部绑同一个 toggle,效果跟以前的单一 sb-toggle/rp-toggle 一样,只是入口变成顶栏。 */ +document.querySelectorAll('.pt-sb-toggle').forEach(b => b.addEventListener('click', () => bodyEl.classList.toggle('sb-collapsed'))); +document.querySelectorAll('.pt-rp-toggle').forEach(b => b.addEventListener('click', () => bodyEl.classList.toggle('rp-collapsed'))); + +const sbResize = document.getElementById('sb-resize'); +const sbPanel = document.querySelector('.sidebar'); + +// 通用拖拽:dir=+1 拖动 →clientX 增大就增宽(左侧栏);dir=-1 反之(右侧) +function bindResize(handle, panel, dir, min, max) { + if (!handle || !panel) return; + let dragging = false, startX = 0, startW = 0; + handle.addEventListener('mousedown', (e) => { + dragging = true; startX = e.clientX; startW = panel.offsetWidth; + handle.classList.add('dragging'); + panel.style.transition = 'none'; // 拖拽期间禁用 transition,避免宽度动画延迟 + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + e.preventDefault(); + }); + document.addEventListener('mousemove', (e) => { + if (!dragging) return; + const w = Math.min(max, Math.max(min, startW + dir * (e.clientX - startX))); + panel.style.width = w + 'px'; + panel.style.flex = '0 0 ' + w + 'px'; + }); + document.addEventListener('mouseup', () => { + if (!dragging) return; + dragging = false; + handle.classList.remove('dragging'); + panel.style.transition = ''; // 恢复 CSS transition(按钮折叠动画仍生效) + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }); +} +bindResize(rpResize, rpPanel, -1, 160, 400); // 右栏:cursor 左移 → 增宽 +bindResize(sbResize, sbPanel, +1, 180, 360); // 左栏:cursor 右移 → 增宽 +const modelChip = document.getElementById('model-chip'); +const modelNameEl= modelChip ? modelChip.querySelector('.model-name') : null; +// conductor 页面也有一个独立的模型 chip,共用一份模型数据 +const collabModelChip = document.getElementById('cdb-model-chip'); +const collabModelNameEl = collabModelChip ? collabModelChip.querySelector('.model-name') : null; + +let msgsEl = null; +function ensureMsgs() { + if (!msgsEl) { + msgsEl = document.createElement('div'); + msgsEl.className = 'msgs'; + msgArea.insertBefore(msgsEl, msgLoading || null); } - return runtime; + return msgsEl; +} +function refreshEmptyState(sess) { + const has = sess && sess.messages.length > 0; + msgArea.classList.toggle('has-msgs', !!has); + if (chatStart) chatStart.style.display = has ? 'none' : ''; + if (msgsEl) msgsEl.style.display = has ? '' : 'none'; } -function getActiveSessionRuntime() { - const sess = state.sessions.get(state.activeId); - return sess ? getSessionRuntime(sess) : null; +function planTpl(tpl, v) { + return String(tpl || '').replace(/\{(\w+)\}/g, (_, k) => (v[k] != null ? String(v[k]) : `{${k}}`)); } -function findSessionByBridgeId(bridgeSessionId) { - if (!bridgeSessionId) return state.sessions.get(state.activeId) || null; - for (const sess of state.sessions.values()) { - if (sess.bridgeSessionId === bridgeSessionId || sess.id === bridgeSessionId) return sess; +let planPollTimer; +function syncPlanPollTimer() { + const on = !!(activeSess()?.bridgeSessionId && state.bridgeReady); + if (on && !planPollTimer) { + planPollTimer = setInterval(() => { + const s = activeSess(); + if (!s || !isActive(s)) return; + planFetch(s); + planTick(s); + }, 1000); + } else if (!on && planPollTimer) { + clearInterval(planPollTimer); + planPollTimer = null; } - return null; } -function isActiveSession(sess) { - return !!sess && sess.id === state.activeId; +function clearPlanGrace(r) { + r.planCompleteAt = r.planLostAt = null; + r.planHoldItems = []; + r.planLastPayload = null; + r.planLastComplete = false; + r.planDismissedComplete = false; + if (r.planHideTimer) { clearTimeout(r.planHideTimer); r.planHideTimer = null; } } -function withSessionDom(sess, fn) { - if (isActiveSession(sess)) return fn(); - return null; +function schedulePlanCompleteDismiss(sess) { + const r = rt(sess); + if (r.planHideTimer) clearTimeout(r.planHideTimer); + r.planHideTimer = setTimeout(() => { + r.planHideTimer = null; + r.planDismissedComplete = true; + if (isActive(sess)) refreshPlanBar(null); + }, PLAN_COMPLETE_GRACE_MS); } -function updateTaskRuntimeBadges(now = getNowMs()) { - const badges = document.querySelectorAll('.task-elapsed[data-started-at]'); - badges.forEach((badge) => { - const startedAt = Number(badge.dataset.startedAt || 0); - if (startedAt) badge.textContent = formatTaskElapsed(now - startedAt); +/** tuiapp_v2._refresh_planbar:用 runtime 里缓存的 items / placeholder 重绘 */ +function refreshPlanBarFromRuntime(sess) { + const r = rt(sess); + const lp = r.planLastPayload; + let items = r.planHoldItems || []; + if (r.planLostAt != null && Date.now() - r.planLostAt >= PLAN_LOST_GRACE_MS) { + items = []; + r.planHoldItems = []; + r.planLostAt = null; + } + if (r.planDismissedComplete) { + refreshPlanBar(null); + return; + } + if (r.planCompleteAt != null && Date.now() - r.planCompleteAt >= PLAN_COMPLETE_GRACE_MS) { + r.planDismissedComplete = true; + refreshPlanBar(null); + return; + } + if (!items.length) { + if (lp?.active && lp.placeholder) { + refreshPlanBar(lp); + return; + } + const held = r.planHoldItems || []; + if (lp?.complete && (lp.items?.length || held.length)) { + refreshPlanBar({ + active: true, + placeholder: false, + items: lp.items?.length ? lp.items : held, + done: lp.done ?? held.filter(it => it.status === 'done').length, + total: lp.total ?? (lp.items?.length || held.length), + complete: true, + step: lp.step || '', + }); + return; + } + refreshPlanBar(null); + return; + } + refreshPlanBar({ + active: true, + placeholder: false, + items, + done: lp?.done ?? items.filter(it => it.status === 'done').length, + total: lp?.total ?? items.length, + complete: !!(lp?.complete || (items.length && items.every(it => it.status === 'done'))), + step: lp?.step || '', }); } -function clearTaskTimer(sess) { - const runtime = sess ? getSessionRuntime(sess) : getActiveSessionRuntime(); - if (runtime?.taskTimerId) { - clearInterval(runtime.taskTimerId); - runtime.taskTimerId = null; +/** 每秒 tick grace(对齐 TUI _poll_plan_files → _refresh_planbar) */ +function planTick(sess) { + if (!sess || !isActive(sess)) return; + refreshPlanBarFromRuntime(sess); +} + +function applyPlanPayload(sess, raw) { + if (!sess) return; + const r = rt(sess); + const now = Date.now(); + + if (raw?.active) { + if (raw.placeholder && !r.planLastPayload?.active) { + r.planCollapsed = false; + r.planShowAll = false; + } + r.planLastPayload = raw; + const items = raw.items || []; + if (items.length) { + r.planLostAt = null; + r.planHoldItems = items; + } else if (!raw.placeholder && !raw.complete && r.planHoldItems.length) { + if (!r.planLostAt) r.planLostAt = now; + } + const nowComplete = !!raw.complete && (items.length > 0 || r.planHoldItems.length > 0); + const wasComplete = r.planLastComplete; + if (nowComplete && !wasComplete) { + r.planCompleteAt = now; + schedulePlanCompleteDismiss(sess); + } else if (!nowComplete) { + r.planCompleteAt = null; + r.planDismissedComplete = false; + if (r.planHideTimer) { clearTimeout(r.planHideTimer); r.planHideTimer = null; } + } + r.planLastComplete = nowComplete; + } else if (r.planHoldItems.length && !r.planDismissedComplete) { + if (!r.planLostAt) r.planLostAt = now; + } else if (!r.planDismissedComplete) { + clearPlanGrace(r); + } + + if (!isActive(sess)) return; + if (r.planDismissedComplete) { + refreshPlanBar(null); + return; + } + if (raw?.active && raw.placeholder) { + refreshPlanBar(raw); + return; + } + if (raw?.active && raw.complete && (raw.items?.length || r.planHoldItems.length)) { + refreshPlanBar({ + ...raw, + items: raw.items?.length ? raw.items : r.planHoldItems, + }); + return; + } + refreshPlanBarFromRuntime(sess); +} + +function planItemUi(status, isCurrent) { + const st = String(status || 'open').toLowerCase(); + if (st === 'done') return { cls: 'plan-item--done', mark: '✓' }; + if (st === 'error' || st === 'failed') return { cls: 'plan-item--error', mark: '✕' }; + if (st === 'warn' || st === 'warning') return { cls: 'plan-item--warn', mark: '!' }; + if (isCurrent) return { cls: 'plan-item--current', mark: '●' }; + return { cls: 'plan-item--pending', mark: '○' }; +} + +function pickPlanWindow(items, stepText) { + const list = Array.isArray(items) ? items : []; + if (!list.length) return { shown: [], curInShown: -1, overflow: 0 }; + let cur = list.findIndex(it => it.status !== 'done'); + if (cur < 0) cur = list.length - 1; + const step = String(stepText || '').trim(); + if (step) { + const hit = list.findIndex(it => String(it.content || '').includes(step.slice(0, 24))); + if (hit >= 0) cur = hit; + } + let start, end; + if (cur <= 1) { + start = cur; + end = Math.min(list.length, cur + 3); + } else { + start = cur - 1; + end = Math.min(list.length, cur + 2); + } + const shown = list.slice(start, end); + return { shown, curInShown: cur - start, overflow: Math.max(0, list.length - shown.length) }; +} + +function planCapsuleLabel(plan) { + const step = plan.step ? String(plan.step).slice(0, 80) : ''; + if (plan.complete) return { tag: t('plan.capsuleComplete'), step: step || planTpl(t('plan.complete'), { n: plan.total }) }; + if (plan.placeholder) return { tag: t('plan.placeholder'), step: '' }; + return { tag: t('plan.capsuleRunning'), step: step || planTpl(t('plan.header'), { done: plan.done, total: plan.total }) }; +} + +function bindPlanCardUiOnce() { + if (!planBarEl || planBarEl._planUiBound) return; + planBarEl._planUiBound = true; + planBarEl.addEventListener('click', (e) => { + const sess = activeSess(); + if (!sess) return; + const r = rt(sess); + const payload = r.planLastPayload; + if (!payload?.active) return; + if (e.target.closest('[data-plan-expand]')) { + r.planCollapsed = false; + refreshPlanBar(payload); + } else if (e.target.closest('[data-plan-collapse]')) { + r.planCollapsed = true; + refreshPlanBar(payload); + } else if (e.target.closest('[data-plan-details]')) { + r.planShowAll = !r.planShowAll; + refreshPlanBar(payload); + } + }); +} + +function refreshPlanBar(plan) { + if (!planBarEl) return; + bindPlanCardUiOnce(); + if (!plan?.active) { + planBarEl.hidden = true; + planBarEl.replaceChildren(); + planBarEl.className = 'plan-card'; + return; + } + const sess = activeSess(); + const r = sess ? rt(sess) : { planCollapsed: false, planShowAll: false }; + const collapsed = !!r.planCollapsed; + const stepText = plan.step ? String(plan.step).slice(0, 120) : ''; + const done = plan.done ?? (plan.items || []).filter(it => it.status === 'done').length; + const total = plan.total ?? (plan.items || []).length; + const mod = [ + 'plan-card', + collapsed ? 'plan-card--collapsed' : 'plan-card--expanded', + plan.complete ? 'plan-card--complete' : '', + plan.placeholder ? 'plan-card--placeholder' : '', + ].filter(Boolean).join(' '); + planBarEl.hidden = false; + planBarEl.className = mod; + + if (collapsed) { + const cap = planCapsuleLabel(plan); + planBarEl.innerHTML = ''; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'plan-capsule'; + btn.dataset.planExpand = '1'; + const dot = document.createElement('span'); + dot.className = 'plan-status-dot'; + const txt = document.createElement('span'); + txt.className = 'plan-capsule-text'; + if (cap.step) txt.innerHTML = `${escapeHtml(cap.tag)} · ${escapeHtml(cap.step)}`; + else txt.textContent = cap.tag; + btn.append(dot, txt); + planBarEl.append(btn); + return; + } + + const frag = document.createDocumentFragment(); + const head = document.createElement('div'); + head.className = 'plan-card-head'; + const dot = document.createElement('span'); + dot.className = 'plan-status-dot'; + const title = document.createElement('span'); + title.className = 'plan-title'; + title.textContent = plan.placeholder ? t('plan.placeholder') + : plan.complete ? t('plan.completeTitle') + : t('plan.running'); + head.append(dot, title); + if (!plan.placeholder && total > 0) { + const prog = document.createElement('span'); + prog.className = 'plan-progress'; + prog.textContent = `${done}/${total}`; + head.append(prog); + } + const actions = document.createElement('div'); + actions.className = 'plan-head-actions'; + const collapseBtn = document.createElement('button'); + collapseBtn.type = 'button'; + collapseBtn.className = 'plan-btn'; + collapseBtn.dataset.planCollapse = '1'; + collapseBtn.textContent = t('plan.collapse'); + actions.append(collapseBtn); + head.append(actions); + frag.append(head); + + if (stepText) { + const cur = document.createElement('div'); + cur.className = 'plan-current'; + const lab = document.createElement('span'); + lab.className = 'plan-current-label'; + lab.textContent = `${t('plan.current')}:`; + const body = document.createElement('span'); + body.className = 'plan-current-text'; + body.textContent = stepText; + cur.append(lab, body); + frag.append(cur); + } + + if (plan.placeholder) { + const wait = document.createElement('div'); + wait.className = 'plan-wait'; + wait.textContent = planTpl(t('plan.waiting'), { path: plan.pathHint || 'plan.md' }); + frag.append(wait); + } else { + const list = plan.items || []; + const { shown, curInShown, overflow } = r.planShowAll + ? { shown: list, curInShown: list.findIndex(it => it.status !== 'done'), overflow: 0 } + : pickPlanWindow(list, stepText); + if (shown.length) { + const ul = document.createElement('ul'); + ul.className = 'plan-items'; + shown.forEach((it, i) => { + const ui = planItemUi(it.status, i === curInShown); + const li = document.createElement('li'); + li.className = 'plan-item ' + ui.cls; + const mark = document.createElement('span'); + mark.className = 'plan-item-mark'; + mark.textContent = ui.mark; + const txt = document.createElement('span'); + txt.className = 'plan-item-text'; + txt.textContent = it.content || ''; + li.append(mark, txt); + ul.append(li); + }); + frag.append(ul); + } + const foot = document.createElement('div'); + foot.className = 'plan-foot'; + const moreN = r.planShowAll ? 0 : (overflow || Math.max(0, list.length - shown.length)); + if (moreN > 0) { + const hint = document.createElement('span'); + hint.className = 'plan-more-hint'; + hint.textContent = planTpl(t('plan.overflow'), { n: moreN }); + foot.append(hint); + } + if (list.length > 3) { + const det = document.createElement('button'); + det.type = 'button'; + det.className = 'plan-btn'; + det.dataset.planDetails = '1'; + det.textContent = r.planShowAll ? t('plan.collapse') : t('plan.details'); + foot.append(det); + } + if (foot.childNodes.length) frag.append(foot); + } + planBarEl.replaceChildren(frag); +} + +async function planFetch(sess) { + if (!sess?.bridgeSessionId || !state.bridgeReady || !isActive(sess)) return; + try { + const res = await fetch(`${BRIDGE_ORIGIN}/session/${encodeURIComponent(sess.bridgeSessionId)}/plan`); + if (!res.ok) throw new Error(`plan ${res.status}`); + const data = await res.json(); + applyPlanPayload(sess, data.plan ?? data.result?.plan); + } catch (_) { /* 对齐 TUI:读盘/网络失败不立刻清空条 */ } +} + +async function planPoll(sess) { + await planFetch(sess); + planTick(sess); +} + +/* ═══════════════ 消息渲染 ═══════════════ */ +function stripAttachPlaceholders(text) { + return String(text || '').replace(/\[(Image|File)\s+#\d+\]\s*/g, '').trim(); +} +// 把消息文本里的 [Image #N]/[File #N] 占位符“按原位置”渲染成内联 chip(显示文件名),其余文本转义+换行, +// 这样消息里能看到附件在文本中的位置(卡片/缩略图照常另外渲染)。lookup(kind,n) 取该附件文件名。 +// 同时兜底去掉内联的本地上传路径(历史/conductor 回显)。 +function renderMsgTextWithChips(text, lookup) { + const s = String(text || '').replace(/[^\s]*desktop_uploads[^\s]*\s*/g, ''); + const esc = t2 => escapeHtml(t2).replace(/\n/g, '
'); + const re = /\[(Image|File)\s+#(\d+)\]/g; + let out = '', last = 0, m; + while ((m = re.exec(s))) { + out += esc(s.slice(last, m.index)); + const name = (lookup && lookup(m[1], Number(m[2]))) || (m[1] === 'Image' ? 'image' : 'file'); + out += `${escapeHtml(name)}`; + last = re.lastIndex; + } + out += esc(s.slice(last)); + return out.trim(); +} +function fileSubLabel(name) { + const m = String(name || '').match(/\.([^.]+)$/); + if (!m) return t('file.kindGeneric'); + const ext = m[1].toLowerCase(); + const docExts = ['pdf', 'doc', 'docx', 'rtf', 'odt', 'pages', 'tex']; + const sheetExts = ['xls', 'xlsx', 'csv', 'tsv', 'numbers', 'ods']; + const slideExts = ['ppt', 'pptx', 'key', 'odp']; + const codeExts = ['py', 'js', 'ts', 'tsx', 'jsx', 'java', 'c', 'cpp', 'h', 'hpp', 'rs', 'go', 'rb', 'php', 'sh', 'html', 'css', 'json', 'yaml', 'yml', 'xml', 'sql', 'md']; + const archiveExts = ['zip', 'tar', 'gz', 'rar', '7z', 'bz2']; + const audioExts = ['mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a']; + const videoExts = ['mp4', 'mov', 'avi', 'mkv', 'webm', 'wmv']; + if (docExts.includes(ext)) return t('file.kindDoc'); + if (sheetExts.includes(ext)) return t('file.kindSheet'); + if (slideExts.includes(ext)) return t('file.kindSlide'); + if (codeExts.includes(ext)) return t('file.kindCode') + ' · ' + ext.toUpperCase(); + if (archiveExts.includes(ext)) return t('file.kindArchive'); + if (audioExts.includes(ext)) return t('file.kindAudio'); + if (videoExts.includes(ext)) return t('file.kindVideo'); + return ext.toUpperCase(); +} +/** 无 turn_segs 时按 LLM Running 标记切轮(与 stapp.fold_turns 同源) */ +function splitContentTurnSegs(content) { + const src = String(content || ''); + const parts = src.split(/\**LLM Running \(Turn \d+\) \.\.\.\**/); + const segs = []; + for (let i = 1; i < parts.length; i++) { + const body = parts[i] || ''; + if (body.length || segs.length) segs.push(body); + } + if (segs.length > 1) return segs; + return src.length ? [src] : []; +} + +function assistantTurnSegs(msg) { + if (Array.isArray(msg?.turn_segs) && msg.turn_segs.length) return msg.turn_segs; + if (typeof msg?.content === 'string' && msg.content.length) return splitContentTurnSegs(msg.content); + return []; +} + +/** turn_segs 未就绪时回退 content(双轨兼容,不改 ljq 渲染主路径) */ +function draftSegsFromPartial(raw, m) { + const segs = Array.isArray(m.turn_segs) ? m.turn_segs : []; + if (segs.length && segs.some(s => (s || '').length > 0)) return segs; + const content = typeof raw.content === 'string' ? raw.content : (m.content || ''); + return content ? [content] : segs; +} +function firstNonEmptyTurnIndex(segs) { + const arr = Array.isArray(segs) ? segs : []; + for (let i = 0; i < arr.length; i++) { + if ((arr[i] || '').length > 0) return i; + } + return 0; +} +// 后端 curr_turn 是内部 0-based 下标。展示时优先当前 turn;若该 turn 为空,回退到最后一个非空 turn。 +function resolveVisibleTurnIndex(segs, preferredTurn) { + const arr = Array.isArray(segs) ? segs : []; + const first = firstNonEmptyTurnIndex(arr); + const preferred = Number.isFinite(Number(preferredTurn)) ? Number(preferredTurn) : -1; + if (preferred >= 0 && (arr[preferred] || '').length > 0) return preferred; + for (let i = arr.length - 1; i >= 0; i--) { + if ((arr[i] || '').length > 0) return i; + } + return Math.max(first, preferred >= 0 ? preferred : (arr.length ? arr.length - 1 : first)); +} +// 静态完整渲染:visibleTurn 之前的 seg 固化折叠;visibleTurn 作为当前正文展示。 +function renderAssistantTurnsHtml(segs, currTurn, withCursor = false) { + const arr = Array.isArray(segs) ? segs : []; + if (!arr.length) return ''; + const first = firstNonEmptyTurnIndex(arr); + const curr = resolveVisibleTurnIndex(arr, currTurn); + let html = ''; + for (let i = first; i < curr; i++) { + if ((arr[i] || '').length > 0) html += `
${renderTurnFold(arr[i] || '', i)}
`; + } + html += `
${renderTurnBody(arr[curr] || '')}${withCursor ? '' : ''}
`; + return html; +} +function assistantCopyText(msg) { + const segs = assistantTurnSegs(msg); + if (segs.length) { + // 复制保持旧行为:只复制当前/最后可见 turn,不复制已折叠历史 turn。 + const turn = resolveVisibleTurnIndex(segs, msg?.curr_turn); + return stripTurnMarker(segs[turn] || '').replace(/[\s\S]*?<\/summary>\s*/i, '').trim(); + } + return ''; +} +function msgNode(msg) { + const el = document.createElement('div'); + el.className = 'msg ' + (msg.role || 'system'); + if (msg.role === 'user') { + const shown = (typeof msg.display === 'string' && msg.display.length) ? msg.display : msg.content; + const imgsHtml = (msg.images && msg.images.length) + ? `
${msg.images.map(im => ``).join('')}
` + : ''; + const filesHtml = (msg.files && msg.files.length) + ? `
${msg.files.map(f => { + const name = f.name || 'file'; + const sub = fileSubLabel(name); + return `
${GA_ICON('fileText')}${escapeHtml(name)}${escapeHtml(sub)}
`; + }).join('')}
` + : ''; + const chipText = renderMsgTextWithChips(shown, (kind, n) => { + const hit = (kind === 'Image' ? (msg.images || []) : (msg.files || [])).find(x => x.id === 'f-' + n); + return hit && (hit.name || ''); + }); + const textHtml = chipText ? `
${chipText}
` : ''; + el.innerHTML = `
${filesHtml}${imgsHtml}${textHtml}
`; + } + else if (msg.role === 'assistant') { + const segs = assistantTurnSegs(msg); + let html = renderAssistantTurnsHtml(segs, msg.curr_turn, false); + if (msg.stopped) html += `

[${escapeHtml(t('status.stopped'))}]

`; + el.innerHTML = `
${html}
`; + postRenderEnhance(el.querySelector('.bubble')); + } + else if (msg.role === 'error') el.innerHTML = `
${escapeHtml(msg.content)}
`; + else el.innerHTML = `
${escapeHtml(msg.content)}
`; + if (msg.role === 'user' || msg.role === 'assistant') { + const copyBtn = document.createElement('button'); + copyBtn.className = 'bubble-copy-btn'; + copyBtn.title = t('act.copy'); + copyBtn.innerHTML = SVG_COPY_ICON; + copyBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const text = (msg.role === 'user') + ? stripAttachPlaceholders((typeof msg.display === 'string' && msg.display.length) ? msg.display : (msg.content || '')) + : assistantCopyText(msg); + navigator.clipboard.writeText(text).then(() => { + copyBtn.innerHTML = SVG_CHECK_ICON; + setTimeout(() => { copyBtn.innerHTML = SVG_COPY_ICON; }, 1500); + }); + }); + el.appendChild(copyBtn); + } + return el; +} +function collabItemToMsg(item) { + const attach = arr => (arr || []).map(x => { + const sid = x.sid != null ? x.sid : (String(x.id || '').startsWith('f-') ? String(x.id).slice(2) : x.id); + return { id: 'f-' + sid, name: x.name, path: x.path, dataUrl: x.dataUrl }; + }); + if (item.role === 'user') { + return { role: 'user', content: item.msg, display: item.msg, images: attach(item.images), files: attach(item.files) }; + } + if (item.role === 'conductor') return { role: 'assistant', turn_segs: [item.msg || ''], curr_turn: 0 }; + if (item.role === 'error') return { role: 'error', content: item.msg || '' }; + return { role: 'system', content: item.msg || '' }; +} +function renderAllMessages(sess) { + const box = ensureMsgs(); box.innerHTML = ''; + for (const m of sess.messages) box.appendChild(msgNode(m)); + syncAskUserUi(); + // badge 恢复在 pollSession finally 中执行(此时 messages 已通过异步加载填充) + refreshEmptyState(sess); scrollBottom(true); +} +// 遍历消息对,用 ts 差值恢复 badge;对运行中任务恢复 taskStartedAt +function restoreElapsedBadges(sess, box) { + const msgs = sess.messages; + if (!msgs || !msgs.length) return; + const nodes = box.querySelectorAll('.msg'); + let lastUserTs = null; + for (let i = 0; i < msgs.length; i++) { + if (msgs[i].role === 'user') { + lastUserTs = msgs[i].ts ? msgs[i].ts * 1000 : null; // 无 ts 则重置 + } else if (msgs[i].role === 'assistant') { + if (lastUserTs && msgs[i].ts) { + const elapsed = msgs[i].ts * 1000 - lastUserTs; + if (elapsed > 0 && nodes[i]) { + ensureTaskElapsedBadge(nodes[i], lastUserTs, msgs[i].ts * 1000); + } + } + lastUserTs = null; + } + } + // 运行中任务:最后一条是 user 且 session busy,恢复实时计时 + if (lastUserTs && rt(sess).busy) { + const r = rt(sess); + r.taskStartedAt = lastUserTs; + r.taskEndedAt = null; + } +} +function appendMessage(sess, msg) { + if (!isActive(sess)) return; + const el = msgNode(msg); + ensureMsgs().appendChild(el); + if (msg.role === 'assistant') { + const r = rt(sess); + if (r.taskStartedAt) { + ensureTaskElapsedBadge(el, r.taskStartedAt, r.taskEndedAt || Date.now()); + r.taskStartedAt = null; r.taskEndedAt = null; + } + } + refreshEmptyState(sess); scrollBottom(true); + if (msg.role === 'assistant' || msg.role === 'user') syncAskUserUi(); +} +function isNearBottom(threshold = 80) { + return msgArea.scrollHeight - msgArea.scrollTop - msgArea.clientHeight < threshold; +} +function scrollBottom(force) { + if (force || isNearBottom()) { + requestAnimationFrame(() => { msgArea.scrollTop = msgArea.scrollHeight; }); + } +} +/* ═══════════════ 打字机效果 (PR移植) ═══════════════ */ +const TW_SPEED = 10; // 逐字步长;paintDraft 只重绘当前轮 +const TW_INTERVAL = 35; // ms +const TW_CATCHUP_THRESHOLD = 480; // 积压过大时加速追平 +const TW_CATCHUP_MULTIPLIER = 8; +const TW_RECOVER_MIN = 1200; // 保留常量;刷新恢复改由 snapDraftRecover 一律对齐 +const DRAFT_INTERACT_MS = 520; // 用户滚代码块/点折叠时暂缓 DOM 重写 + +function isDraftInteractFrozen(r) { + return Date.now() < (r.draftFreezeUntil || 0); +} +function armDraftInteractFreeze(r, ms = DRAFT_INTERACT_MS) { + r.draftFreezeUntil = Math.max(r.draftFreezeUntil || 0, Date.now() + ms); +} +function snapshotDraftScroll(root) { + if (!root) return []; + return [...root.querySelectorAll('.bubble .code-block pre, .bubble .fold-pre')].map(n => n.scrollTop); +} +function restoreDraftScroll(root, tops) { + if (!root || !tops.length) return; + const nodes = root.querySelectorAll('.bubble .code-block pre, .bubble .fold-pre'); + tops.forEach((top, i) => { if (nodes[i] && top > 0) nodes[i].scrollTop = top; }); +} +function bindDraftInteractGuard(el, r) { + if (!el || el.dataset.gaDraftGuard) return; + el.dataset.gaDraftGuard = '1'; + const arm = () => { if (!r._suppressToggleFreeze) armDraftInteractFreeze(r); }; + el.addEventListener('mousedown', (e) => { + if (e.target.closest('details summary, .code-block pre, .fold-pre')) armDraftInteractFreeze(r); + }, true); + el.addEventListener('wheel', (e) => { + if (e.target.closest('.code-block pre, .fold-pre')) armDraftInteractFreeze(r); + }, { capture: true, passive: true }); + el.addEventListener('toggle', (e) => { + if (e.target.matches('details')) arm(); + }, true); +} +function resetTypewriterState(r) { + if (r.twState?.timer) clearInterval(r.twState.timer); + r.twState = null; + r.draftRecoverPending = false; + r.draftStreamBaseline = 0; + r._draftPaintBody = ''; +} + +/** 刷新/hydrate/重连:已有 partial 一次性对齐全文并单帧绘制,不重播打字机 */ +function snapDraftRecover(r) { + if (!r.draftRecoverPending || !r.draftEl) return false; + if (!r.twState) r.twState = { turn: 0, shown: 0, timer: null }; + const tw = r.twState; + if (tw.timer) { clearInterval(tw.timer); tw.timer = null; } + const segs = Array.isArray(r.draftSegs) ? r.draftSegs : []; + const turn = Math.max(0, Number(r.draftTurn) || 0); + tw.turn = turn; + tw.shown = (segs[turn] || '').length; + r.draftRecoverPending = false; + r.draftStreamBaseline = tw.shown; + r._draftPaintBody = ''; + ensureDraftFrozenThrough(r, turn); + paintDraft(r, turn, segs[turn] || ''); + return true; +} + +function renderDraft(sess) { + const r = rt(sess); + if (!isActive(sess)) return; + const box = ensureMsgs(); + if (!r.draftEl || r.draftEl.parentNode !== box) { + r.draftEl = document.createElement('div'); r.draftEl.className = 'msg assistant'; box.appendChild(r.draftEl); + bindDraftInteractGuard(r.draftEl, r); + if (r.taskStartedAt) ensureTaskElapsedBadge(r.draftEl, r.taskStartedAt, null); + } + if (!r.twState) r.twState = { turn: 0, shown: 0, timer: null }; + const tw = r.twState; + const backendTurn = Math.max(0, Number(r.draftTurn) || 0); + if (tw.turn == null || tw.turn < 0) tw.turn = 0; + if (tw.turn > backendTurn) tw.turn = backendTurn; + // 流式渲染模型:segs 是源数据;tw.turn 是正在打字的 turn;tw.turn 之前的 DOM 一旦 frozen 就不再动。 + ensureDraftFrozenThrough(r, tw.turn); + + const tick = () => { + const segs = Array.isArray(r.draftSegs) ? r.draftSegs : []; + const backendTurnNow = Math.max(0, Number(r.draftTurn) || 0); + if (tw.turn == null || tw.turn < 0) tw.turn = 0; + if (tw.turn > backendTurnNow) tw.turn = backendTurnNow; + r.streamTurn = tw.turn; + const cur = segs[tw.turn] || ''; + + // 当前 turn 打完,并且后端已进入后续 turn:当前 DOM 固化,然后打字机推进到下一 turn。 + if (tw.shown >= cur.length && backendTurnNow > tw.turn) { + paintDraft(r, tw.turn, cur); + freezeCurrentTurnDom(r, tw.turn); + tw.turn += 1; + tw.shown = 0; + r.streamTurn = tw.turn; + return; + } + + if (tw.shown >= cur.length) return; // 中途不停止 timer,等新 partial/done。 + if (isDraftInteractFrozen(r)) return; + + const t0 = performance.now(); + const backlog = cur.length - tw.shown; + let step = backlog > TW_CATCHUP_THRESHOLD ? TW_SPEED * TW_CATCHUP_MULTIPLIER : TW_SPEED; + const last = tw.lastElapsed || 0; + if (last > 60) step = Math.max(step, Math.ceil(backlog / 3)); + else step = Math.min(step, 120); + tw.shown = Math.min(tw.shown + step, cur.length); + paintDraft(r, tw.turn, cur.slice(0, tw.shown)); + tw.lastElapsed = performance.now() - t0; + }; + + if (snapDraftRecover(r)) { + if (!tw.timer) tw.timer = setInterval(tick, TW_INTERVAL); + refreshEmptyState(sess); + return; + } + + if (!tw.timer) tw.timer = setInterval(tick, TW_INTERVAL); + if (!isDraftInteractFrozen(r)) tick(); + refreshEmptyState(sess); +} + +function ensureDraftFrozenThrough(r, currTurn) { + if (!r.draftEl) return; + const segs = Array.isArray(r.draftSegs) ? r.draftSegs : []; + const upto = Math.max(0, Number(currTurn) || 0); + let bubble = r.draftEl.querySelector(':scope > .bubble.md'); + if (!bubble) { + bubble = document.createElement('div'); + bubble.className = 'bubble md'; + r.draftEl.appendChild(bubble); + } + const cur = bubble.querySelector(':scope > .turn-cur'); + for (let turn = 0; turn < upto; turn++) { + if (bubble.querySelector(`:scope > .turn-frozen[data-turn="${turn}"]`)) continue; + const frozen = document.createElement('div'); + frozen.className = 'turn-frozen'; + frozen.dataset.turn = turn; + frozen.innerHTML = renderTurnFold(segs[turn] || '', turn); + if (cur) bubble.insertBefore(frozen, cur); + else bubble.appendChild(frozen); + postRenderEnhance(frozen); } } -function startTaskTimer(sess, startedAt = getNowMs()) { - const runtime = getSessionRuntime(sess); - clearTaskTimer(sess); - runtime.taskStartedAt = Number(startedAt) || getNowMs(); - if (isActiveSession(sess)) updateTaskRuntimeBadges(runtime.taskStartedAt); - runtime.taskTimerId = setInterval(() => { - if (isActiveSession(sess)) updateTaskRuntimeBadges(); +function freezeCurrentTurnDom(r, turn) { + if (!r.draftEl) return; + const bubble = r.draftEl.querySelector(':scope > .bubble.md'); + if (!bubble) return; + const cur = bubble.querySelector(':scope > .turn-cur'); + if (!cur) return; + cur.className = 'turn-frozen'; + cur.dataset.turn = turn; + cur.innerHTML = renderTurnFold((r.draftSegs || [])[turn] || '', turn); + postRenderEnhance(cur); +} + +function paintDraft(r, turn, visibleCurrBody) { + if (!r.draftEl || isDraftInteractFrozen(r)) return; + const wasNear = isNearBottom(); + let bubble = r.draftEl.querySelector(':scope > .bubble.md'); + if (!bubble) { + bubble = document.createElement('div'); + bubble.className = 'bubble md'; + r.draftEl.appendChild(bubble); + } + let cur = bubble.querySelector(':scope > .turn-cur'); + if (!cur) { + cur = document.createElement('div'); + cur.className = 'turn-cur'; + bubble.appendChild(cur); + } + cur.dataset.turn = turn ?? 0; + const body = visibleCurrBody || ''; + const prevBody = r._draftPaintBody || ''; + if (!tryPatchInflightToolDom(cur, body, prevBody)) { + cur.innerHTML = renderTurnBody(body) + ''; + postRenderEnhance(cur); + } else if (!cur.querySelector('.cursor')) { + cur.insertAdjacentHTML('beforeend', ''); + } + r._draftPaintBody = body; + + if (wasNear) { + const inCodeScroll = document.activeElement?.closest?.('.code-block pre, .fold-pre') + && r.draftEl.contains(document.activeElement); + if (!inCodeScroll) scrollBottom(); + } +} + +function flushTypewriter(sess) { + resetTypewriterState(rt(sess)); +} + +/* ═══════════════ 运行状态 ═══════════════ */ +function pageStatusBar(btnEl) { + const label = btnEl?.querySelector('.rs-label'); + return { + /** state: 'ready' | 'busy' | 'offline' | 'connecting';兼容旧调用 set(text, true) */ + set(text, state = 'ready') { + if (!btnEl) return; + const mode = state === true ? 'busy' : (state === false ? 'ready' : state); + btnEl.classList.remove('busy', 'offline', 'connecting'); + if (mode === 'busy') btnEl.classList.add('busy'); + else if (mode === 'offline') btnEl.classList.add('offline'); + else if (mode === 'connecting') btnEl.classList.add('connecting'); + if (label) label.textContent = text ?? ''; + }, + setBusy(text) { this.set(text, 'busy'); }, + setReady() { this.set(t('status.ready'), 'ready'); }, + setDisconnected() { this.set(t('status.disconnected'), 'offline'); }, + setConnecting() { this.set(t('status.connecting'), 'connecting'); }, + }; +} +function refreshStatusLabel() { + const s = activeSess(); + if (s && rt(s).busy) { + chatStatus.setBusy(formatTaskElapsed(Date.now() - (rt(s).taskStartedAt || Date.now()))); + } else if (state.bridgeReady) { + chatStatus.setReady(); + } else { + chatStatus.setDisconnected(); + } +} + +/* ═══════════════ 消息计时 ═══════════════ */ +function formatTaskElapsed(ms) { + const v = Number(ms); + if (!Number.isFinite(v) || v < 0) return ''; + const sec = Math.round(v / 1000); + if (sec < 60) return t('timing.elapsed').replace('{t}', `${Math.max(1, sec)}s`); + const min = Math.floor(sec / 60), s = sec % 60; + if (min < 60) return t('timing.elapsed').replace('{t}', `${min}m ${s}s`); + const hr = Math.floor(min / 60), m = min % 60; + return t('timing.elapsed').replace('{t}', `${hr}h ${m}m`); +} + +function ensureTaskElapsedBadge(wrap, startedAt, endedAt) { + if (!wrap || !startedAt) return null; + let badge = wrap.querySelector(':scope > .task-elapsed'); + if (!badge) { + badge = document.createElement('div'); + badge.className = 'task-elapsed'; + wrap.prepend(badge); + } + const elapsed = (endedAt || Date.now()) - startedAt; + badge.textContent = formatTaskElapsed(elapsed); + badge.dataset.live = endedAt ? '' : '1'; + return badge; +} + +function startTaskTimer(sess) { + const r = rt(sess); + if (r.taskStartedAt) return; // 已在计时,不重置 + // 优先从消息时间戳恢复(刷新后持久化) + const msgs = sess.messages; + let restored = 0; + if (msgs && msgs.length) { + for (let i = msgs.length - 1; i >= 0; i--) { + if (msgs[i].role === 'user' && msgs[i].ts) { restored = msgs[i].ts * 1000; break; } + } + } + r.taskStartedAt = restored || Date.now(); + r.taskEndedAt = null; + if (r.taskTimerId) clearInterval(r.taskTimerId); + r.taskTimerId = setInterval(() => { + if (!r.taskStartedAt) return; + const el = r.draftEl || document.querySelector('.msg-list .msg.assistant:last-child'); + if (el) ensureTaskElapsedBadge(el, r.taskStartedAt, null); + // 更新左上角状态栏显示实时耗时 + if (isActive(sess)) { + chatStatus.setBusy(formatTaskElapsed(Date.now() - r.taskStartedAt)); + } }, 1000); } function stopTaskTimer(sess) { - if (isActiveSession(sess)) updateTaskRuntimeBadges(); - const runtime = getSessionRuntime(sess); - clearTaskTimer(sess); - runtime.taskStartedAt = 0; + const r = rt(sess); + if (r.taskTimerId) { clearInterval(r.taskTimerId); r.taskTimerId = null; } + if (!r.taskStartedAt) return; + r.taskEndedAt = Date.now(); } -function taskElapsedBadge(startedAt, endedAt) { - const start = Number(startedAt || 0); - if (!start) return ''; - const end = Number(endedAt || 0); - const now = end || getNowMs(); - const ended = !!end; - const liveAttr = ended ? 'data-ended="1"' : `data-started-at="${escapeHtml(String(start))}"`; - return `${escapeHtml(formatTaskElapsed(now - start, ended))}`; -} - -function ensureAssistantTaskElapsed(wrap, startedAt, endedAt) { - if (!wrap) return null; - const html = taskElapsedBadge(startedAt, endedAt); - let badge = wrap.querySelector(':scope > .task-elapsed'); - if (!html) { - badge?.remove(); - return null; - } - if (!badge) { - wrap.insertAdjacentHTML('afterbegin', html); - badge = wrap.querySelector(':scope > .task-elapsed'); +function setBusy(sess, busy) { + const r = rt(sess); + if (r.busy && !busy) resetTypewriterState(r); + r.busy = busy; + if (busy) startTaskTimer(sess); else stopTaskTimer(sess); + if (!isActive(sess)) return; + if (busy) { + chatStatus.setBusy(formatTaskElapsed(Date.now() - (r.taskStartedAt || Date.now()))); + } else if (state.bridgeReady) { + chatStatus.setReady(); } else { - const holder = document.createElement('div'); - holder.innerHTML = html; - badge.replaceWith(holder.firstElementChild); - badge = wrap.querySelector(':scope > .task-elapsed'); + chatStatus.setDisconnected(); + } + if (sendBtn) { + sendBtn.classList.toggle('is-stop', busy); + sendBtn.setAttribute('aria-label', busy ? t('act.stop') : t('act.send')); + sendBtn.title = busy ? t('act.stop') : ''; } - return badge; } +// run-toggle 现为纯状态展示组件:运行中转红,不再响应点击(停止改由发送键的录制键承担) -function turnLabelForSegment(seg, index) { - const summary = summarizeStructuredBlock(seg.kind, seg.text); - if (seg.kind === 'LLM_RUNNING') return summary || `Turn ${index + 1}`; - if (seg.kind === 'TOOL_CALL') return 'Tool'; - if (seg.kind === 'TOOL_RESULT') return 'Result'; - return summary || seg.kind || `Turn ${index + 1}`; -} - -function nextTurnIndexForWrap(wrap) { - const current = Number(wrap?.dataset?.turnIndex || 0) || 0; - const next = current + 1; - if (wrap) wrap.dataset.turnIndex = String(next); - return next; -} - -function turnHeaderLabel(index, label) { - return `Turn ${index} : ${label || 'response'}`; -} - -function groupIntoTurns(segments, options = {}) { - let foldIndex = 0; - return (segments || []).map((seg) => { - if (!shouldFoldSegment(seg.kind, seg.text)) return { type: 'plain', segment: seg }; - const index = ++foldIndex; - return { - type: 'turn', - index, - label: turnLabelForSegment(seg, index - 1), - segment: seg - }; +/* ═══════════════ 会话 ═══════════════ */ +function isUntitled(x) { return !x || /^(new chat|新对话|新会话)$/i.test(String(x).trim()); } +function sortedSessions() { + // display order: pinned first, then most-recently-active. [0] is the topmost. + return [...state.sessions.values()].sort((a, b) => { + if (a.pinned && !b.pinned) return -1; + if (!a.pinned && b.pinned) return 1; + return (b.lastActiveTs || 0) - (a.lastActiveTs || 0); }); } - -function extractTagBody(text, tag) { - const escapedTag = String(tag || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const pattern = '<' + escapedTag + '\\b[^>]*>([\\s\\S]*?)<\\/' + escapedTag + '>'; - const m = String(text || '').match(new RegExp(pattern, 'i')); - return m ? m[1].trim() : ''; +function renderSessionList() { + convListEl.innerHTML = ''; + const query = (searchInput ? searchInput.value : '').trim().toLowerCase(); + const all = sortedSessions(); + const filtered = query + ? all.filter(s => { + const title = displayTitle(s).toLowerCase(); + const hasMsg = s.messages && s.messages.some(m => (m.text || '').toLowerCase().includes(query)); + return title.includes(query) || hasMsg; + }) + : all; + if (filtered.length === 0) { + const e = document.createElement('div'); + e.className = 'conv-empty'; e.textContent = t('conv.emptyList'); + convListEl.appendChild(e); return; + } + for (const sess of filtered) { + const r = state.runtime.get(sess.id); + const busy = !!(r && r.busy); + const item = document.createElement('div'); + item.className = 'conv-item' + (currentPage === 'chat' && sess.id === state.activeId ? ' active' : '') + (busy ? '' : ' idle'); + item.dataset.id = sess.id; + const pinSvg = sess.pinned ? GA_ICON('pushPinSimple', 'ci-pin') : ''; + item.innerHTML = + `
` + + `
${pinSvg}${escapeHtml(displayTitle(sess))}
` + + `
${busy ? t('status.running') : t('status.idle')}
` + + ``; + convListEl.appendChild(item); + } } - -function parseToolDetails(kind, text) { - const raw = String(text || ''); - if (kind === 'TOOL_CALL') { - const invoke = raw.match(/]*\bname=["']([^"']+)["'][^>]*>/i); - const tool = invoke ? invoke[1] : ''; - const params = extractTagBody(raw, 'parameter') || extractTagBody(raw, 'arguments') || extractTagBody(raw, 'args'); - const jsonish = params || (raw.match(/]*>[\s\S]*?<\/invoke>/i)?.[0] || '').replace(/<\/?invoke[^>]*>/gi, '').trim(); - if (tool || jsonish) return { title: tool ? `Tool: ${tool}` : 'Tool call', tool, args: jsonish }; - } - if (kind === 'TOOL_RESULT') { - const result = extractTagBody(raw, 'result') || raw.replace(/<\/?function_results[^>]*>/gi, '').trim(); - if (result) return { title: 'Tool result', tool: 'result', args: result }; - } - return null; +if (searchInput) searchInput.addEventListener('input', () => renderSessionList()); +async function ensureBridgeSession(sess) { + if (sess.bridgeSessionId) return sess.bridgeSessionId; + const res = await window.ga.rpc('session/new', { cwd: '', mcp_servers: [] }); + if (res?.error) throw new Error(res.error.message || res.error); + sess.bridgeSessionId = res.sessionId || res.result?.sessionId; + return sess.bridgeSessionId; } - -function renderToolDetailInto(container, seg) { - const detail = parseToolDetails(seg.kind, seg.text); - if (!detail) return; - const detailTurn = document.createElement('div'); - detailTurn.className = 'turn tool-detail-turn'; - const header = document.createElement('button'); - header.type = 'button'; - header.className = 'turn-header tool-detail-header'; - header.innerHTML = `▼${escapeHtml(detail.title)}args`; - header.addEventListener('click', () => detailTurn.classList.toggle('collapsed')); - const body = document.createElement('div'); - body.className = 'turn-body md tool-detail-body'; - const codeText = `Tool: ${detail.tool || detail.title.replace(/^Tool:\s*/, '') || 'tool'}\nargs:\n${detail.args || ''}`; - body.innerHTML = `
${escapeHtml(codeText)}
`; - detailTurn.appendChild(header); - detailTurn.appendChild(body); - container.appendChild(detailTurn); +// 仿 TUI(continue_cmd.py 的 _preview_text 思路):sess.title 只在用户手动 rename 时被填, +// 平时为空;sidebar 显示名实时从消息派生 —— 优先取最后一段 assistant 输出里的 ..., +// 其次用首条用户消息纯文本,都没有时回退到 t('conv.defaultTitle')。 +function isAutoTitle(x) { + const s = String(x || '').trim(); + if (!s) return true; + if (/^(new chat|新对话|新会话)$/i.test(s)) return true; + if (/^agent-\d+$/i.test(s)) return true; // 兼容上一轮误存的 agent-N + return false; } - -function renderTurnTreeInto(container, turn) { - const seg = turn.segment; - const node = document.createElement('div'); - node.className = 'turn collapsed structured-turn turn-group'; - node.dataset.kind = seg.kind; - node.dataset.buf = seg.text; - const header = document.createElement('button'); - header.type = 'button'; - header.className = 'turn-header'; - header.innerHTML = `▼${escapeHtml(turnHeaderLabel(turn.index, turn.label))}`; - header.addEventListener('click', () => node.classList.toggle('collapsed')); - const body = document.createElement('div'); - body.className = 'turn-body md'; - const hasToolDetail = Boolean(parseToolDetails(seg.kind, seg.text)); - renderToolDetailInto(body, seg); - if (!hasToolDetail) { - const rendered = document.createElement('div'); - rendered.className = 'turn-rendered-md'; - rendered.innerHTML = renderMarkdown(seg.text); - body.appendChild(rendered); - } - node.appendChild(header); - node.appendChild(body); - container.appendChild(node); -} - -/** - * Extract ... from text, render it as a faded italic hint, - * and return the remaining text. If no summary tag found, returns text unchanged. - */ -/** - * Strip leading and tags from text. - * Returns { summary, think, remaining } where summary/think are the extracted - * content strings (or null), and remaining is the text to render as markdown. - */ -function stripLeadingMetaTags(text) { - let remaining = text; - let summary = null; - let think = null; - // Strip ... at start - const sumRe = /^([\s\S]*?)<\/summary>\s*/i; - const sumM = remaining.match(sumRe); - if (sumM) { - summary = sumM[1].trim(); - remaining = remaining.slice(sumM[0].length); - } - // Strip ... at start (or after summary) - const thinkRe = /^([\s\S]*?)<\/think>\s*/i; - const thinkM = remaining.match(thinkRe); - if (thinkM) { - think = thinkM[1].trim(); - remaining = remaining.slice(thinkM[0].length); - } - return { summary, think, remaining }; -} - -function extractAndRenderSummary(container, text) { - const { summary, think, remaining } = stripLeadingMetaTags(text); - if (summary) { - const hint = document.createElement('div'); - hint.className = 'summary-hint'; - hint.textContent = summary; - container.appendChild(hint); - } - if (think) { - const thinkEl = document.createElement('div'); - thinkEl.className = 'think-hint'; - thinkEl.textContent = think; - container.appendChild(thinkEl); - } - return remaining; -} - -function renderStructuredMarkdownInto(container, text, options = {}) { - const segments = splitStructuredSegments(text); - container.innerHTML = ''; - if (segments.length === 1 && !shouldFoldSegment(segments[0].kind, segments[0].text)) { - const remaining = extractAndRenderSummary(container, text); - if (remaining) container.insertAdjacentHTML('beforeend', renderMarkdown(remaining)); - return; - } - for (const item of groupIntoTurns(segments, options)) { - if (item.type === 'plain') { - const plain = document.createElement('div'); - plain.className = 'md'; - const remaining = extractAndRenderSummary(plain, item.segment.text); - if (remaining) plain.insertAdjacentHTML('beforeend', renderMarkdown(remaining)); - container.appendChild(plain); - continue; +function displayTitle(sess) { + if (sess && sess.title && !isAutoTitle(sess.title)) return sess.title; + const msgs = (sess && sess.messages) || []; + // 1) 优先:最后一段 assistant 文本里的 ... + for (let i = msgs.length - 1; i >= 0; i--) { + const m = msgs[i]; + if (!m || m.role !== 'assistant') continue; + const txt = assistantStructuredText(m); + const sm = /([\s\S]*?)<\/summary>/i.exec(txt); + if (sm && sm[1].trim()) { + const line = sm[1].trim().split('\n')[0].trim(); + if (line) return line.length > 60 ? line.slice(0, 60) + '…' : line; } - renderTurnTreeInto(container, item); } + // 2) 兜底:首条用户消息纯文本(去附件占位符) + for (const m of msgs) { + if (!m || m.role !== 'user') continue; + const raw = typeof m.content === 'string' ? m.content : (m.display || ''); + const clean = stripAttachPlaceholders(raw).trim(); + if (clean) return clean.length > 40 ? clean.slice(0, 40) + '…' : clean; + } + return t('conv.defaultTitle'); } - -// ─── Copy button injection for code blocks and pre blocks ───────────────── -function injectCopyButtons(container) { - if (!container) return; - const blocks = container.querySelectorAll('pre'); - blocks.forEach(pre => { - if (pre.querySelector('.copy-btn')) return; // already injected - pre.style.position = 'relative'; - const btn = document.createElement('button'); - btn.className = 'copy-btn'; - btn.textContent = 'Copy'; - btn.setAttribute('aria-label', 'Copy code'); - btn.addEventListener('click', () => { - const code = pre.querySelector('code') || pre; - navigator.clipboard.writeText(code.textContent).then(() => { - btn.textContent = '✓ Copied'; - btn.classList.add('copied'); - setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 2000); - }).catch(() => { - btn.textContent = '✗ Failed'; - setTimeout(() => { btn.textContent = 'Copy'; }, 2000); - }); - }); - pre.appendChild(btn); - }); -} - -function escapeHtml(s) { - return String(s).replace(/[&<>"']/g, c => ({ - '&':'&','<':'<','>':'>','"':'"',"'":''' - }[c])); -} - -// ─── Session management ────────────────────────────────────────────────── -function isUntitledSessionTitle(title) { - return !title || /^new\s+chat$/i.test(String(title).trim()); -} - -function createLocalSession(id, title, bridgeSessionId = id) { - const sess = { - id, bridgeSessionId, title: title || 'New chat', messages: [], cwd: null, - untitled: isUntitledSessionTitle(title), - config: { ...state.defaultConfig }, - diagnostics: [], - }; - getSessionRuntime(sess); - // Keep freshly-created chats visually quiet: the empty state is enough guidance. - state.sessions.set(id, sess); +async function newSession() { + const localId = 'local-' + Date.now() + '-' + Math.random().toString(16).slice(2); + const sess = { id: localId, bridgeSessionId: null, title: '', messages: [], untitled: true, lastActiveTs: Date.now() }; + state.sessions.set(localId, sess); + try { + await ensureBridgeSession(sess); + state.sessions.delete(localId); + sess.id = sess.bridgeSessionId; + state.sessions.set(sess.id, sess); + } catch (e) { showError(t('err.newSession') + ': ' + (e.message || e)); } + setActiveSession(sess.id); + saveSessions(); renderSessionList(); - return sess; +} +function sessionNeedsHydrate(sess) { + return !!(sess?.bridgeSessionId && state.bridgeReady && !sess.messages.length); +} + +function runSessionHydrate(sess) { + setSessionLoading(true); + const tid = setTimeout(() => { + if (isActive(sess)) setSessionLoading(false); + }, HYDRATE_LOADING_TIMEOUT_MS); + return hydrateSession(sess).finally(() => { + clearTimeout(tid); + if (isActive(sess)) setSessionLoading(false); + }); } function setActiveSession(id) { - // Save scroll position of current session before switching - if (state.activeId) { - const prevRuntime = state.runtimeBySessionId.get(state.activeId); - if (prevRuntime) prevRuntime.scrollPos = messagesEl.scrollTop; - } + setSessionLoading(false); state.activeId = id; + if (id) localStorage.setItem('ga_active', id); // 持久化当前会话,刷新后固定恢复它 const sess = state.sessions.get(id); if (!sess) return; - sessionTitleEl.textContent = sess.title; - renderMessages(); + if (msgsEl) msgsEl.innerHTML = ''; + const r = rt(sess); + r.draftEl = null; + resetTypewriterState(r); + renderAllMessages(sess); + setBusy(sess, rt(sess).busy); renderSessionList(); - renderDiagnostics(); - const runtime = getSessionRuntime(sess); - setBusy(runtime.busy, runtime.busy ? 'Agent is responding…' : null, sess); - // When switching to a session that is still running, ensure the live draft - // is rendered immediately and polling is active (it may have been started - // earlier but its render calls were no-ops because the session wasn't active). - if (runtime.busy) { - const draft = runtime.assistantDraft; - if (draft && !draft.finalized) { - renderAssistantDraftInPlace(sess, draft); - } - // Restart polling if it stopped (e.g. page reload or race condition) - if (!runtime.polling) { - runtime.forcePollOnce = true; - pollSessionMessages(sess); - } else { - // Polling is running but was rendering as no-op while we were away. - // Do an immediate one-shot poll to refresh the view right now. - (async () => { - try { - const res = await GaBridge.pollSession(sess.bridgeSessionId || sess.id, runtime.lastPolledMessageId || 0); - if (res?.error) return; - const result = res.result || res; - for (const msg of (result.messages || [])) upsertPolledMessage(sess, msg, { partial: false }); - if (result.partial) upsertPolledMessage(sess, result.partial, { partial: true }); - } catch(e) { /* ignore, regular polling will handle it */ } - })(); - } + refreshPlanBar(null); + syncPlanPollTimer(); + if (!sess.bridgeSessionId || !state.bridgeReady) return; + if (sessionNeedsHydrate(sess)) { + runSessionHydrate(sess); + } else { + restoreElapsedBadges(sess, ensureMsgs()); + planPoll(sess); } } - -function renderSessionList() { - // Preserve the + button (must remain in DOM as anchor for insertBefore) - const newBtn = document.getElementById('new-session-btn'); - // Remove only existing tab elements, never the + button - sessionListEl.querySelectorAll('.session-tab').forEach((el) => el.remove()); - if (state.sessions.size === 0) return; - for (const sess of state.sessions.values()) { - const item = document.createElement('button'); - item.type = 'button'; - item.className = 'session-tab' + (sess.id === state.activeId ? ' active' : ''); - item.setAttribute('role', 'tab'); - item.setAttribute('aria-selected', sess.id === state.activeId ? 'true' : 'false'); - item.setAttribute('data-session-id', sess.id); - item.title = sess.title; - // ─── Drag-and-drop reorder ─── - item.draggable = true; - item.addEventListener('dragstart', (e) => { - e.dataTransfer.setData('text/plain', sess.id); - e.dataTransfer.effectAllowed = 'move'; - item.classList.add('dragging'); - }); - item.addEventListener('dragend', () => { - item.classList.remove('dragging'); - sessionListEl.querySelectorAll('.session-tab.drag-over').forEach(el => el.classList.remove('drag-over')); - }); - item.addEventListener('dragover', (e) => { - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - item.classList.add('drag-over'); - }); - item.addEventListener('dragleave', () => { - item.classList.remove('drag-over'); - }); - item.addEventListener('drop', (e) => { - e.preventDefault(); - item.classList.remove('drag-over'); - const draggedId = e.dataTransfer.getData('text/plain'); - if (draggedId && draggedId !== sess.id) { - reorderSession(draggedId, sess.id); - } - }); - // Per-tab status dot - const dot = document.createElement('span'); - dot.className = 'tab-dot'; - const runtime = getSessionRuntime(sess); - if (runtime && runtime.busy) dot.classList.add('busy'); - item.appendChild(dot); - // Tab label - const label = document.createElement('span'); - label.className = 'tab-label'; - label.textContent = sess.title; - item.appendChild(label); - // Close button (Chrome-style ×) - const closeBtn = document.createElement('span'); - closeBtn.className = 'tab-close'; - closeBtn.setAttribute('role', 'button'); - closeBtn.setAttribute('aria-label', 'Close tab'); - closeBtn.textContent = '×'; - closeBtn.addEventListener('click', (e) => { - e.stopPropagation(); - closeSession(sess.id); - }); - item.appendChild(closeBtn); - item.addEventListener('click', () => setActiveSession(sess.id)); - sessionListEl.insertBefore(item, newBtn); - } -} - -// ─── Tab drag reorder helper ───────────────────────────────────────────────── -function reorderSession(draggedId, targetId) { - const entries = [...state.sessions.entries()]; - const fromIdx = entries.findIndex(([id]) => id === draggedId); - const toIdx = entries.findIndex(([id]) => id === targetId); - if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return; - const [moved] = entries.splice(fromIdx, 1); - entries.splice(toIdx, 0, moved); - state.sessions = new Map(entries); - renderSessionList(); -} - -function closeSession(id) { - if (state.sessions.size <= 1) return; // Don't close the last tab - // Notify bridge to delete this session +async function closeSession(id) { const sess = state.sessions.get(id); if (sess && sess.bridgeSessionId) { - const bridgeUrl = window.ga.bridgeUrl || 'http://127.0.0.1:14168'; - fetch(`${bridgeUrl}/session/${sess.bridgeSessionId}`, { method: 'DELETE' }).catch(() => {}); + try { await window.ga.rpc('session/cancel', { sessionId: sess.bridgeSessionId }); } catch (_) {} + fetch(`${BRIDGE_ORIGIN}/session/${sess.bridgeSessionId}`, { method: 'DELETE' }).catch(() => {}); } - const keys = [...state.sessions.keys()]; - const idx = keys.indexOf(id); - state.sessions.delete(id); - state.runtimeBySessionId.delete(id); + state.sessions.delete(id); state.runtime.delete(id); if (state.activeId === id) { - // Switch to adjacent tab (prefer right, fallback left) - const newIdx = Math.min(idx, keys.length - 2); - const remaining = [...state.sessions.keys()]; - setActiveSession(remaining[Math.max(0, Math.min(newIdx, remaining.length - 1))]); - } else { - renderSessionList(); + const next = (sortedSessions()[0] || {}).id || null; // 切到列表最靠上的会话 + if (next) setActiveSession(next); + else { state.activeId = null; localStorage.removeItem('ga_active'); if (msgsEl) msgsEl.innerHTML = ''; refreshEmptyState(null); refreshStatusLabel(); } } + saveSessions(); + renderSessionList(); } -async function newSession() { - if (!state.bridgeReady) { - showError('Bridge is not ready yet. Please wait a moment.'); +const convMenu = document.getElementById('conv-menu'); +let menuTargetId = null; +convListEl.addEventListener('click', (e) => { + const more = e.target.closest('.ci-more'); + if (more) { + e.stopPropagation(); + menuTargetId = more.closest('.conv-item').dataset.id; + // 根据当前会话置顶状态切菜单文案:置顶 / 取消置顶 + const tgt = state.sessions.get(menuTargetId); + const pinSpan = convMenu.querySelector('[data-act="pin"] [data-i18n]'); + if (pinSpan) { + const k = tgt && tgt.pinned ? 'ctx.unpin' : 'ctx.pin'; + pinSpan.setAttribute('data-i18n', k); + pinSpan.textContent = t(k); + } + convMenu.hidden = false; + const rect = more.getBoundingClientRect(); + convMenu.style.top = (rect.bottom + 4) + 'px'; + convMenu.style.left = (rect.right - convMenu.offsetWidth) + 'px'; return; } - const previousSess = state.sessions.get(state.activeId) || null; - // Don't mark previousSess as busy - it's not doing anything - // Just show status text without changing any tab dot - const statusEl = $('status'); - if (statusEl) statusEl.textContent = 'Creating session…'; - let createdSess = null; - try { - const cwd = await getCwd(); - const res = await window.ga.rpc('session/new', { cwd, mcp_servers: [] }); - if (res.error) throw new Error(typeof res.error === 'string' ? res.error : (res.error.message || JSON.stringify(res.error))); - const bridgeSessionId = res.sessionId; - const localSessionId = `local-${Date.now()}-${Math.random().toString(16).slice(2)}`; - createdSess = createLocalSession(localSessionId, 'New chat', bridgeSessionId); - createdSess.cwd = cwd; - setActiveSession(localSessionId); - } catch (e) { - showError('Failed to create session: ' + e.message); - } finally { - setBusy(false, null, createdSess || previousSess); + const it = e.target.closest('.conv-item'); + if (it && it.dataset.id) { + setActiveSession(it.dataset.id); + const chatNav = nav.querySelector('.nav-item[data-page="chat"]'); + if (chatNav && !chatNav.classList.contains('active')) chatNav.click(); } -} - -async function getCwd() { - // Use GA root as default cwd - const status = await window.ga.checkStatus(); - return status.gaRoot; -} - -// ─── Messages rendering ────────────────────────────────────────────────── -// DOM cache: sessionId -> { fragment, scrollTop } -const _domCache = new Map(); - -function renderMessages() { - const sess = state.sessions.get(state.activeId); - const runtime = sess ? getSessionRuntime(sess) : null; - - // Save current DOM + scroll to cache for previous session - if (state._prevRenderedId && state._prevRenderedId !== state.activeId) { - const frag = document.createDocumentFragment(); - while (messagesEl.firstChild) frag.appendChild(messagesEl.firstChild); - _domCache.set(state._prevRenderedId, { - fragment: frag, - scrollTop: runtime ? (state.runtimeBySessionId.get(state._prevRenderedId)?.scrollPos ?? 0) : 0, - }); - } - - if (runtime) { - runtime.currentTurnEl = null; - runtime.lastMessageType = null; - } - - const hasSavedMessages = !!sess && sess.messages.length > 0; - const hasDraft = !!runtime?.assistantDraft && !runtime.assistantDraft.finalized; - if (!sess || (!hasSavedMessages && !hasDraft)) { - messagesEl.innerHTML = ''; - messagesEl.classList.add('empty'); - messagesEl.innerHTML = ` -
-
New task
-
Task me anything. Type /help for commands.
-
`; - state._prevRenderedId = state.activeId; - return; - } - - messagesEl.classList.remove('empty'); - - // Try to restore from cache - const cached = _domCache.get(state.activeId); - if (cached) { - messagesEl.innerHTML = ''; - messagesEl.appendChild(cached.fragment); - _domCache.delete(state.activeId); - // If there's a live draft, the cached DOM is stale — re-render the draft portion - if (hasDraft) { - // Remove the stale assistant wrap (last unfinalized msg-assistant element) - const last = messagesEl.lastElementChild; - if (last?.classList?.contains('msg-assistant') && last.dataset.finalized !== '1') { - last.remove(); - } - renderAssistantDraft(sess, runtime.assistantDraft); - } - messagesEl.scrollTop = cached.scrollTop; - } else { - messagesEl.innerHTML = ''; - for (const m of sess.messages) renderMessage(m, false); - if (hasDraft) renderAssistantDraft(sess, runtime.assistantDraft); - messagesEl.scrollTop = messagesEl.scrollHeight; - } - state._prevRenderedId = state.activeId; -} - -function prepareMessagesForContent() { - if (messagesEl.classList.contains('empty')) messagesEl.innerHTML = ''; - messagesEl.classList.remove('empty'); -} - -function renderMessage(msg, append = true) { - prepareMessagesForContent(); - - if (msg.role === 'user') { - const wrap = document.createElement('div'); - wrap.className = 'msg msg-user'; - let imagesHtml = ''; - const ids = msg.image_ids || []; - if (ids.length > 0) { - imagesHtml = '
' + ids.map(id => { - const dataUrl = sessionStorage.getItem('img:' + id); - if (dataUrl) { - return ``; - } - return `🖼`; - }).join('') + '
'; - } - wrap.innerHTML = `
${imagesHtml}${escapeHtml(msg.content)}
`; - messagesEl.appendChild(wrap); - const sess = state.sessions.get(state.activeId); - const runtime = sess ? getSessionRuntime(sess) : null; - if (runtime) { - runtime.currentTurnEl = null; // reset turn grouping on user message - runtime.lastMessageType = 'user'; - } - } else if (msg.role === 'system') { - const wrap = document.createElement('div'); - wrap.className = 'msg msg-system'; - wrap.textContent = msg.content; - messagesEl.appendChild(wrap); - } else if (msg.role === 'error') { - const wrap = document.createElement('div'); - wrap.className = 'msg msg-error'; - wrap.textContent = msg.content; - messagesEl.appendChild(wrap); - const sess = state.sessions.get(state.activeId); - const runtime = sess ? getSessionRuntime(sess) : null; - if (runtime) runtime.currentTurnEl = null; - } else if (msg.role === 'assistant') { - // Final full message (when reloading from state) - const wrap = document.createElement('div'); - wrap.className = 'msg msg-assistant'; - if (msg.segments) { - ensureAssistantTaskElapsed(wrap, msg.taskStartedAt, msg.taskEndedAt); - for (const seg of msg.segments) { - wrap.appendChild(buildTurn(seg.kind, seg.text, seg.collapsed, nextTurnIndexForWrap(wrap))); - } +}); +convMenu.addEventListener('click', (e) => { + e.stopPropagation(); + const act = e.target.closest('.ctx-item')?.dataset.act; + const sess = menuTargetId && state.sessions.get(menuTargetId); + if (sess && act === 'pin') { + if (sess.pinned) { + sess.pinned = false; // 取消置顶 + 放到 pinned 之后、其它 unpinned 之前(unpinned 区域顶部) + const others = [...state.sessions.values()].filter(s => s.id !== sess.id); + const m = new Map(); + for (const s of others) if (s.pinned) m.set(s.id, s); // 先所有仍 pinned 的 + m.set(sess.id, sess); // 再本会话(刚 unpinned) + for (const s of others) if (!s.pinned) m.set(s.id, s); // 再其它 unpinned + state.sessions = m; } else { - const body = document.createElement('div'); - body.className = 'assistant-response md'; - ensureAssistantTaskElapsed(wrap, msg.taskStartedAt, msg.taskEndedAt); - const cleanContent = (msg.content || '').replace(/\n*`{5}\n*\[Info\] Final response to user\.\n*`{5}\s*$/, ''); - renderStructuredMarkdownInto(body, cleanContent); - injectCopyButtons(body); - wrap.appendChild(body); + sess.pinned = true; // 置顶 + 移到列表顶 + const m = new Map(); m.set(sess.id, sess); + for (const [k, v] of state.sessions) if (k !== sess.id) m.set(k, v); + state.sessions = m; } - injectCopyButtons(wrap); - messagesEl.appendChild(wrap); - } - if (append) scrollToBottom(); -} - -function buildTurn(kind, text, collapsed, index) { - const turn = document.createElement('div'); - turn.className = 'turn' + (collapsed ? ' collapsed' : ''); - turn.dataset.kind = kind; - const turnIndex = Number(index || 0); - const label = turnIndex ? turnHeaderLabel(turnIndex, kind) : kind; - const summary = summarizeStructuredBlock(kind, text); - const header = document.createElement('div'); - header.className = 'turn-header'; - header.innerHTML = `▼${escapeHtml(label)}${escapeHtml(summary)}`; - header.addEventListener('click', () => turn.classList.toggle('collapsed')); - const body = document.createElement('div'); - body.className = 'turn-body md'; - const hasToolDetail = Boolean(parseToolDetails(kind, text)); - renderToolDetailInto(body, { kind, text }); - if (!hasToolDetail) { - const rendered = document.createElement('div'); - rendered.className = 'turn-rendered-md'; - rendered.innerHTML = renderMarkdown(text); - body.appendChild(rendered); - } - turn.appendChild(header); - turn.appendChild(body); - injectCopyButtons(body); - return turn; -} - -function isNearBottom(threshold = 150) { - return messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < threshold; -} - -function scrollToBottom(smooth = true) { - messagesEl.scrollTo({ top: messagesEl.scrollHeight, behavior: smooth ? 'smooth' : 'auto' }); -} - -// ─── Streaming chunks (from ACP bridge notifications) ──────────────────── -// ACP sends method='session/update' with params.update.sessionUpdate= -// agent_message_chunk | agent_thought_chunk | tool_call | tool_call_update | plan | available_commands_update -function handleNotification(msg) { - // Handle WS session-state notifications from the bridge backend. - // These have {type: "session-state", sessionId, state, status, seq, ...} - // and are used to kick-start polling for sessions that became active - // (e.g. after page reload, or when a background session starts running). - if (msg.type === 'session-state') { - const sess = findSessionByBridgeId(msg.sessionId); - if (!sess) return; - const runtime = getSessionRuntime(sess); - if ((msg.state === 'running' || msg.status === 'running') && !runtime.polling) { - runtime.busy = true; - runtime.forcePollOnce = true; - setBusy(true, 'Thinking…', sess); - pollSessionMessages(sess); - } else if (msg.state === 'idle' || msg.state === 'error' || msg.status === 'idle') { - // Session finished in background — do a final poll to pick up remaining messages - if (!runtime.polling && runtime.busy) { - runtime.forcePollOnce = true; - pollSessionMessages(sess); - } - } - // Update tab dot regardless + saveSessions(); + patchSession(sess, { pinned: sess.pinned }); renderSessionList(); - return; - } - if (msg.method !== 'session/update') return; - const update = msg.params?.update; - if (!update) return; - const kind = update.sessionUpdate; - const bridgeSessionId = msg.params?.sessionId || update.sessionId || update.session?.id; - const sess = findSessionByBridgeId(bridgeSessionId); - if (!sess) return; - - - if (kind === 'agent_message_chunk') { - const text = extractText(update.content); - appendAssistantChunk(sess, text); - } else if (kind === 'task_started') { - hideError(); - startTaskTimer(sess); - setBusy(true, 'Thinking…', sess); - } else if (kind === 'task_completed' || kind === 'cancelled') { - finalizeAssistantReply(sess); - setBusy(false, null, sess); - hideError(); - } else if (kind === 'error') { - finalizeAssistantReply(sess); - setBusy(false, null, sess); - const errText = update.message || update.error || 'Bridge error'; - sess.messages.push({ role: 'error', content: errText }); - if (isActiveSession(sess)) renderMessage({ role: 'error', content: errText }); - showError(errText); - } else if (kind === 'agent_thought_chunk') { - const text = extractText(update.content); - appendStreamChunk(sess, kind, text); - } else if (kind === 'tool_call') { - const toolName = update.title || update.name || update.kind || update.toolCallId || 'tool'; - const args = update.arguments || update.args || update.input || update.content || ''; - const argText = typeof args === 'string' ? args : JSON.stringify(args, null, 2); - const text = ` - -${escapeHtml(argText)} - -`; - appendTurn(sess, 'TOOL_CALL', text, true); - } else if (kind === 'tool_call_update') { - // Status updates, keep simple - if (update.status && update.status !== 'in_progress') { - appendTurn(sess, 'tool', `[${update.status}] ${update.toolCallId || ''}`, true); - } - } else if (kind === 'plan') { - const lines = (update.entries || []).map(e => - `- [${e.status || 'pending'}] ${e.content || ''}` - ).join('\n'); - appendTurn(sess, 'plan', lines, false); - } -} - -function extractText(content) { - if (!content) return ''; - if (typeof content === 'string') return content; - if (content.type === 'text') return content.text || ''; - if (Array.isArray(content)) return content.map(extractText).join(''); - return ''; -} - -function getLiveAssistantWrap(sess) { - if (!isActiveSession(sess)) return null; - const last = messagesEl.lastElementChild; - if (last?.classList?.contains('msg-assistant') && last.dataset.finalized !== '1') return last; - const wrap = document.createElement('div'); - wrap.className = 'msg msg-assistant'; - const runtime = getSessionRuntime(sess); - if (runtime.taskStartedAt) { - wrap.dataset.taskStartedAt = String(runtime.taskStartedAt); - ensureAssistantTaskElapsed(wrap, runtime.taskStartedAt); - } - messagesEl.appendChild(wrap); - return wrap; -} - -function getAssistantDraft(sess) { - const runtime = getSessionRuntime(sess); - if (!runtime.assistantDraft || runtime.assistantDraft.finalized) { - runtime.assistantDraft = { - text: '', - segments: [], - currentSegmentIndex: -1, - taskStartedAt: runtime.taskStartedAt || 0, - taskEndedAt: 0, - finalized: false, - bridgeMessageId: 0 + } else if (sess && act === 'rename') { + convMenu.hidden = true; + const item = convListEl.querySelector(`.conv-item[data-id="${sess.id}"]`); + if (!item) return; + const titleEl = item.querySelector('.ci-title'); + if (!titleEl) return; + const oldTitle = sess.title || ''; + const inp = document.createElement('input'); + inp.className = 'ci-rename-input'; + inp.maxLength = 50; + inp.value = oldTitle; + titleEl.replaceWith(inp); + bindToastLimit(inp); + inp.focus(); + inp.select(); + const finish = (save) => { + if (inp._done) return; + inp._done = true; + const val = inp.value.trim(); + if (save && val && val !== oldTitle) { + sess.title = val; + sess.untitled = false; + saveSessions(); + patchSession(sess, { title: val, untitled: false }); + const history = tokLoadHistory(); + const sid = sess.bridgeSessionId || sess.id; + let changed = false; + history.forEach(h => { if (h.sessionId === sid) { h.title = val; changed = true; } }); + if (changed) tokSaveHistory(history); + } + renderSessionList(); }; - } - if (!runtime.assistantDraft.taskStartedAt && runtime.taskStartedAt) runtime.assistantDraft.taskStartedAt = runtime.taskStartedAt; - return runtime.assistantDraft; -} - -function renderAssistantDraft(sess, draft) { - if (!isActiveSession(sess) || !draft || draft.finalized) return null; - prepareMessagesForContent(); - const wrap = document.createElement('div'); - wrap.className = 'msg msg-assistant'; - if (draft.taskStartedAt) { - wrap.dataset.taskStartedAt = String(draft.taskStartedAt); - ensureAssistantTaskElapsed(wrap, draft.taskStartedAt, draft.taskEndedAt); - } - if (draft.text) { - wrap.dataset.buf = draft.text; - const body = document.createElement('div'); - body.className = 'assistant-response md'; - renderStructuredMarkdownInto(body, draft.text); - injectCopyButtons(body); - if (!draft.finalized) body.insertAdjacentHTML('beforeend', ''); - wrap.appendChild(body); - } - for (const seg of draft.segments || []) { - wrap.appendChild(buildTurn(seg.kind, seg.text, seg.collapsed, nextTurnIndexForWrap(wrap))); - } - injectCopyButtons(wrap); - messagesEl.appendChild(wrap); - return wrap; -} - -function renderAssistantDraftInPlace(sess, draft) { - if (!isActiveSession(sess) || !draft || draft.finalized) return null; - prepareMessagesForContent(); - const runtime = getSessionRuntime(sess); - const wrap = getLiveAssistantWrap(sess); - if (draft.taskStartedAt || runtime.taskStartedAt) { - const startedAt = draft.taskStartedAt || runtime.taskStartedAt; - wrap.dataset.taskStartedAt = String(startedAt); - ensureAssistantTaskElapsed(wrap, startedAt, draft.taskEndedAt); - } - wrap.dataset.buf = draft.text || ''; - let body = wrap.querySelector('.assistant-response'); - if (!body) { - body = document.createElement('div'); - body.className = 'assistant-response md'; - // Keep plain assistant text before folded/tool turns. - const firstTurn = wrap.querySelector('.turn'); - if (firstTurn) wrap.insertBefore(body, firstTurn); - else wrap.appendChild(body); - } - renderStructuredMarkdownInto(body, draft.text || ''); - injectCopyButtons(body); - body.insertAdjacentHTML('beforeend', ''); - if (isNearBottom()) scrollToBottom(false); - return wrap; -} - -function appendAssistantChunk(sess, text) { - if (!text) return; - const runtime = getSessionRuntime(sess); - const draft = getAssistantDraft(sess); - draft.text += text; - draft.currentSegmentIndex = -1; - runtime.currentTurnEl = null; - if (!isActiveSession(sess)) return; - prepareMessagesForContent(); - let wrap = getLiveAssistantWrap(sess); - let body = wrap.querySelector('.assistant-response'); - if (!body) { - body = document.createElement('div'); - body.className = 'assistant-response md'; - wrap.appendChild(body); - } - wrap.dataset.buf = draft.text; - ensureAssistantTaskElapsed(wrap, draft.taskStartedAt || runtime.taskStartedAt); - renderStructuredMarkdownInto(body, draft.text); - body.insertAdjacentHTML('beforeend', ''); - if (isNearBottom()) scrollToBottom(false); -} - -function appendStreamChunk(sess, kind, text) { - if (!text) return; - // Group consecutive chunks of same kind into one turn (fold_turns style) - const runtime = getSessionRuntime(sess); - const draft = getAssistantDraft(sess); - let seg = draft.segments[draft.currentSegmentIndex]; - if (!seg || seg.kind !== kind) { - seg = { kind, text: '', collapsed: false }; - draft.segments.push(seg); - draft.currentSegmentIndex = draft.segments.length - 1; - runtime.currentTurnEl = null; - } - seg.text += text; - if (!isActiveSession(sess)) return; - let turn = runtime.currentTurnEl; - const currentKind = turn?.dataset.kind; - if (!turn || currentKind !== kind) { - turn = createStreamingTurn(sess, kind); - runtime.currentTurnEl = turn; - } - turn.dataset.buf = seg.text; - const body = turn.querySelector('.turn-body'); - const { summary, remaining: cleanText } = stripLeadingMetaTags(seg.text); - // Update the header turn-summary span (visible when collapsed) - const summarySpan = turn.querySelector('.turn-summary'); - if (summarySpan && summary) { - summarySpan.textContent = summary; - } - // Only render the clean body text (no summary-hint in body) - body.innerHTML = renderMarkdown(cleanText) + ''; - if (isNearBottom()) scrollToBottom(false); -} - -function appendTurn(sess, kind, text, collapsed) { - const draft = getAssistantDraft(sess); - draft.segments.push({ kind, text, collapsed: !!collapsed }); - draft.currentSegmentIndex = -1; - if (!isActiveSession(sess)) return; - prepareMessagesForContent(); - const wrap = getLiveAssistantWrap(sess); - wrap.appendChild(buildTurn(kind, text, collapsed, nextTurnIndexForWrap(wrap))); - if (isNearBottom()) scrollToBottom(false); -} - -function createStreamingTurn(sess, kind) { - prepareMessagesForContent(); - const wrap = getLiveAssistantWrap(sess); - const turn = document.createElement('div'); - turn.className = 'turn'; - turn.dataset.kind = kind; - const displayKind = kind === 'agent_thought_chunk' ? 'thinking' : 'response'; - const turnIndex = nextTurnIndexForWrap(wrap); - turn.innerHTML = ` -
▼${escapeHtml(turnHeaderLabel(turnIndex, displayKind))}
-
`; - turn.querySelector('.turn-header').addEventListener('click', () => turn.classList.toggle('collapsed')); - // thinking turns collapsed by default once complete - if (kind === 'agent_thought_chunk') turn.dataset.autoCollapse = '1'; - wrap.appendChild(turn); - return turn; -} - -function getCurrentAssistantWrap(sess) { - if (!isActiveSession(sess)) return null; - const last = messagesEl.lastElementChild; - if (last?.classList?.contains('msg-assistant') && last.dataset.finalized !== '1') return last; - return null; -} - -function finalizeStreamingTurn(sess) { - const runtime = getSessionRuntime(sess); - const wrap = getCurrentAssistantWrap(sess); - const liveAssistant = wrap?.querySelector('.assistant-response'); - if (liveAssistant) { - renderStructuredMarkdownInto(liveAssistant, wrap.dataset.buf || ''); - injectCopyButtons(liveAssistant); - } - if (runtime.assistantDraft?.segments?.length) { - for (const seg of runtime.assistantDraft.segments) { - if (seg.kind === 'agent_thought_chunk') seg.collapsed = true; - } - } - if (!runtime.currentTurnEl) return; - const t = runtime.currentTurnEl; - const body = t.querySelector('.turn-body'); - // Remove cursor, strip summary/think tags; set turn-summary in header - if (body) { - const { summary: extractedSummary, remaining: cleanBuf } = stripLeadingMetaTags(t.dataset.buf || ''); - body.innerHTML = renderMarkdown(cleanBuf); - // Set the turn-summary span in the header for collapsed display - const summaryEl = t.querySelector('.turn-summary'); - if (summaryEl) { - const kind = t.dataset.kind || 'response'; - summaryEl.textContent = extractedSummary || summarizeStructuredBlock(kind, cleanBuf); - } - } - if (t.dataset.autoCollapse === '1') t.classList.add('collapsed'); - const idx = runtime.assistantDraft?.currentSegmentIndex; - if (Number.isInteger(idx) && runtime.assistantDraft?.segments?.[idx]) { - runtime.assistantDraft.segments[idx].collapsed = t.classList.contains('collapsed'); - } - runtime.currentTurnEl = null; -} - -function finalizeAssistantReply(sess) { - const endedAt = getNowMs(); - finalizeStreamingTurn(sess); - // Remove any residual blinking cursors (e.g. after RPC timeout) - messagesEl.querySelectorAll('.cursor').forEach(el => el.remove()); - const runtime = getSessionRuntime(sess); - const draft = runtime.assistantDraft; - const wrap = getCurrentAssistantWrap(sess); - if (draft && sess && !draft.finalized) { - draft.finalized = true; - draft.taskEndedAt = endedAt; - // Strip trailing [Info] Final response to user. marker (wrapped in 5 backticks) - if (draft.text) { - draft.text = draft.text.replace(/\n*`{5}\n*\[Info\] Final response to user\.\n*`{5}\s*$/, ''); - } - if (draft.segments?.length) { - const last = draft.segments[draft.segments.length - 1]; - if (last && last.text) { - last.text = last.text.replace(/\n*`{5}\n*\[Info\] Final response to user\.\n*`{5}\s*$/, ''); - } - } - const msg = { role: 'assistant', finalized: true, taskEndedAt: endedAt }; - if (draft.bridgeMessageId) msg.id = Number(draft.bridgeMessageId); - if (draft.taskStartedAt) msg.taskStartedAt = Number(draft.taskStartedAt); - if (draft.text) msg.content = draft.text; - if (draft.segments?.length) msg.segments = draft.segments.map((seg) => ({ - kind: seg.kind, - text: seg.text || '', - collapsed: !!seg.collapsed - })); - if (msg.content || msg.segments?.length) sess.messages.push(msg); - runtime.assistantDraft = null; - } - if (wrap) { - wrap.dataset.finalized = '1'; - wrap.dataset.taskEndedAt = String(endedAt); - ensureAssistantTaskElapsed(wrap, wrap.dataset.taskStartedAt || runtime.taskStartedAt || draft?.taskStartedAt, endedAt); - renderMessages(); - } else if (!isActiveSession(sess)) { - // Session finished in background — its DOM cache is stale, discard it - // so that switching to it will do a full re-render from sess.messages - _domCache.delete(sess.id); - } - stopTaskTimer(sess); -} - -// ─── Sending prompts ───────────────────────────────────────────────────── -function normalizeBridgeMessage(msg) { - return { - id: Number(msg.id || 0), - role: msg.role || 'system', - content: msg.content || '', - image_ids: msg.image_ids || [] - }; -} - -function upsertPolledMessage(sess, raw, { partial = false } = {}) { - if (!sess || !raw) return; - const msg = normalizeBridgeMessage(raw); - if (!msg.id) return; - const runtime = getSessionRuntime(sess); - if (!runtime.seenBridgeMessageIds) runtime.seenBridgeMessageIds = new Set(); - - if (partial && msg.role === 'assistant') { - const draft = getAssistantDraft(sess); - const changed = draft.bridgeMessageId !== msg.id || draft.text !== (msg.content || ''); - draft.bridgeMessageId = msg.id; - draft.text = msg.content || ''; - draft.currentSegmentIndex = -1; - draft.finalized = false; - // Polling partial updates used to call renderMessages(), which rebuilt the - // whole message list every 500ms. That destroys user fold/collapse DOM - // state and can make the live answer appear to jump/duplicate. Update only - // the live assistant draft in-place; final messages are still reconciled by - // id in the non-partial branch below. - if (changed && isActiveSession(sess)) renderAssistantDraftInPlace(sess, draft); - return; - } - - if (runtime.seenBridgeMessageIds.has(msg.id)) return; - runtime.seenBridgeMessageIds.add(msg.id); - runtime.lastPolledMessageId = Math.max(Number(runtime.lastPolledMessageId || 0), msg.id); - - const draft = runtime.assistantDraft; - if (msg.role === 'assistant' && draft && !draft.finalized && Number(draft.bridgeMessageId || 0) === msg.id) { - draft.text = msg.content || draft.text || ''; - finalizeAssistantReply(sess); - return; - } - sess.messages.push(msg); - if (isActiveSession(sess)) renderMessage(msg); -} - -async function pollSessionMessages(sess) { - if (!sess) return; - const runtime = getSessionRuntime(sess); - if (runtime.polling) return; - runtime.polling = true; - try { - while (runtime.busy || runtime.forcePollOnce) { - runtime.forcePollOnce = false; - const res = await window.ga.pollSession(sess.bridgeSessionId || sess.id, runtime.lastPolledMessageId || 0); - if (res?.error) throw new Error(res.error.message || res.error); - const result = res.result || res; - for (const msg of (result.messages || [])) upsertPolledMessage(sess, msg, { partial: false }); - if (result.partial) upsertPolledMessage(sess, result.partial, { partial: true }); - const busy = result.status === 'running' || !!result.partial; - setBusy(busy, busy ? 'Thinking…' : null, sess); - if (!busy) { - finalizeAssistantReply(sess); - break; - } - await new Promise(resolve => setTimeout(resolve, 500)); - } - } catch (e) { - addDiagnostic('error', 'Polling failed', e); - showError('Polling failed: ' + (e.message || e)); - setBusy(false, null, sess); - } finally { - runtime.polling = false; - } -} - -async function sendPrompt(text, images = []) { - if (!state.bridgeReady) { - showError('Bridge is not ready.'); - return; - } - if (!state.activeId) { - await newSession(); - if (!state.activeId) return; - } - const sess = state.sessions.get(state.activeId); - const runtime = getSessionRuntime(sess); - if (runtime.busy) return; - - // Store images in sessionStorage and collect ids - const imageIds = images.map(img => { - try { sessionStorage.setItem('img:' + img.id, img.dataUrl); } catch(e) { /* quota */ } - return img.id; - }); - - const localUserMsg = { role: 'user', content: text, image_ids: imageIds }; - sess.messages.push(localUserMsg); - renderMessage(localUserMsg); - startTaskTimer(sess); - if (sess.untitled || isUntitledSessionTitle(sess.title)) { - sess.title = text.trim().slice(0, 40) + (text.trim().length > 40 ? '…' : ''); - sess.untitled = false; - sessionTitleEl.textContent = sess.title; - renderSessionList(); - } - - setBusy(true, 'Thinking…', sess); - try { - const res = await window.ga.rpc('session/prompt', { - sessionId: await ensureBridgeSession(sess), - prompt: text, - images: images.map(img => ({id: img.id, dataUrl: img.dataUrl})), - llmNo: sess.config.llmNo + inp.addEventListener('keydown', e => { + if (e.key === 'Enter') { e.preventDefault(); finish(true); } + else if (e.key === 'Escape') { e.preventDefault(); finish(false); } }); - if (res?.error) throw new Error(res.error.message || res.error); - const acceptedUserId = Number(res.userMessageId || res.result?.userMessageId || 0); - if (acceptedUserId) { - if (!runtime.seenBridgeMessageIds) runtime.seenBridgeMessageIds = new Set(); - runtime.seenBridgeMessageIds.add(acceptedUserId); - runtime.lastPolledMessageId = Math.max(Number(runtime.lastPolledMessageId || 0), acceptedUserId); + inp.addEventListener('blur', () => finish(true)); + return; + } else if (sess && act === 'del') { + closeSession(sess.id); + } + convMenu.hidden = true; +}); +document.addEventListener('click', () => { convMenu.hidden = true; }); +newConvBtn.addEventListener('click', (e) => { e.preventDefault(); newSession(); }); + +/* ═══════════════ 轮询 + 流式 ═══════════════ */ +function normalize(m) { + const o = { id: Number(m.id || 0), role: m.role || 'system' }; + if (m.role !== 'assistant') o.content = m.content || ''; + if (typeof m.display === 'string' && m.display.length) o.display = m.display; + if (m.stopped) o.stopped = true; + if (m.images) o.images = m.images; + if (m.files) o.files = m.files; + if (m.ts) o.ts = m.ts; + // [turn_segs双轨] 透传结构化轮数组(若后端提供);落库消息与 partial 都可能带 + if (Array.isArray(m.turn_segs)) o.turn_segs = m.turn_segs; + if (typeof m.curr_turn === 'number') o.curr_turn = m.curr_turn; + if (m.role === 'assistant' && !o.turn_segs?.length && typeof m.content === 'string') o.content = m.content; + return o; +} +function upsert(sess, raw, partial) { + const m = normalize(raw); const r = rt(sess); + if (partial && m.role === 'assistant') { + if (!r.draftEl) resetTypewriterState(r); + const prevLen = (Array.isArray(r.draftSegs) ? (r.draftSegs[r.draftTurn] || '') : '').length; + r.draftSegs = draftSegsFromPartial(raw, m); + r.draftTurn = (typeof m.curr_turn === 'number') ? m.curr_turn : Math.max(0, r.draftSegs.length - 1); + const curLen = (r.draftSegs[r.draftTurn] || '').length; + const wasEmpty = prevLen === 0; + // 新 draft(含刷新/停止后再开):对齐已有 partial,避免从 0 重播打字机 + if ((!r.draftEl || wasEmpty) && curLen > 0) { + r.draftRecoverPending = true; } - runtime.forcePollOnce = true; - pollSessionMessages(sess); + const tw = r.twState; + if (tw && curLen > (r.draftStreamBaseline || 0)) { + const baseline = r.draftStreamBaseline || 0; + if (tw.shown < baseline) tw.shown = baseline; + } + r.draftStreamBaseline = curLen; + if (isActive(sess)) renderDraft(sess); + return; + } + if (!m.id || r.seen.has(m.id)) return; + r.seen.add(m.id); r.lastId = Math.max(r.lastId, m.id); + if (m.role === 'assistant' && r.draftEl) { + // done 收尾:用 final m 原地重画 bubble(保留 ljq 的"复用 draftEl 不闪烁"优化), + // 同时彻底丢掉 partial 累积的 DOM——避免 frp 高延迟下漏掉的最后一拍丢字。 + // assistantTurnSegs(m) 双轨处理 turn_segs/content,跟 msgNode refresh 路径同源。 + flushTypewriter(sess); + const segs = assistantTurnSegs(m); + const curr = resolveVisibleTurnIndex(segs, m.curr_turn); + r.draftSegs = segs; + r.draftTurn = curr; + r.streamTurn = curr; + if (!r.twState) r.twState = { shown: 0, timer: null }; + if (r.twState.timer) { clearInterval(r.twState.timer); r.twState.timer = null; } + r.twState.turn = curr; + r.twState.shown = (segs[curr] || '').length; + r.draftRecoverPending = false; + if (isActive(sess)) { + let bubble = r.draftEl.querySelector(':scope > .bubble.md'); + if (!bubble) { + bubble = document.createElement('div'); + bubble.className = 'bubble md'; + r.draftEl.appendChild(bubble); + } + bubble.innerHTML = renderAssistantTurnsHtml(segs, curr, false); + postRenderEnhance(bubble); + } + const cursor = r.draftEl.querySelector('.cursor'); + if (cursor) cursor.remove(); + if (r.taskStartedAt) { + ensureTaskElapsedBadge(r.draftEl, r.taskStartedAt, r.taskEndedAt || Date.now()); + r.taskStartedAt = null; r.taskEndedAt = null; + } + if (!r.draftEl.querySelector('.bubble-copy-btn')) { + const copyBtn = document.createElement('button'); + copyBtn.className = 'bubble-copy-btn'; + copyBtn.title = t('act.copy'); + copyBtn.innerHTML = SVG_COPY_ICON; + copyBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const text = assistantCopyText(m); + navigator.clipboard.writeText(text).then(() => { + copyBtn.innerHTML = SVG_CHECK_ICON; + setTimeout(() => { copyBtn.innerHTML = SVG_COPY_ICON; }, 1500); + }); + }); + r.draftEl.appendChild(copyBtn); + } + r.draftEl = null; r.draftSegs = null; r.draftTurn = 0; r.streamTurn = 0; + sess.messages.push(m); + refreshEmptyState(sess); + if (m.role === 'assistant' || m.role === 'user') syncAskUserUi(); + saveSessions(); + return; + } + sess.messages.push(m); appendMessage(sess, m); + saveSessions(); +} + +async function fetchSessionPoll(sess, opts = {}) { + const r = rt(sess); + const sid = sess.bridgeSessionId || sess.id; + const afterId = opts.after ?? r.lastId ?? 0; + const limit = opts.limit ?? POLL_MSG_LIMIT; + const res = await window.ga.rpc('session/poll', { sessionId: sid, afterId, limit }); + if (res?.error) throw new Error(res.error.message || res.error); + return res.result || res; +} + +function applyPollResult(sess, result) { + if (result.partial) upsert(sess, result.partial, true); + 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); + 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); + for (const raw of (messages || [])) { + const m = normalize(raw); + if (!m.id || r.seen.has(m.id)) continue; + r.seen.add(m.id); + r.lastId = Math.max(r.lastId, m.id); + sess.messages.push(m); + } + if (isActive(sess)) renderAllMessages(sess); +} + +/** 拉历史:limit=0 一次拿全量(bridge 不截断);不等 idle,running 续交给 pollSession */ +async function hydrateSession(sess) { + try { + const result = await fetchSessionPoll(sess, { after: 0, limit: 0 }); + hydrateHistoryMessages(sess, result.messages); + if (result.partial) upsert(sess, result.partial, true); + const busy = result.status === 'running' || !!result.partial; + setBusy(sess, busy); + if (isActive(sess)) applyPlanPayload(sess, result.plan); + if (busy && !rt(sess).polling) pollSession(sess); } catch (e) { - sess.messages.push({ role: 'error', content: e.message || String(e) }); - if (isActiveSession(sess)) renderMessage({ role: 'error', content: e.message || String(e) }); - setBusy(false, null, sess); + showError(t('err.poll') + ': ' + (e.message || e)); + setBusy(sess, false); + } finally { + if (isActive(sess)) { + restoreElapsedBadges(sess, ensureMsgs()); + syncAskUserUi(); + } + tokPollBridge(); } } -async function cancelPrompt() { - const sess = state.sessions.get(state.activeId); - const runtime = sess ? getSessionRuntime(sess) : null; - if (!runtime?.busy) return false; +async function pollSession(sess) { + const r = rt(sess); + if (r.polling) { r.pollAgain = true; return; } + r.polling = true; + r.pollAgain = false; + /* 手机切后台再回前台时,第一拍 fetch 经常用着死连接秒挂(Failed to fetch), + 但只要给链路 1-2 秒重建,后续就稳。原版一炸就 showError,体验糟。 + 改成:同一次 polling 循环里连续失败 ≥ MAX_ERRORS 次才放弃, + 单次失败做指数退避(1s / 2s / 4s / 8s),够 ride through 一次后台恢复抖动。 */ + const MAX_ERRORS = 5; + let consecutiveErrors = 0; try { - const res = await window.ga.rpc('session/cancel', { sessionId: sess?.bridgeSessionId || state.activeId }); - if (res.error) throw new Error(res.error.message || res.error); - setBusy(false, null, sess); // clear busy immediately; don't wait for server-side cancelled event + do { + try { + const result = await fetchSessionPoll(sess); + consecutiveErrors = 0; + const busy = applyPollResult(sess, result); + if (busy) await new Promise(z => setTimeout(z, 500)); + else { + if (r.draftEl) { r.draftEl.remove(); r.draftEl = null; r.draftSegs = null; r.draftTurn = 0; } + resetTypewriterState(r); + break; + } + } catch (innerErr) { + consecutiveErrors++; + if (consecutiveErrors >= MAX_ERRORS) throw innerErr; + const backoff = Math.min(8000, 1000 * Math.pow(2, consecutiveErrors - 1)); + await new Promise(z => setTimeout(z, backoff)); + } + } while (true); + } catch (e) { + showError(t('err.poll') + ': ' + (e.message || e)); + setBusy(sess, false); + } finally { + r.polling = false; renderSessionList(); + // 历史消息已全部加载,恢复已完成任务的耗时 badge + if (isActive(sess)) { + restoreElapsedBadges(sess, ensureMsgs()); + syncAskUserUi(); + } + tokPollBridge(); + if (r.pollAgain) { + r.pollAgain = false; + pollSession(sess); + } + } +} + +function removeUsedPendingFiles(usedFiles) { + if (!usedFiles.length) return; + const usedSids = new Set(usedFiles.map(f => f.sid)); + const touched = new Set(usedFiles.map(f => fileCtx(f))); + state.pendingFiles = state.pendingFiles.filter(f => !usedSids.has(f.sid)); + touched.forEach(ctx => renderThumbStrip(ctx)); +} + +function clearDraft(sess) { + const r = rt(sess); + resetTypewriterState(r); + if (r.draftEl) { r.draftEl.remove(); r.draftEl = null; r.draftSegs = null; r.draftTurn = 0; } +} + +async function waitSessionIdle(sess, maxMs = 4000) { + const start = Date.now(); + while (rt(sess).busy && Date.now() - start < maxMs) { + await new Promise(z => setTimeout(z, 100)); + } + return !rt(sess).busy; +} + +function setSessionLoading(on) { + if (!msgArea || !sessionLoadingEl) return; + if (on && msgArea.classList.contains('is-loading')) return; + msgArea.classList.toggle('is-session-loading', !!on); + sessionLoadingEl.hidden = !on; + if (on && sessionLoadingEl.querySelector('[data-i18n]')) { + sessionLoadingEl.querySelector('[data-i18n]').textContent = t('chat.sessionLoading'); + } +} + +function setMsgLoading(on) { + if (msgArea) msgArea.classList.toggle('is-loading', !!on); + if (msgLoading) { + msgLoading.hidden = !on; + if (on) { + setSessionLoading(false); + scrollBottom(); + } + } +} + +function setComposerLocked(on) { + if (composerEl) composerEl.classList.toggle('is-locked', !!on); + if (inputEl) inputEl.contentEditable = on ? 'false' : 'true'; // contenteditable 无 readOnly,改切 contentEditable + if (sendBtn) { + sendBtn.disabled = !!on; + sendBtn.classList.toggle('is-busy', !!on); + sendBtn.setAttribute('aria-busy', on ? 'true' : 'false'); + } +} + +/** stapp.py 同款:运行中再发 → cancel 当前轮次,等 idle 后再提交新 prompt */ +async function interruptBeforeSend(sess) { + if (!rt(sess).busy) return true; + const t0 = Date.now(); + setMsgLoading(true); + try { + clearDraft(sess); + try { + const res = await window.ga.rpc('session/cancel', { sessionId: sess.bridgeSessionId || sess.id }); + if (res?.error) throw new Error(res.error.message || res.error); + } catch (e) { + showChanToast(t('err.stop') + ': ' + (e.message || e), '', 'err'); + return false; + } + showChanToast(t('sys.interruptPrev.hint'), '', 'info'); + const idle = await waitSessionIdle(sess); + clearDraft(sess); + if (!idle) { + showChanToast(t('err.interruptTimeout'), '', 'err'); + return false; + } + return true; + } finally { + const wait = Math.max(0, MIN_MSG_LOADING_MS - (Date.now() - t0)); + if (wait) await new Promise(r => setTimeout(r, wait)); + setMsgLoading(false); + } +} + +/* ═══════════════ 发送 / 取消 ═══════════════ */ +async function sendPrompt(text) { + text = String(text || '').trim(); + if (!text) return false; + if (!state.bridgeReady) { showError(t('err.bridge')); return false; } + if (!state.activeId) { await newSession(); if (!state.activeId) return false; } + const sess = activeSess(); const r = rt(sess); + if (r.busy) { + const interrupted = await interruptBeforeSend(sess); + if (!interrupted) return false; + } + // PLAN/AUTO 现在是预设功能(preset 卡片)一次性发送,不再是常驻 prefix + const composedPrompt = expandFilePlaceholders(text).trim(); + const usedFiles = collectUsedFiles(text); + const userMsg = { role: 'user', content: text, ts: Date.now() / 1000 }; + const previewImgs = usedFiles.filter(f => f.isImage).map(f => ({ id: 'f-' + f.sid, name: f.name, path: f.path, dataUrl: f.dataUrl || '' })); + if (previewImgs.length) userMsg.images = previewImgs; + const previewFiles = usedFiles.filter(f => !f.isImage).map(f => ({ id: 'f-' + f.sid, name: f.name, path: f.path })); + if (previewFiles.length) userMsg.files = previewFiles; + sess.messages.push(userMsg); appendMessage(sess, userMsg); + if (isPlanPresetPrompt(text)) { + const pr = rt(sess); + pr.planCollapsed = false; + pr.planShowAll = false; + const sidHint = (sess.bridgeSessionId || sess.id || 'sess').replace(/\//g, '_'); + applyPlanPayload(sess, { + active: true, placeholder: true, done: 0, total: 0, complete: false, + step: '', pathHint: `plan_${sidHint}/plan.md`, items: [], + }); + } + sess.lastActiveTs = Date.now(); + // 仿 TUI:不再从首条消息自动改名 —— 标题在 newSession 时已设为 agent-N, + // 之后只接受用户手动 rename。 + saveSessions(); + setBusy(sess, true); + try { + let sid = await ensureBridgeSession(sess); + try { + await bridgeFetch(`/session/${encodeURIComponent(sid)}/restore`, { method: 'POST', body: {} }); + } catch (restoreErr) { + if (/not found/i.test(restoreErr.message || '')) { + sess.bridgeSessionId = null; + sid = await ensureBridgeSession(sess); + state.sessions.delete(sess.id); + sess.id = sess.bridgeSessionId; + state.sessions.set(sess.id, sess); + state.activeId = sess.id; + localStorage.setItem('ga_active', sess.id); // 会话 id 因 bridge 重建而变更,同步持久化 + } + } + const res = await window.ga.rpc('session/prompt', { sessionId: sid, prompt: composedPrompt, display: text, llmNo: state.llmNo, + files: previewFiles, imageMetas: previewImgs.map(im => ({ name: im.name, path: im.path })) }); + if (res?.error) throw new Error(res.error.message || res.error); + removeUsedPendingFiles(usedFiles); + const uid = Number(res.userMessageId || res.result?.userMessageId || 0); + if (uid) { r.seen.add(uid); r.lastId = Math.max(r.lastId, uid); } + planPoll(sess); + pollSession(sess); return true; } catch (e) { - showSystem('Stop failed: ' + (e.message || e)); + const em = { role: 'error', content: e.message || String(e) }; + sess.messages.push(em); appendMessage(sess, em); + setBusy(sess, false); return false; } } - -// ─── Slash commands ────────────────────────────────────────────────────── -async function handleSlash(cmd) { - const [name, ...rest] = cmd.trim().slice(1).split(/\s+/); - const arg = rest.join(' '); - const sess = state.sessions.get(state.activeId); - - switch (name) { - case 'help': - showSystem([ - 'Available commands:', - ' /new New session', - ' /clear Clear current session display', - ' /stop Cancel the current request', - ' /theme Switch theme (light|dark|auto)', - ].join('\n')); - break; - case 'new': - await newSession(); - break; - case 'clear': - if (sess) { sess.messages = []; renderMessages(); } - break; - case 'stop': - if (await cancelPrompt()) showSystem('Stop requested.'); - break; - case 'restart': - await restartBridge(); - break; - case 'settings': - openSettings(); - break; - case 'theme': - if (['light', 'dark', 'auto'].includes(arg)) { - const cfg = getActiveConfig(); - cfg.theme = arg; - applyTheme(); - await window.ga.saveConfig(cfg); - showSystem(`Theme → ${arg}`); - } else { - showSystem('Usage: /theme light|dark|auto'); - } - break; - case 'cwd': - if (!arg) { - const status = await window.ga.checkStatus(); - showSystem(`cwd: ${sess?.cwd || status.gaRoot}`); - } else { - showSystem(`Creating new session in ${arg}…`); - // Need a new session for different cwd - const res = await window.ga.rpc('session/new', { cwd: arg, mcp_servers: [] }); - if (res.error) showSystem('Failed: ' + (res.error.message || res.error)); - else { - const bridgeSessionId = res.sessionId; - const localSessionId = `local-${Date.now()}-${Math.random().toString(16).slice(2)}`; - const ns = createLocalSession(localSessionId, arg.split('/').pop() || arg, bridgeSessionId); - ns.cwd = arg; - setActiveSession(localSessionId); - } - } - break; - default: - showSystem(`Unknown command: /${name}. Try /help.`); - } +async function cancelPrompt() { + const sess = activeSess(); + if (!sess || !rt(sess).busy) return false; + try { + const res = await window.ga.rpc('session/cancel', { sessionId: sess.bridgeSessionId || sess.id }); + if (res?.error) throw new Error(res.error.message || res.error); + clearDraft(sess); + rt(sess).pollAgain = true; + return true; + } catch (e) { showError(t('err.stop') + ': ' + (e.message || e)); return false; } } +/* ═══════════════ 输入区 / slash / 预设 ═══════════════ */ +async function submitInput() { + if (_submitInFlight) return; + let text = composerText('chat'); + if (!text.trim()) return; + if (text.trim().startsWith('/')) { + inputEl.innerHTML = ''; + handleSlash(text.trim()); + return; + } + if (text.length > 20000) { + text = text.slice(0, 20000); + showToast(t('err.charLimit').replace('{n}', 20000), 'warn'); + } + _submitInFlight = true; + setComposerLocked(true); + try { + const sent = await sendPrompt(text); + if (sent) { + inputEl.innerHTML = ''; + } + } finally { + _submitInFlight = false; + setComposerLocked(false); + syncAskUserUi(); + } +} +sendBtn.addEventListener('click', (e) => { + e.preventDefault(); + const sess = activeSess(); + if (sess && rt(sess).busy) { cancelPrompt(); return; } // 运行中:发送键是录制键 → 纯停止 + submitInput(); +}); +inputEl.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) { e.preventDefault(); submitInput(); } +}); +// 输入框的 input/paste 监听统一在 bindComposerUpload(ctx) 里绑(chat + collab 通用) function showSystem(text) { - const msg = { role: 'system', content: text }; - const sess = state.sessions.get(state.activeId); - if (sess) sess.messages.push(msg); - renderMessage(msg); - return msg; + const sess = activeSess(); if (!sess) return; + const m = { role: 'system', content: text }; + sess.messages.push(m); appendMessage(sess, m); } - -function updateBridgeNotice(text) { - const notice = state.bridgeNoticeMessage; - state.bridgeNoticeMessage = null; - if (!notice) return; - notice.content = text; - const sess = state.sessions.get(state.activeId); - if (sess && sess.messages.includes(notice)) renderMessages(); +function showError(text) { + const sess = activeSess(); + if (sess) { const m = { role: 'error', content: text }; sess.messages.push(m); appendMessage(sess, m); } + else console.error(text); } - -// ─── Status / UI helpers ───────────────────────────────────────────────── -function setStatus(kind, text) { - statusBadge.className = 'badge ' + kind; - statusText.textContent = text; - // Update per-tab dot for active session - updateTabDot(state.activeId, kind); +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); } - -function updateTabDot(sessionId, kind) { - if (!sessionId) return; - const tab = sessionListEl.querySelector(`[data-session-id="${sessionId}"]`); - if (!tab) return; - const dot = tab.querySelector('.tab-dot'); - if (!dot) return; - dot.className = 'tab-dot'; - if (kind === 'busy') dot.classList.add('busy'); - else if (kind === 'warn') dot.classList.add('warn'); - else if (kind === 'err') dot.classList.add('err'); - // 'ok' = default green (no extra class needed) -} - -const SEND_ICON = ``; -const STOP_ICON = ``; - -function setBusy(busy, label, sess = state.sessions.get(state.activeId)) { - const runtime = sess ? getSessionRuntime(sess) : null; - if (runtime) runtime.busy = busy; - // Always update per-tab dot for this session - if (sess) { - const dotKind = busy ? 'busy' : (state.bridgeReady ? 'ok' : 'warn'); - updateTabDot(sess.id, dotKind); +async function handleSlash(cmd) { + const name = cmd.slice(1).split(/\s+/)[0]; + switch (name) { + case 'help': showSystem(t('slash.help')); break; + case 'new': await newSession(); break; + case 'clear': { const s = activeSess(); if (s) { s.messages = []; renderAllMessages(s); } break; } + case 'stop': if (await cancelPrompt()) showSystem(t('sys.stopRequested')); break; + case 'settings': openSettings(); break; + default: showSystem(t('slash.unknown') + ': /' + name); } - if (!isActiveSession(sess)) return; - if (busy) setStatus('busy', label || 'Working…'); - else setStatus(state.bridgeReady ? 'ok' : 'warn', state.bridgeReady ? 'Ready' : 'Starting…'); - renderSendButtonState(); +} +// 预设卡:按 data-preset 解耦(与翻译后的标题无关) +document.querySelectorAll('.feature-grid').forEach(grid => { + grid.addEventListener('click', (e) => { + const editBtn = e.target.closest('.fc-edit'); + if (editBtn) { + e.stopPropagation(); + const cp = state.customPresets.find(p => p.id === editBtn.dataset.editId); + if (cp) openCustomPresetEditor(cp); + return; + } + const xBtn = e.target.closest('.fc-x'); + if (xBtn) { + e.stopPropagation(); + const kind = xBtn.dataset.removeKind; + const id = xBtn.dataset.removeId; + if (kind === 'builtin') hideBuiltinPreset(id); + else if (kind === 'custom') removeCustomPreset(id); + return; + } + const card = e.target.closest('.fcard'); + if (!card || !grid.contains(card)) return; + const key = card.dataset.preset; + if (key === 'add') { closeModals(); openModal('custom-preset-modal'); resetCustomPresetForm(); return; } + if (card.classList.contains('fcard-custom')) { + const id = card.dataset.id; + const cp = state.customPresets.find(p => p.id === id); + if (cp) { closeModals(); sendPrompt(cp.prompt); } + return; + } + if (!key) { inputEl.focus(); closeModals(); return; } + const bp = BUILTIN_PRESETS.find(p => p.key === key); + if (bp?.navigate) { closeModals(); gaGoPage(bp.navigate); window.collabFocus?.(); return; } + const prompt = I18N[lang]['presetPrompt.' + key] || I18N.zh['presetPrompt.' + key]; + closeModals(); + if (prompt) sendPrompt(prompt); + }); +}); + +/* ═══════════════ 模型 / 设置 ═══════════════ */ +function updateModelChip() { + const name = state.modelName || ''; + 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.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; + +function setModelApikeyMode(isAdd) { + const apikey = document.getElementById('model-apikey-input'); + const apikeyReq = document.querySelector('#model-apikey-label .field-req'); + if (!apikey) return; + apikey.required = isAdd; + apikey.dataset.i18nPh = isAdd ? 'model.apikeyPh' : 'model.apikeyKeep'; + if (isAdd) apikey.removeAttribute('data-optional-ph'); + else { apikey.value = ''; apikey.setAttribute('data-optional-ph', ''); } + if (apikeyReq) apikeyReq.hidden = !isAdd; } -function renderSendButtonState() { - const hasText = inputEl.value.trim().length > 0; - const busy = !!getActiveSessionRuntime()?.busy; - sendBtn.classList.toggle('stop', busy); - sendBtn.title = busy ? 'Stop (Esc)' : 'Send (Enter)'; - sendBtn.innerHTML = busy ? STOP_ICON : SEND_ICON; - sendBtn.disabled = !hasText && !busy; +/* ═══════════════ 官方模型快速接入(DeepSeek / 通义千问)═══════════════ */ +// 预填 API 地址 / 协议 / 模型,用户只需粘贴 API Key。apibase 末尾的 /v1 会被 +// 后端自动补成 /v1/chat/completions(见 mykey_template.py 的拼接规则)。 +const PROVIDER_PRESETS = { + deepseek: { + label: 'DeepSeek', descKey: 'pq.deepseekDesc', + protocol: 'oai', apibase: 'https://api.deepseek.com/v1', + model: 'deepseek-v4-pro', name: 'DeepSeek', + keyUrl: 'https://platform.deepseek.com/api_keys', + color: '#4D6BFE', tint: 'rgba(77,107,254,.12)', + logo: '', + }, + qwen: { + label: '通义千问', descKey: 'pq.qwenDesc', + protocol: 'oai', apibase: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + model: 'qwen3.6-max-preview', name: '通义千问', + keyUrl: 'https://bailian.console.aliyun.com/?apiKey=1', + color: '#615CED', tint: 'rgba(97,92,237,.12)', + logo: '', + }, +}; +window.gaProviderPresets = PROVIDER_PRESETS; + +// 在「添加模型」弹窗顶部显示/隐藏接入指引横幅。key 为 null 时隐藏。 +function setModelGuide(key) { + const box = document.getElementById('model-guide'); + if (!box) return; + const p = key && PROVIDER_PRESETS[key]; + if (!p) { box.hidden = true; box.dataset.provider = ''; return; } + box.hidden = false; + box.dataset.provider = key; + const logo = document.getElementById('model-guide-logo'); + if (logo) { logo.innerHTML = p.logo || ''; logo.style.background = p.tint || ''; } + const nameEl = document.getElementById('model-guide-name'); + if (nameEl) nameEl.textContent = p.label; + const link = document.getElementById('model-guide-link'); + if (link) { link.href = p.keyUrl; link.textContent = t('guide.getKey').replace('{name}', p.label); } +} +window.gaRefreshModelGuide = () => { + const box = document.getElementById('model-guide'); + if (box && !box.hidden && box.dataset.provider) setModelGuide(box.dataset.provider); +}; + +function openAddModelFormForProvider(key) { + const p = PROVIDER_PRESETS[key]; + if (!p) return openAddModelForm(); + editingModelId = null; + const form = document.getElementById('add-model-form'); + const title = document.getElementById('model-form-title'); + const errEl = document.getElementById('add-model-err'); + if (title) title.dataset.i18n = 'modal.addModel'; + if (form) { + form.reset(); + form.model.value = p.model || ''; + form.apibase.value = p.apibase || ''; + form.name.value = p.name || ''; + const pr = form.querySelector(`input[name="protocol"][value="${p.protocol}"]`); + if (pr) pr.checked = true; + } + setModelApikeyMode(true); + if (errEl) { errEl.hidden = true; errEl.textContent = ''; } + setModelGuide(key); + openModal('add-model-modal'); + applyI18n(); + const apikey = document.getElementById('model-apikey-input'); + if (apikey) setTimeout(() => apikey.focus(), 60); } -function updateSendButton() { - renderSendButtonState(); +function openAddModelForm() { + editingModelId = null; + const form = document.getElementById('add-model-form'); + const title = document.getElementById('model-form-title'); + const errEl = document.getElementById('add-model-err'); + if (title) title.dataset.i18n = 'modal.addModel'; + if (form) form.reset(); + setModelApikeyMode(true); + setModelGuide(null); + if (errEl) { errEl.hidden = true; errEl.textContent = ''; } + openModal('add-model-modal'); + applyI18n(); } +async function openEditModelForm(id) { + editingModelId = id; + setModelGuide(null); + const errEl = document.getElementById('add-model-err'); + if (errEl) { errEl.hidden = true; errEl.textContent = ''; } + try { + const res = await bridgeFetch(`/model-profiles/${id}`); + const p = res.profile; + if (!p) throw new Error(t('err.modelSave')); + const form = document.getElementById('add-model-form'); + const title = document.getElementById('model-form-title'); + if (title) title.dataset.i18n = 'modal.editModel'; + if (form) { + form.model.value = p.model || ''; + form.apibase.value = p.apibase || ''; + form.name.value = p.name || ''; + form.max_retries.value = p.max_retries ?? 5; + form.connect_timeout.value = p.connect_timeout ?? 15; + form.read_timeout.value = p.read_timeout ?? 300; + // 编辑模式:按 varName 回填协议分段控件 + 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) { + showChanToast(t('err.modelSave'), ex.message || '', 'err'); + } +} +async function deleteModel(id, name) { + const label = profileLabel(name) || name || ('#' + id); + 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')); + const wasActive = state.llmNo === id; + const oldNo = state.llmNo; + state.modelProfiles = normalizeProfiles(res.profiles || []); + if (wasActive) { + const p = state.modelProfiles[0]; + if (p) await selectModel(p.id ?? 0, p.name); + else { state.llmNo = 0; state.modelName = null; updateModelChip(); } + } else if (oldNo > id) { + const p = state.modelProfiles[oldNo - 1]; + if (p) await selectModel(p.id ?? (oldNo - 1), p.name); + } + renderSettingsModels(); + } catch (ex) { + const msg = ex.message || ''; + showChanToast(msg.includes('last profile') ? t('err.modelDeleteLast') : t('err.modelDelete'), msg.includes('last profile') ? '' : msg, 'err'); + } +} +function renderSettingsModels() { + const box = document.getElementById('model-list'); + if (!box) return; + box.innerHTML = ''; + const list = state.modelProfiles || []; + 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])); -function showError(text, actionLabel, actionFn, options = {}) { - if (!options.skipDiagnostic) addDiagnostic('error', text); - $('error-text').textContent = text; - const actionBtn = $('error-action'); - if (actionLabel && actionFn) { - actionBtn.textContent = actionLabel; - actionBtn.classList.remove('hidden'); - actionBtn.onclick = async () => { - try { - await actionFn(); - } catch (err) { - showError('Action failed: ' + (err.message || err)); + // ── 渠道组(自动故障转移):可展开组;本身也可被选为激活模型 ── + 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 = `${GA_ICON(expanded ? 'caretDown' : 'caretRight')}${escapeHtml(t('model.aggregation'))}`; + 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 = `${escapeHtml(profileLabel(mName) || mName)}`; + 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); } else { - actionBtn.classList.add('hidden'); - } - errorBanner.classList.remove('hidden'); - clearTimeout(showError._t); - if (!actionLabel) { - showError._t = setTimeout(() => errorBanner.classList.add('hidden'), 6000); + 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 ? '' : ``; + row.innerHTML = `${escapeHtml(label)}${mixToggle}`; + 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 hideError() { errorBanner.classList.add('hidden'); } - -// ─── Theme ─────────────────────────────────────────────────────────────── -function applyTheme() { - const cfg = getActiveConfig(); - document.documentElement.setAttribute('data-theme', cfg.theme || 'auto'); +function openSettings() { + openModal('settings-modal'); + renderSettingsModels(); + renderLangList(); + applyTheme(theme, { persist: false }); + applyAppearance(appearance, plainUi, { persist: false }); + applyChatFontSize(chatFontSize, { persist: false }); } - -// ─── Settings modal ────────────────────────────────────────────────────── -function renderModelOptions() { - const select = $('cfg-llm'); - const selected = String(getActiveConfig().llmNo || 0); - const profiles = Array.isArray(state.modelProfiles) ? state.modelProfiles : []; - const options = profiles.length ? profiles : [{ llmNo: 0, name: 'Default / Auto' }]; - select.textContent = ''; - for (const profile of options) { - const opt = document.createElement('option'); - opt.value = String(profile.llmNo); - // Display as "name/model" when both fields available - const displayName = profile.name && profile.model - ? `${profile.name}/${profile.model}` - : profile.name || profile.model || `Model ${profile.llmNo}`; - opt.textContent = displayName; - select.appendChild(opt); - } - if (![...select.options].some((opt) => opt.value === selected)) { - const opt = document.createElement('option'); - opt.value = selected; - opt.textContent = selected === '0' ? 'Default / Auto' : `Model ${selected}`; - select.appendChild(opt); - } - select.value = selected; -} - async function loadModelProfiles() { try { - const result = await window.ga.getModelProfiles(); - state.modelProfiles = Array.isArray(result && result.profiles) ? result.profiles : []; - renderModelOptions(); - } catch (err) { - addDiagnostic('warn', 'Failed to load model names', err); - renderModelOptions(); - } -} - -function openSettings() { - renderModelOptions(); - const cfg = getActiveConfig(); - $('cfg-llm').value = String(cfg.llmNo || 0); - settingsModal.classList.remove('hidden'); - loadModelProfiles(); -} -function closeSettings() { settingsModal.classList.add('hidden'); } - -async function openConfigFile(openFn, label) { - try { - const result = await openFn(); - if (result && result.ok === false) { - showError(`Failed to open ${label}: ${result.error || result.path || 'unknown error'}`); + const res = await window.ga.getModelProfiles(); + const list = res?.profiles || res?.result?.profiles || []; + state.modelProfiles = normalizeProfiles(list); + const active = state.modelProfiles.find(p => p.active) || state.modelProfiles[0]; + if (active) { + state.llmNo = active.id ?? 0; + state.modelName = modelDisplayName(active); } - } catch (err) { - showError(`Failed to open ${label}: ${err.message || err}`); + updateModelChip(); + renderSettingsModels(); + } catch (_) {} +} +/* ═══════════════ 模型菜单(chat + conductor 共用一份逻辑,各自一个 DOM) ═══════════════ */ +const modelMenu = document.getElementById('model-menu'); +const collabModelMenu = document.getElementById('cdb-model-menu'); +function renderModelMenu(menuEl) { + if (!menuEl) return; + const list = state.modelProfiles || []; + const rows = list.map((p, i) => { + const no = (p.id ?? i); + const isActive = (state.llmNo === no) ? ' active' : ''; + const label = (isActive && p.kind === 'mixin' && state.modelName) ? state.modelName : modelDisplayName(p); + return `
${escapeHtml(label || '')}
`; + }); + menuEl.innerHTML = rows.join(''); + applyI18n(); +} +function openModelMenu(chipEl, menuEl) { + if (!chipEl || !menuEl) return; + if (typeof convMenu !== 'undefined' && convMenu) convMenu.hidden = true; + window.collabComposer?.closeMenu?.(); + closeAllModelMenus(); + renderModelMenu(menuEl); + menuEl.hidden = false; + chipEl.classList.add('open'); + const chipRect = chipEl.getBoundingClientRect(); + const composer = chipEl.closest('.composer'); + if (composer) { + const composerRect = composer.getBoundingClientRect(); + menuEl.style.left = (chipRect.left - composerRect.left) + 'px'; + menuEl.style.bottom = (composerRect.bottom - chipRect.top + 4) + 'px'; } } +function closeAllModelMenus() { + if (modelMenu) modelMenu.hidden = true; + if (collabModelMenu) collabModelMenu.hidden = true; + if (modelChip) modelChip.classList.remove('open'); + if (collabModelChip) collabModelChip.classList.remove('open'); +} +function bindModelMenuItemClick(menuEl) { + if (!menuEl) return; + menuEl.addEventListener('click', (e) => { + e.stopPropagation(); + const item = e.target.closest('.ga-menu-item'); + if (!item) return; + const no = parseInt(item.dataset.llmno, 10); + if (Number.isNaN(no)) return; + const p = (state.modelProfiles || []).find(x => (x.id ?? 0) === no); + selectModel(no, (p && p.name) || ''); + closeAllModelMenus(); + }); +} +bindModelMenuItemClick(modelMenu); +bindModelMenuItemClick(collabModelMenu); +if (modelChip) modelChip.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); + if (modelMenu && !modelMenu.hidden) { closeAllModelMenus(); return; } + openModelMenu(modelChip, modelMenu); +}); +if (collabModelChip) collabModelChip.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); + if (collabModelMenu && !collabModelMenu.hidden) { closeAllModelMenus(); return; } + openModelMenu(collabModelChip, collabModelMenu); +}); +document.addEventListener('click', (e) => { + if (e.target.closest('#model-menu') || e.target.closest('#model-chip') || + e.target.closest('#cdb-model-menu') || e.target.closest('#cdb-model-chip') || + e.target.closest('#chat-menu') || e.target.closest('#chat-plus-btn') || + e.target.closest('#cdb-menu') || e.target.closest('#cdb-plus-btn')) return; + closeAllModelMenus(); + window.chatComposer?.closeMenu?.(); + window.collabComposer?.closeMenu?.(); +}); +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + closeAllModelMenus(); + window.chatComposer?.closeMenu?.(); + window.collabComposer?.closeMenu?.(); + } +}); -async function saveSettings() { - const saveBtn = $('save-settings'); - saveBtn.disabled = true; +// 主题色板已删除,点击事件不再注册 +const appearanceSeg = document.getElementById('appearance-seg'); +if (appearanceSeg) appearanceSeg.addEventListener('click', (e) => { + const btn = e.target.closest('.appear-card[data-appearance]'); + if (!btn) return; + const isLight = btn.dataset.appearance === 'light'; + applyAppearance(btn.dataset.appearance, isLight && plainUi); +}); +const plainUiSwitch = document.getElementById('plain-ui-switch'); +if (plainUiSwitch) plainUiSwitch.addEventListener('click', () => { + if (appearance === 'light') applyAppearance('light', !plainUi); +}); +async function loadBridgeConfig() { try { - const sess = state.sessions.get(state.activeId); - if (!sess) throw new Error('No active session'); - const cfg = sess.config; - cfg.llmNo = Math.max(0, parseInt($('cfg-llm').value, 10) || 0); - await window.ga.saveConfig(cfg); - closeSettings(); - } catch (err) { - showError('Failed to save settings: ' + (err.message || err)); - } finally { - saveBtn.disabled = false; - } -} - -async function ensureBridgeSession(sess) { - if (!sess) throw new Error('No active session.'); - if (sess.bridgeSessionId) return sess.bridgeSessionId; - const cwd = sess.cwd || await getCwd(); - const res = await window.ga.rpc('session/new', { cwd, mcp_servers: [] }); - if (res.error) throw new Error(typeof res.error === 'string' ? res.error : (res.error.message || JSON.stringify(res.error))); - sess.bridgeSessionId = res.sessionId; - sess.cwd = cwd; - return sess.bridgeSessionId; -} - -async function restartBridge(options = {}) { - const { remapSessions = false } = options; - setStatus('warn', 'Restarting…'); - state.bridgeReady = false; - state.restartingBridge = true; - if (remapSessions) { - for (const sess of state.sessions.values()) sess.bridgeSessionId = null; - } - state.bridgeNoticeMessage = showSystem('Bridge restarting…'); - await window.ga.startBridge(getActiveConfig().llmNo || 0); - window.setTimeout(() => { - if (state.restartingBridge && !state.bridgeReady && !getActiveSessionRuntime()?.busy) { - markBridgeReady('Bridge ready.'); - addDiagnostic('warn', 'Bridge ready event timeout; restored Ready status locally'); + const res = await window.ga.getConfig(); + const cfg = res?.config || {}; + if (LANGS.includes(cfg.lang)) { + lang = cfg.lang; + applyI18n(); } - }, 2500); + if (cfg.theme != null) applyTheme(cfg.theme, { persist: false }); + if (cfg.appearance) applyAppearance(cfg.appearance, !!cfg.plain, { persist: false }); + if (cfg.fontSize != null) applyChatFontSize(cfg.fontSize, { persist: false }); + if (cfg.llmNo != null && state.modelProfiles.length) { + const p = state.modelProfiles.find(x => (x.id ?? 0) === cfg.llmNo); + if (p) { + state.llmNo = cfg.llmNo; + state.modelName = modelDisplayName(p); + updateModelChip(); + renderSettingsModels(); + } + } + syncBootCache(); + } catch (_) {} } -// ─── Bridge events ─────────────────────────────────────────────────────── -let _bootstrappingSession = false; -async function markBridgeReady(noticeText = 'Bridge ready.') { - if (state.bridgeReady) return; // already marked ready, prevent double-fire - state.bridgeReady = true; - state.restartingBridge = false; - if (getActiveSessionRuntime()?.busy) setStatus('busy', 'Agent is responding…'); - else setStatus('ok', 'Ready'); - updateBridgeNotice(noticeText); - hideError(); - // Restore sessions from bridge (survives page refresh) or create first session - if (state.sessions.size === 0 && !_bootstrappingSession) { - _bootstrappingSession = true; +const addModelForm = document.getElementById('add-model-form'); +if (addModelForm) addModelForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const errEl = document.getElementById('add-model-err'); + const fd = new FormData(addModelForm); + const payload = Object.fromEntries(fd.entries()); + const isEdit = editingModelId != null; + if (!payload.apibase?.trim() || !payload.model?.trim()) { + if (errEl) { errEl.textContent = t('err.modelRequired'); errEl.hidden = false; } + return; + } + if (!isEdit && !payload.apikey?.trim()) { + if (errEl) { errEl.textContent = t('err.modelRequired'); errEl.hidden = false; } + return; + } + try { + const res = isEdit + ? await bridgeFetch(`/model-profiles/${editingModelId}`, { method: 'PUT', body: payload }) + : await bridgeFetch('/model-profiles', { method: 'POST', body: payload }); + if (res?.ok === false || res?.error) throw new Error(res.error || t('err.modelSave')); + state.modelProfiles = normalizeProfiles(res.profiles || []); + const pid = isEdit ? editingModelId : (res.profileId ?? state.modelProfiles.at(-1)?.id ?? 0); + const p = state.modelProfiles.find(x => (x.id ?? 0) === pid) || state.modelProfiles.at(-1); + if (p) await selectModel(p.id ?? pid, p.name); + document.getElementById('add-model-modal').hidden = true; + addModelForm.reset(); + editingModelId = null; + if (errEl) errEl.hidden = true; + } catch (ex) { + if (errEl) { errEl.textContent = (ex.message || t('err.modelSave')); errEl.hidden = false; } + } +}); + +/* ═══════════════ 文件上传(图片+任意文件,tuiapp_v2 模式) ═══════════════ */ +const MAX_UPLOAD_FILES = 10; +const MAX_UPLOAD_BYTES = 500 * 1024 * 1024; // 500 MB +const IMG_EXT_RE = /\.(png|jpe?g|gif|webp|bmp|svg)$/i; +const thumbStrip = document.getElementById('thumb-strip'); +const chatPanel = document.querySelector('main.main'); +let activeFileComposer = 'chat'; + +function fileCtx(f) { return f.ctx || 'chat'; } +function filesForCtx(ctx) { return state.pendingFiles.filter(f => fileCtx(f) === ctx); } + +function composerPageEl(ctx) { + const page = ctx === 'collab' ? 'collab' : 'chat'; + return document.querySelector(`.page--chat-ui[data-page="${page}"]`); +} +function composerRootEl(ctx) { + return composerPageEl(ctx)?.querySelector('.composer'); +} +function composerCfg(ctx = activeFileComposer) { + const root = composerRootEl(ctx); + const page = composerPageEl(ctx); + return { + input: root?.querySelector('.composer-inset .input') || null, + strip: root?.querySelector('.thumb-strip') || null, + uploadBtn: null, + imgInput: root?.querySelector('input[type="file"]') || null, + dropZone: ctx === 'collab' ? page : chatPanel, + }; +} + +function renderThumbStrip(ctx = activeFileComposer) { + const cfg = composerCfg(ctx); + if (!cfg.strip) return; + const files = filesForCtx(ctx); + if (files.length === 0) { + cfg.strip.innerHTML = ''; + cfg.strip.hidden = true; + return; + } + cfg.strip.innerHTML = files.map(f => { + if (f.isImage && f.dataUrl) { + return `
`; + } + const name = f.name || 'file'; + const label = name.replace(/[<>&]/g, c => ({ '<': '<', '>': '>', '&': '&' }[c])); + const sub = fileSubLabel(name).replace(/[<>&]/g, c => ({ '<': '<', '>': '>', '&': '&' }[c])); + const path = (f.path || '').replace(/[<>&"]/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[c])); + const dataName = name.replace(/[<>&"]/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[c])); + return `
${GA_ICON('fileText')}${label}${sub}
`; + }).join(''); + cfg.strip.hidden = false; + applyI18n(); +} + +// 在 ctx 对应输入框(contenteditable)的光标处插入原子 chip(删不进中间,像 @人) +function insertPlaceholderInComposer(file, ctx = activeFileComposer) { + const input = composerCfg(ctx).input; + if (!input) return; + const chip = document.createElement('span'); + chip.className = 'ph-chip'; + chip.setAttribute('contenteditable', 'false'); + chip.dataset.sid = String(file.sid); + chip.dataset.kind = file.isImage ? 'image' : 'file'; + chip.textContent = file.name || 'file'; + input.focus(); + const sel = window.getSelection(); + let range; + if (sel && sel.rangeCount && input.contains(sel.getRangeAt(0).commonAncestorContainer)) { + range = sel.getRangeAt(0); + } else { + range = document.createRange(); range.selectNodeContents(input); range.collapse(false); + } + range.deleteContents(); + range.insertNode(chip); + const sp = document.createTextNode(' '); // chip 后补一个 nbsp,便于继续打字/定位光标 + chip.after(sp); + range.setStartAfter(sp); range.collapse(true); + if (sel) { sel.removeAllRanges(); sel.addRange(range); } + input.dispatchEvent(new Event('input', { bubbles: true })); +} + +// 按 sid 从该文件所属 ctx 的输入框移除 chip(连同紧邻空格) +function removePlaceholderFromComposer(file) { + const input = composerCfg(fileCtx(file)).input; + if (!input) return; + const chip = input.querySelector(`.ph-chip[data-sid="${file.sid}"]`); + if (!chip) return; + const next = chip.nextSibling; + if (next && next.nodeType === 3) next.nodeValue = next.nodeValue.replace(/^[\s ]/, ''); + chip.remove(); + input.dispatchEvent(new Event('input', { bubbles: true })); +} + +// 读取 contenteditable 输入框为纯文本:chip → [Image #N]/[File #N],
/
→ 换行 +function readComposerTextFrom(input) { + if (!input) return ''; + const ser = (node, first) => { + if (node.nodeType === 3) return node.nodeValue; + if (node.nodeType !== 1) return ''; + if (node.classList && node.classList.contains('ph-chip')) { + const kind = node.dataset.kind === 'image' ? 'Image' : 'File'; + return `[${kind} #${node.dataset.sid}]`; + } + if (node.tagName === 'BR') return '\n'; + let inner = ''; + node.childNodes.forEach(c => { inner += ser(c, false); }); + return (first ? '' : '\n') + inner; + }; + let out = ''; + input.childNodes.forEach((n, i) => { out += ser(n, i === 0); }); + return out.replace(/ /g, ' '); +} + +function composerText(ctx = activeFileComposer) { + return readComposerTextFrom(composerCfg(ctx).input); +} + +function isImageFile(f) { + return (f && (f.type || '').startsWith('image/')) || IMG_EXT_RE.test(f?.name || ''); +} + +function placeholderFor(file) { + return file.isImage ? `[Image #${file.sid}]` : `[File #${file.sid}]`; +} + +function expandFilePlaceholders(text) { + return text.replace(/\[(Image|File) #(\d+)\]/g, (m, kind, n) => { + const f = state.pendingFiles.find(x => x.sid === Number(n)); + return (f && f.path) ? f.path : ''; // #3 悬空占位符(无对应文件)→ 删掉,不把垃圾发给 agent + }); +} + +function collectUsedFiles(text) { + const used = []; + text.replace(/\[(Image|File) #(\d+)\]/g, (m, kind, n) => { + const f = state.pendingFiles.find(x => x.sid === Number(n)); + if (f) used.push(f); + return m; + }); + return used; +} + +// ── 附件占位符健壮性 ───────────────────────────────────────────── +// 统一移除一个待发附件:出列 + (可选)抹占位符 + 重绘 + 删 bridge 上的文件 +function removePendingFile(sid, { stripPlaceholder = false } = {}) { + const idx = state.pendingFiles.findIndex(f => f.sid === sid); + if (idx < 0) return; + const removed = state.pendingFiles.splice(idx, 1)[0]; + if (stripPlaceholder) removePlaceholderFromComposer(removed); + renderThumbStrip(fileCtx(removed)); + if (removed.path) { + fetch(`${BRIDGE_ORIGIN}/upload`, { + method: 'DELETE', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: removed.path }), + }).catch(() => {}); + } +} +// #1 对账:DOM 里 chip 没了(被原子删除/退格整块删)→ 同步移除附件 + 删磁盘文件 +function reconcilePendingFiles(ctx = activeFileComposer) { + const input = composerCfg(ctx).input; + if (!input) return; + const present = new Set([...input.querySelectorAll('.ph-chip[data-sid]')].map(c => Number(c.dataset.sid))); + for (const f of filesForCtx(ctx).filter(x => !present.has(x.sid))) { + removePendingFile(f.sid, { stripPlaceholder: false }); + } +} + +async function uploadOne(name, dataUrl, sid) { + const res = await fetch(`${BRIDGE_ORIGIN}/upload`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, dataUrl, sid: sid || '' }), + }); + const j = await res.json(); + if (!j.ok) throw new Error(j.error || 'upload failed'); + return j.path; +} + +async function addFiles(fileList) { + const files = Array.from(fileList || []); + if (files.length === 0) return; + let skipped = false; + let emptyHit = false; + const accepted = []; + for (const f of files) { + if (!f || f.size === 0) { emptyHit = true; continue; } + if (f.size > MAX_UPLOAD_BYTES) { skipped = true; continue; } + if (state.pendingFiles.length + accepted.length >= MAX_UPLOAD_FILES) { skipped = true; break; } + accepted.push(f); + } + if (emptyHit) showChanToast(t('upload.empty'), '', 'err'); + if (accepted.length === 0) { + if (skipped) showChanToast(t('upload.tooLarge'), '', 'err'); + return; + } + const ctx = activeFileComposer; + let uploadSid = ''; + if (ctx === 'collab') { + uploadSid = 'collab'; + } else { + let upSess = activeSess(); + if (!upSess) { await newSession(); upSess = activeSess(); } + if (upSess && !upSess.bridgeSessionId) { try { await ensureBridgeSession(upSess); } catch (_) {} } + uploadSid = (upSess && upSess.bridgeSessionId) || ''; + } + for (const f of accepted) { try { - // Try to restore existing sessions from bridge - const bridgeUrl = window.ga.bridgeUrl || 'http://127.0.0.1:14168'; - const listRes = await fetch(`${bridgeUrl}/sessions`).then(r => r.json()).catch(() => null); - const existingSessions = listRes?.sessions || []; - if (existingSessions.length > 0) { - // Restore each session from bridge - for (const bSess of existingSessions) { - const localId = `local-${Date.now()}-${Math.random().toString(16).slice(2)}`; - const sess = createLocalSession(localId, bSess.title || 'Restored', bSess.id || bSess.sessionId); - // Fetch full messages for this session - const sid = bSess.id || bSess.sessionId; - const msgRes = await fetch(`${bridgeUrl}/session/${sid}/messages?after=0&limit=9999`).then(r => r.json()).catch(() => null); - if (msgRes?.messages) { - sess.messages = msgRes.messages; - // Initialize polling state so we don't re-fetch these messages - const runtime = getSessionRuntime(sess); - runtime.seenBridgeMessageIds = new Set(); - let maxId = 0; - for (const m of msgRes.messages) { - if (m.id) { runtime.seenBridgeMessageIds.add(Number(m.id)); maxId = Math.max(maxId, Number(m.id)); } - } - runtime.lastPolledMessageId = maxId; - } - } - // Activate the first session - const firstLocalId = [...state.sessions.keys()][0]; - if (firstLocalId) setActiveSession(firstLocalId); - } else { - await newSession(); - } - } finally { _bootstrappingSession = false; } - } - updateSendButton(); - // Refresh model profiles from bridge (authoritative source) - loadModelProfiles(); -} - -window.ga.onBridgeReady(() => { - markBridgeReady(); -}); - -window.ga.onBridgeMessage(() => { - // RPC responses are resolved in main; renderer readiness comes from bridge-ready. -}); - -window.ga.onBridgeNotification((msg) => { - handleNotification(msg); -}); - -window.ga.onBridgeError((err) => { - console.error('Bridge error:', err); - addDiagnostic('error', 'Bridge error', err); - setStatus('err', 'Error'); - state.bridgeReady = false; - state.restartingBridge = false; - - if (err.type === 'no-mykey') { - showError(err.message, 'Setup', async () => { - await window.ga.openMykeyTemplate(); - }, { skipDiagnostic: true }); - } else if (err.type === 'no-python') { - showError(err.message, 'Settings', openSettings, { skipDiagnostic: true }); - } else { - showError(err.message || 'Bridge error', null, null, { skipDiagnostic: true }); - } -}); - -window.ga.onBridgeClosed((info) => { - addDiagnostic('warn', 'Bridge closed', info); - if (state.restartingBridge) { - setStatus('warn', 'Restarting…'); - return; - } - state.bridgeReady = false; - // Clear busy flag on all sessions so pending poll loops can exit cleanly - for (const [sid, runtime] of state.runtimeBySessionId) { - if (runtime.busy) setBusy(false, null, state.sessions.get(sid)); - } - setStatus('err', `Bridge stopped (${info.code})`); -}); - -window.ga.onBridgeLog((text) => { - console.log('[bridge]', text); - addDiagnostic('info', 'Bridge log', text); -}); - -// ─── Input handling ────────────────────────────────────────────────────── -inputEl.addEventListener('input', () => { - // auto-resize - inputEl.style.height = 'auto'; - inputEl.style.height = Math.min(inputEl.scrollHeight, 200) + 'px'; - updateSendButton(); -}); - -// IME composition fix - triple guard for CJK input methods (macOS especially) -let _imeComposing = false; -inputEl.addEventListener('compositionstart', () => { _imeComposing = true; }); -inputEl.addEventListener('compositionend', () => { _imeComposing = false; }); - -inputEl.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - if (e.isComposing || _imeComposing || e.keyCode === 229) return; // IME active, ignore - e.preventDefault(); - submitInput(); - } else if (e.key === 'Escape' && getActiveSessionRuntime()?.busy) { - e.preventDefault(); - cancelPrompt(); - } -}); - -// ─── Image paste handling ───────────────────────────────────────────────── -const imagePreviews = document.getElementById('image-previews'); -const pendingImages = []; // Array of { dataUrl, id } - -inputEl.addEventListener('paste', (e) => { - const items = e.clipboardData?.items; - if (!items) return; - for (const item of items) { - if (item.type.startsWith('image/')) { - e.preventDefault(); - const file = item.getAsFile(); - if (!file) continue; - const reader = new FileReader(); - reader.onload = () => { - const dataUrl = reader.result; - const id = `img-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; - pendingImages.push({ dataUrl, id }); - renderImagePreviews(); + const dataUrl = await new Promise((resolve, reject) => { + const r = new FileReader(); + r.onload = () => resolve(String(r.result || '')); + r.onerror = () => reject(r.error); + r.readAsDataURL(f); + }); + const path = await uploadOne(f.name || 'file', dataUrl, uploadSid); + state.fileSeq += 1; + const sid = state.fileSeq; + const isImage = isImageFile(f); + const entry = { + sid, name: f.name || 'file', isImage, path, + dataUrl: isImage ? dataUrl : '', + ctx, }; - reader.readAsDataURL(file); - break; // handle one image per paste + state.pendingFiles.push(entry); + insertPlaceholderInComposer(entry, ctx); + renderThumbStrip(ctx); + } catch (e) { + showChanToast(t('upload.failed'), e.message || String(e), 'err'); } } -}); - -function renderImagePreviews() { - imagePreviews.innerHTML = ''; - for (const img of pendingImages) { - const wrapper = document.createElement('div'); - wrapper.className = 'image-preview-item'; - wrapper.dataset.imgId = img.id; - - const imgEl = document.createElement('img'); - imgEl.src = img.dataUrl; - imgEl.alt = 'Pasted image'; - - const closeBtn = document.createElement('button'); - closeBtn.className = 'remove-img'; - closeBtn.textContent = '×'; - closeBtn.setAttribute('aria-label', 'Remove image'); - closeBtn.addEventListener('click', () => { - const idx = pendingImages.findIndex(i => i.id === img.id); - if (idx !== -1) pendingImages.splice(idx, 1); - renderImagePreviews(); - }); - - wrapper.appendChild(imgEl); - wrapper.appendChild(closeBtn); - imagePreviews.appendChild(wrapper); - } - imagePreviews.style.display = pendingImages.length ? 'flex' : 'none'; + if (skipped) showChanToast(t('upload.tooLarge'), '', 'err'); } -function clearPendingImages() { - pendingImages.length = 0; - renderImagePreviews(); -} - -function submitInput() { - const text = inputEl.value.trim(); - if (!text && pendingImages.length === 0) return; - if (getActiveSessionRuntime()?.busy) { - showSystem('Agent is still responding. Press Esc or Stop before sending another message.'); +function handleThumbStripClick(e, ctx) { + const x = e.target.closest('.x'); + if (x) { + const sid = Number(x.dataset.sid); + const idx = state.pendingFiles.findIndex(f => f.sid === sid && fileCtx(f) === ctx); + if (idx >= 0) { + const removed = state.pendingFiles[idx]; + state.pendingFiles.splice(idx, 1); + removePlaceholderFromComposer(removed); + renderThumbStrip(ctx); + if (removed.path) { + fetch(`${BRIDGE_ORIGIN}/upload`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: removed.path }), + }).catch(() => {}); + } + } return; } - const images = [...pendingImages]; - inputEl.value = ''; - inputEl.style.height = 'auto'; - clearPendingImages(); - updateSendButton(); + const fileChip = e.target.closest('.file-chip.pending'); + if (fileChip) { + const path = fileChip.getAttribute('data-path'); + const name = fileChip.getAttribute('data-name'); + if (path) openUploadFile(path, name); + return; + } + const img = e.target.closest('img'); + if (img && img.src) openLightbox(img.src); +} - if (text.startsWith('/')) { - handleSlash(text).catch((err) => { - showSystem('Command failed: ' + (err.message || err)); +function bindComposerUpload(ctx) { + const cfg = composerCfg(ctx); + if (!cfg.input || cfg.input.dataset.gaUploadBound) return; + cfg.input.dataset.gaUploadBound = ctx; + + if (cfg.uploadBtn && !cfg.uploadBtn.dataset.bound) { + cfg.uploadBtn.dataset.bound = '1'; + cfg.uploadBtn.addEventListener('click', (e) => { + e.preventDefault(); + activeFileComposer = ctx; + cfg.imgInput?.click(); }); - } else { - sendPrompt(text, images); + } + if (cfg.imgInput && !cfg.imgInput.dataset.bound) { + cfg.imgInput.dataset.bound = '1'; + cfg.imgInput.addEventListener('change', () => { + activeFileComposer = ctx; + addFiles(cfg.imgInput.files); + cfg.imgInput.value = ''; + }); + } + cfg.strip?.addEventListener('click', (e) => handleThumbStripClick(e, ctx)); + cfg.input.addEventListener('paste', (e) => { + activeFileComposer = ctx; + const cd = e.clipboardData || window.clipboardData; + const items = cd && cd.items; + const files = []; + if (items) for (const it of items) { if (it.kind === 'file') { const f = it.getAsFile(); if (f) files.push(f); } } + if (files.length) { e.preventDefault(); addFiles(files); return; } + // 富文本粘贴 → 强制纯文本(contenteditable 默认会粘 HTML,会污染输入框) + e.preventDefault(); + const text = cd ? cd.getData('text/plain').replace(/\r\n/g, '\n') : ''; + if (!text) return; + // Hard-limit: only insert what fits within maxLen + const maxLen = 20000; + const curLen = cfg.input.textContent.length; + const sel = window.getSelection(); + const selLen = (sel.rangeCount && cfg.input.contains(sel.anchorNode)) ? sel.toString().length : 0; + const remaining = maxLen - curLen + selLen; + if (remaining <= 0) { showChanToast(t('err.charLimit').replace('{n}', maxLen), '', 'err'); return; } + const insert = text.slice(0, remaining); + document.execCommand('insertText', false, insert); + if (text.length > remaining) showChanToast(t('err.charLimit').replace('{n}', maxLen), '', 'err'); + }); + cfg.input.addEventListener('input', () => { + activeFileComposer = ctx; + // 内容清空后浏览器可能残留
,抹掉以便 :empty 占位提示生效 + if (!cfg.input.textContent.trim() && !cfg.input.querySelector('.ph-chip')) cfg.input.innerHTML = ''; + reconcilePendingFiles(ctx); // chip 被删 → 同步清理附件 + 删磁盘文件 + }); + const zone = cfg.dropZone; + const dropKey = `dropBound_${ctx}`; + if (!zone || zone.dataset[dropKey]) return; + zone.dataset[dropKey] = '1'; + let dragDepth = 0; + const hasFiles = (e) => { + const types = e.dataTransfer && e.dataTransfer.types; + if (!types) return false; + for (let i = 0; i < types.length; i += 1) { + if (types[i] === 'Files') return true; + } + return false; + }; + zone.addEventListener('dragenter', (e) => { + if (!hasFiles(e)) return; + e.preventDefault(); + activeFileComposer = ctx; + dragDepth += 1; + zone.classList.add('dragover'); + zone.dataset.dropHint = t('upload.dropHint'); + }); + zone.addEventListener('dragover', (e) => { + if (!hasFiles(e)) return; + e.preventDefault(); + activeFileComposer = ctx; + e.dataTransfer.dropEffect = 'copy'; + }); + zone.addEventListener('dragleave', (e) => { + if (!hasFiles(e)) return; + dragDepth = Math.max(0, dragDepth - 1); + if (dragDepth === 0) zone.classList.remove('dragover'); + }); + zone.addEventListener('drop', (e) => { + if (!hasFiles(e)) return; + e.preventDefault(); + dragDepth = 0; + zone.classList.remove('dragover'); + activeFileComposer = ctx; + addFiles(e.dataTransfer.files); + }); +} + +bindComposerUpload('chat'); +bindComposerUpload('collab'); + +Object.assign(window, { + gaSetActiveFileComposer: ctx => { activeFileComposer = ctx === 'collab' ? 'collab' : 'chat'; }, + gaPageStatusBar: pageStatusBar, + gaExpandFilePlaceholders: expandFilePlaceholders, + gaRenderMsgChips: renderMsgTextWithChips, + gaCollectUsedFiles: collectUsedFiles, + gaComposerText: composerText, + gaClearUsedPendingFiles: text => removeUsedPendingFiles(collectUsedFiles(text)), + gaFileSubLabel: fileSubLabel, + gaMsgNode: msgNode, + gaCollabItemToMsg: collabItemToMsg, + gaPostRenderEnhance: postRenderEnhance, + gaEscapeHtml: escapeHtml, +}); + +if (chatPanel) { + const blockFileDrop = e => { + const types = e.dataTransfer?.types; + if (!types) return; + for (let i = 0; i < types.length; i += 1) if (types[i] === 'Files') { e.preventDefault(); return; } + }; + window.addEventListener('dragover', blockFileDrop); + window.addEventListener('drop', blockFileDrop); +} + +/* ═══════════════ bridge 事件 ═══════════════ */ +window.ga.onBridgeReady(async () => { + state.bridgeReady = true; + syncPlanPollTimer(); + refreshStatusLabel(); + if (!state.activeId) { refreshEmptyState(null); } + await loadModelProfiles(); + await loadBridgeConfig(); + if (isServicesPageActive()) renderChannelList(gaServiceStore.list()); + const sess = activeSess(); + if (sess && sessionNeedsHydrate(sess)) { + await runSessionHydrate(sess); + } else if (sess) planPoll(sess); + delete document.documentElement.dataset.bootHasSessions; + if (sess) refreshEmptyState(sess); +}); +setTimeout(() => { delete document.documentElement.dataset.bootHasSessions; }, 3000); +window.ga.onBridgeNotification((msg) => { + if (msg && msg.type === 'session-state') { + for (const sess of state.sessions.values()) { + if (sess.bridgeSessionId === msg.sessionId) { + if (msg.status === 'running' || msg.state === 'running') pollSession(sess); + if (msg.state === 'idle' || msg.status === 'idle') tokPollBridge(); + renderSessionList(); + break; + } + } + } +}); +window.ga.onBridgeError((err) => { console.warn('[bridge error]', err); }); +window.ga.onBridgeClosed(() => { + state.bridgeReady = false; + syncPlanPollTimer(); + const s = activeSess(); + if (s) applyPlanPayload(s, null); + chatStatus.setDisconnected(); +}); + +/* ═══════════════ 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'); +const tokTotalN = document.getElementById('tok-total-n'); +const tokTodayN = document.getElementById('tok-today-n'); +const tokCostN = document.getElementById('tok-cost-n'); +const TOK_PER_PAGE = 15; +let _tokPage = 0; +let _tokHistory = []; +let _tokLastSnap = {}; + +// Model price table: $/M tokens [input, output] +const MODEL_PRICES = { + 'gpt-5.4':[2.50,15],'gpt-5':[1.25,10],'gpt-5-mini':[0.25,2],'gpt-4o':[2.50,10],'gpt-4o-mini':[0.15,0.60], + 'gpt-4.1':[2,8],'gpt-4.1-mini':[0.40,1.60],'gpt-4.1-nano':[0.10,0.40],'o4-mini':[0.55,2.20], + 'claude-opus-4-8':[5,25],'claude-opus-4-7':[5,25],'claude-opus-4-6':[5,25],'claude-sonnet-4-6':[3,15],'claude-sonnet-4-5':[3,15],'claude-haiku-4-5':[1,5], + 'deepseek-v4':[0.14,0.28],'deepseek-v4-pro':[0.435,0.87],'deepseek-chat':[0.14,0.28],'deepseek-reasoner':[0.55,2.19], + 'glm-5.1':[0.50,0.50],'minimax-m2.7':[0.50,0.50],'kimi-for-coding':[0.50,2], +}; +const CNY_RATE = 7.2; +function estCost(inp, out, model, cacheRead, cacheCreate) { + let p = [3,15]; + if (model) { const m = model.toLowerCase().replace(/\[.*\]/,''); p = MODEL_PRICES[m] || Object.entries(MODEL_PRICES).find(([k])=>m.includes(k))?.[1] || p; } + const isClaudeOrDS = model && /claude|deepseek/i.test(model); + const cacheReadRate = isClaudeOrDS ? 0.1 : 0.5; + const cacheWriteRate = isClaudeOrDS ? 1.25 : 1.0; + const cost = (inp*p[0] + out*p[1] + (cacheRead||0)*p[0]*cacheReadRate + (cacheCreate||0)*p[0]*cacheWriteRate) / 1e6 * CNY_RATE; + return cost.toFixed(2); +} +function fmtTok(n) { return n>=1e6?(n/1e6).toFixed(2)+'M':n>=1e3?(n/1e3).toFixed(1)+'k':String(n); } +function fmtTime(ts) { return new Date(ts*1000).toLocaleString(undefined,{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}); } +function modelPriceTip(model) { + if (!model) return ''; + const m = model.toLowerCase().replace(/\[.*\]/,''); + const entry = MODEL_PRICES[m] || Object.entries(MODEL_PRICES).find(([k])=>m.includes(k))?.[1]; + const known = !!entry; + const p = entry || [3,15]; + const isClaudeOrDS = /claude|deepseek/i.test(m); + const cacheReadRate = isClaudeOrDS ? 0.1 : 0.5; + const cacheWriteRate = isClaudeOrDS ? 1.25 : 1.0; + const lines = []; + if (!known) lines.push(t('tok.pricingUnknown')); + lines.push(t('tok.priceInput') + p[0] + ' /M'); + lines.push(t('tok.priceOutput') + p[1] + ' /M'); + lines.push(t('tok.priceCacheW') + (p[0] * cacheWriteRate).toFixed(2) + ' /M'); + lines.push(t('tok.priceCacheR') + (p[0] * cacheReadRate).toFixed(2) + ' /M'); + return lines.join('\n'); +} + +function tokLoadHistory() { return _tokHistory; } +function tokSaveHistory(h) { + _tokHistory = h; + fetch(`${BRIDGE_ORIGIN}/token-history`, { + method:'POST', headers:{'Content-Type':'application/json'}, + body: JSON.stringify({history:h, snap:_tokLastSnap, conductorHist:_condHist, conductorLast:_condLast}) + }).catch(()=>{}); +} + +let _tokPolling = false; +async function tokPollBridge() { + if (_tokPolling) return; + _tokPolling = true; + try { + if (!_tokHistory.length) { + const stored = await bridgeFetch('/token-history'); + if (stored.history?.length) _tokHistory = stored.history; + if (stored.snap) _tokLastSnap = stored.snap; + if (stored.conductorHist) _condHist = stored.conductorHist; + if (stored.conductorLast) _condLast = stored.conductorLast; + } + const data = await bridgeFetch('/token-stats'); + const history = tokLoadHistory(); + for (const r of (data.records||[])) { + const key = r.thread; + const sid = key.replace('GA-',''); + const sess = [...state.sessions.values()].find(s=>s.bridgeSessionId===sid); + if (sess && rt(sess).busy) continue; + const prev = _tokLastSnap[key] || {input:0,output:0,cacheCreate:0,cacheRead:0}; + let di = r.input-prev.input, do_ = r.output-prev.output, dc = r.cacheCreate-prev.cacheCreate, dr = r.cacheRead-prev.cacheRead; + if (di<0||do_<0||dc<0||dr<0) { di = r.input; do_ = r.output; dc = r.cacheCreate; dr = r.cacheRead; } + if (di>0||do_>0||dc>0||dr>0) { + const title = sess ? displayTitle(sess) : sid; + history.push({sessionId:sid, title:title, input:di, output:do_, cacheCreate:dc, cacheRead:dr, model:r.model||'', ts:Date.now()/1000}); + if(sess?.title) history.forEach(h=>{if(h.sessionId===sid&&(!h.title||h.title===sid))h.title=sess.title;}); + } + _tokLastSnap[key] = {input:r.input, output:r.output, cacheCreate:r.cacheCreate, cacheRead:r.cacheRead}; + } + tokSaveHistory(history); + } catch(_) {} + _tokPolling = false; +} + +function tokGetFiltered() { + let records = tokLoadHistory(); + const parseD = v => v ? new Date(v.replace(/\s+/,'T')).getTime()/1000 : 0; + const since = parseD(tokSince?.value); + const until = parseD(tokUntil?.value); + if (since) records = records.filter(r=>r.ts>=since); + if (until) records = records.filter(r=>r.ts<=until); + return records; +} + +function tokRenderStats(filtered, all) { + let total=0, totalInput=0, totalCacheRead=0, totalCacheCreate=0; + filtered.forEach(r=>{total+=(r.input||0)+(r.output||0)+(r.cacheRead||0)+(r.cacheCreate||0); totalInput+=(r.input||0); totalCacheRead+=(r.cacheRead||0); totalCacheCreate+=(r.cacheCreate||0);}); + if(tokTotalN) tokTotalN.textContent=fmtTok(total); + const cacheBase = totalInput + totalCacheRead + totalCacheCreate; + if(tokCostN) tokCostN.textContent= cacheBase > 0 ? (totalCacheRead / cacheBase * 100).toFixed(1) + '%' : '0%'; + const todayStart=new Date(); todayStart.setHours(0,0,0,0); const todayTs=todayStart.getTime()/1000; + let todayT=0; all.filter(r=>r.ts>=todayTs).forEach(r=>{todayT+=(r.input||0)+(r.output||0)+(r.cacheRead||0)+(r.cacheCreate||0);}); + if(tokTodayN) tokTodayN.textContent=fmtTok(todayT); +} + +function tokRenderTable(records) { + if(!tokTbody) return; + const bySession=new Map(); + for(const r of records){ + const k=r.sessionId||'?'; + const ss= r._conductor ? null : [...state.sessions.values()].find(s=>s.bridgeSessionId===k); + let title = ss ? displayTitle(ss) : (r.title||k); + const deleted = r._conductor ? !!r._killed : !ss; + if(!bySession.has(k)) bySession.set(k,{title:title,deleted:deleted,input:0,output:0,cacheCreate:0,cacheRead:0,lastTs:0,prompts:[]}); + const s=bySession.get(k); s.input+=r.input||0; s.output+=r.output||0; s.cacheCreate+=r.cacheCreate||0; s.cacheRead+=r.cacheRead||0; + if(r.ts>s.lastTs){s.lastTs=r.ts; s.title=title;} s.prompts.push(r); + } + tokTbody.innerHTML=''; + if(bySession.size===0){tokTbody.innerHTML=`${t('tok.noData')}`;if(tokPager)tokPager.innerHTML='';return;} + const sorted=[...bySession.values()].sort((a,b)=>b.lastTs-a.lastTs); + const totalPages=Math.ceil(sorted.length/TOK_PER_PAGE); + if(_tokPage>=totalPages)_tokPage=totalPages-1; + const pageItems=sorted.slice(_tokPage*TOK_PER_PAGE,(_tokPage+1)*TOK_PER_PAGE); + for(const s of pageItems){ + const sCacheBase = s.input + s.cacheRead + s.cacheCreate; + const sCacheRate = sCacheBase > 0 ? (s.cacheRead / sCacheBase * 100).toFixed(1) + '%' : '0%'; + const tr=document.createElement('tr'); tr.className='tok-row-session'; + tr.innerHTML=`${escapeHtml(s.title)}${s.deleted?''+t('tok.deleted')+'':''}${fmtTok(s.input)}${fmtTok(s.output)}${fmtTok(s.cacheCreate)}${fmtTok(s.cacheRead)}${sCacheRate}`; + tokTbody.appendChild(tr); + const details=[]; s.prompts.sort((a,b)=>b.ts-a.ts); + for(const p of s.prompts){ + const dr=document.createElement('tr'); dr.className='tok-detail'; dr.hidden=true; + const modelHtml = p.model ? ` · ${escapeHtml(p.model)}` : ''; + const pCacheBase = (p.input||0) + (p.cacheRead||0) + (p.cacheCreate||0); + const pCacheRate = pCacheBase > 0 ? ((p.cacheRead||0) / pCacheBase * 100).toFixed(1) + '%' : '0%'; + dr.innerHTML=`${fmtTime(p.ts)}${modelHtml}${fmtTok(p.input||0)}${fmtTok(p.output||0)}${fmtTok(p.cacheCreate||0)}${fmtTok(p.cacheRead||0)}${pCacheRate}`; + tokTbody.appendChild(dr); details.push(dr); + } + tr.addEventListener('click',()=>{const o=tr.classList.toggle('open');details.forEach(d=>d.hidden=!o);}); + } + if(tokPager){renderTokPager(tokPager, totalPages, _tokPage, p => { _tokPage = p; tokRenderTable(records); });} +} + +/* 分页按钮:首页/末页 + 当前页前后各 2 + 省略号,最多渲染 ~9 个 DOM, + 1000 页和 10 页都长一样,不会一行排开拖死浏览器。 */ +function renderTokPager(host, totalPages, currentPage, onJump) { + host.innerHTML = ''; + if (totalPages <= 1) return; + const makeBtn = (label, page, opts = {}) => { + const b = document.createElement('button'); + if (opts.svg) b.innerHTML = label; + else b.textContent = label; + if (opts.active) b.classList.add('active'); + if (opts.arrow) b.classList.add('tok-pager-arrow'); + if (opts.disabled) b.disabled = true; + if (!opts.disabled) b.addEventListener('click', () => onJump(page)); + return b; + }; + const makeEllipsis = () => { + const s = document.createElement('span'); + s.className = 'tok-pager-gap'; + s.textContent = '…'; + return s; + }; + // 收集页码:1 / 当前-2..当前+2 / 末页,去重并填省略号 + const pages = new Set([0, totalPages - 1]); + for (let i = Math.max(0, currentPage - 2); i <= Math.min(totalPages - 1, currentPage + 2); i++) pages.add(i); + const sorted = [...pages].sort((a, b) => a - b); + // 首尾箭头用 phosphor 图标(跟侧栏 .chev 同款),不再用 Unicode 字符 + host.appendChild(makeBtn(GA_ICON('caretLeft'), currentPage - 1, { svg: true, arrow: true, disabled: currentPage === 0 })); + let prev = -1; + for (const p of sorted) { + if (prev >= 0 && p - prev > 1) host.appendChild(makeEllipsis()); + host.appendChild(makeBtn(String(p + 1), p, { active: p === currentPage })); + prev = p; + } + host.appendChild(makeBtn(GA_ICON('caretRight'), currentPage + 1, { svg: true, arrow: true, disabled: currentPage === totalPages - 1 })); +} + +async function loadTokenPage(){await tokPollBridge();const f=tokGetFiltered();const all=tokLoadHistory();tokRenderStats(f,all);tokRenderTable(f);} + +const _COND_HIST_KEY = 'conductor_token_hist'; +const _COND_LAST_KEY = 'conductor_token_last'; +const _condZero = {input:0,output:0,cacheCreate:0,cacheRead:0,cost:0}; +let _condHist = null, _condLast = null; +function _condLoadHist() { return _condHist || {..._condZero}; } +function _condLoadLast() { return _condLast; } +function _condSave(hist, last) { + _condHist = hist; _condLast = last; + fetch(`${BRIDGE_ORIGIN}/token-history`, { + method:'POST', headers:{'Content-Type':'application/json'}, + body: JSON.stringify({history:_tokHistory, snap:_tokLastSnap, conductorHist:hist, conductorLast:last}) + }).catch(()=>{}); +} + +/* ─── Token tab switching ─── */ +let _tokTab = 'chat'; +const tokTabs = document.getElementById('tok-tabs'); +const tokFilter = document.querySelector('.tok-filter'); +const tokStatRow = document.querySelector('.page[data-page="token"] .stat-row'); +if (tokTabs) tokTabs.addEventListener('click', e => { + const btn = e.target.closest('.tok-tab'); + if (!btn || btn.classList.contains('active')) return; + tokTabs.querySelectorAll('.tok-tab').forEach(b => b.classList.remove('active')); + 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'; 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() { + let curIn = 0, curOut = 0, curCc = 0, curCr = 0, curCost = 0; + let fetchOk = false; + try { + const data = await (await fetch(`${CONDUCTOR_ORIGIN}/token-stats`)).json(); + const recs = (data.records || []).filter(r => r.thread === 'conductor-agent' || r.thread.startsWith('subagent-')); + for (const r of recs) { + curIn += r.input || 0; curOut += r.output || 0; curCc += r.cacheCreate || 0; curCr += r.cacheRead || 0; + curCost += parseFloat(estCost(r.input || 0, r.output || 0, r.model || '', r.cacheRead || 0, r.cacheCreate || 0)); + } + fetchOk = true; + } catch (_) { + if (tokTbody) tokTbody.innerHTML = `${t('tok.condOffline')}`; + return; + } + const hist = _condLoadHist(); + const last = _condLoadLast(); + if (fetchOk && last && (curIn < last.input || curOut < last.output)) { + hist.input += last.input; hist.output += last.output; hist.cacheCreate += last.cacheCreate; hist.cacheRead += last.cacheRead; hist.cost += last.cost; + } + if (fetchOk) _condSave(hist, {input:curIn, output:curOut, cacheCreate:curCc, cacheRead:curCr, cost:curCost}); + 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', 'tok-cond-ico'); + const hCacheBase = hIn + hCr + hCc; + const hCacheRate = hCacheBase > 0 ? (hCr / hCacheBase * 100).toFixed(1) + '%' : '0%'; + const curCacheBase = curIn + curCr + curCc; + const curCacheRate = curCacheBase > 0 ? (curCr / curCacheBase * 100).toFixed(1) + '%' : '0%'; + tokTbody.innerHTML = `${_ci}${t('tok.condTotal')}${fmtTok(hIn)}${fmtTok(hOut)}${fmtTok(hCc)}${fmtTok(hCr)}${hCacheRate}${_ci}${t('tok.condCurrent')}${fmtTok(curIn)}${fmtTok(curOut)}${fmtTok(curCc)}${fmtTok(curCr)}${curCacheRate}`; + const pager = document.getElementById('tok-pager'); + if (pager) pager.innerHTML = ''; +} + +/* Flatpickr 初始化 */ +const _fpOpts = { enableTime:true, time_24hr:true, dateFormat:'Y-m-d H:i', locale:window.flatpickr?.l10ns?.[document.documentElement.lang==='en'?'default':'zh']||'default', allowInput:false, onChange(){ _tokPage=0; loadTokenPage(); } }; +const fpSince = tokSince ? flatpickr(tokSince, _fpOpts) : null; +const fpUntil = tokUntil ? flatpickr(tokUntil, _fpOpts) : null; +const tokResetBtn=document.getElementById('tok-reset'); +if(tokResetBtn)tokResetBtn.addEventListener('click',()=>{if(fpSince)fpSince.clear();if(fpUntil)fpUntil.clear();_tokPage=0;loadTokenPage();}); + +/* ─── Token trend chart ─── */ +nav.addEventListener('click',(e)=>{const item=e.target.closest('.nav-item');if(item&&item.dataset.page==='token'){if(_tokTab==='conductor')loadConductorTokens();else loadTokenPage();}if(item&&item.dataset.page==='services')refreshServicesPanel();}); +/* ═══════════════ 自定义预设 ═══════════════ */ +const CP_KEY = 'ga_custom_presets'; +const HB_KEY = 'ga_hidden_builtins'; + +const BUILTIN_PRESETS = [ + { key: 'butler', titleKey: 'preset.butler.t', descKey: 'preset.butler.d', navigate: 'collab', + get iconSvg() { return GA_ICON('gitFork', 'fc-ic'); } }, + { key: 'plan', titleKey: 'preset.plan.t', descKey: 'preset.plan.d', promptKey: 'presetPrompt.plan', + get iconSvg() { return GA_ICON('listChecks', 'fc-ic'); } }, + { key: 'goal', titleKey: 'preset.goal.t', descKey: 'preset.goal.d', promptKey: 'presetPrompt.goal', + get iconSvg() { return GA_ICON('crosshair', 'fc-ic'); } }, + { key: 'autonomous', titleKey: 'preset.autonomous.t', descKey: 'preset.autonomous.d', promptKey: 'presetPrompt.autonomous', + get iconSvg() { return GA_ICON('gridFour', 'fc-ic'); } }, + { key: 'hive', titleKey: 'preset.hive.t', descKey: 'preset.hive.d', promptKey: 'presetPrompt.hive', + get iconSvg() { return GA_ICON('hexagon', 'fc-ic'); } }, + { key: 'review', titleKey: 'preset.review.t', descKey: 'preset.review.d', promptKey: 'presetPrompt.review', + get iconSvg() { return GA_ICON('magnifyingGlass', 'fc-ic'); } }, + { key: 'findwork', titleKey: 'preset.findwork.t', descKey: 'preset.findwork.d', promptKey: 'presetPrompt.findwork', + get iconSvg() { return GA_ICON('robot', 'fc-ic'); } }, + { key: 'mine', titleKey: 'preset.mine.t', descKey: 'preset.mine.d', promptKey: 'presetPrompt.mine', + get iconSvg() { return GA_ICON('star', 'fc-ic'); } }, +]; +const ADD_ICON_SVG = GA_ICON('plus', 'fc-ic'); +// 自定义保存后生成的卡片图标(用户图标,表示"用户自定义的任务")—— 与"添加"卡的 + 区分 +const CUSTOM_ICON_SVG = ''; + +state.customPresets = []; +state.hiddenBuiltins = new Set(); + +function loadCustomPresets() { + try { + const raw = localStorage.getItem(CP_KEY); + const arr = raw ? JSON.parse(raw) : []; + state.customPresets = Array.isArray(arr) ? arr.filter(p => p && p.id && p.title && p.prompt) : []; + } catch { state.customPresets = []; } +} +function saveCustomPresets() { + localStorage.setItem(CP_KEY, JSON.stringify(state.customPresets)); +} +function loadHiddenBuiltins() { + try { + const raw = localStorage.getItem(HB_KEY); + const arr = raw ? JSON.parse(raw) : []; + state.hiddenBuiltins = new Set(Array.isArray(arr) ? arr.filter(k => typeof k === 'string') : []); + } catch { state.hiddenBuiltins = new Set(); } +} +function saveHiddenBuiltins() { + localStorage.setItem(HB_KEY, JSON.stringify([...state.hiddenBuiltins])); +} + +const EDIT_PENCIL_SVG = ''; +function makeCardEl({ kind, dataAttrs, iconSvg, titleText, descText, removable, editable }) { + const card = document.createElement('div'); + card.className = 'fcard ' + kind; + for (const [k, v] of Object.entries(dataAttrs || {})) card.dataset[k] = v; + card.innerHTML = iconSvg; + if (editable) { + const ed = document.createElement('button'); + ed.className = 'fc-edit'; + ed.type = 'button'; + ed.dataset.editId = dataAttrs?.id || ''; + ed.dataset.i18nTitle = 'customPreset.editTitle'; + ed.title = t('customPreset.editTitle'); + ed.innerHTML = EDIT_PENCIL_SVG; + card.appendChild(ed); + } + if (removable) { + const x = document.createElement('button'); + x.className = 'fc-x'; + x.type = 'button'; + x.dataset.removeKind = kind === 'fcard-builtin' ? 'builtin' : 'custom'; + x.dataset.removeId = dataAttrs?.id || dataAttrs?.preset || ''; + x.dataset.i18nTitle = 'customPreset.removeTitle'; + x.title = t('customPreset.removeTitle'); + x.textContent = '×'; + card.appendChild(x); + } + const titleEl = document.createElement('div'); + titleEl.className = 'fc-t'; + titleEl.textContent = titleText; + titleEl.title = titleText; // 截断后悬停看完整标题 + card.appendChild(titleEl); + const descEl = document.createElement('div'); + descEl.className = 'fc-d'; + descEl.textContent = descText; + descEl.title = descText; // 截断后悬停看完整描述 + card.appendChild(descEl); + return card; +} + +function renderAllPresets() { + document.querySelectorAll('.feature-grid').forEach(grid => { + grid.innerHTML = ''; + for (const bp of BUILTIN_PRESETS) { + if (state.hiddenBuiltins.has(bp.key)) continue; + grid.appendChild(makeCardEl({ + kind: 'fcard-builtin', + dataAttrs: { preset: bp.key }, + iconSvg: bp.iconSvg, + titleText: t(bp.titleKey), + descText: t(bp.descKey), + removable: true, + })); + } + for (const cp of state.customPresets) { + grid.appendChild(makeCardEl({ + kind: 'fcard-custom', + dataAttrs: { id: cp.id }, + iconSvg: CUSTOM_ICON_SVG, + titleText: cp.title, + descText: cp.prompt, + removable: true, + editable: true, + })); + } + const addCard = makeCardEl({ + kind: 'add', + dataAttrs: { preset: 'add' }, + iconSvg: ADD_ICON_SVG, + titleText: t('preset.add.t'), + descText: t('preset.add.d'), + removable: false, + }); + grid.appendChild(addCard); + }); + updateRestoreBtnVisibility(); +} + +function addCustomPreset(title, prompt) { + const id = 'cp-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6); + state.customPresets.push({ id, title, prompt }); + saveCustomPresets(); + renderAllPresets(); +} +function updateCustomPreset(id, title, prompt) { + const cp = state.customPresets.find(p => p.id === id); + if (!cp) return; + cp.title = title; + cp.prompt = prompt; + saveCustomPresets(); + renderAllPresets(); +} +function removeCustomPreset(id) { + const idx = state.customPresets.findIndex(p => p.id === id); + if (idx < 0) return; + state.customPresets.splice(idx, 1); + saveCustomPresets(); + renderAllPresets(); +} +function hideBuiltinPreset(key) { + if (!BUILTIN_PRESETS.some(bp => bp.key === key)) return; + state.hiddenBuiltins.add(key); + saveHiddenBuiltins(); + renderAllPresets(); +} +function restoreBuiltinPresets() { + state.hiddenBuiltins.clear(); + saveHiddenBuiltins(); + renderAllPresets(); +} +function updateRestoreBtnVisibility() { + const btn = document.getElementById('preset-restore-btn'); + if (!btn) return; + btn.hidden = state.hiddenBuiltins.size === 0; +} + +const cpModal = document.getElementById('custom-preset-modal'); +const cpTitleInput = document.getElementById('cp-title'); +const cpPromptInput = document.getElementById('cp-prompt'); +const cpSaveBtn = document.getElementById('cp-save'); +const cpError = document.getElementById('cp-error'); +const cpModalTitle = cpModal?.querySelector('.modal-title'); +let cpEditId = null; // null=新建模式; 否则为正在编辑的自定义预设 id +function clearCpFieldHints() { + cpModal?.querySelectorAll('.field-limit-hint').forEach(h => { h.style.display = 'none'; }); +} +function resetCustomPresetForm() { + cpEditId = null; + if (cpModalTitle) cpModalTitle.textContent = t('modal.customPreset'); + if (cpTitleInput) cpTitleInput.value = ''; + if (cpPromptInput) cpPromptInput.value = ''; + if (cpError) { cpError.hidden = true; cpError.textContent = ''; } + clearCpFieldHints(); + setTimeout(() => { if (cpTitleInput) cpTitleInput.focus(); }, 0); +} +function openCustomPresetEditor(cp) { + closeModals(); + openModal('custom-preset-modal'); + cpEditId = cp.id; + if (cpModalTitle) cpModalTitle.textContent = t('modal.editCustomPreset'); + if (cpTitleInput) cpTitleInput.value = cp.title; + if (cpPromptInput) cpPromptInput.value = cp.prompt; + if (cpError) { cpError.hidden = true; cpError.textContent = ''; } + clearCpFieldHints(); + setTimeout(() => { if (cpTitleInput) cpTitleInput.focus(); }, 0); +} +if (cpSaveBtn) cpSaveBtn.addEventListener('click', () => { + const title = (cpTitleInput?.value || '').trim(); + const prompt = (cpPromptInput?.value || '').trim(); + if (!title || !prompt) { + if (cpError) { cpError.textContent = t('customPreset.empty'); cpError.hidden = false; } + return; + } + if (cpEditId) updateCustomPreset(cpEditId, title, prompt); + else addCustomPreset(title, prompt); + cpEditId = null; + if (cpModal) cpModal.hidden = true; +}); + +const restoreBtn = document.getElementById('preset-restore-btn'); +if (restoreBtn) restoreBtn.addEventListener('click', () => { restoreBuiltinPresets(); }); + + +/* ═══════════════ 图片预览 lightbox ═══════════════ */ +const lightbox = document.getElementById('lightbox'); +const lightboxImg = document.getElementById('lightbox-img'); +function openLightbox(src) { + if (!lightbox || !lightboxImg || !src) return; + lightboxImg.src = src; + lightbox.hidden = false; +} +function closeLightbox() { + if (!lightbox || !lightboxImg) return; + lightbox.hidden = true; + lightboxImg.src = ''; +} +if (lightbox) { + lightbox.addEventListener('click', (e) => { + if (e.target.closest('[data-close]')) closeLightbox(); + }); +} +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && lightbox && !lightbox.hidden) closeLightbox(); +}); +if (msgArea) { + msgArea.addEventListener('click', (e) => { + const img = e.target.closest('.user-imgs img'); + if (img && img.src) { openLightbox(img.src); return; } + const fileChip = e.target.closest('.user-files .file-chip'); + if (fileChip) { + const path = fileChip.getAttribute('data-path'); + const name = fileChip.getAttribute('data-name'); + if (path) openUploadFile(path, name); + } + }); +} + +function uploadRawUrl(path, download) { + return `${BRIDGE_ORIGIN}/upload/raw?path=${encodeURIComponent(path || '')}${download ? '&download=1' : ''}`; +} +function bridgeIsLocal() { + return location.hostname === '127.0.0.1' || location.hostname === 'localhost'; +} +async function openUploadFile(path, name) { + // 远程访问:浏览器无法调起 bridge 那台/本机的系统程序,降级为下载到本机 + if (!bridgeIsLocal()) { + const a = document.createElement('a'); + a.href = uploadRawUrl(path, true); + a.download = name || ''; + document.body.appendChild(a); a.click(); a.remove(); + return; + } + // 本地:bridge 与你同机,调系统默认程序打开 / 在文件夹显示 + const mode = isPreviewableByName(name || path) ? 'open' : 'reveal'; + try { + const res = await fetch(`${BRIDGE_ORIGIN}/path/open`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ kind: 'upload', path, mode }), + }); + const j = await res.json(); + if (!j.ok) throw new Error(j.error || 'open failed'); + } catch (e) { + showChanToast(t('file.openFailed'), e.message || String(e), 'err'); } } -sendBtn.addEventListener('click', () => { - if (getActiveSessionRuntime()?.busy) { - cancelPrompt().then((ok) => { - if (ok) showSystem('Stop requested.'); +const PREVIEWABLE_EXTS = new Set([ + 'pdf', + 'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'heic', 'tiff', + 'txt', 'md', 'log', 'json', 'yaml', 'yml', 'xml', 'csv', 'tsv', 'ini', 'toml', 'env', 'rtf', + 'py', 'js', 'ts', 'tsx', 'jsx', 'java', 'c', 'cpp', 'h', 'hpp', 'rs', 'go', 'rb', 'php', 'sh', 'bash', 'zsh', 'fish', 'lua', 'pl', 'r', 'scala', 'kt', 'swift', + 'html', 'htm', 'css', 'scss', 'sass', 'less', 'vue', 'svelte', 'sql', + 'doc', 'docx', 'pages', 'odt', + 'xls', 'xlsx', 'numbers', 'ods', + 'ppt', 'pptx', 'key', 'odp', + 'mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a', + 'mp4', 'mov', 'avi', 'mkv', 'webm', 'wmv', +]); +function isPreviewableByName(name) { + const m = String(name || '').match(/\.([^./\\]+)$/); + if (!m) return false; + return PREVIEWABLE_EXTS.has(m[1].toLowerCase()); +} + +/* ═══════════════ 后台服务页 Tab(消息通道 / 状态面板) ═══════════════ */ +let _svcTab = 'channels'; +const svcTabsEl = document.getElementById('svc-tabs'); + +function isServicesPageActive() { + return !!document.querySelector('.page[data-page="services"].active'); +} +function isSvcTab(tab) { + return isServicesPageActive() && _svcTab === tab; +} +function setSvcTab(tab) { + if (!tab || tab === _svcTab) return; + _svcTab = tab; + svcTabsEl?.querySelectorAll('.svc-tab').forEach((b) => b.classList.toggle('active', b.dataset.tab === tab)); + document.querySelectorAll('[data-svc-panel]').forEach((p) => p.classList.toggle('active', p.dataset.svcPanel === tab)); + if (tab === 'channels') renderChannelList(gaServiceStore.list()); + else loadStatusPanel(); +} +function refreshServicesPanel() { + if (!isServicesPageActive()) return; + if (_svcTab === 'channels') renderChannelList(gaServiceStore.list()); + else loadStatusPanel(); +} +if (svcTabsEl) { + svcTabsEl.addEventListener('click', (e) => { + const btn = e.target.closest('.svc-tab'); + if (!btn) return; + setSvcTab(btn.dataset.tab); + }); +} + +/* ═══════════════ 消息通道(复用 gaServiceStore + WS 同步) ═══════════════ */ +const CHAN_ICON = GA_ICON('chatTeardropText', 'lr-ic'); +const CHAN_FILE_LABELS = { + 'qqapp.py': 'ch.qq', + 'wechatapp.py': 'ch.wechat', + 'wecomapp.py': 'ch.wecom', + 'dingtalkapp.py': 'ch.dingtalk', + 'tgapp.py': 'ch.telegram', + 'dcapp.py': 'ch.discord', + 'fsapp.py': 'ch.lark', +}; +const chanListEl = document.getElementById('chan-list'); +const chanEmptyEl = document.getElementById('chan-empty'); +const chanLogModal = document.getElementById('chan-log-modal'); +const chanLogPre = document.getElementById('chan-log-pre'); +const chanLogTitle = document.getElementById('chan-log-title'); +const chanConfigModal = document.getElementById('chan-config-modal'); +const chanConfigTitle = document.getElementById('chan-config-title'); +const chanConfigEditor = document.getElementById('chan-config-editor'); +const chanConfigSave = document.getElementById('chan-config-save'); +let _chanLogId = null; +let _chanBusy = false; +let _chanToastTimer = null; + +function getToastRoot() { + let root = document.getElementById('toast-root'); + if (!root) { + root = document.createElement('div'); + root.id = 'toast-root'; + root.className = 'toast-root'; + root.setAttribute('aria-live', 'polite'); + document.body.appendChild(root); + } + return root; +} + +function showChanToast(title, detail, kind) { + if (!title) return; + const root = getToastRoot(); + if (_chanToastTimer) clearTimeout(_chanToastTimer); + root.innerHTML = ''; + const el = document.createElement('div'); + el.className = `toast toast-${kind === 'err' ? 'err' : kind === 'info' ? 'info' : 'ok'}`; + const tEl = document.createElement('span'); + tEl.className = 'toast-title'; + tEl.textContent = title; + el.appendChild(tEl); + if (detail) { + const dEl = document.createElement('span'); + dEl.className = 'toast-detail'; + dEl.textContent = detail; + el.appendChild(dEl); + } + root.appendChild(el); + const show = () => el.classList.add('show'); + requestAnimationFrame(show); + setTimeout(show, 16); + _chanToastTimer = setTimeout(() => { + el.classList.remove('show'); + setTimeout(() => el.remove(), 300); + }, 3000); +} + +/* ── Input length validation ── */ +(function initInputLimits() { + let _toastTimer = null; + + // msgKey 默认用会截断的文案;硬上限输入框传 'err.charLimitReached'(只提醒不提截断) + function limitToast(maxLen, msgKey = 'err.charLimit') { + clearTimeout(_toastTimer); + _toastTimer = setTimeout(() => { + const msg = t(msgKey).replace('{n}', maxLen); + showChanToast(msg, '', 'err'); + }, 300); + } + + // Toast-based: for elements with maxLength attribute (input/textarea) —— 硬上限,不会截断 + function bindToastLimit(el) { + if (!el || !el.maxLength || el.maxLength < 0) return; + el.addEventListener('input', () => { + if (el.value.length >= el.maxLength) limitToast(el.maxLength, 'err.charLimitReached'); }); - } else submitInput(); -}); - -// ─── Buttons ───────────────────────────────────────────────────────────── -$('new-session-btn').addEventListener('click', newSession); -$('settings-btn').addEventListener('click', openSettings); -$('close-settings').addEventListener('click', closeSettings); -$('cancel-settings').addEventListener('click', closeSettings); -$('save-settings').addEventListener('click', saveSettings); -$('open-mykey').addEventListener('click', () => openConfigFile(window.ga.openMykey, 'mykey.py')); -$('error-dismiss').addEventListener('click', hideError); - -settingsModal.querySelector('.modal-backdrop').addEventListener('click', closeSettings); - -// ─── Message Search (Cmd/Ctrl+F) ───────────────────────────────────────── -(function initSearch() { - const searchBar = document.getElementById('search-bar'); - const searchInput = document.getElementById('search-input'); - const searchClose = document.getElementById('search-close'); - const searchPrev = document.getElementById('search-prev'); - const searchNext = document.getElementById('search-next'); - const searchCount = document.getElementById('search-count'); - - let highlights = []; - let currentIdx = -1; - - function openSearch() { - searchBar.classList.remove('hidden'); - searchBar.classList.add('visible'); - searchInput.focus(); - searchInput.select(); } - function closeSearch() { - searchBar.classList.remove('visible'); - searchBar.classList.add('hidden'); - clearHighlights(); - searchInput.value = ''; - searchCount.textContent = ''; - } - - function clearHighlights() { - highlights.forEach(el => { - const parent = el.parentNode; - if (parent) { - parent.replaceChild(document.createTextNode(el.textContent), el); - parent.normalize(); + // Toast-based: for contenteditable elements (no native maxLength) + // Note: we only warn on input, not hard-truncate, because truncating innerHTML + // would destroy embedded chips, cursor position, and break IME composition. + // Actual truncation happens at send time in submitInput(). + function bindContentEditableLimit(el, maxLen) { + if (!el) return; + let composing = false; + el.addEventListener('compositionstart', () => { composing = true; }); + el.addEventListener('compositionend', () => { + composing = false; + trimExcess(); + }); + // Layer 1: block non-IME input when at capacity + el.addEventListener('beforeinput', (e) => { + if (composing) return; // let IME through, trim after compositionend + if (e.inputType === 'historyUndo' || e.inputType === 'historyRedo') return; + if (e.inputType && e.inputType.startsWith('delete')) return; + if (el.textContent.length >= maxLen) { + e.preventDefault(); + limitToast(maxLen); } }); - highlights = []; - currentIdx = -1; - } - - function doSearch(query) { - clearHighlights(); - if (!query) { searchCount.textContent = ''; return; } - - const chatArea = document.getElementById('messages'); - const walker = document.createTreeWalker(chatArea, NodeFilter.SHOW_TEXT, null); - const textNodes = []; - while (walker.nextNode()) textNodes.push(walker.currentNode); - - const lowerQ = query.toLowerCase(); - textNodes.forEach(node => { - const text = node.textContent; - const lower = text.toLowerCase(); - let idx = lower.indexOf(lowerQ); - if (idx === -1) return; - - const frag = document.createDocumentFragment(); - let lastIdx = 0; - while (idx !== -1) { - frag.appendChild(document.createTextNode(text.slice(lastIdx, idx))); - const mark = document.createElement('mark'); - mark.className = 'search-highlight'; - mark.textContent = text.slice(idx, idx + query.length); - frag.appendChild(mark); - highlights.push(mark); - lastIdx = idx + query.length; - idx = lower.indexOf(lowerQ, lastIdx); - } - frag.appendChild(document.createTextNode(text.slice(lastIdx))); - node.parentNode.replaceChild(frag, node); - }); - - searchCount.textContent = highlights.length ? `1/${highlights.length}` : '0'; - if (highlights.length) { currentIdx = 0; scrollToHighlight(); } - } - - function scrollToHighlight() { - highlights.forEach((el, i) => el.classList.toggle('active', i === currentIdx)); - if (highlights[currentIdx]) { - // Expand any collapsed ancestor turns so the match is visible - let ancestor = highlights[currentIdx].parentElement; - while (ancestor && ancestor !== document.body) { - if (ancestor.classList.contains('turn') && ancestor.classList.contains('collapsed')) { - ancestor.classList.remove('collapsed'); + // Layer 2: after IME commits, trim excess from last text node + function trimExcess() { + const cur = el.textContent.length; + if (cur <= maxLen) return; + const excess = cur - maxLen; + // Walk text nodes in reverse to trim from the end (skip chip internals) + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); + const textNodes = []; + while (walker.nextNode()) textNodes.push(walker.currentNode); + if (!textNodes.length) return; + let toRemove = excess; + for (let i = textNodes.length - 1; i >= 0 && toRemove > 0; i--) { + const node = textNodes[i]; + if (node.nodeValue.length <= toRemove) { + toRemove -= node.nodeValue.length; + node.nodeValue = ''; + } else { + node.nodeValue = node.nodeValue.slice(0, node.nodeValue.length - toRemove); + toRemove = 0; } - ancestor = ancestor.parentElement; } - highlights[currentIdx].scrollIntoView({ block: 'center', behavior: 'smooth' }); - searchCount.textContent = `${currentIdx + 1}/${highlights.length}`; + limitToast(maxLen); } } - function nextMatch() { if (!highlights.length) return; currentIdx = (currentIdx + 1) % highlights.length; scrollToHighlight(); } - function prevMatch() { if (!highlights.length) return; currentIdx = (currentIdx - 1 + highlights.length) % highlights.length; scrollToHighlight(); } - - // Event listeners - searchClose.addEventListener('click', closeSearch); - searchPrev.addEventListener('click', prevMatch); - searchNext.addEventListener('click', nextMatch); - - let searchTimeout; - searchInput.addEventListener('input', () => { - clearTimeout(searchTimeout); - searchTimeout = setTimeout(() => doSearch(searchInput.value), 200); - }); - searchInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { e.shiftKey ? prevMatch() : nextMatch(); e.preventDefault(); } - if (e.key === 'Escape') { closeSearch(); e.preventDefault(); } - }); - - // Global shortcut: Cmd+F (Mac) / Ctrl+F (Win/Linux) - // Note: On macOS Electron intercepts Cmd+F via menu accelerator, - // so we also listen for IPC 'open-search' from main process. - document.addEventListener('keydown', (e) => { - const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; - const mod = isMac ? e.metaKey : e.ctrlKey; - if (mod && e.key === 'f') { - e.preventDefault(); - openSearch(); + // Per-field inline hint: creates a small red span right after the input. + // 用 JS 管控长度(去掉原生 maxlength),以便"超限尝试"时可靠告警: + // 超出上限的字符先被键入,再由本逻辑截回上限并告警——每次超限都触发 input, + // 兼容打字/粘贴/中文 IME。到达上限本身合法、不告警。告警出现后自动消失,不常驻。 + function bindFieldInlineLimit(el) { + if (!el || !el.maxLength || el.maxLength < 0) return; + const max = el.maxLength; + el.removeAttribute('maxlength'); // 转为 JS 管控 + const hint = document.createElement('span'); + hint.className = 'field-limit-hint'; + hint.style.cssText = 'color:var(--err,#dc2626);font-size:.75rem;display:none;margin-top:2px'; + el.insertAdjacentElement('afterend', hint); + let hideTimer = null; + function warn() { + hint.textContent = t('err.charLimitReached').replace('{n}', max); + hint.style.display = 'block'; + clearTimeout(hideTimer); + hideTimer = setTimeout(() => { hint.style.display = 'none'; }, 2500); } - if (e.key === 'Escape' && searchBar.classList.contains('visible')) { - e.preventDefault(); - closeSearch(); + function enforce() { + if (el.value.length <= max) return; + const atEnd = el.selectionStart >= el.value.length; + el.value = el.value.slice(0, max); + if (atEnd) el.setSelectionRange(max, max); + warn(); } - }); + let composing = false; + el.addEventListener('compositionstart', () => { composing = true; }); + el.addEventListener('compositionend', () => { composing = false; enforce(); }); + el.addEventListener('input', () => { if (!composing) enforce(); }); + } - // Listen for IPC from main process (menu accelerator on macOS) - if (window.ga && window.ga.onOpenSearch) { - window.ga.onOpenSearch(() => openSearch()); + window.bindFieldInlineLimit = bindFieldInlineLimit; + window.bindToastLimit = bindToastLimit; + + // Number field: clamp to max on blur, no red text; block non-integer chars + function bindNumberClamp(el) { + if (!el || !el.max) return; + const max = Number(el.max); + if (!max) return; + // Block all non-digit keys (allow navigation/editing keys) + el.addEventListener('keydown', (e) => { + if (e.ctrlKey || e.metaKey || e.altKey) return; // allow shortcuts + if (['Backspace','Delete','Tab','ArrowLeft','ArrowRight','Home','End'].includes(e.key)) return; + if (e.key.length === 1 && !/[0-9]/.test(e.key)) e.preventDefault(); + }); + el.addEventListener('input', () => { + // Strip non-digit chars (IME can bypass keydown) + const cleaned = el.value.replace(/[^0-9]/g, ''); + if (cleaned !== el.value) el.value = cleaned; + const v = Number(el.value); + if (el.value !== '' && v > max) el.value = max; + }); + } + + // Wait for DOM ready + function setup() { + // Contenteditable inputs (chat + collab) + const chatInput = document.querySelector('.input[contenteditable][data-i18n-ph="composer.placeholder"]'); + const collabInput = document.getElementById('cdb-input'); + bindContentEditableLimit(chatInput, 20000); + bindContentEditableLimit(collabInput, 20000); + + // Toast targets (standard inputs/textareas with maxLength) + const searchInput = document.querySelector('.search input'); + const mykeyEditor = document.getElementById('chan-config-editor'); + [searchInput, mykeyEditor].forEach(bindToastLimit); + + // Model form: per-field inline hints + const form = document.getElementById('add-model-form'); + if (form) { + ['model', 'apikey', 'apibase', 'name'].forEach(name => { + bindFieldInlineLimit(form.querySelector(`[name="${name}"]`)); + }); + ['max_retries', 'connect_timeout', 'read_timeout'].forEach(name => { + bindNumberClamp(form.querySelector(`[name="${name}"]`)); + }); + } + + // 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') { + document.addEventListener('DOMContentLoaded', setup); + } else { + setup(); } })(); -// ─── Init ──────────────────────────────────────────────────────────────── -(async function init() { - // Add platform class to body for platform-specific CSS - const platform = (window.ga && window.ga.platform) || process.platform || 'unknown'; - document.body.classList.add('platform-' + platform); - +function channelDisplayName(ch) { + const file = (ch.name || ch.id || '').split('/').pop(); + const key = CHAN_FILE_LABELS[file]; + return key ? t(key) : (ch.name || ch.id || ''); +} +function channelStatusClass(status) { + if (status === 'running') return 'on'; + if (status === 'error') return 'err'; + return 'off'; +} +function channelStatusLabel(status) { + const map = { + running: 'st.running', offline: 'st.offline', error: 'st.error', + starting: 'st.starting', stopping: 'st.stopping', + }; + return t(map[status] || 'st.offline'); +} +function channelErrorMessage(code) { + const map = { not_configured: 'err.channelNotConfigured' }; + return t(map[code] || code || 'err.channelStart'); +} +function channelToastDetail(e) { + const svc = e.data && e.data.service; + if (svc && svc.lastError) return svc.lastError; + const code = e.data && e.data.error; + return channelErrorMessage(code || e.message); +} +function renderChannelList(channels) { + if (!chanListEl) return; + const rows = (channels || []).filter((ch) => (ch.id || '').startsWith('frontends/')); + chanListEl.innerHTML = ''; + if (chanEmptyEl) chanEmptyEl.hidden = rows.length > 0; + for (const ch of rows) { + const row = document.createElement('div'); + row.className = 'list-row'; + row.dataset.channelId = ch.id; + const stClass = channelStatusClass(ch.status || 'offline'); + const running = !!ch.running; + row.innerHTML = ` + ${CHAN_ICON} +
+ + +
+ + + + + `; + row.querySelector('.chan-name').textContent = channelDisplayName(ch); + row.querySelector('.chan-path').textContent = ch.name || ch.id; + row.querySelector('.chan-status').textContent = channelStatusLabel(ch.status || 'offline'); + row.querySelector('[data-act="configure"]').textContent = t('act.configure'); + row.querySelector('[data-act="logs"]').textContent = t('act.logs'); + chanListEl.appendChild(row); + } +} +async function toggleChannel(id, running, toggleEl) { + if (_chanBusy) return; + _chanBusy = true; + if (toggleEl) toggleEl.disabled = true; + const label = channelDisplayName(gaServiceStore.get(id) || { id }); try { - const saved = await window.ga.getConfig(); - Object.assign(state.defaultConfig, saved); - } catch (err) { - addDiagnostic('error', 'Failed to load settings', err); - showError('Failed to load settings; using defaults: ' + (err.message || err)); + if (running) { + await window.ga.stopService(id); + showChanToast(t('sys.channelStopped') + ' · ' + label, '', 'ok'); + } else { + const res = await window.ga.startService(id); + if (res && res.service && res.service.status === 'error') { + throw Object.assign(new Error(res.service.lastError || 'start_failed'), { data: res }); + } + showChanToast(t('sys.channelStarted') + ' · ' + label, '', 'ok'); + } + } catch (e) { + showChanToast( + (running ? t('err.channelStop') : t('err.channelStart')) + ' · ' + label, + channelToastDetail(e), + 'err' + ); + } finally { + _chanBusy = false; + if (toggleEl) toggleEl.disabled = false; } - applyTheme(); - await loadModelProfiles(); - updateSendButton(); - inputEl.focus(); +} +async function openChannelLogs(id) { + if (!chanLogModal || !chanLogPre) return; + _chanLogId = id; + const ch = gaServiceStore.get(id) || { id }; + const titleName = id === '__bridge__' ? (ch.name || 'bridge') : statusDisplayName(ch); + if (chanLogTitle) chanLogTitle.textContent = t('modal.channelLogs') + ' · ' + titleName; + chanLogPre.textContent = t('ch.loading'); + openModal('chan-log-modal'); + try { + const res = await window.ga.getServiceLogs(id, 200); + const lines = res.lines || []; + chanLogPre.textContent = lines.length ? lines.join('\n') : t('ch.logEmpty'); + } catch (e) { + chanLogPre.textContent = t('err.channelLoad') + ': ' + (e.message || e); + } +} +async function openChannelMykey(channelId) { + if (!chanConfigModal || !chanConfigEditor) return; + const ch = gaServiceStore.get(channelId) || { id: channelId }; + if (chanConfigTitle) { + chanConfigTitle.textContent = t('modal.mykeyConfig') + (channelId ? ' · ' + channelDisplayName(ch) : ''); + } + chanConfigEditor.value = t('ch.loading'); + chanConfigEditor.disabled = true; + if (chanConfigSave) chanConfigSave.disabled = true; + openModal('chan-config-modal'); + try { + const res = await window.ga.getMykeyContent(); + chanConfigEditor.value = res.content || ''; + } catch (e) { + chanConfigEditor.value = t('err.channelLoad') + ': ' + (e.message || e); + } finally { + chanConfigEditor.disabled = false; + if (chanConfigSave) chanConfigSave.disabled = false; + chanConfigEditor.focus(); + } +} +async function saveChannelMykey() { + if (!chanConfigEditor || !chanConfigSave) return; + chanConfigSave.disabled = true; + try { + await window.ga.saveMykeyContent(chanConfigEditor.value); + showChanToast(t('sys.configSaved'), '', 'ok'); + chanConfigModal.hidden = true; + } catch (e) { + showChanToast(t('err.channelLoad'), e.message || String(e), 'err'); + } finally { + chanConfigSave.disabled = false; + } +} +if (chanConfigSave) { + chanConfigSave.addEventListener('click', saveChannelMykey); +} + +/* ═══════════════ 状态面板(复用 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_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); +} +function fmtPid(pid) { return pid ? `PID ${pid}` : '—'; } +function fmtRes(s) { + const cpu = s.cpuPct != null ? `${s.cpuPct}%` : '—'; + const mem = s.memMb != null ? `${s.memMb}MB` : '—'; + return `${cpu} / ${mem}`; +} + +function renderStatusPanel(services) { + if (!statusListEl) return; + statusListEl.innerHTML = ''; + for (const s of services || []) { + const row = document.createElement('div'); + row.className = 'list-row'; + row.dataset.serviceId = s.id; + const stClass = channelStatusClass(s.status || 'offline'); + const running = !!s.running; + const managed = s.managed !== false; + const isBridge = s.id === BRIDGE_SERVICE_ID; + const isExtra = EXTRA_SERVICE_IDS.has(s.id); + let acts = ''; + if (isBridge) { + if (running) { + acts += ``; + acts += ``; + } else { + acts += ``; + } + } else if (!s.bridgeOffline) { + acts += ``; + if (managed) { + if (running) acts += ``; + if (isExtra) { + acts += ``; + } else { + acts += ``; + } + } + } + row.innerHTML = ` + + + + + + ${acts}`; + row.querySelector('.st-name').textContent = statusDisplayName(s); + row.querySelector('.st-status').textContent = channelStatusLabel(s.status || 'offline'); + row.querySelector('.st-pid').textContent = fmtPid(s.pid); + row.querySelector('.st-res').textContent = fmtRes(s); + const logBtn = row.querySelector('[data-act="logs"]'); + 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; + 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) { + const label = statusDisplayName(gaServiceStore.get(id) || { id }); + await window.ga.stopService(id); + const res = await window.ga.startService(id); + if (res && res.service && res.service.status === 'error') { + throw Object.assign(new Error(res.service.lastError || 'start_failed'), { data: res }); + } + showChanToast(t('act.restart') + ' · ' + label, '', 'ok'); +} + +if (statusListEl) { + statusListEl.addEventListener('click', async (e) => { + const row = e.target.closest('.list-row'); + if (!row) return; + const id = row.dataset.serviceId; + const actEl = e.target.closest('[data-act]'); + if (!actEl || !id) return; + const act = actEl.dataset.act; + if (act === 'logs') { + 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; + try { + await restartService(id); + await loadStatusPanel(); + } catch (err) { + showChanToast(t('act.restart') + ' · ' + statusDisplayName({ id }), err.message || String(err), 'err'); + } finally { + _chanBusy = false; + } + return; + } + if (act === 'toggle') { + if (actEl.disabled || _chanBusy) return; + const running = actEl.classList.contains('on'); + await toggleChannel(id, running, actEl); + if (isSvcTab('status')) loadStatusPanel(); + } + }); +} + +gaServiceStore.onServices((list) => { + if (isSvcTab('channels')) renderChannelList(list); + if (isSvcTab('status')) { + if (bridgeUiOffline) renderStatusPanel(bridgeOfflinePanelServices()); + else loadStatusPanel(); + } +}); +if (chanListEl) { + chanListEl.addEventListener('click', async (e) => { + const row = e.target.closest('.list-row'); + if (!row) return; + const id = row.dataset.channelId; + const actEl = e.target.closest('[data-act]'); + if (!actEl || !id) return; + const act = actEl.dataset.act; + if (act === 'logs') { + openChannelLogs(id); + return; + } + if (act === 'configure') { + openChannelMykey(id); + return; + } + if (act === 'toggle') { + if (actEl.disabled || _chanBusy) return; + const running = actEl.classList.contains('on'); + await toggleChannel(id, running, actEl); + } + }); +} + +/* ═══════════════ 启动 ═══════════════ */ +(async () => { +await loadSessions(); +applyAppearance(appearance, plainUi, { persist: false }); +applyTheme(theme, { persist: false }); +initChatFontStepper(); +applyChatFontSize(chatFontSize, { persist: false }); +syncHljsTheme(); +applyI18n(); +updateModelChip(); +renderSessionList(); +loadCustomPresets(); +loadHiddenBuiltins(); +renderAllPresets(); +if (state.activeId) setActiveSession(state.activeId); +else refreshEmptyState(null); +// bridge-ready 可能在上面的 await 期间就已到达(WS 一连上 bridge 即推送), +// 此时 state.bridgeReady 已为 true,直接按真实状态渲染,避免把「就绪」覆盖回「连接中」。 +if (state.bridgeReady) refreshStatusLabel(); +else chatStatus.setConnecting(); +window.ga.startBridge && window.ga.startBridge(); +})(); + +/* 聊天 / Conductor 共用 composer 绑定(结构:.composer > .composer-slot > .composer-inset) */ +function bindComposerInRoot(root, opts) { + if (!root || root.dataset.composerBound) return null; + root.dataset.composerBound = '1'; + const ctx = opts.ctx || root.dataset.composerCtx || 'chat'; + const input = root.querySelector('.composer-inset .input'); + const fileInput = root.querySelector('input[type="file"]'); + const plusBtn = root.querySelector('.composer-plus'); + const menu = root.querySelector('.composer-menu'); + const sendBtn = root.querySelector('.send'); + + function closeMenu() { + if (!menu) return; + menu.hidden = true; + plusBtn?.setAttribute('aria-expanded', 'false'); + } + + function openMenu() { + if (!menu || !plusBtn) return; + closeAllModelMenus?.(); + if (ctx === 'chat') window.collabComposer?.closeMenu?.(); + else window.chatComposer?.closeMenu?.(); + menu.hidden = false; + plusBtn.setAttribute('aria-expanded', 'true'); + } + + function toggleMenu() { + if (!menu) return; + if (menu.hidden) openMenu(); + else closeMenu(); + } + + function doSend() { opts.onSend?.(); } + + plusBtn?.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + toggleMenu(); + }); + + menu?.addEventListener('click', (e) => { + const item = e.target.closest('[data-composer-action]'); + if (!item) return; + e.stopPropagation(); + closeMenu(); + const act = item.dataset.composerAction; + if (act === 'upload') { + window.gaSetActiveFileComposer?.(ctx); + fileInput?.click(); + return; + } + if (act === 'preset') openModal('preset-modal'); + }); + + input?.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) { + e.preventDefault(); + doSend(); + } + }); + + sendBtn?.addEventListener('click', (e) => { + e.preventDefault(); + doSend(); + }); + + opts.afterBind?.(root, { input, closeMenu, doSend }); + return { ctx, input, closeMenu, focus: () => input?.focus() }; +} + +(function () { + 'use strict'; + const root = document.getElementById('chat-composer'); + const bound = bindComposerInRoot(root, { + ctx: 'chat', + onSend() { + const sess = activeSess(); + if (sess && rt(sess).busy) { cancelPrompt(); return; } + submitInput(); + }, + }); + if (bound) window.chatComposer = { closeMenu: bound.closeMenu, focus: bound.focus }; +})(); + +(function () { + 'use strict'; + const root = document.getElementById('cdb-composer'); + if (!root) return; + + let onSend = null; + const input = root.querySelector('.composer-inset .input'); + const sendBtn = root.querySelector('.send'); + + function text() { return window.gaComposerText?.('collab') ?? ''; } + function clearIfMatch(raw) { + if (input && text().trim() === String(raw || '').trim()) input.innerHTML = ''; + } + function setEnabled(on) { + if (input) input.contentEditable = on ? 'true' : 'false'; + if (sendBtn) sendBtn.disabled = !on; + } + + const bound = bindComposerInRoot(root, { + ctx: 'collab', + onSend() { if (onSend) onSend(text()); }, + afterBind() { + document.querySelectorAll('#collab-quick [data-prompt-key]').forEach((btn) => { + btn.addEventListener('click', () => { + if (!onSend) return; + const key = btn.dataset.promptKey; + onSend((window.gaT && window.gaT(key)) || key); + }); + }); + }, + }); + if (!bound) return; + + function init(handler) { + onSend = handler; + } + + window.collabComposer = { + init, text, clearIfMatch, setEnabled, + focus: bound.focus, + closeMenu: bound.closeMenu, + }; +})(); + +/* Conductor 页 — 直连 Conductor WS,不走 bridge session */ +(function () { + 'use strict'; + const wsUrl = () => `${CONDUCTOR_WS_ORIGIN}/ws`; + const FAIL_MAX = 5, RECON_BASE = 1200, RECON_MAX = 30000; + const $ = id => document.getElementById(id); + const t = k => (window.gaT && window.gaT(k)) || k; + const esc = s => (window.gaEscapeHtml ? window.gaEscapeHtml(s) : String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]))); + const stripAttach = text => String(text || '') + .replace(/\[(Image|File)\s+#\d+\]\s*/g, '') + .replace(/[^\s]*desktop_uploads[^\s]*\s*/g, '') // 兜底:去掉内联的本地上传路径,避免历史/回显消息把全路径甩出来 + .trim(); + const GA_STATUS_BREATHE_SM = ''; + function collabStatusMark(status) { + switch (status) { + case 'running': return GA_STATUS_BREATHE_SM; + case 'reported': return ''; + case 'paused': return ''; + case 'failed': return ''; + case 'terminated': return ''; + default: return ''; + } + } + const ST_KEYS = { running: 'collab.stRunning', reported: 'collab.stReported', paused: 'collab.stPaused', failed: 'collab.stFailed', terminated: 'collab.stTerminated' }; + + const S = { + everConnected: false, reconnecting: false, serviceAvailable: false, + messages: [], workers: [], runningCount: 0, + conductorTyping: false, failCount: 0, + historyReady: false, reconnectAt: 0, progressOpen: false, + }; + let ws, connectTimer, reconnectTick, titleSeq = 0, wsGen = 0, localSeq = 0; + const titleSeen = new Map(); + let prevRail = { running: 0, done: 0, issue: 0, count: 0, sig: '' }; + const prevUpdated = new Map(); + const collabStatus = window.gaPageStatusBar?.($('collab-run-toggle')); + + let draftEl = null; + + const scrollMsgs = () => { + const root = $('collab-msgs'); + const sc = root?.querySelector('.collab-scroll'); + if (sc) sc.scrollTop = sc.scrollHeight; + }; + const showDraft = () => S.conductorTyping && S.serviceAvailable && S.historyReady && S.messages.length > 0; + + function workerSig(list) { + return (list || []).map(w => `${w.id}:${w.updatedAt}:${w.status}`).join('|'); + } + + function pulseEl(el) { + if (!el) return; + el.classList.remove('pulse'); + void el.offsetWidth; + el.classList.add('pulse'); + } + + function syncProgressDrawer() { + const page = document.querySelector('.page[data-page="collab"]'); + if (page) page.classList.toggle('collab-prog-open', S.progressOpen); + } + + function syncRail(opts = {}) { + const rail = $('collab-rail'); + const hasChat = S.historyReady && S.messages.length > 0; + if (rail) rail.hidden = !hasChat; + + const running = S.workers.filter(w => w.status === 'running').length; + const done = S.workers.filter(w => w.status === 'reported').length; + const issue = S.workers.filter(w => w.status === 'failed').length; + const runBadge = $('collab-rail-run'); + const doneBadge = $('collab-rail-done'); + const issueBadge = $('collab-rail-issue'); + const runN = $('collab-rail-run-n'); + const doneN = $('collab-rail-done-n'); + const issueN = $('collab-rail-issue-n'); + + if (runBadge) runBadge.hidden = running <= 0; + if (doneBadge) doneBadge.hidden = done <= 0; + if (issueBadge) issueBadge.hidden = issue <= 0; + if (runN) runN.textContent = String(running); + if (doneN) doneN.textContent = String(done); + if (issueN) issueN.textContent = String(issue); + + const sig = workerSig(S.workers); + if (opts.pulse) { + if (running > prevRail.running || S.workers.length > prevRail.count) pulseEl(runBadge); + if (done > prevRail.done) pulseEl(doneBadge); + if (issue > prevRail.issue) pulseEl(issueBadge); + } else if (sig !== prevRail.sig) { + if (running !== prevRail.running) pulseEl(runBadge); + if (done !== prevRail.done) pulseEl(doneBadge); + if (issue !== prevRail.issue) pulseEl(issueBadge); + } + prevRail = { running, done, issue, count: S.workers.length, sig }; + syncProgressDrawer(); + } + + function toggleProgress(open) { + S.progressOpen = typeof open === 'boolean' ? open : !S.progressOpen; + syncRail(); + } + + function clearDraft() { + if (draftEl) { draftEl.remove(); draftEl = null; } + } + + function syncDraft() { + const list = $('collab-msg-list'); + if (!list || list.hidden || !showDraft()) return clearDraft(); + if (!draftEl) draftEl = document.createElement('div'); + draftEl.className = 'msg system collab-msg-enter'; + draftEl.setAttribute('aria-label', t('collab.typing')); + draftEl.innerHTML = '
'; + list.appendChild(draftEl); + requestAnimationFrame(scrollMsgs); + } + + function relTime(ts) { + if (!ts) return ''; + const ms = typeof ts === 'number' ? (ts > 1e12 ? ts : ts * 1000) : Date.parse(ts); + if (!ms || Number.isNaN(ms)) return ''; + const sec = Math.max(0, Math.floor((Date.now() - ms) / 1000)); + if (sec < 10) return t('collab.timeJust'); + if (sec < 60) return t('collab.timeSec').replace('{n}', sec); + const min = Math.floor(sec / 60); + if (min < 60) return t('collab.timeMin').replace('{n}', min); + const hr = Math.floor(min / 60); + return hr < 24 ? t('collab.timeHr').replace('{n}', hr) : t('collab.timeDay').replace('{n}', Math.floor(hr / 24)); + } + + function mapStatus(status, reply) { + const r = (reply || '').trim(); + if (status === 'running') return 'running'; + if (status === 'failed') return 'failed'; + if (status === 'aborted') return 'terminated'; + if (status === 'stopped') return r ? 'reported' : 'paused'; + return 'paused'; + } + + function normalizeWorker(raw) { + if (!titleSeen.has(raw.id)) titleSeen.set(raw.id, ++titleSeq); + const ui = mapStatus(raw.status, raw.reply); + let title = String(raw.prompt ?? '').replace(/^[\s请帮我麻烦]+/u, '').trim(); + if (!title) title = t('collab.taskFallback').replace('{n}', titleSeen.get(raw.id)); + else { + title = (title.split(/[\n。!?.!?]/)[0] || '').trim(); + if (title.length > 18) title = title.slice(0, 18) + '…'; + } + const reply = String(raw.reply || '').replace(/\s+/g, ' ').trim(); + let summary = reply ? (reply.length > 80 ? reply.slice(0, 80) + '…' : reply) : t(ui === 'running' ? 'collab.summaryRunning' : 'collab.summaryWait'); + return { id: raw.id, title, status: ui, summary, fullReply: raw.reply || '', updatedAt: raw.updated_at }; + } + + function syncCollabStatus() { + if (!collabStatus) return; + if (S.conductorTyping && S.serviceAvailable) collabStatus.setBusy(t('status.running')); + else if (S.serviceAvailable) collabStatus.setReady(); + else if (S.reconnecting || (!S.everConnected && S.failCount < FAIL_MAX)) collabStatus.setConnecting(); + else collabStatus.set(t('collab.offlineShort'), 'offline'); // 顶栏直接显示"无法连接 Conductor(8900)"取代"未连接" + } + + function setConnUi() { + const retry = $('collab-retry'); + const avail = S.serviceAvailable; + const trying = !avail && !S.everConnected && S.failCount < FAIL_MAX; + // 只有"真离线且不在自动重连"才显示刷新图标(右边的手动重试入口) + if (retry) retry.hidden = avail || S.reconnecting || trying; + window.collabComposer?.setEnabled?.(avail); + syncCollabStatus(); + syncDraft(); + syncRail(); + } + + let cardMenu = null; + function hideCardMenu() { if (cardMenu) { cardMenu.remove(); cardMenu = null; } } + function showCardMenu(x, y, sid) { + hideCardMenu(); + cardMenu = document.createElement('div'); + cardMenu.className = 'ctx-menu'; + cardMenu.style.left = x + 'px'; + cardMenu.style.top = y + 'px'; + cardMenu.innerHTML = `
${GA_ICON('trash')}${esc(t('ctx.del'))}
`; + cardMenu.querySelector('.ctx-item').onclick = (e) => { + e.stopPropagation(); + fetch(`${CONDUCTOR_ORIGIN}/subagent/${sid}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'kill' }) }); + hideCardMenu(); + }; + document.body.appendChild(cardMenu); + setTimeout(() => document.addEventListener('mousedown', (e) => { if (!cardMenu?.contains(e.target)) hideCardMenu(); }, { once: true }), 0); + } + + let drawerEl = null; + function closeWorkerDrawer() { if (drawerEl) { drawerEl.remove(); drawerEl = null; } } + function openWorkerDrawer(w) { + closeWorkerDrawer(); + drawerEl = document.createElement('div'); + drawerEl.className = 'collab-drawer-wrap'; + drawerEl.innerHTML = `
`; + const bubble = drawerEl.querySelector('.collab-drawer-body .bubble'); + if (bubble) { + bubble.innerHTML = renderTurnBody(w.fullReply || t('collab.summaryWait')); + postRenderEnhance(bubble); + } + drawerEl.querySelector('.collab-drawer-backdrop').onclick = closeWorkerDrawer; + drawerEl.querySelector('.collab-drawer-close').onclick = closeWorkerDrawer; + document.body.appendChild(drawerEl); + } + + function renderWorkers() { + const box = $('collab-workers'), empty = $('collab-progress-empty'); + if (!box) return; + if (empty) empty.hidden = S.workers.length > 0; + box.innerHTML = S.workers.map(w => ` +
+
${collabStatusMark(w.status)}${esc(t(ST_KEYS[w.status] || 'collab.stPaused'))}${w.updatedAt ? `${esc(relTime(w.updatedAt))}` : ''}
+
${esc(w.title)}
+
${esc(w.summary)}
+
`).join(''); + box.querySelectorAll('.collab-card').forEach(el => { + const w = S.workers.find(x => x.id === el.dataset.sid); + if (w) { + const prev = prevUpdated.get(w.id); + if (prev != null && prev !== w.updatedAt) pulseEl(el); + prevUpdated.set(w.id, w.updatedAt); + } + el.addEventListener('contextmenu', e => { + e.preventDefault(); + showCardMenu(e.clientX, e.clientY, el.dataset.sid); + }); + el.addEventListener('click', () => { + if (w) openWorkerDrawer(w); + }); + }); + const running = S.workers.filter(w => w.status === 'running').length; + const done = S.workers.filter(w => w.status === 'reported').length; + S.runningCount = running; + document.dispatchEvent(new CustomEvent('collab:running-count', { detail: { count: running } })); + const stats = $('collab-progress-stats'); + if (stats) { + const has = running > 0 || done > 0; + stats.hidden = !has; + if (has) { + stats.innerHTML = [ + running > 0 ? `${GA_STATUS_BREATHE_SM}${running} ${esc(t('collab.statRunning'))}` : '', + done > 0 ? `${done} ${esc(t('collab.statDone'))}` : '', + ].filter(Boolean).join(''); + } + } + syncRail({ pulse: true }); + } + + function syncMessages() { + const area = $('collab-msgs'), welcome = $('collab-welcome'), list = $('collab-msg-list'); + if (!area || !list) return; + if (!S.historyReady) { + area.classList.remove('has-msgs'); + if (welcome) welcome.hidden = true; + list.hidden = true; + syncRail(); + return; + } + const has = S.messages.length > 0; + area.classList.toggle('has-msgs', has); + if (welcome) welcome.hidden = has; + list.hidden = !has; + list.replaceChildren(); + const toMsg = window.gaCollabItemToMsg; + const render = window.gaMsgNode; + if (toMsg && render) { + for (const item of S.messages) { + const el = render(toMsg(item)); + el.classList.add('collab-msg-enter'); + list.appendChild(el); + } + } + syncDraft(); + scrollMsgs(); + syncRail(); + } + + function pushMsg(item) { + if (item.id && S.messages.some(m => m.id === item.id)) return; + if (item.role === 'user') { + const plain = stripAttach(item.msg); + const expand = window.gaExpandFilePlaceholders; + for (let i = S.messages.length - 1; i >= 0; i--) { + const m = S.messages[i]; + // 服务端回显的 msg 是 expand(本地文本)(附件被展开成了本地路径),和本地乐观消息其实是同一条。 + // 命中后保留本地那条的干净显示(占位符 msg + 结构化 files/images 卡片),只补服务端 id/ts, + // 丢弃带路径的回显文本 —— 既去重、又不丢卡片、不外露本地路径,与 chat 显示一致。 + if (m._local && m.role === 'user' && + (stripAttach(m.msg) === plain || m.msg === item.msg || (expand && expand(m.msg) === item.msg))) { + m.id = item.id || m.id; + if (item.ts != null) m.ts = item.ts; + if (item.read != null) m.read = item.read; + m._local = false; + syncMessages(); + setConnUi(); + return; + } + } + } + S.messages.push(item); + if (item.role === 'conductor') S.conductorTyping = false; + syncMessages(); + setConnUi(); + } + + function setWorkers(rawList) { + S.workers = (rawList || []).map(normalizeWorker); + renderWorkers(); + } + + function onWsData(data, gen) { + if (gen !== wsGen) return; + if (data.type === 'hello') { + S.historyReady = true; + S.messages = (data.chat || []).map(raw => ({ id: raw.id, role: raw.role || 'system', msg: raw.msg || '', ts: raw.ts, read: raw.read, files: raw.files || [], images: raw.images || [] })); + S.conductorTyping = !!data.running; + setWorkers(data.subagents || []); + syncMessages(); + setConnUi(); + } else if (data.type === 'subagents') setWorkers(data.items || []); + else if (data.type === 'chat') pushMsg({ id: data.item.id, role: data.item.role || 'system', msg: data.item.msg || '', ts: data.item.ts, read: data.item.read, files: data.item.files || [], images: data.item.images || [] }); + } + + function resetWs() { + wsGen++; + if (!ws) return; + const old = ws; + ws = null; + old.onopen = old.onclose = old.onerror = old.onmessage = null; + try { old.close(); } catch {} + } + + function scheduleReconnect() { + clearTimeout(connectTimer); + clearInterval(reconnectTick); + if (!S.everConnected && S.failCount >= FAIL_MAX) { + S.reconnecting = false; + return setConnUi(); + } + const delay = Math.min(RECON_MAX, RECON_BASE * Math.pow(2, Math.max(0, S.failCount - 1))); + S.reconnectAt = Date.now() + delay; + S.reconnecting = S.everConnected; + setConnUi(); + reconnectTick = setInterval(() => { if (!S.reconnecting) clearInterval(reconnectTick); else setConnUi(); }, 500); + connectTimer = setTimeout(connect, delay); + } + + function connect() { + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return; + clearTimeout(connectTimer); + clearInterval(reconnectTick); + const gen = ++wsGen; + setConnUi(); + let sock; + try { sock = new WebSocket(wsUrl()); } catch (e) { + if (gen !== wsGen) return; + S.failCount++; + return scheduleReconnect(); + } + ws = sock; + sock.onopen = () => { + if (gen !== wsGen) return; + S.everConnected = true; + S.serviceAvailable = true; + S.reconnecting = false; + S.failCount = 0; + setConnUi(); + }; + sock.onclose = (ev) => { + if (gen !== wsGen) return; + S.serviceAvailable = false; + if (S.everConnected) S.reconnecting = true; + else S.failCount++; + setConnUi(); + scheduleReconnect(); + }; + sock.onerror = () => {}; + sock.onmessage = ev => { + if (gen !== wsGen) return; + try { onWsData(JSON.parse(ev.data), gen); } catch {} + }; + } + + function sendText(rawText) { + const text = (rawText || '').trim(); + if (!text || !ws || ws.readyState !== WebSocket.OPEN) return false; + window.gaSetActiveFileComposer?.('collab'); + const expand = window.gaExpandFilePlaceholders || (s => s); + const collect = window.gaCollectUsedFiles || (() => []); + const clearUsed = window.gaClearUsedPendingFiles || (() => {}); + const used = collect(text); + const images = [], files = []; + for (const f of used) (f.isImage ? images : files).push(f.isImage ? { path: f.path, dataUrl: f.dataUrl, name: f.name, sid: f.sid } : { path: f.path, name: f.name, sid: f.sid }); + S.messages.push({ id: `_local_${++localSeq}`, _local: true, role: 'user', msg: text, ts: Date.now() / 1000, images, files }); + S.conductorTyping = true; + syncMessages(); + ws.send(JSON.stringify({ msg: expand(text), files, images })); + clearUsed(text); + window.collabComposer?.clearIfMatch?.(text); + setConnUi(); + return true; + } + + $('collab-retry')?.addEventListener('click', () => { S.failCount = 0; S.reconnecting = false; resetWs(); connect(); }); + $('collab-rail-toggle')?.addEventListener('click', () => toggleProgress()); + $('collab-prog-close')?.addEventListener('click', () => toggleProgress(false)); + + window.collabComposer?.init?.(sendText); + + window.collabInit = () => { + window.gaSetActiveFileComposer?.('collab'); + syncMessages(); + setConnUi(); + renderWorkers(); + connect(); + }; + window.collabFocus = () => window.collabComposer?.focus?.(); + window.collabRetranslate = () => { renderWorkers(); syncMessages(); setConnUi(); }; +})(); + +/* ═══════════════ Composer layout: inline / stacked ═══════════════ */ +(function initComposerLayout() { + const BREAKPOINT = 480; + const SINGLE_LINE = 36; + + document.querySelectorAll('.composer-inset').forEach(inset => { + const input = inset.querySelector('.input'); + if (!input) return; + + function update() { + const wide = inset.offsetWidth >= BREAKPOINT; + const single = input.scrollHeight <= SINGLE_LINE; + inset.classList.toggle('is-inline', wide && single); + } + + new ResizeObserver(update).observe(inset); + input.addEventListener('input', update); + update(); + }); })(); diff --git a/frontends/desktop/static/assets/fonts/README.md b/frontends/desktop/static/assets/fonts/README.md new file mode 100644 index 00000000..f8e5a57e --- /dev/null +++ b/frontends/desktop/static/assets/fonts/README.md @@ -0,0 +1,34 @@ +# Desktop Font Assets + +These font files are the offline Latin bundle for the vanilla desktop shell. + +## Files + +- `azonix-wordmark.woff2` + - Role: brand wordmark only + - Source: converted on 2026-05-25 from the local owner-installed file at `~/Library/Fonts/azonix/Azonix.otf` + - License: owner-provided / verify before external redistribution + - Note: this repo currently treats Azonix as a project-local brand asset + +- `lexend-latin.woff2` + - Role: English titles and navigation + - Source: Google Fonts CSS2 API, specimen page + - Downloaded: 2026-05-25 + - License: SIL Open Font License 1.1 + +- `noto-sans-latin.woff2` + - Role: English body copy and default Latin UI text + - Source: Google Fonts CSS2 API, specimen page + - Downloaded: 2026-05-25 + - License: SIL Open Font License 1.1 + +- `jetbrains-mono-latin.woff2` + - Role: config/value dense controls and numeric surfaces + - Source: Google Fonts CSS2 API, specimen page + - Downloaded: 2026-05-25 + - License: SIL Open Font License 1.1 + +## Notes + +- Only Latin subsets are bundled here. Chinese UI text continues to use the existing system fallback stack. +- Runtime does not fetch fonts from a CDN. `frontends/desktop/static/assets/fonts/fonts.css` is the only font entrypoint for the vanilla shell. diff --git a/frontends/desktop/static/assets/fonts/azonix-wordmark.woff2 b/frontends/desktop/static/assets/fonts/azonix-wordmark.woff2 new file mode 100644 index 00000000..947bfbeb Binary files /dev/null and b/frontends/desktop/static/assets/fonts/azonix-wordmark.woff2 differ diff --git a/frontends/desktop/static/assets/fonts/fonts.css b/frontends/desktop/static/assets/fonts/fonts.css new file mode 100644 index 00000000..8103b351 --- /dev/null +++ b/frontends/desktop/static/assets/fonts/fonts.css @@ -0,0 +1,21 @@ +@font-face { + font-family: "GA Azonix"; + src: + url("./azonix-wordmark.woff2") format("woff2"), + local("Azonix"), + local("Azonix Regular"), + local("Azonix-Regular"); + font-style: normal; + font-weight: 400; + font-display: swap; + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+20AC, U+2122, U+2212, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "GA JetBrains Mono"; + src: url("./jetbrains-mono-latin.woff2") format("woff2"); + font-style: normal; + font-weight: 400 600; + font-display: swap; + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} diff --git a/frontends/desktop/static/assets/fonts/jetbrains-mono-latin.woff2 b/frontends/desktop/static/assets/fonts/jetbrains-mono-latin.woff2 new file mode 100644 index 00000000..2ca6ac60 Binary files /dev/null and b/frontends/desktop/static/assets/fonts/jetbrains-mono-latin.woff2 differ diff --git a/frontends/desktop/static/assets/fonts/lexend-latin.woff2 b/frontends/desktop/static/assets/fonts/lexend-latin.woff2 new file mode 100644 index 00000000..0968c152 Binary files /dev/null and b/frontends/desktop/static/assets/fonts/lexend-latin.woff2 differ diff --git a/frontends/desktop/static/assets/fonts/noto-sans-latin.woff2 b/frontends/desktop/static/assets/fonts/noto-sans-latin.woff2 new file mode 100644 index 00000000..e0829304 Binary files /dev/null and b/frontends/desktop/static/assets/fonts/noto-sans-latin.woff2 differ diff --git a/frontends/desktop/static/fallback.html b/frontends/desktop/static/fallback.html index 6c28749a..c53f3c4e 100644 --- a/frontends/desktop/static/fallback.html +++ b/frontends/desktop/static/fallback.html @@ -87,5 +87,46 @@ async function doStart() { pyInput.addEventListener('keydown', e=>{ if(e.key==='Enter') dirInput.focus(); }); dirInput.addEventListener('keydown', e=>{ if(e.key==='Enter') doStart(); }); + + diff --git a/frontends/desktop/static/index.html b/frontends/desktop/static/index.html index e86ba27e..3390ca8b 100644 --- a/frontends/desktop/static/index.html +++ b/frontends/desktop/static/index.html @@ -1,116 +1,679 @@ - + - - GenericAgent - + +GenericAgent + + + + + + + + + + + -
- -
-
-
-
- -
-
-
-

GenericAgent

- - - Starting… - -
-
-
- -