aha 0.2.6

aha model inference library, now supports Qwen(2.5VL/3/3VL/3.5/ASR/3Embedding/3Reranker), MiniCPM(4/5), VoxCPM(0.5B/1.5/2), DeepSeek-OCR/2, Hunyuan-OCR, PaddleOCR-VL/1.5, RMBG2.0, GLM(ASR-Nano-2512/OCR), Fun-ASR-Nano-2512, LFM(2/2.5/2VL/2.5VL)
Documentation
import { useState, useEffect } from "react"
import { invoke } from "@tauri-apps/api/core"
import { Download, Trash2, RefreshCw, HardDrive, Package } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Card } from "@/components/ui/card"
import { Header } from "@/components/layout/header"
import { Main } from "@/components/layout/main"
import { ProfileDropdown } from "@/components/profile-dropdown"
import { ThemeSwitch } from "@/components/theme-switch"

const SAVE_DIR_KEY = "aha-model-save-dir"

interface ModelInfo {
  model_id: string
  owner: string
  model_type: string
  downloaded: boolean
  size: number | null
  size_human: string | null
  path: string | null
}

const typeColors: Record<string, string> = {
  llm: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
  vlm: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200",
  ocr: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
  asr: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
  tts: "bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-200",
  image: "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200",
  embedding: "bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200",
  reranker: "bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200",
}

const typeLabels: Record<string, string> = {
  llm: "LLM",
  vlm: "VLM",
  ocr: "OCR",
  asr: "ASR",
  tts: "TTS",
  image: "图像",
  embedding: "嵌入",
  reranker: "重排序",
}

export function ModelsPage() {
  const [models, setModels] = useState<ModelInfo[]>([])
  const [loading, setLoading] = useState(true)
  const [downloading, setDownloading] = useState<string | null>(null)
  const [error, setError] = useState<string | null>(null)

  const loadModels = async () => {
    setLoading(true)
    try {
      const data = await invoke<ModelInfo[]>("list_models")
      setModels(data)
      setError(null)
    } catch (e) {
      setError(String(e))
    }
    setLoading(false)
  }

  useEffect(() => {
    loadModels()
  }, [])

  const handleDownload = async (modelId: string) => {
    setDownloading(modelId)
    setError(null)
    try {
      const saveDir = localStorage.getItem(SAVE_DIR_KEY) || null
      await invoke("download_model", { modelId, saveDir })
      await loadModels()
    } catch (e) {
      setError(String(e))
    }
    setDownloading(null)
  }

  const handleDelete = async (modelId: string) => {
    if (!confirm(`确定删除模型 ${modelId}?`)) return
    try {
      await invoke("delete_model", { modelId })
      await loadModels()
    } catch (e) {
      setError(String(e))
    }
  }

  return (
    <>
      <Header>
        <div className="flex items-center gap-2 ms-auto">
          <ThemeSwitch />
          <ProfileDropdown />
        </div>
      </Header>

      <Main>
        <div className="mb-6 flex items-center justify-between">
          <div>
            <h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
              <Package className="w-6 h-6" />
              模型列表
            </h1>
            <p className="text-muted-foreground text-sm mt-1">
              管理已下载和可用的模型
            </p>
          </div>
          <Button variant="outline" onClick={loadModels} disabled={loading}>
            <RefreshCw className={`w-4 h-4 mr-1.5 ${loading ? "animate-spin" : ""}`} />
            刷新
          </Button>
        </div>

        {error && (
          <div className="mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-md text-sm text-destructive">
            {error}
          </div>
        )}

        <Card className="overflow-hidden">
          <div className="overflow-x-auto">
            <table className="w-full text-sm">
              <thead>
                <tr className="bg-muted/50 border-b">
                  <th className="text-left px-4 py-3 font-medium">模型 ID</th>
                  <th className="text-left px-4 py-3 font-medium">类型</th>
                  <th className="text-left px-4 py-3 font-medium">大小</th>
                  <th className="text-left px-4 py-3 font-medium">状态</th>
                  <th className="text-right px-4 py-3 font-medium">操作</th>
                </tr>
              </thead>
              <tbody>
                {models.length === 0 && !loading ? (
                  <tr>
                    <td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
                      暂无模型数据
                    </td>
                  </tr>
                ) : (
                  models.map((m) => (
                    <tr key={m.model_id} className="border-b last:border-0 hover:bg-muted/30">
                      <td className="px-4 py-3">
                        <div className="font-medium">{m.model_id}</div>
                        {m.path && (
                          <div className="text-xs text-muted-foreground mt-0.5 truncate max-w-[400px]">
                            {m.path}
                          </div>
                        )}
                      </td>
                      <td className="px-4 py-3">
                        <Badge
                          variant="secondary"
                          className={typeColors[m.model_type] || ""}
                        >
                          {typeLabels[m.model_type] || m.model_type}
                        </Badge>
                      </td>
                      <td className="px-4 py-3 text-muted-foreground">
                        {m.size_human ? (
                          <span className="flex items-center gap-1">
                            <HardDrive className="w-3.5 h-3.5" />
                            {m.size_human}
                          </span>
                        ) : (
                          "-"
                        )}
                      </td>
                      <td className="px-4 py-3">
                        {m.downloaded ? (
                          <Badge variant="outline" className="text-green-600 border-green-200 bg-green-50 dark:bg-green-950 dark:border-green-800">
                            <span className="w-1.5 h-1.5 rounded-full bg-green-500 mr-1.5 inline-block" />
                            已下载
                          </Badge>
                        ) : (
                          <span className="text-muted-foreground text-sm">未下载</span>
                        )}
                      </td>
                      <td className="px-4 py-3 text-right">
                        <div className="flex items-center justify-end gap-2">
                          {!m.downloaded ? (
                            <Button
                              size="sm"
                              onClick={() => handleDownload(m.model_id)}
                              disabled={downloading === m.model_id}
                            >
                              <Download className="w-3.5 h-3.5 mr-1" />
                              {downloading === m.model_id ? "下载中..." : "下载"}
                            </Button>
                          ) : (
                            <Button
                              size="sm"
                              variant="destructive"
                              onClick={() => handleDelete(m.model_id)}
                            >
                              <Trash2 className="w-3.5 h-3.5 mr-1" />
                              删除
                            </Button>
                          )}
                        </div>
                      </td>
                    </tr>
                  ))
                )}
              </tbody>
            </table>
          </div>
        </Card>
      </Main>
    </>
  )
}