mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-16 01:40:15 +08:00
fix(command-suggestion): support custom wake-up words & hover information (#8353)
* feature: add command suggestion for ChatUI * feat(chat): add focus functionality to chat input after sending messages * feat(llm): add error handling for LLM provider selection failures * feat(command-suggestion): enhance command filtering and sorting logic * fix(command-suggestion): support custom wake-up words & hover information * ruff --------- Co-authored-by: Soulter <905617992@qq.com>
This commit is contained in:
@@ -18,8 +18,9 @@ from .route import Response, Route, RouteContext
|
||||
|
||||
|
||||
class CommandRoute(Route):
|
||||
def __init__(self, context: RouteContext) -> None:
|
||||
def __init__(self, context: RouteContext, core_lifecycle=None) -> None:
|
||||
super().__init__(context)
|
||||
self.core_lifecycle = core_lifecycle
|
||||
self.routes = {
|
||||
"/commands": ("GET", self.get_commands),
|
||||
"/commands/conflicts": ("GET", self.get_conflicts),
|
||||
@@ -36,7 +37,18 @@ class CommandRoute(Route):
|
||||
"disabled": len([cmd for cmd in commands if not cmd["enabled"]]),
|
||||
"conflicts": len([cmd for cmd in commands if cmd.get("has_conflict")]),
|
||||
}
|
||||
return Response().ok({"items": commands, "summary": summary}).__dict__
|
||||
# 优先从指定 config_id 的配置中读取唤醒词,否则使用默认配置
|
||||
config_id = request.args.get("config_id", "").strip()
|
||||
wake_prefix = self.config.get("wake_prefix", ["/"])
|
||||
if config_id and self.core_lifecycle:
|
||||
acm = getattr(self.core_lifecycle, "astrbot_config_mgr", None)
|
||||
if acm and config_id in acm.confs:
|
||||
wake_prefix = acm.confs[config_id].get("wake_prefix", wake_prefix)
|
||||
return (
|
||||
Response()
|
||||
.ok({"items": commands, "summary": summary, "wake_prefix": wake_prefix})
|
||||
.__dict__
|
||||
)
|
||||
|
||||
async def get_conflicts(self):
|
||||
conflicts = await list_command_conflicts()
|
||||
|
||||
@@ -154,7 +154,7 @@ class AstrBotDashboard:
|
||||
core_lifecycle,
|
||||
core_lifecycle.plugin_manager,
|
||||
)
|
||||
self.command_route = CommandRoute(self.context)
|
||||
self.command_route = CommandRoute(self.context, core_lifecycle)
|
||||
self.cr = ConfigRoute(self.context, core_lifecycle)
|
||||
self.lr = LogRoute(self.context, core_lifecycle.log_broker)
|
||||
self.sfr = StaticFileRoute(self.context)
|
||||
|
||||
@@ -406,15 +406,36 @@ const allCommands = ref<CommandItem[]>([]);
|
||||
const showCommandSuggestion = ref(false);
|
||||
const selectedCommandIndex = ref(0);
|
||||
const commandSuggestionLoading = ref(false);
|
||||
const wakePrefixes = ref<string[]>(["/"]);
|
||||
const currentConfigId = ref((props.configId as string) || "default");
|
||||
|
||||
/** 检查文本是否以任意一个唤醒词前缀开头 */
|
||||
function hasWakePrefix(text: string): boolean {
|
||||
return wakePrefixes.value.some((p) => text.startsWith(p));
|
||||
}
|
||||
|
||||
/** 去掉文本开头匹配的任意唤醒词前缀,返回剥离后的文本 */
|
||||
function stripWakePrefix(text: string): string {
|
||||
let result = text;
|
||||
for (const p of wakePrefixes.value) {
|
||||
if (result.startsWith(p)) {
|
||||
result = result.slice(p.length);
|
||||
break; // 只剥离第一个匹配的前缀
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeCommandSearchText(value: string) {
|
||||
return value.trim().replace(/^\/+/, "").toLowerCase();
|
||||
return stripWakePrefix(value.trim()).toLowerCase();
|
||||
}
|
||||
|
||||
/** 从所有指令中展平获取启用的普通指令和子指令 */
|
||||
const enabledCommands = computed(() => {
|
||||
const result: SuggestionCommand[] = [];
|
||||
const seen = new Set<string>();
|
||||
// 使用第一个唤醒词前缀作为指令的展示前缀
|
||||
const displayPrefix = wakePrefixes.value[0] || "/";
|
||||
|
||||
function addCommand(cmd: CommandItem) {
|
||||
if (!cmd.enabled) return;
|
||||
@@ -423,10 +444,10 @@ const enabledCommands = computed(() => {
|
||||
cmd.sub_commands?.forEach(addCommand);
|
||||
return;
|
||||
}
|
||||
// 统一添加 / 前缀(子命令的 effective_command 如 "music play" 需要变成 "/music play")
|
||||
const displayCmd = cmd.effective_command.startsWith("/")
|
||||
// 统一添加唤醒词前缀(子命令的 effective_command 如 "music play" 需要变成 "/music play")
|
||||
const displayCmd = hasWakePrefix(cmd.effective_command)
|
||||
? cmd.effective_command
|
||||
: `/${cmd.effective_command}`;
|
||||
: `${displayPrefix}${cmd.effective_command}`;
|
||||
if (!seen.has(displayCmd)) {
|
||||
seen.add(displayCmd);
|
||||
result.push({
|
||||
@@ -438,14 +459,14 @@ const enabledCommands = computed(() => {
|
||||
reserved: cmd.reserved,
|
||||
});
|
||||
}
|
||||
// 同时加入别名(别名也需要加上 / 前缀)
|
||||
// 同时加入别名(别名也需要加上唤醒词前缀)
|
||||
cmd.aliases?.forEach((alias) => {
|
||||
const aliasBase = cmd.parent_signature
|
||||
? `${cmd.parent_signature} ${alias}`
|
||||
: alias;
|
||||
const aliasKey = aliasBase.startsWith("/")
|
||||
const aliasKey = hasWakePrefix(aliasBase)
|
||||
? aliasBase
|
||||
: `/${aliasBase}`;
|
||||
: `${displayPrefix}${aliasBase}`;
|
||||
if (!seen.has(aliasKey)) {
|
||||
seen.add(aliasKey);
|
||||
result.push({
|
||||
@@ -471,7 +492,7 @@ function sortSystemPluginCommandsFirst(commands: SuggestionCommand[]) {
|
||||
/** 根据当前输入过滤候选指令 */
|
||||
const filteredCommands = computed(() => {
|
||||
const text = props.prompt;
|
||||
if (!text || !text.startsWith("/")) return [];
|
||||
if (!text || !hasWakePrefix(text)) return [];
|
||||
|
||||
const query = normalizeCommandSearchText(text);
|
||||
if (!query) return sortSystemPluginCommandsFirst(enabledCommands.value);
|
||||
@@ -689,7 +710,7 @@ function handleKeyDown(e: KeyboardEvent) {
|
||||
/** 处理输入变化,控制命令提示显示 */
|
||||
function handleInput() {
|
||||
const text = props.prompt;
|
||||
if (text && text.startsWith("/") && !isComposing.value) {
|
||||
if (text && hasWakePrefix(text) && !isComposing.value) {
|
||||
showCommandSuggestion.value = filteredCommands.value.length > 0;
|
||||
selectedCommandIndex.value = 0;
|
||||
} else {
|
||||
@@ -721,9 +742,19 @@ async function fetchCommands() {
|
||||
if (commandSuggestionLoading.value) return;
|
||||
commandSuggestionLoading.value = true;
|
||||
try {
|
||||
const res = await axios.get("/api/commands");
|
||||
const params: Record<string, string> = {};
|
||||
const cid = currentConfigId.value;
|
||||
if (cid && cid !== "default") {
|
||||
params.config_id = cid;
|
||||
}
|
||||
const res = await axios.get("/api/commands", { params });
|
||||
if (res.data.status === "ok") {
|
||||
allCommands.value = res.data.data.items || [];
|
||||
// 读取当前配置的唤醒词列表,用于指令候选的触发前缀
|
||||
const prefixes: string[] = res.data.data.wake_prefix;
|
||||
if (prefixes && prefixes.length > 0) {
|
||||
wakePrefixes.value = prefixes;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// 静默失败,不影响聊天功能
|
||||
@@ -826,6 +857,11 @@ function handleConfigChange(payload: {
|
||||
const runnerType = (payload.agentRunnerType || "").toLowerCase();
|
||||
const isInternal = runnerType === "internal" || runnerType === "local";
|
||||
showProviderSelector.value = isInternal;
|
||||
// 配置切换后重新获取指令列表和唤醒词
|
||||
if (payload.configId && payload.configId !== currentConfigId.value) {
|
||||
currentConfigId.value = payload.configId;
|
||||
fetchCommands();
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentSelection() {
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
:class="{ active: index === selectedIndex }"
|
||||
@click="handleSelect(index)"
|
||||
@mouseenter="handleMouseEnter(index)"
|
||||
@mousemove="handleMouseMove"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<div class="command-suggestion-main">
|
||||
<span class="command-name">{{ cmd.effective_command }}</span>
|
||||
@@ -31,10 +33,21 @@
|
||||
<span>Esc {{ tm("commandSuggestion.close") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tooltip: 鼠标悬停时显示完整用途 -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="tooltip.visible"
|
||||
class="command-tooltip"
|
||||
:class="{ 'is-dark': isDark }"
|
||||
:style="tooltipStyle"
|
||||
>
|
||||
{{ tooltip.text }}
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { computed, reactive } from "vue";
|
||||
import { useModuleI18n } from "@/i18n/composables";
|
||||
|
||||
export interface SuggestionCommand {
|
||||
@@ -67,6 +80,21 @@ const { tm } = useModuleI18n("features/chat");
|
||||
|
||||
const filteredCommands = computed(() => props.commands);
|
||||
|
||||
// Tooltip 状态:鼠标悬停在指令上时显示完整用途
|
||||
const tooltip = reactive({
|
||||
visible: false,
|
||||
text: "",
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
const tooltipStyle = computed(() => ({
|
||||
position: "fixed" as const,
|
||||
left: `${tooltip.x + 12}px`,
|
||||
top: `${tooltip.y + 12}px`,
|
||||
zIndex: 10000,
|
||||
}));
|
||||
|
||||
const panelStyle = computed(() => {
|
||||
if (props.caretPosition) {
|
||||
return {
|
||||
@@ -94,6 +122,21 @@ function handleSelect(index: number) {
|
||||
|
||||
function handleMouseEnter(index: number) {
|
||||
emit("updateSelectedIndex", index);
|
||||
// 显示 tooltip
|
||||
const cmd = props.commands[index];
|
||||
if (cmd?.description) {
|
||||
tooltip.text = cmd.description;
|
||||
tooltip.visible = true;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseMove(e: MouseEvent) {
|
||||
tooltip.x = e.clientX;
|
||||
tooltip.y = e.clientY;
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
tooltip.visible = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -203,3 +246,28 @@ function handleMouseEnter(index: number) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 非 scoped 样式:tooltip 通过 Teleport 渲染到 body,scoped 无法生效 -->
|
||||
<style>
|
||||
.command-tooltip {
|
||||
max-width: 360px;
|
||||
padding: 8px 12px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
pointer-events: none;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.command-tooltip.is-dark {
|
||||
background: #2d2d2d;
|
||||
border-color: #404040;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
color: #e0e0e0;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user