magic-agent 2.4.2 → 2.6.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 (156) hide show
  1. package/CLIENT-CONTRACT.md +290 -30
  2. package/README.md +15 -14
  3. package/client-ui/pc/app.css +32 -0
  4. package/client-ui/pc/js/04-task-list.js +5 -1
  5. package/client-ui/pc/js/07-chat-stream-render.js +3 -3
  6. package/client-ui/pc/js/90-glue.js +37 -2
  7. package/client-ui/pc/js/92-composer.js +19 -4
  8. package/client-ui/pc/js/93-rail.js +16 -5
  9. package/client-ui/shared/conv/02-media-kinds.js +2 -2
  10. package/client-ui/shared/conv/03-chat-message.js +241 -23
  11. package/client-ui/shared/conv/06-ask-answer.js +75 -1
  12. package/client-ui/shared/conv/09-stream-apply.js +63 -7
  13. package/client-ui/shared/conv/19-attachments.js +157 -6
  14. package/client-ui/shared/conv/27-conv-ledger.js +5 -2
  15. package/client-ui/shared/conv/29-msg-variants.js +81 -0
  16. package/client-ui/shared/conv/conv.css +79 -19
  17. package/client-ui/shared/conv/dom-map.md +1 -0
  18. package/client-ui/shared/conversation.css +13 -2
  19. package/client-ui/shared/conversation.js +336 -50
  20. package/client-ui/shared/transport-http.js +14 -0
  21. package/client-ui-mobile/index.html +2932 -333
  22. package/client-ui-mobile/reqs.css +102 -0
  23. package/client-ui-mobile/reqs.js +485 -70
  24. package/client-ui-mobile/reqshot.js +505 -0
  25. package/client-ui-mobile/scripts.css +21 -0
  26. package/client-ui-mobile/scripts.js +539 -0
  27. package/client-ui-mobile/vendor/cose-base.js +3214 -0
  28. package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
  29. package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
  30. package/client-ui-mobile/vendor/layout-base.js +5230 -0
  31. package/cmd/magic-client/browsectl.go +199 -0
  32. package/cmd/magic-client/clientctl.go +33 -9
  33. package/cmd/magic-client/main.go +57 -26
  34. package/internal/agent/ask.go +45 -0
  35. package/internal/agent/ask_test.go +39 -0
  36. package/internal/agent/attachments_test.go +6 -2
  37. package/internal/agent/capability.go +7 -1
  38. package/internal/agent/claude.go +20 -15
  39. package/internal/agent/codebuddy.go +125 -34
  40. package/internal/agent/codebuddy_account_test.go +54 -0
  41. package/internal/agent/codebuddy_stream_test.go +103 -0
  42. package/internal/agent/credits.go +713 -0
  43. package/internal/agent/credits_minimax_test.go +97 -0
  44. package/internal/agent/credits_test.go +211 -0
  45. package/internal/agent/custom_models.go +155 -0
  46. package/internal/agent/custom_models_test.go +125 -0
  47. package/internal/agent/dsh.go +123 -49
  48. package/internal/agent/dsh_resume.go +13 -2
  49. package/internal/agent/dsh_test.go +108 -6
  50. package/internal/agent/engine.go +37 -1
  51. package/internal/agent/engine_base.go +21 -17
  52. package/internal/agent/engine_base_test.go +2 -2
  53. package/internal/agent/engine_test.go +47 -86
  54. package/internal/agent/fallback.go +313 -0
  55. package/internal/agent/fallback_test.go +340 -0
  56. package/internal/agent/launch.go +43 -1
  57. package/internal/agent/llmhttp.go +46 -4
  58. package/internal/agent/llmhttp_test.go +157 -0
  59. package/internal/agent/models.go +53 -1
  60. package/internal/agent/models_test.go +20 -5
  61. package/internal/agent/pidalive_windows_test.go +40 -0
  62. package/internal/agent/runcmd.go +26 -0
  63. package/internal/agent/runcmd_windows.go +36 -5
  64. package/internal/agent/stream.go +136 -6
  65. package/internal/agent/stream_test.go +25 -38
  66. package/internal/agent/stream_timeline_test.go +2 -37
  67. package/internal/agent/streamjson.go +108 -6
  68. package/internal/agent/tools_suffix_test.go +6 -3
  69. package/internal/agent/trae.go +50 -243
  70. package/internal/agent/trae_exec.go +433 -0
  71. package/internal/agent/trae_exec_test.go +181 -0
  72. package/internal/agent/wraplaunch_test.go +113 -0
  73. package/internal/cli/ask.go +160 -37
  74. package/internal/cli/ask_test.go +1 -1
  75. package/internal/cli/checkin.go +77 -0
  76. package/internal/cli/clientcmd.go +3 -2
  77. package/internal/cli/control.go +46 -5
  78. package/internal/cli/control_test.go +65 -0
  79. package/internal/cli/events.go +4 -0
  80. package/internal/cli/quotas.go +125 -0
  81. package/internal/cli/requirements.go +51 -0
  82. package/internal/cli/root.go +122 -5
  83. package/internal/cli/session.go +7 -10
  84. package/internal/cli/suggest.go +7 -5
  85. package/internal/cli/team.go +37 -9
  86. package/internal/cli/team_test.go +39 -0
  87. package/internal/client/ab.go +372 -0
  88. package/internal/client/ab_test.go +287 -0
  89. package/internal/client/browse.go +84 -36
  90. package/internal/client/browse_test.go +107 -9
  91. package/internal/client/browseinput.go +481 -0
  92. package/internal/client/browseinput_test.go +216 -0
  93. package/internal/client/corecmd.go +23 -0
  94. package/internal/client/corecmd_other.go +8 -0
  95. package/internal/client/corecmd_windows.go +24 -0
  96. package/internal/client/daemon.go +90 -17
  97. package/internal/client/daemon_test.go +33 -0
  98. package/internal/client/engines_checkin_test.go +57 -0
  99. package/internal/client/fallback_flags_test.go +64 -0
  100. package/internal/client/jobs.go +294 -21
  101. package/internal/client/jobs_test.go +299 -2
  102. package/internal/client/knowledge.go +854 -0
  103. package/internal/client/knowledge_test.go +510 -0
  104. package/internal/client/lan.go +193 -7
  105. package/internal/client/lan_test.go +110 -0
  106. package/internal/client/quotas_merge_test.go +107 -0
  107. package/internal/client/requirements.go +198 -1
  108. package/internal/client/requirements_shots_test.go +200 -0
  109. package/internal/client/restart.go +134 -0
  110. package/internal/client/restart_test.go +151 -0
  111. package/internal/client/scripts.go +1047 -0
  112. package/internal/client/scripts_decode_other.go +27 -0
  113. package/internal/client/scripts_decode_test.go +125 -0
  114. package/internal/client/scripts_decode_windows.go +206 -0
  115. package/internal/client/scripts_test.go +726 -0
  116. package/internal/client/service.go +322 -18
  117. package/internal/client/service_test.go +51 -0
  118. package/internal/client/suggest.go +105 -2
  119. package/internal/client/suggest_test.go +86 -0
  120. package/internal/client/team_flags.go +25 -11
  121. package/internal/client/team_flags_test.go +39 -0
  122. package/internal/requirements/bootstrap.go +16 -0
  123. package/internal/requirements/consolidate.go +100 -9
  124. package/internal/requirements/doc.go +154 -3
  125. package/internal/requirements/graph.go +138 -0
  126. package/internal/requirements/memory.go +7 -0
  127. package/internal/requirements/modules.go +312 -0
  128. package/internal/requirements/modules_test.go +373 -0
  129. package/internal/requirements/relations.go +155 -0
  130. package/internal/requirements/relations_test.go +231 -0
  131. package/internal/requirements/requirements.go +10 -0
  132. package/internal/requirements/shots.go +418 -0
  133. package/internal/requirements/shots_test.go +387 -0
  134. package/internal/session/session.go +92 -0
  135. package/internal/session/sessionlog.go +23 -0
  136. package/internal/session/sessionlog_read_test.go +39 -0
  137. package/internal/suggest/suggest.go +23 -1
  138. package/internal/team/event.go +3 -1
  139. package/internal/team/execute.go +21 -6
  140. package/internal/team/fallback_route_test.go +80 -0
  141. package/internal/team/planner.go +15 -3
  142. package/internal/team/route.go +89 -17
  143. package/internal/team/team.go +18 -4
  144. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  145. package/npm/apk/version.json +1 -5
  146. package/npm/dist/darwin-arm64/magic-agent +0 -0
  147. package/npm/dist/darwin-arm64/magic-client +0 -0
  148. package/npm/dist/darwin-x64/magic-agent +0 -0
  149. package/npm/dist/darwin-x64/magic-client +0 -0
  150. package/npm/dist/linux-arm64/magic-agent +0 -0
  151. package/npm/dist/linux-arm64/magic-client +0 -0
  152. package/npm/dist/linux-x64/magic-agent +0 -0
  153. package/npm/dist/linux-x64/magic-client +0 -0
  154. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  155. package/npm/dist/win32-x64/magic-client.exe +0 -0
  156. package/package.json +8 -2
@@ -0,0 +1,713 @@
1
+ package agent
2
+
3
+ // credits.go - codebuddy 系账号的「剩余积分」查询(`--engines` 的 credit_left 字段)。
4
+ //
5
+ // 剩余积分只存在于账单接口(CLI 本地缓存里只有各模型倍率,没有余额):
6
+ //
7
+ // POST https://<auth.domain>/billing/meter/get-user-resource-summary
8
+ // Authorization: Bearer <auth.accessToken>
9
+ // X-User-Id: <account.uid> X-Domain: <auth.domain> Accept-Language: zh
10
+ //
11
+ // 返回 data.TotalCredits(credits,小数)或 data.Packages[].CycleRemainCapacity。
12
+ // 企业账号走 /billing/meter/get-enterprise-user-usage(X-Enterprise-Id),
13
+ // 返回 limit_num / used_num(limit_num=-1 表示不限量,此时不给数字)。
14
+ //
15
+ // 票据就在本机 auth/<id>.info;桌面 App 那几份(workbuddy-desktop*.info)的
16
+ // token 是 $wbEncrypted(受管密钥),独立进程读不了 —— 解析时自然跳过。
17
+ //
18
+ // 约束:只读票据 + 只发这一条只读请求;任何失败都返回 "",--engines 因此不带该字段,
19
+ // 绝不影响引擎列表本身。
20
+
21
+ import (
22
+ "context"
23
+ "encoding/json"
24
+ "fmt"
25
+ "io"
26
+ "net/http"
27
+ "os"
28
+ "path/filepath"
29
+ "runtime"
30
+ "sort"
31
+ "strconv"
32
+ "strings"
33
+ "time"
34
+ )
35
+
36
+ // CreditLister 能报告账号当前剩余积分(仅 codebuddy / codebuddy-ai 实现)。
37
+ //
38
+ // 返回十进制字符串(如 "5939.2",已去掉多余的 0);未登录 / 无票据 / 查询失败
39
+ // 返回空串 —— 调用方据此省略字段,而不是显示 0。
40
+ type CreditLister interface {
41
+ CreditLeft(ctx context.Context) string
42
+ }
43
+
44
+ // LoginChecker 多账号引擎的**槽位**是否已登录(`--engines` 的 logged_in 字段)。
45
+ //
46
+ // codebuddy / codebuddy-ai 是两个独立账号槽位(各自票据目录),没登录的那个
47
+ // 不该出现在引擎选择器里 —— 「登录了几个账号就显示几个」,而不是固定两行。
48
+ // 判定只看票据文件是否存在且可读(App 的 $wbEncrypted 票据不算),不发网络请求。
49
+ type LoginChecker interface {
50
+ LoggedIn() bool
51
+ }
52
+
53
+ // LoggedIn 实现 LoginChecker:codebuddy 主账号。
54
+ func (e *CodeBuddyEngine) LoggedIn() bool {
55
+ _, ok := readCodebuddyTicket(codebuddyAuthDir(userHomeDir()), codebuddyAuthID)
56
+ return ok
57
+ }
58
+
59
+ // LoggedIn 实现 LoginChecker:codebuddy-ai 的隔离账号。
60
+ func (e *CodeBuddyAIEngine) LoggedIn() bool {
61
+ _, ok := readCodebuddyTicket(codebuddyAuthDir(codebuddyAIDir()), codebuddyAIAuthID)
62
+ return ok
63
+ }
64
+
65
+ // AccountLister 多账号引擎的**账号昵称**(`--engines` / `--quotas` 的 account 字段)。
66
+ //
67
+ // 两个 codebuddy 槽位在选择器里只差一个引擎名,用户看不出「这行是哪个号」——
68
+ // 昵称来自票据里的 account.nickname(登录时写进去的),只读文件、不发网络。
69
+ // 未登录 / 票据里没有昵称时返回空串(调用方省略该字段)。
70
+ type AccountLister interface {
71
+ AccountName() string
72
+ }
73
+
74
+ // AccountName 实现 AccountLister:codebuddy 主账号。
75
+ func (e *CodeBuddyEngine) AccountName() string {
76
+ t, ok := readCodebuddyTicket(codebuddyAuthDir(userHomeDir()), codebuddyAuthID)
77
+ if !ok {
78
+ return ""
79
+ }
80
+ return t.Nickname
81
+ }
82
+
83
+ // AccountName 实现 AccountLister:codebuddy-ai 的隔离账号。
84
+ func (e *CodeBuddyAIEngine) AccountName() string {
85
+ t, ok := readCodebuddyTicket(codebuddyAuthDir(codebuddyAIDir()), codebuddyAIAuthID)
86
+ if !ok {
87
+ return ""
88
+ }
89
+ return t.Nickname
90
+ }
91
+
92
+ // CreditLeft 实现 CreditLister:codebuddy 主账号(真实 homedir + ~/.codebuddy)。
93
+ func (e *CodeBuddyEngine) CreditLeft(ctx context.Context) string {
94
+ return codebuddyCreditLeft(ctx, userHomeDir(), codebuddyAuthID)
95
+ }
96
+
97
+ // CreditLeft 实现 CreditLister:codebuddy-ai 的隔离账号(homedir = ~/.codebuddy-ai)。
98
+ func (e *CodeBuddyAIEngine) CreditLeft(ctx context.Context) string {
99
+ return codebuddyCreditLeft(ctx, codebuddyAIDir(), codebuddyAIAuthID)
100
+ }
101
+
102
+ // userHomeDir 当前用户家目录(与 CLI 的 os.homedir() 同源:Windows 读 USERPROFILE)。
103
+ func userHomeDir() string {
104
+ home, err := os.UserHomeDir()
105
+ if err != nil || home == "" {
106
+ home = os.Getenv("HOME")
107
+ }
108
+ return home
109
+ }
110
+
111
+ // codebuddyAuthDir 票据目录。与 CLI 的 FilePathService 同构:
112
+ //
113
+ // <home>/AppData/Local/CodeBuddyExtension/Data/Public/auth (win32)
114
+ // <home>/Library/Application Support/CodeBuddyExtension/.../auth (darwin)
115
+ // <home>/.local/share/CodeBuddyExtension/Data/Public/auth (linux)
116
+ //
117
+ // codebuddy-ai 传自己的隔离 home(~/.codebuddy-ai),因此票据天然与主引擎分开。
118
+ func codebuddyAuthDir(home string) string {
119
+ switch runtime.GOOS {
120
+ case "windows":
121
+ return filepath.Join(home, "AppData", "Local", "CodeBuddyExtension", "Data", "Public", "auth")
122
+ case "darwin":
123
+ return filepath.Join(home, "Library", "Application Support", "CodeBuddyExtension", "Data", "Public", "auth")
124
+ default:
125
+ return filepath.Join(home, ".local", "share", "CodeBuddyExtension", "Data", "Public", "auth")
126
+ }
127
+ }
128
+
129
+ // codebuddyTicket 一张可用的 CLI 票据(只取查余额 / 展示账号需要的字段)。
130
+ type codebuddyTicket struct {
131
+ UID string
132
+ Nickname string
133
+ EnterpriseID string
134
+ Token string
135
+ Domain string
136
+ }
137
+
138
+ // baseProductAuthID CLI 包内 product.json 的 authentication.id —— 新版 CLI 有时会把
139
+ // 票据写到这个名字上(见 codebuddy.go 的账号隔离说明),故作为第二候选。
140
+ const baseProductAuthID = "Tencent-Cloud.coding-copilot"
141
+
142
+ // readCodebuddyTicket 在 auth 目录里挑一张可读的票据。
143
+ // 顺序:本引擎 id → base product id → 其余 *.info 按 mtime 新到旧(跳过 $wbEncrypted)。
144
+ func readCodebuddyTicket(authDir, authID string) (codebuddyTicket, bool) {
145
+ preferred := []string{
146
+ filepath.Join(authDir, authID+".info"),
147
+ filepath.Join(authDir, baseProductAuthID+".info"),
148
+ }
149
+ seen := map[string]bool{}
150
+ for _, p := range preferred {
151
+ seen[p] = true
152
+ }
153
+ type cand struct {
154
+ path string
155
+ mod time.Time
156
+ }
157
+ var rest []cand
158
+ entries, err := os.ReadDir(authDir)
159
+ if err != nil {
160
+ return codebuddyTicket{}, false
161
+ }
162
+ for _, e := range entries {
163
+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".info") {
164
+ continue
165
+ }
166
+ p := filepath.Join(authDir, e.Name())
167
+ if seen[p] {
168
+ continue
169
+ }
170
+ if st, statErr := e.Info(); statErr == nil {
171
+ rest = append(rest, cand{p, st.ModTime()})
172
+ }
173
+ }
174
+ sort.Slice(rest, func(i, j int) bool { return rest[i].mod.After(rest[j].mod) })
175
+
176
+ for _, p := range preferred {
177
+ if t, ok := parseCodebuddyTicket(p); ok {
178
+ return t, true
179
+ }
180
+ }
181
+ for _, c := range rest {
182
+ if t, ok := parseCodebuddyTicket(c.path); ok {
183
+ return t, true
184
+ }
185
+ }
186
+ return codebuddyTicket{}, false
187
+ }
188
+
189
+ // parseCodebuddyTicket 解析一张票据;App 的加密 token(对象形态)直接判不可用。
190
+ func parseCodebuddyTicket(path string) (codebuddyTicket, bool) {
191
+ raw, err := os.ReadFile(path)
192
+ if err != nil {
193
+ return codebuddyTicket{}, false
194
+ }
195
+ var doc struct {
196
+ Account struct {
197
+ UID string `json:"uid"`
198
+ Nickname string `json:"nickname"`
199
+ EnterpriseID string `json:"enterpriseId"`
200
+ } `json:"account"`
201
+ Auth struct {
202
+ AccessToken json.RawMessage `json:"accessToken"`
203
+ Domain string `json:"domain"`
204
+ } `json:"auth"`
205
+ }
206
+ if json.Unmarshal(raw, &doc) != nil {
207
+ return codebuddyTicket{}, false
208
+ }
209
+ var tok string
210
+ // $wbEncrypted 是对象;独立 CLI 解不开,跳过。
211
+ if len(doc.Auth.AccessToken) == 0 || json.Unmarshal(doc.Auth.AccessToken, &tok) != nil || tok == "" {
212
+ return codebuddyTicket{}, false
213
+ }
214
+ if doc.Auth.Domain == "" {
215
+ return codebuddyTicket{}, false
216
+ }
217
+ return codebuddyTicket{
218
+ UID: doc.Account.UID,
219
+ Nickname: strings.TrimSpace(doc.Account.Nickname),
220
+ EnterpriseID: doc.Account.EnterpriseID,
221
+ Token: tok,
222
+ Domain: doc.Auth.Domain,
223
+ }, true
224
+ }
225
+
226
+ // codebuddyCreditLeft 查一张票据对应的剩余积分;失败返回 ""。
227
+ func codebuddyCreditLeft(ctx context.Context, home, authID string) string {
228
+ t, ok := readCodebuddyTicket(codebuddyAuthDir(home), authID)
229
+ if !ok {
230
+ return ""
231
+ }
232
+ ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
233
+ defer cancel()
234
+ if t.EnterpriseID != "" {
235
+ return fetchEnterpriseCreditLeft(ctx, t)
236
+ }
237
+ return fetchPersonalCreditLeft(ctx, t)
238
+ }
239
+
240
+ // codebuddyBillingPost 发一次只读账单请求,返回响应体;非 200 / 网络错误返回 false。
241
+ func codebuddyBillingPost(ctx context.Context, t codebuddyTicket, path string, extraHeaders map[string]string) ([]byte, bool) {
242
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost,
243
+ "https://"+t.Domain+path, strings.NewReader("{}"))
244
+ if err != nil {
245
+ return nil, false
246
+ }
247
+ req.Header.Set("Content-Type", "application/json")
248
+ req.Header.Set("Accept-Language", "zh")
249
+ req.Header.Set("Authorization", "Bearer "+t.Token)
250
+ req.Header.Set("X-Domain", t.Domain)
251
+ if t.UID != "" {
252
+ req.Header.Set("X-User-Id", t.UID)
253
+ }
254
+ for k, v := range extraHeaders {
255
+ req.Header.Set(k, v)
256
+ }
257
+ client := &http.Client{Timeout: 8 * time.Second}
258
+ resp, err := client.Do(req)
259
+ if err != nil {
260
+ return nil, false
261
+ }
262
+ defer resp.Body.Close()
263
+ /* 不按 HTTP 状态码直接判失败:领取接口在「今天已领」这类业务失败时会返回
264
+ 非 200 + JSON(code/msg)—— 那份正文才是给用户看的原因。解析交给调用方。 */
265
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
266
+ if err != nil || len(body) == 0 {
267
+ return nil, false
268
+ }
269
+ return body, true
270
+ }
271
+
272
+ // fetchPersonalCreditLeft 个人账号:优先 data.TotalCredits,缺失时累加各包剩余。
273
+ func fetchPersonalCreditLeft(ctx context.Context, t codebuddyTicket) string {
274
+ body, ok := codebuddyBillingPost(ctx, t, "/billing/meter/get-user-resource-summary", nil)
275
+ if !ok {
276
+ return ""
277
+ }
278
+ return personalCreditLeftFromJSON(body)
279
+ }
280
+
281
+ // personalCreditLeftFromJSON 解析 get-user-resource-summary 的响应(抽出来便于单测)。
282
+ func personalCreditLeftFromJSON(body []byte) string {
283
+ var doc struct {
284
+ Code int `json:"code"`
285
+ Data struct {
286
+ TotalCredits json.RawMessage `json:"TotalCredits"`
287
+ Packages []struct {
288
+ CycleRemainCapacity json.RawMessage `json:"CycleRemainCapacity"`
289
+ } `json:"Packages"`
290
+ } `json:"data"`
291
+ }
292
+ if json.Unmarshal(body, &doc) != nil || doc.Code != 0 {
293
+ return ""
294
+ }
295
+ if v, ok := creditFloat(doc.Data.TotalCredits); ok {
296
+ return formatCredit(v)
297
+ }
298
+ var sum float64
299
+ found := false
300
+ for _, p := range doc.Data.Packages {
301
+ if v, ok := creditFloat(p.CycleRemainCapacity); ok {
302
+ sum += v
303
+ found = true
304
+ }
305
+ }
306
+ if !found {
307
+ return ""
308
+ }
309
+ return formatCredit(sum)
310
+ }
311
+
312
+ // fetchEnterpriseCreditLeft 企业账号:limit_num - used_num(-1 = 不限量,不给数字)。
313
+ func fetchEnterpriseCreditLeft(ctx context.Context, t codebuddyTicket) string {
314
+ body, ok := codebuddyBillingPost(ctx, t, "/billing/meter/get-enterprise-user-usage",
315
+ map[string]string{"X-Enterprise-Id": t.EnterpriseID})
316
+ if !ok {
317
+ return ""
318
+ }
319
+ return enterpriseCreditLeftFromJSON(body)
320
+ }
321
+
322
+ // enterpriseCreditLeftFromJSON 解析 get-enterprise-user-usage 的响应。
323
+ func enterpriseCreditLeftFromJSON(body []byte) string {
324
+ var doc struct {
325
+ Code int `json:"code"`
326
+ Data struct {
327
+ LimitNum json.RawMessage `json:"limit_num"`
328
+ UsedNum json.RawMessage `json:"used_num"`
329
+ } `json:"data"`
330
+ }
331
+ if json.Unmarshal(body, &doc) != nil || doc.Code != 0 {
332
+ return ""
333
+ }
334
+ limit, ok := creditFloat(doc.Data.LimitNum)
335
+ if !ok || limit < 0 {
336
+ return ""
337
+ }
338
+ used, _ := creditFloat(doc.Data.UsedNum)
339
+ if left := limit - used; left > 0 {
340
+ return formatCredit(left)
341
+ }
342
+ return "0"
343
+ }
344
+
345
+ // creditFloat 解析「数字或字符串形式的数字」(接口里两种形态都出现过)。
346
+ func creditFloat(raw json.RawMessage) (float64, bool) {
347
+ if len(raw) == 0 {
348
+ return 0, false
349
+ }
350
+ s := string(raw)
351
+ var quoted string
352
+ if json.Unmarshal(raw, &quoted) == nil {
353
+ s = quoted
354
+ }
355
+ s = strings.TrimSpace(s)
356
+ if s == "" {
357
+ return 0, false
358
+ }
359
+ f, err := strconv.ParseFloat(s, 64)
360
+ if err != nil {
361
+ return 0, false
362
+ }
363
+ return f, true
364
+ }
365
+
366
+ // formatCredit 归一化成最多两位小数、去掉多余的 0(5939.20000022 → "5939.2")。
367
+ func formatCredit(f float64) string {
368
+ s := strconv.FormatFloat(f, 'f', 2, 64)
369
+ s = strings.TrimRight(s, "0")
370
+ s = strings.TrimRight(s, ".")
371
+ if s == "" || s == "-0" {
372
+ return "0"
373
+ }
374
+ return s
375
+ }
376
+
377
+ // ── 每日积分签到 ──────────────────────────────────────────────
378
+ //
379
+ // 状态:POST https://<domain>/billing/meter/checkin-activity-status
380
+ // 领取:POST https://<domain>/billing/meter/daily-checkin
381
+ //
382
+ // ⚠️ 别用旧的 /billing/meter/checkin-status:实测返回 active=false 全 0,
383
+ // 活动接口是 checkin-activity-status(字段 today_checked_in / daily_credit / streak_days)。
384
+
385
+ // CheckinInfo 每日签到状态 / 领取结果。
386
+ type CheckinInfo struct {
387
+ // CheckedToday 今天是否已领。
388
+ CheckedToday bool
389
+ // DailyCredit 今日可领积分(十进制字符串,可能为空)。
390
+ DailyCredit string
391
+ // Credit 本次实际到账积分(仅领取成功时有)。
392
+ Credit string
393
+ // StreakDays 连续签到天数。
394
+ StreakDays int
395
+ // Message 业务失败原因(如「今天已经领过了」)。
396
+ Message string
397
+ }
398
+
399
+ // Checkiner 每日积分签到(仅 codebuddy / codebuddy-ai 实现)。
400
+ type Checkiner interface {
401
+ // CheckinStatus 返回签到状态;第二个返回值 false = 拿不到(未登录 / 活动未开 / 网络失败)。
402
+ CheckinStatus(ctx context.Context) (CheckinInfo, bool)
403
+ // ClaimCheckin 领取每日积分;业务失败返回 error(Message 里是服务端原因)。
404
+ ClaimCheckin(ctx context.Context) (CheckinInfo, error)
405
+ }
406
+
407
+ // CheckinStatus 实现 Checkiner:codebuddy 主账号。
408
+ func (e *CodeBuddyEngine) CheckinStatus(ctx context.Context) (CheckinInfo, bool) {
409
+ return codebuddyCheckinStatus(ctx, userHomeDir(), codebuddyAuthID)
410
+ }
411
+
412
+ // CheckinStatus 实现 Checkiner:codebuddy-ai 的隔离账号。
413
+ func (e *CodeBuddyAIEngine) CheckinStatus(ctx context.Context) (CheckinInfo, bool) {
414
+ return codebuddyCheckinStatus(ctx, codebuddyAIDir(), codebuddyAIAuthID)
415
+ }
416
+
417
+ // ClaimCheckin 实现 Checkiner:codebuddy 主账号。
418
+ func (e *CodeBuddyEngine) ClaimCheckin(ctx context.Context) (CheckinInfo, error) {
419
+ return codebuddyClaimCheckin(ctx, userHomeDir(), codebuddyAuthID)
420
+ }
421
+
422
+ // ClaimCheckin 实现 Checkiner:codebuddy-ai 的隔离账号。
423
+ func (e *CodeBuddyAIEngine) ClaimCheckin(ctx context.Context) (CheckinInfo, error) {
424
+ return codebuddyClaimCheckin(ctx, codebuddyAIDir(), codebuddyAIAuthID)
425
+ }
426
+
427
+ // codebuddyCheckinStatus 查签到状态;拿不到返回 false(--engines 因此省略 checkin_today)。
428
+ func codebuddyCheckinStatus(ctx context.Context, home, authID string) (CheckinInfo, bool) {
429
+ t, ok := readCodebuddyTicket(codebuddyAuthDir(home), authID)
430
+ if !ok {
431
+ return CheckinInfo{}, false
432
+ }
433
+ ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
434
+ defer cancel()
435
+ body, ok := codebuddyBillingPost(ctx, t, "/billing/meter/checkin-activity-status", nil)
436
+ if !ok {
437
+ return CheckinInfo{}, false
438
+ }
439
+ return checkinStatusFromJSON(body)
440
+ }
441
+
442
+ // checkinStatusFromJSON 解析 checkin-activity-status(抽出来便于单测)。
443
+ func checkinStatusFromJSON(body []byte) (CheckinInfo, bool) {
444
+ var doc struct {
445
+ Code int `json:"code"`
446
+ Data struct {
447
+ Active bool `json:"active"`
448
+ TodayCheckedIn bool `json:"today_checked_in"`
449
+ DailyCredit json.RawMessage `json:"daily_credit"`
450
+ StreakDays int `json:"streak_days"`
451
+ } `json:"data"`
452
+ }
453
+ if json.Unmarshal(body, &doc) != nil || doc.Code != 0 || !doc.Data.Active {
454
+ return CheckinInfo{}, false
455
+ }
456
+ daily := ""
457
+ if v, ok := creditFloat(doc.Data.DailyCredit); ok {
458
+ daily = formatCredit(v)
459
+ }
460
+ return CheckinInfo{
461
+ CheckedToday: doc.Data.TodayCheckedIn,
462
+ DailyCredit: daily,
463
+ StreakDays: doc.Data.StreakDays,
464
+ }, true
465
+ }
466
+
467
+ // codebuddyClaimCheckin 领取每日积分(重复领取会返回业务错误,不会重复加)。
468
+ //
469
+ // 无论成功还是业务失败(今天已签),都**回查一次状态**:领取接口的失败响应不带
470
+ // 「今天是否已领」,不回查的话客户端领完一次(或发现已领)后仍会显示「领取」按钮。
471
+ func codebuddyClaimCheckin(ctx context.Context, home, authID string) (CheckinInfo, error) {
472
+ t, ok := readCodebuddyTicket(codebuddyAuthDir(home), authID)
473
+ if !ok {
474
+ return CheckinInfo{}, fmt.Errorf("没有可用的登录票据(先 magic-agent --login %s)", authID)
475
+ }
476
+ ctx, cancel := context.WithTimeout(ctx, 12*time.Second)
477
+ defer cancel()
478
+ body, ok := codebuddyBillingPost(ctx, t, "/billing/meter/daily-checkin", nil)
479
+ if !ok {
480
+ return CheckinInfo{}, fmt.Errorf("领取请求失败(网络或服务端错误)")
481
+ }
482
+ info, claimErr := claimCheckinFromJSON(body)
483
+ if st, ok := codebuddyCheckinStatus(ctx, home, authID); ok {
484
+ st.Credit = info.Credit
485
+ if info.Message != "" {
486
+ st.Message = info.Message
487
+ }
488
+ return st, claimErr
489
+ }
490
+ return info, claimErr
491
+ }
492
+
493
+ // claimCheckinFromJSON 解析 daily-checkin 的响应。成功时 credit/streak_days 可能在
494
+ // 顶层或 data 里,两处都认。
495
+ func claimCheckinFromJSON(body []byte) (CheckinInfo, error) {
496
+ var doc struct {
497
+ Code int `json:"code"`
498
+ Msg string `json:"msg"`
499
+ Credit json.RawMessage `json:"credit"`
500
+ StreakDays int `json:"streak_days"`
501
+ Data struct {
502
+ Credit json.RawMessage `json:"credit"`
503
+ StreakDays int `json:"streak_days"`
504
+ TodayCheckedIn bool `json:"today_checked_in"`
505
+ } `json:"data"`
506
+ }
507
+ if json.Unmarshal(body, &doc) != nil {
508
+ return CheckinInfo{}, fmt.Errorf("领取响应解析失败")
509
+ }
510
+ if doc.Code != 0 {
511
+ msg := strings.TrimSpace(doc.Msg)
512
+ if msg == "" {
513
+ msg = fmt.Sprintf("领取失败(code=%d)", doc.Code)
514
+ }
515
+ return CheckinInfo{Message: msg}, fmt.Errorf("%s", msg)
516
+ }
517
+ raw := doc.Credit
518
+ if len(raw) == 0 {
519
+ raw = doc.Data.Credit
520
+ }
521
+ credit := ""
522
+ if v, ok := creditFloat(raw); ok {
523
+ credit = formatCredit(v)
524
+ }
525
+ streak := doc.StreakDays
526
+ if streak == 0 {
527
+ streak = doc.Data.StreakDays
528
+ }
529
+ return CheckinInfo{
530
+ CheckedToday: true,
531
+ Credit: credit,
532
+ StreakDays: streak,
533
+ }, nil
534
+ }
535
+
536
+ // ── llm 引擎:MiniMax 套餐(Token Plan / Coding Plan)剩余额度 ──────────────
537
+ //
538
+ // 额度在套餐用量接口(只读,Bearer <API Key>):
539
+ //
540
+ // GET https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains
541
+ // GET https://www.minimaxi.com/v1/token_plan/remains
542
+ //
543
+ // 返回 model_remains[],取 model_name=general 的两个窗口:
544
+ // current_interval_remaining_percent 5 小时窗口剩余百分比
545
+ // current_weekly_remaining_percent 本周剩余百分比
546
+ //(同一份票据两个接口当前返回一致,先试 coding_plan 再退 token_plan)。
547
+ // key 从 ~/.magic-agent/models.json 里带 minimax 域名的条目取(sk-cp-… 是 Coding Plan)。
548
+ //
549
+ // 额度按**模型**挂(ModelQuota),不挂引擎行:llm 下还有火山等别的供应商模型。
550
+ // 任何失败都不给该字段,不影响引擎列表。
551
+
552
+ // CreditUnitLister 可选接口:说明 CreditLeft / ModelQuota 的单位(缺省按「积分」展示)。
553
+ // MiniMax 套餐给的是百分比,客户端据此把「剩余 53」显示成「剩余 53%」。
554
+ type CreditUnitLister interface {
555
+ CreditUnit() string
556
+ }
557
+
558
+ // QuotaWindows 套餐在「5 小时窗口 / 本周」两个维度的剩余百分比(已格式化的数字串,
559
+ // 如 "98" / "53")。空串 = 该窗口没有数据。
560
+ type QuotaWindows struct {
561
+ IntervalLeft string `json:"interval_left,omitempty"`
562
+ WeeklyLeft string `json:"weekly_left,omitempty"`
563
+ }
564
+
565
+ // ModelQuotaLister 可选:按**模型**报告套餐额度快照(model id → 两个窗口的剩余百分比)。
566
+ // llm 用它把 MiniMax 套餐额度挂到真正走 MiniMax 端点的模型上 —— 不挂在引擎行,
567
+ // 因为同一个 llm 引擎下还有别的供应商模型(ark-glm 等),引擎级额度会张冠李戴。
568
+ type ModelQuotaLister interface {
569
+ ModelQuota(ctx context.Context) map[string]QuotaWindows
570
+ }
571
+
572
+ // ModelQuota 实现 ModelQuotaLister:models.json 里 URL 指向 minimax 的模型**各用各的
573
+ // API Key** 查额度 —— 不同 key 是不同账号/套餐,额度当然不同;只有共用同一把 key
574
+ // 的模型才共享同一份额度。
575
+ func (e *LLMEngine) ModelQuota(ctx context.Context) map[string]QuotaWindows {
576
+ list, err := LoadLLMConfigModels()
577
+ if err != nil {
578
+ return nil
579
+ }
580
+ return modelQuotaMap(ctx, list, minimaxQuota)
581
+ }
582
+
583
+ // CreditUnit 实现 CreditUnitLister:MiniMax 额度是百分比,不是积分。
584
+ func (e *LLMEngine) CreditUnit() string { return "%" }
585
+
586
+ // CLIModelQuotas 给 codebuddy 系用:按它自己 models.json 里**自定义模型**的
587
+ // (端点, key) 查 MiniMax 套餐额度,映射到展示 id(有别名用别名,与清单同形)。
588
+ //
589
+ // 为什么挂到 codebuddy:用户在 models.json 里接的 MiniMax 自定义端点(custom-local:*)
590
+ // 与 llm 下的是同一类 token plan,额度也应该在选择器里显示;官方 minimax-* 走平台
591
+ // 账号、没有用户侧的 key,查不到自然不显示。
592
+ func CLIModelQuotas(ctx context.Context, configDir string) map[string]QuotaWindows {
593
+ customs := CLICustomModels(configDir)
594
+ if len(customs) == 0 {
595
+ return nil
596
+ }
597
+ list := make([]LLMConfigModel, 0, len(customs))
598
+ for _, m := range customs {
599
+ if m.URL == "" || m.APIKey == "" {
600
+ continue
601
+ }
602
+ list = append(list, LLMConfigModel{ID: m.ID, URL: m.URL, APIKey: m.APIKey})
603
+ }
604
+ if len(list) == 0 {
605
+ return nil
606
+ }
607
+ return modelQuotaMap(ctx, list, minimaxQuota)
608
+ }
609
+
610
+ // modelQuotaMap 按 (站点, key) 去重查额度,再映射回各自的模型 id。
611
+ // 同一把 key 只查一次;查不到的模型不给字段(不是复用别人的额度)。
612
+ func modelQuotaMap(ctx context.Context, list []LLMConfigModel,
613
+ query func(context.Context, string, string) (QuotaWindows, bool)) map[string]QuotaWindows {
614
+ type res struct {
615
+ q QuotaWindows
616
+ ok bool
617
+ }
618
+ cache := make(map[string]res)
619
+ out := make(map[string]QuotaWindows)
620
+ for _, m := range list {
621
+ host := minimaxHost(m.URL)
622
+ id := strings.TrimSpace(m.ID)
623
+ key := strings.TrimSpace(m.APIKey)
624
+ if host == "" || id == "" || key == "" {
625
+ continue
626
+ }
627
+ ck := host + "\x00" + key
628
+ r, hit := cache[ck]
629
+ if !hit {
630
+ r.q, r.ok = query(ctx, host, key)
631
+ cache[ck] = r
632
+ }
633
+ if !r.ok || (r.q.IntervalLeft == "" && r.q.WeeklyLeft == "") {
634
+ continue
635
+ }
636
+ out[id] = r.q
637
+ }
638
+ if len(out) == 0 {
639
+ return nil
640
+ }
641
+ return out
642
+ }
643
+
644
+ // minimaxHost 返回该 URL 对应套餐用量接口的站点根;不是 MiniMax 端点返回 ""。
645
+ func minimaxHost(rawURL string) string {
646
+ u := strings.ToLower(strings.TrimSpace(rawURL))
647
+ switch {
648
+ case strings.Contains(u, "minimax.io"):
649
+ return "https://www.minimax.io"
650
+ case strings.Contains(u, "minimaxi.com"):
651
+ return "https://www.minimaxi.com"
652
+ }
653
+ return ""
654
+ }
655
+
656
+ // minimaxQuota 依次试 coding_plan / token_plan 两个用量接口。
657
+ func minimaxQuota(ctx context.Context, host, key string) (QuotaWindows, bool) {
658
+ ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
659
+ defer cancel()
660
+ for _, p := range []string{
661
+ "/v1/api/openplatform/coding_plan/remains",
662
+ "/v1/token_plan/remains",
663
+ } {
664
+ if q, ok := minimaxQuotaAt(ctx, host+p, key); ok {
665
+ return q, true
666
+ }
667
+ }
668
+ return QuotaWindows{}, false
669
+ }
670
+
671
+ // minimaxQuotaAt 查一次套餐用量,取 general 模型的「5 小时 / 本周」剩余百分比。
672
+ func minimaxQuotaAt(ctx context.Context, url, key string) (QuotaWindows, bool) {
673
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
674
+ if err != nil {
675
+ return QuotaWindows{}, false
676
+ }
677
+ req.Header.Set("Authorization", "Bearer "+key)
678
+ req.Header.Set("Accept", "application/json")
679
+ resp, err := http.DefaultClient.Do(req)
680
+ if err != nil {
681
+ return QuotaWindows{}, false
682
+ }
683
+ defer resp.Body.Close()
684
+ if resp.StatusCode != http.StatusOK {
685
+ return QuotaWindows{}, false
686
+ }
687
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
688
+ if err != nil {
689
+ return QuotaWindows{}, false
690
+ }
691
+ var doc struct {
692
+ ModelRemains []struct {
693
+ ModelName string `json:"model_name"`
694
+ IntervalPct float64 `json:"current_interval_remaining_percent"`
695
+ WeeklyPercent float64 `json:"current_weekly_remaining_percent"`
696
+ } `json:"model_remains"`
697
+ BaseResp struct {
698
+ StatusCode int `json:"status_code"`
699
+ } `json:"base_resp"`
700
+ }
701
+ if json.Unmarshal(body, &doc) != nil || doc.BaseResp.StatusCode != 0 {
702
+ return QuotaWindows{}, false
703
+ }
704
+ for _, m := range doc.ModelRemains {
705
+ if m.ModelName == "general" {
706
+ return QuotaWindows{
707
+ IntervalLeft: formatCredit(m.IntervalPct),
708
+ WeeklyLeft: formatCredit(m.WeeklyPercent),
709
+ }, true
710
+ }
711
+ }
712
+ return QuotaWindows{}, false
713
+ }