Files
AstrBot/dashboard/src/components/chat/ChatInput.vue
2026-07-04 17:56:33 +08:00

1671 lines
43 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div
class="input-area fade-in"
:class="{ 'is-dark': isDark }"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
>
<div
class="input-container"
:class="{
'is-multiline': inputIsMultiline,
'has-attachments': hasStagedAttachments,
}"
:style="{
width: 'var(--chat-content-width, 76%)',
maxWidth: 'var(--chat-content-max-width, 760px)',
margin: '0 auto',
border: isDark ? 'none' : '1px solid #e0e0e0',
borderRadius: '24px',
boxShadow: isDark ? 'none' : '0px 2px 2px rgba(0, 0, 0, 0.1)',
backgroundColor: isDark ? '#2d2d2d' : '#fff',
position: 'relative',
transition: 'min-height 0.2s ease, padding 0.2s ease',
}"
>
<!-- 拖拽上传遮罩 -->
<transition name="fade">
<div v-if="isDragging" class="drop-overlay">
<div class="drop-overlay-content">
<v-icon size="48" color="primary">mdi-cloud-upload</v-icon>
<span class="drop-text">{{ tm("input.dropToUpload") }}</span>
</div>
</div>
</transition>
<!-- 引用预览区 -->
<transition name="slideReply" @after-leave="handleReplyAfterLeave">
<div class="reply-preview" v-if="props.replyTo && !isReplyClosing">
<div class="reply-content">
<v-icon size="small" class="reply-icon">mdi-reply</v-icon>
"<span class="reply-text">{{ props.replyTo.selectedText }}</span
>"
</div>
<v-btn
@click="handleClearReply"
class="remove-reply-btn"
icon="mdi-close"
size="x-small"
color="grey"
variant="text"
/>
</div>
</transition>
<transition name="attachments">
<div class="attachments-preview" v-if="hasStagedAttachments">
<div
v-for="(img, index) in stagedImagesUrl"
:key="'img-' + index"
class="attachment-card image-preview"
>
<img :src="img" class="preview-image" alt="attachment preview" />
<v-btn
@click="$emit('removeImage', index)"
class="remove-attachment-btn"
icon="mdi-close"
size="x-small"
color="error"
variant="tonal"
/>
</div>
<div v-if="stagedAudioUrl" class="attachment-card audio-preview">
<div class="attachment-icon attachment-icon--audio">
<v-icon icon="mdi-microphone" size="24"></v-icon>
</div>
<span class="attachment-name">{{ tm("voice.recording") }}</span>
<v-btn
@click="$emit('removeAudio')"
class="remove-attachment-btn"
icon="mdi-close"
size="x-small"
color="error"
variant="tonal"
/>
</div>
<div
v-for="(file, index) in stagedFiles"
:key="'file-' + index"
class="attachment-card file-preview"
>
<div
class="attachment-icon"
:style="{ '--attachment-color': filePresentation(file).color }"
>
<v-icon :icon="filePresentation(file).icon" size="24"></v-icon>
<span class="attachment-ext">{{
filePresentation(file).label
}}</span>
</div>
<span class="attachment-name">{{ file.original_name }}</span>
<v-btn
@click="$emit('removeFile', index)"
class="remove-attachment-btn"
icon="mdi-close"
size="x-small"
color="error"
variant="tonal"
/>
</div>
</div>
</transition>
<CommandSuggestion
:visible="showCommandSuggestion"
:commands="filteredCommands"
:selected-index="selectedCommandIndex"
:is-dark="isDark"
@select="handleCommandSelect"
@update-selected-index="selectedCommandIndex = $event"
/>
<div class="composer-row">
<div class="input-left-actions">
<!-- Settings Menu -->
<StyledMenu
offset="8"
location="top start"
:close-on-content-click="false"
>
<template v-slot:activator="{ props: activatorProps }">
<v-btn
v-bind="activatorProps"
icon="mdi-plus"
variant="outlined"
class="input-neutral-btn input-outline-control"
/>
</template>
<!-- Upload Files -->
<v-list-item
class="styled-menu-item"
rounded="md"
@click="triggerImageInput"
>
<template v-slot:prepend>
<v-icon icon="mdi-file-upload" size="small"></v-icon>
</template>
<v-list-item-title>
{{ tm("input.upload") }}
</v-list-item-title>
</v-list-item>
<!-- Config Selector in Menu -->
<ConfigSelector
:session-id="sessionId || null"
:platform-id="sessionPlatformId"
:is-group="sessionIsGroup"
:initial-config-id="props.configId"
@config-changed="handleConfigChange"
/>
<!-- Streaming Toggle in Menu -->
<v-list-item
class="styled-menu-item"
rounded="md"
@click="$emit('toggleStreaming')"
>
<template v-slot:prepend>
<v-icon icon="mdi-lightning-bolt" size="small"></v-icon>
</template>
<v-list-item-title>
{{
enableStreaming
? tm("streaming.enabled")
: tm("streaming.disabled")
}}
</v-list-item-title>
</v-list-item>
</StyledMenu>
</div>
<div class="input-field-shell">
<input
v-if="!inputIsMultiline"
ref="inputField"
v-model="localPrompt"
@keydown="handleKeyDown"
@input="handleInput"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
@compositioncancel="handleCompositionEnd"
@blur="handleBlur"
@paste="handlePaste"
:disabled="disabled"
:placeholder="tm('input.placeholder')"
class="chat-text-input"
autocomplete="off"
autocorrect="off"
autocapitalize="sentences"
spellcheck="false"
type="text"
/>
<textarea
v-else
ref="inputField"
v-model="localPrompt"
@keydown="handleKeyDown"
@input="handleInput"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
@compositioncancel="handleCompositionEnd"
@blur="handleBlur"
@paste="handlePaste"
:disabled="disabled"
:placeholder="tm('input.placeholder')"
class="chat-textarea"
autocomplete="off"
autocorrect="off"
autocapitalize="sentences"
spellcheck="false"
></textarea>
</div>
<div class="input-right-actions">
<input
type="file"
ref="imageInputRef"
@change="handleFileSelect"
style="display: none"
multiple
/>
<!-- Provider/Model Selector Menu -->
<ProviderModelMenu
v-if="props.showProviderSelector && providerSelectorAvailable"
ref="providerModelMenuRef"
/>
<v-progress-circular
v-if="disabled && !mobile"
indeterminate
size="16"
class="mr-1"
width="1.5"
/>
<!-- <v-btn @click="$emit('openLiveMode')"
icon
variant="text"
color="purple"
size="small"
>
<v-icon icon="mdi-phone-in-talk" variant="text" plain></v-icon>
<v-tooltip activator="parent" location="top">
{{ tm('voice.liveMode') }}
</v-tooltip>
</v-btn> -->
<v-btn
@click="handleRecordClick"
icon
variant="text"
class="record-btn input-icon-btn"
>
<v-icon
:icon="isRecording ? 'mdi-stop-circle' : 'mdi-microphone'"
variant="text"
plain
></v-icon>
<v-tooltip activator="parent" location="top">
{{
isRecording ? tm("voice.speaking") : tm("voice.startRecording")
}}
</v-tooltip>
</v-btn>
<v-btn
icon
v-if="isRunning && !canSend"
@click="$emit('stop')"
variant="tonal"
class="send-btn input-action-btn"
>
<v-icon icon="mdi-stop" variant="text" plain></v-icon>
<v-tooltip activator="parent" location="top">
{{ tm("input.stopGenerating") }}
</v-tooltip>
</v-btn>
<v-btn
v-else
@click="$emit('send')"
icon="mdi-arrow-up"
variant="tonal"
:disabled="!canSend"
class="send-btn input-action-btn"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
ref,
computed,
watch,
nextTick,
onMounted,
onBeforeUnmount,
} from "vue";
import { useDisplay } from "vuetify";
import { useModuleI18n } from "@/i18n/composables";
import { useCustomizerStore } from "@/stores/customizer";
import { isComposingEnter } from "@/utils/imeInput.mjs";
import { commandApi } from "@/api/v1";
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 { attachmentPresentation } from "./attachmentPresentation";
import type { Session } from "@/composables/useSessions";
import type { SuggestionCommand } from "./CommandSuggestion.vue";
interface StagedFileInfo {
attachment_id: string;
filename: string;
original_name: string;
url: string;
type: string;
}
interface ReplyInfo {
messageId: string | number;
selectedText?: string;
}
interface Props {
prompt: string;
stagedImagesUrl: string[];
stagedAudioUrl: string;
stagedFiles?: StagedFileInfo[];
disabled: boolean;
enableStreaming: boolean;
isRecording: boolean;
isRunning: boolean;
sessionId?: string | null;
currentSession?: Session | null;
configId?: string | null;
replyTo?: ReplyInfo | null;
sendShortcut?: "enter" | "shift_enter";
showProviderSelector?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
sessionId: null,
currentSession: null,
configId: null,
stagedFiles: () => [],
replyTo: null,
sendShortcut: "shift_enter",
showProviderSelector: true,
});
const emit = defineEmits<{
"update:prompt": [value: string];
send: [];
stop: [];
toggleStreaming: [];
removeImage: [index: number];
removeAudio: [];
removeFile: [index: number];
startRecording: [];
stopRecording: [];
pasteImage: [event: ClipboardEvent];
fileSelect: [files: FileList];
clearReply: [];
openLiveMode: [];
}>();
const { tm } = useModuleI18n("features/chat");
const isDark = computed(
() => useCustomizerStore().uiTheme === "PurpleThemeDark",
);
const inputField = ref<HTMLInputElement | HTMLTextAreaElement | null>(null);
const imageInputRef = ref<HTMLInputElement | null>(null);
const providerModelMenuRef = ref<InstanceType<typeof ProviderModelMenu> | null>(
null,
);
const providerSelectorAvailable = ref(true);
const isReplyClosing = ref(false);
const isDragging = ref(false);
const isComposing = ref(false);
const inputIsMultiline = 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);
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 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;
if (cmd.type === "group") {
// 指令组本身不加入,但其子指令加入
cmd.sub_commands?.forEach(addCommand);
return;
}
// 统一添加唤醒词前缀(子命令的 effective_command 如 "music play" 需要变成 "/music play"
const displayCmd = hasWakePrefix(cmd.effective_command)
? cmd.effective_command
: `${displayPrefix}${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 = hasWakePrefix(aliasBase)
? aliasBase
: `${displayPrefix}${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 || !hasWakePrefix(text)) 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) => {
// Suppress v-model sync during IME composition to avoid a reactive
// feedback loop. Vue's :value binding overwrites the native textarea
// DOM state mid-composition, which interferes with IME insertion at
// non-terminal cursor positions (alternating character loss).
// The final value is synced manually in handleCompositionEnd.
if (!isComposing.value) emit("update:prompt", value);
},
});
const sessionPlatformId = computed(
() => props.currentSession?.platform_id || "webchat",
);
const sessionIsGroup = computed(() => Boolean(props.currentSession?.is_group));
const canSend = computed(() => {
return (
(props.prompt && props.prompt.trim()) ||
props.stagedImagesUrl.length > 0 ||
props.stagedAudioUrl ||
(props.stagedFiles && props.stagedFiles.length > 0)
);
});
const hasStagedAttachments = computed(() => {
return (
props.stagedImagesUrl.length > 0 ||
props.stagedAudioUrl ||
(props.stagedFiles && props.stagedFiles.length > 0)
);
});
function filePresentation(file: StagedFileInfo) {
return attachmentPresentation(file);
}
// Ctrl+B 长按录音相关
const ctrlKeyDown = ref(false);
const ctrlKeyTimer = ref<number | null>(null);
const ctrlKeyLongPressThreshold = 300;
// 处理清除引用 - 触发关闭动画
function handleClearReply() {
isReplyClosing.value = true;
}
// 动画完成后发送clearReply事件
function handleReplyAfterLeave() {
emit("clearReply");
isReplyClosing.value = false;
}
const { mobile } = useDisplay();
// Auto-resize textarea
function autoResize() {
const el = inputField.value;
if (!el) return;
if (!(el instanceof HTMLTextAreaElement)) {
const shouldExpand =
localPrompt.value.includes("\n") ||
(el.clientWidth > 0 && el.scrollWidth > el.clientWidth + 4);
if (shouldExpand) {
const cursor = el.selectionStart ?? localPrompt.value.length;
inputIsMultiline.value = true;
nextTick(() => {
inputField.value?.focus();
inputField.value?.setSelectionRange(cursor, cursor);
autoResize();
});
}
return;
}
const isMobileViewport =
typeof window !== "undefined" &&
window.matchMedia("(max-width: 768px)").matches;
const viewportHeight =
typeof window !== "undefined" ? window.innerHeight : 900;
const minHeight = isMobileViewport ? 56 : 52;
const maxHeight = isMobileViewport
? Math.min(220, Math.round(viewportHeight * 0.42))
: Math.min(420, Math.round(viewportHeight * 0.48));
if (!localPrompt.value) {
inputIsMultiline.value = false;
el.style.height = minHeight + "px";
return;
}
el.style.height = "auto";
const measuredHeight = el.scrollHeight;
const shouldUseMultiline =
localPrompt.value.includes("\n") || measuredHeight > minHeight + 8;
if (inputIsMultiline.value !== shouldUseMultiline) {
const cursor = el.selectionStart ?? localPrompt.value.length;
inputIsMultiline.value = shouldUseMultiline;
nextTick(() => {
inputField.value?.focus();
inputField.value?.setSelectionRange(cursor, cursor);
autoResize();
});
return;
}
el.style.height = shouldUseMultiline
? Math.min(Math.max(measuredHeight, minHeight), maxHeight) + "px"
: minHeight + "px";
}
watch(
() => props.prompt,
(value) => {
if (!value) {
inputIsMultiline.value = false;
}
nextTick(autoResize);
},
);
watch(inputIsMultiline, () => {
nextTick(autoResize);
});
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 录音
if (e.ctrlKey && e.keyCode === 66) {
e.preventDefault();
if (ctrlKeyDown.value) return;
ctrlKeyDown.value = true;
ctrlKeyTimer.value = window.setTimeout(() => {
if (ctrlKeyDown.value && !props.isRecording) {
emit("startRecording");
}
}, ctrlKeyLongPressThreshold);
}
return;
}
if (isComposingEnter(e, isComposing.value, lastCompositionEndAt.value)) {
return;
}
const isSendHotkey =
e.ctrlKey ||
e.metaKey ||
(props.sendShortcut === "enter" ? !e.shiftKey : e.shiftKey);
if (isSendHotkey) {
e.preventDefault();
if (localPrompt.value.trim() === "/astr_live_dev") {
emit("openLiveMode");
localPrompt.value = "";
return;
}
if (canSend.value) {
emit("send");
}
return;
}
if (!inputIsMultiline.value) {
e.preventDefault();
const target = e.target as HTMLInputElement;
const start = target.selectionStart ?? localPrompt.value.length;
const end = target.selectionEnd ?? start;
localPrompt.value =
localPrompt.value.slice(0, start) + "\n" + localPrompt.value.slice(end);
inputIsMultiline.value = true;
nextTick(() => {
inputField.value?.focus();
inputField.value?.setSelectionRange(start + 1, start + 1);
autoResize();
});
}
}
/** 处理输入变化,控制命令提示显示 */
function handleInput() {
const text = props.prompt;
if (text && hasWakePrefix(text) && !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 cid = currentConfigId.value;
const res = await commandApi.list(cid && cid !== "default" ? cid : undefined);
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) {
// 静默失败,不影响聊天功能
console.warn("Failed to fetch commands for suggestion:", err);
} finally {
commandSuggestionLoading.value = false;
}
}
function handleCompositionStart() {
isComposing.value = true;
lastCompositionEndAt.value = null;
}
function handleCompositionEnd(e: CompositionEvent) {
lastCompositionEndAt.value = e.timeStamp;
clearCompositionState({ keepLastEndAt: true });
// Manually sync the final composited text to the parent component
// after the IME commits. The v-model setter is suppressed during
// composition (see localPrompt computed), so we must explicitly
// propagate the DOM value once composition ends.
//
// Capture the DOM value at compositionend to guard against a race
// where props.prompt is externally updated between now and nextTick.
const endValue = inputField.value?.value;
nextTick(() => {
const el = inputField.value;
// Only sync if the DOM hasn't been changed externally in the meantime.
if (el && el.value === endValue && el.value !== props.prompt) {
emit("update:prompt", el.value);
// Re-evaluate command suggestions that were suppressed during IME
// composition (handleInput checks isComposing). Only needed when
// the value actually changed. Runs in a nested nextTick so
// props.prompt reflects the emit above.
nextTick(() => {
handleInput();
});
}
});
}
function clearCompositionState({ keepLastEndAt = false } = {}) {
isComposing.value = false;
if (!keepLastEndAt) {
lastCompositionEndAt.value = null;
}
}
function handleKeyUp(e: KeyboardEvent) {
if (e.keyCode === 66) {
ctrlKeyDown.value = false;
if (ctrlKeyTimer.value) {
clearTimeout(ctrlKeyTimer.value);
ctrlKeyTimer.value = null;
}
if (props.isRecording) {
emit("stopRecording");
}
}
}
function handlePaste(e: ClipboardEvent) {
const pastedText = e.clipboardData?.getData("text/plain") || "";
if (!inputIsMultiline.value && pastedText.includes("\n")) {
e.preventDefault();
const target = e.target as HTMLInputElement;
const start = target.selectionStart ?? localPrompt.value.length;
const end = target.selectionEnd ?? start;
localPrompt.value =
localPrompt.value.slice(0, start) +
pastedText +
localPrompt.value.slice(end);
inputIsMultiline.value = true;
nextTick(() => {
inputField.value?.focus();
const cursor = start + pastedText.length;
inputField.value?.setSelectionRange(cursor, cursor);
autoResize();
});
}
emit("pasteImage", e);
}
function handleDragOver(e: DragEvent) {
// 清除之前的 leave timeout
if (dragLeaveTimeout) {
clearTimeout(dragLeaveTimeout);
dragLeaveTimeout = null;
}
// 检查是否有文件
if (e.dataTransfer?.types.includes("Files")) {
isDragging.value = true;
}
}
function handleDragLeave(e: DragEvent) {
// 使用 timeout 避免在子元素间移动时闪烁
dragLeaveTimeout = window.setTimeout(() => {
isDragging.value = false;
}, 50);
}
function handleDrop(e: DragEvent) {
isDragging.value = false;
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
emit("fileSelect", files);
}
}
function triggerImageInput() {
imageInputRef.value?.click();
}
function handleFileSelect(event: Event) {
const target = event.target as HTMLInputElement;
const files = target.files;
if (files) {
emit("fileSelect", files);
}
target.value = "";
}
function handleRecordClick() {
if (props.isRecording) {
emit("stopRecording");
} else {
emit("startRecording");
}
}
function handleConfigChange(payload: {
configId: string;
agentRunnerType: string;
}) {
const runnerType = (payload.agentRunnerType || "").toLowerCase();
const isInternal = runnerType === "internal" || runnerType === "local";
providerSelectorAvailable.value = isInternal;
// 配置切换后重新获取指令列表和唤醒词
if (payload.configId && payload.configId !== currentConfigId.value) {
currentConfigId.value = payload.configId;
fetchCommands();
}
}
function getCurrentSelection() {
if (!props.showProviderSelector || !providerSelectorAvailable.value) {
return null;
}
return providerModelMenuRef.value?.getCurrentSelection();
}
function focusInput() {
if (!inputField.value) return;
inputField.value.focus();
}
onMounted(() => {
document.addEventListener("keyup", handleKeyUp);
// 预加载指令列表
fetchCommands();
nextTick(autoResize);
});
onBeforeUnmount(() => {
clearCompositionState();
document.removeEventListener("keyup", handleKeyUp);
});
defineExpose({
getCurrentSelection,
focusInput,
});
</script>
<style scoped>
.input-area {
padding: 12px 16px 0;
background-color: transparent;
position: relative;
border-top: 1px solid var(--v-theme-border);
flex-shrink: 0;
}
.input-neutral-btn {
color: #6f6f6f !important;
}
.input-neutral-btn:hover {
background: #efefef;
}
.input-neutral-btn--tonal {
background: #efefef;
color: #4f4f4f !important;
}
.input-neutral-btn--tonal:hover {
background: #e7e7e7;
}
.input-action-btn {
background: #5594c6 !important;
color: #fff !important;
}
.input-action-btn:hover {
background: #4c86b3 !important;
}
.input-action-btn:disabled {
background: rgba(85, 148, 198, 0.24) !important;
color: rgba(255, 255, 255, 0.72) !important;
}
.input-icon-btn {
background: transparent !important;
color: rgb(var(--v-theme-on-surface)) !important;
margin-right: 8px;
}
.input-icon-btn:hover {
background: rgba(var(--v-theme-on-surface), 0.04) !important;
}
.input-outline-control {
width: 36px !important;
height: 36px !important;
min-width: 36px !important;
border: 0 !important;
border-color: transparent !important;
background: transparent !important;
box-shadow: none !important;
}
.input-outline-control:hover,
.input-outline-control:focus-visible {
border-color: transparent !important;
background: rgba(var(--v-theme-on-surface), 0.04) !important;
}
.input-area.is-dark .input-neutral-btn {
color: rgba(255, 255, 255, 0.78) !important;
}
.input-area.is-dark .input-neutral-btn:hover,
.input-area.is-dark .input-neutral-btn--tonal {
background: rgba(255, 255, 255, 0.1);
}
.input-area.is-dark .input-outline-control {
border-color: transparent !important;
background: transparent !important;
}
.input-area.is-dark .input-outline-control:hover,
.input-area.is-dark .input-outline-control:focus-visible {
border-color: transparent !important;
background: rgba(255, 255, 255, 0.06) !important;
}
.input-area.is-dark .input-action-btn {
background: rgb(var(--v-theme-on-surface)) !important;
color: rgb(var(--v-theme-surface)) !important;
}
.input-area.is-dark .input-action-btn:hover {
background: rgba(var(--v-theme-on-surface), 0.86) !important;
}
.input-area.is-dark .input-action-btn:disabled {
background: rgba(var(--v-theme-on-surface), 0.14) !important;
color: rgba(var(--v-theme-on-surface), 0.4) !important;
}
.input-container {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 64px;
padding: 6px 12px 6px 14px !important;
border-color: #f0f0f0 !important;
border-radius: 999px !important;
background: #fff !important;
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.08) !important;
}
.input-container.is-multiline {
justify-content: flex-start;
padding: 16px 20px 14px !important;
border-radius: 34px !important;
}
.input-container.has-attachments {
justify-content: flex-start;
min-height: 130px;
padding: 14px 18px 10px !important;
border-radius: 30px !important;
}
.input-area.is-dark .input-container {
border: 1px solid rgba(255, 255, 255, 0.12) !important;
background: #2d2d2d !important;
box-shadow: none !important;
}
.reply-preview,
.attachments-preview {
width: 100%;
flex: 0 0 auto;
}
.composer-row {
width: 100%;
min-height: 52px;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-areas: "left field right";
align-items: center;
column-gap: 10px;
}
.input-container.is-multiline .composer-row {
grid-template-areas:
"field field field"
"left . right";
row-gap: 10px;
align-items: end;
}
.input-field-shell {
grid-area: field;
min-width: 0;
min-height: 52px;
display: flex;
align-items: center;
}
.input-container.is-multiline .input-field-shell {
min-height: auto;
align-items: flex-start;
}
.chat-text-input,
.chat-textarea {
display: block;
width: 100%;
box-sizing: border-box;
min-width: 0;
min-height: 52px !important;
max-height: 72px !important;
margin: 0;
padding: 0 !important;
border: 0 !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
resize: none;
outline: none;
font-family: inherit;
font-size: 18px !important;
}
.chat-text-input {
height: 52px !important;
padding: 0 !important;
line-height: normal !important;
overflow: hidden;
}
.chat-textarea {
max-height: min(48vh, 420px) !important;
padding: 12px 0 !important;
overflow-y: auto;
overflow-wrap: break-word;
line-height: 28px !important;
transition: height 0.16s ease;
}
.chat-text-input::placeholder,
.chat-textarea::placeholder {
color: rgba(var(--v-theme-on-surface), 0.56);
opacity: 1;
}
.input-left-actions {
grid-area: left;
display: flex;
align-items: center;
flex: 0 0 auto !important;
justify-content: center !important;
gap: 0 !important;
min-width: auto !important;
margin-top: 0 !important;
overflow: visible !important;
}
.input-right-actions {
grid-area: right;
display: flex;
align-items: center;
justify-content: flex-end;
flex-shrink: 0;
gap: 10px;
margin-top: 0 !important;
}
.input-outline-control {
width: 34px !important;
height: 34px !important;
min-width: 34px !important;
border: 0 !important;
border-color: transparent !important;
border-radius: 50% !important;
box-shadow: none !important;
}
.input-icon-btn {
width: 42px !important;
height: 42px !important;
min-width: 42px !important;
margin-right: 0;
}
.input-right-actions :deep(.provider-chip) {
height: 40px !important;
min-height: 40px !important;
border-radius: 999px !important;
}
.input-area:not(.is-dark) .input-action-btn {
width: 46px !important;
height: 46px !important;
min-width: 46px !important;
background: #8fcfb4 !important;
color: #fff !important;
}
.input-area:not(.is-dark) .input-action-btn:hover {
background: #7fc4a8 !important;
}
.input-area:not(.is-dark) .input-action-btn:disabled {
background: #f2f5f3 !important;
color: rgba(0, 0, 0, 0.18) !important;
}
/* 拖拽上传遮罩 */
.drop-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(var(--v-theme-primary), 0.12);
border: 2px dashed rgba(var(--v-theme-primary), 0.45);
border-radius: 24px;
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
pointer-events: none;
}
.drop-overlay-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.drop-text {
font-size: 16px;
font-weight: 500;
color: rgb(var(--v-theme-primary));
}
/* Fade transition for drop overlay */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.reply-preview {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
margin: 8px 8px 0 8px;
background-color: rgba(var(--v-theme-primary), 0.06);
border-radius: 12px;
gap: 8px;
max-height: 500px;
overflow: hidden;
}
/* Transition animations for reply preview */
.slideReply-enter-active {
animation: slideDown 0.2s ease-out;
}
.slideReply-leave-active {
animation: slideUp 0.2s ease-out;
}
@keyframes slideDown {
from {
max-height: 0;
opacity: 0;
margin-top: 0;
padding-top: 0;
padding-bottom: 0;
}
to {
max-height: 500px;
opacity: 1;
margin-top: 8px;
padding-top: 8px;
padding-bottom: 8px;
}
}
@keyframes slideUp {
from {
max-height: 500px;
opacity: 1;
margin-top: 8px;
padding-top: 8px;
padding-bottom: 8px;
}
to {
max-height: 0;
opacity: 0;
margin-top: 0;
padding-top: 0;
padding-bottom: 0;
}
}
.reply-content {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
overflow: hidden;
}
.reply-icon {
color: var(--v-theme-secondary);
flex-shrink: 0;
}
.reply-text {
font-size: 13px;
color: var(--v-theme-secondaryText);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.remove-reply-btn {
flex-shrink: 0;
opacity: 0.6;
}
.attachments-preview {
display: flex;
gap: 10px;
margin: 10px 12px 0;
padding: 2px 2px 4px;
flex-wrap: nowrap;
align-items: center;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
max-height: 72px;
}
.input-container.has-attachments .attachments-preview {
margin: 0 0 8px;
padding: 0;
}
.attachment-card {
--attachment-color: #607d8b;
position: relative;
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
width: 210px;
height: 54px;
flex: 0 0 auto;
min-width: 0;
padding: 7px 32px 7px 10px;
overflow: hidden;
color: rgb(var(--v-theme-on-surface));
background: rgba(var(--v-theme-on-surface), 0.055);
border: 0;
border-radius: 8px;
}
.file-preview {
background: rgba(var(--v-theme-on-surface), 0.055);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--attachment-color) 14%, transparent),
rgba(var(--v-theme-on-surface), 0.055) 62%
);
}
.image-preview {
width: 54px;
flex-basis: 54px;
padding: 0;
background: rgba(var(--v-theme-on-surface), 0.06);
}
.preview-image {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 8px;
}
.attachment-icon {
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1px;
flex-shrink: 0;
min-width: 34px;
color: var(--attachment-color);
}
.attachment-icon--audio {
color: #00897b;
}
.attachment-ext {
max-width: 58px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 10px;
font-weight: 700;
line-height: 12px;
color: var(--attachment-color);
}
.attachment-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
line-height: 17px;
}
.remove-attachment-btn {
position: absolute;
top: 4px;
right: 4px;
width: 22px !important;
height: 22px !important;
min-width: 22px !important;
opacity: 0.8;
transition: opacity 0.2s;
}
.remove-attachment-btn:hover {
opacity: 1;
}
.fade-in {
animation: fadeIn 0.3s ease-in-out;
}
.attachments-enter-active,
.attachments-leave-active {
overflow: hidden;
transition:
max-height 0.2s ease,
margin 0.2s ease,
padding 0.2s ease,
opacity 0.16s ease,
transform 0.2s ease;
}
.attachments-enter-from,
.attachments-leave-to {
max-height: 0;
margin-top: 0;
padding-top: 0;
padding-bottom: 0;
opacity: 0;
transform: translateY(6px);
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 768px) {
.input-area {
padding: 8px 0 0 !important;
border-top: 0;
}
.input-container {
display: flex !important;
flex-direction: column;
justify-content: center;
width: calc(100% - 20px) !important;
max-width: 100% !important;
min-height: 64px;
margin: 0 10px calc(8px + env(safe-area-inset-bottom)) !important;
padding: 6px 8px 6px 10px !important;
overflow: hidden;
border: 1px solid rgba(var(--v-theme-on-surface), 0.14) !important;
border-radius: 999px !important;
background: #fff !important;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08) !important;
}
.input-container.is-multiline {
justify-content: flex-start;
min-height: 128px;
padding: 10px !important;
border-radius: 26px !important;
}
.input-container.has-attachments {
justify-content: flex-start;
min-height: 124px;
padding: 10px !important;
border-radius: 26px !important;
}
.input-area.is-dark .input-container {
border-color: rgba(255, 255, 255, 0.16) !important;
background: #2d2d2d !important;
box-shadow: none !important;
}
.composer-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-areas: "left field right";
min-height: 52px;
row-gap: 0;
column-gap: 8px;
align-items: center;
}
.input-container.is-multiline .composer-row {
grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas:
"field field"
"left right";
min-height: auto;
row-gap: 4px;
}
.input-field-shell {
min-height: 52px;
align-items: center;
}
.input-container.is-multiline .input-field-shell {
min-height: 56px;
align-items: flex-start;
}
.input-left-actions,
.input-right-actions {
margin-top: 0 !important;
align-items: center !important;
}
.input-right-actions {
gap: 6px;
}
.input-outline-control {
width: 38px !important;
height: 38px !important;
min-width: 38px !important;
border: 0 !important;
border-color: transparent !important;
border-radius: 50% !important;
}
.chat-text-input,
.chat-textarea {
min-height: 52px !important;
max-height: 132px !important;
border: 0 !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
font-size: 18px !important;
}
.chat-text-input {
height: 52px !important;
padding: 0 2px !important;
line-height: normal !important;
overflow: hidden;
}
.chat-textarea {
max-height: min(42vh, 220px) !important;
padding: 4px 10px 2px !important;
line-height: 24px !important;
overflow-y: auto;
}
.chat-text-input::placeholder,
.chat-textarea::placeholder {
color: rgba(var(--v-theme-on-surface), 0.56);
opacity: 1;
}
.input-icon-btn {
width: 38px !important;
height: 38px !important;
min-width: 38px !important;
margin-right: 0;
}
.input-action-btn {
width: 42px !important;
height: 42px !important;
min-width: 42px !important;
border-radius: 50% !important;
}
.input-action-btn:not(:disabled) {
background: rgb(var(--v-theme-on-surface)) !important;
color: rgb(var(--v-theme-surface)) !important;
}
.input-action-btn:disabled {
background: rgba(var(--v-theme-on-surface), 0.04) !important;
color: rgba(var(--v-theme-on-surface), 0.18) !important;
}
:deep(.provider-chip) {
height: 38px !important;
min-height: 38px !important;
border-radius: 999px !important;
padding: 0 12px !important;
font-size: 14px !important;
border-color: rgba(var(--v-theme-on-surface), 0.18) !important;
background: transparent !important;
}
.attachments-preview {
margin: 8px 16px 0;
gap: 8px;
}
.input-container.has-attachments .attachments-preview {
margin: 0 0 8px;
}
.attachment-card {
width: min(220px, calc(100vw - 28px));
height: 54px;
}
.image-preview {
width: 54px;
flex-basis: 54px;
}
}
</style>