增加命令补全

This commit is contained in:
xiaoxixi 2026-07-15 14:00:36 +08:00
parent dba1a046c0
commit 6e56a84054
7 changed files with 154 additions and 10 deletions

View File

@ -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 - **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 - **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 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 - **Providers** are pure HTTP clients; no bus/session/channel awareness
- **Tools** are executed by `AgentLoop`; they receive raw arguments and return string results - **Tools** are executed by `AgentLoop`; they receive raw arguments and return string results

View File

@ -102,7 +102,7 @@ http://127.0.0.1:19876/
WebUI 随二进制嵌入,不需要 Node.js、npm 或单独部署静态文件,提供: WebUI 随二进制嵌入,不需要 Node.js、npm 或单独部署静态文件,提供:
- 在线聊天、会话创建/切换与历史回放 - 在线聊天、会话创建/切换、历史回放,以及基于 Gateway 实时命令清单的 `/` 斜杠命令补全
- Cron 定时任务、最近运行记录和后台子任务状态。 - Cron 定时任务、最近运行记录和后台子任务状态。
- Knowledge/Timeline 记忆的分类与全文检索。 - Knowledge/Timeline 记忆的分类与全文检索。
- 本地滚动日志的尾部查看、过滤和自动刷新。 - 本地滚动日志的尾部查看、过滤和自动刷新。

View File

@ -194,7 +194,7 @@ WebSocket 每个客户端的 writer task 是连接局部任务,由连接 handl
### WebUI 与管理 API ### 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、历史持久化和出站 laneWebUI 不直接调用 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/*` 管理接口只提供显式白名单能力: 同源 `/api/*` 管理接口只提供显式白名单能力:

View File

@ -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` `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 和认证。

View File

@ -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 { impl SessionManager {
fn worker_deps(&self) -> AgentWorkerDeps { fn worker_deps(&self) -> AgentWorkerDeps {
AgentWorkerDeps { AgentWorkerDeps {
@ -1123,9 +1134,7 @@ impl SessionManager {
chat_id: &str, chat_id: &str,
current_session_id: Option<&UnifiedSessionId>, current_session_id: Option<&UnifiedSessionId>,
) -> Result<(Option<UnifiedSessionId>, String), AgentError> { ) -> Result<(Option<UnifiedSessionId>, String), AgentError> {
let cmd = SLASH_COMMANDS let cmd = resolve_slash_command(command)
.iter()
.find(|c| c.name == command)
.ok_or_else(|| AgentError::Other(format!("Unknown command: {}", command)))?; .ok_or_else(|| AgentError::Other(format!("Unknown command: {}", command)))?;
tracing::info!(cmd = %cmd.name, args = ?args, "Executing slash command"); tracing::info!(cmd = %cmd.name, args = ?args, "Executing slash command");
@ -1348,7 +1357,7 @@ impl SessionManager {
Ok((None, "No active session.".to_string())) Ok((None, "No active session.".to_string()))
} }
} }
"?" | "help" => { "?" => {
let lines: Vec<String> = SLASH_COMMANDS let lines: Vec<String> = SLASH_COMMANDS
.iter() .iter()
.map(|c| format!(" {} - {}", c.aliases.join(", "), c.description)) .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), 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());
}
}

View File

@ -11,6 +11,9 @@
let messages = $state([]); let messages = $state([]);
let search = $state(""); let search = $state("");
let draft = $state(""); let draft = $state("");
let commands = $state([]);
let selectedCommand = $state(0);
let commandMenuDismissed = $state(false);
let thinking = $state(false); let thinking = $state(false);
let messageBox; let messageBox;
let input; let input;
@ -18,6 +21,23 @@
let stopped = false; let stopped = false;
const currentSession = $derived(sessions.find((item) => item.session_id === currentId)); const currentSession = $derived(sessions.find((item) => item.session_id === currentId));
const filteredSessions = $derived(sessions.filter((item) => item.title.toLowerCase().includes(search.toLowerCase()))); 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) { function send(frame) {
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(frame)); if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(frame));
@ -31,6 +51,7 @@
ws.onopen = () => { ws.onopen = () => {
connected = true; connected = true;
send({ type: "list_sessions", include_archived: false }); send({ type: "list_sessions", include_archived: false });
send({ type: "get_slash_commands" });
}; };
ws.onerror = () => ws.close(); ws.onerror = () => ws.close();
ws.onclose = () => { ws.onclose = () => {
@ -63,6 +84,10 @@
scrollToBottom(); scrollToBottom();
} }
break; break;
case "slash_commands_list":
commands = frame.commands || [];
selectedCommand = 0;
break;
case "assistant_response": case "assistant_response":
thinking = false; thinking = false;
if (!frame.session_id || frame.session_id === currentId) appendMessage(frame.role || "assistant", frame.content); if (!frame.session_id || frame.session_id === currentId) appendMessage(frame.role || "assistant", frame.content);
@ -100,10 +125,54 @@
thinking = true; thinking = true;
send({ type: "user_input", content }); send({ type: "user_input", content });
draft = ""; 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"; if (input) input.style.height = "auto";
} }
function keydown(event) { 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) { if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault(); event.preventDefault();
submit(); submit();
@ -115,6 +184,12 @@
event.currentTarget.style.height = `${Math.min(event.currentTarget.scrollHeight, 180)}px`; event.currentTarget.style.height = `${Math.min(event.currentTarget.scrollHeight, 180)}px`;
} }
function inputChanged(event) {
commandMenuDismissed = false;
selectedCommand = 0;
resize(event);
}
onMount(() => { onMount(() => {
connect(); connect();
return () => { return () => {
@ -157,9 +232,39 @@
{#if thinking}<div class="message assistant typing"><div class="avatar">P</div><div class="bubble"><span class="pulse"></span>正在思考…</div></div>{/if} {#if thinking}<div class="message assistant typing"><div class="avatar">P</div><div class="bubble"><span class="pulse"></span>正在思考…</div></div>{/if}
</div> </div>
<form class="composer" onsubmit={(event) => { event.preventDefault(); submit(); }}> <form class="composer" onsubmit={(event) => { event.preventDefault(); submit(); }}>
<textarea bind:this={input} bind:value={draft} onkeydown={keydown} oninput={resize} rows="1" placeholder="输入消息Enter 发送Shift+Enter 换行"></textarea> {#if commandSuggestions.length}
<div class="command-menu" id="slash-command-menu" role="listbox" aria-label="斜杠命令">
<div class="command-menu-heading"><span>斜杠命令</span><kbd>↑↓ 选择 · Tab/Enter 补全 · Esc 关闭</kbd></div>
{#each commandSuggestions as command, index (`${command.name}:${command.alias}`)}
<button
id={`slash-command-${index}`}
type="button"
role="option"
aria-selected={index === selectedCommand}
class:selected={index === selectedCommand}
onmouseenter={() => selectedCommand = index}
onclick={() => completeCommand(index)}
>
<code>{command.alias}</code><span>{command.description}</span>
</button>
{/each}
</div>
{/if}
<textarea
bind:this={input}
bind:value={draft}
onkeydown={keydown}
oninput={inputChanged}
rows="1"
role="combobox"
aria-autocomplete="list"
aria-controls="slash-command-menu"
aria-expanded={commandSuggestions.length > 0}
aria-activedescendant={commandSuggestions.length ? `slash-command-${selectedCommand}` : undefined}
placeholder="输入消息,输入 / 查看命令"
></textarea>
<button class="send" type="submit" aria-label="发送" disabled={!connected || !draft.trim()}>↑</button> <button class="send" type="submit" aria-label="发送" disabled={!connected || !draft.trim()}>↑</button>
<small><span class:online={connected}>{connected ? "已连接" : "已断开,正在重连"}</span><span>支持 Slash Command</span></small> <small><span class:online={connected}>{connected ? "已连接" : "已断开,正在重连"}</span><span>/ 打开命令 · Shift+Enter 换行</span></small>
</form> </form>
</div> </div>
</section> </section>

View File

@ -80,11 +80,18 @@ main { min-width: 0; height: 100vh; display: flex; flex-direction: column; }
.typing .bubble { color: var(--muted); } .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; } .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); } } @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; } .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; } .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 { 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); } .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; } .content-page { padding: 22px 26px; overflow: auto; }
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 18px; gap: 10px; } .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; } .tabs { display: flex; background: var(--panel); padding: 3px; border: 1px solid var(--line); border-radius: 10px; }