增加命令补全
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
|
- **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
|
||||||
|
|
||||||
|
|||||||
@ -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 记忆的分类与全文检索。
|
||||||
- 本地滚动日志的尾部查看、过滤和自动刷新。
|
- 本地滚动日志的尾部查看、过滤和自动刷新。
|
||||||
|
|||||||
@ -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、历史持久化和出站 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/*` 管理接口只提供显式白名单能力:
|
同源 `/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`。
|
`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 {
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user