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
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,17 @@ jobs:
- name: Check release helper syntax
run: bun build scripts/release.ts --target=bun --outdir=.tmp/ci-release-script-check

- name: GUI lint
run: |
cd gui
bun install --frozen-lockfile
bun run lint

- name: GUI build
run: |
cd gui
bun run build

- name: CLI help smoke
run: bun run src/cli/index.ts help

Expand Down
10 changes: 8 additions & 2 deletions gui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ function readStoredTheme(): Theme {

export default function App() {
const [page, setPageState] = useState<Page>(readPageFromHash);
const setPage = (p: Page) => { location.hash = p; setPageState(p); };
const [theme, setTheme] = useState<Theme>(readStoredTheme);
const [runtimeVersion, setRuntimeVersion] = useState<string | null>(null);
const { locale, setLocale } = useI18n();
Expand All @@ -68,6 +67,13 @@ export default function App() {
return () => window.removeEventListener("hashchange", onHash);
}, []);

useEffect(() => {
const nextHash = `#${page}`;
if (window.location.hash !== nextHash) {
window.location.hash = page;
}
}, [page]);

useEffect(() => {
const el = document.documentElement;
if (theme === "system") { el.removeAttribute("data-theme"); localStorage.removeItem(THEME_KEY); }
Expand Down Expand Up @@ -112,7 +118,7 @@ export default function App() {
</div>
<nav>
{NAV.map(({ id, tkey, Icon }) => (
<button key={id} className={`nav-item${page === id ? " active" : ""}`} data-page={id} onClick={() => setPage(id)}
<button key={id} className={`nav-item${page === id ? " active" : ""}`} data-page={id} onClick={() => setPageState(id)}
aria-current={page === id ? "page" : undefined}>
<Icon /> {t(tkey)}
</button>
Expand Down
2 changes: 2 additions & 0 deletions gui/src/i18n/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { LanguageProvider, Trans } from "./provider";
export { LOCALES, useI18n, useT, type Locale, type TFn, type TKey } from "./shared";
78 changes: 0 additions & 78 deletions gui/src/i18n/index.tsx

This file was deleted.

24 changes: 24 additions & 0 deletions gui/src/i18n/provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useEffect, useState, type ReactNode } from "react";
import { DICTS, I18nContext, LOCALES, detectInitial, interpolate, type TFn, type TKey, type Vars } from "./shared";
import { en } from "./en";
import { useI18n } from "./shared";

export function LanguageProvider({ children }: { children: ReactNode }) {
const [locale, setLocale] = useState(detectInitial);

useEffect(() => {
const meta = LOCALES.find(l => l.code === locale) ?? LOCALES[0];
document.documentElement.lang = meta.htmlLang;
try { localStorage.setItem("ocx-lang", locale); } catch { /* ignore */ }
}, [locale]);

const t: TFn = (key, vars) => interpolate(DICTS[locale][key] ?? en[key] ?? key, vars);

return <I18nContext.Provider value={{ locale, setLocale, t }}>{children}</I18nContext.Provider>;
}

export function Trans({ k, cmd, vars }: { k: TKey; cmd: string; vars?: Vars }) {
const { t } = useI18n();
const [pre, post = ""] = t(k, vars).split("{cmd}");
return <>{pre}<code className="chip">{cmd}</code>{post}</>;
}
55 changes: 55 additions & 0 deletions gui/src/i18n/shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { createContext, useContext } from "react";
import { en, type TKey } from "./en";
import { de } from "./de";
import { ko } from "./ko";
import { zh } from "./zh";

export type Locale = "en" | "de" | "ko" | "zh";
export type { TKey };

export const DICTS: Record<Locale, Record<TKey, string>> = { en, de, ko, zh };

export const LOCALES: { code: Locale; name: string; htmlLang: string }[] = [
{ code: "en", name: "English", htmlLang: "en" },
{ code: "de", name: "Deutsch", htmlLang: "de" },
{ code: "ko", name: "한국어", htmlLang: "ko" },
{ code: "zh", name: "中文", htmlLang: "zh-CN" },
];

const LANG_KEY = "ocx-lang";

export function detectInitial(): Locale {
try {
const stored = localStorage.getItem(LANG_KEY);
if (stored === "en" || stored === "de" || stored === "ko" || stored === "zh") return stored;
} catch { /* ignore */ }
const nav = typeof navigator !== "undefined" ? navigator.language.toLowerCase() : "en";
if (nav.startsWith("de")) return "de";
if (nav.startsWith("ko")) return "ko";
if (nav.startsWith("zh")) return "zh";
return "en";
}

export type Vars = Record<string, string | number>;
export type TFn = (key: TKey, vars?: Vars) => string;

export interface I18nContextValue { locale: Locale; setLocale: (l: Locale) => void; t: TFn }

export const I18nContext = createContext<I18nContextValue | null>(null);

export function interpolate(s: string, vars?: Vars): string {
if (!vars) return s;
let out = s;
for (const k of Object.keys(vars)) out = out.split(`{${k}}`).join(String(vars[k]));
return out;
}

export function useI18n(): I18nContextValue {
const ctx = useContext(I18nContext);
if (!ctx) throw new Error("useI18n must be used within LanguageProvider");
return ctx;
}

export function useT(): TFn {
return useI18n().t;
}
7 changes: 6 additions & 1 deletion gui/src/pages/ApiKeys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) {
} catch { /* proxy down */ }
}, [apiBase]);

useEffect(() => { fetchKeys(); }, [fetchKeys]);
useEffect(() => {
const timeout = window.setTimeout(() => {
void fetchKeys();
}, 0);
return () => window.clearTimeout(timeout);
}, [fetchKeys]);

const responseEndpoint = endpoint || "http://127.0.0.1:10100/v1/responses";

Expand Down
21 changes: 15 additions & 6 deletions gui/src/pages/CodexAuth.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useT, type TFn } from "../i18n";
import { IconLock, IconPlus, IconX, IconAlert, IconRefresh, IconTicket } from "../icons";
import { Notice, EmptyState } from "../ui";
import AddCodexAccountModal from "../components/AddCodexAccountModal";
import type { AccountQuota } from "../codex-quota-utils";
import QuotaBars from "../components/QuotaBars";

export { normalizeQuotaForPlan, isThirtyDayOnlyPlan } from "../codex-quota-utils";
export type { AccountQuota } from "../codex-quota-utils";
interface AccountEntry {
id: string; email: string; plan?: string; isMain: boolean;
hasCredential: boolean; quota: AccountQuota | null;
Expand All @@ -29,7 +27,7 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) {
const [creditDetails, setCreditDetails] = useState<{ granted_at: string; expires_at: string }[] | null>(null);
const [creditDetailsLoading, setCreditDetailsLoading] = useState(false);

const load = async (refreshQuota = false) => {
const load = useCallback(async (refreshQuota = false) => {
try {
const [accts, active] = await Promise.all([
fetch(`${apiBase}/api/codex-auth/accounts${refreshQuota ? "?refresh=1" : ""}`).then(r => r.json()),
Expand All @@ -42,8 +40,19 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) {
} catch {
return false;
}
};
useEffect(() => { load(); const iv = setInterval(load, 30_000); return () => clearInterval(iv); }, [apiBase]);
}, [apiBase]);
useEffect(() => {
const timeout = window.setTimeout(() => {
void load();
}, 0);
const iv = window.setInterval(() => {
void load();
}, 30_000);
return () => {
window.clearTimeout(timeout);
window.clearInterval(iv);
};
}, [load]);

const setActive = async (id: string | null) => {
await fetch(`${apiBase}/api/codex-auth/active`, {
Expand Down
25 changes: 18 additions & 7 deletions gui/src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { formatUptime } from "../formatUptime";
import { IconAlert, IconExternal, IconInfo, IconRefresh, IconX } from "../icons";
import { useI18n, Trans } from "../i18n";
Expand Down Expand Up @@ -198,14 +198,25 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
return () => clearInterval(interval);
}, [apiBase]);

const fetchModels = useCallback(async () => {
setModelsLoading(true);
try {
const response = await fetch(`${apiBase}/api/models`);
setModels(await response.json());
} catch {
// Keep the previous models list on transient failures.
} finally {
setModelsLoading(false);
}
}, [apiBase]);

useEffect(() => {
if (error) return;
setModelsLoading(true);
fetch(`${apiBase}/api/models`)
.then(r => r.json())
.then((data: ModelInfo[]) => { setModels(data); setModelsLoading(false); })
.catch(() => setModelsLoading(false));
}, [apiBase, error]);
const timeout = window.setTimeout(() => {
void fetchModels();
}, 0);
return () => window.clearTimeout(timeout);
}, [error, fetchModels]);

useEffect(() => {
if (!updateJob?.id || !updateJob.restart) return;
Expand Down
45 changes: 27 additions & 18 deletions gui/src/pages/Models.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Switch, Notice, EmptyState, Select } from "../ui";
import { IconChevron, IconBoxes, IconInfo } from "../icons";
import { useT } from "../i18n";
Expand Down Expand Up @@ -68,7 +68,7 @@ export default function Models({ apiBase }: { apiBase: string }) {
const [showThreadsCustom, setShowThreadsCustom] = useState(false);
const [v2HelpOpen, setV2HelpOpen] = useState(false);

const loadV2 = async () => {
const loadV2 = useCallback(async () => {
// Never let a toggle in flight be clobbered by the poll (same single-flight rule as models).
if (v2BusyRef.current) return;
try {
Expand All @@ -86,36 +86,45 @@ export default function Models({ apiBase }: { apiBase: string }) {
} catch {
setV2(null); // old server / network: hide the section instead of guessing
}
};
}, [apiBase]);

const load = async () => {
const load = useCallback(async () => {
try {
const [data, capsData] = await Promise.all([
fetch(`${apiBase}/api/models`).then(r => r.json()) as Promise<ModelRow[]>,
fetch(`${apiBase}/api/provider-context-caps`).then(r => r.json()) as Promise<ProviderContextCapsResponse>,
]);
void loadV2(); // best-effort, independent of the models fetch
setModels(data);
setDisabled(new Set(data.filter(m => m.disabled).map(m => m.namespaced)));
const [data, capsData] = await Promise.all([
fetch(`${apiBase}/api/models`).then(r => r.json()) as Promise<ModelRow[]>,
fetch(`${apiBase}/api/provider-context-caps`).then(r => r.json()) as Promise<ProviderContextCapsResponse>,
]);
void loadV2(); // best-effort, independent of the models fetch
setModels(data);
setDisabled(new Set(data.filter(m => m.disabled).map(m => m.namespaced)));
const value = typeof capsData.value === "number" && Number.isFinite(capsData.value) && capsData.value > 0
? capsData.value
: (typeof capsData.cap === "number" && Number.isFinite(capsData.cap) && capsData.cap > 0 ? capsData.cap : undefined);
if (value !== undefined) setContextCapValue(value);
setContextCaps(capsData.caps ?? {});
} catch {
setContextCaps(capsData.caps ?? {});
} catch {
setOk(false); setStatus(t("models.loadFail"));
} finally {
setLoading(false);
}
};
}, [apiBase, loadV2, t]);
useEffect(() => {
load();
const timeout = window.setTimeout(() => {
void load();
}, 0);
// Provider models resolve lazily (live /models + OAuth tokens), so a provider that wasn't ready
// on first load (e.g. anthropic right after login) would otherwise stay missing until a manual
// remove/re-add. Re-poll to pick it up; skip while a toggle PUT is in flight to avoid clobbering.
const timer = setInterval(() => { if (!busyRef.current) load(); }, 10000);
return () => clearInterval(timer);
}, [apiBase]);
const timer = window.setInterval(() => {
if (!busyRef.current) {
void load();
}
}, 10000);
return () => {
window.clearTimeout(timeout);
window.clearInterval(timer);
};
}, [load]);

const groups = useMemo(() => {
const g: Record<string, ModelRow[]> = {};
Expand Down
Loading
Loading