From c1e8b8c7ade34b21d68df8ff86b142e4f2ad11f3 Mon Sep 17 00:00:00 2001 From: lordquest Date: Tue, 14 Jul 2026 23:55:29 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B=20fix:=20Web=20=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E5=9B=9E=E6=98=BE=E7=9A=84=E8=AE=BF=E9=97=AE=20IP=20?= =?UTF-8?q?=E9=80=89=E9=94=99=E7=BD=91=E5=8D=A1(169.254.x.x=20/=20?= =?UTF-8?q?=E8=99=9A=E6=8B=9F=E7=BD=91=E5=8D=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:localIP() 只过滤回环地址就返回第一个非回环 IPv4,而 net.InterfaceAddrs() 枚举顺序下,排在前面的常是 169.254.x.x 链路本地(APIPA,虚拟网卡 / 无 DHCP 自动分配) 或 Docker/WSL 虚拟网卡,导致面板 URL 回显成点不开的地址。 修复: 1. 优先用默认路由出口探测 —— net.Dial("udp","8.8.8.8:80")(不发真实流量, 仅触发内核路由查找)取 LocalAddr,即默认路由那张真实网卡的 IP;多张真实网卡时 取到的是 LAN 设备访问本机该用的地址。探测失败再退用枚举挑选法。 2. 枚举挑选法 selectHostIP 跳过链路本地(169.254.0.0/16),优先非链路本地地址; 全是链路本地才退用它,避免直接回退 127.0.0.1 让用户连不上。 3. 出口 IP 即便为链路本地也信任(说明默认路由真在那张卡上),不再二次过滤。 测试:新增 web/server_ip_test.go,用注入 dial 的桩覆盖 outboundIP 三态、 selectHostIP 多网卡挑选;原 web 包测试无回归。 --- web/server.go | 56 ++++++++++++++-- web/server_ip_test.go | 144 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 web/server_ip_test.go diff --git a/web/server.go b/web/server.go index e7e99a0..2e538c1 100644 --- a/web/server.go +++ b/web/server.go @@ -94,18 +94,64 @@ func displayHost(bind string) string { return bind } -// localIP 返回本机第一个非回环 IPv4;找不到则回退 127.0.0.1。 -// best-effort:多网卡(docker 网桥 / VPN / 多 NIC)时取到的不一定是用户期望的那张,仅用于 URL 回显, -// 不影响实际监听(已绑在所有接口)。 +// localIP 返回本机一个可用于访问 URL 的 IPv4:优先用默认路由出口地址(对多网卡最准—— +// 多张真实网卡时取到的是默认路由那张,正是 LAN 设备访问本机该用的地址),探测失败再退用 +// selectHostIP(枚举网卡挑第一个非链路本地);都没有则 127.0.0.1。 +// 仅用于 URL 回显,不影响实际监听(已绑在 0.0.0.0 所有接口)。 func localIP() string { + if ip, ok := outboundIP(func() (net.Conn, error) { + // UDP "连接"不发真实流量,只让内核做路由查找,返回的 LocalAddr 即出口网卡地址。 + return net.Dial("udp", "8.8.8.8:80") + }); ok { + return ip + } addrs, err := net.InterfaceAddrs() if err != nil { return "127.0.0.1" } + return selectHostIP(addrs) +} + +// outboundIP 通过 dial 到一个外部地址来获取本机默认路由出口的本地 IP。dial 注入便于测试 +// (无需真实网络)。成功且出口为合法非回环 IPv4 时返回 (ip, true);否则 ("", false),交由调用方回退。 +// 出口 IP 即便为链路本地也信任(说明默认路由真在那张卡上),不再额外过滤。 +func outboundIP(dial func() (net.Conn, error)) (string, bool) { + conn, err := dial() + if err != nil { + return "", false + } + defer conn.Close() + udp, ok := conn.LocalAddr().(*net.UDPAddr) + if !ok { + return "", false + } + ip := udp.IP + if ip == nil || ip.IsLoopback() || ip.To4() == nil { + return "", false + } + return ip.String(), true +} + +// selectHostIP 从一组接口地址里挑一个适合回显给用户的 IPv4:优先非链路本地地址 +// (192.168/10/172.16-31 等),没有则退用链路本地(169.254.x.x),再没有则 127.0.0.1。 +// 抽成纯函数便于注入合成地址做单元测试。 +func selectHostIP(addrs []net.Addr) string { + var linkLocal string for _, a := range addrs { - if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil { - return ipnet.IP.String() + ipnet, ok := a.(*net.IPNet) + if !ok || ipnet.IP.IsLoopback() || ipnet.IP.To4() == nil { + continue } + if ipnet.IP.IsLinkLocalUnicast() { // 覆盖 169.254.0.0/16(及 fe80::/10) + if linkLocal == "" { + linkLocal = ipnet.IP.String() + } + continue + } + return ipnet.IP.String() + } + if linkLocal != "" { + return linkLocal } return "127.0.0.1" } diff --git a/web/server_ip_test.go b/web/server_ip_test.go new file mode 100644 index 0000000..ae18877 --- /dev/null +++ b/web/server_ip_test.go @@ -0,0 +1,144 @@ +package web + +import ( + "net" + "testing" + "time" +) + +// fakeConn 实现一个只暴露给定 LocalAddr 的 net.Conn 桩,用于测试 outboundIP。 +type fakeConn struct{ local net.Addr } + +func (f *fakeConn) Read([]byte) (int, error) { return 0, nil } +func (f *fakeConn) Write([]byte) (int, error) { return 0, nil } +func (f *fakeConn) Close() error { return nil } +func (f *fakeConn) LocalAddr() net.Addr { return f.local } +func (f *fakeConn) RemoteAddr() net.Addr { return nil } +func (f *fakeConn) SetDeadline(time.Time) error { return nil } +func (f *fakeConn) SetReadDeadline(time.Time) error { return nil } +func (f *fakeConn) SetWriteDeadline(time.Time) error { return nil } + +// udpAddr 把 "host:port" 转成 *net.UDPAddr 桩,模拟 UDP dial 的 LocalAddr。 +func udpAddr(hostport string) net.Addr { + a, err := net.ResolveUDPAddr("udp", hostport) + if err != nil { + panic(err) + } + return a +} + +// TestOutboundIP 验证通过 dial 外部地址选出默认路由出口 IP 的逻辑。 +func TestOutboundIP(t *testing.T) { + tests := []struct { + name string + dial func() (net.Conn, error) + wantIP string + wantOK bool + }{ + { + name: "出口为真实局域网网卡 -> 返回它", + dial: func() (net.Conn, error) { + return &fakeConn{local: udpAddr("192.168.1.50:54321")}, nil + }, + wantIP: "192.168.1.50", + wantOK: true, + }, + { + name: "dial 失败 -> 回退(false)", + dial: func() (net.Conn, error) { + return nil, net.ErrWriteToConnected + }, + wantIP: "", + wantOK: false, + }, + { + name: "出口为回环 -> 回退(false)", + dial: func() (net.Conn, error) { + return &fakeConn{local: udpAddr("127.0.0.1:54321")}, nil + }, + wantIP: "", + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ip, ok := outboundIP(tt.dial) + if ok != tt.wantOK || ip != tt.wantIP { + t.Fatalf("outboundIP() = (%q, %v), want (%q, %v)", ip, ok, tt.wantIP, tt.wantOK) + } + }) + } +} + + +// mustIPNet 把 "ip/prefix" 字符串转成 *net.IPNet,用于构造测试地址。 +// 注意:net.ParseCIDR 返回的 *net.IPNet.IP 是网络号(主机位清零),而 net.InterfaceAddrs() +// 在真实运行时的 *net.IPNet.IP 是接口主机地址。这里把主机地址写回 IPNet.IP, +// 以精确模拟真实网卡地址的形制(否则 .String() 会得到 192.168.1.0 而非 192.168.1.50)。 +func mustIPNet(cidr string) net.Addr { + ip, ipnet, err := net.ParseCIDR(cidr) + if err != nil { + panic(err) + } + ipnet.IP = ip + return ipnet +} + +// TestSelectHostIP 验证从多网卡地址中挑选访问 URL 用的 IP: +// 必须跳过链路本地 169.254.x.x,优先返回真实局域网地址。 +func TestSelectHostIP(t *testing.T) { + tests := []struct { + name string + addrs []net.Addr + want string + }{ + { + name: "链路本地排在前,真实网卡在后 -> 取真实网卡", + addrs: []net.Addr{ + mustIPNet("169.254.8.253/16"), // 出问题的 APIPA 地址 + mustIPNet("192.168.1.50/24"), // 用户真正的局域网 IP + }, + want: "192.168.1.50", + }, + { + name: "只有链路本地 -> 退回它(而非 127.0.0.1)", + addrs: []net.Addr{ + mustIPNet("169.254.8.253/16"), + }, + want: "169.254.8.253", + }, + { + name: "回环地址被忽略,取真实网卡", + addrs: []net.Addr{ + mustIPNet("127.0.0.1/8"), + mustIPNet("10.0.0.5/8"), + }, + want: "10.0.0.5", + }, + { + name: "虚拟网卡(链路本地)与真实网卡并存,取真实(排序在任意位置)", + addrs: []net.Addr{ + mustIPNet("172.16.3.1/12"), // 真实局域网 + mustIPNet("169.254.1.1/16"), // Hyper-V/Docker 虚拟网卡 + }, + want: "172.16.3.1", + }, + { + name: "只有回环与 IPv6 链路本地 -> 退回 127.0.0.1", + addrs: []net.Addr{ + mustIPNet("127.0.0.1/8"), + mustIPNet("fe80::1/64"), + }, + want: "127.0.0.1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := selectHostIP(tt.addrs); got != tt.want { + t.Fatalf("selectHostIP() = %q, want %q", got, tt.want) + } + }) + } +} From a31c86f08ca483295ba532b8ed32a860ca639847 Mon Sep 17 00:00:00 2001 From: lordquest Date: Wed, 15 Jul 2026 00:04:16 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=90=9B=20fix:=20/provider=20=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E4=BE=9B=E5=BA=94=E5=95=86=E5=90=8E=20Web=20=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E6=A8=A1=E5=9E=8B=E5=90=8D=E6=9C=AA=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:Hub 的 Models 在 tui/run.go 启动时初始化一次,之后 /provider 切换供应商只更新了 TUI 侧的 m.models,没有同步到 Hub 快照,导致 Web 右栏仍显示旧模型名。 修复: - 后端:Event 加 Flash/Pro 字段;Hub 新增 SetModels(flash,pro,activeRole)方法 (线程安全,直接更新 snap.Models + 广播 models 事件); applyProvider 末尾调用 m.hub.SetModels 同步 Web 侧。 - 前端(web/static/app.js):reducer 新增 case 'models',更新 this.models.flash/pro/activeRole。 --- tui/setup_modal.go | 5 +++++ web/events.go | 4 ++++ web/hub.go | 34 ++++++++++++++++++++++++++++++++++ web/static/app.js | 5 +++++ 4 files changed, 48 insertions(+) diff --git a/tui/setup_modal.go b/tui/setup_modal.go index 6ec940d..5fd1988 100644 --- a/tui/setup_modal.go +++ b/tui/setup_modal.go @@ -425,6 +425,11 @@ func (m *model) applyProvider(name string) tea.Cmd { m.visionByModel = loadVisionCaps(m.models) m.appendChat("System", fmt.Sprintf(T("provider.switched"), name, m.models.Flash.Model, m.models.Pro.Model)) m.refreshViewport() + // /provider 切供应商后同步 Web 面板模型名(Hub 快照的 Models 只在启动时设过一次, + // 不广播的话浏览器右栏仍显示旧模型)。 + if m.hub != nil { + m.hub.SetModels(m.models.Flash.Model, m.models.Pro.Model, m.activeModelRole) + } // 换供应商 → 余额变了,先清空待重探回灌。 m.balance = "" cmds := visionProbeCmds(m.models) diff --git a/web/events.go b/web/events.go index 7dd84f3..d74a689 100644 --- a/web/events.go +++ b/web/events.go @@ -33,6 +33,10 @@ type Event struct { ModelID string `json:"modelId,omitempty"` Reason string `json:"reason,omitempty"` + // models(全量模型名同步:/provider 切供应商后更新 flash/pro 展示名) + Flash string `json:"flash,omitempty"` + Pro string `json:"pro,omitempty"` + // plan(整份)/ plan_status(单节点) Plan []PlanNode `json:"plan,omitempty"` PlanKind string `json:"planKind,omitempty"` // "todo"(计划)| "createplan"(步骤) diff --git a/web/hub.go b/web/hub.go index 71ae3a3..837b975 100644 --- a/web/hub.go +++ b/web/hub.go @@ -139,6 +139,29 @@ func (h *Hub) Broadcast(ev Event) { h.mu.Unlock() } +// SetModels 全量更新 Hub 快照里的模型展示信息,并广播给所有已连接的浏览器。 +// /provider 切换供应商后调用,保证 Web 右栏的模型名与 CLI 一致。 +func (h *Hub) SetModels(flash, pro, activeRole string) { + if h == nil { + return + } + h.mu.Lock() + h.snap.Models = ModelsInfo{Flash: flash, Pro: pro, ActiveRole: activeRole} + ev := Event{Kind: "models", Flash: flash, Pro: pro} + if activeRole != "" { + ev.Role = activeRole + } + for ch := range h.clients { + select { + case ch <- ev: + default: + close(ch) + delete(h.clients, ch) + } + } + h.mu.Unlock() +} + // Subscribe 注册一个新客户端,返回其事件 channel、当前快照、以及注销函数。 // 调用方应先把 snapshot 发给浏览器,再从 channel 持续读增量。 func (h *Hub) Subscribe() (<-chan Event, Snapshot, func()) { @@ -271,6 +294,17 @@ func (h *Hub) apply(ev Event) Event { h.snap.Models.ActiveRole = ev.Role } + case "models": + if ev.Flash != "" { + h.snap.Models.Flash = ev.Flash + } + if ev.Pro != "" { + h.snap.Models.Pro = ev.Pro + } + if ev.Role != "" { + h.snap.Models.ActiveRole = ev.Role + } + case "plan": // createplan → 步骤;todo / 其它 → 计划。对齐 TUI 的 计划/步骤 两套。 if ev.PlanKind == "createplan" { diff --git a/web/static/app.js b/web/static/app.js index 1dd908c..2ab7edf 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -765,6 +765,11 @@ createApp({ case 'model_switch': if (ev.role) this.models.activeRole = ev.role; break; + case 'models': + if (ev.flash) this.models.flash = ev.flash; + if (ev.pro) this.models.pro = ev.pro; + if (ev.role) this.models.activeRole = ev.role; + break; case 'plan': // createplan → 步骤;todo / 其它 → 计划(对齐 TUI 与后端 hub) if (ev.planKind === 'createplan') this.step = ev.plan || [];