2729 lines
88 KiB
Vue
2729 lines
88 KiB
Vue
<template>
|
||
<div class="input-area compact-input-area" ref="inputAreaRoot">
|
||
<div class="stadium-input-wrapper" ref="stadiumShellOuter">
|
||
<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>
|
||
<transition
|
||
name="skill-slash-menu-motion"
|
||
@before-enter="handleSlashMenuTransitionStart"
|
||
@before-leave="handleSlashMenuTransitionStart"
|
||
@after-enter="handleSlashMenuAfterEnter"
|
||
@after-leave="handleSlashMenuAfterLeave"
|
||
>
|
||
<div
|
||
v-if="skillSlashMenuOpen"
|
||
:key="slashMenuMode"
|
||
class="skill-slash-menu-wrapper"
|
||
role="listbox"
|
||
:aria-label="slashMenuAriaLabel"
|
||
>
|
||
<div
|
||
ref="skillSlashList"
|
||
class="skill-slash-menu"
|
||
>
|
||
<button
|
||
v-for="(item, index) in activeSlashMenuItems"
|
||
:key="item.id"
|
||
type="button"
|
||
class="skill-slash-item"
|
||
:class="{
|
||
'skill-slash-item--active': index === skillSlashActiveIndex,
|
||
'skill-slash-item--disabled': item.disabled
|
||
}"
|
||
role="option"
|
||
:aria-selected="index === skillSlashActiveIndex"
|
||
:disabled="item.disabled"
|
||
@mousedown.prevent="selectSlashMenuItem(index)"
|
||
>
|
||
<span class="skill-slash-item__name">{{ item.label }}</span>
|
||
<span class="skill-slash-item__description">{{ item.description }}</span>
|
||
</button>
|
||
<div v-if="!activeSlashMenuItems.length" class="skill-slash-empty">
|
||
{{ slashMenuEmptyText }}
|
||
</div>
|
||
</div>
|
||
<div
|
||
class="skill-slash-menu__highlight"
|
||
:style="slashHighlightStyle"
|
||
/>
|
||
</div>
|
||
</transition>
|
||
<transition name="floating-status-motion">
|
||
<div
|
||
v-if="floatingStatusVisible"
|
||
class="floating-project-status"
|
||
@click.stop
|
||
>
|
||
<span v-if="projectGitSummaryForRender" class="floating-project-status__left">
|
||
<span class="floating-project-status__menu-wrap">
|
||
<button
|
||
type="button"
|
||
class="floating-project-status__button floating-project-status__button--project"
|
||
:class="{ 'floating-project-status__button--active': projectGitMenuOpen === 'project' }"
|
||
@click.stop="toggleProjectGitMenu('project')"
|
||
>
|
||
{{ projectGitSummaryForRender.projectName }}
|
||
</button>
|
||
<div
|
||
v-if="projectGitMenuOpen === 'project'"
|
||
class="floating-project-status__submenu floating-project-status__submenu--project"
|
||
@click.stop
|
||
>
|
||
<button type="button" @click.stop="openProjectInFileManager">在文件管理器中打开</button>
|
||
<button type="button" @click.stop="copyProjectPath">复制地址</button>
|
||
</div>
|
||
</span>
|
||
<span class="floating-project-status__menu-wrap">
|
||
<button
|
||
type="button"
|
||
class="floating-project-status__button floating-project-status__button--branch"
|
||
:class="{ 'floating-project-status__button--active': projectGitMenuOpen === 'branch' }"
|
||
@click.stop="toggleProjectGitMenu('branch')"
|
||
>
|
||
{{ projectGitSummaryForRender.branch }}
|
||
</button>
|
||
<div
|
||
v-if="projectGitMenuOpen === 'branch'"
|
||
class="floating-project-status__submenu floating-project-status__submenu--branch"
|
||
@click.stop
|
||
>
|
||
<button type="button" @click.stop="copyProjectBranch">复制分支名称</button>
|
||
</div>
|
||
</span>
|
||
</span>
|
||
<span class="floating-project-status__right">
|
||
<span v-if="goalRunning" class="floating-project-status__notice">目标模式运行中</span>
|
||
<span v-else-if="goalModeArmed" class="floating-project-status__notice">目标模式就绪</span>
|
||
<button
|
||
v-if="userQuestionMinimized && pendingUserQuestionCount > 0"
|
||
type="button"
|
||
class="floating-project-status__notice floating-project-status__notice--button"
|
||
@click.stop="$emit('restore-user-question')"
|
||
>
|
||
等待回答问题
|
||
</button>
|
||
<button
|
||
v-if="projectGitSummaryForRender"
|
||
type="button"
|
||
class="floating-project-status__stats"
|
||
@click.stop="$emit('open-git-changes-panel')"
|
||
>
|
||
<span class="floating-project-status__add">+<RollingNumber :value="projectGitSummaryForRender.additions" /></span>
|
||
<span class="floating-project-status__del">-<RollingNumber :value="projectGitSummaryForRender.deletions" /></span>
|
||
</button>
|
||
</span>
|
||
</div>
|
||
</transition>
|
||
<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">
|
||
<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>
|
||
</div>
|
||
<div class="input-actions">
|
||
<button
|
||
type="button"
|
||
class="stadium-btn add-btn"
|
||
data-tutorial="quick-menu-open"
|
||
@click.stop="$emit('toggle-quick-menu')"
|
||
:disabled="!isConnected"
|
||
>
|
||
+
|
||
</button>
|
||
<div class="input-actions-right">
|
||
<button
|
||
v-if="isVoiceInputSupported"
|
||
type="button"
|
||
class="stadium-btn voice-btn"
|
||
:class="{ 'voice-btn--recording': isRecording, 'voice-btn--disabled': voiceModelStatus === 'downloading' || voiceModelStatus === 'model_not_ready' }"
|
||
@click="toggleVoiceRecording"
|
||
:disabled="!isConnected || inputLocked"
|
||
:title="voiceButtonTitle"
|
||
>
|
||
<template v-if="!isRecording">
|
||
<svg
|
||
class="mic-icon"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
viewBox="0 0 24 24"
|
||
width="20"
|
||
height="20"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="1.5"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
>
|
||
<path d="M12 19v3m7-12v2a7 7 0 0 1-14 0v-2" />
|
||
<rect width="6" height="13" x="9" y="2" rx="3" />
|
||
</svg>
|
||
</template>
|
||
<template v-else>
|
||
<div class="voice-wave">
|
||
<span class="voice-wave-bar" />
|
||
<span class="voice-wave-bar" />
|
||
<span class="voice-wave-bar" />
|
||
<span class="voice-wave-bar" />
|
||
</div>
|
||
</template>
|
||
</button>
|
||
<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>
|
||
</div>
|
||
</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 RollingNumber from '@/components/input/RollingNumber.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',
|
||
'open-git-changes-panel'
|
||
]);
|
||
|
||
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 }>;
|
||
projectGitSummary?: {
|
||
has_git?: boolean;
|
||
project_name?: string;
|
||
project_path?: string;
|
||
branch?: string;
|
||
additions?: number;
|
||
deletions?: number;
|
||
} | null;
|
||
userQuestionMinimized?: boolean;
|
||
pendingUserQuestionCount?: number;
|
||
goalModeArmed?: boolean;
|
||
goalRunning?: boolean;
|
||
goalProgress?: Record<string, any> | null;
|
||
hasPendingRuntimeGuidance?: boolean;
|
||
}>();
|
||
|
||
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 };
|
||
type SlashMenuMode = 'root' | 'skills' | 'theme' | 'permission' | 'execution' | 'model' | 'run-mode';
|
||
type SlashMenuItem = {
|
||
id: string;
|
||
label: string;
|
||
description: string;
|
||
disabled?: boolean;
|
||
action?: () => void;
|
||
mode?: SlashMenuMode;
|
||
skill?: SkillItem;
|
||
};
|
||
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);
|
||
const slashMenuMode = ref<SlashMenuMode>('root');
|
||
const slashMenuParentIndex = ref(0);
|
||
const slashMenuTransitioning = ref(false);
|
||
const slashHighlightTop = ref(4);
|
||
let slashAnimId: number | null = null;
|
||
|
||
const slashHighlightStyle = computed(() => {
|
||
const isFirst = skillSlashActiveIndex.value === 0 && skillSlashList.value?.scrollTop === 0;
|
||
return {
|
||
top: `${slashHighlightTop.value}px`,
|
||
...(isFirst ? {
|
||
borderTopLeftRadius: 'calc(var(--skill-slash-radius) - var(--skill-slash-gap))',
|
||
borderTopRightRadius: 'calc(var(--skill-slash-radius) - var(--skill-slash-gap))',
|
||
} : {}),
|
||
};
|
||
});
|
||
|
||
const animateSlash = (element: HTMLElement, targetScroll: number) => {
|
||
if (slashAnimId !== null) {
|
||
cancelAnimationFrame(slashAnimId);
|
||
}
|
||
const startScroll = element.scrollTop;
|
||
const startHighlight = slashHighlightTop.value;
|
||
const distScroll = targetScroll - startScroll;
|
||
const targetHighlight = 4 + skillSlashActiveIndex.value * 31 - targetScroll;
|
||
const distHighlight = targetHighlight - startHighlight;
|
||
const duration = 180;
|
||
const startTime = performance.now();
|
||
|
||
const step = (now: number) => {
|
||
const elapsed = now - startTime;
|
||
const progress = Math.min(elapsed / duration, 1);
|
||
const eased = 1 - Math.pow(1 - progress, 3);
|
||
element.scrollTop = startScroll + distScroll * eased;
|
||
slashHighlightTop.value = startHighlight + distHighlight * eased;
|
||
if (progress < 1) {
|
||
slashAnimId = requestAnimationFrame(step);
|
||
} else {
|
||
slashAnimId = null;
|
||
}
|
||
};
|
||
slashAnimId = requestAnimationFrame(step);
|
||
};
|
||
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 projectGitSummaryForRender = computed(() => {
|
||
if (personalizationStore?.form?.show_git_status_bar === false) return null;
|
||
const summary = props.projectGitSummary;
|
||
if (!summary?.has_git) return null;
|
||
const projectName = String(summary.project_name || '').trim();
|
||
const branch = String(summary.branch || '').trim();
|
||
if (!projectName || !branch) return null;
|
||
return {
|
||
projectName,
|
||
projectPath: String(summary.project_path || '').trim(),
|
||
branch,
|
||
additions: Math.max(0, Number(summary.additions || 0)),
|
||
deletions: Math.max(0, Number(summary.deletions || 0))
|
||
};
|
||
});
|
||
|
||
const floatingStatusVisible = computed(() => {
|
||
if (skillSlashMenuOpen.value || props.quickMenuOpen) return false;
|
||
// 消息队列(预输入/引导消息)存在时隐藏git状态栏,与 / 菜单出现时逻辑相同
|
||
if (runtimeQueuedMessagesForRender.value.length > 0 || props.hasPendingRuntimeGuidance) return false;
|
||
return (
|
||
!!projectGitSummaryForRender.value ||
|
||
!!props.goalRunning ||
|
||
!!props.goalModeArmed ||
|
||
(!!props.userQuestionMinimized && Number(props.pendingUserQuestionCount || 0) > 0)
|
||
);
|
||
});
|
||
|
||
const projectGitMenuOpen = ref<null | 'project' | 'branch'>(null);
|
||
|
||
const closeProjectGitMenu = () => {
|
||
projectGitMenuOpen.value = null;
|
||
};
|
||
|
||
const toggleProjectGitMenu = (menu: 'project' | 'branch') => {
|
||
projectGitMenuOpen.value = projectGitMenuOpen.value === menu ? null : menu;
|
||
};
|
||
|
||
const copyTextToClipboard = async (text: string) => {
|
||
const value = String(text || '').trim();
|
||
if (!value || typeof navigator === 'undefined' || !navigator.clipboard?.writeText) {
|
||
return;
|
||
}
|
||
await navigator.clipboard.writeText(value);
|
||
closeProjectGitMenu();
|
||
};
|
||
|
||
const copyProjectPath = () => {
|
||
copyTextToClipboard(projectGitSummaryForRender.value?.projectPath || '');
|
||
};
|
||
|
||
const copyProjectBranch = () => {
|
||
copyTextToClipboard(projectGitSummaryForRender.value?.branch || '');
|
||
};
|
||
|
||
const openProjectInFileManager = async () => {
|
||
try {
|
||
await fetch('/api/project/open-in-file-manager', { method: 'POST' });
|
||
} catch (error) {
|
||
console.warn('打开项目文件管理器失败:', error);
|
||
} finally {
|
||
closeProjectGitMenu();
|
||
}
|
||
};
|
||
|
||
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])\/([^\s/]*)$/.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 closeSlashMenu = () => {
|
||
if (slashAnimId !== null) {
|
||
cancelAnimationFrame(slashAnimId);
|
||
slashAnimId = null;
|
||
}
|
||
skillSlashOpen.value = false;
|
||
skillSlashQuery.value = '';
|
||
skillSlashActiveIndex.value = 0;
|
||
slashMenuMode.value = 'root';
|
||
slashMenuParentIndex.value = 0;
|
||
slashHighlightTop.value = 4;
|
||
};
|
||
|
||
const deleteSlashToken = () => {
|
||
const token = findSlashToken();
|
||
const instance = editor.value;
|
||
if (!token || !instance) {
|
||
return;
|
||
}
|
||
const { state, view } = instance;
|
||
let tr = state.tr.delete(token.start, token.end);
|
||
tr = tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(0, token.start))));
|
||
view.dispatch(tr);
|
||
view.focus();
|
||
nextTick(() => {
|
||
adjustTextareaSize();
|
||
});
|
||
};
|
||
|
||
const scrollSkillSlashSelectionIntoMiddle = () => {
|
||
nextTick(() => {
|
||
const list = skillSlashList.value;
|
||
if (!list) return;
|
||
const styles = getComputedStyle(list);
|
||
const cssRowHeight = parseFloat(styles.getPropertyValue('--skill-slash-row-height'));
|
||
const cssGap = parseFloat(styles.getPropertyValue('--skill-slash-gap'));
|
||
const rowHeight = (Number.isFinite(cssRowHeight) ? cssRowHeight : 38) +
|
||
(Number.isFinite(cssGap) ? cssGap : 5);
|
||
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));
|
||
animateSlash(list, 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 currentModelSupportsImage = computed(() => {
|
||
const found = props.modelOptions?.find((m) => m.key === props.currentModelKey) as any;
|
||
return !!found?.supportsImage;
|
||
});
|
||
|
||
const currentModelSupportsVideo = computed(() => {
|
||
const found = props.modelOptions?.find((m) => m.key === props.currentModelKey) as any;
|
||
return !!found?.supportsVideo;
|
||
});
|
||
|
||
const applySlashTheme = async (theme: 'classic' | 'light' | 'dark') => {
|
||
personalizationStore.applyTheme(theme);
|
||
personalizationStore.updateField({ key: 'theme', value: theme });
|
||
try {
|
||
const resp = await fetch('/api/personalization', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ theme })
|
||
});
|
||
const result = await resp.json().catch(() => ({}));
|
||
if (!resp.ok || !result.success) {
|
||
console.warn('保存主题到后端失败:', result?.error);
|
||
}
|
||
} catch (error) {
|
||
console.warn('保存主题到后端失败:', error);
|
||
}
|
||
};
|
||
|
||
const executeSlashAction = (action: () => void) => {
|
||
deleteSlashToken();
|
||
closeSlashMenu();
|
||
action();
|
||
};
|
||
|
||
const rootSlashMenuItems = computed<SlashMenuItem[]>(() => {
|
||
const items: SlashMenuItem[] = [
|
||
{
|
||
id: 'upload',
|
||
label: '上传文件',
|
||
description: props.uploading ? '上传中...' : '选择本地文件上传',
|
||
disabled: !props.isConnected || props.uploading || props.blockUpload,
|
||
action: () => emit('quick-upload')
|
||
},
|
||
{
|
||
id: 'image',
|
||
label: '发送图片',
|
||
description: currentModelSupportsImage.value ? '选择图片并附加到消息' : '当前模型不支持图片',
|
||
disabled: !props.isConnected || props.streamingMessage || !currentModelSupportsImage.value,
|
||
action: () => emit('pick-images')
|
||
},
|
||
{
|
||
id: 'video',
|
||
label: '发送视频',
|
||
description: currentModelSupportsVideo.value ? '选择视频并附加到消息' : '当前模型不支持视频',
|
||
disabled: !props.isConnected || props.streamingMessage || !currentModelSupportsVideo.value,
|
||
action: () => emit('pick-video')
|
||
},
|
||
{
|
||
id: 'compress',
|
||
label: '压缩对话',
|
||
description: props.compressing ? '压缩中...' : '手动压缩当前对话上下文',
|
||
disabled:
|
||
!props.isConnected ||
|
||
props.compressing ||
|
||
props.streamingMessage ||
|
||
props.blockCompressConversation,
|
||
action: () => emit('compress-conversation')
|
||
},
|
||
{
|
||
id: 'skills',
|
||
label: '选择 AgentSkill',
|
||
description: '插入一个 AgentSkill 引用',
|
||
mode: 'skills'
|
||
},
|
||
{
|
||
id: 'theme',
|
||
label: '切换主题',
|
||
description: '经典 / 明亮 / 夜间',
|
||
mode: 'theme'
|
||
},
|
||
{
|
||
id: 'review',
|
||
label: '对话回顾',
|
||
description: '打开当前对话回顾',
|
||
disabled: !props.isConnected || props.streamingMessage || props.blockConversationReview,
|
||
action: () => emit('open-review')
|
||
},
|
||
{
|
||
id: 'tokens',
|
||
label: '用量统计',
|
||
description: '打开上下文与 token 用量面板',
|
||
disabled: !props.currentConversationId || props.blockTokenPanel,
|
||
action: () => emit('toggle-token-panel', true)
|
||
},
|
||
{
|
||
id: 'goal',
|
||
label: '目标模式',
|
||
description: props.goalRunning ? '目标模式运行中' : props.goalModeArmed ? '目标模式已就绪' : '切换目标模式',
|
||
disabled: !props.isConnected,
|
||
action: () => emit('toggle-goal-mode')
|
||
},
|
||
{
|
||
id: 'personalization',
|
||
label: '个人设置',
|
||
description: '打开个人空间设置',
|
||
action: () => {
|
||
void personalizationStore.openDrawer();
|
||
}
|
||
},
|
||
{
|
||
id: 'terminal',
|
||
label: '实时终端',
|
||
description: '打开实时终端面板',
|
||
disabled: !props.isConnected || props.blockRealtimeTerminal,
|
||
action: () => emit('realtime-terminal')
|
||
},
|
||
{
|
||
id: 'model',
|
||
label: '切换模型',
|
||
description: '选择对话使用的模型',
|
||
disabled: !props.isConnected || props.streamingMessage,
|
||
mode: 'model'
|
||
},
|
||
{
|
||
id: 'run-mode',
|
||
label: '思考模式',
|
||
description: '切换 fast / thinking / deep',
|
||
disabled: !props.isConnected || props.streamingMessage,
|
||
mode: 'run-mode'
|
||
},
|
||
{
|
||
id: 'permission',
|
||
label: '权限模式',
|
||
description: '切换 readonly / approval / auto / unrestricted',
|
||
disabled: !props.isConnected || props.streamingMessage,
|
||
mode: 'permission'
|
||
},
|
||
...(props.executionModeEnabled
|
||
? [{
|
||
id: 'execution',
|
||
label: '执行环境',
|
||
description: '切换 sandbox / direct',
|
||
disabled: !props.isConnected || props.streamingMessage,
|
||
mode: 'execution'
|
||
} as SlashMenuItem]
|
||
: []),
|
||
{
|
||
id: 'versioning',
|
||
label: '版本控制',
|
||
description: props.versioningEnabled ? '当前:开启' : '当前:关闭',
|
||
disabled: !props.isConnected || props.streamingMessage,
|
||
action: () => emit('open-versioning-dialog')
|
||
},
|
||
{
|
||
id: 'git-bar',
|
||
label: 'Git 状态栏',
|
||
description: (personalizationStore?.form?.show_git_status_bar !== false) ? '当前:显示' : '当前:隐藏',
|
||
action: () => {
|
||
const currentValue = personalizationStore?.form?.show_git_status_bar !== false;
|
||
const newValue = !currentValue;
|
||
if (personalizationStore?.form) {
|
||
personalizationStore.form.show_git_status_bar = newValue;
|
||
}
|
||
fetch('/api/personalization', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ show_git_status_bar: newValue })
|
||
}).catch(() => {});
|
||
}
|
||
},
|
||
{
|
||
id: 'path-auth',
|
||
label: '路径授权',
|
||
description: '查看和管理路径授权',
|
||
disabled: !props.isConnected,
|
||
action: () => emit('open-path-authorization')
|
||
},
|
||
{
|
||
id: 'approval',
|
||
label: '审批面板',
|
||
description: '查看审批记录',
|
||
disabled: !props.isConnected,
|
||
action: () => emit('toggle-approval-panel')
|
||
}
|
||
];
|
||
const query = skillSlashQuery.value.trim().toLowerCase();
|
||
if (!query) {
|
||
return items;
|
||
}
|
||
return items.filter((item) => {
|
||
const haystack = `${item.label} ${item.description} ${item.id}`.toLowerCase();
|
||
return haystack.includes(query);
|
||
});
|
||
});
|
||
|
||
const themeSlashMenuItems = computed<SlashMenuItem[]>(() => {
|
||
const themes: Array<SlashMenuItem & { theme: 'classic' | 'light' | 'dark' }> = [
|
||
{
|
||
id: 'theme-classic',
|
||
label: '经典',
|
||
description: '米色质感,柔和高对比',
|
||
theme: 'classic'
|
||
},
|
||
{
|
||
id: 'theme-light',
|
||
label: '明亮',
|
||
description: '纯白底色 + 优雅灰,简洁清爽',
|
||
theme: 'light'
|
||
},
|
||
{
|
||
id: 'theme-dark',
|
||
label: '夜间',
|
||
description: '深灰 + 黑,低亮度显示',
|
||
theme: 'dark'
|
||
}
|
||
];
|
||
return themes.map((item) => ({
|
||
...item,
|
||
action: () => {
|
||
void applySlashTheme(item.theme);
|
||
}
|
||
}));
|
||
});
|
||
|
||
const permissionSlashMenuItems = computed<SlashMenuItem[]>(() => {
|
||
const options = (props.permissionOptions || []);
|
||
const current = String(props.currentPermissionMode || '');
|
||
return options.map((opt) => ({
|
||
id: `perm:${opt.value}`,
|
||
label: opt.label,
|
||
description: `${opt.value === current ? '当前 · ' : ''}${opt.description || ''}`,
|
||
action: () => emit('change-permission-mode', opt.value)
|
||
}));
|
||
});
|
||
|
||
const executionSlashMenuItems = computed<SlashMenuItem[]>(() => {
|
||
const options = (props.executionModeOptions || []);
|
||
const current = String(props.currentExecutionMode || '');
|
||
return options.map((opt) => ({
|
||
id: `exec:${opt.value}`,
|
||
label: opt.label,
|
||
description: `${opt.value === current ? '当前 · ' : ''}${opt.description || ''}`,
|
||
action: () => emit('change-execution-mode', opt.value)
|
||
}));
|
||
});
|
||
|
||
const modelSlashMenuItems = computed<SlashMenuItem[]>(() => {
|
||
const list = Array.isArray(props.modelOptions) ? props.modelOptions : [];
|
||
const current = String(props.currentModelKey || '');
|
||
return list.map((model) => ({
|
||
id: `model:${model.key}`,
|
||
label: model.label,
|
||
description: `${model.key === current ? '当前 · ' : ''}${model.description || ''}`,
|
||
disabled: !!model.disabled,
|
||
action: () => emit('select-model', model.key)
|
||
}));
|
||
});
|
||
|
||
const runModeSlashMenuItems = computed<SlashMenuItem[]>(() => {
|
||
const modes: Array<{ value: string; label: string }> = [
|
||
{ value: 'fast', label: '快速' },
|
||
{ value: 'thinking', label: '思考' },
|
||
{ value: 'deep', label: '深度' }
|
||
];
|
||
const current = String(props.runMode || '');
|
||
return modes.map((mode) => ({
|
||
id: `runmode:${mode.value}`,
|
||
label: mode.label,
|
||
description: mode.value === current ? '当前' : '',
|
||
action: () => emit('select-run-mode', mode.value)
|
||
}));
|
||
});
|
||
|
||
const skillSlashMenuItems = computed<SlashMenuItem[]>(() =>
|
||
filteredSkills.value.map((skill) => ({
|
||
id: `skill:${skill.path}`,
|
||
label: skill.name,
|
||
description: skill.description,
|
||
skill
|
||
}))
|
||
);
|
||
|
||
const activeSlashMenuItems = computed<SlashMenuItem[]>(() => {
|
||
if (slashMenuMode.value === 'skills') {
|
||
return skillSlashMenuItems.value;
|
||
}
|
||
if (slashMenuMode.value === 'theme') {
|
||
return themeSlashMenuItems.value;
|
||
}
|
||
if (slashMenuMode.value === 'permission') {
|
||
return permissionSlashMenuItems.value;
|
||
}
|
||
if (slashMenuMode.value === 'execution') {
|
||
return executionSlashMenuItems.value;
|
||
}
|
||
if (slashMenuMode.value === 'model') {
|
||
return modelSlashMenuItems.value;
|
||
}
|
||
if (slashMenuMode.value === 'run-mode') {
|
||
return runModeSlashMenuItems.value;
|
||
}
|
||
return rootSlashMenuItems.value;
|
||
});
|
||
|
||
const slashMenuAriaLabel = computed(() => {
|
||
if (slashMenuMode.value === 'skills') return '可用 AgentSkills';
|
||
if (slashMenuMode.value === 'theme') return '主题选项';
|
||
if (slashMenuMode.value === 'permission') return '权限模式选项';
|
||
if (slashMenuMode.value === 'execution') return '执行环境选项';
|
||
if (slashMenuMode.value === 'model') return '模型选项';
|
||
if (slashMenuMode.value === 'run-mode') return '思考模式选项';
|
||
return '快捷指令';
|
||
});
|
||
|
||
const slashMenuEmptyText = computed(() => {
|
||
if (slashMenuMode.value === 'skills') return skillsLoading.value ? '正在加载 skills...' : '没有匹配的 skill';
|
||
if (slashMenuMode.value === 'permission') return '无可用权限模式';
|
||
if (slashMenuMode.value === 'execution') return '无可用执行环境';
|
||
if (slashMenuMode.value === 'model') return '无可用模型';
|
||
return '没有匹配的指令';
|
||
});
|
||
|
||
const skillSlashMenuOpen = computed(() => skillSlashOpen.value && (skillsLoading.value || activeSlashMenuItems.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) {
|
||
closeSlashMenu();
|
||
return;
|
||
}
|
||
closeProjectGitMenu();
|
||
skillSlashOpen.value = true;
|
||
if (skillSlashQuery.value !== token.query) {
|
||
skillSlashActiveIndex.value = 0;
|
||
}
|
||
skillSlashQuery.value = token.query;
|
||
if (slashMenuMode.value === 'skills') {
|
||
void loadSkills();
|
||
}
|
||
skillSlashActiveIndex.value = Math.min(skillSlashActiveIndex.value, Math.max(0, activeSlashMenuItems.value.length - 1));
|
||
scrollSkillSlashSelectionIntoMiddle();
|
||
};
|
||
|
||
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();
|
||
closeSlashMenu();
|
||
nextTick(() => {
|
||
adjustTextareaSize();
|
||
});
|
||
};
|
||
|
||
const selectSlashMenuItem = (index = skillSlashActiveIndex.value) => {
|
||
const item = activeSlashMenuItems.value[index];
|
||
if (!item || item.disabled) {
|
||
return;
|
||
}
|
||
if (item.mode) {
|
||
slashMenuParentIndex.value = index;
|
||
slashMenuMode.value = item.mode;
|
||
skillSlashActiveIndex.value = 0;
|
||
if (item.mode === 'skills') {
|
||
void loadSkills();
|
||
}
|
||
scrollSkillSlashSelectionIntoMiddle();
|
||
return;
|
||
}
|
||
if (item.skill) {
|
||
const skillIndex = filteredSkills.value.findIndex((skill) => skill.path === item.skill?.path);
|
||
selectSkillSlashItem(skillIndex >= 0 ? skillIndex : index);
|
||
return;
|
||
}
|
||
if (item.action) {
|
||
executeSlashAction(item.action);
|
||
}
|
||
};
|
||
|
||
const handleSlashMenuKeydown = (event: KeyboardEvent): boolean => {
|
||
if (event.ctrlKey && event.key === 'Enter') {
|
||
return false;
|
||
}
|
||
if (skillSlashOpen.value) {
|
||
if (event.key === 'ArrowDown') {
|
||
event.preventDefault();
|
||
const count = activeSlashMenuItems.value.length;
|
||
if (count > 0) {
|
||
skillSlashActiveIndex.value = (skillSlashActiveIndex.value + 1) % count;
|
||
scrollSkillSlashSelectionIntoMiddle();
|
||
}
|
||
return true;
|
||
}
|
||
if (event.key === 'ArrowUp') {
|
||
event.preventDefault();
|
||
const count = activeSlashMenuItems.value.length;
|
||
if (count > 0) {
|
||
skillSlashActiveIndex.value = (skillSlashActiveIndex.value - 1 + count) % count;
|
||
scrollSkillSlashSelectionIntoMiddle();
|
||
}
|
||
return true;
|
||
}
|
||
if (event.key === 'Enter') {
|
||
const count = activeSlashMenuItems.value.length;
|
||
if (count > 0) {
|
||
event.preventDefault();
|
||
selectSlashMenuItem(skillSlashActiveIndex.value);
|
||
}
|
||
return true;
|
||
}
|
||
if (event.key === 'Escape') {
|
||
event.preventDefault();
|
||
if (slashMenuMode.value !== 'root') {
|
||
slashMenuMode.value = 'root';
|
||
skillSlashActiveIndex.value = Math.min(
|
||
slashMenuParentIndex.value,
|
||
Math.max(0, activeSlashMenuItems.value.length - 1)
|
||
);
|
||
scrollSkillSlashSelectionIntoMiddle();
|
||
} else {
|
||
closeSlashMenu();
|
||
}
|
||
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(() => {
|
||
closeSlashMenu();
|
||
}, 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 isRecording = ref(false);
|
||
const recognitionInstance = ref<SpeechRecognition | null>(null);
|
||
let voiceAnchorPos = 0;
|
||
let lastVoiceTextLength = 0;
|
||
|
||
// 模型状态:idle | downloading | ready | model_not_ready | error
|
||
const voiceModelStatus = ref<string>('idle');
|
||
const voiceDownloadPercent = ref(0);
|
||
const voiceDownloadMsg = ref('');
|
||
|
||
// 是否支持语音输入(仅 Android APK 环境,通过 VoiceBridge)
|
||
const isVoiceInputSupported = computed(() => {
|
||
if (typeof window === 'undefined') return false;
|
||
return !!(window as any).AndroidVoiceBridge;
|
||
});
|
||
|
||
const voiceButtonTitle = computed(() => {
|
||
if (voiceModelStatus.value === 'downloading') return `模型下载中 ${voiceDownloadPercent.value}%`;
|
||
if (voiceModelStatus.value === 'model_not_ready') return '模型未下载,请在个人空间下载';
|
||
if (isRecording.value) return '停止录音';
|
||
return '语音输入';
|
||
});
|
||
|
||
// 注册全局回调(Android Bridge 会调用这些函数)
|
||
const setupVoiceBridgeCallbacks = () => {
|
||
if (typeof window === 'undefined') return;
|
||
(window as any).__onVoiceResult = (text: string) => {
|
||
console.log('[VoiceInput] Android 识别结果:', text);
|
||
const bridge = (window as any).AndroidVoiceBridge;
|
||
bridge?.debugLog('__onVoiceResult 被调用, text=' + JSON.stringify(text));
|
||
if (!text) {
|
||
bridge?.debugLog('__onVoiceResult: text 为空,跳过');
|
||
return;
|
||
}
|
||
const ed = editor.value;
|
||
if (!ed) {
|
||
bridge?.debugLog('__onVoiceResult: editor.value 为 null');
|
||
console.warn('[VoiceInput] editor is null');
|
||
return;
|
||
}
|
||
bridge?.debugLog('__onVoiceResult: editor.value 存在,准备插入文字');
|
||
bridge?.debugLog('__onVoiceResult: voiceAnchorPos=' + voiceAnchorPos + ' lastVoiceTextLength=' + lastVoiceTextLength);
|
||
const { state, view } = ed;
|
||
const docSize = state.doc.content.size;
|
||
const safeAnchor = Math.max(0, Math.min(voiceAnchorPos, docSize));
|
||
const oldEnd = Math.min(safeAnchor + lastVoiceTextLength, docSize);
|
||
bridge?.debugLog('__onVoiceResult: docSize=' + docSize + ' safeAnchor=' + safeAnchor + ' oldEnd=' + oldEnd);
|
||
try {
|
||
let tr = state.tr;
|
||
if (oldEnd > safeAnchor) tr = tr.delete(safeAnchor, oldEnd);
|
||
tr = tr.insertText(text, safeAnchor);
|
||
const newCursor = Math.min(safeAnchor + text.length, tr.doc.content.size);
|
||
const TextSelection = (window as any).TextSelection;
|
||
if (TextSelection) {
|
||
tr = tr.setSelection(TextSelection.create(tr.doc, newCursor));
|
||
}
|
||
view.dispatch(tr);
|
||
view.focus();
|
||
lastVoiceTextLength = text.length;
|
||
emit('update:input-message', getEditorPlainText());
|
||
emit('input-change');
|
||
nextTick(() => adjustTextareaSize());
|
||
bridge?.debugLog('__onVoiceResult: 文字插入成功');
|
||
} catch (e: any) {
|
||
bridge?.debugLog('__onVoiceResult: 插入异常 ' + (e.message || String(e)));
|
||
}
|
||
};
|
||
(window as any).__onVoiceStatus = (status: string) => {
|
||
console.log('[VoiceInput] 状态:', status);
|
||
if (status === 'listening' || status === 'processing' || status === 'initializing') {
|
||
// initializing 也视为录音中,防止用户重复点击触发多次 startListening
|
||
isRecording.value = true;
|
||
} else if (status === 'idle') {
|
||
isRecording.value = false;
|
||
voiceAnchorPos = 0;
|
||
lastVoiceTextLength = 0;
|
||
} else if (status === 'model_not_ready') {
|
||
voiceModelStatus.value = 'model_not_ready';
|
||
}
|
||
};
|
||
(window as any).__onVoiceError = (error: string) => {
|
||
console.warn('[VoiceInput] 错误:', error);
|
||
isRecording.value = false;
|
||
voiceAnchorPos = 0;
|
||
lastVoiceTextLength = 0;
|
||
};
|
||
(window as any).__onVoiceDownloadProgress = (pct: number, msg: string) => {
|
||
voiceModelStatus.value = 'downloading';
|
||
voiceDownloadPercent.value = pct;
|
||
voiceDownloadMsg.value = msg;
|
||
if (pct >= 100) {
|
||
voiceModelStatus.value = 'ready';
|
||
}
|
||
};
|
||
(window as any).__onVoiceModelReady = () => {
|
||
voiceModelStatus.value = 'ready';
|
||
};
|
||
};
|
||
|
||
// 初始化时设置回调和检查模型状态
|
||
if (typeof window !== 'undefined') {
|
||
setupVoiceBridgeCallbacks();
|
||
// 检查 Android Bridge 模型状态
|
||
const bridge = (window as any).AndroidVoiceBridge;
|
||
if (bridge && typeof bridge.isModelReady === 'function') {
|
||
try {
|
||
voiceModelStatus.value = bridge.isModelReady() ? 'ready' : 'model_not_ready';
|
||
} catch (_) {
|
||
voiceModelStatus.value = 'idle';
|
||
}
|
||
}
|
||
}
|
||
|
||
const isWebSpeechSupported = computed(() => {
|
||
if (typeof window === 'undefined') return false;
|
||
const hasSR = 'SpeechRecognition' in window;
|
||
const hasWebkit = 'webkitSpeechRecognition' in window;
|
||
console.log('[VoiceInput] 检测支持:', { hasSR, hasWebkit, supported: hasSR || hasWebkit });
|
||
return hasSR || hasWebkit;
|
||
});
|
||
|
||
const getSpeechRecognitionConstructor = (): typeof SpeechRecognition | null => {
|
||
if (typeof window === 'undefined') return null;
|
||
const Ctor = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition || null;
|
||
console.log('[VoiceInput] 构造函数:', Ctor ? (Ctor.name || 'webkitSpeechRecognition') : 'null');
|
||
return Ctor;
|
||
};
|
||
|
||
const stopVoiceRecording = () => {
|
||
console.log('[VoiceInput] stopVoiceRecording called, isRecording:', isRecording.value);
|
||
if (recognitionInstance.value) {
|
||
try {
|
||
recognitionInstance.value.stop();
|
||
console.log('[VoiceInput] recognition.stop() called');
|
||
} catch (e) {
|
||
console.warn('[VoiceInput] recognition.stop() error:', e);
|
||
}
|
||
recognitionInstance.value = null;
|
||
}
|
||
isRecording.value = false;
|
||
voiceAnchorPos = 0;
|
||
lastVoiceTextLength = 0;
|
||
};
|
||
|
||
const initSpeechRecognition = () => {
|
||
const SpeechRecognitionCtor = getSpeechRecognitionConstructor();
|
||
if (!SpeechRecognitionCtor) return null;
|
||
|
||
const recognition = new SpeechRecognitionCtor();
|
||
recognition.lang = 'zh-CN';
|
||
recognition.continuous = true;
|
||
recognition.interimResults = true;
|
||
recognition.maxAlternatives = 1;
|
||
|
||
// 记录锚点位置(录音开始时的光标位置)
|
||
const instance = editor.value;
|
||
if (instance) {
|
||
voiceAnchorPos = instance.state.selection.from;
|
||
} else {
|
||
voiceAnchorPos = 0;
|
||
}
|
||
lastVoiceTextLength = 0;
|
||
console.log('[VoiceInput] init: anchorPos=', voiceAnchorPos);
|
||
|
||
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
||
console.log('[VoiceInput] onresult fired:', {
|
||
resultLength: event.results.length,
|
||
resultIndex: (event as any).resultIndex
|
||
});
|
||
let fullText = '';
|
||
for (let i = 0; i < event.results.length; i++) {
|
||
const r = event.results[i];
|
||
console.log(`[VoiceInput] result[${i}]:`, { transcript: r[0]?.transcript, isFinal: r.isFinal, confidence: r[0]?.confidence });
|
||
fullText += r[0].transcript;
|
||
}
|
||
|
||
console.log('[VoiceInput] fullText:', JSON.stringify(fullText));
|
||
if (!fullText) {
|
||
console.log('[VoiceInput] fullText empty, skip');
|
||
return;
|
||
}
|
||
|
||
const ed = editor.value;
|
||
if (!ed) {
|
||
console.warn('[VoiceInput] editor is null');
|
||
return;
|
||
}
|
||
|
||
// 从锚点位置替换语音文本:先删旧、再插新
|
||
const { state, view } = ed;
|
||
const docSize = state.doc.content.size;
|
||
// ProseMirror 合法位置范围是 [0, docSize],docSize 本身是有效位置(文档末尾)
|
||
const safeAnchor = Math.max(0, Math.min(voiceAnchorPos, docSize));
|
||
const oldEnd = Math.min(safeAnchor + lastVoiceTextLength, docSize);
|
||
|
||
let tr = state.tr;
|
||
if (oldEnd > safeAnchor) {
|
||
tr = tr.delete(safeAnchor, oldEnd);
|
||
}
|
||
tr = tr.insertText(fullText, safeAnchor);
|
||
|
||
const newCursor = Math.min(safeAnchor + fullText.length, tr.doc.content.size);
|
||
tr = tr.setSelection(TextSelection.create(tr.doc, newCursor));
|
||
view.dispatch(tr);
|
||
view.focus();
|
||
|
||
lastVoiceTextLength = fullText.length;
|
||
console.log('[VoiceInput] inserted ok, lastVoiceTextLength=', lastVoiceTextLength);
|
||
|
||
emit('update:input-message', getEditorPlainText());
|
||
emit('input-change');
|
||
nextTick(() => {
|
||
adjustTextareaSize();
|
||
});
|
||
};
|
||
|
||
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
|
||
console.warn('[VoiceInput] onerror:', { error: event.error, message: (event as any).message });
|
||
if (event.error === 'aborted') {
|
||
console.log('[VoiceInput] aborted (user stop), ignore');
|
||
return;
|
||
}
|
||
// no-speech / network / not-allowed / service-not-allowed 等全部打印
|
||
console.warn('[VoiceInput] stopping due to error:', event.error);
|
||
stopVoiceRecording();
|
||
};
|
||
|
||
recognition.onstart = () => {
|
||
console.log('[VoiceInput] onstart: 录音已开始');
|
||
};
|
||
|
||
recognition.onaudiostart = () => {
|
||
console.log('[VoiceInput] onaudiostart: 音频捕获开始');
|
||
};
|
||
|
||
recognition.onsoundstart = () => {
|
||
console.log('[VoiceInput] onsoundstart: 检测到声音');
|
||
};
|
||
|
||
recognition.onspeechstart = () => {
|
||
console.log('[VoiceInput] onspeechstart: 检测到语音');
|
||
};
|
||
|
||
recognition.onspeechend = () => {
|
||
console.log('[VoiceInput] onspeechend: 语音结束');
|
||
};
|
||
|
||
recognition.onsoundend = () => {
|
||
console.log('[VoiceInput] onsoundend: 声音结束');
|
||
};
|
||
|
||
recognition.onaudioend = () => {
|
||
console.log('[VoiceInput] onaudioend: 音频捕获结束');
|
||
};
|
||
|
||
recognition.onend = () => {
|
||
console.log('[VoiceInput] onend: 录音结束, isRecording=', isRecording.value);
|
||
isRecording.value = false;
|
||
recognitionInstance.value = null;
|
||
voiceAnchorPos = 0;
|
||
lastVoiceTextLength = 0;
|
||
};
|
||
|
||
return recognition;
|
||
};
|
||
|
||
const toggleVoiceRecording = () => {
|
||
console.log('[VoiceInput] toggleVoiceRecording, isRecording=', isRecording.value);
|
||
|
||
// Android Bridge 模式
|
||
const bridge = (window as any).AndroidVoiceBridge;
|
||
if (bridge) {
|
||
if (isRecording.value) {
|
||
bridge.stopListening();
|
||
return;
|
||
}
|
||
// 记录锚点
|
||
const instance = editor.value;
|
||
voiceAnchorPos = instance ? instance.state.selection.from : 0;
|
||
lastVoiceTextLength = 0;
|
||
bridge.startListening();
|
||
return;
|
||
}
|
||
|
||
// 浏览器原生 Web Speech API 模式
|
||
if (isRecording.value) {
|
||
stopVoiceRecording();
|
||
return;
|
||
}
|
||
|
||
const recognition = initSpeechRecognition();
|
||
if (!recognition) {
|
||
console.warn('[VoiceInput] 浏览器不支持语音识别');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
recognition.start();
|
||
console.log('[VoiceInput] recognition.start() called');
|
||
recognitionInstance.value = recognition;
|
||
isRecording.value = true;
|
||
} catch (error) {
|
||
console.warn('[VoiceInput] 启动语音识别失败:', error);
|
||
stopVoiceRecording();
|
||
}
|
||
};
|
||
|
||
/* ═══════════════════ runtime queue 动画 ═══════════════════ */
|
||
|
||
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 runtimeQueuePrevRects = new Map<string, DOMRect>();
|
||
const runtimeQueuePrevElements = new Map<string, HTMLElement>();
|
||
const runtimeQueueGhosts = new Map<string, HTMLElement>();
|
||
const runtimeQueueFallbackTimers = new WeakMap<HTMLElement, number>();
|
||
const runtimeQueueTransitioning = ref(false);
|
||
let runtimeQueueAnimationReady = false;
|
||
let runtimeQueueTransitionTimer: number | null = null;
|
||
|
||
const bindRuntimeQueueItemRef = (id: string) => (el: Element | null) => {
|
||
if (!id) return;
|
||
if (el instanceof HTMLElement) {
|
||
runtimeQueueItemRefs.set(id, el);
|
||
} else {
|
||
runtimeQueueItemRefs.delete(id);
|
||
}
|
||
};
|
||
|
||
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[] =>
|
||
runtimeQueuedMessagesForRender.value
|
||
.map((entry) => String(entry?.id || '').trim())
|
||
.filter((id) => !!id);
|
||
|
||
const keepRuntimeQueueTransitionCollapsed = () => {
|
||
runtimeQueueTransitioning.value = true;
|
||
if (runtimeQueueTransitionTimer !== null) window.clearTimeout(runtimeQueueTransitionTimer);
|
||
runtimeQueueTransitionTimer = window.setTimeout(() => {
|
||
runtimeQueueTransitioning.value = false;
|
||
runtimeQueueTransitionTimer = null;
|
||
emitComposerHeight();
|
||
}, RUNTIME_QUEUE_ANIM_DURATION + 40);
|
||
};
|
||
|
||
const cancelAnim = (el: HTMLElement) => {
|
||
const timer = runtimeQueueFallbackTimers.get(el);
|
||
if (timer) { clearTimeout(timer); runtimeQueueFallbackTimers.delete(el); }
|
||
el.style.removeProperty('transition');
|
||
el.style.removeProperty('transform');
|
||
el.style.removeProperty('opacity');
|
||
};
|
||
|
||
/**
|
||
* Schedule a CSS transition on an element.
|
||
* All calls in the same synchronous batch share one RAF callback,
|
||
* so all animations start in the same frame.
|
||
*/
|
||
const scheduleTransition = (
|
||
el: HTMLElement,
|
||
props: string[],
|
||
from: Record<string, string>,
|
||
to: Record<string, string>,
|
||
done?: () => void
|
||
) => {
|
||
const finish = () => {
|
||
el.style.removeProperty('transition');
|
||
props.forEach((p) => el.style.removeProperty(p));
|
||
done?.();
|
||
};
|
||
// Apply from state immediately (no transition)
|
||
el.style.transition = 'none';
|
||
Object.entries(from).forEach(([k, v]) => { el.style.setProperty(k, v); });
|
||
// Defer to next frame: enable transition + apply to state
|
||
// All scheduleTransition calls in the same microtask share this RAF
|
||
requestAnimationFrame(() => {
|
||
el.style.transition = props
|
||
.map((p) => `${p} ${RUNTIME_QUEUE_ANIM_DURATION}ms ${RUNTIME_QUEUE_ANIM_EASING}`)
|
||
.join(', ');
|
||
Object.entries(to).forEach(([k, v]) => { el.style.setProperty(k, v); });
|
||
const timer = window.setTimeout(finish, RUNTIME_QUEUE_ANIM_DURATION + 40);
|
||
runtimeQueueFallbackTimers.set(el, timer);
|
||
});
|
||
};
|
||
|
||
/* ═══════════════════ FLIP 动画 ═══════════════════ */
|
||
|
||
// 上一轮渲染的 ID(onBeforeUpdate 时数据已更新,必须用这个记录旧位置)
|
||
let runtimeQueueLastRenderedIds: string[] = [];
|
||
|
||
onBeforeUpdate(() => {
|
||
// 用上一轮渲染的 ID 记录旧位置(此时数据已更新,snapshotRuntimeQueueIds() 是新数据)
|
||
runtimeQueuePrevIds.splice(0, runtimeQueuePrevIds.length, ...runtimeQueueLastRenderedIds);
|
||
runtimeQueuePrevRects.clear();
|
||
runtimeQueuePrevElements.clear();
|
||
runtimeQueueLastRenderedIds.forEach((id) => {
|
||
const el = runtimeQueueItemRefs.get(id);
|
||
if (!el?.isConnected) return;
|
||
runtimeQueuePrevRects.set(id, el.getBoundingClientRect());
|
||
runtimeQueuePrevElements.set(id, el);
|
||
});
|
||
});
|
||
|
||
onUpdated(() => {
|
||
const list = runtimeQueueList.value;
|
||
const currentIds = snapshotRuntimeQueueIds();
|
||
|
||
// First render: just bootstrap tracking state
|
||
if (!runtimeQueueAnimationReady) {
|
||
runtimeQueueAnimationReady = true;
|
||
runtimeQueueLastRenderedIds = [...currentIds];
|
||
runtimeQueuePrevIds.splice(0, runtimeQueuePrevIds.length);
|
||
runtimeQueuePrevRects.clear();
|
||
runtimeQueuePrevElements.clear();
|
||
return;
|
||
}
|
||
|
||
keepRuntimeQueueTransitionCollapsed();
|
||
|
||
const previousIdSet = new Set(runtimeQueuePrevIds);
|
||
const currentIdSet = new Set(currentIds);
|
||
const enteringIds = currentIds.filter((id) => !previousIdSet.has(id));
|
||
const leavingIds = runtimeQueuePrevIds.filter((id) => !currentIdSet.has(id));
|
||
|
||
if (!enteringIds.length && !leavingIds.length) return;
|
||
|
||
// ── Leave: create ghost clones at old positions ──
|
||
leavingIds.forEach((id) => {
|
||
const src = runtimeQueuePrevElements.get(id);
|
||
const rect = runtimeQueuePrevRects.get(id);
|
||
if (!src || !rect || !list?.isConnected) return;
|
||
cancelAnim(src);
|
||
|
||
// Clean up any stale ghost with same id
|
||
const existing = runtimeQueueGhosts.get(id);
|
||
if (existing) { cancelAnim(existing); existing.remove(); runtimeQueueGhosts.delete(id); }
|
||
|
||
const ghost = src.cloneNode(true) as HTMLElement;
|
||
const lr = list.getBoundingClientRect();
|
||
ghost.style.position = 'absolute';
|
||
ghost.style.left = '0';
|
||
ghost.style.top = `${Math.round(rect.top - lr.top)}px`;
|
||
ghost.style.width = `${Math.round(rect.width > 0 ? rect.width : lr.width)}px`;
|
||
ghost.style.pointerEvents = 'none';
|
||
ghost.style.zIndex = '0';
|
||
ghost.style.margin = '0';
|
||
ghost.style.opacity = '1';
|
||
ghost.querySelectorAll('button').forEach((b) => {
|
||
if (b instanceof HTMLButtonElement) b.disabled = true;
|
||
});
|
||
list.appendChild(ghost);
|
||
runtimeQueueGhosts.set(id, ghost);
|
||
|
||
// Schedule ghost sink — shares RAF with item animations below
|
||
const offset = resolveRuntimeQueueOffset(ghost);
|
||
scheduleTransition(ghost, ['transform'],
|
||
{ transform: 'translateY(0)' },
|
||
{ transform: `translateY(${offset}px)` },
|
||
() => { runtimeQueueGhosts.delete(id); ghost.remove(); }
|
||
);
|
||
|
||
runtimeQueueItemRefs.delete(id);
|
||
});
|
||
|
||
// ── Remaining items: apply FLIP transforms ──
|
||
currentIds.forEach((id) => {
|
||
const el = runtimeQueueItemRefs.get(id);
|
||
if (!el?.isConnected) return;
|
||
cancelAnim(el);
|
||
|
||
if (enteringIds.includes(id)) {
|
||
// New item: rise from below
|
||
const offset = resolveRuntimeQueueOffset(el);
|
||
scheduleTransition(el, ['transform'],
|
||
{ transform: `translateY(${offset}px)` },
|
||
{ transform: 'translateY(0)' }
|
||
);
|
||
return;
|
||
}
|
||
|
||
// Existing item: invert the position delta
|
||
const prev = runtimeQueuePrevRects.get(id);
|
||
if (!prev) return;
|
||
const next = el.getBoundingClientRect();
|
||
const deltaY = prev.top - next.top;
|
||
if (Math.abs(deltaY) < 0.5) return;
|
||
scheduleTransition(el, ['transform'],
|
||
{ transform: `translateY(${deltaY}px)` },
|
||
{ transform: 'translateY(0)' }
|
||
);
|
||
});
|
||
|
||
runtimeQueuePrevIds.splice(0, runtimeQueuePrevIds.length);
|
||
runtimeQueuePrevRects.clear();
|
||
runtimeQueuePrevElements.clear();
|
||
// 保存本轮 ID 供下个 onBeforeUpdate 使用
|
||
runtimeQueueLastRenderedIds = [...currentIds];
|
||
});
|
||
|
||
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 || floatingStatusVisible.value || skillSlashOpen.value || hasImages || hasVideos || !!props.inputIsMultiline || !!props.goalModeArmed || !!props.goalRunning || goalCompleted.value;
|
||
});
|
||
|
||
const goalCompleted = computed(() => String(props.goalProgress?.status || '').toLowerCase() === 'done');
|
||
|
||
const handleSlashMenuTransitionStart = () => {
|
||
slashMenuTransitioning.value = true;
|
||
};
|
||
|
||
const handleSlashMenuAfterEnter = () => {
|
||
slashMenuTransitioning.value = false;
|
||
emitComposerHeight();
|
||
};
|
||
|
||
const handleSlashMenuAfterLeave = () => {
|
||
slashMenuTransitioning.value = false;
|
||
emitComposerHeight();
|
||
};
|
||
|
||
// 当消息队列非空或 skill 选择菜单展开时,把目标横幅收起为仅圆点,
|
||
// 让队列/菜单紧贴输入框,不再被横幅顶高。队列清空且无 skill 选择时自动恢复。
|
||
const goalBannerCollapsed = computed(
|
||
() =>
|
||
runtimeQueuedMessagesForRender.value.length > 0 ||
|
||
runtimeQueueTransitioning.value ||
|
||
skillSlashMenuOpen.value ||
|
||
slashMenuTransitioning.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;
|
||
}
|
||
// 用 offset 布局坐标(offsetTop/offsetHeight)计算可视高度,而非 getBoundingClientRect。
|
||
// 后者会把进入/离开动画的 transform 一起算进去:状态栏进场时还停在 translateY(44px) 的低位,
|
||
// 测得的增长量≈0,要等 260ms 动画结束才量到真实高度、再迟一拍下降——表现为「先挡住、完全出现后才下降」。
|
||
// offset 系列只反映静止布局、不受 transform 影响,状态栏一挂载就按最终高度上报,
|
||
// 输入栏下降与状态栏出现动画得以同时发生。这些浮层与 shell 同为 .stadium-input-wrapper 的直接子节点,
|
||
// 其 offsetParent 一致,offsetTop 可直接比较。
|
||
let top = shell.offsetTop;
|
||
let bottom = shell.offsetTop + shell.offsetHeight;
|
||
const slashMenu = root.querySelector('.skill-slash-menu');
|
||
if (slashMenu instanceof HTMLElement) {
|
||
const sTop = slashMenu.offsetTop;
|
||
const sBottom = slashMenu.offsetTop + slashMenu.offsetHeight;
|
||
return Math.max(0, Math.max(bottom, sBottom) - Math.min(top, sTop));
|
||
}
|
||
// 收起态横幅是脱离流、浮在角上的小圆点,不应计入为聊天区预留的高度,
|
||
// 否则会把消息区可滚动范围顶高。故排除 .goal-mode-banner--collapsed。
|
||
const nodes = root.querySelectorAll('.runtime-queue-list:not(.runtime-queue-list--empty), .floating-project-status');
|
||
nodes.forEach((node) => {
|
||
if (!(node instanceof HTMLElement)) return;
|
||
// 离开态:floatingStatusVisible 已翻 false 但元素仍在 DOM 中做退场动画。
|
||
// 此时不再计入它的高度,让输入栏立即回升、与状态栏的退场动画同步。
|
||
if (node.classList.contains('floating-project-status') && !floatingStatusVisible.value) return;
|
||
top = Math.min(top, node.offsetTop);
|
||
bottom = Math.max(bottom, node.offsetTop + node.offsetHeight);
|
||
});
|
||
return Math.max(0, bottom - top);
|
||
};
|
||
|
||
const collectComposerShellHeight = () => {
|
||
const shell = compactInputShell.value;
|
||
if (!shell) {
|
||
return 0;
|
||
}
|
||
return Math.max(0, shell.getBoundingClientRect().height);
|
||
};
|
||
|
||
const emitComposerHeight = () => {
|
||
const visualHeight = collectComposerVisualHeight();
|
||
if (!visualHeight) {
|
||
return;
|
||
}
|
||
const shellHeight = collectComposerShellHeight();
|
||
if (!hasRuntimeLayoutExpansion.value) {
|
||
baselineComposerVisualHeight.value = visualHeight;
|
||
} else if (baselineComposerVisualHeight.value <= 0 && shellHeight > 0) {
|
||
baselineComposerVisualHeight.value = shellHeight;
|
||
} else if (baselineComposerVisualHeight.value > 0 && shellHeight > 0) {
|
||
baselineComposerVisualHeight.value = Math.min(baselineComposerVisualHeight.value, shellHeight);
|
||
} else if (baselineComposerVisualHeight.value <= 0) {
|
||
baselineComposerVisualHeight.value = visualHeight;
|
||
}
|
||
const baseline = baselineComposerVisualHeight.value || visualHeight;
|
||
const growth = Math.max(0, visualHeight - baseline);
|
||
const baseReservedHeight = 93;
|
||
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;
|
||
const previousScrollTop = target.scrollTop;
|
||
const selectionShouldStayVisible =
|
||
document.activeElement === target || target.contains(document.activeElement);
|
||
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`;
|
||
keepEditorSelectionVisible(target, {
|
||
previousScrollTop,
|
||
selectionShouldStayVisible,
|
||
padding: Math.max(4, lineHeight * 0.3)
|
||
});
|
||
nextTick(() => {
|
||
emitComposerHeight();
|
||
});
|
||
};
|
||
|
||
const keepEditorSelectionVisible = (
|
||
target: HTMLElement,
|
||
options: { previousScrollTop: number; selectionShouldStayVisible: boolean; padding: number }
|
||
) => {
|
||
const maxScrollTop = Math.max(0, target.scrollHeight - target.clientHeight);
|
||
if (maxScrollTop <= 0) {
|
||
target.scrollTop = 0;
|
||
return;
|
||
}
|
||
|
||
if (!options.selectionShouldStayVisible) {
|
||
target.scrollTop = Math.min(maxScrollTop, Math.max(0, options.previousScrollTop));
|
||
return;
|
||
}
|
||
|
||
requestAnimationFrame(() => {
|
||
const instance = editor.value;
|
||
if (!instance) {
|
||
target.scrollTop = Math.min(maxScrollTop, Math.max(0, options.previousScrollTop));
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const caretRect = instance.view.coordsAtPos(instance.state.selection.head);
|
||
const targetRect = target.getBoundingClientRect();
|
||
const padding = options.padding;
|
||
const currentMaxScrollTop = Math.max(0, target.scrollHeight - target.clientHeight);
|
||
let nextScrollTop = target.scrollTop;
|
||
|
||
if (caretRect.bottom > targetRect.bottom - padding) {
|
||
nextScrollTop += caretRect.bottom - targetRect.bottom + padding;
|
||
} else if (caretRect.top < targetRect.top + padding) {
|
||
nextScrollTop -= targetRect.top + padding - caretRect.top;
|
||
}
|
||
|
||
target.scrollTop = Math.min(currentMaxScrollTop, Math.max(0, nextScrollTop));
|
||
} catch {
|
||
target.scrollTop = Math.min(maxScrollTop, Math.max(0, options.previousScrollTop));
|
||
}
|
||
});
|
||
};
|
||
|
||
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 'var(--state-success)';
|
||
if (percent <= 80) return 'var(--state-warning)';
|
||
return 'var(--state-danger)';
|
||
});
|
||
|
||
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,
|
||
emitComposerHeight,
|
||
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.projectGitSummary,
|
||
async () => {
|
||
if (!projectGitSummaryForRender.value) {
|
||
closeProjectGitMenu();
|
||
}
|
||
await nextTick();
|
||
emitComposerHeight();
|
||
},
|
||
{ deep: true }
|
||
);
|
||
|
||
watch(floatingStatusVisible, async () => {
|
||
await nextTick();
|
||
emitComposerHeight();
|
||
window.setTimeout(() => {
|
||
emitComposerHeight();
|
||
}, 280);
|
||
});
|
||
|
||
watch(
|
||
() => [props.selectedImages?.length || 0, props.selectedVideos?.length || 0],
|
||
async () => {
|
||
await nextTick();
|
||
emitComposerHeight();
|
||
}
|
||
);
|
||
|
||
// skill 菜单开关会切换目标横幅收起/展开态,需重新上报高度,
|
||
// 否则展开回来时聊天区预留高度不会同步恢复。
|
||
watch(goalBannerCollapsed, async () => {
|
||
await nextTick();
|
||
emitComposerHeight();
|
||
});
|
||
|
||
// 发送消息或输入锁定时,自动停止语音录音
|
||
watch(
|
||
() => props.streamingMessage || props.inputLocked,
|
||
(shouldStop) => {
|
||
if (shouldStop && isRecording.value) {
|
||
stopVoiceRecording();
|
||
}
|
||
}
|
||
);
|
||
|
||
onMounted(() => {
|
||
document.addEventListener('click', closeProjectGitMenu);
|
||
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(() => {
|
||
stopVoiceRecording();
|
||
document.removeEventListener('click', closeProjectGitMenu);
|
||
editor.value?.destroy();
|
||
runtimeQueueItemRefs.forEach((el) => {
|
||
if (el) {
|
||
cancelAnim(el);
|
||
}
|
||
});
|
||
runtimeQueueGhosts.forEach((ghost) => {
|
||
cancelAnim(ghost);
|
||
ghost.remove();
|
||
});
|
||
runtimeQueueGhosts.clear();
|
||
runtimeQueueItemRefs.clear();
|
||
runtimeQueuePrevIds.splice(0, runtimeQueuePrevIds.length);
|
||
runtimeQueuePrevRects.clear();
|
||
runtimeQueuePrevElements.clear();
|
||
runtimeQueueAnimationReady = false;
|
||
runtimeQueueLastRenderedIds = [];
|
||
if (runtimeQueueTransitionTimer !== null) {
|
||
window.clearTimeout(runtimeQueueTransitionTimer);
|
||
runtimeQueueTransitionTimer = null;
|
||
}
|
||
runtimeQueueTransitioning.value = false;
|
||
if (composerResizeObserver) {
|
||
composerResizeObserver.disconnect();
|
||
composerResizeObserver = null;
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.floating-project-status {
|
||
position: absolute;
|
||
left: 5%;
|
||
bottom: calc(100% + 6px);
|
||
z-index: auto;
|
||
width: 90%;
|
||
max-width: 90%;
|
||
height: 34px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 4px 10px 4px 4px;
|
||
border: 1px solid var(--claude-border);
|
||
border-radius: 14px;
|
||
background: var(--theme-surface-soft);
|
||
box-shadow: none;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.floating-project-status__left,
|
||
.floating-project-status__right {
|
||
min-width: 0;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.floating-project-status__left {
|
||
flex: 1 1 auto;
|
||
}
|
||
|
||
.floating-project-status__right {
|
||
flex: 0 0 auto;
|
||
margin-left: auto;
|
||
}
|
||
|
||
.floating-project-status__menu-wrap {
|
||
position: relative;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
min-width: 0;
|
||
height: 26px;
|
||
}
|
||
|
||
.floating-project-status__button,
|
||
.floating-project-status__notice {
|
||
height: 26px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
border: none;
|
||
border-radius: 10px;
|
||
background: transparent;
|
||
color: var(--claude-text);
|
||
font: inherit;
|
||
font-size: 12px;
|
||
line-height: 1.2;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.floating-project-status__button {
|
||
min-width: 0;
|
||
padding: 0 9px;
|
||
cursor: pointer;
|
||
transition: background 140ms ease, color 140ms ease;
|
||
}
|
||
|
||
.floating-project-status__button:hover,
|
||
.floating-project-status__button--active,
|
||
.floating-project-status__notice--button:hover,
|
||
.floating-project-status__submenu button:hover {
|
||
background: var(--theme-tab-active);
|
||
box-shadow: 0 1px 2px var(--shadow-color);
|
||
}
|
||
|
||
.floating-project-status__button--project {
|
||
max-width: min(260px, 36vw);
|
||
font-weight: 600;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.floating-project-status__button--branch {
|
||
color: var(--claude-text-secondary);
|
||
}
|
||
|
||
.floating-project-status__notice {
|
||
padding: 0 8px;
|
||
color: var(--claude-text-secondary);
|
||
}
|
||
|
||
.floating-project-status__notice--button {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.floating-project-status__stats {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
height: 26px;
|
||
padding: 0 8px;
|
||
border: none;
|
||
border-radius: 10px;
|
||
background: transparent;
|
||
font-size: 12px;
|
||
line-height: 1.2;
|
||
font-variant-numeric: tabular-nums;
|
||
cursor: pointer;
|
||
transition: background 140ms ease, color 140ms ease;
|
||
}
|
||
|
||
.floating-project-status__stats:hover {
|
||
background: var(--theme-tab-active);
|
||
box-shadow: 0 1px 2px var(--shadow-color);
|
||
}
|
||
|
||
.floating-project-status__add {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
color: var(--git-diff-add-text);
|
||
}
|
||
|
||
.floating-project-status__del {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
color: var(--git-diff-del-text);
|
||
}
|
||
|
||
.floating-project-status__submenu {
|
||
position: absolute;
|
||
top: calc(100% + 8px);
|
||
left: 0;
|
||
z-index: 46;
|
||
width: max-content;
|
||
min-width: 160px;
|
||
max-width: min(260px, 72vw);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
padding: 6px;
|
||
border: 1px solid var(--claude-border);
|
||
border-radius: 14px;
|
||
background: var(--theme-surface-soft);
|
||
box-shadow: none;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.floating-project-status__submenu button {
|
||
width: 100%;
|
||
min-width: 148px;
|
||
height: 36px;
|
||
padding: 0 10px;
|
||
border: none;
|
||
border-radius: 10px;
|
||
background: transparent;
|
||
color: var(--claude-text);
|
||
font-size: 14px;
|
||
line-height: 1.2;
|
||
text-align: left;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
cursor: pointer;
|
||
}
|
||
|
||
:global(html[data-theme='dark'] .floating-project-status),
|
||
:global(body[data-theme='dark'] .floating-project-status),
|
||
:global(html[data-theme='dark'] .floating-project-status__submenu),
|
||
:global(body[data-theme='dark'] .floating-project-status__submenu) {
|
||
background: var(--badge-bg) !important;
|
||
border-color: var(--border-card-strong);
|
||
}
|
||
|
||
:global(html[data-theme='dark'] .floating-project-status__button:hover),
|
||
:global(body[data-theme='dark'] .floating-project-status__button:hover),
|
||
:global(html[data-theme='dark'] .floating-project-status__notice--button:hover),
|
||
:global(body[data-theme='dark'] .floating-project-status__notice--button:hover),
|
||
:global(html[data-theme='dark'] .floating-project-status__stats:hover),
|
||
:global(body[data-theme='dark'] .floating-project-status__stats:hover),
|
||
:global(html[data-theme='dark'] .floating-project-status__submenu button:hover),
|
||
:global(body[data-theme='dark'] .floating-project-status__submenu button:hover) {
|
||
box-shadow: 0 1px 2px var(--shadow-color);
|
||
}
|
||
|
||
.floating-status-motion-enter-active,
|
||
.floating-status-motion-leave-active {
|
||
transition:
|
||
opacity 180ms ease,
|
||
transform 260ms cubic-bezier(0.25, 0.8, 0.25, 1);
|
||
transform-origin: bottom center;
|
||
will-change: transform, opacity;
|
||
/* 状态栏本体永远在最底层:输入栏 shell(z-index:2) > / 菜单(z-index:1) > 状态栏(auto)。
|
||
不在这里设置 z-index,避免创建低层 stacking context 后把二级菜单也压在输入栏下。 */
|
||
z-index: auto;
|
||
}
|
||
|
||
.floating-status-motion-enter-from,
|
||
.floating-status-motion-leave-to {
|
||
opacity: 0;
|
||
transform: translateY(44px);
|
||
}
|
||
|
||
.floating-status-motion-enter-to,
|
||
.floating-status-motion-leave-from {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
|
||
/* 目标模式横幅:与 + 按钮左对齐,置于输入框上方 */
|
||
.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);
|
||
background: color-mix(in srgb, var(--theme-surface-soft) 92%, var(--claude-text-tertiary) 8%);
|
||
border: 1px solid var(--theme-chip-border);
|
||
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);
|
||
border-color: var(--claude-accent);
|
||
background: color-mix(in srgb, var(--theme-surface-soft) 82%, var(--claude-accent) 18%);
|
||
}
|
||
.goal-mode-banner--done {
|
||
color: var(--claude-success);
|
||
border-color: var(--claude-success);
|
||
background: color-mix(in srgb, var(--theme-surface-soft) 82%, var(--claude-success) 18%);
|
||
}
|
||
:global(:root[data-theme='dark']) .goal-mode-banner,
|
||
:global(body[data-theme='dark']) .goal-mode-banner {
|
||
color: var(--claude-text-secondary);
|
||
background: color-mix(in srgb, var(--theme-surface-muted) 82%, var(--claude-text-tertiary) 18%);
|
||
border-color: var(--theme-control-border-strong);
|
||
}
|
||
:global(:root[data-theme='dark']) .goal-mode-banner--running,
|
||
:global(body[data-theme='dark']) .goal-mode-banner--running {
|
||
color: var(--text-primary);
|
||
background: color-mix(in srgb, var(--theme-surface-muted) 72%, var(--claude-accent) 28%);
|
||
border-color: color-mix(in srgb, var(--claude-accent) 70%, white 30%);
|
||
}
|
||
:global(:root[data-theme='dark']) .goal-mode-banner--done,
|
||
:global(body[data-theme='dark']) .goal-mode-banner--done {
|
||
color: color-mix(in srgb, var(--state-success) 30%, white);
|
||
background: color-mix(in srgb, var(--theme-surface-muted) 72%, var(--claude-success) 28%);
|
||
border-color: color-mix(in srgb, var(--claude-success) 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);
|
||
}
|
||
.goal-mode-banner--running .goal-mode-banner__dot {
|
||
background: var(--claude-accent);
|
||
animation: goal-banner-pulse 1.4s ease-in-out infinite;
|
||
}
|
||
.goal-mode-banner--done .goal-mode-banner__dot {
|
||
background: var(--claude-success);
|
||
}
|
||
@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-default);
|
||
}
|
||
|
||
.image-thumbnail {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
}
|
||
|
||
.image-remove-btn-hover {
|
||
position: absolute;
|
||
top: 0;
|
||
right: 0;
|
||
background: none;
|
||
color: var(--on-accent);
|
||
border: none;
|
||
font-size: 20px;
|
||
font-weight: bold;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
display: none;
|
||
padding: 2px 4px;
|
||
text-shadow: 0 0 3px var(--text-shadow-legible);
|
||
transition: color 0.15s ease;
|
||
}
|
||
|
||
.image-thumbnail-wrapper:hover .image-remove-btn-hover {
|
||
display: block;
|
||
}
|
||
|
||
.image-remove-btn-hover:hover {
|
||
color: var(--state-danger);
|
||
}
|
||
</style>
|