feat(webui): add guided setup flows

* feat(channels): add guided setup flows

* test(channels): preserve setup config values

* fix(channels): reflect saved setup state

* refactor(channels): simplify setup state metadata

* fix(channels): harden setup lifecycle

* refactor(channels): centralize setup contracts

* fix(channels): route setup actions through webui shim

* fix(channels): adapt settings for compact screens

* fix(models): preserve default preset display

* feat(models): add curated Codex catalog

* fix(webui): stop attached gateway on interrupt

* fix(webui): simplify apps catalog

* docs(webui): clarify apps and runtime features

* feat(settings): add guided capability setup

* fix(webui): harden setup and managed services

* test: keep managed runtime checks portable

* test: scope POSIX runtime coverage

* fix(webui): simplify file settings

* feat(files): bundle document reading

* fix(webui): harden setup request boundaries

* fix(webui): prevent channel setup status squeeze

* fix(settings): group provider compatibility aliases

* refactor(settings): remove redundant setup surfaces

* fix(webui): harden guided setup lifecycle

* fix(webui): preserve channel setup compatibility
This commit is contained in:
Xubin Ren
2026-07-13 13:11:46 +08:00
committed by GitHub
parent 791c7fd505
commit fe0717b385
92 changed files with 15058 additions and 1311 deletions
+78
View File
@@ -0,0 +1,78 @@
import { useCallback, useEffect, useMemo, useState } from "react";
const loadedLogoUrls = new Set<string>();
const failedLogoUrls = new Set<string>();
const resolvedLogoIndexByKey = new Map<string, number>();
function logoCacheKey(urls: readonly string[]): string {
return urls.join("\n");
}
function logoUrlsFromKey(key: string): string[] {
return key ? key.split("\n") : [];
}
function firstUsableLogoIndex(urls: readonly string[]): number {
const key = logoCacheKey(urls);
const cachedIndex = resolvedLogoIndexByKey.get(key);
if (
typeof cachedIndex === "number" &&
cachedIndex >= 0 &&
cachedIndex < urls.length &&
!failedLogoUrls.has(urls[cachedIndex])
) {
return cachedIndex;
}
const loadedIndex = urls.findIndex((url) => loadedLogoUrls.has(url));
if (loadedIndex >= 0) {
resolvedLogoIndexByKey.set(key, loadedIndex);
return loadedIndex;
}
const firstUnfailedIndex = urls.findIndex((url) => !failedLogoUrls.has(url));
if (firstUnfailedIndex >= 0) return firstUnfailedIndex;
return -1;
}
function nextLogoIndex(urls: readonly string[], afterIndex: number): number {
for (let index = afterIndex + 1; index < urls.length; index += 1) {
if (!failedLogoUrls.has(urls[index])) return index;
}
return -1;
}
export function useLogoFallback(urls: readonly string[] | undefined) {
const cacheKey = useMemo(() => logoCacheKey(urls?.filter(Boolean) ?? []), [urls]);
const safeUrls = useMemo(() => logoUrlsFromKey(cacheKey), [cacheKey]);
const [logoIndex, setLogoIndex] = useState(() => firstUsableLogoIndex(safeUrls));
const logoUrl = logoIndex >= 0 ? safeUrls[logoIndex] : undefined;
useEffect(() => {
setLogoIndex(firstUsableLogoIndex(safeUrls));
}, [cacheKey, safeUrls]);
const onLogoLoad = useCallback(() => {
if (!logoUrl || logoIndex < 0) return;
loadedLogoUrls.add(logoUrl);
failedLogoUrls.delete(logoUrl);
resolvedLogoIndexByKey.set(cacheKey, logoIndex);
}, [cacheKey, logoIndex, logoUrl]);
const onLogoError = useCallback(() => {
if (!logoUrl || logoIndex < 0) return;
failedLogoUrls.add(logoUrl);
if (resolvedLogoIndexByKey.get(cacheKey) === logoIndex) {
resolvedLogoIndexByKey.delete(cacheKey);
}
setLogoIndex(nextLogoIndex(safeUrls, logoIndex));
}, [cacheKey, logoIndex, logoUrl, safeUrls]);
return { logoUrl, onLogoLoad, onLogoError };
}
export function __clearLogoFallbackCacheForTests(): void {
loadedLogoUrls.clear();
failedLogoUrls.clear();
resolvedLogoIndexByKey.clear();
}
+30
View File
@@ -0,0 +1,30 @@
import { useEffect, useState } from "react";
export function useMediaQuery(query: string, fallback = false): boolean {
const readMatch = () => {
if (
typeof window === "undefined" ||
typeof window.matchMedia !== "function"
) {
return fallback;
}
return window.matchMedia(query).matches;
};
const [matches, setMatches] = useState(readMatch);
useEffect(() => {
if (
typeof window === "undefined" ||
typeof window.matchMedia !== "function"
)
return;
const media = window.matchMedia(query);
const update = () => setMatches(media.matches);
update();
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, [query]);
return matches;
}