diff --git a/README.md b/README.md index a4f3ae9..5c013f6 100644 --- a/README.md +++ b/README.md @@ -85,8 +85,14 @@ Also delete config, logs, and the virtualenv: | `port` | Default `8765`. | | `device` | `auto`, `mps`, `cuda`, or `cpu`. `auto` falls through if warmup fails. | | `log_prompts` | Write the raw prompt into `data/routes.jsonl`. | -| `request_timeout` | Seconds the hook waits for the service. Default `2.5`. | +| `request_timeout` | Seconds the prompt hook waits for the service. Default `2.5`. | +| `event_timeout` | Seconds a tool hook waits. Default `1.0`. | | `max_prompt_chars` | Max characters sent to the model. Longer prompts keep the head and tail. | +| `trajectory` | Inject a short hint after tool calls, and before an edit of unread code. | +| `gate` | Tighten risky Bash and edit calls. Never returns allow. | +| `stop_check` | Ask the agent to verify after edits before it stops. | +| `skills` | Suggest at most one installed skill on each user prompt. | +| `skill_inject` | `suggest` names the skill. `content` attaches `SKILL.md` once per session. | Restart after config or server changes: `laya-router restart`. @@ -103,6 +109,20 @@ Every call is appended to `~/.laya-router/data/routes.jsonl` (timestamp, client, If the service is down, the hook prints `{}` and exits 0. Codex and Claude Code continue. The error is appended to `~/.laya-router/logs/hook.log`. +## During a turn + +The same hook command also listens for `PreToolUse`, `PostToolUse`, and `Stop`. The resident service keeps the session trace in memory. + +- `PreToolUse` on Bash or an edit: a high risk is denied; a medium risk asks on Claude Code and warns on Codex, because Codex rejects `permissionDecision: ask`. An edit of a file that was not read gets a short hint. +- `PostToolUse`: every Bash or edit checkpoint gets a short hint. The same hint is not repeated. Two failures in a row, or a high `is_stuck`, tells the agent to stop and replan. `Read`, `Grep`, and `Glob` are recorded and stay quiet. +- `Stop`: if code changed and nothing verified it, the hook blocks once so the agent keeps going. `stop_hook_active` is always allowed through. + +On `UserPromptSubmit`, a second pair of questions can name one installed skill. The roster is every `SKILL.md` under `~/.claude/skills`, `~/.codex/skills`, `~/.agents/skills`, the project skill directories, and Claude plugin install paths. A skill with `disable-model-invocation: true` is skipped. The first pass ranks the roster and gates on whether a skill is warranted. The second pass re-reads the top three. Either threshold below 0.30 suggests nothing. + +`laya-mcp` exposes `laya_judge` over stdio. Allow `mcp__laya` in Claude Code if you want the agent to call it. Install registers it with `claude mcp add` and `codex mcp add` when those CLIs are on `PATH`. + +The first Codex session must trust the new hook entries. Run `/hooks` again after upgrading. + ## Develop ```bash @@ -115,7 +135,7 @@ Unit tests do not load the model. After the service is up, `laya-router route` r | --- | --- | --- | | `src/laya_router/questions.py` | Question text. Bump `QUESTIONS_VERSION` when the wording changes. | `laya-router restart` | | `src/laya_router/render.py` | Text injected into the agent | `laya-router restart`, then the next prompt | -| `src/laya_router/hook.py` | Timeout, slash-command skip, fail-open | `laya-router restart`, then the next prompt | +| `src/laya_router/hook.py` | Prompt and tool events, timeout, fail-open | `laya-router restart`, then the next prompt | | `src/laya_router/server.py` | Model load, `/route`, dashboard | `laya-router restart` | | `src/laya_router/dashboard.html` | Browser UI | `laya-router restart` | @@ -129,7 +149,7 @@ The service has no authentication. It listens on `127.0.0.1` only. `/route` acce `routes.jsonl` stores the raw prompt by default, including anything you paste into Codex or Claude Code. Set `log_prompts = false` to keep only a SHA-256. The runtime directory is `~/.laya-router/`, outside this repo. Do not commit it. -The installer rewrites `~/.codex/hooks.json` and merges a `UserPromptSubmit` entry into `~/.claude/settings.json`. That Claude file often holds API credentials. The tool preserves existing keys, writes a timestamped `*.bak-*` copy before changing a file, and removes only its own hook on uninstall. Read the diff before you publish a dotfile. +The installer rewrites `~/.codex/hooks.json` and merges `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, and `Stop` entries into `~/.claude/settings.json`. That Claude file often holds API credentials. The tool preserves existing keys, writes a timestamped `*.bak-*` copy before changing a file, and removes only its own hook on uninstall. Read the diff before you publish a dotfile. A web page on another origin cannot read these responses: the server does not send `Access-Control-Allow-Origin`. Keep the default bind address anyway. diff --git a/README.zh-CN.md b/README.zh-CN.md index cf5ebbe..35f11b3 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -85,8 +85,14 @@ laya-router start | `port` | 默认 `8765`。 | | `device` | `auto`、`mps`、`cuda` 或 `cpu`。`auto` 预热失败会回退。 | | `log_prompts` | 是否把原文写入 `data/routes.jsonl`。 | -| `request_timeout` | Hook 等待服务的秒数,默认 `2.5`。 | +| `request_timeout` | 用户 prompt 的 hook 等待秒数,默认 `2.5`。 | +| `event_timeout` | 工具 hook 的等待秒数,默认 `1.0`。 | | `max_prompt_chars` | 送进模型的最大字符数。超长时保留头尾。 | +| `trajectory` | 工具调用之后,以及改未读过的文件之前,注入一句短提示。 | +| `gate` | 收紧有风险的 Bash 和编辑。不会返回 allow。 | +| `stop_check` | 改完代码却没验证时,不让这一轮停下来。 | +| `skills` | 每条用户 prompt 最多推荐一个已安装的 skill。 | +| `skill_inject` | `suggest` 只给名字和路径。`content` 在同一会话里附上一次 `SKILL.md` 正文。 | 改完配置或服务端代码后执行 `laya-router restart`。 @@ -103,6 +109,20 @@ laya-router start 服务挂了时,hook 输出 `{}` 并退出 0,Codex 和 Claude Code 照常工作。原因写在 `~/.laya-router/logs/hook.log`。 +## 运行过程中 + +同一条 hook 命令也监听 `PreToolUse`、`PostToolUse` 和 `Stop`。会话轨迹放在常驻服务的内存里。 + +- `PreToolUse` 遇到 Bash 或编辑:风险高就拒绝;风险中等时,Claude Code 会询问,Codex 只注入警告,因为 Codex 不接受 `permissionDecision: ask`。要改一个还没读过的文件时,会提示先读。 +- `PostToolUse`:每次 Bash 或编辑都会注入一句短提示。连续两条完全相同的提示会去掉。连续失败两次,或者 `is_stuck` 很高,就要求先停下来重新规划。`Read`、`Grep`、`Glob` 只记账,不插话。 +- `Stop`:代码改过、之后没有跑测试或构建时,hook 最多拦一次,让 agent 继续做验证。`stop_hook_active` 一律放行,避免死循环。 + +`UserPromptSubmit` 上还会再做一轮 skill 推荐,最多一个。名单来自 `~/.claude/skills`、`~/.codex/skills`、`~/.agents/skills`、项目里的 skill 目录,以及 Claude 插件的安装路径。frontmatter 里 `disable-model-invocation: true` 的 skill 会跳过。第一问给全部候选排序,并用三个是否题决定这条 prompt 是否需要 skill。第二问只重读前三名。任一阈值低于 0.30 就不推荐。 + +`laya-mcp` 用 stdio 提供 `laya_judge`。想让模型自己调用,在 Claude Code 里允许 `mcp__laya`。安装时如果 `PATH` 上有对应命令,会执行 `claude mcp add` 和 `codex mcp add`。 + +升级之后,Codex 要再跑一次 `/hooks`,信任新加的 hook。 + ## 开发 ```bash @@ -115,7 +135,7 @@ PYTHONPATH=src uv run --no-project --with pytest python -m pytest | --- | --- | --- | | `src/laya_router/questions.py` | 问题措辞。改措辞时把 `QUESTIONS_VERSION` 加一。 | `laya-router restart` | | `src/laya_router/render.py` | 注入给 Agent 的文本 | `laya-router restart`,然后下一条 prompt | -| `src/laya_router/hook.py` | 超时、跳过斜杠命令、失败放行 | `laya-router restart`,然后下一条 prompt | +| `src/laya_router/hook.py` | 用户 prompt 和工具事件、超时、失败放行 | `laya-router restart`,然后下一条 prompt | | `src/laya_router/server.py` | 加载模型、`/route`、看板 | `laya-router restart` | | `src/laya_router/dashboard.html` | 浏览器页面 | `laya-router restart` | @@ -129,7 +149,7 @@ PYTHONPATH=src uv run --no-project --with pytest python -m pytest `routes.jsonl` 默认保存完整 prompt,包括你贴进 Codex 或 Claude Code 的内容。`log_prompts = false` 时只保存 SHA-256。运行目录是仓库外面的 `~/.laya-router/`,不要把它提交进 git。 -安装程序会改写 `~/.codex/hooks.json`,并往 `~/.claude/settings.json` 合并一条 `UserPromptSubmit`。Claude 的这个文件里常常有 API 凭证。程序会保留原有字段,改文件前写一份带时间戳的 `*.bak-*`,卸载时只删自己的 hook。公开任何本机配置之前先看 diff。 +安装程序会改写 `~/.codex/hooks.json`,并往 `~/.claude/settings.json` 合并 `UserPromptSubmit`、`PreToolUse`、`PostToolUse` 和 `Stop`。Claude 的这个文件里常常有 API 凭证。程序会保留原有字段,改文件前写一份带时间戳的 `*.bak-*`,卸载时只删自己的 hook。公开任何本机配置之前先看 diff。 其他网站的页面读不到这些接口:服务没有发送 `Access-Control-Allow-Origin`。监听地址仍然应该保持默认。 diff --git a/pyproject.toml b/pyproject.toml index d60bdf1..3628f81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ [project.scripts] laya-router = "laya_router.cli:main" laya-hook = "laya_router.hook:main" +laya-mcp = "laya_router.mcp:main" [dependency-groups] dev = ["pytest>=8"] diff --git a/src/laya_router/cli.py b/src/laya_router/cli.py index 52fd4d1..6796efe 100644 --- a/src/laya_router/cli.py +++ b/src/laya_router/cli.py @@ -63,6 +63,109 @@ def _settings(): return load_settings(home) +def _bin_link_targets(settings) -> list[tuple[str, Path]]: + """Pairs of (link name, source binary in the venv) exposed on the user's PATH.""" + return [ + ("laya-router", settings.router_bin), + ("laya-hook", settings.hook_bin), + ("laya-mcp", settings.mcp_bin), + ] + + +def _user_path_bin_dir() -> Path | None: + """Pick the first writable PATH directory that is not a system location. + + Prefers ``~/.local/bin`` per the pip / pipx convention. Falls back to + scanning ``$PATH`` left-to-right, so users with a customised PATH still get + a sensible target. Returns ``None`` when no writable candidate exists. + + ``LAYA_ROUTER_BIN_DIR`` overrides the directory entirely — useful for + shared / locked-down hosts where ``~/.local/bin`` is not writable and the + caller wants to install elsewhere. + """ + override = os.environ.get("LAYA_ROUTER_BIN_DIR", "").strip() + if override: + path = Path(override).expanduser() + if path.is_dir() and os.access(path, os.W_OK): + return path + return None + preferred = Path.home() / ".local" / "bin" + if preferred.is_dir() and os.access(preferred, os.W_OK): + return preferred + for entry in os.environ.get("PATH", "").split(":"): + if not entry: + continue + path = Path(entry) + if not path.is_dir() or not os.access(path, os.W_OK): + continue + resolved = path.resolve() if path.exists() else path + if str(resolved).startswith(("/usr/", "/bin/", "/sbin/", "/System/")): + continue + return path + return None + + +def ensure_path_symlinks(settings, *, target_dir: Path | None = None) -> None: + """Expose the venv's CLI binaries on the user's PATH via symlinks. + + The venv lives under ``~/.laya-router/.venv/bin/``, which is normally off + PATH. Without a symlink, ``laya-router restart`` and friends only work + when invoked through the absolute path. + """ + if target_dir is None: + target_dir = _user_path_bin_dir() + if target_dir is None: + print( + "警告: PATH 上没有可写的目录;'laya-router' 命令不会直接可用。", + file=sys.stderr, + ) + print( + " 手动设置: export PATH=\"$HOME/.laya-router/.venv/bin:$PATH\"", + file=sys.stderr, + ) + return + _apply_path_symlinks(settings, target_dir) + + +def remove_path_symlinks(settings, *, target_dir: Path | None = None) -> None: + """Remove the symlinks installed by ``ensure_path_symlinks``.""" + if target_dir is None: + target_dir = _user_path_bin_dir() + if target_dir is None: + return + _remove_path_symlinks(settings, target_dir) + + +def _apply_path_symlinks(settings, target_dir: Path) -> None: + """Idempotent: creates / repairs the venv symlinks inside ``target_dir``.""" + for name, source in _bin_link_targets(settings): + if not source.is_file(): + continue + link = target_dir / name + try: + if link.is_symlink(): + if link.resolve() == source.resolve(): + continue + link.unlink() + elif link.exists(): + print(f"跳过 {link}: 已被占用(不是符号链接)", file=sys.stderr) + continue + link.symlink_to(source) + print(f"{link} -> {source}") + except OSError as exc: + print(f"建立 {link} 失败: {exc}", file=sys.stderr) + + +def _remove_path_symlinks(settings, target_dir: Path) -> None: + for name, _source in _bin_link_targets(settings): + link = target_dir / name + try: + if link.is_symlink(): + link.unlink() + except OSError as exc: + print(f"移除 {link} 失败: {exc}", file=sys.stderr) + + def _clients(only: list[str] | None, *, detected_only: bool): from laya_router.clients import ClaudeClient, CodexClient @@ -161,6 +264,22 @@ def _print_log_tail(settings, lines: int = 40) -> None: print("\n".join(content[-lines:]), file=sys.stderr) +def _register_client_mcp(name: str, settings) -> None: + from laya_router.clients.base import register_mcp + + if not settings.mcp_bin.is_file(): + print(f"{name}: 跳过 MCP 注册,找不到 {settings.mcp_bin}", file=sys.stderr) + return + if name == "claude": + register_mcp( + "claude", + ["mcp", "add", "--scope", "user", "--transport", "stdio", "laya", "--", str(settings.mcp_bin)], + ) + elif name == "codex": + register_mcp("codex", ["mcp", "add", "laya", "--", str(settings.mcp_bin)]) + print(f"{name}: 已尝试注册 MCP 工具 laya_judge(失败不影响 hook)") + + def cmd_install(args: argparse.Namespace) -> int: settings = _settings() sync_source(settings) @@ -184,6 +303,7 @@ def cmd_install(args: argparse.Namespace) -> int: _print_log_tail(settings) return 1 print(f"服务就绪: {settings.base_url} device={health.get('device')}", flush=True) + ensure_path_symlinks(settings) clients = _clients(args.only, detected_only=True) if not clients: @@ -196,6 +316,7 @@ def cmd_install(args: argparse.Namespace) -> int: return 1 state = "已更新" if changed else "已是最新" print(f"{client.name}: hook {state}") + _register_client_mcp(client.name, settings) print("冒烟测试...", flush=True) smoke = argparse.Namespace( @@ -206,8 +327,8 @@ def cmd_install(args: argparse.Namespace) -> int: return 1 print() print("安装完成。") - print("- Codex: 启动 codex 后运行 /hooks,首次需要信任这条 hook。") - print("- Claude Code: 新会话会自动加载。") + print("- Codex: 启动 codex 后运行 /hooks,首次需要信任这条 hook。新增的 PreToolUse、PostToolUse、Stop 也要信任一次。") + print("- Claude Code: 新会话会自动加载。若要让模型主动调用判定,在权限里允许 mcp__laya。") print(f"- 路由记录: {settings.routes_path}") print("- 查看服务: laya-router status") return 0 @@ -223,6 +344,7 @@ def cmd_uninstall(args: argparse.Namespace) -> int: return 1 print(f"{client.name}: 已移除 hook") uninstall_agent() + remove_path_symlinks(settings) print("已停止并移除 launchd 服务。") if args.purge: home = settings.home.resolve() diff --git a/src/laya_router/clients/base.py b/src/laya_router/clients/base.py index 166ff0e..5a32f72 100644 --- a/src/laya_router/clients/base.py +++ b/src/laya_router/clients/base.py @@ -21,6 +21,20 @@ def is_our_command(command: str) -> bool: return "laya-hook" in command +def register_mcp(binary: str, args: list[str]) -> None: + """Best-effort MCP registration. A missing CLI or a rejected command is not fatal.""" + import shutil + import subprocess + + exe = shutil.which(binary) + if exe is None: + return + try: + subprocess.run([exe, *args], check=False, timeout=30) + except (OSError, subprocess.TimeoutExpired): + return + + def backup_file(path: Path) -> Path | None: if not path.is_file(): return None @@ -64,20 +78,22 @@ def _hook_entry(command: str, *, timeout: int, status_message: str | None) -> di return entry -def upsert_user_prompt_hook( +def upsert_hook( config: dict, + event_name: str, command: str, *, + matcher: str | None = None, timeout: int = 5, status_message: str | None = None, ) -> dict: - """Insert or replace this project's UserPromptSubmit hook. Other hooks stay.""" + """Insert or replace this project's hook for one event. Other hooks stay.""" hooks = config.setdefault("hooks", {}) if not isinstance(hooks, dict): raise ValueError("hooks 字段必须是对象") - event = hooks.get("UserPromptSubmit", []) + event = hooks.get(event_name, []) if not isinstance(event, list): - raise ValueError("hooks.UserPromptSubmit 必须是数组") + raise ValueError(f"hooks.{event_name} 必须是数组") kept: list = [] for group in event: @@ -96,19 +112,63 @@ def upsert_user_prompt_hook( if remaining: kept.append({**group, "hooks": remaining}) - kept.append({"hooks": [_hook_entry(command, timeout=timeout, status_message=status_message)]}) - hooks["UserPromptSubmit"] = kept + group: dict = {"hooks": [_hook_entry(command, timeout=timeout, status_message=status_message)]} + if matcher: + group["matcher"] = matcher + kept.append(group) + hooks[event_name] = kept return config -def remove_user_prompt_hook(config: dict) -> dict: - hooks = config.get("hooks") - if not isinstance(hooks, dict): - return config - event = hooks.get("UserPromptSubmit") - if not isinstance(event, list): - return config +def upsert_user_prompt_hook( + config: dict, + command: str, + *, + timeout: int = 5, + status_message: str | None = None, +) -> dict: + """Insert or replace this project's UserPromptSubmit hook. Other hooks stay.""" + return upsert_hook( + config, + "UserPromptSubmit", + command, + timeout=timeout, + status_message=status_message, + ) + +PRE_TOOL_MATCHER = "Bash|Edit|Write|MultiEdit|apply_patch" +POST_TOOL_MATCHER = "Bash|Edit|Write|MultiEdit|apply_patch|Read|Grep|Glob" + + +def install_router_hooks( + config: dict, + command: str, + *, + prompt_status: str | None, +) -> dict: + upsert_hook(config, "UserPromptSubmit", command, timeout=5, status_message=prompt_status) + upsert_hook( + config, + "PreToolUse", + command, + matcher=PRE_TOOL_MATCHER, + timeout=5, + status_message="Laya check", + ) + upsert_hook( + config, + "PostToolUse", + command, + matcher=POST_TOOL_MATCHER, + timeout=5, + status_message="Laya check", + ) + upsert_hook(config, "Stop", command, timeout=5, status_message="Laya check") + return config + + +def _without_our_hooks(event: list) -> list: kept: list = [] for group in event: if not isinstance(group, dict) or not isinstance(group.get("hooks"), list): @@ -121,10 +181,26 @@ def remove_user_prompt_hook(config: dict) -> dict: ] if remaining: kept.append({**group, "hooks": remaining}) - if kept: - hooks["UserPromptSubmit"] = kept - else: - hooks.pop("UserPromptSubmit", None) + return kept + + +def remove_user_prompt_hook(config: dict) -> dict: + return remove_our_hooks(config) + + +def remove_our_hooks(config: dict) -> dict: + hooks = config.get("hooks") + if not isinstance(hooks, dict): + return config + for event_name in list(hooks): + event = hooks.get(event_name) + if not isinstance(event, list): + continue + kept = _without_our_hooks(event) + if kept: + hooks[event_name] = kept + else: + hooks.pop(event_name, None) if not hooks: config.pop("hooks", None) return config diff --git a/src/laya_router/clients/claude.py b/src/laya_router/clients/claude.py index 7499744..09b73cf 100644 --- a/src/laya_router/clients/claude.py +++ b/src/laya_router/clients/claude.py @@ -5,7 +5,7 @@ from __future__ import annotations import shutil from pathlib import Path -from laya_router.clients.base import read_json, remove_user_prompt_hook, upsert_user_prompt_hook, write_json +from laya_router.clients.base import install_router_hooks, read_json, remove_our_hooks, write_json from laya_router.config import Settings, hook_command @@ -20,17 +20,12 @@ class ClaudeClient: def install(self, settings: Settings) -> bool: config = read_json(self.config_path) - upsert_user_prompt_hook( - config, - hook_command(settings, self.name), - timeout=5, - status_message=None, - ) + install_router_hooks(config, hook_command(settings, self.name), prompt_status=None) return write_json(self.config_path, config) def uninstall(self) -> bool: if not self.config_path.is_file(): return False config = read_json(self.config_path) - remove_user_prompt_hook(config) + remove_our_hooks(config) return write_json(self.config_path, config) diff --git a/src/laya_router/clients/codex.py b/src/laya_router/clients/codex.py index b36950b..6d76dbe 100644 --- a/src/laya_router/clients/codex.py +++ b/src/laya_router/clients/codex.py @@ -11,7 +11,7 @@ import shutil import subprocess from pathlib import Path -from laya_router.clients.base import read_json, remove_user_prompt_hook, upsert_user_prompt_hook, write_json +from laya_router.clients.base import install_router_hooks, read_json, remove_our_hooks, write_json from laya_router.config import Settings, hook_command @@ -40,11 +40,10 @@ class CodexClient: self.ensure_hooks_feature() config = read_json(self.hooks_path) config.setdefault("description", "Laya local System-1 router") - upsert_user_prompt_hook( + install_router_hooks( config, hook_command(settings, self.name), - timeout=5, - status_message="Laya routing", + prompt_status="Laya routing", ) return write_json(self.hooks_path, config) @@ -52,7 +51,7 @@ class CodexClient: if not self.hooks_path.is_file(): return False config = read_json(self.hooks_path) - remove_user_prompt_hook(config) + remove_our_hooks(config) if config.get("description") == "Laya local System-1 router" and "hooks" not in config: config.pop("description", None) if not config: diff --git a/src/laya_router/config.py b/src/laya_router/config.py index e657cfa..1d4950f 100644 --- a/src/laya_router/config.py +++ b/src/laya_router/config.py @@ -33,8 +33,20 @@ log_prompts = true # Hook 调用本服务的超时(秒)。应小于客户端 hook timeout(5 秒)。 request_timeout = 2.5 +# 工具事件的超时。PreToolUse 会挡住每一次工具调用。 +event_timeout = 1.0 + # 送进模型的最大字符数。中英混合下大约落在 1024 token 以内,超长时保留头尾。 max_prompt_chars = 3000 + +# 运行中判定。关掉某一项后,对应事件只记轨迹、不再注入。 +trajectory = true +gate = true +stop_check = true +skills = true + +# suggest 只给出 skill 名字和路径;content 把 SKILL.md 正文附在 prompt 上。 +skill_inject = "suggest" """ @@ -55,7 +67,13 @@ class Settings: subfolder: str = "multilingual" log_prompts: bool = True request_timeout: float = 2.5 + event_timeout: float = 1.0 max_prompt_chars: int = 3000 + trajectory: bool = True + gate: bool = True + stop_check: bool = True + skills: bool = True + skill_inject: str = "suggest" @property def config_path(self) -> Path: @@ -93,6 +111,10 @@ class Settings: def router_bin(self) -> Path: return self.venv_dir / "bin" / "laya-router" + @property + def mcp_bin(self) -> Path: + return self.venv_dir / "bin" / "laya-mcp" + @property def base_url(self) -> str: return f"http://{self.host}:{self.port}" @@ -124,7 +146,15 @@ def load_settings(home: Path | None = None) -> Settings: subfolder = str(data.get("subfolder", "multilingual")) log_prompts = _as_bool(data.get("log_prompts", True), True) request_timeout = float(data.get("request_timeout", 2.5)) + event_timeout = float(data.get("event_timeout", 1.0)) max_prompt_chars = int(data.get("max_prompt_chars", 3000)) + trajectory = _as_bool(data.get("trajectory", True), True) + gate = _as_bool(data.get("gate", True), True) + stop_check = _as_bool(data.get("stop_check", True), True) + skills = _as_bool(data.get("skills", True), True) + skill_inject = str(data.get("skill_inject", "suggest")) + if skill_inject not in {"suggest", "content"}: + skill_inject = "suggest" if os.environ.get("LAYA_ROUTER_HOST"): host = os.environ["LAYA_ROUTER_HOST"] @@ -139,6 +169,8 @@ def load_settings(home: Path | None = None) -> Settings: raise ValueError(f"无效端口: {port}") if request_timeout <= 0: raise ValueError(f"request_timeout 必须大于 0: {request_timeout}") + if event_timeout <= 0: + raise ValueError(f"event_timeout 必须大于 0: {event_timeout}") if max_prompt_chars < 200: raise ValueError(f"max_prompt_chars 过小: {max_prompt_chars}") @@ -151,7 +183,13 @@ def load_settings(home: Path | None = None) -> Settings: subfolder=subfolder, log_prompts=log_prompts, request_timeout=request_timeout, + event_timeout=event_timeout, max_prompt_chars=max_prompt_chars, + trajectory=trajectory, + gate=gate, + stop_check=stop_check, + skills=skills, + skill_inject=skill_inject, ) diff --git a/src/laya_router/dashboard.html b/src/laya_router/dashboard.html index aa20a55..dc95a17 100644 --- a/src/laya_router/dashboard.html +++ b/src/laya_router/dashboard.html @@ -147,6 +147,33 @@ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); } .switch input { accent-color: var(--blue); width: 16px; height: 16px; } + .stage-controls { + display: inline-flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + } + .segmented { + display: inline-flex; + background: white; + border: 1px solid var(--line); + border-radius: 999px; + padding: 3px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + } + .segmented button { + border: 0; + background: transparent; + padding: 5px 12px; + border-radius: 999px; + font-size: 13px; + color: var(--secondary); + transition: background 0.18s, color 0.18s; + } + .segmented button.active { + background: var(--blue); + color: white; + } .empty { max-width: 36rem; color: var(--secondary); @@ -168,6 +195,15 @@ to { opacity: 1; transform: none; } } .when { margin: 0; color: var(--tertiary); font-size: 12px; } + .tag { + display: inline-block; + margin-right: 6px; + padding: 0 6px; + border-radius: 999px; + background: var(--fill); + color: var(--secondary); + font-size: 12px; + } .prompt { margin: 10px 0 16px; font-size: 17px; @@ -214,7 +250,7 @@ } @media (prefers-reduced-motion: reduce) { .card.arrive { animation: none; } - .split span, .track i { transition: none; } + .split span, .track i, .segmented button { transition: none; } } @@ -244,7 +280,28 @@ const METERS = [ ["needs_web", "先查文档"], ["needs_deep_reasoning", "先想清楚"], ]; -const state = { records: [], follow: true, selected: null, fresh: new Set() }; +const EVENTS = { + UserPromptSubmit: "用户提问", + PreToolUse: "工具前", + PostToolUse: "工具后", + Stop: "结束检查", + judge: "主动判定", +}; +const ACTIONS = { + inject: "注入", + deny: "拦截", + ask: "询问", + block: "拦截", + silent: "沉默", +}; +const SORT_KEY = "laya-router:turn-sort"; +const state = { + records: [], + follow: true, + selected: null, + fresh: new Set(), + sort: localStorage.getItem(SORT_KEY) === "asc" ? "asc" : "desc", +}; function keyOf(record) { if (record.session_id) return record.session_id; @@ -295,7 +352,7 @@ function render() { aside.innerHTML = groups.map(([key, items]) => { const last = items[items.length - 1]; const snippet = (last.prompt || "没有记下原文").replace(/\s+/g, " "); - const levelName = LEVELS.find(([id]) => id === last.level)?.[1] || last.level; + const levelName = LEVELS.find(([id]) => id === last.level)?.[1] || ACTIONS[last.action] || EVENTS[last.event] || last.level || ""; return ` + + + + - ${items.map((record, index) => { + ${ordered.map(([index, record]) => { const id = recordId(record); const arrive = state.fresh.has(id) ? "arrive" : ""; state.fresh.delete(id); const probs = record.level_probabilities || {}; const levelName = LEVELS.find(([name]) => name === record.level)?.[1] || record.level; - return `
-
-

第 ${index + 1} 次 · ${when(record.ts)} · ${record.elapsed_ms ?? "?"} 毫秒

-

${escapeHtml(record.prompt || "原文没有写入日志")}

-