278 lines
8.7 KiB
Vue
278 lines
8.7 KiB
Vue
<template>
|
||
<div class="input-area compact-input-area">
|
||
<div class="stadium-input-wrapper" ref="stadiumShellOuter">
|
||
<div
|
||
class="stadium-shell"
|
||
ref="compactInputShell"
|
||
:class="{
|
||
'is-multiline': inputIsMultiline,
|
||
'is-focused': inputIsFocused,
|
||
'has-text': (inputMessage || '').trim().length > 0
|
||
}"
|
||
>
|
||
<input type="file" ref="fileUploadInput" class="file-input-hidden" @change="onFileChange" />
|
||
<div class="input-stack">
|
||
<div v-if="selectedImages && selectedImages.length" class="image-inline-row">
|
||
<span class="image-name" v-for="img in selectedImages" :key="img">
|
||
{{ formatImageName(img) }}
|
||
<button type="button" class="image-remove-btn" @click.stop="$emit('remove-image', img)">×</button>
|
||
</span>
|
||
</div>
|
||
<div v-if="selectedVideos && selectedVideos.length" class="image-inline-row video-inline-row">
|
||
<span class="image-name" v-for="video in selectedVideos" :key="video">
|
||
{{ formatImageName(video) }}
|
||
<button type="button" class="image-remove-btn" @click.stop="$emit('remove-video', video)">×</button>
|
||
</span>
|
||
</div>
|
||
<div class="input-row">
|
||
<button
|
||
type="button"
|
||
class="stadium-btn add-btn"
|
||
@click.stop="$emit('toggle-quick-menu')"
|
||
:disabled="!isConnected"
|
||
>
|
||
+
|
||
</button>
|
||
<textarea
|
||
ref="stadiumInput"
|
||
class="stadium-input"
|
||
rows="1"
|
||
:value="inputMessage"
|
||
:disabled="!isConnected || streamingMessage || inputLocked"
|
||
placeholder="输入消息... (Ctrl+Enter 发送)"
|
||
@input="onInput"
|
||
@focus="$emit('input-focus')"
|
||
@blur="$emit('input-blur')"
|
||
@keydown.enter.ctrl.prevent="$emit('send-message')"
|
||
></textarea>
|
||
<button
|
||
type="button"
|
||
class="stadium-btn send-btn"
|
||
@click="$emit('send-or-stop')"
|
||
:disabled="
|
||
!isConnected ||
|
||
(inputLocked && !streamingMessage) ||
|
||
((!(inputMessage || '').trim() && (!selectedImages?.length && !selectedVideos?.length)) && !streamingMessage)
|
||
"
|
||
>
|
||
<span v-if="streamingMessage" class="stop-icon"></span>
|
||
<span v-else class="send-icon"></span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<QuickMenu
|
||
:open="quickMenuOpen"
|
||
:is-connected="isConnected"
|
||
:uploading="uploading"
|
||
:streaming-message="streamingMessage"
|
||
:thinking-mode="thinkingMode"
|
||
:run-mode="runMode"
|
||
:model-menu-open="modelMenuOpen"
|
||
:model-options="modelOptions"
|
||
:current-model-key="currentModelKey"
|
||
:tool-menu-open="toolMenuOpen"
|
||
:tool-settings="toolSettings"
|
||
:tool-settings-loading="toolSettingsLoading"
|
||
:settings-open="settingsOpen"
|
||
:mode-menu-open="modeMenuOpen"
|
||
:compressing="compressing"
|
||
:current-conversation-id="currentConversationId"
|
||
:icon-style="iconStyle"
|
||
:tool-category-icon="toolCategoryIcon"
|
||
:block-upload="blockUpload"
|
||
:block-tool-toggle="blockToolToggle"
|
||
:block-realtime-terminal="blockRealtimeTerminal"
|
||
:block-focus-panel="blockFocusPanel"
|
||
:block-token-panel="blockTokenPanel"
|
||
:block-compress-conversation="blockCompressConversation"
|
||
:block-conversation-review="blockConversationReview"
|
||
@quick-upload="triggerQuickUpload"
|
||
@pick-images="$emit('pick-images')"
|
||
@pick-video="$emit('pick-video')"
|
||
@toggle-tool-menu="$emit('toggle-tool-menu')"
|
||
@toggle-settings="$emit('toggle-settings')"
|
||
@toggle-mode-menu="$emit('toggle-mode-menu')"
|
||
@select-run-mode="(mode) => $emit('select-run-mode', mode)"
|
||
@toggle-model-menu="$emit('toggle-model-menu')"
|
||
@select-model="(key) => $emit('select-model', key)"
|
||
@update-tool-category="(id, enabled) => $emit('update-tool-category', id, enabled)"
|
||
@realtime-terminal="$emit('realtime-terminal')"
|
||
@toggle-focus-panel="$emit('toggle-focus-panel')"
|
||
@toggle-token-panel="$emit('toggle-token-panel')"
|
||
@compress-conversation="$emit('compress-conversation')"
|
||
@open-review="$emit('open-review')"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { onMounted, ref, watch, nextTick } from 'vue';
|
||
import QuickMenu from '@/components/input/QuickMenu.vue';
|
||
import { useInputStore } from '@/stores/input';
|
||
|
||
defineOptions({ name: 'InputComposer' });
|
||
|
||
const emit = defineEmits([
|
||
'update:input-message',
|
||
'input-change',
|
||
'input-focus',
|
||
'input-blur',
|
||
'toggle-quick-menu',
|
||
'send-message',
|
||
'send-or-stop',
|
||
'quick-upload',
|
||
'pick-images',
|
||
'pick-video',
|
||
'toggle-tool-menu',
|
||
'toggle-mode-menu',
|
||
'toggle-model-menu',
|
||
'select-run-mode',
|
||
'select-model',
|
||
'toggle-settings',
|
||
'update-tool-category',
|
||
'realtime-terminal',
|
||
'toggle-focus-panel',
|
||
'toggle-token-panel',
|
||
'compress-conversation',
|
||
'file-selected',
|
||
'remove-image',
|
||
'remove-video',
|
||
'open-review'
|
||
]);
|
||
|
||
const props = defineProps<{
|
||
inputMessage: string;
|
||
inputIsMultiline: boolean;
|
||
inputIsFocused: boolean;
|
||
isConnected: boolean;
|
||
streamingMessage: boolean;
|
||
inputLocked: boolean;
|
||
uploading: boolean;
|
||
thinkingMode: boolean;
|
||
runMode: 'fast' | 'thinking' | 'deep';
|
||
quickMenuOpen: boolean;
|
||
toolMenuOpen: boolean;
|
||
modeMenuOpen: boolean;
|
||
modelMenuOpen: boolean;
|
||
toolSettings: Array<{ id: string; label: string; enabled: boolean }>;
|
||
toolSettingsLoading: boolean;
|
||
settingsOpen: boolean;
|
||
compressing: boolean;
|
||
currentConversationId: string | null;
|
||
iconStyle: (key: string) => Record<string, string>;
|
||
toolCategoryIcon: (categoryId: string) => string;
|
||
modelOptions: Array<{ key: string; label: string; description: string; disabled?: boolean }>;
|
||
currentModelKey: string;
|
||
selectedImages?: string[];
|
||
selectedVideos?: string[];
|
||
blockUpload?: boolean;
|
||
blockToolToggle?: boolean;
|
||
blockRealtimeTerminal?: boolean;
|
||
blockFocusPanel?: boolean;
|
||
blockTokenPanel?: boolean;
|
||
blockCompressConversation?: boolean;
|
||
blockConversationReview?: boolean;
|
||
}>();
|
||
|
||
const inputStore = useInputStore();
|
||
const stadiumShellOuter = ref<HTMLElement | null>(null);
|
||
const compactInputShell = ref<HTMLElement | null>(null);
|
||
const stadiumInput = ref<HTMLTextAreaElement | null>(null);
|
||
const fileUploadInput = ref<HTMLInputElement | null>(null);
|
||
|
||
const formatImageName = (path: string): string => {
|
||
if (!path) return '';
|
||
const parts = path.split(/[/\\]/);
|
||
return parts[parts.length - 1] || path;
|
||
};
|
||
|
||
const applyLineMetrics = (lines: number, multiline: boolean) => {
|
||
inputStore.setInputLineCount(lines);
|
||
inputStore.setInputMultiline(multiline);
|
||
};
|
||
|
||
const adjustTextareaSize = () => {
|
||
const textarea = stadiumInput.value;
|
||
if (!textarea) {
|
||
return;
|
||
}
|
||
const previousHeight = textarea.offsetHeight;
|
||
textarea.style.height = 'auto';
|
||
const computedStyle = window.getComputedStyle(textarea);
|
||
const lineHeight = parseFloat(computedStyle.lineHeight || '20') || 20;
|
||
const maxHeight = lineHeight * 6;
|
||
const targetHeight = Math.min(textarea.scrollHeight, maxHeight);
|
||
const lines = Math.max(1, Math.round(targetHeight / lineHeight));
|
||
const multiline = targetHeight > lineHeight * 1.4;
|
||
applyLineMetrics(lines, multiline);
|
||
|
||
if (Math.abs(targetHeight - previousHeight) <= 0.5) {
|
||
textarea.style.height = `${targetHeight}px`;
|
||
return;
|
||
}
|
||
textarea.style.height = `${previousHeight}px`;
|
||
void textarea.offsetHeight;
|
||
requestAnimationFrame(() => {
|
||
textarea.style.height = `${targetHeight}px`;
|
||
});
|
||
};
|
||
|
||
const onInput = (event: Event) => {
|
||
const target = event.target as HTMLTextAreaElement;
|
||
emit('update:input-message', target.value);
|
||
emit('input-change');
|
||
adjustTextareaSize();
|
||
};
|
||
|
||
const onFileChange = (event: Event) => {
|
||
const target = event.target as HTMLInputElement;
|
||
emit('file-selected', target?.files || null);
|
||
if (target) {
|
||
target.value = '';
|
||
}
|
||
};
|
||
|
||
const triggerQuickUpload = () => {
|
||
if (!props.isConnected || props.uploading) {
|
||
return;
|
||
}
|
||
emit('quick-upload');
|
||
};
|
||
|
||
defineExpose({
|
||
stadiumShellOuter,
|
||
compactInputShell,
|
||
stadiumInput,
|
||
fileUploadInput
|
||
});
|
||
|
||
watch(
|
||
() => props.inputMessage,
|
||
async () => {
|
||
await nextTick();
|
||
adjustTextareaSize();
|
||
}
|
||
);
|
||
|
||
onMounted(() => {
|
||
adjustTextareaSize();
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.image-inline-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
padding: 4px 10px 2px;
|
||
font-size: 12px;
|
||
color: var(--text-secondary, #7f8792);
|
||
line-height: 1.4;
|
||
}
|
||
.image-name {
|
||
white-space: nowrap;
|
||
}
|
||
</style>
|