Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions tui/setup_modal.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions web/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"(步骤)
Expand Down
34 changes: 34 additions & 0 deletions web/hub.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down Expand Up @@ -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" {
Expand Down
56 changes: 51 additions & 5 deletions web/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
144 changes: 144 additions & 0 deletions web/server_ip_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
5 changes: 5 additions & 0 deletions web/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || [];
Expand Down