Skip to content

Commit ff91edc

Browse files
jamesbmourAmery2010
authored andcommitted
feat: enhance Header and SearchResult components with session export/import functionality and shortcut management
1 parent 95f8380 commit ff91edc

File tree

6 files changed

+599
-19
lines changed

6 files changed

+599
-19
lines changed

src/components/Internal/Header.tsx

Lines changed: 300 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,260 @@
11
"use client";
2+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
23
import { useTranslation } from "react-i18next";
3-
import { Settings, Github, History, BookText } from "lucide-react";
4+
import { z } from "zod";
5+
import { toast } from "sonner";
6+
import {
7+
Settings,
8+
Github,
9+
History,
10+
BookText,
11+
Keyboard,
12+
Download,
13+
Upload,
14+
} from "lucide-react";
415
import { Button } from "@/components/Internal/Button";
16+
import {
17+
Dialog,
18+
DialogContent,
19+
DialogHeader,
20+
DialogTitle,
21+
} from "@/components/ui/dialog";
522
import { useGlobalStore } from "@/store/global";
23+
import { useTaskStore, type TaskStore } from "@/store/task";
24+
import { useHistoryStore } from "@/store/history";
25+
import { downloadFile } from "@/utils/file";
26+
import { fileParser } from "@/utils/parser";
627

728
const VERSION = process.env.NEXT_PUBLIC_VERSION;
829

30+
const resourceSchema = z.object({
31+
id: z.string(),
32+
name: z.string(),
33+
type: z.string(),
34+
size: z.number(),
35+
status: z.enum(["unprocessed", "processing", "completed", "failed"]),
36+
});
37+
38+
const imageSourceSchema = z.object({
39+
url: z.string(),
40+
description: z.string().optional(),
41+
});
42+
43+
const sourceSchema = z.object({
44+
title: z.string().optional(),
45+
content: z.string().optional(),
46+
url: z.string(),
47+
images: z.array(imageSourceSchema).optional(),
48+
});
49+
50+
const searchTaskSchema = z.object({
51+
state: z.enum(["unprocessed", "processing", "completed", "failed"]),
52+
query: z.string(),
53+
researchGoal: z.string(),
54+
learning: z.string(),
55+
sources: z.array(sourceSchema).optional(),
56+
images: z.array(imageSourceSchema).optional(),
57+
});
58+
59+
const taskSnapshotSchema = z.object({
60+
id: z.string().optional(),
61+
question: z.string().optional(),
62+
resources: z.array(resourceSchema).optional(),
63+
query: z.string().optional(),
64+
questions: z.string().optional(),
65+
feedback: z.string().optional(),
66+
reportPlan: z.string().optional(),
67+
suggestion: z.string().optional(),
68+
tasks: z.array(searchTaskSchema).optional(),
69+
requirement: z.string().optional(),
70+
title: z.string().optional(),
71+
finalReport: z.string().optional(),
72+
sources: z.array(sourceSchema).optional(),
73+
images: z.array(imageSourceSchema).optional(),
74+
knowledgeGraph: z.string().optional(),
75+
});
76+
77+
function normalizeTaskSnapshot(
78+
snapshot: z.infer<typeof taskSnapshotSchema>
79+
): TaskStore {
80+
return {
81+
id: snapshot.id ?? "",
82+
question: snapshot.question ?? "",
83+
resources: snapshot.resources ?? [],
84+
query: snapshot.query ?? "",
85+
questions: snapshot.questions ?? "",
86+
feedback: snapshot.feedback ?? "",
87+
reportPlan: snapshot.reportPlan ?? "",
88+
suggestion: snapshot.suggestion ?? "",
89+
tasks: (snapshot.tasks ?? []).map((task) => ({
90+
...task,
91+
sources: task.sources ?? [],
92+
images: task.images ?? [],
93+
})),
94+
requirement: snapshot.requirement ?? "",
95+
title: snapshot.title ?? "",
96+
finalReport: snapshot.finalReport ?? "",
97+
sources: snapshot.sources ?? [],
98+
images: snapshot.images ?? [],
99+
knowledgeGraph: snapshot.knowledgeGraph ?? "",
100+
};
101+
}
102+
103+
function getSafeSnapshotFilename(value: string): string {
104+
return (
105+
value
106+
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "-")
107+
.replace(/\s+/g, "-")
108+
.slice(0, 80) || "deep-research-session"
109+
);
110+
}
111+
112+
function isEditableTarget(target: EventTarget | null): boolean {
113+
if (!(target instanceof HTMLElement)) return false;
114+
const tagName = target.tagName;
115+
return (
116+
target.isContentEditable ||
117+
tagName === "INPUT" ||
118+
tagName === "TEXTAREA" ||
119+
tagName === "SELECT"
120+
);
121+
}
122+
9123
function Header() {
10124
const { t } = useTranslation();
125+
const fileInputRef = useRef<HTMLInputElement>(null);
126+
const [openShortcuts, setOpenShortcuts] = useState<boolean>(false);
11127
const { setOpenSetting, setOpenHistory, setOpenKnowledge } = useGlobalStore();
12128

129+
const exportSnapshot = useCallback(() => {
130+
const { backup, title, question } = useTaskStore.getState();
131+
const snapshot = backup();
132+
const baseName = title || question || "deep-research-session";
133+
downloadFile(
134+
JSON.stringify(snapshot, null, 2),
135+
`${getSafeSnapshotFilename(baseName)}.session.json`,
136+
"application/json;charset=utf-8"
137+
);
138+
toast.message(t("header.session.exportSuccess"));
139+
}, [t]);
140+
141+
const importSnapshot = useCallback(
142+
async (file: File) => {
143+
try {
144+
const raw = await fileParser(file);
145+
const parsed = JSON.parse(raw);
146+
const snapshotResult = taskSnapshotSchema.safeParse(parsed);
147+
if (!snapshotResult.success) {
148+
throw snapshotResult.error;
149+
}
150+
const nextTask = normalizeTaskSnapshot(snapshotResult.data);
151+
const { id, backup, reset, restore } = useTaskStore.getState();
152+
if (id) {
153+
useHistoryStore.getState().update(id, backup());
154+
}
155+
reset();
156+
restore(nextTask);
157+
toast.message(t("header.session.importSuccess"));
158+
} catch (error) {
159+
console.error(error);
160+
toast.error(t("header.session.importFailed"));
161+
}
162+
},
163+
[t]
164+
);
165+
166+
const openSnapshotImport = useCallback(() => {
167+
fileInputRef.current?.click();
168+
}, []);
169+
170+
const shortcuts = useMemo(
171+
() => [
172+
{
173+
key: "Ctrl/Cmd + ,",
174+
description: t("header.shortcuts.openSetting"),
175+
},
176+
{
177+
key: "Ctrl/Cmd + Shift + H",
178+
description: t("header.shortcuts.openHistory"),
179+
},
180+
{
181+
key: "Ctrl/Cmd + Shift + K",
182+
description: t("header.shortcuts.openKnowledge"),
183+
},
184+
{
185+
key: "Ctrl/Cmd + Shift + E",
186+
description: t("header.shortcuts.exportSession"),
187+
},
188+
{
189+
key: "Ctrl/Cmd + Shift + O",
190+
description: t("header.shortcuts.importSession"),
191+
},
192+
{
193+
key: "Ctrl/Cmd + Shift + /",
194+
description: t("header.shortcuts.toggleHelp"),
195+
},
196+
],
197+
[t]
198+
);
199+
200+
useEffect(() => {
201+
function handleKeyDown(event: KeyboardEvent): void {
202+
const withModifier = event.metaKey || event.ctrlKey;
203+
if (!withModifier) return;
204+
if (isEditableTarget(event.target)) return;
205+
206+
const key = event.key.toLowerCase();
207+
if (!event.shiftKey && key === ",") {
208+
event.preventDefault();
209+
setOpenSetting(true);
210+
return;
211+
}
212+
if (event.shiftKey && key === "h") {
213+
event.preventDefault();
214+
setOpenHistory(true);
215+
return;
216+
}
217+
if (event.shiftKey && key === "k") {
218+
event.preventDefault();
219+
setOpenKnowledge(true);
220+
return;
221+
}
222+
if (event.shiftKey && key === "e") {
223+
event.preventDefault();
224+
exportSnapshot();
225+
return;
226+
}
227+
if (event.shiftKey && key === "o") {
228+
event.preventDefault();
229+
openSnapshotImport();
230+
return;
231+
}
232+
if (event.shiftKey && event.key === "?") {
233+
event.preventDefault();
234+
setOpenShortcuts((previous) => !previous);
235+
}
236+
}
237+
238+
window.addEventListener("keydown", handleKeyDown);
239+
return () => {
240+
window.removeEventListener("keydown", handleKeyDown);
241+
};
242+
}, [
243+
exportSnapshot,
244+
openSnapshotImport,
245+
setOpenHistory,
246+
setOpenKnowledge,
247+
setOpenSetting,
248+
]);
249+
250+
async function handleFileUpload(files: FileList | null) {
251+
if (!files || files.length === 0) return;
252+
await importSnapshot(files[0]);
253+
if (fileInputRef.current) {
254+
fileInputRef.current.value = "";
255+
}
256+
}
257+
13258
return (
14259
<>
15260
<header className="flex justify-between items-center my-6 max-sm:my-4 print:hidden">
@@ -30,6 +275,33 @@ function Header() {
30275
<Github className="h-5 w-5" />
31276
</Button>
32277
</a>
278+
<Button
279+
className="h-8 w-8"
280+
variant="ghost"
281+
size="icon"
282+
title={t("header.session.export")}
283+
onClick={() => exportSnapshot()}
284+
>
285+
<Download className="h-5 w-5" />
286+
</Button>
287+
<Button
288+
className="h-8 w-8"
289+
variant="ghost"
290+
size="icon"
291+
title={t("header.session.import")}
292+
onClick={() => openSnapshotImport()}
293+
>
294+
<Upload className="h-5 w-5" />
295+
</Button>
296+
<Button
297+
className="h-8 w-8"
298+
variant="ghost"
299+
size="icon"
300+
title={t("header.shortcuts.title")}
301+
onClick={() => setOpenShortcuts(true)}
302+
>
303+
<Keyboard className="h-5 w-5" />
304+
</Button>
33305
<Button
34306
className="h-8 w-8"
35307
variant="ghost"
@@ -59,6 +331,33 @@ function Header() {
59331
</Button>
60332
</div>
61333
</header>
334+
<Dialog open={openShortcuts} onOpenChange={setOpenShortcuts}>
335+
<DialogContent className="max-w-md">
336+
<DialogHeader>
337+
<DialogTitle>{t("header.shortcuts.title")}</DialogTitle>
338+
</DialogHeader>
339+
<div className="space-y-2 text-sm">
340+
{shortcuts.map((shortcut) => (
341+
<div
342+
key={shortcut.key}
343+
className="flex items-center justify-between gap-3 border rounded-md px-3 py-2"
344+
>
345+
<span className="font-mono text-xs text-muted-foreground">
346+
{shortcut.key}
347+
</span>
348+
<span>{shortcut.description}</span>
349+
</div>
350+
))}
351+
</div>
352+
</DialogContent>
353+
</Dialog>
354+
<input
355+
ref={fileInputRef}
356+
type="file"
357+
accept="application/json"
358+
hidden
359+
onChange={(event) => handleFileUpload(event.target.files)}
360+
/>
62361
</>
63362
);
64363
}

0 commit comments

Comments
 (0)