增加命令补全
This commit is contained in:
parent
dba1a046c0
commit
6e56a84054
@ -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
|
||||
|
||||
|
||||
@ -102,7 +102,7 @@ http://127.0.0.1:19876/
|
||||
|
||||
WebUI 随二进制嵌入,不需要 Node.js、npm 或单独部署静态文件,提供:
|
||||
|
||||
- 在线聊天、会话创建/切换与历史回放。
|
||||
- 在线聊天、会话创建/切换、历史回放,以及基于 Gateway 实时命令清单的 `/` 斜杠命令补全。
|
||||
- Cron 定时任务、最近运行记录和后台子任务状态。
|
||||
- Knowledge/Timeline 记忆的分类与全文检索。
|
||||
- 本地滚动日志的尾部查看、过滤和自动刷新。
|
||||
|
||||
@ -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/*` 管理接口只提供显式白名单能力:
|
||||
|
||||
|
||||
@ -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 和认证。
|
||||
|
||||
@ -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<UnifiedSessionId>, 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<String> = 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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}<div class="message assistant typing"><div class="avatar">P</div><div class="bubble"><span class="pulse"></span>正在思考…</div></div>{/if}
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -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; }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user