Files
AstrBot/dashboard/src/components/chat/ChatInput.vue
T

410 lines
12 KiB
Vue

<template>
<div class="input-area fade-in">
<div class="input-container"
:style="{
width: '85%',
maxWidth: '900px',
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' : 'transparent'
}">
<!-- 引用预览区 -->
<div class="reply-preview" v-if="props.replyTo">
<div class="reply-content">
<v-icon size="small" class="reply-icon">mdi-reply</v-icon>
"<span class="reply-text">{{ props.replyTo.messageContent }}</span>"
</div>
<v-btn @click="$emit('clearReply')" class="remove-reply-btn" icon="mdi-close" size="x-small" color="grey" variant="text" />
</div>
<textarea
ref="inputField"
v-model="localPrompt"
@keydown="handleKeyDown"
:disabled="disabled"
placeholder="Ask AstrBot..."
style="width: 100%; resize: none; outline: none; border: 1px solid var(--v-theme-border); border-radius: 12px; padding: 12px 16px; min-height: 40px; font-family: inherit; font-size: 16px; background-color: var(--v-theme-surface);"></textarea>
<div style="display: flex; justify-content: space-between; align-items: center; padding: 6px 14px;">
<div style="display: flex; justify-content: flex-start; margin-top: 4px; align-items: center; gap: 8px;">
<ConfigSelector
:session-id="sessionId || null"
:platform-id="sessionPlatformId"
:is-group="sessionIsGroup"
:initial-config-id="props.configId"
@config-changed="handleConfigChange"
/>
<!-- Provider/Model Selector Menu -->
<ProviderModelMenu v-if="showProviderSelector" ref="providerModelMenuRef" />
<v-tooltip :text="enableStreaming ? tm('streaming.enabled') : tm('streaming.disabled')" location="top">
<template v-slot:activator="{ props }">
<v-chip v-bind="props" @click="$emit('toggleStreaming')" size="x-small" class="streaming-toggle-chip">
<v-icon start :icon="enableStreaming ? 'mdi-flash' : 'mdi-flash-off'" size="small"></v-icon>
{{ enableStreaming ? tm('streaming.on') : tm('streaming.off') }}
</v-chip>
</template>
</v-tooltip>
</div>
<div style="display: flex; justify-content: flex-end; margin-top: 8px; align-items: center;">
<input type="file" ref="imageInputRef" @change="handleFileSelect"
style="display: none" multiple />
<v-progress-circular v-if="disabled" indeterminate size="16" class="mr-1" width="1.5" />
<v-btn @click="triggerImageInput" icon="mdi-plus" variant="text" color="deep-purple"
class="add-btn" size="small" />
<v-btn @click="handleRecordClick"
:icon="isRecording ? 'mdi-stop-circle' : 'mdi-microphone'" variant="text"
:color="isRecording ? 'error' : 'deep-purple'" class="record-btn" size="small" />
<v-btn @click="$emit('send')" icon="mdi-send" variant="text" color="deep-purple"
:disabled="!canSend" class="send-btn" size="small" />
</div>
</div>
</div>
<!-- 附件预览区 -->
<div class="attachments-preview" v-if="stagedImagesUrl.length > 0 || stagedAudioUrl || (stagedFiles && stagedFiles.length > 0)">
<div v-for="(img, index) in stagedImagesUrl" :key="'img-' + index" class="image-preview">
<img :src="img" class="preview-image" />
<v-btn @click="$emit('removeImage', index)" class="remove-attachment-btn" icon="mdi-close"
size="small" color="error" variant="text" />
</div>
<div v-if="stagedAudioUrl" class="audio-preview">
<v-chip color="deep-purple-lighten-4" class="audio-chip">
<v-icon start icon="mdi-microphone" size="small"></v-icon>
{{ tm('voice.recording') }}
</v-chip>
<v-btn @click="$emit('removeAudio')" class="remove-attachment-btn" icon="mdi-close" size="small"
color="error" variant="text" />
</div>
<div v-for="(file, index) in stagedFiles" :key="'file-' + index" class="file-preview">
<v-chip color="blue-grey-lighten-4" class="file-chip">
<v-icon start icon="mdi-file-document-outline" size="small"></v-icon>
<span class="file-name-preview">{{ file.original_name }}</span>
</v-chip>
<v-btn @click="$emit('removeFile', index)" class="remove-attachment-btn" icon="mdi-close" size="small"
color="error" variant="text" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import { useCustomizerStore } from '@/stores/customizer';
import ConfigSelector from './ConfigSelector.vue';
import ProviderModelMenu from './ProviderModelMenu.vue';
import type { Session } from '@/composables/useSessions';
interface StagedFileInfo {
attachment_id: string;
filename: string;
original_name: string;
url: string;
type: string;
}
interface ReplyInfo {
messageId: number;
messageContent: string;
}
interface Props {
prompt: string;
stagedImagesUrl: string[];
stagedAudioUrl: string;
stagedFiles?: StagedFileInfo[];
disabled: boolean;
enableStreaming: boolean;
isRecording: boolean;
sessionId?: string | null;
currentSession?: Session | null;
configId?: string | null;
replyTo?: ReplyInfo | null;
}
const props = withDefaults(defineProps<Props>(), {
sessionId: null,
currentSession: null,
configId: null,
stagedFiles: () => [],
replyTo: null
});
const emit = defineEmits<{
'update:prompt': [value: string];
send: [];
toggleStreaming: [];
removeImage: [index: number];
removeAudio: [];
removeFile: [index: number];
startRecording: [];
stopRecording: [];
pasteImage: [event: ClipboardEvent];
fileSelect: [files: FileList];
clearReply: [];
}>();
const { tm } = useModuleI18n('features/chat');
const isDark = computed(() => useCustomizerStore().uiTheme === 'PurpleThemeDark');
const inputField = ref<HTMLTextAreaElement | null>(null);
const imageInputRef = ref<HTMLInputElement | null>(null);
const providerModelMenuRef = ref<InstanceType<typeof ProviderModelMenu> | null>(null);
const showProviderSelector = ref(true);
const localPrompt = computed({
get: () => props.prompt,
set: (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);
});
// Ctrl+B 长按录音相关
const ctrlKeyDown = ref(false);
const ctrlKeyTimer = ref<number | null>(null);
const ctrlKeyLongPressThreshold = 300;
function handleKeyDown(e: KeyboardEvent) {
// Enter 发送消息
if (e.keyCode === 13 && !e.shiftKey) {
e.preventDefault();
if (canSend.value) {
emit('send');
}
}
// 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);
}
}
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) {
emit('pasteImage', e);
}
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';
showProviderSelector.value = isInternal;
}
function getCurrentSelection() {
if (!showProviderSelector.value) {
return null;
}
return providerModelMenuRef.value?.getCurrentSelection();
}
onMounted(() => {
if (inputField.value) {
inputField.value.addEventListener('paste', handlePaste);
}
document.addEventListener('keyup', handleKeyUp);
});
onBeforeUnmount(() => {
if (inputField.value) {
inputField.value.removeEventListener('paste', handlePaste);
}
document.removeEventListener('keyup', handleKeyUp);
});
defineExpose({
getCurrentSelection
});
</script>
<style scoped>
.input-area {
padding: 16px;
background-color: transparent;
position: relative;
border-top: 1px solid var(--v-theme-border);
flex-shrink: 0;
}
.reply-preview {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
margin: 8px 8px 0 8px;
background-color: rgba(103, 58, 183, 0.06);
border-radius: 12px;
gap: 8px;
}
.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: 8px;
margin-top: 8px;
max-width: 900px;
margin: 8px auto 0;
flex-wrap: wrap;
}
.image-preview,
.audio-preview,
.file-preview {
position: relative;
display: inline-flex;
}
.preview-image {
width: 60px;
height: 60px;
object-fit: cover;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.audio-chip,
.file-chip {
height: 36px;
border-radius: 18px;
}
.file-name-preview {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.remove-attachment-btn {
position: absolute;
top: -8px;
right: -8px;
opacity: 0.8;
transition: opacity 0.2s;
}
.remove-attachment-btn:hover {
opacity: 1;
}
.streaming-toggle-chip {
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
}
.streaming-toggle-chip:hover {
opacity: 0.8;
}
.fade-in {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 768px) {
.input-area {
padding: 0 !important;
}
.input-container {
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
border-radius: 0 !important;
border-left: none !important;
border-right: none !important;
border-bottom: none !important;
}
}
</style>