chore(web): enforce frontend architecture boundaries

This commit is contained in:
Bingxi Zhao (Frank)
2026-09-01 19:55:48 +08:00
parent b5cdb10f2e
commit 829be646a1
47 changed files with 1624 additions and 960 deletions
+55
View File
@@ -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);
+3 -17
View File
@@ -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]);
+4 -2
View File
@@ -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]);
+5 -3
View File
@@ -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));
}
}
+4 -2
View File
@@ -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.
}
+4 -2
View File
@@ -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),
);
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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";
/**
+1 -1
View File
@@ -14,7 +14,7 @@ import {
settingsAnchorHref,
type Lang,
type SettingsLeaf,
} from "@/lib/settings-nav";
} from "@/features/settings/navigation/settings-nav";
import {
getActiveModel,
getActiveProfile,
+1 -1
View File
@@ -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";
/**
+4 -2
View File
@@ -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.
}
+9 -7
View File
@@ -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(
+28 -19
View File
@@ -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),
);
+5 -2
View File
@@ -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",
);
+4 -2
View File
@@ -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
}
+10 -4
View File
@@ -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
View File
@@ -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;
+5 -3
View File
@@ -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 -3
View File
@@ -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 */
}
+5 -3
View File
@@ -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 "";
-254
View File
@@ -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;
+4 -2
View File
@@ -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
View File
@@ -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";
+4 -2
View File
@@ -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
View File
@@ -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;
+323 -3
View File
@@ -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",
+2
View File
@@ -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",
+3
View File
@@ -0,0 +1,3 @@
export * from "./keys";
export * from "./schema";
export * from "./store";
+27
View File
@@ -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 });
}
+47
View File
@@ -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" };
}
+188
View File
@@ -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();
+69
View File
@@ -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", () => {
+140
View File
@@ -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");
});
+1 -1
View File
@@ -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(