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 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."""

View File

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

View File

@@ -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); }
</style>
</head>
<body>
@@ -460,6 +557,7 @@
<button id="note-type-btn">private</button>
<button id="note-btn">Note</button>
<button id="send">Send</button>
<button id="stop">Stop</button>
</div>
</div>
@@ -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();
}