11"use client" ;
2+ import { useCallback , useEffect , useMemo , useRef , useState } from "react" ;
23import { 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" ;
415import { Button } from "@/components/Internal/Button" ;
16+ import {
17+ Dialog ,
18+ DialogContent ,
19+ DialogHeader ,
20+ DialogTitle ,
21+ } from "@/components/ui/dialog" ;
522import { 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
728const 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+
9123function 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