Compare commits

...
Author SHA1 Message Date
alvinfeiandCursor cfbf7e9fe0 test(memhub): align localization fixtures with current keys
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 16:28:31 +08:00
alvinfeiandCursor bf811ce7bb style(memhub): satisfy Node workspace lint
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 16:20:37 +08:00
b7b34ef502 feat(memhub): add Chinese English Spanish localization
Co-authored-by: Nicolas Pirello <nicoalepirello@gmail.com>

Co-authored-by: kristinhe <kristinhe@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 16:15:29 +08:00
alvinfeiandCursor 2680c10dde merge: integrate PR #39 with current main
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 15:50:44 +08:00
Nicolas Pirello 9a93a67e5b Merge upstream/main into feature/memhub-gui-i18n
Bring in MemHub 1.0.4 and resolve the conflicts with the GUI localization:

- Desktop preload and web bridge types keep both the build info and the
  language preference methods.
- Applications home follows the new sedimentation switch; its states and
  the new source authorization notes go through the catalogs.
- The diagnostics panel keeps the new logs/usage tabs and fullscreen mode
  with localized controls; the model usage view is localized as well.
- Settings keep the new sedimentation switch and About section, and the
  import progress cards keep the new scan wording, in both languages.
- The startup placeholder follows the saved interface language.
- Automatic connection failures word their reason in the interface
  language.

Chinese copy matches upstream; tests from the localization branch now
expect the new upstream behavior.
2026-10-18 11:48:16 -03:00
Nicolas Pirello 60e39f9341 Extend MemHub Spanish localization to the remaining GUI screens
Move the copy of Applications, memory space and map, matters, workbench,
call history, handoff and arrival panels into shared contracts catalogs.

- Merge domain catalogs without silent overrides: a key shared by two
  catalogs must have identical Chinese and Spanish texts.
- Add Spanish singular variants (`.one`) and count helpers that keep the
  Chinese numbers exactly as they were rendered before.
- Build the Spanish connection prompt from a catalog template; MCP and
  shell snippets are inserted verbatim, the Chinese prompt is unchanged.
- Word host failures at render time so a language switch re-words them.
- Unknown text, names, paths and error codes pass through untranslated;
  unknown graph node types and edge origins keep their previous labels.
- Add catalog parity, interpolation, adapter and regression tests.
2026-10-18 04:13:00 -03:00
Nicolas Pirello d7f0ab7ede Merge remote-tracking branch 'upstream/main' into feature/memhub-gui-i18n
Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	memhub/apps/web/src/R6Settings.test.tsx
2026-10-17 11:09:31 -03:00
Nicolas PirelloandCursor 91ee1fb2a2 Add Spanish localization and persistent GUI language preferences to MemHub
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 10:54:12 -03:00
153 changed files with 13961 additions and 1861 deletions

No files matched your search

+4
View File
@@ -40,6 +40,10 @@ Hy Memory and MemHub are versioned and released independently.
## Unreleased
- Added an extensible MemHub interface-localization layer with Simplified
Chinese, English, and Spanish catalogs shared by Web and Electron-native
surfaces. Interface language remains independent from memory content and
model processing language.
- `search(..., layers=["l1_raw"])` now explicitly searches SDK raw records in
pro/ultra mode and keeps existing user/agent/session/source filters; default
search continues to hide raw.
+8 -1
View File
@@ -1,6 +1,6 @@
# Contributing
感谢你参与 One Memory。
感谢你参与 MemHub。
## 开发环境
@@ -27,3 +27,10 @@ pnpm check
涉及外部源码时,必须记录项目、固定版本或 commit、许可证、采用文件和修改说明。不得提交
密钥、内部地址、真实对话、个人路径、模型权重或签名证书。
## 界面翻译
新增或修改界面语言时遵循
[`docs/LOCALIZATION.md`](docs/LOCALIZATION.md):使用稳定语义 key,保持所有
locale 的 key 与占位符一致,不翻译用户内容、路径、标识符或外部错误正文。语言
catalog 与无关产品行为应拆成不同 PR。
+3
View File
@@ -50,11 +50,14 @@ Linux 制品由 GitHub Actions 在原生架构 runner 上构建并完成生命
- `apps/web`:React / TypeScript 管理界面。
- `apps/desktop`:Electron 桌面壳与图形安装入口。
- `packages/contracts`:公共 API 类型。
- `packages/i18n`:Web 与 Electron 共用的界面语言合同和 catalog。
- `packages/host-bridge`:AI App 的 Skill、Hook、插件和 MCP 接入。
- `packages/runtime-control`:Core、Host Gateway 和 Runtime 生命周期控制。
架构详情见 [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) 和
[`docs/contracts/runtime-unification.md`](docs/contracts/runtime-unification.md)。
界面语言架构与社区翻译规则见
[`docs/LOCALIZATION.md`](docs/LOCALIZATION.md)。
产品、测试和一线支持排查导入与记忆沉淀时,使用
[`docs/PRODUCT_LOG_TROUBLESHOOTING.md`](docs/PRODUCT_LOG_TROUBLESHOOTING.md)。
+24 -2
View File
@@ -3,9 +3,9 @@
"dataLicense": "CC0-1.0",
"SPDXID": "SPDXRef-DOCUMENT",
"name": "one-memory",
"documentNamespace": "https://one-memory.local/spdx/2026-10-17T13:52:48Z",
"documentNamespace": "https://one-memory.local/spdx/2026-10-22T08:09:31Z",
"creationInfo": {
"created": "2026-10-17T13:52:48Z",
"created": "2026-10-22T08:09:31Z",
"creators": [
"Tool: one-memory-generate-sbom"
]
@@ -88,6 +88,23 @@
}
]
},
{
"SPDXID": "SPDXRef-Package-npm-i18next",
"name": "i18next",
"versionInfo": "25.10.10",
"downloadLocation": "NOASSERTION",
"filesAnalyzed": false,
"licenseConcluded": "NOASSERTION",
"licenseDeclared": "NOASSERTION",
"copyrightText": "NOASSERTION",
"externalRefs": [
{
"referenceCategory": "PACKAGE-MANAGER",
"referenceType": "purl",
"referenceLocator": "pkg:npm/i18next@25.10.10"
}
]
},
{
"SPDXID": "SPDXRef-Package-npm-lucide-react",
"name": "lucide-react",
@@ -2380,6 +2397,11 @@
"relationshipType": "DEPENDS_ON",
"relatedSpdxElement": "SPDXRef-Package-npm-https-proxy-agent"
},
{
"spdxElementId": "SPDXRef-Package-one-memory",
"relationshipType": "DEPENDS_ON",
"relatedSpdxElement": "SPDXRef-Package-npm-i18next"
},
{
"spdxElementId": "SPDXRef-Package-one-memory",
"relationshipType": "DEPENDS_ON",
+3 -2
View File
@@ -2,7 +2,7 @@
MemHub itself is licensed under Apache-2.0 (see `LICENSE`).
It bundles the runtime dependencies listed below.
Generated from the installed dependency tree on 2026-10-17T13:52:48Z
Generated from the installed dependency tree on 2026-10-22T08:09:31Z
by `python3 scripts/generate_sbom.py`. Do not edit by hand.
Per-package license texts are not reproduced here yet; the license
@@ -112,12 +112,13 @@ Adaptation boundary:
No Memmy brand, icon, font or mascot assets are distributed.
## JavaScript (npm) — 10 packages
## JavaScript (npm) — 11 packages
- @tanstack/react-query 5.103.2
- axios 1.20.0
- fflate 0.8.3
- https-proxy-agent 7.0.0
- i18next 25.10.10
- lucide-react 1.43.0
- proxy-from-env 2.1.0
- react 19.2.8
+1
View File
@@ -84,6 +84,7 @@
"dependencies": {
"@one-memory/contracts": "workspace:*",
"@one-memory/host-bridge": "workspace:*",
"@one-memory/i18n": "workspace:*",
"@one-memory/runtime-control": "workspace:*",
"axios": "^1.20.0"
}
@@ -1,7 +1,46 @@
import type { LanguageSnapshot } from "@one-memory/i18n";
import { expect, it, vi } from "vitest";
import { installApplicationMenu } from "./application-menu";
it("rebuilds translated labels from committed language updates without exporting", () => {
let change: (() => void) | undefined;
const snapshot: LanguageSnapshot = {
preference: "es",
locale: "es",
revision: 1,
};
const unsubscribe = vi.fn();
const read = vi.fn(() => snapshot);
const buildMenu = vi.fn(() => ({}) as never);
const dispose = installApplicationMenu({
productName: "MemHub",
buildMenu,
setMenu: vi.fn(),
exportLogs: async () => {},
reportError: vi.fn(),
language: {
read,
subscribe: (listener) => {
change = () => listener(snapshot);
return unsubscribe;
},
},
});
expect(buildMenu).toHaveBeenLastCalledWith(
expect.arrayContaining([expect.objectContaining({ label: "Ayuda" })]),
);
snapshot.locale = "zh-CN";
change?.();
expect(read).toHaveBeenCalledTimes(2);
expect(buildMenu).toHaveBeenCalledTimes(2);
expect(buildMenu).toHaveBeenLastCalledWith(
expect.arrayContaining([expect.objectContaining({ label: "帮助" })]),
);
dispose();
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
it("exports logs from Help without requiring a renderer window", async () => {
const exportLogs = vi.fn(async () => undefined);
let template: Array<Record<string, unknown>> = [];
+34 -21
View File
@@ -1,36 +1,49 @@
import { translate } from "@one-memory/i18n";
import type { Menu, MenuItemConstructorOptions } from "electron";
import type { NativeLanguageSource } from "./native-language";
export function installApplicationMenu(options: {
productName: string;
language?: NativeLanguageSource;
buildMenu(template: MenuItemConstructorOptions[]): Menu;
setMenu(menu: Menu): void;
exportLogs(): Promise<void>;
reportError(message: string): void;
}): void {
}): () => void {
const runExport = async () => {
try {
await options.exportLogs();
} catch {
options.reportError("日志未能导出,请稍后重试。");
options.reportError(
translate(
options.language?.read().locale ?? "zh-CN",
"nativeExportError",
),
);
}
};
options.setMenu(
options.buildMenu([
{
label: options.productName,
submenu: [{ role: "about" }, { type: "separator" }, { role: "quit" }],
},
{ role: "editMenu" },
{ role: "windowMenu" },
{
label: "帮助",
submenu: [
{
label: "导出日志(发给开发排查)",
click: runExport,
},
],
},
]),
);
const refresh = () => {
const locale = options.language?.read().locale ?? "zh-CN";
options.setMenu(
options.buildMenu([
{
label: options.productName,
submenu: [{ role: "about" }, { type: "separator" }, { role: "quit" }],
},
{ role: "editMenu" },
{ role: "windowMenu" },
{
label: translate(locale, "nativeHelp"),
submenu: [
{
label: translate(locale, "nativeExportLogs"),
click: runExport,
},
],
},
]),
);
};
refresh();
return options.language?.subscribe(refresh) ?? (() => {});
}
@@ -1,4 +1,5 @@
import { EventEmitter } from "node:events";
import type { LanguageSnapshot } from "@one-memory/i18n";
import type {
App,
Menu,
@@ -25,6 +26,12 @@ function fixture(
enabled: false,
status: "disabled",
};
let changed: (() => void) | undefined;
const languageSnapshot: LanguageSnapshot = {
preference: "zh-CN",
locale: "zh-CN",
revision: 1,
};
const app = { isPackaged: packaged, quit: vi.fn() };
const tray = Object.assign(new EventEmitter(), {
setToolTip: vi.fn(),
@@ -49,6 +56,15 @@ function fixture(
installBackgroundTray({
app: app as unknown as App,
platform,
language: {
read: () => languageSnapshot,
subscribe: (listener) => {
changed = () => listener(languageSnapshot);
return () => {
changed = undefined;
};
},
},
resourcesPath: "/bundle/Contents/Resources",
appPath: "/repo/apps/desktop",
createImage: vi.fn(() => image as unknown as NativeImage),
@@ -80,6 +96,10 @@ function fixture(
install,
menu,
click,
changeLanguage(locale: LanguageSnapshot["locale"]) {
languageSnapshot.locale = locale;
changed?.();
},
setState(value: LoginPreference) {
state = value;
},
@@ -87,6 +107,26 @@ function fixture(
}
describe("native macOS background tray", () => {
it("rebuilds Spanish and Chinese labels without changing login or stopping services", async () => {
const f = fixture();
f.install();
await flush();
f.changeLanguage("es");
await flush();
expect(f.menu()[0]?.label).toBe("Abrir MemHub");
expect(f.menu()[1]?.label).toBe("Iniciar MemHub al iniciar sesión");
expect(f.menu()[3]?.label).toBe(
"Salir de MemHub (el servicio sigue en segundo plano)",
);
expect(f.setLoginPreference).not.toHaveBeenCalled();
f.click(0);
expect(f.open).toHaveBeenCalledOnce();
f.changeLanguage("zh-CN");
await flush();
expect(f.menu()[0]?.label).toBe("打开 MemHub");
f.click(3);
expect(f.app.quit).toHaveBeenCalledOnce();
});
it("reads and changes the unified Runtime login preference", async () => {
const f = fixture();
const dispose = f.install();
+21 -8
View File
@@ -1,4 +1,5 @@
import path from "node:path";
import { type MessageKey, translate } from "@one-memory/i18n";
import type {
App,
Menu,
@@ -7,6 +8,7 @@ import type {
Tray,
} from "electron";
import type { LoginPreference } from "./login-preference";
import type { NativeLanguageSource } from "./native-language";
export const LOGIN_SERVICE_NAME = "com.tencent.onememory.runtime.plist";
@@ -24,6 +26,7 @@ interface TrayOptions {
setLoginPreference(enabled: boolean): Promise<LoginPreference>;
allowLoginStartup?: boolean;
productName?: string;
language?: NativeLanguageSource;
}
export function installBackgroundTray(options: TrayOptions): () => void {
@@ -41,29 +44,32 @@ export function installBackgroundTray(options: TrayOptions): () => void {
let generation = 0;
const refresh = async () => {
if (disposed) return;
const locale = options.language?.read().locale ?? "zh-CN";
const t = (key: MessageKey) =>
translate(locale, key, { product: productName });
const current = ++generation;
let checked = false;
let enabled = app.isPackaged && options.allowLoginStartup !== false;
let label = `登录时启动 ${productName}`;
let label = t("nativeLogin");
if (options.allowLoginStartup === false) {
label += "(阶段包不可用)";
label += t("nativeTrialUnavailable");
} else if (app.isPackaged) {
try {
const state = await options.readLoginPreference();
if (disposed || current !== generation) return;
checked = state.enabled;
enabled = state.supported;
if (state.status === "managed-by-cli") label += "(由 CLI 管理)";
if (state.status === "managed-by-cli") label += t("nativeCliManaged");
} catch {
label += "(状态不可用)";
label += t("nativeStatusUnavailable");
enabled = false;
}
} else {
label += "(开发模式不可用)";
label += t("nativeDevelopmentUnavailable");
}
tray.setContextMenu(
options.buildMenu([
{ label: `打开 ${productName}`, click: options.open },
{ label: t("nativeOpen"), click: options.open },
{
label,
type: "checkbox",
@@ -74,14 +80,19 @@ export function installBackgroundTray(options: TrayOptions): () => void {
void options
.setLoginPreference(item.checked)
.catch(() =>
options.reportError("无法更改登录启动设置,请重试。"),
options.reportError(
translate(
options.language?.read().locale ?? "zh-CN",
"nativeLoginError",
),
),
)
.finally(() => void refresh());
},
},
{ type: "separator" },
{
label: `退出 ${productName}(后台服务继续运行)`,
label: t("nativeQuit"),
click: () => {
if (!disposed) app.quit();
},
@@ -89,6 +100,7 @@ export function installBackgroundTray(options: TrayOptions): () => void {
]),
);
};
const unsubscribeLanguage = options.language?.subscribe(() => void refresh());
try {
void refresh();
// Refresh before the native menu opens, including changes made in Settings.
@@ -101,6 +113,7 @@ export function installBackgroundTray(options: TrayOptions): () => void {
return () => {
if (disposed) return;
disposed = true;
unsubscribeLanguage?.();
tray.removeAllListeners("mouse-down");
tray.removeAllListeners("right-click");
tray.destroy();
@@ -50,6 +50,7 @@ interface Overrides {
whenReady?: Promise<void>;
windowGate?: Promise<void>;
windowError?: Error;
beforeNativeUi?: () => Promise<void>;
/** Give the lifecycle a startup placeholder, as the packaged App does. */
startupWindow?: boolean;
startupWindowError?: Error;
@@ -94,6 +95,7 @@ function fixture(background = false, lock = true, overrides: Overrides = {}) {
platform: overrides.platform ?? "darwin",
background,
prepare,
beforeNativeUi: overrides.beforeNativeUi,
initialize,
stop,
dispose,
@@ -148,6 +150,35 @@ function fixture(background = false, lock = true, overrides: Overrides = {}) {
}
describe("desktop background lifecycle with injected Electron", () => {
it("awaits the post-readiness language hook before menus, tray and runtime", async () => {
const gate = deferred();
const beforeNativeUi = vi.fn(() => gate.promise);
const f = fixture(false, true, { beforeNativeUi });
expect(beforeNativeUi).not.toHaveBeenCalled();
f.ready.resolve();
await f.flush();
expect(beforeNativeUi).toHaveBeenCalledTimes(1);
expect(f.installTray).not.toHaveBeenCalled();
expect(f.initialize).not.toHaveBeenCalled();
gate.resolve();
f.starting.resolve();
await f.flush();
expect(f.installTray).toHaveBeenCalledTimes(1);
expect(f.windows).toHaveLength(1);
});
it("does not install native UI after quitting during the language hook", async () => {
const gate = deferred();
const f = fixture(false, true, { beforeNativeUi: () => gate.promise });
f.ready.resolve();
await f.flush();
f.quit();
gate.resolve();
await f.flush();
expect(f.installTray).not.toHaveBeenCalled();
expect(f.initialize).not.toHaveBeenCalled();
f.stopping.resolve();
await f.flush();
});
it("starts services and tray without a window in background mode", async () => {
const f = fixture(true);
await f.boot();
@@ -18,6 +18,7 @@ interface LifecycleOptions {
platform: NodeJS.Platform;
background: boolean;
prepare(): void;
beforeNativeUi?(): Promise<void>;
initialize(isQuitting: () => boolean): Promise<void>;
stop(): Promise<void>;
dispose(): void;
@@ -152,6 +153,8 @@ export function startDesktopLifecycle(options: LifecycleOptions): void {
.then(async () => {
if (quitting) return;
startup = Promise.resolve().then(async () => {
if (quitting) return;
await options.beforeNativeUi?.();
if (quitting) return;
options.installTray(open);
// A window first, services second. Startup can take tens of seconds;
@@ -1,4 +1,8 @@
import { EventEmitter } from "node:events";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { translate } from "@one-memory/i18n";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
interface FakeFrame {
@@ -11,13 +15,17 @@ interface FakeFrame {
const fake = vi.hoisted(() => ({
ready: undefined as (() => Promise<void>) | undefined,
handlers: new Map<string, (event: unknown) => unknown>(),
handlers: new Map<string, (event: unknown, ...args: unknown[]) => unknown>(),
windows: [] as FakeWindow[],
app: undefined as unknown as EventEmitter,
theme: undefined as unknown as EventEmitter,
renderer: undefined as unknown as EventEmitter,
unload: undefined as unknown as EventTarget,
packaged: true,
userData: "",
preferred: vi.fn(() => ["es-MX", "zh-CN"]),
locale: vi.fn(() => "zh-CN"),
menu: vi.fn(),
loadFailure: false,
reduceMotion: false as unknown,
reduceTransparency: false as unknown,
@@ -93,7 +101,10 @@ vi.mock("electron", () => ({
}),
isPackaged: fake.packaged,
getName: () => "One Memory Preview",
getPath: () => "/unused/display-test",
getPath: (name: string) =>
name === "userData" ? fake.userData : "/unused/display-test",
getPreferredSystemLanguages: fake.preferred,
getLocale: fake.locale,
getAppPath: () => "/unused/display-test",
quit: vi.fn(),
exit: vi.fn(),
@@ -119,8 +130,10 @@ vi.mock("electron", () => ({
},
BrowserWindow: FakeWindow,
ipcMain: {
handle: (channel: string, handler: (event: unknown) => unknown) =>
fake.handlers.set(channel, handler),
handle: (
channel: string,
handler: (event: unknown, ...args: unknown[]) => unknown,
) => fake.handlers.set(channel, handler),
removeHandler: (channel: string) => {
fake.removeHandler(channel);
fake.handlers.delete(channel);
@@ -138,7 +151,7 @@ vi.mock("electron", () => ({
session: { defaultSession: {} },
clipboard: { writeText: vi.fn() },
dialog: { showErrorBox: fake.error },
Menu: { buildFromTemplate: vi.fn(() => ({})), setApplicationMenu: vi.fn() },
Menu: { buildFromTemplate: vi.fn(() => ({})), setApplicationMenu: fake.menu },
shell: { openExternal: vi.fn() },
}));
vi.mock("@one-memory/host-bridge", () => ({
@@ -190,7 +203,8 @@ vi.mock("./core-supervisor", () => ({
findAvailablePort: async () => 9999,
}));
beforeEach(() => {
beforeEach(async () => {
fake.userData = await mkdtemp(path.join(os.tmpdir(), "memhub-language-ipc-"));
vi.resetModules();
vi.resetAllMocks();
fake.app = new EventEmitter();
@@ -212,8 +226,9 @@ beforeEach(() => {
vi.stubEnv("ONE_MEMORY_WEB_URL", "http://127.0.0.1:5173");
vi.stubEnv("ONE_MEMORY_SMOKE_QUIT_MS", "0");
});
afterEach(() => {
afterEach(async () => {
fake.app.emit("will-quit");
await rm(fake.userData, { recursive: true, force: true });
fake.unload.dispatchEvent(new Event("unload"));
vi.unstubAllGlobals();
vi.unstubAllEnvs();
@@ -242,6 +257,91 @@ const eventFor = (window: FakeWindow) => ({
senderFrame: window.frame,
});
describe("GUI language main IPC", () => {
it("loads the active profile after ready and before native menus", async () => {
await writeFile(
path.join(fake.userData, "gui-language.json"),
JSON.stringify({ preference: "es" }),
);
await import("./main.js");
expect(fake.preferred).not.toHaveBeenCalled();
expect(fake.menu).not.toHaveBeenCalled();
await fake.ready?.();
expect(fake.preferred).toHaveBeenCalledTimes(1);
expect(fake.preferred.mock.invocationCallOrder[0]).toBeLessThan(
fake.menu.mock.invocationCallOrder[0],
);
const read = fake.handlers.get("one-memory:get-language-preference");
if (!read) throw Error("Missing language read handler");
expect(await read(eventFor(fake.windows[0]))).toEqual({
preference: "es",
locale: "es",
revision: 0,
});
});
it("guards both operations and broadcasts only to live owned main frames", async () => {
const window = await startup();
const read = fake.handlers.get("one-memory:get-language-preference");
if (!read) throw Error("Missing language read handler");
const write = fake.handlers.get("one-memory:set-language-preference");
if (!write) throw Error("Missing language write handler");
const foreign = new FakeWindow();
for (const event of [
eventFor(foreign),
{
sender: window.webContents,
senderFrame: { ...window.frame, parent: window.frame },
},
{ sender: window.webContents, senderFrame: null },
]) {
await expect(read(event)).rejects.toThrow();
await expect(write(event, "es")).rejects.toThrow();
}
window.frame.url = "https://example.invalid";
await expect(read(eventFor(window))).rejects.toThrow();
await expect(write(eventFor(window), "es")).rejects.toThrow();
window.frame.url = "one-memory://app/index.html";
await write(eventFor(window), "zh-CN");
expect(window.frame.send).toHaveBeenCalledWith(
"one-memory:language-preference-changed",
{ preference: "zh-CN", locale: "zh-CN", revision: 1 },
);
expect(foreign.frame.send).not.toHaveBeenCalled();
window.frame.send.mockImplementation(() => {
throw Error("destroyed during send");
});
await expect(write(eventFor(window), "es")).resolves.toMatchObject({
revision: 2,
});
fake.app.emit("will-quit");
expect(fake.handlers.has("one-memory:get-language-preference")).toBe(false);
expect(fake.handlers.has("one-memory:set-language-preference")).toBe(false);
});
it("does not deliver an admitted write to a document that navigated during persistence", async () => {
const window = await startup();
const write = fake.handlers.get("one-memory:set-language-preference");
const read = fake.handlers.get("one-memory:get-language-preference");
if (!write || !read) throw Error("Missing language handlers");
const result = write(eventFor(window), "es");
window.frame.url = "https://example.invalid";
await expect(result).rejects.toThrow();
expect(window.frame.send).not.toHaveBeenCalled();
window.frame.url = "one-memory://app/index.html";
expect(await read(eventFor(window))).toMatchObject({
preference: "es",
revision: 1,
});
});
it("uses the shared locale for the native error title without translating error codes", async () => {
fake.loadFailure = true;
await startup();
expect(fake.error).toHaveBeenCalledWith(
translate("es", "nativeErrorTitle"),
"page_load_failed",
);
});
});
describe("display preference main IPC", () => {
it("registers background inspection against the fixed data directory and guards navigation", async () => {
const window = await startup();
@@ -412,6 +512,9 @@ describe("display preference main IPC", () => {
interface PreferenceAPI {
getBackgroundHealth(): Promise<unknown>;
restartBackground(): Promise<{ state: "restarted" }>;
getLanguagePreference(): Promise<unknown>;
setLanguagePreference(preference: unknown): Promise<unknown>;
onLanguagePreferenceChanged(listener: (value: unknown) => void): () => void;
getDisplayPreferences(): Promise<{
reduceMotion: boolean;
reduceTransparency: boolean;
@@ -424,6 +527,51 @@ async function preload() {
return fake.expose.mock.calls[0][1] as PreferenceAPI;
}
describe("language preference preload", () => {
it("validates writes and snapshots, strips event metadata and cleans subscriptions", async () => {
const api = await preload();
const snapshot = { preference: "es", locale: "es", revision: 2 };
fake.invoke.mockResolvedValue({ ...snapshot, secret: "not exposed" });
expect(await api.getLanguagePreference()).toEqual(snapshot);
expect(await api.setLanguagePreference("es")).toEqual(snapshot);
expect(fake.invoke).toHaveBeenLastCalledWith(
"one-memory:set-language-preference",
"es",
);
fake.invoke.mockClear();
await expect(api.setLanguagePreference("fr")).rejects.toThrow();
expect(fake.invoke).not.toHaveBeenCalled();
const listener = vi.fn();
const unsubscribe = api.onLanguagePreferenceChanged(listener);
for (const invalid of [
null,
{},
{ ...snapshot, revision: -1 },
{ ...snapshot, preference: "en" },
{ ...snapshot, locale: "en" },
]) {
fake.invoke.mockResolvedValue(invalid);
await expect(api.getLanguagePreference()).rejects.toThrow();
await expect(api.setLanguagePreference("es")).rejects.toThrow();
fake.renderer.emit("one-memory:language-preference-changed", {}, invalid);
}
expect(listener).not.toHaveBeenCalled();
fake.renderer.emit(
"one-memory:language-preference-changed",
{ secret: true },
{ ...snapshot, secret: true },
);
expect(listener).toHaveBeenCalledExactlyOnceWith(snapshot);
expect(listener.mock.calls[0][0]).not.toBe(snapshot);
fake.unload.dispatchEvent(new Event("unload"));
unsubscribe();
unsubscribe();
expect(
fake.renderer.listenerCount("one-memory:language-preference-changed"),
).toBe(0);
});
});
describe("display preference preload", () => {
it("preload exposes a no-argument background health read", async () => {
const api = await preload();
@@ -2,8 +2,10 @@ import { EventEmitter } from "node:events";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { translate } from "@one-memory/i18n";
import { afterEach, describe, expect, it, vi } from "vitest";
import { installEarlyStartupErrorHandler } from "./early-startup-errors";
import { stableRuntimeUserMessage } from "./stable-runtime";
const roots: string[] = [];
@@ -14,6 +16,40 @@ afterEach(async () => {
});
describe("early desktop startup errors", () => {
it.each(["es", "throws"] as const)(
"uses best-effort language (%s) without changing safe error codes",
async (language) => {
const dataDir = await mkdtemp(
path.join(os.tmpdir(), "desktop-early-language-"),
);
roots.push(dataDir);
const processEvents = new EventEmitter();
const showError = vi.fn();
const exit = vi.fn();
const dispose = installEarlyStartupErrorHandler({
processEvents,
dataDir,
stderr: { write: vi.fn(() => true) },
showError,
exit,
readLocale: () => {
if (language === "throws") throw new Error("unavailable");
return "es";
},
});
processEvents.emit(
"uncaughtException",
new Error("synthetic_safe_failure"),
);
expect(showError).toHaveBeenCalledExactlyOnceWith(
language === "es" ? "MemHub no pudo iniciarse" : "MemHub 无法启动",
stableRuntimeUserMessage(new Error("synthetic_safe_failure")),
);
expect(exit).toHaveBeenCalledExactlyOnceWith(1);
dispose();
},
);
it("replaces an earlier handler on the same process emitter", () => {
const processEvents = new EventEmitter();
const options = {
@@ -32,6 +68,37 @@ describe("early desktop startup errors", () => {
dispose();
});
it.each(["en", "es", undefined] as const)(
"uses a safe title for initialized locale %s and retains the technical code",
async (locale) => {
const dataDir = await mkdtemp(
path.join(os.tmpdir(), "desktop-early-locale-"),
);
roots.push(dataDir);
const processEvents = new EventEmitter();
const showError = vi.fn();
const exit = vi.fn();
const dispose = installEarlyStartupErrorHandler({
processEvents,
dataDir,
stderr: { write: vi.fn(() => true) },
showError,
exit,
readLocale: () => locale,
});
processEvents.emit(
"uncaughtException",
new Error("desktop_startup_failed"),
);
expect(showError).toHaveBeenCalledExactlyOnceWith(
translate(locale ?? "zh-CN", "nativeErrorTitle"),
stableRuntimeUserMessage(new Error("desktop_startup_failed")),
);
expect(exit).toHaveBeenCalledExactlyOnceWith(1);
dispose();
},
);
it("writes stderr and diagnostics before showing the fatal dialog", async () => {
const dataDir = await mkdtemp(
path.join(os.tmpdir(), "desktop-early-error-"),
@@ -1,4 +1,5 @@
import type { EventEmitter } from "node:events";
import { type GuiLocale, translate } from "@one-memory/i18n";
import {
diagnosticError,
writeDiagnosticEventSync,
@@ -12,6 +13,8 @@ interface EarlyStartupErrorHandlerOptions {
showError(title: string, message: string): void;
exit(code: number): void;
afterDiagnosticWrite?: () => void;
/** Only already initialized preferences; never read configuration during a fatal error. */
readLocale?: () => GuiLocale | undefined;
}
const SAFE_ERROR_CODE = /^[a-z][a-z0-9_]{0,127}$/;
@@ -56,7 +59,16 @@ export function installEarlyStartupErrorHandler(
// The dialog is the only part of this a person ever reads, so it gets the
// explanation, not the code. The code stays in the diagnostic event,
// where support can still match it against a known failure.
options.showError("MemHub 无法启动", stableRuntimeUserMessage(error));
let locale: GuiLocale = "zh-CN";
try {
locale = options.readLocale?.() ?? "zh-CN";
} catch {
// Preferences may not exist yet; do not read files or guess the locale.
}
options.showError(
translate(locale, "nativeErrorTitle"),
stableRuntimeUserMessage(error),
);
options.exit(1);
}
};
@@ -0,0 +1,55 @@
import { describe, expect, it, vi } from "vitest";
import { LanguagePreferences } from "./language-preferences";
import { installLanguagePreferencesIpc } from "./language-preferences-ipc";
describe("language preference IPC", () => {
it("guards reads/writes, validates payloads, broadcasts commits and disposes", async () => {
const handlers = new Map<
string,
(event: string, ...args: unknown[]) => Promise<unknown>
>();
const save = vi.fn(async () => {});
const preferences = await LanguagePreferences.open(
"/synthetic/profile",
"es",
{ read: async () => "{}", save },
);
const broadcast = vi.fn();
const dispose = installLanguagePreferencesIpc({
ipc: {
handle: (name, handler) => handlers.set(name, handler),
removeHandler: (name) => {
handlers.delete(name);
},
},
preferences,
requireTrustedWindow: (event: string) => {
if (event !== "trusted") throw Error("untrusted");
},
broadcast,
});
const read = handlers.get("one-memory:get-language-preference");
const write = handlers.get("one-memory:set-language-preference");
if (!read || !write) throw Error("Missing language handlers");
await expect(read("foreign")).rejects.toThrow("untrusted");
await expect(write("iframe", "es")).rejects.toThrow("untrusted");
await expect(write("trusted", "fr")).rejects.toThrow();
await expect(write("trusted", "es", "extra")).rejects.toThrow();
expect(save).not.toHaveBeenCalled();
expect(await read("trusted")).toEqual({
preference: "system",
locale: "es",
revision: 0,
});
await write("trusted", "en");
expect(broadcast).toHaveBeenCalledExactlyOnceWith({
preference: "en",
locale: "en",
revision: 1,
});
dispose();
dispose();
expect(handlers.size).toBe(0);
await expect(write("trusted", "es")).rejects.toThrow();
});
});
@@ -0,0 +1,47 @@
import { isLanguagePreference, type LanguageSnapshot } from "@one-memory/i18n";
import type { LanguagePreferences } from "./language-preferences";
const GET = "one-memory:get-language-preference";
const SET = "one-memory:set-language-preference";
export function installLanguagePreferencesIpc<Event>(options: {
ipc: {
handle(
name: string,
handler: (event: Event, ...args: unknown[]) => Promise<unknown>,
): unknown;
removeHandler(name: string): void;
};
preferences: LanguagePreferences;
requireTrustedWindow(event: Event): unknown;
broadcast(snapshot: LanguageSnapshot): void;
}): () => void {
let closed = false;
const requireTrusted = (event: Event) => {
if (closed) throw Error("language_preferences_closed");
options.requireTrustedWindow(event);
};
options.ipc.handle(GET, async (event, ...args) => {
requireTrusted(event);
if (args.length) throw Error("invalid_language_preference_arguments");
return options.preferences.read();
});
options.ipc.handle(SET, async (event, ...args) => {
requireTrusted(event);
if (args.length !== 1 || !isLanguagePreference(args[0]))
throw Error("invalid_language_preference");
const snapshot = await options.preferences.set(args[0]);
requireTrusted(event);
return snapshot;
});
const unsubscribe = options.preferences.subscribe((snapshot) => {
if (!closed) options.broadcast(snapshot);
});
return () => {
if (closed) return;
closed = true;
unsubscribe();
options.ipc.removeHandler(GET);
options.ipc.removeHandler(SET);
};
}
@@ -0,0 +1,141 @@
import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
LanguagePreferences,
preferredSystemLanguage,
} from "./language-preferences";
const roots: string[] = [];
async function profile() {
const root = await mkdtemp(path.join(os.tmpdir(), "memhub-language-"));
roots.push(root);
return root;
}
afterEach(async () => {
await Promise.all(
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
});
describe("GUI language preferences", () => {
it("defaults to system, persists only the preference in the active profile and reloads", async () => {
const root = await profile();
const store = await LanguagePreferences.open(root, "es-MX");
expect(store.read()).toEqual({
preference: "system",
locale: "es",
revision: 0,
});
expect(await readdir(root)).toEqual([]);
expect(await store.set("zh-CN")).toEqual({
preference: "zh-CN",
locale: "zh-CN",
revision: 1,
});
expect(
JSON.parse(await readFile(path.join(root, "gui-language.json"), "utf8")),
).toEqual({ preference: "zh-CN" });
expect((await LanguagePreferences.open(root, "es")).read().preference).toBe(
"zh-CN",
);
expect(
(await LanguagePreferences.open(await profile(), "es")).read().preference,
).toBe("system");
expect(await readdir(root)).toEqual(["gui-language.json"]);
});
it.each(["{broken", '{"preference":"fr"}', "null", "[]"])(
"does not overwrite invalid input %s while loading",
async (content) => {
const root = await profile();
const file = path.join(root, "gui-language.json");
await writeFile(file, content);
const store = await LanguagePreferences.open(root, "es");
expect(store.read().preference).toBe("system");
expect(await readFile(file, "utf8")).toBe(content);
},
);
it("survives failed reads and preserves the snapshot on failed writes, then recovers", async () => {
const save = vi
.fn()
.mockRejectedValueOnce(new Error("disk full"))
.mockResolvedValue(undefined);
const store = await LanguagePreferences.open("/synthetic/profile", "es", {
read: async () => {
throw new Error("unreadable");
},
save,
});
const listener = vi.fn();
store.subscribe(listener);
await expect(store.set("zh-CN")).rejects.toThrow(
"language_preference_save_failed",
);
expect(store.read()).toEqual({
preference: "system",
locale: "es",
revision: 0,
});
expect(listener).not.toHaveBeenCalled();
await store.set("zh-CN");
expect(store.read().revision).toBe(1);
expect(listener).toHaveBeenCalledTimes(1);
});
it("serializes racing writes and emits only committed snapshots", async () => {
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const save = vi
.fn()
.mockImplementationOnce(() => gate)
.mockResolvedValue(undefined);
const store = await LanguagePreferences.open("/synthetic/profile", "es", {
read: async () => "{}",
save,
});
const listener = vi.fn();
const unsubscribe = store.subscribe(listener);
const first = store.set("zh-CN");
const second = store.set("es");
await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1));
expect(store.read().revision).toBe(0);
release();
expect((await first).revision).toBe(1);
expect(await second).toEqual({
preference: "es",
locale: "es",
revision: 2,
});
expect(listener.mock.calls.map(([value]) => value.revision)).toEqual([
1, 2,
]);
unsubscribe();
await store.set("system");
expect(listener).toHaveBeenCalledTimes(2);
await expect(store.set("invalid" as "es")).rejects.toThrow();
});
it("uses only the primary preferred language, falling back to locale if unavailable", () => {
expect(
preferredSystemLanguage({
getPreferredSystemLanguages: () => ["en", "es"],
getLocale: () => "es",
}),
).toBe("en");
expect(
preferredSystemLanguage({
getPreferredSystemLanguages: () => [],
getLocale: () => "es",
}),
).toBe("es");
expect(
preferredSystemLanguage({
getPreferredSystemLanguages: () => {
throw Error();
},
getLocale: () => "es",
}),
).toBe("es");
});
});
@@ -0,0 +1,135 @@
import { randomUUID } from "node:crypto";
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import {
isLanguagePreference,
type LanguagePreference,
type LanguageSnapshot,
resolveGuiLocale,
} from "@one-memory/i18n";
interface Storage {
read(file: string): Promise<string>;
save(file: string, content: string): Promise<void>;
}
const storage: Storage = {
read: (file) => readFile(file, "utf8"),
async save(file, content) {
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
const temporary = `${file}.${randomUUID()}.tmp`;
try {
await writeFile(temporary, content, {
encoding: "utf8",
mode: 0o600,
flag: "wx",
});
await rename(temporary, file);
} finally {
await rm(temporary, { force: true }).catch(() => {});
}
},
};
/** GUI-only preferences, scoped to the active Electron userData profile. */
export class LanguagePreferences {
private queue: Promise<unknown> = Promise.resolve();
private readonly listeners = new Set<(snapshot: LanguageSnapshot) => void>();
private snapshot: LanguageSnapshot;
private constructor(
private readonly file: string,
private readonly systemLanguage: string | undefined,
private readonly io: Storage,
preference: LanguagePreference,
) {
this.snapshot = {
preference,
locale: resolveGuiLocale(preference, systemLanguage),
revision: 0,
};
}
static async open(
userData: string,
systemLanguage?: string,
io: Storage = storage,
): Promise<LanguagePreferences> {
const file = path.join(userData, "gui-language.json");
let preference: LanguagePreference = "system";
try {
const data: unknown = JSON.parse(await io.read(file));
if (
data &&
typeof data === "object" &&
"preference" in data &&
isLanguagePreference(data.preference)
) {
preference = data.preference;
}
} catch {
// Missing, unreadable or corrupt preferences never block startup or get rewritten.
}
return new LanguagePreferences(file, systemLanguage, io, preference);
}
read(): LanguageSnapshot {
return { ...this.snapshot };
}
set(preference: LanguagePreference): Promise<LanguageSnapshot> {
if (!isLanguagePreference(preference))
return Promise.reject(Error("invalid_language_preference"));
const operation = this.queue.then(async () => {
if (preference === this.snapshot.preference) return this.read();
try {
await this.io.save(this.file, `${JSON.stringify({ preference })}\n`);
} catch {
throw Error("language_preference_save_failed");
}
this.snapshot = {
preference,
locale: resolveGuiLocale(preference, this.systemLanguage),
revision: this.snapshot.revision + 1,
};
for (const listener of this.listeners) {
try {
listener(this.read());
} catch {
/* A closed consumer cannot undo a committed save. */
}
}
return this.read();
});
this.queue = operation.catch(() => {});
return operation;
}
subscribe(listener: (snapshot: LanguageSnapshot) => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
async drain(): Promise<void> {
await this.queue;
}
}
/** Call only after Electron readiness: later preferred languages are not fallbacks. */
export function preferredSystemLanguage(app: {
getPreferredSystemLanguages(): string[];
getLocale(): string;
}): string | undefined {
try {
const primary = app.getPreferredSystemLanguages()[0];
if (primary) return primary;
} catch {
/* Older/unavailable platform API: use Electron's locale. */
}
try {
return app.getLocale() || undefined;
} catch {
return undefined;
}
}
+74 -7
View File
@@ -9,6 +9,7 @@ import {
renderCustomHarnessSkill,
verifyRecallInstall,
} from "@one-memory/host-bridge/installer";
import { type GuiLocale, translate } from "@one-memory/i18n";
import {
claimRuntimeServiceForApp,
claimRuntimeServiceForCli,
@@ -91,6 +92,11 @@ import {
resolvePackagedBuildNumber,
resolvePackagedCommit,
} from "./isolated-runtime";
import {
LanguagePreferences,
preferredSystemLanguage,
} from "./language-preferences";
import { installLanguagePreferencesIpc } from "./language-preferences-ipc";
import {
disableLegacyLoginItem,
wasOpenedByLegacyLoginItem,
@@ -125,6 +131,9 @@ let runtimeSupervisor: RuntimeSupervisor | null = null;
let runtimeDataDir: string | null = null;
let preserveManagedRuntimeOnQuit = false;
let disposeTray: (() => void) | null = null;
let disposeApplicationMenu: (() => void) | null = null;
let languagePreferences: LanguagePreferences | null = null;
let disposeLanguagePreferences: (() => void) | null = null;
const displayWindows = new Set<BrowserWindow>();
let disposeDisplayPreferences: (() => void) | null = null;
let disposeSourceDirectoryPicker: (() => void) | null = null;
@@ -146,6 +155,7 @@ installEarlyStartupErrorHandler({
process.env.ONE_MEMORY_DATA_DIR?.trim() ||
path.join(app.getPath("home"), ".one-memory"),
stderr: process.stderr,
readLocale: () => languagePreferences?.read().locale ?? "zh-CN",
showError: (title, message) => dialog.showErrorBox(title, message),
exit: (code) => app.exit(code),
});
@@ -274,7 +284,16 @@ let appProtocolInstalled = false;
function installPackagedWebProtocol(): void {
if (!app.isPackaged || appProtocolInstalled) return;
installAppProtocol(path.join(process.resourcesPath, "web"), {
startupScreen: startupScreenHtml,
// Preferences are open by now: beforeNativeUi runs before this window.
startupScreen: () => {
let locale: GuiLocale = "zh-CN";
try {
locale = languagePreferences?.read().locale ?? "zh-CN";
} catch {
// An unreadable preference keeps the Chinese placeholder.
}
return startupScreenHtml(locale);
},
});
appProtocolInstalled = true;
}
@@ -1076,6 +1095,26 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
};
try {
if (languagePreferences) {
disposeLanguagePreferences = installLanguagePreferencesIpc({
ipc: ipcMain,
preferences: languagePreferences,
requireTrustedWindow,
broadcast: (snapshot) => {
for (const window of displayWindows) {
try {
assertTrustedWindow(window);
window.webContents.mainFrame.send(
"one-memory:language-preference-changed",
snapshot,
);
} catch {
// Navigation/destruction races must never leak to another document.
}
}
},
});
}
disposeDisplayPreferences = installDisplayPreferences();
disposeSourceDirectoryPicker = installSourceDirectoryPicker();
if (appManagedRuntime) {
@@ -1292,10 +1331,20 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
});
void dialog.showMessageBox({
type: "warning",
message: "请先将 MemHub 拖到“应用程序”文件夹",
detail:
"从“应用程序”重新打开后,MemHub 才会接入其他应用并安装命令行工具。",
buttons: ["知道了"],
message: translate(
languagePreferences?.read().locale ?? "zh-CN",
"nativeMoveTitle",
),
detail: translate(
languagePreferences?.read().locale ?? "zh-CN",
"nativeMoveDetail",
),
buttons: [
translate(
languagePreferences?.read().locale ?? "zh-CN",
"nativeAcknowledge",
),
],
});
} else if (
trialProfile === null &&
@@ -1462,14 +1511,21 @@ if (desktopVersion !== null) {
platform: process.platform,
background: desktopBackgroundLaunch,
prepare: prepareProtocol,
beforeNativeUi: async () => {
languagePreferences = await LanguagePreferences.open(
app.getPath("userData"),
preferredSystemLanguage(app),
);
},
initialize,
createWindow,
showStartupWindow: createStartupWindow,
revealStartupWindow,
getWindows: () => [...displayWindows],
installTray: (open) => {
installApplicationMenu({
disposeApplicationMenu = installApplicationMenu({
productName: app.getName(),
language: languagePreferences ?? undefined,
buildMenu: (template) => Menu.buildFromTemplate(template),
setMenu: (menu) => Menu.setApplicationMenu(menu),
exportLogs: async () => {
@@ -1481,6 +1537,7 @@ if (desktopVersion !== null) {
});
disposeTray = installBackgroundTray({
app,
language: languagePreferences ?? undefined,
platform: process.platform,
resourcesPath: process.resourcesPath,
appPath: app.getAppPath(),
@@ -1525,6 +1582,9 @@ if (desktopVersion !== null) {
...(coreSupervisor ? { instance_id: coreSupervisor.instanceId } : {}),
});
// An admitted native effect must settle and journal before Runtime detach.
disposeLanguagePreferences?.();
disposeLanguagePreferences = null;
await languagePreferences?.drain();
await connectionSetup?.drain();
// BUG-12: stopping the supervisor only releases this process's handles. In
// a packaged build the Runtime is its own process and was left running
@@ -1573,6 +1633,10 @@ if (desktopVersion !== null) {
});
},
dispose: () => {
disposeLanguagePreferences?.();
disposeLanguagePreferences = null;
disposeApplicationMenu?.();
disposeApplicationMenu = null;
connectionSetup?.dispose();
disposeDiagnostics?.();
disposeDiagnostics = null;
@@ -1594,7 +1658,10 @@ if (desktopVersion !== null) {
error instanceof Error ? error.name : "UnknownError",
);
dialog.showErrorBox(
"MemHub 无法启动",
translate(
languagePreferences?.read().locale ?? "zh-CN",
"nativeErrorTitle",
),
error instanceof Error ? error.message : "desktop_unavailable",
);
},
@@ -0,0 +1,7 @@
import type { LanguageSnapshot } from "@one-memory/i18n";
/** Shared committed GUI language for native menus, tray, and dialogs. */
export interface NativeLanguageSource {
read(): LanguageSnapshot;
subscribe(listener: (snapshot: LanguageSnapshot) => void): () => void;
}
@@ -59,6 +59,9 @@ it("loads the production preload in a sandbox with Electron as its only module",
const bridge = exposed as unknown as Record<string, unknown>;
expect(bridge.getApiBaseUrl).toBeTypeOf("function");
expect(bridge.getApiToken).toBeTypeOf("function");
expect(bridge.getLanguagePreference).toBeTypeOf("function");
expect(bridge.setLanguagePreference).toBeTypeOf("function");
expect(bridge.onLanguagePreferenceChanged).toBeTypeOf("function");
} finally {
rmSync(output, { recursive: true, force: true });
}
+54
View File
@@ -10,6 +10,12 @@ import type {
RecallInstallPreview,
RecallUninstallPreview,
} from "@one-memory/host-bridge/installer";
import {
isLanguagePreference,
isLanguageSnapshot,
type LanguagePreference,
type LanguageSnapshot,
} from "@one-memory/i18n";
import type { BackgroundHealth } from "@one-memory/runtime-control";
import { contextBridge, ipcRenderer } from "electron";
import type { DebugLogSnapshot } from "./diagnostics-ipc";
@@ -34,12 +40,60 @@ declare const __ONE_MEMORY_VERSION__: string;
declare const __ONE_MEMORY_BUILD_NUMBER__: string;
declare const __ONE_MEMORY_COMMIT__: string;
function languageSnapshot(payload: unknown): LanguageSnapshot {
if (!isLanguageSnapshot(payload))
throw Error("language_preferences_unavailable");
return {
preference: payload.preference,
locale: payload.locale,
revision: payload.revision,
};
}
contextBridge.exposeInMainWorld("oneMemory", {
getBuildInfo: () => ({
version: __ONE_MEMORY_VERSION__,
buildNumber: __ONE_MEMORY_BUILD_NUMBER__,
commit: __ONE_MEMORY_COMMIT__,
}),
getLanguagePreference: async (): Promise<LanguageSnapshot> =>
languageSnapshot(
await ipcRenderer.invoke("one-memory:get-language-preference"),
),
setLanguagePreference: async (
preference: LanguagePreference,
): Promise<LanguageSnapshot> => {
if (!isLanguagePreference(preference))
throw Error("invalid_language_preference");
return languageSnapshot(
await ipcRenderer.invoke(
"one-memory:set-language-preference",
preference,
),
);
},
onLanguagePreferenceChanged: (
listener: (value: LanguageSnapshot) => void,
): (() => void) => {
if (typeof listener !== "function")
throw Error("invalid_language_preference_listener");
const handler = (_event: unknown, payload: unknown) => {
if (isLanguageSnapshot(payload)) listener(languageSnapshot(payload));
};
let subscribed = true;
const unsubscribe = () => {
if (!subscribed) return;
subscribed = false;
ipcRenderer.removeListener(
"one-memory:language-preference-changed",
handler,
);
window.removeEventListener("unload", unsubscribe);
};
ipcRenderer.on("one-memory:language-preference-changed", handler);
window.addEventListener("unload", unsubscribe, { once: true });
return unsubscribe;
},
getDebugLogs: (): Promise<DebugLogSnapshot> =>
ipcRenderer.invoke("one-memory:get-debug-logs"),
logUiTrace: (input: {
@@ -11,6 +11,16 @@ describe("startup screen", () => {
expect(html).not.toContain("oneMemory");
expect(html).not.toContain("ipcRenderer");
});
it("speaks the interface language and keeps Chinese by default", () => {
expect(startupScreenHtml()).toContain('<html lang="zh-CN">');
expect(startupScreenHtml()).toContain("正在启动,正在准备记忆服务…");
const spanish = startupScreenHtml("es");
expect(spanish).toContain('<html lang="es">');
expect(spanish).toContain(
"Iniciando y preparando el servicio de memorias…",
);
expect(spanish).not.toMatch(/[一-鿿]/);
});
it("is served from the app origin, not from the web bundle", () => {
expect(STARTUP_SCREEN_URL).toBe("one-memory://app/__startup");
});
+4 -3
View File
@@ -1,3 +1,4 @@
import { type GuiLocale, translate } from "@one-memory/i18n";
import { APP_ORIGIN, STARTUP_SCREEN_PATH } from "./app-protocol";
export const STARTUP_SCREEN_URL = `${APP_ORIGIN}${STARTUP_SCREEN_PATH}`;
@@ -10,9 +11,9 @@ export const STARTUP_SCREEN_URL = `${APP_ORIGIN}${STARTUP_SCREEN_PATH}`;
* the gap. It deliberately makes no IPC calls: it renders before `initialize`
* has registered any handler, and anything it asked for would fail.
*/
export function startupScreenHtml(): string {
export function startupScreenHtml(locale: GuiLocale = "zh-CN"): string {
return `<!doctype html>
<html lang="zh-CN">
<html lang="${locale}">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'" />
@@ -102,7 +103,7 @@ export function startupScreenHtml(): string {
<body>
<main>
<h1>MemHub</h1>
<p>正在启动,正在准备记忆服务…</p>
<p>${translate(locale, "startupPreparing")}</p>
<div class="bar"><span></span></div>
</main>
</body>
+2
View File
@@ -34,8 +34,10 @@ export default defineConfig({
noExternal: [
"@one-memory/host-bridge",
"@one-memory/contracts",
"@one-memory/i18n",
"@one-memory/runtime-control",
"axios",
"i18next",
"https-proxy-agent",
"proxy-from-env",
],
+1
View File
@@ -11,6 +11,7 @@
},
"dependencies": {
"@one-memory/contracts": "workspace:*",
"@one-memory/i18n": "workspace:*",
"@one-memory/operations": "workspace:*",
"@tanstack/react-query": "^5.103.2",
"axios": "^1.20.0",
+25
View File
@@ -9,6 +9,31 @@ import {
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { App } from "./App";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
it("localizes shared navigation and footer in Spanish", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
render(
<LanguageProvider>
<App />
</LanguageProvider>,
);
const nav = await screen.findByRole("navigation", {
name: "Navegación principal",
});
expect(
within(nav)
.getAllByRole("button")
.map((button) => button.textContent),
).toEqual(["Aplicaciones", "Memorias", "Configuración"]);
expect(within(nav).getByRole("button", { name: "Configuración" }).title).toBe(
"Configuración",
);
expect(screen.getAllByText("Almacenamiento local").length).toBeGreaterThan(0);
expect(
screen.getByRole("link", { name: "Saltar al contenido principal" }),
).toBeTruthy();
});
const memoryProps = vi.hoisted(() => vi.fn());
+15 -13
View File
@@ -22,10 +22,12 @@ import {
CALL_HISTORY_ENTRY_VISIBLE,
} from "./ApplicationsHome";
import { getOnboarding } from "./api";
import { useApplicationsLanguage } from "./applications-language";
import { CallHistory } from "./CallHistory";
import { DebugLogPanel } from "./DebugLogPanel";
import { DesktopFrame } from "./DesktopFrame";
import { FirstUseGuide } from "./FirstUseGuide";
import { useGuiLanguage } from "./gui-language";
import { HealthPanel } from "./HealthPanel";
import { ImportProgressStrip } from "./ImportProgressStrip";
import { MatterWorkspace } from "./MatterWorkspace";
@@ -64,6 +66,8 @@ export function App() {
}
function AppContent() {
const { t } = useGuiLanguage();
const { a } = useApplicationsLanguage();
const [onboarding, setOnboarding] = useState<OnboardingState | null>(null);
const [startupRead, setStartupRead] = useState<"loading" | "ready" | "error">(
"loading",
@@ -216,19 +220,19 @@ function AppContent() {
{startupRead === "error" ? (
<div className="boot-status">
<p className="error-banner" role="alert">
暂时无法读取启动状态,请重试。
{t("startupReadError")}
</p>
<R6Button
onClick={() => {
if (!startupFlight.current) readStartup();
}}
>
重新读取
{t("startupRetry")}
</R6Button>
</div>
) : (
<p className="boot-status" aria-live="polite">
正在打开 MemHub…
{t("startupOpening")}
</p>
)}
</main>
@@ -264,9 +268,9 @@ function AppContent() {
the text it used to contain. */}
<header className="r6-titlebar" />
<a className="skip-link" href="#main-content">
跳到主要内容
{a("skip")}
</a>
<nav className="app-rail" aria-label="主要导航">
<nav className="app-rail" aria-label={a("navigation")}>
<div className="brand-mark" title="MemHub">
<Layers3 size={21} strokeWidth={1.6} aria-hidden="true" />
<span>MemHub</span>
@@ -285,7 +289,7 @@ function AppContent() {
key={destination}
active={displayedView === destination}
tourId={destination === "sources" ? "nav-sources" : undefined}
label={destination === "memory" ? "记忆" : "应用"}
label={a(destination === "memory" ? "navMemories" : "apps")}
icon={
destination === "memory" ? (
<Network size={19} />
@@ -308,16 +312,13 @@ function AppContent() {
<RailButton
active={displayedView === "settings" || displayedView === "health"}
tourId="nav-settings"
label="设置"
label={t("settings")}
icon={<Settings size={18} />}
onClick={() => navigate("settings")}
/>
<p
className="rail-core"
title="记忆保存在本机;使用云模型整理或交给已授权的助手时,相关内容会发送给对应服务。"
>
<p className="rail-core" title={a("localHint")}>
<span aria-hidden="true" />
本地存储
{a("local")}
</p>
</div>
</nav>
@@ -439,7 +440,7 @@ function AppContent() {
onClick={() => navigate("settings")}
>
<ArrowLeft size={16} />
设置
{t("settings")}
</button>
<HealthPanel imports={imports} />
</>
@@ -511,6 +512,7 @@ function RailButton({
className={`rail-button${active ? " active" : ""}`}
aria-current={active ? "page" : undefined}
data-tour-id={tourId}
title={label}
onClick={onClick}
>
<span className="rail-icon" aria-hidden="true">
@@ -12,6 +12,32 @@ import {
import { useState } from "react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { ApplicationDetail } from "./ApplicationDetail";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
it("translates application detail live without triggering extra reads or writes", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
const refresh = vi.fn().mockResolvedValue({ sources: [source] });
render(
<LanguageProvider>
<ApplicationDetail {...props} refresh={refresh} />
</LanguageProvider>,
);
expect(
await screen.findByRole("heading", { name: "Detalles de la aplicación" }),
).toBeTruthy();
expect(screen.getByRole("switch", { name: "Sincronización" })).toBeTruthy();
await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1));
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
await act(async () =>
window.dispatchEvent(
new StorageEvent("storage", { key: LANGUAGE_STORAGE_KEY }),
),
);
expect(await screen.findByRole("heading", { name: "应用详情" })).toBeTruthy();
expect(refresh).toHaveBeenCalledTimes(1);
expect(props.setSync).not.toHaveBeenCalled();
});
import { saveConnectionPreferences } from "./connection-preferences";
import { resetConnectionReconcile } from "./connection-reconcile";
import { commitSources } from "./source-state";
@@ -576,6 +602,109 @@ it("shows insertion still on when neither removal nor revocation succeeded", asy
);
});
let languageListener: ((snapshot: unknown) => void) | undefined;
function switchLanguage(locale: "es" | "zh-CN", revision: number) {
act(() => languageListener?.({ preference: locale, locale, revision }));
}
async function failDisconnectInSpanish() {
const oneMemory = window.oneMemory as unknown as Record<string, unknown>;
oneMemory.getLanguagePreference = vi.fn(async () => ({
preference: "es",
locale: "es",
revision: 1,
}));
oneMemory.onLanguagePreferenceChanged = vi.fn(
(listener: (snapshot: unknown) => void) => {
languageListener = listener;
return () => {};
},
);
render(
<LanguageProvider>
<ApplicationDetail
{...props}
source={opencode}
sources={[opencode]}
refresh={vi.fn().mockResolvedValue({ sources: [opencode] })}
/>
</LanguageProvider>,
);
const insertion = await screen.findByRole("switch", {
name: "Conectar con un clic",
});
await waitFor(() =>
expect(insertion.getAttribute("aria-checked")).toBe("true"),
);
fireEvent.click(insertion);
return insertion;
}
it("words a failed turn-off with its reason in Spanish, without Chinese punctuation", async () => {
editedHostBridge({ revokeFails: true });
await failDisconnectInSpanish();
const alert = await screen.findByText(/No se pudo eliminar la conexión/);
expect(alert.textContent).toBe(
"No se pudo eliminar la conexión ni revocar el permiso. OpenCode aún puede usar memorias. Reintenta. Motivo: El canal de control de la aplicación no está disponible.",
);
expect(alert.textContent).not.toMatch(/[。:;,]/);
});
it("re-words a visible failure when the language changes, without new writes", async () => {
const bridge = editedHostBridge({ revokeFails: true });
await failDisconnectInSpanish();
await screen.findByText(/Motivo: El canal de control/);
const reads = (
window.oneMemory as unknown as {
getRecallHosts: ReturnType<typeof vi.fn>;
}
).getRecallHosts;
const readCount = reads.mock.calls.length;
const revokes = bridge.revokeRecallPermission.mock.calls.length;
switchLanguage("zh-CN", 2);
expect(
await screen.findByText(
"连接未能移除,记忆权限也未能撤销,OpenCode 仍可使用记忆,请重试。原因:应用控制通道暂不可用。",
),
).toBeTruthy();
switchLanguage("es", 3);
expect(await screen.findByText(/Motivo: El canal de control/)).toBeTruthy();
expect(bridge.revokeRecallPermission.mock.calls.length).toBe(revokes);
expect(bridge.uninstallRecall).not.toHaveBeenCalled();
expect(reads.mock.calls.length).toBe(readCount);
});
it("omits the reason in Spanish when the failure has no known cause", async () => {
editedHostBridge({ revokeFails: true });
const oneMemory = window.oneMemory as unknown as Record<string, unknown>;
oneMemory.previewRecallUninstall = vi.fn(async () => {
throw Error("something_unknown");
});
oneMemory.revokeRecallPermission = vi.fn(async () => {
throw Error("something_unknown");
});
await failDisconnectInSpanish();
const alert = await screen.findByText(/No se pudo eliminar la conexión/);
expect(alert.textContent).toBe(
"No se pudo eliminar la conexión ni revocar el permiso. OpenCode aún puede usar memorias. Reintenta.",
);
});
it("adds the failed re-read note in Spanish after the reason", async () => {
editedHostBridge({ revokeFails: true });
const oneMemory = window.oneMemory as unknown as Record<string, unknown>;
const getRecallHosts = oneMemory.getRecallHosts as ReturnType<typeof vi.fn>;
const revoke = oneMemory.revokeRecallPermission as ReturnType<typeof vi.fn>;
revoke.mockImplementation(async () => {
getRecallHosts.mockRejectedValue(Error("offline"));
throw Error("host_control_unavailable");
});
await failDisconnectInSpanish();
const alert = await screen.findByText(/No se pudo eliminar la conexión/);
expect(alert.textContent).toBe(
"No se pudo eliminar la conexión ni revocar el permiso. OpenCode aún puede usar memorias. Reintenta. Motivo: El canal de control de la aplicación no está disponible. Tampoco se pudo volver a leer la conexión; el estado mostrado podría no estar actualizado.",
);
});
it("explains why an app without a revocable connection cannot be inserted", async () => {
const pi = {
...source,
+111 -64
View File
@@ -1,4 +1,5 @@
import type { SourceResponse } from "@one-memory/contracts";
import type { MessageKey } from "@one-memory/i18n";
import { useCallback, useEffect, useRef, useState } from "react";
import { AppMark } from "./app-mark";
import { connectionHostId, preferenceHasSource } from "./connection-hosts";
@@ -14,7 +15,6 @@ import {
hostCapabilities,
hostConnectionApproved,
hostConnectionUnsupported,
hostFailureNote,
hostRereadFailed,
hostWriteReason,
matchHost,
@@ -24,6 +24,7 @@ import {
UNSUPPORTED_NOTE,
useRecallHosts,
} from "./host-state";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button, R6Switch } from "./R6Controls";
import {
latestSource,
@@ -32,16 +33,20 @@ import {
} from "./source-state";
import "./application-detail.css";
/** A failed host write as data: worded only when rendered. */
type HostFailure = { reason: string | null; rereadFailed: boolean };
type InstallError = {
key: MessageKey;
params: Record<string, string>;
failure?: HostFailure;
};
const SYNC_UNCONFIRMED = "此应用的处理开关结果未能确认,其他应用不受影响。";
// D97: the single sync switch now governs both taking in new data and
// generating memories, so the separate read-permission switch is hidden, not
// removed — flip this back to restore it without digging through history.
const MEMORY_PERMISSION_SECTION_VISIBLE = false;
function connectFailure(reason: string | null, rereadFailed = false) {
return `此应用的一键接入未完成,其他应用不受影响。${hostFailureNote(reason, rereadFailed)}`;
}
function connectionLabel(input: {
approved: boolean;
insertionOn: boolean;
@@ -85,6 +90,13 @@ export function ApplicationDetail({
refresh(): Promise<{ sources: SourceResponse[] }>;
onBack(): void;
}) {
const { text, t, locale } = useOnboardingTranslation();
/** Word a host failure at render time so a language switch re-words it. */
function failureNote({ reason, rereadFailed }: HostFailure) {
return `${reason ? t("appFailureReason", { reason: text(reason) }) : ""}${
rereadFailed ? t("appFailureReread") : ""
}`;
}
const source = useSource(given);
const hosts = useRecallHosts().data;
const [read, setRead] = useState<{
@@ -98,7 +110,7 @@ export function ApplicationDetail({
const [refreshing, setRefreshing] = useState(false);
const [refreshError, setRefreshError] = useState(false);
const [installing, setInstalling] = useState(false);
const [installError, setInstallError] = useState("");
const [installError, setInstallError] = useState<string | InstallError>("");
const [syncing, setSyncing] = useState(false);
const [syncError, setSyncError] = useState("");
const [memoryPending, setMemoryPending] = useState(false);
@@ -305,7 +317,15 @@ export function ApplicationDetail({
});
const hostId =
host?.hostId ?? connectionHostId(source.source_id) ?? source.source_id;
const hostError = installError;
const hostError =
typeof installError === "string"
? text(installError)
: t(installError.key, {
...installError.params,
...(installError.failure
? { reason: failureNote(installError.failure) }
: {}),
});
async function applyInsertion(enabled: boolean) {
const bridge = window.oneMemory;
if (!bridge) return;
@@ -318,27 +338,30 @@ export function ApplicationDetail({
const { outcome } = await disconnectHost(hostId);
if (outcome.state === "failed")
throw Object.assign(Error("disconnect_failed"), {
note: hostFailureNote(outcome.reason, outcome.rereadFailed),
failure: {
reason: outcome.reason,
rereadFailed: outcome.rereadFailed,
} satisfies HostFailure,
});
setLink(null);
if (outcome.state === "review")
setInstallError(
`${
setInstallError({
key:
outcome.permission?.receipt?.status === "revoked"
? `已撤销记忆权限,${source.display_name} 已查不到记忆。但`
: ""
}无法确认连接已全部移除,需要你手动核对:${outcome.findings
.map((finding) => `${finding.path}(${finding.where})`)
.join(";")}。`,
);
? "appRevokedReview"
: "appRemovalReview",
params: {
name: source.display_name,
paths: outcome.findings
.map((finding) => `${finding.path}(${finding.where})`)
.join(";"),
},
});
if (outcome.state === "revoked")
setInstallError(
`已撤销记忆权限,${source.display_name} 已查不到记忆。${
outcome.configEdited
? "配置文件被改过,MemHub 没有自动删除;请手动移除其中的 MemHub 连接。"
: "连接文件没有移除,请重新核对。"
}`,
);
setInstallError({
key: outcome.configEdited ? "appRevokedModified" : "appRevokedRetained",
params: { name: source.display_name },
});
}
async function changeInsertion(enabled: boolean) {
writeConnectionPreference("host", source.source_id, enabled);
@@ -364,10 +387,22 @@ export function ApplicationDetail({
// shows the grant that is really still there after the re-read.
setInstallError(
wanted
? connectFailure(hostWriteReason(error), hostRereadFailed(error))
: `连接未能移除,记忆权限也未能撤销,${source.display_name} 仍可使用记忆,请重试。${
(error as { note?: string }).note ?? ""
}`,
? {
key: "appConnectFailed",
params: {},
failure: {
reason: hostWriteReason(error),
rereadFailed: hostRereadFailed(error),
},
}
: {
key: "appDisconnectFailed",
params: { name: source.display_name },
failure: (error as { failure?: HostFailure }).failure ?? {
reason: null,
rereadFailed: false,
},
},
);
}
}
@@ -453,12 +488,14 @@ export function ApplicationDetail({
);
} else if (!wanted) setHostRestartNotice("");
} catch (error) {
setInstallError(
`此应用的记忆使用权限未能确认,请重试。${hostFailureNote(
hostWriteReason(error),
hostRereadFailed(error),
)}`,
);
setInstallError({
key: "appPermissionFailed",
params: {},
failure: {
reason: hostWriteReason(error),
rereadFailed: hostRereadFailed(error),
},
});
}
}
} finally {
@@ -472,11 +509,11 @@ export function ApplicationDetail({
return (
<section
className="r6-app-detail"
aria-label={`${source.display_name} 应用设置`}
aria-label={t("appSettingsTitle", { name: source.display_name })}
>
<h1 className="r6-app-detail-title">应用详情</h1>
<h1 className="r6-app-detail-title">{text("应用详情")}</h1>
<R6Button variant="quiet" onClick={onBack}>
返回应用
{text("返回应用")}
</R6Button>
<header>
<span className="r6-app-detail-icon">
@@ -484,31 +521,33 @@ export function ApplicationDetail({
</span>
<div>
<h2>{source.display_name}</h2>
<p>{syncStatus}</p>
<p>{text(syncStatus)}</p>
</div>
</header>
<div className="r6-app-settings">
<section className="r6-app-setting">
<div>
<h2>同步开关</h2>
<p>开启后处理历史待办,并持续接收此应用的新会话。</p>
<h2>{text("同步开关")}</h2>
<p>{text("开启后处理历史待办,并持续接收此应用的新会话。")}</p>
{syncing && syncChecked && (
<p role="status">
正在读取已有对话,首次开启可能需要一两分钟,请不要重复点击。
{text(
"正在读取已有对话,首次开启可能需要一两分钟,请不要重复点击。",
)}
</p>
)}
{backfillNotice && (
<p role="status">
将补充冻结期间的新对话;原有 pending Job 继续处理。
{text("将补充冻结期间的新对话;原有 pending Job 继续处理。")}
</p>
)}
{!syncing && authorizationNote && (
<p role="status">{authorizationNote}</p>
<p role="status">{text(authorizationNote)}</p>
)}
{syncError && <p role="alert">{syncError}</p>}
{syncError && <p role="alert">{text(syncError)}</p>}
</div>
<R6Switch
label="同步开关"
label={text("同步开关")}
checked={syncChecked}
pending={syncing}
disabled={
@@ -520,19 +559,19 @@ export function ApplicationDetail({
</section>
<section className="r6-app-setting">
<div>
<h2>一键插入应用</h2>
<p>安装记忆连接,并默认允许此应用使用已授权记忆。</p>
<h2>{text("一键插入应用")}</h2>
<p>{text("安装记忆连接,并默认允许此应用使用已授权记忆。")}</p>
{unsupported && (
<p>{`${source.display_name} ${UNSUPPORTED_NOTE}`}</p>
<p>{`${source.display_name} ${text(UNSUPPORTED_NOTE)}`}</p>
)}
{hostError && <p role="alert">{hostError}</p>}
{appLocationNote && <p role="status">{appLocationNote}</p>}
{appLocationNote && <p role="status">{text(appLocationNote)}</p>}
{hostRestartNotice ? (
<p role="status">{hostRestartNotice}</p>
<p role="status">{text(hostRestartNotice)}</p>
) : null}
</div>
<R6Switch
label="一键插入应用"
label={text("一键插入应用")}
checked={insertionChecked}
pending={installing}
disabled={
@@ -549,11 +588,11 @@ export function ApplicationDetail({
{MEMORY_PERMISSION_SECTION_VISIBLE && (
<section className="r6-app-setting">
<div>
<h2>允许使用记忆</h2>
<p>让 {source.display_name} 查询已授权范围内的记忆。</p>
<h2>{text("允许使用记忆")}</h2>
<p>{t("appMemoryScope", { name: source.display_name })}</p>
</div>
<R6Switch
label="允许使用记忆"
label={text("允许使用记忆")}
checked={memoryChecked}
pending={memoryPending}
disabled={
@@ -567,30 +606,38 @@ export function ApplicationDetail({
)}
<section className="r6-app-setting">
<div>
<h2>连接状态</h2>
<h2>{text("连接状态")}</h2>
{refreshError && (
<p role="alert">
暂时无法刷新来源状态,请重试;未更改同步或读取权限。
{text("暂时无法刷新来源状态,请重试;未更改同步或读取权限。")}
</p>
)}
{readFailed && (
<p role="alert">暂时无法读取连接状态,当前状态待核对;请重试。</p>
)}
{probeState && probeState !== "wired" && (
<p>
{`记忆工具没有进到 ${source.display_name} 当前对话。完全退出 ${source.display_name} 后重新打开,新对话里才会出现。`}
<p role="alert">
{text("暂时无法读取连接状态,当前状态待核对;请重试。")}
</p>
)}
{probeState && probeState !== "wired" && (
<p>{t("appRestart", { name: source.display_name })}</p>
)}
{probeState === "wired" && (
<p>已接通。已经打开的对话不会中途出现这个工具,请新开一轮。</p>
<p>
{text(
"已接通。已经打开的对话不会中途出现这个工具,请新开一轮。",
)}
</p>
)}
{linkFailed && (
<p role="alert">
本次连接探测失败,当前状态待核对;未把上次读取当作本次成功。
{text(
"本次连接探测失败,当前状态待核对;未把上次读取当作本次成功。",
)}
</p>
)}
{checkedAt && !stale && current?.state === "ready" && (
<small>上次读取:{new Date(checkedAt).toLocaleString()}</small>
<small>
{text("上次读取")}: {new Date(checkedAt).toLocaleString(locale)}
</small>
)}
</div>
<div className="r6-app-detail-actions">
@@ -600,7 +647,7 @@ export function ApplicationDetail({
aria-busy={probing || undefined}
onClick={() => void reread()}
>
<span>{stateText}</span>
<span>{text(stateText)}</span>
{probing ? (
<span className="r6-button-spinner" aria-hidden="true" />
) : null}
@@ -17,8 +17,54 @@ import {
import * as api from "./api";
import { saveConnectionPreferences } from "./connection-preferences";
import { resetConnectionReconcile } from "./connection-reconcile";
import { LanguageProvider } from "./gui-language";
import { queryClient, resetClient } from "./query-client";
it("localizes the whole home live without repeating reads or changing imported text", async () => {
let change:
| ((snapshot: {
preference: "es" | "zh-CN";
locale: "es" | "zh-CN";
revision: number;
}) => void)
| undefined;
Object.assign(window.oneMemory ?? {}, {
getLanguagePreference: vi
.fn()
.mockResolvedValue({ preference: "es", locale: "es", revision: 1 }),
onLanguagePreferenceChanged: vi.fn((listener) => {
change = listener;
return () => {};
}),
});
render(<LanguageProvider>{mountHome()}</LanguageProvider>);
await screen.findByRole("heading", { name: "Aplicaciones", level: 1 });
await screen.findByText("实际治理记忆");
expect(
screen.getByRole("region", { name: "Resumen de memorias" }),
).toBeTruthy();
expect(screen.getByText("Servicio activo")).toBeTruthy();
expect(screen.getByText("2 aplicaciones conectadas")).toBeTruthy();
expect(screen.getAllByRole("button", { name: "Ver detalles" })).toHaveLength(
3,
);
const calls = summary.mock.calls.length;
fireEvent.click(
screen.getByRole("button", { name: "Ver las 5 aplicaciones" }),
);
act(() => change?.({ preference: "zh-CN", locale: "zh-CN", revision: 2 }));
expect(
screen.getByRole("heading", { name: "全部应用", level: 1 }),
).toBeTruthy();
expect(screen.getByText("App 4")).toBeTruthy();
expect(summary).toHaveBeenCalledTimes(calls);
act(() => change?.({ preference: "es", locale: "es", revision: 3 }));
expect(
screen.getByRole("heading", { name: "Todas las aplicaciones", level: 1 }),
).toBeTruthy();
expect(summary).toHaveBeenCalledTimes(calls);
});
const {
summary,
processing,
@@ -114,6 +160,58 @@ beforeEach(() => {
]),
} as unknown as NonNullable<typeof window.oneMemory>;
});
it.each([
[true, "Organizando", "Organizando nuevas conversaciones"],
[false, "En pausa", "Se dejó de generar memorias nuevas"],
])(
"renders the Spanish sedimentation state without extra requests: %s",
async (enabled, label, detail) => {
// Task rows still running must not override the sedimentation switch.
processing.mockResolvedValue({ running: 3, pending: 10 });
sedimentation.mockResolvedValue({
enabled,
pending_retained: true,
imports_unaffected: true,
});
Object.assign(window.oneMemory ?? {}, {
getLanguagePreference: vi
.fn()
.mockResolvedValue({ preference: "es", locale: "es", revision: 1 }),
onLanguagePreferenceChanged: vi.fn(() => () => {}),
});
render(<LanguageProvider>{mountHome()}</LanguageProvider>);
expect(await screen.findAllByText(label)).toHaveLength(2);
expect(screen.getByText(detail)).toBeTruthy();
expect(sedimentation).toHaveBeenCalledOnce();
},
);
it("localizes failure and empty states without leaking errors", async () => {
summary.mockRejectedValue(new Error("synthetic_private_error"));
Object.assign(window.oneMemory ?? {}, {
getLanguagePreference: vi
.fn()
.mockResolvedValue({ preference: "es", locale: "es", revision: 1 }),
onLanguagePreferenceChanged: vi.fn(() => () => {}),
});
render(
<LanguageProvider>
<ApplicationsHome
imports={{ ...imports, sources: [] }}
onManage={() => {}}
onMemories={() => {}}
onSettings={() => {}}
/>
</LanguageProvider>,
);
expect(
await screen.findByText("No se puede leer el estado de las memorias"),
).toBeTruthy();
expect(screen.getByText("Aún no hay aplicaciones para mostrar")).toBeTruthy();
expect(
screen.getByRole("button", { name: "Reintentar lectura" }),
).toBeTruthy();
expect(document.body.textContent).not.toContain("synthetic_private_error");
});
it("hides a debug-frame dialogue excerpt from 最近更新 and falls back to memory", async () => {
recentConversations.mockResolvedValue({
items: [
@@ -498,6 +596,41 @@ it("warns only after three consecutive timeouts and keeps them out of app rows",
expect(screen.queryByRole("button", { name: "查看记录" })).toBeNull();
}
});
it("shows the consecutive-timeout warning in Spanish with the host name", async () => {
history.mockResolvedValue({
available: true,
recording_error: false,
items: Array.from({ length: 3 }, (_, index) => ({
event_id: `timeout-es-${index}`,
host_id: "workbuddy",
tool: "om_recall",
state: "timed_out",
occurred_at: new Date(Date.now() - index * 1000).toISOString(),
memory_count: 0,
raw_count: 0,
references: [],
})),
});
Object.assign(window.oneMemory ?? {}, {
getLanguagePreference: vi
.fn()
.mockResolvedValue({ preference: "es", locale: "es", revision: 1 }),
onLanguagePreferenceChanged: vi.fn(() => () => {}),
});
render(
<LanguageProvider>
<ApplicationsHome
imports={imports}
onManage={() => {}}
onMemories={() => {}}
onSettings={() => {}}
onCalls={() => {}}
/>
</LanguageProvider>,
);
const warning = await screen.findByText(/Las últimas consultas de WorkBuddy/);
expect(warning.textContent).not.toMatch(/[\u4e00-\u9fff]/);
});
it("uses live-sync success time for an application row", async () => {
liveSync.mockResolvedValue([
{
@@ -1294,3 +1427,36 @@ describe("运行状态卡:一次失败的刷新不能让卡片继续说在运
expect(screen.getByText("601")).toBeTruthy();
});
});
it("words an automatic sync failure and its reason in Spanish", async () => {
localStorage.setItem(
"one-memory.connection-preferences.v1",
JSON.stringify({ version: 1, syncSourceIds: ["app-0"], hostSourceIds: [] }),
);
queryClient.setQueryData(["connection-auto"], {
running: [],
attempted: ["app-0"],
failures: [
{
kind: "sync",
sourceId: "app-0",
reason: "此应用的读取授权已撤销,请重新授权。",
},
],
});
Object.assign(window.oneMemory ?? {}, {
getLanguagePreference: vi
.fn()
.mockResolvedValue({ preference: "es", locale: "es", revision: 1 }),
onLanguagePreferenceChanged: vi.fn(() => () => {}),
});
render(<LanguageProvider>{mountHome()}</LanguageProvider>);
const notice = await screen.findByText(
/No se pudo activar la sincronización continua de App 0\./,
);
expect(notice.textContent).toContain(
"Se revocó el permiso de lectura de esta aplicación. Vuelve a autorizarla.",
);
expect(notice.textContent).not.toMatch(/[一-鿿]/);
localStorage.removeItem("one-memory.connection-preferences.v1");
});
+163 -111
View File
@@ -5,6 +5,7 @@ import type {
RecentConversationItem,
SourceResponse,
} from "@one-memory/contracts";
import { type GuiLocale, isMessageKey } from "@one-memory/i18n";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { flushSync } from "react-dom";
@@ -30,6 +31,7 @@ import {
retryAutoReconcile,
useAutoConnect,
} from "./connection-reconcile";
import { useGuiLanguage } from "./gui-language";
import { HomeCardBoundary } from "./HomeCardBoundary";
import {
hostBridgeAvailable,
@@ -70,13 +72,15 @@ function presentableDialogue(text: string): boolean {
const retainedViews = new WeakMap<object, HomeViewState>();
function clockParts(value: string) {
type Translate = ReturnType<typeof useGuiLanguage>["t"];
function clockParts(value: string, locale: GuiLocale) {
const ms = Date.parse(value);
if (!Number.isFinite(ms)) return null;
const date = new Date(ms);
const now = new Date();
return {
clock: new Intl.DateTimeFormat("zh-CN", {
clock: new Intl.DateTimeFormat(locale, {
hour: "2-digit",
minute: "2-digit",
}).format(date),
@@ -87,16 +91,17 @@ function clockParts(value: string) {
};
}
function stamped(value: string, suffix = "") {
const parts = clockParts(value);
if (!parts) return `时间未知${suffix}`;
return `${parts.today ? "今天 " : ""}${parts.clock}${suffix}`;
function stamped(value: string, locale: GuiLocale, t: Translate, suffix = "") {
const parts = clockParts(value, locale);
if (!parts) return `${t("apps.unknownTime")}${suffix}`;
return `${parts.today ? t("apps.today") : ""}${parts.clock}${suffix}`;
}
function memoryAction(action: string | undefined) {
if (action === "create") return "新增记忆";
if (action === "correct" || action === "restore") return "更新记忆";
return "记忆已保存";
function memoryAction(action: string | undefined, t: Translate) {
if (action === "create") return t("apps.newMemory");
if (action === "correct" || action === "restore")
return t("apps.updatedMemory");
return t("apps.saved");
}
type ActivityChoice = "call" | "update";
@@ -135,23 +140,32 @@ function callAttention(events: CallEvent[]) {
function sourceActivity(
source: SourceResponse,
events: CallEvent[],
locale: GuiLocale,
t: Translate,
callHistoryUnavailable = false,
): string {
if (!source.authorized) return "尚未关联";
if (!source.authorized) return t("apps.unlinked");
const latest = events.find(
(item) =>
successfulCall(item) && sourceMatchesHost(source.source_id, item.host_id),
);
if (!latest) return "";
const time = stamped(latest.occurred_at);
const prefix = callHistoryUnavailable ? "上次读取:" : "";
return `${prefix}${callDetailHeadline(latest, source.display_name)} · ${time}`;
const time = stamped(latest.occurred_at, locale, t);
const prefix = callHistoryUnavailable ? t("apps.lastRead") : "";
return `${prefix}${callDetailHeadline(latest, source.display_name, locale)} · ${time}`;
}
function sourceSyncActivity(sourceId: string, sync: LiveSyncHealth[]): string {
function sourceSyncActivity(
sourceId: string,
sync: LiveSyncHealth[],
locale: GuiLocale,
t: Translate,
): string {
const current = sync.find((item) => item.source_id === sourceId);
return current?.last_success_at
? `最近同步 ${stamped(current.last_success_at)}`
? t("apps.recentSync", {
time: stamped(current.last_success_at, locale, t),
})
: "";
}
@@ -181,6 +195,7 @@ export function ApplicationsHome({
onCalls?(): void;
active?: boolean;
}) {
const { locale, t, tn } = useGuiLanguage();
const hostQuery = useRecallHosts({ poll: active });
const hostBridge = hostBridgeAvailable();
const hosts = hostQuery.data ?? [];
@@ -626,7 +641,7 @@ export function ApplicationsHome({
sourceMatchesHost(source.source_id, recentCall?.host_id ?? null),
);
const attention = callAttention(callEvents);
const attentionHost = attention ? callHostName(attention.host) : null;
const attentionHost = attention ? callHostName(attention.host, locale) : null;
const activity = summaryUnavailable
? "unavailable"
: visible === null
@@ -657,16 +672,16 @@ export function ApplicationsHome({
);
const stateLabel =
sedimentation?.enabled === true
? "整理中"
? t("apps.running")
: sedimentation?.enabled === false
? "已暂停"
: "待核对";
? t("apps.paused")
: t("apps.unchecked");
const stateDetail =
sedimentation?.enabled === false
? "已停止生成新记忆"
? t("apps.stoppedNew")
: sedimentation?.enabled === true
? "新的对话正在整理"
: "正在核对整理状态";
? t("apps.organizingNew")
: t("apps.checkingProcessing");
const latestUpdatedAt =
latest === null || latest === undefined
? null
@@ -675,7 +690,9 @@ export function ApplicationsHome({
: latest.gmt_created === null
? null
: new Date(latest.gmt_created * 1000).toISOString();
const latestClock = latestUpdatedAt ? clockParts(latestUpdatedAt) : null;
const latestClock = latestUpdatedAt
? clockParts(latestUpdatedAt, locale)
: null;
const latestSourceIds =
latest === null || latest === undefined
? []
@@ -684,8 +701,8 @@ export function ApplicationsHome({
: latest.source
? [latest.source]
: [];
const time = latestClock?.clock ?? (latest ? "时间未知" : null);
const checked = visible ? clockParts(visible.observed_at) : null;
const time = latestClock?.clock ?? (latest ? t("apps.unknownTime") : null);
const checked = visible ? clockParts(visible.observed_at, locale) : null;
// A failed read leaves the last health in place on purpose: clearing it would
// drop the instance it names, and that instance is what keeps memory writes
// and backfill receipts attached to the run that produced them. So the card
@@ -701,20 +718,24 @@ export function ApplicationsHome({
(!hostBridge && hostState !== "ready") ||
(admitted === 0 && hostApplyPending(auto, preferences.hostSourceIds));
const accessText = accessPending
? "正在核对中"
? t("apps.checkingNow")
: hostReadFailed
? "待核对"
? t("apps.unchecked")
: !unchecked.length
? `${admitted} 个应用已接入`
? tn("apps.connectedCount", admitted)
: admitted
? `${admitted} 个应用已接入,${unchecked.length} 个待核对`
: `${unchecked.length} 个应用待核对`;
? tn("apps.mixedCount", admitted, {
pending: new Intl.NumberFormat(locale).format(unchecked.length),
})
: tn("apps.pendingCount", unchecked.length);
const accessUnknownText = accessPending
? null
: hostReadFailed
? "应用接入状态暂时没有读到,无法确认有几个应用已接入。"
? t("apps.unknownAccess")
: unchecked.length
? `${unchecked.map((host) => host.label).join("、")} 的记忆权限暂时没有读到,无法确认是否已接入。`
? t("apps.unknownPermissions", {
names: unchecked.map((host) => host.label).join(", "),
})
: null;
async function restartBackground() {
if (restartingBackground) return;
@@ -751,11 +772,23 @@ export function ApplicationsHome({
source.source_id === failure.sourceId && source.sync_enabled,
),
);
const localizeFailureReason = (reason: string | null | undefined) =>
reason && isMessageKey(reason) ? t(reason) : (reason ?? "");
const autoFailureText = autoFailures
.map((failure) =>
failure.kind === "host"
? `${nameOf(failure.sourceId)} 未能自动接入${failure.reason ? `:${failure.reason}` : ""}。`
: `${nameOf(failure.sourceId)} 的持续同步未能自动开启。${failure.reason ?? ""}`,
? t("apps.autoHostFailed", {
name: nameOf(failure.sourceId),
reason: failure.reason
? t("apps.failureReason", {
reason: localizeFailureReason(failure.reason),
})
: "",
})
: t("apps.autoSyncFailed", {
name: nameOf(failure.sourceId),
reason: localizeFailureReason(failure.reason),
}),
)
.join("");
const authorizedSyncSources = imports.sources.filter(
@@ -766,12 +799,12 @@ export function ApplicationsHome({
(failure) => failure.kind === "sync",
);
const syncState = authorizedSyncSources.length
? `${authorizedSyncSources.length} 个应用已开启`
? tn("apps.enabledCount", authorizedSyncSources.length)
: syncApplyFailed
? "未能开启"
? t("apps.enableFailed")
: wantsSync || !visible
? "正在检查状态"
: "尚未开启";
? t("apps.checkingState")
: t("apps.notEnabled");
function toggleAll(next: boolean) {
transition.current(next);
}
@@ -779,7 +812,7 @@ export function ApplicationsHome({
<section
className="r6-home"
ref={root}
aria-label="应用首页"
aria-label={t("apps.home")}
data-expanded={expanded}
data-phase={phase}
onFocusCapture={(event) => {
@@ -788,7 +821,7 @@ export function ApplicationsHome({
}}
>
<header className="r6-home-heading">
<h1>{expanded ? "全部应用" : "应用"}</h1>
<h1>{expanded ? t("apps.allApps") : t("apps.apps")}</h1>
</header>
<button
type="button"
@@ -801,7 +834,7 @@ export function ApplicationsHome({
>
<span className="r6-button-label">
<ArrowLeft size={16} aria-hidden="true" />
返回首页
{t("apps.back")}
</span>
</button>
<div
@@ -810,14 +843,14 @@ export function ApplicationsHome({
hidden={expanded}
inert={expanded || phase !== "idle"}
>
<HomeCardBoundary label="记忆概览">
<section className="r6-home-overview" aria-label="记忆概览">
<HomeCardBoundary label={t("apps.overview")}>
<section className="r6-home-overview" aria-label={t("apps.overview")}>
<header>
<h2>记忆概览</h2>
<h2>{t("apps.overview")}</h2>
</header>
<div className="r6-home-metrics">
<div>
<p>已沉淀记忆</p>
<p>{t("apps.memories")}</p>
{/* The figure slot used to hold the word 读取中 while the first
read was in flight, which is how "大数字不可见" (D51) shows up
on a slow link: the biggest type on the page spends the first
@@ -833,38 +866,41 @@ export function ApplicationsHome({
aria-hidden="true"
/>
</p>
<small role="status">正在读取已沉淀记忆数量…</small>
<small role="status">{t("apps.readingCount")}</small>
</>
) : (
<>
<p className="r6-home-figure">
<strong className={visible ? "" : "r6-home-word"}>
{visible
? new Intl.NumberFormat("zh-CN").format(
? new Intl.NumberFormat(locale).format(
visible.memory_count,
)
: "暂无法读取"}
: t("apps.unreadable")}
</strong>
{visible ? <i>条</i> : null}
{visible && t("apps.unit") ? (
<i>{t("apps.unit")}</i>
) : null}
</p>
<small>按应用授权范围使用</small>
<small>{t("apps.scope")}</small>
</>
)}
</div>
<div>
<p>沉淀状态</p>
<p>{t("apps.processing")}</p>
<strong className="r6-home-word">{stateLabel}</strong>
<small>{stateDetail}</small>
</div>
<div>
<p>最近更新</p>
<p>{t("apps.recentUpdate")}</p>
<strong className={time ? "r6-home-time" : "r6-home-word"}>
{time ?? (visible ? "尚未生成" : "待核对")}
{time ??
(visible ? t("apps.notGenerated") : t("apps.unchecked"))}
</strong>
<small>
{time
? `${latestClock?.today ? "今天 · " : ""}记忆已保存`
: "历史与记忆分别处理"}
? `${latestClock?.today ? `${t("apps.today")}· ` : ""}${t("apps.saved")}`
: t("apps.separate")}
</small>
</div>
</div>
@@ -886,19 +922,21 @@ export function ApplicationsHome({
}
onClick={() => setActivityView(item)}
>
{item === "call" ? "最近调用" : "最近更新"}
{item === "call"
? t("apps.recentCall")
: t("apps.recentUpdate")}
</button>
))}
</div>
) : (
<p>
{activity === "unavailable"
? "动态暂不可用"
? t("apps.activityUnavailable")
: activity === "call"
? "最近调用"
? t("apps.recentCall")
: activity === "dialogue" || activity === "memory"
? "最近更新"
: "首批沉淀"}
? t("apps.recentUpdate")
: t("apps.firstBatch")}
</p>
)}
<div>
@@ -938,44 +976,45 @@ export function ApplicationsHome({
<h3>
{activity === "unavailable"
? callState === "unavailable"
? "暂时无法读取调用记录"
: "暂时无法读取记忆状态"
? t("apps.callsUnavailable")
: t("apps.memoryUnavailable")
: activity === "call" && recentCall
? callDetailHeadline(
recentCall,
recentCallHost?.display_name ??
callHostName(recentCall.host_id),
callHostName(recentCall.host_id, locale),
locale,
)
: activity === "memory" && latest
? `${latest.content}${"truncated" in latest && latest.truncated ? "…" : ""}`
: activity === "dialogue" && latestDialogue
? `${latestDialogue.excerpt}${latestDialogue.truncated ? "…" : ""}`
: activity === "importing"
? "对话持续导入中,即将沉淀你的记忆"
? t("apps.importing")
: activity === "first-running"
? "正在沉淀第一批记忆"
? t("apps.firstRunning")
: activity === "first-queued"
? "准备沉淀第一批记忆"
? t("apps.firstQueued")
: activity === "loading"
? "正在读取动态"
: "还没有已生成的记忆"}
? t("apps.readingActivity")
: t("apps.emptyMemories")}
</h3>
<small>
{activity === "unavailable"
? callState === "unavailable"
? "已有记忆仍可查看;重试不会发起新的调用。"
: "读取失败不会当作没有记忆。"
? t("apps.callReadFailure")
: t("apps.memoryReadFailure")
: activity === "call" && recentCall
? `${stamped(recentCall.occurred_at)} · ${callRowDetail(recentCall)}${callState === "incomplete" ? " · 有几次调用没被记下来,列表可能不全" : ""}`
? `${stamped(recentCall.occurred_at, locale, t)} · ${callRowDetail(recentCall, locale)}${callState === "incomplete" ? t("apps.incompleteCalls") : ""}`
: activity === "memory" && latest
? "revision" in latest
? `${time} · ${memoryAction(latest.action)} · ${latestSourceIds.map((id) => imports.sources.find((s) => s.source_id === id)?.display_name ?? id).join("、")} · 版本 ${latest.revision}`
? `${time} · ${memoryAction(latest.action, t)} · ${latestSourceIds.map((id) => imports.sources.find((s) => s.source_id === id)?.display_name ?? id).join(", ")} · ${t("apps.revision", { count: new Intl.NumberFormat(locale).format(latest.revision) })}`
: `${time} · SDK ${latest.layer} · ${latestSourceIds.map((id) => imports.sources.find((s) => s.source_id === id)?.display_name ?? id).join("、")}`
: activity === "dialogue" && latestDialogue
? `${clockParts(latestDialogue.created_at ?? latestDialogue.imported_at)?.clock ?? "时间未知"} ${latestDialogue.time_basis === "conversation" ? "对话时间" : "导入时间"} · ${imports.sources.find((source) => source.source_id === latestDialogue.source_id)?.display_name ?? latestDialogue.source_id}`
? `${clockParts(latestDialogue.created_at ?? latestDialogue.imported_at, locale)?.clock ?? t("apps.unknownTime")} ${latestDialogue.time_basis === "conversation" ? t("apps.conversationTime") : t("apps.importTime")} · ${imports.sources.find((source) => source.source_id === latestDialogue.source_id)?.display_name ?? latestDialogue.source_id}`
: activity === "importing"
? "对话正在后台持续保存。"
: "历史已保存,记忆生成后会显示在这里。"}
? t("apps.savingConversations")
: t("apps.waitingMemories")}
</small>
</section>
{(activity === "unavailable" ||
@@ -996,10 +1035,10 @@ export function ApplicationsHome({
}
>
{activity === "unavailable"
? "重新读取"
? t("apps.reread")
: activity === "call"
? "查看记录"
: "查看记忆"}
? t("apps.viewCalls")
: t("apps.viewMemory")}
{activity === "unavailable" ? null : (
<ArrowRight size={14} aria-hidden="true" />
)}
@@ -1013,8 +1052,8 @@ export function ApplicationsHome({
<section className="r6-home-auto-failure" role="alert">
<p>
{attention.kind === "unavailable"
? "后台服务没有运行,应用暂时查不到记忆"
: `${attentionHost} 最近几次没查到记忆,后台可能正忙,稍后会自动恢复`}
? t("apps.backgroundDown")
: t("apps.timeoutWarning", { host: attentionHost ?? "" })}
</p>
{attention.kind === "unavailable" || CALL_HISTORY_ENTRY_VISIBLE ? (
<R6Button
@@ -1028,40 +1067,42 @@ export function ApplicationsHome({
: onCalls
}
>
{attention.kind === "unavailable" ? "重新启动" : "查看记录"}
{attention.kind === "unavailable"
? t("apps.restart")
: t("apps.viewCalls")}
</R6Button>
) : null}
</section>
) : null}
<HomeCardBoundary label="运行状态">
<HomeCardBoundary label={t("apps.runtime")}>
<aside className="r6-home-runtime">
<h2>运行状态</h2>
<h2>{t("apps.runtime")}</h2>
<h3>
{connected
? "后台运行中"
? t("apps.backgroundRunning")
: imports.healthStale
? "连接状态待核对"
: "服务未连接"}
? t("apps.connectionUnchecked")
: t("apps.disconnected")}
</h3>
<p>仅处理已授权范围</p>
<p>{t("apps.authorizedOnly")}</p>
<dl>
<div>
<dt>对话同步</dt>
<dt>{t("apps.sync")}</dt>
<dd>{syncState}</dd>
</div>
<div>
<dt>记忆整理</dt>
<dt>{t("apps.organizing")}</dt>
<dd>{stateLabel}</dd>
</div>
<div>
<dt>应用访问</dt>
<dt>{t("apps.access")}</dt>
<dd>{accessText}</dd>
</div>
</dl>
{accessUnknownText ? (
<div
role="status"
aria-label="应用接入核对"
aria-label={t("apps.checkingAccess")}
className="r6-home-auto-failure"
>
<p>{accessUnknownText}</p>
@@ -1071,22 +1112,24 @@ export function ApplicationsHome({
disabled={hostQuery.isFetching}
onClick={() => void hostQuery.refetch()}
>
{hostQuery.isFetching ? "核对中…" : "重新核对"}
{hostQuery.isFetching
? t("apps.checking")
: t("apps.recheck")}
</button>
</div>
) : null}
{hostStale ? (
<p
role="status"
aria-label="应用接入状态"
aria-label={t("apps.accessState")}
className="r6-home-auto-failure"
>
应用接入状态暂时读取失败,当前为上次状态。
{t("apps.staleAccess")}
</p>
) : null}
{autoFailureText ? (
<div role="alert" className="r6-home-auto-failure">
<p>{`${autoFailureText}不会自动重试。`}</p>
<p>{`${autoFailureText}${t("apps.noAutoRetry")}`}</p>
<button
type="button"
className="r6-home-activity-link"
@@ -1099,14 +1142,16 @@ export function ApplicationsHome({
})
}
>
重新接入
{t("apps.reconnect")}
</button>
</div>
) : null}
<p className="r6-home-checked">
{!checked
? "上次核对:待核对"
: `上次核对:${checked.today ? "今天 " : ""}${checked.clock}`}
? t("apps.checkedAt", { time: t("apps.unchecked") })
: t("apps.checkedAt", {
time: `${checked.today ? t("apps.today") : ""}${checked.clock}`,
})}
</p>
</aside>
</HomeCardBoundary>
@@ -1114,7 +1159,7 @@ export function ApplicationsHome({
<section
className="r6-home-apps"
ref={panel}
aria-label="我的应用"
aria-label={t("apps.myApps")}
onWheel={(event) => {
if (!expanded && apps.length > 3 && event.deltaY > 10)
toggleAll(true);
@@ -1145,7 +1190,9 @@ export function ApplicationsHome({
}}
>
<header>
<h2>{expanded ? `${apps.length} 个应用` : "我的应用"}</h2>
<h2>
{expanded ? tn("apps.appCount", apps.length) : t("apps.myApps")}
</h2>
{apps.length > 3 ? (
<button
type="button"
@@ -1157,7 +1204,7 @@ export function ApplicationsHome({
className="r6-home-expand"
onClick={() => toggleAll(true)}
>
查看全部 {apps.length} 个应用
{tn("apps.viewAll", apps.length)}
</button>
) : null}
</header>
@@ -1165,7 +1212,7 @@ export function ApplicationsHome({
className="r6-home-list"
ref={list}
id={listId}
aria-label="应用列表"
aria-label={t("apps.list")}
tabIndex={expanded ? 0 : -1}
onScroll={(event) => {
if (expanded)
@@ -1174,8 +1221,13 @@ export function ApplicationsHome({
>
{(expanded ? apps : apps.slice(0, 3)).map((source) => {
const activity =
sourceActivity(source, callEvents, callState === "unavailable") ||
sourceSyncActivity(source.source_id, syncHealth);
sourceActivity(
source,
callEvents,
locale,
t,
callState === "unavailable",
) || sourceSyncActivity(source.source_id, syncHealth, locale, t);
const host = hosts.find((item) =>
sourceMatchesHost(source.source_id, item.hostId),
);
@@ -1187,13 +1239,13 @@ export function ApplicationsHome({
<h3>{source.display_name}</h3>
<p>
{source.sync_enabled
? "持续同步已开启"
? t("apps.syncEnabled")
: host?.installed &&
host.permission?.state === "approved"
? "允许请求已授权记忆"
? t("apps.recallAllowed")
: source.authorized
? "历史读取已授权"
: "连接待完成"}
? t("apps.historyAllowed")
: t("apps.connectionPending")}
</p>
</section>
{activity ? (
@@ -1204,7 +1256,7 @@ export function ApplicationsHome({
data-home-focus={`source:${source.source_id}`}
onClick={() => onManage(source.source_id)}
>
查看详情
{t("apps.details")}
</R6Button>
</div>
</div>
@@ -1212,7 +1264,7 @@ export function ApplicationsHome({
})}
{!apps.length ? (
<p className="r6-home-empty">
{stale ? "应用列表暂时不可读取" : "还没有可显示的应用"}
{stale ? t("apps.listUnavailable") : t("apps.emptyApps")}
</p>
) : null}
</section>
+75 -3
View File
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
import { readFileSync } from "node:fs";
import {
act,
cleanup,
@@ -10,12 +11,20 @@ import {
within,
} from "@testing-library/react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
it("permite crecer al botón de retorno y ajustar la barra sin cortar el español", () => {
const css = readFileSync(`${process.cwd()}/src/call-history.css`, "utf8");
expect(css).toMatch(/\.call-history-back\.r6-button\s*\{[^}]*width:\s*auto/s);
expect(css).toMatch(/\.call-history-actions\s*\{[^}]*flex-wrap:\s*wrap/s);
});
import { CallHistory } from "./CallHistory";
import type {
CallEvent,
CallHistoryPage,
ResolvedCallReference,
} from "./call-history-client";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
const { getHistory, clearEvents, clearAllEvents, getReferences } = vi.hoisted(
() => ({
@@ -138,6 +147,7 @@ beforeEach(() => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
window.localStorage.removeItem(LANGUAGE_STORAGE_KEY);
if (originalShowModal)
Object.defineProperty(
HTMLDialogElement.prototype,
@@ -147,6 +157,68 @@ afterEach(() => {
else Reflect.deleteProperty(HTMLDialogElement.prototype, "showModal");
});
it("traduce el historial en vivo sin cambiar nombres ni contenido importado", async () => {
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
render(
<LanguageProvider>
<CallHistory onBack={vi.fn()} />
</LanguageProvider>,
);
const first = await loaded();
expect(
screen.getByRole("heading", { name: "Historial de uso" }),
).toBeTruthy();
expect(first.textContent).toBe("Cursor encontró 3 contenidos relacionados");
fireEvent.click(first);
expect(
screen.getByRole("heading", { name: "Esta consulta de memorias" }),
).toBeTruthy();
expect(screen.getAllByText("Consulta completada")).toHaveLength(2);
expect(screen.getByText("Consulta de memorias")).toBeTruthy();
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
fireEvent(window, new StorageEvent("storage", { key: LANGUAGE_STORAGE_KEY }));
expect(screen.getByRole("heading", { name: "这次查记忆" })).toBeTruthy();
window.localStorage.removeItem(LANGUAGE_STORAGE_KEY);
});
it.each([0, 1, 3])(
"muestra %i registros con confirmación española y conserva títulos opacos",
async (count) => {
getHistory.mockResolvedValue(
page(
Array.from({ length: count }, (_, index) =>
event(`event-${index}`, {
host_id: "Aplicación 中文",
references: [],
}),
),
),
);
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
render(
<LanguageProvider>
<CallHistory onBack={vi.fn()} />
</LanguageProvider>,
);
if (!count) {
expect(
await screen.findByText("Todavía no hay registros de uso"),
).toBeTruthy();
return;
}
await loaded("event-0");
expect(screen.getAllByText(/Aplicación 中文 encontró/)).toHaveLength(count);
fireEvent.click(
screen.getByRole("button", { name: "Borrar registros de esta página" }),
);
expect(screen.getByRole("dialog").textContent).toContain(
count === 1
? "¿Borrar el 1 registro de esta página?"
: "¿Borrar los 3 registros de esta página?",
);
},
);
it("loads a bounded first page and returns to applications", async () => {
const pending = deferred<CallHistoryPage>();
getHistory.mockReturnValueOnce(pending.promise);
@@ -354,7 +426,7 @@ it("does not claim delivery for a call Core never completed", async () => {
const detail = screen.getByRole("article", { name: "调用详情" });
expect(within(detail).queryByText(/已返回给/)).toBeNull();
expect(
within(detail).getByText("One Memory 暂时不可用,这次回答没有带上记忆。"),
within(detail).getByText("MemHub 暂时不可用,这次回答没有带上记忆。"),
).toBeTruthy();
});
it("does not restate an undelivered result as returned", async () => {
@@ -414,7 +486,7 @@ it.each([
"查询超时,没有提供记忆",
"直接调用(诊断) 这次没来得及查到记忆",
],
["unavailable", "One Memory 暂时不可用", "直接调用(诊断) 暂时无法查记忆"],
["unavailable", "MemHub 暂时不可用", "直接调用(诊断) 暂时无法查记忆"],
["denied", "当前未授权查询", "直接调用(诊断) 未获准查记忆"],
] as const)(
"renders the %s state without claiming external adoption",
@@ -471,7 +543,7 @@ it("does not call an unavailable call returned", async () => {
const detail = screen.getByRole("article", { name: "调用详情" });
expect(within(detail).queryByText(/· 已返回/)).toBeNull();
expect(
within(detail).getAllByText(/One Memory 暂时不可用/).length,
within(detail).getAllByText(/MemHub 暂时不可用/).length,
).toBeGreaterThan(0);
expect(within(detail).queryByText(/采用未确认|Core/)).toBeNull();
});
+127 -100
View File
@@ -1,3 +1,4 @@
import type { GuiLocale, MessageKey } from "@one-memory/i18n";
import {
ArrowLeft,
ChevronLeft,
@@ -9,7 +10,6 @@ import { useEffect, useId, useRef, useState } from "react";
import { getConversation } from "./api";
import {
CALL_HOSTS,
CALL_TOOLS,
type CallEvent,
type CallHistoryPage,
callDeliveryLabel,
@@ -24,27 +24,24 @@ import {
type ResolvedCallReference,
} from "./call-history-client";
import { clockStamp } from "./clock-stamp";
import { useGuiLanguage } from "./gui-language";
import { R6Button } from "./R6Controls";
import { traceUiAction } from "./ui-trace";
import { isDefiniteRefusal } from "./write-outcome";
import "./call-history.css";
const PAGE_SIZE = 20;
const stateLabels: Record<CallEvent["state"], string> = {
grounded: "查询成功",
empty: "查询成功,没有找到相关记忆",
partial: "查询成功,部分内容还在整理中",
timed_out: "查询超时,没有提供记忆",
unavailable: "One Memory 暂时不可用",
denied: "当前未授权查询",
};
const stateLabels = {
grounded: "calls.state.grounded",
empty: "calls.state.empty",
partial: "calls.state.partial",
timed_out: "calls.state.timed_out",
unavailable: "calls.state.unavailable",
denied: "calls.state.denied",
} as const satisfies Record<CallEvent["state"], MessageKey>;
function toolName(tool: CallEvent["tool"]) {
return CALL_TOOLS[tool];
}
function sourceLabel(id: string | null) {
return id === null ? "未记录" : (CALL_HOSTS[id] ?? id);
function sourceLabel(id: string | null, notRecorded: string) {
return id === null ? notRecorded : (CALL_HOSTS[id] ?? id);
}
function CallReferences({
@@ -56,6 +53,7 @@ function CallReferences({
onOpenMemory?(id: string): void;
onOpenEvidence?(id: string): void;
}) {
const { t } = useGuiLanguage();
const [result, setResult] = useState<{
event: CallEvent;
items: ResolvedCallReference[];
@@ -101,29 +99,31 @@ function CallReferences({
return (
<section
className="call-history-references"
aria-label="相关内容"
aria-label={t("calls.references")}
aria-busy={loading}
>
<h3>相关内容</h3>
{loading && <p role="status">正在读取相关内容</p>}
{failed && <p role="alert">相关内容暂时无法读取,请刷新重试。</p>}
<h3>{t("calls.references")}</h3>
{loading && <p role="status">{t("calls.referencesLoading")}</p>}
{failed && <p role="alert">{t("calls.referencesFailed")}</p>}
{event.references.length ? (
<ul>
{Array.from(references, ([key, { reference, current }]) => (
<li key={key}>
<div>
<h4>
{reference.kind === "raw" ? "原文" : "记忆"} ·{" "}
{t(reference.kind === "raw" ? "calls.raw" : "calls.memory")} ·{" "}
{current?.available && current.reason === null
? current.title
: current?.reason === "changed"
? "版本已变更"
? t("calls.changed")
: loading
? "待核对"
: "不可用"}
? t("calls.pending")
: t("calls.notAvailable")}
</h4>
{reference.source_id && (
<p>{sourceLabel(reference.source_id)}</p>
<p>
{sourceLabel(reference.source_id, t("calls.notRecorded"))}
</p>
)}
</div>
{current?.available && current.reason === null && (
@@ -160,23 +160,29 @@ function CallReferences({
});
}}
>
{reference.kind === "raw" ? "查看原文" : "查看记忆"}
{t(
reference.kind === "raw"
? "calls.openRaw"
: "calls.openMemory",
)}
</R6Button>
)}
</li>
))}
</ul>
) : (
<p>没有找到相关内容</p>
<p>{t("calls.noReferences")}</p>
)}
{transcript && (
<aside
aria-label="原文正文"
aria-label={t("calls.rawBody")}
aria-busy={transcript.status === "loading"}
>
{transcript.status === "loading" && <p role="status">正在读取原文</p>}
{transcript.status === "loading" && (
<p role="status">{t("calls.rawLoading")}</p>
)}
{transcript.status === "failed" && (
<p role="alert">原文已不可用或版本已变更。</p>
<p role="alert">{t("calls.rawFailed")}</p>
)}
{transcript.status === "ready" &&
transcript.messages.map((message) => (
@@ -188,8 +194,8 @@ function CallReferences({
);
}
function shortTime(value: string) {
return clockStamp(value);
function shortTime(value: string, locale: GuiLocale) {
return clockStamp(value, locale);
}
function ClearConfirmation({
@@ -207,6 +213,7 @@ function ClearConfirmation({
onCancel(): void;
onConfirm(): void;
}) {
const { t } = useGuiLanguage();
const dialog = useRef<HTMLDialogElement>(null);
const titleId = useId();
const descriptionId = useId();
@@ -233,7 +240,7 @@ function ClearConfirmation({
<p id={descriptionId}>{description}</p>
<div className="call-history-actions">
<R6Button disabled={pending} onClick={onCancel}>
取消
{t("calls.cancel")}
</R6Button>
<R6Button variant="primary" pending={pending} onClick={onConfirm}>
{confirmLabel}
@@ -252,6 +259,7 @@ export function CallHistory({
onOpenMemory?(id: string): void;
onOpenEvidence?(id: string): void;
}) {
const { t, tnPlain, locale } = useGuiLanguage();
const [request, setRequest] = useState({
hostId: "",
offset: 0,
@@ -267,7 +275,7 @@ export function CallHistory({
{ kind: "page"; ids: string[] } | { kind: "all" } | null
>(null);
const [clearing, setClearing] = useState(false);
const [clearNotice, setClearNotice] = useState("");
const [clearNotice, setClearNotice] = useState<MessageKey | null>(null);
const mounted = useRef(false);
const clearFlight = useRef(false);
const heading = useRef<HTMLHeadingElement>(null);
@@ -334,14 +342,14 @@ export function CallHistory({
const clearingAll = confirmation.kind === "all";
clearFlight.current = true;
setClearing(true);
setClearNotice("");
setClearNotice(null);
const trace = traceUiAction("calls", "confirm");
try {
if (clearingAll) await clearAllCallEvents(trace.traceId);
else await clearCallEvents([...confirmation.ids], trace.traceId);
trace.done("succeeded");
if (!mounted.current) return;
setClearNotice(clearingAll ? "已清空全部记录。" : "已清除所选记录。");
setClearNotice(clearingAll ? "calls.clearedAll" : "calls.clearedPage");
} catch (reason) {
// A 4xx (e.g. `409 call_history_disabled`) is Core answering and
// refusing: nothing was deleted, so it is a definite failure, not an
@@ -361,9 +369,9 @@ export function CallHistory({
setClearNotice(
refused
? clearingAll
? "未能清空使用记录,当前记录保持不变。请核对后重试。"
: "未能清除所选记录,当前记录保持不变。请核对后重试。"
: "清除结果未确认,请核对重新读取的记录。未自动重试清除。",
? "calls.clearAllFailed"
: "calls.clearPageFailed"
: "calls.clearUnknown",
);
} finally {
clearFlight.current = false;
@@ -384,7 +392,7 @@ export function CallHistory({
<section className="call-history" aria-labelledby={titleId}>
<header className="call-history-heading">
<h1 id={titleId} ref={heading} tabIndex={-1}>
{selectedId === null ? "使用记录" : "这次查记忆"}
{t(selectedId === null ? "calls.title" : "calls.query")}
</h1>
<R6Button
className="call-history-back"
@@ -392,7 +400,7 @@ export function CallHistory({
onClick={selectedId === null ? onBack : () => setSelectedId(null)}
>
<ArrowLeft size={16} aria-hidden="true" />
{selectedId === null ? "返回应用" : "返回记录"}
{t(selectedId === null ? "calls.backApps" : "calls.backHistory")}
</R6Button>
</header>
<div
@@ -401,7 +409,7 @@ export function CallHistory({
{selectedId === null ? (
<div className="call-history-filter">
<label className="call-history-sr-only" htmlFor={filterId}>
按应用筛选
{t("calls.filter")}
</label>
<select
id={filterId}
@@ -416,7 +424,7 @@ export function CallHistory({
}));
}}
>
<option value="">全部应用</option>
<option value="">{t("calls.allApps")}</option>
{Object.entries(CALL_HOSTS).map(([host, label]) => (
<option key={host} value={host}>
{label}
@@ -428,12 +436,12 @@ export function CallHistory({
<span />
)}
<div className="call-history-actions">
{selectedId === null && <span>最近记录</span>}
{selectedId === null && <span>{t("calls.recent")}</span>}
<R6Button
className="call-history-icon"
variant="quiet"
title="刷新记录"
aria-label="刷新记录"
title={t("calls.refresh")}
aria-label={t("calls.refresh")}
disabled={clearing}
onClick={refresh}
>
@@ -444,8 +452,8 @@ export function CallHistory({
<R6Button
className="call-history-icon"
variant="quiet"
title="清除本页记录"
aria-label="清除本页记录"
title={t("calls.clearPage")}
aria-label={t("calls.clearPage")}
disabled={!events.length || clearing}
onClick={() =>
setConfirmation({
@@ -458,12 +466,12 @@ export function CallHistory({
</R6Button>
<R6Button
variant="quiet"
title="清空全部记录"
aria-label="清空全部记录"
title={t("calls.clearAll")}
aria-label={t("calls.clearAll")}
disabled={!page?.total || clearing}
onClick={() => setConfirmation({ kind: "all" })}
>
清空全部
{t("calls.clearAllShort")}
</R6Button>
</>
)}
@@ -471,55 +479,67 @@ export function CallHistory({
</div>
{clearNotice && (
<p className="call-history-notice" role="status">
{clearNotice}
{t(clearNotice)}
</p>
)}
<div aria-busy={loading || clearing}>
{loading && (
<p className="call-history-state" role="status">
正在读取使用记录
{t("calls.loading")}
</p>
)}
{clearing && (
<p className="call-history-state" role="status">
正在清除所选记录
{t("calls.clearing")}
</p>
)}
{failed && (
<p className="call-history-state" role="alert">
记录读取失败,请刷新重试。
{t("calls.readFailed")}
</p>
)}
{page && !page.available && (
<p className="call-history-state" role="alert">
使用记录暂不可用,请稍后刷新。
{t("calls.unavailable")}
</p>
)}
{page?.recording_error && (
<p className="call-history-notice" role="alert">
有几次调用没被记下来,列表可能不全。
{t("calls.incomplete")}
</p>
)}
{page?.available &&
(selectedId !== null ? (
selected ? (
<article className="call-history-detail" aria-label="调用详情">
<h2>{`${sourceLabel(selected.host_id)} · ${shortTime(selected.occurred_at)}`}</h2>
<article
className="call-history-detail"
aria-label={t("calls.callDetail")}
>
<h2>{`${sourceLabel(selected.host_id, t("calls.notRecorded"))} · ${shortTime(selected.occurred_at, locale)}`}</h2>
<p className="call-history-summary">
{callDetailHeadline(selected)}
{returnedFrom ? ` · 来源 ${sourceLabel(returnedFrom)}` : ""}
{callDetailHeadline(selected, undefined, locale)}
{returnedFrom
? t("calls.source", {
source: sourceLabel(
returnedFrom,
t("calls.notRecorded"),
),
})
: ""}
</p>
<p className="call-history-stage">
{stateLabels[selected.state]}
{t(stateLabels[selected.state])}
</p>
<p className="call-history-stage">
找到原文 {selected.raw_count} 段 · 记忆{" "}
{selected.memory_count} 条 · 用时{" "}
{(selected.elapsed_ms / 1000).toFixed(1)} 秒
{t("calls.stats", {
raw: selected.raw_count,
memories: selected.memory_count,
seconds: (selected.elapsed_ms / 1000).toFixed(1),
})}
</p>
{callDeliveryLabel(selected) && (
{callDeliveryLabel(selected, locale) && (
<p className="call-history-stage">
{callDeliveryLabel(selected)}
{callDeliveryLabel(selected, locale)}
</p>
)}
<CallReferences
@@ -527,22 +547,20 @@ export function CallHistory({
onOpenMemory={onOpenMemory}
onOpenEvidence={onOpenEvidence}
/>
{callReturnDeliveryLabel(selected) && (
{callReturnDeliveryLabel(selected, locale) && (
<p className="call-history-adoption">
{callReturnDeliveryLabel(selected)}
{callReturnDeliveryLabel(selected, locale)}
</p>
)}
<section
className="call-history-metadata"
aria-label="记录详情"
aria-label={t("calls.metadata")}
>
<h3>记录详情</h3>
<p>
为保护隐私,这里不保存你问的原话和回答,只保存时间、来源和结果。
</p>
<h3>{t("calls.metadata")}</h3>
<p>{t("calls.privacy")}</p>
<dl>
<div>
<dt>准确时间</dt>
<dt>{t("calls.exactTime")}</dt>
<dd>
<time dateTime={selected.occurred_at}>
{selected.occurred_at}
@@ -550,27 +568,29 @@ export function CallHistory({
</dd>
</div>
<div>
<dt>应用</dt>
<dd>{sourceLabel(selected.host_id)}</dd>
<dt>{t("calls.app")}</dt>
<dd>
{sourceLabel(selected.host_id, t("calls.notRecorded"))}
</dd>
</div>
<div>
<dt>类型</dt>
<dd>{toolName(selected.tool)}</dd>
<dt>{t("calls.type")}</dt>
<dd>{t("calls.tool")}</dd>
</div>
<div>
<dt>结果状态</dt>
<dt>{t("calls.result")}</dt>
{/* The tool above is already humanized in this same
panel ("查记忆", never `om_recall`); printing the raw
`grounded`/`partial` enum here leaks the internal state
vocabulary the rest of the page translates. */}
<dd>{stateLabels[selected.state]}</dd>
<dd>{t(stateLabels[selected.state])}</dd>
</div>
</dl>
</section>
</article>
) : (
<p className="call-history-state" role="status">
这条记录已不在当前页,请返回记录查看。
{t("calls.missingRow")}
</p>
)
) : (
@@ -578,12 +598,12 @@ export function CallHistory({
<ul
ref={list}
className="call-history-list"
aria-label="调用记录"
aria-label={t("calls.list")}
>
{events.map((item) => (
<li key={item.event_id}>
<time dateTime={item.occurred_at} title={item.occurred_at}>
{shortTime(item.occurred_at)}
{shortTime(item.occurred_at, locale)}
</time>
<div className="call-history-row-content">
<h2>
@@ -592,23 +612,26 @@ export function CallHistory({
data-event-id={item.event_id}
onClick={() => setSelectedId(item.event_id)}
>
{callRowHeadline(item)}
{callRowHeadline(item, locale)}
</button>
</h2>
<p>{callRowDetail(item)}</p>
<p>{callRowDetail(item, locale)}</p>
</div>
</li>
))}
</ul>
{!events.length && (
<p className="call-history-state" role="status">
{request.offset ? "本页暂无记录" : "暂无使用记录"}
{t(request.offset ? "calls.emptyPage" : "calls.empty")}
</p>
)}
<nav className="call-history-pagination" aria-label="记录分页">
<nav
className="call-history-pagination"
aria-label={t("calls.pagination")}
>
<R6Button
aria-label="上一页"
title="上一页"
aria-label={t("calls.previous")}
title={t("calls.previous")}
disabled={request.offset === 0}
onClick={() =>
setRequest((current) => ({
@@ -626,8 +649,8 @@ export function CallHistory({
/ {page.total}
</span>
<R6Button
aria-label="下一页"
title="下一页"
aria-label={t("calls.next")}
title={t("calls.next")}
disabled={
page.offset + PAGE_SIZE >= page.total || !events.length
}
@@ -648,25 +671,29 @@ export function CallHistory({
page.retention_days !== null &&
page.max_events !== null && (
<p className="call-history-policy">
保留 {page.retention_days} 天,最多{" "}
{new Intl.NumberFormat("zh-CN").format(page.max_events)} 条记录
{t("calls.policy", {
days: page.retention_days,
count: new Intl.NumberFormat(locale).format(page.max_events),
})}
</p>
)}
{confirmation && (
<ClearConfirmation
title={
confirmation.kind === "all"
? "清空全部使用记录?"
: `清除本页 ${confirmation.ids.length} 条记录?`
? t("calls.confirmAllTitle")
: tnPlain("calls.confirmPageTitle", confirmation.ids.length)
}
description={
confirmation.kind === "all"
? "将删除当前保留的全部调用记录,不限于本页或当前筛选。不删除原文或记忆,不更改授权与留存规则。此操作无法撤销。"
: "仅清除这批调用记录,不删除原文或记忆,不更改授权与留存规则。此操作无法撤销。"
}
confirmLabel={
confirmation.kind === "all" ? "确认清空全部" : "确认清除"
? t("calls.confirmAllDescription")
: t("calls.confirmPageDescription")
}
confirmLabel={t(
confirmation.kind === "all"
? "calls.confirmAll"
: "calls.confirmPage",
)}
pending={clearing}
onCancel={() => setConfirmation(null)}
onConfirm={() => void confirmClear()}
@@ -3,12 +3,50 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ConnectionFallback } from "./ConnectionFallback";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
afterEach(() => {
cleanup();
delete window.oneMemory;
localStorage.clear();
});
describe("ConnectionFallback", () => {
it("localizes pairing controls while preserving generated Skill bytes", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
const skill = "pair --token '{no-traducir}'";
window.oneMemory = {
createCustomHarnessSkill: vi.fn().mockResolvedValue({
displayName: "Harness 原名",
expiresAt: "2026-10-19T12:10:00.000Z",
skill,
}),
copyText: vi.fn().mockResolvedValue(undefined),
getLanguagePreference: vi
.fn()
.mockResolvedValue({ preference: "es", locale: "es", revision: 1 }),
onLanguagePreferenceChanged: vi.fn().mockReturnValue(() => {}),
} as unknown as Window["oneMemory"];
render(
<LanguageProvider>
<ConnectionFallback />
</LanguageProvider>,
);
const user = userEvent.setup();
await user.click(await screen.findByText("Conectar otras herramientas"));
await user.type(
screen.getByLabelText("Nombre del Harness"),
"Harness 原名",
);
await user.click(
screen.getByRole("button", {
name: "Generar Skill de emparejamiento",
}),
);
expect(
await screen.findByLabelText("Skill de emparejamiento"),
).toHaveProperty("value", skill);
});
it("names an unknown Harness before generating and copying its pairing Skill", async () => {
const user = userEvent.setup();
const createCustomHarnessSkill = vi.fn().mockResolvedValue({
+181 -19
View File
@@ -1,16 +1,29 @@
import { Copy, RefreshCw } from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { copyText } from "./copy-text";
import { useGuiLanguage } from "./gui-language";
type Material = { displayName: string; expiresAt: string; skill: string };
type ConnectionMaterial = {
mcp: string;
shell: string | null;
prompt: string;
};
type MaterialBridge = {
getConnectionMaterial(): Promise<ConnectionMaterial>;
};
export function ConnectionFallback() {
const { locale, t } = useGuiLanguage();
const [displayName, setDisplayName] = useState("");
const [material, setMaterial] = useState<Material | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [notice, setNotice] = useState("");
const available = !!window.oneMemory?.createCustomHarnessSkill;
const materialBridge = window.oneMemory as
| (Window["oneMemory"] & Partial<MaterialBridge>)
| undefined;
async function generate() {
const bridge = window.oneMemory;
@@ -30,21 +43,24 @@ export function ConnectionFallback() {
return (
<details className="connection-fallback">
<summary>连接其他工具</summary>
{!available ? (
<p>本机配对仅在桌面 App 中可用。</p>
<summary>{t("connectionFallback.title")}</summary>
{materialBridge?.getConnectionMaterial ? (
<ConnectionMaterialFallback
bridge={materialBridge as Window["oneMemory"] & MaterialBridge}
/>
) : !available ? (
<p>{t("connectionFallback.desktopOnly")}</p>
) : (
<>
<p className="connection-boundary">
为未列出的本机 Harness 生成一次性配对
Skill。纯网页聊天、远端容器或另一台机器不能直接连接本机。
{t("connectionFallback.description")}
</p>
<label className="custom-harness-name">
Harness 名称
{t("connectionFallback.name")}
<input
value={displayName}
maxLength={80}
placeholder="例如 Kimi Code"
placeholder={t("connectionFallback.namePlaceholder")}
onChange={(event) => {
setDisplayName(event.target.value);
setMaterial(null);
@@ -58,29 +74,37 @@ export function ConnectionFallback() {
disabled={loading || !displayName.trim()}
onClick={() => void generate()}
>
{loading ? "正在生成…" : "生成配对 Skill"}
{loading
? t("connectionFallback.generating")
: t("connectionFallback.generate")}
</button>
{error && (
<p role="alert">
未能生成配对 Skill。
{t("connectionFallback.generateFailed")}
<button
type="button"
className="quiet"
onClick={() => void generate()}
>
<RefreshCw size={15} aria-hidden="true" />
重试
{t("connectionFallback.retry")}
</button>
</p>
)}
{material && (
<>
<p>
已为 {material.displayName} 生成一次性配对 Skill,有效期至{" "}
{new Date(material.expiresAt).toLocaleTimeString()}。
{t("connectionFallback.generated", {
name: material.displayName,
time: new Intl.DateTimeFormat(locale, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(new Date(material.expiresAt)),
})}
</p>
<textarea
aria-label="配对 Skill"
aria-label={t("connectionFallback.skillLabel")}
readOnly
value={material.skill}
spellCheck={false}
@@ -91,16 +115,14 @@ export function ConnectionFallback() {
onClick={async () => {
try {
await copyText(material.skill);
setNotice(
"已复制配对 Skill。请粘贴到对应 Harness 中执行。",
);
setNotice(t("connectionFallback.copied"));
} catch {
setNotice("复制失败,可在上方选中文本复制。");
setNotice(t("connectionFallback.copyFailed"));
}
}}
>
<Copy size={15} aria-hidden="true" />
复制 Skill
{t("connectionFallback.copy")}
</button>
</>
)}
@@ -110,3 +132,143 @@ export function ConnectionFallback() {
</details>
);
}
function ConnectionMaterialFallback({
bridge,
}: {
bridge: Window["oneMemory"] & MaterialBridge;
}) {
const { locale, t } = useGuiLanguage();
const [material, setMaterial] = useState<ConnectionMaterial | null>(null);
const [tab, setTab] = useState<"prompt" | "mcp" | "shell">("prompt");
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [notice, setNotice] = useState("");
async function load() {
setLoading(true);
setError(false);
try {
setMaterial(await bridge.getConnectionMaterial());
} catch {
setError(true);
} finally {
setLoading(false);
}
}
useEffect(() => {
let current = true;
void bridge.getConnectionMaterial().then(
(value) => {
if (!current) return;
setMaterial(value);
setLoading(false);
},
() => {
if (!current) return;
setError(true);
setLoading(false);
},
);
// The material is a snapshot. Language changes re-render its presentation
// without reading paths or snippets again.
return () => {
current = false;
};
}, [bridge]);
if (loading && !material)
return <p role="status">{t("connectionFallback.loading")}</p>;
if (error && !material)
return (
<p role="alert">
{t("connectionFallback.loadFailed")}
<button type="button" className="quiet" onClick={() => void load()}>
<RefreshCw size={15} aria-hidden="true" />
{t("connectionFallback.retry")}
</button>
</p>
);
if (!material) return null;
const shellBlock = material.shell
? t("connectionFallback.promptShell", { shell: material.shell })
: "";
const prompt =
locale === "zh-CN"
? material.prompt
: t("connectionFallback.promptTemplate", {
mcp: material.mcp,
shellBlock,
});
const value =
tab === "prompt"
? prompt
: tab === "mcp"
? material.mcp
: (material.shell ?? "");
const copyLabel =
tab === "prompt"
? t("connectionFallback.copyPrompt")
: tab === "mcp"
? t("connectionFallback.copyConfig")
: t("connectionFallback.copyCommand");
return (
<>
<p className="connection-boundary">
{t("connectionFallback.localDescription")}
</p>
<fieldset>
<legend>{t("connectionFallback.format")}</legend>
<div role="tablist">
{(
[
["prompt", t("connectionFallback.promptTab")],
["mcp", t("connectionFallback.mcpTab")],
["shell", t("connectionFallback.commandTab")],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
role="tab"
aria-selected={tab === value}
disabled={value === "shell" && !material.shell}
onClick={() => {
setTab(value);
setNotice("");
}}
>
{label}
</button>
))}
</div>
</fieldset>
<textarea
aria-label={t("connectionFallback.materialLabel")}
readOnly
value={value}
spellCheck={false}
/>
<p>{t("connectionFallback.materialBoundary")}</p>
<button
type="button"
className="secondary"
onClick={async () => {
try {
await copyText(value);
setNotice(t("connectionFallback.materialCopied"));
} catch {
setNotice(t("connectionFallback.copyFailed"));
}
}}
>
<Copy size={15} aria-hidden="true" />
{copyLabel}
</button>
{notice && <p role="status">{notice}</p>}
</>
);
}
+103
View File
@@ -11,6 +11,11 @@ import {
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { DebugLogPanel } from "./DebugLogPanel";
import {
LANGUAGE_STORAGE_KEY,
LanguageProvider,
useGuiLanguage,
} from "./gui-language";
import {
recordRendererDebugLog,
resetRendererDebugLogsForTest,
@@ -87,6 +92,7 @@ const snapshot = {
};
beforeEach(() => {
window.localStorage.clear();
resetRendererDebugLogsForTest();
diagnosticLevel.get.mockClear();
diagnosticLevel.set.mockClear();
@@ -102,6 +108,55 @@ afterEach(() => {
vi.useRealTimers();
});
function SwitchLanguage() {
const { setPreference } = useGuiLanguage();
return (
<button type="button" onClick={() => void setPreference("zh-CN")}>
Cambiar idioma
</button>
);
}
it("translates controls live but preserves events and does not change capture", async () => {
// A language bridge is not required by this synthetic browser fixture.
const read = vi.fn().mockResolvedValue(snapshot);
window.oneMemory = undefined;
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
const view = render(
<LanguageProvider>
<SwitchLanguage />
<DebugLogPanel />
</LanguageProvider>,
);
window.oneMemory = { getDebugLogs: read } as unknown as NonNullable<
typeof window.oneMemory
>;
view.rerender(
<LanguageProvider>
<SwitchLanguage />
<DebugLogPanel />
</LanguageProvider>,
);
await userEvent.click(
await screen.findByRole("button", {
name: "Abrir registros de depuración",
}),
);
await screen.findByText(/source\.scanned/);
expect(
screen.getByRole("searchbox", { name: "Buscar registros" }),
).toBeTruthy();
await userEvent.click(
screen.getByRole("button", { name: "Pausar actualización en vivo" }),
);
const count = read.mock.calls.length;
await userEvent.click(screen.getByRole("button", { name: "Cambiar idioma" }));
await screen.findByRole("searchbox", { name: "搜索日志" });
expect(screen.getByText(/source\.scanned/)).toBeTruthy();
expect(read).toHaveBeenCalledTimes(count);
expect(diagnosticLevel.set).not.toHaveBeenCalled();
});
it("opens from the floating button and filters by text, level and source", async () => {
render(<DebugLogPanel />);
await userEvent.click(
@@ -255,3 +310,51 @@ it("shows per-minute model usage in a separate tab", async () => {
}),
).toBeTruthy();
});
it("shows the diagnostics tabs and model usage in Spanish without Chinese copy", async () => {
// Browser-mode language, then the desktop log bridge, as in the test above.
const bridge = window.oneMemory;
window.oneMemory = undefined;
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
const view = render(
<LanguageProvider>
<DebugLogPanel />
</LanguageProvider>,
);
window.oneMemory = bridge;
view.rerender(
<LanguageProvider>
<DebugLogPanel />
</LanguageProvider>,
);
await userEvent.click(
await screen.findByRole("button", {
name: "Abrir registros de depuración",
}),
);
const panel = await screen.findByRole("complementary", {
name: "Registros de depuración",
});
expect(within(panel).getByText("Diagnóstico")).toBeTruthy();
expect(
within(panel).getByRole("button", {
name: "Mostrar los registros en pantalla completa",
}),
).toBeTruthy();
await userEvent.click(
within(panel).getByRole("tab", { name: "Uso del modelo" }),
);
await waitFor(() => expect(diagnosticLevel.usage).toHaveBeenCalled());
expect(within(panel).getByText("Tokens totales")).toBeTruthy();
expect(
within(panel).getByText("Uso del modelo que genera memorias"),
).toBeTruthy();
expect(
within(panel).getByRole("img", {
name: "Tokens consumidos por minuto de cada prompt",
}),
).toBeTruthy();
expect(panel.textContent).not.toMatch(/[一-鿿]/);
});
+46 -34
View File
@@ -28,6 +28,7 @@ import type {
DebugLogLevel,
DebugLogSnapshot,
} from "./debug-log-types";
import { useGuiLanguage } from "./gui-language";
import { ModelUsageView } from "./ModelUsageView";
import {
rendererDebugLogs,
@@ -53,6 +54,7 @@ const SERVICE_LABELS: Record<DebugLogEntry["service"], string> = {
};
export function DebugLogPanel() {
const { t, tnPlain, locale } = useGuiLanguage();
const supported = typeof window.oneMemory?.getDebugLogs === "function";
const [open, setOpen] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
@@ -171,23 +173,23 @@ export function DebugLogPanel() {
{open ? (
<aside
className={`debug-log-panel${fullscreen ? " is-fullscreen" : ""}`}
aria-label="Debug 日志"
aria-label={t("debugTitle")}
>
<header className="debug-log-header">
<div>
<strong>诊断</strong>
<strong>{t("debugDiagnostics")}</strong>
<span>
{tab === "logs"
? `${entries.length} 条日志,本次启动`
: "记忆沉淀模型用量"}
? tnPlain("debugCount", entries.length)
: t("debugUsageSubtitle")}
</span>
</div>
<div className="debug-log-header-actions">
<button
type="button"
className="debug-icon-button"
title={live ? "暂停实时刷新" : "继续实时刷新"}
aria-label={live ? "暂停实时刷新" : "继续实时刷新"}
title={live ? t("debugPause") : t("debugResume")}
aria-label={live ? t("debugPause") : t("debugResume")}
onClick={() => setLive((value) => !value)}
>
{live ? <CirclePause size={17} /> : <CirclePlay size={17} />}
@@ -195,8 +197,8 @@ export function DebugLogPanel() {
<button
type="button"
className="debug-icon-button"
title="立即刷新"
aria-label="立即刷新"
title={t("debugRefresh")}
aria-label={t("debugRefresh")}
onClick={() => {
if (tab === "logs") void refresh();
else setUsageRefreshToken((value) => value + 1);
@@ -210,8 +212,14 @@ export function DebugLogPanel() {
<button
type="button"
className="debug-icon-button"
title={fullscreen ? "退出全屏" : "全屏显示"}
aria-label={fullscreen ? "退出全屏显示日志" : "全屏显示日志"}
title={t(
fullscreen ? "debugExitFullscreen" : "debugFullscreen",
)}
aria-label={t(
fullscreen
? "debugExitFullscreenLabel"
: "debugFullscreenLabel",
)}
onClick={() => setFullscreen((value) => !value)}
>
{fullscreen ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
@@ -219,8 +227,8 @@ export function DebugLogPanel() {
<button
type="button"
className="debug-icon-button"
title="关闭 Debug 日志"
aria-label="关闭 Debug 日志"
title={t("debugClose")}
aria-label={t("debugClose")}
onClick={() => {
setFullscreen(false);
setOpen(false);
@@ -233,7 +241,7 @@ export function DebugLogPanel() {
<div
className="debug-panel-tabs"
role="tablist"
aria-label="诊断页面"
aria-label={t("debugTabs")}
>
<button
type="button"
@@ -241,7 +249,7 @@ export function DebugLogPanel() {
aria-selected={tab === "logs"}
onClick={() => setTab("logs")}
>
日志
{t("debugLogsTab")}
</button>
<button
type="button"
@@ -249,7 +257,7 @@ export function DebugLogPanel() {
aria-selected={tab === "usage"}
onClick={() => setTab("usage")}
>
模型用量
{t("debugUsageTab")}
</button>
</div>
{tab === "logs" ? (
@@ -261,18 +269,18 @@ export function DebugLogPanel() {
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索事件、组件、错误码或内容"
aria-label="搜索日志"
placeholder={t("debugSearchPlaceholder")}
aria-label={t("debugSearch")}
/>
</label>
<select
aria-label="日志来源"
aria-label={t("debugSource")}
value={service}
onChange={(event) =>
setService(event.target.value as typeof service)
}
>
<option value="all">全部来源</option>
<option value="all">{t("debugAllSources")}</option>
{Object.entries(SERVICE_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
@@ -280,9 +288,9 @@ export function DebugLogPanel() {
))}
</select>
<label className="debug-capture-level">
<span>采集级别</span>
<span>{t("debugCapture")}</span>
<select
aria-label="采集级别"
aria-label={t("debugCapture")}
value={captureLevel}
disabled={capturePending}
onChange={(event) => {
@@ -303,7 +311,7 @@ export function DebugLogPanel() {
</label>
</div>
<fieldset className="debug-levels">
<legend>显示级别</legend>
<legend>{t("debugDisplay")}</legend>
{LEVELS.map((option) => (
<button
type="button"
@@ -311,16 +319,20 @@ export function DebugLogPanel() {
aria-pressed={level === option.value}
onClick={() => setLevel(option.value)}
>
{option.label}
{option.value === "all" ? t("debugAll") : option.label}
</button>
))}
</fieldset>
<div className="debug-log-status" aria-live="polite">
<span>{filtered.length} 条匹配</span>
<span>{live ? "实时刷新" : "已暂停"}</span>
{snapshot?.truncated ? <span>日志过多,已截断</span> : null}
<span>{tnPlain("debugMatches", filtered.length)}</span>
<span>{live ? t("debugLive") : t("debugPaused")}</span>
{snapshot?.truncated ? (
<span>{t("debugTruncated")}</span>
) : null}
{readState === "error" ? (
<span className="debug-read-error">读取失败</span>
<span className="debug-read-error">
{t("debugReadError")}
</span>
) : null}
</div>
<div
@@ -347,7 +359,7 @@ export function DebugLogPanel() {
) : (
<ChevronRight size={14} />
)}
<time>{logTime(entry.timestamp)}</time>
<time>{logTime(entry.timestamp, locale)}</time>
<span className="debug-log-level">{entry.level}</span>
<span className="debug-log-service">
{SERVICE_LABELS[entry.service]}
@@ -369,8 +381,8 @@ export function DebugLogPanel() {
) : (
<p className="debug-log-empty">
{readState === "loading"
? "正在读取日志…"
: "没有匹配的日志"}
? t("debugLoading")
: t("debugEmpty")}
</p>
)}
</div>
@@ -383,8 +395,8 @@ export function DebugLogPanel() {
<button
type="button"
className="debug-fab"
aria-label={open ? "Debug 日志已打开" : "打开 Debug 日志"}
title="打开 Debug 日志"
aria-label={open ? t("debugOpened") : t("debugOpen")}
title={t("debugOpen")}
onClick={() => setOpen(true)}
>
<Bug size={20} aria-hidden="true" />
@@ -400,9 +412,9 @@ function searchableEntry(entry: DebugLogEntry): string {
return JSON.stringify(entry).toLocaleLowerCase();
}
function logTime(timestamp: string): string {
function logTime(timestamp: string, locale: string): string {
const date = new Date(timestamp);
return Number.isNaN(date.getTime())
? timestamp
: date.toLocaleTimeString("zh-CN", { hour12: false });
: date.toLocaleTimeString(locale, { hour12: false });
}
+6 -5
View File
@@ -47,17 +47,18 @@ describe("desktop window frame", () => {
container.querySelectorAll(".onboarding-r6-titlebar"),
).toHaveLength(1);
expect(container.querySelector(".onboarding-r6-traffic")).toBeNull();
// The user removed the mark, the "One Memory" wordmark and the
// "第一次见面" caption on 2026-09-20. The bar stays empty; MemHub is not
// put back into it.
// Branding stays removed; only the explicit GUI language selector
// occupies the native titlebar, outside the draggable area.
expect(screen.queryByText("MemHub")).toBeNull();
expect(screen.queryByText("One Memory")).toBeNull();
expect(screen.queryByText("第一次见面")).toBeNull();
expect(
container.querySelector(".onboarding-r6-titlebar")?.children.length,
).toBe(0);
).toBe(1);
expect(screen.getByRole("combobox", { name: "界面语言" })).toBeTruthy();
const titlebar = declaration(".onboarding-r6-titlebar");
expect(titlebar).toMatch(/flex:\s*0 0 58px/);
expect(titlebar).toMatch(/flex:\s*0 0 auto/);
expect(titlebar).toMatch(/min-height:\s*58px/);
expect(titlebar).toMatch(/-webkit-app-region:\s*drag/);
},
);
+80 -53
View File
@@ -2,6 +2,7 @@ import type { ImportJob, SourceResponse } from "@one-memory/contracts";
import { Pause, Play } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { connectionHostId } from "./connection-hosts";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6ColdStartFrame } from "./R6ColdStartFrame";
import { R6Button } from "./R6Controls";
import { R6ImportProgress } from "./R6ImportProgress";
@@ -87,6 +88,7 @@ export function FirstImportReview({
onConnectionDraftChange,
onContinue,
}: FirstImportReviewProps) {
const { text, t } = useOnboardingTranslation();
const stale = sourceSnapshot?.stale === true;
const ids = [...new Set(sourceIds)];
// Without explicit IDs, capture the initial batch once; later sync/history jobs
@@ -120,7 +122,14 @@ export function FirstImportReview({
);
const progressStale = stale && batch.length === 0;
const [changing, setChanging] = useState(false);
const [actionMessage, setActionMessage] = useState("");
const [actionResult, setActionResult] = useState<{
stopped: boolean;
confirmed: number;
failed: number;
} | null>(null);
const actionMessage = actionResult
? `${actionResult.stopped ? t("onboardingBatchStopped") : ""}${t("onboardingBatchResult", { confirmed: actionResult.confirmed, failed: actionResult.failed })}${actionResult.failed ? text("已确认项保留,请核对未确认任务后重试。") : ""}`
: "";
const batchFlight = useRef(false),
stopBatch = useRef(false),
mounted = useRef(true);
@@ -196,7 +205,7 @@ export function FirstImportReview({
batchFlight.current = true;
stopBatch.current = false;
setChanging(true);
setActionMessage("");
setActionResult(null);
let confirmed = 0,
failed = 0;
try {
@@ -262,9 +271,7 @@ export function FirstImportReview({
}
}
if (mounted.current)
setActionMessage(
`${stopBatch.current ? "已停止后续操作;已发出的操作仍按回执核对。" : ""}已确认 ${confirmed} 项,${failed} 项未确认。${failed ? "已确认项保留,请核对未确认任务后重试。" : ""}`,
);
setActionResult({ stopped: stopBatch.current, confirmed, failed });
} finally {
batchFlight.current = false;
if (mounted.current) setChanging(false);
@@ -274,21 +281,23 @@ export function FirstImportReview({
return (
<R6ColdStartFrame
step={2}
title={title}
eyebrow="导入进行中 · 顺手连接应用"
title={text(title)}
eyebrow={text("导入进行中 · 顺手连接应用")}
footer={
<>
<div className="first-import-r6-footer-note">
<p>
{allCompleted
? "对话已保存,记忆尚待生成。"
: displayImporting
? "导入在后台继续,无需等待。"
: stale
? "正在重新核对来源;已保存数量仍按任务回执显示。"
: allPaused
? "本次历史导入已暂停,已保存的对话还在。"
: "导入等待处理,已保存的对话还在。"}
{text(
allCompleted
? "对话已保存,记忆尚待生成。"
: displayImporting
? "导入在后台继续,无需等待。"
: stale
? "正在重新核对来源;已保存数量仍按任务回执显示。"
: allPaused
? "本次历史导入已暂停,已保存的对话还在。"
: "导入等待处理,已保存的对话还在。",
)}
</p>
{(error || actionMessage) && (
<p
@@ -307,7 +316,7 @@ export function FirstImportReview({
: undefined
}
>
{error || actionMessage}
{error ? text(error) : actionMessage}
</p>
)}
</div>
@@ -318,7 +327,7 @@ export function FirstImportReview({
stopBatch.current = true;
}}
>
停止后续操作
{text("停止后续操作")}
</R6Button>
) : onChangeJob && (canPause || canResume) ? (
<R6Button
@@ -330,7 +339,7 @@ export function FirstImportReview({
) : (
<Play size={16} aria-hidden="true" />
)}
{canPause ? "暂停导入" : "继续导入"}
{text(canPause ? "暂停导入" : "继续导入")}
</R6Button>
) : null}
{recovery ? (
@@ -342,18 +351,20 @@ export function FirstImportReview({
recovery.onSkip();
}}
>
{recovery.destination === "sources"
? "仍进入应用页"
: recovery.destination === "settings"
? "仍进入设置"
: "仍进入工作台"}
{text(
recovery.destination === "sources"
? "仍进入应用页"
: recovery.destination === "settings"
? "仍进入设置"
: "仍进入工作台",
)}
</R6Button>
<R6Button
variant="primary"
disabled={busy}
onClick={() => leave(recovery.destination)}
>
重试保存并进入
{text("重试保存并进入")}
</R6Button>
</>
) : (
@@ -362,7 +373,7 @@ export function FirstImportReview({
disabled={busy}
onClick={() => leave("settings")}
>
配置模型,沉淀记忆
{text("配置模型,沉淀记忆")}
</R6Button>
)}
</div>
@@ -370,19 +381,25 @@ export function FirstImportReview({
}
>
<div className="first-import-r6-layout">
<section className="first-import-r6-main" aria-label="导入与原文">
<section
className="first-import-r6-main"
aria-label={text("导入与原文")}
>
<p className="first-import-r6-intro">
{allCompleted
? "本次导入已结束。已保存的对话无需重复导入。"
: "对话先保存在本机。你可以同时设置后续连接。"}
{text(
allCompleted
? "本次导入已结束。已保存的对话无需重复导入。"
: "对话先保存在本机。你可以同时设置后续连接。",
)}
</p>
<p className="first-import-r6-subintro">
导入不会自动开放应用读取权限。配置模型后,才会开始生成记忆。
{text(
"导入不会自动开放应用读取权限。配置模型后,才会开始生成记忆。",
)}
</p>
{historyWindowLimited && (
<p className="first-import-r6-history-window" role="status">
检测到会话数据量较大。为控制用量并保持各应用时间线一致,本次所有应用仅导入最近
3 天的对话。
{t("onboardingHistoryWindow")}
</p>
)}
<R6ImportProgress
@@ -391,21 +408,24 @@ export function FirstImportReview({
jobs={batch}
stale={progressStale}
/>
<section className="first-import-r6-feed" aria-label="导入说明">
<h2>正在导入你的会话</h2>
<section
className="first-import-r6-feed"
aria-label={text("导入说明")}
>
<h2>{text("正在导入你的会话")}</h2>
<p className="first-import-r6-feed-status">
后台将持续为你导入,可以放心进入下一步
{text("后台将持续为你导入,可以放心进入下一步")}
</p>
{Object.keys(failedSources).length > 0 && (
<section
className="first-import-r6-failures"
aria-label="未开始的导入"
aria-label={text("未开始的导入")}
>
{Object.keys(failedSources).map((id) => (
<p key={id}>
{sources.find((source) => source.source_id === id)
?.display_name ?? id}{" "}
未能开始导入,其他已接受任务不受影响。
{text("未能开始导入,其他已接受任务不受影响。")}
</p>
))}
<R6Button
@@ -413,27 +433,30 @@ export function FirstImportReview({
disabled={busy}
onClick={() => leave("sources")}
>
查看未导入的应用
{text("查看未导入的应用")}
</R6Button>
</section>
)}
</section>
</section>
<aside className="first-import-r6-options" aria-label="连接应用">
<p className="first-import-r6-eyebrow">让记忆继续生长</p>
<h2>连接你的应用</h2>
<aside
className="first-import-r6-options"
aria-label={text("连接应用")}
>
<p className="first-import-r6-eyebrow">{text("让记忆继续生长")}</p>
<h2>{text("连接你的应用")}</h2>
<p className="first-import-r6-options-intro">
适用于本次选择且支持的应用。
{text("适用于本次选择且支持的应用。")}
</p>
<SelectionTile
title={
hostConnectionAvailable ? "一键接入应用" : "测试实例不接入应用"
}
body={
title={text(
hostConnectionAvailable ? "一键接入应用" : "测试实例不接入应用",
)}
body={text(
hostConnectionAvailable
? "安装记忆连接,让所选应用用上 MemHub 中的相关记忆。"
: "当前使用独立测试数据目录,不会修改本机应用的连接配置。"
}
: "当前使用独立测试数据目录,不会修改本机应用的连接配置。",
)}
checked={
hostConnectionAvailable &&
!!hostEligible.length &&
@@ -456,12 +479,16 @@ export function FirstImportReview({
}
/>
{connectionNote && (
<p className="first-import-r6-options-note">{connectionNote}</p>
<p className="first-import-r6-options-note">
{text(connectionNote)}
</p>
)}
<p className="first-import-r6-options-note">
{draftReady
? "点击底部按钮后,后台分别接入各应用;某个应用失败不影响其他应用。"
: "暂时无法连接应用,当前不会安装连接。"}
{text(
draftReady
? "点击底部按钮后,后台分别接入各应用;某个应用失败不影响其他应用。"
: "暂时无法连接应用,当前不会安装连接。",
)}
</p>
</aside>
</div>
+50 -1
View File
@@ -1,9 +1,58 @@
// @vitest-environment jsdom
import type { MemoryDetail, SourceResponse } from "@one-memory/contracts";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { FirstUseGuide, FirstUseInvitation } from "./FirstUseGuide";
import {
LANGUAGE_STORAGE_KEY,
LanguageProvider,
useGuiLanguage,
} from "./gui-language";
function GuideLanguageSwitch() {
const { setPreference } = useGuiLanguage();
return (
<button type="button" onClick={() => void setPreference("zh-CN")}>
Cambiar idioma
</button>
);
}
it("localizes the supported guide without changing original memory or copying", async () => {
window.oneMemory = undefined;
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
render(
<LanguageProvider>
<GuideLanguageSwitch />
<FirstUseGuide sources={[source]} onSources={vi.fn()} onClose={vi.fn()} />
</LanguageProvider>,
);
await screen.findByRole("heading", {
name: "Retoma tu próxima conversación",
});
await screen.findByText(detail.memory.content, { selector: "blockquote" });
expect(
screen.getByRole("button", { name: "Copiar mensaje inicial" }),
).toBeTruthy();
const reads = getMemory.mock.calls.length;
fireEvent.click(screen.getByText("Cambiar idioma"));
await screen.findByRole("heading", { name: "让下一次对话接得上" });
expect(getMemory).toHaveBeenCalledTimes(reads);
expect(
screen.getByText(detail.memory.content, { selector: "blockquote" }),
).toBeTruthy();
expect(copyText).not.toHaveBeenCalled();
expect(openHandoff).not.toHaveBeenCalled();
localStorage.clear();
});
const getMemories = vi.fn(),
getMemory = vi.fn(),
+116 -90
View File
@@ -3,6 +3,7 @@ import type {
MemoryDetail,
SourceResponse,
} from "@one-memory/contracts";
import type { MessageKey } from "@one-memory/i18n";
import {
ArrowLeft,
ArrowRight,
@@ -27,6 +28,7 @@ import {
sameGuideMemory,
} from "./first-use-model";
import type { RecallHostStatus } from "./global";
import { useGuiLanguage } from "./gui-language";
import { refreshRecallHosts, useRecallHosts } from "./host-state";
import { refreshSources } from "./source-state";
@@ -41,6 +43,7 @@ export function FirstUseGuide({
onSources(): void;
onClose(): void;
}) {
const { t, tn, locale } = useGuiLanguage();
const [scene, setScene] = useState<GuideScenario>("same");
const [route, setRoute] = useState<GuideRoute>("context");
const [memories, setMemories] = useState<GovernedMemory[]>([]);
@@ -52,11 +55,12 @@ export function FirstUseGuide({
const hosts = hostsError ? NO_HOSTS : (hostRead.data ?? NO_HOSTS);
const [loading, setLoading] = useState(true);
const [detailLoading, setDetailLoading] = useState(false);
const [error, setError] = useState("");
const [error, setError] = useState<MessageKey | "">("");
const [reload, setReload] = useState(0);
const [targetId, setTargetId] = useState("");
const [topic, setTopic] = useState("");
const [notice, setNotice] = useState("");
const [notice, setNotice] = useState<MessageKey | "">("");
const [noticeHost, setNoticeHost] = useState("");
const [copiedKey, setCopiedKey] = useState("");
const [busy, setBusy] = useState(false);
const [reported, setReported] = useState<"ok" | "missing" | null>(null);
@@ -108,7 +112,7 @@ export function FirstUseGuide({
if (!cancelled) {
setMemories([]);
setSelectedId("");
setError("暂时无法读取记忆,可稍后重试。");
setError("guideReadError");
}
})
.finally(() => {
@@ -143,7 +147,7 @@ export function FirstUseGuide({
);
})
.catch(() => {
if (!cancelled) setError("这条记忆暂不可读,请换一条或刷新。");
if (!cancelled) setError("guideDetailError");
})
.finally(() => {
if (!cancelled) setDetailLoading(false);
@@ -172,7 +176,12 @@ export function FirstUseGuide({
const nameOf = (id: string) =>
sources.find((item) => item.source_id === id)?.display_name ??
(id === "one-memory-manual" ? "端内添加" : id);
// The handoff draft is task content, not UI copy: retain its existing wording.
const sourceLabel = sourceIds.map(nameOf).join("、") || "原应用";
const displaySourceLabel =
sourceIds
.map((id) => (id === "one-memory-manual" ? t("guideManual") : nameOf(id)))
.join(t("guideListSeparator")) || t("guideOriginalApp");
const draft =
available && detail
? draftForScenario(scene, effectiveRoute, topic, detail, sourceLabel)
@@ -218,7 +227,7 @@ export function FirstUseGuide({
) {
setDetail(null);
setCopiedKey("");
setError("记忆内容或授权已变化,请刷新后重新核对。");
setError("guideChanged");
return;
}
if (
@@ -232,7 +241,7 @@ export function FirstUseGuide({
)
) {
setCopiedKey("");
setNotice("连接状态已变化,尚未复制。请重新选择方式。");
setNotice("guideConnectionChanged");
return;
}
validated = true;
@@ -242,25 +251,24 @@ export function FirstUseGuide({
if (open && host?.handoff === "open" && window.oneMemory) {
try {
const result = await window.oneMemory.openHandoff(host.hostId, draft);
if (current())
if (current()) {
setNoticeHost(host.label);
setNotice(
result.opened
? `已复制并请求打开 ${host.label}。尚未发送,请核对输入框。`
: "已完整复制,未能预填。可在目标应用粘贴,尚未发送。",
result.opened ? "guideCopiedOpened" : "guideCopiedNotFilled",
);
}
} catch {
if (current())
setNotice("已完整复制,但未能打开目标应用。尚未发送。");
if (current()) setNotice("guideCopiedNotOpened");
}
} else setNotice("已完整复制。尚未发送,也没有新增读取授权。");
} else setNotice("guideCopied");
} catch {
if (current()) {
if (!validated) {
setDetail(null);
setCopiedKey("");
setError("无法核对当前记忆与授权,旧预览已收起。请刷新后重试。");
setError("guideValidationError");
}
setNotice("未能核对最新内容或完成复制。请重试;没有打开目标应用。");
setNotice("guideCopyError");
}
} finally {
if (current()) setBusy(false);
@@ -271,16 +279,16 @@ export function FirstUseGuide({
<section className="view first-use-view" aria-labelledby="first-use-title">
<header className="first-use-heading">
<div>
<p className="first-use-eyebrow">上手体验</p>
<h1 id="first-use-title">让下一次对话接得上</h1>
<p className="first-use-eyebrow">{t("guideEyebrow")}</p>
<h1 id="first-use-title">{t("guideTitle")}</h1>
</div>
<button type="button" className="quiet" onClick={onClose}>
<ArrowLeft size={16} aria-hidden="true" />
返回记忆
{t("guideBack")}
</button>
</header>
<fieldset className="first-use-scenarios">
<legend className="sr-only">选择使用场景</legend>
<legend className="sr-only">{t("guideChooseScene")}</legend>
{GUIDE_SCENARIOS.map((item, index) => (
<label key={item.id} data-selected={scene === item.id}>
<input
@@ -296,47 +304,61 @@ export function FirstUseGuide({
}}
/>
<span className="first-use-number">0{index + 1}</span>
<span>{item.label}</span>
<span>
{t(
item.id === "same"
? "guideSame"
: item.id === "switch"
? "guideSwitch"
: "guideNew",
)}
</span>
</label>
))}
</fieldset>
<p className="first-use-description">{scenario.description}</p>
<p className="first-use-description">
{t(
scenario.id === "same"
? "guideSameDescription"
: scenario.id === "switch"
? "guideSwitchDescription"
: "guideNewDescription",
)}
</p>
{importedMessages > 0 && (
<p className="first-use-receipt">
本机已保存 {importedMessages.toLocaleString()} 条原始消息 ·{" "}
{tn("guideSaved", importedMessages)}
{loading
? "正在读取已整理记忆"
: `本页已加载 ${memories.length} 条可用记忆`}
。两个阶段分别计数。
? t("guideReadingMemories")
: tn("guideLoaded", memories.length)}
{t("guideSeparate")}
</p>
)}
<div className="first-use-path">
<span>选择背景</span>
<span>{t("guideChooseBackground")}</span>
<ArrowRight size={14} aria-hidden="true" />
<span>核对来源</span>
<span>{t("guideCheckSources")}</span>
<ArrowRight size={14} aria-hidden="true" />
<span>到新对话试用</span>
<span>{t("guideTry")}</span>
</div>
{error && (
<p className="error-banner" role="alert">
{error}
{t(error)}
</p>
)}
{loading ? (
<p role="status">正在读取本机已有记忆…</p>
<p role="status">{t("guideLoading")}</p>
) : !memories.length ? (
<section className="first-use-empty">
<FileText size={28} aria-hidden="true" />
<h2>先留下一条真正有用的背景</h2>
<p>
目前没有可用于这次体验的记忆。已导入的原文不等于已经整理好;候选内容也不会被自动带出。
</p>
<h2>{t("guideEmptyTitle")}</h2>
<p>{t("guideEmptyDescription")}</p>
<div>
<button type="button" onClick={onClose}>
去添加记忆
{t("guideAdd")}
</button>
<button type="button" className="secondary" onClick={onSources}>
查看导入与连接
{t("guideConnections")}
</button>
<button
type="button"
@@ -344,7 +366,7 @@ export function FirstUseGuide({
onClick={() => setReload((n) => n + 1)}
>
<RefreshCw size={15} />
刷新
{t("guideRefresh")}
</button>
</div>
</section>
@@ -355,12 +377,12 @@ export function FirstUseGuide({
aria-labelledby="guide-background-title"
>
<header>
<h2 id="guide-background-title">这次用到的背景</h2>
<h2 id="guide-background-title">{t("guideBackgroundTitle")}</h2>
<button
type="button"
className="quiet"
title="刷新示例"
aria-label="刷新示例"
title={t("guideRefreshExample")}
aria-label={t("guideRefreshExample")}
onClick={() => {
changed();
setDetail(null);
@@ -372,7 +394,7 @@ export function FirstUseGuide({
</button>
</header>
<label className="first-use-field">
选择一条记忆
{t("guideChooseMemory")}
<select
value={selectedId}
onChange={(event) => {
@@ -390,46 +412,51 @@ export function FirstUseGuide({
</select>
</label>
<p className="first-use-meta">
从当前已加载的 {memories.length}{" "}
条可用记忆中选择,不代表全部历史。
{tn("guideLoadedScope", memories.length)}
</p>
{detailLoading ? (
<p role="status">正在核对原始依据…</p>
<p role="status">{t("guideCheckingEvidence")}</p>
) : available && detail ? (
<>
<blockquote className="first-use-memory">
{detail.memory.content}
</blockquote>
<p className="first-use-meta">
{sourceLabel} · 版本 {detail.memory.revision} ·
系统整理,需你核对
{t("guideRevision", {
source: displaySourceLabel,
revision: detail.memory.revision.toLocaleString(locale),
})}
</p>
<p className="first-use-scope">
适用范围:
{t("guideScope")}
{detail.memory.scopes.includes("all-apps")
? "已设为跨应用可用,不等于个人偏好"
? t("guideAllApps")
: detail.memory.scopes.includes("private")
? "已停止外部记忆召回;手动带出需另行确认"
: "限定范围,未扩大外部召回范围"}
? t("guidePrivate")
: t("guideLimited")}
</p>
<details className="first-use-evidence" open>
<summary>原始依据 · {detail.evidence.length} 段</summary>
<summary>
{tn("guideEvidence", detail.evidence.length)}
</summary>
{detail.evidence.map((item) => (
<article key={item.evidence_id}>
<header>
<span>
{item.source_id ? nameOf(item.source_id) : "未知来源"}
{item.source_id
? nameOf(item.source_id)
: t("guideUnknownSource")}
</span>
<span>
{item.written_by === "person"
? "你"
? t("guideYou")
: item.written_by === "agent"
? "Agent"
: "作者未识别"}{" "}
: t("guideUnknownAuthor")}{" "}
·{" "}
{item.created_at
? new Date(item.created_at).toLocaleString()
: "时间未知"}
? new Date(item.created_at).toLocaleString(locale)
: t("guideUnknownTime")}
</span>
</header>
<p>{item.verbatim}</p>
@@ -438,18 +465,16 @@ export function FirstUseGuide({
</details>
</>
) : (
<p className="first-use-unavailable">
原文或当前授权不可用。换一条背景或刷新后再试,不会带出这条内容。
</p>
<p className="first-use-unavailable">{t("guideUnavailable")}</p>
)}
</section>
<section
className="first-use-destination"
aria-labelledby="guide-draft-title"
>
<h2 id="guide-draft-title">下一段对话的开场白</h2>
<h2 id="guide-draft-title">{t("guideDraftTitle")}</h2>
<label className="first-use-field">
在哪个应用试用
{t("guideChooseApp")}
<select
value={host?.hostId ?? ""}
onChange={(event) => {
@@ -458,7 +483,7 @@ export function FirstUseGuide({
setRoute("context");
}}
>
<option value="">自行选择应用 · 仅复制</option>
<option value="">{t("guideCopyOnly")}</option>
{destinations.map((item) => (
<option key={item.hostId} value={item.hostId}>
{item.label}
@@ -468,14 +493,14 @@ export function FirstUseGuide({
</label>
<p className="first-use-connection">
{hostsError
? "暂时无法读取连接状态,可重试或仅复制背景。"
? t("guideHostsError")
: !host
? "尚未选择已连接的应用。手动带入不等于安装连接。"
? t("guideNoApp")
: canQuery
? "上次自检可达,本次是否用对仍需核对。"
? t("guideWired")
: host.installed
? "已安装连接,尚未验证读取。"
: "尚未安装读取连接。"}
? t("guideInstalled")
: t("guideNotInstalled")}
</p>
<button
type="button"
@@ -483,10 +508,10 @@ export function FirstUseGuide({
onClick={onSources}
>
<Link2 size={15} aria-hidden="true" />
管理应用连接
{t("guideManage")}
</button>
<fieldset className="first-use-routes">
<legend className="sr-only">选择带入方式</legend>
<legend className="sr-only">{t("guideChooseRoute")}</legend>
<label>
<input
type="radio"
@@ -497,7 +522,7 @@ export function FirstUseGuide({
setRoute("context");
}}
/>
带入背景
{t("guideContext")}
</label>
<label>
<input
@@ -510,12 +535,12 @@ export function FirstUseGuide({
setRoute("query");
}}
/>
让应用查询
{t("guideQuery")}
</label>
</fieldset>
{effectiveRoute === "query" && (
<label className="first-use-field">
要接续的主题
{t("guideTopic")}
<input
value={topic}
maxLength={160}
@@ -528,17 +553,17 @@ export function FirstUseGuide({
)}
<p className="first-use-meta">
{effectiveRoute === "query"
? "只带查询,不把上方的预期答案塞进新会话。"
? t("guideQueryHint")
: scene === "new"
? "仅带你选择的这一条,不自动生成个人画像。"
: "完整带入这一条背景与引用;这不是自动召回测试。"}
? t("guideNewHint")
: t("guideContextHint")}
</p>
<textarea
className="first-use-draft"
aria-label="开场白完整预览"
aria-label={t("guidePreview")}
readOnly
value={draft}
placeholder="原始依据可用后显示开场白"
placeholder={t("guidePreviewPlaceholder")}
spellCheck={false}
/>
<div className="first-use-actions">
@@ -548,7 +573,7 @@ export function FirstUseGuide({
onClick={() => void carry(false)}
>
<Copy size={16} aria-hidden="true" />
{busy ? "正在核对…" : "复制开场白"}
{busy ? t("guideChecking") : t("guideCopy")}
</button>
{host?.handoff === "open" && (
<button
@@ -558,18 +583,18 @@ export function FirstUseGuide({
onClick={() => void carry(true)}
>
<ExternalLink size={16} aria-hidden="true" />
复制并打开 {host.label}
{t("guideCopyOpen", { host: host.label })}
</button>
)}
</div>
{notice && (
<p className="first-use-notice" role="status">
{notice}
{t(notice, { host: noticeHost })}
</p>
)}
{copiedKey === contextKey && (
<section className="first-use-result">
<p>尚无本次外部调用回执。新对话是否引用了正确的背景?</p>
<p>{t("guideNoReceipt")}</p>
<div>
<button
type="button"
@@ -577,21 +602,21 @@ export function FirstUseGuide({
onClick={() => setReported("ok")}
>
<Check size={15} />
我已核对结果
{t("guideVerified")}
</button>
<button
type="button"
className="quiet"
onClick={() => setReported("missing")}
>
没有找回来
{t("guideMissing")}
</button>
</div>
{reported && (
<p role="status">
{reported === "ok"
? "你的反馈:已核对。这不是系统自动验收记录。"
: "你的反馈:没有找回来。先检查来源是否导入、记忆范围与应用连接。"}
? t("guideFeedbackOk")
: t("guideFeedbackMissing")}
</p>
)}
</section>
@@ -610,18 +635,19 @@ export function FirstUseInvitation({
onOpen(): void;
onDismiss(): void;
}) {
const { t } = useGuiLanguage();
return (
<aside className="first-use-invitation" aria-label="上手体验邀请">
<span>用一条自己的记忆,试着接续下一次对话。</span>
<aside className="first-use-invitation" aria-label={t("guideInvitation")}>
<span>{t("guideInvitationHint")}</span>
<button type="button" className="quiet" onClick={onOpen}>
试一次
{t("guideTryOnce")}
<ArrowRight size={15} aria-hidden="true" />
</button>
<button
type="button"
className="quiet"
title="稍后再看"
aria-label="关闭上手邀请"
title={t("guideLater")}
aria-label={t("guideDismiss")}
onClick={onDismiss}
>
<X size={15} />
@@ -13,6 +13,20 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { GraphWidthPrompt } from "./GraphWidthPrompt";
import {
LANGUAGE_STORAGE_KEY,
LanguageProvider,
useGuiLanguage,
} from "./gui-language";
function LanguageSwitch() {
const { setPreference } = useGuiLanguage();
return (
<button type="button" onClick={() => void setPreference("zh-CN")}>
Cambiar idioma
</button>
);
}
const { getGraphWidth, decideGraphWidth } = vi.hoisted(() => ({
getGraphWidth: vi.fn(),
@@ -47,6 +61,44 @@ describe("GraphWidthPrompt", () => {
});
});
it("translates the mismatch and live switch without requesting or confirming twice", async () => {
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
const user = userEvent.setup();
render(
<LanguageProvider>
<LanguageSwitch />
<GraphWidthPrompt />
</LanguageProvider>,
);
await screen.findByRole("dialog", {
name: "La biblioteca y el modelo integrado no coinciden",
});
const keep = screen.getByRole("radio", {
name: /Conservar la biblioteca actual/,
}) as HTMLInputElement;
expect(keep.checked).toBe(true);
await user.click(
screen.getByRole("radio", { name: /Usar el modelo integrado/ }),
);
expect(decideGraphWidth).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Cambiar idioma" }));
await screen.findByRole("dialog", { name: "现有记忆库和内置模型对不上" });
expect(
(screen.getByRole("radio", { name: /改用内置模型/ }) as HTMLInputElement)
.checked,
).toBe(true);
expect(getGraphWidth).toHaveBeenCalledOnce();
expect(decideGraphWidth).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "就这么办" }));
await waitFor(() =>
expect(decideGraphWidth).toHaveBeenCalledExactlyOnceWith(
"rebuild_with_new",
expect.any(String),
),
);
window.localStorage.removeItem(LANGUAGE_STORAGE_KEY);
});
it("moves focus into the modal and keeps Tab inside it", async () => {
const user = userEvent.setup();
render(
+23 -10
View File
@@ -4,6 +4,7 @@ import type {
} from "@one-memory/contracts";
import { useEffect, useRef, useState } from "react";
import { decideGraphWidth, getGraphWidth } from "./api";
import { useGuiLanguage } from "./gui-language";
import { traceUiAction } from "./ui-trace";
import { isDefiniteRefusal, writeTraceStatus } from "./write-outcome";
@@ -20,6 +21,7 @@ export function GraphWidthPrompt({
}: {
onResolved?(): void;
}) {
const { t, locale } = useGuiLanguage();
const [status, setStatus] = useState<GraphWidthStatus | null>(null);
const [decision, setDecision] = useState<GraphWidthDecision>("keep_existing");
const [busy, setBusy] = useState(false);
@@ -88,7 +90,7 @@ export function GraphWidthPrompt({
? reason instanceof Error
? reason.message
: String(reason)
: "宽度选择尚未确认,现有记忆库未被改动。请重试。",
: "graphWidthUnknown",
);
} finally {
setBusy(false);
@@ -98,15 +100,19 @@ export function GraphWidthPrompt({
const choices: { value: GraphWidthDecision; label: string }[] = [
{
value: "keep_existing",
label: `保留现有记忆库(默认):继续用原来的 embedding 配置,${status.memory_count} 条记忆和关系图都不动`,
label: t("graphWidthKeep", {
count: status.memory_count.toLocaleString(locale),
}),
},
{
value: "rebuild_with_new",
label: `改用内置模型:现有关系图会被归档(不删除),${status.memory_count} 条记忆的关系需要重新建一遍`,
label: t("graphWidthRebuild", {
count: status.memory_count.toLocaleString(locale),
}),
},
{
value: "export_first",
label: "先导出再决定:现在什么都不动,导出之后再回来选",
label: t("graphWidthExport"),
},
];
@@ -115,7 +121,7 @@ export function GraphWidthPrompt({
className="graph-width-prompt"
role="dialog"
aria-modal="true"
aria-label="现有记忆库和内置模型对不上"
aria-label={t("graphWidthTitle")}
ref={panel}
onKeyDown={(event) => {
if (event.key !== "Tab" || !panel.current) return;
@@ -136,9 +142,12 @@ export function GraphWidthPrompt({
}
}}
>
<h2>现有记忆库和内置模型对不上</h2>
<h2>{t("graphWidthTitle")}</h2>
<p>
{`你现在的记忆库按 ${stored_width} 维建的,内置模型出的是 ${expected_width} 维。两者接不上,不选一个的话抽取会一直失败。`}
{t("graphWidthDescription", {
stored: stored_width.toLocaleString(locale),
expected: expected_width.toLocaleString(locale),
})}
</p>
<ul>
{choices.map((choice) => (
@@ -159,12 +168,16 @@ export function GraphWidthPrompt({
</ul>
{exportedPath ? (
<p className="notice">
已导出到 <code>{exportedPath}</code>。原库没有动,现在可以再选一次。
{t("graphWidthExported", { path: exportedPath })}
</p>
) : null}
{error ? (
<p className="notice">
{error === "graphWidthUnknown" ? t("graphWidthUnknown") : error}
</p>
) : null}
{error ? <p className="notice">{error}</p> : null}
<button type="button" disabled={busy} onClick={() => void confirm()}>
就这么办
{t("graphWidthConfirm")}
</button>
</div>
);
+22
View File
@@ -3,6 +3,7 @@ import type { GovernedMemory } from "@one-memory/contracts";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { HandoffBar, promptFrom } from "./HandoffBar";
function memory(
@@ -67,6 +68,27 @@ afterEach(() => {
delete window.oneMemory;
});
it("traduce la vista previa sin modificar el prompt contractual chino", async () => {
delete window.oneMemory;
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
const memories = [memory("a", { content: "Contenido 中文 opaco" })];
render(
<LanguageProvider>
<HandoffBar memories={memories} matterLabel="Tema 中文" />
</LanguageProvider>,
);
await userEvent.click(
screen.getByRole("button", { name: "Llevar las memorias seleccionadas" }),
);
expect(
screen.getByRole("heading", { name: "Llevar a otras aplicaciones" }),
).toBeTruthy();
expect((screen.getByRole("textbox") as HTMLTextAreaElement).value).toBe(
promptFrom(memories, "Tema 中文"),
);
window.localStorage.removeItem(LANGUAGE_STORAGE_KEY);
});
describe("handoff preview", () => {
it("does not open a host after the preview was closed while copying", async () => {
let copied: () => void = () => undefined;
+25 -20
View File
@@ -2,6 +2,7 @@ import { Copy, ExternalLink, X } from "lucide-react";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { copyText } from "./copy-text";
import type { RecallHostStatus } from "./global";
import { useGuiLanguage } from "./gui-language";
import { useRecallHosts } from "./host-state";
const NO_HOSTS: RecallHostStatus[] = [];
@@ -23,6 +24,7 @@ export function HandoffBar({
answerText?: string;
suspended?: boolean;
}) {
const { t } = useGuiLanguage();
const hosts = (useRecallHosts({ read: true }).data ?? NO_HOSTS).filter(
(host) => host.present,
);
@@ -36,14 +38,14 @@ export function HandoffBar({
const prompt = promptFrom(usable, matterLabel, answerText);
if (!usable.length) return null;
return (
<section className="wb-handoff" aria-label="带出记忆">
<section className="wb-handoff" aria-label={t("handoff.region")}>
<button
type="button"
className="quiet"
onClick={() => setOpenedFor(prompt)}
>
<ExternalLink size={15} aria-hidden="true" />
{answerText ? "带出回答与引用" : "带出所选记忆"}
{answerText ? t("handoff.answer") : t("handoff.selected")}
</button>
{openedFor === prompt && (
<HandoffDialog
@@ -72,6 +74,7 @@ function HandoffDialog({
onClose(): void;
suspended?: boolean;
}) {
const { locale, t, tn } = useGuiLanguage();
const titleId = useId();
const panel = useRef<HTMLElement>(null);
const [hostId, setHostId] = useState("");
@@ -105,7 +108,7 @@ function HandoffDialog({
try {
await copyText(prompt);
} catch {
setNote("复制失败,未打开任何应用。可在预览中选中文本复制。");
setNote(t("handoff.copyFailed"));
setBusy(false);
return;
}
@@ -115,15 +118,17 @@ function HandoffDialog({
const result = await window.oneMemory.openHandoff(host.hostId, prompt);
setNote(
result.opened
? `已复制并请求打开 ${host.label}。尚未发送;请核对目标输入框。`
: `已完整复制。${host.label} 未能预填,请在目标应用粘贴;尚未发送。`,
? t("handoff.opened", { host: host.label })
: t("handoff.notPrefilled", { host: host.label }),
);
} catch {
setNote(`内容已完整复制,但未能打开 ${host.label}。尚未发送。`);
setNote(t("handoff.openFailed", { host: host.label }));
}
} else {
setNote(
`已完整复制${host ? `,可粘贴到 ${host.label}` : ""}。尚未发送。`,
t("handoff.copied", {
target: host ? t("handoff.pasteTarget", { host: host.label }) : "",
}),
);
}
setBusy(false);
@@ -154,28 +159,30 @@ function HandoffDialog({
}}
>
<header className="handoff-heading">
<h2 id={titleId}>带到其他应用</h2>
<h2 id={titleId}>{t("handoff.title")}</h2>
<button
type="button"
className="quiet"
title="关闭预览"
aria-label="关闭带出预览"
title={t("handoff.closeTitle")}
aria-label={t("handoff.close")}
onClick={onClose}
>
<X size={18} />
</button>
</header>
<p>
{count} 条记忆 · {prompt.length.toLocaleString()} 字符 · 未省略
{tn("handoff.stats", count, {
characters: new Intl.NumberFormat(locale).format(prompt.length),
})}
</p>
<textarea
aria-label="将带出的完整内容"
aria-label={t("handoff.content")}
readOnly
value={prompt}
spellCheck={false}
/>
<label className="handoff-target">
目标应用
{t("handoff.target")}
<select
value={hostId}
onChange={(event) => {
@@ -183,18 +190,16 @@ function HandoffDialog({
setNote("");
}}
>
<option value="">其他应用 · 仅复制</option>
<option value="">{t("handoff.other")}</option>
{hosts.map((item) => (
<option key={item.hostId} value={item.hostId}>
{item.label}
{item.handoff !== "open" ? " · 仅复制" : ""}
{item.handoff !== "open" ? t("handoff.copyOnly") : ""}
</option>
))}
</select>
</label>
<p>
仅带出上方内容,不包含原始对话。复制不是授权连接,已带出的副本无法从其他应用撤回。
</p>
<p>{t("handoff.boundary")}</p>
<div className="consent-actions">
<button
type="button"
@@ -203,7 +208,7 @@ function HandoffDialog({
onClick={() => void send(false)}
>
<Copy size={15} aria-hidden="true" />
复制内容
{t("handoff.copy")}
</button>
{host?.handoff === "open" && (
<button
@@ -212,7 +217,7 @@ function HandoffDialog({
onClick={() => void send(true)}
>
<ExternalLink size={15} aria-hidden="true" />
复制并打开 {host.label}
{t("handoff.open", { host: host.label })}
</button>
)}
</div>
+65 -1
View File
@@ -12,6 +12,11 @@ import {
import { StrictMode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
LANGUAGE_STORAGE_KEY,
LanguageProvider,
useGuiLanguage,
} from "./gui-language";
import { HealthPanel } from "./HealthPanel";
import type { ImportSourcesController } from "./use-import-sources";
@@ -25,6 +30,37 @@ vi.mock("./api", () => ({
setDetailedLogging: (enabled: boolean) => setDetailedLogging(enabled),
}));
function HealthLanguageSwitch() {
const { setPreference } = useGuiLanguage();
return (
<button type="button" onClick={() => void setPreference("zh-CN")}>
Cambiar idioma
</button>
);
}
it("shows supported diagnostics in Spanish without toggling logging", async () => {
window.oneMemory = undefined;
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
render(
<LanguageProvider>
<HealthLanguageSwitch />
<HealthPanel imports={imports()} />
</LanguageProvider>,
);
await screen.findByRole("heading", { name: "Diagnóstico" });
expect(
screen.getByRole("button", { name: "Volver a leer el estado" }),
).toBeTruthy();
expect(screen.getByRole("heading", { name: "Servicio local" })).toBeTruthy();
const reads = getMemoryProcessing.mock.calls.length;
fireEvent.click(screen.getByText("Cambiar idioma"));
await screen.findByRole("heading", { name: "运行诊断" });
expect(getMemoryProcessing).toHaveBeenCalledTimes(reads);
expect(setDetailedLogging).not.toHaveBeenCalled();
localStorage.clear();
});
function source(
sourceId: string,
overrides: Partial<SourceResponse> = {},
@@ -151,7 +187,9 @@ describe("HealthPanel", () => {
render(<HealthPanel imports={imports()} />);
const details = await screen.findByLabelText("导入与整理详情");
expect(details.textContent).toContain("100000 条原文 · 50000 个完整 turn");
expect(details.textContent).toContain(
"100,000 条原文 · 50,000 个完整 turn",
);
expect(details.textContent).toContain(
"120 次调用 · 100 完成 · 1 结果不确定 · 2 失败 · 3 取消",
);
@@ -641,6 +679,32 @@ describe("HealthPanel", () => {
expect(block.textContent).toContain("暂无需要处理的问题");
});
it("read receipt timestamps follow the Spanish interface language", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
try {
render(
<LanguageProvider>
<HealthPanel imports={imports()} />
</LanguageProvider>,
);
await settle();
getMemoryProcessing.mockRejectedValue(new Error("offline"));
await act(async () => {
await vi.advanceTimersByTimeAsync(5_000);
});
const stamps = Array.from(
document.querySelectorAll(".health-stamp time"),
);
expect(stamps.length).toBeGreaterThan(0);
for (const stamp of stamps) {
expect(stamp.textContent).toMatch(/^Hoy /);
expect(stamp.textContent).not.toContain("今天");
}
} finally {
localStorage.clear();
}
});
it("sourceSnapshot 过期不继续报服务运行中或来源空", async () => {
render(
<HealthPanel
+168 -85
View File
@@ -1,6 +1,6 @@
import { type GuiLocale, translate } from "@one-memory/i18n";
import { Download, Flag, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
getDetailedLogging,
getMemoryProcessing,
@@ -13,16 +13,16 @@ import {
import { clockStamp } from "./clock-stamp";
import { exportDiagnosticsFile } from "./diagnostics-export";
import type { RecallHostStatus } from "./global";
import { useGuiLanguage } from "./gui-language";
import { refreshRecallHosts } from "./host-state";
import { memoryText } from "./memory-language";
import {
blockReasonLabel,
retryingBlockLabel,
splitBlockedStages,
taskKindLabel,
} from "./memory-processing-labels";
import { subscribePoll } from "./poll-scheduler";
import { R6Button, R6Switch } from "./R6Controls";
import { LINK_WORDING } from "./recall-host-wording";
import type { useImportSources } from "./use-import-sources";
type ImportState = ReturnType<typeof useImportSources>;
@@ -98,10 +98,11 @@ async function readHosts() {
}
function ReadTime({ label, value }: { label: string; value: string }) {
const { locale } = useGuiLanguage();
return (
<p className="health-stamp">
{label}
<time dateTime={value}>{clockStamp(value)}</time>
<time dateTime={value}>{clockStamp(value, locale)}</time>
</p>
);
}
@@ -111,23 +112,26 @@ function ReadReceipt({
confirmedAt,
failedAt,
}: Pick<Reading<unknown>, "pending" | "confirmedAt" | "failedAt">) {
const { t } = useGuiLanguage();
return (
<>
{failedAt ? (
<p role="status">
暂时无法读取
{confirmedAt ? " · 数据已过期,以下为上次读取结果。" : "。"}
{t("healthReadFailed")}
{confirmedAt ? t("healthStale") : "。"}
</p>
) : null}
{pending ? (
<p role="status">
{confirmedAt ? "正在重新读取,以下为上次读取结果…" : "正在读取状态…"}
{confirmedAt ? t("healthRereading") : t("healthReading")}
</p>
) : null}
{confirmedAt ? (
<ReadTime label="上次成功读取" value={confirmedAt} />
<ReadTime label={t("healthReadSuccessTime")} value={confirmedAt} />
) : null}
{failedAt ? (
<ReadTime label={t("healthReadFailedTime")} value={failedAt} />
) : null}
{failedAt ? <ReadTime label="读取失败时间" value={failedAt} /> : null}
</>
);
}
@@ -143,6 +147,8 @@ function ReadReceipt({
* the table the originating screen already uses.
*/
export function HealthPanel({ imports }: { imports: ImportState }) {
const { t, tn, tnPlain, locale } = useGuiLanguage();
const n = (value: number) => value.toLocaleString(locale);
const [detailedLogging, setDetailedLoggingState] = useState(false);
const [detailedLoggingPending, setDetailedLoggingPending] = useState(false);
const backgroundRead = useDiagnosticRead(readBackgroundHealth);
@@ -199,13 +205,13 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
async function markIncident() {
if (!window.oneMemory?.markDiagnosticIncident) {
setDiagnosticMessage("请在桌面 App 中记录问题时间点。");
setDiagnosticMessage(t("healthIncidentDesktop"));
return;
}
setDiagnosticAction("incident");
setDiagnosticMessage("");
try {
const description = window.prompt("简单描述刚才遇到的问题(可不填)", "");
const description = window.prompt(t("healthIncidentPrompt"), "");
if (description === null) {
setDiagnosticAction(null);
return;
@@ -213,9 +219,11 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
const receipt =
await window.oneMemory.markDiagnosticIncident(description);
await window.oneMemory.copyText?.(receipt.incident_id);
setDiagnosticMessage(`问题编号已复制:${receipt.incident_id}`);
setDiagnosticMessage(
t("healthIncidentCopied", { id: receipt.incident_id }),
);
} catch {
setDiagnosticMessage("问题时间点未能记录,请重试。");
setDiagnosticMessage(t("healthIncidentError"));
} finally {
setDiagnosticAction(null);
}
@@ -223,13 +231,13 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
async function exportDiagnostics() {
if (!window.oneMemory?.exportDiagnostics) {
setDiagnosticMessage("请在桌面 App 中导出诊断信息。");
setDiagnosticMessage(t("diagnosticExportDesktop"));
return;
}
setDiagnosticAction("export");
setDiagnosticMessage("");
try {
const result = await exportDiagnosticsFile();
const result = await exportDiagnosticsFile(locale);
setDiagnosticMessage(result.message);
} finally {
setDiagnosticAction(null);
@@ -244,20 +252,23 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
const sourceReceipt = (
<>
{imports.loading ? (
<p role="status">正在读取本地服务与来源状态…</p>
<p role="status">{t("healthReadingSources")}</p>
) : sourceUnavailable ? (
<p role="status">
暂时无法读取
{t("healthReadFailed")}
{sourceConfirmedAt || imports.health || imports.sources.length
? " · 数据已过期,以下为上次读取结果。"
? t("healthStale")
: "。"}
</p>
) : null}
{sourceConfirmedAt ? (
<ReadTime label="上次成功读取" value={sourceConfirmedAt} />
<ReadTime
label={t("healthReadSuccessTime")}
value={sourceConfirmedAt}
/>
) : null}
{sourceUnavailable && sourceFailedAt ? (
<ReadTime label="读取失败时间" value={sourceFailedAt} />
<ReadTime label={t("healthReadFailedTime")} value={sourceFailedAt} />
) : null}
</>
);
@@ -274,7 +285,7 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
<section className="view health-view" aria-labelledby="health-title">
<header className="view-header">
<div>
<h1 id="health-title">运行诊断</h1>
<h1 id="health-title">{t("healthTitle")}</h1>
</div>
</header>
@@ -285,26 +296,34 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
onClick={() => void exportDiagnostics()}
>
<Download size={16} aria-hidden="true" />
导出日志(发给开发排查)
{t("nativeExportLogs")}
</R6Button>
</div>
<section className="health-block" aria-label="本地服务">
<h2>本地服务</h2>
<section className="health-block" aria-label={t("healthLocal")}>
<h2>{t("healthLocal")}</h2>
{sourceReceipt}
{imports.health ? (
<p>
{`${sourceUnavailable || imports.loading ? "上次读取" : "运行中"} · 版本 ${imports.health.version} · 实例 ${imports.health.instance_id}`}
{t("healthCoreVersion", {
state: t(
sourceUnavailable || imports.loading
? "healthPrevious"
: "healthRunning",
),
version: imports.health.version,
instance: imports.health.instance_id,
})}
</p>
) : !sourceUnavailable && !imports.loading ? (
<p>还没连上本地 Core。</p>
<p>{t("healthCoreDisconnected")}</p>
) : null}
</section>
<section className="health-block" aria-label="后台服务">
<h2>后台服务</h2>
<section className="health-block" aria-label={t("healthBackground")}>
<h2>{t("healthBackground")}</h2>
{backgroundRead.value === "no-bridge" ? (
<p>当前环境无法检查后台服务,请在支持此功能的桌面 App 中查看。</p>
<p>{t("healthBackgroundUnavailable")}</p>
) : (
<>
<ReadReceipt {...backgroundRead} />
@@ -312,13 +331,18 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
<>
<p>
{backgroundRead.pending || backgroundRead.failedAt
? "上次读取:"
? t("healthPreviousPrefix")
: ""}
{backgroundVerdict(backgroundRead.value.state)}
{backgroundVerdict(backgroundRead.value.state, locale)}
</p>
<p>
Core:{runtimeStateLabel(backgroundRead.value.core)} ·
宿主桥接:{runtimeStateLabel(backgroundRead.value.broker)}
{t("healthRuntime", {
core: runtimeStateLabel(backgroundRead.value.core, locale),
broker: runtimeStateLabel(
backgroundRead.value.broker,
locale,
),
})}
</p>
</>
) : null}
@@ -326,8 +350,8 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
)}
</section>
<section className="health-block" aria-label="各来源累计条数">
<h2>各来源累计条数</h2>
<section className="health-block" aria-label={t("healthSources")}>
<h2>{t("healthSources")}</h2>
{sourceReceipt}
{imports.sources.length ? (
<dl>
@@ -335,41 +359,70 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
<div key={source.source_id}>
<dt>{source.display_name}</dt>
<dd>
{`${source.imported_conversations} 个会话 · ${source.imported_messages} 条消息`}
{t("healthSourceCount", {
conversations: n(source.imported_conversations),
messages: n(source.imported_messages),
})}
</dd>
</div>
))}
</dl>
) : !sourceUnavailable && !imports.loading ? (
<p>还没有已连接的来源。</p>
<p>{t("healthNoSources")}</p>
) : null}
</section>
<section className="health-block" aria-label="记忆提炼">
<h2>记忆提炼</h2>
<section className="health-block" aria-label={t("healthProcessing")}>
<h2>{t("healthProcessing")}</h2>
<ReadReceipt {...processingRead} />
{processing ? (
<>
<p>
{`${processing.running} 处理中 · ${processing.pending} 排队 · ${processing.completed} 已完成 · ${processing.failed} 失败`}
{t("healthTaskCounts", {
running: n(processing.running),
pending: n(processing.pending),
completed: n(processing.completed),
failed: n(processing.failed),
})}
</p>
{processing.metrics ? (
<dl aria-label="导入与整理详情">
<dl aria-label={t("healthImportDetails")}>
<div>
<dt>原文与完整对话轮次</dt>
<dd>{`${processing.metrics.raw_evidence} 条原文 · ${processing.metrics.complete_turns} 个完整 turn`}</dd>
<dt>{t("healthRawTurns")}</dt>
<dd>
{t("healthRawCounts", {
raw: n(processing.metrics.raw_evidence),
turns: n(processing.metrics.complete_turns),
})}
</dd>
</div>
<div>
<dt>HY 整理</dt>
<dd>{`${processing.metrics.hy_calls} 次调用 · ${processing.metrics.completed} 完成 · ${processing.metrics.unknown} 结果不确定 · ${processing.metrics.failed} 失败 · ${processing.metrics.cancelled} 取消`}</dd>
<dt>{t("healthHy")}</dt>
<dd>
{t("healthHyCounts", {
calls: n(processing.metrics.hy_calls),
completed: n(processing.metrics.completed),
unknown: n(processing.metrics.unknown),
failed: n(processing.metrics.failed),
cancelled: n(processing.metrics.cancelled),
})}
</dd>
</div>
<div>
<dt>产出</dt>
<dd>{`${processing.metrics.governed_memories} 条记忆`}</dd>
<dt>{t("healthOutput")}</dt>
<dd>
{tn("healthMemories", processing.metrics.governed_memories)}
</dd>
</div>
<div>
<dt>WorkBuddy 查记忆</dt>
<dd>{`${processing.metrics.workbuddy.injected} 次注入 · ${processing.metrics.workbuddy.timed_out} 次超时 · ${processing.metrics.workbuddy.unavailable} 次不可用`}</dd>
<dt>{t("healthWorkbuddy")}</dt>
<dd>
{t("healthWorkbuddyCounts", {
injected: n(processing.metrics.workbuddy.injected),
timedOut: n(processing.metrics.workbuddy.timed_out),
unavailable: n(processing.metrics.workbuddy.unavailable),
})}
</dd>
</div>
</dl>
) : null}
@@ -378,26 +431,35 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
{splitBlockedStages(processing.blocked_stages).named.map(
(block) => (
<li key={`${block.kind}:${block.reason}`}>
{`${taskKindLabel(block.kind)}卡在 ${blockReasonLabel(block.reason)}(${block.count} 条)`}
{t("healthBlocked", {
kind: memoryText(locale, taskKindLabel(block.kind)),
reason: memoryText(
locale,
blockReasonLabel(block.reason),
),
count: n(block.count),
})}
</li>
),
)}
{retrying ? <li>{retryingBlockLabel(retrying)}</li> : null}
{retrying ? (
<li>{tnPlain("healthRetrying", retrying)}</li>
) : null}
</ul>
) : !processingRead.failedAt && !processingRead.pending ? (
<p>暂无需要处理的问题。</p>
<p>{t("healthNoIssues")}</p>
) : null}
</>
) : null}
</section>
<section className="health-block" aria-label="AI 接通情况">
<h2>AI 接通情况</h2>
<section className="health-block" aria-label={t("healthHosts")}>
<h2>{t("healthHosts")}</h2>
{/* Host state lives in the desktop shell. In a browser it is not
false, it is unavailable, and reporting it as 未接通 would send
the user to repair links that may well be fine. */}
{hosts === "no-bridge" || !window.oneMemory ? (
<p>在浏览器里读不到;请在桌面 App 里查看。</p>
<p>{t("healthHostsUnavailable")}</p>
) : (
<>
<ReadReceipt {...hostsRead} />
@@ -408,34 +470,34 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
<dt>{host.label}</dt>
<dd>
{hostsRead.failedAt || hostsRead.pending
? "上次读取:"
? t("healthPreviousPrefix")
: ""}
{hostVerdict(host)}
{hostVerdict(host, locale)}
</dd>
</div>
))}
</dl>
) : hosts && !hostsRead.failedAt && !hostsRead.pending ? (
<p>本机没有检测到可接入的 AI 工具。</p>
<p>{t("healthNoHosts")}</p>
) : null}
</>
)}
</section>
<section className="health-block" aria-label="详细日志">
<section className="health-block" aria-label={t("healthDetailed")}>
<div className="health-setting-row">
<div>
<h2>详细日志(排查用)</h2>
<p>默认关闭。仅在开发人员请你协助排查时开启。</p>
<h2>{t("healthDetailedTitle")}</h2>
<p>{t("healthDetailedHint")}</p>
</div>
<R6Switch
label="详细日志(排查用)"
label={t("healthDetailedTitle")}
checked={detailedLogging}
pending={detailedLoggingPending}
onCheckedChange={(enabled) => {
setDetailedLoggingPending(true);
void setDetailedLogging(enabled)
.then(setDetailedLoggingState)
.catch(() => setDiagnosticMessage("详细日志设置失败,请重试。"))
.catch(() => setDiagnosticMessage(t("healthDetailedError")))
.finally(() => setDetailedLoggingPending(false));
}}
/>
@@ -448,7 +510,7 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
onClick={() => void refresh()}
>
<RefreshCw size={16} aria-hidden="true" />
重新读取状态
{t("healthRefresh")}
</R6Button>
<R6Button
variant="quiet"
@@ -456,7 +518,7 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
onClick={() => void markIncident()}
>
<Flag size={16} aria-hidden="true" />
记录问题时间点
{t("healthIncident")}
</R6Button>
</div>
{diagnosticMessage ? (
@@ -470,31 +532,52 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
/** A host with no self-check on record has not been shown to work, and the
* install flow's own wording for that is 未接通. */
function hostVerdict(host: RecallHostStatus): string {
if (!host.installed) return LINK_WORDING.unreachable;
const state = host.selfCheck?.state;
return (state ? LINK_WORDING[state] : undefined) ?? LINK_WORDING.unreachable;
function hostVerdict(host: RecallHostStatus, locale: GuiLocale): string {
return translate(
locale,
!host.installed
? "healthUnreachable"
: host.selfCheck?.state === "wired"
? "healthWired"
: host.selfCheck?.state === "reachable_but_blind"
? "healthBlind"
: "healthUnreachable",
);
}
function backgroundVerdict(state: BackgroundSnapshot["state"]): string {
return {
healthy: "后台服务可用",
stopped: "后台服务已停止",
degraded: "后台服务部分不可用",
untrusted: "后台服务身份未通过核验",
unknown: "后台状态尚未确认",
}[state];
function backgroundVerdict(
state: BackgroundSnapshot["state"],
locale: GuiLocale,
): string {
return translate(
locale,
(
{
healthy: "healthBackgroundHealthy",
stopped: "healthBackgroundStopped",
degraded: "healthBackgroundDegraded",
untrusted: "healthBackgroundUntrusted",
unknown: "healthBackgroundUnknown",
} as const
)[state],
);
}
function runtimeStateLabel(
state: BackgroundSnapshot["core"] | BackgroundSnapshot["broker"],
locale: GuiLocale,
): string {
return {
running: "运行中",
stopped: "已停止",
unhealthy: "无法连接",
untrusted: "身份未通过核验",
unknown: "尚未确认",
not_checked: "未检查",
}[state];
return translate(
locale,
(
{
running: "healthRunning",
stopped: "healthStopped",
unhealthy: "healthUnhealthy",
untrusted: "healthUntrusted",
unknown: "healthUnknown",
not_checked: "healthNotChecked",
} as const
)[state],
);
}
+7 -4
View File
@@ -1,9 +1,14 @@
import { Component, type ReactNode } from "react";
import { useApplicationsLanguage } from "./applications-language";
type Props = { label: string; children: ReactNode };
type Props = { label: string; children: ReactNode; fallback?: ReactNode };
type State = { crashed: boolean };
const RESET_MS = 15_000;
function CardFallback({ label }: { label: string }) {
const { a } = useApplicationsLanguage();
return <p>{a("cardUnavailable", { label })}</p>;
}
/**
* 2026-10-12: 首页任何一张卡片渲染出错时,只塌这一张卡,不塌整页。
@@ -36,9 +41,7 @@ export class HomeCardBoundary extends Component<Props, State> {
if (this.state.crashed) {
return (
<section className="r6-home-auto-failure" role="status">
<p>
{this.props.label}暂时显示不了,其他功能不受影响,稍后会自动恢复。
</p>
{this.props.fallback ?? <CardFallback label={this.props.label} />}
</section>
);
}
@@ -12,6 +12,11 @@ import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
LANGUAGE_STORAGE_KEY,
LanguageProvider,
useGuiLanguage,
} from "./gui-language";
import { ImportProgressStrip } from "./ImportProgressStrip";
function job(overrides: Partial<ImportJob> = {}): ImportJob {
@@ -84,6 +89,57 @@ function deferred() {
describe("ImportProgressStrip", () => {
afterEach(cleanup);
it("translates user-facing progress and live settings CTA without invoking actions", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
const open = vi.fn();
function Switch() {
const { setPreference } = useGuiLanguage();
return (
<button type="button" onClick={() => void setPreference("zh-CN")}>
Cambiar idioma
</button>
);
}
const view = render(
<LanguageProvider>
<Switch />
<ImportProgressStrip
userFacing
jobs={[job()]}
sources={[
source("cursor", "Cursor", { imported_conversations: 12345 }),
]}
backfilling={false}
onOpenSources={open}
/>
</LanguageProvider>,
);
expect((await screen.findByRole("status")).textContent).toContain(
"12.345 conversaciones",
);
view.rerender(
<LanguageProvider>
<Switch />
<ImportProgressStrip
userFacing
jobs={[job({ status: "failed" })]}
sources={sources}
backfilling={false}
onOpenSources={open}
onConfigureModel={open}
/>
</LanguageProvider>,
);
await screen.findByRole("button", { name: "Ir a configuración" });
expect(open).not.toHaveBeenCalled();
await userEvent.click(
screen.getByRole("button", { name: "Cambiar idioma" }),
);
await screen.findByRole("button", { name: "去设置" });
expect(open).not.toHaveBeenCalled();
localStorage.clear();
});
it("keeps daily sync silent in the user-facing shell", () => {
const { container } = render(
<ImportProgressStrip
+6 -4
View File
@@ -5,6 +5,7 @@ import type {
} from "@one-memory/contracts";
import { LoaderCircle, Pause, Play, RefreshCw, Settings2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useGuiLanguage } from "./gui-language";
import {
type MemoryArrival,
memoryArrivalTitle,
@@ -85,6 +86,7 @@ export function ImportProgressStrip({
showProgressDetails = true,
userFacing = false,
}: ImportProgressStripProps) {
const { t, tn } = useGuiLanguage();
const [refreshing, setRefreshing] = useState(false);
const [refreshError, setRefreshError] = useState<{
epoch: number;
@@ -252,10 +254,10 @@ export function ImportProgressStrip({
0,
);
const message = firstImport
? `正在导入你的历史对话,已导入 ${imported.toLocaleString("zh-CN")} 个会话。记忆会陆续生成,你可以先正常使用。`
? tn("importUserProgress", imported)
: currentArrival?.phase === "model-required"
? "模型还没有配置好,新的对话暂时不会整理成记忆。"
: "有些内容没有处理完成,请前往设置查看。";
? t("importUserModel")
: t("importUserIncomplete");
return (
<div
className="import-strip"
@@ -277,7 +279,7 @@ export function ImportProgressStrip({
onClick={onConfigureModel}
>
<Settings2 size={15} aria-hidden="true" />
去设置
{t("importUserSettings")}
</button>
)}
</div>
@@ -0,0 +1,98 @@
// @vitest-environment jsdom
import { translate } from "@one-memory/i18n";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { LanguageSelector } from "./LanguageSelector";
afterEach(() => {
cleanup();
localStorage.clear();
vi.restoreAllMocks();
});
it.each(["zh-CN", "en", "es"] as const)(
"hides the compact label while preserving its accessible name in %s",
(locale) => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
render(
<LanguageProvider>
<LanguageSelector compact />
</LanguageProvider>,
);
const name = translate(locale, "languageTitle");
const selector = screen.getByRole("combobox", { name });
const label = screen.getByText(name) as HTMLLabelElement;
expect(label.hidden).toBe(true);
expect(label.htmlFor).toBe(selector.id);
expect(screen.getByRole("region", { name })).toBeTruthy();
expect(screen.getByRole("option", { name: "Español" })).toBeTruthy();
expect(screen.getByRole("option", { name: "English" })).toBeTruthy();
expect(screen.getByRole("option", { name: "简体中文" })).toBeTruthy();
},
);
it.each(["zh-CN", "en", "es"] as const)(
"keeps the normal settings label visible and accessible in %s",
(locale) => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
render(
<LanguageProvider>
<LanguageSelector />
</LanguageProvider>,
);
const name = translate(locale, "languageLabel");
const selector = screen.getByRole("combobox", { name });
const label = screen.getByText(name) as HTMLLabelElement;
expect(label.hidden).toBe(false);
expect(label.htmlFor).toBe(selector.id);
expect(selector.getAttribute("aria-label")).toBeNull();
expect(
screen.getByRole("heading", { name: translate(locale, "languageTitle") }),
).toBeTruthy();
expect(
screen.getByText(translate(locale, "languageDescription")),
).toBeTruthy();
},
);
it("offers an accessible selector, persists and updates its label", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
render(
<LanguageProvider>
<LanguageSelector />
</LanguageProvider>,
);
const selector = screen.getByRole("combobox", {
name: translate("zh-CN", "languageLabel"),
});
expect(screen.getAllByRole("option")).toHaveLength(4);
expect(selector.getAttribute("aria-describedby")).toBeTruthy();
await userEvent.selectOptions(selector, "es");
expect(localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe("es");
expect(
screen.getByRole("combobox", { name: translate("es", "languageLabel") }),
).toBe(selector);
expect(document.documentElement.lang).toBe("es");
});
it("announces storage errors without replacing the confirmed choice", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
render(
<LanguageProvider>
<LanguageSelector />
</LanguageProvider>,
);
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("denied");
});
await userEvent.selectOptions(screen.getByRole("combobox"), "es");
expect(screen.getByRole("alert").textContent).toBe(
translate("zh-CN", "languageSaveError"),
);
expect((screen.getByRole("combobox") as HTMLSelectElement).value).toBe(
"zh-CN",
);
});
+58
View File
@@ -0,0 +1,58 @@
import { isLanguagePreference } from "@one-memory/i18n";
import { useId, useState } from "react";
import { useGuiLanguage } from "./gui-language";
export function LanguageSelector({ compact = false }: { compact?: boolean }) {
const id = useId();
const { preference, setPreference, t, error } = useGuiLanguage();
const [pending, setPending] = useState(false);
return (
<section
aria-labelledby={compact ? `${id}-label` : `${id}-title`}
className={
compact ? "r6-language-selector--compact" : "r6-language-setting"
}
>
{!compact && <h2 id={`${id}-title`}>{t("languageTitle")}</h2>}
<div className={compact ? "r6-language-selector-row" : "r6-setting-row"}>
<label id={`${id}-label`} htmlFor={`${id}-select`} hidden={compact}>
{t(compact ? "languageTitle" : "languageLabel")}
</label>
<select
id={`${id}-select`}
aria-label={compact ? t("languageTitle") : undefined}
aria-describedby={compact ? undefined : `${id}-description`}
value={preference}
disabled={pending}
onChange={async (event) => {
const value = event.currentTarget.value;
if (!isLanguagePreference(value)) return;
setPending(true);
try {
await setPreference(value);
} finally {
setPending(false);
}
}}
>
<option value="system">{t("languageSystem")}</option>
<option value="zh-CN" lang={compact ? "zh-CN" : undefined}>
{compact ? "简体中文" : t("languageChinese")}
</option>
<option value="en" lang={compact ? "en" : undefined}>
{compact ? "English" : t("languageEnglish")}
</option>
<option value="es" lang={compact ? "es" : undefined}>
{compact ? "Español" : t("languageSpanish")}
</option>
</select>
</div>
{!compact && <p id={`${id}-description`}>{t("languageDescription")}</p>}
{error && (
<p role="alert" className="r6-settings-error">
{t(error)}
</p>
)}
</section>
);
}
+3 -1
View File
@@ -5,6 +5,7 @@ import {
useRef,
useState,
} from "react";
import { useMemoryLanguage } from "./memory-language";
import { useMediaPreference } from "./use-media-preference";
type Phase = "closed" | "opening" | "open" | "closing";
@@ -20,6 +21,7 @@ export function MemoryEvidencePanel({
children: ReactNode;
suspended?: boolean;
}) {
const { text } = useMemoryLanguage();
const compact = useMediaPreference("(max-width: 1100px)");
const reduceMotion = useMediaPreference("(prefers-reduced-motion: reduce)");
const reduceTransparency = useMediaPreference(
@@ -135,7 +137,7 @@ export function MemoryEvidencePanel({
className="map-inspector"
{...(modal ? { role: "dialog", "aria-modal": !suspended } : {})}
inert={suspended}
aria-label="记忆与原文"
aria-label={text("记忆与原文")}
data-tour-id="memory-original-pane"
tabIndex={-1}
hidden={!present || (compact && suspended)}
+10 -8
View File
@@ -11,6 +11,7 @@ import {
subscribeDisplayPreference,
} from "./display-preferences";
import { graphEase } from "./graph-motion";
import { useMemoryLanguage } from "./memory-language";
/** 力导向迭代总步数。原实现在挂载时一次算完,现改为分帧推进。 */
const LAYOUT_ITERATIONS = 90;
@@ -35,6 +36,7 @@ export function MemoryGraph3D({
selectedNodeId: string | null;
onSelectNode(node: MemoryGraphNode): void;
}) {
const { text } = useMemoryLanguage();
const host = useRef<HTMLDivElement>(null);
const labelHost = useRef<HTMLDivElement>(null);
const selectRef = useRef(onSelectNode);
@@ -626,7 +628,7 @@ export function MemoryGraph3D({
<div className="graph-labels" ref={labelHost} aria-hidden="true" />
{!available ? (
<div className="memory-graph-fallback">
<p>这台设备打不开 3D 视图,已显示关联列表。</p>
<p>{text("这台设备打不开 3D 视图,已显示关联列表。")}</p>
{graph.nodes.map((node) => (
<button
type="button"
@@ -644,13 +646,13 @@ export function MemoryGraph3D({
<div
className="memory-graph-controls"
role="toolbar"
aria-label="图谱工具"
aria-label={text("图谱工具")}
>
<button
type="button"
className="secondary"
title="放大"
aria-label="放大图谱"
title={text("放大")}
aria-label={text("放大图谱")}
onClick={() => actionsRef.current?.zoom(0.8)}
>
<Plus size={16} />
@@ -658,8 +660,8 @@ export function MemoryGraph3D({
<button
type="button"
className="secondary"
title="缩小"
aria-label="缩小图谱"
title={text("缩小")}
aria-label={text("缩小图谱")}
onClick={() => actionsRef.current?.zoom(1.25)}
>
<Minus size={16} />
@@ -667,8 +669,8 @@ export function MemoryGraph3D({
<button
type="button"
className="secondary"
title="重置视角"
aria-label="重置图谱视角"
title={text("重置视角")}
aria-label={text("重置图谱视角")}
onClick={() => actionsRef.current?.reset()}
>
<Focus size={16} />
+17
View File
@@ -18,6 +18,23 @@ import {
within,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
it("traduce los controles del mapa en español sin cambiar etiquetas importadas", () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
const { container } = render(
<LanguageProvider>
<Host simplified />
</LanguageProvider>,
);
expect(screen.getByLabelText("Filtrar memorias del mapa")).toBeTruthy();
expect(container.textContent).not.toContain("全部记忆");
expect(container.textContent).not.toContain("当前已加载");
expect(container.textContent).not.toContain("来源视角");
expect(container.textContent).not.toContain("段原文");
localStorage.removeItem(LANGUAGE_STORAGE_KEY);
});
import { useState } from "react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import {
+190 -124
View File
@@ -5,6 +5,7 @@ import type {
MemoryLibraryPage,
RecentConversationItem,
} from "@one-memory/contracts";
import type { GuiLocale } from "@one-memory/i18n";
import {
ArrowUp,
ChevronLeft,
@@ -29,6 +30,7 @@ import {
type MemoryArrival,
resolveMemoryArrival,
} from "./memory-arrival-state";
import { memoryText, useMemoryLanguage } from "./memory-language";
import {
type MapAxis,
mapGroups,
@@ -96,8 +98,12 @@ function authorLabel(writtenBy: string) {
return "未知";
}
function dayStamp(value: string | null | undefined) {
if (!value || !Number.isFinite(Date.parse(value))) return "时间未知";
function dayStamp(
value: string | null | undefined,
locale: GuiLocale = "zh-CN",
) {
if (!value || !Number.isFinite(Date.parse(value)))
return memoryText(locale, "时间未知");
const date = new Date(value);
const now = new Date();
if (
@@ -105,8 +111,8 @@ function dayStamp(value: string | null | undefined) {
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate()
)
return "今天";
return date.toLocaleDateString("zh-CN");
return memoryText(locale, "今天");
return date.toLocaleDateString(locale);
}
/**
@@ -116,28 +122,37 @@ function dayStamp(value: string | null | undefined) {
*/
export const DIALOGUE_PANE_VISIBLE = false;
/** "10月2日", with the year only when it is not this year. */
function monthDay(value: string): string | null {
/** "10月2日" / "2 oct", with the year only when it is not this year. */
function monthDay(value: string, locale: GuiLocale): string | null {
if (!Number.isFinite(Date.parse(value))) return null;
const date = new Date(value);
const day = `${date.getMonth() + 1}月${date.getDate()}日`;
return date.getFullYear() === new Date().getFullYear()
? day
: `${date.getFullYear()}年${day}`;
return new Intl.DateTimeFormat(locale, {
day: "numeric",
month: "short",
...(date.getFullYear() !== new Date().getFullYear()
? { year: "numeric" }
: {}),
}).format(date);
}
/** D96: when the cited conversation happened, not when the memory was generated. */
function sourceStamp(
time: { at: string; basis: "conversation" | "import" } | undefined,
fallback: string | null | undefined,
locale: GuiLocale = "zh-CN",
): string {
const day = time ? monthDay(time.at) : null;
const day = time ? monthDay(time.at, locale) : null;
if (time && day)
return time.basis === "conversation" ? `对话于 ${day}` : `导入于 ${day}`;
return dayStamp(fallback);
return memoryText(
locale,
time.basis === "conversation" ? "对话于 {day}" : "导入于 {day}",
{ day },
);
return dayStamp(fallback, locale);
}
export function MemoryMap(props: Props) {
const { locale, text, count, plain } = useMemoryLanguage();
const {
memories,
graph: baseGraph,
@@ -322,14 +337,16 @@ export function MemoryMap(props: Props) {
if (library) {
const ids = library.page?.source_ids_by_memory?.[memoryId];
return ids?.length
? ids.map((id) => sourceNameOr(sources, id, "来源待核对")).join("、")
: "来源待核对";
? ids
.map((id) => sourceNameOr(sources, id, text("来源待核对")))
.join("、")
: text("来源待核对");
}
return (
sourceGroups
.filter((entry) => entry.memoryIds.includes(memoryId))
.map((entry) => entry.label)
.join("、") || "来源待核对"
.join(text("列表分隔符")) || text("来源待核对")
);
}
const filtered = useMemo(
@@ -594,7 +611,7 @@ export function MemoryMap(props: Props) {
(source) =>
source.authorized && groupKey === `source:${source.source_id}`,
)?.display_name ??
"全部记忆";
text("全部记忆");
if (
props.simplified &&
(!library ||
@@ -614,28 +631,31 @@ export function MemoryMap(props: Props) {
<div>
<h2>
{running
? "已开始处理所选对话"
? text("已开始处理所选对话")
: queued
? "处理请求已受理"
: "还没有已生成的记忆"}
? text("处理请求已受理")
: text("还没有已生成的记忆")}
</h2>
<p>
{running || queued
? "模型连接已通过。首批记忆生成后,会逐步出现在记忆列表里。"
? text("模型连接已通过。首批记忆生成后,会逐步出现在记忆列表里。")
: arrival.importedConversations > 0
? `已导入 ${arrival.importedConversations.toLocaleString()} 段对话,整理成记忆后会出现在这里。`
? text("已导入 {count} 段对话,整理成记忆后会出现在这里。", {
count: count(arrival.importedConversations),
})
: arrival.importedMessages > 0
? "历史已保存在本机,记忆就绪后会出现在这里。"
: "连接应用并导入历史后,可以开始沉淀记忆。"}
? text("历史已保存在本机,记忆就绪后会出现在这里。")
: text("连接应用并导入历史后,可以开始沉淀记忆。")}
{(running || queued) && (
<small>
当前已生成 0
条记忆。请求已受理不等于已经完成,原始对话、模型处理任务和记忆数量分别统计。
{text(
"当前已生成 0 条记忆。请求已受理不等于已经完成,原始对话、模型处理任务和记忆数量分别统计。",
)}
</small>
)}
</p>
<div>
<R6Button onClick={props.onConnect}>返回应用</R6Button>
<R6Button onClick={props.onConnect}>{text("返回应用")}</R6Button>
</div>
</div>
{running && (
@@ -648,7 +668,7 @@ export function MemoryMap(props: Props) {
<section
id={id}
className="core-memory-map"
aria-label="记忆工作台"
aria-label={text("记忆工作台")}
hidden={hidden}
inert={hidden}
onKeyDown={(e) => {
@@ -663,12 +683,12 @@ export function MemoryMap(props: Props) {
clear();
}}
>
<nav className="map-library" aria-label="来源与主题" ref={pane}>
<nav className="map-library" aria-label={text("来源与主题")} ref={pane}>
{props.simplified && DIALOGUE_PANE_VISIBLE ? (
<div
className="map-library-pane"
role="tablist"
aria-label="记忆或原始对话"
aria-label={text("记忆或原始对话")}
onKeyDown={(event) => {
const order = ["memories", "dialogues"] as const;
const current = order.indexOf(libraryPane);
@@ -705,20 +725,20 @@ export function MemoryMap(props: Props) {
aria-controls={`memory-pane-${value}`}
onClick={() => setLibraryPane(value)}
>
{title}
{text(title)}
</button>
))}
</div>
) : null}
{props.simplified ? (
<div className="selected-source-filter">
<label htmlFor="selected-source-filter">来源</label>
<label htmlFor="selected-source-filter">{text("来源")}</label>
<OptionMenu
id="selected-source-filter"
className="om-option-menu--plain"
value={controlledSource}
options={[
{ value: "", label: "全部应用" },
{ value: "", label: text("全部应用") },
...(library?.source &&
!sources.some(
(source) =>
@@ -727,7 +747,7 @@ export function MemoryMap(props: Props) {
? [
{
value: `source:${library.source}`,
label: "所选来源待核对",
label: text("所选来源待核对"),
},
]
: []),
@@ -747,7 +767,7 @@ export function MemoryMap(props: Props) {
/>
</div>
) : (
<fieldset className="map-axis" aria-label="地图组织方式">
<fieldset className="map-axis" aria-label={text("地图组织方式")}>
{(
[
["source", "来源"],
@@ -764,7 +784,7 @@ export function MemoryMap(props: Props) {
setGroupKey("");
}}
>
{title}
{text(title)}
</button>
))}
</fieldset>
@@ -772,15 +792,15 @@ export function MemoryMap(props: Props) {
<div className="map-search">
<Search size={16} aria-hidden="true" />
<input
aria-label="筛选地图记忆"
aria-label={text("筛选地图记忆")}
name="map-memory-search"
autoComplete="off"
placeholder={
libraryPane === "dialogues"
? "搜索原始对话"
? text("搜索原始对话")
: props.simplified
? "搜索记忆"
: "筛选记忆…"
? text("搜索记忆")
: text("筛选记忆…")
}
value={
libraryPane === "dialogues"
@@ -815,22 +835,30 @@ export function MemoryMap(props: Props) {
<span>
{library.stale
? library.page && library.page.total > 0
? `共 ${library.page.total} 条 · 正在核对来源…`
: "正在核对来源…"
? text(
"共 {count} 条 · 正在核对来源…",
{ count: plain(library.page.total) },
library.page.total,
)
: text("正在核对来源…")
: library.failed
? "读取失败"
? text("读取失败")
: library.loading && library.page?.total === 0
? "正在读取…"
? text("正在读取…")
: library.page
? `共 ${library.page.total} 条${refreshing ? " · 正在核对…" : ""}`
: "正在读取…"}
? `${text(
"共 {count} 条",
{ count: plain(library.page.total) },
library.page.total,
)}${refreshing ? text(" · 正在核对…") : ""}`
: text("正在读取…")}
</span>
{!library.stale && (
<button
type="button"
className="quiet map-tool"
aria-label="刷新地图记忆"
title="刷新地图记忆"
aria-label={text("刷新地图记忆")}
title={text("刷新地图记忆")}
disabled={library.loading || hidden}
onClick={library.onRetry}
>
@@ -847,7 +875,7 @@ export function MemoryMap(props: Props) {
aria-pressed={!groupKey}
onClick={() => chooseGroup("")}
>
全部记忆 <span>{active.length}</span>
{text("全部记忆")} <span>{active.length}</span>
</button>
{groups.map((g) => (
<button
@@ -867,16 +895,22 @@ export function MemoryMap(props: Props) {
{!props.simplified && (
<div className="map-list-heading">
<span>{label}</span>
<span>{filtered.length} 条</span>
<span>{text("{count} 条", { count: filtered.length })}</span>
</div>
)}
{arrivalCount > 0 && (
<div className="map-arrival-notice">
<span role="status">新增 {arrivalCount} 条记忆</span>
<span role="status">
{text(
"新增 {count} 条记忆",
{ count: plain(arrivalCount) },
arrivalCount,
)}
</span>
<button
type="button"
title="关闭新增记忆提示"
aria-label="关闭新增记忆提示"
title={text("关闭新增记忆提示")}
aria-label={text("关闭新增记忆提示")}
onClick={() => setArrivedIds([])}
>
<X size={15} aria-hidden="true" />
@@ -885,7 +919,7 @@ export function MemoryMap(props: Props) {
)}
{filterNotice && (
<p className="map-filter-notice" role="status">
{filterNotice}
{text(filterNotice)}
</p>
)}
{libraryPane === "dialogues" ? (
@@ -893,10 +927,10 @@ export function MemoryMap(props: Props) {
<div className="map-list-heading" aria-live="polite">
<span>
{dialogueFailed
? "读取失败"
? text("读取失败")
: dialoguePage
? `最近 ${dialoguePage.length} 条`
: "正在读取…"}
? text("最近 {count} 条", { count: dialoguePage.length })
: text("正在读取…")}
</span>
</div>
<ul
@@ -925,19 +959,23 @@ export function MemoryMap(props: Props) {
</li>
))}
</ul>
{dialogueFailed ? <p role="alert">原始对话暂时无法读取</p> : null}
{dialogueFailed ? (
<p role="alert">{text("原始对话暂时无法读取")}</p>
) : null}
{dialoguePage && !dialoguePage.length ? (
<p className="map-empty">
{dialogueFilter.trim()
? "没有匹配的原始对话"
: "还没有导入的原始对话"}
{text(
dialogueFilter.trim()
? "没有匹配的原始对话"
: "还没有导入的原始对话",
)}
</p>
) : null}
</>
) : null}
<ul
className="map-memory-list"
aria-label="地图记忆列表"
aria-label={text("地图记忆列表")}
id="memory-pane-memories"
hidden={libraryPane === "dialogues"}
>
@@ -957,15 +995,19 @@ export function MemoryMap(props: Props) {
{`${sourceLabel(m.memory_id)} · ${sourceStamp(
library?.page?.source_time_by_memory?.[m.memory_id],
m.updated_at,
locale,
)}`}
{/* D99: the conversation date alone would hide that this
one was made today, so the generated day stays too. */}
<span className="map-memory-made">
沉淀于 {dayStamp(m.updated_at)}
{text("沉淀于 ")}
{dayStamp(m.updated_at, locale)}
</span>
</>
) : (
<>已可用 · {m.evidence_ids.length} 段原文</>
text("已可用 · {count} 段原文", {
count: m.evidence_ids.length,
})
)}
</small>
</button>
@@ -974,8 +1016,10 @@ export function MemoryMap(props: Props) {
</ul>
{libraryPane === "memories" && library?.failed && (
<div role="alert">
<p>记忆读取失败,请重试。</p>
<R6Button onClick={library.onRetry}>重新读取地图记忆</R6Button>
<p>{text("记忆读取失败,请重试。")}</p>
<R6Button onClick={library.onRetry}>
{text("重新读取地图记忆")}
</R6Button>
</div>
)}
{libraryPane === "memories" &&
@@ -986,10 +1030,10 @@ export function MemoryMap(props: Props) {
!filtered.length && (
<p className="map-empty">
{library.query || library.source
? "没有匹配的记忆"
? text("没有匹配的记忆")
: groupKey
? "本页没有匹配的主题记忆"
: "还没有已生成的记忆"}
? text("本页没有匹配的主题记忆")
: text("还没有已生成的记忆")}
</p>
)}
{libraryPane === "memories" &&
@@ -997,12 +1041,12 @@ export function MemoryMap(props: Props) {
!library.loading &&
!library.failed &&
!library.stale && (
<nav aria-label="地图记忆分页" className="map-list-heading">
<nav aria-label={text("地图记忆分页")} className="map-list-heading">
<button
type="button"
className="quiet map-tool"
aria-label="上一页记忆"
title="上一页记忆"
aria-label={text("上一页记忆")}
title={text("上一页记忆")}
disabled={library.page.offset === 0}
onClick={() => {
clear();
@@ -1014,18 +1058,25 @@ export function MemoryMap(props: Props) {
<ChevronLeft size={16} aria-hidden="true" />
</button>
<span>
共 {library.page.total.toLocaleString()} 条,第{" "}
{library.page.total === 0
? 0
: Math.floor(library.page.offset / library.page.limit) +
1}{" "}
/ {Math.ceil(library.page.total / library.page.limit)} 页
{text(
"共 {count} 条,第 {page} / {pages} 页",
{
count: count(library.page.total),
page:
library.page.total === 0
? 0
: Math.floor(library.page.offset / library.page.limit) +
1,
pages: Math.ceil(library.page.total / library.page.limit),
},
library.page.total,
)}
</span>
<button
type="button"
className="quiet map-tool"
aria-label="下一页记忆"
title="下一页记忆"
aria-label={text("下一页记忆")}
title={text("下一页记忆")}
disabled={
library.page.offset + library.page.items.length >=
library.page.total
@@ -1042,7 +1093,7 @@ export function MemoryMap(props: Props) {
</nav>
)}
{!library && loaded && !filtered.length && (search || groupKey) && (
<p className="map-empty">没有匹配的记忆</p>
<p className="map-empty">{text("没有匹配的记忆")}</p>
)}
{(!props.simplified || (!library && !active.length)) && (
<MemoryArrivalPanel
@@ -1056,26 +1107,26 @@ export function MemoryMap(props: Props) {
/>
)}
</nav>
<section className="map-stage" aria-label="记忆关系图">
<section className="map-stage" aria-label={text("记忆关系图")}>
<header className="map-stage-heading">
<div>
<span className="map-kicker" hidden={props.simplified}>
{axis === "topic" ? "主题视角(非事项)" : "来源视角"}
{text(axis === "topic" ? "主题视角(非事项)" : "来源视角")}
</span>
<h2>
{libraryPane === "dialogues"
? "原始对话"
? text("原始对话")
: props.simplified
? "记忆关联"
? text("记忆关联")
: label}
</h2>
{props.simplified && (
<p className="selected-graph-subtitle">
{libraryPane === "dialogues"
? "刚导入的原话,还不是沉淀后的记忆"
? text("刚导入的原话,还不是沉淀后的记忆")
: projection.nodes.length
? "来源归属"
: "可在沉淀记忆后查看视图"}
? text("来源归属")
: text("可在沉淀记忆后查看视图")}
</p>
)}
</div>
@@ -1083,10 +1134,10 @@ export function MemoryMap(props: Props) {
<button
type="button"
className="quiet map-tool"
aria-label={
selectedSource ? "查看选中来源原文" : "查看选中记忆原文"
}
title="查看原文"
aria-label={text(
selectedSource ? "查看选中来源原文" : "查看选中记忆原文",
)}
title={text("查看原文")}
onClick={() => setInspectorOpen(true)}
>
<FileText size={17} />
@@ -1097,7 +1148,7 @@ export function MemoryMap(props: Props) {
type="button"
className="quiet map-tool"
title="整理已有关系"
aria-label="整理已有关系"
aria-label={text("整理已有关系")}
disabled={busy || !graph?.nodes.length}
onClick={props.onBuildRelations}
>
@@ -1120,9 +1171,9 @@ export function MemoryMap(props: Props) {
))}
</ol>
) : selectedDialogueId ? (
<p>正在读取原文…</p>
<p>{text("正在读取原文…")}</p>
) : (
<p>选择一条原始对话</p>
<p>{text("选择一条原始对话")}</p>
)}
</div>
) : projection.nodes.length && (!props.simplified || !hidden) ? (
@@ -1139,19 +1190,19 @@ export function MemoryMap(props: Props) {
<Network size={32} strokeWidth={1} />
<p>
{library?.stale
? "可在沉淀记忆后查看视图"
? text("可在沉淀记忆后查看视图")
: library?.failed
? "记忆暂时无法读取"
? text("记忆暂时无法读取")
: library?.page &&
(library.query || library.source || groupKey)
? "当前筛选没有可显示的记忆"
? text("当前筛选没有可显示的记忆")
: !loaded
? "正在读取记忆…"
? text("正在读取记忆…")
: active.length
? "当前筛选没有可显示的记忆"
? text("当前筛选没有可显示的记忆")
: arrival.importedMessages > 0
? "已保存的对话还未生成记忆"
: "还没有可显示的记忆"}
? text("已保存的对话还未生成记忆")
: text("还没有可显示的记忆")}
</p>
</div>
)}
@@ -1161,18 +1212,20 @@ export function MemoryMap(props: Props) {
something else left it dangling under a view it cannot describe. */}
{!props.simplified && libraryPane === "memories" && (
<div className="map-legend">
<span data-kind="memory">记忆</span>
<span data-kind="topic">主题</span>
<span data-kind="source">来源</span>
<span data-kind="schema">归纳</span>
<span data-kind="memory">{text("记忆")}</span>
<span data-kind="topic">{text("主题")}</span>
<span data-kind="source">{text("来源")}</span>
<span data-kind="schema">{text("归纳")}</span>
</div>
)}
{props.simplified ? (
projection.nodes.length ? (
<span>连线表示来源归属</span>
<span>{text("连线表示来源归属")}</span>
) : null
) : (
<span>{filtered.length} 条记忆 · 当前已加载</span>
<span>
{text("{count} 条记忆 · 当前已加载", { count: filtered.length })}
</span>
)}
</footer>
{!props.simplified && (
@@ -1187,10 +1240,10 @@ export function MemoryMap(props: Props) {
}}
>
<input
aria-label="向记忆提问"
aria-label={text("向记忆提问")}
name="map-question"
autoComplete="off"
placeholder="询问全部已授权可用记忆…"
placeholder={text("询问全部已授权可用记忆…")}
value={question}
onCompositionStart={() => {
composing.current = true;
@@ -1209,8 +1262,8 @@ export function MemoryMap(props: Props) {
/>
<button
type="submit"
title="发送问题"
aria-label="发送问题"
title={text("发送问题")}
aria-label={text("发送问题")}
disabled={!question.trim() || !!props.streaming}
>
<ArrowUp size={17} />
@@ -1248,19 +1301,19 @@ export function MemoryMap(props: Props) {
<header className="map-inspector-heading">
<h2>
{inspectorDetail
? "记忆详情"
? text("记忆详情")
: selectedSource
? "来源原文"
? text("来源原文")
: visibleGroupNode
? "关联节点"
: "记忆详情"}
? text("关联节点")
: text("记忆详情")}
</h2>
{(activeId || visibleGroupNode) && (
<button
type="button"
className="quiet map-tool"
aria-label="关闭记忆详情"
title="关闭详情"
aria-label={text("关闭记忆详情")}
title={text("关闭详情")}
onClick={closeInspector}
>
<X size={17} />
@@ -1311,7 +1364,7 @@ export function MemoryMap(props: Props) {
<i />
<i />
</div>
<p role="status">正在读取这条记忆…</p>
<p role="status">{text("正在读取这条记忆…")}</p>
</div>
) : (
<SelectedMemoryDetail
@@ -1366,13 +1419,15 @@ export function MemoryMap(props: Props) {
{props.detailLoad?.memoryId === activeId &&
props.detailLoad.status === "failed" ? (
<>
<p role="alert">这条记忆的原文暂时无法读取,选择已保留。</p>
<p role="alert">
{text("这条记忆的原文暂时无法读取,选择已保留。")}
</p>
<button
type="button"
className="secondary"
onClick={() => activeId && onOpenMemory(activeId)}
>
重试读取原文
{text("重试读取原文")}
</button>
</>
) : props.simplified && !activeId && filtered.length ? (
@@ -1382,15 +1437,26 @@ export function MemoryMap(props: Props) {
// single-column surface needs it — the three-column one already
// lists the same memories in the pane beside this one.
<>
<p>{`已沉淀 ${filtered.length} 条记忆`}</p>
<ul className="map-settled-list" aria-label="已沉淀的记忆">
<p>
{text(
"已沉淀 {count} 条记忆",
{ count: plain(filtered.length) },
filtered.length,
)}
</p>
<ul
className="map-settled-list"
aria-label={text("已沉淀的记忆")}
>
{filtered.map((m) => (
<li key={m.memory_id}>{m.content}</li>
))}
</ul>
</>
) : (
<p>{activeId ? "正在读取记忆详情…" : "沉淀记忆后查看详情"}</p>
<p>
{text(activeId ? "正在读取记忆详情…" : "沉淀记忆后查看详情")}
</p>
)}
</div>
)}
@@ -3,8 +3,40 @@ import type { MemoryNoteReceipt } from "@one-memory/contracts";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { MemoryNoteComposer } from "./MemoryNoteComposer";
it("opens a Spanish composer without saving, processing or enabling anything", async () => {
delete window.oneMemory;
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
render(
<LanguageProvider>
<MemoryNoteComposer
onSaved={vi.fn()}
onOpenMemory={vi.fn()}
onConfigureModel={vi.fn()}
/>
</LanguageProvider>,
);
await userEvent.click(
await screen.findByRole("button", { name: "Añadir memoria" }),
);
expect(screen.getByRole("dialog", { name: "Añadir memoria" })).toBeTruthy();
expect(
screen.getByRole("textbox", { name: "¿Qué quieres recordar?" }),
).toBeTruthy();
expect(
(screen.getByRole("button", { name: "Guardar texto" }) as HTMLButtonElement)
.disabled,
).toBe(true);
await userEvent.click(
screen.getByRole("button", { name: "Cerrar nueva memoria" }),
);
expect(api.saveMemoryNote).not.toHaveBeenCalled();
expect(api.processMemoryNote).not.toHaveBeenCalled();
localStorage.clear();
});
const api = vi.hoisted(() => ({
getMemoryNotes: vi.fn(),
getMemoryNoteOperation: vi.fn(),
+27 -20
View File
@@ -8,6 +8,7 @@ import {
processMemoryNote,
saveMemoryNote,
} from "./api";
import { useMemoryLanguage } from "./memory-language";
import { memoryWriteErrorText } from "./model-error-text";
import { subscribePoll } from "./poll-scheduler";
import { traceUiAction } from "./ui-trace";
@@ -42,6 +43,7 @@ export function MemoryNoteComposer({
onPendingChange?(note: PendingMemoryNote | null): void;
suspended?: boolean;
}) {
const { text: copy } = useMemoryLanguage();
const [open, setOpen] = useState(false);
const [text, setText] = useState("");
const [notes, setNotes] = useState<MemoryNoteReceipt[]>([]);
@@ -274,11 +276,11 @@ export function MemoryNoteComposer({
data-tour-id="memory-note-trigger"
onClick={() => setOpen(true)}
>
<FilePlus2 size={16} aria-hidden="true" /> 添加记忆
<FilePlus2 size={16} aria-hidden="true" /> {copy("添加记忆")}
</button>
{savedToast && (
<p className="memory-note-toast" role="status">
{savedToast}
{copy(savedToast)}
</p>
)}
{open && (
@@ -313,12 +315,12 @@ export function MemoryNoteComposer({
}}
>
<header className="memory-note-heading">
<h2 id="memory-note-title">添加记忆</h2>
<h2 id="memory-note-title">{copy("添加记忆")}</h2>
<button
className="quiet"
type="button"
aria-label="关闭新增记忆"
title="关闭"
aria-label={copy("关闭新增记忆")}
title={copy("关闭")}
disabled={busy}
onClick={close}
>
@@ -332,7 +334,7 @@ export function MemoryNoteComposer({
}}
>
<label className="memory-note-label" htmlFor="memory-note-text">
记下这件事
{copy("记下这件事")}
</label>
<textarea
id="memory-note-text"
@@ -340,20 +342,20 @@ export function MemoryNoteComposer({
maxLength={12000}
value={text}
disabled={busy}
placeholder="例如:项目周会不排在周五。"
placeholder={copy("例如:项目周会不排在周五。")}
onChange={(event) => setText(event.target.value)}
/>
<p className="memory-note-model">
整理模型:
{copy("整理模型:")}
{model?.name ??
(modelRead ? "尚未配置,原话仍可保存" : "正在读取")}
copy(modelRead ? "尚未配置,原话仍可保存" : "正在读取")}
</p>
{error && (
<p className="error-banner" role="alert">
{error}
{copy(error)}
</p>
)}
{notice && <p role="status">{notice}</p>}
{notice && <p role="status">{copy(notice)}</p>}
<div className="consent-actions">
{modelRead && !model && (
<button
@@ -365,30 +367,35 @@ export function MemoryNoteComposer({
onConfigureModel();
}}
>
配置模型
{copy("配置模型")}
</button>
)}
<button type="submit" disabled={busy || !text.trim()}>
{busy ? "保存中…" : "保存原话"}
{copy(busy ? "保存中…" : "保存原话")}
</button>
</div>
</form>
{notes.length > 0 && (
<section className="memory-note-recent" aria-label="最近添加">
<h3>最近添加</h3>
<section
className="memory-note-recent"
aria-label={copy("最近添加")}
>
<h3>{copy("最近添加")}</h3>
<ul>
{notes.map((note) => (
<li key={note.evidence_id}>
<p>{note.text}</p>
<small>{states[note.state]}</small>
<small>{copy(states[note.state])}</small>
{memoryWriteErrorText(note.error) && (
<p className="error-banner">
{memoryWriteErrorText(note.error)}
{copy(memoryWriteErrorText(note.error) ?? "")}
</p>
)}
{note.error === "hy_memory_model_payment_required" && (
<p className="error-banner">
模型服务要求检查余额或付款状态,原话仍已保存。
{copy(
"模型服务要求检查余额或付款状态,原话仍已保存。",
)}
</p>
)}
<div className="memory-note-actions">
@@ -402,7 +409,7 @@ export function MemoryNoteComposer({
onOpenMemory(id);
}}
>
查看记忆
{copy("查看记忆")}
{note.memory_ids.length > 1 ? ` ${index + 1}` : ""}
</button>
))}
@@ -416,7 +423,7 @@ export function MemoryNoteComposer({
onClick={() => void retry(note)}
>
<RefreshCw size={14} aria-hidden="true" />
重新整理
{copy("重新整理")}
</button>
)}
</div>
+108
View File
@@ -8,9 +8,87 @@ import {
waitFor,
} from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { DIALOGUE_PANE_VISIBLE } from "./MemoryMap";
import { MemorySpace } from "./MemorySpace";
it("translates memory filters and detail affordances live without refetching", async () => {
setup();
delete window.oneMemory;
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
render(
<LanguageProvider>
<MemorySpace simplified />
</LanguageProvider>,
);
await screen.findByRole("heading", { name: "Memorias", level: 1 });
await screen.findByText("已保存的记忆");
expect(
screen.getByRole("textbox", { name: "Filtrar memorias del mapa" }),
).toBeTruthy();
expect(
screen.getByRole("button", { name: "Actualizar memorias" }),
).toBeTruthy();
const calls = reads.mapLibrary.mock.calls.length;
act(() => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
window.dispatchEvent(
new StorageEvent("storage", { key: LANGUAGE_STORAGE_KEY }),
);
});
expect(screen.getByRole("heading", { name: "记忆", level: 1 })).toBeTruthy();
expect(reads.mapLibrary).toHaveBeenCalledTimes(calls);
localStorage.clear();
});
it("localiza la navegación de gobierno de la biblioteca", async () => {
setup();
delete window.oneMemory;
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
render(
<LanguageProvider>
<MemorySpace simplified />
</LanguageProvider>,
);
await screen.findByRole("heading", { name: "Memorias", level: 1 });
expect(
screen.getByRole("navigation", {
name: "Estado de las memorias",
hidden: true,
}),
).toBeTruthy();
localStorage.clear();
});
it("localiza cola y estimación sin alterar las memorias", async () => {
setup();
delete window.oneMemory;
reads.processing.mockResolvedValue({
running: 1,
pending: 2,
fresh_pending: 1,
backlog_pending: 1,
in_flight_count: 1,
active_queue: 1,
seconds_per_completion: 60,
completed: 0,
blocked_stages: [],
});
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
render(
<LanguageProvider>
<MemorySpace />
</LanguageProvider>,
);
expect(
await screen.findByText(
/1 en procesamiento · 1 recientes pendientes · 1 del historial pendientes/,
),
).toBeTruthy();
expect(screen.getByText(/aprox. 2 minutos/)).toBeTruthy();
localStorage.clear();
});
const { reads } = vi.hoisted(() => ({
reads: {
memories: vi.fn(),
@@ -73,6 +151,36 @@ afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
it("translates the model-required prompt without configuring a model", async () => {
setup();
reads.processing.mockResolvedValue({
running: 0,
pending: 0,
completed: 0,
blocked_stages: [
{ kind: "extract", reason: "waiting_for_model", count: 4 },
],
});
delete window.oneMemory;
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
const configure = vi.fn();
render(
<LanguageProvider>
<MemorySpace simplified onConfigureModel={configure} />
</LanguageProvider>,
);
expect(
await screen.findByRole("status", {
name: "Generación de memorias detenida",
}),
).toHaveProperty(
"textContent",
"Aún no se generan memorias: Esperando un modelo disponible (4 pendientes)Configurar modelo de generación",
);
expect(configure).not.toHaveBeenCalled();
localStorage.clear();
});
function setup() {
const items = [
{
File diff suppressed because it is too large. Load diff
+40 -1
View File
@@ -13,7 +13,7 @@ import {
import userEvent from "@testing-library/user-event";
import { createRef, useState } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import {
ModelSettings,
type ModelSettingsActions,
@@ -22,6 +22,45 @@ import {
import type { PendingModelProbe } from "./pending-model-probe";
import { PROVIDER_PRESETS } from "./provider-presets";
it("keeps the edited model draft and does not mutate on live language changes", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
profiles = [profile];
const user = userEvent.setup();
render(
<LanguageProvider>
<ModelSettings />
</LanguageProvider>,
);
const field = await screen.findByLabelText(
"Identificador del modelo de generación",
);
await user.type(field, "synthetic-model");
const before = [
create.mock.calls.length,
update.mock.calls.length,
activate.mock.calls.length,
probeProfile.mock.calls.length,
];
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
await act(async () =>
window.dispatchEvent(
new StorageEvent("storage", { key: LANGUAGE_STORAGE_KEY }),
),
);
expect(await screen.findByLabelText("生成模型名")).toHaveProperty(
"value",
"synthetic-model",
);
expect([
create.mock.calls.length,
update.mock.calls.length,
activate.mock.calls.length,
probeProfile.mock.calls.length,
]).toEqual(before);
cleanup();
localStorage.clear();
});
const create = vi.fn();
const probeProfile = vi.fn();
const activate = vi.fn();
+167 -134
View File
@@ -17,6 +17,7 @@ import {
useState,
} from "react";
import { OptionMenu } from "./OptionMenu";
import { useOnboardingTranslation } from "./onboarding-language";
import { PROVIDER_PRESETS } from "./provider-presets";
import { useModelProfiles } from "./use-model-profiles";
import "./model-setup-next.css";
@@ -231,6 +232,7 @@ export function ModelSettings({
compactSingle?: boolean;
onSetupComplete?(): void;
} = {}) {
const { text } = useOnboardingTranslation();
const models = useModelProfiles();
const [provider, setProvider] = useState<ProviderKind>("openai_compatible");
const [providerPreset, setProviderPreset] = useState("custom");
@@ -698,25 +700,28 @@ export function ModelSettings({
<div className="section-heading">
<div>
<p className="section-index">02</p>
<h2 id="models-title">记忆模型</h2>
<h2 id="models-title">{text("记忆模型")}</h2>
</div>
<p>
只需配置生成模型。向量嵌入已内置在本机,不为 System 1、System 2
分开选模型。
{text(
"只需配置生成模型。向量嵌入已内置在本机,不为 System 1、System 2 分开选模型。",
)}
</p>
</div>
{models.error ? (
<p className="error-banner" role="alert">
{models.error}
{text(models.error)}
</p>
) : null}
{(models.pendingProbe || models.probeStorageFailed) && (
<div role="status" className="model-refresh-notice">
<p>
{models.probeStorageFailed
? "无法读取测试恢复记录,暂不发送新的测试。"
: "上次模型测试结果待核对,暂不修改或启用配置。"}
{text(
models.probeStorageFailed
? "无法读取测试恢复记录,暂不发送新的测试。"
: "上次模型测试结果待核对,暂不修改或启用配置。",
)}
</p>
{models.pendingProbe && (
<button
@@ -731,7 +736,7 @@ export function ModelSettings({
if (alive.current) onProfilesChanged?.();
}}
>
核对测试结果
{text("核对测试结果")}
</button>
)}
</div>
@@ -739,9 +744,11 @@ export function ModelSettings({
{(models.pendingCreation || models.creationStorageFailed) && (
<div role="status" className="model-refresh-notice">
<p>
{models.creationMissing
? "未找到上次创建记录。确认放弃旧记录后可重新配置;系统不会自动重试。"
: "上次模型创建结果待核对,暂不开始新的操作。"}
{text(
models.creationMissing
? "未找到上次创建记录。确认放弃旧记录后可重新配置;系统不会自动重试。"
: "上次模型创建结果待核对,暂不开始新的操作。",
)}
</p>
<button
type="button"
@@ -757,7 +764,7 @@ export function ModelSettings({
onProfilesChanged?.();
}}
>
核对创建结果
{text("核对创建结果")}
</button>
{models.pendingCreation && (
<button
@@ -771,26 +778,28 @@ export function ModelSettings({
setSaveError(null);
}}
>
放弃旧记录,重新配置
{text("放弃旧记录,重新配置")}
</button>
)}
</div>
)}
{setupPhase && (
<p className="model-test-notice" role="status">
{setupPhase === "testing"
? "配置已保存,正在测试连接…"
: "连接通过,正在启用模型…"}
{text(
setupPhase === "testing"
? "配置已保存,正在测试连接…"
: "连接通过,正在启用模型…",
)}
</p>
)}
{setupError && (
<p className="model-save-error" role="alert">
{setupError}
{text(setupError)}
</p>
)}
{models.refreshError && (
<div className="model-refresh-notice" role="status">
<p>{models.refreshError}</p>
<p>{text(models.refreshError)}</p>
<button
type="button"
className="secondary"
@@ -798,7 +807,7 @@ export function ModelSettings({
onClick={() => void models.refreshProfiles()}
>
<RefreshCw size={16} aria-hidden="true" />
刷新配置列表
{text("刷新配置列表")}
</button>
</div>
)}
@@ -837,7 +846,7 @@ export function ModelSettings({
} else newProfile();
}}
>
自定义一个模型
{text("自定义一个模型")}
</button>
</div>
) : null}
@@ -845,7 +854,7 @@ export function ModelSettings({
{!compactSingle && savedProfile && (
<article
className="model-saved-card"
aria-label="已保存的模型配置"
aria-label={text("已保存的模型配置")}
tabIndex={-1}
ref={savedCard}
data-tour-id="model-form"
@@ -853,34 +862,37 @@ export function ModelSettings({
<div className="model-saved-heading">
<Check size={18} aria-hidden="true" />
<strong>
{isReady(savedProfile) && savedProfile.is_active
? "已保存,已启用"
: isReady(savedProfile)
? "已保存,连接通过"
: models.probeFor(savedProfile)
? "已保存,测试未通过"
: "已保存,待测试"}
{text(
isReady(savedProfile) && savedProfile.is_active
? "已保存,已启用"
: isReady(savedProfile)
? "已保存,连接通过"
: models.probeFor(savedProfile)
? "已保存,测试未通过"
: "已保存,待测试",
)}
</strong>
</div>
<h3>{savedProfile.name}</h3>
<p>
{savedProfile.model} · {endpointLabel(savedProfile.base_url)} · 版本{" "}
{savedProfile.revision}
{savedProfile.model} · {text(endpointLabel(savedProfile.base_url))}{" "}
· {text("版本")} {savedProfile.revision}
</p>
{savedProfile.credential_state === "unavailable" && (
<p role="status">
需要允许 MemHub
访问钥匙串。系统询问时请选择“始终允许”;已保存的配置不会丢失。
{text(
"需要允许 MemHub 访问钥匙串。系统询问时请选择“始终允许”;已保存的配置不会丢失。",
)}
</p>
)}
<p>测试只向此接口发送测试内容,不发送历史对话。</p>
<p>{text("测试只向此接口发送测试内容,不发送历史对话。")}</p>
<div className="source-actions">
<button
type="button"
disabled={locked}
onClick={() => void resumeSetup(savedProfile)}
>
{setupError ? "重试测试并启用" : "测试并启用"}
{text(setupError ? "重试测试并启用" : "测试并启用")}
</button>
<button
type="button"
@@ -889,7 +901,7 @@ export function ModelSettings({
onClick={() => editProfile(savedProfile)}
>
<Pencil size={16} aria-hidden="true" />
编辑配置
{text("编辑配置")}
</button>
<button
type="button"
@@ -898,7 +910,7 @@ export function ModelSettings({
onClick={newProfile}
>
<Plus size={16} aria-hidden="true" />
新增配置
{text("新增配置")}
</button>
</div>
<ProbeFeedback
@@ -920,18 +932,20 @@ export function ModelSettings({
>
{editing && (
<p className="model-test-notice">
编辑 {editing.name} · 版本 {editing.revision}
。修改后需重新测试并设为当前模型,内容授权不会自动更新。
{text("编辑")} {editing.name} · {text("版本")} {editing.revision}
{text("。修改后需重新测试并设为当前模型,内容授权不会自动更新。")}
</p>
)}
{editChanged && (
<p role="alert" className="model-save-error">
配置已在其他位置变化,草稿已保留。请取消编辑后重新打开核对。
{text(
"配置已在其他位置变化,草稿已保留。请取消编辑后重新打开核对。",
)}
</p>
)}
{saveError && (
<p className="model-save-error" role="alert">
{saveError}
{text(saveError)}
</p>
)}
{unknownCreation && !editing && (
@@ -940,13 +954,13 @@ export function ModelSettings({
disabled={formLocked}
onClick={() => void models.refreshProfiles()}
>
刷新配置列表
{text("刷新配置列表")}
</button>
)}
<div className="model-form-field">
服务商预设
{text("服务商预设")}
<OptionMenu
ariaLabel="服务商预设"
ariaLabel={text("服务商预设")}
describedBy="model-preset-help"
value={providerPreset}
disabled={formLocked}
@@ -955,7 +969,7 @@ export function ModelSettings({
value: item.id,
label: item.label,
})),
{ value: "custom", label: "自定义兼容服务" },
{ value: "custom", label: text("自定义兼容服务") },
]}
onChange={(value) => {
const next = PROVIDER_PRESETS.find((item) => item.id === value);
@@ -977,15 +991,16 @@ export function ModelSettings({
}}
/>
<small id="model-preset-help">
只是帮你填好接口地址和一个模型名,不代表这个模型当前可用。
请对照服务商的模型列表核对后再保存。
{text(
"只是帮你填好接口地址和一个模型名,不代表这个模型当前可用。 请对照服务商的模型列表核对后再保存。",
)}
</small>
</div>
<label>
协议
{text("协议")}
<select
name="provider"
aria-label="协议"
aria-label={text("协议")}
aria-describedby="model-provider-help"
value={provider}
disabled={formLocked}
@@ -1001,19 +1016,19 @@ export function ModelSettings({
* the reader parse a parenthesis to find out they were dead. */}
{PROVIDERS.filter((item) => item.activatable).map((item) => (
<option key={item.kind} value={item.kind}>
{item.label}
{text(item.label)}
</option>
))}
</select>
<small id="model-provider-help">
与模型服务提供的接口协议保持一致。
{text("与模型服务提供的接口协议保持一致。")}
</small>
</label>
<label>
配置名称
{text("配置名称")}
<input
name="name"
aria-label="配置名称"
aria-label={text("配置名称")}
aria-describedby="model-name-help"
autoComplete="off"
maxLength={128}
@@ -1024,13 +1039,15 @@ export function ModelSettings({
setName(event.target.value);
}}
/>
<small id="model-name-help">用于区分配置,不影响模型请求。</small>
<small id="model-name-help">
{text("用于区分配置,不影响模型请求。")}
</small>
</label>
<label>
生成模型名
{text("生成模型名")}
<input
name="model"
aria-label="生成模型名"
aria-label={text("生成模型名")}
aria-describedby={`model-identifier-help${fields.model ? " model-field-error" : ""}`}
aria-invalid={!!fields.model}
autoComplete="off"
@@ -1043,14 +1060,14 @@ export function ModelSettings({
setProviderPreset("custom");
if (!nameEdited) setName(suggestedName(event.target.value));
}}
placeholder="例如:gpt-4o-mini…"
placeholder={text("例如:gpt-4o-mini…")}
/>
<small id="model-identifier-help">
填写服务方提供的完整模型标识。
{text("填写服务方提供的完整模型标识。")}
</small>
{fields.model && (
<small id="model-field-error" className="model-field-error">
{fields.model}
{text(fields.model)}
</small>
)}
</label>
@@ -1063,12 +1080,12 @@ export function ModelSettings({
disabled={formLocked}
onClick={() => setAdvancedEmbedding(!advancedEmbedding)}
>
高级:自行配置向量模型
{text("高级:自行配置向量模型")}
</button>
{advancedEmbedding ? (
<>
<label>
向量配置模式
{text("向量配置模式")}
<select
name="embedding-mode"
value={embeddingMode}
@@ -1085,14 +1102,14 @@ export function ModelSettings({
}));
}}
>
<option value="local">本地内置</option>
<option value="custom">自定义向量模型</option>
<option value="local">{text("本地内置")}</option>
<option value="custom">{text("自定义向量模型")}</option>
</select>
</label>
{embeddingMode === "custom" ? (
<>
<label>
向量模型名
{text("向量模型名")}
<input
name="embedding-model"
autoComplete="off"
@@ -1109,19 +1126,19 @@ export function ModelSettings({
onChange={(event) =>
setEmbeddingModel(event.target.value)
}
placeholder="例如:text-embedding-3-small…"
placeholder={text("例如:text-embedding-3-small…")}
/>
{fields["embedding-model"] && (
<small
id="model-embedding-error"
className="model-field-error"
>
{fields["embedding-model"]}
{text(fields["embedding-model"])}
</small>
)}
</label>
<label>
向量模型来源
{text("向量模型来源")}
<select
name="embedding-provider"
value={embeddingProvider}
@@ -1133,14 +1150,14 @@ export function ModelSettings({
setEmbeddingProvider(next);
}}
>
<option value="ollama">本机 Ollama</option>
<option value="ollama">{text("本机 Ollama")}</option>
<option value="openai_compatible">
同源 OpenAI-compatible(共用 API Key)
{text("同源 OpenAI-compatible(共用 API Key)")}
</option>
</select>
</label>
<label>
向量模型 Base URL
{text("向量模型 Base URL")}
<input
name="embedding-base-url"
type="url"
@@ -1169,27 +1186,27 @@ export function ModelSettings({
id="model-embedding-url-error"
className="model-field-error"
>
{fields["embedding-base-url"]}
{text(fields["embedding-base-url"])}
</small>
)}
</label>
{embeddingProvider === "openai_compatible" ? (
<small>
为避免凭据外泄,向量地址必须与生成模型同源。
{text("为避免凭据外泄,向量地址必须与生成模型同源。")}
</small>
) : null}
</>
) : (
<small>
使用内置本地向量模型;不会发送内容给远程向量服务。
{text("使用内置本地向量模型;不会发送内容给远程向量服务。")}
</small>
)}
</>
) : null}
{!advancedEmbedding && embeddingMode === "custom" ? (
<p className="model-help">
自定义向量:{embeddingModel || "尚未填写模型"} ·{" "}
{embeddingBaseUrl || "尚未填写地址"}
{text("自定义向量")}: {embeddingModel || text("尚未填写模型")} ·{" "}
{embeddingBaseUrl || text("尚未填写地址")}
</p>
) : null}
</div>
@@ -1212,19 +1229,19 @@ export function ModelSettings({
}}
/>
<small id="model-url-help">
填写服务接口地址,不是聊天网页地址。
{text("填写服务接口地址,不是聊天网页地址。")}
</small>
{fields["base-url"] && (
<small id="model-url-error" className="model-field-error">
{fields["base-url"]}
{text(fields["base-url"])}
</small>
)}
</label>
<label>
API Key{keyRequired ? "" : "(可选)"}
API Key{keyRequired ? "" : text("(可选)")}
<input
name="api-key"
aria-label={`API Key${keyRequired ? "" : "(可选)"}`}
aria-label={`API Key${keyRequired ? "" : text("(可选)")}`}
aria-describedby={`model-key-help${fields["api-key"] ? " model-key-error" : ""}`}
aria-invalid={!!fields["api-key"]}
type="password"
@@ -1233,18 +1250,20 @@ export function ModelSettings({
value={apiKey}
disabled={formLocked}
onChange={(event) => setApiKey(event.target.value)}
placeholder="保存后不会再次显示…"
placeholder={text("保存后不会再次显示…")}
/>
<small id="model-key-help">
{editing && !endpointMoved
? "留空保留已保存密钥;填写新密钥后需重新测试和启用。"
: editing && keyRequired
? "换了服务商后需要填写新的 API Key,已保存的密钥不会带到新地址。"
: "保存在系统安全存储中,不随安装包分发。"}
{text(
editing && !endpointMoved
? "留空保留已保存密钥;填写新密钥后需重新测试和启用。"
: editing && keyRequired
? "换了服务商后需要填写新的 API Key,已保存的密钥不会带到新地址。"
: "保存在系统安全存储中,不随安装包分发。",
)}
</small>
{fields["api-key"] && (
<small id="model-key-error" className="model-field-error">
{fields["api-key"]}
{text(fields["api-key"])}
</small>
)}
</label>
@@ -1256,7 +1275,7 @@ export function ModelSettings({
disabled={formLocked}
onChange={(event) => setDisableThinking(event.target.checked)}
/>
关闭思考链(向支持的厂商下发 thinking-off 参数)
{text("关闭思考链(向支持的厂商下发 thinking-off 参数)")}
</label>
<div className="form-submit">
{!compactSingle && (
@@ -1266,7 +1285,7 @@ export function ModelSettings({
locked || editChanged || (!editing && unknownCreation)
}
>
{editing ? "保存修改" : "保存配置"}
{text(editing ? "保存修改" : "保存配置")}
</button>
)}
<button
@@ -1274,13 +1293,15 @@ export function ModelSettings({
disabled={locked || editChanged || (!editing && unknownCreation)}
onClick={() => void persist(true)}
>
{setupPhase === "testing"
? "正在测试连接…"
: setupPhase === "activating"
? "正在启用配置…"
: compactSingle
? "测试并完成配置"
: "保存并测试启用"}
{text(
setupPhase === "testing"
? "正在测试连接…"
: setupPhase === "activating"
? "正在启用配置…"
: compactSingle
? "测试并完成配置"
: "保存并测试启用",
)}
</button>
{editing && (
<button
@@ -1295,10 +1316,12 @@ export function ModelSettings({
setFields({});
}}
>
取消编辑
{text("取消编辑")}
</button>
)}
<small>密钥保存在系统安全存储;测试仅发送测试内容。</small>
<small>
{text("密钥保存在系统安全存储;测试仅发送测试内容。")}
</small>
</div>
</form>
) : null}
@@ -1315,14 +1338,16 @@ export function ModelSettings({
<article key={profile.profile_id} className="profile-row">
{profile.credential_state === "unavailable" && (
<p className="error-banner" role="status">
需要允许 MemHub
访问钥匙串。系统询问时请选择“始终允许”;已保存的配置不会丢失。
{text(
"需要允许 MemHub 访问钥匙串。系统询问时请选择“始终允许”;已保存的配置不会丢失。",
)}
</p>
)}
<div>
<strong>{profile.name}</strong>
<p>
{providerLabel(profile.provider_kind)} · {profile.model} ·
{text(providerLabel(profile.provider_kind))} ·{" "}
{profile.model} ·
{/* Core fills in the built-in vector model on every profile,
* so printing it unconditionally put a line about
* bge-small-zh-v1.5 under a profile whose owner never chose
@@ -1331,17 +1356,17 @@ export function ModelSettings({
{profile.embedding_model &&
profile.embedding_provider_kind &&
profile.embedding_provider_kind !== BUILTIN_EMBEDDING_KIND
? ` 向量: ${profile.embedding_model} (${providerLabel(
profile.embedding_provider_kind,
? ` ${text("自定义向量")}: ${profile.embedding_model} (${text(
providerLabel(profile.embedding_provider_kind),
)}) ·`
: ""}{" "}
revision {profile.revision}
</p>
</div>
<span>{profile.is_active ? "当前使用" : "未启用"}</span>
<span>{text(profile.is_active ? "当前使用" : "未启用")}</span>
<p className="model-test-notice">
测试目标:{endpointLabel(profile.base_url)}
。仅发送测试内容,不发送历史对话。
{text("测试目标")}: {text(endpointLabel(profile.base_url))}
{text("。仅发送测试内容,不发送历史对话。")}
</p>
<div className="source-actions">
<button
@@ -1349,7 +1374,7 @@ export function ModelSettings({
disabled={locked}
onClick={() => void resumeSetup(profile)}
>
{setupError ? "重试测试并启用" : "测试并启用"}
{text(setupError ? "重试测试并启用" : "测试并启用")}
</button>
<button
type="button"
@@ -1358,7 +1383,7 @@ export function ModelSettings({
onClick={() => editProfile(profile)}
>
<Pencil size={16} aria-hidden="true" />
编辑配置
{text("编辑配置")}
</button>
<button
type="button"
@@ -1366,7 +1391,7 @@ export function ModelSettings({
disabled={locked}
onClick={() => models.probeProfile(profile.profile_id)}
>
测试连接
{text("测试连接")}
</button>
{!profile.is_active ? (
<>
@@ -1375,7 +1400,7 @@ export function ModelSettings({
disabled={locked || !isReady(profile)}
onClick={() => models.activate(profile.profile_id)}
>
设为当前模型
{text("设为当前模型")}
</button>
<button
type="button"
@@ -1383,7 +1408,7 @@ export function ModelSettings({
disabled={locked}
onClick={() => models.remove(profile.profile_id)}
>
删除
{text("删除")}
</button>
</>
) : null}
@@ -1435,6 +1460,7 @@ function PresetProfileCard({
probe: ModelProbeResult | null;
probeError: string | null;
}) {
const { text } = useOnboardingTranslation();
const [key, setKey] = useState("");
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
@@ -1483,21 +1509,25 @@ function PresetProfileCard({
<section className="preset-profile" aria-label={profile.name}>
<div>
<strong>{profile.name}</strong>
<p>{presetStatus(profile)}</p>
<p>{text(presetStatus(profile))}</p>
</div>
{discloseRecipients && (
<p>
生成接收方:{endpointLabel(profile.base_url)};向量接收方:
{text("生成接收方")}: {text(endpointLabel(profile.base_url))};{" "}
{text("向量接收方")}:
{profile.embedding_provider_kind === BUILTIN_EMBEDDING_KIND
? "本机"
: endpointLabel(profile.embedding_base_url ?? profile.base_url)}
。启用此配置后按上方选择处理历史及未来允许来源。
? text("本机")
: text(
endpointLabel(profile.embedding_base_url ?? profile.base_url),
)}
{text("。启用此配置后按上方选择处理历史及未来允许来源。")}
</p>
)}
{profile.credential_state === "unavailable" && (
<p role="status">
需要允许 MemHub
访问钥匙串。系统询问时请选择“始终允许”;已保存的配置不会丢失。
{text(
"需要允许 MemHub 访问钥匙串。系统询问时请选择“始终允许”;已保存的配置不会丢失。",
)}
</p>
)}
{profile.has_api_key ? null : (
@@ -1511,7 +1541,7 @@ function PresetProfileCard({
noValidate
>
<label>
{keyLabel}
{text(keyLabel)}
<input
name="preset-api-key"
type="password"
@@ -1524,7 +1554,7 @@ function PresetProfileCard({
}
value={key}
onChange={(event) => setKey(event.target.value)}
placeholder="保存后不会再次显示…"
placeholder={text("保存后不会再次显示…")}
/>
{error && (
<small
@@ -1532,12 +1562,12 @@ function PresetProfileCard({
className="model-field-error"
role="alert"
>
{error}
{text(error)}
</small>
)}
</label>
<button type="submit" disabled={busy || saving}>
保存 key
{text("保存 key")}
</button>
<button
type="button"
@@ -1545,11 +1575,11 @@ function PresetProfileCard({
disabled={busy || saving || !canEnable}
onClick={() => void save(true)}
>
保存并测试启用
{text("保存并测试启用")}
</button>
{applyHref ? (
<a href={applyHref} target="_blank" rel="noreferrer">
还没有 key?去申请
{text("还没有 key?去申请")}
</a>
) : null}
</form>
@@ -1562,21 +1592,21 @@ function PresetProfileCard({
disabled={busy || !canEnable}
onClick={onContinue}
>
测试并启用
{text("测试并启用")}
</button>
<p className="model-test-notice">
测试目标:{endpointLabel(profile.base_url)}
。仅发送测试内容,不发送历史对话。
{text("测试目标")}: {text(endpointLabel(profile.base_url))}
{text("。仅发送测试内容,不发送历史对话。")}
</p>
<button type="button" disabled={busy} onClick={onTest}>
测试连接
{text("测试连接")}
</button>
<button
type="button"
disabled={busy || !canEnable || !ready(profile, probe, probeError)}
onClick={onActivate}
>
设为当前模型
{text("设为当前模型")}
</button>
<button
type="button"
@@ -1584,7 +1614,7 @@ function PresetProfileCard({
disabled={busy}
onClick={onRemove}
>
删除
{text("删除")}
</button>
</div>
) : null}
@@ -1601,7 +1631,7 @@ function PresetProfileCard({
disabled={busy}
onClick={onRemove}
>
删除这张卡
{text("删除这张卡")}
</button>
</div>
)}
@@ -1639,6 +1669,7 @@ function ProbeFeedback({
result: ModelProbeResult | null;
error: string | null;
}) {
const { text } = useOnboardingTranslation();
const matching =
result?.profile_id === profile.profile_id &&
result.profile_revision === profile.revision
@@ -1648,20 +1679,22 @@ function ProbeFeedback({
return (
<section
className="probe-result"
aria-label={`${profile.name} 版本 ${profile.revision} 测试结果`}
aria-label={`${profile.name} ${text("版本")} ${profile.revision} ${text("测试结果")}`}
>
<strong>
{profile.name} · 版本 {profile.revision} · 连接测试
{profile.name} · {text("版本")} {profile.revision} · {text("测试连接")}
</strong>
<p>
测试目标:{endpointLabel(profile.base_url)} · {profile.model}
{text("测试目标")}: {text(endpointLabel(profile.base_url))} ·{" "}
{profile.model}
</p>
{error && <p role="alert">{error}</p>}
{error && <p role="alert">{text(error)}</p>}
{matching && (
<ul>
{matching.stages.map((stage) => (
<li key={stage.stage}>
{stage.stage}: {probeLabel(stage.code)} · {stage.latency_ms}ms
{stage.stage}: {text(probeLabel(stage.code))} · {stage.latency_ms}
ms
</li>
))}
</ul>
+28 -24
View File
@@ -1,16 +1,18 @@
import type { MessageKey } from "@one-memory/i18n";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
getModelUsage,
type ModelUsageMinute,
type ModelUsageReport,
} from "./api";
import { useGuiLanguage } from "./gui-language";
const RANGES = [
{ minutes: 15, label: "最近 15 分钟" },
{ minutes: 60, label: "最近 1 小时" },
{ minutes: 360, label: "最近 6 小时" },
{ minutes: 1_440, label: "最近 24 小时" },
{ minutes: 10_080, label: "最近 7 天" },
const RANGES: { minutes: number; label: MessageKey }[] = [
{ minutes: 15, label: "usageRange15m" },
{ minutes: 60, label: "usageRange1h" },
{ minutes: 360, label: "usageRange6h" },
{ minutes: 1_440, label: "usageRange24h" },
{ minutes: 10_080, label: "usageRange7d" },
];
const STAGE_LABELS: Record<string, string> = {
@@ -41,6 +43,7 @@ interface ModelUsageViewProps {
}
export function ModelUsageView({ live, refreshToken }: ModelUsageViewProps) {
const { t, tnPlain } = useGuiLanguage();
const [rangeMinutes, setRangeMinutes] = useState(60);
const [report, setReport] = useState<ModelUsageReport | null>(null);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
@@ -76,33 +79,33 @@ export function ModelUsageView({ live, refreshToken }: ModelUsageViewProps) {
<div className="model-usage-view">
<div className="model-usage-toolbar">
<label>
<span>时间范围</span>
<span>{t("usageRange")}</span>
<select
aria-label="模型用量时间范围"
aria-label={t("usageRangeLabel")}
value={rangeMinutes}
onChange={(event) => setRangeMinutes(Number(event.target.value))}
>
{RANGES.map((range) => (
<option key={range.minutes} value={range.minutes}>
{range.label}
{t(range.label)}
</option>
))}
</select>
</label>
<span className={`model-usage-state state-${state}`}>
{state === "loading"
? "正在读取…"
? t("usageLoading")
: state === "error"
? "读取失败"
? t("usageReadError")
: live
? "每 5 秒刷新"
: "已暂停"}
? t("usageLive")
: t("debugPaused")}
</span>
</div>
<div className="model-usage-metrics">
<Metric
label="总 Tokens"
label={t("usageTotalTokens")}
value={formatTokens(report?.summary.total_tokens)}
/>
<Metric
@@ -114,26 +117,26 @@ export function ModelUsageView({ live, refreshToken }: ModelUsageViewProps) {
value={formatTokens(report?.summary.avg_completion_tokens)}
/>
<Metric
label="请求数"
label={t("usageRequests")}
value={String(report?.summary.request_count ?? 0)}
/>
</div>
<section
className="model-usage-chart-card"
aria-label="各 Prompt 每分钟 Token 曲线"
aria-label={t("usageChartRegion")}
>
<div className="model-usage-section-title">
<strong>各 Prompt 花费曲线</strong>
<span>每分钟 · Total Tokens</span>
<strong>{t("usageChartTitle")}</strong>
<span>{t("usageChartSubtitle")}</span>
</div>
<UsageChart rows={report?.by_minute ?? []} />
</section>
<section className="model-usage-stage-card">
<div className="model-usage-section-title">
<strong>Prompt 明细</strong>
<span>当前时间范围</span>
<strong>{t("usageBreakdown")}</strong>
<span>{t("usageCurrentRange")}</span>
</div>
<div className="model-usage-stage-list">
{stages.length ? (
@@ -141,14 +144,14 @@ export function ModelUsageView({ live, refreshToken }: ModelUsageViewProps) {
<div className="model-usage-stage-row" key={stage.name}>
<i style={{ background: stageColor(stage.name) }} />
<strong>{stageLabel(stage.name)}</strong>
<span>{stage.requests} 次</span>
<span>{tnPlain("usageRequestCount", stage.requests)}</span>
<span>Prompt {formatTokens(stage.prompt)}</span>
<span>Completion {formatTokens(stage.completion)}</span>
<b>{formatTokens(stage.total)}</b>
</div>
))
) : (
<p className="debug-log-empty">这个时间范围内没有沉淀模型调用</p>
<p className="debug-log-empty">{t("usageEmpty")}</p>
)}
</div>
</section>
@@ -166,6 +169,7 @@ function Metric({ label, value }: { label: string; value: string }) {
}
function UsageChart({ rows }: { rows: ModelUsageMinute[] }) {
const { t } = useGuiLanguage();
const minutes = [...new Set(rows.map((row) => row.minute))].sort();
const stages = [...new Set(rows.map((row) => row.prompt_stage))].sort();
const width = 640;
@@ -189,7 +193,7 @@ function UsageChart({ rows }: { rows: ModelUsageMinute[] }) {
pad.top + plotHeight - (value / max) * plotHeight;
if (!rows.length)
return <div className="model-usage-chart-empty">暂无曲线数据</div>;
return <div className="model-usage-chart-empty">{t("usageNoChart")}</div>;
return (
<>
@@ -197,7 +201,7 @@ function UsageChart({ rows }: { rows: ModelUsageMinute[] }) {
className="model-usage-chart"
viewBox={`0 0 ${width} ${height}`}
role="img"
aria-label="按分钟显示各 Prompt 的 Token 花费"
aria-label={t("usageChartAria")}
>
{[0, 0.5, 1].map((ratio) => (
<g key={ratio}>
@@ -0,0 +1,492 @@
// @vitest-environment jsdom
import type { ImportJob, SourceResponse } from "@one-memory/contracts";
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { completeOnboarding } from "./api";
import { FirstImportReview } from "./FirstImportReview";
import {
LANGUAGE_STORAGE_KEY,
LanguageProvider,
useGuiLanguage,
} from "./gui-language";
import { OnboardingFlow } from "./OnboardingFlow";
import { OnboardingModelController } from "./OnboardingModelController";
import { R6ImportProgress } from "./R6ImportProgress";
import type { ImportSourcesController } from "./use-import-sources";
vi.mock("./api", () => ({
completeOnboarding: vi.fn(),
getModelProfiles: vi.fn(),
}));
const models = {
pendingProbe: false,
probeStorageFailed: false,
pendingCreation: false,
creationStorageFailed: false,
error: null,
create: vi.fn(),
update: vi.fn(),
probeProfile: vi.fn(),
activate: vi.fn(),
};
vi.mock("./use-model-profiles", () => ({ useModelProfiles: () => models }));
const source: SourceResponse = {
source_id: "cursor",
display_name: "Cursor",
available: true,
authorized: false,
detected: false,
storage_kinds: ["jsonl"],
root_label: null,
candidate_path: "/fixture/cursor",
candidate_kind: "default",
candidate_root_id: "fixture-root",
location_state: "found",
imported_messages: 0,
imported_conversations: 0,
sync_enabled: false,
sync_completeness: "additions_only",
capabilities: {
historical_import: "implemented",
incremental_import: "implemented",
live_sync: "not_implemented",
recall: "not_implemented",
installer: "not_implemented",
verification: "fixture_only",
},
};
function controller(overrides: Partial<SourceResponse> = {}) {
const sources = [{ ...source, ...overrides }];
return {
sources,
jobs: [],
loading: false,
refresh: vi.fn().mockResolvedValue({ sources, jobs: [], health: null }),
connectForOnboarding: vi.fn(),
detect: vi.fn(),
} as unknown as ImportSourcesController;
}
function SwitchLanguage() {
const { setPreference } = useGuiLanguage();
return (
<button type="button" onClick={() => void setPreference("zh-CN")}>
中文
</button>
);
}
const state = {
status: "not_started" as const,
selected_source_ids: [],
started_at: null,
completed_at: null,
};
beforeEach(() => {
delete window.oneMemory;
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
localStorage.clear();
delete window.oneMemory;
});
it("changes the first-screen language without losing source choices or starting an import", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
const imports = controller();
const finish = vi.fn();
const user = userEvent.setup();
render(
<LanguageProvider>
<OnboardingFlow state={state} imports={imports} onFinish={finish} />
</LanguageProvider>,
);
const selector = screen.getByRole("combobox", { name: "界面语言" });
await waitFor(() =>
expect(screen.getByLabelText("导入 Cursor 历史")).toHaveProperty(
"checked",
true,
),
);
await user.click(screen.getByLabelText("导入 Cursor 历史"));
await user.selectOptions(selector, "es");
expect(screen.getByRole("combobox", { name: "Idioma de la interfaz" })).toBe(
selector,
);
expect(
screen.getByLabelText("Importar el historial de Cursor"),
).toHaveProperty("checked", false);
await user.click(screen.getByLabelText("Importar el historial de Cursor"));
await user.selectOptions(selector, "zh-CN");
expect(screen.getByLabelText("导入 Cursor 历史")).toHaveProperty(
"checked",
true,
);
expect(imports.refresh).toHaveBeenCalledOnce();
expect(imports.connectForOnboarding).not.toHaveBeenCalled();
expect(imports.detect).not.toHaveBeenCalled();
expect(completeOnboarding).not.toHaveBeenCalled();
expect(finish).not.toHaveBeenCalled();
});
it("translates discovery, progress and privacy without importing or authorizing on language changes", async () => {
const imports = controller();
render(
<LanguageProvider>
<SwitchLanguage />
<OnboardingFlow state={state} imports={imports} onFinish={vi.fn()} />
</LanguageProvider>,
);
expect(
await screen.findByRole("heading", {
name: "Trae aquí el contexto que ya tienes",
}),
).toBeTruthy();
await waitFor(() =>
expect(
screen.getByLabelText<HTMLInputElement>("Importar el historial de Cursor")
.checked,
).toBe(true),
);
expect(
screen.getByRole("navigation", {
name: "Progreso de la configuración inicial",
}),
).toBeTruthy();
expect(
screen.getByText(
"Antes de confirmar, solo se comprueba la información de las aplicaciones. No se leen conversaciones ni se instalan conexiones.",
),
).toBeTruthy();
expect(
screen.getByText("/fixture/cursor · Ubicación predeterminada"),
).toBeTruthy();
fireEvent.click(screen.getByText("中文"));
await screen.findByRole("heading", { name: "把熟悉的背景,带到这里" });
expect(
screen.getByLabelText<HTMLInputElement>("导入 Cursor 历史").checked,
).toBe(true);
expect(imports.refresh).toHaveBeenCalledTimes(1);
expect(imports.connectForOnboarding).not.toHaveBeenCalled();
expect(imports.detect).not.toHaveBeenCalled();
});
it("translates stored discovery errors live without repeating discovery", async () => {
const imports = controller();
vi.mocked(imports.refresh).mockRejectedValue(new Error("fixture-offline"));
render(
<LanguageProvider>
<SwitchLanguage />
<OnboardingFlow state={state} imports={imports} onFinish={vi.fn()} />
</LanguageProvider>,
);
await waitFor(() =>
expect(screen.getByRole("alert").textContent).toContain(
"No se completó la detección",
),
);
fireEvent.click(screen.getByText("中文"));
await waitFor(() =>
expect(screen.getByRole("alert").textContent).toContain("位置检测未完成"),
);
expect(imports.refresh).toHaveBeenCalledTimes(1);
});
it("translates custom-location consent without granting access", async () => {
const imports = controller({ candidate_kind: "environment_override" });
render(
<LanguageProvider>
<OnboardingFlow state={state} imports={imports} onFinish={vi.fn()} />
</LanguageProvider>,
);
await waitFor(() =>
expect(
screen.getByLabelText("Importar el historial de Cursor"),
).toHaveProperty("disabled", false),
);
fireEvent.click(screen.getByLabelText("Importar el historial de Cursor"));
expect(
screen.getByRole("dialog", { name: "Confirmar ubicación personalizada" }),
).toBeTruthy();
expect(
screen.getByLabelText(
"Confirmo que esta es la ubicación del historial de Cursor",
),
).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Cancelar" }));
expect(imports.connectForOnboarding).not.toHaveBeenCalled();
});
it("translates import review and preserves independent permission disclosure", async () => {
const change = vi.fn();
render(
<LanguageProvider>
<FirstImportReview
sources={[{ ...source, authorized: true }]}
sourceIds={[source.source_id]}
jobs={[]}
onContinue={change}
/>
</LanguageProvider>,
);
expect(
await screen.findByRole("heading", {
name: "Tus conversaciones llegan; tus memorias empiezan aquí",
}),
).toBeTruthy();
expect(
screen.getByText(
"Importar no concede automáticamente acceso de lectura a las aplicaciones. Las memorias comenzarán a generarse cuando configures un modelo.",
),
).toBeTruthy();
expect(
screen.getByRole("region", {
name: "Progreso de importación de las aplicaciones",
}),
).toBeTruthy();
expect(change).not.toHaveBeenCalled();
});
it("uses compact Spanish actions and preserves Chinese copy and permissions", async () => {
const next = vi.fn();
const draft = vi.fn();
render(
<LanguageProvider>
<SwitchLanguage />
<FirstImportReview
sources={[{ ...source, authorized: true }]}
sourceIds={[source.source_id]}
jobs={[]}
connectionDraft={{ hostSourceIds: [] }}
onConnectionDraftChange={draft}
onContinue={next}
/>
</LanguageProvider>,
);
const navigation = screen.getByRole("navigation");
expect(navigation.textContent).toContain("Conectar apps");
expect(
screen.getByRole("button", {
name: "Configurar un modelo para crear memorias",
}),
).toBeTruthy();
expect(screen.getByText(/Importar no concede automáticamente/)).toBeTruthy();
expect(
screen.getByText(/Solo para las apps seleccionadas y compatibles/),
).toBeTruthy();
expect(screen.getByText(/Si una falla, las demás siguen/)).toBeTruthy();
fireEvent.click(screen.getByText("中文"));
expect(
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
).toBeTruthy();
expect(navigation.textContent).toContain("连接应用");
expect(screen.getByText(/不会自动开放应用读取权限/)).toBeTruthy();
expect(next).not.toHaveBeenCalled();
expect(draft).not.toHaveBeenCalled();
});
it.each([
["pending", "Importando"],
["running", "Importando"],
["paused", "En pausa"],
["interrupted", "Interrumpida"],
["failed", "Problema de importación"],
["cancelled", "Cancelada"],
["completed", "Importación completada"],
] as const)(
"translates %s progress receipts without changing counts",
async (status, label) => {
const job = {
job_id: "fixture-job",
source_id: "cursor",
status,
stored: 17,
errors: 2,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:01:00Z",
} as ImportJob;
render(
<LanguageProvider>
<R6ImportProgress
sourceIds={["cursor"]}
sources={[{ ...source, authorized: true }]}
jobs={[job]}
/>
</LanguageProvider>,
);
expect(
await screen.findByRole("article", {
name: "Progreso de importación de Cursor",
}),
).toBeTruthy();
expect(screen.getByText("Mensajes guardados: 17")).toBeTruthy();
expect(screen.getByRole("status").textContent).toBe(
`${label} · Errores de lectura: 2`,
);
},
);
it.each(["es", "zh-CN"] as const)(
"preserves three multiline progress cards and receipt statuses in %s",
(locale) => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
const sources = ["cursor", "claude-code", "codex"].map((id, i) => ({
...source,
source_id: id,
display_name: `${locale === "es" ? "Aplicación con nombre largo" : "名称较长的应用"} ${i + 1}`,
authorized: true,
}));
const jobs = sources.map((item, i) => ({
job_id: `fixture-${i}`,
source_id: item.source_id,
status: ["completed", "failed", "paused"][i],
stored: 12345,
errors: i,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:01:00Z",
})) as ImportJob[];
render(
<LanguageProvider>
<R6ImportProgress
sourceIds={sources.map((item) => item.source_id)}
sources={sources}
jobs={jobs}
/>
</LanguageProvider>,
);
expect(screen.getAllByRole("article")).toHaveLength(3);
const statuses = screen
.getAllByRole("status")
.map((item) => item.textContent);
expect(statuses).toEqual(
locale === "es"
? [
"Importación completada",
"Problema de importación · Errores de lectura: 1",
"En pausa · Errores de lectura: 2",
]
: ["导入完成", "导入遇到问题 · 1 条读取异常", "已暂停 · 2 条读取异常"],
);
},
);
it("translates completion recovery live without repeating completion writes", async () => {
vi.mocked(completeOnboarding).mockRejectedValueOnce(
new Error("fixture-offline"),
);
const imports = controller();
const finish = vi.fn();
render(
<LanguageProvider>
<SwitchLanguage />
<OnboardingFlow state={state} imports={imports} onFinish={finish} />
</LanguageProvider>,
);
await waitFor(() =>
expect(
screen.getByRole("button", { name: "Entrar y añadir más tarde" }),
).toHaveProperty("disabled", false),
);
fireEvent.click(
screen.getByRole("button", { name: "Entrar y añadir más tarde" }),
);
expect(
await screen.findByRole("button", { name: "Reintentar guardar y entrar" }),
).toBeTruthy();
expect(screen.getByRole("alert").textContent).toContain(
"aún no está confirmada",
);
fireEvent.click(screen.getByText("中文"));
expect(
await screen.findByRole("button", { name: "重试保存并进入" }),
).toBeTruthy();
expect(completeOnboarding).toHaveBeenCalledTimes(1);
expect(finish).not.toHaveBeenCalled();
});
it("translates model configuration, validation and advanced fields without activating permissions", async () => {
render(
<LanguageProvider>
<SwitchLanguage />
<OnboardingModelController sourceNames={["Cursor"]} onBack={vi.fn()} />
</LanguageProvider>,
);
expect(
await screen.findByRole("heading", {
name: "Elige un modelo para crear memorias",
}),
).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Probar conexión" }));
expect(await screen.findByText("Introduce una API Key.")).toBeTruthy();
expect(screen.getByLabelText("Tiempo de espera (segundos)")).toBeTruthy();
expect(models.create).not.toHaveBeenCalled();
expect(models.activate).not.toHaveBeenCalled();
await act(async () => fireEvent.click(screen.getByText("中文")));
expect(screen.getByText("请填写 API Key。")).toBeTruthy();
expect(screen.getByLabelText("超时(秒)")).toBeTruthy();
});
it.each([
[
"completed",
null,
"Mensajes examinados: 5; no hay conversaciones para guardar",
"已扫描 5 条,本次没有可保存的对话",
],
[
"completed",
"2026-01-01T00:00:00Z",
"Mensajes examinados: 5; no hay conversaciones dentro del periodo elegido",
"已扫描 5 条,本次没有符合时间范围的对话",
],
[
"running",
"2026-01-01T00:00:00Z",
"Mensajes examinados: 5; filtrando los últimos 3 días",
"已扫描 5 条,正在筛选最近 3 天",
],
[
"running",
null,
"Mensajes examinados: 5; filtrando",
"已扫描 5 条,正在筛选",
],
[
"paused",
null,
"Mensajes examinados: 5; aún sin guardar",
"已扫描 5 条,尚未保存",
],
] as const)(
"words a %s scan with history_after=%s in both languages",
async (status, historyAfter, spanish, chinese) => {
const job = {
job_id: "fixture-job",
source_id: "cursor",
status,
stored: 0,
discovered: 5,
errors: 0,
history_after: historyAfter,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:01:00Z",
} as unknown as ImportJob;
for (const [locale, expected] of [
["es", spanish],
["zh-CN", chinese],
] as const) {
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
const view = render(
<LanguageProvider>
<R6ImportProgress
sourceIds={["cursor"]}
sources={[{ ...source, authorized: true }]}
jobs={[job]}
/>
</LanguageProvider>,
);
expect(await screen.findByText(expected)).toBeTruthy();
view.unmount();
}
},
);
@@ -261,7 +261,7 @@ it("keeps the single-source submit frozen when another source arrives during fin
it("empty discovery shows zero and allows explicit entry without a connection request", async () => {
await ready();
expect(count()).toBe("0");
await waitFor(() => expect(count()).toBe("0"));
expect(screen.getByRole("button", { name: "开始导入" })).toHaveProperty(
"disabled",
true,
+95 -58
View File
@@ -29,6 +29,7 @@ import {
hostWriteReason,
} from "./host-state";
import { OnboardingModelController } from "./OnboardingModelController";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6ColdStartFrame } from "./R6ColdStartFrame";
import { R6Button } from "./R6Controls";
import { traceUiAction } from "./ui-trace";
@@ -63,6 +64,7 @@ export function OnboardingFlow({
imports: ImportSourcesController;
onFinish(destination: OnboardingDestination): void;
}) {
const { text, t } = useOnboardingTranslation();
const [sources, setSources] = useState(imports.sources);
const sourceGeneration = useRef({ value: imports.sources, epoch: 0 });
if (sourceGeneration.current.value !== imports.sources) {
@@ -99,7 +101,21 @@ export function OnboardingFlow({
"idle" | "applying" | "applied" | "partial" | "failed"
>("idle");
const [applyTick, setApplyTick] = useState(0);
const [connectionNote, setConnectionNote] = useState("");
const [connectionNote, setConnectionNote] = useState<
string | { success: number; failures: [string, string | null][] }
>("");
const renderedConnectionNote =
typeof connectionNote === "string"
? text(connectionNote)
: t("onboardingConnectionPartial", {
success: connectionNote.success,
failed: connectionNote.failures.length,
names: connectionNote.failures
.map(([name, reason]) =>
reason ? `${name}(${text(reason)})` : name,
)
.join("、"),
});
const flight = useRef(false);
const connectionAttempt = useRef<string | null>(null);
const connectionQueue = useRef(Promise.resolve());
@@ -244,12 +260,10 @@ export function OnboardingFlow({
}
if (failed.size && alive.current) {
connectionQueueFailed.current = true;
const names = [...failed].map(([name, reason]) =>
reason ? `${name}(${reason})` : name,
);
setConnectionNote(
`${apps.size - failed.size} 个应用已接入,${failed.size} 个未完成:${names.join("、")}。可进入应用后单独处理。`,
);
setConnectionNote({
success: apps.size - failed.size,
failures: [...failed],
});
}
try {
await refreshImports();
@@ -637,7 +651,7 @@ export function OnboardingFlow({
}}
connectionExecutionAvailable={connectionAvailable}
hostConnectionAvailable={hostConnectionAvailable}
connectionNote={connectionNote}
connectionNote={renderedConnectionNote}
acceptedJobIds={accepted?.jobs.map((job) => job.job_id)}
onChangeJob={imports.changeJob}
sourceIds={sourceIds}
@@ -646,7 +660,7 @@ export function OnboardingFlow({
failedSources={accepted?.failed_sources}
sourceSnapshot={imports.sourceSnapshot}
busy={busy === "leaving"}
error={error}
error={error && text(error)}
onContinue={(destination) => {
if (busy === "leaving") return;
const selection = {
@@ -710,8 +724,8 @@ export function OnboardingFlow({
return (
<R6ColdStartFrame
step={1}
title="把熟悉的背景,带到这里"
eyebrow="你的记忆,从这里接上"
title={text("把熟悉的背景,带到这里")}
eyebrow={text("你的记忆,从这里接上")}
footer={
<>
<R6Button
@@ -719,20 +733,22 @@ export function OnboardingFlow({
disabled={busy !== null}
onClick={() => finishOnce(exitDestination)}
>
{exitDestination === "sources" ? "仍进入应用页" : "仍进入工作台"}
{text(
exitDestination === "sources" ? "仍进入应用页" : "仍进入工作台",
)}
</R6Button>
<R6Button
variant="primary"
disabled={busy !== null}
onClick={() => void leave(exitDestination)}
>
重试保存并进入
{text("重试保存并进入")}
</R6Button>
</>
}
>
<p className="onboarding-r6-error" role="alert">
{error}
{error && text(error)}
</p>
</R6ColdStartFrame>
);
@@ -740,8 +756,8 @@ export function OnboardingFlow({
return (
<R6ColdStartFrame
step={1}
title="把熟悉的背景,带到这里"
eyebrow="你的记忆,从这里接上"
title={text("把熟悉的背景,带到这里")}
eyebrow={text("你的记忆,从这里接上")}
footer={
<>
<R6Button
@@ -749,11 +765,11 @@ export function OnboardingFlow({
disabled={locked}
onClick={() => void leave("sources")}
>
稍后添加,先进入
{text("稍后添加,先进入")}
</R6Button>
{error && (
<p className="onboarding-r6-error" role="alert">
{error}
{error && text(error)}
</p>
)}
<R6Button
@@ -762,7 +778,7 @@ export function OnboardingFlow({
disabled={locked || count === 0}
onClick={() => void startImport()}
>
{busy === "submitting" ? "正在开始导入…" : "开始导入"}
{text(busy === "submitting" ? "正在开始导入…" : "开始导入")}
</R6Button>
</>
}
@@ -773,33 +789,35 @@ export function OnboardingFlow({
aria-labelledby="cold-start-phase"
>
<div className="onboarding-r6-intro">
<p>不用从头介绍自己。从常用的 App 开始,把已有背景带过来。</p>
<p>确认前只检查应用信息,不读取对话,也不安装连接。</p>
<p>
{text("不用从头介绍自己。从常用的 App 开始,把已有背景带过来。")}
</p>
<p>{text("确认前只检查应用信息,不读取对话,也不安装连接。")}</p>
</div>
<div className="onboarding-r6-list-heading">
<h2 id="cold-start-phase" ref={heading} tabIndex={-1}>
本机 AI 应用
{text("本机 AI 应用")}
</h2>
<button
type="button"
className="onboarding-r6-quiet"
aria-label="重新检测位置"
aria-label={text("重新检测位置")}
disabled={locked}
onClick={() => void checkLocations()}
>
{busy === "checking" ? "检测中…" : "重新检测"}
{text(busy === "checking" ? "检测中…" : "重新检测")}
</button>
</div>
<SourceList
className="onboarding-r6-list"
aria-label="候选应用"
aria-label={text("候选应用")}
aria-busy={busy === "checking"}
count={visibleApps.length}
status={
locationCheck === "checking"
? "正在检测其他应用…"
? text("正在检测其他应用…")
: locationCheck === "failed"
? "检测未完成"
? text("检测未完成")
: undefined
}
selection={
@@ -809,7 +827,7 @@ export function OnboardingFlow({
disabled={locked || allSelected || readableApps.length === 0}
onClick={selectAllSources}
>
{allSelected ? "已全选" : "全选"}
{text(allSelected ? "已全选" : "全选")}
</button>
}
>
@@ -818,7 +836,9 @@ export function OnboardingFlow({
<label>
<input
type="checkbox"
aria-label={`导入 ${visibleApps[0].display_name} 历史`}
aria-label={t("onboardingImportHistory", {
name: visibleApps[0].display_name,
})}
checked
disabled
readOnly
@@ -826,11 +846,11 @@ export function OnboardingFlow({
<R6SourceIcon id={visibleApps[0].source_id} />
<span className="onboarding-r6-row-info">
<strong>{visibleApps[0].display_name}</strong>
<small>正在核对本机保存位置</small>
<small>{text("正在核对本机保存位置")}</small>
</span>
<span className="onboarding-r6-badge" data-found="true">
<i aria-hidden="true" />
已发现
{text("已发现")}
</span>
</label>
</li>
@@ -842,6 +862,7 @@ export function OnboardingFlow({
);
const note =
locationCheck === "ready" ? locationNote(item) : null;
const translatedNote = note ? text(note) : null;
return (
<li
className={`onboarding-r6-row${checked ? " is-selected" : ""}`}
@@ -853,7 +874,9 @@ export function OnboardingFlow({
<label>
<input
type="checkbox"
aria-label={`导入 ${item.display_name} 历史`}
aria-label={t("onboardingImportHistory", {
name: item.display_name,
})}
checked={checked}
disabled={
locked || locationCheck !== "ready" || !canRead(item)
@@ -865,13 +888,13 @@ export function OnboardingFlow({
<R6SourceIcon id={item.source_id} />
<span className="onboarding-r6-row-info">
<strong>{item.display_name}</strong>
<small title={note ?? item.candidate_path}>
{item.candidate_path || "位置待核对"} ·{" "}
{pathKind(item)}
<small title={translatedNote ?? item.candidate_path}>
{item.candidate_path || text("位置待核对")} ·{" "}
{text(pathKind(item))}
</small>
{itemErrors[item.source_id] && (
<small className="onboarding-r6-error">
{failureLabel(itemErrors[item.source_id])}
{failureLabel(itemErrors[item.source_id], text, t)}
</small>
)}
</span>
@@ -881,10 +904,10 @@ export function OnboardingFlow({
locationCheck === "ready" &&
item.location_state === "found"
}
title={note ?? undefined}
title={translatedNote ?? undefined}
>
<i aria-hidden="true" />
{locationLabel(item, locationCheck)}
{text(locationLabel(item, locationCheck))}
</span>
</label>
</li>
@@ -907,28 +930,30 @@ export function OnboardingFlow({
{!visibleApps.length && busy !== "checking" && (
<li className="onboarding-r6-empty">
{locationCheck === "ready"
? "当前没有候选来源。可以稍后在应用页添加历史包。"
: "候选来源尚未加载。可以检测位置,或先进入工作台。"}
? text("当前没有候选来源。可以稍后在应用页添加历史包。")
: text("候选来源尚未加载。可以检测位置,或先进入工作台。")}
</li>
)}
</SourceList>
</section>
<aside className="onboarding-r6-summary" aria-label="本次导入">
<p>本次导入</p>
<aside className="onboarding-r6-summary" aria-label={text("本次导入")}>
<p>{text("本次导入")}</p>
<p
className="onboarding-r6-count"
aria-live="polite"
data-unknown={locationCheck !== "ready"}
>
{summaryCount}
{text(summaryCount)}
</p>
<p>个 App 的历史对话</p>
<p>{text("个 App 的历史对话")}</p>
<h2>
把零散的会话,
{text("把零散的会话,")}
<br />
汇成完整的记忆
{text("汇成完整的记忆")}
</h2>
<p className="onboarding-r6-signoff">少一些重复交代,多一些理解。</p>
<p className="onboarding-r6-signoff">
{text("少一些重复交代,多一些理解。")}
</p>
</aside>
</div>
{custom && (
@@ -966,6 +991,7 @@ function SourceList({
status?: string;
selection: ReactNode;
}) {
const { t, tnPlain } = useOnboardingTranslation();
const list = useRef<HTMLUListElement>(null);
const [more, setMore] = useState(0);
const measure = useCallback(() => {
@@ -1014,9 +1040,9 @@ function SourceList({
<span data-scroll-hint={!status && more > 0 ? "true" : "false"}>
{status ??
(more > 0
? `向下查看更多 · 还有 ${more} 个应用`
? tnPlain("onboardingMoreApps", more)
: count > 3
? `已显示全部 ${count} 个应用`
? t("onboardingAllApps", { count })
: "")}
</span>
{selection}
@@ -1114,7 +1140,11 @@ function locationNote(source: SourceResponse) {
return null;
}
function failureLabel(reason: string) {
function failureLabel(
reason: string,
text: (value: string) => string,
t: ReturnType<typeof useOnboardingTranslation>["t"],
) {
const labels: Record<string, string> = {
source_unavailable: "来源目录暂时不可读取,请核对位置和权限。",
source_not_available: "来源目录暂时不可读取,请核对位置和权限。",
@@ -1124,7 +1154,9 @@ function failureLabel(reason: string) {
environment_override_confirmation_required: "自定义来源位置需要重新确认。",
imports_disabled: "当前 Core 未启用历史导入。",
};
return labels[reason] ?? `尚未获准导入(${reason})。请核对来源后重试。`;
return labels[reason]
? text(labels[reason])
: t("onboardingImportRefused", { reason });
}
// S1.3 supplied app marks; WorkBuddy keeps the source geometry with flat fills.
@@ -1149,6 +1181,7 @@ function CustomLocationDialog({
onCancel(): void;
onConfirm(): void;
}) {
const { text, t } = useOnboardingTranslation();
const [confirmed, setConfirmed] = useState(false);
const panel = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
@@ -1233,12 +1266,12 @@ function CustomLocationDialog({
tabIndex={-1}
>
<header>
<h2 id="cold-start-custom-title">确认自定义位置</h2>
<h2 id="cold-start-custom-title">{text("确认自定义位置")}</h2>
<button
type="button"
className="cold-start-icon-button"
aria-label="关闭位置确认"
title="关闭位置确认"
aria-label={text("关闭位置确认")}
title={text("关闭位置确认")}
onClick={onCancel}
>
<X size={18} aria-hidden="true" />
@@ -1246,7 +1279,9 @@ function CustomLocationDialog({
</header>
<div className="cold-start-dialog-body">
<p id="cold-start-custom-description">
此位置来自当前配置,并非应用默认目录。确认位置不会开始读取历史。
{text(
"此位置来自当前配置,并非应用默认目录。确认位置不会开始读取历史。",
)}
</p>
<strong>{source.display_name}</strong>
<code>{source.candidate_path}</code>
@@ -1256,7 +1291,9 @@ function CustomLocationDialog({
checked={confirmed}
onChange={(event) => setConfirmed(event.target.checked)}
/>
<span>确认这是 {source.display_name} 的历史位置</span>
<span>
{t("onboardingConfirmHistory", { name: source.display_name })}
</span>
</label>
</div>
<footer>
@@ -1265,7 +1302,7 @@ function CustomLocationDialog({
className="cold-start-secondary"
onClick={onCancel}
>
取消
{text("取消")}
</button>
<button
type="button"
@@ -1275,7 +1312,7 @@ function CustomLocationDialog({
}}
>
<Check size={16} aria-hidden="true" />
确认此位置
{text("确认此位置")}
</button>
</footer>
</div>
@@ -9,6 +9,7 @@ import {
type OnboardingModelDraft,
OnboardingModelStep,
} from "./OnboardingModelStep";
import { useOnboardingTranslation } from "./onboarding-language";
import { PROVIDER_PRESETS } from "./provider-presets";
import { traceUiAction } from "./ui-trace";
import { useModelProfiles } from "./use-model-profiles";
@@ -65,6 +66,7 @@ export function OnboardingModelController({
onEnabled?(): void;
connectionAlreadyApplied?: boolean;
}) {
const { text, t } = useOnboardingTranslation();
const models = useModelProfiles();
const [draft, setDraft] = useState(initial);
const [advanced, setAdvanced] = useState(false);
@@ -119,7 +121,7 @@ export function OnboardingModelController({
value < min ||
value > max
)
next[key] = `请输入 ${min} 至 ${max} 的整数。`;
next[key] = "onboardingIntegerRange";
return value;
};
const timeout = integer("timeout_seconds", 1, 600);
@@ -269,8 +271,22 @@ export function OnboardingModelController({
canTest={!models.pendingProbe && !models.probeStorageFailed}
canSaveDraft={!models.pendingCreation && !models.creationStorageFailed}
inputsLocked={flight.current}
fieldErrors={fields}
error={error ?? models.error}
fieldErrors={Object.fromEntries(
Object.entries(fields).map(([key, value]) => [
key,
value === "onboardingIntegerRange"
? t(
"onboardingIntegerRange",
key === "timeout_seconds"
? { min: 1, max: 600 }
: key === "max_concurrency"
? { min: 1, max: 32 }
: { min: 1024, max: 10_000_000 },
)
: text(value),
]),
)}
error={error ?? (models.error ? text(models.error) : null)}
probe={probe}
profile={
saved
@@ -9,12 +9,93 @@ import {
within,
} from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import {
OnboardingModelStep,
type OnboardingModelStepProps,
} from "./OnboardingModelStep";
afterEach(cleanup);
afterEach(() => {
cleanup();
localStorage.clear();
});
it.each(["es", "zh-CN"] as const)(
"keeps %s footer actions, pending locks and permission boundaries",
async (locale) => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
const p = props();
p.onSkip = vi.fn();
let finish!: () => void;
p.onTest = vi.fn(
() =>
new Promise<void>((resolve) => {
finish = resolve;
}),
);
const labels =
locale === "es"
? [
"Omitir por ahora",
"Probar conexión",
"Confirmar y habilitar",
"Probando el modelo…",
]
: ["跳过,先进入应用", "测试模型连通性", "确认启用", "正在测试模型…"];
const draw = (probe?: OnboardingModelStepProps["probe"]) => (
<LanguageProvider>
<OnboardingModelStep
{...p}
capability={{ saveAndEnable: true }}
probe={probe}
advancedOpen
/>
</LanguageProvider>
);
const view = render(draw());
const skip = screen.getByRole("button", { name: labels[0] });
if (locale === "es") {
expect(
document.getElementById(skip.getAttribute("aria-describedby") ?? "")
?.textContent,
).toContain("Aplicaciones");
}
fireEvent.click(skip);
expect(p.onSkip).toHaveBeenCalledOnce();
expect(p.onBack).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: labels[2] })).toHaveProperty(
"disabled",
true,
);
fireEvent.click(screen.getByRole("button", { name: labels[1] }));
expect(screen.getByRole("button", { name: labels[3] })).toHaveProperty(
"disabled",
true,
);
expect(skip).toHaveProperty("disabled", true);
expect(p.onSaveAndEnable).not.toHaveBeenCalled();
await act(async () => finish());
view.rerender(draw({ state: "failed", message: "synthetic failure" }));
expect(screen.getByRole("button", { name: labels[2] })).toHaveProperty(
"disabled",
true,
);
expect(screen.getByLabelText("API Key")).toHaveProperty("value", "secret");
expect(p.onDraftChange).not.toHaveBeenCalled();
view.rerender(draw({ state: "passed", message: "synthetic success" }));
fireEvent.click(screen.getByRole("button", { name: labels[2] }));
await act(async () => {});
expect(p.onSaveAndEnable).toHaveBeenCalledOnce();
expect(p.onSaveDraft).not.toHaveBeenCalled();
expect(
screen.getByText(
locale === "es"
? /La prueba de conexión solo envía/
: /测试连接只发送测试内容/,
),
).toBeTruthy();
},
);
function props(): OnboardingModelStepProps {
return {
draft: {
+132 -81
View File
@@ -2,6 +2,7 @@ import type { ProviderKind } from "@one-memory/contracts";
import { ChevronDown, ChevronUp } from "lucide-react";
import { useEffect, useId, useRef, useState } from "react";
import { OptionMenu } from "./OptionMenu";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6ColdStartFrame } from "./R6ColdStartFrame";
import { R6Button } from "./R6Controls";
import "./onboarding-model-r6.css";
@@ -71,8 +72,13 @@ export interface OnboardingModelStepProps {
}
export function OnboardingModelStep(props: OnboardingModelStepProps) {
const { text, t, locale } = useOnboardingTranslation();
const { draft, advancedOpen, onDraftChange, fieldErrors = {}, phase } = props;
const id = useId();
// The Chinese label already names the destination; shorter translated labels
// need it as a description for assistive technology.
const skipDestination =
locale === "zh-CN" ? "" : t("onboardingModelSkipDestination");
const fields = useRef<HTMLElement>(null);
const advancedToggle = useRef<HTMLButtonElement>(null);
const restoreToggleFocus = useRef(false);
@@ -113,7 +119,10 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
const sourceSummary =
props.sourceNames.length <= 3
? props.sourceNames.join(" · ")
: `${props.sourceNames.slice(0, 3).join(" · ")} 等 ${props.sourceNames.length} 个应用`;
: t("onboardingSourcesSummary", {
names: props.sourceNames.slice(0, 3).join(" · "),
count: props.sourceNames.length,
});
async function run(action: Action) {
if (flight.current || locked) return;
if (action === "save-and-enable" && !canEnable) return;
@@ -153,11 +162,11 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
const error = fieldErrors[key];
return (
<label className="onboarding-model-field" htmlFor={`${id}-${key}`}>
<span>{label}</span>
<span>{text(label)}</span>
<input
id={`${id}-${key}`}
name={key}
aria-label={label}
aria-label={text(label)}
type={options.type ?? "text"}
autoComplete="off"
spellCheck={false}
@@ -169,7 +178,9 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
max={options.max}
maxLength={options.maxLength}
required={options.required}
placeholder={options.placeholder}
placeholder={
options.placeholder ? text(options.placeholder) : undefined
}
onChange={(event) => {
if (!flight.current && !fieldsLocked)
onDraftChange({ [key]: event.target.value });
@@ -177,7 +188,7 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
/>
{error && (
<small id={`${id}-${key}-error`} className="onboarding-model-error">
{error}
{text(error)}
</small>
)}
</label>
@@ -215,43 +226,57 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
>
<R6ColdStartFrame
step={3}
eyebrow="沉淀记忆 · 使用你选择的模型"
title={pageTitle}
eyebrow={text("沉淀记忆 · 使用你选择的模型")}
title={text(pageTitle)}
footer={
<>
<p className="onboarding-model-footer-note">
{!props.capability.saveAndEnable
? "当前无法启用模型,已填写内容保留。"
: props.probe?.state === "passed"
? "模型连接测试通过,可以启用并开始沉淀记忆。"
: "先测试模型连通性;测试通过后再启用。"}
{text(
!props.capability.saveAndEnable
? "当前无法启用模型,已填写内容保留。"
: props.probe?.state === "passed"
? "模型连接测试通过,可以启用并开始沉淀记忆。"
: "先测试模型连通性;测试通过后再启用。",
)}
</p>
<div className="onboarding-model-footer-actions">
{skipDestination && (
<span id={`${id}-skip-destination`} hidden>
{skipDestination}
</span>
)}
<R6Button
aria-describedby={
skipDestination ? `${id}-skip-destination` : undefined
}
disabled={locked}
onClick={() => {
if (!flight.current && !locked)
(props.onSkip ?? props.onBack)();
}}
>
跳过,先进入应用
{text("跳过,先进入应用")}
</R6Button>
<R6Button
disabled={locked || !props.canTest}
onClick={() => void run("test")}
>
{displayedPhase === "testing"
? "正在测试模型…"
: (props.testActionLabel ?? "测试模型连通性")}
{text(
displayedPhase === "testing"
? "正在测试模型…"
: (props.testActionLabel ?? "测试模型连通性"),
)}
</R6Button>
<R6Button
variant="primary"
disabled={locked || !canEnable}
onClick={() => void run("save-and-enable")}
>
{displayedPhase === "enabling"
? "正在启用…"
: (props.enableActionLabel ?? "确认启用")}
{text(
displayedPhase === "enabling"
? "正在启用…"
: (props.enableActionLabel ?? "确认启用"),
)}
</R6Button>
</div>
</>
@@ -260,27 +285,29 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
<div className="onboarding-model-layout">
<section
className="onboarding-model-form"
aria-label="模型配置"
aria-label={text("模型配置")}
data-advanced={advancedOpen}
>
<section
ref={fields}
id={`${id}-fields`}
className="onboarding-model-fields"
aria-label="模型参数"
aria-label={text("模型参数")}
// biome-ignore lint/a11y/noNoninteractiveTabindex: The parameter pane needs keyboard scrolling.
tabIndex={0}
>
{!advancedOpen ? (
<>
<p className="onboarding-model-intro">
选择服务商,填入 API Key。核对已有配置后再继续。
{text("选择服务商,填入 API Key。核对已有配置后再继续。")}
</p>
<label
className="onboarding-model-field onboarding-model-provider"
htmlFor={`${id}-provider`}
>
<span id={`${id}-provider-label`}>模型服务商</span>
<span id={`${id}-provider-label`}>
{text("模型服务商")}
</span>
<OptionMenu
id={`${id}-provider`}
labelledBy={`${id}-provider-label`}
@@ -288,11 +315,11 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
disabled={fieldsLocked}
options={[
...(!provider
? [{ value: "", label: "选择服务商" }]
? [{ value: "", label: text("选择服务商") }]
: []),
...props.providers.map((item) => ({
value: item.id,
label: item.label,
label: text(item.label),
disabled: !item.supported,
})),
]}
@@ -317,13 +344,15 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
className="onboarding-model-key-note"
data-state={connectionFailed ? "failed" : "idle"}
>
{connectionFailed
? "连接未通过,请检查 API Key、模型与接口地址后重试。配置已保留。"
: props.profile?.credential_state === "unavailable"
? "需要允许 MemHub 访问钥匙串。系统询问时请选择“始终允许”。"
: props.profile?.has_configured_api_key
? "留空保留已保存密钥;填写新密钥后需重新测试。"
: "密钥保存在系统安全存储中,不写入对话或日志。"}
{text(
connectionFailed
? "连接未通过,请检查 API Key、模型与接口地址后重试。配置已保留。"
: props.profile?.credential_state === "unavailable"
? "需要允许 MemHub 访问钥匙串。系统询问时请选择“始终允许”。"
: props.profile?.has_configured_api_key
? "留空保留已保存密钥;填写新密钥后需重新测试。"
: "密钥保存在系统安全存储中,不写入对话或日志。",
)}
</p>
<button
ref={advancedToggle}
@@ -334,24 +363,27 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
disabled={fieldsLocked}
onClick={() => changeAdvanced(true)}
>
配置详情与高级设置{" "}
{text("配置详情与高级设置")}{" "}
<ChevronDown size={16} aria-hidden="true" />
</button>
<p className="onboarding-model-profile">
{provider?.label ?? "服务商待选择"} ·{" "}
{draft.model || "模型待填写"}
{provider ? text(provider.label) : text("服务商待选择")} ·{" "}
{draft.model || text("模型待填写")}
<br />
{safeEndpoint(draft.base_url)} ·{" "}
{text(safeEndpoint(draft.base_url))} ·{" "}
{draft.embedding_mode === "local"
? "本地向量模型"
: draft.embedding_model || "向量模型待填写"}{" "}
· {draft.disable_thinking ? "思考已关闭" : "思考未关闭"}
? text("本地向量模型")
: draft.embedding_model || text("向量模型待填写")}{" "}
·{" "}
{text(draft.disable_thinking ? "思考已关闭" : "思考未关闭")}
</p>
</>
) : (
<>
<div className="onboarding-model-advanced-head">
<p>预设已填好,只有自定义服务或特殊需求才需要修改。</p>
<p>
{text("预设已填好,只有自定义服务或特殊需求才需要修改。")}
</p>
<button
ref={advancedToggle}
type="button"
@@ -361,7 +393,8 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
disabled={fieldsLocked}
onClick={() => changeAdvanced(false)}
>
返回服务商选择 <ChevronUp size={16} aria-hidden="true" />
{text("返回服务商选择")}{" "}
<ChevronUp size={16} aria-hidden="true" />
</button>
</div>
<div className="onboarding-model-advanced-grid">
@@ -390,15 +423,17 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
className="onboarding-model-key-note"
data-state={connectionFailed ? "failed" : "idle"}
>
{props.profile?.has_configured_api_key
? "留空保留已保存密钥;填写新密钥后需重新测试。"
: "密钥保存在系统安全存储中,不写入对话或日志。"}
{text(
props.profile?.has_configured_api_key
? "留空保留已保存密钥;填写新密钥后需重新测试。"
: "密钥保存在系统安全存储中,不写入对话或日志。",
)}
</p>
</div>
<label className="onboarding-model-field">
<span>协议</span>
<span>{text("协议")}</span>
<select
aria-label="协议"
aria-label={text("协议")}
value={draft.provider_kind}
disabled={fieldsLocked}
onChange={(e) => {
@@ -413,17 +448,17 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
</option>
<option value="ollama">Ollama</option>
<option value="anthropic" disabled>
Anthropic(暂不支持)
{text("Anthropic(暂不支持)")}
</option>
<option value="hy_default" disabled>
HY 托管(当前不可用)
{text("HY 托管(当前不可用)")}
</option>
</select>
</label>
<label className="onboarding-model-field">
<span>向量模型</span>
<span>{text("向量模型")}</span>
<select
aria-label="向量模型"
aria-label={text("向量模型")}
value={draft.embedding_mode}
disabled={fieldsLocked}
onChange={(e) => {
@@ -435,8 +470,10 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
});
}}
>
<option value="local">本地 ONNX · 内置</option>
<option value="custom">自定义向量</option>
<option value="local">
{text("本地 ONNX · 内置")}
</option>
<option value="custom">{text("自定义向量")}</option>
</select>
</label>
<div className="onboarding-model-numbers">
@@ -469,12 +506,14 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
});
}}
/>
关闭思考模式
{text("关闭思考模式")}
</label>
<p className="onboarding-model-vector-note">
自定义向量配置可展开;远程向量服务须遵守凭据边界。
{text(
"自定义向量配置可展开;远程向量服务须遵守凭据边界。",
)}
<br />
切换服务商或模型后,需要重新测试并启用。
{text("切换服务商或模型后,需要重新测试并启用。")}
</p>
{draft.embedding_mode === "custom" && (
<>
@@ -483,9 +522,9 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
maxLength: 256,
})}
<label className="onboarding-model-field">
<span>向量协议</span>
<span>{text("向量协议")}</span>
<select
aria-label="向量协议"
aria-label={text("向量协议")}
value={draft.embedding_provider_kind}
disabled={fieldsLocked}
onChange={(e) => {
@@ -498,9 +537,11 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
}}
>
<option value="openai_compatible">
同源 OpenAI-compatible(共用 Key)
{text("同源 OpenAI-compatible(共用 Key)")}
</option>
<option value="ollama">
{text("本机 Ollama")}
</option>
<option value="ollama">本机 Ollama</option>
</select>
</label>
<div className="onboarding-model-wide">
@@ -509,7 +550,9 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
required: true,
})}
<small>
远程向量地址须与生成服务同源;本机地址仍需校验。
{text(
"远程向量地址须与生成服务同源;本机地址仍需校验。",
)}
</small>
</div>
</>
@@ -524,7 +567,7 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
disabled={locked}
onClick={() => void props.onRecoverCreation?.()}
>
核对创建结果
{text("核对创建结果")}
</R6Button>
)}
{props.onDiscardMissingCreation && (
@@ -533,17 +576,19 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
disabled={locked}
onClick={() => void props.onDiscardMissingCreation?.()}
>
放弃旧记录,重新配置
{text("放弃旧记录,重新配置")}
</R6Button>
)}
</div>
<div className="onboarding-model-feedback" tabIndex={-1}>
{(displayedPhase === "testing" ||
displayedPhase === "enabling") && (
<p role="status" aria-label="模型提交状态">
{displayedPhase === "testing"
? "正在测试连接…"
: "正在提交保存与启用,请等待回执…"}
<p role="status" aria-label={text("模型提交状态")}>
{text(
displayedPhase === "testing"
? "正在测试连接…"
: "正在提交保存与启用,请等待回执…",
)}
</p>
)}
{props.probe && (
@@ -552,54 +597,60 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
data-state={props.probe.state}
role="status"
>
{props.probe.message}
{text(props.probe.message)}
</p>
)}
{(props.error || callbackFailed) && (
<p className="onboarding-model-error" role="alert">
{props.error ||
"操作结果未确认,已填写内容保留,请核对后重试。"}
{text(
props.error ||
"操作结果未确认,已填写内容保留,请核对后重试。",
)}
</p>
)}
</div>
</section>
<aside
className="onboarding-model-summary"
aria-label="本次沉淀"
aria-label={text("本次沉淀")}
data-full-height={advancedOpen || connectionFailed}
// biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users must be able to scroll the full permission disclosure.
tabIndex={0}
>
<div className="onboarding-model-summary-heading">
<p>本次沉淀</p>
<h2>让记忆从这里开始</h2>
<p>{text("本次沉淀")}</p>
<h2>{text("让记忆从这里开始")}</h2>
</div>
<div className="onboarding-model-connections">
<p>对话来源</p>
<strong>{sourceSummary || "尚无已选来源"}</strong>
<p>{text("对话来源")}</p>
<strong>{sourceSummary || text("尚无已选来源")}</strong>
<small>
已导入 {props.importedConversationCount ?? 0}{" "}
条对话;来源开关继续控制之后同步到本机的内容。
{t("onboardingImportedConversations", {
count: props.importedConversationCount ?? 0,
})}
</small>
</div>
<div className="onboarding-model-summary-status">
{connectionReceipt && (
<small className="onboarding-model-connection-receipt">
{connectionReceipt}
{text(connectionReceipt)}
</small>
)}
<small>
处理服务:{provider?.label ?? "待选择"}
{text("处理服务:")}
{text(provider?.label ?? "待选择")}
<br />
相关对话会发送给所选服务商。
{text("相关对话会发送给所选服务商。")}
</small>
<small>
测试连接只发送测试内容。启用后会使用此模型整理已导入和之后同步的会话。
{text(
"测试连接只发送测试内容。启用后会使用此模型整理已导入和之后同步的会话。",
)}
</small>
</div>
{!props.capability.saveAndEnable && (
<p role="status">
{props.capability.reason || "当前不能在此启用模型。"}
{text(props.capability.reason || "当前不能在此启用模型。")}
</p>
)}
</aside>
+3 -1
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { useGuiLanguage } from "./gui-language";
export interface OptionMenuItem {
value: string;
@@ -18,6 +19,7 @@ export function OptionMenu(props: {
options: readonly OptionMenuItem[];
onChange: (value: string) => void;
}) {
const { t } = useGuiLanguage();
const [open, setOpen] = useState(false);
const root = useRef<HTMLDivElement>(null);
const selected = props.options.find((item) => item.value === props.value);
@@ -60,7 +62,7 @@ export function OptionMenu(props: {
}
}}
>
{selected?.label ?? "请选择"}
{selected?.label ?? t("wb.select")}
</button>
{open && (
<div className="om-option-menu-panel" role="listbox">
@@ -0,0 +1,153 @@
// @vitest-environment jsdom
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it } from "vitest";
import { LanguageProvider } from "./gui-language";
import { R6ColdStartFrame } from "./R6ColdStartFrame";
afterEach(() => {
cleanup();
localStorage.clear();
});
function declaration(file: string, selector: string): string {
const css = readFileSync(join(process.cwd(), "src", file), "utf8");
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return css.match(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`))?.[1] ?? "";
}
it("lets multiline headings grow above the separator in normal flow", () => {
const head = declaration("onboarding-r6.css", ".onboarding-r6-head");
expect(head).toMatch(/flex:\s*0 0 auto/);
expect(head).toMatch(/display:\s*flex/);
expect(head).toMatch(/flex-wrap:\s*wrap/);
expect(
declaration("onboarding-r6.css", ".onboarding-r6-head .r6-stepper"),
).not.toMatch(/position:\s*absolute/);
});
it("allows step labels to wrap in separate flexible tracks", () => {
const list = declaration("r6-controls.css", ".r6-stepper ol");
const step = declaration("r6-controls.css", ".r6-stepper li");
expect(list).toMatch(
/grid-template-columns:\s*repeat\(3, minmax\(0, 1fr\)\)/,
);
expect(step).toMatch(/white-space:\s*normal/);
expect(step).toMatch(/height:\s*auto/);
expect(step).not.toMatch(/width:\s*84px/);
});
it("sizes the import action to its label and wraps footer content", () => {
const submit = declaration("onboarding-r6.css", ".onboarding-r6-submit");
expect(submit).toMatch(/width:\s*auto/);
expect(submit).toMatch(/min-width:\s*138px/);
expect(declaration("onboarding-r6.css", ".onboarding-r6-foot")).toMatch(
/flex-wrap:\s*wrap/,
);
});
it("keeps progress cards content-sized and the step-two footer in normal flow", () => {
const card = declaration(
"r6-import-progress.css",
".r6-import-progress-card",
);
expect(card).toMatch(/min-height:\s*218px/);
expect(card).not.toMatch(/(?:^|;)\s*height:/);
expect(
declaration(
"first-import-r6.css",
".first-import-r6-main > .r6-import-progress",
),
).toMatch(/flex:\s*0 0 auto/);
expect(declaration("first-import-r6.css", ".first-import-r6-layout")).toMatch(
/height:\s*auto/,
);
expect(
declaration("first-import-r6.css", ".first-import-r6-footer-note"),
).not.toMatch(/max-height|overflow/);
expect(declaration("onboarding-r6.css", ".onboarding-r6-body")).toMatch(
/overflow-y:\s*auto/,
);
});
it("removes the duplicate native drag overlay above the language selector", () => {
expect(
declaration(
"onboarding-r6.css",
".desktop-frame:has(.onboarding-r6) > .window-drag-region",
),
).toMatch(/display:\s*none/);
expect(
declaration(
"onboarding-r6.css",
".onboarding-r6-titlebar .r6-language-selector--compact",
),
).toMatch(/-webkit-app-region:\s*no-drag/);
});
it("keeps selection status outside decorative fades and readable when disabled", () => {
expect(
declaration("onboarding-r6.css", ".onboarding-r6-list-area::after"),
).toBe("");
expect(
declaration(
"onboarding-r6.css",
".onboarding-r6-list-status .onboarding-r6-quiet:disabled",
),
).toMatch(/opacity:\s*1/);
});
it("keeps the native selector keyboard-focusable and switches both languages", async () => {
render(
<LanguageProvider>
<R6ColdStartFrame
step={1}
title="Fixture"
eyebrow="Fixture"
footer={null}
>
<p>Fixture body</p>
</R6ColdStartFrame>
</LanguageProvider>,
);
const user = userEvent.setup();
const selector = screen.getByRole("combobox");
await user.tab();
expect(document.activeElement).toBe(selector);
await user.selectOptions(selector, "es");
expect(document.documentElement.lang).toBe("es");
expect(screen.getByRole("combobox").getAttribute("id")).toBe(selector.id);
await user.selectOptions(selector, "zh-CN");
expect(document.documentElement.lang).toBe("zh-CN");
expect(screen.getByRole("combobox", { name: "界面语言" })).toBe(selector);
});
it.each([1, 2, 3] as const)(
"offers a compact language selector on step %s",
(step) => {
render(
<LanguageProvider>
<R6ColdStartFrame
step={step}
title="Fixture"
eyebrow="Fixture"
footer={null}
>
<p>Fixture body</p>
</R6ColdStartFrame>
</LanguageProvider>,
);
const selector = screen.getByRole("combobox", { name: "界面语言" });
expect(selector.closest(".r6-language-selector--compact")).toBeTruthy();
expect(
screen.getByRole("option", { name: "简体中文" }).getAttribute("lang"),
).toBe("zh-CN");
expect(
screen.getByRole("option", { name: "Español" }).getAttribute("lang"),
).toBe("es");
expect(screen.queryByRole("heading", { name: "界面语言" })).toBeNull();
expect(selector.getAttribute("aria-describedby")).toBeNull();
},
);
+7 -3
View File
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import { LanguageSelector } from "./LanguageSelector";
import { R6LocalStorage, R6Stepper } from "./R6Controls";
import "./onboarding-r6.css";
@@ -21,9 +22,12 @@ export function R6ColdStartFrame({
left of this bar and a "第一次见面" caption on the right. The user
removed both deliberately on 2026-09-20 — this is a product decision,
not a missing implementation, so do not "restore" them when comparing
against the canvas. The bar itself stays: it is the 58px window drag
region and reserves room for the native traffic lights. */}
<header className="onboarding-r6-titlebar" />
against the canvas. The bar itself stays: it is the window drag
region (at least 58px) and reserves room for native traffic lights.
The language control opts out of dragging. */}
<header className="onboarding-r6-titlebar">
<LanguageSelector compact />
</header>
<div className="onboarding-r6-stage">
<section className="onboarding-r6-shell" data-step={step}>
<header className="onboarding-r6-head">
+9 -4
View File
@@ -3,6 +3,7 @@ import {
type InputHTMLAttributes,
useId,
} from "react";
import { useOnboardingTranslation } from "./onboarding-language";
import "./r6-controls.css";
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
@@ -35,15 +36,16 @@ export function R6Button({
}
export function R6Stepper({ active }: { active: 1 | 2 | 3 }) {
const { text } = useOnboardingTranslation();
return (
<nav className="r6-stepper" aria-label="首次设置进度">
<nav className="r6-stepper" aria-label={text("首次设置进度")}>
<ol>
{["导入历史", "连接应用", "沉淀记忆"].map((label, index) => (
<li
key={label}
aria-current={active === index + 1 ? "step" : undefined}
>
<span aria-hidden="true">0{index + 1}</span> {label}
<span aria-hidden="true">0{index + 1}</span> {text(label)}
</li>
))}
</ol>
@@ -115,13 +117,16 @@ export function R6Field({
}
export function R6LocalStorage() {
const { text } = useOnboardingTranslation();
return (
<span
className="r6-local-storage"
title="对话与记忆保存在本机;使用云模型或已授权的助手时,相关内容会发送给对应服务。"
title={text(
"对话与记忆保存在本机;使用云模型或已授权的助手时,相关内容会发送给对应服务。",
)}
>
<span aria-hidden="true" />
本地存储
{text("本地存储")}
</span>
);
}
+45 -22
View File
@@ -1,21 +1,37 @@
import type { ImportJob, SourceResponse } from "@one-memory/contracts";
import { useEffect, useLayoutEffect, useRef } from "react";
import { AppMark } from "./app-mark";
import { useOnboardingTranslation } from "./onboarding-language";
import "./r6-import-progress.css";
function progressText(job: ImportJob): string | null {
if (job.stored > 0) return `已保存 ${job.stored.toLocaleString()} 条消息`;
type Translate = ReturnType<typeof useOnboardingTranslation>["t"];
function progressText(
job: ImportJob,
t: Translate,
locale: string,
): string | null {
if (job.stored > 0)
return t("onboardingSavedMessages", {
count: job.stored.toLocaleString(locale),
});
if (job.discovered <= 0) return null;
const scanned = `已扫描 ${job.discovered.toLocaleString()} 条`;
const count = job.discovered.toLocaleString(locale);
if (job.status === "completed")
return job.history_after
? `${scanned},本次没有符合时间范围的对话`
: `${scanned},本次没有可保存的对话`;
return t(
job.history_after
? "onboardingScannedOutsideRange"
: "onboardingScannedNothingToSave",
{ count },
);
if (job.status === "pending" || job.status === "running")
return job.history_after
? `${scanned},正在筛选最近 3 天`
: `${scanned},正在筛选`;
return `${scanned},尚未保存`;
return t(
job.history_after
? "onboardingScannedFilteringRecent"
: "onboardingScannedFiltering",
{ count },
);
return t("onboardingScannedNotSaved", { count });
}
export function R6ImportProgress({
@@ -29,6 +45,7 @@ export function R6ImportProgress({
jobs: ImportJob[];
stale?: boolean;
}) {
const { text, t, locale } = useOnboardingTranslation();
const scroller = useRef<HTMLElement>(null);
const userScrollLeft = useRef(0);
const userScrollIntent = useRef(false);
@@ -56,7 +73,7 @@ export function R6ImportProgress({
return (
<section
className="r6-import-progress"
aria-label="本次应用导入进度"
aria-label={text("本次应用导入进度")}
data-count={ids.length <= 2 ? String(ids.length) : "many"}
ref={scroller}
onPointerDown={markUserScrollIntent}
@@ -103,12 +120,14 @@ export function R6ImportProgress({
cancelled: "已取消",
completed: "导入完成",
};
const progress = job ? progressText(job) : null;
const progress = job ? progressText(job, t, locale) : null;
return (
<article
className="r6-import-progress-card"
key={id}
aria-label={`${source?.display_name ?? id} 导入进度`}
aria-label={t("onboardingAppProgress", {
name: source?.display_name ?? id,
})}
data-running={running ? "true" : "false"}
>
<div className="r6-import-progress-orbit" aria-hidden="true">
@@ -121,18 +140,22 @@ export function R6ImportProgress({
{progress ? (
<p>{progress}</p>
) : noImportedHistory ? (
<p>没有可保存的历史对话</p>
<p>{text("没有可保存的历史对话")}</p>
) : null}
<small role="status">
{stale
? "状态待核对"
: source?.authorized !== true
? "读取授权不可用"
: job
? names[job.status]
: "正在导入"}
{text(
stale
? "状态待核对"
: source?.authorized !== true
? "读取授权不可用"
: job
? names[job.status]
: "正在导入",
)}
{job && job.errors > 0
? ` · ${job.errors.toLocaleString()} 条读取异常`
? t("onboardingReadErrors", {
count: job.errors.toLocaleString(locale),
})
: ""}
</small>
</article>
+26
View File
@@ -5,9 +5,32 @@ import userEvent from "@testing-library/user-event";
import type { Ref } from "react";
import { afterEach, expect, it, vi } from "vitest";
import { getModelProfiles, getSedimentation, setSedimentation } from "./api";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { queryClient, resetClient } from "./query-client";
import { R6Settings } from "./R6Settings";
it("changes settings copy live without rereading profiles or changing permissions", async () => {
const user = userEvent.setup();
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
const { getModelProfiles } = await import("./api");
render(
<LanguageProvider>
<R6Settings onDiagnostics={vi.fn()} />
</LanguageProvider>,
);
expect(
await screen.findByRole("heading", { name: "Modelo de memorias" }),
).toBeTruthy();
expect(
screen.queryByRole("switch", {
name: "Permitir que el modelo configurado lea las conversaciones locales",
}),
).toBeNull();
const reads = vi.mocked(getModelProfiles).mock.calls.length;
await user.selectOptions(screen.getByRole("combobox"), "zh-CN");
expect(await screen.findByRole("heading", { name: "记忆模型" })).toBeTruthy();
expect(vi.mocked(getModelProfiles).mock.calls).toHaveLength(reads);
});
vi.mock("./api", () => ({
getModelProfiles: vi.fn().mockResolvedValue([
{
@@ -55,6 +78,7 @@ vi.mock("./diagnostics-export", () => ({
}));
afterEach(() => {
cleanup();
localStorage.clear();
resetClient();
vi.clearAllMocks();
window.oneMemory = undefined;
@@ -70,6 +94,8 @@ it("shows the help and diagnostics group by default and hides it when opted out"
} as NonNullable<typeof window.oneMemory>;
const user = userEvent.setup();
const view = render(<R6Settings onDiagnostics={vi.fn()} />);
expect(screen.getByRole("combobox")).toBeTruthy();
expect(screen.getAllByRole("option")).toHaveLength(4);
await user.click(screen.getByRole("button", { name: "帮助与诊断" }));
expect(screen.getByRole("heading", { name: "帮助与诊断" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "导出日志" })).toBeTruthy();
+62 -46
View File
@@ -11,7 +11,9 @@ import {
import { getModelProfiles, setSedimentation } from "./api";
import { ConnectionFallback } from "./ConnectionFallback";
import { exportDiagnosticsFile } from "./diagnostics-export";
import { LanguageSelector } from "./LanguageSelector";
import { ModelSettings, type ModelSettingsActions } from "./ModelSettings";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button, R6Switch } from "./R6Controls";
import {
refreshLoginPreference,
@@ -55,6 +57,7 @@ export function R6Settings({
showDiagnostics?: boolean;
}) {
const id = useId();
const { t, text, locale } = useOnboardingTranslation();
const groups = showDiagnostics
? [...primaryGroups, "help" as const]
: primaryGroups;
@@ -99,7 +102,7 @@ export function R6Settings({
setExportingLogs(true);
setExportMessage("");
try {
const result = await exportDiagnosticsFile();
const result = await exportDiagnosticsFile(locale);
setExportMessage(result.message);
} finally {
setExportingLogs(false);
@@ -263,10 +266,11 @@ export function R6Settings({
}
return (
<section className="r6-settings" aria-label="设置">
<h1>设置</h1>
<section className="r6-settings" aria-label={t("settings")}>
<h1>{t("settings")}</h1>
<LanguageSelector />
<div className="r6-settings-layout">
<nav aria-label="设置分组">
<nav aria-label={text("设置分组")}>
{groups.map((key) => (
<button
type="button"
@@ -277,7 +281,7 @@ export function R6Settings({
onKeyDown={moveGroupFocus}
onClick={() => setGroup(key)}
>
{labels[key]}
{text(labels[key])}
</button>
))}
</nav>
@@ -288,20 +292,23 @@ export function R6Settings({
hidden={group !== "model"}
className="r6-settings-model"
>
<h2>记忆模型</h2>
<h2>{text("记忆模型")}</h2>
<div className="r6-model-overview">
<p className="r6-model-caption">当前模型</p>
<p className="r6-model-caption">{text("当前模型")}</p>
<h3 className="r6-current-model">
{currentModel?.name ??
(profiles === null
? "正在读取模型…"
: modelError
? "模型状态暂不可读"
: "尚未配置模型")}
text(
profiles === null
? "正在读取模型…"
: modelError
? "模型状态暂不可读"
: "尚未配置模型",
)}
</h3>
{currentModel && (
<p className="r6-model-state">
{currentModel.model} · {safeEndpoint(currentModel.base_url)}
{currentModel.model} ·{" "}
{text(safeEndpoint(currentModel.base_url))}
</p>
)}
<div className="r6-model-actions">
@@ -310,12 +317,12 @@ export function R6Settings({
disabled={profiles === null}
onClick={openConfiguration}
>
{currentModel ? "修改模型" : "配置模型"}
{text(currentModel ? "修改模型" : "配置模型")}
</R6Button>
<R6Button
variant="quiet"
aria-label="刷新模型状态"
title="刷新模型状态"
aria-label={text("刷新模型状态")}
title={text("刷新模型状态")}
onClick={() => setModelVersion((value) => value + 1)}
>
<RefreshCw size={16} aria-hidden="true" />
@@ -323,29 +330,32 @@ export function R6Settings({
</div>
{modelError && (
<p role="alert" className="r6-settings-error">
{profiles === null
? "模型状态未能读取,请重试。"
: "模型状态刷新未成功,仍显示上次确认的模型状态;请重试。"}
{text(
profiles === null
? "模型状态未能读取,请重试。"
: "模型状态刷新未成功,仍显示上次确认的模型状态;请重试。",
)}
</p>
)}
{configurationRefusal && (
<p role="alert" className="r6-settings-error">
上次模型测试或创建还待核对,配置暂时不能改动。请先在下方模型配置区核对结果;
现有配置和原文不会被改动。
{text(
"上次模型测试或创建还待核对,配置暂时不能改动。请先在下方模型配置区核对结果; 现有配置和原文不会被改动。",
)}
</p>
)}
{currentModel && (
<div className="r6-setting-row">
<div>
<h3>用模型沉淀记忆</h3>
<h3>{text("用模型沉淀记忆")}</h3>
<p>
开启后,导入和同步到本机的会话会交给当前模型整理成记忆,会产生模型调用。
停止后不再调用模型、不再整理记忆,但会话仍会照常导入和保存;已排队的内容会留着,重新开启后继续。
模型配置始终保留,重新开启不需要重新填写。
{text(
"开启后,导入和同步到本机的会话会交给当前模型整理成记忆,会产生模型调用。 停止后不再调用模型、不再整理记忆,但会话仍会照常导入和保存;已排队的内容会留着,重新开启后继续。 模型配置始终保留,重新开启不需要重新填写。",
)}
</p>
</div>
<R6Switch
label="用模型沉淀记忆"
label={text("用模型沉淀记忆")}
checked={sedimentation === true}
disabled={
sedimentation === null ||
@@ -360,19 +370,21 @@ export function R6Settings({
)}
{sedimentationError && (
<p role="alert" className="r6-settings-error">
{sedimentationError}
{text(sedimentationError)}
</p>
)}
{sedimentationUnavailable && (
<p role="alert" className="r6-settings-error">
当前安装还不能读取这个开关,请确认后台服务已启动后重试。
{text(
"当前安装还不能读取这个开关,请确认后台服务已启动后重试。",
)}
</p>
)}
</div>
<section
className="r6-model-owner"
hidden={!configurationOpen}
aria-label="模型配置表单"
aria-label={text("模型配置表单")}
>
<ModelSettings
compactSingle
@@ -388,7 +400,7 @@ export function R6Settings({
hidden={group !== "connections"}
className="r6-settings-connections"
>
<h2>连接其他应用</h2>
<h2>{text("连接其他应用")}</h2>
<ConnectionFallback />
</section>
{showDiagnostics && (
@@ -398,14 +410,14 @@ export function R6Settings({
hidden={group !== "help"}
className="r6-settings-help"
>
<h2>帮助与诊断</h2>
<h2>{text("帮助与诊断")}</h2>
<div className="r6-setting-row">
<div>
<h3>登录后启动后台服务</h3>
<p>无需打开界面,持续同步、整理和提供记忆。</p>
<h3>{text("登录后启动后台服务")}</h3>
<p>{text("无需打开界面,持续同步、整理和提供记忆。")}</p>
</div>
<R6Switch
label="登录后启动后台服务"
label={text("登录后启动后台服务")}
checked={login?.enabled === true}
disabled={
!dataReady ||
@@ -418,15 +430,17 @@ export function R6Settings({
</div>
{(dataError || readError) && (
<p role="alert" className="r6-settings-error">
{dataError || readError}
{text(dataError || readError)}
</p>
)}
<div className="r6-setting-row">
<div>
<h3>导出日志</h3>
<h3>{text("导出日志")}</h3>
<p>
{exportMessage ||
"把本机运行日志打包成一个文件,发给开发排查。"}
{text(
exportMessage ||
"把本机运行日志打包成一个文件,发给开发排查。",
)}
</p>
</div>
<R6Button
@@ -435,15 +449,15 @@ export function R6Settings({
onClick={() => void handleExportLogs()}
>
<Download size={16} aria-hidden="true" />
导出日志
{text("导出日志")}
</R6Button>
</div>
<div className="r6-setting-row r6-settings-about">
<div>
<h3>关于</h3>
<h3>{text("关于")}</h3>
<dl>
<div>
<dt>版本</dt>
<dt>{text("版本")}</dt>
<dd>{buildInfo.version}</dd>
</div>
<div>
@@ -497,23 +511,25 @@ export function R6Settings({
}
}}
>
<h2 id={`${id}-quit-title`}>确定退出 One Memory 界面吗?</h2>
<p>后台服务会继续同步、整理和提供记忆。</p>
<h2 id={`${id}-quit-title`}>
{text("确定退出 One Memory 界面吗?")}
</h2>
<p>{text("后台服务会继续同步、整理和提供记忆。")}</p>
{quitError && (
<p role="alert" className="r6-settings-error">
{quitError}
{text(quitError)}
</p>
)}
<footer>
<R6Button disabled={quitting} onClick={() => setQuitOpen(false)}>
取消
{text("取消")}
</R6Button>
<R6Button
variant="primary"
pending={quitting}
onClick={() => void quitApplication()}
>
退出界面
{text("退出界面")}
</R6Button>
</footer>
</section>
+21 -1
View File
@@ -18,6 +18,7 @@ vi.mock("./api", () => ({
updateSdkMemory,
}));
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { SdkMemoryLibrary } from "./SdkMemoryLibrary";
const memory = {
@@ -52,7 +53,26 @@ describe("SDK memory library", () => {
);
deleteSdkMemory.mockResolvedValue(undefined);
});
afterEach(cleanup);
afterEach(() => {
cleanup();
localStorage.clear();
});
it("localizes controls while leaving memory content unchanged", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
render(
<LanguageProvider>
<SdkMemoryLibrary active />
</LanguageProvider>,
);
expect(
await screen.findByRole("navigation", {
name: "Estado de la memoria",
}),
).toBeTruthy();
expect(screen.getByRole("button", { name: "original" })).toBeTruthy();
expect(screen.getByText("1 memoria")).toBeTruthy();
});
it("edits the same memory id and physically deletes without revision or undo", async () => {
render(<SdkMemoryLibrary active />);
+24 -18
View File
@@ -1,11 +1,12 @@
import type { SdkMemoryRecord, SdkMemoryStatus } from "@one-memory/contracts";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
deleteSdkMemory,
getSdkMemory,
getSdkMemoryLibrary,
updateSdkMemory,
} from "./api";
import { useGuiLanguage } from "./gui-language";
import { R6Button } from "./R6Controls";
export function SdkMemoryLibrary({
@@ -17,6 +18,9 @@ export function SdkMemoryLibrary({
onChanged?(): void;
focusedMemoryId?: string | null;
}) {
const { t, tn } = useGuiLanguage();
const translation = useRef(t);
translation.current = t;
const [status, setStatus] = useState<SdkMemoryStatus>("active");
const [offset, setOffset] = useState(0);
const [rows, setRows] = useState<SdkMemoryRecord[]>([]);
@@ -42,7 +46,7 @@ export function SdkMemoryLibrary({
if (offset >= page.total && offset > 0)
setOffset(Math.max(0, offset - limit));
} catch {
setError("记忆库读取失败,请重试。");
setError(translation.current("sdk.memory.loadFailed"));
} finally {
setBusy(false);
}
@@ -60,7 +64,7 @@ export function SdkMemoryLibrary({
setSelected(memory);
setDraft(memory.content);
} catch {
setError("记忆详情读取失败。");
setError(translation.current("sdk.memory.detailLoadFailed"));
} finally {
setBusy(false);
}
@@ -80,7 +84,7 @@ export function SdkMemoryLibrary({
await refresh();
onChanged?.();
} catch {
setError("修改未完成,请重试。");
setError(t("sdk.memory.updateFailed"));
} finally {
setBusy(false);
}
@@ -96,19 +100,19 @@ export function SdkMemoryLibrary({
await refresh();
onChanged?.();
} catch {
setError("删除未完成,请重试。");
setError(t("sdk.memory.deleteFailed"));
} finally {
setBusy(false);
}
}
return (
<section className="sdk-memory-library" aria-label="SDK 记忆库">
<nav aria-label="记忆状态">
<section className="sdk-memory-library" aria-label={t("sdk.memory.title")}>
<nav aria-label={t("sdk.memory.status")}>
{(
[
["active", "已可用"],
["superseded", "已被取代"],
["active", t("sdk.memory.status.active")],
["superseded", t("sdk.memory.status.superseded")],
] as const
).map(([value, label]) => (
<button
@@ -126,9 +130,11 @@ export function SdkMemoryLibrary({
</button>
))}
</nav>
<p aria-live="polite">共 {total} 条</p>
<p aria-live="polite">{tn("sdk.memory.total", total)}</p>
{error && <p role="alert">{error}</p>}
{busy && !rows.length ? <p role="status">正在读取记忆库…</p> : null}
{busy && !rows.length ? (
<p role="status">{t("sdk.memory.loading")}</p>
) : null}
<ul>
{rows.map((memory) => (
<li key={memory.memory_id}>
@@ -138,29 +144,29 @@ export function SdkMemoryLibrary({
</li>
))}
</ul>
<nav aria-label="SDK 记忆库分页">
<nav aria-label={t("sdk.memory.pagination")}>
<R6Button
variant="quiet"
disabled={busy || offset === 0}
onClick={() => setOffset(Math.max(0, offset - limit))}
>
上一页
{t("sdk.memory.previous")}
</R6Button>
<R6Button
variant="quiet"
disabled={busy || offset + limit >= total}
onClick={() => setOffset(offset + limit)}
>
下一页
{t("sdk.memory.next")}
</R6Button>
</nav>
{selected ? (
<aside aria-label="SDK 记忆详情">
<aside aria-label={t("sdk.memory.detail")}>
<p>{selected.layer}</p>
{selected.status === "active" ? (
<>
<textarea
aria-label="记忆内容"
aria-label={t("sdk.memory.content")}
value={draft}
disabled={busy}
onChange={(event) => setDraft(event.target.value)}
@@ -171,10 +177,10 @@ export function SdkMemoryLibrary({
pending={busy}
onClick={() => void save()}
>
保存修改
{t("sdk.memory.save")}
</R6Button>
<R6Button pending={busy} onClick={() => void remove()}>
删除记忆
{t("sdk.memory.delete")}
</R6Button>
</>
) : (
+37 -1
View File
@@ -9,9 +9,45 @@ const { getSdkRaw, searchSdkRaw } = vi.hoisted(() => ({
}));
vi.mock("./api", () => ({ getSdkRaw, searchSdkRaw }));
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { SdkRawHistory } from "./SdkRawHistory";
afterEach(cleanup);
afterEach(() => {
cleanup();
localStorage.clear();
});
it("localizes controls without translating original conversation fields", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
getSdkRaw.mockResolvedValue({
items: [
{
raw_id: "raw-es",
content: "原始内容 sin traducir",
user_id: "local",
agent_id: "one-memory",
session_id: "sesión-original",
source: "cursor",
memory_at: null,
gmt_created: null,
custom: {},
},
],
total: 1,
offset: 0,
limit: 100,
});
render(
<LanguageProvider>
<SdkRawHistory />
</LanguageProvider>,
);
expect(
await screen.findByLabelText("Buscar conversaciones originales"),
).toBeTruthy();
expect(screen.getByText("原始内容 sin traducir")).toBeTruthy();
expect(screen.getByText(/sesión-original/)).toBeTruthy();
});
it("lists and searches SDK raw without legacy conversation APIs", async () => {
getSdkRaw.mockResolvedValue({
+14 -8
View File
@@ -1,8 +1,12 @@
import type { SdkRawContextItem, SdkRawRecord } from "@one-memory/contracts";
import { type FormEvent, useEffect, useState } from "react";
import { type FormEvent, useEffect, useRef, useState } from "react";
import { getSdkRaw, searchSdkRaw } from "./api";
import { useGuiLanguage } from "./gui-language";
export function SdkRawHistory() {
const { locale, t, tn } = useGuiLanguage();
const translation = useRef(t);
translation.current = t;
const [recent, setRecent] = useState<SdkRawRecord[]>([]);
const [results, setResults] = useState<SdkRawContextItem[] | null>(null);
const [query, setQuery] = useState("");
@@ -16,7 +20,7 @@ export function SdkRawHistory() {
if (current) setRecent(page.items);
},
() => {
if (current) setError("原始对话暂时无法读取。");
if (current) setError(translation.current("sdk.raw.loadFailed"));
},
);
return () => {
@@ -32,7 +36,7 @@ export function SdkRawHistory() {
try {
setResults(await searchSdkRaw(query.trim()));
} catch {
setError("原始对话搜索失败,请重试。");
setError(t("sdk.raw.searchFailed"));
} finally {
setBusy(false);
}
@@ -40,22 +44,24 @@ export function SdkRawHistory() {
const items = results ?? recent.map(asContextItem);
return (
<section aria-label="SDK 原始对话">
<section aria-label={t("sdk.raw.title")}>
<form onSubmit={(event) => void submit(event)}>
<label>
搜索原始对话
{t("sdk.raw.searchLabel")}
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</label>
<button type="submit" disabled={busy || !query.trim()}>
{busy ? "搜索中…" : "搜索"}
{busy ? t("sdk.raw.searching") : t("sdk.raw.search")}
</button>
</form>
{error ? <p role="alert">{error}</p> : null}
<p aria-live="polite">
{results === null ? "最近原始对话" : `找到 ${items.length} 条`}
{results === null
? t("sdk.raw.recent")
: tn("sdk.raw.found", items.length)}
</p>
<ol>
{items.map((item) => (
@@ -64,7 +70,7 @@ export function SdkRawHistory() {
<small>
{item.source} · {item.session_id}
{item.memory_at
? ` · ${new Date(item.memory_at).toLocaleString("zh-CN")}`
? ` · ${new Date(item.memory_at).toLocaleString(locale)}`
: ""}
</small>
</li>
@@ -11,8 +11,32 @@ import {
} from "@testing-library/react";
import { StrictMode } from "react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { SelectedMemoryDetail } from "./SelectedMemoryDetail";
it("localizes informational details and dates while preserving evidence and memory", async () => {
delete window.oneMemory;
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
render(
<LanguageProvider>
<SelectedMemoryDetail
detail={detail}
sources={[]}
onChanged={vi.fn()}
hideActions
/>
</LanguageProvider>,
);
expect(await screen.findByText("Guardada · Versión 2")).toBeTruthy();
expect(
screen.getByRole("heading", { name: "Evidencia original" }),
).toBeTruthy();
expect(screen.getByText("这次先确认范围。")).toBeTruthy();
expect(correct).not.toHaveBeenCalled();
expect(remove).not.toHaveBeenCalled();
localStorage.clear();
});
const { correct, read, remove, errorCode } = vi.hoisted(() => ({
correct: vi.fn(),
read: vi.fn(),
+86 -60
View File
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { apiErrorCode, correctMemory, getMemory, mutateMemory } from "./api";
import { clockStamp } from "./clock-stamp";
import { useGuiLanguage } from "./gui-language";
import { R6Button } from "./R6Controls";
import { sourceNameOr } from "./source-label";
import { traceUiAction } from "./ui-trace";
@@ -27,6 +28,7 @@ export function SelectedMemoryDetail({
/** D99: when the cited conversation happened, in the list's own terms. */
conversationAt?: { at: string; basis: "conversation" | "import" } | null;
}) {
const { locale, t } = useGuiLanguage();
const [page, setPage] = useState<Page>("read");
const [draft, setDraft] = useState(detail.memory.content);
const [base, setBase] = useState(detail.memory.revision);
@@ -50,13 +52,17 @@ export function SelectedMemoryDetail({
const evidence = detail.evidence[0];
const statusLabel =
memory.status === "confirmed"
? "已沉淀"
? t("selectedMemory.status.confirmed")
: memory.status === "deleted"
? "已删除"
? t("selectedMemory.status.deleted")
: memory.status === "superseded"
? "已被取代"
: "状态待核对";
const source = sourceNameOr(sources, evidence?.source_id, "来源待核对");
? t("selectedMemory.status.superseded")
: t("selectedMemory.status.unknown");
const source = sourceNameOr(
sources,
evidence?.source_id,
t("selectedMemory.sourceUnknown"),
);
const dirty = page === "edit" && draft !== memory.content;
useEffect(() => {
if (page === "edit" && memory.revision > base) setConflictFound(true);
@@ -85,7 +91,7 @@ export function SelectedMemoryDetail({
}, [dirty]);
function close() {
if (busy) return;
if (dirty && !window.confirm("放弃尚未保存的修改?")) return;
if (dirty && !window.confirm(t("selectedMemory.discardDraft"))) return;
setPage("read");
setError("");
setConflictFound(false);
@@ -98,7 +104,7 @@ export function SelectedMemoryDetail({
const latest = await getMemory(memory.memory_id);
if (alive.current) setConflict(latest);
} catch {
if (alive.current) setError("暂时无法读取新版本,草稿已保留。");
if (alive.current) setError(t("selectedMemory.newVersionReadFailed"));
} finally {
if (alive.current) setBusy(false);
}
@@ -138,8 +144,8 @@ export function SelectedMemoryDetail({
setConflictFound(true);
setError("");
} else if (isDefiniteRefusal(reason))
setError("修改未提交;草稿已保留。请按提示修正后重试。");
else setError("修改尚未确认,草稿已保留。请先刷新核对,避免重复提交。");
setError(t("selectedMemory.updateRejected"));
else setError(t("selectedMemory.updateUnknown"));
}
} finally {
flight.current = false;
@@ -175,8 +181,8 @@ export function SelectedMemoryDetail({
if (alive.current)
setError(
refused
? "删除未提交。请核对这条记忆的状态后重试。"
: "删除尚未确认。请重新核对记忆状态,不会自动扩大删除范围。",
? t("selectedMemory.deleteRejected")
: t("selectedMemory.deleteUnknown"),
);
} finally {
flight.current = false;
@@ -207,8 +213,8 @@ export function SelectedMemoryDetail({
if (alive.current)
setError(
refused
? "撤销未提交。请按提示修正后重试。"
: "撤销尚未确认,请先核对当前记忆版本,避免重复撤销。",
? t("selectedMemory.undoRejected")
: t("selectedMemory.undoUnknown"),
);
} finally {
flight.current = false;
@@ -217,11 +223,11 @@ export function SelectedMemoryDetail({
}
const author =
evidence?.written_by === "person" || evidence?.written_by === "user"
? "你说"
? t("selectedMemory.author.you")
: evidence?.written_by === "agent" || evidence?.written_by === "assistant"
? "助手原话"
: "作者待核对";
const timestamp = clockStamp(evidence?.created_at);
? t("selectedMemory.author.assistant")
: t("selectedMemory.author.unknown");
const timestamp = clockStamp(evidence?.created_at, locale);
const split = memory.content.search(/[。\n]/);
const heading =
split > 0 && split < 85
@@ -236,17 +242,24 @@ export function SelectedMemoryDetail({
data-hide-actions={hideActions ? "true" : "false"}
>
<p className="selected-memory-status">
{statusLabel} · 第 {memory.revision} 版
{statusLabel} ·{" "}
{t("selectedMemory.version", {
revision: new Intl.NumberFormat(locale).format(memory.revision),
})}
</p>
<h3>{heading}</h3>
{body && <p className="selected-memory-body">{body}</p>}
<p className="selected-memory-source">
来源 {source} · {timestamp}
{t("selectedMemory.sourceLine", { source, time: timestamp })}
</p>
<p className="selected-memory-times">
对话时间 {clockStamp(conversationAt?.at ?? evidence?.created_at)}
{" · "}
沉淀时间 {clockStamp(memory.updated_at)}
{t("selectedMemory.timestamps", {
conversation: clockStamp(
conversationAt?.at ?? evidence?.created_at,
locale,
),
created: clockStamp(memory.updated_at, locale),
})}
</p>
{!hideActions && (
<R6Button
@@ -260,16 +273,18 @@ export function SelectedMemoryDetail({
setPage("edit");
}}
>
编辑
{t("selectedMemory.edit")}
</R6Button>
)}
<section className="selected-memory-evidence">
<h4>原始依据</h4>
<h4>{t("selectedMemory.evidence")}</h4>
<small>
{author} · 原对话 {timestamp}:
{t("selectedMemory.evidenceLine", { author, time: timestamp })}
</small>
<blockquote>
{evidence?.available ? evidence.verbatim : "这段原文当前不可查看。"}
{evidence?.available
? evidence.verbatim
: t("selectedMemory.evidenceUnavailable")}
</blockquote>
{!hideActions && (
<R6Button
@@ -278,20 +293,24 @@ export function SelectedMemoryDetail({
setPage("original");
}}
>
查看原文
{t("selectedMemory.viewOriginal")}
</R6Button>
)}
</section>
{!hideActions && (
<R6Button variant="quiet" onClick={() => setShowHistory((v) => !v)}>
版本记录
{t("selectedMemory.history")}
</R6Button>
)}
{!hideActions && showHistory && (
<ol className="selected-memory-history">
{detail.history.map((v) => (
<li key={v.revision}>
<small>第 {v.revision} 版</small>
<small>
{t("selectedMemory.version", {
revision: new Intl.NumberFormat(locale).format(v.revision),
})}
</small>
<p>{v.content}</p>
</li>
))}
@@ -299,10 +318,10 @@ export function SelectedMemoryDetail({
)}
{!hideActions && (
<details className="selected-memory-more">
<summary>更多管理</summary>
<summary>{t("selectedMemory.more")}</summary>
{detail.history.length > 1 && (
<R6Button pending={busy} onClick={() => void govern("undo")}>
撤销上次修改
{t("selectedMemory.undo")}
</R6Button>
)}
<R6Button
@@ -312,7 +331,7 @@ export function SelectedMemoryDetail({
setPage("delete");
}}
>
删除记忆
{t("selectedMemory.delete")}
</R6Button>
</details>
)}
@@ -325,10 +344,10 @@ export function SelectedMemoryDetail({
hidden={suspended}
aria-label={
page === "edit"
? "编辑记忆"
? t("selectedMemory.editTitle")
: page === "original"
? "原始对话"
: "删除记忆"
? t("selectedMemory.originalTitle")
: t("selectedMemory.delete")
}
onKeyDown={(event) => {
if (event.key === "Tab") {
@@ -360,20 +379,20 @@ export function SelectedMemoryDetail({
<header>
<h1 ref={focus} tabIndex={-1}>
{page === "edit"
? "编辑记忆"
? t("selectedMemory.editTitle")
: page === "original"
? "原始对话"
: "删除记忆"}
? t("selectedMemory.originalTitle")
: t("selectedMemory.delete")}
</h1>
{page === "original" && (
<R6Button onClick={close}>返回记忆</R6Button>
<R6Button onClick={close}>{t("selectedMemory.back")}</R6Button>
)}
</header>
{page === "edit" ? (
<div className="selected-editor">
<p>修改这条记忆,原始对话保持不变。</p>
<p>{t("selectedMemory.editDescription")}</p>
<textarea
aria-label="记忆内容"
aria-label={t("selectedMemory.content")}
name="memory-content"
value={draft}
disabled={busy}
@@ -383,16 +402,16 @@ export function SelectedMemoryDetail({
request.current = null;
}}
/>
<p>使用范围保持不变</p>
<p>{t("selectedMemory.scopeUnchanged")}</p>
{conflictFound && (
<section className="selected-conflict">
<h2>已有新版本</h2>
<p>你的草稿已保留,先查看新版本再决定如何修改。</p>
<h2>{t("selectedMemory.conflictTitle")}</h2>
<p>{t("selectedMemory.conflictDescription")}</p>
<R6Button
pending={busy}
onClick={() => void refreshConflict()}
>
查看新版本
{t("selectedMemory.viewNewVersion")}
</R6Button>
{conflict && (
<>
@@ -404,7 +423,7 @@ export function SelectedMemoryDetail({
request.current = null;
}}
>
基于此版本继续编辑
{t("selectedMemory.continueFromVersion")}
</R6Button>
</>
)}
@@ -412,7 +431,7 @@ export function SelectedMemoryDetail({
)}
<div className="selected-page-actions">
<R6Button disabled={busy} onClick={close}>
取消
{t("selectedMemory.cancel")}
</R6Button>
<R6Button
variant="primary"
@@ -420,7 +439,7 @@ export function SelectedMemoryDetail({
disabled={!draft.trim() || conflictFound}
onClick={() => void save()}
>
保存修改
{t("selectedMemory.save")}
</R6Button>
</div>
</div>
@@ -433,43 +452,50 @@ export function SelectedMemoryDetail({
{detail.evidence.map((item) => (
<section key={item.evidence_id}>
<small>
{sourceNameOr(sources, item.source_id, "来源待核对")} ·{" "}
{sourceNameOr(
sources,
item.source_id,
t("selectedMemory.sourceUnknown"),
)}{" "}
·{" "}
{item.written_by === "person"
? "你"
? t("selectedMemory.author.user")
: item.written_by === "agent"
? "助手"
: "作者待核对"}
? t("selectedMemory.author.agent")
: t("selectedMemory.author.unknown")}
</small>
<blockquote>
{item.available
? item.verbatim
: "来源权限已变化或原文不可用。"}
: t("selectedMemory.sourceUnavailable")}
</blockquote>
</section>
))}
<small>对应记忆</small>
<small>{t("selectedMemory.related")}</small>
<h2>{heading}</h2>
<R6Button onClick={close}>查看记忆</R6Button>
<R6Button onClick={close}>
{t("selectedMemory.viewMemory")}
</R6Button>
</div>
) : (
<div className="selected-delete">
<h2>删除这条记忆?</h2>
<h2>{t("selectedMemory.deleteTitle")}</h2>
<p>{heading}</p>
<p>
删除后不再用于新的召回,来源 App 中的原始对话不会删除。
{t("selectedMemory.deleteDescription")}
<br />
已经发送给应用的内容无法远程收回。
{t("selectedMemory.sentCannotRecall")}
</p>
<div className="selected-page-actions">
<R6Button disabled={busy} onClick={close}>
取消
{t("selectedMemory.cancel")}
</R6Button>
<R6Button
variant="primary"
pending={busy}
onClick={() => void remove()}
>
删除记忆
{t("selectedMemory.delete")}
</R6Button>
</div>
</div>
+60 -43
View File
@@ -3,9 +3,11 @@ import type {
SourceImportReceipt as Receipt,
SourceResponse,
} from "@one-memory/contracts";
import type { GuiLocale } from "@one-memory/i18n";
import { FileText, RefreshCw, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { getConversation, getSourceImportReceipt } from "./api";
import { memoryText, useMemoryLanguage } from "./memory-language";
export function SourceImportReceipt({ source }: { source: SourceResponse }) {
return source.authorized ? (
@@ -14,6 +16,7 @@ export function SourceImportReceipt({ source }: { source: SourceResponse }) {
}
function ReceiptBody({ source }: { source: SourceResponse }) {
const { locale, text, count } = useMemoryLanguage();
const [receipt, setReceipt] = useState<Receipt | null>(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
@@ -97,85 +100,96 @@ function ReceiptBody({ source }: { source: SourceResponse }) {
}
}}
>
<summary>查看已读内容</summary>
<summary>{text("查看已读内容")}</summary>
{opened && (
<div className="source-receipt-body">
<header>
<span>已保存原文 · {source.display_name}</span>
<span>
{text("已保存原文")} · {source.display_name}
</span>
<button
type="button"
className="quiet"
title="刷新已读内容"
aria-label="刷新已读内容"
title={text("刷新已读内容")}
aria-label={text("刷新已读内容")}
disabled={loading}
onClick={() => void load()}
>
<RefreshCw size={15} />
</button>
</header>
{loading && <p role="status">正在核对本机已保存记录…</p>}
{error && <p role="alert">{error}</p>}
{loading && <p role="status">{text("正在核对本机已保存记录…")}</p>}
{error && <p role="alert">{text(error)}</p>}
{receipt && (
<>
<dl className="source-receipt-counts">
<div>
<dt>原始消息</dt>
<dd>{receipt.messages.toLocaleString()}</dd>
<dt>{text("原始消息")}</dt>
<dd>{receipt.messages.toLocaleString(locale)}</dd>
</div>
<div>
<dt>会话</dt>
<dd>{receipt.conversations.toLocaleString()}</dd>
<dt>{text("会话")}</dt>
<dd>{receipt.conversations.toLocaleString(locale)}</dd>
</div>
<div>
<dt>用户发言</dt>
<dd>{receipt.person_messages.toLocaleString()}</dd>
<dt>{text("用户发言")}</dt>
<dd>{receipt.person_messages.toLocaleString(locale)}</dd>
</div>
<div>
<dt>Agent 发言</dt>
<dd>{receipt.agent_messages.toLocaleString()}</dd>
<dt>{text("Agent 发言")}</dt>
<dd>{receipt.agent_messages.toLocaleString(locale)}</dd>
</div>
<div>
<dt>作者未知</dt>
<dd>{receipt.unknown_author_messages.toLocaleString()}</dd>
<dt>{text("作者未知")}</dt>
<dd>
{receipt.unknown_author_messages.toLocaleString(locale)}
</dd>
</div>
<div>
<dt>原始时间未知</dt>
<dd>{receipt.unknown_time_messages.toLocaleString()}</dd>
<dt>{text("原始时间未知")}</dt>
<dd>
{receipt.unknown_time_messages.toLocaleString(locale)}
</dd>
</div>
{receipt.tool_messages > 0 && (
<div>
<dt>工具记录</dt>
<dd>{receipt.tool_messages.toLocaleString()}</dd>
<dt>{text("工具记录")}</dt>
<dd>{receipt.tool_messages.toLocaleString(locale)}</dd>
</div>
)}
</dl>
<p className="source-receipt-note">
{receipt.unknown_time_messages > 0
? `${receipt.unknown_time_messages.toLocaleString()} 条记录没有原始时间,未用导入时间代替。`
: "原始时间来自已保存记录。"}{" "}
作者数量按当前记录分类统计,不代表记忆抽取已完成或语义已核实。
? text("{count} 条记录没有原始时间,未用导入时间代替。", {
count: count(receipt.unknown_time_messages),
})
: text("原始时间来自已保存记录。")}{" "}
{text(
"作者数量按当前记录分类统计,不代表记忆抽取已完成或语义已核实。",
)}
</p>
<p className="source-receipt-meta">
统计于 {time(receipt.measured_at)}
{text("统计于 ")}
{time(receipt.measured_at, locale)}
{receipt.messages !== source.imported_messages
? " · 导入数量仍在更新,可刷新核对"
? text(" · 导入数量仍在更新,可刷新核对")
: ""}
</p>
{receipt.examples.length > 0 ? (
<section
className="source-receipt-examples"
aria-label="已保存的原文样例"
aria-label={text("已保存的原文样例")}
>
<h3>已保存的原文样例</h3>
<h3>{text("已保存的原文样例")}</h3>
<p className="source-receipt-meta">
最多展示 3 段,不代表全部历史或推荐记忆。
{text("最多展示 3 段,不代表全部历史或推荐记忆。")}
</p>
{receipt.examples.map((example) => (
<article key={example.evidence_id}>
<header>
<span>
{author(example.written_by)} ·{" "}
{time(example.created_at)}
{text(author(example.written_by))} ·{" "}
{time(example.created_at, locale)}
</span>
<button
type="button"
@@ -183,35 +197,37 @@ function ReceiptBody({ source }: { source: SourceResponse }) {
onClick={() => void read(example.evidence_id)}
>
<FileText size={14} aria-hidden="true" />
查看完整对话
{text("查看完整对话")}
</button>
</header>
<p className="source-receipt-quote">{example.excerpt}</p>
{example.truncated && (
<small>原文较长,此处仅展示前 600 个字符。</small>
<small>
{text("原文较长,此处仅展示前 600 个字符。")}
</small>
)}
</article>
))}
</section>
) : (
<p>这个来源还没有保存原文。</p>
<p>{text("这个来源还没有保存原文。")}</p>
)}
</>
)}
{reading && <p role="status">正在读取完整对话…</p>}
{readError && <p role="alert">{readError}</p>}
{reading && <p role="status">{text("正在读取完整对话…")}</p>}
{readError && <p role="alert">{text(readError)}</p>}
{transcript && (
<section
className="source-receipt-conversation"
aria-label="完整对话"
aria-label={text("完整对话")}
>
<header>
<h3>完整对话</h3>
<h3>{text("完整对话")}</h3>
<button
type="button"
className="quiet"
title="关闭完整对话"
aria-label="关闭完整对话"
title={text("关闭完整对话")}
aria-label={text("关闭完整对话")}
onClick={() => {
conversationGeneration.current++;
setTranscript(null);
@@ -224,7 +240,8 @@ function ReceiptBody({ source }: { source: SourceResponse }) {
{transcript.messages.map((message) => (
<li key={message.evidence_id}>
<small>
{author(message.written_by)} · {time(message.created_at)}
{text(author(message.written_by))} ·{" "}
{time(message.created_at, locale)}
</small>
<p>{message.verbatim}</p>
</li>
@@ -247,8 +264,8 @@ function author(value: string) {
? "工具"
: "作者未知";
}
function time(value: string | null) {
function time(value: string | null, locale: GuiLocale) {
return value && Number.isFinite(Date.parse(value))
? new Date(value).toLocaleString()
: "时间未知";
? new Date(value).toLocaleString(locale)
: memoryText(locale, "时间未知");
}
+108 -1
View File
@@ -18,8 +18,40 @@ import {
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { OptionMenu } from "./OptionMenu";
import { Answer, Workbench } from "./Workbench";
import { Workbench } from "./Workbench";
it("localiza la selección vacía del menú auxiliar", () => {
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
render(
<LanguageProvider>
<OptionMenu value="" options={[]} onChange={vi.fn()} />
</LanguageProvider>,
);
expect(
screen.getByRole("button", { name: "Selecciona una opción" }),
).toBeTruthy();
window.localStorage.removeItem(LANGUAGE_STORAGE_KEY);
});
it("traduce la respuesta visible sin traducir la respuesta del modelo", () => {
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
render(
<LanguageProvider>
<Answer
answer={null}
streaming={{ text: "Respuesta 中文 opaca", memories: [] }}
onOpenMemory={vi.fn()}
onSaveAnswer={vi.fn()}
asPage={false}
/>
</LanguageProvider>,
);
expect(screen.getByText("Respuesta 中文 opaca")).toBeTruthy();
expect(screen.getByText("Se recuperaron 0 memorias")).toBeTruthy();
window.localStorage.removeItem(LANGUAGE_STORAGE_KEY);
});
const getConversation = vi.fn();
@@ -222,6 +254,49 @@ function mount(props: Partial<Parameters<typeof Workbench>[0]> = {}) {
return { ...handlers, view };
}
it("localiza el espacio vacío y sus controles", () => {
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
const props = {
memories: [],
graph: null,
sources: [],
processing: null,
loaded: true,
detail: null,
draft: "",
scopes: "",
busy: false,
answer: null,
streaming: null,
requestIds: { current: new Map() },
onDraft: vi.fn(),
onScopes: vi.fn(),
onOpenMemory: vi.fn(),
onCloseDetail: vi.fn(),
onSave: vi.fn(),
onAction: vi.fn(),
onSyncIndex: vi.fn(),
onAsk: vi.fn(),
onSaveAnswer: vi.fn(),
onBuildRelations: vi.fn(),
onConnect: vi.fn(),
onConfigureModel: vi.fn(),
};
render(
<LanguageProvider>
<Workbench {...props} />
</LanguageProvider>,
);
expect(screen.getByRole("navigation", { name: "Temas" })).toBeTruthy();
expect(
screen.getByRole("button", { name: "Autorizar una fuente" }),
).toBeTruthy();
expect(
screen.getByRole("textbox", { name: "Preguntar sobre este asunto" }),
).toBeTruthy();
window.localStorage.removeItem(LANGUAGE_STORAGE_KEY);
});
it("选中的图节点把记忆库与详情联到一起(图 → 列表 → 原文)", async () => {
// The goal requires the graph, the memory list and the original text to be
// linked in both directions. The list→graph direction is covered elsewhere
@@ -796,4 +871,36 @@ describe("按主题聚类", () => {
expect(panel.textContent).toContain("同一个主题");
expect(panel.textContent).not.toContain("原话来源");
});
it("Core 送来未知的节点类型或连线来头时,仍然说得出一个词而不是留空", async () => {
const user = userEvent.setup();
mount({
graph: {
total_memories: 1,
projected_memories: 1,
hy_graph_available: false,
nodes: [
{ node_id: "future:x", node_type: "future_kind", label: "新节点" },
{ node_id: "memory:a", node_type: "memory", label: "统一走 axios" },
],
edges: [
{
edge_id: "e1",
source_node_id: "future:x",
target_node_id: "memory:a",
relation: "related_to",
origin: "future_origin",
},
],
} as unknown as MemoryGraphResponse,
});
await user.click(screen.getByRole("button", { name: "新节点" }));
const panel = screen.getByLabelText("这个节点");
expect(panel.querySelector(".wb-status")?.textContent).toBe("记忆");
expect(panel.querySelector(".wb-trace-origin")?.textContent).toBe(
"原话来源",
);
});
});
+260 -149
View File
@@ -11,6 +11,7 @@ import type {
SdkAskMemory,
SourceResponse,
} from "@one-memory/contracts";
import { isMessageKey } from "@one-memory/i18n";
import { Check, PencilLine, RotateCcw, Trash2 } from "lucide-react";
import {
type FormEvent,
@@ -20,9 +21,9 @@ import {
useState,
} from "react";
import { getConversation } from "./api";
import { useGuiLanguage } from "./gui-language";
import { HandoffBar } from "./HandoffBar";
import { MemoryGraph3D } from "./MemoryGraph3D";
import { memoryProvenanceLabel } from "./MemoryRecallScope";
import { extractionStateLabel } from "./memory-processing-labels";
import {
type Finding,
@@ -32,6 +33,10 @@ import {
} from "./workbench-findings";
import { singletonTags, tagCounts } from "./workbench-tags";
type Translate = ReturnType<typeof useGuiLanguage>["t"];
type TranslateCount = ReturnType<typeof useGuiLanguage>["tn"];
type GuiLocale = ReturnType<typeof useGuiLanguage>["locale"];
/** The memory tab: three columns over one graph.
*
* Left is what the memories are about, centre is how they relate and where
@@ -97,6 +102,7 @@ export function Workbench({
* reuses the request id prepared for text the user has since replaced. */
requestIds: RefObject<Map<string, string>>;
}) {
const { locale, t, tn } = useGuiLanguage();
const [matter, setMatter] = useState("");
const [selectedNode, setSelectedNode] = useState<MemoryGraphNode | null>(
null,
@@ -122,8 +128,14 @@ export function Workbench({
[memories, matter],
);
const findings = useMemo(
() => findingsOf({ memories: inMatter, processing, now: new Date() }),
[inMatter, processing],
() =>
findingsOf({
memories: inMatter,
processing,
now: new Date(),
locale,
}),
[inMatter, processing, locale],
);
// Counted across the whole library, not this matter. A tag used once here
// and once elsewhere is a tag used twice; scoping this to the matter would
@@ -137,8 +149,8 @@ export function Workbench({
// fails. Keying the skeleton on null meant the one screen it existed for
// never saw it.
const drawn = graph ? narrow(graph, inMatter, matter) : null;
return drawn?.nodes.length ? drawn : skeleton(sources, graph);
}, [graph, inMatter, matter, sources]);
return drawn?.nodes.length ? drawn : skeleton(sources, graph, tn);
}, [graph, inMatter, matter, sources, tn]);
const evidence = useMemo(
() => orderEvidence(detail, highlighted, memories),
[detail, highlighted, memories],
@@ -157,14 +169,16 @@ export function Workbench({
setTranscript(await getConversation(evidenceId));
} catch {
setTranscript(null);
setTranscriptError("这段对话的原文已经不在库里了。");
setTranscriptError(t("wb.transcriptGone"));
}
}
const nameOf = (sourceId: string | null) =>
sources.find((item) => item.source_id === sourceId)?.display_name ??
sourceId ??
"未知来源";
t("wb.unknownSource");
const matterLabel = (scope: string, label: string) =>
scope ? label : t("wb.all");
return (
<div className="workbench">
@@ -176,16 +190,16 @@ export function Workbench({
belongs to — is a workspace scope, and its plaintext name lives on
the evidence rows in Core; nothing here can recover it from the
hash, so calling this column 事项 named something it was not. */}
<nav className="wb-matters" aria-label="主题">
<p className="wb-column-label">主题</p>
<nav className="wb-matters" aria-label={t("wb.topics")}>
<p className="wb-column-label">{t("wb.topics")}</p>
{allTags.length ? (
// Says the two numbers the column actually shows: how many
// topics took shape, and how many tags did not. Reporting one
// total meant reading "22 个主题标签" above six rows.
<p className="wb-tag-tally">
{onceOnly.size === 0
? `${allTags.length - onceOnly.size} 个主题成形`
: `${allTags.length - onceOnly.size} 个主题成形,另有 ${onceOnly.size} 个标签只出现过一次`}
? tn("wb.topicCount", allTags.length - onceOnly.size)
: `${tn("wb.topicCount", allTags.length - onceOnly.size)}${tn("wb.singletons", onceOnly.size)}`}
</p>
) : null}
{matters.map((item) => (
@@ -201,10 +215,12 @@ export function Workbench({
setTranscript(null);
}}
>
{item.label}
{matterLabel(item.scope, item.label)}
<small>
{item.memoryCount} 条记忆
{item.sourceCount ? ` · ${item.sourceCount} 个来源` : ""}
{tn("wb.memories", item.memoryCount)}
{item.sourceCount
? ` · ${tn("wb.sources", item.sourceCount)}`
: ""}
</small>
</button>
{/* The graph is a canvas: clicking a circle is a mouse-only
@@ -249,9 +265,7 @@ export function Workbench({
className="wb-tag"
data-once={onceOnly.has(tag) ? "true" : undefined}
title={
onceOnly.has(tag)
? "这个主题只出现在这一条上。可能是它只发生过一次,也可能是同一个主题被拆成了两个名字。"
: undefined
onceOnly.has(tag) ? t("wb.once") : undefined
}
>
{tag}
@@ -270,14 +284,25 @@ export function Workbench({
<div className="wb-stage">
<div className="wb-graph" data-testid="workbench-graph">
<p className="wb-graph-title">
{matters.find((item) => item.scope === matter)?.label ?? "全部"}
{shown.nodes.length ? " 的关系图" : ""}
{shown.nodes.length
? t("wb.graphTitle", {
label: matterLabel(
matter,
matters.find((item) => item.scope === matter)?.label ??
t("wb.all"),
),
})
: matterLabel(
matter,
matters.find((item) => item.scope === matter)?.label ??
t("wb.all"),
)}
</p>
<div className="wb-graph-legend">
<span data-kind="memory">记忆</span>
<span data-kind="schema">HY 归纳</span>
<span data-kind="topic">主题</span>
<span data-kind="source">来源 App</span>
<span data-kind="memory">{t("wb.type.memory")}</span>
<span data-kind="schema">{t("wb.type.schema")}</span>
<span data-kind="topic">{t("wb.type.topic")}</span>
<span data-kind="source">{t("wb.type.source")}</span>
</div>
{/* Laid out by the row, not by offsets. Both were absolutely
positioned with a right edge each, and measuring them found
@@ -292,16 +317,16 @@ export function Workbench({
className="quiet"
disabled={busy}
onClick={() => {
const label =
const label = matterLabel(
matter,
matters.find((item) => item.scope === matter)?.label ??
"全部";
setPageOf(matter);
onAsk(
`把「${label}」这件事讲清楚:现在到哪一步了、已经定下来的有哪些、还有什么没定。`,
t("wb.all"),
);
setPageOf(matter);
onAsk(t("wb.summaryPrompt", { label }));
}}
>
读这个事项的综述
{t("wb.readSummary")}
</button>
) : null}
<button
@@ -310,7 +335,7 @@ export function Workbench({
disabled={busy || !graph?.nodes.length}
onClick={onBuildRelations}
>
{busy ? "正在归纳…" : "用 HY Memory 生成关系"}
{busy ? t("wb.building") : t("wb.build")}
</button>
</div>
{shown.nodes.length ? (
@@ -338,10 +363,16 @@ export function Workbench({
</div>
{graph?.nodes.length ? (
<p className="wb-receipt">
画的是{" "}
{shown.nodes.filter((node) => node.node_type === "memory").length}{" "}
/ 全部 {graph.total_memories} 条记忆
<span>{graphReceipt(shown)}</span>
{t("wb.drawn", {
shown: new Intl.NumberFormat(locale).format(
shown.nodes.filter((node) => node.node_type === "memory")
.length,
),
total: new Intl.NumberFormat(locale).format(
graph.total_memories,
),
})}
<span>{graphReceipt(shown, t, tn)}</span>
</p>
) : null}
@@ -351,9 +382,11 @@ export function Workbench({
detail={detail}
draft={draft}
busy={busy}
matterLabel={
matters.find((item) => item.scope === matter)?.label ?? "全部"
}
matterLabel={matterLabel(
matter,
matters.find((item) => item.scope === matter)?.label ??
t("wb.all"),
)}
onDraft={onDraft}
onSave={onSave}
onAction={onAction}
@@ -376,7 +409,7 @@ export function Workbench({
}}
>
<div className="wb-chips">
{chipsFor(inMatter).map((chip) => (
{chipsFor(inMatter, t).map((chip) => (
<button
key={chip}
type="button"
@@ -392,13 +425,13 @@ export function Workbench({
</div>
<div className="wb-ask-row">
<input
aria-label="就着这个事项提问"
placeholder="就着这个事项问,回答只用已确认的记忆"
aria-label={t("wb.askLabel")}
placeholder={t("wb.askPlaceholder")}
value={question}
onChange={(event) => setQuestion(event.target.value)}
/>
<button type="submit" disabled={!question.trim()}>
问
{t("wb.ask")}
</button>
</div>
{answer || streaming ? (
@@ -416,18 +449,20 @@ export function Workbench({
memories={inMatter.filter(
(memory) => memory.memory_id === detail?.memory.memory_id,
)}
matterLabel={
matters.find((item) => item.scope === matter)?.label ?? "全部"
}
matterLabel={matterLabel(
matter,
matters.find((item) => item.scope === matter)?.label ??
t("wb.all"),
)}
/>
</div>
<aside className="wb-evidence" aria-label="原始对话">
<p className="wb-column-label">原始对话</p>
<aside className="wb-evidence" aria-label={t("wb.originals")}>
<p className="wb-column-label">{t("wb.originals")}</p>
<p className="wb-evidence-note">
{detail
? `这条记忆出自下面 ${evidence.length} 段原话,按时间排`
: "点图上任意一个圈,这里显示它出自哪些原话"}
? tn("wb.evidenceNote", evidence.length)
: t("wb.pickCircle")}
</p>
{evidence.map((item) => (
<article
@@ -448,16 +483,19 @@ export function Workbench({
>
<span className="wb-evidence-meta">
<b>{nameOf(item.source_id)}</b>
<span>{when(item.created_at)}</span>
<span>{when(item.created_at, locale, t)}</span>
</span>
{item.extraction_state &&
item.extraction_state !== "extracted" ? (
<small className="wb-evidence-state">
{extractionStateLabel(item.extraction_state)}
{localizedMemoryText(
extractionStateLabel(item.extraction_state),
t,
)}
</small>
) : null}
<span className="wb-evidence-excerpt">
{item.available ? item.verbatim : "来源原话已不可读取"}
{item.available ? item.verbatim : t("wb.unavailable")}
</span>
</button>
{openEvidence === item.evidence_id ? (
@@ -467,7 +505,7 @@ export function Workbench({
className="secondary"
onClick={() => void openTranscript(item.evidence_id)}
>
看完整对话
{t("wb.fullConversation")}
</button>
{transcriptError ? (
<p className="wb-transcript-error">{transcriptError}</p>
@@ -477,7 +515,9 @@ export function Workbench({
{transcript.messages.map((message) => (
<li key={message.evidence_id}>
<span className="wb-transcript-role">
{message.written_by === "user" ? "你" : "助手"}
{message.written_by === "user"
? t("wb.you")
: t("wb.assistant")}
</span>
{message.verbatim}
</li>
@@ -489,18 +529,16 @@ export function Workbench({
</article>
))}
{!detail && !memories.length && loaded ? (
<p className="wb-evidence-note">
还没有记忆。已经导入的原话可以在「历史对话」里直接搜。
</p>
<p className="wb-evidence-note">{t("wb.noMemories")}</p>
) : null}
</aside>
</div>
{processing?.blocked_stages.length ? (
<p className="wb-floor">
有对话排在队里等一个条件。原文已经在库里,随时搜得到。
{t("wb.waiting")}
<button type="button" className="quiet" onClick={onConfigureModel}>
去看看缺什么
{t("wb.conditions")}
</button>
</p>
) : null}
@@ -515,12 +553,13 @@ function Findings({
findings: Finding[];
onPick(finding: Finding): void;
}) {
const { t } = useGuiLanguage();
if (!findings.length) return null;
return (
<section className="wb-findings" aria-label="我替你看出这几件事">
<section className="wb-findings" aria-label={t("wb.findings")}>
<div className="wb-findings-head">
<b>我替你看出这几件事</b>
<span>都是本机记录算出来的,点一条会在图上标出它依据的记忆</span>
<b>{t("wb.findings")}</b>
<span>{t("wb.findingsNote")}</span>
</div>
{findings.map((finding) => (
<div key={finding.id} className="wb-finding">
@@ -534,7 +573,7 @@ function Findings({
className="quiet wb-finding-link"
onClick={() => onPick(finding)}
>
在图上看
{t("wb.showGraph")}
</button>
) : null}
</div>
@@ -575,19 +614,24 @@ export function NodeDetail({
onClose(): void;
compact?: boolean;
}) {
const { locale, t } = useGuiLanguage();
const [editing, setEditing] = useState(!compact);
const formId = useId();
if (detail) {
const memory = detail.memory;
return (
<article className="wb-detail" aria-label="这条记忆">
<article className="wb-detail" aria-label={t("wb.memory")}>
<div className="wb-detail-head">
<span className={`wb-status s-${memory.status}`}>
{statusLabel(memory.status)}
{statusLabel(memory.status, t)}
</span>
<span className="wb-detail-rev">
{t("wb.revision", {
revision: new Intl.NumberFormat(locale).format(memory.revision),
})}
</span>
<span className="wb-detail-rev">第 {memory.revision} 版</span>
<button type="button" className="quiet" onClick={onClose}>
收起
{t("wb.collapse")}
</button>
</div>
{detail.index_sync?.status === "pending" ? (
@@ -595,13 +639,13 @@ export function NodeDetail({
// the memory here reads correctly and search does not, and nobody
// would guess that from a sync badge.
<p className="notice" role="status">
搜索里还是改之前那句话。
{t("wb.oldSearch")}
{detail.index_sync.last_error_code ===
"hy_memory_update_rejected" ? (
// HY has no such memory to re-embed, so retrying cannot end
// differently. Offering the button anyway would have the user
// pressing it until they gave up.
"HY Memory 里没有这条记忆,重试也补不上。"
t("wb.missingIndex")
) : (
<button
type="button"
@@ -609,7 +653,7 @@ export function NodeDetail({
disabled={busy}
onClick={onSyncIndex}
>
重试同步
{t("wb.retrySync")}
</button>
)}
</p>
@@ -618,15 +662,15 @@ export function NodeDetail({
<p className="map-memory-reading">{memory.content}</p>
)}
{compact && !editing && (
<p className="map-origin">{memoryProvenanceLabel(detail)}</p>
<p className="map-origin">{localizedProvenance(detail, t)}</p>
)}
{editing && memory.status === "confirmed" ? (
<form id={formId} onSubmit={onSave} aria-label="纠正记忆内容">
<form id={formId} onSubmit={onSave} aria-label={t("wb.correctForm")}>
<label>
记忆内容
{t("wb.content")}
<textarea
name="memory-content"
aria-label="记忆内容"
aria-label={t("wb.content")}
autoComplete="off"
required
rows={3}
@@ -647,13 +691,13 @@ export function NodeDetail({
onClick={() => setEditing(true)}
>
<PencilLine size={14} aria-hidden="true" />
纠正
{t("wb.correct")}
</button>
)}
{editing && memory.status === "confirmed" ? (
<button type="submit" form={formId} disabled={busy}>
<Check size={14} aria-hidden="true" />
保存纠正
{t("wb.saveCorrection")}
</button>
) : null}
{compact && editing && (
@@ -667,7 +711,7 @@ export function NodeDetail({
setEditing(false);
}}
>
取消编辑
{t("wb.cancelEdit")}
</button>
)}
{detail.history.length > 1 && memory.status !== "deleted" ? (
@@ -676,8 +720,8 @@ export function NodeDetail({
className="secondary"
disabled={busy}
onClick={() => onAction("undo")}
title="撤销上次修改"
aria-label="撤销上次修改"
title={t("wb.undo")}
aria-label={t("wb.undo")}
>
<RotateCcw size={15} aria-hidden="true" />
</button>
@@ -690,7 +734,7 @@ export function NodeDetail({
onClick={() => onAction("delete")}
>
<Trash2 size={14} aria-hidden="true" />
删除
{t("wb.delete")}
</button>
) : null}
</div>
@@ -702,24 +746,28 @@ export function NodeDetail({
const trace = graph ? traceFrom(graph, node.node_id, 2) : [];
const direct = trace.filter((step) => step.hop === 1);
return (
<article className="wb-detail" aria-label="这个节点">
<article className="wb-detail" aria-label={t("wb.node")}>
<div className="wb-detail-head">
<span className="wb-status">{nodeTypeLabel(node.node_type)}</span>
<span className="wb-status">{nodeTypeLabel(node.node_type, t)}</span>
<strong>{node.label}</strong>
</div>
<p className="wb-detail-note">
这不是一条记忆,是记忆挂靠的东西。直接关系 {direct.length} 条,
两跳可达 {trace.length - direct.length} 条。
{t("wb.nodeNote", {
direct: new Intl.NumberFormat(locale).format(direct.length),
indirect: new Intl.NumberFormat(locale).format(
trace.length - direct.length,
),
})}
</p>
<ul className="wb-trace">
{trace.slice(0, 8).map((step) => (
<li key={`${step.edge.edge_id}:${step.hop}`}>
<span className="wb-trace-hop">
{step.hop === 1 ? "直接" : "两跳"}
{step.hop === 1 ? t("wb.direct") : t("wb.twoHops")}
</span>
{relationLabel(step.edge.relation)} · {step.node.label}
{relationLabel(step.edge.relation, t)} · {step.node.label}
<span className="wb-trace-origin">
{edgeOriginLabel(step.edge.origin)}
{edgeOriginLabel(step.edge.origin, t)}
</span>
</li>
))}
@@ -729,13 +777,11 @@ export function NodeDetail({
}
return (
<article className="wb-detail wb-detail-idle" aria-label="这个事项">
<article className="wb-detail wb-detail-idle" aria-label={t("wb.matter")}>
<div className="wb-detail-head">
<strong>{matterLabel}</strong>
</div>
<p className="wb-detail-note">
图上的圈是记忆,线是它们被同一段对话提到过。选中一个,这里会变成它的来历和改法。左边换一个事项,图跟着换。
</p>
<p className="wb-detail-note">{t("wb.idle")}</p>
</article>
);
}
@@ -771,6 +817,7 @@ export function Answer({
asPage: boolean;
suspended?: boolean;
}) {
const { t, tn } = useGuiLanguage();
const shownMemories = (answer ?? streaming)?.memories ?? [];
// Only a finished answer, and only one that stands on something. Filing
// back what a stream is halfway through writing would keep a sentence that
@@ -785,11 +832,18 @@ export function Answer({
groundedAnswer.answer.length > ANSWER_AS_MEMORY_LIMIT;
return (
<div className="wb-answer" aria-live="polite">
<p>{answer ? answer.answer : streaming?.text || "正在检索记忆…"}</p>
<p>{answer ? answer.answer : streaming?.text || t("wb.searching")}</p>
<p className="wb-answer-source">
{answer
? `${answer.mode === "generated" ? "模型综合" : answer.mode === "extractive" ? "原文摘录" : "没有命中"} · 依据 ${shownMemories.length} 条已确认记忆`
: `已召回 ${shownMemories.length} 条`}
? tn("wb.answerSource", shownMemories.length, {
mode:
answer.mode === "generated"
? t("wb.generated")
: answer.mode === "extractive"
? t("wb.extractive")
: t("wb.noHit"),
})
: tn("wb.recalled", shownMemories.length)}
</p>
{groundedAnswer && !tooLong ? (
<button
@@ -808,23 +862,17 @@ export function Answer({
})
}
>
存为记忆
{t("wb.saveMemory")}
</button>
) : null}
{tooLong ? (
<p className="wb-answer-source">
这个回答太长,超过当前保存上限,暂不能存为记忆。
</p>
) : null}
{tooLong ? <p className="wb-answer-source">{t("wb.tooLong")}</p> : null}
{asPage ? (
// Not offered as a memory, and said out loud why. A synthesis is
// rebuilt from the memories under it every time it is read, which is
// the only reason it is safe to let a model write it: a stored
// summary drifts from what it summarised and nothing notices, and a
// rebuilt one cannot.
<p className="wb-answer-source">
这是综述,不是记忆。每次都会重新生成,所以它不会和下面这些记忆走散。
</p>
<p className="wb-answer-source">{t("wb.summary")}</p>
) : null}
<ol>
{shownMemories.map((memory) => (
@@ -843,7 +891,7 @@ export function Answer({
<HandoffBar
memories={shownMemories}
suspended={suspended}
matterLabel="这次回答"
matterLabel={t("wb.thisAnswer")}
answerText={answer.answer}
/>
)}
@@ -858,22 +906,21 @@ function EmptyStage({
loaded: boolean;
onConnect(): void;
}) {
const { t } = useGuiLanguage();
if (!loaded) {
return (
<div className="wb-graph-empty">
<p>正在读取记忆…</p>
<small>本地 Core 启动后会自动出现,不用刷新。</small>
<p>{t("wb.loading")}</p>
<small>{t("wb.coreStarting")}</small>
</div>
);
}
return (
<div className="wb-graph-empty">
<p>这里还没有东西可以连起来。</p>
<small>
授权一个来源之后,先出现的是来源和主题的骨架,记忆会陆续挂上去。
</small>
<p>{t("wb.nothingToConnect")}</p>
<small>{t("wb.skeletonNote")}</small>
<button type="button" onClick={onConnect}>
去授权一个来源
{t("wb.authorize")}
</button>
</div>
);
@@ -912,6 +959,7 @@ function narrow(
function skeleton(
sources: SourceResponse[],
graph: MemoryGraphResponse | null,
tn: TranslateCount,
): MemoryGraphResponse {
const withHistory = sources.filter(
(item) => item.authorized && item.imported_conversations > 0,
@@ -923,7 +971,10 @@ function skeleton(
nodes: withHistory.map((item) => ({
node_id: `source:${item.source_id}`,
node_type: "source" as const,
label: `${item.display_name} · ${item.imported_conversations} 段`,
label: `${item.display_name} · ${tn(
"wb.conversations",
item.imported_conversations,
)}`,
memory_id: null,
source_id: item.source_id,
kind: null,
@@ -971,34 +1022,46 @@ function evidenceOf(
/** Questions built from what is actually in this matter, so a chip never
* offers to answer something there is no record of. */
function chipsFor(memories: GovernedMemory[]): string[] {
function chipsFor(memories: GovernedMemory[], t: Translate): string[] {
if (!memories.length) return [];
const chips = ["这里最近有什么变化"];
if (memories.length >= 3) chips.push("把这些归纳成几条");
const chips = [t("wb.chipChanges")];
if (memories.length >= 3) chips.push(t("wb.chipSummarize"));
return chips;
}
function when(iso: string | null): string {
if (!iso) return "时间不明";
function when(iso: string | null, locale: GuiLocale, t: Translate): string {
if (!iso) return t("wb.unknownTime");
const at = new Date(iso);
if (Number.isNaN(at.getTime())) return "时间不明";
return `${at.getMonth() + 1}/${at.getDate()} ${String(at.getHours()).padStart(2, "0")}:${String(at.getMinutes()).padStart(2, "0")}`;
if (Number.isNaN(at.getTime())) return t("wb.unknownTime");
return new Intl.DateTimeFormat(locale, {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(at);
}
function statusLabel(status: GovernedMemory["status"]): string {
if (status === "confirmed") return "已可用";
if (status === "superseded") return "已被取代";
return "已删除";
function statusLabel(status: GovernedMemory["status"], t: Translate): string {
if (status === "confirmed") return t("wb.available");
if (status === "superseded") return t("wb.superseded");
return t("wb.deleted");
}
function nodeTypeLabel(type: MemoryGraphNode["node_type"]): string {
if (type === "source") return "来源 App";
if (type === "topic") return "主题";
if (type === "schema") return "HY 归纳";
return "记忆";
function nodeTypeLabel(
type: MemoryGraphNode["node_type"],
t: Translate,
): string {
if (type === "source") return t("wb.type.source");
if (type === "topic") return t("wb.type.topic");
if (type === "schema") return t("wb.type.schema");
return t("wb.type.memory");
}
function graphReceipt(graph: MemoryGraphResponse): string {
function graphReceipt(
graph: MemoryGraphResponse,
t: Translate,
tn: TranslateCount,
): string {
const counts = {
provenance: 0,
same_conversation: 0,
@@ -1007,43 +1070,91 @@ function graphReceipt(graph: MemoryGraphResponse): string {
};
for (const edge of graph.edges) counts[edge.origin] += 1;
const parts = [
`原话来源 ${counts.provenance} 条`,
`同一次对话 ${counts.same_conversation} 条`,
tn("wb.edgeCount", counts.provenance, {
label: t("wb.origin.provenance"),
}),
tn("wb.edgeCount", counts.same_conversation, {
label: t("wb.origin.same_conversation"),
}),
];
// Only mentioned once there is one. A young library has no shared tag yet,
// and a line reading 0 would be one more thing to read past.
if (counts.shared_tag > 0) parts.push(`同一个主题 ${counts.shared_tag} 条`);
if (counts.shared_tag > 0)
parts.push(
tn("wb.edgeCount", counts.shared_tag, {
label: t("wb.origin.shared_tag"),
}),
);
if (counts.hy_relation > 0) {
parts.push(`HY Memory 归纳 ${counts.hy_relation} 条`);
parts.push(
tn("wb.edgeCount", counts.hy_relation, {
label: t("wb.origin.hy_relation"),
}),
);
return parts.join(" · ");
}
parts.push(
graph.hy_graph_available
? "HY Memory 还没归纳出关系,点击上方按钮运行 System 2"
: "还没配置可用模型,无法读取 HY Memory 的归纳关系",
graph.hy_graph_available ? t("wb.noRelations") : t("wb.noModelRelations"),
);
return parts.join(" · ");
}
function edgeOriginLabel(origin: MemoryGraphEdge["origin"]): string {
if (origin === "hy_relation") return "HY Memory 归纳";
if (origin === "same_conversation") return "同一次对话";
function edgeOriginLabel(
origin: MemoryGraphEdge["origin"],
t: Translate,
): string {
if (origin === "hy_relation") return t("wb.origin.hy_relation");
if (origin === "same_conversation") return t("wb.origin.same_conversation");
// Named before the fallthrough. The chain ends in 原话来源, so a missing
// case here is not an error — it is a line that says the wrong thing.
if (origin === "shared_tag") return "同一个主题";
return "原话来源";
if (origin === "shared_tag") return t("wb.origin.shared_tag");
return t("wb.origin.provenance");
}
/** Walk out from one node so each drawn line can be checked by hand. */
function relationLabel(relation: string): string {
const labels: Record<string, string> = {
evidence_source: "来源于",
same_conversation: "同一次对话",
shared_tag: "同一个主题",
derived_from: "归纳自",
tagged_with: "属于主题",
related_to: "相关",
function relationLabel(relation: string, t: Translate): string {
const labels: Record<string, Parameters<Translate>[0]> = {
evidence_source: "wb.relation.evidence_source",
same_conversation: "wb.relation.same_conversation",
shared_tag: "wb.relation.shared_tag",
derived_from: "wb.relation.derived_from",
tagged_with: "wb.relation.tagged_with",
related_to: "wb.relation.related_to",
};
return labels[relation] ?? relation.replaceAll("_", " ");
const key = labels[relation];
return key ? t(key) : relation.replaceAll("_", " ");
}
function localizedProvenance(detail: MemoryDetail, t: Translate): string {
const evidence = detail.evidence.filter((item) => item.available);
if (
evidence.length &&
evidence.every((item) => item.source_id === "memory-content-export")
) {
const authors = new Set(
evidence.map((item) => item.written_by ?? "unknown"),
);
const author =
authors.size !== 1
? t("provenance.multiple")
: authors.has("person")
? t("provenance.person")
: authors.has("agent")
? t("provenance.agent")
: t("provenance.unknown");
return t("provenance.imported", { author });
}
return detail.memory.origin === "manual"
? t("provenance.manual")
: detail.memory.origin === "answered"
? t("provenance.answered")
: detail.memory.origin === "agent"
? t("provenance.generated")
: t("provenance.conversation");
}
function localizedMemoryText(text: string, t: Translate): string {
const key = `memory:${text}`;
return isMessageKey(key) ? t(key) : text;
}
/** Two numbers rather than one total.
+17 -2
View File
@@ -139,7 +139,9 @@
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
white-space: normal;
overflow-wrap: anywhere;
text-align: end;
cursor: pointer;
}
.r6-app-detail-status .r6-button-spinner {
@@ -213,7 +215,20 @@
padding: 20px 16px;
}
.r6-app-settings {
padding: 0 18px;
padding: 0;
margin: 0;
}
.r6-app-detail > .r6-button:first-of-type {
position: static;
width: auto;
max-width: 100%;
}
.r6-app-detail > header {
margin: 24px 0;
min-width: 0;
}
.r6-app-detail > header h2 {
overflow-wrap: anywhere;
}
}
@media (prefers-reduced-transparency: reduce) {
+26 -7
View File
@@ -339,7 +339,9 @@
min-width: 0;
}
.r6-home-apps > header {
height: 56px;
min-height: 56px;
flex-wrap: wrap;
padding-block: 8px;
}
button.r6-home-expand {
padding: 8px;
@@ -411,18 +413,19 @@ button.r6-home-expand:active:not(:disabled) {
flex: 0 0 auto;
}
.r6-home-app-row {
height: 96px;
min-height: 96px;
box-sizing: border-box;
border-bottom: 1px solid #d0dde5;
}
.r6-home[data-expanded="true"] .r6-home-app-row {
height: 112px;
min-height: 112px;
}
.r6-home-row-content {
display: flex;
align-items: center;
gap: 20px;
height: 100%;
min-height: inherit;
padding-block: 12px;
}
.r6-home-app-row img,
.r6-home-row-content > svg {
@@ -447,6 +450,8 @@ button.r6-home-expand:active:not(:disabled) {
}
.r6-home-app-row .r6-home-app-meta {
width: 220px;
max-width: 30%;
overflow-wrap: anywhere;
flex-shrink: 0;
margin: 0;
text-align: right;
@@ -456,8 +461,9 @@ button.r6-home-expand:active:not(:disabled) {
}
.r6-home-app-row .r6-button {
min-width: 78px;
min-height: 26px;
height: 26px;
flex-shrink: 0;
min-height: 32px;
height: auto;
padding: 0;
font-size: 12px;
}
@@ -510,7 +516,20 @@ button.r6-home-expand:focus-visible,
padding: 0 18px 12px;
}
.r6-home-row-content {
gap: 12px;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 8px 12px;
}
.r6-home-app-row .r6-home-app-meta {
grid-column: 2;
grid-row: 2;
width: auto;
max-width: none;
text-align: left;
}
.r6-home-row-content > .r6-button {
grid-column: 3;
grid-row: 1 / 3;
}
.r6-home-heading {
flex-wrap: wrap;
@@ -0,0 +1,37 @@
import {
type GuiLocale,
isMessageKey,
type MessageKey,
translate,
translateCount,
} from "@one-memory/i18n";
import { useGuiLanguage } from "./gui-language";
// Presentation-only adapter over the shared "apps." catalog: never translate
// imported content, names or error codes.
type Namespaced = Extract<MessageKey, `apps.${string}`>;
export type ApplicationsMessage = Namespaced extends `apps.${infer Key}`
? Key extends `${string}.one`
? never
: Key
: never;
export function applicationsText(
locale: GuiLocale,
key: ApplicationsMessage,
params?: Record<string, string | number>,
): string {
const full = `apps.${key}` as const;
if (!isMessageKey(full)) return key;
return typeof params?.count === "number"
? translateCount(locale, full, params.count, params)
: translate(locale, full, params);
}
export function useApplicationsLanguage() {
const { locale } = useGuiLanguage();
return {
locale,
a: (key: ApplicationsMessage, params?: Record<string, string | number>) =>
applicationsText(locale, key, params),
};
}
@@ -16,8 +16,11 @@ vi.mock("./api", () => ({
import {
type CallEvent,
callDeliveryLabel,
callDetailHeadline,
callEventHeadline,
callHostName,
callReturnDeliveryLabel,
callRowDetail,
callRowHeadline,
clearAllCallEvents,
@@ -59,6 +62,32 @@ beforeEach(() => {
vi.resetAllMocks();
fake.list.mockResolvedValue(structuredClone(page));
});
it("traduce entrega y límites sin atribuir adopción ni alterar anfitriones desconocidos", () => {
const delivered = {
...event,
host_id: "Aplicación 中文",
delivery: "delivered" as const,
};
expect(callDeliveryLabel(delivered, "es")).toBe(
"Entregado a Aplicación 中文",
);
expect(callDeliveryLabel(delivered)).toBe("已提供给 Aplicación 中文");
expect(callDeliveryLabel(event, "es")).toBe("");
expect(callReturnDeliveryLabel(event, "es")).toBe("");
expect(
callReturnDeliveryLabel({ ...delivered, state: "timed_out" }, "es"),
).toContain("Aplicación 中文 respondió sin memorias");
expect(
callReturnDeliveryLabel({ ...event, state: "denied" }, "es"),
).toContain("No se entregaron memorias");
expect(callReturnDeliveryLabel({ ...event, state: "partial" }, "es")).toBe(
"Parte del contenido sigue procesándose.",
);
expect(callReturnDeliveryLabel({ ...event, state: "unavailable" })).toBe(
"MemHub 暂时不可用,这次回答没有带上记忆。",
);
});
it("reads exact bounded history and current reference metadata", async () => {
expect(await getCallHistory()).toEqual(page);
expect(fake.list).toHaveBeenCalledExactlyOnceWith({ limit: 20, offset: 0 });
@@ -199,3 +228,29 @@ it.each(
fake.references.mockResolvedValue(refs);
await expect(getCallReferences(event)).rejects.toThrow();
});
it("names a direct (host_id null) call in the locale it receives", () => {
const direct: CallEvent = { ...event, host_id: null, state: "empty" };
expect(callHostName(null, "es")).toBe("Consulta directa (diagnóstico)");
expect(callHostName(null)).toBe("直接调用(诊断)");
expect(callEventHeadline(direct, undefined, "es")).toBe(
"Consulta directa (diagnóstico) no encontró memorias relacionadas",
);
expect(callDetailHeadline(direct, undefined, "es")).toBe(
"Consulta directa (diagnóstico) no encontró memorias relacionadas",
);
const delivered: CallEvent = {
...direct,
state: "grounded",
delivery: "delivered",
};
expect(callRowDetail(delivered, "es")).toBe(
"Entregado a Consulta directa (diagnóstico)",
);
for (const text of [
callEventHeadline(direct, undefined, "es"),
callRowDetail(delivered, "es"),
])
expect(text).not.toMatch(/[\u4e00-\u9fff。()]/);
// Unknown host ids are names, never catalog keys.
expect(callHostName("toString", "es")).toBe("toString");
});
+77 -45
View File
@@ -1,3 +1,10 @@
import {
type GuiLocale,
type MessageKey,
plainCount,
translate,
translateCount,
} from "@one-memory/i18n";
import {
clearAllCallEventsRequest,
clearCallEventsRequest,
@@ -47,12 +54,6 @@ export interface CallHistoryPage {
retention_days: number | null;
max_events: number | null;
}
export const CALL_TOOLS: Record<CallEvent["tool"], string> = {
om_recall: "查记忆",
om_conversations: "查记忆",
om_ask: "查记忆",
om_matters: "查记忆",
};
export const CALL_HOSTS: Record<string, string> = {
cursor: "Cursor",
codex: "Codex",
@@ -64,60 +65,91 @@ export const CALL_HOSTS: Record<string, string> = {
pi: "Pi",
"deepseek-harness": "DeepSeek Harness",
};
export function callHostName(id: string | null) {
return id === null ? "直接调用(诊断)" : (CALL_HOSTS[id] ?? id);
export function callHostName(id: string | null, locale: GuiLocale = "zh-CN") {
return id === null
? translate(locale, "calls.directHost")
: Object.hasOwn(CALL_HOSTS, id)
? CALL_HOSTS[id]
: id;
}
const HEADLINES = {
timed_out: "calls.headline.timedOut",
unavailable: "calls.headline.unavailable",
denied: "calls.headline.denied",
empty: "calls.headline.empty",
} as const satisfies Partial<Record<CallEvent["state"], MessageKey>>;
/** The host defaults to its name in the same locale as the sentence. */
export function callEventHeadline(
item: CallEvent,
host = callHostName(item.host_id),
host?: string,
locale: GuiLocale = "zh-CN",
) {
const name = host ?? callHostName(item.host_id, locale);
const fixed = HEADLINES[item.state as keyof typeof HEADLINES];
if (fixed) return translate(locale, fixed, { host: name });
const count = item.raw_count + item.memory_count;
if (item.state === "timed_out") return `${host} 这次没来得及查到记忆`;
if (item.state === "unavailable") return `${host} 暂时无法查记忆`;
if (item.state === "denied") return `${host} 未获准查记忆`;
if (item.state === "empty") return `${host} 没有查到相关记忆`;
return `${host} 查到 ${count} 条相关内容`;
return translateCount(locale, "calls.headline.found", count, {
host: name,
count: plainCount(locale, count),
});
}
export function callRowHeadline(item: CallEvent) {
return callEventHeadline(item);
export function callRowHeadline(item: CallEvent, locale: GuiLocale = "zh-CN") {
return callEventHeadline(item, undefined, locale);
}
export function callRowDetail(item: CallEvent) {
const host = callHostName(item.host_id);
if (item.state === "timed_out")
return "后台正在整理记忆,查询超时,这次回答没有带上记忆";
if (item.state === "unavailable")
return "One Memory 暂时不可用,这次回答没有带上记忆";
if (item.state === "denied") return "当前授权不允许这次查询";
if (item.state === "partial") {
return item.delivery === "delivered"
? `已提供给 ${host} · 部分内容还在整理中`
: "部分内容还在整理中";
}
if (item.delivery === "delivered") return `已提供给 ${host}`;
if (item.delivery === "not_delivered") return "没有提供给应用";
return item.state === "empty" ? "这次没有找到相关记忆" : "查询成功";
export function callRowDetail(item: CallEvent, locale: GuiLocale = "zh-CN") {
const host = callHostName(item.host_id, locale);
const key: MessageKey =
item.state === "timed_out"
? "calls.detail.timedOut"
: item.state === "unavailable"
? "calls.detail.unavailable"
: item.state === "denied"
? "calls.detail.denied"
: item.state === "partial"
? item.delivery === "delivered"
? "calls.detail.partialDelivered"
: "calls.detail.partial"
: item.delivery === "delivered"
? "calls.detail.delivered"
: item.delivery === "not_delivered"
? "calls.detail.notDelivered"
: item.state === "empty"
? "calls.detail.empty"
: "calls.detail.success";
return translate(locale, key, { host });
}
export function callDetailHeadline(
item: CallEvent,
host = callHostName(item.host_id),
host?: string,
locale: GuiLocale = "zh-CN",
) {
return callEventHeadline(item, host);
return callEventHeadline(item, host, locale);
}
export function callDeliveryLabel(item: CallEvent) {
export function callDeliveryLabel(
item: CallEvent,
locale: GuiLocale = "zh-CN",
) {
if (item.delivery === "delivered")
return `已提供给 ${callHostName(item.host_id)}`;
if (item.delivery === "not_delivered") return "没有提供给应用";
return translate(locale, "calls.detail.delivered", {
host: callHostName(item.host_id, locale),
});
if (item.delivery === "not_delivered")
return translate(locale, "calls.detail.notDelivered");
return "";
}
export function callReturnDeliveryLabel(item: CallEvent) {
const host = callHostName(item.host_id);
if (item.state === "timed_out")
return `One Memory 正在导入和整理你的记忆,这次没能及时查完,所以 ${host} 这次是在没有记忆的情况下回答的。整理完成后会恢复正常。`;
if (item.state === "unavailable")
return "One Memory 暂时不可用,这次回答没有带上记忆。";
if (item.state === "denied")
return "当前授权不允许这次查询,没有向应用提供记忆。";
if (item.state === "partial") return "部分内容还在整理中。";
export function callReturnDeliveryLabel(
item: CallEvent,
locale: GuiLocale = "zh-CN",
) {
if (
item.state === "timed_out" ||
item.state === "unavailable" ||
item.state === "denied" ||
item.state === "partial"
)
return translate(locale, `calls.return.${item.state}`, {
host: callHostName(item.host_id, locale),
});
return "";
}
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
+3 -1
View File
@@ -25,7 +25,8 @@
line-height: 45px;
}
.call-history-back.r6-button {
width: 132px;
width: auto;
min-width: 132px;
}
.call-history .r6-button-label {
display: inline-flex;
@@ -65,6 +66,7 @@
}
.call-history-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 12px;
+70
View File
@@ -0,0 +1,70 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { clockStamp } from "./clock-stamp";
// Controlled clock and zone: "now" is 2026-09-20 14:30 in Buenos Aires.
const NOW = "2026-09-20T17:30:00Z";
const previousTz = process.env.TZ;
beforeEach(() => {
process.env.TZ = "America/Argentina/Buenos_Aires";
vi.useFakeTimers();
vi.setSystemTime(new Date(NOW));
});
afterEach(() => {
vi.useRealTimers();
if (previousTz === undefined) delete process.env.TZ;
else process.env.TZ = previousTz;
});
describe("clockStamp", () => {
it.each([null, undefined, "", "not a date"])(
"reports an unknown time for %j",
(value) => {
expect(clockStamp(value)).toBe("时间未知");
expect(clockStamp(value, "zh-CN")).toBe("时间未知");
expect(clockStamp(value, "es")).toBe("Hora desconocida");
},
);
it("says today in the requested language, in the local zone", () => {
// 12:05 UTC is 09:05 local on the same day.
const value = "2026-09-20T12:05:00Z";
expect(clockStamp(value, "es")).toBe("Hoy 09:05");
expect(clockStamp(value, "zh-CN")).toBe("今天 09:05");
});
it("defaults to Chinese", () => {
expect(clockStamp("2026-09-20T12:05:00Z")).toBe("今天 09:05");
});
it("uses the local day, not the UTC day", () => {
// 02:00 UTC on the 20th is still the 19th in Buenos Aires.
const value = "2026-09-20T02:00:00Z";
expect(clockStamp(value, "es")).not.toMatch(/^Hoy/);
expect(clockStamp(value, "zh-CN")).not.toMatch(/^今天/);
expect(clockStamp(value, "es")).toBe(
new Intl.DateTimeFormat("es", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value)),
);
expect(clockStamp(value, "es")).toContain("19");
});
it("formats another date with month and day, without a today word", () => {
const value = "2026-08-03T15:07:00Z";
const es = clockStamp(value, "es");
const zh = clockStamp(value, "zh-CN");
// Spanish short date is day/month: 3 August. Padding varies across ICU
// versions, so only the order is pinned.
expect(es).toMatch(/^0?3\/0?8\b/);
expect(es).toContain("12:07");
expect(es).not.toMatch(/Hoy|今天/);
expect(zh).toContain("12:07");
expect(zh).not.toMatch(/Hoy|今天/);
expect(clockStamp(value)).toBe(zh);
});
});
+11 -5
View File
@@ -1,3 +1,5 @@
import { type GuiLocale, translate } from "@one-memory/i18n";
/**
* One clock format for every user-facing timestamp.
*
@@ -14,11 +16,15 @@
* Unparseable input returns 时间未知 rather than a fabricated date. That string
* is already asserted across several suites, so it stays exactly as-is.
*/
export function clockStamp(value: string | null | undefined): string {
if (!value || !Number.isFinite(Date.parse(value))) return "时间未知";
export function clockStamp(
value: string | null | undefined,
locale: GuiLocale = "zh-CN",
): string {
if (!value || !Number.isFinite(Date.parse(value)))
return translate(locale, "clock.unknown");
const date = new Date(value);
const now = new Date();
const clock = new Intl.DateTimeFormat("zh-CN", {
const clock = new Intl.DateTimeFormat(locale, {
hour: "2-digit",
minute: "2-digit",
}).format(date);
@@ -27,8 +33,8 @@ export function clockStamp(value: string | null | undefined): string {
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate()
)
return `今天 ${clock}`;
return new Intl.DateTimeFormat("zh-CN", {
return translate(locale, "clock.today", { clock });
return new Intl.DateTimeFormat(locale, {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
+10 -4
View File
@@ -1,3 +1,5 @@
import { type GuiLocale, translate } from "@one-memory/i18n";
// Log export is offered from two places (the health view and the settings
// help card). Both must report the same outcome wording, so the desktop
// bridge call and the message live here instead of being copied.
@@ -6,17 +8,21 @@ export type DiagnosticsExportResult = {
message: string;
};
export async function exportDiagnosticsFile(): Promise<DiagnosticsExportResult> {
export async function exportDiagnosticsFile(
locale: GuiLocale = "zh-CN",
): Promise<DiagnosticsExportResult> {
if (!window.oneMemory?.exportDiagnostics) {
return { ok: false, message: "请在桌面 App 中导出诊断信息。" };
return { ok: false, message: translate(locale, "diagnosticExportDesktop") };
}
try {
const receipt = await window.oneMemory.exportDiagnostics();
return {
ok: true,
message: `日志已导出:${receipt.filename ?? "诊断文件"}`,
message: translate(locale, "diagnosticExported", {
filename: receipt.filename ?? translate(locale, "diagnosticFile"),
}),
};
} catch {
return { ok: false, message: "诊断信息未能导出,请重试。" };
return { ok: false, message: translate(locale, "diagnosticExportFailed") };
}
}
+14 -14
View File
@@ -3,8 +3,8 @@
display: grid;
grid-template-columns: minmax(0, 738px) 278px;
gap: 30px;
height: 560.2px;
min-height: 0;
min-height: 100%;
height: auto;
}
.first-import-r6-main {
min-width: 0;
@@ -35,13 +35,12 @@
line-height: 20px;
}
.first-import-r6-main > .r6-import-progress {
flex: 0 0 218px;
flex: 0 0 auto;
}
.first-import-r6-feed {
min-height: 0;
flex: 1;
flex: 0 0 auto;
margin-top: 26px;
overflow: auto;
overflow: visible;
scrollbar-width: thin;
overscroll-behavior: contain;
}
@@ -225,10 +224,8 @@
margin: 6px 0;
}
.first-import-r6-footer-note {
flex: 1;
flex: 1 1 240px;
min-width: 0;
max-height: 64px;
overflow: auto;
}
.first-import-r6-footer-note p {
margin: 0;
@@ -245,12 +242,15 @@
}
.first-import-r6-footer-actions {
display: flex;
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
flex-wrap: wrap;
gap: 16px;
gap: 12px;
align-items: center;
}
.first-import-r6-footer-actions > [data-variant="primary"] {
min-width: 210px;
min-width: min(160px, 100%);
}
.first-import-r6-footer-actions .r6-button-label {
display: inline-flex;
@@ -403,18 +403,18 @@ dialog.first-import-r6-original[open] {
}
@media (min-width: 761px) and (max-height: 959px) {
.onboarding-r6-body:has(> .first-import-r6-layout) {
overflow: hidden;
overflow-y: auto;
padding-top: 16px;
padding-bottom: 16px;
}
.first-import-r6-layout {
height: 100%;
height: auto;
}
}
@media (min-width: 761px) and (max-height: 859px) {
.first-import-r6-options {
padding: 14px;
overflow: hidden;
overflow: visible;
}
.first-import-r6-eyebrow {
margin-bottom: 4px;
+8
View File
@@ -6,6 +6,7 @@ import type {
ConnectionSetupResult,
ConnectionSetupSelection,
} from "@one-memory/contracts";
import type { LanguagePreference, LanguageSnapshot } from "@one-memory/i18n";
import type { DebugLogSnapshot } from "./debug-log-types.js";
export type RecallPermissionTool =
@@ -219,6 +220,13 @@ declare global {
buildNumber: string;
commit: string;
};
getLanguagePreference?(): Promise<LanguageSnapshot>;
setLanguagePreference?(
preference: LanguagePreference,
): Promise<LanguageSnapshot>;
onLanguagePreferenceChanged?(
listener: (snapshot: LanguageSnapshot) => void,
): () => void;
getDebugLogs?(): Promise<DebugLogSnapshot>;
/** Renderer end of the four-step UI/kernel trace. */
logUiTrace?(input: {
@@ -0,0 +1,140 @@
// @vitest-environment jsdom
import type { LanguageSnapshot } from "@one-memory/i18n";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { StrictMode } from "react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import {
LANGUAGE_STORAGE_KEY,
LanguageProvider,
useGuiLanguage,
} from "./gui-language";
function Probe() {
const { locale, preference, error, setPreference } = useGuiLanguage();
return (
<>
<output>
{locale}:{preference}
</output>
<button type="button" onClick={() => void setPreference("es")}>
Spanish
</button>
{error && <p role="alert">{error}</p>}
</>
);
}
function mount() {
return render(
<StrictMode>
<LanguageProvider>
<Probe />
</LanguageProvider>
</StrictMode>,
);
}
beforeEach(() => {
localStorage.clear();
delete window.oneMemory;
});
afterEach(() => {
cleanup();
delete window.oneMemory;
vi.restoreAllMocks();
localStorage.clear();
});
it("removes browser listeners on StrictMode cleanup and final unmount", () => {
const add = vi.spyOn(window, "addEventListener");
const remove = vi.spyOn(window, "removeEventListener");
const view = mount();
view.unmount();
for (const type of ["storage", "languagechange"]) {
const callbacks = add.mock.calls
.filter(([name]) => name === type)
.map(([, callback]) => callback);
expect(callbacks).toHaveLength(2);
for (const callback of callbacks)
expect(remove).toHaveBeenCalledWith(type, callback);
}
});
it("ignores unrelated storage and language changes for explicit preferences", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
mount();
vi.spyOn(navigator, "language", "get").mockReturnValue("es");
act(() => {
window.dispatchEvent(new Event("languagechange"));
window.dispatchEvent(
new StorageEvent("storage", { key: "unrelated", newValue: "es" }),
);
window.dispatchEvent(
new StorageEvent("storage", {
key: LANGUAGE_STORAGE_KEY,
storageArea: sessionStorage,
}),
);
});
expect(screen.getByText("zh-CN:zh-CN")).toBeTruthy();
});
it("shows errors for unavailable desktop bridges without using browser fallback", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
window.oneMemory = {} as NonNullable<Window["oneMemory"]>;
const read = vi.spyOn(Storage.prototype, "getItem");
const write = vi.spyOn(Storage.prototype, "setItem");
mount();
expect(screen.getByRole("alert").textContent).toBe("languageLoadError");
await userEvent.click(screen.getByRole("button"));
expect(screen.getByRole("alert").textContent).toBe("languageSaveError");
expect(screen.getByText("zh-CN:system")).toBeTruthy();
expect(read).not.toHaveBeenCalled();
expect(write).not.toHaveBeenCalled();
});
it("does not surface a stale read rejection after an authoritative event", async () => {
let rejectRead!: (reason?: unknown) => void;
let listener!: (value: LanguageSnapshot) => void;
const pending = new Promise<LanguageSnapshot>((_, reject) => {
rejectRead = reject;
});
window.oneMemory = {
getLanguagePreference: () => pending,
onLanguagePreferenceChanged: (next: typeof listener) => {
listener = next;
return () => {};
},
} as NonNullable<Window["oneMemory"]>;
mount();
await act(async () => {
listener({ preference: "es", locale: "es", revision: 3 });
rejectRead(new Error("late failure"));
});
expect(screen.getByText("es:es")).toBeTruthy();
expect(screen.queryByRole("alert")).toBeNull();
});
it("validates native replies and keeps the last confirmed snapshot when saving fails", async () => {
const set = vi.fn().mockRejectedValue(new Error("denied"));
let listener!: (value: LanguageSnapshot) => void;
window.oneMemory = {
getLanguagePreference: async () => ({
preference: "bad",
locale: "es",
revision: 0,
}),
setLanguagePreference: set,
onLanguagePreferenceChanged: (next: typeof listener) => {
listener = next;
return () => {};
},
} as unknown as NonNullable<Window["oneMemory"]>;
mount();
await waitFor(() =>
expect(screen.getByRole("alert").textContent).toBe("languageLoadError"),
);
act(() => listener({ preference: "system", locale: "zh-CN", revision: 2 }));
await userEvent.click(screen.getByRole("button"));
expect(set).toHaveBeenCalledWith("es");
expect(screen.getByRole("alert").textContent).toBe("languageSaveError");
expect(screen.getByText("zh-CN:system")).toBeTruthy();
act(() => listener({ preference: "system", locale: "zh-CN", revision: 2 }));
expect(screen.queryByRole("alert")).toBeNull();
});
+261
View File
@@ -0,0 +1,261 @@
// @vitest-environment jsdom
import type { LanguageSnapshot } from "@one-memory/i18n";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { StrictMode } from "react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import {
LANGUAGE_STORAGE_KEY,
LanguageProvider,
useGuiLanguage,
} from "./gui-language";
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
function Probe() {
const language = useGuiLanguage();
return (
<>
<output>
{language.locale}:{language.preference}
</output>
<input aria-label="draft" defaultValue="draft" />
<button onClick={() => void language.setPreference("es")} type="button">
Spanish
</button>
{language.error && <p role="alert">{language.error}</p>}
</>
);
}
function mount() {
return render(
<StrictMode>
<LanguageProvider>
<Probe />
</LanguageProvider>
</StrictMode>,
);
}
function desktop(get: () => Promise<LanguageSnapshot>) {
const listeners = new Set<(value: LanguageSnapshot) => void>();
const unsubscribe = vi.fn();
const bridge = {
getLanguagePreference: vi.fn(() => {
expect(listeners.size).toBe(1);
return get();
}),
setLanguagePreference: vi
.fn()
.mockResolvedValue({ preference: "es", locale: "es", revision: 2 }),
onLanguagePreferenceChanged: vi.fn(
(listener: (value: LanguageSnapshot) => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
unsubscribe();
};
},
),
};
window.oneMemory = bridge as unknown as NonNullable<Window["oneMemory"]>;
return { bridge, listeners, unsubscribe };
}
beforeEach(() => {
localStorage.clear();
delete window.oneMemory;
vi.spyOn(navigator, "language", "get").mockReturnValue("zh-CN");
});
afterEach(() => {
cleanup();
delete window.oneMemory;
vi.restoreAllMocks();
localStorage.clear();
});
it("persists before publishing and retains form state without remounting", async () => {
const user = userEvent.setup();
mount();
await user.type(screen.getByRole("textbox"), " preserved");
await user.click(screen.getByRole("button"));
expect(localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe("es");
expect(screen.getByText("es:es")).toBeTruthy();
expect(document.documentElement.lang).toBe("es");
expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe(
"draft preserved",
);
cleanup();
mount();
expect(screen.getByText("es:es")).toBeTruthy();
});
it("keeps confirmed language and exposes storage write errors", async () => {
mount();
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("private storage");
});
await userEvent.click(screen.getByRole("button"));
expect(screen.getByText("zh-CN:system")).toBeTruthy();
expect(screen.getByRole("alert").textContent).toBe("languageSaveError");
});
it("exposes browser read errors without crashing", () => {
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new Error("denied");
});
mount();
expect(screen.getByRole("alert").textContent).toBe("languageLoadError");
});
it("validates browser storage and synchronizes tabs and principal navigator language", () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "invalid");
mount();
expect(screen.getByText("zh-CN:system")).toBeTruthy();
act(() => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
window.dispatchEvent(
new StorageEvent("storage", {
key: LANGUAGE_STORAGE_KEY,
storageArea: localStorage,
}),
);
});
expect(screen.getByText("es:es")).toBeTruthy();
act(() => {
localStorage.removeItem(LANGUAGE_STORAGE_KEY);
window.dispatchEvent(
new StorageEvent("storage", { key: null, storageArea: localStorage }),
);
});
vi.spyOn(navigator, "language", "get").mockReturnValue("es-MX");
act(() => window.dispatchEvent(new Event("languagechange")));
expect(screen.getByText("es:system")).toBeTruthy();
});
it("subscribes before reads, rejects stale snapshots and cleans StrictMode subscriptions", async () => {
const reads = [deferred<LanguageSnapshot>(), deferred<LanguageSnapshot>()];
let index = 0;
const { listeners, unsubscribe } = desktop(() => reads[index++].promise);
const view = mount();
expect(listeners.size).toBe(1);
act(() => {
for (const listener of listeners)
listener({ preference: "es", locale: "es", revision: 5 });
});
await act(async () => {
for (const read of reads)
read.resolve({ preference: "zh-CN", locale: "zh-CN", revision: 1 });
});
expect(screen.getByText("es:es")).toBeTruthy();
view.unmount();
expect(listeners.size).toBe(0);
expect(unsubscribe).toHaveBeenCalledTimes(2);
});
it("never accesses browser persistence or browser locale in desktop mode and exposes read failures", async () => {
const { listeners } = desktop(() => Promise.reject(new Error("unavailable")));
const read = vi.spyOn(Storage.prototype, "getItem");
const write = vi.spyOn(Storage.prototype, "setItem");
mount();
await waitFor(() =>
expect(screen.getByRole("alert").textContent).toBe("languageLoadError"),
);
vi.spyOn(navigator, "language", "get").mockReturnValue("es");
act(() => window.dispatchEvent(new Event("languagechange")));
expect(screen.getByText("zh-CN:system")).toBeTruthy();
act(() => {
for (const listener of listeners)
listener({ preference: "system", locale: "es", revision: 3 });
});
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.getByText("es:system")).toBeTruthy();
expect(read).not.toHaveBeenCalled();
expect(write).not.toHaveBeenCalled();
});
it("does not surface an obsolete write failure after a newer save succeeds", async () => {
const { bridge } = desktop(async () => ({
preference: "system",
locale: "zh-CN",
revision: 0,
}));
const first = deferred<LanguageSnapshot>();
bridge.setLanguagePreference
.mockReturnValueOnce(first.promise)
.mockResolvedValueOnce({ preference: "es", locale: "es", revision: 2 });
mount();
await act(async () => {});
await userEvent.click(screen.getByRole("button"));
await userEvent.click(screen.getByRole("button"));
expect(screen.getByText("es:es")).toBeTruthy();
await act(async () => first.reject(new Error("obsolete failure")));
expect(screen.queryByRole("alert")).toBeNull();
});
it("rejects conflicting native locale and explicit preference pairs", async () => {
desktop(async () => ({ preference: "es", locale: "zh-CN", revision: 1 }));
mount();
await waitFor(() =>
expect(screen.getByRole("alert").textContent).toBe("languageLoadError"),
);
expect(screen.getByText("zh-CN:system")).toBeTruthy();
});
it("does not allow a late write reply to overwrite a newer native event", async () => {
const { bridge, listeners } = desktop(async () => ({
preference: "system",
locale: "zh-CN",
revision: 0,
}));
const write = deferred<LanguageSnapshot>();
bridge.setLanguagePreference.mockReturnValue(write.promise);
mount();
await act(async () => {});
await userEvent.click(screen.getByRole("button"));
act(() => {
for (const listener of listeners)
listener({ preference: "zh-CN", locale: "zh-CN", revision: 8 });
});
await act(async () =>
write.resolve({ preference: "es", locale: "es", revision: 7 }),
);
expect(screen.getByText("zh-CN:zh-CN")).toBeTruthy();
});
it("groups counts in Chinese only where tn asks for it, never with tnPlain", () => {
function Counts() {
const { tn, tnPlain } = useGuiLanguage();
return (
<>
<p>{tn("healthMemories", 12345)}</p>
<p>{tnPlain("healthRetrying", 12345)}</p>
<p>{tnPlain("healthRetrying", 1)}</p>
</>
);
}
const view = (stored: string | null) => {
if (stored) localStorage.setItem(LANGUAGE_STORAGE_KEY, stored);
const { container, unmount } = render(
<LanguageProvider>
<Counts />
</LanguageProvider>,
);
const lines = [...container.querySelectorAll("p")].map(
(p) => p.textContent,
);
unmount();
localStorage.clear();
return lines;
};
expect(view(null)).toEqual([
"12,345 条记忆",
"12345 条这次没能整理成功,正在重试",
"1 条这次没能整理成功,正在重试",
]);
expect(view("es")).toEqual([
"12.345 memorias",
"12.345 no se pudieron procesar esta vez; reintentando",
"1 no se pudo procesar esta vez; reintentando",
]);
});
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff. Show more