diff --git a/AGENTS.md b/AGENTS.md index 68dfca2..1fc12a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,6 +78,7 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message - **SessionManager** owns session state, dialog operations, context construction, per-session work queues, and persistence coordination - **AgentLoop** is stateless across turns; it receives prepared history, calls LLM providers, executes tools, and returns one result - **WebUI management APIs** only expose allowlisted config/profile/log/storage operations; keep response limits, secret redaction, atomic config writes, and profile path allowlists intact +- **WebUI slash completion** must consume the existing `get_slash_commands` WebSocket response; do not duplicate the backend command list in frontend source - **Providers** are pure HTTP clients; no bus/session/channel awareness - **Tools** are executed by `AgentLoop`; they receive raw arguments and return string results diff --git a/README.md b/README.md index 0f902bb..2e2742c 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ http://127.0.0.1:19876/ WebUI 随二进制嵌入,不需要 Node.js、npm 或单独部署静态文件,提供: -- 在线聊天、会话创建/切换与历史回放。 +- 在线聊天、会话创建/切换、历史回放,以及基于 Gateway 实时命令清单的 `/` 斜杠命令补全。 - Cron 定时任务、最近运行记录和后台子任务状态。 - Knowledge/Timeline 记忆的分类与全文检索。 - 本地滚动日志的尾部查看、过滤和自动刷新。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 89cd5fa..417a4f6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -194,7 +194,7 @@ WebSocket 每个客户端的 writer task 是连接局部任务,由连接 handl ### WebUI 与管理 API -Gateway 在 `/` 提供随二进制编译的 HTML/CSS/JavaScript,不依赖外部 CDN 或前端运行服务。前端源码位于 `webui/`,由 Svelte 5 + Vite 构建,Bits UI 提供无样式的可访问组件原语。`build.rs` 监听前端源码、锁文件和构建配置,增量地将生产资源生成到 Cargo `OUT_DIR`,Rust 再从该目录编译嵌入;前端产物不进入仓库,最终用户使用发布二进制时不需要 Node.js。浏览器聊天继续使用 `/ws` 和 `cli_chat` 渠道,因此复用现有 dialog scope、每会话串行 worker、历史持久化和出站 lane;WebUI 不直接调用 Provider 或 SessionManager。 +Gateway 在 `/` 提供随二进制编译的 HTML/CSS/JavaScript,不依赖外部 CDN 或前端运行服务。前端源码位于 `webui/`,由 Svelte 5 + Vite 构建,Bits UI 提供无样式的可访问组件原语。`build.rs` 监听前端源码、锁文件和构建配置,增量地将生产资源生成到 Cargo `OUT_DIR`,Rust 再从该目录编译嵌入;前端产物不进入仓库,最终用户使用发布二进制时不需要 Node.js。浏览器聊天继续使用 `/ws` 和 `cli_chat` 渠道,因此复用现有 dialog scope、每会话串行 worker、历史持久化和出站 lane;斜杠命令补全通过 `get_slash_commands` 获取 Gateway 的实时命令与别名清单,不在前端重复定义;WebUI 不直接调用 Provider 或 SessionManager。 同源 `/api/*` 管理接口只提供显式白名单能力: diff --git a/resources/skills/about-picobot/references/commands.md b/resources/skills/about-picobot/references/commands.md index 406bc19..a26246d 100644 --- a/resources/skills/about-picobot/references/commands.md +++ b/resources/skills/about-picobot/references/commands.md @@ -52,4 +52,4 @@ cargo test --test test_tool_calling -- --ignored `test_scheduler` 和 `test_request_format` 不需要 API Key,也没有标记 `#[ignore]`。只有会真实调用 Provider 的测试需要从 `tests/test.env.example` 创建 `tests/test.env` 后使用 `-- --ignored`。 -最终用户使用 WebUI 不需要单独构建;开发源码采用 Svelte 5、Vite 和 Bits UI,`cargo build` 会增量生成前端到 Cargo `OUT_DIR` 并嵌入二进制,生成产物不提交。WebUI 支持在线聊天、日志、任务、记忆以及 `config.json`、`USER.md`、`AGENTS.md` 编辑。它与 Gateway 属于同一信任边界;非回环部署需要在外层配置 TLS 和认证。 +最终用户使用 WebUI 不需要单独构建;开发源码采用 Svelte 5、Vite 和 Bits UI,`cargo build` 会增量生成前端到 Cargo `OUT_DIR` 并嵌入二进制,生成产物不提交。WebUI 支持在线聊天、动态斜杠命令补全、日志、任务、记忆以及 `config.json`、`USER.md`、`AGENTS.md` 编辑。它与 Gateway 属于同一信任边界;非回环部署需要在外层配置 TLS 和认证。 diff --git a/src/session/session.rs b/src/session/session.rs index 7e6a7ab..0dd35a4 100644 --- a/src/session/session.rs +++ b/src/session/session.rs @@ -984,6 +984,17 @@ pub static SLASH_COMMANDS: &[SlashCommand] = &[ }, ]; +fn resolve_slash_command(command: &str) -> Option<&'static SlashCommand> { + let command = command.strip_prefix('/').unwrap_or(command); + SLASH_COMMANDS.iter().find(|candidate| { + candidate.name == command + || candidate + .aliases + .iter() + .any(|alias| alias.strip_prefix('/') == Some(command)) + }) +} + impl SessionManager { fn worker_deps(&self) -> AgentWorkerDeps { AgentWorkerDeps { @@ -1123,9 +1134,7 @@ impl SessionManager { chat_id: &str, current_session_id: Option<&UnifiedSessionId>, ) -> Result<(Option, String), AgentError> { - let cmd = SLASH_COMMANDS - .iter() - .find(|c| c.name == command) + let cmd = resolve_slash_command(command) .ok_or_else(|| AgentError::Other(format!("Unknown command: {}", command)))?; tracing::info!(cmd = %cmd.name, args = ?args, "Executing slash command"); @@ -1348,7 +1357,7 @@ impl SessionManager { Ok((None, "No active session.".to_string())) } } - "?" | "help" => { + "?" => { let lines: Vec = SLASH_COMMANDS .iter() .map(|c| format!(" {} - {}", c.aliases.join(", "), c.description)) @@ -2553,3 +2562,25 @@ fn format_task_notification( crate::agent::TaskStatus::TimedOut => format!("📋 后台任务超时\n\n任务 ID: {}", task_id), } } + +#[cfg(test)] +mod slash_command_tests { + use super::resolve_slash_command; + + #[test] + fn aliases_resolve_to_their_canonical_command() { + assert_eq!( + resolve_slash_command("?").map(|command| command.name), + Some("?") + ); + assert_eq!( + resolve_slash_command("help").map(|command| command.name), + Some("?") + ); + assert_eq!( + resolve_slash_command("/help").map(|command| command.name), + Some("?") + ); + assert!(resolve_slash_command("unknown").is_none()); + } +} diff --git a/webui/src/pages/ChatPage.svelte b/webui/src/pages/ChatPage.svelte index 1b1e7cb..5ccaa8f 100644 --- a/webui/src/pages/ChatPage.svelte +++ b/webui/src/pages/ChatPage.svelte @@ -11,6 +11,9 @@ let messages = $state([]); let search = $state(""); let draft = $state(""); + let commands = $state([]); + let selectedCommand = $state(0); + let commandMenuDismissed = $state(false); let thinking = $state(false); let messageBox; let input; @@ -18,6 +21,23 @@ let stopped = false; const currentSession = $derived(sessions.find((item) => item.session_id === currentId)); const filteredSessions = $derived(sessions.filter((item) => item.title.toLowerCase().includes(search.toLowerCase()))); + const commandQuery = $derived( + !commandMenuDismissed && draft.startsWith("/") && !/[\s]/.test(draft) + ? draft.toLowerCase() + : null + ); + const commandSuggestions = $derived.by(() => { + if (commandQuery === null) return []; + const term = commandQuery.slice(1); + return commands.flatMap((command) => { + const aliases = command.aliases?.length ? command.aliases : [`/${command.name}`]; + return aliases + .filter((alias) => alias.toLowerCase().startsWith(commandQuery) + || command.name.toLowerCase().startsWith(term) + || command.description.toLowerCase().includes(term)) + .map((alias) => ({ ...command, alias })); + }); + }); function send(frame) { if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(frame)); @@ -31,6 +51,7 @@ ws.onopen = () => { connected = true; send({ type: "list_sessions", include_archived: false }); + send({ type: "get_slash_commands" }); }; ws.onerror = () => ws.close(); ws.onclose = () => { @@ -63,6 +84,10 @@ scrollToBottom(); } break; + case "slash_commands_list": + commands = frame.commands || []; + selectedCommand = 0; + break; case "assistant_response": thinking = false; if (!frame.session_id || frame.session_id === currentId) appendMessage(frame.role || "assistant", frame.content); @@ -100,10 +125,54 @@ thinking = true; send({ type: "user_input", content }); draft = ""; + commandMenuDismissed = false; + selectedCommand = 0; + if (input) input.style.height = "auto"; + } + + async function moveCommandSelection(offset) { + const length = commandSuggestions.length; + if (!length) return; + selectedCommand = (selectedCommand + offset + length) % length; + await tick(); + document.getElementById(`slash-command-${selectedCommand}`)?.scrollIntoView({ block: "nearest" }); + } + + async function completeCommand(index = selectedCommand) { + const command = commandSuggestions[index]; + if (!command) return; + draft = `${command.alias} `; + selectedCommand = 0; + commandMenuDismissed = true; + await tick(); + input?.focus(); if (input) input.style.height = "auto"; } function keydown(event) { + if (event.isComposing) return; + if (commandSuggestions.length) { + if (event.key === "ArrowDown") { + event.preventDefault(); + moveCommandSelection(1); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + moveCommandSelection(-1); + return; + } + if (event.key === "Tab" || (event.key === "Enter" && !event.shiftKey)) { + event.preventDefault(); + completeCommand(); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + commandMenuDismissed = true; + return; + } + } if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); submit(); @@ -115,6 +184,12 @@ event.currentTarget.style.height = `${Math.min(event.currentTarget.scrollHeight, 180)}px`; } + function inputChanged(event) { + commandMenuDismissed = false; + selectedCommand = 0; + resize(event); + } + onMount(() => { connect(); return () => { @@ -157,9 +232,39 @@ {#if thinking}
P
正在思考…
{/if}
{ event.preventDefault(); submit(); }}> - + {#if commandSuggestions.length} +
+
斜杠命令↑↓ 选择 · Tab/Enter 补全 · Esc 关闭
+ {#each commandSuggestions as command, index (`${command.name}:${command.alias}`)} + + {/each} +
+ {/if} + - {connected ? "已连接" : "已断开,正在重连"}支持 Slash Command + {connected ? "已连接" : "已断开,正在重连"}/ 打开命令 · Shift+Enter 换行
diff --git a/webui/src/styles.css b/webui/src/styles.css index 09f08f0..b4b5b21 100644 --- a/webui/src/styles.css +++ b/webui/src/styles.css @@ -80,11 +80,18 @@ main { min-width: 0; height: 100vh; display: flex; flex-direction: column; } .typing .bubble { color: var(--muted); } .pulse { display: inline-block; width: 6px; height: 6px; margin-right: 8px; border-radius: 50%; background: var(--accent); animation: pulse 1.1s infinite; } @keyframes pulse { 50% { opacity: .25; transform: scale(.8); } } -.composer { margin: 0 max(18px, calc((100% - 850px) / 2)) 18px; border: 1px solid #343d47; background: var(--panel-2); border-radius: 14px; padding: 10px 11px 6px; display: grid; grid-template-columns: 1fr 38px; box-shadow: 0 10px 35px #0006; } +.composer { position: relative; margin: 0 max(18px, calc((100% - 850px) / 2)) 18px; border: 1px solid #343d47; background: var(--panel-2); border-radius: 14px; padding: 10px 11px 6px; display: grid; grid-template-columns: 1fr 38px; box-shadow: 0 10px 35px #0006; } .composer textarea { resize: none; max-height: 180px; background: transparent; border: 0; outline: 0; color: var(--text); padding: 7px; line-height: 1.5; } .send { width: 36px; height: 36px; border-radius: 10px; background: var(--accent); border: 0; font-size: 19px; cursor: pointer; } .composer small { grid-column: 1 / -1; display: flex; justify-content: space-between; padding: 3px 7px; color: var(--muted); font-size: 10px; } .composer small .online { color: var(--accent); } +.command-menu { position: absolute; z-index: 5; left: 0; right: 0; bottom: calc(100% + 8px); max-height: min(360px, 48vh); overflow-y: auto; padding: 6px; border: 1px solid #3b454f; border-radius: 13px; background: #14181d; box-shadow: 0 18px 50px #000b; } +.command-menu-heading { position: sticky; top: -6px; z-index: 1; display: flex; justify-content: space-between; gap: 16px; padding: 9px 10px 8px; color: var(--muted); background: #14181df2; font-size: 10px; } +.command-menu-heading kbd { color: #89919a; font: inherit; } +.command-menu button { display: grid; grid-template-columns: minmax(100px, auto) 1fr; gap: 14px; width: 100%; padding: 9px 10px; border: 0; border-radius: 8px; color: var(--text); background: transparent; text-align: left; cursor: pointer; } +.command-menu button:hover, .command-menu button.selected { background: #252d22; } +.command-menu code { color: var(--accent); font: 600 12px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; } +.command-menu button span { color: #b8bec5; font-size: 12px; line-height: 1.4; } .content-page { padding: 22px 26px; overflow: auto; } .toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 18px; gap: 10px; } .tabs { display: flex; background: var(--panel); padding: 3px; border: 1px solid var(--line); border-radius: 10px; }