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");
}
}