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
24 changes: 24 additions & 0 deletions deeptutor/api/routers/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from pydantic import BaseModel, Field, field_validator

from deeptutor.services.session import get_session_store, get_sqlite_session_store
from deeptutor.services.storage.attachment_store import get_attachment_store

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -106,9 +107,32 @@ async def delete_session(session_id: str):
deleted = await store.delete_session(session_id)
if not deleted:
raise HTTPException(status_code=404, detail="Session not found")
try:
await get_attachment_store().delete_session(session_id)
except Exception:
logger.exception("failed to clean up attachments for session %s", session_id)
return {"deleted": True, "session_id": session_id}


@router.delete("/{session_id}/messages/{message_id}")
async def delete_turn_by_message(session_id: str, message_id: int):
store = get_sqlite_session_store()
result = await store.delete_turn_by_message(session_id, message_id)
if result["was_running"]:
raise HTTPException(
status_code=409, detail="Cannot delete a message while its turn is running"
)
if not result["deleted"]:
raise HTTPException(status_code=404, detail="Message not found")
attachment_store = get_attachment_store()
for aid in result["attachment_ids"]:
try:
await attachment_store.delete_attachment(session_id, aid)
except Exception:
logger.exception("failed to delete attachment %s for session %s", aid, session_id)
return result


@router.post("/{session_id}/quiz-results")
async def record_quiz_results(session_id: str, payload: QuizResultsRequest):
if not payload.answers:
Expand Down
119 changes: 119 additions & 0 deletions deeptutor/services/session/sqlite_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,125 @@ def _delete_message_sync(self, message_id: int | str) -> bool:
async def delete_message(self, message_id: int | str) -> bool:
return await self._run(self._delete_message_sync, message_id)

def _delete_turn_by_message_sync(self, session_id: str, message_id: int) -> dict[str, Any]:
with self._connect() as conn:
msg = conn.execute(
"""
SELECT id, session_id, role, attachments_json, created_at
FROM messages
WHERE id = ?
""",
(int(message_id),),
).fetchone()
if msg is None or msg["session_id"] != session_id:
return {
"deleted": False,
"attachment_ids": [],
"turn_id": None,
"was_running": False,
}

role = msg["role"]
paired_msg = None
if role == "user":
paired_msg = conn.execute(
"""
SELECT id, session_id, role, attachments_json, created_at
FROM messages
WHERE session_id = ? AND role = 'assistant' AND id > ?
ORDER BY id ASC
LIMIT 1
""",
(session_id, int(message_id)),
).fetchone()
elif role == "assistant":
paired_msg = conn.execute(
"""
SELECT id, session_id, role, attachments_json, created_at
FROM messages
WHERE session_id = ? AND role = 'user' AND id < ?
ORDER BY id DESC
LIMIT 1
""",
(session_id, int(message_id)),
).fetchone()

user_msg = msg if role == "user" else paired_msg
turn_id = None
was_running = False
if user_msg is not None:
user_created_at = user_msg["created_at"]
turn_row = conn.execute(
"""
SELECT id, status
FROM turns
WHERE session_id = ? AND created_at >= ?
ORDER BY created_at ASC
LIMIT 1
""",
(session_id, user_created_at),
).fetchone()
if turn_row is not None:
turn_id = turn_row["id"]
was_running = turn_row["status"] == "running"

if was_running:
return {
"deleted": False,
"attachment_ids": [],
"turn_id": turn_id,
"was_running": True,
}

attachment_ids: list[str] = []
for m in [msg, paired_msg]:
if m is not None:
atts = _json_loads(m["attachments_json"], [])
for att in atts:
aid = att.get("id") or att.get("attachment_id")
if aid:
attachment_ids.append(aid)

if turn_id is not None:
conn.execute("DELETE FROM turn_events WHERE turn_id = ?", (turn_id,))
conn.execute("DELETE FROM turns WHERE id = ?", (turn_id,))

ids_to_delete = [int(message_id)]
if paired_msg is not None:
ids_to_delete.append(int(paired_msg["id"]))
conn.execute(
f"DELETE FROM messages WHERE id IN ({','.join('?' * len(ids_to_delete))})", # nosec B608
tuple(ids_to_delete),
)

session_row = conn.execute(
"SELECT summary_up_to_msg_id FROM sessions WHERE id = ?",
(session_id,),
).fetchone()
if session_row is not None:
summary_up_to = int(session_row["summary_up_to_msg_id"])
if any(mid <= summary_up_to for mid in ids_to_delete):
conn.execute(
"UPDATE sessions SET summary_up_to_msg_id = 0 WHERE id = ?",
(session_id,),
)

conn.execute(
"UPDATE sessions SET updated_at = ? WHERE id = ?",
(time.time(), session_id),
)
conn.commit()

return {
"deleted": True,
"attachment_ids": attachment_ids,
"turn_id": turn_id,
"was_running": was_running,
}

async def delete_turn_by_message(self, session_id: str, message_id: int) -> dict[str, Any]:
return await self._run(self._delete_turn_by_message_sync, session_id, message_id)

def _get_last_message_sync(
self, session_id: str, role: str | None = None
) -> dict[str, Any] | None:
Expand Down
25 changes: 25 additions & 0 deletions deeptutor/services/storage/attachment_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ async def put(
async def delete_session(self, session_id: str) -> None:
"""Best-effort cleanup of all attachments for *session_id*."""

async def delete_attachment(self, session_id: str, attachment_id: str) -> None:
"""Best-effort cleanup of a single attachment identified by *attachment_id*."""

def resolve_path(self, *, session_id: str, attachment_id: str, filename: str) -> Path | None:
"""Return the absolute path on disk for an attachment, or ``None``
if it does not exist or escapes the storage root.
Expand Down Expand Up @@ -186,6 +189,13 @@ async def delete_session(self, session_id: str) -> None:
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, self._rmtree_sync, session_dir)

async def delete_attachment(self, session_id: str, attachment_id: str) -> None:
session_dir = self._session_dir(session_id)
if not session_dir.exists():
return
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, self._delete_attachment_sync, session_dir, attachment_id)

@staticmethod
def _rmtree_sync(path: Path) -> None:
import shutil
Expand All @@ -195,6 +205,21 @@ def _rmtree_sync(path: Path) -> None:
except OSError as exc:
logger.warning("failed to clean up attachment dir %s: %s", path, exc)

@staticmethod
def _delete_attachment_sync(session_dir: Path, attachment_id: str) -> None:
prefix = f"{attachment_id}_"
for entry in session_dir.iterdir():
if entry.name.startswith(prefix):
try:
entry.unlink()
except OSError as exc:
logger.warning("failed to delete attachment file %s: %s", entry, exc)
try:
if session_dir.exists() and not any(session_dir.iterdir()):
session_dir.rmdir()
except OSError as exc:
logger.warning("failed to remove empty attachment dir %s: %s", session_dir, exc)

def resolve_path(self, *, session_id: str, attachment_id: str, filename: str) -> Path | None:
stored = self._stored_filename(attachment_id, filename)
target = self._safe_join(session_id, stored)
Expand Down
2 changes: 2 additions & 0 deletions web/app/(workspace)/chat/[[...sessionId]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,7 @@ export default function ChatPage() {
sendMessage,
cancelStreamingTurn,
regenerateLastMessage,
deleteTurn,
newSession,
loadSession,
} = useUnifiedChat();
Expand Down Expand Up @@ -1645,6 +1646,7 @@ export default function ChatPage() {
onRegenerateMessage={handleRegenerateMessage}
onConfirmOutline={handleConfirmOutline}
onPreviewAttachment={handlePreviewMessageAttachment}
onDeleteTurn={deleteTurn}
/>
<div ref={messagesEndRef} className="h-px w-full shrink-0" />
</div>
Expand Down
47 changes: 46 additions & 1 deletion web/components/chat/home/ChatMessages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
RefreshCcw,
Wand2,
X,
Trash2,
Zap,
type LucideIcon,
} from "lucide-react";
Expand Down Expand Up @@ -52,6 +53,7 @@ const VisualizationViewer = dynamic(
);

interface ChatMessageItem {
id?: number;
role: "user" | "assistant" | "system";
content: string;
capability?: string;
Expand Down Expand Up @@ -336,17 +338,57 @@ const UserMessage = memo(function UserMessage({
msg,
index,
onPreviewAttachment,
onDeleteTurn,
}: {
msg: ChatMessageItem;
index: number;
onPreviewAttachment?: (attachment: MessageAttachment) => void;
onDeleteTurn?: (messageId: number) => void;
}) {
const { t } = useTranslation();
const [confirmDelete, setConfirmDelete] = useState(false);
if (msg.content.startsWith("[Quiz Performance]")) return null;

return (
<div key={`${msg.role}-${index}`} className="flex justify-end">
<div key={`${msg.role}-${index}`} className="group flex justify-end">
<div className="max-w-[75%] space-y-1.5">
<div className="flex justify-end">
<div className="relative">
{!confirmDelete ? (
<button
type="button"
onClick={() => setConfirmDelete(true)}
className="rounded-md p-1 text-[var(--muted-foreground)] opacity-0 transition-opacity group-hover:opacity-100 hover:text-[var(--destructive)]"
title={t("Delete")}
>
<Trash2 size={14} strokeWidth={1.5} />
</button>
) : (
<div className="flex items-center gap-1.5 text-[12px]">
<span className="text-[var(--muted-foreground)]">
{t("Delete this turn?")}
</span>
<button
type="button"
onClick={() => {
if (msg.id != null) onDeleteTurn?.(msg.id);
setConfirmDelete(false);
}}
className="rounded-md px-2 py-0.5 font-medium text-[var(--destructive)] hover:bg-[var(--destructive)]/10"
>
{t("Delete")}
</button>
<button
type="button"
onClick={() => setConfirmDelete(false)}
className="rounded-md px-2 py-0.5 font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)]/40"
>
{t("Cancel")}
</button>
</div>
)}
</div>
</div>
<div className="flex justify-end pr-1">
<span className="text-[10px] tracking-wide text-[var(--muted-foreground)]">
{t(getModeBadgeLabel(msg.capability))}
Expand Down Expand Up @@ -686,6 +728,7 @@ export const ChatMessageList = memo(function ChatMessageList({
onConfirmOutline,
onPreviewAttachment,
onSwitchToManualMode,
onDeleteTurn,
}: {
messages: ChatMessageItem[];
isStreaming: boolean;
Expand All @@ -707,6 +750,7 @@ export const ChatMessageList = memo(function ChatMessageList({
// button after a terminal failure. Optional so non-auto chat surfaces don't
// have to wire it.
onSwitchToManualMode?: () => void;
onDeleteTurn?: (messageId: number) => void;
}) {
const { t } = useTranslation();
const outlineStatusByIndex = useMemo(() => {
Expand Down Expand Up @@ -783,6 +827,7 @@ export const ChatMessageList = memo(function ChatMessageList({
msg={msg}
index={i}
onPreviewAttachment={onPreviewAttachment}
onDeleteTurn={onDeleteTurn}
/>
);
}
Expand Down
Loading
Loading