chore(web): enforce frontend architecture boundaries
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
module.exports = {
|
||||
forbidden: [
|
||||
{
|
||||
name: "no-circular",
|
||||
severity: "error",
|
||||
from: {},
|
||||
to: { circular: true },
|
||||
},
|
||||
{
|
||||
name: "contracts-are-leaves",
|
||||
severity: "error",
|
||||
from: { path: "^contracts/" },
|
||||
to: { path: "^(?!contracts/|node_modules/)" },
|
||||
},
|
||||
{
|
||||
name: "shared-does-not-depend-up",
|
||||
severity: "error",
|
||||
from: { path: "^shared/" },
|
||||
to: { path: "^(app|components|context|features)/" },
|
||||
},
|
||||
{
|
||||
name: "feature-domain-does-not-render",
|
||||
severity: "error",
|
||||
from: {
|
||||
path: "^features/[^/]+/(model|store|transport)/",
|
||||
pathNot: "^features/settings/store/SettingsStore\\.tsx$",
|
||||
},
|
||||
to: { path: "^(app|components|context)/" },
|
||||
},
|
||||
{
|
||||
name: "lib-does-not-depend-on-ui",
|
||||
severity: "error",
|
||||
from: { path: "^lib/" },
|
||||
to: { path: "^(app|components|context)/" },
|
||||
},
|
||||
{
|
||||
name: "no-route-page-imports",
|
||||
severity: "error",
|
||||
from: {},
|
||||
to: { path: "/page\\.(?:ts|tsx)$" },
|
||||
},
|
||||
],
|
||||
options: {
|
||||
doNotFollow: { path: "node_modules" },
|
||||
exclude: "(^|/)node_modules/|(^|/)\\.next/",
|
||||
tsConfig: { fileName: "tsconfig.json" },
|
||||
tsPreCompilationDeps: true,
|
||||
enhancedResolveOptions: {
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".json"],
|
||||
},
|
||||
reporterOptions: {
|
||||
dot: { collapsePattern: "node_modules/[^/]+" },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { browserStorage } from "@/shared/storage";
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import type {
|
||||
ChangeEvent,
|
||||
@@ -120,7 +122,7 @@ export default function BookChatPanel({
|
||||
useImeComposing();
|
||||
|
||||
useEffect(() => {
|
||||
const raw = window.localStorage.getItem("deeptutor.bookChat.width");
|
||||
const raw = browserStorage.readRaw("local", "deeptutor.bookChat.width");
|
||||
const parsed = Number(raw);
|
||||
if (Number.isFinite(parsed) && parsed >= 300 && parsed <= 720) {
|
||||
// Hydrate persisted panel width after the SSR-safe default render.
|
||||
@@ -130,7 +132,7 @@ export default function BookChatPanel({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem("deeptutor.bookChat.width", String(width));
|
||||
browserStorage.writeRaw("local", "deeptutor.bookChat.width", String(width));
|
||||
}, [width]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { browserStorage } from "@/shared/storage";
|
||||
|
||||
/**
|
||||
* SessionViewerPanel — full right-side sidebar with browser-style tabs that
|
||||
* can hold (a) attachment previews and (b) embedded web pages clicked from
|
||||
@@ -126,7 +128,7 @@ function clampViewerWidth(px: number): number {
|
||||
|
||||
function readStoredViewerWidth(): number {
|
||||
if (typeof window === "undefined") return VIEWER_WIDTH_DEFAULT;
|
||||
const raw = window.localStorage.getItem(VIEWER_WIDTH_KEY);
|
||||
const raw = browserStorage.readRaw("local", VIEWER_WIDTH_KEY);
|
||||
const parsed = raw ? Number(raw) : NaN;
|
||||
return Number.isFinite(parsed)
|
||||
? clampViewerWidth(parsed)
|
||||
@@ -308,7 +310,7 @@ function SessionViewerPanelInner(
|
||||
document.body.style.cursor = "";
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("pointerup", onUp);
|
||||
window.localStorage.setItem(VIEWER_WIDTH_KEY, String(widthRef.current));
|
||||
browserStorage.writeRaw("local", VIEWER_WIDTH_KEY, String(widthRef.current));
|
||||
};
|
||||
window.addEventListener("pointermove", onMove);
|
||||
window.addEventListener("pointerup", onUp);
|
||||
|
||||
@@ -2,28 +2,14 @@
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import SimpleMarkdownRenderer from "./SimpleMarkdownRenderer";
|
||||
import type { MarkdownRendererProps } from "./markdown-renderer-types";
|
||||
|
||||
export type { MarkdownRendererProps } from "./markdown-renderer-types";
|
||||
|
||||
const RichMarkdownRenderer = dynamic(() => import("./RichMarkdownRenderer"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export interface MarkdownRendererProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
variant?: "default" | "compact" | "prose" | "trace";
|
||||
enableMath?: boolean;
|
||||
enableCode?: boolean;
|
||||
enableMermaid?: boolean;
|
||||
enableImages?: boolean;
|
||||
allowHtml?: boolean;
|
||||
/**
|
||||
* When true, top-level block elements receive a `data-source-line` attribute
|
||||
* pointing at their starting line in the original markdown source. Useful for
|
||||
* editor/preview scroll synchronization.
|
||||
*/
|
||||
trackSourceLines?: boolean;
|
||||
}
|
||||
|
||||
// Detection during streaming has a subtle correctness requirement: it must
|
||||
// be monotonic. Once `true`, it should stay `true` as more tokens arrive so
|
||||
// the renderer never downgrades from Rich back to Simple (which would cause
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
parseAttachmentHref,
|
||||
useInlineFileCardContext,
|
||||
} from "@/components/common/InlineFileCard";
|
||||
import type { MarkdownRendererProps } from "./MarkdownRenderer";
|
||||
import type { MarkdownRendererProps } from "./markdown-renderer-types";
|
||||
|
||||
function MermaidLoading() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
parseAttachmentHref,
|
||||
useInlineFileCardContext,
|
||||
} from "@/components/common/InlineFileCard";
|
||||
import type { MarkdownRendererProps } from "./MarkdownRenderer";
|
||||
import type { MarkdownRendererProps } from "./markdown-renderer-types";
|
||||
|
||||
function extractText(children: React.ReactNode): string {
|
||||
return React.Children.toArray(children)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface MarkdownRendererProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
variant?: "default" | "compact" | "prose" | "trace";
|
||||
enableMath?: boolean;
|
||||
enableCode?: boolean;
|
||||
enableMermaid?: boolean;
|
||||
enableImages?: boolean;
|
||||
allowHtml?: boolean;
|
||||
/** Add source-line markers used by synchronized editor previews. */
|
||||
trackSourceLines?: boolean;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { browserStorage } from "@/shared/storage";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { Archive, ChevronDown, ChevronUp, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -18,14 +20,14 @@ export default function MemoryArchivedBanner({
|
||||
const { t } = useTranslation();
|
||||
const [dismissed, setDismissed] = useState<string | null>(() => {
|
||||
if (typeof window === "undefined") return null;
|
||||
return window.localStorage.getItem(STORAGE_KEY);
|
||||
return browserStorage.readRaw("local", STORAGE_KEY);
|
||||
});
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
if (!latestBackup) return;
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(STORAGE_KEY, latestBackup);
|
||||
browserStorage.writeRaw("local", STORAGE_KEY, latestBackup);
|
||||
}
|
||||
setDismissed(latestBackup);
|
||||
}, [latestBackup]);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { browserStorage } from "@/shared/storage";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -283,7 +285,7 @@ export default function MemorySection({
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
setDismissedBackup(
|
||||
window.localStorage.getItem("dt:memory:banner-dismissed") || null,
|
||||
browserStorage.readRaw("local", "dt:memory:banner-dismissed") || null,
|
||||
);
|
||||
}, []);
|
||||
|
||||
@@ -293,7 +295,7 @@ export default function MemorySection({
|
||||
const dismissArchivedBanner = useCallback(() => {
|
||||
if (!latestBackup) return;
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem("dt:memory:banner-dismissed", latestBackup);
|
||||
browserStorage.writeRaw("local", "dt:memory:banner-dismissed", latestBackup);
|
||||
}
|
||||
setDismissedBackup(latestBackup);
|
||||
}, [latestBackup]);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { browserStorage } from "@/shared/storage";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import i18n from "i18next";
|
||||
|
||||
@@ -60,15 +62,15 @@ function storageKey(layer: string, key: string) {
|
||||
|
||||
function readPersistedRunId(layer: string, key: string): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return window.localStorage.getItem(storageKey(layer, key));
|
||||
return browserStorage.readRaw("local", storageKey(layer, key));
|
||||
}
|
||||
|
||||
function writePersistedRunId(layer: string, key: string, runId: string | null) {
|
||||
if (typeof window === "undefined") return;
|
||||
if (runId) {
|
||||
window.localStorage.setItem(storageKey(layer, key), runId);
|
||||
browserStorage.writeRaw("local", storageKey(layer, key), runId);
|
||||
} else {
|
||||
window.localStorage.removeItem(storageKey(layer, key));
|
||||
browserStorage.removeRaw("local", storageKey(layer, key));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { browserStorage } from "@/shared/storage";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -180,7 +182,7 @@ export function ReaderPane({
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(AUTO_JUMP_KEY);
|
||||
const stored = browserStorage.readRaw("local", AUTO_JUMP_KEY);
|
||||
if (stored !== null) setAutoJump(stored === "1");
|
||||
} catch {
|
||||
// Private mode / storage disabled — keep the default.
|
||||
@@ -191,7 +193,7 @@ export function ReaderPane({
|
||||
setAutoJump((current) => {
|
||||
const next = !current;
|
||||
try {
|
||||
window.localStorage.setItem(AUTO_JUMP_KEY, next ? "1" : "0");
|
||||
browserStorage.writeRaw("local", AUTO_JUMP_KEY, next ? "1" : "0");
|
||||
} catch {
|
||||
// Non-fatal: the toggle still works for this session.
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { browserStorage } from "@/shared/storage";
|
||||
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
@@ -124,7 +126,7 @@ export function TextUnitView({
|
||||
useEffect(() => {
|
||||
try {
|
||||
const value = normaliseReaderDisplayPreferences(
|
||||
JSON.parse(window.localStorage.getItem(READER_PREFS_KEY) || "{}"),
|
||||
JSON.parse(browserStorage.readRaw("local", READER_PREFS_KEY) || "{}"),
|
||||
);
|
||||
setFontSize(value.fontSize);
|
||||
setLineWidth(value.lineWidth);
|
||||
@@ -150,7 +152,7 @@ export function TextUnitView({
|
||||
setSerif(merged.serif);
|
||||
setReaderTheme(merged.readerTheme);
|
||||
try {
|
||||
window.localStorage.setItem(READER_PREFS_KEY, JSON.stringify(merged));
|
||||
browserStorage.writeRaw("local", READER_PREFS_KEY, JSON.stringify(merged));
|
||||
} catch {
|
||||
// Preferences still apply for the current session.
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { browserStorage } from "@/shared/storage";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
@@ -144,7 +146,7 @@ export function ReadingWorkspacePage() {
|
||||
if (typeof window === "undefined") return 380;
|
||||
try {
|
||||
const stored = Number(
|
||||
window.localStorage.getItem("dt.reader.companionWidth"),
|
||||
browserStorage.readRaw("local", "dt.reader.companionWidth"),
|
||||
);
|
||||
return Number.isFinite(stored) && stored >= 300 && stored <= 640
|
||||
? stored
|
||||
@@ -260,7 +262,8 @@ export function ReadingWorkspacePage() {
|
||||
window.removeEventListener("pointerup", onUp);
|
||||
setCompanionWidth((current) => {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
browserStorage.writeRaw(
|
||||
"local",
|
||||
"dt.reader.companionWidth",
|
||||
String(current),
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ import SettingsNav, {
|
||||
} from "@/components/settings/SettingsNav";
|
||||
import { SettingsToolbar } from "@/components/settings/SettingsToolbar";
|
||||
import { SettingsLoadStatusBanner } from "@/components/settings/SettingsLoadStatusBanner";
|
||||
import { isNavOnlyRoute } from "@/lib/settings-nav";
|
||||
import { isNavOnlyRoute } from "@/features/settings/navigation/settings-nav";
|
||||
|
||||
/**
|
||||
* Settings shell: a persistent navigator on the left, one page on the right.
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
settingsAnchorHref,
|
||||
type Lang,
|
||||
type SettingsLeaf,
|
||||
} from "@/lib/settings-nav";
|
||||
} from "@/features/settings/navigation/settings-nav";
|
||||
import { serviceReadiness, useSettings } from "@/features/settings/store/SettingsStore";
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
settingsAnchorHref,
|
||||
type Lang,
|
||||
type SettingsLeaf,
|
||||
} from "@/lib/settings-nav";
|
||||
} from "@/features/settings/navigation/settings-nav";
|
||||
import {
|
||||
getActiveModel,
|
||||
getActiveProfile,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Loader2, Rocket, Save, Undo2, Wand2 } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { storagePathFor } from "@/lib/settings-nav";
|
||||
import { storagePathFor } from "@/features/settings/navigation/settings-nav";
|
||||
import { useSettings } from "@/features/settings/store/SettingsStore";
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { browserStorage } from "@/shared/storage";
|
||||
|
||||
/**
|
||||
* The workspace feature list — the part of the sidebar a learner owns.
|
||||
*
|
||||
@@ -99,7 +101,7 @@ export function SidebarNav({
|
||||
if (typeof window === "undefined") return;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLayout(readNavLayout());
|
||||
setMoreExpanded(window.localStorage.getItem(MORE_EXPANDED_KEY) === "1");
|
||||
setMoreExpanded(browserStorage.readRaw("local", MORE_EXPANDED_KEY) === "1");
|
||||
}, []);
|
||||
|
||||
const resolved = useMemo(
|
||||
@@ -120,7 +122,7 @@ export function SidebarNav({
|
||||
const showMore = useCallback((next: boolean) => {
|
||||
setMoreExpanded(next);
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(MORE_EXPANDED_KEY, next ? "1" : "0");
|
||||
browserStorage.writeRaw("local", MORE_EXPANDED_KEY, next ? "1" : "0");
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { browserStorage } from "@/shared/storage";
|
||||
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -142,7 +144,7 @@ export function MasteryStudy({
|
||||
useEffect(() => {
|
||||
try {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setOutlineOpen(localStorage.getItem(OUTLINE_STORAGE_KEY) !== "0");
|
||||
setOutlineOpen(browserStorage.readRaw("local", OUTLINE_STORAGE_KEY) !== "0");
|
||||
} catch {
|
||||
// Private mode / blocked storage: the default (open) stands.
|
||||
}
|
||||
@@ -151,7 +153,7 @@ export function MasteryStudy({
|
||||
setOutlineOpen((open) => {
|
||||
const next = !open;
|
||||
try {
|
||||
localStorage.setItem(OUTLINE_STORAGE_KEY, next ? "1" : "0");
|
||||
browserStorage.writeRaw("local", OUTLINE_STORAGE_KEY, next ? "1" : "0");
|
||||
} catch {
|
||||
// Preference is best-effort; the session still honours the toggle.
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { browserStorage } from "@/shared/storage";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
@@ -60,16 +62,16 @@ export function WatchingProvider({ children }: { children: ReactNode }) {
|
||||
setWatchingMaterial(next.material_id);
|
||||
setWatchingViewport(next.playback.start_seconds || 0);
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(LAST_MATERIAL_KEY, next.material_id);
|
||||
window.localStorage.setItem(LAST_URL_KEY, next.source.url);
|
||||
browserStorage.writeRaw("local", LAST_MATERIAL_KEY, next.material_id);
|
||||
browserStorage.writeRaw("local", LAST_URL_KEY, next.source.url);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const materialId = window.localStorage.getItem(LAST_MATERIAL_KEY);
|
||||
const materialId = browserStorage.readRaw("local", LAST_MATERIAL_KEY);
|
||||
if (!materialId) return;
|
||||
const sourceUrl = window.localStorage.getItem(LAST_URL_KEY) || "";
|
||||
const sourceUrl = browserStorage.readRaw("local", LAST_URL_KEY) || "";
|
||||
setLastUrl(sourceUrl);
|
||||
setLoading(true);
|
||||
void getVideoMaterial(materialId)
|
||||
@@ -80,7 +82,7 @@ export function WatchingProvider({ children }: { children: ReactNode }) {
|
||||
? caught.message
|
||||
: t("The player provider is unavailable."),
|
||||
);
|
||||
window.localStorage.removeItem(LAST_MATERIAL_KEY);
|
||||
browserStorage.removeRaw("local", LAST_MATERIAL_KEY);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [accept, t]);
|
||||
@@ -145,8 +147,8 @@ export function WatchingProvider({ children }: { children: ReactNode }) {
|
||||
setWatchingMaterial(null);
|
||||
setError(null);
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.removeItem(LAST_MATERIAL_KEY);
|
||||
window.localStorage.removeItem(LAST_URL_KEY);
|
||||
browserStorage.removeRaw("local", LAST_MATERIAL_KEY);
|
||||
browserStorage.removeRaw("local", LAST_URL_KEY);
|
||||
}
|
||||
}, []);
|
||||
const reportTime = useCallback(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { browserStorage } from "@/shared/storage";
|
||||
|
||||
export type AppLanguage = "en" | "zh";
|
||||
|
||||
export const ACTIVE_SESSION_STORAGE_KEY = "deeptutor.activeSessionId.tab";
|
||||
@@ -33,7 +35,7 @@ export function readStoredChatResponseTimeout(): number {
|
||||
if (typeof window === "undefined")
|
||||
return DEFAULT_CHAT_RESPONSE_TIMEOUT_SECONDS;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(CHAT_RESPONSE_TIMEOUT_STORAGE_KEY);
|
||||
const raw = browserStorage.readRaw("local", CHAT_RESPONSE_TIMEOUT_STORAGE_KEY);
|
||||
const parsed = raw ? Number.parseInt(raw, 10) : NaN;
|
||||
return Number.isFinite(parsed) && parsed > 0
|
||||
? clampChatResponseTimeout(parsed)
|
||||
@@ -46,7 +48,8 @@ export function readStoredChatResponseTimeout(): number {
|
||||
export function writeStoredChatResponseTimeout(seconds: number): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
browserStorage.writeRaw(
|
||||
"local",
|
||||
CHAT_RESPONSE_TIMEOUT_STORAGE_KEY,
|
||||
String(clampChatResponseTimeout(seconds)),
|
||||
);
|
||||
@@ -79,7 +82,7 @@ export function resolveResponseLanguage(
|
||||
export function readStoredLanguage(): AppLanguage {
|
||||
if (typeof window === "undefined") return "en";
|
||||
try {
|
||||
return normalizeLanguage(window.localStorage.getItem(LANGUAGE_STORAGE_KEY));
|
||||
return normalizeLanguage(browserStorage.readRaw("local", LANGUAGE_STORAGE_KEY));
|
||||
} catch {
|
||||
return "en";
|
||||
}
|
||||
@@ -95,7 +98,7 @@ export function readStoredLanguage(): AppLanguage {
|
||||
export function hasStoredLanguage(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.localStorage.getItem(LANGUAGE_STORAGE_KEY) !== null;
|
||||
return browserStorage.readRaw("local", LANGUAGE_STORAGE_KEY) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -104,7 +107,7 @@ export function hasStoredLanguage(): boolean {
|
||||
export function writeStoredLanguage(language: AppLanguage): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, language);
|
||||
browserStorage.writeRaw("local", LANGUAGE_STORAGE_KEY, language);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(LANGUAGE_EVENT, {
|
||||
detail: { language },
|
||||
@@ -119,8 +122,8 @@ export function readStoredResponseLanguage(): AppLanguage {
|
||||
if (typeof window === "undefined") return "en";
|
||||
try {
|
||||
return resolveResponseLanguage(
|
||||
window.localStorage.getItem(RESPONSE_LANGUAGE_STORAGE_KEY),
|
||||
window.localStorage.getItem(LANGUAGE_STORAGE_KEY),
|
||||
browserStorage.readRaw("local", RESPONSE_LANGUAGE_STORAGE_KEY),
|
||||
browserStorage.readRaw("local", LANGUAGE_STORAGE_KEY),
|
||||
);
|
||||
} catch {
|
||||
return "en";
|
||||
@@ -130,7 +133,7 @@ export function readStoredResponseLanguage(): AppLanguage {
|
||||
export function writeStoredResponseLanguage(language: AppLanguage): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(RESPONSE_LANGUAGE_STORAGE_KEY, language);
|
||||
browserStorage.writeRaw("local", RESPONSE_LANGUAGE_STORAGE_KEY, language);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(RESPONSE_LANGUAGE_EVENT, {
|
||||
detail: { language },
|
||||
@@ -144,7 +147,7 @@ export function writeStoredResponseLanguage(language: AppLanguage): void {
|
||||
export function readStoredActiveSessionId(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return window.sessionStorage.getItem(ACTIVE_SESSION_STORAGE_KEY);
|
||||
return browserStorage.readRaw("session", ACTIVE_SESSION_STORAGE_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -154,9 +157,9 @@ export function writeStoredActiveSessionId(sessionId: string | null): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
if (sessionId) {
|
||||
window.sessionStorage.setItem(ACTIVE_SESSION_STORAGE_KEY, sessionId);
|
||||
browserStorage.writeRaw("session", ACTIVE_SESSION_STORAGE_KEY, sessionId);
|
||||
} else {
|
||||
window.sessionStorage.removeItem(ACTIVE_SESSION_STORAGE_KEY);
|
||||
browserStorage.removeRaw("session", ACTIVE_SESSION_STORAGE_KEY);
|
||||
}
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(ACTIVE_SESSION_EVENT, {
|
||||
@@ -171,7 +174,7 @@ export function writeStoredActiveSessionId(sessionId: string | null): void {
|
||||
export function readStoredSidebarCollapsed(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === "1";
|
||||
return browserStorage.readRaw("local", SIDEBAR_COLLAPSED_STORAGE_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -180,7 +183,8 @@ export function readStoredSidebarCollapsed(): boolean {
|
||||
export function writeStoredSidebarCollapsed(collapsed: boolean): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
browserStorage.writeRaw(
|
||||
"local",
|
||||
SIDEBAR_COLLAPSED_STORAGE_KEY,
|
||||
collapsed ? "1" : "0",
|
||||
);
|
||||
@@ -209,7 +213,7 @@ export function normalizeCodeBlockTheme(
|
||||
export function readStoredCodeBlockTheme(): string {
|
||||
if (typeof window === "undefined") return DEFAULT_CODE_BLOCK_THEME;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(CODE_BLOCK_THEME_STORAGE_KEY);
|
||||
const raw = browserStorage.readRaw("local", CODE_BLOCK_THEME_STORAGE_KEY);
|
||||
return normalizeCodeBlockTheme(raw);
|
||||
} catch {
|
||||
return DEFAULT_CODE_BLOCK_THEME;
|
||||
@@ -219,7 +223,8 @@ export function readStoredCodeBlockTheme(): string {
|
||||
export function writeStoredCodeBlockTheme(theme: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
browserStorage.writeRaw(
|
||||
"local",
|
||||
CODE_BLOCK_THEME_STORAGE_KEY,
|
||||
normalizeCodeBlockTheme(theme),
|
||||
);
|
||||
@@ -245,7 +250,8 @@ export function readStoredCodeBlockShowLineNumbers(): boolean {
|
||||
if (typeof window === "undefined")
|
||||
return DEFAULT_CODE_BLOCK_SHOW_LINE_NUMBERS;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(
|
||||
const raw = browserStorage.readRaw(
|
||||
"local",
|
||||
CODE_BLOCK_SHOW_LINE_NUMBERS_STORAGE_KEY,
|
||||
);
|
||||
return normalizeCodeBlockShowLineNumbers(raw);
|
||||
@@ -257,7 +263,8 @@ export function readStoredCodeBlockShowLineNumbers(): boolean {
|
||||
export function writeStoredCodeBlockShowLineNumbers(show: boolean): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
browserStorage.writeRaw(
|
||||
"local",
|
||||
CODE_BLOCK_SHOW_LINE_NUMBERS_STORAGE_KEY,
|
||||
String(show),
|
||||
);
|
||||
@@ -282,7 +289,8 @@ export function normalizeCodeBlockWrapLongLines(
|
||||
export function readStoredCodeBlockWrapLongLines(): boolean {
|
||||
if (typeof window === "undefined") return DEFAULT_CODE_BLOCK_WRAP_LONG_LINES;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(
|
||||
const raw = browserStorage.readRaw(
|
||||
"local",
|
||||
CODE_BLOCK_WRAP_LONG_LINES_STORAGE_KEY,
|
||||
);
|
||||
return normalizeCodeBlockWrapLongLines(raw);
|
||||
@@ -294,7 +302,8 @@ export function readStoredCodeBlockWrapLongLines(): boolean {
|
||||
export function writeStoredCodeBlockWrapLongLines(wrap: boolean): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
browserStorage.writeRaw(
|
||||
"local",
|
||||
CODE_BLOCK_WRAP_LONG_LINES_STORAGE_KEY,
|
||||
String(wrap),
|
||||
);
|
||||
|
||||
@@ -111,6 +111,7 @@ import {
|
||||
type ToolName,
|
||||
} from "@/features/capabilities/presentation";
|
||||
import { useCapabilityCatalog } from "@/features/capabilities/useCapabilityCatalog";
|
||||
import { browserStorage } from "@/shared/storage";
|
||||
import { downloadChatMarkdown } from "@/lib/chat-export";
|
||||
import { buildChatOutline } from "@/lib/chat-outline";
|
||||
import { isPlaceholderSessionTitle } from "@/lib/session-title";
|
||||
@@ -371,21 +372,21 @@ export default function ChatWorkspace() {
|
||||
} | null>(null);
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (window.localStorage.getItem("dt:chat:viewer-panel") === "1") {
|
||||
if (browserStorage.readRaw("local", "dt:chat:viewer-panel") === "1") {
|
||||
setViewerPanelOpen(true);
|
||||
}
|
||||
}, []);
|
||||
const setViewerOpen = useCallback((next: boolean) => {
|
||||
setViewerPanelOpen(next);
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem("dt:chat:viewer-panel", next ? "1" : "0");
|
||||
browserStorage.writeRaw("local", "dt:chat:viewer-panel", next ? "1" : "0");
|
||||
}
|
||||
}, []);
|
||||
const toggleViewerPanel = useCallback(() => {
|
||||
setViewerPanelOpen((prev) => {
|
||||
const next = !prev;
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem("dt:chat:viewer-panel", next ? "1" : "0");
|
||||
browserStorage.writeRaw("local", "dt:chat:viewer-panel", next ? "1" : "0");
|
||||
}
|
||||
return next;
|
||||
});
|
||||
@@ -446,7 +447,7 @@ export default function ChatWorkspace() {
|
||||
if (!capabilityConfigStorageKey) return;
|
||||
if (lastHydratedConfigKeyRef.current === capabilityConfigStorageKey) return;
|
||||
lastHydratedConfigKeyRef.current = capabilityConfigStorageKey;
|
||||
const raw = window.localStorage.getItem(capabilityConfigStorageKey);
|
||||
const raw = browserStorage.readRaw("local", capabilityConfigStorageKey);
|
||||
if (!raw) return;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as {
|
||||
@@ -470,7 +471,8 @@ export default function ChatWorkspace() {
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!capabilityConfigStorageKey) return;
|
||||
window.localStorage.setItem(
|
||||
browserStorage.writeRaw(
|
||||
"local",
|
||||
capabilityConfigStorageKey,
|
||||
JSON.stringify({
|
||||
quizConfig,
|
||||
|
||||
@@ -315,7 +315,7 @@ export default function CoWriterWorkspace({ docId }: CoWriterWorkspaceProps) {
|
||||
markdown,
|
||||
draftRevisionRef.current,
|
||||
);
|
||||
}, [docId, hasLoadedDraft, markdown]);
|
||||
}, [docId, hasLoadedDraft, isUnmountedRef, markdown]);
|
||||
|
||||
// Debounced autosave to the server.
|
||||
useEffect(() => {
|
||||
@@ -518,7 +518,7 @@ export default function CoWriterWorkspace({ docId }: CoWriterWorkspaceProps) {
|
||||
} finally {
|
||||
if (!isUnmountedRef.current) setIsSavingDoc(false);
|
||||
}
|
||||
}, [docId, docTitle, titleDraft]);
|
||||
}, [docId, docTitle, isUnmountedRef, titleDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditingTitle) return;
|
||||
@@ -1538,6 +1538,7 @@ export default function CoWriterWorkspace({ docId }: CoWriterWorkspaceProps) {
|
||||
}, [
|
||||
buildJointMarkers,
|
||||
releaseScrollSyncSource,
|
||||
scrollSyncSourceRef,
|
||||
syncScrollEnabled,
|
||||
updateSelectionPopover,
|
||||
]);
|
||||
@@ -1576,7 +1577,12 @@ export default function CoWriterWorkspace({ docId }: CoWriterWorkspaceProps) {
|
||||
scrollSyncSourceRef.current = "preview";
|
||||
editor.scrollTop = next;
|
||||
releaseScrollSyncSource();
|
||||
}, [buildJointMarkers, releaseScrollSyncSource, syncScrollEnabled]);
|
||||
}, [
|
||||
buildJointMarkers,
|
||||
releaseScrollSyncSource,
|
||||
syncScrollEnabled,
|
||||
scrollSyncSourceRef,
|
||||
]);
|
||||
|
||||
// Mermaid diagrams, images, and KaTeX render asynchronously, so the preview's
|
||||
// scrollHeight (and the y position of every marker after them) shifts well
|
||||
@@ -1626,7 +1632,7 @@ export default function CoWriterWorkspace({ docId }: CoWriterWorkspaceProps) {
|
||||
observer.disconnect();
|
||||
inner.removeEventListener("load", onLoad, true);
|
||||
};
|
||||
}, [showPreview]);
|
||||
}, [scrollSyncSourceRef, showPreview]);
|
||||
|
||||
if (docNotFound) {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,581 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AudioLines,
|
||||
Bot,
|
||||
Boxes,
|
||||
Brain,
|
||||
BrainCircuit,
|
||||
Clapperboard,
|
||||
Database,
|
||||
FileScan,
|
||||
Image as ImageIcon,
|
||||
Info,
|
||||
KeyRound,
|
||||
Library,
|
||||
ListChecks,
|
||||
MessagesSquare,
|
||||
Mic,
|
||||
Network,
|
||||
Palette,
|
||||
Paperclip,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
UserRound,
|
||||
Wrench,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
ClaudeGlyph,
|
||||
CodexGlyph,
|
||||
DeepSeekGlyph,
|
||||
GeminiGlyph,
|
||||
HermesGlyph,
|
||||
KimiGlyph,
|
||||
MimoGlyph,
|
||||
OpenClawGlyph,
|
||||
OpencodeGlyph,
|
||||
} from "@/components/agents/agent-icons";
|
||||
import type { ServiceName } from "@/features/settings/store/SettingsStore";
|
||||
|
||||
/**
|
||||
* Settings information architecture.
|
||||
*
|
||||
* Two levels, deliberately unlike the flat Learning Space dashboard:
|
||||
* • The hub (`/settings`) shows six category blocks + a resident Status
|
||||
* module — nothing else.
|
||||
* • Categories with several settings (Models, Chat) open a sub-hub page
|
||||
* that lists their leaves as tiles; single-setting categories link
|
||||
* straight to their leaf page.
|
||||
*
|
||||
* This module is the single source for the blocks, the sub-hub tiles, and the
|
||||
* breadcrumb trail rendered top-left on every page.
|
||||
*/
|
||||
|
||||
export type Lang = { zh: string; en: string };
|
||||
|
||||
export interface SettingsLeaf {
|
||||
key: string;
|
||||
href: string;
|
||||
label: Lang;
|
||||
blurb: Lang;
|
||||
icon: LucideIcon;
|
||||
/** Colored icon-tile accent for the sub-hub grid (full class strings). */
|
||||
tile: string;
|
||||
/** Model-service leaves carry a configured/not chip from the catalog. */
|
||||
service?: ServiceName;
|
||||
/** Hidden from non-admin users (the backend rejects them anyway). */
|
||||
adminOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface SettingsCategory {
|
||||
key: string;
|
||||
label: Lang;
|
||||
/** One-line descriptor shown on the hub block. */
|
||||
blurb: Lang;
|
||||
icon: LucideIcon;
|
||||
/** Where clicking the block lands — a sub-hub or a leaf page. */
|
||||
href: string;
|
||||
/** Leaves listed on the sub-hub page (omitted for direct-leaf categories). */
|
||||
children?: SettingsLeaf[];
|
||||
/** Shown only when the backend reports an active learner policy. */
|
||||
learnerOnly?: boolean;
|
||||
/** Shown only to authenticated standard users who may act as guardians. */
|
||||
guardianOnly?: boolean;
|
||||
}
|
||||
|
||||
const MODEL_CHILDREN: SettingsLeaf[] = [
|
||||
{
|
||||
key: "connections",
|
||||
href: "/settings/models#connections",
|
||||
label: { zh: "连接", en: "Connections" },
|
||||
blurb: {
|
||||
zh: "一份凭据供给多个服务。",
|
||||
en: "One credential, supplying several services.",
|
||||
},
|
||||
icon: KeyRound,
|
||||
tile: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
|
||||
},
|
||||
{
|
||||
key: "llm",
|
||||
href: "/settings/models#llm",
|
||||
label: { zh: "LLM", en: "LLM" },
|
||||
blurb: {
|
||||
zh: "语言模型供应商与当前档位。",
|
||||
en: "Language model providers and active profile.",
|
||||
},
|
||||
icon: Brain,
|
||||
tile: "bg-violet-500/10 text-violet-600 dark:text-violet-400",
|
||||
service: "llm",
|
||||
},
|
||||
{
|
||||
key: "task-models",
|
||||
href: "/settings/models#task-models",
|
||||
label: { zh: "任务模型", en: "Task models" },
|
||||
blurb: {
|
||||
zh: "DeepTutor 自己发起的调用使用的模型。",
|
||||
en: "The model behind the calls DeepTutor makes on its own.",
|
||||
},
|
||||
icon: ListChecks,
|
||||
tile: "bg-cyan-500/10 text-cyan-600 dark:text-cyan-400",
|
||||
},
|
||||
{
|
||||
key: "embedding",
|
||||
href: "/settings/models#embedding",
|
||||
label: { zh: "嵌入模型", en: "Embedding" },
|
||||
blurb: {
|
||||
zh: "向量模型供应商与维度。",
|
||||
en: "Embedding model providers and dimensions.",
|
||||
},
|
||||
icon: Database,
|
||||
tile: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
|
||||
service: "embedding",
|
||||
},
|
||||
{
|
||||
key: "search",
|
||||
href: "/settings/models#search",
|
||||
label: { zh: "搜索", en: "Search" },
|
||||
blurb: { zh: "联网搜索供应商。", en: "Web search providers." },
|
||||
icon: Search,
|
||||
tile: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
|
||||
service: "search",
|
||||
},
|
||||
{
|
||||
key: "tts",
|
||||
href: "/settings/models#tts",
|
||||
label: { zh: "语音合成", en: "Text-to-Speech" },
|
||||
blurb: {
|
||||
zh: "朗读助手回复的 TTS 供应商。",
|
||||
en: "Text-to-speech for reading replies aloud.",
|
||||
},
|
||||
icon: AudioLines,
|
||||
tile: "bg-rose-500/10 text-rose-600 dark:text-rose-400",
|
||||
service: "tts",
|
||||
},
|
||||
{
|
||||
key: "stt",
|
||||
href: "/settings/models#stt",
|
||||
label: { zh: "语音识别", en: "Speech-to-Text" },
|
||||
blurb: {
|
||||
zh: "转写麦克风录音的 STT 供应商。",
|
||||
en: "Speech-to-text for the composer microphone.",
|
||||
},
|
||||
icon: Mic,
|
||||
tile: "bg-pink-500/10 text-pink-600 dark:text-pink-400",
|
||||
service: "stt",
|
||||
},
|
||||
{
|
||||
key: "imagegen",
|
||||
href: "/settings/models#imagegen",
|
||||
label: { zh: "文生图", en: "Image Generation" },
|
||||
blurb: {
|
||||
zh: "chat imagegen 工具使用的文生图模型。",
|
||||
en: "Text-to-image model for the chat imagegen tool.",
|
||||
},
|
||||
icon: ImageIcon,
|
||||
tile: "bg-fuchsia-500/10 text-fuchsia-600 dark:text-fuchsia-400",
|
||||
service: "imagegen",
|
||||
},
|
||||
{
|
||||
key: "videogen",
|
||||
href: "/settings/models#videogen",
|
||||
label: { zh: "文生视频", en: "Video Generation" },
|
||||
blurb: {
|
||||
zh: "chat videogen 工具使用的文生视频模型。",
|
||||
en: "Text-to-video model for the chat videogen tool.",
|
||||
},
|
||||
icon: Clapperboard,
|
||||
tile: "bg-indigo-500/10 text-indigo-600 dark:text-indigo-400",
|
||||
service: "videogen",
|
||||
},
|
||||
];
|
||||
|
||||
const CHAT_CHILDREN: SettingsLeaf[] = [
|
||||
{
|
||||
key: "video-learning",
|
||||
href: "/settings/video-learning",
|
||||
label: { zh: "视频学习", en: "Video Learning" },
|
||||
blurb: {
|
||||
zh: "原生 YouTube 与本地 Invidious 播放供应商。",
|
||||
en: "Native YouTube and local Invidious playback providers.",
|
||||
},
|
||||
icon: Clapperboard,
|
||||
tile: "bg-red-500/10 text-red-600 dark:text-red-400",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
key: "tools",
|
||||
href: "/settings/chat#tools",
|
||||
label: { zh: "工具", en: "Tools" },
|
||||
blurb: {
|
||||
zh: "对话智能体可调用的内置工具。",
|
||||
en: "Built-in tools the chat agent can invoke.",
|
||||
},
|
||||
icon: Wrench,
|
||||
tile: "bg-orange-500/10 text-orange-600 dark:text-orange-400",
|
||||
},
|
||||
{
|
||||
key: "capabilities",
|
||||
href: "/settings/chat#capabilities",
|
||||
label: { zh: "能力", en: "Capabilities" },
|
||||
blurb: {
|
||||
zh: "各能力的 LLM 参数与运行时旋钮。",
|
||||
en: "Per-capability LLM parameters and runtime knobs.",
|
||||
},
|
||||
icon: SlidersHorizontal,
|
||||
tile: "bg-lime-500/10 text-lime-600 dark:text-lime-400",
|
||||
},
|
||||
{
|
||||
key: "starters",
|
||||
href: "/settings/chat#starters",
|
||||
label: { zh: "起始建议", en: "Starting points" },
|
||||
blurb: {
|
||||
zh: "主页输入框下方那三行引导的素材范围。",
|
||||
en: "How much history shapes the three lines under the composer.",
|
||||
},
|
||||
icon: Sparkles,
|
||||
tile: "bg-violet-500/10 text-violet-600 dark:text-violet-400",
|
||||
},
|
||||
{
|
||||
key: "attachments",
|
||||
href: "/settings/chat#attachments",
|
||||
label: { zh: "附件", en: "Attachments" },
|
||||
blurb: {
|
||||
zh: "聊天附件的大小上限与文本提取预算。",
|
||||
en: "Upload caps and extraction budgets for chat attachments.",
|
||||
},
|
||||
icon: Paperclip,
|
||||
tile: "bg-teal-500/10 text-teal-600 dark:text-teal-400",
|
||||
adminOnly: true,
|
||||
},
|
||||
];
|
||||
|
||||
const AGENT_CHILDREN: SettingsLeaf[] = [
|
||||
{
|
||||
key: "agent-claude-code",
|
||||
href: "/settings/agents#agent-claude-code",
|
||||
label: { zh: "Claude Code", en: "Claude Code" },
|
||||
blurb: {
|
||||
zh: "DeepTutor 调用本机 Claude Code 时的模型、推理强度与运行参数。",
|
||||
en: "Model, reasoning effort, and run params for the local Claude Code.",
|
||||
},
|
||||
// Brand glyph shares the lucide call signature (size/className).
|
||||
icon: ClaudeGlyph as unknown as LucideIcon,
|
||||
tile: "bg-orange-500/10 text-orange-600 dark:text-orange-400",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
key: "agent-codex",
|
||||
href: "/settings/agents#agent-codex",
|
||||
label: { zh: "Codex", en: "Codex" },
|
||||
blurb: {
|
||||
zh: "DeepTutor 调用本机 Codex 时的模型、推理强度与运行参数。",
|
||||
en: "Model, reasoning effort, and run params for the local Codex.",
|
||||
},
|
||||
icon: CodexGlyph as unknown as LucideIcon,
|
||||
tile: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
// Gemini CLI's supported replacement.
|
||||
key: "agent-antigravity",
|
||||
href: "/settings/agents#agent-antigravity",
|
||||
label: { zh: "Antigravity CLI", en: "Antigravity CLI" },
|
||||
blurb: {
|
||||
zh: "DeepTutor 调用本机 Antigravity CLI 时的模型与运行参数。",
|
||||
en: "Model and run params for the local Antigravity CLI.",
|
||||
},
|
||||
icon: GeminiGlyph as unknown as LucideIcon,
|
||||
tile: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
key: "agent-kimi",
|
||||
href: "/settings/agents#agent-kimi",
|
||||
label: { zh: "Kimi CLI", en: "Kimi CLI" },
|
||||
blurb: {
|
||||
zh: "DeepTutor 调用本机 Kimi CLI 时的模型与运行参数。",
|
||||
en: "Model and run params for the local Kimi CLI.",
|
||||
},
|
||||
icon: KimiGlyph as unknown as LucideIcon,
|
||||
tile: "bg-zinc-500/10 text-zinc-700 dark:text-zinc-300",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
key: "agent-opencode",
|
||||
href: "/settings/agents#agent-opencode",
|
||||
label: { zh: "opencode", en: "opencode" },
|
||||
blurb: {
|
||||
zh: "DeepTutor 调用本机 opencode 时的模型、推理强度与运行参数。",
|
||||
en: "Model, reasoning effort, and run params for the local opencode.",
|
||||
},
|
||||
icon: OpencodeGlyph as unknown as LucideIcon,
|
||||
tile: "bg-neutral-500/10 text-neutral-700 dark:text-neutral-300",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
key: "agent-mimo",
|
||||
href: "/settings/agents#agent-mimo",
|
||||
label: { zh: "MiMo Code", en: "MiMo Code" },
|
||||
blurb: {
|
||||
zh: "DeepTutor 调用本机 MiMo Code 时的模型、推理强度与运行参数。",
|
||||
en: "Model, reasoning effort, and run params for the local MiMo Code.",
|
||||
},
|
||||
icon: MimoGlyph as unknown as LucideIcon,
|
||||
tile: "bg-orange-500/10 text-orange-600 dark:text-orange-400",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
key: "agent-hermes",
|
||||
href: "/settings/agents#agent-hermes",
|
||||
label: { zh: "Hermes Agent", en: "Hermes Agent" },
|
||||
blurb: {
|
||||
zh: "DeepTutor 调用本机 Hermes Agent 时的模型、推理强度与运行参数。",
|
||||
en: "Model, reasoning effort, and run params for the local Hermes Agent.",
|
||||
},
|
||||
icon: HermesGlyph as unknown as LucideIcon,
|
||||
tile: "bg-violet-500/10 text-violet-600 dark:text-violet-400",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
key: "agent-openclaw",
|
||||
href: "/settings/agents#agent-openclaw",
|
||||
label: { zh: "OpenClaw", en: "OpenClaw" },
|
||||
blurb: {
|
||||
zh: "DeepTutor 通过 Gateway 或本地模式调用 OpenClaw 的运行参数。",
|
||||
en: "Gateway or local-mode run params for the local OpenClaw agent.",
|
||||
},
|
||||
icon: OpenClawGlyph as unknown as LucideIcon,
|
||||
tile: "bg-red-500/10 text-red-600 dark:text-red-400",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
key: "agent-deepseek-harness",
|
||||
href: "/settings/agents#agent-deepseek-harness",
|
||||
label: { zh: "DeepSeek Harness", en: "DeepSeek Harness" },
|
||||
blurb: {
|
||||
zh: "DeepTutor 通过 Python SDK 或 headless CLI 调用 DeepSeek Harness。",
|
||||
en: "Python SDK or headless CLI settings for DeepSeek Harness.",
|
||||
},
|
||||
icon: DeepSeekGlyph as unknown as LucideIcon,
|
||||
tile: "bg-indigo-500/10 text-indigo-600 dark:text-indigo-400",
|
||||
adminOnly: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const SETTINGS_CATEGORIES: SettingsCategory[] = [
|
||||
{
|
||||
key: "appearance",
|
||||
label: { zh: "外观", en: "Appearance" },
|
||||
blurb: { zh: "视觉主题与界面语言", en: "Theme and interface language" },
|
||||
icon: Palette,
|
||||
href: "/settings/appearance",
|
||||
},
|
||||
{
|
||||
key: "network",
|
||||
label: { zh: "网络", en: "Network" },
|
||||
blurb: {
|
||||
zh: "端口、浏览器 API 地址与 CORS",
|
||||
en: "Ports, browser API base, and CORS",
|
||||
},
|
||||
icon: Network,
|
||||
href: "/settings/network",
|
||||
},
|
||||
{
|
||||
key: "models",
|
||||
label: { zh: "模型", en: "Models" },
|
||||
blurb: {
|
||||
zh: "语言、向量、搜索、语音与生成模型",
|
||||
en: "Language, embedding, search, voice, and generation models",
|
||||
},
|
||||
icon: Boxes,
|
||||
href: "/settings/models",
|
||||
children: MODEL_CHILDREN,
|
||||
},
|
||||
{
|
||||
key: "knowledge",
|
||||
label: { zh: "知识库", en: "Knowledge Base" },
|
||||
blurb: { zh: "文档解析引擎", en: "Document parsing engine" },
|
||||
icon: Library,
|
||||
href: "/settings/document-parsing",
|
||||
},
|
||||
{
|
||||
key: "chat",
|
||||
label: { zh: "聊天", en: "Chat" },
|
||||
blurb: {
|
||||
zh: "工具、能力与附件",
|
||||
en: "Tools, capabilities, and attachments",
|
||||
},
|
||||
icon: MessagesSquare,
|
||||
href: "/settings/chat",
|
||||
children: CHAT_CHILDREN,
|
||||
},
|
||||
{
|
||||
key: "agents",
|
||||
label: { zh: "伙伴和智能体", en: "Partners & Agents" },
|
||||
blurb: {
|
||||
zh: "配置可在对话中调用的子智能体",
|
||||
en: "Configure the subagents you can call on in chat",
|
||||
},
|
||||
icon: Bot,
|
||||
href: "/settings/agents",
|
||||
children: AGENT_CHILDREN,
|
||||
},
|
||||
{
|
||||
key: "learner-profile",
|
||||
learnerOnly: true,
|
||||
label: { zh: "学习档案", en: "Learner profile" },
|
||||
blurb: {
|
||||
zh: "调整年龄、年级与讲解偏好。",
|
||||
en: "Adjust age, grade, and explanation preferences.",
|
||||
},
|
||||
icon: UserRound,
|
||||
href: "/settings/learner-profile",
|
||||
},
|
||||
{
|
||||
key: "guardian",
|
||||
guardianOnly: true,
|
||||
label: { zh: "监护管理", en: "Guardian" },
|
||||
blurb: {
|
||||
zh: "查看已授权学习者与学习材料。",
|
||||
en: "Review authorized learners and learning materials.",
|
||||
},
|
||||
icon: ShieldCheck,
|
||||
href: "/settings/guardian",
|
||||
},
|
||||
{
|
||||
key: "memory",
|
||||
label: { zh: "记忆", en: "Memory" },
|
||||
blurb: {
|
||||
zh: "分块、预算、去重与引用策略",
|
||||
en: "Chunking, budget, dedup, and reference policies",
|
||||
},
|
||||
icon: BrainCircuit,
|
||||
href: "/settings/memory",
|
||||
},
|
||||
{
|
||||
key: "about",
|
||||
label: { zh: "关于", en: "About" },
|
||||
blurb: {
|
||||
zh: "版本、更新与项目资源",
|
||||
en: "Version, updates, and project resources",
|
||||
},
|
||||
icon: Info,
|
||||
href: "/settings/about",
|
||||
},
|
||||
];
|
||||
|
||||
export const SETTINGS_HUB_HREF = "/settings";
|
||||
const HUB_LABEL: Lang = { zh: "设置", en: "Settings" };
|
||||
|
||||
/** The canonical in-document URL used by the persistent settings navigator. */
|
||||
export function settingsAnchorHref(key: string): string {
|
||||
return `${SETTINGS_HUB_HREF}#${key}`;
|
||||
}
|
||||
|
||||
/** Legacy standalone routes that do not edit the shared settings draft. */
|
||||
const NAV_ONLY_ROUTES = new Set<string>(["/settings/about"]);
|
||||
|
||||
export function isNavOnlyRoute(pathname: string): boolean {
|
||||
return NAV_ONLY_ROUTES.has(pathname);
|
||||
}
|
||||
|
||||
/**
|
||||
* Categories rendered as one continuously-scrolling page (Models, Chat,
|
||||
* Partners & Agents) rather than a route per leaf. Their children's `href`
|
||||
* points at `${category.href}#${leaf.key}` — a same-page anchor, not a route
|
||||
* change — so switching between them never remounts the page.
|
||||
*/
|
||||
export const MERGED_CATEGORY_HREFS = new Set(
|
||||
SETTINGS_CATEGORIES.filter((c) => c.children).map((c) => c.href),
|
||||
);
|
||||
|
||||
// The on-disk file (under data/user/settings/) each leaf module persists to.
|
||||
// Surfaced in the toolbar status line so every page says where its parameters
|
||||
// live, without duplicating the string on each page. Singleton pages (no
|
||||
// merged category) are keyed by pathname; leaves inside a merged category
|
||||
// page share one pathname, so those are keyed by `leaf.key` instead and
|
||||
// looked up via the currently scrolled-to section (see `storagePathFor`).
|
||||
const STORAGE_PATHS: Record<string, string> = {
|
||||
"/settings/appearance": "data/user/settings/interface.json",
|
||||
"/settings/network": "data/user/settings/system.json",
|
||||
"/settings/llm": "data/user/settings/model_catalog.json",
|
||||
"/settings/embedding": "data/user/settings/model_catalog.json",
|
||||
"/settings/search": "data/user/settings/model_catalog.json",
|
||||
"/settings/tts": "data/user/settings/model_catalog.json",
|
||||
"/settings/stt": "data/user/settings/model_catalog.json",
|
||||
"/settings/image": "data/user/settings/model_catalog.json",
|
||||
"/settings/video": "data/user/settings/model_catalog.json",
|
||||
"/settings/video-learning": "data/user/settings/video_learning.json",
|
||||
"/settings/document-parsing": "data/user/settings/document_parsing.json",
|
||||
"/settings/memory": "data/user/settings/main.yaml",
|
||||
appearance: "data/user/settings/interface.json",
|
||||
network: "data/user/settings/system.json",
|
||||
connections: "data/user/settings/model_catalog.json",
|
||||
"task-models": "data/user/settings/model_catalog.json",
|
||||
knowledge: "data/user/settings/document_parsing.json",
|
||||
"video-learning": "data/user/settings/video_learning.json",
|
||||
starters: "data/user/settings/interface.json",
|
||||
memory: "data/user/settings/main.yaml",
|
||||
llm: "data/user/settings/model_catalog.json",
|
||||
embedding: "data/user/settings/model_catalog.json",
|
||||
search: "data/user/settings/model_catalog.json",
|
||||
tts: "data/user/settings/model_catalog.json",
|
||||
stt: "data/user/settings/model_catalog.json",
|
||||
imagegen: "data/user/settings/model_catalog.json",
|
||||
videogen: "data/user/settings/model_catalog.json",
|
||||
tools: "data/user/settings/interface.json",
|
||||
attachments: "data/user/settings/system.json",
|
||||
capabilities: "data/user/settings/main.yaml · agents.yaml",
|
||||
"agent-claude-code": "data/user/settings/subagent.json",
|
||||
"agent-codex": "data/user/settings/subagent.json",
|
||||
"agent-antigravity": "data/user/settings/subagent.json",
|
||||
"agent-kimi": "data/user/settings/subagent.json",
|
||||
"agent-opencode": "data/user/settings/subagent.json",
|
||||
"agent-mimo": "data/user/settings/subagent.json",
|
||||
};
|
||||
|
||||
export function storagePathFor(
|
||||
pathname: string,
|
||||
activeSection?: string | null,
|
||||
): string | null {
|
||||
if (pathname === SETTINGS_HUB_HREF || MERGED_CATEGORY_HREFS.has(pathname)) {
|
||||
return activeSection ? (STORAGE_PATHS[activeSection] ?? null) : null;
|
||||
}
|
||||
return STORAGE_PATHS[pathname] ?? null;
|
||||
}
|
||||
|
||||
export interface Crumb {
|
||||
label: Lang;
|
||||
/** Omitted on the current (last) crumb. */
|
||||
href?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The breadcrumb trail for a settings route, e.g.
|
||||
* /settings/llm → 设置 / 模型 / LLM
|
||||
* /settings/network → 设置 / 网络
|
||||
* Returns just [设置] for the hub itself.
|
||||
*/
|
||||
export function breadcrumbFor(pathname: string): Crumb[] {
|
||||
const root: Crumb = { label: HUB_LABEL, href: SETTINGS_HUB_HREF };
|
||||
if (pathname === SETTINGS_HUB_HREF) return [{ label: HUB_LABEL }];
|
||||
|
||||
// Direct-leaf or sub-hub category landed on its own href.
|
||||
const category = SETTINGS_CATEGORIES.find((c) => c.href === pathname);
|
||||
if (category) return [root, { label: category.label }];
|
||||
|
||||
// A leaf inside a sub-hub category.
|
||||
for (const c of SETTINGS_CATEGORIES) {
|
||||
const leaf = c.children?.find((l) => l.href === pathname);
|
||||
if (leaf) {
|
||||
return [root, { label: c.label, href: c.href }, { label: leaf.label }];
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown sub-route (e.g. a legacy redirect target rendered directly).
|
||||
return [root];
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { invalidateLLMOptionsCache } from "@/lib/llm-options";
|
||||
import { setModelReasoningEffort } from "@/lib/reasoning-effort";
|
||||
import { applyExtensionPayload } from "@/lib/settings-extensions";
|
||||
import { setTheme as applyThemePreference } from "@/lib/theme";
|
||||
import { browserStorage } from "@/shared/storage";
|
||||
|
||||
// ─── Domain types ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -501,7 +502,7 @@ function readStoredDiagnosticsResults(): Partial<
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
window.sessionStorage.getItem(DIAGNOSTICS_RESULTS_KEY) || "{}",
|
||||
browserStorage.readRaw("session", DIAGNOSTICS_RESULTS_KEY) || "{}",
|
||||
) as Partial<Record<ServiceName, DiagnosticsResult>>;
|
||||
return parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch {
|
||||
@@ -913,7 +914,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.sessionStorage.setItem(
|
||||
browserStorage.writeRaw(
|
||||
"session",
|
||||
DIAGNOSTICS_RESULTS_KEY,
|
||||
JSON.stringify(diagnosticsResults),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { browserStorage } from "@/shared/storage";
|
||||
|
||||
/**
|
||||
* Persisted, per-key collapsed/expanded state for side panels.
|
||||
@@ -17,7 +18,8 @@ export function useCollapsiblePanel(
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const stored = window.localStorage.getItem(
|
||||
const stored = browserStorage.readRaw(
|
||||
"local",
|
||||
`panel:${storageKey}:collapsed`,
|
||||
);
|
||||
if (stored != null) {
|
||||
@@ -35,7 +37,8 @@ export function useCollapsiblePanel(
|
||||
const next = typeof value === "function" ? value(prev) : value;
|
||||
try {
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(
|
||||
browserStorage.writeRaw(
|
||||
"local",
|
||||
`panel:${storageKey}:collapsed`,
|
||||
next ? "1" : "0",
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { browserStorage } from "@/shared/storage";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { TaskKind } from "@/hooks/useKnowledgeProgress";
|
||||
|
||||
@@ -29,7 +31,7 @@ interface HistoryStore {
|
||||
function readStore(): HistoryStore {
|
||||
if (typeof window === "undefined") return { byKb: {} };
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
const raw = browserStorage.readRaw("local", STORAGE_KEY);
|
||||
if (!raw) return { byKb: {} };
|
||||
const parsed = JSON.parse(raw) as HistoryStore;
|
||||
if (parsed && typeof parsed === "object" && parsed.byKb) {
|
||||
@@ -44,7 +46,7 @@ function readStore(): HistoryStore {
|
||||
function writeStore(store: HistoryStore) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
|
||||
browserStorage.writeRaw("local", STORAGE_KEY, JSON.stringify(store));
|
||||
} catch {
|
||||
// quota exceeded; ignore
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { browserStorage } from "@/shared/storage";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { apiFetch, apiUrl } from "@/lib/api";
|
||||
@@ -44,7 +46,8 @@ function scopedKey(prefix: string, scopeKey?: string): string {
|
||||
function readSession(scopeKey?: string): boolean | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const v = window.sessionStorage.getItem(
|
||||
const v = browserStorage.readRaw(
|
||||
"session",
|
||||
scopedKey(SESSION_KEY_PREFIX, scopeKey),
|
||||
);
|
||||
return v === "on" ? true : v === "off" ? false : null;
|
||||
@@ -56,7 +59,8 @@ function readSession(scopeKey?: string): boolean | null {
|
||||
function writeSession(value: boolean, scopeKey?: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.sessionStorage.setItem(
|
||||
browserStorage.writeRaw(
|
||||
"session",
|
||||
scopedKey(SESSION_KEY_PREFIX, scopeKey),
|
||||
value ? "on" : "off",
|
||||
);
|
||||
@@ -162,7 +166,8 @@ export function useVoiceAutoplay(scopeKey?: string) {
|
||||
const markPrompted = useCallback(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.sessionStorage.setItem(
|
||||
browserStorage.writeRaw(
|
||||
"session",
|
||||
scopedKey(PROMPTED_KEY_PREFIX, normalizedScopeKey),
|
||||
"1",
|
||||
);
|
||||
@@ -179,7 +184,8 @@ export function useVoiceAutoplay(scopeKey?: string) {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return (
|
||||
window.sessionStorage.getItem(
|
||||
browserStorage.readRaw(
|
||||
"session",
|
||||
scopedKey(PROMPTED_KEY_PREFIX, normalizedScopeKey),
|
||||
) !== "1"
|
||||
);
|
||||
|
||||
+18
-15
@@ -14,7 +14,10 @@
|
||||
* persisted positive id).
|
||||
*/
|
||||
|
||||
import type { MessageItem } from "@/features/chat/compat/UnifiedChatFacade";
|
||||
export interface BranchMessage {
|
||||
id?: number;
|
||||
parentMessageId?: number | null;
|
||||
}
|
||||
|
||||
const ROOT_KEY = "null";
|
||||
|
||||
@@ -28,7 +31,7 @@ function parentKey(id: number | null | undefined): string {
|
||||
// ample headroom and stays well under ``Number.MAX_SAFE_INTEGER``.
|
||||
const OPTIMISTIC_RANK_OFFSET = 1e15;
|
||||
|
||||
function siblingRank(message: MessageItem): number {
|
||||
function siblingRank(message: BranchMessage): number {
|
||||
// Optimistic, in-flight messages get a negative ``id`` on the client
|
||||
// (``-Date.now()``) and must be treated as the freshest sibling so the
|
||||
// bubble the user just submitted stays visible. Among optimistic rows,
|
||||
@@ -47,9 +50,9 @@ function siblingRank(message: MessageItem): number {
|
||||
* Those rows are still real history, so the walk starts from the dangling
|
||||
* parent of the oldest orphan rather than rendering a blank page (#912).
|
||||
*/
|
||||
function walkStartKey(
|
||||
allMessages: MessageItem[],
|
||||
childrenByParent: Map<string, MessageItem[]>,
|
||||
function walkStartKey<T extends BranchMessage>(
|
||||
allMessages: T[],
|
||||
childrenByParent: Map<string, T[]>,
|
||||
): string {
|
||||
if ((childrenByParent.get(ROOT_KEY)?.length ?? 0) > 0) return ROOT_KEY;
|
||||
|
||||
@@ -87,20 +90,20 @@ export interface SiblingInfo {
|
||||
parentId: number | null;
|
||||
}
|
||||
|
||||
export interface VisiblePathResult {
|
||||
export interface VisiblePathResult<T extends BranchMessage> {
|
||||
/** The flat message list to render, in chronological order. */
|
||||
messages: MessageItem[];
|
||||
messages: T[];
|
||||
/** Sibling info keyed by message id. Only present for messages whose
|
||||
* parent has more than one child (i.e. branching points). */
|
||||
siblingsByMessageId: Map<number, SiblingInfo>;
|
||||
}
|
||||
|
||||
export function buildVisiblePath(
|
||||
allMessages: MessageItem[],
|
||||
export function buildVisiblePath<T extends BranchMessage>(
|
||||
allMessages: T[],
|
||||
selectedBranches: Record<string, number> | undefined,
|
||||
): VisiblePathResult {
|
||||
): VisiblePathResult<T> {
|
||||
// Group by parent.
|
||||
const childrenByParent = new Map<string, MessageItem[]>();
|
||||
const childrenByParent = new Map<string, T[]>();
|
||||
for (const msg of allMessages) {
|
||||
if (msg.id === undefined) continue;
|
||||
const key = parentKey(msg.parentMessageId);
|
||||
@@ -113,7 +116,7 @@ export function buildVisiblePath(
|
||||
}
|
||||
|
||||
const selection = selectedBranches ?? {};
|
||||
const visible: MessageItem[] = [];
|
||||
const visible: T[] = [];
|
||||
const siblingsByMessageId = new Map<number, SiblingInfo>();
|
||||
const guard = new Set<string>();
|
||||
let currentParent = walkStartKey(allMessages, childrenByParent);
|
||||
@@ -126,7 +129,7 @@ export function buildVisiblePath(
|
||||
const children = childrenByParent.get(currentParent);
|
||||
if (!children || children.length === 0) break;
|
||||
|
||||
let chosen: MessageItem;
|
||||
let chosen: T;
|
||||
if (children.length === 1) {
|
||||
chosen = children[0];
|
||||
} else {
|
||||
@@ -190,7 +193,7 @@ export function selectChildBranch(
|
||||
* useful as a persisted selection target.
|
||||
*/
|
||||
export function latestChildId(
|
||||
allMessages: MessageItem[],
|
||||
allMessages: BranchMessage[],
|
||||
parentId: number | null,
|
||||
): number | null {
|
||||
const key = parentKey(parentId);
|
||||
@@ -215,7 +218,7 @@ export function latestChildId(
|
||||
* when no server reload has reconciled real ids yet. ``null`` for an
|
||||
* empty session.
|
||||
*/
|
||||
export function tipMessageId(visible: MessageItem[]): number | null {
|
||||
export function tipMessageId(visible: BranchMessage[]): number | null {
|
||||
for (let i = visible.length - 1; i >= 0; i -= 1) {
|
||||
const id = visible[i].id;
|
||||
if (id !== undefined) return id;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Typed client for first-class Partner Groups. */
|
||||
|
||||
import { apiFetch, apiUrl } from "@/lib/api";
|
||||
import { browserStorage } from "@/shared/storage";
|
||||
import type { PartnerInfo } from "@/lib/partners-api";
|
||||
import type { StreamEvent } from "@/lib/unified-ws";
|
||||
|
||||
@@ -199,10 +200,10 @@ export async function getPartnerGroupInvocations(
|
||||
export function partnerGroupSessionKey(groupId: string): string {
|
||||
const storageKey = `deeptutor:partner-group:${groupId}:session`;
|
||||
if (typeof window === "undefined") return "default";
|
||||
const existing = window.localStorage.getItem(storageKey);
|
||||
const existing = browserStorage.readRaw("local", storageKey);
|
||||
if (existing) return existing;
|
||||
const created = `group-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
window.localStorage.setItem(storageKey, created);
|
||||
browserStorage.writeRaw("local", storageKey, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
@@ -242,7 +243,8 @@ export async function deletePartnerGroupSession(
|
||||
/** Point this group at another (or brand new) discussion thread. */
|
||||
export function setPartnerGroupSessionKey(groupId: string, key: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(
|
||||
browserStorage.writeRaw(
|
||||
"local",
|
||||
`deeptutor:partner-group:${groupId}:session`,
|
||||
key,
|
||||
);
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* the filename stem and the id used by resume / delete / branch).
|
||||
*/
|
||||
|
||||
import { browserStorage } from "@/shared/storage";
|
||||
|
||||
function storageKey(partnerId: string): string {
|
||||
return `partner-session:${partnerId}`;
|
||||
}
|
||||
@@ -15,10 +17,10 @@ export function freshPartnerSessionKey(): string {
|
||||
|
||||
export function loadPartnerSessionKey(partnerId: string): string {
|
||||
try {
|
||||
const existing = window.localStorage.getItem(storageKey(partnerId));
|
||||
const existing = browserStorage.readRaw("local", storageKey(partnerId));
|
||||
if (existing) return existing;
|
||||
const fresh = freshPartnerSessionKey();
|
||||
window.localStorage.setItem(storageKey(partnerId), fresh);
|
||||
browserStorage.writeRaw("local", storageKey(partnerId), fresh);
|
||||
return fresh;
|
||||
} catch {
|
||||
return freshPartnerSessionKey();
|
||||
@@ -27,7 +29,7 @@ export function loadPartnerSessionKey(partnerId: string): string {
|
||||
|
||||
export function persistPartnerSessionKey(partnerId: string, key: string): void {
|
||||
try {
|
||||
window.localStorage.setItem(storageKey(partnerId), key);
|
||||
browserStorage.writeRaw("local", storageKey(partnerId), key);
|
||||
} catch {
|
||||
/* private mode / storage disabled — in-memory only */
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
* written for a mastery path would be nonsense in the reader, so a hand-off the
|
||||
* learner declined must not leak into whichever surface they open next.
|
||||
*/
|
||||
import { browserStorage } from "@/shared/storage";
|
||||
|
||||
const PENDING_PROMPT_KEY = "deeptutor.pendingPrompt";
|
||||
|
||||
function keyFor(scope: string): string {
|
||||
@@ -28,7 +30,7 @@ function keyFor(scope: string): string {
|
||||
export function setPendingPrompt(text: string, scope = ""): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.sessionStorage.setItem(keyFor(scope), text);
|
||||
browserStorage.writeRaw("session", keyFor(scope), text);
|
||||
} catch {
|
||||
// Private-mode browsers reject sessionStorage; the user still lands on the
|
||||
// destination, just with an empty composer.
|
||||
@@ -39,8 +41,8 @@ export function consumePendingPrompt(scope = ""): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
try {
|
||||
const key = keyFor(scope);
|
||||
const value = window.sessionStorage.getItem(key);
|
||||
if (value) window.sessionStorage.removeItem(key);
|
||||
const value = browserStorage.readRaw("session", key);
|
||||
if (value) browserStorage.removeRaw("session", key);
|
||||
return value ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
/**
|
||||
* Persistence utility library for localStorage operations
|
||||
* Provides safe read/write operations with error handling, versioning, and selective persistence
|
||||
*/
|
||||
|
||||
// Storage key prefix to avoid conflicts with other apps
|
||||
const STORAGE_PREFIX = "deeptutor_";
|
||||
|
||||
// Current storage version for data migration support
|
||||
const STORAGE_VERSION = 1;
|
||||
|
||||
// Version key suffix
|
||||
const VERSION_SUFFIX = "_version";
|
||||
|
||||
/**
|
||||
* Storage wrapper interface for storing versioned data
|
||||
*/
|
||||
interface StorageWrapper<T> {
|
||||
version: number;
|
||||
data: T;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely load data from localStorage
|
||||
* @param key Storage key (will be prefixed automatically)
|
||||
* @param defaultValue Default value if key doesn't exist or data is invalid
|
||||
* @returns The stored value or default value
|
||||
*/
|
||||
export function loadFromStorage<T>(key: string, defaultValue: T): T {
|
||||
if (typeof window === "undefined") {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
try {
|
||||
const prefixedKey = STORAGE_PREFIX + key;
|
||||
const raw = localStorage.getItem(prefixedKey);
|
||||
|
||||
if (!raw) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const wrapper: StorageWrapper<T> = JSON.parse(raw);
|
||||
|
||||
// Version check - if version mismatch, return default (can add migration logic here)
|
||||
if (wrapper.version !== STORAGE_VERSION) {
|
||||
console.warn(
|
||||
`Storage version mismatch for ${key}. Expected ${STORAGE_VERSION}, got ${wrapper.version}. Using default value.`,
|
||||
);
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return wrapper.data;
|
||||
} catch (error) {
|
||||
// Handle JSON parse errors or other issues
|
||||
console.warn(`Failed to load ${key} from localStorage:`, error);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely save data to localStorage
|
||||
* @param key Storage key (will be prefixed automatically)
|
||||
* @param value Value to store
|
||||
*/
|
||||
export function saveToStorage<T>(key: string, value: T): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const prefixedKey = STORAGE_PREFIX + key;
|
||||
const wrapper: StorageWrapper<T> = {
|
||||
version: STORAGE_VERSION,
|
||||
data: value,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
localStorage.setItem(prefixedKey, JSON.stringify(wrapper));
|
||||
} catch (error) {
|
||||
// Handle quota exceeded or other storage errors
|
||||
if (error instanceof Error && error.name === "QuotaExceededError") {
|
||||
console.error(
|
||||
`localStorage quota exceeded when saving ${key}. Consider clearing old data.`,
|
||||
);
|
||||
} else {
|
||||
console.warn(`Failed to save ${key} to localStorage:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove data from localStorage
|
||||
* @param key Storage key (will be prefixed automatically)
|
||||
*/
|
||||
export function removeFromStorage(key: string): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const prefixedKey = STORAGE_PREFIX + key;
|
||||
localStorage.removeItem(prefixedKey);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to remove ${key} from localStorage:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all DeepTutor data from localStorage
|
||||
*/
|
||||
export function clearAllStorage(): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const keysToRemove: string[] = [];
|
||||
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && key.startsWith(STORAGE_PREFIX)) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
||||
console.info(`Cleared ${keysToRemove.length} DeepTutor storage items`);
|
||||
} catch (error) {
|
||||
console.warn("Failed to clear localStorage:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a partial copy of state excluding specified fields
|
||||
* Useful for excluding runtime-only fields like isLoading, WebSocket refs, etc.
|
||||
* @param state The full state object
|
||||
* @param exclude Array of field names to exclude from persistence
|
||||
* @returns A new object without the excluded fields
|
||||
*/
|
||||
export function persistState<T extends Record<string, any>>(
|
||||
state: T,
|
||||
exclude: (keyof T)[],
|
||||
): Partial<T> {
|
||||
const result: Partial<T> = {};
|
||||
|
||||
for (const key of Object.keys(state) as (keyof T)[]) {
|
||||
if (!exclude.includes(key)) {
|
||||
result[key] = state[key];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge persisted state with default state
|
||||
* Ensures all required fields exist even if persisted data is incomplete
|
||||
* @param persistedState Partial state loaded from storage
|
||||
* @param defaultState Complete default state
|
||||
* @param exclude Fields that should always use default values (runtime-only fields)
|
||||
* @returns Merged state with all fields populated
|
||||
*/
|
||||
export function mergeWithDefaults<T extends Record<string, any>>(
|
||||
persistedState: Partial<T> | null | undefined,
|
||||
defaultState: T,
|
||||
exclude: (keyof T)[] = [],
|
||||
): T {
|
||||
if (!persistedState) {
|
||||
return defaultState;
|
||||
}
|
||||
|
||||
const result = { ...defaultState };
|
||||
|
||||
for (const key of Object.keys(persistedState) as (keyof T)[]) {
|
||||
// Skip excluded fields - always use defaults
|
||||
if (exclude.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only copy if value is not undefined
|
||||
if (persistedState[key] !== undefined) {
|
||||
result[key] = persistedState[key] as T[keyof T];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage usage statistics
|
||||
* @returns Object with total size and per-key sizes
|
||||
*/
|
||||
export function getStorageStats(): {
|
||||
totalSize: number;
|
||||
items: { key: string; size: number }[];
|
||||
} {
|
||||
if (typeof window === "undefined") {
|
||||
return { totalSize: 0, items: [] };
|
||||
}
|
||||
|
||||
const items: { key: string; size: number }[] = [];
|
||||
let totalSize = 0;
|
||||
|
||||
try {
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && key.startsWith(STORAGE_PREFIX)) {
|
||||
const value = localStorage.getItem(key) || "";
|
||||
const size = new Blob([key + value]).size;
|
||||
items.push({ key: key.replace(STORAGE_PREFIX, ""), size });
|
||||
totalSize += size;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to get storage stats:", error);
|
||||
}
|
||||
|
||||
return { totalSize, items };
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage keys for each module
|
||||
*/
|
||||
export const STORAGE_KEYS = {
|
||||
CHAT_STATE: "chat_state",
|
||||
SOLVER_STATE: "solver_state",
|
||||
QUESTION_STATE: "question_state",
|
||||
RESEARCH_STATE: "research_state",
|
||||
COWRITER_CONTENT: "cowriter_content",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Fields to exclude from persistence for each module
|
||||
* These are runtime-only fields that shouldn't be saved
|
||||
*/
|
||||
export const EXCLUDE_FIELDS = {
|
||||
CHAT: ["isLoading", "currentStage"] as const,
|
||||
SOLVER: [
|
||||
"isSolving",
|
||||
"logs",
|
||||
"agentStatus",
|
||||
"tokenStats",
|
||||
"progress",
|
||||
] as const,
|
||||
QUESTION: [
|
||||
"logs",
|
||||
"progress",
|
||||
"agentStatus",
|
||||
"tokenStats",
|
||||
"uploadedFile",
|
||||
] as const,
|
||||
RESEARCH: ["status", "logs", "progress"] as const,
|
||||
} as const;
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { UnitKind } from "@/lib/reading-api";
|
||||
import { browserStorage } from "@/shared/storage";
|
||||
|
||||
export const READING_HISTORY_LIMIT = 50;
|
||||
const STORAGE_PREFIX = "dt.reader.history.";
|
||||
@@ -180,7 +181,7 @@ export function loadReadingHistory(sessionId: string): ReadingLocationHistory {
|
||||
if (!sessionId || typeof window === "undefined") return EMPTY_READING_HISTORY;
|
||||
try {
|
||||
return parseReadingHistory(
|
||||
window.localStorage.getItem(readingHistoryStorageKey(sessionId)),
|
||||
browserStorage.readRaw("local", readingHistoryStorageKey(sessionId)),
|
||||
);
|
||||
} catch {
|
||||
return EMPTY_READING_HISTORY;
|
||||
@@ -193,7 +194,8 @@ export function saveReadingHistory(
|
||||
): void {
|
||||
if (!sessionId || typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
browserStorage.writeRaw(
|
||||
"local",
|
||||
readingHistoryStorageKey(sessionId),
|
||||
JSON.stringify(history),
|
||||
);
|
||||
|
||||
+2
-581
@@ -1,581 +1,2 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AudioLines,
|
||||
Bot,
|
||||
Boxes,
|
||||
Brain,
|
||||
BrainCircuit,
|
||||
Clapperboard,
|
||||
Database,
|
||||
FileScan,
|
||||
Image as ImageIcon,
|
||||
Info,
|
||||
KeyRound,
|
||||
Library,
|
||||
ListChecks,
|
||||
MessagesSquare,
|
||||
Mic,
|
||||
Network,
|
||||
Palette,
|
||||
Paperclip,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
UserRound,
|
||||
Wrench,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
ClaudeGlyph,
|
||||
CodexGlyph,
|
||||
DeepSeekGlyph,
|
||||
GeminiGlyph,
|
||||
HermesGlyph,
|
||||
KimiGlyph,
|
||||
MimoGlyph,
|
||||
OpenClawGlyph,
|
||||
OpencodeGlyph,
|
||||
} from "@/components/agents/agent-icons";
|
||||
import type { ServiceName } from "@/features/settings/store/SettingsStore";
|
||||
|
||||
/**
|
||||
* Settings information architecture.
|
||||
*
|
||||
* Two levels, deliberately unlike the flat Learning Space dashboard:
|
||||
* • The hub (`/settings`) shows six category blocks + a resident Status
|
||||
* module — nothing else.
|
||||
* • Categories with several settings (Models, Chat) open a sub-hub page
|
||||
* that lists their leaves as tiles; single-setting categories link
|
||||
* straight to their leaf page.
|
||||
*
|
||||
* This module is the single source for the blocks, the sub-hub tiles, and the
|
||||
* breadcrumb trail rendered top-left on every page.
|
||||
*/
|
||||
|
||||
export type Lang = { zh: string; en: string };
|
||||
|
||||
export interface SettingsLeaf {
|
||||
key: string;
|
||||
href: string;
|
||||
label: Lang;
|
||||
blurb: Lang;
|
||||
icon: LucideIcon;
|
||||
/** Colored icon-tile accent for the sub-hub grid (full class strings). */
|
||||
tile: string;
|
||||
/** Model-service leaves carry a configured/not chip from the catalog. */
|
||||
service?: ServiceName;
|
||||
/** Hidden from non-admin users (the backend rejects them anyway). */
|
||||
adminOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface SettingsCategory {
|
||||
key: string;
|
||||
label: Lang;
|
||||
/** One-line descriptor shown on the hub block. */
|
||||
blurb: Lang;
|
||||
icon: LucideIcon;
|
||||
/** Where clicking the block lands — a sub-hub or a leaf page. */
|
||||
href: string;
|
||||
/** Leaves listed on the sub-hub page (omitted for direct-leaf categories). */
|
||||
children?: SettingsLeaf[];
|
||||
/** Shown only when the backend reports an active learner policy. */
|
||||
learnerOnly?: boolean;
|
||||
/** Shown only to authenticated standard users who may act as guardians. */
|
||||
guardianOnly?: boolean;
|
||||
}
|
||||
|
||||
const MODEL_CHILDREN: SettingsLeaf[] = [
|
||||
{
|
||||
key: "connections",
|
||||
href: "/settings/models#connections",
|
||||
label: { zh: "连接", en: "Connections" },
|
||||
blurb: {
|
||||
zh: "一份凭据供给多个服务。",
|
||||
en: "One credential, supplying several services.",
|
||||
},
|
||||
icon: KeyRound,
|
||||
tile: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
|
||||
},
|
||||
{
|
||||
key: "llm",
|
||||
href: "/settings/models#llm",
|
||||
label: { zh: "LLM", en: "LLM" },
|
||||
blurb: {
|
||||
zh: "语言模型供应商与当前档位。",
|
||||
en: "Language model providers and active profile.",
|
||||
},
|
||||
icon: Brain,
|
||||
tile: "bg-violet-500/10 text-violet-600 dark:text-violet-400",
|
||||
service: "llm",
|
||||
},
|
||||
{
|
||||
key: "task-models",
|
||||
href: "/settings/models#task-models",
|
||||
label: { zh: "任务模型", en: "Task models" },
|
||||
blurb: {
|
||||
zh: "DeepTutor 自己发起的调用使用的模型。",
|
||||
en: "The model behind the calls DeepTutor makes on its own.",
|
||||
},
|
||||
icon: ListChecks,
|
||||
tile: "bg-cyan-500/10 text-cyan-600 dark:text-cyan-400",
|
||||
},
|
||||
{
|
||||
key: "embedding",
|
||||
href: "/settings/models#embedding",
|
||||
label: { zh: "嵌入模型", en: "Embedding" },
|
||||
blurb: {
|
||||
zh: "向量模型供应商与维度。",
|
||||
en: "Embedding model providers and dimensions.",
|
||||
},
|
||||
icon: Database,
|
||||
tile: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
|
||||
service: "embedding",
|
||||
},
|
||||
{
|
||||
key: "search",
|
||||
href: "/settings/models#search",
|
||||
label: { zh: "搜索", en: "Search" },
|
||||
blurb: { zh: "联网搜索供应商。", en: "Web search providers." },
|
||||
icon: Search,
|
||||
tile: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
|
||||
service: "search",
|
||||
},
|
||||
{
|
||||
key: "tts",
|
||||
href: "/settings/models#tts",
|
||||
label: { zh: "语音合成", en: "Text-to-Speech" },
|
||||
blurb: {
|
||||
zh: "朗读助手回复的 TTS 供应商。",
|
||||
en: "Text-to-speech for reading replies aloud.",
|
||||
},
|
||||
icon: AudioLines,
|
||||
tile: "bg-rose-500/10 text-rose-600 dark:text-rose-400",
|
||||
service: "tts",
|
||||
},
|
||||
{
|
||||
key: "stt",
|
||||
href: "/settings/models#stt",
|
||||
label: { zh: "语音识别", en: "Speech-to-Text" },
|
||||
blurb: {
|
||||
zh: "转写麦克风录音的 STT 供应商。",
|
||||
en: "Speech-to-text for the composer microphone.",
|
||||
},
|
||||
icon: Mic,
|
||||
tile: "bg-pink-500/10 text-pink-600 dark:text-pink-400",
|
||||
service: "stt",
|
||||
},
|
||||
{
|
||||
key: "imagegen",
|
||||
href: "/settings/models#imagegen",
|
||||
label: { zh: "文生图", en: "Image Generation" },
|
||||
blurb: {
|
||||
zh: "chat imagegen 工具使用的文生图模型。",
|
||||
en: "Text-to-image model for the chat imagegen tool.",
|
||||
},
|
||||
icon: ImageIcon,
|
||||
tile: "bg-fuchsia-500/10 text-fuchsia-600 dark:text-fuchsia-400",
|
||||
service: "imagegen",
|
||||
},
|
||||
{
|
||||
key: "videogen",
|
||||
href: "/settings/models#videogen",
|
||||
label: { zh: "文生视频", en: "Video Generation" },
|
||||
blurb: {
|
||||
zh: "chat videogen 工具使用的文生视频模型。",
|
||||
en: "Text-to-video model for the chat videogen tool.",
|
||||
},
|
||||
icon: Clapperboard,
|
||||
tile: "bg-indigo-500/10 text-indigo-600 dark:text-indigo-400",
|
||||
service: "videogen",
|
||||
},
|
||||
];
|
||||
|
||||
const CHAT_CHILDREN: SettingsLeaf[] = [
|
||||
{
|
||||
key: "video-learning",
|
||||
href: "/settings/video-learning",
|
||||
label: { zh: "视频学习", en: "Video Learning" },
|
||||
blurb: {
|
||||
zh: "原生 YouTube 与本地 Invidious 播放供应商。",
|
||||
en: "Native YouTube and local Invidious playback providers.",
|
||||
},
|
||||
icon: Clapperboard,
|
||||
tile: "bg-red-500/10 text-red-600 dark:text-red-400",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
key: "tools",
|
||||
href: "/settings/chat#tools",
|
||||
label: { zh: "工具", en: "Tools" },
|
||||
blurb: {
|
||||
zh: "对话智能体可调用的内置工具。",
|
||||
en: "Built-in tools the chat agent can invoke.",
|
||||
},
|
||||
icon: Wrench,
|
||||
tile: "bg-orange-500/10 text-orange-600 dark:text-orange-400",
|
||||
},
|
||||
{
|
||||
key: "capabilities",
|
||||
href: "/settings/chat#capabilities",
|
||||
label: { zh: "能力", en: "Capabilities" },
|
||||
blurb: {
|
||||
zh: "各能力的 LLM 参数与运行时旋钮。",
|
||||
en: "Per-capability LLM parameters and runtime knobs.",
|
||||
},
|
||||
icon: SlidersHorizontal,
|
||||
tile: "bg-lime-500/10 text-lime-600 dark:text-lime-400",
|
||||
},
|
||||
{
|
||||
key: "starters",
|
||||
href: "/settings/chat#starters",
|
||||
label: { zh: "起始建议", en: "Starting points" },
|
||||
blurb: {
|
||||
zh: "主页输入框下方那三行引导的素材范围。",
|
||||
en: "How much history shapes the three lines under the composer.",
|
||||
},
|
||||
icon: Sparkles,
|
||||
tile: "bg-violet-500/10 text-violet-600 dark:text-violet-400",
|
||||
},
|
||||
{
|
||||
key: "attachments",
|
||||
href: "/settings/chat#attachments",
|
||||
label: { zh: "附件", en: "Attachments" },
|
||||
blurb: {
|
||||
zh: "聊天附件的大小上限与文本提取预算。",
|
||||
en: "Upload caps and extraction budgets for chat attachments.",
|
||||
},
|
||||
icon: Paperclip,
|
||||
tile: "bg-teal-500/10 text-teal-600 dark:text-teal-400",
|
||||
adminOnly: true,
|
||||
},
|
||||
];
|
||||
|
||||
const AGENT_CHILDREN: SettingsLeaf[] = [
|
||||
{
|
||||
key: "agent-claude-code",
|
||||
href: "/settings/agents#agent-claude-code",
|
||||
label: { zh: "Claude Code", en: "Claude Code" },
|
||||
blurb: {
|
||||
zh: "DeepTutor 调用本机 Claude Code 时的模型、推理强度与运行参数。",
|
||||
en: "Model, reasoning effort, and run params for the local Claude Code.",
|
||||
},
|
||||
// Brand glyph shares the lucide call signature (size/className).
|
||||
icon: ClaudeGlyph as unknown as LucideIcon,
|
||||
tile: "bg-orange-500/10 text-orange-600 dark:text-orange-400",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
key: "agent-codex",
|
||||
href: "/settings/agents#agent-codex",
|
||||
label: { zh: "Codex", en: "Codex" },
|
||||
blurb: {
|
||||
zh: "DeepTutor 调用本机 Codex 时的模型、推理强度与运行参数。",
|
||||
en: "Model, reasoning effort, and run params for the local Codex.",
|
||||
},
|
||||
icon: CodexGlyph as unknown as LucideIcon,
|
||||
tile: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
// Gemini CLI's supported replacement.
|
||||
key: "agent-antigravity",
|
||||
href: "/settings/agents#agent-antigravity",
|
||||
label: { zh: "Antigravity CLI", en: "Antigravity CLI" },
|
||||
blurb: {
|
||||
zh: "DeepTutor 调用本机 Antigravity CLI 时的模型与运行参数。",
|
||||
en: "Model and run params for the local Antigravity CLI.",
|
||||
},
|
||||
icon: GeminiGlyph as unknown as LucideIcon,
|
||||
tile: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
key: "agent-kimi",
|
||||
href: "/settings/agents#agent-kimi",
|
||||
label: { zh: "Kimi CLI", en: "Kimi CLI" },
|
||||
blurb: {
|
||||
zh: "DeepTutor 调用本机 Kimi CLI 时的模型与运行参数。",
|
||||
en: "Model and run params for the local Kimi CLI.",
|
||||
},
|
||||
icon: KimiGlyph as unknown as LucideIcon,
|
||||
tile: "bg-zinc-500/10 text-zinc-700 dark:text-zinc-300",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
key: "agent-opencode",
|
||||
href: "/settings/agents#agent-opencode",
|
||||
label: { zh: "opencode", en: "opencode" },
|
||||
blurb: {
|
||||
zh: "DeepTutor 调用本机 opencode 时的模型、推理强度与运行参数。",
|
||||
en: "Model, reasoning effort, and run params for the local opencode.",
|
||||
},
|
||||
icon: OpencodeGlyph as unknown as LucideIcon,
|
||||
tile: "bg-neutral-500/10 text-neutral-700 dark:text-neutral-300",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
key: "agent-mimo",
|
||||
href: "/settings/agents#agent-mimo",
|
||||
label: { zh: "MiMo Code", en: "MiMo Code" },
|
||||
blurb: {
|
||||
zh: "DeepTutor 调用本机 MiMo Code 时的模型、推理强度与运行参数。",
|
||||
en: "Model, reasoning effort, and run params for the local MiMo Code.",
|
||||
},
|
||||
icon: MimoGlyph as unknown as LucideIcon,
|
||||
tile: "bg-orange-500/10 text-orange-600 dark:text-orange-400",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
key: "agent-hermes",
|
||||
href: "/settings/agents#agent-hermes",
|
||||
label: { zh: "Hermes Agent", en: "Hermes Agent" },
|
||||
blurb: {
|
||||
zh: "DeepTutor 调用本机 Hermes Agent 时的模型、推理强度与运行参数。",
|
||||
en: "Model, reasoning effort, and run params for the local Hermes Agent.",
|
||||
},
|
||||
icon: HermesGlyph as unknown as LucideIcon,
|
||||
tile: "bg-violet-500/10 text-violet-600 dark:text-violet-400",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
key: "agent-openclaw",
|
||||
href: "/settings/agents#agent-openclaw",
|
||||
label: { zh: "OpenClaw", en: "OpenClaw" },
|
||||
blurb: {
|
||||
zh: "DeepTutor 通过 Gateway 或本地模式调用 OpenClaw 的运行参数。",
|
||||
en: "Gateway or local-mode run params for the local OpenClaw agent.",
|
||||
},
|
||||
icon: OpenClawGlyph as unknown as LucideIcon,
|
||||
tile: "bg-red-500/10 text-red-600 dark:text-red-400",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
key: "agent-deepseek-harness",
|
||||
href: "/settings/agents#agent-deepseek-harness",
|
||||
label: { zh: "DeepSeek Harness", en: "DeepSeek Harness" },
|
||||
blurb: {
|
||||
zh: "DeepTutor 通过 Python SDK 或 headless CLI 调用 DeepSeek Harness。",
|
||||
en: "Python SDK or headless CLI settings for DeepSeek Harness.",
|
||||
},
|
||||
icon: DeepSeekGlyph as unknown as LucideIcon,
|
||||
tile: "bg-indigo-500/10 text-indigo-600 dark:text-indigo-400",
|
||||
adminOnly: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const SETTINGS_CATEGORIES: SettingsCategory[] = [
|
||||
{
|
||||
key: "appearance",
|
||||
label: { zh: "外观", en: "Appearance" },
|
||||
blurb: { zh: "视觉主题与界面语言", en: "Theme and interface language" },
|
||||
icon: Palette,
|
||||
href: "/settings/appearance",
|
||||
},
|
||||
{
|
||||
key: "network",
|
||||
label: { zh: "网络", en: "Network" },
|
||||
blurb: {
|
||||
zh: "端口、浏览器 API 地址与 CORS",
|
||||
en: "Ports, browser API base, and CORS",
|
||||
},
|
||||
icon: Network,
|
||||
href: "/settings/network",
|
||||
},
|
||||
{
|
||||
key: "models",
|
||||
label: { zh: "模型", en: "Models" },
|
||||
blurb: {
|
||||
zh: "语言、向量、搜索、语音与生成模型",
|
||||
en: "Language, embedding, search, voice, and generation models",
|
||||
},
|
||||
icon: Boxes,
|
||||
href: "/settings/models",
|
||||
children: MODEL_CHILDREN,
|
||||
},
|
||||
{
|
||||
key: "knowledge",
|
||||
label: { zh: "知识库", en: "Knowledge Base" },
|
||||
blurb: { zh: "文档解析引擎", en: "Document parsing engine" },
|
||||
icon: Library,
|
||||
href: "/settings/document-parsing",
|
||||
},
|
||||
{
|
||||
key: "chat",
|
||||
label: { zh: "聊天", en: "Chat" },
|
||||
blurb: {
|
||||
zh: "工具、能力与附件",
|
||||
en: "Tools, capabilities, and attachments",
|
||||
},
|
||||
icon: MessagesSquare,
|
||||
href: "/settings/chat",
|
||||
children: CHAT_CHILDREN,
|
||||
},
|
||||
{
|
||||
key: "agents",
|
||||
label: { zh: "伙伴和智能体", en: "Partners & Agents" },
|
||||
blurb: {
|
||||
zh: "配置可在对话中调用的子智能体",
|
||||
en: "Configure the subagents you can call on in chat",
|
||||
},
|
||||
icon: Bot,
|
||||
href: "/settings/agents",
|
||||
children: AGENT_CHILDREN,
|
||||
},
|
||||
{
|
||||
key: "learner-profile",
|
||||
learnerOnly: true,
|
||||
label: { zh: "学习档案", en: "Learner profile" },
|
||||
blurb: {
|
||||
zh: "调整年龄、年级与讲解偏好。",
|
||||
en: "Adjust age, grade, and explanation preferences.",
|
||||
},
|
||||
icon: UserRound,
|
||||
href: "/settings/learner-profile",
|
||||
},
|
||||
{
|
||||
key: "guardian",
|
||||
guardianOnly: true,
|
||||
label: { zh: "监护管理", en: "Guardian" },
|
||||
blurb: {
|
||||
zh: "查看已授权学习者与学习材料。",
|
||||
en: "Review authorized learners and learning materials.",
|
||||
},
|
||||
icon: ShieldCheck,
|
||||
href: "/settings/guardian",
|
||||
},
|
||||
{
|
||||
key: "memory",
|
||||
label: { zh: "记忆", en: "Memory" },
|
||||
blurb: {
|
||||
zh: "分块、预算、去重与引用策略",
|
||||
en: "Chunking, budget, dedup, and reference policies",
|
||||
},
|
||||
icon: BrainCircuit,
|
||||
href: "/settings/memory",
|
||||
},
|
||||
{
|
||||
key: "about",
|
||||
label: { zh: "关于", en: "About" },
|
||||
blurb: {
|
||||
zh: "版本、更新与项目资源",
|
||||
en: "Version, updates, and project resources",
|
||||
},
|
||||
icon: Info,
|
||||
href: "/settings/about",
|
||||
},
|
||||
];
|
||||
|
||||
export const SETTINGS_HUB_HREF = "/settings";
|
||||
const HUB_LABEL: Lang = { zh: "设置", en: "Settings" };
|
||||
|
||||
/** The canonical in-document URL used by the persistent settings navigator. */
|
||||
export function settingsAnchorHref(key: string): string {
|
||||
return `${SETTINGS_HUB_HREF}#${key}`;
|
||||
}
|
||||
|
||||
/** Legacy standalone routes that do not edit the shared settings draft. */
|
||||
const NAV_ONLY_ROUTES = new Set<string>(["/settings/about"]);
|
||||
|
||||
export function isNavOnlyRoute(pathname: string): boolean {
|
||||
return NAV_ONLY_ROUTES.has(pathname);
|
||||
}
|
||||
|
||||
/**
|
||||
* Categories rendered as one continuously-scrolling page (Models, Chat,
|
||||
* Partners & Agents) rather than a route per leaf. Their children's `href`
|
||||
* points at `${category.href}#${leaf.key}` — a same-page anchor, not a route
|
||||
* change — so switching between them never remounts the page.
|
||||
*/
|
||||
export const MERGED_CATEGORY_HREFS = new Set(
|
||||
SETTINGS_CATEGORIES.filter((c) => c.children).map((c) => c.href),
|
||||
);
|
||||
|
||||
// The on-disk file (under data/user/settings/) each leaf module persists to.
|
||||
// Surfaced in the toolbar status line so every page says where its parameters
|
||||
// live, without duplicating the string on each page. Singleton pages (no
|
||||
// merged category) are keyed by pathname; leaves inside a merged category
|
||||
// page share one pathname, so those are keyed by `leaf.key` instead and
|
||||
// looked up via the currently scrolled-to section (see `storagePathFor`).
|
||||
const STORAGE_PATHS: Record<string, string> = {
|
||||
"/settings/appearance": "data/user/settings/interface.json",
|
||||
"/settings/network": "data/user/settings/system.json",
|
||||
"/settings/llm": "data/user/settings/model_catalog.json",
|
||||
"/settings/embedding": "data/user/settings/model_catalog.json",
|
||||
"/settings/search": "data/user/settings/model_catalog.json",
|
||||
"/settings/tts": "data/user/settings/model_catalog.json",
|
||||
"/settings/stt": "data/user/settings/model_catalog.json",
|
||||
"/settings/image": "data/user/settings/model_catalog.json",
|
||||
"/settings/video": "data/user/settings/model_catalog.json",
|
||||
"/settings/video-learning": "data/user/settings/video_learning.json",
|
||||
"/settings/document-parsing": "data/user/settings/document_parsing.json",
|
||||
"/settings/memory": "data/user/settings/main.yaml",
|
||||
appearance: "data/user/settings/interface.json",
|
||||
network: "data/user/settings/system.json",
|
||||
connections: "data/user/settings/model_catalog.json",
|
||||
"task-models": "data/user/settings/model_catalog.json",
|
||||
knowledge: "data/user/settings/document_parsing.json",
|
||||
"video-learning": "data/user/settings/video_learning.json",
|
||||
starters: "data/user/settings/interface.json",
|
||||
memory: "data/user/settings/main.yaml",
|
||||
llm: "data/user/settings/model_catalog.json",
|
||||
embedding: "data/user/settings/model_catalog.json",
|
||||
search: "data/user/settings/model_catalog.json",
|
||||
tts: "data/user/settings/model_catalog.json",
|
||||
stt: "data/user/settings/model_catalog.json",
|
||||
imagegen: "data/user/settings/model_catalog.json",
|
||||
videogen: "data/user/settings/model_catalog.json",
|
||||
tools: "data/user/settings/interface.json",
|
||||
attachments: "data/user/settings/system.json",
|
||||
capabilities: "data/user/settings/main.yaml · agents.yaml",
|
||||
"agent-claude-code": "data/user/settings/subagent.json",
|
||||
"agent-codex": "data/user/settings/subagent.json",
|
||||
"agent-antigravity": "data/user/settings/subagent.json",
|
||||
"agent-kimi": "data/user/settings/subagent.json",
|
||||
"agent-opencode": "data/user/settings/subagent.json",
|
||||
"agent-mimo": "data/user/settings/subagent.json",
|
||||
};
|
||||
|
||||
export function storagePathFor(
|
||||
pathname: string,
|
||||
activeSection?: string | null,
|
||||
): string | null {
|
||||
if (pathname === SETTINGS_HUB_HREF || MERGED_CATEGORY_HREFS.has(pathname)) {
|
||||
return activeSection ? (STORAGE_PATHS[activeSection] ?? null) : null;
|
||||
}
|
||||
return STORAGE_PATHS[pathname] ?? null;
|
||||
}
|
||||
|
||||
export interface Crumb {
|
||||
label: Lang;
|
||||
/** Omitted on the current (last) crumb. */
|
||||
href?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The breadcrumb trail for a settings route, e.g.
|
||||
* /settings/llm → 设置 / 模型 / LLM
|
||||
* /settings/network → 设置 / 网络
|
||||
* Returns just [设置] for the hub itself.
|
||||
*/
|
||||
export function breadcrumbFor(pathname: string): Crumb[] {
|
||||
const root: Crumb = { label: HUB_LABEL, href: SETTINGS_HUB_HREF };
|
||||
if (pathname === SETTINGS_HUB_HREF) return [{ label: HUB_LABEL }];
|
||||
|
||||
// Direct-leaf or sub-hub category landed on its own href.
|
||||
const category = SETTINGS_CATEGORIES.find((c) => c.href === pathname);
|
||||
if (category) return [root, { label: category.label }];
|
||||
|
||||
// A leaf inside a sub-hub category.
|
||||
for (const c of SETTINGS_CATEGORIES) {
|
||||
const leaf = c.children?.find((l) => l.href === pathname);
|
||||
if (leaf) {
|
||||
return [root, { label: c.label, href: c.href }, { label: leaf.label }];
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown sub-route (e.g. a legacy redirect target rendered directly).
|
||||
return [root];
|
||||
}
|
||||
/** @deprecated Import settings navigation from the settings feature. */
|
||||
export * from "@/features/settings/navigation/settings-nav";
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
* ``window``.
|
||||
*/
|
||||
|
||||
import { browserStorage } from "@/shared/storage";
|
||||
|
||||
export interface SidebarNavLayout {
|
||||
/**
|
||||
* Flat top-to-bottom order of every known feature, folded ones included.
|
||||
@@ -237,7 +239,7 @@ export function mergeManualOrder(
|
||||
function readJson<T>(key: string, fallback: T): T {
|
||||
if (typeof window === "undefined") return fallback;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
const raw = browserStorage.readRaw("local", key);
|
||||
if (!raw) return fallback;
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
@@ -248,7 +250,7 @@ function readJson<T>(key: string, fallback: T): T {
|
||||
function writeJson(key: string, value: unknown) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
browserStorage.writeRaw("local", key, JSON.stringify(value));
|
||||
} catch {
|
||||
// A full or disabled store costs the preference, never the sidebar.
|
||||
}
|
||||
|
||||
+4
-3
@@ -3,6 +3,8 @@
|
||||
* Handles light/dark theme with localStorage fallback and system preference detection
|
||||
*/
|
||||
|
||||
import { browserStorage } from "@/shared/storage";
|
||||
|
||||
export type Theme = "light" | "dark" | "glass" | "snow";
|
||||
|
||||
export const THEME_STORAGE_KEY = "deeptutor-theme";
|
||||
@@ -34,7 +36,7 @@ export function getStoredTheme(): Theme | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
const stored = browserStorage.readRaw("local", THEME_STORAGE_KEY);
|
||||
if (
|
||||
stored === "light" ||
|
||||
stored === "dark" ||
|
||||
@@ -57,8 +59,7 @@ export function saveThemeToStorage(theme: Theme): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
|
||||
try {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||||
return true;
|
||||
return browserStorage.writeRaw("local", THEME_STORAGE_KEY, theme);
|
||||
} catch (e) {
|
||||
// Silently fail - localStorage may be disabled or full
|
||||
return false;
|
||||
|
||||
Generated
+323
-3
@@ -46,6 +46,7 @@
|
||||
"@types/react-dom": "^19",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"dependency-cruiser": "^17.4.3",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.3",
|
||||
"jsdom": "^29.1.1",
|
||||
@@ -3615,9 +3616,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
@@ -3636,6 +3637,39 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-jsx-walk": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn-jsx-walk/-/acorn-jsx-walk-2.0.0.tgz",
|
||||
"integrity": "sha512-uuo6iJj4D4ygkdzd6jPtcxs8vZgDX9YFIkqczGImoypX2fQ4dVImmu3UzA4ynixCIMTrEOWW+95M2HuBaCEOVA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/acorn-loose": {
|
||||
"version": "8.5.2",
|
||||
"resolved": "https://registry.npmjs.org/acorn-loose/-/acorn-loose-8.5.2.tgz",
|
||||
"integrity": "sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.15.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-walk": {
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
|
||||
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
@@ -5505,6 +5539,90 @@
|
||||
"robust-predicates": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/dependency-cruiser": {
|
||||
"version": "17.4.3",
|
||||
"resolved": "https://registry.npmjs.org/dependency-cruiser/-/dependency-cruiser-17.4.3.tgz",
|
||||
"integrity": "sha512-L4GLuAvmXevWnPCIaFfOz6eD92c+yY+pDgVqgufrLDnW3xYA799CSZQlly2r2N13nhAlnZY6VzY7Rx5pHNvk2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "8.16.0",
|
||||
"acorn-jsx": "5.3.2",
|
||||
"acorn-jsx-walk": "2.0.0",
|
||||
"acorn-loose": "8.5.2",
|
||||
"acorn-walk": "8.3.5",
|
||||
"commander": "14.0.3",
|
||||
"enhanced-resolve": "5.22.1",
|
||||
"ignore": "7.0.5",
|
||||
"interpret": "3.1.1",
|
||||
"is-installed-globally": "1.0.0",
|
||||
"json5": "2.2.3",
|
||||
"picomatch": "4.0.4",
|
||||
"prompts": "2.4.2",
|
||||
"rechoir": "0.8.0",
|
||||
"safe-regex": "2.1.1",
|
||||
"semver": "7.8.1",
|
||||
"tsconfig-paths-webpack-plugin": "4.2.0",
|
||||
"watskeburt": "5.0.3"
|
||||
},
|
||||
"bin": {
|
||||
"depcruise": "bin/dependency-cruise.mjs",
|
||||
"depcruise-baseline": "bin/depcruise-baseline.mjs",
|
||||
"depcruise-fmt": "bin/depcruise-fmt.mjs",
|
||||
"depcruise-wrap-stream-in-html": "bin/wrap-stream-in-html.mjs",
|
||||
"dependency-cruise": "bin/dependency-cruise.mjs",
|
||||
"dependency-cruiser": "bin/dependency-cruise.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.12||^22||>=24"
|
||||
}
|
||||
},
|
||||
"node_modules/dependency-cruiser/node_modules/commander": {
|
||||
"version": "14.0.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
|
||||
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/dependency-cruiser/node_modules/ignore": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/dependency-cruiser/node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/dependency-cruiser/node_modules/semver": {
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
|
||||
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
@@ -5672,6 +5790,20 @@
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.22.1",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz",
|
||||
"integrity": "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
"tapable": "^2.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
@@ -6912,6 +7044,22 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/global-directory": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz",
|
||||
"integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ini": "4.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
|
||||
@@ -7529,6 +7677,16 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz",
|
||||
"integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/inline-style-parser": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
|
||||
@@ -7559,6 +7717,16 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/interpret": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz",
|
||||
"integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iobuffer": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
|
||||
@@ -7839,6 +8007,23 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/is-installed-globally": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-1.0.0.tgz",
|
||||
"integrity": "sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"global-directory": "^4.0.1",
|
||||
"is-path-inside": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-map": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
|
||||
@@ -7892,6 +8077,19 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-path-inside": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz",
|
||||
"integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-obj": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
|
||||
@@ -8448,6 +8646,16 @@
|
||||
"resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz",
|
||||
"integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
|
||||
"integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/langium": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/langium/-/langium-4.2.2.tgz",
|
||||
@@ -11069,6 +11277,20 @@
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prompts": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
||||
"integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"kleur": "^3.0.3",
|
||||
"sisteransi": "^1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
@@ -11326,6 +11548,19 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rechoir": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz",
|
||||
"integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve": "^1.20.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||
@@ -11386,6 +11621,16 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/regexp-tree": {
|
||||
"version": "0.1.27",
|
||||
"resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz",
|
||||
"integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"regexp-tree": "bin/regexp-tree"
|
||||
}
|
||||
},
|
||||
"node_modules/regexp.prototype.flags": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
||||
@@ -11747,6 +11992,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-regex": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz",
|
||||
"integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"regexp-tree": "~0.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-regex-test": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
|
||||
@@ -12038,6 +12293,13 @@
|
||||
"node": ">=0.12.18"
|
||||
}
|
||||
},
|
||||
"node_modules/sisteransi": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
|
||||
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -12475,6 +12737,20 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
|
||||
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
@@ -12736,6 +13012,37 @@
|
||||
"strip-bom": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tsconfig-paths-webpack-plugin": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-4.2.0.tgz",
|
||||
"integrity": "sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "^4.1.0",
|
||||
"enhanced-resolve": "^5.7.0",
|
||||
"tapable": "^2.2.1",
|
||||
"tsconfig-paths": "^4.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tsconfig-paths-webpack-plugin/node_modules/tsconfig-paths": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz",
|
||||
"integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json5": "^2.2.2",
|
||||
"minimist": "^1.2.6",
|
||||
"strip-bom": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tsconfig-paths/node_modules/json5": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
|
||||
@@ -13556,6 +13863,19 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/watskeburt": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/watskeburt/-/watskeburt-5.0.3.tgz",
|
||||
"integrity": "sha512-g9CXukMjazlJJVQ3OHzXsnG25KFYgSgKMIyoJrD8ggr0DbS9UNF7OzIqWmmKKBMedkxj3T01uqEaGnn+y7QhMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"watskeburt": "dist/run-cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.12||^22.13||>=24.0"
|
||||
}
|
||||
},
|
||||
"node_modules/web-namespaces": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"i18n:audit": "node ./scripts/i18n_audit.mjs",
|
||||
"i18n:audit:strict": "node ./scripts/i18n_audit.mjs --strict",
|
||||
"i18n:check": "npm run i18n:parity && npm run i18n:audit",
|
||||
"architecture:check": "depcruise --config .dependency-cruiser.cjs app components context contracts features hooks lib shared",
|
||||
"audit": "playwright test --project=ui-audit",
|
||||
"audit:ui": "playwright test --ui --project=ui-audit",
|
||||
"audit:report": "playwright show-report",
|
||||
@@ -71,6 +72,7 @@
|
||||
"@types/react-dom": "^19",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"dependency-cruiser": "^17.4.3",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.3",
|
||||
"jsdom": "^29.1.1",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./keys";
|
||||
export * from "./schema";
|
||||
export * from "./store";
|
||||
@@ -0,0 +1,27 @@
|
||||
export const STORAGE_NAMESPACE = "deeptutor:v2:";
|
||||
|
||||
export type StorageScope = "local" | "session";
|
||||
|
||||
export interface StorageKey<T> {
|
||||
name: string;
|
||||
scope: StorageScope;
|
||||
version: number;
|
||||
fallback: T;
|
||||
validate: (value: unknown) => value is T;
|
||||
migrate?: (value: unknown, fromVersion: number) => T | null;
|
||||
}
|
||||
|
||||
export function defineStorageKey<T>(key: StorageKey<T>): StorageKey<T> {
|
||||
return Object.freeze({ ...key });
|
||||
}
|
||||
|
||||
export function physicalStorageKey<T>(key: StorageKey<T>): string {
|
||||
return `${STORAGE_NAMESPACE}${key.scope}:${key.name}`;
|
||||
}
|
||||
|
||||
export function dynamicStorageKey<T>(
|
||||
base: Omit<StorageKey<T>, "name">,
|
||||
name: string,
|
||||
): StorageKey<T> {
|
||||
return defineStorageKey({ ...base, name });
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { StorageKey } from "./keys";
|
||||
|
||||
export interface StorageEnvelope<T> {
|
||||
version: number;
|
||||
value: T;
|
||||
writtenAt: number;
|
||||
}
|
||||
|
||||
export type ParsedStorageValue<T> =
|
||||
| { ok: true; value: T; migrated: boolean }
|
||||
| { ok: false; reason: "corrupt" | "invalid" | "version" };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function encodeStorageValue<T>(
|
||||
key: StorageKey<T>,
|
||||
value: T,
|
||||
now = Date.now(),
|
||||
): string {
|
||||
return JSON.stringify({ version: key.version, value, writtenAt: now });
|
||||
}
|
||||
|
||||
export function parseStorageValue<T>(
|
||||
key: StorageKey<T>,
|
||||
raw: string,
|
||||
): ParsedStorageValue<T> {
|
||||
let decoded: unknown;
|
||||
try {
|
||||
decoded = JSON.parse(raw);
|
||||
} catch {
|
||||
return { ok: false, reason: "corrupt" };
|
||||
}
|
||||
if (!isRecord(decoded) || typeof decoded.version !== "number") {
|
||||
return { ok: false, reason: "invalid" };
|
||||
}
|
||||
if (decoded.version === key.version) {
|
||||
return key.validate(decoded.value)
|
||||
? { ok: true, value: decoded.value, migrated: false }
|
||||
: { ok: false, reason: "invalid" };
|
||||
}
|
||||
const migrated = key.migrate?.(decoded.value, decoded.version) ?? null;
|
||||
return migrated !== null && key.validate(migrated)
|
||||
? { ok: true, value: migrated, migrated: true }
|
||||
: { ok: false, reason: "version" };
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
STORAGE_NAMESPACE,
|
||||
physicalStorageKey,
|
||||
type StorageKey,
|
||||
type StorageScope,
|
||||
} from "./keys";
|
||||
import { encodeStorageValue, parseStorageValue } from "./schema";
|
||||
|
||||
export interface StorageLike {
|
||||
readonly length: number;
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
key(index: number): string | null;
|
||||
}
|
||||
|
||||
export interface StorageEventLike {
|
||||
key: string | null;
|
||||
newValue: string | null;
|
||||
storageArea?: StorageLike | null;
|
||||
}
|
||||
|
||||
export interface StorageEventTargetLike {
|
||||
addEventListener(type: "storage", listener: (event: StorageEventLike) => void): void;
|
||||
removeEventListener(type: "storage", listener: (event: StorageEventLike) => void): void;
|
||||
}
|
||||
|
||||
export class StorageStore {
|
||||
constructor(
|
||||
private readonly providers: Partial<Record<StorageScope, StorageLike>>,
|
||||
private readonly events?: StorageEventTargetLike,
|
||||
) {}
|
||||
|
||||
private provider(scope: StorageScope): StorageLike | undefined {
|
||||
return this.providers[scope];
|
||||
}
|
||||
|
||||
read<T>(key: StorageKey<T>): T {
|
||||
const storage = this.provider(key.scope);
|
||||
if (!storage) return key.fallback;
|
||||
try {
|
||||
const raw = storage.getItem(physicalStorageKey(key));
|
||||
if (raw === null) return key.fallback;
|
||||
const parsed = parseStorageValue(key, raw);
|
||||
if (!parsed.ok) return key.fallback;
|
||||
if (parsed.migrated) this.write(key, parsed.value);
|
||||
return parsed.value;
|
||||
} catch {
|
||||
return key.fallback;
|
||||
}
|
||||
}
|
||||
|
||||
write<T>(key: StorageKey<T>, value: T): boolean {
|
||||
const storage = this.provider(key.scope);
|
||||
if (!storage || !key.validate(value)) return false;
|
||||
try {
|
||||
storage.setItem(physicalStorageKey(key), encodeStorageValue(key, value));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
remove<T>(key: StorageKey<T>): boolean {
|
||||
const storage = this.provider(key.scope);
|
||||
if (!storage) return false;
|
||||
try {
|
||||
storage.removeItem(physicalStorageKey(key));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
readRaw(scope: StorageScope, name: string): string | null {
|
||||
try {
|
||||
return this.provider(scope)?.getItem(name) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
writeRaw(scope: StorageScope, name: string, value: string): boolean {
|
||||
try {
|
||||
const storage = this.provider(scope);
|
||||
if (!storage) return false;
|
||||
storage.setItem(name, value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
removeRaw(scope: StorageScope, name: string): boolean {
|
||||
try {
|
||||
const storage = this.provider(scope);
|
||||
if (!storage) return false;
|
||||
storage.removeItem(name);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
clearNamespace(scope: StorageScope, namePrefix = ""): number {
|
||||
const storage = this.provider(scope);
|
||||
if (!storage) return 0;
|
||||
const prefix = `${STORAGE_NAMESPACE}${scope}:${namePrefix}`;
|
||||
const matches: string[] = [];
|
||||
try {
|
||||
for (let index = 0; index < storage.length; index += 1) {
|
||||
const key = storage.key(index);
|
||||
if (key?.startsWith(prefix)) matches.push(key);
|
||||
}
|
||||
for (const key of matches) storage.removeItem(key);
|
||||
return matches.length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
subscribe<T>(key: StorageKey<T>, listener: (value: T) => void): () => void {
|
||||
const events = this.events;
|
||||
if (!events) return () => undefined;
|
||||
const expected = physicalStorageKey(key);
|
||||
const storage = this.provider(key.scope);
|
||||
const onStorage = (event: StorageEventLike) => {
|
||||
if (event.key !== expected) return;
|
||||
if (event.storageArea && storage && event.storageArea !== storage) return;
|
||||
if (event.newValue === null) {
|
||||
listener(key.fallback);
|
||||
return;
|
||||
}
|
||||
const parsed = parseStorageValue(key, event.newValue);
|
||||
if (parsed.ok) listener(parsed.value);
|
||||
};
|
||||
events.addEventListener("storage", onStorage);
|
||||
return () => events.removeEventListener("storage", onStorage);
|
||||
}
|
||||
}
|
||||
|
||||
export function createBrowserStorageStore(): StorageStore {
|
||||
if (typeof window === "undefined") return new StorageStore({});
|
||||
return new StorageStore(
|
||||
{ local: window.localStorage, session: window.sessionStorage },
|
||||
window as unknown as StorageEventTargetLike,
|
||||
);
|
||||
}
|
||||
|
||||
class BrowserStorageStore extends StorageStore {
|
||||
constructor() {
|
||||
super({});
|
||||
}
|
||||
|
||||
override read<T>(key: StorageKey<T>): T {
|
||||
return createBrowserStorageStore().read(key);
|
||||
}
|
||||
|
||||
override write<T>(key: StorageKey<T>, value: T): boolean {
|
||||
return createBrowserStorageStore().write(key, value);
|
||||
}
|
||||
|
||||
override remove<T>(key: StorageKey<T>): boolean {
|
||||
return createBrowserStorageStore().remove(key);
|
||||
}
|
||||
|
||||
override readRaw(scope: StorageScope, name: string): string | null {
|
||||
return createBrowserStorageStore().readRaw(scope, name);
|
||||
}
|
||||
|
||||
override writeRaw(scope: StorageScope, name: string, value: string): boolean {
|
||||
return createBrowserStorageStore().writeRaw(scope, name, value);
|
||||
}
|
||||
|
||||
override removeRaw(scope: StorageScope, name: string): boolean {
|
||||
return createBrowserStorageStore().removeRaw(scope, name);
|
||||
}
|
||||
|
||||
override clearNamespace(scope: StorageScope, namePrefix = ""): number {
|
||||
return createBrowserStorageStore().clearNamespace(scope, namePrefix);
|
||||
}
|
||||
|
||||
override subscribe<T>(key: StorageKey<T>, listener: (value: T) => void) {
|
||||
return createBrowserStorageStore().subscribe(key, listener);
|
||||
}
|
||||
}
|
||||
|
||||
export const browserStorage = new BrowserStorageStore();
|
||||
@@ -0,0 +1,69 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
|
||||
const root = process.cwd();
|
||||
const sourceRoots = ["app", "components", "context", "features", "hooks", "lib", "shared"];
|
||||
|
||||
function sourceFiles(relativeRoot: string): string[] {
|
||||
const start = path.join(root, relativeRoot);
|
||||
const result: string[] = [];
|
||||
const visit = (directory: string) => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const target = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) visit(target);
|
||||
else if (/\.(?:ts|tsx)$/.test(entry.name)) result.push(target);
|
||||
}
|
||||
};
|
||||
visit(start);
|
||||
return result;
|
||||
}
|
||||
|
||||
const allSources = sourceRoots.flatMap(sourceFiles);
|
||||
|
||||
test("browser storage methods stay behind the shared boundary", () => {
|
||||
const violations = allSources
|
||||
.filter((file) => !file.endsWith("components/ThemeScript.tsx"))
|
||||
.filter((file) => !file.includes("shared/storage/"))
|
||||
.filter((file) =>
|
||||
/(?:window\.)?(?:localStorage|sessionStorage)\.(?:getItem|setItem|removeItem)/.test(
|
||||
fs.readFileSync(file, "utf8"),
|
||||
),
|
||||
)
|
||||
.map((file) => path.relative(root, file));
|
||||
assert.deepEqual(violations, []);
|
||||
});
|
||||
|
||||
test("raw fetch is limited to the shared API client and media preview", () => {
|
||||
const allow = new Set([
|
||||
"shared/api/client.ts",
|
||||
"components/chat/preview/FilePreviewDrawer.tsx",
|
||||
]);
|
||||
const violations = allSources
|
||||
.filter((file) => /\bfetch\(/.test(fs.readFileSync(file, "utf8")))
|
||||
.map((file) => path.relative(root, file))
|
||||
.filter((file) => !allow.has(file));
|
||||
assert.deepEqual(violations, []);
|
||||
});
|
||||
|
||||
test("source modules cannot import Next route pages", () => {
|
||||
const violations = allSources
|
||||
.filter((file) => /from\s+["'][^"']*\/page["']/.test(fs.readFileSync(file, "utf8")))
|
||||
.map((file) => path.relative(root, file));
|
||||
assert.deepEqual(violations, []);
|
||||
});
|
||||
|
||||
test("tracked source contains no editor backups or generated trash", () => {
|
||||
const suspicious = execFileSync("git", ["ls-files", "web"], {
|
||||
cwd: path.dirname(root),
|
||||
encoding: "utf8",
|
||||
})
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.filter((file) =>
|
||||
/(?:~|\.bak|\.orig|\.rej)$/.test(file) || file.endsWith("/.DS_Store"),
|
||||
);
|
||||
assert.deepEqual(suspicious, []);
|
||||
});
|
||||
@@ -27,7 +27,7 @@ test("text reader exposes persistent display preferences", () => {
|
||||
[DEFAULT_LINE_WIDTH, MIN_LINE_WIDTH, MAX_LINE_WIDTH],
|
||||
[84, 48, 104],
|
||||
);
|
||||
assert.match(reader, /window\.localStorage\.setItem/);
|
||||
assert.match(reader, /browserStorage\.writeRaw\("local"/);
|
||||
});
|
||||
|
||||
test("reset includes typography and theme preferences", () => {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { defineStorageKey, physicalStorageKey } from "../shared/storage/keys";
|
||||
import {
|
||||
StorageStore,
|
||||
type StorageEventLike,
|
||||
type StorageEventTargetLike,
|
||||
type StorageLike,
|
||||
} from "../shared/storage/store";
|
||||
import { encodeStorageValue } from "../shared/storage/schema";
|
||||
|
||||
class MemoryStorage implements StorageLike {
|
||||
readonly values = new Map<string, string>();
|
||||
get length() {
|
||||
return this.values.size;
|
||||
}
|
||||
getItem(key: string) {
|
||||
return this.values.get(key) ?? null;
|
||||
}
|
||||
setItem(key: string, value: string) {
|
||||
this.values.set(key, value);
|
||||
}
|
||||
removeItem(key: string) {
|
||||
this.values.delete(key);
|
||||
}
|
||||
key(index: number) {
|
||||
return [...this.values.keys()][index] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryEvents implements StorageEventTargetLike {
|
||||
readonly listeners = new Set<(event: StorageEventLike) => void>();
|
||||
addEventListener(_type: "storage", listener: (event: StorageEventLike) => void) {
|
||||
this.listeners.add(listener);
|
||||
}
|
||||
removeEventListener(_type: "storage", listener: (event: StorageEventLike) => void) {
|
||||
this.listeners.delete(listener);
|
||||
}
|
||||
dispatch(event: StorageEventLike) {
|
||||
for (const listener of this.listeners) listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
const countKey = defineStorageKey({
|
||||
name: "tests.count",
|
||||
scope: "local" as const,
|
||||
version: 2,
|
||||
fallback: 0,
|
||||
validate: (value: unknown): value is number =>
|
||||
typeof value === "number" && Number.isFinite(value),
|
||||
migrate: (value: unknown, fromVersion: number) =>
|
||||
fromVersion === 1 && typeof value === "string" ? Number(value) : null,
|
||||
});
|
||||
|
||||
test("unavailable, corrupted, and invalid storage safely return the fallback", () => {
|
||||
assert.equal(new StorageStore({}).read(countKey), 0);
|
||||
const local = new MemoryStorage();
|
||||
const store = new StorageStore({ local });
|
||||
local.setItem(physicalStorageKey(countKey), "not-json");
|
||||
assert.equal(store.read(countKey), 0);
|
||||
local.setItem(
|
||||
physicalStorageKey(countKey),
|
||||
JSON.stringify({ version: 2, value: "wrong", writtenAt: 1 }),
|
||||
);
|
||||
assert.equal(store.read(countKey), 0);
|
||||
});
|
||||
|
||||
test("old schemas migrate and are rewritten at the current version", () => {
|
||||
const local = new MemoryStorage();
|
||||
const store = new StorageStore({ local });
|
||||
local.setItem(
|
||||
physicalStorageKey(countKey),
|
||||
JSON.stringify({ version: 1, value: "7", writtenAt: 1 }),
|
||||
);
|
||||
assert.equal(store.read(countKey), 7);
|
||||
assert.equal(JSON.parse(local.getItem(physicalStorageKey(countKey))!).version, 2);
|
||||
});
|
||||
|
||||
test("quota failures never escape and do not replace the current value", () => {
|
||||
const local = new MemoryStorage();
|
||||
const store = new StorageStore({
|
||||
local: {
|
||||
...local,
|
||||
get length() {
|
||||
return local.length;
|
||||
},
|
||||
getItem: (key) => local.getItem(key),
|
||||
removeItem: (key) => local.removeItem(key),
|
||||
key: (index) => local.key(index),
|
||||
setItem: () => {
|
||||
throw Object.assign(new Error("full"), { name: "QuotaExceededError" });
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(store.write(countKey, 3), false);
|
||||
assert.equal(store.read(countKey), 0);
|
||||
});
|
||||
|
||||
test("local and session values with the same logical name stay isolated", () => {
|
||||
const local = new MemoryStorage();
|
||||
const session = new MemoryStorage();
|
||||
const store = new StorageStore({ local, session });
|
||||
const sessionKey = defineStorageKey({ ...countKey, scope: "session" as const });
|
||||
assert.equal(store.write(countKey, 2), true);
|
||||
assert.equal(store.write(sessionKey, 9), true);
|
||||
assert.equal(store.read(countKey), 2);
|
||||
assert.equal(store.read(sessionKey), 9);
|
||||
});
|
||||
|
||||
test("cross-tab storage events notify only matching typed keys", () => {
|
||||
const local = new MemoryStorage();
|
||||
const events = new MemoryEvents();
|
||||
const store = new StorageStore({ local }, events);
|
||||
const values: number[] = [];
|
||||
const unsubscribe = store.subscribe(countKey, (value) => values.push(value));
|
||||
events.dispatch({ key: "unrelated", newValue: "{}", storageArea: local });
|
||||
events.dispatch({
|
||||
key: physicalStorageKey(countKey),
|
||||
newValue: encodeStorageValue(countKey, 11, 1),
|
||||
storageArea: local,
|
||||
});
|
||||
events.dispatch({ key: physicalStorageKey(countKey), newValue: null, storageArea: local });
|
||||
unsubscribe();
|
||||
assert.deepEqual(values, [11, 0]);
|
||||
assert.equal(events.listeners.size, 0);
|
||||
});
|
||||
|
||||
test("prefix cleanup removes only the requested application namespace", () => {
|
||||
const local = new MemoryStorage();
|
||||
const store = new StorageStore({ local });
|
||||
const otherKey = defineStorageKey({ ...countKey, name: "other.value" });
|
||||
store.write(countKey, 1);
|
||||
store.write(otherKey, 2);
|
||||
local.setItem("third-party", "keep");
|
||||
assert.equal(store.clearNamespace("local", "tests."), 1);
|
||||
assert.equal(store.read(countKey), 0);
|
||||
assert.equal(store.read(otherKey), 2);
|
||||
assert.equal(local.getItem("third-party"), "keep");
|
||||
});
|
||||
@@ -56,7 +56,7 @@ test('Gemini CLI is retired and agent glyphs use official local assets', () => {
|
||||
test('new harnesses have settings routes and category sections', () => {
|
||||
const editor = readWebFile('components', 'settings', 'SubagentSettingsEditor.tsx')
|
||||
const category = readWebFile('features', 'settings', 'sections', 'AgentsSettingsSection.tsx')
|
||||
const nav = readWebFile('lib', 'settings-nav.ts')
|
||||
const nav = readWebFile('features', 'settings', 'navigation', 'settings-nav.ts')
|
||||
|
||||
for (const harness of HARNESSES) {
|
||||
const page = path.join(
|
||||
|
||||
Reference in New Issue
Block a user