fix: refine title ribbon visuals and typing

This commit is contained in:
JOJO 2026-01-02 14:26:59 +08:00
parent d34fbe963a
commit e903c99ca4
5 changed files with 191 additions and 21 deletions

View File

@ -88,7 +88,8 @@
:class="{ :class="{
'chat-container--immersive': workspaceCollapsed, 'chat-container--immersive': workspaceCollapsed,
'chat-container--mobile': isMobileViewport, 'chat-container--mobile': isMobileViewport,
'chat-container--monitor': chatDisplayMode === 'monitor' 'chat-container--monitor': chatDisplayMode === 'monitor',
'has-title-ribbon': titleRibbonVisible
}" }"
> >
<TokenDrawer <TokenDrawer
@ -108,6 +109,10 @@
:format-rate="formatRate" :format-rate="formatRate"
/> />
<div v-if="titleRibbonVisible" class="conversation-ribbon">
<span class="conversation-ribbon__text">{{ titleTypingText || currentConversationTitle }}</span>
</div>
<ChatArea <ChatArea
v-show="chatDisplayMode === 'chat'" v-show="chatDisplayMode === 'chat'"
ref="messagesArea" ref="messagesArea"

View File

@ -257,6 +257,12 @@ const appOptions = {
blankHeroExiting: false, blankHeroExiting: false,
blankWelcomeText: '', blankWelcomeText: '',
lastBlankConversationId: null, lastBlankConversationId: null,
// 对话标题打字效果
titleTypingText: '',
titleTypingTarget: '',
titleTypingTimer: null,
titleReady: false,
suppressTitleTyping: false,
blankWelcomePool: [ blankWelcomePool: [
'有什么可以帮忙的?', '有什么可以帮忙的?',
'想了解些热点吗?', '想了解些热点吗?',
@ -405,6 +411,9 @@ const appOptions = {
} }
return this.thinkingMode ? 'thinking' : 'fast'; return this.thinkingMode ? 'thinking' : 'fast';
}, },
titleRibbonVisible() {
return !this.isMobileViewport && this.chatDisplayMode === 'chat';
},
...mapWritableState(useToolStore, [ ...mapWritableState(useToolStore, [
'preparingTools', 'preparingTools',
'activeTools', 'activeTools',
@ -459,6 +468,10 @@ const appOptions = {
this.resourceStopProjectStoragePolling(); this.resourceStopProjectStoragePolling();
this.resourceStopUsageQuotaPolling(); this.resourceStopUsageQuotaPolling();
teardownShowImageObserver(); teardownShowImageObserver();
if (this.titleTypingTimer) {
clearInterval(this.titleTypingTimer);
this.titleTypingTimer = null;
}
const cleanup = this.destroyEasterEggEffect(true); const cleanup = this.destroyEasterEggEffect(true);
if (cleanup && typeof cleanup.catch === 'function') { if (cleanup && typeof cleanup.catch === 'function') {
cleanup.catch(() => {}); cleanup.catch(() => {});
@ -475,6 +488,19 @@ const appOptions = {
this.refreshBlankHeroState(); this.refreshBlankHeroState();
} }
}, },
currentConversationTitle(newVal, oldVal) {
const target = (newVal && newVal.trim()) || '';
if (this.suppressTitleTyping) {
this.titleTypingText = target;
this.titleTypingTarget = target;
return;
}
const previous = (oldVal && oldVal.trim()) || (this.titleTypingText && this.titleTypingText.trim()) || '';
const placeholderPrev = !previous || previous === '新对话';
const placeholderTarget = !target || target === '新对话';
const animate = placeholderPrev && !placeholderTarget; // 仅从空/占位切换到真实标题时动画
this.startTitleTyping(target, { animate });
},
currentConversationId: { currentConversationId: {
immediate: false, immediate: false,
handler(newValue, oldValue) { handler(newValue, oldValue) {
@ -971,10 +997,18 @@ const appOptions = {
}, },
async bootstrapRoute() { async bootstrapRoute() {
// 在路由解析期间抑制标题动画,避免预置“新对话”闪烁
this.suppressTitleTyping = true;
this.titleReady = false;
this.currentConversationTitle = '';
this.titleTypingText = '';
const path = window.location.pathname.replace(/^\/+/, ''); const path = window.location.pathname.replace(/^\/+/, '');
if (!path || path === 'new') { if (!path || path === 'new') {
this.currentConversationId = null; this.currentConversationId = null;
this.currentConversationTitle = '新对话'; this.currentConversationTitle = '新对话';
this.titleReady = true;
this.suppressTitleTyping = false;
this.startTitleTyping('新对话', { animate: false });
this.initialRouteResolved = true; this.initialRouteResolved = true;
return; return;
} }
@ -985,18 +1019,27 @@ const appOptions = {
const result = await resp.json(); const result = await resp.json();
if (result.success) { if (result.success) {
this.currentConversationId = convId; this.currentConversationId = convId;
this.currentConversationTitle = result.title || '对话'; this.currentConversationTitle = result.title || '';
this.titleReady = true;
this.suppressTitleTyping = false;
this.startTitleTyping(this.currentConversationTitle, { animate: false });
history.replaceState({ conversationId: convId }, '', `/${this.stripConversationPrefix(convId)}`); history.replaceState({ conversationId: convId }, '', `/${this.stripConversationPrefix(convId)}`);
} else { } else {
history.replaceState({}, '', '/new'); history.replaceState({}, '', '/new');
this.currentConversationId = null; this.currentConversationId = null;
this.currentConversationTitle = '新对话'; this.currentConversationTitle = '新对话';
this.titleReady = true;
this.suppressTitleTyping = false;
this.startTitleTyping('新对话', { animate: false });
} }
} catch (error) { } catch (error) {
console.warn('初始化路由失败:', error); console.warn('初始化路由失败:', error);
history.replaceState({}, '', '/new'); history.replaceState({}, '', '/new');
this.currentConversationId = null; this.currentConversationId = null;
this.currentConversationTitle = '新对话'; this.currentConversationTitle = '新对话';
this.titleReady = true;
this.suppressTitleTyping = false;
this.startTitleTyping('新对话', { animate: false });
} finally { } finally {
this.initialRouteResolved = true; this.initialRouteResolved = true;
} }
@ -1313,6 +1356,10 @@ const appOptions = {
this.skipConversationHistoryReload = true; this.skipConversationHistoryReload = true;
// 首次从状态恢复对话时,避免 socket 的 conversation_loaded 再次触发历史加载 // 首次从状态恢复对话时,避免 socket 的 conversation_loaded 再次触发历史加载
this.skipConversationLoadedEvent = true; this.skipConversationLoadedEvent = true;
this.suppressTitleTyping = true;
this.titleReady = false;
this.currentConversationTitle = '';
this.titleTypingText = '';
this.currentConversationId = statusConversationId; this.currentConversationId = statusConversationId;
// 如果有当前对话,尝试获取标题和历史 // 如果有当前对话,尝试获取标题和历史
@ -1321,6 +1368,15 @@ const appOptions = {
const convData = await convResponse.json(); const convData = await convResponse.json();
if (convData.success && convData.data) { if (convData.success && convData.data) {
this.currentConversationTitle = convData.data.title; this.currentConversationTitle = convData.data.title;
this.titleReady = true;
this.suppressTitleTyping = false;
this.startTitleTyping(this.currentConversationTitle, { animate: false });
} else {
this.titleReady = true;
this.suppressTitleTyping = false;
const fallbackTitle = this.currentConversationTitle || '新对话';
this.currentConversationTitle = fallbackTitle;
this.startTitleTyping(fallbackTitle, { animate: false });
} }
// 初始化时调用一次,因为 skipConversationHistoryReload 会阻止 watch 触发 // 初始化时调用一次,因为 skipConversationHistoryReload 会阻止 watch 触发
if ( if (
@ -1335,6 +1391,9 @@ const appOptions = {
this.updateCurrentContextTokens(); this.updateCurrentContextTokens();
} catch (e) { } catch (e) {
console.warn('获取当前对话标题失败:', e); console.warn('获取当前对话标题失败:', e);
this.titleReady = true;
this.suppressTitleTyping = false;
this.startTitleTyping(this.currentConversationTitle || '新对话', '');
} }
} }
@ -1482,10 +1541,16 @@ const appOptions = {
debugLog('加载对话:', conversationId); debugLog('加载对话:', conversationId);
traceLog('loadConversation:start', { conversationId, currentConversationId: this.currentConversationId, force }); traceLog('loadConversation:start', { conversationId, currentConversationId: this.currentConversationId, force });
this.logMessageState('loadConversation:start', { conversationId, force }); this.logMessageState('loadConversation:start', { conversationId, force });
this.suppressTitleTyping = true;
this.titleReady = false;
this.currentConversationTitle = '';
this.titleTypingText = '';
if (!force && conversationId === this.currentConversationId) { if (!force && conversationId === this.currentConversationId) {
debugLog('已是当前对话,跳过加载'); debugLog('已是当前对话,跳过加载');
traceLog('loadConversation:skip-same', { conversationId }); traceLog('loadConversation:skip-same', { conversationId });
this.suppressTitleTyping = false;
this.titleReady = true;
return; return;
} }
@ -1504,6 +1569,9 @@ const appOptions = {
this.skipConversationHistoryReload = true; this.skipConversationHistoryReload = true;
this.currentConversationId = conversationId; this.currentConversationId = conversationId;
this.currentConversationTitle = result.title; this.currentConversationTitle = result.title;
this.titleReady = true;
this.suppressTitleTyping = false;
this.startTitleTyping(this.currentConversationTitle, { animate: false });
this.promoteConversationToTop(conversationId); this.promoteConversationToTop(conversationId);
history.pushState({ conversationId }, '', `/${this.stripConversationPrefix(conversationId)}`); history.pushState({ conversationId }, '', `/${this.stripConversationPrefix(conversationId)}`);
this.skipConversationLoadedEvent = true; this.skipConversationLoadedEvent = true;
@ -1524,6 +1592,8 @@ const appOptions = {
} else { } else {
console.error('对话加载失败:', result.message); console.error('对话加载失败:', result.message);
this.suppressTitleTyping = false;
this.titleReady = true;
this.uiPushToast({ this.uiPushToast({
title: '加载对话失败', title: '加载对话失败',
message: result.message || '服务器未返回成功状态', message: result.message || '服务器未返回成功状态',
@ -1533,6 +1603,8 @@ const appOptions = {
} catch (error) { } catch (error) {
console.error('加载对话异常:', error); console.error('加载对话异常:', error);
traceLog('loadConversation:error', { conversationId, error: error?.message || String(error) }); traceLog('loadConversation:error', { conversationId, error: error?.message || String(error) });
this.suppressTitleTyping = false;
this.titleReady = true;
this.uiPushToast({ this.uiPushToast({
title: '加载对话异常', title: '加载对话异常',
message: error.message || String(error), message: error.message || String(error),
@ -2723,6 +2795,47 @@ const appOptions = {
this.$forceUpdate(); this.$forceUpdate();
this.conditionalScrollToBottom(); this.conditionalScrollToBottom();
}, },
startTitleTyping(title: string, options: { animate?: boolean } = {}) {
if (this.titleTypingTimer) {
clearInterval(this.titleTypingTimer);
this.titleTypingTimer = null;
}
const target = (title || '').trim();
const animate = options.animate ?? true;
if (!animate) {
this.titleTypingTarget = target;
this.titleTypingText = target;
return;
}
const previous = (this.titleTypingText || '').trim();
if (previous === target) {
this.titleTypingTarget = target;
this.titleTypingText = target;
return;
}
this.titleTypingTarget = target;
this.titleTypingText = previous;
const frames: string[] = [];
for (let i = previous.length; i >= 0; i--) {
frames.push(previous.slice(0, i));
}
for (let j = 1; j <= target.length; j++) {
frames.push(target.slice(0, j));
}
let index = 0;
this.titleTypingTimer = window.setInterval(() => {
if (index >= frames.length) {
clearInterval(this.titleTypingTimer!);
this.titleTypingTimer = null;
this.titleTypingText = target;
return;
}
this.titleTypingText = frames[index];
index += 1;
}, 32);
},
toggleBlock(blockId) { toggleBlock(blockId) {
if (!blockId) { if (!blockId) {
return; return;

View File

@ -179,6 +179,8 @@
:format-search-topic="formatSearchTopic" :format-search-topic="formatSearchTopic"
:format-search-time="formatSearchTime" :format-search-time="formatSearchTime"
:streaming-message="streamingMessage" :streaming-message="streamingMessage"
:register-collapse-content="registerCollapseContent"
:collapse-key="group.action.blockId || `${index}-tool-${group.actionIndex}`"
@toggle="toggleBlock(group.action.blockId || `${index}-tool-${group.actionIndex}`)" @toggle="toggleBlock(group.action.blockId || `${index}-tool-${group.actionIndex}`)"
/> />
</div> </div>
@ -311,6 +313,8 @@
:format-search-topic="formatSearchTopic" :format-search-topic="formatSearchTopic"
:format-search-time="formatSearchTime" :format-search-time="formatSearchTime"
:streaming-message="streamingMessage" :streaming-message="streamingMessage"
:register-collapse-content="registerCollapseContent"
:collapse-key="action.blockId || `${index}-tool-${actionIndex}`"
@toggle="toggleBlock(action.blockId || `${index}-tool-${actionIndex}`)" @toggle="toggleBlock(action.blockId || `${index}-tool-${actionIndex}`)"
/> />
</div> </div>

View File

@ -28,7 +28,7 @@ export const useConversationStore = defineStore('conversation', {
hasMoreConversations: false, hasMoreConversations: false,
loadingMoreConversations: false, loadingMoreConversations: false,
currentConversationId: null, currentConversationId: null,
currentConversationTitle: '当前对话', currentConversationTitle: '',
searchQuery: '', searchQuery: '',
searchTimer: null, searchTimer: null,
conversationsOffset: 0, conversationsOffset: 0,

View File

@ -1,9 +1,10 @@
/* 聊天容器整体布局,保证聊天区可见并支持上下滚动 */ /* 聊天容器整体布局,保证聊天区可见并支持上下滚动 */
.chat-container { .chat-container {
--chat-surface-color: var(--theme-surface-strong, #ffffff);
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: rgba(255, 255, 255, 0.78); background: var(--chat-surface-color);
min-width: 0; min-width: 0;
height: var(--app-viewport, 100vh); height: var(--app-viewport, 100vh);
overflow: hidden; overflow: hidden;
@ -11,6 +12,53 @@
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
} }
.conversation-ribbon {
position: absolute;
inset: 0 0 auto 0;
height: 38px;
padding: 8px 16px;
display: flex;
align-items: center;
justify-content: flex-end;
color: #0f172a;
background: var(--chat-surface-color); /* 与对话区域保持一致且不透明 */
box-shadow: none;
pointer-events: none;
user-select: none;
backdrop-filter: none;
z-index: 40;
}
.conversation-ribbon::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: -8px;
height: 16px;
background: var(--chat-surface-color);
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.6) 55%, rgba(0, 0, 0, 0) 100%);
-webkit-mask-image: linear-gradient(180deg, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.6) 55%, rgba(0, 0, 0, 0) 100%);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
pointer-events: none;
}
.conversation-ribbon__text {
font-weight: 700;
font-size: 15px;
line-height: 1.4;
letter-spacing: 0.02em;
max-width: 60%;
color: #0f172a;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.12);
white-space: nowrap;
}
.chat-container.has-title-ribbon .messages-area {
padding-top: 42px;
}
.chat-container--monitor .virtual-monitor-surface { .chat-container--monitor .virtual-monitor-surface {
flex: 1; flex: 1;
} }