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:
@@ -1,10 +1,11 @@
|
||||
from fastapi import FastAPI, Depends, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from . import models, schemas
|
||||
from .database import Base, engine, SessionLocal
|
||||
from .ollama_client import list_models as ollama_list, chat as ollama_chat
|
||||
from .ollama_client import list_models as ollama_list, chat as ollama_chat, chat_stream as ollama_chat_stream
|
||||
|
||||
# Create tables
|
||||
Base.metadata.create_all(bind=engine)
|
||||
@@ -61,7 +62,7 @@ def history(session_id: str, db: Session = Depends(get_db)):
|
||||
msgs = [{"role": r.role, "content": r.content} for r in rows]
|
||||
return {"messages": msgs}
|
||||
|
||||
@app.post("/chat", response_model=schemas.ChatResponse)
|
||||
@app.post("/chat")
|
||||
async def chat(req: schemas.ChatRequest, db: Session = Depends(get_db)):
|
||||
# Find or create session
|
||||
session = db.query(models.ChatSession).filter(models.ChatSession.session_id == req.session_id).first()
|
||||
@@ -81,17 +82,35 @@ async def chat(req: schemas.ChatRequest, db: Session = Depends(get_db)):
|
||||
|
||||
messages = [{"role": m.role, "content": m.content} for m in last_msgs]
|
||||
|
||||
try:
|
||||
reply = await ollama_chat(req.model, messages)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"Ollama error: {e}")
|
||||
if req.stream:
|
||||
async def stream_generator():
|
||||
full_reply = ""
|
||||
try:
|
||||
async for chunk in ollama_chat_stream(req.model, messages):
|
||||
full_reply += chunk
|
||||
yield chunk
|
||||
except Exception as e:
|
||||
# How to handle errors in a stream? Could yield an error message.
|
||||
yield f"Ollama error: {e}"
|
||||
|
||||
# Save assistant reply
|
||||
as_row = models.ChatMessage(session_pk=session.id, role='assistant', content=reply)
|
||||
db.add(as_row)
|
||||
db.commit()
|
||||
# Save full reply after stream is complete
|
||||
as_row = models.ChatMessage(session_pk=session.id, role='assistant', content=full_reply)
|
||||
db.add(as_row)
|
||||
db.commit()
|
||||
|
||||
return StreamingResponse(stream_generator(), media_type="text/plain")
|
||||
else:
|
||||
try:
|
||||
reply = await ollama_chat(req.model, messages)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"Ollama error: {e}")
|
||||
|
||||
return {"reply": reply}
|
||||
# Save assistant reply
|
||||
as_row = models.ChatMessage(session_pk=session.id, role='assistant', content=reply)
|
||||
db.add(as_row)
|
||||
db.commit()
|
||||
|
||||
return {"reply": reply}
|
||||
|
||||
@app.post("/generate-title", response_model=schemas.GenerateTitleResponse)
|
||||
async def generate_title(req: schemas.GenerateTitleRequest, db: Session = Depends(get_db)):
|
||||
@@ -102,7 +121,7 @@ async def generate_title(req: schemas.GenerateTitleRequest, db: Session = Depend
|
||||
prompt = f"Generate a very short, concise title (5 words or less) for a chat conversation that begins with this user message: \"{req.message}\". Do not use quotation marks in the title."
|
||||
|
||||
try:
|
||||
title = await ollama_chat("llama3", [{"role": "user", "content": prompt}])
|
||||
title = await ollama_chat(req.model, [{"role": "user", "content": prompt}])
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"Ollama error: {e}")
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
|
||||
import httpx
|
||||
from typing import Dict, Any, List
|
||||
import json
|
||||
from typing import Dict, Any, List, AsyncGenerator
|
||||
|
||||
OLLAMA_URL = "http://127.0.0.1:11434"
|
||||
|
||||
@@ -32,3 +33,23 @@ async def chat(model: str, messages: List[Dict[str, str]]) -> str:
|
||||
if msgs:
|
||||
return msgs[-1].get("content", "")
|
||||
return data.get("content", "")
|
||||
|
||||
async def chat_stream(model: str, messages: List[Dict[str, str]]) -> AsyncGenerator[str, None]:
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": True
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=600.0) as client:
|
||||
async with client.stream("POST", f"{OLLAMA_URL}/api/chat", json=payload) as r:
|
||||
r.raise_for_status()
|
||||
async for line in r.aiter_lines():
|
||||
if line:
|
||||
try:
|
||||
chunk = json.loads(line)
|
||||
if "content" in chunk: # Newer Ollama format
|
||||
yield chunk["content"]
|
||||
elif "message" in chunk and "content" in chunk["message"]: # Older format
|
||||
yield chunk["message"]["content"]
|
||||
except json.JSONDecodeError:
|
||||
pass # Ignore invalid JSON lines
|
||||
|
||||
@@ -10,6 +10,7 @@ class ChatRequest(BaseModel):
|
||||
session_id: str
|
||||
model: str
|
||||
message: str
|
||||
stream: Optional[bool] = False
|
||||
|
||||
class ChatResponse(BaseModel):
|
||||
reply: str
|
||||
@@ -20,6 +21,7 @@ class HistoryResponse(BaseModel):
|
||||
class GenerateTitleRequest(BaseModel):
|
||||
session_id: str
|
||||
message: str
|
||||
model: str
|
||||
|
||||
class GenerateTitleResponse(BaseModel):
|
||||
title: str
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
const { app, BrowserWindow, Menu, ipcMain } = require('electron')
|
||||
const { app, BrowserWindow, Menu, ipcMain, shell } = require('electron')
|
||||
const path = require('path')
|
||||
const { is } = require('@electron-toolkit/utils')
|
||||
const fs = require('fs')
|
||||
@@ -44,6 +44,8 @@ async function createMainWindow () {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1000,
|
||||
height: 720,
|
||||
minWidth: 680,
|
||||
minHeight: 300,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
@@ -62,6 +64,11 @@ async function createMainWindow () {
|
||||
} else {
|
||||
await mainWindow.loadFile(path.join(__dirname, '../dist/index.html'))
|
||||
}
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
});
|
||||
}
|
||||
|
||||
async function createSettingsWindow () {
|
||||
@@ -166,6 +173,16 @@ ipcMain.handle('set-setting', (event, key, value) => {
|
||||
return true
|
||||
})
|
||||
|
||||
ipcMain.handle('update-settings', (event, settings) => {
|
||||
appSettings = { ...appSettings, ...settings }
|
||||
saveSettings()
|
||||
return true
|
||||
})
|
||||
|
||||
ipcMain.on('open-external-link', (event, url) => {
|
||||
shell.openExternal(url)
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit()
|
||||
})
|
||||
|
||||
@@ -4,5 +4,11 @@ const { contextBridge, ipcRenderer } = require('electron')
|
||||
// Expose a secure API to the renderer process
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getSettings: () => ipcRenderer.invoke('get-settings'),
|
||||
setSetting: (key, value) => ipcRenderer.invoke('set-setting', key, value)
|
||||
setSetting: (key, value) => ipcRenderer.invoke('set-setting', key, value),
|
||||
updateSettings: (settings) => ipcRenderer.invoke('update-settings', settings),
|
||||
openExternalLink: (event) => {
|
||||
event.preventDefault();
|
||||
const url = event.currentTarget.href;
|
||||
ipcRenderer.send('open-external-link', url);
|
||||
}
|
||||
})
|
||||
|
||||
489
src/App.jsx
489
src/App.jsx
@@ -1,4 +1,6 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
// /Users/giers/Heimgeist/src/App.jsx
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { flushSync } from 'react-dom';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import GeneralSettings from './GeneralSettings'
|
||||
import InterfaceSettings from './InterfaceSettings'
|
||||
@@ -9,6 +11,8 @@ const COLOR_SCHEME_KEY = 'colorScheme';
|
||||
|
||||
// Initial API value will be set by useEffect after settings are loaded
|
||||
let API = import.meta.env.VITE_API_URL ?? 'http://127.0.0.1:8000';
|
||||
const TOP_ALIGN_OFFSET = 48; // match .chat padding + header height for exact top alignment (should be more dynamic depending on header height)
|
||||
const BOTTOM_EPSILON = 24; // px tolerance for treating as bottom
|
||||
|
||||
export default function App() {
|
||||
const [chatSessions, setChatSessions] = useState([])
|
||||
@@ -24,16 +28,46 @@ export default function App() {
|
||||
const textareaRef = useRef(null); // Ref for the textarea
|
||||
const [ollamaApiUrl, setOllamaApiUrl] = useState(API); // State for Ollama API URL
|
||||
const [colorScheme, setColorScheme] = useState('Default'); // State for color scheme
|
||||
const [streamOutput, setStreamOutput] = useState(false);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [loading, setLoading] = useState(true); // Loading state for initial session fetch
|
||||
const [unreadSessions, setUnreadSessions] = useState([]); // Track unread messages
|
||||
const activeRequestSessionId = useRef(null); // Ref to track the session ID of the active request
|
||||
const [scrollPositions, setScrollPositions] = useState({}); // Store scroll positions for each session
|
||||
|
||||
// Persist userScrolledUp state per session + live ref for closures (streaming)
|
||||
const [userScrolledUpState, setUserScrolledUpState] = useState({});
|
||||
const userScrolledUpRef = useRef({});
|
||||
|
||||
// When a response arrives in a non-active chat, remember to scroll to the new ASSISTANT message on open
|
||||
const [pendingScrollToLastUser, setPendingScrollToLastUser] = useState({}); // { [sessionId]: assistantMsgId }
|
||||
|
||||
// Live per-session scrollTop tracker to avoid races
|
||||
const scrollTopsRef = useRef({});
|
||||
|
||||
// Tip state: { [sessionId]: messageId }
|
||||
const [newMsgTip, setNewMsgTip] = useState({});
|
||||
|
||||
const setUserScrolledUp = React.useCallback((sessionId, value) => {
|
||||
setUserScrolledUpState(prev => {
|
||||
const next = { ...prev, [sessionId]: value };
|
||||
userScrolledUpRef.current = next;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const activeRequestSessionId = useRef(null);
|
||||
const justSentMessage = useRef(false);
|
||||
const lastSentSessionRef = useRef(null);
|
||||
const activeSessionIdRef = useRef(activeSessionId);
|
||||
useEffect(() => {
|
||||
activeSessionIdRef.current = activeSessionId;
|
||||
}, [activeSessionId]);
|
||||
|
||||
// Flag to ensure we only restore once per open of a chat
|
||||
const restoredForRef = useRef(null);
|
||||
|
||||
// Sidebar resizing state
|
||||
const [sidebarWidth, setSidebarWidth] = useState(280); // Initial sidebar width
|
||||
const [sidebarWidth, setSidebarWidth] = useState(230);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
|
||||
const startResizing = React.useCallback((mouseDownEvent) => {
|
||||
@@ -74,6 +108,8 @@ export default function App() {
|
||||
setOllamaApiUrl(settings.ollamaApiUrl);
|
||||
setColorScheme(settings.colorScheme);
|
||||
setModel(settings.chatModel || ''); // Load the selected model, with a fallback
|
||||
setStreamOutput(settings.streamOutput || false);
|
||||
setScrollPositions(settings.scrollPositions || {}); // Load scroll positions
|
||||
applyColorScheme(settings.colorScheme); // Apply initial scheme
|
||||
});
|
||||
}, []);
|
||||
@@ -171,80 +207,373 @@ export default function App() {
|
||||
fetchHistory(activeSessionId);
|
||||
}, [activeSessionId]);
|
||||
|
||||
const handleSidebarClick = (mode) => {
|
||||
// Saving happens in the centralized cleanup effect below
|
||||
setActiveSidebarMode(mode);
|
||||
};
|
||||
|
||||
const handleSelectChat = (sessionId) => {
|
||||
// Saving happens in the centralized cleanup effect below
|
||||
selectChat(sessionId);
|
||||
};
|
||||
|
||||
const messages = useMemo(() => {
|
||||
return chatSessions.find(s => s.session_id === activeSessionId)?.messages || [];
|
||||
}, [activeSessionId, chatSessions]);
|
||||
|
||||
// Persist the scrollTop of the session we are LEAVING (on chat change or when leaving the chat view)
|
||||
useEffect(() => {
|
||||
chatRef.current?.scrollTo({ top: chatRef.current.scrollHeight, behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
const leavingSessionId = activeSessionId;
|
||||
const leavingMode = activeSidebarMode;
|
||||
|
||||
return () => {
|
||||
if (leavingMode === 'chats' && leavingSessionId) {
|
||||
const top = typeof scrollTopsRef.current[leavingSessionId] === 'number'
|
||||
? scrollTopsRef.current[leavingSessionId]
|
||||
: (chatRef.current ? chatRef.current.scrollTop : 0);
|
||||
|
||||
setScrollPositions(prev => {
|
||||
const updated = { ...prev, [leavingSessionId]: top };
|
||||
window.electronAPI.updateSettings({ scrollPositions: updated });
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [activeSessionId, activeSidebarMode]);
|
||||
|
||||
// Track scroll + whether user left bottom
|
||||
useEffect(() => {
|
||||
const chatDiv = chatRef.current;
|
||||
if (!chatDiv) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = chatDiv;
|
||||
const isAtBottom = (scrollHeight - scrollTop - clientHeight) <= BOTTOM_EPSILON;
|
||||
if (activeSessionId) {
|
||||
scrollTopsRef.current[activeSessionId] = scrollTop;
|
||||
}
|
||||
setUserScrolledUp(activeSessionId, !isAtBottom);
|
||||
};
|
||||
|
||||
chatDiv.addEventListener('scroll', handleScroll);
|
||||
return () => chatDiv.removeEventListener('scroll', handleScroll);
|
||||
}, [activeSessionId, setUserScrolledUp]);
|
||||
|
||||
// Auto-hide the tip if user returns to bottom in the active chat
|
||||
useEffect(() => {
|
||||
const sid = activeSessionId;
|
||||
if (!sid) return;
|
||||
if (userScrolledUpState[sid] === false) {
|
||||
setNewMsgTip(prev => {
|
||||
if (!(sid in prev)) return prev;
|
||||
const rest = { ...prev };
|
||||
delete rest[sid];
|
||||
return rest;
|
||||
});
|
||||
}
|
||||
}, [activeSessionId, userScrolledUpState]);
|
||||
|
||||
// --- Robust restoration: do it before paint, exactly once per open ---
|
||||
useLayoutEffect(() => {
|
||||
if (activeSidebarMode !== 'chats' || !activeSessionId) return;
|
||||
|
||||
const div = chatRef.current;
|
||||
if (!div) return;
|
||||
|
||||
restoredForRef.current = null;
|
||||
|
||||
const applyRestore = () => {
|
||||
if (restoredForRef.current === activeSessionId) return;
|
||||
|
||||
const liveSaved = typeof scrollTopsRef.current[activeSessionId] === 'number'
|
||||
? scrollTopsRef.current[activeSessionId]
|
||||
: undefined;
|
||||
const saved = typeof liveSaved === 'number'
|
||||
? liveSaved
|
||||
: scrollPositions[activeSessionId];
|
||||
|
||||
if (typeof saved === 'number') {
|
||||
div.scrollTop = saved;
|
||||
restoredForRef.current = activeSessionId;
|
||||
return;
|
||||
}
|
||||
if (messages.length > 0) {
|
||||
// default: bottom when no saved position
|
||||
div.scrollTop = div.scrollHeight;
|
||||
restoredForRef.current = activeSessionId;
|
||||
}
|
||||
};
|
||||
|
||||
// Run immediately (pre-paint) and also schedule a fallback rAF
|
||||
applyRestore();
|
||||
const r0 = requestAnimationFrame(applyRestore);
|
||||
|
||||
// If content size/DOM changes after first paint, apply once
|
||||
const onDomChange = () => {
|
||||
if (restoredForRef.current !== activeSessionId) {
|
||||
requestAnimationFrame(applyRestore);
|
||||
}
|
||||
};
|
||||
|
||||
const mo = new MutationObserver(onDomChange);
|
||||
mo.observe(div, { childList: true, subtree: true });
|
||||
|
||||
const ro = new ResizeObserver(onDomChange);
|
||||
ro.observe(div);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(r0);
|
||||
mo.disconnect();
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [activeSessionId, activeSidebarMode, messages.length, scrollPositions]);
|
||||
|
||||
// If there is no saved scroll and content arrives later (e.g., on first app load),
|
||||
// default to bottom exactly once for this open chat.
|
||||
useEffect(() => {
|
||||
if (activeSidebarMode !== 'chats' || !activeSessionId) return;
|
||||
if (restoredForRef.current === activeSessionId) return; // already applied
|
||||
|
||||
const liveSaved = typeof scrollTopsRef.current[activeSessionId] === 'number'
|
||||
? scrollTopsRef.current[activeSessionId]
|
||||
: undefined;
|
||||
const savedScrollTop = typeof liveSaved === 'number'
|
||||
? liveSaved
|
||||
: scrollPositions[activeSessionId];
|
||||
|
||||
// Only when there is no saved position and we now have content
|
||||
if (typeof savedScrollTop !== 'number' && messages.length > 0) {
|
||||
requestAnimationFrame(() => {
|
||||
const div = chatRef.current;
|
||||
if (!div) return;
|
||||
div.scrollTop = div.scrollHeight;
|
||||
restoredForRef.current = activeSessionId;
|
||||
});
|
||||
}
|
||||
}, [messages.length, activeSessionId, activeSidebarMode, scrollPositions]);
|
||||
|
||||
// Session-aware scroll helpers
|
||||
const scrollToBottom = (behavior = 'smooth', sessionId = null) => {
|
||||
const chatDiv = chatRef.current;
|
||||
if (!chatDiv) return;
|
||||
const target = sessionId ?? activeSessionIdRef.current;
|
||||
if (activeSessionIdRef.current !== target) return;
|
||||
chatDiv.scrollTo({ top: chatDiv.scrollHeight, behavior });
|
||||
setUserScrolledUp(target, false);
|
||||
};
|
||||
|
||||
const scrollMessageToTop = (msgId, behavior = 'auto', sessionId = null) => {
|
||||
const chatDiv = chatRef.current;
|
||||
if (!chatDiv) return;
|
||||
const target = sessionId ?? activeSessionIdRef.current;
|
||||
if (activeSessionIdRef.current !== target) return;
|
||||
const el = document.getElementById(msgId);
|
||||
if (el) {
|
||||
const top = Math.max(0, el.offsetTop - TOP_ALIGN_OFFSET);
|
||||
chatDiv.scrollTo({ top, behavior });
|
||||
}
|
||||
};
|
||||
|
||||
// Handler for new message tip click
|
||||
const handleNewMsgTipClick = () => {
|
||||
const sid = activeSessionIdRef.current;
|
||||
const msgId = newMsgTip[sid];
|
||||
if (msgId) {
|
||||
scrollMessageToTop(msgId, 'smooth', sid);
|
||||
setNewMsgTip(prev => {
|
||||
const { [sid]: _omit, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
async function sendMessage() {
|
||||
let targetSessionId = activeSessionId;
|
||||
let isNewChat = false;
|
||||
|
||||
if (!input.trim() || !model) return;
|
||||
|
||||
let targetSessionId = activeSessionId;
|
||||
let isNewChat = false;
|
||||
if (!targetSessionId) {
|
||||
const newSession = await createNewChat();
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
targetSessionId = newSession.session_id;
|
||||
isNewChat = true;
|
||||
} else {
|
||||
// Check if it's an existing "New Chat" receiving its first message
|
||||
const currentSession = chatSessions.find(s => s.session_id === targetSessionId);
|
||||
if (currentSession && currentSession.name === "New Chat" && currentSession.messages.length === 0) {
|
||||
isNewChat = true;
|
||||
}
|
||||
isNewChat = currentSession && currentSession.name === "New Chat" && currentSession.messages.length === 0;
|
||||
}
|
||||
const currentActiveSessionAtSend = activeSessionId; // Capture activeSessionId at send time
|
||||
|
||||
const userMsg = { role: 'user', content: input.trim() };
|
||||
|
||||
// Optimistically add user message
|
||||
setChatSessions(prevSessions =>
|
||||
prevSessions.map(session =>
|
||||
session.session_id === targetSessionId
|
||||
? { ...session, messages: [...(session.messages || []), userMsg] }
|
||||
: session
|
||||
)
|
||||
);
|
||||
setInput('');
|
||||
const userMsg = { role: 'user', content: input.trim(), id: `msg-${Date.now()}-${Math.random()}` };
|
||||
justSentMessage.current = true;
|
||||
lastSentSessionRef.current = targetSessionId;
|
||||
setUserScrolledUp(targetSessionId, false);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${ollamaApiUrl}/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_id: targetSessionId,
|
||||
model,
|
||||
message: userMsg.content
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
const assistantMsg = { role: 'assistant', content: data.reply };
|
||||
// Cancel any pending restore for the active session (we're about to control the scroll)
|
||||
if (activeSessionIdRef.current === targetSessionId) {
|
||||
restoredForRef.current = activeSessionIdRef.current; // mark as already restored
|
||||
}
|
||||
|
||||
// Update messages with assistant's reply
|
||||
// Optimistic add and flush DOM, then scroll to bottom
|
||||
flushSync(() => {
|
||||
setChatSessions(prevSessions =>
|
||||
prevSessions.map(session =>
|
||||
session.session_id === targetSessionId
|
||||
? { ...session, messages: [...(session.messages || []), assistantMsg] }
|
||||
? { ...session, messages: [...(session.messages || []), userMsg] }
|
||||
: session
|
||||
)
|
||||
);
|
||||
setInput('');
|
||||
});
|
||||
requestAnimationFrame(() => scrollToBottom('auto', targetSessionId));
|
||||
|
||||
setIsSending(true);
|
||||
try {
|
||||
if (streamOutput) {
|
||||
const assistantMsgId = `msg-${Date.now()}-${Math.random()}`;
|
||||
const assistantMsg = { role: 'assistant', content: '', id: assistantMsgId };
|
||||
setChatSessions(prevSessions =>
|
||||
prevSessions.map(session =>
|
||||
session.session_id === targetSessionId
|
||||
? { ...session, messages: [...(session.messages || []), assistantMsg] }
|
||||
: session
|
||||
)
|
||||
);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`${ollamaApiUrl}/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_id: targetSessionId,
|
||||
model,
|
||||
message: userMsg.content,
|
||||
stream: true
|
||||
})
|
||||
});
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let fullReply = '';
|
||||
let pendingMarked = false;
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
setChatSessions(prevSessions =>
|
||||
prevSessions.map(session =>
|
||||
session.session_id === targetSessionId
|
||||
? {
|
||||
...session,
|
||||
messages: session.messages.map(m =>
|
||||
m.id === assistantMsgId ? { ...m, content: fullReply } : m
|
||||
)
|
||||
}
|
||||
: session
|
||||
)
|
||||
);
|
||||
|
||||
if (activeSessionIdRef.current === targetSessionId) {
|
||||
if (!userScrolledUpRef.current[targetSessionId]) {
|
||||
// user stayed at bottom -> reveal the message immediately
|
||||
requestAnimationFrame(() => scrollMessageToTop(assistantMsgId, 'smooth', targetSessionId));
|
||||
} else {
|
||||
// user scrolled away while it was generating -> show tip instead of auto-scroll
|
||||
setNewMsgTip(prev => ({ ...prev, [targetSessionId]: assistantMsgId }));
|
||||
}
|
||||
} else {
|
||||
setPendingScrollToLastUser(prev => ({ ...prev, [targetSessionId]: assistantMsgId }));
|
||||
setUnreadSessions(prev => [...new Set([...prev, targetSessionId])]);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
fullReply += chunk;
|
||||
const messageElement = document.getElementById(assistantMsgId)?.firstChild;
|
||||
if (messageElement) {
|
||||
messageElement.innerHTML = markdownToHTML(fullReply);
|
||||
}
|
||||
// Keep sticky-bottom *only* when streaming in the active chat and user is at/near bottom.
|
||||
// This restores the old "push down while generating" behavior without fighting user scrolls.
|
||||
if (
|
||||
activeSessionIdRef.current === targetSessionId &&
|
||||
!userScrolledUpRef.current[targetSessionId]
|
||||
) {
|
||||
// use 'auto' so it stays snappy during streaming
|
||||
scrollToBottom('auto', targetSessionId);
|
||||
}
|
||||
// If streaming in a background chat, prepare a one-time guided scroll
|
||||
if (activeSessionIdRef.current !== targetSessionId && !pendingMarked) {
|
||||
setPendingScrollToLastUser(prev => ({ ...prev, [targetSessionId]: assistantMsgId }));
|
||||
pendingMarked = true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to send message:", e);
|
||||
const errorMsg = { role: 'assistant', content: 'Error: ' + e.message, id: `msg-${Date.now()}-${Math.random()}` };
|
||||
setChatSessions(prevSessions =>
|
||||
prevSessions.map(session =>
|
||||
session.session_id === targetSessionId
|
||||
? { ...session, messages: [...session.messages.slice(0, -1), errorMsg] }
|
||||
: session
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
const res = await fetch(`${ollamaApiUrl}/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_id: targetSessionId,
|
||||
model,
|
||||
message: userMsg.content,
|
||||
stream: false
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
const assistantMsgId = `msg-${Date.now()}`;
|
||||
const assistantMsg = { role: 'assistant', content: data.reply, id: assistantMsgId };
|
||||
|
||||
setChatSessions(prevSessions =>
|
||||
prevSessions.map(session =>
|
||||
session.session_id === targetSessionId
|
||||
? { ...session, messages: [...(session.messages || []), assistantMsg] }
|
||||
: session
|
||||
)
|
||||
);
|
||||
|
||||
// For non-stream: align new ASSISTANT message to top, unless user scrolled away
|
||||
if (assistantMsgId) {
|
||||
if (activeSessionIdRef.current === targetSessionId) {
|
||||
if (!userScrolledUpRef.current[targetSessionId]) {
|
||||
requestAnimationFrame(() => scrollMessageToTop(assistantMsgId, 'smooth', targetSessionId));
|
||||
} else {
|
||||
// <<< show the tip if user scrolled away while waiting >>>
|
||||
setNewMsgTip(prev => ({ ...prev, [targetSessionId]: assistantMsgId }));
|
||||
}
|
||||
} else {
|
||||
setPendingScrollToLastUser(prev => ({ ...prev, [targetSessionId]: assistantMsgId }));
|
||||
}
|
||||
}
|
||||
setIsSending(false);
|
||||
}
|
||||
|
||||
// Handle unread status: only set if the chat was NOT active when the message was sent
|
||||
if (activeSessionIdRef.current !== targetSessionId) {
|
||||
setUnreadSessions(prev => [...new Set([...prev, targetSessionId])]);
|
||||
}
|
||||
|
||||
// Generate title if new chat
|
||||
if (isNewChat) {
|
||||
fetch(`${ollamaApiUrl}/generate-title`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_id: targetSessionId,
|
||||
message: userMsg.content
|
||||
message: userMsg.content,
|
||||
model: model
|
||||
})
|
||||
})
|
||||
.then(r => r.json())
|
||||
@@ -258,15 +587,15 @@ export default function App() {
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to send message:", e);
|
||||
// Add error message to chat
|
||||
const errorMsg = { role: 'assistant', content: 'Error: ' + e.message };
|
||||
const errorMsg = { role: 'assistant', content: 'Error: ' + e.message, id: `msg-${Date.now()}-${Math.random()}` };
|
||||
setChatSessions(prevSessions =>
|
||||
prevSessions.map(session =>
|
||||
session.session_id === targetSessionId
|
||||
? { ...session, messages: [...(session.messages || []), errorMsg] }
|
||||
? { ...session, messages: [...session.messages, errorMsg] }
|
||||
: session
|
||||
)
|
||||
);
|
||||
setIsSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,7 +615,43 @@ export default function App() {
|
||||
|
||||
function selectChat(sessionId) {
|
||||
setActiveSessionId(sessionId);
|
||||
// Clear unread dot immediately for this chat
|
||||
setUnreadSessions(prev => prev.filter(id => id !== sessionId));
|
||||
|
||||
// If we had queued a guided scroll for this chat (from background replies), run it now, smoothly
|
||||
const pendingId = pendingScrollToLastUser[sessionId];
|
||||
if (pendingId) {
|
||||
// Defer until the chat content renders; restoration is gated by restoredForRef, so won't fight
|
||||
requestAnimationFrame(() => {
|
||||
let tries = 12; // ~200ms @ 60fps
|
||||
const attempt = () => {
|
||||
const chatDiv = chatRef.current;
|
||||
if (!chatDiv) return;
|
||||
|
||||
let el = document.getElementById(pendingId);
|
||||
if (!el) {
|
||||
const sess = chatSessions.find(s => s.session_id === sessionId);
|
||||
if (sess && Array.isArray(sess.messages)) {
|
||||
for (let i = sess.messages.length - 1; i >= 0; i--) {
|
||||
const m = sess.messages[i];
|
||||
if (m.role === 'assistant' && m.id) { el = document.getElementById(m.id); break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (el) {
|
||||
scrollMessageToTop(el.id, 'smooth', sessionId);
|
||||
setPendingScrollToLastUser(prev => {
|
||||
const { [sessionId]: _omit, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
} else if (tries-- > 0) {
|
||||
requestAnimationFrame(attempt);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(attempt);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleRename(sessionId, newName) {
|
||||
@@ -334,19 +699,19 @@ export default function App() {
|
||||
<div className="sidebar-header">
|
||||
<div
|
||||
className={`sidebar-tab ${activeSidebarMode === 'chats' ? 'active' : ''}`}
|
||||
onClick={() => setActiveSidebarMode('chats')}
|
||||
onClick={() => handleSidebarClick('chats')}
|
||||
>
|
||||
Chats
|
||||
</div>
|
||||
<div
|
||||
className={`sidebar-tab ${activeSidebarMode === 'dbs' ? 'active' : ''}`}
|
||||
onClick={() => setActiveSidebarMode('dbs')}
|
||||
onClick={() => handleSidebarClick('dbs')}
|
||||
>
|
||||
DBs
|
||||
</div>
|
||||
<div
|
||||
className={`sidebar-tab ${activeSidebarMode === 'settings' ? 'active' : ''}`}
|
||||
onClick={() => setActiveSidebarMode('settings')}
|
||||
onClick={() => handleSidebarClick('settings')}
|
||||
>
|
||||
Settings
|
||||
</div>
|
||||
@@ -358,7 +723,7 @@ export default function App() {
|
||||
<div
|
||||
key={session.session_id}
|
||||
className={`chat-item ${session.session_id === activeSessionId ? 'active' : ''}`}
|
||||
onClick={() => selectChat(session.session_id)}
|
||||
onClick={() => handleSelectChat(session.session_id)}
|
||||
>
|
||||
{editingSessionId === session.session_id ? (
|
||||
<input
|
||||
@@ -434,14 +799,26 @@ export default function App() {
|
||||
<strong>Chat - {chatSessions.find(s => s.session_id === activeSessionId)?.name || 'New Chat'}</strong>
|
||||
</div>
|
||||
|
||||
<div className="chat" ref={chatRef}>
|
||||
<div key={activeSessionId} className="chat" ref={chatRef}>
|
||||
{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>
|
||||
))}
|
||||
</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-content-wrapper">
|
||||
<TextareaAutosize
|
||||
@@ -457,7 +834,9 @@ export default function App() {
|
||||
placeholder="Ask any question..."
|
||||
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>
|
||||
</>
|
||||
@@ -473,7 +852,13 @@ export default function App() {
|
||||
<div className="header">
|
||||
<strong>{activeSettingsSubmenu} Settings</strong>
|
||||
</div>
|
||||
{activeSettingsSubmenu === 'General' && <GeneralSettings />}
|
||||
{activeSettingsSubmenu === 'General' && (
|
||||
<GeneralSettings
|
||||
onModelChange={setModel}
|
||||
streamOutput={streamOutput}
|
||||
onStreamOutputChange={setStreamOutput}
|
||||
/>
|
||||
)}
|
||||
{activeSettingsSubmenu === 'Interface' && <InterfaceSettings />}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -2,17 +2,19 @@ import React, { useState, useEffect } from 'react';
|
||||
|
||||
const API_URL_KEY = 'ollamaApiUrl';
|
||||
const MODEL_KEY = 'chatModel';
|
||||
const STREAM_KEY = 'streamOutput';
|
||||
|
||||
export default function GeneralSettings() {
|
||||
export default function GeneralSettings({ onModelChange, onStreamOutputChange }) {
|
||||
const [ollamaApiUrl, setOllamaApiUrl] = useState('');
|
||||
const [models, setModels] = useState([]);
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
const [streamOutput, setStreamOutput] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
window.electronAPI.getSettings().then(settings => {
|
||||
setOllamaApiUrl(settings.ollamaApiUrl);
|
||||
// Set selectedModel from settings, or fallback to an empty string if not found
|
||||
setSelectedModel(settings.chatModel || '');
|
||||
setStreamOutput(settings.streamOutput || false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -44,6 +46,18 @@ export default function GeneralSettings() {
|
||||
const newModel = e.target.value;
|
||||
setSelectedModel(newModel);
|
||||
window.electronAPI.setSetting(MODEL_KEY, newModel);
|
||||
if (onModelChange) {
|
||||
onModelChange(newModel);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStreamToggle = () => {
|
||||
const newStreamValue = !streamOutput;
|
||||
setStreamOutput(newStreamValue);
|
||||
window.electronAPI.setSetting(STREAM_KEY, newStreamValue);
|
||||
if (onStreamOutputChange) {
|
||||
onStreamOutputChange(newStreamValue);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -69,6 +83,17 @@ export default function GeneralSettings() {
|
||||
{models.map(m => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,52 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { colorSchemes, applyColorScheme } from './colorSchemes';
|
||||
|
||||
const COLOR_SCHEME_KEY = 'colorScheme';
|
||||
|
||||
const colorSchemes = {
|
||||
'Default': {
|
||||
'--bg': '#0b1020',
|
||||
'--panel': '#141b34',
|
||||
'--text': '#e6e8ef',
|
||||
'--muted': '#9aa3b2',
|
||||
'--accent': '#6ea8fe',
|
||||
'--border': '#24304f',
|
||||
'--input-bg': '#0e1530',
|
||||
'--user-msg-bg': '#111933',
|
||||
'--assistant-msg-bg': '#101927',
|
||||
},
|
||||
'Grayscale': {
|
||||
'--bg': '#1a1a1a',
|
||||
'--panel': '#2a2a2a',
|
||||
'--text': '#f0f0f0',
|
||||
'--muted': '#aaaaaa',
|
||||
'--accent': '#888888',
|
||||
'--border': '#4a4a4a',
|
||||
'--input-bg': '#202020',
|
||||
'--user-msg-bg': '#333333',
|
||||
'--assistant-msg-bg': '#252525',
|
||||
},
|
||||
'Rose': {
|
||||
'--bg': '#200a10',
|
||||
'--panel': '#301a20',
|
||||
'--text': '#ffe0e0',
|
||||
'--muted': '#a09090',
|
||||
'--accent': '#E91E63',
|
||||
'--border': '#402025',
|
||||
'--input-bg': '#2a1015',
|
||||
'--user-msg-bg': '#331119',
|
||||
'--assistant-msg-bg': '#271019',
|
||||
},
|
||||
};
|
||||
|
||||
function applyColorScheme(schemeName) {
|
||||
const scheme = colorSchemes[schemeName];
|
||||
if (scheme) {
|
||||
for (const [key, value] of Object.entries(scheme)) {
|
||||
document.documentElement.style.setProperty(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function InterfaceSettings() {
|
||||
const [selectedColorScheme, setSelectedColorScheme] = useState('Default');
|
||||
|
||||
|
||||
78
src/colorSchemes.js
Normal file
78
src/colorSchemes.js
Normal 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 };
|
||||
33
src/main.jsx
33
src/main.jsx
@@ -1,17 +1,30 @@
|
||||
|
||||
import React from 'react'
|
||||
import React, { useEffect } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { HashRouter as Router, Routes, Route } from 'react-router-dom'
|
||||
import App from './App.jsx'
|
||||
import './styles.css'
|
||||
import { applyColorScheme } from './colorSchemes'
|
||||
|
||||
function Main() {
|
||||
useEffect(() => {
|
||||
window.electronAPI.getSettings().then(settings => {
|
||||
if (settings.colorScheme) {
|
||||
applyColorScheme(settings.colorScheme)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<React.StrictMode>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<App />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</React.StrictMode>
|
||||
)
|
||||
}
|
||||
|
||||
const root = createRoot(document.getElementById('root'))
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<App />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</React.StrictMode>
|
||||
)
|
||||
root.render(<Main />)
|
||||
|
||||
@@ -11,8 +11,8 @@ export function markdownToHTML(text) {
|
||||
// 2) Extract code blocks and replace with placeholders
|
||||
const codeblocks = [];
|
||||
const placeholder = idx => `@@CODEBLOCK${idx}@@`;
|
||||
tmp = tmp.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
|
||||
codeblocks.push({ lang, code });
|
||||
tmp = tmp.replace(/```([^\n]*)\n([\s\S]*?)```/g, (_, lang, code) => {
|
||||
codeblocks.push({ lang: (lang || '').trim(), code });
|
||||
return placeholder(codeblocks.length - 1);
|
||||
});
|
||||
|
||||
@@ -29,6 +29,12 @@ export function markdownToHTML(text) {
|
||||
.replace(/^## (.+)$/gm, "<h2>$1</h2>")
|
||||
.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
|
||||
escaped = escaped.replace(
|
||||
/(^|\n)([ \t]*\* .+(?:\n[ \t]*\* .+)*)/g,
|
||||
@@ -42,26 +48,90 @@ export function markdownToHTML(text) {
|
||||
}
|
||||
);
|
||||
|
||||
// 4.6) Markdown tables (GitHub-style). Strict: requires header, separator, ≥2 cols.
|
||||
const mdTableBlockRe =
|
||||
/(^\|[^\n]*\|?\s*\n\|\s*[:\-]+(?:\s*\|\s*[:\-]+)+\s*\|?\s*\n(?:\|[^\n]*\|?\s*(?:\n|$))*)/gm;
|
||||
|
||||
escaped = escaped.replace(mdTableBlockRe, (block) => {
|
||||
// Preserve trailing newline so '^---$' can match next line
|
||||
const hadTrailingNewline = /\n$/.test(block);
|
||||
const lines = block.replace(/\n$/, '').split('\n');
|
||||
|
||||
const split = (line) => line.replace(/^\||\|$/g, '').split('|').map(s => s.trim());
|
||||
|
||||
const headers = split(lines[0]);
|
||||
const seps = split(lines[1]);
|
||||
if (headers.length < 2 || seps.length < 2) return block;
|
||||
if (!seps.every(s => /^[ :\-]+$/.test(s) && /-/.test(s))) return block;
|
||||
|
||||
// Alignment per column (default left). Vertical-align top by default.
|
||||
const aligns = seps.map(seg => {
|
||||
const s = seg.replace(/\s+/g,'');
|
||||
const left = s.startsWith(':');
|
||||
const right = s.endsWith(':');
|
||||
if (left && right) return 'center';
|
||||
if (right) return 'right';
|
||||
return 'left';
|
||||
});
|
||||
|
||||
const bodyLines = lines.slice(2).filter(l => /^\|/.test(l.trim()));
|
||||
const cellStyle = (i) =>
|
||||
` style="text-align:${aligns[i] || 'left'};vertical-align:top;border:1px solid #e5e7eb;padding:.6rem .75rem"`;
|
||||
|
||||
const ths = headers.map((h,i)=>`<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
|
||||
let html = escaped
|
||||
.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>")
|
||||
.replace(/(?<!\*)\*(.+?)\*(?!\*)/g, "<i>$1</i>")
|
||||
.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) => {
|
||||
const { lang, code } = codeblocks[+idx];
|
||||
const title = (lang && lang.trim()) ? lang.trim() : 'code';
|
||||
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>
|
||||
html = html.replace(/\n/g, "<br>");
|
||||
|
||||
// 8) Cleanup stray <br> immediately before/after lists
|
||||
// 8) Cleanup stray <br> around lists/tables/wrappers
|
||||
html = html
|
||||
.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;
|
||||
}
|
||||
}
|
||||
187
src/styles.css
187
src/styles.css
@@ -1,4 +1,4 @@
|
||||
|
||||
/* /Users/giers/Heimgeist/src/styles.css */
|
||||
:root {
|
||||
--bg: #0b1020;
|
||||
--panel: #141b34;
|
||||
@@ -19,7 +19,7 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy
|
||||
|
||||
.app {
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-width, 280px) 1fr; /* Sidebar width and main content */
|
||||
grid-template-columns: var(--sidebar-width, 230px) 1fr; /* Sidebar width and main content */
|
||||
grid-template-rows: 1fr;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -34,6 +34,10 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy
|
||||
position: relative; /* For resizer positioning */
|
||||
}
|
||||
|
||||
.sidebar span{
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.resizer {
|
||||
width: 13px; /* 8px original + 5px to the right */
|
||||
cursor: ew-resize;
|
||||
@@ -50,28 +54,31 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 0; /* Remove padding to allow tabs to fill */
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.sidebar-tab {
|
||||
flex-grow: 1;
|
||||
text-align: center;
|
||||
padding: 12px 16px;
|
||||
padding: 11px 16px;
|
||||
cursor: pointer;
|
||||
border-bottom: 3px solid transparent; /* For active indicator */
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar-tab:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
.sidebar-tab.active {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
background-color: var(--active-bg);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.sidebar-tab.active:hover {
|
||||
background-color: var(--active-bg);
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
@@ -94,12 +101,16 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy
|
||||
}
|
||||
|
||||
.settings-item.active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: var(--active-bg);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
.settings-item.active:hover {
|
||||
background: var(--active-bg);
|
||||
}
|
||||
|
||||
.settings-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.settings-footer-placeholder {
|
||||
@@ -122,6 +133,7 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
|
||||
.chat-list {
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
@@ -188,12 +200,16 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy
|
||||
}
|
||||
|
||||
.chat-item.active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: var(--active-bg);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
.chat-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.chat-item.active:hover {
|
||||
background: var(--active-bg);
|
||||
}
|
||||
|
||||
.rename-input {
|
||||
@@ -226,7 +242,6 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
@@ -244,6 +259,7 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy
|
||||
}
|
||||
|
||||
.main-content {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
height: 100vh; /* Use viewport height to prevent window scrolling */
|
||||
@@ -293,7 +309,7 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
white-space: wrap;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Inter, Helvetica, Arial; /* Match body font */
|
||||
}
|
||||
|
||||
@@ -307,13 +323,37 @@ textarea.input {
|
||||
.msg.user {
|
||||
background: var(--user-msg-bg);
|
||||
margin-left: auto; /* Right-align user messages */
|
||||
max-width: 800px;
|
||||
max-width: 80%;
|
||||
border: 1px solid var(--border);
|
||||
margin-right: 5px;
|
||||
}
|
||||
.msg.assistant {
|
||||
background: transparent;
|
||||
border: none;
|
||||
max-width: none; /* Remove max-width for assistant messages */
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
/* new message pill */
|
||||
.new-msg-tip {
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
bottom: 84px; /* sits just above the footer */
|
||||
padding: 8px 12px;
|
||||
border-radius: 9999px;
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,.35);
|
||||
}
|
||||
|
||||
.new-msg-tip:hover { opacity: .9; }
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.footer {
|
||||
@@ -422,3 +462,124 @@ textarea.input {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.msg a {
|
||||
color: var(--text); /* White color */
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
position: relative;
|
||||
text-decoration: underline dotted;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.msg a span:first-child {
|
||||
}
|
||||
|
||||
.msg a .tooltip {
|
||||
visibility: hidden;
|
||||
width: auto;
|
||||
background-color: var(--panel);
|
||||
color: var(--text);
|
||||
text-align: center;
|
||||
border-radius: 6px;
|
||||
padding: 5px 10px;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
bottom: 110%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.msg a:hover .tooltip {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.msg a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.msg a svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
stroke: var(--text);
|
||||
transition: stroke 0.2s ease;
|
||||
}
|
||||
|
||||
.msg a:hover svg {
|
||||
stroke: var(--accent);
|
||||
}
|
||||
|
||||
.msg hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
/* Toggle Switch Styles */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--input-bg);
|
||||
border: 1px solid var(--border);
|
||||
transition: .4s;
|
||||
border-radius: 28px;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: var(--muted);
|
||||
transition: .4s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: var(--accent);
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(22px);
|
||||
background-color: var(--panel);
|
||||
}
|
||||
|
||||
/* Spinner Styles */
|
||||
.spinner {
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top: 3px solid var(--accent);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user