fix(stage-pages): sync active card sessions (#2110)

This commit is contained in:
RainbowBird
2026-07-26 17:45:04 +08:00
committed by GitHub
parent 019d3fa15f
commit 20355670a9
15 changed files with 237 additions and 17 deletions
@@ -316,8 +316,10 @@ pages:
activate: Activate
active: Active
active_badge: Currently Active
activation_notice: Switched to {name}. The current conversation now uses this character.
cancel: Cancel action
save: Save changes
save_and_activate: Save and activate
card_not_found: Card not found
character: Character
close: Close
@@ -301,8 +301,10 @@ pages:
activate: Activar
active: Activo
active_badge: Actualmente Activo
activation_notice: Se cambió a {name}. La conversación actual ahora usa este personaje.
cancel: Cancelar acción
save: Guardar cambios
save_and_activate: Guardar y activar
card_not_found: Tarjeta no encontrada
character: Personaje
close: Cerrar
@@ -301,8 +301,10 @@ pages:
activate: Activer
active: Actif
active_badge: Actuellement actif
activation_notice: Passage à {name}. La conversation actuelle utilise maintenant ce personnage.
cancel: Annuler l'action
save: Enregistrer les modifications
save_and_activate: Enregistrer et activer
card_not_found: Carte non trouvée
character: Personnage
close: Fermer
@@ -301,8 +301,10 @@ pages:
activate: 有効化
active: 有効
active_badge: 現在有効
activation_notice: '{name} に切り替えました。現在の会話ではこのキャラクターが使用されます。'
cancel: 操作の取消
save: 変更を保存
save_and_activate: 保存して有効化
card_not_found: カードが見つかりません
character: キャラクター
close: 閉じる
@@ -301,8 +301,10 @@ pages:
activate: 활성화
active: 활성
active_badge: 현재 활성 상태
activation_notice: '{name}(으)로 전환했습니다. 현재 대화에서 이 캐릭터를 사용합니다.'
cancel: 작업 취소
save: 변경 사항 저장
save_and_activate: 저장 및 활성화
card_not_found: 카드를 찾을 수 없음
character: 캐릭터
close: 닫기
@@ -301,8 +301,10 @@ pages:
activate: Активировать
active: Активно
active_badge: Текущая
activation_notice: Выбран персонаж {name}. Текущий разговор теперь использует его.
cancel: Отменить действие
save: Сохранить изменения
save_and_activate: Сохранить и активировать
card_not_found: Карта не найдена
character: Персонаж
close: Закрыть
@@ -301,8 +301,10 @@ pages:
activate: Kích hoạt
active: Đang hoạt động
active_badge: Hiện đang hoạt động
activation_notice: Đã chuyển sang {name}. Cuộc trò chuyện hiện tại giờ sử dụng nhân vật này.
cancel: Hủy thao tác
save: Lưu thay đổi
save_and_activate: Lưu và kích hoạt
card_not_found: Không tìm thấy thẻ
character: Nhân vật
close: Đóng
@@ -301,8 +301,10 @@ pages:
activate: 激活
active: 已激活
active_badge: 当前使用中
activation_notice: 已切换到 {name},当前会话将使用该角色。
cancel: 取消操作
save: 保存更改
save_and_activate: 保存并激活
card_not_found: 未找到角色卡
character: 角色设定
close: 关闭
@@ -301,8 +301,10 @@ pages:
activate: 啟用
active: 已啟用
active_badge: 目前使用中
activation_notice: 已切換至 {name},目前對話將使用此角色。
cancel: 取消操作
save: 儲存變更
save_and_activate: 儲存並啟用
card_not_found: 找不到角色卡
character: 角色設定
close: 關閉
@@ -77,6 +77,7 @@ const { activeProvider: defaultArtistryProvider } = storeToRefs(artistryStore)
// Determine if we're in edit mode
const isEditMode = computed(() => !!props.cardId)
const isEditingActiveCard = computed(() => isEditMode.value && props.cardId === cardStore.activeCardId)
// Modules configuration
const selectedConsciousnessProvider = ref<string>('')
@@ -303,7 +304,7 @@ watch(() => props.modelValue, (isOpen) => {
const showError = ref<boolean>(false)
const errorMessage = ref<string>('')
function saveCard(card: Card): boolean {
function saveCard(card: Card, activate: boolean): boolean {
const draftResult = safeParseAiriCardDraft(toRaw(card), selectedArtistryConfigStr.value)
if (!draftResult.success) {
showError.value = true
@@ -350,18 +351,24 @@ function saveCard(card: Card): boolean {
} as AiriExtension,
},
}
let savedCardId: string
if (isEditMode.value && props.cardId) {
// Edit mode: update existing card
cardStore.updateCard(props.cardId, cardWithModules)
if (!cardStore.updateCard(props.cardId, cardWithModules)) {
showError.value = true
errorMessage.value = t('settings.pages.card.card_not_found')
return false
}
savedCardId = props.cardId
trackCardEdited({ card_id: props.cardId })
}
else {
const newCardId = cardStore.addCard(cardWithModules, 'scratch')
// A new card becomes the runtime profile immediately so Create does not
// appear to succeed while conversations continue using the previous card.
cardStore.activeCardId = newCardId
savedCardId = cardStore.addCard(cardWithModules, 'scratch')
}
if (activate)
cardStore.activeCardId = savedCardId
modelValue.value = false // Close this
return true
}
@@ -690,11 +697,19 @@ function getDefaultPlaceholder(defaultValue: string | undefined): string {
@click="modelValue = false"
/>
<Button
variant="primary"
:variant="isEditingActiveCard ? 'primary' : 'secondary'"
icon="i-solar:check-circle-bold-duotone"
:label="isEditMode ? t('settings.pages.card.save') : t('settings.pages.card.creation.create')"
:label="t('settings.pages.card.save')"
:disabled="false"
@click="saveCard(card)"
@click="saveCard(card, false)"
/>
<Button
v-if="!isEditingActiveCard"
variant="primary"
icon="i-solar:play-circle-bold-duotone"
:label="t('settings.pages.card.save_and_activate')"
:disabled="false"
@click="saveCard(card, true)"
/>
</div>
</div>
@@ -40,7 +40,7 @@ const isCardCreationDialogOpen = ref(false)
const searchQuery = ref('')
// Sort option
const sortOption = ref('nameAsc')
const sortOption = ref<'nameAsc' | 'nameDesc' | 'recent'>('nameAsc')
const inputFiles = ref<File[]>([])
@@ -96,17 +96,17 @@ const filteredCards = computed<CardItem[]>(() => {
// Sorted filtered cards based on sort option
const sortedFilteredCards = computed<CardItem[]>(() => {
// Create a new array to avoid mutating the source
const sorted = [...filteredCards.value]
if (sortOption.value === 'nameAsc')
return sorted.sort((a, b) => a.name.localeCompare(b.name))
else if (sortOption.value === 'nameDesc')
if (sortOption.value === 'nameDesc')
return sorted.sort((a, b) => b.name.localeCompare(a.name))
else if (sortOption.value === 'recent')
return sorted.sort((a, b) => b.id.localeCompare(a.id))
else
return sorted
// The persisted Map retains insertion order; nanoids are random and cannot
// represent when a card was added.
return sorted.reverse()
})
// Delete confirmation
@@ -157,6 +157,15 @@ function activateCard(id: string) {
activeCardId.value = id
}
watch(activeCardId, (cardId, previousCardId) => {
if (!previousCardId || cardId === previousCardId)
return
const activeCard = cards.value.get(cardId)
if (activeCard)
toast(t('settings.pages.card.activation_notice', { name: activeCard.name }))
})
// Clear editing state when creation/edit dialog closes
watch(isCardCreationDialogOpen, (isOpen) => {
if (!isOpen) {
@@ -294,3 +294,101 @@ describe('chat-session-store · loadSession vs concurrent deleteSession', () =>
expect(store.sessionMetas['sess-1']).toBeUndefined()
})
})
describe('chat-session-store · active card prompt edits', () => {
// ROOT CAUSE:
//
// Editing the active card updates `systemPrompt`, but the session store only
// used that value when creating or resetting a session. The current
// conversation therefore kept sending its stale system message until the
// user manually started a new session.
//
// We fix this by replacing only the current character session's system
// message when its resolved card prompt changes, while preserving the
// message identity and conversation history.
// https://github.com/moeru-ai/airi/issues/1995
it('updates the current session system message for Issue #1995 without clearing its history', async () => {
systemPromptRef.value = 'Original character prompt'
const store = useChatSessionStore()
await store.initialize()
const sessionId = store.activeSessionId
const originalSystemMessage = store.messages[0]
store.appendSessionMessage(sessionId, {
role: 'user',
content: 'Keep this turn.',
id: 'user-message',
createdAt: 2,
})
systemPromptRef.value = 'Updated character prompt'
await nextTick()
expect(store.messages).toHaveLength(2)
expect(store.messages[0]?.role).toBe('system')
expect(store.messages[0]?.id).toBe(originalSystemMessage?.id)
expect(store.messages[0]?.createdAt).toBe(originalSystemMessage?.createdAt)
expect(store.messages[0]?.content).toContain('Updated character prompt')
expect(store.messages[0]?.content).not.toContain('Original character prompt')
expect(store.messages[1]?.content).toBe('Keep this turn.')
})
// https://github.com/moeru-ai/airi/issues/1995
it('hydrates a persisted Issue #1995 session before refreshing its system message', async () => {
const meta: ChatSessionMeta = {
sessionId: 'persisted-session',
userId: 'local',
characterId: 'default',
createdAt: 1,
updatedAt: 1,
}
getIndexMock.mockResolvedValue({
userId: 'local',
characters: {
default: {
activeSessionId: meta.sessionId,
sessions: { [meta.sessionId]: meta },
},
},
})
let resolveStoredSession: ((record: ChatSessionRecord) => void) | undefined
getSessionMock.mockImplementation(() => new Promise<ChatSessionRecord | null>((resolve) => {
resolveStoredSession = resolve
}))
systemPromptRef.value = 'Updated persisted prompt'
const store = useChatSessionStore()
const initializePromise = store.initialize()
await flushMicrotasks()
// Updating the active session id must not persist a fresh system message
// over history that has not finished loading from IndexedDB.
expect(store.sessionMessages[meta.sessionId]).toBeUndefined()
resolveStoredSession?.({
meta,
messages: [
{
role: 'system',
content: 'Stale persisted prompt',
id: 'system-message',
createdAt: 1,
},
{
role: 'user',
content: 'Persisted history',
id: 'user-message',
createdAt: 2,
},
],
})
await initializePromise
await nextTick()
expect(store.messages).toHaveLength(2)
expect(store.messages[0]?.id).toBe('system-message')
expect(store.messages[0]?.content).toContain('Updated persisted prompt')
expect(store.messages[1]?.content).toBe('Persisted history')
})
})
@@ -178,6 +178,39 @@ export const useChatSessionStore = defineStore('chat-session', () => {
return generateInitialMessageFromPrompt(systemPrompt.value)
}
function refreshActiveSessionSystemMessage() {
const sessionId = activeSessionId.value
const meta = sessionMetas.value[sessionId]
// A card switch updates `systemPrompt` before its character session has
// necessarily finished loading. Never rewrite the previous character's
// session or persist an empty in-memory placeholder over an IDB history
// that is still being hydrated.
if (!sessionId || !loadedSessions.has(sessionId) || meta?.characterId !== getCurrentCharacterId())
return
const currentMessages = sessionMessages.value[sessionId] ?? []
const systemMessageIndex = currentMessages.findIndex(message => message.role === 'system')
const currentSystemMessage = currentMessages[systemMessageIndex]
const resolvedSystemMessage = generateInitialMessage()
if (currentSystemMessage?.content === resolvedSystemMessage.content)
return
if (currentSystemMessage) {
const nextMessages = [...currentMessages]
nextMessages[systemMessageIndex] = {
...currentSystemMessage,
role: 'system',
content: resolvedSystemMessage.content,
}
replaceSessionMessages(sessionId, nextMessages)
return
}
replaceSessionMessages(sessionId, [resolvedSystemMessage, ...currentMessages])
}
function ensureGeneration(sessionId: string) {
if (sessionGenerations.value[sessionId] === undefined)
sessionGenerations.value[sessionId] = 0
@@ -322,6 +355,8 @@ export const useChatSessionStore = defineStore('chat-session', () => {
await persistSession(sessionId)
}
loadedSessions.add(sessionId)
if (activeSessionId.value === sessionId)
refreshActiveSessionSystemMessage()
// Cloud gap fill: when the session is mapped to a cloud chat, ask
// the server for everything past our highest known seq. Best
@@ -1386,6 +1421,11 @@ export const useChatSessionStore = defineStore('chat-session', () => {
void ensureActiveSessionForCharacter()
})
// Keep the active conversation aligned with edits to the active card. The
// active session id is included because card switching resolves the target
// session asynchronously after the card prompt itself has already changed.
watch([systemPrompt, activeSessionId], refreshActiveSessionSystemMessage)
// Auth toggles drive cloud WS lifecycle independently of activeCardId so
// a card swap inside a single session does not bounce the socket. The
// critical invariant: when the auth user changes, every piece of in-memory
@@ -120,4 +120,31 @@ describe('airi-card store', () => {
voice_id: 'aria',
})
})
it('falls back to the default card when the active custom card is deleted', () => {
const cardStore = useAiriCardStore()
cardStore.initialize()
const cardId = cardStore.addCard({
name: 'Custom card',
version: '1.0.0',
description: 'A removable card.',
}, 'scratch')
cardStore.activeCardId = cardId
cardStore.removeCard(cardId)
expect(cardStore.cards.has(cardId)).toBe(false)
expect(cardStore.activeCardId).toBe('default')
expect(cardStore.activeCard?.name).toBe('ReLU')
})
it('keeps the built-in fallback card when deletion is requested directly', () => {
const cardStore = useAiriCardStore()
cardStore.initialize()
expect(cardStore.removeCard('default')).toBe(false)
expect(cardStore.cards.has('default')).toBe(true)
expect(cardStore.activeCardId).toBe('default')
})
})
@@ -129,8 +129,21 @@ export const useAiriCardStore = defineStore('airi-card', () => {
}
const removeCard = (id: string) => {
cards.value.delete(id)
// The built-in card is the guaranteed fallback for every runtime profile.
if (id === 'default')
return false
const removed = cards.value.delete(id)
if (!removed)
return false
// The active id is persisted independently from the card map. Reset it
// before consumers observe a dangling runtime profile after deletion.
if (activeCardId.value === id)
activeCardId.value = 'default'
capturePosthogEvent('character_deleted', { character_id: id })
return true
}
const updateCard = (id: string, updates: AiriCard | Card | ccv3.CharacterCardV3) => {