diff --git a/backend/main.py b/backend/main.py index d9e019e..51e3eb6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,10 +1,11 @@ from fastapi import FastAPI, Depends, HTTPException from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session from typing import List from . import models, schemas from .database import Base, engine, SessionLocal -from .ollama_client import list_models as ollama_list, chat as ollama_chat +from .ollama_client import list_models as ollama_list, chat as ollama_chat, chat_stream as ollama_chat_stream # Create tables Base.metadata.create_all(bind=engine) @@ -61,7 +62,7 @@ def history(session_id: str, db: Session = Depends(get_db)): msgs = [{"role": r.role, "content": r.content} for r in rows] return {"messages": msgs} -@app.post("/chat", response_model=schemas.ChatResponse) +@app.post("/chat") async def chat(req: schemas.ChatRequest, db: Session = Depends(get_db)): # Find or create session session = db.query(models.ChatSession).filter(models.ChatSession.session_id == req.session_id).first() @@ -81,17 +82,35 @@ async def chat(req: schemas.ChatRequest, db: Session = Depends(get_db)): messages = [{"role": m.role, "content": m.content} for m in last_msgs] - try: - reply = await ollama_chat(req.model, messages) - except Exception as e: - raise HTTPException(status_code=502, detail=f"Ollama error: {e}") + if req.stream: + async def stream_generator(): + full_reply = "" + try: + async for chunk in ollama_chat_stream(req.model, messages): + full_reply += chunk + yield chunk + except Exception as e: + # How to handle errors in a stream? Could yield an error message. + yield f"Ollama error: {e}" - # Save assistant reply - as_row = models.ChatMessage(session_pk=session.id, role='assistant', content=reply) - db.add(as_row) - db.commit() + # Save full reply after stream is complete + as_row = models.ChatMessage(session_pk=session.id, role='assistant', content=full_reply) + db.add(as_row) + db.commit() + + return StreamingResponse(stream_generator(), media_type="text/plain") + else: + try: + reply = await ollama_chat(req.model, messages) + except Exception as e: + raise HTTPException(status_code=502, detail=f"Ollama error: {e}") - return {"reply": reply} + # Save assistant reply + as_row = models.ChatMessage(session_pk=session.id, role='assistant', content=reply) + db.add(as_row) + db.commit() + + return {"reply": reply} @app.post("/generate-title", response_model=schemas.GenerateTitleResponse) async def generate_title(req: schemas.GenerateTitleRequest, db: Session = Depends(get_db)): @@ -102,7 +121,7 @@ async def generate_title(req: schemas.GenerateTitleRequest, db: Session = Depend prompt = f"Generate a very short, concise title (5 words or less) for a chat conversation that begins with this user message: \"{req.message}\". Do not use quotation marks in the title." try: - title = await ollama_chat("llama3", [{"role": "user", "content": prompt}]) + title = await ollama_chat(req.model, [{"role": "user", "content": prompt}]) except Exception as e: raise HTTPException(status_code=502, detail=f"Ollama error: {e}") diff --git a/backend/ollama_client.py b/backend/ollama_client.py index ae5dd5c..2dea886 100644 --- a/backend/ollama_client.py +++ b/backend/ollama_client.py @@ -1,6 +1,7 @@ import httpx -from typing import Dict, Any, List +import json +from typing import Dict, Any, List, AsyncGenerator OLLAMA_URL = "http://127.0.0.1:11434" @@ -32,3 +33,23 @@ async def chat(model: str, messages: List[Dict[str, str]]) -> str: if msgs: return msgs[-1].get("content", "") return data.get("content", "") + +async def chat_stream(model: str, messages: List[Dict[str, str]]) -> AsyncGenerator[str, None]: + payload = { + "model": model, + "messages": messages, + "stream": True + } + async with httpx.AsyncClient(timeout=600.0) as client: + async with client.stream("POST", f"{OLLAMA_URL}/api/chat", json=payload) as r: + r.raise_for_status() + async for line in r.aiter_lines(): + if line: + try: + chunk = json.loads(line) + if "content" in chunk: # Newer Ollama format + yield chunk["content"] + elif "message" in chunk and "content" in chunk["message"]: # Older format + yield chunk["message"]["content"] + except json.JSONDecodeError: + pass # Ignore invalid JSON lines diff --git a/backend/schemas.py b/backend/schemas.py index 950424a..9bcb0c4 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -10,6 +10,7 @@ class ChatRequest(BaseModel): session_id: str model: str message: str + stream: Optional[bool] = False class ChatResponse(BaseModel): reply: str @@ -20,6 +21,7 @@ class HistoryResponse(BaseModel): class GenerateTitleRequest(BaseModel): session_id: str message: str + model: str class GenerateTitleResponse(BaseModel): title: str diff --git a/electron/main.cjs b/electron/main.cjs index 1a90f69..3d8367f 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1,5 +1,5 @@ -const { app, BrowserWindow, Menu, ipcMain } = require('electron') +const { app, BrowserWindow, Menu, ipcMain, shell } = require('electron') const path = require('path') const { is } = require('@electron-toolkit/utils') const fs = require('fs') @@ -44,6 +44,8 @@ async function createMainWindow () { mainWindow = new BrowserWindow({ width: 1000, height: 720, + minWidth: 680, + minHeight: 300, show: false, webPreferences: { preload: path.join(__dirname, 'preload.cjs'), @@ -62,6 +64,11 @@ async function createMainWindow () { } else { await mainWindow.loadFile(path.join(__dirname, '../dist/index.html')) } + + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + shell.openExternal(url); + return { action: 'deny' }; + }); } async function createSettingsWindow () { @@ -166,6 +173,16 @@ ipcMain.handle('set-setting', (event, key, value) => { return true }) +ipcMain.handle('update-settings', (event, settings) => { + appSettings = { ...appSettings, ...settings } + saveSettings() + return true +}) + +ipcMain.on('open-external-link', (event, url) => { + shell.openExternal(url) +}) + app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit() }) diff --git a/electron/preload.cjs b/electron/preload.cjs index c132863..895b088 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -4,5 +4,11 @@ const { contextBridge, ipcRenderer } = require('electron') // Expose a secure API to the renderer process contextBridge.exposeInMainWorld('electronAPI', { getSettings: () => ipcRenderer.invoke('get-settings'), - setSetting: (key, value) => ipcRenderer.invoke('set-setting', key, value) + setSetting: (key, value) => ipcRenderer.invoke('set-setting', key, value), + updateSettings: (settings) => ipcRenderer.invoke('update-settings', settings), + openExternalLink: (event) => { + event.preventDefault(); + const url = event.currentTarget.href; + ipcRenderer.send('open-external-link', url); + } }) diff --git a/src/App.jsx b/src/App.jsx index fbce7ce..7f5251d 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,4 +1,6 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react' +// /Users/giers/Heimgeist/src/App.jsx +import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { flushSync } from 'react-dom'; import TextareaAutosize from 'react-textarea-autosize'; import GeneralSettings from './GeneralSettings' import InterfaceSettings from './InterfaceSettings' @@ -9,6 +11,8 @@ const COLOR_SCHEME_KEY = 'colorScheme'; // Initial API value will be set by useEffect after settings are loaded let API = import.meta.env.VITE_API_URL ?? 'http://127.0.0.1:8000'; +const TOP_ALIGN_OFFSET = 48; // match .chat padding + header height for exact top alignment (should be more dynamic depending on header height) +const BOTTOM_EPSILON = 24; // px tolerance for treating as bottom export default function App() { const [chatSessions, setChatSessions] = useState([]) @@ -24,16 +28,46 @@ export default function App() { const textareaRef = useRef(null); // Ref for the textarea const [ollamaApiUrl, setOllamaApiUrl] = useState(API); // State for Ollama API URL const [colorScheme, setColorScheme] = useState('Default'); // State for color scheme + const [streamOutput, setStreamOutput] = useState(false); + const [isSending, setIsSending] = useState(false); const [loading, setLoading] = useState(true); // Loading state for initial session fetch const [unreadSessions, setUnreadSessions] = useState([]); // Track unread messages - const activeRequestSessionId = useRef(null); // Ref to track the session ID of the active request + const [scrollPositions, setScrollPositions] = useState({}); // Store scroll positions for each session + + // Persist userScrolledUp state per session + live ref for closures (streaming) + const [userScrolledUpState, setUserScrolledUpState] = useState({}); + const userScrolledUpRef = useRef({}); + + // When a response arrives in a non-active chat, remember to scroll to the new ASSISTANT message on open + const [pendingScrollToLastUser, setPendingScrollToLastUser] = useState({}); // { [sessionId]: assistantMsgId } + + // Live per-session scrollTop tracker to avoid races + const scrollTopsRef = useRef({}); + + // Tip state: { [sessionId]: messageId } + const [newMsgTip, setNewMsgTip] = useState({}); + + const setUserScrolledUp = React.useCallback((sessionId, value) => { + setUserScrolledUpState(prev => { + const next = { ...prev, [sessionId]: value }; + userScrolledUpRef.current = next; + return next; + }); + }, []); + + const activeRequestSessionId = useRef(null); + const justSentMessage = useRef(false); + const lastSentSessionRef = useRef(null); const activeSessionIdRef = useRef(activeSessionId); useEffect(() => { activeSessionIdRef.current = activeSessionId; }, [activeSessionId]); + // Flag to ensure we only restore once per open of a chat + const restoredForRef = useRef(null); + // Sidebar resizing state - const [sidebarWidth, setSidebarWidth] = useState(280); // Initial sidebar width + const [sidebarWidth, setSidebarWidth] = useState(230); const [isResizing, setIsResizing] = useState(false); const startResizing = React.useCallback((mouseDownEvent) => { @@ -74,6 +108,8 @@ export default function App() { setOllamaApiUrl(settings.ollamaApiUrl); setColorScheme(settings.colorScheme); setModel(settings.chatModel || ''); // Load the selected model, with a fallback + setStreamOutput(settings.streamOutput || false); + setScrollPositions(settings.scrollPositions || {}); // Load scroll positions applyColorScheme(settings.colorScheme); // Apply initial scheme }); }, []); @@ -171,80 +207,373 @@ export default function App() { fetchHistory(activeSessionId); }, [activeSessionId]); + const handleSidebarClick = (mode) => { + // Saving happens in the centralized cleanup effect below + setActiveSidebarMode(mode); + }; + + const handleSelectChat = (sessionId) => { + // Saving happens in the centralized cleanup effect below + selectChat(sessionId); + }; + const messages = useMemo(() => { return chatSessions.find(s => s.session_id === activeSessionId)?.messages || []; }, [activeSessionId, chatSessions]); + // Persist the scrollTop of the session we are LEAVING (on chat change or when leaving the chat view) useEffect(() => { - chatRef.current?.scrollTo({ top: chatRef.current.scrollHeight, behavior: 'smooth' }); - }, [messages]); + const leavingSessionId = activeSessionId; + const leavingMode = activeSidebarMode; + + return () => { + if (leavingMode === 'chats' && leavingSessionId) { + const top = typeof scrollTopsRef.current[leavingSessionId] === 'number' + ? scrollTopsRef.current[leavingSessionId] + : (chatRef.current ? chatRef.current.scrollTop : 0); + + setScrollPositions(prev => { + const updated = { ...prev, [leavingSessionId]: top }; + window.electronAPI.updateSettings({ scrollPositions: updated }); + return updated; + }); + } + }; + }, [activeSessionId, activeSidebarMode]); + + // Track scroll + whether user left bottom + useEffect(() => { + const chatDiv = chatRef.current; + if (!chatDiv) return; + + const handleScroll = () => { + const { scrollTop, scrollHeight, clientHeight } = chatDiv; + const isAtBottom = (scrollHeight - scrollTop - clientHeight) <= BOTTOM_EPSILON; + if (activeSessionId) { + scrollTopsRef.current[activeSessionId] = scrollTop; + } + setUserScrolledUp(activeSessionId, !isAtBottom); + }; + + chatDiv.addEventListener('scroll', handleScroll); + return () => chatDiv.removeEventListener('scroll', handleScroll); + }, [activeSessionId, setUserScrolledUp]); + + // Auto-hide the tip if user returns to bottom in the active chat + useEffect(() => { + const sid = activeSessionId; + if (!sid) return; + if (userScrolledUpState[sid] === false) { + setNewMsgTip(prev => { + if (!(sid in prev)) return prev; + const rest = { ...prev }; + delete rest[sid]; + return rest; + }); + } + }, [activeSessionId, userScrolledUpState]); + + // --- Robust restoration: do it before paint, exactly once per open --- + useLayoutEffect(() => { + if (activeSidebarMode !== 'chats' || !activeSessionId) return; + + const div = chatRef.current; + if (!div) return; + + restoredForRef.current = null; + + const applyRestore = () => { + if (restoredForRef.current === activeSessionId) return; + + const liveSaved = typeof scrollTopsRef.current[activeSessionId] === 'number' + ? scrollTopsRef.current[activeSessionId] + : undefined; + const saved = typeof liveSaved === 'number' + ? liveSaved + : scrollPositions[activeSessionId]; + + if (typeof saved === 'number') { + div.scrollTop = saved; + restoredForRef.current = activeSessionId; + return; + } + if (messages.length > 0) { + // default: bottom when no saved position + div.scrollTop = div.scrollHeight; + restoredForRef.current = activeSessionId; + } + }; + + // Run immediately (pre-paint) and also schedule a fallback rAF + applyRestore(); + const r0 = requestAnimationFrame(applyRestore); + + // If content size/DOM changes after first paint, apply once + const onDomChange = () => { + if (restoredForRef.current !== activeSessionId) { + requestAnimationFrame(applyRestore); + } + }; + + const mo = new MutationObserver(onDomChange); + mo.observe(div, { childList: true, subtree: true }); + + const ro = new ResizeObserver(onDomChange); + ro.observe(div); + + return () => { + cancelAnimationFrame(r0); + mo.disconnect(); + ro.disconnect(); + }; + }, [activeSessionId, activeSidebarMode, messages.length, scrollPositions]); + + // If there is no saved scroll and content arrives later (e.g., on first app load), + // default to bottom exactly once for this open chat. + useEffect(() => { + if (activeSidebarMode !== 'chats' || !activeSessionId) return; + if (restoredForRef.current === activeSessionId) return; // already applied + + const liveSaved = typeof scrollTopsRef.current[activeSessionId] === 'number' + ? scrollTopsRef.current[activeSessionId] + : undefined; + const savedScrollTop = typeof liveSaved === 'number' + ? liveSaved + : scrollPositions[activeSessionId]; + + // Only when there is no saved position and we now have content + if (typeof savedScrollTop !== 'number' && messages.length > 0) { + requestAnimationFrame(() => { + const div = chatRef.current; + if (!div) return; + div.scrollTop = div.scrollHeight; + restoredForRef.current = activeSessionId; + }); + } + }, [messages.length, activeSessionId, activeSidebarMode, scrollPositions]); + + // Session-aware scroll helpers + const scrollToBottom = (behavior = 'smooth', sessionId = null) => { + const chatDiv = chatRef.current; + if (!chatDiv) return; + const target = sessionId ?? activeSessionIdRef.current; + if (activeSessionIdRef.current !== target) return; + chatDiv.scrollTo({ top: chatDiv.scrollHeight, behavior }); + setUserScrolledUp(target, false); + }; + + const scrollMessageToTop = (msgId, behavior = 'auto', sessionId = null) => { + const chatDiv = chatRef.current; + if (!chatDiv) return; + const target = sessionId ?? activeSessionIdRef.current; + if (activeSessionIdRef.current !== target) return; + const el = document.getElementById(msgId); + if (el) { + const top = Math.max(0, el.offsetTop - TOP_ALIGN_OFFSET); + chatDiv.scrollTo({ top, behavior }); + } + }; + + // Handler for new message tip click + const handleNewMsgTipClick = () => { + const sid = activeSessionIdRef.current; + const msgId = newMsgTip[sid]; + if (msgId) { + scrollMessageToTop(msgId, 'smooth', sid); + setNewMsgTip(prev => { + const { [sid]: _omit, ...rest } = prev; + return rest; + }); + } + }; + async function sendMessage() { - let targetSessionId = activeSessionId; - let isNewChat = false; - if (!input.trim() || !model) return; + let targetSessionId = activeSessionId; + let isNewChat = false; if (!targetSessionId) { const newSession = await createNewChat(); + await new Promise(resolve => setTimeout(resolve, 200)); targetSessionId = newSession.session_id; isNewChat = true; } else { - // Check if it's an existing "New Chat" receiving its first message const currentSession = chatSessions.find(s => s.session_id === targetSessionId); - if (currentSession && currentSession.name === "New Chat" && currentSession.messages.length === 0) { - isNewChat = true; - } + isNewChat = currentSession && currentSession.name === "New Chat" && currentSession.messages.length === 0; } - const currentActiveSessionAtSend = activeSessionId; // Capture activeSessionId at send time - - const userMsg = { role: 'user', content: input.trim() }; - // Optimistically add user message - setChatSessions(prevSessions => - prevSessions.map(session => - session.session_id === targetSessionId - ? { ...session, messages: [...(session.messages || []), userMsg] } - : session - ) - ); - setInput(''); + const userMsg = { role: 'user', content: input.trim(), id: `msg-${Date.now()}-${Math.random()}` }; + justSentMessage.current = true; + lastSentSessionRef.current = targetSessionId; + setUserScrolledUp(targetSessionId, false); - try { - const res = await fetch(`${ollamaApiUrl}/chat`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - session_id: targetSessionId, - model, - message: userMsg.content - }) - }); - const data = await res.json(); - const assistantMsg = { role: 'assistant', content: data.reply }; + // Cancel any pending restore for the active session (we're about to control the scroll) + if (activeSessionIdRef.current === targetSessionId) { + restoredForRef.current = activeSessionIdRef.current; // mark as already restored + } - // Update messages with assistant's reply + // Optimistic add and flush DOM, then scroll to bottom + flushSync(() => { setChatSessions(prevSessions => prevSessions.map(session => session.session_id === targetSessionId - ? { ...session, messages: [...(session.messages || []), assistantMsg] } + ? { ...session, messages: [...(session.messages || []), userMsg] } : session ) ); + setInput(''); + }); + requestAnimationFrame(() => scrollToBottom('auto', targetSessionId)); + + setIsSending(true); + try { + if (streamOutput) { + const assistantMsgId = `msg-${Date.now()}-${Math.random()}`; + const assistantMsg = { role: 'assistant', content: '', id: assistantMsgId }; + setChatSessions(prevSessions => + prevSessions.map(session => + session.session_id === targetSessionId + ? { ...session, messages: [...(session.messages || []), assistantMsg] } + : session + ) + ); + + (async () => { + try { + const res = await fetch(`${ollamaApiUrl}/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + session_id: targetSessionId, + model, + message: userMsg.content, + stream: true + }) + }); + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let fullReply = ''; + let pendingMarked = false; + + while (true) { + const { value, done } = await reader.read(); + if (done) { + setChatSessions(prevSessions => + prevSessions.map(session => + session.session_id === targetSessionId + ? { + ...session, + messages: session.messages.map(m => + m.id === assistantMsgId ? { ...m, content: fullReply } : m + ) + } + : session + ) + ); + + if (activeSessionIdRef.current === targetSessionId) { + if (!userScrolledUpRef.current[targetSessionId]) { + // user stayed at bottom -> reveal the message immediately + requestAnimationFrame(() => scrollMessageToTop(assistantMsgId, 'smooth', targetSessionId)); + } else { + // user scrolled away while it was generating -> show tip instead of auto-scroll + setNewMsgTip(prev => ({ ...prev, [targetSessionId]: assistantMsgId })); + } + } else { + setPendingScrollToLastUser(prev => ({ ...prev, [targetSessionId]: assistantMsgId })); + setUnreadSessions(prev => [...new Set([...prev, targetSessionId])]); + } + + break; + } + const chunk = decoder.decode(value, { stream: true }); + fullReply += chunk; + const messageElement = document.getElementById(assistantMsgId)?.firstChild; + if (messageElement) { + messageElement.innerHTML = markdownToHTML(fullReply); + } + // Keep sticky-bottom *only* when streaming in the active chat and user is at/near bottom. + // This restores the old "push down while generating" behavior without fighting user scrolls. + if ( + activeSessionIdRef.current === targetSessionId && + !userScrolledUpRef.current[targetSessionId] + ) { + // use 'auto' so it stays snappy during streaming + scrollToBottom('auto', targetSessionId); + } + // If streaming in a background chat, prepare a one-time guided scroll + if (activeSessionIdRef.current !== targetSessionId && !pendingMarked) { + setPendingScrollToLastUser(prev => ({ ...prev, [targetSessionId]: assistantMsgId })); + pendingMarked = true; + } + } + } catch (e) { + console.error("Failed to send message:", e); + const errorMsg = { role: 'assistant', content: 'Error: ' + e.message, id: `msg-${Date.now()}-${Math.random()}` }; + setChatSessions(prevSessions => + prevSessions.map(session => + session.session_id === targetSessionId + ? { ...session, messages: [...session.messages.slice(0, -1), errorMsg] } + : session + ) + ); + } finally { + setIsSending(false); + } + })(); + } else { + const res = await fetch(`${ollamaApiUrl}/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + session_id: targetSessionId, + model, + message: userMsg.content, + stream: false + }) + }); + const data = await res.json(); + const assistantMsgId = `msg-${Date.now()}`; + const assistantMsg = { role: 'assistant', content: data.reply, id: assistantMsgId }; + + setChatSessions(prevSessions => + prevSessions.map(session => + session.session_id === targetSessionId + ? { ...session, messages: [...(session.messages || []), assistantMsg] } + : session + ) + ); + + // For non-stream: align new ASSISTANT message to top, unless user scrolled away + if (assistantMsgId) { + if (activeSessionIdRef.current === targetSessionId) { + if (!userScrolledUpRef.current[targetSessionId]) { + requestAnimationFrame(() => scrollMessageToTop(assistantMsgId, 'smooth', targetSessionId)); + } else { + // <<< show the tip if user scrolled away while waiting >>> + setNewMsgTip(prev => ({ ...prev, [targetSessionId]: assistantMsgId })); + } + } else { + setPendingScrollToLastUser(prev => ({ ...prev, [targetSessionId]: assistantMsgId })); + } + } + setIsSending(false); + } - // Handle unread status: only set if the chat was NOT active when the message was sent if (activeSessionIdRef.current !== targetSessionId) { setUnreadSessions(prev => [...new Set([...prev, targetSessionId])]); } - // Generate title if new chat if (isNewChat) { fetch(`${ollamaApiUrl}/generate-title`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: targetSessionId, - message: userMsg.content + message: userMsg.content, + model: model }) }) .then(r => r.json()) @@ -258,15 +587,15 @@ export default function App() { } } catch (e) { console.error("Failed to send message:", e); - // Add error message to chat - const errorMsg = { role: 'assistant', content: 'Error: ' + e.message }; + const errorMsg = { role: 'assistant', content: 'Error: ' + e.message, id: `msg-${Date.now()}-${Math.random()}` }; setChatSessions(prevSessions => prevSessions.map(session => session.session_id === targetSessionId - ? { ...session, messages: [...(session.messages || []), errorMsg] } + ? { ...session, messages: [...session.messages, errorMsg] } : session ) ); + setIsSending(false); } } @@ -286,7 +615,43 @@ export default function App() { function selectChat(sessionId) { setActiveSessionId(sessionId); + // Clear unread dot immediately for this chat setUnreadSessions(prev => prev.filter(id => id !== sessionId)); + + // If we had queued a guided scroll for this chat (from background replies), run it now, smoothly + const pendingId = pendingScrollToLastUser[sessionId]; + if (pendingId) { + // Defer until the chat content renders; restoration is gated by restoredForRef, so won't fight + requestAnimationFrame(() => { + let tries = 12; // ~200ms @ 60fps + const attempt = () => { + const chatDiv = chatRef.current; + if (!chatDiv) return; + + let el = document.getElementById(pendingId); + if (!el) { + const sess = chatSessions.find(s => s.session_id === sessionId); + if (sess && Array.isArray(sess.messages)) { + for (let i = sess.messages.length - 1; i >= 0; i--) { + const m = sess.messages[i]; + if (m.role === 'assistant' && m.id) { el = document.getElementById(m.id); break; } + } + } + } + + if (el) { + scrollMessageToTop(el.id, 'smooth', sessionId); + setPendingScrollToLastUser(prev => { + const { [sessionId]: _omit, ...rest } = prev; + return rest; + }); + } else if (tries-- > 0) { + requestAnimationFrame(attempt); + } + }; + requestAnimationFrame(attempt); + }); + } } function handleRename(sessionId, newName) { @@ -334,19 +699,19 @@ export default function App() {
setActiveSidebarMode('chats')} + onClick={() => handleSidebarClick('chats')} > Chats
setActiveSidebarMode('dbs')} + onClick={() => handleSidebarClick('dbs')} > DBs
setActiveSidebarMode('settings')} + onClick={() => handleSidebarClick('settings')} > Settings
@@ -358,7 +723,7 @@ export default function App() {
selectChat(session.session_id)} + onClick={() => handleSelectChat(session.session_id)} > {editingSessionId === session.session_id ? ( Chat - {chatSessions.find(s => s.session_id === activeSessionId)?.name || 'New Chat'}
-
+
{messages.map((m, i) => ( -
+
))}
+ {/* New message tip (active chat only) */} + {newMsgTip[activeSessionId] && ( + + )} +
- +
@@ -473,7 +852,13 @@ export default function App() {
{activeSettingsSubmenu} Settings
- {activeSettingsSubmenu === 'General' && } + {activeSettingsSubmenu === 'General' && ( + + )} {activeSettingsSubmenu === 'Interface' && } )} diff --git a/src/GeneralSettings.jsx b/src/GeneralSettings.jsx index 1936a67..d1cc59b 100644 --- a/src/GeneralSettings.jsx +++ b/src/GeneralSettings.jsx @@ -2,17 +2,19 @@ import React, { useState, useEffect } from 'react'; const API_URL_KEY = 'ollamaApiUrl'; const MODEL_KEY = 'chatModel'; +const STREAM_KEY = 'streamOutput'; -export default function GeneralSettings() { +export default function GeneralSettings({ onModelChange, onStreamOutputChange }) { const [ollamaApiUrl, setOllamaApiUrl] = useState(''); const [models, setModels] = useState([]); const [selectedModel, setSelectedModel] = useState(''); + const [streamOutput, setStreamOutput] = useState(false); useEffect(() => { window.electronAPI.getSettings().then(settings => { setOllamaApiUrl(settings.ollamaApiUrl); - // Set selectedModel from settings, or fallback to an empty string if not found setSelectedModel(settings.chatModel || ''); + setStreamOutput(settings.streamOutput || false); }); }, []); @@ -44,6 +46,18 @@ export default function GeneralSettings() { const newModel = e.target.value; setSelectedModel(newModel); window.electronAPI.setSetting(MODEL_KEY, newModel); + if (onModelChange) { + onModelChange(newModel); + } + }; + + const handleStreamToggle = () => { + const newStreamValue = !streamOutput; + setStreamOutput(newStreamValue); + window.electronAPI.setSetting(STREAM_KEY, newStreamValue); + if (onStreamOutputChange) { + onStreamOutputChange(newStreamValue); + } }; return ( @@ -69,6 +83,17 @@ export default function GeneralSettings() { {models.map(m => )}
+
+

Stream Output

+ +
); } diff --git a/src/InterfaceSettings.jsx b/src/InterfaceSettings.jsx index 6923ff3..40d88f8 100644 --- a/src/InterfaceSettings.jsx +++ b/src/InterfaceSettings.jsx @@ -1,52 +1,8 @@ import React, { useState, useEffect } from 'react'; +import { colorSchemes, applyColorScheme } from './colorSchemes'; const COLOR_SCHEME_KEY = 'colorScheme'; -const colorSchemes = { - 'Default': { - '--bg': '#0b1020', - '--panel': '#141b34', - '--text': '#e6e8ef', - '--muted': '#9aa3b2', - '--accent': '#6ea8fe', - '--border': '#24304f', - '--input-bg': '#0e1530', - '--user-msg-bg': '#111933', - '--assistant-msg-bg': '#101927', - }, - 'Grayscale': { - '--bg': '#1a1a1a', - '--panel': '#2a2a2a', - '--text': '#f0f0f0', - '--muted': '#aaaaaa', - '--accent': '#888888', - '--border': '#4a4a4a', - '--input-bg': '#202020', - '--user-msg-bg': '#333333', - '--assistant-msg-bg': '#252525', - }, - 'Rose': { - '--bg': '#200a10', - '--panel': '#301a20', - '--text': '#ffe0e0', - '--muted': '#a09090', - '--accent': '#E91E63', - '--border': '#402025', - '--input-bg': '#2a1015', - '--user-msg-bg': '#331119', - '--assistant-msg-bg': '#271019', - }, -}; - -function applyColorScheme(schemeName) { - const scheme = colorSchemes[schemeName]; - if (scheme) { - for (const [key, value] of Object.entries(scheme)) { - document.documentElement.style.setProperty(key, value); - } - } -} - export default function InterfaceSettings() { const [selectedColorScheme, setSelectedColorScheme] = useState('Default'); diff --git a/src/colorSchemes.js b/src/colorSchemes.js new file mode 100644 index 0000000..3e0dc2c --- /dev/null +++ b/src/colorSchemes.js @@ -0,0 +1,78 @@ +const colorSchemes = { + 'Nightsky': { + '--bg': '#0a0e1a', + '--panel': '#18203a', + '--text': '#ffffff', + '--muted': '#aab5c4', + '--accent': '#4a90e2', + '--border': '#304060', + '--input-bg': '#121a35', + '--user-msg-bg': '#1a2545', + '--assistant-msg-bg': '#15203a', + '--active-bg': 'rgba(74, 144, 226, 0.15)', + '--hover-bg': 'rgba(255, 255, 255, 0.05)', + }, + 'Grayscale': { + '--bg': '#1a1a1a', + '--panel': '#2a2a2a', + '--text': '#f0f0f0', + '--muted': '#aaaaaa', + '--accent': '#888888', + '--border': '#4a4a4a', + '--input-bg': '#202020', + '--user-msg-bg': '#333333', + '--assistant-msg-bg': '#252525', + '--active-bg': 'rgba(136, 136, 136, 0.15)', + '--hover-bg': 'rgba(255, 255, 255, 0.05)', + }, + 'Japan': { + '--bg': '#ffffff', + '--panel': '#f5f5f5', + '--text': '#000000', + '--muted': '#444444', + '--accent': '#e74c3c', /* Vibrant Red */ + '--border': '#999999', + '--input-bg': '#ffffff', + '--user-msg-bg': '#f0f0f0', + '--assistant-msg-bg': '#f0f0f0', + '--active-bg': 'rgba(231, 76, 60, 0.15)', /* Light red for active */ + '--hover-bg': 'rgba(231, 76, 60, 0.08)', /* Lighter red for hover */ + }, + 'Lime': { + '--bg': '#f0fff0', + '--panel': '#e0ffe0', + '--text': '#1a1a1a', + '--muted': '#72a272ff', + '--accent': '#8e9f38ff', + '--border': '#a0c0a0', + '--input-bg': '#ffffff', + '--user-msg-bg': '#f8f7adff', + '--assistant-msg-bg': '#f5fff5', + '--active-bg': 'rgba(104, 159, 56, 0.2)', + '--hover-bg': 'rgba(104, 159, 56, 0.1)', + }, + 'Vampire': { + '--bg': '#1a050a', + '--panel': '#2a1015', + '--text': '#ffefff', + '--muted': '#c0a0a0', + '--accent': '#d81b60', + '--border': '#4a2025', + '--input-bg': '#200a10', + '--user-msg-bg': '#331119', + '--assistant-msg-bg': '#271019', + '--active-bg': 'rgba(216, 27, 96, 0.15)', + '--hover-bg': 'rgba(255, 255, 255, 0.05)', + }, +}; + +function applyColorScheme(schemeName) { + const scheme = colorSchemes[schemeName]; + if (scheme) { + for (const [key, value] of Object.entries(scheme)) { + document.documentElement.style.setProperty(key, value); + } + } +} + +export { colorSchemes, applyColorScheme }; diff --git a/src/main.jsx b/src/main.jsx index 482e2cf..de4c1e0 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,17 +1,30 @@ -import React from 'react' +import React, { useEffect } from 'react' import { createRoot } from 'react-dom/client' import { HashRouter as Router, Routes, Route } from 'react-router-dom' import App from './App.jsx' import './styles.css' +import { applyColorScheme } from './colorSchemes' + +function Main() { + useEffect(() => { + window.electronAPI.getSettings().then(settings => { + if (settings.colorScheme) { + applyColorScheme(settings.colorScheme) + } + }) + }, []) + + return ( + + + + } /> + + + + ) +} const root = createRoot(document.getElementById('root')) -root.render( - - - - } /> - - - -) +root.render(
) diff --git a/src/markdown.js b/src/markdown.js index 7a0041f..a5f50cb 100644 --- a/src/markdown.js +++ b/src/markdown.js @@ -11,8 +11,8 @@ export function markdownToHTML(text) { // 2) Extract code blocks and replace with placeholders const codeblocks = []; const placeholder = idx => `@@CODEBLOCK${idx}@@`; - tmp = tmp.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => { - codeblocks.push({ lang, code }); + tmp = tmp.replace(/```([^\n]*)\n([\s\S]*?)```/g, (_, lang, code) => { + codeblocks.push({ lang: (lang || '').trim(), code }); return placeholder(codeblocks.length - 1); }); @@ -29,6 +29,12 @@ export function markdownToHTML(text) { .replace(/^## (.+)$/gm, "

$1

") .replace(/^# (.+)$/gm, "

$1

"); + // 4.2) Remove blank empty lines immediately after headings + escaped = escaped.replace( + /(.*?<\/h[1-4]>)[ \t]*\n(?:[ \t]*\n)+/g, + "$1\n" + ); + // 4.5) Unordered lists escaped = escaped.replace( /(^|\n)([ \t]*\* .+(?:\n[ \t]*\* .+)*)/g, @@ -42,26 +48,90 @@ export function markdownToHTML(text) { } ); + // 4.6) Markdown tables (GitHub-style). Strict: requires header, separator, ≥2 cols. + const mdTableBlockRe = + /(^\|[^\n]*\|?\s*\n\|\s*[:\-]+(?:\s*\|\s*[:\-]+)+\s*\|?\s*\n(?:\|[^\n]*\|?\s*(?:\n|$))*)/gm; + + escaped = escaped.replace(mdTableBlockRe, (block) => { + // Preserve trailing newline so '^---$' can match next line + const hadTrailingNewline = /\n$/.test(block); + const lines = block.replace(/\n$/, '').split('\n'); + + const split = (line) => line.replace(/^\||\|$/g, '').split('|').map(s => s.trim()); + + const headers = split(lines[0]); + const seps = split(lines[1]); + if (headers.length < 2 || seps.length < 2) return block; + if (!seps.every(s => /^[ :\-]+$/.test(s) && /-/.test(s))) return block; + + // Alignment per column (default left). Vertical-align top by default. + const aligns = seps.map(seg => { + const s = seg.replace(/\s+/g,''); + const left = s.startsWith(':'); + const right = s.endsWith(':'); + if (left && right) return 'center'; + if (right) return 'right'; + return 'left'; + }); + + const bodyLines = lines.slice(2).filter(l => /^\|/.test(l.trim())); + const cellStyle = (i) => + ` style="text-align:${aligns[i] || 'left'};vertical-align:top;border:1px solid #e5e7eb;padding:.6rem .75rem"`; + + const ths = headers.map((h,i)=>`${h}`).join(''); + const rows = bodyLines.map(line => { + const cells = split(line); + const tds = cells.map((c,i)=>`${c}`).join(''); + return `${tds}`; + }).join(''); + + // Wrapper: rounded outer border + top/bottom spacing; inner cells keep their own borders. + const wrapperOpen = `
`; + const table = `${ths}${rows}
`; + + return wrapperOpen + table + (hadTrailingNewline ? '\n' : ''); + }); + + // 4.75) Horizontal rules + escaped = escaped.replace(/^---\s*$/gm, "
"); + // 5) Bold, italic, inline code let html = escaped .replace(/\*\*(.+?)\*\*/g, "$1") .replace(/(?$1") .replace(/`(.+?)`/g, "$1"); - // 6) Restore code blocks + // 5.5) Links + html = html.replace(/\[([^\]]+?)\]\(([^)]+?)\)/g, '$1 $2'); + + // 6) Restore code blocks with title bar (language) html = html.replace(/@@CODEBLOCK(\d+)@@/g, (_, idx) => { const { lang, code } = codeblocks[+idx]; + const title = (lang && lang.trim()) ? lang.trim() : 'code'; const escapedCode = code.replace(//g, ">"); - return `
${escapedCode}
`; + const head = `
${title}
`; + const body = `
${escapedCode}
`; + return `
${head}${body}
`; }); // 7) Convert line-breaks to
html = html.replace(/\n/g, "
"); - // 8) Cleanup stray
immediately before/after lists + // 8) Cleanup stray
around lists/tables/wrappers html = html .replace(/
\s*(
    )/g, "$1") - .replace(/(<\/ul>)\s*
    /g, "$1"); + .replace(/(<\/ul>)\s*
    /g, "$1") + .replace(/
    \s*(
    ]*>)/g, "$1") + .replace(/(<\/div>)\s*
    /g, "$1") + .replace(/
    \s*(
    ]*>)/g, "$1") + .replace(/(<\/div>)\s*
    /g, "$1") + .replace(/
    \s*(]*>)/g, "$1") + .replace(/(<\/table>)\s*
    /g, "$1"); + + // 9) Trim spaces/tabs and remove empty newline(s) immediately after
    + html = html + .replace(/(
    )[ \t]+/g, "$1") // remove spaces/tabs + .replace(/(
    )(?:[ \t]*
    )+/g, "$1"); // remove one or more blank lines (now
    ) after
    return html; -} +} \ No newline at end of file diff --git a/src/styles.css b/src/styles.css index 5b25968..5808d48 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,4 +1,4 @@ - +/* /Users/giers/Heimgeist/src/styles.css */ :root { --bg: #0b1020; --panel: #141b34; @@ -19,7 +19,7 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy .app { display: grid; - grid-template-columns: var(--sidebar-width, 280px) 1fr; /* Sidebar width and main content */ + grid-template-columns: var(--sidebar-width, 230px) 1fr; /* Sidebar width and main content */ grid-template-rows: 1fr; height: 100%; } @@ -34,6 +34,10 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy position: relative; /* For resizer positioning */ } +.sidebar span{ + font-size: 14px; +} + .resizer { width: 13px; /* 8px original + 5px to the right */ cursor: ew-resize; @@ -50,28 +54,31 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy display: flex; justify-content: space-around; padding: 0; /* Remove padding to allow tabs to fill */ - border-bottom: 1px solid var(--border); background: var(--panel); } .sidebar-tab { flex-grow: 1; text-align: center; - padding: 12px 16px; + padding: 11px 16px; cursor: pointer; border-bottom: 3px solid transparent; /* For active indicator */ transition: background-color 0.2s ease; } .sidebar-tab:hover { - background-color: rgba(255, 255, 255, 0.05); + background-color: var(--hover-bg); } .sidebar-tab.active { - background-color: rgba(255, 255, 255, 0.1); + background-color: var(--active-bg); border-bottom-color: var(--accent); } +.sidebar-tab.active:hover { + background-color: var(--active-bg); +} + .sidebar-content { flex-grow: 1; overflow-y: auto; @@ -94,12 +101,16 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy } .settings-item.active { - background: rgba(255, 255, 255, 0.1); + background: var(--active-bg); border-left-color: var(--accent); } +.settings-item.active:hover { + background: var(--active-bg); +} + .settings-item:hover { - background: rgba(255, 255, 255, 0.05); + background: var(--hover-bg); } .settings-footer-placeholder { @@ -122,6 +133,7 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy opacity: 0.9; } + .chat-list { overflow-y: auto; padding: 8px 0; @@ -188,12 +200,16 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy } .chat-item.active { - background: rgba(255, 255, 255, 0.1); + background: var(--active-bg); border-left-color: var(--accent); } .chat-item:hover { - background: rgba(255, 255, 255, 0.05); + background: var(--hover-bg); +} + +.chat-item.active:hover { + background: var(--active-bg); } .rename-input { @@ -226,7 +242,6 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy .sidebar-footer { padding: 12px 16px; - border-top: 1px solid var(--border); background: var(--panel); } @@ -244,6 +259,7 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy } .main-content { + position: relative; display: grid; grid-template-rows: auto 1fr auto; height: 100vh; /* Use viewport height to prevent window scrolling */ @@ -293,7 +309,7 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy padding: 12px 14px; border-radius: 12px; line-height: 1.5; - white-space: pre-wrap; + white-space: wrap; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Inter, Helvetica, Arial; /* Match body font */ } @@ -307,13 +323,37 @@ textarea.input { .msg.user { background: var(--user-msg-bg); margin-left: auto; /* Right-align user messages */ - max-width: 800px; + max-width: 80%; border: 1px solid var(--border); + margin-right: 5px; } .msg.assistant { background: transparent; border: none; max-width: none; /* Remove max-width for assistant messages */ + animation: fadeIn 0.3s ease-in-out; +} + +/* new message pill */ +.new-msg-tip { + position: absolute; + right: 24px; + bottom: 84px; /* sits just above the footer */ + padding: 8px 12px; + border-radius: 9999px; + background: var(--accent); + color: var(--bg); + border: none; + cursor: pointer; + font-weight: 600; + box-shadow: 0 6px 20px rgba(0,0,0,.35); +} + +.new-msg-tip:hover { opacity: .9; } + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } } .footer { @@ -422,3 +462,124 @@ textarea.input { padding: 0; background-color: transparent; } + +.msg a { + color: var(--text); /* White color */ + display: inline-flex; + align-items: center; + gap: 4px; + position: relative; + text-decoration: underline dotted; + text-underline-offset: 3px; +} + +.msg a span:first-child { +} + +.msg a .tooltip { + visibility: hidden; + width: auto; + background-color: var(--panel); + color: var(--text); + text-align: center; + border-radius: 6px; + padding: 5px 10px; + position: absolute; + z-index: 1; + bottom: 110%; + left: 50%; + transform: translateX(-50%); + opacity: 0; + transition: opacity 0.3s; + white-space: nowrap; +} + +.msg a:hover .tooltip { + visibility: visible; + opacity: 1; +} + +.msg a:hover { + color: var(--accent); +} + +.msg a svg { + width: 14px; + height: 14px; + stroke: var(--text); + transition: stroke 0.2s ease; +} + +.msg a:hover svg { + stroke: var(--accent); +} + +.msg hr { + border: none; + border-top: 1px solid var(--border); + margin: 20px 0; +} + +/* Toggle Switch Styles */ +.toggle-switch { + position: relative; + display: inline-block; + width: 50px; + height: 28px; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--input-bg); + border: 1px solid var(--border); + transition: .4s; + border-radius: 28px; +} + +.slider:before { + position: absolute; + content: ""; + height: 20px; + width: 20px; + left: 3px; + bottom: 3px; + background-color: var(--muted); + transition: .4s; + border-radius: 50%; +} + +input:checked + .slider { + background-color: var(--accent); +} + +input:checked + .slider:before { + transform: translateX(22px); + background-color: var(--panel); +} + +/* Spinner Styles */ +.spinner { + border: 3px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + border-top: 3px solid var(--accent); + width: 20px; + height: 20px; + animation: spin 1s linear infinite; + margin: 0 auto; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +}