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