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.
- package/CLIENT-CONTRACT.md +290 -30
- package/README.md +15 -14
- package/client-ui/pc/app.css +32 -0
- package/client-ui/pc/js/04-task-list.js +5 -1
- package/client-ui/pc/js/07-chat-stream-render.js +3 -3
- package/client-ui/pc/js/90-glue.js +37 -2
- package/client-ui/pc/js/92-composer.js +19 -4
- package/client-ui/pc/js/93-rail.js +16 -5
- package/client-ui/shared/conv/02-media-kinds.js +2 -2
- package/client-ui/shared/conv/03-chat-message.js +241 -23
- package/client-ui/shared/conv/06-ask-answer.js +75 -1
- package/client-ui/shared/conv/09-stream-apply.js +63 -7
- package/client-ui/shared/conv/19-attachments.js +157 -6
- package/client-ui/shared/conv/27-conv-ledger.js +5 -2
- package/client-ui/shared/conv/29-msg-variants.js +81 -0
- package/client-ui/shared/conv/conv.css +79 -19
- package/client-ui/shared/conv/dom-map.md +1 -0
- package/client-ui/shared/conversation.css +13 -2
- package/client-ui/shared/conversation.js +336 -50
- package/client-ui/shared/transport-http.js +14 -0
- package/client-ui-mobile/index.html +2932 -333
- package/client-ui-mobile/reqs.css +102 -0
- package/client-ui-mobile/reqs.js +485 -70
- package/client-ui-mobile/reqshot.js +505 -0
- package/client-ui-mobile/scripts.css +21 -0
- package/client-ui-mobile/scripts.js +539 -0
- package/client-ui-mobile/vendor/cose-base.js +3214 -0
- package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
- package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
- package/client-ui-mobile/vendor/layout-base.js +5230 -0
- package/cmd/magic-client/browsectl.go +199 -0
- package/cmd/magic-client/clientctl.go +33 -9
- package/cmd/magic-client/main.go +57 -26
- package/internal/agent/ask.go +45 -0
- package/internal/agent/ask_test.go +39 -0
- package/internal/agent/attachments_test.go +6 -2
- package/internal/agent/capability.go +7 -1
- package/internal/agent/claude.go +20 -15
- package/internal/agent/codebuddy.go +125 -34
- package/internal/agent/codebuddy_account_test.go +54 -0
- package/internal/agent/codebuddy_stream_test.go +103 -0
- package/internal/agent/credits.go +713 -0
- package/internal/agent/credits_minimax_test.go +97 -0
- package/internal/agent/credits_test.go +211 -0
- package/internal/agent/custom_models.go +155 -0
- package/internal/agent/custom_models_test.go +125 -0
- package/internal/agent/dsh.go +123 -49
- package/internal/agent/dsh_resume.go +13 -2
- package/internal/agent/dsh_test.go +108 -6
- package/internal/agent/engine.go +37 -1
- package/internal/agent/engine_base.go +21 -17
- package/internal/agent/engine_base_test.go +2 -2
- package/internal/agent/engine_test.go +47 -86
- package/internal/agent/fallback.go +313 -0
- package/internal/agent/fallback_test.go +340 -0
- package/internal/agent/launch.go +43 -1
- package/internal/agent/llmhttp.go +46 -4
- package/internal/agent/llmhttp_test.go +157 -0
- package/internal/agent/models.go +53 -1
- package/internal/agent/models_test.go +20 -5
- package/internal/agent/pidalive_windows_test.go +40 -0
- package/internal/agent/runcmd.go +26 -0
- package/internal/agent/runcmd_windows.go +36 -5
- package/internal/agent/stream.go +136 -6
- package/internal/agent/stream_test.go +25 -38
- package/internal/agent/stream_timeline_test.go +2 -37
- package/internal/agent/streamjson.go +108 -6
- package/internal/agent/tools_suffix_test.go +6 -3
- package/internal/agent/trae.go +50 -243
- package/internal/agent/trae_exec.go +433 -0
- package/internal/agent/trae_exec_test.go +181 -0
- package/internal/agent/wraplaunch_test.go +113 -0
- package/internal/cli/ask.go +160 -37
- package/internal/cli/ask_test.go +1 -1
- package/internal/cli/checkin.go +77 -0
- package/internal/cli/clientcmd.go +3 -2
- package/internal/cli/control.go +46 -5
- package/internal/cli/control_test.go +65 -0
- package/internal/cli/events.go +4 -0
- package/internal/cli/quotas.go +125 -0
- package/internal/cli/requirements.go +51 -0
- package/internal/cli/root.go +122 -5
- package/internal/cli/session.go +7 -10
- package/internal/cli/suggest.go +7 -5
- package/internal/cli/team.go +37 -9
- package/internal/cli/team_test.go +39 -0
- package/internal/client/ab.go +372 -0
- package/internal/client/ab_test.go +287 -0
- package/internal/client/browse.go +84 -36
- package/internal/client/browse_test.go +107 -9
- package/internal/client/browseinput.go +481 -0
- package/internal/client/browseinput_test.go +216 -0
- package/internal/client/corecmd.go +23 -0
- package/internal/client/corecmd_other.go +8 -0
- package/internal/client/corecmd_windows.go +24 -0
- package/internal/client/daemon.go +90 -17
- package/internal/client/daemon_test.go +33 -0
- package/internal/client/engines_checkin_test.go +57 -0
- package/internal/client/fallback_flags_test.go +64 -0
- package/internal/client/jobs.go +294 -21
- package/internal/client/jobs_test.go +299 -2
- package/internal/client/knowledge.go +854 -0
- package/internal/client/knowledge_test.go +510 -0
- package/internal/client/lan.go +193 -7
- package/internal/client/lan_test.go +110 -0
- package/internal/client/quotas_merge_test.go +107 -0
- package/internal/client/requirements.go +198 -1
- package/internal/client/requirements_shots_test.go +200 -0
- package/internal/client/restart.go +134 -0
- package/internal/client/restart_test.go +151 -0
- package/internal/client/scripts.go +1047 -0
- package/internal/client/scripts_decode_other.go +27 -0
- package/internal/client/scripts_decode_test.go +125 -0
- package/internal/client/scripts_decode_windows.go +206 -0
- package/internal/client/scripts_test.go +726 -0
- package/internal/client/service.go +322 -18
- package/internal/client/service_test.go +51 -0
- package/internal/client/suggest.go +105 -2
- package/internal/client/suggest_test.go +86 -0
- package/internal/client/team_flags.go +25 -11
- package/internal/client/team_flags_test.go +39 -0
- package/internal/requirements/bootstrap.go +16 -0
- package/internal/requirements/consolidate.go +100 -9
- package/internal/requirements/doc.go +154 -3
- package/internal/requirements/graph.go +138 -0
- package/internal/requirements/memory.go +7 -0
- package/internal/requirements/modules.go +312 -0
- package/internal/requirements/modules_test.go +373 -0
- package/internal/requirements/relations.go +155 -0
- package/internal/requirements/relations_test.go +231 -0
- package/internal/requirements/requirements.go +10 -0
- package/internal/requirements/shots.go +418 -0
- package/internal/requirements/shots_test.go +387 -0
- package/internal/session/session.go +92 -0
- package/internal/session/sessionlog.go +23 -0
- package/internal/session/sessionlog_read_test.go +39 -0
- package/internal/suggest/suggest.go +23 -1
- package/internal/team/event.go +3 -1
- package/internal/team/execute.go +21 -6
- package/internal/team/fallback_route_test.go +80 -0
- package/internal/team/planner.go +15 -3
- package/internal/team/route.go +89 -17
- package/internal/team/team.go +18 -4
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +1 -5
- 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/package.json +8 -2
package/internal/client/lan.go
CHANGED
|
@@ -8,9 +8,9 @@ import (
|
|
|
8
8
|
|
|
9
9
|
/* 局域网接入(P4 的第一级:让手机能连上)。
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* 默认就绑局域网(0.0.0.0:9100,手机开箱可连):这一开,**令牌就是唯一的门锁** ——
|
|
12
|
+
* 所以这里的两条守卫是配套的:局域网模式必须给足强度的令牌,短令牌直接拒绝启动;
|
|
13
|
+
* 只想本机访问用 `--local` 绑回 127.0.0.1。
|
|
14
14
|
*
|
|
15
15
|
* 三级接入里这一级是 L1(局域网);L2 的跨网中继需要服务端,不在本文件范围。
|
|
16
16
|
*/
|
|
@@ -33,17 +33,142 @@ func ValidateLanToken(token string) error {
|
|
|
33
33
|
return nil
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/* ── 局域网地址的挑选 ──
|
|
37
|
+
*
|
|
38
|
+
* ⚠️ **顺序有讲究,而且这份顺序是「能不能连上」的关键**(2026-10-23 修)。
|
|
39
|
+
*
|
|
40
|
+
* 调用方全都只认**第一个**:
|
|
41
|
+
* · `/desk/health` 的 lanUrls → 移动端自动补的「公司远程」环境取 [0];
|
|
42
|
+
* · `magic-client start` 打印的**第一张**二维码 → 扫出来就是这个地址。
|
|
43
|
+
*
|
|
44
|
+
* 而一台装了企业 VPN / Hyper-V 的机器上有多张网卡,枚举顺序由系统决定,
|
|
45
|
+
* 先出来的常常是**手机根本路由不到**的那张:企业 VPN 虚拟网卡(10.x / 2.x 段)
|
|
46
|
+
* 与虚拟交换机(172.16/12 段)都属于「只在本机或专用隧道里有效」的地址,
|
|
47
|
+
* 手机在同一个 Wi-Fi 下压根到不了它们。
|
|
48
|
+
*
|
|
49
|
+
* 现象就是「启动成功、地址也打印出来了,但手机就是连不上」,而且**扫码比手抄更容易撞上**
|
|
50
|
+
* —— 扫的正是打印出来的第一张码。
|
|
51
|
+
*
|
|
52
|
+
* 现在的口径(三步,详见 orderLanAddresses):
|
|
53
|
+
* 1. 按**名字**剔掉虚拟网卡(容器 / 虚拟交换机 / 隧道 / 回环伪接口)。
|
|
54
|
+
* 2. 再剔掉**公网段**地址(只要还有私有地址):企业 VPN 的虚拟网卡常占一段
|
|
55
|
+
* 公网地址(这里 2.0.0.1),手机在局域网里根本路由不到那一段。
|
|
56
|
+
* 3. 剩下的里,出站主地址排最前(问一次内核要源地址,不发包)—— 那张网卡
|
|
57
|
+
* 就是本机跟外界说话用的那张,手机连它最可能通。
|
|
58
|
+
*
|
|
59
|
+
* 每一步都有兜底:真实网卡没剩就退回虚拟网卡,私有地址没剩就退回公网地址 ——
|
|
60
|
+
* 不能一个地址都不报。其余地址也会列在后面,用户可以手挑 —— 不猜、不假装。
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
// lanCandidate 一个候选局域网地址,以及它所在的网卡。
|
|
64
|
+
type lanCandidate struct {
|
|
65
|
+
ip string
|
|
66
|
+
iface net.Interface
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// virtualIfaceHints 虚拟 / 容器网卡的**名称特征**(子串匹配,忽略大小写)。
|
|
70
|
+
//
|
|
71
|
+
// 为什么按名字认:这些网卡在 Windows 上叫「vEthernet (Default Switch)」
|
|
72
|
+
// (Hyper-V)、「VMware Network Adapter」;在 Linux 上叫 docker0 / veth* / virbr0;
|
|
73
|
+
// 在 macOS 上叫 utun*。它们承载的地址只在本机或专用隧道内有效,手机连不到。
|
|
74
|
+
var virtualIfaceHints = []string{
|
|
75
|
+
"vethernet", // Hyper-V 虚拟交换机
|
|
76
|
+
"hyper-v",
|
|
77
|
+
"vmware",
|
|
78
|
+
"virtualbox",
|
|
79
|
+
"docker",
|
|
80
|
+
"veth",
|
|
81
|
+
"virbr",
|
|
82
|
+
"br-", // Linux 网桥
|
|
83
|
+
"loopback", // Windows「Loopback Pseudo-Interface」
|
|
84
|
+
"pseudo",
|
|
85
|
+
"npcap",
|
|
86
|
+
"bluetooth", // 蓝牙 PAN
|
|
87
|
+
"utun", // macOS 隧道
|
|
88
|
+
"tailscale",
|
|
89
|
+
"zerotier",
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// virtualIfaceNames 虚拟网卡的**完整**名称(小写)—— 这些是短名,
|
|
93
|
+
// 用子串匹配会误伤别的网卡("tun" 之于 "Tunnel"、"wg" 之于别的东西),所以整名匹配。
|
|
94
|
+
var virtualIfaceNames = map[string]bool{
|
|
95
|
+
"tun0": true, "tun1": true,
|
|
96
|
+
"tap0": true, "tap1": true,
|
|
97
|
+
"docker0": true,
|
|
98
|
+
"wg0": true, // WireGuard
|
|
99
|
+
"zt0": true, // ZeroTier
|
|
100
|
+
"zt1": true,
|
|
101
|
+
"bridge100": true, // macOS 上的 VMware
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// isVirtualIface 网卡是不是虚拟 / 容器网卡。
|
|
105
|
+
func isVirtualIface(name string) bool {
|
|
106
|
+
n := strings.ToLower(strings.TrimSpace(name))
|
|
107
|
+
if virtualIfaceNames[n] {
|
|
108
|
+
return true
|
|
109
|
+
}
|
|
110
|
+
for _, h := range virtualIfaceHints {
|
|
111
|
+
if strings.Contains(n, h) {
|
|
112
|
+
return true
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return false
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// isPrivateIPv4 是不是 RFC1918 私有网段(10/8、172.16/12、192.168/16)。
|
|
119
|
+
//
|
|
120
|
+
// 为什么用它排序:手机在同一张局域网里拿到的也是私有地址,所以私有地址是
|
|
121
|
+
// 「手机连得上」的第一嫌疑。反过来,企业 VPN 的虚拟网卡常占一段**公网**地址
|
|
122
|
+
// (这台机器上 Sangfor aTrust VNIC 占的是 2.0.0.1)—— 手机在局域网里
|
|
123
|
+
// 根本路由不到那一段,把公网地址排前面就是第一个就撞墙。
|
|
124
|
+
func isPrivateIPv4(ip string) bool {
|
|
125
|
+
p := net.ParseIP(ip)
|
|
126
|
+
if p == nil {
|
|
127
|
+
return false
|
|
128
|
+
}
|
|
129
|
+
p = p.To4()
|
|
130
|
+
if p == nil {
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
133
|
+
switch {
|
|
134
|
+
case p[0] == 10:
|
|
135
|
+
return true
|
|
136
|
+
case p[0] == 172 && p[1]&0xf0 == 16:
|
|
137
|
+
return true
|
|
138
|
+
case p[0] == 192 && p[1] == 168:
|
|
139
|
+
return true
|
|
140
|
+
}
|
|
141
|
+
return false
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// primaryOutboundIP 返回本机对外通信时实际使用的源 IP(默认路由所在网卡的那张)。
|
|
145
|
+
//
|
|
146
|
+
// 怎么问内核:发起一次 UDP「连接」。UDP 的 connect 只查路由表并选定源地址,
|
|
147
|
+
// **一个包都不发** —— 不是在访问外网,也就不会产生任何出网流量。
|
|
148
|
+
// 问不到(没有默认路由 / 离线)时返回空串,调用方退化成保持网卡枚举顺序。
|
|
149
|
+
func primaryOutboundIP() string {
|
|
150
|
+
conn, err := net.Dial("udp", "8.8.8.8:80")
|
|
151
|
+
if err != nil {
|
|
152
|
+
return ""
|
|
153
|
+
}
|
|
154
|
+
defer conn.Close()
|
|
155
|
+
host, _, err := net.SplitHostPort(conn.LocalAddr().String())
|
|
156
|
+
if err != nil {
|
|
157
|
+
return ""
|
|
158
|
+
}
|
|
159
|
+
return host
|
|
160
|
+
}
|
|
161
|
+
|
|
36
162
|
// LanAddresses 列出本机可用于局域网访问的 IPv4 地址(形如 `192.168.1.5:50022`)。
|
|
37
163
|
//
|
|
38
164
|
// 过滤掉回环、链路本地(169.254.x)与非 IPv4:这三类给手机都没用。
|
|
39
|
-
//
|
|
40
|
-
// 猜错网卡比多打两行地址麻烦得多。
|
|
165
|
+
// 挑选与排序的口径见上面那段注释(虚拟网卡剔除 + 默认路由那张排最前)。
|
|
41
166
|
func LanAddresses(port int) []string {
|
|
42
167
|
ifaces, err := net.Interfaces()
|
|
43
168
|
if err != nil {
|
|
44
169
|
return nil
|
|
45
170
|
}
|
|
46
|
-
var
|
|
171
|
+
var cands []lanCandidate
|
|
47
172
|
for _, ifc := range ifaces {
|
|
48
173
|
if ifc.Flags&net.FlagUp == 0 || ifc.Flags&net.FlagLoopback != 0 {
|
|
49
174
|
continue
|
|
@@ -61,7 +186,68 @@ func LanAddresses(port int) []string {
|
|
|
61
186
|
if ip4 == nil || ip4.IsLoopback() || ip4.IsLinkLocalUnicast() {
|
|
62
187
|
continue
|
|
63
188
|
}
|
|
64
|
-
|
|
189
|
+
cands = append(cands, lanCandidate{ip: ip4.String(), iface: ifc})
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return orderLanAddresses(cands, primaryOutboundIP(), port)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// orderLanAddresses 把候选地址按「手机最可能连得上」排好并拼上端口。
|
|
196
|
+
//
|
|
197
|
+
// 三步:
|
|
198
|
+
// 1. 按**名字**剔掉虚拟网卡(Hyper-V / 容器 / 隧道 / 回环伪接口)——
|
|
199
|
+
// 它们的地址只在本机或专用隧道里有效,手机连不到。
|
|
200
|
+
// 2. 再剔掉**公网段**地址:只要还有私有地址,公网段的就不要
|
|
201
|
+
// (企业 VPN 虚拟网卡常占一段公网地址,而它在 Windows 上报的是连接别名
|
|
202
|
+
// 「本地连接」这种通用名,靠名字抓不到 —— 靠网段能抓到)。
|
|
203
|
+
// 3. 剩下的里,出站主地址(默认路由所在网卡)排最前。
|
|
204
|
+
//
|
|
205
|
+
// 每一步都有兜底:真实网卡没剩就退回虚拟网卡,私有地址没剩就退回公网地址 ——
|
|
206
|
+
// **不能一个地址都不报**。
|
|
207
|
+
//
|
|
208
|
+
// 拆成独立函数是为了能直接喂合成数据做单测 —— 各人机器上的网卡千差万别,
|
|
209
|
+
// 靠真机跑出来的用例没法覆盖「VPN 虚拟网卡恰好排在最前」这种关键情形。
|
|
210
|
+
func orderLanAddresses(cands []lanCandidate, primary string, port int) []string {
|
|
211
|
+
if len(cands) == 0 {
|
|
212
|
+
return nil
|
|
213
|
+
}
|
|
214
|
+
real := make([]lanCandidate, 0, len(cands))
|
|
215
|
+
virtual := make([]lanCandidate, 0, len(cands))
|
|
216
|
+
for _, c := range cands {
|
|
217
|
+
if isVirtualIface(c.iface.Name) {
|
|
218
|
+
virtual = append(virtual, c)
|
|
219
|
+
} else {
|
|
220
|
+
real = append(real, c)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
pool := real
|
|
224
|
+
if len(pool) == 0 {
|
|
225
|
+
// 一张真实网卡都没有:虚拟网卡也得给 —— 不能一个地址都不报。
|
|
226
|
+
pool = virtual
|
|
227
|
+
}
|
|
228
|
+
if priv := filterByPrivate(pool); len(priv) > 0 {
|
|
229
|
+
pool = priv
|
|
230
|
+
}
|
|
231
|
+
out := make([]string, 0, len(pool))
|
|
232
|
+
for _, c := range pool {
|
|
233
|
+
if c.ip == primary {
|
|
234
|
+
out = append(out, fmt.Sprintf("%s:%d", c.ip, port))
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
for _, c := range pool {
|
|
238
|
+
if c.ip != primary {
|
|
239
|
+
out = append(out, fmt.Sprintf("%s:%d", c.ip, port))
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return out
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// filterByPrivate 只留私有网段(RFC1918)的候选。
|
|
246
|
+
func filterByPrivate(cands []lanCandidate) []lanCandidate {
|
|
247
|
+
out := make([]lanCandidate, 0, len(cands))
|
|
248
|
+
for _, c := range cands {
|
|
249
|
+
if isPrivateIPv4(c.ip) {
|
|
250
|
+
out = append(out, c)
|
|
65
251
|
}
|
|
66
252
|
}
|
|
67
253
|
return out
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package client
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"net"
|
|
4
5
|
"strings"
|
|
5
6
|
"testing"
|
|
6
7
|
)
|
|
@@ -51,3 +52,112 @@ func TestLanAddresses(t *testing.T) {
|
|
|
51
52
|
// 本机可能只连着回环(离线环境),所以不要求非空,只在有结果时做上面的形状断言。
|
|
52
53
|
t.Logf("局域网地址:%v", got)
|
|
53
54
|
}
|
|
55
|
+
|
|
56
|
+
// TestIsVirtualIface 虚拟网卡的识别。
|
|
57
|
+
//
|
|
58
|
+
// 回归背景(2026-10-23):一台装了企业 VPN + Hyper-V 的机器上,网卡枚举里
|
|
59
|
+
// 排最前的是 VPN 虚拟网卡,手机拿到它就连不上 —— 详见 lan.go 那段注释。
|
|
60
|
+
func TestIsVirtualIface(t *testing.T) {
|
|
61
|
+
cases := []struct {
|
|
62
|
+
name string
|
|
63
|
+
want bool
|
|
64
|
+
}{
|
|
65
|
+
{"以太网", false},
|
|
66
|
+
{"Ethernet", false},
|
|
67
|
+
{"WLAN", false},
|
|
68
|
+
{"Wi-Fi", false},
|
|
69
|
+
{"本地连接", false}, // Windows 上真实网卡也可能是中文名
|
|
70
|
+
{"vEthernet (Default Switch)", true},
|
|
71
|
+
{"Hyper-V Virtual Ethernet Adapter", true},
|
|
72
|
+
{"VMware Network Adapter VMnet8", true},
|
|
73
|
+
{"docker0", true},
|
|
74
|
+
{"veth1234", true},
|
|
75
|
+
{"virbr0", true},
|
|
76
|
+
{"br-abcdef", true},
|
|
77
|
+
{"utun3", true},
|
|
78
|
+
{"tailscale0", true},
|
|
79
|
+
{"wg0", true},
|
|
80
|
+
{"Loopback Pseudo-Interface 1", true},
|
|
81
|
+
{" 以太网 ", false}, // 前后空格不算
|
|
82
|
+
}
|
|
83
|
+
for _, tc := range cases {
|
|
84
|
+
if got := isVirtualIface(tc.name); got != tc.want {
|
|
85
|
+
t.Errorf("isVirtualIface(%q) = %v,want %v", tc.name, got, tc.want)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// TestIsPrivateIPv4 RFC1918 判定。
|
|
91
|
+
func TestIsPrivateIPv4(t *testing.T) {
|
|
92
|
+
cases := []struct {
|
|
93
|
+
ip string
|
|
94
|
+
want bool
|
|
95
|
+
}{
|
|
96
|
+
{"192.168.31.37", true},
|
|
97
|
+
{"10.0.0.1", true},
|
|
98
|
+
{"172.16.0.1", true},
|
|
99
|
+
{"172.31.255.254", true},
|
|
100
|
+
{"172.32.0.1", false},
|
|
101
|
+
{"172.15.0.1", false},
|
|
102
|
+
{"2.0.0.1", false}, // 企业 VPN 虚拟网卡占的公网段
|
|
103
|
+
{"8.8.8.8", false},
|
|
104
|
+
{"", false},
|
|
105
|
+
}
|
|
106
|
+
for _, tc := range cases {
|
|
107
|
+
if got := isPrivateIPv4(tc.ip); got != tc.want {
|
|
108
|
+
t.Errorf("isPrivateIPv4(%q) = %v,want %v", tc.ip, got, tc.want)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// TestOrderLanAddresses 排序:手机最可能连上的那个必须排第一。
|
|
114
|
+
//
|
|
115
|
+
// 回归背景(2026-10-23):真实机器上枚举顺序是
|
|
116
|
+
//
|
|
117
|
+
// [0] 2.0.0.1 ← Sangfor aTrust VNIC(企业 VPN 虚拟网卡)
|
|
118
|
+
// [1] 172.29.64.1 ← Hyper-V 虚拟交换机
|
|
119
|
+
// [2] 192.168.31.37 ← 以太网(手机能连的只有这个)
|
|
120
|
+
//
|
|
121
|
+
// 移动端只取 lanUrls[0],于是「公司远程」环境被种成了 2.0.0.1 —— 手机永远连不上。
|
|
122
|
+
func TestOrderLanAddresses(t *testing.T) {
|
|
123
|
+
cands := []lanCandidate{
|
|
124
|
+
{ip: "2.0.0.1", iface: net.Interface{Name: "本地连接"}}, // VPN 虚拟网卡(Windows 上是中文别名)
|
|
125
|
+
{ip: "172.29.64.1", iface: net.Interface{Name: "vEthernet (Default Switch)"}}, // Hyper-V
|
|
126
|
+
{ip: "192.168.31.37", iface: net.Interface{Name: "以太网"}},
|
|
127
|
+
}
|
|
128
|
+
got := orderLanAddresses(cands, "192.168.31.37", 9100)
|
|
129
|
+
if len(got) == 0 {
|
|
130
|
+
t.Fatal("不该返回空")
|
|
131
|
+
}
|
|
132
|
+
if got[0] != "192.168.31.37:9100" {
|
|
133
|
+
t.Errorf("第一个必须是手机连得上的那个(192.168.31.37),got %v", got)
|
|
134
|
+
}
|
|
135
|
+
for _, a := range got {
|
|
136
|
+
if strings.HasPrefix(a, "2.0.0.1:") || strings.HasPrefix(a, "172.29.") {
|
|
137
|
+
t.Errorf("虚拟网卡地址不该出现在列表里:%s", a)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// TestOrderLanAddressesVirtualOnly 只有虚拟网卡时也得给地址 —— 不能一个都不报。
|
|
143
|
+
func TestOrderLanAddressesVirtualOnly(t *testing.T) {
|
|
144
|
+
cands := []lanCandidate{
|
|
145
|
+
{ip: "172.29.64.1", iface: net.Interface{Name: "vEthernet (Default Switch)"}},
|
|
146
|
+
}
|
|
147
|
+
got := orderLanAddresses(cands, "", 9100)
|
|
148
|
+
if len(got) != 1 || got[0] != "172.29.64.1:9100" {
|
|
149
|
+
t.Errorf("没有真实网卡时应退回报虚拟网卡地址,got %v", got)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// TestOrderLanAddressesPrimaryFirst 出站主地址在同一档里排最前。
|
|
154
|
+
func TestOrderLanAddressesPrimaryFirst(t *testing.T) {
|
|
155
|
+
cands := []lanCandidate{
|
|
156
|
+
{ip: "192.168.31.10", iface: net.Interface{Name: "以太网 2"}},
|
|
157
|
+
{ip: "192.168.31.37", iface: net.Interface{Name: "以太网"}},
|
|
158
|
+
}
|
|
159
|
+
got := orderLanAddresses(cands, "192.168.31.37", 9100)
|
|
160
|
+
if len(got) != 2 || got[0] != "192.168.31.37:9100" {
|
|
161
|
+
t.Errorf("出站主地址应排最前,got %v", got)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
// quotas_merge_test.go - --quotas 现查结果合并进缓存的引擎清单(额度不跟着缓存冻住)。
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"encoding/json"
|
|
7
|
+
"testing"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func TestMergeQuotaJSONOverridesStaleValues(t *testing.T) {
|
|
11
|
+
engines := []byte(`[
|
|
12
|
+
{"engine":"llm","ok":true,"credit_suffix":"%",
|
|
13
|
+
"model_quota":{"MiniMax-M3.1-Flash":{"interval_left":"98","weekly_left":"53"},
|
|
14
|
+
"minimax-peter":{"interval_left":"98","weekly_left":"53"}}},
|
|
15
|
+
{"engine":"codebuddy","ok":true,"credit_left":"5800.1","checkin_today":false,"checkin_credit":"100"},
|
|
16
|
+
{"engine":"trae","ok":true}
|
|
17
|
+
]`)
|
|
18
|
+
quotas := []byte(`[
|
|
19
|
+
{"engine":"llm","credit_suffix":"%",
|
|
20
|
+
"model_quota":{"MiniMax-M3.1-Flash":{"interval_left":"98","weekly_left":"53"},
|
|
21
|
+
"minimax-peter":{"interval_left":"1","weekly_left":"0"}}},
|
|
22
|
+
{"engine":"codebuddy","credit_left":"5910.77","checkin_today":true,"checkin_credit":"100"}
|
|
23
|
+
]`)
|
|
24
|
+
|
|
25
|
+
out := mergeQuotaJSON(engines, quotas)
|
|
26
|
+
var rows []map[string]any
|
|
27
|
+
if err := json.Unmarshal(out, &rows); err != nil {
|
|
28
|
+
t.Fatalf("合并结果不是合法 JSON: %v\n%s", err, out)
|
|
29
|
+
}
|
|
30
|
+
if len(rows) != 3 {
|
|
31
|
+
t.Fatalf("引擎行数变了: %d", len(rows))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
byEngine := map[string]map[string]any{}
|
|
35
|
+
for _, r := range rows {
|
|
36
|
+
byEngine[r["engine"].(string)] = r
|
|
37
|
+
}
|
|
38
|
+
if got := byEngine["codebuddy"]["credit_left"]; got != "5910.77" {
|
|
39
|
+
t.Errorf("credit_left 未刷新: %v", got)
|
|
40
|
+
}
|
|
41
|
+
if got := byEngine["codebuddy"]["checkin_today"]; got != true {
|
|
42
|
+
t.Errorf("checkin_today 未刷新: %v", got)
|
|
43
|
+
}
|
|
44
|
+
quota := byEngine["llm"]["model_quota"].(map[string]any)
|
|
45
|
+
peter := quota["minimax-peter"].(map[string]any)
|
|
46
|
+
if peter["interval_left"] != "1" || peter["weekly_left"] != "0" {
|
|
47
|
+
t.Errorf("minimax-peter 额度未按新 key 刷新: %v", peter)
|
|
48
|
+
}
|
|
49
|
+
if _, ok := byEngine["trae"]["credit_left"]; ok {
|
|
50
|
+
t.Errorf("没有额度的引擎不该被塞字段: %v", byEngine["trae"])
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
func TestMergeQuotaJSONKeepsOldOnEmpty(t *testing.T) {
|
|
55
|
+
engines := []byte(`[{"engine":"codebuddy","ok":true,"credit_left":"5800.1"}]`)
|
|
56
|
+
// 现查失败(字段缺失 / 空串)时保留缓存里的旧值,不能把数字抹掉。
|
|
57
|
+
quotas := []byte(`[{"engine":"codebuddy","credit_left":""}]`)
|
|
58
|
+
|
|
59
|
+
out := mergeQuotaJSON(engines, quotas)
|
|
60
|
+
var rows []map[string]any
|
|
61
|
+
if err := json.Unmarshal(out, &rows); err != nil {
|
|
62
|
+
t.Fatal(err)
|
|
63
|
+
}
|
|
64
|
+
if got := rows[0]["credit_left"]; got != "5800.1" {
|
|
65
|
+
t.Fatalf("空结果覆盖了旧值: %v", got)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func TestMergeQuotaJSONBadInputFallsBack(t *testing.T) {
|
|
70
|
+
engines := []byte(`[{"engine":"llm","ok":true}]`)
|
|
71
|
+
if out := mergeQuotaJSON(engines, []byte("not json")); string(out) != string(engines) {
|
|
72
|
+
t.Fatalf("额度输出非法时应原样返回,实际 %s", out)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 登录态也要现查:缓存里是「已登录」,但用户刚登出时下一次打开就该隐藏。
|
|
77
|
+
func TestMergeQuotaJSONLoggedIn(t *testing.T) {
|
|
78
|
+
engines := []byte(`[
|
|
79
|
+
{"engine":"codebuddy","ok":true,"logged_in":true,"account":"旧昵称"},
|
|
80
|
+
{"engine":"codebuddy-ai","ok":true,"logged_in":true}
|
|
81
|
+
]`)
|
|
82
|
+
quotas := []byte(`[
|
|
83
|
+
{"engine":"codebuddy","logged_in":true,"account":"Darren"},
|
|
84
|
+
{"engine":"codebuddy-ai","logged_in":false}
|
|
85
|
+
]`)
|
|
86
|
+
|
|
87
|
+
out := mergeQuotaJSON(engines, quotas)
|
|
88
|
+
var rows []map[string]any
|
|
89
|
+
if err := json.Unmarshal(out, &rows); err != nil {
|
|
90
|
+
t.Fatal(err)
|
|
91
|
+
}
|
|
92
|
+
got := map[string]any{}
|
|
93
|
+
acct := map[string]any{}
|
|
94
|
+
for _, r := range rows {
|
|
95
|
+
got[r["engine"].(string)] = r["logged_in"]
|
|
96
|
+
acct[r["engine"].(string)] = r["account"]
|
|
97
|
+
}
|
|
98
|
+
if got["codebuddy"] != true {
|
|
99
|
+
t.Errorf("codebuddy logged_in = %v, want true", got["codebuddy"])
|
|
100
|
+
}
|
|
101
|
+
if got["codebuddy-ai"] != false {
|
|
102
|
+
t.Errorf("codebuddy-ai 登出后应合并为 false,实际 %v", got["codebuddy-ai"])
|
|
103
|
+
}
|
|
104
|
+
if acct["codebuddy"] != "Darren" {
|
|
105
|
+
t.Errorf("账号昵称未刷新: %v", acct["codebuddy"])
|
|
106
|
+
}
|
|
107
|
+
}
|