Spaces:
Sleeping
Sleeping
| "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; | |