feat: polish minimal summary loader interactions
This commit is contained in:
parent
0b923d6c53
commit
0c213b0a9f
@ -52,6 +52,8 @@
|
||||
<template v-if="blockDisplayMode === 'minimal'">
|
||||
<MinimalBlocks
|
||||
:actions="msg.actions || []"
|
||||
:conversation-running="streamingMessage"
|
||||
:is-latest-message="index === latestMessageIndex"
|
||||
:icon-style="iconStyleSafe"
|
||||
:get-tool-icon="getToolIcon"
|
||||
:get-tool-status-text="getToolStatusText"
|
||||
@ -423,6 +425,7 @@ const userName = computed(() => {
|
||||
const filteredMessages = computed(() =>
|
||||
(props.messages || []).filter(m => !(m && m.metadata && m.metadata.system_injected_image) && m.role !== 'system')
|
||||
);
|
||||
const latestMessageIndex = computed(() => filteredMessages.value.length - 1);
|
||||
|
||||
const DEFAULT_GENERATING_TEXT = '生成中…';
|
||||
const rootEl = ref<HTMLElement | null>(null);
|
||||
|
||||
@ -113,6 +113,8 @@ interface BlockGroup {
|
||||
|
||||
const props = defineProps<{
|
||||
actions: Action[];
|
||||
conversationRunning?: boolean;
|
||||
isLatestMessage?: boolean;
|
||||
iconStyle?: (name: string) => any;
|
||||
getToolIcon?: (tool: any) => string;
|
||||
getToolStatusText?: (tool: any) => string;
|
||||
@ -129,6 +131,15 @@ const personalizationStore = usePersonalizationStore();
|
||||
const expandedGroups = ref(new Set<string>());
|
||||
const thinkingRefs = new Map<string, HTMLElement>();
|
||||
const summaryLoaders = new Map<string, Component>();
|
||||
const SUMMARY_PREVIEW_MAX_CHARS = 25;
|
||||
|
||||
const truncateSummaryPreview = (raw: string) => {
|
||||
const text = typeof raw === 'string' ? raw : '';
|
||||
if (text.length > SUMMARY_PREVIEW_MAX_CHARS) {
|
||||
return `${text.slice(0, SUMMARY_PREVIEW_MAX_CHARS)}...`;
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
// 获取摘要组的加载动画组件(每次action类型切换时随机一次)
|
||||
const getSummaryLoader = (groupId: string) => {
|
||||
@ -237,11 +248,11 @@ const getSummaryPreview = (actions: Action[]) => {
|
||||
if (!currentStep) return '';
|
||||
|
||||
if (currentStep.type === 'thinking') {
|
||||
// 思考内容:最多显示到第一个换行,且不超过50字符
|
||||
// 思考内容:最多显示到第一个换行,且不超过25字符(不含...)
|
||||
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;
|
||||
return truncateSummaryPreview(textToShow);
|
||||
} else if (currentStep.type === 'tool') {
|
||||
// 工具:优先显示 intent_rendered 或 intent_full
|
||||
const tool = currentStep.tool;
|
||||
@ -254,7 +265,7 @@ const getSummaryPreview = (actions: Action[]) => {
|
||||
// 开启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;
|
||||
return truncateSummaryPreview(textToShow);
|
||||
}
|
||||
|
||||
// 没有intent或未开启intent模式时显示状态
|
||||
@ -274,6 +285,10 @@ const getSummaryPreview = (actions: Action[]) => {
|
||||
|
||||
// 判断摘要组是否正在运行(显示加载动画还是对勾)
|
||||
const isSummaryRunning = (actions: Action[], groupId: string) => {
|
||||
if (!props.conversationRunning || !props.isLatestMessage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 找到当前 group 在 blockGroups 中的索引
|
||||
const currentIndex = blockGroups.value.findIndex(g => g.id === groupId);
|
||||
if (currentIndex === -1) return false;
|
||||
@ -465,10 +480,11 @@ watch(() => props.actions, () => {
|
||||
|
||||
/* 摘要行(只有文字) */
|
||||
.summary-line-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
column-gap: 8px;
|
||||
padding: 0 20px 0 15px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
@ -478,24 +494,26 @@ watch(() => props.actions, () => {
|
||||
}
|
||||
|
||||
.summary-content-wrapper {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 15px;
|
||||
color: var(--claude-text-secondary);
|
||||
line-height: 1.7;
|
||||
padding: 0 20px 0 15px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.summary-preview {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.summary-status-icon {
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
margin-left: 0;
|
||||
align-self: start;
|
||||
margin-top: calc((1.7em - 18px) / 2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@ -12,46 +12,43 @@
|
||||
|
||||
<style scoped>
|
||||
.bouncing-squares-loader {
|
||||
width: 40px;
|
||||
--square-size: 3px;
|
||||
--square-gap: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, var(--square-size));
|
||||
column-gap: var(--square-gap);
|
||||
align-items: end;
|
||||
width: calc(3 * var(--square-size) + 2 * var(--square-gap));
|
||||
height: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.square {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
position: absolute;
|
||||
width: var(--square-size);
|
||||
height: var(--square-size);
|
||||
background-color: var(--claude-text-tertiary);
|
||||
left: 15%;
|
||||
transform-origin: 50%;
|
||||
transform-origin: center bottom;
|
||||
animation: bounce-square .5s alternate infinite ease;
|
||||
}
|
||||
|
||||
@keyframes bounce-square {
|
||||
0% {
|
||||
top: 20px;
|
||||
height: 2px;
|
||||
transform: scaleX(1.7);
|
||||
transform: translateY(0) scaleX(1.7) scaleY(0.7);
|
||||
}
|
||||
|
||||
40% {
|
||||
height: 3px;
|
||||
transform: scaleX(1);
|
||||
transform: translateY(-8px) scaleX(1) scaleY(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 0%;
|
||||
transform: translateY(-17px) scaleX(1) scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
.square:nth-child(2) {
|
||||
left: 45%;
|
||||
animation-delay: .2s;
|
||||
}
|
||||
|
||||
.square:nth-child(3) {
|
||||
left: auto;
|
||||
right: 15%;
|
||||
animation-delay: .3s;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -19,20 +19,19 @@
|
||||
<style scoped>
|
||||
.ripple-loader {
|
||||
--cell-size: 3px;
|
||||
--cell-spacing: 2px;
|
||||
--cells: 3;
|
||||
--total-size: calc(var(--cells) * (var(--cell-size) + 2 * var(--cell-spacing)));
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: var(--total-size);
|
||||
height: var(--total-size);
|
||||
--cell-gap: 4px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, var(--cell-size));
|
||||
grid-template-rows: repeat(3, var(--cell-size));
|
||||
gap: var(--cell-gap);
|
||||
width: calc(3 * var(--cell-size) + 2 * var(--cell-gap));
|
||||
height: calc(3 * var(--cell-size) + 2 * var(--cell-gap));
|
||||
}
|
||||
|
||||
.cell {
|
||||
flex: 0 0 var(--cell-size);
|
||||
margin: var(--cell-spacing);
|
||||
width: var(--cell-size);
|
||||
height: var(--cell-size);
|
||||
background-color: transparent;
|
||||
box-sizing: border-box;
|
||||
animation: 1.5s ripple ease infinite;
|
||||
}
|
||||
|
||||
|
||||
@ -18,8 +18,42 @@ export const loaderPool: Component[] = [
|
||||
RippleLoader
|
||||
];
|
||||
|
||||
// 随机获取一个加载动画
|
||||
// 至少间隔多少次后才允许重复(例如 4 表示最近 4 次不重复)
|
||||
const MIN_NON_REPEAT_WINDOW = 4;
|
||||
const recentPickedIndices: number[] = [];
|
||||
|
||||
const pickRandomIndex = (indices: number[]) => {
|
||||
const randomIndex = Math.floor(Math.random() * indices.length);
|
||||
return indices[randomIndex];
|
||||
};
|
||||
|
||||
// 随机获取一个加载动画(带“近期不重复”约束)
|
||||
export function getRandomLoader(): Component {
|
||||
const randomIndex = Math.floor(Math.random() * loaderPool.length);
|
||||
return loaderPool[randomIndex];
|
||||
const poolSize = loaderPool.length;
|
||||
if (poolSize === 0) {
|
||||
throw new Error('loaderPool 不能为空');
|
||||
}
|
||||
|
||||
// 不重复窗口不能超过 poolSize - 1,否则无解
|
||||
const nonRepeatWindow = Math.max(0, Math.min(MIN_NON_REPEAT_WINDOW, poolSize - 1));
|
||||
|
||||
let candidateIndices = Array.from({ length: poolSize }, (_, i) => i);
|
||||
if (nonRepeatWindow > 0 && recentPickedIndices.length > 0) {
|
||||
const blocked = new Set(recentPickedIndices.slice(-nonRepeatWindow));
|
||||
const filtered = candidateIndices.filter((idx) => !blocked.has(idx));
|
||||
if (filtered.length > 0) {
|
||||
candidateIndices = filtered;
|
||||
}
|
||||
}
|
||||
|
||||
const pickedIndex = pickRandomIndex(candidateIndices);
|
||||
recentPickedIndices.push(pickedIndex);
|
||||
|
||||
// 控制历史队列长度,避免无限增长
|
||||
const maxHistory = Math.max(poolSize * 2, nonRepeatWindow + 1);
|
||||
if (recentPickedIndices.length > maxHistory) {
|
||||
recentPickedIndices.splice(0, recentPickedIndices.length - maxHistory);
|
||||
}
|
||||
|
||||
return loaderPool[pickedIndex];
|
||||
}
|
||||
|
||||
@ -112,8 +112,8 @@
|
||||
}
|
||||
|
||||
.scroll-lock-toggle {
|
||||
position: absolute;
|
||||
right: 28px;
|
||||
position: fixed;
|
||||
right: 8px;
|
||||
bottom: 140px;
|
||||
z-index: 25;
|
||||
display: flex;
|
||||
@ -126,7 +126,7 @@
|
||||
}
|
||||
|
||||
.chat-container--immersive .scroll-lock-toggle {
|
||||
right: calc((100% - min(900px, 100%)) / 2 + 24px);
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.chat-container--mobile {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user