| 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 |