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 → ipcApi.request('ai.stream.open') │
│ ├─ reconnectToStream → ipcApi.request('ai.stream.attach') │
│ └─ abort signal → ipcApi.request('ai.stream.abort') │
│ │
│ History: useQuery('/topics/:topicId/messages') → DataApi │
│ Topic-level state: useTopicStreamStatus → shared cache │
│ Approval bridge: useToolApprovalBridge → ai.tool.respond_approval │
└──────────────────────────────────────────────────────────────────────┘
↕ IPC (keyed by topicId)
┌──────────────────────────────────────────────────────────────────────┐
│ Main │
│ │
│ src/main/ipc/handlers/ai.ts — thin IpcApi transport adapters: │
│ ├─ ai.stream.open → AiStreamManager.dispatch │
│ ├─ ai.stream.attach → AiStreamManager.attach │
│ ├─ ai.stream.detach → AiStreamManager.detach │
│ ├─ ai.stream.abort → AiStreamManager.abort │
│ └─ ai.tool.respond_approval → AiService.respondToolApproval │
│ │
│ 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 → ai.stream.done directed event │
│ ChannelAdapterListener → adapter.onStreamComplete │
│ SseListener → res.write('[DONE]') │
└──────────────────────────────────────────────────────────────────────┘
↓
@ai-sdk/* package
↓
LLM provider API
useChat.sendMessages calls IpcChatTransport.sendMessages.AiStreamOpenRequest, dispatches via
streamDispatchService over IpcApi ai.stream.open.ai.stream.open handler in src/main/ipc/handlers/ai.ts resolves the
managed sender window, wraps it in a WebContentsListener, and calls
AiStreamManager.dispatch.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 sends ai.stream.done to subscribed windows.topic.stream.statuses.<topicId> flips to the terminal status.useQuery('/topics/:topicId/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.tool.respond_approval.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 |
| Agent-session host plus Claude Code, Pi, and DSH runtime drivers | 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 service, 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 ← provider operations, built-in tool init, approval decisions
├── runtime/ ← aiSdk plus claudeCode / pi / dsh agent-session drivers
├── 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, dispatch
├── provider/ ← provider config, endpoint resolution, custom providers
├── mcp/ ← McpRuntimeService / McpCatalogService, oauth, built-in servers
├── skills/ ← SkillService, SkillInstaller
├── contextBuild/ ← context policy, compression, persisted tool outputs
├── inference/ ← local embedding/OCR inference
├── tokens/ ← token estimators and modality profiles
├── tools/ ← AI SDK registry and runtime-specific adapters
├── 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/main/ipc/handlers/ai.ts ← IpcApi transport adapters
src/renderer/services/aiTransport/ ← IpcChatTransport, StreamDispatchService, overlays
src/renderer/hooks/ ← useChatWithHistory, useToolApprovalBridge, useTopicStreamStatus
packages/aiCore/ ← @cherrystudio/ai-core (Agent + plugins + provider extensions)
packages/provider-registry/ ← provider catalog, registry-utils (adapterFamily inference)