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
7 changes: 6 additions & 1 deletion emrg/gui/main.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -258,7 +258,7 @@ vision = false
function validateConfig(c) {
// 设计 §7.1:直接接收所需字段 + 基本类型检查(防写坏 config.toml 的健壮性,非安全设计)
const out = {};
for (const k of ["apiKey", "baseUrl", "model", "theme"]) {
for (const k of ["apiKey", "baseUrl", "model", "theme", "lang"]) {
if (c[k] !== undefined) out[k] = typeof c[k] === "string" ? c[k] : String(c[k]);
}
if (Array.isArray(c.models)) {
Expand DownExpand Up@@ -863,6 +863,7 @@ vision = false
models,
modelDetails,
theme: cfg.gui?.theme || "system", // §7.1:外观主题持久化(浅色/深色/跟随系统)
lang: cfg.gui?.lang || "", // rant 22:22:50:语言持久化(""=跟随系统 / zh / en)
};
});

Expand All@@ -886,6 +887,10 @@ vision = false
toml.gui = toml.gui || {};
toml.gui.theme = cfg.theme; // §7.1:主题持久化(浅色/深色/跟随系统)
}
if (cfg.lang !== undefined) {
toml.gui = toml.gui || {};
toml.gui.lang = cfg.lang; // rant 22:22:50:语言持久化(""=跟随系统 / zh / en)
}
if (cfg.models !== undefined) {
// §7.1:多模型保存——合并写 [[llm.models]](保留已有项的 context_window 等高级字段)
const existing = toml.llm.models || [];
Expand Down
21 changes: 20 additions & 1 deletion emrg/gui/renderer/src/App.tsx
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
import { useEffect } from "react";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { Shell } from "./components/Shell";
import { DaemonBridgeProvider } from "./components/DaemonBridgeProvider";
import { I18nProvider } from "./lib/i18n";
import { I18nProvider, setLocale, type Locale } from "./lib/i18n";

/**
* App — React 组件树根(Batch 0 骨架 + Batch 5 slice 2 daemon-event context)。
Expand All@@ -17,6 +18,24 @@ import { I18nProvider } from "./lib/i18n";
* 旧 vanilla renderer(js/*.js)保持不动直到 Batch 5 一次性切换(设计 D3)。
*/
export function App() {
// 启动恢复持久化外观偏好(rant 2026-08-27T22:22:50):theme/lang 从 config.toml
// 读回(getSettings)。window.emrg 缺失(jsdom / 预览)时静默降级。
useEffect(() => {
const emrg = (window as unknown as { emrg?: { getSettings?: () => Promise<{ theme?: string; lang?: string }> } }).emrg;
if (!emrg?.getSettings) return;
emrg
.getSettings()
.then((s) => {
if (s.lang) setLocale(s.lang as "" | Locale);
if (s.theme && s.theme !== "system") {
document.documentElement.setAttribute("data-theme", s.theme);
}
})
.catch(() => {
/* 读配置失败:保持默认(跟随系统) */
});
}, []);

return (
<ErrorBoundary>
<I18nProvider>
Expand Down
52 changes: 48 additions & 4 deletions emrg/gui/renderer/src/components/SettingsPanel.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -225,27 +225,71 @@ describe("SettingsPanel", () => {
);
});

it("applies theme option to documentElement", async () => {
setup();
it("applies theme option to documentElement and persists via saveSettings", async () => {
const { calls } = setup();
await screen.findByTestId("settings-tab-appearance");
fireEvent.click(screen.getByTestId("settings-tab-appearance"));
fireEvent.click(screen.getByTestId("theme-light"));
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
// 点击即持久化(rant 22:22:50):saveSettings 载荷含 theme
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { theme?: string };
expect(last.theme).toBe("light");
});
fireEvent.click(screen.getByTestId("theme-dark"));
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { theme?: string };
expect(last.theme).toBe("dark");
});
fireEvent.click(screen.getByTestId("theme-system"));
expect(document.documentElement.hasAttribute("data-theme")).toBe(false);
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { theme?: string };
expect(last.theme).toBe("system");
});
});

it("sets locale from language tab", async () => {
it("sets locale from language tab and persists via saveSettings", async () => {
const { calls } = setup();
const spy = vi.spyOn(i18n, "setLocale").mockImplementation((l): i18n.Locale => (l === "" ? "zh" : l));
setup();
await screen.findByTestId("settings-tab-language");
fireEvent.click(screen.getByTestId("settings-tab-language"));
fireEvent.click(screen.getByTestId("lang-zh"));
expect(spy).toHaveBeenCalledWith("zh");
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { lang?: string };
expect(last.lang).toBe("zh");
});
fireEvent.click(screen.getByTestId("lang-system"));
expect(spy).toHaveBeenCalledWith("");
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { lang?: string };
expect(last.lang).toBe("");
});
});

it("reopen reads persisted theme/lang and does not reset to system", async () => {
const { calls } = setup({
getSettings: vi.fn().mockResolvedValue({
apiKey: "sk-test",
baseUrl: "https://api.deepseek.com",
model: "deepseek-v3",
theme: "light",
lang: "en",
modelDetails: [],
}),
});
await screen.findByTestId("settings-tab-appearance");
fireEvent.click(screen.getByTestId("settings-tab-appearance"));
// 持久化值被应用(light 而非恒 "system" 重置)
await waitFor(() => expect(document.documentElement.getAttribute("data-theme")).toBe("light"));
// 语言:getSettings 读回 lang=en → setLocale 被调用
await waitFor(() => {
const spyCalls = calls.saveSettings.length; // 无点击 → 不新增持久化调用
expect(spyCalls).toBe(0);
});
expect(calls.saveSettings.length).toBe(0);
});

it("renders version and evolution count in about tab", async () => {
Expand Down
23 changes: 21 additions & 2 deletions emrg/gui/renderer/src/components/SettingsPanel.tsx
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { useEffect, useReducer, useRef, useState } from "react";
import { useI18n, setLocale } from "../lib/i18n";
import { useI18n, setLocale, type Locale } from "../lib/i18n";
import { dialogReducer, initialDialogState } from "../lib/dialog";
import { ConfirmDialog } from "./ConfirmDialog";

Expand DownExpand Up@@ -44,6 +44,7 @@ interface GetSettingsResult {
model?: string;
modelDetails?: { name: string; model?: string; vision?: boolean }[];
theme?: string;
lang?: string;
}

/** SettingsPanel 用到的 preload 桥方法(与 preload.js 通道对齐) */
Expand DownExpand Up@@ -121,6 +122,8 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
})),
);
applyTheme(s.theme || "system");
// 语言持久化(rant 22:22:50):config.toml 有 lang → 应用;""(跟随系统/旧配置)→ 不动
if (s.lang) setLocale(s.lang as "" | Locale);
})
.catch((e: Error) => setMsg({ kind: "err", text: t("settings.readFailed", { msg: e.message }) }));
void refreshGithubStatus();
Expand DownExpand Up@@ -150,6 +153,18 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
else root.setAttribute("data-theme", mode);
}

/** 外观偏好即点即持久化(rant 22:22:50):theme/lang 点击 → saveSettings 落盘
* config.toml(重开面板/重启 GUI 不回退)。成功静默;失败显示错误消息。 */
async function persistAppearance(patch: { theme?: string; lang?: string }) {
const b = bridge();
if (!b?.saveSettings) return;
try {
await b.saveSettings(patch);
} catch (e: unknown) {
setMsg({ kind: "err", text: t("settings.saveFailed", { msg: (e as Error).message }) });
}
}

// ── model tab ─────────────────────────────
function openModelForm(name: string | null, isDefault: boolean) {
setEditingName(isDefault ? name : name);
Expand DownExpand Up@@ -573,6 +588,7 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
onClick={() => {
setTheme(m);
applyTheme(m);
void persistAppearance({ theme: m });
}}
>
{t(`settings.theme${m[0].toUpperCase()}${m.slice(1)}`)}
Expand All@@ -598,7 +614,10 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
className="theme-option"
data-lang={loc}
data-testid={`lang-${loc || "system"}`}
onClick={() => setLocale(loc)}
onClick={() => {
setLocale(loc);
void persistAppearance({ lang: loc });
}}
>
{t(
loc === "" ? "settings.langFollowSystem" : loc === "zh" ? "settings.langZh" : "settings.langEn",
Expand Down
25 changes: 20 additions & 5 deletions emrg/gui/renderer/src/lib/i18n.tsx
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { DICTS } from "./i18n-dicts";

/**
Expand All@@ -17,6 +17,8 @@ interface I18nContextValue {
}

export const LOCALE_KEY = "emrg.locale";
/** setLocale 后广播给 I18nProvider 的事件(localStorage 写入本身不触发 React 重渲染) */
export const LOCALE_CHANGED_EVENT = "emrg:locale-changed";
export type Locale = "zh" | "en";

/** 完整词典(373 keys,zh/en 严格对齐)——由 vanilla i18n.js 机械生成,见 ./i18n-dicts.ts */
Expand DownExpand Up@@ -65,6 +67,12 @@ export function setLocale(loc: "" | Locale): Locale {
} catch {
/* ignore */
}
// 通知 I18nProvider 重读(React 版 provider 状态化:不广播则 UI 不刷新)
try {
if (typeof window !== "undefined") window.dispatchEvent(new Event(LOCALE_CHANGED_EVENT));
} catch {
/* ignore */
}
return getLocale();
}

Expand DownExpand Up@@ -102,14 +110,21 @@ export function I18nProvider({
dicts?: Record<string, Record<string, string>>;
children: ReactNode;
}) {
const l = detectLocale(lang);
const [locale, setLocaleState] = useState<Locale>(() => detectLocale(lang));
// 外部 setLocale(SettingsPanel 直接调用模块函数)→ 广播 LOCALE_CHANGED_EVENT →
// 本 provider 重读生效(React 化后 localStorage 写入本身不触发重渲染)
useEffect(() => {
const onChange = () => setLocaleState(detectLocale(lang));
window.addEventListener(LOCALE_CHANGED_EVENT, onChange);
return () => window.removeEventListener(LOCALE_CHANGED_EVENT, onChange);
}, [lang]);
const value = useMemo<I18nContextValue>(() => {
const dict = { ...(DEFAULT_DICTS[l] ?? DEFAULT_DICTS.en), ...(dicts?.[l] ?? {}) };
const dict = { ...(DEFAULT_DICTS[locale] ?? DEFAULT_DICTS.en), ...(dicts?.[locale] ?? {}) };
return {
lang: l,
lang: locale,
t: (key, params) => interpolate(key in dict ? dict[key] : (DICTS.zh[key] ?? key), params),
};
}, [l, dicts]);
}, [locale, dicts]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
7 changes: 6 additions & 1 deletion emrg/gui/main.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -258,7 +258,7 @@ vision = false
function validateConfig(c) {
// 设计 §7.1:直接接收所需字段 + 基本类型检查(防写坏 config.toml 的健壮性,非安全设计)
const out = {};
for (const k of ["apiKey", "baseUrl", "model", "theme"]) {
for (const k of ["apiKey", "baseUrl", "model", "theme", "lang"]) {
if (c[k] !== undefined) out[k] = typeof c[k] === "string" ? c[k] : String(c[k]);
}
if (Array.isArray(c.models)) {
Expand DownExpand Up@@ -863,6 +863,7 @@ vision = false
models,
modelDetails,
theme: cfg.gui?.theme || "system", // §7.1:外观主题持久化(浅色/深色/跟随系统)
lang: cfg.gui?.lang || "", // rant 22:22:50:语言持久化(""=跟随系统 / zh / en)
};
});

Expand All@@ -886,6 +887,10 @@ vision = false
toml.gui = toml.gui || {};
toml.gui.theme = cfg.theme; // §7.1:主题持久化(浅色/深色/跟随系统)
}
if (cfg.lang !== undefined) {
toml.gui = toml.gui || {};
toml.gui.lang = cfg.lang; // rant 22:22:50:语言持久化(""=跟随系统 / zh / en)
}
if (cfg.models !== undefined) {
// §7.1:多模型保存——合并写 [[llm.models]](保留已有项的 context_window 等高级字段)
const existing = toml.llm.models || [];
Expand Down
21 changes: 20 additions & 1 deletion emrg/gui/renderer/src/App.tsx
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
import { useEffect } from "react";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { Shell } from "./components/Shell";
import { DaemonBridgeProvider } from "./components/DaemonBridgeProvider";
import { I18nProvider } from "./lib/i18n";
import { I18nProvider, setLocale, type Locale } from "./lib/i18n";

/**
* App — React 组件树根(Batch 0 骨架 + Batch 5 slice 2 daemon-event context)。
Expand All@@ -17,6 +18,24 @@ import { I18nProvider } from "./lib/i18n";
* 旧 vanilla renderer(js/*.js)保持不动直到 Batch 5 一次性切换(设计 D3)。
*/
export function App() {
// 启动恢复持久化外观偏好(rant 2026-08-27T22:22:50):theme/lang 从 config.toml
// 读回(getSettings)。window.emrg 缺失(jsdom / 预览)时静默降级。
useEffect(() => {
const emrg = (window as unknown as { emrg?: { getSettings?: () => Promise<{ theme?: string; lang?: string }> } }).emrg;
if (!emrg?.getSettings) return;
emrg
.getSettings()
.then((s) => {
if (s.lang) setLocale(s.lang as "" | Locale);
if (s.theme && s.theme !== "system") {
document.documentElement.setAttribute("data-theme", s.theme);
}
})
.catch(() => {
/* 读配置失败:保持默认(跟随系统) */
});
}, []);

return (
<ErrorBoundary>
<I18nProvider>
Expand Down
52 changes: 48 additions & 4 deletions emrg/gui/renderer/src/components/SettingsPanel.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -225,27 +225,71 @@ describe("SettingsPanel", () => {
);
});

it("applies theme option to documentElement", async () => {
setup();
it("applies theme option to documentElement and persists via saveSettings", async () => {
const { calls } = setup();
await screen.findByTestId("settings-tab-appearance");
fireEvent.click(screen.getByTestId("settings-tab-appearance"));
fireEvent.click(screen.getByTestId("theme-light"));
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
// 点击即持久化(rant 22:22:50):saveSettings 载荷含 theme
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { theme?: string };
expect(last.theme).toBe("light");
});
fireEvent.click(screen.getByTestId("theme-dark"));
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { theme?: string };
expect(last.theme).toBe("dark");
});
fireEvent.click(screen.getByTestId("theme-system"));
expect(document.documentElement.hasAttribute("data-theme")).toBe(false);
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { theme?: string };
expect(last.theme).toBe("system");
});
});

it("sets locale from language tab", async () => {
it("sets locale from language tab and persists via saveSettings", async () => {
const { calls } = setup();
const spy = vi.spyOn(i18n, "setLocale").mockImplementation((l): i18n.Locale => (l === "" ? "zh" : l));
setup();
await screen.findByTestId("settings-tab-language");
fireEvent.click(screen.getByTestId("settings-tab-language"));
fireEvent.click(screen.getByTestId("lang-zh"));
expect(spy).toHaveBeenCalledWith("zh");
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { lang?: string };
expect(last.lang).toBe("zh");
});
fireEvent.click(screen.getByTestId("lang-system"));
expect(spy).toHaveBeenCalledWith("");
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { lang?: string };
expect(last.lang).toBe("");
});
});

it("reopen reads persisted theme/lang and does not reset to system", async () => {
const { calls } = setup({
getSettings: vi.fn().mockResolvedValue({
apiKey: "sk-test",
baseUrl: "https://api.deepseek.com",
model: "deepseek-v3",
theme: "light",
lang: "en",
modelDetails: [],
}),
});
await screen.findByTestId("settings-tab-appearance");
fireEvent.click(screen.getByTestId("settings-tab-appearance"));
// 持久化值被应用(light 而非恒 "system" 重置)
await waitFor(() => expect(document.documentElement.getAttribute("data-theme")).toBe("light"));
// 语言:getSettings 读回 lang=en → setLocale 被调用
await waitFor(() => {
const spyCalls = calls.saveSettings.length; // 无点击 → 不新增持久化调用
expect(spyCalls).toBe(0);
});
expect(calls.saveSettings.length).toBe(0);
});

it("renders version and evolution count in about tab", async () => {
Expand Down
23 changes: 21 additions & 2 deletions emrg/gui/renderer/src/components/SettingsPanel.tsx
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { useEffect, useReducer, useRef, useState } from "react";
import { useI18n, setLocale } from "../lib/i18n";
import { useI18n, setLocale, type Locale } from "../lib/i18n";
import { dialogReducer, initialDialogState } from "../lib/dialog";
import { ConfirmDialog } from "./ConfirmDialog";

Expand DownExpand Up@@ -44,6 +44,7 @@ interface GetSettingsResult {
model?: string;
modelDetails?: { name: string; model?: string; vision?: boolean }[];
theme?: string;
lang?: string;
}

/** SettingsPanel 用到的 preload 桥方法(与 preload.js 通道对齐) */
Expand DownExpand Up@@ -121,6 +122,8 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
})),
);
applyTheme(s.theme || "system");
// 语言持久化(rant 22:22:50):config.toml 有 lang → 应用;""(跟随系统/旧配置)→ 不动
if (s.lang) setLocale(s.lang as "" | Locale);
})
.catch((e: Error) => setMsg({ kind: "err", text: t("settings.readFailed", { msg: e.message }) }));
void refreshGithubStatus();
Expand DownExpand Up@@ -150,6 +153,18 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
else root.setAttribute("data-theme", mode);
}

/** 外观偏好即点即持久化(rant 22:22:50):theme/lang 点击 → saveSettings 落盘
* config.toml(重开面板/重启 GUI 不回退)。成功静默;失败显示错误消息。 */
async function persistAppearance(patch: { theme?: string; lang?: string }) {
const b = bridge();
if (!b?.saveSettings) return;
try {
await b.saveSettings(patch);
} catch (e: unknown) {
setMsg({ kind: "err", text: t("settings.saveFailed", { msg: (e as Error).message }) });
}
}

// ── model tab ─────────────────────────────
function openModelForm(name: string | null, isDefault: boolean) {
setEditingName(isDefault ? name : name);
Expand DownExpand Up@@ -573,6 +588,7 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
onClick={() => {
setTheme(m);
applyTheme(m);
void persistAppearance({ theme: m });
}}
>
{t(`settings.theme${m[0].toUpperCase()}${m.slice(1)}`)}
Expand All@@ -598,7 +614,10 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
className="theme-option"
data-lang={loc}
data-testid={`lang-${loc || "system"}`}
onClick={() => setLocale(loc)}
onClick={() => {
setLocale(loc);
void persistAppearance({ lang: loc });
}}
>
{t(
loc === "" ? "settings.langFollowSystem" : loc === "zh" ? "settings.langZh" : "settings.langEn",
Expand Down
25 changes: 20 additions & 5 deletions emrg/gui/renderer/src/lib/i18n.tsx
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { DICTS } from "./i18n-dicts";

/**
Expand All@@ -17,6 +17,8 @@ interface I18nContextValue {
}

export const LOCALE_KEY = "emrg.locale";
/** setLocale 后广播给 I18nProvider 的事件(localStorage 写入本身不触发 React 重渲染) */
export const LOCALE_CHANGED_EVENT = "emrg:locale-changed";
export type Locale = "zh" | "en";

/** 完整词典(373 keys,zh/en 严格对齐)——由 vanilla i18n.js 机械生成,见 ./i18n-dicts.ts */
Expand DownExpand Up@@ -65,6 +67,12 @@ export function setLocale(loc: "" | Locale): Locale {
} catch {
/* ignore */
}
// 通知 I18nProvider 重读(React 版 provider 状态化:不广播则 UI 不刷新)
try {
if (typeof window !== "undefined") window.dispatchEvent(new Event(LOCALE_CHANGED_EVENT));
} catch {
/* ignore */
}
return getLocale();
}

Expand DownExpand Up@@ -102,14 +110,21 @@ export function I18nProvider({
dicts?: Record<string, Record<string, string>>;
children: ReactNode;
}) {
const l = detectLocale(lang);
const [locale, setLocaleState] = useState<Locale>(() => detectLocale(lang));
// 外部 setLocale(SettingsPanel 直接调用模块函数)→ 广播 LOCALE_CHANGED_EVENT →
// 本 provider 重读生效(React 化后 localStorage 写入本身不触发重渲染)
useEffect(() => {
const onChange = () => setLocaleState(detectLocale(lang));
window.addEventListener(LOCALE_CHANGED_EVENT, onChange);
return () => window.removeEventListener(LOCALE_CHANGED_EVENT, onChange);
}, [lang]);
const value = useMemo<I18nContextValue>(() => {
const dict = { ...(DEFAULT_DICTS[l] ?? DEFAULT_DICTS.en), ...(dicts?.[l] ?? {}) };
const dict = { ...(DEFAULT_DICTS[locale] ?? DEFAULT_DICTS.en), ...(dicts?.[locale] ?? {}) };
return {
lang: l,
lang: locale,
t: (key, params) => interpolate(key in dict ? dict[key] : (DICTS.zh[key] ?? key), params),
};
}, [l, dicts]);
}, [locale, dicts]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
7 changes: 6 additions & 1 deletion emrg/gui/main.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -258,7 +258,7 @@ vision = false
function validateConfig(c) {
// 设计 §7.1:直接接收所需字段 + 基本类型检查(防写坏 config.toml 的健壮性,非安全设计)
const out = {};
for (const k of ["apiKey", "baseUrl", "model", "theme"]) {
for (const k of ["apiKey", "baseUrl", "model", "theme", "lang"]) {
if (c[k] !== undefined) out[k] = typeof c[k] === "string" ? c[k] : String(c[k]);
}
if (Array.isArray(c.models)) {
Expand DownExpand Up@@ -863,6 +863,7 @@ vision = false
models,
modelDetails,
theme: cfg.gui?.theme || "system", // §7.1:外观主题持久化(浅色/深色/跟随系统)
lang: cfg.gui?.lang || "", // rant 22:22:50:语言持久化(""=跟随系统 / zh / en)
};
});

Expand All@@ -886,6 +887,10 @@ vision = false
toml.gui = toml.gui || {};
toml.gui.theme = cfg.theme; // §7.1:主题持久化(浅色/深色/跟随系统)
}
if (cfg.lang !== undefined) {
toml.gui = toml.gui || {};
toml.gui.lang = cfg.lang; // rant 22:22:50:语言持久化(""=跟随系统 / zh / en)
}
if (cfg.models !== undefined) {
// §7.1:多模型保存——合并写 [[llm.models]](保留已有项的 context_window 等高级字段)
const existing = toml.llm.models || [];
Expand Down
21 changes: 20 additions & 1 deletion emrg/gui/renderer/src/App.tsx
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
import { useEffect } from "react";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { Shell } from "./components/Shell";
import { DaemonBridgeProvider } from "./components/DaemonBridgeProvider";
import { I18nProvider } from "./lib/i18n";
import { I18nProvider, setLocale, type Locale } from "./lib/i18n";

/**
* App — React 组件树根(Batch 0 骨架 + Batch 5 slice 2 daemon-event context)。
Expand All@@ -17,6 +18,24 @@ import { I18nProvider } from "./lib/i18n";
* 旧 vanilla renderer(js/*.js)保持不动直到 Batch 5 一次性切换(设计 D3)。
*/
export function App() {
// 启动恢复持久化外观偏好(rant 2026-08-27T22:22:50):theme/lang 从 config.toml
// 读回(getSettings)。window.emrg 缺失(jsdom / 预览)时静默降级。
useEffect(() => {
const emrg = (window as unknown as { emrg?: { getSettings?: () => Promise<{ theme?: string; lang?: string }> } }).emrg;
if (!emrg?.getSettings) return;
emrg
.getSettings()
.then((s) => {
if (s.lang) setLocale(s.lang as "" | Locale);
if (s.theme && s.theme !== "system") {
document.documentElement.setAttribute("data-theme", s.theme);
}
})
.catch(() => {
/* 读配置失败:保持默认(跟随系统) */
});
}, []);

return (
<ErrorBoundary>
<I18nProvider>
Expand Down
52 changes: 48 additions & 4 deletions emrg/gui/renderer/src/components/SettingsPanel.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -225,27 +225,71 @@ describe("SettingsPanel", () => {
);
});

it("applies theme option to documentElement", async () => {
setup();
it("applies theme option to documentElement and persists via saveSettings", async () => {
const { calls } = setup();
await screen.findByTestId("settings-tab-appearance");
fireEvent.click(screen.getByTestId("settings-tab-appearance"));
fireEvent.click(screen.getByTestId("theme-light"));
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
// 点击即持久化(rant 22:22:50):saveSettings 载荷含 theme
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { theme?: string };
expect(last.theme).toBe("light");
});
fireEvent.click(screen.getByTestId("theme-dark"));
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { theme?: string };
expect(last.theme).toBe("dark");
});
fireEvent.click(screen.getByTestId("theme-system"));
expect(document.documentElement.hasAttribute("data-theme")).toBe(false);
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { theme?: string };
expect(last.theme).toBe("system");
});
});

it("sets locale from language tab", async () => {
it("sets locale from language tab and persists via saveSettings", async () => {
const { calls } = setup();
const spy = vi.spyOn(i18n, "setLocale").mockImplementation((l): i18n.Locale => (l === "" ? "zh" : l));
setup();
await screen.findByTestId("settings-tab-language");
fireEvent.click(screen.getByTestId("settings-tab-language"));
fireEvent.click(screen.getByTestId("lang-zh"));
expect(spy).toHaveBeenCalledWith("zh");
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { lang?: string };
expect(last.lang).toBe("zh");
});
fireEvent.click(screen.getByTestId("lang-system"));
expect(spy).toHaveBeenCalledWith("");
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { lang?: string };
expect(last.lang).toBe("");
});
});

it("reopen reads persisted theme/lang and does not reset to system", async () => {
const { calls } = setup({
getSettings: vi.fn().mockResolvedValue({
apiKey: "sk-test",
baseUrl: "https://api.deepseek.com",
model: "deepseek-v3",
theme: "light",
lang: "en",
modelDetails: [],
}),
});
await screen.findByTestId("settings-tab-appearance");
fireEvent.click(screen.getByTestId("settings-tab-appearance"));
// 持久化值被应用(light 而非恒 "system" 重置)
await waitFor(() => expect(document.documentElement.getAttribute("data-theme")).toBe("light"));
// 语言:getSettings 读回 lang=en → setLocale 被调用
await waitFor(() => {
const spyCalls = calls.saveSettings.length; // 无点击 → 不新增持久化调用
expect(spyCalls).toBe(0);
});
expect(calls.saveSettings.length).toBe(0);
});

it("renders version and evolution count in about tab", async () => {
Expand Down
23 changes: 21 additions & 2 deletions emrg/gui/renderer/src/components/SettingsPanel.tsx
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { useEffect, useReducer, useRef, useState } from "react";
import { useI18n, setLocale } from "../lib/i18n";
import { useI18n, setLocale, type Locale } from "../lib/i18n";
import { dialogReducer, initialDialogState } from "../lib/dialog";
import { ConfirmDialog } from "./ConfirmDialog";

Expand DownExpand Up@@ -44,6 +44,7 @@ interface GetSettingsResult {
model?: string;
modelDetails?: { name: string; model?: string; vision?: boolean }[];
theme?: string;
lang?: string;
}

/** SettingsPanel 用到的 preload 桥方法(与 preload.js 通道对齐) */
Expand DownExpand Up@@ -121,6 +122,8 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
})),
);
applyTheme(s.theme || "system");
// 语言持久化(rant 22:22:50):config.toml 有 lang → 应用;""(跟随系统/旧配置)→ 不动
if (s.lang) setLocale(s.lang as "" | Locale);
})
.catch((e: Error) => setMsg({ kind: "err", text: t("settings.readFailed", { msg: e.message }) }));
void refreshGithubStatus();
Expand DownExpand Up@@ -150,6 +153,18 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
else root.setAttribute("data-theme", mode);
}

/** 外观偏好即点即持久化(rant 22:22:50):theme/lang 点击 → saveSettings 落盘
* config.toml(重开面板/重启 GUI 不回退)。成功静默;失败显示错误消息。 */
async function persistAppearance(patch: { theme?: string; lang?: string }) {
const b = bridge();
if (!b?.saveSettings) return;
try {
await b.saveSettings(patch);
} catch (e: unknown) {
setMsg({ kind: "err", text: t("settings.saveFailed", { msg: (e as Error).message }) });
}
}

// ── model tab ─────────────────────────────
function openModelForm(name: string | null, isDefault: boolean) {
setEditingName(isDefault ? name : name);
Expand DownExpand Up@@ -573,6 +588,7 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
onClick={() => {
setTheme(m);
applyTheme(m);
void persistAppearance({ theme: m });
}}
>
{t(`settings.theme${m[0].toUpperCase()}${m.slice(1)}`)}
Expand All@@ -598,7 +614,10 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
className="theme-option"
data-lang={loc}
data-testid={`lang-${loc || "system"}`}
onClick={() => setLocale(loc)}
onClick={() => {
setLocale(loc);
void persistAppearance({ lang: loc });
}}
>
{t(
loc === "" ? "settings.langFollowSystem" : loc === "zh" ? "settings.langZh" : "settings.langEn",
Expand Down
25 changes: 20 additions & 5 deletions emrg/gui/renderer/src/lib/i18n.tsx
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { DICTS } from "./i18n-dicts";

/**
Expand All@@ -17,6 +17,8 @@ interface I18nContextValue {
}

export const LOCALE_KEY = "emrg.locale";
/** setLocale 后广播给 I18nProvider 的事件(localStorage 写入本身不触发 React 重渲染) */
export const LOCALE_CHANGED_EVENT = "emrg:locale-changed";
export type Locale = "zh" | "en";

/** 完整词典(373 keys,zh/en 严格对齐)——由 vanilla i18n.js 机械生成,见 ./i18n-dicts.ts */
Expand DownExpand Up@@ -65,6 +67,12 @@ export function setLocale(loc: "" | Locale): Locale {
} catch {
/* ignore */
}
// 通知 I18nProvider 重读(React 版 provider 状态化:不广播则 UI 不刷新)
try {
if (typeof window !== "undefined") window.dispatchEvent(new Event(LOCALE_CHANGED_EVENT));
} catch {
/* ignore */
}
return getLocale();
}

Expand DownExpand Up@@ -102,14 +110,21 @@ export function I18nProvider({
dicts?: Record<string, Record<string, string>>;
children: ReactNode;
}) {
const l = detectLocale(lang);
const [locale, setLocaleState] = useState<Locale>(() => detectLocale(lang));
// 外部 setLocale(SettingsPanel 直接调用模块函数)→ 广播 LOCALE_CHANGED_EVENT →
// 本 provider 重读生效(React 化后 localStorage 写入本身不触发重渲染)
useEffect(() => {
const onChange = () => setLocaleState(detectLocale(lang));
window.addEventListener(LOCALE_CHANGED_EVENT, onChange);
return () => window.removeEventListener(LOCALE_CHANGED_EVENT, onChange);
}, [lang]);
const value = useMemo<I18nContextValue>(() => {
const dict = { ...(DEFAULT_DICTS[l] ?? DEFAULT_DICTS.en), ...(dicts?.[l] ?? {}) };
const dict = { ...(DEFAULT_DICTS[locale] ?? DEFAULT_DICTS.en), ...(dicts?.[locale] ?? {}) };
return {
lang: l,
lang: locale,
t: (key, params) => interpolate(key in dict ? dict[key] : (DICTS.zh[key] ?? key), params),
};
}, [l, dicts]);
}, [locale, dicts]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
7 changes: 6 additions & 1 deletion emrg/gui/main.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -258,7 +258,7 @@ vision = false
function validateConfig(c) {
// 设计 §7.1:直接接收所需字段 + 基本类型检查(防写坏 config.toml 的健壮性,非安全设计)
const out = {};
for (const k of ["apiKey", "baseUrl", "model", "theme"]) {
for (const k of ["apiKey", "baseUrl", "model", "theme", "lang"]) {
if (c[k] !== undefined) out[k] = typeof c[k] === "string" ? c[k] : String(c[k]);
}
if (Array.isArray(c.models)) {
Expand DownExpand Up@@ -863,6 +863,7 @@ vision = false
models,
modelDetails,
theme: cfg.gui?.theme || "system", // §7.1:外观主题持久化(浅色/深色/跟随系统)
lang: cfg.gui?.lang || "", // rant 22:22:50:语言持久化(""=跟随系统 / zh / en)
};
});

Expand All@@ -886,6 +887,10 @@ vision = false
toml.gui = toml.gui || {};
toml.gui.theme = cfg.theme; // §7.1:主题持久化(浅色/深色/跟随系统)
}
if (cfg.lang !== undefined) {
toml.gui = toml.gui || {};
toml.gui.lang = cfg.lang; // rant 22:22:50:语言持久化(""=跟随系统 / zh / en)
}
if (cfg.models !== undefined) {
// §7.1:多模型保存——合并写 [[llm.models]](保留已有项的 context_window 等高级字段)
const existing = toml.llm.models || [];
Expand Down
21 changes: 20 additions & 1 deletion emrg/gui/renderer/src/App.tsx
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
import { useEffect } from "react";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { Shell } from "./components/Shell";
import { DaemonBridgeProvider } from "./components/DaemonBridgeProvider";
import { I18nProvider } from "./lib/i18n";
import { I18nProvider, setLocale, type Locale } from "./lib/i18n";

/**
* App — React 组件树根(Batch 0 骨架 + Batch 5 slice 2 daemon-event context)。
Expand All@@ -17,6 +18,24 @@ import { I18nProvider } from "./lib/i18n";
* 旧 vanilla renderer(js/*.js)保持不动直到 Batch 5 一次性切换(设计 D3)。
*/
export function App() {
// 启动恢复持久化外观偏好(rant 2026-08-27T22:22:50):theme/lang 从 config.toml
// 读回(getSettings)。window.emrg 缺失(jsdom / 预览)时静默降级。
useEffect(() => {
const emrg = (window as unknown as { emrg?: { getSettings?: () => Promise<{ theme?: string; lang?: string }> } }).emrg;
if (!emrg?.getSettings) return;
emrg
.getSettings()
.then((s) => {
if (s.lang) setLocale(s.lang as "" | Locale);
if (s.theme && s.theme !== "system") {
document.documentElement.setAttribute("data-theme", s.theme);
}
})
.catch(() => {
/* 读配置失败:保持默认(跟随系统) */
});
}, []);

return (
<ErrorBoundary>
<I18nProvider>
Expand Down
52 changes: 48 additions & 4 deletions emrg/gui/renderer/src/components/SettingsPanel.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -225,27 +225,71 @@ describe("SettingsPanel", () => {
);
});

it("applies theme option to documentElement", async () => {
setup();
it("applies theme option to documentElement and persists via saveSettings", async () => {
const { calls } = setup();
await screen.findByTestId("settings-tab-appearance");
fireEvent.click(screen.getByTestId("settings-tab-appearance"));
fireEvent.click(screen.getByTestId("theme-light"));
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
// 点击即持久化(rant 22:22:50):saveSettings 载荷含 theme
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { theme?: string };
expect(last.theme).toBe("light");
});
fireEvent.click(screen.getByTestId("theme-dark"));
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { theme?: string };
expect(last.theme).toBe("dark");
});
fireEvent.click(screen.getByTestId("theme-system"));
expect(document.documentElement.hasAttribute("data-theme")).toBe(false);
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { theme?: string };
expect(last.theme).toBe("system");
});
});

it("sets locale from language tab", async () => {
it("sets locale from language tab and persists via saveSettings", async () => {
const { calls } = setup();
const spy = vi.spyOn(i18n, "setLocale").mockImplementation((l): i18n.Locale => (l === "" ? "zh" : l));
setup();
await screen.findByTestId("settings-tab-language");
fireEvent.click(screen.getByTestId("settings-tab-language"));
fireEvent.click(screen.getByTestId("lang-zh"));
expect(spy).toHaveBeenCalledWith("zh");
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { lang?: string };
expect(last.lang).toBe("zh");
});
fireEvent.click(screen.getByTestId("lang-system"));
expect(spy).toHaveBeenCalledWith("");
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { lang?: string };
expect(last.lang).toBe("");
});
});

it("reopen reads persisted theme/lang and does not reset to system", async () => {
const { calls } = setup({
getSettings: vi.fn().mockResolvedValue({
apiKey: "sk-test",
baseUrl: "https://api.deepseek.com",
model: "deepseek-v3",
theme: "light",
lang: "en",
modelDetails: [],
}),
});
await screen.findByTestId("settings-tab-appearance");
fireEvent.click(screen.getByTestId("settings-tab-appearance"));
// 持久化值被应用(light 而非恒 "system" 重置)
await waitFor(() => expect(document.documentElement.getAttribute("data-theme")).toBe("light"));
// 语言:getSettings 读回 lang=en → setLocale 被调用
await waitFor(() => {
const spyCalls = calls.saveSettings.length; // 无点击 → 不新增持久化调用
expect(spyCalls).toBe(0);
});
expect(calls.saveSettings.length).toBe(0);
});

it("renders version and evolution count in about tab", async () => {
Expand Down
23 changes: 21 additions & 2 deletions emrg/gui/renderer/src/components/SettingsPanel.tsx
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { useEffect, useReducer, useRef, useState } from "react";
import { useI18n, setLocale } from "../lib/i18n";
import { useI18n, setLocale, type Locale } from "../lib/i18n";
import { dialogReducer, initialDialogState } from "../lib/dialog";
import { ConfirmDialog } from "./ConfirmDialog";

Expand DownExpand Up@@ -44,6 +44,7 @@ interface GetSettingsResult {
model?: string;
modelDetails?: { name: string; model?: string; vision?: boolean }[];
theme?: string;
lang?: string;
}

/** SettingsPanel 用到的 preload 桥方法(与 preload.js 通道对齐) */
Expand DownExpand Up@@ -121,6 +122,8 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
})),
);
applyTheme(s.theme || "system");
// 语言持久化(rant 22:22:50):config.toml 有 lang → 应用;""(跟随系统/旧配置)→ 不动
if (s.lang) setLocale(s.lang as "" | Locale);
})
.catch((e: Error) => setMsg({ kind: "err", text: t("settings.readFailed", { msg: e.message }) }));
void refreshGithubStatus();
Expand DownExpand Up@@ -150,6 +153,18 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
else root.setAttribute("data-theme", mode);
}

/** 外观偏好即点即持久化(rant 22:22:50):theme/lang 点击 → saveSettings 落盘
* config.toml(重开面板/重启 GUI 不回退)。成功静默;失败显示错误消息。 */
async function persistAppearance(patch: { theme?: string; lang?: string }) {
const b = bridge();
if (!b?.saveSettings) return;
try {
await b.saveSettings(patch);
} catch (e: unknown) {
setMsg({ kind: "err", text: t("settings.saveFailed", { msg: (e as Error).message }) });
}
}

// ── model tab ─────────────────────────────
function openModelForm(name: string | null, isDefault: boolean) {
setEditingName(isDefault ? name : name);
Expand DownExpand Up@@ -573,6 +588,7 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
onClick={() => {
setTheme(m);
applyTheme(m);
void persistAppearance({ theme: m });
}}
>
{t(`settings.theme${m[0].toUpperCase()}${m.slice(1)}`)}
Expand All@@ -598,7 +614,10 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
className="theme-option"
data-lang={loc}
data-testid={`lang-${loc || "system"}`}
onClick={() => setLocale(loc)}
onClick={() => {
setLocale(loc);
void persistAppearance({ lang: loc });
}}
>
{t(
loc === "" ? "settings.langFollowSystem" : loc === "zh" ? "settings.langZh" : "settings.langEn",
Expand Down
25 changes: 20 additions & 5 deletions emrg/gui/renderer/src/lib/i18n.tsx
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { DICTS } from "./i18n-dicts";

/**
Expand All@@ -17,6 +17,8 @@ interface I18nContextValue {
}

export const LOCALE_KEY = "emrg.locale";
/** setLocale 后广播给 I18nProvider 的事件(localStorage 写入本身不触发 React 重渲染) */
export const LOCALE_CHANGED_EVENT = "emrg:locale-changed";
export type Locale = "zh" | "en";

/** 完整词典(373 keys,zh/en 严格对齐)——由 vanilla i18n.js 机械生成,见 ./i18n-dicts.ts */
Expand DownExpand Up@@ -65,6 +67,12 @@ export function setLocale(loc: "" | Locale): Locale {
} catch {
/* ignore */
}
// 通知 I18nProvider 重读(React 版 provider 状态化:不广播则 UI 不刷新)
try {
if (typeof window !== "undefined") window.dispatchEvent(new Event(LOCALE_CHANGED_EVENT));
} catch {
/* ignore */
}
return getLocale();
}

Expand DownExpand Up@@ -102,14 +110,21 @@ export function I18nProvider({
dicts?: Record<string, Record<string, string>>;
children: ReactNode;
}) {
const l = detectLocale(lang);
const [locale, setLocaleState] = useState<Locale>(() => detectLocale(lang));
// 外部 setLocale(SettingsPanel 直接调用模块函数)→ 广播 LOCALE_CHANGED_EVENT →
// 本 provider 重读生效(React 化后 localStorage 写入本身不触发重渲染)
useEffect(() => {
const onChange = () => setLocaleState(detectLocale(lang));
window.addEventListener(LOCALE_CHANGED_EVENT, onChange);
return () => window.removeEventListener(LOCALE_CHANGED_EVENT, onChange);
}, [lang]);
const value = useMemo<I18nContextValue>(() => {
const dict = { ...(DEFAULT_DICTS[l] ?? DEFAULT_DICTS.en), ...(dicts?.[l] ?? {}) };
const dict = { ...(DEFAULT_DICTS[locale] ?? DEFAULT_DICTS.en), ...(dicts?.[locale] ?? {}) };
return {
lang: l,
lang: locale,
t: (key, params) => interpolate(key in dict ? dict[key] : (DICTS.zh[key] ?? key), params),
};
}, [l, dicts]);
}, [locale, dicts]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
7 changes: 6 additions & 1 deletion emrg/gui/main.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -258,7 +258,7 @@ vision = false
function validateConfig(c) {
// 设计 §7.1:直接接收所需字段 + 基本类型检查(防写坏 config.toml 的健壮性,非安全设计)
const out = {};
for (const k of ["apiKey", "baseUrl", "model", "theme"]) {
for (const k of ["apiKey", "baseUrl", "model", "theme", "lang"]) {
if (c[k] !== undefined) out[k] = typeof c[k] === "string" ? c[k] : String(c[k]);
}
if (Array.isArray(c.models)) {
Expand DownExpand Up@@ -863,6 +863,7 @@ vision = false
models,
modelDetails,
theme: cfg.gui?.theme || "system", // §7.1:外观主题持久化(浅色/深色/跟随系统)
lang: cfg.gui?.lang || "", // rant 22:22:50:语言持久化(""=跟随系统 / zh / en)
};
});

Expand All@@ -886,6 +887,10 @@ vision = false
toml.gui = toml.gui || {};
toml.gui.theme = cfg.theme; // §7.1:主题持久化(浅色/深色/跟随系统)
}
if (cfg.lang !== undefined) {
toml.gui = toml.gui || {};
toml.gui.lang = cfg.lang; // rant 22:22:50:语言持久化(""=跟随系统 / zh / en)
}
if (cfg.models !== undefined) {
// §7.1:多模型保存——合并写 [[llm.models]](保留已有项的 context_window 等高级字段)
const existing = toml.llm.models || [];
Expand Down
21 changes: 20 additions & 1 deletion emrg/gui/renderer/src/App.tsx
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
import { useEffect } from "react";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { Shell } from "./components/Shell";
import { DaemonBridgeProvider } from "./components/DaemonBridgeProvider";
import { I18nProvider } from "./lib/i18n";
import { I18nProvider, setLocale, type Locale } from "./lib/i18n";

/**
* App — React 组件树根(Batch 0 骨架 + Batch 5 slice 2 daemon-event context)。
Expand All@@ -17,6 +18,24 @@ import { I18nProvider } from "./lib/i18n";
* 旧 vanilla renderer(js/*.js)保持不动直到 Batch 5 一次性切换(设计 D3)。
*/
export function App() {
// 启动恢复持久化外观偏好(rant 2026-08-27T22:22:50):theme/lang 从 config.toml
// 读回(getSettings)。window.emrg 缺失(jsdom / 预览)时静默降级。
useEffect(() => {
const emrg = (window as unknown as { emrg?: { getSettings?: () => Promise<{ theme?: string; lang?: string }> } }).emrg;
if (!emrg?.getSettings) return;
emrg
.getSettings()
.then((s) => {
if (s.lang) setLocale(s.lang as "" | Locale);
if (s.theme && s.theme !== "system") {
document.documentElement.setAttribute("data-theme", s.theme);
}
})
.catch(() => {
/* 读配置失败:保持默认(跟随系统) */
});
}, []);

return (
<ErrorBoundary>
<I18nProvider>
Expand Down
52 changes: 48 additions & 4 deletions emrg/gui/renderer/src/components/SettingsPanel.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -225,27 +225,71 @@ describe("SettingsPanel", () => {
);
});

it("applies theme option to documentElement", async () => {
setup();
it("applies theme option to documentElement and persists via saveSettings", async () => {
const { calls } = setup();
await screen.findByTestId("settings-tab-appearance");
fireEvent.click(screen.getByTestId("settings-tab-appearance"));
fireEvent.click(screen.getByTestId("theme-light"));
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
// 点击即持久化(rant 22:22:50):saveSettings 载荷含 theme
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { theme?: string };
expect(last.theme).toBe("light");
});
fireEvent.click(screen.getByTestId("theme-dark"));
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { theme?: string };
expect(last.theme).toBe("dark");
});
fireEvent.click(screen.getByTestId("theme-system"));
expect(document.documentElement.hasAttribute("data-theme")).toBe(false);
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { theme?: string };
expect(last.theme).toBe("system");
});
});

it("sets locale from language tab", async () => {
it("sets locale from language tab and persists via saveSettings", async () => {
const { calls } = setup();
const spy = vi.spyOn(i18n, "setLocale").mockImplementation((l): i18n.Locale => (l === "" ? "zh" : l));
setup();
await screen.findByTestId("settings-tab-language");
fireEvent.click(screen.getByTestId("settings-tab-language"));
fireEvent.click(screen.getByTestId("lang-zh"));
expect(spy).toHaveBeenCalledWith("zh");
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { lang?: string };
expect(last.lang).toBe("zh");
});
fireEvent.click(screen.getByTestId("lang-system"));
expect(spy).toHaveBeenCalledWith("");
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { lang?: string };
expect(last.lang).toBe("");
});
});

it("reopen reads persisted theme/lang and does not reset to system", async () => {
const { calls } = setup({
getSettings: vi.fn().mockResolvedValue({
apiKey: "sk-test",
baseUrl: "https://api.deepseek.com",
model: "deepseek-v3",
theme: "light",
lang: "en",
modelDetails: [],
}),
});
await screen.findByTestId("settings-tab-appearance");
fireEvent.click(screen.getByTestId("settings-tab-appearance"));
// 持久化值被应用(light 而非恒 "system" 重置)
await waitFor(() => expect(document.documentElement.getAttribute("data-theme")).toBe("light"));
// 语言:getSettings 读回 lang=en → setLocale 被调用
await waitFor(() => {
const spyCalls = calls.saveSettings.length; // 无点击 → 不新增持久化调用
expect(spyCalls).toBe(0);
});
expect(calls.saveSettings.length).toBe(0);
});

it("renders version and evolution count in about tab", async () => {
Expand Down
23 changes: 21 additions & 2 deletions emrg/gui/renderer/src/components/SettingsPanel.tsx
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { useEffect, useReducer, useRef, useState } from "react";
import { useI18n, setLocale } from "../lib/i18n";
import { useI18n, setLocale, type Locale } from "../lib/i18n";
import { dialogReducer, initialDialogState } from "../lib/dialog";
import { ConfirmDialog } from "./ConfirmDialog";

Expand DownExpand Up@@ -44,6 +44,7 @@ interface GetSettingsResult {
model?: string;
modelDetails?: { name: string; model?: string; vision?: boolean }[];
theme?: string;
lang?: string;
}

/** SettingsPanel 用到的 preload 桥方法(与 preload.js 通道对齐) */
Expand DownExpand Up@@ -121,6 +122,8 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
})),
);
applyTheme(s.theme || "system");
// 语言持久化(rant 22:22:50):config.toml 有 lang → 应用;""(跟随系统/旧配置)→ 不动
if (s.lang) setLocale(s.lang as "" | Locale);
})
.catch((e: Error) => setMsg({ kind: "err", text: t("settings.readFailed", { msg: e.message }) }));
void refreshGithubStatus();
Expand DownExpand Up@@ -150,6 +153,18 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
else root.setAttribute("data-theme", mode);
}

/** 外观偏好即点即持久化(rant 22:22:50):theme/lang 点击 → saveSettings 落盘
* config.toml(重开面板/重启 GUI 不回退)。成功静默;失败显示错误消息。 */
async function persistAppearance(patch: { theme?: string; lang?: string }) {
const b = bridge();
if (!b?.saveSettings) return;
try {
await b.saveSettings(patch);
} catch (e: unknown) {
setMsg({ kind: "err", text: t("settings.saveFailed", { msg: (e as Error).message }) });
}
}

// ── model tab ─────────────────────────────
function openModelForm(name: string | null, isDefault: boolean) {
setEditingName(isDefault ? name : name);
Expand DownExpand Up@@ -573,6 +588,7 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
onClick={() => {
setTheme(m);
applyTheme(m);
void persistAppearance({ theme: m });
}}
>
{t(`settings.theme${m[0].toUpperCase()}${m.slice(1)}`)}
Expand All@@ -598,7 +614,10 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
className="theme-option"
data-lang={loc}
data-testid={`lang-${loc || "system"}`}
onClick={() => setLocale(loc)}
onClick={() => {
setLocale(loc);
void persistAppearance({ lang: loc });
}}
>
{t(
loc === "" ? "settings.langFollowSystem" : loc === "zh" ? "settings.langZh" : "settings.langEn",
Expand Down
25 changes: 20 additions & 5 deletions emrg/gui/renderer/src/lib/i18n.tsx
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { DICTS } from "./i18n-dicts";

/**
Expand All@@ -17,6 +17,8 @@ interface I18nContextValue {
}

export const LOCALE_KEY = "emrg.locale";
/** setLocale 后广播给 I18nProvider 的事件(localStorage 写入本身不触发 React 重渲染) */
export const LOCALE_CHANGED_EVENT = "emrg:locale-changed";
export type Locale = "zh" | "en";

/** 完整词典(373 keys,zh/en 严格对齐)——由 vanilla i18n.js 机械生成,见 ./i18n-dicts.ts */
Expand DownExpand Up@@ -65,6 +67,12 @@ export function setLocale(loc: "" | Locale): Locale {
} catch {
/* ignore */
}
// 通知 I18nProvider 重读(React 版 provider 状态化:不广播则 UI 不刷新)
try {
if (typeof window !== "undefined") window.dispatchEvent(new Event(LOCALE_CHANGED_EVENT));
} catch {
/* ignore */
}
return getLocale();
}

Expand DownExpand Up@@ -102,14 +110,21 @@ export function I18nProvider({
dicts?: Record<string, Record<string, string>>;
children: ReactNode;
}) {
const l = detectLocale(lang);
const [locale, setLocaleState] = useState<Locale>(() => detectLocale(lang));
// 外部 setLocale(SettingsPanel 直接调用模块函数)→ 广播 LOCALE_CHANGED_EVENT →
// 本 provider 重读生效(React 化后 localStorage 写入本身不触发重渲染)
useEffect(() => {
const onChange = () => setLocaleState(detectLocale(lang));
window.addEventListener(LOCALE_CHANGED_EVENT, onChange);
return () => window.removeEventListener(LOCALE_CHANGED_EVENT, onChange);
}, [lang]);
const value = useMemo<I18nContextValue>(() => {
const dict = { ...(DEFAULT_DICTS[l] ?? DEFAULT_DICTS.en), ...(dicts?.[l] ?? {}) };
const dict = { ...(DEFAULT_DICTS[locale] ?? DEFAULT_DICTS.en), ...(dicts?.[locale] ?? {}) };
return {
lang: l,
lang: locale,
t: (key, params) => interpolate(key in dict ? dict[key] : (DICTS.zh[key] ?? key), params),
};
}, [l, dicts]);
}, [locale, dicts]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
7 changes: 6 additions & 1 deletion emrg/gui/main.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -258,7 +258,7 @@ vision = false
function validateConfig(c) {
// 设计 §7.1:直接接收所需字段 + 基本类型检查(防写坏 config.toml 的健壮性,非安全设计)
const out = {};
for (const k of ["apiKey", "baseUrl", "model", "theme"]) {
for (const k of ["apiKey", "baseUrl", "model", "theme", "lang"]) {
if (c[k] !== undefined) out[k] = typeof c[k] === "string" ? c[k] : String(c[k]);
}
if (Array.isArray(c.models)) {
Expand DownExpand Up@@ -863,6 +863,7 @@ vision = false
models,
modelDetails,
theme: cfg.gui?.theme || "system", // §7.1:外观主题持久化(浅色/深色/跟随系统)
lang: cfg.gui?.lang || "", // rant 22:22:50:语言持久化(""=跟随系统 / zh / en)
};
});

Expand All@@ -886,6 +887,10 @@ vision = false
toml.gui = toml.gui || {};
toml.gui.theme = cfg.theme; // §7.1:主题持久化(浅色/深色/跟随系统)
}
if (cfg.lang !== undefined) {
toml.gui = toml.gui || {};
toml.gui.lang = cfg.lang; // rant 22:22:50:语言持久化(""=跟随系统 / zh / en)
}
if (cfg.models !== undefined) {
// §7.1:多模型保存——合并写 [[llm.models]](保留已有项的 context_window 等高级字段)
const existing = toml.llm.models || [];
Expand Down
21 changes: 20 additions & 1 deletion emrg/gui/renderer/src/App.tsx
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
import { useEffect } from "react";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { Shell } from "./components/Shell";
import { DaemonBridgeProvider } from "./components/DaemonBridgeProvider";
import { I18nProvider } from "./lib/i18n";
import { I18nProvider, setLocale, type Locale } from "./lib/i18n";

/**
* App — React 组件树根(Batch 0 骨架 + Batch 5 slice 2 daemon-event context)。
Expand All@@ -17,6 +18,24 @@ import { I18nProvider } from "./lib/i18n";
* 旧 vanilla renderer(js/*.js)保持不动直到 Batch 5 一次性切换(设计 D3)。
*/
export function App() {
// 启动恢复持久化外观偏好(rant 2026-08-27T22:22:50):theme/lang 从 config.toml
// 读回(getSettings)。window.emrg 缺失(jsdom / 预览)时静默降级。
useEffect(() => {
const emrg = (window as unknown as { emrg?: { getSettings?: () => Promise<{ theme?: string; lang?: string }> } }).emrg;
if (!emrg?.getSettings) return;
emrg
.getSettings()
.then((s) => {
if (s.lang) setLocale(s.lang as "" | Locale);
if (s.theme && s.theme !== "system") {
document.documentElement.setAttribute("data-theme", s.theme);
}
})
.catch(() => {
/* 读配置失败:保持默认(跟随系统) */
});
}, []);

return (
<ErrorBoundary>
<I18nProvider>
Expand Down
52 changes: 48 additions & 4 deletions emrg/gui/renderer/src/components/SettingsPanel.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -225,27 +225,71 @@ describe("SettingsPanel", () => {
);
});

it("applies theme option to documentElement", async () => {
setup();
it("applies theme option to documentElement and persists via saveSettings", async () => {
const { calls } = setup();
await screen.findByTestId("settings-tab-appearance");
fireEvent.click(screen.getByTestId("settings-tab-appearance"));
fireEvent.click(screen.getByTestId("theme-light"));
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
// 点击即持久化(rant 22:22:50):saveSettings 载荷含 theme
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { theme?: string };
expect(last.theme).toBe("light");
});
fireEvent.click(screen.getByTestId("theme-dark"));
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { theme?: string };
expect(last.theme).toBe("dark");
});
fireEvent.click(screen.getByTestId("theme-system"));
expect(document.documentElement.hasAttribute("data-theme")).toBe(false);
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { theme?: string };
expect(last.theme).toBe("system");
});
});

it("sets locale from language tab", async () => {
it("sets locale from language tab and persists via saveSettings", async () => {
const { calls } = setup();
const spy = vi.spyOn(i18n, "setLocale").mockImplementation((l): i18n.Locale => (l === "" ? "zh" : l));
setup();
await screen.findByTestId("settings-tab-language");
fireEvent.click(screen.getByTestId("settings-tab-language"));
fireEvent.click(screen.getByTestId("lang-zh"));
expect(spy).toHaveBeenCalledWith("zh");
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { lang?: string };
expect(last.lang).toBe("zh");
});
fireEvent.click(screen.getByTestId("lang-system"));
expect(spy).toHaveBeenCalledWith("");
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { lang?: string };
expect(last.lang).toBe("");
});
});

it("reopen reads persisted theme/lang and does not reset to system", async () => {
const { calls } = setup({
getSettings: vi.fn().mockResolvedValue({
apiKey: "sk-test",
baseUrl: "https://api.deepseek.com",
model: "deepseek-v3",
theme: "light",
lang: "en",
modelDetails: [],
}),
});
await screen.findByTestId("settings-tab-appearance");
fireEvent.click(screen.getByTestId("settings-tab-appearance"));
// 持久化值被应用(light 而非恒 "system" 重置)
await waitFor(() => expect(document.documentElement.getAttribute("data-theme")).toBe("light"));
// 语言:getSettings 读回 lang=en → setLocale 被调用
await waitFor(() => {
const spyCalls = calls.saveSettings.length; // 无点击 → 不新增持久化调用
expect(spyCalls).toBe(0);
});
expect(calls.saveSettings.length).toBe(0);
});

it("renders version and evolution count in about tab", async () => {
Expand Down
23 changes: 21 additions & 2 deletions emrg/gui/renderer/src/components/SettingsPanel.tsx
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { useEffect, useReducer, useRef, useState } from "react";
import { useI18n, setLocale } from "../lib/i18n";
import { useI18n, setLocale, type Locale } from "../lib/i18n";
import { dialogReducer, initialDialogState } from "../lib/dialog";
import { ConfirmDialog } from "./ConfirmDialog";

Expand DownExpand Up@@ -44,6 +44,7 @@ interface GetSettingsResult {
model?: string;
modelDetails?: { name: string; model?: string; vision?: boolean }[];
theme?: string;
lang?: string;
}

/** SettingsPanel 用到的 preload 桥方法(与 preload.js 通道对齐) */
Expand DownExpand Up@@ -121,6 +122,8 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
})),
);
applyTheme(s.theme || "system");
// 语言持久化(rant 22:22:50):config.toml 有 lang → 应用;""(跟随系统/旧配置)→ 不动
if (s.lang) setLocale(s.lang as "" | Locale);
})
.catch((e: Error) => setMsg({ kind: "err", text: t("settings.readFailed", { msg: e.message }) }));
void refreshGithubStatus();
Expand DownExpand Up@@ -150,6 +153,18 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
else root.setAttribute("data-theme", mode);
}

/** 外观偏好即点即持久化(rant 22:22:50):theme/lang 点击 → saveSettings 落盘
* config.toml(重开面板/重启 GUI 不回退)。成功静默;失败显示错误消息。 */
async function persistAppearance(patch: { theme?: string; lang?: string }) {
const b = bridge();
if (!b?.saveSettings) return;
try {
await b.saveSettings(patch);
} catch (e: unknown) {
setMsg({ kind: "err", text: t("settings.saveFailed", { msg: (e as Error).message }) });
}
}

// ── model tab ─────────────────────────────
function openModelForm(name: string | null, isDefault: boolean) {
setEditingName(isDefault ? name : name);
Expand DownExpand Up@@ -573,6 +588,7 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
onClick={() => {
setTheme(m);
applyTheme(m);
void persistAppearance({ theme: m });
}}
>
{t(`settings.theme${m[0].toUpperCase()}${m.slice(1)}`)}
Expand All@@ -598,7 +614,10 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
className="theme-option"
data-lang={loc}
data-testid={`lang-${loc || "system"}`}
onClick={() => setLocale(loc)}
onClick={() => {
setLocale(loc);
void persistAppearance({ lang: loc });
}}
>
{t(
loc === "" ? "settings.langFollowSystem" : loc === "zh" ? "settings.langZh" : "settings.langEn",
Expand Down
25 changes: 20 additions & 5 deletions emrg/gui/renderer/src/lib/i18n.tsx
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { DICTS } from "./i18n-dicts";

/**
Expand All@@ -17,6 +17,8 @@ interface I18nContextValue {
}

export const LOCALE_KEY = "emrg.locale";
/** setLocale 后广播给 I18nProvider 的事件(localStorage 写入本身不触发 React 重渲染) */
export const LOCALE_CHANGED_EVENT = "emrg:locale-changed";
export type Locale = "zh" | "en";

/** 完整词典(373 keys,zh/en 严格对齐)——由 vanilla i18n.js 机械生成,见 ./i18n-dicts.ts */
Expand DownExpand Up@@ -65,6 +67,12 @@ export function setLocale(loc: "" | Locale): Locale {
} catch {
/* ignore */
}
// 通知 I18nProvider 重读(React 版 provider 状态化:不广播则 UI 不刷新)
try {
if (typeof window !== "undefined") window.dispatchEvent(new Event(LOCALE_CHANGED_EVENT));
} catch {
/* ignore */
}
return getLocale();
}

Expand DownExpand Up@@ -102,14 +110,21 @@ export function I18nProvider({
dicts?: Record<string, Record<string, string>>;
children: ReactNode;
}) {
const l = detectLocale(lang);
const [locale, setLocaleState] = useState<Locale>(() => detectLocale(lang));
// 外部 setLocale(SettingsPanel 直接调用模块函数)→ 广播 LOCALE_CHANGED_EVENT →
// 本 provider 重读生效(React 化后 localStorage 写入本身不触发重渲染)
useEffect(() => {
const onChange = () => setLocaleState(detectLocale(lang));
window.addEventListener(LOCALE_CHANGED_EVENT, onChange);
return () => window.removeEventListener(LOCALE_CHANGED_EVENT, onChange);
}, [lang]);
const value = useMemo<I18nContextValue>(() => {
const dict = { ...(DEFAULT_DICTS[l] ?? DEFAULT_DICTS.en), ...(dicts?.[l] ?? {}) };
const dict = { ...(DEFAULT_DICTS[locale] ?? DEFAULT_DICTS.en), ...(dicts?.[locale] ?? {}) };
return {
lang: l,
lang: locale,
t: (key, params) => interpolate(key in dict ? dict[key] : (DICTS.zh[key] ?? key), params),
};
}, [l, dicts]);
}, [locale, dicts]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
7 changes: 6 additions & 1 deletion emrg/gui/main.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -258,7 +258,7 @@ vision = false
function validateConfig(c) {
// 设计 §7.1:直接接收所需字段 + 基本类型检查(防写坏 config.toml 的健壮性,非安全设计)
const out = {};
for (const k of ["apiKey", "baseUrl", "model", "theme"]) {
for (const k of ["apiKey", "baseUrl", "model", "theme", "lang"]) {
if (c[k] !== undefined) out[k] = typeof c[k] === "string" ? c[k] : String(c[k]);
}
if (Array.isArray(c.models)) {
Expand DownExpand Up@@ -863,6 +863,7 @@ vision = false
models,
modelDetails,
theme: cfg.gui?.theme || "system", // §7.1:外观主题持久化(浅色/深色/跟随系统)
lang: cfg.gui?.lang || "", // rant 22:22:50:语言持久化(""=跟随系统 / zh / en)
};
});

Expand All@@ -886,6 +887,10 @@ vision = false
toml.gui = toml.gui || {};
toml.gui.theme = cfg.theme; // §7.1:主题持久化(浅色/深色/跟随系统)
}
if (cfg.lang !== undefined) {
toml.gui = toml.gui || {};
toml.gui.lang = cfg.lang; // rant 22:22:50:语言持久化(""=跟随系统 / zh / en)
}
if (cfg.models !== undefined) {
// §7.1:多模型保存——合并写 [[llm.models]](保留已有项的 context_window 等高级字段)
const existing = toml.llm.models || [];
Expand Down
21 changes: 20 additions & 1 deletion emrg/gui/renderer/src/App.tsx
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
import { useEffect } from "react";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { Shell } from "./components/Shell";
import { DaemonBridgeProvider } from "./components/DaemonBridgeProvider";
import { I18nProvider } from "./lib/i18n";
import { I18nProvider, setLocale, type Locale } from "./lib/i18n";

/**
* App — React 组件树根(Batch 0 骨架 + Batch 5 slice 2 daemon-event context)。
Expand All@@ -17,6 +18,24 @@ import { I18nProvider } from "./lib/i18n";
* 旧 vanilla renderer(js/*.js)保持不动直到 Batch 5 一次性切换(设计 D3)。
*/
export function App() {
// 启动恢复持久化外观偏好(rant 2026-08-27T22:22:50):theme/lang 从 config.toml
// 读回(getSettings)。window.emrg 缺失(jsdom / 预览)时静默降级。
useEffect(() => {
const emrg = (window as unknown as { emrg?: { getSettings?: () => Promise<{ theme?: string; lang?: string }> } }).emrg;
if (!emrg?.getSettings) return;
emrg
.getSettings()
.then((s) => {
if (s.lang) setLocale(s.lang as "" | Locale);
if (s.theme && s.theme !== "system") {
document.documentElement.setAttribute("data-theme", s.theme);
}
})
.catch(() => {
/* 读配置失败:保持默认(跟随系统) */
});
}, []);

return (
<ErrorBoundary>
<I18nProvider>
Expand Down
52 changes: 48 additions & 4 deletions emrg/gui/renderer/src/components/SettingsPanel.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -225,27 +225,71 @@ describe("SettingsPanel", () => {
);
});

it("applies theme option to documentElement", async () => {
setup();
it("applies theme option to documentElement and persists via saveSettings", async () => {
const { calls } = setup();
await screen.findByTestId("settings-tab-appearance");
fireEvent.click(screen.getByTestId("settings-tab-appearance"));
fireEvent.click(screen.getByTestId("theme-light"));
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
// 点击即持久化(rant 22:22:50):saveSettings 载荷含 theme
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { theme?: string };
expect(last.theme).toBe("light");
});
fireEvent.click(screen.getByTestId("theme-dark"));
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { theme?: string };
expect(last.theme).toBe("dark");
});
fireEvent.click(screen.getByTestId("theme-system"));
expect(document.documentElement.hasAttribute("data-theme")).toBe(false);
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { theme?: string };
expect(last.theme).toBe("system");
});
});

it("sets locale from language tab", async () => {
it("sets locale from language tab and persists via saveSettings", async () => {
const { calls } = setup();
const spy = vi.spyOn(i18n, "setLocale").mockImplementation((l): i18n.Locale => (l === "" ? "zh" : l));
setup();
await screen.findByTestId("settings-tab-language");
fireEvent.click(screen.getByTestId("settings-tab-language"));
fireEvent.click(screen.getByTestId("lang-zh"));
expect(spy).toHaveBeenCalledWith("zh");
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { lang?: string };
expect(last.lang).toBe("zh");
});
fireEvent.click(screen.getByTestId("lang-system"));
expect(spy).toHaveBeenCalledWith("");
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { lang?: string };
expect(last.lang).toBe("");
});
});

it("reopen reads persisted theme/lang and does not reset to system", async () => {
const { calls } = setup({
getSettings: vi.fn().mockResolvedValue({
apiKey: "sk-test",
baseUrl: "https://api.deepseek.com",
model: "deepseek-v3",
theme: "light",
lang: "en",
modelDetails: [],
}),
});
await screen.findByTestId("settings-tab-appearance");
fireEvent.click(screen.getByTestId("settings-tab-appearance"));
// 持久化值被应用(light 而非恒 "system" 重置)
await waitFor(() => expect(document.documentElement.getAttribute("data-theme")).toBe("light"));
// 语言:getSettings 读回 lang=en → setLocale 被调用
await waitFor(() => {
const spyCalls = calls.saveSettings.length; // 无点击 → 不新增持久化调用
expect(spyCalls).toBe(0);
});
expect(calls.saveSettings.length).toBe(0);
});

it("renders version and evolution count in about tab", async () => {
Expand Down
23 changes: 21 additions & 2 deletions emrg/gui/renderer/src/components/SettingsPanel.tsx
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { useEffect, useReducer, useRef, useState } from "react";
import { useI18n, setLocale } from "../lib/i18n";
import { useI18n, setLocale, type Locale } from "../lib/i18n";
import { dialogReducer, initialDialogState } from "../lib/dialog";
import { ConfirmDialog } from "./ConfirmDialog";

Expand DownExpand Up@@ -44,6 +44,7 @@ interface GetSettingsResult {
model?: string;
modelDetails?: { name: string; model?: string; vision?: boolean }[];
theme?: string;
lang?: string;
}

/** SettingsPanel 用到的 preload 桥方法(与 preload.js 通道对齐) */
Expand DownExpand Up@@ -121,6 +122,8 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
})),
);
applyTheme(s.theme || "system");
// 语言持久化(rant 22:22:50):config.toml 有 lang → 应用;""(跟随系统/旧配置)→ 不动
if (s.lang) setLocale(s.lang as "" | Locale);
})
.catch((e: Error) => setMsg({ kind: "err", text: t("settings.readFailed", { msg: e.message }) }));
void refreshGithubStatus();
Expand DownExpand Up@@ -150,6 +153,18 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
else root.setAttribute("data-theme", mode);
}

/** 外观偏好即点即持久化(rant 22:22:50):theme/lang 点击 → saveSettings 落盘
* config.toml(重开面板/重启 GUI 不回退)。成功静默;失败显示错误消息。 */
async function persistAppearance(patch: { theme?: string; lang?: string }) {
const b = bridge();
if (!b?.saveSettings) return;
try {
await b.saveSettings(patch);
} catch (e: unknown) {
setMsg({ kind: "err", text: t("settings.saveFailed", { msg: (e as Error).message }) });
}
}

// ── model tab ─────────────────────────────
function openModelForm(name: string | null, isDefault: boolean) {
setEditingName(isDefault ? name : name);
Expand DownExpand Up@@ -573,6 +588,7 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
onClick={() => {
setTheme(m);
applyTheme(m);
void persistAppearance({ theme: m });
}}
>
{t(`settings.theme${m[0].toUpperCase()}${m.slice(1)}`)}
Expand All@@ -598,7 +614,10 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
className="theme-option"
data-lang={loc}
data-testid={`lang-${loc || "system"}`}
onClick={() => setLocale(loc)}
onClick={() => {
setLocale(loc);
void persistAppearance({ lang: loc });
}}
>
{t(
loc === "" ? "settings.langFollowSystem" : loc === "zh" ? "settings.langZh" : "settings.langEn",
Expand Down
25 changes: 20 additions & 5 deletions emrg/gui/renderer/src/lib/i18n.tsx
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { DICTS } from "./i18n-dicts";

/**
Expand All@@ -17,6 +17,8 @@ interface I18nContextValue {
}

export const LOCALE_KEY = "emrg.locale";
/** setLocale 后广播给 I18nProvider 的事件(localStorage 写入本身不触发 React 重渲染) */
export const LOCALE_CHANGED_EVENT = "emrg:locale-changed";
export type Locale = "zh" | "en";

/** 完整词典(373 keys,zh/en 严格对齐)——由 vanilla i18n.js 机械生成,见 ./i18n-dicts.ts */
Expand DownExpand Up@@ -65,6 +67,12 @@ export function setLocale(loc: "" | Locale): Locale {
} catch {
/* ignore */
}
// 通知 I18nProvider 重读(React 版 provider 状态化:不广播则 UI 不刷新)
try {
if (typeof window !== "undefined") window.dispatchEvent(new Event(LOCALE_CHANGED_EVENT));
} catch {
/* ignore */
}
return getLocale();
}

Expand DownExpand Up@@ -102,14 +110,21 @@ export function I18nProvider({
dicts?: Record<string, Record<string, string>>;
children: ReactNode;
}) {
const l = detectLocale(lang);
const [locale, setLocaleState] = useState<Locale>(() => detectLocale(lang));
// 外部 setLocale(SettingsPanel 直接调用模块函数)→ 广播 LOCALE_CHANGED_EVENT →
// 本 provider 重读生效(React 化后 localStorage 写入本身不触发重渲染)
useEffect(() => {
const onChange = () => setLocaleState(detectLocale(lang));
window.addEventListener(LOCALE_CHANGED_EVENT, onChange);
return () => window.removeEventListener(LOCALE_CHANGED_EVENT, onChange);
}, [lang]);
const value = useMemo<I18nContextValue>(() => {
const dict = { ...(DEFAULT_DICTS[l] ?? DEFAULT_DICTS.en), ...(dicts?.[l] ?? {}) };
const dict = { ...(DEFAULT_DICTS[locale] ?? DEFAULT_DICTS.en), ...(dicts?.[locale] ?? {}) };
return {
lang: l,
lang: locale,
t: (key, params) => interpolate(key in dict ? dict[key] : (DICTS.zh[key] ?? key), params),
};
}, [l, dicts]);
}, [locale, dicts]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
7 changes: 6 additions & 1 deletion emrg/gui/main.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -258,7 +258,7 @@ vision = false
function validateConfig(c) {
// 设计 §7.1:直接接收所需字段 + 基本类型检查(防写坏 config.toml 的健壮性,非安全设计)
const out = {};
for (const k of ["apiKey", "baseUrl", "model", "theme"]) {
for (const k of ["apiKey", "baseUrl", "model", "theme", "lang"]) {
if (c[k] !== undefined) out[k] = typeof c[k] === "string" ? c[k] : String(c[k]);
}
if (Array.isArray(c.models)) {
Expand DownExpand Up@@ -863,6 +863,7 @@ vision = false
models,
modelDetails,
theme: cfg.gui?.theme || "system", // §7.1:外观主题持久化(浅色/深色/跟随系统)
lang: cfg.gui?.lang || "", // rant 22:22:50:语言持久化(""=跟随系统 / zh / en)
};
});

Expand All@@ -886,6 +887,10 @@ vision = false
toml.gui = toml.gui || {};
toml.gui.theme = cfg.theme; // §7.1:主题持久化(浅色/深色/跟随系统)
}
if (cfg.lang !== undefined) {
toml.gui = toml.gui || {};
toml.gui.lang = cfg.lang; // rant 22:22:50:语言持久化(""=跟随系统 / zh / en)
}
if (cfg.models !== undefined) {
// §7.1:多模型保存——合并写 [[llm.models]](保留已有项的 context_window 等高级字段)
const existing = toml.llm.models || [];
Expand Down
21 changes: 20 additions & 1 deletion emrg/gui/renderer/src/App.tsx
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
import { useEffect } from "react";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { Shell } from "./components/Shell";
import { DaemonBridgeProvider } from "./components/DaemonBridgeProvider";
import { I18nProvider } from "./lib/i18n";
import { I18nProvider, setLocale, type Locale } from "./lib/i18n";

/**
* App — React 组件树根(Batch 0 骨架 + Batch 5 slice 2 daemon-event context)。
Expand All@@ -17,6 +18,24 @@ import { I18nProvider } from "./lib/i18n";
* 旧 vanilla renderer(js/*.js)保持不动直到 Batch 5 一次性切换(设计 D3)。
*/
export function App() {
// 启动恢复持久化外观偏好(rant 2026-08-27T22:22:50):theme/lang 从 config.toml
// 读回(getSettings)。window.emrg 缺失(jsdom / 预览)时静默降级。
useEffect(() => {
const emrg = (window as unknown as { emrg?: { getSettings?: () => Promise<{ theme?: string; lang?: string }> } }).emrg;
if (!emrg?.getSettings) return;
emrg
.getSettings()
.then((s) => {
if (s.lang) setLocale(s.lang as "" | Locale);
if (s.theme && s.theme !== "system") {
document.documentElement.setAttribute("data-theme", s.theme);
}
})
.catch(() => {
/* 读配置失败:保持默认(跟随系统) */
});
}, []);

return (
<ErrorBoundary>
<I18nProvider>
Expand Down
52 changes: 48 additions & 4 deletions emrg/gui/renderer/src/components/SettingsPanel.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -225,27 +225,71 @@ describe("SettingsPanel", () => {
);
});

it("applies theme option to documentElement", async () => {
setup();
it("applies theme option to documentElement and persists via saveSettings", async () => {
const { calls } = setup();
await screen.findByTestId("settings-tab-appearance");
fireEvent.click(screen.getByTestId("settings-tab-appearance"));
fireEvent.click(screen.getByTestId("theme-light"));
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
// 点击即持久化(rant 22:22:50):saveSettings 载荷含 theme
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { theme?: string };
expect(last.theme).toBe("light");
});
fireEvent.click(screen.getByTestId("theme-dark"));
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { theme?: string };
expect(last.theme).toBe("dark");
});
fireEvent.click(screen.getByTestId("theme-system"));
expect(document.documentElement.hasAttribute("data-theme")).toBe(false);
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { theme?: string };
expect(last.theme).toBe("system");
});
});

it("sets locale from language tab", async () => {
it("sets locale from language tab and persists via saveSettings", async () => {
const { calls } = setup();
const spy = vi.spyOn(i18n, "setLocale").mockImplementation((l): i18n.Locale => (l === "" ? "zh" : l));
setup();
await screen.findByTestId("settings-tab-language");
fireEvent.click(screen.getByTestId("settings-tab-language"));
fireEvent.click(screen.getByTestId("lang-zh"));
expect(spy).toHaveBeenCalledWith("zh");
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { lang?: string };
expect(last.lang).toBe("zh");
});
fireEvent.click(screen.getByTestId("lang-system"));
expect(spy).toHaveBeenCalledWith("");
await waitFor(() => {
const last = calls.saveSettings[calls.saveSettings.length - 1]?.[0] as { lang?: string };
expect(last.lang).toBe("");
});
});

it("reopen reads persisted theme/lang and does not reset to system", async () => {
const { calls } = setup({
getSettings: vi.fn().mockResolvedValue({
apiKey: "sk-test",
baseUrl: "https://api.deepseek.com",
model: "deepseek-v3",
theme: "light",
lang: "en",
modelDetails: [],
}),
});
await screen.findByTestId("settings-tab-appearance");
fireEvent.click(screen.getByTestId("settings-tab-appearance"));
// 持久化值被应用(light 而非恒 "system" 重置)
await waitFor(() => expect(document.documentElement.getAttribute("data-theme")).toBe("light"));
// 语言:getSettings 读回 lang=en → setLocale 被调用
await waitFor(() => {
const spyCalls = calls.saveSettings.length; // 无点击 → 不新增持久化调用
expect(spyCalls).toBe(0);
});
expect(calls.saveSettings.length).toBe(0);
});

it("renders version and evolution count in about tab", async () => {
Expand Down
23 changes: 21 additions & 2 deletions emrg/gui/renderer/src/components/SettingsPanel.tsx
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { useEffect, useReducer, useRef, useState } from "react";
import { useI18n, setLocale } from "../lib/i18n";
import { useI18n, setLocale, type Locale } from "../lib/i18n";
import { dialogReducer, initialDialogState } from "../lib/dialog";
import { ConfirmDialog } from "./ConfirmDialog";

Expand DownExpand Up@@ -44,6 +44,7 @@ interface GetSettingsResult {
model?: string;
modelDetails?: { name: string; model?: string; vision?: boolean }[];
theme?: string;
lang?: string;
}

/** SettingsPanel 用到的 preload 桥方法(与 preload.js 通道对齐) */
Expand DownExpand Up@@ -121,6 +122,8 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
})),
);
applyTheme(s.theme || "system");
// 语言持久化(rant 22:22:50):config.toml 有 lang → 应用;""(跟随系统/旧配置)→ 不动
if (s.lang) setLocale(s.lang as "" | Locale);
})
.catch((e: Error) => setMsg({ kind: "err", text: t("settings.readFailed", { msg: e.message }) }));
void refreshGithubStatus();
Expand DownExpand Up@@ -150,6 +153,18 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
else root.setAttribute("data-theme", mode);
}

/** 外观偏好即点即持久化(rant 22:22:50):theme/lang 点击 → saveSettings 落盘
* config.toml(重开面板/重启 GUI 不回退)。成功静默;失败显示错误消息。 */
async function persistAppearance(patch: { theme?: string; lang?: string }) {
const b = bridge();
if (!b?.saveSettings) return;
try {
await b.saveSettings(patch);
} catch (e: unknown) {
setMsg({ kind: "err", text: t("settings.saveFailed", { msg: (e as Error).message }) });
}
}

// ── model tab ─────────────────────────────
function openModelForm(name: string | null, isDefault: boolean) {
setEditingName(isDefault ? name : name);
Expand DownExpand Up@@ -573,6 +588,7 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
onClick={() => {
setTheme(m);
applyTheme(m);
void persistAppearance({ theme: m });
}}
>
{t(`settings.theme${m[0].toUpperCase()}${m.slice(1)}`)}
Expand All@@ -598,7 +614,10 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
className="theme-option"
data-lang={loc}
data-testid={`lang-${loc || "system"}`}
onClick={() => setLocale(loc)}
onClick={() => {
setLocale(loc);
void persistAppearance({ lang: loc });
}}
>
{t(
loc === "" ? "settings.langFollowSystem" : loc === "zh" ? "settings.langZh" : "settings.langEn",
Expand Down
25 changes: 20 additions & 5 deletions emrg/gui/renderer/src/lib/i18n.tsx
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { DICTS } from "./i18n-dicts";

/**
Expand All@@ -17,6 +17,8 @@ interface I18nContextValue {
}

export const LOCALE_KEY = "emrg.locale";
/** setLocale 后广播给 I18nProvider 的事件(localStorage 写入本身不触发 React 重渲染) */
export const LOCALE_CHANGED_EVENT = "emrg:locale-changed";
export type Locale = "zh" | "en";

/** 完整词典(373 keys,zh/en 严格对齐)——由 vanilla i18n.js 机械生成,见 ./i18n-dicts.ts */
Expand DownExpand Up@@ -65,6 +67,12 @@ export function setLocale(loc: "" | Locale): Locale {
} catch {
/* ignore */
}
// 通知 I18nProvider 重读(React 版 provider 状态化:不广播则 UI 不刷新)
try {
if (typeof window !== "undefined") window.dispatchEvent(new Event(LOCALE_CHANGED_EVENT));
} catch {
/* ignore */
}
return getLocale();
}

Expand DownExpand Up@@ -102,14 +110,21 @@ export function I18nProvider({
dicts?: Record<string, Record<string, string>>;
children: ReactNode;
}) {
const l = detectLocale(lang);
const [locale, setLocaleState] = useState<Locale>(() => detectLocale(lang));
// 外部 setLocale(SettingsPanel 直接调用模块函数)→ 广播 LOCALE_CHANGED_EVENT →
// 本 provider 重读生效(React 化后 localStorage 写入本身不触发重渲染)
useEffect(() => {
const onChange = () => setLocaleState(detectLocale(lang));
window.addEventListener(LOCALE_CHANGED_EVENT, onChange);
return () => window.removeEventListener(LOCALE_CHANGED_EVENT, onChange);
}, [lang]);
const value = useMemo<I18nContextValue>(() => {
const dict = { ...(DEFAULT_DICTS[l] ?? DEFAULT_DICTS.en), ...(dicts?.[l] ?? {}) };
const dict = { ...(DEFAULT_DICTS[locale] ?? DEFAULT_DICTS.en), ...(dicts?.[locale] ?? {}) };
return {
lang: l,
lang: locale,
t: (key, params) => interpolate(key in dict ? dict[key] : (DICTS.zh[key] ?? key), params),
};
}, [l, dicts]);
}, [locale, dicts]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}

Expand Down
Loading