Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
273 changes: 167 additions & 106 deletions app/[locale]/_components/model-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { ByokConfig } from "@/lib/domain/model/byok";
import { BYOK_PROVIDERS, findProvider, type ProviderId } from "@/lib/domain/model/providers";
import type { Dictionary } from "@/lib/i18n";


/**
* Connect your own model.
*
Expand All @@ -22,6 +23,18 @@ import type { Dictionary } from "@/lib/i18n";
* 2. It does not hide where the key goes. That is stated plainly above the
* field, not linked from a footer.
*/
type Probe = { provider: ProviderId; key: string; n: number };
type Result = {
state: "idle" | "checking" | "ok" | "error";
models: string[];
suggested: string | null;
message: string;
reachable: boolean;
};
const IDLE: Result = { state: "idle", models: [], suggested: null, message: "", reachable: true };
/** Long enough to finish pasting, short enough to feel immediate. */
const CHECK_AFTER_MS = 600;

export function ModelSheet({
t,
current,
Expand All @@ -37,67 +50,90 @@ export function ModelSheet({
}) {
const [providerId, setProviderId] = useState<ProviderId>(current?.provider ?? "openrouter");
const [key, setKey] = useState(current?.key ?? "");
const [state, setState] = useState<"idle" | "testing" | "ok" | "error">("idle");
const [detail, setDetail] = useState("");
const [model, setModel] = useState(current?.model ?? "");
const [result, setResult] = useState<Result>(IDLE);
const [probe, setProbe] = useState<Probe | null>(null);
const [saved, setSaved] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const seq = useRef(0);
const dialogRef = useRef<HTMLDivElement>(null);

const provider = findProvider(providerId);

/**
* The model is DERIVED, not synced. Only what the person typed is state; the
* default comes from the chosen provider — its vision model first, since
* reading a picture is why most people are here.
*
* The obvious version is an effect that writes the default into state when
* the provider changes, and it causes a cascading render for a value that
* was always computable. Deriving it also makes "changed provider" reset the
* choice by construction rather than by remembering to clear it.
*/
const [typedModel, setTypedModel] = useState<string | null>(current?.model ?? null);
const model = typedModel ?? provider?.visionModel ?? provider?.textModel ?? "";
useDismiss({ open: true, onDismiss: onClose, containerRef: dialogRef, onPointerOutside: false });

useEffect(() => {
dialogRef.current?.focus();
}, []);

/**
* Escape closes it. No outside-pointer dismissal: this sheet holds a key the
* person is part-way through typing, and losing it to a stray click on the
* backdrop would mean fetching the credential again.
*
* No `focusRef` either — the control that opened this sheet is frequently
* gone by the time it closes (the attach button belongs to a composer that
* may have been replaced), so focus goes to the dialog on open and the
* closing path lets the caller decide.
* Ask the server to check a key — debounced, from the event that changed
* it. A key already saved is checked once on open, so the model list is
* there without the reader doing anything.
*/
useDismiss({ open: true, onDismiss: onClose, containerRef: dialogRef, onPointerOutside: false });
function schedule(nextProvider: ProviderId, nextKey: string, delay = CHECK_AFTER_MS) {
clearTimeout(timer.current);
setSaved(false);
if (nextKey.trim().length < 8) {
setResult(IDLE);
return;
}
timer.current = setTimeout(() => {
setResult({ ...IDLE, state: "checking" });
seq.current += 1;
setProbe({ provider: nextProvider, key: nextKey.trim(), n: seq.current });
}, delay);
}

useEffect(() => {
dialogRef.current?.focus();
if (current) {
const id = setTimeout(() => schedule(current.provider, current.key, 0), 0);
return () => clearTimeout(id);
}
// Once, on open: `current` is the saved config at the moment the sheet opened.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

async function test() {
useEffect(() => () => clearTimeout(timer.current), []);

useEffect(() => {
if (!probe) return;
let live = true;
fetch("/api/model/check", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ provider: probe.provider, key: probe.key }),
})
.then((res) => res.json() as Promise<Omit<Result, "state"> & { ok: boolean }>)
.then((data) => {
if (!live || probe.n !== seq.current) return;
setResult({
state: data.ok ? "ok" : "error",
models: data.models ?? [],
suggested: data.suggested ?? null,
message: data.message ?? "",
reachable: data.reachable !== false,
});
// Keep the reader's saved choice when this key can still use it;
// otherwise preselect the strongest model the key reaches.
if (data.ok) {
setModel((m) => ((data.models ?? []).includes(m) ? m : (data.suggested ?? data.models?.[0] ?? m)));
}
})
.catch(() => live && setResult({ ...IDLE, state: "error", reachable: false }));
return () => {
live = false;
};
}, [probe]);

function use() {
if (!provider || !key.trim() || !model.trim()) return;
setState("testing");
setDetail("");
const config: ByokConfig = { provider: provider.id, key: key.trim(), model: model.trim() };
try {
const res = await fetch("/api/model/check", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ byok: config }),
});
const data = await res.json();
if (data.ok) {
setState("ok");
setDetail(data.model ?? model);
onSave(config);
} else {
setState("error");
setDetail(String(data.reason ?? ""));
}
} catch {
setState("error");
setDetail("");
}
onSave({ provider: provider.id, key: key.trim(), model: model.trim() });
setSaved(true);
}

const listed = result.state === "ok" && result.models.length > 0;

return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-fg-primary/30 p-0 sm:items-center sm:p-6">
<div
Expand Down Expand Up @@ -127,7 +163,7 @@ export function ModelSheet({
{current && (
<p className="mt-4 flex flex-wrap items-center gap-2 rounded-control border border-ok bg-surface-page px-3 py-2 text-sm">
<span className="font-mono text-caption uppercase tracking-caps text-ok">{t.connected}</span>
<span className="text-fg-primary">
<span className="min-w-0 break-all text-fg-primary">
{t.connectedWith} {current.model}
</span>
</p>
Expand All @@ -142,36 +178,28 @@ export function ModelSheet({
id="byok-provider"
value={providerId}
onChange={(e) => {
setProviderId(e.target.value as ProviderId);
setTypedModel(null);
setState("idle");
const next = e.target.value as ProviderId;
setProviderId(next);
setModel("");
schedule(next, key);
}}
className="mt-1 min-h-11 w-full rounded-control border border-border-strong bg-surface-page px-3 text-base text-fg-primary"
>
{/* Label only. The `note` on each provider is English source
copy for maintainers, and putting it here leaked English onto
a German page — the localised badge below already carries the
one fact that decides the choice. */}
{BYOK_PROVIDERS.map((p) => (
<option key={p.id} value={p.id}>
{p.label}
</option>
))}
</select>
{provider && (
<p className="mt-1 flex flex-wrap items-center gap-2">
<span className="font-mono text-caption uppercase tracking-caps text-fg-muted">
{provider.visionModel ? t.canSee : t.textOnly}
</span>
<a
href={provider.keysUrl}
target="_blank"
rel="noreferrer noopener"
className="text-sm text-link underline underline-offset-4 hover:text-accent"
>
{t.getKey} ↗
</a>
</p>
<a
href={provider.keysUrl}
target="_blank"
rel="noreferrer noopener"
className="mt-1 inline-flex min-h-11 items-center text-sm text-link underline underline-offset-4 hover:text-accent"
>
{t.getKey} ↗
</a>
)}
</div>

Expand All @@ -187,46 +215,76 @@ export function ModelSheet({
value={key}
onChange={(e) => {
setKey(e.target.value);
setState("idle");
schedule(providerId, e.target.value);
}}
placeholder={provider?.keyPrefix ? `${provider.keyPrefix}…` : t.keyPlaceholder}
placeholder={provider?.keyHint || t.keyPlaceholder}
className="mt-1 min-h-11 w-full rounded-control border border-border-strong bg-surface-page px-3 font-mono text-sm text-fg-primary"
/>
<p className="mt-1 text-sm text-fg-muted" role="status">
{result.state === "checking" ? t.testing : result.state === "idle" ? t.pasteHint : null}
</p>
</div>

<div>
<label htmlFor="byok-model" className="font-mono text-caption uppercase tracking-caps text-fg-muted">
{t.modelLabel}
</label>
<input
id="byok-model"
value={model}
onChange={(e) => {
setTypedModel(e.target.value);
setState("idle");
}}
spellCheck={false}
className="mt-1 min-h-11 w-full rounded-control border border-border-strong bg-surface-page px-3 font-mono text-sm text-fg-primary"
/>
</div>
{result.state === "ok" && (
<div>
<label htmlFor="byok-model" className="font-mono text-caption uppercase tracking-caps text-fg-muted">
{t.modelLabel}
</label>
{listed ? (
<select
id="byok-model"
value={model}
onChange={(e) => {
setModel(e.target.value);
setSaved(false);
}}
className="mt-1 min-h-11 w-full rounded-control border border-border-strong bg-surface-page px-3 font-mono text-sm text-fg-primary"
>
{result.models.map((m) => (
<option key={m} value={m}>
{m === result.suggested ? `${m} — ${t.strongest}` : m}
</option>
))}
</select>
) : (
// Only when the vendor answered but listed nothing: then the
// reader types the id, with the vendor's own example as a hint.
<input
id="byok-model"
value={model}
onChange={(e) => {
setModel(e.target.value);
setSaved(false);
}}
placeholder={provider?.modelExample}
spellCheck={false}
className="mt-1 min-h-11 w-full rounded-control border border-border-strong bg-surface-page px-3 font-mono text-sm text-fg-primary"
/>
)}
</div>
)}
</div>

<div className="mt-5 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => void test()}
disabled={state === "testing" || !key.trim() || !model.trim()}
className="inline-flex min-h-11 items-center rounded-control bg-action px-5 font-medium text-on-action disabled:opacity-40"
>
{state === "testing" ? t.testing : t.test}
</button>
{result.state === "ok" && (
<button
type="button"
onClick={use}
disabled={!model.trim()}
className="inline-flex min-h-11 items-center rounded-control bg-action px-5 font-medium text-on-action disabled:opacity-40"
>
{t.test}
</button>
)}
{current && (
<button
type="button"
onClick={() => {
onClear();
setKey("");
setState("idle");
setModel("");
setResult(IDLE);
setSaved(false);
}}
className="min-h-11 text-sm text-link underline underline-offset-4 hover:text-accent"
>
Expand All @@ -235,20 +293,23 @@ export function ModelSheet({
)}
</div>

{state === "ok" && (
<p role="status" className="mt-3 text-sm text-ok">
{t.connected} · {detail}
{saved && (
<p role="status" className="mt-3 break-all text-sm text-ok">
{t.connected} · {model}
</p>
)}
{/* The server's own `reason` used to be appended here. Those strings
are written once, in English, for a log — and this sheet is read in
seven languages. The localised sentence stands alone; what actually
went wrong is in the server log, where a reader cannot act on it
anyway. (`detail` on success is a MODEL ID, not prose.) */}
{state === "error" && (
<p role="alert" className="mt-3 break-words text-sm text-danger">
{t.failed}
</p>
{result.state === "error" && (
<div role="alert" className="mt-3 text-sm">
{/* Our sentence first, in the reader's language; then the vendor's
own words, marked as theirs — usually the part that says what
to do ("insufficient credits"). Never our server's English. */}
<p className="text-danger">{result.reachable ? t.failed : t.unreachable}</p>
{result.reachable && result.message && (
<p className="mt-1 break-words text-fg-secondary">
{t.vendorSaid} <q className="font-mono text-caption">{result.message}</q>
</p>
)}
</div>
)}

<div className="mt-6 border-t border-border-subtle pt-4">
Expand Down
13 changes: 6 additions & 7 deletions app/[locale]/_components/use-byok.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,19 +58,18 @@ export function useByok() {
* read pictures" — that was never true of free models, only of a chain
* with no vision routing.
*
* A CONNECTED key still decides for itself, and this is the case that
* keeps the check alive rather than deleting it: a brought key REPLACES
* the free chain (see `respondInThread`), so someone connected to Groq or
* DeepSeek — text-only, both in the allowlist — genuinely cannot send a
* picture, and telling them to attach one would be a worse lie than the
* old one.
* A CONNECTED key no longer decides from a per-vendor flag. That flag
* was a guess written into a table ("Groq: text only"), and vendors add
* vision models without telling us; ai-kit routes on the model's observed
* capability. So the button is offered, and a text-only model answers
* `blind` in one sentence — the chain's answer, not this file's guess.
*
* Optimistic where we have no evidence, which matches what the chain does
* underneath: if the deployment turns out to have no sighted vendor keyed,
* the turn comes back `blind` and says so in one sentence. Refusing up
* front would be this file guessing at something the chain answers.
*/
canSee: config ? Boolean(provider?.visionModel) : true,
canSee: true,
save,
clear,
};
Expand Down
Loading
Loading