diff --git a/.gitignore b/.gitignore index 2747ae9..968ccb3 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ ref_docs/ +web/node_modules/ +web/dist/ +.env diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..a5e96eb --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + Knowledge Assistant + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..9c9a03e --- /dev/null +++ b/web/package.json @@ -0,0 +1,25 @@ +{ + "name": "knowledge-assistant-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-markdown": "^10.1.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.7", + "@types/react": "^19.1.4", + "@types/react-dom": "^19.1.5", + "@vitejs/plugin-react": "^4.5.2", + "tailwindcss": "^4.1.7", + "typescript": "~5.8.3", + "vite": "^6.3.5" + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..4b7e5c7 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,5 @@ +import Layout from './components/Layout' + +export default function App() { + return +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..a6fdd04 --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,90 @@ +import type { Note, Message } from '../types' + +const BASE = '/api' + +export async function fetchNotes(): Promise { + const res = await fetch(`${BASE}/notes`) + return res.json() +} + +export async function fetchNote(id: string): Promise { + const res = await fetch(`${BASE}/notes/${encodeURIComponent(id)}`) + return res.json() +} + +export async function createNote(note: { title: string; content: string; tags?: string[] }): Promise { + const res = await fetch(`${BASE}/notes`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(note), + }) + return res.json() +} + +export async function deleteNote(id: string): Promise { + await fetch(`${BASE}/notes/${encodeURIComponent(id)}`, { method: 'DELETE' }) +} + +export async function rebuildIndex(): Promise { + await fetch(`${BASE}/index`, { method: 'POST' }) +} + +export function streamChat( + message: string, + onEvent: (event: { type: string; agent: string; content: string; role: string }) => void, + onDone: () => void, + onError: (err: string) => void, +): AbortController { + const controller = new AbortController() + + fetch(`${BASE}/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message }), + signal: controller.signal, + }) + .then(async (res) => { + if (!res.ok) { + onError(`HTTP ${res.status}`) + return + } + const reader = res.body?.getReader() + if (!reader) { + onError('No response body') + return + } + const decoder = new TextDecoder() + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + let currentEvent = '' + for (const line of lines) { + if (line.startsWith('event: ')) { + currentEvent = line.slice(7) + } else if (line.startsWith('data: ')) { + try { + const data = JSON.parse(line.slice(6)) + onEvent({ type: currentEvent, ...data }) + } catch { + // skip malformed JSON + } + } + } + } + onDone() + }) + .catch((err) => { + if (err.name !== 'AbortError') { + onError(err.message) + } + }) + + return controller +} diff --git a/web/src/components/ChatPanel.tsx b/web/src/components/ChatPanel.tsx new file mode 100644 index 0000000..57c40c6 --- /dev/null +++ b/web/src/components/ChatPanel.tsx @@ -0,0 +1,75 @@ +import { useState, useRef, useEffect } from 'react' +import { useChat } from '../hooks/useChat' +import MessageBubble from './MessageBubble' + +export default function ChatPanel() { + const { messages, loading, sendMessage, clearMessages } = useChat() + const [input, setInput] = useState('') + const messagesEndRef = useRef(null) + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages]) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!input.trim()) return + sendMessage(input) + setInput('') + } + + return ( +
+
+

Knowledge Assistant

+ {messages.length > 0 && ( + + )} +
+ +
+ {messages.length === 0 ? ( +
+ Ask me anything about your notes... +
+ ) : ( + <> + {messages.map((msg) => ( + + ))} + {loading && messages[messages.length - 1]?.role === 'assistant' && ( +
+ thinking... +
+ )} + + )} +
+
+ +
+
+ setInput(e.target.value)} + placeholder="Type your message..." + className="flex-1 px-4 py-2.5 text-sm border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + disabled={loading} + /> + +
+
+
+ ) +} diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx new file mode 100644 index 0000000..256cf88 --- /dev/null +++ b/web/src/components/Layout.tsx @@ -0,0 +1,68 @@ +import { useState } from 'react' +import ChatPanel from './ChatPanel' +import NoteList from './NoteList' +import NoteDetail from './NoteDetail' +import { useNotes } from '../hooks/useNotes' + +export default function Layout() { + const { notes, selectedNote, loadNote, removeNote, setSelectedNote } = useNotes() + const [sidebarTab, setSidebarTab] = useState<'chat' | 'notes'>('chat') + + return ( +
+
+
+ + +
+ + {sidebarTab === 'notes' && ( +
+ +
+ )} + + {sidebarTab === 'chat' && ( +
+ Use the chat panel to interact with your knowledge assistant +
+ )} +
+ +
+ {selectedNote ? ( +
+ setSelectedNote(null)} /> +
+ ) : ( +
+ +
+ )} +
+
+ ) +} diff --git a/web/src/components/MessageBubble.tsx b/web/src/components/MessageBubble.tsx new file mode 100644 index 0000000..68ddc6b --- /dev/null +++ b/web/src/components/MessageBubble.tsx @@ -0,0 +1,28 @@ +import type { Message } from '../types' + +interface Props { + message: Message +} + +export default function MessageBubble({ message }: Props) { + const isUser = message.role === 'user' + + return ( +
+
+ {!isUser && message.agent && ( +
+ {message.agent} +
+ )} +
{message.content}
+
+
+ ) +} diff --git a/web/src/components/NoteDetail.tsx b/web/src/components/NoteDetail.tsx new file mode 100644 index 0000000..8ee80e0 --- /dev/null +++ b/web/src/components/NoteDetail.tsx @@ -0,0 +1,41 @@ +import type { Note } from '../types' + +interface Props { + note: Note + onClose: () => void +} + +export default function NoteDetail({ note, onClose }: Props) { + return ( +
+
+
+

{note.title}

+ {note.tags.length > 0 && ( +
+ {note.tags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+ +
+
+
+          {note.content}
+        
+
+
+ ) +} diff --git a/web/src/components/NoteList.tsx b/web/src/components/NoteList.tsx new file mode 100644 index 0000000..53c4dc3 --- /dev/null +++ b/web/src/components/NoteList.tsx @@ -0,0 +1,59 @@ +import type { Note } from '../types' + +interface Props { + notes: Note[] + selectedId?: string + onSelect: (id: string) => void + onDelete: (id: string) => void +} + +export default function NoteList({ notes, selectedId, onSelect, onDelete }: Props) { + if (notes.length === 0) { + return ( +
+ No notes yet +
+ ) + } + + return ( +
+ {notes.map((note) => ( +
onSelect(note.id)} + className={`px-4 py-3 cursor-pointer hover:bg-gray-50 transition-colors ${ + selectedId === note.id ? 'bg-blue-50 border-l-2 border-blue-500' : '' + }`} + > +
+
+

{note.title}

+ {note.tags.length > 0 && ( +
+ {note.tags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+ +
+
+ ))} +
+ ) +} diff --git a/web/src/hooks/useChat.ts b/web/src/hooks/useChat.ts new file mode 100644 index 0000000..e40d3f6 --- /dev/null +++ b/web/src/hooks/useChat.ts @@ -0,0 +1,72 @@ +import { useState, useCallback, useRef } from 'react' +import { streamChat } from '../api/client' +import type { Message } from '../types' + +export function useChat() { + const [messages, setMessages] = useState([]) + const [loading, setLoading] = useState(false) + const controllerRef = useRef(null) + + const sendMessage = useCallback((content: string) => { + if (!content.trim() || loading) return + + const userMsg: Message = { + id: Date.now().toString(), + role: 'user', + content, + } + setMessages((prev) => [...prev, userMsg]) + setLoading(true) + + const assistantMsg: Message = { + id: (Date.now() + 1).toString(), + role: 'assistant', + content: '', + } + setMessages((prev) => [...prev, assistantMsg]) + + controllerRef.current = streamChat( + content, + (event) => { + if (event.type === 'message' && event.content) { + setMessages((prev) => { + const last = prev[prev.length - 1] + if (last.role === 'assistant') { + return [ + ...prev.slice(0, -1), + { + ...last, + content: last.content + event.content, + agent: event.agent || last.agent, + }, + ] + } + return prev + }) + } + }, + () => setLoading(false), + (err) => { + setMessages((prev) => { + const last = prev[prev.length - 1] + if (last.role === 'assistant') { + return [ + ...prev.slice(0, -1), + { ...last, content: last.content || `Error: ${err}` }, + ] + } + return prev + }) + setLoading(false) + }, + ) + }, [loading]) + + const clearMessages = useCallback(() => { + controllerRef.current?.abort() + setMessages([]) + setLoading(false) + }, []) + + return { messages, loading, sendMessage, clearMessages } +} diff --git a/web/src/hooks/useNotes.ts b/web/src/hooks/useNotes.ts new file mode 100644 index 0000000..17bdcd0 --- /dev/null +++ b/web/src/hooks/useNotes.ts @@ -0,0 +1,47 @@ +import { useState, useCallback, useEffect } from 'react' +import { fetchNotes, fetchNote, deleteNote } from '../api/client' +import type { Note } from '../types' + +export function useNotes() { + const [notes, setNotes] = useState([]) + const [selectedNote, setSelectedNote] = useState(null) + const [loading, setLoading] = useState(false) + + const loadNotes = useCallback(async () => { + setLoading(true) + try { + const data = await fetchNotes() + setNotes(data || []) + } catch { + // ignore + } + setLoading(false) + }, []) + + const loadNote = useCallback(async (id: string) => { + try { + const note = await fetchNote(id) + setSelectedNote(note) + } catch { + // ignore + } + }, []) + + const removeNote = useCallback(async (id: string) => { + try { + await deleteNote(id) + setNotes((prev) => prev.filter((n) => n.id !== id)) + if (selectedNote?.id === id) { + setSelectedNote(null) + } + } catch { + // ignore + } + }, [selectedNote]) + + useEffect(() => { + loadNotes() + }, [loadNotes]) + + return { notes, selectedNote, loading, loadNotes, loadNote, removeNote, setSelectedNote } +} diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/web/src/index.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..db032b7 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/web/src/types.ts b/web/src/types.ts new file mode 100644 index 0000000..1d6eadc --- /dev/null +++ b/web/src/types.ts @@ -0,0 +1,14 @@ +export interface Note { + id: string + title: string + content?: string + tags: string[] + created_at: string +} + +export interface Message { + id: string + role: 'user' | 'assistant' + content: string + agent?: string +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..5d93dec --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..7bca0fa --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + plugins: [react(), tailwindcss()], + server: { + proxy: { + '/api': 'http://localhost:8080', + }, + }, +})