Merge upstream v0.4.0 into Gitea fork
This commit is contained in:
+24
-67
@@ -10,61 +10,17 @@ import { ModelMenuList, ThinkingMenuList } from "@/components/settings/model-men
|
||||
import { composeSlotModel } from "@/components/settings/agent-section";
|
||||
import { MenuShell } from "@/components/menu-shell";
|
||||
import { Hint } from "@/components/ui/hint";
|
||||
import { notifySettingsSaved, onSettingsSaved, useSettingsSaved } from "@/components/settings-signal";
|
||||
import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal";
|
||||
import { useModelCatalog } from "@/components/model-catalog";
|
||||
export { useModelCatalog } from "@/components/model-catalog";
|
||||
import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default";
|
||||
|
||||
/** GET /api/models is cached per page load for instant menu opens, but
|
||||
* the federation is LIVE per call — keys saved mid-session widen the
|
||||
* catalog immediately server-side. ModelControl therefore re-fetches on
|
||||
* every model-menu open (stale-while-revalidate: the cached list shows
|
||||
* instantly, the fresh one swaps in while the menu is open). */
|
||||
let catalogCache: ModelCatalogResponse | undefined;
|
||||
let catalogInflight: Promise<void> | undefined;
|
||||
let catalogGeneration = 0;
|
||||
let stopCatalogSignals: (() => void) | undefined;
|
||||
const catalogReaders = new Set<() => void>();
|
||||
const publishCatalog = () => { for (const reader of catalogReaders) reader(); };
|
||||
function fetchCatalog(revalidate = false): void {
|
||||
if (revalidate) { catalogGeneration++; catalogInflight = undefined; }
|
||||
if (catalogInflight) return;
|
||||
const generation = catalogGeneration;
|
||||
catalogInflight = fetch("/api/models")
|
||||
.then((r) => { if (!r.ok) throw new Error(`catalog request failed: ${r.status}`); return r.json() as Promise<ModelCatalogResponse>; })
|
||||
.then((c) => { if (generation === catalogGeneration) { catalogCache = c; publishCatalog(); } })
|
||||
.catch(() => { /* existing unavailable/usage-only posture, no retry timer */ })
|
||||
.finally(() => { if (generation === catalogGeneration) catalogInflight = undefined; });
|
||||
}
|
||||
export function useModelCatalog(): { catalog: ModelCatalogResponse | undefined; refresh: () => void } {
|
||||
const [catalog, setCatalog] = useState(catalogCache);
|
||||
useEffect(() => {
|
||||
const first = catalogReaders.size === 0;
|
||||
const read = () => setCatalog(catalogCache);
|
||||
catalogReaders.add(read);
|
||||
if (first) {
|
||||
stopCatalogSignals = onSettingsSaved((section) => {
|
||||
if (section === "keys") {
|
||||
catalogGeneration++; catalogInflight = undefined; catalogCache = undefined;
|
||||
publishCatalog(); fetchCatalog();
|
||||
} else if (section === "models") fetchCatalog(true);
|
||||
});
|
||||
fetchCatalog(true);
|
||||
} else fetchCatalog();
|
||||
read();
|
||||
return () => { catalogReaders.delete(read); if (!catalogReaders.size) { stopCatalogSignals?.(); stopCatalogSignals = undefined; } };
|
||||
}, []);
|
||||
const refresh = useCallback(() => fetchCatalog(true), []);
|
||||
return { catalog, refresh };
|
||||
}
|
||||
|
||||
/** provider:id → contextWindow over the cached catalog — the client-side
|
||||
* twin of the server's catalogContextLimits join (context-meter
|
||||
* amendment, owner 2026-09-23: pi's live catalog is the ONLY limit
|
||||
* source — no hardcoded fallback exists anywhere, so a model missing
|
||||
* from the index reads limit-null and the meter renders usage-only,
|
||||
* the honest-render precedent). Consumed by the stats bar: the same
|
||||
* cached /api/models fetch the picker rides feeds the denominator. */
|
||||
/** provider:id → known contextWindow over the cached catalog, mirroring
|
||||
* the server's catalogContextLimits join. CLI aliases have no global
|
||||
* denominator: the stats bar uses that session's actual CLI report instead.
|
||||
* Missing limits always render usage-only, without a hardcoded fallback. */
|
||||
export function catalogLimits(catalog: Pick<ModelCatalogResponse, "models">): Map<string, number> {
|
||||
return new Map(catalog.models.map((m) => [`${m.provider}:${m.id}`, m.contextWindow]));
|
||||
return new Map(catalog.models.flatMap((m) => m.contextWindow === null ? [] : [[`${m.provider}:${m.id}`, m.contextWindow] as const]));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,7 +43,9 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
|
||||
const { t } = useI18n();
|
||||
const { catalog, refresh } = useModelCatalog();
|
||||
const [models, setModels] = useState<ModelsSettings | undefined>();
|
||||
const [session, setSession] = useState<ChatSettings | undefined>();
|
||||
// A successful read with no settings is a legacy session; undefined
|
||||
// means no session record is known yet, not permission to inherit Strong.
|
||||
const [session, setSession] = useState<{ settings?: ChatSettings }>();
|
||||
const [open, setOpen] = useState<"model" | "thinking" | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -102,8 +60,8 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
|
||||
const readRecord = useCallback(() => {
|
||||
if (chatId) {
|
||||
fetch(`/api/chats/${chatId}`)
|
||||
.then((r) => r.json() as Promise<{ settings?: ChatSettings }>)
|
||||
.then((body) => setSession(body.settings))
|
||||
.then((r) => { if (!r.ok) throw new Error(`session read failed: ${r.status}`); return r.json() as Promise<{ settings?: ChatSettings }>; })
|
||||
.then((body) => setSession({ settings: body.settings }))
|
||||
.catch(() => {
|
||||
/* same read-only posture as a models-section failure */
|
||||
});
|
||||
@@ -127,15 +85,17 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
|
||||
});
|
||||
|
||||
// Dual-bound truth: chat mode displays the session's own record,
|
||||
// falling back to the strong slot for legacy unstamped sessions —
|
||||
// EXACTLY what resolveConversationSpec runs. Draft mode is the strong
|
||||
// slot itself.
|
||||
// falling back to Strong only for a successfully read legacy unstamped
|
||||
// session — EXACTLY what resolveConversationSpec runs. An unresolved or
|
||||
// failed record read must not report a provisional global preference as
|
||||
// this session's identity (including to the Send guard). Draft mode is
|
||||
// the strong slot itself.
|
||||
const strong = models?.strong;
|
||||
const override: ChatSettings | undefined = chatId
|
||||
? {
|
||||
model: session?.model ?? strong?.model,
|
||||
thinkingLevel: session?.thinkingLevel ?? strong?.thinkingLevel,
|
||||
}
|
||||
? session ? {
|
||||
model: session.settings?.model ?? strong?.model,
|
||||
thinkingLevel: session.settings?.thinkingLevel ?? strong?.thinkingLevel,
|
||||
} : undefined
|
||||
: strong;
|
||||
const effectiveId = override?.model?.id;
|
||||
const effectiveProvider = override?.model?.provider;
|
||||
@@ -239,7 +199,7 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
|
||||
return;
|
||||
}
|
||||
const stored = (await res.json().catch(() => null)) as { settings?: ChatSettings } | null;
|
||||
setSession(stored?.settings ?? next);
|
||||
setSession({ settings: stored?.settings ?? next });
|
||||
setOpen(null);
|
||||
} catch {
|
||||
setError("Could not reach the server — try again.");
|
||||
@@ -318,10 +278,7 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
|
||||
return (
|
||||
<div ref={containerRef} className="relative flex items-center gap-1.5" data-testid="model-control">
|
||||
<Hint
|
||||
// Trimmed (owner, subtlety round 2026-09-25): "Model selector"
|
||||
// — the long sentence was way too much. ⌘K opens this same
|
||||
// menu (registry, override-aware).
|
||||
label={t("Model selector")}
|
||||
label={effectiveId ? `${effectiveProvider ? `${effectiveProvider}:` : ""}${effectiveId}` : t("Model selector")}
|
||||
action="open-model-picker"
|
||||
side="bottom"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user