From 2b2ff44fcd1adbc62f4657e51c2aa400dd334036 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sun, 28 Jun 2026 01:48:18 +0800 Subject: [PATCH] Fix: user message timestamp from backend, bubble action layout --- server/chat_flow_task_main.py | 4 + static/src/app/methods/history.ts | 8 +- .../src/app/methods/taskPolling/messaging.ts | 8 ++ static/src/components/chat/ChatArea.vue | 76 +++++++++++++++++-- static/src/composables/useLegacySocket.ts | 4 + static/src/stores/chat.ts | 3 +- static/src/stores/model.ts | 16 ++++ .../styles/components/chat/_chat-area.scss | 61 ++++++++++++--- 8 files changed, 161 insertions(+), 19 deletions(-) diff --git a/server/chat_flow_task_main.py b/server/chat_flow_task_main.py index eb7372d..448ef3c 100644 --- a/server/chat_flow_task_main.py +++ b/server/chat_flow_task_main.py @@ -464,6 +464,7 @@ async def _dispatch_completion_user_notice( session_data["auto_user_message_payload"] = { **dict(extra_payload or {}), **ui_defaults, + "timestamp": datetime.now().isoformat(), } # 轮询客户端通过后续任务事件流回放前置通知(在线客户端已由上面的 socketio 回显覆盖)。 if preceding_notices: @@ -491,6 +492,7 @@ async def _dispatch_completion_user_notice( 'conversation_id': conversation_id, 'task_id': rec.task_id, 'message_source': message_source, + 'timestamp': datetime.now().isoformat(), **ui_defaults, } payload.update(extra_payload) @@ -508,6 +510,7 @@ async def _dispatch_completion_user_notice( 'message': user_message, 'conversation_id': conversation_id, 'message_source': message_source, + 'timestamp': datetime.now().isoformat(), } payload.update(_user_message_ui_defaults(message_source, auto_user_message_event=True)) payload.update(extra_payload) @@ -976,6 +979,7 @@ async def handle_task_with_sender( "starts_work": user_message_metadata.get("starts_work"), "metadata": user_message_metadata, "conversation_id": conversation_id, + "timestamp": (saved_user_message or {}).get("timestamp") or user_work_started_at, }, ) except Exception as exc: diff --git a/static/src/app/methods/history.ts b/static/src/app/methods/history.ts index 2886daa..c9b0dc2 100644 --- a/static/src/app/methods/history.ts +++ b/static/src/app/methods/history.ts @@ -278,13 +278,19 @@ export const historyMethods = { if (Array.isArray(videos) && videos.length) { historyHasVideos = true; } + const rawCreatedAt = message.created_at ?? message.createdAt ?? message.timestamp ?? null; + const normalizedCreatedAt = + typeof rawCreatedAt === 'number' && rawCreatedAt > 0 + ? new Date(rawCreatedAt * 1000).toISOString() + : rawCreatedAt || null; this.messages.push({ role: 'user', content: message.content || '', images, videos, media_refs: Array.isArray(mediaRefs) ? mediaRefs : [], - metadata: message.metadata || {} + metadata: message.metadata || {}, + created_at: normalizedCreatedAt }); debugLog('添加用户消息:', message.content?.substring(0, 50) + '...'); } else if (message.role === 'assistant') { diff --git a/static/src/app/methods/taskPolling/messaging.ts b/static/src/app/methods/taskPolling/messaging.ts index c2a18ba..68763c3 100644 --- a/static/src/app/methods/taskPolling/messaging.ts +++ b/static/src/app/methods/taskPolling/messaging.ts @@ -75,6 +75,10 @@ export const messagingMethods = { ...eventMetadata, media_refs: incomingMediaRefs }; + const backendTimestamp = data?.timestamp ?? data?.created_at ?? data?.createdAt ?? null; + if (backendTimestamp) { + target.created_at = backendTimestamp; + } } else { this.chatAddUserMessage( message, @@ -122,6 +126,10 @@ export const messagingMethods = { media_refs: incomingMediaRefs, message_source: source }; + const backendTimestamp = data?.timestamp ?? data?.created_at ?? data?.createdAt ?? null; + if (backendTimestamp) { + recentMatch.created_at = backendTimestamp; + } } else { this.chatAddUserMessage( message, diff --git a/static/src/components/chat/ChatArea.vue b/static/src/components/chat/ChatArea.vue index 430f3eb..b1f8d11 100644 --- a/static/src/components/chat/ChatArea.vue +++ b/static/src/components/chat/ChatArea.vue @@ -60,6 +60,18 @@ /> +
+ +
- + {{ formatUserMessageTime(msg) }}
@@ -724,6 +729,35 @@ const registerUserBubbleRef = (msg: any, index: number, el: Element | null) => { } }; +const estimateUserBubbleNeedsFold = (msg: any): boolean => { + const content = typeof msg?.content === 'string' ? msg.content : ''; + if (!content) return false; + // 按常见桌面端气泡宽度和字号估算:每行约 48 个字符。 + // 预留余量,避免短气泡被误判为需要折叠而出现“先折叠后展开”的跳变。 + const APPROX_CHARS_PER_LINE = 48; + const foldChars = APPROX_CHARS_PER_LINE * (USER_BUBBLE_FOLD_LINES + 2); + const newlineCount = (content.match(/\n/g) || []).length; + return content.length > foldChars || newlineCount >= USER_BUBBLE_FOLD_LINES + 2; +}; + +const preMeasureUserBubbles = () => { + // 在 DOM 测量前,先根据文本内容同步估算是否需要折叠, + // 避免长气泡先完整渲染再被收起。 + // 只给尚未有状态记录的气泡做首次估算,避免切换对话时旧消息反复重算导致动画。 + filteredMessages.value.forEach((msg, index) => { + if (!msg || msg.role !== 'user') return; + const key = getUserBubbleKey(msg, index); + if (userBubbleFoldStates.value[key]) return; + const needsFold = estimateUserBubbleNeedsFold(msg); + userBubbleFoldStates.value[key] = { + needsFold, + expanded: false, + foldHeight: 0, + fullHeight: 0 + }; + }); +}; + const measureUserBubbles = () => { userBubbleRefs.forEach((el, key) => { if (!el.isConnected) { @@ -771,6 +805,30 @@ const toggleUserBubble = (msg: any, index: number) => { userBubbleFoldStates.value[key] = { ...state, expanded: !state.expanded }; }; +const formatUserMessageTime = (msg: any): string => { + const createdAt = msg?.created_at; + if (!createdAt) return ''; + const date = new Date(createdAt); + const now = new Date(); + const diffMs = Math.max(0, now.getTime() - date.getTime()); + const diffSec = Math.floor(diffMs / 1000); + const diffMin = Math.floor(diffSec / 60); + const diffHour = Math.floor(diffMin / 60); + const diffDay = Math.floor(diffHour / 24); + + if (diffDay >= 1) { + const month = date.getMonth() + 1; + const day = date.getDate(); + const hour = String(date.getHours()).padStart(2, '0'); + const minute = String(date.getMinutes()).padStart(2, '0'); + return `${month}月${day}日 ${hour}:${minute}`; + } + if (diffHour > 0) return `${diffHour}小时前`; + if (diffMin > 0) return `${diffMin}分钟前`; + if (diffSec > 0) return `${diffSec}秒前`; + return '刚刚'; +}; + const isUserBubbleCopied = (msg: any, index: number) => { const key = getUserBubbleKey(msg, index); return copiedBubbleKeys.value.has(key); @@ -1749,6 +1807,8 @@ function compactBriefLabel(msg: any): string { watch( () => filteredMessages.value.map((m) => m?.id ?? m?.content?.slice(0, 80)), () => { + // 先同步估算折叠状态,再异步精确测量,避免长气泡先展开后收起 + preMeasureUserBubbles(); nextTick(() => { requestAnimationFrame(() => { measureUserBubbles(); diff --git a/static/src/composables/useLegacySocket.ts b/static/src/composables/useLegacySocket.ts index 521b372..7dc4fc0 100644 --- a/static/src/composables/useLegacySocket.ts +++ b/static/src/composables/useLegacySocket.ts @@ -966,6 +966,10 @@ export async function initializeLegacySocket(ctx: any) { ...eventMetadata, media_refs: incomingMediaRefs }; + const backendTimestamp = data?.timestamp ?? data?.created_at ?? data?.createdAt ?? null; + if (backendTimestamp) { + last.created_at = backendTimestamp; + } } else { ctx.chatAddUserMessage(message, incomingImages, incomingVideos, incomingMediaRefs, source, eventMetadata); } diff --git a/static/src/stores/chat.ts b/static/src/stores/chat.ts index 5334ff9..81c770b 100644 --- a/static/src/stores/chat.ts +++ b/static/src/stores/chat.ts @@ -210,7 +210,8 @@ export const useChatStore = defineStore('chat', { images, videos, media_refs: Array.isArray(mediaRefs) ? mediaRefs : [], - metadata + metadata, + created_at: startedAt }); this.currentMessageIndex = -1; }, diff --git a/static/src/stores/model.ts b/static/src/stores/model.ts index d065f2b..c46db53 100644 --- a/static/src/stores/model.ts +++ b/static/src/stores/model.ts @@ -46,6 +46,22 @@ export const useModelStore = defineStore('model', { const exists = this.models.some((m) => m.key === key); if (exists) { this.currentModelKey = key; + return; + } + // 如果列表中还没有该模型(例如 fetchModels 失败), + // 临时插入一个 fallback 项,避免首屏显示“未选择模型” + if (key) { + this.models = [ + ...this.models, + { + key, + label: key, + description: '', + fastOnly: false, + supportsThinking: true + } + ]; + this.currentModelKey = key; } }, async fetchModels() { diff --git a/static/src/styles/components/chat/_chat-area.scss b/static/src/styles/components/chat/_chat-area.scss index af84492..7678bfc 100644 --- a/static/src/styles/components/chat/_chat-area.scss +++ b/static/src/styles/components/chat/_chat-area.scss @@ -589,11 +589,45 @@ max-height: var(--bubble-fold-height, 240px); } -.user-bubble-actions { +/* 展开/收起按钮独占一行,不遮挡气泡内容,始终显示 */ +.user-bubble-expand-row { display: flex; align-items: center; justify-content: flex-end; - gap: 6px; + margin-top: -4px; + margin-bottom: -10px; + opacity: 1; + transform: none; + pointer-events: auto; + align-self: flex-end; + width: 100%; + min-height: 22px; +} + +.user-bubble-expand-row .user-bubble-expand-btn { + width: 22px; + height: 22px; + margin: 0; + padding: 0; + opacity: 1; + transform: none; + pointer-events: auto; + background: transparent; + color: var(--claude-text-tertiary); + border-radius: 4px; + transition: color 140ms ease; +} + +.user-bubble-expand-row .user-bubble-expand-btn:hover { + background: transparent; + color: var(--claude-text); +} + +.user-bubble-actions { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 4px; margin-top: 6px; opacity: 0; transform: translateY(4px); @@ -605,6 +639,15 @@ align-self: flex-end; } +.user-bubble-time { + font-size: 12px; + color: var(--claude-text-secondary); + margin-left: auto; + padding-left: 8px; + line-height: 20px; + user-select: none; +} + .user-message:not(.user-message--compact):not(.user-message--brief):hover .user-bubble-actions, .user-message:not(.user-message--compact):not(.user-message--brief):focus-within .user-bubble-actions { opacity: 1; @@ -613,10 +656,10 @@ } .user-bubble-action-btn { - width: 28px; - height: 28px; + width: 20px; + height: 20px; border: none; - border-radius: 8px; + border-radius: 6px; padding: 0; background: transparent; color: var(--claude-text-secondary); @@ -637,8 +680,8 @@ .user-bubble-action-btn::before { content: ''; - width: 18px; - height: 18px; + width: 14px; + height: 14px; display: block; background: currentColor; mask: center / contain no-repeat; @@ -660,13 +703,13 @@ -webkit-mask-image: url('/static/icons/git-fork.svg'); } -.user-bubble-action-btn.expand-toggle::before { +.user-bubble-action-btn.user-bubble-expand-btn::before { mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); transition: transform 260ms cubic-bezier(0.4, 0, 0.2, 1); } -.user-bubble-action-btn.expand-toggle.expanded::before { +.user-bubble-action-btn.user-bubble-expand-btn.expanded::before { transform: rotate(180deg); }