feat: 新增极简模式(Minimal Mode)
新增了一个全新的消息显示模式,提供更简洁的对话体验。 主要功能: - 新增 MinimalBlocks.vue 组件,实现极简风格的消息块展示 - 在 ChatArea.vue 中集成极简模式渲染逻辑 - 在个性化设置中添加"极简模式"选项(blockDisplayMode) - 支持三种显示模式:堆叠模式、极简模式、传统模式 技术实现: - 使用 personalization store 管理显示模式状态 - 通过 computed 属性动态切换渲染组件 - 保持与现有堆叠模式和传统模式的兼容性 用户体验: - 更清爽的界面布局 - 减少视觉干扰 - 适合快速浏览对话内容 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
b474a69b3b
commit
321818f7ef
@ -49,7 +49,20 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="stackedBlocksEnabled">
|
||||
<template v-if="blockDisplayMode === 'minimal'">
|
||||
<MinimalBlocks
|
||||
:actions="msg.actions || []"
|
||||
:icon-style="iconStyleSafe"
|
||||
:get-tool-icon="getToolIcon"
|
||||
:get-tool-status-text="getToolStatusText"
|
||||
:get-tool-description="getToolDescription"
|
||||
:format-search-topic="formatSearchTopic"
|
||||
:format-search-time="formatSearchTime"
|
||||
:format-search-domains="formatSearchDomains"
|
||||
:render-markdown="renderMarkdown"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="stackedBlocksEnabled">
|
||||
<template v-for="(group, groupIndex) in splitActionGroups(msg.actions || [], index)" :key="group.key">
|
||||
<StackedBlocks
|
||||
v-if="group.kind === 'stack'"
|
||||
@ -365,6 +378,7 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import ToolAction from '@/components/chat/actions/ToolAction.vue';
|
||||
import StackedBlocks from './StackedBlocks.vue';
|
||||
import MinimalBlocks from './MinimalBlocks.vue';
|
||||
import { usePersonalizationStore } from '@/stores/personalization';
|
||||
|
||||
const props = defineProps<{
|
||||
@ -385,10 +399,12 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const personalization = usePersonalizationStore();
|
||||
const blockDisplayMode = computed(() => {
|
||||
return personalization.experiments.blockDisplayMode || 'stacked';
|
||||
});
|
||||
const stackedBlocksEnabled = computed(() => {
|
||||
// 默认启用堆叠块显示,除非明确设置为 false
|
||||
const enabled = personalization.experiments.stackedBlocksEnabled;
|
||||
return enabled !== false;
|
||||
// 堆叠模式和极简模式都使用堆叠布局
|
||||
return blockDisplayMode.value === 'stacked' || blockDisplayMode.value === 'minimal';
|
||||
});
|
||||
const aiAssistantName = computed(() => {
|
||||
if (!personalization.form.use_custom_names) {
|
||||
|
||||
609
static/src/components/chat/MinimalBlocks.vue
Normal file
609
static/src/components/chat/MinimalBlocks.vue
Normal file
@ -0,0 +1,609 @@
|
||||
<template>
|
||||
<div class="minimal-blocks-container">
|
||||
<!-- 等待动画 -->
|
||||
<div v-if="showGenerating" class="generating-placeholder">
|
||||
<span v-for="(letter, letterIdx) in '思考中...'.split('')" :key="letterIdx" class="letter" :style="{ animationDelay: `${letterIdx * 0.1}s` }">
|
||||
{{ letter }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 按真实顺序显示分组 -->
|
||||
<template v-for="group in blockGroups" :key="group.id">
|
||||
<!-- 摘要组(thinking/tool) -->
|
||||
<div v-if="group.type === 'summary' && group.actions" class="summary-group">
|
||||
<!-- 摘要行(只有文字,可点击展开) -->
|
||||
<div
|
||||
class="summary-line-text"
|
||||
:class="{ running: isSummaryRunning(group.actions, group.id) }"
|
||||
@click="toggleExpand(group.id)"
|
||||
>
|
||||
<div class="summary-content-wrapper">
|
||||
<span class="summary-preview">{{ getSummaryPreview(group.actions) }}</span>
|
||||
</div>
|
||||
<svg class="expand-icon" :class="{ expanded: expandedGroups.has(group.id) }" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 步骤容器(展开时显示所有步骤) -->
|
||||
<div class="steps-container" :class="{ show: expandedGroups.has(group.id) }">
|
||||
<div class="steps-wrapper">
|
||||
<div
|
||||
v-for="(step, idx) in getSummarySteps(group.actions)"
|
||||
:key="step.id"
|
||||
class="step-item"
|
||||
>
|
||||
<div class="step-timeline">
|
||||
<svg class="step-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<g v-html="getStepIcon(step)"></g>
|
||||
</svg>
|
||||
<div class="step-line"></div>
|
||||
</div>
|
||||
<div class="step-content">
|
||||
<div v-if="step.type === 'thinking'" class="step-body">
|
||||
<div class="thinking-content" v-html="renderContent(step.content)"></div>
|
||||
</div>
|
||||
<div v-else-if="step.type === 'tool'" class="step-body">
|
||||
<div class="step-header">{{ getToolName(step.action) }}</div>
|
||||
<div class="tool-intent" v-if="getToolIntent(step.action)">{{ getToolIntent(step.action) }}</div>
|
||||
<div class="tool-result" v-if="step.action.tool?.result">
|
||||
<div v-html="renderToolResult(step.action)"></div>
|
||||
</div>
|
||||
<div v-else class="tool-result">
|
||||
<div class="result-item">执行中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文本组 -->
|
||||
<div v-else-if="group.type === 'text'" class="text-output" :class="{ 'streaming-text': group.streaming }">
|
||||
<div class="text-content" :class="{ 'streaming-text': group.streaming }">
|
||||
<div v-if="group.streaming" v-html="renderMarkdown(group.content || '', true)"></div>
|
||||
<div v-else v-html="renderMarkdown(group.content || '', false)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { usePersonalizationStore } from '@/stores/personalization';
|
||||
import { renderEnhancedToolResult } from './actions/toolRenderers';
|
||||
|
||||
interface Action {
|
||||
id?: string;
|
||||
type: 'thinking' | 'tool' | 'text' | 'system';
|
||||
content: string;
|
||||
streaming?: boolean;
|
||||
blockId?: string;
|
||||
tool?: {
|
||||
name?: string;
|
||||
intent?: string;
|
||||
arguments?: any;
|
||||
result?: any;
|
||||
};
|
||||
}
|
||||
|
||||
interface Step {
|
||||
id: string;
|
||||
type: 'thinking' | 'tool';
|
||||
content: string;
|
||||
streaming: boolean;
|
||||
action: Action;
|
||||
}
|
||||
|
||||
interface BlockGroup {
|
||||
type: 'summary' | 'text';
|
||||
id: string;
|
||||
actions?: Action[];
|
||||
content?: string;
|
||||
streaming?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
actions: Action[];
|
||||
iconStyle?: (name: string) => any;
|
||||
getToolIcon?: (tool: any) => string;
|
||||
getToolStatusText?: (tool: any) => string;
|
||||
getToolDescription?: (tool: any) => string;
|
||||
formatSearchTopic?: (filters: Record<string, any>) => string;
|
||||
formatSearchTime?: (filters: Record<string, any>) => string;
|
||||
formatSearchDomains?: (filters: Record<string, any>) => string;
|
||||
renderMarkdown?: (content: string, isStreaming: boolean) => string;
|
||||
}>();
|
||||
|
||||
const personalizationStore = usePersonalizationStore();
|
||||
const expandedGroups = ref(new Set<string>());
|
||||
|
||||
// 将 actions 分组:连续的 thinking/tool 为一组,text 单独为一组
|
||||
const blockGroups = computed(() => {
|
||||
const groups: BlockGroup[] = [];
|
||||
let currentGroup: Action[] = [];
|
||||
let groupIndex = 0;
|
||||
|
||||
props.actions.forEach((action, idx) => {
|
||||
if (action.type === 'thinking' || action.type === 'tool') {
|
||||
currentGroup.push(action);
|
||||
} else if (action.type === 'text') {
|
||||
// 先保存当前的 thinking/tool 组
|
||||
if (currentGroup.length > 0) {
|
||||
groups.push({
|
||||
type: 'summary',
|
||||
id: `summary-${groupIndex++}`,
|
||||
actions: currentGroup
|
||||
});
|
||||
currentGroup = [];
|
||||
}
|
||||
// 添加 text 组
|
||||
groups.push({
|
||||
type: 'text',
|
||||
id: `text-${idx}`,
|
||||
content: action.content,
|
||||
streaming: action.streaming
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 处理最后剩余的 thinking/tool 组
|
||||
if (currentGroup.length > 0) {
|
||||
groups.push({
|
||||
type: 'summary',
|
||||
id: `summary-${groupIndex}`,
|
||||
actions: currentGroup
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
});
|
||||
|
||||
// 是否显示等待动画
|
||||
const showGenerating = computed(() => {
|
||||
const hasAnyThinkingOrTool = props.actions.some(a => a.type === 'thinking' || a.type === 'tool');
|
||||
const isStreaming = props.actions.some(a => a.streaming && (a.type === 'thinking' || a.type === 'tool'));
|
||||
return isStreaming && !hasAnyThinkingOrTool;
|
||||
});
|
||||
|
||||
// 获取摘要组的预览文本(始终显示最新的思考/工具)
|
||||
const getSummaryPreview = (actions: Action[]) => {
|
||||
// 优先返回正在 streaming 的最后一个
|
||||
const streamingActions = actions.filter(a => a.streaming);
|
||||
const currentStep = streamingActions.length > 0
|
||||
? streamingActions[streamingActions.length - 1]
|
||||
: actions[actions.length - 1]; // 如果没有 streaming 的,返回最后一个
|
||||
|
||||
if (!currentStep) return '';
|
||||
|
||||
if (currentStep.type === 'thinking') {
|
||||
// 思考内容:最多显示到第一个换行,且不超过50字符
|
||||
const content = currentStep.content || '';
|
||||
const firstLineEnd = content.indexOf('\n');
|
||||
const textToShow = firstLineEnd > 0 ? content.substring(0, firstLineEnd) : content;
|
||||
return textToShow.length > 50 ? textToShow.substring(0, 50) + '...' : textToShow;
|
||||
} else if (currentStep.type === 'tool') {
|
||||
// 工具:优先显示 intent_rendered 或 intent_full
|
||||
const tool = currentStep.tool;
|
||||
if (!tool) return '';
|
||||
|
||||
const intentEnabled = personalizationStore.form.tool_intent_enabled;
|
||||
const intentText = tool.intent_rendered || tool.intent_full || '';
|
||||
|
||||
if (intentEnabled && intentText) {
|
||||
// 开启intent模式且有intent时,只显示intent
|
||||
const firstLineEnd = intentText.indexOf('\n');
|
||||
const textToShow = firstLineEnd > 0 ? intentText.substring(0, firstLineEnd) : intentText;
|
||||
return textToShow.length > 50 ? textToShow.substring(0, 50) + '...' : textToShow;
|
||||
}
|
||||
|
||||
// 没有intent或未开启intent模式时显示状态
|
||||
if (tool.status === 'preparing') {
|
||||
return `准备调用 ${tool.name || '工具'}...`;
|
||||
}
|
||||
if (tool.status === 'running') {
|
||||
return `正在调用 ${tool.name || '工具'}...`;
|
||||
}
|
||||
if (tool.status === 'completed') {
|
||||
return '工具执行完成';
|
||||
}
|
||||
return currentStep.streaming ? '正在执行工具...' : '工具执行完成';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// 判断摘要组是否正在运行(包括已完成但后面还没有文本输出的情况)
|
||||
const isSummaryRunning = (actions: Action[], groupId: string) => {
|
||||
// 找到当前 group 在 blockGroups 中的索引
|
||||
const currentIndex = blockGroups.value.findIndex(g => g.id === groupId);
|
||||
if (currentIndex === -1) return false;
|
||||
|
||||
// 检查后面是否有文本输出
|
||||
const hasTextAfter = blockGroups.value.slice(currentIndex + 1).some(g => g.type === 'text');
|
||||
|
||||
// 如果后面有文本输出,说明这个 summary group 已经完成了,不再显示变色效果
|
||||
if (hasTextAfter) return false;
|
||||
|
||||
// 否则,这个 summary group 应该有变色效果(无论是运行中还是已完成)
|
||||
return true;
|
||||
};
|
||||
|
||||
// 获取摘要组的所有步骤(用于展开显示)
|
||||
const getSummarySteps = (actions: Action[]) => {
|
||||
return actions.map((action, idx) => ({
|
||||
id: action.id || action.blockId || `step-${idx}`,
|
||||
type: action.type as 'thinking' | 'tool',
|
||||
content: action.content || '',
|
||||
streaming: action.streaming || false,
|
||||
action
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleExpand = (groupId: string) => {
|
||||
if (expandedGroups.value.has(groupId)) {
|
||||
expandedGroups.value.delete(groupId);
|
||||
} else {
|
||||
expandedGroups.value.add(groupId);
|
||||
}
|
||||
};
|
||||
|
||||
const getStepIcon = (step: Step) => {
|
||||
return step.type === 'thinking' ? getIconPath('brain') : getIconPath('terminal');
|
||||
};
|
||||
|
||||
const getToolName = (action: Action) => {
|
||||
const tool = action.tool;
|
||||
if (!tool) return '执行工具';
|
||||
|
||||
const intentEnabled = personalizationStore.form.tool_intent_enabled;
|
||||
const intentText = tool.intent_rendered || tool.intent_full || '';
|
||||
|
||||
if (intentEnabled && intentText) {
|
||||
// 开启intent模式且有intent时,只显示intent
|
||||
return intentText;
|
||||
}
|
||||
|
||||
// 没有intent或未开启intent模式时显示状态
|
||||
if (tool.status === 'preparing') {
|
||||
return `准备调用 ${tool.name || '工具'}...`;
|
||||
}
|
||||
if (tool.status === 'running') {
|
||||
return `正在调用 ${tool.name || '工具'}...`;
|
||||
}
|
||||
if (tool.status === 'completed') {
|
||||
return '工具执行完成';
|
||||
}
|
||||
return tool.name || '执行工具';
|
||||
};
|
||||
|
||||
const getToolIntent = (action: Action) => {
|
||||
return action.tool?.intent || '';
|
||||
};
|
||||
|
||||
const renderToolResult = (action: Action) => {
|
||||
if (personalizationStore.form.enhanced_tool_display) {
|
||||
const rendered = renderEnhancedToolResult(
|
||||
action,
|
||||
props.formatSearchTopic || (() => ''),
|
||||
props.formatSearchTime || (() => ''),
|
||||
props.formatSearchDomains || (() => '')
|
||||
);
|
||||
if (rendered) return rendered;
|
||||
}
|
||||
|
||||
const result = action.tool?.result;
|
||||
if (!result) return '<div class="result-item">执行中...</div>';
|
||||
|
||||
if (typeof result === 'string') {
|
||||
return `<div class="result-item"><pre>${escapeHtml(result)}</pre></div>`;
|
||||
}
|
||||
|
||||
return `<div class="result-item"><pre>${escapeHtml(JSON.stringify(result, null, 2))}</pre></div>`;
|
||||
};
|
||||
|
||||
const renderContent = (content: string) => {
|
||||
if (!content) return '';
|
||||
return escapeHtml(content).replace(/\n/g, '<br>');
|
||||
};
|
||||
|
||||
const renderMarkdown = (content: string, isStreaming: boolean) => {
|
||||
if (props.renderMarkdown) {
|
||||
return props.renderMarkdown(content, isStreaming);
|
||||
}
|
||||
// 降级:如果没有提供renderMarkdown,使用简单的HTML转义
|
||||
return renderContent(content);
|
||||
};
|
||||
|
||||
const escapeHtml = (text: string) => {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
};
|
||||
|
||||
const getIconPath = (name: string) => {
|
||||
const icons: Record<string, string> = {
|
||||
brain: '<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"></path><path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"></path><path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"></path><path d="M17.599 6.5a3 3 0 0 0 .399-1.375"></path><path d="M6.003 5.125A3 3 0 0 0 6.401 6.5"></path><path d="M3.477 10.896a4 4 0 0 1 .585-.396"></path><path d="M19.938 10.5a4 4 0 0 1 .585.396"></path><path d="M6 18a4 4 0 0 1-1.967-.516"></path><path d="M19.967 17.484A4 4 0 0 1 18 18"></path>',
|
||||
terminal: '<polyline points="4 17 10 11 4 5"></polyline><line x1="12" y1="19" x2="20" y2="19"></line>',
|
||||
search: '<circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.35-4.35"></path>'
|
||||
};
|
||||
return icons[name] || icons.brain;
|
||||
};
|
||||
|
||||
watch(() => props.actions, () => {
|
||||
// 简化:不需要动态更新高度
|
||||
}, { deep: true });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.minimal-blocks-container {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
/* 等待动画 */
|
||||
.generating-placeholder {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 12px 0;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--claude-text-tertiary);
|
||||
}
|
||||
|
||||
.generating-placeholder .letter {
|
||||
display: inline-block;
|
||||
animation: wave 1.6s ease-in-out infinite;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
@keyframes wave {
|
||||
0%, 100% {
|
||||
opacity: 0.4;
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 摘要组 */
|
||||
.summary-group {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
/* 摘要行(只有文字) */
|
||||
.summary-line-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.summary-line-text:hover {
|
||||
background-color: var(--claude-panel);
|
||||
}
|
||||
|
||||
.summary-content-wrapper {
|
||||
flex: 1;
|
||||
font-size: 15px;
|
||||
color: var(--claude-text-secondary);
|
||||
line-height: 1.7;
|
||||
padding: 0 20px 0 15px;
|
||||
}
|
||||
|
||||
.summary-preview {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
color: var(--claude-text-tertiary);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.expand-icon.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* 运行中的发光效果 */
|
||||
.summary-line-text.running .summary-preview {
|
||||
animation: textGlow 1.5s ease-in-out infinite !important;
|
||||
}
|
||||
|
||||
@keyframes textGlow {
|
||||
0%, 100% {
|
||||
color: var(--claude-text-secondary) !important;
|
||||
}
|
||||
50% {
|
||||
color: var(--claude-accent) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 步骤容器 */
|
||||
.steps-container {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
margin-top: 8px;
|
||||
padding: 0 20px 0 15px;
|
||||
}
|
||||
|
||||
.steps-wrapper {
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.steps-container.show {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
/* 步骤项 */
|
||||
.step-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.step-item:not(:last-child) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.step-timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.step-icon {
|
||||
color: var(--claude-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-line {
|
||||
width: 2px;
|
||||
flex: 1;
|
||||
background: var(--claude-border);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 最后一个步骤隐藏竖线 */
|
||||
.step-item:last-child .step-line {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.step-header {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--claude-text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.step-body {
|
||||
font-size: 14px;
|
||||
color: var(--claude-text-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* 思考内容 */
|
||||
.thinking-content {
|
||||
color: var(--claude-text-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
max-height: calc(1.7em * 6); /* 6行 */
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
/* 让第一行文字紧贴顶部 */
|
||||
display: block;
|
||||
}
|
||||
|
||||
.thinking-content::before {
|
||||
content: '';
|
||||
display: block;
|
||||
margin-top: calc((1.7em - 1em) / -2);
|
||||
}
|
||||
|
||||
.thinking-content::after {
|
||||
content: '';
|
||||
display: block;
|
||||
margin-bottom: calc((1.7em - 1em) / -2);
|
||||
}
|
||||
|
||||
/* 工具内容 */
|
||||
.step-header {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--claude-text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin: 0 0 8px 0;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.tool-intent {
|
||||
font-weight: 500;
|
||||
color: var(--claude-text);
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
/* 让第一行文字紧贴顶部 */
|
||||
margin-top: calc((1.7em - 1em) / -2);
|
||||
}
|
||||
|
||||
.tool-result {
|
||||
background: var(--claude-panel);
|
||||
border: 1px solid var(--claude-border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
font-size: 13px;
|
||||
max-height: 200px; /* 约为原来卡片的三分之一 */
|
||||
overflow-y: auto;
|
||||
/* 隐藏滚动条 */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
}
|
||||
|
||||
.tool-result::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari, Opera */
|
||||
}
|
||||
|
||||
.tool-result * {
|
||||
/* 隐藏所有子元素的滚动条 */
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.tool-result *::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tool-result pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--claude-text-secondary);
|
||||
}
|
||||
|
||||
.result-item {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--claude-border);
|
||||
}
|
||||
|
||||
.result-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* 文本输出 */
|
||||
.text-output {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.text-content {
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
color: var(--claude-text);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
</style>
|
||||
@ -430,27 +430,27 @@
|
||||
</div>
|
||||
<div class="behavior-field">
|
||||
<div class="behavior-field-header">
|
||||
<span class="field-title">堆叠块显示</span>
|
||||
<p class="field-desc">使用新版堆叠动画展示思考/工具块,超过 6 条自动收纳为"更多"。默认开启。</p>
|
||||
<span class="field-title">堆叠块显示模式</span>
|
||||
<p class="field-desc">选择思考/工具块的显示方式。默认为堆叠动画。</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="run-mode-options">
|
||||
<button
|
||||
v-for="option in blockDisplayOptions"
|
||||
:key="option.id"
|
||||
type="button"
|
||||
class="run-mode-card"
|
||||
:class="{ active: experiments.blockDisplayMode === option.value }"
|
||||
:aria-pressed="experiments.blockDisplayMode === option.value"
|
||||
@click.prevent="handleBlockDisplayModeChange(option.value)"
|
||||
>
|
||||
<div class="run-mode-card-header">
|
||||
<span class="run-mode-title">{{ option.label }}</span>
|
||||
<span v-if="option.badge" class="run-mode-badge">{{ option.badge }}</span>
|
||||
</div>
|
||||
<p class="run-mode-desc">{{ option.desc }}</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="behavior-field">
|
||||
<div class="behavior-field-header">
|
||||
<span class="field-title">静默禁用</span>
|
||||
@ -769,7 +769,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { usePersonalizationStore } from '@/stores/personalization';
|
||||
import { usePersonalizationStore, type BlockDisplayMode } from '@/stores/personalization';
|
||||
import { useResourceStore } from '@/stores/resource';
|
||||
import { useUiStore } from '@/stores/ui';
|
||||
import { usePolicyStore } from '@/stores/policy';
|
||||
@ -1062,9 +1062,14 @@ const handleLiquidGlassToggle = (event: Event) => {
|
||||
personalization.setLiquidGlassExperimentEnabled(!!target?.checked);
|
||||
};
|
||||
|
||||
const handleStackedBlocksToggle = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement | null;
|
||||
personalization.setStackedBlocksEnabled(!!target?.checked);
|
||||
const blockDisplayOptions = [
|
||||
{ id: 'traditional', label: '传统列表', desc: '按时间顺序垂直排列,经典样式', value: 'traditional' as const },
|
||||
{ id: 'stacked', label: '堆叠动画', desc: '超过 6 条自动收纳为"更多",动态展示', value: 'stacked' as const, badge: '推荐' },
|
||||
{ id: 'minimal', label: '极简模式', desc: '摘要行 + 无缝展开,流式输出效果', value: 'minimal' as const, badge: '新' }
|
||||
];
|
||||
|
||||
const handleBlockDisplayModeChange = (mode: 'traditional' | 'stacked' | 'minimal') => {
|
||||
personalization.setBlockDisplayMode(mode);
|
||||
};
|
||||
|
||||
const openAdminPanel = () => {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
export type BlockDisplayMode = 'traditional' | 'stacked' | 'minimal';
|
||||
type RunMode = 'fast' | 'thinking' | 'deep';
|
||||
|
||||
interface PersonalForm {
|
||||
@ -31,7 +32,7 @@ interface LiquidGlassPosition {
|
||||
interface ExperimentState {
|
||||
liquidGlassEnabled: boolean;
|
||||
liquidGlassPosition: LiquidGlassPosition | null;
|
||||
stackedBlocksEnabled: boolean;
|
||||
blockDisplayMode: BlockDisplayMode;
|
||||
}
|
||||
|
||||
interface PersonalizationState {
|
||||
@ -84,7 +85,7 @@ const defaultForm = (): PersonalForm => ({
|
||||
const defaultExperimentState = (): ExperimentState => ({
|
||||
liquidGlassEnabled: false,
|
||||
liquidGlassPosition: null,
|
||||
stackedBlocksEnabled: true
|
||||
blockDisplayMode: 'stacked'
|
||||
});
|
||||
|
||||
const isValidPosition = (value: any): value is LiquidGlassPosition => {
|
||||
@ -108,11 +109,21 @@ const loadExperimentState = (): ExperimentState => {
|
||||
return defaultExperimentState();
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
|
||||
// 兼容旧版 stackedBlocksEnabled
|
||||
let blockDisplayMode: BlockDisplayMode = defaultExperimentState().blockDisplayMode;
|
||||
if (typeof parsed?.blockDisplayMode === 'string' &&
|
||||
['traditional', 'stacked', 'minimal'].includes(parsed.blockDisplayMode)) {
|
||||
blockDisplayMode = parsed.blockDisplayMode;
|
||||
} else if (typeof parsed?.stackedBlocksEnabled === 'boolean') {
|
||||
// 兼容旧版:true -> stacked, false -> traditional
|
||||
blockDisplayMode = parsed.stackedBlocksEnabled ? 'stacked' : 'traditional';
|
||||
}
|
||||
|
||||
return {
|
||||
liquidGlassEnabled: Boolean(parsed?.liquidGlassEnabled),
|
||||
liquidGlassPosition: isValidPosition(parsed?.liquidGlassPosition) ? parsed?.liquidGlassPosition : null,
|
||||
stackedBlocksEnabled:
|
||||
typeof parsed?.stackedBlocksEnabled === 'boolean' ? parsed.stackedBlocksEnabled : defaultExperimentState().stackedBlocksEnabled
|
||||
blockDisplayMode
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('无法读取实验功能设置:', error);
|
||||
@ -532,16 +543,13 @@ export const usePersonalizationStore = defineStore('personalization', {
|
||||
toggleLiquidGlassExperiment() {
|
||||
this.setLiquidGlassExperimentEnabled(!this.experiments.liquidGlassEnabled);
|
||||
},
|
||||
setStackedBlocksEnabled(enabled: boolean) {
|
||||
setBlockDisplayMode(mode: BlockDisplayMode) {
|
||||
this.experiments = {
|
||||
...this.experiments,
|
||||
stackedBlocksEnabled: !!enabled
|
||||
blockDisplayMode: mode
|
||||
};
|
||||
this.persistExperiments();
|
||||
},
|
||||
toggleStackedBlocks() {
|
||||
this.setStackedBlocksEnabled(!this.experiments.stackedBlocksEnabled);
|
||||
},
|
||||
updateLiquidGlassPosition(position: LiquidGlassPosition | null) {
|
||||
this.experiments = {
|
||||
...this.experiments,
|
||||
|
||||
@ -363,6 +363,12 @@
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.user-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end; /* 靠右对齐 */
|
||||
}
|
||||
|
||||
.user-message .message-header,
|
||||
.assistant-message .message-header {
|
||||
font-size: 14px;
|
||||
@ -390,6 +396,9 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-width: 60%; /* 最大宽度 60% */
|
||||
width: fit-content; /* 根据内容自适应宽度 */
|
||||
align-self: flex-end; /* 确保靠右 */
|
||||
}
|
||||
|
||||
.assistant-message .message-text {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user