mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-03 20:30:52 +08:00
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.
12 KiB
12 KiB
Core Architecture
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.
Layered view
┌──────────────────────────────────────────────────────────────────────┐
│ 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
Sequence: a fresh chat turn
- User hits send.
useChat.sendMessagescallsIpcChatTransport.sendMessages. - Transport packages
AiStreamOpenRequest, dispatches viastreamDispatchCoordinatorover IPCAi_Stream_Open. AiStreamManager'sAi_Stream_Openhandler (registered inonInit) wraps the sender in aWebContentsListenerand callsdispatchStreamRequest(manager, subscriber, request).dispatchStreamRequestpicks the firstChatContextProviderwhosecanHandle(topicId)matches and asks it toprepareDispatch.- The provider resolves models, persists the user message (chat) or skips
persistence (temporary / translate), creates
PersistenceListenerper execution, returnsPreparedDispatch. dispatchreconciles any live stream, then callsmanager.send(input):- chat resubmit (topic already streaming): the provider persists the
steer user row and
dispatchcallsmanager.enqueuePendingSteer(topicId);send()injects (just upserts the subscriber). The running turn yields viasteerYield(persisting assuccess) andonExecutionDonechains asteer-continuation— steering is enqueue + yield + chain, not abort-and-restart and not mid-turn injection. - agent-session follow-up: the stream is left running and
send()injects — it upsertslistenersonto the running stream,modelsignored (the message was already enqueued on the session'spendingTurns). - no live stream:
send()starts — evict any grace-period stream, create anActiveStream, launch oneStreamExecutionper model.
- chat resubmit (topic already streaming): the provider persists the
steer user row and
- For each
StreamExecution,AiStreamManager's privaterunExecutionLoopcallsAiService.streamText(request, signal), which builds params (buildAgentParamsFor → buildAgentParams:registry.selectActive+applyDeferExposition+ per-feature hooks), constructs anAgent(composeHooksfolds observers + caller + features insideAgent), and callsagent.stream(messages, signal)— which opens AI SDK's stream and yieldsUIMessageChunks. Agent-session runtime requests skip the generic agent loop here:AiService.streamText()callsAgentSessionRuntimeService.openTurnStream()so the registered driver can own the concrete agent runtime. pipeStreamLoopreads the chunk stream once, tees: broadcast to listeners, accumulate viareadUIMessageStream.- On terminal (
done/error/aborted/awaiting-approval):PersistenceListenerwrites the final assistant message.WebContentsListenerbroadcastsAi_StreamDoneto subscribed windows.- Shared-cache
topic.stream.statuses.<topicId>flips to the terminal status.
- Renderer's
useQuery('/topics/:id/messages')revalidates; the optimistic overlay is disposed.
Sequence: tool approval pause + resume
- AI SDK calls
tool.execute(args, toolCallContext). The wrapper seesneedsApproval(args)returns true and the assistant's auto-approve policy says "ask". It writes anapproval-requestedpart on the accumulated message and holds the promise. - Manager flips status to
awaiting-approvalon the shared cache. - Renderer's
useTopicAwaitingApproval(topicId)returns true; the UI shows the approval card. - User decides →
useToolApprovalBridge→Ai_ToolApproval_Respond. - Main applies the decision to the anchor row, resumes the stream
(Claude-Agent: resolves the
canUseToolpromise; MCP: dispatches acontinue-conversationso the existing stream rebroadcasts). - Status flips back to
streaming; UI hides the card.
See Tool Approval for invariants and the overlay-vs-persist conditional write.
Key subsystems
| 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 |
Invariants
- Topic-level addressing. Every IPC, broadcast, and shared-cache
entry is keyed by
topicId. A topic has at most one active stream; subscribers are equal — there is no "owner" window. - Main owns persistence. Renderer closing or crashing does not abort
the stream or lose data.
PersistenceListenerwrites on terminal regardless of subscriber state. - Main owns approval state. The renderer is never a writer.
- Adapter family is per-endpoint. Multi-endpoint relays may use
different
@ai-sdk/*packages on different endpoints under the sameprovider.id. tools/appliespredicates are pure. They run on everyselectActivepass; side effects there break tool selection determinism.- Features must not mutate
RequestScope. It is shared across all features for a single request.
Code map
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, cherryclaw
├── 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)