Preserve preview harness, editor, email and task improvements

Snapshot current maintainer-preview application changes and regression fixtures for integration into lab. Excludes local runtime data, evaluation outputs and source backups. Focused Python regression selection: 140 passed; full suite not certified.
This commit is contained in:
pewdiepie-archdaemon
2026-10-01 01:34:26 +00:00
parent 86f376ac3a
commit 2e8413a54a
170 changed files with 11010 additions and 1094 deletions
+18
View File
@@ -81,6 +81,10 @@ class SetAdminRequest(BaseModel):
is_admin: bool
class ResetUserPasswordRequest(BaseModel):
new_password: str
class SetOpenRegistrationRequest(BaseModel):
enabled: bool
@@ -322,6 +326,20 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
raise HTTPException(409, "Username already taken")
return {"ok": True}
@router.put("/users/{username}/password")
async def reset_user_password(username: str, body: ResetUserPasswordRequest, request: Request):
user = _get_current_user(request)
if not user or not auth_manager.is_admin(user):
raise HTTPException(403, "Admin only")
if len(body.new_password) < PASSWORD_MIN_LENGTH:
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
if len(body.new_password.encode("utf-8")) > 72:
raise HTTPException(400, "Password must be at most 72 UTF-8 bytes")
ok = await asyncio.to_thread(auth_manager.reset_user_password, username, body.new_password, user)
if not ok:
raise HTTPException(403, "Password reset is only available for existing non-admin accounts")
return {"ok": True}
@router.put("/users/{username}/privileges")
async def update_user_privileges(username: str, request: Request):
user = _get_current_user(request)
+48 -11
View File
@@ -87,7 +87,7 @@ from src.model_profiles import (
)
from src.tool_execution import AgentExecutionBridge, bind_execution_bridge
from src.turn_contract import (
bind_turn_contract, preserve_bound_editor_selected_tools,
FAMILY_TOOLS, bind_turn_contract, preserve_bound_editor_selected_tools,
requested_capabilities, resolve_turn_contract,
requests_independent_web_source, requires_external_web_verification,
selected_tools_for_request,
@@ -3061,10 +3061,23 @@ def setup_chat_routes(
full_schema_route=(_effective_tool_schema_mode == "full"),
)
_turn_history = getattr(sess, "history", []) or []
from src.turn_contract import corrected_browser_target
_corrected_browser_target = corrected_browser_target(message, _turn_history)
_turn_capabilities = requested_capabilities(
message, _turn_history,
active_document=bool(active_doc), workspace=bool(workspace),
image_attachment=any(str(a.get('mime') or '').startswith('image/') for a in (ctx.preprocessed.attachment_meta or [])),
) if _use_turn_contract else frozenset()
if 'image_editing' in _turn_capabilities and ctx.preprocessed.attachment_meta:
image_refs = ['odysseus://attachment/' + str(a['id']) for a in ctx.preprocessed.attachment_meta
if a.get('id') and str(a.get('mime') or '').startswith('image/')]
if image_refs:
image_edit_context = {'role': 'system', 'content':
'For the requested image edit, use edit_image with action=prompt and image_id set to the uploaded image reference: '
+ ', '.join(image_refs) + '. Pass the requested changes as prompt. The backend sends the actual source pixels; a description or stock-image URL is not an edited image.'}
ctx.messages.insert(0, image_edit_context)
if foreground_policy.enabled:
getattr(ctx, 'route_messages', ctx.messages).insert(0, dict(image_edit_context))
if _use_turn_contract and _explicit_browser_intent:
# Interactive navigation is already an unambiguous request for
# the browser family. The lexical family classifier intentionally
@@ -3086,6 +3099,12 @@ def setup_chat_routes(
# happened to run earlier in the session.
_turn_capabilities = frozenset({'search_browser'})
_active_turn_capabilities = _turn_capabilities
if _use_turn_contract and active_doc:
# A visible, owner-checked editor is a turn capability even when
# the request classifier focuses on another task or misses a
# pasted revision request. This only offers permitted schemas;
# it never requires or performs a document mutation.
_turn_capabilities = _turn_capabilities | {"documents"}
_clean_v3_preview = bool(_use_turn_contract and _clean_v3_route_requested)
# requested_capabilities already inherits a typed, recently executed
# family for referential follow-ups. Do not additionally union stale
@@ -3240,7 +3259,6 @@ def setup_chat_routes(
_privs = request.app.state.auth_manager.get_privileges(_user)
if _privs:
if not _privs.get("can_use_bash", True):
from src.turn_contract import FAMILY_TOOLS
disabled_tools.update(FAMILY_TOOLS["shell_files"])
if not _privs.get("can_use_browser", True):
disabled_tools.update(_BROWSER_MCP_TOOLS)
@@ -3248,7 +3266,7 @@ def setup_chat_routes(
if not _privs.get("can_use_documents", True):
disabled_tools.update({"manage_documents", "create_document", "edit_document", "update_document", "suggest_document"})
if not _privs.get("can_generate_images", True):
disabled_tools.add("generate_image")
disabled_tools.update({"generate_image", "edit_image"})
if not _privs.get("can_manage_memory", True):
disabled_tools.update({"manage_memory", "manage_skills"})
if not _privs.get("can_use_research", True):
@@ -3332,7 +3350,10 @@ def setup_chat_routes(
}.issubset(disabled_tools),
}
_turn_contract = None
if _use_turn_contract and chat_mode == "agent":
# Image models execute directly, not through the text-agent inventory.
# Keep the permission policy above, but do not apply routing omissions
# as denials to this separate execution path.
if _use_turn_contract and chat_mode == "agent" and not image_generation_session:
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
from src.tool_utils import get_mcp_manager
from src.tool_security import blocked_tools_for_owner
@@ -3444,11 +3465,15 @@ def setup_chat_routes(
# substitute direct sending or document creation.
_turn_capabilities = _turn_capabilities | {"ui"}
_required_tools.add("ui_control")
if _corrected_browser_target:
_selected_tools = {'private_browser', 'web_fetch', 'web_search'}
_required_tools = {'private_browser'}
_turn_contract = resolve_turn_contract(
capabilities=_turn_capabilities, schemas=_contract_schemas,
policy=_contract_policy, required_tools=_required_tools,
required_capabilities=_active_turn_capabilities,
selected_tools=_selected_tools,
always_available_tools=(FAMILY_TOOLS["documents"] if active_doc else ()),
warm_tools=_warm_tools,
message=message, history=getattr(sess, "history", []) or [],
)
@@ -3807,10 +3832,16 @@ def setup_chat_routes(
yield f'data: {json.dumps(_model_info)}\n\n'
_terminal_saved = False
if _is_image_generation_session(sess, owner=_user):
if image_generation_session:
from src.settings import get_setting
if tool_policy.blocks("generate_image"):
_blocked_msg = tool_policy.reason_for("generate_image")
_image_upload = _first_image_attachment(chat_handler, att_ids, owner=_user)
_image_tool_name = "edit_image" if _image_upload else "generate_image"
_blocked_image_tool = next((
name for name in dict.fromkeys(("generate_image", _image_tool_name))
if tool_policy.blocks(name)
), None)
if _blocked_image_tool:
_blocked_msg = tool_policy.reason_for(_blocked_image_tool)
yield f'data: {json.dumps({"delta": _blocked_msg})}\n\n'
yield "data: [DONE]\n\n"
_active_streams.pop(session, None)
@@ -3822,8 +3853,6 @@ def setup_chat_routes(
return
from src.ai_interaction import do_edit_image, do_generate_image
_user_msg = message or ""
_image_upload = _first_image_attachment(chat_handler, att_ids, owner=_user)
_image_tool_name = "edit_image" if _image_upload else "generate_image"
yield f'data: {json.dumps({"type": "tool_start", "tool": _image_tool_name, "command": _user_msg[:100]})}\n\n'
yield ": heartbeat\n\n"
_progress_queue: asyncio.Queue = asyncio.Queue()
@@ -3841,7 +3870,7 @@ def setup_chat_routes(
model_spec=sess.model,
session_id=session,
owner=_user,
size="1024x1024",
size="auto",
progress_callback=_image_progress_callback,
))
else:
@@ -4420,7 +4449,7 @@ def setup_chat_routes(
elif data.get("type") in (
"tool_start", "tool_output", "agent_step",
"doc_stream_open", "doc_stream_delta",
"doc_update", "doc_suggestions", "ui_control",
"doc_update", "doc_suggestions", "editor_progress", "ui_control", "email_open",
"rounds_exhausted", "budget_exceeded",
"loop_breaker_triggered",
"intent_nudge_exhausted",
@@ -4763,6 +4792,14 @@ def setup_chat_routes(
stopped = agent_runs.stop(session_id, _expected_run_id)
return {"stopped": stopped}
@router.post("/api/chat/finish/{session_id}")
async def chat_finish(request: Request, session_id: str) -> Dict[str, Any]:
"""Finish an editor run without discarding completed tools or review cards."""
_verify_session_owner(request, session_id)
expected_run_id = request.headers.get("X-Odysseus-Run-Id")
accepted = agent_runs.request_finish(session_id, expected_run_id)
return {"accepted": accepted}
# ------------------------------------------------------------------ #
# GET /api/chat/stream_status — check if a stream is active for a session
# ------------------------------------------------------------------ #
+2 -3
View File
@@ -1984,9 +1984,8 @@ _EMAIL_REPLY_SYS_PROMPT_BASE = (
"<<<REPLY>>>\n"
"(the reply body goes here)\n"
"<<<END>>>\n"
"Any reasoning, planning, or notes-to-self must come BEFORE the <<<REPLY>>> marker "
"(ideally wrapped in <think>...</think>). Only the text between <<<REPLY>>> and <<<END>>> "
"is sent as the email — nothing else is shown to anyone."
"Start with <<<REPLY>>> immediately. Do not output reasoning, planning, or notes-to-self. "
"Only the final reply belongs between <<<REPLY>>> and <<<END>>>."
)
+52 -15
View File
@@ -3768,6 +3768,11 @@ def setup_email_routes():
full = True if full is True or str(full).lower() == "true" else False
fixture_result = _fixture_email_read(uid, folder, owner)
if fixture_result is not None:
# require_owner validated the selected account. Fixture row labels
# are not configured account IDs; keep the selection for AI Reply
# and other subsequent account-scoped operations.
if account_id and not fixture_result.get("error"):
fixture_result = {**fixture_result, "account_id": account_id}
return fixture_result
ck = _read_cache_key(account_id, folder, uid, owner=owner) + (int(bool(full)),)
cached = _read_cache_get(ck)
@@ -6153,6 +6158,23 @@ def setup_email_routes():
@router.post("/ai-reply")
async def ai_reply(data: dict, owner: str = Depends(require_owner)):
"""Generate an AI-drafted reply to an email using the user's writing style."""
if data.get('stream'):
async def events():
queue = asyncio.Queue()
async def run():
result = await ai_reply({**data, 'stream': False, '_emit': queue.put}, owner)
await queue.put({'type': 'result', **result})
task = asyncio.create_task(run())
try:
while True:
item = await queue.get()
yield 'data: ' + json.dumps(item) + '\n\n'
if item.get('type') == 'result':
break
finally:
task.cancel()
await asyncio.gather(task, return_exceptions=True)
return StreamingResponse(events(), media_type='text/event-stream', headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
try:
from src.endpoint_resolver import resolve_endpoint
@@ -6176,7 +6198,7 @@ def setup_email_routes():
# Skip cache lookup when the caller supplied a user_hint — the
# cached generic reply doesn't reflect the instructions and
# would silently override them.
if message_id and not user_hint and not account_id:
if message_id and not user_hint and not account_id and not callable(data.get('_emit')):
try:
_c = _sql3.connect(SCHEDULED_DB)
owner_clause, owner_params = _email_cache_owner_clause(owner)
@@ -6276,7 +6298,7 @@ def setup_email_routes():
# by exact id, then basename; fall back to the first served model.
try:
from src.llm_core import list_model_ids
_avail = list_model_ids(url, headers=headers)
_avail = await asyncio.to_thread(list_model_ids, url, headers=headers)
if _avail and model not in _avail:
import os as _os
_base = _os.path.basename((model or "").rstrip("/"))
@@ -6293,7 +6315,7 @@ def setup_email_routes():
# Owner-scoped so pre-retrieval never crosses tenants.
context_snippets, _terms = ([], [])
if not fast_reply:
context_snippets, _terms = _pre_retrieve_context(original_body, to, owner=owner)
context_snippets, _terms = await asyncio.to_thread(_pre_retrieve_context, original_body, to, owner=owner)
# NEW: also pull the last few emails from the original sender +
# their attachments. The "to" field on this endpoint is the
@@ -6304,7 +6326,7 @@ def setup_email_routes():
if not fast_reply:
try:
from_addr_for_ctx = email.utils.parseaddr(to or "")[1]
referenced = _fetch_sender_thread_context(
referenced = await asyncio.to_thread(_fetch_sender_thread_context,
sender_addr=from_addr_for_ctx,
exclude_uid=source_uid,
exclude_folder=source_folder,
@@ -6315,6 +6337,14 @@ def setup_email_routes():
logger.warning(f"sender-thread-context failed: {_e}")
system_prompt = _EMAIL_REPLY_SYS_PROMPT_BASE
config = await asyncio.to_thread(_get_email_config, account_id, owner=owner)
mailbox = config.get('from_address') or config.get('imap_user') or ''
system_prompt += (
f'\n\nYou are writing FROM this mailbox: {mailbox}. '
'The recipient is the person being replied to, not your identity. '
'Quoted participants are third parties. Do not adopt their signatures or commitments. '
'Return the final reply immediately inside <<<REPLY>>> and <<<END>>>; no analysis.'
)
if general_style:
system_prompt += f"\n\nGENERAL WRITING STYLE:\n{general_style}"
if style:
@@ -6332,7 +6362,7 @@ def setup_email_routes():
user_msg = (
f"Recipient: {to}\nSubject: {subject}\n\n"
f"Original email and any current draft:\n{original_body[:6000]}\n\n"
f"Received email and quoted conversation (not your draft):\n{original_body[:6000]}\n\n"
)
if user_hint:
user_msg += (
@@ -6384,19 +6414,25 @@ def setup_email_routes():
{"role": "user", "content": user_msg},
]
try:
reply_raw = await llm_call_async_with_fallback(
_candidates,
messages=_messages,
temperature=0.7,
max_tokens=1536 if fast_reply else 6144,
timeout=120 if fast_reply else 180,
)
if callable(data.get('_emit')):
from src.email_reply_stream import stream_reply
reply_raw, model = await stream_reply(_candidates, _messages, data['_emit'], max_tokens=1536)
else:
reply_raw = await llm_call_async_with_fallback(
_candidates,
messages=_messages,
temperature=0.7,
max_tokens=1536 if fast_reply else 6144,
timeout=120 if fast_reply else 180,
thinking_mode='off',
)
except Exception as e:
detail = getattr(e, "detail", None) or str(e)
_attempted = ", ".join(f"{m}@{u.split('/')[2] if '/' in u else u}" for u, m, _ in _candidates) or "no candidates"
return {"success": False, "error": f"All endpoints failed ({_attempted}): {detail}. Check your API keys in Settings → Services."}
return {"success": False, "error": f"AI reply failed ({_attempted}): {detail}"}
reply = _apply_email_style_mechanics(_extract_reply(reply_raw or ""))
from src.email_reply_stream import reply_body
reply = _apply_email_style_mechanics(reply_body(reply_raw or "", complete=True))
# Small/local models sometimes satisfy the format request with a
# one-word acknowledgement ("Thanks.") even though the email
# needs an actual draft. Treat that as an unusable result and
@@ -6432,8 +6468,9 @@ def setup_email_routes():
max_tokens=2048 if fast_reply else 4096,
timeout=90 if fast_reply else 120,
max_retries=1,
thinking_mode='off',
)
retry_reply = _apply_email_style_mechanics(_extract_reply(raw_retry or ""))
retry_reply = _apply_email_style_mechanics(reply_body(raw_retry or "", complete=True))
if retry_reply and (len(retry_reply.split()) >= 4 or allow_short_reply):
reply = retry_reply
model = cand_model
+48 -12
View File
@@ -265,6 +265,7 @@ def _is_openai_api_base(url: str) -> bool:
_GALLERY_ENDPOINT_PATHS = frozenset({
"/images",
"/images/edits",
"/images/generations",
"/images/harmonize",
@@ -305,12 +306,28 @@ def _first_visible_image_endpoint(db, owner: str | None):
return endpoints[0] if endpoints else None
def _visible_image_endpoint_for_base(db, base: str, owner: str | None):
def _visible_image_endpoint_for_base(db, base: str, owner: str | None, model: str = ""):
import json
target = _normalize_image_endpoint_base(base)
if not target:
return None
fallback = None
for ep in _visible_image_endpoint_query(db, owner).all():
from src.auth_helpers import owner_filter
from src.image_model_ids import looks_like_image_generation_model
query = owner_filter(db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True), ModelEndpoint, owner)
for ep in query.all():
if getattr(ep, "model_type", None) != "image":
# Mixed providers are eligible only for a configured image model.
configured = set()
for field in ("cached_models", "pinned_models"):
try:
values = getattr(ep, field, None) or []
values = json.loads(values) if isinstance(values, str) else values
configured.update(v for v in values if isinstance(v, str))
except (TypeError, ValueError):
pass
if model not in configured or not looks_like_image_generation_model(model):
continue
if _normalize_image_endpoint_base(getattr(ep, "base_url", "")) == target:
if owner and getattr(ep, "owner", None) == owner:
return ep
@@ -1293,7 +1310,7 @@ def setup_gallery_routes() -> APIRouter:
# so the outbound URL never depends directly on request-body input.
db = SessionLocal()
try:
ep = _visible_image_endpoint_for_base(db, requested_base, user)
ep = _visible_image_endpoint_for_base(db, requested_base, user, chosen_model)
if not ep:
raise HTTPException(403, "Choose a registered image endpoint")
base = ep.base_url.rstrip("/")
@@ -1305,8 +1322,10 @@ def setup_gallery_routes() -> APIRouter:
base += "/v1"
is_openai = _is_openai_api_base(base)
from src.model_capability_readers.base import detect_vendor
is_openrouter = detect_vendor(base) == "openrouter"
if is_openai:
if is_openai or is_openrouter:
# OpenAI path: /v1/images/edits with gpt-image-1.
# Mask convention differs from Stable Diffusion:
# SD: white pixels = regenerate, black = keep
@@ -1374,7 +1393,23 @@ def setup_gallery_routes() -> APIRouter:
headers = {"Authorization": f"Bearer {api_key}"}
try:
async with httpx.AsyncClient(timeout=120) as client:
r = await client.post(_join_checked_gallery_endpoint(base, "/images/edits"), headers=headers, data=data, files=files)
if is_openrouter:
# Reference-based editing, then composite locally so
# pixels outside the user's mask remain unchanged.
reference_mask = io.BytesIO()
mask_png.save(reference_mask, format="PNG")
references = [src_buf.getvalue(), reference_mask.getvalue()]
payload = {
"model": oa_model, "size": size, "n": 1,
"output_format": "png",
"prompt": "Edit the first image. The second image is a mask: white marks the region to edit, black marks the region to preserve. Keep composition and framing unchanged. Requested edit: " + str(body.get("prompt", "")),
"input_references": [{"type": "image_url", "image_url": {
"url": "data:image/png;base64," + base64.b64encode(value).decode(),
}} for value in references],
}
r = await client.post(_join_checked_gallery_endpoint(base, "/images"), headers=headers, json=payload)
else:
r = await client.post(_join_checked_gallery_endpoint(base, "/images/edits"), headers=headers, data=data, files=files)
if r.status_code != 200:
logger.error("inpaint_proxy OpenAI edit: status %s", r.status_code)
raise HTTPException(r.status_code, "OpenAI edit failed")
@@ -1410,10 +1445,9 @@ def setup_gallery_routes() -> APIRouter:
blended.save(out_buf, format="PNG")
return {"image": base64.b64encode(out_buf.getvalue()).decode()}
except Exception as comp_err:
# If compositing fails for any reason, fall back
# to the raw OpenAI output rather than blocking.
logger.warning(f"Inpaint compose failed, returning raw: {comp_err}")
return {"image": raw_b64}
# Never return a full-image edit when masking fails.
logger.warning(f"Inpaint compose failed: {comp_err}")
raise HTTPException(502, "Could not apply the edit within the selected region")
except httpx.TimeoutException:
raise HTTPException(504, "OpenAI inpaint timed out (120s)")
@@ -2016,12 +2050,14 @@ def setup_gallery_routes() -> APIRouter:
try:
from rembg import remove
cut = remove(crop)
from starlette.concurrency import run_in_threadpool
cut = await run_in_threadpool(remove, crop)
except ImportError:
try:
from transformers import pipeline
pipe = pipeline("image-segmentation", model="briaai/RMBG-1.4", trust_remote_code=True)
mask_img = pipe(crop, return_mask=True).convert("L")
from starlette.concurrency import run_in_threadpool
pipe = await run_in_threadpool(pipeline, "image-segmentation", model="briaai/RMBG-1.4", trust_remote_code=True)
mask_img = (await run_in_threadpool(pipe, crop, return_mask=True)).convert("L")
tmp = crop.copy()
tmp.putalpha(mask_img)
cut = tmp
+17 -4
View File
@@ -1507,7 +1507,7 @@ def setup_model_routes(model_discovery):
# opens from starting duplicate /models probes, and gives slow/offline
# providers a cooldown after failures.
_refresh_state: Dict[str, Dict[str, Any]] = {}
_refresh_inflight = {"v": False} # coarse single-flight guard
_refresh_inflight = {"v": False, "done": None} # coarse single-flight guard
_REFRESH_FAILURE_BASE = 300.0
_REFRESH_FAILURE_MAX = 3600.0
@@ -1581,7 +1581,9 @@ def setup_model_routes(model_discovery):
endpoints are skipped unless explicitly forced."""
import threading
if _refresh_inflight["v"]:
return # already running
return _refresh_inflight["done"] # already running
done = threading.Event()
_refresh_inflight["done"] = done
_refresh_inflight["v"] = True
def _do():
@@ -1649,7 +1651,9 @@ def setup_model_routes(model_discovery):
for st in _refresh_state.values():
st["inflight"] = False
_refresh_inflight["v"] = False
done.set()
threading.Thread(target=_do, daemon=True).start()
return done
def _fetch_models(owner: str = "", is_admin: bool = False):
"""Return model list from cached data (instant). Background refresh keeps caches fresh.
@@ -1740,7 +1744,8 @@ def setup_model_routes(model_discovery):
return {"hosts": [], "items": items}
@router.get("/models")
def api_models(request: Request, refresh: bool = False, background: bool = False):
def api_models(request: Request, refresh: bool = False, background: bool = False,
wait_refresh: bool = False):
"""Get available models — per-user (caller sees only their endpoints +
legacy/shared null-owner rows). Cached per-user for 30s."""
# Require auth; "" is the unconfigured single-user mode, treated as
@@ -1786,7 +1791,15 @@ def setup_model_routes(model_discovery):
# Page boot can opt out with background=false so opening Odysseus does
# not start endpoint probes against slow/offline model servers.
if background or refresh:
_refresh_caches_bg(force=refresh)
done = _refresh_caches_bg(force=refresh)
if refresh and wait_refresh and done is not None:
done.wait(timeout=15)
refreshed = _models_cache.get(_cache_key)
if refreshed is not None:
result = refreshed["data"]
else:
result = _fetch_models(owner=owner, is_admin=_is_admin)
_models_cache[_cache_key] = {"data": result, "time": _time.time()}
return result
# Brief cache for local-probe results so picker-open doesn't hammer
+19 -4
View File
@@ -797,8 +797,19 @@ def setup_session_routes(
pass
return {"deleted": deleted_count}
@router.get("/session/{sid}/deletion-info")
def session_deletion_info(request: Request, sid: str):
_verify_session_owner(request, sid, session_manager)
from src.session_image_cleanup import session_gallery_images
db = SessionLocal()
try:
count = session_gallery_images(db, sid).filter(GalleryImage.is_active.is_(True)).count()
return {"image_count": count}
finally:
db.close()
@router.delete("/session/{sid}")
def delete_session(request: Request, sid: str):
def delete_session(request: Request, sid: str, delete_images: bool = False):
"""Permanently delete a session and all its messages."""
_verify_session_owner(request, sid, session_manager)
try:
@@ -815,7 +826,8 @@ def setup_session_routes(
db.close()
# Delete the session and all its messages
if session_manager.delete_session(sid):
if (session_manager.delete_session(sid, delete_images=True) if delete_images
else session_manager.delete_session(sid)):
from routes.chat_helpers import remove_session_sft_trace_rows
remove_session_sft_trace_rows(effective_user(request), sid)
return {"status": "deleted"}
@@ -834,7 +846,7 @@ def setup_session_routes(
)
@router.delete("/sessions/all")
def delete_all_sessions(request: Request):
def delete_all_sessions(request: Request, delete_images: bool = False):
"""Admin only: permanently delete ALL sessions and their messages."""
from core.middleware import require_admin
require_admin(request)
@@ -861,7 +873,7 @@ def setup_session_routes(
if filenames:
clauses.append(GalleryImage.filename.in_(list(filenames)))
image_query = db.query(GalleryImage).filter(or_(*clauses))
images = image_query.all()
images = image_query.all() if delete_images else []
removed_images = 0
for img in images:
img.is_active = False
@@ -873,6 +885,9 @@ def setup_session_routes(
except Exception as exc:
logger.warning("Could not remove generated image %s during all-session delete: %s", img.filename, exc)
removed_images += 1
db.query(GalleryImage).filter(GalleryImage.session_id.in_(session_ids)).update(
{GalleryImage.session_id: None}, synchronize_session=False
)
db.query(DbChatMessage).delete()
db.query(DbSession).delete()
db.commit()