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;
}