feat: refine stacked blocks toggle and animations
This commit is contained in:
parent
93304bd2b8
commit
713659a644
@ -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()
|
||||||
|
|||||||
302
static/src/components/chat/StackedBlocks.vue
Normal file
302
static/src/components/chat/StackedBlocks.vue
Normal 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>
|
||||||
@ -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();
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user