diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 0fec74d..a007924 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -62,7 +62,7 @@ dist/ [build] command = "pnpm run build" publish = "dist" - + [[headers]] for = "/*.wasm" [headers.values] @@ -142,34 +142,34 @@ dist/ server_name your-domain.com; root /var/www/dks; index index.html; - + # Security headers add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "no-referrer-when-downgrade" always; add_header Content-Security-Policy "default-src 'self' 'wasm-unsafe-eval'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https:;" always; - + # WASM MIME type types { application/wasm wasm; } - + # SPA routing location / { try_files $uri $uri/ /index.html; } - + # Cache static assets location /assets/ { expires 1y; add_header Cache-Control "public, immutable"; } - + # No cache for index.html location = /index.html { add_header Cache-Control "no-cache, no-store, must-revalidate"; } - + # Gzip compression gzip on; gzip_types text/css application/javascript application/wasm application/json; @@ -201,21 +201,21 @@ dist/ ServerName your-domain.com DocumentRoot /var/www/dks - + Options -Indexes +FollowSymLinks AllowOverride All Require all granted - + # Security headers Header set X-Content-Type-Options "nosniff" Header set X-Frame-Options "SAMEORIGIN" Header set Referrer-Policy "no-referrer-when-downgrade" - + # WASM MIME type AddType application/wasm .wasm - + # SPA routing RewriteEngine On RewriteBase / @@ -223,7 +223,7 @@ dist/ RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule . /index.html [L] - + # Compression AddOutputFilterByType DEFLATE text/css application/javascript application/wasm application/json @@ -244,7 +244,7 @@ dist/ your-domain.com { root * /var/www/dks encode gzip - + # Security headers header { X-Content-Type-Options "nosniff" @@ -252,10 +252,10 @@ dist/ Referrer-Policy "no-referrer-when-downgrade" Content-Security-Policy "default-src 'self' 'wasm-unsafe-eval'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https:;" } - + # SPA routing try_files {path} /index.html - + # Cache static assets @assets path /assets/* header @assets Cache-Control "public, immutable, max-age=31536000" @@ -277,7 +277,7 @@ dist/ RUN corepack enable && pnpm install --frozen-lockfile COPY . . RUN pnpm run build - + FROM nginx:alpine COPY --from=builder /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf @@ -291,15 +291,15 @@ dist/ listen 80; root /usr/share/nginx/html; index index.html; - + types { application/wasm wasm; } - + location / { try_files $uri $uri/ /index.html; } - + location /assets/ { expires 1y; add_header Cache-Control "public, immutable"; diff --git a/src/app/App.tsx b/src/app/App.tsx index 65a34e8..f0c88af 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -236,7 +236,10 @@ const AppContent: React.FC = () => { {dbReady && currentView === 'chat' && ( }> window.location.reload()}> - setCurrentView('editor')} /> + { setCurrentView('editor'); }} + onNavigate={handleEditEntity} + /> )} diff --git a/src/components/Overlay.tsx b/src/components/Overlay.tsx index a06ab52..90b91fc 100644 --- a/src/components/Overlay.tsx +++ b/src/components/Overlay.tsx @@ -40,7 +40,6 @@ export const Overlay: React.FC = ({ useEffect(() => { if (!isOpen) return; document.addEventListener('keydown', handleKeyDown); - // eslint-disable-next-line @typescript-eslint/no-empty-function -- cleanup returns void return () => { document.removeEventListener('keydown', handleKeyDown); }; }, [isOpen, handleKeyDown]); diff --git a/src/features/chat/Chat.tsx b/src/features/chat/Chat.tsx index b0830b7..d94d91b 100644 --- a/src/features/chat/Chat.tsx +++ b/src/features/chat/Chat.tsx @@ -16,55 +16,114 @@ interface ChatProps { onNavigate?: (id: string) => void; } -/* biome-ignore lint/correctness/useQwikValidLexicalScope: false positive - standard React arrow function */ -const buildResponse = (input: string, results: RankedResult[]): string => { +function buildResponse(queryText: string, results: RankedResult[]): string { if (results.length > 0) { - return `Based on your local records, here's what I found about "${input}". I've cited the most relevant items below.`; + return `Based on your local records, here's what I found about "${queryText}". I've cited the most relevant items below.`; } - return `I couldn't find any direct matches in your local library for "${input}". You might want to try different keywords or add more context to your entities.`; -}; + return `I couldn't find any direct matches in your local library for "${queryText}". You might want to try different keywords or add more context to your entities.`; +} + +interface CitationsPanelProps { + citations: RankedResult[]; + expanded: boolean; + onToggle: () => void; + onNavigate?: (id: string) => void; +} + +function CitationsPanel({ citations, expanded, onToggle, onNavigate }: CitationsPanelProps): React.ReactElement { + return ( +
+ + {expanded && ( +
+ {citations.map((cite) => ( + + ))} +
+ )} +
+ ); +} + +interface ChatMessageProps { + message: Message; + showSources: boolean; + onToggleSources: () => void; + onNavigate?: (id: string) => void; +} -const Chat: React.FC = ({ onCreateEntity, onNavigate }) => { +function ChatMessage({ message, showSources, onToggleSources, onNavigate }: ChatMessageProps): React.ReactElement { + return ( +
+
+ {message.role === 'user' ? 'You' : 'Studio Assistant'} +
+
+
{message.content}
+ {message.citations && message.citations.length > 0 && ( + + )} +
+
+ ); +} + +function Chat({ onCreateEntity, onNavigate }: ChatProps): React.ReactElement { const [input, setInput] = useState(''); const [messages, setMessages] = useState([]); const [isSearching, setIsSearching] = useState(false); - const [showSources, setShowSources] = useState>({}); + const [showSources, setShowSources] = useState>({}); const debounceRef = useRef | null>(null); - /* biome-ignore lint/correctness/useQwikValidLexicalScope: false positive - standard React async function */ - const handleSend = async (e?: React.FormEvent, query?: string) => { + function canSend(currentInput: string): boolean { + return currentInput.trim() !== '' && !isSearching && !debounceRef.current; + } + + function scheduleDebounceReset() { + debounceRef.current = setTimeout(() => { debounceRef.current = null; }, 300); + } + + async function handleSend(e?: React.FormEvent, query?: string) { e?.preventDefault(); const currentInput = query ?? input; - if (!currentInput.trim() || isSearching || debounceRef.current) return; + if (!canSend(currentInput)) return; - debounceRef.current = setTimeout(() => { debounceRef.current = null; }, 300); - const userMessageId = `user-${Date.now()}`; - setMessages(prev => [...prev, { id: userMessageId, role: 'user', content: currentInput }]); + scheduleDebounceReset(); + const userId = `user-${Date.now()}`; + setMessages(prev => [...prev, { id: userId, role: 'user', content: currentInput }]); setInput(''); setIsSearching(true); try { const results = await searchKnowledge(currentInput, { limit: 5 }); - const assistantMessageId = `assistant-${Date.now()}`; + const assistantId = `assistant-${Date.now()}`; setMessages(prev => [...prev, { - id: assistantMessageId, + id: assistantId, role: 'assistant', content: buildResponse(currentInput, results), citations: results }]); } catch (err) { logger.error('Ask retrieval failed', err); - const errorMessageId = `error-${Date.now()}`; - setMessages(prev => [...prev, { id: errorMessageId, role: 'assistant', content: 'Sorry, I encountered an issue while searching your local library.' }]); + const errorId = `error-${Date.now()}`; + setMessages(prev => [...prev, { id: errorId, role: 'assistant', content: 'Sorry, I encountered an issue while searching your local library.' }]); } finally { setIsSearching(false); } - }; - - const toggleSources = (index: number) => { - setShowSources(prev => ({ ...prev, [index]: !prev[index] })); - }; + } return (
@@ -85,48 +144,22 @@ const Chat: React.FC = ({ onCreateEntity, onNavigate }) => {

Ask your library

Search and synthesize information across your local entities, claims, and notes. Your data never leaves this device.

- - + +
)} - {messages.map((m, i) => ( -
-
- {m.role === 'user' ? 'You' : 'Studio Assistant'} -
-
-
{m.content}
- - {m.citations && m.citations.length > 0 && ( -
- - - {showSources[i] && ( -
- {m.citations.map((cite) => ( - - ))} -
- )} -
- )} -
-
+ {messages.map((m) => ( + { setShowSources(prev => ({ ...prev, [m.id]: !prev[m.id] })); }} + onNavigate={onNavigate} + /> ))} {isSearching && (
@@ -138,7 +171,7 @@ const Chat: React.FC = ({ onCreateEntity, onNavigate }) => { )}
-
{ handleSend(e).catch(console.error); }}> + { void handleSend(e); }}>