Leon4gr45's picture
Deploy app
c16e487 verified
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { toast } from "sonner";
import { Settings, Github, History, BookText, Keyboard, Bug } from "lucide-react";
import { Button } from "@/components/Internal/Button";
import { useDebugStore, initLogInterception } from "@/store/debug";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useGlobalStore } from "@/store/global";
import { useTaskStore, type TaskStore } from "@/store/task";
import { useHistoryStore } from "@/store/history";
import { downloadFile } from "@/utils/file";
import { fileParser } from "@/utils/parser";
const VERSION = process.env.NEXT_PUBLIC_VERSION;
const resourceSchema = z.object({
id: z.string(),
name: z.string(),
type: z.string(),
size: z.number(),
status: z.enum(["unprocessed", "processing", "completed", "failed"]),
});
const imageSourceSchema = z.object({
url: z.string(),
description: z.string().optional(),
});
const sourceSchema = z.object({
title: z.string().optional(),
content: z.string().optional(),
url: z.string(),
images: z.array(imageSourceSchema).optional(),
});
const searchTaskSchema = z.object({
state: z.enum(["unprocessed", "processing", "completed", "failed"]),
query: z.string(),
researchGoal: z.string(),
learning: z.string(),
sources: z.array(sourceSchema).optional(),
images: z.array(imageSourceSchema).optional(),
});
const taskSnapshotSchema = z.object({
id: z.string().optional(),
question: z.string().optional(),
resources: z.array(resourceSchema).optional(),
query: z.string().optional(),
questions: z.string().optional(),
feedback: z.string().optional(),
reportPlan: z.string().optional(),
suggestion: z.string().optional(),
tasks: z.array(searchTaskSchema).optional(),
requirement: z.string().optional(),
title: z.string().optional(),
finalReport: z.string().optional(),
sources: z.array(sourceSchema).optional(),
images: z.array(imageSourceSchema).optional(),
knowledgeGraph: z.string().optional(),
});
function normalizeTaskSnapshot(
snapshot: z.infer<typeof taskSnapshotSchema>,
): TaskStore {
return {
id: snapshot.id ?? "",
question: snapshot.question ?? "",
resources: snapshot.resources ?? [],
query: snapshot.query ?? "",
questions: snapshot.questions ?? "",
feedback: snapshot.feedback ?? "",
reportPlan: snapshot.reportPlan ?? "",
suggestion: snapshot.suggestion ?? "",
tasks: (snapshot.tasks ?? []).map((task) => ({
...task,
sources: task.sources ?? [],
images: task.images ?? [],
})),
requirement: snapshot.requirement ?? "",
title: snapshot.title ?? "",
finalReport: snapshot.finalReport ?? "",
sources: snapshot.sources ?? [],
images: snapshot.images ?? [],
knowledgeGraph: snapshot.knowledgeGraph ?? "",
};
}
function getSafeSnapshotFilename(value: string): string {
return (
value
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "-")
.replace(/\s+/g, "-")
.slice(0, 80) || "deep-research-session"
);
}
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tagName = target.tagName;
return (
target.isContentEditable ||
tagName === "INPUT" ||
tagName === "TEXTAREA" ||
tagName === "SELECT"
);
}
function Header() {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const [openShortcuts, setOpenShortcuts] = useState<boolean>(false);
const { setOpenSetting, setOpenHistory, setOpenKnowledge } = useGlobalStore();
const { exportLogs } = useDebugStore();
useEffect(() => {
initLogInterception();
}, []);
const handleExportLogs = useCallback(() => {
try {
const logs = exportLogs();
downloadFile(
logs,
`debug-logs-${new Date().toISOString()}.json`,
"application/json;charset=utf-8"
);
toast.message("Debug logs exported successfully");
} catch (error) {
console.error("Failed to export logs:", error);
toast.error("Failed to export debug logs");
}
}, [exportLogs]);
const exportSnapshot = useCallback(() => {
const { backup, title, question } = useTaskStore.getState();
const snapshot = backup();
const baseName = title || question || "deep-research-session";
downloadFile(
JSON.stringify(snapshot, null, 2),
`${getSafeSnapshotFilename(baseName)}.session.json`,
"application/json;charset=utf-8",
);
toast.message(t("header.session.exportSuccess"));
}, [t]);
const importSnapshot = useCallback(
async (file: File) => {
try {
const raw = await fileParser(file);
const parsed = JSON.parse(raw);
const snapshotResult = taskSnapshotSchema.safeParse(parsed);
if (!snapshotResult.success) {
throw snapshotResult.error;
}
const nextTask = normalizeTaskSnapshot(snapshotResult.data);
const { id, backup, reset, restore } = useTaskStore.getState();
if (id) {
useHistoryStore.getState().update(id, backup());
}
reset();
restore(nextTask);
toast.message(t("header.session.importSuccess"));
} catch (error) {
console.error(error);
toast.error(t("header.session.importFailed"));
}
},
[t],
);
const openSnapshotImport = useCallback(() => {
fileInputRef.current?.click();
}, []);
const shortcuts = useMemo(
() => [
{
key: "Ctrl/Cmd + ,",
description: t("header.shortcuts.openSetting"),
},
{
key: "Ctrl/Cmd + Shift + H",
description: t("header.shortcuts.openHistory"),
},
{
key: "Ctrl/Cmd + Shift + K",
description: t("header.shortcuts.openKnowledge"),
},
{
key: "Ctrl/Cmd + Shift + E",
description: t("header.shortcuts.exportSession"),
},
{
key: "Ctrl/Cmd + Shift + O",
description: t("header.shortcuts.importSession"),
},
{
key: "Ctrl/Cmd + Shift + /",
description: t("header.shortcuts.toggleHelp"),
},
],
[t],
);
useEffect(() => {
function handleKeyDown(event: KeyboardEvent): void {
const withModifier = event.metaKey || event.ctrlKey;
if (!withModifier) return;
if (isEditableTarget(event.target)) return;
const key = event.key.toLowerCase();
if (!event.shiftKey && key === ",") {
event.preventDefault();
setOpenSetting(true);
return;
}
if (event.shiftKey && key === "h") {
event.preventDefault();
setOpenHistory(true);
return;
}
if (event.shiftKey && key === "k") {
event.preventDefault();
setOpenKnowledge(true);
return;
}
if (event.shiftKey && key === "e") {
event.preventDefault();
exportSnapshot();
return;
}
if (event.shiftKey && key === "o") {
event.preventDefault();
openSnapshotImport();
return;
}
if (event.shiftKey && event.key === "?") {
event.preventDefault();
setOpenShortcuts((previous) => !previous);
}
}
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [
exportSnapshot,
openSnapshotImport,
setOpenHistory,
setOpenKnowledge,
setOpenSetting,
]);
async function handleFileUpload(files: FileList | null) {
if (!files || files.length === 0) return;
await importSnapshot(files[0]);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
return (
<>
<header className="flex justify-between items-center my-6 max-sm:my-4 print:hidden">
<a href="https://github.com/u14app/deep-research" target="_blank">
<h1 className="text-left text-xl font-semibold">
{t("title")}
<small className="ml-2 font-normal text-base">v{VERSION}</small>
</h1>
</a>
<div className="flex">
<a href="https://github.com/u14app/deep-research" target="_blank">
<Button
className="h-8 w-8"
title={t("openSource")}
variant="ghost"
size="icon"
>
<Github className="h-5 w-5" />
</Button>
</a>
<Button
className="h-8 w-8"
variant="ghost"
size="icon"
title="Export Debug Logs"
onClick={handleExportLogs}
>
<Bug className="h-5 w-5" />
</Button>
<Button
className="h-8 w-8"
variant="ghost"
size="icon"
title={t("header.shortcuts.title")}
onClick={() => setOpenShortcuts(true)}
>
<Keyboard className="h-5 w-5" />
</Button>
<Button
className="h-8 w-8"
variant="ghost"
size="icon"
title={t("history.title")}
onClick={() => setOpenHistory(true)}
>
<History className="h-5 w-5" />
</Button>
<Button
className="h-8 w-8"
variant="ghost"
size="icon"
title={t("knowledge.title")}
onClick={() => setOpenKnowledge(true)}
>
<BookText />
</Button>
<Button
className="h-8 w-8"
title={t("setting.title")}
variant="ghost"
size="icon"
onClick={() => setOpenSetting(true)}
>
<Settings className="h-5 w-5" />
</Button>
</div>
</header>
<Dialog open={openShortcuts} onOpenChange={setOpenShortcuts}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("header.shortcuts.title")}</DialogTitle>
</DialogHeader>
<div className="space-y-2 text-sm">
{shortcuts.map((shortcut) => (
<div
key={shortcut.key}
className="flex items-center justify-between gap-3 border rounded-md px-3 py-2"
>
<span className="font-mono text-xs text-muted-foreground">
{shortcut.key}
</span>
<span>{shortcut.description}</span>
</div>
))}
</div>
</DialogContent>
</Dialog>
<input
ref={fileInputRef}
type="file"
accept="application/json"
hidden
onChange={(event) => handleFileUpload(event.target.files)}
/>
</>
);
}
export default Header;