返回 ViMax
App.tsx
根目录 / web / src / App.tsx
1 import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
2 import {
3 ArrowUp,
4 Brain,
5 Braces,
6 CircleStop,
7 Clock3,
8 FilePenLine,
9 FileText,
10 Film,
11 Folder,
12 FolderPlus,
13 Files,
14 Image as ImageIcon,
15 Menu,
16 Moon,
17 PanelLeftClose,
18 PanelLeftOpen,
19 PanelRight,
20 Plus,
21 Save,
22 Search,
23 Settings,
24 ListChecks,
25 Terminal,
26 Trash2,
27 Sun,
28 Wrench,
29 X,
30 } from 'lucide-react';
31 import ReactMarkdown from 'react-markdown';
32 import remarkGfm from 'remark-gfm';
33 import {deleteSession, getAgentConfig, getArtifacts, getHistory, getSessions, saveAgentConfig, sendMessage, startAgent, stopAgent, subscribeToEvents, uploadWorkspaceFile} from './api';
34 import {ArtifactsView, StoryboardPanel} from './ArtifactViews';
35 import {applyAgentEvent, appendLocalUser, composeAgentPrompt, createChatState, humanize} from './events';
36 import {matchingSlashCommands, shouldShowSlashCommands, type SlashCommandMatch} from './slashCommands';
37 import {applyTheme, resolveTheme, THEME_STORAGE_KEY, type Theme} from './theme';
38 import type {AgentConfig, AgentEvent, Artifact, ChatState, ConfigSection, Message, SessionSummary, WorkspaceUpload} from './types';
39
40 const CONTEXT_TARGET = 160_000;
41
42 type WorkspaceView = 'workspace' | 'artifacts' | 'settings';
43
44 export default function App() {
45 const [sessions, setSessions] = useState<SessionSummary[]>([]);
46 const [selectedSessionId, setSelectedSessionId] = useState('');
47 const [chat, setChat] = useState<ChatState>(() => createChatState());
48 const [artifacts, setArtifacts] = useState<Artifact[]>([]);
49 const [workspaceView, setWorkspaceView] = useState<WorkspaceView>('workspace');
50 const [agentReady, setAgentReady] = useState(false);
51 const [sidebarOpen, setSidebarOpen] = useState(true);
52 const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
53 const [storyboardPanelOpen, setStoryboardPanelOpen] = useState(false);
54 const [storyboardCount, setStoryboardCount] = useState(0);
55 const [theme, setTheme] = useState<Theme>(() => resolveTheme(document.documentElement.dataset.theme, false));
56 const [draft, setDraft] = useState('');
57 const [workspaceUploads, setWorkspaceUploads] = useState<WorkspaceUpload[]>([]);
58 const [uploadingFiles, setUploadingFiles] = useState(false);
59 const [loadError, setLoadError] = useState('');
60 const [newProjectOpen, setNewProjectOpen] = useState(false);
61 const [newProjectName, setNewProjectName] = useState('');
62 const [newProjectError, setNewProjectError] = useState('');
63 const [creatingProject, setCreatingProject] = useState(false);
64 const [pendingDelete, setPendingDelete] = useState<SessionSummary>();
65 const [deleting, setDeleting] = useState(false);
66 const scrollRef = useRef<HTMLDivElement>(null);
67 const textareaRef = useRef<HTMLTextAreaElement>(null);
68 const fileInputRef = useRef<HTMLInputElement>(null);
69
70 const selectedSession = sessions.find((session) => session.sessionId === selectedSessionId);
71 const slashMatches = useMemo(() => matchingSlashCommands(draft), [draft]);
72 const showSlashCommands = shouldShowSlashCommands(draft, chat.busy);
73 const runningRender = useMemo(
74 () => [...chat.messages].reverse().find((message) => message.role === 'activity'
75 && message.status === 'running'
76 && (message.tool || '').toLowerCase().includes('render_video')),
77 [chat.messages],
78 );
79
80 useEffect(() => {
81 applyTheme(theme);
82 try {
83 window.localStorage.setItem(THEME_STORAGE_KEY, theme);
84 } catch {
85 // Theme still applies when persistence is unavailable.
86 }
87 }, [theme]);
88
89 const refreshSessions = useCallback(async () => {
90 const state = await getSessions();
91 setSessions(state.sessions);
92 return state;
93 }, []);
94
95 const refreshArtifacts = useCallback(async (sessionId: string) => {
96 if (!sessionId) {
97 setArtifacts([]);
98 return;
99 }
100 const payload = await getArtifacts(sessionId);
101 setArtifacts(payload.artifacts);
102 }, []);
103
104 useEffect(() => subscribeToEvents((event) => {
105 if (event.type === 'sessions_changed') {
106 setSessions(event.sessions || []);
107 if (event.activeSessionId) setSelectedSessionId(event.activeSessionId);
108 return;
109 }
110 if (event.type === 'bridge_status') {
111 if (event.status === 'ready' || event.status === 'starting') setAgentReady(true);
112 if (event.status === 'stopped' || event.status === 'error') setAgentReady(false);
113 }
114 if (event.type === 'session') {
115 const sessionId = event.session?.active_session_id || event.session?.session?.session_id || '';
116 if (sessionId) {
117 setSelectedSessionId(sessionId);
118 void refreshSessions();
119 void refreshArtifacts(sessionId);
120 }
121 }
122 setChat((current) => applyAgentEvent(current, event));
123 }, () => undefined), [refreshArtifacts, refreshSessions]);
124
125 useEffect(() => {
126 let cancelled = false;
127 void (async () => {
128 try {
129 const state = await refreshSessions();
130 if (cancelled || !state.activeSessionId) return;
131 setSelectedSessionId(state.activeSessionId);
132 const [history] = await Promise.all([
133 getHistory(state.activeSessionId),
134 refreshArtifacts(state.activeSessionId),
135 startAgent({sessionId: state.activeSessionId}),
136 ]);
137 if (!cancelled) {
138 setChat(createChatState(history.messages));
139 setAgentReady(true);
140 }
141 } catch (error) {
142 if (!cancelled) setLoadError(error instanceof Error ? error.message : String(error));
143 }
144 })();
145 return () => {
146 cancelled = true;
147 };
148 }, [refreshArtifacts, refreshSessions]);
149
150 useEffect(() => {
151 if (!runningRender || !selectedSessionId) return;
152 void refreshArtifacts(selectedSessionId);
153 const interval = window.setInterval(() => void refreshArtifacts(selectedSessionId), 2_000);
154 return () => {
155 window.clearInterval(interval);
156 void refreshArtifacts(selectedSessionId);
157 };
158 }, [refreshArtifacts, runningRender, selectedSessionId]);
159
160 useEffect(() => {
161 const element = scrollRef.current;
162 if (!element) return;
163 element.scrollTo({top: element.scrollHeight, behavior: chat.busy ? 'smooth' : 'auto'});
164 }, [chat.messages, chat.busy]);
165
166 useEffect(() => {
167 const textarea = textareaRef.current;
168 if (!textarea) return;
169 textarea.style.height = '0px';
170 textarea.style.height = `${Math.min(168, Math.max(28, textarea.scrollHeight))}px`;
171 }, [draft]);
172
173 useEffect(() => {
174 setWorkspaceUploads([]);
175 if (fileInputRef.current) fileInputRef.current.value = '';
176 }, [selectedSessionId]);
177
178 async function openSession(sessionId: string) {
179 if (!sessionId || sessionId === selectedSessionId) {
180 setMobileSidebarOpen(false);
181 return;
182 }
183 setLoadError('');
184 setSelectedSessionId(sessionId);
185 setMobileSidebarOpen(false);
186 setChat(createChatState());
187 try {
188 const [history] = await Promise.all([
189 getHistory(sessionId),
190 refreshArtifacts(sessionId),
191 startAgent({sessionId}),
192 ]);
193 setChat(createChatState(history.messages));
194 setAgentReady(true);
195 } catch (error) {
196 setLoadError(error instanceof Error ? error.message : String(error));
197 }
198 }
199
200 function openNewProjectDialog() {
201 setNewProjectName('');
202 setNewProjectError('');
203 setNewProjectOpen(true);
204 }
205
206 async function newProject() {
207 const projectName = newProjectName.trim();
208 if (!projectName || creatingProject) return;
209 setCreatingProject(true);
210 setNewProjectError('');
211 setLoadError('');
212 setMobileSidebarOpen(false);
213 try {
214 await startAgent({newSession: true, projectName});
215 setAgentReady(true);
216 await new Promise((resolve) => window.setTimeout(resolve, 450));
217 const state = await refreshSessions();
218 setSelectedSessionId(state.activeSessionId);
219 setChat(createChatState());
220 setArtifacts([]);
221 setWorkspaceView('workspace');
222 setNewProjectOpen(false);
223 setNewProjectName('');
224 textareaRef.current?.focus();
225 } catch (error) {
226 setNewProjectError(error instanceof Error ? error.message : String(error));
227 } finally {
228 setCreatingProject(false);
229 }
230 }
231
232 async function submit() {
233 const text = draft.trim();
234 if (!text || chat.busy || uploadingFiles) return;
235 setLoadError('');
236 setDraft('');
237 setChat((current) => appendLocalUser(current, text));
238 try {
239 if (!agentReady) {
240 await startAgent(selectedSessionId ? {sessionId: selectedSessionId} : {newSession: true});
241 setAgentReady(true);
242 }
243 const outbound = composeAgentPrompt(text, workspaceUploads.map((file) => file.path));
244 await sendMessage(outbound);
245 setWorkspaceUploads([]);
246 } catch (error) {
247 const event: AgentEvent = {type: 'error', message: error instanceof Error ? error.message : String(error)};
248 setChat((current) => applyAgentEvent(current, event));
249 }
250 }
251
252 async function uploadFiles(files: FileList | null) {
253 const sessionId = selectedSessionId;
254 if (!files?.length) return;
255 if (!sessionId) {
256 setLoadError('Create or select a project before uploading files');
257 return;
258 }
259 setUploadingFiles(true);
260 setLoadError('');
261 let uploadedAny = false;
262 try {
263 for (const file of Array.from(files)) {
264 const result = await uploadWorkspaceFile(sessionId, file);
265 uploadedAny = true;
266 setWorkspaceUploads((current) => [
267 ...current.filter((item) => item.path !== result.file.path),
268 result.file,
269 ]);
270 }
271 } catch (error) {
272 setLoadError(error instanceof Error ? error.message : String(error));
273 } finally {
274 if (fileInputRef.current) fileInputRef.current.value = '';
275 if (uploadedAny) await refreshArtifacts(sessionId);
276 setUploadingFiles(false);
277 textareaRef.current?.focus();
278 }
279 }
280
281 async function stop() {
282 await stopAgent();
283 setAgentReady(false);
284 setChat((current) => ({...current, busy: false}));
285 }
286
287 async function confirmDelete() {
288 if (!pendingDelete || deleting) return;
289 const sessionId = pendingDelete.sessionId;
290 const deletingSelected = sessionId === selectedSessionId;
291 setDeleting(true);
292 setLoadError('');
293 try {
294 const state = await deleteSession(sessionId);
295 setSessions(state.sessions);
296 setPendingDelete(undefined);
297 if (deletingSelected) {
298 setSelectedSessionId('');
299 setChat(createChatState());
300 setArtifacts([]);
301 setAgentReady(false);
302 if (state.activeSessionId) await openSession(state.activeSessionId);
303 }
304 } catch (error) {
305 setLoadError(error instanceof Error ? error.message : String(error));
306 } finally {
307 setDeleting(false);
308 }
309 }
310
311 const contextPercent = Math.min(100, Math.round((chat.promptTokens / CONTEXT_TARGET) * 100));
312 const hasConversation = chat.messages.length > 0;
313
314 return (
315 <div className="app-shell">
316 <Sidebar
317 open={sidebarOpen}
318 mobileOpen={mobileSidebarOpen}
319 sessions={sessions}
320 selectedSessionId={selectedSessionId}
321 activeView={workspaceView}
322 onToggle={() => setSidebarOpen((value) => !value)}
323 onMobileClose={() => setMobileSidebarOpen(false)}
324 onNew={openNewProjectDialog}
325 onSelect={(sessionId) => void openSession(sessionId)}
326 onWorkspace={() => {
327 setWorkspaceView('workspace');
328 setMobileSidebarOpen(false);
329 }}
330 onArtifacts={() => {
331 setWorkspaceView('artifacts');
332 setMobileSidebarOpen(false);
333 }}
334 onSettings={() => {
335 setWorkspaceView('settings');
336 setMobileSidebarOpen(false);
337 }}
338 onDelete={setPendingDelete}
339 />
340
341 <main className="workspace-main">
342 {workspaceView === 'workspace' ? (
343 <div className="workspace-utility-bar">
344 <button className="icon-button mobile-only" onClick={() => setMobileSidebarOpen(true)} aria-label="Open navigation">
345 <Menu size={19} />
346 </button>
347 {!sidebarOpen && (
348 <button className="icon-button desktop-only" onClick={() => setSidebarOpen(true)} aria-label="Open navigation">
349 <PanelLeftOpen size={18} />
350 </button>
351 )}
352 <span className="workspace-utility-spacer" />
353 <div className="workspace-utility-actions">
354 <ThemeToggle theme={theme} onToggle={() => setTheme((current) => current === 'dark' ? 'light' : 'dark')} />
355 <button className="icon-button artifact-toggle" onClick={() => setStoryboardPanelOpen((value) => !value)} aria-label="Toggle storyboard preview">
356 <PanelRight size={18} />
357 {storyboardCount > 0 && <span className="count-badge">{storyboardCount}</span>}
358 </button>
359 </div>
360 </div>
361 ) : (
362 <header className="workspace-header">
363 <div className="workspace-title-row">
364 <button className="icon-button mobile-only" onClick={() => setMobileSidebarOpen(true)} aria-label="Open navigation">
365 <Menu size={19} />
366 </button>
367 {!sidebarOpen && (
368 <button className="icon-button desktop-only" onClick={() => setSidebarOpen(true)} aria-label="Open navigation">
369 <PanelLeftOpen size={18} />
370 </button>
371 )}
372 <div className="workspace-title-copy">
373 <strong>{workspaceView === 'settings' ? 'Settings' : 'Artifacts'}</strong>
374 {workspaceView === 'settings' && <span>configs/agent.local.yaml</span>}
375 </div>
376 </div>
377 <ThemeToggle theme={theme} onToggle={() => setTheme((current) => current === 'dark' ? 'light' : 'dark')} />
378 </header>
379 )}
380
381 {workspaceView === 'workspace' ? (
382 <div className="conversation" ref={scrollRef}>
383 {!hasConversation ? (
384 <EmptyState theme={theme} />
385 ) : (
386 <div className="message-stream">
387 {chat.messages.map((message) => <MessageRow key={message.id} message={message} />)}
388 {chat.busy && <ThinkingRow messages={chat.messages} />}
389 </div>
390 )}
391 </div>
392 ) : workspaceView === 'artifacts' ? (
393 <ArtifactsView session={selectedSession} artifacts={artifacts} />
394 ) : (
395 <SettingsView />
396 )}
397
398 {workspaceView === 'workspace' && <div className="composer-zone">
399 {loadError && (
400 <div className="inline-error" role="alert">
401 <span>{loadError}</span>
402 <button onClick={() => setLoadError('')} aria-label="Dismiss error"><X size={15} /></button>
403 </div>
404 )}
405 {showSlashCommands && <SlashCommandMenu matches={slashMatches} contextPercent={contextPercent} onSelect={(command) => {
406 setDraft(command);
407 textareaRef.current?.focus();
408 }} />}
409 <div className={`composer ${chat.busy ? 'is-busy' : ''}`}>
410 <textarea
411 ref={textareaRef}
412 value={draft}
413 onChange={(event) => setDraft(event.target.value)}
414 onKeyDown={(event) => {
415 if (event.key === 'Tab' && slashMatches[0]) {
416 event.preventDefault();
417 setDraft(slashMatches[0].name);
418 return;
419 }
420 if (event.key === 'Enter' && !event.shiftKey) {
421 event.preventDefault();
422 void submit();
423 }
424 }}
425 placeholder="Describe what you want to make"
426 aria-label="Message ViMax"
427 disabled={chat.busy}
428 rows={1}
429 />
430 {(workspaceUploads.length > 0 || uploadingFiles) && (
431 <div className="composer-attachments" aria-live="polite">
432 {workspaceUploads.map((file) => (
433 <span className="composer-attachment" key={file.path} title={file.path}>
434 <FileText size={13} />
435 <span>{file.name}</span>
436 <button
437 type="button"
438 onClick={() => setWorkspaceUploads((current) => current.filter((item) => item.path !== file.path))}
439 aria-label={`Remove ${file.name} from this message`}
440 >
441 <X size={12} />
442 </button>
443 </span>
444 ))}
445 {uploadingFiles && <span className="composer-uploading">Uploading…</span>}
446 </div>
447 )}
448 <div className="composer-controls">
449 <input
450 ref={fileInputRef}
451 className="composer-file-input"
452 type="file"
453 multiple
454 onChange={(event) => void uploadFiles(event.currentTarget.files)}
455 tabIndex={-1}
456 />
457 <button
458 type="button"
459 className="composer-add"
460 onClick={() => fileInputRef.current?.click()}
461 disabled={!selectedSessionId || uploadingFiles || chat.busy}
462 aria-label="Upload files to workspace"
463 aria-busy={uploadingFiles}
464 title={selectedSessionId ? 'Upload files to workspace' : 'Create or select a project first'}
465 >
466 <Plus size={20} />
467 </button>
468 <div className="composer-spacer" />
469 {chat.busy ? (
470 <button className="send-button stop" onClick={() => void stop()} aria-label="Stop generation"><CircleStop size={18} /></button>
471 ) : (
472 <button className="send-button" onClick={() => void submit()} disabled={!draft.trim() || uploadingFiles} aria-label="Send message"><ArrowUp size={19} /></button>
473 )}
474 </div>
475 </div>
476 </div>}
477 </main>
478
479 <StoryboardPanel
480 open={storyboardPanelOpen && workspaceView === 'workspace'}
481 artifacts={artifacts}
482 activeRenderStage={runningRender?.stage}
483 onClose={() => setStoryboardPanelOpen(false)}
484 onCountChange={setStoryboardCount}
485 />
486 <DeleteProjectDialog
487 session={pendingDelete}
488 deleting={deleting}
489 onCancel={() => !deleting && setPendingDelete(undefined)}
490 onConfirm={() => void confirmDelete()}
491 />
492 <NewProjectDialog
493 open={newProjectOpen}
494 name={newProjectName}
495 error={newProjectError}
496 creating={creatingProject}
497 onNameChange={(value) => {
498 setNewProjectName(value);
499 setNewProjectError('');
500 }}
501 onCancel={() => {
502 if (creatingProject) return;
503 setNewProjectOpen(false);
504 setNewProjectError('');
505 }}
506 onConfirm={() => void newProject()}
507 />
508 </div>
509 );
510 }
511
512 function Sidebar({open, mobileOpen, sessions, selectedSessionId, activeView, onToggle, onMobileClose, onNew, onSelect, onWorkspace, onArtifacts, onSettings, onDelete}: {
513 open: boolean;
514 mobileOpen: boolean;
515 sessions: SessionSummary[];
516 selectedSessionId: string;
517 activeView: WorkspaceView;
518 onToggle: () => void;
519 onMobileClose: () => void;
520 onNew: () => void;
521 onSelect: (sessionId: string) => void;
522 onWorkspace: () => void;
523 onArtifacts: () => void;
524 onSettings: () => void;
525 onDelete: (session: SessionSummary) => void;
526 }) {
527 return (
528 <>
529 {mobileOpen && <button className="sidebar-scrim" onClick={onMobileClose} aria-label="Close navigation" />}
530 <aside className={`sidebar ${open ? 'is-open' : 'is-collapsed'} ${mobileOpen ? 'is-mobile-open' : ''}`}>
531 <div className="sidebar-brand">
532 <strong>ViMax</strong>
533 <button className="icon-button sidebar-collapse desktop-only" onClick={onToggle} aria-label="Collapse navigation">
534 <PanelLeftClose size={17} />
535 </button>
536 <button className="icon-button mobile-only" onClick={onMobileClose} aria-label="Close navigation"><X size={18} /></button>
537 </div>
538 <nav className="primary-nav" aria-label="Primary navigation">
539 <button onClick={onNew}><FolderPlus size={17} /><span>New project</span></button>
540 <button className={activeView === 'workspace' ? 'is-active' : ''} onClick={onWorkspace}><Folder size={17} /><span>Workspace</span></button>
541 <button className={activeView === 'artifacts' ? 'is-active' : ''} onClick={onArtifacts}><Files size={17} /><span>Artifacts</span></button>
542 <button className={activeView === 'settings' ? 'is-active' : ''} onClick={onSettings}><Settings size={17} /><span>Settings</span></button>
543 </nav>
544 <div className="session-section">
545 <div className="section-label"><span>Projects</span><span>{sessions.length}</span></div>
546 <div className="session-list">
547 {sessions.map((session) => (
548 <div
549 key={session.sessionId}
550 className={`session-item ${session.sessionId === selectedSessionId ? 'is-selected' : ''}`}
551 >
552 <button className="session-open" onClick={() => onSelect(session.sessionId)}>
553 <span className="session-copy">
554 <strong>{sessionTitle(session)}</strong>
555 <small>{relativeTime(session.updatedAt)} · {stageLabel(session.stage)}</small>
556 </span>
557 </button>
558 <button className="session-delete" onClick={() => onDelete(session)} aria-label={`Delete ${sessionTitle(session)}`} title="Delete project">
559 <Trash2 size={14} />
560 </button>
561 </div>
562 ))}
563 {sessions.length === 0 && <span className="empty-list">No projects yet</span>}
564 </div>
565 </div>
566 <div className="sidebar-footer">
567 <span className="avatar">V</span>
568 <div><strong>Local workspace</strong><small>Local · ViMax</small></div>
569 </div>
570 </aside>
571 </>
572 );
573 }
574
575 function EmptyState({theme}: {theme: Theme}) {
576 return (
577 <section className="empty-state">
578 <img className="empty-state-logo" src={theme === 'dark' ? '/vimax-light.svg' : '/vimax-dark.svg'} alt="ViMax" />
579 <h1>What should we create?</h1>
580 </section>
581 );
582 }
583
584 function ThemeToggle({theme, onToggle}: {theme: Theme; onToggle: () => void}) {
585 const dark = theme === 'dark';
586 return (
587 <button
588 type="button"
589 className="icon-button theme-toggle"
590 onClick={onToggle}
591 aria-label={dark ? 'Use light mode' : 'Use dark mode'}
592 aria-pressed={dark}
593 title={dark ? 'Light mode' : 'Dark mode'}
594 >
595 {dark ? <Sun size={18} /> : <Moon size={18} />}
596 </button>
597 );
598 }
599
600 function MessageRow({message}: {message: Message}) {
601 if (message.role === 'activity') return <ActivityRow message={message} />;
602 return (
603 <article className={`message-row role-${message.role}`}>
604 <div className="message-body">
605 <ReactMarkdown remarkPlugins={[remarkGfm]} components={{a: (props) => <a {...props} target="_blank" rel="noreferrer" />}}>
606 {message.text}
607 </ReactMarkdown>
608 </div>
609 </article>
610 );
611 }
612
613 function ActivityRow({message}: {message: Message}) {
614 const stage = message.stage ? humanize(message.stage) : '';
615 const detail = stage.toLowerCase() === message.text.toLowerCase() ? message.text : [stage, message.text].filter(Boolean).join(' · ');
616 const toolKind = activityToolKind(message.tool);
617 return (
618 <div className={`activity-row status-${message.status || 'done'}`}>
619 <span className={`activity-indicator tool-${toolKind}`}><ActivityToolIcon tool={message.tool} /></span>
620 <div>
621 <strong>{humanize(message.tool || 'Workflow')}</strong>
622 <span>{detail}</span>
623 </div>
624 </div>
625 );
626 }
627
628 function ActivityToolIcon({tool}: {tool?: string}) {
629 const name = (tool || '').toLowerCase();
630 const props = {size: 13, strokeWidth: 1.8};
631 if (name.includes('narrative_planning') || name.includes('novel_planning')) return <FilePenLine {...props} />;
632 if (name.includes('render_video')) return <Film {...props} />;
633 if (name === 'view_image' || name.includes('image')) return <ImageIcon {...props} />;
634 if (name === 'read_json' || name === 'write_json') return <Braces {...props} />;
635 if (name === 'read_file' || name === 'write_file') return <FileText {...props} />;
636 if (name === 'list_files' || name === 'glob_files') return <Folder {...props} />;
637 if (name === 'search_text') return <Search {...props} />;
638 if (name.startsWith('memory_')) return <Brain {...props} />;
639 if (name.startsWith('todo_')) return <ListChecks {...props} />;
640 if (name === 'run_shell') return <Terminal {...props} />;
641 if (name === 'sleep') return <Clock3 {...props} />;
642 return <Wrench {...props} />;
643 }
644
645 function activityToolKind(tool?: string) {
646 const name = (tool || '').toLowerCase();
647 if (name.includes('narrative_planning') || name.includes('novel_planning')) return 'planning';
648 if (name.includes('render_video')) return 'render';
649 if (name === 'view_image' || name.includes('image')) return 'image';
650 if (name.startsWith('memory_')) return 'memory';
651 if (name.startsWith('todo_')) return 'todo';
652 if (name === 'run_shell') return 'shell';
653 if (name === 'sleep') return 'time';
654 return 'file';
655 }
656
657 function ThinkingRow({messages}: {messages: Message[]}) {
658 const running = [...messages].reverse().find((message) => message.role === 'activity' && message.status === 'running');
659 return (
660 <div className="thinking-row">
661 <span className="thinking-mark"><i /><i /><i /></span>
662 <span>{running ? `${humanize(running.tool || 'ViMax')} · ${running.text}` : 'ViMax is thinking'}</span>
663 </div>
664 );
665 }
666
667 function SlashCommandMenu({matches, contextPercent, onSelect}: {matches: SlashCommandMatch[]; contextPercent: number; onSelect: (command: string) => void}) {
668 return (
669 <div className="slash-command-menu" role="listbox" aria-label="Slash commands">
670 {matches.length > 0 ? matches.map((command) => (
671 <button key={command.name} role="option" aria-selected="false" onMouseDown={(event) => event.preventDefault()} onClick={() => onSelect(command.name)}>
672 <code><span><b>{command.matchedPrefix}</b><span>{command.unmatchedSuffix}</span></span>{command.name === '/compact' && <em>{contextPercent}%</em>}</code>
673 <small>{command.description}</small>
674 </button>
675 )) : <span className="slash-command-empty">No matching commands</span>}
676 </div>
677 );
678 }
679
680 const CONFIG_SECTIONS: Array<{key: keyof AgentConfig['sections']; title: string; description: string}> = [
681 {key: 'llm', title: 'Agent LLM', description: 'Planning, tool selection, and conversation'},
682 {key: 'image', title: 'Image generation', description: 'Characters, keyframes, and shot frames'},
683 {key: 'video', title: 'Video generation', description: 'Shot clips and final video'},
684 {key: 'embedding', title: 'Embedding', description: 'Optional novel retrieval'},
685 {key: 'reranker', title: 'Reranker', description: 'Optional novel retrieval ranking'},
686 ];
687
688 function SettingsView() {
689 const [config, setConfig] = useState<AgentConfig>();
690 const [loading, setLoading] = useState(true);
691 const [saving, setSaving] = useState(false);
692 const [status, setStatus] = useState('');
693
694 useEffect(() => {
695 let cancelled = false;
696 void getAgentConfig()
697 .then((payload) => !cancelled && setConfig(payload))
698 .catch((error) => !cancelled && setStatus(error instanceof Error ? error.message : String(error)))
699 .finally(() => !cancelled && setLoading(false));
700 return () => { cancelled = true; };
701 }, []);
702
703 function update(section: keyof AgentConfig['sections'], field: keyof ConfigSection, value: string) {
704 setStatus('');
705 setConfig((current) => current ? {
706 sections: {
707 ...current.sections,
708 [section]: {...current.sections[section], [field]: value},
709 },
710 } : current);
711 }
712
713 async function save() {
714 if (!config || saving) return;
715 setSaving(true);
716 setStatus('');
717 try {
718 setConfig(await saveAgentConfig(config));
719 setStatus('Saved');
720 } catch (error) {
721 setStatus(error instanceof Error ? error.message : String(error));
722 } finally {
723 setSaving(false);
724 }
725 }
726
727 if (loading) return <div className="settings-loading">Loading configuration…</div>;
728 if (!config) return <div className="settings-loading is-error">{status || 'Configuration unavailable'}</div>;
729 return (
730 <section className="settings-view">
731 <header>
732 <div><span>Local configuration</span><h1>Settings</h1></div>
733 <div className="settings-save-group">
734 {status && <span className={status === 'Saved' ? 'is-saved' : 'is-error'}>{status}</span>}
735 <button className="settings-save" onClick={() => void save()} disabled={saving}>
736 <Save size={15} />{saving ? 'Saving…' : 'Save'}
737 </button>
738 </div>
739 </header>
740 <div className="settings-sections">
741 {CONFIG_SECTIONS.map((definition) => (
742 <ConfigSectionEditor
743 key={definition.key}
744 definition={definition}
745 value={config.sections[definition.key]}
746 onChange={(field, value) => update(definition.key, field, value)}
747 />
748 ))}
749 </div>
750 </section>
751 );
752 }
753
754 function ConfigSectionEditor({definition, value, onChange}: {
755 definition: {title: string; description: string};
756 value: ConfigSection;
757 onChange: (field: keyof ConfigSection, value: string) => void;
758 }) {
759 return (
760 <section className="config-section">
761 <header><h2>{definition.title}</h2><p>{definition.description}</p></header>
762 <div className="config-fields">
763 {value.model_provider !== undefined && (
764 <label><span>Model provider</span><input value={value.model_provider} onChange={(event) => onChange('model_provider', event.target.value)} /></label>
765 )}
766 <label><span>Model</span><input value={value.model} onChange={(event) => onChange('model', event.target.value)} /></label>
767 <label className="config-field-wide"><span>Base URL</span><input value={value.base_url} onChange={(event) => onChange('base_url', event.target.value)} inputMode="url" /></label>
768 <label className="config-field-wide">
769 <span>API key <i className={value.has_api_key ? 'is-configured' : ''}>{value.has_api_key ? 'Configured' : 'Not configured'}</i></span>
770 <input type="password" value={value.api_key} onChange={(event) => onChange('api_key', event.target.value)} placeholder={value.has_api_key ? 'Leave blank to keep current key' : 'Enter API key'} autoComplete="off" />
771 </label>
772 </div>
773 </section>
774 );
775 }
776
777 function DeleteProjectDialog({session, deleting, onCancel, onConfirm}: {
778 session?: SessionSummary;
779 deleting: boolean;
780 onCancel: () => void;
781 onConfirm: () => void;
782 }) {
783 useEffect(() => {
784 if (!session) return;
785 const onKeyDown = (event: KeyboardEvent) => {
786 if (event.key === 'Escape' && !deleting) onCancel();
787 };
788 window.addEventListener('keydown', onKeyDown);
789 return () => window.removeEventListener('keydown', onKeyDown);
790 }, [deleting, onCancel, session]);
791
792 if (!session) return null;
793 return (
794 <div className="dialog-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && onCancel()}>
795 <section className="delete-dialog" role="dialog" aria-modal="true" aria-labelledby="delete-project-title">
796 <span className="dialog-icon"><Trash2 size={18} /></span>
797 <div className="dialog-copy">
798 <h2 id="delete-project-title">Delete project?</h2>
799 <p><strong>{sessionTitle(session)}</strong> and its generated files will be permanently removed.</p>
800 </div>
801 <div className="dialog-actions">
802 <button onClick={onCancel} disabled={deleting} autoFocus>Cancel</button>
803 <button className="danger" onClick={onConfirm} disabled={deleting}>{deleting ? 'Deleting…' : 'Delete'}</button>
804 </div>
805 </section>
806 </div>
807 );
808 }
809
810 function NewProjectDialog({open, name, error, creating, onNameChange, onCancel, onConfirm}: {
811 open: boolean;
812 name: string;
813 error: string;
814 creating: boolean;
815 onNameChange: (value: string) => void;
816 onCancel: () => void;
817 onConfirm: () => void;
818 }) {
819 useEffect(() => {
820 if (!open) return;
821 const onKeyDown = (event: KeyboardEvent) => {
822 if (event.key === 'Escape' && !creating) onCancel();
823 };
824 window.addEventListener('keydown', onKeyDown);
825 return () => window.removeEventListener('keydown', onKeyDown);
826 }, [creating, onCancel, open]);
827
828 if (!open) return null;
829 return (
830 <div className="dialog-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && onCancel()}>
831 <form className="project-dialog" role="dialog" aria-modal="true" aria-labelledby="new-project-title" onSubmit={(event) => {
832 event.preventDefault();
833 onConfirm();
834 }}>
835 <span className="dialog-icon is-create"><FolderPlus size={18} /></span>
836 <div className="dialog-copy">
837 <h2 id="new-project-title">Create a new project</h2>
838 <p>Name the workspace before creating it.</p>
839 </div>
840 <label className="project-name-field">
841 <span>Project name</span>
842 <input
843 value={name}
844 onChange={(event) => onNameChange(event.target.value)}
845 placeholder="Untitled video"
846 maxLength={64}
847 autoFocus
848 disabled={creating}
849 />
850 {error && <small role="alert">{error}</small>}
851 </label>
852 <div className="dialog-actions">
853 <button type="button" onClick={onCancel} disabled={creating}>Cancel</button>
854 <button type="submit" className="primary" disabled={creating || !name.trim()}>{creating ? 'Creating…' : 'Create'}</button>
855 </div>
856 </form>
857 </div>
858 );
859 }
860
861 function sessionTitle(session?: SessionSummary) {
862 if (!session) return 'New video';
863 if (session.projectName) return session.projectName;
864 const source = session.idea || session.summary;
865 if (source) return source.length > 38 ? `${source.slice(0, 38).trim()}…` : source;
866 return session.sessionId.replace(/^\d{8}-\d{6}-?/, '') || 'Untitled video';
867 }
868
869 function stageLabel(stage: string) {
870 const labels: Record<string, string> = {
871 created: 'Created',
872 narrative_planning: 'Planning',
873 narrative_planned: 'Plan ready',
874 novel_planning: 'Planning novel',
875 novel_planned: 'Novel ready',
876 rendering: 'Rendering',
877 rendered: 'Rendered',
878 error: 'Needs attention',
879 };
880 return labels[stage] || humanize(stage || 'Created');
881 }
882
883 function relativeTime(value: string) {
884 const timestamp = Date.parse(value);
885 if (!Number.isFinite(timestamp)) return 'Recently';
886 const delta = Math.max(0, Date.now() - timestamp);
887 const minutes = Math.floor(delta / 60_000);
888 if (minutes < 1) return 'Now';
889 if (minutes < 60) return `${minutes}m`;
890 const hours = Math.floor(minutes / 60);
891 if (hours < 24) return `${hours}h`;
892 return `${Math.floor(hours / 24)}d`;
893 }
894
894 lines Plain Text