mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-16 01:40:15 +08:00
feat: 为ChatUI添加指令候选功能 (#8279)
* 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 --------- Co-authored-by: Soulter <905617992@qq.com>
This commit is contained in:
@@ -114,6 +114,8 @@ from astrbot.core.utils.quoted_message_parser import (
|
||||
)
|
||||
from astrbot.core.utils.string_utils import normalize_and_dedupe_strings
|
||||
|
||||
LLM_ERROR_MESSAGE_EXTRA_KEY = "_llm_error_message"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MainAgentBuildConfig:
|
||||
@@ -183,6 +185,10 @@ class MainAgentBuildResult:
|
||||
reset_coro: Coroutine | None = None
|
||||
|
||||
|
||||
def _set_llm_error_message(event: AstrMessageEvent, message: str) -> None:
|
||||
event.set_extra(LLM_ERROR_MESSAGE_EXTRA_KEY, message)
|
||||
|
||||
|
||||
def _select_provider(
|
||||
event: AstrMessageEvent, plugin_context: Context
|
||||
) -> Provider | None:
|
||||
@@ -190,18 +196,28 @@ def _select_provider(
|
||||
sel_provider = event.get_extra("selected_provider")
|
||||
if sel_provider and isinstance(sel_provider, str):
|
||||
provider = plugin_context.get_provider_by_id(sel_provider)
|
||||
if not provider:
|
||||
if provider is None:
|
||||
logger.error("未找到指定的提供商: %s。", sel_provider)
|
||||
_set_llm_error_message(
|
||||
event,
|
||||
f"LLM 请求失败:未找到指定的提供商 `{sel_provider}`。请检查提供商配置或重新选择可用模型。",
|
||||
)
|
||||
return None
|
||||
if not isinstance(provider, Provider):
|
||||
logger.error(
|
||||
"选择的提供商类型无效(%s),跳过 LLM 请求处理。", type(provider)
|
||||
)
|
||||
_set_llm_error_message(
|
||||
event,
|
||||
f"LLM 请求失败:选择的提供商类型无效({type(provider).__name__}),已跳过本次请求。",
|
||||
)
|
||||
return None
|
||||
return provider
|
||||
try:
|
||||
return plugin_context.get_using_provider(umo=event.unified_msg_origin)
|
||||
except ValueError as exc:
|
||||
logger.error("Error occurred while selecting provider: %s", exc)
|
||||
_set_llm_error_message(event, f"LLM 请求失败:{exc}")
|
||||
return None
|
||||
|
||||
|
||||
@@ -1192,6 +1208,11 @@ async def build_main_agent(
|
||||
provider = provider or _select_provider(event, plugin_context)
|
||||
if provider is None:
|
||||
logger.info("未找到任何对话模型(提供商),跳过 LLM 请求处理。")
|
||||
if not event.get_extra(LLM_ERROR_MESSAGE_EXTRA_KEY):
|
||||
_set_llm_error_message(
|
||||
event,
|
||||
"LLM 请求失败:未找到任何可用的对话模型(提供商)。请先在 WebUI 中配置并启用可用模型。",
|
||||
)
|
||||
return None
|
||||
|
||||
if req is None:
|
||||
|
||||
@@ -14,6 +14,7 @@ from astrbot.core.agent.message import (
|
||||
)
|
||||
from astrbot.core.agent.response import AgentStats
|
||||
from astrbot.core.astr_main_agent import (
|
||||
LLM_ERROR_MESSAGE_EXTRA_KEY,
|
||||
MainAgentBuildConfig,
|
||||
MainAgentBuildResult,
|
||||
build_main_agent,
|
||||
@@ -151,6 +152,11 @@ class InternalAgentSubStage(Stage):
|
||||
max_quoted_fallback_images=settings.get("max_quoted_fallback_images", 20),
|
||||
)
|
||||
|
||||
async def _send_llm_error_message(
|
||||
self, event: AstrMessageEvent, message: object
|
||||
) -> None:
|
||||
await event.send(MessageChain().message(str(message)))
|
||||
|
||||
async def process(
|
||||
self, event: AstrMessageEvent, provider_wake_prefix: str
|
||||
) -> AsyncGenerator[None, None]:
|
||||
@@ -219,6 +225,13 @@ class InternalAgentSubStage(Stage):
|
||||
)
|
||||
|
||||
if build_result is None:
|
||||
if llm_error_message := event.get_extra(
|
||||
LLM_ERROR_MESSAGE_EXTRA_KEY
|
||||
):
|
||||
await self._send_llm_error_message(
|
||||
event,
|
||||
llm_error_message,
|
||||
)
|
||||
return
|
||||
|
||||
agent_runner = build_result.agent_runner
|
||||
@@ -229,10 +242,12 @@ class InternalAgentSubStage(Stage):
|
||||
api_base = provider.provider_config.get("api_base", "")
|
||||
for host in decoded_blocked:
|
||||
if host in api_base:
|
||||
logger.error(
|
||||
"Provider API base %s is blocked due to security reasons. Please use another ai provider.",
|
||||
api_base,
|
||||
error_message = (
|
||||
f"LLM 请求失败:Provider API base `{api_base}` "
|
||||
"因安全原因被拦截,请更换可用的 AI 提供商。"
|
||||
)
|
||||
logger.error(error_message)
|
||||
await self._send_llm_error_message(event, error_message)
|
||||
return
|
||||
|
||||
stream_to_general = (
|
||||
|
||||
@@ -841,6 +841,7 @@ async function startNewChat() {
|
||||
replyTarget.value = null;
|
||||
newChat();
|
||||
closeMobileSidebar();
|
||||
await focusChatInput();
|
||||
}
|
||||
|
||||
function openCreateProjectDialog() {
|
||||
@@ -975,6 +976,7 @@ async function selectSession(sessionId: string, pushRoute = true) {
|
||||
}
|
||||
scrollToBottom();
|
||||
closeMobileSidebar();
|
||||
await focusChatInput();
|
||||
}
|
||||
|
||||
async function sendCurrentMessage() {
|
||||
@@ -1032,6 +1034,7 @@ async function sendCurrentMessage() {
|
||||
console.error("Failed to send message:", error);
|
||||
} finally {
|
||||
sending.value = false;
|
||||
await focusChatInput();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1326,6 +1329,13 @@ function scrollToBottom() {
|
||||
});
|
||||
}
|
||||
|
||||
async function focusChatInput() {
|
||||
await nextTick();
|
||||
window.requestAnimationFrame(() => {
|
||||
inputRef.value?.focusInput();
|
||||
});
|
||||
}
|
||||
|
||||
async function stopCurrentSession() {
|
||||
if (!currSessionId.value) return;
|
||||
try {
|
||||
@@ -1487,6 +1497,9 @@ function toggleTheme() {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
padding-right: 68px;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -1511,15 +1524,24 @@ function toggleTheme() {
|
||||
}
|
||||
|
||||
.session-actions {
|
||||
display: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.session-item:hover .session-actions,
|
||||
.session-item:focus-within .session-actions {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.session-action-btn {
|
||||
|
||||
@@ -108,14 +108,23 @@
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<CommandSuggestion
|
||||
:visible="showCommandSuggestion"
|
||||
:commands="filteredCommands"
|
||||
:selected-index="selectedCommandIndex"
|
||||
:is-dark="isDark"
|
||||
@select="handleCommandSelect"
|
||||
@update-selected-index="selectedCommandIndex = $event"
|
||||
/>
|
||||
<textarea
|
||||
ref="inputField"
|
||||
v-model="localPrompt"
|
||||
@keydown="handleKeyDown"
|
||||
@input="handleInput"
|
||||
@compositionstart="handleCompositionStart"
|
||||
@compositionend="handleCompositionEnd"
|
||||
@compositioncancel="handleCompositionEnd"
|
||||
@blur="clearCompositionState()"
|
||||
@blur="handleBlur"
|
||||
:disabled="disabled"
|
||||
placeholder="Ask AstrBot..."
|
||||
class="chat-textarea"
|
||||
@@ -312,10 +321,14 @@ import { useDisplay } from "vuetify";
|
||||
import { useModuleI18n } from "@/i18n/composables";
|
||||
import { useCustomizerStore } from "@/stores/customizer";
|
||||
import { isComposingEnter } from "@/utils/imeInput.mjs";
|
||||
import axios from "axios";
|
||||
import type { CommandItem } from "@/components/extension/componentPanel/types";
|
||||
import ConfigSelector from "./ConfigSelector.vue";
|
||||
import ProviderModelMenu from "./ProviderModelMenu.vue";
|
||||
import StyledMenu from "@/components/shared/StyledMenu.vue";
|
||||
import CommandSuggestion from "./CommandSuggestion.vue";
|
||||
import type { Session } from "@/composables/useSessions";
|
||||
import type { SuggestionCommand } from "./CommandSuggestion.vue";
|
||||
|
||||
interface StagedFileInfo {
|
||||
attachment_id: string;
|
||||
@@ -388,6 +401,105 @@ const isComposing = ref(false);
|
||||
const lastCompositionEndAt = ref<number | null>(null);
|
||||
let dragLeaveTimeout: number | null = null;
|
||||
|
||||
// 命令提示相关状态
|
||||
const allCommands = ref<CommandItem[]>([]);
|
||||
const showCommandSuggestion = ref(false);
|
||||
const selectedCommandIndex = ref(0);
|
||||
const commandSuggestionLoading = ref(false);
|
||||
|
||||
function normalizeCommandSearchText(value: string) {
|
||||
return value.trim().replace(/^\/+/, "").toLowerCase();
|
||||
}
|
||||
|
||||
/** 从所有指令中展平获取启用的普通指令和子指令 */
|
||||
const enabledCommands = computed(() => {
|
||||
const result: SuggestionCommand[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
function addCommand(cmd: CommandItem) {
|
||||
if (!cmd.enabled) return;
|
||||
if (cmd.type === "group") {
|
||||
// 指令组本身不加入,但其子指令加入
|
||||
cmd.sub_commands?.forEach(addCommand);
|
||||
return;
|
||||
}
|
||||
// 统一添加 / 前缀(子命令的 effective_command 如 "music play" 需要变成 "/music play")
|
||||
const displayCmd = cmd.effective_command.startsWith("/")
|
||||
? cmd.effective_command
|
||||
: `/${cmd.effective_command}`;
|
||||
if (!seen.has(displayCmd)) {
|
||||
seen.add(displayCmd);
|
||||
result.push({
|
||||
handler_full_name: cmd.handler_full_name,
|
||||
effective_command: displayCmd,
|
||||
description: cmd.description,
|
||||
plugin_display_name: cmd.plugin_display_name,
|
||||
enabled: cmd.enabled,
|
||||
reserved: cmd.reserved,
|
||||
});
|
||||
}
|
||||
// 同时加入别名(别名也需要加上 / 前缀)
|
||||
cmd.aliases?.forEach((alias) => {
|
||||
const aliasBase = cmd.parent_signature
|
||||
? `${cmd.parent_signature} ${alias}`
|
||||
: alias;
|
||||
const aliasKey = aliasBase.startsWith("/")
|
||||
? aliasBase
|
||||
: `/${aliasBase}`;
|
||||
if (!seen.has(aliasKey)) {
|
||||
seen.add(aliasKey);
|
||||
result.push({
|
||||
handler_full_name: cmd.handler_full_name,
|
||||
effective_command: aliasKey,
|
||||
description: cmd.description,
|
||||
plugin_display_name: cmd.plugin_display_name,
|
||||
enabled: cmd.enabled,
|
||||
reserved: cmd.reserved,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
allCommands.value.forEach(addCommand);
|
||||
return result;
|
||||
});
|
||||
|
||||
function sortSystemPluginCommandsFirst(commands: SuggestionCommand[]) {
|
||||
return [...commands].sort((a, b) => Number(b.reserved) - Number(a.reserved));
|
||||
}
|
||||
|
||||
/** 根据当前输入过滤候选指令 */
|
||||
const filteredCommands = computed(() => {
|
||||
const text = props.prompt;
|
||||
if (!text || !text.startsWith("/")) return [];
|
||||
|
||||
const query = normalizeCommandSearchText(text);
|
||||
if (!query) return sortSystemPluginCommandsFirst(enabledCommands.value);
|
||||
|
||||
const startsWithMatches: SuggestionCommand[] = [];
|
||||
const containsMatches: SuggestionCommand[] = [];
|
||||
|
||||
for (const cmd of enabledCommands.value) {
|
||||
const commandText = normalizeCommandSearchText(cmd.effective_command);
|
||||
const pluginText = normalizeCommandSearchText(cmd.plugin_display_name || "");
|
||||
const descriptionText = normalizeCommandSearchText(cmd.description || "");
|
||||
const matchesCommand = commandText.includes(query);
|
||||
const matchesMetadata =
|
||||
pluginText.includes(query) || descriptionText.includes(query);
|
||||
|
||||
if (commandText.startsWith(query)) {
|
||||
startsWithMatches.push(cmd);
|
||||
} else if (matchesCommand || matchesMetadata) {
|
||||
containsMatches.push(cmd);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
...sortSystemPluginCommandsFirst(startsWithMatches),
|
||||
...sortSystemPluginCommandsFirst(containsMatches),
|
||||
];
|
||||
});
|
||||
|
||||
const localPrompt = computed({
|
||||
get: () => props.prompt,
|
||||
set: (value) => emit("update:prompt", value),
|
||||
@@ -504,6 +616,36 @@ watch(localPrompt, () => {
|
||||
});
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
// 命令提示激活时,拦截方向键和 Enter/Esc
|
||||
if (showCommandSuggestion.value && filteredCommands.value.length > 0) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
selectedCommandIndex.value =
|
||||
(selectedCommandIndex.value + 1) % filteredCommands.value.length;
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
selectedCommandIndex.value =
|
||||
(selectedCommandIndex.value - 1 + filteredCommands.value.length) %
|
||||
filteredCommands.value.length;
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const cmd = filteredCommands.value[selectedCommandIndex.value];
|
||||
if (cmd) {
|
||||
handleCommandSelect(cmd);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
showCommandSuggestion.value = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const isEnter = e.key === "Enter";
|
||||
if (!isEnter) {
|
||||
// Ctrl+B 录音
|
||||
@@ -544,6 +686,53 @@ function handleKeyDown(e: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理输入变化,控制命令提示显示 */
|
||||
function handleInput() {
|
||||
const text = props.prompt;
|
||||
if (text && text.startsWith("/") && !isComposing.value) {
|
||||
showCommandSuggestion.value = filteredCommands.value.length > 0;
|
||||
selectedCommandIndex.value = 0;
|
||||
} else {
|
||||
showCommandSuggestion.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理 blur 事件,延迟关闭命令提示以允许点击 */
|
||||
function handleBlur() {
|
||||
clearCompositionState();
|
||||
// 延迟关闭,避免点击候选项时面板已消失
|
||||
setTimeout(() => {
|
||||
showCommandSuggestion.value = false;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/** 选择命令,填入输入框 */
|
||||
function handleCommandSelect(cmd: SuggestionCommand) {
|
||||
localPrompt.value = cmd.effective_command + " ";
|
||||
showCommandSuggestion.value = false;
|
||||
nextTick(() => {
|
||||
inputField.value?.focus();
|
||||
autoResize();
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取指令列表 */
|
||||
async function fetchCommands() {
|
||||
if (commandSuggestionLoading.value) return;
|
||||
commandSuggestionLoading.value = true;
|
||||
try {
|
||||
const res = await axios.get("/api/commands");
|
||||
if (res.data.status === "ok") {
|
||||
allCommands.value = res.data.data.items || [];
|
||||
}
|
||||
} catch (err) {
|
||||
// 静默失败,不影响聊天功能
|
||||
console.warn("Failed to fetch commands for suggestion:", err);
|
||||
} finally {
|
||||
commandSuggestionLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCompositionStart() {
|
||||
isComposing.value = true;
|
||||
lastCompositionEndAt.value = null;
|
||||
@@ -656,6 +845,8 @@ onMounted(() => {
|
||||
inputField.value.addEventListener("paste", handlePaste);
|
||||
}
|
||||
document.addEventListener("keyup", handleKeyUp);
|
||||
// 预加载指令列表
|
||||
fetchCommands();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
||||
205
dashboard/src/components/chat/CommandSuggestion.vue
Normal file
205
dashboard/src/components/chat/CommandSuggestion.vue
Normal file
@@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="visible && filteredCommands.length > 0"
|
||||
class="command-suggestion-panel"
|
||||
:class="{ 'is-dark': isDark }"
|
||||
:style="panelStyle"
|
||||
>
|
||||
<div class="command-suggestion-list">
|
||||
<div
|
||||
v-for="(cmd, index) in filteredCommands"
|
||||
:key="`${cmd.handler_full_name}:${cmd.effective_command}`"
|
||||
class="command-suggestion-item"
|
||||
:class="{ active: index === selectedIndex }"
|
||||
@click="handleSelect(index)"
|
||||
@mouseenter="handleMouseEnter(index)"
|
||||
>
|
||||
<div class="command-suggestion-main">
|
||||
<span class="command-name">{{ cmd.effective_command }}</span>
|
||||
<span v-if="cmd.plugin_display_name" class="command-plugin">
|
||||
{{ cmd.plugin_display_name }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="cmd.description" class="command-description">
|
||||
{{ cmd.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="command-suggestion-hint">
|
||||
<span>↑↓ {{ tm("commandSuggestion.navigate") }}</span>
|
||||
<span>Enter {{ tm("commandSuggestion.select") }}</span>
|
||||
<span>Esc {{ tm("commandSuggestion.close") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useModuleI18n } from "@/i18n/composables";
|
||||
|
||||
export interface SuggestionCommand {
|
||||
handler_full_name: string;
|
||||
effective_command: string;
|
||||
description: string;
|
||||
plugin_display_name: string | null;
|
||||
enabled: boolean;
|
||||
reserved: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
commands: SuggestionCommand[];
|
||||
selectedIndex: number;
|
||||
isDark: boolean;
|
||||
caretPosition?: { left: number; top: number } | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
caretPosition: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [command: SuggestionCommand];
|
||||
updateSelectedIndex: [index: number];
|
||||
}>();
|
||||
|
||||
const { tm } = useModuleI18n("features/chat");
|
||||
|
||||
const filteredCommands = computed(() => props.commands);
|
||||
|
||||
const panelStyle = computed(() => {
|
||||
if (props.caretPosition) {
|
||||
return {
|
||||
position: "absolute" as const,
|
||||
left: `${props.caretPosition.left}px`,
|
||||
bottom: `${props.caretPosition.top + 8}px`,
|
||||
zIndex: 1000,
|
||||
};
|
||||
}
|
||||
return {
|
||||
position: "absolute" as const,
|
||||
left: "18px",
|
||||
bottom: "100%",
|
||||
marginBottom: "8px",
|
||||
zIndex: 1000,
|
||||
};
|
||||
});
|
||||
|
||||
function handleSelect(index: number) {
|
||||
const cmd = props.commands[index];
|
||||
if (cmd) {
|
||||
emit("select", cmd);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseEnter(index: number) {
|
||||
emit("updateSelectedIndex", index);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.command-suggestion-panel {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
max-height: 280px;
|
||||
width: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.command-suggestion-panel.is-dark {
|
||||
background: #1e1e1e;
|
||||
border-color: #404040;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.command-suggestion-list {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.command-suggestion-item {
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.command-suggestion-item:hover,
|
||||
.command-suggestion-item.active {
|
||||
background-color: rgba(var(--v-theme-primary), 0.1);
|
||||
}
|
||||
|
||||
.is-dark .command-suggestion-item:hover,
|
||||
.is-dark .command-suggestion-item.active {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.command-suggestion-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.command-name {
|
||||
font-family: "Fira Code", "Consolas", monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.command-plugin {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.is-dark .command-plugin {
|
||||
color: #aaa;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.command-description {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.is-dark .command-description {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.command-suggestion-hint {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 6px 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.is-dark .command-suggestion-hint {
|
||||
border-top-color: #333;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.command-suggestion-hint span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -317,6 +317,7 @@ async function sendCurrentMessage() {
|
||||
draft.value = "";
|
||||
clearStaged({ revokeUrls: false });
|
||||
scrollToBottom();
|
||||
await focusChatInput();
|
||||
|
||||
sendMessageStream({
|
||||
sessionId,
|
||||
@@ -393,6 +394,13 @@ function scrollToBottom() {
|
||||
});
|
||||
}
|
||||
|
||||
async function focusChatInput() {
|
||||
await nextTick();
|
||||
window.requestAnimationFrame(() => {
|
||||
inputRef.value?.focusInput();
|
||||
});
|
||||
}
|
||||
|
||||
function messageRefs(message: ChatRecord) {
|
||||
const refs = messageContent(message).refs;
|
||||
if (refs && typeof refs === "object" && Array.isArray(refs.used)) {
|
||||
|
||||
@@ -162,6 +162,11 @@
|
||||
"failed": "Connection failed, please refresh the page"
|
||||
}
|
||||
},
|
||||
"commandSuggestion": {
|
||||
"navigate": "Navigate",
|
||||
"select": "Select",
|
||||
"close": "Close"
|
||||
},
|
||||
"errors": {
|
||||
"sendMessageFailed": "Failed to send message, please try again",
|
||||
"createSessionFailed": "Failed to create session, please refresh the page"
|
||||
|
||||
@@ -162,6 +162,11 @@
|
||||
"failed": "Ошибка подключения, обновите страницу"
|
||||
}
|
||||
},
|
||||
"commandSuggestion": {
|
||||
"navigate": "Выбор",
|
||||
"select": "Подтвердить",
|
||||
"close": "Закрыть"
|
||||
},
|
||||
"errors": {
|
||||
"sendMessageFailed": "Ошибка отправки сообщения, попробуйте еще раз",
|
||||
"createSessionFailed": "Ошибка создания сессии, обновите страницу"
|
||||
|
||||
@@ -162,6 +162,11 @@
|
||||
"failed": "连接失败,请刷新页面重试"
|
||||
}
|
||||
},
|
||||
"commandSuggestion": {
|
||||
"navigate": "选择",
|
||||
"select": "确认",
|
||||
"close": "关闭"
|
||||
},
|
||||
"errors": {
|
||||
"sendMessageFailed": "发送消息失败,请重试",
|
||||
"createSessionFailed": "创建会话失败,请刷新页面重试"
|
||||
|
||||
@@ -184,6 +184,10 @@ class TestSelectProvider:
|
||||
result = module._select_provider(mock_event, mock_context)
|
||||
|
||||
assert result is None
|
||||
mock_event.set_extra.assert_called_with(
|
||||
module.LLM_ERROR_MESSAGE_EXTRA_KEY,
|
||||
"LLM 请求失败:未找到指定的提供商 `non-existent`。请检查提供商配置或重新选择可用模型。",
|
||||
)
|
||||
|
||||
def test_select_provider_invalid_type(self, mock_event, mock_context):
|
||||
"""Test selecting provider when result is not a Provider instance."""
|
||||
@@ -196,6 +200,10 @@ class TestSelectProvider:
|
||||
result = module._select_provider(mock_event, mock_context)
|
||||
|
||||
assert result is None
|
||||
mock_event.set_extra.assert_called_with(
|
||||
module.LLM_ERROR_MESSAGE_EXTRA_KEY,
|
||||
"LLM 请求失败:选择的提供商类型无效(str),已跳过本次请求。",
|
||||
)
|
||||
|
||||
def test_select_provider_fallback(self, mock_event, mock_context, mock_provider):
|
||||
"""Test provider selection fallback to using provider."""
|
||||
@@ -219,6 +227,10 @@ class TestSelectProvider:
|
||||
result = module._select_provider(mock_event, mock_context)
|
||||
|
||||
assert result is None
|
||||
mock_event.set_extra.assert_called_with(
|
||||
module.LLM_ERROR_MESSAGE_EXTRA_KEY,
|
||||
"LLM 请求失败:Test error",
|
||||
)
|
||||
|
||||
|
||||
class TestGetSessionConv:
|
||||
|
||||
Reference in New Issue
Block a user