feat(stage-tamagotchi,stage-ui,ui): input should not flink, added retry for errored item, adjusted button
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
chat:
|
||||
actions:
|
||||
retry: Retry
|
||||
message:
|
||||
character-name:
|
||||
airi: AIRI
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
chat:
|
||||
actions:
|
||||
retry: Volver a intentar
|
||||
message:
|
||||
character-name:
|
||||
airi: AIRI
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
chat:
|
||||
actions:
|
||||
retry: Réessayer
|
||||
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,4 +1,6 @@
|
||||
chat:
|
||||
actions:
|
||||
retry: Повторить
|
||||
message:
|
||||
character-name:
|
||||
airi: AIRI
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
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>
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
Generated
+98
-12
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user