fix(stage-pages): sync active card sessions (#2110)
This commit is contained in:
@@ -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: 關閉
|
||||
|
||||
+24
-9
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user