magic-agent 1.0.3 → 1.0.49
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/CLIENT-CONTRACT.md +254 -8
- package/README.md +48 -5
- package/client-ui/index.html +32 -0
- package/client-ui/pc/app.css +89 -1
- package/client-ui/pc/index.html +18 -8
- package/client-ui/pc/js/00-base.js +8 -30
- package/client-ui/pc/js/05-task-list-render.js +4 -1
- package/client-ui/pc/js/07-chat-stream-render.js +2 -85
- package/client-ui/pc/js/10-markdown.js +3 -171
- package/client-ui/pc/js/18-askbox.js +2 -176
- package/client-ui/pc/js/90-glue.js +1 -1
- package/client-ui/pc/js/91-workbench-stubs.js +4 -10
- package/client-ui/pc/js/93-rail.js +10 -3
- package/client-ui/shared/conv/01-dom.js +52 -0
- package/client-ui/shared/conv/02-media-kinds.js +34 -0
- package/client-ui/shared/conv/03-chat-message.js +1561 -0
- package/client-ui/shared/conv/06-ask-answer.js +377 -0
- package/client-ui/shared/conv/07-stream-delegation.js +158 -0
- package/client-ui/{pc/js → shared/conv}/09-stream-apply.js +121 -6
- package/client-ui/shared/conv/10-markdown-render.js +252 -0
- package/client-ui/shared/conv/18-askbox-markup.js +185 -0
- package/client-ui/{pc/js → shared/conv}/19-attachments.js +365 -21
- package/client-ui/shared/conv/27-conv-ledger.js +381 -0
- package/client-ui/{pc/js → shared/conv}/29-msg-variants.js +112 -3
- package/client-ui/shared/conv/README.md +257 -0
- package/client-ui/shared/conv/conv.css +1638 -0
- package/client-ui/shared/conv/dom-map.md +170 -0
- package/client-ui/shared/conv/manifest.json +44 -0
- package/client-ui/shared/conversation.css +368 -190
- package/client-ui/shared/conversation.js +1722 -788
- package/client-ui/shared/host-glue.js +143 -0
- package/client-ui/shared/transport-http.js +130 -6
- package/client-ui-mobile/index.html +2417 -103
- package/cmd/magic-client/clientctl.go +273 -0
- package/cmd/magic-client/main.go +71 -5
- package/go.mod +6 -3
- package/go.sum +4 -1
- package/internal/agent/arkclaw.go +3 -2
- package/internal/agent/arkclaw_stream.go +126 -8
- package/internal/agent/arkclaw_stream_test.go +97 -15
- package/internal/agent/arkclaw_test.go +3 -3
- package/internal/agent/attachments.go +18 -5
- package/internal/agent/attachments_test.go +7 -1
- package/internal/agent/capability_test.go +11 -1
- package/internal/agent/claude.go +96 -158
- package/internal/agent/codebuddy.go +108 -133
- package/internal/agent/codebuddy_gateway.go +8 -4
- package/internal/agent/codebuddy_gateway_stream.go +82 -2
- package/internal/agent/codebuddy_gateway_test.go +236 -1
- package/internal/agent/codebuddy_stream_test.go +262 -0
- package/internal/agent/codex.go +50 -9
- package/internal/agent/codex_stream_test.go +30 -0
- package/internal/agent/codex_test.go +24 -0
- package/internal/agent/custom_models.go +109 -3
- package/internal/agent/custom_models_test.go +74 -0
- package/internal/agent/dsh.go +464 -130
- package/internal/agent/dsh_resume.go +258 -0
- package/internal/agent/dsh_sdk.go +68 -17
- package/internal/agent/dsh_sdk_test.go +102 -2
- package/internal/agent/dsh_test.go +409 -36
- package/internal/agent/engine.go +26 -6
- package/internal/agent/engine_base.go +46 -2
- package/internal/agent/engine_base_test.go +29 -0
- package/internal/agent/engine_test.go +101 -0
- package/internal/agent/env.go +69 -1
- package/internal/agent/itemtracker.go +271 -0
- package/internal/agent/launch.go +192 -0
- package/internal/agent/launch_test.go +206 -0
- package/internal/agent/launch_unix.go +15 -0
- package/internal/agent/launch_windows.go +24 -0
- package/internal/agent/limitnotice.go +103 -0
- package/internal/agent/limitnotice_test.go +211 -0
- package/internal/agent/llmengine.go +2 -2
- package/internal/agent/models.go +3 -2
- package/internal/agent/openclaw.go +4 -3
- package/internal/agent/openclaw_acp.go +7 -0
- package/internal/agent/openclaw_test.go +2 -2
- package/internal/agent/permission.go +100 -11
- package/internal/agent/permission_test.go +100 -36
- package/internal/agent/runcmd.go +13 -3
- package/internal/agent/runner.go +7 -1
- package/internal/agent/runner_test.go +1 -1
- package/internal/agent/stream.go +777 -43
- package/internal/agent/stream_test.go +3 -2
- package/internal/agent/stream_timeline_test.go +905 -0
- package/internal/agent/streamjson.go +36 -2
- package/internal/agent/toolkind.go +20 -1
- package/internal/agent/toolkind_test.go +7 -1
- package/internal/agent/tools_suffix_test.go +10 -3
- package/internal/agent/trae.go +5 -3
- package/internal/agent/workspace_test.go +52 -0
- package/internal/cli/ask.go +260 -33
- package/internal/cli/ask_test.go +15 -11
- package/internal/cli/attach_test.go +32 -20
- package/internal/cli/clientcmd.go +156 -0
- package/internal/cli/events.go +53 -8
- package/internal/cli/events_test.go +230 -0
- package/internal/cli/keepalive_test.go +101 -6
- package/internal/cli/permission_test.go +5 -1
- package/internal/cli/root.go +174 -32
- package/internal/cli/session.go +175 -28
- package/internal/cli/session_test.go +144 -5
- package/internal/cli/sessionlog.go +385 -38
- package/internal/cli/sessionlog_test.go +708 -0
- package/internal/cli/sessionlog_user_test.go +142 -1
- package/internal/cli/sessiontitle.go +141 -0
- package/internal/client/browse.go +456 -0
- package/internal/client/browse_test.go +199 -0
- package/internal/client/cors_test.go +35 -0
- package/internal/client/daemon.go +525 -0
- package/internal/client/daemon_test.go +212 -0
- package/internal/client/detach_unix.go +19 -0
- package/internal/client/detach_windows.go +27 -0
- package/internal/client/pair.go +3 -3
- package/internal/client/plugins.go +376 -0
- package/internal/client/plugins_test.go +223 -0
- package/internal/client/proxy.go +374 -0
- package/internal/client/proxy_test.go +212 -0
- package/internal/client/service.go +1464 -48
- package/internal/client/service_test.go +649 -206
- package/internal/client/session_events_test.go +130 -0
- package/internal/client/wsproxy.go +113 -0
- package/internal/client/wsproxy_test.go +102 -0
- package/internal/session/delete.go +151 -0
- package/internal/session/delete_test.go +103 -0
- package/internal/session/session.go +155 -20
- package/internal/session/session_append.go +79 -21
- package/internal/session/session_test.go +124 -10
- package/internal/session/sessionlog.go +572 -6
- package/internal/session/sessionlog_read_test.go +410 -0
- package/internal/session/title.go +486 -0
- package/internal/session/title_test.go +298 -0
- package/npm/bin/magic-agent.js +101 -10
- package/npm/bin/magic-client.js +118 -0
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/npm/install.js +36 -13
- package/npm/lib/platform.js +27 -5
- package/package.json +8 -3
- package/client-ui/pc/js/03-chat-message.js +0 -880
- package/client-ui/pc/js/06-ask-answer.js +0 -204
- /package/client-ui/{pc/js → shared/conv}/08-ask-event-map.js +0 -0
- /package/client-ui/{pc → shared}/vendor/highlight.min.js +0 -0
- /package/client-ui/{pc → shared}/vendor/marked.min.js +0 -0
- /package/client-ui/{pc → shared}/vendor/purify.min.js +0 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
// clientctl.go - `magic-client start|stop|status`:把服务本身当后台常驻进程管起来。
|
|
4
|
+
//
|
|
5
|
+
// 为什么在插件侧而不是 core:`internal/boundary` 钉着「core 的依赖闭包里不许出现
|
|
6
|
+
// internal/client」(桌面 / 移动端是可选插件)。core 的 `magic-agent client …`
|
|
7
|
+
// 只负责转发到这里。
|
|
8
|
+
//
|
|
9
|
+
// 三件事都围绕同一份状态文件(默认 ~/.magic-client/client.json,0600):
|
|
10
|
+
// start 后台拉起 serve,等它报端口,落状态
|
|
11
|
+
// status 读状态 + 探活
|
|
12
|
+
// stop SIGTERM(5s 不退再 SIGKILL),清状态
|
|
13
|
+
|
|
14
|
+
import (
|
|
15
|
+
"encoding/json"
|
|
16
|
+
"flag"
|
|
17
|
+
"fmt"
|
|
18
|
+
"os"
|
|
19
|
+
"path/filepath"
|
|
20
|
+
"strings"
|
|
21
|
+
"time"
|
|
22
|
+
|
|
23
|
+
"github.com/darren/magic-agent/internal/client"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
// newFlagSet 建一个带中文用法头的 flag 集。
|
|
27
|
+
//
|
|
28
|
+
// 用标准库 flag 而不是 cobra:magic-client 是个小工具,多一层命令框架只会让
|
|
29
|
+
// 「参数原样透传」这件事变复杂(core 那边已经不解析,这里要能接住任意 flag)。
|
|
30
|
+
func newFlagSet(name string) *flag.FlagSet {
|
|
31
|
+
fs := flag.NewFlagSet(name, flag.ExitOnError)
|
|
32
|
+
fs.Usage = func() {
|
|
33
|
+
fmt.Fprintf(os.Stderr, "用法: magic-client %s [flags]\n\n", name)
|
|
34
|
+
fs.PrintDefaults()
|
|
35
|
+
}
|
|
36
|
+
return fs
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func isDirPath(p string) bool {
|
|
40
|
+
st, err := os.Stat(p)
|
|
41
|
+
return err == nil && st.IsDir()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func writeJSONStdout(v any) {
|
|
45
|
+
enc := json.NewEncoder(os.Stdout)
|
|
46
|
+
enc.SetIndent("", " ")
|
|
47
|
+
_ = enc.Encode(v)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// runClientCommand 处理 start / stop / status;未知动作返回 false 交给 serve 分支。
|
|
51
|
+
func runClientCommand(action string, args []string) (handled bool, code int) {
|
|
52
|
+
switch action {
|
|
53
|
+
case "start":
|
|
54
|
+
return true, runClientStart(args)
|
|
55
|
+
case "stop":
|
|
56
|
+
return true, runClientStop(args)
|
|
57
|
+
case "status":
|
|
58
|
+
return true, runClientStatus(args)
|
|
59
|
+
}
|
|
60
|
+
return false, 0
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// runClientCommandWithExit 同上,但保证「动作认了、退出码一定有值」。
|
|
64
|
+
func runClientCommandWithExit(action string, args []string) int {
|
|
65
|
+
handled, code := runClientCommand(action, args)
|
|
66
|
+
if !handled {
|
|
67
|
+
fmt.Fprintf(os.Stderr, "magic-client: 未知动作 %q\n\n%s", action, clientUsage)
|
|
68
|
+
return 2
|
|
69
|
+
}
|
|
70
|
+
return code
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const clientUsage = `magic-client - magic-agent 的客户端插件(桌面 / 移动端界面 + 扩展接口)
|
|
74
|
+
|
|
75
|
+
用法:
|
|
76
|
+
magic-client serve [flags] 前台运行(看日志 / 排障用)
|
|
77
|
+
magic-client start [flags] 后台常驻(关掉终端也还在),打印手机可用的地址
|
|
78
|
+
magic-client status [--json] 看状态(是否在跑、端口、手机地址、健康检查)
|
|
79
|
+
magic-client stop 停掉后台实例(本来没跑也算成功)
|
|
80
|
+
|
|
81
|
+
手机要连**必须**用 start --lan(或 serve --lan):默认只绑 127.0.0.1,同机可用、手机不可达。
|
|
82
|
+
--lan 时令牌是唯一门锁,至少 24 字符;start 会复用 ~/.magic-client/token,跨重启稳定
|
|
83
|
+
(手机里存的是「地址 + 令牌」,每次换就等于配对作废)。
|
|
84
|
+
|
|
85
|
+
serve 的 flags 见 ` + "`magic-client serve --help`" + `;start 另有 --state / --log / --wait。
|
|
86
|
+
`
|
|
87
|
+
|
|
88
|
+
func runClientStart(args []string) int {
|
|
89
|
+
fs := newFlagSet("start")
|
|
90
|
+
lan := fs.Bool("lan", false, "绑到局域网(0.0.0.0),让手机 / 平板连得上。⚠️ 开了之后令牌是唯一门锁")
|
|
91
|
+
addr := fs.String("addr", "", "监听地址(默认:带 --lan 时 0.0.0.0:0,否则 127.0.0.1:0)")
|
|
92
|
+
profilePath := fs.String("profile", "", "profile JSON 路径(空 = 内置默认)")
|
|
93
|
+
uiDir := fs.String("ui-dir", "", "桌面端界面目录(空 = 自动找与本程序同级的 client-ui)")
|
|
94
|
+
mobileDir := fs.String("mobile-dir", "", "移动端界面目录(空 = 自动找同级的 client-ui-mobile),/m/ 由它提供")
|
|
95
|
+
proxyAddr := fs.String("proxy-addr", "", "SOCKS5 出口监听地址(移动端 VPN 隧道核心用),如 0.0.0.0:1080")
|
|
96
|
+
token := fs.String("token", "", "访问令牌(空 = 复用 ~/.magic-client/token,没有就生成;--lan 时至少 24 字符)")
|
|
97
|
+
statePath := fs.String("state", "", "状态文件路径(默认 ~/.magic-client/client.json;也可用 MAGIC_CLIENT_STATE)")
|
|
98
|
+
logPath := fs.String("log", "", "后台日志路径(默认 ~/.magic-client/logs/client.log;也可用 MAGIC_CLIENT_LOG)")
|
|
99
|
+
wait := fs.Duration("wait", 15*time.Second, "等后台服务报端口的时长(超时即判定启动失败)")
|
|
100
|
+
browseUA := fs.String("browse-ua", "", "内置浏览器中转的 User-Agent:mobile(默认)/ desktop / 自定义")
|
|
101
|
+
_ = fs.Parse(args)
|
|
102
|
+
|
|
103
|
+
ui, mobile := strings.TrimSpace(*uiDir), strings.TrimSpace(*mobileDir)
|
|
104
|
+
if ui == "" && mobile == "" {
|
|
105
|
+
ui, mobile = detectUIRoots()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
st, err := client.StartDaemon(client.StartOptions{
|
|
109
|
+
Exe: selfExe(),
|
|
110
|
+
MagicAgent: coreExe(),
|
|
111
|
+
Profile: *profilePath,
|
|
112
|
+
UIRoot: ui,
|
|
113
|
+
MobileDir: mobile,
|
|
114
|
+
Addr: *addr,
|
|
115
|
+
Token: *token,
|
|
116
|
+
ProxyAddr: *proxyAddr,
|
|
117
|
+
BrowseUA: *browseUA,
|
|
118
|
+
Lan: *lan,
|
|
119
|
+
StatePath: *statePath,
|
|
120
|
+
LogPath: *logPath,
|
|
121
|
+
Wait: *wait,
|
|
122
|
+
})
|
|
123
|
+
if err != nil {
|
|
124
|
+
fmt.Fprintln(os.Stderr, "magic-client:", err)
|
|
125
|
+
return 1
|
|
126
|
+
}
|
|
127
|
+
printStarted(st)
|
|
128
|
+
return 0
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
func runClientStop(args []string) int {
|
|
132
|
+
fs := newFlagSet("stop")
|
|
133
|
+
statePath := fs.String("state", "", "状态文件路径(默认 ~/.magic-client/client.json)")
|
|
134
|
+
_ = fs.Parse(args)
|
|
135
|
+
|
|
136
|
+
stopped, st, err := client.StopDaemon(client.StopOptions{StatePath: *statePath})
|
|
137
|
+
if err != nil {
|
|
138
|
+
fmt.Fprintln(os.Stderr, "magic-client:", err)
|
|
139
|
+
return 1
|
|
140
|
+
}
|
|
141
|
+
switch {
|
|
142
|
+
case stopped:
|
|
143
|
+
fmt.Printf("已停止(pid %d,端口 %d)\n", st.PID, st.Port)
|
|
144
|
+
case st == nil:
|
|
145
|
+
fmt.Println("本来就没在跑")
|
|
146
|
+
default:
|
|
147
|
+
fmt.Printf("进程已不在(pid %d),状态文件已清理\n", st.PID)
|
|
148
|
+
}
|
|
149
|
+
return 0
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
func runClientStatus(args []string) int {
|
|
153
|
+
fs := newFlagSet("status")
|
|
154
|
+
statePath := fs.String("state", "", "状态文件路径(默认 ~/.magic-client/client.json)")
|
|
155
|
+
asJSON := fs.Bool("json", false, "输出 JSON(给脚本 / 客户端用)")
|
|
156
|
+
_ = fs.Parse(args)
|
|
157
|
+
|
|
158
|
+
st, err := client.StatusDaemon(*statePath)
|
|
159
|
+
if err != nil {
|
|
160
|
+
fmt.Fprintln(os.Stderr, "magic-client:", err)
|
|
161
|
+
return 1
|
|
162
|
+
}
|
|
163
|
+
if *asJSON {
|
|
164
|
+
writeJSONStdout(st)
|
|
165
|
+
if !st.Running {
|
|
166
|
+
// 与 systemctl is-active 同一口径:没在跑时退出码非 0,脚本可直接判。
|
|
167
|
+
return 3
|
|
168
|
+
}
|
|
169
|
+
return 0
|
|
170
|
+
}
|
|
171
|
+
printStatus(st)
|
|
172
|
+
if !st.Running {
|
|
173
|
+
return 3
|
|
174
|
+
}
|
|
175
|
+
return 0
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// printStarted 启动成功后的说明:**手机能直接用的东西放最前面**。
|
|
179
|
+
func printStarted(st *client.DaemonState) {
|
|
180
|
+
fmt.Printf("客户端服务已在后台运行(pid %d,端口 %d)\n", st.PID, st.Port)
|
|
181
|
+
fmt.Printf(" 本机 %s\n", st.URL)
|
|
182
|
+
switch {
|
|
183
|
+
case len(st.LanAddrs) == 0:
|
|
184
|
+
fmt.Println(" 局域网 (本次只绑了 127.0.0.1,手机连不上;要手机连请加 --lan)")
|
|
185
|
+
default:
|
|
186
|
+
for _, u := range st.LanAddrs {
|
|
187
|
+
fmt.Printf(" 手机 %s\n", u)
|
|
188
|
+
}
|
|
189
|
+
for _, u := range st.LanAddrs {
|
|
190
|
+
base := strings.TrimSuffix(u, "/?token="+st.Token)
|
|
191
|
+
fmt.Printf(" 二维码 %s/pair?token=%s (在电脑上打开,手机扫屏幕)\n", base, st.Token)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if st.ProxyAddr != "" {
|
|
195
|
+
fmt.Printf(" SOCKS5 %s(用户名随意,口令 = 令牌)\n", st.ProxyAddr)
|
|
196
|
+
}
|
|
197
|
+
fmt.Printf(" 日志 %s\n", st.LogPath)
|
|
198
|
+
fmt.Println(" 停止 magic-client stop")
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
func printStatus(st *client.DaemonStatus) {
|
|
202
|
+
if st.State == nil {
|
|
203
|
+
fmt.Println("未运行:" + st.Note)
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
s := st.State
|
|
207
|
+
if !st.Running {
|
|
208
|
+
fmt.Printf("未运行:%s(端口 %d)\n", st.Note, s.Port)
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
fmt.Printf("运行中(pid %d,端口 %d)\n", s.PID, s.Port)
|
|
212
|
+
fmt.Printf(" 本机 %s\n", s.URL)
|
|
213
|
+
for _, u := range s.LanAddrs {
|
|
214
|
+
fmt.Printf(" 手机 %s\n", u)
|
|
215
|
+
}
|
|
216
|
+
if len(s.LanAddrs) == 0 {
|
|
217
|
+
fmt.Println(" 局域网 (未绑局域网,手机连不上)")
|
|
218
|
+
}
|
|
219
|
+
if st.Health != nil {
|
|
220
|
+
fmt.Printf(" 应用 %s|契约 v%d|引擎 %d 个\n",
|
|
221
|
+
st.Health.App, st.Health.ContractVersion, st.Health.Engines)
|
|
222
|
+
}
|
|
223
|
+
fmt.Printf(" 启动于 %s\n", s.StartedAt.Format("2006-01-02 15:04:05"))
|
|
224
|
+
fmt.Printf(" 日志 %s\n", s.LogPath)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// detectUIRoots 按 npm 包布局找界面目录。
|
|
228
|
+
//
|
|
229
|
+
// npm 包把 UI 作为数据文件放在**包根**(node_modules/magic-agent/client-ui),
|
|
230
|
+
// 二进制在 npm/dist/<plat>/ 下 —— 所以从自身往上找包根,看有没有那两个目录。
|
|
231
|
+
// 找不到返回空(只提供接口),这不是错误:纯 API 用法本来就合法。
|
|
232
|
+
func detectUIRoots() (string, string) {
|
|
233
|
+
dir := filepath.Dir(selfExe())
|
|
234
|
+
for i := 0; i < 4; i++ {
|
|
235
|
+
ui := filepath.Join(dir, "client-ui")
|
|
236
|
+
mobile := filepath.Join(dir, "client-ui-mobile")
|
|
237
|
+
if isDirPath(ui) {
|
|
238
|
+
if !isDirPath(mobile) {
|
|
239
|
+
mobile = ""
|
|
240
|
+
}
|
|
241
|
+
return ui, mobile
|
|
242
|
+
}
|
|
243
|
+
parent := filepath.Dir(dir)
|
|
244
|
+
if parent == dir {
|
|
245
|
+
break
|
|
246
|
+
}
|
|
247
|
+
dir = parent
|
|
248
|
+
}
|
|
249
|
+
return "", ""
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// selfExe 当前可执行文件(start 时用它拉起后台 serve)。
|
|
253
|
+
func selfExe() string {
|
|
254
|
+
p, err := os.Executable()
|
|
255
|
+
if err != nil {
|
|
256
|
+
return ""
|
|
257
|
+
}
|
|
258
|
+
return p
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// coreExe 交给 serve 的 core 路径。
|
|
262
|
+
//
|
|
263
|
+
// 优先级:MAGIC_AGENT_BIN(core 转发时设的,一定是**同一次安装**的那份)> PATH。
|
|
264
|
+
// 为什么不直接让 serve 从 PATH 找:PATH 里可能是另一个版本,契约比对会失败,
|
|
265
|
+
// 而用户看到的是「刚装的这个坏了」。与引擎探测同一条规则(AGENTS.md)。
|
|
266
|
+
func coreExe() string {
|
|
267
|
+
if p := strings.TrimSpace(os.Getenv("MAGIC_AGENT_BIN")); p != "" {
|
|
268
|
+
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
|
269
|
+
return p
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return ""
|
|
273
|
+
}
|
package/cmd/magic-client/main.go
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
//
|
|
10
10
|
// magic-client serve --profile ./profiles/zhantianping.json --ui-dir ./client-ui
|
|
11
11
|
// magic-client serve --print-url # 只打印带令牌的地址,不启动(脚本用)
|
|
12
|
+
// magic-client start --lan # 后台常驻(关掉终端也还在),手机可连
|
|
13
|
+
// magic-client status | stop # 看状态 / 停掉后台实例
|
|
12
14
|
//
|
|
13
15
|
// 默认**只绑 127.0.0.1**,并需要 X-Magic-Token:同机任意进程都能访问本地端口,
|
|
14
16
|
// 不开鉴权等于把引擎执行权交给它们。
|
|
@@ -31,19 +33,35 @@ import (
|
|
|
31
33
|
)
|
|
32
34
|
|
|
33
35
|
func main() {
|
|
34
|
-
if len(os.Args) < 2
|
|
35
|
-
fmt.
|
|
36
|
+
if len(os.Args) < 2 {
|
|
37
|
+
fmt.Fprint(os.Stderr, clientUsage)
|
|
38
|
+
os.Exit(2)
|
|
39
|
+
}
|
|
40
|
+
switch os.Args[1] {
|
|
41
|
+
case "start", "stop", "status":
|
|
42
|
+
os.Exit(runClientCommandWithExit(os.Args[1], os.Args[2:]))
|
|
43
|
+
case "serve":
|
|
44
|
+
// 落下去走下面的前台流程
|
|
45
|
+
case "-h", "--help", "help":
|
|
46
|
+
fmt.Print(clientUsage)
|
|
47
|
+
os.Exit(0)
|
|
48
|
+
default:
|
|
49
|
+
fmt.Fprintf(os.Stderr, "magic-client: 未知动作 %q\n\n%s", os.Args[1], clientUsage)
|
|
36
50
|
os.Exit(2)
|
|
37
51
|
}
|
|
38
52
|
fs := flag.NewFlagSet("serve", flag.ExitOnError)
|
|
39
53
|
var (
|
|
40
54
|
profilePath = fs.String("profile", "", "profile JSON 路径(空 = 内置默认)")
|
|
41
55
|
uiDir = fs.String("ui-dir", "", "静态资源目录(基础版 UI 与业务模块)")
|
|
56
|
+
mobileDir = fs.String("mobile-dir", "", "移动端界面目录(client-ui-mobile)。配了之后 /m/ 远程提供它,APK 壳可远程加载、改界面免重打")
|
|
42
57
|
addr = fs.String("addr", "", "监听地址(默认 127.0.0.1:0;带 --lan 时是 0.0.0.0:0)")
|
|
43
58
|
lan = fs.Bool("lan", false, "绑到局域网(0.0.0.0),让手机 / 平板连得上。⚠️ 开了之后令牌是唯一门锁")
|
|
44
59
|
token = fs.String("token", "", "访问令牌(空 = 随机生成并打印;--lan 时至少要 24 个字符)")
|
|
45
60
|
magicAgent = fs.String("magic-agent", os.Getenv("MAGIC_AGENT_BIN"), "core 可执行文件(空 = 从 PATH 找 magic-agent)")
|
|
46
61
|
printURL = fs.Bool("print-url", false, "只打印带令牌的访问地址后退出(不启动服务)")
|
|
62
|
+
proxyAddr = fs.String("proxy-addr", "", "SOCKS5 出口监听地址(供移动端 VPN 隧道核心使用),如 0.0.0.0:1080")
|
|
63
|
+
browseUA = fs.String("browse-ua", "", "内置浏览器中转的 User-Agent:mobile(默认)/ desktop / 任意自定义串")
|
|
64
|
+
readyFile = fs.String("ready-file", "", "监听成功后把 {pid,port,url,lanAddrs} 原子写到这个路径(`magic-client start` 用它拿端口,别手工传)")
|
|
47
65
|
)
|
|
48
66
|
_ = fs.Parse(os.Args[2:])
|
|
49
67
|
|
|
@@ -68,7 +86,9 @@ func main() {
|
|
|
68
86
|
MagicAgent: *magicAgent,
|
|
69
87
|
Profile: prof,
|
|
70
88
|
UIRoot: *uiDir,
|
|
89
|
+
MobileRoot: *mobileDir,
|
|
71
90
|
Token: tok,
|
|
91
|
+
BrowseUA: *browseUA,
|
|
72
92
|
})
|
|
73
93
|
if err != nil {
|
|
74
94
|
// 契约不匹配是**要立刻停**的错误:带着不匹配的能力跑起来只会让人以为是界面 bug。
|
|
@@ -102,15 +122,19 @@ func main() {
|
|
|
102
122
|
fmt.Fprintf(os.Stderr, "magic-client: %s | core=%s 契约 v%d | 引擎 %d 个 | 数据目录 %s\n",
|
|
103
123
|
prof.App.Name, svc.MagicAgentPath(), c.ContractVersion, len(c.Engines), prof.DataDir())
|
|
104
124
|
fmt.Fprintf(os.Stderr, "magic-client: 打开 %s\n", url)
|
|
125
|
+
if strings.TrimSpace(*uiDir) != "" || strings.TrimSpace(*mobileDir) != "" {
|
|
126
|
+
fmt.Fprintf(os.Stderr, "magic-client: 移动端界面(远程 HTML,改界面免重打 APK) http://127.0.0.1:%d/m/?token=%s\n", port, tok)
|
|
127
|
+
}
|
|
105
128
|
fmt.Fprintf(os.Stderr, "magic-client: 接口 /desk/{health,contract,engines,modules,sessions,profile,asks,ask,ask/{id}/control} + 业务转发 /api/*(都要 X-Magic-Token)\n")
|
|
129
|
+
var lanAddrs []string
|
|
106
130
|
if *lan {
|
|
107
131
|
// 绑到局域网就把可用地址全打出来:一台机器常有多个网卡,猜错比多打两行麻烦。
|
|
108
132
|
fmt.Fprintln(os.Stderr, "magic-client: ⚠️ 已绑到局域网(0.0.0.0):同网络内的设备都能访问,令牌是唯一门锁")
|
|
109
|
-
|
|
110
|
-
if len(
|
|
133
|
+
lanAddrs = client.LanAddresses(port)
|
|
134
|
+
if len(lanAddrs) == 0 {
|
|
111
135
|
fmt.Fprintln(os.Stderr, "magic-client: 没找到局域网地址(只连着回环?用 ipconfig getifaddr en0 查一下本机 IP)")
|
|
112
136
|
}
|
|
113
|
-
for _, a := range
|
|
137
|
+
for _, a := range lanAddrs {
|
|
114
138
|
fmt.Fprintf(os.Stderr, "magic-client: 手机 / 平板打开 http://%s/?token=%s\n", a, tok)
|
|
115
139
|
// 配对二维码页:要在**电脑上**打开这个链接(手机扫屏幕上的码)。
|
|
116
140
|
// 用某个网卡地址打开,码里就是那个地址 —— 所以每个地址各给一条。
|
|
@@ -118,6 +142,48 @@ func main() {
|
|
|
118
142
|
}
|
|
119
143
|
}
|
|
120
144
|
|
|
145
|
+
// SOCKS5 出口:给移动端 VPN 隧道核心当出口(TUN → SOCKS5)。
|
|
146
|
+
// 显式开了才起 —— 能连到它的人就能借这台机器上网,令牌是唯一门锁。
|
|
147
|
+
proxyActual := ""
|
|
148
|
+
if pa := strings.TrimSpace(*proxyAddr); pa != "" {
|
|
149
|
+
pln, err := net.Listen("tcp", pa)
|
|
150
|
+
if err != nil {
|
|
151
|
+
fmt.Fprintln(os.Stderr, "magic-client: SOCKS5 监听失败:", err)
|
|
152
|
+
os.Exit(1)
|
|
153
|
+
}
|
|
154
|
+
go func() {
|
|
155
|
+
if err := svc.ServeProxy(pln); err != nil {
|
|
156
|
+
fmt.Fprintln(os.Stderr, "magic-client: SOCKS5 服务退出:", err)
|
|
157
|
+
}
|
|
158
|
+
}()
|
|
159
|
+
proxyActual = pln.Addr().String()
|
|
160
|
+
fmt.Fprintf(os.Stderr, "magic-client: SOCKS5 出口 %s(用户名随意,口令 = 令牌)\n", proxyActual)
|
|
161
|
+
} else if *lan {
|
|
162
|
+
fmt.Fprintln(os.Stderr, "magic-client: 未开 SOCKS5 出口(移动端隧道需要它:加 --proxy-addr 0.0.0.0:1080)")
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/* ready 文件:`magic-client start` 靠它拿端口(端口是 0 = 系统分配的,
|
|
166
|
+
父进程除了读这个文件没有别的可靠途径)。**必须在监听成功之后写** ——
|
|
167
|
+
写早了父进程会拿着一个还没 Serve 的端口去探活,白等一轮。 */
|
|
168
|
+
if p := strings.TrimSpace(*readyFile); p != "" {
|
|
169
|
+
lanURLs := make([]string, 0, len(lanAddrs))
|
|
170
|
+
for _, a := range lanAddrs {
|
|
171
|
+
lanURLs = append(lanURLs, fmt.Sprintf("http://%s/?token=%s", a, tok))
|
|
172
|
+
}
|
|
173
|
+
if err := client.WriteReadyFile(p, client.ReadyInfo{
|
|
174
|
+
Port: port,
|
|
175
|
+
Addr: ln.Addr().String(),
|
|
176
|
+
URL: url,
|
|
177
|
+
Lan: *lan,
|
|
178
|
+
LanAddrs: lanURLs,
|
|
179
|
+
ProxyAddr: proxyActual,
|
|
180
|
+
}); err != nil {
|
|
181
|
+
// 写不出来就直接退出:父进程会一直等不到,报错还不如现在说清楚。
|
|
182
|
+
fmt.Fprintln(os.Stderr, "magic-client: 写 ready 文件失败:", err)
|
|
183
|
+
os.Exit(1)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
121
187
|
srv := &http.Server{
|
|
122
188
|
Handler: svc.Handler(),
|
|
123
189
|
ReadHeaderTimeout: 10 * time.Second,
|
package/go.mod
CHANGED
|
@@ -2,10 +2,13 @@ module github.com/darren/magic-agent
|
|
|
2
2
|
|
|
3
3
|
go 1.26.5
|
|
4
4
|
|
|
5
|
-
require
|
|
5
|
+
require (
|
|
6
|
+
github.com/gorilla/websocket v1.5.3
|
|
7
|
+
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
|
8
|
+
github.com/spf13/cobra v1.10.2
|
|
9
|
+
)
|
|
6
10
|
|
|
7
11
|
require (
|
|
8
12
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
|
9
|
-
github.com/
|
|
10
|
-
github.com/spf13/pflag v1.0.9 // indirect
|
|
13
|
+
github.com/spf13/pflag v1.0.10 // indirect
|
|
11
14
|
)
|
package/go.sum
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
|
2
|
+
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
|
3
|
+
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
|
2
4
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
|
3
5
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
|
4
6
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
|
@@ -6,7 +8,8 @@ github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1
|
|
|
6
8
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
|
7
9
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
|
8
10
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
|
9
|
-
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
|
10
11
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
|
12
|
+
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
|
13
|
+
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
|
11
14
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
|
12
15
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
@@ -84,9 +84,10 @@ import (
|
|
|
84
84
|
//
|
|
85
85
|
// 2026-09-24 由 3 分钟提到 10 分钟:3 分钟是按「单轮往返 8~23s」定的,但**带工具循环的
|
|
86
86
|
// 任务会跑很久** —— 实测「生成周报」在网关侧要 304s 才回(期间每 15s 一个 working 心跳),
|
|
87
|
-
// 3 分钟必被砍、正文一个字都拿不到。
|
|
87
|
+
// 3 分钟必被砍、正文一个字都拿不到。
|
|
88
|
+
// 2026-10-10 再由 10 分钟统一上调到 1 小时,与 CLI 的 -t 默认对齐,也让流式与非流式
|
|
88
89
|
// 两条路的默认一致(非流式走 Runner,拿的就是 -t)。
|
|
89
|
-
const DefaultArkClawTimeout =
|
|
90
|
+
const DefaultArkClawTimeout = time.Hour
|
|
90
91
|
|
|
91
92
|
// maxArkClawBody 单次响应体读取上限,防止异常响应把内存吃满。
|
|
92
93
|
const maxArkClawBody = 8 << 20 // 8 MiB
|
|
@@ -67,6 +67,19 @@ type arkClawStreamState struct {
|
|
|
67
67
|
completed bool // 是否见过 completed 帧
|
|
68
68
|
state string // 最近一次 status.state(错误信息用)
|
|
69
69
|
sessionID string // contextId(会话锚点,逐帧刷新)
|
|
70
|
+
|
|
71
|
+
// tracker 状态收敛的身份与版本(见 itemtracker.go)。
|
|
72
|
+
textTrack *itemTracker
|
|
73
|
+
// textTrack 是否已经发过事件(发过就不能再换 item id)。
|
|
74
|
+
textStarted bool
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// trackers 惰性建立跟踪器。
|
|
78
|
+
func (st *arkClawStreamState) trackers() *itemTracker {
|
|
79
|
+
if st.textTrack == nil {
|
|
80
|
+
st.textTrack = newItemTracker("arkclaw", laneText)
|
|
81
|
+
}
|
|
82
|
+
return st.textTrack
|
|
70
83
|
}
|
|
71
84
|
|
|
72
85
|
// Stream 实现 Streamer:走 A2A 官方 SSE 通道(见文件头)。
|
|
@@ -155,8 +168,24 @@ func (e *ArkClawEngine) Stream(ctx context.Context, req Request, onEvent func(St
|
|
|
155
168
|
return StreamResult{Response: resp}, perr
|
|
156
169
|
}
|
|
157
170
|
fmt.Fprintln(stderr, "magic-agent: arkclaw 网关未按 SSE 回(message/stream 不可用?),本轮按一次性响应处理(无增量)")
|
|
171
|
+
// 一次性响应也走 tracker:一次性只是**没有增量帧**,不是**没有状态收敛**。
|
|
172
|
+
// 收尾发 turn_end(final)而不是又一条 text,与 SSE 路径同一约定。
|
|
158
173
|
if onEvent != nil && resp.Text != "" {
|
|
159
|
-
onEvent
|
|
174
|
+
fst := &arkClawStreamState{onEvent: onEvent, sessionID: resp.SessionID}
|
|
175
|
+
// 原生 id 与 SSE 路径同源:一次性 body 里也有 result.id(task id),
|
|
176
|
+
// 用 arkClawParseResponse 解析出的响应拿不到它,所以这里按帧重解一次。
|
|
177
|
+
if id := arkClawTaskID(raw); id != "" {
|
|
178
|
+
fst.trackers().Begin(id)
|
|
179
|
+
}
|
|
180
|
+
ev := StreamEvent{Kind: KindText, Text: resp.Text, SessionID: resp.SessionID}
|
|
181
|
+
// 一次性响应是整段到达,没有前序增量:snapshot 就是全文本身。
|
|
182
|
+
fst.trackers().nextWith(StatusRunning, resp.Text).apply(&ev)
|
|
183
|
+
onEvent(ev)
|
|
184
|
+
end := StreamEvent{Kind: KindTurnEnd, Text: resp.Text, SessionID: resp.SessionID}
|
|
185
|
+
if attrs, ok := fst.trackers().Finalize(resp.Text); ok {
|
|
186
|
+
attrs.apply(&end)
|
|
187
|
+
}
|
|
188
|
+
onEvent(end)
|
|
160
189
|
}
|
|
161
190
|
return StreamResult{Response: resp}, nil
|
|
162
191
|
}
|
|
@@ -171,6 +200,7 @@ func (e *ArkClawEngine) Stream(ctx context.Context, req Request, onEvent func(St
|
|
|
171
200
|
}
|
|
172
201
|
done, herr := st.handleFrame([]byte(payload), req.JSONSchema)
|
|
173
202
|
if herr != nil {
|
|
203
|
+
st.fail(herr)
|
|
174
204
|
return st.result(e, req, start), herr
|
|
175
205
|
}
|
|
176
206
|
if done {
|
|
@@ -180,18 +210,27 @@ func (e *ArkClawEngine) Stream(ctx context.Context, req Request, onEvent func(St
|
|
|
180
210
|
if serr := sc.Err(); serr != nil {
|
|
181
211
|
// 超时/取消会在读 body 时爆出来(请求头早就到了,卡的是流)。
|
|
182
212
|
if cerr := httpCtx.Err(); cerr != nil {
|
|
183
|
-
|
|
213
|
+
err := fmt.Errorf("arkclaw: 流式读取中断: %w", cerr)
|
|
214
|
+
st.fail(err)
|
|
215
|
+
return st.result(e, req, start), err
|
|
184
216
|
}
|
|
185
|
-
|
|
217
|
+
err := fmt.Errorf("arkclaw: 读取流式响应失败: %w", serr)
|
|
218
|
+
st.fail(err)
|
|
219
|
+
return st.result(e, req, start), err
|
|
186
220
|
}
|
|
187
221
|
|
|
188
222
|
if !st.completed {
|
|
189
|
-
|
|
190
|
-
|
|
223
|
+
err := fmt.Errorf("arkclaw: 流式响应结束但没有 completed 帧(最后状态 %q)", st.state)
|
|
224
|
+
st.fail(err)
|
|
225
|
+
return st.result(e, req, start), err
|
|
191
226
|
}
|
|
192
227
|
res := st.result(e, req, start)
|
|
193
228
|
if strings.TrimSpace(res.Text) == "" {
|
|
194
|
-
|
|
229
|
+
// 正文空 → 整轮没有可用输出。item 可能已建立(网关发过 artifact),
|
|
230
|
+
// 必须收成 failed,否则那条 item 永远停在 running。
|
|
231
|
+
err := fmt.Errorf("arkclaw: 网关返回空正文(流式)")
|
|
232
|
+
st.fail(err)
|
|
233
|
+
return res, err
|
|
195
234
|
}
|
|
196
235
|
return res, nil
|
|
197
236
|
}
|
|
@@ -213,6 +252,17 @@ func (st *arkClawStreamState) handleFrame(raw []byte, schema *JSONSchema) (bool,
|
|
|
213
252
|
if id := strings.TrimSpace(res.ContextID); id != "" {
|
|
214
253
|
st.sessionID = id
|
|
215
254
|
}
|
|
255
|
+
/* 原生 item id 用 **taskId(res.ID)** 而不是 contextId —— 两者语义不同,
|
|
256
|
+
用错会让两轮共用一个 item:
|
|
257
|
+
- contextId 是**会话**锚点,跨轮不变(下一轮带着它续接,见
|
|
258
|
+
arkClawBuildRequest 的 contextID 参数),拿它当 item id 的话
|
|
259
|
+
一个会话里的每一轮都是同一个 item,后一轮覆盖前一轮
|
|
260
|
+
(症状:多轮对话只剩最后一次回复)。
|
|
261
|
+
- taskId 是**单轮**任务 id,每个 message/stream 一次,天然是一轮一个 item。
|
|
262
|
+
两者都拿不到时降级到本轮内稳定的 local_<n>(见 itemTracker.ensure)。 */
|
|
263
|
+
if id := strings.TrimSpace(res.ID); id != "" && !st.textStarted {
|
|
264
|
+
st.trackers().Note(id)
|
|
265
|
+
}
|
|
216
266
|
state := strings.ToLower(strings.TrimSpace(res.Status.State))
|
|
217
267
|
st.state = state
|
|
218
268
|
|
|
@@ -237,6 +287,8 @@ func (st *arkClawStreamState) handleFrame(raw []byte, schema *JSONSchema) (bool,
|
|
|
237
287
|
st.final = text
|
|
238
288
|
st.completed = true
|
|
239
289
|
st.emitText(text)
|
|
290
|
+
// completed = 轮次终点:发 turn_end 把 item 收成 final(见 finish 的注释)。
|
|
291
|
+
st.finish()
|
|
240
292
|
return true, nil
|
|
241
293
|
}
|
|
242
294
|
|
|
@@ -265,9 +317,61 @@ func (st *arkClawStreamState) emitText(text string) {
|
|
|
265
317
|
return
|
|
266
318
|
}
|
|
267
319
|
st.emitted.WriteString(text)
|
|
268
|
-
if st.onEvent
|
|
269
|
-
|
|
320
|
+
if st.onEvent == nil {
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
/* snapshot 直接取 st.emitted(累积到此刻的全文),**不能**喂给 tracker 的
|
|
324
|
+
Append —— Append 的语义是「把这段**增量**拼到已有 snapshot 后面」,
|
|
325
|
+
而这里传的是「累积到此刻的全文」,两者混用会逐帧翻倍
|
|
326
|
+
(「你好」→「你好你好」,与 codebuddy-gateway 踩过的同一个坑)。
|
|
327
|
+
正确分工见 itemtracker.go 文件头:tracker 管 id/revision/status,
|
|
328
|
+
内容累积由调用方独家负责。 */
|
|
329
|
+
ev := StreamEvent{Kind: KindText, Text: text}
|
|
330
|
+
st.trackers().nextWith(StatusRunning, st.emitted.String()).apply(&ev)
|
|
331
|
+
st.textStarted = true
|
|
332
|
+
st.onEvent(ev)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// finish 收尾:把 item 标成 final(completed 帧已到,正文不会再变)。
|
|
336
|
+
//
|
|
337
|
+
// 发 **KindTurnEnd** 而不是又一条 KindText,理由同 codebuddy-gateway:
|
|
338
|
+
// completed 是**轮次边界**而非「又一段正文」;补一条空 Text 的 KindText
|
|
339
|
+
// 会打破既有按 ev.Text 收集的消费者(既有测试就是这么写的)。
|
|
340
|
+
// arkclaw 不在 AppendSupportOf 名单里,不会启用常驻会话,
|
|
341
|
+
// 所以不会被误触发 keep-alive 空闲收工。
|
|
342
|
+
func (st *arkClawStreamState) finish() {
|
|
343
|
+
if st.onEvent == nil || !st.textStarted {
|
|
344
|
+
return
|
|
270
345
|
}
|
|
346
|
+
ev := StreamEvent{Kind: KindTurnEnd, Text: st.final, SessionID: st.sessionID}
|
|
347
|
+
if attrs, ok := st.trackers().Finalize(st.emitted.String()); ok {
|
|
348
|
+
attrs.apply(&ev)
|
|
349
|
+
}
|
|
350
|
+
st.onEvent(ev)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// fail 收尾异常路径:流被截断 / 任务 failed / JSON-RPC error / 空正文时,把已经发出去的
|
|
354
|
+
// 正文 item 收成 failed,而不是让它永远停在 running(消费方只能靠超时猜,
|
|
355
|
+
// 历史回放更糟:那一条永远显示「生成中」)。
|
|
356
|
+
//
|
|
357
|
+
// 语义与 finish 的分工:turn_end = 网关给了 completed 帧(成功收尾),
|
|
358
|
+
// turn_failed = 没有(异常收尾),带 Error / Reason。partial 的内容仍然存在,
|
|
359
|
+
// 只是这条 item 不会再更新(Status=failed 明确终止增长)。
|
|
360
|
+
func (st *arkClawStreamState) fail(err error) {
|
|
361
|
+
if st.onEvent == nil || err == nil || !st.textStarted {
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
ev := StreamEvent{
|
|
365
|
+
Kind: KindTurnFailed,
|
|
366
|
+
SessionID: st.sessionID,
|
|
367
|
+
Error: err.Error(),
|
|
368
|
+
Reason: ReasonOf(err),
|
|
369
|
+
Status: StatusFailed,
|
|
370
|
+
}
|
|
371
|
+
if attrs, ok := st.trackers().Failed(st.emitted.String()); ok {
|
|
372
|
+
attrs.apply(&ev)
|
|
373
|
+
}
|
|
374
|
+
st.onEvent(ev)
|
|
271
375
|
}
|
|
272
376
|
|
|
273
377
|
// result 把累积状态收成 StreamResult(终态与错误路径共用)。
|
|
@@ -306,6 +410,20 @@ func arkClawSSEPayload(line string) (string, bool) {
|
|
|
306
410
|
return payload, true
|
|
307
411
|
}
|
|
308
412
|
|
|
413
|
+
// arkClawTaskID 从一次性响应体里取 task id(result.id)。
|
|
414
|
+
//
|
|
415
|
+
// 非 SSE 回退路径专用:那里走 arkClawParseResponse 拿不到 result 结构
|
|
416
|
+
// (它只返回拼好的 Response),但原生 id 不该因此降级到 local_<n> ——
|
|
417
|
+
// 同一个 task 在 SSE 与一次性两条路径下必须得到**同一个** item id,
|
|
418
|
+
// 否则同一轮回复会因走不同通道而在消费方眼里裂成两条 item。
|
|
419
|
+
func arkClawTaskID(raw []byte) string {
|
|
420
|
+
env := arkClawEnvelope{}
|
|
421
|
+
if err := json.Unmarshal(raw, &env); err != nil || env.Result == nil {
|
|
422
|
+
return ""
|
|
423
|
+
}
|
|
424
|
+
return strings.TrimSpace(env.Result.ID)
|
|
425
|
+
}
|
|
426
|
+
|
|
309
427
|
// arkClawIsEventStream 响应是否为 SSE(content-type 判断,容忍参数与大小写)。
|
|
310
428
|
func arkClawIsEventStream(contentType string) bool {
|
|
311
429
|
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(contentType)), "text/event-stream")
|