docs/references/ai/core-architecture.md
End-to-end view of how a Cherry chat turn moves from user input to LLM response and back to UI, with pointers to the focused references for each subsystem.
┌──────────────────────────────────────────────────────────────────────┐
│ Renderer │
│ │
│ useChat({ id: topicId, transport: IpcChatTransport }) │
│ ├─ sendMessages → window.api.ai.streamOpen │
│ ├─ reconnectToStream → window.api.ai.streamAttach │
│ └─ abort signal → window.api.ai.streamAbort │
│ │
│ History: useQuery('/topics/:id/messages') → DataApi │
│ Topic-level state: useTopicStreamStatus → shared cache │
│ Approval bridge: useToolApprovalBridge → window.api.ai.toolApproval│
└──────────────────────────────────────────────────────────────────────┘
↕ IPC (keyed by topicId)
┌──────────────────────────────────────────────────────────────────────┐
│ Main │
│ │
│ AiStreamManager (lifecycle service) — registers in onInit: │
│ ├─ ipcHandle('Ai_Stream_Open', → dispatchStreamRequest) │
│ ├─ ipcHandle('Ai_Stream_Attach', → this.attach) │
│ ├─ ipcHandle('Ai_Stream_Detach', → this.detach) │
│ └─ ipcHandle('Ai_Stream_Abort', → this.abort) │
│ │
│ AiService (lifecycle service) — registers: │
│ ├─ ipcHandle('Ai_ToolApproval_Respond', <inline handler>) │
│ └─ ipcHandle('Ai_GenerateText' / 'Ai_Translate_Open' / …) │
│ │
│ dispatch (src/main/ai/streamManager/context/dispatch.ts) │
│ pick ChatContextProvider → prepareDispatch → manager.send(...) │
│ │
│ AiStreamManager │
│ activeStreams: Map<topicId, ActiveStream> │
│ listeners + executions │
│ runs N StreamExecution loops, fan-out per chunk to listeners │
│ │
│ runExecutionLoop (AiStreamManager) → AiService.streamText(req,signal)│
│ buildAgentParams: registry.selectActive + applyDeferExposition │
│ new Agent({tools, hookParts}) — composeHooks runs inside Agent │
│ → agent.stream(messages, signal) │
│ pipeStreamLoop tees: │
│ • broadcast → WebContents / SSE / channel-adapter / persistence │
│ • readUIMessageStream → CherryUIMessage snapshot │
│ │
│ Terminal listeners: │
│ PersistenceListener → MessageService / TemporaryChat / Translation
│ WebContentsListener → wc.send(Ai_StreamDone) │
│ ChannelAdapterListener → adapter.onStreamComplete │
│ SseListener → res.write('[DONE]') │
└──────────────────────────────────────────────────────────────────────┘
↓
@ai-sdk/* package
↓
LLM provider API
useChat.sendMessages calls IpcChatTransport.sendMessages.AiStreamOpenRequest, dispatches via
streamDispatchCoordinator over IPC Ai_Stream_Open.AiStreamManager's Ai_Stream_Open handler (registered in onInit)
wraps the sender in a WebContentsListener and calls
dispatchStreamRequest(manager, subscriber, request).dispatchStreamRequest picks the first ChatContextProvider whose
canHandle(topicId) matches and asks it to prepareDispatch.PersistenceListener per
execution, returns PreparedDispatch.dispatch reconciles any live stream, then calls manager.send(input):
dispatch calls manager.enqueuePendingSteer(topicId);
send() injects (just upserts the subscriber). The running turn yields
via steerYield (persisting as success) and onExecutionDone chains a
steer-continuation — steering is enqueue + yield + chain, not
abort-and-restart and not mid-turn injection.send()
injects — it upserts listeners onto the running stream, models
ignored (the message was already enqueued on the session's pendingTurns).send() starts — evict any grace-period stream,
create an ActiveStream, launch one StreamExecution per model.StreamExecution, AiStreamManager's private runExecutionLoop
calls AiService.streamText(request, signal), which builds params
(buildAgentParamsFor → buildAgentParams: registry.selectActive +
applyDeferExposition + per-feature hooks), constructs an Agent
(composeHooks folds observers + caller + features inside Agent), and
calls agent.stream(messages, signal) — which opens AI SDK's stream and
yields UIMessageChunks. Agent-session runtime requests skip the generic
agent loop here: AiService.streamText() calls
AgentSessionRuntimeService.openTurnStream() so the registered driver
can own the concrete agent runtime.pipeStreamLoop reads the chunk stream once, tees: broadcast to
listeners, accumulate via readUIMessageStream.done / error / aborted / awaiting-approval):
PersistenceListener writes the final assistant message.WebContentsListener broadcasts Ai_StreamDone to subscribed windows.topic.stream.statuses.<topicId> flips to the terminal status.useQuery('/topics/:id/messages') revalidates; the
optimistic overlay is disposed.tool.execute(args, toolCallContext). The wrapper sees
needsApproval(args) returns true and the assistant's auto-approve
policy says "ask". It writes an approval-requested part on the
accumulated message and holds the promise.awaiting-approval on the shared cache.useTopicAwaitingApproval(topicId) returns true; the UI
shows the approval card.useToolApprovalBridge → Ai_ToolApproval_Respond.canUseTool promise; MCP: dispatches a
continue-conversation so the existing stream rebroadcasts).streaming; UI hides the card.See Tool Approval for invariants and the overlay-vs-persist conditional write.
| Subsystem | Reference |
|---|---|
| Active-stream registry, listeners, persistence backends, reconnect, abort, grace-period eviction | Stream Manager |
| Claude Code agent-session long-lived runtime, SDK input queue, resume fallback | Agent Session Runtime |
Agent.stream single-pass loop, hooks model, error/abort | Agent Loop |
buildAgentParams, RequestFeature composition, INTERNAL_FEATURES order | Params Pipeline |
Tool registry, MCP sync, meta-tools (tool_search / tool_inspect / tool_invoke / tool_exec), defer exposition | Tool Registry |
Provider.endpointConfigs, endpointType resolution, variant suffixes, custom providers | Provider Resolution |
adapterFamily field, runtime resolver, write paths (catalog / migrator) | Adapter Family |
OTel span tree, AdapterTracer, AiSdkSpanAdapter, dev-tools view | Observability |
IpcChatTransport, dispatch coordinator, per-execution demux | IPC Transport |
| Approval flow, Main-as-writer invariant, persistent decisions | Tool Approval |
topicId. A topic has at most one active stream;
subscribers are equal — there is no "owner" window.PersistenceListener writes on terminal
regardless of subscriber state.@ai-sdk/* packages on different endpoints under the same
provider.id.tools/applies predicates are pure. They run on every
selectActive pass; side effects there break tool selection
determinism.RequestScope. It is shared across all
features for a single request.src/main/ai/
├── AiService.ts ← lifecycle owner, IPC entry (generate / translate / approval)
├── runtime/ ← execution backends: runtime/aiSdk (Agent + params), runtime/claudeCode
├── agentSession/ ← agent-session topic host
├── agents/ ← AgentJobsService, AgentTaskJobHandler, runAgentTask, prompt, heartbeat
├── channels/ ← ChannelManager + IM adapters (discord/feishu/qq/slack/telegram/wechat) + security/
├── streamManager/ ← AiStreamManager, listeners, persistence (registers the stream IPC)
├── provider/ ← provider config, endpoint resolution, custom providers
├── mcp/ ← McpRuntimeService / McpCatalogService, oauth, built-in servers
├── skills/ ← SkillService, SkillInstaller
├── tools/ ← unified tool registry (adapters/aiSdk + adapters/claudeCode)
├── observability/ ← AI trace adapters, local projection, sinks
├── messages/ ← UI part → AI SDK part conversion
├── types/ ← AppProviderId, merged types, request types
└── utils/ ← reasoning / model parameters / options / websearch
src/renderer/services/aiTransport/ ← IpcChatTransport, dispatch coordinator
src/renderer/hooks/ ← useChatWithHistory, useToolApprovalBridge, useTopicStreamStatus
packages/aiCore/ ← @cherrystudio/ai-core (Agent + plugins + provider extensions)
packages/provider-registry/ ← provider catalog, registry-utils (adapterFamily inference)