Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 19 additions & 19 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ dist/
[build]
command = "pnpm run build"
publish = "dist"

[[headers]]
for = "/*.wasm"
[headers.values]
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -201,29 +201,29 @@ dist/
<VirtualHost *:80>
ServerName your-domain.com
DocumentRoot /var/www/dks

<Directory /var/www/dks>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>

# 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 /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]

# Compression
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/css application/javascript application/wasm application/json
Expand All @@ -244,18 +244,18 @@ dist/
your-domain.com {
root * /var/www/dks
encode gzip

# Security headers
header {
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
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"
Expand All @@ -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
Expand All @@ -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";
Expand Down
5 changes: 4 additions & 1 deletion src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,10 @@ const AppContent: React.FC = () => {
{dbReady && currentView === 'chat' && (
<Suspense fallback={<AISkeleton />}>
<ErrorBoundary featureName="Chat" onRetry={() => window.location.reload()}>
<Chat onCreateEntity={() => setCurrentView('editor')} />
<Chat
onCreateEntity={() => { setCurrentView('editor'); }}
onNavigate={handleEditEntity}
/>
</ErrorBoundary>
</Suspense>
)}
Expand Down
1 change: 0 additions & 1 deletion src/components/Overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ export const Overlay: React.FC<OverlayProps> = ({
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]);

Expand Down
151 changes: 92 additions & 59 deletions src/features/chat/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="citations-section">
<button type="button" className="source-drawer-toggle" onClick={onToggle}>
<span>Used {citations.length} local items</span>
{expanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
{expanded && (
<div className="citation-cards">
{citations.map((cite) => (
<button type="button" key={cite.id} className="citation-card" onClick={() => onNavigate?.(cite.id)}>
<div className="cite-type">{cite.type}</div>
<div className="cite-name">{cite.title}</div>
<div className="cite-excerpt">{cite.content}</div>
</button>
))}
</div>
)}
</div>
);
}

interface ChatMessageProps {
message: Message;
showSources: boolean;
onToggleSources: () => void;
onNavigate?: (id: string) => void;
}

const Chat: React.FC<ChatProps> = ({ onCreateEntity, onNavigate }) => {
function ChatMessage({ message, showSources, onToggleSources, onNavigate }: ChatMessageProps): React.ReactElement {
return (
<div className={`message ${message.role}`}>
<div className="message-header">
<strong>{message.role === 'user' ? 'You' : 'Studio Assistant'}</strong>
</div>
<div className="message-content">
<div className="msg-text">{message.content}</div>
{message.citations && message.citations.length > 0 && (
<CitationsPanel
citations={message.citations}
expanded={showSources}
onToggle={onToggleSources}
onNavigate={onNavigate}
/>
)}
</div>
</div>
);
}

function Chat({ onCreateEntity, onNavigate }: ChatProps): React.ReactElement {
const [input, setInput] = useState('');
const [messages, setMessages] = useState<Message[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [showSources, setShowSources] = useState<Record<number, boolean>>({});
const [showSources, setShowSources] = useState<Record<string, boolean>>({});

const debounceRef = useRef<ReturnType<typeof setTimeout> | 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 (
<div className="chat-view">
Expand All @@ -85,48 +144,22 @@ const Chat: React.FC<ChatProps> = ({ onCreateEntity, onNavigate }) => {
<h2>Ask your library</h2>
<p>Search and synthesize information across your local entities, claims, and notes. Your data never leaves this device.</p>
<div className="suggested-actions">
<button type="button" onClick={() => { handleSend(undefined, 'Summarize my recent projects').catch(console.error); }}>Summarize recent projects</button>
<button type="button" onClick={() => { handleSend(undefined, 'Who are the key people?').catch(console.error); }}>Key people</button>
<button type="button" onClick={() => { void handleSend(undefined, 'Summarize my recent projects'); }}>Summarize recent projects</button>
<button type="button" onClick={() => { void handleSend(undefined, 'Who are the key people?'); }}>Key people</button>
<button type="button" onClick={onCreateEntity}>
Create new entity
</button>
</div>
</div>
)}
{messages.map((m, i) => (
<div key={m.id} className={`message ${m.role}`}>
<div className="message-header">
<strong>{m.role === 'user' ? 'You' : 'Studio Assistant'}</strong>
</div>
<div className="message-content">
<div className="msg-text">{m.content}</div>

{m.citations && m.citations.length > 0 && (
<div className="citations-section">
<button
type="button"
className="source-drawer-toggle"
onClick={() => toggleSources(i)}
>
<span>Used {m.citations.length} local items</span>
{showSources[i] ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>

{showSources[i] && (
<div className="citation-cards">
{m.citations.map((cite) => (
<button type="button" key={cite.id} className="citation-card" onClick={() => onNavigate?.(cite.id)}>
<div className="cite-type">{cite.type}</div>
<div className="cite-name">{cite.title}</div>
<div className="cite-excerpt">{cite.content}</div>
</button>
))}
</div>
)}
</div>
)}
</div>
</div>
{messages.map((m) => (
<ChatMessage
key={m.id}
message={m}
showSources={showSources[m.id] ?? false}
onToggleSources={() => { setShowSources(prev => ({ ...prev, [m.id]: !prev[m.id] })); }}
onNavigate={onNavigate}
/>
))}
{isSearching && (
<div className="message assistant loading">
Expand All @@ -138,7 +171,7 @@ const Chat: React.FC<ChatProps> = ({ onCreateEntity, onNavigate }) => {
)}
</div>

<form className="chat-controls" onSubmit={e => { handleSend(e).catch(console.error); }}>
<form className="chat-controls" onSubmit={(e) => { void handleSend(e); }}>
<div className="input-wrapper">
<Search size={18} className="search-icon" aria-hidden="true" />
<input
Expand Down
1 change: 0 additions & 1 deletion src/hooks/useScrollLock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { useEffect } from 'react';
* Locks document scroll when active.
* Restores previous scroll position on deactivation.
*/
// eslint-disable-next-line @typescript-eslint/no-empty-function -- cleanup function returns void
export const useScrollLock = (active: boolean): void => {
useEffect(() => {
if (!active) return;
Expand Down
4 changes: 2 additions & 2 deletions src/lib/mindmap-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ interface MindMapNode {

/**
* Builds a tree structure from entities and links for mind map visualization.
*
*
* @param currentId - The ID of the current entity to start from
* @param depth - Current depth in the tree
* @param maxDepth - Maximum depth to traverse
Expand Down Expand Up @@ -44,7 +44,7 @@ export function buildTree(

/**
* Adds ARIA attributes to mind map nodes for accessibility.
*
*
* @param container - The container element containing mind map nodes
*/
export function addAriaToNodes(container: HTMLElement): void {
Expand Down
Loading
Loading