refactor(stage-ui,stage-pages): improve structure of character & character-orchestrator

This commit is contained in:
Neko Ayaka
2026-01-10 03:15:04 +08:00
parent 44520d3083
commit 56d321f39c
15 changed files with 214 additions and 157 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { OnboardingDialog, ToasterRoot } from '@proj-airi/stage-ui/components'
import { useSharedAnalyticsStore } from '@proj-airi/stage-ui/stores/analytics'
import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/character-orchestrator'
import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/character'
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
import { useModsServerChannelStore } from '@proj-airi/stage-ui/stores/mods/api/channel-server'
import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/context-bridge'
+1 -1
View File
@@ -3,7 +3,7 @@ import { defineInvoke, defineInvokeHandler } from '@moeru/eventa'
import { themeColorFromValue, useThemeColor } from '@proj-airi/stage-layouts/composables/theme-color'
import { ToasterRoot } from '@proj-airi/stage-ui/components'
import { useSharedAnalyticsStore } from '@proj-airi/stage-ui/stores/analytics'
import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/character-orchestrator'
import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/character'
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
import { useModsServerChannelStore } from '@proj-airi/stage-ui/stores/mods/api/channel-server'
import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/context-bridge'
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { OnboardingDialog, ToasterRoot } from '@proj-airi/stage-ui/components'
import { useSharedAnalyticsStore } from '@proj-airi/stage-ui/stores/analytics'
import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/character-orchestrator'
import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/character'
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
import { useModsServerChannelStore } from '@proj-airi/stage-ui/stores/mods/api/channel-server'
import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/context-bridge'
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { FlowEntry, SparkNotifyEntryState } from '../context-flow-types'
import type { FlowChannel, FlowDirection, FlowEntry, SparkNotifyEntryState } from '../context-flow-types'
import ContextFlowPreview from './context-flow-preview.vue'
import ContextFlowSparkNotify from './context-flow-spark-notify.vue'
@@ -9,14 +9,51 @@ import { useContextFlowFormatters } from '../composables/use-context-flow-format
defineProps<{ entry: FlowEntry, sparkNotifyState?: SparkNotifyEntryState }>()
const {
channelBadgeClasses,
directionBadgeClasses,
directionIconClass,
formatPayload,
formatTimestamp,
getEventSource,
sourceBadgeClasses,
} = useContextFlowFormatters()
const directionBadgeClassMap: Record<FlowDirection, string[]> = {
incoming: [
'bg-complementary-500/15',
'text-complementary-600',
'dark:text-complementary-300',
'border-complementary-500/30',
],
outgoing: [
'bg-primary-500/15',
'text-primary-600',
'dark:text-primary-300',
'border-primary-500/30',
],
}
const channelBadgeClassMap: Record<FlowChannel, string[]> = {
server: ['bg-orange-500/15', 'text-orange-600', 'dark:text-orange-300', 'border-orange-500/30'],
broadcast: ['bg-violet-500/15', 'text-violet-600', 'dark:text-violet-300', 'border-violet-500/30'],
chat: ['bg-lime-500/15', 'text-lime-600', 'dark:text-lime-300', 'border-lime-500/30'],
devtools: ['bg-neutral-400/15', 'text-neutral-600', 'dark:text-neutral-300', 'border-neutral-500/30'],
}
const directionIconClassMap: Record<FlowDirection, string> = {
incoming: 'i-solar:arrow-down-linear',
outgoing: 'i-solar:arrow-up-linear',
}
const sourceBadgeClasses = ['bg-neutral-400/15', 'text-neutral-600', 'dark:text-neutral-300', 'border-neutral-500/30']
function directionBadgeClasses(direction: FlowDirection) {
return directionBadgeClassMap[direction]
}
function channelBadgeClasses(channel: FlowChannel) {
return channelBadgeClassMap[channel]
}
function directionIconClass(direction: FlowDirection) {
return directionIconClassMap[direction]
}
</script>
<template>
@@ -42,7 +79,7 @@ const {
</span>
<span
v-if="getEventSource(entry)"
:class="['rounded-full', 'border', 'px-2', 'py-0.5', ...sourceBadgeClasses()]"
:class="['rounded-full', 'border', 'px-2', 'py-0.5', ...sourceBadgeClasses]"
>
{{ getEventSource(entry) }}
</span>
@@ -19,11 +19,8 @@ const { buildPreviewItems } = useContextFlowFormatters()
:key="`${entry.id}-${item.label}`"
:class="[
'rounded-lg',
'border',
'border-neutral-200/70',
'bg-white/80',
'p-3',
'dark:border-neutral-800/80',
'dark:bg-neutral-900/70',
]"
>
@@ -13,12 +13,9 @@ const { buildSparkCommandPreview } = useContextFlowFormatters()
:class="[
'mt-3',
'rounded-lg',
'border',
'border-neutral-200/70',
'bg-white/80',
'p-3',
'text-xs',
'dark:border-neutral-800/80',
'dark:bg-neutral-900/70',
'grid',
'gap-3',
@@ -29,7 +26,7 @@ const { buildSparkCommandPreview } = useContextFlowFormatters()
<div :class="['flex', 'items-center', 'gap-2', 'text-neutral-700', 'dark:text-neutral-200']">
<span
v-if="state.handling"
:class="['size-3.5', 'i-solar:spinner-line-duotone', 'animate-spin']"
:class="['size-3.5', 'i-svg-spinners:ring-resize', 'animate-spin']"
/>
<span>
{{ state.handling ? 'Handling spark:notify...' : 'spark:notify handled' }}
@@ -1,6 +1,6 @@
import type { WebSocketEvents } from '@proj-airi/server-sdk'
import type { FlowChannel, FlowDirection, FlowEntry, PreviewItem } from '../context-flow-types'
import type { FlowEntry, PreviewItem } from '../context-flow-types'
const previewMaxLength = 420
@@ -187,57 +187,15 @@ function formatPayload(payload: unknown) {
}
}
function directionBadgeClasses(direction: FlowDirection) {
if (direction === 'incoming') {
return [
'bg-complementary-500/15',
'text-complementary-600',
'dark:text-complementary-300',
'border-complementary-500/30',
]
}
return [
'bg-primary-500/15',
'text-primary-600',
'dark:text-primary-300',
'border-primary-500/30',
]
}
function directionIconClass(direction: FlowDirection) {
return direction === 'incoming' ? 'i-solar:arrow-down-linear' : 'i-solar:arrow-up-linear'
}
function channelBadgeClasses(channel: FlowChannel) {
switch (channel) {
case 'server':
return ['bg-orange-500/15', 'text-orange-600', 'dark:text-orange-300', 'border-orange-500/30']
case 'broadcast':
return ['bg-violet-500/15', 'text-violet-600', 'dark:text-violet-300', 'border-violet-500/30']
case 'chat':
return ['bg-lime-500/15', 'text-lime-600', 'dark:text-lime-300', 'border-lime-500/30']
default:
return ['bg-neutral-400/15', 'text-neutral-600', 'dark:text-neutral-300', 'border-neutral-500/30']
}
}
function sourceBadgeClasses() {
return ['bg-neutral-400/15', 'text-neutral-600', 'dark:text-neutral-300', 'border-neutral-500/30']
}
export function useContextFlowFormatters() {
return {
buildPreviewItems,
buildSparkCommandPreview,
channelBadgeClasses,
directionBadgeClasses,
directionIconClass,
formatDestinations,
formatPayload,
formatTimestamp,
getEventSource,
getPayloadData,
sourceBadgeClasses,
summarizeContextUpdate,
truncateText,
}
@@ -6,8 +6,7 @@ import type { FlowDirection, FlowEntry, SparkNotifyEntryState } from './context-
import { errorMessageFrom } from '@moeru/std'
import { ContextUpdateStrategy } from '@proj-airi/server-sdk'
import { useCharacterStore } from '@proj-airi/stage-ui/stores/character'
import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/character-orchestrator'
import { useCharacterOrchestratorStore, useCharacterStore } from '@proj-airi/stage-ui/stores/character'
import { CHAT_STREAM_CHANNEL_NAME, CONTEXT_CHANNEL_NAME, useChatStore } from '@proj-airi/stage-ui/stores/chat'
import { useModsServerChannelStore } from '@proj-airi/stage-ui/stores/mods/api/channel-server'
import { Callout } from '@proj-airi/ui'
+5
View File
@@ -9,6 +9,11 @@
],
"module": "ESNext",
"moduleResolution": "Bundler",
"paths": {
"@proj-airi/stage-ui/*": [
"../../packages/stage-ui/src/*"
]
},
"resolveJsonModule": true,
"types": [
"vitest",
+1
View File
@@ -28,6 +28,7 @@
"./libs": "./src/libs/index.ts",
"./stores/providers/aliyun": "./src/stores/providers/aliyun/index.ts",
"./stores/analytics": "./src/stores/analytics/index.ts",
"./stores/character": "./src/stores/character/index.ts",
"./stores/*": "./src/stores/*.ts",
"./stores": "./src/stores/index.ts",
"./workers/vad": "./src/workers/vad/index.ts",
@@ -1,12 +1,14 @@
import type { TextSegmentationItem } from '../composables/queues'
import type { TextSegmentationItem } from '../../composables/queues'
import { nanoid } from 'nanoid'
import { defineStore, storeToRefs } from 'pinia'
import { computed, reactive, ref } from 'vue'
import { usePipelineWorkflowTextSegmentationStore } from '../composables/queues'
import { TTS_FLUSH_INSTRUCTION } from '../utils/tts'
import { useAiriCardStore } from './modules'
import { usePipelineWorkflowTextSegmentationStore } from '../../composables/queues'
import { TTS_FLUSH_INSTRUCTION } from '../../utils/tts'
import { useAiriCardStore } from '../modules'
export * from './orchestrator'
export interface CharacterSparkNotifyReaction {
id: string
@@ -1,24 +1,16 @@
import type { WebSocketBaseEvent, WebSocketEvents } from '@proj-airi/server-sdk'
import type { ChatProvider } from '@xsai-ext/providers/utils'
import type { ChatProvider, ChatProviderWithExtraOptions, EmbedProvider, EmbedProviderWithExtraOptions, SpeechProvider, SpeechProviderWithExtraOptions, TranscriptionProvider, TranscriptionProviderWithExtraOptions } from '@xsai-ext/providers/utils'
import type { Message } from '@xsai/shared-chat'
import type { StreamEvent } from './llm'
import type { StreamEvent } from '../../../../llm'
import { errorMessageFrom } from '@moeru/std'
import { tool } from '@xsai/tool'
import { nanoid } from 'nanoid'
import { defineStore, storeToRefs } from 'pinia'
import { ref } from 'vue'
import { validate } from 'xsschema'
import { z } from 'zod'
import { useCharacterStore } from './character'
import { useLLM } from './llm'
import { useModsServerChannelStore } from './mods/api/channel-server'
import { useConsciousnessStore } from './modules/consciousness'
import { useProvidersStore } from './providers'
interface SparkNotifyCommandDraft {
export interface SparkNotifyCommandDraft {
destinations: string[]
interrupt?: 'force' | 'soft' | boolean
priority?: 'critical' | 'high' | 'normal' | 'low'
@@ -28,11 +20,45 @@ interface SparkNotifyCommandDraft {
contexts?: WebSocketEvents['spark:command']['contexts']
}
interface SparkNotifyResponse {
export interface SparkNotifyResponse {
reaction?: string
commands?: SparkNotifyCommandDraft[]
}
export interface SparkNotifyAgentDeps {
stream: (
model: string,
provider: ChatProvider,
messages: Message[],
options: {
tools?: any[]
supportsTools?: boolean
waitForTools?: boolean
onStreamEvent?: (event: StreamEvent) => void | Promise<void>
},
) => Promise<void>
getActiveProvider: () => string | undefined
getActiveModel: () => string | undefined
getProviderInstance: <R extends
| ChatProvider
| ChatProviderWithExtraOptions
| EmbedProvider
| EmbedProviderWithExtraOptions
| SpeechProvider
| SpeechProviderWithExtraOptions
| TranscriptionProvider
| TranscriptionProviderWithExtraOptions,
>(name: string,
) => Promise<R>
onReactionDelta: (eventId: string, text: string) => void
onReactionEnd: (eventId: string, text: string) => void
getSystemPrompt: () => string
getProcessing: () => boolean
setProcessing: (next: boolean) => void
getPending: () => Array<WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify']>>
setPending: (next: Array<WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify']>>) => void
}
function getSparkNotifyHandlingAgentInstruction(moduleName: string) {
return [
'This is AIRI system, the life pod hosting your consciousness. You don\'t need to respond to me or every spark:notify event directly.',
@@ -72,33 +98,25 @@ export const sparkCommandSchema = z.object({
export type SparkCommandSchema = z.infer<typeof sparkCommandSchema>
export const useCharacterOrchestratorStore = defineStore('character-orchestrator', () => {
const { stream } = useLLM()
const { activeProvider, activeModel } = storeToRefs(useConsciousnessStore())
const providersStore = useProvidersStore()
const characterStore = useCharacterStore()
const { systemPrompt } = storeToRefs(characterStore)
const modsServerChannelStore = useModsServerChannelStore()
const processing = ref(false)
const pendingNotifies = ref<Array<WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify']>>>([])
export function setupAgentSparkNotifyHandler(deps: SparkNotifyAgentDeps) {
async function runNotifyAgent(event: WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify']>) {
if (!activeProvider.value || !activeModel.value) {
const activeProvider = deps.getActiveProvider()
const activeModel = deps.getActiveModel()
if (!activeProvider || !activeModel) {
console.warn('Spark notify ignored: missing active provider or model')
return undefined
}
const chatProvider = await providersStore.getProviderInstance<ChatProvider>(activeProvider.value)
const chatProvider = await deps.getProviderInstance<ChatProvider>(activeProvider)
const commandDrafts: SparkNotifyCommandDraft[] = []
let noResponse = false
const sparkNoResponseTool = await tool({
name: 'builtIn_sparkNoResponse',
description: `Indicate that no response or action is needed for the current spark:notify event.`,
description: 'Indicate that no response or action is needed for the current spark:notify event.',
parameters: z.object({}).strict(),
execute: async (_payload) => {
execute: async () => {
noResponse = true
return 'AIRI System: Acknowledged, no response or action will be processed.'
},
@@ -106,7 +124,7 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
const sparkCommandTool = await tool({
name: 'builtIn_sparkCommand',
description: `Issue a spark:command to sub-agents. You can call this tool multiple times to issue matrices of commands to different sub-agents as needed.`,
description: 'Issue a spark:command to sub-agents. You can call this tool multiple times to issue matrices of commands to different sub-agents as needed.',
parameters: sparkCommandSchema,
execute: async (payload) => {
try {
@@ -153,7 +171,7 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
const systemMessage: Message = {
role: 'system',
content: [
systemPrompt.value,
deps.getSystemPrompt(),
getSparkNotifyHandlingAgentInstruction(event.source),
].filter(Boolean).join('\n\n'),
}
@@ -168,32 +186,32 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
let fullText = ''
await stream(activeModel.value, chatProvider, [systemMessage, userMessage], {
await deps.stream(activeModel, chatProvider, [systemMessage, userMessage], {
tools: [
sparkNoResponseTool,
sparkCommandTool,
],
supportsTools: true, // we expect tools to be supported
waitForTools: true, // see https://github.com/moeru-ai/airi/issues/907
supportsTools: true,
waitForTools: true,
onStreamEvent: async (streamEvent: StreamEvent) => {
if (streamEvent.type === 'text-delta') {
if (noResponse)
return
characterStore.onSparkNotifyReactionStreamEvent(event.data.id, streamEvent.text)
deps.onReactionDelta(event.data.id, streamEvent.text)
fullText += streamEvent.text
}
if (streamEvent.type === 'finish') {
if (noResponse) {
characterStore.onSparkNotifyReactionStreamEnd(event.data.id, '')
deps.onReactionEnd(event.data.id, '')
return
}
characterStore.onSparkNotifyReactionStreamEnd(event.data.id, fullText)
deps.onReactionEnd(event.data.id, fullText)
}
if (streamEvent.type === 'error') {
characterStore.onSparkNotifyReactionStreamEnd(event.data.id, fullText)
deps.onReactionEnd(event.data.id, fullText)
throw streamEvent.error ?? new Error('Spark notify stream error')
}
},
@@ -205,17 +223,17 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
} satisfies SparkNotifyResponse
}
async function handleSparkNotify(event: WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify']>) {
if (event.data.urgency !== 'immediate' && pendingNotifies.value.length > 0) {
pendingNotifies.value = [...pendingNotifies.value, event]
async function handle(event: WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify']>) {
if (event.data.urgency !== 'immediate' && deps.getPending().length > 0) {
deps.setPending([...deps.getPending(), event])
return undefined
}
if (processing.value) {
pendingNotifies.value = [...pendingNotifies.value, event]
if (deps.getProcessing()) {
deps.setPending([...deps.getPending(), event])
return undefined
}
processing.value = true
deps.setProcessing(true)
try {
const response = await runNotifyAgent(event)
@@ -243,51 +261,11 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
}
}
finally {
processing.value = false
deps.setProcessing(false)
}
}
async function handleSparkEmit(_: WebSocketBaseEvent<'spark:emit', WebSocketEvents['spark:emit']>) {
// Currently no-op
return undefined
}
function initialize() {
modsServerChannelStore.onEvent('spark:notify', async (event) => {
try {
const result = await handleSparkNotify(event)
if (!result?.commands?.length)
return
for (const command of result.commands) {
modsServerChannelStore.send({
type: 'spark:command',
data: command,
})
}
}
catch (error) {
console.warn('Failed to handle spark:notify event:', error)
}
})
modsServerChannelStore.onEvent('spark:emit', async (event) => {
try {
await handleSparkEmit(event)
}
catch (error) {
console.warn('Failed to handle spark:emit event:', error)
}
})
}
return {
processing,
pendingNotifies,
initialize,
handleSparkNotify,
handleSparkEmit,
handle,
}
})
}
@@ -7,8 +7,8 @@ import type { Mock } from 'vitest'
import type { UnwrapRef } from 'vue'
import type z from 'zod'
import type { StreamEvent } from './llm'
import type { AiriCard } from './modules'
import type { StreamEvent } from '../../llm'
import type { AiriCard } from '../../modules'
import { createTestingPinia } from '@pinia/testing'
import { tool } from '@xsai/tool'
@@ -16,11 +16,11 @@ import { nanoid } from 'nanoid'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCharacterStore } from './character'
import { sparkCommandSchema, useCharacterOrchestratorStore } from './character-orchestrator'
import { useLLM } from './llm'
import { useAiriCardStore, useConsciousnessStore } from './modules'
import { useProvidersStore } from './providers'
import { sparkCommandSchema, useCharacterOrchestratorStore } from '.'
import { useCharacterStore } from '..'
import { useLLM } from '../../llm'
import { useAiriCardStore, useConsciousnessStore } from '../../modules'
import { useProvidersStore } from '../../providers'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
@@ -0,0 +1 @@
export * from './store'
@@ -0,0 +1,82 @@
import type { WebSocketBaseEvent, WebSocketEvents } from '@proj-airi/server-sdk'
import { defineStore, storeToRefs } from 'pinia'
import { ref } from 'vue'
import { useCharacterStore } from '../'
import { useLLM } from '../../llm'
import { useModsServerChannelStore } from '../../mods/api/channel-server'
import { useConsciousnessStore } from '../../modules/consciousness'
import { useProvidersStore } from '../../providers'
import { setupAgentSparkNotifyHandler } from './agents/event-handler-spark-notify'
export { sparkCommandSchema } from './agents/event-handler-spark-notify'
export const useCharacterOrchestratorStore = defineStore('character-orchestrator', () => {
const { stream } = useLLM()
const { activeProvider, activeModel } = storeToRefs(useConsciousnessStore())
const providersStore = useProvidersStore()
const characterStore = useCharacterStore()
const { systemPrompt } = storeToRefs(characterStore)
const modsServerChannelStore = useModsServerChannelStore()
const processing = ref(false)
const pendingNotifies = ref<Array<WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify']>>>([])
const sparkNotifyAgent = setupAgentSparkNotifyHandler({
stream,
getActiveProvider: () => activeProvider.value,
getActiveModel: () => activeModel.value,
getProviderInstance: name => providersStore.getProviderInstance(name),
onReactionDelta: (eventId, text) => characterStore.onSparkNotifyReactionStreamEvent(eventId, text),
onReactionEnd: (eventId, text) => characterStore.onSparkNotifyReactionStreamEnd(eventId, text),
getSystemPrompt: () => systemPrompt.value,
getProcessing: () => processing.value,
setProcessing: next => processing.value = next,
getPending: () => pendingNotifies.value,
setPending: next => pendingNotifies.value = next,
})
async function handleSparkEmit(_: WebSocketBaseEvent<'spark:emit', WebSocketEvents['spark:emit']>) {
// Currently no-op
return undefined
}
function initialize() {
modsServerChannelStore.onEvent('spark:notify', async (event) => {
try {
const result = await sparkNotifyAgent.handle(event)
if (!result?.commands?.length)
return
for (const command of result.commands) {
modsServerChannelStore.send({
type: 'spark:command',
data: command,
})
}
}
catch (error) {
console.warn('Failed to handle spark:notify event:', error)
}
})
modsServerChannelStore.onEvent('spark:emit', async (event) => {
try {
await handleSparkEmit(event)
}
catch (error) {
console.warn('Failed to handle spark:emit event:', error)
}
})
}
return {
processing,
pendingNotifies,
initialize,
handleSparkNotify: sparkNotifyAgent.handle,
handleSparkEmit,
}
})