跳转至

apps\hub\src\main\java\cn\nfarms\hub\ui\ModelsScreen.kt

源码路径:apps\hub\src\main\java\cn\nfarms\hub\ui\ModelsScreen.kt · 162 行

package cn.nfarms.hub.ui

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import cn.nfarms.core.ui.BrandErr
import cn.nfarms.core.ui.BrandOk
import cn.nfarms.core.ui.BrandWarn
import cn.nfarms.core.ui.CardBox
import cn.nfarms.core.ui.KV
import cn.nfarms.core.ui.Pill
import cn.nfarms.core.ui.Section
import cn.nfarms.core.ui.bytes as fmtBytes
import cn.nfarms.hub.HubUi
import cn.nfarms.hub.HubViewModel
import cn.nfarms.hub.engine.ModelCatalog
import cn.nfarms.hub.task.TaskState

/**
 * 模型页。
 *
 * 三组(文本 / 图像 / 语音)各自一张卡。**每个文件都显示它要的 SHA-256**,
 * 而不是只显示「已下载」——用户有权知道这个二进制是按什么校验的。
 */
@Composable
fun ModelsScreen(ui: HubUi, vm: HubViewModel) {
    val activeDownload = ui.tasks.firstOrNull { it.kind == "download" && it.state == TaskState.Running }

    LazyColumn(
        modifier = Modifier.fillMaxSize().padding(horizontal = 14.dp),
        verticalArrangement = Arrangement.spacedBy(10.dp),
        contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 12.dp),
    ) {
        item {
            CardBox {
                Section("权重不进安装包", "安装包里只有清单与校验和;权重按需下载到 App 私有目录")
                KV("下载目录", vm.modelsDirLabel(), mono = true)
                if (activeDownload != null) {
                    Spacer(Modifier.height(8.dp))
                    Text(
                        "正在下载:${activeDownload.title}",
                        style = MaterialTheme.typography.bodySmall,
                        color = BrandWarn,
                    )
                    Spacer(Modifier.height(4.dp))
                    if (activeDownload.progress >= 0f) {
                        LinearProgressIndicator(
                            progress = { activeDownload.progress },
                            modifier = Modifier.fillMaxWidth(),
                        )
                    } else {
                        LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
                    }
                }
                Spacer(Modifier.height(8.dp))
                Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                    OutlinedButton(onClick = { vm.verifyAll() }) { Text("全部校验") }
                    OutlinedButton(onClick = { vm.refreshModels() }) { Text("刷新") }
                }
            }
        }

        for (set in ModelCatalog.sets) {
            item { Section(set.title, set.hint) }
            items(set.files, key = { it.id }) { f ->
                val row = ui.models.firstOrNull { it.file.id == f.id }
                val present = row?.present == true
                val corrupt = row?.corrupt == true
                CardBox {
                    Row(verticalAlignment = Alignment.CenterVertically) {
                        Column(Modifier.weight(1f)) {
                            Text(f.title, fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
                            Text(
                                "${f.kind} · 约 ${f.approxMb} MB · ${f.fileName}",
                                style = MaterialTheme.typography.labelSmall,
                                color = MaterialTheme.colorScheme.onSurfaceVariant,
                            )
                        }
                        when {
                            present -> Pill("已就绪", BrandOk)
                            corrupt -> Pill("校验失败", BrandErr)
                            else -> Pill("未下载", MaterialTheme.colorScheme.onSurfaceVariant)
                        }
                    }
                    if (f.note.isNotBlank()) {
                        Spacer(Modifier.height(4.dp))
                        Text(
                            f.note,
                            style = MaterialTheme.typography.bodySmall,
                            color = MaterialTheme.colorScheme.onSurfaceVariant,
                        )
                    }
                    Spacer(Modifier.height(6.dp))
                    KV("SHA-256", f.sha256, mono = true)
                    if (present && row != null) KV("磁盘占用", fmtBytes(row.bytes))

                    Row(
                        Modifier.fillMaxWidth().padding(top = 6.dp),
                        horizontalArrangement = Arrangement.End,
                        verticalAlignment = Alignment.CenterVertically,
                    ) {
                        if (corrupt) {
                            Text(
                                "校验和不符 —— 文件不可用",
                                style = MaterialTheme.typography.labelSmall,
                                color = BrandErr,
                                modifier = Modifier.weight(1f),
                            )
                        }
                        Spacer(Modifier.weight(1f))
                        if (present) {
                            OutlinedButton(onClick = { vm.deleteModel(f) }) { Text("删除") }
                        } else {
                            Button(onClick = { vm.download(f) }) { Text("下载并校验") }
                        }
                        if (corrupt) {
                            Spacer(Modifier.width(8.dp))
                            TextButton(onClick = { vm.deleteModel(f) }) {
                                Text("丢弃", color = BrandErr)
                            }
                        }
                    }
                }
            }
        }

        item {
            CardBox {
                Section("为什么是「下载 → 校验 → 落盘」", null)
                Text(
                    "顺序不能反:先写正式文件名、校验失败再删,中间那一瞬文件会被当成可用;" +
                        "而模型权重是要被加载进进程的二进制,校验不过就必须当作不存在。",
                    style = MaterialTheme.typography.bodySmall,
                    fontFamily = FontFamily.Default,
                    color = MaterialTheme.colorScheme.onSurfaceVariant,
                )
            }
        }
    }
}