Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions astrbot/dashboard/routes/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@


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),
Expand All @@ -36,7 +37,18 @@ async def get_commands(self):
"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()
Expand Down
2 changes: 1 addition & 1 deletion astrbot/dashboard/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ def __init__(
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)
Expand Down
56 changes: 46 additions & 10 deletions dashboard/src/components/chat/ChatInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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({
Expand All @@ -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({
Expand All @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
// 静默失败,不影响聊天功能
Expand Down Expand Up @@ -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() {
Expand Down
70 changes: 69 additions & 1 deletion dashboard/src/components/chat/CommandSuggestion.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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>

Expand Down Expand Up @@ -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>
Loading