Files
CherryHQ-cherry-studio/docs/references/ai/ipc-transport.md
fullex e161112a1a refactor(ai-transport): relocate renderer transport runtime into services/aiTransport
Move the renderer-side AI-streaming runtime (IpcChatTransport,
TopicStreamSubscription, streamDispatchCoordinator) out of the top-level
src/renderer/transport/ directory into the shared services/aiTransport/
bucket. By shape these are stateful runtime singletons/classes, and the
runtime is cross-surface (consumed by chat, quick-assistant, selection),
so per the renderer architecture it routes into services/, not its own
top-level directory.

- Add a curated index.ts barrel exposing only the externally consumed
  symbols (ipcChatTransport, TopicStreamSubscription, ExecutionTerminal);
  the class, dispatch coordinator and helpers stay private.
- Update the 6 consumer sites (5 imports + 1 vi.mock) to the barrel.
- Sync architecture and AI docs to the new path; drop the now-resolved
  transport/ deviation from the renderer-architecture pending table.
2026-06-27 09:27:21 -07:00

4.3 KiB

IPC Transport

What it is

IpcChatTransport (src/renderer/services/aiTransport/IpcChatTransport.ts) implements AI SDK's ChatTransport<CherryUIMessage> over Electron IPC. The renderer feeds it into useChat({ id: topicId, transport: ... }). The ChatTransport interface has only two methods — sendMessages / reconnectToStream; the transport relays each over window.api.ai.stream* to Main's AiStreamManager. cancel is not a transport method: it is the cancel callback of the ReadableStream that sendMessages returns (AI SDK invokes it on unmount/disposal), and abort is driven by the request's abortSignal.

useChat({ id: topicId, transport: new IpcChatTransport(defaultBody) })
   │  transport methods
   ├─ sendMessages         → window.api.ai.streamOpen   (Ai_Stream_Open)
   ├─ reconnectToStream    → window.api.ai.streamAttach (Ai_Stream_Attach)
   │  returned-stream / signal callbacks
   ├─ stream cancel()      → window.api.ai.streamDetach (Ai_Stream_Detach)
   └─ request abort signal → window.api.ai.streamAbort  (Ai_Stream_Abort)

Detach ≠ abort. cancel() (e.g. unmount/disposal) calls streamDetach: it drops this subscriber while Main keeps generating and persists the result. Stopping generation is a separate path — the request's abortSignal firing calls streamAbort. Conflating the two would resurrect the v1 "unmount → cancel → upstream abort → lost reply" bug class.

Per-topic chunks arrive via onStreamChunk listeners filtered by topicId.

Triggers

sendMessages distinguishes two triggers:

Trigger What it does
submit-message Includes userMessageParts (the latest message) so Main persists it
regenerate-message Sends parentAnchorId only; Main re-runs from the existing parent

Cherry's transport never derives continue-conversation from message-state introspection. Approval-driven resumption goes through the explicit Ai_ToolApproval_Respond IPC handled by useToolApprovalBridge.

Dispatch coordinator

streamDispatchCoordinator (src/renderer/services/aiTransport/streamDispatchCoordinator.ts) sits between the transport and the IPC call so the Ai_Stream_Open ack (userMessageId, placeholder ids, executionIds) is observable to callers that need to join optimistic UI bubbles, rather than being thrown away by AI SDK's transport interface.

It does not serialize sends — there is no single-in-flight guard in the coordinator. Concurrency for a topic is arbitrated on the Main side: a chat resubmit to a live topic is persisted and queued as a steer (AiStreamManager.enqueuePendingSteer) — the running turn yields and a continuation answers it — while an agent-session follow-up attaches to the running stream.

Per-execution demux

The chunk stream from Main is keyed by (topicId, executionId). TopicStreamSubscription (src/renderer/services/aiTransport/TopicStreamSubscription.ts) owns the topic-level streamAttach / streamDetach with ref-counted lifecycle and demuxes chunks into per-execution branch ReadableStreams, so multi-model parallel responses render as separate AI SDK messages on the same topic. useExecutionOverlay consumes each branch through readUIMessageStream — the same accumulator Main runs in pipeStreamLoop, so the renderer overlay and the persisted message are structurally identical.

See Execution Overlay for the merge-function symmetry, seed rule, cancellation layering, and lifecycle.

Topic-level subscription

useTopicStreamStatus(topicId) reads topic.stream.statuses.<topicId> from the shared cache. The cache is the cross-window source of truth for:

  • pending / streaming / awaiting-approval / done / error / aborted
  • broadcast-completion anchor ids

classifyTurn(status) decodes the status into the TurnStateFlags predicates the UI consumes (isStreamLive, isTurnActive, isAwaitingApproval, isTerminal).

Where to read more