Fix: user message timestamp from backend, bubble action layout

This commit is contained in:
JOJO 2026-06-28 01:48:18 +08:00
parent 0a4c75ce45
commit 2b2ff44fcd
8 changed files with 161 additions and 19 deletions

View File

@ -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:

View File

@ -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') {

View File

@ -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,

View File

@ -60,6 +60,18 @@
/>
</div>
</div>
<div
v-if="isUserBubbleExpandable(msg, index)"
class="user-bubble-expand-row"
>
<button
class="user-bubble-action-btn user-bubble-expand-btn"
:class="{ expanded: isUserBubbleExpanded(msg, index) }"
:title="isUserBubbleExpanded(msg, index) ? '收起' : '展开'"
:aria-label="isUserBubbleExpanded(msg, index) ? '收起' : '展开'"
@click.stop="toggleUserBubble(msg, index)"
></button>
</div>
</div>
<div class="user-bubble-actions">
<button
@ -75,14 +87,7 @@
aria-label="分支"
@click="branchUserMessage(msg)"
></button>
<button
v-if="isUserBubbleExpandable(msg, index)"
class="user-bubble-action-btn expand-toggle"
:class="{ expanded: isUserBubbleExpanded(msg, index) }"
:title="isUserBubbleExpanded(msg, index) ? '收起' : '展开'"
:aria-label="isUserBubbleExpanded(msg, index) ? '收起' : '展开'"
@click="toggleUserBubble(msg, index)"
></button>
<span class="user-bubble-time">{{ formatUserMessageTime(msg) }}</span>
</div>
</template>
</div>
@ -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();

View File

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

View File

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

View File

@ -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() {

View File

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