diff --git a/cortex/cortex.service b/cortex/cortex.service new file mode 100644 index 0000000..8e57438 --- /dev/null +++ b/cortex/cortex.service @@ -0,0 +1,20 @@ +[Unit] +Description=Cortex / Inara LLM Gateway +After=network.target + +[Service] +Type=simple +User=scott +WorkingDirectory=/home/scott/agents_sync/projects/Cortex_and_Inara_dev/cortex +ExecStart=/home/scott/agents_sync/projects/Cortex_and_Inara_dev/cortex/.venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000 +Restart=on-failure +RestartSec=5 + +# Pass through the user's environment so API keys in ~/.env or shell env are available +EnvironmentFile=-/home/scott/agents_sync/projects/Cortex_and_Inara_dev/cortex/.env + +# Give LLM subprocesses time to finish before force-kill +TimeoutStopSec=15 + +[Install] +WantedBy=multi-user.target diff --git a/cortex/routers/chat.py b/cortex/routers/chat.py index 7baa1dc..16cf3e9 100644 --- a/cortex/routers/chat.py +++ b/cortex/routers/chat.py @@ -1,13 +1,12 @@ import asyncio import json -import uuid from fastapi import APIRouter, HTTPException from fastapi.responses import StreamingResponse from pydantic import BaseModel from context_loader import load_context from llm_client import complete from session_logger import log_turn -from session_store import load as load_session, save as save_session, list_all +from session_store import load as load_session, save as save_session, list_all, generate_session_id from config import settings @@ -30,6 +29,10 @@ class NoteRequest(BaseModel): note: str +class HistoryUpdate(BaseModel): + messages: list[dict] + + async def _stream_chat(req: ChatRequest): """ SSE generator: sends keepalive events every 3s while the LLM works, @@ -42,7 +45,7 @@ async def _stream_chat(req: ChatRequest): "backend": "...", "fallback_used": bool} data: {"type": "error", "message": "..."} """ - session_id = req.session_id or str(uuid.uuid4())[:8] + session_id = req.session_id or generate_session_id() tier = req.tier or settings.default_tier system_prompt = load_context(tier) @@ -131,6 +134,13 @@ async def list_sessions() -> dict: return {"sessions": list_all()} +@router.put("/history/{session_id}") +async def replace_history(session_id: str, req: HistoryUpdate) -> dict: + """Replace the full message list for a session (used by edit/delete UI).""" + save_session(session_id, req.messages) + return {"ok": True, "session_id": session_id} + + @router.post("/note") async def add_note(req: NoteRequest) -> dict: """Inject a public note into session history so the LLM sees it next turn.""" diff --git a/cortex/session_store.py b/cortex/session_store.py index c4b7667..3bc8529 100644 --- a/cortex/session_store.py +++ b/cortex/session_store.py @@ -1,9 +1,46 @@ import json +import random from pathlib import Path from datetime import datetime from config import settings +_ADJECTIVES = [ + "amber", "azure", "bold", "bright", "calm", "cedar", "cobalt", "coral", + "crisp", "dusk", "ember", "fern", "frost", "gold", "hazy", "indigo", + "jade", "keen", "lark", "lunar", "maple", "misty", "noble", "north", + "oak", "onyx", "opal", "pine", "quiet", "raven", "ridge", "river", + "sage", "silver", "slate", "solar", "steel", "stone", "swift", "teal", + "timber", "vale", "velvet", "violet", "warm", "wild", "winter", "wren", +] + +_NOUNS = [ + "bay", "bloom", "brook", "canyon", "cave", "cliff", "cloud", "coast", + "creek", "dale", "dawn", "delta", "dune", "echo", "falls", "field", + "fjord", "flare", "glade", "glen", "grove", "harbor", "haven", "hill", + "hollow", "isle", "lake", "ledge", "marsh", "meadow", "mist", "moon", + "moor", "peak", "plain", "pond", "reef", "ridge", "rise", "rock", + "shore", "sky", "slope", "spring", "star", "storm", "trail", "vale", + "wave", "wood", +] + + +def generate_session_id() -> str: + """Return a readable slug like 'amber-lake' or 'amber-lake-03'.""" + existing = {s["session_id"] for s in list_all()} + for _ in range(30): + base = f"{random.choice(_ADJECTIVES)}-{random.choice(_NOUNS)}" + if base not in existing: + return base + # Try with a numeric suffix + candidate = f"{base}-{random.randint(2, 99):02d}" + if candidate not in existing: + return candidate + # Statistically unreachable for a personal tool + import uuid + return str(uuid.uuid4())[:8] + + def _path(session_id: str) -> Path: d = settings.sessions_path() d.mkdir(parents=True, exist_ok=True) diff --git a/cortex/static/index.html b/cortex/static/index.html index 143eed6..2923952 100644 --- a/cortex/static/index.html +++ b/cortex/static/index.html @@ -419,12 +419,109 @@ #send:disabled { background: var(--surface); color: var(--muted); border-color: var(--border); cursor: not-allowed; } + /* Stop button */ + #stop { + display: none; + background: var(--error-bg); + border: 1px solid var(--error-border); + color: var(--error-text); + border-radius: 8px; + padding: 10px 0; + cursor: pointer; + font-size: 0.9rem; + text-align: center; + transition: background 0.15s; + } + + #stop:hover { background: #5c1a1a; } + #session-id { font-size: 0.7rem; color: var(--border); padding: 0 20px 6px; background: var(--surface); } + + /* ── Message wrappers (edit/delete controls) ──────────────── */ + .msg-wrapper { + display: flex; + flex-direction: column; + max-width: 75%; + } + + .msg-wrapper.user { align-self: flex-end; } + .msg-wrapper.assistant { align-self: flex-start; } + + /* Inner message fills wrapper width */ + .msg-wrapper .message.user, + .msg-wrapper .message.assistant { + align-self: stretch; + max-width: none; + } + + .msg-actions { + display: flex; + gap: 4px; + opacity: 0; + transition: opacity 0.15s; + padding: 2px 2px 0; + } + + .msg-wrapper.user .msg-actions { justify-content: flex-end; } + .msg-wrapper.assistant .msg-actions { justify-content: flex-start; } + .msg-wrapper:hover .msg-actions { opacity: 1; } + + .msg-act-btn { + background: none; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--muted); + font-size: 0.65rem; + padding: 1px 6px; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; + } + + .msg-act-btn:hover { color: var(--text); border-color: var(--muted); } + .msg-act-btn.del:hover { color: var(--error-text); border-color: var(--error-border); } + + /* Inline edit */ + .edit-textarea { + width: 100%; + background: var(--bg); + border: 1px solid var(--muted); + border-radius: 6px; + color: var(--text); + padding: 6px 10px; + font-size: 0.9rem; + font-family: inherit; + resize: vertical; + line-height: 1.4; + } + + .edit-textarea:focus { outline: none; border-color: var(--accent); } + + .edit-btns { + display: flex; + gap: 6px; + margin-top: 6px; + justify-content: flex-end; + } + + .edit-save-btn, .edit-cancel-btn { + background: none; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--muted); + font-size: 0.75rem; + padding: 3px 10px; + cursor: pointer; + transition: color 0.15s, border-color 0.15s, background 0.15s; + } + + .edit-save-btn { border-color: var(--inara-border); color: var(--accent); } + .edit-save-btn:hover { background: var(--inara-bg); } + .edit-cancel-btn:hover { color: var(--text); border-color: var(--muted); }
@@ -460,6 +557,7 @@ + @@ -477,9 +575,12 @@ const enterToggle = document.getElementById('enter-toggle'); const noteTypeBtnEl = document.getElementById('note-type-btn'); const noteBtnEl = document.getElementById('note-btn'); + const stopBtn = document.getElementById('stop'); - let sessionId = null; - let primaryBackend = 'claude'; + let sessionId = null; + let primaryBackend = 'claude'; + let activeController = null; + let currentHistory = []; // mirrors backend session [{role, content}, ...] // ── Enter toggle ───────────────────────────────────────────── // Default: Ctrl+Enter sends. Stored in localStorage. @@ -621,6 +722,7 @@ const newItem = makeItem('new', '+ New session', ''); newItem.addEventListener('click', () => { sessionId = null; + currentHistory = []; messagesEl.innerHTML = ''; sessionEl.textContent = ''; addMessage('system', 'New session'); @@ -673,12 +775,18 @@ messagesEl.innerHTML = ''; sessionId = id; sessionEl.textContent = `session: ${id}`; + currentHistory = []; - for (const msg of data.messages) { - addMessage(msg.role === 'user' ? 'user' : 'assistant', msg.content); + for (let i = 0; i < data.messages.length; i++) { + const msg = data.messages[i]; + const role = msg.role === 'user' ? 'user' : 'assistant'; + currentHistory.push({ role, content: msg.content }); + const msgDiv = addMessage(role, msg.content); + attachHistoryControls(msgDiv, i); } addMessage('system', `Resumed session ${id}`); + scrollToBottom(); sessionsPanel.classList.remove('open'); inputEl.focus(); } @@ -703,8 +811,20 @@ document.body.removeChild(ta); } + // ── Scroll helpers ──────────────────────────────────────────── + // Only auto-scroll when the user is already near the bottom (within 80px). + // Explicit user actions (send, resume) call scrollToBottom() directly. + function isNearBottom() { + return messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 80; + } + + function scrollToBottom() { + messagesEl.scrollTop = messagesEl.scrollHeight; + } + // ── Chat ───────────────────────────────────────────────────── + // Returns the inner .message div. For user/assistant, wraps in .msg-wrapper. function addMessage(role, text) { const div = document.createElement('div'); div.className = `message ${role}`; @@ -730,11 +850,152 @@ div.textContent = text; } - messagesEl.appendChild(div); - messagesEl.scrollTop = messagesEl.scrollHeight; + // Wrap user/assistant messages so action buttons can be attached + const baseRole = role.split(' ')[0]; // 'user' or 'assistant' (strips 'thinking' etc) + if (baseRole === 'user' || baseRole === 'assistant') { + const wrapper = document.createElement('div'); + wrapper.className = `msg-wrapper ${baseRole}`; + wrapper.appendChild(div); + const actions = document.createElement('div'); + actions.className = 'msg-actions'; + wrapper.appendChild(actions); + messagesEl.appendChild(wrapper); + } else { + messagesEl.appendChild(div); + } + + if (isNearBottom()) scrollToBottom(); return div; } + // Wire edit/delete controls onto a message div (must already be in a .msg-wrapper). + // histIdx is the index into currentHistory. Reads wrapper.dataset.histIdx at click time + // so re-indexing after deletions is automatically picked up. + function attachHistoryControls(msgDiv, histIdx) { + const wrapper = msgDiv.parentElement; + if (!wrapper || !wrapper.classList.contains('msg-wrapper')) return; + wrapper.dataset.histIdx = histIdx; + + const actionsDiv = wrapper.querySelector('.msg-actions'); + if (!actionsDiv) return; + actionsDiv.innerHTML = ''; + + const editBtn = document.createElement('button'); + editBtn.className = 'msg-act-btn'; + editBtn.textContent = 'edit'; + editBtn.addEventListener('click', () => { + startEdit(msgDiv); + }); + + const delBtn = document.createElement('button'); + delBtn.className = 'msg-act-btn del'; + delBtn.textContent = 'del'; + delBtn.addEventListener('click', () => { + deleteMsg(wrapper); + }); + + actionsDiv.appendChild(editBtn); + actionsDiv.appendChild(delBtn); + } + + // After any currentHistory splice, renumber all wrapper data-hist-idx attributes. + function reIndexWrappers() { + messagesEl.querySelectorAll('.msg-wrapper').forEach((w, i) => { + w.dataset.histIdx = i; + }); + } + + function startEdit(msgDiv) { + const wrapper = msgDiv.parentElement; + const idx = parseInt(wrapper.dataset.histIdx); + const role = msgDiv.classList.contains('user') ? 'user' : 'assistant'; + const originalText = currentHistory[idx]?.content + || msgDiv.dataset.raw + || msgDiv.textContent; + + // Lock the current rendered size so the bubble doesn't collapse when we clear it + const lockedW = msgDiv.offsetWidth; + const lockedH = msgDiv.offsetHeight; + msgDiv.style.minWidth = lockedW + 'px'; + msgDiv.style.minHeight = lockedH + 'px'; + + const actionsDiv = wrapper.querySelector('.msg-actions'); + if (actionsDiv) actionsDiv.style.display = 'none'; + + const ta = document.createElement('textarea'); + ta.className = 'edit-textarea'; + ta.value = originalText; + ta.rows = Math.min(originalText.split('\n').length + 1, 12); + + const saveBtn = document.createElement('button'); + saveBtn.textContent = 'Save'; + saveBtn.className = 'edit-save-btn'; + + const cancelBtn = document.createElement('button'); + cancelBtn.textContent = 'Cancel'; + cancelBtn.className = 'edit-cancel-btn'; + + const btnRow = document.createElement('div'); + btnRow.className = 'edit-btns'; + btnRow.appendChild(saveBtn); + btnRow.appendChild(cancelBtn); + + msgDiv.innerHTML = ''; + msgDiv.appendChild(ta); + msgDiv.appendChild(btnRow); + ta.focus(); + ta.setSelectionRange(ta.value.length, ta.value.length); + + function unlock() { + msgDiv.style.minWidth = ''; + msgDiv.style.minHeight = ''; + if (actionsDiv) actionsDiv.style.display = ''; + } + + function restore() { + setMessageText(msgDiv, role, originalText); + unlock(); + } + + function save() { + const newText = ta.value.trim(); + if (!newText) return; + const currentIdx = parseInt(wrapper.dataset.histIdx); + currentHistory[currentIdx].content = newText; + setMessageText(msgDiv, role, newText); + unlock(); + syncHistory(); + } + + saveBtn.addEventListener('click', save); + cancelBtn.addEventListener('click', restore); + ta.addEventListener('keydown', (e) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); save(); } + if (e.key === 'Escape') { e.preventDefault(); restore(); } + }); + } + + function deleteMsg(wrapper) { + const idx = parseInt(wrapper.dataset.histIdx); + currentHistory.splice(idx, 1); + wrapper.remove(); + reIndexWrappers(); + syncHistory(); + } + + async function syncHistory() { + if (!sessionId) return; + try { + await fetch(`/history/${sessionId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ messages: currentHistory }), + }); + } catch (err) { + console.error('syncHistory failed:', err); + } + } + function setMessageText(div, role, text) { if (role === 'assistant' && typeof marked !== 'undefined') { div.dataset.raw = text; @@ -803,16 +1064,28 @@ } } + stopBtn.addEventListener('click', () => { + if (activeController) activeController.abort(); + }); + async function sendMessage() { const text = inputEl.value.trim(); - if (!text || sendBtn.disabled) return; + if (!text || activeController) return; inputEl.value = ''; syncHeight(); - sendBtn.disabled = true; + sendBtn.style.display = 'none'; + stopBtn.style.display = 'block'; headerEmoji.classList.add('processing'); - addMessage('user', text); + activeController = new AbortController(); + + const userHistIdx = currentHistory.length; + currentHistory.push({ role: 'user', content: text }); + const userMsgDiv = addMessage('user', text); + attachHistoryControls(userMsgDiv, userHistIdx); + scrollToBottom(); + const thinkingDiv = addMessage('assistant thinking', '✨ thinking…'); try { @@ -820,6 +1093,7 @@ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: text, session_id: sessionId }), + signal: activeController.signal, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); @@ -847,6 +1121,9 @@ sessionEl.textContent = `session: ${sessionId}`; thinkingDiv.className = 'message assistant'; setMessageText(thinkingDiv, 'assistant', data.response); + const assistHistIdx = currentHistory.length; + currentHistory.push({ role: 'assistant', content: data.response }); + attachHistoryControls(thinkingDiv, assistHistIdx); if (data.fallback_used) { addMessage('system', `⚡ ${primaryBackend} unavailable — answered by ${data.backend}`); @@ -857,12 +1134,19 @@ } } } catch (err) { - thinkingDiv.className = 'message error'; - thinkingDiv.textContent = `Error: ${err.message}`; + if (err.name === 'AbortError') { + thinkingDiv.className = 'message system'; + thinkingDiv.textContent = 'Stopped.'; + } else { + thinkingDiv.className = 'message error'; + thinkingDiv.textContent = `Error: ${err.message}`; + } } + activeController = null; headerEmoji.classList.remove('processing'); - sendBtn.disabled = false; + sendBtn.style.display = 'block'; + stopBtn.style.display = 'none'; inputEl.focus(); }