跳转至

crates\nfarms-gui\src\local_model.rs

源码路径:crates\nfarms-gui\src\local_model.rs · 1193 行

//! Harness 页的「本地大模型」区块。
//!
//! 两件事:把 llama.cpp / stable-diffusion.cpp 的安装状态显示出来,并且能**真的拉起来**。
//!
//! 进程管理沿用原版 Dsh 页的做法 —— **重定向到日志文件 + 定时读文件尾部**,
//! 而不是去接子进程的管道。原因:
//!   * 不用为每个子进程起读线程,也不用担心管道满了把子进程卡死;
//!   * 进程崩了日志还在,出问题能查;
//!   * 原版 README 里「输出集成在界面里、不弹控制台窗口」就是这个套路。
//!
//! 拉起时加 `CREATE_NO_WINDOW`,不然每次点按钮都弹一个黑框。

use std::path::{Path, PathBuf};
use std::process::Child;

/// 两个后端在界面上的名字。定成常量是因为**同一个名字要用在三处**:
/// 拦住启动的理由、界面状态行、"谁占着"的提示 —— 分开写迟早会不一致。
const LLAMA_RUNNING: &str = "编程处理(llama.cpp)";
const SD_RUNNING: &str = "图像处理(stable-diffusion.cpp)";

use eframe::egui;
use egui::{Color32, FontId, RichText};

use nfarms_core::libs::{self, LLAMA, SD};
// 端口的**默认值只有一个出处**:`nfarms_core::store` 顶部那两个常量(9094 / 9095)。
//
// 为什么不在这里再写一遍数字:两处各写一份,迟早只改一处,而症状是最难查的那种 ——
// 界面(Port 页读的是本文件这个字段)显示 9094,真正拼进 `llama-server -p` 的却是
// 另一个数,「启动成功但连不上」。
//
// 为什么不去调 `store.llama_port()` 存取器:`LocalModel::new` 的签名只有 `&Paths`
// (调用方在 lib.rs,本次不动别的文件),手上根本没有 `Store` 实例;初值这边
// 直接取常量即可,读的一侧(load_ports)照旧走 store。
use nfarms_core::store::{DEFAULT_LLAMA_PORT, DEFAULT_SD_PORT, KEY_LLAMA_PORT, KEY_SD_PORT};
use nfarms_core::{Log, Paths};

use crate::theme;

/// 日志文件里最多回显多少行
const LOG_TAIL_LINES: usize = 40;

#[derive(Default)]
pub struct LocalModel {
    /// `llama-server` 的监听端口(文本形式,便于输入框直接绑定)。
    ///
    /// 初值是 [`DEFAULT_LLAMA_PORT`] = **9094**,不再是老默认 8080:8080 属于
    /// 「谁都爱占」的常见端口(本地 http 服务、各种开发脚手架),撞上时本程序的
    /// 请求会被另一个进程收下,报错只会在别处出现,看不出是撞端口。
    /// 改端口统一在 Setting › Proxy and Port 页做,这里只是内存里的当前值。
    pub llama_port: String,
    /// llama-server 的 `-c/--ctx-size`(文本形式,便于输入框直接绑定)。
    ///
    /// **它不是个纯性能参数,而是和 DSH 的 `contextWindow` 配对的另一半。**
    /// 实测过:DSH 声明 32768、服务端只开 8192 时,请求到 9961 token 才被
    /// llama.cpp 以 400 拒绝(`exceeds the available context size`),
    /// 而 DSH 那边一直以为还有 32K 空间 —— 超了也不自知,压缩也触发不了。
    ///
    /// 与端口同理,存在 store.json(见 `load_ctx` / `save_ctx`)。
    pub llama_ctx: String,
    pub prompt: String,
    /// 手动按「刷新状态」的时间戳提示
    pub last_action: String,
    /// 当前选中的可拉起模型(`libs::SERVABLE` 的下标)
    pub selected: usize,

    llama: Option<Child>,
    sd: Option<Child>,
    llama_log: PathBuf,
    sd_log: PathBuf,
    /// `sd-server` 的监听端口(文本形式,便于输入框直接绑定)。
    ///
    /// 初值是 [`DEFAULT_SD_PORT`] = **9095**,不再是老默认 1234:1234 同样是常见端口
    /// (不少本地服务 / 隧道默认就用它),避开它才能和 llama.cpp 的 9094 一起
    /// 被 Setting › Proxy and Port 页统一编排。
    ///
    /// 放在这里而不是 `Settings`:`settings.json` 是**与 C# 原版逐字节比对**的,
    /// 往里加字段会直接打破那个回归。
    pub sd_port: String,

    /// `spawn_fetch` 起的下载进程的把手。
    ///
    /// 原来是 `Ok(_) => …` —— **`Child` 被直接丢掉**。`fetch-libs.ps1` 要跑十几分钟
    /// 到几小时,于是界面上既没有"正在下载",也没有任何办法停它:脚本卡住就只能去
    /// 任务管理器里翻 `powershell.exe`。留着把手才能 `try_wait` 判存活、`kill` 停。
    ///
    /// **只留一个槽位是有意的**:三个下载入口共用同一份 `-Root`,并发跑会在 `libs\`
    /// 里抢同一批目标文件。所以已经在下载时第二个请求会被**明确拒绝**(见
    /// `spawn_fetch`)—— 和 `blocking()` 那道硬拦同一个理由:与其让人去猜,
    /// 不如在放第二个进程出去之前就说清是谁占着。
    fetch: Option<Child>,
    /// 这一轮下载写到哪个日志。
    ///
    /// 界面要读它:`libs\` 下这个文件**全仓没有任何读者** —— 日志查看器只看
    /// `logs\`(`core_pages.rs` 的 `list_logs`),下载日志写在 `libs\`(见 `spawn_fetch`)。
    fetch_log: Option<PathBuf>,
    /// 这一轮下载的开关名(`-BinariesOnly` 之类),给界面显示用 ——
    /// 光看日志文件名说明不了是哪一个入口起的。
    fetch_flag: Option<String>,
    /// 这一轮下载最后是怎么结束的(`None` = 没跑过 / 还在跑)。
    fetch_end: Option<FetchEnd>,
}

/// 下载进程最后是怎么结束的。
///
/// 需要单独记它,是因为"进程没了"和"下完了"是两件事:`fetch-libs.ps1` 失败时同样
/// 是"没了",用户按「停止下载」也是"没了"。只看 `Option<Child>` 变回 `None`
/// 分不出这几种,界面上就只能装聋作哑(原来的代码连"没了"都不知道)。
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum FetchEnd {
    /// 脚本自己退出,里面是退出码(0 才算全成功)
    Exited(i32),
    /// 用户点了「停止下载」
    Stopped,
}

/// 两个模型后端。
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Which {
    /// 编程处理(llama.cpp 的 llama-server)
    Llama,
    /// 图像处理(stable-diffusion.cpp 的 sd-cli)
    Sd,
}

impl LocalModel {
    /// 「**一个模型停了才能起另一个**」的唯一判定点。
    ///
    /// 这不是建议,是硬拦。两个后端各占一大块:
    /// llama.cpp 的 MoE 专家权重、sd.cpp 的扩散主干 + 文本编码器。
    /// 这台机器是 **UMA(显存和内存是同一块)**,同时跑会把两边一起挤爆,
    /// 表现是"启动成功但推理极慢"甚至被 OOM 杀掉 —— 与其让用户去猜,
    /// 不如在按下按钮前就拦住,并**说清是谁占着**。
    ///
    /// 返回 `Some(占用者名字)` = 不许启动。
    pub fn blocking(&mut self, want: Which) -> Option<&'static str> {
        let llama_up = running(&mut self.llama);
        let sd_up = running(&mut self.sd);
        match want {
            Which::Llama if sd_up => Some(SD_RUNNING),
            Which::Sd if llama_up => Some(LLAMA_RUNNING),
            _ => None,
        }
    }

    /// 现在在跑的是哪个(给界面显示 + 决定哪个「启动」按钮要置灰)。
    pub fn running_name(&mut self) -> Option<&'static str> {
        if running(&mut self.llama) {
            Some(LLAMA_RUNNING)
        } else if running(&mut self.sd) {
            Some(SD_RUNNING)
        } else {
            None
        }
    }

    /// 停掉**所有**模型进程。给「全部停止」用 —— 也是唯一的"解绑"方式。
    pub fn stop_all(&mut self) {
        Self::stop(&mut self.llama);
        Self::stop(&mut self.sd);
    }
    pub fn new(paths: &Paths) -> Self {
        // 默认选第一个**已经下载好**的:没下编程模型时不至于一点就报错
        let selected = libs::servable_status(paths)
            .iter()
            .position(|p| p.present())
            .unwrap_or(0);
        Self {
            // 初值取自 store.rs 的常量(9094),本文件不再写死数字:
            // 真正生效的默认值就是这里 —— store.rs 改了口子,初值也必须跟着走
            llama_port: DEFAULT_LLAMA_PORT.to_string(),
            llama_ctx: libs::DEFAULT_LLAMA_CTX.to_string(),
            prompt: "a lovely cat holding a sign says 'nfarms'".to_string(),
            last_action: String::new(),
            selected,
            llama: None,
            sd: None,
            llama_log: paths.lib(LLAMA.dir).join("nfarms-llama-server.log"),
            sd_log: paths.lib(SD.dir).join("nfarms-sd.log"),
            // 同理:初值来自 store.rs 的常量(9095)。原来这里注的是
            // 「sd-server --help 里的默认值」—— 迁移后已经**不再是** sd 自己的默认值,
            // 而是本项目统一编排出来的端口,别照旧注释去对 sd-cli 的 help
            sd_port: DEFAULT_SD_PORT.to_string(),
            fetch: None,
            fetch_log: None,
            fetch_flag: None,
            fetch_end: None,
        }
    }

    pub fn llama_running(&mut self) -> bool {
        running(&mut self.llama)
    }

    pub fn sd_running(&mut self) -> bool {
        running(&mut self.sd)
    }

    fn stop(child: &mut Option<Child>) {
        if let Some(mut c) = child.take() {
            let _ = c.kill();
            let _ = c.wait();
        }
    }

    // ------------------------------------------------------------ 动作

    /// 启动 llama-server。
    ///
    /// **对外公开**是因为 Tokens 页要调它:那边的「指标接口没开(501)」
    /// 只有一个修法 —— 停掉端口上那个(可能是别处拉起的),再用本程序启动。
    /// 本程序的启动参数里带 `--metrics`。
    pub fn start_llama(&mut self, paths: &Paths, log: &Log) {
        let st = libs::component_status(paths, &LLAMA);
        let Some(exe) = st.found.iter().find(|(n, _)| *n == "llama-server.exe") else {
            self.last_action = "找不到 llama-server.exe".into();
            return;
        };
        // 用界面上选中的那个模型(编程处理 / 通用对话)
        let spec = libs::SERVABLE[self.selected.min(libs::SERVABLE.len() - 1)];
        let model = paths.lib("model").join(spec.file);
        if !model.is_file() {
            self.last_action = format!(
                "{} 还没下载({})。点「下载缺失组件」或跑 .\\libs\\scripts\\fetch-libs.ps1",
                spec.name, spec.file
            );
            return;
        }
        // 硬拦:另一个后端在跑就不许启动(见 `blocking` 的说明)
        if let Some(other) = self.blocking(Which::Llama) {
            let msg = format!("{other} 正在运行 —— 先停掉它,才能启动编程模型");
            log.write("llama", &msg);
            self.last_action = msg;
            return;
        }
        // 兜底走同一个常量(9094):这里原来是写死的 `unwrap_or(8080)`,
        // 于是"输入框空着"和"没设过"会走到**两个不同的端口**上
        let port: u16 = llama_port_or_default(&self.llama_port);
        // 界面/store.json 来的上下文长度要**再校验一次**:非法值一路传到
        // llama-server 才炸的话,报错里看不出是哪来的。校验不过就用默认值,
        // 而不是拿一个可疑的数字去起服务。
        let ctx: u32 = self
            .llama_ctx
            .trim()
            .parse::<u32>()
            .ok()
            .filter(|c| libs::valid_llama_ctx(*c))
            .unwrap_or(libs::DEFAULT_LLAMA_CTX);
        let args = libs::llama_server_args_with_ctx(&model, port, spec.offload, ctx);
        match spawn_logged(&exe.1, &args, &st.dir, &self.llama_log) {
            Ok(child) => {
                self.llama = Some(child);
                self.last_action =
                    format!("{} 已启动,端口 {port},上下文 {ctx}", spec.name);
                log.write(
                    "llama",
                    &format!(
                        "启动 llama-server({})端口 {port},-c {ctx}。\
                         DSH 侧 contextWindow 必须是同一个数",
                        spec.name
                    ),
                );
            }
            Err(e) => {
                self.last_action = format!("启动失败:{e}");
                log.write("llama", &format!("启动失败:{e}"));
            }
        }
    }

    /// **端口记忆:从 store.json 读回上次用的值。**
    ///
    /// 不记的话每次重启都回到默认值(现在是 9094 / 9095),而 **DSH 的 provider
    /// baseURL 写在它自己的配置文件里** —— 两边就对不上了,症状是 `connection refused`,
    /// 从这句话看不出是端口变了。
    ///
    /// 读回时**顺手校验**:store.json 被手工改坏时,一个非法端口会一路传到
    /// `llama_server_args` 才炸。不合法的丢弃、用默认值。
    ///
    /// **读到什么就用什么,不做归一化**:老用户盘上写着 8080,读回来就得是 8080。
    /// 这里要是顺手"迁移"成 9094,而用户 DSH 那边的 baseURL 还指着 8080,
    /// 症状就是本文档开头那句 connection refused —— 迁移只动"没设过"时的初值。
    ///
    /// 也**不走 `store.llama_port()` 那个 u16 存取器**:它把值折成数字再回来,
    /// 盘上原本怎么写的就看不出来了(读设置的动作不该顺手改用户的写法)。
    pub fn load_ports(&mut self, store: &nfarms_core::store::Store) {
        if let Some(v) = store.get_str(KEY_LLAMA_PORT) {
            if v.trim().parse::<u16>().is_ok() {
                self.llama_port = v;
            }
        }
        if let Some(v) = store.get_str(KEY_SD_PORT) {
            if v.trim().parse::<u16>().is_ok() {
                self.sd_port = v;
            }
        }
    }

    /// 把当前端口存回 store.json。改了端口就调它。
    ///
    /// 键名用 `nfarms_core::store` 的常量而不是字面量:打错一个字母的后果是
    /// "设置存进去了、下次启动读不回来、悄悄回到默认",而盘上那份值还好好的。
    pub fn save_ports(&self, store: &mut nfarms_core::store::Store) -> Result<(), String> {
        store.set_str(KEY_LLAMA_PORT, self.llama_port.trim());
        store.set_str(KEY_SD_PORT, self.sd_port.trim());
        store.save()
    }

    /// **上下文长度记忆:从 store.json 读回上次用的值。**
    ///
    /// 和端口是同一类东西,理由也一样:它**不是本程序自己的私有参数**,
    /// DSH 的 `contextWindow` 必须跟着它填(见 `llama_ctx` 的说明)。
    /// 不记的话每次重启回到默认值,而 DSH 那边还留着上次的数 ——
    /// 两边再次错位,症状又变回那个看不懂的 400。
    ///
    /// 读回时同样校验:非法值丢弃、用默认值(`libs::valid_llama_ctx`)。
    pub fn load_ctx(&mut self, store: &nfarms_core::store::Store) {
        if let Some(v) = store.get_str("llama_ctx") {
            if v.trim()
                .parse::<u32>()
                .ok()
                .is_some_and(libs::valid_llama_ctx)
            {
                self.llama_ctx = v;
            }
        }
    }

    /// 把当前上下文长度存回 store.json。改了那一栏就调它。
    pub fn save_ctx(&self, store: &mut nfarms_core::store::Store) -> Result<(), String> {
        store.set_str("llama_ctx", self.llama_ctx.trim());
        store.save()
    }

    /// 删掉一个已就位的权重文件。
    ///
    /// 两道保护,都是有理由的:
    ///   * **有模型在跑就不许删** —— 文件被进程占着,Windows 上 remove_file 会直接失败;
    ///     就算侥幸成功,sd-cli 已经握着句柄,后面行为不可预期。
    ///   * **只删 `libs\model\` 下清单里列出的文件** —— 名字必须在
    ///     `QWEN_IMAGE_2_1` 里找得到才动手。排除了"某天一个变量传错就把别的目录清了"。
    fn delete_model_file(&mut self, paths: &Paths, log: &Log, name: &str) {
        if let Some(r) = self.running_name() {
            let msg = format!("{r} 正在运行 —— 先停掉它才能删模型文件");
            log.write("model", &msg);
            self.last_action = msg;
            return;
        }
        let Some(f) = libs::QWEN_IMAGE_2_1.iter().find(|f| f.name == name) else {
            let msg = format!("清单里没有 {name},不删");
            log.write("model", &msg);
            self.last_action = msg;
            return;
        };
        let p = paths.libs().join("model").join(f.name);
        let msg = match std::fs::remove_file(&p) {
            Ok(()) => format!("已删除 {}", f.name),
            Err(e) => format!("删除 {} 失败:{e}", f.name),
        };
        log.write("model", &msg);
        self.last_action = msg;
    }

    /// 启动 sd-server。公开的理由和 [`Self::start_llama`] 一样:
    /// Proxy and Port 页要把「启动」和「停止」放在一起。
    pub fn start_sd(&mut self, paths: &Paths, log: &Log) {
        let st = libs::component_status(paths, &SD);
        let Some(exe) = st.found.iter().find(|(n, _)| *n == "sd-cli.exe") else {
            self.last_action = "找不到 sd-cli.exe".into();
            return;
        };
        let ms = libs::model_status(paths);
        if !ms.ready() {
            self.last_action = "模型不齐,先点「下载缺失模型」".into();
            return;
        }
        // 硬拦:同上
        if let Some(other) = self.blocking(Which::Sd) {
            let msg = format!("{other} 正在运行 —— 先停掉它,才能启动图像模型");
            log.write("sd", &msg);
            self.last_action = msg;
            return;
        }
        let out = paths
            .shots()
            .join(format!("qwen-{}.png", crate::now_stamp()));
        let _ = std::fs::create_dir_all(paths.shots());
        // 512×512 = sd-cli 自己的默认值,所以这里**和改动前行为一致**。
        // 分辨率选择器在桌宠对话框那边(那边是"我想画多大就画多大"的场景);
        // 这一页是"启动一次看看能不能跑通",不需要多一个选择项。
        let args = libs::sd_cli_args(&self.prompt, &out, &ms.dir, 512, 512);
        match spawn_logged(&exe.1, &args, &st.dir, &self.sd_log) {
            Ok(child) => {
                self.sd = Some(child);
                self.last_action = format!("sd-cli 已启动 → {}", out.display());
                log.write("sd", &format!("启动 sd-cli → {}", out.display()));
            }
            Err(e) => {
                self.last_action = format!("启动失败:{e}");
                log.write("sd", &format!("启动失败:{e}"));
            }
        }
    }

    /// 复用已经写过、跑通过的下载脚本,而不是在 Rust 里重写一遍断点续传。
    ///
    /// `flag` 就是脚本的开关:`-BinariesOnly` / `-ImageOnly` / `-CodeOnly`。
    ///
    /// 起完进程**必须把 `Child` 留下**(见 `fetch` 字段):下载要跑很久,界面得能说出
    /// "正在下载"、能停。
    fn spawn_fetch(&mut self, paths: &Paths, log: &Log, flag: &str) {
        // 先收割再判断:上一轮可能刚刚结束,那种情况下要放行
        self.poll_fetch();
        if self.fetch.is_some() {
            // 硬拦,理由见 `fetch` 字段:三个入口共用 `libs\` 那一份地盘
            let msg = format!(
                "已有下载在跑({})—— 等它结束,或先点「停止下载」",
                self.fetch_flag.as_deref().unwrap_or("未知")
            );
            log.write("libs", &msg);
            self.last_action = msg;
            return;
        }
        let script = paths.fetch_libs_script();
        if !script.is_file() {
            self.last_action = format!("找不到下载脚本:{}", script.display());
            return;
        }
        let out = paths.libs().join(format!(
            "nfarms-fetch-{}.log",
            flag.trim_start_matches('-').to_lowercase()
        ));
        let args = vec![
            "-NoProfile".to_string(),
            "-ExecutionPolicy".to_string(),
            "Bypass".to_string(),
            "-File".to_string(),
            script.display().to_string(),
            flag.to_string(),
            "-Root".to_string(),
            paths.root().display().to_string(),
        ];
        match spawn_logged(Path::new("powershell.exe"), &args, paths.root(), &out) {
            Ok(child) => {
                self.fetch = Some(child);
                self.fetch_log = Some(out.clone());
                self.fetch_flag = Some(flag.to_string());
                self.fetch_end = None; // 新的一轮:上一次的结论作废
                self.last_action = format!("已开始下载({flag})→ {}", out.display());
                log.write("libs", &format!("开始下载 {flag}"));
            }
            Err(e) => self.last_action = format!("下载启动失败:{e}"),
        }
    }

    /// 轮询下载进程,**每帧调一次**(和 `running()` 同性质:顺手收割已经退出的)。
    ///
    /// 这里**不阻塞**:`try_wait` 只查一次内核里的进程状态。改用 `wait()` 会把界面
    /// 线程按在下载脚本上十几分钟 —— 那正是这一批要修的东西。
    fn poll_fetch(&mut self) {
        let Some(c) = self.fetch.as_mut() else {
            return;
        };
        match c.try_wait() {
            Ok(Some(st)) => {
                // 退出码拿不到(被别的信号干掉之类)时记 -1:界面至少还能说出
                // "结束得不对劲",比"没有状态"强
                self.fetch_end = Some(FetchEnd::Exited(st.code().unwrap_or(-1)));
                self.fetch = None;
            }
            Ok(None) => {} // 还在跑
            Err(e) => {
                // 句柄坏了之类:当成结束,但**别把原因吞掉**(原来这条链整条是静默的)
                self.fetch_end = Some(FetchEnd::Exited(-1));
                self.fetch = None;
                self.last_action = format!("下载进程状态读取失败:{e}");
            }
        }
    }

    /// 停掉当前的下载进程 —— 界面上的「停止下载」按钮走这里。
    ///
    /// 记成 [`FetchEnd::Stopped`] 而不是退出码:我们自己杀的和脚本自己失败要分得开,
    /// 否则用户点了停止却看到"退出码 1",只会以为脚本坏了。
    fn stop_fetch(&mut self) {
        if self.fetch.is_none() {
            return;
        }
        Self::stop(&mut self.fetch);
        self.fetch_end = Some(FetchEnd::Stopped);
    }

    // ------------------------------------------------------------ 界面

    pub fn ui(&mut self, ui: &mut egui::Ui, paths: &Paths, log: &Log) {
        let llama = libs::component_status(paths, &LLAMA);
        let servable = libs::servable_status(paths);

        // ---- 编程处理(llama.cpp)----
        crate::plugins::ui::section(
            ui,
            "编程处理",
            "本地大模型推理 · llama.cpp 的 llama-server(OpenAI 兼容接口)",
        );
        crate::plugins::ui::card(ui, |ui| {
            status_row(
                ui,
                "运行时",
                llama.installed(),
                &match &llama.build {
                    Some(b) => format!("{} · 构建 {b}", llama.dir.display()),
                    None => format!("{} · 构建未知", llama.dir.display()),
                },
            );
            status_row(
                ui,
                "后端",
                true,
                "SYCL(Intel Arc)+ CPU —— 本机不是 NVIDIA,所以没下 CUDA 版",
            );
            // 把「权重放哪」这件事说清楚:MoE 模型按实测配置了专家层留 CPU,
            // 不然会崩(见 libs::Offload 的注释)
            let cur = libs::SERVABLE[self.selected.min(libs::SERVABLE.len() - 1)];
            status_row(
                ui,
                "卸载",
                true,
                match cur.offload {
                    libs::Offload::CpuMoe => {
                        "MoE:注意力层上 GPU,专家权重留 CPU(实测生成 18.3 vs 7.7 t/s)"
                    }
                    libs::Offload::Gpu => "全部层上 GPU",
                    libs::Offload::Cpu => "纯 CPU",
                },
            );

            ui.add_space(6.0);
            ui.label(RichText::new("选择模型").size(12.0).color(theme::TEXT_SUB));
            for (i, p) in servable.iter().enumerate() {
                let mark = if p.present() { "✓" } else { "✗" };
                let text = format!(
                    "{} {:<24} {:>10}   {}",
                    mark,
                    p.model.name,
                    p.describe(),
                    p.model.role
                );
                let color = if p.present() {
                    theme::TEXT_MAIN
                } else {
                    theme::TEXT_SUB
                };
                if ui
                    .selectable_label(
                        self.selected == i,
                        RichText::new(text).size(12.0).color(color),
                    )
                    .clicked()
                {
                    self.selected = i;
                }
            }

            ui.add_space(8.0);
            ui.horizontal(|ui| {
                // 端口只在 Setting › Proxy and Port 改;字段保留(启动与拼 URL 要用)
                ui.label(
                    RichText::new(format!("端口 {}", self.llama_port.trim()))
                        .size(11.5)
                        .color(theme::TEXT_SUB),
                )
                .on_hover_text("要改端口请到 Setting › Proxy and Port");

                let running = self.llama_running();
                if running {
                    if ui
                        .add_sized([110.0, 28.0], egui::Button::new("■ 停止服务"))
                        .clicked()
                    {
                        Self::stop(&mut self.llama);
                        self.last_action = "llama-server 已停止".into();
                    }
                    let url = format!("http://127.0.0.1:{}/", self.llama_port.trim());
                    if ui
                        .add_sized([96.0, 28.0], egui::Button::new("🌐 打开"))
                        .clicked()
                    {
                        open_url(&url);
                    }
                }
                else {
                    // 「**一个模型停了才能起另一个**」在界面上的体现:
                    // sd-cli 在跑时这个按钮直接置灰并说明原因。
                    //
                    // 数据层(`start_llama`)里也有一道硬拦,但那是兜底 ——
                    // 让用户点下去才被告知不行,不如一开始就不给点。
                    // 两处都留是有意的:界面可能被绕过(命令行、快捷键),
                    // 而真正决定能不能跑的是显存。
                    let blocked = self.running_name() == Some(SD_RUNNING);
                    let btn = ui.add_enabled(
                        !blocked,
                        egui::Button::new("▶ 启动服务").min_size(egui::vec2(110.0, 28.0)),
                    );
                    let btn = if blocked {
                        btn.on_disabled_hover_text(format!(
                            "{SD_RUNNING} 正在运行 —— 先停掉它才能启动编程模型\n\
                             (两个后端同时跑会把显存挤爆)"
                        ))
                    } else {
                        btn
                    };
                    if btn.clicked() {
                        self.start_llama(paths, log);
                    }
                }

                // 「全部停止」。单例约束下一次只跑一个,所以它实际就是
                // "把当前那个停掉" —— 但价值在于**不用先判断是谁在跑**。
                if self.running_name().is_some()
                    && ui
                        .add_sized([110.0, 28.0], egui::Button::new("■ 全部停止"))
                        .on_hover_text("停掉所有模型进程(编程 / 图像)")
                        .clicked()
                {
                    self.stop_all();
                    self.last_action = "所有模型已停止".into();
                }

                if ui
                    .add_sized([120.0, 28.0], egui::Button::new("⤓ 下载缺失组件"))
                    .on_hover_text("从 GitHub 经代理下载 llama.cpp / stable-diffusion.cpp 二进制")
                    .clicked()
                {
                    self.spawn_fetch(paths, log, "-BinariesOnly");
                }
                if ui
                    .add_sized([120.0, 28.0], egui::Button::new("⤓ 下载编程模型"))
                    .on_hover_text("从 ModelScope 下载 Qwen3-Coder-30B(约 17.3 GB)")
                    .clicked()
                {
                    self.spawn_fetch(paths, log, "-CodeOnly");
                }
            });

            // 下载进程的状态 / 停止 / 输出。紧挨着下载按钮放,点了之后**同一眼里**
            // 就能看到"起没起来、跑到哪了"
            self.fetch_row(ui);

            if self.llama_running() {
                log_panel(ui, &self.llama_log, "llama-server 输出");
            }
        });
    }

    /// **图像处理**这一段,原来和「编程处理」挤在同一个 `ui()` 里。
    ///
    /// 拆开是因为它们是两个独立后端(llama.cpp 管文本、sd.cpp 管图像),
    /// 各有自己的二进制、端口和显存占用。挤在一页时:
    ///
    /// - 「哪个装了哪个没装」要在一段 300 行的代码里翻找;
    /// - 两个下载/启动入口混在一起,点错是常事。
    ///
    /// 拆完之后 llama.cpp 页只管编程,sd.cpp 页只管图像。
    pub fn ui_image(&mut self, ui: &mut egui::Ui, paths: &Paths, log: &Log) {
        // 这两行原来算在 `ui()` 的开头。**拆函数时漏掉局部变量是这类重构最容易
        // 踩的坑** —— 编译器一次报了 9 个 "cannot find value",把依赖全指了出来。
        // 它们只依赖 `paths` 和常量,各算一份即可,没有共享状态的副作用。
        let sd = libs::component_status(paths, &SD);
        let model = libs::model_status(paths);

        // ---- 图像处理(sd.cpp + Qwen-Image 2.1)----
        crate::plugins::ui::section(
            ui,
            "图像处理",
            "本地图像生成与编辑 · stable-diffusion.cpp + Qwen-Image 2.1",
        );
        crate::plugins::ui::card(ui, |ui| {
            status_row(
                ui,
                "运行时",
                sd.installed(),
                &format!("{} · 构建 {}", sd.dir.display(), sd.build.as_deref().unwrap_or("未知")),
            );
            status_row(
                ui,
                "后端",
                true,
                "Vulkan(Intel Arc)+ CPU 兜底",
            );
            ui.add_space(6.0);
            ui.label(RichText::new("权重清单").size(12.0).color(theme::TEXT_SUB));
            // 「有模型在跑」在循环外算一次:循环里每行都调一次 `running_name()`
            // 会反复去 reap 子进程,没必要
            let any_running = self.running_name().is_some();
            let mut delete_me: Option<&'static str> = None;
            for f in libs::QWEN_IMAGE_2_1.iter() {
                let hit = model.present.iter().find(|(p, _)| p.name == f.name);
                match hit {
                    Some((_, size)) => {
                        ui.horizontal(|ui| {
                            bullet(
                                ui,
                                true,
                                &format!("{}  ·  {:.0} MB", f.name, *size as f64 / 1_048_576.0),
                            );
                            // **删除**。一个 4 GB 的权重,手滑点掉要重下半小时,
                            // 所以两道保护:
                            //   ① 有模型在跑就不给点(文件被进程占着,Windows 上必失败);
                            //   ② hover 里写明**要删哪个路径**,不用让人猜。
                            if ui
                                .add_enabled(!any_running, egui::Button::new("删除").small())
                                .on_hover_text(format!("删除 libs\\model\\{}", f.name))
                                .on_disabled_hover_text("模型正在运行 —— 先停掉才能删")
                                .clicked()
                            {
                                delete_me = Some(f.name);
                            }
                        });
                    }
                    None => {
                        bullet(
                            ui,
                            false,
                            &format!(
                                "{}  ·  约 {} MB  ·  {}{}",
                                f.name,
                                f.approx_mb,
                                f.role,
                                if f.required { "  ← 必需" } else { "  (可选,图片编辑用)" }
                            ),
                        );
                    }
                }
            }
            // 删除动作**在这里**执行:按钮在循环里只记下"要删谁",
            // 真正动文件放到循环外 —— 免得在遍历 `model.present` 的同时改磁盘。
            if let Some(name) = delete_me {
                self.delete_model_file(paths, log, name);
            }
            ui.add_space(4.0);
            ui.label(
                RichText::new(format!(
                    "已就位 {:.2} GB / 全部约 10.6 GB    {}",
                    model.total_bytes() as f64 / 1_073_741_824.0,
                    if model.ready() {
                        if model.can_edit() { "可出图 + 可编辑图片" } else { "可出图" }
                    } else {
                        "还不完整"
                    }
                ))
                .size(12.0)
                .color(theme::TEXT_SUB),
            );

            ui.add_space(8.0);
            ui.horizontal(|ui| {
                if ui
                    .add_sized([130.0, 28.0], egui::Button::new("⤓ 下载图像模型"))
                    .on_hover_text("从 ModelScope 下载 Qwen-Image 2.1 的四个文件(约 10.6 GB)")
                    .clicked()
                {
                    self.spawn_fetch(paths, log, "-ImageOnly");
                }
                if self.sd_running() {
                    if ui
                        .add_sized([110.0, 28.0], egui::Button::new("■ 停止生成"))
                        .clicked()
                    {
                        Self::stop(&mut self.sd);
                        self.last_action = "sd-cli 已停止".into();
                    }
                }
                else if model.ready()
                    && ui
                        .add_sized([110.0, 28.0], egui::Button::new("▶ 生成一张"))
                        .clicked()
                {
                    self.start_sd(paths, log);
                }
            });

            // 同「编程处理」页:下载入口在这里,状态也得在这里(下载进程只有一个)
            self.fetch_row(ui);

            ui.add_space(6.0);
            ui.label(RichText::new("提示词").size(12.0).color(theme::TEXT_SUB));
            ui.add(
                egui::TextEdit::multiline(&mut self.prompt)
                    .desired_rows(2)
                    .desired_width(f32::INFINITY),
            );

            if self.sd_running() {
                log_panel(ui, &self.sd_log, "sd-cli 输出");
            }
        });

        if !self.last_action.is_empty() {
            ui.add_space(8.0);
            ui.label(
                RichText::new(&self.last_action)
                    .size(12.0)
                    .color(theme::TEXT_SUB),
            );
        }
        if !model.ready() {
            ui.add_space(4.0);
            ui.label(
                RichText::new(libs::model_hint(paths))
                    .size(11.0)
                    .color(theme::TEXT_SUB),
            );
        }
    }

    /// 下载那一行:状态、停止按钮、下载输出面板。
    ///
    /// **两个页面都要调。** 下载入口在「编程处理」页(缺失组件 / 编程模型)和
    /// 「图像处理」页(图像模型)上都有,而进程只有一个 —— 只在一页上显示的话,
    /// 在另一页点了下载就再也看不到它的状态了。
    ///
    /// (这个面板也是 `libs\nfarms-fetch-*.log` 的**唯一读者**:日志查看器只列
    /// `logs\` 一层,`libs\` 下的文件它看不见。)
    fn fetch_row(&mut self, ui: &mut egui::Ui) {
        self.poll_fetch();
        let Some(log_path) = self.fetch_log.clone() else {
            return; // 这台机器上还没跑过下载:不占地方
        };

        ui.add_space(6.0);
        ui.horizontal(|ui| {
            if self.fetch.is_some() {
                ui.label(
                    RichText::new(format!(
                        "⤓ 正在下载 {} · {}",
                        self.fetch_flag.as_deref().unwrap_or(""),
                        log_path.display()
                    ))
                    .size(11.5)
                    .color(theme::TEXT_SUB),
                );
                if ui
                    .add_sized([110.0, 24.0], egui::Button::new("■ 停止下载"))
                    .on_hover_text("停掉 powershell.exe;下次再点会由脚本自己接着下")
                    .clicked()
                {
                    self.stop_fetch();
                }
            } else if let Some(text) = fetch_status_text(self.fetch_end, &log_path) {
                ui.label(RichText::new(text).size(11.5).color(theme::TEXT_SUB));
            }
        });

        if self.fetch.is_some() {
            // 下载是**后台进程**,界面空闲时 egui 不会自己重绘:不主动叫一声,
            // 「正在下载」会一直挂着 —— 它早就结束了也不会变成"已结束"。
            // 500ms 一次足够,而且每帧只做一次 `try_wait`,不阻塞界面线程。
            ui.ctx()
                .request_repaint_after(std::time::Duration::from_millis(500));
            log_panel(ui, &log_path, "下载输出");
        }
    }
}

/// 下载结束后那一行状态字。
///
/// 抽成纯函数是为了**能测**:"下完了""退出码非 0""被用户停了"必须是三句不同的话 ——
/// 混成一句的话,用户看到"下载结束"会以为文件齐了。
fn fetch_status_text(end: Option<FetchEnd>, log: &Path) -> Option<String> {
    match end {
        Some(FetchEnd::Exited(0)) => Some(format!("✓ 下载进程已结束 · {}", log.display())),
        Some(FetchEnd::Exited(code)) => Some(format!(
            "⚠ 下载退出码 {code}(不是 0,去看日志)· {}",
            log.display()
        )),
        Some(FetchEnd::Stopped) => Some(format!("■ 下载已停止 · {}", log.display())),
        None => None,
    }
}

// ---------------------------------------------------------------- 小部件

/// 把端口输入框里的文本转成端口号;**解析不出来就回落到统一默认值**(9094)。
///
/// 抽成函数是为了能测:输入框里可能是空串、带空格、或者用户乱敲的东西。
/// 就地写 `unwrap_or(9094)` 会让默认值多出一个出处 —— store.rs 改了、这里忘了,
/// 表现是"没填端口时起在 8080 上",而界面上什么都看不出来。
fn llama_port_or_default(text: &str) -> u16 {
    text.trim().parse().unwrap_or(DEFAULT_LLAMA_PORT)
}

/// 子进程是否还活着。顺手回收已经退出的 —— 否则界面会一直显示「运行中」。
fn running(child: &mut Option<Child>) -> bool {
    match child {
        Some(c) => match c.try_wait() {
            Ok(None) => true, // 还在跑
            _ => {
                *child = None;
                false
            }
        },
        None => false,
    }
}

// 这里原本有一份**抄来的** `card` 和 `section`。它们的问题不只是重复:
// 那份 `card` 直接用 `theme::card_frame()`,没有「撑满容器宽度」那一步 ——
// 于是 Model 组这几页的卡片跟别处宽窄不一。全部改用共享实现。



fn status_row(ui: &mut egui::Ui, label: &str, ok: bool, detail: &str) {
    ui.horizontal(|ui| {
        let (dot, _) = ui.allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover());
        ui.painter().circle_filled(
            dot.center(),
            4.0,
            if ok { theme::ACCENT } else { theme::DANGER },
        );
        ui.label(RichText::new(label).size(12.0).color(theme::TEXT_SUB));
        ui.label(RichText::new(detail).size(12.0).color(theme::TEXT_MAIN));
    });
}

fn bullet(ui: &mut egui::Ui, ok: bool, text: &str) {
    ui.horizontal(|ui| {
        ui.label(
            RichText::new(if ok { "✓" } else { "✗" })
                .size(12.0)
                .color(if ok { theme::ACCENT } else { theme::DANGER }),
        );
        ui.label(RichText::new(text).size(12.0).color(theme::TEXT_MAIN));
    });
}

/// 子进程输出面板 —— 读日志文件尾部,而不是接管道。
fn log_panel(ui: &mut egui::Ui, path: &Path, title: &str) {
    ui.add_space(6.0);
    ui.label(RichText::new(title).size(11.0).color(theme::TEXT_SUB));
    let text = tail(path, LOG_TAIL_LINES);
    egui::Frame::NONE
        .fill(Color32::from_rgba_unmultiplied(20, 24, 22, 230))
        .corner_radius(8.0)
        .inner_margin(8)
        .show(ui, |ui| {
            ui.add(
                egui::Label::new(
                    RichText::new(if text.is_empty() { "(暂无输出)" } else { &text })
                        .font(FontId::monospace(11.0))
                        .color(Color32::from_rgb(190, 226, 205)),
                )
                .wrap(),
            );
        });
}

fn tail(path: &Path, lines: usize) -> String {
    let Ok(text) = std::fs::read(path) else {
        return String::new();
    };
    let text = String::from_utf8_lossy(&text);
    let all: Vec<&str> = text.lines().collect();
    let from = all.len().saturating_sub(lines);
    all[from..].join("\n")
}

/// 起一个子进程,输出重定向到日志文件,且**不弹控制台窗口**。
fn spawn_logged(exe: &Path, args: &[String], cwd: &Path, log: &Path) -> std::io::Result<Child> {
    if let Some(dir) = log.parent() {
        let _ = std::fs::create_dir_all(dir);
    }
    let out = std::fs::File::create(log)?;
    let err = out.try_clone()?;

    let mut cmd = std::process::Command::new(exe);
    cmd.args(args)
        .current_dir(cwd)
        .stdin(std::process::Stdio::null())
        .stdout(out)
        .stderr(err);

    #[cfg(windows)]
    {
        use std::os::windows::process::CommandExt;
        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
        cmd.creation_flags(CREATE_NO_WINDOW);
    }

    cmd.spawn()
}

fn open_url(url: &str) {
    #[cfg(windows)]
    {
        use std::os::windows::process::CommandExt;
        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
        let _ = std::process::Command::new("cmd")
            .args(["/c", "start", "", url])
            .creation_flags(CREATE_NO_WINDOW)
            .spawn();
    }
    #[cfg(not(windows))]
    {
        let _ = url;
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use nfarms_core::store::Store;

    /// 干净的一次性临时根目录。
    ///
    /// **前置清理**:上一次跑剩的文件(尤其是 `fetch-libs.ps1` 或下载出来的
    /// `libs\`)会让"没跑过下载"这类断言变得不可信。
    fn fresh_root(tag: &str) -> std::path::PathBuf {
        let dir = std::env::temp_dir().join(format!("nfarms-local-model-{tag}"));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        dir
    }

    /// 起一个马上结束、退出码可控的假进程(不碰网络,也不依赖下载脚本)。
    fn quick_child(code: i32) -> Child {
        #[cfg(windows)]
        let (exe, args) = ("cmd", vec!["/c".to_string(), format!("exit {code}")]);
        #[cfg(not(windows))]
        let (exe, args) = ("sh", vec!["-c".to_string(), format!("exit {code}")]);
        let mut cmd = std::process::Command::new(exe);
        cmd.args(&args)
            .stdin(std::process::Stdio::null())
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null());
        cmd.spawn().unwrap()
    }

    /// 缺陷本体:`spawn_fetch` 原来 `Ok(_) =>` 把 `Child` 丢掉,于是界面既说不出
    /// "正在下载",也没有东西可停。这条测试就是钉住"把手要留下 + 能停"。
    #[cfg(windows)] // spawn_fetch 起的是 powershell.exe
    #[test]
    fn spawn_fetch_keeps_the_child_and_refuses_a_second_download() {
        let dir = fresh_root("fetch");
        let paths = Paths::from_exe_dir(&dir);
        std::fs::create_dir_all(paths.scripts()).unwrap();
        // 假的下载脚本:睡一觉就完事 —— 不联网、不装东西、不改仓库
        std::fs::write(
            paths.fetch_libs_script(),
            "param()\r\nStart-Sleep -Seconds 30\r\n",
        )
        .unwrap();
        let log = Log::at(dir.join("logs"));

        let mut lm = LocalModel::new(&paths);
        lm.spawn_fetch(&paths, &log, "-BinariesOnly");
        assert!(
            lm.fetch.is_some(),
            "下载进程的把手被丢了:{}",
            lm.last_action
        );
        assert_eq!(lm.fetch_flag.as_deref(), Some("-BinariesOnly"));
        let logged = lm.fetch_log.clone().expect("要记住这一轮下载的日志路径");
        assert!(logged.starts_with(paths.libs()), "{}", logged.display());
        assert_eq!(lm.fetch_end, None, "刚起来时还没有结论");

        // 已经在下载:第二个入口不能被放出去(三个脚本共用 `libs\` 那一份地盘)
        lm.spawn_fetch(&paths, &log, "-ImageOnly");
        assert!(
            lm.last_action.contains("已有下载"),
            "第二次点击应当被拦下:{}",
            lm.last_action
        );
        assert_eq!(
            lm.fetch_flag.as_deref(),
            Some("-BinariesOnly"),
            "被拦下的那次不能顶掉正在跑的那次"
        );

        // 「停止下载」:把手清掉,并且状态记成"用户停的"(不是退出码)
        lm.stop_fetch();
        assert!(lm.fetch.is_none());
        assert_eq!(lm.fetch_end, Some(FetchEnd::Stopped));

        lm.stop_fetch(); // 没在跑时再点一次不能 panic / 不能改状态
        assert_eq!(lm.fetch_end, Some(FetchEnd::Stopped));

        let _ = std::fs::remove_dir_all(&dir);
    }

    /// 进程结束必须被**收割成退出码** —— 否则界面分不出"下完了"和"脚本失败了"。
    #[test]
    fn poll_fetch_records_the_exit_code() {
        // 结构体字面量而不是"先 default 再赋值":clippy 的
        // `field_reassign_with_default` 说的就是这个 —— 后者在字段一多时
        // 容易漏,而漏掉的那个字段会静默用默认值。
        let mut lm = LocalModel {
            fetch: Some(quick_child(7)),
            ..LocalModel::default()
        };
        // 等它结束(最多 5 秒)。轮询本身就是 `fetch_row` 每帧做的那一步,
        // 所以这里顺带验证了"还在跑时不能把把手清掉"。
        for _ in 0..100 {
            lm.poll_fetch();
            if lm.fetch.is_none() {
                break;
            }
            std::thread::sleep(std::time::Duration::from_millis(50));
        }
        assert!(lm.fetch.is_none(), "cmd /c exit 7 早就该结束了");
        assert_eq!(lm.fetch_end, Some(FetchEnd::Exited(7)));
        // 再轮询一次不能把结论改掉(已经没有把手可查)
        lm.poll_fetch();
        assert_eq!(lm.fetch_end, Some(FetchEnd::Exited(7)));
    }

    /// 三种结局必须是三句不同的话 —— 都写成"下载结束"的话,用户会以为文件齐了。
    #[test]
    fn fetch_status_text_separates_done_from_failed_and_stopped() {
        let log = Path::new("C:\\nfarms\\libs\\nfarms-fetch-imageonly.log");
        assert!(fetch_status_text(None, log).is_none(), "没跑过就不该占一行");
        let ok = fetch_status_text(Some(FetchEnd::Exited(0)), log).unwrap();
        assert!(ok.contains("已结束"), "{ok}");
        let bad = fetch_status_text(Some(FetchEnd::Exited(3)), log).unwrap();
        assert!(bad.contains("退出码 3"), "{bad}");
        assert_ne!(bad, ok);
        let stopped = fetch_status_text(Some(FetchEnd::Stopped), log).unwrap();
        assert!(stopped.contains("已停止"), "{stopped}");
        assert_ne!(stopped, ok);
    }

    /// **真正生效的默认值在本文件里** —— store.rs 只提供存取口子,界面初始化走的是
    /// `LocalModel::new`。这里要是还留着 8080 / 1234,读的一侧再对也没用。
    ///
    /// 断言同时钉住字面量和 store.rs 的常量:前者是迁移的目标值,后者保证
    /// "默认值只有一个出处"这件事没有被改回写死的数字。
    #[test]
    fn default_ports_are_the_migrated_ones() {
        let dir = fresh_root("default-ports");
        let paths = Paths::from_exe_dir(&dir);
        let lm = LocalModel::new(&paths);

        assert_eq!(lm.llama_port, "9094", "llama.cpp 的默认端口(旧值 8080 已废弃)");
        assert_eq!(lm.sd_port, "9095", "sd.cpp 的默认端口(旧值 1234 已废弃)");
        assert_eq!(lm.llama_port, DEFAULT_LLAMA_PORT.to_string());
        assert_eq!(lm.sd_port, DEFAULT_SD_PORT.to_string());

        let _ = std::fs::remove_dir_all(&dir);
    }

    /// 老用户盘上写着 8080 / 1234:读回来必须**原样保留**。
    ///
    /// 迁移只改「没设过」时的初值,不替用户改配置 —— 他的 DSH provider baseURL
    /// 还指着 8080,这里顺手"升级"成 9094,症状就是连不上的 connection refused。
    #[test]
    fn load_ports_keeps_stored_values_verbatim() {
        let dir = fresh_root("ports-stored");
        let paths = Paths::from_exe_dir(&dir);
        let store_path = Store::default_path(&paths);
        std::fs::write(&store_path, r#"{"llama_port":"8080","sd_port":"1234"}"#).unwrap();
        let store = Store::load(&store_path);

        let mut lm = LocalModel::new(&paths);
        lm.load_ports(&store);
        assert_eq!(lm.llama_port, "8080", "已存的 8080 不能被新默认值 9094 顶掉");
        assert_eq!(lm.sd_port, "1234", "已存的 1234 同理,不能被 9095 顶掉");

        // 没存过这两个键时:保持 `new` 里的默认值,不会被读操作清成空串
        let empty = Store::load(&dir.join("store-empty.json"));
        let mut fresh = LocalModel::new(&paths);
        fresh.load_ports(&empty);
        assert_eq!(fresh.llama_port, "9094", "键缺失时才回落到默认");
        assert_eq!(fresh.sd_port, "9095");

        // 存回去的键名/写法要和读的一侧对得上(否则下次启动又回到默认)
        let mut store = Store::load(&store_path);
        lm.save_ports(&mut store).unwrap();
        let reloaded = Store::load(&store_path);
        assert_eq!(reloaded.get_str(KEY_LLAMA_PORT).as_deref(), Some("8080"));
        assert_eq!(reloaded.get_str(KEY_SD_PORT).as_deref(), Some("1234"));

        let _ = std::fs::remove_dir_all(&dir);
    }

    /// 端口文本是空串 / 垃圾时,兜底走的必须是**同一个**默认端口,不能是 0 或 panic。
    #[test]
    fn a_broken_port_text_falls_back_to_the_default() {
        assert_eq!(llama_port_or_default("9094"), 9094);
        assert_eq!(llama_port_or_default(" 8080 "), 8080, "带空格也得认(输入框常见)");
        assert_eq!(llama_port_or_default(""), DEFAULT_LLAMA_PORT, "空串不是端口");
        assert_eq!(llama_port_or_default("9095x"), DEFAULT_LLAMA_PORT, "带后缀不是端口");
        assert_eq!(llama_port_or_default("70000"), DEFAULT_LLAMA_PORT, "超出 u16");
    }
}