From 97e2b12f71db280895af18a1b8e8d260aae6e01a Mon Sep 17 00:00:00 2001 From: matevip Date: Sat, 9 May 2026 16:41:18 +0800 Subject: [PATCH] feat(chat): show reply model attribution in assistant message bubbles --- .../vip/mate/channel/web/ChatController.java | 8 ++++++ .../workspace/conversation/vo/MessageVO.java | 8 ++++++ .../src/components/chat/MessageBubble.vue | 28 +++++++++++++++++++ mateclaw-ui/src/composables/chat/useChat.ts | 2 ++ mateclaw-ui/src/i18n/locales/en-US.ts | 1 + mateclaw-ui/src/i18n/locales/zh-CN.ts | 1 + mateclaw-ui/src/types/index.ts | 3 ++ mateclaw-ui/src/views/ChatConsole.vue | 2 ++ 8 files changed, 53 insertions(+) diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java index f31411f4..105fef0f 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java @@ -1416,6 +1416,14 @@ public class ChatController { payload.put("status", status); if (savedAssistant != null && savedAssistant.getId() != null) { payload.put("assistantMessageId", savedAssistant.getId()); + // Surface runtime model attribution so the chat bubble can show + // which model produced this reply without waiting for a history reload. + if (savedAssistant.getRuntimeModel() != null && !savedAssistant.getRuntimeModel().isBlank()) { + payload.put("runtimeModel", savedAssistant.getRuntimeModel()); + } + if (savedAssistant.getRuntimeProvider() != null && !savedAssistant.getRuntimeProvider().isBlank()) { + payload.put("runtimeProvider", savedAssistant.getRuntimeProvider()); + } } if (promptTokens > 0) payload.put("promptTokens", promptTokens); if (completionTokens > 0) payload.put("completionTokens", completionTokens); diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/MessageVO.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/MessageVO.java index 37c7381e..111ce8b2 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/MessageVO.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/MessageVO.java @@ -41,6 +41,12 @@ public class MessageVO { /** Completion tokens 消耗 */ private Integer completionTokens; + /** Model name actually used to produce this message (e.g. "deepseek-chat"). */ + private String runtimeModel; + + /** Provider id of the runtime model (e.g. "deepseek", "zhipu"). */ + private String runtimeProvider; + private LocalDateTime createTime; private LocalDateTime updateTime; @@ -59,6 +65,8 @@ public class MessageVO { vo.setMetadata(parseMetadataToObject(entity.getMetadata())); vo.setPromptTokens(entity.getPromptTokens()); vo.setCompletionTokens(entity.getCompletionTokens()); + vo.setRuntimeModel(entity.getRuntimeModel()); + vo.setRuntimeProvider(entity.getRuntimeProvider()); vo.setCreateTime(entity.getCreateTime()); vo.setUpdateTime(entity.getUpdateTime()); vo.setContentParts(contentParts); diff --git a/mateclaw-ui/src/components/chat/MessageBubble.vue b/mateclaw-ui/src/components/chat/MessageBubble.vue index 15684c84..17697c86 100644 --- a/mateclaw-ui/src/components/chat/MessageBubble.vue +++ b/mateclaw-ui/src/components/chat/MessageBubble.vue @@ -337,6 +337,12 @@ > + + {{ replyModel }} {{ formattedTime }} @@ -710,6 +716,16 @@ const formattedTime = computed(() => { }) }) +// Reply model attribution: shows which model produced the assistant message. +// Empty for streaming (server only emits runtimeModel after persistence) and +// historical messages prior to MessageVO carrying the field. +const replyModel = computed(() => props.message.runtimeModel || '') +const replyModelTitle = computed(() => { + const provider = props.message.runtimeProvider + const base = t('chat.replyModel', { model: replyModel.value }) + return provider ? `${base} (${provider})` : base +}) + const formatFileSize = (size: number) => { if (size < 1024) return `${size} B` if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB` @@ -1475,6 +1491,18 @@ watch(isGenerating, (generating) => { user-select: none; } +.action-model { + font-size: 11px; + color: var(--mc-text-secondary, #64748b); + margin-left: 4px; + padding: 1px 6px; + border-radius: 4px; + background: var(--mc-fill-2, rgba(100, 116, 139, 0.08)); + font-family: var(--mc-mono-font, ui-monospace, "SF Mono", Menlo, monospace); + user-select: text; + white-space: nowrap; +} + /* ==================== 主内容区域 ==================== */ .msg-content { position: relative; diff --git a/mateclaw-ui/src/composables/chat/useChat.ts b/mateclaw-ui/src/composables/chat/useChat.ts index 86553d0a..e6a511c3 100644 --- a/mateclaw-ui/src/composables/chat/useChat.ts +++ b/mateclaw-ui/src/composables/chat/useChat.ts @@ -487,6 +487,8 @@ export function useChat(options: UseChatOptions): UseChatReturn { const msg = messages.value[msgIndex] if (data.promptTokens !== undefined) msg.promptTokens = data.promptTokens if (data.completionTokens !== undefined) msg.completionTokens = data.completionTokens + if (data.runtimeModel) msg.runtimeModel = data.runtimeModel + if (data.runtimeProvider) msg.runtimeProvider = data.runtimeProvider // Replace the local temp ID with the backend-persisted ID so reconcile can match by ID if (data.assistantMessageId) { msg.id = data.assistantMessageId diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 269b544f..ff02e473 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -148,6 +148,7 @@ export default { copy: 'Copy', copied: 'Copied', regenerate: 'Regenerate', + replyModel: 'Reply model: {model}', ttsPlay: 'Read Aloud', ttsStop: 'Stop Reading', conversations: 'Conversations', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index b5b90508..fe3d7f61 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -148,6 +148,7 @@ export default { copy: '复制', copied: '已复制', regenerate: '重新生成', + replyModel: '本条回复模型: {model}', ttsPlay: '朗读', ttsStop: '停止朗读', conversations: '会话列表', diff --git a/mateclaw-ui/src/types/index.ts b/mateclaw-ui/src/types/index.ts index 3efd79f1..ee3d2de6 100644 --- a/mateclaw-ui/src/types/index.ts +++ b/mateclaw-ui/src/types/index.ts @@ -83,6 +83,9 @@ export interface Message { // Token 统计 promptTokens?: number completionTokens?: number + // Runtime model attribution (assistant messages): the model that actually produced this reply + runtimeModel?: string + runtimeProvider?: string // 前端临时字段 streaming?: boolean // 内部动画控制,UI 渲染以 status 为准 attachments?: ChatAttachment[] diff --git a/mateclaw-ui/src/views/ChatConsole.vue b/mateclaw-ui/src/views/ChatConsole.vue index 51cf4b34..f3301e57 100644 --- a/mateclaw-ui/src/views/ChatConsole.vue +++ b/mateclaw-ui/src/views/ChatConsole.vue @@ -1774,6 +1774,8 @@ function normalizeMessage(raw: Message): Message { // 保留后端返回的 token 字段(MessageVO 新增) if ((raw as any).promptTokens) msg.promptTokens = (raw as any).promptTokens if ((raw as any).completionTokens) msg.completionTokens = (raw as any).completionTokens + if ((raw as any).runtimeModel) msg.runtimeModel = (raw as any).runtimeModel + if ((raw as any).runtimeProvider) msg.runtimeProvider = (raw as any).runtimeProvider if (msg.contentParts.length === 0 && msg.content) { if (msg.role === 'assistant') {