feat: Add streaming chat + scroll persistence; improve markdown & links

Backend
- /chat: support streaming via StreamingResponse; save full reply after stream ends. Non-stream path unchanged.
- ChatRequest: add stream flag (default false).
- GenerateTitleRequest: add model and use it instead of hardcoded llama3.
- ollama_client.chat_stream(): new async generator parsing Ollama streaming JSON (both formats).
- Remove response_model from /chat to allow streaming; non-stream still returns { reply }.

Electron
- Open external links in system browser (setWindowOpenHandler, shell.openExternal).
- New IPC: update-settings, open-external-link.
- Set minimum window size; preload exposes updateSettings and openExternalLink.

Frontend (React)
- Streaming UI with live chunking; sticky-bottom only when user at bottom.
- Per-session scroll persistence and robust restore.
- New message tip to jump to latest reply when scrolled up.
- Disable Send while sending; spinner.
- General Settings: stream output toggle; propagate model/stream changes.
- Apply color scheme at boot; extract colorSchemes helper.
- Sidebar UX tweaks and unread badges.

Markdown/rendering
- Code blocks: language title bar and wrapper.
- Tables: GitHub-style parsing, per-cell borders, rounded wrapper, spacing, alignment.
- Headings: remove blank line after h1-h4.
- <hr>: handle after tables; strip following whitespace.
- Links: target=_blank with icon and URL tooltip.

Styles
- Add styles for code/table wrappers, new-message tip, toggle, spinner; hover/active vars; narrower sidebar.

API notes / breaking changes
- /chat accepts stream=true and returns text/plain streamed chunks.
- generate-title now requires a model.
- Non-stream /chat response shape unchanged.
This commit is contained in:
2025-08-23 16:45:46 +02:00
parent 191fe98ac0
commit 41c69abe28
12 changed files with 897 additions and 144 deletions

View File

@@ -1,10 +1,11 @@
from fastapi import FastAPI, Depends, HTTPException from fastapi import FastAPI, Depends, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List from typing import List
from . import models, schemas from . import models, schemas
from .database import Base, engine, SessionLocal 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 # Create tables
Base.metadata.create_all(bind=engine) 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] msgs = [{"role": r.role, "content": r.content} for r in rows]
return {"messages": msgs} return {"messages": msgs}
@app.post("/chat", response_model=schemas.ChatResponse) @app.post("/chat")
async def chat(req: schemas.ChatRequest, db: Session = Depends(get_db)): async def chat(req: schemas.ChatRequest, db: Session = Depends(get_db)):
# Find or create session # Find or create session
session = db.query(models.ChatSession).filter(models.ChatSession.session_id == req.session_id).first() session = db.query(models.ChatSession).filter(models.ChatSession.session_id == req.session_id).first()
@@ -81,6 +82,24 @@ async def chat(req: schemas.ChatRequest, db: Session = Depends(get_db)):
messages = [{"role": m.role, "content": m.content} for m in last_msgs] messages = [{"role": m.role, "content": m.content} for m in last_msgs]
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 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: try:
reply = await ollama_chat(req.model, messages) reply = await ollama_chat(req.model, messages)
except Exception as e: except Exception as e:
@@ -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." 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: try:
title = await ollama_chat("llama3", [{"role": "user", "content": prompt}]) title = await ollama_chat(req.model, [{"role": "user", "content": prompt}])
except Exception as e: except Exception as e:
raise HTTPException(status_code=502, detail=f"Ollama error: {e}") raise HTTPException(status_code=502, detail=f"Ollama error: {e}")

View File

@@ -1,6 +1,7 @@
import httpx 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" 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: if msgs:
return msgs[-1].get("content", "") return msgs[-1].get("content", "")
return data.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

View File

@@ -10,6 +10,7 @@ class ChatRequest(BaseModel):
session_id: str session_id: str
model: str model: str
message: str message: str
stream: Optional[bool] = False
class ChatResponse(BaseModel): class ChatResponse(BaseModel):
reply: str reply: str
@@ -20,6 +21,7 @@ class HistoryResponse(BaseModel):
class GenerateTitleRequest(BaseModel): class GenerateTitleRequest(BaseModel):
session_id: str session_id: str
message: str message: str
model: str
class GenerateTitleResponse(BaseModel): class GenerateTitleResponse(BaseModel):
title: str title: str

View File

@@ -1,5 +1,5 @@
const { app, BrowserWindow, Menu, ipcMain } = require('electron') const { app, BrowserWindow, Menu, ipcMain, shell } = require('electron')
const path = require('path') const path = require('path')
const { is } = require('@electron-toolkit/utils') const { is } = require('@electron-toolkit/utils')
const fs = require('fs') const fs = require('fs')
@@ -44,6 +44,8 @@ async function createMainWindow () {
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 1000, width: 1000,
height: 720, height: 720,
minWidth: 680,
minHeight: 300,
show: false, show: false,
webPreferences: { webPreferences: {
preload: path.join(__dirname, 'preload.cjs'), preload: path.join(__dirname, 'preload.cjs'),
@@ -62,6 +64,11 @@ async function createMainWindow () {
} else { } else {
await mainWindow.loadFile(path.join(__dirname, '../dist/index.html')) await mainWindow.loadFile(path.join(__dirname, '../dist/index.html'))
} }
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
} }
async function createSettingsWindow () { async function createSettingsWindow () {
@@ -166,6 +173,16 @@ ipcMain.handle('set-setting', (event, key, value) => {
return true 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', () => { app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit() if (process.platform !== 'darwin') app.quit()
}) })

View File

@@ -4,5 +4,11 @@ const { contextBridge, ipcRenderer } = require('electron')
// Expose a secure API to the renderer process // Expose a secure API to the renderer process
contextBridge.exposeInMainWorld('electronAPI', { contextBridge.exposeInMainWorld('electronAPI', {
getSettings: () => ipcRenderer.invoke('get-settings'), 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);
}
}) })

View File

@@ -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 TextareaAutosize from 'react-textarea-autosize';
import GeneralSettings from './GeneralSettings' import GeneralSettings from './GeneralSettings'
import InterfaceSettings from './InterfaceSettings' 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 // 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'; 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() { export default function App() {
const [chatSessions, setChatSessions] = useState([]) const [chatSessions, setChatSessions] = useState([])
@@ -24,16 +28,46 @@ export default function App() {
const textareaRef = useRef(null); // Ref for the textarea const textareaRef = useRef(null); // Ref for the textarea
const [ollamaApiUrl, setOllamaApiUrl] = useState(API); // State for Ollama API URL const [ollamaApiUrl, setOllamaApiUrl] = useState(API); // State for Ollama API URL
const [colorScheme, setColorScheme] = useState('Default'); // State for color scheme 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 [loading, setLoading] = useState(true); // Loading state for initial session fetch
const [unreadSessions, setUnreadSessions] = useState([]); // Track unread messages 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); const activeSessionIdRef = useRef(activeSessionId);
useEffect(() => { useEffect(() => {
activeSessionIdRef.current = activeSessionId; activeSessionIdRef.current = activeSessionId;
}, [activeSessionId]); }, [activeSessionId]);
// Flag to ensure we only restore once per open of a chat
const restoredForRef = useRef(null);
// Sidebar resizing state // Sidebar resizing state
const [sidebarWidth, setSidebarWidth] = useState(280); // Initial sidebar width const [sidebarWidth, setSidebarWidth] = useState(230);
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
const startResizing = React.useCallback((mouseDownEvent) => { const startResizing = React.useCallback((mouseDownEvent) => {
@@ -74,6 +108,8 @@ export default function App() {
setOllamaApiUrl(settings.ollamaApiUrl); setOllamaApiUrl(settings.ollamaApiUrl);
setColorScheme(settings.colorScheme); setColorScheme(settings.colorScheme);
setModel(settings.chatModel || ''); // Load the selected model, with a fallback 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 applyColorScheme(settings.colorScheme); // Apply initial scheme
}); });
}, []); }, []);
@@ -171,36 +207,214 @@ export default function App() {
fetchHistory(activeSessionId); fetchHistory(activeSessionId);
}, [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(() => { const messages = useMemo(() => {
return chatSessions.find(s => s.session_id === activeSessionId)?.messages || []; return chatSessions.find(s => s.session_id === activeSessionId)?.messages || [];
}, [activeSessionId, chatSessions]); }, [activeSessionId, chatSessions]);
// Persist the scrollTop of the session we are LEAVING (on chat change or when leaving the chat view)
useEffect(() => { useEffect(() => {
chatRef.current?.scrollTo({ top: chatRef.current.scrollHeight, behavior: 'smooth' }); const leavingSessionId = activeSessionId;
}, [messages]); 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() { async function sendMessage() {
let targetSessionId = activeSessionId;
let isNewChat = false;
if (!input.trim() || !model) return; if (!input.trim() || !model) return;
let targetSessionId = activeSessionId;
let isNewChat = false;
if (!targetSessionId) { if (!targetSessionId) {
const newSession = await createNewChat(); const newSession = await createNewChat();
await new Promise(resolve => setTimeout(resolve, 200));
targetSessionId = newSession.session_id; targetSessionId = newSession.session_id;
isNewChat = true; isNewChat = true;
} else { } else {
// Check if it's an existing "New Chat" receiving its first message
const currentSession = chatSessions.find(s => s.session_id === targetSessionId); const currentSession = chatSessions.find(s => s.session_id === targetSessionId);
if (currentSession && currentSession.name === "New Chat" && currentSession.messages.length === 0) { isNewChat = currentSession && currentSession.name === "New Chat" && currentSession.messages.length === 0;
isNewChat = true;
} }
const userMsg = { role: 'user', content: input.trim(), id: `msg-${Date.now()}-${Math.random()}` };
justSentMessage.current = true;
lastSentSessionRef.current = targetSessionId;
setUserScrolledUp(targetSessionId, false);
// 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
} }
const currentActiveSessionAtSend = activeSessionId; // Capture activeSessionId at send time
const userMsg = { role: 'user', content: input.trim() }; // Optimistic add and flush DOM, then scroll to bottom
flushSync(() => {
// Optimistically add user message
setChatSessions(prevSessions => setChatSessions(prevSessions =>
prevSessions.map(session => prevSessions.map(session =>
session.session_id === targetSessionId session.session_id === targetSessionId
@@ -209,21 +423,14 @@ export default function App() {
) )
); );
setInput(''); 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(); requestAnimationFrame(() => scrollToBottom('auto', targetSessionId));
const assistantMsg = { role: 'assistant', content: data.reply };
// Update messages with assistant's reply setIsSending(true);
try {
if (streamOutput) {
const assistantMsgId = `msg-${Date.now()}-${Math.random()}`;
const assistantMsg = { role: 'assistant', content: '', id: assistantMsgId };
setChatSessions(prevSessions => setChatSessions(prevSessions =>
prevSessions.map(session => prevSessions.map(session =>
session.session_id === targetSessionId session.session_id === targetSessionId
@@ -232,19 +439,141 @@ export default function App() {
) )
); );
// Handle unread status: only set if the chat was NOT active when the message was sent (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);
}
if (activeSessionIdRef.current !== targetSessionId) { if (activeSessionIdRef.current !== targetSessionId) {
setUnreadSessions(prev => [...new Set([...prev, targetSessionId])]); setUnreadSessions(prev => [...new Set([...prev, targetSessionId])]);
} }
// Generate title if new chat
if (isNewChat) { if (isNewChat) {
fetch(`${ollamaApiUrl}/generate-title`, { fetch(`${ollamaApiUrl}/generate-title`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
session_id: targetSessionId, session_id: targetSessionId,
message: userMsg.content message: userMsg.content,
model: model
}) })
}) })
.then(r => r.json()) .then(r => r.json())
@@ -258,15 +587,15 @@ export default function App() {
} }
} catch (e) { } catch (e) {
console.error("Failed to send message:", e); console.error("Failed to send message:", e);
// Add error message to chat const errorMsg = { role: 'assistant', content: 'Error: ' + e.message, id: `msg-${Date.now()}-${Math.random()}` };
const errorMsg = { role: 'assistant', content: 'Error: ' + e.message };
setChatSessions(prevSessions => setChatSessions(prevSessions =>
prevSessions.map(session => prevSessions.map(session =>
session.session_id === targetSessionId session.session_id === targetSessionId
? { ...session, messages: [...(session.messages || []), errorMsg] } ? { ...session, messages: [...session.messages, errorMsg] }
: session : session
) )
); );
setIsSending(false);
} }
} }
@@ -286,7 +615,43 @@ export default function App() {
function selectChat(sessionId) { function selectChat(sessionId) {
setActiveSessionId(sessionId); setActiveSessionId(sessionId);
// Clear unread dot immediately for this chat
setUnreadSessions(prev => prev.filter(id => id !== sessionId)); 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) { function handleRename(sessionId, newName) {
@@ -334,19 +699,19 @@ export default function App() {
<div className="sidebar-header"> <div className="sidebar-header">
<div <div
className={`sidebar-tab ${activeSidebarMode === 'chats' ? 'active' : ''}`} className={`sidebar-tab ${activeSidebarMode === 'chats' ? 'active' : ''}`}
onClick={() => setActiveSidebarMode('chats')} onClick={() => handleSidebarClick('chats')}
> >
Chats Chats
</div> </div>
<div <div
className={`sidebar-tab ${activeSidebarMode === 'dbs' ? 'active' : ''}`} className={`sidebar-tab ${activeSidebarMode === 'dbs' ? 'active' : ''}`}
onClick={() => setActiveSidebarMode('dbs')} onClick={() => handleSidebarClick('dbs')}
> >
DBs DBs
</div> </div>
<div <div
className={`sidebar-tab ${activeSidebarMode === 'settings' ? 'active' : ''}`} className={`sidebar-tab ${activeSidebarMode === 'settings' ? 'active' : ''}`}
onClick={() => setActiveSidebarMode('settings')} onClick={() => handleSidebarClick('settings')}
> >
Settings Settings
</div> </div>
@@ -358,7 +723,7 @@ export default function App() {
<div <div
key={session.session_id} key={session.session_id}
className={`chat-item ${session.session_id === activeSessionId ? 'active' : ''}`} className={`chat-item ${session.session_id === activeSessionId ? 'active' : ''}`}
onClick={() => selectChat(session.session_id)} onClick={() => handleSelectChat(session.session_id)}
> >
{editingSessionId === session.session_id ? ( {editingSessionId === session.session_id ? (
<input <input
@@ -434,14 +799,26 @@ export default function App() {
<strong>Chat - {chatSessions.find(s => s.session_id === activeSessionId)?.name || 'New Chat'}</strong> <strong>Chat - {chatSessions.find(s => s.session_id === activeSessionId)?.name || 'New Chat'}</strong>
</div> </div>
<div className="chat" ref={chatRef}> <div key={activeSessionId} className="chat" ref={chatRef}>
{messages.map((m, i) => ( {messages.map((m, i) => (
<div key={i} className={'msg ' + (m.role === 'user' ? 'user' : 'assistant')}> <div key={m.id || i} id={m.id} className={'msg ' + (m.role === 'user' ? 'user' : 'assistant')}>
<div dangerouslySetInnerHTML={{ __html: markdownToHTML(m.content) }} /> <div dangerouslySetInnerHTML={{ __html: markdownToHTML(m.content) }} />
</div> </div>
))} ))}
</div> </div>
{/* New message tip (active chat only) */}
{newMsgTip[activeSessionId] && (
<button
className="new-msg-tip"
onClick={handleNewMsgTipClick}
title="Jump to the new message"
aria-label="Jump to the new message"
>
New message<span style={{ marginLeft: 6 }}>↓</span>
</button>
)}
<div className="footer"> <div className="footer">
<div className="footer-content-wrapper"> <div className="footer-content-wrapper">
<TextareaAutosize <TextareaAutosize
@@ -457,7 +834,9 @@ export default function App() {
placeholder="Ask any question..." placeholder="Ask any question..."
maxRows={13} maxRows={13}
/> />
<button className="button" onClick={sendMessage}>Send</button> <button className="button" onClick={sendMessage} disabled={isSending}>
{isSending ? <div className="spinner"></div> : 'Send'}
</button>
</div> </div>
</div> </div>
</> </>
@@ -473,7 +852,13 @@ export default function App() {
<div className="header"> <div className="header">
<strong>{activeSettingsSubmenu} Settings</strong> <strong>{activeSettingsSubmenu} Settings</strong>
</div> </div>
{activeSettingsSubmenu === 'General' && <GeneralSettings />} {activeSettingsSubmenu === 'General' && (
<GeneralSettings
onModelChange={setModel}
streamOutput={streamOutput}
onStreamOutputChange={setStreamOutput}
/>
)}
{activeSettingsSubmenu === 'Interface' && <InterfaceSettings />} {activeSettingsSubmenu === 'Interface' && <InterfaceSettings />}
</> </>
)} )}

View File

@@ -2,17 +2,19 @@ import React, { useState, useEffect } from 'react';
const API_URL_KEY = 'ollamaApiUrl'; const API_URL_KEY = 'ollamaApiUrl';
const MODEL_KEY = 'chatModel'; const MODEL_KEY = 'chatModel';
const STREAM_KEY = 'streamOutput';
export default function GeneralSettings() { export default function GeneralSettings({ onModelChange, onStreamOutputChange }) {
const [ollamaApiUrl, setOllamaApiUrl] = useState(''); const [ollamaApiUrl, setOllamaApiUrl] = useState('');
const [models, setModels] = useState([]); const [models, setModels] = useState([]);
const [selectedModel, setSelectedModel] = useState(''); const [selectedModel, setSelectedModel] = useState('');
const [streamOutput, setStreamOutput] = useState(false);
useEffect(() => { useEffect(() => {
window.electronAPI.getSettings().then(settings => { window.electronAPI.getSettings().then(settings => {
setOllamaApiUrl(settings.ollamaApiUrl); setOllamaApiUrl(settings.ollamaApiUrl);
// Set selectedModel from settings, or fallback to an empty string if not found
setSelectedModel(settings.chatModel || ''); setSelectedModel(settings.chatModel || '');
setStreamOutput(settings.streamOutput || false);
}); });
}, []); }, []);
@@ -44,6 +46,18 @@ export default function GeneralSettings() {
const newModel = e.target.value; const newModel = e.target.value;
setSelectedModel(newModel); setSelectedModel(newModel);
window.electronAPI.setSetting(MODEL_KEY, 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 ( return (
@@ -69,6 +83,17 @@ export default function GeneralSettings() {
{models.map(m => <option key={m} value={m}>{m}</option>)} {models.map(m => <option key={m} value={m}>{m}</option>)}
</select> </select>
</div> </div>
<div className="setting-section">
<h3>Stream Output</h3>
<label className="toggle-switch">
<input
type="checkbox"
checked={streamOutput}
onChange={handleStreamToggle}
/>
<span className="slider"></span>
</label>
</div>
</div> </div>
); );
} }

View File

@@ -1,52 +1,8 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { colorSchemes, applyColorScheme } from './colorSchemes';
const COLOR_SCHEME_KEY = 'colorScheme'; 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() { export default function InterfaceSettings() {
const [selectedColorScheme, setSelectedColorScheme] = useState('Default'); const [selectedColorScheme, setSelectedColorScheme] = useState('Default');

78
src/colorSchemes.js Normal file
View File

@@ -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 };

View File

@@ -1,12 +1,21 @@
import React from 'react' import React, { useEffect } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { HashRouter as Router, Routes, Route } from 'react-router-dom' import { HashRouter as Router, Routes, Route } from 'react-router-dom'
import App from './App.jsx' import App from './App.jsx'
import './styles.css' import './styles.css'
import { applyColorScheme } from './colorSchemes'
const root = createRoot(document.getElementById('root')) function Main() {
root.render( useEffect(() => {
window.electronAPI.getSettings().then(settings => {
if (settings.colorScheme) {
applyColorScheme(settings.colorScheme)
}
})
}, [])
return (
<React.StrictMode> <React.StrictMode>
<Router> <Router>
<Routes> <Routes>
@@ -14,4 +23,8 @@ root.render(
</Routes> </Routes>
</Router> </Router>
</React.StrictMode> </React.StrictMode>
) )
}
const root = createRoot(document.getElementById('root'))
root.render(<Main />)

View File

@@ -11,8 +11,8 @@ export function markdownToHTML(text) {
// 2) Extract code blocks and replace with placeholders // 2) Extract code blocks and replace with placeholders
const codeblocks = []; const codeblocks = [];
const placeholder = idx => `@@CODEBLOCK${idx}@@`; const placeholder = idx => `@@CODEBLOCK${idx}@@`;
tmp = tmp.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => { tmp = tmp.replace(/```([^\n]*)\n([\s\S]*?)```/g, (_, lang, code) => {
codeblocks.push({ lang, code }); codeblocks.push({ lang: (lang || '').trim(), code });
return placeholder(codeblocks.length - 1); return placeholder(codeblocks.length - 1);
}); });
@@ -29,6 +29,12 @@ export function markdownToHTML(text) {
.replace(/^## (.+)$/gm, "<h2>$1</h2>") .replace(/^## (.+)$/gm, "<h2>$1</h2>")
.replace(/^# (.+)$/gm, "<h1>$1</h1>"); .replace(/^# (.+)$/gm, "<h1>$1</h1>");
// 4.2) Remove blank empty lines immediately after headings
escaped = escaped.replace(
/(<h[1-4]>.*?<\/h[1-4]>)[ \t]*\n(?:[ \t]*\n)+/g,
"$1\n"
);
// 4.5) Unordered lists // 4.5) Unordered lists
escaped = escaped.replace( escaped = escaped.replace(
/(^|\n)([ \t]*\* .+(?:\n[ \t]*\* .+)*)/g, /(^|\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)=>`<th${cellStyle(i)}>${h}</th>`).join('');
const rows = bodyLines.map(line => {
const cells = split(line);
const tds = cells.map((c,i)=>`<td${cellStyle(i)}>${c}</td>`).join('');
return `<tr>${tds}</tr>`;
}).join('');
// Wrapper: rounded outer border + top/bottom spacing; inner cells keep their own borders.
const wrapperOpen = `<div class="md-table" style="margin:1rem 0;border:1px solid #e5e7eb;border-radius:12px;overflow:hidden">`;
const table = `<table class="nice" style="border-collapse:separate;border-spacing:0;width:100%"><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table></div>`;
return wrapperOpen + table + (hadTrailingNewline ? '\n' : '');
});
// 4.75) Horizontal rules
escaped = escaped.replace(/^---\s*$/gm, "<hr>");
// 5) Bold, italic, inline code // 5) Bold, italic, inline code
let html = escaped let html = escaped
.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>") .replace(/\*\*(.+?)\*\*/g, "<b>$1</b>")
.replace(/(?<!\*)\*(.+?)\*(?!\*)/g, "<i>$1</i>") .replace(/(?<!\*)\*(.+?)\*(?!\*)/g, "<i>$1</i>")
.replace(/`(.+?)`/g, "<code>$1</code>"); .replace(/`(.+?)`/g, "<code>$1</code>");
// 6) Restore code blocks // 5.5) Links
html = html.replace(/\[([^\]]+?)\]\(([^)]+?)\)/g, '<a href="$2" target="_blank"><span>$1</span> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" class="feather feather-external-link"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg><span class="tooltip">$2</span></a>');
// 6) Restore code blocks with title bar (language)
html = html.replace(/@@CODEBLOCK(\d+)@@/g, (_, idx) => { html = html.replace(/@@CODEBLOCK(\d+)@@/g, (_, idx) => {
const { lang, code } = codeblocks[+idx]; const { lang, code } = codeblocks[+idx];
const title = (lang && lang.trim()) ? lang.trim() : 'code';
const escapedCode = code.replace(/</g, "<").replace(/>/g, ">"); const escapedCode = code.replace(/</g, "<").replace(/>/g, ">");
return `<pre><code class="language-${lang}">${escapedCode}</code></pre>`; const head = `<div class="md-code-head" style="background:#f9fafb;border-bottom:1px solid #e5e7eb;padding:.6rem .75rem;font-weight:600">${title}</div>`;
const body = `<pre style="margin:0;padding:.75rem;border:0;overflow:auto"><code class="language-${title}">${escapedCode}</code></pre>`;
return `<div class="md-code" style="margin:1rem 0;border:1px solid #e5e7eb;border-radius:12px;overflow:hidden">${head}${body}</div>`;
}); });
// 7) Convert line-breaks to <br> // 7) Convert line-breaks to <br>
html = html.replace(/\n/g, "<br>"); html = html.replace(/\n/g, "<br>");
// 8) Cleanup stray <br> immediately before/after lists // 8) Cleanup stray <br> around lists/tables/wrappers
html = html html = html
.replace(/<br>\s*(<ul>)/g, "$1") .replace(/<br>\s*(<ul>)/g, "$1")
.replace(/(<\/ul>)\s*<br>/g, "$1"); .replace(/(<\/ul>)\s*<br>/g, "$1")
.replace(/<br>\s*(<div class="md-table"[^>]*>)/g, "$1")
.replace(/(<\/div>)\s*<br>/g, "$1")
.replace(/<br>\s*(<div class="md-code"[^>]*>)/g, "$1")
.replace(/(<\/div>)\s*<br>/g, "$1")
.replace(/<br>\s*(<table\b[^>]*>)/g, "$1")
.replace(/(<\/table>)\s*<br>/g, "$1");
// 9) Trim spaces/tabs and remove empty newline(s) immediately after <hr>
html = html
.replace(/(<hr>)[ \t]+/g, "$1") // remove spaces/tabs
.replace(/(<hr>)(?:[ \t]*<br>)+/g, "$1"); // remove one or more blank lines (now <br>) after <hr>
return html; return html;
} }

View File

@@ -1,4 +1,4 @@
/* /Users/giers/Heimgeist/src/styles.css */
:root { :root {
--bg: #0b1020; --bg: #0b1020;
--panel: #141b34; --panel: #141b34;
@@ -19,7 +19,7 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy
.app { .app {
display: grid; 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; grid-template-rows: 1fr;
height: 100%; height: 100%;
} }
@@ -34,6 +34,10 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy
position: relative; /* For resizer positioning */ position: relative; /* For resizer positioning */
} }
.sidebar span{
font-size: 14px;
}
.resizer { .resizer {
width: 13px; /* 8px original + 5px to the right */ width: 13px; /* 8px original + 5px to the right */
cursor: ew-resize; cursor: ew-resize;
@@ -50,28 +54,31 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy
display: flex; display: flex;
justify-content: space-around; justify-content: space-around;
padding: 0; /* Remove padding to allow tabs to fill */ padding: 0; /* Remove padding to allow tabs to fill */
border-bottom: 1px solid var(--border);
background: var(--panel); background: var(--panel);
} }
.sidebar-tab { .sidebar-tab {
flex-grow: 1; flex-grow: 1;
text-align: center; text-align: center;
padding: 12px 16px; padding: 11px 16px;
cursor: pointer; cursor: pointer;
border-bottom: 3px solid transparent; /* For active indicator */ border-bottom: 3px solid transparent; /* For active indicator */
transition: background-color 0.2s ease; transition: background-color 0.2s ease;
} }
.sidebar-tab:hover { .sidebar-tab:hover {
background-color: rgba(255, 255, 255, 0.05); background-color: var(--hover-bg);
} }
.sidebar-tab.active { .sidebar-tab.active {
background-color: rgba(255, 255, 255, 0.1); background-color: var(--active-bg);
border-bottom-color: var(--accent); border-bottom-color: var(--accent);
} }
.sidebar-tab.active:hover {
background-color: var(--active-bg);
}
.sidebar-content { .sidebar-content {
flex-grow: 1; flex-grow: 1;
overflow-y: auto; overflow-y: auto;
@@ -94,12 +101,16 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy
} }
.settings-item.active { .settings-item.active {
background: rgba(255, 255, 255, 0.1); background: var(--active-bg);
border-left-color: var(--accent); border-left-color: var(--accent);
} }
.settings-item.active:hover {
background: var(--active-bg);
}
.settings-item:hover { .settings-item:hover {
background: rgba(255, 255, 255, 0.05); background: var(--hover-bg);
} }
.settings-footer-placeholder { .settings-footer-placeholder {
@@ -122,6 +133,7 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy
opacity: 0.9; opacity: 0.9;
} }
.chat-list { .chat-list {
overflow-y: auto; overflow-y: auto;
padding: 8px 0; padding: 8px 0;
@@ -188,12 +200,16 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy
} }
.chat-item.active { .chat-item.active {
background: rgba(255, 255, 255, 0.1); background: var(--active-bg);
border-left-color: var(--accent); border-left-color: var(--accent);
} }
.chat-item:hover { .chat-item:hover {
background: rgba(255, 255, 255, 0.05); background: var(--hover-bg);
}
.chat-item.active:hover {
background: var(--active-bg);
} }
.rename-input { .rename-input {
@@ -226,7 +242,6 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy
.sidebar-footer { .sidebar-footer {
padding: 12px 16px; padding: 12px 16px;
border-top: 1px solid var(--border);
background: var(--panel); background: var(--panel);
} }
@@ -244,6 +259,7 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy
} }
.main-content { .main-content {
position: relative;
display: grid; display: grid;
grid-template-rows: auto 1fr auto; grid-template-rows: auto 1fr auto;
height: 100vh; /* Use viewport height to prevent window scrolling */ 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; padding: 12px 14px;
border-radius: 12px; border-radius: 12px;
line-height: 1.5; 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 */ 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 { .msg.user {
background: var(--user-msg-bg); background: var(--user-msg-bg);
margin-left: auto; /* Right-align user messages */ margin-left: auto; /* Right-align user messages */
max-width: 800px; max-width: 80%;
border: 1px solid var(--border); border: 1px solid var(--border);
margin-right: 5px;
} }
.msg.assistant { .msg.assistant {
background: transparent; background: transparent;
border: none; border: none;
max-width: none; /* Remove max-width for assistant messages */ 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 { .footer {
@@ -422,3 +462,124 @@ textarea.input {
padding: 0; padding: 0;
background-color: transparent; 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); }
}