Add edit/delete history, named sessions, scroll fix, systemd service

- Edit/delete individual messages from session context with inline editing
  (Ctrl+Enter saves, Escape cancels); changes sync to backend via PUT /history
- PUT /history/{session_id} endpoint to replace full message list
- Named sessions: readable slugs (e.g. quiet-spring) instead of UUID fragments
- Scroll no longer snaps to bottom when user has scrolled up to read history
- cortex.service: systemd unit for auto-start and restart-on-failure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-10 23:38:39 -04:00
parent 2f675ee4bf
commit 8add4ffd02
4 changed files with 366 additions and 15 deletions

20
cortex/cortex.service Normal file
View File

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

View File

@@ -1,13 +1,12 @@
import asyncio import asyncio
import json import json
import uuid
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
from context_loader import load_context from context_loader import load_context
from llm_client import complete from llm_client import complete
from session_logger import log_turn 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 from config import settings
@@ -30,6 +29,10 @@ class NoteRequest(BaseModel):
note: str note: str
class HistoryUpdate(BaseModel):
messages: list[dict]
async def _stream_chat(req: ChatRequest): async def _stream_chat(req: ChatRequest):
""" """
SSE generator: sends keepalive events every 3s while the LLM works, 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} "backend": "...", "fallback_used": bool}
data: {"type": "error", "message": "..."} 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 tier = req.tier or settings.default_tier
system_prompt = load_context(tier) system_prompt = load_context(tier)
@@ -131,6 +134,13 @@ async def list_sessions() -> dict:
return {"sessions": list_all()} 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") @router.post("/note")
async def add_note(req: NoteRequest) -> dict: async def add_note(req: NoteRequest) -> dict:
"""Inject a public note into session history so the LLM sees it next turn.""" """Inject a public note into session history so the LLM sees it next turn."""

View File

@@ -1,9 +1,46 @@
import json import json
import random
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
from config import settings 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: def _path(session_id: str) -> Path:
d = settings.sessions_path() d = settings.sessions_path()
d.mkdir(parents=True, exist_ok=True) d.mkdir(parents=True, exist_ok=True)

View File

@@ -419,12 +419,109 @@
#send:disabled { background: var(--surface); color: var(--muted); #send:disabled { background: var(--surface); color: var(--muted);
border-color: var(--border); cursor: not-allowed; } 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 { #session-id {
font-size: 0.7rem; font-size: 0.7rem;
color: var(--border); color: var(--border);
padding: 0 20px 6px; padding: 0 20px 6px;
background: var(--surface); 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); }
</style> </style>
</head> </head>
<body> <body>
@@ -460,6 +557,7 @@
<button id="note-type-btn">private</button> <button id="note-type-btn">private</button>
<button id="note-btn">Note</button> <button id="note-btn">Note</button>
<button id="send">Send</button> <button id="send">Send</button>
<button id="stop">Stop</button>
</div> </div>
</div> </div>
@@ -477,9 +575,12 @@
const enterToggle = document.getElementById('enter-toggle'); const enterToggle = document.getElementById('enter-toggle');
const noteTypeBtnEl = document.getElementById('note-type-btn'); const noteTypeBtnEl = document.getElementById('note-type-btn');
const noteBtnEl = document.getElementById('note-btn'); const noteBtnEl = document.getElementById('note-btn');
const stopBtn = document.getElementById('stop');
let sessionId = null; let sessionId = null;
let primaryBackend = 'claude'; let primaryBackend = 'claude';
let activeController = null;
let currentHistory = []; // mirrors backend session [{role, content}, ...]
// ── Enter toggle ───────────────────────────────────────────── // ── Enter toggle ─────────────────────────────────────────────
// Default: Ctrl+Enter sends. Stored in localStorage. // Default: Ctrl+Enter sends. Stored in localStorage.
@@ -621,6 +722,7 @@
const newItem = makeItem('new', '+ New session', ''); const newItem = makeItem('new', '+ New session', '');
newItem.addEventListener('click', () => { newItem.addEventListener('click', () => {
sessionId = null; sessionId = null;
currentHistory = [];
messagesEl.innerHTML = ''; messagesEl.innerHTML = '';
sessionEl.textContent = ''; sessionEl.textContent = '';
addMessage('system', 'New session'); addMessage('system', 'New session');
@@ -673,12 +775,18 @@
messagesEl.innerHTML = ''; messagesEl.innerHTML = '';
sessionId = id; sessionId = id;
sessionEl.textContent = `session: ${id}`; sessionEl.textContent = `session: ${id}`;
currentHistory = [];
for (const msg of data.messages) { for (let i = 0; i < data.messages.length; i++) {
addMessage(msg.role === 'user' ? 'user' : 'assistant', msg.content); 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}`); addMessage('system', `Resumed session ${id}`);
scrollToBottom();
sessionsPanel.classList.remove('open'); sessionsPanel.classList.remove('open');
inputEl.focus(); inputEl.focus();
} }
@@ -703,8 +811,20 @@
document.body.removeChild(ta); 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 ───────────────────────────────────────────────────── // ── Chat ─────────────────────────────────────────────────────
// Returns the inner .message div. For user/assistant, wraps in .msg-wrapper.
function addMessage(role, text) { function addMessage(role, text) {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = `message ${role}`; div.className = `message ${role}`;
@@ -730,11 +850,152 @@
div.textContent = text; div.textContent = text;
} }
messagesEl.appendChild(div); // Wrap user/assistant messages so action buttons can be attached
messagesEl.scrollTop = messagesEl.scrollHeight; 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; 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) { function setMessageText(div, role, text) {
if (role === 'assistant' && typeof marked !== 'undefined') { if (role === 'assistant' && typeof marked !== 'undefined') {
div.dataset.raw = text; div.dataset.raw = text;
@@ -803,16 +1064,28 @@
} }
} }
stopBtn.addEventListener('click', () => {
if (activeController) activeController.abort();
});
async function sendMessage() { async function sendMessage() {
const text = inputEl.value.trim(); const text = inputEl.value.trim();
if (!text || sendBtn.disabled) return; if (!text || activeController) return;
inputEl.value = ''; inputEl.value = '';
syncHeight(); syncHeight();
sendBtn.disabled = true; sendBtn.style.display = 'none';
stopBtn.style.display = 'block';
headerEmoji.classList.add('processing'); 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…'); const thinkingDiv = addMessage('assistant thinking', '✨ thinking…');
try { try {
@@ -820,6 +1093,7 @@
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text, session_id: sessionId }), body: JSON.stringify({ message: text, session_id: sessionId }),
signal: activeController.signal,
}); });
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -847,6 +1121,9 @@
sessionEl.textContent = `session: ${sessionId}`; sessionEl.textContent = `session: ${sessionId}`;
thinkingDiv.className = 'message assistant'; thinkingDiv.className = 'message assistant';
setMessageText(thinkingDiv, 'assistant', data.response); setMessageText(thinkingDiv, 'assistant', data.response);
const assistHistIdx = currentHistory.length;
currentHistory.push({ role: 'assistant', content: data.response });
attachHistoryControls(thinkingDiv, assistHistIdx);
if (data.fallback_used) { if (data.fallback_used) {
addMessage('system', addMessage('system',
`${primaryBackend} unavailable — answered by ${data.backend}`); `${primaryBackend} unavailable — answered by ${data.backend}`);
@@ -857,12 +1134,19 @@
} }
} }
} catch (err) { } catch (err) {
thinkingDiv.className = 'message error'; if (err.name === 'AbortError') {
thinkingDiv.textContent = `Error: ${err.message}`; thinkingDiv.className = 'message system';
thinkingDiv.textContent = 'Stopped.';
} else {
thinkingDiv.className = 'message error';
thinkingDiv.textContent = `Error: ${err.message}`;
}
} }
activeController = null;
headerEmoji.classList.remove('processing'); headerEmoji.classList.remove('processing');
sendBtn.disabled = false; sendBtn.style.display = 'block';
stopBtn.style.display = 'none';
inputEl.focus(); inputEl.focus();
} }