Merge upstream v0.4.0 into Gitea fork

This commit is contained in:
2026-10-31 13:45:06 +03:00
286 changed files with 24403 additions and 2546 deletions
+21 -57
View File
@@ -57,6 +57,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { MenuShell } from "@/components/menu-shell";
import { useSettingsSaved } from "@/components/settings-signal";
import { defaultOrderLevel, populateLevel } from "@/components/thinking-default";
import { useModelCatalog } from "@/components/model-catalog";
import { Hint } from "@/components/ui/hint";
import type React from "react";
import {
COMPACTION_THRESHOLD_FLOOR,
@@ -65,7 +67,6 @@ import {
type AgentSettings,
type CompactionSettings,
type GraphSettings,
type ModelCatalogEntry,
type ModelSlotName,
type ModelsSettings,
} from "@/src/core";
@@ -73,8 +74,7 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { prettyModel } from "@/components/usage-format";
import { cn } from "@/lib/utils";
import { ZapIcon } from "lucide-react";
import { Hint } from "@/components/ui/hint";
import { SettingsGroup, SettingsRow, Toggle, CommitInput } from "./controls.tsx";
import { SettingsGroup, SettingsRow, SettingsMenuTrigger, Toggle, CommitInput } from "./controls.tsx";
import { ModelMenuList, ThinkingMenuList } from "./model-menu";
import { ChatGPTBackgroundAccess } from "./chatgpt-connection";
import { NativeSubscriptionBackgroundAccess } from "./subscription-connections";
@@ -105,15 +105,6 @@ interface SegmentInfo {
shared: boolean;
}
/** The GET /api/models response fields this section consumes. */
interface ModelsBody {
models: ModelCatalogEntry[];
/** The three tiers' RESOLVED pins (BYOK: NULL per unset tier — no
* factory floor) — machinery only (the thinking list fallback); the
* rows' displays read the record override, never a phantom pin. */
slots: Record<ModelSlotName, { model: { provider: string; id: string }; thinkingLevel?: string } | null>;
}
/** Compose: one tool toggled, the rest of the section carried verbatim.
* Pure — exported for tests. */
export function composeTool(agent: AgentSettings | undefined, id: string, on: boolean): AgentSettings {
@@ -247,8 +238,10 @@ function MenuTrigger({
onClick,
widthClass,
disabled,
hint,
}: {
value: string;
hint?: string;
/** What the trigger shows when there is no override ("Default"). */
emptyLabel: string;
ariaLabel: string;
@@ -264,9 +257,9 @@ function MenuTrigger({
disabled?: boolean;
}) {
const { t } = useI18n();
return (
<button
type="button"
const button = (
<SettingsMenuTrigger
value={t(value || emptyLabel)}
aria-label={ariaLabel}
aria-expanded={open}
disabled={disabled}
@@ -274,16 +267,11 @@ function MenuTrigger({
onClick={() => {
if (!disabled) onClick();
}}
className={`flex h-7 max-w-full items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors ${
disabled ? "cursor-not-allowed" : "hover:border-muted-foreground/50"
} ${widthClass}`}
>
<span className="min-w-0 flex-1 truncate">{t(value || emptyLabel)}</span>
<span aria-hidden className="text-muted-foreground">
▾
</span>
</button>
unavailable={disabled}
className={widthClass}
/>
);
return hint ? <Hint label={hint}>{button}</Hint> : button;
}
/* The section's dropdowns all mount the shared MenuShell (fix wave chunk
@@ -319,19 +307,19 @@ export function AgentSection({
onCompactionChange: (next: CompactionSettings) => void;
}) {
const { t } = useI18n();
const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(null);
const [savingModels, setSavingModels] = useState(false);
const [modelsError, setModelsError] = useState<string | null>(null);
const updateModels = async (next: ModelsSettings) => {
if (savingModels) return;
setSavingModels(true); setModelsError(null);
setSavingModels(true);
setModelsError(null);
try { await onModelsChange(next); }
catch { setModelsError("Could not save model settings"); }
finally { setSavingModels(false); }
};
/** The three tiers' resolved pins — GET /api/models `slots`. The rows'
* Default hints and thinking lists resolve from it. */
const [slots, setSlots] = useState<ModelsBody["slots"] | null>(null);
const { catalog: body, failed: catalogFailed, refresh: refreshCatalog } = useModelCatalog();
const catalog = body?.models;
const slots = body?.slots;
/** The live search state (/api/search) — the row's reason line. */
const [search, setSearch] = useState<{ available: boolean; engine: string | null; reason: string } | null>(null);
/** Which search engines have a key (/api/keys) — the no-keys-anywhere
@@ -366,28 +354,9 @@ export function AgentSection({
.catch(() => {});
}, []);
// The catalog (federated: keyed providers only), the resolved slots,
// the prompt segments, and the search engines' key presence — one
// fetch each per section mount. The models read ALSO re-runs on the
// cross-surface signal (fix wave: another surface's write re-reads
// into the slot rows live).
const modelsRequest = useRef(0);
const modelsMounted = useRef(false);
const readModels = useCallback((connectionChanged = false) => {
const request = ++modelsRequest.current;
if (connectionChanged) setCatalog(null);
fetch("/api/models")
.then((r) => { if (!r.ok) throw new Error("Catalog unavailable"); return r.json(); })
.then((body: ModelsBody) => {
if (!modelsMounted.current || request !== modelsRequest.current) return;
setCatalog(body.models);
setSlots(body.slots);
})
.catch(() => { if (modelsMounted.current && request === modelsRequest.current) setCatalog(null); });
}, []);
// The shared catalog seeds the first frame and refreshes per mount and
// keys/models signal. Settings props still own saved selections/levels.
useSettingsSaved((section) => {
if (section === "models" || section === "keys") readModels(section === "keys");
// THE state-echo root cause: the prompt switches write through the
// agent section, but this view (the toggles' truth) never re-read —
// the settings-saved law (every reader re-reads) was missing this
@@ -396,8 +365,6 @@ export function AgentSection({
});
useEffect(() => {
modelsMounted.current = true;
readModels();
fetch("/api/settings/prompts")
.then((r) => r.json())
.then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body))
@@ -413,7 +380,6 @@ export function AgentSection({
setEngineKeys(present);
})
.catch(() => {});
return () => { modelsMounted.current = false; modelsRequest.current++; };
}, []);
// The reason line re-reads /api/search AFTER an engine write lands —
@@ -657,9 +623,6 @@ export function AgentSection({
const chosen = coord ? catalog?.find((m) => m.id === coord.id && m.provider === coord.provider) : undefined;
const levels = chosen ? chosen.supportedLevels : THINKING_LEVELS;
const thisMenuOpen = openMenu?.target === slot ? openMenu.kind : null;
// Vision slots pick from image-capable models only.
const slotCatalog =
slot === "vision" ? catalog?.filter((m) => m.input.includes("image")) : catalog;
return (
<SettingsRow
key={slot}
@@ -720,9 +683,10 @@ export function AgentSection({
menuClass="flex w-[22rem] flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
>
<ModelMenuList
models={slotCatalog ?? undefined}
models={catalog ?? undefined}
effectiveId={modelId}
effectiveProvider={provider}
vision={slot === "vision"}
onPick={(entry) => {
// Auto-populate (chunk 2, owner: "populate everywhere"): the
// fresh pick writes a level — the stored one rides as-is