Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

1251 lines
40 KiB
TypeScript

"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
import { writeStoredLanguage } from "@/context/app-shell-storage";
import { apiFetch, apiUrl } from "@/lib/api";
import { setTheme as applyThemePreference } from "@/lib/theme";
// ─── Domain types ─────────────────────────────────────────────────────────
export type ServiceName =
| "llm"
| "embedding"
| "search"
| "tts"
| "stt"
| "imagegen"
| "videogen";
export type CatalogModel = {
id: string;
name: string;
model: string;
dimension?: string;
send_dimensions?: boolean;
supported_dimensions?: string;
context_window?: string;
context_window_source?: string;
context_window_detected_at?: string;
// Voice (TTS): free-form provider/model-specific voice string, e.g.
// "alloy", "autumn", "model:voice". `response_format` is the TTS output
// codec (mp3/wav/...) and is reused by imagegen ("url"/"b64_json").
// `language` is an optional STT hint.
voice?: string;
response_format?: string;
language?: string;
// Image generation: pixel size (e.g. "1024x1024"), quality, and style.
size?: string;
quality?: string;
style?: string;
// Video generation: aspect ratio (e.g. "16:9"), duration (seconds), resolution.
aspect_ratio?: string;
duration?: string;
resolution?: string;
};
export type LlmContextWindowDetection = {
profileId: string | null;
modelId: string | null;
contextWindow: number;
source: string;
detail?: string;
detectedAt?: string;
};
export type CatalogProfile = {
id: string;
name: string;
binding?: string;
provider?: string;
base_url: string;
api_key: string;
api_version: string;
extra_headers?: Record<string, string> | string;
proxy?: string;
max_results?: number;
models: CatalogModel[];
};
export type CatalogService = {
active_profile_id: string | null;
active_model_id?: string | null;
profiles: CatalogProfile[];
};
export type Catalog = {
version: number;
services: {
llm: CatalogService;
embedding: CatalogService;
search: CatalogService;
tts: CatalogService;
stt: CatalogService;
imagegen: CatalogService;
videogen: CatalogService;
};
};
export type UiSettings = {
theme: "light" | "dark" | "glass" | "snow";
language: "en" | "zh";
};
export type ProviderOption = {
value: string;
label: string;
base_url?: string;
default_dim?: string;
default_model?: string;
default_voice?: string;
};
export type SystemStatus = {
backend: { status: string; timestamp: string };
llm: { status: string; model?: string; error?: string };
embeddings: { status: string; model?: string; error?: string };
search: { status: string; provider?: string; error?: string };
};
export type EmbeddingCapabilities = {
detected_dim?: number;
default_dim?: number;
supported_dimensions?: number[];
supports_variable_dimensions?: boolean;
model_known?: boolean;
active_dim?: number;
active_dim_source?: string;
};
export type DiagnosticsResult = {
state: "success" | "failed";
message: string;
profileId: string | null;
modelId: string | null;
};
export type ServiceReadiness =
| "not_configured"
| "untested"
| "passed"
| "failed";
type SettingsPayload = {
ui: UiSettings;
catalog?: Catalog;
providers?: Record<ServiceName, ProviderOption[]>;
};
const DIAGNOSTICS_RESULTS_KEY = "deeptutor.settings.diagnosticsResults.v1";
// ─── Tour ──────────────────────────────────────────────────────────────────
//
// The tour now spans routes — each step names the sub-page it lives on so the
// controller can navigate there before the spotlight resolves a target. Adding
// a new step is a matter of pushing onto this list; the overlay reads the
// target via ``data-tour=""`` after the page has rendered.
export type TourStep = {
target: string;
route: string;
titleKey: string;
descKey: string;
};
// Tour step order broadly follows the category order in
// ``web/lib/settings-nav.ts`` so the guided walk moves through the hub's
// sections top to bottom. Each step names the route it lives on (the Status
// step targets the resident module on the hub itself); the overlay resolves
// the ``data-tour`` target after the page renders.
export const TOUR_STEPS: TourStep[] = [
{
target: "tour-status",
route: "/settings",
titleKey: "settingsTour.status.title",
descKey: "settingsTour.status.desc",
},
{
target: "tour-cat-appearance",
route: "/settings",
titleKey: "settingsTour.appearance.title",
descKey: "settingsTour.appearance.desc",
},
{
target: "tour-cat-network",
route: "/settings",
titleKey: "settingsTour.network.title",
descKey: "settingsTour.network.desc",
},
{
target: "tour-cat-models",
route: "/settings",
titleKey: "settingsTour.models.title",
descKey: "settingsTour.models.desc",
},
{
target: "tour-cat-knowledge",
route: "/settings",
titleKey: "settingsTour.knowledge.title",
descKey: "settingsTour.knowledge.desc",
},
{
target: "tour-cat-chat",
route: "/settings",
titleKey: "settingsTour.chat.title",
descKey: "settingsTour.chat.desc",
},
{
target: "tour-cat-memory",
route: "/settings",
titleKey: "settingsTour.memory.title",
descKey: "settingsTour.memory.desc",
},
];
// ─── Helpers ───────────────────────────────────────────────────────────────
export function cloneCatalog(catalog: Catalog): Catalog {
return JSON.parse(JSON.stringify(catalog)) as Catalog;
}
/** TTS/STT share the catalog shape but configure audio providers. */
export function voiceService(service: ServiceName): boolean {
return service === "tts" || service === "stt";
}
/** imagegen/videogen share the catalog shape but configure media generation. */
export function generationService(service: ServiceName): boolean {
return service === "imagegen" || service === "videogen";
}
/** Services whose model entry should prefill from the provider's default model. */
function prefillsDefaultModel(service: ServiceName): boolean {
return voiceService(service) || generationService(service);
}
export function defaultCatalog(): Catalog {
return {
version: 1,
services: {
llm: { active_profile_id: null, active_model_id: null, profiles: [] },
embedding: {
active_profile_id: null,
active_model_id: null,
profiles: [],
},
search: { active_profile_id: null, profiles: [] },
tts: { active_profile_id: null, active_model_id: null, profiles: [] },
stt: { active_profile_id: null, active_model_id: null, profiles: [] },
imagegen: {
active_profile_id: null,
active_model_id: null,
profiles: [],
},
videogen: {
active_profile_id: null,
active_model_id: null,
profiles: [],
},
},
};
}
export function getActiveProfile(
catalog: Catalog,
serviceName: ServiceName,
): CatalogProfile | null {
const service = catalog.services[serviceName];
return (
service.profiles.find(
(profile) => profile.id === service.active_profile_id,
) ??
service.profiles[0] ??
null
);
}
export function getActiveModel(
catalog: Catalog,
serviceName: ServiceName,
): CatalogModel | null {
if (serviceName === "search") return null;
const service = catalog.services[serviceName];
const profile = getActiveProfile(catalog, serviceName);
if (!profile) return null;
return (
profile.models.find((model) => model.id === service.active_model_id) ??
profile.models[0] ??
null
);
}
export function serviceConfigured(
catalog: Catalog,
serviceName: ServiceName,
): boolean {
return serviceName === "search"
? Boolean(getActiveProfile(catalog, serviceName)?.provider)
: Boolean(getActiveModel(catalog, serviceName)?.model);
}
export function currentDiagnosticsResult(
catalog: Catalog,
serviceName: ServiceName,
diagnosticsResults: Partial<Record<ServiceName, DiagnosticsResult>>,
): DiagnosticsResult | null {
const service = catalog.services[serviceName];
const diagnostics = diagnosticsResults[serviceName];
if (!diagnostics) return null;
const profileId = service.active_profile_id ?? null;
const modelId =
serviceName === "search" ? null : (service.active_model_id ?? null);
return diagnostics.profileId === profileId && diagnostics.modelId === modelId
? diagnostics
: null;
}
export function serviceReadiness(
catalog: Catalog,
serviceName: ServiceName,
diagnosticsResults: Partial<Record<ServiceName, DiagnosticsResult>>,
): ServiceReadiness {
if (!serviceConfigured(catalog, serviceName)) return "not_configured";
const diagnostics = currentDiagnosticsResult(
catalog,
serviceName,
diagnosticsResults,
);
if (diagnostics?.state === "failed") return "failed";
if (diagnostics?.state === "success") return "passed";
return "untested";
}
export function servicePendingApply(
catalog: Catalog,
draft: Catalog,
service: ServiceName,
): boolean {
return (
JSON.stringify(catalog.services[service]) !==
JSON.stringify(draft.services[service])
);
}
function nextModelName(
models: CatalogModel[],
language: UiSettings["language"],
): string {
const prefix = language === "zh" ? "模型" : "Model ";
const used = new Set(models.map((model) => model.name.trim()));
let index = models.length + 1;
while (used.has(`${prefix}${index}`)) {
index += 1;
}
return `${prefix}${index}`;
}
function readStoredDiagnosticsResults(): Partial<
Record<ServiceName, DiagnosticsResult>
> {
if (typeof window === "undefined") return {};
try {
const parsed = JSON.parse(
window.sessionStorage.getItem(DIAGNOSTICS_RESULTS_KEY) || "{}",
) as Partial<Record<ServiceName, DiagnosticsResult>>;
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
// ─── Context ───────────────────────────────────────────────────────────────
export interface SettingsExtension {
dirty: boolean;
save: () => Promise<void>;
}
type SettingsContextValue = {
// State
catalog: Catalog;
draft: Catalog;
status: SystemStatus | null;
providers: Record<ServiceName, ProviderOption[]>;
catalogEditable: boolean | null;
settingsLoading: boolean;
settingsError: string | null;
reloadSettings: () => Promise<void>;
hasUnsavedChanges: boolean;
theme: UiSettings["theme"];
language: UiSettings["language"];
toast: string;
setToast: (value: string) => void;
// UI prefs
updateTheme: (next: UiSettings["theme"]) => Promise<void>;
updateLanguage: (next: UiSettings["language"]) => Promise<void>;
// Catalog mutation
mutateCatalog: (mutator: (next: Catalog) => void) => void;
addProfile: (service: ServiceName) => void;
removeActiveProfile: (service: ServiceName) => void;
addModel: (service: ServiceName) => void;
removeActiveModel: (service: ServiceName) => void;
updateProfileField: (
service: ServiceName,
field: keyof CatalogProfile,
value: string,
) => void;
updateModelField: (
service: ServiceName,
field: keyof CatalogModel,
value: string,
) => void;
updateModelBoolField: (
service: ServiceName,
field: keyof CatalogModel,
value: boolean,
) => void;
updateContextWindowField: (value: string) => void;
llmContextDetection: LlmContextWindowDetection | null;
applyDetectedContextWindow: () => void;
// Save / apply
saving: boolean;
applying: boolean;
saveCatalog: () => Promise<void>;
applyCatalog: () => Promise<void>;
// Sub-page extension hooks. Sub-routes (e.g. /settings/memory) that own
// state outside the catalog register a "dirty + save" pair so the global
// Apply button can flush them alongside the catalog. Re-register on every
// render — the latest closure wins.
registerExtension: (key: string, ext: SettingsExtension | null) => void;
// Diagnostics
logs: string;
testRunning: ServiceName | null;
diagnosticsResults: Partial<Record<ServiceName, DiagnosticsResult>>;
embeddingCapabilities: EmbeddingCapabilities | null;
runDetailedTest: (service: ServiceName) => Promise<void>;
// Helpers
embeddingDefaultDim: (binding?: string) => string;
// Tour
tourStepIndex: number;
startTour: () => void;
advanceTour: () => void;
goBackTour: () => void;
skipTour: () => void;
};
const SettingsContext = createContext<SettingsContextValue | null>(null);
export function useSettings(): SettingsContextValue {
const ctx = useContext(SettingsContext);
if (!ctx) {
throw new Error("useSettings must be used inside <SettingsProvider>");
}
return ctx;
}
// ─── Provider ──────────────────────────────────────────────────────────────
export function SettingsProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation();
const router = useRouter();
const [status, setStatus] = useState<SystemStatus | null>(null);
const [theme, setTheme] = useState<UiSettings["theme"]>("snow");
const [language, setLanguage] = useState<UiSettings["language"]>("en");
const [catalog, setCatalog] = useState<Catalog>(defaultCatalog());
const [draft, setDraft] = useState<Catalog>(defaultCatalog());
const [catalogEditable, setCatalogEditable] = useState<boolean | null>(null);
const [providers, setProviders] = useState<
Record<ServiceName, ProviderOption[]>
>({
llm: [],
embedding: [],
search: [],
tts: [],
stt: [],
imagegen: [],
videogen: [],
});
const [toast, setToast] = useState("");
const [saving, setSaving] = useState(false);
const [applying, setApplying] = useState(false);
// Empty string is the "no diagnostics yet" sentinel; the editor renders
// a localized placeholder when logs is falsy. Don't seed an English
// literal here — older code did, then read it back via .startsWith.
const [logs, setLogs] = useState<string>("");
const [testRunning, setTestRunning] = useState<ServiceName | null>(null);
const [diagnosticsResults, setDiagnosticsResults] = useState<
Partial<Record<ServiceName, DiagnosticsResult>>
>(() => readStoredDiagnosticsResults());
const [llmContextDetection, setLlmContextDetection] =
useState<LlmContextWindowDetection | null>(null);
const [embeddingCapabilities, setEmbeddingCapabilities] =
useState<EmbeddingCapabilities | null>(null);
const [tourStepIndex, setTourStepIndex] = useState(-1);
const eventSourceRef = useRef<EventSource | null>(null);
// Extensions register their latest dirty/save on each render. We track
// a "version" counter to trigger re-renders for `hasUnsavedChanges`
// when an extension's dirty flag flips.
const extensionsRef = useRef<Map<string, SettingsExtension>>(new Map());
const [extensionsVersion, setExtensionsVersion] = useState(0);
const registerExtension = useCallback(
(key: string, ext: SettingsExtension | null) => {
const map = extensionsRef.current;
const prev = map.get(key);
if (ext === null) {
if (prev === undefined) return;
map.delete(key);
setExtensionsVersion((n) => n + 1);
return;
}
if (prev && prev.dirty === ext.dirty && prev.save === ext.save) {
return;
}
map.set(key, ext);
// Only bump version when dirty flips — save fn changes every render
// are common and should not re-render the toolbar.
if (prev?.dirty !== ext.dirty) {
setExtensionsVersion((n) => n + 1);
}
},
[],
);
const [settingsError, setSettingsError] = useState<string | null>(null);
// Single load step. Kept separate from the mount effect so a "Retry" action
// can re-run it without remounting the provider.
const loadSettings = useCallback(async () => {
setSettingsError(null);
let settingsLoaded = false;
try {
const settingsResponse = await apiFetch(apiUrl("/api/v1/settings"));
if (!settingsResponse.ok) {
throw new Error(
`Settings fetch failed: HTTP ${settingsResponse.status}`,
);
}
const payload = (await settingsResponse.json()) as SettingsPayload;
if (payload.catalog) {
setCatalog(payload.catalog);
setDraft(cloneCatalog(payload.catalog));
setCatalogEditable(true);
} else {
setCatalogEditable(false);
}
setTheme(payload.ui.theme);
setLanguage(payload.ui.language);
if (payload.providers) setProviders(payload.providers);
settingsLoaded = true;
} catch (err) {
console.error("Failed to load settings:", err);
const message = err instanceof Error ? err.message : String(err);
setSettingsError(message);
// Resolve the loading gate so the page can render the error UI instead
// of staying in an infinite skeleton state.
setCatalogEditable((current) => (current === null ? false : current));
}
try {
const statusResponse = await apiFetch(apiUrl("/api/v1/system/status"));
if (statusResponse.ok) {
setStatus((await statusResponse.json()) as SystemStatus);
}
} catch (err) {
console.error("Failed to load system status:", err);
// Only surface this when settings itself loaded; otherwise the
// settings-fetch error already explains the disconnect.
if (settingsLoaded) {
setSettingsError(
(current) =>
current ??
(err instanceof Error
? t("System status unavailable: {{message}}", {
message: err.message,
})
: t("System status unavailable.")),
);
}
}
}, [t]);
// Load settings + status once on mount. Subsequent navigations between
// settings sub-pages share this state via the layout-level provider.
useEffect(() => {
loadSettings();
return () => {
if (eventSourceRef.current) eventSourceRef.current.close();
};
}, [loadSettings]);
useEffect(() => {
if (!toast) return;
const timer = setTimeout(() => setToast(""), 3500);
return () => clearTimeout(timer);
}, [toast]);
useEffect(() => {
try {
window.sessionStorage.setItem(
DIAGNOSTICS_RESULTS_KEY,
JSON.stringify(diagnosticsResults),
);
} catch {
// Session storage is an enhancement for cross-route feedback only.
}
}, [diagnosticsResults]);
// ── UI preferences ──────────────────────────────────────────────────────
const persistUi = useCallback(
async (
nextTheme: UiSettings["theme"],
nextLanguage: UiSettings["language"],
) => {
await apiFetch(apiUrl("/api/v1/settings/ui"), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ theme: nextTheme, language: nextLanguage }),
});
},
[],
);
const updateTheme = useCallback(
async (next: UiSettings["theme"]) => {
setTheme(next);
applyThemePreference(next);
await persistUi(next, language);
},
[language, persistUi],
);
const updateLanguage = useCallback(
async (next: UiSettings["language"]) => {
setLanguage(next);
writeStoredLanguage(next);
await persistUi(theme, next);
},
[persistUi, theme],
);
// ── Catalog mutators ────────────────────────────────────────────────────
const mutateCatalog = useCallback((mutator: (next: Catalog) => void) => {
setDraft((current) => {
const next = cloneCatalog(current);
mutator(next);
return next;
});
}, []);
const embeddingDefaultDim = useCallback(
(binding?: string) => {
const match = (providers.embedding || []).find(
(p) => p.value === (binding || "openai"),
);
return match?.default_dim || "3072";
},
[providers.embedding],
);
const addProfile = useCallback(
(service: ServiceName) => {
mutateCatalog((next) => {
const target = next.services[service];
const profileId = `${service}-profile-${Date.now()}`;
const defaultBinding = service === "search" ? undefined : "openai";
const defaultProvider = service === "search" ? "brave" : undefined;
const providerKey =
service === "search" ? defaultProvider : defaultBinding;
const providerOption = (providers[service] || []).find(
(p) => p.value === providerKey,
);
const providerLabel =
providerOption?.label ?? providerKey ?? "New Profile";
const profile: CatalogProfile = {
id: profileId,
name: providerLabel,
binding: defaultBinding,
provider: defaultProvider,
base_url: "",
api_key: "",
api_version: "",
extra_headers: service === "search" ? undefined : {},
proxy: service === "search" ? "" : undefined,
models: [],
};
if (service !== "search") {
const modelId = `${service}-model-${Date.now()}`;
const modelName = nextModelName([], language);
profile.models.push({
id: modelId,
name: modelName,
model: prefillsDefaultModel(service)
? (providerOption?.default_model ?? "")
: "",
...(service === "embedding"
? {
dimension: embeddingDefaultDim(),
send_dimensions: true,
}
: {}),
...(service === "tts"
? {
voice: providerOption?.default_voice ?? "",
response_format: "mp3",
}
: {}),
});
target.active_model_id = modelId;
}
target.profiles.push(profile);
target.active_profile_id = profileId;
});
},
[embeddingDefaultDim, language, mutateCatalog, providers],
);
const removeActiveProfile = useCallback(
(service: ServiceName) => {
mutateCatalog((next) => {
const target = next.services[service];
target.profiles = target.profiles.filter(
(profile) => profile.id !== target.active_profile_id,
);
target.active_profile_id = target.profiles[0]?.id ?? null;
if (service !== "search") {
target.active_model_id = target.profiles[0]?.models?.[0]?.id ?? null;
}
});
},
[mutateCatalog],
);
const addModel = useCallback(
(service: ServiceName) => {
if (service === "search") return;
mutateCatalog((next) => {
const target = next.services[service];
const profile =
target.profiles.find(
(item) => item.id === target.active_profile_id,
) ?? null;
if (!profile) return;
const providerOption = (providers[service] || []).find(
(p) => p.value === profile.binding,
);
const modelId = `${service}-model-${Date.now()}`;
const modelName = nextModelName(profile.models, language);
profile.models.push({
id: modelId,
name: modelName,
model: prefillsDefaultModel(service)
? (providerOption?.default_model ?? "")
: "",
...(service === "embedding"
? {
dimension: embeddingDefaultDim(profile.binding),
send_dimensions: true,
}
: {}),
...(service === "tts"
? {
voice: providerOption?.default_voice ?? "",
response_format: "mp3",
}
: {}),
});
target.active_model_id = modelId;
});
},
[embeddingDefaultDim, language, mutateCatalog, providers],
);
const removeActiveModel = useCallback(
(service: ServiceName) => {
if (service === "search") return;
mutateCatalog((next) => {
const target = next.services[service];
const profile =
target.profiles.find(
(item) => item.id === target.active_profile_id,
) ?? null;
if (!profile) return;
profile.models = profile.models.filter(
(item) => item.id !== target.active_model_id,
);
target.active_model_id = profile.models[0]?.id ?? null;
});
},
[mutateCatalog],
);
const updateProfileField = useCallback(
(service: ServiceName, field: keyof CatalogProfile, value: string) => {
mutateCatalog((next) => {
const profile = getActiveProfile(next, service);
if (!profile) return;
(profile[field] as string | undefined) = value;
});
},
[mutateCatalog],
);
const updateModelField = useCallback(
(service: ServiceName, field: keyof CatalogModel, value: string) => {
if (service === "search") return;
mutateCatalog((next) => {
const model = getActiveModel(next, service);
if (!model) return;
(model[field] as string | undefined) = value;
});
},
[mutateCatalog],
);
const updateModelBoolField = useCallback(
(service: ServiceName, field: keyof CatalogModel, value: boolean) => {
if (service === "search") return;
mutateCatalog((next) => {
const model = getActiveModel(next, service);
if (!model) return;
(model[field] as boolean | undefined) = value;
});
},
[mutateCatalog],
);
const updateContextWindowField = useCallback(
(value: string) => {
const normalized = value.replace(/[^\d]/g, "");
mutateCatalog((next) => {
const model = getActiveModel(next, "llm");
if (!model) return;
if (normalized) {
model.context_window = normalized;
model.context_window_source = "manual";
delete model.context_window_detected_at;
} else {
delete model.context_window;
delete model.context_window_source;
delete model.context_window_detected_at;
}
});
},
[mutateCatalog],
);
const applyDetectedContextWindow = useCallback(() => {
if (!llmContextDetection) return;
mutateCatalog((next) => {
const target = next.services.llm;
if (
target.active_profile_id !== llmContextDetection.profileId ||
target.active_model_id !== llmContextDetection.modelId
) {
return;
}
const model = getActiveModel(next, "llm");
if (!model) return;
model.context_window = String(llmContextDetection.contextWindow);
model.context_window_source = llmContextDetection.source;
if (llmContextDetection.detectedAt) {
model.context_window_detected_at = llmContextDetection.detectedAt;
} else {
delete model.context_window_detected_at;
}
});
setToast(t("Detected context window written to draft"));
}, [llmContextDetection, mutateCatalog, t]);
// ── Save / Apply ────────────────────────────────────────────────────────
const saveCatalog = useCallback(async () => {
if (!catalogEditable) return;
setSaving(true);
try {
const response = await apiFetch(apiUrl("/api/v1/settings/catalog"), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ catalog: draft }),
});
const payload = await response.json();
setCatalog(payload.catalog);
setDraft(cloneCatalog(payload.catalog));
setToast(t("Draft saved"));
} finally {
setSaving(false);
}
}, [catalogEditable, draft, t]);
const applyCatalog = useCallback(async () => {
setApplying(true);
try {
// Flush extensions (e.g. /settings/memory) first so their saved
// state is visible to any backend side-effects in /apply below.
const exts = Array.from(extensionsRef.current.values()).filter(
(e) => e.dirty,
);
await Promise.all(exts.map((e) => e.save()));
// The catalog apply is only meaningful when editable; an extension-
// only flush should still produce a success toast.
if (catalogEditable) {
const response = await apiFetch(apiUrl("/api/v1/settings/apply"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ catalog: draft }),
});
const payload = await response.json();
setCatalog(payload.catalog);
setDraft(cloneCatalog(payload.catalog));
const statusResponse = await apiFetch(apiUrl("/api/v1/system/status"));
setStatus((await statusResponse.json()) as SystemStatus);
}
setToast(t("All changes saved"));
} finally {
setApplying(false);
}
}, [catalogEditable, draft, t]);
// ── Diagnostics ─────────────────────────────────────────────────────────
// Reset capability snapshot when switching embedding profile/model so a
// stale "Detected: Xd" hint doesn't bleed across profiles.
useEffect(() => {
setEmbeddingCapabilities(null);
}, [
draft.services.embedding.active_profile_id,
draft.services.embedding.active_model_id,
]);
useEffect(() => {
setLlmContextDetection((current) => {
if (!current) return null;
const llm = draft.services.llm;
if (
current.profileId === llm.active_profile_id &&
current.modelId === llm.active_model_id
) {
return current;
}
return null;
});
}, [
draft.services.llm.active_profile_id,
draft.services.llm.active_model_id,
]);
const runDetailedTest = useCallback(
async (service: ServiceName) => {
if (!catalogEditable) return;
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
setLogs(t("Preparing {{service}} diagnostics...", { service }) + "\n");
setTestRunning(service);
const target = draft.services[service];
const runProfileId = target.active_profile_id ?? null;
const runModelId =
service === "search" ? null : (target.active_model_id ?? null);
setDiagnosticsResults((current) => {
const next = { ...current };
delete next[service];
return next;
});
if (service === "llm") setLlmContextDetection(null);
if (service === "embedding") setEmbeddingCapabilities(null);
try {
const response = await apiFetch(
apiUrl(`/api/v1/settings/tests/${service}/start`),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ catalog: draft }),
},
);
const payload = (await response.json()) as {
run_id?: string;
detail?: string;
};
if (!response.ok || !payload.run_id) {
throw new Error(payload.detail || t("Could not start diagnostics."));
}
const source = new EventSource(
apiUrl(`/api/v1/settings/tests/${service}/${payload.run_id}/events`),
{ withCredentials: true },
);
eventSourceRef.current = source;
source.onmessage = (event) => {
const entry = JSON.parse(event.data) as {
type: string;
message: string;
catalog?: Catalog;
detected_dim?: number;
default_dim?: number;
supported_dimensions?: number[];
supports_variable_dimensions?: boolean;
model_known?: boolean;
active_dim?: number;
active_dim_source?: string;
context_window?: number;
source?: string;
detail?: string;
detected_at?: string;
};
setLogs((current) => `${current}[${entry.type}] ${entry.message}\n`);
if (service === "llm" && entry.type === "context_window") {
const detected =
typeof entry.context_window === "number"
? entry.context_window
: Number.parseInt(String(entry.context_window ?? ""), 10);
if (Number.isFinite(detected) && detected > 0) {
setLlmContextDetection({
profileId: runProfileId,
modelId: runModelId,
contextWindow: detected,
source: entry.source || "metadata",
detail: entry.detail,
detectedAt: entry.detected_at,
});
}
}
if (entry.type === "capabilities") {
setEmbeddingCapabilities({
detected_dim: entry.detected_dim,
default_dim: entry.default_dim,
supported_dimensions: entry.supported_dimensions,
supports_variable_dimensions: entry.supports_variable_dimensions,
model_known: entry.model_known,
active_dim: entry.active_dim,
active_dim_source: entry.active_dim_source,
});
}
if (entry.catalog) {
setCatalog(entry.catalog);
setDraft(cloneCatalog(entry.catalog));
}
if (entry.type === "completed" || entry.type === "failed") {
source.close();
eventSourceRef.current = null;
setTestRunning(null);
setDiagnosticsResults((current) => ({
...current,
[service]: {
state: entry.type === "completed" ? "success" : "failed",
message: entry.message,
profileId: runProfileId,
modelId: runModelId,
},
}));
setToast(entry.message);
}
};
source.onerror = () => {
source.close();
eventSourceRef.current = null;
setTestRunning(null);
setLogs(
(current) =>
`${current}[failed] ${t("Diagnostics stream disconnected.")}\n`,
);
setDiagnosticsResults((current) => ({
...current,
[service]: {
state: "failed",
message: t("Diagnostics stream disconnected."),
profileId: runProfileId,
modelId: runModelId,
},
}));
setToast(t("Diagnostics stream disconnected"));
};
} catch (error) {
const message =
error instanceof Error
? error.message
: t("Could not start diagnostics.");
setLogs((current) => `${current}[failed] ${message}\n`);
setDiagnosticsResults((current) => ({
...current,
[service]: {
state: "failed",
message,
profileId: runProfileId,
modelId: runModelId,
},
}));
setToast(message);
setTestRunning(null);
}
},
[catalogEditable, draft, t],
);
// ── Tour ────────────────────────────────────────────────────────────────
// The tour drives a SpotlightOverlay rendered by the layout. When the step
// changes, we navigate to the step's route; the overlay then resolves the
// target via data-tour after the page renders.
const startTour = useCallback(() => {
if (TOUR_STEPS.length === 0) return;
setTourStepIndex(0);
// No router.push here — the route-sync effect below handles it,
// and doing it in two places would issue a redundant push.
}, []);
// Pure state updaters — DO NOT call router.push inside these. React
// may invoke the updater twice in StrictMode, and triggering a
// separate component's setState (Router) from inside a setState
// callback raises "Cannot update a component while rendering another".
// The route is synced via the effect below.
const advanceTour = useCallback(() => {
setTourStepIndex((idx) => {
const nextIdx = idx + 1;
return nextIdx >= TOUR_STEPS.length ? -1 : nextIdx;
});
}, []);
const goBackTour = useCallback(() => {
setTourStepIndex((idx) => (idx > 0 ? idx - 1 : idx));
}, []);
const skipTour = useCallback(() => {
setTourStepIndex(-1);
}, []);
// Sync the URL to the current tour step. Runs after render commits
// so it never re-enters another component's render.
useEffect(() => {
if (tourStepIndex < 0 || tourStepIndex >= TOUR_STEPS.length) return;
const step = TOUR_STEPS[tourStepIndex];
router.push(step.route);
}, [tourStepIndex, router]);
// ── Derived ─────────────────────────────────────────────────────────────
const hasUnsavedChanges = useMemo(() => {
const catalogDirty =
catalogEditable === true &&
JSON.stringify(catalog) !== JSON.stringify(draft);
if (catalogDirty) return true;
// Any registered extension dirty also counts. Reading from the ref is
// safe because `extensionsVersion` invalidates this memo on flip.
for (const ext of extensionsRef.current.values()) {
if (ext.dirty) return true;
}
return false;
}, [catalog, catalogEditable, draft, extensionsVersion]);
const settingsLoading = catalogEditable === null;
const value = useMemo<SettingsContextValue>(
() => ({
catalog,
draft,
status,
providers,
catalogEditable,
settingsLoading,
settingsError,
reloadSettings: loadSettings,
hasUnsavedChanges,
theme,
language,
toast,
setToast,
updateTheme,
updateLanguage,
mutateCatalog,
addProfile,
removeActiveProfile,
addModel,
removeActiveModel,
updateProfileField,
updateModelField,
updateModelBoolField,
updateContextWindowField,
llmContextDetection,
applyDetectedContextWindow,
saving,
applying,
saveCatalog,
applyCatalog,
registerExtension,
logs,
testRunning,
diagnosticsResults,
embeddingCapabilities,
runDetailedTest,
embeddingDefaultDim,
tourStepIndex,
startTour,
advanceTour,
goBackTour,
skipTour,
}),
[
addModel,
addProfile,
applyDetectedContextWindow,
applyCatalog,
applying,
catalog,
catalogEditable,
diagnosticsResults,
draft,
embeddingCapabilities,
embeddingDefaultDim,
hasUnsavedChanges,
language,
llmContextDetection,
logs,
mutateCatalog,
providers,
registerExtension,
removeActiveModel,
removeActiveProfile,
runDetailedTest,
saveCatalog,
saving,
settingsError,
loadSettings,
settingsLoading,
skipTour,
startTour,
advanceTour,
goBackTour,
status,
testRunning,
theme,
toast,
tourStepIndex,
updateContextWindowField,
updateLanguage,
updateModelBoolField,
updateModelField,
updateProfileField,
updateTheme,
],
);
return (
<SettingsContext.Provider value={value}>
{children}
</SettingsContext.Provider>
);
}