crates\nfarms-core\src\libs.rs¶
源码路径:
crates\nfarms-core\src\libs.rs· 1218 行
//! `libs\` 下的第三方组件与本地大模型。
//!
//! 目录约定(和 webview2 / ffmpeg / bililive-go 一样,放进去即启用):
//!
//! ```text
//! libs\llama.cpp\ llama-server.exe / llama-cli.exe(+ SYCL / CPU 后端 DLL)
//! libs\stable-diffusion.cpp\ sd-cli.exe / sd-server.exe(+ Vulkan / CPU 后端 DLL)
//! libs\model\ Qwen-Image 2.1 的权重
//! ```
//!
//! **这台机器是 Intel Arc,不是 NVIDIA**,所以选的是 SYCL(llama.cpp)与
//! Vulkan(sd.cpp)构建,而不是 CUDA/ROCm 那几个几百 MB 的包。
//!
//! Qwen-Image 2.1 需要**三个**文件才能跑,少一个都不行 —— 依据 sd.cpp 官方文档
//! `docs/qwen_image_2.1.md`:
//!
//! 1. **diffusion model** —— 去噪主干(GGUF)
//! 2. **text encoder** —— **Qwen3-VL-8B**。这里最容易搞错:
//! 初代 Qwen-Image 用的是 Qwen2.5-VL,2.1 换成了 Qwen3-VL
//! 3. **VAE** —— 必须是 2.1 专用的。官方明确写了「旧的 Qwen Image 与 Wan 2.2
//! 的 VAE 权重**不可互换**」
//!
//! 另外图片编辑还要 mmproj(视觉投影层),所以它是第 4 个文件。
use std::path::{Path, PathBuf};
use crate::paths::Paths;
/// 每个组件目录里记一行「装的是哪个构建」的标记文件,由 fetch-libs.ps1 写入。
pub const VERSION_MARKER: &str = ".nfarms-version";
pub struct Component {
pub key: &'static str,
/// 界面上的名字
pub name: &'static str,
/// libs\ 下的子目录名
pub dir: &'static str,
/// 必须存在的可执行文件(全都在才算装好)
pub required_exes: &'static [&'static str],
pub note: &'static str,
}
pub const LLAMA: Component = Component {
key: "llama.cpp",
name: "llama.cpp",
dir: "llama.cpp",
// llama-server 是给界面用的 HTTP 服务;llama-cli 用于一次性推理与自检
required_exes: &["llama-server.exe", "llama-cli.exe"],
note: "本地大模型推理(llama-server 提供 OpenAI 兼容 HTTP 接口)",
};
pub const SD: Component = Component {
key: "stable-diffusion.cpp",
name: "stable-diffusion.cpp",
dir: "stable-diffusion.cpp",
required_exes: &["sd-cli.exe", "sd-server.exe"],
note: "本地图像生成(sd-server 提供 HTTP 接口)",
};
pub const COMPONENTS: [&Component; 2] = [&LLAMA, &SD];
// ---------------------------------------------------------------- 组件状态
pub struct ComponentStatus {
pub dir: PathBuf,
/// 已找到的可执行文件:(名字, 绝对路径)
pub found: Vec<(&'static str, PathBuf)>,
/// 缺失的可执行文件
pub missing: Vec<&'static str>,
/// 目录内所有文件总字节数
pub total_bytes: u64,
/// 装的是哪个构建(读 `.nfarms-version`,读不到就是 None)
pub build: Option<String>,
}
impl ComponentStatus {
pub fn installed(&self) -> bool {
self.missing.is_empty()
}
/// 主可执行文件(界面启动用的那个)
pub fn primary_exe(&self) -> Option<&Path> {
self.found.first().map(|(_, p)| p.as_path())
}
}
pub fn component_status(paths: &Paths, comp: &Component) -> ComponentStatus {
let dir = paths.lib(comp.dir);
let mut found = Vec::new();
let mut missing = Vec::new();
for exe in comp.required_exes {
let p = dir.join(exe);
if p.is_file() {
found.push((*exe, p));
} else {
missing.push(*exe);
}
}
let build = std::fs::read_to_string(dir.join(VERSION_MARKER))
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
ComponentStatus {
total_bytes: dir_size(&dir),
dir,
found,
missing,
build,
}
}
// ---------------------------------------------------------------- 本地模型
/// 本地大模型的用途分类 —— 也就是「一个图像处理,一个编程处理」。
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ModelKind {
/// 图像处理:Qwen-Image 2.1,由 stable-diffusion.cpp 跑
Image,
/// 编程处理:Qwen3-Coder-30B-A3B,由 llama.cpp 跑
Code,
/// 通用对话(同时也是图像管线的文本编码器)
Chat,
}
impl ModelKind {
pub fn label(self) -> &'static str {
match self {
ModelKind::Image => "图像处理",
ModelKind::Code => "编程处理",
ModelKind::Chat => "通用对话",
}
}
}
/// 权重往哪儿放。
///
/// 这不是拍脑袋定的,是 `llama-bench` 实测出来的 —— 同一台机器、同一个模型:
///
/// ```text
/// A 纯 CPU (-ngl 0) pp256 = 22.79 t/s tg64 = 7.69 t/s
/// B 专家层留 CPU (-ngl 99 -ot exps=CPU) pp256 = 20.57 t/s tg64 = 18.29 t/s
/// ```
///
/// MoE 用 B:**生成快 2.4 倍**,prompt 只慢 10%。原因是 17.28 GiB 的专家权重
/// 本来就在内存里(mmap),搬到 GPU 只会来回倒腾;真正吃算力的注意力层交给
/// Intel Arc 才划算。
///
/// 反例:整层全卸载(`-ngl 99` 不带 `-ot`)在这台机器上**直接崩**
/// (0xC0000409,内存耗尽)—— 本机是 UMA,权重既要在内存又要进显存。
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Offload {
/// 全部层交给 GPU(稠密小模型,如 8B)
Gpu,
/// MoE:专家权重留 CPU,注意力层上 GPU
CpuMoe,
/// 纯 CPU。
///
/// 生产路径现在选不到它 —— `SERVABLE` 里两个模型的 offload 分别是
/// `CpuMoe` / `Gpu`。**但它不是死代码**:显存/内存不够时用户要靠它手动降级
/// (`-ngl 0`,见 `llama_server_args_with_ctx` 的同名分支),界面也已经在
/// 用它显示「纯 CPU」文案。别看到"没人构造"就把它删掉。
Cpu,
}
/// 一个可以单独拉起的大模型。
///
/// 注意「图像管线」和「图像模型」是两件事:Qwen-Image 2.1 的扩散主干**不能**被
/// llama-server 加载,它只由 sd.cpp 用;而它的文本编码器 Qwen3-VL-8B 本身就是
/// 一个完整的 LLM,可以单独跑。所以这里分两个注册表。
pub struct LocalModel {
pub key: &'static str,
/// 界面显示名
pub name: &'static str,
/// libs\model\ 下的主权重文件名
pub file: &'static str,
pub kind: ModelKind,
/// 大致体积(MB),没下载时给个体量预期
pub approx_mb: u32,
/// 说明(界面与自检都用它)
pub role: &'static str,
/// 下载来源(ModelScope 仓库路径),自检里打出来便于核对
pub source: &'static str,
/// 权重放哪(见 [`Offload`] 里的实测数据)
pub offload: Offload,
}
/// 编程处理:MoE,30B 总参 / 3B 激活。官方没出 GGUF,用的是 unsloth 的转换。
///
/// 本机只有 31.5 GB 内存,这个 17.3 GB 的权重装得下但很紧;
/// 真拉不起来就换更小的量化(UD-Q4_K_XL 16.8GB / IQ4_XS 15.6GB)。
pub const CODING_MODEL: LocalModel = LocalModel {
key: "qwen3-coder-30b",
name: "Qwen3-Coder-30B-A3B",
file: "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf",
kind: ModelKind::Code,
approx_mb: 17697,
role: "代码生成与补全(MoE:30B 总参 / 3B 激活)",
source: "unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF",
offload: Offload::CpuMoe,
};
/// 通用对话,同时是 Qwen-Image 2.1 管线的文本编码器。
pub const CHAT_MODEL: LocalModel = LocalModel {
key: "qwen3-vl-8b",
name: "Qwen3-VL-8B-Instruct",
file: "Qwen3VL-8B-Instruct-Q4_K_M.gguf",
kind: ModelKind::Chat,
approx_mb: 4795,
role: "通用对话;也是 Qwen-Image 2.1 的文本编码器",
source: "Qwen/Qwen3-VL-8B-Instruct-GGUF",
offload: Offload::Gpu,
};
/// 能被 `llama-server` 直接拉起来的模型(界面上的选择列表)。
pub const SERVABLE: [&LocalModel; 2] = [&CODING_MODEL, &CHAT_MODEL];
pub struct ModelFile {
/// 文件名(libs\model\ 下)
pub name: &'static str,
/// 它在 sd.cpp 命令行里的参数名(`--diffusion-model` / `--vae` / `--llm` / `--llm_vision`)
///
/// 把参数名和文件名放在同一条记录里,是为了让 `weight_args()` 能直接遍历
/// 注册表 —— 见那个函数里关于"文件名不要抄两遍"的说明。
pub flag: &'static str,
/// 用途
pub role: &'static str,
/// 大致体积(MB),只用于「还没下过」时给个体量预期
pub approx_mb: u32,
/// 缺了就跑不起来
pub required: bool,
}
/// Qwen-Image 2.1 跑起来需要的全部权重。
///
/// 顺序就是 sd.cpp 官方文档里 `<1>`~`<4>` 的编号顺序,便于和文档对照:
/// `sd-cli.exe --diffusion-model <1> --llm <2> --vae <3> [--llm_vision <4>]`
///
/// **`flag` 是这些文件名在命令行里的唯一归宿**:`sd_cli_args` / `sd_server_args`
/// 都从这里取名,别的地方再抄一遍文件名就会在改量化版本时漏改一处,
/// 而 sd.cpp 对"文件打不开"只会说打不开,看不出是少改了一行。
pub const QWEN_IMAGE_2_1: [ModelFile; 4] = [
ModelFile {
name: "qwen_image_2.1-Q4_K.gguf",
flag: "--diffusion-model",
role: "扩散主干(diffusion model)",
approx_mb: 4003,
required: true,
},
ModelFile {
name: "Qwen3VL-8B-Instruct-Q4_K_M.gguf",
flag: "--llm",
role: "文本编码器(注意是 Qwen3-VL,不是 Qwen2.5-VL)",
approx_mb: 4795,
required: true,
},
ModelFile {
name: "qwen_image_2.1_vae_bf16.safetensors",
flag: "--vae",
role: "VAE(必须用 2.1 专用,旧版不可互换)",
approx_mb: 644,
required: true,
},
ModelFile {
name: "mmproj-Qwen3VL-8B-Instruct-F16.gguf",
flag: "--llm_vision",
role: "视觉投影层(图片编辑才需要)",
approx_mb: 1105,
required: false,
},
];
pub struct ModelStatus {
pub dir: PathBuf,
/// 已就位:(文件, 实际字节数)
pub present: Vec<(&'static ModelFile, u64)>,
pub missing: Vec<&'static ModelFile>,
}
impl ModelStatus {
/// 三个必需文件齐了才算「能出图」
pub fn ready(&self) -> bool {
self.missing.iter().all(|m| !m.required)
}
/// 能编辑图片(需要 mmproj)
pub fn can_edit(&self) -> bool {
self.ready()
&& QWEN_IMAGE_2_1
.iter()
.filter(|m| !m.required)
.all(|m| self.present.iter().any(|(p, _)| p.name == m.name))
}
pub fn total_bytes(&self) -> u64 {
self.present.iter().map(|(_, n)| *n).sum()
}
}
pub fn model_status(paths: &Paths) -> ModelStatus {
let dir = paths.lib("model");
let mut present = Vec::new();
let mut missing = Vec::new();
for f in QWEN_IMAGE_2_1.iter() {
match std::fs::metadata(dir.join(f.name)) {
Ok(m) if m.is_file() => present.push((f, m.len())),
_ => missing.push(f),
}
}
ModelStatus {
dir,
present,
missing,
}
}
/// 判定「这份权重下完了」的体积下限(占 [`LocalModel::approx_mb`] 的比例)。
///
/// **为什么不能只看「文件在不在」**:`dist/libs/scripts/fetch-libs.ps1` 是
/// **直接往最终路径写**的 —— `curl -sS -L --fail --retry 2 -C - -o $Dest`,
/// 断点续传续的就是 `libs\model\` 里那个本体,完整性校验(比对远端
/// content-length)是在**写完之后**才做的。于是断流、磁盘写满、中途关机会留下
/// 一个 `is_file()` 为真、内容却只有一部分的权重。旧实现只看 `is_file()`,
/// 界面给它打 ✓、允许拉起,而 llama-server 加载截断的 GGUF 报的是
/// 「张量读不出来」这类错 —— 看上去像构建坏了,不像没下完,
/// 排查方向从一开始就是歪的。
///
/// **为什么是 90% 而不是精确比对**:脚本自己手里有远端 `content-length`,
/// 能精确判;`presence()` 是离线的,只有注册表里那个粗略的 `approx_mb`。
/// 留 10% 余量是为了注册表估算偏大时**不把一个完整的权重误判成没下完** ——
/// 那个方向的错更糟(用户明明下好了却用不了,还不知道为什么)。
/// 代价是只拦得住「缺了一块」的,拦不住最后 1% 断流:那属于脚本的活。
const PRESENT_MIN_RATIO: f64 = 0.9;
/// 这份权重「下完了」至少要有的字节数。
fn min_complete_bytes(model: &LocalModel) -> u64 {
// approx_mb 按 MiB 记(与 describe() 里 MB→GB 的换算同一口径)
(model.approx_mb as f64 * 1_048_576.0 * PRESENT_MIN_RATIO) as u64
}
/// 某个大模型在磁盘上的存在情况。
pub struct ModelPresence {
pub path: PathBuf,
/// `None` = 文件不在
pub bytes: Option<u64>,
pub model: &'static LocalModel,
}
impl ModelPresence {
/// 文件在**而且**体积不像半截。
///
/// 界面上「能不能拉起这个模型」都走它,所以它必须对半截文件说「不」——
/// 只判存在的话,一个 3 MB 的残文件会被当成 17.3 GB 的权重点亮 ✓。
pub fn present(&self) -> bool {
self.bytes
.is_some_and(|b| b >= min_complete_bytes(self.model))
}
/// 文件在,但体积明显小于注册表 —— 也就是「疑似没下完」。
///
/// 单独给界面与自检一个入口,是因为这种状态**不能只说「没下载」**:
/// 文件确实躺在那里,说没下载会让人以为没下过,于是反复重下同一份。
pub fn partial(&self) -> bool {
self.bytes
.is_some_and(|b| b < min_complete_bytes(self.model))
}
/// 界面显示用:`17.3 GB` / `疑似未下完(1.0 GB / 约 17.3 GB)` / `未下载(约 17.3 GB)`
pub fn describe(&self) -> String {
match self.bytes {
Some(b) if self.present() => format!("{:.1} GB", b as f64 / 1_073_741_824.0),
// 半截的把「实际有多少 / 大约该多少」都写出来:只写「未下载」看不出
// 是断了还是下错了文件,而这两种的处理办法不一样。
Some(b) => format!(
"疑似未下完({:.1} GB / 约 {:.1} GB)",
b as f64 / 1_073_741_824.0,
self.model.approx_mb as f64 / 1024.0
),
None => format!("未下载(约 {:.1} GB)", self.model.approx_mb as f64 / 1024.0),
}
}
}
pub fn presence(paths: &Paths, model: &'static LocalModel) -> ModelPresence {
let path = paths.lib("model").join(model.file);
// 半截文件仍然把实际字节数带上(而不是当成 None 丢掉):用户得看到
// 「实际 1.0 GB / 应该 17.3 GB」才判断得出是断了还是下错了文件。
let bytes = std::fs::metadata(&path).ok().filter(|m| m.is_file()).map(|m| m.len());
ModelPresence {
path,
bytes,
model,
}
}
/// 界面上的模型选择列表(带存在情况)。
pub fn servable_status(paths: &Paths) -> Vec<ModelPresence> {
SERVABLE.iter().map(|m| presence(paths, m)).collect()
}
// ---------------------------------------------------------------- 启动参数
/// llama-server 向 API **报出的模型名**(`/v1/models` 里的 id)。
///
/// **这是 DSH 侧 `models[].id` 必须对上的那个值。** 用 `--alias` 固定下来,
/// 而不是让它从 GGUF 文件名推导 —— 否则换一个模型,DSH 的 provider 配置
/// 就跟着失效,而症状是「模型列表里找不到」,从这句话看不出是名字对不上。
pub const LLAMA_API_ALIAS: &str = "nfarms-local";
/// llama.cpp 上 GPU 后端要用的设备名。
///
/// 本机是 Intel Arc,走 SYCL(见 crate 头:不是 NVIDIA,所以没有 CUDA)。
/// `Offload::Gpu` 与 `Offload::CpuMoe` 两条分支都要它,所以只在这里写一次:
/// 换一台 CUDA 机器时改这一个字符串就行,而不是在两处 `"SYCL0"` 里挑一处漏改 ——
/// 漏改的那条分支会以 `--device` 报"设备不存在"退出,看着像后端没装。
const LLAMA_DEVICE: &str = "SYCL0";
/// llama-server 的启动参数(使用默认上下文长度)。
pub fn llama_server_args(model: &Path, port: u16, offload: Offload) -> Vec<String> {
llama_server_args_with_ctx(model, port, offload, DEFAULT_LLAMA_CTX)
}
/// `--ctx-size` 的默认值。
///
/// **这个值必须 ≤ DSH 侧 `contextWindow`,而且两边差距不能有** —— 实测过:
/// DSH 声明 32768、服务端只有 8192 时,请求到 9961 token 才被 llama.cpp
/// 以 400 拒绝(`exceeds the available context size`),DSH 那边一直以为
/// 还有 32K 空间,超了也不自知,连压缩都触发不了。
///
/// 16384 的取法:DSH 的系统提示词 + 工具表约 9K,留 7K 左右给真实对话。
/// **别盲目调大**:这台机器只有 31.5 GB 内存,编程模型权重就占 17.3 GB,
/// KV cache 大约每 8K 上下文 ≈ 0.4 GB(Qwen3-30B-A3B 是 GQA,4 个 KV 头)。
///
/// 模型训练上下文是 262144,所以上限由内存决定,不由模型决定。
pub const DEFAULT_LLAMA_CTX: u32 = 16384;
/// `--ctx-size` 的允许范围。
///
/// 下限 512:再小连系统提示词都放不下,起来的服务是个只会报错的摆设。
/// 上限 262144:`/v1/models` 实测报出的 `n_ctx_train`,超过它没有意义
/// (而且必然先把内存吃光)。
pub const MIN_LLAMA_CTX: u32 = 512;
pub const MAX_LLAMA_CTX: u32 = 262_144;
/// 校验界面/store.json 来的上下文长度。
///
/// 与端口同样的理由:`store.json` 是**人能手工改**的,一个非法值会一路传到
/// llama-server 才炸,而那时的报错看不出是哪来的。`0` 也要拦住 ——
/// llama.cpp 会把 `-c 0` 当成「用模型默认」,那就又回到「DSH 不知道真实容量」
/// 的老问题上了。
pub fn valid_llama_ctx(ctx: u32) -> bool {
(MIN_LLAMA_CTX..=MAX_LLAMA_CTX).contains(&ctx)
}
/// 带上可调上下文长度(界面/命令行可以按内存情况收窄)。
pub fn llama_server_args_with_ctx(
model: &Path,
port: u16,
offload: Offload,
ctx: u32,
) -> Vec<String> {
let mut args = vec![
"-m".to_string(),
model.display().to_string(),
"--host".to_string(),
"127.0.0.1".to_string(),
"--port".to_string(),
port.to_string(),
// 固定别名 → `/v1/models` 报出的 id 是确定的(见 LLAMA_API_ALIAS 的说明)
"--alias".to_string(),
LLAMA_API_ALIAS.to_string(),
"-c".to_string(),
ctx.to_string(),
// **指标接口默认是关的**(不带这个参数 `/metrics` 回 501)。
// Tokens 页要读它,所以启动时就带上 —— 让用户自己去记这个参数
// 等于把这个功能做成"要读文档才能用"。
"--metrics".to_string(),
];
match offload {
Offload::Gpu => args.extend(["-ngl", "99", "--device", LLAMA_DEVICE].map(String::from)),
Offload::CpuMoe => {
args.extend(["-ngl", "99", "--device", LLAMA_DEVICE].map(String::from));
// 关键:MoE 的专家权重留在 CPU。少了这一条,17 GiB 权重会被要求
// 同时存在于内存和显存 → 本机 UMA 直接 0xC0000409 崩掉。
args.extend(["-ot".to_string(), "exps=CPU".to_string()]);
}
Offload::Cpu => args.extend(["-ngl", "0"].map(String::from)),
}
args
}
/// sd.cpp 的采样参数。
///
/// **有意写死,不进设置**:这两个值是 Qwen-Image 2.1 官方示例给的,换一个模型
/// 就得重新定,而这里只服务这一个模型 —— 放进设置页等于多一个能被调坏的开关,
/// 而调坏的后果(cfg 过高的图糊成一团)看上去像"模型不行"。
/// 真要开放,应该配 store.json 字段 + 界面上的高级折叠区,而不是散在参数里。
const SD_CFG_SCALE: &str = "6.0";
const SD_SAMPLING_METHOD: &str = "euler";
/// 把注册表里的权重拼成 sd.cpp 的 `--flag <路径>` 序列。
///
/// **文件名只在这里出现一次**:`sd_cli_args` 与 `sd_server_args` 原来各自抄了
/// 一遍那三个文件名,改量化版本时漏掉一处,命令行就会指向一个不存在的文件 ——
/// 而 sd.cpp 的报错只说"打不开",看不出是少改了一行。
///
/// `with_vision` 控制第 4 件(mmproj):
/// - sd.cpp 在**启动时**就把视觉投影层读进显存(1.1 GB;本机是 UMA,显存和内存
/// 是同一块),所以纯文生图带上它纯属白占 —— 两个入口各自显式表态,
/// 不在这里替它们猜。
/// - 文件不在时无论传什么都跳过:这些路径是 `fetch-libs.ps1` 按需下载的,
/// mmproj 经常没下,喂一个不存在的路径会让 sd.cpp 直接以"打不开模型"退出。
fn weight_args(model_dir: &Path, with_vision: bool) -> Vec<String> {
QWEN_IMAGE_2_1
.iter()
.filter(|f| f.required || (with_vision && model_dir.join(f.name).is_file()))
.flat_map(|f| {
[
f.flag.to_string(),
model_dir.join(f.name).display().to_string(),
]
})
.collect()
}
/// sd-cli 生成一张图的参数。
///
/// 对照 sd.cpp 官方文档的示例;`--offload-to-cpu` 与 `--fa`(flash attention)
/// 在显存吃紧时很有用 —— 这台机器是集显/入门独显,先带上更稳。
/// `w`/`h` 是出图分辨率。sd-cli 的 `-W`/`-H` 默认 512 —— 想画大图必须显式给,
/// 而**不是**画完再放大(放大只是插值,细节不会多出来)。
///
/// 这条路是**纯文生图**,所以不带 mmproj(`weight_args(.., false)`):
/// 图生图要的是"给一张 init-img + 提示词",那需要界面上的入口把
/// `--init-img` 和 `--llm_vision` 一起给出来,现在还没有这个入口 ——
/// 在那之前把 mmproj 挂到每次出图上,只会让每张图都多加载 1.1 GB。
pub fn sd_cli_args(
prompt: &str,
output: &Path,
model_dir: &Path,
w: u32,
h: u32,
) -> Vec<String> {
let mut args = weight_args(model_dir, false);
args.extend([
"-p".to_string(),
prompt.to_string(),
"--cfg-scale".to_string(),
SD_CFG_SCALE.to_string(),
"--sampling-method".to_string(),
SD_SAMPLING_METHOD.to_string(),
"-W".to_string(),
w.to_string(),
"-H".to_string(),
h.to_string(),
"--offload-to-cpu".to_string(),
"--fa".to_string(),
"-o".to_string(),
output.display().to_string(),
]);
args
}
/// `sd-server` 的启动参数。
///
/// 模型部分和 `sd_cli_args` **相同**,但去掉了 `-p`(提示词)和 `-o`(输出路径)——
/// 这两样在服务形态下是**每次请求**的参数,由客户端(DSH / curl)给。
/// 留下的采样参数成为服务端默认值。
///
/// **服务形态要带上 mmproj**(在它已下载的前提下):模型路径是**启动参数**,
/// 服务起来之后改不了 —— 不在这里带上,同一个常驻进程就永远发不出
/// 图片编辑的请求,只能再起一个进程占一份显存。
///
/// 参数名是从 `sd-server.exe --help` 实读的,不是照文档猜的:
/// `-l, --listen-ip <string>`(默认 127.0.0.1)、`--listen-port <int>`(默认 1234)
pub fn sd_server_args(model_dir: &Path, port: u16) -> Vec<String> {
let mut args = weight_args(model_dir, true);
args.extend([
"--cfg-scale".to_string(),
SD_CFG_SCALE.to_string(),
"--sampling-method".to_string(),
SD_SAMPLING_METHOD.to_string(),
// **这里刻意没有 -W/-H**:分辨率是**每次请求**的参数,
// 服务形态下写死在启动参数里等于把所有请求锁成一个尺寸。
"--offload-to-cpu".to_string(),
"--fa".to_string(),
// **只监听回环** —— 这和 DSH 的 provider 是同一台机器上的两个进程,
// 没有理由把 28 GB 模型的服务暴露到局域网。
"-l".to_string(),
"127.0.0.1".to_string(),
"--listen-port".to_string(),
port.to_string(),
]);
args
}
/// 找不到可用模型时,给用户一句能照做的话。
pub fn model_hint(paths: &Paths) -> String {
format!(
"模型目录: {}\n还没下载的话运行: .\\libs\\scripts\\fetch-libs.ps1 -ModelsOnly",
paths.lib("model").display()
)
}
/// `libs\` 下一个子目录的占用与用途。
pub struct LibDir {
pub name: String,
pub bytes: u64,
pub files: usize,
pub note: &'static str,
}
impl LibDir {
pub fn human(&self) -> String {
let b = self.bytes as f64;
if b >= 1024.0 * 1024.0 * 1024.0 {
format!("{:.2} GB", b / 1024.0 / 1024.0 / 1024.0)
} else {
format!("{:.1} MB", b / 1024.0 / 1024.0)
}
}
}
/// 扫描 `libs\` 下的每个子目录。
///
/// 为什么要有这个:Libs 页原来只有「插件装载表」,看不到**磁盘上到底有什么**。
/// 而 `libs\model` 有 28 GB、`llama.cpp` 444 MB —— 这些不进安装包、由
/// `fetch-libs.ps1` 按需下载,所以「下没下、占多少」必须能看见。
pub fn scan_dirs(paths: &Paths) -> Vec<LibDir> {
let mut out: Vec<LibDir> = Vec::new();
let Ok(rd) = std::fs::read_dir(paths.libs()) else {
return out;
};
for e in rd.flatten() {
let p = e.path();
if !p.is_dir() {
continue;
}
let name = e.file_name().to_string_lossy().to_string();
let mut bytes = 0u64;
let mut files = 0usize;
// 只数两层:model\ 的权重是平铺的,llama.cpp\ 可能带一层 bin\。
//
// **层级必须跟条目一起入栈**。原来 `depth` 在循环外、每弹出一个目录才
// `+= 1`,于是"弹出的第几个目录"被当成了"第几层",而且超层用的是
// `break`:同级只要还有第二个子目录就整个跳出循环 —— 那棵子树的直属
// 文件、它的下级、以及栈里剩下的目录**全都丢**,界面上的占用凭空少一块,
// 少的恰恰是"看起来已经下过"的那部分,于是用户以为下载没生效。
// 用 `continue` 而不是 `break`:超层的只是这一条,兄弟目录还要接着数。
let mut stack = vec![(p.clone(), 0u8)];
while let Some((dir, level)) = stack.pop() {
if level > 1 {
continue;
}
if let Ok(inner) = std::fs::read_dir(&dir) {
for f in inner.flatten() {
match f.metadata() {
Ok(md) if md.is_file() => {
bytes += md.len();
files += 1;
}
Ok(md) if md.is_dir() => stack.push((f.path(), level + 1)),
_ => {}
}
}
}
}
out.push(LibDir {
note: dir_note(&name),
name,
bytes,
files,
});
}
out.sort_by(|a, b| b.bytes.cmp(&a.bytes).then(a.name.cmp(&b.name)));
out
}
/// 已知目录的用途。未登记的原样列出,不隐藏。
fn dir_note(name: &str) -> &'static str {
match name {
"model" => "模型权重(Qwen-Image 2.1 + Qwen3-VL),不进安装包",
"llama.cpp" => "编程模型推理后端(llama-server)",
"stable-diffusion.cpp" => "图像生成后端(sd-cli)",
"ffmpeg" => "转码 / 抽帧 / 合流",
"victoriametrics" => "指标存储(监控栈)",
"loki" => "日志存储(监控栈)",
"alloy" => "统一采集(监控栈)",
"live" => "bililive-go 等直播工具",
"scripts" => "本程序调用的 PowerShell 脚本",
"_download" => "下载中的临时文件,可安全删除",
_ => "(未登记)",
}
}
// ---------------------------------------------------------------- 报告
/// `--libs-status` 的输出。返回「行」,换行交给调用方(与 `paths.rs` 一致)。
pub fn report_lines(paths: &Paths) -> Vec<String> {
let mut lines = Vec::new();
lines.push(format!("libs 目录: {}", paths.libs().display()));
lines.push(String::new());
for comp in COMPONENTS {
let st = component_status(paths, comp);
lines.push(format!("[{}] {}", comp.name, comp.note));
lines.push(format!(" 目录 : {}", st.dir.display()));
lines.push(format!(
" 状态 : {}{}",
if st.installed() { "已就位" } else { "缺失" },
match &st.build {
Some(b) => format!(" 构建={b}"),
None => " 构建=(未知)".to_string(),
}
));
// 名字后面带上**真实路径**:`libs\` 里的 exe 可能来自不同构建
// (包内带一份、系统里另装一份),自检里"你说找到了"最常被追问的就是
// "到底找的是哪个文件" —— 原来那句 `let _ = p;` 把路径显式丢掉了。
for (name, p) in &st.found {
lines.push(format!(" ✓ {name} {}", p.display()));
}
for name in &st.missing {
lines.push(format!(" ✗ {name} (缺)"));
}
lines.push(format!(" 占用 : {:.1} MB", st.total_bytes as f64 / 1_048_576.0));
lines.push(String::new());
}
// ---- 可以被 llama-server 直接拉起的大模型 ----
lines.push("[本地大模型 · 可拉起]".to_string());
lines.push(format!(
" {:<8} {:<24} {:>10} {}",
"用途", "模型", "体积", "文件名"
));
for p in servable_status(paths) {
lines.push(format!(
" {:<8} {:<24} {:>10} {}{}",
p.model.kind.label(),
p.model.name,
p.describe(),
p.model.file,
// 「半截」和「没下」要分开写:前者文件在,直接说「缺」会让人以为
// 要从零下一遍,而正确的做法往往是把那个残文件删掉再重下。
match (p.present(), p.partial()) {
(true, _) => "",
(false, true) => " ← 疑似未下完",
(false, false) => " ← 缺",
}
));
}
lines.push(String::new());
let ms = model_status(paths);
lines.push("[本地模型 · 图像管线] Qwen-Image 2.1".to_string());
lines.push(format!(" 目录 : {}", ms.dir.display()));
lines.push(format!(
" 状态 : {}{}",
if ms.ready() { "可出图" } else { "不完整" },
if ms.can_edit() { " + 可编辑图片" } else { "" }
));
for (f, size) in &ms.present {
lines.push(format!(
" ✓ {:<42} {:>7.0} MB {}",
f.name,
*size as f64 / 1_048_576.0,
f.role
));
}
for f in &ms.missing {
lines.push(format!(
" ✗ {:<42} 约 {:>4} MB {}{}",
f.name,
f.approx_mb,
f.role,
if f.required { " ← 必需" } else { " (可选)" }
));
}
lines.push(format!(
" 合计 : {:.2} GB",
ms.total_bytes() as f64 / 1_073_741_824.0
));
if !ms.ready() {
lines.push(format!(" {}", model_hint(paths).replace('\n', "\n ")));
}
lines
}
/// 递归统计目录大小(只用于显示占用,失败就当 0)。
fn dir_size(dir: &Path) -> u64 {
let mut total = 0u64;
let Ok(rd) = std::fs::read_dir(dir) else {
return 0;
};
for e in rd.flatten() {
match e.metadata() {
Ok(m) if m.is_file() => total += m.len(),
Ok(m) if m.is_dir() => total += dir_size(&e.path()),
_ => {}
}
}
total
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_paths(name: &str) -> (Paths, PathBuf) {
let root = std::env::temp_dir().join(format!("nfarms-libs-test-{name}"));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("bin")).unwrap();
let paths = Paths::from_exe_dir(root.join("bin"));
let libs = paths.libs();
(paths, libs)
}
#[test]
fn nothing_installed_reports_missing() {
let (paths, libs) = tmp_paths("empty");
std::fs::create_dir_all(&libs).unwrap();
for comp in COMPONENTS {
let st = component_status(&paths, comp);
assert!(!st.installed());
assert_eq!(st.missing.len(), comp.required_exes.len());
assert!(st.build.is_none());
}
let ms = model_status(&paths);
assert!(!ms.ready());
assert_eq!(ms.missing.len(), QWEN_IMAGE_2_1.len());
let _ = std::fs::remove_dir_all(paths.root());
}
#[test]
fn detects_executables_and_reads_build_marker() {
let (paths, libs) = tmp_paths("llama");
let dir = libs.join(LLAMA.dir);
std::fs::create_dir_all(&dir).unwrap();
for exe in LLAMA.required_exes {
std::fs::write(dir.join(exe), b"stub").unwrap();
}
std::fs::write(dir.join(VERSION_MARKER), "b11284 sycl\n").unwrap();
let st = component_status(&paths, &LLAMA);
assert!(st.installed());
assert_eq!(st.found.len(), 2);
assert_eq!(st.build.as_deref(), Some("b11284 sycl"));
assert!(st.total_bytes > 0);
let _ = std::fs::remove_dir_all(paths.root());
}
#[test]
fn model_ready_needs_all_required_files_but_vision_is_optional() {
let (paths, libs) = tmp_paths("model");
let dir = libs.join("model");
std::fs::create_dir_all(&dir).unwrap();
// 只放三个必需文件,不放 mmproj
for f in QWEN_IMAGE_2_1.iter().filter(|f| f.required) {
std::fs::write(dir.join(f.name), vec![0u8; 16]).unwrap();
}
let ms = model_status(&paths);
assert!(ms.ready(), "三个必需文件齐了就该能出图");
assert!(!ms.can_edit(), "没有 mmproj 就不能编辑图片");
assert_eq!(ms.present.len(), 3);
assert_eq!(ms.total_bytes(), 48);
// 补上 mmproj 之后编辑能力才打开
let vision = QWEN_IMAGE_2_1.iter().find(|f| !f.required).unwrap();
std::fs::write(dir.join(vision.name), vec![0u8; 8]).unwrap();
assert!(model_status(&paths).can_edit());
let _ = std::fs::remove_dir_all(paths.root());
}
#[test]
fn llama_args_point_at_the_model_and_the_sycl_device() {
let args = llama_server_args(Path::new(r"C:\x\m.gguf"), 8080, Offload::Gpu);
assert!(args.contains(&"127.0.0.1".to_string()));
assert!(args.contains(&"8080".to_string()));
// Intel Arc 走 SYCL,不是 CUDA
assert!(args.contains(&"SYCL0".to_string()));
let i = args.iter().position(|a| a == "-m").unwrap();
assert_eq!(args[i + 1], r"C:\x\m.gguf");
// 稠密模型全量卸载,不需要 exps=CPU
assert!(!args.iter().any(|a| a == "exps=CPU"));
}
#[test]
fn moe_model_keeps_experts_on_cpu() {
// 这条是硬性要求:少了 -ot exps=CPU,17 GiB 的 MoE 权重会被要求
// 同时存在于内存和显存,本机 UMA 直接崩(0xC0000409)。
let args = llama_server_args(Path::new(r"C:\x\coder.gguf"), 8080, Offload::CpuMoe);
let i = args
.iter()
.position(|a| a == "-ot")
.expect("MoE 必须带 -ot");
assert_eq!(args[i + 1], "exps=CPU");
assert!(args.contains(&"SYCL0".to_string()), "注意力层仍然上 GPU");
}
#[test]
fn cpu_offload_uses_no_gpu() {
let args = llama_server_args(Path::new(r"C:\x\m.gguf"), 8080, Offload::Cpu);
let i = args.iter().position(|a| a == "-ngl").unwrap();
assert_eq!(args[i + 1], "0");
assert!(!args.iter().any(|a| a == "SYCL0"));
}
#[test]
fn llama_ctx_is_carried_into_the_args_and_validated() {
// 上下文长度必须真的落到 `-c` 上:DSH 的 contextWindow 就是照它填的,
// 少传一次这两边就又对不上了(那正是 400 exceed_context_size 的成因)。
let args = llama_server_args_with_ctx(Path::new(r"C:\x\m.gguf"), 8080, Offload::Cpu, 16384);
let i = args.iter().position(|a| a == "-c").expect("必须带 -c");
assert_eq!(args[i + 1], "16384");
// 默认值走的也是带 ctx 的那条路径,不能是另一套写死的数字。
let d = llama_server_args(Path::new(r"C:\x\m.gguf"), 8080, Offload::Cpu);
let j = d.iter().position(|a| a == "-c").expect("必须带 -c");
assert_eq!(d[j + 1], DEFAULT_LLAMA_CTX.to_string());
// 0 要拦住:llama.cpp 会把 `-c 0` 当成「用模型默认」,DSH 就又不知道真实容量了。
assert!(!valid_llama_ctx(0));
assert!(!valid_llama_ctx(MIN_LLAMA_CTX - 1));
assert!(!valid_llama_ctx(MAX_LLAMA_CTX + 1));
assert!(valid_llama_ctx(MIN_LLAMA_CTX));
assert!(valid_llama_ctx(DEFAULT_LLAMA_CTX));
assert!(valid_llama_ctx(MAX_LLAMA_CTX));
}
#[test]
fn coder_model_is_the_moe_one() {
// 编程模型是 MoE,必须按 MoE 配置;8B 的稠密模型则全量卸载
assert_eq!(CODING_MODEL.offload, Offload::CpuMoe);
assert_eq!(CHAT_MODEL.offload, Offload::Gpu);
}
#[test]
fn servable_models_cover_image_encoder_and_coder() {
// 用户的要求是「一个图像处理一个编程处理」:编程模型必须在列,
// 图像管线的文本编码器也要能单独拉起来当通用对话用。
assert_eq!(SERVABLE.len(), 2);
let kinds: Vec<ModelKind> = SERVABLE.iter().map(|m| m.kind).collect();
assert!(kinds.contains(&ModelKind::Code), "缺编程模型");
assert!(kinds.contains(&ModelKind::Chat), "缺通用对话/图像编码器");
let coder = SERVABLE.iter().find(|m| m.kind == ModelKind::Code).unwrap();
assert!(coder.name.contains("Coder"));
assert!(coder.file.ends_with(".gguf"));
// 编程模型应当明显比 8B 的对话模型大
assert!(coder.approx_mb > CHAT_MODEL.approx_mb * 2);
}
/// 测试专用的小模型替身。
///
/// 真实权重是 4~17 GB,照真尺寸造文件会把临时目录写爆(原来那条用例就是
/// 为此写了 1 GiB 全零数据)。`presence()` 只用到 `file` 和 `approx_mb`,
/// 所以 8 MiB 的替身足够覆盖「没下 / 半截 / 下完」三种状态。
static TINY_MODEL: LocalModel = LocalModel {
key: "test-tiny",
name: "Tiny-Test",
file: "tiny-test.gguf",
kind: ModelKind::Code,
approx_mb: 8,
role: "仅测试用",
source: "-",
offload: Offload::Cpu,
};
#[test]
fn presence_reports_missing_then_present() {
let (paths, libs) = tmp_paths("presence");
let dir = libs.join("model");
std::fs::create_dir_all(&dir).unwrap();
let before = presence(&paths, &TINY_MODEL);
assert!(!before.present());
assert!(!before.partial());
assert!(before.describe().contains("未下载"));
assert!(before.path.ends_with(TINY_MODEL.file));
// 整份写出来才算「下完了」
let full = TINY_MODEL.approx_mb as usize * 1_048_576;
std::fs::write(dir.join(TINY_MODEL.file), vec![0u8; full]).unwrap();
let after = presence(&paths, &TINY_MODEL);
assert!(after.present(), "体积与注册表一致就该算就位");
assert!(!after.partial());
assert!(after.describe().ends_with("GB"));
let _ = std::fs::remove_dir_all(paths.root());
}
/// 守的就是这条:半截权重**不能**算已下载。
///
/// 触发条件照着真实的下载器造:`dist/libs/scripts/fetch-libs.ps1` 用
/// `curl -C - -o $Dest` **直接往最终路径写**,校验在写完之后 ——
/// 所以断流之后 `libs\model\` 里就是一个 `is_file()` 为真、
/// 却只有一部分内容的权重。旧的 `presence()` 只看 `is_file()`,
/// 这种文件在界面上是 ✓ + 「权重已就位」,拉起来才发现加载不了。
#[test]
fn presence_flags_a_truncated_weight_as_not_downloaded() {
let (paths, libs) = tmp_paths("presence-truncated");
let dir = libs.join("model");
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join(TINY_MODEL.file);
// (a) 0 字节:刚建了文件就断了 —— 最容易被 `is_file()` 放过去的一种
std::fs::write(&file, b"").unwrap();
let empty = presence(&paths, &TINY_MODEL);
assert!(empty.bytes.is_some(), "文件在,字节数不能丢");
assert!(!empty.present(), "0 字节不能算已下载");
assert!(empty.partial());
// (b) 8 MiB 的权重只下到 3 MiB:明显缺一块,不是"另一个量化版本"
std::fs::write(&file, vec![0u8; 3 * 1_048_576]).unwrap();
let half = presence(&paths, &TINY_MODEL);
assert!(half.partial(), "体积明显小于注册表,必须判为没下完");
assert!(!half.present(), "半截权重不能算已下载");
assert!(
half.describe().contains("疑似未下完"),
"自检里要能一眼看出是断了,不能显示成已下载;实际: {}",
half.describe()
);
// (c) 边界:刚好到下限就算下完。余量是留给"注册表估算偏大"的,
// 不能反过来把完整的权重判成没下完。
let min = min_complete_bytes(&TINY_MODEL) as usize;
std::fs::write(&file, vec![0u8; min]).unwrap();
assert!(presence(&paths, &TINY_MODEL).present(), "到下限就该认");
// 比下限少 1 字节就翻回"半截",证明判据真的落在体积上
std::fs::write(&file, vec![0u8; min - 1]).unwrap();
assert!(!presence(&paths, &TINY_MODEL).present());
let _ = std::fs::remove_dir_all(paths.root());
}
/// 半截权重的显示串要同时给出「实际」和「应该」两个数,
/// 否则用户分不清是断了大半还是下错了文件。
///
/// 直接造 `ModelPresence` 而不碰磁盘:它验证的是格式化,
/// 没必要为了看一眼「17.3 GB」在临时目录里写 17 GB。
#[test]
fn describe_shows_both_actual_and_expected_size_when_truncated() {
let full = ModelPresence {
path: PathBuf::from(CODING_MODEL.file),
bytes: Some(CODING_MODEL.approx_mb as u64 * 1_048_576),
model: &CODING_MODEL,
};
assert!(full.present());
assert_eq!(full.describe(), "17.3 GB");
let half = ModelPresence {
path: PathBuf::from(CODING_MODEL.file),
bytes: Some(1_073_741_824),
model: &CODING_MODEL,
};
assert!(half.partial());
assert_eq!(half.describe(), "疑似未下完(1.0 GB / 约 17.3 GB)");
}
#[test]
fn model_kind_labels_are_the_users_words() {
assert_eq!(ModelKind::Image.label(), "图像处理");
assert_eq!(ModelKind::Code.label(), "编程处理");
}
#[test]
fn sd_args_include_all_three_required_weights() {
let args = sd_cli_args(
"a cat",
Path::new(r"C:\out.png"),
Path::new(r"C:\model"),
768,
1024,
);
for flag in ["--diffusion-model", "--vae", "--llm", "-p", "-o", "-W", "-H"] {
assert!(args.iter().any(|a| a == flag), "缺少参数 {flag}");
}
let joined = args.join(" ");
assert!(joined.contains("qwen_image_2.1-Q4_K.gguf"));
assert!(joined.contains("qwen_image_2.1_vae_bf16.safetensors"));
assert!(joined.contains("Qwen3VL-8B-Instruct-Q4_K_M.gguf"));
// 分辨率要**按传进去的值原样出现**。少了 -W/-H 的话 sd-cli 会默默
// 用回默认的 512 —— 输出看上去完全正常,只是小了一半,
// 而那种"看起来对"的错最容易一路带下去。
assert!(joined.contains("-W 768"), "实际: {joined}");
assert!(joined.contains("-H 1024"), "实际: {joined}");
}
/// 权重文件名只允许在注册表 `QWEN_IMAGE_2_1` 里出现一次。
///
/// 守的是"两处各抄一遍文件名"这个复发点:`sd_cli_args` / `sd_server_args`
/// 都得从注册表取值 —— 以后改量化版本(比如 Q4_K → Q8_0)时,只要注册表改了、
/// 参数还是从它取的,命令行就一定跟着变;抄字面量则必然漏掉一处。
#[test]
fn sd_args_take_weight_flags_and_paths_from_the_registry() {
let dir = Path::new(r"C:\model");
let cli = sd_cli_args("a cat", Path::new(r"C:\out.png"), dir, 512, 512);
let server = sd_server_args(dir, 1234);
for args in [&cli, &server] {
for f in QWEN_IMAGE_2_1.iter().filter(|f| f.required) {
let i = args
.iter()
.position(|a| a == f.flag)
.unwrap_or_else(|| panic!("{} 少了 {} 这一项", args.join(" "), f.flag));
assert_eq!(
args[i + 1],
dir.join(f.name).display().to_string(),
"{} 后面必须跟注册表里的文件名",
f.flag
);
}
}
}
/// mmproj 进命令行要**两个条件同时成立**:调用方说要(服务形态),
/// 而且文件真的在磁盘上。
///
/// 只满足前者:注册表里的权重是按需下载的,喂一个不存在的路径,
/// sd.cpp 会以"打不开模型"直接退出 —— 看上去像构建坏了。
/// 只满足后者(sd-cli 那条路)也不行:纯文生图带上它,每张图都白加载
/// 1.1 GB 的视觉编码器,而已知的翻车方式是 UMA 机器内存不够直接崩。
#[test]
fn vision_encoder_is_opt_in_and_needs_the_file_on_disk() {
let dir = std::env::temp_dir().join("nfarms-libs-vision-args");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let vision = QWEN_IMAGE_2_1.iter().find(|f| !f.required).unwrap();
let has_vision = |args: &[String]| args.iter().any(|a| a == vision.flag);
// (a) 文件不在:两条路都不许出现 mmproj 的参数
assert!(!has_vision(&sd_server_args(&dir, 1234)), "文件都不在,不能拼进参数");
assert!(!has_vision(&sd_cli_args(
"p",
Path::new("out.png"),
&dir,
512,
512
)));
// (b) 文件在了:服务形态带上(模型路径是启动参数,起服务之后改不了),
// 而 sd-cli 那条路仍然是纯文生图,不带。
std::fs::write(dir.join(vision.name), b"stub").unwrap();
let server = sd_server_args(&dir, 1234);
let i = server
.iter()
.position(|a| a == vision.flag)
.expect("服务形态要带上已就位的 mmproj");
assert_eq!(server[i + 1], dir.join(vision.name).display().to_string());
assert!(
!has_vision(&sd_cli_args(
"p",
Path::new("out.png"),
&dir,
512,
512
)),
"纯文生图不该为一张图白加载 1.1 GB 的视觉编码器"
);
let _ = std::fs::remove_dir_all(&dir);
}
/// `scan_dirs()` 只数两层,但**每一层里的每个子目录都要数到**。
///
/// 原来的实现把"弹出的第几个目录"当成了"第几层",而且超层用 `break`:
/// 同级有第二个子目录时整棵子树连同栈里剩下的目录一起丢。
/// 这条用例专门把"同级两个子目录"摆出来 —— 少任何一个,占用就不对。
#[test]
fn scan_dirs_counts_every_sibling_dir_and_stops_at_two_levels() {
let (paths, libs) = tmp_paths("scan");
let comp = libs.join("llama.cpp");
std::fs::create_dir_all(comp.join("bin")).unwrap();
std::fs::create_dir_all(comp.join("include")).unwrap();
std::fs::create_dir_all(comp.join("bin").join("deep")).unwrap();
// 第一层 1 字节 + 第二层的 2 与 4 字节 = 三层都不该漏;第三层 8 字节不算
std::fs::write(comp.join("a.txt"), vec![0u8; 1]).unwrap();
std::fs::write(comp.join("bin").join("b.txt"), vec![0u8; 2]).unwrap();
std::fs::write(comp.join("include").join("c.txt"), vec![0u8; 4]).unwrap();
std::fs::write(comp.join("bin").join("deep").join("d.txt"), vec![0u8; 8]).unwrap();
let dirs = scan_dirs(&paths);
let names: Vec<&str> = dirs.iter().map(|d| d.name.as_str()).collect();
assert_eq!(names, vec!["llama.cpp"], "只该有一个目录:{names:?}");
let llama = &dirs[0];
assert_eq!(
llama.files, 3,
"同级两个子目录都要进去(第三层不算);实际数到 {} 个文件",
llama.files
);
assert_eq!(
llama.bytes, 7,
"第二层的两个目录都得算上,第三层不算;实际 {} 字节",
llama.bytes
);
let _ = std::fs::remove_dir_all(paths.root());
}
/// 自检里说"✓ 找到了"就必须说清是**哪一个文件**。
#[test]
fn report_lines_show_where_the_found_exe_actually_is() {
let (paths, libs) = tmp_paths("report");
let dir = libs.join(LLAMA.dir);
std::fs::create_dir_all(&dir).unwrap();
for exe in LLAMA.required_exes {
std::fs::write(dir.join(exe), b"stub").unwrap();
}
let text = report_lines(&paths).join("\n");
let exe = dir.join("llama-server.exe");
assert!(
text.contains(&exe.display().to_string()),
"报告里只有名字、没有路径,排查时问不出「到底找的是哪一份」:\n{text}"
);
let _ = std::fs::remove_dir_all(paths.root());
}
}