feat: add persistent conversation input queue

Messages and Scheduled Task occurrences previously could not share one ordered path while a Conversation was active. They now enter a durable FIFO queue with frozen execution settings and bounded attachments, recover safely after restart, and never write concurrently to the same timeline.

The compact queue above Composer supports removal and explicit interrupt-and-promote actions. SQLite schema v23 preserves pending schedule work, while Conversation Task children reuse the shared status-dot semantics for running, completed, failed, approval, paused, and cancelled states.

Release note: 回复生成期间仍可继续发送普通消息;消息与 Scheduled Task 会按 Conversation 顺序排队,并支持删除待发送项或立即中断后优先执行。
This commit is contained in:
mesalogo
2026-08-19 21:46:43 +08:00
parent 993c439228
commit 34033053be
28 changed files with 3441 additions and 362 deletions
+64 -1
View File
@@ -614,7 +614,10 @@ const desktopApi: DesktopApi = {
conversationId
) as Promise<boolean>,
onChanged: (listener) => {
const handler = (): void => listener()
const handler = (
_event: Electron.IpcRendererEvent,
conversationId?: string
): void => listener(conversationId)
ipcRenderer.on(ipcChannels.conversationsChanged, handler)
return () =>
ipcRenderer.removeListener(
@@ -623,6 +626,66 @@ const desktopApi: DesktopApi = {
)
}
},
conversationQueue: {
list: (conversationId?: string) =>
ipcRenderer.invoke(
ipcChannels.conversationQueueList,
conversationId
),
enqueueUser: (input) =>
ipcRenderer.invoke(
ipcChannels.conversationQueueEnqueueUser,
input
),
remove: async (itemId: string) => {
await ipcRenderer.invoke(
ipcChannels.conversationQueueRemove,
itemId
)
},
interruptAndRun: async (itemId: string) => {
await ipcRenderer.invoke(
ipcChannels.conversationQueueInterruptAndRun,
itemId
)
},
releaseUser: async (itemId: string) => {
await ipcRenderer.invoke(
ipcChannels.conversationQueueReleaseUser,
itemId
)
},
ready: async (conversationId: string) => {
await ipcRenderer.invoke(
ipcChannels.conversationQueueReady,
conversationId
)
},
onChanged: (listener) => {
const handler = (
_event: Electron.IpcRendererEvent,
conversationId?: string
): void => listener(conversationId)
ipcRenderer.on(ipcChannels.conversationQueueChanged, handler)
return () =>
ipcRenderer.removeListener(
ipcChannels.conversationQueueChanged,
handler
)
},
onDispatch: (listener) => {
const handler = (
_event: Electron.IpcRendererEvent,
dispatch: Parameters<typeof listener>[0]
): void => listener(dispatch)
ipcRenderer.on(ipcChannels.conversationQueueDispatch, handler)
return () =>
ipcRenderer.removeListener(
ipcChannels.conversationQueueDispatch,
handler
)
}
},
workspace: {
getChanges: (projectId: string) =>
ipcRenderer.invoke(
+14
View File
@@ -95,4 +95,18 @@ describe('sandboxed preload', () => {
expect(source).toContain('ipcChannels.knowledgeTaskCancel')
expect(source).toContain('ipcChannels.knowledgeTaskRetry')
})
it('exposes only explicit Conversation queue operations and listeners', () => {
const source = readFileSync(
join(process.cwd(), 'src', 'preload', 'index.ts'),
'utf8'
)
expect(source).toContain('conversationQueue: {')
expect(source).toContain('ipcChannels.conversationQueueEnqueueUser')
expect(source).toContain(
'ipcChannels.conversationQueueInterruptAndRun'
)
expect(source).toContain('ipcChannels.conversationQueueDispatch')
expect(source).toContain('listener(conversationId)')
})
})