feat: refine stacked blocks toggle and animations

This commit is contained in:
JOJO 2026-01-01 03:06:05 +08:00
parent 93304bd2b8
commit 713659a644
6 changed files with 871 additions and 115 deletions

View File

@ -35,132 +35,280 @@
</div> </div>
</div> </div>
</div> </div>
<div <template v-if="stackedBlocksEnabled">
v-for="(action, actionIndex) in msg.actions || []" <template v-for="(group, groupIndex) in splitActionGroups(msg.actions || [], index)" :key="group.key">
:key="action.id || `${index}-${actionIndex}`" <StackedBlocks
class="action-item" v-if="group.kind === 'stack'"
:class="{ class="stacked-blocks-wrapper"
'streaming-content': action.streaming, :actions="group.actions"
'completed-tool': action.type === 'tool' && !action.streaming, :expanded-blocks="expandedBlocks"
'immediate-show': action.streaming || action.type === 'text' || action.type === 'thinking', :icon-style="iconStyleSafe"
'thinking-finished': action.type === 'thinking' && !action.streaming :toggle-block="toggleBlock"
}" :register-thinking-ref="registerThinkingRef"
> :handle-thinking-scroll="handleThinkingScroll"
<div :get-tool-animation-class="getToolAnimationClass"
v-if="action.type === 'thinking'" :get-tool-icon="getToolIcon"
class="collapsible-block thinking-block" :get-tool-status-text="getToolStatusText"
:class="{ expanded: expandedBlocks?.has(action.blockId || `${index}-thinking-${actionIndex}`) }" :get-tool-description="getToolDescription"
> :format-search-topic="formatSearchTopic"
<div class="collapsible-header" @click="toggleBlock(action.blockId || `${index}-thinking-${actionIndex}`)"> :format-search-time="formatSearchTime"
<div class="arrow"></div> />
<div class="status-icon"> <div
<span class="thinking-icon" :class="{ 'thinking-animation': action.streaming }"> v-else
<span class="icon icon-sm" :style="iconStyleSafe('brain')" aria-hidden="true"></span> class="action-item"
</span> :key="group.action?.id || `${index}-${group.actionIndex}`"
</div> :class="{
<span class="status-text">{{ action.streaming ? '正在思考...' : '思考过程' }}</span> 'streaming-content': group.action?.streaming,
</div> 'completed-tool': group.action?.type === 'tool' && !group.action?.streaming,
<div class="collapsible-content"> 'immediate-show':
group.action?.streaming || group.action?.type === 'text' || group.action?.type === 'thinking',
'thinking-finished': group.action?.type === 'thinking' && !group.action?.streaming
}"
>
<div <div
class="content-inner thinking-content" v-if="group.action?.type === 'thinking'"
:ref="el => registerThinkingRef(action.blockId || `${index}-thinking-${actionIndex}`, el)" class="collapsible-block thinking-block"
@scroll=" :class="{ expanded: expandedBlocks?.has(group.action.blockId || `${index}-thinking-${group.actionIndex}`) }"
handleThinkingScroll(
action.blockId || `${index}-thinking-${actionIndex}`,
$event
)
"
style="max-height: 240px; overflow-y: auto;"
> >
<div class="collapsible-header" @click="toggleBlock(group.action.blockId || `${index}-thinking-${group.actionIndex}`)">
<div class="arrow"></div>
<div class="status-icon">
<span class="thinking-icon" :class="{ 'thinking-animation': group.action.streaming }">
<span class="icon icon-sm" :style="iconStyleSafe('brain')" aria-hidden="true"></span>
</span>
</div>
<span class="status-text">{{ group.action.streaming ? '正在思考...' : '思考过程' }}</span>
</div>
<div class="collapsible-content">
<div
class="content-inner thinking-content"
:ref="el => registerThinkingRef(group.action.blockId || `${index}-thinking-${group.actionIndex}`, el)"
@scroll="
handleThinkingScroll(
group.action.blockId || `${index}-thinking-${group.actionIndex}`,
$event
)
"
style="max-height: 240px; overflow-y: auto;"
>
{{ group.action.content }}
</div>
</div>
</div>
<div v-else-if="group.action?.type === 'text'" class="text-output">
<div class="text-content" :class="{ 'streaming-text': group.action.streaming }">
<div v-if="group.action.streaming" v-html="renderMarkdown(group.action.content, true)"></div>
<div v-else v-html="renderMarkdown(group.action.content, false)"></div>
</div>
</div>
<div v-else-if="group.action?.type === 'system'" class="system-action">
<div class="system-action-content">
{{ group.action.content }}
</div>
</div>
<div
v-else-if="group.action?.type === 'append_payload'"
class="append-placeholder"
:class="{ 'append-error': group.action.append?.success === false }"
>
<div class="append-placeholder-content">
<template v-if="group.action.append?.success !== false">
<div class="icon-label append-status">
<span class="icon icon-sm" :style="iconStyleSafe('pencil')" aria-hidden="true"></span>
<span>已写入 {{ group.action.append?.path || '目标文件' }} 的追加内容内容已保存至文件</span>
</div>
</template>
<template v-else>
<div class="icon-label append-status append-error-text">
<span class="icon icon-sm" :style="iconStyleSafe('x')" aria-hidden="true"></span>
<span> {{ group.action.append?.path || '目标文件' }} 写入失败内容已截获供后续修复</span>
</div>
</template>
<div class="append-meta" v-if="group.action.append">
<span v-if="group.action.append.lines !== null && group.action.append.lines !== undefined">
· 行数 {{ group.action.append.lines }}
</span>
<span v-if="group.action.append.bytes !== null && group.action.append.bytes !== undefined">
· 字节 {{ group.action.append.bytes }}
</span>
</div>
<div class="append-warning icon-label" v-if="group.action.append?.forced">
<span class="icon icon-sm" :style="iconStyleSafe('triangleAlert')" aria-hidden="true"></span>
<span>未检测到结束标记请根据提示继续补充</span>
</div>
</div>
</div>
<div
v-else-if="group.action?.type === 'append'"
class="append-placeholder"
:class="{ 'append-error': group.action.append?.success === false }"
>
<div class="append-placeholder-content">
<div class="icon-label append-status">
<span class="icon icon-sm" :style="iconStyleSafe('pencil')" aria-hidden="true"></span>
<span>{{ group.action.append?.summary || '文件追加完成' }}</span>
</div>
<div class="append-meta" v-if="group.action.append">
<span>{{ group.action.append.path || '目标文件' }}</span>
<span v-if="group.action.append.lines">· 行数 {{ group.action.append.lines }}</span>
<span v-if="group.action.append.bytes">· 字节 {{ group.action.append.bytes }}</span>
</div>
<div class="append-warning icon-label" v-if="group.action.append?.forced">
<span class="icon icon-sm" :style="iconStyleSafe('triangleAlert')" aria-hidden="true"></span>
<span>未检测到结束标记请按提示继续补充</span>
</div>
</div>
</div>
<ToolAction
v-else-if="group.action?.type === 'tool'"
:action="group.action"
:expanded="expandedBlocks?.has(group.action.blockId || `${index}-tool-${group.actionIndex}`)"
:icon-style="iconStyleSafe"
:get-tool-animation-class="getToolAnimationClass"
:get-tool-icon="getToolIcon"
:get-tool-status-text="getToolStatusText"
:get-tool-description="getToolDescription"
:format-search-topic="formatSearchTopic"
:format-search-time="formatSearchTime"
:streaming-message="streamingMessage"
@toggle="toggleBlock(group.action.blockId || `${index}-tool-${group.actionIndex}`)"
/>
</div>
</template>
</template>
<template v-else>
<div
v-for="(action, actionIndex) in msg.actions || []"
:key="action.id || `${index}-${actionIndex}`"
class="action-item"
:class="{
'streaming-content': action.streaming,
'completed-tool': action.type === 'tool' && !action.streaming,
'immediate-show': action.streaming || action.type === 'text' || action.type === 'thinking',
'thinking-finished': action.type === 'thinking' && !action.streaming
}"
>
<div
v-if="action.type === 'thinking'"
class="collapsible-block thinking-block"
:class="{ expanded: expandedBlocks?.has(action.blockId || `${index}-thinking-${actionIndex}`) }"
>
<div class="collapsible-header" @click="toggleBlock(action.blockId || `${index}-thinking-${actionIndex}`)">
<div class="arrow"></div>
<div class="status-icon">
<span class="thinking-icon" :class="{ 'thinking-animation': action.streaming }">
<span class="icon icon-sm" :style="iconStyleSafe('brain')" aria-hidden="true"></span>
</span>
</div>
<span class="status-text">{{ action.streaming ? '正在思考...' : '思考过程' }}</span>
</div>
<div class="collapsible-content">
<div
class="content-inner thinking-content"
:ref="el => registerThinkingRef(action.blockId || `${index}-thinking-${actionIndex}`, el)"
@scroll="
handleThinkingScroll(
action.blockId || `${index}-thinking-${actionIndex}`,
$event
)
"
style="max-height: 240px; overflow-y: auto;"
>
{{ action.content }}
</div>
</div>
</div>
<div v-else-if="action.type === 'text'" class="text-output">
<div class="text-content" :class="{ 'streaming-text': action.streaming }">
<div v-if="action.streaming" v-html="renderMarkdown(action.content, true)"></div>
<div v-else v-html="renderMarkdown(action.content, false)"></div>
</div>
</div>
<div v-else-if="action.type === 'system'" class="system-action">
<div class="system-action-content">
{{ action.content }} {{ action.content }}
</div> </div>
</div> </div>
<div v-if="action.streaming" class="progress-indicator"></div>
</div>
<div v-else-if="action.type === 'text'" class="text-output"> <div
<div class="text-content" :class="{ 'streaming-text': action.streaming }"> v-else-if="action.type === 'append_payload'"
<div v-if="action.streaming" v-html="renderMarkdown(action.content, true)"></div> class="append-placeholder"
<div v-else v-html="renderMarkdown(action.content, false)"></div> :class="{ 'append-error': action.append?.success === false }"
>
<div class="append-placeholder-content">
<template v-if="action.append?.success !== false">
<div class="icon-label append-status">
<span class="icon icon-sm" :style="iconStyleSafe('pencil')" aria-hidden="true"></span>
<span>已写入 {{ action.append?.path || '目标文件' }} 的追加内容内容已保存至文件</span>
</div>
</template>
<template v-else>
<div class="icon-label append-status append-error-text">
<span class="icon icon-sm" :style="iconStyleSafe('x')" aria-hidden="true"></span>
<span> {{ action.append?.path || '目标文件' }} 写入失败内容已截获供后续修复</span>
</div>
</template>
<div class="append-meta" v-if="action.append">
<span v-if="action.append.lines !== null && action.append.lines !== undefined">
· 行数 {{ action.append.lines }}
</span>
<span v-if="action.append.bytes !== null && action.append.bytes !== undefined">
· 字节 {{ action.append.bytes }}
</span>
</div>
<div class="append-warning icon-label" v-if="action.append?.forced">
<span class="icon icon-sm" :style="iconStyleSafe('triangleAlert')" aria-hidden="true"></span>
<span>未检测到结束标记请根据提示继续补充</span>
</div>
</div>
</div> </div>
</div>
<div v-else-if="action.type === 'system'" class="system-action"> <div
<div class="system-action-content"> v-else-if="action.type === 'append'"
{{ action.content }} class="append-placeholder"
</div> :class="{ 'append-error': action.append?.success === false }"
</div> >
<div class="append-placeholder-content">
<div
v-else-if="action.type === 'append_payload'"
class="append-placeholder"
:class="{ 'append-error': action.append?.success === false }"
>
<div class="append-placeholder-content">
<template v-if="action.append?.success !== false">
<div class="icon-label append-status"> <div class="icon-label append-status">
<span class="icon icon-sm" :style="iconStyleSafe('pencil')" aria-hidden="true"></span> <span class="icon icon-sm" :style="iconStyleSafe('pencil')" aria-hidden="true"></span>
<span>已写入 {{ action.append?.path || '目标文件' }} 的追加内容内容已保存至文件</span> <span>{{ action.append?.summary || '文件追加完成' }}</span>
</div> </div>
</template> <div class="append-meta" v-if="action.append">
<template v-else> <span>{{ action.append.path || '目标文件' }}</span>
<div class="icon-label append-status append-error-text"> <span v-if="action.append.lines">· 行数 {{ action.append.lines }}</span>
<span class="icon icon-sm" :style="iconStyleSafe('x')" aria-hidden="true"></span> <span v-if="action.append.bytes">· 字节 {{ action.append.bytes }}</span>
<span> {{ action.append?.path || '目标文件' }} 写入失败内容已截获供后续修复</span> </div>
<div class="append-warning icon-label" v-if="action.append?.forced">
<span class="icon icon-sm" :style="iconStyleSafe('triangleAlert')" aria-hidden="true"></span>
<span>未检测到结束标记请按提示继续补充</span>
</div> </div>
</template>
<div class="append-meta" v-if="action.append">
<span v-if="action.append.lines !== null && action.append.lines !== undefined">
· 行数 {{ action.append.lines }}
</span>
<span v-if="action.append.bytes !== null && action.append.bytes !== undefined">
· 字节 {{ action.append.bytes }}
</span>
</div>
<div class="append-warning icon-label" v-if="action.append?.forced">
<span class="icon icon-sm" :style="iconStyleSafe('triangleAlert')" aria-hidden="true"></span>
<span>未检测到结束标记请根据提示继续补充</span>
</div> </div>
</div> </div>
</div>
<div <ToolAction
v-else-if="action.type === 'append'" v-else-if="action.type === 'tool'"
class="append-placeholder" :action="action"
:class="{ 'append-error': action.append?.success === false }" :expanded="expandedBlocks?.has(action.blockId || `${index}-tool-${actionIndex}`)"
> :icon-style="iconStyleSafe"
<div class="append-placeholder-content"> :get-tool-animation-class="getToolAnimationClass"
<div class="icon-label append-status"> :get-tool-icon="getToolIcon"
<span class="icon icon-sm" :style="iconStyleSafe('pencil')" aria-hidden="true"></span> :get-tool-status-text="getToolStatusText"
<span>{{ action.append?.summary || '文件追加完成' }}</span> :get-tool-description="getToolDescription"
</div> :format-search-topic="formatSearchTopic"
<div class="append-meta" v-if="action.append"> :format-search-time="formatSearchTime"
<span>{{ action.append.path || '目标文件' }}</span> :streaming-message="streamingMessage"
<span v-if="action.append.lines">· 行数 {{ action.append.lines }}</span> @toggle="toggleBlock(action.blockId || `${index}-tool-${actionIndex}`)"
<span v-if="action.append.bytes">· 字节 {{ action.append.bytes }}</span> />
</div>
<div class="append-warning icon-label" v-if="action.append?.forced">
<span class="icon icon-sm" :style="iconStyleSafe('triangleAlert')" aria-hidden="true"></span>
<span>未检测到结束标记请按提示继续补充</span>
</div>
</div>
</div> </div>
</template>
<ToolAction
v-else-if="action.type === 'tool'"
:action="action"
:expanded="expandedBlocks?.has(action.blockId || `${index}-tool-${actionIndex}`)"
:icon-style="iconStyleSafe"
:get-tool-animation-class="getToolAnimationClass"
:get-tool-icon="getToolIcon"
:get-tool-status-text="getToolStatusText"
:get-tool-description="getToolDescription"
:format-search-topic="formatSearchTopic"
:format-search-time="formatSearchTime"
:streaming-message="streamingMessage"
@toggle="toggleBlock(action.blockId || `${index}-tool-${actionIndex}`)"
/>
</div>
</div> </div>
<div v-else class="system-message"> <div v-else class="system-message">
<div class="collapsible-block system-block" :class="{ expanded: expandedBlocks?.has(`system-${index}`) }"> <div class="collapsible-block system-block" :class="{ expanded: expandedBlocks?.has(`system-${index}`) }">
@ -184,8 +332,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { computed, ref } from 'vue';
import ToolAction from '@/components/chat/actions/ToolAction.vue'; import ToolAction from '@/components/chat/actions/ToolAction.vue';
import StackedBlocks from './StackedBlocks.vue';
import { usePersonalizationStore } from '@/stores/personalization';
const props = defineProps<{ const props = defineProps<{
messages: Array<any>; messages: Array<any>;
@ -203,6 +353,9 @@ const props = defineProps<{
formatSearchTime: (filters: Record<string, any>) => string; formatSearchTime: (filters: Record<string, any>) => string;
}>(); }>();
const personalization = usePersonalizationStore();
const stackedBlocksEnabled = computed(() => personalization.experiments.stackedBlocksEnabled);
const DEFAULT_GENERATING_TEXT = '生成中…'; const DEFAULT_GENERATING_TEXT = '生成中…';
const rootEl = ref<HTMLElement | null>(null); const rootEl = ref<HTMLElement | null>(null);
const thinkingRefs = new Map<string, HTMLElement | null>(); const thinkingRefs = new Map<string, HTMLElement | null>();
@ -226,6 +379,50 @@ function iconStyleSafe(key: string, size?: string) {
return {}; return {};
} }
const isStackable = (action: any) => action && (action.type === 'thinking' || action.type === 'tool');
const splitActionGroups = (actions: any[] = [], messageIndex = 0) => {
const result: Array<
| { kind: 'stack'; actions: any[]; key: string }
| { kind: 'single'; action: any; actionIndex: number; key: string }
> = [];
let buffer: any[] = [];
const flushBuffer = () => {
if (buffer.length >= 2) {
result.push({
kind: 'stack',
actions: buffer.slice(),
key: `stack-${messageIndex}-${result.length}`
});
} else if (buffer.length === 1) {
const single = buffer[0];
result.push({
kind: 'single',
action: single,
actionIndex: actions.indexOf(single),
key: single.id || `single-${messageIndex}-${result.length}`
});
}
buffer = [];
};
actions.forEach((action, idx) => {
if (isStackable(action)) {
buffer.push(action);
} else {
flushBuffer();
result.push({
kind: 'single',
action,
actionIndex: idx,
key: action.id || `single-${messageIndex}-${idx}`
});
}
});
flushBuffer();
return result;
};
function getGeneratingLetters(message: any) { function getGeneratingLetters(message: any) {
const label = const label =
typeof message?.generatingLabel === 'string' && message.generatingLabel.trim() typeof message?.generatingLabel === 'string' && message.generatingLabel.trim()

View File

@ -0,0 +1,302 @@
<template>
<div
class="stacked-shell"
ref="shell"
:style="{
height: `${shellHeight}px`,
paddingTop: moreVisible ? `${moreHeight}px` : '0px'
}"
>
<div
class="stacked-more-block"
ref="moreBlock"
:class="{ visible: moreVisible }"
:style="{ height: `${moreVisible ? moreHeight : 0}px` }"
@click="toggleMore"
>
<img class="more-icon" src="/static/icons/align-left.svg" alt="展开" />
<div class="more-copy">
<span class="more-title">{{ moreTitle }}</span>
<span class="more-desc">{{ moreDesc }}</span>
</div>
</div>
<div class="stacked-viewport" :style="{ height: `${viewportHeight}px` }">
<div class="stacked-inner" ref="inner" :style="{ transform: `translateY(${innerOffset}px)` }">
<div v-for="(action, idx) in stackableActions" :key="blockKey(action, idx)" class="stacked-item">
<div
v-if="action.type === 'thinking'"
class="collapsible-block thinking-block stacked-block"
:class="{ expanded: isExpanded(action, idx), processing: action.streaming }"
>
<div class="collapsible-header" @click="toggleBlock(blockKey(action, idx))">
<div class="arrow"></div>
<div class="status-icon">
<span class="thinking-icon" :class="{ 'thinking-animation': action.streaming }">
<span class="icon icon-sm" :style="iconStyle('brain')" aria-hidden="true"></span>
</span>
</div>
<span class="status-text">{{ action.streaming ? '正在思考...' : '思考过程' }}</span>
</div>
<div class="collapsible-content" :style="contentStyle(blockKey(action, idx))">
<div
class="content-inner thinking-content"
:ref="el => registerThinking(blockKey(action, idx), el)"
@scroll="handleThinkingScrollInternal(blockKey(action, idx), $event)"
style="max-height: 240px; overflow-y: auto;"
>
{{ action.content }}
</div>
</div>
</div>
<div
v-else-if="action.type === 'tool'"
class="collapsible-block tool-block stacked-block"
:class="{
expanded: isExpanded(action, idx),
processing: isToolProcessing(action),
completed: isToolCompleted(action)
}"
>
<div class="collapsible-header" @click="toggleBlock(blockKey(action, idx))">
<div class="arrow"></div>
<div class="status-icon">
<span
class="tool-icon icon icon-md"
:class="getToolAnimationClass(action.tool)"
:style="iconStyle(getToolIcon(action.tool))"
aria-hidden="true"
></span>
</div>
<span class="status-text">{{ getToolStatusText(action.tool) }}</span>
<span class="tool-desc">{{ getToolDescription(action.tool) }}</span>
</div>
<div class="collapsible-content" :style="contentStyle(blockKey(action, idx))">
<div class="content-inner">
<div v-if="action.tool?.name === 'web_search' && action.tool?.result">
<div class="search-meta">
<div><strong>搜索内容</strong>{{ action.tool.result.query || action.tool.arguments?.query }}</div>
<div><strong>主题</strong>{{ formatSearchTopic(action.tool.result.filters || {}) }}</div>
<div><strong>时间范围</strong>{{ formatSearchTime(action.tool.result.filters || {}) }}</div>
<div><strong>结果数量</strong>{{ action.tool.result.total_results }}</div>
</div>
<div v-if="action.tool.result.results && action.tool.result.results.length" class="search-result-list">
<div v-for="item in action.tool.result.results" :key="item.url || item.index" class="search-result-item">
<div class="search-result-title">{{ item.title || '无标题' }}</div>
<div class="search-result-url">
<a v-if="item.url" :href="item.url" target="_blank">{{ item.url }}</a><span v-else>无可用链接</span>
</div>
</div>
</div>
<div v-else class="search-empty">未返回详细的搜索结果</div>
</div>
<div v-else-if="action.tool?.name === 'run_python' && action.tool?.result">
<div class="code-block">
<div class="code-label">代码</div>
<pre><code class="language-python">{{ action.tool.result.code || action.tool.arguments?.code }}</code></pre>
</div>
<div v-if="action.tool.result.output" class="output-block">
<div class="output-label">输出</div>
<pre>{{ action.tool.result.output }}</pre>
</div>
</div>
<div v-else>
<pre>{{ JSON.stringify(action.tool?.result || action.tool?.arguments, null, 2) }}</pre>
</div>
</div>
</div>
<div v-if="isToolProcessing(action)" class="progress-indicator"></div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
defineOptions({ name: 'StackedBlocks' });
const props = defineProps<{
actions: any[];
expandedBlocks: Set<string>;
iconStyle: (key: string, size?: string) => Record<string, string>;
toggleBlock: (blockId: string) => void;
registerThinkingRef?: (key: string, el: Element | null) => void;
handleThinkingScroll?: (blockId: string, event: Event) => void;
getToolAnimationClass: (tool: any) => Record<string, unknown>;
getToolIcon: (tool: any) => string;
getToolStatusText: (tool: any) => string;
getToolDescription: (tool: any) => string;
formatSearchTopic: (filters: Record<string, any>) => string;
formatSearchTime: (filters: Record<string, any>) => string;
}>();
const VISIBLE_LIMIT = 6;
const shell = ref<HTMLElement | null>(null);
const inner = ref<any>(null);
const moreBlock = ref<any>(null);
const showAll = ref(false);
const shellHeight = ref(0);
const moreHeight = ref(0);
const innerOffset = ref(0);
const viewportHeight = ref(0);
const contentHeights = ref<Record<string, number>>({});
const stackableActions = computed(() => (props.actions || []).filter((item) => item && (item.type === 'thinking' || item.type === 'tool')));
const hiddenCount = computed(() => Math.max(0, stackableActions.value.length - VISIBLE_LIMIT));
const moreVisible = computed(() => hiddenCount.value > 0 || showAll.value);
const totalSteps = computed(() => stackableActions.value.length);
const moreTitle = computed(() => (showAll.value ? '已展开全部' : '更多'));
const moreDesc = computed(() =>
showAll.value ? `${totalSteps.value} 个步骤` : `${hiddenCount.value} 个步骤折叠`
);
const blockKey = (action: any, idx: number) => action?.blockId || action?.id || `stacked-${idx}`;
const isExpanded = (action: any, idx: number) => props.expandedBlocks?.has(blockKey(action, idx));
const isExpandedById = (blockId: string) => props.expandedBlocks?.has(blockId);
const contentStyle = (blockId: string) => {
const h = Math.max(0, contentHeights.value[blockId] || 0);
return { maxHeight: isExpandedById(blockId) ? `${h}px` : '0px' };
};
const isToolProcessing = (action: any) => {
const status = action?.tool?.status;
return status === 'preparing' || status === 'running';
};
const isToolCompleted = (action: any) => action?.tool?.status === 'completed';
const toggleMore = () => {
showAll.value = !showAll.value;
nextTick(() => {
setShellMetrics();
});
};
const toggleBlock = (blockId: string) => {
if (typeof props.toggleBlock === 'function') {
props.toggleBlock(blockId);
nextTick(() => {
setShellMetrics();
});
}
};
const registerThinking = (key: string, el: Element | null) => {
if (typeof props.registerThinkingRef === 'function') {
props.registerThinkingRef(key, el);
}
};
const handleThinkingScrollInternal = (blockId: string, event: Event) => {
if (typeof props.handleThinkingScroll === 'function') {
props.handleThinkingScroll(blockId, event);
}
};
const resolveEl = (target: any): HTMLElement | null => {
if (!target) return null;
if (target instanceof HTMLElement) return target;
if (target.$el && target.$el instanceof HTMLElement) return target.$el;
return null;
};
const moreBaseHeight = () => {
const moreEl = resolveEl(moreBlock.value);
if (!moreEl) return 56;
const label = moreEl.querySelector('.more-title') as HTMLElement | null;
return Math.max(48, Math.ceil(label?.getBoundingClientRect().height || 56));
};
const setShellMetrics = (expandedOverride: Record<string, boolean> = {}) => {
const innerEl = resolveEl(inner.value);
const shellEl = resolveEl(shell.value);
if (!shellEl || !innerEl) return;
const children = Array.from(innerEl.children) as HTMLElement[];
const heights: number[] = [];
const nextContentHeights: Record<string, number> = {};
children.forEach((el, idx) => {
const action = stackableActions.value[idx];
const key = blockKey(action, idx);
const header = el.querySelector('.collapsible-header') as HTMLElement | null;
const content = el.querySelector('.collapsible-content') as HTMLElement | null;
const progress = el.querySelector('.progress-indicator') as HTMLElement | null;
const expanded = typeof expandedOverride[key] === 'boolean' ? expandedOverride[key] : isExpanded(action, idx);
const headerH = header ? header.getBoundingClientRect().height : 0;
const contentHeight = content ? Math.ceil(content.scrollHeight) : 0;
nextContentHeights[key] = contentHeight;
const contentH = expanded ? contentHeight : 0;
const progressH = progress ? progress.getBoundingClientRect().height : 0;
const borderH = idx < children.length - 1 ? parseFloat(getComputedStyle(el).borderBottomWidth) || 0 : 0;
heights.push(Math.ceil(headerH + contentH + progressH + borderH));
});
contentHeights.value = nextContentHeights;
const sum = (arr: number[]) => (arr.length ? arr.reduce((a, b) => a + b, 0) : 0);
const totalHeight = sum(heights);
const hiddenHeight = sum(heights.slice(0, Math.max(0, heights.length - VISIBLE_LIMIT)));
const windowHeight = sum(heights.slice(-VISIBLE_LIMIT));
moreHeight.value = moreVisible.value ? moreBaseHeight() : 0;
const targetShell = moreHeight.value + (showAll.value || !moreVisible.value ? totalHeight : windowHeight);
const targetOffset = showAll.value || !moreVisible.value ? 0 : -hiddenHeight;
shellHeight.value = targetShell;
innerOffset.value = targetOffset;
viewportHeight.value = Math.max(0, targetShell - moreHeight.value);
};
let resizeObserver: ResizeObserver | null = null;
let heightRaf: number | null = null;
onMounted(() => {
const innerEl = resolveEl(inner.value);
setShellMetrics();
resizeObserver = new ResizeObserver(() => {
if (heightRaf) {
cancelAnimationFrame(heightRaf);
}
heightRaf = requestAnimationFrame(() => {
setShellMetrics();
});
});
if (innerEl) {
resizeObserver.observe(innerEl);
}
});
onBeforeUnmount(() => {
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
if (heightRaf) {
cancelAnimationFrame(heightRaf);
heightRaf = null;
}
});
watch([stackableActions, moreVisible], () => {
nextTick(() => {
setShellMetrics();
});
});
watch(
() => (props.expandedBlocks ? props.expandedBlocks.size : 0),
() => {
nextTick(() => setShellMetrics());
}
);
</script>

View File

@ -207,6 +207,29 @@
</button> </button>
</div> </div>
</div> </div>
<div class="behavior-field">
<div class="behavior-field-header">
<span class="field-title">堆叠块显示</span>
<p class="field-desc">使用新版堆叠动画展示思考/工具块超过 6 条自动收纳为更多默认开启</p>
</div>
<label class="toggle-row">
<input
type="checkbox"
:checked="experiments.stackedBlocksEnabled"
@change="handleStackedBlocksToggle($event)"
/>
<span class="fancy-check" aria-hidden="true">
<svg viewBox="0 0 64 64">
<path
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
pathLength="575.0541381835938"
class="fancy-path"
></path>
</svg>
</span>
<span>在对话区使用堆叠动画可随时切换回传统列表</span>
</label>
</div>
<div class="behavior-field"> <div class="behavior-field">
<div class="behavior-field-header"> <div class="behavior-field-header">
<span class="field-title">自动生成对话标题</span> <span class="field-title">自动生成对话标题</span>
@ -218,6 +241,15 @@
:checked="form.auto_generate_title" :checked="form.auto_generate_title"
@change="personalization.updateField({ key: 'auto_generate_title', value: $event.target.checked })" @change="personalization.updateField({ key: 'auto_generate_title', value: $event.target.checked })"
/> />
<span class="fancy-check" aria-hidden="true">
<svg viewBox="0 0 64 64">
<path
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
pathLength="575.0541381835938"
class="fancy-path"
></path>
</svg>
</span>
<span>使用快速模型为新对话生成含 emoji 的简短标题</span> <span>使用快速模型为新对话生成含 emoji 的简短标题</span>
</label> </label>
</div> </div>
@ -469,7 +501,6 @@ const swipeState = ref<{ startY: number; active: boolean }>({ startY: 0, active:
type RunModeValue = 'fast' | 'thinking' | 'deep' | null; type RunModeValue = 'fast' | 'thinking' | 'deep' | null;
const runModeOptions: Array<{ id: string; label: string; desc: string; value: RunModeValue; badge?: string }> = [ const runModeOptions: Array<{ id: string; label: string; desc: string; value: RunModeValue; badge?: string }> = [
{ id: 'auto', label: '跟随系统', desc: '沿用工作区默认设置', value: null },
{ id: 'fast', label: '快速模式', desc: '追求响应速度,跳过思考模型', value: 'fast' }, { id: 'fast', label: '快速模式', desc: '追求响应速度,跳过思考模型', value: 'fast' },
{ id: 'thinking', label: '思考模式', desc: '首轮回复会先输出思考过程', value: 'thinking', badge: '推荐' }, { id: 'thinking', label: '思考模式', desc: '首轮回复会先输出思考过程', value: 'thinking', badge: '推荐' },
{ id: 'deep', label: '深度思考', desc: '整轮对话都使用思考模型', value: 'deep' } { id: 'deep', label: '深度思考', desc: '整轮对话都使用思考模型', value: 'deep' }
@ -562,6 +593,11 @@ const handleLiquidGlassToggle = (event: Event) => {
personalization.setLiquidGlassExperimentEnabled(!!target?.checked); personalization.setLiquidGlassExperimentEnabled(!!target?.checked);
}; };
const handleStackedBlocksToggle = (event: Event) => {
const target = event.target as HTMLInputElement | null;
personalization.setStackedBlocksEnabled(!!target?.checked);
};
const openAdminPanel = () => { const openAdminPanel = () => {
window.open('/admin/monitor', '_blank', 'noopener'); window.open('/admin/monitor', '_blank', 'noopener');
personalization.closeDrawer(); personalization.closeDrawer();

View File

@ -23,6 +23,7 @@ interface LiquidGlassPosition {
interface ExperimentState { interface ExperimentState {
liquidGlassEnabled: boolean; liquidGlassEnabled: boolean;
liquidGlassPosition: LiquidGlassPosition | null; liquidGlassPosition: LiquidGlassPosition | null;
stackedBlocksEnabled: boolean;
} }
interface PersonalizationState { interface PersonalizationState {
@ -65,7 +66,8 @@ const defaultForm = (): PersonalForm => ({
const defaultExperimentState = (): ExperimentState => ({ const defaultExperimentState = (): ExperimentState => ({
liquidGlassEnabled: false, liquidGlassEnabled: false,
liquidGlassPosition: null liquidGlassPosition: null,
stackedBlocksEnabled: true
}); });
const isValidPosition = (value: any): value is LiquidGlassPosition => { const isValidPosition = (value: any): value is LiquidGlassPosition => {
@ -91,7 +93,9 @@ const loadExperimentState = (): ExperimentState => {
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
return { return {
liquidGlassEnabled: Boolean(parsed?.liquidGlassEnabled), liquidGlassEnabled: Boolean(parsed?.liquidGlassEnabled),
liquidGlassPosition: isValidPosition(parsed?.liquidGlassPosition) ? parsed?.liquidGlassPosition : null liquidGlassPosition: isValidPosition(parsed?.liquidGlassPosition) ? parsed?.liquidGlassPosition : null,
stackedBlocksEnabled:
typeof parsed?.stackedBlocksEnabled === 'boolean' ? parsed.stackedBlocksEnabled : defaultExperimentState().stackedBlocksEnabled
}; };
} catch (error) { } catch (error) {
console.warn('无法读取实验功能设置:', error); console.warn('无法读取实验功能设置:', error);
@ -452,6 +456,16 @@ export const usePersonalizationStore = defineStore('personalization', {
toggleLiquidGlassExperiment() { toggleLiquidGlassExperiment() {
this.setLiquidGlassExperimentEnabled(!this.experiments.liquidGlassEnabled); this.setLiquidGlassExperimentEnabled(!this.experiments.liquidGlassEnabled);
}, },
setStackedBlocksEnabled(enabled: boolean) {
this.experiments = {
...this.experiments,
stackedBlocksEnabled: !!enabled
};
this.persistExperiments();
},
toggleStackedBlocks() {
this.setStackedBlocksEnabled(!this.experiments.stackedBlocksEnabled);
},
updateLiquidGlassPosition(position: LiquidGlassPosition | null) { updateLiquidGlassPosition(position: LiquidGlassPosition | null) {
this.experiments = { this.experiments = {
...this.experiments, ...this.experiments,

View File

@ -265,7 +265,9 @@
max-height: 0; max-height: 0;
overflow: hidden; overflow: hidden;
opacity: 0; opacity: 0;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); transition:
max-height 0.26s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.26s cubic-bezier(0.4, 0, 0.2, 1);
} }
.collapsible-block.expanded .collapsible-content { .collapsible-block.expanded .collapsible-content {
@ -279,6 +281,11 @@
font-size: 14px; font-size: 14px;
line-height: 1.6; line-height: 1.6;
color: var(--claude-text-secondary); color: var(--claude-text-secondary);
scrollbar-width: none;
}
.content-inner::-webkit-scrollbar {
display: none;
} }
.action-item { .action-item {
@ -299,6 +306,131 @@
animation: none; animation: none;
} }
.stacked-blocks-wrapper {
margin: 12px 0 8px;
}
.stacked-shell {
position: relative;
border: 1px solid var(--claude-border);
border-radius: 16px;
background: var(--claude-card);
box-shadow: var(--claude-shadow);
overflow: hidden;
transition:
height 280ms cubic-bezier(0.25, 0.9, 0.3, 1),
padding-top 280ms cubic-bezier(0.25, 0.9, 0.3, 1);
min-height: 0;
}
.stacked-inner {
position: relative;
width: 100%;
transition: transform 280ms cubic-bezier(0.25, 0.9, 0.3, 1);
}
.stacked-viewport {
overflow: hidden;
position: relative;
width: 100%;
}
.stacked-item {
border-bottom: 1px solid var(--claude-border);
}
.stacked-item:last-child {
border-bottom: none;
}
.stacked-block {
margin: 0;
border: none;
border-radius: 0;
box-shadow: none;
background: transparent;
}
.stacked-more-block {
position: absolute;
inset: 0 0 auto 0;
background: var(--claude-card);
border-bottom: 0 solid var(--claude-border);
display: flex;
align-items: center;
gap: 10px;
padding: 0 22px;
height: 0;
opacity: 0;
overflow: hidden;
cursor: pointer;
z-index: 2;
transition:
height 280ms cubic-bezier(0.25, 0.9, 0.3, 1),
padding 280ms cubic-bezier(0.25, 0.9, 0.3, 1),
border-bottom-width 280ms cubic-bezier(0.25, 0.9, 0.3, 1),
opacity 160ms ease;
}
.stacked-more-block.visible {
opacity: 1;
padding: 14px 22px;
border-bottom-width: 1px;
}
.more-icon {
width: 18px;
height: 18px;
display: inline-block;
object-fit: contain;
}
.more-copy {
display: flex;
flex-direction: column;
gap: 2px;
}
.more-title {
font-weight: 700;
color: var(--claude-text);
}
.more-desc {
color: var(--claude-text-secondary);
font-size: 12px;
}
.stacked-enter-from {
opacity: 0;
transform: translateY(14px);
}
.stacked-enter-active {
transition: all 220ms cubic-bezier(0.4, 0, 0.2, 1);
}
.stacked-leave-active {
position: absolute;
width: 100%;
transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
.stacked-leave-to {
opacity: 0;
transform: translateY(-12px);
}
.stacked-move {
transition: transform 220ms cubic-bezier(0.4, 0, 0.2, 1);
}
.stacked-block .collapsible-content {
transition:
max-height 280ms cubic-bezier(0.25, 0.9, 0.3, 1),
opacity 220ms ease;
}
.progress-indicator { .progress-indicator {
position: absolute; position: absolute;
bottom: 0; bottom: 0;

View File

@ -170,6 +170,81 @@
margin-bottom: 18px; margin-bottom: 18px;
} }
.toggle-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid var(--theme-control-border);
background: var(--theme-surface-muted);
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
position: relative;
}
.toggle-row:hover {
border-color: var(--theme-control-border);
box-shadow: none;
background: var(--theme-surface-muted);
}
.toggle-row:focus-within {
border-color: var(--theme-control-border);
box-shadow: none;
}
.toggle-row input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.toggle-row .fancy-check {
width: 28px;
height: 28px;
border-radius: 6px;
background: transparent;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: none;
transition: opacity 0.2s ease;
}
.toggle-row .fancy-check svg {
width: 22px;
height: 22px;
overflow: visible;
}
.fancy-path {
fill: none;
stroke: var(--claude-text-secondary);
stroke-width: 5;
stroke-linecap: round;
stroke-linejoin: round;
transition: stroke-dasharray 0.5s ease, stroke-dashoffset 0.5s ease, stroke 0.2s ease;
stroke-dasharray: 241 9999999;
stroke-dashoffset: 0;
}
.toggle-row input:checked + .fancy-check {
background: transparent;
box-shadow: none;
}
.toggle-row input:checked + .fancy-check .fancy-path {
stroke: var(--claude-accent);
stroke-dasharray: 70.5096664428711 9999999;
stroke-dashoffset: -262.2723388671875;
}
.toggle-row span {
color: var(--claude-text);
font-weight: 500;
}
.personalization-layout { .personalization-layout {
display: grid; display: grid;
grid-template-columns: 200px minmax(0, 1fr); grid-template-columns: 200px minmax(0, 1fr);