返回 DeepSeek-Reasonix
bot_event_sink.go
根目录 / desktop / bot_event_sink.go
1 package main
2
3 import (
4 "context"
5 "log"
6 "strings"
7 "sync"
8 "time"
9
10 "reasonix/internal/bot"
11 "reasonix/internal/event"
12 )
13
14 const botForwardSendTimeout = 30 * time.Second
15 const botForwardQueueSize = 64
16
17 // ── Forward target ──────────────────────────────────────────────────────────
18
19 // botForwardTarget identifies one remote chat to send forwarded events to.
20 type botForwardTarget struct {
21 ConnID string
22 Domain string
23 ChatID string
24 ChatType bot.ChatType
25 }
26
27 // ── Event forwarder ─────────────────────────────────────────────────────────
28
29 // botEventForwarder implements event.Sink and forwards relevant events to
30 // connected bot channels through the desktopBotRuntime. It is attached to a
31 // tabEventSink when a heartbeat task should push AI output to IM channels.
32 //
33 // It accumulates Text events and sends them as complete messages on TurnDone
34 // (and occasionally during generation when the buffer grows large enough), so
35 // the remote side sees progressive streaming output rather than one big blob.
36 type botEventForwarder struct {
37 runtime *desktopBotRuntime
38 targets []botForwardTarget
39
40 mu sync.Mutex
41 buf strings.Builder
42 queueMu sync.Mutex
43 queue chan string
44 closed bool
45 closeOnce sync.Once
46 }
47
48 // newBotEventForwarder creates a forwarder that sends to all given targets.
49 // runtime may be nil — Emit calls are then no-ops.
50 func newBotEventForwarder(runtime *desktopBotRuntime, targets []botForwardTarget) *botEventForwarder {
51 f := &botEventForwarder{
52 runtime: runtime,
53 targets: targets,
54 queue: make(chan string, botForwardQueueSize),
55 }
56 go f.run()
57 return f
58 }
59
60 // Emit implements event.Sink. It forwards text and lifecycle events to the
61 // connected bot channels; reasoning, tool dispatch, and other internal events
62 // are dropped to avoid noisy IM output.
63 func (f *botEventForwarder) Emit(e event.Event) {
64 if f.runtime == nil || len(f.targets) == 0 {
65 return
66 }
67 switch e.Kind {
68 case event.TurnStarted:
69 f.mu.Lock()
70 f.buf.Reset()
71 f.mu.Unlock()
72
73 case event.Text:
74 f.mu.Lock()
75 f.buf.WriteString(e.Text)
76 size := f.buf.Len()
77 f.mu.Unlock()
78 // Flush opportunistically when the buffer crosses a threshold, so long
79 // streams (e.g. "tell me three jokes") produce multiple messages.
80 if size >= 400 {
81 f.flush()
82 }
83
84 case event.TurnDone:
85 f.flush()
86 f.Close()
87
88 case event.ApprovalRequest:
89 // The heartbeat turn belongs to the desktop tab controller, not the bot
90 // gateway session, so remote /approve replies cannot satisfy this ID.
91 text := "⚠️ 需要在 Reasonix 桌面端批准操作: " + e.Approval.Tool + " — " + e.Approval.Subject
92 text += "\n请回到桌面窗口处理。"
93 f.sendToAll(text)
94
95 case event.AskRequest:
96 var qb strings.Builder
97 qb.WriteString("❓ 需要在 Reasonix 桌面端回答问题:\n")
98 for i, q := range e.Ask.Questions {
99 if i > 0 {
100 qb.WriteString("\n")
101 }
102 qb.WriteString(q.Prompt)
103 }
104 qb.WriteString("\n请回到桌面窗口处理。")
105 f.sendToAll(qb.String())
106
107 case event.Notice:
108 if e.Audience == event.NoticeAudienceOperator {
109 // Local runtime maintenance is not actionable in the remote IM chat.
110 break
111 }
112 if e.Level == event.LevelWarn {
113 f.sendToAll("⚠️ " + e.Text)
114 }
115
116 case event.CompactionStarted:
117 f.sendToAll("🔄 正在压缩上下文...")
118 }
119 }
120
121 // flush sends the accumulated buffer as one message per target channel.
122 func (f *botEventForwarder) flush() {
123 f.mu.Lock()
124 text := strings.TrimSpace(f.buf.String())
125 if text == "" {
126 f.mu.Unlock()
127 return
128 }
129 f.buf.Reset()
130 f.mu.Unlock()
131
132 f.sendToAll(text)
133 }
134
135 // sendToAll dispatches text to every target channel. Errors are logged and
136 // non-fatal; a failed target does not block other targets.
137 func (f *botEventForwarder) sendToAll(text string) {
138 text = strings.TrimSpace(text)
139 if f.runtime == nil || len(f.targets) == 0 || text == "" {
140 return
141 }
142 f.queueMu.Lock()
143 defer f.queueMu.Unlock()
144 if f.closed {
145 return
146 }
147 select {
148 case f.queue <- text:
149 default:
150 log.Printf("[bot-forward] send queue full; dropping message for %d target(s)", len(f.targets))
151 }
152 }
153
154 func (f *botEventForwarder) run() {
155 for text := range f.queue {
156 f.sendToAllNow(text)
157 }
158 }
159
160 func (f *botEventForwarder) sendToAllNow(text string) {
161 for _, tgt := range f.targets {
162 ctx, cancel := context.WithTimeout(context.Background(), botForwardSendTimeout)
163 _, err := f.runtime.SendToAdapter(ctx, tgt.ConnID, tgt.Domain, bot.OutboundMessage{
164 ChatID: tgt.ChatID,
165 ChatType: tgt.ChatType,
166 Text: text,
167 })
168 cancel()
169 if err != nil {
170 log.Printf("[bot-forward] send to %s/%s failed: %v", tgt.ConnID, tgt.ChatType, err)
171 }
172 }
173 }
174
175 func (f *botEventForwarder) Close() {
176 f.closeOnce.Do(func() {
177 f.flush()
178 f.queueMu.Lock()
179 f.closed = true
180 close(f.queue)
181 f.queueMu.Unlock()
182 })
183 }
184
184 lines GO