fix(stage-ui): resolve critical state and initialization issues (#1614)

This commit is contained in:
Iro
2026-04-11 01:02:16 +08:00
committed by GitHub
parent aa7428ace2
commit adcfcd9399
7 changed files with 125 additions and 48 deletions
@@ -38,6 +38,8 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
maxAttempts: 3,
})
let tickTimer: ReturnType<typeof setInterval> | undefined
let initialized = false
const eventUnsubscribes: Array<() => void> = []
const sparkNotifyAgent = setupAgentSparkNotifyHandler({
stream,
getActiveProvider: () => activeProvider.value,
@@ -76,17 +78,17 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
function enqueueSparkNotify(event: WebSocketEventOf<'spark:notify'>, options?: { reason?: string, nextRunAt?: number, maxAttempts?: number }) {
if (!pendingNotifies.value.some(item => item.data.id === event.data.id)) {
pendingNotifies.value = [...pendingNotifies.value, event]
pendingNotifies.value.push(event)
}
scheduledNotifies.value = [...scheduledNotifies.value, {
scheduledNotifies.value.push({
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: WebSocketEventOf<'spark:notify'>) {
@@ -198,27 +200,47 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
}
function initialize() {
modsServerChannelStore.onEvent('spark:notify', async (event) => {
try {
await handleIncomingSparkNotify(event)
}
catch (error) {
console.warn('Failed to handle spark:notify event:', error)
}
})
if (initialized)
return
modsServerChannelStore.onEvent('spark:emit', async (event) => {
try {
await handleSparkEmit(event)
}
catch (error) {
console.warn('Failed to handle spark:emit event:', error)
}
})
initialized = true
eventUnsubscribes.push(
modsServerChannelStore.onEvent('spark:notify', async (event) => {
try {
await handleIncomingSparkNotify(event)
}
catch (error) {
console.warn('Failed to handle spark:notify event:', error)
}
}),
)
eventUnsubscribes.push(
modsServerChannelStore.onEvent('spark:emit', async (event) => {
try {
await handleSparkEmit(event)
}
catch (error) {
console.warn('Failed to handle spark:emit event:', error)
}
}),
)
startTicker()
}
function dispose() {
stopTicker()
for (const unsubscribe of eventUnsubscribes) {
unsubscribe()
}
eventUnsubscribes.length = 0
initialized = false
}
return {
processing,
pendingNotifies,
@@ -228,6 +250,7 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
initialize,
startTicker,
stopTicker,
dispose,
handleSparkNotify: handleIncomingSparkNotify,
handleSparkEmit,
+13 -7
View File
@@ -8,7 +8,7 @@ import type { StreamEvent, StreamOptions } from './llm'
import { createQueue } from '@proj-airi/stream-kit'
import { nanoid } from 'nanoid'
import { defineStore, storeToRefs } from 'pinia'
import { ref, toRaw } from 'vue'
import { computed, ref, toRaw } from 'vue'
import { useAnalytics } from '../composables'
import { useLlmmarkerParser } from '../composables/llm-marker-parser'
@@ -23,6 +23,15 @@ import { useContextObservabilityStore } from './devtools/context-observability'
import { useLLM } from './llm'
import { useConsciousnessStore } from './modules/consciousness'
function cloneStreamingMessage(message: StreamingAssistantMessage): StreamingAssistantMessage {
try {
return structuredClone(message)
}
catch {
return JSON.parse(JSON.stringify(message)) as StreamingAssistantMessage
}
}
interface SendOptions {
model: string
chatProvider: ChatProvider
@@ -75,7 +84,7 @@ export const useChatOrchestratorStore = defineStore('chat-orchestrator', () => {
const sending = ref(false)
const pendingQueuedSends = ref<QueuedSend[]>([])
const pendingQueuedSendCount = ref(0)
const pendingQueuedSendCount = computed(() => pendingQueuedSends.value.length)
const hooks = createChatHooks()
const sendQueue = createQueue<QueuedSend>({
@@ -103,13 +112,11 @@ export const useChatOrchestratorStore = defineStore('chat-orchestrator', () => {
})
sendQueue.on('enqueue', (queuedSend) => {
pendingQueuedSends.value = [...pendingQueuedSends.value, queuedSend]
pendingQueuedSendCount.value = pendingQueuedSends.value.length
pendingQueuedSends.value.push(queuedSend)
})
sendQueue.on('dequeue', (queuedSend) => {
pendingQueuedSends.value = pendingQueuedSends.value.filter(item => item !== queuedSend)
pendingQueuedSendCount.value = pendingQueuedSends.value.length
})
async function performSend(
@@ -162,7 +169,7 @@ export const useChatOrchestratorStore = defineStore('chat-orchestrator', () => {
const updateUI = () => {
if (isForegroundSession()) {
streamingMessage.value = JSON.parse(JSON.stringify(buildingMessage))
streamingMessage.value = cloneStreamingMessage(buildingMessage)
}
}
@@ -476,7 +483,6 @@ export const useChatOrchestratorStore = defineStore('chat-orchestrator', () => {
pendingQueuedSends.value = sessionId
? pendingQueuedSends.value.filter(item => item.sessionId !== sessionId)
: []
pendingQueuedSendCount.value = pendingQueuedSends.value.length
}
function getPendingQueuedSendSnapshot() {
@@ -28,6 +28,15 @@ export interface ChatDataStore {
}
export function createChatDataStore(access: ChatDataAccess): ChatDataStore {
function cloneDeep<T>(value: T): T {
try {
return structuredClone(value)
}
catch {
return JSON.parse(JSON.stringify(value)) as T
}
}
function ensureGeneration(sessionId: string) {
const generations = access.getGenerations()
if (generations[sessionId] === undefined)
@@ -118,7 +127,7 @@ export function createChatDataStore(access: ChatDataAccess): ChatDataStore {
}
function getAllSessions() {
return JSON.parse(JSON.stringify(access.getSessions())) as Record<string, ChatHistoryItem[]>
return cloneDeep(access.getSessions())
}
function getSessionGenerationValue(sessionId?: string) {
@@ -46,8 +46,17 @@ export const useChatSessionStore = defineStore('chat-session', () => {
return persistQueue
}
function cloneDeep<T>(value: T): T {
try {
return structuredClone(value)
}
catch {
return JSON.parse(JSON.stringify(value)) as T
}
}
function snapshotMessages(messages: ChatHistoryItem[]) {
return JSON.parse(JSON.stringify(messages)) as ChatHistoryItem[]
return cloneDeep(messages)
}
function ensureSessionMessageIds(sessionId: string) {
@@ -106,7 +115,7 @@ export const useChatSessionStore = defineStore('chat-session', () => {
async function persistIndex() {
if (!index.value)
return
const snapshot = JSON.parse(JSON.stringify(index.value)) as ChatSessionsIndex
const snapshot = cloneDeep(index.value)
await enqueuePersist(() => chatSessionsRepo.saveIndex(snapshot))
}
@@ -136,7 +145,7 @@ export const useChatSessionStore = defineStore('chat-session', () => {
await chatSessionsRepo.saveSession(sessionId, record)
if (index.value) {
const snapshot = JSON.parse(JSON.stringify(index.value)) as ChatSessionsIndex
const snapshot = cloneDeep(index.value)
await chatSessionsRepo.saveIndex(snapshot)
}
})
@@ -208,7 +217,7 @@ export const useChatSessionStore = defineStore('chat-session', () => {
updatedAt: now,
}
const initialMessages = options?.messages?.length ? options.messages : [generateInitialMessage()]
const initialMessages = options?.messages?.length ? cloneDeep(options.messages) : [generateInitialMessage()]
sessionMetas.value[sessionId] = meta
replaceSessionMessages(sessionId, initialMessages, { persist: false })
@@ -302,7 +311,6 @@ export const useChatSessionStore = defineStore('chat-session', () => {
if (!loadedSessions.has(activeSessionId.value) && !sessionMessages.value[activeSessionId.value] && hasKnownSession(activeSessionId.value)) {
return []
}
ensureSession(activeSessionId.value)
return sessionMessages.value[activeSessionId.value] ?? []
},
set: (value) => {
@@ -334,10 +342,14 @@ export const useChatSessionStore = defineStore('chat-session', () => {
activeSessionId: string
sessionMessages: Record<string, ChatHistoryItem[]>
sessionMetas: Record<string, ChatSessionMeta>
index?: ChatSessionsIndex | null
}) {
activeSessionId.value = snapshot.activeSessionId
sessionMessages.value = snapshot.sessionMessages
sessionMetas.value = snapshot.sessionMetas
sessionMessages.value = cloneDeep(snapshot.sessionMessages)
sessionMetas.value = cloneDeep(snapshot.sessionMetas)
if (snapshot.index !== undefined) {
index.value = cloneDeep(snapshot.index)
}
sessionGenerations.value = Object.fromEntries(
Object.keys(snapshot.sessionMessages).map(sessionId => [sessionId, sessionGenerations.value[sessionId] ?? 0]),
)
@@ -350,8 +362,9 @@ export const useChatSessionStore = defineStore('chat-session', () => {
function getSnapshot() {
return {
activeSessionId: activeSessionId.value,
sessionMessages: JSON.parse(JSON.stringify(sessionMessages.value)) as Record<string, ChatHistoryItem[]>,
sessionMetas: JSON.parse(JSON.stringify(sessionMetas.value)) as Record<string, ChatSessionMeta>,
sessionMessages: cloneDeep(sessionMessages.value),
sessionMetas: cloneDeep(sessionMetas.value),
index: cloneDeep(index.value),
}
}
@@ -362,7 +375,7 @@ export const useChatSessionStore = defineStore('chat-session', () => {
}
function getAllSessions() {
return JSON.parse(JSON.stringify(sessionMessages.value)) as Record<string, ChatHistoryItem[]>
return cloneDeep(sessionMessages.value)
}
async function resetAllSessions() {
@@ -453,8 +466,8 @@ export const useChatSessionStore = defineStore('chat-session', () => {
return {
format: 'chat-sessions-index:v1',
index: index.value,
sessions,
index: cloneDeep(index.value),
sessions: cloneDeep(sessions),
}
}
@@ -462,20 +475,23 @@ export const useChatSessionStore = defineStore('chat-session', () => {
if (payload.format !== 'chat-sessions-index:v1')
return
index.value = payload.index
index.value = cloneDeep(payload.index)
sessionMessages.value = {}
sessionMetas.value = {}
sessionGenerations.value = {}
loadedSessions.clear()
loadingSessions.clear()
await enqueuePersist(() => chatSessionsRepo.saveIndex(payload.index))
await enqueuePersist(() => chatSessionsRepo.saveIndex(cloneDeep(payload.index)))
for (const [sessionId, record] of Object.entries(payload.sessions)) {
sessionMetas.value[sessionId] = record.meta
sessionMessages.value[sessionId] = record.messages
sessionMetas.value[sessionId] = cloneDeep(record.meta)
sessionMessages.value[sessionId] = cloneDeep(record.messages)
ensureGeneration(sessionId)
await enqueuePersist(() => chatSessionsRepo.saveSession(sessionId, record))
await enqueuePersist(() => chatSessionsRepo.saveSession(sessionId, {
meta: cloneDeep(record.meta),
messages: cloneDeep(record.messages),
}))
}
await ensureActiveSessionForCharacter()
@@ -1,7 +1,7 @@
import type { StreamingAssistantMessage } from '../../types/chat'
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ref, toRaw } from 'vue'
import { useChatSessionStore } from './session-store'
@@ -31,7 +31,7 @@ export const useChatStreamStore = defineStore('chat-stream', () => {
function finalizeStream(fullText?: string) {
const sessionId = chatSession.activeSessionId
if (streamingMessage.value.slices.length > 0)
chatSession.appendSessionMessage(sessionId, streamingMessage.value)
chatSession.appendSessionMessage(sessionId, toRaw(streamingMessage.value))
streamingMessage.value = { role: 'assistant', content: '', slices: [], tool_results: [] }
if (fullText)
streamingMessage.value.content = fullText
@@ -55,7 +55,12 @@ function truncateText(value: string, limit = 220) {
}
function cloneValue<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T
try {
return structuredClone(value)
}
catch {
return JSON.parse(JSON.stringify(value)) as T
}
}
export const useContextObservabilityStore = defineStore('devtools:context-observability', () => {
@@ -58,11 +58,15 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
const disposeHookFns = ref<Array<() => void>>([])
let remoteStreamGuard: { sessionId: string, generation: number } | null = null
let initialized = false
async function initialize() {
await mutex.acquire()
try {
if (initialized)
return
const registerConsumers = () => {
for (const consumerEvent of consumerRegistrationEvents) {
serverChannelStore.send({
@@ -173,6 +177,13 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
})
}))
function withContextBridgeLock<T>(key: string, callback: () => Promise<T>) {
if (typeof navigator !== 'undefined' && 'locks' in navigator && typeof navigator.locks.request === 'function') {
return navigator.locks.request(key, callback)
}
return callback()
}
disposeHookFns.value.push(serverChannelStore.onEvent('input:text', async (event) => {
const {
text,
@@ -275,7 +286,7 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
// - https://chromestatus.com/feature/6265472244514816
// - https://developer.mozilla.org/en-US/docs/Web/API/SharedWorker
// - https://developer.mozilla.org/en-US/docs/Web/API/Web_Locks_API
navigator.locks.request('context-bridge:event:input:text', async () => {
await withContextBridgeLock('context-bridge:event:input:text', async () => {
try {
await chatOrchestrator.ingest(messageText, {
model: activeModel.value,
@@ -472,6 +483,7 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
}
})
disposeHookFns.value.push(stopIncomingStreamWatch)
initialized = true
}
finally {
mutex.release()
@@ -482,6 +494,9 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
await mutex.acquire()
try {
if (!initialized)
return
for (const consumerEvent of consumerRegistrationEvents) {
serverChannelStore.send({
type: 'module:consumer:unregister',
@@ -496,6 +511,9 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
for (const fn of disposeHookFns.value) {
fn()
}
initialized = false
remoteStreamGuard = null
}
finally {
mutex.release()