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

508 lines
15 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"
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>