diff --git a/.gitignore b/.gitignore index 1116a8de..4f4aef99 100644 --- a/.gitignore +++ b/.gitignore @@ -16,9 +16,7 @@ build/ .idea/ .gemini/ .claude/ -tests/ agents_lineup.svg -docs/ goals.py pr-reviews/ agentchattr-*.zip diff --git a/README.md b/README.md index 68cdfb5e..e895c57a 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,50 @@ ![Windows](https://img.shields.io/badge/platform-Windows-blue) ![macOS](https://img.shields.io/badge/platform-macOS-lightgrey) ![Linux](https://img.shields.io/badge/platform-Linux-orange) ![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-green) [![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/qzfn5YTT9a) +## 中文快速开始 + +agentchattr 是一个本地多 Agent 协作聊天室:你可以把 Claude Code、Codex、Gemini CLI、Kimi、Qwen、GitHub Copilot CLI、Kilo、CodeBuddy、MiniMax 等代理接进同一个房间,让人和代理、代理和代理通过 `@mention` 自动唤醒、读取上下文并继续协作。 + +### 下载 Windows 免安装包 + +1. 打开 [v0.4.1-chines.1 Release](https://github.com/MoringstarsH/agentchattr/releases/tag/v0.4.1-chines.1)。 +2. 下载 `agentchattr-windows-x64-v0.4.1-chines.1.zip`。 +3. 解压整个文件夹后运行 `agentchattr.exe`。 + +不要只移动 `agentchattr.exe`。Windows 免安装包是 PyInstaller `onedir` 应用,`_internal/`、`static/`、`session_templates/`、`config.toml` 等文件需要和 exe 保持在同一个目录下。 + +### 从源码运行 + +- Windows:打开 `windows` 文件夹,双击 `start_*.bat` 启动对应代理;或运行 `python desktop_launcher.py` 打开桌面工具台。 +- Mac/Linux:进入 `macos-linux` 文件夹,运行 `sh start_*.sh` 启动对应代理。 +- 只启动网页服务:运行 `python run.py`,然后访问 `http://localhost:8300`。 +- 访问网页工具台:服务启动后打开 `http://localhost:8300/launcher`。 + +### 可视化工具台使用 + +- **Overview**:查看 Server 状态、MCP 端口、在线代理数量;可以启动 Server,并用 **Open Chat** 打开聊天页。 +- **Agents**:从配置中选择 Agent 类型,设置普通模式或 Yolo 模式、角色、工作目录后启动。实例名由后端 registry 自动分配,前端不会手动生成实例名。 +- **Terminal**:查看由工具台启动的 Server/Agent 日志,并复制或清空当前日志。 +- **Settings**:保留轻量设置入口;MVP 阶段主要用于展示工具台配置状态。 + +工具台只停止或重启自己启动的进程。通过 `windows/start_*.bat` 等外部方式启动的 Server/Agent 会显示为外部进程,工具台不会杀掉它们。 + +--- + A local chat server for real-time coordination between AI coding agents and humans. Ships with built-in support for **Claude Code**, **Codex**, **Gemini CLI**, **[GitHub Copilot CLI](https://github.com/github/copilot-cli)**, **Kimi**, **Qwen**, **Kilo CLI**, **[CodeBuddy](https://www.codebuddy.ai/cli)**, and **[MiniMax](https://platform.minimax.io)** — and any MCP-compatible agent can join. Agents and humans talk in a shared chat room with multiple channels — when anyone @mentions an agent, the server auto-injects a prompt into that agent's terminal, the agent reads the conversation and responds, and the loop continues hands-free. No copy-pasting between ugly terminals. No manual prompting. *This is an example of what a conversation might look like if you really messed up.* -![screenshot](screenshot.png) +![screenshot](docs/assets/screenshot.png) ## Quickstart (Windows) +Desktop launcher MVP notes, including `python desktop_launcher.py`, desktop +requirements, MVP limits, and the Windows smoke checklist, live in +[DESKTOP_LAUNCHER_MVP.md](docs/DESKTOP_LAUNCHER_MVP.md). + **1. Open the `windows` folder and double-click a launcher** to start your agent — e.g. `start_claude.bat`, `start_codex.bat`, `start_gemini.bat`, etc. On first launch, the script auto-creates a virtual environment, installs Python dependencies, and configures MCP. Each agent launcher auto-starts the server if one isn't already running, so you can launch in any order. Run multiple launchers for multiple agents — they share the same server. @@ -106,7 +140,7 @@ Agents wake each other up, coordinate, and report back. ```

- agentchattr gang
+ agentchattr gang
the gang after /hatmaking

diff --git a/app.py b/app.py index e8d8e407..92daf6c2 100644 --- a/app.py +++ b/app.py @@ -2,9 +2,13 @@ import asyncio import json +import os import re as _re +import secrets +import signal import sys import threading +import time import uuid import logging from pathlib import Path @@ -21,13 +25,16 @@ from schedules import ScheduleStore, parse_schedule_spec from router import Router from agents import AgentTrigger -from registry import RuntimeRegistry +from registry import RuntimeRegistry, canonicalize_name from session_store import SessionStore, validate_session_template from session_engine import SessionEngine +from launcher import launcher +from launcher_routes import router as launcher_router, launcher_events_ws log = logging.getLogger(__name__) app = FastAPI(title="agentchattr") +app.include_router(launcher_router) # --- globals (set by configure()) --- store: MessageStore | None = None @@ -45,6 +52,7 @@ # --- Security: session token (set by configure()) --- session_token: str = "" +launcher_shutdown_token: str = "" # Room settings (persisted to data/settings.json) room_settings: dict = { @@ -184,9 +192,18 @@ async def dispatch(self, request: Request, call_next): # Static assets, index page, and uploaded images are public. # The index page injects the token client-side via same-origin script. # Uploads use random filenames and have path-traversal protection. - if path == "/" or path.startswith(("/static/", "/uploads/", "/api/roles")): + if path == "/" or path == "/launcher" or path.startswith(("/static/", "/uploads/", "/api/roles")): return await call_next(request) + # Local desktop launcher endpoints are restricted to loopback. + if ( + path in ("/api/status", "/api/launcher/shutdown", "/api/shutdown_launcher_server") + or path.startswith("/api/launcher/agents/") + ): + client_ip = request.client.host if request.client else "" + if client_ip in ("127.0.0.1", "::1", "localhost"): + return await call_next(request) + # Agent registration/heartbeat: loopback only (no remote agent minting). if path.startswith(("/api/register", "/api/deregister/", "/api/heartbeat/")): client_ip = request.client.host if request.client else "" @@ -229,9 +246,10 @@ async def dispatch(self, request: Request, call_next): app.add_middleware(SecurityMiddleware) -def configure(cfg: dict, session_token: str = ""): - global store, rules, summaries, jobs, schedules, router, agents, registry, session_store, session_engine, config +def configure(cfg: dict, session_token: str = "", launcher_token: str = ""): + global store, rules, summaries, jobs, schedules, router, agents, registry, session_store, session_engine, config, launcher_shutdown_token config = cfg + launcher_shutdown_token = launcher_token or "" # --- Security: store the session token and install middleware --- _install_security_middleware(session_token, cfg) @@ -360,7 +378,7 @@ def _background_checks(): # Crash timeout: if a wrapper hasn't heartbeated for 60s, # it's dead — deregister it to free the slot. - _CRASH_TIMEOUT = 15 + _CRASH_TIMEOUT = 90 registered = set(registry.get_all_names()) for name in registered: with mcp_bridge._presence_lock: @@ -672,6 +690,9 @@ async def _handle_new_message(msg: dict): if not suppress_broadcast: await broadcast(msg) + # Clear typing indicator when an agent finishes responding + if sender in known_agents: + await broadcast_typing(sender, False) # If the raw slash command was persisted (MCP path), silently remove it. # It was never broadcast to WebSocket clients, so no delete event needed. @@ -1279,6 +1300,10 @@ async def websocket_endpoint(websocket: WebSocket): agent_name = (event.get("name") or "").strip() new_label = (event.get("label") or "").strip() if agent_name and new_label and registry: + registry.set_label(agent_name, new_label) + await broadcast_agents() + await broadcast_status() + continue # Derive a sanitized sender ID from the label import re as _re new_id = _re.sub(r'[^a-z0-9-]', '', new_label.lower().replace(' ', '-')).strip('-') @@ -1312,6 +1337,32 @@ async def websocket_endpoint(websocket: WebSocket): agent_name = (event.get("name") or "").strip() new_label = (event.get("label") or "").strip() if agent_name and registry: + if not new_label: + registry.confirm_pending(agent_name) + else: + new_id = canonicalize_name(new_label) + if new_id and new_id != canonicalize_name(agent_name): + result = registry.rename(agent_name, new_id, new_label) + if isinstance(result, str): + registry.set_label(agent_name, new_label) + registry.confirm_pending(agent_name) + else: + registry.confirm_pending(new_id) + import mcp_bridge + mcp_bridge.migrate_identity(agent_name, new_id) + store.rename_sender(agent_name, new_id) + rename_event = json.dumps({ + "type": "agent_renamed", + "old_name": agent_name, + "new_name": new_id, + }) + await _broadcast(rename_event) + else: + registry.set_label(agent_name, new_label) + registry.confirm_pending(agent_name) + await broadcast_status() + await broadcast_agents() + continue if not new_label: # Accept default name registry.confirm_pending(agent_name) @@ -1535,6 +1586,13 @@ async def api_send(request: Request): @app.get("/api/status") async def get_status(): status = agents.get_status() + if registry: + for name, info in registry.get_all().items(): + agent_status = status.setdefault(name, {}) + agent_status.setdefault("label", info.get("label", name)) + agent_status.setdefault("color", info.get("color", "#888")) + agent_status["base"] = info.get("base") + agent_status["state"] = info.get("state") status["paused"] = any(router.is_paused(ch) for ch in room_settings.get("channels", ["general"])) return status @@ -1544,6 +1602,99 @@ async def get_settings(): return room_settings +async def _handle_launcher_shutdown(request: Request): + """Stop this server when requested by the native desktop launcher.""" + client_ip = request.client.host if request.client else "" + if client_ip not in ("127.0.0.1", "::1", "localhost"): + return JSONResponse( + {"error": "forbidden: launcher shutdown is restricted to local loopback"}, + status_code=403, + ) + provided = ( + request.headers.get("x-launcher-token") + or request.query_params.get("launcher_token") + or "" + ) + if not launcher_shutdown_token or not secrets.compare_digest( + provided, launcher_shutdown_token + ): + return JSONResponse( + {"error": "forbidden: invalid or missing launcher token"}, + status_code=403, + ) + + def shutdown_process() -> None: + time.sleep(0.25) + try: + os.kill(os.getpid(), signal.SIGTERM) + except Exception: + os._exit(0) + + threading.Thread(target=shutdown_process, daemon=True).start() + return JSONResponse({"ok": True, "status": "shutting_down"}) + + +def _validate_launcher_request(request: Request) -> JSONResponse | None: + client_ip = request.client.host if request.client else "" + if client_ip not in ("127.0.0.1", "::1", "localhost"): + return JSONResponse( + {"error": "forbidden: launcher endpoint is restricted to local loopback"}, + status_code=403, + ) + if not launcher_shutdown_token: + return None + provided = ( + request.headers.get("x-launcher-token") + or request.query_params.get("launcher_token") + or "" + ) + if not secrets.compare_digest(provided, launcher_shutdown_token): + return JSONResponse( + {"error": "forbidden: invalid or missing launcher token"}, + status_code=403, + ) + return None + + +@app.post("/api/launcher/shutdown") +async def launcher_shutdown(request: Request): + return await _handle_launcher_shutdown(request) + + +@app.post("/api/shutdown_launcher_server") +async def shutdown_launcher_server(request: Request): + return await _handle_launcher_shutdown(request) + + +@app.post("/api/launcher/agents/{name}/release") +async def launcher_release_agent(name: str, request: Request): + """Release a launcher-owned agent identity after its wrapper process exits.""" + denied = _validate_launcher_request(request) + if denied: + return denied + + canonical = canonicalize_name(name) + result = registry.deregister(canonical) + + import mcp_bridge + + mcp_bridge.purge_identity(canonical) + registry.clean_renames_for(canonical) + if result: + renamed = result.pop("_renamed_back", None) + if renamed: + mcp_bridge.migrate_identity(renamed["old"], renamed["new"]) + store.rename_sender(renamed["old"], renamed["new"]) + if _event_loop: + rename_event = json.dumps({ + "type": "agent_renamed", + "old_name": renamed["old"], + "new_name": renamed["new"], + }) + asyncio.run_coroutine_threadsafe(_broadcast(rename_event), _event_loop) + return JSONResponse({"ok": True, "name": canonical, "released": bool(result)}) + + @app.delete("/api/hat/{agent_name}") async def delete_hat(agent_name: str): """Remove an agent's hat (called by the trash-can UI).""" @@ -2086,15 +2237,36 @@ async def register_agent(request: Request): return JSONResponse({"error": "invalid JSON"}, status_code=400) base = body.get("base", "") label = body.get("label") + preferred_name = body.get("preferred_name") if not base: return JSONResponse({"error": "base is required"}, status_code=400) - result = registry.register(base, label) + import mcp_bridge + + replace_existing = False + if preferred_name: + existing = registry.get_instance(preferred_name) + if existing: + if mcp_bridge.is_online(existing["name"]): + return JSONResponse( + {"error": "preferred_name_in_use", "name": existing["name"]}, + status_code=409, + ) + mcp_bridge.purge_identity(existing["name"]) + registry.clean_renames_for(existing["name"]) + replace_existing = True + + result = registry.register( + base, + label, + preferred_name=preferred_name, + replace_existing=replace_existing, + ) if result is None: return JSONResponse({"error": f"unknown base: {base}"}, status_code=400) + if result.get("error"): + return JSONResponse(result, status_code=409) # Touch presence so the instance doesn't immediately time out - import mcp_bridge - with mcp_bridge._presence_lock: - mcp_bridge._presence[result["name"]] = __import__("time").time() + mcp_bridge._touch_presence(result["name"]) # If slot 1 was renamed (e.g. "claude" → "claude-1"), migrate state renamed = result.pop("_renamed_slot1", None) if renamed: @@ -2165,6 +2337,15 @@ async def rename_agent_label(name: str, request: Request): if not label: return JSONResponse({"error": "label is required"}, status_code=400) + if registry.set_label(name, label): + inst = registry.get_instance(name) + return JSONResponse({ + "ok": True, + "name": inst["name"] if inst else registry.resolve_name(name), + "label": inst.get("label", label) if inst else label, + }) + return JSONResponse({"error": "not found"}, status_code=404) + import re as _re new_id = _re.sub(r'[^a-z0-9-]', '', label.lower().replace(' ', '-')).strip('-') if not new_id: @@ -2205,9 +2386,12 @@ async def heartbeat(agent_name: str, request: Request): if registry and registry.is_agent_family(agent_name) and not auth_inst: return JSONResponse({"error": "authenticated agent session required"}, status_code=403) - current_name = auth_inst["name"] if auth_inst else agent_name - with mcp_bridge._presence_lock: - mcp_bridge._presence[current_name] = __import__("time").time() + if auth_inst: + current_name = auth_inst["name"] + else: + resolved_name = registry.resolve_name(agent_name) + current_name = resolved_name if registry.is_registered(resolved_name) else canonicalize_name(agent_name) + mcp_bridge._touch_presence(current_name) # Optional activity report from wrapper's terminal monitor _activity_changed = False try: @@ -2222,6 +2406,7 @@ async def heartbeat(agent_name: str, request: Request): # Immediately broadcast on activity state change (don't wait for background checker) if _activity_changed: await broadcast_status() + await broadcast_typing(current_name, active_val) # Return canonical name so wrapper can track renames resp = {"ok": True, "name": current_name} if registry: @@ -2609,3 +2794,12 @@ async def serve_upload(filename: str): if filepath.exists(): return FileResponse(filepath) return JSONResponse({"error": "not found"}, status_code=404) + +@app.websocket("/ws/launcher/events") +async def ws_launcher_events(websocket: WebSocket): + token = websocket.query_params.get("token", "") + if token != session_token: + await websocket.accept() + await websocket.close(code=4003, reason="forbidden: invalid session token") + return + await launcher_events_ws(websocket) diff --git a/build_desktop_exe.py b/build_desktop_exe.py new file mode 100644 index 00000000..450a7635 --- /dev/null +++ b/build_desktop_exe.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Build the Windows desktop launcher as a PyInstaller onedir app.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +BUILD_DIR = ROOT / "build" +DIST_DIR = ROOT / "dist" +APP_DIR = DIST_DIR / "agentchattr" +ICON_SVG = ROOT / "static" / "agentchattr-icon.svg" +ICON_ICO = BUILD_DIR / "agentchattr-icon.ico" + +RESOURCE_FILES = [ + "config.toml", + "VERSION", + "LICENSE", +] + +RESOURCE_DIRS = [ + "static", + "session_templates", +] + +HIDDEN_IMPORTS = [ + "run", + "wrapper", + "wrapper_windows", + "wrapper_unix", + "wrapper_api", + "app", + "launcher_supervisor", + "launcher_routes", + "mcp_bridge", + "mcp_proxy", + "config_loader", + "agents", + "archive", + "jobs", + "registry", + "router", + "rules", + "schedules", + "session_engine", + "session_store", + "store", + "summaries", + "uvicorn", + "fastapi", + "mcp.server.fastmcp", +] + + +def _make_icon() -> None: + BUILD_DIR.mkdir(exist_ok=True) + try: + from PIL import Image + from PySide6.QtCore import QSize + from PySide6.QtGui import QGuiApplication, QIcon + except Exception as exc: + raise SystemExit( + "Icon generation requires Pillow and PySide6. " + "Install requirements-desktop.txt first." + ) from exc + + app = QGuiApplication.instance() or QGuiApplication([]) + icon = QIcon(str(ICON_SVG)) + images: list[Image.Image] = [] + for size in (16, 24, 32, 48, 64, 128, 256): + pixmap = icon.pixmap(QSize(size, size)) + if pixmap.isNull(): + raise SystemExit(f"Failed to render SVG icon: {ICON_SVG}") + png_path = BUILD_DIR / f"agentchattr-icon-{size}.png" + pixmap.save(str(png_path), "PNG") + images.append(Image.open(png_path).convert("RGBA")) + images[-1].save( + ICON_ICO, + format="ICO", + sizes=[(image.width, image.height) for image in images], + append_images=images[:-1], + ) + app.quit() + + +def _clean_previous_build() -> None: + for path in [APP_DIR, ROOT / "agentchattr.spec"]: + if path.is_dir(): + shutil.rmtree(path) + elif path.exists(): + path.unlink() + + +def _copy_runtime_resources() -> None: + APP_DIR.mkdir(parents=True, exist_ok=True) + if ICON_ICO.exists(): + shutil.copy2(ICON_ICO, APP_DIR / "agentchattr-icon.ico") + for rel in RESOURCE_FILES: + src = ROOT / rel + if src.exists(): + shutil.copy2(src, APP_DIR / rel) + for rel in RESOURCE_DIRS: + src = ROOT / rel + dst = APP_DIR / rel + if dst.exists(): + shutil.rmtree(dst) + if src.exists(): + shutil.copytree(src, dst) + + +def build() -> Path: + if os.name != "nt": + raise SystemExit("Desktop EXE packaging is supported on Windows only.") + _make_icon() + _clean_previous_build() + + cmd = [ + sys.executable, + "-m", + "PyInstaller", + "--noconfirm", + "--clean", + "--onedir", + "--windowed", + "--name", + "agentchattr", + "--icon", + str(ICON_ICO), + "--distpath", + str(DIST_DIR), + "--workpath", + str(BUILD_DIR / "pyinstaller"), + "--specpath", + str(BUILD_DIR), + ] + for module in HIDDEN_IMPORTS: + cmd.extend(["--hidden-import", module]) + cmd.append(str(ROOT / "desktop_launcher.py")) + + subprocess.run(cmd, cwd=str(ROOT), check=True) + _copy_runtime_resources() + exe = APP_DIR / "agentchattr.exe" + if not exe.exists(): + raise SystemExit(f"Build did not produce {exe}") + print(f"Built {exe}") + return exe + + +if __name__ == "__main__": + build() diff --git a/build_release.py b/build_release.py index 0beb2c53..add4dd19 100644 --- a/build_release.py +++ b/build_release.py @@ -18,7 +18,12 @@ "app.py", "agents.py", "config_loader.py", + "desktop_launcher.py", "jobs.py", + "launcher.py", + "launcher_routes.py", + "launcher_rules.py", + "launcher_supervisor.py", "mcp_bridge.py", "mcp_proxy.py", "registry.py", @@ -37,12 +42,12 @@ "open_chat.html", "config.toml", "config.local.toml.example", + "build_desktop_exe.py", "requirements.txt", + "requirements-desktop.txt", "README.md", "LICENSE", "VERSION", - "screenshot.png", - "gang.gif", ] INCLUDE_DIRS = [ @@ -50,6 +55,7 @@ "windows", "macos-linux", "session_templates", + "docs", ] diff --git a/config_loader.py b/config_loader.py index 4c2c0636..33ee313c 100644 --- a/config_loader.py +++ b/config_loader.py @@ -23,7 +23,15 @@ import tomllib from pathlib import Path -ROOT = Path(__file__).parent + +def app_root() -> Path: + """Return the install/runtime root for source and PyInstaller builds.""" + if getattr(sys, "frozen", False): + return Path(sys.executable).resolve().parent + return Path(__file__).parent + + +ROOT = app_root() # Mapping: env var name → (config section, key, is_int) diff --git a/desktop_launcher.py b/desktop_launcher.py new file mode 100644 index 00000000..bd3b13ed --- /dev/null +++ b/desktop_launcher.py @@ -0,0 +1,1522 @@ +"""Native PySide6 desktop launcher for agentchattr. + +The UI talks to Launcher from a dedicated QThread that owns its asyncio event +loop, keeping process supervision and status polling off the Qt main thread. +""" + +from __future__ import annotations + +import asyncio +import os +import sys +import time +import traceback +import webbrowser +from pathlib import Path +from typing import Any, Callable, Coroutine + +from PySide6.QtCore import Qt, QThread, Signal +from PySide6.QtGui import QFont, QGuiApplication, QIcon +from PySide6.QtWidgets import ( + QApplication, + QCheckBox, + QComboBox, + QDialog, + QDialogButtonBox, + QFormLayout, + QFrame, + QGridLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QMainWindow, + QMessageBox, + QPlainTextEdit, + QPushButton, + QScrollArea, + QStatusBar, + QStackedWidget, + QTabWidget, + QVBoxLayout, + QWidget, +) + +try: + from config_loader import load_config +except Exception: + load_config = None + +try: + from launcher_supervisor import Launcher +except ImportError: + # Compatibility fallback while launcher_supervisor is not present yet. + from launcher import Launcher + + +AsyncFactory = Callable[[Any], Coroutine[Any, Any, Any]] +INTERNAL_ARG = "--agentchattr-internal" +_STREAM_FALLBACKS: list[Any] = [] +APP_USER_MODEL_ID = "agentchattr.desktop.launcher" + + +def runtime_root() -> Path: + if getattr(sys, "frozen", False): + return Path(sys.executable).resolve().parent + return Path(__file__).resolve().parent + + +def app_icon() -> QIcon: + root = runtime_root() + icon = QIcon(str(root / "static" / "agentchattr-icon.svg")) + if not icon.isNull(): + return icon + return QIcon(str(root / "agentchattr-icon.ico")) + + +def set_windows_app_user_model_id() -> None: + if sys.platform != "win32": + return + try: + import ctypes + + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(APP_USER_MODEL_ID) + except Exception: + pass + + +def _ensure_internal_standard_streams() -> None: + if sys.stdin is None: + stream = open(os.devnull, "r", encoding="utf-8") + _STREAM_FALLBACKS.append(stream) + sys.stdin = stream + if sys.stdout is None: + stream = open(os.devnull, "w", encoding="utf-8") + _STREAM_FALLBACKS.append(stream) + sys.stdout = stream + if sys.stderr is None: + stream = open(os.devnull, "w", encoding="utf-8") + _STREAM_FALLBACKS.append(stream) + sys.stderr = stream + + +def _run_internal_subcommand() -> int | None: + if len(sys.argv) < 3 or sys.argv[1] != INTERNAL_ARG: + return None + + _ensure_internal_standard_streams() + command = sys.argv[2] + sys.argv = [sys.argv[0], *sys.argv[3:]] + try: + if command == "server": + import run + + run.main() + return 0 + if command == "wrapper": + import wrapper + + wrapper.main() + return 0 + print(f"Unknown internal command: {command}", file=sys.stderr) + return 2 + except Exception: + trace_path = os.environ.get("AGENTCHATTR_INTERNAL_TRACE") + if trace_path: + try: + path = Path(trace_path) + if not path.is_absolute(): + path = Path(sys.executable).resolve().parent / path + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "a", encoding="utf-8") as fh: + fh.write(traceback.format_exc()) + except Exception: + pass + raise + + +class LauncherThread(QThread): + ready = Signal() + status_updated = Signal(dict) + logs_updated = Signal(str, list) + operation_finished = Signal(str, object) + operation_failed = Signal(str, str) + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.loop: asyncio.AbstractEventLoop | None = None + self.launcher: Launcher | None = None + self._closing = False + self._poll_task: asyncio.Task | None = None + + def run(self) -> None: + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + try: + self.launcher = Launcher() + self._poll_task = self.loop.create_task(self._poll_status()) + self.ready.emit() + self.loop.run_forever() + finally: + pending = asyncio.all_tasks(self.loop) + for task in pending: + task.cancel() + if pending: + self.loop.run_until_complete( + asyncio.gather(*pending, return_exceptions=True) + ) + self.loop.close() + + def close(self) -> None: + self._closing = True + if self.loop and self.loop.is_running(): + self.loop.call_soon_threadsafe(self.loop.stop) + + def submit( + self, + name: str, + factory: AsyncFactory, + refresh: bool = True, + notify: bool = True, + ) -> None: + if not self.loop or not self.loop.is_running() or not self.launcher: + self.operation_failed.emit(name, "Launcher thread is not ready yet.") + return + + def schedule() -> None: + async def runner() -> None: + try: + result = await factory(self.launcher) + if notify: + self.operation_finished.emit(name, result) + if refresh: + await self._emit_status() + except Exception as exc: + detail = f"{exc}\n{traceback.format_exc()}" + self.operation_failed.emit(name, detail) + + self.loop.create_task(runner()) + + self.loop.call_soon_threadsafe(schedule) + + async def _poll_status(self) -> None: + while not self._closing: + try: + await self._emit_status() + except Exception as exc: + self.operation_failed.emit("status refresh", str(exc)) + await asyncio.sleep(1.0) + + async def _emit_status(self) -> None: + if not self.launcher: + return + status = await self.launcher.get_status() + self.status_updated.emit(status) + keys = ["server"] + seen: set[str] = set() + for key in keys: + if key in seen: + continue + seen.add(key) + logs = self.launcher.get_logs(key, 500) + if logs: + self.logs_updated.emit(key, logs) + + def start_server(self) -> None: + self.submit("Start Server", lambda launcher: launcher.start_server()) + + def stop_server(self) -> None: + self.submit("Stop Server", lambda launcher: launcher.stop_server()) + + def restart_server(self) -> None: + self.submit("Restart Server", lambda launcher: launcher.restart_process("server")) + + def start_agent( + self, + base: str, + mode: str = "normal", + role: str | None = None, + custom_role: str | None = None, + cwd: str | None = None, + ) -> None: + async def run(launcher: Launcher) -> dict: + return await launcher.start_agent( + base=base, + mode=mode, + role=role, + custom_role=custom_role, + cwd=cwd or None, + ) + + self.submit(f"Start Agent {base}", run) + + def stop_process(self, key: str) -> None: + self.submit(f"Stop {key}", lambda launcher: launcher.stop_process(key)) + + def start_existing_agent(self, key: str) -> None: + self.submit(f"Start {key}", lambda launcher: launcher.start_existing_agent(key)) + + def restart_process(self, key: str) -> None: + self.submit(f"Restart {key}", lambda launcher: launcher.restart_process(key)) + + def send_input(self, key: str, text: str) -> None: + self.submit( + f"Send input to {key}", + lambda launcher: launcher.send_input(key, text), + refresh=False, + ) + + def start_all(self) -> None: + async def run(launcher: Launcher) -> dict: + server_result = await self._ensure_server_running(launcher) + results: dict[str, Any] = { + "server": server_result, + "agents": [], + } + status = await launcher.get_status() + if not status.get("server", {}).get("running"): + results["agents_error"] = "Skipped agent startup because server is not running." + return results + for base in status.get("templates", {}): + results["agents"].append(await launcher.start_agent(base=base)) + return results + + self.submit("Start All", run) + + def stop_all(self) -> None: + async def run(launcher: Launcher) -> dict: + status = await launcher.get_status() + results: dict[str, Any] = {"agents": [], "server": None} + for key, process in status.get("processes", {}).items(): + if process.get("kind") == "agent" and process.get("started_by_launcher"): + results["agents"].append(await launcher.stop_process(key)) + results["server"] = await launcher.stop_server() + return results + + self.submit("Stop All", run) + + def restart_all(self) -> None: + async def run(launcher: Launcher) -> dict: + stopped = await self._stop_all_for_restart(launcher) + server_result = await self._ensure_server_running(launcher) + started: dict[str, Any] = { + "server": server_result, + "agents": [], + } + status = await launcher.get_status() + if not status.get("server", {}).get("running"): + started["agents_error"] = "Skipped agent startup because server is not running." + return {"stopped": stopped, "started": started} + for base in status.get("templates", {}): + started["agents"].append(await launcher.start_agent(base=base)) + return {"stopped": stopped, "started": started} + + self.submit("Restart All", run) + + async def _ensure_server_running(self, launcher: Launcher) -> dict: + status = await launcher.get_status() + if status.get("server", {}).get("running"): + return {"status": "running"} + + result = await launcher.start_server() + for _ in range(30): + await asyncio.sleep(0.5) + status = await launcher.get_status() + if status.get("server", {}).get("running"): + return result + return { + "error": "Server did not report running before agent startup timeout", + "start_result": result, + } + + async def _stop_all_for_restart(self, launcher: Launcher) -> dict: + status = await launcher.get_status() + results: dict[str, Any] = {"agents": [], "server": None} + for key, process in status.get("processes", {}).items(): + if process.get("kind") == "agent" and process.get("started_by_launcher"): + results["agents"].append(await launcher.stop_process(key)) + results["server"] = await launcher.stop_server() + await asyncio.sleep(0.5) + return results + + +APP_QSS = """ +QMainWindow, QWidget#root { + background: #f4f7fb; + color: #182230; + font-family: "Microsoft YaHei UI", "Microsoft YaHei", "Segoe UI", Arial, sans-serif; + font-size: 13px; +} +QFrame#topBar, QFrame#pageNav { + background: #ffffff; + border-bottom: 1px solid #d8e0ea; +} +QLabel#logoImage { + background: transparent; + border: 0; +} +QLabel#topTitle { + color: #0c111d; + font-size: 16px; + font-weight: 700; +} +QPushButton { + border: 1px solid #d8e0ea; + border-radius: 6px; + background: #ffffff; + color: #667085; + padding: 7px 13px; + font-weight: 600; +} +QPushButton:hover { + background: #f8fafc; + border-color: #b8c4d4; + color: #182230; +} +QPushButton[variant="primary"] { + background: #2563eb; + border-color: #2563eb; + color: #ffffff; +} +QPushButton[variant="primary"]:hover { background: #1d4ed8; } +QPushButton[variant="success"] { + background: #16a34a; + border-color: #16a34a; + color: #ffffff; +} +QPushButton[variant="success"]:hover { background: #15803d; } +QPushButton[variant="danger"] { + background: #dc2626; + border-color: #dc2626; + color: #ffffff; +} +QPushButton[variant="danger"]:hover { background: #b91c1c; } +QPushButton[variant="outline-success"] { + background: #ecfdf3; + border-color: rgba(22, 163, 74, 80); + color: #16a34a; +} +QPushButton[variant="outline-danger"] { + background: #fef2f2; + border-color: rgba(220, 38, 38, 80); + color: #dc2626; +} +QPushButton[variant="ghost"] { + background: transparent; + border-color: transparent; +} +QPushButton[nav="true"] { + border: 0; + border-radius: 0; + background: #ffffff; + padding: 12px 22px; + color: #667085; +} +QPushButton[nav="true"]:checked { + color: #2563eb; + border-bottom: 3px solid #2563eb; + font-weight: 800; +} +QFrame[card="true"] { + background: #ffffff; + border: 1px solid #d8e0ea; + border-radius: 10px; +} +QFrame[agentCard="true"] { + background: #f8fafc; + border: 1px solid #d8e0ea; + border-radius: 10px; +} +QFrame[metric="true"], QFrame[infoRow="true"], QFrame[summaryRow="true"] { + background: #f8fafc; + border: 1px solid #d8e0ea; + border-radius: 8px; +} +QLabel[heading="true"] { + color: #0c111d; + font-weight: 800; + font-size: 14px; +} +QLabel[muted="true"] { + color: #667085; +} +QLabel[pill="online"] { + color: #16a34a; + background: #ecfdf3; + border-radius: 13px; + padding: 4px 10px; + font-weight: 800; +} +QLabel[pill="working"] { + color: #d97706; + background: #fffbeb; + border-radius: 13px; + padding: 4px 10px; + font-weight: 800; +} +QLabel[pill="offline"] { + color: #667085; + background: #f8f9fb; + border-radius: 13px; + padding: 4px 10px; + font-weight: 800; +} +QLabel[pill="error"] { + color: #dc2626; + background: #fef2f2; + border-radius: 13px; + padding: 4px 10px; + font-weight: 800; +} +QLabel[avatar="true"] { + color: #ffffff; + border-radius: 9px; + font-size: 15px; + font-weight: 900; +} +QLabel#terminalTitle { + color: #0c111d; + font-weight: 800; +} +QScrollArea { + border: 0; + background: transparent; +} +QScrollArea > QWidget > QWidget { + background: transparent; +} +QTabWidget::pane { + border: 1px solid #d8e0ea; + border-radius: 8px; + background: #ffffff; +} +QTabBar::tab { + background: #f8fafc; + border: 1px solid #d8e0ea; + padding: 8px 14px; + color: #667085; +} +QTabBar::tab:selected { + background: #ffffff; + color: #2563eb; + font-weight: 800; +} +QPlainTextEdit, QLineEdit, QComboBox { + background: #f8fafc; + border: 1px solid #d8e0ea; + border-radius: 8px; + padding: 7px; + color: #182230; +} +QStatusBar { + background: #ffffff; + border-top: 1px solid #d8e0ea; + color: #667085; +} +""" + + +def repolish(widget: QWidget) -> None: + widget.style().unpolish(widget) + widget.style().polish(widget) + + +def button(text: str, variant: str = "", *, fixed_width: int | None = None) -> QPushButton: + btn = QPushButton(text) + if variant: + btn.setProperty("variant", variant) + if fixed_width: + btn.setFixedWidth(fixed_width) + btn.setCursor(Qt.CursorShape.PointingHandCursor) + return btn + + +def label(text: str = "", *, heading: bool = False, muted: bool = False) -> QLabel: + lbl = QLabel(text) + if heading: + lbl.setProperty("heading", True) + if muted: + lbl.setProperty("muted", True) + return lbl + + +def card() -> QFrame: + frame = QFrame() + frame.setProperty("card", True) + return frame + + +def status_kind(status: str | None) -> str: + value = (status or "").lower() + if value in {"running", "active", "online"}: + return "online" + if value in {"starting", "stopping", "working", "pending"}: + return "working" + if value in {"error", "occupied"}: + return "error" + return "offline" + + +def agent_display_status(proc: dict[str, Any]) -> str: + if proc.get("busy"): + return "working" + if proc.get("available"): + return "active" + status = str(proc.get("status") or "unknown").lower() + if status == "online": + return "active" + return status + + +def summarize_agents(agent_processes: dict[str, dict[str, Any]]) -> dict[str, int]: + counts = {"online": 0, "working": 0, "offline": 0, "error": 0} + for proc in agent_processes.values(): + status = agent_display_status(proc) + if status == "working": + counts["working"] += 1 + elif status in {"running", "active", "online"}: + counts["online"] += 1 + elif status == "error": + counts["error"] += 1 + else: + counts["offline"] += 1 + return counts + + +def status_text(status: str | None, external: bool = False) -> str: + if external and status in {"running", "active", "online"}: + return "外部运行" + return { + "running": "运行中", + "active": "在线", + "working": "工作中", + "starting": "启动中", + "stopping": "停止中", + "stopped": "离线", + "error": "异常", + "occupied": "端口占用", + "pending": "连接中", + }.get(status or "", status or "未知") + + +def make_pill(text: str, kind: str) -> QLabel: + pill = QLabel(text) + pill.setAlignment(Qt.AlignmentFlag.AlignCenter) + pill.setProperty("pill", kind) + return pill + + +def short_name(value: str | None) -> str: + raw = (value or "??").replace("-", " ") + parts = [part for part in raw.split() if part] + if not parts: + return "??" + if len(parts) == 1: + return parts[0][:2].upper() + return "".join(part[0] for part in parts[:2]).upper() + + +class AddAgentDialog(QDialog): + def __init__(self, templates: dict[str, dict[str, Any]], parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setWindowTitle("新增代理") + self.setModal(True) + self.setMinimumWidth(430) + self.templates = templates + self.setStyleSheet(APP_QSS) + + self.base = QComboBox() + for key, template in sorted(templates.items()): + label_text = template.get("label") or key + self.base.addItem(f"{label_text} ({key})", key) + + self.mode = QComboBox() + self.mode.addItem("普通模式", "normal") + self.mode.addItem("Yolo 模式", "yolo") + + self.role = QComboBox() + self.role.addItem("无", None) + self.role.addItem("Planner - 规划者", "planner") + self.role.addItem("Builder - 构建者", "builder") + self.role.addItem("Reviewer - 审查者", "reviewer") + self.role.addItem("Researcher - 研究者", "researcher") + self.role.addItem("自定义", "custom") + + self.custom_role = QLineEdit() + self.custom_role.setPlaceholderText("仅在角色为自定义时使用") + + self.cwd = QLineEdit() + self.cwd.setPlaceholderText("可选工作目录,如 D:\\kimicode") + + self.start_immediately = QCheckBox("保存后立即启动") + self.start_immediately.setChecked(True) + self.start_immediately.setEnabled(False) + + form = QFormLayout() + form.setSpacing(12) + form.addRow("代理类型", self.base) + form.addRow("启动模式", self.mode) + form.addRow("角色", self.role) + form.addRow("自定义角色", self.custom_role) + form.addRow("工作目录", self.cwd) + form.addRow("", self.start_immediately) + + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok + | QDialogButtonBox.StandardButton.Cancel + ) + buttons.button(QDialogButtonBox.StandardButton.Ok).setText("启动") + buttons.button(QDialogButtonBox.StandardButton.Cancel).setText("取消") + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + + layout = QVBoxLayout(self) + layout.setContentsMargins(22, 20, 22, 20) + title = label("新增代理", heading=True) + hint = label("实例名称由 wrapper.py / registry 自动分配", muted=True) + layout.addWidget(title) + layout.addWidget(hint) + layout.addSpacing(10) + layout.addLayout(form) + layout.addSpacing(8) + layout.addWidget(buttons) + self.base.currentIndexChanged.connect(self._sync_mode) + self._sync_mode() + + def _sync_mode(self, *_args: object) -> None: + base = self.base.currentData() + supports_yolo = bool(self.templates.get(base, {}).get("supports_yolo")) + self.mode.model().item(1).setEnabled(supports_yolo) + if not supports_yolo and self.mode.currentData() == "yolo": + self.mode.setCurrentIndex(0) + + def values(self) -> dict[str, Any]: + return { + "base": self.base.currentData(), + "mode": self.mode.currentData(), + "role": self.role.currentData(), + "custom_role": self.custom_role.text().strip() or None, + "cwd": self.cwd.text().strip() or None, + } + + +class DesktopLauncher(QMainWindow): + def __init__(self) -> None: + super().__init__() + self.setWindowTitle("agentchattr 控制台") + self.setWindowIcon(app_icon()) + self.resize(1320, 820) + self.setMinimumSize(980, 650) + self.setStyleSheet(APP_QSS) + self.latest_status: dict[str, Any] = {} + self.templates: dict[str, dict[str, Any]] = {} + self.agent_rows: list[tuple[str, dict[str, Any]]] = [] + self.log_editors: dict[str, QPlainTextEdit] = {} + self.log_clear_after: dict[str, float] = {} + self.recent_events: list[str] = [] + + self.worker = LauncherThread(self) + self.worker.ready.connect(lambda: self.statusBar().showMessage("Launcher ready", 2500)) + self.worker.status_updated.connect(self.apply_status) + self.worker.logs_updated.connect(self.apply_logs) + self.worker.operation_finished.connect(self.operation_finished) + self.worker.operation_failed.connect(self.operation_failed) + + self._build_shell() + self.setStatusBar(QStatusBar()) + self.statusBar().showMessage("Starting launcher thread...") + self.worker.start() + + def closeEvent(self, event) -> None: + self.worker.close() + self.worker.wait(2500) + super().closeEvent(event) + + def _build_shell(self) -> None: + root = QWidget() + root.setObjectName("root") + layout = QVBoxLayout(root) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + layout.addWidget(self._build_top_bar()) + layout.addWidget(self._build_nav()) + self.stack = QStackedWidget() + self.stack.addWidget(self._build_overview_page()) + self.stack.addWidget(self._build_agents_page()) + self.stack.addWidget(self._build_logs_page()) + self.stack.addWidget(self._build_settings_page()) + layout.addWidget(self.stack, 1) + self.setCentralWidget(root) + + def _build_top_bar(self) -> QFrame: + bar = QFrame() + bar.setObjectName("topBar") + bar.setFixedHeight(62) + layout = QHBoxLayout(bar) + layout.setContentsMargins(20, 10, 20, 10) + layout.setSpacing(12) + + logo = QLabel() + logo.setObjectName("logoImage") + logo.setFixedSize(30, 30) + logo.setAlignment(Qt.AlignmentFlag.AlignCenter) + icon_pixmap = app_icon().pixmap(30, 30) + if icon_pixmap.isNull(): + logo.setText("A") + else: + logo.setPixmap(icon_pixmap) + title = QLabel("agentchattr 控制台") + title.setObjectName("topTitle") + layout.addWidget(logo) + layout.addWidget(title) + layout.addStretch(1) + + actions = [ + ("■ 打开聊天", "primary", self.open_chat), + ("▶ 全部启动", "success", self.worker.start_all), + ("■ 全部停止", "danger", self.worker.stop_all), + ("↻ 全部重启", "", self.worker.restart_all), + ] + for text, variant, callback in actions: + btn = button(text, variant) + btn.clicked.connect(lambda _checked=False, cb=callback: cb()) + layout.addWidget(btn) + return bar + + def _build_nav(self) -> QFrame: + nav = QFrame() + nav.setObjectName("pageNav") + nav.setFixedHeight(44) + layout = QHBoxLayout(nav) + layout.setContentsMargins(20, 0, 20, 0) + layout.setSpacing(0) + self.nav_buttons: list[QPushButton] = [] + for index, text in enumerate(["■ 概览", "● 代理", "■ 终端", "⚙ 设置"]): + btn = QPushButton(text) + btn.setCheckable(True) + btn.setProperty("nav", True) + btn.clicked.connect(lambda _checked=False, i=index: self._set_page(i)) + layout.addWidget(btn) + self.nav_buttons.append(btn) + layout.addStretch(1) + self.nav_buttons[0].setChecked(True) + return nav + + def _set_page(self, index: int) -> None: + self.stack.setCurrentIndex(index) + for i, btn in enumerate(self.nav_buttons): + btn.setChecked(i == index) + + def _page_scroll(self, content: QWidget) -> QScrollArea: + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setWidget(content) + return scroll + + def _build_overview_page(self) -> QWidget: + page = QWidget() + page_layout = QHBoxLayout(page) + page_layout.setContentsMargins(20, 16, 20, 16) + page_layout.setSpacing(16) + + left = self._build_server_card() + left.setFixedWidth(280) + center = self._build_dashboard_agents_card() + right = self._build_right_column() + right.setFixedWidth(220) + + page_layout.addWidget(left) + page_layout.addWidget(center, 1) + page_layout.addWidget(right) + return page + + def _build_server_card(self) -> QFrame: + panel = card() + layout = QVBoxLayout(panel) + layout.setContentsMargins(16, 16, 16, 16) + layout.setSpacing(12) + + header = QHBoxLayout() + header.addWidget(label("服务", heading=True)) + self.server_status_pill = make_pill("检测中", "working") + header.addWidget(self.server_status_pill) + layout.addLayout(header) + + controls = QGridLayout() + self.btn_start_server = button("▶ 启动服务", "success") + self.btn_stop_server = button("■ 停止服务", "danger") + self.btn_restart_server = button("↻ 重启服务") + self.btn_open_chat = button("■ 打开聊天", "primary") + self.btn_start_server.clicked.connect(lambda _checked=False: self.worker.start_server()) + self.btn_stop_server.clicked.connect(lambda _checked=False: self.worker.stop_server()) + self.btn_restart_server.clicked.connect(lambda _checked=False: self.worker.restart_server()) + self.btn_open_chat.clicked.connect(lambda _checked=False: self.open_chat()) + controls.addWidget(self.btn_start_server, 0, 0) + controls.addWidget(self.btn_stop_server, 0, 1) + controls.addWidget(self.btn_restart_server, 1, 0, 1, 2) + controls.addWidget(self.btn_open_chat, 2, 0, 1, 2) + layout.addLayout(controls) + + layout.addWidget(label("服务信息", heading=True)) + self.info_rows: dict[str, QLabel] = {} + for key, title in [ + ("port", "HTTP 端口"), + ("mcp_sse", "MCP SSE"), + ("mcp_http", "MCP HTTP"), + ("data_dir", "数据目录"), + ]: + row, value = self._info_row(title, "--") + self.info_rows[key] = value + layout.addWidget(row) + + layout.addWidget(label("资源", heading=True)) + metrics = QGridLayout() + self.metric_online = self._metric("0", "在线") + self.metric_working = self._metric("0", "工作中") + self.metric_memory = self._metric("--", "内存占用") + self.metric_errors = self._metric("0", "近1小时错误") + metrics.addWidget(self.metric_online, 0, 0) + metrics.addWidget(self.metric_working, 0, 1) + metrics.addWidget(self.metric_memory, 1, 0) + metrics.addWidget(self.metric_errors, 1, 1) + layout.addLayout(metrics) + + layout.addStretch(1) + return panel + + def _info_row(self, title: str, value: str) -> tuple[QFrame, QLabel]: + row = QFrame() + row.setProperty("infoRow", True) + layout = QHBoxLayout(row) + layout.setContentsMargins(12, 9, 12, 9) + title_label = label(title, muted=True) + value_label = QLabel(value) + value_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + value_label.setWordWrap(True) + layout.addWidget(title_label) + layout.addWidget(value_label, 1) + return row, value_label + + def _metric(self, value: str, caption: str) -> QFrame: + frame = QFrame() + frame.setProperty("metric", True) + layout = QVBoxLayout(frame) + layout.setContentsMargins(8, 12, 8, 12) + val = QLabel(value) + val.setAlignment(Qt.AlignmentFlag.AlignCenter) + val.setStyleSheet("font-size: 24px; font-weight: 900; color: #182230;") + cap = label(caption, muted=True) + cap.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(val) + layout.addWidget(cap) + frame.value_label = val # type: ignore[attr-defined] + return frame + + def _build_dashboard_agents_card(self) -> QFrame: + panel = card() + layout = QVBoxLayout(panel) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + header = QHBoxLayout() + header.setContentsMargins(16, 14, 16, 12) + header.addWidget(label("代理实例", heading=True)) + self.agent_summary_label = label("0 个实例", muted=True) + header.addStretch(1) + header.addWidget(self.agent_summary_label) + layout.addLayout(header) + + toolbar = QHBoxLayout() + toolbar.setContentsMargins(16, 0, 16, 12) + self.filter_label = label("全部 (0)", muted=True) + self.filter_label.setStyleSheet( + "background:#ffffff;border:1px solid #d8e0ea;border-radius:8px;" + "padding:8px 14px;color:#182230;font-weight:700;" + ) + add_btn = button("+ 新增代理", "primary") + add_btn.clicked.connect(self.add_agent) + toolbar.addWidget(self.filter_label) + toolbar.addStretch(1) + toolbar.addWidget(add_btn) + layout.addLayout(toolbar) + + self.dashboard_agent_list = QVBoxLayout() + self.dashboard_agent_list.setContentsMargins(16, 0, 16, 16) + self.dashboard_agent_list.setSpacing(10) + list_host = QWidget() + list_host.setLayout(self.dashboard_agent_list) + scroll = self._page_scroll(list_host) + layout.addWidget(scroll, 1) + return panel + + def _build_right_column(self) -> QWidget: + host = QWidget() + layout = QVBoxLayout(host) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(16) + layout.addWidget(self._build_summary_card()) + layout.addWidget(self._build_events_card(), 1) + return host + + def _build_summary_card(self) -> QFrame: + panel = card() + layout = QVBoxLayout(panel) + layout.setContentsMargins(16, 16, 16, 16) + layout.setSpacing(10) + layout.addWidget(label("运行摘要", heading=True)) + self.summary_values: dict[str, QLabel] = {} + for key, title, color in [ + ("online", "在线代理", "#16a34a"), + ("working", "工作中", "#d97706"), + ("offline", "离线代理", "#667085"), + ("error", "异常", "#dc2626"), + ]: + row = QFrame() + row.setProperty("summaryRow", True) + row_layout = QHBoxLayout(row) + row_layout.setContentsMargins(12, 10, 12, 10) + row_layout.addWidget(label(title, muted=True)) + val = QLabel("0") + val.setAlignment(Qt.AlignmentFlag.AlignRight) + val.setStyleSheet(f"color:{color};font-size:18px;font-weight:900;") + row_layout.addWidget(val, 1) + self.summary_values[key] = val + layout.addWidget(row) + return panel + + def _build_events_card(self) -> QFrame: + panel = card() + layout = QVBoxLayout(panel) + layout.setContentsMargins(0, 0, 0, 0) + header = QHBoxLayout() + header.setContentsMargins(16, 14, 16, 12) + header.addWidget(label("最近事件", heading=True)) + header.addStretch(1) + view_logs = button("查看全部", "ghost") + view_logs.clicked.connect(lambda: self._set_page(2)) + header.addWidget(view_logs) + layout.addLayout(header) + self.events_list = QVBoxLayout() + self.events_list.setContentsMargins(16, 0, 16, 16) + self.events_list.setSpacing(8) + host = QWidget() + host.setLayout(self.events_list) + layout.addWidget(host) + return panel + + def _build_agents_page(self) -> QWidget: + page = QWidget() + layout = QVBoxLayout(page) + layout.setContentsMargins(20, 16, 20, 16) + layout.setSpacing(0) + panel = card() + panel_layout = QVBoxLayout(panel) + panel_layout.setContentsMargins(0, 0, 0, 0) + header = QHBoxLayout() + header.setContentsMargins(16, 14, 16, 12) + header.addWidget(label("代理管理", heading=True)) + header.addStretch(1) + add_btn = button("+ 新增代理", "primary") + add_btn.clicked.connect(self.add_agent) + header.addWidget(add_btn) + panel_layout.addLayout(header) + self.agents_page_list = QVBoxLayout() + self.agents_page_list.setContentsMargins(16, 0, 16, 16) + self.agents_page_list.setSpacing(10) + host = QWidget() + host.setLayout(self.agents_page_list) + panel_layout.addWidget(self._page_scroll(host), 1) + layout.addWidget(panel) + return page + + def _build_logs_page(self) -> QWidget: + page = QWidget() + layout = QVBoxLayout(page) + layout.setContentsMargins(20, 16, 20, 16) + panel = card() + panel_layout = QVBoxLayout(panel) + panel_layout.setContentsMargins(16, 14, 16, 16) + toolbar = QHBoxLayout() + title = QLabel("服务终端 - Server stdout/stderr") + title.setObjectName("terminalTitle") + toolbar.addWidget(title) + toolbar.addStretch(1) + clear_button = button("清空", "ghost") + copy_button = button("复制", "ghost") + clear_button.clicked.connect(self.clear_current_log) + copy_button.clicked.connect(self.copy_current_log) + toolbar.addWidget(clear_button) + toolbar.addWidget(copy_button) + panel_layout.addLayout(toolbar) + self.logs_tabs = QTabWidget() + self.logs_tabs.currentChanged.connect(self._update_terminal_input_state) + panel_layout.addWidget(self.logs_tabs, 1) + self._log_editor("server") + + input_row = QHBoxLayout() + input_row.setSpacing(8) + self.terminal_input = QLineEdit() + self.terminal_input.setPlaceholderText("服务日志只读;agent CLI 请在 Windows Terminal 中操作") + self.terminal_input.returnPressed.connect(self.send_current_input) + self.terminal_input.textChanged.connect(self._update_terminal_input_state) + self.terminal_send_button = button("发送", "primary", fixed_width=82) + self.terminal_send_button.clicked.connect(self.send_current_input) + input_row.addWidget(self.terminal_input, 1) + input_row.addWidget(self.terminal_send_button) + panel_layout.addLayout(input_row) + + self.terminal_status_label = label( + "这里仅显示 launcher 启动的 server 日志;Claude/Codex/Kimi 等 CLI 在 Windows Terminal 中操作。", + muted=True, + ) + self.terminal_status_label.setWordWrap(True) + panel_layout.addWidget(self.terminal_status_label) + self._update_terminal_input_state() + + layout.addWidget(panel) + return page + + def _build_settings_page(self) -> QWidget: + page = QWidget() + layout = QVBoxLayout(page) + layout.setContentsMargins(20, 16, 20, 16) + layout.setSpacing(14) + + header = QVBoxLayout() + header.setSpacing(4) + header.addWidget(label("设置", heading=True)) + header.addWidget(label("配置仍以 config.toml 为准;这里展示桌面启动器实际读取到的运行信息。", muted=True)) + layout.addLayout(header) + + self.settings_values: dict[str, QLabel] = {} + + content = QWidget() + content_layout = QVBoxLayout(content) + content_layout.setContentsMargins(0, 0, 0, 0) + content_layout.setSpacing(14) + + service_card, service_form = self._settings_card("服务配置", "Web UI 和 server 运行参数") + self._add_setting_row(service_form, "服务状态", "server_status") + self._add_setting_row(service_form, "访问地址", "server_url") + self._add_setting_row(service_form, "监听端口", "server_port") + self._add_setting_row(service_form, "管理方式", "server_managed") + content_layout.addWidget(service_card) + + mcp_card, mcp_form = self._settings_card("MCP 端口", "供各 agent CLI 连接 agentchattr") + self._add_setting_row(mcp_form, "Streamable HTTP", "mcp_http") + self._add_setting_row(mcp_form, "SSE", "mcp_sse") + content_layout.addWidget(mcp_card) + + data_card, data_form = self._settings_card("数据目录", "运行数据、上传文件和本地配置位置") + self._add_setting_row(data_form, "数据目录", "data_dir") + self._add_setting_row(data_form, "上传目录", "upload_dir") + self._add_setting_row(data_form, "配置文件", "config_file") + content_layout.addWidget(data_card) + + templates_card, templates_layout = self._settings_card("Agent templates", "可由启动器创建的 agent 类型") + self.settings_templates_layout = QVBoxLayout() + self.settings_templates_layout.setContentsMargins(0, 4, 0, 0) + self.settings_templates_layout.setSpacing(8) + templates_layout.addRow(self.settings_templates_layout) + content_layout.addWidget(templates_card) + + help_card, help_layout = self._settings_card("操作说明", "状态和按钮的含义") + for text in [ + "运行摘要以 server /api/status 的 busy/available 为准;busy 会显示为“工作中”。", + "外部启动的 agent 可以展示状态,但不能由桌面启动器停止或重启。", + "修改端口、数据目录或 agent 命令后,需要重启 server 才会生效。", + "新增 agent 会先启动 wrapper.py,实例名称由 registry 分配并回填。", + ]: + item = label(text, muted=True) + item.setWordWrap(True) + help_layout.addRow(item) + content_layout.addWidget(help_card) + content_layout.addStretch(1) + + layout.addWidget(self._page_scroll(content), 1) + self._update_settings_summary({}) + return page + + def _load_config_summary(self) -> dict[str, Any]: + if not load_config: + return {} + try: + loaded = load_config() + return loaded if isinstance(loaded, dict) else {} + except Exception: + return {} + + def _settings_card(self, title: str, subtitle: str = "") -> tuple[QFrame, QFormLayout]: + frame = card() + layout = QVBoxLayout(frame) + layout.setContentsMargins(16, 14, 16, 16) + layout.setSpacing(8) + layout.addWidget(label(title, heading=True)) + if subtitle: + hint = label(subtitle, muted=True) + hint.setWordWrap(True) + layout.addWidget(hint) + form = QFormLayout() + form.setLabelAlignment(Qt.AlignmentFlag.AlignLeft) + form.setFormAlignment(Qt.AlignmentFlag.AlignTop) + form.setHorizontalSpacing(20) + form.setVerticalSpacing(9) + layout.addLayout(form) + return frame, form + + def _add_setting_row(self, form: QFormLayout, title: str, key: str) -> QLabel: + value = QLabel("--") + value.setWordWrap(True) + value.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + form.addRow(label(title, muted=True), value) + self.settings_values[key] = value + return value + + def apply_status(self, status: dict) -> None: + self.latest_status = status + self.templates = status.get("templates", {}) + server = status.get("server", {}) + processes = status.get("processes", {}) + agent_processes = { + key: proc for key, proc in processes.items() if proc.get("kind") == "agent" + } + + running = server.get("status") or ("running" if server.get("running") else "stopped") + host = server.get("host", "127.0.0.1") + port = server.get("port", 8300) + status_label = status_text(running) + status_label = "运行中" if server.get("running") else status_label + kind = status_kind("running" if server.get("running") else running) + self.server_status_pill.setText(f"● {status_label}") + self.server_status_pill.setProperty("pill", kind) + repolish(self.server_status_pill) + server_running = bool(server.get("running")) + self.btn_start_server.setEnabled(not server_running) + self.btn_stop_server.setEnabled(server_running) + self.btn_restart_server.setEnabled(server_running) + + self.info_rows["port"].setText(str(port)) + self.info_rows["mcp_sse"].setText(f"端口 {server.get('mcp_sse_port', '--')}") + self.info_rows["mcp_http"].setText(f"端口 {server.get('mcp_http_port', '--')}") + self.info_rows["data_dir"].setText(str(server.get("data_dir", "--"))) + + counts = summarize_agents(agent_processes) + online = counts["online"] + working = counts["working"] + errors = counts["error"] + offline = counts["offline"] + self.metric_online.value_label.setText(str(online)) # type: ignore[attr-defined] + self.metric_working.value_label.setText(str(working)) # type: ignore[attr-defined] + self.metric_memory.value_label.setText("--") # type: ignore[attr-defined] + self.metric_errors.value_label.setText(str(errors)) # type: ignore[attr-defined] + self.summary_values["online"].setText(str(online)) + self.summary_values["working"].setText(str(working)) + self.summary_values["offline"].setText(str(offline)) + self.summary_values["error"].setText(str(errors)) + + self.agent_summary_label.setText( + f"{len(agent_processes)} 个实例 · {online} 在线 · {working} 工作中" + ) + self.filter_label.setText(f"全部 ({len(agent_processes)})") + self.agent_rows = self._sorted_agents(agent_processes) + self._render_agent_lists() + self._render_events() + self._update_settings_summary(status) + self._update_terminal_input_state() + + def _sorted_agents(self, agents: dict[str, dict[str, Any]]) -> list[tuple[str, dict[str, Any]]]: + return sorted( + agents.items(), + key=lambda item: ( + item[1].get("base") or "", + item[1].get("assigned_name") or item[0], + ), + ) + + def _clear_layout(self, layout: QVBoxLayout) -> None: + while layout.count(): + item = layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.deleteLater() + + def _render_agent_lists(self) -> None: + for layout in (self.dashboard_agent_list, self.agents_page_list): + self._clear_layout(layout) + if not self.agent_rows: + empty = label("暂无代理实例", muted=True) + empty.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(empty) + else: + for key, proc in self.agent_rows: + layout.addWidget(self._agent_card(key, proc)) + layout.addStretch(1) + + def _agent_card(self, key: str, proc: dict[str, Any]) -> QFrame: + base = proc.get("base") or "agent" + name = proc.get("assigned_name") or base + tmpl = self.templates.get(base, {}) + status = agent_display_status(proc) + external = not bool(proc.get("started_by_launcher")) + role = proc.get("role") or "" + mode = proc.get("mode") or "" + + frame = QFrame() + frame.setProperty("agentCard", True) + frame.setMinimumHeight(82) + layout = QHBoxLayout(frame) + layout.setContentsMargins(14, 12, 14, 12) + layout.setSpacing(14) + + avatar = QLabel(short_name(name)) + avatar.setProperty("avatar", True) + avatar.setFixedSize(40, 40) + avatar.setAlignment(Qt.AlignmentFlag.AlignCenter) + avatar.setStyleSheet(f"background:{tmpl.get('color', '#2563eb')};") + layout.addWidget(avatar) + + body = QVBoxLayout() + body.setSpacing(5) + top = QHBoxLayout() + top.setSpacing(8) + name_label = QLabel(str(name)) + name_label.setStyleSheet("font-size:15px;font-weight:900;color:#0c111d;") + base_label = make_pill(str(base), "offline") + state = make_pill( + f"● {status_text(status, external)}", + status_kind(status), + ) + top.addWidget(name_label) + top.addWidget(base_label) + top.addWidget(state) + if mode == "yolo": + top.addWidget(make_pill("Yolo", "working")) + top.addStretch(1) + body.addLayout(top) + + subtitle = f"{tmpl.get('label', base.capitalize())} 代理" + if role: + subtitle = f"{role} · {subtitle}" + body.addWidget(label(subtitle, muted=True)) + + terminal_capability = str(proc.get("terminal_capability") or "") + if external: + ownership = "外部进程" + elif terminal_capability == "windows_terminal": + ownership = "由启动器管理 · Windows Terminal" + else: + ownership = "由启动器管理" + meta = QLabel(f"● PID: {proc.get('pid') or '未运行'} ■ {ownership}") + meta.setProperty("muted", True) + body.addWidget(meta) + layout.addLayout(body, 1) + + launcher_owned = bool(proc.get("started_by_launcher")) + can_stop = launcher_owned and status in {"running", "active", "working", "starting"} + can_restart = launcher_owned and status in {"running", "active", "working"} + can_start = launcher_owned and status in {"stopped", "error"} + if can_stop: + stop = button("停止", "outline-danger", fixed_width=72) + stop.clicked.connect(lambda _checked=False, k=key: self.worker.stop_process(k)) + layout.addWidget(stop) + elif can_start: + start = button("启动", "outline-success", fixed_width=72) + start.clicked.connect(lambda _checked=False, k=key: self.worker.start_existing_agent(k)) + layout.addWidget(start) + else: + disabled = button("--", fixed_width=72) + disabled.setEnabled(False) + layout.addWidget(disabled) + + restart = button("↻", "ghost", fixed_width=34) + restart.setEnabled(bool(can_restart)) + restart.clicked.connect(lambda _checked=False, k=key: self.worker.restart_process(k)) + layout.addWidget(restart) + return frame + + def _render_events(self) -> None: + self._clear_layout(self.events_list) + if not self.recent_events: + self.recent_events = ["服务状态已同步", "桌面控制台已连接"] + for text in self.recent_events[:6]: + row = QLabel(text) + row.setProperty("muted", True) + row.setWordWrap(True) + row.setStyleSheet("border-bottom:1px solid #eef2f6;padding:6px 0;") + self.events_list.addWidget(row) + self.events_list.addStretch(1) + + def _update_settings_summary(self, status: dict[str, Any]) -> None: + if not hasattr(self, "settings_values"): + return + + cfg = self._load_config_summary() + server = status.get("server", {}) if status else {} + cfg_server = cfg.get("server", {}) if isinstance(cfg.get("server"), dict) else {} + cfg_mcp = cfg.get("mcp", {}) if isinstance(cfg.get("mcp"), dict) else {} + cfg_images = cfg.get("images", {}) if isinstance(cfg.get("images"), dict) else {} + + host = server.get("host") or cfg_server.get("host", "127.0.0.1") + port = server.get("port") or cfg_server.get("port", 8300) + status_value = server.get("status") or ("running" if server.get("running") else "stopped") + managed = "桌面启动器管理" if server.get("managed_by_launcher") else "外部或未启动" + + values = { + "server_status": status_text(status_value), + "server_url": f"http://{host}:{port}", + "server_port": str(port), + "server_managed": managed, + "mcp_http": f"http://{host}:{server.get('mcp_http_port') or cfg_mcp.get('http_port', 8200)}/mcp", + "mcp_sse": f"http://{host}:{server.get('mcp_sse_port') or cfg_mcp.get('sse_port', 8201)}/sse", + "data_dir": str(server.get("data_dir") or cfg_server.get("data_dir", "./data")), + "upload_dir": str(cfg_images.get("upload_dir", "./uploads")), + "config_file": "config.toml", + } + for key, value in values.items(): + if key in self.settings_values: + self.settings_values[key].setText(value) + + templates = status.get("templates") or self.templates or {} + self._render_settings_templates(templates) + + def _render_settings_templates(self, templates: dict[str, dict[str, Any]]) -> None: + if not hasattr(self, "settings_templates_layout"): + return + self._clear_layout(self.settings_templates_layout) + if not templates: + self.settings_templates_layout.addWidget(label("暂无 agent template 配置", muted=True)) + return + for base, template in sorted(templates.items()): + row = QFrame() + row.setProperty("infoRow", True) + row_layout = QVBoxLayout(row) + row_layout.setContentsMargins(12, 9, 12, 9) + title = QLabel(f"{template.get('label') or base} ({base})") + title.setStyleSheet("font-weight:800;color:#0c111d;") + command = template.get("command") or base + cwd = template.get("cwd") or "--" + yolo = "支持 YOLO" if template.get("supports_yolo") else "普通模式" + detail = label(f"命令: {command} · 工作目录: {cwd} · {yolo}", muted=True) + detail.setWordWrap(True) + row_layout.addWidget(title) + row_layout.addWidget(detail) + self.settings_templates_layout.addWidget(row) + + def add_agent(self) -> None: + if not self.templates: + QMessageBox.warning(self, "Add Agent", "Agent templates are not loaded yet.") + return + dialog = AddAgentDialog(self.templates, self) + if dialog.exec() == QDialog.Accepted: + values = dialog.values() + self.worker.start_agent(**values) + + def open_chat(self) -> None: + server = self.latest_status.get("server", {}) + host = server.get("host", "127.0.0.1") + port = server.get("port", 8300) + webbrowser.open(f"http://{host}:{port}") + + def apply_logs(self, key: str, logs: list[dict[str, Any]]) -> None: + if key != "server": + return + editor = self._log_editor(key) + threshold = self.log_clear_after.get(key, 0.0) + lines = [] + for event in logs: + if event.get("timestamp", 0) <= threshold: + continue + timestamp = time.strftime( + "%H:%M:%S", time.localtime(float(event.get("timestamp", 0) or 0)) + ) + stream = event.get("stream", "log") + text = event.get("text", "") + lines.append(f"[{timestamp}] {stream}: {text}") + next_text = "\n".join(lines) + if editor.toPlainText() != next_text: + scrollbar = editor.verticalScrollBar() + at_bottom = scrollbar.value() >= scrollbar.maximum() - 4 + editor.setPlainText(next_text) + if at_bottom: + scrollbar.setValue(scrollbar.maximum()) + + def _log_editor(self, key: str) -> QPlainTextEdit: + if key != "server": + raise ValueError("Desktop logs only render launcher-owned server output") + if key in self.log_editors: + return self.log_editors[key] + editor = QPlainTextEdit() + editor.setReadOnly(True) + editor.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) + self.log_editors[key] = editor + self.logs_tabs.addTab(editor, key) + self._update_terminal_input_state() + return editor + + def current_log_key(self) -> str | None: + index = self.logs_tabs.currentIndex() + if index < 0: + return None + return self.logs_tabs.tabText(index) + + def clear_current_log(self) -> None: + key = self.current_log_key() + if not key: + return + self.log_clear_after[key] = time.time() + if key in self.log_editors: + self.log_editors[key].clear() + + def _current_terminal_process(self) -> dict[str, Any] | None: + key = self.current_log_key() + if not key: + return None + return self.latest_status.get("processes", {}).get(key) + + def _update_terminal_input_state(self, *_args: object) -> None: + if not hasattr(self, "terminal_send_button"): + return + self.terminal_send_button.setEnabled(False) + self.terminal_input.setEnabled(False) + self.terminal_status_label.setText( + "这里只显示 launcher 启动的 server stdout/stderr;agent CLI 请在 Windows Terminal 中操作。" + ) + + def send_current_input(self) -> None: + QMessageBox.information(self, "服务日志", self.terminal_status_label.text()) + + def copy_current_log(self) -> None: + key = self.current_log_key() + if not key: + return + editor = self.log_editors[key] + text = editor.textCursor().selectedText() or editor.toPlainText() + QGuiApplication.clipboard().setText(text) + self.statusBar().showMessage(f"Copied log: {key}", 2000) + + def operation_finished(self, name: str, result: object) -> None: + if isinstance(result, dict) and result.get("error"): + QMessageBox.warning(self, name, str(result.get("error"))) + return + self.statusBar().showMessage(f"{name} complete", 2500) + + def operation_failed(self, name: str, detail: str) -> None: + QMessageBox.critical(self, name, detail) + + +def main() -> int: + internal_result = _run_internal_subcommand() + if internal_result is not None: + return internal_result + + set_windows_app_user_model_id() + app = QApplication(sys.argv) + app.setWindowIcon(app_icon()) + app.setFont(QFont("Microsoft YaHei UI", 9)) + window = DesktopLauncher() + window.show() + return app.exec() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/DESKTOP_LAUNCHER_MVP.md b/docs/DESKTOP_LAUNCHER_MVP.md new file mode 100644 index 00000000..ffddd507 --- /dev/null +++ b/docs/DESKTOP_LAUNCHER_MVP.md @@ -0,0 +1,131 @@ +# Desktop Launcher MVP + +This note covers the Windows desktop launcher MVP smoke path for agentchattr. +The process supervisor remains intentionally small: it can start and stop only +processes it created, while externally started `.bat` processes are displayed as +external and are not killed by the launcher. + +## Install + +From the repository root: + +```powershell +py -3.11 -m venv .venv +.\.venv\Scripts\Activate.ps1 +python -m pip install --upgrade pip +python -m pip install -r requirements.txt +python -m pip install -r requirements-desktop.txt +``` + +`requirements.txt` is still required for the FastAPI server. The desktop +requirements file contains the native desktop shell dependency plus the Windows +packaging tools: PySide6, PyInstaller, and Pillow. + +## Run + +```powershell +python desktop_launcher.py +``` + +For the web-hosted control panel fallback, start the server and open the +launcher route: + +```powershell +python run.py +``` + +Then open: + +```text +http://localhost:8300/launcher +``` + +## Build Windows EXE + +The desktop launcher is packaged as a PyInstaller `onedir` app. Build it from +the repository root after installing both requirements files: + +```powershell +python build_desktop_exe.py +``` + +The output is: + +```text +dist/agentchattr/agentchattr.exe +``` + +Keep the whole `dist/agentchattr/` folder together. Do not move only the exe: +the launcher reads `config.toml`, `static/`, and other runtime resources from +the same folder. If you need local overrides, place `config.local.toml` beside +`agentchattr.exe`. + +## MVP Behavior + +- The launcher reads agent templates from `config.toml` and `config.local.toml`. +- The server can be started from the launcher only when it is not already + listening on the configured host and port. +- Stop and restart are enabled only for server and agent processes that were + started by the launcher. +- Externally started agents, including agents launched with `windows/start_*.bat`, + are visible but are not stoppable from the launcher. +- Agent instance names are assigned by `wrapper.py` and `registry.py`; the + launcher does not invent names. +- Logs are captured only for launcher-owned subprocesses. +- Yolo/bypass mode is mapped in the backend. The UI should send only + `mode: "normal"` or `mode: "yolo"`. + +## MVP Limits + +- No full interactive PTY. +- No tray icon or auto-start-on-login behavior. +- No profile manager beyond the existing config files. +- No log search, filtering, or log-level parsing. +- No attempt to terminate external processes. +- No replacement for the existing `.bat` launchers. + +## Windows Smoke Checklist + +Use PowerShell from the repository root unless noted otherwise. + +1. Install dependencies with `requirements.txt` and `requirements-desktop.txt`. +2. Run `python desktop_launcher.py`, or build and double-click + `dist/agentchattr/agentchattr.exe`. For the web fallback, run `python run.py` + and open `http://localhost:8300/launcher`. +3. Confirm the launcher shows the configured host, port, MCP ports, and agent + templates from config. +4. With no server running, click Start Server and verify the server enters a + running state. +5. Click Open Chat and verify `http://localhost:8300` loads. +6. Start one agent in normal mode and verify a launcher-owned process appears. +7. Verify server stdout/stderr lines appear in the log view; agent CLI windows + are hosted by Windows Terminal. +8. Start a second instance of the same agent and verify the registry-assigned + instance name appears when the wrapper registers. +9. Stop the launcher-owned agent and verify its state changes to stopped. +10. Start an agent with `windows/start_*.bat`, refresh the launcher, and verify + it is shown as external with stop/restart disabled. +11. Try yolo mode for an unsupported agent and verify the API/UI shows a clear + validation error. +12. Occupy the configured server port with another process and verify the + launcher reports the server as external or blocked instead of killing it. +13. Temporarily remove an agent CLI from `PATH` and verify a clear start error + is surfaced. +14. Confirm existing `.bat` launchers still start agents and register them in + chat. + +## Unit Test Focus + +The MVP tests should stay fast and avoid starting real agent CLIs. Covered +surfaces: + +- supervisor status serialization for server, templates, and managed processes +- log capture and recent-log slicing +- external process stop rejection +- shared action/button rules for launcher-owned versus external processes + +Run the focused test file: + +```powershell +python -m unittest tests.test_launcher_supervisor +``` diff --git a/docs/LAUNCHER_CONTROL_PANEL_MVP.md b/docs/LAUNCHER_CONTROL_PANEL_MVP.md new file mode 100644 index 00000000..c8330917 --- /dev/null +++ b/docs/LAUNCHER_CONTROL_PANEL_MVP.md @@ -0,0 +1,224 @@ +# Launcher Control Panel MVP + +## Purpose + +Build a real agentchattr control panel from the v3.1.2 mockup while keeping the +first implementation narrow. The panel should make the Windows workflow easier: +open chat, start/stop the server, start/stop agents, and view basic logs in one +place. + +This document is the implementation baseline for the current job: + +- Frontend: kimi-1 +- Backend: kimi-2 +- Testing: Qwen +- Planning, architecture, review: Codex + +## MVP Scope + +Must ship: + +- Control panel entry point. +- Open Chat action for the configured server URL. +- Server status, start, stop, and restart. +- Agent template list sourced from `config.toml` / `config.local.toml`. +- Agent start, stop, and restart. +- Add Agent drawer matching v3.1.2 semantics: + - agent type + - normal or yolo mode + - role, default none + - custom role text when custom is selected + - working directory + - advanced settings + - auto-start preference +- Instance names assigned by `wrapper.py` / `registry.py`, not by the UI. +- Basic stdout/stderr log tabs for processes started by the launcher. +- Clear error states for missing CLI, port conflict, wrapper failure, and health + check failure. + +Out of scope for MVP: + +- Full interactive PTY. +- System tray behavior. +- Complex profiles. +- Log search or log level parsing. +- Killing externally started processes. +- Replacing existing `.bat` launchers. +- Typing indicator changes. + +## Architecture Decision + +Implement this inside the existing `agentchattr` project first. Reuse the +current Python/FastAPI stack and static frontend structure. Avoid adding a heavy +desktop framework until the process model and user flow are proven. + +The launcher layer must stay thin: + +- Read existing config with `config_loader.load_config`. +- Start `python run.py` for the server when needed. +- Start `python wrapper.py ...extra_args` for agents. +- Keep handles only for processes started by the launcher. +- Detect externally running server/agents, but do not stop or kill them in MVP. +- Capture stdout/stderr for launcher-owned processes. + +## Backend Modules + +Recommended new files: + +- `launcher.py` + - Process supervisor and state model. + - Knows how to start/stop/restart server and agent subprocesses. + - Owns in-memory process registry for launcher-owned processes. +- `launcher_routes.py` + - FastAPI endpoints and websocket for launcher features. + - Calls `launcher.py`; does not directly manage subprocesses. +- `static/launcher.html` + - Control panel page. +- `static/launcher.js` + - Frontend behavior and API calls. +- `static/launcher.css` + - Styles adapted from v3.1.2 mockup. + +Register routes from `run.py` or `app.py` in the same style as existing +application routes. Keep the change small and explicit. + +## Data Model + +Use simple models first. + +```text +AgentTemplate +- base +- label +- command +- cwd +- color +- normal_args +- yolo_args +- supports_yolo + +ManagedProcess +- key +- kind: server | agent +- base +- assigned_name +- pid +- status +- started_by_launcher +- started_at +- last_error + +LogEvent +- process_key +- stream: stdout | stderr +- text +- timestamp +``` + +`assigned_name` may be empty at process start. Fill it once the wrapper +registers or once the backend can resolve the active registry instance. + +## Yolo Mode + +The frontend sends only `mode: "normal"` or `mode: "yolo"`. + +The backend maps mode to real command arguments per agent template. Do not +hard-code `--yolo` in the frontend. + +Initial mapping must be verified before use: + +- Codex: pass through to CLI as `-- --dangerously-bypass-approvals-and-sandbox`. +- Claude: pass through to CLI as `-- --dangerously-skip-permissions`. +- Gemini: likely `-- --yolo` or existing launcher equivalent. +- Qwen: likely `-- --yolo` or existing launcher equivalent. +- Kimi: unknown; treat yolo as unsupported until verified. + +If an agent does not support yolo mode, the API should return a clear validation +error and the UI should show it. + +## API Contract + +Minimal endpoints: + +```text +GET /api/launcher/status +GET /api/launcher/agents +POST /api/launcher/server/start +POST /api/launcher/server/stop +POST /api/launcher/server/restart +POST /api/launcher/agents/{base}/start +POST /api/launcher/processes/{key}/stop +POST /api/launcher/processes/{key}/restart +GET /api/launcher/logs/{key} +WS /ws/launcher/events +``` + +Start agent request: + +```json +{ + "base": "kimi", + "mode": "normal", + "role": null, + "custom_role": null, + "cwd": "D:\\kimicode", + "auto_start": false +} +``` + +Start agent response: + +```json +{ + "process_key": "agent:kimi:1234", + "base": "kimi", + "assigned_name": null, + "status": "starting", + "started_by_launcher": true +} +``` + +## Frontend Requirements + +The frontend must not infer or invent: + +- instance names +- process ownership +- yolo command arguments +- agent health + +The frontend should render backend state and provide actions only when the +backend says they are allowed. + +Pages for MVP: + +- Overview: server status, Open Chat, high-level agent counts. +- Agents: templates and running instances, start/stop/restart actions. +- Terminal: log tabs for launcher-owned processes. +- Settings: lightweight placeholders only unless needed by MVP. + +## Testing Plan + +Qwen should verify these flows on Windows: + +1. Server not running -> start server -> Open Chat works. +2. Server already running externally -> panel shows external running state. +3. Start one agent -> process appears -> logs stream. +4. Start second instance of same agent -> registry-assigned name appears. +5. Stop launcher-owned agent -> process exits and state updates. +6. Stop external process action is unavailable. +7. Missing CLI shows a clear error. +8. Port conflict shows a clear error. +9. Yolo unsupported agent returns validation error. +10. Existing `.bat` launchers still work. + +## Review Gates + +Codex review checkpoints: + +1. API and module boundaries before broad implementation. +2. Server process management before agent management expands. +3. Yolo mapping before exposing it widely in UI. +4. Stop/restart behavior before Qwen full test pass. +5. Final MVP review before considering PTY, tray, profiles, or richer logs. + diff --git a/docs/TEST_PLAN.md b/docs/TEST_PLAN.md new file mode 100644 index 00000000..07c2332b --- /dev/null +++ b/docs/TEST_PLAN.md @@ -0,0 +1,172 @@ +# Launcher Control Panel MVP — 验收清单 + +> 角色:Qwen(测试) +> 基于:`./LAUNCHER_CONTROL_PANEL_MVP.md` § Testing Plan +> 目标平台:Windows + +--- + +## 阶段 1:架构基线验证 + +### 1.1 后端骨架 +- [ ] `GET /api/launcher/status` 返回有效 JSON +- [ ] `GET /api/launcher/agents` 返回 agent templates(从 config.toml 读取) +- [ ] agent template 包含 base / label / command / cwd / color / supports_yolo 字段 +- [ ] 错误路径:config.toml 缺失时返回明确错误,不崩溃 + +### 1.2 前端骨架 +- [ ] 控制面板页面可访问 +- [ ] 概览/代理/终端/设置 四个页面可切换 +- [ ] Open Chat 按钮可用,跳转到正确的聊天 URL +- [ ] 页面无 JS 控制台错误(favicon 404 除外) + +--- + +## 阶段 2:Server 管理 + +### 2.1 Server 未启动 → 启动 +- [ ] 概览页显示 server 状态为「未运行」 +- [ ] 点击启动 → server 启动成功 +- [ ] 概览页状态更新为「运行中」 +- [ ] 启动后 Open Chat 可正常打开聊天页面 + +### 2.2 Server 已外部启动 +- [ ] 手动通过 `.bat` 启动 server 后,面板显示「外部运行中」 +- [ ] 停止按钮不可用(或提示无法停止外部进程) +- [ ] Open Chat 仍可正常使用 + +### 2.3 Server 停止 +- [ ] 点击停止 → server 进程退出 +- [ ] 面板状态更新为「已停止」 +- [ ] 由 launcher 启动的子 agent 进程也被清理 + +### 2.4 Server 重启 +- [ ] 重启功能:停止 → 启动,状态正确过渡 + +### 2.5 错误状态 +- [ ] 端口占用:启动失败时显示明确错误信息 +- [ ] Python/venv/依赖缺失或 `run.py` 启动失败时显示明确错误 +- [ ] 重复启动:不会创建多个 server 进程 + +--- + +## 阶段 3:Agent 管理 + +### 3.1 启动单个 Agent +- [ ] 代理管理页显示 agent template 列表 +- [ ] 选择一个 agent → 点击启动 → 进程创建 +- [ ] 面板显示 agent 状态为「运行中」 +- [ ] 日志 tab 显示 stdout/stderr 输出 + +### 3.2 多实例命名(关键) +- [ ] 启动第二个同类型 agent(如 kimi)→ registry 自动分配名(如 kimi-2) +- [ ] 面板显示 `assigned_name` 为 registry 分配的真实名称 +- [ ] 前端不生成实例名,不出现手动输入实例名的字段 + +### 3.3 停止 Agent +- [ ] 停止由 launcher 启动的 agent → 进程退出 +- [ ] 状态更新为「已停止」 +- [ ] 日志流正常关闭 + +### 3.4 外部 Agent 处理 +- [ ] 手动通过 `.bat` 启动的 agent → 面板显示但不可停止 +- [ ] 「停止」按钮置灰或隐藏 +- [ ] 不可误杀外部进程 + +### 3.5 Agent 重启 +- [ ] 重启功能:停止 → 启动,状态正确过渡 +- [ ] 重启后日志流正常 + +--- + +## 阶段 4:新增代理抽屉 + +### 4.1 基本流程 +- [ ] 点击「新增代理」→ 抽屉滑入 +- [ ] 代理类型列表从 config.toml 读取 +- [ ] 启动模式:普通 / Yolo(分段选择器) +- [ ] 角色:默认「无」;选择「自定义」→ 输入框出现 +- [ ] 工作目录可编辑 +- [ ] 高级设置默认折叠 + +### 4.2 保存并启动 +- [ ] 表单填写后点击「保存并启动」→ 代理启动 +- [ ] 不要求填写实例名 +- [ ] toast 显示启动结果(含 registry 分配名) + +### 4.3 仅保存(P2) +- [ ] 点击「仅保存」→ 保存启动偏好(UI 状态,不修改 config.toml),不立即启动 +- [ ] 不创建实例名,不持久化 agent command/template + +### 4.4 校验 +- [ ] 未选代理类型 → 提示必填 +- [ ] Yolo 不支持时 → 后端返回错误,前端显示提示 + +--- + +## 阶段 5:日志流 + +### 5.1 基础日志 +- [ ] 终端 tab 显示 launcher 启动进程的日志 +- [ ] stdout 和 stderr 均可显示 +- [ ] 新日志实时追加 + +### 5.2 WebSocket 事件 +- [ ] WebSocket 连接正常 +- [ ] 进程状态变化通过 WS 推送 +- [ ] WS 断线重连正常 + +--- + +## 阶段 6:Yolo 模式 + +### 6.1 参数映射 +- [ ] 前端只传 `mode: "yolo"`,不拼 `--yolo` +- [ ] Kimi:后端映射为 `--yolo`(`start_kimi_yolo.bat` 已验证存在) +- [ ] Codex:后端映射为 `-- --dangerously-bypass-approvals-and-sandbox` +- [ ] Qwen:后端映射为 `--yolo` +- [ ] minimax/kilo 等不支持 yolo 的 agent → 返回 400 验证错误 + +### 6.2 安全验收(关键) +- [ ] Yolo 不支持时,后端必须返回 validation error(HTTP 400) +- [ ] **不得静默退回普通模式**,避免用户误以为已以高权限模式启动 +- [ ] 前端显示后端返回的错误信息,不可自行降级 + +--- + +## 阶段 7:回归验证 + +### 7.1 现有工作流不受影响 +- [ ] 现有 `start_kimi.bat` 仍可正常启动 +- [ ] 现有 `start_codex.bat` 仍可正常启动 +- [ ] 现有 server `.bat` 仍可正常启动 +- [ ] 外部启动的进程不被 launcher 误杀 + +### 7.2 配置一致性 +- [ ] `config.toml` 不被 launcher 修改(只读) +- [ ] `config.local.toml` 如有,launcher 可合并读取 + +--- + +## 阶段 8:边界条件 + +- [ ] 快速连续启动/停止不产生孤儿进程 +- [ ] 控制面板关闭后,launcher 启动的进程行为明确(继续运行 or 随面板退出) +- [ ] 同一 agent 快速多次启动 → 不创建重复进程 +- [ ] 面板长时间空转无内存泄漏 +- [ ] 非 ASCII 路径(如 `D:\项目\agentchattr` 或含中文用户名的路径)正常工作,验证路径编码 + +--- + +## 执行记录 + +| 阶段 | 日期 | 结果 | 问题 | +|:---|:---|:---|:---| +| 1. 架构基线 | — | — | — | +| 2. Server 管理 | — | — | — | +| 3. Agent 管理 | — | — | — | +| 4. 新增代理抽屉 | — | — | — | +| 5. 日志流 | — | — | — | +| 6. Yolo 模式 | — | — | — | +| 7. 回归验证 | — | — | — | +| 8. 边界条件 | — | — | — | diff --git a/gang.gif b/docs/assets/gang.gif similarity index 100% rename from gang.gif rename to docs/assets/gang.gif diff --git a/screenshot.png b/docs/assets/screenshot.png similarity index 100% rename from screenshot.png rename to docs/assets/screenshot.png diff --git a/docs/mockups/launcher-control-panel-v2.html b/docs/mockups/launcher-control-panel-v2.html new file mode 100644 index 00000000..4d6d99cf --- /dev/null +++ b/docs/mockups/launcher-control-panel-v2.html @@ -0,0 +1,1028 @@ + + + + + + agentchattr Control Panel + + + + + +
+
+
A
+
agentchattr Control Panel
+
+ + Profile: + +
+
+
+ + + + +
+
+ + +
+ +
+
+ Server + Running +
+
+
+
+ + +
+ + +
+ +
+
+ + Service Info +
+
+ HTTP Port + 8300 +
+
+ MCP SSE + Active +
+
+ MCP HTTP + Active +
+
+ Data Dir + D:\agentchattr +
+
+ +
+
+ + Resources +
+
+
+
2
+
Online
+
+
+
1
+
Working
+
+
+
128MB
+
Server RAM
+
+
+
0
+
Errors (1h)
+
+
+
+ +
+
+ + Uptime +
+
+ Server + 2h 14m +
+
+ Session + #7a3f2b +
+
+
+
+ + +
+
+ Agents + 4 configured · 2 online · 1 working +
+
+
+ +
+
CX
+
+
+ codex + Working +
+
通用开发代理 · Python/JS/架构/代码审查
+
+ + + heartbeat: 2s ago + + + + wrapper.py + +
+
+
+ + +
+
+ + +
+
KM
+
+
+ kimi + Online +
+
通用助手代理 · 中文优先/任务执行/协调
+
+ + + heartbeat: 1s ago + + + + wrapper.py + +
+
+
+ + +
+
+ + +
+
CL
+
+
+ claude + Offline +
+
分析代理 · 深度推理/文档/复杂决策
+
+ + + last seen: 3h ago + + + + wrapper.py + +
+
+
+ + +
+
+ + +
+
GM
+
+
+ gemini + Offline +
+
搜索代理 · 联网检索/信息聚合/实时数据
+
+ + + last seen: 5h ago + + + + wrapper.py + +
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+ + server +
+
+ + codex +
+
+ + kimi +
+
+ + claude +
+
+ + gemini +
+
+
+
+ + Unified Terminal — All logs in one place +
+
+ + +
+
+
+
+
14:20:01INFOagentchattr server starting on port 8300
+
14:20:02OKDatabase initialized: agentchattr.db
+
14:20:02OKMCP SSE endpoint ready at /mcp/v1/sse
+
14:20:02OKMCP HTTP endpoint ready at /mcp/v1/http
+
14:20:03INFOWebSocket handler registered
+
14:20:04OKServer ready. Listening on http://127.0.0.1:8300
+
14:20:15INFOPOST /api/heartbeat/codex 200 OK (latency: 12ms)
+
14:20:16INFOPOST /api/heartbeat/kimi 200 OK (latency: 8ms)
+
14:20:17INFOPOST /api/heartbeat/codex 200 OK (latency: 10ms)
+
14:20:18INFOPOST /api/heartbeat/kimi 200 OK (latency: 9ms)
+
14:20:19INFOPOST /api/heartbeat/codex 200 OK (latency: 11ms)
+
14:20:20WARNHeartbeat from claude missed (last: 3h ago)
+
14:20:21INFOPOST /api/heartbeat/kimi 200 OK (latency: 7ms)
+
14:20:22INFOPOST /api/heartbeat/codex 200 OK (latency: 13ms)
+
14:20:23INFOWebSocket connection from kimi established
+
+ +
+
14:19:55INFO[codex] Wrapper starting...
+
14:19:56INFO[codex] Loading skills from .kimi-code/skills/
+
14:19:57OK[codex] 14 skills loaded
+
14:19:58OK[codex] Connected to agentchattr via WebSocket
+
14:19:58INFO[codex] Heartbeat interval: 5s
+
14:20:10INFO[codex] Received message in #agentchat from 林婉音
+
14:20:11INFO[codex] Processing: launcher UI mockup review
+
14:20:12INFO[codex] Context window: 13.2% (34.6k/262.1k)
+
14:20:13INFO[codex] Response generated (1,247 tokens)
+
14:20:15OK[codex] Message sent to #agentchat
+
14:20:20INFO[codex] Received message in #agentchat from 林婉音
+
14:20:21INFO[codex] Processing v2 mockup request
+
14:20:22INFO[codex] Delegating to kimi: static HTML v2 task
+
+ +
+
14:19:50INFO[kimi] Wrapper starting...
+
14:19:51INFO[kimi] Loading skills from .kimi-code/skills/
+
14:19:52OK[kimi] 12 skills loaded
+
14:19:53OK[kimi] Connected to agentchattr via WebSocket
+
14:19:53INFO[kimi] Heartbeat interval: 5s
+
14:19:58INFO[kimi] Received message in #agentchat
+
14:19:59INFO[kimi] Typing indicator: active=true
+
14:20:02OK[kimi] Message sent to #agentchat
+
14:20:04INFO[kimi] Typing indicator: active=false
+
14:20:10INFO[kimi] Received message in #agentchat
+
14:20:11INFO[kimi] Evaluating codex launcher proposal
+
14:20:13OK[kimi] Message sent to #agentchat
+
+ +
+
11:15:02INFO[claude] Wrapper starting...
+
11:15:03INFO[claude] Loading skills from .kimi-code/skills/
+
11:15:04OK[claude] 10 skills loaded
+
11:15:05OK[claude] Connected to agentchattr via WebSocket
+
11:20:30WARN[claude] Heartbeat timeout after 30s
+
11:20:31ERR[claude] WebSocket connection lost
+
11:20:32WARN[claude] Attempting reconnect (1/5)...
+
11:20:37ERR[claude] Reconnect failed: Connection refused
+
11:20:37WARN[claude] Giving up. Wrapper exiting.
+
11:20:38INFO[claude] Process terminated with code 1
+
11:20:38INFO--- claude wrapper exited ---
+
+ +
+
09:30:01INFO[gemini] Wrapper starting...
+
09:30:02INFO[gemini] Loading skills from .kimi-code/skills/
+
09:30:03OK[gemini] 8 skills loaded
+
09:30:04OK[gemini] Connected to agentchattr via WebSocket
+
09:30:04INFO[gemini] Heartbeat interval: 5s
+
09:45:12INFO[gemini] Executing web search: "Python subprocess management"
+
09:45:15OK[gemini] Search completed: 12 results
+
09:45:16INFO[gemini] Sending search summary to #agentchat
+
10:00:45WARN[gemini] Heartbeat timeout after 30s
+
10:00:46ERR[gemini] WebSocket connection lost
+
10:00:47INFO[gemini] Process terminated with code 0
+
10:00:47INFO--- gemini wrapper exited ---
+
+
+
+ + + + + diff --git a/docs/mockups/launcher-control-panel-v3.1.2.html b/docs/mockups/launcher-control-panel-v3.1.2.html new file mode 100644 index 00000000..22bb5758 --- /dev/null +++ b/docs/mockups/launcher-control-panel-v3.1.2.html @@ -0,0 +1,2192 @@ + + + + + + agentchattr 控制台 + + + + + +
+
+
A
+
agentchattr 控制台
+
+
+ + + + +
+
+ + + + + +
+ + +
+
+ +
+
+ 服务 + 运行中 +
+
+
+
+ + +
+ + +
+ +
+
服务信息
+
+ HTTP 端口 + 8300 +
+
+ MCP SSE + 活跃 +
+
+ MCP HTTP + 活跃 +
+
+ 数据目录 + D:\agentchattr +
+
+ +
+
资源
+
+
+
2
+
在线
+
+
+
1
+
工作中
+
+
+
128MB
+
内存占用
+
+
+
0
+
近1小时错误
+
+
+
+ +
+
运行时长
+
+ 服务 + 2h 14m +
+
+ 会话 + #7a3f2b +
+
+
+
+ + +
+
+ 代理实例 + 6 个实例 · 3 在线 · 1 工作中 +
+
+
+
+ + + + + +
+ +
+ +
+ +
+
CX
+
+
+ codex + codex + 工作中 +
+
通用开发代理 · Python/JS/架构/代码审查
+
+ + + 心跳: 2秒前 + + + + wrapper.py + +
+
+
+ + +
+
+ + +
+
KM
+
+
+ kimi + kimi + 在线 +
+
通用助手代理 · 中文优先/任务执行/协调
+
+ + + 心跳: 1秒前 + + + + wrapper.py + +
+
+
+ + +
+
+ + +
+
KM
+
+
+ kimi-2 + kimi + 在线 +
+
通用助手代理 · 中文优先/任务执行/协调
+
+ + + 心跳: 3秒前 + + + + wrapper.py + +
+
+
+ + +
+
+ + +
+
KM
+
+
+ kimi-3 + kimi + 离线 +
+
通用助手代理 · 中文优先/任务执行/协调
+
+ + + 最后在线: 30分钟前 + + + + wrapper.py + +
+
+
+ + +
+
+ + +
+
CL
+
+
+ claude + claude + 离线 +
+
分析代理 · 深度推理/文档/复杂决策
+
+ + + 最后在线: 3小时前 + + + + wrapper.py + +
+
+
+ + +
+
+ + +
+
GM
+
+
+ gemini + gemini + 离线 +
+
搜索代理 · 联网检索/信息聚合/实时数据
+
+ + + 最后在线: 5小时前 + + + + wrapper.py + +
+
+
+ + +
+
+
+
+
+ + +
+
+
+ 运行摘要 +
+
+
+
+ + + 在线代理 + + 3 +
+
+ + + 工作中 + + 1 +
+
+ + + 离线代理 + + 3 +
+
+ + + 异常 + + 0 +
+
+
+
+ +
+
+ 最近事件 + +
+
+
+
+ 14:20 + + kimi 发送消息到 #agentchat +
+
+ 14:19 + + codex WebSocket 连接已建立 +
+
+ 14:15 + + claude 心跳超时 — 最后在线 3小时前 +
+
+ 14:10 + + 会话恢复(循环保护后) +
+
+ 13:50 + + 服务由 林婉音 重启 +
+
+
+
+
+
+
+ + +
+
+
+ 代理管理 + +
+
+
+
+ + + + + +
+
+
+ +
+
CX
+
+
+ codex + codex + 工作中 +
+
通用开发代理 · Python/JS/架构/代码审查
+
+ 心跳: 2秒前 + wrapper.py +
+
+
+ + +
+
+
+
KM
+
+
+ kimi + kimi + 在线 +
+
通用助手代理 · 中文优先/任务执行/协调
+
+ 心跳: 1秒前 + wrapper.py +
+
+
+ + +
+
+
+
KM
+
+
+ kimi-2 + kimi + 在线 +
+
通用助手代理 · 中文优先/任务执行/协调
+
+ 心跳: 3秒前 + wrapper.py +
+
+
+ + +
+
+
+
KM
+
+
+ kimi-3 + kimi + 离线 +
+
通用助手代理 · 中文优先/任务执行/协调
+
+ 最后在线: 30分钟前 + wrapper.py +
+
+
+ + +
+
+
+
CL
+
+
+ claude + claude + 离线 +
+
分析代理 · 深度推理/文档/复杂决策
+
+ 最后在线: 3小时前 + wrapper.py +
+
+
+ + +
+
+
+
GM
+
+
+ gemini + gemini + 离线 +
+
搜索代理 · 联网检索/信息聚合/实时数据
+
+ 最后在线: 5小时前 + wrapper.py +
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+ + server +
+
+ + codex +
+
+ + kimi +
+
+ + kimi-2 +
+
+ + kimi-3 +
+
+ + claude +
+
+ + gemini +
+
+
+
+ + 统一终端 — 所有代理日志集中显示 +
+
+ + +
+
+
+
+
14:20:01INFOagentchattr 服务启动,端口 8300
+
14:20:02OK数据库初始化完成: agentchattr.db
+
14:20:02OKMCP SSE 端点就绪 /mcp/v1/sse
+
14:20:03OK服务就绪 http://127.0.0.1:8300
+
14:20:15INFOPOST /api/heartbeat/codex 200 (12ms)
+
14:20:16INFOPOST /api/heartbeat/kimi 200 (8ms)
+
14:20:17INFOPOST /api/heartbeat/kimi-2 200 (9ms)
+
14:20:20WARNclaude 心跳丢失,最后在线 3小时前
+
14:20:23INFOWebSocket 连接建立: kimi
+
+
+
14:19:55INFO[codex] Wrapper 启动中...
+
14:19:57OK[codex] 14 个技能已加载
+
14:19:58OK[codex] WebSocket 连接成功
+
14:20:10INFO[codex] 收到 #agentchat 消息
+
14:20:15OK[codex] 消息已发送
+
+
+
14:19:50INFO[kimi] Wrapper 启动中...
+
14:19:52OK[kimi] 12 个技能已加载
+
14:19:53OK[kimi] WebSocket 连接成功
+
14:20:02OK[kimi] 消息已发送
+
+
+
14:18:30INFO[kimi-2] Wrapper 启动中...
+
14:18:32OK[kimi-2] 12 个技能已加载
+
14:18:33OK[kimi-2] WebSocket 连接成功
+
+
+
13:50:01INFO[kimi-3] Wrapper 启动中...
+
13:50:03OK[kimi-3] 12 个技能已加载
+
13:50:04OK[kimi-3] WebSocket 连接成功
+
14:20:30WARN[kimi-3] 心跳超时
+
14:20:31ERR[kimi-3] WebSocket 连接断开
+
+
+
11:15:02INFO[claude] Wrapper 启动中...
+
11:15:04OK[claude] 10 个技能已加载
+
11:20:31ERR[claude] WebSocket 连接丢失
+
11:20:37ERR[claude] 重连失败,进程退出
+
+
+
09:30:01INFO[gemini] Wrapper 启动中...
+
09:30:03OK[gemini] 8 个技能已加载
+
10:00:46ERR[gemini] WebSocket 连接丢失
+
10:00:47INFO--- gemini 进程已退出 ---
+
+
+
+
+ + +
+
+
+
服务设置
+
+
+
HTTP 端口
+
agentchattr 服务监听端口
+
+
+ +
+
+
+
+
数据目录
+
数据库存储位置
+
+
+ +
+
+
+
+
自动启动代理
+
服务启动时自动启动所有代理
+
+
+ +
+
+
+
+
代理默认配置
+
+
+
心跳间隔
+
代理向服务发送心跳的频率
+
+
+ +
+
+
+
+
心跳超时
+
超过此时间未收到心跳视为离线
+
+
+ +
+
+
+
+
自动重连
+
断开后自动尝试重新连接
+
+
+ +
+
+
+
+
界面设置
+
+
+
语言
+
控制台显示语言
+
+
+ +
+
+
+
+
主题
+
浅色 / 深色模式
+
+
+ +
+
+
+
+
+
+ + +
+ + +
+
+ 新增代理 + +
+
+
+ + +
选择要新增的代理类型
+
+ +
+ +
+ + +
+
普通模式:高风险操作需要确认
+
+ + + +
+ + +
+ + + + + +
+ + +
+ + +
+
+ + 高级设置 + (可选) +
+ +
+ +
+
+
+
自动启动
+
保存后立即启动该代理
+
+
+
+
+
+ +
+ + +
+ + + + + diff --git a/docs/mockups/launcher-control-panel-v3.1.html b/docs/mockups/launcher-control-panel-v3.1.html new file mode 100644 index 00000000..0bcdc7f5 --- /dev/null +++ b/docs/mockups/launcher-control-panel-v3.1.html @@ -0,0 +1,2105 @@ + + + + + + agentchattr 控制台 + + + + + +
+
+
A
+
agentchattr 控制台
+
+
+ + + + +
+
+ + + + + +
+ + +
+
+ +
+
+ 服务 + 运行中 +
+
+
+
+ + +
+ + +
+ +
+
服务信息
+
+ HTTP 端口 + 8300 +
+
+ MCP SSE + 活跃 +
+
+ MCP HTTP + 活跃 +
+
+ 数据目录 + D:\agentchattr +
+
+ +
+
资源
+
+
+
2
+
在线
+
+
+
1
+
工作中
+
+
+
128MB
+
内存占用
+
+
+
0
+
近1小时错误
+
+
+
+ +
+
运行时长
+
+ 服务 + 2h 14m +
+
+ 会话 + #7a3f2b +
+
+
+
+ + +
+
+ 代理实例 + 6 个实例 · 3 在线 · 1 工作中 +
+
+
+
+ + + + + +
+ +
+ +
+ +
+
CX
+
+
+ codex + codex + 工作中 +
+
通用开发代理 · Python/JS/架构/代码审查
+
+ + + 心跳: 2秒前 + + + + wrapper.py + +
+
+
+ + +
+
+ + +
+
KM
+
+
+ kimi + kimi + 在线 +
+
通用助手代理 · 中文优先/任务执行/协调
+
+ + + 心跳: 1秒前 + + + + wrapper.py + +
+
+
+ + +
+
+ + +
+
KM
+
+
+ kimi-2 + kimi + 在线 +
+
通用助手代理 · 中文优先/任务执行/协调
+
+ + + 心跳: 3秒前 + + + + wrapper.py + +
+
+
+ + +
+
+ + +
+
KM
+
+
+ kimi-3 + kimi + 离线 +
+
通用助手代理 · 中文优先/任务执行/协调
+
+ + + 最后在线: 30分钟前 + + + + wrapper.py + +
+
+
+ + +
+
+ + +
+
CL
+
+
+ claude + claude + 离线 +
+
分析代理 · 深度推理/文档/复杂决策
+
+ + + 最后在线: 3小时前 + + + + wrapper.py + +
+
+
+ + +
+
+ + +
+
GM
+
+
+ gemini + gemini + 离线 +
+
搜索代理 · 联网检索/信息聚合/实时数据
+
+ + + 最后在线: 5小时前 + + + + wrapper.py + +
+
+
+ + +
+
+
+
+
+ + +
+
+
+ 运行摘要 +
+
+
+
+ + + 在线代理 + + 3 +
+
+ + + 工作中 + + 1 +
+
+ + + 离线代理 + + 3 +
+
+ + + 异常 + + 0 +
+
+
+
+ +
+
+ 最近事件 + +
+
+
+
+ 14:20 + + kimi 发送消息到 #agentchat +
+
+ 14:19 + + codex WebSocket 连接已建立 +
+
+ 14:15 + + claude 心跳超时 — 最后在线 3小时前 +
+
+ 14:10 + + 会话恢复(循环保护后) +
+
+ 13:50 + + 服务由 林婉音 重启 +
+
+
+
+
+
+
+ + +
+
+
+ 代理管理 + +
+
+
+
+ + + + + +
+
+
+ +
+
CX
+
+
+ codex + codex + 工作中 +
+
通用开发代理 · Python/JS/架构/代码审查
+
+ 心跳: 2秒前 + wrapper.py +
+
+
+ + +
+
+
+
KM
+
+
+ kimi + kimi + 在线 +
+
通用助手代理 · 中文优先/任务执行/协调
+
+ 心跳: 1秒前 + wrapper.py +
+
+
+ + +
+
+
+
KM
+
+
+ kimi-2 + kimi + 在线 +
+
通用助手代理 · 中文优先/任务执行/协调
+
+ 心跳: 3秒前 + wrapper.py +
+
+
+ + +
+
+
+
KM
+
+
+ kimi-3 + kimi + 离线 +
+
通用助手代理 · 中文优先/任务执行/协调
+
+ 最后在线: 30分钟前 + wrapper.py +
+
+
+ + +
+
+
+
CL
+
+
+ claude + claude + 离线 +
+
分析代理 · 深度推理/文档/复杂决策
+
+ 最后在线: 3小时前 + wrapper.py +
+
+
+ + +
+
+
+
GM
+
+
+ gemini + gemini + 离线 +
+
搜索代理 · 联网检索/信息聚合/实时数据
+
+ 最后在线: 5小时前 + wrapper.py +
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+ + server +
+
+ + codex +
+
+ + kimi +
+
+ + kimi-2 +
+
+ + kimi-3 +
+
+ + claude +
+
+ + gemini +
+
+
+
+ + 统一终端 — 所有代理日志集中显示 +
+
+ + +
+
+
+
+
14:20:01INFOagentchattr 服务启动,端口 8300
+
14:20:02OK数据库初始化完成: agentchattr.db
+
14:20:02OKMCP SSE 端点就绪 /mcp/v1/sse
+
14:20:03OK服务就绪 http://127.0.0.1:8300
+
14:20:15INFOPOST /api/heartbeat/codex 200 (12ms)
+
14:20:16INFOPOST /api/heartbeat/kimi 200 (8ms)
+
14:20:17INFOPOST /api/heartbeat/kimi-2 200 (9ms)
+
14:20:20WARNclaude 心跳丢失,最后在线 3小时前
+
14:20:23INFOWebSocket 连接建立: kimi
+
+
+
14:19:55INFO[codex] Wrapper 启动中...
+
14:19:57OK[codex] 14 个技能已加载
+
14:19:58OK[codex] WebSocket 连接成功
+
14:20:10INFO[codex] 收到 #agentchat 消息
+
14:20:15OK[codex] 消息已发送
+
+
+
14:19:50INFO[kimi] Wrapper 启动中...
+
14:19:52OK[kimi] 12 个技能已加载
+
14:19:53OK[kimi] WebSocket 连接成功
+
14:20:02OK[kimi] 消息已发送
+
+
+
14:18:30INFO[kimi-2] Wrapper 启动中...
+
14:18:32OK[kimi-2] 12 个技能已加载
+
14:18:33OK[kimi-2] WebSocket 连接成功
+
+
+
13:50:01INFO[kimi-3] Wrapper 启动中...
+
13:50:03OK[kimi-3] 12 个技能已加载
+
13:50:04OK[kimi-3] WebSocket 连接成功
+
14:20:30WARN[kimi-3] 心跳超时
+
14:20:31ERR[kimi-3] WebSocket 连接断开
+
+
+
11:15:02INFO[claude] Wrapper 启动中...
+
11:15:04OK[claude] 10 个技能已加载
+
11:20:31ERR[claude] WebSocket 连接丢失
+
11:20:37ERR[claude] 重连失败,进程退出
+
+
+
09:30:01INFO[gemini] Wrapper 启动中...
+
09:30:03OK[gemini] 8 个技能已加载
+
10:00:46ERR[gemini] WebSocket 连接丢失
+
10:00:47INFO--- gemini 进程已退出 ---
+
+
+
+
+ + +
+
+
+
服务设置
+
+
+
HTTP 端口
+
agentchattr 服务监听端口
+
+
+ +
+
+
+
+
数据目录
+
数据库存储位置
+
+
+ +
+
+
+
+
自动启动代理
+
服务启动时自动启动所有代理
+
+
+ +
+
+
+
+
代理默认配置
+
+
+
心跳间隔
+
代理向服务发送心跳的频率
+
+
+ +
+
+
+
+
心跳超时
+
超过此时间未收到心跳视为离线
+
+
+ +
+
+
+
+
自动重连
+
断开后自动尝试重新连接
+
+
+ +
+
+
+
+
界面设置
+
+
+
语言
+
控制台显示语言
+
+
+ +
+
+
+
+
主题
+
浅色 / 深色模式
+
+
+ +
+
+
+
+
+
+ + +
+ + +
+
+ 新增代理 + +
+
+
+ + +
选择要新增的代理类型,实例名会自动建议
+
+ +
+ + +
当前已有 kimi、kimi-2、kimi-3,建议命名为 kimi-4
+
+ +
+ + +
+ +
+ + +
从配置文件自动读取,如需修改请编辑配置后重启
+
+ +
+ + +
+ + +
+
+ + 高级设置 + (可选) +
+ +
+ +
+
+
+
自动启动
+
保存后立即启动该代理
+
+
+
+
+
+ +
+ + +
+ + + + + diff --git a/docs/mockups/launcher-control-panel-v3.html b/docs/mockups/launcher-control-panel-v3.html new file mode 100644 index 00000000..dc617a06 --- /dev/null +++ b/docs/mockups/launcher-control-panel-v3.html @@ -0,0 +1,1590 @@ + + + + + + agentchattr 控制台 + + + + + +
+
+
A
+
agentchattr 控制台
+
+
+ + + + +
+
+ + + + + +
+ + +
+
+ +
+
+ 服务 + 运行中 +
+
+
+
+ + +
+ + +
+ +
+
服务信息
+
+ HTTP 端口 + 8300 +
+
+ MCP SSE + 活跃 +
+
+ MCP HTTP + 活跃 +
+
+ 数据目录 + D:\agentchattr +
+
+ +
+
资源
+
+
+
2
+
在线
+
+
+
1
+
工作中
+
+
+
128MB
+
内存占用
+
+
+
0
+
近1小时错误
+
+
+
+ +
+
运行时长
+
+ 服务 + 2h 14m +
+
+ 会话 + #7a3f2b +
+
+
+
+ + +
+
+ 代理实例 + 6 个实例 · 3 在线 · 1 工作中 +
+
+
+
+ + + + + +
+ +
+ +
+ +
+
CX
+
+
+ codex + codex + 工作中 +
+
通用开发代理 · Python/JS/架构/代码审查
+
+ + + 心跳: 2秒前 + + + + wrapper.py + +
+
+
+ + +
+
+ + +
+
KM
+
+
+ kimi + kimi + 在线 +
+
通用助手代理 · 中文优先/任务执行/协调
+
+ + + 心跳: 1秒前 + + + + wrapper.py + +
+
+
+ + +
+
+ + +
+
KM
+
+
+ kimi-2 + kimi + 在线 +
+
通用助手代理 · 中文优先/任务执行/协调
+
+ + + 心跳: 3秒前 + + + + wrapper.py + +
+
+
+ + +
+
+ + +
+
KM
+
+
+ kimi-3 + kimi + 离线 +
+
通用助手代理 · 中文优先/任务执行/协调
+
+ + + 最后在线: 30分钟前 + + + + wrapper.py + +
+
+
+ + +
+
+ + +
+
CL
+
+
+ claude + claude + 离线 +
+
分析代理 · 深度推理/文档/复杂决策
+
+ + + 最后在线: 3小时前 + + + + wrapper.py + +
+
+
+ + +
+
+ + +
+
GM
+
+
+ gemini + gemini + 离线 +
+
搜索代理 · 联网检索/信息聚合/实时数据
+
+ + + 最后在线: 5小时前 + + + + wrapper.py + +
+
+
+ + +
+
+
+
+
+ + +
+
+
+ 运行摘要 +
+
+
+
+ + + 在线代理 + + 3 +
+
+ + + 工作中 + + 1 +
+
+ + + 离线代理 + + 3 +
+
+ + + 异常 + + 0 +
+
+
+
+ +
+
+ 最近事件 + +
+
+
+
+ 14:20 + + kimi 发送消息到 #agentchat +
+
+ 14:19 + + codex WebSocket 连接已建立 +
+
+ 14:15 + + claude 心跳超时 — 最后在线 3小时前 +
+
+ 14:10 + + 会话恢复(循环保护后) +
+
+ 13:50 + + 服务由 林婉音 重启 +
+
+
+
+
+
+
+ + +
+
+
+ 代理管理 + +
+
+
+
+ + + + + +
+
+
+ +
+
CX
+
+
+ codex + codex + 工作中 +
+
通用开发代理 · Python/JS/架构/代码审查
+
+ 心跳: 2秒前 + wrapper.py +
+
+
+ + +
+
+
+
KM
+
+
+ kimi + kimi + 在线 +
+
通用助手代理 · 中文优先/任务执行/协调
+
+ 心跳: 1秒前 + wrapper.py +
+
+
+ + +
+
+
+
KM
+
+
+ kimi-2 + kimi + 在线 +
+
通用助手代理 · 中文优先/任务执行/协调
+
+ 心跳: 3秒前 + wrapper.py +
+
+
+ + +
+
+
+
KM
+
+
+ kimi-3 + kimi + 离线 +
+
通用助手代理 · 中文优先/任务执行/协调
+
+ 最后在线: 30分钟前 + wrapper.py +
+
+
+ + +
+
+
+
CL
+
+
+ claude + claude + 离线 +
+
分析代理 · 深度推理/文档/复杂决策
+
+ 最后在线: 3小时前 + wrapper.py +
+
+
+ + +
+
+
+
GM
+
+
+ gemini + gemini + 离线 +
+
搜索代理 · 联网检索/信息聚合/实时数据
+
+ 最后在线: 5小时前 + wrapper.py +
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+ + server +
+
+ + codex +
+
+ + kimi +
+
+ + kimi-2 +
+
+ + kimi-3 +
+
+ + claude +
+
+ + gemini +
+
+
+
+ + 统一终端 — 所有代理日志集中显示 +
+
+ + +
+
+
+
+
14:20:01INFOagentchattr 服务启动,端口 8300
+
14:20:02OK数据库初始化完成: agentchattr.db
+
14:20:02OKMCP SSE 端点就绪 /mcp/v1/sse
+
14:20:03OK服务就绪 http://127.0.0.1:8300
+
14:20:15INFOPOST /api/heartbeat/codex 200 (12ms)
+
14:20:16INFOPOST /api/heartbeat/kimi 200 (8ms)
+
14:20:17INFOPOST /api/heartbeat/kimi-2 200 (9ms)
+
14:20:20WARNclaude 心跳丢失,最后在线 3小时前
+
14:20:23INFOWebSocket 连接建立: kimi
+
+
+
14:19:55INFO[codex] Wrapper 启动中...
+
14:19:57OK[codex] 14 个技能已加载
+
14:19:58OK[codex] WebSocket 连接成功
+
14:20:10INFO[codex] 收到 #agentchat 消息
+
14:20:15OK[codex] 消息已发送
+
+
+
14:19:50INFO[kimi] Wrapper 启动中...
+
14:19:52OK[kimi] 12 个技能已加载
+
14:19:53OK[kimi] WebSocket 连接成功
+
14:20:02OK[kimi] 消息已发送
+
+
+
14:18:30INFO[kimi-2] Wrapper 启动中...
+
14:18:32OK[kimi-2] 12 个技能已加载
+
14:18:33OK[kimi-2] WebSocket 连接成功
+
+
+
13:50:01INFO[kimi-3] Wrapper 启动中...
+
13:50:03OK[kimi-3] 12 个技能已加载
+
13:50:04OK[kimi-3] WebSocket 连接成功
+
14:20:30WARN[kimi-3] 心跳超时
+
14:20:31ERR[kimi-3] WebSocket 连接断开
+
+
+
11:15:02INFO[claude] Wrapper 启动中...
+
11:15:04OK[claude] 10 个技能已加载
+
11:20:31ERR[claude] WebSocket 连接丢失
+
11:20:37ERR[claude] 重连失败,进程退出
+
+
+
09:30:01INFO[gemini] Wrapper 启动中...
+
09:30:03OK[gemini] 8 个技能已加载
+
10:00:46ERR[gemini] WebSocket 连接丢失
+
10:00:47INFO--- gemini 进程已退出 ---
+
+
+
+
+ + +
+
+
+
服务设置
+
+
+
HTTP 端口
+
agentchattr 服务监听端口
+
+
+ +
+
+
+
+
数据目录
+
数据库存储位置
+
+
+ +
+
+
+
+
自动启动代理
+
服务启动时自动启动所有代理
+
+
+ +
+
+
+
+
代理默认配置
+
+
+
心跳间隔
+
代理向服务发送心跳的频率
+
+
+ +
+
+
+
+
心跳超时
+
超过此时间未收到心跳视为离线
+
+
+ +
+
+
+
+
自动重连
+
断开后自动尝试重新连接
+
+
+ +
+
+
+
+
界面设置
+
+
+
语言
+
控制台显示语言
+
+
+ +
+
+
+
+
主题
+
浅色 / 深色模式
+
+
+ +
+
+
+
+
+
+ + + + + diff --git a/docs/mockups/launcher-control-panel.html b/docs/mockups/launcher-control-panel.html new file mode 100644 index 00000000..d845a4cf --- /dev/null +++ b/docs/mockups/launcher-control-panel.html @@ -0,0 +1,955 @@ + + + + + + agentchattr Control Panel + + + + + +
+
+
A
+
agentchattr Control Panel
+
Launcher v0.1
+
+
+ + + + +
+
+ + +
+ +
+
+ Server Status + Running +
+
+
+
+ + Service +
+
+ HTTP Port + 8300 +
+
+ MCP SSE + Active +
+
+ MCP HTTP + Active +
+
+ Data Dir + D:\kimicode\agentchattr +
+
+ +
+
+ + Resources +
+
+
+
4
+
Agents Online
+
+
+
2
+
Agents Working
+
+
+
128MB
+
Server RAM
+
+
+
0
+
Errors (1h)
+
+
+
+ +
+
+ + Uptime +
+
+ Server + 2h 14m +
+
+ Session + #7a3f2b +
+
+
+
+ + +
+
+ Agents + 4 configured · 4 online +
+
+
+ +
+
CX
+
+
+ codex + Working +
+
通用开发代理 · Python/JS/架构/代码审查
+
+ + + heartbeat: 2s ago + + + + wrapper.py + +
+
+
+ + +
+
+ + +
+
KM
+
+
+ kimi + Online +
+
通用助手代理 · 中文优先/任务执行/协调
+
+ + + heartbeat: 1s ago + + + + wrapper.py + +
+
+
+ + +
+
+ + +
+
CL
+
+
+ claude + Offline +
+
分析代理 · 深度推理/文档/复杂决策
+
+ + + last seen: 3h ago + + + + wrapper.py + +
+
+
+ + +
+
+ + +
+
GM
+
+
+ gemini + Offline +
+
搜索代理 · 联网检索/信息聚合/实时数据
+
+ + + last seen: 5h ago + + + + wrapper.py + +
+
+
+ + +
+
+
+
+
+ + +
+
+ Terminal + Unified logs +
+
+
+ + server +
+
+ + codex +
+
+ + kimi +
+
+ + claude +
+
+
+
+
14:20:01INFOagentchattr server starting on port 8300
+
14:20:02INFODatabase initialized: agentchattr.db
+
14:20:02OKMCP SSE endpoint ready at /mcp/v1/sse
+
14:20:02OKMCP HTTP endpoint ready at /mcp/v1/http
+
14:20:03INFOWebSocket handler registered
+
14:20:03INFOStatic files served from ./static
+
14:20:04OKServer ready. Listening on http://127.0.0.1:8300
+
14:20:15INFOPOST /api/heartbeat/codex 200 OK (latency: 12ms)
+
14:20:16INFOPOST /api/heartbeat/kimi 200 OK (latency: 8ms)
+
14:20:17INFOPOST /api/heartbeat/codex 200 OK (latency: 10ms)
+
14:20:18INFOPOST /api/heartbeat/kimi 200 OK (latency: 9ms)
+
14:20:19INFOPOST /api/heartbeat/codex 200 OK (latency: 11ms)
+
14:20:20WARNHeartbeat from claude missed (last: 3h ago)
+
14:20:21INFOPOST /api/heartbeat/kimi 200 OK (latency: 7ms)
+
14:20:22INFOPOST /api/heartbeat/codex 200 OK (latency: 13ms)
+
14:20:23INFOWebSocket connection from kimi established
+
+ +
+
14:19:55INFO[codex] Wrapper starting...
+
14:19:56INFO[codex] Loading skills from .kimi-code/skills/
+
14:19:57OK[codex] 14 skills loaded
+
14:19:58OK[codex] Connected to agentchattr via WebSocket
+
14:19:58INFO[codex] Heartbeat interval: 5s
+
14:20:10INFO[codex] Received message in #agentchat from 林婉音
+
14:20:11INFO[codex] Processing: "你觉得codex的方案怎么样?"
+
14:20:12INFO[codex] Context window: 13.2% (34.6k/262.1k)
+
14:20:13INFO[codex] Invoking reasoning pipeline...
+
14:20:14INFO[codex] Response generated (482 tokens)
+
14:20:15OK[codex] Message sent to #agentchat
+
14:20:20INFO[codex] Received message in #agentchat from 林婉音
+
14:20:21INFO[codex] Processing launcher UI mockup request
+
14:20:22INFO[codex] Delegating to kimi: static HTML mockup task
+
+ +
+
14:19:50INFO[kimi] Wrapper starting...
+
14:19:51INFO[kimi] Loading skills from .kimi-code/skills/
+
14:19:52OK[kimi] 12 skills loaded
+
14:19:53OK[kimi] Connected to agentchattr via WebSocket
+
14:19:53INFO[kimi] Heartbeat interval: 5s
+
14:19:58INFO[kimi] Received message in #agentchat
+
14:19:59INFO[kimi] Typing indicator: active=true
+
14:20:00INFO[kimi] Processing: launcher UI discussion
+
14:20:02INFO[kimi] Context window: 13.2% (34.6k/262.1k)
+
14:20:03OK[kimi] Message sent to #agentchat
+
14:20:04INFO[kimi] Typing indicator: active=false
+
14:20:10INFO[kimi] Received message in #agentchat
+
14:20:11INFO[kimi] Evaluating codex launcher proposal
+
14:20:13OK[kimi] Message sent to #agentchat
+
+ +
+
11:15:02INFO[claude] Wrapper starting...
+
11:15:03INFO[claude] Loading skills from .kimi-code/skills/
+
11:15:04OK[claude] 10 skills loaded
+
11:15:05OK[claude] Connected to agentchattr via WebSocket
+
11:15:05INFO[claude] Heartbeat interval: 5s
+
11:20:30WARN[claude] Heartbeat timeout after 30s
+
11:20:31ERR[claude] WebSocket connection lost
+
11:20:32WARN[claude] Attempting reconnect (1/5)...
+
11:20:37ERR[claude] Reconnect failed: Connection refused
+
11:20:37WARN[claude] Giving up. Wrapper exiting.
+
11:20:38INFO[claude] Process terminated with code 1
+
11:20:38INFO--- claude wrapper exited ---
+
+
+
+
+ + +
+
+ Event Timeline + Recent 20 events +
+
+
+ 14:20 + + kimi sent message to #agentchat +
+
+ 14:20 + + codex delegated task to kimi: static HTML mockup +
+
+ 14:18 + + codex WebSocket connection established +
+
+ 14:15 + + claude heartbeat timeout — last seen 3h ago +
+
+ 14:10 + + Session resumed after loop guard +
+
+ 14:09 + + Loop guard: 4 agent-to-agent hops reached +
+
+ 14:05 + + kimi heartbeat restored after 12s gap +
+
+ 14:01 + + Port 8300 briefly unreachable (502) — recovered +
+
+ 13:55 + + kimi-2 disconnected (timeout) +
+
+ 13:50 + + Server restarted by 林婉音 +
+
+ 13:48 + + 林婉音 asked about service restart +
+
+ 13:46 + + Session #7a3f2b started +
+
+
+ + + + + diff --git a/launcher.py b/launcher.py new file mode 100644 index 00000000..943fee72 --- /dev/null +++ b/launcher.py @@ -0,0 +1,37 @@ +"""Compatibility entry point for the web launcher control panel.""" + +from typing import Any + +from launcher_supervisor import AgentTemplate, Launcher, LogEvent, ManagedProcess + + +def _web_registry_provider() -> Any: + """Return app.registry lazily to avoid importing app from reusable supervisor code.""" + try: + import app as _app + + return getattr(_app, "registry", None) + except Exception: + return None + + +def _web_role_setter(name: str, role: str) -> None: + """Apply roles through the existing web runtime bridge.""" + import mcp_bridge + + mcp_bridge.set_role(name, role) + + +launcher = Launcher( + registry_provider=_web_registry_provider, + role_setter=_web_role_setter, +) + + +__all__ = [ + "AgentTemplate", + "Launcher", + "LogEvent", + "ManagedProcess", + "launcher", +] diff --git a/launcher_routes.py b/launcher_routes.py new file mode 100644 index 00000000..428e07de --- /dev/null +++ b/launcher_routes.py @@ -0,0 +1,114 @@ +"""FastAPI routes for launcher control panel. + +All launcher API endpoints live under /api/launcher/*. +WebSocket is at /ws/launcher/events (mounted separately in app.py). +""" + +import asyncio + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from fastapi.requests import Request +from fastapi.responses import JSONResponse + +from launcher import launcher +from launcher_supervisor import Launcher + + +def create_router(supervisor: Launcher) -> APIRouter: + router = APIRouter(prefix="/api/launcher") + + @router.get("/status") + async def launcher_status(): + return await supervisor.get_status() + + @router.get("/agents") + async def launcher_agents(): + return await supervisor.get_agents() + + @router.post("/server/start") + async def server_start(): + result = await supervisor.start_server() + if "error" in result: + return JSONResponse(result, status_code=400) + return result + + @router.post("/server/stop") + async def server_stop(): + result = await supervisor.stop_server() + if "error" in result: + return JSONResponse(result, status_code=400) + return result + + @router.post("/server/restart") + async def server_restart(): + result = await supervisor.restart_process("server") + if "error" in result: + return JSONResponse(result, status_code=400) + return result + + @router.post("/agents/{base}/start") + async def agent_start(base: str, request: Request): + try: + body = await request.json() + except Exception: + body = {} + + result = await supervisor.start_agent( + base=base, + mode=body.get("mode", "normal"), + role=body.get("role"), + custom_role=body.get("custom_role"), + cwd=body.get("cwd"), + auto_start=body.get("auto_start", False), + ) + if "error" in result: + return JSONResponse(result, status_code=400) + return result + + @router.post("/processes/{key}/stop") + async def process_stop(key: str): + result = await supervisor.stop_process(key) + if "error" in result: + return JSONResponse(result, status_code=400) + return result + + @router.post("/processes/{key}/restart") + async def process_restart(key: str): + result = await supervisor.restart_process(key) + if "error" in result: + return JSONResponse(result, status_code=400) + return result + + @router.get("/logs/{key}") + async def process_logs(key: str, limit: int = 100): + return {"logs": supervisor.get_logs(key, limit)} + + return router + + +def create_launcher_events_ws(supervisor: Launcher): + async def launcher_events_ws(websocket: WebSocket): + await websocket.accept() + try: + while True: + status = await supervisor.get_status() + await websocket.send_json({"type": "status", "data": status}) + await asyncio.sleep(1) + except WebSocketDisconnect: + pass + except Exception: + pass + + return launcher_events_ws + + +router = create_router(launcher) +launcher_events_ws = create_launcher_events_ws(launcher) + + +__all__ = [ + "create_launcher_events_ws", + "create_router", + "launcher_events_ws", + "router", +] diff --git a/launcher_rules.py b/launcher_rules.py new file mode 100644 index 00000000..744cbca1 --- /dev/null +++ b/launcher_rules.py @@ -0,0 +1,34 @@ +"""Shared launcher UI/action rules. + +Keep these predicates pure so the backend, frontend port, and tests can agree +on which actions are safe without touching subprocess state. +""" + +RUNNING_PROCESS_STATUSES = {"running", "working"} + + +def server_actions(server: dict) -> dict[str, bool]: + """Return enabled states for server-level controls.""" + running = bool(server.get("running")) + managed = bool(server.get("managed_by_launcher")) + return { + "can_start": not running, + "can_stop": running and managed, + "can_restart": running and managed, + } + + +def process_actions(process: dict) -> dict[str, bool]: + """Return enabled states for one process card.""" + status = process.get("status") + managed = bool(process.get("started_by_launcher")) + active = status in RUNNING_PROCESS_STATUSES + stopped = status == "stopped" + has_base = bool(process.get("base")) + return { + "can_start": managed and stopped and has_base, + "can_stop": managed and active, + "can_restart": managed and active, + "can_view_logs": managed, + "is_external": not managed and status != "stopped", + } diff --git a/launcher_supervisor.py b/launcher_supervisor.py new file mode 100644 index 00000000..c14c2e0a --- /dev/null +++ b/launcher_supervisor.py @@ -0,0 +1,1481 @@ +"""Reusable process supervisor for launcher-style UIs. + +The web `/launcher` page and the native desktop launcher both use this module. +It manages processes it starts itself. Desktop-launched servers are recovered +from a local state file; external agents are detected and displayed. +""" + +from __future__ import annotations + +import asyncio +import inspect +import json +import locale +import os +import platform +import re +import secrets +import subprocess +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from collections import deque +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable, Optional + +from config_loader import ROOT, load_config + + +_YOLO_ARG_MAP: dict[str, list[str]] = { + "kimi": ["--yolo"], + "codex": ["--", "--dangerously-bypass-approvals-and-sandbox"], + "claude": ["--dangerously-skip-permissions"], + "gemini": ["--", "--yolo"], + "qwen": ["--yolo"], +} + +_SESSION_TOKEN_RE = re.compile(r"Session token:\s*([0-9a-fA-F]{32,})") + + +ANSI_CONTROL_RE = re.compile( + r""" + \x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)) + |[\x00-\x08\x0b\x0c\x0e-\x1f\x7f] + """, + re.VERBOSE, +) + + +RegistryProvider = Callable[[], Any] +RoleSetter = Callable[[str, str], Any] + + +@dataclass +class AgentTemplate: + base: str + label: str + command: str + cwd: str + color: str + normal_args: list[str] = field(default_factory=list) + yolo_args: list[str] = field(default_factory=list) + supports_yolo: bool = False + + +@dataclass +class ManagedProcess: + key: str + kind: str + base: Optional[str] + assigned_name: Optional[str] + pid: int + status: str + started_by_launcher: bool + started_at: float + last_error: Optional[str] = None + mode: Optional[str] = None + role: Optional[str] = None + cwd: Optional[str] = None + + +@dataclass +class LogEvent: + process_key: str + stream: str + text: str + timestamp: float + + +@dataclass +class ServerProbe: + running: bool + occupied: bool = False + status: str = "stopped" + detail: str = "" + + +def http_role_setter(host: str, port: int, token: str = "") -> RoleSetter: + """Create a role setter that talks to a running agentchattr server.""" + + def _set(name: str, role: str) -> None: + body = json.dumps({"role": role}).encode("utf-8") + headers = {"Content-Type": "application/json"} + if token: + headers["Authorization"] = f"Bearer {token}" + headers["X-Session-Token"] = token + req = urllib.request.Request( + f"http://{host}:{port}/api/roles/{name}", + method="POST", + data=body, + headers=headers, + ) + urllib.request.urlopen(req, timeout=3).read() + + return _set + + +class Launcher: + """Thin process supervisor shared by web and desktop launchers.""" + + MAX_LOG_LINES = 500 + + def __init__( + self, + *, + root: Path | None = None, + config: dict | None = None, + registry_provider: RegistryProvider | None = None, + role_setter: RoleSetter | None = None, + session_token: str = "", + env_overrides: dict[str, str] | None = None, + ): + self.root = root or ROOT + self._config: dict = config or {} + self._templates: dict[str, AgentTemplate] = {} + self._processes: dict[str, ManagedProcess] = {} + self._subprocesses: dict[str, asyncio.subprocess.Process] = {} + self._logs: dict[str, deque[LogEvent]] = {} + self._lock = asyncio.Lock() + self._registry_provider = registry_provider + self._role_setter = role_setter + self._session_token = session_token + self._env_overrides = dict(env_overrides or {}) + self._load_config() + + def _load_config(self) -> None: + if not self._config: + self._config = load_config(self.root) + self._templates = {} + for name, cfg in self._config.get("agents", {}).items(): + base = name.lower() + yolo_args = _YOLO_ARG_MAP.get(base, []) + self._templates[base] = AgentTemplate( + base=base, + label=cfg.get("label", base.capitalize()), + command=cfg.get("command", base), + cwd=cfg.get("cwd", ".."), + color=cfg.get("color", "#888"), + normal_args=[], + yolo_args=yolo_args, + supports_yolo=bool(yolo_args), + ) + + def set_session_token(self, token: str) -> None: + self._session_token = token or "" + + def _server_host_port(self) -> tuple[str, int]: + server = self._config.get("server", {}) + return server.get("host", "127.0.0.1"), int(server.get("port", 8300)) + + def _probe_host_port(self) -> tuple[str, int]: + host, port = self._server_host_port() + if host in ("0.0.0.0", "::"): + host = "127.0.0.1" + return host, port + + def _data_dir(self) -> Path: + raw = self._config.get("server", {}).get("data_dir", "./data") + path = Path(raw) + if not path.is_absolute(): + path = (self.root / path).resolve() + return path + + def _server_state_path(self) -> Path: + return self._data_dir() / "launcher_server.json" + + def _load_server_state(self) -> dict[str, Any]: + path = self._server_state_path() + try: + state = json.loads(path.read_text("utf-8")) + except Exception: + return {} + + _, port = self._server_host_port() + if state.get("root") != str(self.root.resolve()): + return {} + if int(state.get("port") or 0) != port: + return {} + if not state.get("launcher_token"): + return {} + return state + + def _save_server_state(self, *, pid: int, launcher_token: str) -> None: + host, port = self._server_host_port() + state = { + "pid": pid, + "host": host, + "port": port, + "root": str(self.root.resolve()), + "launcher_token": launcher_token, + "started_at": time.time(), + } + path = self._server_state_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(state, indent=2), "utf-8") + + def _clear_server_state(self) -> None: + try: + self._server_state_path().unlink(missing_ok=True) + except Exception: + pass + + def _child_env(self, extra: dict[str, str] | None = None) -> dict[str, str]: + env = dict(os.environ) + env.update(self._env_overrides) + if extra: + env.update(extra) + env.setdefault("PYTHONUTF8", "1") + env.setdefault("PYTHONIOENCODING", "utf-8") + if platform.system().lower() == "windows": + self._augment_windows_cli_path(env) + return env + + def _augment_windows_cli_path(self, env: dict[str, str]) -> None: + """Mirror the Windows .bat launchers' CLI PATH fixes for desktop starts.""" + + existing = env.get("PATH", "") + parts = [part for part in existing.split(os.pathsep) if part] + seen = {str(Path(part)).lower() for part in parts} + candidates: list[Path] = [] + + local_appdata = Path(env.get("LOCALAPPDATA", "")) + appdata = Path(env.get("APPDATA", "")) + userprofile = Path(env.get("USERPROFILE", "")) + + if local_appdata: + codex_bin = local_appdata / "OpenAI" / "Codex" / "bin" + candidates.append(codex_bin) + try: + candidates.extend( + path + for path in sorted( + codex_bin.iterdir(), + key=lambda item: item.stat().st_mtime, + reverse=True, + ) + if path.is_dir() + ) + except Exception: + pass + + if appdata: + candidates.append(appdata / "npm") + + if userprofile: + versions = userprofile / ".workbuddy" / "binaries" / "node" / "versions" + try: + candidates.extend( + path + for path in sorted( + versions.iterdir(), + key=lambda item: item.stat().st_mtime, + reverse=True, + ) + if path.is_dir() + ) + except Exception: + pass + + candidates.extend( + [ + Path(r"C:\ServBay\packages\node\current"), + Path(r"C:\ServBay\packages\python\current"), + Path(r"C:\ServBay\packages\python\current\Scripts"), + ] + ) + + prepend: list[str] = [] + for candidate in candidates: + try: + if not candidate.exists(): + continue + normalized = str(candidate.resolve()).lower() + except Exception: + continue + if normalized in seen: + continue + seen.add(normalized) + prepend.append(str(candidate)) + + if prepend: + env["PATH"] = os.pathsep.join([*prepend, existing]) if existing else os.pathsep.join(prepend) + + def _agent_launch_mode(self) -> str: + raw = ( + self._config.get("launcher", {}).get("agent_launch_mode") + or os.environ.get("AGENTCHATTR_AGENT_LAUNCH_MODE") + or "" + ) + mode = str(raw).strip().lower() + if mode in {"pipe", "pipes"}: + return "pipe" + if mode in { + "console", + "terminal", + "real_console", + "external_console", + "windows_terminal", + "windows-console", + }: + return "external_console" + if platform.system().lower() == "windows": + return "external_console" + return "pipe" + + def _server_command(self) -> list[str]: + if getattr(sys, "frozen", False): + return [sys.executable, "--agentchattr-internal", "server"] + return [sys.executable, str(self.root / "run.py")] + + def _wrapper_command(self, base: str) -> list[str]: + if getattr(sys, "frozen", False): + return [sys.executable, "--agentchattr-internal", "wrapper", base] + return [sys.executable, str(self.root / "wrapper.py"), base] + + def _ensure_no_restart_arg(self, cmd: list[str]) -> list[str]: + if "--no-restart" in cmd: + return cmd + if len(cmd) >= 4 and cmd[1:3] == ["--agentchattr-internal", "wrapper"]: + return [cmd[0], cmd[1], cmd[2], cmd[3], "--no-restart", *cmd[4:]] + if len(cmd) >= 3 and Path(cmd[1]).name.lower() == "wrapper.py": + return [cmd[0], cmd[1], cmd[2], "--no-restart", *cmd[3:]] + return [*cmd, "--no-restart"] + + async def _http_health_ok(self, path: str) -> bool: + host, port = self._probe_host_port() + url_host = f"[{host}]" if ":" in host and not host.startswith("[") else host + url = f"http://{url_host}:{port}{path}" + try: + def request() -> bool: + req = urllib.request.Request( + url, + method="GET", + headers={"User-Agent": "agentchattr-launcher/1.0"}, + ) + with urllib.request.urlopen(req, timeout=1.5) as response: + return 200 <= response.status < 400 + + return await asyncio.to_thread(request) + except Exception: + return False + + async def _http_json(self, path: str, timeout: float = 1.5) -> dict | None: + host, port = self._probe_host_port() + url_host = f"[{host}]" if ":" in host and not host.startswith("[") else host + url = f"http://{url_host}:{port}{path}" + + def request() -> dict | None: + headers = {"User-Agent": "agentchattr-launcher/1.0"} + if self._session_token: + headers["X-Session-Token"] = self._session_token + headers["Authorization"] = f"Bearer {self._session_token}" + req = urllib.request.Request(url, method="GET", headers=headers) + with urllib.request.urlopen(req, timeout=timeout) as response: + if not 200 <= response.status < 400: + return None + data = json.loads(response.read().decode("utf-8")) + return data if isinstance(data, dict) else None + + try: + return await asyncio.to_thread(request) + except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, json.JSONDecodeError): + return None + except Exception: + return None + + async def _tcp_port_open(self) -> bool: + host, port = self._probe_host_port() + try: + reader, writer = await asyncio.wait_for( + asyncio.open_connection(host, port), timeout=1.0 + ) + writer.close() + await writer.wait_closed() + return True + except Exception: + return False + + async def _probe_server(self) -> ServerProbe: + _, port = self._server_host_port() + if await self._http_health_ok("/launcher") or await self._http_health_ok("/"): + return ServerProbe(running=True, occupied=False, status="running") + if await self._tcp_port_open(): + return ServerProbe( + running=False, + occupied=True, + status="occupied", + detail=f"Port {port} is occupied but did not pass HTTP health checks", + ) + return ServerProbe(running=False, occupied=False, status="stopped") + + async def _is_server_running(self) -> bool: + return (await self._probe_server()).running + + def _pid_is_alive(self, pid: int | None) -> bool: + if not pid: + return False + if pid == os.getpid(): + return True + if platform.system().lower() == "windows": + try: + result = subprocess.run( + ["tasklist", "/FI", f"PID eq {pid}"], + capture_output=True, + text=True, + timeout=5, + ) + except Exception: + return False + return result.returncode == 0 and str(pid) in result.stdout + try: + os.kill(pid, 0) + return True + except OSError: + return False + except Exception: + return False + + def _saved_server_is_manageable(self) -> bool: + state = self._load_server_state() + return bool(state and self._pid_is_alive(int(state.get("pid") or 0))) + + def _find_windows_pid_for_port(self, port: int) -> int | None: + if platform.system().lower() != "windows": + return None + try: + result = subprocess.run( + ["netstat", "-ano", "-p", "tcp"], + capture_output=True, + text=True, + timeout=5, + ) + except Exception: + return None + if result.returncode != 0: + return None + suffix = f":{port}" + for line in result.stdout.splitlines(): + parts = line.split() + if len(parts) < 5 or parts[0].upper() != "TCP": + continue + local_addr = parts[1] + state = parts[3].upper() + if state != "LISTENING" or not local_addr.endswith(suffix): + continue + try: + return int(parts[-1]) + except ValueError: + return None + return None + + async def _terminate_port_owner(self) -> dict: + _, port = self._server_host_port() + pid = await asyncio.to_thread(self._find_windows_pid_for_port, port) + if not pid: + return { + "error": "Server is running externally and cannot be stopped by launcher", + "status": "external", + } + if pid == os.getpid(): + return { + "error": "Refusing to terminate the current launcher process", + "status": "external", + } + try: + if platform.system().lower() == "windows": + result = await asyncio.to_thread( + subprocess.run, + ["taskkill", "/PID", str(pid), "/T", "/F"], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode != 0: + detail = (result.stderr or result.stdout or "").strip() + return { + "error": f"Failed to stop PID {pid}: {detail or 'taskkill failed'}", + "status": "error", + } + else: + os.kill(pid, 15) + except Exception as exc: + return {"error": f"Failed to stop PID {pid}: {exc}", "status": "error"} + + for _ in range(20): + if not (await self._probe_server()).running: + self._clear_server_state() + return {"key": "server", "status": "stopped", "pid": pid} + await asyncio.sleep(0.25) + return { + "error": f"PID {pid} was stopped, but the server port still responds", + "status": "error", + } + + async def _request_launcher_shutdown(self, token: str) -> dict: + host, port = self._probe_host_port() + url_host = f"[{host}]" if ":" in host and not host.startswith("[") else host + url = f"http://{url_host}:{port}/api/shutdown_launcher_server" + + def request() -> tuple[int, bytes]: + req = urllib.request.Request( + url, + method="POST", + headers={ + "User-Agent": "agentchattr-launcher/1.0", + "X-Launcher-Token": token, + }, + ) + with urllib.request.urlopen(req, timeout=3) as response: + return response.status, response.read() + + status, body = await asyncio.to_thread(request) + try: + payload = json.loads(body.decode("utf-8") or "{}") + except Exception: + payload = {} + payload.setdefault("http_status", status) + return payload + + async def _release_agent_identity(self, name: str | None) -> dict: + if not name: + return {"ok": False, "status": "skipped"} + host, port = self._probe_host_port() + url_host = f"[{host}]" if ":" in host and not host.startswith("[") else host + safe_name = urllib.parse.quote(str(name), safe="") + url = f"http://{url_host}:{port}/api/launcher/agents/{safe_name}/release" + token = str(self._load_server_state().get("launcher_token") or "") + + def request() -> tuple[int, bytes]: + headers = {"User-Agent": "agentchattr-launcher/1.0"} + if token: + headers["X-Launcher-Token"] = token + req = urllib.request.Request( + url, + method="POST", + data=b"", + headers=headers, + ) + with urllib.request.urlopen(req, timeout=3) as response: + return response.status, response.read() + + try: + status, body = await asyncio.to_thread(request) + try: + payload = json.loads(body.decode("utf-8") or "{}") + except Exception: + payload = {} + payload.setdefault("http_status", status) + return payload + except Exception as exc: + self._logs.setdefault("server", deque(maxlen=self.MAX_LOG_LINES)).append( + LogEvent( + process_key="server", + stream="launcher", + text=f"Failed to release agent identity {name}: {exc}", + timestamp=time.time(), + ) + ) + return {"ok": False, "error": str(exc)} + + async def _stop_server_with_saved_token(self) -> dict | None: + state = self._load_server_state() + token = state.get("launcher_token") + if not token: + return None + try: + await self._request_launcher_shutdown(str(token)) + except urllib.error.HTTPError as exc: + if exc.code in (401, 403, 404, 405): + self._clear_server_state() + return None + return { + "error": f"Launcher shutdown request failed: HTTP {exc.code}", + "status": "error", + } + except Exception as exc: + return { + "error": f"Launcher shutdown request failed: {exc}", + "status": "error", + } + + for _ in range(30): + if not (await self._probe_server()).running: + self._clear_server_state() + return { + "key": "server", + "status": "stopped", + "pid": state.get("pid"), + } + await asyncio.sleep(0.25) + return { + "error": "Server accepted shutdown but did not stop before timeout", + "status": "error", + } + + def _decode_log_bytes(self, data: bytes) -> str: + encodings = ["utf-8", locale.getpreferredencoding(False), sys.getfilesystemencoding()] + if os.name == "nt": + encodings.extend(["mbcs", "cp936"]) + + seen: set[str] = set() + for encoding in encodings: + if not encoding or encoding.lower() in seen: + continue + seen.add(encoding.lower()) + try: + return data.decode(encoding) + except (LookupError, UnicodeDecodeError): + continue + return data.decode("utf-8", errors="replace") + + def _clean_log_text(self, text: str) -> str: + text = ANSI_CONTROL_RE.sub("", text) + return text.replace("\r", "").rstrip("\n") + + async def _read_stream( + self, key: str, stream: asyncio.StreamReader | None, stream_name: str + ) -> None: + if stream is None: + return + while True: + try: + line = await stream.readline() + if not line: + break + text = self._clean_log_text(self._decode_log_bytes(line)) + token_match = _SESSION_TOKEN_RE.search(text) + if token_match: + self.set_session_token(token_match.group(1)) + self._logs.setdefault(key, deque(maxlen=self.MAX_LOG_LINES)).append( + LogEvent( + process_key=key, + stream=stream_name, + text=text, + timestamp=time.time(), + ) + ) + except Exception: + break + + async def _monitor_process( + self, + key: str, + proc: asyncio.subprocess.Process, + process: ManagedProcess, + ) -> None: + await asyncio.gather( + self._read_stream(key, proc.stdout, "stdout"), + self._read_stream(key, proc.stderr, "stderr"), + return_exceptions=True, + ) + await proc.wait() + async with self._lock: + if process.status == "stopping": + process.status = "stopped" + elif process.kind == "agent" and process.started_by_launcher: + process.status = "stopped" + process.last_error = None + elif proc.returncode != 0: + process.status = "error" + process.last_error = f"Exited with code {proc.returncode}" + else: + process.status = "stopped" + if key in self._subprocesses and self._subprocesses[key] is proc: + del self._subprocesses[key] + if key == "server": + self._clear_server_state() + release_name = ( + process.assigned_name + if process.kind == "agent" and process.started_by_launcher + else None + ) + if release_name: + await self._release_agent_identity(release_name) + + async def _terminate_windows_process_tree(self, pid: int) -> dict: + try: + startupinfo = None + creationflags = 0 + if platform.system().lower() == "windows": + creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + startupinfo.wShowWindow = subprocess.SW_HIDE + result = await asyncio.to_thread( + subprocess.run, + ["taskkill", "/PID", str(pid), "/T", "/F"], + capture_output=True, + text=True, + timeout=10, + creationflags=creationflags, + startupinfo=startupinfo, + ) + except Exception as exc: + return {"error": f"Failed to stop PID {pid}: {exc}", "status": "error"} + if result.returncode != 0: + detail = (result.stderr or result.stdout or "").strip() + return { + "error": f"Failed to stop PID {pid}: {detail or 'taskkill failed'}", + "status": "error", + } + return {"pid": pid, "status": "stopped"} + + async def send_input(self, key: str, text: str, *, append_newline: bool = True) -> dict: + async with self._lock: + proc = self._subprocesses.get(key) + process = self._processes.get(key) + if not proc: + if key.startswith("external:"): + return { + "error": f"Process {key} is external and has no launcher stdin pipe", + "status": "external", + } + if process and not process.started_by_launcher: + return { + "error": f"Process {key} is not managed by launcher", + "status": "external", + } + return {"error": f"Process {key} not found", "status": "not_found"} + if proc.returncode is not None: + return {"error": f"Process {key} has already exited", "status": "stopped"} + if proc.stdin is None: + return { + "error": f"Process {key} was not started with a stdin pipe", + "status": "unsupported", + } + payload = text + ("\n" if append_newline else "") + stdin = proc.stdin + + try: + stdin.write(payload.encode("utf-8")) + await stdin.drain() + except (BrokenPipeError, ConnectionResetError): + return {"error": f"Process {key} stdin pipe is closed", "status": "closed"} + except Exception as exc: + return {"error": f"Failed to send input to {key}: {exc}", "status": "error"} + + return { + "key": key, + "status": "sent", + "bytes": len(payload.encode("utf-8")), + "capability": "stdin_pipe", + } + + def _registry_instances(self) -> dict[str, dict]: + if not self._registry_provider: + return {} + try: + registry = self._registry_provider() + if not registry: + return {} + if isinstance(registry, dict): + return registry + if hasattr(registry, "get_all"): + return registry.get_all() + except Exception: + return {} + return {} + + async def _server_runtime_status(self, server_running: bool) -> dict[str, dict]: + if not server_running: + return {} + status = await self._http_json("/api/status") + if not status: + return {} + return { + str(name): value + for name, value in status.items() + if name != "paused" and isinstance(value, dict) + } + + def _registry_runtime_status(self, instances: dict[str, dict]) -> dict[str, dict]: + runtime: dict[str, dict] = {} + if not instances: + return runtime + + is_online = is_active = get_role = None + try: + import mcp_bridge + + is_online = getattr(mcp_bridge, "is_online", None) + is_active = getattr(mcp_bridge, "is_active", None) + get_role = getattr(mcp_bridge, "get_role", None) + except Exception: + pass + + for name, inst in instances.items(): + inst_name = str(inst.get("name") or name) + state = inst.get("state", "unknown") + available = False + busy = False + role = inst.get("role") or "" + try: + if callable(is_online): + available = bool(is_online(inst_name)) + if callable(is_active): + busy = bool(is_active(inst_name)) + if callable(get_role): + role = role or str(get_role(inst_name) or "") + except Exception: + pass + runtime[inst_name] = { + "available": available, + "busy": busy, + "label": inst.get("label", inst_name), + "color": inst.get("color", "#888"), + "role": role, + "base": inst.get("base"), + "state": state, + } + return runtime + + @staticmethod + def _runtime_display_status(runtime: dict | None, fallback: str = "unknown") -> str: + if not runtime: + return fallback + if runtime.get("busy"): + return "working" + if runtime.get("available"): + return "active" + if runtime.get("state") == "pending": + return "pending" + if fallback in {"starting", "stopping", "error", "stopped"}: + return fallback + return "stopped" + + def _merge_runtime_status( + self, + processes: dict[str, dict], + runtime_status: dict[str, dict], + ) -> None: + def has_live_launcher_agent(proc_key: str, proc: dict) -> bool: + if not (proc.get("kind") == "agent" and proc.get("started_by_launcher")): + return False + child = self._subprocesses.get(proc_key) + return bool(child and child.returncode is None) + + assigned_to_key = { + proc.get("assigned_name"): key + for key, proc in processes.items() + if proc.get("kind") == "agent" and proc.get("assigned_name") + } + pending_managed_bases = { + proc.get("base") + for key, proc in processes.items() + if ( + proc.get("kind") == "agent" + and proc.get("started_by_launcher") + and not proc.get("assigned_name") + and proc.get("status") in {"starting", "running", "pending"} + and has_live_launcher_agent(key, proc) + ) + } + pending_by_base: dict[str, list[str]] = {} + for proc_key, proc in processes.items(): + base = proc.get("base") + if base in pending_managed_bases and has_live_launcher_agent(proc_key, proc): + pending_by_base.setdefault(str(base), []).append(proc_key) + + for name, runtime in runtime_status.items(): + key = assigned_to_key.get(name) + if key: + proc = processes[key] + proc["label"] = runtime.get("label") or proc.get("label") + proc["color"] = runtime.get("color") or proc.get("color") + proc["state"] = runtime.get("state") or proc.get("state") + proc["role"] = runtime.get("role") or proc.get("role") + proc["base"] = proc.get("base") or runtime.get("base") + if proc.get("started_by_launcher") and not has_live_launcher_agent(key, proc): + proc["status"] = str(proc.get("status") or "stopped") + if proc["status"] not in {"stopped", "error"}: + proc["status"] = "stopped" + proc["available"] = False + proc["busy"] = False + else: + proc["status"] = self._runtime_display_status( + runtime, str(proc.get("status") or "unknown") + ) + proc["available"] = bool(runtime.get("available")) + proc["busy"] = bool(runtime.get("busy")) + continue + + if runtime.get("base") in pending_managed_bases: + candidates = pending_by_base.get(str(runtime.get("base"))) or [] + if candidates: + pending_key = candidates.pop(0) + proc = processes[pending_key] + proc["assigned_name"] = name + proc["status"] = self._runtime_display_status( + runtime, str(proc.get("status") or "running") + ) + proc["available"] = bool(runtime.get("available")) + proc["busy"] = bool(runtime.get("busy")) + proc["label"] = runtime.get("label") or proc.get("label") + proc["color"] = runtime.get("color") or proc.get("color") + proc["state"] = runtime.get("state") or proc.get("state") + proc["role"] = runtime.get("role") or proc.get("role") + async_process = self._processes.get(pending_key) + if async_process: + async_process.assigned_name = name + async_process.status = str(proc["status"]) + continue + + key = f"external:{name}" + processes.setdefault( + key, + { + "key": key, + "kind": "agent", + "base": runtime.get("base"), + "assigned_name": name, + "pid": None, + "status": self._runtime_display_status(runtime, "unknown"), + "started_by_launcher": False, + "started_at": runtime.get("registered_at", 0), + "last_error": None, + "mode": None, + "role": runtime.get("role") or "", + "cwd": None, + "can_send_input": False, + "input_capability": "external", + "terminal_capability": "external", + "available": bool(runtime.get("available")), + "busy": bool(runtime.get("busy")), + "label": runtime.get("label", name), + "color": runtime.get("color", "#888"), + "state": runtime.get("state"), + }, + ) + + def _instances_for(self, base: str) -> list[dict]: + if not self._registry_provider: + return [] + try: + registry = self._registry_provider() + if not registry: + return [] + if hasattr(registry, "get_instances_for"): + return registry.get_instances_for(base) + if isinstance(registry, dict): + return [ + dict(inst, name=name) + for name, inst in registry.items() + if inst.get("base") == base + ] + except Exception: + return [] + return [] + + async def _apply_role(self, name: str, role: str | None) -> None: + if not role or role == "none" or not self._role_setter: + return + try: + result = self._role_setter(name, role) + if inspect.isawaitable(result): + await result + except Exception: + pass + + async def get_status(self) -> dict: + probe = await self._probe_server() + saved_server_state = self._load_server_state() + server_managed = "server" in self._subprocesses or ( + probe.running and self._saved_server_is_manageable() + ) + if server_managed and not probe.running: + server_status = self._processes.get("server") + status_text = server_status.status if server_status else "starting" + else: + status_text = probe.status + + def can_send_input(key: str) -> bool: + proc = self._subprocesses.get(key) + return bool(proc and proc.stdin is not None and proc.returncode is None) + + def input_capability(key: str, process: ManagedProcess) -> str: + proc = self._subprocesses.get(key) + if proc and proc.returncode is None: + if proc.stdin is not None: + return "stdin_pipe" + if process.kind == "agent" and platform.system().lower() == "windows": + return "windows_terminal" + return "process" + return "unavailable" + + processes = { + k: { + "key": p.key, + "kind": p.kind, + "base": p.base, + "assigned_name": p.assigned_name, + "pid": p.pid, + "status": p.status, + "started_by_launcher": p.started_by_launcher, + "started_at": p.started_at, + "last_error": p.last_error, + "mode": p.mode, + "role": p.role, + "cwd": p.cwd, + "can_send_input": can_send_input(k), + "input_capability": input_capability(k, p), + "terminal_capability": input_capability(k, p), + } + for k, p in self._processes.items() + } + + registry_instances = self._registry_instances() + managed_names = { + p.get("assigned_name") + for p in processes.values() + if p.get("started_by_launcher") and p.get("assigned_name") + } + pending_managed_bases = { + p.get("base") + for p in processes.values() + if ( + p.get("started_by_launcher") + and not p.get("assigned_name") + and p.get("status") in {"starting", "running", "pending"} + ) + } + for name, inst in registry_instances.items(): + inst_name = inst.get("name", name) + if inst_name in managed_names: + continue + if inst.get("base") in pending_managed_bases: + continue + key = f"external:{inst_name}" + processes.setdefault( + key, + { + "key": key, + "kind": "agent", + "base": inst.get("base"), + "assigned_name": inst_name, + "pid": None, + "status": inst.get("state", "unknown"), + "started_by_launcher": False, + "started_at": inst.get("registered_at", 0), + "last_error": None, + "mode": None, + "role": inst.get("role"), + "cwd": None, + "can_send_input": False, + "input_capability": "external", + "terminal_capability": "external", + }, + ) + runtime_status = await self._server_runtime_status(probe.running) + if not runtime_status: + runtime_status = self._registry_runtime_status(registry_instances) + self._merge_runtime_status(processes, runtime_status) + + host, port = self._server_host_port() + return { + "server": { + "running": probe.running, + "occupied": probe.occupied, + "port_occupied": probe.occupied, + "status": status_text, + "probe_status": probe.status, + "detail": probe.detail, + "managed_by_launcher": server_managed, + "pid": self._processes.get("server").pid + if self._processes.get("server") + else saved_server_state.get("pid"), + "port": port, + "host": host, + "data_dir": self._config.get("server", {}).get("data_dir", "./data"), + "mcp_http_port": self._config.get("mcp", {}).get("http_port", 8200), + "mcp_sse_port": self._config.get("mcp", {}).get("sse_port", 8201), + }, + "templates": { + t.base: { + "base": t.base, + "label": t.label, + "command": t.command, + "cwd": t.cwd, + "color": t.color, + "supports_yolo": t.supports_yolo, + } + for t in self._templates.values() + }, + "processes": processes, + } + + async def start_server(self) -> dict: + async with self._lock: + probe = await self._probe_server() + if probe.running: + return {"error": "Server is already running", "status": "running"} + if probe.occupied: + return { + "error": probe.detail or "Server port is occupied", + "status": "occupied", + } + if "server" in self._subprocesses: + return {"error": "Server start already in progress", "status": "starting"} + + cmd = self._server_command() + launcher_token = secrets.token_urlsafe(32) + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=str(self.root), + env=self._child_env( + { + "AGENTCHATTR_LAUNCHER_TOKEN": launcher_token, + } + ), + ) + self._save_server_state(pid=proc.pid, launcher_token=launcher_token) + except Exception as exc: + return {"error": f"Failed to start server: {exc}", "status": "error"} + + process = ManagedProcess( + key="server", + kind="server", + base=None, + assigned_name=None, + pid=proc.pid, + status="starting", + started_by_launcher=True, + started_at=time.time(), + cwd=str(self.root), + ) + self._processes["server"] = process + self._subprocesses["server"] = proc + + asyncio.create_task(self._monitor_process("server", proc, process)) + return {"key": "server", "status": "starting", "pid": proc.pid} + + async def stop_server(self) -> dict: + if "server" in self._subprocesses: + return await self.stop_process("server") + + probe = await self._probe_server() + if probe.running: + token_result = await self._stop_server_with_saved_token() + if token_result: + return token_result + return await self._terminate_port_owner() + if probe.occupied: + port_result = await self._terminate_port_owner() + if "error" not in port_result: + return port_result + return { + "error": probe.detail or port_result.get("error") or "Server port is occupied and cannot be stopped by launcher", + "status": "occupied", + } + self._clear_server_state() + return {"error": "Server is not running", "status": "idle"} + + async def start_agent( + self, + base: str, + mode: str = "normal", + role: Optional[str] = None, + custom_role: Optional[str] = None, + cwd: Optional[str] = None, + auto_start: bool = False, + reuse_key: Optional[str] = None, + preferred_name: Optional[str] = None, + ) -> dict: + base = base.lower() + template = self._templates.get(base) + if not template: + return {"error": f"Unknown agent type: {base}"} + if mode not in ("normal", "yolo"): + return {"error": f"Unknown launch mode: {mode}"} + if mode == "yolo" and not template.supports_yolo: + return {"error": f"Agent {base} does not support yolo mode"} + if not await self._is_server_running(): + return {"error": "Server must be running before starting agents"} + + cmd = self._wrapper_command(base) + if preferred_name: + cmd.extend(["--preferred-name", preferred_name]) + if mode == "yolo" and template.yolo_args: + cmd.extend(template.yolo_args) + cmd = self._ensure_no_restart_arg(cmd) + + raw_work_dir = cwd or template.cwd or str(self.root.parent) + work_dir = Path(raw_work_dir) + if not work_dir.is_absolute(): + work_dir = (self.root / work_dir).resolve() + else: + work_dir = work_dir.resolve() + + async with self._lock: + key = reuse_key or f"agent:{base}:{int(time.time() * 1000)}" + existing_process = self._processes.get(key) + if reuse_key: + if not existing_process: + return {"error": f"Process {reuse_key} not found", "status": "not_found"} + if existing_process.kind != "agent" or not existing_process.started_by_launcher: + return {"error": f"Process {reuse_key} is not a launcher-owned agent", "status": "external"} + existing_proc = self._subprocesses.get(key) + if existing_proc and existing_proc.returncode is None: + return {"error": f"Process {reuse_key} is already running", "status": "running"} + launch_mode = self._agent_launch_mode() + env = self._child_env() + subprocess_kwargs: dict[str, Any] = { + "cwd": str(work_dir), + "env": env, + } + if launch_mode == "external_console" and platform.system().lower() == "windows": + subprocess_kwargs.update( + { + "stdin": None, + "stdout": None, + "stderr": None, + "creationflags": subprocess.CREATE_NEW_CONSOLE, + } + ) + else: + subprocess_kwargs.update( + { + "stdin": asyncio.subprocess.PIPE, + "stdout": asyncio.subprocess.PIPE, + "stderr": asyncio.subprocess.PIPE, + } + ) + try: + proc = await asyncio.create_subprocess_exec(*cmd, **subprocess_kwargs) + except Exception as exc: + return {"error": f"Failed to start agent: {exc}"} + pid = proc.pid + self._subprocesses[key] = proc + + if launch_mode == "external_console" and platform.system().lower() == "windows": + self._logs.setdefault(key, deque(maxlen=self.MAX_LOG_LINES)).append( + LogEvent( + process_key=key, + stream="terminal", + text=( + "Started in Windows Terminal. " + "Use that terminal window for interactive CLI input." + ), + timestamp=time.time(), + ) + ) + role_value = custom_role if role == "custom" else role + if role_value == "none": + role_value = None + if existing_process: + existing_process.base = base + existing_process.pid = pid + existing_process.status = "starting" + existing_process.started_at = time.time() + existing_process.last_error = None + existing_process.mode = mode + existing_process.role = role_value + existing_process.cwd = str(work_dir) + process = existing_process + else: + process = ManagedProcess( + key=key, + kind="agent", + base=base, + assigned_name=None, + pid=pid, + status="starting", + started_by_launcher=True, + started_at=time.time(), + mode=mode, + role=role_value, + cwd=str(work_dir), + ) + self._processes[key] = process + + asyncio.create_task(self._monitor_process(key, self._subprocesses[key], process)) + asyncio.create_task(self._resolve_assigned_name(key, base)) + return { + "process_key": key, + "base": base, + "assigned_name": process.assigned_name, + "status": "starting", + "started_by_launcher": True, + "pid": pid, + } + + async def start_existing_agent(self, key: str) -> dict: + process = self._processes.get(key) + if not process: + return {"error": f"Process {key} not found", "status": "not_found"} + if process.kind != "agent" or not process.started_by_launcher: + return {"error": f"Process {key} is not a launcher-owned agent", "status": "external"} + proc = self._subprocesses.get(key) + if proc and proc.returncode is None: + return {"error": f"Process {key} is already running", "status": "running"} + if not process.base: + return {"error": f"Process {key} has no agent base", "status": "error"} + if process.assigned_name: + await self._release_agent_identity(process.assigned_name) + return await self.start_agent( + base=process.base, + mode=process.mode or "normal", + role=process.role, + cwd=process.cwd, + reuse_key=key, + preferred_name=process.assigned_name, + ) + + async def _resolve_assigned_name(self, key: str, base: str) -> None: + for _ in range(30): + await asyncio.sleep(1) + async with self._lock: + process = self._processes.get(key) + if not process or process.status in ("stopped", "error"): + return + + instances = self._instances_for(base) + used_names = { + p.assigned_name + for p in self._processes.values() + if p.assigned_name and p.key != key + } + for inst in sorted( + instances, + key=lambda item: item.get("registered_at", 0), + reverse=True, + ): + name = inst.get("name") + if not name or name in used_names: + continue + role_value = None + async with self._lock: + current = self._processes.get(key) + if not current: + return + current.assigned_name = name + if current.status == "starting": + current.status = "running" + role_value = current.role + await self._apply_role(name, role_value) + return + + async def stop_process(self, key: str) -> dict: + release_name = None + async with self._lock: + proc = self._subprocesses.get(key) + process = self._processes.get(key) + + if not proc and not process: + if key.startswith("external:"): + return { + "error": f"Agent {key} is running externally and cannot be stopped by launcher", + "status": "external", + } + return {"error": f"Process {key} not found", "status": "not_found"} + if not proc: + if process and process.status == "stopped": + return {"key": key, "status": "stopped"} + return { + "error": f"Process {key} is not managed by launcher", + "status": "external", + } + if process: + process.status = "stopping" + + try: + if ( + platform.system().lower() == "windows" + and process + and process.kind == "agent" + ): + result = await self._terminate_windows_process_tree(proc.pid) + if "error" in result: + return result + await asyncio.wait_for(proc.wait(), timeout=5.0) + else: + proc.terminate() + await asyncio.wait_for(proc.wait(), timeout=5.0) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + except Exception as exc: + return {"error": f"Failed to stop process: {exc}"} + + self._subprocesses.pop(key, None) + if process: + process.status = "stopped" + if process.kind == "agent" and process.started_by_launcher: + release_name = process.assigned_name + if key == "server": + self._clear_server_state() + + if release_name: + await self._release_agent_identity(release_name) + return {"key": key, "status": "stopped"} + + async def restart_process(self, key: str) -> dict: + if key == "server" and "server" not in self._subprocesses: + stop_result = await self.stop_server() + if "error" in stop_result and stop_result.get("status") not in ("idle", "stopped"): + return stop_result + for _ in range(20): + probe = await self._probe_server() + if not probe.running and not probe.occupied: + break + await asyncio.sleep(0.25) + return await self.start_server() + + process = self._processes.get(key) + if not process: + return {"error": f"Process {key} not found"} + + is_server = key == "server" or process.kind == "server" + base = process.base + mode = process.mode or "normal" + role = process.role + cwd = process.cwd + + stop_result = await self.stop_process(key) + if "error" in stop_result and stop_result.get("status") != "stopped": + return stop_result + + for _ in range(10): + await asyncio.sleep(0.5) + current = self._processes.get(key) + if current and current.status in ("stopped", "error"): + break + + if is_server: + return await self.start_server() + if base: + return await self.start_agent( + base=base, + mode=mode, + role=role, + cwd=cwd, + reuse_key=key, + preferred_name=process.assigned_name, + ) + return {"error": "Cannot restart: unknown process type"} + + def get_logs(self, key: str, limit: int = 100) -> list[dict]: + events = list(self._logs.get(key, [])) + return [ + { + "process_key": event.process_key, + "stream": event.stream, + "text": event.text, + "timestamp": event.timestamp, + } + for event in events[-limit:] + ] + + async def get_agents(self) -> dict: + status = await self.get_status() + return { + "templates": status["templates"], + "processes": status["processes"], + "registry_instances": self._registry_instances(), + } diff --git a/mcp_bridge.py b/mcp_bridge.py index d6974dba..c9f24cc5 100644 --- a/mcp_bridge.py +++ b/mcp_bridge.py @@ -10,9 +10,11 @@ import time import logging import threading +import asyncio from pathlib import Path from mcp.server.fastmcp import Context, FastMCP +from registry import canonicalize_name log = logging.getLogger(__name__) @@ -155,6 +157,26 @@ def _authenticated_instance(ctx: Context | None) -> dict | None: return registry.resolve_token(token) +def _canonical_agent_name(name: str) -> str: + canonical = canonicalize_name(name) + if registry and canonical: + resolved = registry.resolve_name(canonical) + if registry.is_registered(resolved): + return resolved + return canonical + + +def _notify_status_async(): + try: + import app + loop = getattr(app, "_event_loop", None) + broadcast = getattr(app, "broadcast_status", None) + if loop and broadcast: + asyncio.run_coroutine_threadsafe(broadcast(), loop) + except Exception: + pass + + def _resolve_tool_identity( raw_name: str, ctx: Context | None, @@ -162,7 +184,8 @@ def _resolve_tool_identity( field_name: str, required: bool = False, ) -> tuple[str, str | None]: - provided = raw_name.strip() if raw_name else "" + provided_raw = raw_name.strip() if raw_name else "" + provided = _canonical_agent_name(provided_raw) if provided_raw else "" token = _extract_agent_token(ctx) inst = _authenticated_instance(ctx) if inst: @@ -279,8 +302,8 @@ def chat_send( attachments=job_attachments) if msg is None: return f"Error: job #{job_id} not found." - with _presence_lock: - _presence[sender] = time.time() + _touch_presence(sender) + _notify_status_async() # Route @mentions in job messages to trigger other agents if router and agents: @@ -346,8 +369,8 @@ def chat_send( reply_to=reply_id, channel=channel, msg_type=msg_type, metadata=metadata) _update_cursor(sender, [msg], channel) - with _presence_lock: - _presence[sender] = time.time() + _touch_presence(sender) + _notify_status_async() return f"Sent (id={msg['id']})" @@ -382,8 +405,8 @@ def chat_propose_job( metadata={"title": title, "body": body, "status": "pending"}, ) _update_cursor(sender, [msg], channel) - with _presence_lock: - _presence[sender] = time.time() + _touch_presence(sender) + _notify_status_async() return f"Proposed job (msg_id={msg['id']}): {title}" @@ -480,6 +503,7 @@ def _save_roles(): def set_role(name: str, role: str): """Set or clear an agent's role. Empty string clears.""" + name = _canonical_agent_name(name) if role: _roles[name] = role else: @@ -489,6 +513,7 @@ def set_role(name: str, role: str): def get_role(name: str) -> str: """Get an agent's current role, or empty string.""" + name = _canonical_agent_name(name) return _roles.get(name, "") @@ -499,6 +524,8 @@ def get_all_roles() -> dict[str, str]: def migrate_identity(old_name: str, new_name: str): """Migrate all runtime state when an agent is renamed (presence, cursors, activity, roles).""" + old_name = _canonical_agent_name(old_name) + new_name = _canonical_agent_name(new_name) with _presence_lock: if old_name in _presence: _presence[new_name] = _presence.pop(old_name) @@ -518,6 +545,7 @@ def migrate_identity(old_name: str, new_name: str): def purge_identity(name: str): """Remove all runtime state for a deregistered agent (presence, activity, cursors, roles).""" + name = _canonical_agent_name(name) with _presence_lock: _presence.pop(name, None) _activity.pop(name, None) @@ -618,6 +646,9 @@ def chat_read( if m.get("resolved"): entry["resolved"] = m["resolved"] out.append(entry) + if sender: + _touch_presence(sender) + _notify_status_async() return json.dumps(out, ensure_ascii=False) ch = channel if channel else None @@ -670,6 +701,9 @@ def chat_read( if inst: breadcrumb = f"[identity: {inst['name']} | label: {inst['label']}]" serialized = f"{breadcrumb}\n{serialized}" + if sender: + _touch_presence(sender) + _notify_status_async() return serialized @@ -692,6 +726,8 @@ def chat_resync( msgs = store.get_recent(limit, channel=ch) _update_cursor(sender, msgs, ch) serialized = _serialize_messages(msgs) + _touch_presence(sender) + _notify_status_async() return serialized @@ -715,6 +751,8 @@ def chat_join(name: str, channel: str = "general", ctx: Context | None = None) - return f"Error: '{name}' is not registered. Call chat_claim(sender=your_base_name) to get your identity." store.add(name, f"{name} is online", msg_type="join", channel="general") online = _get_online() + _touch_presence(name) + _notify_status_async() return f"Joined. Online: {', '.join(online)}" @@ -725,6 +763,10 @@ def chat_who() -> str: def _touch_presence(name: str): + """Update presence timestamp on any MCP tool use.""" + name = _canonical_agent_name(name) + if not name: + return """Update presence timestamp — called on any MCP tool use.""" with _presence_lock: _presence[name] = time.time() @@ -739,11 +781,16 @@ def _get_online() -> list[str]: def is_online(name: str) -> bool: now = time.time() + candidates = registry.resolve_to_instances(name) if registry else [name] + candidates = [_canonical_agent_name(n) for n in candidates] with _presence_lock: - return name in _presence and now - _presence.get(name, 0) < PRESENCE_TIMEOUT + return any(n in _presence and now - _presence.get(n, 0) < PRESENCE_TIMEOUT for n in candidates if n) def set_active(name: str, active: bool): + name = _canonical_agent_name(name) + if not name: + return with _presence_lock: _activity[name] = active if active: @@ -752,6 +799,7 @@ def set_active(name: str, active: bool): def is_active(name: str) -> bool: import time as _time + name = _canonical_agent_name(name) with _presence_lock: if not _activity.get(name, False): return False @@ -868,6 +916,7 @@ def chat_claim(sender: str, name: str = "", ctx: Context | None = None) -> str: # Touch presence with the CONFIRMED name (may differ from sender) confirmed = result.get("name", sender) _touch_presence(confirmed) + _notify_status_async() return json.dumps({"confirmed_name": confirmed, "label": result.get("label", ""), "base": result.get("base", "")}) diff --git a/registry.py b/registry.py index cebd8023..33987b68 100644 --- a/registry.py +++ b/registry.py @@ -8,6 +8,7 @@ import colorsys import json +import re import secrets import threading import time @@ -16,6 +17,15 @@ from pathlib import Path +def canonicalize_name(raw: str) -> str: + """Return the stable internal agent id for a user/agent supplied name.""" + value = (raw or "").strip().lower() + value = re.sub(r"\s+", "-", value) + value = re.sub(r"[^a-z0-9-]", "", value) + value = re.sub(r"-+", "-", value).strip("-") + return value + + @dataclass class Instance: """A live agent instance.""" @@ -50,7 +60,12 @@ def seed(self, agents_config: dict): """Load base templates from config.toml [agents.*] section.""" with self._lock: for name, cfg in agents_config.items(): - self._bases[name] = dict(cfg) + canonical = canonicalize_name(name) + if canonical: + self._bases[canonical] = dict(cfg) + changed = self._clean_renames_locked() + if changed: + self._save_renames() def on_change(self, cb): """Register a callback fired after any registry mutation.""" @@ -72,7 +87,14 @@ def _load_renames(self): p = self._renames_path() if p.exists(): try: - self._renames = json.loads(p.read_text("utf-8")) + raw = json.loads(p.read_text("utf-8")) + self._renames = {} + if isinstance(raw, dict): + for key, value in raw.items(): + src = canonicalize_name(str(key)) + dst = canonicalize_name(str(value)) + if src and dst and src != dst: + self._renames[src] = dst except Exception: self._renames = {} @@ -88,19 +110,80 @@ def _save_renames(self): except Exception: pass + def _clean_renames_locked(self) -> bool: + """Normalize persisted rename chains. Caller must hold the lock.""" + before = dict(self._renames) + + cleaned: dict[str, str] = {} + for key, value in self._renames.items(): + src = canonicalize_name(key) + dst = canonicalize_name(value) + if src and dst and src != dst: + cleaned[src] = dst + self._renames = cleaned + + # Break two-way loops caused by display-name case drift, preferring + # base-family -> custom-id mappings such as codex -> planner. + for src, dst in list(self._renames.items()): + if self._renames.get(dst) != src: + continue + if src in self._bases and dst not in self._bases: + self._renames.pop(dst, None) + elif dst in self._bases and src not in self._bases: + self._renames.pop(src, None) + elif src > dst: + self._renames.pop(src, None) + else: + self._renames.pop(dst, None) + + # Collapse acyclic chains and drop any remaining cycle. + for src in list(self._renames): + seen = {src} + current = self._renames[src] + while current in self._renames: + if current in seen: + self._renames.pop(src, None) + break + seen.add(current) + current = self._renames[current] + else: + if src in self._renames and self._renames[src] != current: + self._renames[src] = current + + return before != self._renames + # --- Registration --- - def register(self, base: str, label: str | None = None) -> dict | None: + def register( + self, + base: str, + label: str | None = None, + preferred_name: str | None = None, + replace_existing: bool = False, + ) -> dict | None: """Register a new instance of `base`. Returns slot info or None if unknown base. When a 2nd instance registers, slot 1 is renamed from 'base' to 'base-1' to prevent identity ambiguity. The rename info is returned as '_renamed_slot1'. """ + base = canonicalize_name(base) + preferred = canonicalize_name(preferred_name or "") with self._lock: if base not in self._bases: return None self._expire_reserved() + preferred_slot: int | None = None + if preferred: + preferred_base, parsed_slot = self._parse_name(preferred) + if preferred_base != base or parsed_slot < 1: + return {"error": "preferred_name_mismatch"} + if preferred in self._instances: + if not replace_existing: + return {"error": "preferred_name_in_use"} + del self._instances[preferred] + self._reserved.pop(preferred, None) + preferred_slot = parsed_slot # Find next free slot taken = {i.slot for i in self._instances.values() if i.base == base} @@ -110,9 +193,12 @@ def register(self, base: str, label: str | None = None) -> dict | None: if rb == base: reserved.add(rs) - slot = 1 - while slot in taken or slot in reserved: - slot += 1 + if preferred_slot is not None and preferred_slot not in taken: + slot = preferred_slot + else: + slot = 1 + while slot in taken or slot in reserved: + slot += 1 # When a 2nd instance registers, rename slot-1 from "base" to "base-1" # so that no instance shares a name with the base family. This prevents @@ -162,7 +248,11 @@ def deregister(self, name: str) -> dict | None: Returns result dict with 'ok' and optional '_renamed_back' info, or None if instance not found. """ + original = canonicalize_name(name) + name = canonicalize_name(self.resolve_name(name)) with self._lock: + if name not in self._instances and original in self._instances: + name = original if name not in self._instances: return None base = self._instances[name].base @@ -206,6 +296,78 @@ def claim(self, sender: str, target_name: str | None = None) -> dict | str: - sender='claude', target='claude-music': assign unclaimed instance AND rename - sender='claude-2' (exact match): confirm that specific instance """ + sender_id = canonicalize_name(sender) + target_label = target_name.strip() if isinstance(target_name, str) and target_name.strip() else None + target_id = canonicalize_name(target_label or "") if target_label else None + + with self._lock: + if sender_id not in self._instances and sender_id not in self._bases: + sender_id = self._resolve_name_locked(sender_id) + inst = None + + if sender_id in self._bases: + for candidate in self._instances.values(): + if candidate.base == sender_id and candidate.state == "pending": + inst = candidate + break + if not inst: + for candidate in self._instances.values(): + if candidate.base == sender_id: + inst = candidate + break + else: + inst = self._instances.get(sender_id) + + if not inst: + return f"No available {sender_id or sender} instance. Is a wrapper registered?" + + if target_id and target_id != inst.name: + if target_id in self._instances: + return f"Already claimed: {target_id}" + if family_err := self._conflicts_with_other_family(target_id, inst.base): + return family_err + + t_base, t_slot = self._parse_name(target_id) + if t_base == inst.base: + slot_taken = any( + i.slot == t_slot and i.name != inst.name + for i in self._instances.values() if i.base == inst.base + ) + if slot_taken: + return f"Slot {t_slot} already occupied in {inst.base} family" + + self._reserved.pop(target_id, None) + old_name = inst.name + del self._instances[old_name] + inst.name = target_id + inst.state = "active" + + base_cfg = self._bases.get(inst.base, {}) + if t_base == inst.base: + inst.slot = t_slot + inst.color = _derive_color(base_cfg.get("color", "#888"), t_slot) + inst.label = target_label or ( + base_cfg.get("label", inst.base.capitalize()) + if t_slot == 1 + else f"{base_cfg.get('label', inst.base.capitalize())} {t_slot}" + ) + else: + inst.label = target_label or target_id + + self._instances[target_id] = inst + self._renames[old_name] = target_id + result = _inst_dict(inst) + else: + if target_label: + inst.label = target_label + if inst.state != "pending" or target_name is not None: + inst.state = "active" + result = _inst_dict(inst) + + self._notify() + self._save_renames() + return result + error = None result = None with self._lock: @@ -290,6 +452,7 @@ def claim(self, sender: str, target_name: str | None = None) -> dict | str: def confirm_pending(self, name: str) -> bool: """Auto-confirm a pending instance (10s timeout path).""" + name = canonicalize_name(self.resolve_name(name)) with self._lock: inst = self._instances.get(name) if not inst or inst.state != "pending": @@ -308,12 +471,20 @@ def rename(self, old_name: str, new_name: str, label: str | None = None) -> dict Changes the sender ID, label, and tracks the rename for wrapper sync. If new_name == old_name, falls back to a label-only change. """ + old_name = canonicalize_name(self.resolve_name(old_name)) + new_name = canonicalize_name(new_name) + label = label.strip() if isinstance(label, str) and label.strip() else None with self._lock: inst = self._instances.get(old_name) if not inst: return f"Not found: {old_name}" - if new_name == old_name: + if not new_name: + if label: + inst.label = label + result = _inst_dict(inst) + + elif new_name == old_name: # Same identity — just update label if label: inst.label = label @@ -365,8 +536,12 @@ def rename(self, old_name: str, new_name: str, label: str | None = None) -> dict def set_label(self, name: str, label: str) -> bool: """Set display label only (no identity change).""" + original = canonicalize_name(name) + name = canonicalize_name(self.resolve_name(name)) with self._lock: inst = self._instances.get(name) + if not inst and original: + inst = self._instances.get(original) if not inst: return False inst.label = label @@ -378,8 +553,12 @@ def set_label(self, name: str, label: str) -> bool: # --- Queries --- def get_instance(self, name: str) -> dict | None: + original = canonicalize_name(name) + name = canonicalize_name(self.resolve_name(name)) with self._lock: inst = self._instances.get(name) + if not inst and original: + inst = self._instances.get(original) return _inst_dict(inst) if inst else None def get_all(self) -> dict[str, dict]: @@ -404,6 +583,7 @@ def get_active_names(self) -> list[str]: return [n for n, i in self._instances.items() if i.state == "active"] def get_instances_for(self, base: str) -> list[dict]: + base = canonicalize_name(base) with self._lock: return [_inst_dict(i) for i in self._instances.values() if i.base == base] @@ -412,16 +592,21 @@ def get_bases(self) -> dict[str, dict]: return dict(self._bases) def get_base_config(self, base: str) -> dict | None: + base = canonicalize_name(base) with self._lock: return dict(self._bases[base]) if base in self._bases else None def is_agent_family(self, name: str) -> bool: """Check if a name belongs to any agent family (base, slot, or custom alias).""" + original = canonicalize_name(name) + name = canonicalize_name(self.resolve_name(name)) with self._lock: # Check registered instance first (handles custom names like 'claude-music') - inst = self._instances.get(name) + inst = self._instances.get(name) or self._instances.get(original) if inst: return inst.base in self._bases + if original in self._bases: + return True # Fall back to name parsing for slot names like 'claude-2' base, _ = self._parse_name(name) if base in self._bases: @@ -432,22 +617,27 @@ def is_agent_family(self, name: str) -> bool: def family_instance_count(self, name: str) -> int: """Count registered instances in the same family as `name`.""" + original = canonicalize_name(name) + name = canonicalize_name(self.resolve_name(name)) with self._lock: # Check registered instance first (handles custom names) - inst = self._instances.get(name) + inst = self._instances.get(name) or self._instances.get(original) if inst: base = inst.base else: - base, _ = self._parse_name(name) + base, _ = self._parse_name(original if original in self._bases else name) if base not in self._bases: + resolved_base = None for family in self._bases: if name.startswith(f"{family}-"): - base = family + resolved_base = family break + base = resolved_base or base return sum(1 for i in self._instances.values() if i.base == base) def has_claimed_instances(self, base: str) -> bool: """Check if any instance in this family has been claimed (state=active).""" + base = canonicalize_name(base) with self._lock: return any( i.state == "active" and i.base == base @@ -457,6 +647,7 @@ def has_claimed_instances(self, base: str) -> bool: def get_family_instance(self, base: str) -> dict | None: """Return the instance dict for a family if exactly one exists. Used by heartbeat to find renamed instances after server restart.""" + base = canonicalize_name(base) with self._lock: matches = [i for i in self._instances.values() if i.base == base] if len(matches) == 1: @@ -471,20 +662,28 @@ def resolve_to_instances(self, name: str) -> list[str]: active instances in that family (e.g. 'claude' → ['claude-prime']). Otherwise returns [name] unchanged (for non-agent names like 'ben'). """ + original = name + original_id = canonicalize_name(name) + name = original_id with self._lock: + name = self._resolve_name_locked(name) if name in self._instances: return [name] + if original_id in self._instances: + return [original_id] # Check if it's a base name with registered family members - if name in self._bases: + base_name = original_id if original_id in self._bases else name if name in self._bases else None + if base_name is not None: members = [i.name for i in self._instances.values() - if i.base == name and i.state == "active"] + if i.base == base_name and i.state == "active"] if members: return members - return [name] + return [name or original_id or original] def resolve_name(self, name: str) -> str: """Follow rename chain to find current canonical name.""" with self._lock: + return self._resolve_name_locked(name) # Follow renames (e.g. claude-2 → claude-music) seen = set() current = name @@ -494,12 +693,18 @@ def resolve_name(self, name: str) -> str: return current def is_registered(self, name: str) -> bool: + original = canonicalize_name(name) + name = canonicalize_name(self.resolve_name(name)) with self._lock: - return name in self._instances + return name in self._instances or original in self._instances def is_pending(self, name: str) -> bool: + original = canonicalize_name(name) + name = canonicalize_name(self.resolve_name(name)) with self._lock: i = self._instances.get(name) + if not i and original: + i = self._instances.get(original) return i is not None and i.state == "pending" def resolve_token(self, token: str) -> dict | None: @@ -518,6 +723,15 @@ def get_pending(self) -> list[dict]: # --- Internal --- + def _resolve_name_locked(self, name: str) -> str: + """Follow canonical rename chains. Caller must hold the lock.""" + current = canonicalize_name(name) + seen = set() + while current in self._renames and current not in seen: + seen.add(current) + current = canonicalize_name(self._renames[current]) + return current + def _conflicts_with_other_family(self, name: str, own_base: str) -> str | None: """Check if `name` stomps on another family's namespace. @@ -546,6 +760,7 @@ def _parse_name(self, name: str) -> tuple[str, int]: def clean_renames_for(self, name: str): """Remove all rename chain entries pointing to or from `name`.""" + name = canonicalize_name(name) with self._lock: # Remove entries where name is a key (old name → ...) self._renames.pop(name, None) diff --git a/requirements-desktop.txt b/requirements-desktop.txt new file mode 100644 index 00000000..9f5183c0 --- /dev/null +++ b/requirements-desktop.txt @@ -0,0 +1,3 @@ +PySide6-Essentials>=6.6 +pyinstaller>=6.0 +Pillow>=10.0 diff --git a/router.py b/router.py index 814e2c9f..5efe7365 100644 --- a/router.py +++ b/router.py @@ -38,7 +38,7 @@ def parse_mentions(self, text: str) -> list[str]: if name in ("both", "all"): # Only tag online agents when using @all if self._online_checker: - online = self._online_checker() + online = {n.lower() for n in self._online_checker()} mentions.update(n for n in self.agent_names if n in online) else: mentions.update(self.agent_names) diff --git a/run.py b/run.py index b14bc75c..4381870d 100644 --- a/run.py +++ b/run.py @@ -2,6 +2,7 @@ import argparse import asyncio +import os import secrets import sys import threading @@ -10,10 +11,28 @@ from pathlib import Path # Ensure the project directory is on the import path -ROOT = Path(__file__).parent +if getattr(sys, "frozen", False): + ROOT = Path(sys.executable).resolve().parent +else: + ROOT = Path(__file__).parent sys.path.insert(0, str(ROOT)) +def _trace(message: str) -> None: + path = os.environ.get("AGENTCHATTR_INTERNAL_TRACE") + if not path: + return + try: + trace_path = Path(path) + if not trace_path.is_absolute(): + trace_path = ROOT / trace_path + trace_path.parent.mkdir(parents=True, exist_ok=True) + with open(trace_path, "a", encoding="utf-8") as fh: + fh.write(f"{time.strftime('%H:%M:%S')} {message}\n") + except Exception: + pass + + def _parse_args(): parser = argparse.ArgumentParser( description="Start agentchattr (web UI + MCP server).", @@ -33,6 +52,7 @@ def _parse_args(): def main(): + _trace("run.main start") logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", @@ -46,6 +66,7 @@ def main(): from config_loader import apply_cli_overrides, load_config apply_cli_overrides() + _trace("applied cli overrides") config_path = ROOT / "config.toml" if not config_path.exists(): @@ -53,13 +74,16 @@ def main(): sys.exit(1) config = load_config(ROOT) + _trace("loaded config") # --- Security: generate a random session token (in-memory only) --- session_token = secrets.token_hex(32) + launcher_token = os.environ.get("AGENTCHATTR_LAUNCHER_TOKEN", "") # Configure the FastAPI app (creates shared store) from app import app, configure, set_event_loop, store as _store_ref - configure(config, session_token=session_token) + configure(config, session_token=session_token, launcher_token=launcher_token) + _trace("configured app") # Share stores with the MCP bridge from app import store, rules, summaries, jobs, room_settings, registry, router as app_router, agents as app_agents, session_engine, session_store @@ -80,6 +104,7 @@ def main(): mcp_bridge._load_cursors() mcp_bridge._ROLES_FILE = data_dir / "roles.json" mcp_bridge._load_roles() + _trace("configured mcp bridge") # Start MCP servers in background threads http_port = config.get("mcp", {}).get("http_port", 8200) @@ -90,6 +115,7 @@ def main(): threading.Thread(target=mcp_bridge.run_http_server, daemon=True).start() threading.Thread(target=mcp_bridge.run_sse_server, daemon=True).start() time.sleep(0.5) + _trace("started mcp threads") logging.getLogger(__name__).info("MCP streamable-http on port %d, SSE on port %d", http_port, sse_port) # Mount static files @@ -111,7 +137,18 @@ async def index(): ) return HTMLResponse(injected, headers={"Cache-Control": "no-store"}) + @app.get("/launcher") + async def launcher_page(): + """Serve the launcher control panel.""" + html = (static_dir / "launcher.html").read_text("utf-8") + injected = html.replace( + "", + f'\n', + ) + return HTMLResponse(injected, headers={"Cache-Control": "no-store"}) + app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") + _trace("mounted static") # Capture the event loop for the store→WebSocket bridge @app.on_event("startup") @@ -159,6 +196,7 @@ async def on_startup(): print(f" Agents auto-trigger on @mention") print(f"\n Session token: {session_token}\n") + _trace(f"starting uvicorn {host}:{port}") uvicorn.run(app, host=host, port=port, log_level="info") diff --git a/static/agentchattr-icon.svg b/static/agentchattr-icon.svg new file mode 100644 index 00000000..c7948dc6 --- /dev/null +++ b/static/agentchattr-icon.svg @@ -0,0 +1,44 @@ + + agentchattr icon + Blue multi-agent chat network icon + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/channels.js b/static/channels.js index a74b7332..86b5f15b 100644 --- a/static/channels.js +++ b/static/channels.js @@ -63,14 +63,14 @@ function renderChannelTabs() { const editBtn = document.createElement('button'); editBtn.className = 'ch-edit-btn'; - editBtn.title = 'Rename'; + editBtn.title = t('channel.rename.title'); editBtn.innerHTML = ''; editBtn.onclick = (e) => { e.stopPropagation(); showChannelRenameDialog(name); }; actions.appendChild(editBtn); const delBtn = document.createElement('button'); delBtn.className = 'ch-delete-btn'; - delBtn.title = 'Delete'; + delBtn.title = t('channel.delete.title'); delBtn.innerHTML = ''; delBtn.onclick = (e) => { e.stopPropagation(); deleteChannel(name); }; actions.appendChild(delBtn); @@ -142,13 +142,13 @@ function renderChannelSidebar() { actions.className = 'channel-sidebar-row-actions'; const editBtn = document.createElement('button'); - editBtn.title = 'Rename'; + editBtn.title = t('channel.rename.title'); editBtn.innerHTML = ''; editBtn.onclick = (e) => { e.stopPropagation(); _showSidebarRenameDialog(name); }; actions.appendChild(editBtn); const delBtn = document.createElement('button'); - delBtn.title = 'Delete'; + delBtn.title = t('channel.delete.title'); delBtn.innerHTML = ''; delBtn.onclick = (e) => { e.stopPropagation(); _sidebarConfirmDelete(name, row, label); }; actions.appendChild(delBtn); @@ -198,7 +198,7 @@ function _showSidebarRenameDialog(oldName) { const confirm = document.createElement('button'); confirm.className = 'confirm-btn'; confirm.innerHTML = '✓'; - confirm.title = 'Rename'; + confirm.title = t('channel.rename.title'); confirm.onclick = () => { const newName = input.value.trim().toLowerCase(); if (!newName || !/^[a-z0-9][a-z0-9\-]{0,19}$/.test(newName)) return; @@ -217,7 +217,7 @@ function _showSidebarRenameDialog(oldName) { const cancel = document.createElement('button'); cancel.className = 'cancel-btn'; cancel.innerHTML = '✕'; - cancel.title = 'Cancel'; + cancel.title = t('common.cancel'); cancel.onclick = cleanup; wrapper.appendChild(cancel); @@ -245,7 +245,7 @@ function _sidebarConfirmDelete(name, row, label) { const originalOnclick = row.onclick; row.classList.add('confirm-delete'); - label.textContent = `delete #${name}?`; + label.textContent = t('channel.deleteConfirm', { name }); if (actions) actions.style.display = 'none'; const confirmBar = document.createElement('span'); @@ -253,11 +253,11 @@ function _sidebarConfirmDelete(name, row, label) { confirmBar.style.display = 'flex'; const tickBtn = document.createElement('button'); - tickBtn.title = 'Confirm delete'; + tickBtn.title = t('common.confirm'); tickBtn.innerHTML = ''; const crossBtn = document.createElement('button'); - crossBtn.title = 'Cancel'; + crossBtn.title = t('common.cancel'); crossBtn.innerHTML = ''; confirmBar.appendChild(tickBtn); @@ -360,14 +360,14 @@ function showChannelCreateDialog() { const confirm = document.createElement('button'); confirm.className = 'confirm-btn'; confirm.innerHTML = '✓'; - confirm.title = 'Create'; + confirm.title = t('common.create'); confirm.onclick = () => { _submitInlineCreate(input, wrapper); if (addBtn) addBtn.style.display = ''; }; wrapper.appendChild(confirm); const cancel = document.createElement('button'); cancel.className = 'cancel-btn'; cancel.innerHTML = '✕'; - cancel.title = 'Cancel'; + cancel.title = t('common.cancel'); cancel.onclick = cleanup; wrapper.appendChild(cancel); @@ -425,7 +425,7 @@ function showChannelRenameDialog(oldName) { const confirm = document.createElement('button'); confirm.className = 'confirm-btn'; confirm.innerHTML = '✓'; - confirm.title = 'Rename'; + confirm.title = t('channel.rename.title'); confirm.onclick = () => { const newName = input.value.trim().toLowerCase(); if (!newName || !/^[a-z0-9][a-z0-9\-]{0,19}$/.test(newName)) return; @@ -444,7 +444,7 @@ function showChannelRenameDialog(oldName) { const cancel = document.createElement('button'); cancel.className = 'cancel-btn'; cancel.innerHTML = '✕'; - cancel.title = 'Cancel'; + cancel.title = t('common.cancel'); cancel.onclick = cleanup; wrapper.appendChild(cancel); @@ -482,7 +482,7 @@ function deleteChannel(name) { tab.classList.add('confirm-delete'); tab.classList.remove('editing'); - label.textContent = `delete #${name}?`; + label.textContent = t('channel.deleteConfirm', { name }); if (actions) actions.style.display = 'none'; const confirmBar = document.createElement('span'); @@ -490,12 +490,12 @@ function deleteChannel(name) { const tickBtn = document.createElement('button'); tickBtn.className = 'ch-confirm-yes'; - tickBtn.title = 'Confirm delete'; + tickBtn.title = t('common.confirm'); tickBtn.innerHTML = ''; const crossBtn = document.createElement('button'); crossBtn.className = 'ch-confirm-no'; - crossBtn.title = 'Cancel'; + crossBtn.title = t('common.cancel'); crossBtn.innerHTML = ''; confirmBar.appendChild(tickBtn); @@ -577,12 +577,12 @@ function _updateSupportLabel() { if (!label) return; const inSidebar = document.body.classList.contains('channels-in-sidebar'); if (!inSidebar) { - label.textContent = ' Support development'; + label.textContent = t('header.support'); return; } const panel = document.getElementById('channel-sidebar'); const w = panel ? panel.offsetWidth : 200; - label.textContent = w < 200 ? ' Support' : ' Support development'; + label.textContent = w < 200 ? t('header.support.short') : t('header.support'); } function setupChannelSidebarGrip() { diff --git a/static/chat.js b/static/chat.js index 94c5c373..45dd4459 100644 --- a/static/chat.js +++ b/static/chat.js @@ -26,6 +26,20 @@ let agentHats = {}; // { agent_name: svg_string } window.customRoles = []; // saved custom roles from settings let colorOverrides = JSON.parse(localStorage.getItem('agentchattr-color-overrides') || '{}'); let schedulesList = []; // array of schedule objects from server +let _initialHistoryLoaded = false; // false until initial history burst finishes +let _historyLoadTimer = null; // timer to flip _initialHistoryLoaded + +// Progressive reveal config for agent message streaming effect +const STREAMING_CONFIG = { + INITIAL_DELAY: 12, // ms per char for first part + ACCELERATION_THRESHOLD: 500, // chars after which to accelerate + FAST_DELAY: 2, // ms per char after acceleration + BATCH_SIZE: 1, // chars per tick normally + FAST_BATCH_SIZE: 8, // chars per tick after acceleration +}; + +// Active typing agents (supports multiple simultaneous) +let typingAgents = new Set(); // Expose globals that extracted modules (sessions.js, jobs.js) read via window.* // Using defineProperty so live values are always returned. @@ -66,14 +80,14 @@ function enableDragScroll(el) { // --- Notification sounds --- const SOUND_OPTIONS = [ - { value: 'soft-chime', label: 'Soft Chime' }, - { value: 'bright-ping', label: 'Bright Ping' }, - { value: 'gentle-pop', label: 'Gentle Pop' }, - { value: 'alert-tone', label: 'Alert Tone' }, - { value: 'pluck', label: 'Pluck' }, - { value: 'click', label: 'Click' }, - { value: 'warm-bell', label: 'Warm Bell' }, - { value: 'none', label: 'None' }, + { value: 'soft-chime', labelKey: 'sound.softChime' }, + { value: 'bright-ping', labelKey: 'sound.brightPing' }, + { value: 'gentle-pop', labelKey: 'sound.gentlePop' }, + { value: 'alert-tone', labelKey: 'sound.alertTone' }, + { value: 'pluck', labelKey: 'sound.pluck' }, + { value: 'click', labelKey: 'sound.click' }, + { value: 'warm-bell', labelKey: 'sound.warmBell' }, + { value: 'none', labelKey: 'sound.none' }, ]; const DEFAULT_SOUND = 'soft-chime'; const CROSS_CHANNEL_SOUND = 'pluck'; @@ -116,8 +130,8 @@ function buildSoundSettings() { row.className = 'sound-row'; const label = document.createElement('span'); label.className = 'sound-label'; - label.textContent = name === 'default' ? 'Default sound' - : name === 'cross-channel' ? 'Background alerts' + label.textContent = name === 'default' ? t('sound.default') + : name === 'cross-channel' ? t('sound.background') : (agentConfig[name]?.label || name); const select = document.createElement('select'); select.className = 'sound-select'; @@ -127,7 +141,7 @@ function buildSoundSettings() { for (const opt of SOUND_OPTIONS) { const o = document.createElement('option'); o.value = opt.value; - o.textContent = opt.label; + o.textContent = t(opt.labelKey); if (currentVal === opt.value) o.selected = true; select.appendChild(o); } @@ -135,7 +149,7 @@ function buildSoundSettings() { if (name !== 'default' && name !== 'cross-channel') { const o = document.createElement('option'); o.value = ''; - o.textContent = 'Use default'; + o.textContent = t('sound.useDefault'); if (!soundPrefs[name]) o.selected = true; select.insertBefore(o, select.firstChild); } @@ -207,9 +221,9 @@ async function checkForUpdate() { return; } - const label = data.state === 'upstream_update' ? 'Upstream update available' : 'Update available'; + const label = data.state === 'upstream_update' ? t('update.upstream') : t('update.available'); pill.href = data.url || 'https://github.com/bcurts/agentchattr/releases'; - pill.innerHTML = `${label}`; + pill.innerHTML = `${label}`; pill.classList.remove('hidden'); } catch { // Silent fail -- version check should never block the UI @@ -293,6 +307,31 @@ function renderMarkdown(text) { return html; } +function localizeSystemMessage(text) { + const value = String(text || ''); + let match = value.match(/^Agent routing for (.+?) interrupted\s+.*auto-recovered\.\s+If agents aren't responding, try sending your message again\.$/); + if (match) return t('system.agentRoutingRecovered', { agent: match[1] }); + + match = value.match(/^Routing resumed by (.+)\.$/); + if (match) return t('system.routingResumed', { sender: match[1] }); + + if (value === 'Resuming agent conversation...') { + return t('system.resumingConversation'); + } + + match = value.match(/^(.+?) appears offline\s+.*message queued\.$/); + if (match) return t('system.offlineQueued', { agent: match[1] }); + + match = value.match(/^Loop guard: only humans can \/continue\. (.+?) tried to self-resume\.$/); + if (match) return t('system.loopGuardContinueHumanOnly', { sender: match[1] }); + + match = value.match(/^Loop guard: (\d+) agent-to-agent hops reached\. Type \/continue to resume\.$/); + if (match) return t('system.loopGuardHopsReached', { count: match[1] }); + + return value; +} +window.localizeSystemMessage = localizeSystemMessage; + function linkifyUrls(html) { // Match http/https URLs not already inside an tag. // We match tags first to skip them, then capture URLs in the same pass. @@ -347,17 +386,17 @@ function addCodeCopyButtons(container) { if (pre.querySelector('.code-copy-btn')) continue; const btn = document.createElement('button'); btn.className = 'code-copy-btn'; - btn.textContent = 'copy'; + btn.textContent = t('timeline.copyButton'); btn.onclick = async (e) => { e.stopPropagation(); const code = pre.querySelector('code')?.textContent || pre.textContent; try { await navigator.clipboard.writeText(code); - btn.textContent = 'copied!'; - setTimeout(() => { btn.textContent = 'copy'; }, 1500); + btn.textContent = t('timeline.copied'); + setTimeout(() => { btn.textContent = t('timeline.copyButton'); }, 1500); } catch (err) { - btn.textContent = 'failed'; - setTimeout(() => { btn.textContent = 'copy'; }, 1500); + btn.textContent = t('timeline.failed'); + setTimeout(() => { btn.textContent = t('timeline.copyButton'); }, 1500); } }; pre.style.position = 'relative'; @@ -377,6 +416,14 @@ function connectWebSocket() { clearTimeout(reconnectTimer); reconnectTimer = null; } + // Reset history-load gate: messages arriving in the first ~1s are historical + _initialHistoryLoaded = false; + if (_historyLoadTimer) clearTimeout(_historyLoadTimer); + _historyLoadTimer = setTimeout(() => { _initialHistoryLoaded = true; }, 900); + // Clear stale typing state on reconnect + typingAgents.clear(); + const indicator = document.getElementById('typing-indicator'); + if (indicator) indicator.classList.add('hidden'); }; ws.onmessage = (e) => { @@ -393,7 +440,7 @@ function connectWebSocket() { const choicesEl = existing.querySelector('.decision-choices'); const meta = updated.metadata || {}; if (choicesEl && meta.resolved) { - choicesEl.innerHTML = `
You chose: ${escapeHtml(meta.chosen || '')}
`; + choicesEl.innerHTML = `
${escapeHtml(t('timeline.youChose', { choice: meta.chosen || '' }))}
`; } } } @@ -417,9 +464,12 @@ function connectWebSocket() { document.querySelectorAll('#messages .message').forEach(el => { // Regular chat messages const senderEl = el.querySelector('.msg-sender'); - if (senderEl && senderEl.textContent === event.old_name) { + const senderId = senderEl?.dataset.sender || el.dataset.sender || senderEl?.textContent || ''; + if (senderEl && senderId === event.old_name) { - senderEl.textContent = event.new_name; + el.dataset.sender = event.new_name; + senderEl.dataset.sender = event.new_name; + senderEl.textContent = getDisplayName(event.new_name); senderEl.style.color = newColor; // Update bubble accent color const bubble = el.querySelector('.chat-bubble'); @@ -450,9 +500,11 @@ function connectWebSocket() { } // Join/leave messages (separate structure, no .msg-sender) const joinText = el.querySelector('.join-text strong'); - if (joinText && joinText.textContent === event.old_name) { + const joinId = el.dataset.sender || joinText?.textContent || ''; + if (joinText && joinId === event.old_name) { - joinText.textContent = event.new_name; + el.dataset.sender = event.new_name; + joinText.textContent = getDisplayName(event.new_name); joinText.style.color = newColor; const joinDot = el.querySelector('.join-dot'); if (joinDot) joinDot.style.background = newColor; @@ -633,8 +685,8 @@ function formatDateDivider(dateStr) { const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1); - if (date.toDateString() === today.toDateString()) return 'Today'; - if (date.toDateString() === yesterday.toDateString()) return 'Yesterday'; + if (date.toDateString() === today.toDateString()) return t('timeline.today'); + if (date.toDateString() === yesterday.toDateString()) return t('timeline.yesterday'); return date.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' @@ -661,6 +713,81 @@ function maybeInsertDateDivider(container, msg) { // --- Messages --- +/** + * Animate progressive reveal of an agent message. + * During animation the text is shown as plain text; after completion + * it is replaced with full rendered markdown + post-processing. + */ +function animateMessageReveal(msgEl, rawText, msg) { + const textEl = msgEl.querySelector('.msg-text'); + if (!textEl) return; + + let idx = 0; + const len = rawText.length; + let timer = null; + + function tick() { + if (!msgEl.parentNode) return; // message was deleted, stop + const remaining = len - idx; + if (remaining <= 0) { + finish(); + return; + } + + const fast = idx >= STREAMING_CONFIG.ACCELERATION_THRESHOLD; + const batch = fast ? STREAMING_CONFIG.FAST_BATCH_SIZE : STREAMING_CONFIG.BATCH_SIZE; + const delay = fast ? STREAMING_CONFIG.FAST_DELAY : STREAMING_CONFIG.INITIAL_DELAY; + + const end = Math.min(idx + batch, len); + idx = end; + + // Show plain text during animation (markdown syntax visible raw is OK) + textEl.textContent = rawText.slice(0, idx); + + // Keep scrolling if user hasn't manually scrolled up + if (autoScroll && msg.channel === activeChannel) { + scrollToBottom(); + } + + timer = setTimeout(tick, delay); + } + + function finish() { + if (timer) clearTimeout(timer); + if (!msgEl.parentNode) return; // message was deleted, stop + // Final render: full markdown + code copy buttons + hashtag styling + textEl.innerHTML = styleHashtags(renderMarkdown(rawText)); + addCodeCopyButtons(msgEl); + // Ensure scroll is at bottom after final render + if (autoScroll && msg.channel === activeChannel) { + scrollToBottom(); + } + } + + // Start with empty text + textEl.textContent = ''; + tick(); + + // Store finish handler so external events (channel switch etc.) can fast-forward + msgEl._finishStream = finish; +} + +/** + * Check whether a message should get the progressive-reveal animation. + */ +function shouldAnimateMessage(msg) { + if (!_initialHistoryLoaded) return false; // skip historical load + if (msg.channel !== activeChannel) return false; // skip background channels + const senderClass = getSenderClass(msg.sender); + if (senderClass !== 'agent') return false; // only agent messages + if (msg.sender.toLowerCase() === username.toLowerCase()) return false; // not self + // Only chat and decision types + if (msg.type !== 'chat' && msg.type !== 'decision') return false; + // Skip empty or very short messages (< 10 chars feel instant already) + if (!msg.text || msg.text.length < 10) return false; + return true; +} + function appendMessage(msg) { const container = document.getElementById('messages'); @@ -670,17 +797,18 @@ function appendMessage(msg) { const el = document.createElement('div'); el.className = 'message'; el.dataset.id = msg.id; + el.dataset.sender = msg.sender || ''; const msgChannel = msg.channel || 'general'; el.dataset.channel = msgChannel; if (msg.type === 'join' || msg.type === 'leave') { el.classList.add('join-msg'); const color = getColor(msg.sender); - el.innerHTML = `${escapeHtml(msg.sender)} ${msg.type === 'join' ? 'joined' : 'left'}`; + el.innerHTML = `${escapeHtml(getDisplayName(msg.sender))} ${msg.type === 'join' ? t('timeline.joined') : t('timeline.left')}`; } else if (msg.type === 'summary') { el.classList.add('summary-msg'); const color = getColor(msg.sender); - el.innerHTML = `
Summary${escapeHtml(msg.sender)}
${escapeHtml(msg.text)}
`; + el.innerHTML = `
${t('timeline.summary')}${escapeHtml(getDisplayName(msg.sender))}
${escapeHtml(msg.text)}
`; } else if (msg.type === 'job_proposal') { el.classList.add('proposal-msg'); const meta = msg.metadata || {}; @@ -695,22 +823,22 @@ function appendMessage(msg) { el.innerHTML = `
- Job Proposal - ${escapeHtml(msg.sender)} + ${t('jobs.proposal')} + ${escapeHtml(getDisplayName(msg.sender))}
${title}
${body ? `
${body}
` : ''} ${isPending ? `
- - - + + +
` : ` -
${status === 'accepted' ? 'Accepted' : 'Dismissed'}
+
${status === 'accepted' ? t('jobs.accepted') : t('jobs.dismissed')}
`}
- ${!isPending ? `
` : ''}`; + ${!isPending ? `
` : ''}`; } else if (msg.type === 'rule_proposal') { el.classList.add('proposal-msg'); const meta = msg.metadata || {}; @@ -721,26 +849,26 @@ function appendMessage(msg) { el.innerHTML = `
- Rule Proposal - ${escapeHtml(msg.sender)} + ${t('rules.proposal')} + ${escapeHtml(getDisplayName(msg.sender))}
${ruleText}
${isPending ? `
- - - + + +
` : ` -
${status === 'activated' ? 'Activated' : status === 'drafted' ? 'Added to drafts' : 'Dismissed'}
+
${status === 'activated' ? t('rules.activated') : status === 'drafted' ? t('rules.addedDrafts') : t('jobs.dismissed')}
`}
- ${!isPending ? `
` : ''}`; + ${!isPending ? `
` : ''}`; } else if (window._messageRenderers && window._messageRenderers[msg.type]) { window._messageRenderers[msg.type](el, msg); } else if (msg.type === 'system' || msg.sender === 'system') { el.classList.add('system-msg'); - el.innerHTML = `${escapeHtml(msg.text)}`; + el.innerHTML = `${escapeHtml(localizeSystemMessage(msg.text))}`; } else { const isError = msg.text.startsWith('[') && msg.text.includes('error'); if (isError) el.classList.add('error-msg'); @@ -757,7 +885,8 @@ function appendMessage(msg) { } } - let textHtml = styleHashtags(renderMarkdown(msg.text)); + const animate = shouldAnimateMessage(msg); + let textHtml = animate ? '' : styleHashtags(renderMarkdown(msg.text)); const senderColor = getColor(msg.sender); const isSelf = msg.sender.toLowerCase() === username.toLowerCase(); @@ -796,26 +925,26 @@ function appendMessage(msg) { el.dataset.rawText = msg.text; const senderRole = _agentRoles[msg.sender] || ''; const roleClass = senderRole ? 'bubble-role has-role' : 'bubble-role'; - const rolePillHtml = !isSelf ? `` : ''; + const rolePillHtml = !isSelf ? `` : ''; // Inline decision choices (if present) let choicesHtml = ''; const meta = msg.metadata || {}; const choicesList = meta.choices || []; if (msg.type === 'decision' && choicesList.length > 0) { if (meta.resolved) { - choicesHtml = `
You chose: ${escapeHtml(meta.chosen || '')}
`; + choicesHtml = `
${escapeHtml(t('timeline.youChose', { choice: meta.chosen || '' }))}
`; } else { choicesHtml = '
' + choicesList.map(c => `` ).join('') + '
'; } } - el.innerHTML = `
${isSelf ? '' : avatarHtml}
${replyHtml}
${escapeHtml(msg.sender)}${rolePillHtml}${msg.time || ''}
${textHtml}
${choicesHtml}${attachmentsHtml}
`; + el.innerHTML = `
${isSelf ? '' : avatarHtml}
${replyHtml}
${escapeHtml(getDisplayName(msg.sender))}${rolePillHtml}${msg.time || ''}
${textHtml}
${choicesHtml}${attachmentsHtml}
`; if (todoStatus) el.classList.add('msg-todo', `msg-todo-${todoStatus}`); if (msg.metadata?.session_output) el.classList.add('session-output'); - // Add copy buttons to code blocks - addCodeCopyButtons(el); + // Add copy buttons to code blocks (skip during animation; finish handler will add them) + if (!animate) addCodeCopyButtons(el); } // Hide messages from other channels @@ -834,6 +963,13 @@ function appendMessage(msg) { container.appendChild(el); + // Start progressive reveal for new agent messages + if (!el.classList.contains('join-msg') && !el.classList.contains('summary-msg') && + !el.classList.contains('proposal-msg') && !el.classList.contains('system-msg') && + shouldAnimateMessage(msg)) { + animateMessageReveal(el, msg.text, msg); + } + // Collapse consecutive job_created messages into a group if (msg.type === 'job_created' && window._collapseJobBreadcrumbs) { window._collapseJobBreadcrumbs(container, el); @@ -869,6 +1005,15 @@ function resolveAgent(name) { return null; } +function getDisplayName(sender) { + const s = (sender || '').toLowerCase(); + const resolved = resolveAgent(s); + if (resolved && agentConfig[resolved]) { + return agentConfig[resolved].label || resolved; + } + return sender || ''; +} + function getColor(sender) { const s = sender.toLowerCase(); if (s === 'system') return 'var(--system-color)'; @@ -961,9 +1106,10 @@ function recolorMessages() { for (const el of msgs) { const sender = el.querySelector('.msg-sender'); if (!sender) continue; - const name = sender.textContent.trim(); + const name = sender.dataset.sender || el.dataset.sender || sender.textContent.trim(); const color = getColor(name); sender.style.color = color; + sender.textContent = getDisplayName(name); // Update bubble color const bubble = el.querySelector('.chat-bubble'); if (bubble) bubble.style.setProperty('--bubble-color', color); @@ -1173,6 +1319,22 @@ const ROLE_PRESETS = [ { label: 'Hype', emoji: '🎉' }, ]; +function roleDisplayLabel(label) { + const keyByLabel = { + 'Planner': 'role.planner', + 'Designer': 'role.designer', + 'Architect': 'role.architect', + 'Builder': 'role.builder', + 'Reviewer': 'role.reviewer', + 'Researcher': 'role.researcher', + 'Red Team': 'role.redTeam', + 'Wry': 'role.wry', + 'Unhinged': 'role.unhinged', + 'Hype': 'role.hype', + }; + return keyByLabel[label] ? t(keyByLabel[label]) : label; +} + // --- Agent naming lightbox --- const _pendingNameQueue = []; @@ -1208,8 +1370,8 @@ function showAgentNameModal(opts) {

- - + +
`; // Close on backdrop click @@ -1236,11 +1398,11 @@ function showAgentNameModal(opts) { if (opts.mode === 'pending') { const familyLabel = (baseColors[opts.base] || {}).label || opts.base || 'agent'; - titleEl.textContent = 'Name this agent'; - subtitleEl.textContent = `A new ${familyLabel} instance connected`; + titleEl.textContent = t('agent.nameThis'); + subtitleEl.textContent = t('agent.newInstance', { family: familyLabel }); } else { - titleEl.textContent = 'Rename agent'; - subtitleEl.textContent = `Current ID: @${opts.name}`; + titleEl.textContent = t('agent.rename'); + subtitleEl.textContent = t('agent.currentId', { name: opts.name }); } inputEl.value = opts.label; @@ -1299,7 +1461,7 @@ function showPillPopover(pillEl, opts) { const currentRole = (_agentRoles[opts.name] || '').toLowerCase(); const roleChipsHtml = ROLE_PRESETS.map(p => - `` + `` ).join(''); const customChipsHtml = (window.customRoles || []) .filter(r => r && !ROLE_PRESETS.some(p => p.label.toLowerCase() === r.toLowerCase())) @@ -1309,21 +1471,22 @@ function showPillPopover(pillEl, opts) { popover.innerHTML = `
- +
- +
+
${t('agent.mentionId', { name: opts.name })}
- +
- + ${roleChipsHtml} ${customChipsHtml}
- +
${(() => { @@ -1339,15 +1502,15 @@ function showPillPopover(pillEl, opts) { const matchesSwatch = swatches.some(c => current === c.toLowerCase()); const colorInputVal = (current && !current.startsWith('var(')) ? current : (opts.color || '#888888'); return `
- +
${swatches.map(c => `` ).join('')}
- - + +
`; })()} @@ -1531,14 +1694,14 @@ function showBubbleRolePicker(btn, agentName) { // None chip const noneChip = document.createElement('button'); noneChip.className = 'role-preset-chip' + (!currentRole ? ' active' : ''); - noneChip.textContent = 'None'; + noneChip.textContent = t('common.none'); noneChip.addEventListener('click', () => { _setRole(agentName, ''); closePicker(); }); picker.appendChild(noneChip); for (const preset of ROLE_PRESETS) { const chip = document.createElement('button'); chip.className = 'role-preset-chip' + (currentRole === preset.label.toLowerCase() ? ' active' : ''); - chip.textContent = `${preset.emoji} ${preset.label}`; + chip.textContent = `${preset.emoji} ${roleDisplayLabel(preset.label)}`; chip.addEventListener('click', () => { _setRole(agentName, preset.label); closePicker(); }); picker.appendChild(chip); } @@ -1559,7 +1722,7 @@ function showBubbleRolePicker(btn, agentName) { const customInput = document.createElement('input'); customInput.type = 'text'; customInput.className = 'bubble-role-input'; - customInput.placeholder = 'Custom...'; + customInput.placeholder = t('agent.custom.placeholder'); customInput.maxLength = 20; customInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { @@ -1619,7 +1782,8 @@ function _syncBubbleRolePills(agentName) { document.querySelectorAll('.message').forEach(msg => { const senderEl = msg.querySelector('.msg-sender'); const btn = msg.querySelector('.bubble-role'); - if (!btn || !senderEl || senderEl.textContent !== agentName) return; + const senderId = senderEl?.dataset.sender || msg.dataset.sender || senderEl?.textContent; + if (!btn || !senderEl || senderId !== agentName) return; btn.textContent = pillText; btn.title = role || 'Set role'; btn.classList.toggle('has-role', !!role); @@ -1717,12 +1881,22 @@ function updateStatus(data) { function updateTyping(agent, active) { const indicator = document.getElementById('typing-indicator'); if (active) { - indicator.querySelector('.typing-name').textContent = agent; - indicator.classList.remove('hidden'); - if (autoScroll) scrollToBottom(); + typingAgents.add(agent); } else { + typingAgents.delete(agent); + } + + if (typingAgents.size === 0) { indicator.classList.add('hidden'); + return; } + + // Build display names list + const names = Array.from(typingAgents).map(a => getDisplayName(a)); + const nameText = names.join(', '); + indicator.querySelector('.typing-name').textContent = nameText; + indicator.classList.remove('hidden'); + if (autoScroll) scrollToBottom(); } // --- Settings --- @@ -1793,7 +1967,7 @@ function _clearClearChatConfirm() { const confirmEl = document.getElementById('clear-chat-confirm'); if (confirmEl) confirmEl.remove(); if (btn) { - btn.textContent = 'Clear Chat'; + btn.textContent = t('timeline.clearChat'); btn.classList.remove('confirming'); } document.removeEventListener('click', _clearChatOutsideClick, true); @@ -1823,17 +1997,17 @@ function clearChat() { return; } - btn.textContent = 'Clear Chat?'; + btn.textContent = t('timeline.clearChatConfirm'); btn.classList.add('confirming'); const confirmWrap = document.createElement('span'); confirmWrap.id = 'clear-chat-confirm'; confirmWrap.className = 'session-inline-confirm'; confirmWrap.innerHTML = ` - - `; @@ -1936,7 +2110,7 @@ async function exportHistory() { }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); - showToast(err.error || 'Export failed', 'error'); + showToast(err.error || t('settings.exportFailed'), 'error'); return; } const blob = await resp.blob(); @@ -1950,9 +2124,9 @@ async function exportHistory() { URL.revokeObjectURL(a.href); const counts = []; // Parse counts from manifest if possible, or just show success - showToast('History exported', 'success'); + showToast(t('settings.exportSuccess'), 'success'); } catch (e) { - showToast('Export failed: ' + e.message, 'error'); + showToast(t('settings.exportFailed') + ': ' + e.message, 'error'); } } @@ -1962,7 +2136,7 @@ async function importHistory(input) { input.value = ''; // Reset so same file can be picked again const btn = document.getElementById('import-history-btn'); const origText = btn.textContent; - btn.textContent = 'Importing...'; + btn.textContent = t('settings.importing'); btn.disabled = true; try { const formData = new FormData(); @@ -1974,25 +2148,25 @@ async function importHistory(input) { }); const data = await resp.json(); if (!resp.ok || !data.ok) { - showToast(data.error || 'Import failed', 'error'); + showToast(data.error || t('settings.importFailed'), 'error'); return; } // Build result message const parts = []; const s = data.sections || {}; - if (s.messages) parts.push(`${s.messages.created} messages`); + if (s.messages) parts.push(`${s.messages.created} ${t('settings.messages')}`); if (s.jobs) parts.push(`${s.jobs.created} jobs`); if (s.rules) parts.push(`${s.rules.created} rules`); if (s.summaries) parts.push(`${s.summaries.created + (s.summaries.updated || 0)} summaries`); const dupes = (s.messages?.duplicates || 0) + (s.jobs?.duplicates || 0) + (s.rules?.duplicates || 0); - let msg = 'Imported ' + parts.join(', '); - if (dupes > 0) msg += ` (${dupes} duplicates skipped)`; + let msg = t('settings.imported', { items: parts.join(', ') }); + if (dupes > 0) msg += ` (${t('settings.duplicateSkipped', { count: dupes })})`; if (data.warnings && data.warnings.length > 0) { - msg += `. ${data.warnings.length} warning(s)`; + msg += `. ${t('settings.warnings', { count: data.warnings.length })}`; } showToast(msg, 'success'); } catch (e) { - showToast('Import failed: ' + e.message, 'error'); + showToast(t('settings.importFailed') + ': ' + e.message, 'error'); } finally { btn.textContent = origText; btn.disabled = false; @@ -2049,6 +2223,22 @@ const SLASH_COMMANDS = [ { cmd: '/clear', desc: 'Clear messages in current channel', broadcast: false }, ]; +function slashDescription(item) { + const keyByCmd = { + '/artchallenge': 'slash.artchallenge', + '/hatmaking': 'slash.hatmaking', + '/roastreview': 'slash.roastreview', + '/poetry haiku': 'slash.haiku', + '/poetry limerick': 'slash.limerick', + '/poetry sonnet': 'slash.sonnet', + '/summary': 'slash.summary', + '/summarise': 'slash.summarise', + '/continue': 'slash.continue', + '/clear': 'slash.clear', + }; + return keyByCmd[item.cmd] ? t(keyByCmd[item.cmd]) : item.desc; +} + let slashMenuIndex = 0; let slashMenuVisible = false; let mentionMenuIndex = 0; @@ -2078,7 +2268,7 @@ function updateSlashMenu(text) { matches.forEach((item, i) => { const row = document.createElement('div'); row.className = 'slash-item' + (i === slashMenuIndex ? ' active' : ''); - row.innerHTML = `${escapeHtml(item.cmd)}${escapeHtml(item.desc)}`; + row.innerHTML = `${escapeHtml(item.cmd)}${escapeHtml(slashDescription(item))}`; row.addEventListener('mousedown', (e) => { e.preventDefault(); selectSlashCommand(item.cmd); @@ -2545,12 +2735,13 @@ function startReply(msgId, event) { const el = document.querySelector(`.message[data-id="${msgId}"]`); if (!el) return; const sender = el.querySelector('.msg-sender')?.textContent?.trim() || '?'; + const senderId = el.querySelector('.msg-sender')?.dataset.sender || el.dataset.sender || sender; const text = el.dataset.rawText || el.querySelector('.msg-text')?.textContent || ''; replyingTo = { id: msgId, sender, text }; renderReplyPreview(); // Auto-activate mention chip for the replied-to sender, deactivate others - const resolved = resolveAgent(sender.toLowerCase()); + const resolved = resolveAgent(senderId.toLowerCase()); if (resolved) { for (const btn of document.querySelectorAll('.mention-toggle')) { const agent = btn.dataset.agent; @@ -2581,7 +2772,7 @@ function renderReplyPreview() { } const truncated = replyingTo.text.length > 100 ? replyingTo.text.slice(0, 100) + '...' : replyingTo.text; const color = getColor(replyingTo.sender); - container.innerHTML = `replying to ${escapeHtml(replyingTo.sender)}: ${escapeHtml(truncated)} `; + container.innerHTML = `${t('timeline.replyingTo')} ${escapeHtml(replyingTo.sender)}: ${escapeHtml(truncated)} `; } function cancelReply() { @@ -2601,9 +2792,9 @@ function scrollToMessage(msgId) { // --- Todos --- function todoStatusLabel(status) { - if (!status) return 'pin'; - if (status === 'todo') return 'done?'; - return 'unpin'; + if (!status) return t('pins.pin'); + if (status === 'todo') return t('pins.done?'); + return t('pins.unpin'); } function todoCycle(msgId) { @@ -2755,7 +2946,7 @@ function showDeleteBar() { if (!bar) { bar = document.createElement('div'); bar.id = 'delete-bar'; - bar.innerHTML = ``; + bar.innerHTML = ``; const footer = document.querySelector('footer'); footer.parentNode.insertBefore(bar, footer); } @@ -2765,10 +2956,10 @@ function showDeleteBar() { function updateDeleteBar() { const count = deleteSelected.size; const span = document.querySelector('.delete-bar-count'); - if (span) span.textContent = count > 0 ? `${count} selected` : 'Select messages'; + if (span) span.textContent = count > 0 ? t('timeline.selected', { count }) : t('timeline.selectMessages'); const btn = document.querySelector('.delete-bar-confirm'); if (btn) { - btn.textContent = count > 0 ? `Delete (${count})` : 'Delete'; + btn.textContent = count > 0 ? t('timeline.deleteCount', { count }) : t('common.delete'); btn.disabled = count === 0; } } @@ -2868,7 +3059,7 @@ function renderTodosPanel() { const todoIds = Object.keys(todos); if (todoIds.length === 0) { - list.innerHTML = '
No pinned messages
'; + list.innerHTML = `
${t('pins.empty')}
`; return; } @@ -2892,7 +3083,7 @@ function renderTodosPanel() { const checkClass = status === 'done' ? 'todo-check done' : 'todo-check'; const msgChannel = el.dataset.channel || 'general'; - item.innerHTML = `#${msgChannel} ${escapeHtml(time)} ${escapeHtml(sender)} ${escapeHtml(text)}`; + item.innerHTML = `#${msgChannel} ${escapeHtml(time)} ${escapeHtml(sender)} ${escapeHtml(text)}`; item.addEventListener('click', (e) => { if (e.target.closest('button')) return; // Cross-channel pin: switch channel if needed @@ -3239,22 +3430,22 @@ function renderSchedulesBar() { const s = schedulesList[0]; const isPaused = s.active === false; const targetStr = (s.targets || []).map(t => '@' + t).join(', '); - countEl.textContent = `${targetStr} "${s.prompt}"` + (isPaused ? ' (paused)' : ''); + countEl.textContent = `${targetStr} "${s.prompt}"` + (isPaused ? t('schedule.pausedSuffix') : ''); const nextStr = (!isPaused && s.next_run) ? formatScheduleTime(s.next_run) : ''; nextEl.textContent = nextStr - ? formatScheduleInterval(s) + ' -- next in ' + nextStr + ? formatScheduleInterval(s) + ' -- ' + t('schedule.nextIn', { time: nextStr }) : formatScheduleInterval(s); } else if (active.length > 0) { const paused = schedulesList.length - active.length; - const parts = [`${active.length} active`]; - if (paused > 0) parts.push(`${paused} paused`); + const parts = [t('schedule.activeCount', { count: active.length })]; + if (paused > 0) parts.push(t('schedule.pausedCount', { count: paused })); countEl.textContent = parts.join(', '); const futureRuns = active.filter(s => s.next_run && s.next_run * 1000 > Date.now()).map(s => s.next_run); const nextTimeStr = futureRuns.length > 0 ? formatScheduleTime(Math.min(...futureRuns)) : ''; - nextEl.textContent = nextTimeStr ? 'next in ' + nextTimeStr : ''; + nextEl.textContent = nextTimeStr ? t('schedule.nextIn', { time: nextTimeStr }) : ''; } else { const paused = schedulesList.length; - countEl.textContent = `${paused} paused schedule${paused !== 1 ? 's' : ''}`; + countEl.textContent = t('schedule.paused', { count: paused, plural: paused !== 1 ? 's' : '' }); nextEl.textContent = ''; } @@ -3269,13 +3460,13 @@ function renderSchedulesBar() { pauseBtn.innerHTML = isPaused ? '' : ''; - pauseBtn.title = isPaused ? 'Resume' : 'Pause'; + pauseBtn.title = isPaused ? t('schedule.resume') : t('schedule.pause'); pauseBtn.onclick = () => toggleSchedule(s.id); const trashBtn = document.createElement('button'); trashBtn.className = 'schedule-delete-inline'; trashBtn.innerHTML = ''; - trashBtn.title = 'Delete'; + trashBtn.title = t('common.delete'); trashBtn.onclick = () => deleteSchedule(s.id); summaryDiv.append(pauseBtn, trashBtn); @@ -3283,7 +3474,7 @@ function renderSchedulesBar() { // Collapse expanded list — summary has everything bar.classList.remove('expanded'); document.getElementById('schedules-list')?.classList.add('hidden'); - if (seeAllLink) seeAllLink.textContent = 'See all'; + if (seeAllLink) seeAllLink.textContent = t('schedule.seeAll'); } else { if (seeAllLink) seeAllLink.style.display = ''; } @@ -3314,13 +3505,13 @@ function renderSchedulesBar() { const toggleBtn = document.createElement('button'); toggleBtn.className = 'schedule-toggle'; toggleBtn.textContent = s.active === false ? '▶' : '⏸'; - toggleBtn.title = s.active === false ? 'Resume' : 'Pause'; + toggleBtn.title = s.active === false ? t('schedule.resume') : t('schedule.pause'); toggleBtn.onclick = () => toggleSchedule(s.id); const deleteBtn = document.createElement('button'); deleteBtn.className = 'schedule-delete'; deleteBtn.innerHTML = ''; - deleteBtn.title = 'Delete'; + deleteBtn.title = t('common.delete'); deleteBtn.onclick = () => deleteSchedule(s.id); row.append(targets, prompt, interval, toggleBtn, deleteBtn); @@ -3344,11 +3535,11 @@ function formatScheduleTime(ts) { } function formatScheduleInterval(s) { - if (s.daily_at) return 'daily at ' + s.daily_at; + if (s.daily_at) return t('schedule.dailyAt', { time: s.daily_at }); const sec = s.interval_seconds || 0; - if (sec < 3600) return 'every ' + Math.round(sec / 60) + 'm'; - if (sec < 86400) return 'every ' + Math.round(sec / 3600) + 'h'; - return 'every ' + Math.round(sec / 86400) + 'd'; + if (sec < 3600) return t('schedule.everyShort', { value: Math.round(sec / 60), unit: 'm' }); + if (sec < 86400) return t('schedule.everyShort', { value: Math.round(sec / 3600), unit: 'h' }); + return t('schedule.everyShort', { value: Math.round(sec / 86400), unit: 'd' }); } function toggleSchedulesExpand() { @@ -3358,7 +3549,7 @@ function toggleSchedulesExpand() { const expanded = bar.classList.toggle('expanded'); list.classList.toggle('hidden', !expanded); const link = bar.querySelector('.schedules-see-all'); - if (link) link.textContent = expanded ? 'Hide' : 'See all'; + if (link) link.textContent = expanded ? t('schedule.hide') : t('schedule.seeAll'); } async function toggleSchedule(id) { @@ -3446,14 +3637,14 @@ function populateScheduleDropdowns() { // Date options: Today, Tomorrow, then next 5 weekdays dateEl.innerHTML = ''; - const days = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']; + const days = [t('schedule.sunday'), t('schedule.monday'), t('schedule.tuesday'), t('schedule.wednesday'), t('schedule.thursday'), t('schedule.friday'), t('schedule.saturday')]; const now = new Date(); for (let i = 0; i < 7; i++) { const d = new Date(now); d.setDate(d.getDate() + i); const opt = document.createElement('option'); opt.value = d.toISOString().slice(0, 10); - opt.textContent = i === 0 ? 'Today' : i === 1 ? 'Tomorrow' : days[d.getDay()]; + opt.textContent = i === 0 ? t('schedule.today') : i === 1 ? t('schedule.tomorrow') : days[d.getDay()]; dateEl.appendChild(opt); } @@ -3515,7 +3706,7 @@ function updateSchedulePopoverState() { const targets = new Set(mentionMatches.map(m => m.slice(1))); for (const name of activeMentions) targets.add(name); if (targets.size === 0) { - if (errEl) { errEl.textContent = 'Toggle an agent to set a target'; errEl.classList.remove('hidden'); } + if (errEl) { errEl.textContent = t('schedule.targetError'); errEl.classList.remove('hidden'); } if (submitBtn) { submitBtn.disabled = true; } } else { if (errEl) { errEl.classList.add('hidden'); errEl.textContent = ''; } @@ -3537,7 +3728,7 @@ async function submitSchedulePopover() { if (targets.size === 0) return; // button should be disabled anyway if (!prompt) { - if (errEl) { errEl.textContent = 'Type a message first'; errEl.classList.remove('hidden'); } + if (errEl) { errEl.textContent = t('schedule.messageError'); errEl.classList.remove('hidden'); } return; } @@ -3587,11 +3778,11 @@ async function submitSchedulePopover() { showScheduleConfirmation(); } else { const err = await resp.json().catch(() => ({})); - showSlashHint(err.error || 'Failed to schedule'); + showSlashHint(err.error || t('schedule.failed')); } } catch (e) { console.error('Failed to create schedule:', e); - showSlashHint('Failed to create schedule'); + showSlashHint(t('schedule.createFailed')); } } @@ -3651,90 +3842,88 @@ function _helpCardDefs() { anchor: '#agent-status', row: 'top', html: - '
Agents header pills
' + - '

The status pills in the header show who is here and what they\'re doing.

' + + `
${t('help.agents.title')} ${t('help.agents.loc')}
` + + `

${t('help.agents.desc')}

` + '
' + '
' + '' + 'claude' + '
' + - 'Online' + + `${t('help.agents.online')}` + '
' + '
' + '
' + '' + 'codex' + '
' + - 'Working — spinning border' + + `${t('help.agents.working')}` + '
' + '
' + '
' + '' + 'gemini' + '
' + - 'Offline' + + `${t('help.agents.offline')}` + '
' + - '

Click any pill to rename it, assign a role, or change its colour.

' + `

${t('help.agents.tip')}

` }, { id: 'hg-jobs', anchor: '#jobs-toggle', row: 'top', html: - '
Jobs hover any message + sidebar
' + - '

Jobs turn conversation into tracked work. Any message can become a job.

' + + `
${t('help.jobs.title')} ${t('help.jobs.loc')}
` + + `

${t('help.jobs.desc')}

` + '
' + '
' + '
' + 'claude' + - 'The auth module needs refactoring before we can add SSO support.' + + `${t('help.jobs.sample')}` + '
' + - 'convert to job' + + `${t('timeline.convertJob')}` + '
' + - '

Hover any message to see convert to job. This opens the Jobs sidebar.

' + + `

${t('help.jobs.tip1')}

` + '
' + '
' + - 'Jobs sidebar' + + `${t('help.jobs.sidebar')}` + '+' + '
' + '
' + '' + - 'Auth refactor' + + `${t('help.jobs.jobTitle')}` + '
' + - 'TO DO' + - 'ACTIVE' + - 'CLOSED' + + `${t('common.todo')}` + + `${t('common.active')}` + + `${t('common.closed')}` + '
' + '
' + '
' + - '

Jobs live in the sidebar. Each one opens its own thread, and you can track it from TO DO to ACTIVE to CLOSED.

' + `

${t('help.jobs.tip2')}

` }, { id: 'hg-rules', anchor: '#rules-toggle', row: 'top', html: - '
Rules top-right panel
' + - '

Rules tell agents how to work in this room. Active rules are followed automatically.

' + + `
${t('help.rules.title')} ${t('help.rules.loc')}
` + + `

${t('help.rules.desc')}

` + '
' + '
' + - 'Rules' + + `${t('rules.title')}` + '2' + '+' + '
' + '
' + '' + - 'Run tests before committing' + - 'draft' + + `${t('help.rules.sampleDraft')}` + + `${t('help.rules.draft')}` + '
' + '
' + '' + - 'Always explain your reasoning before making changes' + + `${t('help.rules.sampleActive')}` + '
' + '
' + - '

Active = agents follow this. ' + - ' Draft = not active yet. ' + - 'You or agents can add rules.

' + `

${t('help.rules.tip')}

` }, { id: 'hg-channels', @@ -3743,15 +3932,15 @@ function _helpCardDefs() { light: true, wide: true, html: - '
Channels top bar
' + - '

Split conversations by topic. Each channel has its own message history.

' + + `
${t('help.channels.title')} ${t('help.channels.loc')}
` + + `

${t('help.channels.desc')}

` + '
' + '# general' + '# design' + '# backend' + '+' + '
' + - '

Click + to create a new channel. Agents can be mentioned in any channel.

' + `

${t('help.channels.tip')}

` }, { id: 'hg-mentions', @@ -3759,62 +3948,62 @@ function _helpCardDefs() { row: 'bottom', light: true, html: - '
Mentions pills above composer
' + - '

Type @ in the composer to mention an agent. The pills above the input let you pre-select who to address. Selected agents are mentioned automatically when you send.

' + - '

Mentioned agents receive a trigger and will respond in the channel.

' + `
${t('help.mentions.title')} ${t('help.mentions.loc')}
` + + `

${t('help.mentions.desc')}

` + + `

${t('help.mentions.tip')}

` }, { id: 'hg-sessions', anchor: '#session-launch-btn', row: 'bottom', html: - '
Sessions play button
' + - '

Sessions are structured multi-agent workflows with phases and roles. Launch one to coordinate agents on a shared goal.

' + + `
${t('help.sessions.title')} ${t('help.sessions.loc')}
` + + `

${t('help.sessions.desc')}

` + '
' + '
' + '' + '
' + - 'Brainstorm' + - 'Free-form idea generation with all agents' + + `${t('help.sessions.brainstorm')}` + + `${t('help.sessions.brainstormDesc')}` + '
' + '
' + '
' + '' + '
' + - 'Code Review' + - 'Structured review with phases and roles' + + `${t('help.sessions.codeReview')}` + + `${t('help.sessions.codeReviewDesc')}` + '
' + '
' + '
' + '' + '
' + - 'Design Review' + - 'Critique and iterate on design decisions' + + `${t('help.sessions.designReview')}` + + `${t('help.sessions.designReviewDesc')}` + '
' + '
' + '
' + '
' + - 'Custom sessions — describe a goal and agents will organise themselves into a structured workflow.' + + `${t('help.sessions.custom')} -- ${t('help.sessions.customDesc')}` + '
' + - '

Click the ▶ play button next to the composer to start.

' + `

${t('help.sessions.tip')}

` }, { id: 'hg-scheduling', anchor: '#schedule-btn', row: 'bottom', html: - '
Scheduling clock button
' + - '

Schedule any message for later delivery, or set up recurring prompts on a timer.

' + + `
${t('help.scheduling.title')} ${t('help.scheduling.loc')}
` + + `

${t('help.scheduling.desc')}

` + '
' + '3:00 PM' + - '@codex check deployment status' + - 'every 1h' + + `${t('help.scheduling.sample1')}` + + `${t('help.scheduling.recur')}` + '
' + '
' + - 'Tomorrow 9 AM' + - '@claude summarise overnight changes' + + `${t('help.scheduling.tomorrow9')}` + + `${t('help.scheduling.sample2')}` + '
' + - '

Click the ⏱ clock button next to Send to schedule a one-time or recurring message.

' + `

${t('help.scheduling.tip')}

` } ]; } @@ -3924,7 +4113,7 @@ function _openHelpAnchored(cardDefs) { // Dismiss hint var hint = document.createElement('div'); hint.className = 'hg-dismiss-hint'; - hint.textContent = 'Press Esc or click outside to close'; + hint.textContent = t('help.dismissHint'); overlay.appendChild(hint); document.body.appendChild(overlay); @@ -4046,7 +4235,7 @@ function _openHelpStacked(cardDefs) { // Sticky header var header = document.createElement('div'); header.className = 'hg-modal-header'; - header.innerHTML = 'Guide'; + header.innerHTML = `${t('help.guide')}`; var closeBtn = document.createElement('button'); closeBtn.className = 'hg-modal-close'; closeBtn.innerHTML = '×'; diff --git a/static/i18n.js b/static/i18n.js new file mode 100644 index 00000000..9df6e04b --- /dev/null +++ b/static/i18n.js @@ -0,0 +1,899 @@ +/** + * i18n.js -- lightweight runtime localization for agentchattr. + * + * The app is static JS without a build step, so translations live here and are + * applied directly to DOM nodes or requested by dynamic renderers via t(). + */ + +(function () { + 'use strict'; + + const STORAGE_KEY = 'agentchattr-language'; + + const I18N_STRINGS = { + en: { + 'lang.auto': 'Auto', + 'lang.en': 'English', + 'lang.zhCN': 'Simplified Chinese', + + 'settings.language': 'Language', + 'settings.theme': 'Theme', + 'settings.theme.light': 'Light', + 'settings.theme.dark': 'Dark', + 'settings.name': 'Name', + 'settings.name.placeholder': 'your name', + 'settings.font': 'Font', + 'settings.font.mono': 'Monospace', + 'settings.font.sans': 'Sans', + 'settings.loopGuard': 'Loop guard', + 'settings.loopGuard.title': 'Max agent-to-agent hops before pausing', + 'settings.history': 'History', + 'settings.history.all': 'All', + 'settings.contrast': 'Contrast', + 'settings.contrast.normal': 'Normal', + 'settings.contrast.high': 'High', + 'settings.channels': 'Channels', + 'settings.channels.title': 'Show channels in a sidebar (Discord/Slack-style) instead of a top bar', + 'settings.channels.top': 'Top bar', + 'settings.channels.sidebar': 'Sidebar', + 'settings.rulesRefresh': 'Rule refresh', + 'settings.rulesRefresh.title': 'How often to re-send active rules to agents', + 'settings.rulesRefresh.change': 'On rule changes', + 'settings.rulesRefresh.every': 'Every {count} triggers', + 'settings.rulesRefresh.every5': 'Every 5 triggers', + 'settings.rulesRefresh.every10': 'Every 10 triggers', + 'settings.rulesRefresh.every20': 'Every 20 triggers', + 'settings.rulesRefresh.every50': 'Every 50 triggers', + 'settings.projectHistory': 'Project History', + 'settings.exportHistory': 'Export History', + 'settings.importHistory': 'Import History', + 'settings.importing': 'Importing...', + 'settings.exportFailed': 'Export failed', + 'settings.exportSuccess': 'History exported', + 'settings.importFailed': 'Import failed', + 'settings.imported': 'Imported {items}', + 'settings.messages': 'messages', + 'settings.duplicateSkipped': '{count} duplicates skipped', + 'settings.warnings': '{count} warning(s)', + + 'header.discord.title': 'Join the Discord', + 'header.jobs.title': 'Jobs', + 'header.rules.title': 'Rules', + 'header.pins.title': 'Pinned messages', + 'header.settings.title': 'Settings', + 'header.help.title': 'Help', + 'header.support.title': 'Support development', + 'header.support': ' Support development', + 'header.support.short': ' Support', + + 'common.send': 'Send', + 'common.cancel': 'Cancel', + 'common.create': 'Create', + 'common.save': 'Save', + 'common.saved': 'Saved', + 'common.delete': 'Delete', + 'common.delete?': 'Delete?', + 'common.confirm': 'Confirm', + 'common.rename': 'Rename', + 'common.dismiss': 'Dismiss', + 'common.back': 'Back', + 'common.none': 'None', + 'common.reset': 'Reset', + 'common.active': 'ACTIVE', + 'common.closed': 'CLOSED', + 'common.todo': 'TO DO', + 'common.archive': 'ARCHIVE', + 'common.drafts': 'DRAFTS', + 'common.open': 'Open', + 'common.yes': 'Yes', + 'common.no': 'No', + 'common.error': 'Error: {message}', + + 'pins.title': 'Pinned', + 'pins.empty': 'No pinned messages', + 'pins.remove': 'Remove from todos', + 'pins.pin': 'pin', + 'pins.done?': 'done?', + 'pins.unpin': 'unpin', + + 'channel.add.title': 'Add channel', + 'channel.rename.title': 'Rename', + 'channel.delete.title': 'Delete', + 'channel.deleteConfirm': 'delete #{name}?', + + 'timeline.loading': 'Loading messages...', + 'timeline.thinking': 'is thinking...', + 'timeline.joined': 'joined', + 'timeline.left': 'left', + 'timeline.summary': 'Summary', + 'timeline.today': 'Today', + 'timeline.yesterday': 'Yesterday', + 'timeline.reply': 'reply', + 'timeline.deleteShort': 'del', + 'timeline.copy': 'Copy message', + 'timeline.copyButton': 'copy', + 'timeline.copied': 'copied!', + 'timeline.failed': 'failed', + 'timeline.openPath': 'Open in file manager', + 'timeline.convertJob': 'convert to job', + 'timeline.setRole': 'Set role', + 'timeline.chooseRole': 'choose a role', + 'timeline.youChose': 'You chose: {choice}', + 'timeline.replyingTo': 'replying to', + 'timeline.selected': '{count} selected', + 'timeline.selectMessages': 'Select messages', + 'timeline.deleteCount': 'Delete ({count})', + 'timeline.clearChat': 'Clear Chat', + 'timeline.clearChat.title': 'Clear chat in this channel', + 'timeline.clearChatConfirm': 'Clear Chat?', + 'timeline.confirmClearChat.title': 'Confirm clear chat', + + 'composer.startSession.title': 'Start a session', + 'composer.placeholder': 'Type a message... (use @name to mention agents)', + 'composer.voice.title': 'Voice typing (local speech-to-text)', + 'composer.voice.label': 'Voice typing', + 'composer.schedule.title': 'Schedule message', + 'composer.hint': 'Enter to send · Shift+Enter for newline · Paste/drop images · Mic for voice · Type / or @ for autocomplete', + 'composer.dropImage': 'Drop image here', + 'composer.speechUnsupported': 'Speech recognition not supported -- use Chrome or Edge.', + 'composer.micBlocked': 'Microphone access was blocked. Allow microphone access in Chrome and try again.', + + 'sound.default': 'Default sound', + 'sound.background': 'Background alerts', + 'sound.useDefault': 'Use default', + 'sound.softChime': 'Soft Chime', + 'sound.brightPing': 'Bright Ping', + 'sound.gentlePop': 'Gentle Pop', + 'sound.alertTone': 'Alert Tone', + 'sound.pluck': 'Pluck', + 'sound.click': 'Click', + 'sound.warmBell': 'Warm Bell', + 'sound.none': 'None', + + 'update.upstream': 'Upstream update available', + 'update.available': 'Update available', + 'update.dismiss': 'Dismiss', + + 'agent.nameThis': 'Name this agent', + 'agent.newInstance': 'A new {family} instance connected', + 'agent.rename': 'Rename agent', + 'agent.currentId': 'Current ID: @{name}', + 'agent.displayTitle': 'Display title', + 'agent.mentionId': 'Mention ID: @{name}', + 'agent.role': 'Role', + 'agent.customRole.placeholder': 'Custom role...', + 'agent.color': 'Color', + 'agent.pickColor.title': 'Pick custom color', + 'agent.resetColor.title': 'Reset to default', + 'agent.custom.placeholder': 'Custom...', + + 'role.planner': 'Planner', + 'role.designer': 'Designer', + 'role.architect': 'Architect', + 'role.builder': 'Builder', + 'role.reviewer': 'Reviewer', + 'role.researcher': 'Researcher', + 'role.redTeam': 'Red Team', + 'role.wry': 'Wry', + 'role.unhinged': 'Unhinged', + 'role.hype': 'Hype', + + 'jobs.title': 'Jobs', + 'jobs.create.title': 'Create Job', + 'jobs.openJob.title': 'Open job', + 'jobs.newJob': 'New job:', + 'jobs.suggestion': 'Suggestion', + 'jobs.startJobPrompt': '@{agent} start this job', + 'jobs.proposal': 'Job Proposal', + 'jobs.accept': 'Accept', + 'jobs.requestChanges': 'Request Changes', + 'jobs.dismissed': 'Dismissed', + 'jobs.started': '{count} jobs were started', + 'jobs.none': 'none', + 'jobs.unread.title': 'Unread messages', + 'jobs.empty.title': 'No jobs yet', + 'jobs.empty.meta': 'Create one or convert a chat message', + 'jobs.dropDeleteOne': 'Drop to delete job', + 'jobs.dropDeleteMany': 'Drop to delete {count} jobs', + 'jobs.assign.placeholder': 'Send to assign · click to edit', + 'jobs.message.placeholder': 'Message...', + 'jobs.emptyMessages': 'No messages yet. Start the conversation!', + 'jobs.loadFailed': 'Failed to load messages.', + 'jobs.deleteShort': 'DEL', + 'jobs.title.placeholder': 'Job title', + 'jobs.body.placeholder': 'Description (optional)', + 'jobs.accepted': 'Accepted', + 'jobs.deleteSelected': 'Delete {count} selected', + 'jobs.dragDelete': 'Drag here to delete', + 'jobs.convertTitle': 'Convert to Job', + 'jobs.convertSubtitle': 'An agent will write a job proposal for you to accept', + 'jobs.convertLabel': 'Ask agent to write proposal', + 'jobs.convert': 'Convert', + 'jobs.askingAgent': 'Asking @{agent} to create a job card...', + 'jobs.deletePermanentTitle': 'Delete Job Permanently?', + 'jobs.deletePermanentSubtitle': 'This removes the job and its messages permanently. This cannot be undone.', + 'jobs.replyTarget.title': 'Reply target', + 'jobs.clearReplyTarget.title': 'Clear reply target', + 'jobs.replyTargetPrefix': 'To', + + 'system.agentRoutingRecovered': 'Agent routing for {agent} interrupted — auto-recovered. If agents aren\'t responding, try sending your message again.', + 'system.routingResumed': 'Routing resumed by {sender}.', + 'system.resumingConversation': 'Resuming agent conversation...', + 'system.offlineQueued': '{agent} appears offline — message queued.', + 'system.loopGuardContinueHumanOnly': 'Loop guard: only humans can /continue. {sender} tried to self-resume.', + 'system.loopGuardHopsReached': 'Loop guard: {count} agent-to-agent hops reached. Type /continue to resume.', + + 'rules.title': 'Rules', + 'rules.proposal': 'Rule Proposal', + 'rules.activate': 'Activate', + 'rules.addDrafts': 'Add to drafts', + 'rules.activated': 'Activated', + 'rules.addedDrafts': 'Added to drafts', + 'rules.add.title': 'Add Rule', + 'rules.remind': 'Remind agents', + 'rules.queued': 'Queued -- next trigger', + 'rules.empty.title': 'No rules yet', + 'rules.empty.meta': 'Tell your agents how to work', + 'rules.nextTrigger': 'New rules are sent on the next agent trigger', + 'rules.softWarning': 'Less than seven active rules tends to work better', + 'rules.dragDelete': 'Drag here to delete', + 'rules.create.placeholder': 'Write a short rule agents should follow', + 'rules.deleteConfirm': 'Delete?', + 'rules.rejectedPrefix': 'Rule rejected', + + 'sessions.goToChannel': 'Go to channel', + 'sessions.end': 'End Session', + 'sessions.endConfirm': 'End Session?', + 'sessions.confirmEnd.title': 'Confirm end session', + 'sessions.activeIn': 'Session active in #{channel}', + 'sessions.activeElsewhere': '{count} sessions active elsewhere', + 'sessions.session': 'Session', + 'sessions.customSession': 'Custom session', + 'sessions.waitingFor': 'Waiting for {agent}', + 'sessions.paused': 'Paused', + 'sessions.viewOutput': 'View output', + 'sessions.proposal': 'Session Proposal', + 'sessions.superseded': 'Superseded draft', + 'sessions.replacedByRevision': 'Replaced by revision {revision}.', + 'sessions.invalidDraft': 'Invalid session draft', + 'sessions.invalidDraftHelp': 'This draft needs changes before it can be run or saved.', + 'sessions.requestChanges': 'Request Changes', + 'sessions.run': 'Run', + 'sessions.saveTemplate': 'Save Template', + 'sessions.deleteTemplate.title': 'Delete custom template', + 'sessions.cast': 'Cast', + 'sessions.phaseNumber': 'Phase {number}', + 'sessions.goToChannelNamed': 'Go to #{channel}', + 'sessions.goToChannelExtra': 'Go to #{channel} (+{count})', + 'sessions.template.codeReview.name': 'Code Review', + 'sessions.template.codeReview.description': 'Structured code review with builder, reviewer, and red team.', + 'sessions.template.debate.name': 'Debate', + 'sessions.template.debate.description': 'Two agents argue opposing positions. A third moderates and delivers a verdict.', + 'sessions.template.designCritique.name': 'Design Critique', + 'sessions.template.designCritique.description': 'One agent presents work, one critiques it, one synthesises the outcome.', + 'sessions.template.planning.name': 'Planning', + 'sessions.template.planning.description': 'Turn a problem into an execution plan through framing, challenge, and synthesis.', + 'sessions.role.builder': 'builder', + 'sessions.role.reviewer': 'reviewer', + 'sessions.role.red_team': 'red_team', + 'sessions.role.synthesiser': 'synthesiser', + 'sessions.role.proposer': 'proposer', + 'sessions.role.for': 'for', + 'sessions.role.against': 'against', + 'sessions.role.moderator': 'moderator', + 'sessions.role.presenter': 'presenter', + 'sessions.role.critic': 'critic', + 'sessions.role.planner': 'planner', + 'sessions.role.challenger': 'challenger', + 'sessions.phase.submit': 'Submit', + 'sessions.phase.review': 'Review', + 'sessions.phase.respond': 'Respond', + 'sessions.phase.summary': 'Summary', + 'sessions.phase.frame': 'Frame', + 'sessions.phase.opening_arguments': 'Opening Arguments', + 'sessions.phase.rebuttals': 'Rebuttals', + 'sessions.phase.verdict': 'Verdict', + 'sessions.phase.present': 'Present', + 'sessions.phase.critique': 'Critique', + 'sessions.phase.synthesis': 'Synthesis', + 'sessions.phase.challenge': 'Challenge', + 'sessions.phase.plan': 'Plan', + 'sessions.design.placeholder': 'Describe the session you want...', + 'sessions.goal.placeholder': 'Goal (optional) -- what should this session achieve?', + 'sessions.goalShort.placeholder': 'Goal (optional)', + 'sessions.changes.placeholder': 'What changes do you want?', + 'sessions.sendDesignFailed': 'Failed to send design request (HTTP {status})', + 'sessions.startFailed': 'Failed to start session', + 'sessions.startError': 'Error starting session: {message}', + 'sessions.endFailed': 'Failed to end session', + 'sessions.endError': 'Error ending session: {message}', + 'sessions.startDraftFailed': 'Failed to start session from draft', + 'sessions.saveFailed': 'Failed to save template', + 'sessions.deleteFailed': 'Failed to delete template', + 'sessions.connectionLost': 'Connection lost. Reconnect and try again.', + 'sessions.dismissFailed': 'Failed to dismiss session proposal', + + 'schedule.seeAll': 'See all', + 'schedule.hide': 'Hide', + 'schedule.message': 'Schedule message', + 'schedule.when': 'When', + 'schedule.at': 'At', + 'schedule.recurring': 'Recurring', + 'schedule.every': 'Every', + 'schedule.minutes': 'minutes', + 'schedule.hours': 'hours', + 'schedule.days': 'days', + 'schedule.submit': 'Schedule', + 'schedule.today': 'Today', + 'schedule.tomorrow': 'Tomorrow', + 'schedule.sunday': 'Sunday', + 'schedule.monday': 'Monday', + 'schedule.tuesday': 'Tuesday', + 'schedule.wednesday': 'Wednesday', + 'schedule.thursday': 'Thursday', + 'schedule.friday': 'Friday', + 'schedule.saturday': 'Saturday', + 'schedule.nextIn': 'next in {time}', + 'schedule.paused': '{count} paused schedule{plural}', + 'schedule.activeCount': '{count} active', + 'schedule.pausedCount': '{count} paused', + 'schedule.pausedSuffix': ' (paused)', + 'schedule.dailyAt': 'daily at {time}', + 'schedule.everyShort': 'every {value}{unit}', + 'schedule.pause': 'Pause', + 'schedule.resume': 'Resume', + 'schedule.failed': 'Failed to schedule', + 'schedule.createFailed': 'Failed to create schedule', + 'schedule.targetError': 'Toggle an agent to set a target', + 'schedule.messageError': 'Type a message first', + + 'slash.artchallenge': 'SVG art challenge -- all agents create artwork (optional theme)', + 'slash.hatmaking': 'All agents design a hat to wear on their avatar', + 'slash.roastreview': 'Get all agents to review and roast each other\'s work', + 'slash.haiku': 'Agents write a haiku about the codebase', + 'slash.limerick': 'Agents write a limerick about the codebase', + 'slash.sonnet': 'Agents write a sonnet about the codebase', + 'slash.summary': 'Summarize recent messages -- tag an agent (e.g. /summary @claude)', + 'slash.summarise': 'Summarize recent messages -- tag an agent (e.g. /summarise @claude)', + 'slash.continue': 'Resume after loop guard pauses', + 'slash.clear': 'Clear messages in current channel', + + 'help.guide': 'Guide', + 'help.dismissHint': 'Press Esc or click outside to close', + 'help.agents.title': 'Agents', + 'help.agents.loc': 'header pills', + 'help.agents.desc': 'The status pills in the header show who is here and what they\'re doing.', + 'help.agents.online': 'Online', + 'help.agents.working': 'Working -- spinning border', + 'help.agents.offline': 'Offline', + 'help.agents.tip': 'Click any pill to rename it, assign a role, or change its color.', + 'help.jobs.title': 'Jobs', + 'help.jobs.loc': 'hover any message + sidebar', + 'help.jobs.desc': 'Jobs turn conversation into tracked work. Any message can become a job.', + 'help.jobs.sample': 'The auth module needs refactoring before we can add SSO support.', + 'help.jobs.tip1': 'Hover any message to see convert to job. This opens the Jobs sidebar.', + 'help.jobs.sidebar': 'Jobs sidebar', + 'help.jobs.jobTitle': 'Auth refactor', + 'help.jobs.tip2': 'Jobs live in the sidebar. Each one opens its own thread, and you can track it from TO DO to ACTIVE to CLOSED.', + 'help.rules.title': 'Rules', + 'help.rules.loc': 'top-right panel', + 'help.rules.desc': 'Rules tell agents how to work in this room. Active rules are followed automatically.', + 'help.rules.sampleDraft': 'Run tests before committing', + 'help.rules.sampleActive': 'Always explain your reasoning before making changes', + 'help.rules.draft': 'draft', + 'help.rules.tip': 'Active = agents follow this. Draft = not active yet. You or agents can add rules.', + 'help.channels.title': 'Channels', + 'help.channels.loc': 'top bar', + 'help.channels.desc': 'Split conversations by topic. Each channel has its own message history.', + 'help.channels.tip': 'Click + to create a new channel. Agents can be mentioned in any channel.', + 'help.mentions.title': 'Mentions', + 'help.mentions.loc': 'pills above composer', + 'help.mentions.desc': 'Type @ in the composer to mention an agent. The pills above the input let you pre-select who to address. Selected agents are mentioned automatically when you send.', + 'help.mentions.tip': 'Mentioned agents receive a trigger and will respond in the channel.', + 'help.sessions.title': 'Sessions', + 'help.sessions.loc': 'play button', + 'help.sessions.desc': 'Sessions are structured multi-agent workflows with phases and roles. Launch one to coordinate agents on a shared goal.', + 'help.sessions.brainstorm': 'Brainstorm', + 'help.sessions.brainstormDesc': 'Free-form idea generation with all agents', + 'help.sessions.codeReview': 'Code Review', + 'help.sessions.codeReviewDesc': 'Structured review with phases and roles', + 'help.sessions.designReview': 'Design Review', + 'help.sessions.designReviewDesc': 'Critique and iterate on design decisions', + 'help.sessions.custom': 'Custom sessions', + 'help.sessions.customDesc': 'describe a goal and agents will organize themselves into a structured workflow.', + 'help.sessions.tip': 'Click the play button next to the composer to start.', + 'help.scheduling.title': 'Scheduling', + 'help.scheduling.loc': 'clock button', + 'help.scheduling.desc': 'Schedule any message for later delivery, or set up recurring prompts on a timer.', + 'help.scheduling.sample1': '@codex check deployment status', + 'help.scheduling.sample2': '@claude summarize overnight changes', + 'help.scheduling.recur': 'every 1h', + 'help.scheduling.tomorrow9': 'Tomorrow 9 AM', + 'help.scheduling.tip': 'Click the clock button next to Send to schedule a one-time or recurring message.', + }, + zhCN: { + 'lang.auto': '自动', + 'lang.en': 'English', + 'lang.zhCN': '简体中文', + + 'settings.language': '语言', + 'settings.theme': '主题', + 'settings.theme.light': '白天', + 'settings.theme.dark': '黑夜', + 'settings.name': '名称', + 'settings.name.placeholder': '你的名字', + 'settings.font': '字体', + 'settings.font.mono': '等宽', + 'settings.font.sans': '无衬线', + 'settings.loopGuard': '循环保护', + 'settings.loopGuard.title': '代理连续互相触发多少次后暂停', + 'settings.history': '历史记录', + 'settings.history.all': '全部', + 'settings.contrast': '对比度', + 'settings.contrast.normal': '普通', + 'settings.contrast.high': '高', + 'settings.channels': '频道', + 'settings.channels.title': '用侧边栏显示频道,而不是顶部栏', + 'settings.channels.top': '顶部栏', + 'settings.channels.sidebar': '侧边栏', + 'settings.rulesRefresh': '规则刷新', + 'settings.rulesRefresh.title': '多久重新把启用规则发送给代理', + 'settings.rulesRefresh.change': '规则变更时', + 'settings.rulesRefresh.every': '每 {count} 次触发', + 'settings.rulesRefresh.every5': '每 5 次触发', + 'settings.rulesRefresh.every10': '每 10 次触发', + 'settings.rulesRefresh.every20': '每 20 次触发', + 'settings.rulesRefresh.every50': '每 50 次触发', + 'settings.projectHistory': '项目历史', + 'settings.exportHistory': '导出历史', + 'settings.importHistory': '导入历史', + 'settings.importing': '正在导入...', + 'settings.exportFailed': '导出失败', + 'settings.exportSuccess': '历史已导出', + 'settings.importFailed': '导入失败', + 'settings.imported': '已导入 {items}', + 'settings.messages': '条消息', + 'settings.duplicateSkipped': '跳过 {count} 个重复项', + 'settings.warnings': '{count} 个警告', + + 'header.discord.title': '加入 Discord', + 'header.jobs.title': '任务', + 'header.rules.title': '规则', + 'header.pins.title': '已置顶消息', + 'header.settings.title': '设置', + 'header.help.title': '帮助', + 'header.support.title': '支持开发', + 'header.support': ' 支持开发', + 'header.support.short': ' 支持', + + 'common.send': '发送', + 'common.cancel': '取消', + 'common.create': '创建', + 'common.save': '保存', + 'common.saved': '已保存', + 'common.delete': '删除', + 'common.delete?': '删除?', + 'common.confirm': '确认', + 'common.rename': '重命名', + 'common.dismiss': '忽略', + 'common.back': '返回', + 'common.none': '无', + 'common.reset': '重置', + 'common.active': '进行中', + 'common.closed': '已关闭', + 'common.todo': '待办', + 'common.archive': '归档', + 'common.drafts': '草稿', + 'common.open': '打开', + 'common.yes': '是', + 'common.no': '否', + 'common.error': '错误:{message}', + + 'pins.title': '已置顶', + 'pins.empty': '没有已置顶消息', + 'pins.remove': '从待办中移除', + 'pins.pin': '置顶', + 'pins.done?': '完成?', + 'pins.unpin': '取消置顶', + + 'channel.add.title': '添加频道', + 'channel.rename.title': '重命名', + 'channel.delete.title': '删除', + 'channel.deleteConfirm': '删除 #{name}?', + + 'timeline.loading': '正在加载消息...', + 'timeline.thinking': '正在思考...', + 'timeline.joined': '加入了', + 'timeline.left': '离开了', + 'timeline.summary': '摘要', + 'timeline.today': '今天', + 'timeline.yesterday': '昨天', + 'timeline.reply': '回复', + 'timeline.deleteShort': '删', + 'timeline.copy': '复制消息', + 'timeline.copyButton': '复制', + 'timeline.copied': '已复制', + 'timeline.failed': '失败', + 'timeline.openPath': '在文件管理器中打开', + 'timeline.convertJob': '转为任务', + 'timeline.setRole': '设置角色', + 'timeline.chooseRole': '选择角色', + 'timeline.youChose': '你选择了:{choice}', + 'timeline.replyingTo': '正在回复', + 'timeline.selected': '已选择 {count} 条', + 'timeline.selectMessages': '选择消息', + 'timeline.deleteCount': '删除 ({count})', + 'timeline.clearChat': '清空聊天', + 'timeline.clearChat.title': '清空当前频道聊天', + 'timeline.clearChatConfirm': '确认清空?', + 'timeline.confirmClearChat.title': '确认清空聊天', + + 'composer.startSession.title': '启动会话', + 'composer.placeholder': '输入消息...(用 @name 提及代理)', + 'composer.voice.title': '语音输入(本地语音转文字)', + 'composer.voice.label': '语音输入', + 'composer.schedule.title': '定时消息', + 'composer.hint': 'Enter 发送 · Shift+Enter 换行 · 粘贴/拖放图片 · 麦克风语音输入 · 输入 / 或 @ 自动补全', + 'composer.dropImage': '把图片拖到这里', + 'composer.speechUnsupported': '当前浏览器不支持语音识别,请使用 Chrome 或 Edge。', + 'composer.micBlocked': '麦克风权限被阻止。请在 Chrome 中允许麦克风权限后重试。', + + 'sound.default': '默认提示音', + 'sound.background': '后台提醒', + 'sound.useDefault': '使用默认', + 'sound.softChime': '柔和铃声', + 'sound.brightPing': '明亮提示', + 'sound.gentlePop': '轻柔弹音', + 'sound.alertTone': '警示音', + 'sound.pluck': '拨弦', + 'sound.click': '点击声', + 'sound.warmBell': '温暖铃声', + 'sound.none': '无', + + 'update.upstream': '上游有可用更新', + 'update.available': '有可用更新', + 'update.dismiss': '忽略', + + 'agent.nameThis': '给这个代理命名', + 'agent.newInstance': '新的 {family} 实例已连接', + 'agent.rename': '重命名代理', + 'agent.currentId': '当前 ID:@{name}', + 'agent.role': '角色', + 'agent.customRole.placeholder': '自定义角色...', + 'agent.color': '颜色', + 'agent.pickColor.title': '选择自定义颜色', + 'agent.resetColor.title': '恢复默认颜色', + 'agent.custom.placeholder': '自定义...', + + 'role.planner': '规划者', + 'role.designer': '设计师', + 'role.architect': '架构师', + 'role.builder': '构建者', + 'role.reviewer': '审查者', + 'role.researcher': '研究员', + 'role.redTeam': '红队', + 'role.wry': '吐槽担当', + 'role.unhinged': '放飞型', + 'role.hype': '气氛组', + + 'jobs.title': '任务', + 'jobs.create.title': '创建任务', + 'jobs.openJob.title': '打开任务', + 'jobs.newJob': '新任务:', + 'jobs.suggestion': '建议', + 'jobs.startJobPrompt': '@{agent} 开始这个任务', + 'jobs.proposal': '任务提案', + 'jobs.accept': '接受', + 'jobs.requestChanges': '请求修改', + 'jobs.dismissed': '已忽略', + 'jobs.started': '已创建 {count} 个任务', + 'jobs.none': '无', + 'jobs.unread.title': '未读消息', + 'jobs.empty.title': '还没有任务', + 'jobs.empty.meta': '创建一个任务,或把聊天消息转成任务', + 'jobs.dropDeleteOne': '松手删除任务', + 'jobs.dropDeleteMany': '松手删除 {count} 个任务', + 'jobs.assign.placeholder': '发送以分配 · 点击编辑', + 'jobs.message.placeholder': '消息...', + 'jobs.emptyMessages': '还没有消息。开始对话吧!', + 'jobs.loadFailed': '加载消息失败。', + 'jobs.deleteShort': '删', + 'jobs.title.placeholder': '任务标题', + 'jobs.body.placeholder': '描述(可选)', + 'jobs.accepted': '已接受', + 'jobs.deleteSelected': '删除已选择的 {count} 项', + 'jobs.dragDelete': '拖到这里删除', + 'jobs.convertTitle': '转为任务', + 'jobs.convertSubtitle': '代理会写一个任务提案,供你接受', + 'jobs.convertLabel': '让代理编写提案', + 'jobs.convert': '转换', + 'jobs.askingAgent': '正在请求 @{agent} 创建任务卡片...', + 'jobs.deletePermanentTitle': '永久删除任务?', + 'jobs.deletePermanentSubtitle': '这会永久删除该任务及其消息,无法撤销。', + 'jobs.replyTarget.title': '回复对象', + 'jobs.clearReplyTarget.title': '清除回复对象', + 'jobs.replyTargetPrefix': '发给', + + 'system.agentRoutingRecovered': '{agent} 的代理路由已中断,已自动恢复。如果代理没有响应,请再发送一次消息。', + 'system.routingResumed': '{sender} 已恢复路由。', + 'system.resumingConversation': '正在恢复代理对话...', + 'system.offlineQueued': '{agent} 似乎离线,消息已排队。', + 'system.loopGuardContinueHumanOnly': '循环保护:只有人类可以使用 /continue。{sender} 尝试了自我恢复。', + 'system.loopGuardHopsReached': '循环保护:已达到 {count} 次代理间跳转。输入 /continue 以恢复。', + + 'rules.title': '规则', + 'rules.proposal': '规则提案', + 'rules.activate': '启用', + 'rules.addDrafts': '加入草稿', + 'rules.activated': '已启用', + 'rules.addedDrafts': '已加入草稿', + 'rules.add.title': '添加规则', + 'rules.remind': '提醒代理', + 'rules.queued': '已排队,下次触发时发送', + 'rules.empty.title': '还没有规则', + 'rules.empty.meta': '告诉代理应该怎么工作', + 'rules.nextTrigger': '新规则会在下一次代理触发时发送', + 'rules.softWarning': '启用规则少于 7 条通常效果更好', + 'rules.dragDelete': '拖到这里删除', + 'rules.create.placeholder': '写一条代理应该遵守的简短规则', + 'rules.deleteConfirm': '删除?', + 'rules.rejectedPrefix': '规则已拒绝', + + 'sessions.goToChannel': '前往频道', + 'sessions.end': '结束会话', + 'sessions.endConfirm': '结束会话?', + 'sessions.confirmEnd.title': '确认结束会话', + 'sessions.activeIn': '会话正在 #{channel} 中进行', + 'sessions.activeElsewhere': '其他频道有 {count} 个会话正在进行', + 'sessions.session': '会话', + 'sessions.customSession': '自定义会话', + 'sessions.waitingFor': '正在等待 {agent}', + 'sessions.paused': '已暂停', + 'sessions.viewOutput': '查看输出', + 'sessions.proposal': '会话提案', + 'sessions.superseded': '已被替换的草稿', + 'sessions.replacedByRevision': '已由修订版 {revision} 替换。', + 'sessions.invalidDraft': '无效的会话草稿', + 'sessions.invalidDraftHelp': '这个草稿需要修改后才能运行或保存。', + 'sessions.requestChanges': '请求修改', + 'sessions.run': '运行', + 'sessions.saveTemplate': '保存模板', + 'sessions.deleteTemplate.title': '删除自定义模板', + 'sessions.cast': '成员分配', + 'sessions.phaseNumber': '阶段 {number}', + 'sessions.goToChannelNamed': '前往 #{channel}', + 'sessions.goToChannelExtra': '前往 #{channel}(+{count})', + 'sessions.template.codeReview.name': '代码审查', + 'sessions.template.codeReview.description': '由构建者、审查者和红队进行结构化代码审查。', + 'sessions.template.debate.name': '辩论', + 'sessions.template.debate.description': '两个代理分别支持相反立场,第三个代理主持并给出结论。', + 'sessions.template.designCritique.name': '设计评审', + 'sessions.template.designCritique.description': '一个代理展示方案,一个代理提出评审意见,另一个代理汇总结论。', + 'sessions.template.planning.name': '规划', + 'sessions.template.planning.description': '通过界定问题、挑战假设和综合结论,把问题转成执行计划。', + 'sessions.role.builder': '构建者', + 'sessions.role.reviewer': '审查者', + 'sessions.role.red_team': '红队', + 'sessions.role.synthesiser': '总结者', + 'sessions.role.proposer': '提议者', + 'sessions.role.for': '正方', + 'sessions.role.against': '反方', + 'sessions.role.moderator': '主持人', + 'sessions.role.presenter': '展示者', + 'sessions.role.critic': '评审者', + 'sessions.role.planner': '规划者', + 'sessions.role.challenger': '挑战者', + 'sessions.phase.submit': '提交', + 'sessions.phase.review': '审查', + 'sessions.phase.respond': '回应', + 'sessions.phase.summary': '总结', + 'sessions.phase.frame': '界定问题', + 'sessions.phase.opening_arguments': '开场陈述', + 'sessions.phase.rebuttals': '反驳', + 'sessions.phase.verdict': '结论', + 'sessions.phase.present': '展示', + 'sessions.phase.critique': '评审', + 'sessions.phase.synthesis': '综合', + 'sessions.phase.challenge': '挑战', + 'sessions.phase.plan': '计划', + 'sessions.design.placeholder': '描述你想要的会话...', + 'sessions.goal.placeholder': '目标(可选)-- 这个会话应该达成什么?', + 'sessions.goalShort.placeholder': '目标(可选)', + 'sessions.changes.placeholder': '你希望修改什么?', + 'sessions.sendDesignFailed': '发送设计请求失败(HTTP {status})', + 'sessions.startFailed': '启动会话失败', + 'sessions.startError': '启动会话出错:{message}', + 'sessions.endFailed': '结束会话失败', + 'sessions.endError': '结束会话出错:{message}', + 'sessions.startDraftFailed': '从草稿启动会话失败', + 'sessions.saveFailed': '保存模板失败', + 'sessions.deleteFailed': '删除模板失败', + 'sessions.connectionLost': '连接已断开。请重新连接后重试。', + 'sessions.dismissFailed': '忽略会话提案失败', + + 'schedule.seeAll': '查看全部', + 'schedule.hide': '收起', + 'schedule.message': '定时消息', + 'schedule.when': '日期', + 'schedule.at': '时间', + 'schedule.recurring': '重复', + 'schedule.every': '每', + 'schedule.minutes': '分钟', + 'schedule.hours': '小时', + 'schedule.days': '天', + 'schedule.submit': '定时发送', + 'schedule.today': '今天', + 'schedule.tomorrow': '明天', + 'schedule.sunday': '星期日', + 'schedule.monday': '星期一', + 'schedule.tuesday': '星期二', + 'schedule.wednesday': '星期三', + 'schedule.thursday': '星期四', + 'schedule.friday': '星期五', + 'schedule.saturday': '星期六', + 'schedule.nextIn': '{time} 后执行', + 'schedule.paused': '{count} 个定时已暂停', + 'schedule.activeCount': '{count} 个启用', + 'schedule.pausedCount': '{count} 个暂停', + 'schedule.pausedSuffix': '(已暂停)', + 'schedule.dailyAt': '每天 {time}', + 'schedule.everyShort': '每 {value}{unit}', + 'schedule.pause': '暂停', + 'schedule.resume': '继续', + 'schedule.failed': '定时失败', + 'schedule.createFailed': '创建定时失败', + 'schedule.targetError': '先打开一个代理开关作为目标', + 'schedule.messageError': '请先输入消息', + + 'slash.artchallenge': 'SVG 艺术挑战:所有代理创作作品(可选主题)', + 'slash.hatmaking': '所有代理为自己的头像设计一顶帽子', + 'slash.roastreview': '让所有代理互相审查并犀利点评工作', + 'slash.haiku': '让代理写一首关于代码库的俳句', + 'slash.limerick': '让代理写一首关于代码库的五行打油诗', + 'slash.sonnet': '让代理写一首关于代码库的十四行诗', + 'slash.summary': '总结最近消息:请提及一个代理(例如 /summary @claude)', + 'slash.summarise': '总结最近消息:请提及一个代理(例如 /summarise @claude)', + 'slash.continue': '循环保护暂停后继续', + 'slash.clear': '清空当前频道消息', + + 'help.guide': '指南', + 'help.dismissHint': '按 Esc 或点击外部关闭', + 'help.agents.title': '代理', + 'help.agents.loc': '顶部状态胶囊', + 'help.agents.desc': '顶部状态胶囊显示谁在线,以及他们正在做什么。', + 'help.agents.online': '在线', + 'help.agents.working': '工作中:边框会旋转', + 'help.agents.offline': '离线', + 'help.agents.tip': '点击任意胶囊可以重命名、分配角色或修改颜色。', + 'help.jobs.title': '任务', + 'help.jobs.loc': '悬停消息 + 侧边栏', + 'help.jobs.desc': '任务可以把聊天内容变成可追踪的工作。任何消息都可以转成任务。', + 'help.jobs.sample': '认证模块需要先重构,才能加入 SSO 支持。', + 'help.jobs.tip1': '悬停任意消息可看到“转为任务”,并打开任务侧边栏。', + 'help.jobs.sidebar': '任务侧边栏', + 'help.jobs.jobTitle': '认证重构', + 'help.jobs.tip2': '任务位于侧边栏。每个任务都有自己的线程,并可从待办追踪到进行中、已关闭。', + 'help.rules.title': '规则', + 'help.rules.loc': '右上角面板', + 'help.rules.desc': '规则告诉代理在这个房间里应该如何工作。启用的规则会自动生效。', + 'help.rules.sampleDraft': '提交前运行测试', + 'help.rules.sampleActive': '改动前总是先说明你的推理', + 'help.rules.draft': '草稿', + 'help.rules.tip': '启用 = 代理会遵守。草稿 = 暂未启用。你和代理都可以添加规则。', + 'help.channels.title': '频道', + 'help.channels.loc': '顶部栏', + 'help.channels.desc': '按主题拆分对话。每个频道都有自己的消息历史。', + 'help.channels.tip': '点击 + 创建新频道。任何频道里都可以提及代理。', + 'help.mentions.title': '提及', + 'help.mentions.loc': '输入框上方胶囊', + 'help.mentions.desc': '在输入框里输入 @ 可以提及代理。输入框上方的胶囊可预先选择要发送给谁,发送时会自动提及。', + 'help.mentions.tip': '被提及的代理会收到触发,并在频道中回复。', + 'help.sessions.title': '会话', + 'help.sessions.loc': '播放按钮', + 'help.sessions.desc': '会话是带阶段和角色的多代理结构化工作流。启动一个会话即可让代理围绕共同目标协作。', + 'help.sessions.brainstorm': '头脑风暴', + 'help.sessions.brainstormDesc': '所有代理参与的自由发散', + 'help.sessions.codeReview': '代码审查', + 'help.sessions.codeReviewDesc': '带阶段和角色的结构化审查', + 'help.sessions.designReview': '设计审查', + 'help.sessions.designReviewDesc': '评审并迭代设计决策', + 'help.sessions.custom': '自定义会话', + 'help.sessions.customDesc': '描述一个目标,代理会自行组织成结构化工作流。', + 'help.sessions.tip': '点击输入框旁边的播放按钮开始。', + 'help.scheduling.title': '定时', + 'help.scheduling.loc': '时钟按钮', + 'help.scheduling.desc': '把任意消息安排到稍后发送,或设置按时间重复的提示。', + 'help.scheduling.sample1': '@codex 检查部署状态', + 'help.scheduling.sample2': '@claude 总结夜间变更', + 'help.scheduling.recur': '每 1 小时', + 'help.scheduling.tomorrow9': '明天上午 9 点', + 'help.scheduling.tip': '点击发送按钮旁边的时钟,可以设置一次性或重复定时消息。', + 'agent.displayTitle': '\u663e\u793a\u6807\u9898', + 'agent.mentionId': '\u63d0\u53ca ID: @{name}', + }, + }; + + function normalizeLang(value) { + if (value === 'zh-CN' || value === 'zhCN' || value === 'zh') return 'zh-CN'; + if (value === 'en') return 'en'; + return 'auto'; + } + + function resolveLanguage(pref) { + const normalized = normalizeLang(pref); + if (normalized === 'zh-CN' || normalized === 'en') return normalized; + const nav = (navigator.languages && navigator.languages[0]) || navigator.language || ''; + return /^zh\b/i.test(nav) ? 'zh-CN' : 'en'; + } + + function getLanguagePreference() { + return normalizeLang(localStorage.getItem(STORAGE_KEY) || 'auto'); + } + + function getLanguage() { + return resolveLanguage(getLanguagePreference()); + } + + function interpolate(str, vars) { + if (!vars) return str; + return str.replace(/\{(\w+)\}/g, (m, key) => + Object.prototype.hasOwnProperty.call(vars, key) ? String(vars[key]) : m + ); + } + + function t(key, vars) { + const lang = getLanguage(); + const table = lang === 'zh-CN' ? I18N_STRINGS.zhCN : I18N_STRINGS.en; + const fallback = I18N_STRINGS.en; + return interpolate(table[key] || fallback[key] || key, vars); + } + + function applyTranslations(root) { + const scope = root || document; + document.documentElement.lang = getLanguage(); + + scope.querySelectorAll('[data-i18n]').forEach(el => { + el.textContent = t(el.dataset.i18n); + }); + scope.querySelectorAll('[data-i18n-html]').forEach(el => { + el.innerHTML = t(el.dataset.i18nHtml); + }); + scope.querySelectorAll('[data-i18n-title]').forEach(el => { + el.title = t(el.dataset.i18nTitle); + }); + scope.querySelectorAll('[data-i18n-placeholder]').forEach(el => { + el.placeholder = t(el.dataset.i18nPlaceholder); + }); + scope.querySelectorAll('[data-i18n-aria-label]').forEach(el => { + el.setAttribute('aria-label', t(el.dataset.i18nAriaLabel)); + }); + + const selector = document.getElementById('setting-language'); + if (selector) selector.value = getLanguagePreference(); + } + + function setLanguage(lang) { + const pref = normalizeLang(lang); + localStorage.setItem(STORAGE_KEY, pref); + applyTranslations(document); + document.dispatchEvent(new CustomEvent('i18n:change', { + detail: { language: getLanguage(), preference: pref }, + })); + // Existing messages and sidebar cards are rendered HTML. Reloading keeps + // the switch reliable without introducing a client-side virtual DOM. + setTimeout(() => window.location.reload(), 50); + } + + window.I18N_STRINGS = I18N_STRINGS; + window.t = t; + window.setLanguage = setLanguage; + window.getLanguage = getLanguage; + window.getLanguagePreference = getLanguagePreference; + window.applyTranslations = applyTranslations; + + document.addEventListener('DOMContentLoaded', () => { + applyTranslations(document); + const selector = document.getElementById('setting-language'); + if (selector) selector.addEventListener('change', () => setLanguage(selector.value)); + }); +})(); diff --git a/static/index.html b/static/index.html index 955feb87..af8192e2 100644 --- a/static/index.html +++ b/static/index.html @@ -4,9 +4,12 @@ agentchattr - - - + + + + @@ -15,7 +18,7 @@

agentchattr

-
+ Discord @@ -23,33 +26,33 @@

agentchattr

- - - - - @@ -81,9 +99,9 @@

agentchattr

- +
- +
- - + +
- - + + + + +
@@ -121,10 +139,10 @@

agentchattr

- +
- - + +
@@ -132,7 +150,7 @@

agentchattr

@@ -141,11 +159,11 @@

agentchattr

- +
- Support development + Support development
@@ -156,8 +174,8 @@

agentchattr

- - + +
@@ -165,8 +183,8 @@

agentchattr