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, useRef, useCallback } from "react"
import { invoke } from "@tauri-apps/api/core"
import { listen } from "@tauri-apps/api/event"
import { Play, Square, Terminal, RotateCw, Server } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
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"

interface ModelInfo {
  model_id: string
  owner: string
  model_type: string
  downloaded: boolean
}

interface LaunchConfig {
  model_id: string
  address: string
  port: number
  weight_path?: string | null
  save_dir?: string | null
  gguf_path?: string | null
  mmproj_path?: string | null
}

interface ServerStatus {
  running: boolean
  pid: number | null
  logs: string[]
}

export function LaunchPage() {
  const [models, setModels] = useState<ModelInfo[]>([])
  const [selectedModel, setSelectedModel] = useState("")
  const [address, setAddress] = useState("127.0.0.1")
  const [port, setPort] = useState("10100")
  const [weightPath, setWeightPath] = useState("")
  const [status, setStatus] = useState<ServerStatus>({ running: false, pid: null, logs: [] })
  const logEndRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    invoke<ModelInfo[]>("list_models").then(setModels).catch(() => {})
  }, [])

  const refreshStatus = useCallback(async () => {
    try {
      const s = await invoke<ServerStatus>("get_server_status")
      setStatus(s)
    } catch {}
  }, [])

  useEffect(() => {
    const unlistens: (() => void)[] = []

    listen<string>("server-log", (event) => {
      setStatus((prev) => ({
        ...prev,
        logs: [...prev.logs.slice(-1999), event.payload],
      }))
    }).then((fn) => unlistens.push(fn))

    listen<number>("server-started", () => {
      refreshStatus()
    }).then((fn) => unlistens.push(fn))

    listen("server-stopped", () => {
      refreshStatus()
    }).then((fn) => unlistens.push(fn))

    return () => unlistens.forEach((fn) => fn())
  }, [refreshStatus])

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

  useEffect(() => {
    logEndRef.current?.scrollIntoView({ behavior: "smooth" })
  }, [status.logs])

  const handleStart = async () => {
    if (!selectedModel) return
    try {
      await invoke("start_server", {
        config: {
          model_id: selectedModel,
          address,
          port: parseInt(port) || 10100,
          weight_path: weightPath || null,
          save_dir: null,
          gguf_path: null,
          mmproj_path: null,
        } satisfies LaunchConfig,
      })
    } catch (e) {
      setStatus((prev) => ({
        ...prev,
        logs: [...prev.logs, `[错误] ${e}`],
      }))
    }
  }

  const handleStop = async () => {
    try {
      await invoke("stop_server")
    } catch (e) {
      setStatus((prev) => ({
        ...prev,
        logs: [...prev.logs, `[错误] ${e}`],
      }))
    }
  }

  const handleClearLogs = async () => {
    try {
      await invoke("clear_logs")
      setStatus((prev) => ({ ...prev, logs: [] }))
    } catch {}
  }

  const downloadedModels = models.filter((m) => m.downloaded)

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

      <Main>
        <div className="mb-6">
          <h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
            <Server className="w-6 h-6" />
            启动服务
          </h1>
          <p className="text-muted-foreground text-sm mt-1">
            配置并启动模型推理服务
          </p>
        </div>

        <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
          {/* 配置区 */}
          <Card className="p-4 space-y-4">
            <h3 className="text-sm font-medium">启动配置</h3>

            <div className="space-y-2">
              <Label htmlFor="model-select">模型</Label>
              <select
                id="model-select"
                value={selectedModel}
                onChange={(e) => setSelectedModel(e.target.value)}
                className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
                disabled={status.running}
              >
                <option value="">选择模型...</option>
                {downloadedModels.map((m) => (
                  <option key={m.model_id} value={m.model_id}>
                    {m.model_id}
                  </option>
                ))}
              </select>
              {downloadedModels.length === 0 && (
                <p className="text-xs text-muted-foreground">
                  没有已下载的模型,请先在"模型列表"页面下载模型
                </p>
              )}
            </div>

            <div className="grid grid-cols-2 gap-3">
              <div className="space-y-2">
                <Label htmlFor="address">监听地址</Label>
                <Input
                  id="address"
                  value={address}
                  onChange={(e) => setAddress(e.target.value)}
                  disabled={status.running}
                />
              </div>
              <div className="space-y-2">
                <Label htmlFor="port">端口</Label>
                <Input
                  id="port"
                  value={port}
                  onChange={(e) => setPort(e.target.value)}
                  disabled={status.running}
                />
              </div>
            </div>

            <div className="space-y-2">
              <Label htmlFor="weight-path">
                权重路径 <span className="text-muted-foreground font-normal">(可选)</span>
              </Label>
              <Input
                id="weight-path"
                value={weightPath}
                onChange={(e) => setWeightPath(e.target.value)}
                placeholder="留空则使用默认路径"
                disabled={status.running}
              />
            </div>
          </Card>

          {/* 状态 + 控制 */}
          <Card className="p-4 flex flex-col">
            <h3 className="text-sm font-medium mb-3">服务状态</h3>
            <div className="flex-1 space-y-2 text-sm mb-4">
              <div className="flex items-center gap-2">
                <span
                  className={`w-2.5 h-2.5 rounded-full inline-block ${
                    status.running ? "bg-green-500" : "bg-gray-300"
                  }`}
                />
                <span>{status.running ? "运行中" : "已停止"}</span>
              </div>
              {status.pid && (
                <div className="text-muted-foreground">PID: {status.pid}</div>
              )}
              {selectedModel && (
                <div className="text-muted-foreground">模型: {selectedModel}</div>
              )}
            </div>

            <div className="flex gap-2">
              {!status.running ? (
                <Button
                  onClick={handleStart}
                  disabled={!selectedModel}
                  className="flex-1"
                >
                  <Play className="w-4 h-4 mr-1.5" />
                  启动服务
                </Button>
              ) : (
                <Button
                  onClick={handleStop}
                  variant="destructive"
                  className="flex-1"
                >
                  <Square className="w-4 h-4 mr-1.5" />
                  停止服务
                </Button>
              )}
            </div>
          </Card>
        </div>

        {/* 日志区域 */}
        <Card className="flex-1 min-h-0">
          <div className="flex items-center justify-between p-4 border-b">
            <div className="flex items-center gap-2 text-sm font-medium">
              <Terminal className="w-4 h-4" />
              日志输出
            </div>
            <Button variant="outline" size="sm" onClick={handleClearLogs}>
              <RotateCw className="w-3 h-3 mr-1" />
              清空
            </Button>
          </div>
          <div className="bg-[#1e1e2e] rounded-b-lg p-4 h-64 overflow-y-auto font-mono text-sm leading-relaxed">
            {status.logs.length === 0 ? (
              <div className="text-gray-500 italic">等待日志输出...</div>
            ) : (
              <div className="space-y-0.5">
                {status.logs.map((line, i) => (
                  <div key={i} className="text-gray-300 whitespace-pre-wrap break-all">
                    {line}
                  </div>
                ))}
                <div ref={logEndRef} />
              </div>
            )}
          </div>
        </Card>
      </Main>
    </>
  )
}