Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions backend/routes/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,14 @@ async def clear_messages(session_id: str):
return {"status": "cleared"}


@router.delete("/{session_id}/messages/{message_id}")
async def delete_message(session_id: str, message_id: int):
deleted = db_service.delete_message(session_id, message_id)
if not deleted:
raise HTTPException(404, "Message not found")
return {"status": "deleted", "session_id": session_id, "message_id": message_id}


@router.get("/{session_id}/documents")
async def get_documents(session_id: str):
docs = db_service.get_documents(session_id)
Expand Down
24 changes: 23 additions & 1 deletion backend/services/db_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,11 +209,12 @@ def get_history(session_id: str, limit: int = 20) -> list[dict]:
def get_messages_full(session_id: str) -> list[dict]:
with get_db() as conn:
rows = conn.execute(
"SELECT role, content, sources, created_at, benchmarks FROM messages WHERE session_id=? ORDER BY created_at ASC",
"SELECT id, role, content, sources, created_at, benchmarks FROM messages WHERE session_id=? ORDER BY created_at ASC",
(session_id,),
).fetchall()
return [
{
"id": r["id"],
"role": r["role"],
"content": r["content"],
"sources": json.loads(r["sources"] or "[]"),
Expand All @@ -230,6 +231,27 @@ def clear_messages(session_id: str):
conn.execute("UPDATE sessions SET message_count=0 WHERE id=?", (session_id,))


def delete_message(session_id: str, message_id: int) -> int:
"""Delete a single message from a session.

Returns the number of rows deleted (0 if the message does not exist in this
session). The delete is scoped by session_id so a message can only be
removed from its own thread.
"""
with get_db() as conn:
cur = conn.execute(
"DELETE FROM messages WHERE id=? AND session_id=?",
(message_id, session_id),
)
deleted = cur.rowcount
if deleted:
conn.execute(
"UPDATE sessions SET message_count=MAX(message_count - ?, 0), updated_at=datetime('now') WHERE id=?",
(deleted, session_id),
)
return deleted


# ─── Documents ───────────────────────────────────────────────
def save_document(session_id: str, filename: str, file_path: str, chunks: int, size_kb: float, status: str = "completed") -> int:
with get_db() as conn:
Expand Down
26 changes: 26 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,32 @@ def test_clear_messages():
assert r2.status_code == 200


def test_delete_single_message():
r = client.post("/api/sessions/", json={"title": "Del Msg Test"})
sid = r.json()["id"]
db.save_message(sid, "user", "first")
db.save_message(sid, "assistant", "second")

msgs = client.get(f"/api/sessions/{sid}/messages").json()["messages"]
assert len(msgs) == 2
target_id = msgs[0]["id"]

r2 = client.delete(f"/api/sessions/{sid}/messages/{target_id}")
assert r2.status_code == 200
assert r2.json()["status"] == "deleted"

remaining = client.get(f"/api/sessions/{sid}/messages").json()["messages"]
assert len(remaining) == 1
assert all(m["id"] != target_id for m in remaining)


def test_delete_message_not_found():
r = client.post("/api/sessions/", json={"title": "Del 404 Test"})
sid = r.json()["id"]
r2 = client.delete(f"/api/sessions/{sid}/messages/999999")
assert r2.status_code == 404


# ─── Upload ──────────────────────────────────────────────
def test_upload_invalid_type():
files = {"file": ("bad.exe", b"data", "application/octet-stream")}
Expand Down
15 changes: 14 additions & 1 deletion frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,11 +164,23 @@ export default function App() {
setPanel(null);
try {
const [msgRes, docRes] = await Promise.all([api.getMessages(sid), api.getDocuments(sid)]);
setMessages((msgRes.messages || []).map((m, i) => ({ ...m, id: i })));
setMessages((msgRes.messages || []).map((m, i) => ({ ...m, id: m.id ?? i })));
setDocuments(docRes.documents || []);
} catch { }
}

async function handleDeleteMessage(messageId) {
// Optimistically remove from the thread for instant feedback.
setMessages(prev => prev.filter(m => m.id !== messageId));
try {
await api.deleteMessage(sessionId, messageId);
refreshSessions(); // keep the sidebar message count in sync
} catch {
// The message may have been local-only (not yet persisted); it is already
// removed from the UI, so nothing more to do.
}
}

async function handleDeleteSession(sid) {
await api.deleteSession(sid);
if (sid === sessionId) { setSessionId(uuidv4()); setMessages([]); setDocuments([]); }
Expand Down Expand Up @@ -250,6 +262,7 @@ export default function App() {
messages={messages}
loading={loading || streaming}
onSend={sendMessage}
onDeleteMessage={handleDeleteMessage}
onStop={stopGeneration}
sessionId={sessionId}
/>
Expand Down
36 changes: 34 additions & 2 deletions frontend/src/components/ChatWindow.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useState, useRef, useEffect } from "react";
import { exportSession } from "../utils/api";
import { AppLogoIcon, LockIcon } from "./Icons";

export default function ChatWindow({ messages, loading, onSend, onStop, sessionId }) {
export default function ChatWindow({ messages, loading, onSend, onDeleteMessage, onStop, sessionId }) {
const [input, setInput] = useState("");
const [showPlusMenu, setShowPlusMenu] = useState(false);
const [showTemplateDialog, setShowTemplateDialog] = useState(false);
Expand All @@ -16,6 +16,34 @@ export default function ChatWindow({ messages, loading, onSend, onStop, sessionI
const [exportFormat, setExportFormat] = useState("markdown");
const [copiedMsgId, setCopiedMsgId] = useState(null);
const [hoveredStatsId, setHoveredStatsId] = useState(null);
const [confirmDeleteId, setConfirmDeleteId] = useState(null);

// Inline delete control with a lightweight two-step confirm (no window.confirm).
const renderDeleteControl = (msgId) =>
confirmDeleteId === msgId ? (
<span className="flex items-center gap-1 text-xs">
<span className="text-gray-500">Delete?</span>
<button
onClick={() => { onDeleteMessage?.(msgId); setConfirmDeleteId(null); }}
className="px-1.5 py-0.5 rounded bg-red-600/80 hover:bg-red-600 text-white transition"
title="Confirm delete"
>Yes</button>
<button
onClick={() => setConfirmDeleteId(null)}
className="px-1.5 py-0.5 rounded hover:bg-gray-700 text-gray-400 transition"
title="Cancel"
>No</button>
</span>
) : (
<button
onClick={() => setConfirmDeleteId(msgId)}
className="p-1 rounded hover:bg-gray-800 text-gray-500 hover:text-red-400 transition"
title="Delete message"
aria-label="Delete message"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg>
</button>
);

useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]);

Expand Down Expand Up @@ -160,7 +188,8 @@ export default function ChatWindow({ messages, loading, onSend, onStop, sessionI
</div>
)}
{msg.role === "user" && (
<div className="text-right mt-1 mr-1">
<div className="flex justify-end items-center gap-1 mt-1 mr-1">
{renderDeleteControl(msg.id)}
<span className="text-xs text-gray-400">You</span>
</div>
)}
Expand All @@ -179,6 +208,9 @@ export default function ChatWindow({ messages, loading, onSend, onStop, sessionI
)}
</button>

{/* Delete button */}
{renderDeleteControl(msg.id)}

{/* Stats hover button */}
<div
className="relative"
Expand Down
1 change: 1 addition & 0 deletions frontend/src/utils/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const deleteSession = (id) => req(`/sessions/${id}`, { method: "DELETE" }
export const clearAllSessions = () => req("/sessions/", { method: "DELETE" });
export const getMessages = (id) => req(`/sessions/${id}/messages`);
export const clearMessages = (id) => req(`/sessions/${id}/messages`, { method: "DELETE" });
export const deleteMessage = (id, messageId) => req(`/sessions/${id}/messages/${messageId}`, { method: "DELETE" });
export const getDocuments = (id) => req(`/sessions/${id}/documents`);
export const getModels = () => req("/models/");
export const getOllamaStatus = () => req("/models/status");
Expand Down
Loading