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:
20
cortex/cortex.service
Normal file
20
cortex/cortex.service
Normal 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
|
||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user