返回 DeepSeek-Reasonix
bot_bridge_app.go
根目录 / desktop / bot_bridge_app.go
1 package main
2
3 import (
4 "fmt"
5 "log/slog"
6 "strings"
7
8 "reasonix/internal/bot"
9 "reasonix/internal/config"
10 "reasonix/internal/control"
11 "reasonix/internal/event"
12 )
13
14 // 本文件是 botBridgeHub 对 App 的全部胶水:会话枚举(含后台 detached)、
15 // 按 tab 寻址的审批/问答/驱动、transcript 公告、订阅持久化。
16
17 func (a *App) newBotBridge() *botBridgeHub {
18 return newBotBridgeHub(botBridgeDeps{
19 sessions: a.bridgeSessions,
20 approveTab: a.bridgeApprove,
21 answerTab: a.bridgeAnswer,
22 notify: a.botRuntime.SendToAdapter,
23 drive: a.bridgeDrive,
24 announce: a.bridgeAnnounce,
25 persistWatchers: a.bridgePersistWatchers,
26 takeoverChanged: a.emitProjectTreeChanged,
27 logger: slog.Default(),
28 })
29 }
30
31 // bridgeSessions 枚举所有 live 会话:可见 tab 用完整 TabMeta,后台 detached
32 // 会话补一份轻量快照(controller 仍存活,审批/问答仍可路由)。
33 func (a *App) bridgeSessions() []bot.DesktopSessionInfo {
34 tabs := a.ListTabs()
35 out := make([]bot.DesktopSessionInfo, 0, len(tabs)+4)
36 seen := make(map[string]bool, len(tabs))
37 for _, t := range tabs {
38 seen[t.ID] = true
39 out = append(out, bot.DesktopSessionInfo{
40 TabID: t.ID,
41 Label: t.Label,
42 Workspace: t.WorkspaceName,
43 Topic: t.TopicTitle,
44 Ready: t.Ready,
45 Running: t.Running,
46 PendingPrompt: t.PendingPrompt,
47 })
48 }
49 a.mu.RLock()
50 for _, tab := range a.detachedSessions {
51 if tab == nil || seen[tab.ID] {
52 continue
53 }
54 seen[tab.ID] = true
55 out = append(out, bot.DesktopSessionInfo{
56 TabID: tab.ID,
57 Label: tab.TopicTitle,
58 Topic: tab.TopicTitle,
59 Ready: tab.Ctrl != nil,
60 Running: strings.TrimSpace(tab.ActivityStatus) != "",
61 Detached: true,
62 })
63 }
64 a.mu.RUnlock()
65 return out
66 }
67
68 // bridgeCtrlByTabID 解析可见与后台 detached 两张表(区别于 ctrlByTabID:
69 // 那是前端语义,空 tabID 落到活跃 tab,且不看 detached)。
70 func (a *App) bridgeCtrlByTabID(tabID string) control.SessionAPI {
71 a.mu.RLock()
72 defer a.mu.RUnlock()
73 if tab := a.tabByEventSinkIDLocked(tabID); tab != nil {
74 return tab.Ctrl
75 }
76 return nil
77 }
78
79 func (a *App) bridgeApprove(tabID, id string, allow, session, persist bool) {
80 if ctrl := a.bridgeCtrlByTabID(tabID); ctrl != nil {
81 ctrl.Approve(id, allow, session, persist)
82 }
83 }
84
85 func (a *App) bridgeAnswer(tabID, id string, answers []QuestionAnswer) {
86 ctrl := a.bridgeCtrlByTabID(tabID)
87 if ctrl == nil {
88 return
89 }
90 out := make([]event.AskAnswer, len(answers))
91 for i, an := range answers {
92 out[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}
93 }
94 ctrl.AnswerQuestion(id, out)
95 }
96
97 // bridgeAnnounce 往会话 transcript 发一条 Notice,桌面用户在聊天流里可见。
98 func (a *App) bridgeAnnounce(tabID, text string) {
99 a.mu.RLock()
100 tab := a.tabByEventSinkIDLocked(tabID)
101 var sink *tabEventSink
102 if tab != nil {
103 sink = tab.sink
104 }
105 a.mu.RUnlock()
106 if sink == nil {
107 return
108 }
109 sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: text})
110 }
111
112 // bridgeDrive 把远程文本提交为可见 tab 的新 turn,并为这一轮挂上事件转发器,
113 // 让输出流回接管聊天(转发器在 TurnDone 自动卸载)。
114 func (a *App) bridgeDrive(tabID, text string, route bot.DesktopWatchRoute) error {
115 admission, ctrl, err := a.beginTabTurn(tabID, false)
116 if err != nil {
117 if err == control.ErrTurnRunning {
118 return errDriveBusy
119 }
120 return err
121 }
122 defer admission.abort()
123 tab := admission.tab
124 if tab.sink == nil {
125 return fmt.Errorf("会话事件通道不可用,无法驱动")
126 }
127 // A local submission may have reclaimed the tab while this drive was waiting
128 // for the per-tab admission gate. Revalidate ownership only after the gate is
129 // held, immediately before attaching the route-specific forwarder.
130 if a.botBridge == nil || a.botBridge.TakeoverTab(route) != tabID {
131 return fmt.Errorf("接管已解除,请重新接管会话")
132 }
133 target := botForwardTarget{
134 ConnID: route.ConnectionID,
135 Domain: route.Domain,
136 ChatID: route.ChatID,
137 ChatType: route.ChatType,
138 }
139 generation := tab.sink.SetBotSink(newBotEventForwarder(a.botRuntime, []botForwardTarget{target}))
140 a.ensureTabTopicIndexedForUserTurn(tab)
141 ctrl.SubmitDisplay(text, text)
142 // Confirm the submit actually started a turn. If nothing is running now, the
143 // controller was rotating and the submit no-oped — detach this exact
144 // generation so a later turn's output does not leak.
145 if !admission.finish(ctrl) {
146 tab.sink.clearBotSink(generation)
147 return errDriveBusy
148 }
149 return nil
150 }
151
152 // bridgePersistWatchers 把订阅全集回写用户配置(bot.desktop_watchers),
153 // 桌面重启后由 refreshBotRuntime 重新种子。
154 func (a *App) bridgePersistWatchers(routes []bot.DesktopWatchRoute) error {
155 return a.applyConfigOnly(func(c *config.Config) error {
156 watchers := make([]config.BotDesktopWatcherConfig, 0, len(routes))
157 for _, r := range routes {
158 watchers = append(watchers, config.BotDesktopWatcherConfig{
159 Platform: string(r.Platform),
160 ConnectionID: r.ConnectionID,
161 Domain: r.Domain,
162 ChatType: string(r.ChatType),
163 ChatID: r.ChatID,
164 })
165 }
166 c.Bot.DesktopWatchers = watchers
167 return nil
168 })
169 }
170
171 func bridgeRoutesFromConfig(watchers []config.BotDesktopWatcherConfig) []bot.DesktopWatchRoute {
172 routes := make([]bot.DesktopWatchRoute, 0, len(watchers))
173 for _, w := range watchers {
174 routes = append(routes, bot.DesktopWatchRoute{
175 Platform: bot.Platform(strings.TrimSpace(w.Platform)),
176 ConnectionID: strings.TrimSpace(w.ConnectionID),
177 Domain: strings.TrimSpace(w.Domain),
178 ChatType: bot.ChatType(strings.TrimSpace(w.ChatType)),
179 ChatID: strings.TrimSpace(w.ChatID),
180 })
181 }
182 return routes
183 }
184
184 lines GO