1500 lines
47 KiB
Vue
1500 lines
47 KiB
Vue
<template>
|
||
<div class="input-area compact-input-area" ref="inputAreaRoot">
|
||
<div class="stadium-input-wrapper" ref="stadiumShellOuter">
|
||
<transition name="goal-banner-fade">
|
||
<div
|
||
v-if="goalModeArmed || goalRunning || goalCompleted"
|
||
class="goal-mode-banner"
|
||
:class="{
|
||
'goal-mode-banner--running': goalRunning,
|
||
'goal-mode-banner--done': goalCompleted && !goalRunning,
|
||
'goal-mode-banner--clickable': goalRunning || goalCompleted,
|
||
'goal-mode-banner--collapsed': goalBannerCollapsed
|
||
}"
|
||
:title="goalBannerCollapsed ? goalBannerTitle : undefined"
|
||
@click.stop="goalRunning || goalCompleted ? $emit('open-goal-dialog') : null"
|
||
>
|
||
<span class="goal-mode-banner__dot" aria-hidden="true"></span>
|
||
<span class="goal-mode-banner__text">
|
||
<template v-if="goalRunning">目标模式运行中</template>
|
||
<template v-else-if="goalCompleted">目标模式完成</template>
|
||
<template v-else>目标模式已就绪</template>
|
||
</span>
|
||
</div>
|
||
</transition>
|
||
<div
|
||
class="runtime-queue-list"
|
||
:class="{ 'runtime-queue-list--empty': !runtimeQueuedMessagesForRender.length }"
|
||
ref="runtimeQueueList"
|
||
>
|
||
<div
|
||
v-for="(entry, index) in runtimeQueuedMessagesForRender"
|
||
:key="entry.id"
|
||
class="runtime-queue-item"
|
||
:class="{
|
||
'runtime-queue-item--top': index === 0
|
||
}"
|
||
:data-runtime-queue-id="entry.id || ''"
|
||
:ref="bindRuntimeQueueItemRef(entry.id)"
|
||
>
|
||
<span class="runtime-queue-item__text">{{ entry.text || '' }}</span>
|
||
<button
|
||
type="button"
|
||
class="runtime-queue-item__action"
|
||
@click.stop="$emit('guide-runtime-message', entry.id)"
|
||
>
|
||
引导
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="runtime-queue-item__action runtime-queue-item__action--danger"
|
||
@click.stop="$emit('delete-runtime-message', entry.id)"
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div
|
||
v-if="skillSlashMenuOpen"
|
||
ref="skillSlashList"
|
||
class="skill-slash-menu"
|
||
role="listbox"
|
||
aria-label="可用 Skills"
|
||
>
|
||
<button
|
||
v-for="(skill, index) in filteredSkills"
|
||
:key="skill.path"
|
||
type="button"
|
||
class="skill-slash-item"
|
||
:class="{ 'skill-slash-item--active': index === skillSlashActiveIndex }"
|
||
role="option"
|
||
:aria-selected="index === skillSlashActiveIndex"
|
||
@mousedown.prevent="selectSkillSlashItem(index)"
|
||
>
|
||
<span class="skill-slash-item__name">{{ skill.name }}</span>
|
||
<span class="skill-slash-item__description">{{ skill.description }}</span>
|
||
</button>
|
||
<div v-if="!filteredSkills.length" class="skill-slash-empty">
|
||
没有匹配的 skill
|
||
</div>
|
||
</div>
|
||
<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>
|
||
<div class="stadium-input-rich">
|
||
<EditorContent
|
||
:editor="editor"
|
||
class="stadium-input stadium-input-tiptap"
|
||
:class="{ 'is-disabled': !isConnected || inputLocked }"
|
||
@focusin="$emit('input-focus')"
|
||
@focusout="onInputBlur"
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
class="stadium-btn send-btn"
|
||
@click="$emit('send-or-stop')"
|
||
:disabled="sendButtonDisabled"
|
||
>
|
||
<span v-if="showStopIcon" class="stop-icon"></span>
|
||
<span v-else class="send-icon"></span>
|
||
</button>
|
||
<button
|
||
v-if="userQuestionMinimized && pendingUserQuestionCount > 0"
|
||
type="button"
|
||
class="user-question-mini-dot"
|
||
aria-label="打开待回答问题"
|
||
@click.stop="$emit('restore-user-question')"
|
||
>
|
||
{{ pendingUserQuestionCount }}
|
||
</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"
|
||
:execution-mode-enabled="executionModeEnabled"
|
||
:goal-mode-armed="goalModeArmed"
|
||
:goal-running="goalRunning"
|
||
@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')"
|
||
@open-path-authorization="$emit('open-path-authorization')"
|
||
@toggle-goal-mode="$emit('toggle-goal-mode')"
|
||
/>
|
||
<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"
|
||
@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"
|
||
:class="{ 'permission-switcher__menu--split': executionModeEnabled }"
|
||
>
|
||
<div class="permission-switcher__group">
|
||
<div class="permission-switcher__group-title">权限</div>
|
||
<button
|
||
v-for="option in permissionOptions"
|
||
:key="`permission-${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 v-if="executionModeEnabled" class="permission-switcher__group">
|
||
<div class="permission-switcher__group-title">执行环境</div>
|
||
<button
|
||
v-for="option in executionModeOptions"
|
||
:key="`execution-${option.value}`"
|
||
type="button"
|
||
class="permission-switcher__item"
|
||
:class="{ active: option.value === currentExecutionMode }"
|
||
@click="$emit('change-execution-mode', option.value)"
|
||
>
|
||
<span
|
||
class="permission-switcher__item-label"
|
||
:class="{ 'permission-switcher__item-label--warn': option.value === 'direct' }"
|
||
>{{ option.label }}</span
|
||
>
|
||
<span class="permission-switcher__item-desc">{{ option.description }}</span>
|
||
</button>
|
||
</div>
|
||
</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,
|
||
onBeforeUnmount,
|
||
onBeforeUpdate,
|
||
onUpdated,
|
||
ref,
|
||
watch,
|
||
nextTick,
|
||
computed
|
||
} from 'vue';
|
||
import { EditorContent, useEditor, type JSONContent } from '@tiptap/vue-3';
|
||
import StarterKit from '@tiptap/starter-kit';
|
||
import Mention from '@tiptap/extension-mention';
|
||
import Placeholder from '@tiptap/extension-placeholder';
|
||
import { TextSelection } from 'prosemirror-state';
|
||
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',
|
||
'open-path-authorization',
|
||
'toggle-permission-menu',
|
||
'change-permission-mode',
|
||
'change-execution-mode',
|
||
'open-versioning-dialog',
|
||
'guide-runtime-message',
|
||
'delete-runtime-message',
|
||
'composer-height-change',
|
||
'restore-user-question',
|
||
'toggle-goal-mode',
|
||
'open-goal-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' | 'auto_approval' | 'unrestricted';
|
||
permissionMenuOpen: boolean;
|
||
permissionOptions: Array<{ value: string; label: string; description: string }>;
|
||
executionModeEnabled?: boolean;
|
||
currentExecutionMode?: 'sandbox' | 'direct';
|
||
executionModeOptions?: Array<{ value: string; label: string; description: string }>;
|
||
currentContextTokens: number;
|
||
versioningEnabled?: boolean;
|
||
runtimeQueuedMessages?: Array<{ id: string; text: string }>;
|
||
userQuestionMinimized?: boolean;
|
||
pendingUserQuestionCount?: number;
|
||
goalModeArmed?: boolean;
|
||
goalRunning?: boolean;
|
||
goalProgress?: Record<string, any> | null;
|
||
}>();
|
||
|
||
const inputStore = useInputStore();
|
||
const personalizationStore = usePersonalizationStore();
|
||
const inputAreaRoot = ref<HTMLElement | null>(null);
|
||
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 baselineComposerVisualHeight = ref<number>(0);
|
||
type SkillItem = { name: string; description: string; path: string };
|
||
const availableSkills = ref<SkillItem[]>([]);
|
||
const selectedSkillRefs = ref<SkillItem[]>([]);
|
||
const skillsLoaded = ref(false);
|
||
const skillsLoading = ref(false);
|
||
const skillSlashOpen = ref(false);
|
||
const skillSlashQuery = ref('');
|
||
const skillSlashActiveIndex = ref(0);
|
||
const skillSlashList = ref<HTMLElement | null>(null);
|
||
let composerResizeObserver: ResizeObserver | null = null;
|
||
let syncingEditorFromProps = false;
|
||
|
||
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 runtimeQueuedMessagesForRender = computed(() => {
|
||
const list = Array.isArray(props.runtimeQueuedMessages) ? props.runtimeQueuedMessages : [];
|
||
return list
|
||
.filter((item) => item && item.id && item.text)
|
||
.map((item) => ({
|
||
...item,
|
||
text: String(item.text || '').replace(/[\r\n\u2028\u2029]+/g, ' ')
|
||
}));
|
||
});
|
||
|
||
const findSlashToken = () => {
|
||
const instance = editor.value;
|
||
if (!instance) return null;
|
||
const { state } = instance;
|
||
const { $from } = state.selection;
|
||
const lineStart = $from.start();
|
||
const beforeInLine = $from.parent.textBetween(0, $from.parentOffset, '\n', '\n');
|
||
const match = /(^|[ \n])\/([A-Za-z0-9_-]*)$/.exec(beforeInLine);
|
||
if (!match) return null;
|
||
const query = match[2] || '';
|
||
const start = $from.pos - query.length - 1;
|
||
const end = $from.pos;
|
||
return {
|
||
start,
|
||
end,
|
||
query
|
||
};
|
||
};
|
||
|
||
const scrollSkillSlashSelectionIntoMiddle = () => {
|
||
nextTick(() => {
|
||
const list = skillSlashList.value;
|
||
if (!list) return;
|
||
const rowHeight = 41;
|
||
const visibleRows = Math.max(1, Math.floor(list.clientHeight / rowHeight) || 5);
|
||
const middleOffset = Math.floor(visibleRows / 2);
|
||
const maxScroll = Math.max(0, list.scrollHeight - list.clientHeight);
|
||
const target = Math.max(0, Math.min(maxScroll, (skillSlashActiveIndex.value - middleOffset) * rowHeight));
|
||
list.scrollTop = target;
|
||
});
|
||
};
|
||
|
||
const filteredSkills = computed(() => {
|
||
const query = skillSlashQuery.value.trim().toLowerCase();
|
||
const list = Array.isArray(availableSkills.value) ? availableSkills.value : [];
|
||
const filtered = query
|
||
? list.filter((skill) => String(skill.name || '').toLowerCase().includes(query))
|
||
: list;
|
||
return filtered.slice(0, 100);
|
||
});
|
||
|
||
const skillSlashMenuOpen = computed(() => skillSlashOpen.value && (skillsLoading.value || filteredSkills.value.length >= 0));
|
||
|
||
const loadSkills = async () => {
|
||
if (skillsLoaded.value || skillsLoading.value) {
|
||
return;
|
||
}
|
||
skillsLoading.value = true;
|
||
try {
|
||
const response = await fetch('/api/skills');
|
||
const data = await response.json().catch(() => ({}));
|
||
if (!response.ok || !data?.success) {
|
||
throw new Error(data?.error || '加载 skills 失败');
|
||
}
|
||
availableSkills.value = Array.isArray(data.skills)
|
||
? data.skills
|
||
.filter((item: any) => item && item.name && item.path)
|
||
.map((item: any) => ({
|
||
name: String(item.name || ''),
|
||
description: String(item.description || ''),
|
||
path: String(item.path || '')
|
||
}))
|
||
: [];
|
||
skillsLoaded.value = true;
|
||
} catch (error) {
|
||
availableSkills.value = [];
|
||
skillsLoaded.value = false;
|
||
console.warn('[SkillSlash] 加载 skills 失败:', error);
|
||
} finally {
|
||
skillsLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const refreshSkillSlashState = () => {
|
||
const token = findSlashToken();
|
||
if (!token || !props.isConnected || props.inputLocked) {
|
||
skillSlashOpen.value = false;
|
||
skillSlashQuery.value = '';
|
||
skillSlashActiveIndex.value = 0;
|
||
return;
|
||
}
|
||
skillSlashOpen.value = true;
|
||
if (skillSlashQuery.value !== token.query) {
|
||
skillSlashActiveIndex.value = 0;
|
||
}
|
||
skillSlashQuery.value = token.query;
|
||
skillSlashActiveIndex.value = Math.min(skillSlashActiveIndex.value, Math.max(0, filteredSkills.value.length - 1));
|
||
scrollSkillSlashSelectionIntoMiddle();
|
||
void loadSkills();
|
||
};
|
||
|
||
const selectSkillSlashItem = (index = skillSlashActiveIndex.value) => {
|
||
const token = findSlashToken();
|
||
const skill = filteredSkills.value[index];
|
||
const instance = editor.value;
|
||
if (!token || !skill || !instance) {
|
||
return;
|
||
}
|
||
selectedSkillRefs.value = [
|
||
...selectedSkillRefs.value.filter((item) => item.path !== skill.path),
|
||
skill
|
||
];
|
||
const { state, view, schema } = instance;
|
||
const mentionType = schema.nodes.mention;
|
||
if (!mentionType) {
|
||
return;
|
||
}
|
||
const mentionNode = mentionType.create({
|
||
id: skill.path,
|
||
label: skill.name,
|
||
path: skill.path,
|
||
description: skill.description || '',
|
||
mentionSuggestionChar: ''
|
||
});
|
||
let tr = state.tr.delete(token.start, token.end);
|
||
tr = tr.insert(token.start, mentionNode);
|
||
const afterMention = token.start + mentionNode.nodeSize;
|
||
tr = tr.insertText(' ', afterMention);
|
||
tr = tr.setSelection(TextSelection.near(tr.doc.resolve(afterMention + 1)));
|
||
view.dispatch(tr);
|
||
view.focus();
|
||
skillSlashOpen.value = false;
|
||
skillSlashQuery.value = '';
|
||
skillSlashActiveIndex.value = 0;
|
||
nextTick(() => {
|
||
adjustTextareaSize();
|
||
});
|
||
};
|
||
|
||
const handleSlashMenuKeydown = (event: KeyboardEvent): boolean => {
|
||
if (event.ctrlKey && event.key === 'Enter') {
|
||
return false;
|
||
}
|
||
if (skillSlashOpen.value) {
|
||
if (event.key === 'ArrowDown') {
|
||
event.preventDefault();
|
||
const count = filteredSkills.value.length;
|
||
if (count > 0) {
|
||
skillSlashActiveIndex.value = (skillSlashActiveIndex.value + 1) % count;
|
||
scrollSkillSlashSelectionIntoMiddle();
|
||
}
|
||
return true;
|
||
}
|
||
if (event.key === 'ArrowUp') {
|
||
event.preventDefault();
|
||
const count = filteredSkills.value.length;
|
||
if (count > 0) {
|
||
skillSlashActiveIndex.value = (skillSlashActiveIndex.value - 1 + count) % count;
|
||
scrollSkillSlashSelectionIntoMiddle();
|
||
}
|
||
return true;
|
||
}
|
||
if (event.key === 'Enter') {
|
||
const count = filteredSkills.value.length;
|
||
if (count > 0) {
|
||
event.preventDefault();
|
||
selectSkillSlashItem(skillSlashActiveIndex.value);
|
||
}
|
||
return true;
|
||
}
|
||
if (event.key === 'Escape') {
|
||
event.preventDefault();
|
||
skillSlashOpen.value = false;
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
};
|
||
|
||
const onKeydown = (event: KeyboardEvent): boolean => {
|
||
if (handleSlashMenuKeydown(event)) {
|
||
return true;
|
||
}
|
||
requestAnimationFrame(refreshSkillSlashState);
|
||
return false;
|
||
};
|
||
|
||
const onInputBlur = () => {
|
||
emit('input-blur');
|
||
window.setTimeout(() => {
|
||
skillSlashOpen.value = false;
|
||
}, 120);
|
||
};
|
||
|
||
const textToTiptapContent = (text = ''): JSONContent => {
|
||
const lines = String(text || '').split('\n');
|
||
return {
|
||
type: 'doc',
|
||
content: (lines.length ? lines : ['']).map((line) => ({
|
||
type: 'paragraph',
|
||
content: line ? [{ type: 'text', text: line }] : undefined
|
||
}))
|
||
};
|
||
};
|
||
|
||
const getEditorPlainText = () => {
|
||
const instance = editor.value;
|
||
if (!instance) return props.inputMessage || '';
|
||
return instance.getText({ blockSeparator: '\n' } as any);
|
||
};
|
||
|
||
const editorJsonToMessage = (json?: JSONContent | null) => {
|
||
const skillRefs: Array<{ name: string; path: string }> = [];
|
||
const readInline = (node?: JSONContent): string => {
|
||
if (!node) return '';
|
||
if (node.type === 'text') return node.text || '';
|
||
if (node.type === 'mention') {
|
||
const attrs = node.attrs || {};
|
||
const name = String(attrs.label || attrs.id || '').trim();
|
||
const path = String(attrs.path || attrs.id || '').trim();
|
||
if (name && path && !skillRefs.some((item) => item.path === path)) {
|
||
skillRefs.push({ name, path });
|
||
}
|
||
return name && path ? `[$${name}](${path})` : name;
|
||
}
|
||
if (node.type === 'hardBreak') return '\n';
|
||
return Array.isArray(node.content) ? node.content.map(readInline).join('') : '';
|
||
};
|
||
const blocks = Array.isArray(json?.content) ? json.content : [];
|
||
const message = blocks.map((block) => readInline(block)).join('\n').trim();
|
||
return { message, skillRefs };
|
||
};
|
||
|
||
const editor = useEditor({
|
||
content: textToTiptapContent(props.inputMessage || ''),
|
||
editable: props.isConnected && !props.inputLocked,
|
||
extensions: [
|
||
StarterKit.configure({
|
||
codeBlock: false,
|
||
heading: false,
|
||
blockquote: false,
|
||
bulletList: false,
|
||
orderedList: false,
|
||
listItem: false,
|
||
horizontalRule: false
|
||
}),
|
||
Placeholder.configure({
|
||
placeholder: '输入消息... (Ctrl+Enter 发送)'
|
||
}),
|
||
Mention.configure({
|
||
HTMLAttributes: {
|
||
class: 'skill-md-link'
|
||
},
|
||
deleteTriggerWithBackspace: true,
|
||
renderText({ node }) {
|
||
return String(node.attrs.label || node.attrs.id || '');
|
||
},
|
||
renderHTML({ node, HTMLAttributes }) {
|
||
const attrs = HTMLAttributes || {};
|
||
return [
|
||
'span',
|
||
{
|
||
...attrs,
|
||
class: `${attrs.class || ''} skill-md-link`.trim(),
|
||
'data-skill-path': node.attrs.path || node.attrs.id || ''
|
||
},
|
||
String(node.attrs.label || node.attrs.id || '')
|
||
];
|
||
},
|
||
suggestion: {
|
||
char: '\u0000'
|
||
} as any
|
||
})
|
||
],
|
||
editorProps: {
|
||
attributes: {
|
||
class: 'stadium-input-editor',
|
||
'data-placeholder': '输入消息... (Ctrl+Enter 发送)'
|
||
},
|
||
handleKeyDown(_view, event) {
|
||
if (event.ctrlKey && event.key === 'Enter') {
|
||
event.preventDefault();
|
||
emit('send-or-stop');
|
||
return true;
|
||
}
|
||
return onKeydown(event);
|
||
}
|
||
},
|
||
onUpdate() {
|
||
if (syncingEditorFromProps) return;
|
||
emit('update:input-message', getEditorPlainText());
|
||
emit('input-change');
|
||
nextTick(() => {
|
||
adjustTextareaSize();
|
||
refreshSkillSlashState();
|
||
});
|
||
},
|
||
onFocus() {
|
||
emit('input-focus');
|
||
}
|
||
});
|
||
|
||
const RUNTIME_QUEUE_ANIM_DURATION = 300;
|
||
const RUNTIME_QUEUE_ANIM_EASING = 'cubic-bezier(0.25, 0.8, 0.25, 1)';
|
||
const runtimeQueueList = ref<HTMLElement | null>(null);
|
||
const runtimeQueueItemRefs = new Map<string, HTMLElement>();
|
||
const runtimeQueuePrevIds: string[] = [];
|
||
const runtimeQueueRenderedIds: string[] = [];
|
||
const runtimeQueuePrevRects = new Map<string, DOMRect>();
|
||
const runtimeQueuePrevElements = new Map<string, HTMLElement>();
|
||
const runtimeQueueGhosts = new Map<string, HTMLElement>();
|
||
const runtimeQueueActiveAnimations = new WeakMap<HTMLElement, Animation>();
|
||
const runtimeQueueFallbackTimers = new WeakMap<HTMLElement, number>();
|
||
let runtimeQueueAnimationReady = false;
|
||
|
||
const bindRuntimeQueueItemRef = (id: string) => (el: Element | null) => {
|
||
if (!id) {
|
||
return;
|
||
}
|
||
if (el instanceof HTMLElement) {
|
||
runtimeQueueItemRefs.set(id, el);
|
||
} else {
|
||
runtimeQueueItemRefs.delete(id);
|
||
}
|
||
};
|
||
|
||
const stopRuntimeQueueAnimation = (el: HTMLElement) => {
|
||
const anim = runtimeQueueActiveAnimations.get(el);
|
||
if (anim) {
|
||
try {
|
||
anim.cancel();
|
||
} catch {
|
||
// ignore
|
||
}
|
||
runtimeQueueActiveAnimations.delete(el);
|
||
}
|
||
const timer = runtimeQueueFallbackTimers.get(el);
|
||
if (timer) {
|
||
clearTimeout(timer);
|
||
runtimeQueueFallbackTimers.delete(el);
|
||
}
|
||
el.style.removeProperty('transition');
|
||
el.style.removeProperty('transform');
|
||
el.style.removeProperty('pointer-events');
|
||
};
|
||
|
||
const playRuntimeQueueTransform = (
|
||
el: HTMLElement,
|
||
fromY: number,
|
||
toY: number,
|
||
done?: () => void
|
||
) => {
|
||
if (Math.abs(fromY - toY) < 0.5) {
|
||
stopRuntimeQueueAnimation(el);
|
||
el.style.removeProperty('transition');
|
||
el.style.removeProperty('transform');
|
||
done?.();
|
||
return;
|
||
}
|
||
stopRuntimeQueueAnimation(el);
|
||
const from = `translateY(${fromY}px)`;
|
||
const to = `translateY(${toY}px)`;
|
||
const finish = () => {
|
||
el.style.removeProperty('transition');
|
||
el.style.removeProperty('transform');
|
||
done?.();
|
||
};
|
||
|
||
el.style.transition = 'none';
|
||
el.style.transform = from;
|
||
|
||
if (typeof el.animate === 'function') {
|
||
// 强制应用起始位移,避免首帧先闪现到终点
|
||
void el.offsetHeight;
|
||
const animation = el.animate([{ transform: from }, { transform: to }], {
|
||
duration: RUNTIME_QUEUE_ANIM_DURATION,
|
||
easing: RUNTIME_QUEUE_ANIM_EASING,
|
||
fill: 'both'
|
||
});
|
||
runtimeQueueActiveAnimations.set(el, animation);
|
||
animation.addEventListener(
|
||
'finish',
|
||
() => {
|
||
if (runtimeQueueActiveAnimations.get(el) === animation) {
|
||
runtimeQueueActiveAnimations.delete(el);
|
||
}
|
||
finish();
|
||
},
|
||
{ once: true }
|
||
);
|
||
animation.addEventListener(
|
||
'cancel',
|
||
() => {
|
||
if (runtimeQueueActiveAnimations.get(el) === animation) {
|
||
runtimeQueueActiveAnimations.delete(el);
|
||
}
|
||
},
|
||
{ once: true }
|
||
);
|
||
return;
|
||
}
|
||
|
||
requestAnimationFrame(() => {
|
||
el.style.transition = `transform ${RUNTIME_QUEUE_ANIM_DURATION}ms ${RUNTIME_QUEUE_ANIM_EASING}`;
|
||
el.style.transform = to;
|
||
const timer = window.setTimeout(() => {
|
||
runtimeQueueFallbackTimers.delete(el);
|
||
finish();
|
||
}, RUNTIME_QUEUE_ANIM_DURATION + 24);
|
||
runtimeQueueFallbackTimers.set(el, timer);
|
||
});
|
||
};
|
||
|
||
const resolveRuntimeQueueOffset = (el: HTMLElement): number => {
|
||
const rectHeight = Math.round(el.getBoundingClientRect().height);
|
||
if (rectHeight > 0) {
|
||
return rectHeight;
|
||
}
|
||
if (el.offsetHeight > 0) {
|
||
return el.offsetHeight;
|
||
}
|
||
return 34;
|
||
};
|
||
|
||
const snapshotRuntimeQueueIds = (): string[] => {
|
||
return runtimeQueuedMessagesForRender.value
|
||
.map((entry) => String(entry?.id || '').trim())
|
||
.filter((id) => !!id);
|
||
};
|
||
|
||
const spawnRuntimeQueueLeaveGhost = (id: string) => {
|
||
const sourceEl = runtimeQueuePrevElements.get(id);
|
||
const sourceRect = runtimeQueuePrevRects.get(id);
|
||
const listEl = runtimeQueueList.value;
|
||
if (!sourceEl || !sourceRect || !listEl || !listEl.isConnected) {
|
||
return;
|
||
}
|
||
|
||
const existingGhost = runtimeQueueGhosts.get(id);
|
||
if (existingGhost) {
|
||
stopRuntimeQueueAnimation(existingGhost);
|
||
existingGhost.remove();
|
||
runtimeQueueGhosts.delete(id);
|
||
}
|
||
|
||
const ghost = sourceEl.cloneNode(true) as HTMLElement;
|
||
const listRect = listEl.getBoundingClientRect();
|
||
const top = sourceRect.top - listRect.top;
|
||
const width = sourceRect.width > 0 ? sourceRect.width : listRect.width;
|
||
|
||
ghost.style.position = 'absolute';
|
||
ghost.style.left = '0';
|
||
ghost.style.top = `${Math.round(top)}px`;
|
||
ghost.style.width = `${Math.round(width)}px`;
|
||
ghost.style.pointerEvents = 'none';
|
||
ghost.style.zIndex = '0';
|
||
ghost.style.margin = '0';
|
||
ghost.querySelectorAll('button').forEach((button) => {
|
||
if (button instanceof HTMLButtonElement) {
|
||
button.disabled = true;
|
||
}
|
||
});
|
||
|
||
listEl.appendChild(ghost);
|
||
runtimeQueueGhosts.set(id, ghost);
|
||
|
||
const offset = resolveRuntimeQueueOffset(ghost);
|
||
playRuntimeQueueTransform(ghost, 0, offset, () => {
|
||
runtimeQueueGhosts.delete(id);
|
||
ghost.remove();
|
||
});
|
||
};
|
||
|
||
const animateRuntimeQueueEnter = (el: HTMLElement) => {
|
||
const offset = resolveRuntimeQueueOffset(el);
|
||
playRuntimeQueueTransform(el, offset, 0);
|
||
};
|
||
|
||
onBeforeUpdate(() => {
|
||
runtimeQueuePrevIds.splice(0, runtimeQueuePrevIds.length, ...runtimeQueueRenderedIds);
|
||
runtimeQueuePrevRects.clear();
|
||
runtimeQueuePrevElements.clear();
|
||
|
||
runtimeQueueRenderedIds.forEach((id) => {
|
||
const el = runtimeQueueItemRefs.get(id);
|
||
if (!el?.isConnected) {
|
||
return;
|
||
}
|
||
runtimeQueuePrevRects.set(id, el.getBoundingClientRect());
|
||
runtimeQueuePrevElements.set(id, el);
|
||
});
|
||
});
|
||
|
||
onUpdated(() => {
|
||
const currentIds = snapshotRuntimeQueueIds();
|
||
if (!runtimeQueueAnimationReady) {
|
||
runtimeQueueRenderedIds.splice(0, runtimeQueueRenderedIds.length, ...currentIds);
|
||
runtimeQueueAnimationReady = true;
|
||
runtimeQueuePrevIds.splice(0, runtimeQueuePrevIds.length);
|
||
runtimeQueuePrevRects.clear();
|
||
runtimeQueuePrevElements.clear();
|
||
return;
|
||
}
|
||
|
||
const previousIdSet = new Set(runtimeQueuePrevIds);
|
||
const currentIdSet = new Set(currentIds);
|
||
const enteringIdSet = new Set(currentIds.filter((id) => !previousIdSet.has(id)));
|
||
const leavingIds = runtimeQueuePrevIds.filter((id) => !currentIdSet.has(id));
|
||
|
||
leavingIds.forEach((id) => {
|
||
spawnRuntimeQueueLeaveGhost(id);
|
||
runtimeQueueItemRefs.delete(id);
|
||
});
|
||
|
||
currentIds.forEach((id) => {
|
||
const el = runtimeQueueItemRefs.get(id);
|
||
if (!el?.isConnected) {
|
||
return;
|
||
}
|
||
const prevRect = runtimeQueuePrevRects.get(id);
|
||
if (!prevRect || enteringIdSet.has(id)) {
|
||
animateRuntimeQueueEnter(el);
|
||
return;
|
||
}
|
||
const nextRect = el.getBoundingClientRect();
|
||
const deltaY = prevRect.top - nextRect.top;
|
||
if (Math.abs(deltaY) < 0.5) {
|
||
return;
|
||
}
|
||
playRuntimeQueueTransform(el, deltaY, 0);
|
||
});
|
||
|
||
runtimeQueueRenderedIds.splice(0, runtimeQueueRenderedIds.length, ...currentIds);
|
||
runtimeQueuePrevIds.splice(0, runtimeQueuePrevIds.length);
|
||
runtimeQueuePrevRects.clear();
|
||
runtimeQueuePrevElements.clear();
|
||
});
|
||
|
||
const hasComposerContent = computed(() => {
|
||
const hasText = !!(props.inputMessage || '').trim();
|
||
if (props.streamingMessage) {
|
||
return hasText;
|
||
}
|
||
const hasImages = Array.isArray(props.selectedImages) && props.selectedImages.length > 0;
|
||
const hasVideos = Array.isArray(props.selectedVideos) && props.selectedVideos.length > 0;
|
||
return hasText || hasImages || hasVideos;
|
||
});
|
||
|
||
const showStopIcon = computed(() => props.streamingMessage && !hasComposerContent.value);
|
||
|
||
const sendButtonDisabled = computed(() => {
|
||
if (!props.isConnected) {
|
||
return true;
|
||
}
|
||
if (props.streamingMessage) {
|
||
return false;
|
||
}
|
||
if (props.inputLocked) {
|
||
return true;
|
||
}
|
||
if (props.mediaUploading) {
|
||
return true;
|
||
}
|
||
return !hasComposerContent.value;
|
||
});
|
||
|
||
const hasRuntimeLayoutExpansion = computed(() => {
|
||
const hasQueue = runtimeQueuedMessagesForRender.value.length > 0;
|
||
const hasImages = Array.isArray(props.selectedImages) && props.selectedImages.length > 0;
|
||
const hasVideos = Array.isArray(props.selectedVideos) && props.selectedVideos.length > 0;
|
||
return hasQueue || skillSlashOpen.value || hasImages || hasVideos || !!props.inputIsMultiline || !!props.goalModeArmed || !!props.goalRunning || goalCompleted.value;
|
||
});
|
||
|
||
const goalCompleted = computed(() => String(props.goalProgress?.status || '').toLowerCase() === 'done');
|
||
|
||
// 当消息队列非空或 skill 选择菜单展开时,把目标横幅收起为仅圆点,
|
||
// 让队列/菜单紧贴输入框,不再被横幅顶高。队列清空且无 skill 选择时自动恢复。
|
||
const goalBannerCollapsed = computed(
|
||
() => runtimeQueuedMessagesForRender.value.length > 0 || skillSlashMenuOpen.value
|
||
);
|
||
|
||
const goalBannerTitle = computed(() => {
|
||
if (props.goalRunning) return '目标模式运行中';
|
||
if (goalCompleted.value) return '目标模式完成';
|
||
return '目标模式已就绪';
|
||
});
|
||
|
||
const collectComposerVisualHeight = () => {
|
||
const root = inputAreaRoot.value;
|
||
const shell = compactInputShell.value;
|
||
if (!root || !shell) {
|
||
return 0;
|
||
}
|
||
const shellRect = shell.getBoundingClientRect();
|
||
let top = shellRect.top;
|
||
let bottom = shellRect.bottom;
|
||
// 收起态横幅是脱离流、浮在角上的小圆点,不应计入为聊天区预留的高度,
|
||
// 否则会把消息区可滚动范围顶高。故排除 .goal-mode-banner--collapsed。
|
||
const nodes = root.querySelectorAll('.runtime-queue-list:not(.runtime-queue-list--empty), .skill-slash-menu, .goal-mode-banner:not(.goal-mode-banner--collapsed)');
|
||
nodes.forEach((node) => {
|
||
if (!(node instanceof HTMLElement)) return;
|
||
const rect = node.getBoundingClientRect();
|
||
top = Math.min(top, rect.top);
|
||
bottom = Math.max(bottom, rect.bottom);
|
||
});
|
||
return Math.max(0, bottom - top);
|
||
};
|
||
|
||
const emitComposerHeight = () => {
|
||
const visualHeight = collectComposerVisualHeight();
|
||
if (!visualHeight) {
|
||
return;
|
||
}
|
||
if (baselineComposerVisualHeight.value <= 0 || !hasRuntimeLayoutExpansion.value) {
|
||
baselineComposerVisualHeight.value = visualHeight;
|
||
}
|
||
const baseline = baselineComposerVisualHeight.value || visualHeight;
|
||
const growth = Math.max(0, visualHeight - baseline);
|
||
const baseReservedHeight = 80;
|
||
const reservedHeight = Math.ceil(baseReservedHeight + growth);
|
||
emit('composer-height-change', {
|
||
reservedHeight,
|
||
visualHeight,
|
||
growth
|
||
});
|
||
};
|
||
|
||
const adjustTextareaSize = () => {
|
||
const root = inputAreaRoot.value?.querySelector('.stadium-input-editor') as HTMLElement | null;
|
||
if (!root) {
|
||
return;
|
||
}
|
||
const target = root;
|
||
target.style.height = 'auto';
|
||
const computedStyle = window.getComputedStyle(target);
|
||
const lineHeight = parseFloat(computedStyle.lineHeight || '20') || 20;
|
||
const maxHeight = lineHeight * 6;
|
||
const targetHeight = Math.min(target.scrollHeight, maxHeight);
|
||
const lines = Math.max(1, Math.round(targetHeight / lineHeight));
|
||
const multiline = targetHeight > lineHeight * 1.4;
|
||
applyLineMetrics(lines, multiline);
|
||
target.style.height = `${targetHeight}px`;
|
||
nextTick(() => {
|
||
emitComposerHeight();
|
||
});
|
||
};
|
||
|
||
const onInput = (event: Event) => {
|
||
const target = event.target as HTMLTextAreaElement;
|
||
if (!props.isConnected || props.inputLocked) {
|
||
target.value = props.inputMessage || '';
|
||
return;
|
||
}
|
||
emit('update:input-message', target.value);
|
||
emit('input-change');
|
||
adjustTextareaSize();
|
||
nextTick(refreshSkillSlashState);
|
||
};
|
||
|
||
const onFileChange = (event: Event) => {
|
||
const target = event.target as HTMLInputElement;
|
||
emit('file-selected', target?.files || null);
|
||
if (target) {
|
||
target.value = '';
|
||
}
|
||
};
|
||
|
||
const prepareMessageForSend = () => editorJsonToMessage(editor.value?.getJSON() as JSONContent);
|
||
|
||
const getComposerDraftMeta = () => ({
|
||
editor_json: editor.value?.getJSON() || null,
|
||
skill_refs: editorJsonToMessage(editor.value?.getJSON() as JSONContent).skillRefs
|
||
});
|
||
|
||
const restoreComposerDraftMeta = (meta?: { skill_refs?: SkillItem[]; editor_json?: JSONContent } | null) => {
|
||
const instance = editor.value;
|
||
if (!instance) return;
|
||
if (meta?.editor_json && typeof meta.editor_json === 'object') {
|
||
syncingEditorFromProps = true;
|
||
instance.commands.setContent(meta.editor_json);
|
||
syncingEditorFromProps = false;
|
||
emit('update:input-message', getEditorPlainText());
|
||
nextTick(adjustTextareaSize);
|
||
}
|
||
};
|
||
|
||
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 currentExecutionLabel = computed(() => {
|
||
const options = props.executionModeOptions || [];
|
||
const currentMode = String(props.currentExecutionMode || '');
|
||
const currentMatched = options.find((item) => item.value === currentMode);
|
||
return currentMatched ? currentMatched.label : currentMode;
|
||
});
|
||
|
||
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({
|
||
inputAreaRoot,
|
||
stadiumShellOuter,
|
||
compactInputShell,
|
||
stadiumInput,
|
||
fileUploadInput,
|
||
prepareMessageForSend,
|
||
getComposerDraftMeta,
|
||
restoreComposerDraftMeta
|
||
});
|
||
|
||
watch(
|
||
() => props.inputMessage,
|
||
async () => {
|
||
const instance = editor.value;
|
||
if (instance && !syncingEditorFromProps && getEditorPlainText() !== (props.inputMessage || '')) {
|
||
syncingEditorFromProps = true;
|
||
instance.commands.setContent(textToTiptapContent(props.inputMessage || ''));
|
||
syncingEditorFromProps = false;
|
||
}
|
||
await nextTick();
|
||
adjustTextareaSize();
|
||
refreshSkillSlashState();
|
||
}
|
||
);
|
||
|
||
watch(
|
||
() => props.isConnected,
|
||
async (connected) => {
|
||
editor.value?.setEditable(!!connected && !props.inputLocked);
|
||
if (!connected) {
|
||
editor.value?.commands.blur();
|
||
}
|
||
await nextTick();
|
||
adjustTextareaSize();
|
||
}
|
||
);
|
||
|
||
watch(
|
||
() => props.inputLocked,
|
||
(locked) => {
|
||
editor.value?.setEditable(props.isConnected && !locked);
|
||
}
|
||
);
|
||
|
||
watch(
|
||
() => props.currentConversationId,
|
||
async () => {
|
||
baselineComposerVisualHeight.value = 0;
|
||
await nextTick();
|
||
emitComposerHeight();
|
||
}
|
||
);
|
||
|
||
watch(
|
||
() => props.runtimeQueuedMessages,
|
||
async () => {
|
||
await nextTick();
|
||
emitComposerHeight();
|
||
},
|
||
{ deep: true }
|
||
);
|
||
|
||
watch(
|
||
() => [props.selectedImages?.length || 0, props.selectedVideos?.length || 0],
|
||
async () => {
|
||
await nextTick();
|
||
emitComposerHeight();
|
||
}
|
||
);
|
||
|
||
// skill 菜单开关会切换目标横幅收起/展开态,需重新上报高度,
|
||
// 否则展开回来时聊天区预留高度不会同步恢复。
|
||
watch(goalBannerCollapsed, async () => {
|
||
await nextTick();
|
||
emitComposerHeight();
|
||
});
|
||
|
||
onMounted(() => {
|
||
adjustTextareaSize();
|
||
nextTick(() => {
|
||
emitComposerHeight();
|
||
if (typeof ResizeObserver !== 'undefined') {
|
||
composerResizeObserver = new ResizeObserver(() => {
|
||
emitComposerHeight();
|
||
});
|
||
if (inputAreaRoot.value) {
|
||
composerResizeObserver.observe(inputAreaRoot.value);
|
||
}
|
||
if (stadiumShellOuter.value) {
|
||
composerResizeObserver.observe(stadiumShellOuter.value);
|
||
}
|
||
if (compactInputShell.value) {
|
||
composerResizeObserver.observe(compactInputShell.value);
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
onBeforeUnmount(() => {
|
||
editor.value?.destroy();
|
||
runtimeQueueItemRefs.forEach((el) => {
|
||
if (el) {
|
||
stopRuntimeQueueAnimation(el);
|
||
}
|
||
});
|
||
runtimeQueueGhosts.forEach((ghost) => {
|
||
stopRuntimeQueueAnimation(ghost);
|
||
ghost.remove();
|
||
});
|
||
runtimeQueueGhosts.clear();
|
||
runtimeQueueItemRefs.clear();
|
||
runtimeQueuePrevIds.splice(0, runtimeQueuePrevIds.length);
|
||
runtimeQueueRenderedIds.splice(0, runtimeQueueRenderedIds.length);
|
||
runtimeQueuePrevRects.clear();
|
||
runtimeQueuePrevElements.clear();
|
||
runtimeQueueAnimationReady = false;
|
||
if (composerResizeObserver) {
|
||
composerResizeObserver.disconnect();
|
||
composerResizeObserver = null;
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* 目标模式横幅:与 + 按钮左对齐,置于输入框上方 */
|
||
.goal-mode-banner {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
margin: 0 0 6px 4px;
|
||
padding: 4px 10px;
|
||
border-radius: 999px;
|
||
font-size: 12px;
|
||
line-height: 1.4;
|
||
color: var(--claude-text-secondary, #7f7766);
|
||
background: color-mix(in srgb, var(--theme-surface-soft, #fffaf0) 92%, var(--claude-text-tertiary, #a59a86) 8%);
|
||
border: 1px solid var(--theme-chip-border, rgba(118, 103, 84, 0.2));
|
||
user-select: none;
|
||
transition: gap 0.2s ease, padding 0.2s ease;
|
||
}
|
||
/* 收起态:仅保留圆点与外圈,文字消失、右侧收拢,圆点位置(左侧 padding)不变。
|
||
关键:切到 absolute 脱离文档流,避免横幅占用一行流高度把队列/skill 菜单顶起来;
|
||
锚定在输入框上方 6px、左 4px,与展开时的视觉位置一致,圆点位置不变。 */
|
||
.goal-mode-banner--collapsed {
|
||
position: absolute;
|
||
left: 4px;
|
||
bottom: calc(100% + 6px);
|
||
z-index: 2;
|
||
margin: 0;
|
||
gap: 0;
|
||
padding-right: 10px;
|
||
}
|
||
.goal-mode-banner__text {
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
max-width: 200px;
|
||
opacity: 1;
|
||
transition: max-width 0.2s ease, opacity 0.18s ease;
|
||
}
|
||
.goal-mode-banner--collapsed .goal-mode-banner__text {
|
||
max-width: 0;
|
||
opacity: 0;
|
||
}
|
||
.goal-mode-banner--running {
|
||
color: var(--claude-accent, #da7756);
|
||
border-color: var(--claude-accent, #da7756);
|
||
background: color-mix(in srgb, var(--theme-surface-soft, #fffaf0) 82%, var(--claude-accent, #da7756) 18%);
|
||
}
|
||
.goal-mode-banner--done {
|
||
color: var(--claude-success, #76b086);
|
||
border-color: var(--claude-success, #76b086);
|
||
background: color-mix(in srgb, var(--theme-surface-soft, #fffaf0) 82%, var(--claude-success, #76b086) 18%);
|
||
}
|
||
:global(:root[data-theme='dark']) .goal-mode-banner,
|
||
:global(body[data-theme='dark']) .goal-mode-banner {
|
||
color: var(--claude-text-secondary, #a0a0a0);
|
||
background: color-mix(in srgb, var(--theme-surface-muted, #141414) 82%, var(--claude-text-tertiary, #707070) 18%);
|
||
border-color: var(--theme-control-border-strong, rgba(255, 255, 255, 0.12));
|
||
}
|
||
:global(:root[data-theme='dark']) .goal-mode-banner--running,
|
||
:global(body[data-theme='dark']) .goal-mode-banner--running {
|
||
color: #e5e7eb;
|
||
background: color-mix(in srgb, var(--theme-surface-muted, #141414) 72%, var(--claude-accent, #606060) 28%);
|
||
border-color: color-mix(in srgb, var(--claude-accent, #606060) 70%, white 30%);
|
||
}
|
||
:global(:root[data-theme='dark']) .goal-mode-banner--done,
|
||
:global(body[data-theme='dark']) .goal-mode-banner--done {
|
||
color: #d1fae5;
|
||
background: color-mix(in srgb, var(--theme-surface-muted, #141414) 72%, var(--claude-success, #10b981) 28%);
|
||
border-color: color-mix(in srgb, var(--claude-success, #10b981) 70%, white 30%);
|
||
}
|
||
.goal-mode-banner--clickable {
|
||
cursor: pointer;
|
||
}
|
||
.goal-mode-banner__dot {
|
||
width: 7px;
|
||
height: 7px;
|
||
border-radius: 50%;
|
||
background: var(--claude-text-tertiary, #a59a86);
|
||
}
|
||
.goal-mode-banner--running .goal-mode-banner__dot {
|
||
background: var(--claude-accent, #da7756);
|
||
animation: goal-banner-pulse 1.4s ease-in-out infinite;
|
||
}
|
||
.goal-mode-banner--done .goal-mode-banner__dot {
|
||
background: var(--claude-success, #76b086);
|
||
}
|
||
@keyframes goal-banner-pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.35; }
|
||
}
|
||
.goal-banner-fade-enter-active,
|
||
.goal-banner-fade-leave-active {
|
||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||
}
|
||
.goal-banner-fade-enter-from,
|
||
.goal-banner-fade-leave-to {
|
||
opacity: 0;
|
||
transform: translateY(4px);
|
||
}
|
||
|
||
.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>
|