diff --git a/static/src/App.vue b/static/src/App.vue index 3e2283b..44c5b0b 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -88,7 +88,8 @@ :class="{ 'chat-container--immersive': workspaceCollapsed, 'chat-container--mobile': isMobileViewport, - 'chat-container--monitor': chatDisplayMode === 'monitor' + 'chat-container--monitor': chatDisplayMode === 'monitor', + 'has-title-ribbon': titleRibbonVisible }" > +
+ {{ titleTypingText || currentConversationTitle }} +
+ {}); @@ -466,18 +479,31 @@ const appOptions = { }, watch: { - inputMessage() { - this.autoResizeInput(); - }, - messages: { - deep: true, - handler() { - this.refreshBlankHeroState(); - } - }, - currentConversationId: { - immediate: false, - handler(newValue, oldValue) { + inputMessage() { + this.autoResizeInput(); + }, + messages: { + deep: true, + handler() { + 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: { + immediate: false, + handler(newValue, oldValue) { debugLog('currentConversationId 变化', { oldValue, newValue, skipConversationHistoryReload: this.skipConversationHistoryReload }); traceLog('watch:currentConversationId', { oldValue, @@ -971,10 +997,18 @@ const appOptions = { }, async bootstrapRoute() { + // 在路由解析期间抑制标题动画,避免预置“新对话”闪烁 + this.suppressTitleTyping = true; + this.titleReady = false; + this.currentConversationTitle = ''; + this.titleTypingText = ''; const path = window.location.pathname.replace(/^\/+/, ''); if (!path || path === 'new') { this.currentConversationId = null; this.currentConversationTitle = '新对话'; + this.titleReady = true; + this.suppressTitleTyping = false; + this.startTitleTyping('新对话', { animate: false }); this.initialRouteResolved = true; return; } @@ -985,18 +1019,27 @@ const appOptions = { const result = await resp.json(); if (result.success) { 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)}`); } else { history.replaceState({}, '', '/new'); this.currentConversationId = null; this.currentConversationTitle = '新对话'; + this.titleReady = true; + this.suppressTitleTyping = false; + this.startTitleTyping('新对话', { animate: false }); } } catch (error) { console.warn('初始化路由失败:', error); history.replaceState({}, '', '/new'); this.currentConversationId = null; this.currentConversationTitle = '新对话'; + this.titleReady = true; + this.suppressTitleTyping = false; + this.startTitleTyping('新对话', { animate: false }); } finally { this.initialRouteResolved = true; } @@ -1313,6 +1356,10 @@ const appOptions = { this.skipConversationHistoryReload = true; // 首次从状态恢复对话时,避免 socket 的 conversation_loaded 再次触发历史加载 this.skipConversationLoadedEvent = true; + this.suppressTitleTyping = true; + this.titleReady = false; + this.currentConversationTitle = ''; + this.titleTypingText = ''; this.currentConversationId = statusConversationId; // 如果有当前对话,尝试获取标题和历史 @@ -1321,6 +1368,15 @@ const appOptions = { const convData = await convResponse.json(); if (convData.success && convData.data) { 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 触发 if ( @@ -1335,6 +1391,9 @@ const appOptions = { this.updateCurrentContextTokens(); } catch (e) { console.warn('获取当前对话标题失败:', e); + this.titleReady = true; + this.suppressTitleTyping = false; + this.startTitleTyping(this.currentConversationTitle || '新对话', ''); } } @@ -1482,10 +1541,16 @@ const appOptions = { debugLog('加载对话:', conversationId); traceLog('loadConversation:start', { conversationId, currentConversationId: this.currentConversationId, force }); this.logMessageState('loadConversation:start', { conversationId, force }); + this.suppressTitleTyping = true; + this.titleReady = false; + this.currentConversationTitle = ''; + this.titleTypingText = ''; if (!force && conversationId === this.currentConversationId) { debugLog('已是当前对话,跳过加载'); traceLog('loadConversation:skip-same', { conversationId }); + this.suppressTitleTyping = false; + this.titleReady = true; return; } @@ -1504,6 +1569,9 @@ const appOptions = { this.skipConversationHistoryReload = true; this.currentConversationId = conversationId; this.currentConversationTitle = result.title; + this.titleReady = true; + this.suppressTitleTyping = false; + this.startTitleTyping(this.currentConversationTitle, { animate: false }); this.promoteConversationToTop(conversationId); history.pushState({ conversationId }, '', `/${this.stripConversationPrefix(conversationId)}`); this.skipConversationLoadedEvent = true; @@ -1524,6 +1592,8 @@ const appOptions = { } else { console.error('对话加载失败:', result.message); + this.suppressTitleTyping = false; + this.titleReady = true; this.uiPushToast({ title: '加载对话失败', message: result.message || '服务器未返回成功状态', @@ -1533,6 +1603,8 @@ const appOptions = { } catch (error) { console.error('加载对话异常:', error); traceLog('loadConversation:error', { conversationId, error: error?.message || String(error) }); + this.suppressTitleTyping = false; + this.titleReady = true; this.uiPushToast({ title: '加载对话异常', message: error.message || String(error), @@ -2723,6 +2795,47 @@ const appOptions = { this.$forceUpdate(); 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) { if (!blockId) { return; diff --git a/static/src/components/chat/ChatArea.vue b/static/src/components/chat/ChatArea.vue index 82fb6c4..af9dd1f 100644 --- a/static/src/components/chat/ChatArea.vue +++ b/static/src/components/chat/ChatArea.vue @@ -176,11 +176,13 @@ :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}`)" - /> + :format-search-topic="formatSearchTopic" + :format-search-time="formatSearchTime" + :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}`)" + /> @@ -311,6 +313,8 @@ :format-search-topic="formatSearchTopic" :format-search-time="formatSearchTime" :streaming-message="streamingMessage" + :register-collapse-content="registerCollapseContent" + :collapse-key="action.blockId || `${index}-tool-${actionIndex}`" @toggle="toggleBlock(action.blockId || `${index}-tool-${actionIndex}`)" /> diff --git a/static/src/stores/conversation.ts b/static/src/stores/conversation.ts index 117dfd3..455f451 100644 --- a/static/src/stores/conversation.ts +++ b/static/src/stores/conversation.ts @@ -28,7 +28,7 @@ export const useConversationStore = defineStore('conversation', { hasMoreConversations: false, loadingMoreConversations: false, currentConversationId: null, - currentConversationTitle: '当前对话', + currentConversationTitle: '', searchQuery: '', searchTimer: null, conversationsOffset: 0, diff --git a/static/src/styles/components/chat/_chat-area.scss b/static/src/styles/components/chat/_chat-area.scss index 4d1ed23..c1b4cba 100644 --- a/static/src/styles/components/chat/_chat-area.scss +++ b/static/src/styles/components/chat/_chat-area.scss @@ -1,9 +1,10 @@ /* 聊天容器整体布局,保证聊天区可见并支持上下滚动 */ .chat-container { + --chat-surface-color: var(--theme-surface-strong, #ffffff); flex: 1; display: flex; flex-direction: column; - background: rgba(255, 255, 255, 0.78); + background: var(--chat-surface-color); min-width: 0; height: var(--app-viewport, 100vh); overflow: hidden; @@ -11,6 +12,53 @@ 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 { flex: 1; }