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,437 @@
1
+ package agent
2
+
3
+ // llmhttp.go - llm 引擎「配置文件模型」的直连执行(models.json 条目 → OpenAI 兼容端点)。
4
+ //
5
+ // 为什么需要它:见 llmmodels.go 的文件头 —— 配置里的 id 是唯一标识,真实模型名会重复,
6
+ // 变体差异(extraBody)只有直连才表达得出来。llm CLI 那条路只服务「llm 自己注册的
7
+ // model_id」,两者并存:命中配置文件 → 直连;否则原样委托 llm CLI。
8
+ //
9
+ // 与 llm CLI 路径的差异(都是刻意的):
10
+ //
11
+ // 会话续接:直连没有会话库 → 不支持 --session / -c。显式要求续接时报错而不是静默丢上下文。
12
+ // 思考过程:非流式走 splitThinkingTags、流式走 thinkSplitter(与 CLI 路径同一套剥离逻辑);
13
+ // MiniMax 这类把思维链以标签混进 content 的模型,两端都能分流。
14
+ // 参数透传:max_tokens / temperature 优先用调用方(req)给的值,其次用条目自带的默认;
15
+ // extraBody 整体 merge 到请求体顶层,但 model / messages / stream 由本文件最终设定,
16
+ // 避免配置写错把这三个关键键覆盖掉。
17
+ // 错误:统一 `HTTP <code>: <片段>`(runner.isRetryable 靠 "429/503/timeout" 这类子串判重试)。
18
+ // Token 计数:端点给了 usage 就回填(llm CLI 路径靠 --json,直连靠响应体)。
19
+
20
+ import (
21
+ "bufio"
22
+ "bytes"
23
+ "context"
24
+ "encoding/json"
25
+ "fmt"
26
+ "io"
27
+ "net/http"
28
+ "strings"
29
+ "time"
30
+ )
31
+
32
+ // llmConfiguredClient 直连使用的 HTTP 客户端。超时按条目/调用方取,这里只兜底。
33
+ func llmConfiguredClient() *http.Client {
34
+ return &http.Client{Timeout: 0} // 超时统一走 ctx(条目 timeout / CLI -t),避免两套超时打架
35
+ }
36
+
37
+ // llmConfiguredTimeout 解析本次调用的超时:调用方 req.Timeout > 条目 timeout(秒)> 引擎默认。
38
+ func llmConfiguredTimeout(m LLMConfigModel, req Request) time.Duration {
39
+ if req.Timeout > 0 {
40
+ return req.Timeout
41
+ }
42
+ if m.Timeout > 0 {
43
+ return time.Duration(m.Timeout) * time.Second
44
+ }
45
+ return DefaultLLMTimeout
46
+ }
47
+
48
+ // buildConfiguredBody 组装请求体。stream 由调用方决定。
49
+ func buildConfiguredBody(m LLMConfigModel, req Request, stream bool) (map[string]any, error) {
50
+ msgs := make([]map[string]any, 0, len(req.Messages)+1)
51
+ if s := strings.TrimSpace(req.SystemPrompt); s != "" {
52
+ msgs = append(msgs, map[string]any{"role": "system", "content": s})
53
+ }
54
+ for _, msg := range req.Messages {
55
+ if strings.TrimSpace(msg.Content) == "" {
56
+ continue
57
+ }
58
+ role := strings.TrimSpace(msg.Role)
59
+ if role == "" {
60
+ role = "user"
61
+ }
62
+ msgs = append(msgs, map[string]any{"role": role, "content": msg.Content})
63
+ }
64
+ if len(msgs) == 0 {
65
+ return nil, fmt.Errorf("llm: empty prompt")
66
+ }
67
+
68
+ // 附件(截图)→ OpenAI 兼容的多模态 content parts,挂到**最后一条 user 消息**上
69
+ //(就是本次提问)。实测:MiniMax-M3 直连收 image_url(data URL) 能正确识图
70
+ //(2026-09-17:纯红 64x64 PNG → 答「红色」)。
71
+ if len(req.Attachments) > 0 {
72
+ if err := attachToLastUserMessage(msgs, req.Attachments); err != nil {
73
+ return nil, err
74
+ }
75
+ }
76
+
77
+ body := map[string]any{}
78
+ for k, v := range m.ExtraBody { // extraBody 先铺(变体差异常在这)
79
+ body[k] = v
80
+ }
81
+ body["model"] = m.WireModel()
82
+ body["messages"] = msgs
83
+ body["stream"] = stream
84
+
85
+ if req.MaxTokens > 0 {
86
+ body["max_tokens"] = req.MaxTokens
87
+ } else if m.MaxOutputTokens > 0 {
88
+ body["max_tokens"] = m.MaxOutputTokens
89
+ }
90
+ if req.Temperature != nil {
91
+ body["temperature"] = *req.Temperature
92
+ } else if m.Temperature != nil {
93
+ body["temperature"] = *m.Temperature
94
+ }
95
+ return body, nil
96
+ }
97
+
98
+ // attachToLastUserMessage 把附件塞进最后一条 user 消息的 content:
99
+ //
100
+ // 图片 → {"type":"image_url","image_url":{"url":"data:<mime>;base64,<...>"}}(在前)
101
+ // 其他 → 路径清单并入文本(协议里没有通用文件类型)
102
+ //
103
+ // 找不到 user 消息(理论上不会:CLI 层总要给提示词)时返回错误,不静默丢附件。
104
+ func attachToLastUserMessage(msgs []map[string]any, atts []Attachment) error {
105
+ idx := -1
106
+ for i := len(msgs) - 1; i >= 0; i-- {
107
+ if msgs[i]["role"] == "user" {
108
+ idx = i
109
+ break
110
+ }
111
+ }
112
+ if idx < 0 {
113
+ return fmt.Errorf("llm: 附件需要一条 user 消息承载,本次没有")
114
+ }
115
+ text, _ := msgs[idx]["content"].(string)
116
+
117
+ parts := make([]map[string]any, 0, len(atts)+1)
118
+ var rest []Attachment
119
+ for _, a := range atts {
120
+ if !a.IsImage() {
121
+ rest = append(rest, a)
122
+ continue
123
+ }
124
+ data, err := readAttachmentBase64(a)
125
+ if err != nil {
126
+ return err
127
+ }
128
+ mime := a.MIME
129
+ if mime == "" {
130
+ mime = "image/png"
131
+ }
132
+ parts = append(parts, map[string]any{
133
+ "type": "image_url",
134
+ "image_url": map[string]any{"url": "data:" + mime + ";base64," + data},
135
+ })
136
+ }
137
+ parts = append(parts, map[string]any{"type": "text", "text": appendAttachmentSection(text, rest)})
138
+ msgs[idx]["content"] = parts
139
+ return nil
140
+ }
141
+
142
+ // doConfiguredRequest 发一次请求;非 2xx 统一报 `HTTP <code>: <片段>`。
143
+ func doConfiguredRequest(ctx context.Context, m LLMConfigModel, req Request, body map[string]any) (*http.Response, error) {
144
+ ep := m.Endpoint()
145
+ if ep == "" {
146
+ return nil, fmt.Errorf("llm: model %q has no url in models.json", m.ID)
147
+ }
148
+ raw, err := json.Marshal(body)
149
+ if err != nil {
150
+ return nil, err
151
+ }
152
+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, ep, bytes.NewReader(raw))
153
+ if err != nil {
154
+ return nil, err
155
+ }
156
+ httpReq.Header.Set("Content-Type", "application/json")
157
+ if m.APIKey != "" {
158
+ httpReq.Header.Set("Authorization", "Bearer "+m.APIKey)
159
+ }
160
+ if stream, _ := body["stream"].(bool); stream {
161
+ httpReq.Header.Set("Accept", "text/event-stream")
162
+ }
163
+ resp, err := llmConfiguredClient().Do(httpReq)
164
+ if err != nil {
165
+ return nil, err // 网络类错误原样返回:isRetryable 靠 "connection refused" 等子串判定
166
+ }
167
+ if resp.StatusCode < 200 || resp.StatusCode > 299 {
168
+ defer resp.Body.Close()
169
+ snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
170
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, compactSnippet(string(snippet)))
171
+ }
172
+ return resp, nil
173
+ }
174
+
175
+ // compactSnippet 把错误正文压成单行短串(日志/envelope 里可读)。
176
+ func compactSnippet(s string) string {
177
+ s = strings.Join(strings.Fields(s), " ")
178
+ if len(s) > 300 {
179
+ s = s[:300] + "…"
180
+ }
181
+ return s
182
+ }
183
+
184
+ // checkConfiguredSession 直连没有会话库:显式要求续接时明确报错,不静默丢上下文。
185
+ func checkConfiguredSession(m LLMConfigModel, req Request) error {
186
+ if strings.TrimSpace(req.SessionID) != "" || req.Continue {
187
+ return fmt.Errorf("llm 配置文件模型 %q 不支持会话续接(直连无会话库):请去掉 --session/-c,或改用 llm CLI 注册的模型", m.ID)
188
+ }
189
+ return nil
190
+ }
191
+
192
+ // completeConfigured 非流式直连。返回体与 llm CLI 路径同形(含 token 计数)。
193
+ func (e *LLMEngine) completeConfigured(ctx context.Context, m LLMConfigModel, req Request) (Response, error) {
194
+ start := time.Now()
195
+ if err := checkConfiguredSession(m, req); err != nil {
196
+ return Response{}, err
197
+ }
198
+ body, err := buildConfiguredBody(m, req, false)
199
+ if err != nil {
200
+ return Response{}, err
201
+ }
202
+ cctx, cancel := context.WithTimeout(ctx, llmConfiguredTimeout(m, req))
203
+ defer cancel()
204
+ resp, err := doConfiguredRequest(cctx, m, req, body)
205
+ if err != nil {
206
+ return Response{}, err
207
+ }
208
+ defer resp.Body.Close()
209
+ raw, err := io.ReadAll(resp.Body)
210
+ if err != nil {
211
+ return Response{}, err
212
+ }
213
+ out, err := parseConfiguredChat(raw)
214
+ if err != nil {
215
+ return Response{}, err
216
+ }
217
+
218
+ text, thinking := splitThinkingTags(out.Content)
219
+ if strings.TrimSpace(text) == "" {
220
+ // 全是思维链(模型把答案写进了 reasoning)→ 原样返回,别丢内容
221
+ text = out.Content
222
+ }
223
+ if strings.TrimSpace(text) == "" && strings.TrimSpace(out.Reasoning) != "" {
224
+ text = out.Reasoning
225
+ }
226
+ if req.JSONSchema != nil {
227
+ if extracted, ok := extractJSONObjectStrict(text, req.JSONSchema); ok {
228
+ text = extracted
229
+ }
230
+ }
231
+ _ = thinking // 非流式路径不单独回传思考(与 llm CLI 路径一致)
232
+ return Response{
233
+ Engine: e.Name(),
234
+ Text: strings.TrimSpace(text),
235
+ // Model 回填 **配置里的 id**(调用标识),不是线上真实名:
236
+ // 真实模型名会重复(MiniMax-M3 与 minimax-nothink 同名),只有 id 能唯一指认
237
+ // 「这一轮到底用的哪条配置」;真实名按 id 查配置文件即可。
238
+ Model: m.ID,
239
+ Latency: time.Since(start),
240
+ InputTokens: out.PromptTokens,
241
+ OutputTokens: out.CompletionTokens,
242
+ TotalTokens: out.PromptTokens + out.CompletionTokens,
243
+ }, nil
244
+ }
245
+
246
+ // configuredChat 非流式响应里我们需要的部分。
247
+ type configuredChat struct {
248
+ Content string
249
+ Reasoning string
250
+ PromptTokens int
251
+ CompletionTokens int
252
+ }
253
+
254
+ func parseConfiguredChat(raw []byte) (configuredChat, error) {
255
+ var doc struct {
256
+ Choices []struct {
257
+ Message struct {
258
+ Content string `json:"content"`
259
+ ReasoningContent string `json:"reasoning_content"`
260
+ } `json:"message"`
261
+ Text string `json:"text"`
262
+ } `json:"choices"`
263
+ Usage struct {
264
+ PromptTokens int `json:"prompt_tokens"`
265
+ CompletionTokens int `json:"completion_tokens"`
266
+ } `json:"usage"`
267
+ Error *struct {
268
+ Message string `json:"message"`
269
+ } `json:"error"`
270
+ }
271
+ if err := json.Unmarshal(raw, &doc); err != nil {
272
+ return configuredChat{}, fmt.Errorf("llm: 响应不是 JSON(%w): %s", err, compactSnippet(string(raw)))
273
+ }
274
+ if doc.Error != nil && doc.Error.Message != "" {
275
+ return configuredChat{}, fmt.Errorf("llm: %s", doc.Error.Message)
276
+ }
277
+ if len(doc.Choices) == 0 {
278
+ return configuredChat{}, fmt.Errorf("llm: 响应没有 choices: %s", compactSnippet(string(raw)))
279
+ }
280
+ c := doc.Choices[0]
281
+ content := c.Message.Content
282
+ if content == "" {
283
+ content = c.Text // 少数端点(completion 风格)用 text
284
+ }
285
+ return configuredChat{
286
+ Content: content,
287
+ Reasoning: c.Message.ReasoningContent,
288
+ PromptTokens: doc.Usage.PromptTokens,
289
+ CompletionTokens: doc.Usage.CompletionTokens,
290
+ }, nil
291
+ }
292
+
293
+ // streamConfigured 流式直连(SSE)。逐块 delta 经 tagGuard 防标签被切块后,
294
+ // 交给与 CLI 路径同一个 thinkSplitter 分流 thinking/text。
295
+ func (e *LLMEngine) streamConfigured(ctx context.Context, m LLMConfigModel, req Request, onEvent func(StreamEvent)) (StreamResult, error) {
296
+ start := time.Now()
297
+ if err := checkConfiguredSession(m, req); err != nil {
298
+ return StreamResult{}, err
299
+ }
300
+ body, err := buildConfiguredBody(m, req, true)
301
+ if err != nil {
302
+ return StreamResult{}, err
303
+ }
304
+ cctx, cancel := context.WithTimeout(ctx, llmConfiguredTimeout(m, req))
305
+ defer cancel()
306
+ resp, err := doConfiguredRequest(cctx, m, req, body)
307
+ if err != nil {
308
+ return StreamResult{}, err
309
+ }
310
+ defer resp.Body.Close()
311
+
312
+ sp := &thinkSplitter{OnEvent: onEvent}
313
+ guard := &tagGuard{}
314
+ var usage struct {
315
+ PromptTokens int
316
+ CompletionTokens int
317
+ }
318
+
319
+ sc := bufio.NewScanner(resp.Body)
320
+ sc.Buffer(make([]byte, 0, 64<<10), 16<<20) // 单行上限 16MB(与 runStreamCLI 同口径)
321
+ for sc.Scan() {
322
+ line := strings.TrimSpace(sc.Text())
323
+ if line == "" || strings.HasPrefix(line, ":") {
324
+ continue // 心跳/注释行
325
+ }
326
+ if !strings.HasPrefix(line, "data:") {
327
+ continue // event:/id: 等其它 SSE 字段
328
+ }
329
+ payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
330
+ if payload == "[DONE]" {
331
+ break
332
+ }
333
+ var chunk struct {
334
+ Choices []struct {
335
+ Delta struct {
336
+ Content string `json:"content"`
337
+ ReasoningContent string `json:"reasoning_content"`
338
+ } `json:"delta"`
339
+ } `json:"choices"`
340
+ Usage *struct {
341
+ PromptTokens int `json:"prompt_tokens"`
342
+ CompletionTokens int `json:"completion_tokens"`
343
+ } `json:"usage"`
344
+ }
345
+ if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
346
+ continue // 非 JSON 片段(有些网关会插日志行)→ 跳过,不打断整条流
347
+ }
348
+ if chunk.Usage != nil {
349
+ usage.PromptTokens = chunk.Usage.PromptTokens
350
+ usage.CompletionTokens = chunk.Usage.CompletionTokens
351
+ }
352
+ if len(chunk.Choices) == 0 {
353
+ continue
354
+ }
355
+ d := chunk.Choices[0].Delta
356
+ if d.ReasoningContent != "" {
357
+ sp.feedThinking(d.ReasoningContent) // 独立 reasoning 通道:无标签,直接进 thinking
358
+ }
359
+ if d.Content != "" {
360
+ sp.feed(guard.push(d.Content))
361
+ }
362
+ }
363
+ if err := sc.Err(); err != nil {
364
+ return StreamResult{}, err
365
+ }
366
+ sp.feed(guard.flush())
367
+
368
+ text, thinking := sp.finish()
369
+ if text == "" {
370
+ return StreamResult{}, fmt.Errorf("llm: 流式响应没有正文(配置条目 %s → %s)", m.ID, m.WireModel())
371
+ }
372
+ return StreamResult{
373
+ Response: Response{
374
+ Engine: e.Name(),
375
+ Text: text,
376
+ Model: m.ID, // id(调用标识)—— 见 completeConfigured 里的说明
377
+ Latency: time.Since(start),
378
+ InputTokens: usage.PromptTokens,
379
+ OutputTokens: usage.CompletionTokens,
380
+ TotalTokens: usage.PromptTokens + usage.CompletionTokens,
381
+ },
382
+ Thinking: thinking,
383
+ }, nil
384
+ }
385
+
386
+ // tagGuard 防「思维链标签被 SSE 分块切开」:把尾部可能是 `<think>` / `</think>`
387
+ // 前缀的那几个字符暂扣,等下一块补齐再一起投喂状态机。
388
+ // (llm CLI 路径靠「Scanner 按行交付、标签不含换行」天然免疫,直连没有这层保证。)
389
+ type tagGuard struct {
390
+ buf string
391
+ }
392
+
393
+ // feedThinking 把一段**已知属于思维链**的文本送进 thinking 通道。
394
+ // 独立 reasoning 通道(如 MiniMax 的 delta.reasoning_content)没有标签,
395
+ // 不能走 feed(那会被当正文)—— 见 streamConfigured。
396
+ func (sp *thinkSplitter) feedThinking(s string) {
397
+ sp.emitThinking(s)
398
+ }
399
+
400
+ func (g *tagGuard) push(s string) string {
401
+ if s == "" {
402
+ return ""
403
+ }
404
+ g.buf += s
405
+ keep := prefixKeep(g.buf, thinkOpenTag)
406
+ if k := prefixKeep(g.buf, thinkCloseTag); k > keep {
407
+ keep = k
408
+ }
409
+ if keep == 0 {
410
+ out := g.buf
411
+ g.buf = ""
412
+ return out
413
+ }
414
+ out := g.buf[:len(g.buf)-keep]
415
+ g.buf = g.buf[len(g.buf)-keep:]
416
+ return out
417
+ }
418
+
419
+ func (g *tagGuard) flush() string {
420
+ out := g.buf
421
+ g.buf = ""
422
+ return out
423
+ }
424
+
425
+ // prefixKeep 返回 buf 尾部最多保留多少个字符才「可能是 tag 的前缀」。
426
+ func prefixKeep(buf, tag string) int {
427
+ max := len(tag) - 1
428
+ if max > len(buf) {
429
+ max = len(buf)
430
+ }
431
+ for n := max; n > 0; n-- {
432
+ if strings.HasPrefix(tag, buf[len(buf)-n:]) {
433
+ return n
434
+ }
435
+ }
436
+ return 0
437
+ }