From 768bf8cf668510b0ee678697c84d8fcf092d5823 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 20:52:33 +0000 Subject: [PATCH 1/2] feat(chat): stamp each user message with the persona that sent it A user message now records `personaId` + `personaName` at send time, and the chat renders the persona it was sent as instead of whichever one is active: - `ChatNotifier._sendMessage` stamps the effective persona onto the message. - `ChatMessageMapper` resolves the stored id against a live roster (`ChatBridgeController.setPersonaRoster`, fed from `personaListProvider`): an existing persona contributes its current name and avatar, so renaming it renames its own past messages; a deleted one leaves the message with the name stored on it and `avatarFallback`, which drops the avatar to its letter. - The renderer stamps `data-avatar-pinned` on such a message and `setIdentity` skips pinned avatars, so switching persona no longer re-faces the history. - Messages sent before this change carry no persona and keep following the active identity. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018SXpk5JW6Ext697GbCmZvr --- assets/chat_webview/bridge.legacy.js | 10 +- .../bridge/chat_bridge_controller.js | 10 +- .../chat_webview/renderer/message_renderer.js | 15 +- docs/rules/message-rendering.md | 24 +++ .../chat/bridge/chat_bridge_controller.dart | 30 +++ .../chat/bridge/chat_message_mapper.dart | 57 ++++- lib/features/chat/chat_provider.dart | 12 ++ .../widgets/chat_webview_build_listeners.dart | 49 +++++ .../widgets/chat_webview_initializer.dart | 5 + .../chat/widgets/chat_webview_widget.dart | 7 + test/chat_message_persona_test.dart | 133 ++++++++++++ test/webview_assets_test.dart | 33 +++ test/webview_js/specs/message_persona.spec.js | 204 ++++++++++++++++++ 13 files changed, 581 insertions(+), 8 deletions(-) create mode 100644 test/chat_message_persona_test.dart create mode 100644 test/webview_js/specs/message_persona.spec.js diff --git a/assets/chat_webview/bridge.legacy.js b/assets/chat_webview/bridge.legacy.js index a0a9d552..c342a189 100644 --- a/assets/chat_webview/bridge.legacy.js +++ b/assets/chat_webview/bridge.legacy.js @@ -883,11 +883,17 @@ class Bridge { const isUser = section.classList.contains('user'); const stored = section.dataset.personaName || ''; const storedPersonaName = stored === 'You' ? '' : stored; + // A message pinned to the persona it was sent as keeps that persona's + // name and avatar — the letter included, when the persona was deleted. + // Only unpinned messages follow the active identity. + const pinned = section.dataset.avatarPinned === '1'; // Per-message stored persona wins; otherwise use the active identity. const newName = isUser - ? (storedPersonaName || this._personaName || 'You') + ? ((pinned ? stored : storedPersonaName) || this._personaName || 'You') : (this._charName || stored || 'Character'); - const newAvatarUrl = isUser ? this._personaAvatarUrl : this._charAvatarUrl; + const newAvatarUrl = pinned + ? (section.dataset.avatarUrl || null) + : (isUser ? this._personaAvatarUrl : this._charAvatarUrl); const label = section.querySelector('.msg-name-label'); if (label) label.textContent = newName; diff --git a/assets/chat_webview/bridge/chat_bridge_controller.js b/assets/chat_webview/bridge/chat_bridge_controller.js index e8966e81..18d4d061 100644 --- a/assets/chat_webview/bridge/chat_bridge_controller.js +++ b/assets/chat_webview/bridge/chat_bridge_controller.js @@ -123,11 +123,17 @@ export class Bridge { const isUser = section.classList.contains('user'); const stored = section.dataset.personaName || ''; const storedPersonaName = stored === 'You' ? '' : stored; + // A message pinned to the persona it was sent as keeps that persona's + // name and avatar — the letter included, when the persona was deleted. + // Only unpinned messages follow the active identity. + const pinned = section.dataset.avatarPinned === '1'; // Per-message stored persona wins; otherwise use the active identity. const newName = isUser - ? (storedPersonaName || this._personaName || 'You') + ? ((pinned ? stored : storedPersonaName) || this._personaName || 'You') : (this._charName || stored || 'Character'); - const newAvatarUrl = isUser ? this._personaAvatarUrl : this._charAvatarUrl; + const newAvatarUrl = pinned + ? (section.dataset.avatarUrl || null) + : (isUser ? this._personaAvatarUrl : this._charAvatarUrl); const label = section.querySelector('.msg-name-label'); if (label) label.textContent = newName; diff --git a/assets/chat_webview/renderer/message_renderer.js b/assets/chat_webview/renderer/message_renderer.js index dc403ab9..a7f9fa88 100644 --- a/assets/chat_webview/renderer/message_renderer.js +++ b/assets/chat_webview/renderer/message_renderer.js @@ -108,6 +108,13 @@ export class Renderer { if (reasoning) section.dataset.reasoning = reasoning; if (isLast && this._roleKey(role) === 'char') section.dataset.isLast = 'true'; if (messageData.personaName) section.dataset.personaName = messageData.personaName; + // Sent as a named persona: the message keeps that persona's avatar (or, if + // it was deleted or has no picture, its letter) instead of following the + // currently active persona. `avatarPinned` is what setIdentity checks + // before it repaints avatars. + if (messageData.personaId) section.dataset.personaId = messageData.personaId; + if (messageData.avatarUrl) section.dataset.avatarUrl = messageData.avatarUrl; + if (messageData.avatarUrl || messageData.avatarFallback) section.dataset.avatarPinned = '1'; if (messageData.messageIndex != null) section.dataset.messageIndex = String(messageData.messageIndex); if (messageData.swipeIndex != null) section.dataset.swipeId = String(messageData.swipeIndex); if (messageData.swipeTotal != null) section.dataset.swipeTotal = String(messageData.swipeTotal); @@ -194,11 +201,15 @@ if (messageData.isEditing) classes.push('editing'); const roleKey = this._roleKey(m.role); const finalName = m.displayName || m.personaName || this._getDefaultName(m.role); const identity = window.bridge || null; - const avatarUrl = m.avatarUrl || (roleKey === 'user' + // `avatarFallback` means the message names a persona with no avatar to + // show — deleted, or never given a picture. It renders the letter rather + // than borrowing the active persona's avatar. + const pinnedAvatar = !!(m.avatarUrl || m.avatarFallback); + const avatarUrl = m.avatarUrl || (pinnedAvatar ? null : (roleKey === 'user' ? (identity && identity._personaAvatarUrl) : roleKey === 'char' ? (identity && identity._charAvatarUrl) - : null); + : null)); if (avatarUrl) { const img = document.createElement('img'); img.src = avatarUrl; diff --git a/docs/rules/message-rendering.md b/docs/rules/message-rendering.md index fd4b6fad..49af9ca5 100644 --- a/docs/rules/message-rendering.md +++ b/docs/rules/message-rendering.md @@ -164,6 +164,30 @@ Two rules follow, and `specs/virtual_window.spec.js` holds them: --- +## A user message wears the persona it was sent as, not the active one + +Every user message stores the persona it was sent under — `personaId` and +`personaName` (`ChatNotifier._sendMessage`). By the time the map reaches the +page that id is already resolved against the live roster +(`ChatBridgeController.setPersonaRoster`): + +* the persona still exists → the map carries its `avatarUrl` and its *current* + name, so renaming a persona renames the messages it sent; +* it was deleted, or has no picture → `avatarFallback: true`, and the message + keeps the name stored on it while the avatar drops to the initial letter. + +Either way the renderer stamps `data-avatar-pinned` on the section, and +`setIdentity` — which repaints every avatar in the DOM — skips the pinned ones. +Without that skip, switching persona re-faces the whole history, and a message +from a deleted persona borrows the picture of whoever is active now. +`specs/message_persona.spec.js` holds this. + +A message written before personas were stamped carries neither field: it is +unpinned and keeps following the active identity, which is all there is to go +on for it. + +--- + ## The message body renders into a shadow root `.message-content` gets an open shadow root (`message_renderer.js`), which is diff --git a/lib/features/chat/bridge/chat_bridge_controller.dart b/lib/features/chat/bridge/chat_bridge_controller.dart index a09da69c..6b4e6fca 100644 --- a/lib/features/chat/bridge/chat_bridge_controller.dart +++ b/lib/features/chat/bridge/chat_bridge_controller.dart @@ -82,6 +82,13 @@ class ChatBridgeController { final Map _blockStatusByMessageId = {}; final Map> _triggeredRegexesByMessageId = {}; + /// Every persona that still exists, by id, with its avatar already resolved + /// to a WebView URL. User messages carry the id of the persona they were + /// sent as; this is what that id is resolved against, so a renamed persona + /// renames its own past messages and a deleted one leaves them with their + /// stored name and a letter avatar. Refreshed from `personaListProvider`. + Map _personasById = const {}; + List _displayRegexes = []; Character? _regexCharacter; Persona? _regexPersona; @@ -154,6 +161,7 @@ class ChatBridgeController { isGenerating: isGenerating, isPostGenRunning: isPostGenRunning, isSendPending: isSendPending, + personasById: _personasById, coveredMemoryIds: _coveredMemoryIds, pendingMemoryIds: _pendingMemoryIds, draftMemoryIds: _draftMemoryIds, @@ -179,6 +187,28 @@ class ChatBridgeController { }; } + /// Replaces the persona roster the message mapper resolves `personaId` + /// against. Avatar paths are resolved here, once per roster change, rather + /// than per message. + void setPersonaRoster(List personas) { + _personasById = { + for (final p in personas) + p.id: PersonaIdentity( + name: p.name, + avatarUrl: _rosterAvatarUrl(p.avatarPath), + ), + }; + } + + /// Null unless [path] resolves to a URL the page can actually load — an + /// empty string would reach the renderer as a present-but-blank avatar and + /// suppress the letter fallback the persona is owed. + String? _rosterAvatarUrl(String? path) { + if (path == null || path.isEmpty) return null; + final url = resolveLocalFileUrl(path); + return (url == null || url.isEmpty) ? null : url; + } + void setRegexContext( List regexes, Character? char, diff --git a/lib/features/chat/bridge/chat_message_mapper.dart b/lib/features/chat/bridge/chat_message_mapper.dart index ae0516ee..72cb5ea5 100644 --- a/lib/features/chat/bridge/chat_message_mapper.dart +++ b/lib/features/chat/bridge/chat_message_mapper.dart @@ -9,6 +9,31 @@ import 'package:glaze_flutter/core/utils/think_tags.dart'; part 'chat_message_mapper.freezed.dart'; +/// A persona as the chat needs it for rendering: the live name and its avatar, +/// already resolved to a URL the WebView can load — null when the persona +/// carries no avatar image. +/// +/// Only personas that still exist appear in +/// [ChatMessageMapperContext.personasById] — a message whose `personaId` is +/// missing from that map was sent by a persona that has since been deleted, +/// which is what makes its avatar fall back to the initial letter. +class PersonaIdentity { + final String name; + final String? avatarUrl; + + const PersonaIdentity({required this.name, this.avatarUrl}); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PersonaIdentity && + other.name == name && + other.avatarUrl == avatarUrl; + + @override + int get hashCode => Object.hash(name, avatarUrl); +} + @freezed abstract class ChatMessageMapperContext with _$ChatMessageMapperContext { const factory ChatMessageMapperContext({ @@ -30,6 +55,10 @@ abstract class ChatMessageMapperContext with _$ChatMessageMapperContext { /// trailing user message from the map alone — and that message's reply is /// already on its way. @Default(false) bool isSendPending, + + /// Every persona that still exists, by id — the roster the WebView + /// resolves a message's stored `personaId` against. See [PersonaIdentity]. + @Default({}) Map personasById, @Default({}) Set coveredMemoryIds, @Default({}) Set pendingMemoryIds, @Default({}) Set draftMemoryIds, @@ -110,13 +139,26 @@ class ChatMessageMapper { ? null : m.personaName; + // The persona the message was sent as, resolved against the live roster. + // A renamed persona renames its own past messages; a deleted one keeps the + // name stored on the message and loses its avatar (see [PersonaIdentity]). + final senderPersonaId = isUser ? m.personaId : null; + final senderPersona = senderPersonaId == null + ? null + : ctx.personasById[senderPersonaId]; + final senderAvatarUrl = senderPersona?.avatarUrl; + String? displayName; String? avatarColor; if (isAssistant) { displayName = ctx.currentCharName ?? m.personaName ?? 'Character'; avatarColor = ctx.currentCharColor; } else if (isUser) { - displayName = userMessagePersonaName ?? ctx.currentPersonaName ?? 'You'; + displayName = + senderPersona?.name ?? + userMessagePersonaName ?? + ctx.currentPersonaName ?? + 'You'; } else { displayName = m.personaName ?? 'System'; } @@ -165,7 +207,18 @@ class ChatMessageMapper { 'avatarColor': ?avatarColor, if (m.imagePath != null) 'imagePath': m.imagePath, if (m.imagePath != null) 'imageHidden': m.imageHidden, - if (m.personaName != null && (!isUser || userMessagePersonaName != null)) + if (isUser && senderPersonaId != null) ...{ + // Sent as a named persona: the WebView pins this message's name and + // avatar to it instead of following whichever persona is active now. + 'personaId': senderPersonaId, + 'personaName': displayName, + 'avatarUrl': ?senderAvatarUrl, + // Nothing to pin — the persona was deleted, or it has no avatar image. + // Either way the renderer must draw the initial letter rather than + // falling back to the active persona's picture. + if (senderAvatarUrl == null) 'avatarFallback': true, + } else if (m.personaName != null && + (!isUser || userMessagePersonaName != null)) 'personaName': m.personaName, if (m.swipes.isNotEmpty) 'swipeIndex': m.swipeId, if (m.swipes.isNotEmpty) 'swipeTotal': m.swipes.length, diff --git a/lib/features/chat/chat_provider.dart b/lib/features/chat/chat_provider.dart index a1c9edec..833cf5a6 100644 --- a/lib/features/chat/chat_provider.dart +++ b/lib/features/chat/chat_provider.dart @@ -482,6 +482,16 @@ class ChatNotifier extends AsyncNotifier { final sendPendingToken = ++_sendPendingSeq; try { + // Stamp the message with the persona that sent it. The id is what the + // chat resolves against later (a renamed persona keeps naming its old + // messages correctly, a deleted one falls back to the letter avatar); + // the name is the snapshot that survives the persona being deleted. + final sendingPersona = ref.read( + effectivePersonaForChatProvider(( + charId: arg, + sessionId: current.session!.id, + )), + ); final userMsg = ChatMessage( id: generateId(), role: 'user', @@ -489,6 +499,8 @@ class ChatNotifier extends AsyncNotifier { timestamp: DateTime.now().millisecondsSinceEpoch, tokens: estimateTokens(text), imagePath: imageDataUrl, + personaId: sendingPersona?.id, + personaName: sendingPersona?.name, ); // Only the assistant *immediately* before the new message is accepted — diff --git a/lib/features/chat/widgets/chat_webview_build_listeners.dart b/lib/features/chat/widgets/chat_webview_build_listeners.dart index 1ba74c0c..d1025d18 100644 --- a/lib/features/chat/widgets/chat_webview_build_listeners.dart +++ b/lib/features/chat/widgets/chat_webview_build_listeners.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/llm/generation_phase.dart'; import '../../../core/models/chat_message.dart'; +import '../../../core/models/persona.dart'; import '../../../core/models/preset.dart'; import '../../../core/state/active_regex_provider.dart'; import '../../../core/state/character_provider.dart'; @@ -12,6 +13,7 @@ import '../../extensions/models/info_block.dart'; import '../../extensions/providers/extension_presets_provider.dart'; import '../../extensions/providers/extensions_settings_provider.dart'; import '../../extensions/providers/info_blocks_provider.dart'; +import '../../personas/persona_list_provider.dart'; import '../bridge/chat_bridge_controller.dart'; import '../chat_provider.dart'; import '../chat_state.dart'; @@ -76,6 +78,7 @@ class ChatWebViewBuildListeners { /// from the top of `State.build` after the `ref.watch` reads. void attach() { _listenDisplayRegexes(); + _listenPersonaRoster(); _listenEditingMessage(); _listenGenerationPhase(); _listenStreaming(); @@ -119,6 +122,37 @@ class ChatWebViewBuildListeners { }); } + /// Keeps rendered messages in step with the persona roster. A user message + /// stores the id of the persona it was sent as, and the WebView resolves that + /// id when it renders: renaming a persona must rename its own past messages, + /// and deleting one must drop those messages back to a letter avatar while + /// keeping the name stored on them. Neither reaches the page on its own — + /// the maps are built in Dart — so the roster is re-pushed and the messages + /// re-rendered here. + void _listenPersonaRoster() { + ref.listen>>(personaListProvider, (prev, next) { + final b = bridge; + if (b == null || !ready()) return; + final oldList = prev?.value ?? const []; + final newList = next.value ?? const []; + if (!_personaRosterChanged(oldList, newList)) return; + b.setPersonaRoster(newList); + // Same reasoning as the display-regex re-render above: every message map + // is affected, so the batch is a full re-render that keeps the scroll + // position. + unawaited(() async { + onDomReset(); + await b.setMessages( + messages, + visibleStartIndex: visibleStartIndex, + preserveScroll: true, + ); + if (isCurrentBridge?.call(b) == false || !ready()) return; + await onReconcileActiveGeneration(b); + }()); + }); + } + void _listenEditingMessage() { ref.listen(editingMessageIdProvider(charId), (prev, next) { final b = bridge; @@ -306,6 +340,21 @@ class ChatWebViewBuildListeners { }); } + /// True when the roster changed in a way a rendered message can show: which + /// personas exist, their names, or their avatars. Anything else about a + /// persona (its prompt, say) never reaches the chat bubble. + static bool _personaRosterChanged(List a, List b) { + if (a.length != b.length) return true; + final byId = {for (final p in a) p.id: p}; + for (final p in b) { + final old = byId[p.id]; + if (old == null || old.name != p.name || old.avatarPath != p.avatarPath) { + return true; + } + } + return false; + } + static bool _regexListChanged(List a, List b) { if (a.length != b.length) return true; for (int i = 0; i < a.length; i++) { diff --git a/lib/features/chat/widgets/chat_webview_initializer.dart b/lib/features/chat/widgets/chat_webview_initializer.dart index 05b3da62..22adadfc 100644 --- a/lib/features/chat/widgets/chat_webview_initializer.dart +++ b/lib/features/chat/widgets/chat_webview_initializer.dart @@ -8,6 +8,7 @@ import '../../../core/state/active_regex_provider.dart'; import '../../../core/state/active_selection_provider.dart'; import '../../../core/state/character_provider.dart'; import '../../../core/state/persona_resolution.dart'; +import '../../personas/persona_list_provider.dart'; import '../bridge/chat_bridge_controller.dart'; import '../bridge/chat_overlay_blur_region.dart'; import '../chat_provider.dart'; @@ -143,6 +144,10 @@ class ChatWebViewInitializer { sessionId: input.sessionId, )), ); + // Before the first setMessages: user messages resolve their stored + // `personaId` against this roster, and one that arrives late would render + // the whole chat with letter avatars first. + bridge.setPersonaRoster(ref.read(personaListProvider).value ?? const []); final displayRegexes = ref.read(displayRegexesProvider).value ?? const []; bridge.setRegexContext( displayRegexes, diff --git a/lib/features/chat/widgets/chat_webview_widget.dart b/lib/features/chat/widgets/chat_webview_widget.dart index 46b95bfb..e185e3f5 100644 --- a/lib/features/chat/widgets/chat_webview_widget.dart +++ b/lib/features/chat/widgets/chat_webview_widget.dart @@ -9,6 +9,7 @@ import '../../../core/state/active_regex_provider.dart'; import '../../../core/state/active_selection_provider.dart'; import '../../../core/state/character_provider.dart'; import '../../../core/state/persona_resolution.dart'; +import '../../personas/persona_list_provider.dart'; import '../../../../shared/theme/theme_font_provider.dart'; import '../../../../shared/theme/theme_preset.dart'; import '../bridge/chat_bridge_controller.dart'; @@ -1193,6 +1194,12 @@ class ChatWebViewWidgetState extends ConsumerState if (_bridge != null) { _bindBridgeCallbacks(); final session = ref.watch(chatProvider(widget.charId)).value?.session; + // The roster a message's stored `personaId` is resolved against. Watched + // (not read) so a persona renamed or deleted elsewhere reaches the page: + // the listener in ChatWebViewBuildListeners re-renders on the same change. + _bridge!.setPersonaRoster( + ref.watch(personaListProvider).value ?? const [], + ); _bridge!.setRegexContext( displayRegexes, character, diff --git a/test/chat_message_persona_test.dart b/test/chat_message_persona_test.dart new file mode 100644 index 00000000..91abe063 --- /dev/null +++ b/test/chat_message_persona_test.dart @@ -0,0 +1,133 @@ +// A user message stores the persona it was sent as — the id and the name — +// and the chat renders that persona instead of whichever one is active now. +// +// The id is what gets resolved against the live roster, so renaming a persona +// renames its own past messages. The stored name is the snapshot that outlives +// the persona: once it is deleted the message still carries the right name, +// and only its avatar falls back to the initial letter. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:glaze_flutter/core/models/chat_message.dart'; +import 'package:glaze_flutter/features/chat/bridge/chat_message_mapper.dart'; + +void main() { + const mara = ChatMessage( + id: 'u1', + role: 'user', + content: 'привет', + personaId: 'p-mara', + personaName: 'Mara', + ); + + ChatMessageMapperContext context({ + Map roster = const {}, + }) => ChatMessageMapperContext( + currentCharName: 'Alice', + currentPersonaName: 'Nyx', + isGenerating: false, + personasById: roster, + ); + + test('a message renders the persona it was sent as, not the active one', () { + final map = ChatMessageMapper.toMap( + mara, + context( + roster: const { + 'p-mara': PersonaIdentity( + name: 'Mara', + avatarUrl: 'file:///mara.png', + ), + }, + ), + ); + + expect(map['personaId'], 'p-mara'); + expect(map['personaName'], 'Mara'); + expect(map['displayName'], 'Mara'); + expect(map['avatarUrl'], 'file:///mara.png'); + expect(map.containsKey('avatarFallback'), isFalse); + }); + + test('a renamed persona renames the messages it sent', () { + final map = ChatMessageMapper.toMap( + mara, + context( + roster: const { + 'p-mara': PersonaIdentity( + name: 'Mara Vex', + avatarUrl: 'file:///m.png', + ), + }, + ), + ); + + expect(map['displayName'], 'Mara Vex'); + expect(map['personaName'], 'Mara Vex'); + }); + + test('a deleted persona keeps its name and loses its avatar', () { + // Empty roster: the persona is gone. The name stored on the message is all + // that is left of it, and the renderer must draw the letter rather than + // borrow the active persona's picture. + final map = ChatMessageMapper.toMap(mara, context()); + + expect(map['displayName'], 'Mara'); + expect(map['personaName'], 'Mara'); + expect(map.containsKey('avatarUrl'), isFalse); + expect(map['avatarFallback'], isTrue); + }); + + test('a persona with no avatar image also falls back to its letter', () { + final map = ChatMessageMapper.toMap( + mara, + context(roster: const {'p-mara': PersonaIdentity(name: 'Mara')}), + ); + + expect(map['displayName'], 'Mara'); + expect(map.containsKey('avatarUrl'), isFalse); + expect(map['avatarFallback'], isTrue); + }); + + test( + 'a message sent before personas were stamped follows the active one', + () { + const legacy = ChatMessage(id: 'u0', role: 'user', content: 'привет'); + + final map = ChatMessageMapper.toMap(legacy, context()); + + // No stored persona to pin: the page keeps using the active identity, so + // neither the avatar nor the fallback flag may appear on the map. + expect(map['displayName'], 'Nyx'); + expect(map.containsKey('personaId'), isFalse); + expect(map.containsKey('avatarUrl'), isFalse); + expect(map.containsKey('avatarFallback'), isFalse); + }, + ); + + test('an assistant message ignores the persona roster', () { + const reply = ChatMessage( + id: 'a1', + role: 'assistant', + content: 'hi', + personaId: 'p-mara', + personaName: 'Mara', + ); + + final map = ChatMessageMapper.toMap( + reply, + context( + roster: const { + 'p-mara': PersonaIdentity( + name: 'Mara', + avatarUrl: 'file:///mara.png', + ), + }, + ), + ); + + expect(map['displayName'], 'Alice'); + expect(map.containsKey('personaId'), isFalse); + expect(map.containsKey('avatarUrl'), isFalse); + expect(map.containsKey('avatarFallback'), isFalse); + }); +} diff --git a/test/webview_assets_test.dart b/test/webview_assets_test.dart index 5cf81e3f..34358640 100644 --- a/test/webview_assets_test.dart +++ b/test/webview_assets_test.dart @@ -754,6 +754,21 @@ void main() { contains("const storedPersonaName = stored === 'You' ? '' : stored"), ); }); + + // A user message carries the persona it was sent as, and the renderer + // stamps `data-avatar-pinned` on it. An identity push repaints every + // avatar in the DOM, so it has to leave those alone — otherwise switching + // persona re-faces the whole history, and a message from a deleted persona + // borrows the picture of whoever is active now. + test('identity refresh leaves a message-pinned avatar alone', () { + for (final source in [bridgeControllerJs, _asset('bridge.legacy.js')]) { + expect( + source, + contains("const pinned = section.dataset.avatarPinned === '1'"), + ); + expect(source, contains('section.dataset.avatarUrl || null')); + } + }); }); group('renderer ES module layout', () { @@ -786,6 +801,24 @@ void main() { } }); + // The Dart mapper sends `avatarFallback` for a message whose stored persona + // no longer resolves (deleted, or never given a picture). The renderer must + // draw the initial letter for it instead of falling through to the active + // persona's avatar. + test('message renderer pins the avatar of a stored persona', () { + expect( + rendererMessageJs, + contains('const pinnedAvatar = !!(m.avatarUrl || m.avatarFallback)'), + ); + expect( + rendererMessageJs, + contains( + 'if (messageData.avatarUrl || messageData.avatarFallback) ' + "section.dataset.avatarPinned = '1'", + ), + ); + }); + test('legacy renderer shim points at active module entrypoint', () { expect(_asset('renderer.js'), contains('renderer/index.js')); }); diff --git a/test/webview_js/specs/message_persona.spec.js b/test/webview_js/specs/message_persona.spec.js new file mode 100644 index 00000000..171fddbc --- /dev/null +++ b/test/webview_js/specs/message_persona.spec.js @@ -0,0 +1,204 @@ +// A user message records the persona it was sent as (`personaId` + the name), +// and the page renders that persona rather than whichever one is active now. +// +// The failure these protect against is a chat that rewrites its own history: +// switch persona and every message you ever sent suddenly claims to be from +// the new one — and, once a persona is deleted, wears a stranger's face while +// still carrying the right name. +import { test, expect } from '@playwright/test'; + +const PAGE = '/assets/chat_webview/index.html'; + +// 1x1 transparent PNGs — the renderer only ever sets `src`, so the bytes are +// irrelevant; what matters is which of the two ends up on the message. +const RED = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; +const BLUE = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + +async function boot(page) { + await page.goto(PAGE); + await page.waitForFunction(() => !!window.bridge); + await page.evaluate(() => { + window.__M = (id, role, text, extra = {}) => + JSON.stringify({ + id, + role, + text, + timestamp: 1767225600000, + isUser: role === 'user', + isAssistant: role !== 'user', + isSystem: false, + displayName: role === 'user' ? 'You' : 'Alice', + isError: false, + isHidden: false, + isGenerating: false, + isPostGenRunning: false, + ...extra, + }); + }); +} + +/// The identity Flutter pushes: the character Alice, and the persona the +/// reader is writing as right now — "Nyx" with the red avatar unless the test +/// switches to somebody else. +async function activeIdentity(page, personaAvatarUrl, personaName = 'Nyx') { + await page.evaluate( + ([url, name]) => { + window.bridge.setIdentity({ + charName: 'Alice', + personaName: name, + charAvatarUrl: null, + personaAvatarUrl: url, + }); + }, + [personaAvatarUrl, personaName], + ); +} + +/// What the header of message [id] shows: its name and its avatar, where a +/// null `src` means the letter fallback was drawn instead of an image. +const header = (page, id) => + page.evaluate((messageId) => { + const section = document.querySelector( + `.message-section[data-message-id="${messageId}"]`, + ); + const avatar = section.querySelector('.msg-avatar'); + const img = avatar.querySelector('img'); + return { + name: section.querySelector('.msg-name-label').textContent, + src: img ? img.getAttribute('src') : null, + letter: img ? null : avatar.textContent, + }; + }, id); + +test('a message keeps the avatar of the persona it was sent as', async ({ + page, +}) => { + await boot(page); + await activeIdentity(page, RED); + await page.evaluate( + ([blue]) => { + window.bridge.setMessages( + JSON.stringify([ + JSON.parse( + window.__M('u1', 'user', 'hi', { + displayName: 'Mara', + personaId: 'p-mara', + personaName: 'Mara', + avatarUrl: blue, + }), + ), + ]), + ); + }, + [BLUE], + ); + + expect(await header(page, 'u1')).toEqual({ + name: 'Mara', + src: BLUE, + letter: null, + }); +}); + +test('switching persona does not repaint the messages already sent', async ({ + page, +}) => { + await boot(page); + await activeIdentity(page, RED); + await page.evaluate( + ([blue]) => { + window.bridge.setMessages( + JSON.stringify([ + JSON.parse( + window.__M('u1', 'user', 'hi', { + displayName: 'Mara', + personaId: 'p-mara', + personaName: 'Mara', + avatarUrl: blue, + }), + ), + ]), + ); + }, + [BLUE], + ); + + // The reader switches to another persona, with another picture. Only the + // active identity changed — the message was sent as Mara and stays hers. + await activeIdentity(page, RED, 'Kai'); + + expect(await header(page, 'u1')).toEqual({ + name: 'Mara', + src: BLUE, + letter: null, + }); +}); + +test('a deleted persona keeps its name and falls back to its letter', async ({ + page, +}) => { + await boot(page); + await activeIdentity(page, RED); + // `avatarFallback` is what Dart sends when the stored `personaId` no longer + // resolves — the persona was deleted, or never had a picture. + await page.evaluate(() => { + window.bridge.setMessages( + JSON.stringify([ + JSON.parse( + window.__M('u1', 'user', 'hi', { + displayName: 'Mara', + personaId: 'p-mara', + personaName: 'Mara', + avatarFallback: true, + }), + ), + ]), + ); + }); + + expect(await header(page, 'u1')).toEqual({ + name: 'Mara', + src: null, + letter: 'M', + }); + + // An identity push must not hand the deleted persona the active one's face. + await activeIdentity(page, RED, 'Kai'); + + expect(await header(page, 'u1')).toEqual({ + name: 'Mara', + src: null, + letter: 'M', + }); +}); + +test('a message with no stored persona still follows the active one', async ({ + page, +}) => { + await boot(page); + await activeIdentity(page, RED); + await page.evaluate(() => { + window.bridge.setMessages( + JSON.stringify([JSON.parse(window.__M('u1', 'user', 'hi'))]), + ); + }); + + // Chats written before messages carried a persona: the active identity is + // all there is to go on, and it must still reach them — the avatar on the + // first render, and the name on the next identity push. + expect(await header(page, 'u1')).toEqual({ + name: 'You', + src: RED, + letter: null, + }); + + await activeIdentity(page, RED); + + expect(await header(page, 'u1')).toEqual({ + name: 'Nyx', + src: RED, + letter: null, + }); +}); From 5c16a7c54700f6d99c37a8fbc128a5820e3caf09 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 21:28:35 +0000 Subject: [PATCH 2/2] feat(chat): export each user message under the persona that sent it - `exportChatAsJsonl` writes `msg.personaName` into the SillyTavern `name` field of a user message, falling back to the caller's `userName` only for messages that carry no persona. - `ChatActionsService.exportChat` passes the chat's effective persona as that fallback, so legacy messages are no longer all named the literal "User". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018SXpk5JW6Ext697GbCmZvr --- lib/core/services/chat_import_export.dart | 10 +- lib/features/chat/chat_actions_service.dart | 13 +++ test/chat_export_persona_name_test.dart | 103 ++++++++++++++++++++ 3 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 test/chat_export_persona_name_test.dart diff --git a/lib/core/services/chat_import_export.dart b/lib/core/services/chat_import_export.dart index 61293550..8efbd254 100644 --- a/lib/core/services/chat_import_export.dart +++ b/lib/core/services/chat_import_export.dart @@ -38,7 +38,15 @@ Future exportChatAsJsonl({ for (final msg in session.messages) { if (msg.isHidden) continue; final isUser = msg.role == 'user'; - final name = isUser ? userName : character.name; + // A user message names the persona it was actually sent as; [userName] is + // only the fallback for the ones that carry none (chats written before + // messages stored a persona, or an import that had nothing to store). + final messagePersona = msg.personaName?.trim(); + final name = isUser + ? (messagePersona == null || messagePersona.isEmpty + ? userName + : messagePersona) + : character.name; final stMsg = { 'name': name, diff --git a/lib/features/chat/chat_actions_service.dart b/lib/features/chat/chat_actions_service.dart index efd66e93..c1cc950f 100644 --- a/lib/features/chat/chat_actions_service.dart +++ b/lib/features/chat/chat_actions_service.dart @@ -9,6 +9,7 @@ import '../../core/models/chat_message.dart'; import '../../core/services/chat_import_export.dart'; import '../../core/services/file_export_service.dart'; import '../../core/state/db_provider.dart'; +import '../../core/state/persona_resolution.dart'; import '../../core/utils/time_helpers.dart'; import '../../shared/widgets/glaze_error_dialog.dart'; import '../../shared/widgets/glaze_toast.dart'; @@ -63,10 +64,22 @@ class ChatActionsService { final outputDir = await getTemporaryDirectory(); + // The fallback name for user messages that carry no persona of their own. + // The active persona is the closest thing to who sent them; without it the + // export named every one of them the literal "User". + final persona = _ref.read( + effectivePersonaForChatProvider(( + charId: charId, + sessionId: chatState.session!.id, + )), + ); + final personaName = persona?.name.trim() ?? ''; + final result = await exportChatAsJsonl( session: chatState.session!, character: character, outputDir: outputDir.path, + userName: personaName.isEmpty ? 'User' : personaName, ); final filename = p.basename(result.filePath); diff --git a/test/chat_export_persona_name_test.dart b/test/chat_export_persona_name_test.dart new file mode 100644 index 00000000..630866f2 --- /dev/null +++ b/test/chat_export_persona_name_test.dart @@ -0,0 +1,103 @@ +// A user message names the persona it was sent as when the chat is exported. +// +// The SillyTavern JSONL format puts the sender in each line's `name`, and the +// export used to write one name for every user message — the literal "User", +// since nothing ever passed a persona in. Now that a message stores the persona +// it was sent as, the export writes that, and falls back to the caller's name +// only for the messages that carry none. + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:glaze_flutter/core/models/character.dart'; +import 'package:glaze_flutter/core/models/chat_message.dart'; +import 'package:glaze_flutter/core/services/chat_import_export.dart'; + +void main() { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('glaze_chat_export_test'); + }); + + tearDown(() async { + if (tempDir.existsSync()) await tempDir.delete(recursive: true); + }); + + /// Every exported line after the metadata header, decoded. + Future>> exportMessages( + List messages, { + String userName = 'User', + }) async { + final result = await exportChatAsJsonl( + session: ChatSession( + id: 's1', + characterId: 'c1', + sessionIndex: 0, + messages: messages, + ), + character: const Character(id: 'c1', name: 'Alice'), + outputDir: tempDir.path, + userName: userName, + ); + final lines = await File(result.filePath).readAsLines(); + return lines + .skip(1) + .map((l) => jsonDecode(l) as Map) + .toList(); + } + + test('a user message is exported under the persona that sent it', () async { + final exported = await exportMessages([ + const ChatMessage( + id: 'u1', + role: 'user', + content: 'привет', + personaId: 'p-mara', + personaName: 'Mara', + ), + const ChatMessage(id: 'a1', role: 'assistant', content: 'hi'), + ]); + + expect(exported[0]['name'], 'Mara'); + expect(exported[0]['is_user'], isTrue); + // The reply is still the character's, persona roster or not. + expect(exported[1]['name'], 'Alice'); + }); + + test('personas are exported per message, not per chat', () async { + final exported = await exportMessages([ + const ChatMessage( + id: 'u1', + role: 'user', + content: 'first', + personaId: 'p-mara', + personaName: 'Mara', + ), + const ChatMessage( + id: 'u2', + role: 'user', + content: 'second', + personaId: 'p-kai', + personaName: 'Kai', + ), + ]); + + expect(exported.map((m) => m['name']), ['Mara', 'Kai']); + }); + + test('a message with no persona falls back to the given name', () async { + final exported = await exportMessages([ + const ChatMessage(id: 'u1', role: 'user', content: 'привет'), + const ChatMessage( + id: 'u2', + role: 'user', + content: 'blank', + personaName: ' ', + ), + ], userName: 'Nyx'); + + expect(exported.map((m) => m['name']), ['Nyx', 'Nyx']); + }); +}