agent-Specialization/static/src/components/input/InputComposer.vue
JOJO b4030c386e refactor(frontend): 颜色 token 系统两层重构 + 三主题填色 + 去半透明
将前端配色从散落的硬编码/双命名体系(--claude-* / --theme-*)重构为
两层 token 结构(原始层 + 中性语义层),并按"经典抄 Claude、浅色抄
ChatGPT、深色自研"的定位完成三主题填色,同时建立 stylelint 防回退栏杆。
代码大量由 AI 生成的历史遗留配色问题(同语义多值、跨主题塌缩、强调色
撞文字色、fallback 第二真相源等)在此一并收口。

== 一、Token 结构重构(_tokens.scss)==
- 重写为两层:原始层 + 语义层;组件只引用中性语义名
  (--surface-* / --text-* / --border-* / --accent* / --state-* 等)。
- --claude-* / --theme-* 降级为兼容别名(41 个转发),迁移完成后删除。
- 三主题(classic / light / dark)+ 首屏回退(:root:not([data-theme]),
  与经典同值)全部补齐,结构对齐、不再缺项。

== 二、三主题填色 ==
- 经典 = Claude 官网亮色盘:暖奶油表面阶梯
  canvas #faf9f5 < soft #f5f0e8 < card #efe9de < cream-strong #e8e0d2
  < 纯白浮起;文字 #141413;hairline #e6dfd8;暖橙 primary #cc785c。
- 浅色 = ChatGPT 亮色盘:冷白 #ffffff / 侧栏 #f9f9f9 / 嵌套 #f3f3f3,
  中性灰文字,近黑 primary #181818。
- 深色 = 自研中性灰阶,本轮完全不动(原样保留)。
- 修复塌缩 bug:强调色在 light/dark 原被误填成灰(CTA 失去品牌色),
  现统一回品牌 primary;light 表面原全塌成 #ffffff(过于平面),
  现按灰阶拉开层次。
- 强调色克制(CTA-only voltage):primary 仅用于发送按钮/logo 等极少
  数处;hover / 选中 / 运行态一律走中性灰(运行态指示器从 amber 改灰)。
- 对话区背景从纯白改为暖奶油 canvas;个人空间下拉菜单、git 状态条、
  二级菜单等收口到统一语义色。

== 三、全面去半透明 ==
- 所有实体面板/二级菜单背景改为不透明(surface token 由半透明白改实色,
  --mobile-menu 三亮色主题改实色)。
- 移除已不透明面板上失效的 backdrop-filter 磨砂(移动端菜单、右键菜单、
  模型模式下拉等)。
- 遮罩 scrim(--overlay-scrim)与刻意玻璃质感装饰保留半透明。

== 四、块展开底色统一 ==
- 思考/工具块展开区底色统一为对话背景色:.collapsible-block 及其 header
  背景由 --surface-soft 改 transparent,与堆叠块(stacked-block)一致,
  消除"单块奶油底 vs 多块透明"的不一致。

== 五、布局修复 ==
- 工作区隐藏后残留空白:.workspace-panel--collapsed 补 flex-basis:0 +
  width/min-width:0,修正 flex-basis:auto 被子元素内容宽度撑开导致内联
  width:0 失效、collapsed 后仍占 ~300px 的问题。

== 六、防回退栏杆(新增)==
- 新增 .stylelintrc.cjs:三条规则拦截裸 hex / rgb()|hsl() 字面色 /
  var(--x, fallback) 兜底 / prefers-color-scheme;存量未清理文件用
  BASELINE_EXEMPT 临时豁免(现仅剩 5 个 admin 页面,本轮按用户要求不处理),
  清理一个移除一个、不再回退。
- package.json:build 脚本接入 stylelint(tsc → stylelint → vite),
  新增 lint:css 脚本及 stylelint/postcss-scss/postcss-html 依赖。
- 修复 25 处 \!important 笔误(zsh 历史扩展污染导致的无效 CSS)。

== 七、辅助脚本(新增 scripts/)==
- lint_check.py:Node 环境不可用时的 stylelint 替身(复刻三规则 + 括号
  校验,支持多行声明检测)。
- color_map.py:硬编码色 → token 反查,判定可映射/野色。
- strip_fallback.py:安全剥离已定义 token 的冗余 fallback。

== 八、文档 ==
- CLAUDE.md / AGENTS.md 前端设计规范更新:颜色规则扩写为三主题定位 +
  两层 token + 表面层次 + 强调色克制 + color-mix 派生 + stylelint 栏杆;
  新增"实体面板禁止半透明"规则;"禁止边缘光晕"补充"仅在用户明确允许
  或要求时才可使用"。

注:前端构建验证(npm run build / lint:css)由用户本机完成;AI 工具
上下文 Node v24 段错误无法运行 node/npm/vite/stylelint。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:20:58 +08:00

2222 lines
69 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">
<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"
ref="skillSlashList"
class="skill-slash-menu"
role="listbox"
:aria-label="slashMenuAriaLabel"
>
<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>
</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">
<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>
</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;
}>();
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';
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);
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;
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 = () => {
skillSlashOpen.value = false;
skillSlashQuery.value = '';
skillSlashActiveIndex.value = 0;
slashMenuMode.value = 'root';
slashMenuParentIndex.value = 0;
};
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));
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 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();
}
}
];
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 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;
}
return rootSlashMenuItems.value;
});
const slashMenuAriaLabel = computed(() => {
if (slashMenuMode.value === 'skills') return '可用 AgentSkills';
if (slashMenuMode.value === 'theme') return '主题选项';
return '快捷指令';
});
const slashMenuEmptyText = computed(() => {
if (slashMenuMode.value === 'skills') return skillsLoading.value ? '正在加载 skills...' : '没有匹配的 skill';
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 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>();
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 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 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 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;
}
keepRuntimeQueueTransitionCollapsed();
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 || 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 = 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;
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();
});
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(() => {
document.removeEventListener('click', closeProjectGitMenu);
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 (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: calc(5% - 7px);
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>