agent-Specialization/static/src/components/input/InputComposer.vue

278 lines
8.7 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 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>