= 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;
+ // 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);
+ 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);
+ spawned_bridge = true;
+ }
+ }
+ }
+
+ tauri::Builder::default()
+ .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
+ if let Some(w) = app.get_webview_window("main") {
+ let _ = w.unminimize();
+ let _ = w.show();
+ let _ = w.set_focus();
+ }
+ }))
+ .invoke_handler(tauri::generate_handler![start_bridge_with_config, start_bridge, get_config, export_mykey, shortcut_should_ask, shortcut_decide])
+ .setup(move |app| {
+ // 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" {
+ // 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
+ if let Some(main_win) = window.app_handle().get_webview_window("main") {
+ if !main_win.is_visible().unwrap_or(false) {
+ window.app_handle().exit(0);
+ }
+ } else {
+ window.app_handle().exit(0);
+ }
+ }
+ }
+ })
+ .run(tauri::generate_context!())
+ .expect("error while running tauri application");
+}
diff --git a/frontends/desktop/src-tauri/src/main.rs b/frontends/desktop/src-tauri/src/main.rs
new file mode 100644
index 000000000..ab57d1cb9
--- /dev/null
+++ b/frontends/desktop/src-tauri/src/main.rs
@@ -0,0 +1,5 @@
+#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
+
+fn main() {
+ ga_desktop_lib::run()
+}
diff --git a/frontends/desktop/src-tauri/tauri.conf.json b/frontends/desktop/src-tauri/tauri.conf.json
new file mode 100644
index 000000000..e74082c14
--- /dev/null
+++ b/frontends/desktop/src-tauri/tauri.conf.json
@@ -0,0 +1,41 @@
+{
+ "productName": "GenericAgent",
+ "version": "0.1.0",
+ "identifier": "com.genericagent.app",
+ "build": {
+ "frontendDist": "../static"
+ },
+ "app": {
+ "withGlobalTauri": true,
+ "security": {
+ "csp": null
+ },
+ "windows": [
+ {
+ "title": "GenericAgent",
+ "label": "main",
+ "width": 1280,
+ "height": 800,
+ "resizable": true,
+ "maximized": false,
+ "visible": false,
+ "url": "loading.html"
+ },
+ {
+ "title": "GenericAgent — Setup",
+ "label": "setup",
+ "width": 600,
+ "height": 580,
+ "resizable": false,
+ "visible": false,
+ "center": true,
+ "url": "fallback.html"
+ }
+ ]
+ },
+ "bundle": {
+ "active": true,
+ "targets": ["nsis", "dmg"],
+ "icon": ["icons/icon.ico", "icons/icon.png", "icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png"]
+ }
+}
diff --git a/frontends/desktop/static/app.js b/frontends/desktop/static/app.js
new file mode 100644
index 000000000..b6ad62bab
--- /dev/null
+++ b/frontends/desktop/static/app.js
@@ -0,0 +1,6203 @@
+// GenericAgent 桌面版 —— bridge 适配 + 业务 UI(HTTP 命令 / WS 状态 / i18n)。
+// 文案全部走 i18n:静态用 data-i18n / data-i18n-ph / data-i18n-title,
+// 动态用 t(key)。dev 标注层与发给 agent 的预设 prompt 不进 UI 字典。
+'use strict';
+
+/* ═══════════════ 端口/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}`;
+
+/* ═══════════════ 进程状态 store ═══════════════ */
+const _serviceById = {};
+const _serviceListeners = new Set();
+
+function _serviceList() {
+ return Object.values(_serviceById).sort((a, b) => String(a.id).localeCompare(String(b.id)));
+}
+
+function _serviceNotify() {
+ const items = _serviceList();
+ for (const cb of _serviceListeners) {
+ try { cb(items, _serviceById); } catch (e) { console.error('[service-store]', e); }
+ }
+}
+
+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 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)} \u29C9
${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 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;
+}
+
+/** 预览模式: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 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}
` : ''}
+
`;
+}
+
+/** 单题的 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 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);
+}
+
+/* ═══════════════ 渲染后增强 (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 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);
+ }
+ });
+ // 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);
+ });
+ };
+ });
+ // 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 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 planTpl(tpl, v) {
+ return String(tpl || '').replace(/\{(\w+)\}/g, (_, k) => (v[k] != null ? String(v[k]) : `{${k}}`));
+}
+
+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;
+ }
+}
+
+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 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);
+}
+
+/** 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 || '',
+ });
+}
+
+/** 每秒 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 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) {
+ const r = rt(sess);
+ if (r.taskTimerId) { clearInterval(r.taskTimerId); r.taskTimerId = null; }
+ if (!r.taskStartedAt) return;
+ r.taskEndedAt = Date.now();
+}
+
+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 {
+ 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') : '';
+ }
+}
+// run-toggle 现为纯状态展示组件:运行中转红,不再响应点击(停止改由发送键的录制键承担)
+
+/* ═══════════════ 会话 ═══════════════ */
+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 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')}
` +
+ `${GA_ICON('dotsThreeVertical')} `;
+ convListEl.appendChild(item);
+ }
+}
+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;
+}
+// 仿 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 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;
+ }
+ }
+ // 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');
+}
+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();
+}
+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) {
+ setSessionLoading(false);
+ state.activeId = id;
+ if (id) localStorage.setItem('ga_active', id); // 持久化当前会话,刷新后固定恢复它
+ const sess = state.sessions.get(id);
+ if (!sess) return;
+ if (msgsEl) msgsEl.innerHTML = '';
+ const r = rt(sess);
+ r.draftEl = null;
+ resetTypewriterState(r);
+ renderAllMessages(sess);
+ setBusy(sess, rt(sess).busy);
+ renderSessionList();
+ refreshPlanBar(null);
+ syncPlanPollTimer();
+ if (!sess.bridgeSessionId || !state.bridgeReady) return;
+ if (sessionNeedsHydrate(sess)) {
+ runSessionHydrate(sess);
+ } else {
+ restoreElapsedBadges(sess, ensureMsgs());
+ planPoll(sess);
+ }
+}
+async function closeSession(id) {
+ const sess = state.sessions.get(id);
+ if (sess && sess.bridgeSessionId) {
+ try { await window.ga.rpc('session/cancel', { sessionId: sess.bridgeSessionId }); } catch (_) {}
+ fetch(`${BRIDGE_ORIGIN}/session/${sess.bridgeSessionId}`, { method: 'DELETE' }).catch(() => {});
+ }
+ state.sessions.delete(id); state.runtime.delete(id);
+ if (state.activeId === id) {
+ 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();
+}
+
+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 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();
+ }
+});
+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 {
+ 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;
+ }
+ saveSessions();
+ patchSession(sess, { pinned: sess.pinned });
+ renderSessionList();
+ } 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();
+ };
+ inp.addEventListener('keydown', e => {
+ if (e.key === 'Enter') { e.preventDefault(); finish(true); }
+ else if (e.key === 'Escape') { e.preventDefault(); finish(false); }
+ });
+ 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;
+ }
+ 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) {
+ showError(t('err.poll') + ': ' + (e.message || e));
+ setBusy(sess, false);
+ } finally {
+ if (isActive(sess)) {
+ restoreElapsedBadges(sess, ensureMsgs());
+ syncAskUserUi();
+ }
+ tokPollBridge();
+ }
+}
+
+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 {
+ 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) {
+ const em = { role: 'error', content: e.message || String(e) };
+ sess.messages.push(em); appendMessage(sess, em);
+ setBusy(sess, false);
+ return false;
+ }
+}
+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 sess = activeSess(); if (!sess) return;
+ const m = { role: 'system', content: text };
+ sess.messages.push(m); appendMessage(sess, m);
+}
+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);
+}
+let _toastTimer = null;
+function showToast(text) {
+ let el = document.getElementById('ga-toast');
+ if (!el) { el = document.createElement('div'); el.id = 'ga-toast'; el.className = 'ga-toast'; document.body.appendChild(el); }
+ el.textContent = text;
+ el.classList.add('show');
+ clearTimeout(_toastTimer);
+ _toastTimer = setTimeout(() => el.classList.remove('show'), 1800);
+}
+async function handleSlash(cmd) {
+ const name = cmd.slice(1).split(/\s+/)[0];
+ switch (name) {
+ 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);
+ }
+}
+// 预设卡:按 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;
+}
+
+/* ═══════════════ 官方模型快速接入(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 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]));
+
+ // ── 渠道组(自动故障转移):可展开组;本身也可被选为激活模型 ──
+ 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)} ${GA_ICON('x')} `;
+ 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 {
+ 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 ? '' : `${GA_ICON('plus')} `;
+ row.innerHTML = `${escapeHtml(label)} ${mixToggle}${MODEL_ACT_EDIT} ${MODEL_ACT_DEL} `;
+ row.querySelector('[data-act="edit"]').addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); openEditModelForm(id); });
+ row.querySelector('[data-act="delete"]').addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); deleteModel(id, p.name); });
+ const addBtn = row.querySelector('[data-act="addmix"]');
+ if (addBtn) addBtn.addEventListener('click', (e) => {
+ e.stopPropagation(); e.preventDefault();
+ if (p.inMixin) showToast(t('model.alreadyInMixin'));
+ else addToMixin(id);
+ });
+ row.addEventListener('click', (e) => {
+ if (e.target.closest('.model-row-actions')) return;
+ e.preventDefault();
+ selectModel(id, p.name);
+ });
+ box.appendChild(row);
+ }
+ }
+ applyI18n();
+}
+function openSettings() {
+ openModal('settings-modal');
+ renderSettingsModels();
+ renderLangList();
+ applyTheme(theme, { persist: false });
+ applyAppearance(appearance, plainUi, { persist: false });
+ applyChatFontSize(chatFontSize, { persist: false });
+}
+async function loadModelProfiles() {
+ try {
+ 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);
+ }
+ 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 ``;
+ });
+ 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?.();
+ }
+});
+
+// 主题色板已删除,点击事件不再注册
+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 res = await window.ga.getConfig();
+ const cfg = res?.config || {};
+ if (LANGS.includes(cfg.lang)) {
+ lang = cfg.lang;
+ applyI18n();
+ }
+ 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 (_) {}
+}
+
+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 {
+ 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,
+ };
+ state.pendingFiles.push(entry);
+ insertPlaceholderInComposer(entry, ctx);
+ renderThumbStrip(ctx);
+ } catch (e) {
+ showChanToast(t('upload.failed'), e.message || String(e), 'err');
+ }
+ }
+ if (skipped) showChanToast(t('upload.tooLarge'), '', 'err');
+}
+
+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 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);
+}
+
+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();
+ });
+ }
+ 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');
+ }
+}
+
+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');
+ });
+ }
+
+ // 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);
+ }
+ });
+ // 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;
+ }
+ }
+ limitToast(maxLen);
+ }
+ }
+
+ // 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);
+ }
+ 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(); });
+ }
+
+ 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();
+ }
+})();
+
+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 {
+ 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;
+ }
+}
+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 000000000..f8e5a57e8
--- /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 000000000..947bfbebc
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 000000000..8103b351d
--- /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 000000000..2ca6ac605
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 000000000..0968c152f
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 000000000..e08293045
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
new file mode 100644
index 000000000..c53f3c4eb
--- /dev/null
+++ b/frontends/desktop/static/fallback.html
@@ -0,0 +1,132 @@
+
+
+
+
+GenericAgent — Setup
+
+
+
+
+
⚙️ Setup Required
+
The backend could not start. Please configure the paths below.
+
后端启动失败,请配置以下路径。
+
+
Python interpreter 解释器路径
+
+
+
+
+ e.g. C:/Python312/python.exe / ~/miniconda3/envs/myenv/bin/python
+
+
+
Project directory 项目目录
+
+
+
+
+ The folder containing frontends/desktop_bridge.py
+
+
+
Start 启动
+
+
+
+
+
+
+
diff --git a/frontends/desktop/static/ga-web.js b/frontends/desktop/static/ga-web.js
new file mode 100644
index 000000000..960f0b922
--- /dev/null
+++ b/frontends/desktop/static/ga-web.js
@@ -0,0 +1,142 @@
+// GenericAgent Web2 browser bridge adapter.
+// HTTP is the command/data channel. WebSocket only carries small state events.
+(() => {
+ 'use strict';
+
+ const listeners = new Map();
+ let ws = null;
+ let cachedBridgeReady = null;
+ const bridgeBase = `${location.protocol}//${location.hostname}:14168`;
+ const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.hostname}:14168/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-web2 listener] 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-web2 listener]', channel, err); }
+ }
+ }
+
+ 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;
+ }
+
+ function connectWs() {
+ if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
+ try {
+ ws = new WebSocket(wsUrl);
+ ws.addEventListener('open', () => emit('bridge-log', 'WS state channel 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 === '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' }));
+ ws.addEventListener('error', () => emit('bridge-error', { type: 'ws-error', message: 'WebSocket state channel error' }));
+ } catch (err) {
+ emit('bridge-error', { type: 'ws-error', message: err.message || String(err) });
+ }
+ }
+
+ 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 '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}`);
+ }
+ }
+
+ window.ga = {
+ platform: navigator.platform.toLowerCase().includes('mac') ? 'darwin' : 'win32',
+ startBridge: async () => { connectWs(); return http('/status'); },
+ stopBridge: async () => ({ ok: true }),
+ 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' }),
+ 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),
+ 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) }));
+})();
diff --git a/frontends/desktop/static/index.html b/frontends/desktop/static/index.html
new file mode 100644
index 000000000..3390ca8b8
--- /dev/null
+++ b/frontends/desktop/static/index.html
@@ -0,0 +1,679 @@
+
+
+
+
+GenericAgent
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0
+
+
+
+ 0
+
+
+
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
×
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontends/desktop/static/loading.html b/frontends/desktop/static/loading.html
new file mode 100644
index 000000000..f02eddb34
--- /dev/null
+++ b/frontends/desktop/static/loading.html
@@ -0,0 +1,56 @@
+
+
+
+
+GenericAgent
+
+
+
+
+
+
+
正在启动 GenericAgent…
+
+
+
+
diff --git a/frontends/desktop/static/phosphor-icons.js b/frontends/desktop/static/phosphor-icons.js
new file mode 100644
index 000000000..6a76f642c
--- /dev/null
+++ b/frontends/desktop/static/phosphor-icons.js
@@ -0,0 +1,101 @@
+(() => {
+ const PATHS = {
+ chatTeardropText:
+ 'M172,112a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h68A8,8,0,0,1,172,112Zm-8,24H96a8,8,0,0,0,0,16h68a8,8,0,0,0,0-16Zm68-12A100.11,100.11,0,0,1,132,224H48a16,16,0,0,1-16-16V124a100,100,0,0,1,200,0Zm-16,0a84,84,0,0,0-168,0v84h84A84.09,84.09,0,0,0,216,124Z',
+ broadcast:
+ 'M128,88a40,40,0,1,0,40,40A40,40,0,0,0,128,88Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,152Zm73.71,7.14a80,80,0,0,1-14.08,22.2,8,8,0,0,1-11.92-10.67,63.95,63.95,0,0,0,0-85.33,8,8,0,1,1,11.92-10.67,80.08,80.08,0,0,1,14.08,84.47ZM69,103.09a64,64,0,0,0,11.26,67.58,8,8,0,0,1-11.92,10.67,79.93,79.93,0,0,1,0-106.67A8,8,0,1,1,80.29,85.34,63.77,63.77,0,0,0,69,103.09ZM248,128a119.58,119.58,0,0,1-34.29,84,8,8,0,1,1-11.42-11.2,103.9,103.9,0,0,0,0-145.56A8,8,0,1,1,213.71,44,119.58,119.58,0,0,1,248,128ZM53.71,200.78A8,8,0,1,1,42.29,212a119.87,119.87,0,0,1,0-168,8,8,0,1,1,11.42,11.2,103.9,103.9,0,0,0,0,145.56Z',
+ chartBar:
+ 'M224,200h-8V40a8,8,0,0,0-8-8H152a8,8,0,0,0-8,8V80H96a8,8,0,0,0-8,8v40H48a8,8,0,0,0-8,8v64H32a8,8,0,0,0,0,16H224a8,8,0,0,0,0-16ZM160,48h40V200H160ZM104,96h40V200H104ZM56,144H88v56H56Z',
+ usersThree:
+ 'M244.8,150.4a8,8,0,0,1-11.2-1.6A51.6,51.6,0,0,0,192,128a8,8,0,0,1-7.37-4.89,8,8,0,0,1,0-6.22A8,8,0,0,1,192,112a24,24,0,1,0-23.24-30,8,8,0,1,1-15.5-4A40,40,0,1,1,219,117.51a67.94,67.94,0,0,1,27.43,21.68A8,8,0,0,1,244.8,150.4ZM190.92,212a8,8,0,1,1-13.84,8,57,57,0,0,0-98.16,0,8,8,0,1,1-13.84-8,72.06,72.06,0,0,1,33.74-29.92,48,48,0,1,1,58.36,0A72.06,72.06,0,0,1,190.92,212ZM128,176a32,32,0,1,0-32-32A32,32,0,0,0,128,176ZM72,120a8,8,0,0,0-8-8A24,24,0,1,1,87.24,82a8,8,0,1,0,15.5-4A40,40,0,1,0,37,117.51,67.94,67.94,0,0,0,9.6,139.19a8,8,0,1,0,12.8,9.61A51.6,51.6,0,0,1,64,128,8,8,0,0,0,72,120Z',
+ coins:
+ 'M184,89.57V84c0-25.08-37.83-44-88-44S8,58.92,8,84v40c0,20.89,26.25,37.49,64,42.46V172c0,25.08,37.83,44,88,44s88-18.92,88-44V132C248,111.3,222.58,94.68,184,89.57ZM232,132c0,13.22-30.79,28-72,28-3.73,0-7.43-.13-11.08-.37C170.49,151.77,184,139,184,124V105.74C213.87,110.19,232,122.27,232,132ZM72,150.25V126.46A183.74,183.74,0,0,0,96,128a183.74,183.74,0,0,0,24-1.54v23.79A163,163,0,0,1,96,152,163,163,0,0,1,72,150.25Zm96-40.32V124c0,8.39-12.41,17.4-32,22.87V123.5C148.91,120.37,159.84,115.71,168,109.93ZM96,56c41.21,0,72,14.78,72,28s-30.79,28-72,28S24,97.22,24,84,54.79,56,96,56ZM24,124V109.93c8.16,5.78,19.09,10.44,32,13.57v23.37C36.41,141.4,24,132.39,24,124Zm64,48v-4.17c2.63.1,5.29.17,8,.17,3.88,0,7.67-.13,11.39-.35A121.92,121.92,0,0,0,120,171.41v23.46C100.41,189.4,88,180.39,88,172Zm48,26.25V174.4a179.48,179.48,0,0,0,24,1.6,183.74,183.74,0,0,0,24-1.54v23.79a165.45,165.45,0,0,1-48,0Zm64-3.38V171.5c12.91-3.13,23.84-7.79,32-13.57V172C232,180.39,219.59,189.4,200,194.87Z',
+ sidebarSimple:
+ 'M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40ZM40,56H80V200H40ZM216,200H96V56H216V200Z',
+ pencilSimple:
+ 'M227.31,73.37,182.63,28.68a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.31,96a16,16,0,0,0,0-22.63ZM92.69,208H48V163.31l88-88L180.69,120ZM192,108.68,147.31,64l24-24L216,84.68Z',
+ magnifyingGlass:
+ 'M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z',
+ books:
+ 'M231.65,194.55,198.46,36.75a16,16,0,0,0-19-12.39L132.65,34.42a16.08,16.08,0,0,0-12.3,19l33.19,157.8A16,16,0,0,0,169.16,224a16.25,16.25,0,0,0,3.38-.36l46.81-10.06A16.09,16.09,0,0,0,231.65,194.55ZM136,50.15c0-.06,0-.09,0-.09l46.8-10,3.33,15.87L139.33,66Zm6.62,31.47,46.82-10.05,3.34,15.9L146,97.53Zm6.64,31.57,46.82-10.06,13.3,63.24-46.82,10.06ZM216,197.94l-46.8,10-3.33-15.87L212.67,182,216,197.85C216,197.91,216,197.94,216,197.94ZM104,32H56A16,16,0,0,0,40,48V208a16,16,0,0,0,16,16h48a16,16,0,0,0,16-16V48A16,16,0,0,0,104,32ZM56,48h48V64H56Zm0,32h48v96H56Zm48,128H56V192h48v16Z',
+ gridFour:
+ 'M200,40H56A16,16,0,0,0,40,56V200a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V56A16,16,0,0,0,200,40Zm0,80H136V56h64ZM120,56v64H56V56ZM56,136h64v64H56Zm144,64H136V136h64v64Z',
+ robot:
+ 'M200,48H136V16a8,8,0,0,0-16,0V48H56A32,32,0,0,0,24,80V192a32,32,0,0,0,32,32H200a32,32,0,0,0,32-32V80A32,32,0,0,0,200,48Zm16,144a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V80A16,16,0,0,1,56,64H200a16,16,0,0,1,16,16Zm-52-56H92a28,28,0,0,0,0,56h72a28,28,0,0,0,0-56Zm-24,16v24H116V152ZM80,164a12,12,0,0,1,12-12h8v24H92A12,12,0,0,1,80,164Zm84,12h-8V152h8a12,12,0,0,1,0,24ZM72,108a12,12,0,1,1,12,12A12,12,0,0,1,72,108Zm88,0a12,12,0,1,1,12,12A12,12,0,0,1,160,108Z',
+ dotsThree:
+ 'M140,128a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm56-12a12,12,0,1,0,12,12A12,12,0,0,0,196,116ZM60,116a12,12,0,1,0,12,12A12,12,0,0,0,60,116Z',
+ folderSimplePlus:
+ 'M216,72H130.67L102.93,51.2a16.12,16.12,0,0,0-9.6-3.2H40A16,16,0,0,0,24,64V200a16,16,0,0,0,16,16H216.89A15.13,15.13,0,0,0,232,200.89V88A16,16,0,0,0,216,72Zm0,128H40V64H93.33L123.2,86.4A8,8,0,0,0,128,88h88Zm-56-56a8,8,0,0,1-8,8H136v16a8,8,0,0,1-16,0V152H104a8,8,0,0,1,0-16h16V120a8,8,0,0,1,16,0v16h16A8,8,0,0,1,160,144Z',
+ folderSimple:
+ 'M216,72H130.67L102.93,51.2a16.12,16.12,0,0,0-9.6-3.2H40A16,16,0,0,0,24,64V200a16,16,0,0,0,16,16H216.89A15.13,15.13,0,0,0,232,200.89V88A16,16,0,0,0,216,72Zm0,128H40V64H93.33L123.2,86.4A8,8,0,0,0,128,88h88Z',
+ bracketsCurly:
+ 'M43.18,128a29.78,29.78,0,0,1,8,10.26c4.8,9.9,4.8,22,4.8,33.74,0,24.31,1,36,24,36a8,8,0,0,1,0,16c-17.48,0-29.32-6.14-35.2-18.26-4.8-9.9-4.8-22-4.8-33.74,0-24.31-1-36-24-36a8,8,0,0,1,0-16c23,0,24-11.69,24-36,0-11.72,0-23.84,4.8-33.74C50.68,38.14,62.52,32,80,32a8,8,0,0,1,0,16C57,48,56,59.69,56,84c0,11.72,0,23.84-4.8,33.74A29.78,29.78,0,0,1,43.18,128ZM240,120c-23,0-24-11.69-24-36,0-11.72,0-23.84-4.8-33.74C205.32,38.14,193.48,32,176,32a8,8,0,0,0,0,16c23,0,24,11.69,24,36,0,11.72,0,23.84,4.8,33.74a29.78,29.78,0,0,0,8,10.26,29.78,29.78,0,0,0-8,10.26c-4.8,9.9-4.8,22-4.8,33.74,0,24.31-1,36-24,36a8,8,0,0,0,0,16c17.48,0,29.32-6.14,35.2-18.26,4.8-9.9,4.8-22,4.8-33.74,0-24.31,1-36,24-36a8,8,0,0,0,0-16Z',
+ gear:
+ 'M128,80a48,48,0,1,0,48,48A48.05,48.05,0,0,0,128,80Zm0,80a32,32,0,1,1,32-32A32,32,0,0,1,128,160Zm88-29.84q.06-2.16,0-4.32l14.92-18.64a8,8,0,0,0,1.48-7.06,107.21,107.21,0,0,0-10.88-26.25,8,8,0,0,0-6-3.93l-23.72-2.64q-1.48-1.56-3-3L186,40.54a8,8,0,0,0-3.94-6,107.71,107.71,0,0,0-26.25-10.87,8,8,0,0,0-7.06,1.49L130.16,40Q128,40,125.84,40L107.2,25.11a8,8,0,0,0-7.06-1.48A107.6,107.6,0,0,0,73.89,34.51a8,8,0,0,0-3.93,6L67.32,64.27q-1.56,1.49-3,3L40.54,70a8,8,0,0,0-6,3.94,107.71,107.71,0,0,0-10.87,26.25,8,8,0,0,0,1.49,7.06L40,125.84Q40,128,40,130.16L25.11,148.8a8,8,0,0,0-1.48,7.06,107.21,107.21,0,0,0,10.88,26.25,8,8,0,0,0,6,3.93l23.72,2.64q1.49,1.56,3,3L70,215.46a8,8,0,0,0,3.94,6,107.71,107.71,0,0,0,26.25,10.87,8,8,0,0,0,7.06-1.49L125.84,216q2.16.06,4.32,0l18.64,14.92a8,8,0,0,0,7.06,1.48,107.21,107.21,0,0,0,26.25-10.88,8,8,0,0,0,3.93-6l2.64-23.72q1.56-1.48,3-3L215.46,186a8,8,0,0,0,6-3.94,107.71,107.71,0,0,0,10.87-26.25,8,8,0,0,0-1.49-7.06Zm-16.1-6.5a73.93,73.93,0,0,1,0,8.68,8,8,0,0,0,1.74,5.48l14.19,17.73a91.57,91.57,0,0,1-6.23,15L187,173.11a8,8,0,0,0-5.1,2.64,74.11,74.11,0,0,1-6.14,6.14,8,8,0,0,0-2.64,5.1l-2.51,22.58a91.32,91.32,0,0,1-15,6.23l-17.74-14.19a8,8,0,0,0-5-1.75h-.48a73.93,73.93,0,0,1-8.68,0,8,8,0,0,0-5.48,1.74L100.45,215.8a91.57,91.57,0,0,1-15-6.23L82.89,187a8,8,0,0,0-2.64-5.1,74.11,74.11,0,0,1-6.14-6.14,8,8,0,0,0-5.1-2.64L46.43,170.6a91.32,91.32,0,0,1-6.23-15l14.19-17.74a8,8,0,0,0,1.74-5.48,73.93,73.93,0,0,1,0-8.68,8,8,0,0,0-1.74-5.48L40.2,100.45a91.57,91.57,0,0,1,6.23-15L69,82.89a8,8,0,0,0,5.1-2.64,74.11,74.11,0,0,1,6.14-6.14A8,8,0,0,0,82.89,69L85.4,46.43a91.32,91.32,0,0,1,15-6.23l17.74,14.19a8,8,0,0,0,5.48,1.74,73.93,73.93,0,0,1,8.68,0,8,8,0,0,0,5.48-1.74L155.55,40.2a91.57,91.57,0,0,1,15,6.23L173.11,69a8,8,0,0,0,2.64,5.1,74.11,74.11,0,0,1,6.14,6.14,8,8,0,0,0,5.1,2.64l22.58,2.51a91.32,91.32,0,0,1,6.23,15l-14.19,17.74A8,8,0,0,0,199.87,123.66Z',
+ caretLeft:
+ 'M165.66,202.34a8,8,0,0,1-11.32,11.32l-80-80a8,8,0,0,1,0-11.32l80-80a8,8,0,0,1,11.32,11.32L91.31,128Z',
+ caretDown:
+ 'M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z',
+ caretRight:
+ 'M181.66,133.66l-80,80a8,8,0,0,1-11.32-11.32L164.69,128,90.34,53.66a8,8,0,0,1,11.32-11.32l80,80A8,8,0,0,1,181.66,133.66Z',
+ paperPlaneTilt:
+ 'M227.32,28.68a16,16,0,0,0-15.66-4.08l-.15,0L19.57,82.84a16,16,0,0,0-2.49,29.8L102,154l41.3,84.87A15.86,15.86,0,0,0,157.74,248q.69,0,1.38-.06a15.88,15.88,0,0,0,14-11.51l58.2-191.94c0-.05,0-.1,0-.15A16,16,0,0,0,227.32,28.68ZM157.83,231.85l-.05.14,0-.07-40.06-82.3,48-48a8,8,0,0,0-11.31-11.31l-48,48L24.08,98.25l-.07,0,.14,0L216,40Z',
+ paperclip:
+ 'M209.66,122.34a8,8,0,0,1,0,11.32l-82.05,82a56,56,0,0,1-79.2-79.21L147.67,35.73a40,40,0,1,1,56.61,56.55L105,193A24,24,0,1,1,71,159L154.3,74.38A8,8,0,1,1,165.7,85.6L82.39,170.31a8,8,0,1,0,11.27,11.36L192.93,81A24,24,0,1,0,159,47L59.76,147.68a40,40,0,1,0,56.53,56.62l82.06-82A8,8,0,0,1,209.66,122.34Z',
+ lightning:
+ 'M215.79,118.17a8,8,0,0,0-5-5.66L153.18,90.9l14.66-73.33a8,8,0,0,0-13.69-7l-112,120a8,8,0,0,0,3,13l57.63,21.61L88.16,238.43a8,8,0,0,0,13.69,7l112-120A8,8,0,0,0,215.79,118.17ZM109.37,214l10.47-52.38a8,8,0,0,0-5-9.06L62,132.71l84.62-90.66L136.16,94.43a8,8,0,0,0,5,9.06l52.8,19.8Z',
+ pushPinSimple:
+ 'M216,168h-9.29L185.54,48H192a8,8,0,0,0,0-16H64a8,8,0,0,0,0,16h6.46L49.29,168H40a8,8,0,0,0,0,16h80v56a8,8,0,0,0,16,0V184h80a8,8,0,0,0,0-16ZM86.71,48h82.58l21.17,120H65.54Z',
+ trash:
+ 'M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM96,40a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96Zm96,168H64V64H192ZM112,104v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Zm48,0v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Z',
+ x:
+ 'M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z',
+ dotsThreeVertical:
+ 'M140,128a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm0-56a12,12,0,1,1-12-12A12,12,0,0,1,140,72Zm0,112a12,12,0,1,1-12-12A12,12,0,0,1,140,184Z',
+ plus:
+ 'M224,128a8,8,0,0,1-8,8H136v80a8,8,0,0,1-16,0V136H40a8,8,0,0,1,0-16h80V40a8,8,0,0,1,16,0v80h80A8,8,0,0,1,224,128Z',
+ copy:
+ 'M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z',
+ check:
+ 'M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l-56-56a8,8,0,0,1,11.32-11.32L96,188.69,218.34,66.34a8,8,0,0,1,11.32,11.32Z',
+ crosshair:
+ 'M232,120h-8.34A96.14,96.14,0,0,0,136,32.34V24a8,8,0,0,0-16,0v8.34A96.14,96.14,0,0,0,32.34,120H24a8,8,0,0,0,0,16h8.34A96.14,96.14,0,0,0,120,223.66V232a8,8,0,0,0,16,0v-8.34A96.14,96.14,0,0,0,223.66,136H232a8,8,0,0,0,0-16Zm-96,87.6V200a8,8,0,0,0-16,0v7.6A80.15,80.15,0,0,1,48.4,136H56a8,8,0,0,0,0-16H48.4A80.15,80.15,0,0,1,120,48.4V56a8,8,0,0,0,16,0V48.4A80.15,80.15,0,0,1,207.6,120H200a8,8,0,0,0,0,16h7.6A80.15,80.15,0,0,1,136,207.6ZM128,88a40,40,0,1,0,40,40A40,40,0,0,0,128,88Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,152Z',
+ compass:
+ 'M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216ZM172.42,72.84l-64,32a8.05,8.05,0,0,0-3.58,3.58l-32,64A8,8,0,0,0,80,184a8.1,8.1,0,0,0,3.58-.84l64-32a8.05,8.05,0,0,0,3.58-3.58l32-64a8,8,0,0,0-10.74-10.74ZM138,138,97.89,158.11,118,118l40.15-20.07Z',
+ listChecks:
+ 'M224,128a8,8,0,0,1-8,8H128a8,8,0,0,1,0-16h88A8,8,0,0,1,224,128ZM128,72h88a8,8,0,0,0,0-16H128a8,8,0,0,0,0,16Zm88,112H128a8,8,0,0,0,0,16h88a8,8,0,0,0,0-16ZM82.34,42.34,56,68.69,45.66,58.34A8,8,0,0,0,34.34,69.66l16,16a8,8,0,0,0,11.32,0l32-32A8,8,0,0,0,82.34,42.34Zm0,64L56,132.69,45.66,122.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32,0l32-32a8,8,0,0,0-11.32-11.32Zm0,64L56,196.69,45.66,186.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32,0l32-32a8,8,0,0,0-11.32-11.32Z',
+ star:
+ 'M239.18,97.26A16.38,16.38,0,0,0,224.92,86l-59-4.76L143.14,26.15a16.36,16.36,0,0,0-30.27,0L90.11,81.23,31.08,86a16.46,16.46,0,0,0-9.37,28.86l45,38.83L53,211.75a16.38,16.38,0,0,0,24.5,17.82L128,198.49l50.53,31.08A16.4,16.4,0,0,0,203,211.75l-13.76-58.07,45-38.83A16.43,16.43,0,0,0,239.18,97.26Zm-15.34,5.47-48.7,42a8,8,0,0,0-2.56,7.91l14.88,62.8a.37.37,0,0,1-.17.48c-.18.14-.23.11-.38,0l-54.72-33.65a8,8,0,0,0-8.38,0L69.09,215.94c-.15.09-.19.12-.38,0a.37.37,0,0,1-.17-.48l14.88-62.8a8,8,0,0,0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16A8,8,0,0,0,103,91.86l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153,91.86a8,8,0,0,0,6.75,4.92l63.92,5.16c.15,0,.24,0,.33.29S224,102.63,223.84,102.73Z',
+ fileText:
+ 'M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM160,51.31,188.69,80H160ZM200,216H56V40h88V88a8,8,0,0,0,8,8h48V216Zm-32-80a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,136Zm0,32a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,168Z',
+ gitFork:
+ 'M224,64a32,32,0,1,0-40,31v17a8,8,0,0,1-8,8H80a8,8,0,0,1-8-8V95a32,32,0,1,0-16,0v17a24,24,0,0,0,24,24h40v25a32,32,0,1,0,16,0V136h40a24,24,0,0,0,24-24V95A32.06,32.06,0,0,0,224,64ZM48,64A16,16,0,1,1,64,80,16,16,0,0,1,48,64Zm96,128a16,16,0,1,1-16-16A16,16,0,0,1,144,192ZM192,80a16,16,0,1,1,16-16A16,16,0,0,1,192,80Z',
+ hexagon:
+ 'M223.68,66.15,135.68,18h0a15.88,15.88,0,0,0-15.36,0l-88,48.17a16,16,0,0,0-8.32,14v95.64a16,16,0,0,0,8.32,14l88,48.17a15.88,15.88,0,0,0,15.36,0l88-48.17a16,16,0,0,0,8.32-14V80.18A16,16,0,0,0,223.68,66.15ZM216,175.82,128,224,40,175.82V80.18L128,32h0l88,48.17Z',
+ };
+
+ function gaIcon(name, className = '') {
+ const d = PATHS[name];
+ if (!d) return '';
+ const cls = className ? ` class="${className}"` : '';
+ return ` `;
+ }
+
+ function gaHydrateIcons(root = document) {
+ root.querySelectorAll('[data-ga-icon]').forEach((node) => {
+ node.outerHTML = gaIcon(node.dataset.gaIcon, node.className || '');
+ });
+ }
+
+ window.GA_ICON_PATHS = PATHS;
+ window.gaIcon = gaIcon;
+ window.gaHydrateIcons = gaHydrateIcons;
+
+ if (typeof document !== 'undefined') {
+ const hydrate = () => gaHydrateIcons(document);
+ hydrate();
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', hydrate, { once: true });
+ }
+ }
+})();
diff --git a/frontends/desktop/static/styles.css b/frontends/desktop/static/styles.css
new file mode 100644
index 000000000..44c166eb0
--- /dev/null
+++ b/frontends/desktop/static/styles.css
@@ -0,0 +1,2517 @@
+/* GenericAgent 桌面版 — 界面骨架 mockup
+ 仅布局与视觉占位。所有颜色均走 CSS 变量,主题可整体切换(无硬编码颜色)。 */
+
+* { box-sizing: border-box; margin: 0; padding: 0; }
+
+/* —— 全局滚动条 —— */
+::-webkit-scrollbar{ width:6px; height:6px; }
+::-webkit-scrollbar-track{ background:transparent; }
+::-webkit-scrollbar-thumb{
+ background:var(--line);
+ border-radius:0;
+}
+::-webkit-scrollbar-thumb:hover{ background:var(--line-strong); }
+::-webkit-scrollbar-corner{ background:transparent; }
+
+:root{
+ --font-sans:-apple-system,BlinkMacSystemFont,system-ui,"Segoe UI Variable","Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei UI","Microsoft YaHei",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",emoji;
+ --font-brand:"GA Azonix",var(--font-sans);
+ --font-mono:"GA JetBrains Mono",ui-monospace,"SFMono-Regular",Menlo,Monaco,Consolas,"Liberation Mono","Courier New","PingFang SC","Hiragino Sans GB","Microsoft YaHei UI","Microsoft YaHei",monospace;
+ --font-num:var(--font-mono);
+ --font-num-variant:lining-nums tabular-nums;
+ --chat-col-w:760px;
+ /* —— 基础面 —— */
+ --bg-base:#f4f5f7;
+ --bg:var(--bg-base);
+ --panel:#ffffff;
+ --card:#ffffff;
+ --field-bg:#fafbfc;
+ --line-base:#e9eaee;
+ --line:var(--line-base);
+ --line-soft-base:#f0f1f4;
+ --line-soft:var(--line-soft-base);
+ --line-strong:#d4d8e0; /* hover 描边 */
+ --hover-2:#eceef2; /* 次级 hover 底 */
+ --hover-dim:#0000000f;
+
+ /* —— 文字 —— */
+ --txt:#1f2430;
+ --txt-strong:#3b414f; /* 标题 */
+ --txt-2:#4a4f5c; /* 常规可点文字 */
+ --txt-3:#5b6170;
+ --muted:#656b7a;
+ --muted2:#767d8d;
+ --placeholder:#c4cad6;
+ --icon:#6b7180;
+ --on-accent:#ffffff; /* 彩色底上的字/图标 */
+
+ /* —— 强调色(只保留灰色亮色主题,删去 mac 8 色主题切换)—— */
+ --accent:#8e8e93;
+ --accent-bg:#eef0f3;
+ --accent-soft:#f4f5f7;
+ /* 向后兼容别名:旧代码 / 未合并分支(如 PR#1)仍用 --blue* */
+ --blue:var(--accent); --blue-bg:var(--accent-bg); --blue-soft:var(--accent-soft);
+
+ /* —— 品牌徽标渐变 —— */
+ --brand1:#ffb35c;
+ --brand2:#ff7a59;
+
+ /* —— 状态:成功 / 危险 / 关闭 —— */
+ --ok:#39a85a;
+ --ok-strong:#2f9e54;
+ --ok-bg:#e9f7ee;
+ --ok-ring:#39a85a33;
+ /* 发送键专用:浅色=黑色,深色在 dark 块里覆盖为深蓝 */
+ --ink-green:#1a1a1a;
+ --ink-green-hover:#000000;
+ --danger:#d64545;
+ --danger-dot:#e23b3b;
+ --danger-text:#d3514f;
+ --danger-bg:#fdeceb;
+ --danger-ring:#e23b3b38;
+ --confirm-primary-bg:#1a1a1a;
+ --confirm-primary-fg:#ffffff;
+ --success:var(--ok-strong);
+ --success-bg:var(--ok-bg);
+ --success-ring:var(--ok-ring);
+ --off-bg:#eef0f3;
+ --switch-off:#cfd4de;
+ --dot-idle:#c2c7d0;
+
+ /* —— 外观切换页的样本色(不跟随当前主题,固定为 light/dark 实际配色) —— */
+ --sample-light-bg:#f4f5f7;
+ --sample-light-panel:#ffffff;
+ --sample-light-line:#e9eaee;
+ --sample-dark-bg:#161b22;
+ --sample-dark-panel:#21262d;
+ --sample-dark-line:#484f58;
+
+ /* —— 阴影 / 遮罩 —— */
+ --overlay:#14192861;
+ --shadow:0 1px 2px #1419280a, 0 8px 24px #1419280d;
+ --shadow-pop:0 8px 24px #14192829;
+ --shadow-modal:0 18px 50px #14192847;
+
+ --divider-base:rgba(15, 74, 133, 0.22);
+ --divider:var(--divider-base);
+
+ --radius:4px; --radius-sm:2px;
+ /* —— 折叠块 —— */
+ --fold-thinking-border:#d4c090;
+ --fold-thinking-bg:#fefbf3;
+ --fold-tool-border:#90b8d4;
+ --fold-tool-bg:#f3f8fe;
+ --fold-result-border:#a0d4a0;
+ --fold-result-bg:#f4fdf4;
+ --fold-turn-border:var(--line);
+ --fold-turn-bg:var(--field-bg);
+ --fold-retry-border:var(--line-soft);
+ --fold-retry-bg:transparent;
+}
+
+/* —— 主题色板(素色:仅 accent 浅底) —— */
+html[data-theme]{
+ --accent-bg:color-mix(in srgb, var(--accent) 14%, var(--panel));
+ --accent-soft:color-mix(in srgb, var(--accent) 7%, var(--panel));
+ --brand1:color-mix(in srgb, var(--accent) 40%, #ffc978);
+ --brand2:var(--accent);
+}
+
+/* —— 外观:浅色(accent 轻 tint 页面/边框)—— */
+html[data-appearance="light"]:not([data-plain="1"])[data-theme]{
+ --accent-bg:color-mix(in srgb, var(--accent) 20%, var(--panel));
+ --accent-soft:color-mix(in srgb, var(--accent) 11%, var(--panel));
+ --bg:color-mix(in srgb, var(--accent) 7%, var(--bg-base));
+ --line:color-mix(in srgb, var(--accent) 16%, var(--line-base));
+ --line-soft:color-mix(in srgb, var(--accent) 10%, var(--line-soft-base));
+ --divider:color-mix(in srgb, var(--accent) 22%, var(--divider-base));
+}
+
+/* —— 外观:深色 —— */
+html[data-appearance="dark"]{
+ --bg-base:#161b22;
+ --bg:#161b22;
+ --panel:#21262d;
+ --card:#21262d;
+ --field-bg:#161b22;
+ --line-base:#484f58;
+ --line:#484f58;
+ --line-soft-base:#30363d;
+ --line-soft:#30363d;
+ --line-strong:#656d76;
+ --hover-2:#30363d;
+ --hover-dim:#ffffff0f;
+ --txt:#e6edf3;
+ --txt-strong:#ffffff;
+ --txt-2:#c9d1d9;
+ --txt-3:#afb8c1;
+ --muted:#9aa3af;
+ --muted2:#828b96;
+ --placeholder:#656d76;
+ --icon:#7d8590;
+ --off-bg:#30363d;
+ --switch-off:#656d76;
+ --dot-idle:#656d76;
+ --ok:#3fb950;
+ --ok-strong:#46c35a;
+ --ok-bg:#12261e;
+ --ok-ring:#3fb95033;
+ --danger:#f85149;
+ --danger-dot:#f85149;
+ --danger-text:#ff7b72;
+ --danger-bg:#2d1515;
+ --danger-ring:#f8514938;
+ --confirm-primary-bg:#e6edf3;
+ --confirm-primary-fg:#161b22;
+ --overlay:#010409a6;
+ --shadow:0 1px 2px #01040966, 0 8px 24px #01040966;
+ --shadow-pop:0 8px 24px #01040999;
+ --shadow-modal:0 18px 50px #010409cc;
+ /* —— 折叠块(dark 覆盖) —— */
+ --fold-thinking-border:#6b5b30;
+ --fold-thinking-bg:#2a2418;
+ --fold-tool-border:#3a5066;
+ --fold-tool-bg:#16202b;
+ --fold-result-border:#3f6647;
+ --fold-result-bg:#15241a;
+ --divider-base:#569cd6;
+ --divider:var(--divider-base);
+ /* 发送键:深色用鲜亮深蓝 */
+ --ink-green:#2559d8;
+ --ink-green-hover:#3a6ef0;
+}
+html[data-appearance="dark"][data-theme]{
+ --accent-bg:color-mix(in srgb, var(--accent) 22%, var(--panel));
+ --accent-soft:color-mix(in srgb, var(--accent) 12%, var(--panel));
+ --divider:color-mix(in srgb, var(--accent) 18%, var(--divider-base));
+}
+
+html,body{ height:100%; }
+html{ --chat-font:13px; }
+body{ background:var(--bg); color:var(--txt); font-size:13px; overflow:hidden; font-family:var(--font-sans); }
+
+#app{
+ width:100vw; height:100vh; background:var(--bg);
+ overflow:hidden; display:flex; flex-direction:column;
+}
+
+/* ---- 弹窗(添加模型 / 配置)---- */
+.chip.add-model{ padding:5px 8px; }
+.modal{ position:fixed; inset:0; z-index:500; display:flex; align-items:center; justify-content:center; }
+.modal[hidden]{ display:none; }
+/* 添加模型是从「配置」里二级弹出,需盖在配置之上 */
+#add-model-modal{ z-index:600; }
+#confirm-modal{ z-index:700; }
+.modal-backdrop{ position:absolute; inset:0; background:var(--overlay); }
+.modal-card{
+ position:relative; width:420px; max-width:92vw; max-height:86vh; overflow:hidden;
+ background:var(--panel); border:1px solid var(--line); border-radius:6px;
+ box-shadow:var(--shadow-modal); display:flex; flex-direction:column;
+}
+/* 弹出动画 */
+@keyframes modalBgIn{ from{ opacity:0 } to{ opacity:1 } }
+@keyframes modalCardIn{
+ from{ opacity:0; transform:translateY(10px) scale(.96); }
+ to{ opacity:1; transform:none; }
+}
+.modal:not([hidden]) .modal-backdrop{ animation:modalBgIn .15s ease-out; }
+.modal:not([hidden]) .modal-card{ animation:modalCardIn .18s cubic-bezier(.2,.7,.3,1); }
+
+.modal-head{
+ display:flex; align-items:center; justify-content:space-between;
+ padding:16px 18px; border-bottom:1px solid var(--line-soft);
+}
+.modal-title{ font-size:15px; font-weight:600; color:var(--txt-strong); }
+.modal-x{
+ width:28px; height:28px; border:none; background:transparent; cursor:pointer;
+ color:var(--muted); border-radius:4px; display:flex; align-items:center; justify-content:center;
+}
+.modal-x svg{ width:16px; height:16px; }
+.modal-x:hover{ background:var(--line-soft); }
+.modal-body{ flex:1; min-height:340px; overflow:auto; }
+
+/* 配置弹窗内的骨架控件(未实现逻辑)*/
+.set-body{ min-height:0; padding:18px; display:flex; flex-direction:column; gap:22px; }
+.set-block{ display:flex; flex-direction:column; gap:0; }
+.preset-pop{ min-height:0; padding:18px; display:flex; flex-direction:column; gap:14px; }
+.set-sec-t{ font-size:12px; font-weight:500; color:var(--txt-2); margin-bottom:10px; }
+
+/* 外观:mac 式预览卡片 */
+.appear-cards{ display:grid; grid-template-columns:1fr 1fr; gap:10px; }
+.appear-card{
+ display:flex; flex-direction:column; align-items:center; gap:8px;
+ padding:10px 8px 9px; border:2px solid var(--line); border-radius:4px;
+ background:var(--field-bg); cursor:pointer; font-family:inherit; transition:.15s;
+}
+.appear-card:hover{ border-color:var(--line-strong); background:var(--line-soft); }
+.appear-card.sel{
+ border-color:var(--accent); background:var(--accent-bg);
+ box-shadow:0 0 0 1px color-mix(in srgb, var(--accent) 25%, transparent);
+}
+.appear-preview{
+ width:100%; height:44px; border-radius:4px; border:1px solid var(--line);
+ position:relative; overflow:hidden;
+}
+.appear-preview-light{
+ background:linear-gradient(180deg, var(--sample-light-bg) 0%, var(--sample-light-bg) 42%, var(--sample-light-panel) 42%, var(--sample-light-panel) 100%);
+}
+.appear-preview-light::after{
+ content:''; position:absolute; left:10%; top:52%; width:55%; height:7px;
+ border-radius:4px; background:var(--sample-light-line);
+}
+.appear-preview-dark{
+ background:linear-gradient(180deg, var(--sample-dark-bg) 0%, var(--sample-dark-bg) 42%, var(--sample-dark-panel) 42%, var(--sample-dark-panel) 100%);
+ border-color:var(--sample-dark-line);
+}
+.appear-preview-dark::after{
+ content:''; position:absolute; left:10%; top:52%; width:55%; height:7px;
+ border-radius:4px; background:var(--sample-dark-line);
+}
+.appear-name{ font-size:12px; font-weight:500; color:var(--txt-2); font-family:var(--font-sans); }
+.appear-card.sel .appear-name{ color:var(--accent); }
+
+/* 素色:mac/iOS 设置行 + Toggle Switch */
+.set-toggle-row{
+ display:flex; align-items:center; justify-content:space-between; gap:12px;
+ margin-top:10px; padding:10px 12px;
+ border:1px solid var(--line); border-radius:4px; background:var(--field-bg);
+}
+.set-toggle-row[hidden]{ display:none; }
+.set-toggle-label{ font-size:13px; font-weight:500; color:var(--txt-2); font-family:var(--font-sans); }
+.set-switch{
+ position:relative; flex:0 0 auto; width:44px; height:26px;
+ border:none; border-radius:999px; background:var(--switch-off);
+ cursor:pointer; transition:background .2s; padding:0;
+}
+.set-switch[aria-checked="true"]{ background:var(--accent); }
+.set-switch-knob{
+ position:absolute; top:2px; left:2px; width:22px; height:22px;
+ border-radius:50%; background:var(--card);
+ box-shadow:0 1px 3px var(--hover-dim); transition:transform .2s;
+}
+.set-switch[aria-checked="true"] .set-switch-knob{ transform:translateX(18px); }
+.set-switch:focus-visible{ outline:2px solid var(--accent); outline-offset:2px; }
+
+.lang-list{ margin-bottom:0; }
+
+.chat-font-panel{
+ display:grid; grid-template-columns:1fr auto; grid-template-rows:auto auto;
+ column-gap:14px; row-gap:6px; align-items:center;
+ padding:12px 14px; border:1px solid var(--line); border-radius:4px;
+ background:var(--field-bg);
+}
+.chat-font-stepper{ grid-column:1; grid-row:1; cursor:pointer; touch-action:none; border-radius:4px; }
+.chat-font-value{
+ grid-column:2; grid-row:1 / 3; align-self:center;
+ font-size:13px; font-weight:500; color:var(--txt-strong);
+ font-variant-numeric:var(--font-num-variant); text-align:right; line-height:1;
+ padding-left:14px; border-left:1px solid var(--line); min-width:42px;
+}
+.chat-font-scale{ grid-column:1; grid-row:2; }
+.chat-font-stepper:focus{ outline:none; }
+.chat-font-stepper:focus-visible{
+ outline:2px solid var(--accent); outline-offset:2px;
+}
+.chat-font-segments{
+ display:grid; grid-template-columns:repeat(11, 1fr); gap:3px;
+}
+.chat-font-seg{
+ height:7px; border:none; padding:0; margin:0; border-radius:2px;
+ background:var(--line); cursor:pointer;
+ transition:background .12s;
+}
+.chat-font-seg.on{ background:color-mix(in srgb, var(--accent) 55%, var(--line)); }
+.chat-font-seg.cur{ background:var(--accent); }
+.chat-font-seg:hover{ background:color-mix(in srgb, var(--accent) 35%, var(--line)); }
+.chat-font-seg.on:hover{ background:color-mix(in srgb, var(--accent) 55%, var(--line)); }
+.chat-font-seg.cur:hover{ background:var(--accent); }
+.chat-font-scale{
+ display:grid; grid-template-columns:repeat(11, 1fr); gap:3px;
+ font-size:11px; line-height:1; color:var(--muted);
+ font-family:var(--font-num); font-variant-numeric:var(--font-num-variant);
+}
+.chat-font-scale-min{ grid-column:1; justify-self:start; }
+.chat-font-scale-max{ grid-column:11; justify-self:end; }
+
+/* 颜色色盘已删除(只保留灰色亮色主题) */
+.model-list{ display:flex; flex-direction:column; gap:4px; margin-bottom:10px; max-height:340px; overflow:auto; }
+.model-row{
+ display:flex; align-items:center; gap:10px; padding:8px 10px;
+ border:1px solid var(--line); border-radius:4px; background:var(--field-bg);
+ cursor:pointer; font-size:13px; color:var(--txt-2); transition:.12s;
+}
+.model-row:hover{ background:var(--line-soft); border-color:var(--line-strong); }
+/* 选中态:文字用强调色 --txt-strong(而非偏灰的 --accent),保证选中后只会更清晰、不会变淡 */
+.model-row.sel{ background:var(--accent-bg); border-color:var(--accent); color:var(--txt-strong); }
+.model-row input{ accent-color:var(--accent); flex:0 0 auto; pointer-events:none; }
+.flag{ width:20px; height:14px; flex:0 0 auto; border-radius:2px; box-shadow:0 0 0 1px var(--line); display:inline-block; }
+.model-row-name{ flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+.model-row-actions{
+ display:flex; gap:2px; flex:0 0 auto; opacity:0; transition:opacity .12s;
+}
+.model-row:hover .model-row-actions,
+.model-row.sel .model-row-actions{ opacity:1; }
+.model-act{
+ width:28px; height:28px; border:none; background:transparent; cursor:pointer;
+ color:var(--muted); border-radius:4px; display:flex; align-items:center; justify-content:center;
+ padding:0; flex:0 0 auto;
+}
+.model-act:hover{ background:var(--hover-dim); color:var(--txt-2); }
+.model-act-del:hover{ color:var(--danger-text); }
+.model-act svg{ width:14px; height:14px; pointer-events:none; }
+.set-empty{ font-size:12px; color:var(--muted); padding:6px 0; }
+/* 聚合渠道分组:与独立模型同款行样式,仅多一个折叠箭头,且紧贴下方独立模型 */
+.model-group{ display:flex; flex-direction:column; gap:4px; }
+.model-mixin-caret{ display:flex; align-items:center; justify-content:center; flex:0 0 auto; height:28px; color:var(--muted); cursor:pointer; }
+/* 渠道组是中文标签而非代码标识符,用界面 sans 字体,避免落到等宽字体的中文回退 */
+.model-row--mixin .model-row-name{ font-family:var(--font-sans); }
+.model-mixin-caret svg{ width:13px; height:13px; pointer-events:none; }
+.model-mixin-body{ display:flex; flex-direction:column; gap:3px; margin:2px 0 2px 14px; padding-left:10px; border-left:1px dashed var(--line-strong); }
+.model-mixin-empty{ font-size:12px; color:var(--muted); padding:4px 2px; }
+.model-member{ display:flex; align-items:center; gap:8px; padding:5px 8px; border-radius:4px; font-size:12px; color:var(--txt-2); background:var(--field-bg); border:1px solid var(--line); }
+.model-member:hover{ background:var(--line-soft); }
+.model-member.dragging{ opacity:.78; box-shadow:0 3px 12px rgba(0,0,0,.12); }
+.model-member-drag{
+ width:16px; height:20px; flex:0 0 auto; border:none; background:transparent; color:var(--muted);
+ border-radius:4px; display:flex; align-items:center; justify-content:center; padding:0;
+ cursor:grab; touch-action:none; user-select:none;
+}
+.model-member-drag:active{ cursor:grabbing; }
+.grip-dot{
+ width:2px; height:2px; border-radius:50%; background:currentColor;
+ box-shadow:0 -4px 0 currentColor, 0 4px 0 currentColor, 5px -4px 0 currentColor, 5px 0 0 currentColor, 5px 4px 0 currentColor;
+ transform:translateX(-2.5px); opacity:.68;
+}
+.model-member:hover .model-member-drag{ color:var(--txt-2); background:var(--hover-dim); }
+.mixin-dragging{ cursor:grabbing; }
+.model-member-name{ flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+.model-member .model-act{ width:22px; height:22px; opacity:.6; }
+.model-member:hover .model-act{ opacity:1; }
+.model-act-addmix:hover{ color:var(--accent); }
+/* 已在渠道组:➕ 仍是「加入」按钮,但给个淡淡的 accent 提示,点击只弹「已在渠道组中」 */
+.model-act-addmix.is-in{ color:var(--accent); opacity:.5; }
+.model-act-addmix.is-in:hover{ opacity:.8; }
+/* 轻提示 toast(屏幕顶部)*/
+.ga-toast{
+ position:fixed; left:50%; top:72px; transform:translateX(-50%) translateY(-8px);
+ background:var(--card); color:var(--txt); border:1px solid var(--line-strong);
+ border-radius:6px; padding:10px 18px; font-size:13px; font-family:var(--font-sans);
+ box-shadow:0 8px 28px rgba(0,0,0,.24); opacity:0; pointer-events:none;
+ transition:opacity .18s, transform .18s; z-index:9999;
+}
+.ga-toast.show{ opacity:1; transform:translateX(-50%) translateY(0); }
+.set-btn{
+ width:100%; display:flex; align-items:center; gap:8px;
+ border:1px solid var(--line); background:var(--card); color:var(--txt-2);
+ border-radius:4px; padding:10px 12px; font-size:13px; font-weight:500;
+ font-family:inherit; cursor:pointer; transition:.12s;
+}
+.set-btn svg{ width:15px; height:15px; flex:0 0 auto; color:var(--accent); }
+.set-btn:hover{ background:var(--line-soft); border-color:var(--line-strong); }
+.set-btn.primary{ background:var(--confirm-primary-bg); color:var(--confirm-primary-fg); border-color:var(--confirm-primary-bg); }
+.set-btn.primary:hover{ filter:brightness(1.05); }
+.set-btn.danger{ background:var(--danger-bg); color:var(--danger-text); border-color:var(--danger-ring); }
+.set-btn.danger:hover{ background:var(--danger-bg); border-color:var(--danger-text); }
+.set-btn.ghost{ background:transparent; }
+.set-btn-follow{ margin-top:8px; }
+.model-form-body{ min-height:0; }
+.model-form{ padding:18px; display:flex; flex-direction:column; gap:12px; }
+.model-form label{ display:flex; flex-direction:column; gap:5px; font-size:12px; font-weight:500; color:var(--txt-2); }
+.field-label{ display:inline-flex; align-items:center; gap:2px; font-family:var(--font-sans); }
+.field-req{ color:var(--danger-text); font-weight:500; line-height:1; }
+.model-form input{
+ border:1px solid var(--line); border-radius:4px; padding:8px 10px;
+ font-size:13px; font-family:inherit; background:var(--field-bg); color:var(--txt);
+}
+.model-form input:focus{ outline:none; border-color:var(--accent); }
+.model-form input::placeholder{ color:var(--placeholder); opacity:1; }
+
+/* 协议选择:分段控件(替代 native dropdown,样式可控)*/
+.model-form-field{ display:flex; flex-direction:column; gap:5px; font-size:12px; font-weight:500; color:var(--txt-2); }
+.seg-group{
+ display:flex; gap:4px; padding:3px;
+ border:1px solid var(--line); border-radius:4px; background:var(--field-bg);
+}
+.seg-opt{
+ flex:1; display:flex; align-items:center; justify-content:center;
+ padding:7px 10px; border-radius:4px; cursor:pointer;
+ font-size:12px; font-weight:500; color:var(--txt-2); font-family:var(--font-sans);
+ transition:background .12s, color .12s; user-select:none;
+}
+.seg-opt input{ position:absolute; opacity:0; pointer-events:none; }
+.seg-opt:hover{ background:var(--line-soft); color:var(--txt); }
+.seg-opt:has(input:checked){ background:var(--accent); color:var(--on-accent); }
+.seg-opt:has(input:checked):hover{ background:var(--accent); color:var(--on-accent); }
+.model-form .field-hint{ font-size:11px; font-weight:400; color:var(--muted); line-height:1.35; margin-top:2px; }
+#add-model-form .model-form-row{
+ display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; width:100%;
+}
+#add-model-form .model-form-mini{
+ gap:4px; font-size:11px; font-weight:500; font-family:var(--font-sans);
+}
+#add-model-form .model-form-mini input[type="number"]{
+ padding:5px 6px; font-size:12px; line-height:1; height:30px;
+ border:1px solid var(--line); border-radius:4px;
+ background:var(--field-bg); color:var(--txt); font-family:inherit;
+}
+#add-model-form .model-form-mini input[type="number"]:focus{
+ outline:none; border-color:var(--accent);
+}
+.model-form-actions{ display:flex; gap:8px; margin-top:4px; }
+.model-form-actions .set-btn{ flex:1; justify-content:center; }
+.model-form-err{ font-size:12px; color:var(--danger-text); }
+
+.body{
+ --shell-pad:14px;
+ --shell-head-h:28px;
+ --shell-gap:10px;
+ --new-conv-h:40px;
+ --conv-list-pad-top:0;
+ --nav-align-pad:var(--shell-gap);
+ --chat-stack-gap:10px;
+ flex:1;
+ display:grid;
+ grid-template-columns:auto 0 minmax(0, 1fr) 0 auto;
+ grid-template-rows:minmax(0, 1fr);
+ min-height:0;
+ background:var(--bg);
+ padding:0;
+ position:relative;
+}
+.body > .sidebar{ grid-column:1; grid-row:1; min-height:0; }
+.body > .sb-resize{ grid-column:2; grid-row:1; }
+.body > .main{ grid-column:3; grid-row:1; min-width:0; min-height:0; }
+.body > .rp-resize{ grid-column:4; grid-row:1; }
+.body > .rightpanel{ grid-column:5; grid-row:1; min-height:0; }
+
+/* ============ ① 侧边栏(立体卡片 · 美术重点)============ */
+.sidebar{
+ width:232px; background:var(--panel);
+ border-right:1px solid var(--divider); border-radius:0; box-shadow:none;
+ overflow:hidden; display:flex; flex-direction:column;
+ padding:var(--shell-pad) 12px 12px;
+ box-sizing:border-box;
+ transition:width .2s, padding .2s, opacity .15s;
+}
+.brand{
+ display:flex; align-items:center; gap:0;
+ flex:0 0 var(--shell-head-h);
+ height:var(--shell-head-h);
+ max-height:var(--shell-head-h);
+ padding:0 6px;
+ box-sizing:border-box;
+ overflow:hidden;
+}
+.brand-text{ display:flex; flex-direction:column; min-width:0; justify-content:center; }
+.brand-name{
+ font-family:var(--font-brand);
+ font-weight:400;
+ font-size:17px;
+ line-height:1;
+ white-space:nowrap;
+}
+.brand-sub{ display:none; }
+
+.ga-num{
+ font-family:var(--font-num);
+ font-variant-numeric:var(--font-num-variant);
+}
+
+.nav{
+ display:flex; flex-direction:column; gap:2px; flex:1; min-height:0;
+ overflow:auto;
+ padding-top:var(--nav-align-pad);
+ box-sizing:border-box;
+}
+.nav-item{
+ display:flex; align-items:center; gap:8px; padding:0 8px;
+ height:28px; border-radius:var(--radius-sm); color:var(--txt-2); cursor:pointer;
+ font-size:13px; user-select:none; font-family:var(--font-sans);
+}
+.ic{ width:16px; height:16px; flex:0 0 auto; }
+.nav-item .ic{ opacity:.85; }
+.fr-l{ display:flex; align-items:center; gap:8px; }
+.nav-item:hover{ background:var(--line-soft); }
+/* 选中态保留"加粗"观感,但用 text-shadow 模拟而非 font-weight,
+ 避免真正改变字重导致字形占位变宽、引起布局抖动 */
+.nav-item.active{ background:var(--accent-bg); color:var(--txt-strong); text-shadow:-.3px 0 currentColor, .3px 0 currentColor, 0 0 .5px currentColor; }
+.nav-item.active .ic{ opacity:1; }
+.beta{
+ margin-left:auto; font-size:9px; font-weight:500; color:var(--accent);
+ background:var(--accent-soft); padding:1px 6px; border-radius:10px;
+}
+.nav-item.active .beta{ background:var(--card); }
+
+/* margin-top:6px 跟 .provider-quickstart 内部的 gap:6px 对齐,
+ 保证「通义千问 → 配置」和「pq-head → DeepSeek」间距一致 */
+.sidebar-foot{ border-top:none; padding-top:0; margin-top:6px; }
+.foot-ver{
+ font-size:10px; color:var(--muted2); padding:10px 10px 2px;
+ font-family:var(--font-num); font-variant-numeric:var(--font-num-variant);
+}
+.foot-row{
+ display:flex; justify-content:space-between; align-items:center;
+ padding:14px 10px; border-radius:var(--radius-sm); color:var(--txt-2); cursor:pointer;
+ font-family:var(--font-sans);
+}
+.foot-row:hover{ background:var(--line-soft); }
+.chev{ width:14px; height:14px; color:var(--muted2); flex:0 0 auto; }
+
+/* ── 侧边栏「快速接入官方模型」:头部与「配置」按钮同款 foot-row 风格,
+ 左侧 lightning 图标 + 标题,右侧 caretRight 跟"配置"完全一致,
+ 展开时旋转 90° 朝下,折叠时朝右(== "配置"的 >)。默认展开,可折叠。── */
+.provider-quickstart{
+ flex:0 0 auto; margin-top:8px;
+ display:flex; flex-direction:column; gap:6px;
+}
+.pq-head{
+ display:flex; justify-content:space-between; align-items:center;
+ padding:14px 10px; border-radius:var(--radius-sm); color:var(--txt-2); cursor:pointer;
+ font-family:var(--font-sans); user-select:none;
+}
+.pq-head:hover{ background:var(--line-soft); }
+.pq-collapse{ transition:transform .18s; transform-origin:center; }
+.pq-head:hover .pq-collapse{ color:var(--txt-2); }
+.pq-body{ display:flex; flex-direction:column; gap:6px; padding:0 10px; }
+/* 默认展开:箭头朝下;折叠:箭头朝右(== 「配置」的 >) */
+.provider-quickstart:not(.collapsed) .pq-collapse{ transform:rotate(90deg); }
+.provider-quickstart.collapsed .pq-collapse{ transform:rotate(0deg); }
+.provider-quickstart.collapsed{ gap:0; }
+.provider-quickstart.collapsed .pq-body{ display:none; }
+.pq-btn{
+ display:flex; align-items:center; gap:8px; width:100%;
+ padding:7px 8px; border:1px solid var(--line-base); border-radius:var(--radius-sm);
+ background:var(--field-bg); cursor:pointer; text-align:left;
+ transition:border-color .15s, background .15s;
+}
+.pq-btn:hover{ background:var(--card); border-color:var(--accent); }
+.pq-logo{
+ flex:0 0 auto; width:26px; height:26px; border-radius:7px;
+ display:flex; align-items:center; justify-content:center;
+}
+.pq-logo svg{ width:16px; height:16px; }
+.pq-btn-text{ flex:1; min-width:0; display:flex; flex-direction:column; gap:1px; }
+.pq-btn-name{ font-size:12px; font-weight:500; color:var(--txt); line-height:1.35; }
+.pq-btn-desc{ font-size:10px; color:var(--muted); line-height:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+.pq-arrow{ flex:0 0 auto; width:13px; height:13px; color:var(--muted2); transition:transform .15s, color .15s; }
+.pq-btn:hover .pq-arrow{ color:var(--accent); transform:translateX(2px); }
+
+/* ── 添加模型弹窗内的接入指引横幅 ── */
+.model-guide{
+ margin:18px 18px 0; padding:12px 14px;
+ border:1px solid var(--line-base); border-radius:var(--radius-sm);
+ background:var(--field-bg);
+}
+.model-guide-head{ display:flex; align-items:center; gap:8px; margin-bottom:8px; }
+.model-guide-logo{
+ flex:0 0 auto; width:24px; height:24px; border-radius:6px;
+ display:flex; align-items:center; justify-content:center;
+}
+.model-guide-logo svg{ width:15px; height:15px; }
+.model-guide-name{ font-size:13px; font-weight:600; color:var(--txt-strong); }
+.model-guide-steps{ margin:0 0 8px; padding-left:18px; display:flex; flex-direction:column; gap:3px; }
+.model-guide-steps li{ font-size:12px; color:var(--txt-2); line-height:1.55; }
+.model-guide-link-row{ display:flex; align-items:center; gap:6px; }
+.model-guide-link{
+ flex:1; min-width:0; font-size:12px; font-weight:500; color:var(--accent);
+ text-decoration:none; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
+}
+.model-guide-link:hover{ text-decoration:underline; }
+.model-guide-copy{
+ flex:0 0 auto; width:26px; height:26px; border:1px solid var(--line);
+ border-radius:6px; background:var(--panel); color:var(--muted);
+ display:flex; align-items:center; justify-content:center; cursor:pointer;
+}
+.model-guide-copy:hover{ color:var(--accent); border-color:var(--accent); }
+.model-guide-copy svg{ width:14px; height:14px; display:block; }
+.model-guide-tip{ margin:8px 0 0; font-size:11px; color:var(--muted); line-height:1.35; }
+.confirm-card{ width:360px; }
+.confirm-body{ min-height:0; padding:18px; }
+.confirm-message{ white-space:pre-wrap; color:var(--txt-2); font-size:13px; line-height:1.55; }
+.confirm-actions{ display:flex; gap:10px; margin-top:18px; }
+.confirm-actions .set-btn{ flex:1; justify-content:center; }
+
+/* ============ 中间主区 ============ */
+.main{
+ display:flex; flex-direction:column; min-width:0; min-height:0;
+ background:var(--panel);
+ border:0;
+ border-radius:0; box-shadow:none; overflow:hidden;
+ padding-top:var(--shell-pad);
+ box-sizing:border-box;
+}
+
+.topbar{
+ height:46px; flex:0 0 46px; display:flex; align-items:center;
+ justify-content:space-between; padding:0 16px; border-bottom:none;
+ background:var(--panel);
+}
+/* 各页面自带的顶栏(融入页面、随页面切换),沿用旧 .topbar 视觉,但归属各 page */
+.page > .page-top{
+ height:var(--shell-head-h); max-height:var(--shell-head-h);
+ flex:0 0 var(--shell-head-h);
+ display:grid; grid-template-columns:28px 1fr 28px; align-items:center;
+ padding:0 4px; column-gap:8px;
+ background:var(--panel);
+ box-sizing:border-box;
+}
+.page > .page-top .run-state{
+ grid-column:2; justify-self:center;
+ height:var(--shell-head-h); max-height:var(--shell-head-h);
+}
+.page > .page-top .pt-sb-toggle{ grid-column:1; justify-self:start; }
+.page > .page-top .pt-rp-toggle{ grid-column:3; justify-self:end; }
+.page > .page-top .pt-spacer{ grid-column:2; }
+/* 顶栏左右两侧的纯图标按钮(无边框、无背景):
+ 左 .pt-sb-toggle = 开关左栏,右 .pt-rp-toggle = 开关右栏。
+ 收起时 svg 旋转 180°,跟之前那两个箭头 toggle 的视觉行为一致。 */
+.pt-icon{
+ width:28px; height:28px; display:inline-flex; align-items:center; justify-content:center;
+ background:transparent; border:none; padding:0; flex:0 0 auto;
+ color:var(--txt-2); opacity:.7; cursor:pointer;
+ transition:opacity .15s, color .15s;
+}
+.pt-icon:hover{ opacity:1; color:var(--txt); }
+.pt-icon svg{ width:16px; height:16px; transition:transform .2s; }
+.body.sb-collapsed .pt-sb-toggle svg,
+.body.rp-collapsed .pt-rp-toggle svg{ transform:rotate(180deg); }
+.pt-spacer{ flex:1 1 auto; }
+.tb-left{ display:flex; align-items:center; gap:14px; font-size:12px; }
+.pill{
+ border:1px solid var(--line); background:var(--card); border-radius:20px;
+ padding:5px 12px; font-size:12px; color:var(--txt-2); cursor:pointer;
+}
+.pill:hover{ border-color:var(--line-strong); }
+/* 纯状态展示组件(不可点):就绪/运行中=绿,未连接=红,连接中=灰 */
+.run-state{
+ display:flex; align-items:center; gap:9px; height:var(--shell-head-h); color:var(--ok);
+ font-weight:500; font-size:12px; border:none; background:transparent;
+ padding:0; font-family:inherit; cursor:default; line-height:1;
+}
+.run-state .dot{
+ width:9px; height:9px; border-radius:50%; background:var(--ok); flex:0 0 auto;
+}
+.run-state.connecting{ color:var(--muted); }
+.run-state.connecting .dot{ background:var(--muted2); }
+.run-state.offline{ color:var(--danger); }
+.run-state.offline .dot{ background:var(--danger-dot); }
+.run-state.busy{ color:var(--ok); }
+
+/* 呼吸灯:内圈常亮(容器 60%) + 中圈扩散(无转圈) */
+.ga-status-breathe,
+.run-state.busy .dot{
+ --breathe-color:var(--ok);
+ --breathe-box:12px;
+ --breathe-core:calc(var(--breathe-box) * 0.6);
+ position:relative;
+ display:inline-block;
+ width:var(--breathe-box);
+ height:var(--breathe-box);
+ flex:0 0 auto;
+ background:transparent !important;
+ border:none;
+ vertical-align:middle;
+}
+.ga-status-breathe--sm{ --breathe-box:10px; }
+.run-state.busy .dot{
+ width:var(--breathe-box);
+ height:var(--breathe-box);
+}
+.ga-status-breathe__core,
+.run-state.busy .dot::before{
+ content:'';
+ position:absolute;
+ left:50%; top:50%;
+ width:var(--breathe-core); height:var(--breathe-core);
+ transform:translate(-50%,-50%);
+ border-radius:50%;
+ background:var(--breathe-color);
+ z-index:2;
+}
+.ga-status-breathe__ring,
+.run-state.busy .dot::after{
+ content:'';
+ position:absolute;
+ left:50%; top:50%;
+ width:var(--breathe-core); height:var(--breathe-core);
+ transform:translate(-50%,-50%);
+ border-radius:50%;
+ border:1px solid color-mix(in srgb, var(--breathe-color) 52%, transparent);
+ z-index:1;
+ pointer-events:none;
+ animation:ga-status-breathe 1.75s ease-out infinite;
+}
+@keyframes ga-status-breathe{
+ 0%{ transform:translate(-50%,-50%) scale(1); opacity:.55; }
+ 100%{ transform:translate(-50%,-50%) scale(2.35); opacity:0; }
+}
+.link{ color:var(--accent); cursor:pointer; }
+.muted{ color:var(--muted); }
+.ic-btn{
+ width:30px; height:30px; border-radius:4px; border:1px solid transparent;
+ background:transparent; cursor:pointer; color:var(--icon);
+ display:flex; align-items:center; justify-content:center;
+}
+.ic-btn svg{ width:16px; height:16px; }
+.ic-btn:hover{ background:var(--line-soft); }
+
+.chat-tools{
+ display:flex; align-items:center; gap:8px; padding:10px 16px;
+ border-bottom:none;
+}
+.chat-tools .spacer{ flex:1; }
+
+/* ---- 多页面容器:一项导航一页 ---- */
+#pages{ flex:1; min-height:0; display:flex; flex-direction:column; }
+.page{ display:none; flex:1; min-height:0; flex-direction:column; min-width:0; }
+.page.active{ display:flex; }
+.page-pad{ flex:1; overflow:auto; padding:26px 30px; }
+.page-title{ font-size:18px; font-weight:600; color:var(--txt-strong); font-family:var(--font-sans); }
+.page-sub{ font-size:12px; color:var(--muted); margin-top:5px; margin-bottom:16px; }
+
+/* 聊天 / Conductor 共用对话布局(page--chat-ui) */
+.page.page--chat-ui{ min-height:0; width:100%; }
+.page.page--chat-ui > .page-top{ flex:0 0 var(--shell-head-h); }
+.page.page--chat-ui > .chat-shell{
+ flex:1; min-height:0; width:100%;
+ margin-top:var(--shell-gap);
+ padding:0 16px 16px;
+ display:flex; flex-direction:column;
+ box-sizing:border-box;
+}
+.page.page--chat-ui > .chat-shell > .chat-panel{
+ flex:1; min-height:0; width:100%;
+ display:flex; flex-direction:column;
+ gap:var(--chat-stack-gap);
+ box-sizing:border-box;
+}
+.page.page--chat-ui .msg-area{
+ position:relative;
+ flex:1; min-height:0;
+ width:100%;
+ overflow:auto;
+ display:flex;
+ flex-direction:column;
+ align-items:stretch;
+ justify-content:center;
+ gap:14px;
+ padding:16px 0;
+ margin:0;
+ border:none;
+ border-radius:0;
+ background:transparent;
+ box-sizing:border-box;
+}
+.page.page--chat-ui .composer-anchor,
+.page.page--chat-ui .composer,
+.page.page--chat-ui .composer-slot,
+.page.page--chat-ui .composer-inset{
+ width:100%;
+ min-width:0;
+ box-sizing:border-box;
+}
+.page.page--chat-ui .composer-anchor{
+ flex:0 0 auto;
+ padding:0;
+ margin:0;
+ background:transparent;
+}
+.page.page--chat-ui .composer{
+ padding:10px 0 0;
+}
+.page.page--chat-ui .composer-inset{
+ border:1px solid var(--line);
+ border-radius:var(--radius);
+ background:var(--card);
+}
+.page.page--chat-ui .msgs{
+ width:100%;
+ max-width:var(--chat-col-w);
+ margin:0 auto;
+}
+.page.page--chat-ui .chat-start{
+ width:100%;
+ max-width:var(--chat-col-w);
+ margin:0 auto;
+}
+
+/* 聊天页(其它页面仍用下方默认) */
+.msg-area{
+ position:relative;
+ flex:1; overflow:auto; display:flex; flex-direction:column;
+ align-items:center; justify-content:center; gap:14px; padding:24px;
+ border:1px solid var(--line);
+}
+.session-loading{
+ position:absolute; inset:0; z-index:25;
+ display:flex; flex-direction:column; align-items:center; justify-content:center; gap:10px;
+ background:color-mix(in srgb, var(--panel) 88%, transparent);
+ font-size:13px; font-weight:500; color:var(--txt-2);
+ pointer-events:none;
+}
+.session-loading[hidden]{ display:none !important; }
+.msg-area.is-session-loading .chat-start,
+.msg-area.is-session-loading .msgs{ visibility:hidden; }
+.msg-area.has-msgs .msg-loading{ margin-top:auto; }
+.msg-loading{
+ position:sticky; bottom:16px; align-self:center; z-index:20;
+ display:flex; align-items:center; gap:10px;
+ margin-top:12px; padding:10px 16px;
+ border-radius:999px; background:var(--card);
+ border:1px solid color-mix(in srgb, var(--accent) 35%, var(--line));
+ box-shadow:var(--shadow-pop);
+ font-size:13px; font-weight:500; color:var(--txt);
+ pointer-events:none;
+}
+.msg-loading[hidden]{ display:none !important; }
+.msg-area.is-loading .msg-loading{ animation:msg-loading-in .2s ease-out; }
+@keyframes msg-loading-in{
+ from{ opacity:0; transform:translateY(8px); }
+ to{ opacity:1; transform:translateY(0); }
+}
+.ga-spin{
+ width:20px; height:20px; flex:0 0 auto;
+ border:2.5px solid var(--line); border-top-color:var(--accent);
+ border-radius:50%; animation:ga-spin .65s linear infinite;
+}
+@keyframes ga-spin{ to{ transform:rotate(360deg); } }
+/* 有消息时必须顶对齐:父级 .page--chat-ui .msg-area 的 center 优先级更高,否则会滚不到顶部 */
+.page.page--chat-ui .msg-area.has-msgs{ align-items:stretch; justify-content:flex-start; }
+.conv-empty{ color:var(--muted2); font-size:12px; }
+
+/* 消息流 */
+.msgs{ width:100%; max-width:var(--chat-col-w); margin:0 auto; display:flex; flex-direction:column; gap:14px; }
+.msg{ display:flex; flex-wrap:wrap; }
+.msg.user{ justify-content:flex-end; }
+.msg.assistant,.msg.error{ justify-content:flex-start; }
+.msg.system{ justify-content:center; }
+.bubble{
+ max-width:80%; padding:10px 14px; border-radius:6px; font-size:var(--chat-font);
+ line-height:1.55; white-space:pre-wrap; word-break:break-word;
+ background:var(--accent-bg); color:var(--txt-strong);
+}
+.bubble.md{ white-space:normal; background:transparent; color:var(--txt); max-width:100%; width:100%; border-radius:4px; }
+.bubble.err{ background:var(--danger-bg); color:var(--danger-text); white-space:normal; }
+.bubble.sys{ background:transparent; color:var(--muted); font-size:12px; padding:4px 8px; }
+.bubble.md p{ margin:0 0 8px; } .bubble.md p:last-child{ margin:0; }
+/* ---- TUI 风格代码块 ---- */
+.code-block{ border:1px solid var(--line); border-radius:var(--radius-sm); overflow:hidden; margin:8px 0; }
+.code-block-head{
+ display:flex; align-items:center; justify-content:space-between;
+ padding:4px 12px; background:var(--field-bg); border-bottom:1px solid var(--line);
+ font-family:var(--font-mono); font-size:.75em; color:var(--muted);
+}
+.code-block-lang{ text-transform:uppercase; letter-spacing:.5px; font-weight:500; }
+.code-block-copy{
+ background:none; border:none; color:var(--muted); cursor:pointer;
+ font-size:1.1em; padding:2px 4px; border-radius:3px; line-height:1;
+ transition:color .15s, background .15s;
+}
+.code-block-copy:hover{ color:var(--txt); background:var(--hover-dim); }
+.code-block pre{ margin:0; border:none; border-radius:0; }
+.bubble.md pre{ background:var(--field-bg); border:1px solid var(--line); border-radius:4px; padding:10px 12px; overflow:auto; font-size:.86em; font-family:var(--font-mono); }
+.bubble.md pre code{ font-family:var(--font-mono); }
+.bubble.md .code-block pre{ background:var(--field-bg); border:none; border-radius:0; padding:12px 14px; overflow:auto; font-size:.86em; margin:0; }
+.bubble.md .code-block{ border:1px solid var(--line); }
+.bubble.md :not(pre) > code{ background:var(--field-bg); border-radius:4px; padding:1px 5px; font-size:.86em; font-family:var(--font-mono); }
+.bubble.md a{ color:var(--accent); }
+.bubble.md ul,.bubble.md ol{ margin:0 0 8px; padding-left:20px; }
+.bubble.md blockquote{
+ margin:0 0 8px; padding:0 0 0 12px;
+ border-left:3px solid var(--line-strong); color:var(--txt-2);
+}
+.bubble.md h1,.bubble.md h2,.bubble.md h3,.bubble.md h4,.bubble.md h5,.bubble.md h6{
+ color:var(--txt-strong); margin:0 0 8px; line-height:1.35; font-weight:600;
+}
+.bubble.md h1{ font-size:1.3em; } .bubble.md h2{ font-size:1.15em; } .bubble.md h3{ font-size:1.05em; }
+.bubble.md strong,.bubble.md b{ color:var(--txt-strong); font-weight:600; }
+.bubble.md hr{ border:none; border-top:1px solid var(--line-strong); margin:10px 0; }
+.bubble.md table{ width:100%; border-collapse:collapse; margin:0 0 8px; font-size:.92em; }
+.bubble.md th,.bubble.md td{ border:1px solid var(--line); padding:6px 10px; text-align:left; }
+.bubble.md th{ background:var(--line-soft); color:var(--txt-strong); }
+.fold{ margin:6px 0; border:none; border-radius:0; background:transparent; }
+.bubble .fold summary{ cursor:pointer; padding:6px 10px; font-size:.86em; color:var(--muted); user-select:none; list-style:none; display:flex; align-items:center; gap:4px; }
+.bubble .fold summary::-webkit-details-marker{ display:none; }
+.bubble .fold summary::marker{ display:none; content:''; }
+.bubble .fold summary::before{ content:'▶'; font-size:.7em; display:inline-block; transition:transform .2s ease; }
+.fold[open] > summary::before{ transform:rotate(90deg); }
+.bubble .fold summary:hover{ color:var(--txt-2); }
+.bubble .fold pre{ margin:0; padding:10px 12px; border:none; background:transparent; font-size:.86em; overflow:auto; }
+.cursor{ display:inline-block; width:7px; height:14px; background:var(--accent); vertical-align:-2px; margin-left:2px; animation:blink 1s steps(2) infinite; }
+.draft-tw-plain{
+ white-space:pre-wrap; word-break:break-word;
+ font-family:inherit; font-size:inherit; line-height:inherit;
+}
+.draft-tw-plain:empty{ display:none; }
+@keyframes blink{ 50%{ opacity:0; } }
+
+/* 预设功能卡片(现在嵌在聊天空状态)*/
+.chat-start{ width:100%; max-width:560px; }
+html[data-boot-has-sessions] .chat-start{ display:none !important; }
+.feature-head{ text-align:center; margin-bottom:22px; }
+.feature-grid{ display:grid; grid-template-columns:repeat(auto-fill,minmax(150px,1fr)); gap:12px; }
+.fcard{
+ border:1px solid var(--line); border-radius:4px; background:var(--card);
+ padding:16px 14px; cursor:pointer; transition:.15s;
+}
+.fcard:hover{ border-color:var(--accent); box-shadow:var(--shadow); transform:translateY(-1px); }
+.fcard.add{ border-style:dashed; background:var(--accent-soft); }
+.fc-ic{ width:26px; height:26px; color:var(--txt-strong); }
+.fc-t{
+ font-weight:500; font-size:13px; margin-top:10px; font-family:var(--font-sans);
+ white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
+}
+.fc-d{
+ font-size:11px; color:var(--muted); margin-top:3px;
+ display:-webkit-box; -webkit-box-orient:vertical;
+ -webkit-line-clamp:2; line-clamp:2;
+ overflow:hidden; text-overflow:ellipsis; word-break:break-word;
+}
+
+/* 后台服务页 — 下划线 Tab(消息通道 / 状态面板)。margin 与 .tok-tabs 对齐
+ 保证两个页面标题与 tab 之间间距一致 */
+.svc-tabs{
+ display:flex; gap:28px; margin:10px 0 0;
+ border-bottom:1px solid var(--line);
+}
+.svc-tab{
+ padding:12px 0 10px; margin-bottom:-1px;
+ font-size:14px; font-weight:500; border:none; background:transparent;
+ color:var(--muted); cursor:pointer;
+ border-bottom:2px solid transparent; transition:color .15s, border-color .15s;
+}
+.svc-tab:hover{ color:var(--txt); }
+.svc-tab.active{ color:var(--txt-strong); border-bottom-color:var(--txt-strong); }
+.svc-panel{ display:none; margin-top:16px; }
+.svc-panel.active{ display:block; }
+.svc-panel .list{ margin-top:0; }
+
+/* 通用列表(消息通道 / 状态面板 / token 明细)*/
+.list{ display:flex; flex-direction:column; gap:6px; margin-top:14px; }
+.list-row{
+ display:flex; align-items:center; gap:12px; padding:11px 14px;
+ border:1px solid var(--line); border-radius:var(--radius-sm); background:var(--card); font-size:13px;
+}
+.list-row.head{ background:var(--line-soft); color:var(--muted); font-size:11px; border:none; }
+.lr-ic{ width:18px; height:18px; color:var(--muted); flex:0 0 auto; }
+.grow{ flex:1; }
+.kv{ color:var(--muted); font-size:12px; min-width:54px; text-align:right; }
+.lr-st{ font-size:11px; padding:2px 8px; border-radius:10px; font-weight:500; }
+.lr-st.on{ color:var(--ok-strong); background:var(--ok-bg); }
+.lr-st.off{ color:var(--muted); background:var(--off-bg); }
+.lr-st.err{ color:var(--danger-text); background:var(--danger-bg); }
+.sw-mini{ width:32px; height:18px; border:none; border-radius:12px; background:var(--switch-off); position:relative; cursor:pointer; }
+.sw-mini i{ position:absolute; top:2px; left:2px; width:14px; height:14px; background:var(--on-accent); border-radius:50%; transition:.15s; }
+.sw-mini.on{ background:var(--accent); }
+.sw-mini.on i{ left:16px; }
+.sw-mini:disabled{ opacity:.45; cursor:not-allowed; }
+.link.sm{ font-size:12px; }
+.link-btn{
+ border:none; background:none; padding:0; font:inherit;
+ color:var(--accent); cursor:pointer;
+}
+.link-btn:disabled{ opacity:.45; cursor:not-allowed; }
+.list-row .link-btn, .list-row .sw-mini{ flex-shrink:0; }
+.list-empty{
+ margin-top:14px; padding:24px; text-align:center;
+ font-size:13px; color:var(--muted);
+}
+.toast-root{
+ position:fixed; left:50%; top:72px; transform:translateX(-50%);
+ z-index:99999; display:flex; flex-direction:column; align-items:center; gap:8px;
+ pointer-events:none; max-width:min(420px,92vw); width:max-content;
+}
+.toast{
+ padding:12px 20px; border-radius:var(--radius-sm);
+ font-size:13px; line-height:1.35;
+ box-shadow:var(--shadow-pop);
+ opacity:0; transform:translateY(-8px); transition:opacity .28s ease, transform .28s ease;
+ pointer-events:auto; text-align:center; min-width:200px;
+}
+.toast.show{ opacity:1; transform:translateY(0); }
+.toast-ok{
+ color:var(--success); background:var(--success-bg); border:1px solid var(--success-ring);
+}
+.toast-err{
+ color:var(--danger-text); background:var(--danger-bg); border:1px solid var(--danger-ring);
+}
+.toast-info{
+ color:var(--muted); background:var(--card); border:1px solid var(--line);
+ box-shadow:var(--shadow); padding:6px 12px; min-width:0; font-size:12px;
+}
+.toast-title{ font-weight:500; display:block; margin-bottom:2px; }
+.toast-detail{ font-size:12px; opacity:.88; display:block; word-break:break-word; }
+.toast-info .toast-title{ font-weight:500; margin-bottom:0; }
+.composer.is-locked{
+ opacity:.55; pointer-events:none; filter:saturate(.75);
+}
+.composer.is-locked .input{
+ background:var(--field-bg); cursor:not-allowed;
+}
+.chan-log-pre{
+ max-height:360px; overflow:auto; margin:0;
+ font-family:var(--font-mono); font-variant-numeric:var(--font-num-variant); font-size:13px; line-height:1.55;
+ color:var(--txt-2); background:var(--field-bg);
+ border:1px solid var(--line); border-radius:var(--radius-sm);
+ padding:14px 16px; white-space:pre-wrap; word-break:break-word;
+}
+.modal-card-wide{ width:min(720px,92vw); }
+.modal-card-inset{ width:min(680px,calc(92vw - 48px)); }
+.modal-body-inset{ padding:0 18px 18px; min-height:0; }
+.chan-config-body{
+ display:flex; flex-direction:column; gap:8px;
+}
+.mykey-editor{
+ flex:1; min-height:420px; width:100%;
+ padding:0; margin:0;
+ font-family:var(--font-mono); font-variant-numeric:var(--font-num-variant); font-size:13px; line-height:1.55;
+ color:var(--txt); background:var(--card);
+ border:none; border-radius:0; resize:vertical; outline:none;
+}
+.mykey-editor:focus{ outline:none; }
+.chan-config-foot{
+ display:flex; justify-content:flex-end;
+}
+.chan-config-save{
+ border:1px solid var(--line); background:var(--card); color:var(--txt-2);
+ border-radius:6px; padding:5px 12px; font-size:12px; font-weight:500;
+ font-family:inherit; cursor:pointer;
+}
+.chan-config-save:hover{ background:var(--hover-2); }
+.chan-config-save:disabled{ opacity:.45; cursor:not-allowed; }
+.chan-meta{ display:flex; flex-direction:column; gap:2px; min-width:0; }
+.chan-meta .chan-name{ font-size:14px; }
+.chan-meta .chan-path{ font-size:11px; color:var(--muted); font-family:var(--font-mono); font-variant-numeric:var(--font-num-variant); }
+
+/* 协作动态卡片 */
+.card-row{ display:grid; grid-template-columns:repeat(auto-fill,minmax(150px,1fr)); gap:12px; margin:14px 0; }
+.card-mini{ border:1px solid var(--line); border-radius:var(--radius-sm); background:var(--card); padding:14px; }
+.card-mini b{ font-size:13px; }
+.cm-st{ font-size:11px; font-weight:500; margin-top:8px; }
+.cm-st.on{ color:var(--ok-strong); } .cm-st.idle{ color:var(--muted); }
+.cm-d{ font-size:11px; color:var(--muted); margin-top:4px; }
+
+/* token 汇总卡 — tab 复用 .svc-tabs/.svc-tab 的下划线样式(原蓝色 pill 弃用) */
+.tok-tabs{
+ display:flex; gap:28px; margin:10px 0 0;
+ border-bottom:1px solid var(--line);
+}
+.tok-tab{
+ padding:12px 0 10px; margin-bottom:-1px;
+ font-size:14px; font-weight:500; border:none; background:transparent;
+ color:var(--muted); cursor:pointer;
+ border-bottom:2px solid transparent; transition:color .15s, border-color .15s;
+}
+.tok-tab:hover{ color:var(--txt); background:transparent; }
+.tok-tab.active{
+ color:var(--txt-strong); border-color:transparent; border-bottom-color:var(--txt-strong);
+ background:transparent;
+}
+.tok-filter{ display:flex; align-items:center; gap:8px; margin:12px 0; flex-wrap:wrap; }
+.tok-filter label{ font-size:12px; color:var(--muted); }
+.tok-date{
+ box-sizing:border-box; height:30px;
+ border:1px solid var(--line); border-radius:6px; padding:0 8px;
+ font-size:12px; background:var(--field-bg); color:var(--txt); cursor:pointer;
+ width:160px;
+}
+.tok-date:focus{ border-color:var(--accent); outline:none; }
+/* Flatpickr 主题覆盖 — 全部使用项目 CSS 变量 */
+.flatpickr-calendar{
+ background:var(--card) !important; border:1px solid var(--line) !important; border-radius:var(--radius) !important;
+ box-shadow:var(--shadow-pop) !important; font-family:inherit !important; font-size:13px !important;
+}
+.flatpickr-months .flatpickr-month,
+.flatpickr-current-month{ color:var(--txt) !important; fill:var(--txt) !important; font-weight:500 !important; }
+.flatpickr-current-month .flatpickr-monthDropdown-months,
+.flatpickr-current-month input.cur-year{ color:var(--txt) !important; background:var(--field-bg) !important; font-weight:500 !important; font-size:13px !important; }
+.flatpickr-weekdays{ background:var(--field-bg) !important; }
+span.flatpickr-weekday{ color:var(--muted) !important; font-size:11px; }
+.flatpickr-day{
+ color:var(--txt) !important; border-radius:6px;
+}
+.flatpickr-day:hover{ background:var(--hover-2) !important; border-color:transparent !important; }
+.flatpickr-day.today{ border-color:var(--accent) !important; }
+.flatpickr-day.selected{ background:var(--accent) !important; color:var(--on-accent) !important; border-color:var(--accent) !important; }
+.flatpickr-day.prevMonthDay,
+.flatpickr-day.nextMonthDay{ color:var(--muted2) !important; }
+.flatpickr-time input,
+.flatpickr-time .flatpickr-am-pm{ color:var(--txt) !important; background:var(--field-bg) !important; }
+.flatpickr-time input:hover,
+.flatpickr-time input:focus{ background:var(--hover-2) !important; }
+.numInputWrapper:hover{ background:var(--hover-2) !important; }
+.flatpickr-months .flatpickr-prev-month,
+.flatpickr-months .flatpickr-next-month{ fill:var(--txt-2) !important; }
+.flatpickr-months .flatpickr-prev-month:hover svg,
+.flatpickr-months .flatpickr-next-month:hover svg{ fill:var(--txt) !important; }
+.tok-reset{
+ box-sizing:border-box; height:30px; display:inline-flex; align-items:center;
+ border:1px solid var(--line); border-radius:4px; padding:0 10px;
+ font-size:12px; background:var(--field-bg); color:var(--muted); cursor:pointer;
+}
+.tok-reset:hover{ background:var(--hover-2); color:var(--txt-2); }
+/* 固定列宽:数字列按 fmtTok 最长输出"123.45M"宽度估算固定 px,
+ 第一列(会话名/指挥家累计 等)拿剩余空间,过长用省略号 + title tooltip */
+.tok-table{ width:100%; border-collapse:collapse; margin-top:14px; font-size:13px; table-layout:fixed; }
+.tok-table th{ text-align:left; padding:8px 12px; font-size:11px; color:var(--muted); background:var(--line-soft); font-weight:500; }
+.tok-table th:not(:first-child){ text-align:right; }
+.tok-table td{ padding:9px 12px; border-bottom:1px solid var(--line-soft); }
+.tok-table td:not(:first-child){ text-align:right; color:var(--muted); font-size:12px; font-family:var(--font-num); font-variant-numeric:var(--font-num-variant); }
+.tok-table td:first-child{ font-weight:500; color:var(--txt-strong); }
+/* 第一列自动占剩余宽度,溢出省略 */
+.tok-table th:first-child, .tok-table td:first-child{
+ overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
+}
+/* 数字列固定 80px,缓存率列 85px */
+.tok-table th:nth-child(n+2):nth-child(-n+5),
+.tok-table td:nth-child(n+2):nth-child(-n+5){ width:80px; }
+.tok-table th:nth-child(6),
+.tok-table td:nth-child(6){ width:85px; }
+/* Conductor tab 只有两行汇总: 用自动列宽铺满,数字列均匀分布,不让首列吃掉所有空间 */
+.tok-table--conductor{ table-layout:auto; }
+.tok-table--conductor th:first-child, .tok-table--conductor td:first-child{ overflow:visible; white-space:nowrap; }
+.tok-table--conductor th:nth-child(n+2):nth-child(-n+5),
+.tok-table--conductor td:nth-child(n+2):nth-child(-n+5),
+.tok-table--conductor th:nth-child(6),
+.tok-table--conductor td:nth-child(6){ width:auto; }
+.tok-row-session{ cursor:pointer; }
+.tok-row-session:hover td{ background:var(--hover-dim); }
+.tok-row-session td:first-child::before{ content:'▶'; margin-right:6px; font-size:10px; color:var(--muted2); transition:transform .15s; display:inline-block; }
+.tok-row-session.open td:first-child::before{ transform:rotate(90deg); }
+.tok-row-conductor{ cursor:default; }
+.tok-row-conductor td:first-child::before{ content:''; margin:0; }
+.tok-cond-ico{ width:1em; height:1em; vertical-align:-0.15em; margin-right:5px; }
+.tok-row-conductor:hover td{ background:var(--hover-dim); }
+.tok-detail td{ padding:5px 12px 5px 32px; font-size:11px; color:var(--muted); border-bottom:1px solid var(--line-soft); background:var(--field-bg); }
+.tok-detail td:not(:first-child){ text-align:right; }
+.tok-detail[hidden]{ display:none; }
+.tok-model-tip{
+ color:var(--accent);
+}
+.tok-pager{ display:flex; gap:4px; justify-content:center; margin-top:12px; flex-wrap:wrap; align-items:center; }
+.tok-pager button{
+ min-width:28px; height:28px; border:1px solid var(--line); border-radius:4px;
+ background:var(--card); color:var(--txt-2); font-size:12px; cursor:pointer;
+ display:inline-flex; align-items:center; justify-content:center;
+}
+/* 首尾箭头按钮用 phosphor SVG,跟侧栏 .chev 同款灰色,尺寸略小 */
+.tok-pager-arrow svg{ width:12px; height:12px; color:var(--muted2); }
+.tok-pager-arrow:hover:not(:disabled) svg{ color:var(--txt-2); }
+.tok-pager button:hover:not(:disabled){ background:var(--hover-2); }
+.tok-pager button:disabled{ opacity:.4; cursor:default; }
+.tok-pager button.active{ background:var(--accent); color:var(--on-accent); border-color:var(--accent); }
+.tok-pager-gap{ color:var(--muted); padding:0 4px; font-size:12px; user-select:none; }
+.tok-deleted{ margin-left:6px; font-size:10px; font-weight:400; color:var(--muted2); background:var(--line-soft); border-radius:3px; padding:1px 5px; }
+.stat-row{ display:flex; gap:12px; margin:14px 0; }
+.stat{ flex:1; border:1px solid var(--line); border-radius:var(--radius); background:var(--card); padding:16px; text-align:center; }
+.s-n{ font-size:20px; font-weight:600; color:var(--txt-strong); font-family:var(--font-num); font-variant-numeric:var(--font-num-variant); }
+.s-l{ font-size:11px; color:var(--muted); margin-top:4px; }
+
+.composer{
+ position:relative; padding:12px 16px 16px;
+ border-top:none;
+ background:var(--panel);
+}
+.chip{
+ border:1px solid var(--line); background:transparent; border-radius:0;
+ padding:0 11px; min-height:30px; font-size:12px; font-weight:500; color:var(--txt-2); cursor:pointer;
+ font-family:var(--font-sans);
+ display:inline-flex; align-items:center; gap:5px;
+}
+.chip.sm{
+ padding:0 12px;
+ min-height:32px;
+ font-family:var(--font-sans);
+ font-size:14px;
+ font-weight:700;
+ line-height:1;
+ text-transform:uppercase;
+ color:var(--txt-strong);
+ background:var(--field-bg);
+ border-color:var(--line-strong);
+}
+.chip-ic{ width:12px; height:12px; flex:0 0 auto; transition:transform .18s; }
+.chip.open .chip-ic{ transform:rotate(180deg); }
+.cb-left .chip{
+ border-top:0; border-bottom:0; border-left:0; border-right:1px solid var(--line);
+ background:transparent;
+}
+.cb-left .chip.sm{
+ background:var(--field-bg);
+ color:var(--txt-strong);
+ border-right:1px solid var(--line-strong);
+}
+.cb-left .chip:first-child{ border-left:0; border-top-left-radius:4px; border-bottom-left-radius:4px; }
+.cb-left .chip:last-child{ border-right:0; border-top-right-radius:4px; border-bottom-right-radius:4px; }
+.cb-left .chip:only-child{ border-radius:4px; border-right:0; }
+.composer-inset{
+ width:100%;
+ border:1px solid var(--line); border-radius:var(--radius);
+ background:var(--card);
+ display:grid;
+ grid-template-columns:1fr;
+}
+.composer-inset .thumb-strip{ padding:8px 12px 0; }
+.composer-inset.is-inline{
+ grid-template-columns:1fr auto;
+}
+.composer-inset.is-inline .thumb-strip{ grid-column:1 / -1; }
+.composer-inset.is-inline .input{ grid-column:1; }
+.composer-inset.is-inline .composer-bot{
+ grid-column:2;
+ padding:0 8px 0 0;
+ align-self:end;
+}
+.composer-inset.is-inline .composer-bot .spacer{ display:none; }
+.composer-inset:focus-within{
+ border-color:var(--accent);
+ box-shadow:0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent);
+}
+.composer-inset .input{
+ width:100%; min-height:38px; max-height:160px; overflow-y:auto;
+ border:none; border-radius:0; background:transparent;
+ padding:10px 12px; font-size:13px; line-height:1.55; outline:none;
+ font-family:inherit; color:var(--txt);
+ white-space:pre-wrap; word-break:break-word;
+}
+/* contenteditable 空提示(textarea 的 placeholder 等价物)*/
+.composer-inset .input:empty::before{
+ content:attr(data-ph); color:var(--muted); pointer-events:none;
+ font-size:13px; font-weight:400;
+}
+.composer-inset .composer-bot{
+ margin-top:0; padding:2px 10px 8px; justify-content:flex-start;
+}
+.composer-inset .composer-bot .spacer{ flex:1; }
+.composer-plus-wrap{ position:relative; flex:0 0 auto; }
+.composer-plus-wrap .composer-menu{
+ left:0; right:auto; top:auto; bottom:calc(100% + 6px); min-width:180px;
+}
+.composer-plus{ width:34px; height:34px; }
+.composer-inset .composer-bot > #model-chip,
+.composer-inset .composer-bot > #cdb-model-chip{ border:none; border-radius:4px; }
+/* 原子附件 chip(像 @人,光标进不去、退格整块删)*/
+.ph-chip{
+ display:inline-flex; align-items:center; vertical-align:baseline;
+ margin:0 2px; padding:1px 8px; border-radius:6px;
+ background:color-mix(in srgb, var(--accent) 22%, transparent); color:var(--accent);
+ font-size:.9em; white-space:nowrap; user-select:none; cursor:default;
+}
+.composer-bot{ display:flex; align-items:center; justify-content:flex-end; gap:8px; }
+.cb-left{
+ display:inline-flex; align-items:stretch; gap:0;
+ border:1px solid var(--line); border-radius:4px; overflow:hidden;
+ background:var(--field-bg);
+}
+.send{
+ position:relative;
+ width:34px; height:34px; border-radius:6px; border:none; cursor:pointer;
+ background:var(--ink-green); color:#fff;
+ display:flex; align-items:center; justify-content:center;
+ transition:background .15s;
+}
+.send:hover:not(:disabled):not(.is-stop){ background:var(--ink-green-hover); }
+.send:disabled{ opacity:.7; cursor:not-allowed; }
+.send.is-busy svg{ visibility:hidden; }
+.send.is-busy::after{
+ content:''; position:absolute; inset:0; margin:auto;
+ width:16px; height:16px;
+ border:2px solid color-mix(in srgb, var(--on-accent) 35%, transparent);
+ border-top-color:var(--on-accent); border-radius:50%;
+ animation:ga-spin .65s linear infinite;
+}
+.send svg{ width:15px; height:15px; }
+/* 运行中:发送键变红色停止键(点击=暂停当前回合)。回车仍走发送(打断重发)。 */
+.send.is-stop{ background:var(--danger); cursor:pointer; }
+.send.is-stop:hover{ background:var(--danger-dot); }
+.send.is-stop svg{ display:none; }
+.send.is-stop::after{
+ content:''; width:12px; height:12px; border-radius:3px;
+ background:var(--on-accent);
+}
+
+/* ============ ④ 右侧会话面板 ============ */
+.rp-toggle, .sb-toggle{
+ position:relative; z-index:10; align-self:center;
+ width:20px; height:36px; border:0; background:none; border-radius:4px;
+ cursor:pointer; display:flex; align-items:center; justify-content:center;
+ color:var(--txt-2); flex:0 0 auto; margin-right:-10px; margin-left:-10px;
+ opacity:.55; /* 展开态:纯图标按钮,无底无框 */
+ transition:color .15s, opacity .15s, background .15s, width .2s, margin .2s;
+}
+.rp-toggle:hover, .sb-toggle:hover{ opacity:1; color:var(--txt); background:var(--hover-2); }
+.rp-toggle svg, .sb-toggle svg{ width:14px; height:14px; transition:transform .2s; }
+/* 视觉上压进对应面板内侧(不改变 flex 布局,仅位移) */
+.rp-toggle{ transform:translateX(24px); }
+.sb-toggle{ transform:translateX(-22px); }
+
+.body.rp-collapsed .rp-toggle svg,
+.body.sb-collapsed .sb-toggle svg{ transform:rotate(180deg); }
+.body.rp-collapsed .rp-toggle{
+ position:absolute; right:7px; top:50%; transform:translateY(-50%);
+ margin:0; width:20px; opacity:.6;
+ border:1px solid var(--line); background:var(--panel); /* 折叠态:恢复成边缘小标签 */
+}
+.body.sb-collapsed .sb-toggle{
+ position:absolute; left:7px; top:50%; transform:translateY(-50%);
+ margin:0; width:20px; opacity:.6;
+ border:1px solid var(--line); background:var(--panel);
+}
+.body.rp-collapsed .rp-toggle:hover,
+.body.sb-collapsed .sb-toggle:hover{ opacity:1; background:var(--panel); border-color:var(--line-strong); }
+
+.rp-resize, .sb-resize{
+ width:6px; min-width:0; cursor:col-resize; border-radius:2px;
+ background:transparent; transition:background .15s;
+ align-self:stretch; justify-self:stretch;
+ position:relative; z-index:5;
+ margin:0 -3px;
+}
+.rp-resize:hover, .rp-resize.dragging,
+.sb-resize:hover, .sb-resize.dragging{ background:var(--accent); }
+.body.rp-collapsed .rp-resize,
+.body.sb-collapsed .sb-resize{ display:none; }
+
+/* 侧边栏折叠时整块隐藏 */
+.body.sb-collapsed .sidebar{
+ width:0; padding:0; border:none; opacity:0; pointer-events:none; overflow:hidden;
+ margin:0; display:none;
+}
+
+.rightpanel{
+ width:220px; background:var(--panel);
+ border-left:1px solid var(--divider); border-radius:0; box-shadow:none;
+ overflow:hidden;
+ padding:var(--shell-pad) 12px 12px;
+ box-sizing:border-box;
+ display:flex; flex-direction:column; gap:var(--shell-gap);
+ transition:width .2s, padding .2s, opacity .15s;
+}
+.rp-head{
+ flex:0 0 auto;
+ display:block;
+ box-sizing:border-box;
+}
+.search{
+ display:flex; align-items:center; gap:6px; border:1px solid var(--line);
+ border-radius:4px; padding:0 10px; color:var(--muted); font-size:12px;
+ min-height:var(--shell-head-h);
+ box-sizing:border-box;
+}
+.search input{ border:none; outline:none; flex:1; font-size:12px; background:transparent; color:var(--txt); }
+.search-ic{ width:14px; height:14px; color:var(--muted); flex:0 0 auto; }
+.new-conv{
+ flex:0 0 var(--new-conv-h);
+ min-height:var(--new-conv-h);
+ width:100%;
+ box-sizing:border-box;
+ border:1px solid var(--line); background:var(--line-soft); border-radius:4px;
+ padding:0 12px; font-size:13px; color:var(--txt-2); cursor:pointer; font-weight:500;
+ display:flex; align-items:center; justify-content:center; gap:7px; font-family:var(--font-sans);
+}
+.new-conv svg{ width:14px; height:14px; }
+.new-conv:hover{ background:var(--hover-2); }
+.conv-list{
+ flex:1; display:flex; flex-direction:column; align-items:stretch; gap:6px;
+ padding-top:var(--conv-list-pad-top);
+ overflow:auto; min-height:0;
+ scrollbar-width:none;
+ -ms-overflow-style:none;
+}
+.conv-list::-webkit-scrollbar{ display:none; width:0; height:0; }
+.body.rp-collapsed .rightpanel{
+ width:0; padding:0; border:none; opacity:0; pointer-events:none; overflow:hidden;
+ margin:0; display:none;
+}
+.conv-empty{ color:var(--muted2); font-size:12px; }
+.conv-item{ display:flex; align-items:center; gap:9px; padding:9px 10px; border-radius:4px; cursor:pointer; }
+.conv-item:hover{ background:var(--line-soft); }
+.conv-item.active{ background:var(--accent-bg); }
+.ci-dot{ width:7px; height:7px; border-radius:50%; background:var(--ok); flex:0 0 auto; }
+.conv-item.idle .ci-dot{ background:var(--dot-idle); }
+.ci-main{ flex:1; min-width:0; }
+.ci-title{ font-size:13px; font-weight:500; color:var(--txt-strong); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
+.ci-rename-input{
+ font-size:13px; font-weight:500; color:var(--txt-strong); width:100%;
+ border:1px solid var(--accent); border-radius:4px; padding:1px 4px;
+ background:var(--field-bg); outline:none;
+}
+.ci-pin{ width:14px; height:14px; color:var(--accent); margin-right:3px; vertical-align:-3px; flex:0 0 auto; display:inline-block; }
+.ci-meta{ font-size:11px; color:var(--muted); margin-top:2px; }
+.ci-more{
+ flex:0 0 auto; width:26px; height:26px; border:none; background:transparent;
+ color:var(--txt-3); border-radius:4px; cursor:pointer;
+ display:flex; align-items:center; justify-content:center;
+ opacity:0; transition:opacity .12s;
+}
+.ci-more svg{ width:20px; height:20px; }
+.conv-item:hover .ci-more, .conv-item.active .ci-more{ opacity:1; }
+.ci-more:hover{ background:var(--hover-dim); color:var(--txt-2); }
+
+.ctx-menu{
+ position:fixed; z-index:200; min-width:124px; background:var(--card);
+ border:1px solid var(--line); border-radius:4px; padding:4px;
+ box-shadow:var(--shadow-pop);
+}
+.ctx-menu[hidden]{ display:none; }
+.ctx-item{
+ display:flex; align-items:center; gap:8px; padding:8px 10px;
+ font-size:12px; border-radius:4px; cursor:pointer; color:var(--txt-strong); user-select:none;
+}
+.ctx-item svg{ width:15px; height:15px; flex:0 0 auto; }
+.ctx-item:hover{ background:var(--line-soft); }
+.ctx-item.danger{ color:var(--danger); }
+.ctx-item.danger:hover{ background:var(--danger-bg); }
+
+/* Plan 卡片:吸附在输入框左上方(半悬浮窄卡) */
+.composer-anchor{
+ position:relative;
+ flex:0 0 auto;
+ padding:0;
+ background:var(--panel);
+}
+.composer-anchor > .composer{
+ padding:0;
+ background:transparent;
+}
+.composer-slot{
+ position:relative;
+ width:100%;
+}
+.plan-card{
+ position:absolute;
+ left:10px;
+ bottom:100%;
+ margin-bottom:0;
+ z-index:13;
+ width:min(600px, 100%);
+ min-width:280px;
+ max-width:100%;
+ box-sizing:border-box;
+ font-size:12px;
+ line-height:1.35;
+ color:var(--txt);
+ pointer-events:auto;
+}
+.plan-card[hidden]{ display:none !important; }
+.plan-card--expanded{
+ padding:10px 12px 8px;
+ background:var(--card);
+ border:1px solid var(--line);
+ border-bottom:none;
+ border-left:3px solid var(--accent);
+ border-radius:var(--radius-sm) var(--radius-sm) 0 0;
+ box-shadow:0 -4px 16px #14192812, 0 -1px 0 var(--line) inset;
+}
+.plan-card--collapsed{
+ width:auto;
+ min-width:0;
+ max-width:min(520px, 70%);
+ margin-bottom:0;
+}
+/* 吸附:卡片底与输入框顶共边,视觉上连成一体 */
+.composer-slot:has(.plan-card:not([hidden])) .composer-inset{
+ border-top-left-radius:0;
+ border-top-right-radius:0;
+}
+.composer-slot:has(.plan-card--collapsed:not([hidden])) .plan-capsule{
+ border-radius:var(--radius-sm) var(--radius-sm) 0 0;
+ border-bottom:1px solid var(--line);
+ box-shadow:0 -4px 12px #14192810;
+}
+.plan-card-head{
+ display:flex;
+ align-items:center;
+ gap:8px;
+ margin-bottom:6px;
+}
+.plan-status-dot{
+ flex:0 0 auto;
+ width:7px; height:7px;
+ border-radius:50%;
+ background:var(--accent);
+}
+.plan-card--complete .plan-status-dot{ background:var(--success); }
+.plan-card--placeholder .plan-status-dot{
+ animation:plan-dot-pulse 1.4s ease-in-out infinite;
+}
+@keyframes plan-dot-pulse{
+ 0%,100%{ opacity:1; transform:scale(1); }
+ 50%{ opacity:.55; transform:scale(.92); }
+}
+.plan-title{
+ flex:1 1 auto;
+ font-weight:600;
+ font-size:12px;
+ color:var(--txt-strong);
+ min-width:0;
+ white-space:nowrap;
+ overflow:hidden;
+ text-overflow:ellipsis;
+}
+.plan-progress{
+ flex:0 0 auto;
+ font-weight:600;
+ font-size:12px;
+ color:var(--accent);
+ font-family:var(--font-num);
+ font-variant-numeric:var(--font-num-variant);
+}
+.plan-card--complete .plan-progress{ color:var(--success); }
+.plan-head-actions{
+ flex:0 0 auto;
+ display:flex;
+ align-items:center;
+ gap:4px;
+}
+.plan-btn{
+ border:0;
+ background:transparent;
+ padding:2px 6px;
+ font-size:11px;
+ font-weight:500;
+ color:var(--accent);
+ cursor:pointer;
+ border-radius:var(--radius-sm);
+ font-family:var(--font-sans);
+}
+.plan-btn:hover{ background:var(--accent-soft); }
+.plan-current{
+ display:flex;
+ gap:4px;
+ align-items:flex-start;
+ margin-bottom:8px;
+ padding-left:15px;
+}
+.plan-current-label{
+ flex:0 0 auto;
+ color:var(--muted);
+ font-size:11px;
+}
+.plan-current-text{
+ flex:1 1 auto;
+ color:var(--accent);
+ font-weight:500;
+ word-break:break-word;
+}
+.plan-card--complete .plan-current-text{ color:var(--success); }
+.plan-wait{
+ color:var(--muted2);
+ padding:0 0 4px 15px;
+ font-size:11px;
+}
+.plan-items{
+ list-style:none;
+ margin:0;
+ padding:0 0 4px 15px;
+}
+.plan-item{
+ display:flex;
+ gap:6px;
+ align-items:flex-start;
+ margin-top:3px;
+}
+.plan-item-mark{
+ flex:0 0 auto;
+ width:1.1em;
+ text-align:center;
+ line-height:1.35;
+}
+.plan-item-text{
+ flex:1 1 auto;
+ word-break:break-word;
+}
+.plan-item--done .plan-item-mark,
+.plan-item--done .plan-item-text{
+ color:var(--muted);
+ opacity:.75;
+}
+.plan-item--done .plan-item-text{ text-decoration:line-through; }
+.plan-item--current .plan-item-mark{ color:var(--accent); }
+.plan-item--current .plan-item-text{ color:var(--accent); font-weight:500; }
+.plan-item--pending .plan-item-mark,
+.plan-item--pending .plan-item-text{ color:var(--muted); }
+.plan-item--error .plan-item-mark,
+.plan-item--error .plan-item-text{ color:var(--danger); }
+.plan-item--warn .plan-item-mark,
+.plan-item--warn .plan-item-text{ color:#c67600; }
+html[data-appearance="dark"] .plan-item--warn .plan-item-mark,
+html[data-appearance="dark"] .plan-item--warn .plan-item-text{ color:#f0a020; }
+.plan-foot{
+ display:flex;
+ justify-content:flex-end;
+ gap:8px;
+ padding-top:4px;
+ border-top:1px solid var(--line-soft);
+ margin-top:4px;
+}
+.plan-more-hint{
+ flex:1 1 auto;
+ align-self:center;
+ font-size:11px;
+ color:var(--muted2);
+ text-align:left;
+}
+.plan-capsule{
+ display:inline-flex;
+ align-items:center;
+ gap:8px;
+ max-width:100%;
+ padding:6px 12px;
+ border:1px solid var(--line);
+ border-left:3px solid var(--accent);
+ border-radius:999px;
+ background:var(--card);
+ cursor:pointer;
+ font-size:12px;
+ font-weight:500;
+ color:var(--txt);
+ box-shadow:var(--shadow);
+ font-family:var(--font-sans);
+}
+.plan-capsule:hover{ border-color:var(--line-strong); background:var(--field-bg); }
+.plan-capsule-text{
+ min-width:0;
+ white-space:nowrap;
+ overflow:hidden;
+ text-overflow:ellipsis;
+ color:var(--txt-2);
+}
+.plan-capsule-text em{
+ font-style:normal;
+ color:var(--accent);
+ font-weight:600;
+}
+.plan-card--complete .plan-capsule{ border-left-color:var(--success); }
+.plan-card--complete .plan-capsule-text em{ color:var(--success); }
+
+/* ─── chat composer: Plan/Auto toggle ─── */
+.chip.on{
+ background:var(--accent);
+ color:var(--on-accent);
+ border-color:var(--accent);
+ font-weight:700;
+}
+/* 组内 .cb-left .chip.sm 的底色/字色优先级(0,3,0)高于 .chip.on(0,2,0),会把开态盖掉,
+ 导致根本看不出开/关;这里用 (0,4,0) 覆盖:开态 = 主题色实底高亮,一眼可辨 */
+.cb-left .chip.sm.on{
+ background:var(--accent);
+ color:var(--on-accent);
+ border-right-color:var(--accent);
+}
+
+/* ─── chat composer: model dropdown menu ─── */
+.ga-menu{
+ position:absolute;
+ background:var(--card);
+ border:1px solid var(--line);
+ border-radius:4px;
+ box-shadow:var(--shadow);
+ padding:4px;
+ min-width:160px;
+ z-index:50;
+}
+.ga-menu-item{
+ display:block;
+ width:100%;
+ text-align:left;
+ background:transparent;
+ border:0;
+ padding:6px 10px;
+ border-radius:4px;
+ font:inherit;
+ color:var(--txt-2);
+ cursor:pointer;
+ font-family:var(--font-mono);
+ font-variant-numeric:var(--font-num-variant);
+}
+.ga-menu-item:hover{ background:var(--hover-2); }
+.ga-menu-item.active{ color:var(--txt-strong); } /* 选中项用强调色而非发灰的 --accent,避免变淡 */
+/* 浅色模式:模型下拉里选中项仅靠文字色区分太弱——加背景 + 加粗,一眼可辨 */
+html[data-appearance="light"] .ga-menu-item.active{
+ background:color-mix(in srgb, var(--accent) 16%, var(--panel));
+ font-weight:700;
+}
+/* 深色模式:同样处理 */
+html[data-appearance="dark"] .ga-menu-item.active{
+ background:color-mix(in srgb, var(--accent) 22%, var(--panel));
+ font-weight:700;
+}
+.composer-menu .ga-menu-item{
+ display:flex; align-items:center; gap:8px;
+}
+.composer-menu .ga-menu-item svg{ width:16px; height:16px; flex:0 0 auto; color:var(--icon); }
+
+/* ─── chat composer: image thumb strip ─── */
+.thumb-strip{
+ display:flex;
+ flex-wrap:wrap;
+ gap:6px;
+ padding:6px 0;
+}
+.thumb{
+ position:relative;
+ width:48px;
+ height:48px;
+ border:1px solid var(--line);
+ border-radius:4px;
+ overflow:hidden;
+ background:var(--field-bg);
+}
+.thumb img{
+ width:100%;
+ height:100%;
+ object-fit:cover;
+ display:block;
+}
+.thumb .x{
+ position:absolute;
+ top:1px;
+ right:1px;
+ width:16px;
+ height:16px;
+ border-radius:4px;
+ border:0;
+ background:var(--card);
+ color:var(--txt-2);
+ font-size:11px;
+ line-height:1;
+ cursor:pointer;
+ display:flex;
+ align-items:center;
+ justify-content:center;
+ padding:0;
+}
+.thumb .x:hover{ color:var(--danger); }
+
+.file-chip{
+ position:relative;
+ display:inline-flex;
+ align-items:center;
+ gap:6px;
+ height:32px;
+ padding:0 28px 0 10px;
+ border:1px solid var(--line);
+ border-radius:4px;
+ background:var(--field-bg);
+ color:var(--txt);
+ font-size:12px;
+ max-width:220px;
+}
+.file-chip svg{
+ width:14px; height:14px;
+ color:var(--txt-2);
+ flex:0 0 auto;
+}
+.file-chip .fc-name{
+ white-space:nowrap;
+ overflow:hidden;
+ text-overflow:ellipsis;
+}
+.file-chip .x{
+ position:absolute;
+ top:50%;
+ right:4px;
+ transform:translateY(-50%);
+ width:18px; height:18px;
+ border-radius:4px;
+ border:0;
+ background:transparent;
+ color:var(--txt-2);
+ font-size:13px;
+ line-height:1;
+ cursor:pointer;
+ display:flex;
+ align-items:center;
+ justify-content:center;
+ padding:0;
+}
+.file-chip .x:hover{ color:var(--danger); }
+
+/* ─── thumb-strip 里的文件 chip:升级成卡片样式(与消息中一致) ─── */
+.thumb-strip .file-chip.pending{
+ height:auto;
+ min-height:52px;
+ padding:8px 32px 8px 8px;
+ gap:9px;
+ max-width:240px;
+ border-radius:4px;
+ background:var(--panel);
+ cursor:pointer;
+ transition:background .15s, border-color .15s, box-shadow .15s;
+}
+.thumb-strip .file-chip.pending:hover{
+ background:var(--hover-2);
+ border-color:var(--line-strong);
+ box-shadow:var(--shadow);
+}
+.thumb-strip .file-chip.pending .fc-icon{
+ width:32px; height:32px;
+ flex:0 0 auto;
+ border-radius:4px;
+ background:var(--accent-bg);
+ color:var(--accent);
+ display:flex; align-items:center; justify-content:center;
+}
+.thumb-strip .file-chip.pending .fc-icon svg{
+ width:16px; height:16px;
+ color:inherit;
+}
+.thumb-strip .file-chip.pending .fc-meta{
+ display:flex; flex-direction:column;
+ min-width:0;
+ line-height:1.35;
+}
+.thumb-strip .file-chip.pending .fc-name{
+ font-size:12px;
+ font-weight:500;
+ color:var(--txt-strong);
+ white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
+}
+.thumb-strip .file-chip.pending .fc-sub{
+ font-size:11px;
+ color:var(--muted);
+ margin-top:1px;
+}
+
+/* ─── chat panel: drag-over overlay ─── */
+.main.dragover{ position:relative; }
+.main.dragover::after{
+ content: attr(data-drop-hint);
+ position:absolute; inset:0;
+ display:flex; align-items:center; justify-content:center;
+ background:var(--accent-bg);
+ color:var(--accent);
+ border:2px dashed var(--accent);
+ border-radius:0;
+ font-size:15px; font-weight:500;
+ pointer-events:none;
+ z-index:50;
+}
+
+/* ─── message bubble: image attachments (Gemini-style: image floats free, text in its own bubble) ─── */
+.user-stack{
+ display:flex; flex-direction:column; align-items:flex-end;
+ gap:6px; max-width:80%;
+}
+.user-imgs{
+ display:flex; flex-wrap:wrap; gap:4px;
+ justify-content:flex-end;
+}
+.user-imgs img{
+ width:120px; height:120px;
+ border-radius:12px;
+ background:var(--field-bg);
+ object-fit:cover;
+ cursor:zoom-in;
+ display:block;
+}
+.user-files{
+ display:flex; flex-direction:column; gap:6px;
+ align-items:flex-end;
+}
+.user-files .file-chip{
+ height:auto;
+ min-height:56px;
+ padding:10px 14px 10px 10px;
+ gap:10px;
+ max-width:280px;
+ border-radius:4px;
+ background:var(--panel);
+ cursor:pointer;
+ transition:background .15s, border-color .15s, box-shadow .15s, transform .15s;
+}
+.user-files .file-chip:hover{
+ background:var(--hover-2);
+ border-color:var(--line-strong);
+ box-shadow:var(--shadow);
+ transform:translateY(-1px);
+}
+.user-files .file-chip .fc-icon{
+ width:36px; height:36px;
+ flex:0 0 auto;
+ border-radius:4px;
+ background:var(--accent-bg);
+ color:var(--accent);
+ display:flex; align-items:center; justify-content:center;
+}
+.user-files .file-chip .fc-icon svg{
+ width:18px; height:18px;
+ color:inherit;
+}
+.user-files .file-chip .fc-meta{
+ display:flex; flex-direction:column;
+ min-width:0;
+ line-height:1.35;
+}
+.user-files .file-chip .fc-name{
+ font-size:13px;
+ font-weight:500;
+ color:var(--txt-strong);
+ white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
+}
+.user-files .file-chip .fc-sub{
+ font-size:11px;
+ color:var(--muted);
+ margin-top:2px;
+}
+.user-stack .bubble{ max-width:100%; }
+
+/* ─── lightbox (image preview) ─── */
+.lightbox{
+ position:fixed; inset:0; z-index:600;
+ display:flex; align-items:center; justify-content:center;
+}
+.lightbox[hidden]{ display:none; }
+.lightbox-backdrop{
+ position:absolute; inset:0;
+ background:var(--overlay);
+}
+.lightbox-img{
+ position:relative;
+ max-width:90vw; max-height:90vh;
+ border-radius:8px;
+ box-shadow:var(--shadow-modal);
+ background:var(--card);
+}
+.lightbox-x{
+ position:absolute; top:16px; right:16px;
+ width:36px; height:36px;
+ border:0; border-radius:4px;
+ background:var(--card); color:var(--txt-2);
+ font-size:20px; line-height:1;
+ cursor:pointer;
+ box-shadow:var(--shadow-pop);
+ display:flex; align-items:center; justify-content:center;
+ padding:0;
+}
+.lightbox-x:hover{ color:var(--danger); }
+.thumb img{ cursor:zoom-in; }
+
+/* ─── 自定义预设:卡上的删除按钮 ─── */
+.fcard{ position:relative; }
+.fcard .fc-x{
+ position:absolute; top:6px; right:6px;
+ width:20px; height:20px;
+ border:0; border-radius:4px;
+ background:var(--card); color:var(--txt-2);
+ font-size:14px; line-height:1;
+ cursor:pointer;
+ box-shadow:var(--shadow);
+ display:none; align-items:center; justify-content:center;
+ padding:0;
+}
+.fcard:hover .fc-x{ display:flex; }
+.fcard .fc-x:hover{ color:var(--danger); }
+
+/* ─── 自定义预设:卡上的编辑按钮(删除按钮左侧)─── */
+.fcard .fc-edit{
+ position:absolute; top:6px; right:30px;
+ width:20px; height:20px;
+ border:0; border-radius:4px;
+ background:var(--card); color:var(--txt-2);
+ cursor:pointer;
+ box-shadow:var(--shadow);
+ display:none; align-items:center; justify-content:center;
+ padding:0;
+}
+.fcard .fc-edit svg{ width:13px; height:13px; }
+.fcard:hover .fc-edit{ display:flex; }
+.fcard .fc-edit:hover{ color:var(--txt-strong); }
+
+/* ─── 自定义预设 modal 表单 ─── */
+.cp-body{
+ padding:18px; display:flex; flex-direction:column; gap:12px;
+ min-height:0;
+}
+.cp-input, .cp-textarea{
+ width:100%; box-sizing:border-box;
+ padding:10px 12px;
+ background:var(--field-bg);
+ border:1px solid var(--line); border-radius:4px;
+ color:var(--txt); font:inherit;
+ outline:none;
+}
+.cp-input:focus, .cp-textarea:focus{ border-color:var(--accent); }
+.cp-textarea{ resize:vertical; min-height:120px; }
+.cp-error{
+ font-size:12px; color:var(--danger);
+}
+.cp-actions{
+ display:flex; justify-content:flex-end; gap:8px;
+ margin-top:4px;
+}
+.cp-btn{
+ padding:8px 16px;
+ border:1px solid var(--line); border-radius:4px;
+ background:var(--card); color:var(--txt);
+ cursor:pointer; font:inherit;
+}
+.cp-btn:hover{ background:var(--hover-2); }
+.cp-btn-primary{
+ background:var(--accent); color:var(--on-accent); border-color:var(--accent);
+}
+.cp-btn-primary:hover{ background:var(--accent); opacity:.9; }
+
+.preset-restore{
+ margin-left:auto; margin-right:8px;
+ padding:5px 12px; font-size:12px;
+ border:1px solid var(--line); border-radius:4px;
+ background:var(--card); color:var(--txt-2);
+ cursor:pointer; font-family:inherit;
+}
+.preset-restore:hover{ background:var(--hover-2); color:var(--accent); border-color:var(--accent); }
+.preset-restore[hidden]{ display:none; }
+
+.modal-title,
+.set-sec-t{
+ font-family:var(--font-sans);
+}
+
+.model-row-name,
+#model-chip .model-name,
+#cdb-model-chip .model-name,
+.model-form input,
+#add-model-form .model-form-mini input[type="number"],
+.chat-font-value,
+.kv{
+ font-family:var(--font-mono);
+ font-variant-numeric:var(--font-num-variant);
+}
+
+/* ═══════════════ Markdown 渲染增强 (from PR) ═══════════════ */
+.fold-pre{ white-space:pre-wrap; word-break:break-word; max-height:300px; overflow:auto; }
+.fold.fold-thinking{ border-color:var(--fold-thinking-border); background:var(--fold-thinking-bg); }
+.fold.fold-tool{ border:none; background:transparent; }
+.fold.fold-tool-live > summary{ opacity:.85; font-style:italic; }
+.fold.fold-result{ border:none; background:transparent; }
+.fold.fold-turn{ border-color:var(--fold-turn-border); background:var(--fold-turn-bg); }
+.fold.fold-turn > summary{ font-weight:500; color:var(--txt-2); white-space:nowrap; overflow:hidden; }
+.fold.fold-retry{ border-color:var(--fold-retry-border); background:var(--fold-retry-bg); border-style:dashed; }
+.fold.fold-retry > summary{ font-size:11px; color:var(--muted2); font-weight:400; }
+.fold.fold-retry pre{ font-size:11px; color:var(--muted); }
+/* ---- 代码块容器 + 复制按钮 ---- */
+.bubble.md pre{ position:relative; background:var(--field-bg); border-radius:8px; padding:12px; overflow-x:auto; }
+.bubble.md pre code{ font-size:.92em; }
+.code-copy-btn{
+ position:absolute; top:6px; right:6px; z-index:1;
+ border:1px solid var(--line); background:var(--card); color:var(--muted);
+ border-radius:6px; padding:4px; font-size:0; cursor:pointer;
+ opacity:0; transition:opacity .15s, background .12s;
+ display:flex; align-items:center; justify-content:center;
+}
+.bubble.md pre:hover .code-copy-btn{ opacity:1; }
+.code-copy-btn:hover{ background:var(--line-soft); color:var(--txt-2); }
+.code-copy-btn svg{ width:14px; height:14px; }
+/* ---- KaTeX 公式 ---- */
+.katex-block{ display:block; text-align:center; margin:12px 0; overflow-x:auto; }
+.katex-inline{ display:inline; }
+.katex-block .katex{ font-size:1.1em; }
+.latex-copy-btn{
+ position:absolute; top:4px; right:4px; z-index:1;
+ background:none; border:none; color:var(--muted); cursor:pointer;
+ font-size:0.82em; padding:2px 4px; border-radius:3px; line-height:1;
+ opacity:0; transition:opacity .15s, color .15s, background .15s;
+}
+.katex-block{ position:relative; }
+.katex-block:hover .latex-copy-btn{ opacity:1; }
+.latex-copy-btn:hover{ color:var(--txt); background:var(--hover-dim); }
+/* ---- 气泡级复制按钮 (F2) ---- */
+.bubble-copy-btn{
+ display:inline-flex; align-items:center; flex-basis:100%;
+ margin-top:2px; padding:2px 4px; border:none; background:none;
+ color:var(--muted); cursor:pointer; opacity:0; transition:opacity .15s;
+}
+.msg:hover .bubble-copy-btn{ opacity:1; }
+.msg.user .bubble-copy-btn{ justify-content:flex-end; }
+.msg.assistant .bubble-copy-btn{ justify-content:flex-start; }
+.bubble-copy-btn:hover{ color:var(--txt-2); }
+.bubble-copy-btn svg{ width:14px; height:14px; }
+/* ---- highlight.js 适配 ---- */
+.hljs{ background:transparent !important; padding:0 !important; }
+html[data-appearance="dark"] .bubble.md{
+ --md-link:color-mix(in srgb, var(--accent) 42%, var(--txt-strong));
+}
+html[data-appearance="dark"] .bubble.md .katex{ color:var(--txt); }
+html[data-appearance="dark"] .bubble.md a{ color:var(--md-link); }
+html[data-appearance="dark"] .bubble.md :not(pre) > code{
+ background:var(--line-soft); border:1px solid var(--line); color:var(--txt);
+}
+html[data-appearance="dark"] .bubble.md pre{
+ background:var(--line-soft); border-color:var(--line-strong);
+}
+html[data-appearance="dark"] .bubble.md pre code{ color:var(--txt-2); }
+/* ---- 打字机光标 ---- */
+.cursor-blink::after{
+ content:'▍'; animation:blink 1s step-end infinite; color:var(--accent);
+}
+@keyframes blink{ 50%{ opacity:0; } }
+/* —— 最后一轮 summary 弱化显示 —— */
+.turn-summary{
+ display:block; font-style:italic; color:var(--muted);
+ font-size:12px; margin-bottom:6px;
+ white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
+}
+/* 历史轮 头部的副标题(来自模型 抽取) */
+.fold-turn > summary .turn-head-sum{
+ font-style:italic; color:var(--muted); font-weight:normal; margin-left:4px;
+ min-width:0; overflow:hidden; text-overflow:ellipsis;
+}
+
+/* —— ask_user 显眼提醒 —— */
+.ask-user-notice{
+ margin:8px 0 4px;
+ border:1px solid var(--line);
+ border-left:3px solid var(--accent);
+ border-radius:0;
+ background:color-mix(in srgb, var(--accent-bg) 55%, var(--card));
+ padding:10px 12px;
+}
+.ask-user-notice.is-active{
+ border-color:color-mix(in srgb, var(--accent) 28%, var(--line));
+ background:color-mix(in srgb, var(--accent-bg) 72%, var(--card));
+}
+.ask-user-notice.is-answered{
+ opacity:.78;
+ background:var(--card);
+ border-color:var(--line);
+ border-left-color:var(--muted2);
+}
+.ask-user-banner{
+ display:flex; align-items:baseline; flex-wrap:wrap;
+ gap:4px 6px;
+ margin-bottom:8px;
+ line-height:1.35;
+}
+.ask-user-banner-text{
+ font-size:13px; font-weight:600;
+ color:var(--txt-strong);
+}
+.ask-user-notice.is-active .ask-user-banner-text{
+ color:var(--accent);
+}
+.ask-user-notice.is-answered .ask-user-banner-text{
+ color:var(--muted);
+}
+.ask-user-banner-sep{
+ color:var(--muted2);
+ font-size:12px; user-select:none;
+}
+.ask-user-banner-hint{
+ font-size:12px; font-weight:400; color:var(--muted);
+}
+.ask-user-body{
+ font-size:14px; line-height:1.55; color:var(--txt);
+ font-weight:500; margin:0;
+}
+.ask-user-body strong{ color:var(--txt-strong); }
+.ask-user-body code{
+ background:rgba(127,127,127,.14);
+ padding:1px 5px; border-radius:4px; font-size:.92em;
+}
+/* 覆盖 .bubble.md ul/ol/p,题干内列表与正文左缘对齐 */
+.bubble.md .ask-user-notice .ask-user-body p{
+ margin:0 0 4px;
+}
+.bubble.md .ask-user-notice .ask-user-body p:last-child{
+ margin-bottom:0;
+}
+.bubble.md .ask-user-notice .ask-user-body p.ask-option-line{
+ padding-left:1.25em;
+ margin:0;
+ font-weight:400;
+}
+.bubble.md .ask-user-notice .ask-user-body span.ask-option-line{
+ display:block;
+ padding-left:1.25em;
+ margin:0;
+ font-weight:400;
+ line-height:1.55;
+}
+.bubble.md .ask-user-notice .ask-user-body ul,
+.bubble.md .ask-user-notice .ask-user-body ol{
+ margin:4px 0 0; padding:0; list-style:none;
+}
+.bubble.md .ask-user-notice .ask-user-body ul > li,
+.bubble.md .ask-user-notice .ask-user-body ol > li{
+ margin:0; padding:2px 0; padding-left:0;
+}
+.bubble.md .ask-user-notice .ask-user-body ol{
+ counter-reset:ask-ol;
+}
+.bubble.md .ask-user-notice .ask-user-body ol > li{
+ counter-increment:ask-ol;
+ display:block;
+}
+.bubble.md .ask-user-notice .ask-user-body ol > li::before{
+ content:counter(ask-ol) ". ";
+ font-weight:500;
+ color:color-mix(in srgb, var(--txt) 38%, var(--muted));
+}
+.bubble.md .ask-user-notice .ask-user-body ol > li > p{
+ display:inline;
+ margin:0;
+}
+.ask-user-notice.is-active .ask-user-body ol > li::before{
+ color:color-mix(in srgb, var(--accent) 72%, var(--txt));
+}
+.ask-candidates{
+ display:flex; flex-direction:column; gap:0;
+}
+.ask-candidates li{
+ display:block;
+ font-size:14px; line-height:1.55; color:var(--txt);
+}
+.ask-candidate-key{
+ font-weight:500;
+ color:color-mix(in srgb, var(--txt) 38%, var(--muted));
+}
+.ask-user-notice.is-active .ask-candidate-key{
+ color:color-mix(in srgb, var(--accent) 72%, var(--txt));
+}
+.ask-user-notice.is-answered .ask-candidate-key{
+ color:var(--muted2);
+}
+.ask-candidate-label{
+ white-space:pre-wrap; word-break:break-word;
+}
+
+/* —— 消息计时 badge —— */
+.task-elapsed{
+ display:block; font-size:11px; color:var(--muted);
+ line-height:1; min-height:18px;
+ padding:0 6px; margin-bottom:2px;
+ border-radius:var(--radius-sm);
+}
+
+/* ---- Conductor(仅保留与主聊天不同的部分,布局见 .page--chat-ui) ---- */
+#collab-welcome[hidden], .collab-msgs .msgs[hidden],
+.collab-rail[hidden], .collab-prog-stats[hidden],
+.collab-rail-run[hidden], .collab-rail-done[hidden], .collab-rail-issue[hidden]{ display:none !important; }
+/* 顶栏中央:状态栏 + 离线时的刷新图标按钮(取代原本的 banner+重试) */
+.page > .page-top .page-top-center{
+ grid-column:2; display:flex; align-items:center; justify-content:center; gap:6px;
+ min-width:0;
+}
+/* 收尾按钮:跟 .preset-restore 同款 — 白底 + 中性边框, hover 转 accent 色 */
+.collab-retry-btn{
+ width:24px; height:24px;
+ border:1px solid var(--line); background:var(--card); border-radius:4px;
+ opacity:1; color:var(--txt-2);
+}
+.collab-retry-btn:hover{ background:var(--hover-2); color:var(--accent); border-color:var(--accent); opacity:1; }
+.collab-retry-btn svg{ width:14px; height:14px; }
+.collab-retry-btn[hidden]{ display:none !important; }
+.page.page--chat-ui[data-page="collab"] .msg-area.collab-msgs{ overflow:hidden; padding:0; }
+.page.page--chat-ui[data-page="collab"] .collab-scroll{
+ flex:1 1 0; min-height:0; overflow:auto;
+ display:flex; flex-direction:column; align-items:center; justify-content:center; gap:14px;
+ padding:16px;
+}
+.page.page--chat-ui[data-page="collab"] .collab-msgs.has-msgs .collab-scroll{
+ align-items:stretch; justify-content:flex-start;
+}
+.page.page--chat-ui[data-page="collab"] .collab-overlay{
+ position:absolute; inset:0; z-index:10; pointer-events:none;
+}
+.page.page--chat-ui[data-page="collab"] .collab-overlay > .collab-rail,
+.page.page--chat-ui[data-page="collab"] .collab-overlay > .collab-prog-panel{ pointer-events:auto; }
+.page.page--chat-ui[data-page="collab"].dragover .collab-msgs::after{
+ content:attr(data-drop-hint); position:absolute; inset:8px; z-index:20; pointer-events:none;
+ display:flex; align-items:center; justify-content:center; border-radius:var(--radius);
+ border:2px dashed var(--accent); background:color-mix(in srgb, var(--accent) 8%, var(--panel));
+ font-size:13px; color:var(--accent); font-weight:500;
+}
+.collab-rail{
+ position:absolute; left:0; top:50%; z-index:14;
+ transform:translateY(-50%);
+ transition:left .22s ease-out;
+}
+.page.page--chat-ui[data-page="collab"].collab-prog-open .collab-rail{ left:232px; }
+.collab-rail-handle{
+ display:flex; flex-direction:column; align-items:center; justify-content:center; gap:5px;
+ width:22px; padding:10px 3px; cursor:pointer;
+ border:1px solid var(--line); border-left:none;
+ border-radius:0 var(--radius-sm) var(--radius-sm) 0;
+ background:var(--card); color:var(--muted);
+ box-shadow:2px 0 8px rgba(0,0,0,.06);
+ transition:border-color .2s, color .2s, box-shadow .2s;
+}
+.collab-rail-handle:hover{ border-color:var(--line-strong); color:var(--txt-2); box-shadow:2px 0 12px rgba(0,0,0,.1); }
+.collab-rail-grip{
+ width:3px; height:18px; border-radius:2px;
+ background:linear-gradient(180deg, transparent, var(--line-strong) 20%, var(--line-strong) 80%, transparent);
+ opacity:.55;
+}
+.collab-rail-chev{ width:12px; height:12px; flex:0 0 auto; transition:transform .22s ease-out; }
+.page.page--chat-ui[data-page="collab"].collab-prog-open .collab-rail-chev{ transform:rotate(180deg); }
+.collab-rail-badge{
+ display:flex; flex-direction:column; align-items:center; gap:2px;
+ font-size:10px; font-weight:500; line-height:1;
+ font-family:var(--font-num); font-variant-numeric:var(--font-num-variant);
+}
+.collab-rail-badge.pulse{ animation:collab-rail-pulse .55s ease-out; }
+@keyframes collab-rail-pulse{
+ 0%{ transform:scale(1); }
+ 35%{ transform:scale(1.12); }
+ 100%{ transform:scale(1); }
+}
+/* 把手运行数:转圈与呼吸上下分开,转圈内不叠绿点 */
+.collab-rail-spin{
+ display:block;
+ width:12px; height:12px;
+ margin:0 auto;
+ border-radius:50%;
+ background:transparent;
+ border:2px solid color-mix(in srgb, var(--ok) 22%, transparent);
+ border-top-color:var(--ok);
+ animation:collab-rail-spin .8s linear infinite;
+}
+@keyframes collab-rail-spin{ to{ transform:rotate(360deg); } }
+.collab-rail-dot{ width:6px; height:6px; border-radius:50%; background:var(--ok); flex:0 0 auto; }
+.collab-rail-run .n{ color:var(--ok); }
+.collab-rail-done .n{ color:var(--muted); }
+.collab-rail-done .collab-rail-dot{ background:var(--muted2); }
+.collab-rail-issue .n{ color:var(--danger); }
+.collab-rail-dot--danger{ background:var(--danger-dot); }
+.collab-prog-panel{
+ position:absolute; top:0; left:0; bottom:0; width:232px; z-index:12;
+ display:flex; flex-direction:column; overflow:hidden;
+ background:var(--panel); border-right:1px solid var(--line);
+ box-shadow:4px 0 16px rgba(0,0,0,.06);
+ transform:translateX(-100%); transition:transform .22s ease-out;
+ pointer-events:none;
+}
+.page.page--chat-ui[data-page="collab"].collab-prog-open .collab-prog-panel{
+ transform:translateX(0); pointer-events:auto;
+}
+.collab-prog-panel-head{
+ flex:0 0 auto; display:flex; align-items:center; justify-content:space-between; gap:6px;
+ padding:8px 8px 4px 12px;
+ font-size:13px; font-weight:600; color:var(--txt-strong);
+}
+.collab-prog-panel-head .ic-btn{ width:26px; height:26px; }
+.collab-prog-panel-head .ic-btn svg{ width:14px; height:14px; }
+.collab-prog-stats{
+ flex:0 0 auto; display:flex; flex-wrap:wrap; gap:5px;
+ padding:2px 10px 5px;
+}
+.collab-stat{
+ display:inline-flex; align-items:center; gap:4px;
+ font-size:10px; font-weight:500; padding:2px 7px; border-radius:4px;
+ line-height:1.35;
+}
+.collab-stat--running{ color:var(--ok); background:color-mix(in srgb, var(--ok) 10%, var(--panel)); }
+.collab-stat--done{ color:var(--muted); background:color-mix(in srgb, var(--muted) 10%, var(--panel)); }
+.collab-prog-stats .ga-status-breathe--sm{ --breathe-box:8px; }
+.collab-prog-stats .collab-rail-dot{ width:5px; height:5px; }
+.collab-stat--done .collab-rail-dot{ background:var(--muted2); }
+.collab-stat .n{ font-family:var(--font-num); font-variant-numeric:var(--font-num-variant); font-weight:500; font-size:10px; }
+.collab-progress-empty{
+ flex:0 0 auto; font-size:12px; color:var(--muted); line-height:1.35;
+ padding:4px 10px 2px;
+}
+#collab-workers{
+ flex:1 1 0; min-height:0; overflow-y:auto; padding:6px 10px 12px;
+}
+.collab-card{
+ position:relative;
+ border:1px solid var(--line); border-radius:6px; background:var(--card);
+ padding:8px 10px 8px 12px; margin-bottom:6px; border-left:3px solid var(--collab-st, var(--line));
+ box-shadow:0 1px 4px rgba(0,0,0,.04);
+ transition:border-color .3s, box-shadow .3s, transform .2s;
+}
+.collab-card.pulse{ animation:collab-rail-pulse .55s ease-out; }
+.collab-card:hover{ transform:translateY(-1px); box-shadow:0 4px 14px rgba(0,0,0,.07); }
+.collab-card--running{ --collab-st:var(--ok); }
+.collab-card--reported{ --collab-st:var(--muted); }
+.collab-card--paused{ --collab-st:var(--amber); }
+.collab-card--failed{ --collab-st:var(--danger); }
+.collab-card--terminated{ --collab-st:var(--line-strong); }
+.collab-card-st{ display:flex; align-items:center; gap:5px; font-size:11px; color:var(--muted); margin-bottom:4px; flex-wrap:wrap; }
+.collab-dot{ width:7px; height:7px; border-radius:50%; background:var(--collab-st); flex:0 0 auto; }
+.collab-st-ic{ display:inline-flex; align-items:center; justify-content:center; width:14px; height:14px; font-size:10px; font-weight:500; flex:0 0 auto; }
+.collab-st-ic--ok{ color:var(--ok); }
+.collab-st-ic--done{ color:var(--muted); }
+.collab-st-ic--warn{ color:var(--danger); }
+.collab-st-ic--pause,.collab-st-ic--off{ color:var(--muted); }
+.collab-card-time{ margin-left:auto; font-size:10px; color:var(--muted); opacity:.85; }
+.collab-card-title{ font-size:13px; font-weight:500; color:var(--txt-strong); margin-bottom:3px; line-height:1.35; }
+.collab-card-sum{ font-size:11px; color:var(--muted); line-height:1.35; cursor:pointer; }
+.collab-drawer-wrap{ position:fixed; inset:0; z-index:550; display:flex; }
+.collab-drawer-backdrop{ position:absolute; inset:0; background:var(--overlay); }
+.collab-drawer{
+ position:absolute; right:0; top:0; bottom:0; width:min(420px,85vw);
+ background:var(--panel); border-left:1px solid var(--line);
+ box-shadow:var(--shadow-modal); display:flex; flex-direction:column;
+ animation:collabDrawerIn .2s ease-out;
+}
+@keyframes collabDrawerIn{ from{ transform:translateX(100%); } to{ transform:translateX(0); } }
+.collab-drawer-head{
+ display:flex; align-items:center; justify-content:space-between;
+ padding:14px 16px; border-bottom:1px solid var(--line-soft);
+}
+.collab-drawer-title{ font-size:14px; font-weight:600; color:var(--txt-strong); }
+.collab-drawer-body{
+ flex:1; overflow-y:auto; padding:16px; font-size:13px; line-height:1.55; color:var(--txt);
+}
+.collab-drawer-body p,.collab-drawer-body ul,.collab-drawer-body ol,.collab-drawer-body pre{ margin:0 0 .5em; }
+.collab-drawer-body code{ font-size:12px; background:var(--field-bg); padding:1px 4px; border-radius:4px; }
+.collab-drawer-body pre code{ display:block; padding:8px; border-radius:6px; white-space:pre-wrap; }
+.collab-msg-enter{ animation:collab-msg-in .35s ease-out both; }
+.bubble.sys .collab-wait-dots{
+ display:inline-flex; align-items:center; justify-content:center; gap:5px;
+ vertical-align:middle; padding:2px 0;
+}
+.collab-wait-dots i{
+ display:block; width:5px; height:5px; border-radius:50%; background:var(--muted);
+ animation:collab-wait-dot 1.2s ease-in-out infinite; font-style:normal;
+}
+.collab-wait-dots i:nth-child(1){ animation-delay:0s; }
+.collab-wait-dots i:nth-child(2){ animation-delay:.2s; }
+.collab-wait-dots i:nth-child(3){ animation-delay:.4s; }
+@keyframes collab-wait-dot{
+ 0%,80%,100%{ opacity:.35; transform:translateY(0); }
+ 40%{ opacity:1; transform:translateY(-3px); }
+}
+@keyframes collab-msg-in{
+ from{ opacity:0; transform:translateY(8px); }
+ to{ opacity:1; transform:translateY(0); }
+}
+.collab-guide{ width:100%; max-width:var(--chat-col-w); margin:0 auto; }
+.collab-guide .feature-head{ text-align:center; margin-bottom:22px; }
+.collab-guide .page-title{ font-size:20px; line-height:1.35; margin-bottom:2px; }
+.collab-guide .page-sub{ font-size:13px; line-height:1.55; margin-top:10px; margin-bottom:0; }
+.collab-guide-steps{
+ display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:48px 56px;
+ margin:0 0 8px; align-items:start;
+}
+.collab-guide-step{
+ display:flex; flex-direction:column; align-items:center; gap:22px;
+ text-align:center; min-height:100%;
+}
+.collab-guide-ic{
+ flex:0 0 auto; width:52px; height:52px; border-radius:14px;
+ display:flex; align-items:center; justify-content:center;
+ background:color-mix(in srgb, var(--accent) 10%, var(--panel));
+ color:var(--accent);
+ border:1px solid color-mix(in srgb, var(--accent) 16%, var(--line));
+}
+.collab-guide-ic svg{ width:24px; height:24px; }
+.collab-guide-txt{ display:flex; flex-direction:column; gap:12px; width:100%; }
+.collab-guide-label{ font-size:14px; font-weight:500; color:var(--txt-strong); line-height:1.35; }
+.collab-guide-desc{ font-size:12px; font-weight:400; font-style:italic; color:var(--muted); line-height:1.55; }
+.collab-nav-badge{
+ margin-left:auto; min-width:18px; height:18px; padding:0 5px; border-radius:999px;
+ background:var(--ok); color:#fff; font-size:10px; font-weight:500; line-height:1; text-align:center;
+}
+.page.page--chat-ui[data-page="collab"] > .chat-shell > .chat-panel > .collab-quick{
+ flex:0 0 auto;
+ display:flex; flex-wrap:wrap; gap:8px;
+}
+@media (max-width:900px){
+ .collab-guide-steps{ grid-template-columns:repeat(2, minmax(0, 1fr)); gap:40px 32px; }
+ .collab-guide-step{ gap:20px; }
+ .page.page--chat-ui[data-page="collab"].collab-prog-open .collab-rail{ left:min(232px, 72vw); }
+ .collab-prog-panel{ width:min(232px, 72vw); }
+}
diff --git a/frontends/desktop/static/vendor/marked.min.js b/frontends/desktop/static/vendor/marked.min.js
new file mode 100644
index 000000000..4052d1b49
--- /dev/null
+++ b/frontends/desktop/static/vendor/marked.min.js
@@ -0,0 +1,6 @@
+/**
+ * marked v15.0.7 - a markdown parser
+ * Copyright (c) 2011-2025, Christopher Jeffrey. (MIT Licensed)
+ * https://github.com/markedjs/marked
+ */
+!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).marked={})}(this,(function(e){"use strict";function t(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}function n(t){e.defaults=t}e.defaults={async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null};const s={exec:()=>null};function r(e,t=""){let n="string"==typeof e?e:e.source;const s={replace:(e,t)=>{let r="string"==typeof t?t:t.source;return r=r.replace(i.caret,"$1"),n=n.replace(e,r),s},getRegex:()=>new RegExp(n,t)};return s}const i={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^,endAngleBracket:/>$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:e=>new RegExp(`^( {0,3}${e})((?:[\t ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`),hrRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}#`),htmlBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}<(?:[a-z].*>|!--)`,"i")},l=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,o=/(?:[*+-]|\d{1,9}[.)])/,a=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,c=r(a).replace(/bull/g,o).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),h=r(a).replace(/bull/g,o).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),p=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,u=/(?!\s*\])(?:\\.|[^\[\]\\])+/,g=r(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",u).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),k=r(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,o).getRegex(),d="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",f=/|$))/,x=r("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:\\1>[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|?(tag)(?: +|\\n|/?>)[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)|(?!script|pre|style|textarea)[a-z][\\w-]*\\s*>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$))","i").replace("comment",f).replace("tag",d).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),b=r(p).replace("hr",l).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",d).getRegex(),w={blockquote:r(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",b).getRegex(),code:/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,def:g,fences:/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,heading:/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,hr:l,html:x,lheading:c,list:k,newline:/^(?:[ \t]*(?:\n|$))+/,paragraph:b,table:s,text:/^[^\n]+/},m=r("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",l).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3}\t)[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",d).getRegex(),y={...w,lheading:h,table:m,paragraph:r(p).replace("hr",l).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",m).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",d).getRegex()},$={...w,html:r("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+?\\1> *(?:\\n{2,}|\\s*$)| \\s]*)*?/?> *(?:\\n{2,}|\\s*$))").replace("comment",f).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *([^\s>]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:s,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:r(p).replace("hr",l).replace("heading"," *#{1,6} *[^\n]").replace("lheading",c).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},R=/^( {2,}|\\)\n(?!\s*$)/,S=/[\p{P}\p{S}]/u,T=/[\s\p{P}\p{S}]/u,z=/[^\s\p{P}\p{S}]/u,A=r(/^((?![*_])punctSpace)/,"u").replace(/punctSpace/g,T).getRegex(),_=/(?!~)[\p{P}\p{S}]/u,P=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,I=r(P,"u").replace(/punct/g,S).getRegex(),L=r(P,"u").replace(/punct/g,_).getRegex(),B="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",C=r(B,"gu").replace(/notPunctSpace/g,z).replace(/punctSpace/g,T).replace(/punct/g,S).getRegex(),q=r(B,"gu").replace(/notPunctSpace/g,/(?:[^\s\p{P}\p{S}]|~)/u).replace(/punctSpace/g,/(?!~)[\s\p{P}\p{S}]/u).replace(/punct/g,_).getRegex(),E=r("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,z).replace(/punctSpace/g,T).replace(/punct/g,S).getRegex(),Z=r(/\\(punct)/,"gu").replace(/punct/g,S).getRegex(),v=r(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),D=r(f).replace("(?:--\x3e|$)","--\x3e").getRegex(),M=r("^comment|^[a-zA-Z][\\w:-]*\\s*>|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",D).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),O=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,Q=r(/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/).replace("label",O).replace("href",/<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),j=r(/^!?\[(label)\]\[(ref)\]/).replace("label",O).replace("ref",u).getRegex(),N=r(/^!?\[(ref)\](?:\[\])?/).replace("ref",u).getRegex(),G={_backpedal:s,anyPunctuation:Z,autolink:v,blockSkip:/\[[^[\]]*?\]\((?:\\.|[^\\\(\)]|\((?:\\.|[^\\\(\)])*\))*\)|`[^`]*?`|<[^<>]*?>/g,br:R,code:/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,del:s,emStrongLDelim:I,emStrongRDelimAst:C,emStrongRDelimUnd:E,escape:/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,link:Q,nolink:N,punctuation:A,reflink:j,reflinkSearch:r("reflink|nolink(?!\\()","g").replace("reflink",j).replace("nolink",N).getRegex(),tag:M,text:/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},V=e=>K[e];function W(e,t){if(t){if(i.escapeTest.test(e))return e.replace(i.escapeReplace,V)}else if(i.escapeTestNoEncode.test(e))return e.replace(i.escapeReplaceNoEncode,V);return e}function Y(e){try{e=encodeURI(e).replace(i.percentDecode,"%")}catch{return null}return e}function ee(e,t){const n=e.replace(i.findPipe,((e,t,n)=>{let s=!1,r=t;for(;--r>=0&&"\\"===n[r];)s=!s;return s?"|":" |"})).split(i.splitPipe);let s=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),t)if(n.length>t)n.splice(t);else for(;n.length0)return{type:"space",raw:t[0]}}code(e){const t=this.rules.block.code.exec(e);if(t){const e=t[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?e:te(e,"\n")}}}fences(e){const t=this.rules.block.fences.exec(e);if(t){const e=t[0],n=function(e,t,n){const s=e.match(n.other.indentCodeCompensation);if(null===s)return t;const r=s[1];return t.split("\n").map((e=>{const t=e.match(n.other.beginningSpace);if(null===t)return e;const[s]=t;return s.length>=r.length?e.slice(r.length):e})).join("\n")}(e,t[3]||"",this.rules);return{type:"code",raw:e,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:n}}}heading(e){const t=this.rules.block.heading.exec(e);if(t){let e=t[2].trim();if(this.rules.other.endingHash.test(e)){const t=te(e,"#");this.options.pedantic?e=t.trim():t&&!this.rules.other.endingSpaceChar.test(t)||(e=t.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:e,tokens:this.lexer.inline(e)}}}hr(e){const t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:te(t[0],"\n")}}blockquote(e){const t=this.rules.block.blockquote.exec(e);if(t){let e=te(t[0],"\n").split("\n"),n="",s="";const r=[];for(;e.length>0;){let t=!1;const i=[];let l;for(l=0;l1,r={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");const i=this.rules.other.listItemRegex(n);let l=!1;for(;e;){let n=!1,s="",o="";if(!(t=i.exec(e)))break;if(this.rules.block.hr.test(e))break;s=t[0],e=e.substring(s.length);let a=t[2].split("\n",1)[0].replace(this.rules.other.listReplaceTabs,(e=>" ".repeat(3*e.length))),c=e.split("\n",1)[0],h=!a.trim(),p=0;if(this.options.pedantic?(p=2,o=a.trimStart()):h?p=t[1].length+1:(p=t[2].search(this.rules.other.nonSpaceChar),p=p>4?1:p,o=a.slice(p),p+=t[1].length),h&&this.rules.other.blankLine.test(c)&&(s+=c+"\n",e=e.substring(c.length+1),n=!0),!n){const t=this.rules.other.nextBulletRegex(p),n=this.rules.other.hrRegex(p),r=this.rules.other.fencesBeginRegex(p),i=this.rules.other.headingBeginRegex(p),l=this.rules.other.htmlBeginRegex(p);for(;e;){const u=e.split("\n",1)[0];let g;if(c=u,this.options.pedantic?(c=c.replace(this.rules.other.listReplaceNesting," "),g=c):g=c.replace(this.rules.other.tabCharGlobal," "),r.test(c))break;if(i.test(c))break;if(l.test(c))break;if(t.test(c))break;if(n.test(c))break;if(g.search(this.rules.other.nonSpaceChar)>=p||!c.trim())o+="\n"+g.slice(p);else{if(h)break;if(a.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4)break;if(r.test(a))break;if(i.test(a))break;if(n.test(a))break;o+="\n"+c}h||c.trim()||(h=!0),s+=u+"\n",e=e.substring(u.length+1),a=g.slice(p)}}r.loose||(l?r.loose=!0:this.rules.other.doubleBlankLine.test(s)&&(l=!0));let u,g=null;this.options.gfm&&(g=this.rules.other.listIsTask.exec(o),g&&(u="[ ] "!==g[0],o=o.replace(this.rules.other.listReplaceTask,""))),r.items.push({type:"list_item",raw:s,task:!!g,checked:u,loose:!1,text:o,tokens:[]}),r.raw+=s}const o=r.items.at(-1);if(!o)return;o.raw=o.raw.trimEnd(),o.text=o.text.trimEnd(),r.raw=r.raw.trimEnd();for(let e=0;e"space"===e.type)),n=t.length>0&&t.some((e=>this.rules.other.anyLine.test(e.raw)));r.loose=n}if(r.loose)for(let e=0;e({text:e,tokens:this.lexer.inline(e),header:!1,align:i.align[t]}))));return i}}lheading(e){const t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:"="===t[2].charAt(0)?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){const t=this.rules.block.paragraph.exec(e);if(t){const e="\n"===t[1].charAt(t[1].length-1)?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:e,tokens:this.lexer.inline(e)}}}text(e){const t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){const t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){const t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){const t=this.rules.inline.link.exec(e);if(t){const e=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(e)){if(!this.rules.other.endAngleBracket.test(e))return;const t=te(e.slice(0,-1),"\\");if((e.length-t.length)%2==0)return}else{const e=function(e,t){if(-1===e.indexOf(t[1]))return-1;let n=0;for(let s=0;s-1){const n=(0===t[0].indexOf("!")?5:4)+t[1].length+e;t[2]=t[2].substring(0,e),t[0]=t[0].substring(0,n).trim(),t[3]=""}}let n=t[2],s="";if(this.options.pedantic){const e=this.rules.other.pedanticHrefTitle.exec(n);e&&(n=e[1],s=e[3])}else s=t[3]?t[3].slice(1,-1):"";return n=n.trim(),this.rules.other.startAngleBracket.test(n)&&(n=this.options.pedantic&&!this.rules.other.endAngleBracket.test(e)?n.slice(1):n.slice(1,-1)),ne(t,{href:n?n.replace(this.rules.inline.anyPunctuation,"$1"):n,title:s?s.replace(this.rules.inline.anyPunctuation,"$1"):s},t[0],this.lexer,this.rules)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){const e=t[(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," ").toLowerCase()];if(!e){const e=n[0].charAt(0);return{type:"text",raw:e,text:e}}return ne(n,e,n[0],this.lexer,this.rules)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s)return;if(s[3]&&n.match(this.rules.other.unicodeAlphaNumeric))return;if(!(s[1]||s[2]||"")||!n||this.rules.inline.punctuation.exec(n)){const n=[...s[0]].length-1;let r,i,l=n,o=0;const a="*"===s[0][0]?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(a.lastIndex=0,t=t.slice(-1*e.length+n);null!=(s=a.exec(t));){if(r=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!r)continue;if(i=[...r].length,s[3]||s[4]){l+=i;continue}if((s[5]||s[6])&&n%3&&!((n+i)%3)){o+=i;continue}if(l-=i,l>0)continue;i=Math.min(i,i+l+o);const t=[...s[0]][0].length,a=e.slice(0,n+s.index+t+i);if(Math.min(n,i)%2){const e=a.slice(1,-1);return{type:"em",raw:a,text:e,tokens:this.lexer.inlineTokens(e)}}const c=a.slice(2,-2);return{type:"strong",raw:a,text:c,tokens:this.lexer.inlineTokens(c)}}}}codespan(e){const t=this.rules.inline.code.exec(e);if(t){let e=t[2].replace(this.rules.other.newLineCharGlobal," ");const n=this.rules.other.nonSpaceChar.test(e),s=this.rules.other.startingSpaceChar.test(e)&&this.rules.other.endingSpaceChar.test(e);return n&&s&&(e=e.substring(1,e.length-1)),{type:"codespan",raw:t[0],text:e}}}br(e){const t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){const t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){const t=this.rules.inline.autolink.exec(e);if(t){let e,n;return"@"===t[2]?(e=t[1],n="mailto:"+e):(e=t[1],n=e),{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let e,n;if("@"===t[2])e=t[0],n="mailto:"+e;else{let s;do{s=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??""}while(s!==t[0]);e=t[0],n="www."===t[1]?"http://"+t[0]:t[0]}return{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}inlineText(e){const t=this.rules.inline.text.exec(e);if(t){const e=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:e}}}}class re{tokens;options;state;tokenizer;inlineQueue;constructor(t){this.tokens=[],this.tokens.links=Object.create(null),this.options=t||e.defaults,this.options.tokenizer=this.options.tokenizer||new se,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};const n={other:i,block:U.normal,inline:J.normal};this.options.pedantic?(n.block=U.pedantic,n.inline=J.pedantic):this.options.gfm&&(n.block=U.gfm,this.options.breaks?n.inline=J.breaks:n.inline=J.gfm),this.tokenizer.rules=n}static get rules(){return{block:U,inline:J}}static lex(e,t){return new re(t).lex(e)}static lexInline(e,t){return new re(t).inlineTokens(e)}lex(e){e=e.replace(i.carriageReturn,"\n"),this.blockTokens(e,this.tokens);for(let e=0;e!!(s=n.call({lexer:this},e,t))&&(e=e.substring(s.raw.length),t.push(s),!0))))continue;if(s=this.tokenizer.space(e)){e=e.substring(s.raw.length);const n=t.at(-1);1===s.raw.length&&void 0!==n?n.raw+="\n":t.push(s);continue}if(s=this.tokenizer.code(e)){e=e.substring(s.raw.length);const n=t.at(-1);"paragraph"===n?.type||"text"===n?.type?(n.raw+="\n"+s.raw,n.text+="\n"+s.text,this.inlineQueue.at(-1).src=n.text):t.push(s);continue}if(s=this.tokenizer.fences(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.heading(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.hr(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.blockquote(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.list(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.html(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.def(e)){e=e.substring(s.raw.length);const n=t.at(-1);"paragraph"===n?.type||"text"===n?.type?(n.raw+="\n"+s.raw,n.text+="\n"+s.raw,this.inlineQueue.at(-1).src=n.text):this.tokens.links[s.tag]||(this.tokens.links[s.tag]={href:s.href,title:s.title});continue}if(s=this.tokenizer.table(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.lheading(e)){e=e.substring(s.raw.length),t.push(s);continue}let r=e;if(this.options.extensions?.startBlock){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startBlock.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}if(this.state.top&&(s=this.tokenizer.paragraph(r))){const i=t.at(-1);n&&"paragraph"===i?.type?(i.raw+="\n"+s.raw,i.text+="\n"+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=i.text):t.push(s),n=r.length!==e.length,e=e.substring(s.raw.length)}else if(s=this.tokenizer.text(e)){e=e.substring(s.raw.length);const n=t.at(-1);"text"===n?.type?(n.raw+="\n"+s.raw,n.text+="\n"+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=n.text):t.push(s)}else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n=e,s=null;if(this.tokens.links){const e=Object.keys(this.tokens.links);if(e.length>0)for(;null!=(s=this.tokenizer.rules.inline.reflinkSearch.exec(n));)e.includes(s[0].slice(s[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;null!=(s=this.tokenizer.rules.inline.blockSkip.exec(n));)n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);for(;null!=(s=this.tokenizer.rules.inline.anyPunctuation.exec(n));)n=n.slice(0,s.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);let r=!1,i="";for(;e;){let s;if(r||(i=""),r=!1,this.options.extensions?.inline?.some((n=>!!(s=n.call({lexer:this},e,t))&&(e=e.substring(s.raw.length),t.push(s),!0))))continue;if(s=this.tokenizer.escape(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.tag(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.link(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(s.raw.length);const n=t.at(-1);"text"===s.type&&"text"===n?.type?(n.raw+=s.raw,n.text+=s.text):t.push(s);continue}if(s=this.tokenizer.emStrong(e,n,i)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.codespan(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.br(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.del(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.autolink(e)){e=e.substring(s.raw.length),t.push(s);continue}if(!this.state.inLink&&(s=this.tokenizer.url(e))){e=e.substring(s.raw.length),t.push(s);continue}let l=e;if(this.options.extensions?.startInline){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startInline.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(l=e.substring(0,t+1))}if(s=this.tokenizer.inlineText(l)){e=e.substring(s.raw.length),"_"!==s.raw.slice(-1)&&(i=s.raw.slice(-1)),r=!0;const n=t.at(-1);"text"===n?.type?(n.raw+=s.raw,n.text+=s.text):t.push(s)}else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return t}}class ie{options;parser;constructor(t){this.options=t||e.defaults}space(e){return""}code({text:e,lang:t,escaped:n}){const s=(t||"").match(i.notSpaceStart)?.[0],r=e.replace(i.endingNewline,"")+"\n";return s?''+(n?r:W(r,!0))+" \n":""+(n?r:W(r,!0))+" \n"}blockquote({tokens:e}){return`\n${this.parser.parse(e)} \n`}html({text:e}){return e}heading({tokens:e,depth:t}){return`${this.parser.parseInline(e)} \n`}hr(e){return" \n"}list(e){const t=e.ordered,n=e.start;let s="";for(let t=0;t\n"+s+""+r+">\n"}listitem(e){let t="";if(e.task){const n=this.checkbox({checked:!!e.checked});e.loose?"paragraph"===e.tokens[0]?.type?(e.tokens[0].text=n+" "+e.tokens[0].text,e.tokens[0].tokens&&e.tokens[0].tokens.length>0&&"text"===e.tokens[0].tokens[0].type&&(e.tokens[0].tokens[0].text=n+" "+W(e.tokens[0].tokens[0].text),e.tokens[0].tokens[0].escaped=!0)):e.tokens.unshift({type:"text",raw:n+" ",text:n+" ",escaped:!0}):t+=n+" "}return t+=this.parser.parse(e.tokens,!!e.loose),`${t} \n`}checkbox({checked:e}){return" '}paragraph({tokens:e}){return`${this.parser.parseInline(e)}
\n`}table(e){let t="",n="";for(let t=0;t${s}`),"\n"}tablerow({text:e}){return`\n${e} \n`}tablecell(e){const t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return(e.align?`<${n} align="${e.align}">`:`<${n}>`)+t+`${n}>\n`}strong({tokens:e}){return`${this.parser.parseInline(e)} `}em({tokens:e}){return`${this.parser.parseInline(e)} `}codespan({text:e}){return`${W(e,!0)}`}br(e){return" "}del({tokens:e}){return`${this.parser.parseInline(e)}`}link({href:e,title:t,tokens:n}){const s=this.parser.parseInline(n),r=Y(e);if(null===r)return s;let i='"+s+" ",i}image({href:e,title:t,text:n}){const s=Y(e);if(null===s)return W(n);let r=` ",r}text(e){return"tokens"in e&&e.tokens?this.parser.parseInline(e.tokens):"escaped"in e&&e.escaped?e.text:W(e.text)}}class le{strong({text:e}){return e}em({text:e}){return e}codespan({text:e}){return e}del({text:e}){return e}html({text:e}){return e}text({text:e}){return e}link({text:e}){return""+e}image({text:e}){return""+e}br(){return""}}class oe{options;renderer;textRenderer;constructor(t){this.options=t||e.defaults,this.options.renderer=this.options.renderer||new ie,this.renderer=this.options.renderer,this.renderer.options=this.options,this.renderer.parser=this,this.textRenderer=new le}static parse(e,t){return new oe(t).parse(e)}static parseInline(e,t){return new oe(t).parseInline(e)}parse(e,t=!0){let n="";for(let s=0;s{const r=e[s].flat(1/0);n=n.concat(this.walkTokens(r,t))})):e.tokens&&(n=n.concat(this.walkTokens(e.tokens,t)))}}return n}use(...e){const t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach((e=>{const n={...e};if(n.async=this.defaults.async||n.async||!1,e.extensions&&(e.extensions.forEach((e=>{if(!e.name)throw new Error("extension name required");if("renderer"in e){const n=t.renderers[e.name];t.renderers[e.name]=n?function(...t){let s=e.renderer.apply(this,t);return!1===s&&(s=n.apply(this,t)),s}:e.renderer}if("tokenizer"in e){if(!e.level||"block"!==e.level&&"inline"!==e.level)throw new Error("extension level must be 'block' or 'inline'");const n=t[e.level];n?n.unshift(e.tokenizer):t[e.level]=[e.tokenizer],e.start&&("block"===e.level?t.startBlock?t.startBlock.push(e.start):t.startBlock=[e.start]:"inline"===e.level&&(t.startInline?t.startInline.push(e.start):t.startInline=[e.start]))}"childTokens"in e&&e.childTokens&&(t.childTokens[e.name]=e.childTokens)})),n.extensions=t),e.renderer){const t=this.defaults.renderer||new ie(this.defaults);for(const n in e.renderer){if(!(n in t))throw new Error(`renderer '${n}' does not exist`);if(["options","parser"].includes(n))continue;const s=n,r=e.renderer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n||""}}n.renderer=t}if(e.tokenizer){const t=this.defaults.tokenizer||new se(this.defaults);for(const n in e.tokenizer){if(!(n in t))throw new Error(`tokenizer '${n}' does not exist`);if(["options","rules","lexer"].includes(n))continue;const s=n,r=e.tokenizer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.tokenizer=t}if(e.hooks){const t=this.defaults.hooks||new ae;for(const n in e.hooks){if(!(n in t))throw new Error(`hook '${n}' does not exist`);if(["options","block"].includes(n))continue;const s=n,r=e.hooks[s],i=t[s];ae.passThroughHooks.has(n)?t[s]=e=>{if(this.defaults.async)return Promise.resolve(r.call(t,e)).then((e=>i.call(t,e)));const n=r.call(t,e);return i.call(t,n)}:t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.hooks=t}if(e.walkTokens){const t=this.defaults.walkTokens,s=e.walkTokens;n.walkTokens=function(e){let n=[];return n.push(s.call(this,e)),t&&(n=n.concat(t.call(this,e))),n}}this.defaults={...this.defaults,...n}})),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return re.lex(e,t??this.defaults)}parser(e,t){return oe.parse(e,t??this.defaults)}parseMarkdown(e){return(t,n)=>{const s={...n},r={...this.defaults,...s},i=this.onError(!!r.silent,!!r.async);if(!0===this.defaults.async&&!1===s.async)return i(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(null==t)return i(new Error("marked(): input parameter is undefined or null"));if("string"!=typeof t)return i(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(t)+", string expected"));r.hooks&&(r.hooks.options=r,r.hooks.block=e);const l=r.hooks?r.hooks.provideLexer():e?re.lex:re.lexInline,o=r.hooks?r.hooks.provideParser():e?oe.parse:oe.parseInline;if(r.async)return Promise.resolve(r.hooks?r.hooks.preprocess(t):t).then((e=>l(e,r))).then((e=>r.hooks?r.hooks.processAllTokens(e):e)).then((e=>r.walkTokens?Promise.all(this.walkTokens(e,r.walkTokens)).then((()=>e)):e)).then((e=>o(e,r))).then((e=>r.hooks?r.hooks.postprocess(e):e)).catch(i);try{r.hooks&&(t=r.hooks.preprocess(t));let e=l(t,r);r.hooks&&(e=r.hooks.processAllTokens(e)),r.walkTokens&&this.walkTokens(e,r.walkTokens);let n=o(e,r);return r.hooks&&(n=r.hooks.postprocess(n)),n}catch(e){return i(e)}}}onError(e,t){return n=>{if(n.message+="\nPlease report this to https://github.com/markedjs/marked.",e){const e="An error occurred:
"+W(n.message+"",!0)+" ";return t?Promise.resolve(e):e}if(t)return Promise.reject(n);throw n}}}const he=new ce;function pe(e,t){return he.parse(e,t)}pe.options=pe.setOptions=function(e){return he.setOptions(e),pe.defaults=he.defaults,n(pe.defaults),pe},pe.getDefaults=t,pe.defaults=e.defaults,pe.use=function(...e){return he.use(...e),pe.defaults=he.defaults,n(pe.defaults),pe},pe.walkTokens=function(e,t){return he.walkTokens(e,t)},pe.parseInline=he.parseInline,pe.Parser=oe,pe.parser=oe.parse,pe.Renderer=ie,pe.TextRenderer=le,pe.Lexer=re,pe.lexer=re.lex,pe.Tokenizer=se,pe.Hooks=ae,pe.parse=pe;const ue=pe.options,ge=pe.setOptions,ke=pe.use,de=pe.walkTokens,fe=pe.parseInline,xe=pe,be=oe.parse,we=re.lex;e.Hooks=ae,e.Lexer=re,e.Marked=ce,e.Parser=oe,e.Renderer=ie,e.TextRenderer=le,e.Tokenizer=se,e.getDefaults=t,e.lexer=we,e.marked=pe,e.options=ue,e.parse=xe,e.parseInline=fe,e.parser=be,e.setOptions=ge,e.use=ke,e.walkTokens=de}));
diff --git a/frontends/desktop_bridge.py b/frontends/desktop_bridge.py
new file mode 100644
index 000000000..2ff937aa3
--- /dev/null
+++ b/frontends/desktop_bridge.py
@@ -0,0 +1,1830 @@
+#!/usr/bin/env python3
+"""
+GenericAgent Web2 Bridge.
+
+Clear split:
+1) AgentManager: owns GenericAgent instances, sessions and histories.
+2) Transport: HTTP is the command/data channel; WebSocket only pushes small
+ session-state notifications.
+
+HTTP API:
+ GET /status
+ GET /config
+ POST /config
+ GET /model-profiles (+ POST / PUT / DELETE by id)
+ GET /sessions
+ POST /session/new
+ GET /session/{sid}
+ DELETE /session/{sid}
+ POST /session/{sid}/prompt
+ GET /session/{sid}/messages?after=0&limit=200
+ POST /session/{sid}/cancel
+ POST /services/start body: {"id":"frontends/qqapp.py"}
+ POST /services/stop body: {"id":"frontends/qqapp.py"}
+ GET /services/logs?id=frontends/qqapp.py&tail=200
+ GET /services/panel
+ GET /services/mykey
+ POST /services/mykey body: {"content":"..."}
+ POST /services/stop-extras stop conductor + scheduler (127.0.0.1 only)
+ POST /services/start-extras start conductor + scheduler (127.0.0.1 only)
+ POST /services/bridge/exit stop managed services, then exit bridge (127.0.0.1 only)
+
+WS API (state sync):
+ GET /ws -> on connect sends services.snapshot; service.changed on updates
+ {"type":"services.snapshot","services":[...]}
+ {"type":"service.changed","service":{...}}
+"""
+from __future__ import annotations
+
+import asyncio, atexit, contextlib, importlib, json, os, re, subprocess, sys
+from collections import Counter, deque
+import threading, time, traceback, uuid
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Set
+from aiohttp import web, WSMsgType
+
+APP_DIR = Path(__file__).resolve().parent
+
+
+def find_default_ga_root() -> Path:
+ candidates = [
+ APP_DIR / "..",
+ APP_DIR / ".." / "..",
+ APP_DIR / ".." / "GenericAgent",
+ APP_DIR / ".." / ".." / "GenericAgent",
+ ]
+ for p in candidates:
+ root = p.resolve()
+ if (root / "agentmain.py").exists():
+ return root
+ return APP_DIR.parent.parent.resolve()
+
+
+DEFAULT_GA_ROOT = find_default_ga_root()
+
+_FINAL_INFO_RE = re.compile(r'\n*`{5}\n*\[Info\] Final response to user\.\n*`{5}\s*$')
+
+
+def strip_final_info_marker(text: Any) -> str:
+ return _FINAL_INFO_RE.sub('', str(text or ''))
+
+
+for _s in (sys.stdout, sys.stderr):
+ with contextlib.suppress(Exception):
+ _s.reconfigure(encoding="utf-8", errors="replace")
+
+
+# ---------------------------------------------------------------------------
+# Agent management layer
+# ---------------------------------------------------------------------------
+
+@dataclass
+class Session:
+ id: str
+ title: str = "New chat"
+ cwd: str = ""
+ created_at: float = field(default_factory=time.time)
+ updated_at: float = field(default_factory=time.time)
+ messages: List[dict] = field(default_factory=list)
+ msg_seq: int = 0
+ partial: Optional[dict] = None
+ status: str = "idle" # idle|running|error|cancelled
+ agent: Any = None
+ thread: Optional[threading.Thread] = None
+ cancel_requested: bool = False
+ last_error: str = ""
+ pinned: bool = False
+ untitled: bool = True
+ plan_scan_baseline: int = 0
+ plan_path: str = ""
+ llm_history: Optional[List[dict]] = None
+
+
+def _load_plan_baseline(item: dict, msgs: list) -> int:
+ """Persisted per-session baseline (tuiapp_v2: set on /continue, not on preset text)."""
+ base = int(item.get("plan_scan_baseline", 0) or 0)
+ if base >= len(msgs):
+ return 0
+ return max(0, base)
+
+
+def _sanitize_desktop_plan_path(session_id: str, plan_path: str) -> str:
+ """Desktop: drop shared plan_demo paths so sessions do not read the same file."""
+ import plan_state
+ p = (plan_path or "").strip()
+ if not p:
+ return ""
+ if plan_state.is_session_scoped_plan_path(p, session_id):
+ return p
+ return plan_state.default_session_plan_path(session_id)
+
+
+class AgentManager:
+ def __init__(self):
+ self.lock = threading.RLock()
+ self.ga_root = str(DEFAULT_GA_ROOT)
+ self.config: Dict[str, Any] = {}
+ self.sessions: Dict[str, Session] = {}
+ self.active_session_id: Optional[str] = None
+ self._sessions_file = Path(self.ga_root) / "temp" / "desktop_sessions.json"
+ self._load_sessions()
+
+ @property
+ def mykey_path(self) -> str:
+ return str(Path(self.ga_root) / "mykey.py")
+
+ def _persist(self):
+ try:
+ self._sessions_file.parent.mkdir(parents=True, exist_ok=True)
+ arr = []
+ with self.lock:
+ for s in self.sessions.values():
+ llm_hist = None
+ if s.agent and hasattr(s.agent, 'llmclient'):
+ try: llm_hist = s.agent.llmclient.backend.history
+ except Exception: pass
+ if llm_hist is None:
+ llm_hist = s.llm_history
+ arr.append({"id": s.id, "title": s.title, "cwd": s.cwd,
+ "created_at": s.created_at, "updated_at": s.updated_at,
+ "messages": s.messages, "msg_seq": s.msg_seq,
+ "pinned": s.pinned, "untitled": s.untitled,
+ "plan_scan_baseline": s.plan_scan_baseline,
+ "plan_path": s.plan_path or "",
+ "llm_history": llm_hist})
+ self._sessions_file.write_text(json.dumps(arr, ensure_ascii=False, default=str), encoding="utf-8")
+ except Exception as e:
+ print(f"[bridge] persist sessions failed: {e}", file=sys.stderr)
+
+ def _load_sessions(self):
+ try:
+ if not self._sessions_file.exists():
+ return
+ arr = json.loads(self._sessions_file.read_text(encoding="utf-8"))
+ for item in arr:
+ msgs = item.get("messages", [])
+ sess = Session(id=item["id"], title=item.get("title", "New chat"),
+ cwd=item.get("cwd", self.ga_root),
+ created_at=item.get("created_at", time.time()),
+ updated_at=item.get("updated_at", time.time()),
+ messages=msgs,
+ msg_seq=item.get("msg_seq", 0),
+ pinned=item.get("pinned", False),
+ untitled=item.get("untitled", True),
+ plan_scan_baseline=_load_plan_baseline(item, msgs),
+ plan_path=_sanitize_desktop_plan_path(
+ item["id"], item.get("plan_path") or ""),
+ status="idle", agent=None,
+ llm_history=item.get("llm_history"))
+ self.sessions[sess.id] = sess
+ if self.sessions:
+ self.active_session_id = max(self.sessions.values(), key=lambda s: s.updated_at).id
+ except Exception as e:
+ print(f"[bridge] load sessions failed: {e}", file=sys.stderr)
+
+ def _mykey_file(self) -> Path:
+ p = Path(self.ga_root) / "mykey.py"
+ if not p.exists():
+ tpl = Path(self.ga_root) / "mykey_template.py"
+ p.write_text(tpl.read_text(encoding="utf-8") if tpl.exists() else "", encoding="utf-8")
+ return p
+
+ @staticmethod
+ def _next_native_var(text: str, protocol: str) -> str:
+ # 协议必选(由前端下拉强制),不再用 apibase 兜底瞎猜
+ proto = str(protocol or "").strip().lower()
+ if proto == "claude":
+ prefix = "native_claude_config"
+ elif proto in ("oai", "openai"):
+ prefix = "native_oai_config"
+ else:
+ raise ValueError("protocol is required: choose 'oai' or 'claude'")
+ nums = [0]
+ if re.search(rf"^{prefix}\s*=", text, re.M):
+ nums.append(0)
+ nums.extend(int(m.group(1)) for m in re.finditer(rf"^{prefix}(\d+)\s*=", text, re.M))
+ n = max(nums) + 1
+ return prefix if n == 1 and not re.search(rf"^{prefix}\s*=", text, re.M) else f"{prefix}{n}"
+
+ @staticmethod
+ def _format_py_dict(d: dict) -> str:
+ lines = [f" '{k}': {json.dumps(v, ensure_ascii=False)}," if isinstance(v, str) else f" '{k}': {v}," for k, v in d.items()]
+ return "{\n" + "\n".join(lines) + "\n}"
+
+ def _invalidate_mykey_cache(self) -> None:
+ self.ensure_ga_import_path()
+ sys.modules.pop("mykey", None)
+ with contextlib.suppress(Exception):
+ import llmcore
+ llmcore._mykey_mtime = None
+
+ def _profile_keys(self) -> List[str]:
+ self.ensure_ga_import_path()
+ from llmcore import reload_mykeys
+ return [k for k in reload_mykeys()[0] if any(x in k for x in ("api", "config", "cookie"))]
+
+ def _profile_at(self, profile_id: int) -> tuple[str, dict]:
+ keys = self._profile_keys()
+ if profile_id < 0 or profile_id >= len(keys):
+ raise ValueError("profile not found")
+ var = keys[profile_id]
+ if "mixin" in var:
+ raise ValueError("mixin profiles not supported here")
+ from llmcore import reload_mykeys
+ cfg = reload_mykeys()[0].get(var)
+ if not isinstance(cfg, dict):
+ raise ValueError("profile not editable")
+ return var, dict(cfg)
+
+ @staticmethod
+ def _find_var_block_span(text: str, var_name: str) -> Optional[tuple[int, int]]:
+ m = re.search(rf"^{re.escape(var_name)}\s*=\s*\{{", text, re.M)
+ if not m:
+ return None
+ start, i, depth = m.start(), m.end() - 1, 0
+ while i < len(text):
+ if text[i] == "{":
+ depth += 1
+ elif text[i] == "}":
+ depth -= 1
+ if depth == 0:
+ end = i + 1
+ while end < len(text) and text[end] in "\r\n":
+ end += 1
+ return start, end
+ i += 1
+ return None
+
+ def _patch_var_block(self, text: str, var: str, cfg: Optional[dict] = None) -> str:
+ if not (span := self._find_var_block_span(text, var)):
+ raise ValueError(f"config block not found: {var}")
+ s, e = span
+ if cfg is None:
+ return text[:s].rstrip() + "\n" + text[e:].lstrip("\n")
+ return text[:s] + f"{var} = {self._format_py_dict(cfg)}\n" + text[e:]
+
+ def _build_cfg(self, data: dict, existing: Optional[dict] = None, *, require_key: bool = True) -> dict:
+ apibase, model = str(data.get("apibase") or "").strip(), str(data.get("model") or "").strip()
+ if not apibase or not model:
+ raise ValueError("apibase and model are required")
+ apikey = str(data.get("apikey") or "").strip() or str((existing or {}).get("apikey") or "").strip()
+ if require_key and not apikey:
+ raise ValueError("apikey is required")
+ # 从 existing 起步:保留表单未覆盖的高级字段(proxy / temperature / api_mode /
+ # reasoning_effort / fake_cc_system_prompt / thinking_type …),避免 GUI 编辑时丢失
+ cfg: Dict[str, Any] = dict(existing or {})
+ cfg.update({"apikey": apikey, "apibase": apibase, "model": model})
+ if "name" in data:
+ name = str(data.get("name") or "").strip()
+ if name:
+ cfg["name"] = name
+ else:
+ cfg.pop("name", None)
+ for k in ("max_retries", "connect_timeout", "read_timeout"):
+ if data.get(k) is not None and str(data.get(k)).strip() != "":
+ cfg[k] = int(data[k])
+ # 流式开关:默认 True 不写(保持 mykey 干净),仅显式非流式才落 'stream': False
+ if "stream" in data:
+ s = data["stream"]
+ stream = s if isinstance(s, bool) else str(s).strip().lower() not in ("false", "0", "no", "off")
+ if stream:
+ cfg.pop("stream", None)
+ else:
+ cfg["stream"] = False
+ return cfg
+
+ def _save_mykey_text(self, text: str) -> list:
+ self._mykey_file().write_text(text, encoding="utf-8")
+ self._invalidate_mykey_cache()
+ self._reload_live_agents()
+ return self.list_model_profiles()
+
+ def _reload_live_agents(self) -> None:
+ """mykey.py 改动后,强制所有活着的会话 agent 重建 LLM session,让新 key/模型
+ 立即生效(无需重启)。重建保留对话 history(agentmain 内部用 oldhistory 接回)。
+
+ 纯 bridge 侧实现,不改 agentmain:每次调 agent.load_llm_sessions() 前,把
+ llmcore 的全局 mtime 标志清空(与 _invalidate_mykey_cache 同一手法),使其内部
+ reload_mykeys() 报告 changed=True、从而真正重建——否则刷新模型列表等路径会先
+ 消费掉变更标志,常驻 agent 的 load_llm_sessions 会因 changed=False 跳过重建。"""
+ self.ensure_ga_import_path()
+ try:
+ import llmcore
+ except Exception:
+ return
+ with self.lock:
+ agents = [s.agent for s in self.sessions.values() if getattr(s, "agent", None) is not None]
+ for agent in agents:
+ fn = getattr(agent, "load_llm_sessions", None)
+ if not callable(fn):
+ continue
+ try:
+ llmcore._mykey_mtime = None # 让本次 reload_mykeys() 视为“已变更”,触发真正重建
+ fn()
+ except Exception as e:
+ print(f"[bridge] reload live agent failed: {e}", file=sys.stderr)
+
+ def add_model_profile(self, data: dict) -> dict:
+ cfg = self._build_cfg(data)
+ text = self._mykey_file().read_text(encoding="utf-8")
+ var = self._next_native_var(text, data.get("protocol", ""))
+ profiles = self._save_mykey_text(text.rstrip() + f"\n{var} = {self._format_py_dict(cfg)}\n")
+ return {"varName": var, "profileId": profiles[-1]["id"] if profiles else 0, "profiles": profiles}
+
+ def get_model_profile(self, profile_id: int) -> dict:
+ var, cfg = self._profile_at(profile_id)
+ ks = ("model", "apibase", "apikey", "name", "max_retries", "connect_timeout", "read_timeout")
+ out = {"id": profile_id, "varName": var, **{k: cfg.get(k, d) for k, d in zip(ks, ("", "", "", "", 5, 15, 300))}}
+ out["stream"] = cfg.get("stream", True)
+ return out
+
+ def update_model_profile(self, profile_id: int, data: dict) -> dict:
+ var, existing = self._profile_at(profile_id)
+ text = self._mykey_file().read_text(encoding="utf-8")
+ profiles = self._save_mykey_text(self._patch_var_block(text, var, self._build_cfg(data, existing, require_key=False)))
+ return {"varName": var, "profileId": profile_id, "profiles": profiles}
+
+ def delete_model_profile(self, profile_id: int) -> dict:
+ if len(self._profile_keys()) <= 1:
+ raise ValueError("cannot delete the last profile")
+ var, cfg = self._profile_at(profile_id)
+ text = self._patch_var_block(self._mykey_file().read_text(encoding="utf-8"), var).rstrip() + "\n"
+ # 顺手把它从聚合渠道里摘掉,避免 llm_nos 残留指向已删除的模型(会让 Mixin 构建失败)
+ name = str(cfg.get("name") or cfg.get("model") or "").strip()
+ keys, mk = self._mykey_vars()
+ mvar, mcfg = self._mixin_entry(keys, mk)
+ if mcfg and mvar is not None and name in [str(m) for m in (mcfg.get("llm_nos") or [])]:
+ mcfg = {**mcfg, "llm_nos": [str(m) for m in (mcfg.get("llm_nos") or []) if str(m) != name]}
+ if self._find_var_block_span(text, mvar):
+ text = self._patch_var_block(text, mvar, mcfg)
+ profiles = self._save_mykey_text(text)
+ return {"profileId": profile_id, "profiles": profiles}
+
+ def ensure_ga_import_path(self) -> Path:
+ root = Path(self.ga_root).resolve()
+ if str(root) not in sys.path:
+ sys.path.insert(0, str(root))
+ return root
+
+ def make_agent(self, sess: Session):
+ root = self.ensure_ga_import_path()
+ try: import cost_tracker; cost_tracker.install()
+ except Exception: pass
+ old_cwd = os.getcwd()
+ try:
+ os.chdir(sess.cwd or str(root))
+ agentmain = importlib.import_module("agentmain")
+ GA = getattr(agentmain, "GenericAgent")
+ agent = GA()
+ agent.inc_out = True
+ agent.verbose = True
+ threading.Thread(target=agent.run, daemon=True, name=f"GA-{sess.id}").start()
+ return agent
+ finally:
+ with contextlib.suppress(Exception):
+ os.chdir(old_cwd)
+
+ @staticmethod
+ def _base_display_name(var: str, cfg: Optional[dict]) -> str:
+ c = cfg or {}
+ return str(c.get("name") or c.get("model") or var)
+
+ def _mykey_vars(self):
+ """(keys, mk):mykey 里的模型变量名(按定义顺序,与 agentmain.llmclients 索引
+ 一一对齐)和原始 dict。过滤规则与 _profile_keys / load_llm_sessions 完全一致,
+ 因此 id == llmclients 下标,前端选中 llmNo 能正确激活对应 client。"""
+ self._mykey_file() # 确保 mykey.py 存在(首次从模板生成空配置),否则全新安装时
+ # reload_mykeys 找不到 mykey 会返回空,空聚合渠道就不显示了
+ self.ensure_ga_import_path()
+ from llmcore import reload_mykeys
+ mk = reload_mykeys()[0]
+ keys = [k for k in mk if any(x in k for x in ("api", "config", "cookie"))]
+ return keys, mk
+
+ def _mixin_entry(self, keys, mk):
+ """返回 (mixin_var, mixin_cfg_dict) 或 (None, None)。单一主聚合渠道,只取第一个。"""
+ for k in keys:
+ if "mixin" in k and isinstance(mk.get(k), dict):
+ return k, dict(mk[k])
+ return None, None
+
+ def list_model_profiles(self):
+ """直接读 mykey.py 结构(不依赖能否成功构建出 client),这样空聚合渠道、
+ 未填 key 的模型也能如实展示。聚合渠道(kind=mixin)带 members;基本模型
+ (kind=native)带 inMixin/group。"""
+ try:
+ keys, mk = self._mykey_vars()
+ except Exception as e:
+ print(f"get model profiles failed: {e}", file=sys.stderr)
+ return []
+ active = self.config.get("llmNo", 0)
+ # collect all mixin members for inMixin check
+ all_mixin_members: set = set()
+ for k in keys:
+ if "mixin" in k:
+ c = mk.get(k) if isinstance(mk.get(k), dict) else {}
+ all_mixin_members.update(str(m) for m in (c.get("llm_nos") or []))
+ out = []
+ for i, k in enumerate(keys):
+ cfg = mk.get(k) if isinstance(mk.get(k), dict) else {}
+ if "mixin" in k:
+ mems = [str(m) for m in (cfg.get("llm_nos") or [])]
+ out.append({"id": i, "varName": k, "kind": "mixin", "name": "",
+ "members": mems, "active": i == active})
+ else:
+ name = self._base_display_name(k, cfg)
+ out.append({"id": i, "varName": k, "kind": "native", "name": name,
+ "model": cfg.get("model", ""),
+ "group": "native" if "native" in k else "std",
+ "inMixin": name in all_mixin_members, "active": i == active})
+ return out
+
+ def add_to_mixin(self, profile_id: int) -> dict:
+ """把一个基本模型加入主聚合渠道:把它的 name 追加进 mixin_config['llm_nos']。
+ 坑1:校验 Native 一致性(聚合内必须全 Native 或全非 Native)。
+ 坑2:加入前若该模型没有显式 name,先把 name 写进它的配置块(保证引用稳定)。"""
+ var, cfg = self._profile_at(profile_id) # 对 mixin 会抛错(只接受 native)
+ name = str(cfg.get("name") or cfg.get("model") or "").strip()
+ if not name:
+ raise ValueError("this model needs a name or model before joining the channel")
+ keys, mk = self._mykey_vars()
+ mvar, mcfg = self._mixin_entry(keys, mk)
+ new_is_native = "native" in var
+ name2var = {self._base_display_name(k, mk.get(k) if isinstance(mk.get(k), dict) else {}): k
+ for k in keys if "mixin" not in k}
+ existing = [str(m) for m in (mcfg.get("llm_nos") or [])] if mcfg else []
+ for m in existing:
+ mv = name2var.get(m)
+ if mv is not None and ("native" in mv) != new_is_native:
+ raise ValueError("aggregation channel requires all-Native or all-non-Native models")
+ text = self._mykey_file().read_text(encoding="utf-8")
+ if not cfg.get("name"):
+ text = self._patch_var_block(text, var, {**cfg, "name": name})
+ if mcfg is None:
+ mcfg, mvar, existing = {"llm_nos": [], "max_retries": 10, "base_delay": 0.5}, "mixin_config", []
+ if name not in existing:
+ existing.append(name)
+ mcfg = {**mcfg, "llm_nos": existing}
+ if self._find_var_block_span(text, mvar):
+ text = self._patch_var_block(text, mvar, mcfg)
+ else:
+ text = text.rstrip() + f"\n{mvar} = {self._format_py_dict(mcfg)}\n"
+ return {"profiles": self._save_mykey_text(text)}
+
+ def remove_from_mixin(self, profile_id: int) -> dict:
+ """把一个基本模型移出主聚合渠道。"""
+ var, cfg = self._profile_at(profile_id)
+ name = str(cfg.get("name") or cfg.get("model") or "").strip()
+ keys, mk = self._mykey_vars()
+ mvar, mcfg = self._mixin_entry(keys, mk)
+ if not mcfg or mvar is None:
+ return {"profiles": self.list_model_profiles()}
+ members = [str(m) for m in (mcfg.get("llm_nos") or []) if str(m) != name]
+ mcfg = {**mcfg, "llm_nos": members}
+ text = self._patch_var_block(self._mykey_file().read_text(encoding="utf-8"), mvar, mcfg)
+ return {"profiles": self._save_mykey_text(text)}
+
+ def reorder_mixin(self, members: list) -> dict:
+ """按前端拖拽后的顺序重写主渠道组 llm_nos。只接受当前成员的重排,不增删。"""
+ keys, mk = self._mykey_vars()
+ mvar, mcfg = self._mixin_entry(keys, mk)
+ if not mcfg or mvar is None:
+ raise ValueError("mixin channel not found")
+ old = [str(m) for m in (mcfg.get("llm_nos") or [])]
+ new = [str(m) for m in (members or [])]
+ if len(new) != len(old) or Counter(new) != Counter(old):
+ raise ValueError("reorder must contain the same channel members")
+ if new == old:
+ return {"profiles": self.list_model_profiles()}
+ mcfg = {**mcfg, "llm_nos": new}
+ text = self._patch_var_block(self._mykey_file().read_text(encoding="utf-8"), mvar, mcfg)
+ return {"profiles": self._save_mykey_text(text)}
+
+ @staticmethod
+ def _live_model(sess: Session) -> Optional[dict]:
+ """该会话 agent 当前真正在用的模型(渠道组会随故障转移变化)。
+ agent 还没建(没跑过 turn)时返回 None,前端回退到静态显示。"""
+ ag = getattr(sess, "agent", None)
+ if ag is None:
+ return None
+ try:
+ back = ag.llmclient.backend
+ if "Mixin" in type(back).__name__:
+ return {"current": back.current_name, "isMixin": True}
+ return {"current": back.name, "isMixin": False}
+ except Exception:
+ return None
+
+ def snapshot(self, sess: Session, include_messages: bool = True) -> dict:
+ out = {
+ "sessionId": sess.id,
+ "id": sess.id,
+ "title": sess.title,
+ "cwd": sess.cwd,
+ "status": sess.status,
+ "createdAt": sess.created_at,
+ "updatedAt": sess.updated_at,
+ "lastError": sess.last_error,
+ "msgSeq": sess.msg_seq,
+ "pinned": sess.pinned,
+ "untitled": sess.untitled,
+ "model": self._live_model(sess),
+ }
+ if include_messages:
+ out["messages"] = list(sess.messages)
+ out["partial"] = dict(sess.partial) if sess.partial else None
+ return out
+
+ def add_message(self, sess: Session, role: str, content: str, **extra) -> dict:
+ sess.msg_seq += 1
+ msg = {"id": sess.msg_seq, "role": role, "content": content, "ts": time.time()}
+ msg.update(extra)
+ sess.messages.append(msg)
+ sess.updated_at = time.time()
+ if role == "user" and content.strip() and sess.title == "New chat":
+ sess.title = content.strip().replace("\n", " ")[:40]
+ self._persist()
+ return msg
+
+ def create_session(self, cwd: Optional[str] = None) -> Session:
+ sid = "sess-" + uuid.uuid4().hex[:12]
+ sess = Session(id=sid, cwd=str(cwd or self.ga_root))
+ with self.lock:
+ self.sessions[sid] = sess
+ self.active_session_id = sid
+ emit_session_state(sess, "created")
+ self._persist()
+ return sess
+
+ def get_session(self, sid: str) -> Session:
+ with self.lock:
+ sess = self.sessions.get(sid)
+ if not sess:
+ raise web.HTTPNotFound(text=json.dumps({"error": f"session not found: {sid}"}, ensure_ascii=False), content_type="application/json")
+ return sess
+
+ def delete_session(self, sid: str) -> dict:
+ with self.lock:
+ sess = self.sessions.pop(sid, None)
+ if not sess:
+ raise web.HTTPNotFound(text=json.dumps({"error": f"session not found: {sid}"}, ensure_ascii=False), content_type="application/json")
+ if self.active_session_id == sid:
+ self.active_session_id = next(iter(self.sessions), None)
+ if sess.agent and hasattr(sess.agent, "abort"):
+ with contextlib.suppress(Exception):
+ sess.agent.abort()
+ emit_session_state(sess, "closed")
+ self._persist()
+ _purge_session_uploads(sid)
+ return {"ok": True, "sessionId": sid}
+
+ def submit_prompt(self, sid: str, prompt: Any, images: Optional[list] = None, llm_no: Optional[int] = None, display: Optional[str] = None, files_meta: Optional[list] = None, image_metas: Optional[list] = None) -> dict:
+ prompt, image_ids = normalize_prompt(prompt, images)
+ if llm_no is not None:
+ self.config["llmNo"] = int(llm_no)
+ with self.lock:
+ sess = self.sessions.get(sid)
+ if not sess:
+ raise web.HTTPNotFound(text=json.dumps({"error": f"session not found: {sid}"}, ensure_ascii=False), content_type="application/json")
+ if sess.status == "running":
+ raise web.HTTPConflict(text=json.dumps({"error": "session is already running"}, ensure_ascii=False), content_type="application/json")
+ extra = {}
+ if image_ids:
+ extra["image_ids"] = image_ids
+ if isinstance(display, str) and display.strip() and display != prompt:
+ extra["display"] = display
+ if files_meta:
+ extra["files"] = files_meta
+ if image_metas:
+ extra["images"] = image_metas
+ user_msg = self.add_message(sess, "user", prompt, **extra)
+ import plan_state
+ if plan_state.is_plan_preset_prompt(prompt):
+ plan_state.bind_plan_session(sess, prompt)
+ self._persist()
+ sess.status = "running"
+ sess.cancel_requested = False
+ sess.last_error = ""
+ sess.partial = {"id": sess.msg_seq + 1, "role": "assistant", "content": "", "ts": time.time(), "partial": True,
+ "curr_turn": 0, "turn_segs": []} # turn_segs[i]=第i轮全文(权威结构化,前端按轮渲染);content保留双轨兜底
+ t = threading.Thread(target=self.run_agent_turn, args=(sess, prompt, None, llm_no), daemon=True, name=f"Turn-{sid}")
+ sess.thread = t
+ t.start()
+ seq = sess.msg_seq
+ emit_session_state(sess, "running")
+ return {"ok": True, "sessionId": sid, "accepted": True, "userMessageId": user_msg["id"], "seq": seq}
+
+ def run_agent_turn(self, sess: Session, prompt: str, images: Optional[list] = None, llm_no: Optional[int] = None):
+ try:
+ if sess.agent is None:
+ sess.agent = self.make_agent(sess)
+ agent = sess.agent
+ no = self.config.get("llmNo") if llm_no is None else llm_no
+ if no is not None and hasattr(agent, "next_llm"):
+ with contextlib.suppress(Exception):
+ agent.next_llm(int(no))
+ full = ""
+ done_outputs = None # done时agent给的全量轮文本(turn_resps.copy())
+ if hasattr(agent, "put_task"):
+ display_q = agent.put_task(prompt, images=images or [])
+ pieces = []
+ import queue as _queue
+ while True:
+ if sess.cancel_requested:
+ break
+ try:
+ item = display_q.get(timeout=1.0)
+ except _queue.Empty:
+ continue
+ if isinstance(item, dict):
+ if item.get("next"):
+ text = str(item["next"])
+ pieces.append(text)
+ with self.lock:
+ if sess.partial is not None:
+ sess.partial["content"] = "".join(pieces) if getattr(agent, "inc_out", False) else text
+ sess.partial["ts"] = time.time()
+ sess.updated_at = time.time()
+ # 轨道2: bridge 归一化为前端直接可渲染的 0 基 turn_segs;outputs=turn_resps[-2:]
+ _t = int(item.get("turn", 0) or 0)
+ _outs = item.get("outputs") or []
+ _idx = max(0, _t - 1)
+ sess.partial["curr_turn"] = _idx
+ _segs = sess.partial["turn_segs"]
+ while len(_segs) <= _idx:
+ _segs.append("")
+ if _outs:
+ _segs[_idx] = str(_outs[-1])
+ if len(_outs) >= 2 and _idx >= 1:
+ _segs[_idx - 1] = str(_outs[-2])
+ if "done" in item:
+ full = strip_final_info_marker(item.get("done") or "")
+ done_outputs = item.get("outputs") # done时=turn_resps.copy()全量轮
+ if done_outputs:
+ done_outputs = [strip_final_info_marker(s) for s in done_outputs]
+ with self.lock:
+ if sess.partial is not None:
+ sess.partial["content"] = full
+ sess.partial["ts"] = time.time()
+ sess.partial["updatedAt"] = sess.partial["ts"] if "updatedAt" in sess.partial else sess.partial.get("updatedAt")
+ sess.partial["curr_turn"] = max(0, len(done_outputs) - 1)
+ sess.partial["turn_segs"] = list(done_outputs)
+ sess.updated_at = time.time()
+ break
+ else:
+ pieces.append(str(item))
+ if not full and pieces:
+ full = pieces[-1] if not getattr(agent, "inc_out", False) else "".join(pieces)
+ else:
+ full = "GenericAgent object has no put_task method"
+ if not full:
+ full = "(completed)"
+ if sess.cancel_requested:
+ with self.lock:
+ sess.partial = None
+ # Ensure status stays cancelled (don't overwrite)
+ if sess.status != "cancelled":
+ sess.status = "cancelled"
+ sess.updated_at = time.time()
+ emit_session_state(sess, "cancelled")
+ return
+ with self.lock:
+ sess.partial = None
+ full = strip_final_info_marker(full)
+ if done_outputs:
+ done_outputs = [strip_final_info_marker(s) for s in done_outputs]
+ import plan_state
+ plan_state.sync_plan_path_from_text(sess, full, sess.cwd or self.ga_root)
+ # 轨道2: 落库时带结构化全量轮(权威turn_segs),前端按轮渲染;content保留兜底
+ _final_segs = [str(s) for s in done_outputs] if done_outputs else None
+ if _final_segs:
+ self.add_message(sess, "assistant", full, turn_segs=_final_segs)
+ else:
+ self.add_message(sess, "assistant", full)
+ try: sess.llm_history = json.loads(json.dumps(agent.llmclient.backend.history, ensure_ascii=False, default=str))
+ except Exception: pass
+ sess.status = "idle"
+ sess.last_error = ""
+ emit_session_state(sess, "idle")
+ except Exception as e:
+ tb = traceback.format_exc()
+ with self.lock:
+ sess.partial = None
+ sess.status = "error"
+ sess.last_error = str(e)
+ self.add_message(sess, "error", str(e))
+ print(tb, file=sys.stderr)
+ emit_session_state(sess, "error")
+
+ def messages(self, sid: str, after: int = 0, limit: int = 200) -> dict:
+ with self.lock:
+ sess = self.sessions.get(sid)
+ if not sess:
+ raise web.HTTPNotFound(text=json.dumps({"error": f"session not found: {sid}"}, ensure_ascii=False), content_type="application/json")
+ msgs = [m for m in sess.messages if int(m.get("id", 0)) > after]
+ if limit > 0:
+ msgs = msgs[-limit:]
+ import plan_state
+ return {
+ "sessionId": sid,
+ "status": sess.status,
+ "messages": msgs,
+ "partial": dict(sess.partial) if sess.partial else None,
+ "plan": plan_state.desktop_plan_payload_from_session(sess, self.ga_root),
+ "msgSeq": sess.msg_seq,
+ "updatedAt": sess.updated_at,
+ "lastError": sess.last_error,
+ "model": self._live_model(sess),
+ }
+
+ def plan_snapshot(self, sid: str) -> dict:
+ with self.lock:
+ sess = self.sessions.get(sid)
+ if not sess:
+ raise web.HTTPNotFound(text=json.dumps({"error": f"session not found: {sid}"}, ensure_ascii=False), content_type="application/json")
+ import plan_state
+ return {
+ "sessionId": sid,
+ "plan": plan_state.desktop_plan_payload_from_session(sess, self.ga_root),
+ }
+
+ def cancel(self, sid: str) -> dict:
+ with self.lock:
+ sess = self.sessions.get(sid)
+ if not sess:
+ raise web.HTTPNotFound(text=json.dumps({"error": f"session not found: {sid}"}, ensure_ascii=False), content_type="application/json")
+ sess.cancel_requested = True
+ if sess.agent and hasattr(sess.agent, "abort"):
+ with contextlib.suppress(Exception):
+ sess.agent.abort()
+ partial_text = ""
+ if sess.partial:
+ partial_text = (sess.partial.get("content") or "").strip()
+ if partial_text:
+ self.add_message(sess, "assistant", partial_text, stopped=True)
+ sess.status = "cancelled"
+ sess.partial = None
+ sess.updated_at = time.time()
+ emit_session_state(sess, "cancelled")
+ return {"ok": True, "sessionId": sid}
+
+ def restore_context(self, sid: str) -> dict:
+ with self.lock:
+ sess = self.sessions.get(sid)
+ if not sess:
+ raise web.HTTPNotFound(text=json.dumps({"error": f"session not found: {sid}"}, ensure_ascii=False), content_type="application/json")
+ if sess.agent is not None:
+ return {"ok": True, "sessionId": sid, "restored": False, "reason": "agent already alive"}
+ agent = self.make_agent(sess)
+ if sess.llm_history:
+ try:
+ agent.llmclient.backend.history = sess.llm_history
+ except Exception as e:
+ print(f"[bridge] restore llm_history failed: {e}", file=sys.stderr)
+ else:
+ history = []
+ for m in sess.messages:
+ role = m.get("role")
+ content = m.get("content", "")
+ if role == "user":
+ history.append({"role": "user", "content": [{"type": "text", "text": content}]})
+ elif role == "assistant":
+ history.append({"role": "assistant", "content": [{"type": "text", "text": content}]})
+ if history:
+ try:
+ agent.llmclient.backend.history = history
+ except Exception as e:
+ print(f"[bridge] inject history failed: {e}", file=sys.stderr)
+ with self.lock:
+ sess.agent = agent
+ sess.status = "idle"
+ return {"ok": True, "sessionId": sid, "restored": True, "messageCount": len(sess.llm_history or sess.messages)}
+
+
+import base64
+
+
+def normalize_prompt(prompt: Any, images: Optional[list] = None):
+ """Flatten a prompt (str or content-part list) to plain text.
+
+ Image/file attachments are handled by the frontend, which inlines the
+ uploaded file path into the prompt text (see expandFilePlaceholders) and
+ sends path-only metadata via files/imageMetas — so no per-prompt image
+ persistence happens here. The `images` arg is accepted for backward compat
+ and ignored; the returned image-id list is always empty.
+ """
+ if isinstance(prompt, list):
+ text_parts = []
+ for part in prompt:
+ if isinstance(part, str):
+ text_parts.append(part)
+ elif isinstance(part, dict) and part.get("type") in ("text", "input_text"):
+ text_parts.append(str(part.get("text") or part.get("content") or ""))
+ prompt = "\n".join([p for p in text_parts if p])
+
+ return str(prompt or ""), []
+
+
+manager = AgentManager()
+
+
+# ---------------------------------------------------------------------------
+# Transport layer: WS state push
+# ---------------------------------------------------------------------------
+
+class WsHub:
+ def __init__(self):
+ self.websockets: Set[web.WebSocketResponse] = set()
+ self.loop: Optional[asyncio.AbstractEventLoop] = None
+
+ def emit(self, obj: dict):
+ if self.loop and self.loop.is_running():
+ asyncio.run_coroutine_threadsafe(self._broadcast(obj), self.loop)
+
+ async def _broadcast(self, obj: dict):
+ data = json.dumps(obj, ensure_ascii=False, default=str)
+ dead = set()
+ for ws in list(self.websockets):
+ try:
+ await ws.send_str(data)
+ except Exception:
+ dead.add(ws)
+ self.websockets.difference_update(dead)
+
+
+hub = WsHub()
+
+
+# ---------------------------------------------------------------------------
+# Service management (hub.pyw core + WS notify)
+# ---------------------------------------------------------------------------
+
+_SKIP = frozenset({"goal_mode.py", "chatapp_common.py", "tuiapp.py", "qtapp.py"})
+BRIDGE_ID = "__bridge__"
+
+_SERVICE_KEYS: Dict[str, tuple] = {
+ "frontends/qqapp.py": ("qq_app_id", "qq_app_secret"),
+ "frontends/dcapp.py": ("discord_bot_token",),
+ "frontends/dingtalkapp.py": ("dingtalk_client_id", "dingtalk_client_secret"),
+ "frontends/fsapp.py": ("fs_app_id", "fs_app_secret"),
+ "frontends/tgapp.py": ("tg_bot_token",),
+ "frontends/wecomapp.py": ("wecom_bot_id", "wecom_secret"),
+}
+
+
+def _load_mykeys(ga_root: Path) -> dict:
+ if not (ga_root / "mykey.py").exists():
+ return {}
+ root = str(ga_root.resolve())
+ if root not in sys.path:
+ sys.path.insert(0, root)
+ import mykey as mk
+ importlib.reload(mk)
+ return {k: v for k, v in vars(mk).items() if not k.startswith("_")}
+
+
+def discover_im_services(ga_root: Path) -> List[dict]:
+ out: List[dict] = []
+ d = ga_root / "frontends"
+ if not d.is_dir():
+ return out
+ for f in sorted(os.listdir(d)):
+ if "app" not in f or not f.endswith(".py") or f in _SKIP or "stapp" in f or "tuiapp" in f:
+ continue
+ rel = f"frontends/{f}"
+ out.append({"id": rel, "cmd": [sys.executable, str(d / f)]})
+ return out
+
+
+def discover_extra_services(ga_root: Path) -> List[dict]:
+ out: List[dict] = []
+ sched = ga_root / "reflect" / "scheduler.py"
+ if sched.is_file():
+ out.append({
+ "id": "reflect/scheduler.py",
+ "cmd": [sys.executable, "agentmain.py", "--reflect", "reflect/scheduler.py"],
+ })
+ # conductor 跟 scheduler 一样,bridge 启动时自动拉起。--no-browser 是关键:
+ # conductor.py 默认会用 webbrowser.open 在用户浏览器弹一个 8900 端口 UI,
+ # 桌面版自启时不需要这个独立 UI(用户从「指挥家」页直接访问)。
+ conductor = ga_root / "frontends" / "conductor.py"
+ if conductor.is_file():
+ out.append({
+ "id": "frontends/conductor.py",
+ "cmd": [sys.executable, "frontends/conductor.py", "--no-browser"],
+ })
+ return out
+
+
+def _mem_mb(pid: Optional[int]) -> Optional[int]:
+ if not pid:
+ return None
+ if sys.platform == "win32":
+ import ctypes
+ from ctypes import wintypes
+ class PROCESS_MEMORY_COUNTERS(ctypes.Structure):
+ _fields_ = [
+ ("cb", wintypes.DWORD), ("PageFaultCount", wintypes.DWORD),
+ ("PeakWorkingSetSize", ctypes.c_size_t), ("WorkingSetSize", ctypes.c_size_t),
+ ("QuotaPeakPagedPoolUsage", ctypes.c_size_t), ("QuotaPagedPoolUsage", ctypes.c_size_t),
+ ("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t), ("QuotaNonPagedPoolUsage", ctypes.c_size_t),
+ ("PagefileUsage", ctypes.c_size_t), ("PeakPagefileUsage", ctypes.c_size_t),
+ ]
+ counters = PROCESS_MEMORY_COUNTERS()
+ counters.cb = ctypes.sizeof(PROCESS_MEMORY_COUNTERS)
+ h = ctypes.windll.kernel32.OpenProcess(0x1000, False, pid)
+ if not h:
+ return None
+ ok = ctypes.windll.psapi.GetProcessMemoryInfo(h, ctypes.byref(counters), counters.cb)
+ ctypes.windll.kernel32.CloseHandle(h)
+ return round(counters.WorkingSetSize / 1024 / 1024) if ok else None
+ status = Path(f"/proc/{pid}/status")
+ if status.is_file():
+ for line in status.read_text(encoding="utf-8", errors="replace").splitlines():
+ if line.startswith("VmRSS:"):
+ return round(int(line.split()[1]) / 1024)
+ return None
+
+
+def _cpu_pct(pid: Optional[int]) -> Optional[float]:
+ if not pid:
+ return None
+ try:
+ import psutil
+ return round(psutil.Process(pid).cpu_percent(0) or 0, 1)
+ except Exception:
+ return None
+
+
+class ServiceManager:
+ """hub.pyw ServiceManager + HTTP/WS glue."""
+
+ def __init__(self, ga_root: str, emit_fn):
+ self.ga_root = Path(ga_root)
+ self.procs: Dict[str, subprocess.Popen] = {}
+ self.buffers: Dict[str, deque] = {}
+ self._emit = emit_fn
+ im = discover_im_services(self.ga_root)
+ extra = discover_extra_services(self.ga_root)
+ self._im_catalog = {s["id"]: s for s in im}
+ self._catalog = {**self._im_catalog, **{s["id"]: s for s in extra}}
+ self._stopping: Set[str] = set()
+
+ def _is_configured(self, sid: str) -> bool:
+ keys = _SERVICE_KEYS.get(sid)
+ if not keys:
+ return True
+ mykeys = _load_mykeys(self.ga_root)
+ return all(str(mykeys.get(k) or "").strip() for k in keys)
+
+ def _log_tail(self, sid: str, n: int = 3) -> str:
+ buf = self.buffers.get(sid)
+ if not buf:
+ return ""
+ lines = [ln.strip() for ln in list(buf)[-n:] if ln.strip()]
+ return lines[-1][:300] if lines else ""
+
+ def _state(self, sid: str, *, err: str = "") -> dict:
+ proc = self.procs.get(sid)
+ running = proc is not None and proc.poll() is None
+ status = "running" if running else "offline"
+ last_error = err
+ if proc is not None and proc.poll() is not None:
+ if sid in self._stopping:
+ status, last_error = "offline", ""
+ else:
+ status = "error"
+ last_error = err or self._log_tail(sid) or f"exit code {proc.returncode}"
+ elif err:
+ status, running = "error", False
+ return {
+ "id": sid,
+ "status": status,
+ "running": running,
+ "pid": proc.pid if running else None,
+ "lastError": last_error,
+ }
+
+ def list_state(self) -> List[dict]:
+ return [self._state(sid) for sid in sorted(self._im_catalog)]
+
+ def _bridge_state(self) -> dict:
+ pid = os.getpid()
+ port = int(os.environ.get("BRIDGE_PORT", "14168"))
+ return {
+ "id": BRIDGE_ID,
+ "name": f"bridge (:{port})",
+ "status": "running",
+ "running": True,
+ "pid": pid,
+ "memMb": _mem_mb(pid),
+ "cpuPct": _cpu_pct(pid),
+ "managed": False,
+ "lastError": "",
+ }
+
+ def list_panel_state(self) -> List[dict]:
+ out = [self._bridge_state()]
+ for sid in sorted(self._catalog, key=lambda s: (s in self._im_catalog, s)):
+ item = self._state(sid)
+ item["name"] = sid
+ item["memMb"] = _mem_mb(item.get("pid"))
+ item["cpuPct"] = _cpu_pct(item.get("pid"))
+ item["managed"] = True
+ out.append(item)
+ return out
+
+ def _notify(self, sid: str, *, err: str = "") -> None:
+ self._emit({"type": "service.changed", "service": self._state(sid, err=err)})
+
+ def _wait_started(self, proc: subprocess.Popen, timeout: float = 2.0) -> None:
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ if proc.poll() is not None:
+ return
+ time.sleep(0.1)
+
+ def _reader(self, sid: str, proc: subprocess.Popen) -> None:
+ assert proc.stdout is not None
+ for line in proc.stdout:
+ buf = self.buffers.get(sid)
+ if buf is not None:
+ buf.append(line)
+ self._notify(sid)
+
+ def start_service(self, sid: str) -> dict:
+ svc = self._catalog.get(sid)
+ if not svc:
+ raise KeyError(sid)
+ proc = self.procs.get(sid)
+ if proc is not None and proc.poll() is None:
+ return {"ok": True, "service": self._state(sid)}
+ if not self._is_configured(sid):
+ keys = ", ".join(_SERVICE_KEYS.get(sid, ()))
+ err = f"not configured in mykey.py ({keys})"
+ self._notify(sid, err=err)
+ return {"ok": False, "error": "not_configured", "service": self._state(sid, err=err)}
+ self.buffers[sid] = deque(maxlen=500)
+ env = {**os.environ, "PYTHONUNBUFFERED": "1", "PYTHONIOENCODING": "utf-8"}
+ kw: Dict[str, Any] = dict(
+ cwd=str(self.ga_root), stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
+ text=True, encoding="utf-8", errors="replace", bufsize=1, env=env,
+ )
+ if sys.platform == "win32":
+ kw["creationflags"] = subprocess.CREATE_NO_WINDOW
+ proc = subprocess.Popen(svc["cmd"], **kw)
+ self.procs[sid] = proc
+ threading.Thread(target=self._reader, args=(sid, proc), daemon=True).start()
+ self._wait_started(proc)
+ item = self._state(sid)
+ self._notify(sid)
+ if item["status"] == "error":
+ return {"ok": False, "error": item["lastError"] or "start_failed", "service": item}
+ return {"ok": True, "service": item}
+
+ def autostart_extras(self) -> None:
+ """Auto-start non-IM services on bridge boot. Currently:
+ - reflect/scheduler.py (drives L4 archive cron every 12h).
+ IM services stay manual (need explicit mykey.py config + user opt-in)."""
+ for sid in sorted(set(self._catalog) - set(self._im_catalog)):
+ try:
+ res = self.start_service(sid)
+ tag = "ok" if res.get("ok") else f"fail: {res.get('error')}"
+ except Exception as e:
+ tag = f"exception {type(e).__name__}: {e}"
+ print(f"[autostart] {sid}: {tag}", file=sys.stderr)
+
+ def stop_all_extras(self) -> None:
+ for sid in sorted(set(self._catalog) - set(self._im_catalog)):
+ with contextlib.suppress(Exception):
+ self.stop_service(sid)
+
+ def stop_service(self, sid: str) -> dict:
+ if sid not in self._catalog:
+ raise KeyError(sid)
+ self._stopping.add(sid)
+ proc = self.procs.get(sid)
+ if proc and proc.poll() is None:
+ proc.terminate()
+ proc.wait()
+ self.procs.pop(sid, None)
+ self._stopping.discard(sid)
+ item = self._state(sid)
+ self._notify(sid)
+ return {"ok": True, "service": item}
+
+ def read_logs(self, sid: str, tail: int = 200) -> dict:
+ if sid == BRIDGE_ID:
+ return {"ok": True, "lines": [f"GenericAgent bridge pid={os.getpid()}"]}
+ if sid not in self._catalog:
+ raise KeyError(sid)
+ tail = max(1, min(int(tail or 200), 2000))
+ buf = self.buffers.get(sid)
+ lines = [ln.rstrip("\n") for ln in list(buf or [])[-tail:]]
+ return {"ok": True, "lines": lines}
+
+
+services = ServiceManager(str(DEFAULT_GA_ROOT), hub.emit)
+
+
+def _bridge_shutdown_services() -> None:
+ with contextlib.suppress(Exception):
+ services.stop_all_extras()
+
+
+atexit.register(_bridge_shutdown_services)
+
+
+def emit_session_state(sess: Session, state_name: str):
+ hub.emit({
+ "type": "session-state",
+ "sessionId": sess.id,
+ "state": state_name,
+ "status": sess.status,
+ "seq": sess.msg_seq,
+ "updatedAt": sess.updated_at,
+ "title": sess.title,
+ })
+
+
+async def ws_handler(request):
+ ws = web.WebSocketResponse(heartbeat=30)
+ await ws.prepare(request)
+ hub.websockets.add(ws)
+ await ws.send_str(json.dumps({
+ "type": "bridge-ready",
+ "gaRoot": manager.ga_root,
+ "mykeyPath": manager.mykey_path,
+ "http": True,
+ "wsEventsOnly": True,
+ }, ensure_ascii=False))
+ await ws.send_str(json.dumps({
+ "type": "services.snapshot",
+ "services": services.list_state(),
+ }, ensure_ascii=False, default=str))
+ async for msg in ws:
+ if msg.type == WSMsgType.TEXT:
+ # WS is intentionally not a data/command channel anymore.
+ with contextlib.suppress(Exception):
+ data = json.loads(msg.data)
+ if data.get("action") == "ping":
+ await ws.send_str(json.dumps({"type": "pong", "ts": time.time()}, ensure_ascii=False))
+ hub.websockets.discard(ws)
+ return ws
+
+
+# ---------------------------------------------------------------------------
+# Transport layer: HTTP command/data API
+# ---------------------------------------------------------------------------
+
+def cors_headers():
+ return {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type",
+ }
+
+
+@web.middleware
+async def cors_middleware(request, handler):
+ if request.method == "OPTIONS":
+ return web.Response(status=204, headers=cors_headers())
+ resp = await handler(request)
+ for k, v in cors_headers().items():
+ resp.headers[k] = v
+ return resp
+
+
+def json_ok(data: dict, status: int = 200):
+ return web.json_response(data, status=status, headers=cors_headers(), dumps=lambda x: json.dumps(x, ensure_ascii=False, default=str))
+
+
+async def read_json(request) -> dict:
+ if request.can_read_body:
+ try:
+ data = await request.json()
+ return data if isinstance(data, dict) else {}
+ except Exception:
+ return {}
+ return {}
+
+
+async def status_handler(request):
+ return json_ok({
+ "ok": True,
+ "running": True,
+ "ready": True,
+ "gaRoot": manager.ga_root,
+ "mykeyPath": manager.mykey_path,
+ "sessionCount": len(manager.sessions),
+ "activeSessionId": manager.active_session_id,
+ "ws": "/ws",
+ "transport": {"http": True, "wsEventsOnly": True},
+ })
+
+
+_SETTINGS = Path.home() / ".ga_desktop_settings.json"
+_UI_KEYS = ("lang", "theme", "appearance", "plain", "llmNo", "fontSize")
+
+
+def _desktop_ui() -> dict:
+ try:
+ ui = json.loads(_SETTINGS.read_text(encoding="utf-8")).get("ui")
+ return dict(ui) if isinstance(ui, dict) else {}
+ except Exception:
+ return {}
+
+
+async def get_config_handler(request):
+ profiles = manager.list_model_profiles()
+ active = next((p["id"] for p in profiles if p.get("active")), manager.config.get("llmNo", 0))
+ cfg = dict(manager.config)
+ if "llmNo" not in cfg:
+ cfg["llmNo"] = active
+ cfg.update(_desktop_ui())
+ return json_ok({"gaRoot": manager.ga_root, "mykeyPath": manager.mykey_path, "config": cfg})
+
+
+async def save_config_handler(request):
+ data = await read_json(request)
+ cfg = data.get("config", data)
+ if isinstance(cfg, dict):
+ patch = {k: cfg[k] for k in _UI_KEYS if k in cfg}
+ if patch:
+ try:
+ doc = json.loads(_SETTINGS.read_text(encoding="utf-8")) if _SETTINGS.is_file() else {}
+ if not isinstance(doc, dict):
+ doc = {}
+ ui = doc["ui"] if isinstance(doc.get("ui"), dict) else {}
+ ui.update(patch)
+ doc["ui"] = ui
+ _SETTINGS.write_text(json.dumps(doc, ensure_ascii=False, indent=2), encoding="utf-8")
+ except Exception as e:
+ print(f"[bridge] save ui prefs failed: {e}", file=sys.stderr)
+ manager.config.update(cfg)
+ return json_ok({"ok": True, "gaRoot": manager.ga_root, "mykeyPath": manager.mykey_path, "config": manager.config})
+
+
+async def model_profiles_handler(request):
+ try:
+ pid = request.match_info.get("id")
+ if pid is not None:
+ profile_id = int(pid)
+ if request.method == "GET":
+ return json_ok({"profile": manager.get_model_profile(profile_id)})
+ if request.method == "PUT":
+ return json_ok({"ok": True, **manager.update_model_profile(profile_id, await read_json(request))})
+ if request.method == "DELETE":
+ return json_ok({"ok": True, **manager.delete_model_profile(profile_id)})
+ return json_ok({"ok": False, "error": "method not allowed"}, status=405)
+ if request.method == "POST":
+ return json_ok({"ok": True, **manager.add_model_profile(await read_json(request))})
+ return json_ok({"profiles": manager.list_model_profiles()})
+ except ValueError as e:
+ return json_ok({"ok": False, "error": str(e)}, status=400)
+ except Exception as e:
+ return json_ok({"ok": False, "error": str(e)}, status=500)
+
+
+async def mixin_handler(request):
+ """聚合渠道成员管理:POST 加入 / DELETE 移出 主聚合渠道。"""
+ try:
+ profile_id = int(request.match_info.get("id"))
+ if request.method == "POST":
+ return json_ok({"ok": True, **manager.add_to_mixin(profile_id)})
+ if request.method == "DELETE":
+ return json_ok({"ok": True, **manager.remove_from_mixin(profile_id)})
+ return json_ok({"ok": False, "error": "method not allowed"}, status=405)
+ except ValueError as e:
+ return json_ok({"ok": False, "error": str(e)}, status=400)
+ except Exception as e:
+ return json_ok({"ok": False, "error": str(e)}, status=500)
+
+
+async def mixin_order_handler(request):
+ """渠道组成员拖拽排序:PUT {members:[name,...]}。"""
+ try:
+ data = await read_json(request)
+ return json_ok({"ok": True, **manager.reorder_mixin(data.get("members") or [])})
+ except ValueError as e:
+ return json_ok({"ok": False, "error": str(e)}, status=400)
+ except Exception as e:
+ return json_ok({"ok": False, "error": str(e)}, status=500)
+
+
+async def list_sessions_handler(request):
+ with manager.lock:
+ sessions = [manager.snapshot(s, include_messages=False) for s in manager.sessions.values()]
+ return json_ok({"sessions": sessions, "activeSessionId": manager.active_session_id})
+
+
+async def new_session_handler(request):
+ data = await read_json(request)
+ sess = manager.create_session(cwd=data.get("cwd") or data.get("path"))
+ return json_ok({"ok": True, "sessionId": sess.id, "session": manager.snapshot(sess)}, status=201)
+
+
+async def get_session_handler(request):
+ sid = request.match_info["sid"]
+ sess = manager.get_session(sid)
+ return json_ok({"sessionId": sid, "session": manager.snapshot(sess), "messages": list(sess.messages), "partial": sess.partial})
+
+
+async def delete_session_handler(request):
+ sid = request.match_info["sid"]
+ return json_ok(manager.delete_session(sid))
+
+
+async def patch_session_handler(request):
+ sid = request.match_info["sid"]
+ sess = manager.get_session(sid)
+ data = await read_json(request)
+ if "title" in data:
+ sess.title = data["title"]
+ sess.untitled = False
+ if "pinned" in data:
+ sess.pinned = bool(data["pinned"])
+ if "untitled" in data:
+ sess.untitled = bool(data["untitled"])
+ if "plan_scan_baseline" in data:
+ sess.plan_scan_baseline = int(data["plan_scan_baseline"])
+ sess.updated_at = time.time()
+ manager._persist()
+ return json_ok({"ok": True, "session": manager.snapshot(sess, include_messages=False)})
+
+
+async def prompt_handler(request):
+ sid = request.match_info["sid"]
+ data = await read_json(request)
+ prompt = data.get("prompt", data.get("content", data.get("message", "")))
+ images = data.get("images") or []
+ display = data.get("display")
+ files_meta = data.get("files") or [] # 非图片附件 [{name, path}]
+ image_metas = data.get("imageMetas") or [] # 图片附件 [{name, path}](不含 dataUrl)
+ llm_no = data.get("llmNo")
+ if llm_no is not None:
+ llm_no = int(llm_no)
+ return json_ok(manager.submit_prompt(sid, prompt, images, llm_no=llm_no, display=display,
+ files_meta=files_meta, image_metas=image_metas))
+
+
+async def messages_handler(request):
+ sid = request.match_info["sid"]
+ after = int(request.query.get("after") or request.query.get("afterId") or 0)
+ limit = int(request.query.get("limit") or 200)
+ return json_ok(manager.messages(sid, after=after, limit=limit))
+
+
+async def cancel_handler(request):
+ sid = request.match_info["sid"]
+ return json_ok(manager.cancel(sid))
+
+
+async def restore_handler(request):
+ sid = request.match_info["sid"]
+ return json_ok(manager.restore_context(sid))
+
+
+async def plan_handler(request):
+ sid = request.match_info["sid"]
+ return json_ok(manager.plan_snapshot(sid))
+
+
+async def path_open_handler(request):
+ data = await read_json(request)
+ kind = data.get("kind", "")
+ mode = data.get("mode", "open")
+ if kind == "mykey":
+ target = Path(manager.ga_root) / "mykey.py"
+ if not target.exists():
+ template = Path(manager.ga_root) / "mykey_template.py"
+ target = template if template.exists() else target
+ elif kind == "mykeyTemplate":
+ target = Path(manager.ga_root) / "mykey_template.py"
+ elif kind == "upload":
+ raw = Path(data.get("path") or "")
+ try:
+ resolved = raw.resolve()
+ upload_root = _WEB_UPLOAD_DIR.resolve()
+ resolved.relative_to(upload_root)
+ except (ValueError, OSError):
+ return json_ok({"ok": False, "error": "path not in upload dir"}, status=403)
+ target = resolved
+ else:
+ target = Path(data.get("path") or data.get("target") or manager.ga_root)
+ target = target.resolve()
+ if not target.exists():
+ return json_ok({"ok": False, "error": f"File not found: {target}"}, status=404)
+ try:
+ if mode == "reveal":
+ _reveal_path_in_file_manager(target)
+ elif kind == "upload":
+ _open_path_default(target) # 用户文件用系统默认程序(open 动词),避免 edit 动词 fallback 记事本
+ else:
+ _open_path_in_editor(target) # mykey 等配置文件仍用编辑器(edit 动词)
+ except OSError as e:
+ return json_ok({"ok": False, "error": str(e), "path": str(target)}, status=500)
+ return json_ok({"ok": True, "path": str(target)})
+
+
+# File attachments live under GA's own temp dir (gitignored), NOT the OS temp
+# dir, so they survive bridge restarts. Instead of wiping everything on startup,
+# we keep files for UPLOAD_RETENTION_DAYS and only sweep stale ones.
+_WEB_UPLOAD_DIR = Path(DEFAULT_GA_ROOT) / "temp" / "desktop_uploads"
+_WEB_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
+
+UPLOAD_RETENTION_DAYS = 30
+
+
+def _safe_session_dir(sid: str) -> str:
+ """Sanitize a session id into a safe single-level folder name."""
+ s = re.sub(r"[^A-Za-z0-9_-]", "", str(sid or ""))
+ return s or "_misc"
+
+
+def _session_upload_dir(sid: str) -> Path:
+ """Per-session upload subdir under desktop_uploads/, created on demand."""
+ d = _WEB_UPLOAD_DIR / _safe_session_dir(sid)
+ d.mkdir(parents=True, exist_ok=True)
+ return d
+
+
+def _purge_session_uploads(sid: str) -> None:
+ """Best-effort: drop a session's whole upload subdir when the session is deleted."""
+ import shutil
+ with contextlib.suppress(Exception):
+ shutil.rmtree(_WEB_UPLOAD_DIR / _safe_session_dir(sid), ignore_errors=True)
+
+
+def _sweep_stale_uploads(retention_days: int = UPLOAD_RETENTION_DAYS) -> None:
+ """Best-effort: delete uploaded files older than retention_days (by mtime),
+ then drop empty session subdirs. Replaces the old wholesale rmtree-on-startup
+ so attachments persist across restarts while temp storage can't grow forever."""
+ cutoff = time.time() - retention_days * 86400
+ try:
+ for f in _WEB_UPLOAD_DIR.rglob("*"):
+ try:
+ if f.is_file() and f.stat().st_mtime < cutoff:
+ f.unlink()
+ except OSError:
+ pass
+ for d in _WEB_UPLOAD_DIR.iterdir():
+ try:
+ if d.is_dir() and not any(d.iterdir()):
+ d.rmdir()
+ except OSError:
+ pass
+ except OSError:
+ pass
+
+
+_sweep_stale_uploads()
+
+
+async def upload_handler(request):
+ """Save a file uploaded by the web client and return its absolute path.
+ Body: {name: "", dataUrl: "data:;base64,<...>", sid: ""}
+ Files are grouped per session under desktop_uploads// so deleting a
+ session can purge its attachments. Missing sid falls back to a _misc bucket.
+ Returns: {ok: true, path: ""}
+ """
+ try:
+ data = await request.json()
+ if not isinstance(data, dict):
+ data = {}
+ except web.HTTPRequestEntityTooLarge:
+ return json_ok({"ok": False, "error": "file too large for bridge body limit"})
+ except Exception as e:
+ return json_ok({"ok": False, "error": f"invalid request: {e}"})
+ name = (data.get("name") or "file").strip().replace("/", "_").replace("\\", "_")
+ data_url = data.get("dataUrl") or ""
+ if "," in data_url:
+ b64 = data_url.split(",", 1)[1]
+ else:
+ b64 = data_url
+ try:
+ blob = base64.b64decode(b64)
+ except Exception as e:
+ return json_ok({"ok": False, "error": f"decode failed: {e}"})
+ if not blob:
+ return json_ok({"ok": False, "error": "empty file"})
+ safe_name = name or "file"
+ fpath = _session_upload_dir(data.get("sid") or "") / f"{uuid.uuid4().hex[:12]}__{safe_name}"
+ fpath.write_bytes(blob)
+ return json_ok({"ok": True, "path": str(fpath)})
+
+
+async def upload_delete_handler(request):
+ """Delete a previously-uploaded file. Path must live under _WEB_UPLOAD_DIR."""
+ data = await read_json(request)
+ raw = data.get("path") or ""
+ try:
+ target = Path(raw).resolve()
+ upload_root = _WEB_UPLOAD_DIR.resolve()
+ if upload_root not in target.parents:
+ return json_ok({"ok": False, "error": "path outside upload dir"})
+ if target.exists():
+ target.unlink()
+ return json_ok({"ok": True})
+ except Exception as e:
+ return json_ok({"ok": False, "error": str(e)})
+
+
+async def upload_raw_handler(request):
+ """Stream an uploaded file. inline by default (browser preview / ),
+ ?download=1 forces a download. Path must live under _WEB_UPLOAD_DIR
+ (whitelist — prevents path traversal). Works for remote browsers too,
+ so it covers both 'preview after refresh' and 'download from remote'."""
+ import mimetypes
+ from urllib.parse import quote
+ raw = request.query.get("path", "")
+ try:
+ target = Path(raw).resolve()
+ target.relative_to(_WEB_UPLOAD_DIR.resolve())
+ except (ValueError, OSError):
+ return web.Response(status=403, text="path not in upload dir")
+ if not target.is_file():
+ return web.Response(status=404, text="file not found")
+ ctype = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
+ disp = "attachment" if request.query.get("download") in ("1", "true") else "inline"
+ orig_name = target.name.split("__", 1)[-1] # 去掉 __ 前缀,还原原始文件名
+ return web.Response(
+ body=target.read_bytes(),
+ content_type=ctype,
+ headers={
+ "Content-Disposition": f"{disp}; filename*=UTF-8''{quote(orig_name)}",
+ "Cache-Control": "no-cache",
+ },
+ )
+
+
+def _open_path_in_editor(target: Path) -> None:
+ """Open a file in the user's editor; Windows .py often has no default association."""
+ import platform
+ path = str(target.resolve())
+ if platform.system() == "Windows":
+ try:
+ os.startfile(path, "edit")
+ return
+ except OSError:
+ pass
+ for cmd in (["notepad.exe", path], ["cursor.cmd", path], ["code.cmd", path], ["cursor", path], ["code", path]):
+ try:
+ subprocess.Popen(cmd, close_fds=True)
+ return
+ except (FileNotFoundError, OSError):
+ continue
+ raise OSError(f"No editor available to open: {path}")
+ if platform.system() == "Darwin":
+ subprocess.Popen(["open", path])
+ return
+ subprocess.Popen(["xdg-open", path])
+
+
+def _reveal_path_in_file_manager(target: Path) -> None:
+ """Open the system file manager and select/highlight the target file."""
+ import platform
+ path = str(target.resolve())
+ if platform.system() == "Windows":
+ subprocess.Popen(["explorer", "/select,", path])
+ return
+ if platform.system() == "Darwin":
+ subprocess.Popen(["open", "-R", path])
+ return
+ # Linux: no universal "select file" command; fall back to opening parent dir
+ subprocess.Popen(["xdg-open", str(target.parent)])
+
+
+def _open_path_default(target: Path) -> None:
+ """Open a file with the OS default app (default 'open' verb).
+
+ For user uploads. Unlike _open_path_in_editor (which uses Windows' 'edit'
+ verb and falls back to Notepad), this respects each file type's registered
+ default app — PDF viewer, Word, archive tool, etc. — so binaries like pdf
+ or docx no longer land in Notepad as garbage."""
+ import platform
+ path = str(target.resolve())
+ if platform.system() == "Windows":
+ os.startfile(path) # default "open" verb = double-click behavior
+ return
+ if platform.system() == "Darwin":
+ subprocess.Popen(["open", path])
+ return
+ subprocess.Popen(["xdg-open", path])
+
+
+def _mykey_file() -> Path:
+ root = Path(manager.ga_root)
+ target = root / "mykey.py"
+ if not target.is_file():
+ template = root / "mykey_template.py"
+ if template.is_file():
+ target.write_text(template.read_text(encoding="utf-8"), encoding="utf-8")
+ return target
+
+
+async def mykey_get_handler(request):
+ target = _mykey_file()
+ content = target.read_text(encoding="utf-8") if target.is_file() else ""
+ return json_ok({"content": content, "path": str(target)})
+
+
+async def mykey_save_handler(request):
+ data = await read_json(request)
+ content = data.get("content")
+ if content is None:
+ return json_ok({"ok": False, "error": "missing_content"}, status=400)
+ try:
+ profiles = manager._save_mykey_text(str(content))
+ except Exception as e:
+ return json_ok({"ok": False, "error": str(e)}, status=400)
+ return json_ok({"ok": True, "path": str(manager._mykey_file()), "profiles": profiles})
+
+
+async def service_start_handler(request):
+ body = await read_json(request)
+ sid = body.get("id") or request.query.get("id")
+ if not sid:
+ return json_ok({"ok": False, "error": "missing_id"}, status=400)
+ result = services.start_service(sid)
+ if not result.get("ok"):
+ return json_ok(result, status=400)
+ return json_ok(result)
+
+
+async def service_stop_handler(request):
+ body = await read_json(request)
+ sid = body.get("id") or request.query.get("id")
+ if not sid:
+ return json_ok({"ok": False, "error": "missing_id"}, status=400)
+ return json_ok(services.stop_service(sid))
+
+
+async def service_logs_handler(request):
+ sid = request.query.get("id")
+ if not sid:
+ return json_ok({"ok": False, "error": "missing_id"}, status=400)
+ tail = int(request.query.get("tail") or 200)
+ return json_ok(services.read_logs(sid, tail=tail))
+
+
+async def service_panel_handler(request):
+ return json_ok({"services": services.list_panel_state()})
+
+
+def _is_local_peer(peer: str) -> bool:
+ p = (peer or "").strip()
+ return p in ("127.0.0.1", "::1") or p.startswith("::ffff:127.0.0.1")
+
+
+async def stop_extras_handler(request):
+ if not _is_local_peer(request.remote or ""):
+ return json_ok({"ok": False, "error": "forbidden"}, status=403)
+ services.stop_all_extras()
+ return json_ok({"ok": True})
+
+
+async def start_extras_handler(request):
+ if not _is_local_peer(request.remote or ""):
+ return json_ok({"ok": False, "error": "forbidden"}, status=403)
+ services.autostart_extras()
+ return json_ok({"ok": True})
+
+
+async def identity_handler(request):
+ return json_ok({"ga_root": str(DEFAULT_GA_ROOT), "app_dir": str(APP_DIR), "pid": os.getpid(),
+ "build_id": os.environ.get("GA_BUILD_ID", "")})
+
+
+def _exit_bridge() -> None:
+ with contextlib.suppress(Exception):
+ services.stop_all_extras()
+ threading.Timer(0.4, lambda: os._exit(0)).start()
+
+
+async def bridge_exit_handler(request):
+ if not _is_local_peer(request.remote or ""):
+ return json_ok({"ok": False, "error": "forbidden"}, status=403)
+ _exit_bridge()
+ return json_ok({"ok": True})
+
+
+async def token_stats_handler(request):
+ try:
+ sys.path.insert(0, str(APP_DIR)) if str(APP_DIR) not in sys.path else None
+ import cost_tracker
+ trackers = cost_tracker.all_trackers()
+ records = []
+ for k, v in trackers.items():
+ model = ''
+ sid = k.replace('GA-', '')
+ with manager.lock:
+ sess = manager.sessions.get(sid)
+ if sess and sess.agent:
+ try: model = sess.agent.get_llm_name(model=True) or ''
+ except Exception: pass
+ records.append({"thread": k, "input": v.input, "output": v.output,
+ "cacheCreate": v.cache_create, "cacheRead": v.cache_read, "model": model})
+ except Exception:
+ records = []
+ return json_ok({"records": records})
+
+
+_TOKEN_HISTORY_FILE = None
+
+def _tok_file() -> Path:
+ global _TOKEN_HISTORY_FILE
+ if _TOKEN_HISTORY_FILE is None:
+ _TOKEN_HISTORY_FILE = Path(manager.ga_root) / "temp" / "desktop_token_history.json"
+ return _TOKEN_HISTORY_FILE
+
+async def get_token_history_handler(request):
+ f = _tok_file()
+ if f.is_file():
+ try:
+ data = json.loads(f.read_text(encoding="utf-8"))
+ return json_ok(data)
+ except Exception:
+ pass
+ return json_ok({"history": [], "snap": {}})
+
+async def post_token_history_handler(request):
+ data = await read_json(request)
+ f = _tok_file()
+ f.parent.mkdir(parents=True, exist_ok=True)
+ f.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
+ return json_ok({"ok": True})
+
+
+def create_app():
+ app = web.Application(middlewares=[cors_middleware], client_max_size=500 * 1024 * 1024)
+ app.router.add_get("/ws", ws_handler)
+ app.router.add_get("/status", status_handler)
+ app.router.add_get("/config", get_config_handler)
+ app.router.add_post("/config", save_config_handler)
+ app.router.add_get("/model-profiles", model_profiles_handler)
+ app.router.add_post("/model-profiles", model_profiles_handler)
+ app.router.add_put("/model-profiles/mixin/order", mixin_order_handler)
+ app.router.add_post("/model-profiles/{id}/mixin", mixin_handler)
+ app.router.add_delete("/model-profiles/{id}/mixin", mixin_handler)
+ app.router.add_get("/model-profiles/{id}", model_profiles_handler)
+ app.router.add_put("/model-profiles/{id}", model_profiles_handler)
+ app.router.add_delete("/model-profiles/{id}", model_profiles_handler)
+ app.router.add_get("/sessions", list_sessions_handler)
+ app.router.add_post("/session/new", new_session_handler)
+ app.router.add_get("/session/{sid}", get_session_handler)
+ app.router.add_delete("/session/{sid}", delete_session_handler)
+ app.router.add_patch("/session/{sid}", patch_session_handler)
+ app.router.add_post("/session/{sid}/prompt", prompt_handler)
+ app.router.add_get("/session/{sid}/messages", messages_handler)
+ app.router.add_get("/session/{sid}/plan", plan_handler)
+ app.router.add_post("/session/{sid}/cancel", cancel_handler)
+ app.router.add_post("/session/{sid}/restore", restore_handler)
+ app.router.add_post("/path/open", path_open_handler)
+ app.router.add_post("/upload", upload_handler)
+ app.router.add_delete("/upload", upload_delete_handler)
+ app.router.add_get("/upload/raw", upload_raw_handler)
+ app.router.add_get("/token-stats", token_stats_handler)
+ app.router.add_get("/token-history", get_token_history_handler)
+ app.router.add_post("/token-history", post_token_history_handler)
+ app.router.add_post("/services/start", service_start_handler)
+ app.router.add_post("/services/stop", service_stop_handler)
+ app.router.add_get("/services/logs", service_logs_handler)
+ app.router.add_get("/services/panel", service_panel_handler)
+ app.router.add_get("/services/mykey", mykey_get_handler)
+ app.router.add_post("/services/mykey", mykey_save_handler)
+ app.router.add_post("/services/stop-extras", stop_extras_handler)
+ app.router.add_post("/services/start-extras", start_extras_handler)
+ app.router.add_get("/services/identity", identity_handler)
+ app.router.add_post("/services/bridge/exit", bridge_exit_handler)
+
+ # Serve static frontend (desktop/static/)
+ static_dir = APP_DIR / "desktop" / "static"
+
+ async def index_handler(request):
+ return web.FileResponse(
+ static_dir / "index.html",
+ headers={"Cache-Control": "no-cache, no-store, must-revalidate"},
+ )
+
+ app.router.add_get("/", index_handler)
+ app.router.add_static("/", static_dir, show_index=False)
+
+ async def on_startup(app):
+ hub.loop = asyncio.get_running_loop()
+ services.autostart_extras()
+
+ async def on_shutdown(app):
+ services.stop_all_extras()
+
+ app.on_startup.append(on_startup)
+ app.on_shutdown.append(on_shutdown)
+ return app
+
+
+if __name__ == "__main__":
+ host = os.environ.get("BRIDGE_HOST", "127.0.0.1")
+ port = int(os.environ.get("BRIDGE_PORT", "14168"))
+ print(f"GenericAgent Web2 bridge: http://{host}:{port} ws://{host}:{port}/ws", file=sys.stderr)
+ web.run_app(create_app(), host=host, port=port, print=None)
diff --git a/frontends/desktop_pet_v2.pyw b/frontends/desktop_pet_v2.pyw
new file mode 100644
index 000000000..653aef6b5
--- /dev/null
+++ b/frontends/desktop_pet_v2.pyw
@@ -0,0 +1,1090 @@
+"""Desktop Pet with Skin System — Cross-platform with True Transparency"""
+import os, re, sys, json, threading, io
+from http.server import HTTPServer, BaseHTTPRequestHandler
+from urllib.parse import urlparse, parse_qs
+from PIL import Image, ImageDraw, ImageFont, ImageOps
+
+PORT = 41983
+SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
+PROJECT_DIR = os.path.dirname(SCRIPT_DIR)
+SKINS_DIR = os.path.join(SCRIPT_DIR, 'skins')
+
+class SkinLoader:
+ """Load and parse skin configuration"""
+ @staticmethod
+ def load_skin(skin_path):
+ """Load skin.json and return skin config"""
+ config_file = os.path.join(skin_path, 'skin.json')
+ if not os.path.exists(config_file):
+ raise FileNotFoundError(f"skin.json not found in {skin_path}")
+
+ with open(config_file, 'r', encoding='utf-8') as f:
+ config = json.load(f)
+
+ if 'animations' not in config:
+ raise ValueError("skin.json must contain 'animations' field")
+
+ config['path'] = skin_path
+ return config
+
+ @staticmethod
+ def list_skins():
+ """List all available skins"""
+ if not os.path.exists(SKINS_DIR):
+ return []
+
+ skins = []
+ for item in os.listdir(SKINS_DIR):
+ skin_path = os.path.join(SKINS_DIR, item)
+ if os.path.isdir(skin_path):
+ config_file = os.path.join(skin_path, 'skin.json')
+ if os.path.exists(config_file):
+ skins.append(item)
+ return skins
+
+class AnimationLoader:
+ """Load animation frames from sprite sheet"""
+ @staticmethod
+ def load_sprite_frames(skin_path, anim_config):
+ """Load frames from sprite sheet"""
+ file_path = os.path.join(skin_path, anim_config['file'])
+ sprite_config = anim_config['sprite']
+
+ img = Image.open(file_path)
+ frames = []
+
+ frame_width = sprite_config['frameWidth']
+ frame_height = sprite_config['frameHeight']
+ frame_count = sprite_config['frameCount']
+ columns = sprite_config['columns']
+ start_frame = sprite_config.get('startFrame', 0)
+
+ for i in range(frame_count):
+ frame_idx = start_frame + i
+ row = frame_idx // columns
+ col = frame_idx % columns
+
+ x = col * frame_width
+ y = row * frame_height
+
+ frame = img.crop((x, y, x + frame_width, y + frame_height))
+ frames.append(frame)
+
+ return frames
+
+
+def _load_default_font(size):
+ """Load a usable font for bubble text."""
+ font_candidates = [
+ '/System/Library/Fonts/Supplemental/Arial Unicode.ttf',
+ '/System/Library/Fonts/PingFang.ttc',
+ '/System/Library/Fonts/STHeiti Light.ttc',
+ 'C:/Windows/Fonts/msyh.ttc',
+ 'C:/Windows/Fonts/simhei.ttf',
+ 'C:/Windows/Fonts/arial.ttf',
+ '/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc',
+ '/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
+ '/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc',
+ '/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf',
+ '/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc',
+ ]
+ for font_path in font_candidates:
+ if os.path.exists(font_path):
+ try:
+ return ImageFont.truetype(font_path, size=size)
+ except Exception:
+ pass
+ return ImageFont.load_default()
+
+
+def _normalize_bubble_text(text):
+ """Normalize text for fonts that cannot render some symbols."""
+ text = (text or '').strip()
+ lines = text.replace('\r\n', '\n').replace('\r', '\n').split('\n')
+ if lines:
+ turn_match = re.match(r'^\s*🔄?\s*Turn\s+(\d+)\s*$', lines[0], flags=re.IGNORECASE)
+ if turn_match:
+ rest = '\n'.join(line.strip() for line in lines[1:] if line.strip())
+ return f"Turn {turn_match.group(1)}: {rest}" if rest else f"Turn {turn_match.group(1)}:"
+ return text.replace('🔄 Turn', 'Turn').replace('🔄', '').strip()
+
+
+def _wrap_text_for_width(draw, text, font, max_width):
+ """Wrap text to fit inside max_width."""
+ text = _normalize_bubble_text(text)
+ if not text:
+ return ['']
+
+ paragraphs = text.replace('\r\n', '\n').replace('\r', '\n').split('\n')
+ lines = []
+
+ for paragraph in paragraphs:
+ if not paragraph:
+ lines.append('')
+ continue
+
+ current = ''
+ for ch in paragraph:
+ candidate = current + ch
+ bbox = draw.textbbox((0, 0), candidate, font=font)
+ width = bbox[2] - bbox[0]
+ if current and width > max_width:
+ lines.append(current)
+ current = ch
+ else:
+ current = candidate
+ if current:
+ lines.append(current)
+
+ return lines or ['']
+
+
+def build_bubble_image(message, max_width=220):
+ """Build a PIL image for the toast bubble using the user asset when available."""
+ message = (message or '').strip()
+ bubble_path = next((p for p in [os.path.join(SCRIPT_DIR, 'chat_bubble.png'),
+ os.path.join(SCRIPT_DIR, 'bubble.png')]
+ if os.path.exists(p)), None)
+
+ if bubble_path:
+ bubble = Image.open(bubble_path).convert('RGBA')
+ else:
+ bubble = Image.new('RGBA', (256, 128), (255, 255, 255, 0))
+ draw = ImageDraw.Draw(bubble)
+ draw.rounded_rectangle((8, 8, 247, 87), radius=12, fill=(255, 255, 255, 255), outline=(0, 0, 0, 255), width=3)
+ draw.polygon([(48, 87), (72, 87), (56, 112)], fill=(255, 255, 255, 255), outline=(0, 0, 0, 255))
+
+ bubble = ImageOps.contain(bubble, (max_width, max(64, int(max_width * bubble.height / bubble.width))), Image.NEAREST)
+
+ # Detect the actual opaque bubble region to position text correctly
+ alpha = bubble.getchannel('A')
+ content_box = alpha.getbbox() # (left, top, right, bottom) of opaque area
+ if content_box:
+ cb_left, cb_top, cb_right, cb_bottom = content_box
+ else:
+ cb_left, cb_top, cb_right, cb_bottom = 0, 0, bubble.width, bubble.height
+ content_w = cb_right - cb_left
+ content_h = cb_bottom - cb_top
+
+ font_size = max(12, content_h // 6)
+ font = _load_default_font(font_size)
+ draw = ImageDraw.Draw(bubble)
+
+ # Padding relative to the opaque bubble region, not the full image
+ inner_pad_x = max(6, content_w // 14)
+ inner_pad_top = max(4, content_h // 12)
+ inner_pad_bottom = max(12, content_h // 4)
+ text_area_width = max(36, content_w - inner_pad_x * 2)
+
+ lines = _wrap_text_for_width(draw, message, font, text_area_width)
+ ascent, descent = font.getmetrics() if hasattr(font, 'getmetrics') else (font_size, font_size // 4)
+ line_height = max(font_size, ascent + descent)
+ usable_h = content_h - inner_pad_top - inner_pad_bottom
+ max_lines = max(1, usable_h // line_height)
+ if len(lines) > max_lines:
+ lines = lines[:max_lines]
+ if lines:
+ last = lines[-1]
+ while last and draw.textbbox((0, 0), last + '…', font=font)[2] > text_area_width:
+ last = last[:-1]
+ lines[-1] = (last + '…') if last else '…'
+
+ total_text_height = len(lines) * line_height
+ y = cb_top + inner_pad_top + max(0, (usable_h - total_text_height) // 2) - 3
+
+ for line in lines:
+ bbox = draw.textbbox((0, 0), line, font=font)
+ text_width = bbox[2] - bbox[0]
+ x = cb_left + inner_pad_x + (text_area_width - text_width) / 2
+ draw.text((x, y), line, font=font, fill=(32, 32, 32, 255))
+ y += line_height
+
+ alpha = bubble.getchannel('A')
+ bbox = alpha.getbbox()
+ if bbox:
+ bubble = bubble.crop(bbox)
+
+ width, height = bubble.size
+ alpha = bubble.getchannel('A')
+ bottom_y = height - 1
+ tail_x = width // 2
+ for y in range(height - 1, -1, -1):
+ xs = [x for x in range(width) if alpha.getpixel((x, y)) > 0]
+ if xs:
+ bottom_y = y
+ tail_x = xs[len(xs) // 2]
+ break
+
+ return {
+ 'image': bubble,
+ 'size': bubble.size,
+ 'tail_tip': (tail_x, bottom_y),
+ }
+
+# ============================================================================
+# Shared Base Class
+# ============================================================================
+class PetBase:
+ """Shared logic for Mac and Windows pet implementations."""
+
+ def _schedule_main(self, fn):
+ """Schedule fn on the GUI main thread. Subclasses must override."""
+ raise NotImplementedError
+
+ def set_state_safe(self, state):
+ """Thread-safe wrapper for set_state."""
+ self._schedule_main(lambda: self.set_state(state))
+
+ def show_toast_safe(self, message):
+ """Thread-safe wrapper for show_toast."""
+ self._schedule_main(lambda m=message: self.show_toast(m))
+
+ def _start_server(self):
+ """Start HTTP control server."""
+ pet = self
+
+ class Handler(BaseHTTPRequestHandler):
+ def do_GET(self):
+ parsed = urlparse(self.path)
+ params = parse_qs(parsed.query)
+
+ if 'state' in params:
+ state = params['state'][0]
+ pet.set_state_safe(state)
+ self.send_response(200)
+ self.end_headers()
+ self.wfile.write(b'ok')
+ elif 'msg' in params:
+ msg = params['msg'][0]
+ pet.show_toast_safe(msg)
+ self.send_response(200)
+ self.end_headers()
+ self.wfile.write(b'ok')
+ else:
+ self.send_response(400)
+ self.end_headers()
+ self.wfile.write(b'?state=idle/walk/run/sprint or ?msg=hello')
+
+ def do_POST(self):
+ body = self.rfile.read(int(self.headers.get('Content-Length', 0))).decode()
+ if body:
+ pet.show_toast_safe(body)
+ self.send_response(200)
+ self.end_headers()
+ self.wfile.write(b'ok')
+ else:
+ self.send_response(400)
+ self.end_headers()
+ self.wfile.write(b'empty body')
+
+ def log_message(self, *a):
+ pass
+
+ try:
+ HTTPServer.allow_reuse_address = True
+ srv = HTTPServer(('127.0.0.1', PORT), Handler)
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
+ print(f'✓ Server: http://127.0.0.1:{PORT}/?state=walk')
+ except OSError as e:
+ if e.errno == 48:
+ print(f'⚠ Port {PORT} already in use')
+ else:
+ raise
+
+
+# ============================================================================
+# macOS Implementation - Pure Cocoa with True Transparency
+# ============================================================================
+if sys.platform == 'darwin':
+ from Cocoa import (
+ NSApplication, NSWindow, NSImageView, NSImage, NSData, NSTimer,
+ NSMenu, NSMenuItem, NSApp, NSFloatingWindowLevel, NSColor,
+ NSBackingStoreBuffered, NSWindowStyleMaskBorderless,
+ NSApplicationActivationPolicyAccessory
+ )
+ from Foundation import NSMakeRect, NSMakePoint, NSMakeSize
+ from PyObjCTools import AppHelper
+ import objc
+
+ class MacPet(PetBase):
+ def __init__(self, skin_name=None):
+ self.app = NSApplication.sharedApplication()
+ self.app.setActivationPolicy_(NSApplicationActivationPolicyAccessory)
+
+ # Load skin
+ self.load_skin(skin_name)
+ self.available_skins = SkinLoader.list_skins()
+
+ # Get screen size
+ from AppKit import NSScreen, NSWindowCollectionBehaviorCanJoinAllSpaces, NSWindowCollectionBehaviorStationary
+ screen = NSScreen.mainScreen()
+ screen_frame = screen.frame()
+ screen_width = screen_frame.size.width
+ screen_height = screen_frame.size.height
+
+ # Position at right side
+ x_pos = screen_width - 200
+ y_pos = 300
+
+ # Create transparent window
+ self.window = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
+ NSMakeRect(x_pos, y_pos, self.display_width, self.display_height),
+ NSWindowStyleMaskBorderless,
+ NSBackingStoreBuffered,
+ False
+ )
+
+ self.window.setOpaque_(False)
+ self.window.setBackgroundColor_(NSColor.clearColor())
+ self.window.setLevel_(NSFloatingWindowLevel)
+ self.window.setMovableByWindowBackground_(True)
+ self.window.setAcceptsMouseMovedEvents_(True)
+
+ # Make window sticky across spaces (stays in fixed screen position)
+ self.window.setCollectionBehavior_(
+ NSWindowCollectionBehaviorCanJoinAllSpaces |
+ NSWindowCollectionBehaviorStationary
+ )
+
+ # Create custom view for handling mouse events
+ from AppKit import NSView
+ from objc import super as objc_super
+
+ class DraggableImageView(NSView):
+ """Custom view that handles dragging and double-click"""
+ def initWithFrame_(self, frame):
+ self = objc_super(DraggableImageView, self).initWithFrame_(frame)
+ if self is None:
+ return None
+ self.image_view = NSImageView.alloc().initWithFrame_(self.bounds())
+ self.image_view.setImageScaling_(1) # NSImageScaleProportionallyUpOrDown
+ self.addSubview_(self.image_view)
+
+ # Create overlay view for toast (always on top)
+ # Make it non-opaque so it doesn't block the image
+ self.overlay_view = NSView.alloc().initWithFrame_(self.bounds())
+ self.overlay_view.setWantsLayer_(True)
+ self.addSubview_(self.overlay_view)
+
+ self.drag_start = None
+ return self
+
+ def mouseDown_(self, event):
+ """Handle mouse down for dragging"""
+ if event.clickCount() == 2:
+ # Double-click to quit
+ from AppKit import NSApp
+ NSApp.terminate_(None)
+ else:
+ # Start dragging
+ self.drag_start = event.locationInWindow()
+
+ def mouseDragged_(self, event):
+ """Handle mouse drag"""
+ if self.drag_start:
+ current_location = event.locationInWindow()
+ window_frame = self.window().frame()
+
+ dx = current_location.x - self.drag_start.x
+ dy = current_location.y - self.drag_start.y
+
+ new_origin = NSMakePoint(
+ window_frame.origin.x + dx,
+ window_frame.origin.y + dy
+ )
+
+ self.window().setFrameOrigin_(new_origin)
+
+ def acceptsFirstMouse_(self, event):
+ """Accept first mouse click"""
+ return True
+
+ def rightMouseDown_(self, event):
+ from AppKit import NSMenu, NSMenuItem, NSApp
+
+ menu = NSMenu.alloc().init()
+ pet = getattr(self, 'mac_pet', None) or self.window().delegate()
+ if not pet:
+ return
+
+ for skin_name in pet.available_skins: # preload this in MacPet.__init__
+ item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(
+ skin_name,
+ 'changeSkin:',
+ ''
+ )
+ item.setTarget_(pet)
+ item.setRepresentedObject_(skin_name)
+ menu.addItem_(item)
+
+ menu.addItem_(NSMenuItem.separatorItem())
+ quit_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_('Quit', 'terminate:', '')
+ menu.addItem_(quit_item)
+
+ NSApp.activateIgnoringOtherApps_(True)
+ NSMenu.popUpContextMenu_withEvent_forView_(menu, event, self)
+
+ # Create draggable view
+ self.content_view = DraggableImageView.alloc().initWithFrame_(
+ NSMakeRect(0, 0, self.display_width, self.display_height)
+ )
+ self.content_view.mac_pet = self
+ self.image_view = self.content_view.image_view
+ self.overlay_view = self.content_view.overlay_view
+ self.window.setContentView_(self.content_view)
+
+ # Animation state
+ self.current_state = 'idle'
+ self.frame_idx = 0
+
+ # Toast state
+ self.toast_label = None
+ self.toast_timer = None
+ self.toast_image = None
+ self.toast_window = None
+
+ # Start animation timer
+ self.timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
+ 1.0 / self.animations[self.current_state]['fps'],
+ self,
+ 'animate:',
+ None,
+ True
+ )
+
+ # Show window
+ self.window.makeKeyAndOrderFront_(None)
+
+ # Start HTTP server
+ self._start_server()
+
+ print(f"✓ macOS Pet started at ({x_pos}, {y_pos})")
+ print(f" Animations: {', '.join(self.animations.keys())}")
+
+ def load_skin(self, skin_name=None):
+ """Load skin configuration and animations"""
+ available_skins = SkinLoader.list_skins()
+ if not available_skins:
+ raise FileNotFoundError(f"No skins found in {SKINS_DIR}")
+
+ if skin_name is None or skin_name not in available_skins:
+ skin_name = available_skins[0]
+
+ skin_path = os.path.join(SKINS_DIR, skin_name)
+ self.skin_config = SkinLoader.load_skin(skin_path)
+
+ # Get display size
+ display_size = self.skin_config.get('size', {})
+ self.display_width = display_size.get('width', 128)
+ self.display_height = display_size.get('height', 128)
+
+ # Load animations
+ self.animations = {}
+ for anim_name, anim_config in self.skin_config['animations'].items():
+ pil_frames = AnimationLoader.load_sprite_frames(skin_path, anim_config)
+
+ # Scale frames
+ scaled_frames = []
+ for frame in pil_frames:
+ if frame.mode != 'RGBA':
+ frame = frame.convert('RGBA')
+ scaled = frame.resize((self.display_width, self.display_height), Image.NEAREST)
+ scaled_frames.append(scaled)
+
+ # Convert to NSImage with proper alpha handling
+ ns_images = []
+ for pil_img in scaled_frames:
+ # Convert PIL to PNG bytes (PNG preserves alpha channel)
+ png_buffer = io.BytesIO()
+ pil_img.save(png_buffer, format='PNG')
+ png_data = png_buffer.getvalue()
+
+ # Create NSImage from PNG data
+ ns_data = NSData.dataWithBytes_length_(png_data, len(png_data))
+ ns_image = NSImage.alloc().initWithData_(ns_data)
+ ns_images.append(ns_image)
+
+ self.animations[anim_name] = {
+ 'frames': ns_images,
+ 'fps': anim_config.get('sprite', {}).get('fps', 6)
+ }
+
+ def animate_(self, timer):
+ """Animation callback"""
+ anim = self.animations[self.current_state]
+ frames = anim['frames']
+
+ if frames:
+ self.image_view.setImage_(frames[self.frame_idx])
+ self.frame_idx = (self.frame_idx + 1) % len(frames)
+
+ def set_state(self, state):
+ """Change animation state (must be called on main thread)"""
+ if state in self.animations and state != self.current_state:
+ self.current_state = state
+ self.frame_idx = 0
+
+ # Update timer interval
+ self.timer.invalidate()
+ self.timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
+ 1.0 / self.animations[self.current_state]['fps'],
+ self,
+ 'animate:',
+ None,
+ True
+ )
+ print(f"→ State: {state}")
+
+ def _schedule_main(self, fn):
+ AppHelper.callAfter(fn)
+
+ def show_toast(self, message):
+ """Show toast message above pet"""
+ from AppKit import NSImageView
+
+ if self.toast_window:
+ self.toast_window.orderOut_(None)
+ self.toast_window = None
+ self.toast_label = None
+ if self.toast_timer:
+ self.toast_timer.invalidate()
+ self.toast_timer = None
+
+ bubble_info = build_bubble_image(message, max_width=max(180, min(260, self.display_width * 2)))
+ bubble_pil = bubble_info['image']
+ bubble_width, bubble_height = bubble_info['size']
+ tail_x, tail_y = bubble_info['tail_tip']
+
+ png_buffer = io.BytesIO()
+ bubble_pil.save(png_buffer, format='PNG')
+ png_data = png_buffer.getvalue()
+ ns_data = NSData.dataWithBytes_length_(png_data, len(png_data))
+ self.toast_image = NSImage.alloc().initWithData_(ns_data)
+
+ pet_frame = self.window.frame()
+ anchor_x = pet_frame.origin.x + self.display_width * 0.75
+ anchor_y = pet_frame.origin.y + self.display_height * 1.65
+ toast_x = anchor_x - tail_x
+ toast_y = anchor_y - tail_y
+
+ self.toast_window = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
+ NSMakeRect(toast_x, toast_y, bubble_width, bubble_height),
+ NSWindowStyleMaskBorderless,
+ NSBackingStoreBuffered,
+ False
+ )
+ self.toast_window.setOpaque_(False)
+ self.toast_window.setBackgroundColor_(NSColor.clearColor())
+ self.toast_window.setLevel_(NSFloatingWindowLevel)
+ self.toast_window.setIgnoresMouseEvents_(True)
+ self.toast_window.setHasShadow_(False)
+
+ self.toast_label = NSImageView.alloc().initWithFrame_(
+ NSMakeRect(0, 0, bubble_width, bubble_height)
+ )
+ self.toast_label.setImage_(self.toast_image)
+ self.toast_label.setImageScaling_(0)
+ self.toast_window.setContentView_(self.toast_label)
+ self.toast_window.orderFrontRegardless()
+
+ self.toast_timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
+ 3.0,
+ self,
+ 'hideToast:',
+ None,
+ False
+ )
+ print(f"Toast: {message}")
+
+ def hideToast_(self, timer):
+ """Hide toast message"""
+ if self.toast_window:
+ self.toast_window.orderOut_(None)
+ self.toast_window = None
+ self.toast_label = None
+ self.toast_image = None
+ self.toast_timer = None
+
+ def run(self):
+ """Run the application"""
+ AppHelper.runEventLoop()
+
+ def changeSkin_(self, sender):
+ skin_name = sender.representedObject()
+ print(f"Changing skin to: {skin_name}")
+ self.load_skin(skin_name)
+ self.current_state = 'idle'
+ self.frame_idx = 0
+
+# ============================================================================
+# Windows/Linux Implementations
+# ============================================================================
+else:
+ if sys.platform.startswith('win'):
+ import tkinter as tk
+ from PIL import ImageTk
+
+ class WinPet(PetBase):
+ def __init__(self, skin_name=None):
+ self.root = tk.Tk()
+ self.root.wm_attributes('-topmost', True)
+ self.is_windows = sys.platform.startswith('win')
+ self.platform_name = 'Windows' if self.is_windows else 'Linux'
+ self.pet_bg_color = '#F0F0F0' if self.is_windows else 'black'
+ self.toast_bg_color = '#00ff01' if self.is_windows else 'black'
+
+ # Load skin
+ self.load_skin(skin_name)
+
+ # Setup window
+ screen_width = self.root.winfo_screenwidth()
+ screen_height = self.root.winfo_screenheight()
+
+ x_pos = screen_width - 200
+ y_pos = screen_height - 300
+
+ self.root.geometry(f'{self.display_width}x{self.display_height}+{x_pos}+{y_pos}')
+ self.root.overrideredirect(True)
+ self.root.wm_attributes('-topmost', True)
+
+ # Transparent background
+ if self.is_windows:
+ self.root.wm_attributes('-transparentcolor', self.pet_bg_color)
+ self.root.config(bg=self.pet_bg_color)
+
+ # Create label
+ self.label = tk.Label(self.root, bg=self.pet_bg_color, bd=0)
+ self.label.pack()
+
+ # Bind events
+ self.label.bind('', lambda e: setattr(self, '_d', (e.x, e.y)))
+ self.label.bind('', self._drag)
+ self.label.bind('', lambda e: (self.root.destroy(), os._exit(0)))
+ self.label.bind('', self._on_right_click)
+
+ # Animation state
+ self.current_state = 'idle'
+ self.frame_idx = 0
+
+ # Toast state
+ self.toast_window = None
+ self.toast_photo = None
+
+ # Start animation
+ self._animate()
+ self._start_server()
+
+ print(f"✓ {self.platform_name} Pet started at ({x_pos}, {y_pos})")
+ print(f" Animations: {', '.join(self.animations.keys())}")
+
+ self.root.mainloop()
+
+ def load_skin(self, skin_name=None):
+ """Load skin configuration and animations"""
+ available_skins = SkinLoader.list_skins()
+ if not available_skins:
+ raise FileNotFoundError(f"No skins found in {SKINS_DIR}")
+
+ if skin_name is None or skin_name not in available_skins:
+ skin_name = available_skins[0]
+
+ skin_path = os.path.join(SKINS_DIR, skin_name)
+ self.skin_config = SkinLoader.load_skin(skin_path)
+
+ # Get display size
+ display_size = self.skin_config.get('size', {})
+ self.display_width = display_size.get('width', 128)
+ self.display_height = display_size.get('height', 128)
+
+ # Load animations
+ self.animations = {}
+ for anim_name, anim_config in self.skin_config['animations'].items():
+ pil_frames = AnimationLoader.load_sprite_frames(skin_path, anim_config)
+
+ # Scale and convert frames
+ tk_frames = []
+ for frame in pil_frames:
+ if frame.mode != 'RGBA':
+ frame = frame.convert('RGBA')
+ scaled = frame.resize((self.display_width, self.display_height), Image.NEAREST)
+ tk_frames.append(ImageTk.PhotoImage(scaled))
+
+ self.animations[anim_name] = {
+ 'frames': tk_frames,
+ 'fps': anim_config.get('sprite', {}).get('fps', 6)
+ }
+
+ def set_state(self, state):
+ """Change animation state"""
+ if state in self.animations and state != self.current_state:
+ self.current_state = state
+ self.frame_idx = 0
+ print(f"→ State: {state}")
+
+ def _drag(self, e):
+ x = self.root.winfo_x() + e.x - self._d[0]
+ y = self.root.winfo_y() + e.y - self._d[1]
+ self.root.geometry(f'+{x}+{y}')
+
+ def _animate(self):
+ """Animate current state"""
+ if self.current_state not in self.animations:
+ self.root.after(100, self._animate)
+ return
+
+ anim = self.animations[self.current_state]
+ frames = anim['frames']
+
+ if frames:
+ self.label.config(image=frames[self.frame_idx])
+ self.frame_idx = (self.frame_idx + 1) % len(frames)
+
+ delay = int(1000 / anim['fps'])
+ self.root.after(delay, self._animate)
+
+ def show_toast(self, message):
+ """Show toast message above pet"""
+ if self.toast_window:
+ try:
+ self.toast_window.destroy()
+ except:
+ pass
+ self.toast_window = None
+
+ bubble_info = build_bubble_image(message, max_width=max(180, min(260, self.display_width * 2)))
+ bubble_pil = bubble_info['image']
+ bubble_width, bubble_height = bubble_info['size']
+ tail_x, tail_y = bubble_info['tail_tip']
+
+ self.toast_photo = ImageTk.PhotoImage(bubble_pil)
+
+ self.toast_window = tk.Toplevel(self.root)
+ self.toast_window.overrideredirect(True)
+ self.toast_window.wm_attributes('-topmost', True)
+ if self.is_windows:
+ self.toast_window.wm_attributes('-transparentcolor', self.toast_bg_color)
+ self.toast_window.config(bg=self.toast_bg_color)
+
+ toast_label = tk.Label(
+ self.toast_window,
+ image=self.toast_photo,
+ bg=self.toast_bg_color,
+ bd=0,
+ highlightthickness=0
+ )
+ toast_label.pack()
+
+ pet_x = self.root.winfo_x()
+ pet_y = self.root.winfo_y()
+ anchor_x = pet_x + int(self.display_width * 0.75)
+ anchor_y = pet_y
+ toast_x = anchor_x - tail_x
+ toast_y = anchor_y - bubble_height
+
+ self.toast_window.geometry(f'{bubble_width}x{bubble_height}+{toast_x}+{toast_y}')
+
+ self.root.after(3000, self._hide_toast)
+ print(f"Toast: {message}")
+
+ def _hide_toast(self):
+ """Hide toast message"""
+ if self.toast_window:
+ try:
+ self.toast_window.destroy()
+ self.toast_window = None
+ except:
+ pass
+
+ def _schedule_main(self, fn):
+ self.root.after(0, fn)
+
+ def run(self):
+ """Run the application (already in mainloop)"""
+ pass
+
+ def _on_right_click(self, event):
+ # Build a dynamic menu of all available skins
+ menu = tk.Menu(self.root, tearoff=0)
+ for skin_name in SkinLoader.list_skins():
+ menu.add_command(
+ label=skin_name,
+ command=lambda name=skin_name: self._change_skin(name)
+ )
+ menu.add_separator()
+ menu.add_command(label="Quit", command=lambda: (self.root.destroy(), os._exit(0)))
+ menu.tk_popup(event.x_root, event.y_root)
+
+ def _change_skin(self, skin_name):
+ print(f"Changing skin to: {skin_name}")
+ self.load_skin(skin_name)
+ self.current_state = 'idle'
+ self.frame_idx = 0
+ else:
+ from PySide6.QtCore import Qt, QTimer, QPoint
+ from PySide6.QtGui import QAction, QCursor, QImage, QPixmap
+ from PySide6.QtWidgets import QApplication, QLabel, QMenu, QWidget
+
+ class _LinuxPetLabel(QLabel):
+ def __init__(self, pet):
+ super().__init__()
+ self.pet = pet
+ self.drag_offset = None
+
+ def mousePressEvent(self, event):
+ if event.button() == Qt.LeftButton:
+ self.drag_offset = event.globalPosition().toPoint() - self.pet.window.frameGeometry().topLeft()
+ event.accept()
+ return
+ if event.button() == Qt.RightButton:
+ self.pet._show_context_menu(event.globalPosition().toPoint())
+ event.accept()
+ return
+ super().mousePressEvent(event)
+
+ def mouseMoveEvent(self, event):
+ if self.drag_offset is not None and (event.buttons() & Qt.LeftButton):
+ self.pet.window.move(event.globalPosition().toPoint() - self.drag_offset)
+ self.pet._reposition_toast()
+ event.accept()
+ return
+ super().mouseMoveEvent(event)
+
+ def mouseReleaseEvent(self, event):
+ if event.button() == Qt.LeftButton:
+ self.drag_offset = None
+ super().mouseReleaseEvent(event)
+
+ def mouseDoubleClickEvent(self, event):
+ if event.button() == Qt.LeftButton:
+ QApplication.instance().quit()
+ event.accept()
+ return
+ super().mouseDoubleClickEvent(event)
+
+
+ class LinuxPet(PetBase):
+ def __init__(self, skin_name=None):
+ self.app = QApplication.instance() or QApplication(sys.argv)
+ self.available_skins = SkinLoader.list_skins()
+ self.load_skin(skin_name)
+
+ screen = self.app.primaryScreen()
+ screen_geo = screen.availableGeometry() if screen else None
+ if screen_geo:
+ x_pos = screen_geo.right() - self.display_width - 72
+ y_pos = screen_geo.bottom() - self.display_height - 120
+ else:
+ x_pos, y_pos = 1200, 700
+
+ self.window = QWidget()
+ self.window.setWindowFlags(
+ Qt.FramelessWindowHint |
+ Qt.WindowStaysOnTopHint |
+ Qt.Tool
+ )
+ self.window.setAttribute(Qt.WA_TranslucentBackground, True)
+ self.window.setAttribute(Qt.WA_ShowWithoutActivating, True)
+ self.window.resize(self.display_width, self.display_height)
+ self.window.move(x_pos, y_pos)
+
+ self.label = _LinuxPetLabel(self)
+ self.label.setParent(self.window)
+ self.label.setGeometry(0, 0, self.display_width, self.display_height)
+ self.label.setAttribute(Qt.WA_TranslucentBackground, True)
+ self.label.setStyleSheet('background: transparent;')
+ self.label.setScaledContents(True)
+
+ self.current_state = 'idle'
+ self.frame_idx = 0
+ self.toast_window = None
+ self.toast_label = None
+ self.toast_pixmap = None
+
+ self.anim_timer = QTimer()
+ self.anim_timer.timeout.connect(self._animate)
+ self._restart_animation_timer()
+
+ self.window.show()
+ self._start_server()
+
+ print(f"✓ Linux PySide6 Pet started at ({x_pos}, {y_pos})")
+ print(f" Animations: {', '.join(self.animations.keys())}")
+
+ def _pil_to_qpixmap(self, pil_img):
+ buffer = io.BytesIO()
+ pil_img.save(buffer, format='PNG')
+ qimage = QImage.fromData(buffer.getvalue(), 'PNG')
+ return QPixmap.fromImage(qimage)
+
+ def load_skin(self, skin_name=None):
+ available_skins = SkinLoader.list_skins()
+ if not available_skins:
+ raise FileNotFoundError(f"No skins found in {SKINS_DIR}")
+
+ if skin_name is None or skin_name not in available_skins:
+ skin_name = available_skins[0]
+
+ skin_path = os.path.join(SKINS_DIR, skin_name)
+ self.skin_config = SkinLoader.load_skin(skin_path)
+
+ display_size = self.skin_config.get('size', {})
+ self.display_width = display_size.get('width', 128)
+ self.display_height = display_size.get('height', 128)
+
+ self.animations = {}
+ for anim_name, anim_config in self.skin_config['animations'].items():
+ pil_frames = AnimationLoader.load_sprite_frames(skin_path, anim_config)
+ qt_frames = []
+ for frame in pil_frames:
+ if frame.mode != 'RGBA':
+ frame = frame.convert('RGBA')
+ scaled = frame.resize((self.display_width, self.display_height), Image.NEAREST)
+ qt_frames.append(self._pil_to_qpixmap(scaled))
+
+ self.animations[anim_name] = {
+ 'frames': qt_frames,
+ 'fps': anim_config.get('sprite', {}).get('fps', 6)
+ }
+
+ if hasattr(self, 'window'):
+ self.window.resize(self.display_width, self.display_height)
+ self.label.setGeometry(0, 0, self.display_width, self.display_height)
+ self._animate(force=True)
+ self._reposition_toast()
+
+ def _restart_animation_timer(self):
+ anim = self.animations.get(self.current_state) or next(iter(self.animations.values()))
+ fps = max(1, anim.get('fps', 6))
+ self.anim_timer.start(int(1000 / fps))
+
+ def _animate(self, force=False):
+ if self.current_state not in self.animations:
+ return
+ anim = self.animations[self.current_state]
+ frames = anim['frames']
+ if not frames:
+ return
+ if force:
+ self.frame_idx = 0
+ self.label.setPixmap(frames[self.frame_idx])
+ self.frame_idx = (self.frame_idx + 1) % len(frames)
+
+ def set_state(self, state):
+ if state in self.animations and state != self.current_state:
+ self.current_state = state
+ self.frame_idx = 0
+ self._restart_animation_timer()
+ print(f"→ State: {state}")
+
+ def _show_context_menu(self, global_pos):
+ menu = QMenu(self.window)
+ for skin_name in SkinLoader.list_skins():
+ action = QAction(skin_name, menu)
+ action.triggered.connect(lambda checked=False, name=skin_name: self._change_skin(name))
+ menu.addAction(action)
+ menu.addSeparator()
+ quit_action = QAction('Quit', menu)
+ quit_action.triggered.connect(QApplication.instance().quit)
+ menu.addAction(quit_action)
+ menu.popup(global_pos)
+
+ def _compute_toast_geometry(self, bubble_width, bubble_height, tail_x, tail_y):
+ pet_pos = self.window.frameGeometry().topLeft()
+ anchor_x = pet_pos.x() + int(self.display_width * 0.75)
+ anchor_y = pet_pos.y() + int(self.display_height * 0.15)
+ return anchor_x - tail_x, anchor_y - tail_y - bubble_height // 2
+
+ def show_toast(self, message):
+ if self.toast_window:
+ self.toast_window.close()
+ self.toast_window = None
+ self.toast_label = None
+ self.toast_pixmap = None
+
+ bubble_info = build_bubble_image(message, max_width=max(180, min(260, self.display_width * 2)))
+ bubble_pil = bubble_info['image']
+ bubble_width, bubble_height = bubble_info['size']
+ tail_x, tail_y = bubble_info['tail_tip']
+ self.toast_pixmap = self._pil_to_qpixmap(bubble_pil)
+
+ self.toast_window = QWidget()
+ self.toast_window.setWindowFlags(
+ Qt.FramelessWindowHint |
+ Qt.WindowStaysOnTopHint |
+ Qt.Tool |
+ Qt.WindowTransparentForInput
+ )
+ self.toast_window.setAttribute(Qt.WA_TranslucentBackground, True)
+ self.toast_window.setAttribute(Qt.WA_ShowWithoutActivating, True)
+ self.toast_window.resize(bubble_width, bubble_height)
+
+ self.toast_label = QLabel(self.toast_window)
+ self.toast_label.setGeometry(0, 0, bubble_width, bubble_height)
+ self.toast_label.setPixmap(self.toast_pixmap)
+ self.toast_label.setAttribute(Qt.WA_TranslucentBackground, True)
+ self.toast_label.setStyleSheet('background: transparent;')
+
+ toast_x, toast_y = self._compute_toast_geometry(bubble_width, bubble_height, tail_x, tail_y)
+ self.toast_window.move(toast_x, toast_y)
+ self.toast_window.show()
+
+ QTimer.singleShot(3000, self._hide_toast)
+ print(f"Toast: {message}")
+
+ def _reposition_toast(self):
+ if not self.toast_window:
+ return
+ label_pixmap = self.toast_label.pixmap() if self.toast_label else None
+ if label_pixmap is None:
+ return
+ bubble_width = label_pixmap.width()
+ bubble_height = label_pixmap.height()
+ toast_x, toast_y = self._compute_toast_geometry(
+ bubble_width,
+ bubble_height,
+ bubble_width // 2,
+ bubble_height
+ )
+ self.toast_window.move(toast_x, toast_y)
+
+ def _hide_toast(self):
+ if self.toast_window:
+ self.toast_window.close()
+ self.toast_window = None
+ self.toast_label = None
+ self.toast_pixmap = None
+
+ def _schedule_main(self, fn):
+ QTimer.singleShot(0, fn)
+
+ def _change_skin(self, skin_name):
+ print(f"Changing skin to: {skin_name}")
+ self.load_skin(skin_name)
+ self.current_state = 'idle'
+ self.frame_idx = 0
+ self._restart_animation_timer()
+
+ def run(self):
+ self.app.exec()
+
+if __name__ == '__main__':
+ # Singleton: if port already in use, another instance is running
+ import socket
+ _s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ try:
+ _s.connect(('127.0.0.1', PORT))
+ _s.close()
+ print(f'⚠ Pet already running on port {PORT}, exiting.')
+ sys.exit(0)
+ except ConnectionRefusedError:
+ pass
+
+ if sys.platform == 'darwin':
+ pet = MacPet('vita')
+ pet.run()
+ elif sys.platform.startswith('win'):
+ pet = WinPet('vita')
+ else:
+ pet = LinuxPet('vita')
+ pet.run()
+
diff --git a/frontends/dingtalkapp.py b/frontends/dingtalkapp.py
new file mode 100644
index 000000000..577bfc420
--- /dev/null
+++ b/frontends/dingtalkapp.py
@@ -0,0 +1,151 @@
+import asyncio, json, os, sys, threading, time
+import requests
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+from agentmain import GeneraticAgent
+from chatapp_common import AgentChatMixin, ensure_single_instance, public_access, redirect_log, require_runtime, split_text
+from llmcore import mykeys
+
+try:
+ from dingtalk_stream import AckMessage, CallbackHandler, Credential, DingTalkStreamClient
+ from dingtalk_stream.chatbot import ChatbotMessage
+except Exception:
+ print("Please install dingtalk-stream to use DingTalk: pip install dingtalk-stream")
+ sys.exit(1)
+
+agent = GeneraticAgent(); agent.verbose = False
+CLIENT_ID = str(mykeys.get("dingtalk_client_id", "") or "").strip()
+CLIENT_SECRET = str(mykeys.get("dingtalk_client_secret", "") or "").strip()
+ALLOWED = {str(x).strip() for x in mykeys.get("dingtalk_allowed_users", []) if str(x).strip()}
+USER_TASKS = {}
+
+
+class DingTalkApp(AgentChatMixin):
+ label, source, split_limit = "DingTalk", "dingtalk", 1800
+
+ def __init__(self):
+ super().__init__(agent, USER_TASKS)
+ self.client, self.access_token, self.token_expiry, self.background_tasks = None, None, 0, set()
+
+ async def _get_access_token(self):
+ if self.access_token and time.time() < self.token_expiry:
+ return self.access_token
+
+ def _fetch():
+ resp = requests.post("https://api.dingtalk.com/v1.0/oauth2/accessToken", json={"appKey": CLIENT_ID, "appSecret": CLIENT_SECRET}, timeout=20)
+ resp.raise_for_status()
+ return resp.json()
+
+ last_err = None
+ for attempt in range(2):
+ try:
+ data = await asyncio.to_thread(_fetch)
+ self.access_token = data.get("accessToken")
+ self.token_expiry = time.time() + int(data.get("expireIn", 7200)) - 60
+ return self.access_token
+ except Exception as e:
+ last_err = e
+ if attempt == 0:
+ await asyncio.sleep(1)
+ print(f"[DingTalk] token error after retry: {last_err}")
+ return None
+
+ async def _send_batch_message(self, chat_id, msg_key, msg_param):
+ token = await self._get_access_token()
+ if not token:
+ return False
+ headers = {"x-acs-dingtalk-access-token": token}
+ if chat_id.startswith("group:"):
+ url = "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
+ payload = {"robotCode": CLIENT_ID, "openConversationId": chat_id[6:], "msgKey": msg_key, "msgParam": json.dumps(msg_param, ensure_ascii=False)}
+ else:
+ url = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend"
+ payload = {"robotCode": CLIENT_ID, "userIds": [chat_id], "msgKey": msg_key, "msgParam": json.dumps(msg_param, ensure_ascii=False)}
+
+ def _post():
+ resp = requests.post(url, json=payload, headers=headers, timeout=20)
+ body = resp.text
+ if resp.status_code != 200:
+ raise RuntimeError(f"HTTP {resp.status_code}: {body[:300]}")
+ result = resp.json() if "json" in resp.headers.get("content-type", "") else {}
+ errcode = result.get("errcode")
+ if errcode not in (None, 0):
+ raise RuntimeError(f"API errcode={errcode}: {body[:300]}")
+ return True
+
+ try:
+ return await asyncio.to_thread(_post)
+ except Exception as e:
+ print(f"[DingTalk] send error: {e}")
+ return False
+
+ async def send_text(self, chat_id, content):
+ for part in split_text(content, self.split_limit):
+ await self._send_batch_message(chat_id, "sampleMarkdown", {"text": part, "title": "Agent Reply"})
+
+ async def on_message(self, content, sender_id, sender_name, conversation_type=None, conversation_id=None):
+ try:
+ if not content:
+ return
+ if not public_access(ALLOWED) and sender_id not in ALLOWED:
+ print(f"[DingTalk] unauthorized user: {sender_id}")
+ return
+ is_group = conversation_type == "2" and conversation_id
+ chat_id = f"group:{conversation_id}" if is_group else sender_id
+ print(f"[DingTalk] message from {sender_name} ({sender_id}): {content}")
+ if content.startswith("/"):
+ return await self.handle_command(chat_id, content)
+ task = asyncio.create_task(self.run_agent(chat_id, content))
+ self.background_tasks.add(task)
+ task.add_done_callback(self.background_tasks.discard)
+ except Exception:
+ import traceback
+ print("[DingTalk] handle_message error")
+ traceback.print_exc()
+
+ async def start(self):
+ self.client = DingTalkStreamClient(Credential(CLIENT_ID, CLIENT_SECRET))
+ self.client.register_callback_handler(ChatbotMessage.TOPIC, _DingTalkHandler(self))
+ print("[DingTalk] bot starting...")
+ delay, max_delay = 5, 300
+ while True:
+ started_at = time.monotonic()
+ try:
+ await self.client.start()
+ except Exception as e:
+ print(f"[DingTalk] stream error: {e}")
+ # any session that lived >=60s is treated as healthy -> reset backoff
+ if time.monotonic() - started_at >= 60:
+ delay = 5
+ print(f"[DingTalk] reconnect in {delay}s...")
+ await asyncio.sleep(delay)
+ delay = min(delay * 2, max_delay)
+
+
+class _DingTalkHandler(CallbackHandler):
+ def __init__(self, app):
+ super().__init__()
+ self.app = app
+
+ async def process(self, message):
+ try:
+ chatbot_msg = ChatbotMessage.from_dict(message.data)
+ text = getattr(getattr(chatbot_msg, "text", None), "content", "") or ""
+ extensions = getattr(chatbot_msg, "extensions", None) or {}
+ recognition = ((extensions.get("content") or {}).get("recognition") or "").strip() if isinstance(extensions, dict) else ""
+ if not (text := text.strip()):
+ text = recognition or str((message.data.get("text", {}) or {}).get("content", "") or "").strip()
+ sender_id = str(getattr(chatbot_msg, "sender_staff_id", None) or getattr(chatbot_msg, "sender_id", None) or "unknown")
+ sender_name = getattr(chatbot_msg, "sender_nick", None) or "Unknown"
+ await self.app.on_message(text, sender_id, sender_name, message.data.get("conversationType"), message.data.get("conversationId") or message.data.get("openConversationId"))
+ except Exception as e:
+ print(f"[DingTalk] callback error: {e}")
+ return AckMessage.STATUS_OK, "OK"
+
+
+if __name__ == "__main__":
+ _LOCK_SOCK = ensure_single_instance(19530, "DingTalk")
+ require_runtime(agent, "DingTalk", dingtalk_client_id=CLIENT_ID, dingtalk_client_secret=CLIENT_SECRET)
+ redirect_log(__file__, "dingtalkapp.log", "DingTalk", ALLOWED)
+ threading.Thread(target=agent.run, daemon=True).start()
+ asyncio.run(DingTalkApp().start())
diff --git a/frontends/export_cmd.py b/frontends/export_cmd.py
new file mode 100644
index 000000000..4d030c507
--- /dev/null
+++ b/frontends/export_cmd.py
@@ -0,0 +1,78 @@
+"""`/export` command: export last assistant reply / locate full conversation log.
+Pure functions, no Qt deps. UI wiring lives in the frontend file.
+"""
+import os
+import re
+import sys
+from datetime import datetime
+
+from continue_cmd import _pairs, _assistant_text
+
+_TEMP_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'temp')
+_BACKTICK_RUN_RE = re.compile(r'`+')
+
+
+def wrap_for_clipboard(text, language='markdown'):
+ """Wrap text in a markdown code fence that survives nested fences.
+
+ CommonMark closes a fenced block on a line whose backtick run is at least
+ as long as the opening one. Pick `max(3, longest_inner_run + 1)` so any
+ backticks inside `text` are strictly shorter than the outer fence.
+ """
+ longest = max((len(m.group(0)) for m in _BACKTICK_RUN_RE.finditer(text)), default=0)
+ fence = '`' * max(3, longest + 1)
+ return f"{fence}{language}\n{text}\n{fence}"
+
+
+def last_assistant_text(agent):
+ """Last assistant reply as joined plain text from `agent.log_path`.
+
+ Reads the model_responses log, takes the most recent === Response === block,
+ and joins only the `text` fields (skips thinking/tool_use/tool_result).
+
+ Returns None when:
+ - the LLM backend's history is empty (fresh agent or just-`/new`'d —
+ log_path may still hold prior-session content that no longer belongs
+ to the current conversation)
+ - the log is missing or unreadable
+ - there's no Response yet, or the last Response holds no text blocks
+ (e.g. tool-only turn)
+
+ OS errors are logged to stderr — small failure radius, the UI falls back
+ gracefully.
+ """
+ if not (agent.llmclient and agent.llmclient.backend.history):
+ return None
+ log = agent.log_path
+ if not os.path.isfile(log):
+ return None
+ try:
+ with open(log, encoding='utf-8', errors='replace') as f:
+ content = f.read()
+ except OSError as e:
+ print(f"[export_cmd] failed to read {log}: {e}", file=sys.stderr)
+ return None
+ pairs = _pairs(content)
+ if not pairs:
+ return None
+ text = _assistant_text(pairs[-1][1])
+ return text if text.strip() else None
+
+
+def export_to_temp(text, name):
+ """Write text to temp/.md, overwriting on collision. Returns full path.
+
+ `name` is sanitized via os.path.basename to keep the write inside temp/.
+ Empty/whitespace name falls back to a timestamp. `.md` is appended if
+ the user-supplied name has no extension.
+ """
+ os.makedirs(_TEMP_DIR, exist_ok=True)
+ safe = os.path.basename((name or '').strip())
+ if not safe:
+ safe = f"export_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
+ if not os.path.splitext(safe)[1]:
+ safe = safe + '.md'
+ path = os.path.join(_TEMP_DIR, safe)
+ with open(path, 'w', encoding='utf-8') as f:
+ f.write(text)
+ return path
diff --git a/frontends/fsapp.py b/frontends/fsapp.py
new file mode 100644
index 000000000..ad959f660
--- /dev/null
+++ b/frontends/fsapp.py
@@ -0,0 +1,880 @@
+import argparse, asyncio, importlib.util, json, os, queue as Q, re, sys, threading, time, uuid
+from pathlib import Path
+
+PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, PROJECT_ROOT)
+os.chdir(PROJECT_ROOT)
+
+import traceback
+import lark_oapi as lark
+from lark_oapi.api.im.v1 import *
+
+
+def _ensure_dir(path):
+ path = Path(path)
+ path.mkdir(parents=True, exist_ok=True)
+ return path
+
+
+def _workspace_root_dir():
+ root = os.environ.get("GA_WORKSPACE_ROOT")
+ if root:
+ return _ensure_dir(Path(root).expanduser().resolve())
+ return _ensure_dir(Path(PROJECT_ROOT).resolve())
+
+
+def _workspace_config_dir(root=None):
+ base = Path(root).expanduser().resolve() if root else _workspace_root_dir()
+ if base.name == "ga_config":
+ return _ensure_dir(base)
+ return _ensure_dir(base / "ga_config")
+
+
+def _load_dict_config(path):
+ path = Path(path)
+ if not path.exists():
+ return None
+ try:
+ if path.suffix == ".py":
+ mod_name = f"_fs_mykey_{uuid.uuid4().hex}"
+ spec = importlib.util.spec_from_file_location(mod_name, path)
+ if not spec or not spec.loader:
+ return None
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ data = {k: v for k, v in vars(module).items() if not k.startswith("_")}
+ else:
+ with open(path, encoding="utf-8") as f:
+ data = json.load(f)
+ return data if isinstance(data, dict) else None
+ except Exception as e:
+ print(f"[ERROR] load config failed {path}: {e}")
+ return None
+
+
+def _resolve_mykey_path():
+ workspace_root = _workspace_root_dir()
+ config_root = _workspace_config_dir(workspace_root)
+ candidates = [
+ config_root / "mykey.json",
+ config_root / "mykey.py",
+ workspace_root / "mykey.json",
+ workspace_root / "mykey.py",
+ Path(PROJECT_ROOT) / "mykey.json",
+ Path(PROJECT_ROOT) / "mykey.py",
+ ]
+ for candidate in candidates:
+ if _load_dict_config(candidate):
+ return candidate
+ return candidates[0]
+
+
+def _ensure_runtime_paths():
+ workspace_root = _workspace_root_dir()
+ config_root = _workspace_config_dir(workspace_root)
+ os.environ.setdefault("GA_WORKSPACE_ROOT", str(workspace_root))
+ os.environ.setdefault("GA_USER_DATA_DIR", str(config_root))
+ return str(workspace_root), str(config_root)
+
+
+_ensure_runtime_paths()
+from agentmain import GeneraticAgent
+from frontends.chatapp_common import AgentChatMixin, FILE_HINT, split_text
+
+_TAG_PATS = [r"<" + t + r">.*?" + t + r">" for t in ("thinking", "summary", "tool_use", "file_content")]
+_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"}
+_AUDIO_EXTS = {".opus", ".mp3", ".wav", ".m4a", ".aac"}
+_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm"}
+_FILE_TYPE_MAP = {
+ ".opus": "opus",
+ ".mp4": "mp4",
+ ".pdf": "pdf",
+ ".doc": "doc",
+ ".docx": "doc",
+ ".xls": "xls",
+ ".xlsx": "xls",
+ ".ppt": "ppt",
+ ".pptx": "ppt",
+}
+_MSG_TYPE_MAP = {"image": "[image]", "audio": "[audio]", "file": "[file]", "media": "[media]", "sticker": "[sticker]"}
+
+TEMP_DIR = os.path.join(PROJECT_ROOT, "temp")
+MEDIA_DIR = os.path.join(TEMP_DIR, "feishu_media")
+os.makedirs(MEDIA_DIR, exist_ok=True)
+
+
+_TRUNC_TAIL = 300 # 截断兜底时保留原文尾部字符数
+_DEDUP_TTL_SEC = 10 * 60
+_DEDUP_MAX = 2000
+_DEDUP_LOCK = threading.Lock()
+_SEEN_MESSAGES = {}
+
+
+def _claim_message_once(message_id):
+ """Best-effort cross-platform dedup for Feishu reconnect redeliveries."""
+ if not message_id:
+ return True
+ now = time.time()
+ with _DEDUP_LOCK:
+ expired = [mid for mid, ts in _SEEN_MESSAGES.items() if now - ts > _DEDUP_TTL_SEC]
+ for mid in expired:
+ _SEEN_MESSAGES.pop(mid, None)
+ if len(_SEEN_MESSAGES) > _DEDUP_MAX:
+ for mid, _ in sorted(_SEEN_MESSAGES.items(), key=lambda item: item[1])[:len(_SEEN_MESSAGES) - _DEDUP_MAX]:
+ _SEEN_MESSAGES.pop(mid, None)
+ if message_id in _SEEN_MESSAGES:
+ return False
+ _SEEN_MESSAGES[message_id] = now
+ return True
+
+
+def _clean(text):
+ for pat in _TAG_PATS:
+ text = re.sub(pat, "", text or "", flags=re.DOTALL)
+ return re.sub(r"\n{3,}", "\n\n", text).strip()
+
+
+def _extract_files(text):
+ return re.findall(r"\[FILE:([^\]]+)\]", text or "")
+
+
+def _strip_files(text):
+ return re.sub(r"\[FILE:[^\]]+\]", "", text or "").strip()
+
+
+def _display_text(text):
+ cleaned = _strip_files(_clean(text))
+ if cleaned:
+ return cleaned
+ tail = (text or "").strip()[-_TRUNC_TAIL:]
+ return "⚠️ 模型输出被截断或为空" + (f"\n…{tail}" if tail else "")
+
+
+def _to_allowed_set(value):
+ if value is None:
+ return set()
+ if isinstance(value, str):
+ value = [value]
+ return {str(x).strip() for x in value if str(x).strip()}
+
+
+def _parse_json(raw):
+ if not raw:
+ return {}
+ try:
+ return json.loads(raw)
+ except Exception:
+ return {}
+
+
+def _extract_share_card_content(content_json, msg_type):
+ parts = []
+ if msg_type == "share_chat":
+ parts.append(f"[shared chat: {content_json.get('chat_id', '')}]")
+ elif msg_type == "share_user":
+ parts.append(f"[shared user: {content_json.get('user_id', '')}]")
+ elif msg_type == "interactive":
+ parts.extend(_extract_interactive_content(content_json))
+ elif msg_type == "share_calendar_event":
+ parts.append(f"[shared calendar event: {content_json.get('event_key', '')}]")
+ elif msg_type == "system":
+ parts.append("[system message]")
+ elif msg_type == "merge_forward":
+ parts.append("[merged forward messages]")
+ return "\n".join([p for p in parts if p]).strip() or f"[{msg_type}]"
+
+
+def _extract_interactive_content(content):
+ parts = []
+ if isinstance(content, str):
+ try:
+ content = json.loads(content)
+ except Exception:
+ return [content] if content.strip() else []
+ if not isinstance(content, dict):
+ return parts
+ title = content.get("title")
+ if isinstance(title, dict):
+ title_text = title.get("content", "") or title.get("text", "")
+ if title_text:
+ parts.append(f"title: {title_text}")
+ elif isinstance(title, str) and title:
+ parts.append(f"title: {title}")
+ elements = content.get("elements", [])
+ if isinstance(elements, list):
+ for row in elements:
+ if isinstance(row, dict):
+ parts.extend(_extract_element_content(row))
+ elif isinstance(row, list):
+ for el in row:
+ parts.extend(_extract_element_content(el))
+ card = content.get("card", {})
+ if card:
+ parts.extend(_extract_interactive_content(card))
+ header = content.get("header", {})
+ if isinstance(header, dict):
+ header_title = header.get("title", {})
+ if isinstance(header_title, dict):
+ header_text = header_title.get("content", "") or header_title.get("text", "")
+ if header_text:
+ parts.append(f"title: {header_text}")
+ return [p for p in parts if p]
+
+
+def _extract_element_content(element):
+ parts = []
+ if not isinstance(element, dict):
+ return parts
+ tag = element.get("tag", "")
+ if tag in ("markdown", "lark_md"):
+ content = element.get("content", "")
+ if content:
+ parts.append(content)
+ elif tag == "div":
+ text = element.get("text", {})
+ if isinstance(text, dict):
+ text_content = text.get("content", "") or text.get("text", "")
+ if text_content:
+ parts.append(text_content)
+ elif isinstance(text, str) and text:
+ parts.append(text)
+ for field in element.get("fields", []) or []:
+ if isinstance(field, dict):
+ field_text = field.get("text", {})
+ if isinstance(field_text, dict):
+ content = field_text.get("content", "") or field_text.get("text", "")
+ if content:
+ parts.append(content)
+ elif tag == "a":
+ href = element.get("href", "")
+ text = element.get("text", "")
+ if href:
+ parts.append(f"link: {href}")
+ if text:
+ parts.append(text)
+ elif tag == "button":
+ text = element.get("text", {})
+ if isinstance(text, dict):
+ content = text.get("content", "") or text.get("text", "")
+ if content:
+ parts.append(content)
+ url = element.get("url", "") or (element.get("multi_url", {}) or {}).get("url", "")
+ if url:
+ parts.append(f"link: {url}")
+ elif tag == "img":
+ alt = element.get("alt", {})
+ if isinstance(alt, dict):
+ parts.append(alt.get("content", "[image]") or "[image]")
+ else:
+ parts.append("[image]")
+ for child in element.get("elements", []) or []:
+ parts.extend(_extract_element_content(child))
+ for col in element.get("columns", []) or []:
+ for child in (col.get("elements", []) if isinstance(col, dict) else []):
+ parts.extend(_extract_element_content(child))
+ return parts
+
+
+def _extract_post_content(content_json):
+ def _parse_block(block):
+ if not isinstance(block, dict) or not isinstance(block.get("content"), list):
+ return None, []
+ texts, images = [], []
+ if block.get("title"):
+ texts.append(block.get("title"))
+ for row in block["content"]:
+ if not isinstance(row, list):
+ continue
+ for el in row:
+ if not isinstance(el, dict):
+ continue
+ tag = el.get("tag")
+ if tag in ("text", "a"):
+ texts.append(el.get("text", ""))
+ elif tag == "at":
+ texts.append(f"@{el.get('user_name', 'user')}")
+ elif tag == "img" and el.get("image_key"):
+ images.append(el["image_key"])
+ text = " ".join([t for t in texts if t]).strip()
+ return text or None, images
+
+ root = content_json
+ if isinstance(root, dict) and isinstance(root.get("post"), dict):
+ root = root["post"]
+ if not isinstance(root, dict):
+ return "", []
+ if "content" in root:
+ text, imgs = _parse_block(root)
+ if text or imgs:
+ return text or "", imgs
+ for key in ("zh_cn", "en_us", "ja_jp"):
+ if key in root:
+ text, imgs = _parse_block(root[key])
+ if text or imgs:
+ return text or "", imgs
+ for val in root.values():
+ if isinstance(val, dict):
+ text, imgs = _parse_block(val)
+ if text or imgs:
+ return text or "", imgs
+ return "", []
+
+
+AGENT_TIMEOUT_SEC = 900
+
+agent = None
+agent_error = None
+agent_thread = None
+client, user_tasks, app = None, {}, None
+agent_lock = threading.Lock()
+
+
+def _load_config():
+ path = _resolve_mykey_path()
+ if not path or not path.exists():
+ return {}, str(path or "")
+ try:
+ data = _load_dict_config(path)
+ return data if isinstance(data, dict) else {}, str(path)
+ except Exception as e:
+ print(f"[ERROR] load mykey failed {path}: {e}")
+ return {}, str(path)
+
+
+def _feishu_config():
+ cfg, path = _load_config()
+ app_id = str(cfg.get("fs_app_id", "") or "").strip()
+ app_secret = str(cfg.get("fs_app_secret", "") or "").strip()
+ allowed = _to_allowed_set(cfg.get("fs_allowed_users", []))
+ return app_id, app_secret, allowed, (not allowed or "*" in allowed), path
+
+
+APP_ID, APP_SECRET, ALLOWED_USERS, PUBLIC_ACCESS, CONFIG_PATH = _feishu_config()
+
+
+def get_agent():
+ global agent, agent_error, agent_thread
+ with agent_lock:
+ if agent is not None:
+ return agent
+ if agent_error:
+ raise RuntimeError(agent_error)
+ try:
+ agent = GeneraticAgent()
+ agent_thread = threading.Thread(target=agent.run, daemon=True)
+ agent_thread.start()
+ return agent
+ except Exception as e:
+ agent_error = str(e)
+ raise
+
+
+def create_client():
+ return lark.Client.builder().app_id(APP_ID).app_secret(APP_SECRET).log_level(lark.LogLevel.INFO).build()
+
+
+def _mask_secret(value):
+ value = str(value or "")
+ if len(value) <= 8:
+ return "*" * len(value)
+ return value[:4] + "*" * (len(value) - 8) + value[-4:]
+
+
+def check_config(init_agent=False):
+ app_id, app_secret, allowed, public_access, path = _feishu_config()
+ result = {
+ "config_path": path,
+ "app_id": app_id,
+ "app_secret": _mask_secret(app_secret),
+ "app_secret_present": bool(app_secret),
+ "public_access": public_access,
+ "allowed_users": sorted(allowed),
+ "ready": bool(app_id and app_secret),
+ }
+ if init_agent:
+ try:
+ ga = get_agent()
+ result["agent_ready"] = True
+ result["llm_count"] = len(ga.list_llms()) if hasattr(ga, "list_llms") else 0
+ result["current_llm"] = ga.get_llm_name() if getattr(ga, "llmclient", None) else ""
+ except Exception as e:
+ result["agent_ready"] = False
+ result["agent_error"] = str(e)
+ return result
+
+
+def _card_raw(elements):
+ return json.dumps({
+ "schema": "2.0",
+ "config": {"streaming_mode": False, "width_mode": "fill"},
+ "body": {"elements": elements},
+ }, ensure_ascii=False)
+
+
+def _card(text):
+ return _card_raw([{"tag": "markdown", "content": text}])
+
+
+def _send_raw(receive_id, payload, msg_type, rtype):
+ try:
+ body = CreateMessageRequest.builder().receive_id_type(rtype).request_body(
+ CreateMessageRequestBody.builder().receive_id(receive_id).msg_type(msg_type).content(payload).build()
+ ).build()
+ r = client.im.v1.message.create(body)
+ if r.success():
+ return r.data.message_id if r.data else None
+ print(f"发送失败: {r.code}, {r.msg}")
+ except Exception as e:
+ print(f"[ERROR] send_message failed: {e}")
+ traceback.print_exc()
+ return None
+
+
+def _patch_card(message_id, card_json):
+ try:
+ body = PatchMessageRequest.builder().message_id(message_id).request_body(
+ PatchMessageRequestBody.builder().content(card_json).build()
+ ).build()
+ r = client.im.v1.message.patch(body)
+ if not r.success():
+ print(f"[ERROR] patch_card 失败: {r.code}, {r.msg}")
+ return r.success()
+ except Exception as e:
+ print(f"[ERROR] patch_card exception: {e}")
+ traceback.print_exc()
+ return False
+
+
+def send_message(receive_id, content, msg_type="text", use_card=False, receive_id_type="open_id"):
+ if use_card:
+ return _send_raw(receive_id, _card(content), "interactive", receive_id_type)
+ if msg_type == "text":
+ return _send_raw(receive_id, json.dumps({"text": content}, ensure_ascii=False), "text", receive_id_type)
+ return _send_raw(receive_id, content, msg_type, receive_id_type)
+
+
+def update_message(message_id, content):
+ return _patch_card(message_id, _card(content))
+
+
+def _upload_image_sync(file_path):
+ try:
+ with open(file_path, "rb") as f:
+ request = CreateImageRequest.builder().request_body(
+ CreateImageRequestBody.builder().image_type("message").image(f).build()
+ ).build()
+ response = client.im.v1.image.create(request)
+ if response.success():
+ return response.data.image_key
+ print(f"[ERROR] upload image failed: {response.code}, {response.msg}")
+ except Exception as e:
+ print(f"[ERROR] upload image failed {file_path}: {e}")
+ return None
+
+
+def _upload_file_sync(file_path):
+ ext = os.path.splitext(file_path)[1].lower()
+ file_type = _FILE_TYPE_MAP.get(ext, "stream")
+ file_name = os.path.basename(file_path)
+ try:
+ with open(file_path, "rb") as f:
+ request = CreateFileRequest.builder().request_body(
+ CreateFileRequestBody.builder().file_type(file_type).file_name(file_name).file(f).build()
+ ).build()
+ response = client.im.v1.file.create(request)
+ if response.success():
+ return response.data.file_key
+ print(f"[ERROR] upload file failed: {response.code}, {response.msg}")
+ except Exception as e:
+ print(f"[ERROR] upload file failed {file_path}: {e}")
+ return None
+
+
+def _download_image_sync(message_id, image_key):
+ try:
+ request = GetMessageResourceRequest.builder().message_id(message_id).file_key(image_key).type("image").build()
+ response = client.im.v1.message_resource.get(request)
+ if response.success():
+ data = response.file.read() if hasattr(response.file, "read") else response.file
+ return data, response.file_name
+ print(f"[ERROR] download image failed: {response.code}, {response.msg}")
+ except Exception as e:
+ print(f"[ERROR] download image failed {image_key}: {e}")
+ return None, None
+
+
+def _download_file_sync(message_id, file_key, resource_type="file"):
+ if resource_type == "audio":
+ resource_type = "file"
+ try:
+ request = GetMessageResourceRequest.builder().message_id(message_id).file_key(file_key).type(resource_type).build()
+ response = client.im.v1.message_resource.get(request)
+ if response.success():
+ data = response.file.read() if hasattr(response.file, "read") else response.file
+ return data, response.file_name
+ print(f"[ERROR] download {resource_type} failed: {response.code}, {response.msg}")
+ except Exception as e:
+ print(f"[ERROR] download {resource_type} failed {file_key}: {e}")
+ return None, None
+
+
+def _download_and_save_media(msg_type, content_json, message_id):
+ data, filename = None, None
+ if msg_type == "image":
+ image_key = content_json.get("image_key")
+ if image_key and message_id:
+ data, filename = _download_image_sync(message_id, image_key)
+ if not filename:
+ filename = f"{image_key[:16]}.jpg"
+ elif msg_type in ("audio", "file", "media"):
+ file_key = content_json.get("file_key")
+ if file_key and message_id:
+ data, filename = _download_file_sync(message_id, file_key, msg_type)
+ if not filename:
+ filename = file_key[:16]
+ if msg_type == "audio" and filename and not filename.endswith(".opus"):
+ filename = f"{filename}.opus"
+ if data and filename:
+ file_path = os.path.join(MEDIA_DIR, os.path.basename(filename))
+ with open(file_path, "wb") as f:
+ f.write(data)
+ return file_path, filename
+ return None, None
+
+
+def _describe_media(msg_type, file_path, filename):
+ if msg_type == "image":
+ return f"[image: {filename}]\n[Image: source: {file_path}]"
+ if msg_type == "audio":
+ return f"[audio: {filename}]\n[File: source: {file_path}]"
+ if msg_type in ("file", "media"):
+ return f"[{msg_type}: {filename}]\n[File: source: {file_path}]"
+ return f"[{msg_type}]\n[File: source: {file_path}]"
+
+
+def _send_local_file(receive_id, file_path, receive_id_type="open_id"):
+ if not os.path.isfile(file_path):
+ send_message(receive_id, f"⚠️ 文件不存在: {file_path}", receive_id_type=receive_id_type)
+ return False
+ ext = os.path.splitext(file_path)[1].lower()
+ if ext in _IMAGE_EXTS:
+ image_key = _upload_image_sync(file_path)
+ if image_key:
+ send_message(receive_id, json.dumps({"image_key": image_key}, ensure_ascii=False), msg_type="image", receive_id_type=receive_id_type)
+ return True
+ else:
+ file_key = _upload_file_sync(file_path)
+ if file_key:
+ msg_type = "media" if ext in _AUDIO_EXTS or ext in _VIDEO_EXTS else "file"
+ send_message(receive_id, json.dumps({"file_key": file_key}, ensure_ascii=False), msg_type=msg_type, receive_id_type=receive_id_type)
+ return True
+ send_message(receive_id, f"⚠️ 文件发送失败: {os.path.basename(file_path)}", receive_id_type=receive_id_type)
+ return False
+
+
+def _send_generated_files(receive_id, raw_text, receive_id_type="open_id"):
+ for file_path in _extract_files(raw_text):
+ _send_local_file(receive_id, file_path, receive_id_type)
+
+
+def _build_user_message(message):
+ msg_type = message.message_type
+ message_id = message.message_id
+ content_json = _parse_json(message.content)
+ parts, image_paths = [], []
+ if msg_type == "text":
+ text = str(content_json.get("text", "") or "").strip()
+ if text:
+ parts.append(text)
+ elif msg_type == "post":
+ text, image_keys = _extract_post_content(content_json)
+ if text:
+ parts.append(text)
+ for image_key in image_keys:
+ file_path, filename = _download_and_save_media("image", {"image_key": image_key}, message_id)
+ if file_path and filename:
+ parts.append(_describe_media("image", file_path, filename))
+ image_paths.append(file_path)
+ else:
+ parts.append("[image: download failed]")
+ elif msg_type in ("image", "audio", "file", "media"):
+ file_path, filename = _download_and_save_media(msg_type, content_json, message_id)
+ if file_path and filename:
+ parts.append(_describe_media(msg_type, file_path, filename))
+ if msg_type == "image":
+ image_paths.append(file_path)
+ else:
+ parts.append(f"[{msg_type}: download failed]")
+ elif msg_type in ("share_chat", "share_user", "interactive", "share_calendar_event", "system", "merge_forward"):
+ parts.append(_extract_share_card_content(content_json, msg_type))
+ else:
+ parts.append(_MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]"))
+ return "\n".join([p for p in parts if p]).strip(), image_paths
+
+
+def _fmt_tool_call(tc):
+ name = tc.get('tool_name', '?')
+ args = {k: v for k, v in (tc.get('args') or {}).items() if not k.startswith('_')}
+ return f"- `{name}`({json.dumps(args, ensure_ascii=False)[:200]})"
+
+
+def _build_step_detail(resp, tool_calls):
+ """从 LLM response + tool_calls 组装单步展开详情(纯函数)。"""
+ parts = []
+ thinking = (getattr(resp, 'thinking', '') or '').strip() if resp else ''
+ if thinking:
+ parts.append(f"### 💭 Thinking\n{thinking}")
+ if tool_calls:
+ parts.append("### 🛠 Tool Calls\n" + "\n".join(_fmt_tool_call(tc) for tc in tool_calls))
+ content = _display_text((getattr(resp, 'content', '') or '')).strip() if resp else ''
+ if content and content != '...':
+ parts.append(f"### 📝 Output\n{content}")
+ return "\n\n".join(parts)
+
+
+class _TaskCard:
+ """飞书任务卡片:单卡片持续 patch;每步一个独立折叠面板(header 显示 summary,展开看详情)。"""
+ _DETAIL_LIMIT = 8000
+
+ def __init__(self, receive_id, rid_type):
+ self.rid, self.rtype = receive_id, rid_type
+ self.steps = [] # [(summary, detail), ...]
+ self.status = "🤔 思考中..."
+ self.final = None
+ self.msg_id = None
+ self.start_fallback_sent = False
+ self.final_fallback_sent = False
+
+ def _step_panel(self, idx, summary, detail):
+ detail = detail or "_(无输出)_"
+ if len(detail) > self._DETAIL_LIMIT:
+ detail = detail[:self._DETAIL_LIMIT] + f"\n\n…(已截断,共 {len(detail)} 字符)"
+ return {
+ "tag": "collapsible_panel", "expanded": False,
+ "header": {"title": {"tag": "plain_text", "content": f"Turn {idx} · {summary}"}},
+ "elements": [{"tag": "markdown", "content": detail}],
+ }
+
+ def _build(self):
+ els = [{"tag": "markdown", "content": f"**{self.status}**"}]
+ for i, (s, d) in enumerate(self.steps, 1):
+ els.append(self._step_panel(i, s, d))
+ if self.final:
+ els += [{"tag": "hr"}, {"tag": "markdown", "content": self.final}]
+ return _card_raw(els)
+
+ def _push(self):
+ card = self._build()
+ if self.msg_id:
+ ok = _patch_card(self.msg_id, card)
+ else:
+ self.msg_id = _send_raw(self.rid, card, "interactive", self.rtype)
+ ok = bool(self.msg_id)
+ return ok
+
+ def _fallback_text(self, text, *, final=False):
+ attr = "final_fallback_sent" if final else "start_fallback_sent"
+ if getattr(self, attr):
+ return
+ setattr(self, attr, True)
+ send_message(self.rid, text, receive_id_type=self.rtype)
+
+ # ── 公开接口 ──
+
+ def start(self):
+ if not self._push():
+ self._fallback_text("🤔 思考中...")
+
+ def step(self, summary, detail=""):
+ self.steps.append((summary, detail))
+ self.status = f"⏳ 工作中 · Turn {len(self.steps)}"
+ self._push()
+
+ def done(self, text):
+ self.status = "✅ 已完成"
+ self.final = text or "_(无文本输出)_"
+ if not self._push():
+ self._fallback_text(_display_text(text), final=True)
+
+ def fail(self, msg):
+ self.status = f"❌ {msg}"
+ if not self._push():
+ self._fallback_text(f"❌ {msg}", final=True)
+
+
+def _make_task_hook(card, task_id, on_final):
+ """飞书任务 hook:每轮 patch 卡片状态;结束触发 on_final(raw) 处理附件。"""
+ def hook(ctx):
+ try:
+ parent = getattr(ctx.get("self"), "parent", None)
+ if getattr(parent, "_fs_active_task_id", None) != task_id:
+ return
+ if ctx.get('exit_reason'):
+ resp = ctx.get('response')
+ raw = resp.content if hasattr(resp, 'content') else str(resp)
+ on_final(raw)
+ elif ctx.get('summary'):
+ detail = _build_step_detail(ctx.get('response'), ctx.get('tool_calls') or [])
+ card.step(ctx['summary'], detail)
+ except Exception as e:
+ print(f"[fs hook] error: {e}")
+ return hook
+
+
+class FeishuApp(AgentChatMixin):
+ label, source, split_limit = "Feishu", "feishu", 4000
+
+ async def send_text(self, chat_id, content, *, receive_id=None, receive_id_type="open_id", **_):
+ rid = receive_id or chat_id
+ for part in split_text(content, self.split_limit):
+ await asyncio.to_thread(send_message, rid, part, "text", False, receive_id_type)
+
+ async def send_done(self, chat_id, raw_text, *, receive_id=None, receive_id_type="open_id", **_):
+ rid = receive_id or chat_id
+ text = _display_text(raw_text)
+ await asyncio.to_thread(send_message, rid, text, "text", False, receive_id_type)
+ await asyncio.to_thread(_send_generated_files, rid, raw_text, receive_id_type)
+
+ async def run_agent(self, chat_id, text, *, receive_id=None, receive_id_type="open_id", images=None, **_):
+ if self.user_tasks:
+ await self.send_text(chat_id, "当前会话已有任务在运行,请等待完成或发送 /stop 后再试。", receive_id=receive_id, receive_id_type=receive_id_type)
+ return
+ state = {"running": True}
+ self.user_tasks[chat_id] = state
+ rid = receive_id or chat_id
+ task_id = f"{chat_id}_{uuid.uuid4().hex}"
+ hook_key = f"fs_{task_id}"
+ card = _TaskCard(rid, receive_id_type)
+ result = {"raw": None, "sent": False}
+ finish_lock = threading.Lock()
+
+ def _finish(raw):
+ with finish_lock:
+ if result["sent"]:
+ return
+ result["raw"] = raw
+ result["sent"] = True
+ card.done(_display_text(raw))
+ _send_generated_files(rid, raw, receive_id_type=receive_id_type)
+
+ try:
+ await asyncio.to_thread(card.start)
+ if not hasattr(self.agent, '_turn_end_hooks'):
+ self.agent._turn_end_hooks = {}
+ self.agent._turn_end_hooks[hook_key] = _make_task_hook(card, task_id, _finish)
+ self.agent._fs_active_task_id = task_id
+ dq = self.agent.put_task(f"{FILE_HINT}\n\n{text}", source=self.source, images=images or None)
+ start = time.time()
+ while state["running"] and not result["sent"]:
+ try:
+ item = await asyncio.to_thread(dq.get, True, 1)
+ except Q.Empty:
+ item = None
+ if item and "done" in item:
+ await asyncio.to_thread(_finish, item.get("done", ""))
+ break
+ if time.time() - start > AGENT_TIMEOUT_SEC:
+ self.agent.abort()
+ await asyncio.to_thread(card.fail, "任务超时")
+ break
+ if not state["running"] and not result["sent"]:
+ self.agent.abort()
+ await asyncio.to_thread(card.fail, "已停止")
+ except Exception as e:
+ traceback.print_exc()
+ await asyncio.to_thread(card.fail, f"错误: {e}")
+ finally:
+ if getattr(self.agent, "_fs_active_task_id", None) == task_id:
+ try:
+ delattr(self.agent, "_fs_active_task_id")
+ except AttributeError:
+ pass
+ if hasattr(self.agent, '_turn_end_hooks'):
+ self.agent._turn_end_hooks.pop(hook_key, None)
+ self.user_tasks.pop(chat_id, None)
+
+
+def get_app():
+ global app
+ if app is None:
+ app = FeishuApp(get_agent(), user_tasks)
+ return app
+
+
+def _run_async(coro):
+ try:
+ asyncio.run(coro)
+ except Exception:
+ traceback.print_exc()
+
+
+def handle_message(data):
+ event, message, sender = data.event, data.event.message, data.event.sender
+ message_id = getattr(message, "message_id", "") or ""
+ if not _claim_message_once(message_id):
+ print(f"忽略重复飞书消息: {message_id}")
+ return
+ open_id = sender.sender_id.open_id
+ chat_id = message.chat_id
+ if not PUBLIC_ACCESS and open_id not in ALLOWED_USERS:
+ print(f"未授权用户: {open_id}")
+ return
+ user_input, image_paths = _build_user_message(message)
+ if not user_input:
+ if chat_id:
+ send_message(chat_id, f"⚠️ 暂不支持处理此类飞书消息:{message.message_type}", receive_id_type="chat_id")
+ else:
+ send_message(open_id, f"⚠️ 暂不支持处理此类飞书消息:{message.message_type}")
+ return
+ print(f"收到消息 [{open_id}] ({message.message_type}, {len(image_paths)} images): {user_input[:200]}")
+ receive_id = chat_id or open_id
+ receive_id_type = "chat_id" if chat_id else "open_id"
+ chat_key = receive_id
+ if message.message_type == "text" and user_input.startswith("/"):
+ threading.Thread(
+ target=_run_async,
+ args=(get_app().handle_command(chat_key, user_input, receive_id=receive_id, receive_id_type=receive_id_type),),
+ daemon=True,
+ ).start()
+ return
+ threading.Thread(
+ target=_run_async,
+ args=(get_app().run_agent(chat_key, user_input, receive_id=receive_id, receive_id_type=receive_id_type, images=image_paths),),
+ daemon=True,
+ ).start()
+
+
+def main():
+ global client, APP_ID, APP_SECRET, ALLOWED_USERS, PUBLIC_ACCESS, CONFIG_PATH
+ APP_ID, APP_SECRET, ALLOWED_USERS, PUBLIC_ACCESS, CONFIG_PATH = _feishu_config()
+ if not APP_ID or not APP_SECRET:
+ print(f"错误: 请在 mykey 配置中填写 fs_app_id 和 fs_app_secret\n配置文件: {CONFIG_PATH}", flush=True)
+ sys.exit(1)
+ handler = lark.EventDispatcherHandler.builder("", "").register_p2_im_message_receive_v1(handle_message).build()
+ retry_delay = 5
+ while True:
+ try:
+ client = create_client()
+ cli = lark.ws.Client(APP_ID, APP_SECRET, event_handler=handler, log_level=lark.LogLevel.INFO)
+ print("=" * 50 + "\n飞书 Agent 已启动(长连接模式)\n" + f"App ID: {APP_ID}\n配置: {CONFIG_PATH}\n等待消息...\n" + "=" * 50, flush=True)
+ cli.start()
+ retry_delay = 5
+ except KeyboardInterrupt:
+ raise
+ except Exception as e:
+ print(f"[WARN] 飞书长连接断开或启动失败: {e}", flush=True)
+ traceback.print_exc()
+ print(f"[INFO] {retry_delay}s 后重连飞书长连接...", flush=True)
+ time.sleep(retry_delay)
+ retry_delay = min(retry_delay * 2, 120)
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description="A3Agent Feishu frontend")
+ parser.add_argument("--check", action="store_true", help="只检查飞书配置,不启动长连接")
+ parser.add_argument("--check-agent", action="store_true", help="检查配置并初始化 Agent/LLM")
+ args = parser.parse_args()
+ if args.check or args.check_agent:
+ print(json.dumps(check_config(init_agent=args.check_agent), ensure_ascii=False, indent=2), flush=True)
+ else:
+ main()
diff --git a/frontends/genericagent_acp_bridge.py b/frontends/genericagent_acp_bridge.py
new file mode 100644
index 000000000..7882b6162
--- /dev/null
+++ b/frontends/genericagent_acp_bridge.py
@@ -0,0 +1,375 @@
+import io
+import json
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+# Must run BEFORE importing agentmain — it reconfigures stdout at import time,
+# and its submodules may print() during init. We capture the raw binary stdout
+# for ACP JSON-RPC, then redirect the text-mode stdout to stderr so any stray
+# prints from agentmain/llmcore don't pollute the ACP channel.
+if sys.platform == "win32":
+ import msvcrt
+ _stdout_fd = os.dup(sys.__stdout__.fileno())
+ msvcrt.setmode(_stdout_fd, os.O_BINARY)
+ _acp_stdout = os.fdopen(_stdout_fd, "wb", buffering=0)
+ msvcrt.setmode(sys.stdin.fileno(), os.O_BINARY)
+ # Mark the ACP fd as non-inheritable so child processes can't write to it.
+ os.set_inheritable(_stdout_fd, False)
+ # Redirect the original stdout fd to stderr so child processes
+ # (tool calls) don't write into the ACP JSON-RPC channel.
+ os.dup2(sys.stderr.fileno(), sys.__stdout__.fileno())
+else:
+ _stdout_fd = os.dup(sys.__stdout__.fileno())
+ os.set_inheritable(_stdout_fd, False)
+ _acp_stdout = os.fdopen(_stdout_fd, "wb", buffering=0)
+ os.dup2(sys.stderr.fileno(), sys.__stdout__.fileno())
+
+
+class _StdoutToStderrRouter(io.TextIOBase):
+ """Redirect text-mode stdout to stderr so agentmain prints don't leak."""
+ def writable(self): return True
+ def write(self, s):
+ if s:
+ sys.stderr.write(s)
+ sys.stderr.flush()
+ return len(s) if s else 0
+ def flush(self): sys.stderr.flush()
+
+sys.stdout = _StdoutToStderrRouter()
+
+import argparse
+import queue
+import threading
+import traceback
+import uuid
+from dataclasses import dataclass, field
+from typing import Any, Dict, List, Optional
+
+from agentmain import GeneraticAgent
+
+
+JSONRPC_VERSION = "2.0"
+ACP_PROTOCOL_VERSION = 1
+
+
+def eprint(*args: Any) -> None:
+ print(*args, file=sys.stderr, flush=True)
+
+
+def make_text_block(text: str) -> Dict[str, Any]:
+ return {"type": "text", "text": text}
+
+
+def make_session_update(session_id: str, update: Dict[str, Any]) -> Dict[str, Any]:
+ return {
+ "jsonrpc": JSONRPC_VERSION,
+ "method": "session/update",
+ "params": {"sessionId": session_id, "update": update},
+ }
+
+
+def compact_json(obj: Dict[str, Any]) -> str:
+ return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
+
+
+def parse_jsonrpc_line(line: str) -> Optional[Dict[str, Any]]:
+ stripped = line.strip()
+ if not stripped:
+ return None
+ try:
+ obj = json.loads(stripped)
+ except json.JSONDecodeError:
+ return None
+ return obj if isinstance(obj, dict) else None
+
+
+def content_blocks_to_text(blocks: List[Dict[str, Any]]) -> str:
+ parts: List[str] = []
+ for block in blocks:
+ if not isinstance(block, dict):
+ continue
+ block_type = block.get("type")
+ if block_type == "text":
+ text = block.get("text")
+ if isinstance(text, str) and text:
+ parts.append(text)
+ elif block_type == "resource_link":
+ name = block.get("name") or "resource"
+ uri = block.get("uri") or ""
+ desc = block.get("description") or ""
+ parts.append(f"[ResourceLink] {name}: {uri}\n{desc}".strip())
+ elif block_type == "resource":
+ uri = block.get("uri") or "resource"
+ text = block.get("text")
+ if isinstance(text, str) and text:
+ parts.append(f"[Resource] {uri}\n{text}")
+ else:
+ parts.append(f"[Resource] {uri}")
+ elif block_type == "image":
+ uri = block.get("uri") or "inline-image"
+ parts.append(f"[Image omitted] {uri}")
+ else:
+ parts.append(f"[Unsupported content block: {block_type}]")
+ return "\n\n".join(p for p in parts if p).strip()
+
+
+def jsonrpc_error(code: int, message: str, req_id: Any = None, data: Any = None) -> Dict[str, Any]:
+ err: Dict[str, Any] = {"code": code, "message": message}
+ if data is not None:
+ err["data"] = data
+ return {"jsonrpc": JSONRPC_VERSION, "id": req_id, "error": err}
+
+
+def jsonrpc_result(req_id: Any, result: Any) -> Dict[str, Any]:
+ return {"jsonrpc": JSONRPC_VERSION, "id": req_id, "result": result}
+
+
+@dataclass
+class SessionState:
+ session_id: str
+ cwd: str
+ agent: GeneraticAgent
+ current_prompt_id: Any = None
+ prompt_lock: threading.Lock = field(default_factory=threading.Lock)
+
+
+class GenericAgentAcpBridge:
+ def __init__(self, llm_no: int = 0):
+ self.llm_no = llm_no
+ self._json_out = _acp_stdout
+ self._write_lock = threading.Lock()
+ self._sessions: Dict[str, SessionState] = {}
+ self._shutdown = False
+
+ def write_message(self, msg: Dict[str, Any]) -> None:
+ payload = compact_json(msg)
+ raw = (payload + "\n").encode("utf-8")
+ method = msg.get("method", msg.get("id", "?"))
+ eprint(f"[ACP-BRIDGE] >>> {payload[:500]}")
+ try:
+ with self._write_lock:
+ self._json_out.write(raw)
+ self._json_out.flush()
+ except Exception as e:
+ eprint(f"[ACP-BRIDGE] WRITE FAILED: {type(e).__name__}: {e}")
+
+ def new_agent(self) -> GeneraticAgent:
+ agent = GeneraticAgent()
+ agent.next_llm(self.llm_no)
+ agent.verbose = True
+ agent.inc_out = True
+ threading.Thread(target=agent.run, daemon=True).start()
+ return agent
+
+ def handle_initialize(self, req_id: Any, params: Dict[str, Any]) -> None:
+ requested_version = params.get("protocolVersion", ACP_PROTOCOL_VERSION)
+ version = ACP_PROTOCOL_VERSION if requested_version == ACP_PROTOCOL_VERSION else ACP_PROTOCOL_VERSION
+ result = {
+ "protocolVersion": version,
+ "agentCapabilities": {
+ "loadSession": False,
+ "mcpCapabilities": {"http": False, "sse": False},
+ "promptCapabilities": {
+ "image": False,
+ "audio": False,
+ "embeddedContext": False,
+ },
+ "sessionCapabilities": {},
+ },
+ "agentInfo": {
+ "name": "genericagent-acp",
+ "title": "GenericAgent",
+ "version": "0.1.0",
+ },
+ "authMethods": [],
+ }
+ self.write_message(jsonrpc_result(req_id, result))
+
+ def handle_session_new(self, req_id: Any, params: Dict[str, Any]) -> None:
+ cwd = params.get("cwd")
+ if not isinstance(cwd, str) or not cwd:
+ self.write_message(jsonrpc_error(-32602, "cwd is required", req_id))
+ return
+ if not os.path.isabs(cwd):
+ cwd = os.path.abspath(cwd)
+ session_id = f"ga_{uuid.uuid4().hex}"
+ agent = self.new_agent()
+ session = SessionState(session_id=session_id, cwd=cwd, agent=agent)
+ self._sessions[session_id] = session
+ self.write_message(
+ jsonrpc_result(
+ req_id,
+ {
+ "sessionId": session_id,
+ "modes": None,
+ "configOptions": None,
+ },
+ )
+ )
+
+ def handle_session_prompt(self, req_id: Any, params: Dict[str, Any]) -> None:
+ session_id = params.get("sessionId")
+ prompt_blocks = params.get("prompt")
+ session = self._sessions.get(session_id)
+ if session is None:
+ self.write_message(jsonrpc_error(-32602, "unknown sessionId", req_id))
+ return
+ if not isinstance(prompt_blocks, list):
+ self.write_message(jsonrpc_error(-32602, "prompt must be an array", req_id))
+ return
+ prompt_text = content_blocks_to_text(prompt_blocks)
+ if not prompt_text:
+ self.write_message(jsonrpc_error(-32602, "prompt must contain text or supported content", req_id))
+ return
+
+ with session.prompt_lock:
+ if session.current_prompt_id is not None:
+ self.write_message(
+ jsonrpc_error(-32603, "session already has an active prompt", req_id)
+ )
+ return
+ session.current_prompt_id = req_id
+
+ def run_prompt() -> None:
+ stop_reason = "end_turn"
+ try:
+ dq = session.agent.put_task(prompt_text, source="acp")
+ self._drain_agent_queue(session, dq)
+ except Exception as exc:
+ stop_reason = "end_turn"
+ self.write_message(
+ make_session_update(
+ session.session_id,
+ {
+ "sessionUpdate": "agent_message_chunk",
+ "content": make_text_block(
+ f"[Bridge error] {type(exc).__name__}: {exc}"
+ ),
+ },
+ )
+ )
+ eprint("[GenericAgent ACP] prompt thread failed:", traceback.format_exc())
+ finally:
+ with session.prompt_lock:
+ finished_req_id = session.current_prompt_id
+ session.current_prompt_id = None
+ if finished_req_id is not None:
+ import time
+ time.sleep(0.1)
+ self.write_message(
+ jsonrpc_result(finished_req_id, {"stopReason": stop_reason})
+ )
+
+ threading.Thread(target=run_prompt, daemon=True).start()
+
+ def _drain_agent_queue(self, session: SessionState, dq: "queue.Queue[Dict[str, Any]]") -> None:
+ sent_any = False
+ while True:
+ item = dq.get()
+ if not isinstance(item, dict):
+ continue
+ # With inc_out=True, "next" items are already incremental deltas.
+ if "next" in item and "done" not in item:
+ delta = item["next"]
+ if isinstance(delta, str) and delta:
+ sent_any = True
+ try:
+ self.write_message(
+ make_session_update(
+ session.session_id,
+ {
+ "sessionUpdate": "agent_message_chunk",
+ "content": make_text_block(delta),
+ },
+ )
+ )
+ except Exception as e:
+ eprint(f"[ACP-BRIDGE] ERROR writing update: {e}")
+ if "done" in item:
+ # "done" text has post-processing ( \n\n insertion)
+ # that shifts offsets — cannot safely compute a tail delta.
+ # Only use "done" content if nothing was streamed (error case).
+ if not sent_any:
+ done_text = item["done"]
+ if isinstance(done_text, str) and done_text:
+ try:
+ self.write_message(
+ make_session_update(
+ session.session_id,
+ {
+ "sessionUpdate": "agent_message_chunk",
+ "content": make_text_block(done_text),
+ },
+ )
+ )
+ except Exception as e:
+ eprint(f"[ACP-BRIDGE] ERROR writing done: {e}")
+ break
+
+ def handle_session_cancel(self, params: Dict[str, Any]) -> None:
+ session_id = params.get("sessionId")
+ session = self._sessions.get(session_id)
+ if session is None:
+ return
+ if session.current_prompt_id is not None:
+ session.agent.abort()
+
+ def handle_message(self, msg: Dict[str, Any]) -> None:
+ method = msg.get("method")
+ req_id = msg.get("id")
+ params = msg.get("params") or {}
+
+ try:
+ if method == "initialize":
+ self.handle_initialize(req_id, params)
+ elif method == "session/new":
+ self.handle_session_new(req_id, params)
+ elif method == "session/prompt":
+ self.handle_session_prompt(req_id, params)
+ elif method == "session/cancel":
+ self.handle_session_cancel(params)
+ elif method == "session/load":
+ self.write_message(jsonrpc_error(-32601, "session/load not supported", req_id))
+ elif method == "session/list":
+ self.write_message(jsonrpc_error(-32601, "session/list not supported", req_id))
+ elif method == "session/close":
+ self.write_message(jsonrpc_result(req_id, {}))
+ elif method is None:
+ if req_id is not None:
+ self.write_message(jsonrpc_error(-32600, "invalid request", req_id))
+ else:
+ if req_id is not None:
+ self.write_message(jsonrpc_error(-32601, f"method not found: {method}", req_id))
+ except Exception as exc:
+ eprint("[GenericAgent ACP] request handler failed:", traceback.format_exc())
+ if req_id is not None:
+ self.write_message(
+ jsonrpc_error(-32603, f"internal error: {type(exc).__name__}: {exc}", req_id)
+ )
+
+ def serve(self) -> None:
+ eprint("[GenericAgent ACP] bridge started")
+ stdin = io.TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace") if hasattr(sys.stdin, 'buffer') else sys.stdin
+ for raw_line in stdin:
+ msg = parse_jsonrpc_line(raw_line)
+ if msg is None:
+ continue
+ self.handle_message(msg)
+ if self._shutdown:
+ break
+ eprint("[GenericAgent ACP] bridge stopped")
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="GenericAgent ACP bridge over stdio")
+ parser.add_argument("--llm-no", type=int, default=0, help="LLM index for GenericAgent")
+ args = parser.parse_args()
+ bridge = GenericAgentAcpBridge(llm_no=args.llm_no)
+ bridge.serve()
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/frontends/keysym.py b/frontends/keysym.py
new file mode 100644
index 000000000..3d931027e
--- /dev/null
+++ b/frontends/keysym.py
@@ -0,0 +1,72 @@
+"""Cross-platform keyboard-shortcut display formatter.
+
+One job: turn a Textual binding string like ``"ctrl+b"`` into a human-facing
+label such as ``"Ctrl+B"`` on Win/Linux or ``"⌃B"`` on macOS.
+
+Binding strings (the *physical* keys Textual captures) are NOT touched —
+this module only formats labels for tips / footers / help panels.
+
+Override with env ``GA_KEYSYM_STYLE=auto|mac|ascii`` (default ``auto``).
+"""
+from __future__ import annotations
+
+import os
+import sys
+
+_STYLE = os.environ.get("GA_KEYSYM_STYLE", "auto").lower()
+IS_MAC = _STYLE == "mac" or (_STYLE != "ascii" and sys.platform == "darwin")
+
+# Modifier display per style. mac uses Apple HIG glyphs; others use words.
+_MOD = {
+ "ctrl": "⌃" if IS_MAC else "Ctrl",
+ "shift": "⇧" if IS_MAC else "Shift",
+ "alt": "⌥" if IS_MAC else "Alt",
+ "meta": "⌘" if IS_MAC else "Alt",
+ "super": "⌘" if IS_MAC else "Win",
+ "cmd": "⌘" if IS_MAC else "Win",
+}
+
+# Bare-key display. Arrows / slash are universal; rest mac-glyphs vs words.
+_KEY = {
+ "enter": "⏎" if IS_MAC else "Enter",
+ "tab": "⇥" if IS_MAC else "Tab",
+ "escape": "⎋" if IS_MAC else "Esc",
+ "esc": "⎋" if IS_MAC else "Esc",
+ "backspace": "⌫" if IS_MAC else "Backspace",
+ "delete": "⌦" if IS_MAC else "Del",
+ "space": "␣" if IS_MAC else "Space",
+ "up": "↑", "down": "↓", "left": "←", "right": "→",
+ "slash": "/", "underscore": "_",
+}
+
+# Joiner between modifier and key. mac concatenates (⌃B); others use '+'.
+_JOIN = "" if IS_MAC else "+"
+
+
+def fmt_key(combo: str) -> str:
+ """``"ctrl+b"`` → ``"⌃B"`` (mac) / ``"Ctrl+B"`` (Win/Linux).
+
+ Unknown single-char keys are upper-cased (``"b"`` → ``"B"``);
+ multi-char names fall back to the original token unchanged.
+ """
+ parts = [p.strip() for p in combo.lower().split("+") if p.strip()]
+ if not parts:
+ return combo
+ mods, key = parts[:-1], parts[-1]
+ key_disp = _KEY.get(key) or (key.upper() if len(key) == 1 else key)
+ mod_disp = [_MOD.get(m, m) for m in mods]
+ if not mod_disp:
+ return key_disp
+ return _JOIN.join(mod_disp) + _JOIN + key_disp
+
+
+def fmt_keys(*combos: str, sep: str = " / ") -> str:
+ """Join multiple combos: ``fmt_keys("ctrl+j", "ctrl+enter")`` →
+ ``"Ctrl+J / Ctrl+Enter"`` or ``"⌃J / ⌃⏎"``."""
+ return sep.join(fmt_key(c) for c in combos)
+
+
+# Convenience constants for f-string templates.
+CTRL = _MOD["ctrl"]
+SHIFT = _MOD["shift"]
+ALT = _MOD["alt"]
diff --git a/frontends/model_cmd.py b/frontends/model_cmd.py
new file mode 100644
index 000000000..5f9f098c4
--- /dev/null
+++ b/frontends/model_cmd.py
@@ -0,0 +1,120 @@
+"""/model 命令的 agent 无关逻辑: 拉模型列表 + 运行时改 model。
+被 tuiapp_v2 import; 不 patch 任何类, 不依赖 llmcore。"""
+from typing import Optional, List, Tuple
+
+import requests
+
+
+def _is_mixin(b) -> bool:
+ return type(b).__name__ == 'MixinSession'
+
+
+def _resolve(agent, sub: Optional[int] = None):
+ """返回 (真正持有 model 的 session, mixin或None)。"""
+ b = agent.llmclient.backend
+ if _is_mixin(b):
+ return b._sessions[b._cur_idx if sub is None else sub], b
+ return b, None
+
+
+def list_subsessions(agent) -> Optional[List[Tuple[int, str, bool]]]:
+ """mixin 渠道 → [(idx, name, is_current)]; 普通渠道 → None。"""
+ b = agent.llmclient.backend
+ if not _is_mixin(b):
+ return None
+ return [(i, s.name, i == b._cur_idx) for i, s in enumerate(b._sessions)]
+
+
+def current_model(agent, sub: Optional[int] = None) -> str:
+ s, _ = _resolve(agent, sub)
+ return s.model
+
+
+def fetch_models(agent, sub: Optional[int] = None, timeout: int = 10) -> List[str]:
+ """GET models 列表, 自动尝试 /models 与 /v1/models、原生头与 Bearer。"""
+ s, _ = _resolve(agent, sub)
+ base = s.api_base.rstrip('/')
+ urls = [f'{base}/models'] + ([] if base.endswith('/v1') else [f'{base}/v1/models'])
+ heads = [{'Authorization': f'Bearer {s.api_key}'}]
+ if 'Claude' in type(s).__name__:
+ heads.insert(0, {'x-api-key': s.api_key, 'anthropic-version': '2023-06-01'})
+ err = None
+ for url in urls:
+ for h in heads:
+ try:
+ r = requests.get(url, headers=h, timeout=timeout,
+ proxies=getattr(s, 'proxies', None),
+ verify=getattr(s, 'verify', True))
+ r.raise_for_status()
+ data = r.json().get('data', []) # 非 JSON(如 HTML 首页)会抛错进入下一候选
+ ids = {m['id'] for m in data if isinstance(m, dict) and m.get('id')}
+ if ids:
+ return sorted(ids)
+ except Exception as e:
+ err = e
+ raise err or RuntimeError('no models endpoint')
+
+
+def set_model(agent, model: str, sub: Optional[int] = None) -> str:
+ """运行时改 model(内存态, mykey 重载/重启后还原)。返回结果描述。"""
+ s, mixin = _resolve(agent, sub)
+ old = s.model
+ s.model = model # mixin 的 model 是只读 property, 必须落子 session
+ warn = ""
+ try: # 对齐 agentmain.next_llm 的中文 schema 切换
+ from agentmain import load_tool_schema
+ load_tool_schema('_cn' if any(x in model.lower() for x in ('glm', 'minimax', 'kimi')) else '')
+ except Exception as ex: # schema 选错会实际影响 agent 行为, 半径不为零, 不静默
+ warn = f" (⚠ schema 切换失败: {type(ex).__name__})"
+ where = f"[{s.name}]" if mixin else s.name
+ return f"{where}: {old} → {model}{warn}"
+
+
+# GA 配置层允许的全部档位(llmcore BaseSession._enum)。各协议的真实支持面不同:
+# Claude(output_config.effort) 只认 low/medium/high + xhigh→max, none/minimal
+# 会被 _apply_claude_thinking 打 WARN 忽略; OpenAI 系(reasoning_effort /
+# reasoning.effort) 原样透传, 由渠道端校验。
+EFFORT_LEVELS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh']
+
+
+def _protocols(agent) -> set:
+ """当前渠道涉及的协议集合 {'claude', 'openai'}。mixin 看全部子渠道(广播
+ 会落到每一个)。NativeOAISession 名字不含 'Claude', 正确归入 openai。"""
+ b = agent.llmclient.backend
+ sessions = b._sessions if _is_mixin(b) else [b]
+ return {('claude' if 'Claude' in type(s).__name__ else 'openai')
+ for s in sessions}
+
+
+def effort_note(level, protocols) -> str:
+ """单点描述某档位在给定协议上的特殊行为(空串=无特殊)。set_effort 的结果
+ 描述与 /effort picker 的行内备注共用它, 领域知识只在此编码一次。"""
+ if level and 'claude' in protocols:
+ if level in ('none', 'minimal'):
+ return 'Claude 渠道忽略'
+ if level == 'xhigh':
+ return 'Claude 对应 max'
+ return ''
+
+
+def current_effort(agent) -> str:
+ return getattr(agent.llmclient.backend, 'reasoning_effort', None) or ''
+
+
+def set_effort(agent, value) -> str:
+ """运行时改 reasoning_effort(内存态)。空值/off 清除(不再发送 effort 字段)。
+ 直接设在 backend 上: MixinSession 把它列为 _BROADCAST_ATTRS, 会同步到所有
+ 子渠道(故障切换后档位不丢); 普通渠道就是 session 本身。请求时各协议现读
+ 该属性, 立即生效。"""
+ e = (value or '').strip().lower()
+ if e in ('', 'off', 'clear', 'unset'):
+ e = None
+ elif e not in EFFORT_LEVELS:
+ return (f"无效 effort: {value!r}"
+ f" (可选 {'/'.join(EFFORT_LEVELS)}, 留空或 off 清除)")
+ b = agent.llmclient.backend
+ old = getattr(b, 'reasoning_effort', None)
+ b.reasoning_effort = e
+ note = effort_note(e, _protocols(agent))
+ tail = f" ({note})" if note else ""
+ return f"reasoning_effort: {old or '(未设置)'} → {e or '(清除)'}{tail}"
diff --git a/frontends/plan_state.py b/frontends/plan_state.py
new file mode 100644
index 000000000..bab87ee98
--- /dev/null
+++ b/frontends/plan_state.py
@@ -0,0 +1,345 @@
+"""Plan / todo state — pure stdlib, no UI framework dependency.
+
+API:
+ extract(text) → [(content, "open"|"done"), …]
+ is_active(agent, messages=None) → plan mode on (stash OR per-session msg ref)
+ resolve_path(agent, messages=None) → live plan.md path (or None)
+ find_path_in_messages(messages) → most recent plan.md path mentioned
+ current_step(messages) → latest `当前步骤:…` snippet (or "")
+ summary(items) → (n_done, n_total)
+ is_complete(items) → all done (or empty)
+
+Supported task-line shapes (all matched by `extract`):
+ - [ ] foo ← bullet + open
+ - [x] foo ← bullet + done
+ 1. [✓] foo ← numbered + done
+ 2. [✓ 2026-05-16] foo ← numbered + timestamped done, content after bracket
+ 3. [✓ 已生成: foo] ← numbered + done with description *inside* bracket
+ 4. [D][P] foo ← two marker groups (delegate + parallel), still open
+ 5. [D] foo ← non-standard marker "D" → open (not done)
+"""
+from __future__ import annotations
+import os, re
+from typing import Any, Optional
+
+_DONE_CHARS = set("xX✓✔√☑")
+# Newline-insert before a bullet stuck to JSON debris (`{"content": "- [ ] …`).
+_GLUE_RE = re.compile(r"(? str:
+ return _MD_EMPHASIS_RE.sub(lambda m: next(g for g in m.groups() if g is not None), s)
+
+
+def _has_done_glyph(marker: str) -> bool:
+ return any(c in _DONE_CHARS for c in marker)
+
+
+def extract(text: str) -> list[tuple[str, str]]:
+ if not text: return []
+ norm = text.replace("\\n", "\n") if "\\n" in text else text
+ norm = _GLUE_RE.sub(r"\n\1", norm)
+ found: dict[str, str] = {}
+ for line in norm.splitlines():
+ head = _BULLET_RE.match(line)
+ if not head: continue
+ rest = line[head.end():]
+ groups: list[str] = []
+ # Consume any number of consecutive `[...]` groups — covers `[D][P]`
+ # task-type chains as well as the plain `[ ]` / `[x]` single form.
+ while True:
+ b = _BRACKET_RE.match(rest)
+ if not b: break
+ groups.append(b.group(1))
+ rest = rest[b.end():]
+ if not groups: continue
+ is_done = any(_has_done_glyph(g) for g in groups)
+ inline = rest.strip()
+ if inline:
+ content = inline
+ elif is_done:
+ # `[✓ description]` shape — description lives inside the bracket
+ # next to the glyph. Strip the glyph + optional timestamp.
+ done_g = next(g for g in groups if _has_done_glyph(g))
+ content = _INLINE_STRIP_RE.sub("", done_g).strip()
+ else:
+ continue
+ k = _strip_md(_DEBRIS_RE.sub("", content).strip())
+ if not k: continue
+ status = "done" if is_done else "open"
+ # Same content seen twice — done wins over open.
+ if k not in found or status == "done":
+ found[k] = status
+ return list(found.items())
+
+
+def _stashed_plan_path(agent) -> str:
+ # First non-empty `working['in_plan_mode']` from (handler, agent).
+ for src in (getattr(agent, "handler", None), agent):
+ p = ((getattr(src, "working", None) or {}).get("in_plan_mode") or "").strip()
+ if p: return p
+ return ""
+
+
+def _resolve_stashed(p: str) -> Optional[str]:
+ if not p: return None
+ rel = p.lstrip("./\\")
+ cwd = os.getcwd()
+ for c in (p, os.path.join(cwd, "temp", rel), os.path.join(cwd, rel)):
+ if os.path.isfile(c) and os.path.getsize(c) > 0: return c
+ return None
+
+
+# Strict per-session discovery — scan this session's own messages only.
+_PATH_RE = re.compile(r"""((?:\.\/)?(?:temp\/)?plan_[A-Za-z0-9_\-]+\/plan\.md)""")
+
+
+def _slice(messages, start_idx: int):
+ if not messages: return []
+ if start_idx <= 0: return list(messages)
+ return list(messages)[start_idx:]
+
+
+def find_path_in_messages(messages, start_idx: int = 0) -> Optional[str]:
+ """Latest existing `plan_XXX/plan.md` referenced after `start_idx`.
+ Items can be `ChatMessage`-like (`.content`) or plain strings;
+ only paths that exist on disk are returned."""
+ sliced = _slice(messages, start_idx)
+ if not sliced: return None
+ for m in reversed(sliced):
+ text = getattr(m, "content", None)
+ if text is None: text = m if isinstance(m, str) else ""
+ if not text or "plan.md" not in text: continue
+ for hit in reversed(_PATH_RE.findall(text)):
+ p = _resolve_stashed(hit.strip().strip("\"'"))
+ if p: return p
+ return None
+
+
+# Prefer concise `` narrative over the long plan-item echo;
+# treat `❌ 当前步骤:` as "step done", not "current step".
+_SUMMARY_STEP_RE = re.compile(
+ r"[^<]*?当前步骤[::]\s*([^<\n]{1,160}) ", re.DOTALL)
+_STEP_RE = re.compile(r"📌\s*当前步骤[::]\s*([^\n。!!??]{1,160})")
+_DONE_STEP_RE = re.compile(r"❌\s*当前步骤[::]")
+
+
+def current_step(messages, start_idx: int = 0, max_len: int = 60) -> str:
+ """Latest `当前步骤:…` snippet; `` form preferred, `❌`-prefixed
+ skipped. Trimmed to `max_len` chars so it fits the 5-row plan card."""
+ sliced = _slice(messages, start_idx)
+ if not sliced: return ""
+
+ def _clean(s: str) -> str:
+ return _strip_md(re.sub(r"\s+", " ", s).strip().rstrip(" ::—-"))
+
+ def _cap(s: str) -> str:
+ s = _clean(s)
+ if len(s) <= max_len: return s
+ return s[:max_len - 1].rstrip() + "…"
+
+ for m in reversed(sliced):
+ text = getattr(m, "content", None)
+ if text is None: text = m if isinstance(m, str) else ""
+ if not text or "当前步骤" not in text: continue
+ hits = _SUMMARY_STEP_RE.findall(text)
+ if hits: return _cap(hits[-1])
+ for raw in reversed(_STEP_RE.findall(text)):
+ if _DONE_STEP_RE.search(raw): continue
+ return _cap(raw)
+ return ""
+
+
+def is_active(agent, messages=None, start_idx: int = 0,
+ restored_path: str = "") -> bool:
+ """Plan mode is on. Primary: `working['in_plan_mode']`. Then
+ `restored_path` — a path recovered from the transcript's structured
+ `enter_plan_mode` tool_use by /continue (see continue_cmd.find_plan_entry);
+ unlike the message scan it cannot be spoofed by a path typed in chat.
+ Legacy fallback: a `plan_*/plan.md` referenced in this session's messages
+ (no global scan) — only consulted when `messages` is passed."""
+ if _stashed_plan_path(agent): return True
+ if restored_path and _resolve_stashed(restored_path): return True
+ return find_path_in_messages(messages, start_idx) is not None
+
+
+def resolve_path(agent, messages=None, start_idx: int = 0,
+ restored_path: str = "") -> Optional[str]:
+ p = _resolve_stashed(_stashed_plan_path(agent))
+ if p: return p
+ if restored_path:
+ p = _resolve_stashed(restored_path)
+ if p: return p
+ return find_path_in_messages(messages, start_idx)
+
+
+def summary(items: list[tuple[str, str]]) -> tuple[int, int]:
+ return sum(1 for _, st in items if st == "done"), len(items)
+
+
+def is_complete(items: list[tuple[str, str]]) -> bool:
+ return not items or all(st == "done" for _, st in items)
+
+
+# --- Desktop bridge only (APIs above unchanged) ---
+_ENTER_PLAN_RE = re.compile(r"""enter_plan_mode\s*\(\s*["']([^"']+)["']""", re.I)
+
+
+def _msg_content(m) -> str:
+ if isinstance(m, str): return m
+ c = m.get("content") if isinstance(m, dict) else getattr(m, "content", None)
+ return c if isinstance(c, str) else ""
+
+
+def plan_path_mention_in_messages(messages, start_idx: int = 0) -> Optional[str]:
+ for m in reversed(_slice(messages, start_idx)):
+ text = _msg_content(m)
+ if not text: continue
+ if "enter_plan_mode" in text and (hit := _ENTER_PLAN_RE.search(text)):
+ return hit.group(1).strip().strip("\"'")
+ if "plan.md" in text and (hits := _PATH_RE.findall(text)):
+ return hits[-1].strip().strip("\"'")
+ return None
+
+
+def _resolve_stashed_at(p: str, root: str) -> Optional[str]:
+ if not p or not root: return None
+ rel = p.lstrip("./\\")
+ cwd = root.rstrip("/\\")
+ for c in (p, os.path.join(cwd, "temp", rel), os.path.join(cwd, rel)):
+ if os.path.isfile(c) and os.path.getsize(c) > 0: return c
+ return None
+
+
+def _find_path_at(messages, start_idx: int, root: str) -> Optional[str]:
+ for m in reversed(_slice(messages, start_idx)):
+ text = _msg_content(m)
+ if not text or "plan.md" not in text: continue
+ for hit in reversed(_PATH_RE.findall(text)):
+ if p := _resolve_stashed_at(hit.strip().strip("\"'"), root): return p
+ return None
+
+
+def default_session_plan_path(session_id: str) -> str:
+ sid = (session_id or "sess").replace("/", "_")
+ return f"temp/plan_{sid}/plan.md"
+
+
+def is_session_scoped_plan_path(path: str, session_id: str) -> bool:
+ """Desktop: only bind/adopt paths under this session's plan_{id}/ tree."""
+ if not path:
+ return False
+ sid = (session_id or "sess").replace("/", "_")
+ norm = path.replace("\\", "/").lstrip("./")
+ return f"plan_{sid}/" in norm or norm.endswith(f"plan_{sid}/plan.md")
+
+
+def is_plan_preset_prompt(prompt: str) -> bool:
+ p = (prompt or "").lower()
+ return "plan_sop" in p or "plan 模式" in p or "plan mode" in p
+
+
+def bind_plan_session(sess: Any, prompt: str = "", path: Optional[str] = None) -> str:
+ """Bind plan card to this session only (avoids sharing plan_demo/ across sessions)."""
+ rel = (path or "").strip() or default_session_plan_path(getattr(sess, "id", "") or "")
+ sess.plan_path = rel
+ msgs = list(getattr(sess, "messages", []) or [])
+ sess.plan_scan_baseline = len(msgs)
+ return rel
+
+
+def sync_plan_path_from_text(sess: Any, text: str, root: str) -> None:
+ """If agent emits enter_plan_mode(...), keep session bound path in sync."""
+ if not text:
+ return
+ m = _ENTER_PLAN_RE.search(text)
+ if not m:
+ return
+ raw = m.group(1).strip().strip("\"'")
+ if not raw:
+ return
+ sess.plan_path = raw.lstrip("./")
+
+
+def session_plan_active(sess: Any, agent, messages, start_idx: int, root: str) -> bool:
+ """Desktop: active when this session has a bound plan_path (not global plan_*/ scan)."""
+ bound = (getattr(sess, "plan_path", None) or "").strip()
+ if not bound:
+ return False
+ if _stashed_plan_path(agent):
+ return True
+ if _resolve_stashed_at(bound, root):
+ return True
+ if getattr(sess, "status", "") == "running":
+ return True
+ # Plan preset bound this session — keep placeholder until plan.md appears or path changes
+ if is_session_scoped_plan_path(bound, getattr(sess, "id", "")):
+ return True
+ return False
+
+
+def session_plan_resolve(bound: str, root: str) -> Optional[str]:
+ if not bound:
+ return None
+ return _resolve_stashed_at(bound.strip(), root)
+
+
+def _desktop_resolve_plan_file(sess: Any, bound: str, root: str, agent, messages, base: int) -> Optional[str]:
+ """Desktop read path: bound → agent stash → per-session message scan."""
+ path = session_plan_resolve(bound, root)
+ if path:
+ return path
+ if agent and (stash := _stashed_plan_path(agent)):
+ if p := _resolve_stashed_at(stash, root):
+ return p
+ return _find_path_at(messages, base, root)
+
+
+def desktop_plan_payload_from_session(sess: Any, ga_root: str = "") -> dict:
+ """Per-session plan card — bound plan_path + tuiapp_v2-style item/placeholder/complete."""
+ raw = list(getattr(sess, "messages", []) or [])
+ base = max(0, int(getattr(sess, "plan_scan_baseline", 0) or 0))
+ if base > len(raw):
+ base = len(raw)
+ root = (getattr(sess, "cwd", None) or ga_root or "").strip()
+ agent = getattr(sess, "agent", None)
+ partial = getattr(sess, "partial", None)
+ if isinstance(partial, dict) and isinstance(partial.get("content"), str):
+ sync_plan_path_from_text(sess, partial["content"], root)
+ sid = getattr(sess, "id", "") or ""
+ if not (getattr(sess, "plan_path", None) or "").strip():
+ mentioned = plan_path_mention_in_messages(raw, base)
+ if mentioned and is_session_scoped_plan_path(mentioned, sid):
+ sess.plan_path = mentioned.lstrip("./")
+ else:
+ return {"active": False}
+ if not session_plan_active(sess, agent, raw, base, root):
+ return {"active": False}
+ bound = getattr(sess, "plan_path", "") or ""
+ path = _desktop_resolve_plan_file(sess, bound, root, agent, raw, base)
+ items = []
+ if path:
+ try:
+ items = [{"content": c, "status": st} for c, st in extract(open(path, encoding="utf-8", errors="replace").read())]
+ except OSError:
+ pass
+ step_msgs = list(raw[base:])
+ if isinstance(p := getattr(sess, "partial", None), dict) and isinstance(c := p.get("content"), str) and c:
+ step_msgs.append({"content": c})
+ step = current_step(step_msgs, start_idx=0)
+ if not items:
+ hp = getattr(sess, "plan_path", "") or _stashed_plan_path(agent) or ""
+ hint = "/".join(hp.replace("\\", "/").rstrip("/").split("/")[-2:]) if hp else "plan.md"
+ return {"active": True, "placeholder": True, "done": 0, "total": 0, "complete": False, "step": step, "pathHint": hint, "items": []}
+ pairs = [(x["content"], x["status"]) for x in items]
+ n_done, n_total = summary(pairs)
+ return {"active": True, "placeholder": False, "done": n_done, "total": n_total, "complete": is_complete(pairs), "step": step, "items": items}
diff --git a/frontends/qqapp.py b/frontends/qqapp.py
new file mode 100644
index 000000000..2ad088f88
--- /dev/null
+++ b/frontends/qqapp.py
@@ -0,0 +1,130 @@
+import asyncio, os, sys, threading, time
+from collections import deque
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+from agentmain import GeneraticAgent
+from chatapp_common import AgentChatMixin, ensure_single_instance, public_access, redirect_log, require_runtime, split_text
+from llmcore import mykeys
+
+try:
+ import botpy
+ from botpy.message import C2CMessage, GroupMessage
+except Exception:
+ print("Please install qq-botpy to use QQ module: pip install qq-botpy")
+ sys.exit(1)
+
+agent = GeneraticAgent(); agent.verbose = False
+APP_ID = str(mykeys.get("qq_app_id", "") or "").strip()
+APP_SECRET = str(mykeys.get("qq_app_secret", "") or "").strip()
+ALLOWED = {str(x).strip() for x in mykeys.get("qq_allowed_users", []) if str(x).strip()}
+PROCESSED_IDS, USER_TASKS = deque(maxlen=1000), {}
+SEQ_LOCK, MSG_SEQ = threading.Lock(), 1
+
+
+def _next_msg_seq():
+ global MSG_SEQ
+ with SEQ_LOCK:
+ MSG_SEQ += 1
+ return MSG_SEQ
+
+
+def _build_intents():
+ try:
+ return botpy.Intents(public_messages=True, direct_message=True)
+ except Exception:
+ intents = botpy.Intents.none() if hasattr(botpy.Intents, "none") else botpy.Intents()
+ for attr in ("public_messages", "public_guild_messages", "direct_message", "direct_messages", "c2c_message", "c2c_messages", "group_at_message", "group_at_messages"):
+ if hasattr(intents, attr):
+ try:
+ setattr(intents, attr, True)
+ except Exception:
+ pass
+ return intents
+
+
+def _make_bot_class(app):
+ class QQBot(botpy.Client):
+ def __init__(self):
+ super().__init__(intents=_build_intents(), ext_handlers=False)
+
+ async def on_ready(self):
+ print(f"[QQ] bot ready: {getattr(getattr(self, 'robot', None), 'name', 'QQBot')}")
+
+ async def on_c2c_message_create(self, message: C2CMessage):
+ await app.on_message(message, is_group=False)
+
+ async def on_group_at_message_create(self, message: GroupMessage):
+ await app.on_message(message, is_group=True)
+
+ async def on_direct_message_create(self, message):
+ await app.on_message(message, is_group=False)
+
+ return QQBot
+
+
+class QQApp(AgentChatMixin):
+ label, source, split_limit = "QQ", "qq", 1500
+
+ def __init__(self):
+ super().__init__(agent, USER_TASKS)
+ self.client = None
+
+ async def send_text(self, chat_id, content, *, msg_id=None, is_group=False):
+ if not self.client:
+ return
+ api = self.client.api.post_group_message if is_group else self.client.api.post_c2c_message
+ key = "group_openid" if is_group else "openid"
+ for part in split_text(content, self.split_limit):
+ seq = _next_msg_seq()
+ try:
+ await api(**{key: chat_id, "msg_type": 2, "markdown": {"content": part}, "msg_id": msg_id, "msg_seq": seq})
+ except Exception:
+ await api(**{key: chat_id, "msg_type": 0, "content": part, "msg_id": msg_id, "msg_seq": seq})
+
+ async def on_message(self, data, is_group=False):
+ try:
+ msg_id = getattr(data, "id", None)
+ if msg_id in PROCESSED_IDS:
+ return
+ PROCESSED_IDS.append(msg_id)
+ content = (getattr(data, "content", "") or "").strip()
+ if not content:
+ return
+ author = getattr(data, "author", None)
+ user_id = str(getattr(author, "member_openid" if is_group else "user_openid", "") or getattr(author, "id", "") or "unknown")
+ chat_id = str(getattr(data, "group_openid", "") or user_id) if is_group else user_id
+ if not public_access(ALLOWED) and user_id not in ALLOWED:
+ print(f"[QQ] unauthorized user: {user_id}")
+ return
+ print(f"[QQ] message from {user_id} ({'group' if is_group else 'c2c'}): {content}")
+ if content.startswith("/"):
+ return await self.handle_command(chat_id, content, msg_id=msg_id, is_group=is_group)
+ asyncio.create_task(self.run_agent(chat_id, content, msg_id=msg_id, is_group=is_group))
+ except Exception:
+ import traceback
+ print("[QQ] handle_message error")
+ traceback.print_exc()
+
+ async def start(self):
+ self.client = _make_bot_class(self)()
+ delay, max_delay = 5, 300
+ while True:
+ started_at = time.monotonic()
+ try:
+ print(f"[QQ] bot starting... {time.strftime('%m-%d %H:%M')}")
+ await self.client.start(appid=APP_ID, secret=APP_SECRET)
+ except Exception as e:
+ print(f"[QQ] bot error: {e}")
+ if time.monotonic() - started_at >= 60:
+ delay = 5
+ print(f"[QQ] reconnect in {delay}s...")
+ await asyncio.sleep(delay)
+ delay = min(delay * 2, max_delay)
+
+
+if __name__ == "__main__":
+ _LOCK_SOCK = ensure_single_instance(19528, "QQ")
+ require_runtime(agent, "QQ", qq_app_id=APP_ID, qq_app_secret=APP_SECRET)
+ redirect_log(__file__, "qqapp.log", "QQ", ALLOWED)
+ threading.Thread(target=agent.run, daemon=True).start()
+ asyncio.run(QQApp().start())
diff --git a/frontends/qtapp.py b/frontends/qtapp.py
new file mode 100644
index 000000000..5e40a9c3f
--- /dev/null
+++ b/frontends/qtapp.py
@@ -0,0 +1,2478 @@
+"""
+桌面前端单文件版 – PySide6 聊天面板 + 悬浮按钮 thanks to GaoZhiCheng
+依赖: pip install PySide6
+可选: pip install markdown (Markdown 渲染)
+用法: python frontends/qtapp.py
+"""
+from __future__ import annotations
+
+import math, os, sys, json, glob, re, base64, time, threading
+import queue as _queue
+from datetime import datetime
+from typing import Optional
+
+from PySide6.QtWidgets import (
+ QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
+ QScrollArea, QFrame, QTextEdit, QStackedWidget,
+ QListWidget, QListWidgetItem, QSizePolicy, QFileDialog,
+ QSplitter, QTextBrowser, QApplication, QMessageBox,
+ QMenu, QLineEdit,
+)
+from PySide6.QtCore import (
+ Qt, QTimer, QPoint, QPointF, QByteArray, QSize,
+ Signal, QMetaObject, Q_ARG, QObject, QDateTime, QEvent,
+)
+from PySide6.QtGui import (
+ QPainter, QColor, QLinearGradient, QRadialGradient,
+ QPen, QPainterPath, QCursor, QFont, QIcon, QPixmap, QRegion,
+)
+
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
+from agentmain import GeneraticAgent
+from chatapp_common import FILE_HINT, HELP_TEXT, clean_reply, build_done_text, format_restore
+
+
+# ══════════════════════════════════════════════════════════════════════
+# FloatingButton
+# ══════════════════════════════════════════════════════════════════════
+
+class FloatingButton(QWidget):
+ SIZE = 60 # circle diameter
+ MARGIN = 14 # extra space for glow
+ TOTAL = SIZE + MARGIN * 2
+
+ def __init__(self, chat_panel: QWidget):
+ super().__init__()
+ self.chat_panel = chat_panel
+ self._drag_origin_global: QPoint | None = None
+ self._drag_origin_win: QPoint | None = None
+ self._dragged = False
+ self._glow = 0.5
+ self._glow_dir = 1
+ self._hovering = False
+ self._hover_clock = 0.0
+ self._hover_strength = 0.0
+ self._flow_phase = 0.0
+ self._running = False
+ self._last_toggle_ms = 0 # debounce timestamp
+
+ # Window flags: frameless, always on top, no taskbar entry
+ self.setWindowFlags(
+ Qt.FramelessWindowHint
+ | Qt.WindowStaysOnTopHint
+ | Qt.Tool
+ )
+ self.setAttribute(Qt.WA_TranslucentBackground)
+ self.setFixedSize(self.TOTAL, self.TOTAL)
+ self.setCursor(QCursor(Qt.PointingHandCursor))
+
+ # Smooth animation (~30 fps)
+ self._timer = QTimer(self)
+ self._timer.timeout.connect(self._tick)
+ self._timer.start(33)
+
+ # Default position: bottom-right of the work area
+ scr = QApplication.primaryScreen().availableGeometry()
+ self.move(scr.right() - self.TOTAL - 20, scr.bottom() - self.TOTAL - 20)
+
+ # ── Animation ────────────────────────────────────────
+ def _tick(self):
+ # running status: green when model is actively responding
+ self._running = bool(
+ getattr(self.chat_panel, "_is_streaming", False)
+ or getattr(getattr(self.chat_panel, "agent", None), "is_running", False)
+ )
+
+ self._glow += self._glow_dir * 0.04
+ if self._glow >= 1.0:
+ self._glow, self._glow_dir = 1.0, -1
+ elif self._glow <= 0.0:
+ self._glow, self._glow_dir = 0.0, 1
+
+ target = 1.0 if self._hovering else 0.0
+ self._hover_strength += (target - self._hover_strength) * 0.20
+ self._hover_clock += 0.033
+ self._flow_phase += 0.16 + (0.06 if self._running else 0.0) + (0.05 if self._hovering else 0.0)
+ self.update()
+
+ # ── Painting ──────────────────────────────────────────
+ def paintEvent(self, _event):
+ p = QPainter(self)
+ p.setRenderHint(QPainter.Antialiasing)
+
+ m = self.MARGIN
+ r = self.SIZE // 2
+ cx = m + r
+ # Rhythmic spring bounce: one main hop + one lighter rebound per beat.
+ beat_t = self._hover_clock % 1.18
+ spring = 0.0
+ if beat_t < 0.70:
+ spring += max(0.0, math.exp(-5.2 * beat_t) * math.sin(15.5 * beat_t))
+ if beat_t > 0.20:
+ rt = beat_t - 0.20
+ spring += 0.52 * max(0.0, math.exp(-7.0 * rt) * math.sin(21.0 * rt))
+ idle_sway = 0.20 * math.sin(self._hover_clock * 2.1)
+ bounce = int(round((spring * 7.2 + idle_sway) * self._hover_strength))
+ cy = m + r - bounce
+
+ if self._running:
+ # running: #2DFFF5 -> #FFF878
+ g0 = QColor(45, 255, 245, 195)
+ g1 = QColor(255, 248, 120, 195)
+ glow_rgb = (96, 255, 216)
+ else:
+ # idle: #103CE7 -> #64E9FF
+ g0 = QColor(16, 60, 231, 195)
+ g1 = QColor(100, 233, 255, 195)
+ glow_rgb = (74, 170, 255)
+
+ # --- Outer glow rings (3 layers) ---
+ base_alpha = int(45 + 25 * self._glow)
+ for i, gr in enumerate([r + 10, r + 6, r + 2]):
+ g = QRadialGradient(QPointF(cx, cy), gr)
+ g.setColorAt(0.0, QColor(glow_rgb[0], glow_rgb[1], glow_rgb[2], max(0, base_alpha - i * 14)))
+ g.setColorAt(1.0, QColor(glow_rgb[0], glow_rgb[1], glow_rgb[2], 0))
+ p.setBrush(g)
+ p.setPen(Qt.NoPen)
+ p.drawEllipse(int(cx - gr), int(cy - gr), int(gr * 2), int(gr * 2))
+
+ # --- Frosted glass disc behind main circle ---
+ frost = QRadialGradient(QPointF(cx, cy), r)
+ frost.setColorAt(0.0, QColor(30, 30, 45, 140))
+ frost.setColorAt(0.85, QColor(20, 20, 32, 160))
+ frost.setColorAt(1.0, QColor(14, 14, 20, 100))
+ p.setBrush(frost)
+ p.setPen(Qt.NoPen)
+ p.drawEllipse(cx - r, cy - r, r * 2, r * 2)
+
+ # --- Main circle (flowing state gradient) ---
+ spin = self._flow_phase
+ dx = math.cos(spin) * r
+ dy = math.sin(spin) * r
+ grad = QLinearGradient(cx - dx, cy - dy, cx + dx, cy + dy)
+ grad.setColorAt(0.0, g0)
+ grad.setColorAt(1.0, g1)
+ p.setBrush(grad)
+ p.setPen(QPen(QColor(255, 255, 255, 50), 1.5))
+ p.drawEllipse(cx - r, cy - r, r * 2, r * 2)
+
+ # --- Flowing glass streaks ---
+ clip = QPainterPath()
+ clip.addEllipse(float(cx - r), float(cy - r), float(r * 2), float(r * 2))
+ p.setClipPath(clip)
+
+ flow_shift = math.sin(self._flow_phase * 0.85) * (r * 0.7)
+ streak1 = QLinearGradient(cx - r + flow_shift, cy - r, cx + r + flow_shift, cy + r)
+ streak1.setColorAt(0.00, QColor(255, 255, 255, 0))
+ streak1.setColorAt(0.45, QColor(255, 255, 255, 42))
+ streak1.setColorAt(0.52, QColor(255, 255, 255, 78))
+ streak1.setColorAt(0.60, QColor(255, 255, 255, 24))
+ streak1.setColorAt(1.00, QColor(255, 255, 255, 0))
+ p.setBrush(streak1)
+ p.setPen(Qt.NoPen)
+ p.drawEllipse(cx - r, cy - r, r * 2, r * 2)
+
+ flow_shift_2 = math.cos(self._flow_phase * 1.2) * (r * 0.5)
+ streak2 = QLinearGradient(cx - r, cy + flow_shift_2, cx + r, cy - flow_shift_2)
+ streak2.setColorAt(0.00, QColor(255, 255, 255, 0))
+ streak2.setColorAt(0.35, QColor(255, 255, 255, 16))
+ streak2.setColorAt(0.50, QColor(255, 255, 255, 46))
+ streak2.setColorAt(0.65, QColor(255, 255, 255, 16))
+ streak2.setColorAt(1.00, QColor(255, 255, 255, 0))
+ p.setBrush(streak2)
+ p.drawEllipse(cx - r, cy - r, r * 2, r * 2)
+
+ # --- Top highlight ---
+ hl = QLinearGradient(cx, cy - r, cx, cy)
+ hl.setColorAt(0.0, QColor(255, 255, 255, 72))
+ hl.setColorAt(1.0, QColor(255, 255, 255, 0))
+ p.setBrush(hl)
+ p.drawRect(cx - r, cy - r, r * 2, r)
+ p.setClipping(False)
+
+ # --- Bot icon ---
+ p.setPen(QPen(QColor(255, 255, 255, 220), 1.8))
+ p.setBrush(Qt.NoBrush)
+ # Head
+ p.drawRoundedRect(cx - 9, cy - 6, 18, 12, 2, 2)
+ # Eyes
+ p.setBrush(QColor(255, 255, 255, 220))
+ p.setPen(Qt.NoPen)
+ p.drawEllipse(cx - 6, cy - 3, 4, 4)
+ p.drawEllipse(cx + 2, cy - 3, 4, 4)
+ # Antenna stem
+ p.setPen(QPen(QColor(255, 255, 255, 220), 1.8))
+ p.drawLine(cx, cy - 6, cx, cy - 10)
+ # Antenna tip
+ p.setBrush(QColor(255, 255, 255, 190))
+ p.setPen(Qt.NoPen)
+ p.drawEllipse(cx - 2, cy - 13, 4, 4)
+
+ def enterEvent(self, event):
+ self._hovering = True
+ self.update()
+ super().enterEvent(event)
+
+ def leaveEvent(self, event):
+ self._hovering = False
+ self.update()
+ super().leaveEvent(event)
+
+ # ── Mouse events (drag + click) ───────────────────────
+ def mousePressEvent(self, event):
+ if event.button() == Qt.LeftButton:
+ self._drag_origin_global = event.globalPosition().toPoint()
+ self._drag_origin_win = self.pos()
+ self._dragged = False
+
+ def mouseMoveEvent(self, event):
+ if event.buttons() == Qt.LeftButton and self._drag_origin_global:
+ delta = event.globalPosition().toPoint() - self._drag_origin_global
+ if abs(delta.x()) > 5 or abs(delta.y()) > 5:
+ self._dragged = True
+ if self._dragged:
+ new = self._drag_origin_win + delta
+ scr = QApplication.primaryScreen().availableGeometry()
+ new.setX(max(scr.left(), min(new.x(), scr.right() - self.width())))
+ new.setY(max(scr.top(), min(new.y(), scr.bottom() - self.height())))
+ self.move(new)
+
+ def mouseDoubleClickEvent(self, event):
+ # Qt sends Press→Release→DoubleClick→Release on double-click.
+ # The first Release already toggled the panel; swallow the DoubleClick
+ # so the second Release does NOT trigger a second toggle.
+ self._dragged = True # mark as "dragged" → Release will be ignored
+ event.accept()
+
+ def mouseReleaseEvent(self, event):
+ if event.button() == Qt.LeftButton:
+ if not self._dragged:
+ self._toggle()
+ self._dragged = False
+ self._drag_origin_global = None
+
+ # ── Toggle panel ──────────────────────────────────────
+ def _toggle(self):
+ now = QDateTime.currentMSecsSinceEpoch()
+ if now - self._last_toggle_ms < 500: # 500 ms debounce
+ return
+ self._last_toggle_ms = now
+
+ if self.chat_panel.isVisible():
+ self.chat_panel.hide()
+ else:
+ self._position_panel()
+ self.chat_panel.show()
+ self.chat_panel.raise_()
+ self.chat_panel.activateWindow()
+
+ def _position_panel(self):
+ scr = QApplication.primaryScreen().availableGeometry()
+ btn = self.geometry()
+ pw = self.chat_panel.width()
+ ph = self.chat_panel.height()
+ # Prefer left of button, bottom-aligned
+ x = btn.left() - pw - 12
+ y = btn.bottom() - ph
+ x = max(scr.left() + 10, min(x, scr.right() - pw - 10))
+ y = max(scr.top() + 10, min(y, scr.bottom() - ph - 10))
+ self.chat_panel.move(x, y)
+
+
+# ══════════════════════════════════════════════════════════════════════
+# ChatPanel
+# ══════════════════════════════════════════════════════════════════════
+
+# ── constants ─────────────────────────────────────────────────────────────────
+HISTORY_FILE = "memory/chat_history.json"
+TEXT_FILE_EXTS = {
+ ".txt", ".md", ".py", ".json", ".csv", ".yaml", ".yml",
+ ".log", ".ini", ".toml", ".xml", ".html", ".js", ".ts", ".sql",
+}
+MAX_INLINE_CHARS = 6000
+MAX_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB
+AUTO_IDLE_THRESHOLD = 1800 # seconds before autonomous trigger
+AUTO_COOLDOWN = 120 # seconds between triggers
+
+C = {
+ "bg": QColor(14, 14, 18),
+ "panel": QColor(20, 20, 24, 248),
+ "border": QColor(45, 45, 50),
+ "accent": "#7c3aed",
+ "text": "#e4e4e7",
+ "muted": "#71717a",
+ "user_g0": QColor(79, 70, 229),
+ "user_g1": QColor(124, 58, 237),
+ "asst_bg": QColor(39, 39, 42, 210),
+ "asst_bdr": QColor(63, 63, 70),
+ "send_g0": QColor(220, 38, 38),
+ "send_g1": QColor(239, 68, 68),
+ "green": "#22c55e",
+ "hover_bg": "rgba(63,63,70,0.6)",
+ "accent_bg":"rgba(124,58,237,0.25)",
+ "accent_bdr":"rgba(124,58,237,0.5)",
+}
+
+SCROLLBAR_STYLE = """
+QScrollBar:vertical { width: 5px; background: transparent; border: none; }
+QScrollBar::handle:vertical {
+ background: rgba(255,255,255,0.12); border-radius: 2px; min-height: 20px;
+}
+QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
+QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: none; }
+"""
+
+_SVG_COPY = ' '
+_SVG_REGEN = ' '
+_SVG_CHAT = ' '
+_SVG_CLOCK = ' '
+_SVG_SEARCH = ' '
+_SVG_BOOK = ' '
+_SVG_GEAR = ' '
+_SVG_PLUS = ' '
+_SVG_CLIP = _SVG_PLUS
+_SVG_STOP = ' '
+_SVG_RESET = _SVG_REGEN
+_SVG_SAVE = ' '
+_SVG_TRASH = ' '
+_SVG_BOLT = ' '
+_SVG_PLAY = ' '
+_SVG_FILE = ' '
+_SVG_USER = ' '
+_SVG_BOT = ' '
+_SVG_SEND = ' '
+
+_MD_CSS = """
+body { color: #e4e4e7; font-family: "Arial", "Microsoft YaHei", sans-serif; font-size: 13px; line-height: 1.6; font-weight: 400; }
+h1 { color: #f4f4f5; font-size: 20px; font-weight: 700; border-bottom: 1px solid #3f3f46; padding-bottom: 4px; margin-top: 16px; }
+h2 { color: #f4f4f5; font-size: 17px; font-weight: 700; border-bottom: 1px solid #3f3f46; padding-bottom: 3px; margin-top: 14px; }
+h3 { color: #f4f4f5; font-size: 15px; font-weight: 600; margin-top: 12px; }
+h4,h5,h6 { color: #d4d4d8; font-size: 13px; font-weight: 600; margin-top: 10px; }
+code { background: rgba(63,63,70,0.6); color: #c4b5fd; padding: 1px 4px; border-radius: 3px;
+ font-family: Consolas, "Courier New", monospace; font-size: 12px; }
+pre { background: rgba(24,24,30,0.95); border: 1px solid #3f3f46; border-radius: 6px;
+ padding: 10px 12px; margin: 8px 0; }
+pre code { background: transparent; padding: 0; color: #d4d4d8; }
+a { color: #818cf8; text-decoration: none; }
+a:hover { text-decoration: underline; }
+blockquote { border-left: 3px solid #7c3aed; margin: 8px 0 8px 0; padding: 4px 0 4px 12px; color: #a1a1aa; }
+table { border-collapse: collapse; margin: 8px 0; }
+th, td { border: 1px solid #3f3f46; padding: 5px 10px; }
+th { background: rgba(63,63,70,0.35); color: #d4d4d8; font-weight: 700; }
+hr { border: none; border-top: 1px solid #3f3f46; margin: 12px 0; }
+ul, ol { padding-left: 22px; margin: 4px 0; }
+li { margin: 2px 0; }
+p { margin: 6px 0; }
+"""
+
+
+def _md_to_html(text: str) -> str:
+ try:
+ import markdown
+ return markdown.markdown(
+ text, extensions=["fenced_code", "tables", "nl2br", "sane_lists"]
+ )
+ except ImportError:
+ pass
+ html, in_code, in_ul = [], False, False
+ for raw in text.split("\n"):
+ if raw.strip().startswith("```"):
+ if in_code:
+ html.append("")
+ else:
+ html.append("")
+ in_code = not in_code
+ continue
+ if in_code:
+ html.append(raw.replace("&", "&").replace("<", "<").replace(">", ">"))
+ continue
+ line = raw
+ line = re.sub(r"`([^`]+)`", r"\1", line)
+ line = re.sub(r"\*\*(.+?)\*\*", r"\1 ", line)
+ line = re.sub(r"\*(.+?)\*", r"\1 ", line)
+ line = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'\1 ', line)
+ if re.match(r"^#{1,6}\s", line):
+ lvl = len(line.split()[0])
+ line = f"{line[lvl:].strip()} "
+ elif re.match(r"^-{3,}$|^_{3,}$|^\*{3,}$", line.strip()):
+ line = " "
+ elif re.match(r"^\s*[-*+]\s", line):
+ content = re.sub(r"^\s*[-*+]\s", "", line)
+ if not in_ul:
+ html.append("")
+ in_ul = True
+ line = f"{content} "
+ else:
+ if in_ul:
+ html.append(" ")
+ in_ul = False
+ line = f"{line}
" if line.strip() else ""
+ html.append(line)
+ if in_code:
+ html.append(" ")
+ if in_ul:
+ html.append("")
+ return "\n".join(html)
+
+
+_icon_cache: dict[str, QIcon] = {}
+
+def _svg_icon(key: str, svg_template: str, color: str = "#a1a1aa",
+ size: int = 16) -> QIcon:
+ cache_key = f"{key}_{color}_{size}"
+ if cache_key not in _icon_cache:
+ try:
+ from PySide6.QtSvg import QSvgRenderer
+ except ImportError:
+ return QIcon()
+ data = QByteArray(svg_template.format(c=color).encode("utf-8"))
+ renderer = QSvgRenderer(data)
+ pixmap = QPixmap(size, size)
+ pixmap.fill(Qt.transparent)
+ painter = QPainter(pixmap)
+ renderer.render(painter)
+ painter.end()
+ _icon_cache[cache_key] = QIcon(pixmap)
+ return _icon_cache[cache_key]
+
+
+# ── utilities ─────────────────────────────────────────────────────────────────
+def _make_session_id() -> str:
+ return datetime.now().strftime("%Y%m%d_%H%M%S_%f")
+
+
+def _load_history() -> list:
+ if os.path.exists(HISTORY_FILE):
+ try:
+ with open(HISTORY_FILE, "r", encoding="utf-8") as f:
+ return json.load(f)
+ except Exception:
+ pass
+ return []
+
+
+def _save_history(history: list):
+ os.makedirs(os.path.dirname(HISTORY_FILE), exist_ok=True)
+ with open(HISTORY_FILE, "w", encoding="utf-8") as f:
+ json.dump(history, f, ensure_ascii=False, indent=2)
+
+
+def _build_prompt_with_uploads(prompt: str, files: list) -> tuple:
+ """
+ files: list of {'name': str, 'type': str, 'raw': bytes}
+ returns (full_prompt, display_prompt, display_attachments)
+ """
+ if not files:
+ return prompt, prompt, []
+
+ os.makedirs("temp/uploaded", exist_ok=True)
+ attachment_chunks = ["\n\n[用户上传附件 — 文件已保存到本地磁盘,可用 file_read 工具读取]"]
+ display_attachments = []
+ img_count, file_names = 0, []
+
+ for f in files:
+ raw, name, mime = f["raw"], f["name"], f.get("type", "")
+ size = len(raw)
+ ext = os.path.splitext(name)[1].lower()
+ safe = re.sub(r"[^A-Za-z0-9._\-]", "_", name)
+ saved = os.path.join(
+ "temp", "uploaded",
+ f"{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}_{safe}",
+ )
+ try:
+ with open(saved, "wb") as out:
+ out.write(raw)
+ except Exception:
+ saved = "(保存失败)"
+
+ if mime.startswith("image/"):
+ b64 = base64.b64encode(raw).decode()
+ attachment_chunks.append(
+ f"\n- [图片附件] {name} ({size} bytes)\n 磁盘路径: {saved}"
+ f"\n data:{mime};base64,{b64}"
+ )
+ display_attachments.append({"type": "image", "name": name})
+ img_count += 1
+ elif ext in TEXT_FILE_EXTS:
+ text = raw.decode("utf-8", errors="replace")
+ attachment_chunks.append(
+ f"\n--- 文本文件: {name} ({size} bytes) ---\n磁盘路径: {saved}\n{text[:MAX_INLINE_CHARS]}"
+ + ("\n[内容已截断,请用 file_read 读取完整内容]" if len(text) > MAX_INLINE_CHARS else "")
+ )
+ display_attachments.append({"type": "file", "name": name})
+ file_names.append(name)
+ else:
+ attachment_chunks.append(
+ f"\n- 文件: {name} ({size} bytes)\n 磁盘路径: {saved}"
+ )
+ display_attachments.append({"type": "file", "name": name})
+ file_names.append(name)
+
+ parts = []
+ if img_count:
+ parts.append(f"{img_count} 张图片")
+ if file_names:
+ parts.append(f"{len(file_names)} 个文件({'、'.join(file_names)})")
+ display_prompt = f"{prompt}\n\n📎 已附带:{','.join(parts)}" if parts else prompt
+ return prompt + "\n".join(attachment_chunks), display_prompt, display_attachments
+
+
+# ── small reusable widgets ────────────────────────────────────────────────────
+class _Separator(QFrame):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setFixedHeight(1)
+ self.setStyleSheet(f"background: {C['border'].name()};")
+
+
+class _Badge(QLabel):
+ def __init__(self, text: str, parent=None):
+ super().__init__(text, parent)
+ self.setStyleSheet(
+ "QLabel { background: rgba(63,63,70,0.9); color: #a1a1aa;"
+ " border: 1px solid #3f3f46; border-radius: 9px;"
+ " padding: 1px 8px; font-size: 11px; }"
+ )
+
+
+class _StreamingBadge(QLabel):
+ def __init__(self, parent=None):
+ super().__init__("处理中…", parent)
+ self.setStyleSheet(
+ "QLabel { background: rgba(124,58,237,0.18); color: #c4b5fd;"
+ " border: 1px solid rgba(124,58,237,0.35); border-radius: 9px;"
+ " padding: 1px 8px; font-size: 11px; }"
+ )
+ self.hide()
+
+
+class _FoldableTextBrowser(QTextBrowser):
+ """QTextBrowser subclass that reliably detects clicks on fold anchors."""
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.viewport().installEventFilter(self)
+
+ def eventFilter(self, obj, event):
+ from PySide6.QtCore import QEvent
+ if obj is self.viewport() and event.type() == QEvent.MouseButtonRelease:
+ href = self.anchorAt(event.pos())
+ if href and href.startswith("#fold_"):
+ from urllib.parse import unquote
+ title = unquote(href[6:])
+ p = self.parent()
+ while p and not isinstance(p, _MsgRow):
+ p = p.parent()
+ if p and hasattr(p, '_toggle_fold'):
+ p._toggle_fold(title)
+ return True
+ return super().eventFilter(obj, event)
+
+
+class _MsgRow(QWidget):
+ """A single message row – flat layout with avatar, inspired by ChatGPT / Qwen."""
+
+ _ACTION_BTN = """
+ QPushButton {
+ background: transparent; border: none; border-radius: 4px; padding: 3px;
+ }
+ QPushButton:hover { background: %s; }
+ """ % C["hover_bg"]
+ def __init__(self, text: str, role: str, parent=None, on_resend=None, on_delete=None, on_rewrite=None, created_at: str = None):
+ super().__init__(parent)
+ self._text = text
+ self._role = role
+ self._on_resend = on_resend
+ self._on_delete = on_delete
+ self._on_rewrite = on_rewrite
+ self._created_at = created_at
+ self._action_row = None
+ self._finished = True
+
+ is_user = role == "user"
+ self.setStyleSheet("background: transparent;")
+
+ outer = QHBoxLayout(self)
+ outer.setContentsMargins(12, 10, 12, 10)
+ outer.setSpacing(10)
+ outer.setAlignment(Qt.AlignTop)
+
+ # ── avatar ──
+ avatar = QLabel()
+ avatar.setFixedSize(30, 30)
+ avatar.setAlignment(Qt.AlignCenter)
+ svg_data = _SVG_USER if is_user else _SVG_BOT
+ avatar_color = "#c8c8d0" if is_user else "#9eb4d0"
+ pm = QPixmap(30, 30)
+ pm.fill(QColor(0, 0, 0, 0))
+ from PySide6.QtSvg import QSvgRenderer
+ renderer = QSvgRenderer(QByteArray(svg_data.replace("{c}", avatar_color).encode()))
+ p = QPainter(pm)
+ renderer.render(p)
+ p.end()
+ avatar.setPixmap(pm)
+ avatar.setStyleSheet(
+ "QLabel { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.10);"
+ " border-radius: 15px; }"
+ )
+
+ # ── content column ──
+ content_col = QVBoxLayout()
+ content_col.setContentsMargins(0, 0, 0, 0)
+ content_col.setSpacing(2)
+
+ role_lbl = QLabel("你" if is_user else "助手")
+ role_lbl.setStyleSheet(
+ "color: #d4d4d8; font-size: 12px; font-weight: 700; background: transparent;"
+ )
+ if is_user:
+ role_lbl.setAlignment(Qt.AlignRight)
+ content_col.addWidget(role_lbl)
+
+ if is_user:
+ # ── user: right-aligned bubble ──
+ bubble = QWidget()
+ bubble.setStyleSheet(
+ "background: rgba(63,63,70,0.4); border-radius: 12px;"
+ )
+ bubble_ly = QVBoxLayout(bubble)
+ bubble_ly.setContentsMargins(12, 8, 12, 8)
+ bubble_ly.setSpacing(0)
+
+ label = QLabel(text)
+ label.setWordWrap(True)
+ label.setTextInteractionFlags(Qt.TextSelectableByMouse)
+ label.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum)
+ label.setStyleSheet(
+ "QLabel { background: transparent; color: #e4e4e7;"
+ " padding: 0; font-size: 14px; line-height: 1.6; }"
+ )
+ bubble_ly.addWidget(label)
+ self._label = label
+
+ # Size bubble to text: measure longest line, cap at 420
+ fm = label.fontMetrics()
+ text_w = max((fm.horizontalAdvance(ln) for ln in text.split('\n')), default=0)
+ bubble.setMinimumWidth(min(text_w + 24, 420))
+ bubble.setMaximumWidth(420)
+ content_col.addWidget(bubble, 0, Qt.AlignRight)
+
+ # ── user message action row ──
+ self._action_row = QWidget()
+ self._action_row.setStyleSheet("background: transparent;")
+ alayout = QHBoxLayout(self._action_row)
+ alayout.setContentsMargins(0, 4, 0, 0)
+ alayout.setSpacing(4)
+ alayout.setAlignment(Qt.AlignRight)
+
+ icon_sz = QSize(15, 15)
+
+ copy_btn = QPushButton()
+ copy_btn.setIcon(_svg_icon("copy", _SVG_COPY))
+ copy_btn.setIconSize(icon_sz)
+ copy_btn.setFixedSize(26, 24)
+ copy_btn.setStyleSheet(self._ACTION_BTN)
+ copy_btn.setToolTip("复制")
+ copy_btn.setCursor(QCursor(Qt.PointingHandCursor))
+ copy_btn.clicked.connect(self._copy_text)
+ alayout.addWidget(copy_btn)
+
+ if on_delete:
+ delete_btn = QPushButton()
+ delete_btn.setIcon(_svg_icon("delete", _SVG_TRASH))
+ delete_btn.setIconSize(icon_sz)
+ delete_btn.setFixedSize(26, 24)
+ delete_btn.setStyleSheet(self._ACTION_BTN)
+ delete_btn.setToolTip("删除")
+ delete_btn.setCursor(QCursor(Qt.PointingHandCursor))
+ delete_btn.clicked.connect(self._do_delete)
+ alayout.addWidget(delete_btn)
+
+ if on_rewrite:
+ rewrite_btn = QPushButton()
+ rewrite_btn.setIcon(_svg_icon("rewrite", _SVG_RESET))
+ rewrite_btn.setIconSize(icon_sz)
+ rewrite_btn.setFixedSize(26, 24)
+ rewrite_btn.setStyleSheet(self._ACTION_BTN)
+ rewrite_btn.setToolTip("重写")
+ rewrite_btn.setCursor(QCursor(Qt.PointingHandCursor))
+ rewrite_btn.clicked.connect(self._do_rewrite)
+ alayout.addWidget(rewrite_btn)
+
+ alayout.addStretch()
+
+ if created_at:
+ from datetime import datetime
+ try:
+ dt = datetime.fromisoformat(created_at)
+ time_lbl = QLabel(dt.strftime("%Y-%m-%d %H:%M"))
+ time_lbl.setStyleSheet("color: #a1a1aa; font-size: 11px; background: transparent;")
+ alayout.addWidget(time_lbl)
+ except:
+ pass
+
+ self._action_row.hide()
+ content_col.addWidget(self._action_row, 0, Qt.AlignRight)
+ else:
+ # ── assistant: left-aligned, no bubble ──
+ browser = _FoldableTextBrowser()
+ browser.setReadOnly(True)
+ browser.setOpenExternalLinks(True)
+ browser.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ browser.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ browser.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
+ browser.document().setDefaultStyleSheet(_MD_CSS)
+ browser.setStyleSheet(
+ "QTextBrowser { background: transparent; color: #e4e4e7;"
+ " border: none; padding: 0; font-size: 14px; }"
+ )
+ self._folded_ids = set() # 记录被折叠的块
+ self._auto_fold_new_blocks(text)
+ browser.setHtml(self._render_with_folds(text))
+ self._label = browser
+ content_col.addWidget(browser)
+ self._adjust_browser_height()
+
+ self._action_row = QWidget()
+ self._action_row.setStyleSheet("background: transparent;")
+ alayout = QHBoxLayout(self._action_row)
+ alayout.setContentsMargins(0, 4, 0, 0)
+ alayout.setSpacing(4)
+
+ icon_sz = QSize(15, 15)
+
+ copy_btn = QPushButton()
+ copy_btn.setIcon(_svg_icon("copy", _SVG_COPY))
+ copy_btn.setIconSize(icon_sz)
+ copy_btn.setFixedSize(26, 24)
+ copy_btn.setStyleSheet(self._ACTION_BTN)
+ copy_btn.setToolTip("复制")
+ copy_btn.setCursor(QCursor(Qt.PointingHandCursor))
+ copy_btn.clicked.connect(self._copy_text)
+ alayout.addWidget(copy_btn)
+
+ if on_delete:
+ delete_btn = QPushButton()
+ delete_btn.setIcon(_svg_icon("delete", _SVG_TRASH))
+ delete_btn.setIconSize(icon_sz)
+ delete_btn.setFixedSize(26, 24)
+ delete_btn.setStyleSheet(self._ACTION_BTN)
+ delete_btn.setToolTip("删除")
+ delete_btn.setCursor(QCursor(Qt.PointingHandCursor))
+ delete_btn.clicked.connect(self._do_delete)
+ alayout.addWidget(delete_btn)
+
+ if on_resend:
+ regen_btn = QPushButton()
+ regen_btn.setIcon(_svg_icon("regen", _SVG_REGEN))
+ regen_btn.setIconSize(icon_sz)
+ regen_btn.setFixedSize(26, 24)
+ regen_btn.setStyleSheet(self._ACTION_BTN)
+ regen_btn.setToolTip("重新生成")
+ regen_btn.setCursor(QCursor(Qt.PointingHandCursor))
+ regen_btn.clicked.connect(self._do_resend)
+ alayout.addWidget(regen_btn)
+
+ export_btn = QPushButton()
+ export_btn.setIcon(_svg_icon("save", _SVG_SAVE))
+ export_btn.setIconSize(icon_sz)
+ export_btn.setFixedSize(26, 24)
+ export_btn.setStyleSheet(self._ACTION_BTN)
+ export_btn.setToolTip("导出为md")
+ export_btn.setCursor(QCursor(Qt.PointingHandCursor))
+ export_btn.clicked.connect(self._export_as_md)
+ alayout.addWidget(export_btn)
+
+ alayout.addStretch()
+
+ if created_at:
+ from datetime import datetime
+ try:
+ dt = datetime.fromisoformat(created_at)
+ time_lbl = QLabel(dt.strftime("%Y-%m-%d %H:%M"))
+ time_lbl.setStyleSheet("color: #a1a1aa; font-size: 11px; background: transparent;")
+ alayout.addWidget(time_lbl)
+ except:
+ pass
+
+ self._action_row.hide()
+ content_col.addWidget(self._action_row)
+
+ # ── assemble: assistant left, user right ──
+ if is_user:
+ outer.addStretch(1)
+ outer.addLayout(content_col, 0)
+ outer.addWidget(avatar, 0, Qt.AlignTop)
+ else:
+ outer.addWidget(avatar, 0, Qt.AlignTop)
+ outer.addLayout(content_col, 1)
+
+ def _copy_text(self):
+ QApplication.clipboard().setText(self._text)
+
+ def _do_resend(self):
+ if self._on_resend:
+ self._on_resend()
+
+ def _do_delete(self):
+ if self._on_delete:
+ self._on_delete()
+
+ def _do_rewrite(self):
+ if self._on_rewrite:
+ self._on_rewrite()
+
+ def _export_as_md(self):
+ from PySide6.QtWidgets import QFileDialog
+ import os
+ from datetime import datetime
+ default_name = f"msg_{datetime.now().strftime('%Y%m%d_%H%M%S')}.md"
+ file_path, _ = QFileDialog.getSaveFileName(
+ self, "导出为 Markdown", default_name, "Markdown 文件 (*.md);;所有文件 (*)"
+ )
+ if file_path:
+ try:
+ with open(file_path, "w", encoding="utf-8") as f:
+ f.write(self._text)
+ except Exception as e:
+ import traceback
+ traceback.print_exc()
+
+ def enterEvent(self, event):
+ if self._action_row and self._finished:
+ self._action_row.show()
+ super().enterEvent(event)
+
+ def leaveEvent(self, event):
+ if self._action_row:
+ self._action_row.hide()
+ super().leaveEvent(event)
+
+ def resizeEvent(self, event):
+ super().resizeEvent(event)
+ if self._role != "user" and hasattr(self, '_label'):
+ self._adjust_browser_height()
+
+ def set_finished(self, done: bool):
+ self._finished = done
+ if not done and self._action_row:
+ self._action_row.hide()
+
+ def _adjust_browser_height(self):
+ doc = self._label.document()
+ w = self._label.width()
+ if w < 50:
+ w = 460
+ doc.setTextWidth(w - 6)
+ self._label.setFixedHeight(int(doc.size().height() + 8))
+
+ def set_text(self, text: str):
+ self._text = text
+ if self._role == "user":
+ self._label.setText(text)
+ self._label.adjustSize()
+ else:
+ self._auto_fold_new_blocks(text)
+ self._label.setHtml(self._render_with_folds(text))
+ self._adjust_browser_height()
+
+ def highlight(self, keyword: str):
+ """Apply highlight and return keyword's y position in document, or None."""
+ if not keyword or not self._text:
+ return None
+ kw_lower = keyword.lower()
+ text_lower = self._text.lower()
+ if kw_lower not in text_lower:
+ return None
+ if self._role == "user":
+ escaped = self._text.replace("&", "&").replace("<", "<").replace(">", ">")
+ kw_esc = keyword.replace("&", "&").replace("<", "<").replace(">", ">")
+ highlighted = escaped.replace(kw_esc, f'{kw_esc} ')
+ self._label.setText(highlighted)
+ self._label.adjustSize()
+ return 0 # plain text, keyword at top
+ else:
+ from PySide6.QtGui import QTextDocument, QTextCursor, QTextCharFormat
+ doc = self._label.document()
+ cursor = QTextCursor(doc)
+ flags = QTextDocument.FindFlags(0)
+ fmt = QTextCharFormat()
+ fmt.setBackground(QColor(251, 191, 36, 90))
+ fmt.setForeground(QColor(251, 191, 36))
+ keyword_y = None
+ while True:
+ cursor = doc.find(keyword, cursor, flags)
+ if cursor.isNull():
+ break
+ cursor.mergeCharFormat(fmt)
+ if keyword_y is None:
+ keyword_y = self._label.cursorRect(cursor).y()
+ self._adjust_browser_height()
+ return keyword_y
+
+ def clear_highlight(self):
+ if self._role == "user":
+ self._label.setText(self._text)
+ self._label.adjustSize()
+ else:
+ self._label.setHtml(self._render_with_folds(self._text))
+ self._adjust_browser_height()
+
+
+ def _parse_foldable_blocks(self, text: str):
+ """解析文本为可折叠块,返回 [(type, title_or_None, content), ...]"""
+ import re
+ lines = text.split('\n')
+ blocks = []
+ current_type = "normal"
+ current_title = None
+ current_lines = []
+
+ for line in lines:
+ # 检查是否是折叠块开始
+ llm_match = re.match(r'^\s*\*\*LLM Running \(Turn \d+\) \.\.\.\*\*\s*$', line)
+ tool_match = re.match(r'^\s*🛠️\s*Tool:', line)
+ tool_compact_match = re.match(r'^\s*🛠️\s+\w+\(', line)
+
+ is_foldable_start = llm_match or tool_match or tool_compact_match
+
+ if is_foldable_start:
+ if current_lines:
+ blocks.append((current_type, current_title, '\n'.join(current_lines)))
+
+ title = line.strip()
+ if llm_match:
+ title = line.strip().replace('**', '')
+ current_type = "foldable"
+ current_title = title
+ current_lines = [line]
+ else:
+ current_lines.append(line)
+
+ if current_lines:
+ blocks.append((current_type, current_title, '\n'.join(current_lines)))
+
+ return blocks
+
+ def _auto_fold_new_blocks(self, text: str):
+ """将新出现的折叠块加入 _folded_ids(仅在此处修改集合)"""
+ for _, title, _ in self._parse_foldable_blocks(text):
+ if title is not None and title not in self._folded_ids:
+ self._folded_ids.add(title)
+
+ def _render_with_folds(self, text: str) -> str:
+ """渲染文本为带折叠的 HTML(纯渲染,不修改 _folded_ids)"""
+ from urllib.parse import quote
+ blocks = self._parse_foldable_blocks(text)
+ html_parts = []
+
+ for i, (block_type, title, content) in enumerate(blocks):
+ if block_type == "normal":
+ html_parts.append(f'{_md_to_html(content)}
')
+ else:
+ safe_title = quote(title, safe='')
+ display_title = title.replace('**', '')
+ if title in self._folded_ids:
+ # 折叠状态:只显示标题 + 展开链接
+ html_parts.append(
+ f''
+ )
+ else:
+ # 展开状态:显示标题 + 折叠链接 + 内容
+ html_parts.append(
+ f''
+ )
+ return '\n'.join(html_parts)
+
+ def _toggle_fold(self, title):
+ """折叠/展开切换"""
+ if title in self._folded_ids:
+ self._folded_ids.remove(title)
+ else:
+ self._folded_ids.add(title)
+ self._label.setHtml(self._render_with_folds(self._text))
+ self._adjust_browser_height()
+
+
+class _TabButton(QPushButton):
+ _STYLE = """
+ QPushButton {{
+ background: transparent; color: {muted};
+ border: none; border-radius: 8px;
+ padding: 0 14px; font-size: 12px; font-weight: 700;
+ }}
+ QPushButton:hover {{
+ background: {hover_bg}; color: {text};
+ }}
+ QPushButton:checked {{
+ background: {accent}; color: white;
+ }}
+ """.format(muted=C["muted"], text=C["text"], hover_bg=C["hover_bg"], accent=C["accent"])
+
+ def __init__(self, text: str, parent=None):
+ super().__init__(text, parent)
+ self.setCheckable(True)
+ self.setFixedHeight(30)
+ self.setStyleSheet(self._STYLE)
+
+
+def _action_btn(label: str, color: str, icon: QIcon | None = None) -> QPushButton:
+ btn = QPushButton(label)
+ if icon and not icon.isNull():
+ btn.setIcon(icon)
+ btn.setIconSize(QSize(16, 16))
+ btn.setFixedHeight(36)
+ btn.setStyleSheet(f"""
+ QPushButton {{
+ background: rgba(35,35,40,0.8); color: {C['text']};
+ border: 1px solid {C['border'].name()};
+ border-left: 3px solid {color};
+ border-radius: 8px; padding: 0 14px;
+ font-size: 13px; font-weight: 700; text-align: left;
+ }}
+ QPushButton:hover {{ background: rgba(55,55,62,0.9); }}
+ QPushButton:checked {{ color: {color}; background: rgba(35,35,40,0.95); }}
+ """)
+ return btn
+
+
+# ── Main panel ────────────────────────────────────────────────────────────────
+class ChatPanel(QWidget):
+ """Frameless always-on-top chat window."""
+
+ def __init__(self, agent):
+ super().__init__()
+ self.agent = agent
+
+ # session state
+ self._messages: list[dict] = []
+ self._session = {"id": _make_session_id(), "title": "新对话", "messages": []}
+ self._history: list[dict] = _load_history()
+ self._pending_files: list[dict] = [] # {'name','type','raw'}
+ self._settings_health_checked = False
+
+ # streaming state
+ self._display_queue: Optional[_queue.Queue] = None
+ self._streaming_row: Optional[_MsgRow] = None
+ self._streaming_text = ""
+ self._user_scrolled_up = False
+ self._poll_timer = QTimer(self)
+ self._poll_timer.timeout.connect(self._poll_queue)
+
+ # autonomous mode
+ self.autonomous_enabled = False
+ self.last_reply_time = time.time()
+
+ self.setWindowFlags(
+ Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool
+ )
+ self.setAttribute(Qt.WA_TranslucentBackground)
+ self.resize(530, 700)
+
+ # drag state (title bar)
+ self._drag_pos: Optional[QPoint] = None
+
+ self._build_ui()
+
+ def paintEvent(self, _event):
+ p = QPainter(self)
+ p.setRenderHint(QPainter.Antialiasing)
+ path = QPainterPath()
+ path.addRect(0.5, 0.5, self.width() - 1.0, self.height() - 1.0)
+ grad = QLinearGradient(0, 0, 0, self.height())
+ grad.setColorAt(0.0, QColor(20, 20, 28, 255))
+ grad.setColorAt(1.0, QColor(10, 10, 14, 255))
+ p.fillPath(path, grad)
+
+ def resizeEvent(self, event):
+ path = QPainterPath()
+ path.addRect(0, 0, float(self.width()), float(self.height()))
+ self.setMask(QRegion(path.toFillPolygon().toPolygon()))
+ super().resizeEvent(event)
+
+ # ── UI construction ───────────────────────────────────────────────────────
+ def _build_ui(self):
+ root = QVBoxLayout(self)
+ root.setContentsMargins(0, 0, 0, 0)
+ root.setSpacing(0)
+
+ root.addWidget(self._build_titlebar())
+ root.addWidget(_Separator())
+ root.addWidget(self._build_tabbar())
+ root.addWidget(_Separator())
+
+ self._stack = QStackedWidget()
+ self._stack.setStyleSheet("background: transparent;")
+ self._stack.addWidget(self._build_chat_page()) # 0
+ self._stack.addWidget(self._build_history_page()) # 1
+ self._stack.addWidget(self._build_sop_page()) # 2
+ self._stack.addWidget(self._build_settings_page())# 3
+ root.addWidget(self._stack)
+ root.addWidget(self._build_statusbar())
+
+ # Now that _stack exists, activate the first tab
+ self._switch_tab(0)
+
+ # ── title bar ─────────────────────────────────────────────────────────────
+ def _build_titlebar(self) -> QWidget:
+ bar = QWidget()
+ bar.setFixedHeight(48)
+ bar.setStyleSheet("background: transparent;")
+ bar.setCursor(QCursor(Qt.SizeAllCursor))
+
+ ly = QHBoxLayout(bar)
+ ly.setContentsMargins(16, 0, 10, 0)
+ ly.setSpacing(8)
+
+ # Search button
+ search_btn = QPushButton()
+ search_btn.setIcon(_svg_icon("search", _SVG_SEARCH, "#a1a1aa"))
+ search_btn.setIconSize(QSize(16, 16))
+ search_btn.setFixedSize(26, 26)
+ search_btn.setCursor(QCursor(Qt.PointingHandCursor))
+ search_btn.setStyleSheet("""
+ QPushButton { background: transparent; border: none; border-radius: 13px; }
+ QPushButton:hover { background: rgba(63,63,70,0.6); }
+ """)
+ search_btn.clicked.connect(self._toggle_search)
+ self._search_btn = search_btn
+ ly.addWidget(search_btn)
+
+ # Search widget (hidden by default)
+ self._search_widget = QWidget()
+ self._search_widget.hide()
+ sw_ly = QHBoxLayout(self._search_widget)
+ sw_ly.setContentsMargins(0, 0, 0, 0)
+ sw_ly.setSpacing(6)
+
+ self._search_input = QLineEdit()
+ self._search_input.setPlaceholderText("搜索当前对话和历史...")
+ self._search_input.setFixedHeight(26)
+ self._search_input.setStyleSheet(f"""
+ QLineEdit {{
+ background: rgba(32,32,38,0.9);
+ border: 1px solid {C['border'].name()};
+ border-radius: 13px;
+ color: {C['text']};
+ font-size: 13px;
+ padding: 0 10px;
+ }}
+ QLineEdit::placeholder {{ color: {C['muted']}; }}
+ """)
+ self._search_input.setFixedWidth(200)
+ self._search_input.textChanged.connect(self._on_search_changed)
+ self._search_input.installEventFilter(self)
+ sw_ly.addWidget(self._search_input)
+
+ close_search = QPushButton("×")
+ close_search.setFixedSize(26, 26)
+ close_search.setCursor(QCursor(Qt.PointingHandCursor))
+ close_search.setStyleSheet("""
+ QPushButton { background: transparent; color: #71717a; border: none; font-size: 16px; }
+ QPushButton:hover { color: #a1a1aa; }
+ """)
+ close_search.clicked.connect(self._hide_search)
+ sw_ly.addWidget(close_search)
+ ly.addWidget(self._search_widget)
+
+ ly.addStretch()
+
+ # Minimize button
+ mini = QPushButton("\uE949")
+ mini.setFixedSize(26, 26)
+ mini.setCursor(QCursor(Qt.PointingHandCursor))
+ mini.setStyleSheet("""
+ QPushButton { background: rgba(63,63,70,0.6); color: #a1a1aa;
+ border: none; border-radius: 13px; font-family: "Segoe MDL2 Assets"; font-size: 9px; }
+ QPushButton:hover { background: rgba(63,63,70,0.9); color: white; }
+ """)
+ mini.clicked.connect(self.hide)
+ ly.addWidget(mini)
+
+ # Maximize button
+ maxi = QPushButton("\uE739")
+ maxi.setFixedSize(26, 26)
+ maxi.setCursor(QCursor(Qt.PointingHandCursor))
+ maxi.setStyleSheet("""
+ QPushButton { background: rgba(63,63,70,0.6); color: #a1a1aa;
+ border: none; border-radius: 13px; font-family: "Segoe MDL2 Assets"; font-size: 9px; }
+ QPushButton:hover { background: rgba(63,63,70,0.9); color: white; }
+ """)
+ maxi.clicked.connect(self._toggle_maximize)
+ self._maxi_btn = maxi
+ ly.addWidget(maxi)
+
+ # Close button
+ close = QPushButton("\uE8BB")
+ close.setFixedSize(26, 26)
+ close.setCursor(QCursor(Qt.PointingHandCursor))
+ close.setStyleSheet("""
+ QPushButton { background: rgba(63,63,70,0.6); color: #a1a1aa;
+ border: none; border-radius: 13px; font-family: "Segoe MDL2 Assets"; font-size: 9px; }
+ QPushButton:hover { background: rgba(220,38,38,0.85); color: white; }
+ """)
+ close.clicked.connect(lambda: (self.close(), QApplication.instance().quit()))
+ ly.addWidget(close)
+
+ # Drag
+ bar.mousePressEvent = self._tb_press
+ bar.mouseMoveEvent = self._tb_move
+ bar.mouseReleaseEvent = self._tb_release
+ return bar
+
+ def _toggle_search(self):
+ if hasattr(self, "_search_visible") and self._search_visible:
+ self._hide_search()
+ else:
+ self._show_search()
+
+ def _show_search(self):
+ self._search_visible = True
+ self._search_btn.setFixedSize(0, 0)
+ self._search_widget.show()
+ self._search_input.setFocus()
+ self._search_input.selectAll()
+
+ def _hide_search(self):
+ self._search_visible = False
+ self._search_btn.setFixedSize(26, 26)
+ self._search_widget.hide()
+ self._search_input.clear()
+ self._clear_all_highlights()
+ if self._stack.currentIndex() == 1:
+ self._reset_history_items_style()
+
+ def _hide_search_if_no_focus(self):
+ if not self._search_input.hasFocus():
+ self._hide_search()
+
+ def _on_search_changed(self, text):
+ if not text.strip():
+ self._clear_all_highlights()
+ return
+ keyword = text.strip()
+ current_tab = self._stack.currentIndex()
+
+ if current_tab == 0:
+ self._search_current_chat(keyword)
+ elif current_tab == 1:
+ self._search_history(keyword)
+
+ def _clear_all_highlights(self):
+ for i in range(self._msg_layout.count() - 1):
+ w = self._msg_layout.itemAt(i).widget()
+ if isinstance(w, _MsgRow):
+ w.clear_highlight()
+
+ def _search_current_chat(self, keyword: str):
+ first_found = None
+ first_keyword_y = None
+ for i in range(self._msg_layout.count() - 1):
+ w = self._msg_layout.itemAt(i).widget()
+ if isinstance(w, _MsgRow):
+ if keyword.lower() in w._text.lower():
+ kw_y = w.highlight(keyword)
+ if first_found is None:
+ first_found = w
+ first_keyword_y = kw_y
+ else:
+ w.clear_highlight()
+ # 滚动到第一个匹配项(使用关键词在文档内的实际位置)
+ if first_found:
+ self._scroll_to_widget(first_found, first_keyword_y or 0)
+
+ def _scroll_to_widget(self, w, keyword_y=0):
+ self._user_scrolled_up = True
+ self._msg_container.layout().activate()
+ QApplication.processEvents()
+
+ sb = self._scroll.verticalScrollBar()
+ vp_h = self._scroll.viewport().height()
+ keyword_screen_y = w.y() + keyword_y
+ target = keyword_screen_y - vp_h // 3
+ target = max(0, min(target, sb.maximum()))
+ sb.setValue(target)
+ QApplication.processEvents()
+ self._scroll.viewport().repaint()
+
+ def _search_history(self, keyword: str):
+ kw_lower = keyword.lower()
+ for i in range(self._hist_list.count()):
+ item = self._hist_list.item(i)
+ session = item.data(Qt.UserRole)
+ messages = session.get("messages", []) if session else []
+ content_text = " ".join([m.get("content", "") for m in messages if isinstance(m.get("content"), str)])
+ match = kw_lower in content_text.lower()
+ item.setHidden(not match)
+ if match:
+ item.setBackground(QColor(251, 191, 36, 50))
+ item.setForeground(QColor(251, 191, 36))
+ else:
+ item.setBackground(QColor(0, 0, 0, 0))
+ item.setForeground(QColor(255, 255, 255))
+
+ def _reset_history_items_style(self):
+ for i in range(self._hist_list.count()):
+ item = self._hist_list.item(i)
+ item.setHidden(False)
+ item.setBackground(QColor(0, 0, 0, 0))
+ item.setForeground(QColor(255, 255, 255))
+ w = self._hist_list.itemWidget(item)
+ if w:
+ w.setStyleSheet(
+ f"background: rgba(35,35,42,0.6); color: {C['text']};"
+ " border: 1px solid #3f3f46; border-radius: 8px;"
+ " padding: 8px 12px; margin: 2px 0;"
+ )
+
+ def _tb_press(self, e):
+ if e.button() == Qt.LeftButton:
+ self._drag_pos = e.globalPosition().toPoint() - self.pos()
+
+ def _tb_move(self, e):
+ if e.buttons() == Qt.LeftButton and self._drag_pos is not None:
+ self.move(e.globalPosition().toPoint() - self._drag_pos)
+
+ def _tb_release(self, _e):
+ self._drag_pos = None
+
+ def _toggle_maximize(self):
+ if self.isMaximized():
+ self.showNormal()
+ self._maxi_btn.setText("☐")
+ else:
+ self.showMaximized()
+ self._maxi_btn.setText("❐")
+
+ # ── status bar ─────────────────────────────────────────────────────────────
+ def _build_statusbar(self) -> QWidget:
+ bar = QWidget()
+ bar.setFixedHeight(24)
+ bar.setStyleSheet("background: transparent;")
+ ly = QHBoxLayout(bar)
+ ly.setContentsMargins(16, 0, 10, 0)
+ ly.setSpacing(8)
+
+ # Status dot
+ dot = QLabel("●")
+ dot.setStyleSheet(f"color: {C['green']}; font-size: 9px;")
+ dot.setFixedWidth(12)
+ ly.addWidget(dot)
+
+ # Model name (clickable to show model list)
+ self._model_badge = QLabel(self._model_name())
+ self._model_badge.setStyleSheet("color: #a1a1aa; font-size: 11px;")
+ self._model_badge.setCursor(QCursor(Qt.PointingHandCursor))
+ self._model_badge.mousePressEvent = lambda e: self._show_model_menu(e)
+ ly.addWidget(self._model_badge)
+
+ self._streaming_badge = _StreamingBadge()
+ ly.addWidget(self._streaming_badge)
+
+ ly.addStretch()
+ return bar
+
+ def _show_model_menu(self, _e):
+ menu = QMenu(self._model_badge)
+ menu.setStyleSheet(f"""
+ QMenu {{
+ background: {C['panel'].name()};
+ border: 1px solid {C['border'].name()};
+ padding: 4px 0;
+ }}
+ QMenu::item {{
+ color: {C['text']};
+ padding: 6px 20px 6px 12px;
+ font-size: 12px;
+ }}
+ QMenu::item:selected {{
+ background: {C['hover_bg']};
+ }}
+ """)
+ for i, client in enumerate(self.agent.llmclients):
+ name = getattr(client, 'name', None) or "未知"
+ act = menu.addAction(f"{name} #{i + 1}")
+ act.triggered.connect(lambda _, idx=i: self._do_switch_to(idx))
+ menu.exec(QCursor.pos())
+
+ # ── tab bar ───────────────────────────────────────────────────────────────
+ def _build_tabbar(self) -> QWidget:
+ bar = QWidget()
+ bar.setFixedHeight(40)
+ bar.setStyleSheet("background: rgba(10,10,14,0.6);")
+
+ ly = QHBoxLayout(bar)
+ ly.setContentsMargins(12, 5, 12, 5)
+ ly.setSpacing(4)
+
+ self._tabs: list[_TabButton] = []
+ tab_defs = [
+ (_SVG_CHAT, "对话"),
+ (_SVG_CLOCK, "历史"),
+ (_SVG_BOOK, "SOP"),
+ (_SVG_GEAR, "设置"),
+ ]
+ for i, (svg, text) in enumerate(tab_defs):
+ btn = _TabButton(text)
+ btn.setIcon(_svg_icon(text, svg, "#b0b0b8"))
+ btn.setIconSize(QSize(14, 14))
+ btn.clicked.connect(lambda _checked, idx=i: self._switch_tab(idx))
+ ly.addWidget(btn)
+ self._tabs.append(btn)
+
+ ly.addStretch()
+
+ new_btn = QPushButton("新对话")
+ new_btn.setIcon(_svg_icon("plus", _SVG_PLUS, "#a78bfa"))
+ new_btn.setIconSize(QSize(12, 12))
+ new_btn.setFixedHeight(27)
+ new_btn.setStyleSheet(f"""
+ QPushButton {{ background: rgba(124,58,237,0.18); color: #a78bfa;
+ border: 1px solid rgba(124,58,237,0.3); border-radius: 7px;
+ padding: 0 10px; font-size: 12px; font-weight: 700; }}
+ QPushButton:hover {{ background: rgba(124,58,237,0.35); color: white; }}
+ """)
+ new_btn.clicked.connect(self._new_session)
+ ly.addWidget(new_btn)
+
+ # NOTE: _switch_tab(0) is called in _build_ui() after _stack is created
+ return bar
+
+ def _switch_tab(self, idx: int):
+ self._stack.setCurrentIndex(idx)
+ for i, btn in enumerate(self._tabs):
+ btn.setChecked(i == idx)
+ # 切换标签时关闭搜索框
+ if hasattr(self, '_search_visible') and self._search_visible:
+ self._hide_search()
+ if idx == 1:
+ self._refresh_history()
+ if idx == 2:
+ self._refresh_sop()
+ if idx == 3:
+ self._refresh_model_rows_style()
+ if not self._settings_health_checked:
+ self._start_health_checks()
+ self._settings_health_checked = True
+
+ # ── chat page ─────────────────────────────────────────────────────────────
+ def _build_chat_page(self) -> QWidget:
+ page = QWidget()
+ page.setStyleSheet("background: transparent;")
+ ly = QVBoxLayout(page)
+ ly.setContentsMargins(0, 0, 0, 0)
+ ly.setSpacing(0)
+
+ # ── message scroll area ──
+ self._scroll = QScrollArea()
+ self._scroll.setWidgetResizable(True)
+ self._scroll.setFrameShape(QFrame.NoFrame)
+ self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ self._scroll.setStyleSheet(f"QScrollArea {{ background: transparent; border: none; }} {SCROLLBAR_STYLE}")
+
+ self._msg_container = QWidget()
+ self._msg_container.setStyleSheet("background: transparent;")
+ self._msg_layout = QVBoxLayout(self._msg_container)
+ self._msg_layout.setContentsMargins(0, 12, 0, 12)
+ self._msg_layout.setSpacing(4)
+ self._msg_layout.addStretch()
+
+ self._scroll.setWidget(self._msg_container)
+ self._scroll.verticalScrollBar().valueChanged.connect(self._on_scroll)
+
+ # ── scroll navigation buttons (centered at bottom of message area) ──
+ scroll_wrapper = QWidget()
+ scroll_wrapper.setStyleSheet("background: transparent;")
+ wrap_ly = QVBoxLayout(scroll_wrapper)
+ wrap_ly.setContentsMargins(0, 0, 0, 0)
+ wrap_ly.setSpacing(0)
+ wrap_ly.addWidget(self._scroll)
+
+ self._nav_widget = QWidget()
+ self._nav_widget.setFixedSize(68, 28)
+ self._nav_widget.setStyleSheet("background: transparent; border: none;")
+ nav_ly = QHBoxLayout(self._nav_widget)
+ nav_ly.setContentsMargins(6, 2, 6, 2)
+ nav_ly.setSpacing(8)
+
+ self._nav_up = QPushButton("∧")
+ self._nav_up.setFixedWidth(26)
+ self._nav_up.setCursor(QCursor(Qt.PointingHandCursor))
+ self._nav_up.setStyleSheet("""
+ QPushButton { background: transparent; color: #71717a; border: none; font-size: 14px; }
+ QPushButton:hover { color: #a1a1aa; }
+ QPushButton:disabled { color: #27272a; }
+ """)
+ self._nav_up.clicked.connect(self._scroll_to_top)
+
+ self._nav_down = QPushButton("∨")
+ self._nav_down.setFixedWidth(26)
+ self._nav_down.setCursor(QCursor(Qt.PointingHandCursor))
+ self._nav_down.setStyleSheet("""
+ QPushButton { background: transparent; color: #71717a; border: none; font-size: 14px; }
+ QPushButton:hover { color: #a1a1aa; }
+ QPushButton:disabled { color: #27272a; }
+ """)
+ self._nav_down.clicked.connect(self._scroll_to_bottom)
+
+ nav_ly.addWidget(self._nav_up)
+ nav_ly.addWidget(self._nav_down)
+
+ wrap_ly.addWidget(self._nav_widget, 0, Qt.AlignHCenter | Qt.AlignBottom)
+ self._nav_widget.setContentsMargins(0, 0, 0, 8)
+ self._nav_widget.hide()
+
+ ly.addWidget(scroll_wrapper, 1)
+
+ ly.addWidget(_Separator())
+
+ # ── input area ──
+ ly.addWidget(self._build_input_area())
+
+ QTimer.singleShot(200, self._update_nav_visibility)
+ return page
+
+ def _build_input_area(self) -> QWidget:
+ wrap = QWidget()
+ wrap.setStyleSheet("background: transparent;")
+ ly = QVBoxLayout(wrap)
+ ly.setContentsMargins(20, 6, 20, 0)
+ ly.setSpacing(0)
+
+ self._chips_row = QWidget()
+ self._chips_row.setStyleSheet("background: transparent;")
+ self._chips_ly = QHBoxLayout(self._chips_row)
+ self._chips_ly.setContentsMargins(0, 0, 0, 6)
+ self._chips_ly.setSpacing(6)
+ self._chips_row.hide()
+ ly.addWidget(self._chips_row)
+
+ card = QWidget()
+ card.setStyleSheet(f"""
+ QWidget#inputCard {{
+ background: rgba(32,32,38,0.85);
+ border: 1px solid {C['border'].name()};
+ border-radius: 16px;
+ }}
+ QWidget#inputCard:focus-within {{
+ border-color: rgba(124,58,237,0.55);
+ }}
+ """)
+ card.setObjectName("inputCard")
+ card_ly = QVBoxLayout(card)
+ card_ly.setContentsMargins(14, 10, 10, 10)
+ card_ly.setSpacing(6)
+
+ class _PlainTextEdit(QTextEdit):
+ def insertFromMimeData(self, source):
+ text = source.text() or source.data("text/plain")
+ if text:
+ self.insertPlainText(text)
+
+ self._input = _PlainTextEdit()
+ self._input.setAutoFormatting(QTextEdit.AutoNone)
+ self._input.setFixedHeight(64)
+ self._input.setPlaceholderText("给助手发送消息... Enter发送,Shift+Enter换行")
+ self._input.setStyleSheet(f"""
+ QTextEdit {{
+ background: transparent; color: {C['text']};
+ border: none; padding: 0; font-size: 14px;
+ selection-background-color: rgba(124,58,237,0.4);
+ }}
+ """)
+ self._input.installEventFilter(self)
+ self._input.textChanged.connect(self._on_text_changed)
+ card_ly.addWidget(self._input)
+
+ bottom = QHBoxLayout()
+ bottom.setSpacing(6)
+
+ attach = QPushButton()
+ attach.setIcon(_svg_icon("clip", _SVG_CLIP, "#a1a1aa"))
+ attach.setIconSize(QSize(17, 17))
+ attach.setFixedSize(30, 30)
+ attach.setToolTip("上传附件")
+ attach.setCursor(QCursor(Qt.PointingHandCursor))
+ attach.setStyleSheet("""
+ QPushButton { background: transparent; border: none; border-radius: 15px; }
+ QPushButton:hover { background: rgba(63,63,70,0.6); }
+ """)
+ attach.clicked.connect(self._attach_files)
+ bottom.addWidget(attach)
+
+ self._char_lbl = QLabel("0 / 2000")
+ self._char_lbl.setStyleSheet(f"color: {C['muted']}; font-size: 11px;")
+ bottom.addWidget(self._char_lbl)
+
+ self._token_lbl = QLabel("")
+ self._token_lbl.setStyleSheet(f"color: {C['muted']}; font-size: 11px; margin-left: 10px;")
+ bottom.addWidget(self._token_lbl)
+
+ bottom.addStretch()
+
+ self._is_streaming = False
+ self._send_btn = QPushButton()
+ self._send_btn.setFixedSize(34, 34)
+ self._send_btn.setCursor(QCursor(Qt.PointingHandCursor))
+ self._send_btn.clicked.connect(self._on_send_btn_click)
+ self._set_send_mode()
+ bottom.addWidget(self._send_btn)
+
+ card_ly.addLayout(bottom)
+ ly.addWidget(card)
+ return wrap
+
+ # ── history page ──────────────────────────────────────────────────────────
+ def _build_history_page(self) -> QWidget:
+ page = QWidget()
+ page.setStyleSheet("background: transparent;")
+ ly = QVBoxLayout(page)
+ ly.setContentsMargins(12, 12, 12, 12)
+ ly.setSpacing(8)
+
+ header = QHBoxLayout()
+ lbl = QLabel("历史记录")
+ lbl.setStyleSheet("color: #f4f4f5; font-weight: 600; font-size: 14px;")
+ header.addWidget(lbl)
+ header.addStretch()
+
+ restore_btn = QPushButton("恢复会话")
+ restore_btn.setStyleSheet(self._small_btn_style(C["accent"]))
+ restore_btn.clicked.connect(self._restore_selected)
+ header.addWidget(restore_btn)
+
+ del_btn = QPushButton("删除")
+ del_btn.setStyleSheet(self._small_btn_style("#dc2626"))
+ del_btn.clicked.connect(self._delete_selected)
+ header.addWidget(del_btn)
+ ly.addLayout(header)
+
+ self._hist_list = QListWidget()
+ self._hist_list.setStyleSheet(f"""
+ QListWidget {{ background: transparent; border: none; outline: none; }}
+ QListWidget::item {{
+ background: rgba(35,35,42,0.6); color: {C['text']};
+ border: 1px solid {C['border'].name()}; border-radius: 8px;
+ padding: 8px 12px; margin: 2px 0;
+ }}
+ QListWidget::item:hover {{ background: rgba(55,55,65,0.8);
+ border-color: rgba(124,58,237,0.4); }}
+ QListWidget::item:selected {{ background: {C["accent_bg"]};
+ border-color: rgba(124,58,237,0.6); }}
+ {SCROLLBAR_STYLE}
+ """)
+ self._hist_list.itemDoubleClicked.connect(self._restore_selected)
+ ly.addWidget(self._hist_list)
+ return page
+
+ # ── SOP page ──────────────────────────────────────────────────────────────
+ def _build_sop_page(self) -> QWidget:
+ page = QWidget()
+ page.setStyleSheet("background: transparent;")
+ ly = QVBoxLayout(page)
+ ly.setContentsMargins(0, 0, 0, 0)
+
+ splitter = QSplitter(Qt.Horizontal)
+
+ self._sop_list = QListWidget()
+ self._sop_list.setMaximumWidth(175)
+ self._sop_list.setStyleSheet(f"""
+ QListWidget {{ background: rgba(10,10,14,0.7); border: none;
+ border-right: 1px solid {C['border'].name()}; outline: none; }}
+ QListWidget::item {{ color: {C['muted']}; padding: 7px 10px;
+ border-radius: 4px; margin: 1px 4px; }}
+ QListWidget::item:hover {{ background: rgba(55,55,65,0.7); color: {C['text']}; }}
+ QListWidget::item:selected {{ background: rgba(124,58,237,0.28); color: white; }}
+ {SCROLLBAR_STYLE}
+ """)
+ self._sop_list.currentItemChanged.connect(self._load_sop)
+ splitter.addWidget(self._sop_list)
+
+ self._sop_viewer = QTextBrowser()
+ self._sop_viewer.setOpenExternalLinks(True)
+ self._sop_viewer.document().setDefaultStyleSheet(_MD_CSS)
+ self._sop_viewer.setStyleSheet(f"""
+ QTextBrowser {{ background: transparent; color: {C['text']};
+ border: none; padding: 10px 14px;
+ font-family: "Arial", "Microsoft YaHei", sans-serif;
+ font-size: 13px; }}
+ {SCROLLBAR_STYLE}
+ """)
+ splitter.addWidget(self._sop_viewer)
+ splitter.setSizes([165, 340])
+ ly.addWidget(splitter)
+ return page
+
+ # ── settings page ─────────────────────────────────────────────────────────
+ def _build_settings_page(self) -> QWidget:
+ page = QWidget()
+ page.setStyleSheet("background: transparent;")
+ ly = QVBoxLayout(page)
+ ly.setContentsMargins(16, 16, 16, 16)
+ ly.setSpacing(8)
+
+ lbl = QLabel("控制面板")
+ lbl.setStyleSheet("color: #f4f4f5; font-weight: 600; font-size: 14px;")
+ ly.addWidget(lbl)
+
+ self._model_info = QLabel(f"当前模型:{self._model_name()} (#{self.agent.llm_no})")
+ self._model_info.setStyleSheet(f"color: {C['muted']}; font-size: 12px;")
+ ly.addWidget(self._model_info)
+ ly.addSpacing(4)
+
+ model_hdr = QLabel("模型列表")
+ model_hdr.setStyleSheet("color: #d4d4d8; font-weight: 600; font-size: 13px;")
+ ly.addWidget(model_hdr)
+
+ self._model_rows_container = QWidget()
+ self._model_rows_container.setStyleSheet("background: transparent;")
+ self._model_rows_layout = QVBoxLayout(self._model_rows_container)
+ self._model_rows_layout.setContentsMargins(0, 0, 0, 0)
+ self._model_rows_layout.setSpacing(3)
+ ly.addWidget(self._model_rows_container)
+
+ self._model_row_widgets: list[dict] = []
+ self._health_results: dict[int, bool | None] = {}
+ self._build_model_rows()
+
+ ly.addSpacing(6)
+
+ for (lbl_text, color, handler, svg) in [
+ ("重置提示词", "#059669", self._do_reset_prompt, _SVG_RESET),
+ ("保存当前会话","#0ea5e9", self._do_save, _SVG_SAVE),
+ ("清空对话", "#78716c", self._do_clear, _SVG_TRASH),
+ ]:
+ b = _action_btn(lbl_text, color, _svg_icon(lbl_text, svg))
+ b.clicked.connect(handler)
+ ly.addWidget(b)
+
+ ly.addSpacing(10)
+ sep = QLabel("自主行动")
+ sep.setStyleSheet("color: #f4f4f5; font-weight: 600; font-size: 13px;")
+ ly.addWidget(sep)
+
+ self._auto_btn = _action_btn(f"开启自主行动 (idle > {AUTO_IDLE_THRESHOLD // 60} min 自动触发)", "#f59e0b",
+ _svg_icon("bolt", _SVG_BOLT))
+ self._auto_btn.setCheckable(True)
+ self._auto_btn.clicked.connect(self._do_toggle_auto)
+ ly.addWidget(self._auto_btn)
+
+ trigger_btn = _action_btn("立即触发一次", "#f59e0b",
+ _svg_icon("play", _SVG_PLAY))
+ trigger_btn.clicked.connect(self._do_trigger_auto)
+ ly.addWidget(trigger_btn)
+
+ ly.addStretch()
+ return page
+
+ # ── model list ────────────────────────────────────────────────────────────
+ _MODEL_ROW_STYLE = (
+ "QPushButton { background: rgba(39,39,42,0.7); color: #e4e4e7;"
+ " border: 1px solid #3f3f46; border-radius: 8px;"
+ " padding: 6px 10px; font-size: 12px; font-weight: 700; text-align: left; }"
+ " QPushButton:hover { background: rgba(63,63,70,0.8); }"
+ )
+ _MODEL_ROW_ACTIVE = (
+ "QPushButton { background: rgba(124,58,237,0.25); color: #c4b5fd;"
+ " border: 1px solid rgba(124,58,237,0.5); border-radius: 8px;"
+ " padding: 6px 10px; font-size: 12px; font-weight: 700; text-align: left; }"
+ " QPushButton:hover { background: rgba(124,58,237,0.35); }"
+ )
+
+ def _build_model_rows(self):
+ while self._model_rows_layout.count():
+ w = self._model_rows_layout.takeAt(0).widget()
+ if w:
+ w.deleteLater()
+ self._model_row_widgets.clear()
+
+ for idx, tc in enumerate(self.agent.llmclients):
+ b = tc.backend
+ name = f"{type(b).__name__}/{b.model}"
+ is_current = idx == self.agent.llm_no
+
+ row = QWidget()
+ row.setStyleSheet("background: transparent;")
+ rlay = QHBoxLayout(row)
+ rlay.setContentsMargins(0, 0, 0, 0)
+ rlay.setSpacing(6)
+
+ dot = QLabel("●")
+ dot.setFixedWidth(14)
+ dot.setAlignment(Qt.AlignCenter)
+ dot.setStyleSheet("color: #71717a; font-size: 11px;")
+ rlay.addWidget(dot)
+
+ btn = QPushButton(f" #{idx} {name}")
+ btn.setCursor(QCursor(Qt.PointingHandCursor))
+ btn.setStyleSheet(self._MODEL_ROW_ACTIVE if is_current else self._MODEL_ROW_STYLE)
+ btn.clicked.connect(lambda checked, i=idx: self._do_switch_to(i))
+ rlay.addWidget(btn, 1)
+
+ self._model_rows_layout.addWidget(row)
+ self._model_row_widgets.append({"dot": dot, "btn": btn, "idx": idx})
+
+ def _refresh_model_rows_style(self):
+ for entry in self._model_row_widgets:
+ is_current = entry["idx"] == self.agent.llm_no
+ entry["btn"].setStyleSheet(
+ self._MODEL_ROW_ACTIVE if is_current else self._MODEL_ROW_STYLE
+ )
+ status = self._health_results.get(entry["idx"])
+ if status is True:
+ entry["dot"].setStyleSheet("color: #22c55e; font-size: 11px;")
+ elif status is False:
+ entry["dot"].setStyleSheet("color: #ef4444; font-size: 11px;")
+ else:
+ entry["dot"].setStyleSheet("color: #71717a; font-size: 11px;")
+
+ def _do_switch_to(self, idx: int):
+ if idx == self.agent.llm_no:
+ return
+ self.agent.next_llm(n=idx)
+ name = self._model_name()
+ self._model_badge.setText(name)
+ self._model_info.setText(f"当前模型:{name} (#{self.agent.llm_no})")
+ self._add_system_notice(f"已切换至 {name},对话上下文已保留")
+ self._refresh_model_rows_style()
+
+ def _start_health_checks(self):
+ self._health_results.clear()
+ self._health_pending = 0
+ self._health_result_queue = _queue.Queue()
+ for entry in self._model_row_widgets:
+ entry["dot"].setStyleSheet("color: #71717a; font-size: 11px;")
+ entry["dot"].setText("◌")
+ for idx, tc in enumerate(self.agent.llmclients):
+ self._health_pending += 1
+ t = threading.Thread(target=self._check_backend, args=(idx, tc.backend), daemon=True)
+ t.start()
+ if not hasattr(self, '_health_poll_timer'):
+ self._health_poll_timer = QTimer(self)
+ self._health_poll_timer.timeout.connect(self._poll_health_results)
+ self._health_poll_timer.start(500)
+
+ def _poll_health_results(self):
+ while True:
+ try:
+ idx, ok = self._health_result_queue.get_nowait()
+ self._health_results[idx] = ok
+ except _queue.Empty:
+ break
+ self._refresh_model_rows_style()
+ if len(self._health_results) >= self._health_pending:
+ self._health_poll_timer.stop()
+
+ def _check_backend(self, idx: int, backend):
+ ok = False
+ try:
+ reply = backend.ask("你好")
+ # 兼容生成器函数(NativeClaudeSession.ask是生成器)
+ if hasattr(reply, '__iter__') and not isinstance(reply, str):
+ reply = ''.join(str(b) for b in reply if isinstance(b, str))
+ text = str(reply).strip() if reply else ""
+ ok = len(text) > 0 and not text.startswith("Error") and not text.startswith("[")
+ print(f"[HealthCheck] Backend #{idx} {type(backend).__name__}/{backend.model}: {'OK' if ok else 'FAIL'} -> {text[:60]}")
+ except Exception as e:
+ print(f"[HealthCheck] Backend #{idx} {type(backend).__name__}/{backend.model}: ERROR -> {e}")
+ ok = False
+ if hasattr(backend, 'raw_msgs') and backend.raw_msgs:
+ backend.raw_msgs = [m for m in backend.raw_msgs if m.get("prompt") != "你好"]
+ self._health_result_queue.put((idx, ok))
+
+ # ── event filter (Enter key in text edit, Escape to close search) ──────────
+ def eventFilter(self, obj, event):
+ if event.type() == QEvent.KeyPress:
+ if obj is self._search_input and event.key() == Qt.Key_Escape:
+ self._hide_search()
+ return True
+ if obj is self._input and event.key() in (Qt.Key_Return, Qt.Key_Enter):
+ if not (event.modifiers() & Qt.ShiftModifier):
+ self._handle_send()
+ return True
+ # 搜索框失焦时关闭搜索
+ if event.type() == QEvent.FocusOut and obj is self._search_input:
+ # 延迟关闭,等待点击事件处理完毕
+ QTimer.singleShot(50, self._hide_search_if_no_focus)
+ return super().eventFilter(obj, event)
+
+ def _on_text_changed(self):
+ n = len(self._input.toPlainText())
+ self._char_lbl.setText(f"{n} / 2000")
+
+ # ── file attachment ────────────────────────────────────────────────────────
+ def _attach_files(self):
+ paths, _ = QFileDialog.getOpenFileNames(
+ self, "选择附件", "",
+ "All Files (*);;"
+ "Images (*.png *.jpg *.jpeg *.gif *.webp *.bmp);;"
+ "Text (*.txt *.md *.py *.json *.csv *.yaml *.yml *.log *.js *.ts *.sql)",
+ )
+ for path in paths:
+ name = os.path.basename(path)
+ if any(f["name"] == name for f in self._pending_files):
+ continue
+ ext = os.path.splitext(path)[1].lower()
+ img_exts = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
+ mime = (f"image/{ext[1:]}" if ext in img_exts else
+ "text/plain" if ext in TEXT_FILE_EXTS else
+ "application/octet-stream")
+ try:
+ with open(path, "rb") as fh:
+ raw = fh.read()
+ if len(raw) > MAX_UPLOAD_BYTES:
+ print(f"[Attach] 文件过大,已跳过: {name} ({len(raw)} bytes)")
+ continue
+ self._pending_files.append({"name": name, "type": mime, "raw": raw})
+ except Exception as e:
+ print(f"[Attach] Failed to read {path}: {e}")
+ self._refresh_chips()
+
+ def _refresh_chips(self):
+ while self._chips_ly.count():
+ item = self._chips_ly.takeAt(0)
+ if item.widget():
+ item.widget().deleteLater()
+ if not self._pending_files:
+ self._chips_row.hide()
+ return
+ for f in self._pending_files:
+ chip = QLabel(f['name'])
+ chip.setStyleSheet(f"""
+ QLabel {{ background: rgba(55,55,65,0.7); color: {C['text']};
+ border: 1px solid {C['border'].name()}; border-radius: 6px;
+ padding: 3px 8px; font-size: 11px; }}
+ """)
+ self._chips_ly.addWidget(chip)
+ self._chips_ly.addStretch()
+ self._chips_row.show()
+
+ # ── send / streaming ───────────────────────────────────────────────────────
+ _SEND_BTN_STYLE = """
+ QPushButton { background: #e4e4e7; border: none; border-radius: 17px; }
+ QPushButton:hover { background: #f4f4f5; }
+ QPushButton:pressed { background: #d4d4d8; }
+ """
+ _STOP_BTN_STYLE = """
+ QPushButton { background: rgba(239,68,68,0.85); border: none; border-radius: 17px; }
+ QPushButton:hover { background: rgba(248,113,113,0.9); }
+ QPushButton:pressed { background: rgba(220,38,38,0.9); }
+ """
+
+ def _set_send_mode(self):
+ self._is_streaming = False
+ self._send_btn.setText("")
+ self._send_btn.setIcon(_svg_icon("send_arrow", _SVG_SEND, "#18181b"))
+ self._send_btn.setIconSize(QSize(18, 18))
+ self._send_btn.setStyleSheet(self._SEND_BTN_STYLE)
+
+ def _set_stop_mode(self):
+ self._is_streaming = True
+ self._send_btn.setText("")
+ self._send_btn.setIcon(_svg_icon("stop_circle", _SVG_STOP, "#ffffff"))
+ self._send_btn.setIconSize(QSize(16, 16))
+ self._send_btn.setStyleSheet(self._STOP_BTN_STYLE)
+
+ def _on_send_btn_click(self):
+ if self._is_streaming:
+ self._do_stop()
+ else:
+ self._handle_send()
+
+ def _handle_send(self):
+ text = self._input.toPlainText().strip()
+ files = self._pending_files.copy()
+ if not text and not files:
+ return
+
+ if text.startswith("/"):
+ self._input.clear()
+ self._pending_files.clear()
+ self._refresh_chips()
+ self._handle_command(text)
+ return
+
+ prompt = text or "请分析我上传的附件。"
+ full_prompt, display_prompt, _ = _build_prompt_with_uploads(prompt, files)
+
+ # Clear input state
+ self._input.clear()
+ self._pending_files.clear()
+ self._refresh_chips()
+
+ # Update session title
+ if self._session["title"] == "新对话" and prompt:
+ self._session["title"] = prompt[:20] + ("..." if len(prompt) > 20 else "")
+
+ from datetime import datetime
+ now_iso = datetime.now().isoformat()
+ user_idx = len(self._messages)
+ self._messages.append({"role": "user", "content": display_prompt, "created_at": now_iso})
+ self._add_msg_row(
+ "user",
+ display_prompt,
+ created_at=now_iso,
+ on_delete=lambda idx=user_idx: self._delete_message(idx),
+ on_rewrite=lambda idx=user_idx: self._rewrite_message(idx)
+ )
+ self._update_token_usage()
+
+ # Start streaming — reset scroll lock so new output auto-scrolls
+ self._user_scrolled_up = False
+ self._streaming_text = ""
+ # The streaming row will be replaced when done, it doesn't need deletion/export
+ self._streaming_row = self._add_msg_row("assistant", "▌")
+ self._streaming_row.set_finished(False)
+ self._set_stop_mode()
+ self._streaming_badge.show()
+
+ self._display_queue = self.agent.put_task(f"{FILE_HINT}\n\n{full_prompt}", source="user")
+ self._poll_timer.start(40)
+
+ def _handle_command(self, cmd: str):
+ parts = cmd.split()
+ op = parts[0].lower() if parts else ""
+ if op == "/help":
+ self._add_system_notice(HELP_TEXT)
+ elif op == "/stop":
+ self._do_stop()
+ self._add_system_notice("⏹️ 已停止")
+ elif op == "/status":
+ llm = self._model_name()
+ state = "🔴 运行中" if self.agent.is_running else "🟢 空闲"
+ self._add_system_notice(f"状态: {state}\nLLM: [{self.agent.llm_no}] {llm}")
+ elif op == "/llm":
+ if not self.agent.llmclient:
+ self._add_system_notice("❌ 当前没有可用的 LLM 配置")
+ elif len(parts) > 1:
+ try:
+ idx = int(parts[1])
+ self._do_switch_to(idx)
+ except Exception:
+ self._add_system_notice(f"用法: /llm <0-{len(self.agent.llmclients) - 1}>")
+ else:
+ lines = [f"{'→' if i == self.agent.llm_no else ' '} [{i}] {getattr(c, 'name', type(c.backend).__name__ + '/' + c.backend.model)}"
+ for i, c in enumerate(self.agent.llmclients)]
+ self._add_system_notice("LLMs:\n" + "\n".join(lines))
+ elif op == "/restore":
+ restored_info, err = format_restore()
+ if err:
+ self._add_system_notice(err)
+ else:
+ restored, fname, count = restored_info
+ self.agent.abort()
+ self.agent.history.extend(restored)
+ self._add_system_notice(f"✅ 已恢复 {count} 轮对话\n来源: {fname}")
+ elif op == "/new":
+ self._do_clear()
+ self._add_system_notice("✅ 已开启新对话")
+ else:
+ self._add_system_notice(f"未知命令: {cmd}\n{HELP_TEXT}")
+
+ def _poll_queue(self):
+ if not self._display_queue:
+ return
+ try:
+ while True:
+ item = self._display_queue.get_nowait()
+ if not isinstance(item, dict) or ("next" not in item and "done" not in item):
+ print(f"[Queue] 跳过异常项: {item}")
+ continue
+ if "next" in item:
+ self._streaming_text = item["next"]
+ if self._streaming_row:
+ self._streaming_row.set_text(self._streaming_text + " ▌")
+ self._update_token_usage()
+ self._scroll_bottom()
+ if "done" in item:
+ final = item["done"]
+ from datetime import datetime
+ now_iso = datetime.now().isoformat()
+ # Remove the temporary streaming row
+ if self._streaming_row:
+ # Find its position in the layout to replace it
+ idx = self._msg_layout.indexOf(self._streaming_row)
+ self._streaming_row.deleteLater()
+ self._streaming_row = None
+ # Add the final message with proper buttons
+ assist_idx = len(self._messages)
+ self._messages.append({"role": "assistant", "content": final, "created_at": now_iso})
+ # Insert at the same position where the streaming row was, or before the stretch
+ insert_pos = idx if idx >= 0 else self._msg_layout.count() - 1
+ row = _MsgRow(
+ final,
+ "assistant",
+ on_resend=self._regenerate_response,
+ on_delete=lambda idx=assist_idx: self._delete_message(idx),
+ on_rewrite=None,
+ created_at=now_iso
+ )
+ # 自动展开最后一个 LLM Running 块,方便用户直接看到结果
+ for _, title, _ in reversed(row._parse_foldable_blocks(final)):
+ if title is not None and title in row._folded_ids and 'LLM Running' in title:
+ row._folded_ids.remove(title)
+ row._label.setHtml(row._render_with_folds(final))
+ row._adjust_browser_height()
+ break
+ self._msg_layout.insertWidget(insert_pos, row)
+ self._poll_timer.stop()
+ self._set_send_mode()
+ self._streaming_badge.hide()
+ self.last_reply_time = time.time()
+ self._update_token_usage()
+ self._scroll_bottom()
+ self._auto_save()
+ break
+ except _queue.Empty:
+ pass
+
+ def _add_msg_row(self, role: str, text: str, created_at: str = None, on_delete=None, on_rewrite=None) -> _MsgRow:
+ row = _MsgRow(
+ text,
+ role,
+ on_resend=self._regenerate_response if role != "user" else None,
+ on_delete=on_delete,
+ on_rewrite=on_rewrite,
+ created_at=created_at
+ )
+ self._msg_layout.insertWidget(self._msg_layout.count() - 1, row)
+ self._scroll_bottom()
+ return row
+
+ def _regenerate_response(self):
+ """Resend the last user message to regenerate the assistant response."""
+ if self._is_streaming:
+ return
+ for msg in reversed(self._messages):
+ if msg["role"] == "user":
+ self._input.setPlainText(msg["content"])
+ self._handle_send()
+ break
+
+ def _delete_message(self, index: int):
+ """Delete the message at the given index."""
+ if index < 0 or index >= len(self._messages):
+ return
+ # Remove from data
+ self._messages.pop(index)
+ # Rebuild all rows to ensure on_delete indices are correct
+ self._rebuild_messages()
+ # Update
+ self._update_token_usage()
+ self._auto_save()
+
+ def _rewrite_message(self, index: int):
+ """Rewrite the user message at the given index."""
+ if index < 0 or index >= len(self._messages):
+ return
+ if self._messages[index]["role"] != "user":
+ return
+ # Get the content and fill it into the input
+ content = self._messages[index]["content"]
+ self._input.setPlainText(content)
+ # Remove this message and everything after it
+ self._messages = self._messages[:index]
+ # Rebuild UI
+ self._rebuild_messages()
+ self._update_token_usage()
+ self._auto_save()
+
+ def _on_scroll(self, value):
+ sb = self._scroll.verticalScrollBar()
+ self._user_scrolled_up = value < sb.maximum() - 30
+ self._update_nav_visibility()
+
+ def _update_nav_visibility(self):
+ sb = self._scroll.verticalScrollBar()
+ max_val = sb.maximum()
+ vp_h = self._scroll.viewport().height()
+ total_h = max_val + vp_h
+ show_nav = max_val > 0 and total_h >= vp_h * 1.5
+
+ if show_nav:
+ self._nav_widget.show()
+ self._nav_up.setEnabled(sb.value() > 2)
+ self._nav_down.setEnabled(max_val > 0 and sb.value() < max_val - 2)
+ else:
+ self._nav_widget.hide()
+
+ def _scroll_to_top(self):
+ self._user_scrolled_up = True
+ self._scroll.verticalScrollBar().setValue(0)
+
+ def _scroll_to_bottom(self):
+ self._user_scrolled_up = False
+ QTimer.singleShot(60, lambda: self._scroll.verticalScrollBar().setValue(
+ self._scroll.verticalScrollBar().maximum()
+ ))
+
+ def _scroll_bottom(self):
+ if self._user_scrolled_up:
+ return
+ QTimer.singleShot(60, lambda: self._scroll.verticalScrollBar().setValue(
+ self._scroll.verticalScrollBar().maximum()
+ ))
+
+ # ── inject (autonomous mode) ───────────────────────────────────────────────
+ def inject_message(self, text: str):
+ """Programmatically send a message (called by idle monitor)."""
+ self._input.setPlainText(text)
+ self._handle_send()
+
+ # ── history ────────────────────────────────────────────────────────────────
+ def _refresh_history(self):
+ self._history = _load_history()
+ self._hist_list.clear()
+ for s in reversed(self._history[-20:]):
+ n = len(s.get("messages", []))
+ item = QListWidgetItem(f" {s.get('title','未命名')} ({n} 条)")
+ item.setData(Qt.UserRole, s)
+ self._hist_list.addItem(item)
+
+ def _restore_selected(self, item=None):
+ item = item or self._hist_list.currentItem()
+ if not item:
+ return
+ s = item.data(Qt.UserRole)
+ if s:
+ self._session = s.copy()
+ self._messages = s.get("messages", []).copy()
+ self._rebuild_messages()
+ self._switch_tab(0)
+ self._update_token_usage()
+ search_text = self._search_input.text().strip()
+ if search_text:
+ QTimer.singleShot(50, lambda: self._search_current_chat(search_text))
+
+ def _delete_selected(self):
+ item = self._hist_list.currentItem()
+ if not item:
+ return
+ s = item.data(Qt.UserRole)
+ if s:
+ self._history = [h for h in self._history if h.get("id") != s.get("id")]
+ _save_history(self._history)
+ self._refresh_history()
+
+ def _rebuild_messages(self):
+ while self._msg_layout.count() > 1:
+ it = self._msg_layout.takeAt(0)
+ if it.widget():
+ it.widget().deleteLater()
+ for i, m in enumerate(self._messages):
+ rewrite_cb = (lambda idx=i: self._rewrite_message(idx)) if m["role"] == "user" else None
+ self._add_msg_row(
+ m["role"],
+ m["content"],
+ created_at=m.get("created_at"),
+ on_delete=lambda idx=i: self._delete_message(idx),
+ on_rewrite=rewrite_cb
+ )
+ self._update_token_usage()
+
+ def _update_token_usage(self):
+ in_chars = sum(len(m.get("content", "")) for m in self._messages if m.get("role") == "user")
+ out_chars = sum(len(m.get("content", "")) for m in self._messages if m.get("role") == "assistant")
+ if getattr(self, "_is_streaming", False) and getattr(self, "_streaming_text", ""):
+ out_chars += len(self._streaming_text)
+
+ in_tokens = int(in_chars / 2.5)
+ out_tokens = int(out_chars / 2.5)
+
+ if in_tokens == 0 and out_tokens == 0:
+ self._token_lbl.setText("")
+ else:
+ self._token_lbl.setText(f"| 会话上下文消耗: 入 {in_tokens} 出 {out_tokens} tokens")
+
+ # ── SOP ────────────────────────────────────────────────────────────────────
+ def _refresh_sop(self):
+ self._sop_list.clear()
+ file_icon = _svg_icon("sop_file_item", _SVG_FILE, C["muted"])
+ for path in sorted(glob.glob(os.path.join(os.path.dirname(os.path.dirname(__file__)), "memory", "*.md"))):
+ name = os.path.basename(path)
+ size = os.path.getsize(path)
+ it = QListWidgetItem(name)
+ it.setIcon(file_icon)
+ it.setData(Qt.UserRole, path)
+ it.setToolTip(f"{size:,} 字节")
+ self._sop_list.addItem(it)
+
+ def _load_sop(self, item):
+ if not item:
+ return
+ path = item.data(Qt.UserRole)
+ try:
+ with open(path, "r", encoding="utf-8") as f:
+ self._sop_viewer.setHtml(_md_to_html(f.read()))
+ except Exception as e:
+ self._sop_viewer.setPlainText(f"读取失败: {e}")
+
+ # ── settings actions ───────────────────────────────────────────────────────
+ def _model_name(self) -> str:
+ if self.agent.llmclient is None:
+ return "未配置"
+ try:
+ return self.agent.get_llm_name()
+ except Exception:
+ return "未知"
+
+ def _add_system_notice(self, text: str):
+ """Insert a small centered notice label (not tracked as a message)."""
+ lbl = QLabel(text)
+ lbl.setWordWrap(True)
+ lbl.setAlignment(Qt.AlignCenter)
+ lbl.setStyleSheet(
+ "QLabel { background: transparent; color: #71717a;"
+ " border: none; padding: 6px 20px; font-size: 12px; }"
+ )
+ self._msg_layout.insertWidget(self._msg_layout.count() - 1, lbl)
+ self._scroll_bottom()
+
+ def _do_stop(self):
+ self.agent.abort()
+ self._poll_timer.stop()
+ self._set_send_mode()
+ self._streaming_badge.hide()
+ if self._streaming_row:
+ self._streaming_row.set_text(self._streaming_text or "(已停止)")
+ self._streaming_row.set_finished(True)
+ self._streaming_row = None
+ self._update_token_usage()
+
+ def _do_reset_prompt(self):
+ if self.agent.llmclient and hasattr(self.agent.llmclient, 'last_tools'):
+ self.agent.llmclient.last_tools = ""
+
+ def _auto_save(self):
+ if not self._messages:
+ return
+ if self._session.get("title") == "新对话":
+ first_user = next(
+ (m["content"] for m in self._messages if m["role"] == "user"), ""
+ )
+ if first_user:
+ self._session["title"] = first_user[:30].replace("\n", " ")
+ self._do_save()
+
+ def _do_save(self):
+ if not self._messages:
+ return
+ self._session["messages"] = self._messages.copy()
+ self._session["updatedAt"] = datetime.now().isoformat()
+ self._history = _load_history()
+ for i, s in enumerate(self._history):
+ if s.get("id") == self._session["id"]:
+ self._history[i] = self._session.copy()
+ break
+ else:
+ self._history.append(self._session.copy())
+ _save_history(self._history)
+
+ def _do_clear(self):
+ self._messages.clear()
+ self._session = {"id": _make_session_id(), "title": "新对话", "messages": []}
+ self._rebuild_messages()
+ self._switch_tab(0)
+ self._update_token_usage()
+
+ def _new_session(self):
+ if self._messages:
+ self._do_save()
+ self._do_clear()
+
+ def _do_toggle_auto(self):
+ self.autonomous_enabled = not self.autonomous_enabled
+ self._auto_btn.setChecked(self.autonomous_enabled)
+ lbl = "暂停自主行动" if self.autonomous_enabled else "开启自主行动 (idle > 30 min 自动触发)"
+ self._auto_btn.setText(lbl)
+
+ def _do_trigger_auto(self):
+ self.inject_message(
+ "[AUTO]🤖 用户触发了自主行动,请阅读自动化sop,选择并执行一项有价值的任务。"
+ )
+
+ # ── helpers ────────────────────────────────────────────────────────────────
+ @staticmethod
+ def _small_btn_style(color: str) -> str:
+ return (
+ f"QPushButton {{ background: {color}; color: white; border: none;"
+ f" border-radius: 7px; padding: 4px 12px; font-size: 12px; font-weight: 600; }}"
+ f"QPushButton:hover {{ opacity: 0.85; }}"
+ )
+
+
+# ══════════════════════════════════════════════════════════════════════
+# Entry Point
+# ══════════════════════════════════════════════════════════════════════
+
+def main():
+ # High-DPI support
+ QApplication.setHighDpiScaleFactorRoundingPolicy(
+ Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
+ )
+ app = QApplication(sys.argv)
+ app.setQuitOnLastWindowClosed(False)
+ app.setApplicationName("GenericAgent")
+
+ # Font
+ font = QFont()
+ # Keep English glyphs in Arial; Chinese falls back to Microsoft YaHei.
+ try:
+ font.setFamilies(["Arial", "Microsoft YaHei"])
+ except Exception:
+ font.setFamily("Microsoft YaHei")
+ font.setPointSize(10)
+ app.setFont(font)
+
+ # ── Agent initialisation ──────────────────────────────
+ agent = GeneraticAgent()
+ if agent.llmclient is None:
+ QMessageBox.critical(
+ None,
+ "未配置 LLM",
+ "未在 mykey.py 中发现任何可用的 LLM 接口配置,\n程序将在无 LLM 模式下运行。",
+ )
+ else:
+ threading.Thread(target=agent.run, daemon=True).start()
+
+ # ── Windows ───────────────────────────────────────────
+ panel = ChatPanel(agent)
+ button = FloatingButton(panel)
+ button.show()
+
+ # Position panel next to button and show it on first launch
+ button._position_panel()
+ panel.show()
+
+ scr = QApplication.primaryScreen().availableGeometry()
+ print(f"[GenericAgent] 启动成功")
+ print(f" 屏幕分辨率: {scr.width()}x{scr.height()}")
+ print(f" 悬浮按钮: ({button.x()}, {button.y()})")
+ print(f" 聊天面板: ({panel.x()}, {panel.y()})")
+ print(f" 关闭面板后可点击右下角发光按钮重新打开")
+
+ # ── Idle monitor (autonomous mode) ────────────────────
+ _last_trigger = 0.0
+
+ def idle_check():
+ nonlocal _last_trigger
+ if not panel.autonomous_enabled:
+ return
+ now = time.time()
+ if now - _last_trigger < AUTO_COOLDOWN:
+ return
+ idle = now - panel.last_reply_time
+ if idle > AUTO_IDLE_THRESHOLD:
+ _last_trigger = now
+ panel.inject_message(
+ "[AUTO]🤖 用户已经离开超过30分钟,作为自主智能体,请阅读自动化sop,执行自动任务。"
+ )
+
+ idle_timer = QTimer()
+ idle_timer.timeout.connect(idle_check)
+ idle_timer.start(5000) # check every 5 s
+
+ sys.exit(app.exec())
+
+
+if __name__ == "__main__":
+ main()
diff --git a/frontends/review_cmd.py b/frontends/review_cmd.py
new file mode 100644
index 000000000..b0b982591
--- /dev/null
+++ b/frontends/review_cmd.py
@@ -0,0 +1,81 @@
+"""`/review` 命令:in-session adversarial code reviewer。
+
+用户输入整段作为 user_request 注入 inline prompt;主 agent 在当前 session 内按 prompt
+协议自取审阅范围(用户点名的文件 / git diff)并 echo 报告,不开 subagent、不写落盘文件。
+
+prompt 与 SOP 仅来自 `memory/review_sop/`,作为独立公共入口,不读取其他工作流的私有 prompt。
+"""
+from __future__ import annotations
+import os
+from typing import Optional
+
+CODE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+_PROMPT_DIR = 'review_sop'
+_INLINE_PROMPT_ZH = 'review_inline_prompt.txt'
+_INLINE_PROMPT_EN = 'review_inline_prompt.en.txt'
+_STUB_FALLBACK = (
+ '[/review in-session] (⚠️ prompt 文件缺失: {fpath} → {err})\n\n'
+ '# 本轮用户请求\n{user_request}\n\n'
+ '请按 memory/code_review_principles.md 评审,直接 echo 报告到对话。\n'
+ '不要写 review.md,不要打 [ROUND END]。'
+)
+
+def _render_prompt(user_request: str) -> str:
+ """加载 /review inline prompt 并注入 user_request + ga_root。"""
+ lang = os.environ.get('GA_LANG', '').strip().lower()
+ fname = _INLINE_PROMPT_EN if lang == 'en' else _INLINE_PROMPT_ZH
+ fpath = os.path.join(CODE_ROOT, 'memory', _PROMPT_DIR, fname)
+ ga_root = CODE_ROOT.replace('\\', '/')
+ try:
+ with open(fpath, 'r', encoding='utf-8') as f:
+ return f.read().format(user_request=user_request, ga_root=ga_root)
+ except Exception as e:
+ return _STUB_FALLBACK.format(fpath=fpath, err=e, user_request=user_request)
+
+def _help_text() -> str:
+ return (
+ '**/review 用法**: in-session adversarial code reviewer\n\n'
+ '`/review ` # 默认审本次 uncommitted 改动(主 agent 跑 git diff)\n'
+ '`/review <自然语言请求> ` # 主 agent 按你描述的范围去审\n\n'
+ '例:\n'
+ ' `/review`\n'
+ ' `/review 我刚改了 review_cmd.py 和 tuiapp_v2.py,关注 prompt 注入`\n'
+ ' `/review 审 frontends 目录下所有改过的文件`\n\n'
+ '产出:直接对话 markdown(不写文件、不开 subagent)。\n'
+ '协议: `memory/review_sop/review_inline_prompt.txt` + `memory/code_review_principles.md`'
+ )
+
+_DEFAULT_REQUEST_ZH = '(无具体请求 — 默认审本次 uncommitted 改动:用 code_run 跑 `git diff --stat HEAD` 与 `git diff HEAD`)'
+_DEFAULT_REQUEST_EN = '(no specific request — default to uncommitted diff: run `git diff --stat HEAD` and `git diff HEAD`)'
+_HEADER_ZH = '> 🔍 /review (in-session) → 主 agent 当场审,直接 echo 报告\n\n'
+_HEADER_EN = '> 🔍 /review (in-session) → main agent reviews here, echoes the report inline\n\n'
+
+def handle(agent, body: str, display_queue) -> Optional[str]:
+ """body 是已剥离 `/review` 前缀的纯参数文本(由 install 剥离)。
+ help → 推 done;否则注入 user_request 到 inline prompt return 给主 agent。
+ 不发任何 'done' message(否则前端 `if 'done': break + finally:agent.abort` 会干掉主 agent)。
+ """
+ if body in ('help', '?', '-h', '--help'):
+ display_queue.put({'done': _help_text(), 'source': 'system'})
+ return None
+ en = os.environ.get('GA_LANG', '').strip().lower() == 'en'
+ user_request = body or (_DEFAULT_REQUEST_EN if en else _DEFAULT_REQUEST_ZH)
+ header = _HEADER_EN if en else _HEADER_ZH
+ return header + _render_prompt(user_request)
+
+def install(cls):
+ """`/review` 一律接管,前缀剥离在此完成,handle 只接 body(职责单一)。"""
+ orig = cls._handle_slash_cmd
+ if getattr(orig, '_review_patched', False): return
+ def patched(self, raw_query, display_queue):
+ s = (raw_query or '').strip()
+ if s == '/review':
+ body = ''
+ elif s.startswith('/review ') or s.startswith('/review\t'):
+ body = s[len('/review'):].strip()
+ else:
+ return orig(self, raw_query, display_queue)
+ r = handle(self, body, display_queue)
+ return None if r is None else r
+ patched._review_patched = True
+ cls._handle_slash_cmd = patched
diff --git a/frontends/session_names.py b/frontends/session_names.py
new file mode 100644
index 000000000..adc1734eb
--- /dev/null
+++ b/frontends/session_names.py
@@ -0,0 +1,105 @@
+"""Persistent display names for `/continue`-able sessions.
+
+JSON sidecar at `temp/model_responses/session_names.json` maps log-file
+basename → user name. Touched only by `/rename` and `/continue `.
+"""
+import glob, json, os, re, threading
+
+_LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+ 'temp', 'model_responses')
+_REG_PATH = os.path.join(_LOG_DIR, 'session_names.json')
+_LOG_RE = re.compile(r'^model_responses_(\d+)\.txt$')
+_lock = threading.Lock()
+
+
+def _load() -> dict:
+ try:
+ with open(_REG_PATH, encoding='utf-8') as f:
+ d = json.load(f)
+ return d if isinstance(d, dict) else {}
+ except Exception:
+ return {}
+
+
+def _save(d: dict) -> None:
+ os.makedirs(_LOG_DIR, exist_ok=True)
+ tmp = _REG_PATH + '.tmp'
+ with open(tmp, 'w', encoding='utf-8') as f:
+ json.dump(d, f, ensure_ascii=False, indent=2)
+ os.replace(tmp, _REG_PATH)
+
+
+def _resolve_basename(basename: str):
+ # Registered file may be cleared by `continue_cmd._snapshot_current_log`
+ # on /new or /continue; fall back to the newest non-empty snapshot of the
+ # same PID so a mid-session rename survives the rotation.
+ p = os.path.join(_LOG_DIR, basename)
+ if os.path.isfile(p) and os.path.getsize(p) > 0:
+ return p
+ m = _LOG_RE.match(basename)
+ if m:
+ snaps = glob.glob(os.path.join(_LOG_DIR, f'model_responses_snapshot_{m.group(1)}_*.txt'))
+ snaps.sort(key=os.path.getmtime, reverse=True)
+ for s in snaps:
+ if os.path.getsize(s) > 0:
+ return s
+ return None
+
+
+def set_name(log_path: str, name: str) -> None:
+ """Persist `name` for `log_path`. Empty name removes the entry."""
+ key = os.path.basename(log_path)
+ with _lock:
+ d = _load()
+ if name: d[key] = name
+ else: d.pop(key, None)
+ _save(d)
+
+
+def migrate(old_path: str, new_path: str) -> None:
+ """Move the entry from old basename to new basename after /continue."""
+ if old_path == new_path: return
+ old_key, new_key = os.path.basename(old_path), os.path.basename(new_path)
+ with _lock:
+ d = _load()
+ if old_key in d:
+ d[new_key] = d.pop(old_key)
+ _save(d)
+
+
+def name_for(log_path: str) -> str:
+ return _load().get(os.path.basename(log_path), '')
+
+
+def has_name(name: str, exclude_basename: str = None) -> bool:
+ """True when any other entry already owns `name` (case-insensitive)."""
+ target = (name or '').strip().lower()
+ if not target: return False
+ return any(v.lower() == target for k, v in _load().items() if k != exclude_basename)
+
+
+def gc() -> int:
+ """Drop entries whose log file is gone or empty. Returns count removed."""
+ with _lock:
+ d = _load()
+ bad = [k for k in d if _resolve_basename(k) is None]
+ for k in bad: d.pop(k)
+ if bad: _save(d)
+ return len(bad)
+
+
+def path_for(name: str, exclude_basename: str = None):
+ """Resolve `name` → newest resolvable log path. Exact-match then unique-prefix."""
+ target = (name or '').strip().lower()
+ if not target: return None
+ d = _load()
+ matches = [(k, v) for k, v in d.items() if v.lower() == target]
+ if not matches:
+ matches = [(k, v) for k, v in d.items() if v.lower().startswith(target)]
+ if len(matches) > 1: matches = []
+ if exclude_basename is not None:
+ matches = [m for m in matches if m[0] != exclude_basename]
+ resolved = [(p, k) for p, k in ((_resolve_basename(k), k) for k, _ in matches) if p]
+ if not resolved: return None
+ resolved.sort(key=lambda pk: os.path.getmtime(pk[0]), reverse=True)
+ return resolved[0][0]
diff --git a/frontends/skins/boy/pet.png b/frontends/skins/boy/pet.png
new file mode 100644
index 000000000..506622db7
Binary files /dev/null and b/frontends/skins/boy/pet.png differ
diff --git a/frontends/skins/boy/skin.json b/frontends/skins/boy/skin.json
new file mode 100644
index 000000000..9a116cb44
--- /dev/null
+++ b/frontends/skins/boy/skin.json
@@ -0,0 +1,63 @@
+{
+ "name": "Boy",
+ "version": "1.0.0",
+ "author": "pzuh",
+ "source": "https://pzuh.itch.io/temple-run-game-sprites",
+ "description": "Boy 角色皮肤",
+ "style": "pixel",
+ "format": "sprite",
+ "size": {
+ "width": 80,
+ "height": 122
+ },
+ "animations": {
+ "idle": {
+ "file": "skin.png",
+ "loop": true,
+ "sprite": {
+ "frameWidth": 64,
+ "frameHeight": 98,
+ "frameCount": 10,
+ "columns": 40,
+ "fps": 6,
+ "startFrame": 0
+ }
+ },
+ "walk": {
+ "file": "skin.png",
+ "loop": true,
+ "sprite": {
+ "frameWidth": 64,
+ "frameHeight": 98,
+ "frameCount": 10,
+ "columns": 40,
+ "fps": 3,
+ "startFrame": 20
+ }
+ },
+ "run": {
+ "file": "skin.png",
+ "loop": true,
+ "sprite": {
+ "frameWidth": 64,
+ "frameHeight": 98,
+ "frameCount": 10,
+ "columns": 40,
+ "fps": 10,
+ "startFrame": 20
+ }
+ },
+ "sprint": {
+ "file": "skin.png",
+ "loop": true,
+ "sprite": {
+ "frameWidth": 64,
+ "frameHeight": 98,
+ "frameCount": 10,
+ "columns": 40,
+ "fps": 24,
+ "startFrame": 20
+ }
+ }
+ }
+}
diff --git a/frontends/skins/boy/skin.png b/frontends/skins/boy/skin.png
new file mode 100644
index 000000000..02413f374
Binary files /dev/null and b/frontends/skins/boy/skin.png differ
diff --git a/frontends/skins/dinosaur/pet.png b/frontends/skins/dinosaur/pet.png
new file mode 100644
index 000000000..ee2f5b0ea
Binary files /dev/null and b/frontends/skins/dinosaur/pet.png differ
diff --git a/frontends/skins/dinosaur/skin.json b/frontends/skins/dinosaur/skin.json
new file mode 100644
index 000000000..c961b0eac
--- /dev/null
+++ b/frontends/skins/dinosaur/skin.json
@@ -0,0 +1,60 @@
+{
+ "name": "Dinosaur",
+ "version": "1.0.0",
+ "author": "voidcord54",
+ "source": "https://voidcord54.itch.io/",
+ "description": "像素风小恐龙 Dinosaur",
+ "style": "pixel",
+ "format": "sprite",
+ "size": { "width": 128, "height": 128 },
+ "animations": {
+ "idle": {
+ "file": "skin.png",
+ "loop": true,
+ "sprite": {
+ "frameWidth": 128,
+ "frameHeight": 128,
+ "frameCount": 2,
+ "columns": 5,
+ "fps": 6,
+ "startFrame": 0
+ }
+ },
+ "walk": {
+ "file": "skin.png",
+ "loop": true,
+ "sprite": {
+ "frameWidth": 128,
+ "frameHeight": 128,
+ "frameCount": 2,
+ "columns": 5,
+ "fps": 4,
+ "startFrame": 2
+ }
+ },
+ "run": {
+ "file": "skin.png",
+ "loop": true,
+ "sprite": {
+ "frameWidth": 128,
+ "frameHeight": 128,
+ "frameCount": 2,
+ "columns": 5,
+ "fps": 8,
+ "startFrame": 2
+ }
+ },
+ "sprint": {
+ "file": "skin.png",
+ "loop": true,
+ "sprite": {
+ "frameWidth": 128,
+ "frameHeight": 128,
+ "frameCount": 2,
+ "columns": 5,
+ "fps": 16,
+ "startFrame": 2
+ }
+ }
+ }
+}
diff --git a/frontends/skins/dinosaur/skin.png b/frontends/skins/dinosaur/skin.png
new file mode 100644
index 000000000..60a596a6f
Binary files /dev/null and b/frontends/skins/dinosaur/skin.png differ
diff --git a/frontends/skins/doux/pet.png b/frontends/skins/doux/pet.png
new file mode 100644
index 000000000..b4b17cc05
Binary files /dev/null and b/frontends/skins/doux/pet.png differ
diff --git a/frontends/skins/doux/skin.json b/frontends/skins/doux/skin.json
new file mode 100644
index 000000000..f084745e6
--- /dev/null
+++ b/frontends/skins/doux/skin.json
@@ -0,0 +1,61 @@
+{
+ "name": "Doux",
+ "version": "1.0.0",
+ "author": "arks",
+ "source": "https://arks.itch.io/dino-characters",
+ "license": "CC0",
+ "description": "像素风小恐龙 Doux",
+ "style": "pixel",
+ "format": "sprite",
+ "size": { "width": 128, "height": 128 },
+ "animations": {
+ "idle": {
+ "file": "skin.png",
+ "loop": true,
+ "sprite": {
+ "frameWidth": 24,
+ "frameHeight": 24,
+ "frameCount": 4,
+ "columns": 24,
+ "fps": 6,
+ "startFrame": 0
+ }
+ },
+ "walk": {
+ "file": "skin.png",
+ "loop": true,
+ "sprite": {
+ "frameWidth": 24,
+ "frameHeight": 24,
+ "frameCount": 6,
+ "columns": 24,
+ "fps": 6,
+ "startFrame": 5
+ }
+ },
+ "run": {
+ "file": "skin.png",
+ "loop": true,
+ "sprite": {
+ "frameWidth": 24,
+ "frameHeight": 24,
+ "frameCount": 8,
+ "columns": 24,
+ "fps": 16,
+ "startFrame": 6
+ }
+ },
+ "sprint": {
+ "file": "skin.png",
+ "loop": true,
+ "sprite": {
+ "frameWidth": 24,
+ "frameHeight": 24,
+ "frameCount": 6,
+ "columns": 24,
+ "fps": 16,
+ "startFrame": 17
+ }
+ }
+ }
+}
diff --git a/frontends/skins/doux/skin.png b/frontends/skins/doux/skin.png
new file mode 100644
index 000000000..8fb87343a
Binary files /dev/null and b/frontends/skins/doux/skin.png differ
diff --git a/frontends/skins/glube/idle.png b/frontends/skins/glube/idle.png
new file mode 100644
index 000000000..b2905cbaa
Binary files /dev/null and b/frontends/skins/glube/idle.png differ
diff --git a/frontends/skins/glube/pet.png b/frontends/skins/glube/pet.png
new file mode 100644
index 000000000..28fe80117
Binary files /dev/null and b/frontends/skins/glube/pet.png differ
diff --git a/frontends/skins/glube/run.png b/frontends/skins/glube/run.png
new file mode 100644
index 000000000..636ab8d3e
Binary files /dev/null and b/frontends/skins/glube/run.png differ
diff --git a/frontends/skins/glube/skin.json b/frontends/skins/glube/skin.json
new file mode 100644
index 000000000..2815e2627
--- /dev/null
+++ b/frontends/skins/glube/skin.json
@@ -0,0 +1,60 @@
+{
+ "name": "Glube",
+ "version": "1.0.0",
+ "author": "SketchesWithKevin",
+ "source": "https://sketcheswithkevin.itch.io/glube-platformer",
+ "description": "像素风小怪兽 Glube",
+ "style": "pixel",
+ "format": "sprite",
+ "size": { "width": 65, "height": 38 },
+ "animations": {
+ "idle": {
+ "file": "idle.png",
+ "loop": true,
+ "sprite": {
+ "frameWidth": 44,
+ "frameHeight": 31,
+ "frameCount": 6,
+ "columns": 6,
+ "fps": 6,
+ "startFrame": 0
+ }
+ },
+ "walk": {
+ "file": "walk.png",
+ "loop": true,
+ "sprite": {
+ "frameWidth": 65,
+ "frameHeight": 32,
+ "frameCount": 8,
+ "columns": 8,
+ "fps": 6,
+ "startFrame": 0
+ }
+ },
+ "run": {
+ "file": "run.png",
+ "loop": true,
+ "sprite": {
+ "frameWidth": 65,
+ "frameHeight": 32,
+ "frameCount": 8,
+ "columns": 8,
+ "fps": 12,
+ "startFrame": 0
+ }
+ },
+ "sprint": {
+ "file": "run.png",
+ "loop": true,
+ "sprite": {
+ "frameWidth": 65,
+ "frameHeight": 32,
+ "frameCount": 8,
+ "columns": 8,
+ "fps": 24,
+ "startFrame": 0
+ }
+ }
+ }
+}
diff --git a/frontends/skins/glube/walk.png b/frontends/skins/glube/walk.png
new file mode 100644
index 000000000..636ab8d3e
Binary files /dev/null and b/frontends/skins/glube/walk.png differ
diff --git a/frontends/skins/line/License.txt b/frontends/skins/line/License.txt
new file mode 100644
index 000000000..e0ec332b1
--- /dev/null
+++ b/frontends/skins/line/License.txt
@@ -0,0 +1,6 @@
+License is CC0 - https://creativecommons.org/public-domain/cc0/
+
+YOU CAN:
+
+-> You can do whatever you want with this asset, including modifying it for commercial use.
+-> Credit is not required, but is greatly appreciated!
\ No newline at end of file
diff --git a/frontends/skins/line/pet.png b/frontends/skins/line/pet.png
new file mode 100644
index 000000000..442116788
Binary files /dev/null and b/frontends/skins/line/pet.png differ
diff --git a/frontends/skins/line/skin.json b/frontends/skins/line/skin.json
new file mode 100644
index 000000000..09d1fdc06
--- /dev/null
+++ b/frontends/skins/line/skin.json
@@ -0,0 +1,60 @@
+{
+ "name": "Line",
+ "version": "1.0.0",
+ "author": "itch.io",
+ "source": "https://itch.io",
+ "description": "Line 角色皮肤",
+ "style": "pixel",
+ "format": "sprite",
+ "size": { "width": 128, "height": 128 },
+ "animations": {
+ "idle": {
+ "file": "skin.png",
+ "loop": true,
+ "sprite": {
+ "frameWidth": 156,
+ "frameHeight": 185,
+ "frameCount": 4,
+ "columns": 28,
+ "fps": 6,
+ "startFrame": 0
+ }
+ },
+ "walk": {
+ "file": "skin.png",
+ "loop": true,
+ "sprite": {
+ "frameWidth": 156,
+ "frameHeight": 185,
+ "frameCount": 8,
+ "columns": 28,
+ "fps": 6,
+ "startFrame": 4
+ }
+ },
+ "run": {
+ "file": "skin.png",
+ "loop": true,
+ "sprite": {
+ "frameWidth": 156,
+ "frameHeight": 185,
+ "frameCount": 8,
+ "columns": 28,
+ "fps": 10,
+ "startFrame": 12
+ }
+ },
+ "sprint": {
+ "file": "skin.png",
+ "loop": true,
+ "sprite": {
+ "frameWidth": 156,
+ "frameHeight": 185,
+ "frameCount": 8,
+ "columns": 28,
+ "fps": 24,
+ "startFrame": 12
+ }
+ }
+ }
+}
diff --git a/frontends/skins/line/skin.png b/frontends/skins/line/skin.png
new file mode 100644
index 000000000..41b102e80
Binary files /dev/null and b/frontends/skins/line/skin.png differ
diff --git a/frontends/skins/mort/pet.png b/frontends/skins/mort/pet.png
new file mode 100644
index 000000000..1b4dcbeff
Binary files /dev/null and b/frontends/skins/mort/pet.png differ
diff --git a/frontends/skins/mort/skin.json b/frontends/skins/mort/skin.json
new file mode 100644
index 000000000..f3a031c9f
--- /dev/null
+++ b/frontends/skins/mort/skin.json
@@ -0,0 +1,61 @@
+{
+ "name": "Mort",
+ "version": "1.0.0",
+ "author": "arks",
+ "source": "https://arks.itch.io/dino-characters",
+ "license": "CC0",
+ "description": "像素风小恐龙 Mort",
+ "style": "pixel",
+ "format": "sprite",
+ "size": { "width": 128, "height": 128 },
+ "animations": {
+ "idle": {
+ "file": "skin.png",
+ "loop": true,
+ "sprite": {
+ "frameWidth": 24,
+ "frameHeight": 24,
+ "frameCount": 4,
+ "columns": 24,
+ "fps": 6,
+ "startFrame": 0
+ }
+ },
+ "walk": {
+ "file": "skin.png",
+ "loop": true,
+ "sprite": {
+ "frameWidth": 24,
+ "frameHeight": 24,
+ "frameCount": 6,
+ "columns": 24,
+ "fps": 6,
+ "startFrame": 5
+ }
+ },
+ "run": {
+ "file": "skin.png",
+ "loop": true,
+ "sprite": {
+ "frameWidth": 24,
+ "frameHeight": 24,
+ "frameCount": 8,
+ "columns": 24,
+ "fps": 16,
+ "startFrame": 6
+ }
+ },
+ "sprint": {
+ "file": "skin.png",
+ "loop": true,
+ "sprite": {
+ "frameWidth": 24,
+ "frameHeight": 24,
+ "frameCount": 6,
+ "columns": 24,
+ "fps": 16,
+ "startFrame": 17
+ }
+ }
+ }
+}
diff --git a/frontends/skins/mort/skin.png b/frontends/skins/mort/skin.png
new file mode 100644
index 000000000..908992378
Binary files /dev/null and b/frontends/skins/mort/skin.png differ
diff --git a/frontends/skins/tard/pet.png b/frontends/skins/tard/pet.png
new file mode 100644
index 000000000..12c9aecca
Binary files /dev/null and b/frontends/skins/tard/pet.png differ
diff --git a/frontends/skins/tard/skin.json b/frontends/skins/tard/skin.json
new file mode 100644
index 000000000..92c630421
--- /dev/null
+++ b/frontends/skins/tard/skin.json
@@ -0,0 +1,61 @@
+{
+ "name": "Tard",
+ "version": "1.0.0",
+ "author": "arks",
+ "source": "https://arks.itch.io/dino-characters",
+ "license": "CC0",
+ "description": "像素风小恐龙 Tard",
+ "style": "pixel",
+ "format": "sprite",
+ "size": { "width": 128, "height": 128 },
+ "animations": {
+ "idle": {
+ "file": "skin.png",
+ "loop": true,
+ "sprite": {
+ "frameWidth": 24,
+ "frameHeight": 24,
+ "frameCount": 4,
+ "columns": 24,
+ "fps": 6,
+ "startFrame": 0
+ }
+ },
+ "walk": {
+ "file": "skin.png",
+ "loop": true,
+ "sprite": {
+ "frameWidth": 24,
+ "frameHeight": 24,
+ "frameCount": 6,
+ "columns": 24,
+ "fps": 6,
+ "startFrame": 5
+ }
+ },
+ "run": {
+ "file": "skin.png",
+ "loop": true,
+ "sprite": {
+ "frameWidth": 24,
+ "frameHeight": 24,
+ "frameCount": 8,
+ "columns": 24,
+ "fps": 16,
+ "startFrame": 6
+ }
+ },
+ "sprint": {
+ "file": "skin.png",
+ "loop": true,
+ "sprite": {
+ "frameWidth": 24,
+ "frameHeight": 24,
+ "frameCount": 6,
+ "columns": 24,
+ "fps": 16,
+ "startFrame": 17
+ }
+ }
+ }
+}
diff --git a/frontends/skins/tard/skin.png b/frontends/skins/tard/skin.png
new file mode 100644
index 000000000..c99f59ecd
Binary files /dev/null and b/frontends/skins/tard/skin.png differ
diff --git a/frontends/skins/vita/pet.png b/frontends/skins/vita/pet.png
new file mode 100644
index 000000000..005dff51b
Binary files /dev/null and b/frontends/skins/vita/pet.png differ
diff --git a/frontends/skins/vita/skin.json b/frontends/skins/vita/skin.json
new file mode 100644
index 000000000..0978d205e
--- /dev/null
+++ b/frontends/skins/vita/skin.json
@@ -0,0 +1,61 @@
+{
+ "name": "Vita",
+ "version": "1.0.0",
+ "author": "arks",
+ "source": "https://arks.itch.io/dino-characters",
+ "license": "CC0",
+ "description": "像素风小恐龙 Vita",
+ "style": "pixel",
+ "format": "sprite",
+ "size": { "width": 128, "height": 128 },
+ "animations": {
+ "idle": {
+ "file": "skin.png",
+ "loop": true,
+ "sprite": {
+ "frameWidth": 24,
+ "frameHeight": 24,
+ "frameCount": 4,
+ "columns": 24,
+ "fps": 6,
+ "startFrame": 0
+ }
+ },
+ "walk": {
+ "file": "skin.png",
+ "loop": true,
+ "sprite": {
+ "frameWidth": 24,
+ "frameHeight": 24,
+ "frameCount": 6,
+ "columns": 24,
+ "fps": 6,
+ "startFrame": 5
+ }
+ },
+ "run": {
+ "file": "skin.png",
+ "loop": true,
+ "sprite": {
+ "frameWidth": 24,
+ "frameHeight": 24,
+ "frameCount": 8,
+ "columns": 24,
+ "fps": 16,
+ "startFrame": 6
+ }
+ },
+ "sprint": {
+ "file": "skin.png",
+ "loop": true,
+ "sprite": {
+ "frameWidth": 24,
+ "frameHeight": 24,
+ "frameCount": 6,
+ "columns": 24,
+ "fps": 16,
+ "startFrame": 17
+ }
+ }
+ }
+}
diff --git a/frontends/skins/vita/skin.png b/frontends/skins/vita/skin.png
new file mode 100644
index 000000000..1ee674433
Binary files /dev/null and b/frontends/skins/vita/skin.png differ
diff --git a/frontends/slash_cmds.py b/frontends/slash_cmds.py
new file mode 100644
index 000000000..6c3c0a66b
--- /dev/null
+++ b/frontends/slash_cmds.py
@@ -0,0 +1,614 @@
+"""Slash-command prompt builders + scheduler-task discovery.
+
+Goal of this module: keep TUI files (tuiapp_v2.py / tui_v3.py) thin. They only
+need to forward `/update`, `/autorun`, `/morphling`, `/goal`, `/hive`
+to the corresponding `build_*_prompt(args)` here, and ask
+`list_scheduler_tasks()` / `start_scheduler_task()` for the `/scheduler` picker.
+
+Design (per user 2026-05-27):
+- All non-/scheduler commands are *prompt injection*: we craft a system-style
+ request and feed it to the main agent as a normal user message (the TUI is
+ free to display the raw `/cmd ...` as the visible bubble). This keeps the
+ agent in-session, lets it use every tool/SOP it normally would, and means
+ this file owns zero LLM logic.
+- `/scheduler` is the only exception — it touches local FS state directly via
+ `sche_tasks/*.json` and the existing scheduler daemon, no LLM needed.
+- All prompts deliberately *name* the relevant SOP file so the agent re-reads
+ it before acting (per CONSTITUTION rule 2: SOP-first).
+
+This module has zero TUI imports — both frontends can depend on it without
+either depending on the other.
+"""
+from __future__ import annotations
+
+import json
+import os
+import shutil
+import sys
+import subprocess
+import time
+from pathlib import Path
+from typing import Optional
+
+
+_USER_SHELL: tuple[list[str], str] | None = None
+
+COMMIT_SIGNATURE_PROMPT = 'When you create a git commit, append "Co-Authored-By: GenericAgent " as the final line of the commit message.'
+
+def detect_user_shell() -> tuple[list[str], str]:
+ """Return `([executable, ...flags_for_-c], display_name)` for the user's
+ interactive shell. Cached after first call.
+
+ `!cmd` in tui_v2 / tui_v3 invokes this so commands like `ls`, pipes,
+ globs, and shell builtins behave the way the user expects in whatever
+ shell launched the app, instead of hardcoding cmd.exe / /bin/sh.
+
+ Resolution order:
+ 1. `$SHELL` if it points to an existing file (Unix, Git Bash, WSL)
+ 2. Windows only: Git Bash at the canonical install paths
+ 3. `bash` anywhere on PATH (WSL bash, Cygwin, MSYS2, etc.)
+ 4. Windows only: `pwsh` then `powershell.exe` on PATH
+ 5. Unix `/bin/sh` / Windows `%COMSPEC%` (cmd.exe) — last resort
+ """
+ global _USER_SHELL
+ if _USER_SHELL is not None:
+ return _USER_SHELL
+
+ s = os.environ.get("SHELL")
+ if s and os.path.exists(s):
+ name = os.path.basename(s)
+ if name.lower().endswith(".exe"):
+ name = name[:-4]
+ _USER_SHELL = ([s, "-c"], name)
+ return _USER_SHELL
+
+ if sys.platform == "win32":
+ for p in (
+ r"C:\Program Files\Git\bin\bash.exe",
+ r"C:\Program Files (x86)\Git\bin\bash.exe",
+ ):
+ if os.path.exists(p):
+ _USER_SHELL = ([p, "-c"], "bash")
+ return _USER_SHELL
+ bash = shutil.which("bash")
+ if bash:
+ _USER_SHELL = ([bash, "-c"], "bash")
+ return _USER_SHELL
+ for name in ("pwsh", "powershell"):
+ p = shutil.which(name)
+ if p:
+ # -NoProfile keeps each `!cmd` snappy + reproducible.
+ _USER_SHELL = ([p, "-NoProfile", "-Command"], name)
+ return _USER_SHELL
+ cmd = os.environ.get("COMSPEC", "cmd.exe")
+ _USER_SHELL = ([cmd, "/d", "/s", "/c"], "cmd")
+ return _USER_SHELL
+
+ _USER_SHELL = (["/bin/sh", "-c"], "sh")
+ return _USER_SHELL
+
+
+
+# Repo root = parent of frontends/. Avoid hard-coding; both TUIs live next to
+# this file and share the same anchor.
+_ROOT = Path(__file__).resolve().parent.parent
+
+# Language resolution is owned here (not passed in as a formal arg) so every
+# prompt builder stays single-parameter and TUI call sites don't need to know
+# which prompt happens to be bilingual. Source of truth, in order:
+# 1. `GA_LANG` env var (scriptable override; matches tui_v3 convention)
+# 2. tui_v3's persisted settings file (same path as tui_v3.py:_SETTINGS_PATH)
+# 3. system locale (zh* → 'zh', else 'en')
+# When the user switches language inside tui_v3 (set_lang persists), the next
+# call here picks it up automatically -- no formal coupling, just a shared file.
+_SETTINGS_PATH = _ROOT / "temp" / "tui_v3_settings.json"
+
+
+def _current_lang() -> str:
+ env = (os.environ.get("GA_LANG") or "").strip().lower()
+ if env in ("zh", "en"):
+ return env
+ try:
+ with open(_SETTINGS_PATH, "r", encoding="utf-8") as f:
+ saved = (json.load(f) or {}).get("lang")
+ if saved in ("zh", "en"):
+ return saved
+ except Exception:
+ pass
+ for var in ("LC_ALL", "LC_MESSAGES", "LANG"):
+ v = os.environ.get(var, "")
+ if v:
+ return "zh" if v.lower().startswith("zh") else "en"
+ return "en"
+
+
+# ----- prompt builders (pure functions, no I/O) ---------------------------
+# SOP paths are written inline as literal strings in each builder below: a
+# literal is self-documenting and locally readable, and a stale path is a
+# zero-radius failure (the prompt is a hint to an intelligent agent, which
+# re-reads the dir / asks if a SOP moved) — so we deliberately do NOT wrap it
+# in a registry + existence-check machinery.
+
+def _tail(args_text: str, label: str = "额外指示") -> str:
+ """Append user-supplied args after a slash command as a free-form suffix.
+
+ User pattern (2026-05-27): the base prompt is a fixed injection that names
+ the SOP path; anything the user types after `/cmd ` is appended verbatim so
+ they can add per-invocation hints (e.g. `/morphling https://github.com/...`
+ or `/goal 调研 X,预算 50k token`).
+ """
+ extra = (args_text or "").strip()
+ return f"\n\n{label}: {extra}" if extra else ""
+
+
+def build_update_prompt(args_text: str = "") -> str:
+ """Prompt-only /update orchestration; actual git work stays in-agent.
+
+ The TUI owns zero git/LLM logic. This prompt asks the normal agent loop to
+ do a user-friendly preflight (upstream commits + diff) before pulling.
+ Language follows `_current_lang()` so a /language switch in tui_v3 (or a
+ `GA_LANG=...` shell override) automatically flips this prompt too.
+ """
+ if _current_lang() == "en":
+ return (
+ "Update this GenericAgent checkout from the official upstream "
+ "https://github.com/Lsdefine/GenericAgent .\n"
+ "1. Run `git fetch upstream`; identify the current branch and whether there are "
+ "local commits ahead of upstream.\n"
+ "2. Show a concise preview: recent upstream commits not yet local "
+ "(short hash + subject + date) plus a changed-files summary.\n"
+ "3. Align the COMMIT HISTORY to upstream (upstream wins):\n"
+ " - If local commits exist: `git merge upstream/main` into the current branch, "
+ "resolving any conflicts in favor of upstream; keep the local commits (a merge commit is fine).\n"
+ " - Otherwise: `git reset --mixed upstream/main` so the history of local `main` "
+ "matches upstream/main exactly. Do NOT create any new commit.\n"
+ "4. IMPORTANT: also reconcile the WORKING TREE; step 3 alone is not enough. "
+ "`git reset --mixed` and `git merge` only move history and the index. They do not "
+ "rewrite files that already hold uncommitted local edits, so those stale files keep "
+ "shadowing upstream's latest. That is why 'HEAD is aligned but the files are still "
+ "old'. Reconcile per file:\n"
+ " a. `git diff --name-only upstream/main` lists every tracked file whose working-tree "
+ "content differs from upstream.\n"
+ " b. Decide upstream-first per file. A stale leftover, or a local edit that reverts "
+ "an upstream feature to an older form, takes upstream: back it up, then "
+ "`git checkout upstream/main -- `. A genuine local enhancement upstream lacks "
+ "(local config, key-template placeholders, fork-only features) stays, re-applied on "
+ "top of upstream's latest instead of overwriting it.\n"
+ " c. Upstream wins; keep local only where it adds value upstream lacks. Never "
+ "`git add -A`, never blanket whole-branch checkout, never blindly preserve everything.\n"
+ "5. Do not create any git commit. Back up any file before overwriting it.\n"
+ "6. Finish with a short summary: branch HEAD, distance vs upstream, per-file outcome "
+ "(took upstream / kept+adapted local), and where backups went.\n"
+ "\n"
+ "#### Say 'Congratulations! 🎉' to the user\n"
+ "After a successful update, say: \"Congratulations! 🎉 You have successfully "
+ "updated GenericAgent!\"\n"
+ "\n"
+ "#### Ask for a Star ⭐\n"
+ "Then you may ask: \"If you found this helpful, would you like to star the "
+ "GenericAgent repository? It helps the project grow! ⭐\""
+ f"{_tail(args_text, 'Extra instructions')}"
+ )
+ return (
+ "请你更新当前 GenericAgent 仓库,官方上游为 "
+ "https://github.com/Lsdefine/GenericAgent 。\n"
+ "1. 先 `git fetch upstream`;识别当前分支,以及是否存在领先 upstream 的本地 commit。\n"
+ "2. 给出简洁预览:本地尚未包含的 upstream 最近提交(短 hash + 标题 + 日期)及变更文件摘要。\n"
+ "3. 先对齐【提交历史】到上游(优先上游):\n"
+ " - 若存在本地 commit:`git merge upstream/main` 合并进当前分支,冲突一律优先上游,保留本地 commit(可产生 merge commit)。\n"
+ " - 否则:执行 `git reset --mixed upstream/main`,使本地 `main` 的提交历史与 upstream/main 完全对齐。不要创建任何新 commit。\n"
+ "4. 重要:还须核对【工作区文件】,止步于第 3 步并不够。`git reset --mixed` 与 `git merge` "
+ "只移动提交历史与索引,不会重写那些已带未提交本地改动的文件,于是这些过时文件继续覆盖在上游"
+ "最新版之上。这正是“HEAD 已对齐,但文件其实还是旧的”的根因。逐文件核对:\n"
+ " a. `git diff --name-only upstream/main` 列出工作区内容与上游不一致的全部 tracked 文件。\n"
+ " b. 每个文件按优先上游判断。过时残留、或把上游特性改回旧版的本地改动,取上游:先备份,再 "
+ "`git checkout upstream/main -- `。上游没有且仍有效的本地增强(本机配置、密钥模板占位、"
+ "fork 专属功能)保留,并在上游最新版上重新适配,而非整文件覆盖上游。\n"
+ " c. 优先上游;本地仅保留上游缺失且有价值的部分。禁止 `git add -A`、禁止整分支 checkout 覆盖、禁止盲目全保留。\n"
+ "5. 不要进行任何 git commit。覆盖任何文件前先备份。\n"
+ "6. 最后给出简短小结:分支 HEAD、与上游差距、逐文件处理结果(取上游 / 保留并适配本地)、备份位置。\n"
+ "\n"
+ "#### 向用户说 'Congratulations! 🎉'\n"
+ "更新成功后,请对用户说:\"Congratulations! 🎉 你已成功更新 GenericAgent!\"\n"
+ "\n"
+ "#### 邀请用户点 Star ⭐\n"
+ "随后可邀请:\"如果觉得有帮助,要不要给 GenericAgent 仓库点个 Star?这会让项目成长更快!⭐\""
+ f"{_tail(args_text)}"
+ )
+
+
+def build_autorun_prompt(args_text: str = "") -> str:
+ return (
+ "请进入「自主探索 / autonomous 模式」:先读 "
+ "memory/autonomous_operation_sop.md。"
+ "全程自驱,不可逆 / 高风险动作先 ask_user ,"
+ "结案给一份简明回执(做了什么 / 产物在哪 / 下一步)。"
+ f"{_tail(args_text, '任务种子')}"
+ )
+
+
+def build_morphling_prompt(args_text: str = "") -> str:
+ return (
+ "请启用 Morphling 模式吞噬 / 蒸馏外部项目到本仓库:先读 "
+ "memory/morphling_sop.md。"
+ "没有目标先 ask_user 取 GitHub 仓库 / 本地路径 / 能力描述。"
+ f"{_tail(args_text, '目标技能/仓库')}"
+ )
+
+
+def build_goal_prompt(args_text: str = "") -> str:
+ return (
+ "请进入 Goal 模式:先读 memory/goal_mode_sop.md。"
+ "若未给目标,先 ask_user 一次性问清:一句话目标 + condition 约束。"
+ f"{_tail(args_text, '用户目标')}"
+ )
+
+
+def build_hive_prompt(args_text: str = "") -> str:
+ return (
+ "请进入 Goal Hive 模式(多 worker 协作版 goal):先读 "
+ "memory/goal_hive_sop.md。"
+ "集群目标 / worker 配额 / 终止条件未明确时先 ask_user 补齐再启动。"
+ f"{_tail(args_text, '集群目标')}"
+ )
+
+
+def build_conductor_prompt(args_text: str = "") -> str:
+ """`/conductor ` → run `frontends/conductor.py` on the task.
+
+ Upstream `memory/` ships no conductor SOP, so we deliberately keep the
+ prompt short: name the entrypoint and forward the task verbatim. The
+ agent is expected to know how to drive `conductor.py` (or consult a
+ local SOP if one happens to be installed).
+ """
+ args_text = (args_text or "").strip()
+ if args_text:
+ return f"请调用 frontends/conductor.py 执行:{args_text}"
+ return (
+ "请调用 frontends/conductor.py,根据后续指令完成多 subagent 编排。"
+ "若任务描述缺失,先 ask_user 一次性补齐。"
+ )
+
+
+# ----- /scheduler reflect-task discovery + launch -------------------------
+
+def list_reflect_tasks() -> list[dict]:
+ """Return [{name, path, doc}] for every reflect/*.py task script.
+
+ `doc` is the module docstring's first line (best-effort) so the picker can
+ show a one-liner next to each name. Empty list if reflect/ doesn't exist.
+ """
+ out: list[dict] = []
+ refl = _ROOT / "reflect"
+ if not refl.is_dir():
+ return out
+ for p in sorted(refl.glob("*.py")):
+ if p.name.startswith("_"):
+ continue
+ doc = ""
+ try:
+ # Cheap docstring sniff: read first ~40 lines, look for """...""".
+ head = p.read_text(encoding="utf-8", errors="ignore").splitlines()[:40]
+ joined = "\n".join(head)
+ for q in ('"""', "'''"):
+ i = joined.find(q)
+ if i != -1:
+ j = joined.find(q, i + 3)
+ if j != -1:
+ doc = joined[i + 3:j].strip().splitlines()[0].strip()
+ break
+ except Exception:
+ pass
+ out.append({"name": p.stem, "path": str(p), "doc": doc})
+ return out
+
+
+# ----- hub.pyw parity: every launchable service ---------------------------
+
+_HUB_EXCLUDES = {"goal_mode.py", "chatapp_common.py", "tuiapp.py"}
+
+
+def _sniff_doc(p) -> str:
+ """Best-effort first line of a module docstring (cheap ~40-line read)."""
+ try:
+ head = p.read_text(encoding="utf-8", errors="ignore").splitlines()[:40]
+ joined = "\n".join(head)
+ for q in ('"""', "'''"):
+ i = joined.find(q)
+ if i != -1:
+ j = joined.find(q, i + 3)
+ if j != -1:
+ body = joined[i + 3:j].strip()
+ if body:
+ return body.splitlines()[0].strip()
+ except Exception:
+ pass
+ return ""
+
+
+def list_launchable_services() -> list[dict]:
+ """Mirror hub.pyw's discover_services() so `/scheduler` shows the *same*
+ set of launchable services as the GUI launcher.
+
+ Sources (hub.pyw EXCLUDES = goal_mode.py / chatapp_common.py / tuiapp.py):
+ • reflect/*.py (not '_'-prefixed, not excluded)
+ → cmd = [python, agentmain.py, --reflect, reflect/]
+ • frontends/*app*.py (not excluded)
+ → 'stapp' → `python -m streamlit run … --server.headless=true`
+ others → `python frontends/`
+
+ Returns [{name, cmd, doc, kind}] where `name` is the hub-style path
+ ('reflect/foo.py' / 'frontends/bar.py') and doubles as the picker value.
+ """
+ out: list[dict] = []
+ refl = _ROOT / "reflect"
+ if refl.is_dir():
+ for p in sorted(refl.glob("*.py")):
+ if p.name.startswith("_") or p.name in _HUB_EXCLUDES:
+ continue
+ rel = "reflect/" + p.name
+ out.append({
+ "name": rel,
+ "cmd": [sys.executable, "agentmain.py", "--reflect", rel],
+ "doc": _sniff_doc(p),
+ "kind": "reflect",
+ })
+ fe = _ROOT / "frontends"
+ if fe.is_dir():
+ for p in sorted(fe.glob("*.py")):
+ if "app" not in p.name or p.name in _HUB_EXCLUDES:
+ continue
+ rel = "frontends/" + p.name
+ if "stapp" in p.name:
+ cmd = [sys.executable, "-m", "streamlit", "run", rel,
+ "--server.headless=true"]
+ else:
+ cmd = [sys.executable, rel]
+ out.append({"name": rel, "cmd": cmd, "doc": _sniff_doc(p),
+ "kind": "frontend"})
+ return out
+
+
+def start_service(name: str) -> tuple[bool, str]:
+ """Launch a service from list_launchable_services(), detached & window-less
+ (CONSTITUTION rule 14: creationflags at the launch layer only, never via
+ subprocess.Popen monkeypatch).
+
+ `name` accepts the hub-style path ('reflect/foo.py') or a bare reflect stem
+ ('foo') for backward-compat with `/scheduler start `.
+ """
+ svcs = list_launchable_services()
+ svc = next((s for s in svcs if s["name"] == name), None)
+ if svc is None: # bare reflect stem fallback
+ cand = "reflect/" + name + ".py"
+ svc = next((s for s in svcs if s["name"] == cand), None)
+ if svc is None:
+ return False, f"未知服务: {name}"
+ try:
+ flags = 0
+ if os.name == "nt":
+ flags = 0x00000200 | 0x08000000 # NEW_PROCESS_GROUP | NO_WINDOW
+ proc = subprocess.Popen(
+ svc["cmd"],
+ cwd=str(_ROOT),
+ creationflags=flags,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ close_fds=True,
+ )
+ # Poll-and-confirm: if the child dies immediately (bad path, import
+ # error, port-in-use, etc) Popen still returns happily — without this
+ # check the picker would tick "✅ started" while nothing is running,
+ # which is exactly the bug#4 the user hit. 0.4s is the smallest
+ # window that catches "explodes at import" without making the UI
+ # feel laggy on healthy starts.
+ time.sleep(0.4)
+ rc = proc.poll()
+ if rc is not None:
+ return False, f"启动失败 (退出码 {rc}): {svc['name']}"
+ invalidate_running_cache()
+ return True, f"已启动 {svc['name']} (pid={proc.pid})"
+ except Exception as e:
+ return False, f"启动失败: {type(e).__name__}: {e}"
+
+
+# ----- running-state introspection (bug#4) --------------------------------
+# Why psutil cmdline-scan instead of a launched-by-us pid registry?
+# • Services launched by a previous TUI run, or by hub.pyw, must also be
+# recognised — otherwise /scheduler would happily start a duplicate.
+# • A registry tied to this process dies when the TUI restarts, but the
+# services keep running (CREATE_NEW_PROCESS_GROUP). Cmdline scan is the
+# only single source of truth across launchers, surviving restarts.
+# Trade-off: it costs ~30ms per /scheduler open, and matches by cmdline tail,
+# so two checkouts of GA can collide. We accept that — running two GAs out
+# of two clones is already an unsupported configuration.
+
+def _match_service(cmdline: list[str], svc: dict) -> bool:
+ """Does this OS process belong to `svc`? Match on the trailing script
+ arg (`reflect/foo.py` for reflect tasks, `frontends/bar.py` for apps),
+ which is invariant across `python` vs `pythonw` vs venv shims.
+
+ Reflect detection used to require BOTH `agentmain.py` AND the reflect
+ path in cmdline. That rejected tasks launched directly (`python
+ reflect/scheduler.py`) by launch.pyw, dev scripts, or by an earlier
+ TUI run that used a different launcher — they showed unticked in
+ /scheduler even when alive. Path-only match handles both styles; the
+ Python-process pre-filter in `running_services` keeps false positives
+ (greps, editors with the file open) from sneaking in."""
+ if not cmdline:
+ return False
+ rel = svc["name"] # 'reflect/foo.py' | 'frontends/bar.py'
+ rel_norm = rel.replace("/", os.sep)
+ return any(rel_norm in (a or "") or rel in (a or "")
+ for a in cmdline)
+
+
+# 2s TTL cache + name-prefilter: ~2.1s → ~1.0s cold, ~0ms warm.
+# cmdline() is the per-proc cost; only pay it for python-ish survivors.
+_RUNNING_CACHE: tuple[float, dict[str, int]] | None = None
+_RUNNING_TTL = 2.0
+
+
+def invalidate_running_cache() -> None:
+ """Drop the snapshot. Call after start/stop so the next read is fresh."""
+ global _RUNNING_CACHE
+ _RUNNING_CACHE = None
+
+
+def running_services(use_cache: bool = True) -> dict[str, int]:
+ """{service_name: pid} for live services. {} if psutil missing."""
+ global _RUNNING_CACHE
+ if use_cache and _RUNNING_CACHE and time.time() - _RUNNING_CACHE[0] < _RUNNING_TTL:
+ return dict(_RUNNING_CACHE[1])
+ try:
+ import psutil # type: ignore
+ except Exception:
+ return {}
+ svcs = list_launchable_services()
+ out: dict[str, int] = {}
+ me = os.getpid()
+ for proc in psutil.process_iter(["pid", "name"]):
+ try:
+ if proc.info["pid"] == me:
+ continue
+ nm = (proc.info.get("name") or "").lower()
+ if "python" not in nm and "py.exe" not in nm:
+ continue
+ cmd = proc.cmdline()
+ except Exception:
+ continue
+ for svc in svcs:
+ if svc["name"] not in out and _match_service(cmd, svc):
+ out[svc["name"]] = proc.info["pid"]
+ break
+ _RUNNING_CACHE = (time.time(), dict(out))
+ return out
+
+
+def stop_service(name: str) -> tuple[bool, str]:
+ """Terminate the service `name` if running. Returns (ok, message).
+
+ Sends SIGTERM-equivalent (Popen.terminate on Windows = TerminateProcess),
+ waits up to 3s, then escalates to kill. Also reaps obvious children
+ (e.g. `python -m streamlit` spawns the actual streamlit worker) so we
+ don't leave orphans behind.
+ """
+ try:
+ import psutil # type: ignore
+ except Exception:
+ return False, "未安装 psutil,无法停止服务"
+ running = running_services()
+ pid = running.get(name)
+ if pid is None:
+ return False, f"{name} 未在运行"
+ try:
+ parent = psutil.Process(pid)
+ kids = parent.children(recursive=True)
+ for p in [parent, *kids]:
+ try:
+ p.terminate()
+ except Exception:
+ pass
+ gone, alive = psutil.wait_procs([parent, *kids], timeout=3.0)
+ for p in alive:
+ try:
+ p.kill()
+ except Exception:
+ pass
+ invalidate_running_cache()
+ return True, f"已停止 {name} (pid={pid})"
+ except psutil.NoSuchProcess:
+ invalidate_running_cache()
+ return True, f"{name} 已退出"
+ except Exception as e:
+ return False, f"停止失败: {type(e).__name__}: {e}"
+
+
+def list_scheduler_tasks() -> list[dict]:
+ """Return [{name, path, schedule, enabled}] for every sche_tasks/*.json.
+
+ Used by the /scheduler picker so users can also toggle traditional cron
+ tasks, not just reflect.* scripts.
+ """
+ out: list[dict] = []
+ sd = _ROOT / "sche_tasks"
+ if not sd.is_dir():
+ return out
+ for p in sorted(sd.glob("*.json")):
+ try:
+ data = json.loads(p.read_text(encoding="utf-8"))
+ except Exception:
+ data = {}
+ out.append({
+ "name": p.stem,
+ "path": str(p),
+ "schedule": data.get("schedule") or data.get("cron") or data.get("every") or "",
+ "enabled": bool(data.get("enabled", True)),
+ })
+ return out
+
+
+def start_reflect_task(name: str) -> tuple[bool, str]:
+ """Spawn `python reflect/.py` detached. Returns (ok, message).
+
+ Detached because reflect tasks are long-running; we don't want them to die
+ with the TUI. On Windows we use CREATE_NEW_PROCESS_GROUP|CREATE_NO_WINDOW
+ so no console pops up (per CONSTITUTION rule 14: only at launch layer, no
+ monkeypatching subprocess.Popen).
+ """
+ script = _ROOT / "reflect" / f"{name}.py"
+ if not script.is_file():
+ return False, f"reflect/{name}.py 不存在"
+ try:
+ flags = 0
+ if os.name == "nt":
+ flags = 0x00000200 | 0x08000000 # NEW_PROCESS_GROUP | NO_WINDOW
+ subprocess.Popen(
+ [sys.executable, str(script)],
+ cwd=str(_ROOT),
+ creationflags=flags,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ close_fds=True,
+ )
+ return True, f"已启动 reflect/{name}.py"
+ except Exception as e:
+ return False, f"启动失败: {type(e).__name__}: {e}"
+
+
+# ----- dispatch table for the TUI to register against ---------------------
+
+# (cmd, arg_hint, desc) — kept identical between v2 and v3 so the palette
+# stays consistent across frontends.
+PALETTE_ENTRIES: list[tuple[str, str, str]] = [
+ ("/update", "[note]", "git pull 更新 GA 仓库并报告影响面"),
+ ("/autorun", "[seed]", "进入 autonomous_operation 自主模式"),
+ ("/morphling", "[target]", "启用 Morphling 蒸馏 / 吞噬外部技能"),
+ ("/goal", "[goal]", "进入 Goal 模式(需 condition 约束)"),
+ ("/hive", "[target]", "进入 Hive 多 worker 协作模式"),
+ ("/conductor", "[task]", "调用 frontends/conductor.py 多 subagent 编排"),
+ ("/scheduler", "", "多选启动/停止 reflect 任务(cron 由 reflect/scheduler.py 驱动)"),
+ ("/resume", "", "列出最近会话并恢复其中一个(GA 端展开 prompt)"),
+]
+
+
+def prompt_for(cmd: str, args_text: str) -> Optional[str]:
+ """Return the injected user-message for a given slash command, or None if
+ the command isn't one of ours (e.g. /scheduler — handled by TUI directly).
+
+ Language is resolved inside the builders that care about it (see
+ `_current_lang()`); callers never thread it through, so both TUIs keep a
+ single uniform call site.
+ """
+ table = {
+ "/update": build_update_prompt,
+ "/autorun": build_autorun_prompt,
+ "/morphling": build_morphling_prompt,
+ "/goal": build_goal_prompt,
+ "/hive": build_hive_prompt,
+ "/conductor": build_conductor_prompt,
+ }
+ fn = table.get(cmd)
+ return fn(args_text) if fn else None
diff --git a/frontends/stapp.py b/frontends/stapp.py
new file mode 100644
index 000000000..f490a165b
--- /dev/null
+++ b/frontends/stapp.py
@@ -0,0 +1,404 @@
+import os, sys, subprocess
+from urllib.request import urlopen
+from urllib.parse import quote
+if sys.stdout is None: sys.stdout = open(os.devnull, "w")
+if sys.stderr is None: sys.stderr = open(os.devnull, "w")
+try: sys.stdout.reconfigure(errors='replace')
+except: pass
+try: sys.stderr.reconfigure(errors='replace')
+except: pass
+script_dir = os.path.dirname(__file__)
+sys.path.append(os.path.abspath(os.path.join(script_dir, '..')))
+sys.path.append(os.path.abspath(script_dir))
+
+import streamlit as st
+import time, json, re, threading, queue
+from datetime import timedelta
+from agentmain import GeneraticAgent
+import chatapp_common # activate /continue command (monkey patches GeneraticAgent)
+from continue_cmd import handle_frontend_command, reset_conversation, list_sessions, extract_ui_messages
+from btw_cmd import handle_frontend_command as btw_handle_frontend
+from export_cmd import last_assistant_text, export_to_temp, wrap_for_clipboard
+
+st.set_page_config(page_title="Cowork", layout="wide", initial_sidebar_state="collapsed")
+
+st.markdown("""
+
+""", unsafe_allow_html=True)
+
+LANG = os.environ.get('GA_LANG', 'zh')
+if LANG not in ('zh', 'en'): LANG = 'zh'
+I18N = {
+ 'zh': {
+ 'force_stop': '强行停止任务',
+ 'desktop_pet': '🐱 桌面宠物',
+ 'suggest_btn': '🎯 给我找点事做',
+ 'suggest_prompt': '按照自主行动的规划部分,充分分析我的情况,给我生成一批TODO,务必让我感兴趣',
+ 'auto_start': '开始空闲自主行动',
+ 'auto_pause': '⏸️ 禁止自主行动',
+ 'auto_enable': '▶️ 允许自主行动',
+ 'auto_on_cap': '🟢 自主行动运行中,会在你离开它30分钟后自动进行',
+ 'auto_off_cap': '🔴 自主行动已停止',
+ 'auto_prompt': '[AUTO]🤖 用户已经离开超过30分钟,作为自主智能体,请阅读自动化sop,执行自动任务。',
+ },
+ 'en': {
+ 'force_stop': 'Force Stop',
+ 'desktop_pet': '🐱 Desktop Pet',
+ 'suggest_btn': '🎯 Suggest tasks',
+ 'suggest_prompt': 'Following the planning section of autonomous sop, analyze my situation thoroughly and generate a batch of TODOs that will interest me.',
+ 'auto_start': 'Start idle auto-action',
+ 'auto_pause': '⏸️ Pause auto-action',
+ 'auto_enable': '▶️ Enable auto-action',
+ 'auto_on_cap': '🟢 Auto-action enabled, triggers after 30min idle',
+ 'auto_off_cap': '🔴 Auto-action disabled',
+ 'auto_prompt': '[AUTO]🤖 User has been idle for over 30 minutes. As an autonomous agent, read the automation SOP and execute automatic tasks.',
+ },
+}
+def T(key): return I18N.get(LANG, I18N['zh']).get(key, key)
+
+@st.cache_resource
+def init():
+ agent = GeneraticAgent()
+ if agent.llmclient is None:
+ st.error("⚠️ Please set mykey.py!")
+ st.stop()
+ else: threading.Thread(target=agent.run, daemon=True).start()
+ return agent
+
+agent = init()
+
+def build_prompt(objective):
+ return f"""读取 {agent.log_path} 尾部,获取 agent 的最新输出。
+用户的 loop 诉求:{objective}
+判断该 agent 是否偷懒、是否真正完成诉求,用 输出要追加给它的指令:
+一般复述 objective,或不超过 10 字的督促,如:别停,继续 / 这就叫最优?你优化到位了吗 / 看我要求,你达成了吗 / 你好好看清楚 / 你能不能看看记忆
+只输出 … 。"""
+
+@st.cache_resource
+def get_controller():
+ b = {'ev': threading.Event(), 'obj': '', 'out': None, 'ready': False}
+ def loop():
+ ag = GeneraticAgent(); ag.verbose = False; ag.log_path = False
+ threading.Thread(target=ag.run, daemon=True).start()
+ while True:
+ b['ev'].wait(); b['ev'].clear()
+ dq = ag.put_task(build_prompt(b['obj']), source="controller")
+ while 'done' not in (it := dq.get()): pass
+ ms = re.findall(r'(.*?) ', it['done'], re.S)
+ b['out'] = ms[-1].strip() if ms else None; b['ready'] = True
+ threading.Thread(target=loop, daemon=True).start(); return b
+
+st.title("🖥️ Cowork")
+
+st.session_state.setdefault('autonomous_enabled', False)
+
+@st.fragment
+def render_sidebar():
+ st.session_state.setdefault('autonomous_enabled', False)
+ llm_options = agent.list_llms()
+ current_idx = agent.llm_no
+ llm_labels = {idx: f"{idx}: {(name or '').strip()}" for idx, name, _ in llm_options}
+ st.caption(f"LLM Core: {llm_labels.get(current_idx, str(current_idx))}")
+ selected_idx = st.selectbox("LLM", [idx for idx, _, _ in llm_options], index=next((i for i, (idx, _, _) in enumerate(llm_options) if idx == current_idx), 0), format_func=llm_labels.get, label_visibility="collapsed", key="sidebar_llm_select")
+ if selected_idx != current_idx:
+ agent.next_llm(selected_idx); st.rerun(scope="fragment")
+ if st.button(T('force_stop')):
+ agent.abort(); st.toast("Stop signal sended"); st.rerun()
+ if st.button(T('desktop_pet')):
+ kwargs = {'creationflags': 0x08} if sys.platform == 'win32' else {}
+ pet_script = os.path.join(script_dir, 'desktop_pet_v2.pyw')
+ if not os.path.exists(pet_script):
+ st.error("desktop_pet_v2.pyw not found")
+ return
+ subprocess.Popen([sys.executable, pet_script], **kwargs)
+ def _pet_req(q):
+ def _do():
+ try: urlopen(f'http://127.0.0.1:41983/?{q}', timeout=2)
+ except Exception: pass
+ threading.Thread(target=_do, daemon=True).start()
+ agent._pet_req = _pet_req
+ if not hasattr(agent, '_turn_end_hooks'): agent._turn_end_hooks = {}
+ def _pet_hook(ctx):
+ parts = [f"Turn {ctx.get('turn','?')}"]
+ if ctx.get('summary'): parts.append(ctx['summary'])
+ if ctx.get('exit_reason'): parts.append('DONE')
+ _pet_req(f'msg={quote(chr(10).join(parts))}')
+ if ctx.get('exit_reason'): _pet_req('state=idle')
+ agent._turn_end_hooks['pet'] = _pet_hook
+ st.toast("Desktop pet started")
+
+ if st.button(T('suggest_btn')):
+ st.session_state['_inject_prompt'] = T('suggest_prompt')
+ st.rerun(scope="app")
+ st.divider()
+ st.markdown("""""", unsafe_allow_html=True)
+ st.text_area("Loop prompt", value=st.session_state.get('loop_prompt_input', "继续" if LANG=='zh' else 'next'), key="loop_prompt_input", height=1)
+ if st.session_state.get('loop_enabled'):
+ if st.button("⏹️ Stop Loop"):
+ st.session_state.loop_enabled = False
+ st.toast("⏹️ Loop stopped"); st.rerun(scope="app")
+ st.caption("🔁 Looping")
+ else:
+ if st.button("🔁 Loop!"):
+ st.session_state.loop_enabled = True
+ get_controller()
+ st.session_state['_inject_prompt'] = st.session_state.get('loop_prompt_input', '')
+ st.toast("🔁 Looping"); st.rerun(scope="app")
+ st.divider()
+ if st.button(T('auto_start')):
+ st.session_state.last_reply_time = int(time.time()) - 1800
+ st.session_state.autonomous_enabled = True
+ st.rerun(scope="app")
+ if st.session_state.autonomous_enabled:
+ if st.button(T('auto_pause')):
+ st.session_state.autonomous_enabled = False
+ st.toast(T('auto_pause')); st.rerun(scope="app")
+ st.caption(T('auto_on_cap'))
+ else:
+ if st.button(T('auto_enable'), type="primary"):
+ st.session_state.autonomous_enabled = True
+ st.toast("✅"); st.rerun(scope="app")
+ st.caption(T('auto_off_cap'))
+with st.sidebar: render_sidebar()
+
+def fold_turns(text):
+ """Return list of segments: [{'type':'text','content':...}, {'type':'fold','title':...,'content':...}]"""
+ # 先把4+反引号块替换为占位符,避免误切子agent嵌套的 LLM Running
+ _ph = []
+ safe = re.sub(r'`{4,}.*?`{4,}', lambda m: (_ph.append(m.group(0)), f'\x00PH{len(_ph)-1}\x00')[1], text, flags=re.DOTALL)
+ # 流式中间态:末尾可能有未闭合的4+反引号块,也需保护
+ safe = re.sub(r'`{4,}[^`].*$', lambda m: (_ph.append(m.group(0)), f'\x00PH{len(_ph)-1}\x00')[1], safe, flags=re.DOTALL)
+ parts = re.split(r'(\**LLM Running \(Turn \d+\) \.\.\.\*\**)', safe)
+ parts = [re.sub(r'\x00PH(\d+)\x00', lambda m: _ph[int(m.group(1))], p) for p in parts]
+ if len(parts) < 4: return [{'type': 'text', 'content': text}]
+ segments = []
+ if parts[0].strip(): segments.append({'type': 'text', 'content': parts[0]})
+ turns = []
+ for i in range(1, len(parts), 2):
+ marker = parts[i]
+ content = parts[i+1] if i+1 < len(parts) else ''
+ turns.append((marker, content))
+ for idx, (marker, content) in enumerate(turns):
+ if idx < len(turns) - 1:
+ _c = re.sub(r'`{3,}.*?`{3,}|.*? ', '', content, flags=re.DOTALL)
+ matches = re.findall(r'\s*((?:(?!).)*?)\s* ', _c, re.DOTALL)
+ if matches:
+ title = matches[0].strip()
+ title = title.split('\n')[0]
+ if len(title) > 50: title = title[:50] + '...'
+ else:
+ _plain = _c.strip().split('\n', 1)[0]
+ title = (_plain[:50] + '...') if len(_plain) > 50 else (_plain or marker.strip('*'))
+ segments.append({'type': 'fold', 'title': title, 'content': content})
+ else: segments.append({'type': 'text', 'content': marker + content})
+ return segments
+_SUMMARY_TAG_RE = re.compile(r'.*? \s*', re.DOTALL)
+
+def render_segments(segments, suffix=''):
+ # 整块重画:调用方用 slot.container() 包裹,保证 DOM 路径稳定、跨 rerun 对齐(消除"灰色重影")。
+ # heartbeat 空转时 segments 不变 → Streamlit 后端 diff 无变化 → 前端零闪烁;
+ # 但 container/markdown 本身是 API 调用,StopException 仍会被抛出(abort 照常起作用)。
+ for seg in segments:
+ if seg['type'] == 'fold':
+ with st.expander(seg['title'], expanded=False): st.markdown(seg['content'])
+ else:
+ st.markdown(seg['content'] + suffix)
+
+def agent_backend_stream(prompt=None):
+ """Drain main task display_queue.
+ - prompt given: start a fresh task; new dq is kept in session_state.
+ - prompt is None: resume a dq left in session_state by a prior run (e.g. after /btw).
+ Per-chunk progress is mirrored to session_state.partial_response so the rendered
+ bubble survives reruns. No implicit agent.abort() — explicit stop is on the Stop button."""
+ if prompt is not None:
+ st.session_state.display_queue = agent.put_task(prompt, source="user")
+ st.session_state.partial_response = ''
+ dq = st.session_state.get('display_queue')
+ if dq is None: return
+ # Drop a dangling 'LLM Running (Turn N) ...' marker if the captured partial
+ # ended right at a turn boundary with no content yet — otherwise the resume
+ # bubble flashes as a marker-only gray line. The marker reappears with
+ # content on the next chunk (raw_resp is cumulative).
+ response = re.sub(r'\**LLM Running \(Turn \d+\) \.\.\.\**\s*$',
+ '', st.session_state.get('partial_response', '')).rstrip()
+ try:
+ while True:
+ try: item = dq.get(timeout=1)
+ except queue.Empty:
+ yield response # heartbeat: let outer st.markdown() run → Streamlit checks StopException
+ continue
+ if 'next' in item:
+ response = item['next']
+ st.session_state.partial_response = response
+ yield response
+ if 'done' in item:
+ st.session_state.display_queue = None
+ st.session_state.partial_response = ''
+ yield item['done']; break
+ finally:
+ agent.abort()
+ try:
+ st.session_state.display_queue = None
+ st.session_state.partial_response = ''
+ except BaseException:
+ pass
+
+
+def render_main_stream(prompt=None):
+ """Render the assistant bubble for the main task (new or resumed). Saves final to messages."""
+ with st.chat_message("assistant"):
+ frozen = 0; live = st.empty(); response = ''
+ CURSOR = ' ▌'
+ for response in agent_backend_stream(prompt):
+ segs = fold_turns(response)
+ n_done = max(0, len(segs) - 1)
+ while frozen < n_done:
+ with live.container(): render_segments([segs[frozen]])
+ live = st.empty(); frozen += 1
+ with live.container(): render_segments([segs[-1]], suffix=CURSOR) # live 区域
+ segs = fold_turns(response)
+ for i in range(frozen, len(segs)):
+ with live.container(): render_segments([segs[i]])
+ if i < len(segs) - 1: live = st.empty()
+ if response:
+ st.session_state.messages.append({"role": "assistant", "content": response})
+ st.session_state.last_reply_time = int(time.time())
+ # ── 循环回调:回答完成戳醒 controller 决策(去程,现取最新objective) ──
+ if st.session_state.get('loop_enabled'):
+ b = get_controller()
+ b['obj'] = st.session_state.get('loop_prompt_input', ''); b['ready'] = False; b['ev'].set()
+
+if "messages" not in st.session_state: st.session_state.messages = []
+for msg in st.session_state.messages:
+ with st.chat_message(msg["role"]):
+ # 用 slot=st.empty() + with slot.container(): ... 的外壳,DOM 路径和流式渲染完全一致,跨 rerun 对齐
+ slot = st.empty()
+ with slot.container():
+ if msg["role"] == "assistant": render_segments(fold_turns(msg["content"]))
+ else: st.markdown(msg["content"])
+
+# Scroll-height ghost fix: during streaming, expander open/close mid-animation can leave
+# phantom height → scrollbar long but can't scroll to bottom. Periodically detect & reflow.
+try:
+ from streamlit import iframe as _st_iframe # 1.56+
+ _embed_html = lambda html, **kw: _st_iframe(html, **{k: max(v, 1) if isinstance(v, int) else v for k, v in kw.items()})
+except (ImportError, AttributeError):
+ from streamlit.components.v1 import html as _embed_html # ≤1.55
+# IME composition fix (macOS only) - prevents Enter from submitting during CJK input
+_js_ime_fix = ("" if os.name == 'nt' else
+ "!function(){if(window.parent.__imeFix)return;window.parent.__imeFix=1;"
+ "var d=window.parent.document,c=0;"
+ "d.addEventListener('compositionstart',()=>c=1,!0);"
+ "d.addEventListener('compositionend',()=>c=0,!0);"
+ "function f(){d.querySelectorAll('textarea[data-testid=stChatInputTextArea]')"
+ ".forEach(t=>{t.__imeFix||(t.__imeFix=1,t.addEventListener('keydown',e=>{"
+ "e.key==='Enter'&&!e.shiftKey&&(e.isComposing||c||e.keyCode===229)&&"
+ "(e.stopImmediatePropagation(),e.preventDefault())},!0))})}"
+ "f();new MutationObserver(f).observe(d.body,{childList:1,subtree:1})}()")
+_embed_html(f'', height=0)
+
+_injected = st.session_state.pop('_inject_prompt', None)
+prompt = st.chat_input("any task?") or _injected
+if prompt:
+ ts = time.strftime("%Y-%m-%d %H:%M:%S")
+ cmd = (prompt or "").strip()
+ def _reset_and_rerun():
+ st.session_state.streaming = False
+ st.session_state.stopping = False
+ st.session_state.display_queue = None
+ st.session_state.partial_response = ""
+ st.session_state.reply_ts = ""
+ st.session_state.current_prompt = ""
+ st.session_state.last_reply_time = int(time.time())
+ st.rerun()
+ if cmd == "/new":
+ st.session_state.messages = [{"role": "assistant", "content": reset_conversation(agent), "time": ts}]
+ _reset_and_rerun()
+ if cmd.startswith("/continue"):
+ m = re.match(r'/continue\s+(\d+)\s*$', cmd.strip())
+ sessions = list_sessions(exclude_pid=os.getpid()) if m else []
+ idx = int(m.group(1)) - 1 if m else -1
+ # Resolve target path BEFORE handle (which snapshots current log, shifting indices).
+ target = sessions[idx][0] if 0 <= idx < len(sessions) else None
+ result = handle_frontend_command(agent, cmd)
+ history = extract_ui_messages(target) if target and result.startswith('✅') else None
+ tail = [{"role": "assistant", "content": result, "time": ts}]
+ if history: st.session_state.messages = history + tail
+ else: st.session_state.messages = list(st.session_state.messages)+[{"role": "user", "content": cmd, "time": ts}]+tail
+ _reset_and_rerun()
+ if cmd.startswith("/btw"):
+ answer = btw_handle_frontend(agent, cmd) # sync; bypasses put_task → main agent.run() untouched
+ st.session_state.messages = list(st.session_state.messages) + [
+ {"role": "user", "content": prompt, "time": ts},
+ {"role": "assistant", "content": answer, "time": ts},
+ ]
+ st.rerun() # preserve display_queue/partial_response so resume path drains the running main task
+ if cmd.startswith("/export"):
+ parts = cmd.split(maxsplit=1)
+ sub = parts[1].strip() if len(parts) > 1 else ""
+ sub_lower = sub.lower()
+ if not sub:
+ result = (
+ "**选择导出方式:**\n\n"
+ "- `/export clip` — 整理到代码块中\n"
+ "- `/export <文件名>` — 导出到 `temp/<文件名>`(默认 .md 后缀)\n"
+ "- `/export all` — 显示完整对话日志路径"
+ )
+ elif sub_lower == "all":
+ log = agent.log_path
+ result = (f"📂 完整对话日志:\n\n`{log}`" if os.path.isfile(log)
+ else f"❌ 当前会话尚无日志文件")
+ else:
+ text = last_assistant_text(agent)
+ if not text:
+ result = "❌ 还没有模型回复可导出"
+ elif sub_lower in ("clip", "copy"):
+ result = f"📋 最后一轮回复(点代码块右上角 📋 复制):\n\n{wrap_for_clipboard(text)}"
+ else:
+ try:
+ path = export_to_temp(text, sub)
+ result = f"✅ 已导出:\n\n`{path}`"
+ except Exception as e:
+ result = f"❌ 导出失败: {e}"
+ st.session_state.messages = list(st.session_state.messages) + [
+ {"role": "user", "content": cmd, "time": ts},
+ {"role": "assistant", "content": result, "time": ts},
+ ]
+ _reset_and_rerun()
+ # Regular prompt: any in-flight task will be aborted by the finally block in
+ # agent_backend_stream when StopException interrupts the prior generator.
+ st.session_state.messages.append({"role": "user", "content": prompt})
+ if hasattr(agent, '_pet_req') and not prompt.startswith('/'): agent._pet_req('state=walk')
+ with st.chat_message("user"): st.markdown(prompt)
+ render_main_stream(prompt)
+elif st.session_state.get('display_queue') is not None:
+ # No new prompt but a task is mid-flight (typically a /btw rerun) — resume drain.
+ render_main_stream()
+
+# ── 空闲自主行动:fragment 定时检测,替代 launch.pyw 的 idle_monitor ──
+@st.fragment(run_every=timedelta(minutes=1))
+def _idle_checker():
+ if st.session_state.get('loop_enabled'): # 循环回程:取controller回填
+ b = get_controller()
+ if b['ready']:
+ b['ready'] = False
+ if b['out']: st.session_state['_inject_prompt'] = b['out']
+ else: st.session_state.loop_enabled = False
+ st.rerun(scope="app")
+ return
+ if not st.session_state.get('autonomous_enabled'): return
+ if st.session_state.get('display_queue') is not None: return # 正在运行中
+ last = st.session_state.get('last_reply_time', int(time.time()))
+ if time.time() - last > 1800:
+ st.session_state['_inject_prompt'] = T('auto_prompt')
+ st.session_state['last_reply_time'] = int(time.time()) # 防重入
+ st.rerun(scope="app")
+_idle_checker()
diff --git a/frontends/stapp2.py b/frontends/stapp2.py
new file mode 100644
index 000000000..1d7968f5d
--- /dev/null
+++ b/frontends/stapp2.py
@@ -0,0 +1,1049 @@
+import os, sys
+import html
+if sys.stdout is None: sys.stdout = open(os.devnull, "w")
+if sys.stderr is None: sys.stderr = open(os.devnull, "w")
+try: sys.stdout.reconfigure(errors='replace')
+except: pass
+try: sys.stderr.reconfigure(errors='replace')
+except: pass
+sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
+
+import streamlit as st
+try:
+ from streamlit import iframe as _st_iframe # 1.56+
+ _embed_html = lambda html, **kw: _st_iframe(html, **{k: max(v, 1) if isinstance(v, int) else v for k, v in kw.items()})
+except (ImportError, AttributeError):
+ from streamlit.components.v1 import html as _embed_html # ≤1.55
+import time, json, re, threading, queue
+from datetime import datetime
+from agentmain import GeneraticAgent
+
+st.set_page_config(page_title="Cowork", layout="wide")
+
+# ─── Anthropic Light Theme CSS ───
+ANTHROPIC_CSS = """
+
+"""
+
+ANTHROPIC_SELECTBOX_SCRIPT = """
+
+
+"""
+
+@st.cache_resource
+def init():
+ agent = GeneraticAgent()
+ if agent.llmclient is None:
+ st.error("⚠️ 未配置任何可用的 LLM 接口,请在 mykey.py 中添加 sider_cookie 或 oai_apikey+oai_apibase 等信息后重启。")
+ st.stop()
+ else:
+ threading.Thread(target=agent.run, daemon=True).start()
+ return agent
+
+
+def build_dynamic_font_css(scale_percent: float) -> str:
+ root_percent = max(100.0, min(200.0, float(scale_percent)))
+ rem_scale = root_percent / 100.0
+ return f"""
+
+"""
+
+
+def build_dynamic_font_update_script(scale_percent: float) -> str:
+ css = json.dumps(build_dynamic_font_css(scale_percent))
+ return f"""
+
+"""
+
+
+def build_header_agent_badge_script() -> str:
+ return """
+
+"""
+
+agent = init()
+
+def init_session_state():
+ for key, value in {
+ 'agent_name': 'GenericAgent', 'streaming': False, 'stopping': False, 'display_queue': None,
+ 'partial_response': '', 'reply_ts': '', 'current_prompt': '', 'selected_llm_idx': agent.llm_no,
+ 'autonomous_enabled': False, 'messages': [],
+ }.items(): st.session_state.setdefault(key, value)
+
+init_session_state()
+
+# Inject Anthropic theme
+st.markdown(ANTHROPIC_CSS, unsafe_allow_html=True)
+st.markdown(build_dynamic_font_css(110.0), unsafe_allow_html=True)
+_embed_html(ANTHROPIC_SELECTBOX_SCRIPT, height=0, width=0)
+_embed_html(build_header_agent_badge_script(), height=0, width=0)
+
+st.session_state.agent_name = 'Generic Agent'
+with st.chat_message("assistant"):
+ st.markdown(f'{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
', unsafe_allow_html=True)
+ st.write("欢迎使用GenericAgent~")
+
+
+@st.fragment
+def render_sidebar():
+ llm_options, current_idx = agent.list_llms(), agent.llm_no
+ st.session_state.selected_llm_idx = current_idx
+ llm_labels = {idx: f"{idx}: {(name or '').strip()}" for idx, name, _ in llm_options}
+ st.caption(f"当前使用的LLM为:{current_idx}: {agent.get_llm_name()}", help="可在下方选择链路")
+ st.markdown(f'{html.escape(max(llm_labels.values(), key=len, default=""))}
', unsafe_allow_html=True)
+ selected_idx = st.selectbox("选择链路:", [idx for idx, _, _ in llm_options], index=next((i for i, (idx, _, _) in enumerate(llm_options) if idx == current_idx), 0), format_func=llm_labels.get, key="sidebar_llm_select")
+ if selected_idx != current_idx:
+ agent.next_llm(selected_idx)
+ st.session_state.selected_llm_idx = selected_idx
+ st.toast(f"已切换到备用链路:{llm_labels[selected_idx]}")
+ st.rerun()
+ st.divider()
+ if st.button("重新注入System Prompt"):
+ agent.llmclient.last_tools = ''
+ st.toast("下次将重新注入System Prompt")
+
+with st.sidebar: render_sidebar()
+
+
+def start_agent_task(prompt):
+ st.session_state.display_queue = agent.put_task(prompt, source="user")
+ st.session_state.streaming, st.session_state.stopping, st.session_state.partial_response = True, False, ''
+ st.session_state.reply_ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ st.session_state.current_prompt = prompt
+
+
+def poll_agent_output(max_items=20):
+ q = st.session_state.display_queue
+ if q is None:
+ st.session_state.streaming = False
+ return False
+ done = False
+ for _ in range(max_items):
+ try:
+ item = q.get_nowait()
+ except queue.Empty:
+ break
+ if 'next' in item: st.session_state.partial_response = item['next']
+ if 'done' in item:
+ st.session_state.partial_response = item['done']
+ done = True
+ break
+ if done: st.session_state.streaming = st.session_state.stopping = False; st.session_state.display_queue = None
+ return done
+
+
+def _get_response_segments(text):
+ return [p for p in re.split(r'(?=\*\*LLM Running \(Turn \d+\) \.\.\.\*\*)', text) if p.strip()] or [text]
+
+def render_message(role, content, ts='', unsafe_allow_html=True):
+ with st.chat_message(role):
+ if ts: st.markdown(f'{ts}
', unsafe_allow_html=True)
+ st.markdown(content, unsafe_allow_html=unsafe_allow_html)
+
+def finish_streaming_message():
+ reply_ts = st.session_state.reply_ts
+ st.session_state.messages.extend({"role": "assistant", "content": seg, "time": reply_ts} for seg in _get_response_segments(st.session_state.partial_response))
+ st.session_state.last_reply_time = int(time.time())
+ st.session_state.partial_response = st.session_state.reply_ts = st.session_state.current_prompt = ''
+
+def render_streaming_area():
+ if not st.session_state.streaming: return
+ with st.container():
+ st.markdown(' ', unsafe_allow_html=True)
+ if st.button("⏹️ 停止生成", type="primary"):
+ agent.abort(); st.session_state.stopping = True; st.toast("已发送停止信号"); st.rerun()
+ reply_ts = st.session_state.reply_ts
+ with st.empty().container():
+ segments = _get_response_segments(st.session_state.partial_response)
+ for i, seg in enumerate(segments): render_message("assistant", seg + ("" if i < len(segments) - 1 else "▌"), ts=reply_ts, unsafe_allow_html=False)
+ if poll_agent_output(): finish_streaming_message()
+ else: time.sleep(0.2)
+ st.rerun()
+
+for msg in st.session_state.messages: render_message(msg["role"], msg["content"], ts=msg.get("time", ""), unsafe_allow_html=True)
+if st.session_state.streaming: render_streaming_area()
+if prompt := st.chat_input("请输入指令", disabled=st.session_state.streaming):
+ st.session_state.messages.append({"role": "user", "content": prompt, "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")})
+ start_agent_task(prompt)
+ st.rerun()
+
diff --git a/frontends/tgapp.py b/frontends/tgapp.py
new file mode 100644
index 000000000..ea3b3a1a9
--- /dev/null
+++ b/frontends/tgapp.py
@@ -0,0 +1,1138 @@
+import os, sys, re, threading, asyncio, queue as Q, time, random, uuid
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+_TEMP_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'temp')
+from agentmain import GeneraticAgent
+try:
+ from telegram import BotCommand, InlineKeyboardButton, InlineKeyboardMarkup
+ from telegram.constants import ChatType, MessageLimit, ParseMode
+ from telegram.error import RetryAfter
+ from telegram.ext import ApplicationBuilder, CallbackQueryHandler, MessageHandler, filters, ContextTypes
+ from telegram.helpers import escape_markdown
+ from telegram.request import HTTPXRequest
+except:
+ print("Please ask the agent install python-telegram-bot to use telegram module.")
+ sys.exit(1)
+from chatapp_common import (
+ FILE_HINT,
+ HELP_TEXT,
+ TELEGRAM_MENU_COMMANDS,
+ clean_reply,
+ ensure_single_instance,
+ extract_files,
+ format_restore,
+ redirect_log,
+ require_runtime,
+ split_text,
+)
+from continue_cmd import handle_frontend_command, reset_conversation
+from btw_cmd import handle_frontend_command as handle_btw_frontend_command
+from review_cmd import handle as handle_review_command
+from llmcore import mykeys
+
+agent = GeneraticAgent()
+agent.verbose = False
+agent.inc_out = True
+ALLOWED = set(mykeys.get('tg_allowed_users', []))
+
+_DRAFT_HINT = "thinking..."
+_STREAM_SUFFIX = " ⏳"
+_STREAM_SEGMENT_LIMIT = max(1200, MessageLimit.MAX_TEXT_LENGTH - 256)
+_STREAM_UPDATE_INTERVAL_SECONDS = 2.0
+_STREAM_MIN_UPDATE_CHARS = 400
+_RETRY_AFTER_MARGIN_SECONDS = 1.0
+_QUEUE_WAIT_SECONDS = 1
+_ASK_USER_HOOK_KEY = "telegram_ask_user_menu"
+_ASK_CALLBACK_PREFIX = "ask:"
+_LLM_CALLBACK_PREFIX = "llm:"
+_ASK_CANCEL_ACTION = "none"
+_ASK_MULTI_DONE_ACTION = "done"
+_ASK_TOGGLE_ACTION = "toggle"
+_ASK_CANCEL_LABEL = "none of these above"
+_ASK_CANCEL_PROMPT = "已取消选择,请直接发送下一步操作。"
+_ASK_MULTI_HINT = "可多选:点选项目后点击 Done 提交。"
+_ASK_MULTI_EMPTY_HINT = "请至少选择一项,或选择 none of these above。"
+_LLM_MENU_PROMPT = "请选择要切换的 LLM:"
+_ask_menu_events = Q.Queue()
+_ask_menu_store = {}
+_llm_menu_store = {}
+_MULTI_SELECT_RE = re.compile(r"\[?(?:多选|multi(?:[-_ ]?select)?|select all)\]?", re.IGNORECASE)
+_QUOTE_OPEN_TAG = "<_quote_>"
+_QUOTE_CLOSE_TAG = ""
+_QUOTE_TOKEN_PATTERN = re.escape(_QUOTE_OPEN_TAG) + r"([\s\S]*?)" + re.escape(_QUOTE_CLOSE_TAG)
+_MD_TOKEN_RE = re.compile(
+ (
+ r"(`{3,})([A-Za-z0-9_+-]*)\n([\s\S]*?)\1"
+ r"|" + _QUOTE_TOKEN_PATTERN +
+ r"|\[([^\]]+)\]\(([^)\n]+)\)"
+ r"|`([^`\n]+)`"
+ r"|\*\*([^\n]+?)\*\*"
+ r"|__([^\n]+?)__"
+ r"|~~([^\n]+?)~~"
+ r"|(?\s*(.*?)\s* ", re.DOTALL)
+_TURN_SUMMARY_SEARCH_STRIP_RE = re.compile(r"`{3,}[\s\S]*?`{3,}|[\s\S]*? ", re.DOTALL)
+
+def _make_draft_id():
+ return random.randint(1, 2**31 - 1)
+
+def _visible_segments(text):
+ text = (text or "").strip()
+ if not text:
+ return []
+ segments = []
+ for part in split_text(text, _STREAM_SEGMENT_LIMIT):
+ segments.extend(_markdown_safe_segments(part))
+ return segments
+
+def _markdown_safe_segments(text, limit=None):
+ limit = limit or MessageLimit.MAX_TEXT_LENGTH
+ text = (text or "").strip()
+ if not text:
+ return []
+ if len(_to_markdown_v2(text)) <= limit:
+ return [text]
+ parts = []
+ remaining = text
+ while remaining:
+ if len(_to_markdown_v2(remaining)) <= limit:
+ parts.append(remaining)
+ break
+ low, high, best = 1, len(remaining), 1
+ while low <= high:
+ mid = (low + high) // 2
+ if len(_to_markdown_v2(remaining[:mid].rstrip() or remaining[:mid])) <= limit:
+ best = mid
+ low = mid + 1
+ else:
+ high = mid - 1
+ cut = remaining.rfind("\n", 0, best)
+ if cut < max(1, best * 0.6):
+ cut = best
+ chunk = remaining[:cut].rstrip() or remaining[:best]
+ parts.append(chunk)
+ remaining = remaining[len(chunk):].lstrip()
+ return parts
+
+def _line_complete(line):
+ return (line or "").endswith(("\n", "\r"))
+
+def _turn_marker_number(line):
+ match = _TURN_MARKER_RE.fullmatch((line or "").strip())
+ return int(match.group(1)) if match else None
+
+def _maybe_partial_turn_marker(line):
+ text = (line or "").strip().lstrip("*")
+ if not text:
+ return False
+ marker_head = "LLM Running (Turn "
+ return marker_head.startswith(text) or text.startswith(marker_head)
+
+def _maybe_partial_code_fence(line):
+ return bool(re.match(r"^\s*`{1,}[^`\r\n]*$", line or ""))
+
+def _extract_turn_summary(raw_text):
+ search_text = _TURN_SUMMARY_SEARCH_STRIP_RE.sub("", raw_text or "")
+ match = _TURN_SUMMARY_RE.search(search_text)
+ if not match:
+ return ""
+ summary = re.sub(r"\s+", " ", match.group(1)).strip()
+ if len(summary) > _TURN_SUMMARY_LIMIT:
+ summary = summary[:_TURN_SUMMARY_LIMIT - 3].rstrip() + "..."
+ return summary
+
+def _quote_tag(text):
+ safe_text = (text or "").strip().replace(_QUOTE_OPEN_TAG, "").replace(_QUOTE_CLOSE_TAG, "")
+ return f"{_QUOTE_OPEN_TAG}{safe_text}{_QUOTE_CLOSE_TAG}"
+
+def _inject_turn_summary(body, summary):
+ if not (body or "").strip() or not (summary or "").strip():
+ return body
+ lines = (body or "").splitlines()
+ if not lines or _turn_marker_number(lines[0]) is None:
+ return body
+ title = lines[0].strip()
+ rest = "\n".join(lines[1:]).strip()
+ summary_line = _quote_tag(summary)
+ if rest:
+ return f"{title}\n\n{summary_line}\n\n{rest}"
+ return f"{title}\n\n{summary_line}"
+
+def _resolve_files(paths):
+ files, seen = [], set()
+ for fpath in paths:
+ if not os.path.isabs(fpath):
+ fpath = os.path.join(_TEMP_DIR, fpath)
+ if fpath in seen or not os.path.exists(fpath):
+ continue
+ files.append(fpath)
+ seen.add(fpath)
+ return files
+
+
+def _render_file_markers(text):
+ def repl(match):
+ return os.path.basename(match.group(1))
+ return re.sub(r"\[FILE:([^\]]+)\]", repl, text or "").strip()
+
+def _files_from_text(text):
+ cleaned = clean_reply(text) if (text or "").strip() else ""
+ return _resolve_files(extract_files(cleaned))
+
+async def _send_files(root_msg, files):
+ for fpath in files:
+ if fpath.lower().endswith((".png", ".jpg", ".jpeg", ".gif", ".webp")):
+ try:
+ with open(fpath, "rb") as fp:
+ await root_msg.reply_photo(fp)
+ except Exception:
+ pass
+ else:
+ try:
+ with open(fpath, "rb") as fp:
+ await root_msg.reply_document(fp)
+ except Exception:
+ pass
+
+async def _send_files_from_text(root_msg, text):
+ await _send_files(root_msg, _files_from_text(text))
+
+def _escape_pre(text):
+ return escape_markdown(text or "", version=2, entity_type="pre")
+
+def _escape_code(text):
+ return escape_markdown(text or "", version=2, entity_type="code")
+
+def _escape_link_target(text):
+ return escape_markdown(text or "", version=2, entity_type="text_link")
+
+def _quote_to_markdown_v2(text):
+ lines = (text or "").strip().splitlines() or [""]
+ return "\n".join(f"> {escape_markdown(line, version=2)}" for line in lines)
+
+def _to_markdown_v2(text):
+ if not text:
+ return ""
+ parts, pos = [], 0
+ for match in _MD_TOKEN_RE.finditer(text):
+ parts.append(escape_markdown(text[pos:match.start()], version=2))
+ if match.group(1):
+ lang = re.sub(r"[^A-Za-z0-9_+-]", "", match.group(2) or "")
+ code = _escape_pre(match.group(3) or "")
+ header = f"```{lang}\n" if lang else "```\n"
+ parts.append(f"{header}{code}\n```")
+ elif match.group(4) is not None:
+ parts.append(_quote_to_markdown_v2(match.group(4)))
+ elif match.group(5) is not None:
+ label = escape_markdown(match.group(5), version=2)
+ target = _escape_link_target(match.group(6))
+ parts.append(f"[{label}]({target})")
+ elif match.group(7) is not None:
+ parts.append(f"`{_escape_code(match.group(7))}`")
+ elif match.group(8) is not None:
+ parts.append(f"*{escape_markdown(match.group(8), version=2)}*")
+ elif match.group(9) is not None:
+ parts.append(f"*{escape_markdown(match.group(9), version=2)}*")
+ elif match.group(10) is not None:
+ parts.append(f"~{escape_markdown(match.group(10), version=2)}~")
+ elif match.group(11) is not None:
+ parts.append(f"_{escape_markdown(match.group(11), version=2)}_")
+ pos = match.end()
+ parts.append(escape_markdown(text[pos:], version=2))
+ return "".join(parts)
+
+def _is_not_modified_error(exc):
+ return "not modified" in str(exc).lower()
+
+def _extract_ask_user_event(ctx):
+ exit_reason = (ctx or {}).get("exit_reason") or {}
+ if exit_reason.get("result") != "EXITED":
+ return None
+ payload = exit_reason.get("data")
+ if not isinstance(payload, dict):
+ return None
+ if payload.get("status") != "INTERRUPT" or payload.get("intent") != "HUMAN_INTERVENTION":
+ return None
+ data = payload.get("data")
+ if not isinstance(data, dict):
+ return None
+ raw_candidates = data.get("candidates") or []
+ if not isinstance(raw_candidates, (list, tuple)):
+ return None
+ candidates = []
+ for candidate in raw_candidates:
+ if candidate is None:
+ continue
+ text = str(candidate).strip()
+ if text:
+ candidates.append(text)
+ if not candidates:
+ return None
+ question = str(data.get("question") or "请选择下一步操作:").strip() or "请选择下一步操作:"
+ return {
+ "question": question,
+ "candidates": candidates,
+ "multi": bool(_MULTI_SELECT_RE.search(question)),
+ }
+
+def _register_ask_user_hook():
+ if not hasattr(agent, "_turn_end_hooks"):
+ agent._turn_end_hooks = {}
+ def _hook(ctx):
+ event = _extract_ask_user_event(ctx)
+ if event:
+ _ask_menu_events.put(event)
+ agent._turn_end_hooks[_ASK_USER_HOOK_KEY] = _hook
+
+def _drain_latest_ask_user_event():
+ latest = None
+ while True:
+ try:
+ latest = _ask_menu_events.get_nowait()
+ except Q.Empty:
+ break
+ return latest
+
+def _build_ask_user_markup(menu_id, candidates, multi=False, selected_indexes=None):
+ selected_indexes = set(selected_indexes or [])
+ rows = []
+ for idx, candidate in enumerate(candidates):
+ if multi:
+ label = f"✓ {candidate}" if idx in selected_indexes else candidate
+ action = f"{_ASK_TOGGLE_ACTION}:{idx}"
+ else:
+ label = candidate
+ action = str(idx)
+ rows.append([
+ InlineKeyboardButton(label, callback_data=f"{_ASK_CALLBACK_PREFIX}{menu_id}:{action}")
+ ])
+ if multi:
+ rows.append([
+ InlineKeyboardButton("Done", callback_data=f"{_ASK_CALLBACK_PREFIX}{menu_id}:{_ASK_MULTI_DONE_ACTION}")
+ ])
+ rows.append([
+ InlineKeyboardButton(_ASK_CANCEL_LABEL, callback_data=f"{_ASK_CALLBACK_PREFIX}{menu_id}:{_ASK_CANCEL_ACTION}")
+ ])
+ return InlineKeyboardMarkup(rows)
+
+def _build_llm_markup(menu_id, llms):
+ rows = []
+ for idx, name, current in llms:
+ label = f"→ [{idx}] {name}" if current else f"[{idx}] {name}"
+ rows.append([
+ InlineKeyboardButton(label, callback_data=f"{_LLM_CALLBACK_PREFIX}{menu_id}:{idx}")
+ ])
+ return InlineKeyboardMarkup(rows)
+
+def _parse_menu_callback_data(data, prefix):
+ if not (data or "").startswith(prefix):
+ return None, None
+ payload = data[len(prefix):]
+ menu_id, sep, action = payload.partition(":")
+ if not sep or not menu_id or not action:
+ return None, None
+ return menu_id, action
+
+def _parse_ask_callback_data(data):
+ return _parse_menu_callback_data(data, _ASK_CALLBACK_PREFIX)
+
+def _build_text_prompt(text):
+ return f"{FILE_HINT}\n\n{text}"
+
+def _normalize_ask_menu_event(stored):
+ if isinstance(stored, dict):
+ candidates = stored.get("candidates") or []
+ return {
+ "question": str(stored.get("question") or "请选择下一步操作:").strip() or "请选择下一步操作:",
+ "candidates": [str(candidate).strip() for candidate in candidates if str(candidate).strip()],
+ "multi": bool(stored.get("multi")),
+ "selected": [int(idx) for idx in stored.get("selected", []) if isinstance(idx, int)],
+ }
+ if isinstance(stored, (list, tuple)):
+ return {
+ "question": "请选择下一步操作:",
+ "candidates": [str(candidate).strip() for candidate in stored if str(candidate).strip()],
+ "multi": False,
+ "selected": [],
+ }
+ return None
+
+def _render_ask_user_result(event, selected=None, cancelled=False):
+ question = str(event.get("question") or "请选择下一步操作:").strip() or "请选择下一步操作:"
+ candidates = event.get("candidates") or []
+ lines = [question, "", "选项:"]
+ for idx, candidate in enumerate(candidates, start=1):
+ lines.append(f"{idx}. {candidate}")
+ lines.append(f"{len(candidates) + 1}. {_ASK_CANCEL_LABEL}")
+ lines.append("")
+ if cancelled:
+ lines.append(f"已取消:{_ASK_CANCEL_LABEL}")
+ elif selected:
+ lines.append(f"已选择:{selected}")
+ text = "\n".join(lines)
+ if len(text) > MessageLimit.MAX_TEXT_LENGTH:
+ text = text[:MessageLimit.MAX_TEXT_LENGTH - 18].rstrip() + "\n...[truncated]"
+ return text
+
+async def _clear_ask_reply_markup(query):
+ try:
+ await query.edit_message_reply_markup(reply_markup=None)
+ except Exception as exc:
+ print(f"[TG ask_user menu cleanup] {type(exc).__name__}: {exc}", flush=True)
+
+async def _edit_ask_user_result(query, event, selected=None, cancelled=False):
+ try:
+ await query.edit_message_text(
+ _render_ask_user_result(event, selected=selected, cancelled=cancelled),
+ reply_markup=None,
+ )
+ except Exception as exc:
+ print(f"[TG ask_user menu edit] {type(exc).__name__}: {exc}", flush=True)
+ await _clear_ask_reply_markup(query)
+
+async def _send_ask_user_menu(root_msg, event):
+ menu_id = uuid.uuid4().hex[:16]
+ candidates = event["candidates"]
+ multi = bool(event.get("multi"))
+ _ask_menu_store[menu_id] = {
+ "question": event["question"],
+ "candidates": list(candidates),
+ "multi": multi,
+ "selected": [],
+ }
+ prompt = f"{event['question']}\n\n{_ASK_MULTI_HINT}" if multi else event["question"]
+ try:
+ await root_msg.reply_text(
+ prompt,
+ reply_markup=_build_ask_user_markup(menu_id, candidates, multi=multi),
+ )
+ except Exception as exc:
+ _ask_menu_store.pop(menu_id, None)
+ print(f"[TG ask_user menu error] {type(exc).__name__}: {exc}", flush=True)
+ fallback = event["question"] + "\n" + "\n".join(f"- {candidate}" for candidate in candidates)
+ await root_msg.reply_text(fallback)
+
+class _TelegramStreamSession:
+ def __init__(self, root_msg):
+ self.root_msg = root_msg
+ self.private_chat = getattr(getattr(root_msg, "chat", None), "type", "") == ChatType.PRIVATE
+ self.can_use_draft = self.private_chat # update tg client!
+ self.draft_id = _make_draft_id()
+ self.live_msg = None
+ self.raw_text = ""
+ self.files = []
+ self.sent_segments = 0
+ self.active_display = ""
+ self.pending_display = ""
+ self._edit_overflow_msgs = {}
+ self.retry_until = 0.0
+ self.last_update_at = 0.0
+ self.last_update_raw_len = 0
+
+ def _now(self):
+ return time.monotonic()
+
+ def _retry_after_seconds(self, exc):
+ retry_after = getattr(exc, "_retry_after", None)
+ if retry_after is None:
+ retry_after = getattr(exc, "retry_after", 0) or 0
+ if hasattr(retry_after, "total_seconds"):
+ retry_after = retry_after.total_seconds()
+ try:
+ return max(0.0, float(retry_after))
+ except (TypeError, ValueError):
+ return 0.0
+
+ def _set_retry_after(self, exc):
+ wait_seconds = self._retry_after_seconds(exc) + _RETRY_AFTER_MARGIN_SECONDS
+ self.retry_until = max(self.retry_until, self._now() + wait_seconds)
+
+ def _is_retrying(self):
+ return self._now() < self.retry_until
+
+ async def _wait_for_retry(self):
+ remaining = self.retry_until - self._now()
+ if remaining > 0:
+ await asyncio.sleep(remaining)
+
+ def _should_stream_update(self, display):
+ if display == self.active_display:
+ return False
+ if self.last_update_at <= 0:
+ return True
+ elapsed = self._now() - self.last_update_at
+ raw_delta = len(self.raw_text) - self.last_update_raw_len
+ return elapsed >= _STREAM_UPDATE_INTERVAL_SECONDS or raw_delta >= _STREAM_MIN_UPDATE_CHARS
+
+ def _mark_stream_update(self, display):
+ self.active_display = display
+ self.pending_display = ""
+ self.last_update_at = self._now()
+ self.last_update_raw_len = len(self.raw_text)
+
+ def _stream_display(self, text):
+ base = (text or _DRAFT_HINT).strip() or _DRAFT_HINT
+ safe_parts = _markdown_safe_segments(base)
+ base = safe_parts[-1] if safe_parts else _DRAFT_HINT
+ if base == _DRAFT_HINT:
+ return base
+ display = base + _STREAM_SUFFIX
+ if len(_to_markdown_v2(display)) <= MessageLimit.MAX_TEXT_LENGTH:
+ return display
+ return base
+
+ async def prime(self):
+ if self.can_use_draft:
+ draft_result = await self._send_draft(_DRAFT_HINT)
+ if draft_result is True:
+ self.active_display = _DRAFT_HINT
+ return
+ if draft_result is None:
+ self.active_display = _DRAFT_HINT
+ return
+ try:
+ await self._upsert_live_message(_DRAFT_HINT, wait_retry=False)
+ except RetryAfter:
+ self.active_display = _DRAFT_HINT
+ return
+ self.active_display = _DRAFT_HINT
+
+ async def add_chunk(self, chunk):
+ if not chunk:
+ return
+ self.raw_text += chunk
+ await self._refresh(done=False, send_files=False)
+
+ async def finalize(self, full_text=None, send_files=True):
+ if full_text is not None:
+ self.raw_text = full_text
+ await self._refresh(done=True, send_files=send_files)
+
+ async def finish_with_notice(self, notice):
+ if self.raw_text.strip():
+ await self.finalize(send_files=False)
+ await self._reply_text(notice)
+ return
+ if self.live_msg is not None:
+ await self._edit_text(self.live_msg, notice)
+ self.live_msg = None
+ self.active_display = ""
+ return
+ await self._reply_text(notice)
+ self.active_display = ""
+
+ async def _refresh(self, done, send_files):
+ summary = _extract_turn_summary(self.raw_text)
+ cleaned = clean_reply(self.raw_text) if self.raw_text.strip() else ""
+ self.files = _files_from_text(cleaned)
+ body = _inject_turn_summary(_render_file_markers(cleaned), summary)
+ if done and not body and self.files:
+ body = "已生成附件"
+ elif done and not body:
+ body = "..."
+ segments = _visible_segments(body)
+ finalized_target = len(segments) if done else max(len(segments) - 1, 0)
+ while self.sent_segments < finalized_target:
+ await self._finalize_segment(segments[self.sent_segments])
+ self.sent_segments += 1
+ if done:
+ if send_files:
+ await self._send_files()
+ return
+ active_text = segments[-1] if segments else _DRAFT_HINT
+ await self._stream_active(active_text)
+
+ async def _stream_active(self, text):
+ display = self._stream_display(text)
+ if display == self.active_display:
+ return
+ self.pending_display = display
+ if self._is_retrying() or not self._should_stream_update(display):
+ return
+ try:
+ if self.can_use_draft:
+ draft_result = await self._send_draft(display)
+ if draft_result is True:
+ self._mark_stream_update(display)
+ return
+ if draft_result is None:
+ return
+ await self._upsert_live_message(display, wait_retry=False)
+ self._mark_stream_update(display)
+ except RetryAfter:
+ return
+
+ async def _finalize_segment(self, text):
+ final_text = (text or "").strip() or "..."
+ if self.live_msg is not None:
+ await self._edit_text(self.live_msg, final_text)
+ self.live_msg = None
+ else:
+ await self._reply_text(final_text)
+ self.active_display = ""
+ if self.can_use_draft:
+ self.draft_id = _make_draft_id()
+
+ async def _send_files(self):
+ await _send_files(self.root_msg, self.files)
+
+ async def _send_draft(self, text):
+ try:
+ await self.root_msg.reply_text_draft(
+ self.draft_id,
+ _to_markdown_v2(text),
+ parse_mode=ParseMode.MARKDOWN_V2,
+ )
+ return True
+ except RetryAfter as exc:
+ self._set_retry_after(exc)
+ return None
+ except Exception as exc:
+ if _is_not_modified_error(exc):
+ return True
+ print(f"[TG draft fallback] {type(exc).__name__}: {exc}", flush=True)
+ self.can_use_draft = False
+ self.draft_id = _make_draft_id()
+ return False
+
+ async def _retry_call(self, func, *args):
+ while True:
+ await self._wait_for_retry()
+ try:
+ return await func(*args)
+ except RetryAfter as exc:
+ self._set_retry_after(exc)
+
+ async def _reply_text_once(self, text):
+ markdown = _to_markdown_v2(text)
+ try:
+ return await self.root_msg.reply_text(markdown, parse_mode=ParseMode.MARKDOWN_V2)
+ except RetryAfter as exc:
+ self._set_retry_after(exc)
+ raise
+ except Exception as exc:
+ if _is_not_modified_error(exc):
+ return None
+ try:
+ return await self.root_msg.reply_text(text)
+ except RetryAfter as retry_exc:
+ self._set_retry_after(retry_exc)
+ raise
+
+ async def _reply_text(self, text, wait_retry=True):
+ last_msg = None
+ for segment in _markdown_safe_segments(text) or ["..."]:
+ if wait_retry:
+ last_msg = await self._retry_call(self._reply_text_once, segment)
+ else:
+ last_msg = await self._reply_text_once(segment)
+ return last_msg
+
+ async def _edit_text_once(self, msg, text):
+ markdown = _to_markdown_v2(text)
+ try:
+ updated = await msg.edit_text(markdown, parse_mode=ParseMode.MARKDOWN_V2)
+ except RetryAfter as exc:
+ self._set_retry_after(exc)
+ raise
+ except Exception as exc:
+ if _is_not_modified_error(exc):
+ return msg
+ try:
+ updated = await msg.edit_text(text)
+ except RetryAfter as retry_exc:
+ self._set_retry_after(retry_exc)
+ raise
+ return updated if hasattr(updated, "edit_text") else msg
+
+ def _message_key(self, msg):
+ chat_id = getattr(getattr(msg, "chat", None), "id", None)
+ message_id = getattr(msg, "message_id", None)
+ if chat_id is not None and message_id is not None:
+ return (chat_id, message_id)
+ if message_id is not None:
+ return ("message", message_id)
+ return ("object", id(msg))
+
+ async def _delete_text_once(self, msg):
+ delete = getattr(msg, "delete", None)
+ if delete is None:
+ return
+ try:
+ result = delete()
+ if hasattr(result, "__await__"):
+ await result
+ except RetryAfter as exc:
+ self._set_retry_after(exc)
+ raise
+ except Exception as exc:
+ print(f"[TG stale overflow delete error] {type(exc).__name__}: {exc}", flush=True)
+
+ async def _delete_text(self, msg, wait_retry=True):
+ if wait_retry:
+ await self._retry_call(self._delete_text_once, msg)
+ else:
+ await self._delete_text_once(msg)
+
+ async def _edit_text(self, msg, text, wait_retry=True):
+ segments = _markdown_safe_segments(text) or ["..."]
+ old_key = self._message_key(msg)
+ overflow_msgs = self._edit_overflow_msgs.get(old_key, [])
+ if wait_retry:
+ updated = await self._retry_call(self._edit_text_once, msg, segments[0])
+ else:
+ updated = await self._edit_text_once(msg, segments[0])
+ primary_msg = updated if hasattr(updated, "edit_text") else msg
+ self._edit_overflow_msgs.pop(old_key, None)
+
+ new_overflow_msgs = []
+ for index, segment in enumerate(segments[1:]):
+ if index < len(overflow_msgs):
+ overflow_msg = overflow_msgs[index]
+ if wait_retry:
+ edited_overflow = await self._retry_call(self._edit_text_once, overflow_msg, segment)
+ else:
+ edited_overflow = await self._edit_text_once(overflow_msg, segment)
+ new_overflow_msgs.append(
+ edited_overflow if hasattr(edited_overflow, "edit_text") else overflow_msg
+ )
+ else:
+ new_overflow_msgs.append(await self._reply_text(segment, wait_retry=wait_retry))
+
+ for stale_msg in overflow_msgs[len(new_overflow_msgs):]:
+ await self._delete_text(stale_msg, wait_retry=wait_retry)
+
+ if new_overflow_msgs:
+ self._edit_overflow_msgs[self._message_key(primary_msg)] = new_overflow_msgs
+ return primary_msg
+
+ async def _upsert_live_message(self, text, wait_retry=True):
+ if self.live_msg is None:
+ self.live_msg = await self._reply_text(text, wait_retry=wait_retry)
+ else:
+ self.live_msg = await self._edit_text(self.live_msg, text, wait_retry=wait_retry)
+
+
+class _TelegramTurnStreamCoordinator:
+ def __init__(self, root_msg):
+ self.root_msg = root_msg
+ self.session = None
+ self.pending_line = ""
+ self.code_fence_len = 0
+ self.last_turn = 0
+
+ async def prime(self):
+ await self._ensure_session()
+
+ async def add_chunk(self, chunk):
+ if not chunk:
+ return
+ text = self.pending_line + chunk
+ self.pending_line = ""
+ for line in text.splitlines(keepends=True):
+ if _line_complete(line):
+ await self._process_line(line)
+ elif _maybe_partial_turn_marker(line) or _maybe_partial_code_fence(line):
+ self.pending_line = line
+ else:
+ await self._process_line(line)
+
+ async def finalize(self, done_text="", send_files=True):
+ await self._flush_pending_line()
+ if self.session is None:
+ if done_text:
+ await self._add_to_current(done_text)
+ elif not self.session.raw_text.strip() and done_text:
+ await self.session.finalize(done_text, send_files=False)
+ if send_files:
+ await _send_files_from_text(self.root_msg, done_text)
+ return
+ if self.session is not None:
+ await self.session.finalize(send_files=False)
+ if send_files:
+ await _send_files_from_text(self.root_msg, done_text)
+
+ async def finish_with_notice(self, notice):
+ await self._flush_pending_line()
+ await self._ensure_session()
+ await self.session.finish_with_notice(notice)
+
+ async def _ensure_session(self):
+ if self.session is None:
+ self.session = _TelegramStreamSession(self.root_msg)
+ await self.session.prime()
+
+ async def _start_turn(self, marker):
+ if self.session is not None and self.session.raw_text.strip():
+ await self.session.finalize(send_files=False)
+ self.session = None
+ await self._ensure_session()
+ await self.session.add_chunk(marker)
+
+ async def _add_to_current(self, text):
+ if not text:
+ return
+ await self._ensure_session()
+ await self.session.add_chunk(text)
+
+ async def _process_line(self, line):
+ turn_no = _turn_marker_number(line)
+ if self.code_fence_len == 0 and turn_no == self.last_turn + 1:
+ self.last_turn = turn_no
+ await self._start_turn(line)
+ return
+ await self._add_to_current(line)
+ self._update_code_fence(line)
+
+ async def _flush_pending_line(self):
+ if not self.pending_line:
+ return
+ line = self.pending_line
+ self.pending_line = ""
+ await self._add_to_current(line)
+
+ def _update_code_fence(self, line):
+ match = _CODE_FENCE_RE.match(line or "")
+ if not match:
+ return
+ fence_len = len(match.group(1))
+ if self.code_fence_len:
+ if fence_len >= self.code_fence_len:
+ self.code_fence_len = 0
+ return
+ self.code_fence_len = fence_len
+
+async def _stream(dq, msg):
+ stream = _TelegramTurnStreamCoordinator(msg)
+ await stream.prime()
+ try:
+ while True:
+ try: first = await asyncio.to_thread(dq.get, True, _QUEUE_WAIT_SECONDS)
+ except Q.Empty: continue
+ items = [first]
+ try:
+ while True: items.append(dq.get_nowait())
+ except Q.Empty: pass
+ done_item = None
+ for item in items:
+ chunk = item.get("next", "")
+ if chunk:
+ await stream.add_chunk(chunk)
+ if "done" in item:
+ done_item = item
+ break
+ if done_item is not None:
+ await stream.finalize(done_item.get("done", ""))
+ event = _drain_latest_ask_user_event()
+ if event:
+ await _send_ask_user_menu(msg, event)
+ break
+ except asyncio.CancelledError:
+ await stream.finish_with_notice("⏹️ 已停止")
+ except RetryAfter as exc:
+ print(f"[TG stream retry_after] {type(exc).__name__}: {exc}", flush=True)
+ if stream.session is not None:
+ stream.session._set_retry_after(exc)
+ except Exception as exc:
+ print(f"[TG stream error] {type(exc).__name__}: {exc}", flush=True)
+ if stream.session is not None and stream.session._is_retrying():
+ return
+ try:
+ await stream.finish_with_notice(f"❌ 输出失败: {exc}")
+ except RetryAfter as retry_exc:
+ print(f"[TG stream error notice retry_after] {type(retry_exc).__name__}: {retry_exc}", flush=True)
+
+def _normalized_command(text):
+ parts = (text or "").strip().split(None, 1)
+ if not parts: return ''
+ head = parts[0].lower()
+ if head.startswith('/'): head = '/' + head[1:].split('@', 1)[0]
+ return head + (f" {parts[1].strip()}" if len(parts) > 1 and parts[1].strip() else '')
+
+def _cancel_stream_task(ctx):
+ task = ctx.user_data.pop('stream_task', None)
+ if task and not task.done(): task.cancel()
+
+async def _sync_commands(application):
+ await application.bot.set_my_commands([BotCommand(command, description) for command, description in TELEGRAM_MENU_COMMANDS])
+
+async def _reply_command_text(message, text):
+ for segment in _markdown_safe_segments(text) or ["..."]:
+ try:
+ await message.reply_text(_to_markdown_v2(segment), parse_mode=ParseMode.MARKDOWN_V2)
+ except Exception as exc:
+ print(f"[TG command markdown fallback] {type(exc).__name__}: {exc}", flush=True)
+ await message.reply_text(segment)
+
+def _review_command_body(cmd):
+ cmd = (cmd or "").strip()
+ if cmd == "/review":
+ return ""
+ if cmd.startswith("/review "):
+ return cmd[len("/review"):].strip()
+ return ""
+
+async def _handle_review_command(update, ctx, cmd):
+ dq = Q.Queue()
+ prompt = handle_review_command(agent, _review_command_body(cmd), dq)
+ if not prompt:
+ try:
+ item = dq.get_nowait()
+ return await _reply_command_text(update.message, item.get("done", ""))
+ except Q.Empty:
+ return await _reply_command_text(update.message, "(review 无输出)")
+ _cancel_stream_task(ctx)
+ task_dq = agent.put_task(prompt, source="telegram")
+ task = asyncio.create_task(_stream(task_dq, update.message))
+ ctx.user_data['stream_task'] = task
+
+async def handle_msg(update, ctx):
+ uid = update.effective_user.id
+ if ALLOWED and uid not in ALLOWED:
+ return await update.message.reply_text("no")
+ prompt = _build_text_prompt(update.message.text)
+ dq = agent.put_task(prompt, source="telegram")
+ task = asyncio.create_task(_stream(dq, update.message))
+ ctx.user_data['stream_task'] = task
+
+async def handle_ask_callback(update, ctx):
+ query = update.callback_query
+ if query is None:
+ return
+ uid = update.effective_user.id if update.effective_user else None
+ if ALLOWED and uid not in ALLOWED:
+ return await query.answer("no", show_alert=True)
+ menu_id, action = _parse_ask_callback_data(query.data)
+ if not menu_id:
+ return await query.answer("菜单无效")
+ event = _normalize_ask_menu_event(_ask_menu_store.get(menu_id))
+ if event is None:
+ await query.answer("菜单已过期")
+ return await _clear_ask_reply_markup(query)
+ candidates = event["candidates"]
+ if event.get("multi") and action.startswith(f"{_ASK_TOGGLE_ACTION}:"):
+ try:
+ selected_idx = int(action.split(":", 1)[1])
+ if selected_idx < 0 or selected_idx >= len(candidates):
+ raise ValueError
+ except ValueError:
+ return await query.answer("菜单无效")
+ stored = _ask_menu_store.get(menu_id)
+ if not isinstance(stored, dict):
+ return await query.answer("菜单已过期")
+ selected = set(stored.get("selected", []))
+ if selected_idx in selected:
+ selected.remove(selected_idx)
+ else:
+ selected.add(selected_idx)
+ stored["selected"] = sorted(selected)
+ await query.answer()
+ return await query.edit_message_reply_markup(
+ reply_markup=_build_ask_user_markup(
+ menu_id,
+ candidates,
+ multi=True,
+ selected_indexes=stored["selected"],
+ )
+ )
+ if event.get("multi") and action == _ASK_MULTI_DONE_ACTION:
+ selected_indexes = event.get("selected") or []
+ if not selected_indexes:
+ return await query.answer(_ASK_MULTI_EMPTY_HINT, show_alert=True)
+ selected = "; ".join(candidates[idx] for idx in selected_indexes)
+ _ask_menu_store.pop(menu_id, None)
+ await query.answer()
+ await _edit_ask_user_result(query, event, selected=selected)
+ if query.message is None:
+ return
+ dq = agent.put_task(_build_text_prompt(selected), source="telegram")
+ task = asyncio.create_task(_stream(dq, query.message))
+ ctx.user_data['stream_task'] = task
+ return
+ if action == _ASK_CANCEL_ACTION:
+ _ask_menu_store.pop(menu_id, None)
+ await query.answer()
+ await _edit_ask_user_result(query, event, cancelled=True)
+ if query.message is not None:
+ await query.message.reply_text(_ASK_CANCEL_PROMPT)
+ return
+ try:
+ selected = candidates[int(action)]
+ except (ValueError, IndexError):
+ return await query.answer("菜单无效")
+ _ask_menu_store.pop(menu_id, None)
+ await query.answer()
+ await _edit_ask_user_result(query, event, selected=selected)
+ if query.message is None:
+ return
+ dq = agent.put_task(_build_text_prompt(selected), source="telegram")
+ task = asyncio.create_task(_stream(dq, query.message))
+ ctx.user_data['stream_task'] = task
+
+async def _send_llm_menu(message):
+ llms = agent.list_llms()
+ if not llms:
+ return await message.reply_text("没有可用模型。")
+ menu_id = uuid.uuid4().hex[:16]
+ _llm_menu_store[menu_id] = [idx for idx, _, _ in llms]
+ lines = [f"{'→' if cur else ' '} [{idx}] {name}" for idx, name, cur in llms]
+ try:
+ await message.reply_text(
+ _LLM_MENU_PROMPT,
+ reply_markup=_build_llm_markup(menu_id, llms),
+ )
+ except Exception as exc:
+ _llm_menu_store.pop(menu_id, None)
+ print(f"[TG llm menu error] {type(exc).__name__}: {exc}", flush=True)
+ await message.reply_text("LLMs:\n" + "\n".join(lines))
+
+async def handle_llm_callback(update, ctx):
+ query = update.callback_query
+ if query is None:
+ return
+ uid = update.effective_user.id if update.effective_user else None
+ if ALLOWED and uid not in ALLOWED:
+ return await query.answer("no", show_alert=True)
+ menu_id, action = _parse_menu_callback_data(query.data, _LLM_CALLBACK_PREFIX)
+ if not menu_id:
+ return await query.answer("菜单无效")
+ valid_indexes = _llm_menu_store.get(menu_id)
+ if valid_indexes is None:
+ await query.answer("菜单已过期")
+ return await _clear_ask_reply_markup(query)
+ try:
+ selected_idx = int(action)
+ except (TypeError, ValueError):
+ return await query.answer("菜单无效")
+ if selected_idx not in valid_indexes:
+ return await query.answer("菜单已过期", show_alert=True)
+ try:
+ agent.next_llm(selected_idx)
+ selected_name = agent.get_llm_name()
+ except Exception as exc:
+ return await query.answer(f"切换失败: {exc}", show_alert=True)
+ _llm_menu_store.pop(menu_id, None)
+ await query.answer(f"已切换到 [{selected_idx}] {selected_name}")
+ await query.edit_message_text(f"✅ 已切换到 [{selected_idx}] {selected_name}")
+
+async def cmd_abort(update, ctx):
+ _cancel_stream_task(ctx)
+ agent.abort()
+ await update.message.reply_text("⏹️ 正在停止...")
+
+async def cmd_llm(update, ctx):
+ args = (update.message.text or '').split()
+ if len(args) > 1:
+ try:
+ n = int(args[1])
+ agent.next_llm(n)
+ await update.message.reply_text(f"✅ 已切换到 [{agent.llm_no}] {agent.get_llm_name()}")
+ except (ValueError, IndexError):
+ await update.message.reply_text(f"用法: /llm <0-{len(agent.list_llms())-1}>")
+ else:
+ await _send_llm_menu(update.message)
+
+async def handle_photo(update, ctx):
+ uid = update.effective_user.id
+ if ALLOWED and uid not in ALLOWED: return await update.message.reply_text("no")
+ if update.message.photo:
+ photo = update.message.photo[-1]
+ file = await photo.get_file()
+ fpath = f"tg_{photo.file_unique_id}.jpg"
+ kind = "图片"
+ elif update.message.document:
+ doc = update.message.document
+ file = await doc.get_file()
+ ext = os.path.splitext(doc.file_name or '')[1] or ''
+ fpath = f"tg_{doc.file_unique_id}{ext}"
+ kind = "文件"
+ else: return
+ await file.download_to_drive(os.path.join(_TEMP_DIR, fpath))
+ caption = update.message.caption
+ prompt = f"[TIPS] 收到{kind}temp/{fpath}\n{caption}" if caption else f"[TIPS] 收到{kind}temp/{fpath},请等待下一步指令"
+ dq = agent.put_task(prompt, source="telegram")
+ task = asyncio.create_task(_stream(dq, update.message))
+ ctx.user_data['stream_task'] = task
+
+async def handle_command(update, ctx):
+ uid = update.effective_user.id
+ if ALLOWED and uid not in ALLOWED:
+ return await update.message.reply_text("no")
+ cmd = _normalized_command(update.message.text)
+ op = cmd.split()[0] if cmd else ''
+ if op == '/help': return await update.message.reply_text(HELP_TEXT)
+ if op == '/status':
+ llm = agent.get_llm_name() if agent.llmclient else '未配置'
+ return await update.message.reply_text(f"状态: {'🔴 运行中' if agent.is_running else '🟢 空闲'}\nLLM: [{agent.llm_no}] {llm}")
+ if op == '/stop': return await cmd_abort(update, ctx)
+ if op == '/llm': return await cmd_llm(update, ctx)
+ if op == '/btw':
+ answer = await asyncio.to_thread(handle_btw_frontend_command, agent, cmd)
+ return await _reply_command_text(update.message, answer)
+ if op == '/review':
+ return await _handle_review_command(update, ctx, cmd)
+ if op == '/new':
+ _cancel_stream_task(ctx)
+ return await update.message.reply_text(reset_conversation(agent))
+ if op == '/restore':
+ _cancel_stream_task(ctx)
+ try:
+ restored_info, err = format_restore()
+ if err:
+ return await update.message.reply_text(err)
+ restored, fname, count = restored_info
+ agent.abort()
+ agent.history.extend(restored)
+ return await update.message.reply_text(f"✅ 已恢复 {count} 轮对话\n来源: {fname}\n(仅恢复上下文,请输入新问题继续)")
+ except Exception as e:
+ return await update.message.reply_text(f"❌ 恢复失败: {e}")
+ if op == '/continue':
+ if cmd != '/continue': _cancel_stream_task(ctx)
+ return await update.message.reply_text(handle_frontend_command(agent, cmd))
+ return await update.message.reply_text(HELP_TEXT)
+
+if __name__ == '__main__':
+ _LOCK_SOCK = ensure_single_instance(19527, "Telegram")
+ if not ALLOWED:
+ print('[Telegram] ERROR: tg_allowed_users in mykey.py is empty or missing. Set it to avoid unauthorized access.')
+ sys.exit(1)
+ require_runtime(agent, "Telegram", tg_bot_token=mykeys.get("tg_bot_token"))
+ redirect_log(__file__, "tgapp.log", "Telegram", ALLOWED)
+ _register_ask_user_hook()
+ threading.Thread(target=agent.run, daemon=True).start()
+ proxy = mykeys.get('proxy')
+ if proxy:
+ print('proxy:', proxy)
+ else:
+ print('proxy: ')
+
+ async def _error_handler(update, context: ContextTypes.DEFAULT_TYPE):
+ print(f"[{time.strftime('%m-%d %H:%M')}] TG error: {context.error}", flush=True)
+
+ while True:
+ try:
+ print(f"TG bot starting... {time.strftime('%m-%d %H:%M')}")
+ # Recreate request and app objects on each restart to avoid stale connections
+ request_kwargs = dict(read_timeout=30, write_timeout=30, connect_timeout=30, pool_timeout=30)
+ if proxy:
+ request_kwargs['proxy'] = proxy
+ request = HTTPXRequest(**request_kwargs)
+ app = (ApplicationBuilder().token(mykeys['tg_bot_token'])
+ .request(request).get_updates_request(request).post_init(_sync_commands).build())
+ app.add_handler(CallbackQueryHandler(handle_ask_callback, pattern=r"^ask:"))
+ app.add_handler(CallbackQueryHandler(handle_llm_callback, pattern=r"^llm:"))
+ app.add_handler(MessageHandler(filters.COMMAND, handle_command))
+ app.add_handler(MessageHandler(filters.PHOTO, handle_photo))
+ app.add_handler(MessageHandler(filters.Document.ALL, handle_photo))
+ app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_msg))
+ app.add_error_handler(_error_handler)
+ app.run_polling(drop_pending_updates=True, poll_interval=1.0, timeout=30)
+ except Exception as e:
+ print(f"[{time.strftime('%m-%d %H:%M')}] polling crashed: {e}", flush=True)
+ time.sleep(10)
+ asyncio.set_event_loop(asyncio.new_event_loop())
diff --git a/frontends/tui_v3.py b/frontends/tui_v3.py
new file mode 100644
index 000000000..23f5c9c08
--- /dev/null
+++ b/frontends/tui_v3.py
@@ -0,0 +1,6095 @@
+"""tui_v3 — scrollback-first TUI for GenericAgent, consolidated.
+
+Merged from frontends/tui/ (cjk, clipboard, renderer, protocol, core/sb)
+into a single file so the v3 frontend ships as one drop-in module.
+Run: `python -m frontends.tui_v3` or `python frontends/tui_v3.py`.
+"""
+from __future__ import annotations
+
+import asyncio, atexit, json, locale, logging, os, queue, random, re, select, shutil, signal, subprocess
+import sys, tempfile, threading, time
+
+_IS_WINDOWS = os.name == 'nt'
+
+
+# Make `frontends/` parent (project root) importable so `from agentmain import …`
+# works whether this file is run as `python -m frontends.tui_v3` or directly
+# via `python frontends/tui_v3.py`.
+_proj_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+_front_dir = os.path.dirname(os.path.abspath(__file__))
+for _p in (_proj_root, _front_dir):
+ if _p not in sys.path:
+ sys.path.insert(0, _p)
+
+from agentmain import GeneraticAgent
+from dataclasses import dataclass
+from dataclasses import dataclass, field
+from functools import lru_cache
+from io import StringIO
+from rich.cells import cell_len
+from rich.console import Console
+from rich.markdown import Markdown
+from rich.text import Text
+from rich.theme import Theme
+from typing import Callable
+from frontends import at_complete, workspace_cmd # @ 补全 + /workspace(与 v2 共用)
+
+# ════════════════════════════════════════════════════════════════════════════
+# i18n — minimal dict-based zh/en translation layer (inlined; was tui_v3_i18n.py)
+#
+# - Single nested dict `_I18N[lang][key]` → format string.
+# - `t(key, **fmt)` returns the formatted string for the current language;
+# falls back to English, then to the key itself (so missing keys are visible).
+# - Language detection: persisted user choice > system locale > 'en'.
+# - Persistence: temp/tui_v3_settings.json (workspace-local, matches v2 pattern).
+#
+# Strings that intentionally stay single-language (English):
+# - Spinner gerunds (Pondering/Brewing/...) — ported from v2.
+# - Tech jargon embedded in zh strings: 'tokens', 'ctx', 'model', 'session', …
+# ════════════════════════════════════════════════════════════════════════════
+
+_SETTINGS_PATH = os.path.join(
+ os.path.dirname(os.path.abspath(__file__)), "..", "temp", "tui_v3_settings.json"
+)
+
+_SUPPORTED = ('zh', 'en')
+_DEFAULT = 'en'
+
+_LANG: str = _DEFAULT
+
+
+# ---------------- spinner gerunds (English only, from v2) ----------------
+
+SPINNER_GERUNDS = (
+ "Pondering", "Reticulating", "Sleuthing", "Hatching", "Pouncing",
+ "Brewing", "Sharpening", "Untangling", "Compiling", "Unraveling",
+ "Distilling", "Calibrating", "Marinating", "Conjuring", "Foraging",
+ "Spelunking", "Synthesizing", "Refactoring thoughts", "Tracing breadcrumbs",
+ "Following the rabbit hole",
+ "Routing", "Threading", "Polling", "Spinning", "Hooking",
+ "Patching", "Caching", "Yielding", "Hydrating", "Folding",
+ "Streaming", "Resolving", "Reaping", "Tuning",
+)
+
+
+# Language display names (always shown in their own script — never translated,
+# so users always see them in a form they recognize).
+LANG_LABELS = {
+ 'zh': '简体中文',
+ 'en': 'English',
+}
+
+
+# Rotating usage tips — one picked per launch, shown in the banner (v2 _TIPS).
+# Only covers features v3 has actually adapted; the en/zh lists run parallel.
+_TIPS = {
+ 'en': [
+ "Tip: press / to open the command palette — arrow keys to pick, Enter drops it into the input box.",
+ "Tip: pasted images / files fold into [Image #N] / [File #N] placeholders; backspace deletes the whole block.",
+ "Tip: /btw lets a side-agent answer without interrupting the main task.",
+ "Tip: /rewind [n] rewinds the last n turns; /stop aborts the current task.",
+ "Tip: /continue lists past sessions — arrow keys to pick, Enter to restore.",
+ "Tip: Ctrl+J / Shift+Enter inserts a newline in multi-line input; Enter sends.",
+ "Tip: put [multi-select] in an ask_user prompt to switch to a multi-pick picker.",
+ "Tip: /cost shows token usage; /llm views / switches the model.",
+ "Tip: /new [name] starts a fresh session; /language switches the interface language.",
+ "Tip: /export clip copies the last reply to your system clipboard; /export all prints the log path.",
+ "Tip: Ctrl+O folds / unfolds all completed tool chips — each fold collapses to one line.",
+ "Tip: /update auto-runs git pull and audits the impact; /autorun seeds an autonomous run.",
+ "Tip: /morphling absorbs an external skill.",
+ "Tip: /goal enters Goal mode (will ask for budget / worker cap); /hive for multi-worker.",
+ "Tip: /conductor hands the task to frontends/conductor.py for multi-subagent orchestration.",
+ "Tip: /update runs a dual-branch upstream sync — previews the diff before fast-forwarding either side.",
+ "Tip: /scheduler is live — untick a running row to stop it; tick again to relaunch.",
+ "Tip: Ctrl+S stashes your draft input — it's waiting for you next time you open a picker.",
+ "Tip: /scheduler lists reflect tasks and starts them via `/scheduler start a,b,c`.",
+ "Tip: prefix `!` runs the rest as a host shell command — output is folded into LLM history.",
+ "Tip: /resume lists recent sessions you can pick from to restore prior context.",
+ ],
+ 'zh': [
+ "Tip: 按 / 唤起命令面板 —— 方向键选择,Enter 落入输入框。",
+ "Tip: 粘贴图片 / 文件会折叠成 [Image #N] / [File #N] 占位符,退格可整块删除。",
+ "Tip: /btw <问题> 让 side-agent 回答而不打断主任务。",
+ "Tip: /rewind [n] 回退最近 n 轮对话;/stop 中止当前任务。",
+ "Tip: /continue 列出历史会话 —— 方向键选择,Enter 恢复。",
+ "Tip: 多行输入用 Ctrl+J / Shift+Enter 换行;Enter 直接发送。",
+ "Tip: ask_user 题目里写 [多选] 会自动切到多选 picker。",
+ "Tip: /cost 查看 token 用量;/llm 查看 / 切换模型。",
+ "Tip: /new [name] 新建会话;/language 切换界面语言。",
+ "Tip: /export clip 把最后回复复制到系统剪贴板;/export all 打印日志路径。",
+ "Tip: Ctrl+O 折叠 / 展开所有已完成的工具 chip —— 每个 chip 折叠成一行。",
+ "Tip: /conductor <任务> 直接交给 frontends/conductor.py 做多 subagent 编排。",
+ "Tip: /update 是双分支 upstream 同步 —— 先 diff 预演,再分别快进。",
+ "Tip: /scheduler 里再点一下已勾选的任务可以 stop —— 取消勾选 = 停止。",
+ "Tip: Ctrl+S 把当前输入 stash 起来,下次 / 打开 picker 时还在。",
+ "Tip: 以 `!` 开头直接跑 shell —— 命令与输出都会进入 LLM 历史,agent 可以引用。",
+ "Tip: /resume 列出最近会话,可挑选一个恢复之前的上下文。",
+ ],
+}
+
+
+# ---------------- translations ----------------
+# Keys are dot-namespaced. Format placeholders use {name}.
+
+_I18N: dict[str, dict[str, str]] = {
+ 'en': {
+ # /help intro & rows — phrasing mirrors v2's COMMANDS table.
+ 'help.title': 'Commands:',
+ 'help.help': ' /help Show help',
+ 'help.status': ' /status View session status',
+ 'help.sessions': ' /sessions List all sessions',
+ 'help.llm': ' /llm [n] View / switch model',
+ 'help.btw': ' /btw Side question — does not interrupt main agent',
+ 'help.review': ' /review [request] In-session code review (report inline)',
+ 'help.rewind': ' /rewind [n] Rewind the last n rounds',
+ 'help.continue': ' /continue [n|name] List / restore historical sessions',
+ 'help.new': ' /new [name] Start a new session (clears the current one)',
+ 'help.rename': ' /rename Rename the current session',
+ 'help.clear': ' /clear Clear display (does not touch LLM history)',
+ 'help.cost': ' /cost Token usage for the current session',
+ 'help.verbose': ' /verbose Tool-call audit (↑↓ select · Enter switch · c copy · q quit)',
+ 'help.export': ' /export [sub] Export last reply: clip / file [name] / all',
+ 'help.stop': ' /stop Abort current task',
+ 'help.language': ' /language [code] View / switch interface language',
+ 'help.update': ' /update [note] Preview upstream commits & diff, then pull (no commit)',
+ 'help.autorun': ' /autorun [seed] Enter autonomous-operation mode',
+ 'help.morphling': ' /morphling [target] Distill / absorb an external skill',
+ 'help.goal': ' /goal [goal] Enter Goal mode (asks for budget / worker cap)',
+ 'help.hive': ' /hive [target] Enter Hive multi-worker mode',
+ 'help.conductor': ' /conductor [task] Hand task to conductor.py for multi-subagent run',
+ 'help.scheduler': ' /scheduler Multi-pick reflect tasks / view cron',
+ 'help.emoji': ' /emoji [style] Pick the spinner pet face (picker / direct switch)',
+ 'help.quit': ' /quit Quit',
+ 'help.esc': ' Esc Cancel ask · clear draft · stop task (no exit)',
+ 'help.cc': ' Ctrl+C × 2 Quit (when idle; only aborts the task while running)',
+ 'help.cl': ' Ctrl+L Force repaint (recover from sleep/wake)',
+ 'help.cz': ' Ctrl+Z / Ctrl+Y Undo / redo input edits',
+ 'help.shift_arrow': ' Shift+←→↑↓ Select text (Ctrl+C copy / Ctrl+X cut / Ctrl+A all)',
+
+ # _CMDS palette entries — same wording as /help, condensed for one-line hint.
+ 'cmd.help.desc': 'Show help',
+ 'cmd.status.desc': 'View session status',
+ 'cmd.llm.desc': 'View / switch model',
+ 'cmd.btw.desc': 'Side question — does not interrupt main agent',
+ 'cmd.review.desc': 'In-session code review',
+ 'cmd.rewind.desc': 'Rewind the last n rounds',
+ 'cmd.continue.desc': 'List / restore historical sessions',
+ 'cmd.new.desc': 'Start a new session',
+ 'cmd.rename.desc': 'Rename the current session',
+ 'cmd.clear.desc': 'Clear display (LLM history untouched)',
+ 'cmd.cost.desc': 'Token usage for the current session',
+ 'cmd.verbose.desc': 'Tool-call audit',
+ 'cmd.export.desc': 'Export the last reply (clip/file/all)',
+ 'cmd.stop.desc': 'Abort current task',
+ 'cmd.language.desc': 'View / switch interface language',
+ 'cmd.quit.desc': 'Quit',
+
+ # _CMDS arg hints — mirror v2 (lowercase n, full word "question").
+ 'cmd.llm.arg': '[n]',
+ 'cmd.btw.arg': '',
+ 'cmd.review.arg': '[request]',
+ 'cmd.rewind.arg': '[n]',
+ 'cmd.continue.arg': '[n|name]',
+ 'cmd.new.arg': '[name]',
+ 'cmd.rename.arg': '',
+ 'cmd.export.arg': '[clip|file|all]',
+ 'cmd.language.arg': '[code]',
+ 'cmd.update.arg': '[note]',
+ 'cmd.update.desc': 'preview upstream commits & diff, then pull (no commit)',
+ 'cmd.autorun.arg': '[seed]',
+ 'cmd.autorun.desc': 'enter autonomous operation mode',
+ 'cmd.morphling.arg': '[target]',
+ 'cmd.morphling.desc': 'distill / absorb external skills',
+ 'cmd.goal.arg': '[goal]',
+ 'cmd.goal.desc': 'enter Goal mode (needs condition)',
+ 'cmd.hive.arg': '[target]',
+ 'cmd.hive.desc': 'enter Hive multi-worker mode',
+ 'cmd.conductor.arg': '[task]',
+ 'cmd.conductor.desc': 'hand task to frontends/conductor.py for multi-subagent orchestration',
+ 'cmd.scheduler.desc': 'multi-pick start/stop reflect tasks (cron is driven by reflect/scheduler.py)',
+ 'cmd.emoji.arg': '[style]',
+ 'cmd.emoji.desc': 'pick the spinner pet face — opens picker; arg switches directly',
+
+ # status line (one-liner above input box)
+ 'status.asking': '◉ waiting · Esc cancel',
+ 'status.running.tail': ' · Esc stop',
+ 'status.tps': ' · {rate:.0f} tok/s',
+ 'status.cc_confirm': 'Press Ctrl+C again to quit',
+ 'status.ready': '○ ready',
+
+ # /status output rows
+ 'status.title': ' Session status',
+ 'status.label.model': 'model:',
+ 'status.label.state': 'state:',
+ 'status.label.rounds': 'rounds:',
+ 'status.label.context': 'context:',
+ 'status.label.cwd': 'cwd:',
+ 'status.state.running': 'running · {verb} {elapsed}',
+ 'status.state.waiting': 'waiting · ask_user pending',
+ 'status.state.idle': 'idle',
+ 'status.ctx.unknown': 'n/a',
+ 'status.ctx.fmt': '{used:,} / {cap:,} ctx',
+
+ # banner
+ 'banner.label.model': 'model:',
+ 'banner.label.directory': 'directory:',
+ 'banner.label.session': 'session:',
+ 'banner.session.single': 'single · scrollback',
+ 'banner.llm_hint': '/llm switch',
+
+ # messages — success / status
+ 'msg.ask_cancelled': '✗ ask cancelled · type freely or ask again',
+ 'msg.abort_requested': '⏹ abort requested · Esc',
+ 'msg.abort_done': '⏹ abort requested',
+ 'msg.idle_no_task': '(idle, no task)',
+ 'msg.cleared': 'new conversation · context cleared',
+ 'msg.new_session': 'new session · previous conversation cleared',
+ 'msg.new_session_named': 'new session "{name}" · previous conversation cleared',
+ 'msg.renamed': '✎ session renamed to "{name}"',
+ 'msg.rewind': '↩ rewound {n} turn(s) (removed {removed} history entries; scrollback is not editable)',
+ 'msg.no_rewindable': 'no rewindable turns',
+ 'msg.continue_loading': '┄┄ loaded {name}, full context above ┄┄',
+ 'msg.continue_ready': '┄┄ {msg} · continue typing ┄┄',
+ 'msg.llm_switched': 'LLM → {name}',
+ 'msg.export_done': 'exported: {path}',
+ 'msg.export_clipped': 'copied to clipboard ({n} chars)',
+ 'msg.export_clip_failed': '❌ copy failed: no clipboard tool found',
+ 'msg.export_all': 'full log:\n{path}',
+ 'msg.export_all_missing': 'no log file yet',
+ 'msg.review_empty': '(review produced no output)',
+ 'msg.no_export': '(no reply to export)',
+ 'msg.no_tracker': '(no stats yet)',
+ 'msg.no_history': ' no restorable historical sessions',
+ 'msg.no_llms': '(no LLMs available)',
+ 'msg.no_tools': ' (no tool-call records)',
+ 'msg.lang_current': 'Current language: {label} ({code})',
+ 'msg.lang_switched': 'Language → {label}',
+ 'msg.btw_no_answer': '(no answer)',
+ 'btw.title': 'side-questions · Esc to clear',
+ 'btw.querying': ' ⋯ querying…',
+
+ # plan card
+ 'plan.header': 'Plan ({done}/{total})',
+ 'plan.complete': '✓ Plan complete ({n}/{n})',
+ 'plan.placeholder': 'Plan mode activated',
+ 'plan.waiting': 'waiting for {path} …',
+ 'plan.overflow': '+{n} more',
+
+ # errors
+ 'err.running_blocked': 'busy — /stop before using this command',
+ 'err.continue_usage': 'usage: /continue or /continue N',
+ 'err.index_oob': '❌ index out of range (valid: 1-{max})',
+ 'err.btw_usage': 'usage: /btw (does not pollute main context)',
+ 'err.rewind_usage': 'usage: /rewind ',
+ 'err.rename_usage': 'usage: /rename ',
+ 'err.rewind_range': '❌ rewind failed: n must be 1-{max}',
+ 'err.lang_usage': 'usage: /language [code] (codes: {available})',
+ 'err.lang_unknown': 'unknown language code: {code} (available: {available})',
+ 'err.unknown_cmd': 'unknown command /{name} — /help to list',
+ 'err.multi_session': '/{name}: multi-session backend not wired yet; command reserved',
+ 'err.menu_cb': '❌ menu callback failed: {err}',
+ 'err.no_llm': 'No LLM configured — check mykey.py',
+ 'err.no_tty': 'tui_v3: needs a real TTY (run it in iTerm directly)',
+ 'err.dep_missing': 'Error: {name} is not installed.',
+ 'err.dep_install': 'Install with: pip install rich prompt_toolkit',
+
+ # menu / picker / palette
+ 'menu.default_title': 'Pick',
+ 'menu.hint': '↑↓ pick · Enter confirm · Esc cancel',
+ 'menu.hint.filter': 'type to filter · ↑↓ pick · Enter confirm · Esc cancel',
+ 'menu.search': 'filter workspaces, or type an abs path + Enter to create one',
+ 'menu.no_match': 'no match',
+ 'menu.free.hint': 'Enter to create/enter this path',
+ 'ask.default_q': 'answer:',
+ 'ask.title': '◉ answer',
+ 'ask.pending': ' +{n} pending',
+ 'ask.hint.multi': '↳ ↑↓ move · Space toggle · Enter submit · Esc cancel',
+ 'ask.hint.single': '↳ ↑↓ navigate (options ⇄ input) · Enter confirm · Esc cancel',
+ 'ask.hint.freetext': '↳ ↑↓ back to options · type to input · Enter submit · Esc cancel',
+
+ # continue picker
+ 'continue.title': 'Restore historical session',
+ 'continue.occupied.title': 'Session in use (pid {p}) — copy it and continue?',
+ 'continue.occupied.copy': 'Copy & continue',
+ 'continue.occupied.cancel': 'Cancel',
+ # /workspace (parity with v2; backed by workspace_cmd.py)
+ 'cmd.workspace.arg': '[path|off]',
+ 'cmd.workspace.desc': 'set working dir (abs path) and enter project mode',
+ 'ws.entered': '✅ entered workspace「{n}」',
+ 'ws.fail': '❌ workspace failed: {e}',
+ 'ws.exited': 'left workspace (project mode off; junction & files kept)',
+ 'ws.inactive': 'not in a workspace right now',
+ 'ws.none': 'no registered workspace yet; /workspace to create/enter',
+ 'ws.pick.title': 'Pick a workspace (↑↓ choose · Enter confirm · Esc cancel)',
+ 'ws.restored': 'restored working dir: {t}',
+ 'continue.row.fmt': '{rel:>4} {rounds:>3}r {preview}',
+ 'continue.unit.round': 'r',
+
+ # llm picker
+ 'llm.title': 'Switch LLM',
+
+ # emoji picker (pet style)
+ 'emoji.title': 'Pick spinner pet style',
+ 'emoji.switched': 'pet style → `{style}`',
+ 'emoji.unknown': 'unknown style `{choice}` — valid: {valid}',
+ 'emoji.row.current': '● {name:<8} {sample}',
+ 'emoji.row.other': ' {name:<8} {sample}',
+ 'emoji.row.off': '(hide pet)',
+
+ # /scheduler picker (multi-pick reflect tasks / frontends)
+ 'scheduler.pick.title': 'Pick services — checked = running (untick to stop)',
+ 'scheduler.pick.hint': 'Space toggle · ↑↓ move · Enter next · Esc cancel · or /scheduler start a,b,c',
+ 'scheduler.cron.active': 'cron: {n} task(s) in sche_tasks/*.json · active (reflect/scheduler.py running)',
+ 'scheduler.cron.inactive': 'cron: {n} task(s) in sche_tasks/*.json · inactive (start reflect/scheduler.py to schedule)',
+ 'scheduler.empty': '(no startable services: both reflect/*.py and frontends/*app*.py are empty)',
+ 'scheduler.no_pick': '(no service picked)',
+ 'scheduler.no_change': '(no change vs running set)',
+ 'scheduler.running_tag': ' · running',
+ 'scheduler.confirm.title': 'Ready to submit your answer?',
+ 'scheduler.confirm.hint': '←/→ pick · Enter confirm · Esc go back',
+ 'scheduler.confirm.submit': 'Submit ({n} service: {names})',
+ 'scheduler.confirm.edit': 'Edit selection',
+ 'scheduler.diff.start': '▶ start {n}: {names}',
+ 'scheduler.diff.stop': '■ stop {n}: {names}',
+ 'scheduler.cancelled': 'Cancelled — no change applied',
+ 'scheduler.back_to_pick': 'Back to the picker',
+ 'scheduler.usage_start': 'Usage: /scheduler start [,...]',
+
+ # export picker
+ 'export.title': 'Export the last reply',
+ 'export.opt.clip': 'Copy to clipboard',
+ 'export.opt.file': 'Save to file (temp/)',
+ 'export.opt.all': 'Show full log path',
+
+ # rewind picker
+ 'rewind.title': 'Rewind to which turn',
+ 'rewind.option': 'rewind {n} turn(s) · {preview}',
+
+ # language picker
+ 'lang.title': 'Switch interface language',
+
+ # verbose
+ 'verbose.title': ' Tool Trace',
+ 'verbose.hint': ' ↑↓ pick · PgUp/Dn scroll · Enter switch[{field}] · c copy · e export · q quit',
+ 'verbose.empty': '(empty)',
+
+ # answer prefix when committing user reply to ask_user
+ 'msg.answer_prefix': '[ans] {text}',
+
+ # pending input preview (queued while agent is busy)
+ 'pending.head_running': 'queued {n} · injecting at next turn boundary · Esc to clear',
+ 'pending.cleared': 'cleared {n} pending message(s)',
+ 'pending.queued_marker': '[queued] {text}',
+ # Soft-guidance wrap: treat a mid-task user message as steering input
+ # for the ongoing task, rather than a deferred must-answer queue item.
+ 'pending.inject_wrap': ('User sent a message while you were '
+ 'working:\n{text}\n'
+ 'Please take it into consideration and '
+ 'adjust direction if needed.'),
+
+ # shell-mode magic (`!` prefix)
+ 'shell.hint': '! for shell mode',
+ 'shell.timeout': '[shell: timeout {sec}s]',
+ 'shell.error': '[shell error: {err}]',
+ 'shell.empty': '(no output)',
+ 'shell.history': '[!shell {sh}] {cmd}\n```\n{out}\n```\n(exit {rc})',
+
+ # /resume
+ 'cmd.resume.desc': 'list recent sessions and pick one to recover',
+ 'help.resume': ' /resume List recent sessions and recover one',
+ },
+
+ 'zh': {
+ # /help intro & rows — 措辞与 v2 COMMANDS 表对齐。
+ 'help.title': '命令:',
+ 'help.help': ' /help 显示帮助',
+ 'help.status': ' /status 查看会话状态',
+ 'help.sessions': ' /sessions 列出所有会话',
+ 'help.llm': ' /llm [n] 查看 / 切换模型',
+ 'help.btw': ' /btw 旁问 — 不打断主 agent',
+ 'help.review': ' /review [request] in-session 代码审查(直接输出报告)',
+ 'help.rewind': ' /rewind [n] 回退最近 n 轮',
+ 'help.continue': ' /continue [n|name] 列出 / 恢复历史会话',
+ 'help.new': ' /new [name] 新建会话(清空当前会话)',
+ 'help.rename': ' /rename 重命名当前会话',
+ 'help.clear': ' /clear 清空显示(不动 LLM 历史)',
+ 'help.cost': ' /cost 显示当前会话 token 用量',
+ 'help.verbose': ' /verbose 工具调用审计(↑↓ 选 · Enter 切换 · c 复制 · q 退)',
+ 'help.export': ' /export [sub] 导出最后回复:clip / file [name] / all',
+ 'help.stop': ' /stop 中止当前任务',
+ 'help.language': ' /language [code] 查看 / 切换界面语言',
+ 'help.update': ' /update [备注] 预览 upstream 提交与 diff,再 git pull(不 commit)',
+ 'help.autorun': ' /autorun [seed] 进入 autonomous_operation 自主模式',
+ 'help.morphling': ' /morphling [target] 启用 Morphling 蒸馏 / 吞噬外部技能',
+ 'help.goal': ' /goal [goal] 进入 Goal 模式(需 condition 约束)',
+ 'help.hive': ' /hive [target] 进入 Hive 多 worker 协作模式',
+ 'help.conductor': ' /conductor [task] 交给 conductor.py 做多 subagent 编排',
+ 'help.scheduler': ' /scheduler 多选启动 reflect 任务 / 查看 cron',
+ 'help.emoji': ' /emoji [style] 切换 spinner 宠物样式(picker / 直接传参)',
+ 'help.quit': ' /quit 退出',
+ 'help.esc': ' Esc 撤回提问 · 清草稿 · 停任务(不退出)',
+ 'help.cc': ' Ctrl+C × 2 退出(空闲时;运行中只 abort 任务)',
+ 'help.cl': ' Ctrl+L 强制重画(睡眠唤醒后修复)',
+ 'help.cz': ' Ctrl+Z / Ctrl+Y 撤销 / 重做 输入框编辑',
+ 'help.shift_arrow': ' Shift+←→↑↓ 选中文字(Ctrl+C 复制 / Ctrl+X 剪切 / Ctrl+A 全选)',
+
+ # _CMDS palette entries — 与 /help 同源,命令面板单行显示。
+ 'cmd.help.desc': '显示帮助',
+ 'cmd.status.desc': '查看会话状态',
+ 'cmd.llm.desc': '查看 / 切换模型',
+ 'cmd.btw.desc': '旁问 — 不打断主 agent',
+ 'cmd.review.desc': 'in-session 代码审查',
+ 'cmd.rewind.desc': '回退最近 n 轮',
+ 'cmd.continue.desc': '列出 / 恢复历史会话',
+ 'cmd.new.desc': '新建会话',
+ 'cmd.rename.desc': '重命名当前会话',
+ 'cmd.clear.desc': '清空显示(不动 LLM 历史)',
+ 'cmd.cost.desc': '显示当前会话 token 用量',
+ 'cmd.verbose.desc': '工具调用审计',
+ 'cmd.export.desc': '导出最后回复(剪贴板/文件/日志路径)',
+ 'cmd.stop.desc': '中止当前任务',
+ 'cmd.language.desc': '查看 / 切换界面语言',
+ 'cmd.quit.desc': '退出',
+
+ # arg hints — 与 v2 对齐:小写 n、完整的 question 等。
+ 'cmd.llm.arg': '[n]',
+ 'cmd.btw.arg': '',
+ 'cmd.review.arg': '[request]',
+ 'cmd.rewind.arg': '[n]',
+ 'cmd.continue.arg': '[n|name]',
+ 'cmd.new.arg': '[name]',
+ 'cmd.rename.arg': '',
+ 'cmd.export.arg': '[clip|file|all]',
+ 'cmd.language.arg': '[code]',
+ 'cmd.update.arg': '[备注]',
+ 'cmd.update.desc': '预览 upstream 提交与 diff,再 git pull(不 commit)',
+ 'cmd.autorun.arg': '[seed]',
+ 'cmd.autorun.desc': '进入 autonomous_operation 自主模式',
+ 'cmd.morphling.arg': '[target]',
+ 'cmd.morphling.desc': '启用 Morphling 蒸馏 / 吞噬外部技能',
+ 'cmd.goal.arg': '[goal]',
+ 'cmd.goal.desc': '进入 Goal 模式(需 condition 约束)',
+ 'cmd.hive.arg': '[target]',
+ 'cmd.hive.desc': '进入 Hive 多 worker 协作模式',
+ 'cmd.conductor.arg': '[任务]',
+ 'cmd.conductor.desc': '调用 frontends/conductor.py 做多 subagent 编排',
+ 'cmd.scheduler.desc': '多选启动/停止 reflect 任务(cron 由 reflect/scheduler.py 驱动)',
+ 'cmd.emoji.arg': '[样式]',
+ 'cmd.emoji.desc': '切换 spinner 宠物表情 — 打开 picker;带参数则直接切换',
+
+ # status line
+ 'status.asking': '◉ 待答 · Esc 撤回提问',
+ 'status.running.tail': ' · Esc 停',
+ 'status.tps': ' · {rate:.0f} tok/s',
+ 'status.cc_confirm': '再按 Ctrl+C 退出',
+ 'status.ready': '○ 就绪',
+
+ # /status
+ 'status.title': ' 会话状态',
+ 'status.label.model': 'model:',
+ 'status.label.state': 'state:',
+ 'status.label.rounds': 'rounds:',
+ 'status.label.context': 'context:',
+ 'status.label.cwd': 'cwd:',
+ 'status.state.running': 'running · {verb} {elapsed}',
+ 'status.state.waiting': 'waiting · ask_user pending',
+ 'status.state.idle': 'idle',
+ 'status.ctx.unknown': 'n/a',
+ 'status.ctx.fmt': '{used:,} / {cap:,} ctx',
+
+ # banner
+ 'banner.label.model': 'model:',
+ 'banner.label.directory': 'directory:',
+ 'banner.label.session': 'session:',
+ 'banner.session.single': '单会话 · scrollback',
+ 'banner.llm_hint': '/llm 切换',
+
+ # messages
+ 'msg.ask_cancelled': '✗ 已撤回提问 · 可直接输入或重新发问',
+ 'msg.abort_requested': '⏹ 已请求中止 · Esc',
+ 'msg.abort_done': '⏹ 已请求中止',
+ 'msg.idle_no_task': '(空闲,无任务)',
+ 'msg.cleared': '新对话 · 上下文已清空',
+ 'msg.new_session': '新会话 · 上一段对话已清空',
+ 'msg.new_session_named': '新会话「{name}」· 上一段对话已清空',
+ 'msg.renamed': '✎ 会话已重命名为「{name}」',
+ 'msg.rewind': '↩ 回退 {n} 轮(移除 {removed} 条历史;scrollback 不可改,以此为界)',
+ 'msg.no_rewindable': '没有可回退的轮次',
+ 'msg.continue_loading': '┄┄ 载入 {name},以下为完整上文 ┄┄',
+ 'msg.continue_ready': '┄┄ {msg} · 接着说即可 ┄┄',
+ 'msg.llm_switched': 'LLM → {name}',
+ 'msg.export_done': '已导出: {path}',
+ 'msg.export_clipped': '已复制到剪贴板({n} 字符)',
+ 'msg.export_clip_failed': '❌ 复制失败:未找到剪贴板工具',
+ 'msg.export_all': '完整日志:\n{path}',
+ 'msg.export_all_missing': '尚无日志文件',
+ 'msg.review_empty': '(review 无输出)',
+ 'msg.no_export': '(没有可导出的回答)',
+ 'msg.no_tracker': '(暂无统计)',
+ 'msg.no_history': ' 没有可恢复的历史会话',
+ 'msg.no_llms': '(无可用 LLM)',
+ 'msg.no_tools': ' (暂无工具调用记录)',
+ 'msg.lang_current': '当前界面语言:{label}({code})',
+ 'msg.lang_switched': '界面语言 → {label}',
+ 'msg.btw_no_answer': '(无回答)',
+ 'btw.title': '旁问 · Esc 清空',
+ 'btw.querying': ' ⋯ 查询中…',
+
+ # plan card
+ 'plan.header': '计划 ({done}/{total})',
+ 'plan.complete': '✓ 计划完成 ({n}/{n})',
+ 'plan.placeholder': '计划模式已激活',
+ 'plan.waiting': '等待写入 {path} …',
+ 'plan.overflow': '还有 {n} 项',
+
+ # errors
+ 'err.running_blocked': '运行中,先 /stop 再用该命令',
+ 'err.continue_usage': '用法: /continue 或 /continue N',
+ 'err.index_oob': '❌ 索引越界(有效 1-{max})',
+ 'err.btw_usage': '用法: /btw <旁问>(不污染主上下文)',
+ 'err.rewind_usage': '用法:/rewind ',
+ 'err.rename_usage': '用法:/rename ',
+ 'err.rewind_range': '❌ 回退失败:n 应在 1-{max}',
+ 'err.lang_usage': '用法:/language [code] (可选 code:{available})',
+ 'err.lang_unknown': '未知语言代码:{code} (可选:{available})',
+ 'err.unknown_cmd': '未知命令 /{name} — /help 看可用命令',
+ 'err.multi_session': '/{name}:多会话后端尚未接入,命令已预留但暂未实现',
+ 'err.menu_cb': '❌ 菜单回调失败: {err}',
+ 'err.no_llm': 'No LLM configured — check mykey.py',
+ 'err.no_tty': 'tui_v3: needs a real TTY (run it in iTerm directly)',
+ 'err.dep_missing': 'Error: {name} is not installed.',
+ 'err.dep_install': 'Install with: pip install rich prompt_toolkit',
+
+ # menu / picker / palette
+ 'menu.default_title': '选择',
+ 'menu.hint': '↑↓ 选 · Enter 确认 · Esc 取消',
+ 'menu.hint.filter': '输入过滤 · ↑↓ 选 · Enter 确认 · Esc 取消',
+ 'menu.search': '输入筛选工作区或输入绝对路径回车新建工作区',
+ 'menu.no_match': '无匹配',
+ 'menu.free.hint': '回车以该路径新建/进入',
+ 'ask.default_q': '请回答:',
+ 'ask.title': '◉ 请回答',
+ 'ask.pending': ' +{n} 待答',
+ 'ask.hint.multi': '↳ ↑↓ 移动 · Space 标记 · Enter 提交 · Esc 撤回',
+ 'ask.hint.single': '↳ ↑↓ 切换(选项 ⇄ 输入框)· Enter 确认 · Esc 撤回',
+ 'ask.hint.freetext': '↳ ↑↓ 回到选项 · 输字符输入 · Enter 提交 · Esc 撤回',
+
+ # continue picker
+ 'continue.title': '恢复历史会话',
+ 'continue.occupied.title': '该会话正被占用(pid {p})—— 从原会话拷贝一份继续?',
+ 'continue.occupied.copy': '拷贝一份继续',
+ 'continue.occupied.cancel': '取消',
+ # /workspace(与 v2 一致;后端 workspace_cmd.py)
+ 'cmd.workspace.arg': '[path|off]',
+ 'cmd.workspace.desc': '设定工作目录(绝对路径)并进入项目模式',
+ 'ws.entered': '✅ 已进入 workspace「{n}」',
+ 'ws.fail': '❌ workspace 设定失败: {e}',
+ 'ws.exited': '已退出 workspace(项目模式关闭;junction 与文件保留)',
+ 'ws.inactive': '当前未处于 workspace 模式',
+ 'ws.none': '暂无已登记 workspace;用 /workspace <绝对路径> 新建/进入',
+ 'ws.pick.title': '选择 workspace(↑↓ 选择,Enter 确认,Esc 取消)',
+ 'ws.restored': '已恢复工作目录: {t}',
+ 'continue.row.fmt': '{rel:>4} {rounds:>3}轮 {preview}',
+ 'continue.unit.round': '轮',
+
+ # llm picker
+ 'llm.title': '切换 LLM',
+
+ # emoji picker
+ 'emoji.title': '选择 spinner 宠物样式',
+ 'emoji.switched': '宠物样式 → `{style}`',
+ 'emoji.unknown': '未知样式 `{choice}` — 可选:{valid}',
+ 'emoji.row.current': '● {name:<8} {sample}',
+ 'emoji.row.other': ' {name:<8} {sample}',
+ 'emoji.row.off': '(隐藏 pet)',
+
+ # /scheduler picker (multi-pick reflect tasks / frontends)
+ 'scheduler.pick.title': '挑选要启动的服务(已勾选 = 运行中,取消勾选即停止)',
+ 'scheduler.pick.hint': 'Space 勾选 · ↑↓ 移动 · Enter 下一步 · Esc 取消 · 或 /scheduler start a,b,c',
+ 'scheduler.cron.active': 'cron:sche_tasks/*.json 共 {n} 个任务 · 已激活(reflect/scheduler.py 在运行)',
+ 'scheduler.cron.inactive': 'cron:sche_tasks/*.json 共 {n} 个任务 · 未激活(启动 reflect/scheduler.py 才会调度)',
+ 'scheduler.empty': '(没有可启动的服务:reflect/*.py 与 frontends/*app*.py 均为空)',
+ 'scheduler.no_pick': '(未选择任何服务)',
+ 'scheduler.no_change': '(与当前运行集合相比无变化)',
+ 'scheduler.running_tag': ' · 运行中',
+ 'scheduler.confirm.title': '确认提交本次改动?',
+ 'scheduler.confirm.hint': '←/→ 选择 · Enter 确认 · Esc 回退',
+ 'scheduler.confirm.submit': '提交({n} 个服务:{names})',
+ 'scheduler.confirm.edit': '回去修改选择',
+ 'scheduler.diff.start': '▶ 启动 {n}:{names}',
+ 'scheduler.diff.stop': '■ 停止 {n}:{names}',
+ 'scheduler.cancelled': '已取消,未变更任何服务',
+ 'scheduler.back_to_pick': '已回到选择界面',
+ 'scheduler.usage_start': '用法:/scheduler start <服务名>[,<服务名2>...]',
+
+ # export picker
+ 'export.title': '导出最后回复',
+ 'export.opt.clip': '复制到剪贴板',
+ 'export.opt.file': '保存到文件(temp/)',
+ 'export.opt.all': '显示完整日志路径',
+
+ # rewind picker
+ 'rewind.title': '选择回退到的轮次',
+ 'rewind.option': '回退 {n} 轮 · {preview}',
+
+ # language picker
+ 'lang.title': '切换界面语言',
+
+ # verbose
+ 'verbose.title': ' Tool Trace',
+ 'verbose.hint': ' ↑↓ 选 · PgUp/Dn 滚 · Enter 切换[{field}] · c 复制 · e 导出 · q 退',
+ 'verbose.empty': '(空)',
+
+ # answer prefix
+ 'msg.answer_prefix': '[答] {text}',
+
+ # pending input preview
+ 'pending.head_running': '已排队 {n} 条 · 下个 turn 边界注入 · Esc 清空',
+ 'pending.cleared': '已清空 {n} 条待发送消息',
+ 'pending.queued_marker': '[排队] {text}',
+ 'pending.inject_wrap': ('用户在你工作时发来了一条新消息:\n{text}\n'
+ '请将其纳入考虑,必要时调整方向。'),
+
+ # shell-mode magic (`!` prefix)
+ 'shell.hint': '! 进入 shell 模式',
+ 'shell.timeout': '[shell:{sec}s 超时]',
+ 'shell.error': '[shell 错误:{err}]',
+ 'shell.empty': '(无输出)',
+ 'shell.history': '[!shell {sh}] {cmd}\n```\n{out}\n```\n(退出码 {rc})',
+
+ # /resume
+ 'cmd.resume.desc': '列出最近会话并恢复其中一个',
+ 'help.resume': ' /resume 列出最近会话并恢复其中一个',
+ },
+}
+
+
+def t(key: str, **fmt) -> str:
+ """Translate `key` to current language; fall back to en then to key itself.
+ Missing format fields raise KeyError — caller bug, not i18n bug."""
+ val = _I18N.get(_LANG, {}).get(key)
+ if val is None and _LANG != 'en':
+ val = _I18N.get('en', {}).get(key)
+ if val is None:
+ val = key # visible breadcrumb for missing keys
+ if fmt:
+ try:
+ return val.format(**fmt)
+ except (KeyError, IndexError, ValueError):
+ return val
+ return val
+
+
+def tip_count() -> int:
+ """Number of rotating banner tips (same for every language)."""
+ return len(_TIPS['en'])
+
+
+def tip(idx: int) -> str:
+ """Banner tip at `idx`, resolved in the current language so a /language
+ switch relabels it on the next banner repaint."""
+ pool = _TIPS.get(_LANG) or _TIPS['en']
+ return pool[idx % len(pool)] if pool else ''
+
+
+def get_lang() -> str:
+ return _LANG
+
+
+def set_lang(code: str) -> bool:
+ """Switch active language and persist. Returns True on success."""
+ global _LANG
+ if code not in _SUPPORTED:
+ return False
+ _LANG = code
+ _save_settings({'lang': code})
+ return True
+
+
+def supported() -> tuple[str, ...]:
+ return _SUPPORTED
+
+
+def _detect_system_lang() -> str:
+ """System language: check LC_ALL → LC_MESSAGES → LANG → locale.getlocale().
+ Prefix `zh` → 'zh', else 'en'."""
+ for env in ('LC_ALL', 'LC_MESSAGES', 'LANG'):
+ v = os.environ.get(env)
+ if v:
+ if v.lower().startswith('zh'):
+ return 'zh'
+ return 'en'
+ try:
+ loc = locale.getlocale()[0] or ''
+ if loc.lower().startswith('zh'):
+ return 'zh'
+ except Exception:
+ pass
+ return _DEFAULT
+
+
+def _load_settings() -> dict:
+ try:
+ with open(_SETTINGS_PATH, 'r', encoding='utf-8') as f:
+ data = json.load(f)
+ return data if isinstance(data, dict) else {}
+ except Exception:
+ return {}
+
+
+def _save_settings(patch: dict) -> None:
+ cur = _load_settings()
+ cur.update(patch)
+ try:
+ os.makedirs(os.path.dirname(_SETTINGS_PATH), exist_ok=True)
+ with open(_SETTINGS_PATH, 'w', encoding='utf-8') as f:
+ json.dump(cur, f, ensure_ascii=False, indent=2)
+ except Exception:
+ pass
+
+
+def init_lang() -> str:
+ """Resolve and install initial language: persisted > system > default.
+ Call once at startup; returns the resolved code."""
+ global _LANG
+ saved = _load_settings().get('lang')
+ if saved in _SUPPORTED:
+ _LANG = saved
+ else:
+ _LANG = _detect_system_lang()
+ return _LANG
+
+
+# `_t` alias kept so the hundreds of existing `_t(...)` call sites are untouched.
+_t = t
+
+# Resolve language once on import so any module-level string (banner, _CMDS,
+# /help) sees the right locale.
+init_lang()
+# ════════════════════════════════════════════════════════════════════════════
+
+
+# Module-level `clip` shim: keep sb.py-style `clip.copy(...)` calls
+# working without a separate clipboard module — the underlying funcs
+# (copy, paste, paste_image) are defined later in this same file.
+class _Clip:
+ @staticmethod
+ def copy(text): return copy(text)
+ @staticmethod
+ def paste(): return paste()
+ @staticmethod
+ def paste_image(): return paste_image()
+clip = _Clip()
+
+
+def _enable_windows_vt_mode() -> None:
+ """Enable UTF-8 + ANSI escape processing on Windows consoles when possible."""
+ if not _IS_WINDOWS:
+ return
+ try:
+ import ctypes
+ kernel32 = ctypes.windll.kernel32
+ # Make classic conhost/cmd decode UTF-8 bytes written by _w(). This is
+ # harmless in mintty/Git Bash where these calls usually fail because the
+ # std handles are pipes/ptys rather than Win32 console handles.
+ kernel32.SetConsoleOutputCP(65001)
+ kernel32.SetConsoleCP(65001)
+ enable_vt = 0x0004
+ for handle_id in (-11, -12): # STD_OUTPUT_HANDLE, STD_ERROR_HANDLE
+ handle = kernel32.GetStdHandle(handle_id)
+ mode = ctypes.c_uint32()
+ if kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
+ kernel32.SetConsoleMode(handle, mode.value | enable_vt)
+ except Exception:
+ # Safe fallback: modern terminals usually already support ANSI/UTF-8;
+ # older conhost may render escape codes, but the TUI should not crash.
+ pass
+
+
+def _enter_utf8_charset() -> None:
+ """Ask VT-compatible terminals to interpret subsequent bytes as UTF-8."""
+ # ESC % G is the ISO-2022/VT sequence for selecting UTF-8. It fixes some
+ # mintty/Git-Bash launches where the child process inherits a legacy locale
+ # and mojibakes UTF-8 box drawing/CJK into CP936-looking text.
+ _w('\x1b%G')
+
+
+def _ptk_keypress_to_bytes(kp) -> bytes:
+ """Map prompt_toolkit KeyPress objects to tui_v3's internal byte protocol.
+
+ prompt_toolkit normalizes platform-specific console input (Win32 console,
+ ConPTY, mintty/msys pty) into symbolic Keys. Keep the editor core small by
+ translating those symbols back to the bytes already handled by _feed/_keys.
+ """
+ try:
+ from prompt_toolkit.keys import Keys
+ except Exception:
+ Keys = None # type: ignore[assignment]
+
+ key = getattr(kp, 'key', None)
+ data = getattr(kp, 'data', '') or ''
+ mods = {str(m).lower().replace('_', '-') for m in (getattr(kp, 'modifiers', None) or ())}
+ modded_s = str(key).lower().replace('_', '-') == 's' or data == 's'
+ if modded_s and any(m in mods for m in ('control', 'ctrl', 'command', 'cmd')):
+ return b'\x13'
+
+ # Printable text and paste chunks. PTK may deliver a multi-character data
+ # string for bracketed paste/typeahead; forwarding UTF-8 preserves CJK/emoji.
+ if isinstance(data, str) and data and (len(data) > 1 or (len(data) == 1 and ord(data) >= 0x20 and data != '\x7f')):
+ return data.encode('utf-8', 'replace')
+
+ name = getattr(key, 'name', str(key))
+ key_s = str(key)
+ norm = name.lower().replace('_', '-').replace('keys.', '')
+ norm_s = key_s.lower().replace('_', '-').replace('keys.', '')
+ aliases = {norm, norm_s}
+
+ def has(*needles: str) -> bool:
+ return any(n in a for a in aliases for n in needles)
+
+ # Enter submits; Ctrl+J / Shift+Enter insert newline when PTK can distinguish.
+ # Windows terminals send \r for both Enter/Shift+Enter — detect Shift physically.
+ if has('controlm', 'c-m') or key_s in ('\r', '\n'):
+ if _IS_WINDOWS:
+ import ctypes
+ if ctypes.windll.user32.GetAsyncKeyState(0x10) & 0x8000:
+ return b'\n'
+ return b'\r'
+ if has('controlj', 'c-j', 's-enter', 'shift-enter'):
+ return b'\n'
+ if has('controls', 'control-s', 'c-s', 'commands', 'command-s', 'cmd-s'):
+ return b'\x13'
+
+ # Navigation. Existing _keys() uses these small control bytes.
+ if has('up') and has('shift'):
+ return b'\x1c'
+ if has('down') and has('shift'):
+ return b'\x1d'
+ if has('left') and has('shift'):
+ return b'\x1e'
+ if has('right') and has('shift'):
+ return b'\x1f'
+ if has('up'):
+ return b'\x10'
+ if has('down'):
+ return b'\x0e'
+ if has('left'):
+ return b'\x02'
+ if has('right'):
+ return b'\x06'
+ # Home/End get dedicated bytes — 0x01 is already Ctrl+A (select-all) and
+ # 0x05 has no editor handler, so reusing them would break/no-op the keys.
+ if has('home'):
+ return b'\x07'
+ if has('end'):
+ return b'\x14'
+ if has('delete'):
+ return b'\x7f'
+ if has('backspace') or data == '\x7f' or data == '\x08':
+ return b'\x7f'
+ if has('escape') or data == '\x1b':
+ return b'\x1b'
+
+ ctrl = {
+ 'controla': b'\x01', 'c-a': b'\x01',
+ 'controlb': b'\x02', 'c-b': b'\x02',
+ 'controlc': b'\x03', 'c-c': b'\x03',
+ 'controld': b'\x04', 'c-d': b'\x04',
+ 'controle': b'\x05', 'c-e': b'\x05',
+ 'controlf': b'\x06', 'c-f': b'\x06',
+ 'controlh': b'\x7f', 'c-h': b'\x7f',
+ 'controlj': b'\n', 'c-j': b'\n',
+ 'controlk': b'\x0b', 'c-k': b'\x0b',
+ 'controll': b'\x0c', 'c-l': b'\x0c',
+ 'controln': b'\x0e', 'c-n': b'\x0e',
+ 'controlp': b'\x10', 'c-p': b'\x10',
+ 'controlu': b'\x15', 'c-u': b'\x15',
+ 'controlv': b'\x16', 'c-v': b'\x16',
+ 'controlx': b'\x18', 'c-x': b'\x18',
+ 'controly': b'\x19', 'c-y': b'\x19',
+ 'controlz': b'\x1a', 'c-z': b'\x1a',
+ }
+ for a in aliases:
+ if a in ctrl:
+ return ctrl[a]
+
+ if isinstance(data, str) and data:
+ return data.encode('utf-8', 'replace')
+ return b''
+
+
+# ────────────────────────────────────────────────────────────────────────────
+# cjk: CJK wrap monkey-patch for Rich
+# ────────────────────────────────────────────────────────────────────────────
+
+log = logging.getLogger(__name__)
+
+_CJK_RANGES = (
+ (0x4E00, 0x9FFF), (0x3400, 0x4DBF), (0x20000, 0x2A6DF),
+ (0x2A700, 0x2B73F), (0x2B740, 0x2B81F), (0x2B820, 0x2CEAF),
+ (0x2CEB0, 0x2EBEF), (0xF900, 0xFAFF), (0x2F800, 0x2FA1F),
+ (0x3000, 0x303F), (0x3040, 0x309F), (0x30A0, 0x30FF),
+ (0x31F0, 0x31FF), (0xFF00, 0xFFEF), (0xAC00, 0xD7AF),
+)
+
+
+def _is_cjk(ch: str) -> bool:
+ cp = ord(ch)
+ return any(lo <= cp <= hi for lo, hi in _CJK_RANGES)
+
+
+def _is_wide(ch: str) -> bool:
+ try:
+ from rich.cells import cell_len
+ return cell_len(ch) == 2
+ except ImportError:
+ return _is_cjk(ch)
+
+
+def install_cjk_wrap() -> bool:
+ """Monkey-patch Rich's word-wrap to handle CJK char-level breaks.
+ Returns True on success, False on fallback."""
+ try:
+ import rich._wrap as wrap_mod
+ from rich.cells import cell_len
+ except (ImportError, AttributeError) as e:
+ log.warning("CJK patch skipped: %s", e)
+ return False
+
+ orig_divide = getattr(wrap_mod, 'divide_line', None)
+ if orig_divide is None:
+ log.warning("CJK patch skipped: Rich lacks divide_line")
+ return False
+
+ def _patched_divide_line(text, width, fold=True):
+ divides = set()
+ line_width = 0
+ for i, ch in enumerate(text._text if hasattr(text, '_text') else str(text)):
+ char_w = cell_len(ch) if ch != '\n' else 0
+ if line_width + char_w > width and line_width > 0:
+ if _is_wide(ch) or fold:
+ divides.add(i)
+ line_width = char_w
+ continue
+ line_width += char_w
+ if ch == '\n':
+ line_width = 0
+ # Merge with original for non-CJK content
+ try:
+ orig_divides = orig_divide(text, width, fold)
+ divides.update(orig_divides)
+ except Exception:
+ pass
+ return sorted(divides)
+
+ try:
+ wrap_mod.divide_line = _patched_divide_line
+ log.info("CJK wrap patch installed for Rich %s", _rich_version())
+ return True
+ except Exception as e:
+ log.warning("CJK patch failed: %s", e)
+ return False
+
+
+def _rich_version() -> str:
+ try:
+ from importlib.metadata import version
+ return version('rich')
+ except Exception:
+ return '?'
+
+
+# ────────────────────────────────────────────────────────────────────────────
+# clipboard: cross-platform copy/paste via native tools
+# ────────────────────────────────────────────────────────────────────────────
+
+log = logging.getLogger(__name__)
+
+_TEMP_DIR = os.path.join(tempfile.gettempdir(), 'genericagent_tui')
+_platform = sys.platform
+_HAS_WAYLAND = bool(os.environ.get('WAYLAND_DISPLAY'))
+
+
+def _run(cmd: list[str], input: bytes | None = None, timeout: float = 3.0) -> bytes | None:
+ try:
+ r = subprocess.run(cmd, input=input, capture_output=True, timeout=timeout)
+ return r.stdout if r.returncode == 0 else None
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as e:
+ log.debug("clipboard cmd %s failed: %s", cmd, e)
+ return None
+
+
+def copy(text: str) -> bool:
+ data = text.encode('utf-8')
+ if _platform == 'darwin':
+ return _run(['pbcopy'], input=data) is not None
+ if _platform == 'win32':
+ return _run(['clip.exe'], input=data) is not None
+ if _HAS_WAYLAND and shutil.which('wl-copy'):
+ return _run(['wl-copy'], input=data) is not None
+ if shutil.which('xclip'):
+ return _run(['xclip', '-selection', 'clipboard'], input=data) is not None
+ if shutil.which('xsel'):
+ return _run(['xsel', '--clipboard', '--input'], input=data) is not None
+ log.warning("No clipboard tool found")
+ return False
+
+
+def paste() -> str | None:
+ out: bytes | None = None
+ if _platform == 'darwin':
+ out = _run(['pbpaste'])
+ elif _platform == 'win32':
+ out = _run(['powershell', '-NoProfile', '-Command', 'Get-Clipboard'])
+ elif _HAS_WAYLAND and shutil.which('wl-paste'):
+ out = _run(['wl-paste', '--no-newline'])
+ elif shutil.which('xclip'):
+ out = _run(['xclip', '-selection', 'clipboard', '-o'])
+ elif shutil.which('xsel'):
+ out = _run(['xsel', '--clipboard', '--output'])
+ if out is not None:
+ return out.decode('utf-8', errors='replace')
+ return None
+
+
+def paste_image() -> str | None:
+ """Save clipboard image to temp file, return path or None."""
+ os.makedirs(_TEMP_DIR, exist_ok=True)
+ import time
+ path = os.path.join(_TEMP_DIR, f'clip_{int(time.time()*1000)}.png')
+ ok = False
+ if _platform == 'darwin':
+ script = (
+ 'use framework "AppKit"\n'
+ 'set pb to current application\'s NSPasteboard\'s generalPasteboard()\n'
+ 'set imgData to pb\'s dataForType:"public.png"\n'
+ 'if imgData is missing value then error "no image"\n'
+ 'imgData\'s writeToFile:"' + path + '" atomically:true\n'
+ )
+ ok = _run(['osascript', '-e', script], timeout=5.0) is not None
+ elif _HAS_WAYLAND and shutil.which('wl-paste'):
+ data = _run(['wl-paste', '-t', 'image/png'])
+ if data:
+ with open(path, 'wb') as f:
+ f.write(data)
+ ok = True
+ elif shutil.which('xclip'):
+ data = _run(['xclip', '-selection', 'clipboard', '-t', 'image/png', '-o'])
+ if data and len(data) > 8:
+ with open(path, 'wb') as f:
+ f.write(data)
+ ok = True
+ return path if ok and os.path.isfile(path) else None
+
+
+def _grab_clipboard_file() -> tuple[str, bool] | None:
+ """Return (path, is_image) from the clipboard via PIL — ported from v2.
+
+ PIL.ImageGrab.grabclipboard() is the one cross-platform path that also
+ works on Windows (osascript/xclip/wl-paste below don't). It handles two
+ shapes: a list of copied file paths, or a raw bitmap Image (saved to a
+ temp PNG). is_image distinguishes images (→ `[Image #N]`, sent to the
+ model) from any other file (→ `[File #N]`, expanded to its path)."""
+ try:
+ from PIL import ImageGrab, Image
+ data = ImageGrab.grabclipboard()
+ except Exception:
+ return None
+ if isinstance(data, list):
+ for item in data:
+ if isinstance(item, str) and os.path.isfile(item):
+ return (item, os.path.splitext(item)[1].lower() in _IMAGE_EXTS)
+ return None
+ if isinstance(data, Image.Image):
+ try:
+ os.makedirs(_TEMP_DIR, exist_ok=True)
+ path = os.path.join(_TEMP_DIR, f'clip_{int(time.time() * 1000)}.png')
+ data.save(path, 'PNG')
+ return (path, True)
+ except Exception:
+ return None
+ return None
+
+
+def _cleanup():
+ if os.path.isdir(_TEMP_DIR):
+ shutil.rmtree(_TEMP_DIR, ignore_errors=True)
+ # Drop this run's signal dir if it never accumulated an in-flight file.
+ try: _rmdir_if_empty(os.path.join(_ROOT, 'temp', f'_tui_v3_{os.getpid()}'))
+ except Exception: pass
+
+atexit.register(_cleanup)
+
+
+# ────────────────────────────────────────────────────────────────────────────
+# renderer: markdown / ANSI sanitisation / fold
+# ────────────────────────────────────────────────────────────────────────────
+
+# Comprehensive ANSI sanitization — matches v2's thoroughness
+_ANSI_INCOMPLETE_RE = re.compile(r'\x1b\[[0-9;]*$')
+_ANSI_DEC_PRIVATE_RE = re.compile(r'\x1b\[\?[0-9;]*[a-zA-Z]')
+_ANSI_OSC_RE = re.compile(r'\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?')
+_ANSI_MODE_SET_RE = re.compile(r'\x1b[=>][0-9]*')
+# Keep SGR (color) codes, strip everything else
+_ANSI_SGR_RE = re.compile(r'\x1b\[[0-9;]*m')
+
+# agent_loop.py emits `**LLM Running (Turn N) ...**` by default but switches
+# to the short `**Turn N ...**` when `handler.parent.task_dir` is set
+# (agent_loop.py:52). TUI v3 sets task_dir (for `_stop` signal + `!cmd`
+# shell history via `_intervene`), so we must match both forms.
+_TURN_MARKER_RE = re.compile(r'\*\*(?:LLM Running \()?Turn (\d+)\)?[^\n]*?\*\*')
+_META_TAG_RE = re.compile(r'<(?:thinking|summary|tool_use|file_content)>.*?(?:thinking|summary|tool_use|file_content)>', re.DOTALL)
+_TOOL_USE_BLOCK_RE = re.compile(r'```json\s*\{[^}]*"tool_name"[^}]*\}\s*```', re.DOTALL)
+_TOOL_USE_TAG_RE = re.compile(r'\s*\{.*?"tool_name"\s*:\s*"([^"]+)".*?\}\s* ', re.DOTALL)
+_SUMMARY_RE = re.compile(r'\s*(.*?)\s* ', re.DOTALL)
+_QUAD_BACKTICK_RE = re.compile(r'(`{4,})')
+_ASK_USER_RE = re.compile(r'"tool_name"\s*:\s*"ask_user".*?"question"\s*:\s*"([^"]*)"', re.DOTALL)
+
+# v2 fold_turns helpers (tuiapp_v2.py:240-267). 4-fence stash keeps a tool
+# result's `` ``` `` from being misread as a turn boundary; the per-turn
+# `` regex uses a negative lookahead so two adjacent summaries don't
+# merge; the title cleaner strips fenced code + thinking before extraction.
+_FENCE4_STASH_RE = re.compile(r'^`{4,}.*?^`{4,}\n?', re.DOTALL | re.MULTILINE)
+# Same as _TURN_MARKER_RE but capturing the WHOLE match for str.split() to
+# keep the marker as a separator token in the result list.
+_TURN_SPLIT_FOLD_RE = re.compile(r'(\*\*(?:LLM Running \()?Turn \d+\)? \.\.\.\*\*)')
+_SUMMARY_PERTURN_RE = re.compile(r'\s*((?:(?!).)*?)\s* ', re.DOTALL)
+_TITLE_CLEAN_RE = re.compile(r'`{3,}.*?`{3,}|.*? ', re.DOTALL)
+_TITLE_ARGS_TAIL_RE = re.compile(r',?\s*args:.*$')
+
+
+@dataclass
+class FoldSegment:
+ title: str
+ body: str
+ turn: int
+ is_last: bool = False
+
+
+@dataclass
+class Block:
+ """A unit of finalized scrollback history, stored as SOURCE (not rendered
+ lines). Resize replays each block through its renderer at the new width,
+ so width-baked structures (chip boxes, banner box) reflow correctly. The
+ actual scrollback bytes above the viewport stay frozen at the old width —
+ that's a terminal physics constraint — but the viewport and everything
+ new flows correctly."""
+ kind: str # 'user' | 'assistant' | 'plain' | 'banner'
+ source: str # source text (or '' for banner — regenerated on demand)
+ tool_n: int = 0 # tool count cached at last render (for stable tids)
+
+
+def sanitize_ansi(text: str) -> str:
+ """Strip non-SGR ANSI escapes and incomplete sequences from streaming chunks."""
+ text = _ANSI_DEC_PRIVATE_RE.sub('', text)
+ text = _ANSI_OSC_RE.sub('', text)
+ text = _ANSI_MODE_SET_RE.sub('', text)
+ text = _ANSI_INCOMPLETE_RE.sub('', text)
+ return text
+
+
+def _render_checkboxes(text: str) -> str:
+ """Convert markdown task lists to visual checkboxes."""
+ text = re.sub(r'^(\s*[-*+]\s)\[ \]', r'\1☐', text, flags=re.MULTILINE)
+ text = re.sub(r'^(\s*[-*+]\s)\[x\]', r'\1☑', text, flags=re.MULTILINE | re.IGNORECASE)
+ return text
+
+
+def strip_meta_tags(text: str) -> str:
+ """Strip internal tags, render tool_use as readable summaries."""
+ def _tool_replace(m):
+ name = m.group(1)
+ if name == 'ask_user':
+ q_match = _ASK_USER_RE.search(m.group(0))
+ if q_match:
+ return f'> {q_match.group(1)}'
+ return f'🔧 {name}'
+ text = _TOOL_USE_TAG_RE.sub(_tool_replace, text)
+ text = _META_TAG_RE.sub('', text)
+ text = _TOOL_USE_BLOCK_RE.sub('', text)
+ text = _render_checkboxes(text)
+ return re.sub(r'\n{3,}', '\n\n', text).strip()
+
+
+def _extract_title(text: str, max_len: int = 72) -> str:
+ m = _SUMMARY_RE.search(text)
+ if m:
+ title = m.group(1).strip()
+ else:
+ first = text.strip().split('\n')[0] if text.strip() else ''
+ title = re.sub(r'^[#*>\-\s]+', '', first).strip()
+ if len(title) > max_len:
+ title = title[:max_len - 1] + '…'
+ return title or '...'
+
+
+def fold_segments(text: str) -> list[FoldSegment]:
+ """Split agent response into per-turn fold segments."""
+ if not text:
+ return []
+ cleaned = _QUAD_BACKTICK_RE.sub(lambda m: '~' * len(m.group(1)), text)
+ parts = _TURN_MARKER_RE.split(cleaned)
+ if len(parts) <= 1:
+ return [FoldSegment(title=_extract_title(text), body=text, turn=1, is_last=True)]
+ segments: list[FoldSegment] = []
+ if parts[0].strip():
+ segments.append(FoldSegment(title=_extract_title(parts[0]), body=parts[0], turn=0))
+ for i in range(1, len(parts), 2):
+ turn_num = int(parts[i]) if i < len(parts) else len(segments) + 1
+ body = parts[i + 1] if i + 1 < len(parts) else ''
+ body = body.replace('~' * 4, '````')
+ segments.append(FoldSegment(title=_extract_title(body), body=body, turn=turn_num))
+ if segments:
+ segments[-1].is_last = True
+ return segments
+
+
+# Render cache: (content_hash, width) -> rendered object
+_render_cache: dict[tuple[int, int], object] = {}
+_CACHE_MAX = 200
+
+
+class HardBreakMarkdown(Markdown):
+ """Markdown that treats softbreaks as hardbreaks, preserving code blocks."""
+ def __init__(self, markup: str, **kwargs):
+ lines = []
+ in_code = False
+ for line in markup.split('\n'):
+ stripped = line.strip()
+ if stripped.startswith('```'):
+ in_code = not in_code
+ if in_code:
+ lines.append(line)
+ else:
+ lines.append(line + ' ')
+ super().__init__('\n'.join(lines), **kwargs)
+
+
+def _markdown_to_text(cleaned: str, width: int) -> Text:
+ """Render Markdown to a CONCRETE Text (v2 approach). A Textual Static holding
+ a live rich.markdown.Markdown does not re-composite reliably when scrolled
+ past the viewport (height measurement is unstable → frozen/blank scroll);
+ a pre-rendered Text has a fixed line count and scrolls correctly."""
+ from io import StringIO
+ from rich.console import Console
+ buf = StringIO()
+ Console(file=buf, width=max(1, width), force_terminal=True,
+ color_system='truecolor', legacy_windows=False
+ ).print(HardBreakMarkdown(cleaned), end='')
+ return Text.from_ansi(buf.getvalue().rstrip('\n'))
+
+
+def render_message(text: str, role: str = 'assistant', width: int = 0) -> Text:
+ """Render a message to a concrete Text. width<=0 → provisional plain text
+ (the widget re-renders via on_resize once its real width is known)."""
+ cleaned = strip_meta_tags(text) if role == 'assistant' else text
+ if not cleaned.strip():
+ cleaned = '...'
+ if role == 'system':
+ return Text(cleaned, style='dim')
+ if width <= 0:
+ return Text(cleaned)
+ key = (hash(cleaned), width)
+ cached = _render_cache.get(key)
+ if cached is not None:
+ return cached
+ try:
+ result = _markdown_to_text(cleaned, width)
+ except Exception:
+ result = Text(cleaned)
+ if len(_render_cache) >= _CACHE_MAX:
+ for k in list(_render_cache.keys())[:_CACHE_MAX // 4]:
+ _render_cache.pop(k, None)
+ _render_cache[key] = result
+ return result
+
+
+# ────────────────────────────────────────────────────────────────────────────
+# protocol: AgentBridge + typed events over display_queue
+# ────────────────────────────────────────────────────────────────────────────
+
+@dataclass(frozen=True)
+class StreamEvent:
+ text: str
+ turn: int = 0
+ source: str = "user"
+
+@dataclass(frozen=True)
+class DoneEvent:
+ text: str
+ turn: int = 0
+ source: str = "user"
+ outputs: list[str] = field(default_factory=list)
+
+@dataclass(frozen=True)
+class AskUserEvent:
+ question: str
+ candidates: list[str] = field(default_factory=list)
+
+@dataclass(frozen=True)
+class SystemEvent:
+ text: str
+
+@dataclass(frozen=True)
+class ErrorEvent:
+ message: str
+ exception: Exception | None = None
+
+AgentEvent = StreamEvent | DoneEvent | AskUserEvent | SystemEvent | ErrorEvent
+
+_HOOK_KEY = '_tui_v3_ask_user'
+
+
+def _extract_ask_user(ctx: dict | None) -> AskUserEvent | None:
+ er = (ctx or {}).get('exit_reason') or {}
+ if er.get('result') != 'EXITED':
+ return None
+ payload = er.get('data') or {}
+ if payload.get('status') != 'INTERRUPT' or payload.get('intent') != 'HUMAN_INTERVENTION':
+ return None
+ data = payload.get('data') or {}
+ candidates = data.get('candidates') or []
+ # v2 parity: skip the ask card when the agent didn't supply candidates —
+ # the 'Waiting for your answer ...' marker already lands in scrollback as
+ # part of the assistant stream, and the user replies via the normal input
+ # box. Pushing an empty-candidate event onto the queue would route us
+ # through _enter_ask → free-text ask card, which freezes the live region
+ # in some terminals.
+ if not candidates:
+ return None
+ return AskUserEvent(
+ question=data.get('question', ''),
+ candidates=candidates,
+ )
+
+
+class AgentBridge:
+ """Wraps GenericAgent for the TUI. One bridge per session."""
+
+ def __init__(self, llm_no: int = 0):
+ self.agent = GeneraticAgent()
+ self.agent.llm_no = llm_no
+ if llm_no and hasattr(self.agent, 'llmclients') and self.agent.llmclients:
+ self.agent.llmclient = self.agent.llmclients[llm_no % len(self.agent.llmclients)]
+ self.agent.inc_out = True
+ self.agent.verbose = True
+ from frontends.slash_cmds import COMMIT_SIGNATURE_PROMPT
+ self.agent.extra_sys_prompts.append(COMMIT_SIGNATURE_PROMPT)
+ # 默认普通模式:设 None 让 project_mode 插件不读 pid 文件锚(与 v2 一致)。
+ # /workspace 绑定时改为项目名 + 真实路径。
+ self.agent._ga_project_mode_name = None
+ self.agent._ga_project_mode_workspace_path = ''
+ # task_dir path enables ga's `_keyinfo` / `_intervene` consume paths.
+ # PID-scoped so concurrent v3 processes don't share signal files.
+ # Only the *path* is set here; the dir is created lazily by the writer
+ # (`inject_intervene`) when a signal is actually injected. Eager
+ # makedirs left a stale empty `temp/_tui_v3_` behind for every
+ # run that never used intervene; `consume_file` tolerates a missing dir.
+ self.agent.task_dir = os.path.join(_ROOT, 'temp', f'_tui_v3_{os.getpid()}')
+ self.ask_user_queue: queue.Queue[AskUserEvent] = queue.Queue()
+ # Wrapped user messages we appended to `_intervene` since the last
+ # turn boundary. At a non-exit boundary the file was consumed and
+ # next_prompt now carries our text — clear the list. At an exit
+ # boundary the file was consumed but next_prompt is discarded — replay
+ # via put_task so the user's words aren't lost.
+ self._intervene_pending: list[str] = []
+ self._intervene_lk = threading.Lock()
+ # Display queue created when an exit-boundary replay re-submits queued
+ # user messages (see `_on_turn_end`). Handed to the UI via
+ # `take_replay_dq` so it drains the follow-up run; without this the
+ # replayed turn streams headless — recorded in model_responses but
+ # never shown in the current TUI session.
+ self._replay_dq: queue.Queue | None = None
+ self._install_hook()
+ self._healthy = True
+ self._init_error: str | None = None
+ if not getattr(self.agent, 'llmclient', None):
+ self._healthy = False
+ self._init_error = _t('err.no_llm')
+ # 原地复原:本会话出生即持有自己日志的锁,使占用检测对它可见(别的会话据此判活)。
+ try:
+ from frontends import continue_cmd as _cc
+ _cc.acquire_birth_lock(self.agent)
+ except Exception:
+ pass
+ self._runner = threading.Thread(target=self._run_safe, daemon=True, name=f'ga-tui-agent')
+ self._runner.start()
+
+ def inject_intervene(self, text: str, *, track: bool = False) -> bool:
+ """Append `text` to `/_intervene`. ga.turn_end_callback
+ consumes the file at the next turn boundary and prepends `[MASTER]
+ ...` to next_prompt. Returns False when the agent is idle (caller
+ falls back to put_task). Append-mode keeps us idempotent under
+ the consume_file race. `track=True` records the text so the
+ turn_end hook can replay it on an exit boundary (used for queued
+ user messages — `!cmd` shell facts don't need replay)."""
+ td = getattr(self.agent, 'task_dir', None)
+ if not td or not getattr(self.agent, 'is_running', False):
+ return False
+ try: os.makedirs(td, exist_ok=True)
+ except Exception: return False
+ fp = os.path.join(td, '_intervene')
+ try:
+ sep = ''
+ try:
+ if os.path.getsize(fp) > 0: sep = '\n\n'
+ except OSError: pass
+ with open(fp, 'a', encoding='utf-8') as f:
+ f.write(sep + text)
+ if track:
+ with self._intervene_lk:
+ self._intervene_pending.append(text)
+ return True
+ except Exception:
+ return False
+
+ def _run_safe(self):
+ try:
+ self.agent.run()
+ except Exception as e:
+ self._healthy = False
+ self._init_error = str(e)
+
+ def _install_hook(self):
+ if not hasattr(self.agent, '_turn_end_hooks'):
+ self.agent._turn_end_hooks = {}
+ self.agent._turn_end_hooks[_HOOK_KEY] = self._on_turn_end
+
+ def _on_turn_end(self, ctx: dict):
+ ev = _extract_ask_user(ctx)
+ if ev:
+ self.ask_user_queue.put(ev)
+ with self._intervene_lk:
+ if not self._intervene_pending:
+ return
+ if (ctx or {}).get('exit_reason'):
+ combined = '\n\n'.join(self._intervene_pending)
+ self._intervene_pending = []
+ try: self._replay_dq = self.agent.put_task(combined, source='user')
+ except Exception: pass
+ else:
+ self._intervene_pending = []
+
+ def take_replay_dq(self) -> "queue.Queue | None":
+ """Hand off the display_queue from an exit-boundary replay once.
+
+ If a queued mid-run user message is consumed on the same boundary that
+ exits the current task, ga discards next_prompt. `_on_turn_end` replays
+ it with put_task; the TUI must then drain that returned queue or the
+ reply is written only to model_responses and appears only after
+ /continue.
+ """
+ with self._intervene_lk:
+ dq, self._replay_dq = self._replay_dq, None
+ return dq
+
+ def submit(self, query: str, images: list | None = None) -> queue.Queue:
+ return self.agent.put_task(query, source='user', images=images)
+
+ def abort(self):
+ self.agent.abort()
+
+ @property
+ def is_running(self) -> bool:
+ return self.agent.is_running
+
+ @property
+ def llm_name(self) -> str:
+ try:
+ return self.agent.get_llm_name()
+ except Exception:
+ return '?'
+
+ @property
+ def llm_model(self) -> str:
+ """The concrete model id in use (e.g. claude-opus-4-8), not the
+ channel group `llm_name` returns. Empty string when unavailable."""
+ try:
+ return self.agent.get_llm_name(model=True) or ''
+ except Exception:
+ return ''
+
+ def list_llms(self) -> list[tuple[int, str, bool]]:
+ return self.agent.list_llms()
+
+ def switch_llm(self, n: int):
+ self.agent.next_llm(n)
+
+ def drain_display_queue(self, dq: queue.Queue, timeout: float = 0.25):
+ """Generator: yields typed events from a display_queue."""
+ while True:
+ try:
+ item = dq.get(timeout=timeout)
+ except queue.Empty:
+ yield None
+ continue
+ if not isinstance(item, dict):
+ continue
+ if 'done' in item:
+ yield DoneEvent(
+ text=item['done'],
+ turn=item.get('turn', 0),
+ source=item.get('source', 'user'),
+ outputs=item.get('outputs', []),
+ )
+ break
+ if 'next' in item:
+ yield StreamEvent(
+ text=item['next'],
+ turn=item.get('turn', 0),
+ source=item.get('source', 'user'),
+ )
+
+
+# ────────────────────────────────────────────────────────────────────────────
+# sb: scrollback-first TUI core (input, paint, flow, ask, /verbose, …)
+# ────────────────────────────────────────────────────────────────────────────
+
+# Prose hierarchy via ATTRIBUTES only (bold/italic/underline) — NO dim for body
+# content (dim on white = unreadable grey). Keep normal prose inherited from the
+# surrounding tile; avoid Rich's inline-code reverse without pinning prose dark.
+_MD_THEME = Theme({
+ 'markdown.h1': 'bold underline', 'markdown.h2': 'bold underline',
+ 'markdown.h3': 'bold', 'markdown.h4': 'bold',
+ 'markdown.h5': 'bold', 'markdown.h6': 'bold',
+ 'markdown.strong': 'bold', 'markdown.em': 'italic',
+ # Rich's default inline-code ``reverse`` can vanish on themed tiles; keep it
+ # bold but inherited so it stays readable on both light and dark surfaces.
+ 'markdown.code': 'bold', 'markdown.code_block': 'none',
+ 'markdown.block_quote': 'italic', 'markdown.hr': 'none',
+ 'markdown.link': 'underline', 'markdown.link_url': 'underline',
+ # Bullet inherits the surrounding foreground (a pinned dark hue vanished on
+ # dark terminals); bold alone keeps it visible on any background.
+ 'markdown.item.bullet': 'bold',
+}, inherit=True)
+
+
+PROMPT = '❯ '
+CONT = ' '
+# macOS Terminal.app quantises ALL truecolor escapes to their nearest 256-color
+# slot, and the slot it picks for #5e6ad2 (iTerm lavender) is 62/#5f5fd7 — that's
+# the "blue" border the user sees. It also renders \x1b[2m as a heavy 30%-opacity
+# multiply instead of the gentle blend iTerm does — that's the "heavy shadow".
+# Branching on TERM_PROGRAM lets iTerm keep its truecolor + dim look, while
+# Apple_Terminal uses pinned 256-color slots that match iTerm's RENDERED result.
+_IS_APPLE_TERMINAL = os.environ.get('TERM_PROGRAM') == 'Apple_Terminal'
+_RST = '\x1b[0m'
+if _IS_APPLE_TERMINAL:
+ _DIM = '\x1b[38;5;244m' # mid-gray — no \x1b[2m, no "shadow"
+ _ACCENT = '\x1b[38;5;105m' # 256-slot light purple, closest to iTerm rendered look
+ _BORDER = '\x1b[38;5;146m' # light lavender
+else:
+ _DIM = '\x1b[2m'
+ _ACCENT = '\x1b[38;2;94;106;210m' # Linear lavender #5e6ad2
+ _BORDER = '\x1b[38;5;146m'
+_INK_U = '\x1b[38;5;234m' # user ink — kept for legacy callers
+# User-prompt panel. Charcoal block (RGB 55,55,55) with soft-white ink —
+# full-row tile via _tile() means the band keeps its right edge on every
+# terminal regardless of wrap-width math. Switched from xterm-
+# 256 inverse (which renders muddy on Win Terminal dark themes) to truecolor.
+_TILE_U = '\x1b[48;2;55;55;55m\x1b[38;2;230;230;230m'
+_MARK = _ACCENT + '❯' + _RST # prompt mark — the single accent
+# Shell-mode (`!` magic prefix) accents — vivid pink so it stands out from
+# the normal accent purple without clashing with the heat-counter reds.
+_SHELL_ACCENT = '\x1b[38;5;205m' # hot pink for border / prompt mark
+_SHELL_BG = '\x1b[48;2;65;60;65m' # 65,60,65 charcoal-magenta (per spec)
+_SHELL_MARK = _SHELL_ACCENT + '!' + _RST
+# Full-row tile for committed shell rows (echo + each output line), so the
+# pair reads as one block in scrollback, matching cc-style. Slightly
+# warmer than _TILE_U (55,55,55) so the two row kinds are distinguishable
+# when interleaved. Black-terminal only — light themes get the bare band.
+_TILE_SHELL = '\x1b[48;2;65;60;65m\x1b[38;2;230;230;230m'
+_BG_TOK = {str(n) for n in list(range(40, 48)) + [49] + list(range(100, 108))}
+_SGR_RE = re.compile(r'\x1b\[([0-9;]*)m')
+_CSI_ERASE_RE = re.compile(r'\x1b\[[0-9;?]*[JK]')
+_SGR_TOKEN_RE = re.compile(r'\x1b\[[0-9;]*m')
+
+
+def _tile(s: str, style: str, width: int | None = None) -> str:
+ # Re-assert style after every reset so muted-markdown \x1b[0m can't punch
+ # a hole in the block. When `width` is provided we pad with explicit
+ # bg-active spaces — prompt-toolkit's cell renderer doesn't honour
+ # \x1b[K (erase-to-EOL) inside its own buffer, so PTK-bound scrollback
+ # would otherwise leave the row gap exposed. Fall back to \x1b[K for
+ # legacy callers writing straight to the terminal (no PTK), where the
+ # erase command still fills correctly.
+ body = style + s.replace(_RST, _RST + style)
+ if width is None:
+ return body + '\x1b[K' + _RST
+ visible = _SGR_TOKEN_RE.sub('', s)
+ pad = max(0, width - cell_len(visible))
+ return body + ' ' * pad + _RST
+
+
+def _border(left: str, right: str, width: int, style: str = _BORDER) -> str:
+ width = max(1, width)
+ if width == 1:
+ return style + left + _RST
+ return style + left + '─' * max(0, width - 2) + right + _RST
+
+
+def _strip_bg(s: str) -> str:
+ """Drop only BACKGROUND SGR — keep foreground colour (curated syntax/diff
+ stays, Linear-style functional colour) but no ugly box behind code."""
+ def repl(m: re.Match) -> str:
+ toks = m.group(1).split(';') if m.group(1) else ['0']
+ out, i = [], 0
+ while i < len(toks):
+ t = toks[i]
+ if t == '48':
+ i += 3 if (i + 1 < len(toks) and toks[i + 1] == '5') else \
+ 5 if (i + 1 < len(toks) and toks[i + 1] == '2') else 1
+ continue
+ if t in _BG_TOK:
+ i += 1; continue
+ out.append(t); i += 1
+ return '\x1b[' + ';'.join(out) + 'm' if out else '\x1b[0m'
+ return _SGR_RE.sub(repl, s)
+# CSI (`\x1b[…`) or SS3 (`\x1bO…`, application-cursor mode for Home/End/arrows)
+# as whole sequences, else any 2-byte `\x1b.` — order matters so SS3 wins over `\x1b.`.
+_ESC_RE = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]|\x1bO[@-~]|\x1b.')
+_FILE_REF_RE = re.compile(r'@([\w./\-~]+)')
+_PASTE_PH_RE = re.compile(r'\[Pasted text #(\d+) \+\d+ lines\]')
+_FILE_PH_RE = re.compile(r'\[File #(\d+)\]')
+_IMG_PH_RE = re.compile(r'\[Image #(\d+)\]')
+# All paste placeholders — used for whole-block delete (v2 parity): backspace
+# flush against any of these wipes the entire placeholder, not one char.
+_PLACEHOLDER_RES = (_PASTE_PH_RE, _IMG_PH_RE, _FILE_PH_RE)
+_IMAGE_EXTS = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.tiff', '.tif', '.ico'}
+_TURN_MK_RE = re.compile(
+ r'\*\*(?:LLM Running \()?Turn \d+\)?[^\n]*\*\*' # native + task-mode short form
+ r'|^[ \t]*Turn \d+\s*\.{3,}[ \t]*$', # plain subagent form on its own line
+ re.M)
+_TOOL_RE = re.compile(
+ r'🛠️ Tool: `([^`]+)`[^\n]*\n' # 1 = name
+ r'(`{3,})[^\n]*\n(.*?)\n\2[ \t]*\n*' # 2 = fence delim, 3 = args body
+ r'(?:'
+ r'(`{5,})[^\n]*\n(.*?)\n\4[ \t]*\n*' # 4 = result fence (5-bt), 5 = body
+ r'|' # ─ OR ─
+ # Trail-end sentinel: either form of the `**Turn N ...**` marker, or a
+ # bare `Turn N ...` on its own line, or the next tool / summary tag.
+ r'(.*?)(?=^🛠️ Tool: `|^\*\*(?:LLM Running \()?Turn \d+|^Turn \d+ \.\.\.$|^|\Z)' # 6 = live exec trace
+ r')',
+ re.DOTALL | re.MULTILINE)
+# Prompted-style tool wrappers GA models emit AS TEXT in saved logs (no
+# structured tool_use block). Fold them into chips too so /continue replays
+# match live mode. Whitelist = every name in assets/tools_schema.json + the
+# native metadata wrappers; user HTML (