Selaa lähdekoodia

Refine models page layout

Jax Docker 1 kuukausi sitten
vanhempi
sitoutus
2d35ed485a
1 muutettua tiedostoa jossa 192 lisäystä ja 195 poistoa
  1. 192 195
      web/src/pages/models/ModelsPage.tsx

+ 192 - 195
web/src/pages/models/ModelsPage.tsx

@@ -255,10 +255,10 @@ export function ModelsPage() {
   if (error) return <ApiErrorState message={error} onRetry={() => void load()} />;
 
   return (
-    <div className="space-y-6">
+    <div className="space-y-5">
       <PageHeader
         title={t("models.title")}
-        description={t("models.credentialsFirstDescription", "Configure a provider once, sync its catalog, and use the generated models everywhere.")}
+        description={t("models.credentialsFirstDescription", "Connect a provider, sync its catalog, and manage usable model entries from one workspace.")}
         actions={
           <>
             <Button variant="outline" onClick={() => void load()}>
@@ -275,27 +275,29 @@ export function ModelsPage() {
         syncedCatalogCount={modelProviders.reduce((total, provider) => total + provider.models.length, 0)}
       />
 
-      <ProviderCredentialPanel
-        existingModels={models}
-        providers={modelProviders}
-        onProviderSaved={upsertProvider}
-        onProviderModelsDiscovered={updateProviderModels}
-        onModelsSynced={() => load()}
-      />
+      <div className="grid items-start gap-5 2xl:grid-cols-[420px_minmax(0,1fr)]">
+        <ProviderCredentialPanel
+          existingModels={models}
+          providers={modelProviders}
+          onProviderSaved={upsertProvider}
+          onProviderModelsDiscovered={updateProviderModels}
+          onModelsSynced={() => load()}
+        />
 
-      <EnabledModelsPanel
-        models={filtered}
-        totalCount={models.length}
-        providers={providers}
-        providerFilter={providerFilter}
-        search={search}
-        onSearchChange={setSearch}
-        onProviderFilterChange={setProviderFilter}
-        onManualCreate={() => setCreateOpen(true)}
-        onEdit={openEdit}
-        onTest={openTest}
-        onDelete={(model) => void removeModel(model)}
-      />
+        <EnabledModelsPanel
+          models={filtered}
+          totalCount={models.length}
+          providers={providers}
+          providerFilter={providerFilter}
+          search={search}
+          onSearchChange={setSearch}
+          onProviderFilterChange={setProviderFilter}
+          onManualCreate={() => setCreateOpen(true)}
+          onEdit={openEdit}
+          onTest={openTest}
+          onDelete={(model) => void removeModel(model)}
+        />
+      </div>
 
       <CreateModelDialog
         open={createOpen}
@@ -373,12 +375,14 @@ function ModelAccessOverview({
   ];
 
   return (
-    <div className="grid gap-3 md:grid-cols-4">
+    <div className="grid gap-0 overflow-hidden rounded-md border border-border bg-surface-elevated md:grid-cols-4">
       {items.map((item) => (
-        <div key={item.label} className="rounded-md border border-border bg-surface-elevated px-4 py-3">
-          <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{item.label}</p>
-          <p className="mt-2 text-2xl font-semibold">{item.value}</p>
-          <p className="mt-1 text-xs text-muted-foreground">{item.hint}</p>
+        <div key={item.label} className="border-border px-4 py-3 md:border-r md:last:border-r-0">
+          <div className="flex items-baseline justify-between gap-3">
+            <p className="truncate text-xs font-medium uppercase tracking-wide text-muted-foreground">{item.label}</p>
+            <p className="shrink-0 text-xl font-semibold tabular-nums">{item.value}</p>
+          </div>
+          <p className="mt-1 truncate text-xs text-muted-foreground">{item.hint}</p>
         </div>
       ))}
     </div>
@@ -413,9 +417,9 @@ function EnabledModelsPanel({
   const { t } = useTranslation();
 
   return (
-    <Card>
-      <CardHeader>
-        <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
+    <Card className="min-w-0">
+      <CardHeader className="pb-4">
+        <div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
           <div>
             <CardTitle>{t("models.enabledModels", "Enabled models")}</CardTitle>
             <CardDescription>
@@ -426,26 +430,25 @@ function EnabledModelsPanel({
               })}
             </CardDescription>
           </div>
-          <Button type="button" variant="outline" onClick={onManualCreate}>
-            <Plus className="h-4 w-4" /> {t("models.manualModel", "Manual model")}
-          </Button>
+          <div className="grid gap-2 sm:grid-cols-[minmax(220px,1fr)_180px_auto] xl:min-w-[640px]">
+            <SearchInput value={search} onChange={onSearchChange} placeholder={t("models.searchPlaceholder")} />
+            <Select
+              value={providerFilter}
+              onChange={(event) => onProviderFilterChange(event.target.value)}
+              options={[
+                { value: "all", label: t("models.allProviders") },
+                ...providers.map((provider) => ({ value: provider, label: providerLabel(provider, t) })),
+              ]}
+            />
+            <Button type="button" variant="outline" onClick={onManualCreate}>
+              <Plus className="h-4 w-4" /> {t("models.manualModel", "Manual model")}
+            </Button>
+          </div>
         </div>
       </CardHeader>
-      <CardContent className="space-y-4">
-        <div className="grid gap-3 lg:grid-cols-[1fr_220px]">
-          <SearchInput value={search} onChange={onSearchChange} placeholder={t("models.searchPlaceholder")} />
-          <Select
-            value={providerFilter}
-            onChange={(event) => onProviderFilterChange(event.target.value)}
-            options={[
-              { value: "all", label: t("models.allProviders") },
-              ...providers.map((provider) => ({ value: provider, label: providerLabel(provider, t) })),
-            ]}
-          />
-        </div>
-
+      <CardContent>
         {models.length ? (
-          <div className="grid gap-3 xl:grid-cols-2">
+          <div className="overflow-hidden rounded-md border border-border">
             {models.map((model) => (
               <ModelCard
                 key={model.id}
@@ -482,51 +485,51 @@ function ModelCard({
   onDelete: () => void;
 }) {
   const { t } = useTranslation();
+  const shownCapabilities = model.capabilities_json.slice(0, 3);
 
   return (
-    <div className="rounded-md border border-border bg-surface-elevated p-4 transition hover:border-primary/35">
-      <div className="flex items-start justify-between gap-3">
-        <div className="min-w-0">
-          <div className="flex items-center gap-2">
-            <BrainCircuit className="h-4 w-4 shrink-0 text-primary" />
-            <h3 className="truncate text-sm font-semibold">{demoText(model.name, t)}</h3>
-          </div>
-          <p className="mt-1 truncate font-mono text-xs text-muted-foreground">{model.model_name}</p>
-        </div>
-        <div className="flex shrink-0 items-center gap-1">
-          <Button size="icon" variant="ghost" onClick={onTest} aria-label={t("models.testNamed", { name: demoText(model.name, t) })}>
-            <FlaskConical className="h-4 w-4" />
-          </Button>
-          <Button size="icon" variant="ghost" onClick={onEdit} aria-label={t("models.editNamed", { name: demoText(model.name, t) })}>
-            <Pencil className="h-4 w-4" />
-          </Button>
-          <Button size="icon" variant="ghost" className="text-destructive hover:text-destructive" onClick={onDelete} aria-label={t("models.deleteNamed", { name: demoText(model.name, t) })}>
-            <Trash2 className="h-4 w-4" />
-          </Button>
+    <div className="grid gap-3 border-b border-border bg-surface-elevated p-4 last:border-b-0 transition hover:bg-muted/20 xl:grid-cols-[minmax(220px,1.15fr)_minmax(180px,0.8fr)_minmax(180px,0.8fr)_126px] xl:items-center">
+      <div className="min-w-0">
+        <div className="flex items-center gap-2">
+          <BrainCircuit className="h-4 w-4 shrink-0 text-primary" />
+          <h3 className="truncate text-sm font-semibold">{demoText(model.name, t)}</h3>
         </div>
+        <p className="mt-1 truncate font-mono text-xs text-muted-foreground">{model.model_name}</p>
       </div>
 
-      <div className="mt-4 grid gap-3 sm:grid-cols-2">
-        <div className="min-w-0 rounded-md bg-muted/25 px-3 py-2">
-          <p className="text-xs font-medium text-muted-foreground">{t("models.provider")}</p>
-          <p className="mt-1 truncate text-sm">{providerLabel(model.provider_type, t)}</p>
-          <p className="mt-1 truncate text-xs text-muted-foreground">{model.provider_base_url}</p>
-        </div>
-        <div className="rounded-md bg-muted/25 px-3 py-2">
-          <p className="text-xs font-medium text-muted-foreground">{t("common.updated")}</p>
-          <p className="mt-1 text-sm">{formatDateTime(model.updated_time)}</p>
-          <p className="mt-1 text-xs text-muted-foreground">
-            {model.has_provider_api_key ? t("models.usingSavedCredential", "Saved credential") : t("models.noCredential", "No credential")}
-          </p>
+      <div className="min-w-0 text-sm">
+        <p className="truncate">{providerLabel(model.provider_type, t)}</p>
+        <p className="mt-1 truncate text-xs text-muted-foreground">{model.provider_base_url}</p>
+      </div>
+
+      <div className="min-w-0">
+        <div className="flex flex-wrap gap-1.5">
+          {shownCapabilities.map((capability) => (
+            <Badge key={capability} className="border-primary/20 bg-primary/10 text-primary">
+              {capabilityLabel(capability, t)}
+            </Badge>
+          ))}
+          {model.capabilities_json.length > shownCapabilities.length ? (
+            <Badge className="border-border bg-muted text-muted-foreground">+{model.capabilities_json.length - shownCapabilities.length}</Badge>
+          ) : null}
         </div>
+        <p className="mt-2 text-xs text-muted-foreground">
+          {model.has_provider_api_key ? t("models.usingSavedCredential", "Saved credential") : t("models.noCredential", "No credential")}
+          <span className="mx-2 text-border">/</span>
+          {formatDateTime(model.updated_time)}
+        </p>
       </div>
 
-      <div className="mt-3 flex flex-wrap gap-1.5">
-        {model.capabilities_json.map((capability) => (
-          <Badge key={capability} className="border-primary/20 bg-primary/10 text-primary">
-            {capabilityLabel(capability, t)}
-          </Badge>
-        ))}
+      <div className="flex items-center gap-1 xl:justify-end">
+        <Button size="icon" variant="ghost" onClick={onTest} aria-label={t("models.testNamed", { name: demoText(model.name, t) })}>
+          <FlaskConical className="h-4 w-4" />
+        </Button>
+        <Button size="icon" variant="ghost" onClick={onEdit} aria-label={t("models.editNamed", { name: demoText(model.name, t) })}>
+          <Pencil className="h-4 w-4" />
+        </Button>
+        <Button size="icon" variant="ghost" className="text-destructive hover:text-destructive" onClick={onDelete} aria-label={t("models.deleteNamed", { name: demoText(model.name, t) })}>
+          <Trash2 className="h-4 w-4" />
+        </Button>
       </div>
     </div>
   );
@@ -758,67 +761,67 @@ function ProviderCredentialPanel({
   }
 
   return (
-    <Card className="overflow-hidden">
-      <CardHeader>
-        <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
+    <Card className="h-fit overflow-hidden 2xl:sticky 2xl:top-5">
+      <CardHeader className="pb-4">
+        <div className="flex items-start justify-between gap-3">
           <div>
             <CardTitle>{t("models.credentialsTitle", "Provider credentials")}</CardTitle>
-            <CardDescription>{t("models.credentialsDescription", "Save one credential, then sync and auto-create every model returned by that provider.")}</CardDescription>
-          </div>
-          <div className="flex flex-wrap gap-2">
-            <Badge className="w-fit border-border bg-muted/50 text-muted-foreground">
-              {providers.length} {t("modelProviders.providers", "providers")}
-            </Badge>
-            <Badge className="w-fit border-primary/20 bg-primary/10 text-primary">
-              {providers.reduce((total, provider) => total + provider.models.length, 0)} {t("modelProviders.models", "models")}
-            </Badge>
+            <CardDescription>{t("models.credentialsDescription", "Save credentials once, then sync model entries automatically.")}</CardDescription>
           </div>
+          <Badge className={providerModelCount ? "shrink-0 border-primary/20 bg-primary/10 text-primary" : "shrink-0 border-border bg-muted text-muted-foreground"}>
+            {providerModelCount} {t("modelProviders.models", "models")}
+          </Badge>
         </div>
       </CardHeader>
-      <CardContent>
-        <div className="grid gap-0 overflow-hidden rounded-md border border-border xl:grid-cols-[340px_1fr]">
-          <div className="border-border bg-muted/15 p-3 xl:border-r">
-            <div className="mb-3">
-              <p className="text-sm font-semibold">{t("models.connections", "Connections")}</p>
-              <p className="text-xs text-muted-foreground">{t("models.connectionListHint", "Choose a saved credential or add a new one.")}</p>
-            </div>
+      <CardContent className="space-y-5">
+        <section>
+          <div className="mb-2 flex items-center justify-between gap-3">
+            <p className="text-sm font-semibold">{t("models.connections", "Connections")}</p>
+            <span className="text-xs text-muted-foreground">
+              {providers.length} {t("modelProviders.providers", "providers")}
+            </span>
+          </div>
+
+          <div className="space-y-2">
             <button
               type="button"
-              className={`flex min-h-16 w-full items-center gap-3 rounded-md border px-3 py-3 text-left transition ${
+              className={`flex min-h-12 w-full items-center gap-3 rounded-md border px-3 py-2 text-left transition ${
                 !form.provider_id ? "border-primary bg-primary/10" : "border-border bg-muted/20 hover:bg-muted/35"
               }`}
               onClick={() => selectProvider("")}
             >
-              <div className="grid h-10 w-10 shrink-0 place-items-center rounded-md bg-surface-elevated text-primary">
+              <span className="grid h-8 w-8 shrink-0 place-items-center rounded-md bg-surface-elevated text-primary">
                 <Plus className="h-4 w-4" />
-              </div>
-              <div className="min-w-0">
-                <p className="text-sm font-medium">{t("models.newProviderConnection", "New connection")}</p>
-                <p className="mt-0.5 text-xs text-muted-foreground">{t("models.newProviderConnectionHint", "Credential first, models next")}</p>
-              </div>
+              </span>
+              <span className="min-w-0">
+                <span className="block truncate text-sm font-medium">{t("models.newProviderConnection", "New connection")}</span>
+                <span className="block truncate text-xs text-muted-foreground">{t("models.newProviderConnectionHint", "Credential first, models next")}</span>
+              </span>
             </button>
 
             {providers.length ? (
-              <div className="mt-3 space-y-2">
+              <div className="max-h-56 space-y-2 overflow-auto pr-1">
                 {providers.map((provider) => (
                   <button
                     key={provider.id}
                     type="button"
-                    className={`flex min-h-16 w-full items-center gap-3 rounded-md border px-3 py-3 text-left transition ${
+                    className={`flex min-h-12 w-full items-center gap-3 rounded-md border px-3 py-2 text-left transition ${
                       form.provider_id === provider.id ? "border-primary bg-primary/10" : "border-border hover:bg-muted/35"
                     }`}
                     onClick={() => selectProvider(provider.id)}
                   >
-                    <div className="grid h-10 w-10 shrink-0 place-items-center rounded-md bg-muted/50 text-muted-foreground">
+                    <span className="grid h-8 w-8 shrink-0 place-items-center rounded-md bg-muted/50 text-muted-foreground">
                       <Server className="h-4 w-4" />
-                    </div>
-                    <div className="min-w-0 flex-1">
-                      <div className="flex items-center gap-2">
-                        <p className="truncate text-sm font-medium">{provider.name}</p>
+                    </span>
+                    <span className="min-w-0 flex-1">
+                      <span className="flex items-center gap-2">
+                        <span className="truncate text-sm font-medium">{provider.name}</span>
                         {provider.api_key_ref ? <ShieldCheck className="h-3.5 w-3.5 shrink-0 text-primary" /> : null}
-                      </div>
-                      <p className="mt-0.5 truncate text-xs text-muted-foreground">{providerLabel(provider.provider_type, t)} - {provider.models.length} {t("modelProviders.models", "models")}</p>
-                    </div>
+                      </span>
+                      <span className="block truncate text-xs text-muted-foreground">
+                        {providerLabel(provider.provider_type, t)} / {provider.models.length} {t("modelProviders.models", "models")}
+                      </span>
+                    </span>
                   </button>
                 ))}
               </div>
@@ -828,88 +831,82 @@ function ProviderCredentialPanel({
               </div>
             )}
           </div>
+        </section>
 
-          <div className="bg-surface-elevated p-4">
-            <div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
-              <div>
-                <h3 className="text-base font-semibold">
-                  {form.provider_id ? t("models.editProviderConnection", "Connection details") : t("models.createProviderConnection", "Create connection")}
-                </h3>
-                <p className="mt-1 text-sm text-muted-foreground">
-                  {form.provider_id
-                    ? t("models.credentialReuseHint", "Update credentials here; synced models keep reusing this connection.")
-                    : t("models.saveCredentialFirstHint", "DeepSeek and OpenAI-compatible providers can be synced immediately after saving.")}
-                </p>
-              </div>
-              <Badge className={providerModelCount ? "w-fit border-primary/20 bg-primary/10 text-primary" : "w-fit border-border bg-muted text-muted-foreground"}>
-                {providerModelCount} {t("modelProviders.models", "models")}
-              </Badge>
-            </div>
+        <section className="space-y-4 border-t border-border pt-4">
+          <div>
+            <h3 className="text-sm font-semibold">
+              {form.provider_id ? t("models.editProviderConnection", "Connection details") : t("models.createProviderConnection", "Create connection")}
+            </h3>
+            <p className="mt-1 text-xs leading-5 text-muted-foreground">
+              {form.provider_id
+                ? t("models.credentialReuseHint", "Update credentials here; synced models keep reusing this connection.")
+                : t("models.saveCredentialFirstHint", "Create the credential, then sync the supported models.")}
+            </p>
+          </div>
 
-            <div className="grid gap-4 lg:grid-cols-[1fr_220px]">
-              <Field label={t("modelProviders.providerName")}>
-                <Input
-                  value={form.name}
-                  onChange={(event) => setField("name", event.target.value)}
-                  placeholder={providerLabel(form.provider_type, t)}
-                />
-              </Field>
-              <Field label={t("models.provider")}>
-                <Select
-                  value={form.provider_type}
-                  onChange={(event) => setProviderType(event.target.value)}
-                  disabled={Boolean(form.provider_id)}
-                  options={providerPresets.map((preset) => ({
-                    value: preset.value,
-                    label: providerLabel(preset.value, t),
-                  }))}
-                />
-              </Field>
-            </div>
+          <Field label={t("modelProviders.providerName")}>
+            <Input
+              value={form.name}
+              onChange={(event) => setField("name", event.target.value)}
+              placeholder={providerLabel(form.provider_type, t)}
+            />
+          </Field>
 
-            <div className="mt-4 grid gap-4 lg:grid-cols-[1fr_300px]">
-              <Field label={t("models.baseUrl")}>
-                <Input value={form.base_url} onChange={(event) => setField("base_url", event.target.value)} />
-              </Field>
-              <Field label={t("models.apiKey")}>
-                <Input
-                  type="password"
-                  value={form.api_key}
-                  onChange={(event) => setField("api_key", event.target.value)}
-                  placeholder={form.provider_id ? t("models.keepSecretPlaceholder") : "sk-..."}
-                />
-              </Field>
-            </div>
+          <div className="grid gap-4 sm:grid-cols-2 2xl:grid-cols-1">
+            <Field label={t("models.provider")}>
+              <Select
+                value={form.provider_type}
+                onChange={(event) => setProviderType(event.target.value)}
+                disabled={Boolean(form.provider_id)}
+                options={providerPresets.map((preset) => ({
+                  value: preset.value,
+                  label: providerLabel(preset.value, t),
+                }))}
+              />
+            </Field>
+            <Field label={t("models.apiKey")}>
+              <Input
+                type="password"
+                value={form.api_key}
+                onChange={(event) => setField("api_key", event.target.value)}
+                placeholder={form.provider_id ? t("models.keepSecretPlaceholder") : "sk-..."}
+              />
+            </Field>
+          </div>
 
-            {selectedProvider?.models.length ? (
-              <div className="mt-5 rounded-md border border-border bg-muted/15 p-3">
-                <div className="mb-2 flex items-center justify-between gap-3">
-                  <p className="text-sm font-medium">{t("models.discoveredModels", "Discovered models")}</p>
-                  <span className="text-xs text-muted-foreground">{selectedProvider.models.length}</span>
-                </div>
-                <div className="flex max-h-24 flex-wrap gap-1.5 overflow-auto">
-                  {selectedProvider.models.slice(0, 12).map((model) => (
-                    <Badge key={model.model_id} className="border-border bg-surface-elevated text-muted-foreground">
-                      {model.display_name || model.model_id}
-                    </Badge>
-                  ))}
-                  {selectedProvider.models.length > 12 ? (
-                    <Badge className="border-border bg-muted text-muted-foreground">+{selectedProvider.models.length - 12}</Badge>
-                  ) : null}
-                </div>
-              </div>
-            ) : null}
+          <Field label={t("models.baseUrl")}>
+            <Input value={form.base_url} onChange={(event) => setField("base_url", event.target.value)} />
+          </Field>
 
-            <div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
-              <Button type="button" variant="outline" onClick={() => void discoverProviderModels()} disabled={discovering || saving || !canSave}>
-                <RefreshCw className={`h-4 w-4 ${discovering ? "animate-spin" : ""}`} /> {discovering ? t("modelProviders.discovering") : t("modelProviders.discoverModels")}
-              </Button>
-              <Button type="button" onClick={() => void saveProvider({ discover: true })} disabled={saving || discovering || !canSave}>
-                <Save className="h-4 w-4" /> {saving ? t("common.saving") : t("models.saveCredential", "Save connection")}
-              </Button>
+          {selectedProvider?.models.length ? (
+            <div className="rounded-md border border-border bg-muted/15 p-3">
+              <div className="mb-2 flex items-center justify-between gap-3">
+                <p className="text-sm font-medium">{t("models.discoveredModels", "Discovered models")}</p>
+                <span className="text-xs text-muted-foreground">{selectedProvider.models.length}</span>
+              </div>
+              <div className="flex max-h-24 flex-wrap gap-1.5 overflow-auto">
+                {selectedProvider.models.slice(0, 10).map((model) => (
+                  <Badge key={model.model_id} className="border-border bg-surface-elevated text-muted-foreground">
+                    {model.display_name || model.model_id}
+                  </Badge>
+                ))}
+                {selectedProvider.models.length > 10 ? (
+                  <Badge className="border-border bg-muted text-muted-foreground">+{selectedProvider.models.length - 10}</Badge>
+                ) : null}
+              </div>
             </div>
+          ) : null}
+
+          <div className="grid gap-2 sm:grid-cols-2 2xl:grid-cols-1">
+            <Button type="button" onClick={() => void saveProvider({ discover: true })} disabled={saving || discovering || !canSave}>
+              <Save className="h-4 w-4" /> {saving ? t("common.saving") : t("models.saveCredential", "Save connection")}
+            </Button>
+            <Button type="button" variant="outline" onClick={() => void discoverProviderModels()} disabled={discovering || saving || !canSave}>
+              <RefreshCw className={`h-4 w-4 ${discovering ? "animate-spin" : ""}`} /> {discovering ? t("modelProviders.discovering") : t("modelProviders.discoverModels")}
+            </Button>
           </div>
-        </div>
+        </section>
       </CardContent>
     </Card>
   );