750d74a2af
- omp-share.js: an admin's switch (S3) runs omp's own auth broker + gateway as the host on loopback; a member's omp pane reaches it as a scoped client keyed by its pane token (clients file = live omp panes only), runs on --model agenthub/<model>, never holds a credential. - omp catalog: --allow-home, so a workspace at the account's home keeps its sessions there (they were recorded under a temp dir and dropped). - Settings → Appearance → Open Markdown as: Preview (default) or Raw, for Files/Changes, the config viewer and a chat's file panel. - First-login password change and password rules, avatar URLs, Artifacts "Mine" includes installed shared skills/plugins, a member's own hook pages.
197 lines
6.1 KiB
TypeScript
197 lines
6.1 KiB
TypeScript
/**
|
|
* @file userPrefs.ts
|
|
* @description An account's UI preferences, kept per ACCOUNT on the server
|
|
* (`/api/me/prefs`, docs/plans/onboarding) while every component goes on
|
|
* reading and writing localStorage as it always did. On sign-in `hydrate()`
|
|
* copies the account's stored values into localStorage (the account wins),
|
|
* sends up the ones this browser has and the account does not yet, and makes
|
|
* the theme, language and pet re-read them. From then on a write to one of
|
|
* `PREF_KEYS` through `localStorage.setItem`/`removeItem` is sent to the
|
|
* server, batched. `useOnboarding()` tells the setup wizard whether this
|
|
* account has been through it. Never throws: a server that cannot answer
|
|
* leaves the browser's own values in charge. The same row also holds
|
|
* `agenthub.chatUi`, which `lib/chatUi.ts` reads and writes on the server
|
|
* directly (no localStorage copy).
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useSyncExternalStore } from "react";
|
|
import { api } from "./api";
|
|
import i18n from "../i18n";
|
|
import { applyStoredTheme } from "../hooks/useTheme";
|
|
import { FONT_SIZE_EVENT, applyUiFontSize } from "./fontSize";
|
|
|
|
/** The localStorage keys an account's preferences are made of (server/routes/me.js). */
|
|
export const PREF_KEYS = [
|
|
"theme",
|
|
"agenthub.theme.dark",
|
|
"agenthub.theme.light",
|
|
"i18nextLng",
|
|
"agenthub.terminal.font",
|
|
"agenthub.terminal.fontSize",
|
|
"agenthub.ui.fontSize",
|
|
"agenthub.pet",
|
|
"agenthub.pet.size",
|
|
"agenthub.pet.names",
|
|
"agent-dashboard-sagi-enabled",
|
|
"agent-dashboard-sagi-muted",
|
|
"agent-monitor-notifications",
|
|
"agenthub.defaultTool",
|
|
"agenthub.permissionMode",
|
|
"agenthub.codex.sandbox",
|
|
"agenthub.codex.approval",
|
|
"agenthub.codex.bypass",
|
|
"agenthub.tour",
|
|
"agenthub.workspace.allTabs",
|
|
"agenthub.markdown.view",
|
|
] as const;
|
|
const SYNCED = new Set<string>(PREF_KEYS);
|
|
const PUSH_DELAY_MS = 400;
|
|
|
|
// `forced`: Settings asked for the setup again, whatever the account has.
|
|
type State = { loaded: boolean; onboardedAt: string | null; forced: boolean };
|
|
let state: State = { loaded: false, onboardedAt: null, forced: false };
|
|
const listeners = new Set<() => void>();
|
|
const setState = (next: Partial<State>) => {
|
|
state = { ...state, ...next };
|
|
listeners.forEach((l) => l());
|
|
};
|
|
|
|
let pending: Record<string, string | null> = {};
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
let syncing = false;
|
|
|
|
function queue(key: string, value: string | null) {
|
|
if (!syncing) return;
|
|
pending[key] = value;
|
|
clearTimeout(timer);
|
|
timer = setTimeout(flush, PUSH_DELAY_MS);
|
|
}
|
|
|
|
/** Send what is queued now (also on page hide). */
|
|
export function flush(): Promise<void> {
|
|
clearTimeout(timer);
|
|
const batch = pending;
|
|
pending = {};
|
|
if (!Object.keys(batch).length) return Promise.resolve();
|
|
return api.me
|
|
.savePrefs(batch)
|
|
.then(() => undefined)
|
|
.catch(() => {
|
|
/* offline or signed out: the browser keeps its own copy */
|
|
});
|
|
}
|
|
|
|
let wrapped = false;
|
|
function wrapStorage() {
|
|
if (wrapped || typeof Storage === "undefined") return;
|
|
wrapped = true;
|
|
const proto = Storage.prototype;
|
|
const setItem = proto.setItem;
|
|
const removeItem = proto.removeItem;
|
|
proto.setItem = function (key: string, value: string) {
|
|
setItem.call(this, key, value);
|
|
if (this === window.localStorage && SYNCED.has(key)) queue(key, String(value));
|
|
};
|
|
proto.removeItem = function (key: string) {
|
|
removeItem.call(this, key);
|
|
if (this === window.localStorage && SYNCED.has(key)) queue(key, null);
|
|
};
|
|
window.addEventListener("pagehide", () => void flush());
|
|
}
|
|
|
|
function readLocal(key: string): string | null {
|
|
try {
|
|
return window.localStorage.getItem(key);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Make what reads these keys read them again. */
|
|
function reapply(prefs: Record<string, string>) {
|
|
// Painted now, not only on the next useTheme render: the login page wore the OS's.
|
|
applyStoredTheme();
|
|
window.dispatchEvent(new Event("agenthub-theme"));
|
|
window.dispatchEvent(new Event("sagi:prefs"));
|
|
applyUiFontSize();
|
|
window.dispatchEvent(new Event(FONT_SIZE_EVENT));
|
|
const lng = prefs.i18nextLng;
|
|
if (lng && lng !== i18n.language) void i18n.changeLanguage(lng);
|
|
}
|
|
|
|
/**
|
|
* Load the signed-in account's preferences into this browser and start
|
|
* sending changes. Call once a login (or local mode) is known.
|
|
*/
|
|
export async function hydrate(): Promise<void> {
|
|
syncing = false;
|
|
pending = {};
|
|
let answer;
|
|
try {
|
|
answer = await api.me.prefs();
|
|
} catch {
|
|
// No answer (an older server, a test with no API): no wizard, no sync —
|
|
// the browser's own values stay in charge.
|
|
return;
|
|
}
|
|
const up: Record<string, string> = {};
|
|
for (const key of PREF_KEYS) {
|
|
const theirs = answer.prefs[key];
|
|
if (typeof theirs === "string") {
|
|
try {
|
|
window.localStorage.setItem(key, theirs);
|
|
} catch {
|
|
/* storage blocked */
|
|
}
|
|
} else {
|
|
const mine = readLocal(key);
|
|
if (mine !== null) up[key] = mine;
|
|
}
|
|
}
|
|
wrapStorage();
|
|
syncing = true;
|
|
reapply(answer.prefs);
|
|
if (Object.keys(up).length) {
|
|
pending = { ...up };
|
|
void flush();
|
|
}
|
|
setState({ loaded: true, onboardedAt: answer.onboardedAt });
|
|
}
|
|
|
|
/** The first-run setup is done or skipped, for this account. */
|
|
export async function markOnboarded(): Promise<void> {
|
|
await flush();
|
|
const at = new Date().toISOString();
|
|
setState({ onboardedAt: at, forced: false });
|
|
try {
|
|
const r = await api.me.onboarded();
|
|
setState({ onboardedAt: r.onboardedAt || at });
|
|
} catch {
|
|
/* shown again next time — better than never saving the choices */
|
|
}
|
|
}
|
|
|
|
/** Show the setup again (Settings → System). */
|
|
export function resetOnboarding(): void {
|
|
setState({ onboardedAt: null, forced: true });
|
|
}
|
|
|
|
const subscribe = (l: () => void) => {
|
|
listeners.add(l);
|
|
return () => listeners.delete(l);
|
|
};
|
|
|
|
/** `{loaded, onboardedAt, forced}` for the account signed in now. */
|
|
export function useOnboarding(): State {
|
|
return useSyncExternalStore(subscribe, () => state);
|
|
}
|
|
|
|
/** Tests only. */
|
|
export function __resetUserPrefs(): void {
|
|
state = { loaded: false, onboardedAt: null, forced: false };
|
|
pending = {};
|
|
syncing = false;
|
|
clearTimeout(timer);
|
|
}
|