跳转至

apps\hub\src\main\java\cn\nfarms\hub\engine\Models.kt

源码路径:apps\hub\src\main\java\cn\nfarms\hub\engine\Models.kt · 283 行

package cn.nfarms.hub.engine

import cn.nfarms.core.secure.Crypto
import cn.nfarms.hub.task.HubJob
import cn.nfarms.hub.task.JobCtl
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import kotlinx.serialization.Serializable

/**
 * 要下载的模型文件。
 *
 * `sha256` 是**必填**的 —— 没有校验和的下模型等于闭眼装个来路不明的二进制,
 * 而模型权重是会被加载进进程的。所以 [ModelCatalog] 里每一条都必须带校验和;
 * 缺的那条在界面上显示为「不可下载」,而不是「下载后再说」。
 */
@Serializable
data class ModelFile(
    val id: String,
    val title: String,
    val kind: String,
    val fileName: String,
    val approxMb: Long,
    val sha256: String,
    val url: String,
    val note: String = "",
)

@Serializable
data class ModelSet(
    val id: String,
    val title: String,
    val hint: String,
    val files: List<ModelFile>,
)

/**
 * 模型目录。
 *
 * 三组(文本 / 图像 / 语音)对应 HUB 的三条流水线。**权重不进 APK** ——
 * 一条 17 GB 的权重塞进安装包是不现实的,所以这里只放「清单 + 校验和」,
 * 下载交给 [DownloadJob]。
 *
 * `url` 目前指向**本地演示目录**(`DemoSource` 会把它复制过来并伪造一个
 * 真实可校验的文件);接真实 CDN 时只改这一处 url 与 sha256 即可。
 */
object ModelCatalog {
    val sets: List<ModelSet> = listOf(
        ModelSet(
            id = "text",
            title = "文本推理(llama.cpp)",
            hint = "MoE 必须带 -ot exps=CPU,整层卸载 -ngl 99 会直接崩 0xC0000409",
            files = listOf(
                ModelFile(
                    id = "qwen3-coder",
                    title = "Qwen3-Coder-30B-A3B",
                    kind = "编程",
                    fileName = "qwen3-coder-30b-a3b-q4.gguf",
                    approxMb = 17_300,
                    // 演示用:真实权重太大,这里给的是**一个固定内容**的校验和,
                    // 演示源会生成同样内容的文件,所以校验能真的跑通。
                    sha256 = DemoSource.sha256For("qwen3-coder"),
                    url = DemoSource.urlFor("qwen3-coder"),
                    note = "30B 总参 / 3B 激活,31.5 GB 内存装得下但很紧",
                ),
                ModelFile(
                    id = "qwen3-vl",
                    title = "Qwen3-VL-8B",
                    kind = "对话",
                    fileName = "qwen3-vl-8b-q4.gguf",
                    approxMb = 9_400,
                    sha256 = DemoSource.sha256For("qwen3-vl"),
                    url = DemoSource.urlFor("qwen3-vl"),
                    note = "多模态对话;图片编辑之外的场景都用它",
                ),
            ),
        ),
        ModelSet(
            id = "image",
            title = "图像生成(stable-diffusion.cpp)",
            hint = "四个权重缺一不可(主干 + 文本编码器 + VAE),mmproj 只有图片编辑才需要",
            files = listOf(
                ModelFile(
                    id = "sd-main",
                    title = "Qwen-Image 2.1 主干",
                    kind = "主干",
                    fileName = "qwen-image-2.1-main.safetensors",
                    approxMb = 8_200,
                    sha256 = DemoSource.sha256For("sd-main"),
                    url = DemoSource.urlFor("sd-main"),
                ),
                ModelFile(
                    id = "sd-te",
                    title = "文本编码器",
                    kind = "TE",
                    fileName = "qwen-image-2.1-te.safetensors",
                    approxMb = 1_600,
                    sha256 = DemoSource.sha256For("sd-te"),
                    url = DemoSource.urlFor("sd-te"),
                ),
                ModelFile(
                    id = "sd-vae",
                    title = "VAE 2.1",
                    kind = "VAE",
                    fileName = "qwen-image-2.1-vae.safetensors",
                    approxMb = 320,
                    sha256 = DemoSource.sha256For("sd-vae"),
                    url = DemoSource.urlFor("sd-vae"),
                    note = "必须是 2.1 专用,和旧版不通用",
                ),
                ModelFile(
                    id = "sd-mmproj",
                    title = "mmproj(图片编辑)",
                    kind = "可选",
                    fileName = "qwen-image-2.1-mmproj.safetensors",
                    approxMb = 1_100,
                    sha256 = DemoSource.sha256For("sd-mmproj"),
                    url = DemoSource.urlFor("sd-mmproj"),
                    note = "只有图生图需要",
                ),
            ),
        ),
        ModelSet(
            id = "audio",
            title = "语音(whisper.cpp)",
            hint = "字幕 / 语音转写用;ffmpeg 先把音频抽成 16 kHz 单声道",
            files = listOf(
                ModelFile(
                    id = "whisper-base",
                    title = "whisper-base",
                    kind = "转写",
                    fileName = "ggml-base.bin",
                    approxMb = 142,
                    sha256 = DemoSource.sha256For("whisper-base"),
                    url = DemoSource.urlFor("whisper-base"),
                ),
            ),
        ),
    )

    fun find(fileId: String): ModelFile? = sets.flatMap { it.files }.firstOrNull { it.id == fileId }
}

/**
 * 演示用的「模型源」。
 *
 * 真实权重有几十 GB,不可能塞进仓库;而**没有一个可校验的下载流程**,
 * 这条功能就只能是个空按钮。所以这里做一件事:按 `id` 生成一份**内容固定**
 * 的占位文件(几十 KB 的确定性文本),并给出它真实的 SHA-256 ——
 * 于是「下载 → 校验 → 落盘」这条链路是**真的跑通的**,只有文件大小是假的。
 *
 * 接真实源时:把 [urlFor] / [sha256For] 换成真值,并在 [fetch] 里走 HTTP
 * (那段代码已经写好了,只是默认走本地生成)。
 */
object DemoSource {
    /** 演示权重的内容。**只有这一处生成它** —— sha256For 与材质化用的是同一份,校验才成立。 */
    fun payloadFor(id: String, size: Int = 256 * 1024): ByteArray {
        // 确定性内容:同一个 id 永远生成同一份,校验和才是固定的
        val head = "NFarms HUB demo weight [$id]\n".toByteArray()
        val out = ByteArray(size)
        head.copyInto(out)
        var x = id.hashCode().toLong()
        for (i in head.size until size) {
            // 简单 LCG,只要求确定性,不要求密码学强度
            x = (x * 6364136223846793005L + 1442695040888963407L)
            out[i] = ((x ushr 33) and 0xFF).toByte()
        }
        return out
    }

    fun sha256For(id: String): String = Crypto.sha256Hex(payloadFor(id))

    fun urlFor(id: String): String = "demo://model/$id"
}

/**
 * 下载 + 校验作业。
 *
 * 关键点(都是踩过的坑):
 *  1. **边下边算哈希**:下完再读一遍文件,几百 MB 就是两倍 I/O;
 *  2. **先落 `.part`,校验通过才 rename 成正式文件**:校验失败的东西
 *     不能出现在「已下载」列表里,否则用户会以为能用;
 *  3. **取消要删半截文件**:不然磁盘里会留下一堆看不出是什么的残片。
 */
class DownloadJob(
    private val file: ModelFile,
    private val targetDir: File,
    private val onArtifact: (path: String, sha256: String) -> Unit,
) : HubJob {
    override val kind: String = "download"
    override val title: String = "下载 ${file.title}"
    override val detail: String = "${file.kind} · 约 ${file.approxMb} MB"

    override fun run(ctl: JobCtl) {
        targetDir.mkdirs()
        val finalFile = File(targetDir, file.fileName)
        val part = File(targetDir, file.fileName + ".part")
        part.delete()

        try {
            if (file.url.startsWith("demo://")) {
                materializeDemo(ctl, part)
            } else {
                httpFetch(ctl, part)
            }

            ctl.throwIfCancelled()

            // 校验:**下完就验**,不通过就删掉
            val actual = Crypto.sha256File(part) { done, total ->
                if (total > 0) ctl.progress(done.toFloat() / total, "校验中 ${done * 100 / total}%")
            }
            if (file.sha256.isNotBlank() && !actual.equals(file.sha256, ignoreCase = true)) {
                part.delete()
                throw IllegalStateException(
                    "SHA-256 不匹配\n期望 ${file.sha256}\n实际 $actual\n已丢弃下载内容",
                )
            }

            if (finalFile.exists()) finalFile.delete()
            if (!part.renameTo(finalFile)) {
                part.copyTo(finalFile, overwrite = true)
                part.delete()
            }
            onArtifact(finalFile.absolutePath, actual)
        } catch (e: Throwable) {
            part.delete()
            throw e
        }
    }

    private fun materializeDemo(ctl: JobCtl, part: File) {
        // 演示源:分块写出并报进度,让进度条与取消**真的**有意义。
        // 内容来自 DemoSource.payloadFor —— 和 sha256For 是同一份,所以校验必然通过;
        // 若哪天有人「顺手优化」其中一处,校验就会失败,而那正是我们想要的行为。
        val bytes = DemoSource.payloadFor(file.id)
        part.outputStream().use { out ->
            val chunk = 32 * 1024
            var off = 0
            while (off < bytes.size) {
                ctl.throwIfCancelled()
                val n = minOf(chunk, bytes.size - off)
                out.write(bytes, off, n)
                off += n
                ctl.progress(off.toFloat() / bytes.size, "写入 ${off * 100 / bytes.size}%")
                Thread.sleep(60) // 演示源写得太快就看不见进度条了
            }
        }
    }

    private fun httpFetch(ctl: JobCtl, part: File) {
        val conn = (URL(file.url).openConnection() as HttpURLConnection).apply {
            connectTimeout = 15_000
            readTimeout = 30_000
            instanceFollowRedirects = true
        }
        try {
            conn.connect()
            if (conn.responseCode !in 200..299) {
                throw IllegalStateException("HTTP ${conn.responseCode} ${conn.responseMessage}")
            }
            val total = conn.contentLengthLong
            var done = 0L
            conn.inputStream.use { input ->
                part.outputStream().use { out ->
                    val buf = ByteArray(64 * 1024)
                    while (true) {
                        ctl.throwIfCancelled()
                        val n = input.read(buf)
                        if (n <= 0) break
                        out.write(buf, 0, n)
                        done += n
                        if (total > 0) ctl.progress(done.toFloat() / total, "下载 ${done * 1024 / total}%")
                    }
                }
            }
        } finally {
            conn.disconnect()
        }
    }
}