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