Fix: user message timestamp from backend, bubble action layout
This commit is contained in:
parent
0a4c75ce45
commit
2b2ff44fcd
@ -464,6 +464,7 @@ async def _dispatch_completion_user_notice(
|
|||||||
session_data["auto_user_message_payload"] = {
|
session_data["auto_user_message_payload"] = {
|
||||||
**dict(extra_payload or {}),
|
**dict(extra_payload or {}),
|
||||||
**ui_defaults,
|
**ui_defaults,
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
}
|
}
|
||||||
# 轮询客户端通过后续任务事件流回放前置通知(在线客户端已由上面的 socketio 回显覆盖)。
|
# 轮询客户端通过后续任务事件流回放前置通知(在线客户端已由上面的 socketio 回显覆盖)。
|
||||||
if preceding_notices:
|
if preceding_notices:
|
||||||
@ -491,6 +492,7 @@ async def _dispatch_completion_user_notice(
|
|||||||
'conversation_id': conversation_id,
|
'conversation_id': conversation_id,
|
||||||
'task_id': rec.task_id,
|
'task_id': rec.task_id,
|
||||||
'message_source': message_source,
|
'message_source': message_source,
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
**ui_defaults,
|
**ui_defaults,
|
||||||
}
|
}
|
||||||
payload.update(extra_payload)
|
payload.update(extra_payload)
|
||||||
@ -508,6 +510,7 @@ async def _dispatch_completion_user_notice(
|
|||||||
'message': user_message,
|
'message': user_message,
|
||||||
'conversation_id': conversation_id,
|
'conversation_id': conversation_id,
|
||||||
'message_source': message_source,
|
'message_source': message_source,
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
}
|
}
|
||||||
payload.update(_user_message_ui_defaults(message_source, auto_user_message_event=True))
|
payload.update(_user_message_ui_defaults(message_source, auto_user_message_event=True))
|
||||||
payload.update(extra_payload)
|
payload.update(extra_payload)
|
||||||
@ -976,6 +979,7 @@ async def handle_task_with_sender(
|
|||||||
"starts_work": user_message_metadata.get("starts_work"),
|
"starts_work": user_message_metadata.get("starts_work"),
|
||||||
"metadata": user_message_metadata,
|
"metadata": user_message_metadata,
|
||||||
"conversation_id": conversation_id,
|
"conversation_id": conversation_id,
|
||||||
|
"timestamp": (saved_user_message or {}).get("timestamp") or user_work_started_at,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@ -278,13 +278,19 @@ export const historyMethods = {
|
|||||||
if (Array.isArray(videos) && videos.length) {
|
if (Array.isArray(videos) && videos.length) {
|
||||||
historyHasVideos = true;
|
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({
|
this.messages.push({
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: message.content || '',
|
content: message.content || '',
|
||||||
images,
|
images,
|
||||||
videos,
|
videos,
|
||||||
media_refs: Array.isArray(mediaRefs) ? mediaRefs : [],
|
media_refs: Array.isArray(mediaRefs) ? mediaRefs : [],
|
||||||
metadata: message.metadata || {}
|
metadata: message.metadata || {},
|
||||||
|
created_at: normalizedCreatedAt
|
||||||
});
|
});
|
||||||
debugLog('添加用户消息:', message.content?.substring(0, 50) + '...');
|
debugLog('添加用户消息:', message.content?.substring(0, 50) + '...');
|
||||||
} else if (message.role === 'assistant') {
|
} else if (message.role === 'assistant') {
|
||||||
|
|||||||
@ -75,6 +75,10 @@ export const messagingMethods = {
|
|||||||
...eventMetadata,
|
...eventMetadata,
|
||||||
media_refs: incomingMediaRefs
|
media_refs: incomingMediaRefs
|
||||||
};
|
};
|
||||||
|
const backendTimestamp = data?.timestamp ?? data?.created_at ?? data?.createdAt ?? null;
|
||||||
|
if (backendTimestamp) {
|
||||||
|
target.created_at = backendTimestamp;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.chatAddUserMessage(
|
this.chatAddUserMessage(
|
||||||
message,
|
message,
|
||||||
@ -122,6 +126,10 @@ export const messagingMethods = {
|
|||||||
media_refs: incomingMediaRefs,
|
media_refs: incomingMediaRefs,
|
||||||
message_source: source
|
message_source: source
|
||||||
};
|
};
|
||||||
|
const backendTimestamp = data?.timestamp ?? data?.created_at ?? data?.createdAt ?? null;
|
||||||
|
if (backendTimestamp) {
|
||||||
|
recentMatch.created_at = backendTimestamp;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.chatAddUserMessage(
|
this.chatAddUserMessage(
|
||||||
message,
|
message,
|
||||||
|
|||||||
@ -60,6 +60,18 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
<div class="user-bubble-actions">
|
<div class="user-bubble-actions">
|
||||||
<button
|
<button
|
||||||
@ -75,14 +87,7 @@
|
|||||||
aria-label="分支"
|
aria-label="分支"
|
||||||
@click="branchUserMessage(msg)"
|
@click="branchUserMessage(msg)"
|
||||||
></button>
|
></button>
|
||||||
<button
|
<span class="user-bubble-time">{{ formatUserMessageTime(msg) }}</span>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</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 = () => {
|
const measureUserBubbles = () => {
|
||||||
userBubbleRefs.forEach((el, key) => {
|
userBubbleRefs.forEach((el, key) => {
|
||||||
if (!el.isConnected) {
|
if (!el.isConnected) {
|
||||||
@ -771,6 +805,30 @@ const toggleUserBubble = (msg: any, index: number) => {
|
|||||||
userBubbleFoldStates.value[key] = { ...state, expanded: !state.expanded };
|
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 isUserBubbleCopied = (msg: any, index: number) => {
|
||||||
const key = getUserBubbleKey(msg, index);
|
const key = getUserBubbleKey(msg, index);
|
||||||
return copiedBubbleKeys.value.has(key);
|
return copiedBubbleKeys.value.has(key);
|
||||||
@ -1749,6 +1807,8 @@ function compactBriefLabel(msg: any): string {
|
|||||||
watch(
|
watch(
|
||||||
() => filteredMessages.value.map((m) => m?.id ?? m?.content?.slice(0, 80)),
|
() => filteredMessages.value.map((m) => m?.id ?? m?.content?.slice(0, 80)),
|
||||||
() => {
|
() => {
|
||||||
|
// 先同步估算折叠状态,再异步精确测量,避免长气泡先展开后收起
|
||||||
|
preMeasureUserBubbles();
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
measureUserBubbles();
|
measureUserBubbles();
|
||||||
|
|||||||
@ -966,6 +966,10 @@ export async function initializeLegacySocket(ctx: any) {
|
|||||||
...eventMetadata,
|
...eventMetadata,
|
||||||
media_refs: incomingMediaRefs
|
media_refs: incomingMediaRefs
|
||||||
};
|
};
|
||||||
|
const backendTimestamp = data?.timestamp ?? data?.created_at ?? data?.createdAt ?? null;
|
||||||
|
if (backendTimestamp) {
|
||||||
|
last.created_at = backendTimestamp;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
ctx.chatAddUserMessage(message, incomingImages, incomingVideos, incomingMediaRefs, source, eventMetadata);
|
ctx.chatAddUserMessage(message, incomingImages, incomingVideos, incomingMediaRefs, source, eventMetadata);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -210,7 +210,8 @@ export const useChatStore = defineStore('chat', {
|
|||||||
images,
|
images,
|
||||||
videos,
|
videos,
|
||||||
media_refs: Array.isArray(mediaRefs) ? mediaRefs : [],
|
media_refs: Array.isArray(mediaRefs) ? mediaRefs : [],
|
||||||
metadata
|
metadata,
|
||||||
|
created_at: startedAt
|
||||||
});
|
});
|
||||||
this.currentMessageIndex = -1;
|
this.currentMessageIndex = -1;
|
||||||
},
|
},
|
||||||
|
|||||||
@ -46,6 +46,22 @@ export const useModelStore = defineStore('model', {
|
|||||||
const exists = this.models.some((m) => m.key === key);
|
const exists = this.models.some((m) => m.key === key);
|
||||||
if (exists) {
|
if (exists) {
|
||||||
this.currentModelKey = key;
|
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() {
|
async fetchModels() {
|
||||||
|
|||||||
@ -589,11 +589,45 @@
|
|||||||
max-height: var(--bubble-fold-height, 240px);
|
max-height: var(--bubble-fold-height, 240px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-bubble-actions {
|
/* 展开/收起按钮独占一行,不遮挡气泡内容,始终显示 */
|
||||||
|
.user-bubble-expand-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
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;
|
margin-top: 6px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(4px);
|
transform: translateY(4px);
|
||||||
@ -605,6 +639,15 @@
|
|||||||
align-self: flex-end;
|
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):hover .user-bubble-actions,
|
||||||
.user-message:not(.user-message--compact):not(.user-message--brief):focus-within .user-bubble-actions {
|
.user-message:not(.user-message--compact):not(.user-message--brief):focus-within .user-bubble-actions {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@ -613,10 +656,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.user-bubble-action-btn {
|
.user-bubble-action-btn {
|
||||||
width: 28px;
|
width: 20px;
|
||||||
height: 28px;
|
height: 20px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 6px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--claude-text-secondary);
|
color: var(--claude-text-secondary);
|
||||||
@ -637,8 +680,8 @@
|
|||||||
|
|
||||||
.user-bubble-action-btn::before {
|
.user-bubble-action-btn::before {
|
||||||
content: '';
|
content: '';
|
||||||
width: 18px;
|
width: 14px;
|
||||||
height: 18px;
|
height: 14px;
|
||||||
display: block;
|
display: block;
|
||||||
background: currentColor;
|
background: currentColor;
|
||||||
mask: center / contain no-repeat;
|
mask: center / contain no-repeat;
|
||||||
@ -660,13 +703,13 @@
|
|||||||
-webkit-mask-image: url('/static/icons/git-fork.svg');
|
-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");
|
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");
|
-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);
|
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);
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user