feat(stage-tamagotchi,stage-ui,ui): input should not flink, added retry for errored item, adjusted button

This commit is contained in:
Neko Ayaka
2026-04-23 01:09:46 +08:00
parent ee86608195
commit a3249ccd68
27 changed files with 717 additions and 62 deletions
@@ -158,6 +158,13 @@ const historyMessages = computed(() => messages.value as unknown as ChatHistoryI
async function handleDeleteMessage(index: number) {
await chatSyncStore.requestDeleteMessage({ index })
}
async function handleRetryMessage(index: number) {
await chatSyncStore.requestRetry({
sessionId: chatSession.activeSessionId,
index,
})
}
</script>
<template>
@@ -168,6 +175,7 @@ async function handleDeleteMessage(index: number) {
:sending="sending"
:streaming-message="streamingMessage"
@delete-message="handleDeleteMessage($event.index)"
@retry-message="handleRetryMessage($event.index)"
/>
</div>
<div
@@ -210,9 +218,13 @@ async function handleDeleteMessage(index: number) {
side="top"
:side-offset="8"
:class="[
'z-50 min-w-[180px] rounded-lg border border-primary-200 bg-white p-1 shadow-xl',
'dark:border-primary-700 dark:bg-neutral-800',
'z-50 min-w-[180px] rounded-xl p-1 shadow',
'bg-white dark:bg-neutral-800',
'flex flex-col gap-1',
'data-[side=top]:animate-slideDownAndFade',
'data-[side=left]:animate-none',
'data-[side=bottom]:animate-none',
'data-[side=right]:animate-none',
]"
>
<DropdownMenuItem
@@ -220,7 +232,7 @@ async function handleDeleteMessage(index: number) {
:key="mode"
:class="[
'w-full flex cursor-pointer items-center rounded-md px-3 py-2 text-left text-xs outline-none transition-colors',
'hover:bg-primary-100 dark:hover:bg-primary-900/50',
'hover:bg-primary-50 dark:hover:bg-primary-900/20',
sendMode === mode ? 'bg-primary-50 text-primary-600 font-semibold dark:bg-primary-900/20 dark:text-primary-300' : 'text-neutral-500',
]"
@select="sendMode = mode"
@@ -66,6 +66,7 @@ interface MockState {
activeSessionId: Ref<string>
sessionMessages: Ref<Record<string, Array<{ role: string, content: string }>>>
sessionMetas: Ref<Record<string, unknown>>
applyRemoteSnapshot: ReturnType<typeof vi.fn>
setSessionMessages: ReturnType<typeof vi.fn>
getSessionMessages: ReturnType<typeof vi.fn>
ingest: ReturnType<typeof vi.fn>
@@ -78,7 +79,7 @@ vi.mock('@proj-airi/stage-ui/stores/chat/session-store', () => ({
activeSessionId: mockState.activeSessionId,
sessionMessages: mockState.sessionMessages,
sessionMetas: mockState.sessionMetas,
applyRemoteSnapshot: vi.fn(),
applyRemoteSnapshot: mockState.applyRemoteSnapshot,
getSnapshot: vi.fn(() => ({
activeSessionId: mockState.activeSessionId.value,
sessionMessages: mockState.sessionMessages.value,
@@ -147,6 +148,15 @@ describe('useChatSyncStore authority ingest failures', async () => {
'session-1': [{ role: 'system', content: 'init' }],
})
const sessionMetas = ref<Record<string, unknown>>({})
const applyRemoteSnapshot = vi.fn((snapshot: {
activeSessionId: string
sessionMessages: Record<string, Array<{ role: string, content: string }>>
sessionMetas: Record<string, unknown>
}) => {
activeSessionId.value = snapshot.activeSessionId
sessionMessages.value = snapshot.sessionMessages
sessionMetas.value = snapshot.sessionMetas
})
const setSessionMessages = vi.fn((sessionId: string, next: Array<{ role: string, content: string }>) => {
sessionMessages.value[sessionId] = next
@@ -162,6 +172,7 @@ describe('useChatSyncStore authority ingest failures', async () => {
activeSessionId,
sessionMessages,
sessionMetas,
applyRemoteSnapshot,
setSessionMessages,
getSessionMessages,
ingest,
@@ -211,4 +222,148 @@ describe('useChatSyncStore authority ingest failures', async () => {
peer.close()
store.dispose()
})
/**
* @example
* it('replaces the last failed turn before retrying', async () => {
* // authority receives retry command for trailing user -> error pair
* // authoritative session removes that failed turn before re-ingesting the user text
* })
*/
it('replaces the last failed turn before retrying', async () => {
mockState.sessionMessages.value['session-1'] = [
{ role: 'system', content: 'init' },
{ role: 'user', content: 'hello-1' },
{ role: 'assistant', content: 'answer-1' },
{ role: 'user', content: 'hello' },
{ role: 'error', content: 'Remote sent 400 response' },
{ role: 'user', content: 'hello-3' },
{ role: 'assistant', content: 'answer-3' },
]
mockState.ingest.mockResolvedValueOnce(undefined)
const store = useChatSyncStore()
store.initialize('authority')
const peer = new MockBroadcastChannel('airi:stage-tamagotchi:chat-sync')
peer.postMessage({
type: 'command',
requestId: 'req-2',
senderId: 'peer',
command: 'retry',
payload: {
sessionId: 'session-1',
index: 4,
},
})
await vi.waitFor(() => {
expect(mockState.setSessionMessages).toHaveBeenCalledWith('session-1', [
{ role: 'system', content: 'init' },
{ role: 'user', content: 'hello-1' },
{ role: 'assistant', content: 'answer-1' },
])
expect(mockState.ingest).toHaveBeenCalledWith('hello', expect.any(Object), 'session-1')
})
const persistedMessages = mockState.sessionMessages.value['session-1']
expect(persistedMessages).toEqual([
{ role: 'system', content: 'init' },
{ role: 'user', content: 'hello-1' },
{ role: 'assistant', content: 'answer-1' },
])
peer.close()
store.dispose()
})
/**
* @example
* it('rewinds from the source user turn when retry targets an assistant message', async () => {
* // future assistant retry still trims the whole tail from its originating user turn
* })
*/
it('rewinds from the source user turn when retry targets an assistant message', async () => {
mockState.sessionMessages.value['session-1'] = [
{ role: 'system', content: 'init' },
{ role: 'user', content: 'hello-1' },
{ role: 'assistant', content: 'answer-1' },
{ role: 'user', content: 'hello-2' },
{ role: 'assistant', content: 'answer-2' },
{ role: 'user', content: 'hello-3' },
]
mockState.ingest.mockResolvedValueOnce(undefined)
const store = useChatSyncStore()
store.initialize('authority')
const peer = new MockBroadcastChannel('airi:stage-tamagotchi:chat-sync')
peer.postMessage({
type: 'command',
requestId: 'req-3',
senderId: 'peer',
command: 'retry',
payload: {
sessionId: 'session-1',
index: 4,
},
})
await vi.waitFor(() => {
expect(mockState.setSessionMessages).toHaveBeenCalledWith('session-1', [
{ role: 'system', content: 'init' },
{ role: 'user', content: 'hello-1' },
{ role: 'assistant', content: 'answer-1' },
])
expect(mockState.ingest).toHaveBeenCalledWith('hello-2', expect.any(Object), 'session-1')
})
peer.close()
store.dispose()
})
/**
* @example
* it('keeps the follower chat window on its local session while applying remote snapshots', async () => {
* // follower already displays session-2
* // authority snapshot arrives with session-1 as active
* // follower keeps session-2 selected but still receives session-2 message updates
* })
*/
it('keeps the follower chat window on its local session while applying remote snapshots', async () => {
mockState.activeSessionId.value = 'session-2'
mockState.sessionMessages.value = {
'session-2': [{ role: 'system', content: 'chat-window' }],
}
const store = useChatSyncStore()
store.initialize('follower')
const authority = new MockBroadcastChannel('airi:stage-tamagotchi:chat-sync')
authority.postMessage({
type: 'session-snapshot',
authorityId: 'authority',
snapshot: {
activeSessionId: 'session-1',
sessionMessages: {
'session-1': [{ role: 'system', content: 'main-window' }],
'session-2': [{ role: 'system', content: 'chat-window' }, { role: 'user', content: 'retry me' }],
},
sessionMetas: {},
},
})
await vi.waitFor(() => {
expect(mockState.applyRemoteSnapshot).toHaveBeenCalledTimes(1)
})
expect(mockState.activeSessionId.value).toBe('session-2')
expect(mockState.sessionMessages.value['session-2']).toEqual([
{ role: 'system', content: 'chat-window' },
{ role: 'user', content: 'retry me' },
])
authority.close()
store.dispose()
})
})
@@ -44,12 +44,18 @@ interface IngestCommandPayload {
toolset?: ToolsetId
}
interface RetryCommandPayload {
sessionId?: string
index: number
}
type ChatSyncMessage
= | { type: 'authority-announcement', authorityId: string, sentAt: number }
| { type: 'request-snapshot', requestId: string, senderId: string }
| { type: 'session-snapshot', authorityId: string, snapshot: SessionSnapshotPayload }
| { type: 'stream-snapshot', authorityId: string, snapshot: StreamSnapshotPayload }
| { type: 'command', authorityId?: string, requestId: string, senderId: string, command: 'ingest', payload: IngestCommandPayload }
| { type: 'command', authorityId?: string, requestId: string, senderId: string, command: 'retry', payload: RetryCommandPayload }
| { type: 'command', authorityId?: string, requestId: string, senderId: string, command: 'cleanup', payload: { sessionId?: string } }
| { type: 'command', authorityId?: string, requestId: string, senderId: string, command: 'delete-message', payload: { sessionId?: string, messageId?: string, index?: number } }
| { type: 'response', requestId: string, authorityId: string, ok: boolean, error?: string }
@@ -68,6 +74,50 @@ function createRequestId() {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
}
function getRetryText(message: ChatHistoryItem | undefined): string | null {
if (!message || message.role !== 'user')
return null
if (typeof message.content === 'string') {
const text = message.content.trim()
return text || null
}
if (!Array.isArray(message.content))
return null
const text = message.content.reduce<string[]>((texts, part) => {
if (part.type !== 'text')
return texts
const value = part.text?.trim()
if (value)
texts.push(value)
return texts
}, []).join('\n\n')
return text || null
}
function resolveRetrySourceIndex(messages: ChatHistoryItem[], index: number): number {
const targetMessage = messages[index]
if (!targetMessage)
return -1
if (targetMessage.role === 'user')
return index
if (targetMessage.role === 'assistant' || targetMessage.role === 'error') {
for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
if (messages[cursor]?.role === 'user')
return cursor
}
}
return -1
}
export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () => {
const instanceId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
const mode = ref<ChatSyncMode>('inactive')
@@ -169,7 +219,17 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
}
function applySessionSnapshot(snapshot: SessionSnapshotPayload) {
chatSession.applyRemoteSnapshot(snapshot)
const localActiveSessionId = activeSessionId.value
const shouldPreserveLocalActiveSession = mode.value === 'follower'
&& !!localActiveSessionId
&& !!snapshot.sessionMessages[localActiveSessionId]
chatSession.applyRemoteSnapshot({
...snapshot,
activeSessionId: shouldPreserveLocalActiveSession
? localActiveSessionId
: snapshot.activeSessionId,
})
}
function applyStreamSnapshot(snapshot: StreamSnapshotPayload) {
@@ -216,6 +276,27 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
}, payload.sessionId)
}
async function executeRetry(payload: RetryCommandPayload) {
const sessionId = payload.sessionId || chatSession.activeSessionId
const currentMessages = chatSession.getSessionMessages(sessionId)
const sourceIndex = resolveRetrySourceIndex(currentMessages, payload.index)
if (sourceIndex < 0)
throw new Error('Retry target has no retriable source message')
const text = getRetryText(currentMessages[sourceIndex])
if (!text)
throw new Error('Retry target has no retriable user message')
const nextMessages = currentMessages.slice(0, sourceIndex)
chatSession.setSessionMessages(sessionId, nextMessages)
await executeIngest({
text,
sessionId,
toolset: 'widgets',
})
}
function executeDeleteMessage(payload: { sessionId?: string, messageId?: string, index?: number }) {
const sessionId = payload.sessionId || chatSession.activeSessionId
const nextMessages = chatSession.getSessionMessages(sessionId).filter((message, index) => {
@@ -260,6 +341,9 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
case 'ingest':
await executeIngest(message.payload)
break
case 'retry':
await executeRetry(message.payload)
break
case 'cleanup':
cleanupMessages(message.payload.sessionId)
break
@@ -402,6 +486,21 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
})
}
async function requestRetry(payload: RetryCommandPayload) {
if (mode.value === 'authority') {
await executeRetry(payload)
return
}
return await dispatchCommand({
type: 'command',
requestId: createRequestId(),
senderId: instanceId,
command: 'retry',
payload,
})
}
async function requestCleanup(sessionId?: string) {
if (mode.value === 'authority') {
cleanupMessages(sessionId)
@@ -447,6 +546,7 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
initialize,
dispose,
requestIngest,
requestRetry,
requestCleanup,
requestDeleteMessage,
}
+2
View File
@@ -56,6 +56,7 @@
"@unocss/eslint-plugin": "^66.6.8",
"@unocss/preset-mini": "^66.6.8",
"@unocss/preset-web-fonts": "^66.6.8",
"@vitest/browser-playwright": "catalog:vitest",
"@vitest/coverage-v8": "catalog:vitest",
"bumpp": "^11.0.1",
"eslint": "^10.2.1",
@@ -85,6 +86,7 @@
"vite": "^8.0.8",
"vite-plugin-inspect": "catalog:",
"vitest": "^4.1.4",
"vitest-browser-vue": "catalog:",
"yaml": "^2.8.3"
},
"workspaces": [
@@ -10,9 +10,5 @@
"dependencies": {
"@moeru/std": "catalog:",
"uncrypto": "catalog:"
},
"devDependencies": {
"@vitest/browser-playwright": "catalog:vitest",
"vitest": "catalog:vitest"
}
}
+2
View File
@@ -1,4 +1,6 @@
chat:
actions:
retry: Retry
message:
character-name:
airi: AIRI
+2
View File
@@ -1,4 +1,6 @@
chat:
actions:
retry: Volver a intentar
message:
character-name:
airi: AIRI
+2
View File
@@ -1,4 +1,6 @@
chat:
actions:
retry: Réessayer
message:
character-name:
airi: AIRI
+2
View File
@@ -1,4 +1,6 @@
chat:
actions:
retry: 再試行
message:
character-name:
airi: AIRI
+2
View File
@@ -1,4 +1,6 @@
chat:
actions:
retry: 다시 시도
message:
character-name:
airi: AIRI
+2
View File
@@ -1,4 +1,6 @@
chat:
actions:
retry: Повторить
message:
character-name:
airi: AIRI
+2
View File
@@ -1,4 +1,6 @@
chat:
actions:
retry: Thử lại
message:
character-name:
airi: AIRI
@@ -1,4 +1,6 @@
chat:
actions:
retry: 重试
message:
character-name:
airi: AIRI
@@ -1,4 +1,6 @@
chat:
actions:
retry: 重試
message:
character-name:
airi: AIRI
+1
View File
@@ -195,6 +195,7 @@
"unplugin-info": "catalog:",
"unplugin-yaml": "^4.1.0",
"vite": "^6.4.2",
"vitest-browser-vue": "catalog:",
"vue": "catalog:",
"vue-tsc": "^3.2.6"
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { createChatActionMenuItems } from './menu-items'
/**
* @example
* describe('createChatActionMenuItems', () => {
* it('includes retry between copy and delete when retry is available', () => {})
* })
*/
describe('createChatActionMenuItems', () => {
/**
* @example
* it('includes retry between copy and delete when retry is available', () => {
* const items = createChatActionMenuItems({ canCopy: true, canRetry: true, canDelete: true })
* expect(items.map(item => item.action)).toEqual(['copy', 'retry', 'delete'])
* })
*/
it('includes retry between copy and delete when retry is available', () => {
const items = createChatActionMenuItems({
canCopy: true,
canRetry: true,
canDelete: true,
})
expect(items.map(item => item.action)).toEqual(['copy', 'retry', 'delete'])
expect(items[1]?.label).toBe('Retry')
})
/**
* @example
* it('omits retry when retry is unavailable', () => {
* const items = createChatActionMenuItems({ canCopy: true, canRetry: false, canDelete: true })
* expect(items.map(item => item.action)).toEqual(['copy', 'delete'])
* })
*/
it('omits retry when retry is unavailable', () => {
const items = createChatActionMenuItems({
canCopy: true,
canRetry: false,
canDelete: true,
})
expect(items.map(item => item.action)).toEqual(['copy', 'delete'])
})
})
@@ -1,33 +1,3 @@
export type ChatActionMenuAction = 'copy' | 'delete'
export interface ChatActionMenuItem {
action: ChatActionMenuAction
label: string
icon: string
danger?: boolean
}
export function createChatActionMenuItems(options: {
canCopy: boolean
canDelete: boolean
}): ChatActionMenuItem[] {
return [
options.canCopy
? {
action: 'copy',
label: 'Copy',
icon: 'i-solar:copy-bold',
}
: null,
options.canDelete
? {
action: 'delete',
label: 'Delete',
icon: 'i-solar:trash-bin-minimalistic-bold',
danger: true,
}
: null,
].filter(Boolean) as ChatActionMenuItem[]
}
export { default as ChatActionMenu } from './index.vue'
export type { ChatActionMenuAction, ChatActionMenuItem } from './menu-items'
export { createChatActionMenuItems } from './menu-items'
@@ -21,6 +21,7 @@ import {
DropdownMenuTrigger,
} from 'reka-ui'
import { computed, inject, reactive, ref, shallowRef, toRef, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useWebHaptics } from 'web-haptics/vue'
import { createChatActionMenuItems } from '.'
@@ -30,12 +31,14 @@ import { chatScrollContainerKey } from '../../constants'
const props = withDefaults(defineProps<{
canCopy?: boolean
canRetry?: boolean
canDelete?: boolean
copyText?: string
menuLabel?: string
placement?: 'left' | 'right'
}>(), {
canCopy: true,
canRetry: false,
canDelete: true,
copyText: '',
menuLabel: 'Message actions',
@@ -44,6 +47,7 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{
(e: 'copy'): void
(e: 'retry'): void
(e: 'delete'): void
}>()
defineSlots<{
@@ -79,11 +83,14 @@ const bottomSentinelVisible = useElementVisibility(bottomSentinelRef, {
const { trigger } = useWebHaptics()
const { isMobile } = useBreakpoints()
const { t } = useI18n()
const shouldDisableDropdownMenu = computed(() => (isStageWeb() || isStageCapacitor()) && isMobile.value)
const menuItems = computed(() => createChatActionMenuItems({
canCopy: props.canCopy && props.copyText.trim().length > 0,
canRetry: props.canRetry,
canDelete: props.canDelete,
retryLabel: t('stage.chat.actions.retry'),
}))
const hasMenuItems = computed(() => menuItems.value.length > 0)
const forceVisible = computed(() => contextMenuOpen.value)
@@ -143,6 +150,11 @@ async function handleAction(action: ChatActionMenuAction) {
return
}
if (action === 'retry') {
emit('retry')
return
}
emit('delete')
}
@@ -0,0 +1,40 @@
export type ChatActionMenuAction = 'copy' | 'retry' | 'delete'
export interface ChatActionMenuItem {
action: ChatActionMenuAction
label: string
icon: string
danger?: boolean
}
export function createChatActionMenuItems(options: {
canCopy: boolean
canRetry: boolean
canDelete: boolean
retryLabel?: string
}): ChatActionMenuItem[] {
return [
options.canCopy
? {
action: 'copy',
label: 'Copy',
icon: 'i-solar:copy-bold',
}
: null,
options.canRetry
? {
action: 'retry',
label: options.retryLabel ?? 'Retry',
icon: 'i-solar:refresh-bold',
}
: null,
options.canDelete
? {
action: 'delete',
label: 'Delete',
icon: 'i-solar:trash-bin-minimalistic-bold',
danger: true,
}
: null,
].filter(Boolean) as ChatActionMenuItem[]
}
@@ -2,6 +2,7 @@
import type { ChatHistoryItem, ErrorMessage } from '../../../../types/chat'
import { isStageCapacitor, isStageWeb } from '@proj-airi/stage-shared'
import { Button } from '@proj-airi/ui'
import { computed } from 'vue'
import { MarkdownRenderer } from '../../../markdown'
@@ -11,15 +12,19 @@ import { ChatActionMenu } from './action-menu'
const props = withDefaults(defineProps<{
message: ErrorMessage
label: string
retryLabel?: string
canRetry?: boolean
showPlaceholder?: boolean
variant?: 'desktop' | 'mobile'
}>(), {
canRetry: false,
showPlaceholder: false,
variant: 'desktop',
})
const emit = defineEmits<{
(e: 'copy'): void
(e: 'retry'): void
(e: 'delete'): void
}>()
@@ -32,20 +37,30 @@ const copyText = computed(() => getChatHistoryItemCopyText(props.message as Chat
</script>
<template>
<div flex :class="variant === 'mobile' ? 'mr-0' : 'mr-12'">
<div
:class="[
'flex flex-col',
variant === 'mobile' ? 'mr-0' : 'mr-12',
]"
>
<ChatActionMenu
:copy-text="copyText"
:can-delete="!showPlaceholder"
:can-retry="canRetry && !showPlaceholder"
@copy="emit('copy')"
@retry="emit('retry')"
@delete="emit('delete')"
>
<template #default="{ setMeasuredElement }">
<div
:ref="setMeasuredElement"
flex="~ col" shadow="sm violet-200/50 dark:none"
min-w-20 rounded-xl h="unset <sm:fit"
:class="[
boxClasses,
'relative',
'flex flex-col',
'min-w-20 rounded-xl',
'h-unset <sm:h-fit',
'shadow-sm shadow-violet-200/50 dark:shadow-none',
'bg-violet-100/80 dark:bg-violet-950/80',
(isStageWeb() || isStageCapacitor()) && props.variant === 'mobile' ? 'select-none sm:select-auto' : '',
]"
@@ -65,5 +80,19 @@ const copyText = computed(() => getChatHistoryItemCopyText(props.message as Chat
</div>
</template>
</ChatActionMenu>
<div
v-if="canRetry && !showPlaceholder"
:class="[
'self-end mt-1',
]"
>
<Button
size="sm"
variant="ghost"
shape="square"
icon="i-solar:refresh-bold"
@click="emit('retry')"
/>
</div>
</div>
</template>
@@ -0,0 +1,136 @@
import type { ChatHistoryItem } from '../../../../types/chat'
import { describe, expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-vue'
import { defineComponent, shallowRef } from 'vue'
import { createI18n } from 'vue-i18n'
import ChatHistory from './history.vue'
vi.mock('../composables/use-chat-history-scroll', () => ({
useChatHistoryScroll: () => undefined,
}))
vi.mock('../../../markdown', () => ({
MarkdownRenderer: defineComponent({
name: 'MarkdownRendererStub',
props: {
content: {
type: String,
default: '',
},
},
template: '<div>{{ content }}</div>',
}),
}))
function createTestI18n() {
return createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
stage: {
chat: {
actions: {
retry: 'Retry',
},
message: {
'character-name': {
'airi': 'AIRI',
'core-system': 'System',
'you': 'You',
},
},
},
},
},
},
})
}
function createHarness(messages: ChatHistoryItem[]) {
return defineComponent({
name: 'ChatHistoryRetryHarness',
components: {
ChatHistory,
},
setup() {
const lastRetryIndex = shallowRef('none')
function handleRetryMessage(payload: { index: number }) {
lastRetryIndex.value = String(payload.index)
}
return {
handleRetryMessage,
lastRetryIndex,
messages,
}
},
template: `
<div>
<ChatHistory
:messages="messages"
@retry-message="handleRetryMessage"
/>
<output aria-label="retry-index">{{ lastRetryIndex }}</output>
</div>
`,
})
}
/**
* @example
* describe('ChatHistory retry actions', () => {
* it('emits retry-message when the retry button is clicked for an error after a user message', async () => {})
* })
*/
describe('chatHistory retry actions', () => {
/**
* @example
* it('emits retry-message when the retry button is clicked for an error after a user message', async () => {
* const screen = await render(createHarness(messages), { global: { plugins: [createTestI18n()] } })
* await screen.getByRole('button', { name: 'Retry' }).click()
* await expect.element(screen.getByLabelText('retry-index')).toHaveTextContent('1')
* })
*/
it('emits retry-message when the retry button is clicked for an error after a user message', async () => {
const messages: ChatHistoryItem[] = [
{ role: 'user', content: 'hello' },
{ role: 'error', content: 'Remote sent 400 response' },
]
const screen = await render(createHarness(messages), {
global: {
plugins: [createTestI18n()],
},
})
await screen.getByRole('button', { name: 'Retry' }).click()
await expect.element(screen.getByLabelText('retry-index')).toHaveTextContent('1')
})
/**
* @example
* it('does not render the retry button when the error is not preceded by a user message', async () => {
* const screen = await render(createHarness(messages), { global: { plugins: [createTestI18n()] } })
* expect(document.body.textContent).not.toContain('Retry')
* })
*/
it('does not render the retry button when the error is not preceded by a user message', async () => {
const messages: ChatHistoryItem[] = [
{ role: 'assistant', content: 'hello', slices: [], tool_results: [] },
{ role: 'error', content: 'Remote sent 400 response' },
]
await render(createHarness(messages), {
global: {
plugins: [createTestI18n()],
},
})
expect(document.body.textContent).not.toContain('Retry')
})
})
@@ -19,6 +19,7 @@ const props = withDefaults(defineProps<{
assistantLabel?: string
userLabel?: string
errorLabel?: string
retryLabel?: string
variant?: 'desktop' | 'mobile'
}>(), {
sending: false,
@@ -28,6 +29,7 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{
(e: 'copyMessage', payload: { message: ChatHistoryItem, index: number, key: string | number }): void
(e: 'deleteMessage', payload: { message: ChatHistoryItem, index: number, key: string | number }): void
(e: 'retryMessage', payload: { message: ChatHistoryItem, index: number, key: string | number }): void
}>()
const chatHistoryRef = ref<HTMLDivElement>()
@@ -38,6 +40,7 @@ const labels = computed(() => ({
assistant: props.assistantLabel ?? t('stage.chat.message.character-name.airi'),
user: props.userLabel ?? t('stage.chat.message.character-name.you'),
error: props.errorLabel ?? t('stage.chat.message.character-name.core-system'),
retry: props.retryLabel ?? t('stage.chat.actions.retry'),
}))
const streaming = computed<ChatAssistantMessage & { context?: ContextMessage } & { createdAt?: number }>(() => props.streamingMessage ?? { role: 'assistant', content: '', slices: [], tool_results: [], createdAt: Date.now() })
@@ -86,6 +89,14 @@ function emitDeleteMessage(message: ChatHistoryItem, index: number) {
key: getChatHistoryItemKey(message, index),
})
}
function emitRetryMessage(message: ChatHistoryItem, index: number) {
emit('retryMessage', {
message,
index,
key: getChatHistoryItemKey(message, index),
})
}
</script>
<template>
@@ -100,9 +111,12 @@ function emitDeleteMessage(message: ChatHistoryItem, index: number) {
v-if="message.role === 'error'"
:message="message"
:label="labels.error"
:retry-label="labels.retry"
:can-retry="renderMessages[index - 1]?.role === 'user'"
:show-placeholder="sending && index === renderMessages.length - 1"
:variant="variant"
@copy="emitCopyMessage(message, index)"
@retry="emitRetryMessage(message, index)"
@delete="emitDeleteMessage(message, index)"
/>
<ChatAssistantItem
+34 -4
View File
@@ -1,14 +1,44 @@
import { join } from 'node:path'
import { cwd } from 'node:process'
import Vue from '@vitejs/plugin-vue'
import { playwright } from '@vitest/browser-playwright'
import { loadEnv } from 'vite'
import { defineConfig } from 'vitest/config'
export default defineConfig(({ mode }) => {
return ({
return {
test: {
include: ['src/**/*.test.ts'],
env: loadEnv(mode, join(cwd(), 'packages', 'stage-ui'), ''),
projects: [
{
extends: true,
test: {
name: 'node',
include: ['src/**/*.test.ts'],
exclude: ['src/**/*.browser.test.ts'],
env: loadEnv(mode, join(cwd(), 'packages', 'stage-ui'), ''),
},
},
{
extends: true,
plugins: [
Vue(),
],
test: {
name: 'browser',
include: ['**/*.browser.{spec,test}.ts'],
exclude: ['**/node_modules/**'],
browser: {
enabled: true,
provider: playwright(),
instances: [
{ browser: 'chromium' },
],
},
},
},
],
},
})
}
})
@@ -53,7 +53,12 @@ watch(input, () => {
return
}
textareaHeight.value = `${textareaRef.value.scrollHeight}px`
// NOTICE: not sure why 4px is required but if not added, when
// input happened and placeholder now disappeared, the textarea will shrink
// a little bit and cause the input box to shake.
// TODO: find out the root cause and remove this magic number, or at least
// reference a more specific source.
textareaHeight.value = `${textareaRef.value.scrollHeight + 4}px`
})
}, { immediate: true })
</script>
+3 -1
View File
@@ -109,6 +109,7 @@ const variantClasses: Record<ButtonVariant, Record<ButtonTheme, {
'pure': {
default: {
default: [
'rounded-lg',
'bg-transparent',
'text-neutral-900 dark:text-neutral-50',
'!px-0 !py-0',
@@ -118,10 +119,11 @@ const variantClasses: Record<ButtonVariant, Record<ButtonTheme, {
'ghost': {
default: {
default: [
'rounded-lg',
'bg-transparent',
'hover:bg-neutral-100/50 dark:hover:bg-neutral-800/50',
'text-neutral-500 dark:text-neutral-400',
'focus:ring-2 focus:ring-neutral-300/30 dark:focus:ring-neutral-600/30',
'focus:ring-none',
],
},
},
+98 -12
View File
@@ -327,6 +327,9 @@ catalogs:
vite-plugin-mkcert:
specifier: ^2.0.0
version: 2.0.0
vitest-browser-vue:
specifier: ^2.1.0
version: 2.1.0
vue:
specifier: ^3.5.32
version: 3.5.32
@@ -361,9 +364,6 @@ catalogs:
'@vitest/coverage-v8':
specifier: ^4.1.4
version: 4.1.4
vitest:
specifier: ^4.1.4
version: 4.1.4
xsai:
unspeech:
specifier: ^0.1.13
@@ -442,6 +442,9 @@ importers:
'@unocss/preset-web-fonts':
specifier: ^66.6.8
version: 66.6.8
'@vitest/browser-playwright':
specifier: catalog:vitest
version: 4.1.4(bufferutil@4.1.0)(playwright@1.59.1)(utf-8-validate@5.0.10)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.4)
'@vitest/coverage-v8':
specifier: catalog:vitest
version: 4.1.4(@vitest/browser@4.1.4)(vitest@4.1.4)
@@ -529,6 +532,9 @@ importers:
vitest:
specifier: ^4.1.4
version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@27.4.0(bufferutil@4.1.0)(canvas@3.2.3)(utf-8-validate@5.0.10))(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest-browser-vue:
specifier: 'catalog:'
version: 2.1.0(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3))
yaml:
specifier: ^2.8.3
version: 2.8.3
@@ -2373,13 +2379,6 @@ importers:
uncrypto:
specifier: 'catalog:'
version: 0.1.3
devDependencies:
'@vitest/browser-playwright':
specifier: catalog:vitest
version: 4.1.4(bufferutil@4.1.0)(playwright@1.59.1)(utf-8-validate@5.0.10)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.4)
vitest:
specifier: catalog:vitest
version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@27.4.0(bufferutil@4.1.0)(canvas@3.2.3)(utf-8-validate@5.0.10))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
packages/cap-vite:
dependencies:
@@ -3448,6 +3447,9 @@ importers:
vite:
specifier: ^6.4.2
version: 6.4.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitest-browser-vue:
specifier: 'catalog:'
version: 2.1.0(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3))
vue:
specifier: 'catalog:'
version: 3.5.32(typescript@5.9.3)
@@ -6926,6 +6928,9 @@ packages:
'@nxg-org/mineflayer-util-plugin@1.8.4':
resolution: {integrity: sha512-hPaCZxU0Aq+gUSi/l6x7n32hUG6bnDugAMoQXD2dFE/gyNkmRSpmgH5+Y6G41w3H8P3Nl++upGCOlaxvZ7RuoA==}
'@one-ini/wasm@0.1.1':
resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==}
'@opentelemetry/api-logs@0.215.0':
resolution: {integrity: sha512-xrFlqhdhUyO8wSRn6DjE0145/HPWSJ5Nm0C7vWua6TdL/FSEAZvEyvdsa9CRXuxo9ebb7j/NEPhEcO62IJ0qUA==}
engines: {node: '>=8.0.0'}
@@ -10392,6 +10397,9 @@ packages:
'@vue/shared@3.5.32':
resolution: {integrity: sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==}
'@vue/test-utils@2.4.6':
resolution: {integrity: sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==}
'@vue/tsconfig@0.9.1':
resolution: {integrity: sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w==}
peerDependencies:
@@ -10606,6 +10614,10 @@ packages:
'@xsai/utils-chat@0.5.0-beta.2':
resolution: {integrity: sha512-WSjoxY0w+3oRSZQkAWHdhOuidRlaYJR3y6bvELmJje6lCh86a4PQtMIPwJIoTmtuVY13ZgGvvKqoQz/0xS32LA==}
abbrev@2.0.0:
resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
abbrev@3.0.1:
resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==}
engines: {node: ^18.17.0 || >=20.5.0}
@@ -11442,6 +11454,10 @@ packages:
resolution: {integrity: sha512-PqMLy5+YGwhMh1wS04mVG44oqDsgyLRSKJBdOo1bnYhMKBW65gZF1dRp2OZRhiTjgUHljy99qkO7bsctLaw35Q==}
engines: {node: '>=12.20.0'}
commander@10.0.1:
resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==}
engines: {node: '>=14'}
commander@11.1.0:
resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
engines: {node: '>=16'}
@@ -12238,6 +12254,11 @@ packages:
resolution: {integrity: sha512-dS5cbA9rA2VR4Ybuvhg6jvdmp46ubLn3E+px8cG/35aEDNclrqoCjg6mt0HYZ/M+OoESS3jSkCrqk1kWAEhWAw==}
engines: {bun: '>=1', deno: '>=2', node: '>=16'}
editorconfig@1.0.7:
resolution: {integrity: sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==}
engines: {node: '>=14'}
hasBin: true
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@@ -13900,6 +13921,15 @@ packages:
jpeg-js@0.4.4:
resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==}
js-beautify@1.15.4:
resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==}
engines: {node: '>=14'}
hasBin: true
js-cookie@3.0.5:
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
engines: {node: '>=14'}
js-tokens@10.0.0:
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
@@ -14952,6 +14982,11 @@ packages:
node-vibrant@4.0.4:
resolution: {integrity: sha512-hA/pUXBE9TJ41G9FlTkzeqD5JdxgvvPGYZb/HNpdkaxxXUEnP36imSolZ644JuPun+lTd+FpWWtBpTYdp2noQA==}
nopt@7.2.1:
resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
hasBin: true
nopt@8.1.0:
resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==}
engines: {node: ^18.17.0 || >=20.5.0}
@@ -17631,6 +17666,12 @@ packages:
postcss:
optional: true
vitest-browser-vue@2.1.0:
resolution: {integrity: sha512-K3H/oxIOY4EjXx2bxwrLsPg4jTMvzpRW6Jb6T8XZRoxUvmDVwGkd8mua130F8GZezE7H5QYoXd/S8hYijw7j5g==}
peerDependencies:
vitest: ^4.0.0-0
vue: ^3.0.0
vitest@4.1.4:
resolution: {integrity: sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -17679,6 +17720,9 @@ packages:
vscode-uri@3.1.0:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
vue-component-type-helpers@2.2.12:
resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
@@ -21045,6 +21089,8 @@ snapshots:
'@nxg-org/mineflayer-util-plugin@1.8.4': {}
'@one-ini/wasm@0.1.1': {}
'@opentelemetry/api-logs@0.215.0':
dependencies:
'@opentelemetry/api': 1.9.1
@@ -24147,7 +24193,6 @@ snapshots:
- msw
- utf-8-validate
- vite
optional: true
'@vitest/browser-playwright@4.1.4(bufferutil@4.1.0)(playwright@1.59.1)(utf-8-validate@5.0.10)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.4)':
dependencies:
@@ -24161,6 +24206,7 @@ snapshots:
- msw
- utf-8-validate
- vite
optional: true
'@vitest/browser@4.1.4(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.4)':
dependencies:
@@ -24178,7 +24224,6 @@ snapshots:
- msw
- utf-8-validate
- vite
optional: true
'@vitest/browser@4.1.4(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.4)':
dependencies:
@@ -24196,6 +24241,7 @@ snapshots:
- msw
- utf-8-validate
- vite
optional: true
'@vitest/coverage-v8@4.1.4(@vitest/browser@4.1.4)(vitest@4.1.4)':
dependencies:
@@ -24249,6 +24295,7 @@ snapshots:
magic-string: 0.30.21
optionalDependencies:
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
optional: true
'@vitest/pretty-format@4.1.4':
dependencies:
@@ -24769,6 +24816,11 @@ snapshots:
'@vue/shared@3.5.32': {}
'@vue/test-utils@2.4.6':
dependencies:
js-beautify: 1.15.4
vue-component-type-helpers: 2.2.12
'@vue/tsconfig@0.9.1(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3))':
optionalDependencies:
typescript: 5.9.3
@@ -25021,6 +25073,8 @@ snapshots:
dependencies:
'@xsai/shared-chat': 0.5.0-beta.2(patch_hash=26f2819b987245ec85f216b821ddf73aeb28fd7e611238a2d37250e42f838a01)
abbrev@2.0.0: {}
abbrev@3.0.1: {}
abort-controller@3.0.0:
@@ -25864,6 +25918,8 @@ snapshots:
table-layout: 4.1.1
typical: 7.3.0
commander@10.0.1: {}
commander@11.1.0: {}
commander@12.1.0: {}
@@ -26497,6 +26553,13 @@ snapshots:
'@noble/curves': 1.9.7
'@noble/hashes': 1.8.0
editorconfig@1.0.7:
dependencies:
'@one-ini/wasm': 0.1.1
commander: 10.0.1
minimatch: 9.0.5
semver: 7.7.4
ee-first@1.1.1: {}
ejs@3.1.10:
@@ -28574,6 +28637,16 @@ snapshots:
jpeg-js@0.4.4: {}
js-beautify@1.15.4:
dependencies:
config-chain: 1.1.13
editorconfig: 1.0.7
glob: 10.5.0
js-cookie: 3.0.5
nopt: 7.2.1
js-cookie@3.0.5: {}
js-tokens@10.0.0: {}
js-tokens@4.0.0: {}
@@ -29939,6 +30012,10 @@ snapshots:
transitivePeerDependencies:
- encoding
nopt@7.2.1:
dependencies:
abbrev: 2.0.0
nopt@8.1.0:
dependencies:
abbrev: 3.0.1
@@ -33330,6 +33407,12 @@ snapshots:
- sortablejs
- universal-cookie
vitest-browser-vue@2.1.0(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)):
dependencies:
'@vue/test-utils': 2.4.6
vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@27.4.0(bufferutil@4.1.0)(canvas@3.2.3)(utf-8-validate@5.0.10))(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vue: 3.5.32(typescript@5.9.3)
vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@27.4.0(bufferutil@4.1.0)(canvas@3.2.3)(utf-8-validate@5.0.10))(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@vitest/expect': 4.1.4
@@ -33391,6 +33474,7 @@ snapshots:
jsdom: 27.4.0(bufferutil@4.1.0)(canvas@3.2.3)(utf-8-validate@5.0.10)
transitivePeerDependencies:
- msw
optional: true
vscode-ext-gen@1.6.0:
dependencies:
@@ -33399,6 +33483,8 @@ snapshots:
vscode-uri@3.1.0: {}
vue-component-type-helpers@2.2.12: {}
vue-demi@0.14.10(vue@3.5.32(typescript@5.9.3)):
dependencies:
vue: 3.5.32(typescript@5.9.3)
+1
View File
@@ -135,6 +135,7 @@ catalog:
vite: ^8.0.8
vite-plugin-inspect: 12.0.0-beta.1
vite-plugin-mkcert: ^2.0.0
vitest-browser-vue: ^2.1.0
vue: ^3.5.32
vue-router: ^5.0.4
vue-sonner: 2.0.9