commit 191fe98ac00a337e93d8a8fe9b0f285e4234a2a4 Author: Victor Giers Date: Fri Aug 22 23:42:34 2025 +0200 initial commit diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..cbd8c5b --- /dev/null +++ b/backend/database.py @@ -0,0 +1,14 @@ + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, DeclarativeBase + +DATABASE_URL = "sqlite:///./backend/app.db" + +engine = create_engine( + DATABASE_URL, connect_args={"check_same_thread": False} +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +class Base(DeclarativeBase): + pass diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..d9e019e --- /dev/null +++ b/backend/main.py @@ -0,0 +1,137 @@ +from fastapi import FastAPI, Depends, HTTPException +from fastapi.middleware.cors import CORSMiddleware +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 + +# Create tables +Base.metadata.create_all(bind=engine) + +app = FastAPI(title="LLM Desktop Backend", version="0.1.0" ) + +# CORS (dev-friendly; tighten later) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + +@app.get("/health") +def health(): + return {"ok": True} + +@app.get("/models") +async def get_models(): + try: + data = await ollama_list() + return {"models": [{"name": n} for n in data.get("models", [])]} + except Exception as e: + raise HTTPException(status_code=502, detail=f"Ollama not available: {e}") + +@app.get("/sessions", response_model=schemas.SessionsResponse) +def get_sessions(db: Session = Depends(get_db)): + sessions = db.query(models.ChatSession).order_by(models.ChatSession.created_at.desc()).all() + return {"sessions": sessions} + +@app.post("/sessions", response_model=schemas.ChatSession) +def create_session(req: schemas.CreateSessionRequest, db: Session = Depends(get_db)): + new_session = models.ChatSession(session_id=req.session_id) + db.add(new_session) + db.commit() + db.refresh(new_session) + return new_session + +@app.get("/history", response_model=schemas.HistoryResponse) +def history(session_id: str, db: Session = Depends(get_db)): + session = db.query(models.ChatSession).filter(models.ChatSession.session_id == session_id).first() + if not session: + return {"messages": []} + rows = db.query(models.ChatMessage) .filter(models.ChatMessage.session_pk == session.id) .order_by(models.ChatMessage.created_at.asc()) .all() + msgs = [{"role": r.role, "content": r.content} for r in rows] + return {"messages": msgs} + +@app.post("/chat", response_model=schemas.ChatResponse) +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() + if not session: + session = models.ChatSession(session_id=req.session_id) + db.add(session) + db.commit() + db.refresh(session) + + # Save user message + user_row = models.ChatMessage(session_pk=session.id, role='user', content=req.message) + db.add(user_row) + db.commit() + + # Build minimal conversation context (last 20 messages) + last_msgs = db.query(models.ChatMessage) .filter(models.ChatMessage.session_pk == session.id) .order_by(models.ChatMessage.created_at.asc()) .all()[-20:] + + 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}") + + # 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)): + session = db.query(models.ChatSession).filter(models.ChatSession.session_id == req.session_id).first() + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + 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}]) + except Exception as e: + raise HTTPException(status_code=502, detail=f"Ollama error: {e}") + + session.name = title + db.commit() + + return {"title": title} + +@app.delete("/sessions/{session_id}") +def delete_session(session_id: str, db: Session = Depends(get_db)): + session = db.query(models.ChatSession).filter(models.ChatSession.session_id == session_id).first() + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + # Delete associated messages + db.query(models.ChatMessage).filter(models.ChatMessage.session_pk == session.id).delete() + + db.delete(session) + db.commit() + return {"ok": True} + +@app.put("/sessions/{session_id}/rename") +def rename_session(session_id: str, req: schemas.GenerateTitleResponse, db: Session = Depends(get_db)): + session = db.query(models.ChatSession).filter(models.ChatSession.session_id == session_id).first() + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + session.name = req.title + db.commit() + return {"ok": True} + +# To run standalone: python -m uvicorn backend.main:app --host 127.0.0.1 --port 8000 diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..b886467 --- /dev/null +++ b/backend/models.py @@ -0,0 +1,25 @@ +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey +from sqlalchemy.orm import relationship +from datetime import datetime +from .database import Base + +class ChatSession(Base): + __tablename__ = 'chat_sessions' + + id = Column(Integer, primary_key=True, index=True) + session_id = Column(String(64), unique=True, index=True, nullable=False) + name = Column(String(100), default="New Chat", nullable=False) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + messages = relationship("ChatMessage", back_populates="session") + +class ChatMessage(Base): + __tablename__ = 'chat_messages' + + id = Column(Integer, primary_key=True, index=True) + session_pk = Column(Integer, ForeignKey('chat_sessions.id'), nullable=False) + role = Column(String(16), nullable=False) # 'user' | 'assistant' + content = Column(Text, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + session = relationship("ChatSession", back_populates="messages") diff --git a/backend/ollama_client.py b/backend/ollama_client.py new file mode 100644 index 0000000..ae5dd5c --- /dev/null +++ b/backend/ollama_client.py @@ -0,0 +1,34 @@ + +import httpx +from typing import Dict, Any, List + +OLLAMA_URL = "http://127.0.0.1:11434" + +async def list_models() -> Dict[str, Any]: + async with httpx.AsyncClient(timeout=30.0) as client: + r = await client.get(f"{OLLAMA_URL}/api/tags") + r.raise_for_status() + data = r.json() + # Normalize to a simple list of names + models = [m.get('name') for m in data.get('models', [])] + return {"models": models} + +async def chat(model: str, messages: List[Dict[str, str]]) -> str: + payload = { + "model": model, + "messages": messages, + "stream": False + } + async with httpx.AsyncClient(timeout=600.0) as client: + r = await client.post(f"{OLLAMA_URL}/api/chat", json=payload) + r.raise_for_status() + data = r.json() + # Ollama returns full conversation; pick last message content + try: + return data["message"]["content"] + except Exception: + # Newer Ollama formats may return messages list + msgs = data.get("messages") or [] + if msgs: + return msgs[-1].get("content", "") + return data.get("content", "") diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..f5cdd04 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,6 @@ + +fastapi==0.111.0 +uvicorn[standard]==0.30.1 +SQLAlchemy==2.0.32 +httpx==0.27.0 +pydantic==2.7.4 diff --git a/backend/schemas.py b/backend/schemas.py new file mode 100644 index 0000000..950424a --- /dev/null +++ b/backend/schemas.py @@ -0,0 +1,40 @@ +from pydantic import BaseModel +from typing import List, Optional +from datetime import datetime + +class Message(BaseModel): + role: str + content: str + +class ChatRequest(BaseModel): + session_id: str + model: str + message: str + +class ChatResponse(BaseModel): + reply: str + +class HistoryResponse(BaseModel): + messages: List[Message] + +class GenerateTitleRequest(BaseModel): + session_id: str + message: str + +class GenerateTitleResponse(BaseModel): + title: str + +class CreateSessionRequest(BaseModel): + session_id: str + +class ChatSession(BaseModel): + id: int + session_id: str + name: str + created_at: datetime + + class Config: + orm_mode = True + +class SessionsResponse(BaseModel): + sessions: List[ChatSession] diff --git a/electron/main.cjs b/electron/main.cjs new file mode 100644 index 0000000..1a90f69 --- /dev/null +++ b/electron/main.cjs @@ -0,0 +1,171 @@ + +const { app, BrowserWindow, Menu, ipcMain } = require('electron') +const path = require('path') +const { is } = require('@electron-toolkit/utils') +const fs = require('fs') + +let mainWindow +let settingsWindow = null + +const settingsFilePath = path.join(app.getPath('userData'), 'settings.json') +let appSettings = {} + +// Default settings +const defaultSettings = { + ollamaApiUrl: 'http://127.0.0.1:8000', + colorScheme: 'Default', + chatModel: 'llama3' // Set a default model here +} + +function loadSettings() { + try { + if (fs.existsSync(settingsFilePath)) { + const data = fs.readFileSync(settingsFilePath, 'utf8') + appSettings = { ...defaultSettings, ...JSON.parse(data) } + } else { + appSettings = { ...defaultSettings } + saveSettings() // Create the file with default settings + } + } catch (error) { + console.error('Failed to load settings:', error) + appSettings = { ...defaultSettings } + } +} + +function saveSettings() { + try { + fs.writeFileSync(settingsFilePath, JSON.stringify(appSettings, null, 2), 'utf8') + } catch (error) { + console.error('Failed to save settings:', error) + } +} + +async function createMainWindow () { + mainWindow = new BrowserWindow({ + width: 1000, + height: 720, + show: false, + webPreferences: { + preload: path.join(__dirname, 'preload.cjs'), + contextIsolation: true, + nodeIntegration: false + } + }) + + mainWindow.on('ready-to-show', () => { + mainWindow.show() + }) + + if (is.dev && process.env.VITE_DEV_SERVER_URL) { + await mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL) + mainWindow.webContents.openDevTools({ mode: 'detach' }) + } else { + await mainWindow.loadFile(path.join(__dirname, '../dist/index.html')) + } +} + +async function createSettingsWindow () { + if (settingsWindow) { + settingsWindow.focus() + return + } + + settingsWindow = new BrowserWindow({ + width: 800, + height: 600, + title: 'Settings', + parent: mainWindow, + modal: true, + show: false, + webPreferences: { + preload: path.join(__dirname, 'preload.cjs'), + contextIsolation: true, + nodeIntegration: false + } + }) + + settingsWindow.on('ready-to-show', () => { + settingsWindow.show() + }) + + settingsWindow.on('closed', () => { + settingsWindow = null + }) + + if (is.dev && process.env.VITE_DEV_SERVER_URL) { + await settingsWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/settings`) + settingsWindow.webContents.openDevTools({ mode: 'detach' }) + } else { + await settingsWindow.loadFile(path.join(__dirname, '../dist/index.html'), { hash: '/settings' }) + } +} + +app.whenReady().then(() => { + loadSettings() // Load settings when the app is ready + createMainWindow() + + const menuTemplate = [ + { + label: 'File', + submenu: [ + { + label: 'Settings', + accelerator: 'CmdOrCtrl+,', + click: createSettingsWindow + }, + { type: 'separator' }, + { role: 'quit' } + ] + }, + { + label: 'Edit', + submenu: [ + { role: 'undo' }, + { role: 'redo' }, + { type: 'separator' }, + { role: 'cut' }, + { role: 'copy' }, + { role: 'paste' }, + { role: 'delete' }, + { type: 'separator' }, + { role: 'selectAll' } + ] + }, + { + label: 'View', + submenu: [ + { role: 'reload' }, + { role: 'forcereload' }, + { role: 'toggledevtools' }, + { type: 'separator' }, + { role: 'resetzoom' }, + { role: 'zoomin' }, + { role: 'zoomout' }, + { type: 'separator' }, + { role: 'togglefullscreen' } + ] + } + ] + + const menu = Menu.buildFromTemplate(menuTemplate) + Menu.setApplicationMenu(menu) + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) createMainWindow() + }) +}) + +// IPC handlers for settings +ipcMain.handle('get-settings', () => { + return appSettings +}) + +ipcMain.handle('set-setting', (event, key, value) => { + appSettings[key] = value + saveSettings() + return true +}) + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') app.quit() +}) diff --git a/electron/preload.cjs b/electron/preload.cjs new file mode 100644 index 0000000..c132863 --- /dev/null +++ b/electron/preload.cjs @@ -0,0 +1,8 @@ + +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) +}) diff --git a/index.html b/index.html new file mode 100644 index 0000000..1e54140 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + LLM Desktop + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..c8fc3e7 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "Heimgeist", + "version": "0.1.0", + "private": true, + "main": "electron/main.cjs", + "type": "module", + "scripts": { + "dev": "concurrently -k \"npm:dev:backend\" \"npm:dev:renderer\" \"npm:dev:electron\"", + "dev:backend": "python3 -m uvicorn backend.main:app --host 127.0.0.1 --port 8000 --reload", + "dev:renderer": "vite --port 5173 --strictPort", + "dev:electron": "wait-on http://localhost:5173 tcp:8000 && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 electron .", + "build": "vite build", + "start": "electron ." + }, + "dependencies": { + "@electron-toolkit/utils": "^4.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^7.8.1", + "react-textarea-autosize": "^8.5.9" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "concurrently": "^9.0.1", + "cross-env": "^7.0.3", + "electron": "^31.2.0", + "vite": "^5.4.2", + "wait-on": "^7.2.0" + } +} diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..ab0eba8 --- /dev/null +++ b/run.sh @@ -0,0 +1,6 @@ +#!/bin/sh +python -m venv backend/.venv +source backend/.venv/bin/activate +pip install -r backend/requirements.txt +npm install +npm run dev \ No newline at end of file diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..fbce7ce --- /dev/null +++ b/src/App.jsx @@ -0,0 +1,483 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react' +import TextareaAutosize from 'react-textarea-autosize'; +import GeneralSettings from './GeneralSettings' +import InterfaceSettings from './InterfaceSettings' +import { markdownToHTML } from './markdown'; + +const API_URL_KEY = 'ollamaApiUrl'; +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'; + +export default function App() { + const [chatSessions, setChatSessions] = useState([]) + const [activeSessionId, setActiveSessionId] = useState(null) + const [activeSidebarMode, setActiveSidebarMode] = useState('chats') // 'chats', 'dbs', 'settings' + const [activeSettingsSubmenu, setActiveSettingsSubmenu] = useState('General'); // 'General', 'Interface' + const [editingSessionId, setEditingSessionId] = useState(null); // ID of the session being edited + + // Use currentSessionId for the actual chat operations + const [model, setModel] = useState('') + const [input, setInput] = useState('') + const chatRef = useRef(null) + 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 [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 activeSessionIdRef = useRef(activeSessionId); + useEffect(() => { + activeSessionIdRef.current = activeSessionId; + }, [activeSessionId]); + + // Sidebar resizing state + const [sidebarWidth, setSidebarWidth] = useState(280); // Initial sidebar width + const [isResizing, setIsResizing] = useState(false); + + const startResizing = React.useCallback((mouseDownEvent) => { + setIsResizing(true); + }, []); + + const stopResizing = React.useCallback(() => { + setIsResizing(false); + }, []); + + const resizeSidebar = React.useCallback((mouseMoveEvent) => { + if (isResizing) { + const newWidth = Math.max(230, Math.min(500, mouseMoveEvent.clientX)); + setSidebarWidth(newWidth); + } + }, [isResizing]); + + React.useEffect(() => { + window.addEventListener('mousemove', resizeSidebar); + window.addEventListener('mouseup', stopResizing); + return () => { + window.removeEventListener('mousemove', resizeSidebar); + window.removeEventListener('mouseup', stopResizing); + }; + }, [resizeSidebar, stopResizing]); + + React.useEffect(() => { + if (isResizing) { + document.body.classList.add('no-select'); + } else { + document.body.classList.remove('no-select'); + } + }, [isResizing]); + + // Load settings on startup + useEffect(() => { + window.electronAPI.getSettings().then(settings => { + setOllamaApiUrl(settings.ollamaApiUrl); + setColorScheme(settings.colorScheme); + setModel(settings.chatModel || ''); // Load the selected model, with a fallback + applyColorScheme(settings.colorScheme); // Apply initial scheme + }); + }, []); + + // Apply color scheme whenever it changes + useEffect(() => { + applyColorScheme(colorScheme); + }, [colorScheme]); + + // Function to apply color scheme + 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); + } + } + } + + const fetchHistory = (sessionId) => { + if (!sessionId || !ollamaApiUrl) return; + fetch(`${ollamaApiUrl}/history?session_id=${encodeURIComponent(sessionId)}`) + .then(r => r.json()) + .then(data => { + setChatSessions(prevSessions => + prevSessions.map(session => + session.session_id === sessionId + ? { ...session, messages: data.messages || [] } + : session + ) + ); + }) + .catch(() => {}); + }; + + // Load chat sessions from backend on initial render + useEffect(() => { + if (!ollamaApiUrl) return; + setLoading(true); + fetch(`${ollamaApiUrl}/sessions`) + .then(r => r.json()) + .then(data => { + const sessionsWithMessages = data.sessions.map(s => ({ ...s, messages: [] })); + setChatSessions(sessionsWithMessages); + if (sessionsWithMessages.length > 0) { + setActiveSessionId(sessionsWithMessages[0].session_id); + } else { + setActiveSessionId(null); + } + setLoading(false); + }) + .catch(() => { + setLoading(false); + }); + }, [ollamaApiUrl]); + + // Load messages for the active session + useEffect(() => { + fetchHistory(activeSessionId); + }, [activeSessionId]); + + const messages = useMemo(() => { + return chatSessions.find(s => s.session_id === activeSessionId)?.messages || []; + }, [activeSessionId, chatSessions]); + + useEffect(() => { + chatRef.current?.scrollTo({ top: chatRef.current.scrollHeight, behavior: 'smooth' }); + }, [messages]); + + async function sendMessage() { + let targetSessionId = activeSessionId; + let isNewChat = false; + + if (!input.trim() || !model) return; + + if (!targetSessionId) { + const newSession = await createNewChat(); + 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; + } + } + 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(''); + + 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 }; + + // Update messages with assistant's reply + setChatSessions(prevSessions => + prevSessions.map(session => + session.session_id === targetSessionId + ? { ...session, messages: [...(session.messages || []), assistantMsg] } + : session + ) + ); + + // 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 + }) + }) + .then(r => r.json()) + .then(data => { + setChatSessions(prevSessions => + prevSessions.map(session => + session.session_id === targetSessionId ? { ...session, name: data.title } : session + ) + ); + }); + } + } catch (e) { + console.error("Failed to send message:", e); + // Add error message to chat + const errorMsg = { role: 'assistant', content: 'Error: ' + e.message }; + setChatSessions(prevSessions => + prevSessions.map(session => + session.session_id === targetSessionId + ? { ...session, messages: [...(session.messages || []), errorMsg] } + : session + ) + ); + } + } + + async function createNewChat() { + const newSessionId = 'sess-' + Math.random().toString(36).slice(2) + Date.now().toString(36); + const res = await fetch(`${ollamaApiUrl}/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: newSessionId }) + }); + const newSession = await res.json(); + const sessionWithMessages = { ...newSession, messages: [] }; + setChatSessions(prevSessions => [sessionWithMessages, ...prevSessions]); + setActiveSessionId(newSession.session_id); + return newSession; + } + + function selectChat(sessionId) { + setActiveSessionId(sessionId); + setUnreadSessions(prev => prev.filter(id => id !== sessionId)); + } + + function handleRename(sessionId, newName) { + fetch(`${ollamaApiUrl}/sessions/${sessionId}/rename`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: newName }) + }) + .then(() => { + setChatSessions(prevSessions => + prevSessions.map(session => + session.session_id === sessionId ? { ...session, name: newName } : session + ) + ); + setEditingSessionId(null); + }); + } + + function handleDelete(sessionId) { + fetch(`${ollamaApiUrl}/sessions/${sessionId}`, { method: 'DELETE' }) + .then(() => { + const newSessions = chatSessions.filter(s => s.session_id !== sessionId); + setChatSessions(newSessions); + if (activeSessionId === sessionId) { + setActiveSessionId(newSessions.length > 0 ? newSessions[0].session_id : null); + } + }); + } + + // Auto-delete empty "New Chat" sessions + useEffect(() => { + const emptyNewChats = chatSessions.filter( + s => s.name === "New Chat" && s.session_id !== activeSessionId && s.messages.length === 0 + ); + if (emptyNewChats.length > 0) { + emptyNewChats.forEach(chat => { + handleDelete(chat.session_id); + }); + } + }, [activeSessionId, chatSessions, ollamaApiUrl]); + + return ( +
+
+
+
setActiveSidebarMode('chats')} + > + Chats +
+
setActiveSidebarMode('dbs')} + > + DBs +
+
setActiveSidebarMode('settings')} + > + Settings +
+
+
+ {activeSidebarMode === 'chats' && ( +
+ {chatSessions.map(session => ( +
selectChat(session.session_id)} + > + {editingSessionId === session.session_id ? ( + setEditingSessionId(null)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleRename(session.session_id, e.target.value); + } else if (e.key === 'Escape') { + setEditingSessionId(null); + } + }} + autoFocus + /> + ) : ( + <> + {session.name} +
+ {unreadSessions.includes(session.session_id) &&
} + + +
+ + )} +
+ ))} +
+ )} + {activeSidebarMode === 'dbs' && ( +
+
No databases yet.
+
+ )} + {activeSidebarMode === 'settings' && ( +
+
setActiveSettingsSubmenu('General')} + > + General +
+
setActiveSettingsSubmenu('Interface')} + > + Interface +
+
+ )} +
+ {activeSidebarMode !== 'settings' && ( +
+ {activeSidebarMode === 'chats' && ( + + )} + {activeSidebarMode === 'dbs' && ( + + )} +
+ )} +
+
+
+ {activeSidebarMode === 'chats' && ( + <> +
+ Chat - {chatSessions.find(s => s.session_id === activeSessionId)?.name || 'New Chat'} +
+ +
+ {messages.map((m, i) => ( +
+
+
+ ))} +
+ +
+
+ setInput(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }} + placeholder="Ask any question..." + maxRows={13} + /> + +
+
+ + )} + {activeSidebarMode === 'dbs' && ( +
+

Databases

+

This is a placeholder for the database management view.

+
+ )} + {activeSidebarMode === 'settings' && ( + <> +
+ {activeSettingsSubmenu} Settings +
+ {activeSettingsSubmenu === 'General' && } + {activeSettingsSubmenu === 'Interface' && } + + )} +
+
+ ) +} diff --git a/src/GeneralSettings.jsx b/src/GeneralSettings.jsx new file mode 100644 index 0000000..1936a67 --- /dev/null +++ b/src/GeneralSettings.jsx @@ -0,0 +1,74 @@ +import React, { useState, useEffect } from 'react'; + +const API_URL_KEY = 'ollamaApiUrl'; +const MODEL_KEY = 'chatModel'; + +export default function GeneralSettings() { + const [ollamaApiUrl, setOllamaApiUrl] = useState(''); + const [models, setModels] = useState([]); + const [selectedModel, setSelectedModel] = useState(''); + + 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 || ''); + }); + }, []); + + useEffect(() => { + if (ollamaApiUrl) { + fetch(ollamaApiUrl + '/models') + .then(r => r.json()) + .then(data => { + const names = data.models?.map(m => m.name) || []; + setModels(names); + // If no model is selected or the selected model is no longer available, select the first one + if (!selectedModel || !names.includes(selectedModel)) { + const defaultModel = names[0] || ''; + setSelectedModel(defaultModel); + window.electronAPI.setSetting(MODEL_KEY, defaultModel); + } + }) + .catch(err => console.error('Failed to load models', err)); + } + }, [ollamaApiUrl, selectedModel]); // Depend on selectedModel to re-evaluate default selection + + const handleUrlChange = (e) => { + const newUrl = e.target.value; + setOllamaApiUrl(newUrl); + window.electronAPI.setSetting(API_URL_KEY, newUrl); + }; + + const handleModelChange = (e) => { + const newModel = e.target.value; + setSelectedModel(newModel); + window.electronAPI.setSetting(MODEL_KEY, newModel); + }; + + return ( +
+
+

Ollama API URL

+ +
+
+

Chat Model

+ +
+
+ ); +} diff --git a/src/InterfaceSettings.jsx b/src/InterfaceSettings.jsx new file mode 100644 index 0000000..6923ff3 --- /dev/null +++ b/src/InterfaceSettings.jsx @@ -0,0 +1,88 @@ +import React, { useState, useEffect } from 'react'; + +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'); + + useEffect(() => { + window.electronAPI.getSettings().then(settings => { + setSelectedColorScheme(settings.colorScheme); + applyColorScheme(settings.colorScheme); + }); + }, []); + + useEffect(() => { + applyColorScheme(selectedColorScheme); + }, [selectedColorScheme]); + + const handleColorSchemeChange = (e) => { + const newScheme = e.target.value; + setSelectedColorScheme(newScheme); + window.electronAPI.setSetting(COLOR_SCHEME_KEY, newScheme); + }; + + return ( +
+
+

Color Scheme

+ +
+
+ ); +} diff --git a/src/main.jsx b/src/main.jsx new file mode 100644 index 0000000..482e2cf --- /dev/null +++ b/src/main.jsx @@ -0,0 +1,17 @@ + +import React 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' + +const root = createRoot(document.getElementById('root')) +root.render( + + + + } /> + + + +) diff --git a/src/markdown.js b/src/markdown.js new file mode 100644 index 0000000..7a0041f --- /dev/null +++ b/src/markdown.js @@ -0,0 +1,67 @@ +export function markdownToHTML(text) { + // 0) Remove .../... blocks + text = text.replace( + /(^|\n)\s*[\s\S]*?<\/think(?:ing)?>\s*(\n\s*\n)?/gi, + (_, lead) => (lead ? '\n' : '') + ); + + // 1) Normalize line endings + let tmp = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + + // 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 }); + return placeholder(codeblocks.length - 1); + }); + + // 3) HTML-escape special characters + let escaped = tmp + .replace(/&/g, "&") + .replace(//g, ">"); + + // 4) Headings + escaped = escaped + .replace(/^#### (.+)$/gm, "

$1

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

$1

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

$1

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

$1

"); + + // 4.5) Unordered lists + escaped = escaped.replace( + /(^|\n)([ \t]*\* .+(?:\n[ \t]*\* .+)*)/g, + (_, lead, listBlock) => { + const items = listBlock + .split(/\n/) + .map(line => line.replace(/^[ \t]*\*\s+/, '').trim()) + .map(item => `
  • ${item}
  • `) + .join(''); + return `${lead}
      ${items}
    `; + } + ); + + // 5) Bold, italic, inline code + let html = escaped + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/(?$1") + .replace(/`(.+?)`/g, "$1"); + + // 6) Restore code blocks + html = html.replace(/@@CODEBLOCK(\d+)@@/g, (_, idx) => { + const { lang, code } = codeblocks[+idx]; + const escapedCode = code.replace(//g, ">"); + return `
    ${escapedCode}
    `; + }); + + // 7) Convert line-breaks to
    + html = html.replace(/\n/g, "
    "); + + // 8) Cleanup stray
    immediately before/after lists + html = html + .replace(/
    \s*(
      )/g, "$1") + .replace(/(<\/ul>)\s*
      /g, "$1"); + + return html; +} diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..5b25968 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,424 @@ + +:root { + --bg: #0b1020; + --panel: #141b34; + --text: #e6e8ef; + --muted: #9aa3b2; + --accent: #6ea8fe; + --border: #24304f; +} + +* { box-sizing: border-box; } +html, body, #root { height: 100%; margin: 0; } +body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Inter, Helvetica, Arial; } + +.no-select { + user-select: none; + cursor: ew-resize !important; +} + +.app { + display: grid; + grid-template-columns: var(--sidebar-width, 280px) 1fr; /* Sidebar width and main content */ + grid-template-rows: 1fr; + height: 100%; +} + +.sidebar { + display: grid; + grid-template-rows: auto 1fr auto; + background: var(--panel); + border-right: 1px solid var(--border); + height: 100vh; /* Use viewport height to prevent window scrolling */ + overflow: hidden; /* Prevent the entire sidebar from scrolling */ + position: relative; /* For resizer positioning */ +} + +.resizer { + width: 13px; /* 8px original + 5px to the right */ + cursor: ew-resize; + background: transparent; + position: absolute; + top: 0; + right: -5px; /* Adjust to center the wider resizer */ + bottom: 0; + z-index: 1; +} +/* Removed .resizer:hover as per user request */ + +.sidebar-header { + 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; + 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); +} + +.sidebar-tab.active { + background-color: rgba(255, 255, 255, 0.1); + border-bottom-color: var(--accent); +} + +.sidebar-content { + flex-grow: 1; + overflow-y: auto; +} + +.db-list, .settings-list { + padding: 8px 0; +} + +.empty-list-message { + padding: 10px 16px; + color: var(--muted); + text-align: center; +} + +.settings-item { + padding: 10px 16px; + cursor: pointer; + border-left: 3px solid transparent; /* For active indicator */ +} + +.settings-item.active { + background: rgba(255, 255, 255, 0.1); + border-left-color: var(--accent); +} + +.settings-item:hover { + background: rgba(255, 255, 255, 0.05); +} + +.settings-footer-placeholder { + height: 40px; /* Match button height for alignment */ + padding: 12px 16px; + border-top: 1px solid var(--border); + background: var(--panel); +} + +.new-db-button { + width: 100%; + padding: 10px; + background: var(--accent); + border-color: var(--accent); + color: var(--bg); + font-weight: bold; +} + +.new-db-button:hover { + opacity: 0.9; +} + +.chat-list { + overflow-y: auto; + padding: 8px 0; +} + +.chat-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 16px; + cursor: pointer; + border-left: 3px solid transparent; /* For active indicator */ + /* For truncating text */ + overflow: hidden; + white-space: nowrap; +} + +.chat-item span { + flex-grow: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-item-buttons { + display: flex; + gap: 8px; + align-items: center; +} + +.unread-dot { + width: 8px; + height: 8px; + background-color: red; + border-radius: 50%; + margin-right: 4px; +} + +.icon-button { + background: none; + border: none; + color: var(--muted); + cursor: pointer; + font-size: 16px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.icon-button svg { + width: 16px; + height: 16px; + stroke: var(--muted); + transition: stroke 0.2s ease; +} + +.icon-button:hover svg { + stroke: var(--accent); +} + +.icon-button:hover { + color: var(--accent); +} + +.chat-item.active { + background: rgba(255, 255, 255, 0.1); + border-left-color: var(--accent); +} + +.chat-item:hover { + background: rgba(255, 255, 255, 0.05); +} + +.rename-input { + background: var(--input-bg); + border: 1px solid var(--accent); + color: var(--text); + border-radius: 10px; + padding: 8px 12px; + outline: none; + width: 100%; +} + +/* Scrollbar Styles */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--muted); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--accent); +} + +.sidebar-footer { + padding: 12px 16px; + border-top: 1px solid var(--border); + background: var(--panel); +} + +.new-chat-button { + width: 100%; + padding: 10px; + background: var(--accent); + border-color: var(--accent); + color: var(--bg); + font-weight: bold; +} + +.new-chat-button:hover { + opacity: 0.9; +} + +.main-content { + display: grid; + grid-template-rows: auto 1fr auto; + height: 100vh; /* Use viewport height to prevent window scrolling */ + overflow: hidden; /* Prevent the entire main content from scrolling */ +} + +.header { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + background: var(--panel); +} + +.select, .input, .button { + background: var(--input-bg); + border: 1px solid var(--border); + color: var(--text); + border-radius: 10px; + padding: 8px 12px; + outline: none; +} + +.footer-content-wrapper .button { + flex-shrink: 0; /* Prevent the button from shrinking */ +} + +.select { min-width: 220px; } +.button { cursor: pointer; } +.button:hover { border-color: var(--accent); } + +.chat { + display: grid; + grid-template-columns: 1fr minmax(auto, 1000px) 1fr; + align-content: start; + gap: 8px; + padding: 16px; + overflow: auto; +} + +.chat > * { + grid-column: 2; +} + +.msg { + padding: 12px 14px; + border-radius: 12px; + line-height: 1.5; + white-space: pre-wrap; + font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Inter, Helvetica, Arial; /* Match body font */ +} + +textarea.input { + resize: none; /* Disable manual resizing */ + font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Inter, Helvetica, Arial; /* Match body font */ + overflow-y: auto; /* Add scrollbar when content exceeds max-height */ + flex-grow: 1; /* Allow textarea to take up available space */ +} + +.msg.user { + background: var(--user-msg-bg); + margin-left: auto; /* Right-align user messages */ + max-width: 800px; + border: 1px solid var(--border); +} +.msg.assistant { + background: transparent; + border: none; + max-width: none; /* Remove max-width for assistant messages */ +} + +.footer { + display: flex; /* Use flexbox for centering content */ + justify-content: center; /* Center the content horizontally */ + padding: 12px 16px; + border-top: 1px solid var(--border); + background: var(--panel); + align-items: flex-end; /* Align items to the bottom */ +} + +.footer-content-wrapper { + display: flex; /* Arrange textarea and button side-by-side */ + gap: 8px; /* Space between textarea and button */ + width: 100%; /* Take full width of its parent (footer) */ + max-width: 1000px; /* Max width for the content */ + align-items: flex-end; /* Align items to the bottom */ +} + +/* Settings Page Styles (removed nested sidebar styles) */ +/* The main .app grid and .sidebar will handle settings navigation */ + +.settings-content-panel { + padding: 20px; + overflow-y: auto; + height: 100%; /* Ensure it takes full height of its parent */ +} + +.settings-category { + margin-bottom: 30px; +} + +.settings-category h2 { + color: var(--accent); + margin-bottom: 15px; + font-size: 1.3em; +} + +.setting-section { + margin-bottom: 20px; + padding: 15px; /* Adjusted padding */ + border-bottom: 1px solid var(--border); + background-color: var(--panel); /* Background for setting sections */ + border-radius: 8px; +} + +.setting-section:last-child { + border-bottom: none; + margin-bottom: 0; +} + +.setting-section h3 { + color: var(--text); + margin-top: 0; + margin-bottom: 10px; + font-size: 1.1em; +} + +/* Re-apply general input/select styles for settings components */ +.settings-content-panel .input, +.settings-content-panel .select { + width: 100%; + max-width: 400px; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--border); + background-color: var(--input-bg); + color: var(--text); + font-size: 1em; +} + +.settings-content-panel .select { + min-width: unset; +} + +/* Markdown Styles */ +.msg h1, .msg h2, .msg h3, .msg h4 { + margin: 10px 0; + color: var(--accent); +} + +.msg ul { + padding-left: 20px; +} + +.msg li { + margin-bottom: 5px; +} + +.msg code { + background-color: var(--input-bg); + padding: 2px 4px; + border-radius: 4px; + font-family: monospace; +} + +.msg pre { + background-color: var(--input-bg); + padding: 10px; + border-radius: 8px; + overflow-x: auto; + white-space: pre-wrap; +} + +.msg pre code { + padding: 0; + background-color: transparent; +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..a5b450b --- /dev/null +++ b/vite.config.js @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + strictPort: true + }, + build: { + outDir: 'dist' + } +}) \ No newline at end of file