feat(catalogue): refresh is a button, not a schedule
The daily job is gone. A refresh only matters at one moment — you are picking a provider and want to know its models are current — and at that moment a button answers the question, where a nightly pass can only have already answered it, invisibly, or not. It also bought a standing obligation: something hitting a vendor from every install forever whether anyone opens Atelier or not. So the add dialog is now the curated file made visible. Provider comes from the catalogue, models come from that provider's entry in it, and two buttons re-read it from the vendor: one for the provider in front of you, one for all of them. `POST /providers/catalogue/refresh?provider=` is the only trigger there is. The dialog also says how old the answer is — "as shipped" until a refresh has ever run, which is worth showing, because it means the models listed are exactly what the plugin was released with. A provider with no refresher answers 400 rather than a silent success: from a button, "nothing happened" and "nothing could happen" look identical.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Check, KeyRound, Plus, Trash2 } from 'lucide-react';
|
||||
import { Check, KeyRound, Plus, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -114,6 +114,14 @@ export const ProvidersBar = ({ manager }: { manager: ProvidersManager }) => {
|
||||
|
||||
const label = (provider: ProviderStatus) => `${provider.adapter} · ${provider.model}`;
|
||||
|
||||
/** "never refreshed" is a fact worth showing: it means these models are exactly what the plugin shipped. */
|
||||
const freshness = (updatedAt?: string | null): string => {
|
||||
if (!updatedAt) return 'as shipped';
|
||||
const days = Math.floor((Date.now() - new Date(updatedAt).getTime()) / 86_400_000);
|
||||
if (days <= 0) return 'refreshed today';
|
||||
return `${days}d old`;
|
||||
};
|
||||
|
||||
type DialogState = null | { mode: 'add' } | { mode: 'key'; provider: ProviderStatus };
|
||||
|
||||
/** Add a provider, or set the key on one that has none. Same dialog, since the key field is shared. */
|
||||
@@ -176,8 +184,14 @@ const ProviderDialog = ({
|
||||
>
|
||||
{state?.mode === 'add' && (
|
||||
<>
|
||||
{/*
|
||||
The provider list is the CURATED FILE — the providers Officer decided are worth
|
||||
offering — and the models under it are that file's current answer for the one chosen.
|
||||
Neither is read from the vendor here: the file is the offer, and refreshing it is a
|
||||
deliberate act with its own button rather than something that happens on a timer.
|
||||
*/}
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-xs text-muted-foreground">Adapter</span>
|
||||
<span className="text-xs text-muted-foreground">Provider</span>
|
||||
<Select
|
||||
value={adapter}
|
||||
onValueChange={(value) => {
|
||||
@@ -199,20 +213,44 @@ const ProviderDialog = ({
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-xs text-muted-foreground">Model</span>
|
||||
<span className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
Model
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
<span className="text-muted-foreground/60">{freshness(manager.adapterFor(adapter)?.updated_at)}</span>
|
||||
{/* This provider's models, from the vendor, now. Slow — it is a live fetch. */}
|
||||
<button
|
||||
type="button"
|
||||
title={`Refresh ${manager.adapterFor(adapter)?.label ?? 'this provider'}'s models and prices`}
|
||||
disabled={manager.refreshing || !manager.adapterFor(adapter)?.refreshable}
|
||||
onClick={() => void manager.refreshCatalogue(adapter)}
|
||||
className="rounded p-1 hover:bg-accent disabled:opacity-40"
|
||||
>
|
||||
<RefreshCw className={manager.refreshing ? 'size-3 animate-spin' : 'size-3'} />
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
<Select value={model} onValueChange={setModel}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Choose" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{manager.modelsFor(adapter).map((entry) => (
|
||||
<SelectItem key={entry} value={entry}>
|
||||
{entry}
|
||||
{(manager.adapterFor(adapter)?.model_details ?? []).map((entry) => (
|
||||
<SelectItem key={entry.id} value={entry.id}>
|
||||
{entry.label ?? entry.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={manager.refreshing}
|
||||
onClick={() => void manager.refreshCatalogue()}
|
||||
className="justify-self-start text-xs text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||
>
|
||||
{manager.refreshing ? 'Refreshing…' : 'Refresh all providers'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export const useProviders = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
@@ -65,11 +66,35 @@ export const useProviders = () => {
|
||||
return client.updateProvider(created.id, { apiKey: apiKey.trim() });
|
||||
});
|
||||
|
||||
/**
|
||||
* Re-read the catalogue from the vendor, then re-read our copy of it.
|
||||
*
|
||||
* Separate busy flag from `busy`: this one is a live fetch against OpenAI and takes seconds, where
|
||||
* everything else here is a local database write. Sharing one flag would grey out the whole dialog
|
||||
* for the length of a network round trip to a third party.
|
||||
*/
|
||||
const refreshCatalogue = async (provider?: string) => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await client.refreshCatalogue(provider);
|
||||
setAdapters(await client.listAdapters());
|
||||
setError(null);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
providers,
|
||||
adapters,
|
||||
loading,
|
||||
busy,
|
||||
refreshing,
|
||||
refreshCatalogue,
|
||||
error,
|
||||
refresh,
|
||||
add,
|
||||
|
||||
Reference in New Issue
Block a user