508 lines
15 KiB
Vue
508 lines
15 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"
|
||
multiple
|
||
@change="onFileChange"
|
||
/>
|
||
<div class="input-stack">
|
||
<div v-if="selectedImages && selectedImages.length" class="image-inline-row">
|
||
<div class="image-thumbnail-wrapper" v-for="img in selectedImages" :key="img">
|
||
<img :src="getPreviewUrl(img)" :alt="formatImageName(img)" class="image-thumbnail" />
|
||
<button
|
||
type="button"
|
||
class="image-remove-btn-hover"
|
||
@click.stop="$emit('remove-image', img)"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div
|
||
v-if="selectedVideos && selectedVideos.length"
|
||
class="image-inline-row video-inline-row"
|
||
>
|
||
<div class="image-thumbnail-wrapper" v-for="video in selectedVideos" :key="video">
|
||
<img
|
||
:src="getPreviewUrl(video)"
|
||
:alt="formatImageName(video)"
|
||
class="image-thumbnail"
|
||
/>
|
||
<button
|
||
type="button"
|
||
class="image-remove-btn-hover"
|
||
@click.stop="$emit('remove-video', video)"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="input-row">
|
||
<button
|
||
type="button"
|
||
class="stadium-btn add-btn"
|
||
data-tutorial="quick-menu-open"
|
||
@click.stop="$emit('toggle-quick-menu')"
|
||
:disabled="!isConnected"
|
||
>
|
||
+
|
||
</button>
|
||
<textarea
|
||
:key="composerInputKey"
|
||
ref="stadiumInput"
|
||
class="stadium-input"
|
||
rows="1"
|
||
:value="inputMessage"
|
||
:disabled="!isConnected || streamingMessage || inputLocked"
|
||
autocomplete="off"
|
||
autocorrect="off"
|
||
autocapitalize="off"
|
||
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) ||
|
||
(mediaUploading && !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="(val) => $emit('toggle-token-panel', val)"
|
||
@compress-conversation="$emit('compress-conversation')"
|
||
@toggle-approval-panel="$emit('toggle-approval-panel')"
|
||
@open-review="$emit('open-review')"
|
||
/>
|
||
<div class="permission-switcher" @click.stop>
|
||
<button
|
||
type="button"
|
||
class="permission-switcher__btn"
|
||
:disabled="!isConnected || streamingMessage"
|
||
@click="$emit('open-versioning-dialog')"
|
||
>
|
||
<span>版本控制:{{ versioningEnabled ? '开启' : '关闭' }}</span>
|
||
</button>
|
||
<div class="permission-switcher__block">
|
||
<button
|
||
type="button"
|
||
class="permission-switcher__btn"
|
||
:disabled="!isConnected || streamingMessage"
|
||
@click="$emit('toggle-permission-menu')"
|
||
>
|
||
<span>权限:{{ currentPermissionLabel }}</span>
|
||
<span class="permission-switcher__caret" :class="{ open: permissionMenuOpen }">›</span>
|
||
</button>
|
||
<div v-if="permissionMenuOpen" class="permission-switcher__menu">
|
||
<button
|
||
v-for="option in permissionOptions"
|
||
:key="option.value"
|
||
type="button"
|
||
class="permission-switcher__item"
|
||
:class="{ active: option.value === currentPermissionMode }"
|
||
@click="$emit('change-permission-mode', option.value)"
|
||
>
|
||
<span class="permission-switcher__item-label">{{ option.label }}</span>
|
||
<span class="permission-switcher__item-desc">{{ option.description }}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="context-usage-switcher" @click.stop>
|
||
<button
|
||
type="button"
|
||
class="context-usage-switcher__btn"
|
||
:disabled="!isConnected"
|
||
@click="$emit('toggle-token-panel')"
|
||
>
|
||
<span
|
||
class="context-usage-ring"
|
||
:style="{
|
||
'--context-progress': `${contextUsagePercent}%`,
|
||
'--context-ring-color': contextUsageColor
|
||
}"
|
||
aria-hidden="true"
|
||
>
|
||
<span class="context-usage-ring__inner"></span>
|
||
</span>
|
||
</button>
|
||
<div class="context-usage-switcher__tooltip">
|
||
<div>{{ contextUsagePercentLabel }}已用</div>
|
||
<div>{{ contextUsageCompactText }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { onMounted, ref, watch, nextTick, computed } from 'vue';
|
||
import QuickMenu from '@/components/input/QuickMenu.vue';
|
||
import { useInputStore } from '@/stores/input';
|
||
import { usePersonalizationStore } from '@/stores/personalization';
|
||
|
||
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',
|
||
'toggle-approval-panel',
|
||
'file-selected',
|
||
'remove-image',
|
||
'remove-video',
|
||
'open-review',
|
||
'toggle-permission-menu',
|
||
'change-permission-mode',
|
||
'open-versioning-dialog'
|
||
]);
|
||
|
||
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;
|
||
supportsImage?: boolean;
|
||
supportsVideo?: boolean;
|
||
contextWindow?: number | null;
|
||
}>;
|
||
currentModelKey: string;
|
||
selectedImages?: string[];
|
||
selectedVideos?: string[];
|
||
mediaUploading?: boolean;
|
||
blockUpload?: boolean;
|
||
blockToolToggle?: boolean;
|
||
blockRealtimeTerminal?: boolean;
|
||
blockFocusPanel?: boolean;
|
||
blockTokenPanel?: boolean;
|
||
blockCompressConversation?: boolean;
|
||
blockConversationReview?: boolean;
|
||
currentPermissionMode: 'readonly' | 'approval' | 'unrestricted';
|
||
permissionMenuOpen: boolean;
|
||
permissionOptions: Array<{ value: string; label: string; description: string }>;
|
||
currentContextTokens: number;
|
||
versioningEnabled?: boolean;
|
||
}>();
|
||
|
||
const inputStore = useInputStore();
|
||
const personalizationStore = usePersonalizationStore();
|
||
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 getPreviewUrl = (path: string): string => {
|
||
if (!path) return '';
|
||
return `/api/gui/files/download?path=${encodeURIComponent(path)}`;
|
||
};
|
||
|
||
const applyLineMetrics = (lines: number, multiline: boolean) => {
|
||
inputStore.setInputLineCount(lines);
|
||
inputStore.setInputMultiline(multiline);
|
||
};
|
||
|
||
const composerInputKey = computed(
|
||
() => `${props.currentConversationId || 'new'}:${props.isConnected ? 'online' : 'offline'}`
|
||
);
|
||
|
||
const adjustTextareaSize = () => {
|
||
const textarea = stadiumInput.value;
|
||
if (!textarea) {
|
||
return;
|
||
}
|
||
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);
|
||
textarea.style.height = `${targetHeight}px`;
|
||
};
|
||
|
||
const onInput = (event: Event) => {
|
||
const target = event.target as HTMLTextAreaElement;
|
||
if (!props.isConnected || props.streamingMessage || props.inputLocked) {
|
||
target.value = props.inputMessage || '';
|
||
return;
|
||
}
|
||
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');
|
||
};
|
||
|
||
const currentPermissionLabel = computed(() => {
|
||
const matched = (props.permissionOptions || []).find(
|
||
(item) => item.value === props.currentPermissionMode
|
||
);
|
||
return matched ? matched.label : props.currentPermissionMode;
|
||
});
|
||
|
||
const modelContextWindow = computed(() => {
|
||
const list = Array.isArray(props.modelOptions) ? props.modelOptions : [];
|
||
const current = list.find((item) => item.key === props.currentModelKey);
|
||
return Number(current?.contextWindow || 0);
|
||
});
|
||
|
||
const autoDeepCompressEnabled = computed(() => {
|
||
return !!personalizationStore?.form?.auto_deep_compress_enabled;
|
||
});
|
||
|
||
const deepCompressLimit = computed(() => {
|
||
const custom = Number(personalizationStore?.form?.deep_compress_trigger_tokens || 0);
|
||
if (custom > 0) return custom;
|
||
return 150000;
|
||
});
|
||
|
||
const contextUsageLimit = computed(() => {
|
||
if (autoDeepCompressEnabled.value) {
|
||
return deepCompressLimit.value;
|
||
}
|
||
if (modelContextWindow.value > 0) {
|
||
return modelContextWindow.value;
|
||
}
|
||
return 0;
|
||
});
|
||
|
||
const contextUsagePercent = computed(() => {
|
||
const limit = Number(contextUsageLimit.value || 0);
|
||
if (limit <= 0) return 0;
|
||
const current = Math.max(0, Number(props.currentContextTokens || 0));
|
||
return Math.max(0, Math.min(100, (current / limit) * 100));
|
||
});
|
||
|
||
const contextUsagePercentLabel = computed(() => `${Math.round(contextUsagePercent.value)}%`);
|
||
|
||
const contextUsageColor = computed(() => {
|
||
const percent = contextUsagePercent.value;
|
||
if (percent < 40) return '#16a34a';
|
||
if (percent <= 80) return '#f59e0b';
|
||
return '#ef4444';
|
||
});
|
||
|
||
const formatCompactTokens = (value: number) => {
|
||
const num = Math.max(0, Number(value || 0));
|
||
if (num >= 1000) {
|
||
const raw = num / 1000;
|
||
const text = raw >= 100 ? Math.round(raw).toString() : raw.toFixed(1).replace(/\.0$/, '');
|
||
return `${text}k`;
|
||
}
|
||
return `${Math.round(num)}`;
|
||
};
|
||
|
||
const contextUsageCompactText = computed(() => {
|
||
const current = formatCompactTokens(props.currentContextTokens || 0);
|
||
const limit = Number(contextUsageLimit.value || 0);
|
||
if (limit <= 0) return `${current}/--`;
|
||
return `${current}/${formatCompactTokens(limit)}`;
|
||
});
|
||
|
||
defineExpose({
|
||
stadiumShellOuter,
|
||
compactInputShell,
|
||
stadiumInput,
|
||
fileUploadInput
|
||
});
|
||
|
||
watch(
|
||
() => props.inputMessage,
|
||
async () => {
|
||
await nextTick();
|
||
adjustTextareaSize();
|
||
}
|
||
);
|
||
|
||
watch(
|
||
() => props.isConnected,
|
||
async (connected) => {
|
||
if (!connected && stadiumInput.value) {
|
||
stadiumInput.value.blur();
|
||
}
|
||
await nextTick();
|
||
adjustTextareaSize();
|
||
}
|
||
);
|
||
|
||
onMounted(() => {
|
||
adjustTextareaSize();
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.image-inline-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
padding: 4px 10px 2px;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.image-thumbnail-wrapper {
|
||
position: relative;
|
||
width: 60px;
|
||
height: 60px;
|
||
border-radius: 6px;
|
||
overflow: hidden;
|
||
cursor: pointer;
|
||
border: 1px solid var(--border-color, #2a2f3a);
|
||
}
|
||
|
||
.image-thumbnail {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
}
|
||
|
||
.image-remove-btn-hover {
|
||
position: absolute;
|
||
top: 0;
|
||
right: 0;
|
||
background: none;
|
||
color: #fff;
|
||
border: none;
|
||
font-size: 20px;
|
||
font-weight: bold;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
display: none;
|
||
padding: 2px 4px;
|
||
text-shadow: 0 0 3px rgba(0, 0, 0, 0.8);
|
||
transition: color 0.15s ease;
|
||
}
|
||
|
||
.image-thumbnail-wrapper:hover .image-remove-btn-hover {
|
||
display: block;
|
||
}
|
||
|
||
.image-remove-btn-hover:hover {
|
||
color: #ef4444;
|
||
}
|
||
|
||
</style>
|