Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions assets/chat_webview/bridge.legacy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 8 additions & 2 deletions assets/chat_webview/bridge/chat_bridge_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 13 additions & 2 deletions assets/chat_webview/renderer/message_renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
24 changes: 24 additions & 0 deletions docs/rules/message-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion lib/core/services/chat_import_export.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,15 @@ Future<FileExportResult> 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 = <String, dynamic>{
'name': name,
Expand Down
30 changes: 30 additions & 0 deletions lib/features/chat/bridge/chat_bridge_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ class ChatBridgeController {
final Map<String, String> _blockStatusByMessageId = {};
final Map<String, List<TriggeredEntry>> _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<String, PersonaIdentity> _personasById = const {};

List<PresetRegex> _displayRegexes = [];
Character? _regexCharacter;
Persona? _regexPersona;
Expand Down Expand Up @@ -154,6 +161,7 @@ class ChatBridgeController {
isGenerating: isGenerating,
isPostGenRunning: isPostGenRunning,
isSendPending: isSendPending,
personasById: _personasById,
coveredMemoryIds: _coveredMemoryIds,
pendingMemoryIds: _pendingMemoryIds,
draftMemoryIds: _draftMemoryIds,
Expand All @@ -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<Persona> 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<PresetRegex> regexes,
Character? char,
Expand Down
57 changes: 55 additions & 2 deletions lib/features/chat/bridge/chat_message_mapper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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<String, PersonaIdentity> personasById,
@Default({}) Set<String> coveredMemoryIds,
@Default({}) Set<String> pendingMemoryIds,
@Default({}) Set<String> draftMemoryIds,
Expand Down Expand Up @@ -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';
}
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions lib/features/chat/chat_actions_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions lib/features/chat/chat_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -482,13 +482,25 @@ class ChatNotifier extends AsyncNotifier<ChatState> {
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',
content: text,
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 —
Expand Down
Loading
Loading