magic-agent 2.4.3 → 2.6.3

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 (207) hide show
  1. package/CLIENT-CONTRACT.md +467 -41
  2. package/README.md +15 -14
  3. package/client-ui/pc/app.css +74 -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 +40 -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 +247 -23
  11. package/client-ui/shared/conv/06-ask-answer.js +75 -1
  12. package/client-ui/shared/conv/09-stream-apply.js +72 -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 +217 -0
  16. package/client-ui/shared/conv/conv.css +329 -174
  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 +373 -50
  20. package/client-ui/shared/transport-http.js +14 -0
  21. package/client-ui-mobile/apps.css +16 -0
  22. package/client-ui-mobile/apps.js +406 -0
  23. package/client-ui-mobile/index.html +5554 -639
  24. package/client-ui-mobile/reqs.css +110 -1
  25. package/client-ui-mobile/reqs.js +533 -74
  26. package/client-ui-mobile/reqshot.js +505 -0
  27. package/client-ui-mobile/scripts.css +67 -0
  28. package/client-ui-mobile/scripts.js +599 -0
  29. package/client-ui-mobile/vendor/cose-base.js +3214 -0
  30. package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
  31. package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
  32. package/client-ui-mobile/vendor/layout-base.js +5230 -0
  33. package/cmd/magic-client/browsectl.go +199 -0
  34. package/cmd/magic-client/clientctl.go +69 -10
  35. package/cmd/magic-client/main.go +210 -32
  36. package/internal/agent/ask.go +45 -0
  37. package/internal/agent/ask_test.go +39 -0
  38. package/internal/agent/attachments_test.go +6 -2
  39. package/internal/agent/capability.go +7 -1
  40. package/internal/agent/claude.go +20 -15
  41. package/internal/agent/codebuddy.go +125 -34
  42. package/internal/agent/codebuddy_account_test.go +54 -0
  43. package/internal/agent/codebuddy_stream_test.go +103 -0
  44. package/internal/agent/credits.go +713 -0
  45. package/internal/agent/credits_minimax_test.go +97 -0
  46. package/internal/agent/credits_test.go +211 -0
  47. package/internal/agent/custom_models.go +155 -0
  48. package/internal/agent/custom_models_test.go +125 -0
  49. package/internal/agent/dsh.go +123 -49
  50. package/internal/agent/dsh_caps_cache_test.go +184 -0
  51. package/internal/agent/dsh_resume.go +157 -7
  52. package/internal/agent/dsh_test.go +108 -6
  53. package/internal/agent/engine.go +37 -1
  54. package/internal/agent/engine_base.go +21 -17
  55. package/internal/agent/engine_base_test.go +2 -2
  56. package/internal/agent/engine_test.go +47 -86
  57. package/internal/agent/env.go +173 -1
  58. package/internal/agent/env_test.go +114 -0
  59. package/internal/agent/fallback.go +313 -0
  60. package/internal/agent/fallback_test.go +340 -0
  61. package/internal/agent/launch.go +43 -1
  62. package/internal/agent/limitnotice.go +207 -1
  63. package/internal/agent/limitnotice_test.go +140 -0
  64. package/internal/agent/llmhttp.go +46 -4
  65. package/internal/agent/llmhttp_test.go +157 -0
  66. package/internal/agent/models.go +53 -1
  67. package/internal/agent/models_test.go +20 -5
  68. package/internal/agent/pidalive_windows_test.go +40 -0
  69. package/internal/agent/runcmd.go +26 -0
  70. package/internal/agent/runcmd_windows.go +36 -5
  71. package/internal/agent/stream.go +166 -6
  72. package/internal/agent/stream_test.go +25 -38
  73. package/internal/agent/stream_timeline_test.go +2 -37
  74. package/internal/agent/streamjson.go +119 -7
  75. package/internal/agent/tools_suffix_test.go +6 -3
  76. package/internal/agent/trae.go +50 -243
  77. package/internal/agent/trae_exec.go +433 -0
  78. package/internal/agent/trae_exec_test.go +181 -0
  79. package/internal/agent/wraplaunch_test.go +113 -0
  80. package/internal/cli/ask.go +168 -37
  81. package/internal/cli/ask_test.go +1 -1
  82. package/internal/cli/checkin.go +77 -0
  83. package/internal/cli/clientcmd.go +3 -2
  84. package/internal/cli/control.go +46 -5
  85. package/internal/cli/control_test.go +65 -0
  86. package/internal/cli/engines_test.go +103 -0
  87. package/internal/cli/events.go +15 -0
  88. package/internal/cli/events_test.go +50 -0
  89. package/internal/cli/judge.go +104 -0
  90. package/internal/cli/quotas.go +125 -0
  91. package/internal/cli/requirements.go +51 -0
  92. package/internal/cli/root.go +157 -11
  93. package/internal/cli/session.go +75 -3
  94. package/internal/cli/session_test.go +82 -3
  95. package/internal/cli/sessionlog.go +6 -0
  96. package/internal/cli/sessionlog_test.go +22 -2
  97. package/internal/cli/suggest.go +7 -5
  98. package/internal/cli/team.go +37 -9
  99. package/internal/cli/team_test.go +39 -0
  100. package/internal/client/ab.go +372 -0
  101. package/internal/client/ab_test.go +287 -0
  102. package/internal/client/apk.go +8 -0
  103. package/internal/client/apk_test.go +59 -0
  104. package/internal/client/apps.go +1078 -0
  105. package/internal/client/apps_test.go +516 -0
  106. package/internal/client/browse.go +84 -36
  107. package/internal/client/browse_test.go +107 -9
  108. package/internal/client/browseinput.go +481 -0
  109. package/internal/client/browseinput_test.go +216 -0
  110. package/internal/client/corecmd.go +23 -0
  111. package/internal/client/corecmd_other.go +8 -0
  112. package/internal/client/corecmd_windows.go +24 -0
  113. package/internal/client/coreexe.go +196 -0
  114. package/internal/client/coreexe_test.go +161 -0
  115. package/internal/client/coreupgrade.go +866 -0
  116. package/internal/client/coreupgrade_test.go +561 -0
  117. package/internal/client/daemon.go +297 -33
  118. package/internal/client/daemon_test.go +33 -0
  119. package/internal/client/engines_checkin_test.go +57 -0
  120. package/internal/client/fallback_flags_test.go +64 -0
  121. package/internal/client/feishu.go +1259 -0
  122. package/internal/client/feishu_test.go +649 -0
  123. package/internal/client/health_identity_test.go +84 -0
  124. package/internal/client/hostenv.go +68 -0
  125. package/internal/client/hostenv_test.go +37 -0
  126. package/internal/client/jobs.go +518 -24
  127. package/internal/client/jobs_auto.go +571 -0
  128. package/internal/client/jobs_auto_test.go +431 -0
  129. package/internal/client/jobs_test.go +299 -2
  130. package/internal/client/knowledge.go +854 -0
  131. package/internal/client/knowledge_test.go +510 -0
  132. package/internal/client/lan.go +3 -3
  133. package/internal/client/plugins.go +5 -1
  134. package/internal/client/prefs.go +67 -0
  135. package/internal/client/procstart_linux.go +59 -0
  136. package/internal/client/publicurl.go +82 -0
  137. package/internal/client/quotas_merge_test.go +107 -0
  138. package/internal/client/requirements.go +207 -2
  139. package/internal/client/requirements_shots_test.go +200 -0
  140. package/internal/client/requirements_test.go +103 -0
  141. package/internal/client/restart.go +288 -0
  142. package/internal/client/restart_handoff_test.go +230 -0
  143. package/internal/client/restart_test.go +290 -0
  144. package/internal/client/scripts.go +1047 -0
  145. package/internal/client/scripts_decode_implausible.go +38 -0
  146. package/internal/client/scripts_decode_other.go +31 -0
  147. package/internal/client/scripts_decode_test.go +130 -0
  148. package/internal/client/scripts_decode_windows.go +182 -0
  149. package/internal/client/scripts_test.go +739 -0
  150. package/internal/client/service.go +717 -97
  151. package/internal/client/service_test.go +217 -1
  152. package/internal/client/sessions_cache.go +148 -0
  153. package/internal/client/sessions_cache_test.go +163 -0
  154. package/internal/client/sessions_fold.go +165 -0
  155. package/internal/client/sessions_fold_test.go +204 -0
  156. package/internal/client/singleinstance.go +470 -0
  157. package/internal/client/singleinstance_test.go +607 -0
  158. package/internal/client/suggest.go +105 -2
  159. package/internal/client/suggest_test.go +86 -0
  160. package/internal/client/team_flags.go +25 -11
  161. package/internal/client/team_flags_test.go +39 -0
  162. package/internal/client/testmain_test.go +88 -0
  163. package/internal/client/workspace_default_test.go +158 -0
  164. package/internal/client/workspacelockenv_test.go +54 -0
  165. package/internal/config/config.go +48 -0
  166. package/internal/judge/judge.go +315 -0
  167. package/internal/requirements/bootstrap.go +16 -0
  168. package/internal/requirements/consolidate.go +100 -9
  169. package/internal/requirements/doc.go +154 -3
  170. package/internal/requirements/graph.go +138 -0
  171. package/internal/requirements/memory.go +7 -0
  172. package/internal/requirements/modules.go +312 -0
  173. package/internal/requirements/modules_test.go +373 -0
  174. package/internal/requirements/relations.go +155 -0
  175. package/internal/requirements/relations_test.go +231 -0
  176. package/internal/requirements/requirements.go +10 -0
  177. package/internal/requirements/shots.go +418 -0
  178. package/internal/requirements/shots_test.go +387 -0
  179. package/internal/session/multiclient_test.go +368 -0
  180. package/internal/session/session.go +175 -11
  181. package/internal/session/session_test.go +7 -0
  182. package/internal/session/status.go +21 -0
  183. package/internal/session/status_test.go +28 -0
  184. package/internal/session/title.go +16 -0
  185. package/internal/session/title_test.go +26 -0
  186. package/internal/session/workspace_lock.go +265 -28
  187. package/internal/suggest/suggest.go +23 -1
  188. package/internal/team/event.go +3 -1
  189. package/internal/team/execute.go +21 -6
  190. package/internal/team/fallback_route_test.go +80 -0
  191. package/internal/team/planner.go +15 -3
  192. package/internal/team/route.go +89 -17
  193. package/internal/team/team.go +18 -4
  194. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  195. package/npm/apk/version.json +6 -3
  196. package/npm/dist/darwin-arm64/magic-agent +0 -0
  197. package/npm/dist/darwin-arm64/magic-client +0 -0
  198. package/npm/dist/darwin-x64/magic-agent +0 -0
  199. package/npm/dist/darwin-x64/magic-client +0 -0
  200. package/npm/dist/linux-arm64/magic-agent +0 -0
  201. package/npm/dist/linux-arm64/magic-client +0 -0
  202. package/npm/dist/linux-x64/magic-agent +0 -0
  203. package/npm/dist/linux-x64/magic-client +0 -0
  204. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  205. package/npm/dist/win32-x64/magic-client.exe +0 -0
  206. package/npm/install.js +11 -0
  207. package/package.json +14 -3
@@ -0,0 +1,97 @@
1
+ package agent
2
+
3
+ // credits_minimax_test.go - llm 引擎的 MiniMax 套餐额度(剩余百分比)解析。
4
+
5
+ import (
6
+ "context"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "testing"
10
+ )
11
+
12
+ func TestMiniMaxQuotaAt(t *testing.T) {
13
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
14
+ if got := r.Header.Get("Authorization"); got != "Bearer sk-cp-test" {
15
+ t.Errorf("Authorization = %q", got)
16
+ }
17
+ _, _ = w.Write([]byte(`{"model_remains":[
18
+ {"model_name":"video","current_interval_remaining_percent":100,"current_weekly_remaining_percent":100},
19
+ {"model_name":"general","current_interval_remaining_percent":98,"current_weekly_remaining_percent":53}
20
+ ],"base_resp":{"status_code":0,"status_msg":"success"}}`))
21
+ }))
22
+ defer srv.Close()
23
+
24
+ q, ok := minimaxQuotaAt(context.Background(), srv.URL, "sk-cp-test")
25
+ if !ok || q.IntervalLeft != "98" || q.WeeklyLeft != "53" {
26
+ t.Fatalf("got (%+v, %v), want {98 53}", q, ok)
27
+ }
28
+ }
29
+
30
+ func TestMiniMaxQuotaAtBusinessError(t *testing.T) {
31
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
32
+ _, _ = w.Write([]byte(`{"base_resp":{"status_code":1004,"status_msg":"invalid api key"}}`))
33
+ }))
34
+ defer srv.Close()
35
+
36
+ if q, ok := minimaxQuotaAt(context.Background(), srv.URL, "sk-bad"); ok {
37
+ t.Fatalf("业务失败应返回 ok=false,实际 %+v", q)
38
+ }
39
+ }
40
+
41
+ func TestModelQuotaPerKey(t *testing.T) {
42
+ list := []LLMConfigModel{
43
+ {ID: "minimax-a", URL: "https://api.minimaxi.com/v1/chat/completions", APIKey: "sk-a"},
44
+ {ID: "minimax-a-variant", URL: "https://api.minimaxi.com/v1", APIKey: "sk-a"},
45
+ {ID: "minimax-b", URL: "https://api.minimaxi.com/v1/chat/completions", APIKey: "sk-b"},
46
+ {ID: "ark-glm", URL: "https://ark.example.com/v1", APIKey: "sk-other"},
47
+ {ID: "minimax-nokey", URL: "https://api.minimaxi.com/v1"},
48
+ }
49
+ calls := 0
50
+ query := func(_ context.Context, host, key string) (QuotaWindows, bool) {
51
+ calls++
52
+ if host != "https://www.minimaxi.com" {
53
+ t.Errorf("host = %q", host)
54
+ }
55
+ switch key {
56
+ case "sk-a":
57
+ return QuotaWindows{IntervalLeft: "98", WeeklyLeft: "53"}, true
58
+ case "sk-b":
59
+ return QuotaWindows{IntervalLeft: "12", WeeklyLeft: "7"}, true
60
+ }
61
+ return QuotaWindows{}, false
62
+ }
63
+ got := modelQuotaMap(context.Background(), list, query)
64
+
65
+ if calls != 2 {
66
+ t.Fatalf("同一把 key 应只查一次,实际 %d 次", calls)
67
+ }
68
+ if q := got["minimax-a"]; q.IntervalLeft != "98" || q.WeeklyLeft != "53" {
69
+ t.Fatalf("minimax-a = %+v", q)
70
+ }
71
+ if q := got["minimax-a-variant"]; q.IntervalLeft != "98" {
72
+ t.Fatalf("同 key 的变体应与 sk-a 一致,实际 %+v", q)
73
+ }
74
+ if q := got["minimax-b"]; q.IntervalLeft != "12" || q.WeeklyLeft != "7" {
75
+ t.Fatalf("minimax-b 应按自己的 key 取额度,实际 %+v", q)
76
+ }
77
+ if _, ok := got["ark-glm"]; ok {
78
+ t.Fatal("非 MiniMax 模型不应有额度")
79
+ }
80
+ if _, ok := got["minimax-nokey"]; ok {
81
+ t.Fatal("没有 key 的模型不应有额度")
82
+ }
83
+ }
84
+
85
+ func TestMinimaxHost(t *testing.T) {
86
+ cases := map[string]string{
87
+ "https://api.minimaxi.com/v1/chat/completions": "https://www.minimaxi.com",
88
+ "https://api.minimax.io/v1": "https://www.minimax.io",
89
+ "https://ark.example.com/v1": "",
90
+ "": "",
91
+ }
92
+ for in, want := range cases {
93
+ if got := minimaxHost(in); got != want {
94
+ t.Errorf("minimaxHost(%q) = %q, want %q", in, got, want)
95
+ }
96
+ }
97
+ }
@@ -0,0 +1,211 @@
1
+ package agent
2
+
3
+ // credits_test.go - codebuddy 「剩余积分」查询的纯解析逻辑(不碰网络)。
4
+
5
+ import (
6
+ "os"
7
+ "path/filepath"
8
+ "testing"
9
+ "time"
10
+ )
11
+
12
+ func TestFormatCredit(t *testing.T) {
13
+ cases := map[float64]string{
14
+ 5939.20000022: "5939.2",
15
+ 500: "500",
16
+ 0: "0",
17
+ 0.004: "0",
18
+ 12.345: "12.35",
19
+ }
20
+ for in, want := range cases {
21
+ if got := formatCredit(in); got != want {
22
+ t.Errorf("formatCredit(%v) = %q, want %q", in, got, want)
23
+ }
24
+ }
25
+ }
26
+
27
+ func TestPersonalCreditLeftFromJSON(t *testing.T) {
28
+ // ① 接口给 TotalCredits(数字或字符串都出现过)
29
+ for _, body := range []string{
30
+ `{"code":0,"data":{"TotalCredits":5939.20000022}}`,
31
+ `{"code":0,"data":{"TotalCredits":"5939.20000022"}}`,
32
+ } {
33
+ if got := personalCreditLeftFromJSON([]byte(body)); got != "5939.2" {
34
+ t.Errorf("TotalCredits body %s → %q, want 5939.2", body, got)
35
+ }
36
+ }
37
+ // ② 没有 TotalCredits 时累加 Packages[].CycleRemainCapacity
38
+ body := `{"code":0,"data":{"Packages":[
39
+ {"CycleRemainCapacity":"5339.20000022"},
40
+ {"CycleRemainCapacity":"500"},
41
+ {"CycleRemainCapacity":100}]}}`
42
+ if got := personalCreditLeftFromJSON([]byte(body)); got != "5939.2" {
43
+ t.Errorf("包剩余累加 → %q, want 5939.2", got)
44
+ }
45
+ // ③ 业务错误 / 空数据 → 不给数字
46
+ for _, body := range []string{`{"code":14001,"msg":"no credit"}`, `{"code":0,"data":{}}`, `not-json`} {
47
+ if got := personalCreditLeftFromJSON([]byte(body)); got != "" {
48
+ t.Errorf("bad body %s → %q, want empty", body, got)
49
+ }
50
+ }
51
+ }
52
+
53
+ func TestEnterpriseCreditLeftFromJSON(t *testing.T) {
54
+ body := `{"code":0,"data":{"limit_num":1000,"used_num":321.5}}`
55
+ if got := enterpriseCreditLeftFromJSON([]byte(body)); got != "678.5" {
56
+ t.Errorf("enterprise → %q, want 678.5", got)
57
+ }
58
+ // limit_num = -1:不限量,不给数字
59
+ if got := enterpriseCreditLeftFromJSON([]byte(`{"code":0,"data":{"limit_num":-1,"used_num":10}}`)); got != "" {
60
+ t.Errorf("unlimited → %q, want empty", got)
61
+ }
62
+ if got := enterpriseCreditLeftFromJSON([]byte(`{"code":1}`)); got != "" {
63
+ t.Errorf("code!=0 → %q, want empty", got)
64
+ }
65
+ }
66
+
67
+ // 明文票据可读;桌面 App 的 $wbEncrypted token 必须被跳过(独立进程解不开)。
68
+ func TestParseCodebuddyTicket(t *testing.T) {
69
+ dir := t.TempDir()
70
+ plain := filepath.Join(dir, "codebuddy.info")
71
+ write := func(p, s string) {
72
+ t.Helper()
73
+ if err := os.WriteFile(p, []byte(s), 0o600); err != nil {
74
+ t.Fatal(err)
75
+ }
76
+ }
77
+ write(plain, `{"account":{"uid":"u-1","nickname":"Darren"},"auth":{"accessToken":"tok-1","domain":"www.codebuddy.cn"}}`)
78
+ got, ok := parseCodebuddyTicket(plain)
79
+ if !ok || got.Token != "tok-1" || got.UID != "u-1" || got.Nickname != "Darren" || got.Domain != "www.codebuddy.cn" {
80
+ t.Fatalf("plain ticket = %+v ok=%v", got, ok)
81
+ }
82
+
83
+ enc := filepath.Join(dir, "workbuddy-desktop.info")
84
+ write(enc, `{"account":{"uid":"u-2"},"auth":{"accessToken":{"$wbEncrypted":1,"envelope":"x"},"domain":"www.workbuddy.cn"}}`)
85
+ if _, ok := parseCodebuddyTicket(enc); ok {
86
+ t.Error("$wbEncrypted 票据不应被当成可用票据")
87
+ }
88
+ }
89
+
90
+ // 候选顺序:本引擎 id → base product id → 其余最新明文票据。
91
+ func TestReadCodebuddyTicketOrder(t *testing.T) {
92
+ dir := t.TempDir()
93
+ now := time.Now()
94
+ writeAt := func(name, body string, mod time.Time) {
95
+ t.Helper()
96
+ p := filepath.Join(dir, name)
97
+ if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
98
+ t.Fatal(err)
99
+ }
100
+ if err := os.Chtimes(p, mod, mod); err != nil {
101
+ t.Fatal(err)
102
+ }
103
+ }
104
+ writeAt("codebuddy.info", `{"account":{"uid":"mine"},"auth":{"accessToken":"t1","domain":"d"}}`, now.Add(-time.Hour))
105
+ writeAt(baseProductAuthID+".info", `{"account":{"uid":"base"},"auth":{"accessToken":"t2","domain":"d"}}`, now)
106
+ writeAt("newer-other.info", `{"account":{"uid":"other"},"auth":{"accessToken":"t3","domain":"d"}}`, now.Add(time.Hour))
107
+
108
+ if got, ok := readCodebuddyTicket(dir, "codebuddy"); !ok || got.UID != "mine" {
109
+ t.Errorf("应优先本引擎 id,实际 %+v ok=%v", got, ok)
110
+ }
111
+ if err := os.Remove(filepath.Join(dir, "codebuddy.info")); err != nil {
112
+ t.Fatal(err)
113
+ }
114
+ if got, ok := readCodebuddyTicket(dir, "codebuddy"); !ok || got.UID != "base" {
115
+ t.Errorf("本引擎 id 缺失时应回退 base product id,实际 %+v ok=%v", got, ok)
116
+ }
117
+ if err := os.Remove(filepath.Join(dir, baseProductAuthID+".info")); err != nil {
118
+ t.Fatal(err)
119
+ }
120
+ if got, ok := readCodebuddyTicket(dir, "codebuddy"); !ok || got.UID != "other" {
121
+ t.Errorf("两者都缺失时应取最新明文票据,实际 %+v ok=%v", got, ok)
122
+ }
123
+ }
124
+
125
+ func TestCheckinStatusFromJSON(t *testing.T) {
126
+ body := `{"code":0,"data":{"active":true,"today_checked_in":false,"daily_credit":100,"streak_days":19}}`
127
+ info, ok := checkinStatusFromJSON([]byte(body))
128
+ if !ok || info.CheckedToday || info.DailyCredit != "100" || info.StreakDays != 19 {
129
+ t.Fatalf("status = %+v ok=%v", info, ok)
130
+ }
131
+ // 活动未开 / 业务错误 / 坏 JSON → 拿不到状态(--engines 省略 checkin_today)
132
+ for _, bad := range []string{
133
+ `{"code":0,"data":{"active":false,"today_checked_in":true}}`,
134
+ `{"code":14001,"msg":"x"}`,
135
+ `not-json`,
136
+ } {
137
+ if _, ok := checkinStatusFromJSON([]byte(bad)); ok {
138
+ t.Errorf("bad body %s 应返回 ok=false", bad)
139
+ }
140
+ }
141
+ }
142
+
143
+ func TestClaimCheckinFromJSON(t *testing.T) {
144
+ // 顶层 / data 两种成功形状都要认
145
+ for _, body := range []string{
146
+ `{"code":0,"credit":100,"streak_days":20}`,
147
+ `{"code":0,"data":{"credit":100,"streak_days":20,"today_checked_in":true}}`,
148
+ } {
149
+ info, err := claimCheckinFromJSON([]byte(body))
150
+ if err != nil || !info.CheckedToday || info.Credit != "100" || info.StreakDays != 20 {
151
+ t.Errorf("claim body %s → %+v err=%v", body, info, err)
152
+ }
153
+ }
154
+ // 业务失败:error 带服务端原因
155
+ info, err := claimCheckinFromJSON([]byte(`{"code":14001,"msg":"今天已经领过了"}`))
156
+ if err == nil || info.Message != "今天已经领过了" {
157
+ t.Errorf("业务失败应返回 error 与 message,实际 %+v err=%v", info, err)
158
+ }
159
+ if _, err := claimCheckinFromJSON([]byte(`not-json`)); err == nil {
160
+ t.Error("坏 JSON 应报错")
161
+ }
162
+ }
163
+
164
+ // 多账号槽位的登录态:各自看自己的票据目录,互不影响(「登录几个账号显示几个」)。
165
+ func TestCodeBuddyLoggedIn(t *testing.T) {
166
+ home := t.TempDir()
167
+ // Windows 的 os.UserHomeDir() 读 USERPROFILE;两边都设,跨平台都能隔离。
168
+ t.Setenv("HOME", home)
169
+ t.Setenv("USERPROFILE", home)
170
+
171
+ main, ai := &CodeBuddyEngine{}, &CodeBuddyAIEngine{}
172
+ if main.LoggedIn() || ai.LoggedIn() {
173
+ t.Fatal("没有任何票据时应为未登录")
174
+ }
175
+ if main.AccountName() != "" || ai.AccountName() != "" {
176
+ t.Fatal("未登录时不应有账号昵称")
177
+ }
178
+
179
+ writeTicket := func(dir, id, nickname string) {
180
+ t.Helper()
181
+ if err := os.MkdirAll(dir, 0o700); err != nil {
182
+ t.Fatal(err)
183
+ }
184
+ body := `{"account":{"uid":"u-1","nickname":"` + nickname + `"},"auth":{"accessToken":"tok","domain":"www.codebuddy.cn"}}`
185
+ if err := os.WriteFile(filepath.Join(dir, id+".info"), []byte(body), 0o600); err != nil {
186
+ t.Fatal(err)
187
+ }
188
+ }
189
+
190
+ writeTicket(codebuddyAuthDir(home), codebuddyAuthID, "Darren")
191
+ if !main.LoggedIn() {
192
+ t.Fatal("主账号票据存在时应为已登录")
193
+ }
194
+ if got := main.AccountName(); got != "Darren" {
195
+ t.Fatalf("主账号昵称 = %q,want Darren", got)
196
+ }
197
+ if ai.LoggedIn() {
198
+ t.Fatal("主账号的票据不应让隔离槽位也变成已登录")
199
+ }
200
+
201
+ writeTicket(codebuddyAuthDir(filepath.Join(home, ".codebuddy-ai")), codebuddyAIAuthID, "玲")
202
+ if !ai.LoggedIn() {
203
+ t.Fatal("隔离账号票据存在时应为已登录")
204
+ }
205
+ if got := ai.AccountName(); got != "玲" {
206
+ t.Fatalf("隔离账号昵称 = %q,want 玲", got)
207
+ }
208
+ if got := main.AccountName(); got != "Darren" {
209
+ t.Fatalf("另一个槽位的昵称被带偏: %q", got)
210
+ }
211
+ }
@@ -71,6 +71,149 @@ func StripCustomLocalPrefixCredits(credits map[string]string) map[string]string
71
71
  return out
72
72
  }
73
73
 
74
+ // CLICustomModel 一条本地自定义模型在**清单/选择器**里的展示形态。
75
+ type CLICustomModel struct {
76
+ // ID 「选中时下发给 -m 的值」:优先取 models.json 里 `aliases[0]`
77
+ // (CLI 的模型查找按 id / name / aliases 命中,别名可直接 -m),
78
+ // 没有别名就取剥掉 custom-local: 前缀的 id(= 实际转发给端点的模型名)。
79
+ ID string
80
+ // Wire 去掉 custom-local: 前缀后的真实 id(= CLI 转发给端点的模型名)。
81
+ // 用于把「已经从 models.json 删掉、但桌面端缓存里还留着」的旧条目从清单里剔除。
82
+ Wire string
83
+ // Vendor 供应商展示名(models.json 的 vendor;空 / "Custom" 时为空)。
84
+ Vendor string
85
+ // URL / APIKey 该自定义模型的端点与密钥(仅用于按账号查套餐额度,不外泄到清单)。
86
+ URL string
87
+ APIKey string
88
+ }
89
+
90
+ // CLICustomModels 读 <configDir>/models.json 里**本地自定义**模型(custom-local:*)条目。
91
+ //
92
+ // 为什么只认 custom-local::models.json 里还有官方模型条目(id 是裸名),它们不是
93
+ // 「用户自己接的端点」,混进清单会把官方模型重复列一遍。只读**顶层对象**格式
94
+ // (CLI 自己的 models.json);桌面端数组格式不碰。
95
+ func CLICustomModels(configDir string) []CLICustomModel {
96
+ if strings.TrimSpace(configDir) == "" {
97
+ return nil
98
+ }
99
+ b, err := os.ReadFile(filepath.Join(configDir, customModelsFileName))
100
+ if err != nil {
101
+ return nil
102
+ }
103
+ var root map[string]any
104
+ if err := json.Unmarshal(b, &root); err != nil {
105
+ return nil
106
+ }
107
+ raw, _ := root["models"].([]any)
108
+ var out []CLICustomModel
109
+ seen := map[string]bool{}
110
+ for _, it := range raw {
111
+ m, ok := it.(map[string]any)
112
+ if !ok {
113
+ continue
114
+ }
115
+ rawID, _ := m["id"].(string)
116
+ rawID = strings.TrimSpace(rawID)
117
+ if !strings.HasPrefix(rawID, customModelIDPrefix) {
118
+ continue
119
+ }
120
+ wire := strings.TrimSpace(strings.TrimPrefix(rawID, customModelIDPrefix))
121
+ if wire == "" {
122
+ continue
123
+ }
124
+ listID := wire
125
+ if alias := firstString(m["aliases"]); alias != "" {
126
+ listID = alias // 同名模型(如官方 glm-5.3-flash)靠别名区分
127
+ }
128
+ if seen[listID] {
129
+ continue
130
+ }
131
+ seen[listID] = true
132
+ vendor, _ := m["vendor"].(string)
133
+ vendor = strings.TrimSpace(vendor)
134
+ if strings.EqualFold(vendor, "custom") {
135
+ vendor = ""
136
+ }
137
+ url, _ := m["url"].(string)
138
+ apiKey, _ := m["apiKey"].(string)
139
+ out = append(out, CLICustomModel{
140
+ ID: listID,
141
+ Wire: wire,
142
+ Vendor: vendor,
143
+ URL: strings.TrimSpace(url),
144
+ APIKey: strings.TrimSpace(apiKey),
145
+ })
146
+ }
147
+ return out
148
+ }
149
+
150
+ // CLIModelVendors 模型 id(与 CLICustomModels 的 ID 同形)→ 供应商展示名。
151
+ // 没有供应商信息的条目不出现在 map 里;一条都没有返回 nil。
152
+ func CLIModelVendors(configDir string) map[string]string {
153
+ models := CLICustomModels(configDir)
154
+ out := map[string]string{}
155
+ for _, m := range models {
156
+ if m.Vendor != "" {
157
+ out[m.ID] = m.Vendor
158
+ }
159
+ }
160
+ if len(out) == 0 {
161
+ return nil
162
+ }
163
+ return out
164
+ }
165
+
166
+ // firstString 取 []any 里第一个非空字符串(models.json 的 aliases 列表)。
167
+ func firstString(v any) string {
168
+ list, _ := v.([]any)
169
+ for _, it := range list {
170
+ if s, ok := it.(string); ok {
171
+ if s = strings.TrimSpace(s); s != "" {
172
+ return s
173
+ }
174
+ }
175
+ }
176
+ return ""
177
+ }
178
+
179
+ // MergeCLICustomModels 把 models.json 里的自定义模型并入清单(追加在官方模型之后),
180
+ // 并剔除「已从 models.json 删掉、但桌面端 acc 缓存里还留着」的 custom-local 条目。
181
+ //
182
+ // models.json 不存在时**原样返回**:没有权威来源时不动别人的清单(否则会把 App 里
183
+ // 已配好、只是还没同步到 models.json 的自定义模型一并隐藏)。
184
+ func MergeCLICustomModels(configDir string, ids []string) []string {
185
+ if strings.TrimSpace(configDir) == "" {
186
+ return ids
187
+ }
188
+ if _, err := os.Stat(filepath.Join(configDir, customModelsFileName)); err != nil {
189
+ return ids
190
+ }
191
+ customs := CLICustomModels(configDir)
192
+ // 自定义条目统一在末尾追加(顺序由 UI 再按倍率排);清单里原有的 custom-local:*
193
+ // 一律先剔除 —— 既去掉「已从 models.json 删掉、缓存里还在」的旧条目,也避免
194
+ // 「别名条目 + 缓存里的原名」让同一模型出现两次。
195
+ out := make([]string, 0, len(ids)+len(customs))
196
+ seen := make(map[string]bool, len(ids)+len(customs))
197
+ for _, id := range ids {
198
+ if strings.HasPrefix(id, customModelIDPrefix) {
199
+ continue
200
+ }
201
+ if id == "" || seen[id] {
202
+ continue
203
+ }
204
+ seen[id] = true
205
+ out = append(out, id)
206
+ }
207
+ for _, m := range customs {
208
+ if m.ID == "" || seen[m.ID] {
209
+ continue
210
+ }
211
+ seen[m.ID] = true
212
+ out = append(out, m.ID)
213
+ }
214
+ return out
215
+ }
216
+
74
217
  // syncCustomModelsFromApp 把 App 合并配置缓存(acc-product-config)里的
75
218
  // `custom-local:*` 模型合并进 `<configDir>/models.json`,返回新增条数。
76
219
  //
@@ -261,6 +404,18 @@ func (c codebuddyCore) resolveCustomModelID(model string) string {
261
404
  // 同份 acc 缓存里递归收集全部 id(含嵌套、官方与 custom-local:*):裸名若也是官方模型,
262
405
  // 优先官方、不补前缀;仅当它是「纯自定义」(无同名官方)时才补回 custom-local:。
263
406
  all := readAllAccModelIDs(c.accConfigPath)
407
+ /* CLI 自己的 models.json 也是权威来源(2026-10-25):用户直接往那份文件里加的自定义
408
+ 模型不一定进了 App 的 acc 缓存,只查 acc 缓存就补不回前缀,`-m 裸名` 会找不到端点。
409
+ 按 id / aliases 命中,统一映射回真正的 `custom-local:<wire>`(wire 才是发给端点
410
+ 的模型名,见 CLI 的 stripCustomLocalModelPrefix)。 */
411
+ for _, cm := range CLICustomModels(c.effectiveConfigDir()) {
412
+ if cm.Wire == "" || all[name] {
413
+ continue
414
+ }
415
+ if name == cm.ID || name == cm.Wire {
416
+ return customModelIDPrefix + cm.Wire
417
+ }
418
+ }
264
419
  if all[customID] && !all[name] {
265
420
  return customID
266
421
  }
@@ -130,6 +130,40 @@ func TestCodeBuddyModelArg(t *testing.T) {
130
130
  }
131
131
  }
132
132
 
133
+ // 只出现在 CLI models.json(不在 App acc 缓存)的自定义模型,裸名也要能补回前缀。
134
+ // 2026-10-25:用户直接往 models.json 里加两个 deepseek-v4.1-flash-1017 变体后报「选了
135
+ // 就报没有 endpoint」,根因就是 resolve 只查 acc 缓存。
136
+ func TestResolveCustomModelIDFromCLIModelsJSON(t *testing.T) {
137
+ dir := t.TempDir()
138
+ cfg := filepath.Join(dir, "cfg")
139
+ if err := os.MkdirAll(cfg, 0o700); err != nil {
140
+ t.Fatal(err)
141
+ }
142
+ writeJSON(t, filepath.Join(cfg, "models.json"), map[string]any{
143
+ "availableModels": []any{
144
+ "custom-local:deepseek-v4.1-flash-1017",
145
+ "custom-local:deepseek-v4.1-flash-1017-ark",
146
+ },
147
+ "models": []any{
148
+ map[string]any{"id": "custom-local:deepseek-v4.1-flash-1017", "vendor": "Modelverse"},
149
+ map[string]any{"id": "custom-local:deepseek-v4.1-flash-1017-ark", "vendor": "Volcengine"},
150
+ // aliases:列表用别名,但 -m 必须映射回 wire 名。
151
+ map[string]any{"id": "custom-local:glm-5.3-flash", "aliases": []any{"glm-5.3-flash-volc"}},
152
+ },
153
+ })
154
+ c := codebuddyCore{accConfigPath: filepath.Join(dir, "missing.json"), configDir: cfg}
155
+
156
+ if got := c.modelArg("deepseek-v4.1-flash-1017"); got != "custom-local:deepseek-v4.1-flash-1017" {
157
+ t.Errorf("modelverse 裸名 = %q", got)
158
+ }
159
+ if got := c.modelArg("deepseek-v4.1-flash-1017-ark"); got != "custom-local:deepseek-v4.1-flash-1017-ark" {
160
+ t.Errorf("ark 裸名 = %q", got)
161
+ }
162
+ if got := c.modelArg("glm-5.3-flash-volc"); got != "custom-local:glm-5.3-flash" {
163
+ t.Errorf("别名应映射回 wire 名,实际 %q", got)
164
+ }
165
+ }
166
+
133
167
  func TestSyncCustomModelsFromApp_FirstSync(t *testing.T) {
134
168
  dir := t.TempDir()
135
169
  acc := filepath.Join(dir, "acc-product-config-v3.json")
@@ -483,3 +517,94 @@ func hasStringInList(list []any, want string) bool {
483
517
  }
484
518
  return false
485
519
  }
520
+
521
+ // CLIModelVendors:只取有意义的 vendor(空 / "Custom" 过滤),键剥 custom-local: 前缀,
522
+ // 桌面端数组格式(不认识)返回 nil —— 不能把别人的文件当 CLI 格式解析。
523
+ func TestCLIModelVendors(t *testing.T) {
524
+ dir := t.TempDir()
525
+ writeJSON(t, filepath.Join(dir, customModelsFileName), map[string]any{
526
+ "models": []any{
527
+ map[string]any{
528
+ "id": "custom-local:glm-5.3-flash",
529
+ "vendor": "Volcengine",
530
+ "aliases": []any{"ark-glm-5.3-flash"},
531
+ },
532
+ map[string]any{"id": "custom-local:deepseek-v4.1-flash-1017", "vendor": "Volcengine"},
533
+ map[string]any{"id": "custom-local:MiniMax-M3", "vendor": "Custom"},
534
+ map[string]any{"id": "plain-model", "vendor": ""},
535
+ },
536
+ })
537
+ got := CLIModelVendors(dir)
538
+ want := map[string]string{
539
+ "ark-glm-5.3-flash": "Volcengine", // 有别名:清单/供应商表都用别名(区分同名官方模型)
540
+ "deepseek-v4.1-flash-1017": "Volcengine",
541
+ }
542
+ if len(got) != len(want) {
543
+ t.Fatalf("vendors = %v, want %v", got, want)
544
+ }
545
+ for k, v := range want {
546
+ if got[k] != v {
547
+ t.Errorf("vendors[%q] = %q, want %q(全量 %v)", k, got[k], v, got)
548
+ }
549
+ }
550
+
551
+ // 桌面端数组格式:不是 CLI 的 models.json,返回 nil。
552
+ arrDir := t.TempDir()
553
+ if err := os.WriteFile(filepath.Join(arrDir, customModelsFileName),
554
+ []byte(`[{"id":"custom-local:x","vendor":"Volcengine"}]`), 0o644); err != nil {
555
+ t.Fatal(err)
556
+ }
557
+ if got := CLIModelVendors(arrDir); got != nil {
558
+ t.Errorf("桌面端数组格式应返回 nil,得到 %v", got)
559
+ }
560
+ }
561
+
562
+ // MergeCLICustomModels:自定义条目统一追加到末尾(有别名用别名)、缓存里已被
563
+ // 删除/重复的 custom-local 条目一律剔除;models.json 不存在时原样返回。
564
+ func TestMergeCLICustomModels(t *testing.T) {
565
+ dir := t.TempDir()
566
+ writeJSON(t, filepath.Join(dir, customModelsFileName), map[string]any{
567
+ "models": []any{
568
+ map[string]any{
569
+ "id": "custom-local:glm-5.3-flash",
570
+ "aliases": []any{"ark-glm-5.3-flash"},
571
+ "vendor": "Volcengine",
572
+ },
573
+ map[string]any{"id": "custom-local:MiniMax-M3.1-Flash", "vendor": "MiniMax"},
574
+ },
575
+ })
576
+ ids := []string{
577
+ "hy3",
578
+ "custom-local:MiniMax-M3", // 已从 models.json 删除 → 应剔除
579
+ "custom-local:glm-5.3-flash", // 有别名 → 用别名追加,原名不重复
580
+ "custom-local:MiniMax-M3.1-Flash",
581
+ }
582
+ want := []string{"hy3", "ark-glm-5.3-flash", "MiniMax-M3.1-Flash"}
583
+ if got := MergeCLICustomModels(dir, ids); !equalStrings(got, want) {
584
+ t.Errorf("MergeCLICustomModels = %v, want %v", got, want)
585
+ }
586
+
587
+ // 没有 models.json:没有权威来源,原样返回(不能隐藏 App 侧刚配好的自定义模型)。
588
+ empty := t.TempDir()
589
+ if got := MergeCLICustomModels(empty, ids); !equalStrings(got, ids) {
590
+ t.Errorf("models.json 缺失时应原样返回,得到 %v", got)
591
+ }
592
+ }
593
+
594
+ // CLIModelQuotas:只对 MiniMax 端点发查询;非 MiniMax 的自定义模型直接跳过
595
+ // (这里用 Ark 端点验证「不发请求、返回 nil」,不需要真实网络)。
596
+ func TestCLIModelQuotas_NonMiniMax(t *testing.T) {
597
+ dir := t.TempDir()
598
+ writeJSON(t, filepath.Join(dir, customModelsFileName), map[string]any{
599
+ "models": []any{
600
+ map[string]any{
601
+ "id": "custom-local:glm-5.3-flash",
602
+ "url": "https://ark.cn-beijing.volces.com/api/coding/v3",
603
+ "apiKey": "ark-test",
604
+ },
605
+ },
606
+ })
607
+ if got := CLIModelQuotas(t.Context(), dir); got != nil {
608
+ t.Errorf("非 MiniMax 端点不该查额度,得到 %v", got)
609
+ }
610
+ }