mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-11 20:05:19 +00:00
Feat/prune orphaned data (#16)
* feat: Add prune orphaned data functionality * feat: Add prune orphaned data functionality * feat: Add prune orphaned data functionality * fix: Restyle PruneDataDialog modal * feat: Add comprehensive prune orphaned data functionality and fix circular import * feat: Add comprehensive prune orphaned data functionality and fix circular import * feat: Add comprehensive prune orphaned data functionality and fix database size issues * feat: Add comprehensive prune orphaned data functionality and fix database size issues * feat: Add comprehensive prune orphaned data functionality and fix database size issues * Update prune.py * Update prune.py * Update prune.py * Update prune.py * Update prune.py * Update prune.py * Update prune.py * Update prune.py * Update prune.py * Update prune.py * Update prune.py * Update prune.py * Update prune.py * Update folders.py * Update prune.py * Update prune.py * Update prune.py * Update prune.py * Update prune.py * Update prune.py * Update prune.py * Update prune.py * Update prune.py * Update prune.py * Update prune.py * Update prune.py * Update prune.py * Update prune.py * Update prune.py * Update prune.py * Update prune.py * Update prune.py * Update prune.py * Update prune.py * Update prune.py * Update prune.py * Update prune.py * Delete backend/open_webui/test/test_prune.py * Update prune.ts * Update PruneDataDialog.svelte * Update prune.py * Update PruneDataDialog.svelte * Update PruneDataDialog.svelte * Update PruneDataDialog.svelte * Update PruneDataDialog.svelte * Update PruneDataDialog.svelte * Update PruneDataDialog.svelte * Update prune.py * Update PruneDataDialog.svelte * Update prune.ts * Update Database.svelte * Update prune.py * Update PruneDataDialog.svelte * Update PruneDataDialog.svelte * Update prune.py * Update prune.py * Update PruneDataDialog.svelte * Update PruneDataDialog.svelte * Update PruneDataDialog.svelte * Update PruneDataDialog.svelte * Update PruneDataDialog.svelte * Update PruneDataDialog.svelte * Update PruneDataDialog.svelte * Update PruneDataDialog.svelte * Update PruneDataDialog.svelte * Update Database.svelte * Update prune.py * Update prune.ts * Update PruneDataDialog.svelte * Update PruneDataDialog.svelte * Update PruneDataDialog.svelte * Update PruneDataDialog.svelte * Update PruneDataDialog.svelte * Update PruneDataDialog.svelte * Update PruneDataDialog.svelte * Update PruneDataDialog.svelte * Update PruneDataDialog.svelte * Update PruneDataDialog.svelte * Update PruneDataDialog.svelte * Update PruneDataDialog.svelte * Update PruneDataDialog.svelte * Update PruneDataDialog.svelte * Update PruneDataDialog.svelte * Update PruneDataDialog.svelte * Update prune.py * Update prune.ts * Update PruneDataDialog.svelte * Update files.py * Update prompts.py * Update notes.py * Update models.py * Update access_control.py * Update PruneDataDialog.svelte * Update PruneDataDialog.svelte --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
This commit is contained in:
parent
30d0f8b1f6
commit
d454e6a033
6 changed files with 1402 additions and 24 deletions
|
|
@ -81,6 +81,7 @@ from open_webui.routers import (
|
|||
models,
|
||||
knowledge,
|
||||
prompts,
|
||||
prune,
|
||||
evaluations,
|
||||
tools,
|
||||
users,
|
||||
|
|
@ -1234,6 +1235,7 @@ app.include_router(
|
|||
evaluations.router, prefix="/api/v1/evaluations", tags=["evaluations"]
|
||||
)
|
||||
app.include_router(utils.router, prefix="/api/v1/utils", tags=["utils"])
|
||||
app.include_router(prune.router, prefix="/api/v1/prune", tags=["prune"])
|
||||
|
||||
# SCIM 2.0 API for identity management
|
||||
if SCIM_ENABLED:
|
||||
|
|
|
|||
|
|
@ -135,6 +135,10 @@ class FolderTable:
|
|||
for folder in db.query(Folder).filter_by(user_id=user_id).all()
|
||||
]
|
||||
|
||||
def get_all_folders(self) -> list[FolderModel]:
|
||||
with get_db() as db:
|
||||
return [FolderModel.model_validate(folder) for folder in db.query(Folder).all()]
|
||||
|
||||
def get_folder_by_parent_id_and_user_id_and_name(
|
||||
self, parent_id: Optional[str], user_id: str, name: str
|
||||
) -> Optional[FolderModel]:
|
||||
|
|
|
|||
684
backend/open_webui/routers/prune.py
Normal file
684
backend/open_webui/routers/prune.py
Normal file
|
|
@ -0,0 +1,684 @@
|
|||
import logging
|
||||
import time
|
||||
import os
|
||||
import shutil
|
||||
import json
|
||||
import re
|
||||
from typing import Optional, Set
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
|
||||
from open_webui.utils.auth import get_admin_user
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.files import Files
|
||||
from open_webui.models.notes import Notes
|
||||
from open_webui.models.prompts import Prompts
|
||||
from open_webui.models.models import Models
|
||||
from open_webui.models.knowledge import Knowledges
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.models.tools import Tools
|
||||
from open_webui.models.folders import Folders
|
||||
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT, VECTOR_DB
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
from open_webui.config import CACHE_DIR
|
||||
from open_webui.internal.db import get_db
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class PruneDataForm(BaseModel):
|
||||
days: Optional[int] = None
|
||||
exempt_archived_chats: bool = False
|
||||
exempt_chats_in_folders: bool = False
|
||||
# Orphaned resource deletion toggles (for deleted users)
|
||||
delete_orphaned_chats: bool = True
|
||||
delete_orphaned_tools: bool = False
|
||||
delete_orphaned_functions: bool = False
|
||||
delete_orphaned_prompts: bool = True
|
||||
delete_orphaned_knowledge_bases: bool = True
|
||||
delete_orphaned_models: bool = True
|
||||
delete_orphaned_notes: bool = True
|
||||
delete_orphaned_folders: bool = True
|
||||
|
||||
|
||||
def get_active_file_ids() -> Set[str]:
|
||||
"""
|
||||
Get all file IDs that are actively referenced by knowledge bases, chats, folders, and messages.
|
||||
This is the ground truth for what files should be preserved.
|
||||
"""
|
||||
active_file_ids = set()
|
||||
|
||||
try:
|
||||
# 1. Get files referenced by knowledge bases (original logic)
|
||||
knowledge_bases = Knowledges.get_knowledge_bases()
|
||||
log.debug(f"Found {len(knowledge_bases)} knowledge bases")
|
||||
|
||||
for kb in knowledge_bases:
|
||||
if not kb.data:
|
||||
continue
|
||||
|
||||
# Handle different possible data structures for file references
|
||||
file_ids = []
|
||||
|
||||
# Check for file_ids array
|
||||
if isinstance(kb.data, dict) and "file_ids" in kb.data:
|
||||
if isinstance(kb.data["file_ids"], list):
|
||||
file_ids.extend(kb.data["file_ids"])
|
||||
|
||||
# Check for files array with id field
|
||||
if isinstance(kb.data, dict) and "files" in kb.data:
|
||||
if isinstance(kb.data["files"], list):
|
||||
for file_ref in kb.data["files"]:
|
||||
if isinstance(file_ref, dict) and "id" in file_ref:
|
||||
file_ids.append(file_ref["id"])
|
||||
elif isinstance(file_ref, str):
|
||||
file_ids.append(file_ref)
|
||||
|
||||
# Add all found file IDs
|
||||
for file_id in file_ids:
|
||||
if isinstance(file_id, str) and file_id.strip():
|
||||
active_file_ids.add(file_id.strip())
|
||||
log.debug(f"KB {kb.id} references file {file_id}")
|
||||
|
||||
# 2. Get files referenced in chats (NEW: scan chat JSON for file references)
|
||||
chats = Chats.get_chats()
|
||||
log.debug(f"Found {len(chats)} chats to scan for file references")
|
||||
|
||||
for chat in chats:
|
||||
if not chat.chat or not isinstance(chat.chat, dict):
|
||||
continue
|
||||
|
||||
try:
|
||||
# Convert entire chat JSON to string and extract all file IDs
|
||||
chat_json_str = json.dumps(chat.chat)
|
||||
|
||||
# Find all file ID patterns in the JSON
|
||||
# Pattern 1: "id": "uuid" where uuid looks like a file ID
|
||||
file_id_pattern = re.compile(r'"id":\s*"([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})"')
|
||||
potential_file_ids = file_id_pattern.findall(chat_json_str)
|
||||
|
||||
# Pattern 2: URLs containing /api/v1/files/uuid
|
||||
url_pattern = re.compile(r'/api/v1/files/([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})')
|
||||
url_file_ids = url_pattern.findall(chat_json_str)
|
||||
|
||||
# Combine and validate against actual file records
|
||||
all_potential_ids = set(potential_file_ids + url_file_ids)
|
||||
for file_id in all_potential_ids:
|
||||
# Verify this ID exists in the file table to avoid false positives
|
||||
if Files.get_file_by_id(file_id):
|
||||
active_file_ids.add(file_id)
|
||||
log.debug(f"Chat {chat.id}: Found active file {file_id}")
|
||||
|
||||
except Exception as e:
|
||||
log.debug(f"Error processing chat {chat.id} for file references: {e}")
|
||||
|
||||
# 3. Get files referenced in folders (scan folder.items, folder.data, folder.meta)
|
||||
try:
|
||||
folders = Folders.get_all_folders()
|
||||
log.debug(f"Found {len(folders)} folders to scan for file references")
|
||||
|
||||
for folder in folders:
|
||||
# Check folder.items JSON
|
||||
if folder.items:
|
||||
try:
|
||||
items_str = json.dumps(folder.items)
|
||||
# Look for file ID patterns in the JSON
|
||||
file_id_pattern = re.compile(r'"id":\s*"([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})"')
|
||||
url_pattern = re.compile(r'/api/v1/files/([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})')
|
||||
|
||||
potential_ids = file_id_pattern.findall(items_str) + url_pattern.findall(items_str)
|
||||
for file_id in potential_ids:
|
||||
if Files.get_file_by_id(file_id):
|
||||
active_file_ids.add(file_id)
|
||||
log.debug(f"Folder {folder.id}: Found file {file_id} in items")
|
||||
except Exception as e:
|
||||
log.debug(f"Error processing folder {folder.id} items: {e}")
|
||||
|
||||
# Check folder.data JSON
|
||||
if hasattr(folder, 'data') and folder.data:
|
||||
try:
|
||||
data_str = json.dumps(folder.data)
|
||||
file_id_pattern = re.compile(r'"id":\s*"([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})"')
|
||||
url_pattern = re.compile(r'/api/v1/files/([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})')
|
||||
|
||||
potential_ids = file_id_pattern.findall(data_str) + url_pattern.findall(data_str)
|
||||
for file_id in potential_ids:
|
||||
if Files.get_file_by_id(file_id):
|
||||
active_file_ids.add(file_id)
|
||||
log.debug(f"Folder {folder.id}: Found file {file_id} in data")
|
||||
except Exception as e:
|
||||
log.debug(f"Error processing folder {folder.id} data: {e}")
|
||||
|
||||
except Exception as e:
|
||||
log.debug(f"Error scanning folders for file references: {e}")
|
||||
|
||||
# 4. Get files referenced in standalone messages (message table)
|
||||
try:
|
||||
# Query message table directly since we may not have a Messages model
|
||||
with get_db() as db:
|
||||
message_results = db.execute(text("SELECT id, data FROM message WHERE data IS NOT NULL")).fetchall()
|
||||
log.debug(f"Found {len(message_results)} messages with data to scan")
|
||||
|
||||
for message_id, message_data_json in message_results:
|
||||
if message_data_json:
|
||||
try:
|
||||
# Convert JSON to string and scan for file patterns
|
||||
data_str = json.dumps(message_data_json) if isinstance(message_data_json, dict) else str(message_data_json)
|
||||
|
||||
file_id_pattern = re.compile(r'"id":\s*"([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})"')
|
||||
url_pattern = re.compile(r'/api/v1/files/([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})')
|
||||
|
||||
potential_ids = file_id_pattern.findall(data_str) + url_pattern.findall(data_str)
|
||||
for file_id in potential_ids:
|
||||
if Files.get_file_by_id(file_id):
|
||||
active_file_ids.add(file_id)
|
||||
log.debug(f"Message {message_id}: Found file {file_id}")
|
||||
except Exception as e:
|
||||
log.debug(f"Error processing message {message_id} data: {e}")
|
||||
except Exception as e:
|
||||
log.debug(f"Error scanning messages for file references: {e}")
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error determining active file IDs: {e}")
|
||||
# Fail safe: return empty set, which will prevent deletion
|
||||
return set()
|
||||
|
||||
log.info(f"Found {len(active_file_ids)} active file IDs")
|
||||
return active_file_ids
|
||||
|
||||
|
||||
def safe_delete_vector_collection(collection_name: str) -> bool:
|
||||
"""
|
||||
Safely delete a vector collection, handling both logical and physical cleanup.
|
||||
"""
|
||||
try:
|
||||
# First, try to delete the collection through the client
|
||||
try:
|
||||
VECTOR_DB_CLIENT.delete_collection(collection_name=collection_name)
|
||||
log.debug(f"Deleted collection from vector DB: {collection_name}")
|
||||
except Exception as e:
|
||||
log.debug(f"Collection {collection_name} may not exist in DB: {e}")
|
||||
|
||||
# Then, handle physical cleanup for ChromaDB
|
||||
if "chroma" in VECTOR_DB.lower():
|
||||
vector_dir = Path(CACHE_DIR).parent / "vector_db" / collection_name
|
||||
if vector_dir.exists() and vector_dir.is_dir():
|
||||
shutil.rmtree(vector_dir)
|
||||
log.debug(f"Deleted physical vector directory: {vector_dir}")
|
||||
return True
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error deleting vector collection {collection_name}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def safe_delete_file_by_id(file_id: str) -> bool:
|
||||
"""
|
||||
Safely delete a file record and its associated vector collection.
|
||||
"""
|
||||
try:
|
||||
# Get file info before deletion
|
||||
file_record = Files.get_file_by_id(file_id)
|
||||
if not file_record:
|
||||
log.debug(f"File {file_id} not found in database")
|
||||
return True # Already gone
|
||||
|
||||
# Delete vector collection first
|
||||
collection_name = f"file-{file_id}"
|
||||
safe_delete_vector_collection(collection_name)
|
||||
|
||||
# Delete database record
|
||||
Files.delete_file_by_id(file_id)
|
||||
log.debug(f"Deleted file record: {file_id}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error deleting file {file_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def cleanup_orphaned_uploads(active_file_ids: Set[str]) -> None:
|
||||
"""
|
||||
Clean up orphaned files in the uploads directory.
|
||||
"""
|
||||
upload_dir = Path(CACHE_DIR).parent / "uploads"
|
||||
if not upload_dir.exists():
|
||||
log.debug("Uploads directory does not exist")
|
||||
return
|
||||
|
||||
deleted_count = 0
|
||||
|
||||
try:
|
||||
for file_path in upload_dir.iterdir():
|
||||
if not file_path.is_file():
|
||||
continue
|
||||
|
||||
filename = file_path.name
|
||||
|
||||
# Extract file ID from filename (common patterns)
|
||||
file_id = None
|
||||
|
||||
# Pattern 1: UUID_filename or UUID-filename
|
||||
if len(filename) > 36:
|
||||
potential_id = filename[:36]
|
||||
if potential_id.count('-') == 4: # UUID format
|
||||
file_id = potential_id
|
||||
|
||||
# Pattern 2: filename might be the file ID itself
|
||||
if not file_id and filename.count('-') == 4 and len(filename) == 36:
|
||||
file_id = filename
|
||||
|
||||
# Pattern 3: Check if any part of filename matches active IDs
|
||||
if not file_id:
|
||||
for active_id in active_file_ids:
|
||||
if active_id in filename:
|
||||
file_id = active_id
|
||||
break
|
||||
|
||||
# If we found a potential file ID and it's not active, delete it
|
||||
if file_id and file_id not in active_file_ids:
|
||||
try:
|
||||
file_path.unlink()
|
||||
deleted_count += 1
|
||||
log.debug(f"Deleted orphaned upload file: {filename}")
|
||||
except Exception as e:
|
||||
log.error(f"Failed to delete upload file {filename}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error cleaning uploads directory: {e}")
|
||||
|
||||
if deleted_count > 0:
|
||||
log.info(f"Deleted {deleted_count} orphaned upload files")
|
||||
|
||||
|
||||
def cleanup_orphaned_vector_collections(active_file_ids: Set[str], active_kb_ids: Set[str]) -> None:
|
||||
"""
|
||||
Clean up orphaned vector collections by querying ChromaDB metadata.
|
||||
"""
|
||||
if "chroma" not in VECTOR_DB.lower():
|
||||
return
|
||||
|
||||
vector_dir = Path(CACHE_DIR).parent / "vector_db"
|
||||
if not vector_dir.exists():
|
||||
log.debug("Vector DB directory does not exist")
|
||||
return
|
||||
|
||||
chroma_db_path = vector_dir / "chroma.sqlite3"
|
||||
if not chroma_db_path.exists():
|
||||
log.debug("ChromaDB metadata file does not exist")
|
||||
return
|
||||
|
||||
# Build expected collection names
|
||||
expected_collections = set()
|
||||
|
||||
# File collections: file-{file_id}
|
||||
for file_id in active_file_ids:
|
||||
expected_collections.add(f"file-{file_id}")
|
||||
|
||||
# Knowledge base collections: {kb_id}
|
||||
for kb_id in active_kb_ids:
|
||||
expected_collections.add(kb_id)
|
||||
|
||||
log.debug(f"Expected collections to preserve: {expected_collections}")
|
||||
|
||||
# Query ChromaDB metadata to get the complete mapping chain:
|
||||
# Directory UUID -> Collection ID -> Collection Name
|
||||
uuid_to_collection = {}
|
||||
try:
|
||||
import sqlite3
|
||||
log.debug(f"Attempting to connect to ChromaDB at: {chroma_db_path}")
|
||||
|
||||
with sqlite3.connect(str(chroma_db_path)) as conn:
|
||||
# First, check what tables exist
|
||||
tables = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
|
||||
log.debug(f"ChromaDB tables: {tables}")
|
||||
|
||||
# Check the schema of collections table
|
||||
schema = conn.execute("PRAGMA table_info(collections)").fetchall()
|
||||
log.debug(f"Collections table schema: {schema}")
|
||||
|
||||
# Get Collection ID -> Collection Name mapping
|
||||
collection_id_to_name = {}
|
||||
cursor = conn.execute("SELECT id, name FROM collections")
|
||||
rows = cursor.fetchall()
|
||||
log.debug(f"Raw ChromaDB collections query results: {rows}")
|
||||
|
||||
for row in rows:
|
||||
collection_id, collection_name = row
|
||||
collection_id_to_name[collection_id] = collection_name
|
||||
log.debug(f"Mapped collection ID {collection_id} -> name {collection_name}")
|
||||
|
||||
# Get Directory UUID -> Collection ID mapping from segments table
|
||||
# Only interested in VECTOR segments as those are the actual data directories
|
||||
cursor = conn.execute("SELECT id, collection FROM segments WHERE scope = 'VECTOR'")
|
||||
segment_rows = cursor.fetchall()
|
||||
log.debug(f"Raw ChromaDB segments query results: {segment_rows}")
|
||||
|
||||
for row in segment_rows:
|
||||
segment_id, collection_id = row
|
||||
if collection_id in collection_id_to_name:
|
||||
collection_name = collection_id_to_name[collection_id]
|
||||
uuid_to_collection[segment_id] = collection_name
|
||||
log.debug(f"Mapped directory UUID {segment_id} -> collection {collection_name}")
|
||||
|
||||
log.debug(f"Final uuid_to_collection mapping: {uuid_to_collection}")
|
||||
log.info(f"Found {len(uuid_to_collection)} vector segments in ChromaDB metadata")
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error reading ChromaDB metadata: {e}")
|
||||
# Fail safe: don't delete anything if we can't read metadata
|
||||
return
|
||||
|
||||
deleted_count = 0
|
||||
|
||||
try:
|
||||
for collection_dir in vector_dir.iterdir():
|
||||
if not collection_dir.is_dir():
|
||||
continue
|
||||
|
||||
dir_uuid = collection_dir.name
|
||||
|
||||
# Skip system/metadata files
|
||||
if dir_uuid.startswith('.'):
|
||||
continue
|
||||
|
||||
# Get the actual collection name from metadata
|
||||
collection_name = uuid_to_collection.get(dir_uuid)
|
||||
|
||||
if collection_name is None:
|
||||
# Directory exists but no metadata entry - it's orphaned
|
||||
log.debug(f"Directory {dir_uuid} has no metadata entry, deleting")
|
||||
try:
|
||||
shutil.rmtree(collection_dir)
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
log.error(f"Failed to delete orphaned directory {dir_uuid}: {e}")
|
||||
|
||||
elif collection_name not in expected_collections:
|
||||
# Collection exists but should be deleted
|
||||
log.debug(f"Collection {collection_name} (UUID: {dir_uuid}) is orphaned, deleting")
|
||||
try:
|
||||
shutil.rmtree(collection_dir)
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
log.error(f"Failed to delete collection directory {dir_uuid}: {e}")
|
||||
|
||||
else:
|
||||
# Collection should be preserved
|
||||
log.debug(f"Preserving collection {collection_name} (UUID: {dir_uuid})")
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error cleaning vector collections: {e}")
|
||||
|
||||
if deleted_count > 0:
|
||||
log.info(f"Deleted {deleted_count} orphaned vector collections")
|
||||
|
||||
|
||||
@router.post("/", response_model=bool)
|
||||
async def prune_data(form_data: PruneDataForm, user=Depends(get_admin_user)):
|
||||
"""
|
||||
Prunes old and orphaned data using a safe, multi-stage process.
|
||||
|
||||
Parameters:
|
||||
- days: Optional[int] = None
|
||||
- If None: Skip chat deletion entirely
|
||||
- If 0: Delete all chats (older than 0 days = all chats)
|
||||
- If >= 1: Delete chats older than specified number of days
|
||||
- exempt_archived_chats: bool = False
|
||||
- If True: Exempt archived chats from deletion (only applies when days is not None)
|
||||
- exempt_chats_in_folders: bool = False
|
||||
- If True: Exempt chats that are in folders OR pinned chats from deletion (only applies when days is not None)
|
||||
Note: Pinned chats behave the same as chats in folders
|
||||
- delete_orphaned_chats: bool = True
|
||||
- If True: Delete chats from deleted users
|
||||
- delete_orphaned_tools: bool = True
|
||||
- If True: Delete tools from deleted users
|
||||
- delete_orphaned_functions: bool = True
|
||||
- If True: Delete functions from deleted users
|
||||
- delete_orphaned_prompts: bool = True
|
||||
- If True: Delete prompts from deleted users
|
||||
- delete_orphaned_knowledge_bases: bool = True
|
||||
- If True: Delete knowledge bases from deleted users
|
||||
- delete_orphaned_models: bool = True
|
||||
- If True: Delete models from deleted users
|
||||
- delete_orphaned_notes: bool = True
|
||||
- If True: Delete notes from deleted users
|
||||
- delete_orphaned_folders: bool = True
|
||||
- If True: Delete folders from deleted users
|
||||
"""
|
||||
try:
|
||||
log.info("Starting data pruning process")
|
||||
|
||||
# Stage 1: Delete old chats based on user criteria (optional)
|
||||
if form_data.days is not None:
|
||||
cutoff_time = int(time.time()) - (form_data.days * 86400)
|
||||
chats_to_delete = []
|
||||
|
||||
for chat in Chats.get_chats():
|
||||
if chat.updated_at < cutoff_time:
|
||||
# Check exemption conditions
|
||||
if form_data.exempt_archived_chats and chat.archived:
|
||||
log.debug(f"Exempting archived chat: {chat.id}")
|
||||
continue
|
||||
if form_data.exempt_chats_in_folders and (getattr(chat, 'folder_id', None) is not None or getattr(chat, 'pinned', False)):
|
||||
folder_status = f"folder_id: {getattr(chat, 'folder_id', None)}" if getattr(chat, 'folder_id', None) else "not in folder"
|
||||
pinned_status = f"pinned: {getattr(chat, 'pinned', False)}"
|
||||
log.debug(f"Exempting chat in folder or pinned: {chat.id} ({folder_status}, {pinned_status})")
|
||||
continue
|
||||
log.debug(f"Chat {chat.id} will be deleted - archived: {getattr(chat, 'archived', False)}, folder_id: {getattr(chat, 'folder_id', None)}, pinned: {getattr(chat, 'pinned', False)}")
|
||||
chats_to_delete.append(chat)
|
||||
|
||||
if chats_to_delete:
|
||||
log.info(f"Deleting {len(chats_to_delete)} old chats (older than {form_data.days} days)")
|
||||
for chat in chats_to_delete:
|
||||
Chats.delete_chat_by_id(chat.id)
|
||||
else:
|
||||
log.info(f"No chats found older than {form_data.days} days")
|
||||
else:
|
||||
log.info("Skipping chat deletion (days parameter is None)")
|
||||
|
||||
# Stage 2: Build ground truth of what should be preserved
|
||||
log.info("Building preservation set")
|
||||
|
||||
# Get all active users
|
||||
active_user_ids = {user.id for user in Users.get_users()["users"]}
|
||||
log.info(f"Found {len(active_user_ids)} active users")
|
||||
|
||||
# Get all active knowledge bases and their file references
|
||||
active_kb_ids = set()
|
||||
knowledge_bases = Knowledges.get_knowledge_bases()
|
||||
|
||||
for kb in knowledge_bases:
|
||||
if kb.user_id in active_user_ids:
|
||||
active_kb_ids.add(kb.id)
|
||||
|
||||
log.info(f"Found {len(active_kb_ids)} active knowledge bases")
|
||||
|
||||
# Get all files that should be preserved (NOW COMPREHENSIVE!)
|
||||
active_file_ids = get_active_file_ids()
|
||||
|
||||
# Stage 3: Delete orphaned database records
|
||||
log.info("Deleting orphaned database records")
|
||||
|
||||
# Delete files not referenced by any knowledge base or belonging to deleted users
|
||||
deleted_files = 0
|
||||
for file_record in Files.get_files():
|
||||
should_delete = (
|
||||
file_record.id not in active_file_ids or
|
||||
file_record.user_id not in active_user_ids
|
||||
)
|
||||
|
||||
if should_delete:
|
||||
if safe_delete_file_by_id(file_record.id):
|
||||
deleted_files += 1
|
||||
|
||||
if deleted_files > 0:
|
||||
log.info(f"Deleted {deleted_files} orphaned files")
|
||||
|
||||
# Delete knowledge bases from deleted users (if enabled)
|
||||
deleted_kbs = 0
|
||||
if form_data.delete_orphaned_knowledge_bases:
|
||||
for kb in knowledge_bases:
|
||||
if kb.user_id not in active_user_ids:
|
||||
if safe_delete_vector_collection(kb.id):
|
||||
Knowledges.delete_knowledge_by_id(kb.id)
|
||||
deleted_kbs += 1
|
||||
|
||||
if deleted_kbs > 0:
|
||||
log.info(f"Deleted {deleted_kbs} orphaned knowledge bases")
|
||||
else:
|
||||
log.info("Skipping knowledge base deletion (disabled)")
|
||||
|
||||
# Delete other user-owned resources from deleted users (conditional)
|
||||
deleted_others = 0
|
||||
|
||||
# Delete orphaned chats of deleted users (conditional)
|
||||
if form_data.delete_orphaned_chats:
|
||||
chats_deleted = 0
|
||||
for chat in Chats.get_chats():
|
||||
if chat.user_id not in active_user_ids:
|
||||
Chats.delete_chat_by_id(chat.id)
|
||||
chats_deleted += 1
|
||||
deleted_others += 1
|
||||
if chats_deleted > 0:
|
||||
log.info(f"Deleted {chats_deleted} orphaned chats")
|
||||
else:
|
||||
log.info("Skipping orphaned chat deletion (disabled)")
|
||||
|
||||
# Delete orphaned tools of deleted users (conditional)
|
||||
if form_data.delete_orphaned_tools:
|
||||
tools_deleted = 0
|
||||
for tool in Tools.get_tools():
|
||||
if tool.user_id not in active_user_ids:
|
||||
Tools.delete_tool_by_id(tool.id)
|
||||
tools_deleted += 1
|
||||
deleted_others += 1
|
||||
if tools_deleted > 0:
|
||||
log.info(f"Deleted {tools_deleted} orphaned tools")
|
||||
else:
|
||||
log.info("Skipping tool deletion (disabled)")
|
||||
|
||||
# Delete orphaned functions of deleted users (conditional)
|
||||
if form_data.delete_orphaned_functions:
|
||||
functions_deleted = 0
|
||||
for function in Functions.get_functions():
|
||||
if function.user_id not in active_user_ids:
|
||||
Functions.delete_function_by_id(function.id)
|
||||
functions_deleted += 1
|
||||
deleted_others += 1
|
||||
if functions_deleted > 0:
|
||||
log.info(f"Deleted {functions_deleted} orphaned functions")
|
||||
else:
|
||||
log.info("Skipping function deletion (disabled)")
|
||||
|
||||
# Delete orphaned notes of deleted users (conditional)
|
||||
if form_data.delete_orphaned_notes:
|
||||
notes_deleted = 0
|
||||
for note in Notes.get_notes():
|
||||
if note.user_id not in active_user_ids:
|
||||
Notes.delete_note_by_id(note.id)
|
||||
notes_deleted += 1
|
||||
deleted_others += 1
|
||||
if notes_deleted > 0:
|
||||
log.info(f"Deleted {notes_deleted} orphaned notes")
|
||||
else:
|
||||
log.info("Skipping note deletion (disabled)")
|
||||
|
||||
# Delete orphaned prompts of deleted users (conditional)
|
||||
if form_data.delete_orphaned_prompts:
|
||||
prompts_deleted = 0
|
||||
for prompt in Prompts.get_prompts():
|
||||
if prompt.user_id not in active_user_ids:
|
||||
Prompts.delete_prompt_by_command(prompt.command)
|
||||
prompts_deleted += 1
|
||||
deleted_others += 1
|
||||
if prompts_deleted > 0:
|
||||
log.info(f"Deleted {prompts_deleted} orphaned prompts")
|
||||
else:
|
||||
log.info("Skipping prompt deletion (disabled)")
|
||||
|
||||
# Delete orphaned models of deleted users (conditional)
|
||||
if form_data.delete_orphaned_models:
|
||||
models_deleted = 0
|
||||
for model in Models.get_all_models():
|
||||
if model.user_id not in active_user_ids:
|
||||
Models.delete_model_by_id(model.id)
|
||||
models_deleted += 1
|
||||
deleted_others += 1
|
||||
if models_deleted > 0:
|
||||
log.info(f"Deleted {models_deleted} orphaned models")
|
||||
else:
|
||||
log.info("Skipping model deletion (disabled)")
|
||||
|
||||
# Delete orphaned folders of deleted users (conditional)
|
||||
if form_data.delete_orphaned_folders:
|
||||
folders_deleted = 0
|
||||
for folder in Folders.get_all_folders():
|
||||
if folder.user_id not in active_user_ids:
|
||||
Folders.delete_folder_by_id_and_user_id(folder.id, folder.user_id, delete_chats=False)
|
||||
folders_deleted += 1
|
||||
deleted_others += 1
|
||||
if folders_deleted > 0:
|
||||
log.info(f"Deleted {folders_deleted} orphaned folders")
|
||||
else:
|
||||
log.info("Skipping folder deletion (disabled)")
|
||||
|
||||
if deleted_others > 0:
|
||||
log.info(f"Total other orphaned records deleted: {deleted_others}")
|
||||
|
||||
# Stage 4: Clean up orphaned physical files
|
||||
log.info("Cleaning up orphaned physical files")
|
||||
|
||||
# Rebuild active sets after database cleanup
|
||||
final_active_file_ids = get_active_file_ids()
|
||||
final_active_kb_ids = {kb.id for kb in Knowledges.get_knowledge_bases()}
|
||||
|
||||
# Clean uploads directory
|
||||
cleanup_orphaned_uploads(final_active_file_ids)
|
||||
|
||||
# Clean vector collections
|
||||
cleanup_orphaned_vector_collections(final_active_file_ids, final_active_kb_ids)
|
||||
|
||||
# Stage 5: Database optimization
|
||||
log.info("Optimizing database")
|
||||
|
||||
# Vacuum main database
|
||||
try:
|
||||
with get_db() as db:
|
||||
db.execute(text("VACUUM"))
|
||||
log.debug("Vacuumed main database")
|
||||
except Exception as e:
|
||||
log.error(f"Failed to vacuum main database: {e}")
|
||||
|
||||
# Vacuum ChromaDB database if it exists
|
||||
if "chroma" in VECTOR_DB.lower():
|
||||
chroma_db_path = Path(CACHE_DIR).parent / "vector_db" / "chroma.sqlite3"
|
||||
if chroma_db_path.exists():
|
||||
try:
|
||||
import sqlite3
|
||||
with sqlite3.connect(str(chroma_db_path)) as conn:
|
||||
conn.execute("VACUUM")
|
||||
log.debug("Vacuumed ChromaDB database")
|
||||
except Exception as e:
|
||||
log.error(f"Failed to vacuum ChromaDB database: {e}")
|
||||
|
||||
log.info("Data pruning completed successfully")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
log.exception(f"Error during data pruning: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=ERROR_MESSAGES.DEFAULT("Data pruning failed"),
|
||||
)
|
||||
54
src/lib/apis/prune.ts
Normal file
54
src/lib/apis/prune.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
|
||||
export const pruneData = async (
|
||||
token: string,
|
||||
days: number | null,
|
||||
exempt_archived_chats: boolean,
|
||||
exempt_chats_in_folders: boolean,
|
||||
delete_orphaned_chats: boolean = true,
|
||||
delete_orphaned_tools: boolean = false,
|
||||
delete_orphaned_functions: boolean = false,
|
||||
delete_orphaned_prompts: boolean = true,
|
||||
delete_orphaned_knowledge_bases: boolean = true,
|
||||
delete_orphaned_models: boolean = true,
|
||||
delete_orphaned_notes: boolean = true,
|
||||
delete_orphaned_folders: boolean = true
|
||||
) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/prune/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
days,
|
||||
exempt_archived_chats,
|
||||
exempt_chats_in_folders,
|
||||
delete_orphaned_chats,
|
||||
delete_orphaned_tools,
|
||||
delete_orphaned_functions,
|
||||
delete_orphaned_prompts,
|
||||
delete_orphaned_knowledge_bases,
|
||||
delete_orphaned_models,
|
||||
delete_orphaned_notes,
|
||||
delete_orphaned_folders
|
||||
})
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
console.log(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
<script lang="ts">
|
||||
import fileSaver from 'file-saver';
|
||||
const { saveAs } = fileSaver;
|
||||
|
||||
import { downloadDatabase, downloadLiteLLMConfig } from '$lib/apis/utils';
|
||||
import { onMount, getContext } from 'svelte';
|
||||
import { config, user } from '$lib/stores';
|
||||
|
|
@ -9,23 +8,58 @@
|
|||
import { getAllUserChats } from '$lib/apis/chats';
|
||||
import { getAllUsers } from '$lib/apis/users';
|
||||
import { exportConfig, importConfig } from '$lib/apis/configs';
|
||||
|
||||
import PruneDataDialog from '$lib/components/common/PruneDataDialog.svelte';
|
||||
import { pruneData } from '$lib/apis/prune';
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let saveHandler: Function;
|
||||
|
||||
let showPruneDataDialog = false;
|
||||
const exportAllUserChats = async () => {
|
||||
let blob = new Blob([JSON.stringify(await getAllUserChats(localStorage.token))], {
|
||||
type: 'application/json'
|
||||
});
|
||||
saveAs(blob, `all-chats-export-${Date.now()}.json`);
|
||||
};
|
||||
|
||||
const handlePruneDataConfirm = async (event) => {
|
||||
const {
|
||||
days,
|
||||
exempt_archived_chats,
|
||||
exempt_chats_in_folders,
|
||||
delete_orphaned_chats,
|
||||
delete_orphaned_tools,
|
||||
delete_orphaned_functions,
|
||||
delete_orphaned_prompts,
|
||||
delete_orphaned_knowledge_bases,
|
||||
delete_orphaned_models,
|
||||
delete_orphaned_notes,
|
||||
delete_orphaned_folders
|
||||
} = event.detail;
|
||||
|
||||
const res = await pruneData(
|
||||
localStorage.token,
|
||||
days,
|
||||
exempt_archived_chats,
|
||||
exempt_chats_in_folders,
|
||||
delete_orphaned_chats,
|
||||
delete_orphaned_tools,
|
||||
delete_orphaned_functions,
|
||||
delete_orphaned_prompts,
|
||||
delete_orphaned_knowledge_bases,
|
||||
delete_orphaned_models,
|
||||
delete_orphaned_notes,
|
||||
delete_orphaned_folders
|
||||
).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
if (res) {
|
||||
toast.success('Data pruned successfully');
|
||||
}
|
||||
};
|
||||
|
||||
const exportUsers = async () => {
|
||||
const users = await getAllUsers(localStorage.token);
|
||||
|
||||
const headers = ['id', 'name', 'email', 'role'];
|
||||
|
||||
const csv = [
|
||||
headers.join(','),
|
||||
...users.users.map((user) => {
|
||||
|
|
@ -39,16 +73,15 @@
|
|||
.join(',');
|
||||
})
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
saveAs(blob, 'users.csv');
|
||||
};
|
||||
|
||||
|
||||
onMount(async () => {
|
||||
// permissions = await getUserPermissions(localStorage.token);
|
||||
});
|
||||
</script>
|
||||
|
||||
<PruneDataDialog bind:show={showPruneDataDialog} on:confirm={handlePruneDataConfirm} />
|
||||
<form
|
||||
class="flex flex-col h-full justify-between space-y-3 text-sm"
|
||||
on:submit|preventDefault={async () => {
|
||||
|
|
@ -58,7 +91,6 @@
|
|||
<div class=" space-y-3 overflow-y-scroll scrollbar-hidden h-full">
|
||||
<div>
|
||||
<div class=" mb-2 text-sm font-medium">{$i18n.t('Database')}</div>
|
||||
|
||||
<input
|
||||
id="config-json-input"
|
||||
hidden
|
||||
|
|
@ -67,24 +99,20 @@
|
|||
on:change={(e) => {
|
||||
const file = e.target.files[0];
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = async (e) => {
|
||||
const res = await importConfig(localStorage.token, JSON.parse(e.target.result)).catch(
|
||||
(error) => {
|
||||
toast.error(`${error}`);
|
||||
}
|
||||
);
|
||||
|
||||
if (res) {
|
||||
toast.success('Config imported successfully');
|
||||
}
|
||||
e.target.value = null;
|
||||
};
|
||||
|
||||
reader.readAsText(file);
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class=" flex rounded-md py-2 px-3 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
|
||||
|
|
@ -111,7 +139,6 @@
|
|||
{$i18n.t('Import Config from JSON File')}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class=" flex rounded-md py-2 px-3 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
|
||||
|
|
@ -142,19 +169,15 @@
|
|||
{$i18n.t('Export Config to JSON File')}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<hr class="border-gray-100 dark:border-gray-850 my-1" />
|
||||
|
||||
{#if $config?.features.enable_admin_export ?? true}
|
||||
<div class=" flex w-full justify-between">
|
||||
<!-- <div class=" self-center text-xs font-medium">{$i18n.t('Allow Chat Deletion')}</div> -->
|
||||
|
||||
<button
|
||||
class=" flex rounded-md py-1.5 px-3 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
// exportAllUserChats();
|
||||
|
||||
downloadDatabase(localStorage.token).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
});
|
||||
|
|
@ -178,7 +201,6 @@
|
|||
<div class=" self-center text-sm font-medium">{$i18n.t('Download Database')}</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class=" flex rounded-md py-2 px-3 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
|
||||
on:click={() => {
|
||||
|
|
@ -204,7 +226,6 @@
|
|||
{$i18n.t('Export All Chats (All Users)')}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class=" flex rounded-md py-2 px-3 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
|
||||
on:click={() => {
|
||||
|
|
@ -231,9 +252,34 @@
|
|||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
<hr class="border-gray-100 dark:border-gray-850 my-1" />
|
||||
<button
|
||||
type="button"
|
||||
class=" flex rounded-md py-2 px-3 w-full bg-yellow-500 hover:bg-yellow-600 text-white transition"
|
||||
on:click={() => {
|
||||
showPruneDataDialog = true;
|
||||
}}
|
||||
>
|
||||
<div class=" self-center mr-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.5 2a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h7a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-7ZM3 6a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1H3Zm1 4a.5.5 0 0 1 .5-.5h6a.5.5 0 0 1 0 1H4.5a.5.5 0 0 1-.5-.5Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class=" self-center text-sm font-medium">
|
||||
{$i18n.t('Prune Orphaned Data')}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="flex justify-end pt-3 text-sm font-medium">
|
||||
<button
|
||||
class=" px-4 py-2 bg-emerald-700 hover:bg-emerald-800 text-gray-100 transition rounded-lg"
|
||||
|
|
@ -241,6 +287,5 @@
|
|||
>
|
||||
{$i18n.t('Save')}
|
||||
</button>
|
||||
|
||||
</div> -->
|
||||
</form>
|
||||
</form>
|
||||
589
src/lib/components/common/PruneDataDialog.svelte
Normal file
589
src/lib/components/common/PruneDataDialog.svelte
Normal file
|
|
@ -0,0 +1,589 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher, getContext } from 'svelte';
|
||||
import Modal from '$lib/components/common/Modal.svelte';
|
||||
import Switch from '$lib/components/common/Switch.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let show = false;
|
||||
|
||||
let deleteChatsByAge = false;
|
||||
let days = 60;
|
||||
let exempt_archived_chats = true;
|
||||
let exempt_chats_in_folders = false;
|
||||
|
||||
// Orphaned resource deletion toggles
|
||||
let delete_orphaned_chats = true;
|
||||
let delete_orphaned_tools = false;
|
||||
let delete_orphaned_functions = false;
|
||||
let delete_orphaned_prompts = true;
|
||||
let delete_orphaned_knowledge_bases = true;
|
||||
let delete_orphaned_models = true;
|
||||
let delete_orphaned_notes = true;
|
||||
let delete_orphaned_folders = true;
|
||||
|
||||
let showDetailsExpanded = false;
|
||||
let activeDetailsTab = 'chats';
|
||||
let activeSettingsTab = 'chats';
|
||||
let showApiPreview = false;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const confirm = () => {
|
||||
dispatch('confirm', {
|
||||
days: deleteChatsByAge ? days : null,
|
||||
exempt_archived_chats,
|
||||
exempt_chats_in_folders,
|
||||
delete_orphaned_chats,
|
||||
delete_orphaned_tools,
|
||||
delete_orphaned_functions,
|
||||
delete_orphaned_prompts,
|
||||
delete_orphaned_knowledge_bases,
|
||||
delete_orphaned_models,
|
||||
delete_orphaned_notes,
|
||||
delete_orphaned_folders
|
||||
});
|
||||
show = false;
|
||||
};
|
||||
|
||||
// Generate API call preview
|
||||
$: apiCallPreview = `POST /api/v1/admin/prune
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <your-api-key>
|
||||
|
||||
{
|
||||
"days": ${deleteChatsByAge ? days : null},
|
||||
"exempt_archived_chats": ${exempt_archived_chats},
|
||||
"exempt_chats_in_folders": ${exempt_chats_in_folders},
|
||||
"delete_orphaned_chats": ${delete_orphaned_chats},
|
||||
"delete_orphaned_tools": ${delete_orphaned_tools},
|
||||
"delete_orphaned_functions": ${delete_orphaned_functions},
|
||||
"delete_orphaned_prompts": ${delete_orphaned_prompts},
|
||||
"delete_orphaned_knowledge_bases": ${delete_orphaned_knowledge_bases},
|
||||
"delete_orphaned_models": ${delete_orphaned_models},
|
||||
"delete_orphaned_notes": ${delete_orphaned_notes},
|
||||
"delete_orphaned_folders": ${delete_orphaned_folders}
|
||||
}`;
|
||||
|
||||
const copyApiCall = () => {
|
||||
navigator.clipboard.writeText(apiCallPreview).then(() => {
|
||||
// Could add a toast notification here
|
||||
console.log('API call copied to clipboard');
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy API call: ', err);
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal bind:show size="lg">
|
||||
<div>
|
||||
<div class="flex justify-between dark:text-gray-300 px-5 pt-4 pb-2">
|
||||
<div class="text-lg font-medium self-center">
|
||||
{$i18n.t('Prune Orphaned Data')}
|
||||
</div>
|
||||
<button
|
||||
class="self-center"
|
||||
on:click={() => {
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col w-full px-5 pb-5 dark:text-gray-200">
|
||||
<div class="space-y-4">
|
||||
<!-- Critical Warning Message -->
|
||||
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3 flex-1">
|
||||
<h3 class="text-sm font-medium text-red-800 dark:text-red-200 mb-2">
|
||||
{$i18n.t('Destructive Operation - Backup Recommended')}
|
||||
</h3>
|
||||
<div class="text-sm text-red-700 dark:text-red-300 space-y-1">
|
||||
<p>{$i18n.t('This action will permanently delete data from your database. Only orphaned or old data, based on your configuration settings, will be deleted. All active, referenced data remains completely safe.')}</p>
|
||||
<p>{$i18n.t('This operation cannot be undone. Create a complete backup of your database and files before proceeding. This operation is performed entirely at your own risk - having a backup ensures you can restore any data if something unexpected occurs.')}</p>
|
||||
|
||||
<!-- Expandable Details Section -->
|
||||
<div class="mt-3">
|
||||
<button
|
||||
class="flex items-center text-xs text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200 focus:outline-none"
|
||||
on:click={() => showDetailsExpanded = !showDetailsExpanded}
|
||||
>
|
||||
<svg
|
||||
class="w-3 h-3 mr-1 transition-transform duration-200 {showDetailsExpanded ? 'rotate-90' : ''}"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{showDetailsExpanded ? $i18n.t('Hide details') : $i18n.t('Show details')}
|
||||
</button>
|
||||
|
||||
{#if showDetailsExpanded}
|
||||
<div class="mt-2 pl-4 border-l-2 border-red-300 dark:border-red-700 text-xs text-red-600 dark:text-red-400">
|
||||
<p class="mb-3"><strong>{$i18n.t('Note:')}</strong> {$i18n.t('This list provides an overview of what will be deleted during the pruning process and may not be complete or fully up-to-date.')}</p>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="flex flex-wrap gap-1 mb-3 border-b border-red-300 dark:border-red-700">
|
||||
<button
|
||||
class="px-2 py-1 text-xs font-medium rounded-t transition-colors {activeDetailsTab === 'chats' ? 'bg-red-100 dark:bg-red-800 text-red-800 dark:text-red-200' : 'text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200'}"
|
||||
on:click={() => activeDetailsTab = 'chats'}
|
||||
>
|
||||
{$i18n.t('Chats')}
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 text-xs font-medium rounded-t transition-colors {activeDetailsTab === 'workspace' ? 'bg-red-100 dark:bg-red-800 text-red-800 dark:text-red-200' : 'text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200'}"
|
||||
on:click={() => activeDetailsTab = 'workspace'}
|
||||
>
|
||||
{$i18n.t('Workspace')}
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 text-xs font-medium rounded-t transition-colors {activeDetailsTab === 'datavector' ? 'bg-red-100 dark:bg-red-800 text-red-800 dark:text-red-200' : 'text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200'}"
|
||||
on:click={() => activeDetailsTab = 'datavector'}
|
||||
>
|
||||
{$i18n.t('Data & Vector')}
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 text-xs font-medium rounded-t transition-colors {activeDetailsTab === 'imagesaudio' ? 'bg-red-100 dark:bg-red-800 text-red-800 dark:text-red-200' : 'text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200'}"
|
||||
on:click={() => activeDetailsTab = 'imagesaudio'}
|
||||
>
|
||||
{$i18n.t('Images & Audio')}
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 text-xs font-medium rounded-t transition-colors {activeDetailsTab === 'system' ? 'bg-red-100 dark:bg-red-800 text-red-800 dark:text-red-200' : 'text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200'}"
|
||||
on:click={() => activeDetailsTab = 'system'}
|
||||
>
|
||||
{$i18n.t('System & Database')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="space-y-2">
|
||||
{#if activeDetailsTab === 'chats'}
|
||||
<div class="space-y-1">
|
||||
<p><strong>{$i18n.t('Age-Based Chat Deletion:')}</strong></p>
|
||||
<p>• {$i18n.t('Removes conversations older than specified days based on when they were last modified or updated (not when they were created)')}</p>
|
||||
<p>• {$i18n.t('Supports exemptions for:')}</p>
|
||||
<p class="ml-4">◦ {$i18n.t('Archived chats')}</p>
|
||||
<p class="ml-4">◦ {$i18n.t('Chats organized in folders and pinned chats')}</p>
|
||||
|
||||
<p class="pt-2"><strong>{$i18n.t('Orphaned Content Cleanup:')}</strong></p>
|
||||
<p>• {$i18n.t('Delete orphaned chats from deleted users')}</p>
|
||||
<p>• {$i18n.t('Delete orphaned folders from deleted users')}</p>
|
||||
</div>
|
||||
{:else if activeDetailsTab === 'workspace'}
|
||||
<div class="space-y-1">
|
||||
<p><strong>{$i18n.t('Orphaned Workspace Items from Deleted Users:')}</strong></p>
|
||||
<p>• {$i18n.t('Delete orphaned knowledge bases')}</p>
|
||||
<p>• {$i18n.t('Delete orphaned custom tools')}</p>
|
||||
<p>• {$i18n.t('Delete orphaned custom functions (Actions, Pipes, Filters)')}</p>
|
||||
<p>• {$i18n.t('Delete orphaned custom prompts and templates')}</p>
|
||||
<p>• {$i18n.t('Delete orphaned custom models and configurations')}</p>
|
||||
<p>• {$i18n.t('Delete orphaned notes')}</p>
|
||||
</div>
|
||||
{:else if activeDetailsTab === 'datavector'}
|
||||
<div class="space-y-1">
|
||||
<p><strong>{$i18n.t('Files & Vector Storage:')}</strong></p>
|
||||
<p>• {$i18n.t('Orphaned files and attachments from deleted content')}</p>
|
||||
<p>• {$i18n.t('Vector embeddings and collections for removed data')}</p>
|
||||
<p>• {$i18n.t('Uploaded files that lost their database references')}</p>
|
||||
<p>• {$i18n.t('Vector storage directories without corresponding data')}</p>
|
||||
</div>
|
||||
{:else if activeDetailsTab === 'imagesaudio'}
|
||||
<div class="space-y-1">
|
||||
<p><strong>{$i18n.t('Images & Audio Content Cleanup:')}</strong></p>
|
||||
<p>• {$i18n.t('TBD - Image cleanup functionality')}</p>
|
||||
<p>• {$i18n.t('TBD - Audio cleanup functionality')}</p>
|
||||
<p>• {$i18n.t('TBD - Orphaned images and audio files')}</p>
|
||||
<p>• {$i18n.t('TBD - Media processing cache cleanup')}</p>
|
||||
</div>
|
||||
{:else if activeDetailsTab === 'system'}
|
||||
<div class="space-y-1">
|
||||
<p><strong>{$i18n.t('Database & System Cleanup:')}</strong></p>
|
||||
<p>• {$i18n.t('Removal of broken database references and stale entries')}</p>
|
||||
<p>• {$i18n.t('Disk space reclamation by database cleanup')}</p>
|
||||
<p>• {$i18n.t('Synchronization of database records with actual file storage')}</p>
|
||||
<p>• {$i18n.t('Fix inconsistencies between storage systems')}</p>
|
||||
<p>• {$i18n.t('Database performance optimization')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Performance Warning -->
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
{$i18n.t('Performance Warning: This operation may take a very long time to complete, especially if you have never cleaned your database before or if your instance stores large amounts of data. The process could take anywhere from seconds, to minutes, to half an hour and beyond depending on your data size.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Section with Tabs -->
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<div class="flex items-center mb-3">
|
||||
<svg class="h-4 w-4 text-blue-600 dark:text-blue-400 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<h4 class="text-sm font-medium text-blue-800 dark:text-blue-200">
|
||||
{$i18n.t('Pruning Configuration')}
|
||||
</h4>
|
||||
</div>
|
||||
<p class="text-xs text-blue-700 dark:text-blue-300 mb-4">
|
||||
{$i18n.t('Configure what data should be cleaned up during the pruning process.')}
|
||||
</p>
|
||||
|
||||
<!-- Settings Tab Navigation - ONLY CHATS AND WORKSPACE -->
|
||||
<div class="flex flex-wrap gap-1 mb-4 border-b border-blue-300 dark:border-blue-700">
|
||||
<button
|
||||
class="px-3 py-2 text-sm font-medium rounded-t transition-colors {activeSettingsTab === 'chats' ? 'bg-blue-100 dark:bg-blue-800 text-blue-800 dark:text-blue-200' : 'text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200'}"
|
||||
on:click={() => activeSettingsTab = 'chats'}
|
||||
>
|
||||
{$i18n.t('Chats')}
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-2 text-sm font-medium rounded-t transition-colors {activeSettingsTab === 'workspace' ? 'bg-blue-100 dark:bg-blue-800 text-blue-800 dark:text-blue-200' : 'text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200'}"
|
||||
on:click={() => activeSettingsTab = 'workspace'}
|
||||
>
|
||||
{$i18n.t('Workspace')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Settings Tab Content - ONLY CHATS AND WORKSPACE -->
|
||||
<div class="space-y-4">
|
||||
{#if activeSettingsTab === 'chats'}
|
||||
<!-- Age-Based Chat Deletion -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start py-2">
|
||||
<div class="flex items-center">
|
||||
<div class="mr-3">
|
||||
<Switch bind:state={deleteChatsByAge} />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{$i18n.t('Delete chats by age')}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t('Optionally remove old chats based on last update time')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Options (when enabled) -->
|
||||
{#if deleteChatsByAge}
|
||||
<div class="ml-8 space-y-4 border-l-2 border-gray-200 dark:border-gray-700 pl-4">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{$i18n.t('Delete chats older than')}
|
||||
</label>
|
||||
<div class="flex items-center space-x-2">
|
||||
<input
|
||||
id="days"
|
||||
type="number"
|
||||
min="0"
|
||||
bind:value={days}
|
||||
class="w-20 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{$i18n.t('days')}</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t('Set to 0 to delete all chats, or specify number of days')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start py-2">
|
||||
<div class="flex items-center">
|
||||
<div class="mr-3">
|
||||
<Switch bind:state={exempt_archived_chats} />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{$i18n.t('Exempt archived chats')}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t('Keep archived chats even if they are old')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start py-2">
|
||||
<div class="flex items-center">
|
||||
<div class="mr-3">
|
||||
<Switch bind:state={exempt_chats_in_folders} />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{$i18n.t('Exempt chats in folders')}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t('Keep chats that are organized in folders or pinned')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Orphaned Chat Deletion -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<div class="flex items-start py-2">
|
||||
<div class="flex items-center">
|
||||
<div class="mr-3">
|
||||
<Switch bind:state={delete_orphaned_chats} />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{$i18n.t('Delete orphaned chats')}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t('Delete orphaned chats from deleted users')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start py-2">
|
||||
<div class="flex items-center">
|
||||
<div class="mr-3">
|
||||
<Switch bind:state={delete_orphaned_folders} />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{$i18n.t('Delete orphaned folders')}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t('Delete orphaned folders from deleted users')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if activeSettingsTab === 'workspace'}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<!-- Knowledge Bases -->
|
||||
<div class="flex items-start py-2">
|
||||
<div class="flex items-center">
|
||||
<div class="mr-3">
|
||||
<Switch bind:state={delete_orphaned_knowledge_bases} />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{$i18n.t('Delete orphaned knowledge bases')}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t('Delete orphaned knowledge bases from deleted users')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tools -->
|
||||
<div class="flex items-start py-2">
|
||||
<div class="flex items-center">
|
||||
<div class="mr-3">
|
||||
<Switch bind:state={delete_orphaned_tools} />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{$i18n.t('Delete orphaned tools')}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t('Delete orphaned custom tools from deleted users')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Functions -->
|
||||
<div class="flex items-start py-2">
|
||||
<div class="flex items-center">
|
||||
<div class="mr-3">
|
||||
<Switch bind:state={delete_orphaned_functions} />
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
<span>{$i18n.t('Delete orphaned functions')}</span>
|
||||
<div class="relative group ml-2">
|
||||
<svg class="h-3 w-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 cursor-help" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<div class="absolute left-1/2 transform -translate-x-1/2 bottom-full mb-2 w-48 px-3 py-2 text-xs text-white bg-gray-900 dark:bg-gray-700 rounded-lg shadow-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none z-10">
|
||||
<div class="font-medium mb-1">{$i18n.t('Admin panel functions - all functions, including:')}</div>
|
||||
<div class="space-y-0.5">
|
||||
<div>• {$i18n.t('Actions')}</div>
|
||||
<div>• {$i18n.t('Pipes')}</div>
|
||||
<div>• {$i18n.t('Filters')}</div>
|
||||
</div>
|
||||
<div class="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t('Delete orphaned custom functions from deleted users')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prompts -->
|
||||
<div class="flex items-start py-2">
|
||||
<div class="flex items-center">
|
||||
<div class="mr-3">
|
||||
<Switch bind:state={delete_orphaned_prompts} />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{$i18n.t('Delete orphaned prompts')}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t('Delete orphaned custom prompts from deleted users')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Models -->
|
||||
<div class="flex items-start py-2">
|
||||
<div class="flex items-center">
|
||||
<div class="mr-3">
|
||||
<Switch bind:state={delete_orphaned_models} />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{$i18n.t('Delete orphaned models')}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t('Delete orphaned custom models from deleted users')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="flex items-start py-2">
|
||||
<div class="flex items-center">
|
||||
<div class="mr-3">
|
||||
<Switch bind:state={delete_orphaned_notes} />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{$i18n.t('Delete orphaned notes')}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t('Delete orphaned notes from deleted users')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Call Preview Section -->
|
||||
<div class="bg-gray-50 dark:bg-gray-900/20 border border-gray-200 dark:border-gray-800 rounded-lg p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3 flex-1">
|
||||
<h3 class="text-sm font-medium text-gray-800 dark:text-gray-200 mb-2">
|
||||
{$i18n.t('API Automation Helper')}
|
||||
</h3>
|
||||
|
||||
<button
|
||||
class="flex items-center text-xs text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 focus:outline-none mb-3"
|
||||
on:click={() => showApiPreview = !showApiPreview}
|
||||
>
|
||||
<svg
|
||||
class="w-3 h-3 mr-1 transition-transform duration-200 {showApiPreview ? 'rotate-90' : ''}"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{showApiPreview ? $i18n.t('Hide API call') : $i18n.t('Show API call')}
|
||||
</button>
|
||||
|
||||
{#if showApiPreview}
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
|
||||
{$i18n.t('Use this API call configuration to automate pruning operations in your own maintenance scripts.')}
|
||||
</p>
|
||||
<div class="relative">
|
||||
<textarea
|
||||
readonly
|
||||
value={apiCallPreview}
|
||||
class="w-full h-40 px-3 py-2 text-xs font-mono bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 resize-none focus:ring-2 focus:ring-gray-500 focus:border-gray-500"
|
||||
on:focus={(e) => e.target.select()}
|
||||
></textarea>
|
||||
<button
|
||||
class="absolute top-2 right-2 px-2 py-1 text-xs font-medium text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
on:click={copyApiCall}
|
||||
title={$i18n.t('Copy to clipboard')}
|
||||
>
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z"></path>
|
||||
<path d="M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700 transition-colors"
|
||||
on:click={() => (show = false)}
|
||||
>
|
||||
{$i18n.t('Cancel')}
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-yellow-600 border border-transparent rounded-lg hover:bg-yellow-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yellow-500 transition-colors"
|
||||
on:click={confirm}
|
||||
>
|
||||
{$i18n.t('Prune Data')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
Loading…
Reference in a new issue