refactor(stage-ui): better chat store structure
This commit is contained in:
@@ -8,7 +8,9 @@ import { useDelayMessageQueue, useEmotionsMessageQueue } from '@proj-airi/stage-
|
||||
import { llmInferenceEndToken } from '@proj-airi/stage-ui/constants'
|
||||
import { EMOTION_EmotionMotionName_value, EMOTION_VRMExpressionName_value, EmotionThinkMotionName } from '@proj-airi/stage-ui/constants/emotions'
|
||||
import { useAudioContext, useSpeakingStore } from '@proj-airi/stage-ui/stores/audio'
|
||||
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { useChatOrchestratorStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { useChatMaintenanceStore } from '@proj-airi/stage-ui/stores/chat/maintenance'
|
||||
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
|
||||
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
|
||||
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
|
||||
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
|
||||
@@ -62,9 +64,11 @@ const nowSpeaking = ref(false)
|
||||
const currentMotion = ref<{ group: string }>({ group: EmotionThinkMotionName })
|
||||
const logLines = ref<string[]>([])
|
||||
const chatInput = ref('')
|
||||
const chatStore = useChatStore()
|
||||
const chatOrchestrator = useChatOrchestratorStore()
|
||||
const chatSession = useChatSessionStore()
|
||||
const chatMaintenance = useChatMaintenanceStore()
|
||||
const chatMessages = computed(() => {
|
||||
return chatStore.messages
|
||||
return chatSession.messages
|
||||
.filter(msg => msg.role !== 'system')
|
||||
.map((msg) => {
|
||||
const text = typeof msg.content === 'string'
|
||||
@@ -196,7 +200,7 @@ async function sendChat() {
|
||||
}
|
||||
|
||||
try {
|
||||
await chatStore.send(content, {
|
||||
await chatOrchestrator.ingest(content, {
|
||||
model: activeChatModel.value,
|
||||
chatProvider: provider as ChatProvider,
|
||||
})
|
||||
@@ -209,13 +213,13 @@ async function sendChat() {
|
||||
}
|
||||
|
||||
function resetChat() {
|
||||
chatStore.cleanupMessages()
|
||||
chatMaintenance.cleanupMessages()
|
||||
chatInput.value = ''
|
||||
logLines.value = []
|
||||
playbackManager.stopAll('reset')
|
||||
}
|
||||
|
||||
const { onBeforeMessageComposed, onBeforeSend, onTokenLiteral, onTokenSpecial, onStreamEnd, onAssistantResponseEnd } = chatStore
|
||||
const { onBeforeMessageComposed, onBeforeSend, onTokenLiteral, onTokenSpecial, onStreamEnd, onAssistantResponseEnd } = chatOrchestrator
|
||||
const chatHookCleanups: Array<() => void> = []
|
||||
let currentIntent: ReturnType<typeof speechPipeline.openIntent> | null = null
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useBackgroundStore } from '@proj-airi/stage-layouts/stores/background'
|
||||
import { WidgetStage } from '@proj-airi/stage-ui/components/scenes'
|
||||
import { useAudioRecorder } from '@proj-airi/stage-ui/composables/audio/audio-recorder'
|
||||
import { useVAD } from '@proj-airi/stage-ui/stores/ai/models/vad'
|
||||
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { useChatOrchestratorStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { useLive2d } from '@proj-airi/stage-ui/stores/live2d'
|
||||
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
|
||||
import { useHearingSpeechInputPipeline } from '@proj-airi/stage-ui/stores/modules/hearing'
|
||||
@@ -51,7 +51,7 @@ const { supportsStreamInput } = storeToRefs(hearingPipeline)
|
||||
const providersStore = useProvidersStore()
|
||||
const consciousnessStore = useConsciousnessStore()
|
||||
const { activeProvider: activeChatProvider, activeModel: activeChatModel } = storeToRefs(consciousnessStore)
|
||||
const chatStore = useChatStore()
|
||||
const chatStore = useChatOrchestratorStore()
|
||||
|
||||
const shouldUseStreamInput = computed(() => supportsStreamInput.value && !!stream.value)
|
||||
|
||||
@@ -85,7 +85,7 @@ async function startAudioInteraction() {
|
||||
if (!provider || !activeChatModel.value)
|
||||
return
|
||||
|
||||
await chatStore.send(text, { model: activeChatModel.value, chatProvider: provider as ChatProvider })
|
||||
await chatStore.ingest(text, { model: activeChatModel.value, chatProvider: provider as ChatProvider })
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Failed to send chat from voice:', err)
|
||||
@@ -112,7 +112,7 @@ async function handleSpeechStart() {
|
||||
if (!provider || !activeChatModel.value)
|
||||
return
|
||||
|
||||
await chatStore.send(finalText, { model: activeChatModel.value, chatProvider: provider as ChatProvider })
|
||||
await chatStore.ingest(finalText, { model: activeChatModel.value, chatProvider: provider as ChatProvider })
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Failed to send chat from voice:', err)
|
||||
|
||||
@@ -3,7 +3,10 @@ import type { ChatHistoryItem } from '@proj-airi/stage-ui/types/chat'
|
||||
import type { ChatProvider } from '@xsai-ext/providers/utils'
|
||||
|
||||
import { ChatHistory } from '@proj-airi/stage-ui/components'
|
||||
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { useChatOrchestratorStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { useChatMaintenanceStore } from '@proj-airi/stage-ui/stores/chat/maintenance'
|
||||
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
|
||||
import { useChatStreamStore } from '@proj-airi/stage-ui/stores/chat/stream-store'
|
||||
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
|
||||
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
|
||||
import { BasicTextarea } from '@proj-airi/ui'
|
||||
@@ -16,9 +19,14 @@ import { widgetsTools } from '../stores/tools/builtin/widgets'
|
||||
const messageInput = ref('')
|
||||
const attachments = ref<{ type: 'image', data: string, mimeType: string, url: string }[]>([])
|
||||
|
||||
const chatStore = useChatStore()
|
||||
const { send, onAfterMessageComposed, discoverToolsCompatibility, cleanupMessages } = chatStore
|
||||
const { messages, sending, streamingMessage } = storeToRefs(chatStore)
|
||||
const chatOrchestrator = useChatOrchestratorStore()
|
||||
const chatSession = useChatSessionStore()
|
||||
const chatStream = useChatStreamStore()
|
||||
const { cleanupMessages } = useChatMaintenanceStore()
|
||||
const { ingest, onAfterMessageComposed, discoverToolsCompatibility } = chatOrchestrator
|
||||
const { messages } = storeToRefs(chatSession)
|
||||
const { streamingMessage } = storeToRefs(chatStream)
|
||||
const { sending } = storeToRefs(chatOrchestrator)
|
||||
const { t } = useI18n()
|
||||
const providersStore = useProvidersStore()
|
||||
const { activeModel, activeProvider } = storeToRefs(useConsciousnessStore())
|
||||
@@ -42,7 +50,7 @@ async function handleSend() {
|
||||
|
||||
try {
|
||||
const providerConfig = providersStore.getProviderConfig(activeProvider.value)
|
||||
await send(textToSend, {
|
||||
await ingest(textToSend, {
|
||||
model: activeModel.value,
|
||||
chatProvider: await providersStore.getProviderInstance<ChatProvider>(activeProvider.value),
|
||||
providerConfig,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { WidgetStage } from '@proj-airi/stage-ui/components/scenes'
|
||||
import { useAudioRecorder } from '@proj-airi/stage-ui/composables/audio/audio-recorder'
|
||||
import { useCanvasPixelIsTransparentAtPoint } from '@proj-airi/stage-ui/composables/canvas-alpha'
|
||||
import { useVAD } from '@proj-airi/stage-ui/stores/ai/models/vad'
|
||||
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { useChatOrchestratorStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { useLive2d } from '@proj-airi/stage-ui/stores/live2d'
|
||||
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
|
||||
import { useHearingSpeechInputPipeline } from '@proj-airi/stage-ui/stores/modules/hearing'
|
||||
@@ -136,7 +136,7 @@ const { supportsStreamInput } = storeToRefs(hearingPipeline)
|
||||
const providersStore = useProvidersStore()
|
||||
const consciousnessStore = useConsciousnessStore()
|
||||
const { activeProvider: activeChatProvider, activeModel: activeChatModel } = storeToRefs(consciousnessStore)
|
||||
const chatStore = useChatStore()
|
||||
const chatStore = useChatOrchestratorStore()
|
||||
const shouldUseStreamInput = computed(() => supportsStreamInput.value && !!stream.value)
|
||||
|
||||
const {
|
||||
@@ -179,7 +179,7 @@ async function handleSpeechStart() {
|
||||
if (!provider || !activeChatModel.value)
|
||||
return
|
||||
|
||||
await chatStore.send(finalText, { model: activeChatModel.value, chatProvider: provider as ChatProvider })
|
||||
await chatStore.ingest(finalText, { model: activeChatModel.value, chatProvider: provider as ChatProvider })
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Failed to send chat from voice:', err)
|
||||
@@ -228,7 +228,7 @@ async function startAudioInteraction() {
|
||||
if (!provider || !activeChatModel.value)
|
||||
return
|
||||
|
||||
await chatStore.send(text, { model: activeChatModel.value, chatProvider: provider as ChatProvider })
|
||||
await chatStore.ingest(text, { model: activeChatModel.value, chatProvider: provider as ChatProvider })
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Failed to send chat from voice:', err)
|
||||
|
||||
@@ -8,7 +8,9 @@ import { useDelayMessageQueue, useEmotionsMessageQueue } from '@proj-airi/stage-
|
||||
import { llmInferenceEndToken } from '@proj-airi/stage-ui/constants'
|
||||
import { EMOTION_EmotionMotionName_value, EMOTION_VRMExpressionName_value, EmotionThinkMotionName } from '@proj-airi/stage-ui/constants/emotions'
|
||||
import { useAudioContext, useSpeakingStore } from '@proj-airi/stage-ui/stores/audio'
|
||||
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { useChatOrchestratorStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { useChatMaintenanceStore } from '@proj-airi/stage-ui/stores/chat/maintenance'
|
||||
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
|
||||
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
|
||||
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
|
||||
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
|
||||
@@ -62,9 +64,11 @@ const nowSpeaking = ref(false)
|
||||
const currentMotion = ref<{ group: string }>({ group: EmotionThinkMotionName })
|
||||
const logLines = ref<string[]>([])
|
||||
const chatInput = ref('')
|
||||
const chatStore = useChatStore()
|
||||
const chatOrchestrator = useChatOrchestratorStore()
|
||||
const chatSession = useChatSessionStore()
|
||||
const chatMaintenance = useChatMaintenanceStore()
|
||||
const chatMessages = computed(() => {
|
||||
return chatStore.messages
|
||||
return chatSession.messages
|
||||
.filter(msg => msg.role !== 'system')
|
||||
.map((msg) => {
|
||||
const text = typeof msg.content === 'string'
|
||||
@@ -196,7 +200,7 @@ async function sendChat() {
|
||||
}
|
||||
|
||||
try {
|
||||
await chatStore.send(content, {
|
||||
await chatOrchestrator.ingest(content, {
|
||||
model: activeChatModel.value,
|
||||
chatProvider: provider as ChatProvider,
|
||||
})
|
||||
@@ -209,13 +213,13 @@ async function sendChat() {
|
||||
}
|
||||
|
||||
function resetChat() {
|
||||
chatStore.cleanupMessages()
|
||||
chatMaintenance.cleanupMessages()
|
||||
chatInput.value = ''
|
||||
logLines.value = []
|
||||
playbackManager.stopAll('reset')
|
||||
}
|
||||
|
||||
const { onBeforeMessageComposed, onBeforeSend, onTokenLiteral, onTokenSpecial, onStreamEnd, onAssistantResponseEnd } = chatStore
|
||||
const { onBeforeMessageComposed, onBeforeSend, onTokenLiteral, onTokenSpecial, onStreamEnd, onAssistantResponseEnd } = chatOrchestrator
|
||||
const chatHookCleanups: Array<() => void> = []
|
||||
let currentIntent: ReturnType<typeof speechPipeline.openIntent> | null = null
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { WidgetStage } from '@proj-airi/stage-ui/components/scenes'
|
||||
import { useAudioRecorder } from '@proj-airi/stage-ui/composables/audio/audio-recorder'
|
||||
import { fetchSession } from '@proj-airi/stage-ui/libs/auth'
|
||||
import { useVAD } from '@proj-airi/stage-ui/stores/ai/models/vad'
|
||||
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { useChatOrchestratorStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { useLive2d } from '@proj-airi/stage-ui/stores/live2d'
|
||||
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
|
||||
import { useHearingSpeechInputPipeline } from '@proj-airi/stage-ui/stores/modules/hearing'
|
||||
@@ -52,7 +52,7 @@ const { supportsStreamInput } = storeToRefs(hearingPipeline)
|
||||
const providersStore = useProvidersStore()
|
||||
const consciousnessStore = useConsciousnessStore()
|
||||
const { activeProvider: activeChatProvider, activeModel: activeChatModel } = storeToRefs(consciousnessStore)
|
||||
const chatStore = useChatStore()
|
||||
const chatStore = useChatOrchestratorStore()
|
||||
|
||||
const shouldUseStreamInput = computed(() => supportsStreamInput.value && !!stream.value)
|
||||
|
||||
@@ -86,7 +86,7 @@ async function startAudioInteraction() {
|
||||
if (!provider || !activeChatModel.value)
|
||||
return
|
||||
|
||||
await chatStore.send(text, { model: activeChatModel.value, chatProvider: provider as ChatProvider })
|
||||
await chatStore.ingest(text, { model: activeChatModel.value, chatProvider: provider as ChatProvider })
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Failed to send chat from voice:', err)
|
||||
@@ -113,7 +113,7 @@ async function handleSpeechStart() {
|
||||
if (!provider || !activeChatModel.value)
|
||||
return
|
||||
|
||||
await chatStore.send(finalText, { model: activeChatModel.value, chatProvider: provider as ChatProvider })
|
||||
await chatStore.ingest(finalText, { model: activeChatModel.value, chatProvider: provider as ChatProvider })
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Failed to send chat from voice:', err)
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
import type { ChatHistoryItem } from '@proj-airi/stage-ui/types/chat'
|
||||
|
||||
import { ChatHistory } from '@proj-airi/stage-ui/components'
|
||||
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { useChatOrchestratorStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
|
||||
import { useChatStreamStore } from '@proj-airi/stage-ui/stores/chat/stream-store'
|
||||
import { useDeferredMount } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
@@ -12,7 +14,9 @@ import ChatArea from '../Widgets/ChatArea.vue'
|
||||
import ChatContainer from '../Widgets/ChatContainer.vue'
|
||||
|
||||
const { isReady } = useDeferredMount()
|
||||
const { messages, sending, streamingMessage } = storeToRefs(useChatStore())
|
||||
const { sending } = storeToRefs(useChatOrchestratorStore())
|
||||
const { messages } = storeToRefs(useChatSessionStore())
|
||||
const { streamingMessage } = storeToRefs(useChatStreamStore())
|
||||
|
||||
const isLoading = ref(true)
|
||||
const historyMessages = computed(() => messages.value as unknown as ChatHistoryItem[])
|
||||
|
||||
@@ -5,7 +5,10 @@ import type { ChatProvider } from '@xsai-ext/providers/utils'
|
||||
import { ChatHistory, HearingConfigDialog } from '@proj-airi/stage-ui/components'
|
||||
import { useAudioAnalyzer } from '@proj-airi/stage-ui/composables'
|
||||
import { useAudioContext } from '@proj-airi/stage-ui/stores/audio'
|
||||
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { useChatOrchestratorStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { useChatMaintenanceStore } from '@proj-airi/stage-ui/stores/chat/maintenance'
|
||||
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
|
||||
import { useChatStreamStore } from '@proj-airi/stage-ui/stores/chat/stream-store'
|
||||
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
|
||||
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
|
||||
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
|
||||
@@ -25,8 +28,13 @@ import { BackgroundDialogPicker } from '../Backgrounds'
|
||||
|
||||
const { isDark, toggleDark } = useTheme()
|
||||
const hearingDialogOpen = ref(false)
|
||||
const chatStore = useChatStore()
|
||||
const { messages, sending, streamingMessage } = storeToRefs(chatStore)
|
||||
const chatOrchestrator = useChatOrchestratorStore()
|
||||
const chatSession = useChatSessionStore()
|
||||
const chatStream = useChatStreamStore()
|
||||
const { cleanupMessages } = useChatMaintenanceStore()
|
||||
const { messages } = storeToRefs(chatSession)
|
||||
const { streamingMessage } = storeToRefs(chatStream)
|
||||
const { sending } = storeToRefs(chatOrchestrator)
|
||||
const historyMessages = computed(() => messages.value as unknown as ChatHistoryItem[])
|
||||
|
||||
const viewControlsActiveMode = ref<'x' | 'y' | 'z' | 'scale'>('scale')
|
||||
@@ -44,7 +52,7 @@ useResizeObserver(document.documentElement, () => screenSafeArea.update())
|
||||
const { themeColorsHueDynamic, stageViewControlsEnabled } = storeToRefs(useSettings())
|
||||
const settingsAudioDevice = useSettingsAudioDevice()
|
||||
const { enabled, selectedAudioInput, stream, audioInputs } = storeToRefs(settingsAudioDevice)
|
||||
const { send, onAfterMessageComposed, discoverToolsCompatibility, cleanupMessages } = chatStore
|
||||
const { ingest, onAfterMessageComposed, discoverToolsCompatibility } = chatOrchestrator
|
||||
const { t } = useI18n()
|
||||
const { audioContext } = useAudioContext()
|
||||
const { startAnalyzer, stopAnalyzer, volumeLevel } = useAudioAnalyzer()
|
||||
@@ -71,7 +79,7 @@ async function handleSend() {
|
||||
try {
|
||||
const providerConfig = providersStore.getProviderConfig(activeProvider.value)
|
||||
|
||||
await send(textToSend, {
|
||||
await ingest(textToSend, {
|
||||
chatProvider: await providersStore.getProviderInstance(activeProvider.value) as ChatProvider,
|
||||
model: activeModel.value,
|
||||
providerConfig,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { useChatMaintenanceStore } from '@proj-airi/stage-ui/stores/chat/maintenance'
|
||||
import { useTheme } from '@proj-airi/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { BackgroundDialogPicker } from '../Backgrounds'
|
||||
|
||||
const { cleanupMessages } = useChatStore()
|
||||
const { cleanupMessages } = useChatMaintenanceStore()
|
||||
const { isDark, toggleDark } = useTheme()
|
||||
|
||||
const backgroundDialogOpen = ref(false)
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { ChatProvider } from '@xsai-ext/providers/utils'
|
||||
|
||||
import { useAudioAnalyzer } from '@proj-airi/stage-ui/composables'
|
||||
import { useAudioContext } from '@proj-airi/stage-ui/stores/audio'
|
||||
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { useChatOrchestratorStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
|
||||
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
|
||||
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
|
||||
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
|
||||
@@ -25,9 +26,10 @@ const { themeColorsHueDynamic } = storeToRefs(useSettings())
|
||||
|
||||
const { askPermission } = useSettingsAudioDevice()
|
||||
const { enabled, selectedAudioInput, stream, audioInputs } = storeToRefs(useSettingsAudioDevice())
|
||||
const chatStore = useChatStore()
|
||||
const { send, onAfterMessageComposed, discoverToolsCompatibility } = chatStore
|
||||
const { messages } = storeToRefs(useChatStore())
|
||||
const chatOrchestrator = useChatOrchestratorStore()
|
||||
const chatSession = useChatSessionStore()
|
||||
const { ingest, onAfterMessageComposed, discoverToolsCompatibility } = chatOrchestrator
|
||||
const { messages } = storeToRefs(chatSession)
|
||||
const { audioContext } = useAudioContext()
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -42,7 +44,7 @@ async function handleSend() {
|
||||
try {
|
||||
const providerConfig = providersStore.getProviderConfig(activeProvider.value)
|
||||
|
||||
await send(textToSend, {
|
||||
await ingest(textToSend, {
|
||||
chatProvider: await providersStore.getProviderInstance(activeProvider.value) as ChatProvider,
|
||||
model: activeModel.value,
|
||||
providerConfig,
|
||||
|
||||
+33
-1
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ContextUpdateStrategy } from '@proj-airi/server-sdk'
|
||||
import { Section } from '@proj-airi/stage-ui/components'
|
||||
import { Button, FieldTextArea, SelectTab } from '@proj-airi/ui'
|
||||
import { Button, FieldInput, FieldTextArea, SelectTab } from '@proj-airi/ui'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'sendContextUpdate'): void
|
||||
@@ -10,6 +10,10 @@ const emit = defineEmits<{
|
||||
const testStrategy = defineModel<ContextUpdateStrategy>('testStrategy', { required: true })
|
||||
const testPayload = defineModel<string>('testPayload', { required: true })
|
||||
const testSparkNotifyPayload = defineModel<string>('testSparkNotifyPayload', { required: true })
|
||||
const attentionTickInterval = defineModel<number>('attentionTickInterval', { required: true })
|
||||
const attentionTaskWindow = defineModel<number>('attentionTaskWindow', { required: true })
|
||||
const attentionRequeueDelay = defineModel<number>('attentionRequeueDelay', { required: true })
|
||||
const attentionMaxAttempts = defineModel<number>('attentionMaxAttempts', { required: true })
|
||||
|
||||
const strategyOptions = [
|
||||
{ label: 'Replace', value: ContextUpdateStrategy.ReplaceSelf },
|
||||
@@ -40,6 +44,34 @@ const strategyOptions = [
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
<Section title="Attention" icon="i-solar:settings-bold-duotone" inner-class="gap-3" :expand="false">
|
||||
<div :class="['grid', 'gap-3', 'sm:grid-cols-2']">
|
||||
<FieldInput
|
||||
v-model.number="attentionTickInterval"
|
||||
label="Tick interval (ms)"
|
||||
description="How often the attention loop wakes up."
|
||||
type="number"
|
||||
/>
|
||||
<FieldInput
|
||||
v-model.number="attentionTaskWindow"
|
||||
label="Task notify window (ms)"
|
||||
description="How far ahead tasks should be reminded."
|
||||
type="number"
|
||||
/>
|
||||
<FieldInput
|
||||
v-model.number="attentionRequeueDelay"
|
||||
label="Requeue delay (ms)"
|
||||
description="Delay added when retries are scheduled."
|
||||
type="number"
|
||||
/>
|
||||
<FieldInput
|
||||
v-model.number="attentionMaxAttempts"
|
||||
label="Max attempts"
|
||||
description="How many times to retry a spark:notify."
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
<Section title="Simulate incoming" icon="i-solar:plain-2-bold-duotone" inner-class="gap-3" :expand="false">
|
||||
<FieldTextArea
|
||||
v-model="testSparkNotifyPayload"
|
||||
|
||||
@@ -7,7 +7,8 @@ import type { FlowDirection, FlowEntry, SparkNotifyEntryState } from './context-
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { ContextUpdateStrategy } from '@proj-airi/server-sdk'
|
||||
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 { useChatOrchestratorStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { CHAT_STREAM_CHANNEL_NAME, CONTEXT_CHANNEL_NAME } from '@proj-airi/stage-ui/stores/chat/constants'
|
||||
import { useModsServerChannelStore } from '@proj-airi/stage-ui/stores/mods/api/channel-server'
|
||||
import { getEventSourceKey } from '@proj-airi/stage-ui/utils'
|
||||
import { Callout } from '@proj-airi/ui'
|
||||
@@ -31,7 +32,7 @@ const {
|
||||
truncateText,
|
||||
} = useContextFlowFormatters()
|
||||
|
||||
const chatStore = useChatStore()
|
||||
const chatStore = useChatOrchestratorStore()
|
||||
const characterStore = useCharacterStore()
|
||||
const characterOrchestratorStore = useCharacterOrchestratorStore()
|
||||
const serverChannelStore = useModsServerChannelStore()
|
||||
@@ -564,6 +565,10 @@ onUnmounted(() => {
|
||||
v-model:test-strategy="testStrategy"
|
||||
v-model:test-payload="testPayload"
|
||||
v-model:test-spark-notify-payload="testSparkNotifyPayload"
|
||||
v-model:attention-tick-interval="characterOrchestratorStore.attentionConfig.tickIntervalMs"
|
||||
v-model:attention-task-window="characterOrchestratorStore.attentionConfig.taskNotifyWindowMs"
|
||||
v-model:attention-requeue-delay="characterOrchestratorStore.attentionConfig.requeueDelayMs"
|
||||
v-model:attention-max-attempts="characterOrchestratorStore.attentionConfig.maxAttempts"
|
||||
@send-context-update="sendTestContextUpdate"
|
||||
@send-spark-notify="sendTestSparkNotify"
|
||||
/>
|
||||
|
||||
@@ -148,7 +148,7 @@ function payloadClasses(direction: 'incoming' | 'outgoing') {
|
||||
>
|
||||
No messages found.
|
||||
</div>
|
||||
<div v-else class="grid gap-3">
|
||||
<div v-else v-auto-animate class="grid gap-3">
|
||||
<div
|
||||
v-for="item in filteredHistory"
|
||||
:key="item.id"
|
||||
|
||||
@@ -28,7 +28,7 @@ import { useDelayMessageQueue, useEmotionsMessageQueue } from '../../composables
|
||||
import { llmInferenceEndToken } from '../../constants'
|
||||
import { EMOTION_EmotionMotionName_value, EMOTION_VRMExpressionName_value, EmotionThinkMotionName } from '../../constants/emotions'
|
||||
import { useAudioContext, useSpeakingStore } from '../../stores/audio'
|
||||
import { useChatStore } from '../../stores/chat'
|
||||
import { useChatOrchestratorStore } from '../../stores/chat'
|
||||
import { useAiriCardStore } from '../../stores/modules'
|
||||
import { useSpeechStore } from '../../stores/modules/speech'
|
||||
import { useProvidersStore } from '../../stores/providers'
|
||||
@@ -69,7 +69,7 @@ const { mouthOpenSize } = storeToRefs(useSpeakingStore())
|
||||
const { audioContext } = useAudioContext()
|
||||
const currentAudioSource = ref<AudioBufferSourceNode>()
|
||||
|
||||
const { onBeforeMessageComposed, onBeforeSend, onTokenLiteral, onTokenSpecial, onStreamEnd, onAssistantResponseEnd } = useChatStore()
|
||||
const { onBeforeMessageComposed, onBeforeSend, onTokenLiteral, onTokenSpecial, onStreamEnd, onAssistantResponseEnd } = useChatOrchestratorStore()
|
||||
const chatHookCleanups: Array<() => void> = []
|
||||
// WORKAROUND: clear previous handlers on unmount to avoid duplicate calls when this component remounts.
|
||||
// We keep per-hook disposers instead of wiping the global chat hooks to play nicely with
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import MarkdownRenderer from '../markdown/markdown-renderer.vue'
|
||||
|
||||
import { useChatStore } from '../../stores/chat'
|
||||
|
||||
const { streamingMessage } = useChatStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="streamingMessage.content !== ''"
|
||||
class="animate-stripe"
|
||||
absolute
|
||||
left="1/2"
|
||||
bottom="20%"
|
||||
z="20"
|
||||
rounded-2xl
|
||||
text="primary-600"
|
||||
px-2 py-2
|
||||
transform="translate-x--1/2"
|
||||
>
|
||||
<div bg="primary-50" rounded-xl px-10 py-6>
|
||||
<MarkdownRenderer :content="(streamingMessage.content as string)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="css" scoped>
|
||||
/**
|
||||
Plunker - Untitled
|
||||
https://plnkr.co/edit/4wPv1ogKNMfJ6rQPhZdJ?p=preview&preview
|
||||
|
||||
by https://stackoverflow.com/a/31547711/19954520
|
||||
*/
|
||||
.animate-stripe {
|
||||
--animate-stripe-color-primary: color-mix(in srgb, oklch(74% var(--chromatic-chroma-400) calc(var(--chromatic-hue) + 0deg) / %alpha) 80%, oklch(100% 0 360 / %alpha));
|
||||
--animate-stripe-color-secondary: color-mix(in srgb, oklch(90% var(--chromatic-chroma-200) calc(var(--chromatic-hue) + 0deg) / %alpha) 80%, oklch(100% 0 360 / %alpha));
|
||||
|
||||
background-image: repeating-linear-gradient(-45deg, var(--animate-stripe-color-primary), var(--animate-stripe-color-primary) 25px, var(--animate-stripe-color-secondary) 25px, var(--animate-stripe-color-secondary) 50px);
|
||||
animation: progress 2s linear infinite;
|
||||
background-size: 150% 100%;
|
||||
}
|
||||
|
||||
.dark .animate-stripe {
|
||||
--animate-stripe-color-primary: color-mix(in srgb, oklch(37% calc(var(--chromatic-chroma-900) * 0.5) var(--chromatic-hue)) 30%, oklch(100% 0 360));
|
||||
--animate-stripe-color-secondary: color-mix(in srgb, oklch(29% calc(var(--chromatic-chroma-950) * 0.5) var(--chromatic-hue)) 30%, oklch(100% 0 360));
|
||||
}
|
||||
|
||||
@-webkit-keyframes progress {
|
||||
0% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -75px 0px;
|
||||
}
|
||||
}
|
||||
@-moz-keyframes progress {
|
||||
0% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -75px 0px;
|
||||
}
|
||||
}
|
||||
@-ms-keyframes progress {
|
||||
0% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -75px 0px;
|
||||
}
|
||||
}
|
||||
@keyframes progress {
|
||||
0% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -70px 0px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,8 @@ import type { ChatHistoryItem } from '../types/chat'
|
||||
import { isStageTamagotchi } from '@proj-airi/stage-shared'
|
||||
import { useLive2d } from '@proj-airi/stage-ui-live2d'
|
||||
|
||||
import { useChatStore } from '../stores/chat'
|
||||
import { useChatOrchestratorStore } from '../stores/chat'
|
||||
import { useChatSessionStore } from '../stores/chat/session-store'
|
||||
import { useDisplayModelsStore } from '../stores/display-models'
|
||||
import { useMcpStore } from '../stores/mcp'
|
||||
import { useAiriCardStore } from '../stores/modules/airi-card'
|
||||
@@ -19,7 +20,8 @@ import { useProvidersStore } from '../stores/providers'
|
||||
import { useSettings, useSettingsAudioDevice } from '../stores/settings'
|
||||
|
||||
export function useDataMaintenance() {
|
||||
const chatStore = useChatStore()
|
||||
const chatStore = useChatSessionStore()
|
||||
const chatOrchestrator = useChatOrchestratorStore()
|
||||
const displayModelsStore = useDisplayModelsStore()
|
||||
const providersStore = useProvidersStore()
|
||||
const settingsStore = useSettings()
|
||||
@@ -57,6 +59,7 @@ export function useDataMaintenance() {
|
||||
}
|
||||
|
||||
function deleteAllChatSessions() {
|
||||
chatOrchestrator.cancelPendingSends()
|
||||
chatStore.resetAllSessions()
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useLlmmarkerParser } from '../../composables/llm-marker-parser'
|
||||
import { useAiriCardStore } from '../modules'
|
||||
import { useSpeechRuntimeStore } from '../speech-runtime'
|
||||
|
||||
export * from './notebook'
|
||||
export * from './orchestrator'
|
||||
|
||||
export interface CharacterSparkNotifyReaction {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { nanoid } from 'nanoid'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export type NotebookEntryKind = 'note' | 'diary' | 'focus'
|
||||
|
||||
export interface NotebookEntry {
|
||||
id: string
|
||||
kind: NotebookEntryKind
|
||||
text: string
|
||||
createdAt: number
|
||||
tags?: string[]
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type TaskPriority = 'low' | 'normal' | 'high' | 'critical'
|
||||
export type TaskStatus = 'queued' | 'scheduled' | 'done' | 'dropped'
|
||||
|
||||
export interface ScheduledTask {
|
||||
id: string
|
||||
title: string
|
||||
details?: string
|
||||
priority: TaskPriority
|
||||
status: TaskStatus
|
||||
dueAt?: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
lastNotifiedAt?: number
|
||||
nextNotifyAt?: number
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export const useCharacterNotebookStore = defineStore('character-notebook', () => {
|
||||
const entries = ref<NotebookEntry[]>([])
|
||||
const tasks = ref<ScheduledTask[]>([])
|
||||
|
||||
const partitionDiary = computed(() => entries.value.filter(entry => entry.kind === 'diary'))
|
||||
const partitionFocus = computed(() => entries.value.filter(entry => entry.kind === 'focus'))
|
||||
|
||||
function addEntry(kind: NotebookEntryKind, text: string, options?: { tags?: string[], metadata?: Record<string, unknown> }) {
|
||||
const entry: NotebookEntry = {
|
||||
id: nanoid(),
|
||||
kind,
|
||||
text,
|
||||
createdAt: Date.now(),
|
||||
tags: options?.tags,
|
||||
metadata: options?.metadata,
|
||||
}
|
||||
|
||||
entries.value.push(entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
function addNote(text: string, options?: { tags?: string[], metadata?: Record<string, unknown> }) {
|
||||
return addEntry('note', text, options)
|
||||
}
|
||||
|
||||
function addDiaryEntry(text: string, options?: { tags?: string[], metadata?: Record<string, unknown> }) {
|
||||
return addEntry('diary', text, options)
|
||||
}
|
||||
|
||||
function addFocusEntry(text: string, options?: { tags?: string[], metadata?: Record<string, unknown> }) {
|
||||
return addEntry('focus', text, options)
|
||||
}
|
||||
|
||||
function scheduleTask(payload: {
|
||||
title: string
|
||||
details?: string
|
||||
priority?: TaskPriority
|
||||
dueAt?: number
|
||||
metadata?: Record<string, unknown>
|
||||
}) {
|
||||
const now = Date.now()
|
||||
const task: ScheduledTask = {
|
||||
id: nanoid(),
|
||||
title: payload.title,
|
||||
details: payload.details,
|
||||
priority: payload.priority ?? 'normal',
|
||||
status: payload.dueAt ? 'scheduled' : 'queued',
|
||||
dueAt: payload.dueAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
metadata: payload.metadata,
|
||||
}
|
||||
|
||||
tasks.value.push(task)
|
||||
return task
|
||||
}
|
||||
|
||||
function markTaskDone(taskId: string) {
|
||||
const task = tasks.value.find(item => item.id === taskId)
|
||||
if (!task)
|
||||
return
|
||||
|
||||
task.status = 'done'
|
||||
task.updatedAt = Date.now()
|
||||
}
|
||||
|
||||
function requeueTask(taskId: string, options?: { dueAt?: number, reason?: string }) {
|
||||
const task = tasks.value.find(item => item.id === taskId)
|
||||
if (!task)
|
||||
return
|
||||
|
||||
task.status = 'queued'
|
||||
task.dueAt = options?.dueAt
|
||||
task.updatedAt = Date.now()
|
||||
task.metadata = {
|
||||
...task.metadata,
|
||||
requeueReason: options?.reason,
|
||||
}
|
||||
}
|
||||
|
||||
function markTaskNotified(taskId: string, nextNotifyAt?: number) {
|
||||
const task = tasks.value.find(item => item.id === taskId)
|
||||
if (!task)
|
||||
return
|
||||
|
||||
task.lastNotifiedAt = Date.now()
|
||||
task.nextNotifyAt = nextNotifyAt
|
||||
task.updatedAt = Date.now()
|
||||
}
|
||||
|
||||
function getDueTasks(now: number, windowMs: number) {
|
||||
return tasks.value.filter((task) => {
|
||||
if (task.status === 'done' || task.status === 'dropped')
|
||||
return false
|
||||
const dueAt = task.dueAt ?? now
|
||||
if (dueAt > now + windowMs)
|
||||
return false
|
||||
if (typeof task.nextNotifyAt === 'number' && task.nextNotifyAt > now)
|
||||
return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
entries,
|
||||
tasks,
|
||||
partitionDiary,
|
||||
partitionFocus,
|
||||
addNote,
|
||||
addDiaryEntry,
|
||||
addFocusEntry,
|
||||
scheduleTask,
|
||||
markTaskDone,
|
||||
requeueTask,
|
||||
markTaskNotified,
|
||||
getDueTasks,
|
||||
}
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import type { WebSocketBaseEvent, WebSocketEvents } from '@proj-airi/server-sdk'
|
||||
import { defineStore, storeToRefs } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useCharacterStore } from '../'
|
||||
import { useCharacterNotebookStore, useCharacterStore } from '../'
|
||||
import { useLLM } from '../../llm'
|
||||
import { useModsServerChannelStore } from '../../mods/api/channel-server'
|
||||
import { useConsciousnessStore } from '../../modules/consciousness'
|
||||
@@ -17,11 +17,27 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
|
||||
const { activeProvider, activeModel } = storeToRefs(useConsciousnessStore())
|
||||
const providersStore = useProvidersStore()
|
||||
const characterStore = useCharacterStore()
|
||||
const notebookStore = useCharacterNotebookStore()
|
||||
const { systemPrompt } = storeToRefs(characterStore)
|
||||
const modsServerChannelStore = useModsServerChannelStore()
|
||||
|
||||
const processing = ref(false)
|
||||
const pendingNotifies = ref<Array<WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify']>>>([])
|
||||
const scheduledNotifies = ref<Array<{
|
||||
event: WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify']>
|
||||
enqueuedAt: number
|
||||
nextRunAt: number
|
||||
attempts: number
|
||||
maxAttempts: number
|
||||
reason?: string
|
||||
}>>([])
|
||||
const attentionConfig = ref({
|
||||
tickIntervalMs: 2_000,
|
||||
taskNotifyWindowMs: 60_000,
|
||||
requeueDelayMs: 30_000,
|
||||
maxAttempts: 3,
|
||||
})
|
||||
let tickTimer: ReturnType<typeof setInterval> | undefined
|
||||
const sparkNotifyAgent = setupAgentSparkNotifyHandler({
|
||||
stream,
|
||||
getActiveProvider: () => activeProvider.value,
|
||||
@@ -36,6 +52,146 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
|
||||
setPending: next => pendingNotifies.value = next,
|
||||
})
|
||||
|
||||
function computeNextRunAt(event: WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify']>, attempts: number) {
|
||||
const now = Date.now()
|
||||
const baseDelay = (() => {
|
||||
switch (event.data.urgency) {
|
||||
case 'immediate':
|
||||
return 0
|
||||
case 'soon':
|
||||
return 10_000
|
||||
case 'later':
|
||||
return 60_000
|
||||
default:
|
||||
return 30_000
|
||||
}
|
||||
})()
|
||||
|
||||
return now + baseDelay + (attempts * attentionConfig.value.requeueDelayMs)
|
||||
}
|
||||
|
||||
function removePending(eventId: string) {
|
||||
pendingNotifies.value = pendingNotifies.value.filter(item => item.data.id !== eventId)
|
||||
}
|
||||
|
||||
function enqueueSparkNotify(event: WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify']>, options?: { reason?: string, nextRunAt?: number, maxAttempts?: number }) {
|
||||
if (!pendingNotifies.value.find(item => item.data.id === event.data.id)) {
|
||||
pendingNotifies.value = [...pendingNotifies.value, event]
|
||||
}
|
||||
|
||||
scheduledNotifies.value = [...scheduledNotifies.value, {
|
||||
event,
|
||||
enqueuedAt: Date.now(),
|
||||
nextRunAt: options?.nextRunAt ?? computeNextRunAt(event, 0),
|
||||
attempts: 0,
|
||||
maxAttempts: options?.maxAttempts ?? attentionConfig.value.maxAttempts,
|
||||
reason: options?.reason,
|
||||
}]
|
||||
}
|
||||
|
||||
async function processSparkNotify(event: WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify']>) {
|
||||
const result = await sparkNotifyAgent.handle(event)
|
||||
if (!result?.commands?.length)
|
||||
return result
|
||||
|
||||
for (const command of result.commands) {
|
||||
modsServerChannelStore.send({
|
||||
type: 'spark:command',
|
||||
data: command,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async function handleIncomingSparkNotify(event: WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify']>) {
|
||||
if (event.data.urgency === 'immediate' && !processing.value) {
|
||||
return await processSparkNotify(event)
|
||||
}
|
||||
|
||||
enqueueSparkNotify(event, { reason: 'spark:notify' })
|
||||
return undefined
|
||||
}
|
||||
|
||||
function enqueueDueTasks(now: number) {
|
||||
const dueTasks = notebookStore.getDueTasks(now, attentionConfig.value.taskNotifyWindowMs)
|
||||
if (!dueTasks.length)
|
||||
return
|
||||
|
||||
for (const task of dueTasks) {
|
||||
const event: WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify']> = {
|
||||
type: 'spark:notify',
|
||||
source: 'character:task-scheduler',
|
||||
data: {
|
||||
id: `task-${task.id}`,
|
||||
eventId: task.id,
|
||||
kind: 'reminder',
|
||||
urgency: task.priority === 'critical' ? 'immediate' : 'soon',
|
||||
headline: `Task reminder: ${task.title}`,
|
||||
note: task.details,
|
||||
destinations: ['character'],
|
||||
payload: {
|
||||
taskId: task.id,
|
||||
dueAt: task.dueAt,
|
||||
priority: task.priority,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
enqueueSparkNotify(event, { reason: 'task:due' })
|
||||
notebookStore.markTaskNotified(task.id, now + attentionConfig.value.requeueDelayMs)
|
||||
}
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
if (processing.value)
|
||||
return
|
||||
|
||||
const now = Date.now()
|
||||
enqueueDueTasks(now)
|
||||
|
||||
const nextIndex = scheduledNotifies.value.findIndex(item => item.nextRunAt <= now)
|
||||
if (nextIndex < 0)
|
||||
return
|
||||
|
||||
const [next] = scheduledNotifies.value.splice(nextIndex, 1)
|
||||
removePending(next.event.data.id)
|
||||
|
||||
try {
|
||||
await processSparkNotify(next.event)
|
||||
}
|
||||
catch (error) {
|
||||
if (next.attempts + 1 < next.maxAttempts) {
|
||||
scheduledNotifies.value = [...scheduledNotifies.value, {
|
||||
...next,
|
||||
attempts: next.attempts + 1,
|
||||
nextRunAt: computeNextRunAt(next.event, next.attempts + 1),
|
||||
}]
|
||||
pendingNotifies.value = [...pendingNotifies.value, next.event]
|
||||
}
|
||||
else {
|
||||
console.warn('Dropped spark:notify after max attempts:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startTicker() {
|
||||
if (tickTimer)
|
||||
return
|
||||
|
||||
tickTimer = setInterval(() => {
|
||||
void tick()
|
||||
}, attentionConfig.value.tickIntervalMs)
|
||||
}
|
||||
|
||||
function stopTicker() {
|
||||
if (!tickTimer)
|
||||
return
|
||||
|
||||
clearInterval(tickTimer)
|
||||
tickTimer = undefined
|
||||
}
|
||||
|
||||
async function handleSparkEmit(_: WebSocketBaseEvent<'spark:emit', WebSocketEvents['spark:emit']>) {
|
||||
// Currently no-op
|
||||
return undefined
|
||||
@@ -44,16 +200,7 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
|
||||
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,
|
||||
})
|
||||
}
|
||||
await handleIncomingSparkNotify(event)
|
||||
}
|
||||
catch (error) {
|
||||
console.warn('Failed to handle spark:notify event:', error)
|
||||
@@ -68,15 +215,21 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
|
||||
console.warn('Failed to handle spark:emit event:', error)
|
||||
}
|
||||
})
|
||||
|
||||
startTicker()
|
||||
}
|
||||
|
||||
return {
|
||||
processing,
|
||||
pendingNotifies,
|
||||
scheduledNotifies,
|
||||
attentionConfig,
|
||||
|
||||
initialize,
|
||||
startTicker,
|
||||
stopTicker,
|
||||
|
||||
handleSparkNotify: sparkNotifyAgent.handle,
|
||||
handleSparkNotify: handleIncomingSparkNotify,
|
||||
handleSparkEmit,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,67 +1,60 @@
|
||||
import type { WebSocketEventInputs } from '@proj-airi/server-sdk'
|
||||
import type { ChatProvider } from '@xsai-ext/providers/utils'
|
||||
import type { CommonContentPart, Message, SystemMessage, ToolMessage } from '@xsai/shared-chat'
|
||||
import type { CommonContentPart, Message, ToolMessage } from '@xsai/shared-chat'
|
||||
|
||||
import type { StreamEvent, StreamOptions } from '../stores/llm'
|
||||
import type { ChatAssistantMessage, ChatHistoryItem, ChatSlices, ChatStreamEventContext, ContextMessage, StreamingAssistantMessage } from '../types/chat'
|
||||
import type { ChatAssistantMessage, ChatSlices, ChatStreamEventContext, StreamingAssistantMessage } from '../types/chat'
|
||||
import type { StreamEvent, StreamOptions } from './llm'
|
||||
|
||||
import { ContextUpdateStrategy } from '@proj-airi/server-sdk'
|
||||
import { createQueue } from '@proj-airi/stream-kit'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { defineStore, storeToRefs } from 'pinia'
|
||||
import { computed, ref, toRaw, watch } from 'vue'
|
||||
import { ref, toRaw } from 'vue'
|
||||
|
||||
import { useAnalytics } from '../composables'
|
||||
import { useLlmmarkerParser } from '../composables/llm-marker-parser'
|
||||
import { categorizeResponse, createStreamingCategorizer } from '../composables/response-categoriser'
|
||||
import { useLLM } from '../stores/llm'
|
||||
import { getEventSourceKey } from '../utils/event-source'
|
||||
import { useCharacterStore } from './character'
|
||||
import { useChatContextStore } from './chat/context-store'
|
||||
import { createChatHooks } from './chat/hooks'
|
||||
import { useChatSessionStore } from './chat/session-store'
|
||||
import { useChatStreamStore } from './chat/stream-store'
|
||||
import { useLLM } from './llm'
|
||||
import { useConsciousnessStore } from './modules/consciousness'
|
||||
|
||||
const CHAT_STORAGE_KEY = 'chat/messages/v2'
|
||||
const ACTIVE_SESSION_STORAGE_KEY = 'chat/active-session'
|
||||
export const CONTEXT_CHANNEL_NAME = 'airi-context-update'
|
||||
export const CHAT_STREAM_CHANNEL_NAME = 'airi-chat-stream'
|
||||
interface SendOptions {
|
||||
model: string
|
||||
chatProvider: ChatProvider
|
||||
providerConfig?: Record<string, unknown>
|
||||
attachments?: { type: 'image', data: string, mimeType: string }[]
|
||||
tools?: StreamOptions['tools']
|
||||
input?: WebSocketEventInputs
|
||||
}
|
||||
|
||||
export const useChatStore = defineStore('chat', () => {
|
||||
interface QueuedSend {
|
||||
sendingMessage: string
|
||||
options: SendOptions
|
||||
generation: number
|
||||
sessionId: string
|
||||
cancelled?: boolean
|
||||
deferred: {
|
||||
resolve: () => void
|
||||
reject: (error: unknown) => void
|
||||
}
|
||||
}
|
||||
|
||||
export const useChatOrchestratorStore = defineStore('chat-orchestrator', () => {
|
||||
const llmStore = useLLM()
|
||||
const consciousnessStore = useConsciousnessStore()
|
||||
const { activeProvider } = storeToRefs(consciousnessStore)
|
||||
const { systemPrompt } = storeToRefs(useCharacterStore())
|
||||
const { trackFirstMessage } = useAnalytics()
|
||||
|
||||
const activeSessionId = useLocalStorage<string>(ACTIVE_SESSION_STORAGE_KEY, 'default')
|
||||
const sessionMessages = useLocalStorage<Record<string, ChatHistoryItem[]>>(CHAT_STORAGE_KEY, {})
|
||||
const chatSession = useChatSessionStore()
|
||||
const chatStream = useChatStreamStore()
|
||||
const chatContext = useChatContextStore()
|
||||
const { activeSessionId } = storeToRefs(chatSession)
|
||||
const { streamingMessage } = storeToRefs(chatStream)
|
||||
|
||||
const sending = ref(false)
|
||||
const streamingMessage = ref<StreamingAssistantMessage>({ role: 'assistant', content: '', slices: [], tool_results: [], createdAt: Date.now() })
|
||||
const sessionGenerations = ref<Record<string, number>>({})
|
||||
|
||||
const activeContexts = ref<Record<string, ContextMessage[]>>({})
|
||||
|
||||
interface SendOptions {
|
||||
model: string
|
||||
chatProvider: ChatProvider
|
||||
providerConfig?: Record<string, unknown>
|
||||
attachments?: { type: 'image', data: string, mimeType: string }[]
|
||||
tools?: StreamOptions['tools']
|
||||
input?: WebSocketEventInputs
|
||||
}
|
||||
|
||||
interface QueuedSend {
|
||||
sendingMessage: string
|
||||
options: SendOptions
|
||||
generation: number
|
||||
sessionId: string
|
||||
cancelled?: boolean
|
||||
deferred: {
|
||||
resolve: () => void
|
||||
reject: (error: unknown) => void
|
||||
}
|
||||
}
|
||||
|
||||
const pendingQueuedSends = ref<QueuedSend[]>([])
|
||||
const hooks = createChatHooks()
|
||||
|
||||
const sendQueue = createQueue<QueuedSend>({
|
||||
handlers: [
|
||||
@@ -71,7 +64,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
if (cancelled)
|
||||
return
|
||||
|
||||
if (getSessionGeneration(sessionId) !== generation) {
|
||||
if (chatSession.getSessionGeneration(sessionId) !== generation) {
|
||||
deferred.reject(new Error('Chat session was reset before send could start'))
|
||||
return
|
||||
}
|
||||
@@ -95,259 +88,6 @@ export const useChatStore = defineStore('chat', () => {
|
||||
pendingQueuedSends.value = pendingQueuedSends.value.filter(item => item !== queuedSend)
|
||||
})
|
||||
|
||||
// ----- Hooks (UI callbacks) -----
|
||||
const onBeforeMessageComposedHooks = ref<Array<(message: string, context: Omit<ChatStreamEventContext, 'composedMessage'>) => Promise<void>>>([])
|
||||
const onAfterMessageComposedHooks = ref<Array<(message: string, context: ChatStreamEventContext) => Promise<void>>>([])
|
||||
const onBeforeSendHooks = ref<Array<(message: string, context: ChatStreamEventContext) => Promise<void>>>([])
|
||||
const onAfterSendHooks = ref<Array<(message: string, context: ChatStreamEventContext) => Promise<void>>>([])
|
||||
const onTokenLiteralHooks = ref<Array<(literal: string, context: ChatStreamEventContext) => Promise<void>>>([])
|
||||
const onTokenSpecialHooks = ref<Array<(special: string, context: ChatStreamEventContext) => Promise<void>>>([])
|
||||
const onStreamEndHooks = ref<Array<(context: ChatStreamEventContext) => Promise<void>>>([])
|
||||
const onAssistantResponseEndHooks = ref<Array<(message: string, context: ChatStreamEventContext) => Promise<void>>>([])
|
||||
const onAssistantMessageHooks = ref<Array<(message: StreamingAssistantMessage, messageText: string, context: ChatStreamEventContext) => Promise<void>>>([])
|
||||
const onChatTurnCompleteHooks = ref<Array<(chat: { output: StreamingAssistantMessage, outputText: string, toolCalls: ToolMessage[] }, context: ChatStreamEventContext) => Promise<void>>>([])
|
||||
|
||||
function onBeforeMessageComposed(cb: (message: string, context: Omit<ChatStreamEventContext, 'composedMessage'>) => Promise<void>) {
|
||||
onBeforeMessageComposedHooks.value.push(cb)
|
||||
return () => onBeforeMessageComposedHooks.value = onBeforeMessageComposedHooks.value.filter(hook => hook !== cb) // return remove listener callback
|
||||
}
|
||||
|
||||
function onAfterMessageComposed(cb: (message: string, context: ChatStreamEventContext) => Promise<void>) {
|
||||
onAfterMessageComposedHooks.value.push(cb)
|
||||
return () => onAfterMessageComposedHooks.value = onAfterMessageComposedHooks.value.filter(hook => hook !== cb) // return remove listener callback
|
||||
}
|
||||
|
||||
function onBeforeSend(cb: (message: string, context: ChatStreamEventContext) => Promise<void>) {
|
||||
onBeforeSendHooks.value.push(cb)
|
||||
return () => onBeforeSendHooks.value = onBeforeSendHooks.value.filter(hook => hook !== cb) // return remove listener callback
|
||||
}
|
||||
|
||||
function onAfterSend(cb: (message: string, context: ChatStreamEventContext) => Promise<void>) {
|
||||
onAfterSendHooks.value.push(cb)
|
||||
return () => onAfterSendHooks.value = onAfterSendHooks.value.filter(hook => hook !== cb) // return remove listener callback
|
||||
}
|
||||
|
||||
function onTokenLiteral(cb: (literal: string, context: ChatStreamEventContext) => Promise<void>) {
|
||||
onTokenLiteralHooks.value.push(cb)
|
||||
return () => onTokenLiteralHooks.value = onTokenLiteralHooks.value.filter(hook => hook !== cb) // return remove listener callback
|
||||
}
|
||||
|
||||
function onTokenSpecial(cb: (special: string, context: ChatStreamEventContext) => Promise<void>) {
|
||||
onTokenSpecialHooks.value.push(cb)
|
||||
return () => onTokenSpecialHooks.value = onTokenSpecialHooks.value.filter(hook => hook !== cb) // return remove listener callback
|
||||
}
|
||||
|
||||
function onStreamEnd(cb: (context: ChatStreamEventContext) => Promise<void>) {
|
||||
onStreamEndHooks.value.push(cb)
|
||||
return () => onStreamEndHooks.value = onStreamEndHooks.value.filter(hook => hook !== cb) // return remove listener callback
|
||||
}
|
||||
|
||||
function onAssistantResponseEnd(cb: (message: string, context: ChatStreamEventContext) => Promise<void>) {
|
||||
onAssistantResponseEndHooks.value.push(cb)
|
||||
return () => onAssistantResponseEndHooks.value = onAssistantResponseEndHooks.value.filter(hook => hook !== cb) // return remove listener callback
|
||||
}
|
||||
|
||||
function onAssistantMessage(cb: (message: StreamingAssistantMessage, messageText: string, context: ChatStreamEventContext) => Promise<void>) {
|
||||
onAssistantMessageHooks.value.push(cb)
|
||||
return () => onAssistantMessageHooks.value = onAssistantMessageHooks.value.filter(hook => hook !== cb) // return remove listener callback
|
||||
}
|
||||
|
||||
function onChatTurnComplete(cb: (chat: { output: StreamingAssistantMessage, outputText: string, toolCalls: ToolMessage[] }, context: ChatStreamEventContext) => Promise<void>) {
|
||||
onChatTurnCompleteHooks.value.push(cb)
|
||||
return () => onChatTurnCompleteHooks.value = onChatTurnCompleteHooks.value.filter(hook => hook !== cb) // return remove listener callback
|
||||
}
|
||||
|
||||
function clearHooks() {
|
||||
onBeforeMessageComposedHooks.value = []
|
||||
onAfterMessageComposedHooks.value = []
|
||||
onBeforeSendHooks.value = []
|
||||
onAfterSendHooks.value = []
|
||||
onTokenLiteralHooks.value = []
|
||||
onTokenSpecialHooks.value = []
|
||||
onStreamEndHooks.value = []
|
||||
onAssistantResponseEndHooks.value = []
|
||||
onAssistantMessageHooks.value = []
|
||||
onChatTurnCompleteHooks.value = []
|
||||
}
|
||||
|
||||
async function emitBeforeMessageComposedHooks(message: string, context: Omit<ChatStreamEventContext, 'composedMessage'>) {
|
||||
for (const hook of onBeforeMessageComposedHooks.value)
|
||||
await hook(message, context)
|
||||
}
|
||||
|
||||
async function emitAfterMessageComposedHooks(message: string, context: ChatStreamEventContext) {
|
||||
for (const hook of onAfterMessageComposedHooks.value)
|
||||
await hook(message, context)
|
||||
}
|
||||
|
||||
async function emitBeforeSendHooks(message: string, context: ChatStreamEventContext) {
|
||||
for (const hook of onBeforeSendHooks.value)
|
||||
await hook(message, context)
|
||||
}
|
||||
|
||||
async function emitAfterSendHooks(message: string, context: ChatStreamEventContext) {
|
||||
for (const hook of onAfterSendHooks.value)
|
||||
await hook(message, context)
|
||||
}
|
||||
|
||||
async function emitTokenLiteralHooks(literal: string, context: ChatStreamEventContext) {
|
||||
for (const hook of onTokenLiteralHooks.value)
|
||||
await hook(literal, context)
|
||||
}
|
||||
|
||||
async function emitTokenSpecialHooks(special: string, context: ChatStreamEventContext) {
|
||||
for (const hook of onTokenSpecialHooks.value)
|
||||
await hook(special, context)
|
||||
}
|
||||
|
||||
async function emitStreamEndHooks(context: ChatStreamEventContext) {
|
||||
for (const hook of onStreamEndHooks.value)
|
||||
await hook(context)
|
||||
}
|
||||
|
||||
async function emitAssistantResponseEndHooks(message: string, context: ChatStreamEventContext) {
|
||||
for (const hook of onAssistantResponseEndHooks.value)
|
||||
await hook(message, context)
|
||||
}
|
||||
|
||||
async function emitAssistantMessageHooks(message: StreamingAssistantMessage, messageText: string, context: ChatStreamEventContext) {
|
||||
for (const hook of onAssistantMessageHooks.value)
|
||||
await hook(message, messageText, context)
|
||||
}
|
||||
|
||||
async function emitChatTurnCompleteHooks(chat: { output: StreamingAssistantMessage, outputText: string, toolCalls: ToolMessage[] }, context: ChatStreamEventContext) {
|
||||
for (const hook of onChatTurnCompleteHooks.value)
|
||||
await hook(chat, context)
|
||||
}
|
||||
|
||||
// ----- Session state helpers -----
|
||||
// I know this nu uh, better than loading all language on rehypeShiki
|
||||
const codeBlockSystemPrompt = '- For any programming code block, always specify the programming language that supported on @shikijs/rehype on the rendered markdown, eg. ```python ... ```\n'
|
||||
const mathSyntaxSystemPrompt = '- For any math equation, use LaTeX format, eg: $ x^3 $, always escape dollar sign outside math equation\n'
|
||||
|
||||
function ensureSessionGeneration(sessionId: string) {
|
||||
if (sessionGenerations.value[sessionId] === undefined)
|
||||
sessionGenerations.value = { ...sessionGenerations.value, [sessionId]: 0 }
|
||||
}
|
||||
|
||||
function getSessionGeneration(sessionId: string) {
|
||||
ensureSessionGeneration(sessionId)
|
||||
return sessionGenerations.value[sessionId] ?? 0
|
||||
}
|
||||
|
||||
function bumpSessionGeneration(sessionId: string) {
|
||||
const nextGeneration = getSessionGeneration(sessionId) + 1
|
||||
sessionGenerations.value = { ...sessionGenerations.value, [sessionId]: nextGeneration }
|
||||
return nextGeneration
|
||||
}
|
||||
|
||||
function getSessionGenerationValue(sessionId = activeSessionId.value) {
|
||||
return getSessionGeneration(sessionId)
|
||||
}
|
||||
|
||||
function generateInitialMessage() {
|
||||
// TODO: compose, replace {{ user }} tag, etc
|
||||
const content = codeBlockSystemPrompt + mathSyntaxSystemPrompt + systemPrompt.value
|
||||
|
||||
return {
|
||||
role: 'system',
|
||||
content,
|
||||
} satisfies SystemMessage
|
||||
}
|
||||
|
||||
function ensureSession(sessionId: string) {
|
||||
ensureSessionGeneration(sessionId)
|
||||
|
||||
if (!sessionMessages.value[sessionId] || sessionMessages.value[sessionId].length === 0) {
|
||||
sessionMessages.value[sessionId] = [generateInitialMessage()]
|
||||
}
|
||||
}
|
||||
|
||||
ensureSession(activeSessionId.value)
|
||||
|
||||
function getSessionMessagesById(sessionId: string) {
|
||||
ensureSession(sessionId)
|
||||
return sessionMessages.value[sessionId]!
|
||||
}
|
||||
|
||||
const messages = computed<ChatHistoryItem[]>({
|
||||
get: () => {
|
||||
ensureSession(activeSessionId.value)
|
||||
return sessionMessages.value[activeSessionId.value]
|
||||
},
|
||||
set: (value) => {
|
||||
sessionMessages.value[activeSessionId.value] = value
|
||||
},
|
||||
})
|
||||
|
||||
function setActiveSession(sessionId: string) {
|
||||
activeSessionId.value = sessionId
|
||||
ensureSession(sessionId)
|
||||
}
|
||||
|
||||
function cleanupMessages(sessionId = activeSessionId.value) {
|
||||
bumpSessionGeneration(sessionId)
|
||||
sessionMessages.value[sessionId] = [generateInitialMessage()]
|
||||
activeContexts.value = {}
|
||||
|
||||
// Reject pending sends for this session so callers don't hang after cleanup
|
||||
for (const queued of pendingQueuedSends.value) {
|
||||
if (queued.sessionId !== sessionId)
|
||||
continue
|
||||
|
||||
queued.cancelled = true
|
||||
queued.deferred.reject(new Error('Chat session was reset before send could start'))
|
||||
}
|
||||
|
||||
pendingQueuedSends.value = pendingQueuedSends.value.filter(item => item.sessionId !== sessionId)
|
||||
sending.value = false
|
||||
streamingMessage.value = { role: 'assistant', content: '', slices: [], tool_results: [] }
|
||||
}
|
||||
|
||||
function getAllSessions() {
|
||||
return JSON.parse(JSON.stringify(toRaw(sessionMessages.value))) as Record<string, ChatHistoryItem[]>
|
||||
}
|
||||
|
||||
function replaceSessions(sessions: Record<string, ChatHistoryItem[]>) {
|
||||
sessionMessages.value = sessions
|
||||
sessionGenerations.value = Object.fromEntries(Object.keys(sessions).map(sessionId => [sessionId, 0]))
|
||||
const [firstSessionId] = Object.keys(sessions)
|
||||
if (!sessionMessages.value[activeSessionId.value] && firstSessionId)
|
||||
activeSessionId.value = firstSessionId
|
||||
|
||||
ensureSession(activeSessionId.value)
|
||||
}
|
||||
|
||||
function resetAllSessions() {
|
||||
sessionMessages.value = {}
|
||||
sessionGenerations.value = {}
|
||||
activeSessionId.value = 'default'
|
||||
ensureSession(activeSessionId.value)
|
||||
}
|
||||
|
||||
watch(systemPrompt, () => {
|
||||
for (const [sessionId, history] of Object.entries(sessionMessages.value)) {
|
||||
if (history.length > 0 && history[0].role === 'system') {
|
||||
sessionMessages.value[sessionId][0] = generateInitialMessage()
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function ingestContextMessage(envelope: ContextMessage) {
|
||||
const sourceKey = getEventSourceKey(envelope)
|
||||
if (!activeContexts.value[sourceKey]) {
|
||||
activeContexts.value[sourceKey] = []
|
||||
}
|
||||
|
||||
if (envelope.strategy === ContextUpdateStrategy.ReplaceSelf) {
|
||||
activeContexts.value[sourceKey] = [envelope]
|
||||
}
|
||||
else if (envelope.strategy === ContextUpdateStrategy.AppendSelf) {
|
||||
activeContexts.value[sourceKey].push(envelope)
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Send flow (user -> LLM -> assistant) -----
|
||||
async function performSend(
|
||||
sendingMessage: string,
|
||||
options: SendOptions,
|
||||
@@ -357,17 +97,17 @@ export const useChatStore = defineStore('chat', () => {
|
||||
if (!sendingMessage && !options.attachments?.length)
|
||||
return
|
||||
|
||||
ensureSession(sessionId)
|
||||
chatSession.ensureSession(sessionId)
|
||||
|
||||
const sendingCreatedAt = Date.now()
|
||||
const streamingMessageContext: ChatStreamEventContext = {
|
||||
message: { role: 'user', content: sendingMessage, createdAt: sendingCreatedAt },
|
||||
contexts: toRaw(activeContexts.value),
|
||||
contexts: chatContext.getContextsSnapshot(),
|
||||
composedMessage: [],
|
||||
input: options.input,
|
||||
}
|
||||
|
||||
const isStaleGeneration = () => getSessionGeneration(sessionId) !== generation
|
||||
const isStaleGeneration = () => chatSession.getSessionGeneration(sessionId) !== generation
|
||||
const shouldAbort = () => isStaleGeneration()
|
||||
if (shouldAbort())
|
||||
return
|
||||
@@ -376,23 +116,19 @@ export const useChatStore = defineStore('chat', () => {
|
||||
|
||||
const isForegroundSession = () => sessionId === activeSessionId.value
|
||||
|
||||
// Use a local object for building the message to avoid polluting the UI for background sessions
|
||||
const buildingMessage: StreamingAssistantMessage = { role: 'assistant', content: '', slices: [], tool_results: [], createdAt: Date.now() }
|
||||
|
||||
// NOTICE: Clone into reactive state only for the foreground session
|
||||
// to avoid background streams mutating UI.
|
||||
const updateUI = () => {
|
||||
if (isForegroundSession()) {
|
||||
streamingMessage.value = JSON.parse(JSON.stringify(buildingMessage))
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize UI if foreground
|
||||
updateUI()
|
||||
|
||||
trackFirstMessage()
|
||||
try {
|
||||
await emitBeforeMessageComposedHooks(sendingMessage, streamingMessageContext)
|
||||
await hooks.emitBeforeMessageComposedHooks(sendingMessage, streamingMessageContext)
|
||||
|
||||
const contentParts: CommonContentPart[] = [{ type: 'text', text: sendingMessage }]
|
||||
|
||||
@@ -422,36 +158,27 @@ export const useChatStore = defineStore('chat', () => {
|
||||
if (shouldAbort())
|
||||
return
|
||||
|
||||
const sessionMessagesForSend = getSessionMessagesById(sessionId)
|
||||
const sessionMessagesForSend = chatSession.getSessionMessages(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
|
||||
let streamPosition = 0
|
||||
|
||||
const parser = useLlmmarkerParser({
|
||||
onLiteral: async (literal) => {
|
||||
if (shouldAbort())
|
||||
return
|
||||
|
||||
// Feed to categorizer first
|
||||
categorizer.consume(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
|
||||
|
||||
// Only process non-empty speech content (filter empty/whitespace-only chunks)
|
||||
// Preserve spacing in chunks with content for proper word boundaries
|
||||
if (speechOnly.trim()) {
|
||||
buildingMessage.content += speechOnly
|
||||
|
||||
// Emit TTS only for speech parts, not reasoning (clean data, no empty chunks)
|
||||
await emitTokenLiteralHooks(speechOnly, streamingMessageContext)
|
||||
await hooks.emitTokenLiteralHooks(speechOnly, streamingMessageContext)
|
||||
|
||||
// Add speech content to slices for rendering
|
||||
// merge text slices for markdown
|
||||
const lastSlice = buildingMessage.slices.at(-1)
|
||||
if (lastSlice?.type === 'text') {
|
||||
lastSlice.text += speechOnly
|
||||
@@ -469,23 +196,21 @@ export const useChatStore = defineStore('chat', () => {
|
||||
if (shouldAbort())
|
||||
return
|
||||
|
||||
await emitTokenSpecialHooks(special, streamingMessageContext)
|
||||
await hooks.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
|
||||
buildingMessage.categorization = {
|
||||
speech: finalCategorization.speech,
|
||||
reasoning: finalCategorization.reasoning,
|
||||
}
|
||||
updateUI()
|
||||
},
|
||||
minLiteralEmitLength: 24, // Avoid emitting literals too fast. This is a magic number and can be changed later.
|
||||
minLiteralEmitLength: 24,
|
||||
})
|
||||
|
||||
const toolCallQueue = createQueue<ChatSlices>({
|
||||
@@ -512,7 +237,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
const rawMessage = toRaw(withoutContext)
|
||||
|
||||
if (rawMessage.role === 'assistant') {
|
||||
const { slices: _, tool_results, categorization: __categorization, ...rest } = rawMessage as ChatAssistantMessage
|
||||
const { slices: _slices, tool_results, categorization: _categorization, ...rest } = rawMessage as ChatAssistantMessage
|
||||
return {
|
||||
...toRaw(rest),
|
||||
tool_results: toRaw(tool_results),
|
||||
@@ -522,9 +247,8 @@ export const useChatStore = defineStore('chat', () => {
|
||||
return rawMessage
|
||||
})
|
||||
|
||||
// TODO: possible prototype pollution as key of activeContexts is from external source
|
||||
// TODO: sanitize keys or use a safer structure
|
||||
if (Object.keys(activeContexts.value).length > 0) {
|
||||
const contextsSnapshot = chatContext.getContextsSnapshot()
|
||||
if (Object.keys(contextsSnapshot).length > 0) {
|
||||
const system = newMessages.slice(0, 1)
|
||||
const afterSystem = newMessages.slice(1, newMessages.length)
|
||||
|
||||
@@ -533,11 +257,12 @@ export const useChatStore = defineStore('chat', () => {
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
// TODO: use prompt render & i18n system later
|
||||
// TODO: Module should have description & context length management
|
||||
{ type: 'text', text: ''
|
||||
+ 'These are the contextual information retrieved or on-demand updated from other modules, you may use them as context for chat, or reference of the next action, tool call, etc.:\n'
|
||||
+ `${Object.entries(activeContexts.value).map(([key, value]) => `Module ${key}: ${JSON.stringify(value)}`).join('\n')}\n` },
|
||||
{
|
||||
type: 'text',
|
||||
text: ''
|
||||
+ 'These are the contextual information retrieved or on-demand updated from other modules, you may use them as context for chat, or reference of the next action, tool call, etc.:\n'
|
||||
+ `${Object.entries(contextsSnapshot).map(([key, value]) => `Module ${key}: ${JSON.stringify(value)}`).join('\n')}\n`,
|
||||
},
|
||||
],
|
||||
},
|
||||
...afterSystem,
|
||||
@@ -546,8 +271,8 @@ export const useChatStore = defineStore('chat', () => {
|
||||
|
||||
streamingMessageContext.composedMessage = newMessages as Message[]
|
||||
|
||||
await emitAfterMessageComposedHooks(sendingMessage, streamingMessageContext)
|
||||
await emitBeforeSendHooks(sendingMessage, streamingMessageContext)
|
||||
await hooks.emitAfterMessageComposedHooks(sendingMessage, streamingMessageContext)
|
||||
await hooks.emitBeforeSendHooks(sendingMessage, streamingMessageContext)
|
||||
|
||||
let fullText = ''
|
||||
const headers = (options.providerConfig?.headers || {}) as Record<string, string>
|
||||
@@ -580,7 +305,6 @@ export const useChatStore = defineStore('chat', () => {
|
||||
await parser.consume(event.text)
|
||||
break
|
||||
case 'finish':
|
||||
// Do nothing, resolve
|
||||
break
|
||||
case 'error':
|
||||
throw event.error ?? new Error('Stream error')
|
||||
@@ -588,30 +312,23 @@ 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
|
||||
if (!isStaleGeneration() && buildingMessage.slices.length > 0) {
|
||||
sessionMessagesForSend.push(toRaw(buildingMessage))
|
||||
}
|
||||
|
||||
// Call the end-of-stream hooks
|
||||
await emitStreamEndHooks(streamingMessageContext)
|
||||
await hooks.emitStreamEndHooks(streamingMessageContext)
|
||||
await hooks.emitAssistantResponseEndHooks(fullText, streamingMessageContext)
|
||||
|
||||
// Call the end-of-response hooks with the full text
|
||||
await emitAssistantResponseEndHooks(fullText, streamingMessageContext)
|
||||
|
||||
await emitAfterSendHooks(sendingMessage, streamingMessageContext)
|
||||
await emitAssistantMessageHooks({ ...buildingMessage }, fullText, streamingMessageContext)
|
||||
await emitChatTurnCompleteHooks({
|
||||
await hooks.emitAfterSendHooks(sendingMessage, streamingMessageContext)
|
||||
await hooks.emitAssistantMessageHooks({ ...buildingMessage }, fullText, streamingMessageContext)
|
||||
await hooks.emitChatTurnCompleteHooks({
|
||||
output: { ...buildingMessage },
|
||||
outputText: fullText,
|
||||
toolCalls: sessionMessagesForSend.filter(msg => msg.role === 'tool') as ToolMessage[],
|
||||
}, streamingMessageContext)
|
||||
|
||||
// Reset the streaming message for the next turn
|
||||
if (isForegroundSession()) {
|
||||
streamingMessage.value = { role: 'assistant', content: '', slices: [], tool_results: [] }
|
||||
}
|
||||
@@ -625,43 +342,13 @@ export const useChatStore = defineStore('chat', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Remote stream helpers (for broadcast/devtools) -----
|
||||
function beginRemoteStream() {
|
||||
streamingMessage.value = { role: 'assistant', content: '', slices: [], tool_results: [], createdAt: Date.now() }
|
||||
}
|
||||
|
||||
function appendRemoteLiteral(literal: string) {
|
||||
streamingMessage.value.content += literal
|
||||
|
||||
const lastSlice = streamingMessage.value.slices.at(-1)
|
||||
if (lastSlice?.type === 'text') {
|
||||
lastSlice.text += literal
|
||||
return
|
||||
}
|
||||
|
||||
streamingMessage.value.slices.push({
|
||||
type: 'text',
|
||||
text: literal,
|
||||
})
|
||||
}
|
||||
|
||||
function finalizeRemoteStream(fullText?: string) {
|
||||
const sessionId = activeSessionId.value
|
||||
const sessionMessagesForSend = getSessionMessagesById(sessionId)
|
||||
if (streamingMessage.value.slices.length > 0)
|
||||
sessionMessagesForSend.push(toRaw(streamingMessage.value))
|
||||
streamingMessage.value = { role: 'assistant', content: '', slices: [], tool_results: [] }
|
||||
if (fullText)
|
||||
streamingMessage.value.content = fullText
|
||||
}
|
||||
|
||||
async function send(
|
||||
async function ingest(
|
||||
sendingMessage: string,
|
||||
options: SendOptions,
|
||||
targetSessionId?: string,
|
||||
) {
|
||||
const sessionId = targetSessionId || activeSessionId.value
|
||||
const generation = getSessionGeneration(sessionId)
|
||||
const generation = chatSession.getSessionGeneration(sessionId)
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
sendQueue.enqueue({
|
||||
@@ -674,51 +361,50 @@ export const useChatStore = defineStore('chat', () => {
|
||||
})
|
||||
}
|
||||
|
||||
function cancelPendingSends(sessionId?: string) {
|
||||
for (const queued of pendingQueuedSends.value) {
|
||||
if (sessionId && queued.sessionId !== sessionId)
|
||||
continue
|
||||
|
||||
queued.cancelled = true
|
||||
queued.deferred.reject(new Error('Chat session was reset before send could start'))
|
||||
}
|
||||
|
||||
pendingQueuedSends.value = sessionId
|
||||
? pendingQueuedSends.value.filter(item => item.sessionId !== sessionId)
|
||||
: []
|
||||
}
|
||||
|
||||
return {
|
||||
sending,
|
||||
activeSessionId,
|
||||
messages,
|
||||
streamingMessage,
|
||||
|
||||
discoverToolsCompatibility: llmStore.discoverToolsCompatibility,
|
||||
|
||||
send,
|
||||
setActiveSession,
|
||||
cleanupMessages,
|
||||
getAllSessions,
|
||||
replaceSessions,
|
||||
resetAllSessions,
|
||||
ingest,
|
||||
cancelPendingSends,
|
||||
|
||||
ingestContextMessage,
|
||||
clearHooks: hooks.clearHooks,
|
||||
|
||||
clearHooks,
|
||||
emitBeforeMessageComposedHooks: hooks.emitBeforeMessageComposedHooks,
|
||||
emitAfterMessageComposedHooks: hooks.emitAfterMessageComposedHooks,
|
||||
emitBeforeSendHooks: hooks.emitBeforeSendHooks,
|
||||
emitAfterSendHooks: hooks.emitAfterSendHooks,
|
||||
emitTokenLiteralHooks: hooks.emitTokenLiteralHooks,
|
||||
emitTokenSpecialHooks: hooks.emitTokenSpecialHooks,
|
||||
emitStreamEndHooks: hooks.emitStreamEndHooks,
|
||||
emitAssistantResponseEndHooks: hooks.emitAssistantResponseEndHooks,
|
||||
emitAssistantMessageHooks: hooks.emitAssistantMessageHooks,
|
||||
emitChatTurnCompleteHooks: hooks.emitChatTurnCompleteHooks,
|
||||
|
||||
emitBeforeMessageComposedHooks,
|
||||
emitAfterMessageComposedHooks,
|
||||
emitBeforeSendHooks,
|
||||
emitAfterSendHooks,
|
||||
emitTokenLiteralHooks,
|
||||
emitTokenSpecialHooks,
|
||||
emitStreamEndHooks,
|
||||
emitAssistantResponseEndHooks,
|
||||
emitAssistantMessageHooks,
|
||||
emitChatTurnCompleteHooks,
|
||||
|
||||
getSessionGenerationValue,
|
||||
|
||||
beginRemoteStream,
|
||||
appendRemoteLiteral,
|
||||
finalizeRemoteStream,
|
||||
|
||||
onBeforeMessageComposed,
|
||||
onAfterMessageComposed,
|
||||
onBeforeSend,
|
||||
onAfterSend,
|
||||
onTokenLiteral,
|
||||
onTokenSpecial,
|
||||
onStreamEnd,
|
||||
onAssistantResponseEnd,
|
||||
onAssistantMessage,
|
||||
onChatTurnComplete,
|
||||
onBeforeMessageComposed: hooks.onBeforeMessageComposed,
|
||||
onAfterMessageComposed: hooks.onAfterMessageComposed,
|
||||
onBeforeSend: hooks.onBeforeSend,
|
||||
onAfterSend: hooks.onAfterSend,
|
||||
onTokenLiteral: hooks.onTokenLiteral,
|
||||
onTokenSpecial: hooks.onTokenSpecial,
|
||||
onStreamEnd: hooks.onStreamEnd,
|
||||
onAssistantResponseEnd: hooks.onAssistantResponseEnd,
|
||||
onAssistantMessage: hooks.onAssistantMessage,
|
||||
onChatTurnComplete: hooks.onChatTurnComplete,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export const CHAT_STORAGE_KEY = 'chat/messages/v2'
|
||||
export const ACTIVE_SESSION_STORAGE_KEY = 'chat/active-session'
|
||||
export const CONTEXT_CHANNEL_NAME = 'airi-context-update'
|
||||
export const CHAT_STREAM_CHANNEL_NAME = 'airi-chat-stream'
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ContextMessage } from '../../types/chat'
|
||||
|
||||
import { ContextUpdateStrategy } from '@proj-airi/server-sdk'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, toRaw } from 'vue'
|
||||
|
||||
import { getEventSourceKey } from '../../utils/event-source'
|
||||
|
||||
export const useChatContextStore = defineStore('chat-context', () => {
|
||||
const activeContexts = ref<Record<string, ContextMessage[]>>({})
|
||||
|
||||
function ingestContextMessage(envelope: ContextMessage) {
|
||||
const sourceKey = getEventSourceKey(envelope)
|
||||
if (!activeContexts.value[sourceKey]) {
|
||||
activeContexts.value[sourceKey] = []
|
||||
}
|
||||
|
||||
if (envelope.strategy === ContextUpdateStrategy.ReplaceSelf) {
|
||||
activeContexts.value[sourceKey] = [envelope]
|
||||
}
|
||||
else if (envelope.strategy === ContextUpdateStrategy.AppendSelf) {
|
||||
activeContexts.value[sourceKey].push(envelope)
|
||||
}
|
||||
}
|
||||
|
||||
function resetContexts() {
|
||||
activeContexts.value = {}
|
||||
}
|
||||
|
||||
function getContextsSnapshot() {
|
||||
return toRaw(activeContexts.value)
|
||||
}
|
||||
|
||||
return {
|
||||
ingestContextMessage,
|
||||
resetContexts,
|
||||
getContextsSnapshot,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { SystemMessage } from '@xsai/shared-chat'
|
||||
|
||||
import type { ChatHistoryItem } from '../../types/chat'
|
||||
|
||||
export interface ChatDataAccess {
|
||||
getActiveSessionId: () => string
|
||||
setActiveSessionId: (sessionId: string) => void
|
||||
getSessions: () => Record<string, ChatHistoryItem[]>
|
||||
setSessions: (sessions: Record<string, ChatHistoryItem[]>) => void
|
||||
getGenerations: () => Record<string, number>
|
||||
setGenerations: (generations: Record<string, number>) => void
|
||||
}
|
||||
|
||||
export interface ChatDataStore {
|
||||
ensureSession: (sessionId: string, createInitialMessage: () => SystemMessage) => void
|
||||
getSessionMessages: (sessionId: string, createInitialMessage: () => SystemMessage) => ChatHistoryItem[]
|
||||
setSessionMessages: (sessionId: string, next: ChatHistoryItem[]) => void
|
||||
setActiveSession: (sessionId: string, createInitialMessage: () => SystemMessage) => void
|
||||
getActiveSessionId: () => string
|
||||
resetSession: (sessionId: string, createInitialMessage: () => SystemMessage) => void
|
||||
refreshSystemMessages: (createInitialMessage: () => SystemMessage) => void
|
||||
replaceSessions: (sessions: Record<string, ChatHistoryItem[]>, createInitialMessage: () => SystemMessage) => void
|
||||
resetAllSessions: (createInitialMessage: () => SystemMessage) => void
|
||||
getAllSessions: () => Record<string, ChatHistoryItem[]>
|
||||
getSessionGeneration: (sessionId: string) => number
|
||||
bumpSessionGeneration: (sessionId: string) => number
|
||||
getSessionGenerationValue: (sessionId?: string) => number
|
||||
}
|
||||
|
||||
export function createChatDataStore(access: ChatDataAccess): ChatDataStore {
|
||||
function ensureGeneration(sessionId: string) {
|
||||
const generations = access.getGenerations()
|
||||
if (generations[sessionId] === undefined)
|
||||
access.setGenerations({ ...generations, [sessionId]: 0 })
|
||||
}
|
||||
|
||||
function getSessionGeneration(sessionId: string) {
|
||||
ensureGeneration(sessionId)
|
||||
return access.getGenerations()[sessionId] ?? 0
|
||||
}
|
||||
|
||||
function bumpSessionGeneration(sessionId: string) {
|
||||
const nextGeneration = getSessionGeneration(sessionId) + 1
|
||||
access.setGenerations({ ...access.getGenerations(), [sessionId]: nextGeneration })
|
||||
return nextGeneration
|
||||
}
|
||||
|
||||
function ensureSession(sessionId: string, createInitialMessage: () => SystemMessage) {
|
||||
ensureGeneration(sessionId)
|
||||
|
||||
const sessions = access.getSessions()
|
||||
if (!sessions[sessionId] || sessions[sessionId].length === 0) {
|
||||
access.setSessions({
|
||||
...sessions,
|
||||
[sessionId]: [createInitialMessage()],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionMessages(sessionId: string, createInitialMessage: () => SystemMessage) {
|
||||
ensureSession(sessionId, createInitialMessage)
|
||||
return access.getSessions()[sessionId]!
|
||||
}
|
||||
|
||||
function setSessionMessages(sessionId: string, next: ChatHistoryItem[]) {
|
||||
access.setSessions({
|
||||
...access.getSessions(),
|
||||
[sessionId]: next,
|
||||
})
|
||||
}
|
||||
|
||||
function setActiveSession(sessionId: string, createInitialMessage: () => SystemMessage) {
|
||||
access.setActiveSessionId(sessionId)
|
||||
ensureSession(sessionId, createInitialMessage)
|
||||
}
|
||||
|
||||
function getActiveSessionId() {
|
||||
return access.getActiveSessionId()
|
||||
}
|
||||
|
||||
function resetSession(sessionId: string, createInitialMessage: () => SystemMessage) {
|
||||
bumpSessionGeneration(sessionId)
|
||||
setSessionMessages(sessionId, [createInitialMessage()])
|
||||
}
|
||||
|
||||
function refreshSystemMessages(createInitialMessage: () => SystemMessage) {
|
||||
const sessions = access.getSessions()
|
||||
const nextSessions: Record<string, ChatHistoryItem[]> = {}
|
||||
|
||||
for (const [sessionId, history] of Object.entries(sessions)) {
|
||||
if (history.length > 0 && history[0].role === 'system') {
|
||||
nextSessions[sessionId] = [createInitialMessage(), ...history.slice(1)]
|
||||
}
|
||||
else {
|
||||
nextSessions[sessionId] = history
|
||||
}
|
||||
}
|
||||
|
||||
access.setSessions(nextSessions)
|
||||
}
|
||||
|
||||
function replaceSessions(sessions: Record<string, ChatHistoryItem[]>, createInitialMessage: () => SystemMessage) {
|
||||
access.setSessions(sessions)
|
||||
access.setGenerations(Object.fromEntries(Object.keys(sessions).map(sessionId => [sessionId, 0])))
|
||||
|
||||
const [firstSessionId] = Object.keys(sessions)
|
||||
if (!sessions[access.getActiveSessionId()] && firstSessionId)
|
||||
access.setActiveSessionId(firstSessionId)
|
||||
|
||||
ensureSession(access.getActiveSessionId(), createInitialMessage)
|
||||
}
|
||||
|
||||
function resetAllSessions(createInitialMessage: () => SystemMessage) {
|
||||
access.setSessions({})
|
||||
access.setGenerations({})
|
||||
access.setActiveSessionId('default')
|
||||
ensureSession('default', createInitialMessage)
|
||||
}
|
||||
|
||||
function getAllSessions() {
|
||||
return JSON.parse(JSON.stringify(access.getSessions())) as Record<string, ChatHistoryItem[]>
|
||||
}
|
||||
|
||||
function getSessionGenerationValue(sessionId?: string) {
|
||||
const targetSessionId = sessionId ?? access.getActiveSessionId()
|
||||
return getSessionGeneration(targetSessionId)
|
||||
}
|
||||
|
||||
return {
|
||||
ensureSession,
|
||||
getSessionMessages,
|
||||
setSessionMessages,
|
||||
setActiveSession,
|
||||
getActiveSessionId,
|
||||
resetSession,
|
||||
refreshSystemMessages,
|
||||
replaceSessions,
|
||||
resetAllSessions,
|
||||
getAllSessions,
|
||||
getSessionGeneration,
|
||||
bumpSessionGeneration,
|
||||
getSessionGenerationValue,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import type { ToolMessage } from '@xsai/shared-chat'
|
||||
|
||||
import type { ChatStreamEventContext, StreamingAssistantMessage } from '../../types/chat'
|
||||
|
||||
export interface ChatHookRegistry {
|
||||
onBeforeMessageComposed: (cb: (message: string, context: Omit<ChatStreamEventContext, 'composedMessage'>) => Promise<void>) => () => void
|
||||
onAfterMessageComposed: (cb: (message: string, context: ChatStreamEventContext) => Promise<void>) => () => void
|
||||
onBeforeSend: (cb: (message: string, context: ChatStreamEventContext) => Promise<void>) => () => void
|
||||
onAfterSend: (cb: (message: string, context: ChatStreamEventContext) => Promise<void>) => () => void
|
||||
onTokenLiteral: (cb: (literal: string, context: ChatStreamEventContext) => Promise<void>) => () => void
|
||||
onTokenSpecial: (cb: (special: string, context: ChatStreamEventContext) => Promise<void>) => () => void
|
||||
onStreamEnd: (cb: (context: ChatStreamEventContext) => Promise<void>) => () => void
|
||||
onAssistantResponseEnd: (cb: (message: string, context: ChatStreamEventContext) => Promise<void>) => () => void
|
||||
onAssistantMessage: (cb: (message: StreamingAssistantMessage, messageText: string, context: ChatStreamEventContext) => Promise<void>) => () => void
|
||||
onChatTurnComplete: (cb: (chat: { output: StreamingAssistantMessage, outputText: string, toolCalls: ToolMessage[] }, context: ChatStreamEventContext) => Promise<void>) => () => void
|
||||
emitBeforeMessageComposedHooks: (message: string, context: Omit<ChatStreamEventContext, 'composedMessage'>) => Promise<void>
|
||||
emitAfterMessageComposedHooks: (message: string, context: ChatStreamEventContext) => Promise<void>
|
||||
emitBeforeSendHooks: (message: string, context: ChatStreamEventContext) => Promise<void>
|
||||
emitAfterSendHooks: (message: string, context: ChatStreamEventContext) => Promise<void>
|
||||
emitTokenLiteralHooks: (literal: string, context: ChatStreamEventContext) => Promise<void>
|
||||
emitTokenSpecialHooks: (special: string, context: ChatStreamEventContext) => Promise<void>
|
||||
emitStreamEndHooks: (context: ChatStreamEventContext) => Promise<void>
|
||||
emitAssistantResponseEndHooks: (message: string, context: ChatStreamEventContext) => Promise<void>
|
||||
emitAssistantMessageHooks: (message: StreamingAssistantMessage, messageText: string, context: ChatStreamEventContext) => Promise<void>
|
||||
emitChatTurnCompleteHooks: (chat: { output: StreamingAssistantMessage, outputText: string, toolCalls: ToolMessage[] }, context: ChatStreamEventContext) => Promise<void>
|
||||
clearHooks: () => void
|
||||
}
|
||||
|
||||
export function createChatHooks(): ChatHookRegistry {
|
||||
const onBeforeMessageComposedHooks: Array<(message: string, context: Omit<ChatStreamEventContext, 'composedMessage'>) => Promise<void>> = []
|
||||
const onAfterMessageComposedHooks: Array<(message: string, context: ChatStreamEventContext) => Promise<void>> = []
|
||||
const onBeforeSendHooks: Array<(message: string, context: ChatStreamEventContext) => Promise<void>> = []
|
||||
const onAfterSendHooks: Array<(message: string, context: ChatStreamEventContext) => Promise<void>> = []
|
||||
const onTokenLiteralHooks: Array<(literal: string, context: ChatStreamEventContext) => Promise<void>> = []
|
||||
const onTokenSpecialHooks: Array<(special: string, context: ChatStreamEventContext) => Promise<void>> = []
|
||||
const onStreamEndHooks: Array<(context: ChatStreamEventContext) => Promise<void>> = []
|
||||
const onAssistantResponseEndHooks: Array<(message: string, context: ChatStreamEventContext) => Promise<void>> = []
|
||||
const onAssistantMessageHooks: Array<(message: StreamingAssistantMessage, messageText: string, context: ChatStreamEventContext) => Promise<void>> = []
|
||||
const onChatTurnCompleteHooks: Array<(chat: { output: StreamingAssistantMessage, outputText: string, toolCalls: ToolMessage[] }, context: ChatStreamEventContext) => Promise<void>> = []
|
||||
|
||||
function onBeforeMessageComposed(cb: (message: string, context: Omit<ChatStreamEventContext, 'composedMessage'>) => Promise<void>) {
|
||||
onBeforeMessageComposedHooks.push(cb)
|
||||
return () => {
|
||||
const index = onBeforeMessageComposedHooks.indexOf(cb)
|
||||
if (index >= 0)
|
||||
onBeforeMessageComposedHooks.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function onAfterMessageComposed(cb: (message: string, context: ChatStreamEventContext) => Promise<void>) {
|
||||
onAfterMessageComposedHooks.push(cb)
|
||||
return () => {
|
||||
const index = onAfterMessageComposedHooks.indexOf(cb)
|
||||
if (index >= 0)
|
||||
onAfterMessageComposedHooks.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function onBeforeSend(cb: (message: string, context: ChatStreamEventContext) => Promise<void>) {
|
||||
onBeforeSendHooks.push(cb)
|
||||
return () => {
|
||||
const index = onBeforeSendHooks.indexOf(cb)
|
||||
if (index >= 0)
|
||||
onBeforeSendHooks.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function onAfterSend(cb: (message: string, context: ChatStreamEventContext) => Promise<void>) {
|
||||
onAfterSendHooks.push(cb)
|
||||
return () => {
|
||||
const index = onAfterSendHooks.indexOf(cb)
|
||||
if (index >= 0)
|
||||
onAfterSendHooks.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function onTokenLiteral(cb: (literal: string, context: ChatStreamEventContext) => Promise<void>) {
|
||||
onTokenLiteralHooks.push(cb)
|
||||
return () => {
|
||||
const index = onTokenLiteralHooks.indexOf(cb)
|
||||
if (index >= 0)
|
||||
onTokenLiteralHooks.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function onTokenSpecial(cb: (special: string, context: ChatStreamEventContext) => Promise<void>) {
|
||||
onTokenSpecialHooks.push(cb)
|
||||
return () => {
|
||||
const index = onTokenSpecialHooks.indexOf(cb)
|
||||
if (index >= 0)
|
||||
onTokenSpecialHooks.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function onStreamEnd(cb: (context: ChatStreamEventContext) => Promise<void>) {
|
||||
onStreamEndHooks.push(cb)
|
||||
return () => {
|
||||
const index = onStreamEndHooks.indexOf(cb)
|
||||
if (index >= 0)
|
||||
onStreamEndHooks.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function onAssistantResponseEnd(cb: (message: string, context: ChatStreamEventContext) => Promise<void>) {
|
||||
onAssistantResponseEndHooks.push(cb)
|
||||
return () => {
|
||||
const index = onAssistantResponseEndHooks.indexOf(cb)
|
||||
if (index >= 0)
|
||||
onAssistantResponseEndHooks.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function onAssistantMessage(cb: (message: StreamingAssistantMessage, messageText: string, context: ChatStreamEventContext) => Promise<void>) {
|
||||
onAssistantMessageHooks.push(cb)
|
||||
return () => {
|
||||
const index = onAssistantMessageHooks.indexOf(cb)
|
||||
if (index >= 0)
|
||||
onAssistantMessageHooks.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function onChatTurnComplete(cb: (chat: { output: StreamingAssistantMessage, outputText: string, toolCalls: ToolMessage[] }, context: ChatStreamEventContext) => Promise<void>) {
|
||||
onChatTurnCompleteHooks.push(cb)
|
||||
return () => {
|
||||
const index = onChatTurnCompleteHooks.indexOf(cb)
|
||||
if (index >= 0)
|
||||
onChatTurnCompleteHooks.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function clearHooks() {
|
||||
onBeforeMessageComposedHooks.length = 0
|
||||
onAfterMessageComposedHooks.length = 0
|
||||
onBeforeSendHooks.length = 0
|
||||
onAfterSendHooks.length = 0
|
||||
onTokenLiteralHooks.length = 0
|
||||
onTokenSpecialHooks.length = 0
|
||||
onStreamEndHooks.length = 0
|
||||
onAssistantResponseEndHooks.length = 0
|
||||
onAssistantMessageHooks.length = 0
|
||||
onChatTurnCompleteHooks.length = 0
|
||||
}
|
||||
|
||||
async function emitBeforeMessageComposedHooks(message: string, context: Omit<ChatStreamEventContext, 'composedMessage'>) {
|
||||
for (const hook of onBeforeMessageComposedHooks)
|
||||
await hook(message, context)
|
||||
}
|
||||
|
||||
async function emitAfterMessageComposedHooks(message: string, context: ChatStreamEventContext) {
|
||||
for (const hook of onAfterMessageComposedHooks)
|
||||
await hook(message, context)
|
||||
}
|
||||
|
||||
async function emitBeforeSendHooks(message: string, context: ChatStreamEventContext) {
|
||||
for (const hook of onBeforeSendHooks)
|
||||
await hook(message, context)
|
||||
}
|
||||
|
||||
async function emitAfterSendHooks(message: string, context: ChatStreamEventContext) {
|
||||
for (const hook of onAfterSendHooks)
|
||||
await hook(message, context)
|
||||
}
|
||||
|
||||
async function emitTokenLiteralHooks(literal: string, context: ChatStreamEventContext) {
|
||||
for (const hook of onTokenLiteralHooks)
|
||||
await hook(literal, context)
|
||||
}
|
||||
|
||||
async function emitTokenSpecialHooks(special: string, context: ChatStreamEventContext) {
|
||||
for (const hook of onTokenSpecialHooks)
|
||||
await hook(special, context)
|
||||
}
|
||||
|
||||
async function emitStreamEndHooks(context: ChatStreamEventContext) {
|
||||
for (const hook of onStreamEndHooks)
|
||||
await hook(context)
|
||||
}
|
||||
|
||||
async function emitAssistantResponseEndHooks(message: string, context: ChatStreamEventContext) {
|
||||
for (const hook of onAssistantResponseEndHooks)
|
||||
await hook(message, context)
|
||||
}
|
||||
|
||||
async function emitAssistantMessageHooks(message: StreamingAssistantMessage, messageText: string, context: ChatStreamEventContext) {
|
||||
for (const hook of onAssistantMessageHooks)
|
||||
await hook(message, messageText, context)
|
||||
}
|
||||
|
||||
async function emitChatTurnCompleteHooks(chat: { output: StreamingAssistantMessage, outputText: string, toolCalls: ToolMessage[] }, context: ChatStreamEventContext) {
|
||||
for (const hook of onChatTurnCompleteHooks)
|
||||
await hook(chat, context)
|
||||
}
|
||||
|
||||
return {
|
||||
onBeforeMessageComposed,
|
||||
onAfterMessageComposed,
|
||||
onBeforeSend,
|
||||
onAfterSend,
|
||||
onTokenLiteral,
|
||||
onTokenSpecial,
|
||||
onStreamEnd,
|
||||
onAssistantResponseEnd,
|
||||
onAssistantMessage,
|
||||
onChatTurnComplete,
|
||||
emitBeforeMessageComposedHooks,
|
||||
emitAfterMessageComposedHooks,
|
||||
emitBeforeSendHooks,
|
||||
emitAfterSendHooks,
|
||||
emitTokenLiteralHooks,
|
||||
emitTokenSpecialHooks,
|
||||
emitStreamEndHooks,
|
||||
emitAssistantResponseEndHooks,
|
||||
emitAssistantMessageHooks,
|
||||
emitChatTurnCompleteHooks,
|
||||
clearHooks,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { useChatOrchestratorStore } from '../chat'
|
||||
import { useChatContextStore } from './context-store'
|
||||
import { useChatSessionStore } from './session-store'
|
||||
import { useChatStreamStore } from './stream-store'
|
||||
|
||||
export const useChatMaintenanceStore = defineStore('chat-maintenance', () => {
|
||||
const chatSession = useChatSessionStore()
|
||||
const chatStream = useChatStreamStore()
|
||||
const chatContext = useChatContextStore()
|
||||
const chatOrchestrator = useChatOrchestratorStore()
|
||||
|
||||
function cleanupMessages(sessionId = chatSession.activeSessionId) {
|
||||
chatSession.cleanupMessages(sessionId)
|
||||
chatContext.resetContexts()
|
||||
chatOrchestrator.cancelPendingSends(sessionId)
|
||||
chatStream.resetStream()
|
||||
}
|
||||
|
||||
return {
|
||||
cleanupMessages,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { SystemMessage } from '@xsai/shared-chat'
|
||||
|
||||
import type { ChatHistoryItem } from '../../types/chat'
|
||||
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { defineStore, storeToRefs } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useCharacterStore } from '../character'
|
||||
import { ACTIVE_SESSION_STORAGE_KEY, CHAT_STORAGE_KEY } from './constants'
|
||||
import { createChatDataStore } from './data-store'
|
||||
|
||||
export const useChatSessionStore = defineStore('chat-session', () => {
|
||||
const { systemPrompt } = storeToRefs(useCharacterStore())
|
||||
|
||||
const activeSessionId = useLocalStorage<string>(ACTIVE_SESSION_STORAGE_KEY, 'default')
|
||||
const sessionMessages = useLocalStorage<Record<string, ChatHistoryItem[]>>(CHAT_STORAGE_KEY, {})
|
||||
const sessionGenerations = ref<Record<string, number>>({})
|
||||
|
||||
const dataStore = createChatDataStore({
|
||||
getActiveSessionId: () => activeSessionId.value,
|
||||
setActiveSessionId: sessionId => activeSessionId.value = sessionId,
|
||||
getSessions: () => sessionMessages.value,
|
||||
setSessions: sessions => sessionMessages.value = sessions,
|
||||
getGenerations: () => sessionGenerations.value,
|
||||
setGenerations: generations => sessionGenerations.value = generations,
|
||||
})
|
||||
|
||||
// I know this nu uh, better than loading all language on rehypeShiki
|
||||
const codeBlockSystemPrompt = '- For any programming code block, always specify the programming language that supported on @shikijs/rehype on the rendered markdown, eg. ```python ... ```\n'
|
||||
const mathSyntaxSystemPrompt = '- For any math equation, use LaTeX format, eg: $ x^3 $, always escape dollar sign outside math equation\n'
|
||||
|
||||
function generateInitialMessage() {
|
||||
const content = codeBlockSystemPrompt + mathSyntaxSystemPrompt + systemPrompt.value
|
||||
|
||||
return {
|
||||
role: 'system',
|
||||
content,
|
||||
} satisfies SystemMessage
|
||||
}
|
||||
|
||||
function ensureSession(sessionId: string) {
|
||||
dataStore.ensureSession(sessionId, generateInitialMessage)
|
||||
}
|
||||
|
||||
ensureSession(activeSessionId.value)
|
||||
|
||||
const messages = computed<ChatHistoryItem[]>({
|
||||
get: () => dataStore.getSessionMessages(activeSessionId.value, generateInitialMessage),
|
||||
set: value => dataStore.setSessionMessages(activeSessionId.value, value),
|
||||
})
|
||||
|
||||
function setActiveSession(sessionId: string) {
|
||||
dataStore.setActiveSession(sessionId, generateInitialMessage)
|
||||
}
|
||||
|
||||
function cleanupMessages(sessionId = activeSessionId.value) {
|
||||
dataStore.resetSession(sessionId, generateInitialMessage)
|
||||
}
|
||||
|
||||
function getAllSessions() {
|
||||
return dataStore.getAllSessions()
|
||||
}
|
||||
|
||||
function replaceSessions(sessions: Record<string, ChatHistoryItem[]>) {
|
||||
dataStore.replaceSessions(sessions, generateInitialMessage)
|
||||
}
|
||||
|
||||
function resetAllSessions() {
|
||||
dataStore.resetAllSessions(generateInitialMessage)
|
||||
}
|
||||
|
||||
watch(systemPrompt, () => {
|
||||
dataStore.refreshSystemMessages(generateInitialMessage)
|
||||
}, { immediate: true })
|
||||
|
||||
return {
|
||||
activeSessionId,
|
||||
messages,
|
||||
|
||||
setActiveSession,
|
||||
cleanupMessages,
|
||||
getAllSessions,
|
||||
replaceSessions,
|
||||
resetAllSessions,
|
||||
|
||||
ensureSession,
|
||||
getSessionMessages: (sessionId: string) => dataStore.getSessionMessages(sessionId, generateInitialMessage),
|
||||
getSessionGeneration: (sessionId: string) => dataStore.getSessionGeneration(sessionId),
|
||||
bumpSessionGeneration: (sessionId: string) => dataStore.bumpSessionGeneration(sessionId),
|
||||
getSessionGenerationValue: (sessionId?: string) => dataStore.getSessionGenerationValue(sessionId),
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './session-store'
|
||||
export * from './stream-store'
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { StreamingAssistantMessage } from '../../types/chat'
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useChatSessionStore } from './session-store'
|
||||
|
||||
export const useChatStreamStore = defineStore('chat-stream', () => {
|
||||
const chatSession = useChatSessionStore()
|
||||
const streamingMessage = ref<StreamingAssistantMessage>({ role: 'assistant', content: '', slices: [], tool_results: [], createdAt: Date.now() })
|
||||
|
||||
function beginStream() {
|
||||
streamingMessage.value = { role: 'assistant', content: '', slices: [], tool_results: [], createdAt: Date.now() }
|
||||
}
|
||||
|
||||
function appendStreamLiteral(literal: string) {
|
||||
streamingMessage.value.content += literal
|
||||
|
||||
const lastSlice = streamingMessage.value.slices.at(-1)
|
||||
if (lastSlice?.type === 'text') {
|
||||
lastSlice.text += literal
|
||||
return
|
||||
}
|
||||
|
||||
streamingMessage.value.slices.push({
|
||||
type: 'text',
|
||||
text: literal,
|
||||
})
|
||||
}
|
||||
|
||||
function finalizeStream(fullText?: string) {
|
||||
const sessionId = chatSession.activeSessionId
|
||||
const sessionMessagesForSend = chatSession.getSessionMessages(sessionId)
|
||||
if (streamingMessage.value.slices.length > 0)
|
||||
sessionMessagesForSend.push(streamingMessage.value)
|
||||
streamingMessage.value = { role: 'assistant', content: '', slices: [], tool_results: [] }
|
||||
if (fullText)
|
||||
streamingMessage.value.content = fullText
|
||||
}
|
||||
|
||||
function resetStream() {
|
||||
streamingMessage.value = { role: 'assistant', content: '', slices: [], tool_results: [] }
|
||||
}
|
||||
|
||||
return {
|
||||
streamingMessage,
|
||||
beginStream,
|
||||
appendStreamLiteral,
|
||||
finalizeStream,
|
||||
resetStream,
|
||||
}
|
||||
})
|
||||
@@ -7,7 +7,7 @@ import { defaultPerfTracer, exportCsv as exportCsvFile } from '@proj-airi/stage-
|
||||
import { defineStore, storeToRefs } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useChatStore } from './chat'
|
||||
import { useChatOrchestratorStore } from './chat'
|
||||
import { useLLM } from './llm'
|
||||
import { useConsciousnessStore } from './modules/consciousness'
|
||||
import { usePerfTracerBridgeStore } from './perf-tracer-bridge'
|
||||
@@ -292,7 +292,7 @@ export const useMarkdownStressStore = defineStore('markdownStress', () => {
|
||||
}
|
||||
|
||||
async function runOnlineScenario() {
|
||||
const chatStore = useChatStore()
|
||||
const chatStore = useChatOrchestratorStore()
|
||||
const targetScenario = ensureScenario()
|
||||
|
||||
const provider = await providersStore.getProviderInstance(activeProvider.value) as ChatProvider | undefined
|
||||
@@ -309,7 +309,7 @@ export const useMarkdownStressStore = defineStore('markdownStress', () => {
|
||||
const delay = Math.max(0, runStart + message.atMs - performance.now())
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
await chatStore.send(message.text, {
|
||||
await chatStore.ingest(message.text, {
|
||||
model: activeModel.value!,
|
||||
chatProvider: provider,
|
||||
})
|
||||
@@ -323,7 +323,7 @@ export const useMarkdownStressStore = defineStore('markdownStress', () => {
|
||||
}
|
||||
|
||||
async function runMockScenario() {
|
||||
const chatStore = useChatStore()
|
||||
const chatStore = useChatOrchestratorStore()
|
||||
const llm = useLLM()
|
||||
const targetScenario = ensureScenario()
|
||||
const modelToUse = mockModelId
|
||||
@@ -365,7 +365,7 @@ export const useMarkdownStressStore = defineStore('markdownStress', () => {
|
||||
const delay = Math.max(0, runStart + message.atMs - performance.now())
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
await chatStore.send(message.text, {
|
||||
await chatStore.ingest(message.text, {
|
||||
model: modelToUse,
|
||||
chatProvider: mockProvider,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { EventEnvelope } from './events'
|
||||
|
||||
export type GatewayEvent = EventEnvelope
|
||||
|
||||
export interface GatewayChannel {
|
||||
name: string
|
||||
in?: ReadableStream<GatewayEvent>
|
||||
out?: (event: GatewayEvent) => void
|
||||
canHandle?: (event: GatewayEvent) => boolean
|
||||
}
|
||||
|
||||
export interface GatewayRoute {
|
||||
match: (event: GatewayEvent) => boolean
|
||||
to: string[]
|
||||
mode?: 'fan-out' | 'first' | 'all'
|
||||
}
|
||||
|
||||
export interface DispatchOptions {
|
||||
origin?: string
|
||||
}
|
||||
|
||||
export interface ChannelGateway {
|
||||
register: (channel: GatewayChannel) => void
|
||||
unregister: (name: string) => void
|
||||
dispatch: (event: GatewayEvent, options?: DispatchOptions) => void
|
||||
route: (rule: GatewayRoute) => void
|
||||
clearRoutes: () => void
|
||||
}
|
||||
|
||||
export function createChannelGateway(): ChannelGateway {
|
||||
const channels = new Map<string, GatewayChannel>()
|
||||
const routes: GatewayRoute[] = []
|
||||
const readers = new Map<string, ReadableStreamDefaultReader<GatewayEvent>>()
|
||||
|
||||
function dispatch(event: GatewayEvent, options?: DispatchOptions) {
|
||||
const matchedRoutes = routes.filter(rule => rule.match(event))
|
||||
|
||||
if (matchedRoutes.length > 0) {
|
||||
for (const rule of matchedRoutes) {
|
||||
const targets = rule.to
|
||||
.map(name => channels.get(name))
|
||||
.filter((channel): channel is GatewayChannel => !!channel)
|
||||
|
||||
if (rule.mode === 'first') {
|
||||
const target = targets.find(channel => channel.out)
|
||||
if (target && target.name !== options?.origin)
|
||||
target.out?.(event)
|
||||
continue
|
||||
}
|
||||
|
||||
for (const target of targets) {
|
||||
if (!target.out)
|
||||
continue
|
||||
if (target.name === options?.origin)
|
||||
continue
|
||||
target.out(event)
|
||||
if (rule.mode === 'all')
|
||||
continue
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for (const channel of channels.values()) {
|
||||
if (channel.name === options?.origin)
|
||||
continue
|
||||
if (channel.canHandle && !channel.canHandle(event))
|
||||
continue
|
||||
channel.out?.(event)
|
||||
}
|
||||
}
|
||||
|
||||
function register(channel: GatewayChannel) {
|
||||
channels.set(channel.name, channel)
|
||||
|
||||
if (!channel.in)
|
||||
return
|
||||
|
||||
const reader = channel.in.getReader()
|
||||
readers.set(channel.name, reader)
|
||||
|
||||
const pump = async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const result = await reader.read()
|
||||
if (result.done)
|
||||
break
|
||||
dispatch(result.value, { origin: channel.name })
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.warn('Channel gateway stream error:', channel.name, error)
|
||||
}
|
||||
}
|
||||
|
||||
void pump()
|
||||
}
|
||||
|
||||
function unregister(name: string) {
|
||||
channels.delete(name)
|
||||
const reader = readers.get(name)
|
||||
if (reader) {
|
||||
reader.cancel().catch(() => undefined)
|
||||
readers.delete(name)
|
||||
}
|
||||
}
|
||||
|
||||
function route(rule: GatewayRoute) {
|
||||
routes.push(rule)
|
||||
}
|
||||
|
||||
function clearRoutes() {
|
||||
routes.length = 0
|
||||
}
|
||||
|
||||
return {
|
||||
register,
|
||||
unregister,
|
||||
dispatch,
|
||||
route,
|
||||
clearRoutes,
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,11 @@ import { nanoid } from 'nanoid'
|
||||
import { defineStore, storeToRefs } from 'pinia'
|
||||
import { ref, toRaw, watch } from 'vue'
|
||||
|
||||
import { CHAT_STREAM_CHANNEL_NAME, CONTEXT_CHANNEL_NAME, useChatStore } from '../../chat'
|
||||
import { useChatOrchestratorStore } from '../../chat'
|
||||
import { CHAT_STREAM_CHANNEL_NAME, CONTEXT_CHANNEL_NAME } from '../../chat/constants'
|
||||
import { useChatContextStore } from '../../chat/context-store'
|
||||
import { useChatSessionStore } from '../../chat/session-store'
|
||||
import { useChatStreamStore } from '../../chat/stream-store'
|
||||
import { useConsciousnessStore } from '../../modules/consciousness'
|
||||
import { useProvidersStore } from '../../providers'
|
||||
import { useModsServerChannelStore } from './channel-server'
|
||||
@@ -18,7 +22,10 @@ import { useModsServerChannelStore } from './channel-server'
|
||||
export const useContextBridgeStore = defineStore('mods:api:context-bridge', () => {
|
||||
const mutex = new Mutex()
|
||||
|
||||
const chatStore = useChatStore()
|
||||
const chatOrchestrator = useChatOrchestratorStore()
|
||||
const chatSession = useChatSessionStore()
|
||||
const chatStream = useChatStreamStore()
|
||||
const chatContext = useChatContextStore()
|
||||
const serverChannelStore = useModsServerChannelStore()
|
||||
const consciousnessStore = useConsciousnessStore()
|
||||
const providersStore = useProvidersStore()
|
||||
@@ -38,7 +45,7 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
|
||||
|
||||
const { stop } = watch(incomingContext, (event) => {
|
||||
if (event)
|
||||
chatStore.ingestContextMessage(event)
|
||||
chatContext.ingestContextMessage(event)
|
||||
})
|
||||
disposeHookFns.value.push(stop)
|
||||
|
||||
@@ -48,7 +55,7 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
|
||||
metadata: event.metadata,
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
chatStore.ingestContextMessage(contextMessage)
|
||||
chatContext.ingestContextMessage(contextMessage)
|
||||
broadcastContext(toRaw(contextMessage))
|
||||
}))
|
||||
|
||||
@@ -73,7 +80,7 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
|
||||
if (normalizedContextUpdates?.length) {
|
||||
const createdAt = Date.now()
|
||||
for (const update of normalizedContextUpdates) {
|
||||
chatStore.ingestContextMessage({
|
||||
chatContext.ingestContextMessage({
|
||||
...update,
|
||||
metadata: event.metadata,
|
||||
createdAt,
|
||||
@@ -88,7 +95,7 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
|
||||
if (overrides?.messagePrefix)
|
||||
messageText = `${overrides.messagePrefix}${text}`
|
||||
|
||||
await chatStore.send(messageText, {
|
||||
await chatOrchestrator.ingest(messageText, {
|
||||
model: activeModel.value,
|
||||
chatProvider,
|
||||
input: {
|
||||
@@ -105,56 +112,56 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
|
||||
}))
|
||||
|
||||
disposeHookFns.value.push(
|
||||
chatStore.onBeforeMessageComposed(async (message, context) => {
|
||||
chatOrchestrator.onBeforeMessageComposed(async (message, context) => {
|
||||
if (isProcessingRemoteStream)
|
||||
return
|
||||
|
||||
broadcastStreamEvent({ type: 'before-compose', message, sessionId: chatStore.activeSessionId, context: structuredClone(toRaw(context)) })
|
||||
broadcastStreamEvent({ type: 'before-compose', message, sessionId: chatSession.activeSessionId, context: structuredClone(toRaw(context)) })
|
||||
}),
|
||||
chatStore.onAfterMessageComposed(async (message, context) => {
|
||||
chatOrchestrator.onAfterMessageComposed(async (message, context) => {
|
||||
if (isProcessingRemoteStream)
|
||||
return
|
||||
|
||||
broadcastStreamEvent({ type: 'after-compose', message, sessionId: chatStore.activeSessionId, context: structuredClone(toRaw(context)) })
|
||||
broadcastStreamEvent({ type: 'after-compose', message, sessionId: chatSession.activeSessionId, context: structuredClone(toRaw(context)) })
|
||||
}),
|
||||
chatStore.onBeforeSend(async (message, context) => {
|
||||
chatOrchestrator.onBeforeSend(async (message, context) => {
|
||||
if (isProcessingRemoteStream)
|
||||
return
|
||||
|
||||
broadcastStreamEvent({ type: 'before-send', message, sessionId: chatStore.activeSessionId, context: structuredClone(toRaw(context)) })
|
||||
broadcastStreamEvent({ type: 'before-send', message, sessionId: chatSession.activeSessionId, context: structuredClone(toRaw(context)) })
|
||||
}),
|
||||
chatStore.onAfterSend(async (message, context) => {
|
||||
chatOrchestrator.onAfterSend(async (message, context) => {
|
||||
if (isProcessingRemoteStream)
|
||||
return
|
||||
|
||||
broadcastStreamEvent({ type: 'after-send', message, sessionId: chatStore.activeSessionId, context: structuredClone(toRaw(context)) })
|
||||
broadcastStreamEvent({ type: 'after-send', message, sessionId: chatSession.activeSessionId, context: structuredClone(toRaw(context)) })
|
||||
}),
|
||||
chatStore.onTokenLiteral(async (literal, context) => {
|
||||
chatOrchestrator.onTokenLiteral(async (literal, context) => {
|
||||
if (isProcessingRemoteStream)
|
||||
return
|
||||
|
||||
broadcastStreamEvent({ type: 'token-literal', literal, sessionId: chatStore.activeSessionId, context: structuredClone(toRaw(context)) })
|
||||
broadcastStreamEvent({ type: 'token-literal', literal, sessionId: chatSession.activeSessionId, context: structuredClone(toRaw(context)) })
|
||||
}),
|
||||
chatStore.onTokenSpecial(async (special, context) => {
|
||||
chatOrchestrator.onTokenSpecial(async (special, context) => {
|
||||
if (isProcessingRemoteStream)
|
||||
return
|
||||
|
||||
broadcastStreamEvent({ type: 'token-special', special, sessionId: chatStore.activeSessionId, context: structuredClone(toRaw(context)) })
|
||||
broadcastStreamEvent({ type: 'token-special', special, sessionId: chatSession.activeSessionId, context: structuredClone(toRaw(context)) })
|
||||
}),
|
||||
chatStore.onStreamEnd(async (context) => {
|
||||
chatOrchestrator.onStreamEnd(async (context) => {
|
||||
if (isProcessingRemoteStream)
|
||||
return
|
||||
|
||||
broadcastStreamEvent({ type: 'stream-end', sessionId: chatStore.activeSessionId, context: structuredClone(toRaw(context)) })
|
||||
broadcastStreamEvent({ type: 'stream-end', sessionId: chatSession.activeSessionId, context: structuredClone(toRaw(context)) })
|
||||
}),
|
||||
chatStore.onAssistantResponseEnd(async (message, context) => {
|
||||
chatOrchestrator.onAssistantResponseEnd(async (message, context) => {
|
||||
if (isProcessingRemoteStream)
|
||||
return
|
||||
|
||||
broadcastStreamEvent({ type: 'assistant-end', message, sessionId: chatStore.activeSessionId, context: structuredClone(toRaw(context)) })
|
||||
broadcastStreamEvent({ type: 'assistant-end', message, sessionId: chatSession.activeSessionId, context: structuredClone(toRaw(context)) })
|
||||
}),
|
||||
|
||||
chatStore.onAssistantMessage(async (message, _messageText, context) => {
|
||||
chatOrchestrator.onAssistantMessage(async (message, _messageText, context) => {
|
||||
serverChannelStore.send({
|
||||
type: 'output:gen-ai:chat:message',
|
||||
data: {
|
||||
@@ -172,7 +179,7 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
|
||||
})
|
||||
}),
|
||||
|
||||
chatStore.onChatTurnComplete(async (chat, context) => {
|
||||
chatOrchestrator.onChatTurnComplete(async (chat, context) => {
|
||||
serverChannelStore.send({
|
||||
type: 'output:gen-ai:chat:complete',
|
||||
data: {
|
||||
@@ -209,58 +216,58 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
|
||||
// Use the receiver's active session to avoid clobbering chat state when events come from other windows/devtools.
|
||||
switch (event.type) {
|
||||
case 'before-compose':
|
||||
await chatStore.emitBeforeMessageComposedHooks(event.message, event.context)
|
||||
await chatOrchestrator.emitBeforeMessageComposedHooks(event.message, event.context)
|
||||
break
|
||||
case 'after-compose':
|
||||
await chatStore.emitAfterMessageComposedHooks(event.message, event.context)
|
||||
await chatOrchestrator.emitAfterMessageComposedHooks(event.message, event.context)
|
||||
break
|
||||
case 'before-send':
|
||||
await chatStore.emitBeforeSendHooks(event.message, event.context)
|
||||
await chatOrchestrator.emitBeforeSendHooks(event.message, event.context)
|
||||
remoteStreamGuard = {
|
||||
sessionId: chatStore.activeSessionId,
|
||||
generation: chatStore.getSessionGenerationValue(),
|
||||
sessionId: chatSession.activeSessionId,
|
||||
generation: chatSession.getSessionGenerationValue(),
|
||||
}
|
||||
chatStore.sending = true
|
||||
chatStore.beginRemoteStream()
|
||||
chatOrchestrator.sending = true
|
||||
chatStream.beginStream()
|
||||
break
|
||||
case 'after-send':
|
||||
await chatStore.emitAfterSendHooks(event.message, event.context)
|
||||
await chatOrchestrator.emitAfterSendHooks(event.message, event.context)
|
||||
break
|
||||
case 'token-literal':
|
||||
if (!remoteStreamGuard)
|
||||
return
|
||||
if (remoteStreamGuard.sessionId !== chatStore.activeSessionId)
|
||||
if (remoteStreamGuard.sessionId !== chatSession.activeSessionId)
|
||||
return
|
||||
if (chatStore.getSessionGenerationValue(remoteStreamGuard.sessionId) !== remoteStreamGuard.generation)
|
||||
if (chatSession.getSessionGenerationValue(remoteStreamGuard.sessionId) !== remoteStreamGuard.generation)
|
||||
return
|
||||
chatStore.appendRemoteLiteral(event.literal)
|
||||
await chatStore.emitTokenLiteralHooks(event.literal, event.context)
|
||||
chatStream.appendStreamLiteral(event.literal)
|
||||
await chatOrchestrator.emitTokenLiteralHooks(event.literal, event.context)
|
||||
break
|
||||
case 'token-special':
|
||||
await chatStore.emitTokenSpecialHooks(event.special, event.context)
|
||||
await chatOrchestrator.emitTokenSpecialHooks(event.special, event.context)
|
||||
break
|
||||
case 'stream-end':
|
||||
if (!remoteStreamGuard)
|
||||
break
|
||||
if (remoteStreamGuard.sessionId !== chatStore.activeSessionId)
|
||||
if (remoteStreamGuard.sessionId !== chatSession.activeSessionId)
|
||||
break
|
||||
if (chatStore.getSessionGenerationValue(remoteStreamGuard.sessionId) !== remoteStreamGuard.generation)
|
||||
if (chatSession.getSessionGenerationValue(remoteStreamGuard.sessionId) !== remoteStreamGuard.generation)
|
||||
break
|
||||
await chatStore.emitStreamEndHooks(event.context)
|
||||
chatStore.finalizeRemoteStream()
|
||||
chatStore.sending = false
|
||||
await chatOrchestrator.emitStreamEndHooks(event.context)
|
||||
chatStream.finalizeStream()
|
||||
chatOrchestrator.sending = false
|
||||
remoteStreamGuard = null
|
||||
break
|
||||
case 'assistant-end':
|
||||
if (!remoteStreamGuard)
|
||||
break
|
||||
if (remoteStreamGuard.sessionId !== chatStore.activeSessionId)
|
||||
if (remoteStreamGuard.sessionId !== chatSession.activeSessionId)
|
||||
break
|
||||
if (chatStore.getSessionGenerationValue(remoteStreamGuard.sessionId) !== remoteStreamGuard.generation)
|
||||
if (chatSession.getSessionGenerationValue(remoteStreamGuard.sessionId) !== remoteStreamGuard.generation)
|
||||
break
|
||||
await chatStore.emitAssistantResponseEndHooks(event.message, event.context)
|
||||
chatStore.finalizeRemoteStream(event.message)
|
||||
chatStore.sending = false
|
||||
await chatOrchestrator.emitAssistantResponseEndHooks(event.message, event.context)
|
||||
chatStream.finalizeStream(event.message)
|
||||
chatOrchestrator.sending = false
|
||||
remoteStreamGuard = null
|
||||
break
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { nanoid } from 'nanoid'
|
||||
|
||||
export type EventPriority = 'critical' | 'high' | 'normal' | 'low'
|
||||
|
||||
export interface EventEnvelope<TType extends string = string, TPayload = unknown> {
|
||||
id: string
|
||||
type: TType
|
||||
time: number
|
||||
priority?: EventPriority
|
||||
source?: string
|
||||
tags?: string[]
|
||||
payload: TPayload
|
||||
}
|
||||
|
||||
export interface EventStream<T> {
|
||||
stream: ReadableStream<T>
|
||||
emit: (event: T) => void
|
||||
close: () => void
|
||||
}
|
||||
|
||||
export function createEvent<TPayload>(type: string, payload: TPayload, options?: { priority?: EventPriority, source?: string, tags?: string[], id?: string, time?: number }): EventEnvelope<string, TPayload> {
|
||||
return {
|
||||
id: options?.id ?? nanoid(),
|
||||
type,
|
||||
time: options?.time ?? Date.now(),
|
||||
priority: options?.priority,
|
||||
source: options?.source,
|
||||
tags: options?.tags,
|
||||
payload,
|
||||
}
|
||||
}
|
||||
|
||||
export function createEventStream<T>(): EventStream<T> {
|
||||
let controller: ReadableStreamDefaultController<T> | undefined
|
||||
const stream = new ReadableStream<T>({
|
||||
start(ctrl) {
|
||||
controller = ctrl
|
||||
},
|
||||
cancel() {
|
||||
controller = undefined
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
stream,
|
||||
emit(event) {
|
||||
controller?.enqueue(event)
|
||||
},
|
||||
close() {
|
||||
controller?.close()
|
||||
controller = undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user