magic-agent 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +392 -0
  3. package/cmd/magic-agent/main.go +12 -0
  4. package/cmd/magic-client/main.go +144 -0
  5. package/go.mod +11 -0
  6. package/go.sum +12 -0
  7. package/internal/agent/arkclaw.go +587 -0
  8. package/internal/agent/arkclaw_stream.go +312 -0
  9. package/internal/agent/arkclaw_stream_test.go +428 -0
  10. package/internal/agent/arkclaw_test.go +827 -0
  11. package/internal/agent/ask.go +694 -0
  12. package/internal/agent/ask_test.go +587 -0
  13. package/internal/agent/attachments.go +200 -0
  14. package/internal/agent/attachments_test.go +481 -0
  15. package/internal/agent/capability.go +154 -0
  16. package/internal/agent/capability_test.go +190 -0
  17. package/internal/agent/claude.go +406 -0
  18. package/internal/agent/codebuddy.go +1044 -0
  19. package/internal/agent/codebuddy_account_test.go +128 -0
  20. package/internal/agent/codebuddy_gateway.go +479 -0
  21. package/internal/agent/codebuddy_gateway_stream.go +358 -0
  22. package/internal/agent/codebuddy_gateway_test.go +673 -0
  23. package/internal/agent/codex.go +599 -0
  24. package/internal/agent/codex_home.go +206 -0
  25. package/internal/agent/codex_home_test.go +130 -0
  26. package/internal/agent/codex_stream_test.go +97 -0
  27. package/internal/agent/codex_test.go +280 -0
  28. package/internal/agent/custom_models.go +197 -0
  29. package/internal/agent/custom_models_test.go +283 -0
  30. package/internal/agent/dsh.go +754 -0
  31. package/internal/agent/dsh_sdk.go +679 -0
  32. package/internal/agent/dsh_sdk_test.go +726 -0
  33. package/internal/agent/dsh_test.go +759 -0
  34. package/internal/agent/engine.go +469 -0
  35. package/internal/agent/engine_base.go +497 -0
  36. package/internal/agent/engine_base_test.go +308 -0
  37. package/internal/agent/engine_test.go +858 -0
  38. package/internal/agent/env.go +88 -0
  39. package/internal/agent/env_test.go +106 -0
  40. package/internal/agent/jsonextract.go +128 -0
  41. package/internal/agent/jsonextract_test.go +110 -0
  42. package/internal/agent/keepalive_test.go +216 -0
  43. package/internal/agent/llmengine.go +470 -0
  44. package/internal/agent/llmengine_test.go +551 -0
  45. package/internal/agent/llmhttp.go +437 -0
  46. package/internal/agent/llmhttp_test.go +304 -0
  47. package/internal/agent/llmmodels.go +205 -0
  48. package/internal/agent/login.go +27 -0
  49. package/internal/agent/main_test.go +23 -0
  50. package/internal/agent/models.go +686 -0
  51. package/internal/agent/models_test.go +877 -0
  52. package/internal/agent/openclaw.go +543 -0
  53. package/internal/agent/openclaw_acp.go +949 -0
  54. package/internal/agent/openclaw_acp_test.go +406 -0
  55. package/internal/agent/openclaw_test.go +451 -0
  56. package/internal/agent/output.go +191 -0
  57. package/internal/agent/permission.go +362 -0
  58. package/internal/agent/permission_test.go +526 -0
  59. package/internal/agent/prompt.go +65 -0
  60. package/internal/agent/runcmd.go +259 -0
  61. package/internal/agent/runcmd_test.go +81 -0
  62. package/internal/agent/runcmd_unix.go +56 -0
  63. package/internal/agent/runcmd_windows.go +66 -0
  64. package/internal/agent/runner.go +180 -0
  65. package/internal/agent/runner_test.go +263 -0
  66. package/internal/agent/spawnhook_test.go +96 -0
  67. package/internal/agent/stream.go +882 -0
  68. package/internal/agent/stream_test.go +565 -0
  69. package/internal/agent/streamjson.go +185 -0
  70. package/internal/agent/tags.go +52 -0
  71. package/internal/agent/toolkind.go +96 -0
  72. package/internal/agent/toolkind_test.go +70 -0
  73. package/internal/agent/tools_suffix_test.go +261 -0
  74. package/internal/agent/trae.go +342 -0
  75. package/internal/agent/workspace_test.go +166 -0
  76. package/internal/boundary/boundary_test.go +165 -0
  77. package/internal/cli/ask.go +1113 -0
  78. package/internal/cli/ask_test.go +1088 -0
  79. package/internal/cli/attach_test.go +239 -0
  80. package/internal/cli/contract_test.go +157 -0
  81. package/internal/cli/control.go +149 -0
  82. package/internal/cli/control_test.go +192 -0
  83. package/internal/cli/engines_test.go +205 -0
  84. package/internal/cli/events.go +198 -0
  85. package/internal/cli/events_test.go +166 -0
  86. package/internal/cli/keepalive_test.go +391 -0
  87. package/internal/cli/login.go +78 -0
  88. package/internal/cli/permission_test.go +295 -0
  89. package/internal/cli/prompt_source_test.go +222 -0
  90. package/internal/cli/root.go +752 -0
  91. package/internal/cli/session.go +486 -0
  92. package/internal/cli/session_test.go +202 -0
  93. package/internal/cli/sessionlog.go +325 -0
  94. package/internal/cli/sessionlog_command_test.go +204 -0
  95. package/internal/cli/sessionlog_test.go +262 -0
  96. package/internal/cli/sessionlog_user_test.go +129 -0
  97. package/internal/cli/toolkind_wire_test.go +84 -0
  98. package/internal/cli/workspace_test.go +188 -0
  99. package/internal/client/cors_test.go +136 -0
  100. package/internal/client/lan.go +68 -0
  101. package/internal/client/lan_test.go +53 -0
  102. package/internal/client/pair.go +163 -0
  103. package/internal/client/pair_test.go +109 -0
  104. package/internal/client/profile.go +184 -0
  105. package/internal/client/service.go +888 -0
  106. package/internal/client/service_test.go +654 -0
  107. package/internal/client/session_events_test.go +186 -0
  108. package/internal/client/session_messages_test.go +150 -0
  109. package/internal/config/config.go +439 -0
  110. package/internal/config/config_test.go +448 -0
  111. package/internal/session/session.go +447 -0
  112. package/internal/session/session_append.go +241 -0
  113. package/internal/session/session_append_test.go +161 -0
  114. package/internal/session/session_kill_unix_test.go +90 -0
  115. package/internal/session/session_mark_test.go +95 -0
  116. package/internal/session/session_test.go +237 -0
  117. package/internal/session/sessionlog.go +128 -0
  118. package/internal/session/sessionlog_read_test.go +157 -0
  119. package/npm/dist/darwin-arm64/magic-agent +0 -0
  120. package/npm/dist/darwin-x64/magic-agent +0 -0
  121. package/npm/dist/linux-arm64/magic-agent +0 -0
  122. package/npm/dist/linux-x64/magic-agent +0 -0
  123. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  124. package/npm/install.js +121 -0
  125. package/npm/lib/platform.js +78 -0
  126. package/package.json +36 -0
@@ -0,0 +1,358 @@
1
+ package agent
2
+
3
+ /* codebuddy_gateway_stream.go - codebuddy-gateway 的收流通道(SSE)。
4
+ *
5
+ * 通道形态:GET <base>/api/v1/runs/{runId}/stream + `accept: text/event-stream`,
6
+ * 响应是 SSE,逐帧 `data: {...}`,每帧是一个 **Gateway Protocol 出站消息**:
7
+ *
8
+ * {"version":"1.0","replyTo":"<入站消息 id>","status":"<状态>", ...}
9
+ *
10
+ * 帧类型(对 CodeBuddy Code 2.147.0 读内核得到):
11
+ *
12
+ * status=accepted — 受理帧。**没有正文**;只在投递后立刻发一次。
13
+ * ⚠️ 网关侧用的是普通 RxJS Subject(非 Replay),所以「投递完才
14
+ * 去连流」的客户端**收不到这一帧** —— 别拿它当「连接成功」的判据。
15
+ * status=streaming — 正文增量:content.chunk 是**增量**(不是全量),逐块拼接。
16
+ * status=completed — 终帧:content.markdown 是**整段**正文(权威值),
17
+ * 另带 agent.sessionId / agent.toolCalls。
18
+ * status=error — 终止:error.code / error.message。
19
+ *
20
+ * ⚠️ **实测更正(2026-09-24,本机 WorkBuddy CLI 2.147.0 + 默认模型)**:这个网关目前
21
+ * **并不发 streaming 帧**。裸探针(raw_probe,不经本引擎解析)一次 410 字生成的完整帧序列是:
22
+ *
23
+ * +10.067s 流已建立(content-type=text/event-stream)
24
+ * +10.067s event: message
25
+ * +10.067s data: {"status":"completed","content":{"markdown":"<410 字整段>"}}
26
+ * +10.067s event: done
27
+ * +10.068s data: {}
28
+ *
29
+ * 也就是「**受理 + 整段正文**」两帧(与 arkclaw 那条通道同构),不是打字机。
30
+ * 内核里 `convertEventToOutbound` 确实写了 text_delta → `status=streaming` 的映射,
31
+ * 但它要求会话事件是 `type=model` 且带 `data.delta.type=text_delta`;这一条在本版本的
32
+ * `--serve` 路径上没有出现(TUI 下是否出现未验)。所以:
33
+ * · 本文件照**协议**实现 streaming 帧的解析与去重(网关将来吐增量时客户端零改动即可接住);
34
+ * · 但**不要对外宣称它是逐字流式** —— 当前成色与 arkclaw 一样是「整段到达」。
35
+ *
36
+ * 另一条实测结论(影响超时语义):**网关在首帧到达前不 flush 响应头**。
37
+ * 上面那次探针里,投递在 +0.014s 返回,而流响应头到 +10.067s 才出现(= 首帧生成完)。
38
+ * 所以「等待」全都发生在 http.Client.Do 里面 —— 调用方给的超时必须覆盖**整个生成过程**,
39
+ * 而不是只覆盖连接建立(默认 10 分钟正是按这个语义定的,见 DefaultCBGatewayTimeout)。
40
+ *
41
+ * 与非流式(Complete)的关系:同一条执行核(CodeBuddyGatewayEngine.run)+ 同一套帧解析。
42
+ * Complete 也读 SSE —— 因为投递响应里没有正文,SSE 是唯一拿得到正文的通道(见
43
+ * codebuddy_gateway.go 文件头)。差别只在 onEvent 是不是 nil。
44
+ *
45
+ * 不实现的东西:断线重连(网关没有 resubscribe 端点)、Request.Append(webhook 是
46
+ * 「一次投递一个 run」的模型,没有持续喂消息的通道)。
47
+ */
48
+
49
+ import (
50
+ "bufio"
51
+ "context"
52
+ "encoding/json"
53
+ "fmt"
54
+ "io"
55
+ "net/http"
56
+ "net/url"
57
+ "strings"
58
+ "time"
59
+ )
60
+
61
+ // cbGatewaySSEMaxLine 单帧上限:completed 帧把**整段正文**放在一行 data 里,留足余量。
62
+ const cbGatewaySSEMaxLine = 16 << 20 // 16 MiB
63
+
64
+ // cbGatewayFrame 是一帧出站消息(Gateway Protocol)。
65
+ type cbGatewayFrame struct {
66
+ Version string `json:"version"`
67
+ ReplyTo string `json:"replyTo"`
68
+ Status string `json:"status"`
69
+
70
+ Content *struct {
71
+ // Chunk 增量正文(status=streaming)。
72
+ Chunk string `json:"chunk"`
73
+ // Text / Markdown 整段正文(status=completed;内核两个字段都填,
74
+ // markdown 是完整版,text 在部分路径上被截到 4096)。
75
+ Text string `json:"text"`
76
+ Markdown string `json:"markdown"`
77
+ } `json:"content"`
78
+
79
+ Agent *struct {
80
+ SessionID string `json:"sessionId"`
81
+ ToolCalls []cbGatewayToolCall `json:"toolCalls"`
82
+ } `json:"agent"`
83
+
84
+ Error *struct {
85
+ Code string `json:"code"`
86
+ Message string `json:"message"`
87
+ } `json:"error"`
88
+ }
89
+
90
+ // cbGatewayToolCall 是终帧里带的工具调用记录(尽力解析,缺失不影响正文)。
91
+ //
92
+ // 本版本内核的 convertEventToOutbound 并不填这个数组,所以实测通常为空 ——
93
+ // 解析写成宽松的、字段可缺省,将来网关开始填时零改动即可接住。
94
+ type cbGatewayToolCall struct {
95
+ Name string `json:"name"`
96
+ ID string `json:"id"`
97
+ Args json.RawMessage `json:"args"`
98
+ Result string `json:"result"`
99
+ }
100
+
101
+ // cbGatewayStreamState 一条 run 流的累积状态。
102
+ type cbGatewayStreamState struct {
103
+ onEvent func(StreamEvent)
104
+
105
+ msgID string // 入站消息 id(replyTo,用于过滤串台帧)
106
+ runID string
107
+ convID string
108
+
109
+ // emitted 已作为增量发出的正文(去重用:终帧给的是整段)。
110
+ emitted strings.Builder
111
+ // final 权威正文(终帧给的整段);缺省回落到 emitted。
112
+ final string
113
+
114
+ completed bool // 是否见过终帧
115
+ status string // 最近一次 status(错误信息用)
116
+ sessionID string // 会话锚点(网关回 sessionId 时以它为准)
117
+ tools []ToolCall
118
+ }
119
+
120
+ // Stream 实现 Streamer:走 webhook 投递 + SSE 收流(见文件头)。
121
+ func (e *CodeBuddyGatewayEngine) Stream(ctx context.Context, req Request, onEvent func(StreamEvent)) (StreamResult, error) {
122
+ return e.run(ctx, req, onEvent)
123
+ }
124
+
125
+ // consumeStream 打开 run 流并逐帧消费,直到终帧 / 流结束 / 出错。
126
+ //
127
+ // schema 非 nil 时对终帧正文做严格 JSON 抽取(与 llm / openclaw / arkclaw 同一后处理),
128
+ // 且在**发出之前**做,保证调用方拿到的增量与收尾正文是同一份。
129
+ func (e *CodeBuddyGatewayEngine) consumeStream(ctx context.Context, client *http.Client, r cbGatewayResolved, runID string, st *cbGatewayStreamState, schema *JSONSchema) error {
130
+ endpoint := r.baseURL + "/api/v1/runs/" + url.PathEscape(runID) + "/stream"
131
+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
132
+ if err != nil {
133
+ return fmt.Errorf("%s: 构造流请求失败: %w", e.Name(), err)
134
+ }
135
+ httpReq.Header.Set("accept", "text/event-stream")
136
+ cbGatewaySetAuth(httpReq, r.password)
137
+
138
+ httpResp, err := client.Do(httpReq)
139
+ if err != nil {
140
+ return fmt.Errorf("%s: 连接 run 流失败(网关 %s 是否在跑?): %w", e.Name(), r.baseURL, err)
141
+ }
142
+ defer httpResp.Body.Close()
143
+
144
+ // 网关没按 SSE 回(老版本 / 路由被改):如实退回「一次性解析」,不假装有增量。
145
+ if !cbGatewayIsEventStream(httpResp.Header.Get("content-type")) {
146
+ raw, rerr := io.ReadAll(io.LimitReader(httpResp.Body, cbGatewayMaxBody))
147
+ if rerr != nil {
148
+ return fmt.Errorf("%s: 读取响应失败: %w", e.Name(), rerr)
149
+ }
150
+ if httpResp.StatusCode != http.StatusOK {
151
+ return fmt.Errorf("%s: 网关拒绝流请求(HTTP %d): %s", e.Name(), httpResp.StatusCode, cbGatewayErrorDetail(raw))
152
+ }
153
+ if _, herr := st.handleFrame(raw, schema); herr != nil {
154
+ return herr
155
+ }
156
+ if !st.completed {
157
+ return fmt.Errorf("%s: 网关未按 SSE 回(content-type=%q),且响应里没有终帧 —— 无法取得正文",
158
+ e.Name(), httpResp.Header.Get("content-type"))
159
+ }
160
+ return nil
161
+ }
162
+
163
+ sc := bufio.NewScanner(httpResp.Body)
164
+ sc.Buffer(make([]byte, 0, 64*1024), cbGatewaySSEMaxLine)
165
+ for sc.Scan() {
166
+ payload, ok := cbGatewaySSEPayload(sc.Text())
167
+ if !ok {
168
+ continue // 空行 / ":" 心跳 / event: 等字段
169
+ }
170
+ done, herr := st.handleFrame([]byte(payload), schema)
171
+ if herr != nil {
172
+ return herr
173
+ }
174
+ if done {
175
+ break // 终帧:网关随后即关流,不必再等 EOF
176
+ }
177
+ }
178
+ if serr := sc.Err(); serr != nil {
179
+ // 超时 / 取消会在读 body 时爆出来(响应头早就到了,卡的是流)。
180
+ if cerr := ctx.Err(); cerr != nil {
181
+ return fmt.Errorf("%s: 流式读取中断: %w", e.Name(), cerr)
182
+ }
183
+ return fmt.Errorf("%s: 读取流式响应失败: %w", e.Name(), serr)
184
+ }
185
+
186
+ if !st.completed {
187
+ return fmt.Errorf("%s: run 流结束但没有终帧(最后状态 %q)—— 网关侧任务可能仍在跑或已异常退出",
188
+ e.Name(), st.status)
189
+ }
190
+ return nil
191
+ }
192
+
193
+ // handleFrame 处理一帧出站消息。返回 done=true 表示这帧是终态(可以停读)。
194
+ func (st *cbGatewayStreamState) handleFrame(raw []byte, schema *JSONSchema) (bool, error) {
195
+ var f cbGatewayFrame
196
+ if err := json.Unmarshal(raw, &f); err != nil {
197
+ return false, nil // 非 JSON 帧(心跳 / 日志混入):忽略,不打断整轮
198
+ }
199
+ /* 刻意**不按 replyTo 过滤**(虽然它理论上等于本次入站消息 id)。
200
+ *
201
+ * 理由:run 流本来就是按 runId 分流的(网关侧 taskStreams.get(runId) 是一个 per-run
202
+ * 的 Subject,只推这一条 run 的帧),不存在串台;而「严格比对 replyTo」一旦遇上
203
+ * 字段语义漂移(改成 run id / 出站回复 id),就会**静默丢掉全部帧** —— 症状是
204
+ * 「跑完了但正文是空的」,排查成本极高。宁可不做这道无收益的过滤。
205
+ * f.ReplyTo 仍解析出来,供排查时对照日志。 */
206
+ if f.Agent != nil && strings.TrimSpace(f.Agent.SessionID) != "" {
207
+ st.sessionID = strings.TrimSpace(f.Agent.SessionID)
208
+ }
209
+ if f.Agent != nil && len(f.Agent.ToolCalls) > 0 {
210
+ st.tools = cbGatewayToolCalls(f.Agent.ToolCalls)
211
+ }
212
+
213
+ status := strings.ToLower(strings.TrimSpace(f.Status))
214
+ st.status = status
215
+
216
+ switch status {
217
+ case "error", "failed", "canceled", "cancelled", "rejected":
218
+ code, msg := "", ""
219
+ if f.Error != nil {
220
+ code, msg = f.Error.Code, f.Error.Message
221
+ }
222
+ if strings.TrimSpace(msg) == "" {
223
+ msg = "(网关未给出错误详情)"
224
+ }
225
+ if code != "" {
226
+ return true, fmt.Errorf("%s: run %s 失败(%s): %s", cbGatewayEngineName, st.runID, code, truncateStr(msg, 300))
227
+ }
228
+ return true, fmt.Errorf("%s: run %s 失败(status=%s): %s", cbGatewayEngineName, st.runID, f.Status, truncateStr(msg, 300))
229
+
230
+ case "completed":
231
+ text := ""
232
+ if f.Content != nil {
233
+ text = firstNonEmpty(f.Content.Markdown, f.Content.Text)
234
+ }
235
+ // JSONSchema 后处理:在发出之前做,保证增量与收尾正文是同一份(同 arkclaw)。
236
+ if schema != nil {
237
+ if extracted, ok := extractJSONObjectStrict(text, schema); ok {
238
+ text = extracted
239
+ }
240
+ }
241
+ st.final = text
242
+ st.completed = true
243
+ st.emitText(text)
244
+ return true, nil
245
+
246
+ case "streaming":
247
+ if f.Content != nil && f.Content.Chunk != "" {
248
+ st.emitText(st.emitted.String() + f.Content.Chunk)
249
+ }
250
+ return false, nil
251
+
252
+ case "accepted":
253
+ return false, nil // 受理帧:没有正文(且晚连的客户端本来就收不到)
254
+ }
255
+
256
+ // 未知状态:网关将来加状态时不要打断整轮,但若它挂了正文也照发。
257
+ if f.Content != nil && f.Content.Chunk != "" {
258
+ st.emitText(st.emitted.String() + f.Content.Chunk)
259
+ }
260
+ return false, nil
261
+ }
262
+
263
+ // emitText 发一条正文增量。
264
+ //
265
+ // 去重规则(与 arkclaw 同一套):入参是**当前应显示的全量**。已发出的部分与它同前缀时
266
+ // 只发剩下的那截;对不上(既非空又不是前缀)宁可不发 —— 正文以 StreamResult.Text 为准,
267
+ // 重复发会让调用方看到两份内容。
268
+ func (st *cbGatewayStreamState) emitText(full string) {
269
+ if full == "" {
270
+ return
271
+ }
272
+ if prev := st.emitted.String(); prev != "" {
273
+ if !strings.HasPrefix(full, prev) {
274
+ return
275
+ }
276
+ full = full[len(prev):]
277
+ }
278
+ if full == "" {
279
+ return
280
+ }
281
+ st.emitted.WriteString(full)
282
+ if st.onEvent != nil {
283
+ st.onEvent(StreamEvent{Kind: KindText, Text: full})
284
+ }
285
+ }
286
+
287
+ // result 把累积状态收成 StreamResult(终态与错误路径共用)。
288
+ //
289
+ // ⚠️ 返回的 SessionID 必须是**会话锚点(conversation id)**,不是网关内部的 session UUID:
290
+ // 调用方会把它原样交给下一轮的 --session,而网关的 getOrCreateSession 只认 conversation.id。
291
+ // 实测过一次踩坑:终帧里 agent.sessionId 是 `6a58b80e-…` 这种内部 UUID,若拿它当 session_id
292
+ // 返回,下一轮就会以这个 UUID 为锚点**新建一个会话** —— 上下文静默丢失,看起来像「模型失忆」。
293
+ // 所以这里只认 convID / req.SessionID;网关给的内部 UUID 仅作诊断(留在 st.sessionID)。
294
+ func (st *cbGatewayStreamState) result(e *CodeBuddyGatewayEngine, req Request, start time.Time) StreamResult {
295
+ text := st.final
296
+ if strings.TrimSpace(text) == "" {
297
+ text = st.emitted.String()
298
+ }
299
+ sid := firstNonEmpty(req.SessionID, st.convID)
300
+ return StreamResult{
301
+ Response: Response{
302
+ Engine: e.Name(),
303
+ Text: text,
304
+ SessionID: sid,
305
+ Latency: time.Since(start),
306
+ Attempts: 1,
307
+ },
308
+ Tools: st.tools,
309
+ }
310
+ }
311
+
312
+ // cbGatewayToolCalls 把终帧里的工具记录转成统一的 ToolCall(Args 尽力转成 JSON 串)。
313
+ func cbGatewayToolCalls(in []cbGatewayToolCall) []ToolCall {
314
+ out := make([]ToolCall, 0, len(in))
315
+ for _, t := range in {
316
+ out = append(out, ToolCall{
317
+ Name: t.Name,
318
+ ID: t.ID,
319
+ Args: cbGatewayRawJSONToString(t.Args),
320
+ Result: t.Result,
321
+ })
322
+ }
323
+ return out
324
+ }
325
+
326
+ // cbGatewayRawJSONToString 把 args 原样转成字符串(是 JSON 字符串就解出内容,其余保持原文)。
327
+ func cbGatewayRawJSONToString(raw json.RawMessage) string {
328
+ if len(raw) == 0 {
329
+ return ""
330
+ }
331
+ var s string
332
+ if err := json.Unmarshal(raw, &s); err == nil {
333
+ return s
334
+ }
335
+ return string(raw)
336
+ }
337
+
338
+ // cbGatewaySSEPayload 从一行 SSE 文本里取出 data 载荷。
339
+ // ok=false 表示这行不是数据帧:空行(帧分隔)、":xxx"(注释/心跳)、event:/id:/retry: 等字段。
340
+ func cbGatewaySSEPayload(line string) (string, bool) {
341
+ line = strings.TrimSpace(line)
342
+ if line == "" || strings.HasPrefix(line, ":") {
343
+ return "", false
344
+ }
345
+ if !strings.HasPrefix(line, "data:") {
346
+ return "", false
347
+ }
348
+ payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
349
+ if payload == "" || payload == "[DONE]" {
350
+ return "", false
351
+ }
352
+ return payload, true
353
+ }
354
+
355
+ // cbGatewayIsEventStream 响应是否为 SSE(content-type 判断,容忍参数与大小写)。
356
+ func cbGatewayIsEventStream(contentType string) bool {
357
+ return strings.HasPrefix(strings.ToLower(strings.TrimSpace(contentType)), "text/event-stream")
358
+ }