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.
- package/LICENSE +21 -0
- package/README.md +392 -0
- package/cmd/magic-agent/main.go +12 -0
- package/cmd/magic-client/main.go +144 -0
- package/go.mod +11 -0
- package/go.sum +12 -0
- package/internal/agent/arkclaw.go +587 -0
- package/internal/agent/arkclaw_stream.go +312 -0
- package/internal/agent/arkclaw_stream_test.go +428 -0
- package/internal/agent/arkclaw_test.go +827 -0
- package/internal/agent/ask.go +694 -0
- package/internal/agent/ask_test.go +587 -0
- package/internal/agent/attachments.go +200 -0
- package/internal/agent/attachments_test.go +481 -0
- package/internal/agent/capability.go +154 -0
- package/internal/agent/capability_test.go +190 -0
- package/internal/agent/claude.go +406 -0
- package/internal/agent/codebuddy.go +1044 -0
- package/internal/agent/codebuddy_account_test.go +128 -0
- package/internal/agent/codebuddy_gateway.go +479 -0
- package/internal/agent/codebuddy_gateway_stream.go +358 -0
- package/internal/agent/codebuddy_gateway_test.go +673 -0
- package/internal/agent/codex.go +599 -0
- package/internal/agent/codex_home.go +206 -0
- package/internal/agent/codex_home_test.go +130 -0
- package/internal/agent/codex_stream_test.go +97 -0
- package/internal/agent/codex_test.go +280 -0
- package/internal/agent/custom_models.go +197 -0
- package/internal/agent/custom_models_test.go +283 -0
- package/internal/agent/dsh.go +754 -0
- package/internal/agent/dsh_sdk.go +679 -0
- package/internal/agent/dsh_sdk_test.go +726 -0
- package/internal/agent/dsh_test.go +759 -0
- package/internal/agent/engine.go +469 -0
- package/internal/agent/engine_base.go +497 -0
- package/internal/agent/engine_base_test.go +308 -0
- package/internal/agent/engine_test.go +858 -0
- package/internal/agent/env.go +88 -0
- package/internal/agent/env_test.go +106 -0
- package/internal/agent/jsonextract.go +128 -0
- package/internal/agent/jsonextract_test.go +110 -0
- package/internal/agent/keepalive_test.go +216 -0
- package/internal/agent/llmengine.go +470 -0
- package/internal/agent/llmengine_test.go +551 -0
- package/internal/agent/llmhttp.go +437 -0
- package/internal/agent/llmhttp_test.go +304 -0
- package/internal/agent/llmmodels.go +205 -0
- package/internal/agent/login.go +27 -0
- package/internal/agent/main_test.go +23 -0
- package/internal/agent/models.go +686 -0
- package/internal/agent/models_test.go +877 -0
- package/internal/agent/openclaw.go +543 -0
- package/internal/agent/openclaw_acp.go +949 -0
- package/internal/agent/openclaw_acp_test.go +406 -0
- package/internal/agent/openclaw_test.go +451 -0
- package/internal/agent/output.go +191 -0
- package/internal/agent/permission.go +362 -0
- package/internal/agent/permission_test.go +526 -0
- package/internal/agent/prompt.go +65 -0
- package/internal/agent/runcmd.go +259 -0
- package/internal/agent/runcmd_test.go +81 -0
- package/internal/agent/runcmd_unix.go +56 -0
- package/internal/agent/runcmd_windows.go +66 -0
- package/internal/agent/runner.go +180 -0
- package/internal/agent/runner_test.go +263 -0
- package/internal/agent/spawnhook_test.go +96 -0
- package/internal/agent/stream.go +882 -0
- package/internal/agent/stream_test.go +565 -0
- package/internal/agent/streamjson.go +185 -0
- package/internal/agent/tags.go +52 -0
- package/internal/agent/toolkind.go +96 -0
- package/internal/agent/toolkind_test.go +70 -0
- package/internal/agent/tools_suffix_test.go +261 -0
- package/internal/agent/trae.go +342 -0
- package/internal/agent/workspace_test.go +166 -0
- package/internal/boundary/boundary_test.go +165 -0
- package/internal/cli/ask.go +1113 -0
- package/internal/cli/ask_test.go +1088 -0
- package/internal/cli/attach_test.go +239 -0
- package/internal/cli/contract_test.go +157 -0
- package/internal/cli/control.go +149 -0
- package/internal/cli/control_test.go +192 -0
- package/internal/cli/engines_test.go +205 -0
- package/internal/cli/events.go +198 -0
- package/internal/cli/events_test.go +166 -0
- package/internal/cli/keepalive_test.go +391 -0
- package/internal/cli/login.go +78 -0
- package/internal/cli/permission_test.go +295 -0
- package/internal/cli/prompt_source_test.go +222 -0
- package/internal/cli/root.go +752 -0
- package/internal/cli/session.go +486 -0
- package/internal/cli/session_test.go +202 -0
- package/internal/cli/sessionlog.go +325 -0
- package/internal/cli/sessionlog_command_test.go +204 -0
- package/internal/cli/sessionlog_test.go +262 -0
- package/internal/cli/sessionlog_user_test.go +129 -0
- package/internal/cli/toolkind_wire_test.go +84 -0
- package/internal/cli/workspace_test.go +188 -0
- package/internal/client/cors_test.go +136 -0
- package/internal/client/lan.go +68 -0
- package/internal/client/lan_test.go +53 -0
- package/internal/client/pair.go +163 -0
- package/internal/client/pair_test.go +109 -0
- package/internal/client/profile.go +184 -0
- package/internal/client/service.go +888 -0
- package/internal/client/service_test.go +654 -0
- package/internal/client/session_events_test.go +186 -0
- package/internal/client/session_messages_test.go +150 -0
- package/internal/config/config.go +439 -0
- package/internal/config/config_test.go +448 -0
- package/internal/session/session.go +447 -0
- package/internal/session/session_append.go +241 -0
- package/internal/session/session_append_test.go +161 -0
- package/internal/session/session_kill_unix_test.go +90 -0
- package/internal/session/session_mark_test.go +95 -0
- package/internal/session/session_test.go +237 -0
- package/internal/session/sessionlog.go +128 -0
- package/internal/session/sessionlog_read_test.go +157 -0
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/install.js +121 -0
- package/npm/lib/platform.js +78 -0
- package/package.json +36 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
// arkclaw_stream.go - ArkClaw 的**流式**通道:A2A 官方的 `message/stream`(SSE 事件流)。
|
|
4
|
+
//
|
|
5
|
+
// 为什么不是 WebSocket(2026-09-22 实测结论,别再走一遍):
|
|
6
|
+
// - 向同一端点发 `Upgrade: websocket` 握手,网关**不回 101**,照常当普通 POST 处理
|
|
7
|
+
// (HTTP 200 + application/json;server: istio-envoy / x-powered-by: Express);
|
|
8
|
+
// - A2A 核心规范只定义三种传输(JSON-RPC over HTTP(S) / gRPC / HTTP+JSON),流式统一走
|
|
9
|
+
// SSE,WebSocket 属「自定义协议绑定」,要**服务端**另行实现;
|
|
10
|
+
// - 火山引擎 API 网关(*.volceapi.com)的协议枚举里没有 WebSocket API 类型,也没有
|
|
11
|
+
// 「把已有 HTTP API 升级成 WS」的能力 —— 这条链路本身托不住 WS。
|
|
12
|
+
// 结论:客户端单方面把 URL 换成 wss:// 什么都拿不到,流式只能走 SSE。
|
|
13
|
+
//
|
|
14
|
+
// 通道形态:POST message/stream + `accept: text/event-stream`,响应是 SSE,逐帧 `data: {...}`,
|
|
15
|
+
// 每帧是一个完整的 JSON-RPC 2.0 Response(见 arkclaw.go 文件头的 envelope 说明)。
|
|
16
|
+
//
|
|
17
|
+
// 事件映射(实测 2026-09-22,真实网关):
|
|
18
|
+
//
|
|
19
|
+
// status.state=working/submitted → 不发事件(网关没有增量可发),只刷新 contextId
|
|
20
|
+
// status.state=completed → KindText(正文)
|
|
21
|
+
// status.state=failed/canceled/… → 直接报错(与 Complete 同一条错误路径)
|
|
22
|
+
// 中间帧若挂 artifact 正文 → 按增量发(本网关当前不发,留着以免将来漏掉)
|
|
23
|
+
// `:` 注释行 / 空行 / event: 等字段 → 跳过(SSE 分隔与心跳)
|
|
24
|
+
//
|
|
25
|
+
// ⚠️ 本网关的流式**不是逐字增量**,这是协议事实不是解析缺陷:实测一次 200 字生成只收到 2 帧
|
|
26
|
+
// —— 0.20s 的 working(空正文)与 12.42s 的 completed(654 字**一次性**到达)。
|
|
27
|
+
// 所以这里的语义是「任务受理帧 + 正文整段到达」,不是打字机式流。要逐字得 claw 侧在生成
|
|
28
|
+
// 过程中多发中间 status-update / artifact-update,属服务端改动(与传输协议无关)。
|
|
29
|
+
// 收益是实的:SSE 连接由服务端持续持有并回帧,长任务不再靠单次 HTTP 空等(也就不会被
|
|
30
|
+
// 中间代理的空闲超时掐断),且 claw 侧将来吐增量时客户端零改动即可接住。
|
|
31
|
+
//
|
|
32
|
+
// 与非流式(Complete)的关系:同一条请求构造路径(arkClawBuildRequest)+ 同一套错误判定
|
|
33
|
+
// (arkClawParseResponse),差别只在 method(message/stream vs message/send)与响应读取方式
|
|
34
|
+
// (SSE 逐帧 vs 一次性 body)。网关没按 SSE 回时**如实退回一次性响应**(见 Stream),
|
|
35
|
+
// 不拿「没有 completed 帧」去糊弄调用方。
|
|
36
|
+
//
|
|
37
|
+
// 不实现的东西:tasks/resubscribe(断线重连)、Request.Append(A2A 无「持续喂消息」语义,
|
|
38
|
+
// 见 AppendSupportOf)、-r 重试(增量已实时发出,重放会重复消费,与其余引擎一致)。
|
|
39
|
+
|
|
40
|
+
import (
|
|
41
|
+
"bufio"
|
|
42
|
+
"bytes"
|
|
43
|
+
"context"
|
|
44
|
+
"encoding/json"
|
|
45
|
+
"fmt"
|
|
46
|
+
"io"
|
|
47
|
+
"net/http"
|
|
48
|
+
"strings"
|
|
49
|
+
"time"
|
|
50
|
+
|
|
51
|
+
"github.com/darren/magic-agent/internal/config"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
// arkClawSSEMaxLine 单帧上限:completed 帧把**整段正文**放在一行 data 里,留足余量。
|
|
55
|
+
const arkClawSSEMaxLine = 16 << 20 // 16 MiB
|
|
56
|
+
|
|
57
|
+
// arkClawStreamState 一次 message/stream 的累积状态。
|
|
58
|
+
type arkClawStreamState struct {
|
|
59
|
+
onEvent func(StreamEvent)
|
|
60
|
+
|
|
61
|
+
// emitted 已作为增量发出的正文。completed 帧会把整段正文再给一次(实测 status.message
|
|
62
|
+
// 与 artifacts 都带同一份),靠它做前缀去重,避免调用方看到两份。
|
|
63
|
+
emitted strings.Builder
|
|
64
|
+
// final 权威正文(completed 帧给的整段);缺省回落到 emitted。
|
|
65
|
+
final string
|
|
66
|
+
|
|
67
|
+
completed bool // 是否见过 completed 帧
|
|
68
|
+
state string // 最近一次 status.state(错误信息用)
|
|
69
|
+
sessionID string // contextId(会话锚点,逐帧刷新)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Stream 实现 Streamer:走 A2A 官方 SSE 通道(见文件头)。
|
|
73
|
+
func (e *ArkClawEngine) Stream(ctx context.Context, req Request, onEvent func(StreamEvent)) (StreamResult, error) {
|
|
74
|
+
start := time.Now()
|
|
75
|
+
|
|
76
|
+
endpoint, key, clawID, err := e.endpoint()
|
|
77
|
+
if err != nil {
|
|
78
|
+
return StreamResult{}, fmt.Errorf("arkclaw: %w", err)
|
|
79
|
+
}
|
|
80
|
+
if endpoint == "" || key == "" || clawID == "" {
|
|
81
|
+
return StreamResult{}, fmt.Errorf("arkclaw: 未配置(需要 url / key / claw_id);请在 %s 的 \"arkclaw\" 节补齐,或设置 %s / %s / %s",
|
|
82
|
+
e.configHint(), config.EnvArkClawURL, config.EnvArkClawKey, config.EnvArkClawClawID)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 与非流式同一条契约:A2A 只提供「按 contextId 续接」,没有「查询最近上下文」的接口。
|
|
86
|
+
if req.Continue && req.SessionID == "" {
|
|
87
|
+
return StreamResult{}, fmt.Errorf("arkclaw: 不支持 continue(A2A 无「查询最近上下文」接口),请用 --session <context_id> 显式续接")
|
|
88
|
+
}
|
|
89
|
+
// 模型由 clawId 绑定,-m 在 arkclaw 上无作用(与 Complete 同一告警)。
|
|
90
|
+
if req.Model != "" {
|
|
91
|
+
fmt.Fprintf(stderr, " ⚠ arkclaw: --model %s 不生效(实测网关按 clawId 绑死为 kimi-k2.6),要换模型请改配置 claw_id\n", req.Model)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
prompt := FlattenPrompt(req.SystemPrompt, req.Messages, false)
|
|
95
|
+
if prompt == "" {
|
|
96
|
+
return StreamResult{}, fmt.Errorf("arkclaw: empty prompt")
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
target, err := arkClawEndpointURL(endpoint, key, clawID)
|
|
100
|
+
if err != nil {
|
|
101
|
+
return StreamResult{}, fmt.Errorf("arkclaw: 端点无效: %w", err)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
body, err := func() ([]byte, error) {
|
|
105
|
+
reqObj, berr := arkClawBuildRequest(arkClawMethodStream, prompt, req.SessionID, req.Attachments)
|
|
106
|
+
if berr != nil {
|
|
107
|
+
return nil, berr
|
|
108
|
+
}
|
|
109
|
+
return json.Marshal(reqObj)
|
|
110
|
+
}()
|
|
111
|
+
if err != nil {
|
|
112
|
+
return StreamResult{}, fmt.Errorf("arkclaw: 构造请求失败: %w", err)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
timeout := req.Timeout
|
|
116
|
+
if timeout <= 0 {
|
|
117
|
+
timeout = e.Timeout
|
|
118
|
+
}
|
|
119
|
+
if timeout <= 0 {
|
|
120
|
+
timeout = DefaultArkClawTimeout
|
|
121
|
+
}
|
|
122
|
+
httpCtx, cancel := context.WithTimeout(ctx, timeout)
|
|
123
|
+
defer cancel()
|
|
124
|
+
|
|
125
|
+
httpReq, err := http.NewRequestWithContext(httpCtx, http.MethodPost, target, bytes.NewReader(body))
|
|
126
|
+
if err != nil {
|
|
127
|
+
return StreamResult{}, fmt.Errorf("arkclaw: 构造 HTTP 请求失败: %w", err)
|
|
128
|
+
}
|
|
129
|
+
httpReq.Header.Set("content-type", "application/json")
|
|
130
|
+
httpReq.Header.Set("accept", "text/event-stream")
|
|
131
|
+
|
|
132
|
+
client := e.HTTPClient
|
|
133
|
+
if client == nil {
|
|
134
|
+
client = &http.Client{}
|
|
135
|
+
}
|
|
136
|
+
httpResp, err := client.Do(httpReq)
|
|
137
|
+
if err != nil {
|
|
138
|
+
return StreamResult{}, fmt.Errorf("arkclaw: %w", err)
|
|
139
|
+
}
|
|
140
|
+
defer httpResp.Body.Close()
|
|
141
|
+
|
|
142
|
+
// 网关没按 SSE 回(老网关 / message/stream 不认):退回一次性响应,
|
|
143
|
+
// 把正文补发成一条 text 增量并在 stderr 说明 —— 「装了就能用」不该被流式通道打断。
|
|
144
|
+
if !arkClawIsEventStream(httpResp.Header.Get("content-type")) {
|
|
145
|
+
raw, rerr := io.ReadAll(io.LimitReader(httpResp.Body, maxArkClawBody))
|
|
146
|
+
if rerr != nil {
|
|
147
|
+
return StreamResult{}, fmt.Errorf("arkclaw: 读取响应失败: %w", rerr)
|
|
148
|
+
}
|
|
149
|
+
resp, perr := arkClawParseResponse(httpResp.StatusCode, raw)
|
|
150
|
+
resp.Latency, resp.Engine = time.Since(start), e.Name()
|
|
151
|
+
if resp.SessionID == "" {
|
|
152
|
+
resp.SessionID = req.SessionID
|
|
153
|
+
}
|
|
154
|
+
if perr != nil {
|
|
155
|
+
return StreamResult{Response: resp}, perr
|
|
156
|
+
}
|
|
157
|
+
fmt.Fprintln(stderr, "magic-agent: arkclaw 网关未按 SSE 回(message/stream 不可用?),本轮按一次性响应处理(无增量)")
|
|
158
|
+
if onEvent != nil && resp.Text != "" {
|
|
159
|
+
onEvent(StreamEvent{Kind: KindText, Text: resp.Text})
|
|
160
|
+
}
|
|
161
|
+
return StreamResult{Response: resp}, nil
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
st := &arkClawStreamState{onEvent: onEvent}
|
|
165
|
+
sc := bufio.NewScanner(httpResp.Body)
|
|
166
|
+
sc.Buffer(make([]byte, 0, 64*1024), arkClawSSEMaxLine)
|
|
167
|
+
for sc.Scan() {
|
|
168
|
+
payload, ok := arkClawSSEPayload(sc.Text())
|
|
169
|
+
if !ok {
|
|
170
|
+
continue // 空行 / ":" 心跳 / event: 等字段
|
|
171
|
+
}
|
|
172
|
+
done, herr := st.handleFrame([]byte(payload), req.JSONSchema)
|
|
173
|
+
if herr != nil {
|
|
174
|
+
return st.result(e, req, start), herr
|
|
175
|
+
}
|
|
176
|
+
if done {
|
|
177
|
+
break // 终态帧(completed / failed):网关随后即关流,不必再等 EOF
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if serr := sc.Err(); serr != nil {
|
|
181
|
+
// 超时/取消会在读 body 时爆出来(请求头早就到了,卡的是流)。
|
|
182
|
+
if cerr := httpCtx.Err(); cerr != nil {
|
|
183
|
+
return st.result(e, req, start), fmt.Errorf("arkclaw: 流式读取中断: %w", cerr)
|
|
184
|
+
}
|
|
185
|
+
return st.result(e, req, start), fmt.Errorf("arkclaw: 读取流式响应失败: %w", serr)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if !st.completed {
|
|
189
|
+
return st.result(e, req, start),
|
|
190
|
+
fmt.Errorf("arkclaw: 流式响应结束但没有 completed 帧(最后状态 %q)", st.state)
|
|
191
|
+
}
|
|
192
|
+
res := st.result(e, req, start)
|
|
193
|
+
if strings.TrimSpace(res.Text) == "" {
|
|
194
|
+
return res, fmt.Errorf("arkclaw: 网关返回空正文(流式)")
|
|
195
|
+
}
|
|
196
|
+
return res, nil
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// handleFrame 处理一帧 SSE 数据。返回 done=true 表示这帧是终态(可以停读)。
|
|
200
|
+
func (st *arkClawStreamState) handleFrame(raw []byte, schema *JSONSchema) (bool, error) {
|
|
201
|
+
env := arkClawEnvelope{}
|
|
202
|
+
if err := json.Unmarshal(raw, &env); err != nil {
|
|
203
|
+
return false, nil // 非 JSON 帧(心跳/日志混入):忽略,不打断整轮
|
|
204
|
+
}
|
|
205
|
+
if env.Error != nil {
|
|
206
|
+
return true, fmt.Errorf("arkclaw: JSON-RPC error %d: %s", env.Error.Code, arkClawCollapse(env.Error.Message))
|
|
207
|
+
}
|
|
208
|
+
if env.Result == nil {
|
|
209
|
+
return false, nil // 没有 result 的帧(通知/心跳):忽略
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
res := *env.Result
|
|
213
|
+
if id := strings.TrimSpace(res.ContextID); id != "" {
|
|
214
|
+
st.sessionID = id
|
|
215
|
+
}
|
|
216
|
+
state := strings.ToLower(strings.TrimSpace(res.Status.State))
|
|
217
|
+
st.state = state
|
|
218
|
+
|
|
219
|
+
switch state {
|
|
220
|
+
case "failed", "canceled", "cancelled", "rejected", "unknown":
|
|
221
|
+
detail := arkClawPickText(res)
|
|
222
|
+
if detail == "" {
|
|
223
|
+
detail = "(网关未给出正文)"
|
|
224
|
+
}
|
|
225
|
+
return true, fmt.Errorf("arkclaw: 任务终止(status.state=%s): %s",
|
|
226
|
+
res.Status.State, truncateStr(arkClawCollapse(detail), 300))
|
|
227
|
+
|
|
228
|
+
case "completed":
|
|
229
|
+
text := arkClawPickText(res)
|
|
230
|
+
// JSONSchema 后处理(与 Complete / llm / openclaw 同路径):在**发出之前**做,
|
|
231
|
+
// 保证调用方拿到的增量与收尾正文是同一份。
|
|
232
|
+
if schema != nil {
|
|
233
|
+
if extracted, ok := extractJSONObjectStrict(text, schema); ok {
|
|
234
|
+
text = extracted
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
st.final = text
|
|
238
|
+
st.completed = true
|
|
239
|
+
st.emitText(text)
|
|
240
|
+
return true, nil
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 中间态(working / submitted / 其它):本网关没有正文可发;若某些 claw 把增量挂在
|
|
244
|
+
// 中间帧的 artifact 上,这里如实按增量转发(completed 帧再来时靠前缀去重,不会重复)。
|
|
245
|
+
st.emitText(arkClawPickText(res))
|
|
246
|
+
return false, nil
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// emitText 发一条正文增量。
|
|
250
|
+
//
|
|
251
|
+
// 去重规则:completed 帧给的是**整段**正文,而已发出的增量是它的前缀时只发剩下的部分;
|
|
252
|
+
// 若对不上(既非空、又不是前缀),宁可不发 —— 正文以 StreamResult.Text 为准,
|
|
253
|
+
// 重复发会让调用方看到两份内容。
|
|
254
|
+
func (st *arkClawStreamState) emitText(text string) {
|
|
255
|
+
if text == "" {
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
if prev := st.emitted.String(); prev != "" {
|
|
259
|
+
if !strings.HasPrefix(text, prev) {
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
text = text[len(prev):]
|
|
263
|
+
}
|
|
264
|
+
if text == "" {
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
st.emitted.WriteString(text)
|
|
268
|
+
if st.onEvent != nil {
|
|
269
|
+
st.onEvent(StreamEvent{Kind: KindText, Text: text})
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// result 把累积状态收成 StreamResult(终态与错误路径共用)。
|
|
274
|
+
func (st *arkClawStreamState) result(e *ArkClawEngine, req Request, start time.Time) StreamResult {
|
|
275
|
+
text := st.final
|
|
276
|
+
if strings.TrimSpace(text) == "" {
|
|
277
|
+
text = st.emitted.String()
|
|
278
|
+
}
|
|
279
|
+
sid := st.sessionID
|
|
280
|
+
if sid == "" {
|
|
281
|
+
sid = req.SessionID // 网关没回 contextId 时沿用请求里的 id(与 Complete 一致)
|
|
282
|
+
}
|
|
283
|
+
return StreamResult{Response: Response{
|
|
284
|
+
Engine: e.Name(),
|
|
285
|
+
Text: text,
|
|
286
|
+
SessionID: sid,
|
|
287
|
+
Latency: time.Since(start),
|
|
288
|
+
Attempts: 1,
|
|
289
|
+
}}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// arkClawSSEPayload 从一行 SSE 文本里取出 data 载荷。
|
|
293
|
+
// ok=false 表示这行不是数据帧:空行(帧分隔)、":xxx"(注释/心跳)、event:/id:/retry: 等字段。
|
|
294
|
+
func arkClawSSEPayload(line string) (string, bool) {
|
|
295
|
+
line = strings.TrimSpace(line)
|
|
296
|
+
if line == "" || strings.HasPrefix(line, ":") {
|
|
297
|
+
return "", false
|
|
298
|
+
}
|
|
299
|
+
if !strings.HasPrefix(line, "data:") {
|
|
300
|
+
return "", false
|
|
301
|
+
}
|
|
302
|
+
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
|
303
|
+
if payload == "" || payload == "[DONE]" {
|
|
304
|
+
return "", false
|
|
305
|
+
}
|
|
306
|
+
return payload, true
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// arkClawIsEventStream 响应是否为 SSE(content-type 判断,容忍参数与大小写)。
|
|
310
|
+
func arkClawIsEventStream(contentType string) bool {
|
|
311
|
+
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(contentType)), "text/event-stream")
|
|
312
|
+
}
|