display download size + refactor (#221)
This commit is contained in:
parent
db00ff240f
commit
a9a3e7bfd8
7 changed files with 132 additions and 51 deletions
|
|
@ -70,7 +70,7 @@ impl ModelManager {
|
|||
description: "Fast and fairly accurate.".to_string(),
|
||||
filename: "ggml-small.bin".to_string(),
|
||||
url: Some("https://blob.handy.computer/ggml-small.bin".to_string()),
|
||||
size_mb: 244,
|
||||
size_mb: 487,
|
||||
is_downloaded: false,
|
||||
is_downloading: false,
|
||||
partial_size: 0,
|
||||
|
|
@ -88,7 +88,7 @@ impl ModelManager {
|
|||
description: "Good accuracy, medium speed".to_string(),
|
||||
filename: "whisper-medium-q4_1.bin".to_string(),
|
||||
url: Some("https://blob.handy.computer/whisper-medium-q4_1.bin".to_string()),
|
||||
size_mb: 491, // Approximate size
|
||||
size_mb: 492, // Approximate size
|
||||
is_downloaded: false,
|
||||
is_downloading: false,
|
||||
partial_size: 0,
|
||||
|
|
@ -122,7 +122,7 @@ impl ModelManager {
|
|||
description: "Good accuracy, but slow.".to_string(),
|
||||
filename: "ggml-large-v3-q5_0.bin".to_string(),
|
||||
url: Some("https://blob.handy.computer/ggml-large-v3-q5_0.bin".to_string()),
|
||||
size_mb: 1080, // Approximate size
|
||||
size_mb: 1100, // Approximate size
|
||||
is_downloaded: false,
|
||||
is_downloading: false,
|
||||
partial_size: 0,
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@
|
|||
"label": "main",
|
||||
"title": "Handy",
|
||||
"width": 680,
|
||||
"height": 510,
|
||||
"height": 520,
|
||||
"minWidth": 680,
|
||||
"minHeight": 510,
|
||||
"minHeight": 520,
|
||||
"resizable": true,
|
||||
"maximizable": false,
|
||||
"visible": false
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ function App() {
|
|||
if (showOnboarding) {
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-4 gap-4">
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-4 gap-2">
|
||||
<HandyTextLogo width={200} />
|
||||
<Onboarding onModelSelected={handleModelSelected} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from "react";
|
||||
import { ModelInfo } from "../../lib/types";
|
||||
import { ProgressBar, ProgressData } from "../shared";
|
||||
import { formatModelSize } from "../../lib/utils/format";
|
||||
import { ProgressBar } from "../shared";
|
||||
|
||||
interface DownloadProgress {
|
||||
model_id: string;
|
||||
|
|
@ -178,6 +179,9 @@ const ModelDropdown: React.FC<ModelDropdownProps> = ({
|
|||
<div className="text-xs text-text/40 italic pr-4">
|
||||
{model.description}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-text/50 tabular-nums">
|
||||
Download size · {formatModelSize(model.size_mb)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-logo-primary tabular-nums">
|
||||
{isDownloading && progress ? (
|
||||
|
|
|
|||
80
src/components/onboarding/ModelCard.tsx
Normal file
80
src/components/onboarding/ModelCard.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import React from "react";
|
||||
import { Download } from "lucide-react";
|
||||
import { ModelInfo } from "../../lib/types";
|
||||
import { formatModelSize } from "../../lib/utils/format";
|
||||
|
||||
interface ModelCardProps {
|
||||
model: ModelInfo;
|
||||
variant?: "default" | "featured";
|
||||
disabled?: boolean;
|
||||
badgeText?: string;
|
||||
className?: string;
|
||||
onSelect: (modelId: string) => void;
|
||||
}
|
||||
|
||||
const ModelCard: React.FC<ModelCardProps> = ({
|
||||
model,
|
||||
variant = "default",
|
||||
disabled = false,
|
||||
badgeText,
|
||||
className = "",
|
||||
onSelect,
|
||||
}) => {
|
||||
const isFeatured = variant === "featured";
|
||||
|
||||
const baseButtonClasses =
|
||||
"relative rounded-xl p-3 p-4 text-left transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-logo-primary/25 active:scale-[0.98] cursor-pointer group";
|
||||
|
||||
const variantClasses = isFeatured
|
||||
? "border-2 border-logo-primary/40 bg-logo-primary/5 hover:border-logo-primary/60 hover:bg-logo-primary/10 hover:shadow-lg hover:scale-[1.02] disabled:hover:border-logo-primary/40 disabled:hover:bg-logo-primary/5 disabled:hover:shadow-none disabled:hover:scale-100"
|
||||
: "border-2 border-mid-gray/20 hover:border-logo-primary/50 hover:bg-logo-primary/5 hover:shadow-lg hover:scale-[1.02] disabled:hover:border-mid-gray/20 disabled:hover:bg-transparent disabled:hover:shadow-none disabled:hover:scale-100";
|
||||
|
||||
const titleClasses =
|
||||
"text-lg font-semibold text-text group-hover:text-logo-primary transition-colors";
|
||||
|
||||
const descriptionClasses = "text-text/60 text-sm leading-relaxed";
|
||||
|
||||
const sizeRowClasses =
|
||||
"mt-1 flex items-center gap-2 text-xs text-text/60 tabular-nums";
|
||||
|
||||
const containerSpacing = "space-y-3";
|
||||
|
||||
const sizeIconClasses = "h-3.5 w-3.5 text-text/45";
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onSelect(model.id)}
|
||||
disabled={disabled}
|
||||
className={[baseButtonClasses, variantClasses, className]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
type="button"
|
||||
>
|
||||
{badgeText && (
|
||||
<div className="absolute -top-2 -right-2 bg-logo-primary text-white text-sm px-4 py-2 rounded-full font-medium shadow-md">
|
||||
{badgeText}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={containerSpacing}>
|
||||
<div className="space-y-0">
|
||||
<h3 className={titleClasses}>{model.name}</h3>
|
||||
<p className={descriptionClasses}>{model.description}</p>
|
||||
<div className={sizeRowClasses}>
|
||||
<Download
|
||||
aria-hidden="true"
|
||||
className={sizeIconClasses}
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
<span className="sr-only">Download size</span>
|
||||
<span className="font-medium text-text/70">
|
||||
{formatModelSize(model.size_mb)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelCard;
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { ModelInfo } from "../../lib/types";
|
||||
import ModelCard from "./ModelCard";
|
||||
|
||||
interface OnboardingProps {
|
||||
onModelSelected: () => void;
|
||||
|
|
@ -58,57 +59,32 @@ const Onboarding: React.FC<OnboardingProps> = ({ onModelSelected }) => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-w-2xl mx-auto space-y-4">
|
||||
{/* Recommended model - full width */}
|
||||
{availableModels
|
||||
.filter((model) => getRecommendedBadge(model.id))
|
||||
.map((model) => (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() => handleDownloadModel(model.id)}
|
||||
disabled={downloading}
|
||||
className="relative w-full border-2 border-logo-primary/40 bg-logo-primary/5 rounded-xl p-4 text-left hover:border-logo-primary/60 hover:bg-logo-primary/10 hover:shadow-lg hover:scale-[1.02] focus:border-logo-primary focus:outline-none focus:ring-2 focus:ring-logo-primary/25 active:scale-[0.98] transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-logo-primary/40 disabled:hover:bg-logo-primary/5 disabled:hover:shadow-none disabled:hover:scale-100 cursor-pointer group"
|
||||
>
|
||||
<div className="absolute -top-2 -right-2 bg-logo-primary text-white text-sm px-4 py-2 rounded-full font-medium shadow-md">
|
||||
Recommended
|
||||
</div>
|
||||
<div className="max-w-2xl mx-auto mt-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{availableModels
|
||||
.filter((model) => getRecommendedBadge(model.id))
|
||||
.map((model) => (
|
||||
<ModelCard
|
||||
key={model.id}
|
||||
model={model}
|
||||
variant="featured"
|
||||
badgeText="Recommended"
|
||||
disabled={downloading}
|
||||
className="sm:col-span-2"
|
||||
onSelect={handleDownloadModel}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-0">
|
||||
<h3 className="text-xl sm:text-2xl font-bold text-text group-hover:text-logo-primary transition-colors">
|
||||
{model.name}
|
||||
</h3>
|
||||
<p className="text-text/70 text-sm sm:text-base leading-relaxed">
|
||||
{model.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Other models - 2 column grid */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{availableModels
|
||||
.filter((model) => !getRecommendedBadge(model.id))
|
||||
.sort((a, b) => a.size_mb - b.size_mb)
|
||||
.map((model) => (
|
||||
<button
|
||||
<ModelCard
|
||||
key={model.id}
|
||||
onClick={() => handleDownloadModel(model.id)}
|
||||
model={model}
|
||||
disabled={downloading}
|
||||
className="relative border-2 border-mid-gray/20 rounded-xl sm:p-4 text-left hover:border-logo-primary/50 hover:bg-logo-primary/5 hover:shadow-lg hover:scale-[1.02] focus:border-logo-primary focus:outline-none focus:ring-2 focus:ring-logo-primary/25 active:scale-[0.98] transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-mid-gray/20 disabled:hover:bg-transparent disabled:hover:shadow-none disabled:hover:scale-100 cursor-pointer group"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg sm:text-xl font-semibold text-text group-hover:text-logo-primary transition-colors">
|
||||
{model.name}
|
||||
</h3>
|
||||
<p className="text-text/60 text-xs sm:text-sm leading-relaxed">
|
||||
{model.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
onSelect={handleDownloadModel}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
21
src/lib/utils/format.ts
Normal file
21
src/lib/utils/format.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
export const formatModelSize = (sizeMb: number | null | undefined): string => {
|
||||
if (!sizeMb || !Number.isFinite(sizeMb) || sizeMb <= 0) {
|
||||
return "Unknown size";
|
||||
}
|
||||
|
||||
if (sizeMb >= 1024) {
|
||||
const sizeGb = sizeMb / 1024;
|
||||
const formatter = new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: sizeGb >= 10 ? 0 : 1,
|
||||
maximumFractionDigits: sizeGb >= 10 ? 0 : 1,
|
||||
});
|
||||
return `${formatter.format(sizeGb)} GB`;
|
||||
}
|
||||
|
||||
const formatter = new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: sizeMb >= 100 ? 0 : 1,
|
||||
maximumFractionDigits: sizeMb >= 100 ? 0 : 1,
|
||||
});
|
||||
|
||||
return `${formatter.format(sizeMb)} MB`;
|
||||
};
|
||||
Loading…
Reference in a new issue