diff --git a/packages/i18n/src/locales/en/stage.yaml b/packages/i18n/src/locales/en/stage.yaml
index 570b403e65..b847a87a56 100644
--- a/packages/i18n/src/locales/en/stage.yaml
+++ b/packages/i18n/src/locales/en/stage.yaml
@@ -4,6 +4,7 @@ chat:
airi: AIRI
core-system: Core System
you: You
+ reasoning: Reasoning
message: Say something...
operations:
load-models: Load Models
diff --git a/packages/i18n/src/locales/es/stage.yaml b/packages/i18n/src/locales/es/stage.yaml
index 7dfea96c6d..c54f3b6679 100644
--- a/packages/i18n/src/locales/es/stage.yaml
+++ b/packages/i18n/src/locales/es/stage.yaml
@@ -4,6 +4,7 @@ chat:
airi: AIRI
core-system: Sistema Central
you: Tu
+ reasoning: Razonamiento
message: Dile algo...
operations:
load-models: Cargar Modelos
diff --git a/packages/i18n/src/locales/fr/stage.yaml b/packages/i18n/src/locales/fr/stage.yaml
index 1746298c5b..b6ff070195 100644
--- a/packages/i18n/src/locales/fr/stage.yaml
+++ b/packages/i18n/src/locales/fr/stage.yaml
@@ -4,6 +4,7 @@ chat:
airi: AIRI
core-system: Système central
you: Vous
+ reasoning: Raisonnement
message: Dites quelque chose...
operations:
load-models: Charger les modèles
diff --git a/packages/i18n/src/locales/ja/stage.yaml b/packages/i18n/src/locales/ja/stage.yaml
index 4d09084531..5a4b0eb26a 100644
--- a/packages/i18n/src/locales/ja/stage.yaml
+++ b/packages/i18n/src/locales/ja/stage.yaml
@@ -4,6 +4,7 @@ chat:
airi: AIRI
core-system: コアシステム
you: あなた
+ reasoning: 推論
message: 何か話しかけてみましょう…
operations:
load-models: モデルの読み込み
diff --git a/packages/i18n/src/locales/ko/stage.yaml b/packages/i18n/src/locales/ko/stage.yaml
index 4795260670..a0c700b5a4 100644
--- a/packages/i18n/src/locales/ko/stage.yaml
+++ b/packages/i18n/src/locales/ko/stage.yaml
@@ -4,6 +4,7 @@ chat:
airi: AIRI
core-system: Core System
you: 당신
+ reasoning: 추론
message: 듣고 있어요...
operations:
load-models: 모델 불러오기
diff --git a/packages/i18n/src/locales/ru/stage.yaml b/packages/i18n/src/locales/ru/stage.yaml
index 6edaed9d77..176d3ae002 100644
--- a/packages/i18n/src/locales/ru/stage.yaml
+++ b/packages/i18n/src/locales/ru/stage.yaml
@@ -4,6 +4,7 @@ chat:
airi: AIRI
core-system: Core система
you: Ты
+ reasoning: Рассуждение
message: Спроси что-нибудь
operations:
load-models: Загрузить модели
diff --git a/packages/i18n/src/locales/vi/stage.yaml b/packages/i18n/src/locales/vi/stage.yaml
index 1d3b71ef2d..2f8322f0d5 100644
--- a/packages/i18n/src/locales/vi/stage.yaml
+++ b/packages/i18n/src/locales/vi/stage.yaml
@@ -4,6 +4,7 @@ chat:
airi: AIRI
core-system: Hệ thống lõi
you: Bạn
+ reasoning: Lý luận
message: Nói gì đó...
operations:
load-models: Tải mô hình
diff --git a/packages/i18n/src/locales/zh-Hans/stage.yaml b/packages/i18n/src/locales/zh-Hans/stage.yaml
index b8369b2f36..1eccfbcb5e 100644
--- a/packages/i18n/src/locales/zh-Hans/stage.yaml
+++ b/packages/i18n/src/locales/zh-Hans/stage.yaml
@@ -4,6 +4,7 @@ chat:
airi: AIRI
core-system: 核心系统
you: 你
+ reasoning: 推理
message: 说点什么...
operations:
load-models: 加载模型
diff --git a/packages/i18n/src/locales/zh-Hant/stage.yaml b/packages/i18n/src/locales/zh-Hant/stage.yaml
index c4d1f565b1..ee0146a424 100644
--- a/packages/i18n/src/locales/zh-Hant/stage.yaml
+++ b/packages/i18n/src/locales/zh-Hant/stage.yaml
@@ -4,6 +4,7 @@ chat:
airi: AIRI
core-system: 核心系統
you: 你
+ reasoning: 推理
message: 說點什麼...
operations:
load-models: 載入模型
diff --git a/packages/stage-ui/package.json b/packages/stage-ui/package.json
index 471a0a379f..d9fe5e789a 100644
--- a/packages/stage-ui/package.json
+++ b/packages/stage-ui/package.json
@@ -122,6 +122,7 @@
"posthog-js": "catalog:",
"postprocessing": "^6.38.2",
"rehype-katex": "^7.0.1",
+ "rehype-parse": "^9.0.1",
"rehype-stringify": "^10.0.1",
"reka-ui": "^2.7.0",
"remark-math": "^6.0.0",
@@ -132,6 +133,7 @@
"three": "^0.182.0",
"unified": "^11.0.5",
"unist-builder": "^4.0.0",
+ "unist-util-visit": "^5.0.0",
"unspeech": "catalog:xsai",
"uuid": "^13.0.0",
"valibot": "catalog:",
@@ -170,8 +172,10 @@
"@proj-airi/vite-plugin-warpdrive": "workspace:*",
"@types/audioworklet": "catalog:",
"@types/culori": "^4.0.1",
+ "@types/hast": "catalog:",
"@types/splitpanes": "catalog:",
"@types/three": "^0.182.0",
+ "@types/unist": "catalog:",
"@unocss/reset": "^66.5.11",
"@vitejs/plugin-vue": "^6.0.3",
"clustr": "^1.0.2",
diff --git a/packages/stage-ui/src/components/scenarios/chat/ChatAssistantItem.vue b/packages/stage-ui/src/components/scenarios/chat/ChatAssistantItem.vue
index 090d981cf5..dc67b2f849 100644
--- a/packages/stage-ui/src/components/scenarios/chat/ChatAssistantItem.vue
+++ b/packages/stage-ui/src/components/scenarios/chat/ChatAssistantItem.vue
@@ -4,6 +4,7 @@ import type { ChatAssistantMessage, ChatSlices, ChatSlicesText } from '../../../
import { computed } from 'vue'
import MarkdownRenderer from '../../markdown/MarkdownRenderer.vue'
+import ChatResponsePart from './ChatResponsePart.vue'
import ChatToolCallBlock from './ChatToolCallBlock.vue'
const props = withDefaults(defineProps<{
@@ -66,6 +67,12 @@ const boxClasses = computed(() => [
+
+
diff --git a/packages/stage-ui/src/components/scenarios/chat/ChatResponsePart.vue b/packages/stage-ui/src/components/scenarios/chat/ChatResponsePart.vue
new file mode 100644
index 0000000000..1c16b5041a
--- /dev/null
+++ b/packages/stage-ui/src/components/scenarios/chat/ChatResponsePart.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/stage-ui/src/composables/index.ts b/packages/stage-ui/src/composables/index.ts
index de64a7b6fb..9d1ea9766a 100644
--- a/packages/stage-ui/src/composables/index.ts
+++ b/packages/stage-ui/src/composables/index.ts
@@ -6,5 +6,6 @@ export * from './micvad'
export * from './queues'
export * from './use-analytics'
export * from './use-build-info'
+export * from './use-chat-session/summary'
export * from './use-versioned-local-storage'
export * from './whisper'
diff --git a/packages/stage-ui/src/composables/llmmarkerParser.test.ts b/packages/stage-ui/src/composables/llmmarkerParser.test.ts
index 265a75ae1e..4d6ee2c251 100644
--- a/packages/stage-ui/src/composables/llmmarkerParser.test.ts
+++ b/packages/stage-ui/src/composables/llmmarkerParser.test.ts
@@ -142,4 +142,42 @@ describe('useLlmmarkerParser', async () => {
expect(collectedSpecials).toEqual(expectedSpecials)
}
})
+
+ it('should call onEnd with full text', async () => {
+ const fullText = 'Hello, world!'
+ let endText = ''
+
+ const parser = useLlmmarkerParser({
+ onEnd(text) {
+ endText = text
+ },
+ })
+
+ for (const char of fullText) {
+ await parser.consume(char)
+ }
+
+ await parser.end()
+
+ expect(endText).toBe(fullText)
+ })
+
+ it('should call onEnd with full text including specials', async () => {
+ const fullText = 'Hello <|special|> world!'
+ let endText = ''
+
+ const parser = useLlmmarkerParser({
+ onEnd(text) {
+ endText = text
+ },
+ })
+
+ for (const char of fullText) {
+ await parser.consume(char)
+ }
+
+ await parser.end()
+
+ expect(endText).toBe(fullText)
+ })
})
diff --git a/packages/stage-ui/src/composables/llmmarkerParser.ts b/packages/stage-ui/src/composables/llmmarkerParser.ts
index 04269edc64..3e6fee2147 100644
--- a/packages/stage-ui/src/composables/llmmarkerParser.ts
+++ b/packages/stage-ui/src/composables/llmmarkerParser.ts
@@ -18,6 +18,11 @@ const TAG_CLOSE = '|>'
export function useLlmmarkerParser(options: {
onLiteral?: (literal: string) => void | Promise
onSpecial?: (special: string) => void | Promise
+ /**
+ * Called when parsing ends with the full accumulated text.
+ * Useful for final processing like categorization or filtering.
+ */
+ onEnd?: (fullText: string) => void | Promise
/**
* The minimum length of text required to emit a literal part.
* Useful for avoiding emitting literal parts too fast.
@@ -27,6 +32,7 @@ export function useLlmmarkerParser(options: {
const minLiteralEmitLength = Math.max(1, options.minLiteralEmitLength ?? 1)
let buffer = ''
let inTag = false
+ let fullText = ''
return {
/**
@@ -36,6 +42,7 @@ export function useLlmmarkerParser(options: {
* @param textPart The chunk of text to consume.
*/
async consume(textPart: string) {
+ fullText += textPart
buffer += textPart
while (buffer.length > 0) {
@@ -82,6 +89,7 @@ export function useLlmmarkerParser(options: {
await options.onLiteral?.(buffer)
buffer = ''
}
+ await options.onEnd?.(fullText)
},
}
}
diff --git a/packages/stage-ui/src/composables/response-categoriser.test.ts b/packages/stage-ui/src/composables/response-categoriser.test.ts
new file mode 100644
index 0000000000..057ec721a0
--- /dev/null
+++ b/packages/stage-ui/src/composables/response-categoriser.test.ts
@@ -0,0 +1,358 @@
+import { describe, expect, it } from 'vitest'
+
+import { createStreamingCategorizer } from './response-categoriser'
+
+describe('createStreamingCategorizer', () => {
+ it('should handle pure speech without tags', () => {
+ const categorizer = createStreamingCategorizer()
+ const text = 'Hello, world! This is a test.'
+
+ categorizer.consume(text)
+ const result = categorizer.end()
+
+ expect(result.speech).toBe(text)
+ expect(result.reasoning).toBe('')
+ expect(result.segments).toEqual([])
+ })
+
+ it('should filter out reasoning tags from speech', () => {
+ const categorizer = createStreamingCategorizer()
+ const text = 'Hello thinking about this world!'
+
+ categorizer.consume(text)
+ const result = categorizer.end()
+
+ expect(result.speech).toBe('Hello world!')
+ expect(result.reasoning).toBe('thinking about this')
+ expect(result.segments).toHaveLength(1)
+ expect(result.segments[0].tagName).toBe('reasoning')
+ expect(result.segments[0].content).toBe('thinking about this')
+ })
+
+ it('should handle multiple reasoning tags', () => {
+ const categorizer = createStreamingCategorizer()
+ const text = 'Start first thought middle second thought end'
+
+ categorizer.consume(text)
+ const result = categorizer.end()
+
+ expect(result.speech).toBe('Start middle end')
+ expect(result.reasoning).toBe('first thought\n\nsecond thought')
+ expect(result.segments).toHaveLength(2)
+ })
+
+ it('should filter incomplete tags from speech during streaming', () => {
+ const categorizer = createStreamingCategorizer()
+
+ // Stream incomplete tag
+ categorizer.consume('Hello thinking')
+ const filtered1 = categorizer.filterToSpeech('Hello thinking', 0)
+
+ // Before tag closes, everything should be filtered (tag is incomplete)
+ expect(filtered1).toBe('')
+
+ // Complete the tag - the important thing is the final result is correct
+ categorizer.consume(' about this world!')
+
+ const result = categorizer.end()
+ expect(result.speech).toBe('Hello world!')
+ expect(result.reasoning).toBe('thinking about this')
+ })
+
+ it('should handle tags split across chunks', () => {
+ const categorizer = createStreamingCategorizer()
+ const chunks = [
+ 'Hello <',
+ 'reasoning',
+ '>thinking',
+ ' about this',
+ ' world!',
+ ]
+
+ for (const chunk of chunks) {
+ categorizer.consume(chunk)
+ }
+
+ const result = categorizer.end()
+
+ // Final result should correctly categorize the complete text
+ expect(result.speech).toBe('Hello world!')
+ expect(result.reasoning).toBe('thinking about this')
+ })
+
+ it('should handle nested tags', () => {
+ const categorizer = createStreamingCategorizer()
+ const text = 'Start outer inner more outer end'
+
+ categorizer.consume(text)
+ const result = categorizer.end()
+
+ // Both tags should be extracted
+ expect(result.segments.length).toBeGreaterThan(0)
+ // Speech should exclude tag content
+ // Note: rehype handles nested tags by extracting both, but the speech extraction
+ // may include text between nested tags or closing tag text
+ // The important thing is that the main tag content (inner, outer text) is in reasoning
+ expect(result.speech).toContain('Start')
+ expect(result.speech).toContain('end')
+ // Tag content should be in reasoning, not speech
+ expect(result.reasoning).toContain('inner')
+ // The exact speech format may vary with nested tags, but should not contain the main tag content
+ expect(result.speech).not.toContain('inner')
+ })
+
+ it('should correctly identify speech positions with isSpeechAt', () => {
+ const categorizer = createStreamingCategorizer()
+ const text = 'Hello thinking world!'
+
+ categorizer.consume(text)
+
+ // Position 0-5: "Hello " - should be speech
+ expect(categorizer.isSpeechAt(0)).toBe(true)
+ expect(categorizer.isSpeechAt(5)).toBe(true)
+
+ // Position 6-36: inside tag (including the tag itself) - should not be speech
+ // "Hello " = 6 chars, "" starts at 6, "" ends at 36
+ expect(categorizer.isSpeechAt(6)).toBe(false)
+ expect(categorizer.isSpeechAt(20)).toBe(false)
+ expect(categorizer.isSpeechAt(36)).toBe(false)
+
+ // Position 37+: " world!" - should be speech (after closing tag)
+ expect(categorizer.isSpeechAt(37)).toBe(true)
+ expect(categorizer.isSpeechAt(43)).toBe(true)
+ })
+
+ it('should handle tags with special tokens like emotes inside reasoning', () => {
+ const categorizer = createStreamingCategorizer()
+ // Special tokens like <|EMOTE_HAPPY|> and <|DELAY:1|> should be included in reasoning
+ const text = 'Hello thinking <|EMOTE_HAPPY|> about this <|DELAY:1|> world!'
+
+ categorizer.consume(text)
+ const result = categorizer.end()
+
+ expect(result.speech).toBe('Hello world!')
+ expect(result.reasoning).toBe('thinking <|EMOTE_HAPPY|> about this <|DELAY:1|>')
+ })
+
+ it('should handle tag with special tokens in between', () => {
+ const categorizer = createStreamingCategorizer()
+ // Special tokens like <|EMOTE_CURIOUS|> should be included within reasoning tags
+ const text = 'Hello thinking <|EMOTE_CURIOUS|> about this <|DELAY:2|> and that world!'
+
+ categorizer.consume(text)
+ const result = categorizer.end()
+
+ // Log what was recognized
+ console.info('📋 Test: should handle tag with special tokens')
+ console.info(' Input text:', text)
+ console.info(' Segments found:', result.segments.length)
+ console.info(' Tag name:', result.segments[0]?.tagName)
+ console.info(' Segment content:', result.segments[0]?.content)
+ console.info(' Reasoning:', result.reasoning)
+ console.info(' Speech:', result.speech)
+
+ // Verify tag is recognized
+ expect(result.segments).toHaveLength(1)
+ expect(result.segments[0].tagName).toBe('think')
+
+ // Verify special tokens are preserved in segment content
+ expect(result.segments[0].content).toContain('<|EMOTE_CURIOUS|>')
+ expect(result.segments[0].content).toContain('<|DELAY:2|>')
+
+ // Verify special tokens are in reasoning output
+ expect(result.reasoning).toContain('<|EMOTE_CURIOUS|>')
+ expect(result.reasoning).toContain('<|DELAY:2|>')
+
+ // Verify speech excludes the reasoning content
+ expect(result.speech).toBe('Hello world!')
+ expect(result.speech).not.toContain('<|EMOTE_CURIOUS|>')
+ expect(result.speech).not.toContain('<|DELAY:2|>')
+ expect(result.reasoning).toBe('thinking <|EMOTE_CURIOUS|> about this <|DELAY:2|> and that')
+ })
+
+ it('should handle with special tokens like <|EMOTE_HAPPY|> in between', () => {
+ const categorizer = createStreamingCategorizer()
+ // Testing the exact scenario: ...<|Special in between|>...
+ const text = 'Hello thinking <|EMOTE_HAPPY|> about this <|DELAY:1|> and that world!'
+
+ categorizer.consume(text)
+ const result = categorizer.end()
+
+ // Log what was recognized
+ console.info('📋 Test: should handle with special tokens like <|EMOTE_HAPPY|>')
+ console.info(' Input text:', text)
+ console.info(' Segments found:', result.segments.length)
+ console.info(' Tag name:', result.segments[0]?.tagName)
+ console.info(' Segment content:', result.segments[0]?.content)
+ console.info(' Has <|EMOTE_HAPPY|>:', result.segments[0]?.content.includes('<|EMOTE_HAPPY|>'))
+ console.info(' Has <|DELAY:1|>:', result.segments[0]?.content.includes('<|DELAY:1|>'))
+ console.info(' Reasoning:', result.reasoning)
+ console.info(' Speech:', result.speech)
+
+ // Verify tag is recognized
+ expect(result.segments).toHaveLength(1)
+ expect(result.segments[0].tagName).toBe('think')
+
+ // Verify special tokens are preserved in segment content
+ expect(result.segments[0].content).toContain('<|EMOTE_HAPPY|>')
+ expect(result.segments[0].content).toContain('<|DELAY:1|>')
+
+ // Verify special tokens are in reasoning output
+ expect(result.reasoning).toContain('<|EMOTE_HAPPY|>')
+ expect(result.reasoning).toContain('<|DELAY:1|>')
+
+ // Verify speech excludes the reasoning content
+ expect(result.speech).toBe('Hello world!')
+ expect(result.speech).not.toContain('<|EMOTE_HAPPY|>')
+ expect(result.speech).not.toContain('<|DELAY:1|>')
+ expect(result.reasoning).toBe('thinking <|EMOTE_HAPPY|> about this <|DELAY:1|> and that')
+ })
+
+ it('should handle any tag name as reasoning', () => {
+ const categorizer = createStreamingCategorizer()
+ const text = 'Hello secret thoughts world!'
+
+ categorizer.consume(text)
+ const result = categorizer.end()
+
+ expect(result.speech).toBe('Hello world!')
+ expect(result.reasoning).toBe('secret thoughts')
+ expect(result.segments[0].tagName).toBe('think')
+ })
+
+ it('should handle multiple tags with speech in between', () => {
+ const categorizer = createStreamingCategorizer()
+ const text = 'First thought1 Second thought2 Third'
+
+ categorizer.consume(text)
+ const result = categorizer.end()
+
+ expect(result.speech).toBe('First Second Third')
+ expect(result.reasoning).toBe('thought1\n\nthought2')
+ expect(result.segments).toHaveLength(2)
+ })
+
+ it('should return empty speech when only tags present', () => {
+ const categorizer = createStreamingCategorizer()
+ const text = 'only reasoning'
+
+ categorizer.consume(text)
+ const result = categorizer.end()
+
+ expect(result.speech).toBe('')
+ expect(result.reasoning).toBe('only reasoning')
+ })
+
+ it('should handle streaming with incremental categorization', () => {
+ const categorizer = createStreamingCategorizer()
+
+ // Stream in small chunks
+ categorizer.consume('Hello ')
+ expect(categorizer.getCurrent()?.speech).toBe('Hello ')
+
+ categorizer.consume('')
+ categorizer.consume('thinking')
+ // Tag not closed yet, but should still categorize
+ const midResult = categorizer.getCurrent()
+ expect(midResult).not.toBeNull()
+
+ categorizer.consume('')
+ categorizer.consume(' world!')
+
+ const result = categorizer.end()
+ expect(result.speech).toBe('Hello world!')
+ expect(result.reasoning).toBe('thinking')
+ })
+
+ it('should call onSegment callback when segments are detected', () => {
+ const segments: Array<{ tagName: string, content: string }> = []
+ const categorizer = createStreamingCategorizer(undefined, (segment) => {
+ segments.push({ tagName: segment.tagName, content: segment.content })
+ })
+
+ const text = 'Hello thought1 thought2 world!'
+
+ // Stream the text - segments are emitted when they complete
+ categorizer.consume(text)
+ categorizer.end()
+
+ // Check final result has both segments
+ const result = categorizer.end()
+ expect(result.segments.length).toBeGreaterThanOrEqual(2)
+ expect(result.segments.some(s => s.tagName === 'reasoning')).toBe(true)
+ expect(result.segments.some(s => s.tagName === 'think')).toBe(true)
+
+ // onSegment is called when segments complete during streaming
+ // At minimum, we should have segments detected in the final result
+ expect(result.segments.length).toBeGreaterThanOrEqual(2)
+ })
+
+ it('should handle getCurrentPosition correctly', () => {
+ const categorizer = createStreamingCategorizer()
+
+ expect(categorizer.getCurrentPosition()).toBe(0)
+
+ categorizer.consume('Hello')
+ expect(categorizer.getCurrentPosition()).toBe(5)
+
+ categorizer.consume(' world!')
+ expect(categorizer.getCurrentPosition()).toBe(12)
+ })
+
+ it('should handle edge case: tag at start', () => {
+ const categorizer = createStreamingCategorizer()
+ const text = 'thinkingHello world!'
+
+ categorizer.consume(text)
+ const result = categorizer.end()
+
+ expect(result.speech).toBe('Hello world!')
+ expect(result.reasoning).toBe('thinking')
+ })
+
+ it('should handle edge case: tag at end', () => {
+ const categorizer = createStreamingCategorizer()
+ const text = 'Hello world!thinking'
+
+ categorizer.consume(text)
+ const result = categorizer.end()
+
+ expect(result.speech).toBe('Hello world!')
+ expect(result.reasoning).toBe('thinking')
+ })
+
+ it('should handle malformed tags gracefully', () => {
+ const categorizer = createStreamingCategorizer()
+ const text = 'Hello thinking world!'
+
+ categorizer.consume(text)
+ const result = categorizer.end()
+
+ // Unclosed tag should be treated as speech
+ expect(result.speech).toContain('Hello')
+ expect(result.segments.length).toBe(0)
+ })
+
+ it('should filter speech correctly when tag closes mid-chunk', () => {
+ const categorizer = createStreamingCategorizer()
+
+ // Stream incomplete tag
+ categorizer.consume('Hello thinking')
+ let filtered = categorizer.filterToSpeech('Hello thinking', 0)
+ expect(filtered).toBe('') // Tag not closed, filter everything
+
+ // Stream closing tag
+ categorizer.consume('')
+ filtered = categorizer.filterToSpeech('', 25)
+ expect(filtered).toBe('') // This is the closing tag itself
+
+ // Stream speech after
+ categorizer.consume(' world!')
+ filtered = categorizer.filterToSpeech(' world!', 38)
+ expect(filtered).toBe(' world!')
+
+ const result = categorizer.end()
+ expect(result.speech).toBe('Hello world!')
+ })
+})
diff --git a/packages/stage-ui/src/composables/response-categoriser.ts b/packages/stage-ui/src/composables/response-categoriser.ts
new file mode 100644
index 0000000000..2be9c8abb7
--- /dev/null
+++ b/packages/stage-ui/src/composables/response-categoriser.ts
@@ -0,0 +1,466 @@
+import type { Element, Root } from 'hast'
+import type { Position } from 'unist'
+
+import rehypeParse from 'rehype-parse'
+import rehypeStringify from 'rehype-stringify'
+
+import { unified } from 'unified'
+import { visit } from 'unist-util-visit'
+
+export type ResponseCategory = 'speech' | 'reasoning' | 'unknown'
+
+export interface CategorizedSegment {
+ category: ResponseCategory
+ content: string
+ startIndex: number
+ endIndex: number
+ raw: string // Original tagged content including tags
+ tagName: string // The actual tag name found (e.g., "think", "thought", "reasoning")
+}
+
+export interface CategorizedResponse {
+ segments: CategorizedSegment[]
+ speech: string // Combined speech content (everything outside tags)
+ reasoning: string // Combined reasoning/thought content
+ raw: string // Original full response
+}
+
+/**
+ * Maps tag names to categories
+ * All tags are treated as reasoning (filtered from TTS)
+ */
+function mapTagNameToCategory(_tagName: string): ResponseCategory {
+ // All tags are reasoning - no need to distinguish tag names
+ return 'reasoning'
+}
+
+interface ExtractedTag {
+ tagName: string
+ content: string
+ fullMatch: string
+ startIndex: number
+ endIndex: number
+}
+
+/**
+ * Extracts all XML-like tags from a response using rehype pipeline
+ * Works with any tag format: content
+ * Only extracts tags that are actually complete (have closing tags in source)
+ */
+function extractAllTags(response: string): ExtractedTag[] {
+ const tags: ExtractedTag[] = []
+
+ try {
+ const tree = unified().use(rehypeParse, { fragment: true }).parse(response) as Root
+
+ visit(tree, 'element', (node: Element) => {
+ const position = node.position
+ if (!position?.start || !position?.end)
+ return
+
+ const startIndex = getOffsetFromPosition(response, position.start)
+ const endIndex = getOffsetFromPosition(response, position.end)
+
+ if (startIndex === -1 || endIndex === -1)
+ return
+
+ // Extract the actual tag content from source
+ const fullMatch = response.slice(startIndex, endIndex)
+
+ // Only include tags that have a closing tag in the source (not auto-closed by rehype)
+ // Check if the source actually contains the closing tag
+ const expectedClosingTag = `${node.tagName}>`
+ if (!fullMatch.includes(expectedClosingTag)) {
+ // This tag was auto-closed by rehype, so it's incomplete - skip it
+ return
+ }
+
+ tags.push({
+ tagName: node.tagName,
+ content: extractTextContent(node),
+ fullMatch,
+ startIndex,
+ endIndex,
+ })
+ })
+ }
+ catch (error) {
+ console.error('Failed to parse response for tag extraction:', error)
+ // If parsing fails, return empty array (no tags found)
+ }
+
+ return tags
+}
+
+/**
+ * Converts a position (line/column) to a character offset in the string
+ */
+function getOffsetFromPosition(text: string, position: Position['start']): number {
+ if (!position || typeof position.line !== 'number' || typeof position.column !== 'number')
+ return -1
+
+ const lines = text.split('\n')
+ let offset = 0
+
+ // Sum up lengths of all lines before the target line
+ for (let i = 0; i < position.line - 1 && i < lines.length; i++) {
+ offset += lines[i].length + 1 // +1 for the newline character
+ }
+
+ // Add the column offset (subtract 1 because columns are 1-indexed)
+ offset += position.column - 1
+
+ return offset
+}
+
+/**
+ * Extracts text content from an element node
+ */
+function extractTextContent(node: Element): string {
+ const textParts: string[] = []
+
+ if (node.children) {
+ for (const child of node.children) {
+ if (child.type === 'text') {
+ textParts.push(child.value)
+ }
+ else if (child.type === 'element') {
+ textParts.push(extractTextContent(child))
+ }
+ }
+ }
+
+ return textParts.join('')
+}
+
+/**
+ * Categorizes a model response by dynamically extracting any XML-like tags
+ * Works with any tag format the model uses
+ */
+export function categorizeResponse(
+ response: string,
+ _providerId?: string,
+): CategorizedResponse {
+ // Extract all tags dynamically
+ const extractedTags = extractAllTags(response)
+
+ if (extractedTags.length === 0) {
+ // No tags found, treat everything as speech
+ return {
+ segments: [],
+ speech: response,
+ reasoning: '',
+ raw: response,
+ }
+ }
+
+ // Convert extracted tags to categorized segments
+ const segments: CategorizedSegment[] = extractedTags.map(tag => ({
+ category: mapTagNameToCategory(tag.tagName),
+ content: tag.content.trim(),
+ startIndex: tag.startIndex,
+ endIndex: tag.endIndex,
+ raw: tag.fullMatch,
+ tagName: tag.tagName,
+ }))
+
+ // Sort segments by position
+ segments.sort((a, b) => a.startIndex - b.startIndex)
+
+ // Extract speech content (everything outside tags)
+ const speechParts: string[] = []
+ let lastEnd = 0
+
+ for (const segment of segments) {
+ // Add text before this segment
+ if (segment.startIndex > lastEnd) {
+ const text = response.slice(lastEnd, segment.startIndex).trim()
+ if (text) {
+ speechParts.push(text)
+ }
+ }
+ lastEnd = segment.endIndex
+ }
+
+ // Add remaining text after last segment
+ if (lastEnd < response.length) {
+ const text = response.slice(lastEnd).trim()
+ if (text) {
+ speechParts.push(text)
+ }
+ }
+
+ // Combine segments by category
+ const reasoning = segments
+ .filter(s => s.category === 'reasoning')
+ .map(s => s.content)
+ .join('\n\n')
+
+ // Speech is everything outside tags
+ const speech = speechParts.join(' ').trim()
+
+ return {
+ segments,
+ speech: speech || '',
+ reasoning,
+ raw: response,
+ }
+}
+
+/**
+ * Note: This receives literal text from useLlmmarkerParser (special tokens <|...|> are already extracted).
+ * Only XML/HTML tags like , need to be parsed here.
+ */
+export function createStreamingCategorizer(
+ providerId?: string,
+ onSegment?: (segment: CategorizedSegment) => void,
+) {
+ let buffer = ''
+ let categorized: CategorizedResponse | null = null
+ let lastEmittedSegmentIndex = -1
+ let lastParsedLength = 0
+
+ // Lightweight state machine to detect tag closures without parsing entire buffer
+ type TagState = 'outside' | 'in-opening-tag' | 'in-content' | 'in-closing-tag'
+ let tagState: TagState = 'outside'
+ let tagStackDepth = 0
+
+ // Fallback for filterToSpeech - uses rehype for robust incomplete tag detection
+ function checkIncompleteTag(): boolean {
+ try {
+ const tree = unified().use(rehypeParse, { fragment: true }).parse(buffer) as Root
+ const stringified = unified().use(rehypeStringify).stringify(tree).toString()
+
+ if (stringified !== buffer) {
+ const bufferEnd = buffer.trim().slice(-30)
+ const stringifiedEnd = stringified.trim().slice(-30)
+ return bufferEnd !== stringifiedEnd
+ }
+
+ return false
+ }
+ catch {
+ // If parsing fails, assume incomplete
+ return true
+ }
+ }
+
+ // Tracks tag state incrementally (O(chunk.length)) to detect when tags close
+ // Returns true when the outermost tag just closed
+ function processChunkIncrementally(chunk: string): boolean {
+ let tagJustClosed = false
+
+ for (let i = 0; i < chunk.length; i++) {
+ const char = chunk[i]
+
+ switch (tagState) {
+ case 'outside': {
+ if (char === '<') {
+ if (i + 1 < chunk.length && chunk[i + 1] === '/') {
+ tagState = 'in-closing-tag'
+ i++
+ }
+ else {
+ tagState = 'in-opening-tag'
+ }
+ }
+ break
+ }
+
+ case 'in-opening-tag': {
+ if (char === '>') {
+ tagState = 'in-content'
+ tagStackDepth++
+ }
+ break
+ }
+
+ case 'in-content': {
+ if (char === '<') {
+ if (i + 1 < chunk.length && chunk[i + 1] === '/') {
+ tagState = 'in-closing-tag'
+ i++
+ }
+ else {
+ tagState = 'in-opening-tag'
+ }
+ }
+ break
+ }
+
+ case 'in-closing-tag': {
+ if (char === '>') {
+ tagStackDepth--
+ if (tagStackDepth === 0) {
+ tagState = 'outside'
+ tagJustClosed = true
+ }
+ else {
+ tagState = 'in-content'
+ }
+ }
+ break
+ }
+ }
+ }
+
+ return tagJustClosed
+ }
+
+ return {
+ consume(chunk: string) {
+ // Process before adding to buffer to detect tag closure in this chunk
+ const tagJustClosed = processChunkIncrementally(chunk)
+ buffer += chunk
+
+ // Re-categorize on first chunk, tag closure, or every 1KB (periodic fallback)
+ const shouldRecategorize = !categorized
+ || tagJustClosed
+ || buffer.length - lastParsedLength > 1000
+
+ if (shouldRecategorize) {
+ categorized = categorizeResponse(buffer, providerId)
+ lastParsedLength = buffer.length
+ }
+
+ // Type guard for TypeScript (shouldRecategorize handles !categorized, but TS doesn't know)
+ if (!categorized) {
+ categorized = categorizeResponse(buffer, providerId)
+ lastParsedLength = buffer.length
+ }
+
+ if (onSegment && categorized.segments.length > 0) {
+ for (let i = lastEmittedSegmentIndex + 1; i < categorized.segments.length; i++) {
+ const segment = categorized.segments[i]
+ if (buffer.length >= segment.endIndex) {
+ onSegment(segment)
+ lastEmittedSegmentIndex = i
+ }
+ }
+ }
+ },
+ /**
+ * Checks if the current position in the stream is part of speech content
+ * Returns true if the text should be sent to TTS
+ */
+ isSpeechAt(position: number): boolean {
+ if (!categorized || categorized.segments.length === 0) {
+ // No categorization yet, assume it's speech
+ return true
+ }
+
+ // Check if position falls within any non-speech segment
+ for (const segment of categorized.segments) {
+ if (position >= segment.startIndex && position < segment.endIndex) {
+ // Position is within a tagged segment (thought/reasoning)
+ return false
+ }
+ }
+
+ // Position is not in any tagged segment, so it's speech
+ return true
+ },
+ /**
+ * Filters text to only include speech parts
+ * Removes content that falls within thought/reasoning segments
+ */
+ filterToSpeech(text: string, startPosition: number): string {
+ // Check if we're currently inside an incomplete tag
+ if (checkIncompleteTag()) {
+ // Try to find where the tag closes in the combined buffer + text
+ const fullText = buffer + text
+ try {
+ const tree = unified().use(rehypeParse, { fragment: true }).parse(fullText) as Root
+ let closingOffset = -1
+
+ visit(tree, 'element', (node: Element) => {
+ const position = node.position
+ if (position?.end && closingOffset === -1) {
+ const endOffset = getOffsetFromPosition(fullText, position.end)
+ // Check if this element actually has a closing tag in the source
+ const elementSource = fullText.slice(
+ getOffsetFromPosition(fullText, position.start),
+ endOffset,
+ )
+ const expectedClosingTag = `${node.tagName}>`
+
+ // Only consider it complete if the closing tag exists in source
+ if (elementSource.includes(expectedClosingTag)) {
+ // If this element closes within the new text chunk
+ if (endOffset >= buffer.length && endOffset <= fullText.length) {
+ closingOffset = endOffset - buffer.length
+ }
+ }
+ }
+ })
+
+ if (closingOffset === -1)
+ return '' // Still incomplete, filter everything
+
+ // Return only content after the closing tag
+ // The buffer already includes text up to closingOffset (from consume())
+ text = text.slice(closingOffset)
+ startPosition += closingOffset
+ // Re-categorize with the complete tag now in buffer
+ categorized = categorizeResponse(buffer, providerId)
+ }
+ catch {
+ return '' // Parsing failed, filter everything
+ }
+ }
+
+ if (!categorized || categorized.segments.length === 0) {
+ // No segments detected, all text is speech
+ return text
+ }
+
+ let filtered = ''
+ const endPosition = startPosition + text.length
+
+ // Find all non-speech segments that overlap with this text
+ // Note: segments are already filtered to be complete by extractAllTags
+ const overlappingSegments = categorized.segments.filter(
+ segment => segment.endIndex > startPosition && segment.startIndex < endPosition,
+ )
+
+ if (overlappingSegments.length === 0) {
+ // No overlapping segments, all text is speech
+ return text
+ }
+
+ // Build filtered text by excluding non-speech segments
+ let currentPos = startPosition
+ for (const segment of overlappingSegments) {
+ const segmentStart = Math.max(segment.startIndex, startPosition)
+ const segmentEnd = Math.min(segment.endIndex, endPosition)
+
+ // Add text before this segment
+ if (segmentStart > currentPos) {
+ const beforeStart = currentPos - startPosition
+ const beforeEnd = segmentStart - startPosition
+ filtered += text.slice(beforeStart, beforeEnd)
+ }
+
+ // Skip the segment content (don't add to filtered)
+ currentPos = segmentEnd
+ }
+
+ // Add remaining text after last segment
+ if (currentPos < endPosition) {
+ const afterStart = currentPos - startPosition
+ filtered += text.slice(afterStart)
+ }
+
+ return filtered
+ },
+ getCurrentPosition(): number {
+ return buffer.length
+ },
+ end(): CategorizedResponse {
+ return categorizeResponse(buffer, providerId)
+ },
+ getCurrent(): CategorizedResponse | null {
+ return categorized
+ },
+ }
+}
diff --git a/packages/stage-ui/src/composables/use-chat-session/summary.ts b/packages/stage-ui/src/composables/use-chat-session/summary.ts
new file mode 100644
index 0000000000..32e2c42b9a
--- /dev/null
+++ b/packages/stage-ui/src/composables/use-chat-session/summary.ts
@@ -0,0 +1,76 @@
+import type { ChatAssistantMessage, ChatHistoryItem } from '../../types/chat'
+
+/**
+ * Extract all reasoning from a session's messages
+ */
+export function getAllReasoning(messages: ChatHistoryItem[]): string[] {
+ return messages
+ .filter((msg): msg is ChatAssistantMessage =>
+ msg.role === 'assistant' && 'categorization' in msg,
+ )
+ .map(msg => msg.categorization?.reasoning)
+ .filter((reasoning): reasoning is string => !!reasoning?.trim())
+}
+
+/**
+ * Get combined reasoning as a single string
+ */
+export function getCombinedReasoning(messages: ChatHistoryItem[]): string {
+ return getAllReasoning(messages).join('\n\n')
+}
+
+/**
+ * Get session summary with reasoning, speech, and metadata
+ */
+export function getSessionSummary(
+ sessionId: string,
+ messages: ChatHistoryItem[],
+): {
+ sessionId: string
+ messageCount: number
+ reasoningCount: number
+ allReasoning: string[]
+ allSpeech: string[]
+ combinedReasoning: string
+ combinedSpeech: string
+ createdAt?: number
+ lastMessageAt?: number
+} {
+ const allReasoning: string[] = []
+ const allSpeech: string[] = []
+
+ for (const msg of messages) {
+ if (msg.role === 'assistant' && 'categorization' in msg) {
+ const reasoning = msg.categorization?.reasoning
+ if (reasoning?.trim()) {
+ allReasoning.push(reasoning)
+ }
+ const speech = msg.categorization?.speech
+ if (speech?.trim()) {
+ allSpeech.push(speech)
+ }
+ }
+ }
+
+ return {
+ sessionId,
+ messageCount: messages.length,
+ reasoningCount: allReasoning.length,
+ allReasoning,
+ allSpeech,
+ combinedReasoning: allReasoning.join('\n\n'),
+ combinedSpeech: allSpeech.join('\n\n'),
+ createdAt: messages[0]?.createdAt,
+ lastMessageAt: messages[messages.length - 1]?.createdAt,
+ }
+}
+
+/**
+ * Get reasoning from all sessions
+ */
+export function getAllReasoningFromAllSessions(
+ allSessions: Record,
+): string[] {
+ return Object.values(allSessions)
+ .flatMap(messages => getAllReasoning(messages))
+}
diff --git a/packages/stage-ui/src/stores/chat.ts b/packages/stage-ui/src/stores/chat.ts
index 8f6689fbac..621f3bc03b 100644
--- a/packages/stage-ui/src/stores/chat.ts
+++ b/packages/stage-ui/src/stores/chat.ts
@@ -11,10 +11,12 @@ import { computed, ref, toRaw, watch } from 'vue'
import { useAnalytics } from '../composables'
import { useLlmmarkerParser } from '../composables/llmmarkerParser'
+import { categorizeResponse, createStreamingCategorizer } from '../composables/response-categoriser'
import { useLLM } from '../stores/llm'
import { createQueue } from '../utils/queue'
import { TTS_FLUSH_INSTRUCTION } from '../utils/tts'
import { useCharacterStore } from './character'
+import { useConsciousnessStore } from './modules/consciousness'
const CHAT_STORAGE_KEY = 'chat/messages/v2'
const ACTIVE_SESSION_STORAGE_KEY = 'chat/active-session'
@@ -23,6 +25,8 @@ export const CHAT_STREAM_CHANNEL_NAME = 'airi-chat-stream'
export const useChatStore = defineStore('chat', () => {
const { stream, discoverToolsCompatibility } = useLLM()
+ const consciousnessStore = useConsciousnessStore()
+ const { activeProvider } = storeToRefs(consciousnessStore)
const { systemPrompt } = storeToRefs(useCharacterStore())
const { trackFirstMessage } = useAnalytics()
@@ -391,26 +395,44 @@ export const useChatStore = defineStore('chat', () => {
const sessionMessagesForSend = getSessionMessagesById(sessionId)
sessionMessagesForSend.push({ role: 'user', content: finalContent })
+ // Create categorizer for response categorization
+ const categorizer = createStreamingCategorizer(activeProvider.value)
+ let streamPosition = 0 // Track position in stream for TTS filtering
+
const parser = useLlmmarkerParser({
onLiteral: async (literal) => {
if (shouldAbort())
return
- await emitTokenLiteralHooks(literal, streamingMessageContext)
+ // Feed to categorizer first
+ categorizer.consume(literal)
- streamingMessage.value.content += literal
+ // Filter to only include speech parts (exclude reasoning)
+ // The categorizer handles incomplete tags and filters based on detected tags during streaming
+ const speechOnly = categorizer.filterToSpeech(literal, streamPosition)
+ streamPosition += literal.length
- // merge text slices for markdown
- const lastSlice = streamingMessage.value.slices.at(-1)
- if (lastSlice?.type === 'text') {
- lastSlice.text += literal
- return
- }
+ // Only process non-empty speech content (filter empty/whitespace-only chunks)
+ // Preserve spacing in chunks with content for proper word boundaries
+ if (speechOnly.trim()) {
+ streamingMessage.value.content += speechOnly
+
+ // Emit TTS only for speech parts, not reasoning (clean data, no empty chunks)
+ await emitTokenLiteralHooks(speechOnly, streamingMessageContext)
+
+ // Add speech content to slices for rendering
+ // merge text slices for markdown
+ const lastSlice = streamingMessage.value.slices.at(-1)
+ if (lastSlice?.type === 'text') {
+ lastSlice.text += speechOnly
+ return
+ }
- streamingMessage.value.slices.push({
- type: 'text',
- text: literal,
- })
+ streamingMessage.value.slices.push({
+ type: 'text',
+ text: speechOnly,
+ })
+ }
},
onSpecial: async (special) => {
if (shouldAbort())
@@ -418,6 +440,19 @@ export const useChatStore = defineStore('chat', () => {
await emitTokenSpecialHooks(special, streamingMessageContext)
},
+ onEnd: async (fullText) => {
+ if (isStaleGeneration())
+ return
+
+ // Categorize the full text stream
+ const finalCategorization = categorizeResponse(fullText, activeProvider.value)
+
+ // Always store categorization (even if empty) for consistency and memory features
+ streamingMessage.value.categorization = {
+ speech: finalCategorization.speech,
+ reasoning: finalCategorization.reasoning,
+ }
+ },
minLiteralEmitLength: 24, // Avoid emitting literals too fast. This is a magic number and can be changed later.
})
@@ -443,7 +478,7 @@ export const useChatStore = defineStore('chat', () => {
const rawMessage = toRaw(withoutContext)
if (rawMessage.role === 'assistant') {
- const { slices: _, tool_results, ...rest } = rawMessage as ChatAssistantMessage
+ const { slices: _, tool_results, categorization: __categorization, ...rest } = rawMessage as ChatAssistantMessage
return {
...toRaw(rest),
tool_results: toRaw(tool_results),
@@ -520,6 +555,7 @@ export const useChatStore = defineStore('chat', () => {
})
// Finalize the parsing of the actual message content
+ // Categorization and filtering happens in the onEnd callback
await parser.end()
// Add the completed message to the history only if it has content
diff --git a/packages/stage-ui/src/types/chat.ts b/packages/stage-ui/src/types/chat.ts
index 2b65f94b12..348352df4b 100644
--- a/packages/stage-ui/src/types/chat.ts
+++ b/packages/stage-ui/src/types/chat.ts
@@ -25,6 +25,10 @@ export interface ChatAssistantMessage extends AssistantMessage {
id: string
result?: string | CommonContentPart[]
}[]
+ categorization?: {
+ speech: string
+ reasoning: string
+ }
}
export type ChatMessage = ChatAssistantMessage | SystemMessage | ToolMessage | UserMessage
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 85fec01787..630576a3c2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -45,9 +45,15 @@ catalogs:
'@types/audioworklet':
specifier: ^0.0.92
version: 0.0.92
+ '@types/hast':
+ specifier: ^3.0.4
+ version: 3.0.4
'@types/splitpanes':
specifier: ^2.2.6
version: 2.2.6
+ '@types/unist':
+ specifier: ^3.0.3
+ version: 3.0.3
'@xsai-ext/providers':
specifier: ^0.4.0-beta.13
version: 0.4.0
@@ -685,7 +691,7 @@ importers:
version: 1.0.18
'@proj-airi/unplugin-fetch':
specifier: ^0.2.1
- version: 0.2.1(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
+ version: 0.2.1(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@proj-airi/unplugin-live2d-sdk':
specifier: ^0.1.6
version: 0.1.6(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
@@ -718,7 +724,7 @@ importers:
version: 66.5.11
'@vitejs/plugin-vue':
specifier: ^6.0.3
- version: 6.0.3(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))
+ version: 6.0.3(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))
'@vue-macros/volar':
specifier: ^3.1.1
version: 3.1.1(typescript@5.9.3)(vue-tsc@3.2.1(typescript@5.9.3))(vue@3.5.25(typescript@5.9.3))
@@ -736,34 +742,34 @@ importers:
version: 4.5.1
unplugin-info:
specifier: ^1.2.4
- version: 1.2.4(esbuild@0.25.12)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(rollup@2.79.2)
+ version: 1.2.4(esbuild@0.27.2)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(rollup@2.79.2)
unplugin-vue-router:
specifier: ^0.19.0
version: 0.19.2(@vue/compiler-sfc@3.5.26)(vue-router@4.6.4(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3))
unplugin-yaml:
specifier: ^3.0.7
- version: 3.0.7(esbuild@0.25.12)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(rolldown@1.0.0-beta.53)(rollup@2.79.2)
+ version: 3.0.7(esbuild@0.27.2)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(rolldown@1.0.0-beta.53)(rollup@2.79.2)
vite:
specifier: catalog:rolldown-vite
- version: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
+ version: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vite-bundle-visualizer:
specifier: ^1.2.1
version: 1.2.1(rolldown@1.0.0-beta.53)(rollup@2.79.2)
vite-plugin-mkcert:
specifier: 'catalog:'
- version: 1.17.9(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
+ version: 1.17.9(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
vite-plugin-pwa:
specifier: ^1.2.0
- version: 1.2.0(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(workbox-build@7.4.0)(workbox-window@7.4.0)
+ version: 1.2.0(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(workbox-build@7.4.0)(workbox-window@7.4.0)
vite-plugin-vue-devtools:
specifier: ^8.0.5
- version: 8.0.5(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))
+ version: 8.0.5(@nuxt/kit@3.20.2(magicast@0.5.1))(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))
vite-plugin-vue-layouts:
specifier: ^0.11.0
- version: 0.11.0(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue-router@4.6.4(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3))
+ version: 0.11.0(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue-router@4.6.4(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3))
vue-macros:
specifier: ^3.1.1
- version: 3.1.1(@vueuse/core@14.1.0(vue@3.5.25(typescript@5.9.3)))(esbuild@0.25.12)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(rolldown@1.0.0-beta.53)(rollup@2.79.2)(typescript@5.9.3)(vue-tsc@3.2.1(typescript@5.9.3))(vue@3.5.25(typescript@5.9.3))
+ version: 3.1.1(@vueuse/core@14.1.0(vue@3.5.25(typescript@5.9.3)))(esbuild@0.27.2)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(rolldown@1.0.0-beta.53)(rollup@2.79.2)(typescript@5.9.3)(vue-tsc@3.2.1(typescript@5.9.3))(vue@3.5.25(typescript@5.9.3))
vue-tsc:
specifier: ^3.1.8
version: 3.2.1(typescript@5.9.3)
@@ -2310,6 +2316,9 @@ importers:
rehype-katex:
specifier: ^7.0.1
version: 7.0.1
+ rehype-parse:
+ specifier: ^9.0.1
+ version: 9.0.1
rehype-stringify:
specifier: ^10.0.1
version: 10.0.1
@@ -2340,6 +2349,9 @@ importers:
unist-builder:
specifier: ^4.0.0
version: 4.0.0
+ unist-util-visit:
+ specifier: ^5.0.0
+ version: 5.0.0
unspeech:
specifier: catalog:xsai
version: 0.1.11
@@ -2449,12 +2461,18 @@ importers:
'@types/culori':
specifier: ^4.0.1
version: 4.0.1
+ '@types/hast':
+ specifier: 'catalog:'
+ version: 3.0.4
'@types/splitpanes':
specifier: 'catalog:'
version: 2.2.6
'@types/three':
specifier: ^0.182.0
version: 0.182.0
+ '@types/unist':
+ specifier: 'catalog:'
+ version: 3.0.3
'@unocss/reset':
specifier: ^66.5.11
version: 66.5.11
@@ -2796,7 +2814,7 @@ importers:
version: 66.5.11
'@wxt-dev/module-vue':
specifier: ^1.0.3
- version: 1.0.3(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
+ version: 1.0.3(vite@8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
vue-tsc:
specifier: ^3.1.8
version: 3.2.1(typescript@5.9.3)
@@ -20724,11 +20742,6 @@ snapshots:
'@proj-airi/unocss-preset-chromatic@1.0.2': {}
- '@proj-airi/unplugin-fetch@0.2.1(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
- dependencies:
- ofetch: 1.5.1
- vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
-
'@proj-airi/unplugin-fetch@0.2.1(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
ofetch: 1.5.1
@@ -21935,12 +21948,6 @@ snapshots:
dependencies:
'@vibrant/types': 4.0.0
- '@vitejs/plugin-vue@6.0.3(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))':
- dependencies:
- '@rolldown/pluginutils': 1.0.0-beta.53
- vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
- vue: 3.5.25(typescript@5.9.3)
-
'@vitejs/plugin-vue@6.0.3(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-beta.53
@@ -21959,19 +21966,11 @@ snapshots:
vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vue: 3.5.25(typescript@5.9.3)
- '@vitest/browser-playwright@4.0.16(bufferutil@4.1.0)(playwright@1.57.0)(utf-8-validate@5.0.10)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16)':
+ '@vitejs/plugin-vue@6.0.3(vite@8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))':
dependencies:
- '@vitest/browser': 4.0.16(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16)
- '@vitest/mocker': 4.0.16(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
- playwright: 1.57.0
- tinyrainbow: 3.0.3
- vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@5.0.10))(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
- transitivePeerDependencies:
- - bufferutil
- - msw
- - utf-8-validate
- - vite
- optional: true
+ '@rolldown/pluginutils': 1.0.0-beta.53
+ vite: 8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
+ vue: 3.5.25(typescript@5.9.3)
'@vitest/browser-playwright@4.0.16(bufferutil@4.1.0)(playwright@1.57.0)(utf-8-validate@5.0.10)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16)':
dependencies:
@@ -21986,24 +21985,6 @@ snapshots:
- utf-8-validate
- vite
- '@vitest/browser@4.0.16(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16)':
- dependencies:
- '@vitest/mocker': 4.0.16(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
- '@vitest/utils': 4.0.16
- magic-string: 0.30.21
- pixelmatch: 7.1.0
- pngjs: 7.0.0
- sirv: 3.0.2
- tinyrainbow: 3.0.3
- vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@5.0.10))(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
- ws: 8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10)
- transitivePeerDependencies:
- - bufferutil
- - msw
- - utf-8-validate
- - vite
- optional: true
-
'@vitest/browser@4.0.16(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16)':
dependencies:
'@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
@@ -22060,15 +22041,6 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.0.3
- '@vitest/mocker@4.0.16(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
- dependencies:
- '@vitest/spy': 4.0.16
- estree-walker: 3.0.3
- magic-string: 0.30.21
- optionalDependencies:
- vite: 6.4.1(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
- optional: true
-
'@vitest/mocker@4.0.16(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 4.0.16
@@ -22249,15 +22221,6 @@ snapshots:
transitivePeerDependencies:
- vue
- '@vue-macros/devtools@3.1.1(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(typescript@5.9.3)':
- dependencies:
- sirv: 3.0.2
- vue: 3.5.25(typescript@5.9.3)
- optionalDependencies:
- vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
- transitivePeerDependencies:
- - typescript
-
'@vue-macros/devtools@3.1.1(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(typescript@5.9.3)':
dependencies:
sirv: 3.0.2
@@ -22528,18 +22491,6 @@ snapshots:
dependencies:
'@vue/devtools-kit': 8.0.5
- '@vue/devtools-core@8.0.5(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))':
- dependencies:
- '@vue/devtools-kit': 8.0.5
- '@vue/devtools-shared': 8.0.5
- mitt: 3.0.1
- nanoid: 5.1.6
- pathe: 2.0.3
- vite-hot-client: 2.1.0(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
- vue: 3.5.25(typescript@5.9.3)
- transitivePeerDependencies:
- - vite
-
'@vue/devtools-core@8.0.5(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))':
dependencies:
'@vue/devtools-kit': 8.0.5
@@ -22766,9 +22717,9 @@ snapshots:
'@types/filesystem': 0.0.36
'@types/har-format': 1.2.16
- '@wxt-dev/module-vue@1.0.3(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
+ '@wxt-dev/module-vue@1.0.3(vite@8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
- '@vitejs/plugin-vue': 6.0.3(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))
+ '@vitejs/plugin-vue': 6.0.3(vite@8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))
wxt: 0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies:
- vite
@@ -29076,25 +29027,6 @@ snapshots:
transitivePeerDependencies:
- oxc-resolver
- rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2):
- dependencies:
- '@oxc-project/runtime': 0.101.0
- fdir: 6.5.0(picomatch@4.0.3)
- lightningcss: 1.30.2
- picomatch: 4.0.3
- postcss: 8.5.6
- rolldown: 1.0.0-beta.53
- tinyglobby: 0.2.15
- optionalDependencies:
- '@types/node': 24.10.4
- esbuild: 0.25.12
- fsevents: 2.3.3
- jiti: 2.6.1
- less: 4.5.1
- terser: 5.44.1
- tsx: 4.21.0
- yaml: 2.8.2
-
rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
'@oxc-project/runtime': 0.101.0
@@ -30451,13 +30383,13 @@ snapshots:
unpipe@1.0.0: {}
- unplugin-combine@2.1.3(esbuild@0.25.12)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(rolldown@1.0.0-beta.53)(rollup@2.79.2)(unplugin@2.3.11):
+ unplugin-combine@2.1.3(esbuild@0.27.2)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(rolldown@1.0.0-beta.53)(rollup@2.79.2)(unplugin@2.3.11):
optionalDependencies:
- esbuild: 0.25.12
+ esbuild: 0.27.2
rolldown: 1.0.0-beta.53
rollup: 2.79.2
unplugin: 2.3.11
- vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
+ vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
unplugin-combine@2.1.3(esbuild@0.27.2)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(rolldown@1.0.0-beta.53)(rollup@4.54.0)(unplugin@2.3.11):
optionalDependencies:
@@ -30495,16 +30427,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
- unplugin-info@1.2.4(esbuild@0.25.12)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(rollup@2.79.2):
+ unplugin-info@1.2.4(esbuild@0.27.2)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(rollup@2.79.2):
dependencies:
ci-info: 4.3.1
git-url-parse: 16.1.0
simple-git: 3.30.0
unplugin: 2.3.11
optionalDependencies:
- esbuild: 0.25.12
+ esbuild: 0.27.2
rollup: 2.79.2
- vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
+ vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies:
- supports-color
@@ -30629,16 +30561,16 @@ snapshots:
rollup: 4.54.0
vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
- unplugin-yaml@3.0.7(esbuild@0.25.12)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(rolldown@1.0.0-beta.53)(rollup@2.79.2):
+ unplugin-yaml@3.0.7(esbuild@0.27.2)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(rolldown@1.0.0-beta.53)(rollup@2.79.2):
dependencies:
'@rollup/pluginutils': 5.3.0(rollup@2.79.2)
unplugin: 2.3.10
yaml: 2.8.1
optionalDependencies:
- esbuild: 0.25.12
+ esbuild: 0.27.2
rolldown: 1.0.0-beta.53
rollup: 2.79.2
- vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
+ vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
unplugin-yaml@3.0.7(esbuild@0.27.2)(rolldown@1.0.0-beta.53)(rollup@4.54.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)):
dependencies:
@@ -30826,12 +30758,6 @@ snapshots:
- rollup
- supports-color
- vite-dev-rpc@1.1.0(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)):
- dependencies:
- birpc: 2.9.0
- vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
- vite-hot-client: 2.1.0(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
-
vite-dev-rpc@1.1.0(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)):
dependencies:
birpc: 2.9.0
@@ -30844,10 +30770,6 @@ snapshots:
vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vite-hot-client: 2.1.0(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
- vite-hot-client@2.1.0(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)):
- dependencies:
- vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
-
vite-hot-client@2.1.0(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)):
dependencies:
vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
@@ -30912,21 +30834,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- vite-plugin-inspect@11.3.3(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)):
- dependencies:
- ansis: 4.2.0
- debug: 4.4.3
- error-stack-parser-es: 1.0.5
- ohash: 2.0.11
- open: 10.2.0
- perfect-debounce: 2.0.0
- sirv: 3.0.2
- unplugin-utils: 0.3.1
- vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
- vite-dev-rpc: 1.1.0(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
- transitivePeerDependencies:
- - supports-color
-
vite-plugin-inspect@11.3.3(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)):
dependencies:
ansis: 4.2.0
@@ -30942,23 +30849,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
- vite-plugin-mkcert@1.17.9(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)):
+ vite-plugin-mkcert@1.17.9(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)):
dependencies:
axios: feaxios@0.0.23
debug: 4.4.3
picocolors: 1.1.1
- vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
- transitivePeerDependencies:
- - supports-color
-
- vite-plugin-pwa@1.2.0(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(workbox-build@7.4.0)(workbox-window@7.4.0):
- dependencies:
- debug: 4.4.3
- pretty-bytes: 6.1.1
- tinyglobby: 0.2.15
- vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
- workbox-build: 7.4.0
- workbox-window: 7.4.0
+ vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies:
- supports-color
@@ -30987,35 +30883,6 @@ snapshots:
- supports-color
- vue
- vite-plugin-vue-devtools@8.0.5(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3)):
- dependencies:
- '@vue/devtools-core': 8.0.5(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))
- '@vue/devtools-kit': 8.0.5
- '@vue/devtools-shared': 8.0.5
- sirv: 3.0.2
- vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
- vite-plugin-inspect: 11.3.3(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
- vite-plugin-vue-inspector: 5.3.2(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
- transitivePeerDependencies:
- - '@nuxt/kit'
- - supports-color
- - vue
-
- vite-plugin-vue-inspector@5.3.2(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)):
- dependencies:
- '@babel/core': 7.28.5
- '@babel/plugin-proposal-decorators': 7.28.0(@babel/core@7.28.5)
- '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.28.5)
- '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.28.5)
- '@babel/plugin-transform-typescript': 7.28.5(@babel/core@7.28.5)
- '@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.28.5)
- '@vue/compiler-dom': 3.5.26
- kolorist: 1.8.0
- magic-string: 0.30.21
- vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
- transitivePeerDependencies:
- - supports-color
-
vite-plugin-vue-inspector@5.3.2(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)):
dependencies:
'@babel/core': 7.28.5
@@ -31031,16 +30898,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- vite-plugin-vue-layouts@0.11.0(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue-router@4.6.4(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3)):
- dependencies:
- debug: 4.4.3
- fast-glob: 3.3.3
- vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
- vue: 3.5.25(typescript@5.9.3)
- vue-router: 4.6.4(vue@3.5.25(typescript@5.9.3))
- transitivePeerDependencies:
- - supports-color
-
vite-plugin-vue-layouts@0.11.0(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue-router@4.6.4(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3)):
dependencies:
debug: 4.4.3
@@ -31195,7 +31052,7 @@ snapshots:
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/node': 24.10.4
- '@vitest/browser-playwright': 4.0.16(bufferutil@4.1.0)(playwright@1.57.0)(utf-8-validate@5.0.10)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16)
+ '@vitest/browser-playwright': 4.0.16(bufferutil@4.1.0)(playwright@1.57.0)(utf-8-validate@5.0.10)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16)
jsdom: 25.0.1(bufferutil@4.1.0)(utf-8-validate@5.0.10)
transitivePeerDependencies:
- jiti
@@ -31242,7 +31099,7 @@ snapshots:
'@vue/devtools-api': 6.6.4
vue: 3.5.25(typescript@5.9.3)
- vue-macros@3.1.1(@vueuse/core@14.1.0(vue@3.5.25(typescript@5.9.3)))(esbuild@0.25.12)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(rolldown@1.0.0-beta.53)(rollup@2.79.2)(typescript@5.9.3)(vue-tsc@3.2.1(typescript@5.9.3))(vue@3.5.25(typescript@5.9.3)):
+ vue-macros@3.1.1(@vueuse/core@14.1.0(vue@3.5.25(typescript@5.9.3)))(esbuild@0.27.2)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(rolldown@1.0.0-beta.53)(rollup@2.79.2)(typescript@5.9.3)(vue-tsc@3.2.1(typescript@5.9.3))(vue@3.5.25(typescript@5.9.3)):
dependencies:
'@vue-macros/better-define': 3.1.1(vue@3.5.25(typescript@5.9.3))
'@vue-macros/boolean-prop': 3.1.1(vue@3.5.25(typescript@5.9.3))
@@ -31257,7 +31114,7 @@ snapshots:
'@vue-macros/define-render': 3.1.1(vue@3.5.25(typescript@5.9.3))
'@vue-macros/define-slots': 3.1.1(vue@3.5.25(typescript@5.9.3))
'@vue-macros/define-stylex': 3.1.1(vue@3.5.25(typescript@5.9.3))
- '@vue-macros/devtools': 3.1.1(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(typescript@5.9.3)
+ '@vue-macros/devtools': 3.1.1(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(typescript@5.9.3)
'@vue-macros/export-expose': 3.1.1(vue@3.5.25(typescript@5.9.3))
'@vue-macros/export-props': 3.1.1(vue@3.5.25(typescript@5.9.3))
'@vue-macros/export-render': 3.1.1(vue@3.5.25(typescript@5.9.3))
@@ -31274,7 +31131,7 @@ snapshots:
'@vue-macros/short-vmodel': 3.1.1(vue@3.5.25(typescript@5.9.3))
'@vue-macros/volar': 3.1.1(typescript@5.9.3)(vue-tsc@3.2.1(typescript@5.9.3))(vue@3.5.25(typescript@5.9.3))
unplugin: 2.3.11
- unplugin-combine: 2.1.3(esbuild@0.25.12)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(rolldown@1.0.0-beta.53)(rollup@2.79.2)(unplugin@2.3.11)
+ unplugin-combine: 2.1.3(esbuild@0.27.2)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(rolldown@1.0.0-beta.53)(rollup@2.79.2)(unplugin@2.3.11)
unplugin-vue-define-options: 3.1.1(vue@3.5.25(typescript@5.9.3))
vue: 3.5.25(typescript@5.9.3)
transitivePeerDependencies:
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index b5d6aa5d69..3093051011 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -21,7 +21,9 @@ catalog:
'@pinia/testing': ^1.0.3
'@proj-airi/drizzle-duckdb-wasm': ^0.4.29
'@types/audioworklet': ^0.0.92
+ '@types/hast': ^3.0.4
'@types/splitpanes': ^2.2.6
+ '@types/unist': ^3.0.3
'@xsai-ext/providers': ^0.4.0-beta.13
'@xsai/embed': ^0.4.0-beta.13
'@xsai/generate-speech': 0.4.0-beta.13