feat(chat): add randomized summary loaders in minimal mode
This commit is contained in:
parent
8d5a034c70
commit
3b710020fe
@ -1,12 +1,5 @@
|
||||
<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) -->
|
||||
@ -20,9 +13,14 @@
|
||||
<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 class="summary-status-icon">
|
||||
<component v-if="isSummaryRunning(group.actions, group.id)" :is="getSummaryLoader(group.id)" />
|
||||
<svg v-else class="check-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="7.5 12 10.5 15 16.5 9" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤容器(展开时显示所有步骤) -->
|
||||
@ -78,9 +76,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { ref, computed, watch, nextTick, Component } from 'vue';
|
||||
import { usePersonalizationStore } from '@/stores/personalization';
|
||||
import { renderEnhancedToolResult } from './actions/toolRenderers';
|
||||
import { getRandomLoader } from './loaders/index';
|
||||
|
||||
interface Action {
|
||||
id?: string;
|
||||
@ -128,6 +127,46 @@ const props = defineProps<{
|
||||
|
||||
const personalizationStore = usePersonalizationStore();
|
||||
const expandedGroups = ref(new Set<string>());
|
||||
const thinkingRefs = new Map<string, HTMLElement>();
|
||||
const summaryLoaders = new Map<string, Component>();
|
||||
|
||||
// 获取摘要组的加载动画组件(每次action类型切换时随机一次)
|
||||
const getSummaryLoader = (groupId: string) => {
|
||||
const group = blockGroups.value.find(g => g.id === groupId);
|
||||
if (!group || !group.actions) return getRandomLoader();
|
||||
|
||||
// 找到当前正在streaming的action,或最后一个action
|
||||
const streamingAction = group.actions.find(a => a.streaming);
|
||||
const currentAction = streamingAction || group.actions[group.actions.length - 1];
|
||||
|
||||
if (!currentAction) return getRandomLoader();
|
||||
|
||||
// 找到当前action之前最近的一个不同类型的action
|
||||
// 用于判断是否发生了类型切换(thinking <-> tool)
|
||||
const currentIndex = group.actions.indexOf(currentAction);
|
||||
let lastDifferentTypeIndex = -1;
|
||||
|
||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||
if (group.actions[i].type !== currentAction.type) {
|
||||
lastDifferentTypeIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 使用"类型切换点"作为key
|
||||
// 如果是第一个action,或者类型发生了切换,就会生成新的key
|
||||
// 如果是相同类型的连续action(如多个工具并行),使用相同的key
|
||||
const typeSegmentKey = lastDifferentTypeIndex >= 0
|
||||
? `${group.actions[lastDifferentTypeIndex].id}-${currentAction.type}`
|
||||
: `start-${currentAction.type}`;
|
||||
|
||||
const cacheKey = `${groupId}-${typeSegmentKey}`;
|
||||
|
||||
if (!summaryLoaders.has(cacheKey)) {
|
||||
summaryLoaders.set(cacheKey, getRandomLoader());
|
||||
}
|
||||
return summaryLoaders.get(cacheKey);
|
||||
};
|
||||
|
||||
// 将 actions 分组:连续的 thinking/tool 为一组,text 单独为一组
|
||||
const blockGroups = computed(() => {
|
||||
@ -141,11 +180,14 @@ const blockGroups = computed(() => {
|
||||
} else if (action.type === 'text') {
|
||||
// 先保存当前的 thinking/tool 组
|
||||
if (currentGroup.length > 0) {
|
||||
// 使用第一个 action 的 id 作为 group id,保证稳定性
|
||||
const firstActionId = currentGroup[0].id || currentGroup[0].blockId || `summary-${groupIndex}`;
|
||||
groups.push({
|
||||
type: 'summary',
|
||||
id: `summary-${groupIndex++}`,
|
||||
id: `summary-${firstActionId}`,
|
||||
actions: currentGroup
|
||||
});
|
||||
groupIndex++;
|
||||
currentGroup = [];
|
||||
}
|
||||
// 添加 text 组
|
||||
@ -160,9 +202,11 @@ const blockGroups = computed(() => {
|
||||
|
||||
// 处理最后剩余的 thinking/tool 组
|
||||
if (currentGroup.length > 0) {
|
||||
// 使用第一个 action 的 id 作为 group id,保证稳定性
|
||||
const firstActionId = currentGroup[0].id || currentGroup[0].blockId || `summary-${groupIndex}`;
|
||||
groups.push({
|
||||
type: 'summary',
|
||||
id: `summary-${groupIndex}`,
|
||||
id: `summary-${firstActionId}`,
|
||||
actions: currentGroup
|
||||
});
|
||||
}
|
||||
@ -170,20 +214,25 @@ const blockGroups = computed(() => {
|
||||
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 的,返回最后一个
|
||||
|
||||
let currentStep;
|
||||
if (streamingActions.length > 0) {
|
||||
// 如果有 streaming 的,返回最后一个 streaming 的
|
||||
currentStep = streamingActions[streamingActions.length - 1];
|
||||
} else {
|
||||
// 如果没有 streaming 的(全部完成了),优先返回最后一个工具
|
||||
const toolActions = actions.filter(a => a.type === 'tool');
|
||||
if (toolActions.length > 0) {
|
||||
currentStep = toolActions[toolActions.length - 1];
|
||||
} else {
|
||||
// 如果没有工具,返回最后一个思考
|
||||
currentStep = actions[actions.length - 1];
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentStep) return '';
|
||||
|
||||
@ -223,7 +272,7 @@ const getSummaryPreview = (actions: Action[]) => {
|
||||
return '';
|
||||
};
|
||||
|
||||
// 判断摘要组是否正在运行(包括已完成但后面还没有文本输出的情况)
|
||||
// 判断摘要组是否正在运行(显示加载动画还是对勾)
|
||||
const isSummaryRunning = (actions: Action[], groupId: string) => {
|
||||
// 找到当前 group 在 blockGroups 中的索引
|
||||
const currentIndex = blockGroups.value.findIndex(g => g.id === groupId);
|
||||
@ -232,11 +281,9 @@ const isSummaryRunning = (actions: Action[], groupId: string) => {
|
||||
// 检查后面是否有文本输出
|
||||
const hasTextAfter = blockGroups.value.slice(currentIndex + 1).some(g => g.type === 'text');
|
||||
|
||||
// 如果后面有文本输出,说明这个 summary group 已经完成了,不再显示变色效果
|
||||
if (hasTextAfter) return false;
|
||||
|
||||
// 否则,这个 summary group 应该有变色效果(无论是运行中还是已完成)
|
||||
return true;
|
||||
// 只有当后面有文本输出时,才显示对勾(说明这个摘要组已经完成并开始输出了)
|
||||
// 否则一直显示加载动画(即使工具执行完了,也要等下一个步骤或文本输出)
|
||||
return !hasTextAfter;
|
||||
};
|
||||
|
||||
// 获取摘要组的所有步骤(用于展开显示)
|
||||
@ -253,8 +300,45 @@ const getSummarySteps = (actions: Action[]) => {
|
||||
const toggleExpand = (groupId: string) => {
|
||||
if (expandedGroups.value.has(groupId)) {
|
||||
expandedGroups.value.delete(groupId);
|
||||
|
||||
// 折叠后,取消注册该组中的思考内容 ref
|
||||
nextTick(() => {
|
||||
const group = blockGroups.value.find(g => g.id === groupId);
|
||||
if (group && group.actions) {
|
||||
group.actions.forEach(action => {
|
||||
if (action.type === 'thinking') {
|
||||
const blockId = action.id || action.blockId;
|
||||
if (blockId && typeof props.registerThinkingRef === 'function') {
|
||||
props.registerThinkingRef(blockId, null);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
expandedGroups.value.add(groupId);
|
||||
|
||||
// 展开后,注册该组中的思考内容 ref
|
||||
nextTick(() => {
|
||||
const group = blockGroups.value.find(g => g.id === groupId);
|
||||
if (group && group.actions) {
|
||||
group.actions.forEach(action => {
|
||||
if (action.type === 'thinking') {
|
||||
const blockId = action.id || action.blockId;
|
||||
if (blockId) {
|
||||
const el = thinkingRefs.get(blockId);
|
||||
if (el && typeof props.registerThinkingRef === 'function') {
|
||||
props.registerThinkingRef(blockId, el);
|
||||
// 如果正在 streaming,滚动到底部
|
||||
if (action.streaming) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@ -338,8 +422,23 @@ const escapeHtml = (text: string) => {
|
||||
};
|
||||
|
||||
const registerThinking = (key: string, el: Element | null) => {
|
||||
if (typeof props.registerThinkingRef === 'function') {
|
||||
props.registerThinkingRef(key, el);
|
||||
// 本地保存一份,用于展开/折叠时管理
|
||||
if (el instanceof HTMLElement) {
|
||||
thinkingRefs.set(key, el);
|
||||
} else {
|
||||
thinkingRefs.delete(key);
|
||||
}
|
||||
|
||||
// 检查当前思考内容所在的摘要是否展开
|
||||
const group = blockGroups.value.find(g =>
|
||||
g.type === 'summary' && g.actions && g.actions.some(a => (a.id === key || a.blockId === key))
|
||||
);
|
||||
|
||||
// 只有展开的摘要中的思考内容才注册到父组件
|
||||
if (group && expandedGroups.value.has(group.id)) {
|
||||
if (typeof props.registerThinkingRef === 'function') {
|
||||
props.registerThinkingRef(key, el);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -359,33 +458,6 @@ watch(() => props.actions, () => {
|
||||
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;
|
||||
@ -421,15 +493,20 @@ watch(() => props.actions, () => {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
.summary-status-icon {
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
color: var(--claude-text-tertiary);
|
||||
transition: transform 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.expand-icon.expanded {
|
||||
transform: rotate(180deg);
|
||||
.check-icon {
|
||||
color: var(--claude-text-tertiary);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* 运行中的发光效果 */
|
||||
|
||||
57
static/src/components/chat/loaders/BouncingSquaresLoader.vue
Normal file
57
static/src/components/chat/loaders/BouncingSquaresLoader.vue
Normal file
@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div class="bouncing-squares-loader">
|
||||
<div class="square"></div>
|
||||
<div class="square"></div>
|
||||
<div class="square"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// From Uiverse.io by mobinkakei (modified)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bouncing-squares-loader {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.square {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
position: absolute;
|
||||
background-color: var(--claude-text-tertiary);
|
||||
left: 15%;
|
||||
transform-origin: 50%;
|
||||
animation: bounce-square .5s alternate infinite ease;
|
||||
}
|
||||
|
||||
@keyframes bounce-square {
|
||||
0% {
|
||||
top: 20px;
|
||||
height: 2px;
|
||||
transform: scaleX(1.7);
|
||||
}
|
||||
|
||||
40% {
|
||||
height: 3px;
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 0%;
|
||||
}
|
||||
}
|
||||
|
||||
.square:nth-child(2) {
|
||||
left: 45%;
|
||||
animation-delay: .2s;
|
||||
}
|
||||
|
||||
.square:nth-child(3) {
|
||||
left: auto;
|
||||
right: 15%;
|
||||
animation-delay: .3s;
|
||||
}
|
||||
</style>
|
||||
71
static/src/components/chat/loaders/DualChaseLoader.vue
Normal file
71
static/src/components/chat/loaders/DualChaseLoader.vue
Normal file
@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class="dual-chase-loader"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// From Uiverse.io by satyamchaudharydev (modified)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dual-chase-loader {
|
||||
--size: 6px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dual-chase-loader::after,
|
||||
.dual-chase-loader::before {
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
content: "";
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
top: 50%;
|
||||
animation: chase-up 2.4s cubic-bezier(0, 0, 0.24, 1.21) infinite;
|
||||
left: 50%;
|
||||
background: var(--claude-text-tertiary);
|
||||
}
|
||||
|
||||
.dual-chase-loader::after {
|
||||
top: calc(50% - var(--size));
|
||||
left: calc(50% - var(--size));
|
||||
animation: chase-down 2.4s cubic-bezier(0, 0, 0.24, 1.21) infinite;
|
||||
}
|
||||
|
||||
@keyframes chase-down {
|
||||
0%, 100% {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateX(100%) translateY(100%);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes chase-up {
|
||||
0%, 100% {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateX(-100%) translateY(-100%);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
107
static/src/components/chat/loaders/FiveSquaresLoader.vue
Normal file
107
static/src/components/chat/loaders/FiveSquaresLoader.vue
Normal file
@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="five-squares-loader">
|
||||
<div id="sq1"></div>
|
||||
<div id="sq2"></div>
|
||||
<div id="sq3"></div>
|
||||
<div id="sq4"></div>
|
||||
<div id="sq5"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// From Uiverse.io by Nawsome (modified)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.five-squares-loader {
|
||||
--square: 3px;
|
||||
--offset: 7.5px;
|
||||
width: calc(3 * var(--offset) + var(--square));
|
||||
height: calc(2 * var(--offset) + var(--square));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.five-squares-loader div {
|
||||
display: inline-block;
|
||||
background: var(--claude-text-tertiary);
|
||||
width: var(--square);
|
||||
height: var(--square);
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.five-squares-loader #sq1 {
|
||||
left: 0;
|
||||
top: 0;
|
||||
animation: square1 2.4s 0.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.five-squares-loader #sq2 {
|
||||
left: 0;
|
||||
top: var(--offset);
|
||||
animation: square2 2.4s 0.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.five-squares-loader #sq3 {
|
||||
left: var(--offset);
|
||||
top: var(--offset);
|
||||
animation: square3 2.4s 0.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.five-squares-loader #sq4 {
|
||||
left: calc(2 * var(--offset));
|
||||
top: var(--offset);
|
||||
animation: square4 2.4s 0.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.five-squares-loader #sq5 {
|
||||
left: calc(3 * var(--offset));
|
||||
top: var(--offset);
|
||||
animation: square5 2.4s 0.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes square1 {
|
||||
0% { left: 0; top: 0; }
|
||||
8.33% { left: 0; top: var(--offset); }
|
||||
100% { left: 0; top: var(--offset); }
|
||||
}
|
||||
|
||||
@keyframes square2 {
|
||||
0% { left: 0; top: var(--offset); }
|
||||
8.33% { left: 0; top: calc(2 * var(--offset)); }
|
||||
16.67% { left: var(--offset); top: calc(2 * var(--offset)); }
|
||||
25% { left: var(--offset); top: var(--offset); }
|
||||
83.33% { left: var(--offset); top: var(--offset); }
|
||||
91.67% { left: var(--offset); top: 0; }
|
||||
100% { left: 0; top: 0; }
|
||||
}
|
||||
|
||||
@keyframes square3 {
|
||||
0%, 100% { left: var(--offset); top: var(--offset); }
|
||||
16.67% { left: var(--offset); top: var(--offset); }
|
||||
25% { left: var(--offset); top: 0; }
|
||||
33.33% { left: calc(2 * var(--offset)); top: 0; }
|
||||
41.67% { left: calc(2 * var(--offset)); top: var(--offset); }
|
||||
66.67% { left: calc(2 * var(--offset)); top: var(--offset); }
|
||||
75% { left: calc(2 * var(--offset)); top: calc(2 * var(--offset)); }
|
||||
83.33% { left: var(--offset); top: calc(2 * var(--offset)); }
|
||||
91.67% { left: var(--offset); top: var(--offset); }
|
||||
}
|
||||
|
||||
@keyframes square4 {
|
||||
0% { left: calc(2 * var(--offset)); top: var(--offset); }
|
||||
33.33% { left: calc(2 * var(--offset)); top: var(--offset); }
|
||||
41.67% { left: calc(2 * var(--offset)); top: calc(2 * var(--offset)); }
|
||||
50% { left: calc(3 * var(--offset)); top: calc(2 * var(--offset)); }
|
||||
58.33% { left: calc(3 * var(--offset)); top: var(--offset); }
|
||||
100% { left: calc(3 * var(--offset)); top: var(--offset); }
|
||||
}
|
||||
|
||||
@keyframes square5 {
|
||||
0% { left: calc(3 * var(--offset)); top: var(--offset); }
|
||||
50% { left: calc(3 * var(--offset)); top: var(--offset); }
|
||||
58.33% { left: calc(3 * var(--offset)); top: 0; }
|
||||
66.67% { left: calc(2 * var(--offset)); top: 0; }
|
||||
75% { left: calc(2 * var(--offset)); top: var(--offset); }
|
||||
100% { left: calc(2 * var(--offset)); top: var(--offset); }
|
||||
}
|
||||
</style>
|
||||
197
static/src/components/chat/loaders/NineGridLoader.vue
Normal file
197
static/src/components/chat/loaders/NineGridLoader.vue
Normal file
@ -0,0 +1,197 @@
|
||||
<template>
|
||||
<div class="nine-grid-loader">
|
||||
<div class="box"></div>
|
||||
<div class="box"></div>
|
||||
<div class="box"></div>
|
||||
<div class="box"></div>
|
||||
<div class="box"></div>
|
||||
<div class="box"></div>
|
||||
<div class="box"></div>
|
||||
<div class="box"></div>
|
||||
<div class="box"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// From Uiverse.io by Nawsome (modified)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nine-grid-loader {
|
||||
position: relative;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.box {
|
||||
float: left;
|
||||
position: relative;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.box:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--claude-text-tertiary);
|
||||
}
|
||||
|
||||
.box:nth-child(3n) {
|
||||
margin-right: 0;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.box:nth-child(1):before,
|
||||
.box:nth-child(4):before {
|
||||
margin-left: 7px;
|
||||
}
|
||||
|
||||
.box:nth-child(3):before {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.box:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.box:nth-child(1) { animation: moveBox-1 4s infinite; }
|
||||
.box:nth-child(2) { animation: moveBox-2 4s infinite; }
|
||||
.box:nth-child(3) { animation: moveBox-3 4s infinite; }
|
||||
.box:nth-child(4) { animation: moveBox-4 4s infinite; }
|
||||
.box:nth-child(5) { animation: moveBox-5 4s infinite; }
|
||||
.box:nth-child(6) { animation: moveBox-6 4s infinite; }
|
||||
.box:nth-child(7) { animation: moveBox-7 4s infinite; }
|
||||
.box:nth-child(8) { animation: moveBox-8 4s infinite; }
|
||||
.box:nth-child(9) { animation: moveBox-9 4s infinite; }
|
||||
|
||||
@keyframes moveBox-1 {
|
||||
9% { transform: translate(-7px, 0); }
|
||||
18% { transform: translate(0, 0); }
|
||||
27% { transform: translate(0, 0); }
|
||||
36% { transform: translate(7px, 0); }
|
||||
45% { transform: translate(7px, 7px); }
|
||||
54% { transform: translate(7px, 7px); }
|
||||
63% { transform: translate(7px, 7px); }
|
||||
72% { transform: translate(7px, 0); }
|
||||
81% { transform: translate(0, 0); }
|
||||
90% { transform: translate(-7px, 0); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
@keyframes moveBox-2 {
|
||||
9% { transform: translate(0, 0); }
|
||||
18% { transform: translate(7px, 0); }
|
||||
27% { transform: translate(0, 0); }
|
||||
36% { transform: translate(7px, 0); }
|
||||
45% { transform: translate(7px, 7px); }
|
||||
54% { transform: translate(7px, 7px); }
|
||||
63% { transform: translate(7px, 7px); }
|
||||
72% { transform: translate(7px, 7px); }
|
||||
81% { transform: translate(0, 7px); }
|
||||
90% { transform: translate(0, 7px); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
@keyframes moveBox-3 {
|
||||
9% { transform: translate(-7px, 0); }
|
||||
18% { transform: translate(-7px, 0); }
|
||||
27% { transform: translate(0, 0); }
|
||||
36% { transform: translate(-7px, 0); }
|
||||
45% { transform: translate(-7px, 0); }
|
||||
54% { transform: translate(-7px, 0); }
|
||||
63% { transform: translate(-7px, 0); }
|
||||
72% { transform: translate(-7px, 0); }
|
||||
81% { transform: translate(-7px, -7px); }
|
||||
90% { transform: translate(0, -7px); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
@keyframes moveBox-4 {
|
||||
9% { transform: translate(-7px, 0); }
|
||||
18% { transform: translate(-7px, 0); }
|
||||
27% { transform: translate(-7px, -7px); }
|
||||
36% { transform: translate(0, -7px); }
|
||||
45% { transform: translate(0, 0); }
|
||||
54% { transform: translate(0, -7px); }
|
||||
63% { transform: translate(0, -7px); }
|
||||
72% { transform: translate(0, -7px); }
|
||||
81% { transform: translate(-7px, -7px); }
|
||||
90% { transform: translate(-7px, 0); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
@keyframes moveBox-5 {
|
||||
9% { transform: translate(0, 0); }
|
||||
18% { transform: translate(0, 0); }
|
||||
27% { transform: translate(0, 0); }
|
||||
36% { transform: translate(7px, 0); }
|
||||
45% { transform: translate(7px, 0); }
|
||||
54% { transform: translate(7px, 0); }
|
||||
63% { transform: translate(7px, 0); }
|
||||
72% { transform: translate(7px, 0); }
|
||||
81% { transform: translate(7px, -7px); }
|
||||
90% { transform: translate(0, -7px); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
@keyframes moveBox-6 {
|
||||
9% { transform: translate(0, 0); }
|
||||
18% { transform: translate(-7px, 0); }
|
||||
27% { transform: translate(-7px, 0); }
|
||||
36% { transform: translate(0, 0); }
|
||||
45% { transform: translate(0, 0); }
|
||||
54% { transform: translate(0, 0); }
|
||||
63% { transform: translate(0, 0); }
|
||||
72% { transform: translate(0, 7px); }
|
||||
81% { transform: translate(-7px, 7px); }
|
||||
90% { transform: translate(-7px, 0); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
@keyframes moveBox-7 {
|
||||
9% { transform: translate(7px, 0); }
|
||||
18% { transform: translate(7px, 0); }
|
||||
27% { transform: translate(7px, 0); }
|
||||
36% { transform: translate(0, 0); }
|
||||
45% { transform: translate(0, -7px); }
|
||||
54% { transform: translate(7px, -7px); }
|
||||
63% { transform: translate(0, -7px); }
|
||||
72% { transform: translate(0, -7px); }
|
||||
81% { transform: translate(0, 0); }
|
||||
90% { transform: translate(7px, 0); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
@keyframes moveBox-8 {
|
||||
9% { transform: translate(0, 0); }
|
||||
18% { transform: translate(-7px, 0); }
|
||||
27% { transform: translate(-7px, -7px); }
|
||||
36% { transform: translate(0, -7px); }
|
||||
45% { transform: translate(0, -7px); }
|
||||
54% { transform: translate(0, -7px); }
|
||||
63% { transform: translate(0, -7px); }
|
||||
72% { transform: translate(0, -7px); }
|
||||
81% { transform: translate(7px, -7px); }
|
||||
90% { transform: translate(7px, 0); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
@keyframes moveBox-9 {
|
||||
9% { transform: translate(-7px, 0); }
|
||||
18% { transform: translate(-7px, 0); }
|
||||
27% { transform: translate(0, 0); }
|
||||
36% { transform: translate(-7px, 0); }
|
||||
45% { transform: translate(0, 0); }
|
||||
54% { transform: translate(0, 0); }
|
||||
63% { transform: translate(-7px, 0); }
|
||||
72% { transform: translate(-7px, 0); }
|
||||
81% { transform: translate(-14px, 0); }
|
||||
90% { transform: translate(-7px, 0); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
</style>
|
||||
84
static/src/components/chat/loaders/RippleLoader.vue
Normal file
84
static/src/components/chat/loaders/RippleLoader.vue
Normal file
@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div class="ripple-loader">
|
||||
<div class="cell d-0"></div>
|
||||
<div class="cell d-1"></div>
|
||||
<div class="cell d-2"></div>
|
||||
<div class="cell d-1"></div>
|
||||
<div class="cell d-2"></div>
|
||||
<div class="cell d-2"></div>
|
||||
<div class="cell d-3"></div>
|
||||
<div class="cell d-3"></div>
|
||||
<div class="cell d-4"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// From Uiverse.io by alexruix (modified)
|
||||
</script>
|
||||
|
||||
<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 {
|
||||
flex: 0 0 var(--cell-size);
|
||||
margin: var(--cell-spacing);
|
||||
background-color: transparent;
|
||||
box-sizing: border-box;
|
||||
animation: 1.5s ripple ease infinite;
|
||||
}
|
||||
|
||||
.cell.d-1 {
|
||||
animation-delay: 100ms;
|
||||
}
|
||||
|
||||
.cell.d-2 {
|
||||
animation-delay: 200ms;
|
||||
}
|
||||
|
||||
.cell.d-3 {
|
||||
animation-delay: 300ms;
|
||||
}
|
||||
|
||||
.cell.d-4 {
|
||||
animation-delay: 400ms;
|
||||
}
|
||||
|
||||
.cell:nth-child(1),
|
||||
.cell:nth-child(2),
|
||||
.cell:nth-child(3),
|
||||
.cell:nth-child(4),
|
||||
.cell:nth-child(5),
|
||||
.cell:nth-child(6),
|
||||
.cell:nth-child(7),
|
||||
.cell:nth-child(8),
|
||||
.cell:nth-child(9) {
|
||||
--cell-color: var(--claude-text-tertiary);
|
||||
}
|
||||
|
||||
@keyframes ripple {
|
||||
0% {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
30% {
|
||||
background-color: var(--cell-color);
|
||||
}
|
||||
|
||||
60% {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
83
static/src/components/chat/loaders/RotatingSquaresLoader.vue
Normal file
83
static/src/components/chat/loaders/RotatingSquaresLoader.vue
Normal file
@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div class="rotating-squares-loader">
|
||||
<div class="square"></div>
|
||||
<div class="square"></div>
|
||||
<div class="square"></div>
|
||||
<div class="square"></div>
|
||||
<div class="square"></div>
|
||||
<div class="square"></div>
|
||||
<div class="square"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// From Uiverse.io by ZacharyCrespin (modified)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes square-animation {
|
||||
0% { left: 0; top: 0; }
|
||||
10.5% { left: 0; top: 0; }
|
||||
12.5% { left: 8px; top: 0; }
|
||||
23% { left: 8px; top: 0; }
|
||||
25% { left: 16px; top: 0; }
|
||||
35.5% { left: 16px; top: 0; }
|
||||
37.5% { left: 16px; top: 8px; }
|
||||
48% { left: 16px; top: 8px; }
|
||||
50% { left: 8px; top: 8px; }
|
||||
60.5% { left: 8px; top: 8px; }
|
||||
62.5% { left: 8px; top: 16px; }
|
||||
73% { left: 8px; top: 16px; }
|
||||
75% { left: 0; top: 16px; }
|
||||
85.5% { left: 0; top: 16px; }
|
||||
87.5% { left: 0; top: 8px; }
|
||||
98% { left: 0; top: 8px; }
|
||||
100% { left: 0; top: 0; }
|
||||
}
|
||||
|
||||
.rotating-squares-loader {
|
||||
position: relative;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.square {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
margin: 0.5px;
|
||||
background: var(--claude-text-tertiary);
|
||||
animation: square-animation 10s ease-in-out infinite both;
|
||||
}
|
||||
|
||||
.square:nth-of-type(1) {
|
||||
animation-delay: -1.43s;
|
||||
}
|
||||
|
||||
.square:nth-of-type(2) {
|
||||
animation-delay: -2.86s;
|
||||
}
|
||||
|
||||
.square:nth-of-type(3) {
|
||||
animation-delay: -4.29s;
|
||||
}
|
||||
|
||||
.square:nth-of-type(4) {
|
||||
animation-delay: -5.71s;
|
||||
}
|
||||
|
||||
.square:nth-of-type(5) {
|
||||
animation-delay: -7.14s;
|
||||
}
|
||||
|
||||
.square:nth-of-type(6) {
|
||||
animation-delay: -8.57s;
|
||||
}
|
||||
|
||||
.square:nth-of-type(7) {
|
||||
animation-delay: -10s;
|
||||
}
|
||||
</style>
|
||||
95
static/src/components/chat/loaders/SquareGridLoader.vue
Normal file
95
static/src/components/chat/loaders/SquareGridLoader.vue
Normal file
@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="square-grid-loader">
|
||||
<div class="square" id="sq1"></div>
|
||||
<div class="square" id="sq2"></div>
|
||||
<div class="square" id="sq3"></div>
|
||||
<div class="square" id="sq4"></div>
|
||||
<div class="square" id="sq5"></div>
|
||||
<div class="square" id="sq6"></div>
|
||||
<div class="square" id="sq7"></div>
|
||||
<div class="square" id="sq8"></div>
|
||||
<div class="square" id="sq9"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// From Uiverse.io by JkHuger
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes loader_5191 {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.square-grid-loader {
|
||||
position: relative;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.square {
|
||||
background: var(--claude-text-tertiary);
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin-top: -1.5px;
|
||||
margin-left: -1.5px;
|
||||
border-radius: 0.5px;
|
||||
}
|
||||
|
||||
#sq1 {
|
||||
margin-top: -7.5px;
|
||||
margin-left: -7.5px;
|
||||
animation: loader_5191 675ms ease-in-out 0s infinite alternate;
|
||||
}
|
||||
|
||||
#sq2 {
|
||||
margin-top: -7.5px;
|
||||
animation: loader_5191 675ms ease-in-out 75ms infinite alternate;
|
||||
}
|
||||
|
||||
#sq3 {
|
||||
margin-top: -7.5px;
|
||||
margin-left: 4.5px;
|
||||
animation: loader_5191 675ms ease-in-out 150ms infinite;
|
||||
}
|
||||
|
||||
#sq4 {
|
||||
margin-left: -7.5px;
|
||||
animation: loader_5191 675ms ease-in-out 225ms infinite;
|
||||
}
|
||||
|
||||
#sq5 {
|
||||
animation: loader_5191 675ms ease-in-out 300ms infinite;
|
||||
}
|
||||
|
||||
#sq6 {
|
||||
margin-left: 4.5px;
|
||||
animation: loader_5191 675ms ease-in-out 375ms infinite;
|
||||
}
|
||||
|
||||
#sq7 {
|
||||
margin-top: 4.5px;
|
||||
margin-left: -7.5px;
|
||||
animation: loader_5191 675ms ease-in-out 450ms infinite;
|
||||
}
|
||||
|
||||
#sq8 {
|
||||
margin-top: 4.5px;
|
||||
animation: loader_5191 675ms ease-in-out 525ms infinite;
|
||||
}
|
||||
|
||||
#sq9 {
|
||||
margin-top: 4.5px;
|
||||
margin-left: 4.5px;
|
||||
animation: loader_5191 675ms ease-in-out 600ms infinite;
|
||||
}
|
||||
</style>
|
||||
25
static/src/components/chat/loaders/index.ts
Normal file
25
static/src/components/chat/loaders/index.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { Component } from 'vue';
|
||||
import SquareGridLoader from './SquareGridLoader.vue';
|
||||
import RotatingSquaresLoader from './RotatingSquaresLoader.vue';
|
||||
import DualChaseLoader from './DualChaseLoader.vue';
|
||||
import BouncingSquaresLoader from './BouncingSquaresLoader.vue';
|
||||
import FiveSquaresLoader from './FiveSquaresLoader.vue';
|
||||
import NineGridLoader from './NineGridLoader.vue';
|
||||
import RippleLoader from './RippleLoader.vue';
|
||||
|
||||
// 加载动画池
|
||||
export const loaderPool: Component[] = [
|
||||
SquareGridLoader,
|
||||
RotatingSquaresLoader,
|
||||
DualChaseLoader,
|
||||
BouncingSquaresLoader,
|
||||
FiveSquaresLoader,
|
||||
NineGridLoader,
|
||||
RippleLoader
|
||||
];
|
||||
|
||||
// 随机获取一个加载动画
|
||||
export function getRandomLoader(): Component {
|
||||
const randomIndex = Math.floor(Math.random() * loaderPool.length);
|
||||
return loaderPool[randomIndex];
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user