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

1500 lines
47 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" 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>