feat(chat): show reply model attribution in assistant message bubbles

This commit is contained in:
matevip 2026-05-09 16:41:18 +08:00
parent d2886bdaa3
commit 97e2b12f71
8 changed files with 53 additions and 0 deletions

View File

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

View File

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

View File

@ -337,6 +337,12 @@
>
<el-icon><RefreshRight /></el-icon>
</button>
<!-- Reply model attribution (assistant only) -->
<span
v-if="role === 'assistant' && replyModel"
class="action-model"
:title="replyModelTitle"
>{{ replyModel }}</span>
<!-- 时间戳inline -->
<span class="action-time">{{ formattedTime }}</span>
</div>
@ -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;

View File

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

View File

@ -148,6 +148,7 @@ export default {
copy: 'Copy',
copied: 'Copied',
regenerate: 'Regenerate',
replyModel: 'Reply model: {model}',
ttsPlay: 'Read Aloud',
ttsStop: 'Stop Reading',
conversations: 'Conversations',

View File

@ -148,6 +148,7 @@ export default {
copy: '复制',
copied: '已复制',
regenerate: '重新生成',
replyModel: '本条回复模型: {model}',
ttsPlay: '朗读',
ttsStop: '停止朗读',
conversations: '会话列表',

View File

@ -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[]

View File

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