From db80dd2692f0af3ce0351df7d1ab3112a75592cd Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 24 Jan 2026 02:38:57 +0400 Subject: [PATCH 01/18] feat: prompt history table --- .../374d2f66af06_add_prompt_history_table.py | 245 ++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 backend/open_webui/migrations/versions/374d2f66af06_add_prompt_history_table.py diff --git a/backend/open_webui/migrations/versions/374d2f66af06_add_prompt_history_table.py b/backend/open_webui/migrations/versions/374d2f66af06_add_prompt_history_table.py new file mode 100644 index 000000000..e56854a15 --- /dev/null +++ b/backend/open_webui/migrations/versions/374d2f66af06_add_prompt_history_table.py @@ -0,0 +1,245 @@ +"""Add prompt history table + +Revision ID: 374d2f66af06 +Revises: c440947495f3 +Create Date: 2026-01-23 17:15:00.000000 + +""" + +from typing import Sequence, Union +import uuid + +from alembic import op +import sqlalchemy as sa + + +revision: str = "374d2f66af06" +down_revision: Union[str, None] = "c440947495f3" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + conn = op.get_bind() + + # Step 1: Read existing data from OLD table (schema likely command as PK) + # We use batch_alter previously, but we want to move to new table. + # We need to assume the OLD structure. + + old_prompt_table = sa.table( + "prompt", + sa.column("command", sa.Text()), + sa.column("user_id", sa.Text()), + sa.column("title", sa.Text()), + sa.column("content", sa.Text()), + sa.column("timestamp", sa.BigInteger()), + sa.column("access_control", sa.JSON()), + ) + + # Check if table exists/read data + try: + existing_prompts = conn.execute( + sa.select( + old_prompt_table.c.command, + old_prompt_table.c.user_id, + old_prompt_table.c.title, + old_prompt_table.c.content, + old_prompt_table.c.timestamp, + old_prompt_table.c.access_control, + ) + ).fetchall() + except Exception: + # Fallback if table doesn't exist (new install) + existing_prompts = [] + + # Step 2: Create new prompt table with 'id' as PRIMARY KEY + op.create_table( + "prompt_new", + sa.Column("id", sa.Text(), primary_key=True), + sa.Column("command", sa.String(), unique=True, index=True), + sa.Column("user_id", sa.String(), nullable=False), + sa.Column("name", sa.Text(), nullable=False), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("data", sa.JSON(), nullable=True), + sa.Column("meta", sa.JSON(), nullable=True), + sa.Column("access_control", sa.JSON(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"), + sa.Column("version_id", sa.Text(), nullable=True), + sa.Column("created_at", sa.BigInteger(), nullable=False), + sa.Column("updated_at", sa.BigInteger(), nullable=False), + ) + + # Step 3: Create prompt_history table + op.create_table( + "prompt_history", + sa.Column("id", sa.Text(), primary_key=True), + sa.Column("prompt_id", sa.Text(), nullable=False, index=True), + sa.Column("parent_id", sa.Text(), nullable=True), + sa.Column("snapshot", sa.JSON(), nullable=False), + sa.Column("user_id", sa.Text(), nullable=False), + sa.Column("commit_message", sa.Text(), nullable=True), + sa.Column("created_at", sa.BigInteger(), nullable=False), + ) + + # Step 4: Migrate data + prompt_new_table = sa.table( + "prompt_new", + sa.column("id", sa.Text()), + sa.column("command", sa.String()), + sa.column("user_id", sa.String()), + sa.column("name", sa.Text()), + sa.column("content", sa.Text()), + sa.column("data", sa.JSON()), + sa.column("meta", sa.JSON()), + sa.column("access_control", sa.JSON()), + sa.column("is_active", sa.Boolean()), + sa.column("version_id", sa.Text()), + sa.column("created_at", sa.BigInteger()), + sa.column("updated_at", sa.BigInteger()), + ) + + prompt_history_table = sa.table( + "prompt_history", + sa.column("id", sa.Text()), + sa.column("prompt_id", sa.Text()), + sa.column("parent_id", sa.Text()), + sa.column("snapshot", sa.JSON()), + sa.column("user_id", sa.Text()), + sa.column("commit_message", sa.Text()), + sa.column("created_at", sa.BigInteger()), + ) + + for row in existing_prompts: + command = row[0] + user_id = row[1] + title = row[2] + content = row[3] + timestamp = row[4] + access_control = row[5] + + new_uuid = str(uuid.uuid4()) + history_uuid = str(uuid.uuid4()) + clean_command = command[1:] if command and command.startswith("/") else command + + # Insert into prompt_new + conn.execute( + sa.insert(prompt_new_table).values( + id=new_uuid, + command=clean_command, + user_id=user_id, + name=title, + content=content, + data={}, + meta={}, + access_control=access_control, + is_active=True, + version_id=history_uuid, + created_at=timestamp, + updated_at=timestamp, + ) + ) + + # Create initial history entry + conn.execute( + sa.insert(prompt_history_table).values( + id=history_uuid, + prompt_id=new_uuid, + parent_id=None, + snapshot={ + "name": title, + "content": content, + "command": clean_command, + "data": {}, + "meta": {}, + "access_control": access_control, + }, + user_id=user_id, + commit_message=None, + created_at=timestamp, + ) + ) + + # Step 5: Replace old table with new one + op.drop_table("prompt") + op.rename_table("prompt_new", "prompt") + + +def downgrade() -> None: + conn = op.get_bind() + + # Step 1: Read new data + prompt_table = sa.table( + "prompt", + sa.column("command", sa.String()), + sa.column("name", sa.Text()), + sa.column("created_at", sa.BigInteger()), + sa.column("user_id", sa.Text()), + sa.column("content", sa.Text()), + sa.column("access_control", sa.JSON()), + ) + + try: + current_data = conn.execute( + sa.select( + prompt_table.c.command, + prompt_table.c.name, + prompt_table.c.created_at, + prompt_table.c.user_id, + prompt_table.c.content, + prompt_table.c.access_control, + ) + ).fetchall() + except Exception: + current_data = [] + + # Step 2: Drop history and table + op.drop_table("prompt_history") + op.drop_table("prompt") + + # Step 3: Recreate old table (command as PK?) + # Assuming old schema: + op.create_table( + "prompt", + sa.Column("command", sa.String(), primary_key=True), + sa.Column("user_id", sa.String()), + sa.Column("title", sa.Text()), + sa.Column("content", sa.Text()), + sa.Column("timestamp", sa.BigInteger()), + sa.Column("access_control", sa.JSON()), + sa.Column("id", sa.Integer(), nullable=True), + ) + + # Step 4: Restore data + old_prompt_table = sa.table( + "prompt", + sa.column("command", sa.String()), + sa.column("user_id", sa.String()), + sa.column("title", sa.Text()), + sa.column("content", sa.Text()), + sa.column("timestamp", sa.BigInteger()), + sa.column("access_control", sa.JSON()), + ) + + for row in current_data: + command = row[0] + name = row[1] + created_at = row[2] + user_id = row[3] + content = row[4] + access_control = row[5] + + # Restore leading / + old_command = ( + "/" + command if command and not command.startswith("/") else command + ) + + conn.execute( + sa.insert(old_prompt_table).values( + command=old_command, + user_id=user_id, + title=name, + content=content, + timestamp=created_at, + access_control=access_control, + ) + ) From 66daa1572205f0d7765ac5b2e5d0dfe59bbebb34 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 24 Jan 2026 02:39:29 +0400 Subject: [PATCH 02/18] wip: prompt history models --- backend/open_webui/models/prompt_history.py | 223 +++++++++++++++++++ backend/open_webui/models/prompts.py | 229 ++++++++++++++++++-- 2 files changed, 431 insertions(+), 21 deletions(-) create mode 100644 backend/open_webui/models/prompt_history.py diff --git a/backend/open_webui/models/prompt_history.py b/backend/open_webui/models/prompt_history.py new file mode 100644 index 000000000..ea7f566fb --- /dev/null +++ b/backend/open_webui/models/prompt_history.py @@ -0,0 +1,223 @@ +"""Prompt history model for version tracking.""" + +import time +import uuid +from typing import Optional +import json +import difflib + +from sqlalchemy.orm import Session +from open_webui.internal.db import Base, get_db_context +from open_webui.models.users import Users, UserResponse + +from pydantic import BaseModel, ConfigDict +from sqlalchemy import BigInteger, Column, Text, JSON, Index + + +#################### +# PromptHistory DB Schema +#################### + + +class PromptHistory(Base): + __tablename__ = "prompt_history" + + id = Column(Text, primary_key=True) + prompt_id = Column(Text, nullable=False, index=True) + parent_id = Column(Text, nullable=True) # Reference to parent commit + snapshot = Column(JSON, nullable=False) + user_id = Column(Text, nullable=False) + commit_message = Column(Text, nullable=True) + created_at = Column(BigInteger, nullable=False) + + +class PromptHistoryModel(BaseModel): + id: str + prompt_id: str + parent_id: Optional[str] = None + snapshot: dict + user_id: str + commit_message: Optional[str] = None + created_at: int + + model_config = ConfigDict(from_attributes=True) + + +class PromptHistoryResponse(PromptHistoryModel): + """Response model with user info.""" + user: Optional[UserResponse] = None + + +class PromptHistoryTable: + def create_history_entry( + self, + prompt_id: str, + snapshot: dict, + user_id: str, + parent_id: Optional[str] = None, + commit_message: Optional[str] = None, + db: Optional[Session] = None, + ) -> Optional[PromptHistoryModel]: + """Create a new history entry (commit) for a prompt.""" + with get_db_context(db) as db: + history = PromptHistory( + id=str(uuid.uuid4()), + prompt_id=prompt_id, + parent_id=parent_id, + snapshot=snapshot, + user_id=user_id, + commit_message=commit_message, + created_at=int(time.time()), + ) + db.add(history) + db.commit() + db.refresh(history) + return PromptHistoryModel.model_validate(history) + + def get_history_by_prompt_id( + self, + prompt_id: str, + limit: int = 50, + offset: int = 0, + db: Optional[Session] = None, + ) -> list[PromptHistoryResponse]: + """Get all history entries for a prompt, ordered by created_at desc.""" + with get_db_context(db) as db: + entries = ( + db.query(PromptHistory) + .filter(PromptHistory.prompt_id == prompt_id) + .order_by(PromptHistory.created_at.desc()) + .offset(offset) + .limit(limit) + .all() + ) + + # Get user info for each entry + user_ids = list(set(e.user_id for e in entries)) + users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] + users_dict = {user.id: user for user in users} + + return [ + PromptHistoryResponse( + **PromptHistoryModel.model_validate(entry).model_dump(), + user=users_dict.get(entry.user_id).model_dump() if users_dict.get(entry.user_id) else None, + ) + for entry in entries + ] + + def get_history_entry_by_id( + self, + history_id: str, + db: Optional[Session] = None, + ) -> Optional[PromptHistoryModel]: + """Get a specific history entry by ID.""" + with get_db_context(db) as db: + entry = db.query(PromptHistory).filter(PromptHistory.id == history_id).first() + if entry: + return PromptHistoryModel.model_validate(entry) + return None + + def get_latest_history_entry( + self, + prompt_id: str, + db: Optional[Session] = None, + ) -> Optional[PromptHistoryModel]: + """Get the most recent history entry for a prompt.""" + with get_db_context(db) as db: + entry = ( + db.query(PromptHistory) + .filter(PromptHistory.prompt_id == prompt_id) + .order_by(PromptHistory.created_at.desc()) + .first() + ) + if entry: + return PromptHistoryModel.model_validate(entry) + return None + + def get_history_count( + self, + prompt_id: str, + db: Optional[Session] = None, + ) -> int: + """Get the number of history entries for a prompt.""" + with get_db_context(db) as db: + return ( + db.query(PromptHistory) + .filter(PromptHistory.prompt_id == prompt_id) + .count() + ) + + def compute_diff( + self, + from_id: str, + to_id: str, + db: Optional[Session] = None, + ) -> Optional[dict]: + """Compute diff between two history entries.""" + with get_db_context(db) as db: + from_entry = db.query(PromptHistory).filter(PromptHistory.id == from_id).first() + to_entry = db.query(PromptHistory).filter(PromptHistory.id == to_id).first() + + if not from_entry or not to_entry: + return None + + from_snapshot = from_entry.snapshot + to_snapshot = to_entry.snapshot + + # Compute diff for content field + from_content = from_snapshot.get("content", "") + to_content = to_snapshot.get("content", "") + + diff_lines = list(difflib.unified_diff( + from_content.splitlines(keepends=True), + to_content.splitlines(keepends=True), + fromfile=f"v{from_id[:8]}", + tofile=f"v{to_id[:8]}", + lineterm="", + )) + + return { + "from_id": from_id, + "to_id": to_id, + "from_snapshot": from_snapshot, + "to_snapshot": to_snapshot, + "content_diff": diff_lines, + "name_changed": from_snapshot.get("name") != to_snapshot.get("name"), + "access_control_changed": from_snapshot.get("access_control") != to_snapshot.get("access_control"), + } + + def delete_history_by_prompt_id( + self, + prompt_id: str, + db: Optional[Session] = None, + ) -> bool: + """Delete all history entries for a prompt.""" + with get_db_context(db) as db: + db.query(PromptHistory).filter(PromptHistory.prompt_id == prompt_id).delete() + db.commit() + return True + + def delete_history_entry( + self, + history_id: str, + db: Optional[Session] = None, + ) -> bool: + """Delete a history entry and reparent its children to grandparent.""" + with get_db_context(db) as db: + entry = db.query(PromptHistory).filter_by(id=history_id).first() + if not entry: + return False + + # Find children that reference this entry as parent + children = db.query(PromptHistory).filter_by(parent_id=history_id).all() + + # Reparent children to grandparent + for child in children: + child.parent_id = entry.parent_id + + db.delete(entry) + db.commit() + return True + + +PromptHistories = PromptHistoryTable() diff --git a/backend/open_webui/models/prompts.py b/backend/open_webui/models/prompts.py index 847597bc6..42d724c65 100644 --- a/backend/open_webui/models/prompts.py +++ b/backend/open_webui/models/prompts.py @@ -1,4 +1,5 @@ import time +import uuid from typing import Optional from sqlalchemy.orm import Session @@ -7,7 +8,7 @@ from open_webui.models.groups import Groups from open_webui.models.users import Users, UserResponse from pydantic import BaseModel, ConfigDict -from sqlalchemy import BigInteger, Column, String, Text, JSON +from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON from open_webui.utils.access_control import has_access @@ -19,11 +20,17 @@ from open_webui.utils.access_control import has_access class Prompt(Base): __tablename__ = "prompt" - command = Column(String, primary_key=True) + id = Column(Text, primary_key=True) + command = Column(String, unique=True, index=True) user_id = Column(String) - title = Column(Text) + name = Column(Text) content = Column(Text) - timestamp = Column(BigInteger) + data = Column(JSON, nullable=True) + meta = Column(JSON, nullable=True) + is_active = Column(Boolean, default=True) + version_id = Column(Text, nullable=True) # Points to active history entry + created_at = Column(BigInteger, nullable=True) + updated_at = Column(BigInteger, nullable=True) access_control = Column(JSON, nullable=True) # Controls data access levels. # Defines access control rules for this entry. @@ -44,13 +51,19 @@ class Prompt(Base): class PromptModel(BaseModel): + id: Optional[str] = None command: str user_id: str - title: str + name: str content: str - timestamp: int # timestamp in epoch - + data: Optional[dict] = None + meta: Optional[dict] = None + is_active: Optional[bool] = True + version_id: Optional[str] = None + created_at: Optional[int] = None + updated_at: Optional[int] = None access_control: Optional[dict] = None + model_config = ConfigDict(from_attributes=True) @@ -69,21 +82,34 @@ class PromptAccessResponse(PromptUserResponse): class PromptForm(BaseModel): command: str - title: str + name: str # Changed from title content: str + data: Optional[dict] = None + meta: Optional[dict] = None access_control: Optional[dict] = None + version_id: Optional[str] = None # Active version + commit_message: Optional[str] = None # For history tracking class PromptsTable: def insert_new_prompt( self, user_id: str, form_data: PromptForm, db: Optional[Session] = None ) -> Optional[PromptModel]: + now = int(time.time()) + prompt_id = str(uuid.uuid4()) + prompt = PromptModel( - **{ - "user_id": user_id, - **form_data.model_dump(), - "timestamp": int(time.time()), - } + id=prompt_id, + user_id=user_id, + command=form_data.command, + name=form_data.name, + content=form_data.content, + data=form_data.data or {}, + meta=form_data.meta or {}, + access_control=form_data.access_control, + is_active=True, + created_at=now, + updated_at=now, ) try: @@ -92,26 +118,74 @@ class PromptsTable: db.add(result) db.commit() db.refresh(result) + if result: + # Create initial history entry + from open_webui.models.prompt_history import PromptHistories + + snapshot = { + "name": form_data.name, + "content": form_data.content, + "command": form_data.command, + "data": form_data.data or {}, + "meta": form_data.meta or {}, + "access_control": form_data.access_control, + } + + history_entry = PromptHistories.create_history_entry( + prompt_id=prompt_id, + snapshot=snapshot, + user_id=user_id, + parent_id=None, # Initial commit has no parent + commit_message=form_data.commit_message or "Initial version", + db=db, + ) + + # Set the initial version as the production version + if history_entry: + result.version_id = history_entry.id + db.commit() + db.refresh(result) + return PromptModel.model_validate(result) else: return None except Exception: return None + def get_prompt_by_id( + self, prompt_id: str, db: Optional[Session] = None + ) -> Optional[PromptModel]: + """Get prompt by UUID.""" + try: + with get_db_context(db) as db: + prompt = db.query(Prompt).filter_by(id=prompt_id).first() + if prompt: + return PromptModel.model_validate(prompt) + return None + except Exception: + return None + def get_prompt_by_command( self, command: str, db: Optional[Session] = None ) -> Optional[PromptModel]: try: with get_db_context(db) as db: prompt = db.query(Prompt).filter_by(command=command).first() - return PromptModel.model_validate(prompt) + if prompt: + return PromptModel.model_validate(prompt) + return None except Exception: return None def get_prompts(self, db: Optional[Session] = None) -> list[PromptUserResponse]: with get_db_context(db) as db: - all_prompts = db.query(Prompt).order_by(Prompt.timestamp.desc()).all() + all_prompts = ( + db.query(Prompt) + .filter(Prompt.is_active == True) + .order_by(Prompt.updated_at.desc()) + .all() + ) user_ids = list(set(prompt.user_id for prompt in all_prompts)) @@ -148,16 +222,101 @@ class PromptsTable: ] def update_prompt_by_command( - self, command: str, form_data: PromptForm, db: Optional[Session] = None + self, + command: str, + form_data: PromptForm, + user_id: str, + db: Optional[Session] = None ) -> Optional[PromptModel]: try: with get_db_context(db) as db: prompt = db.query(Prompt).filter_by(command=command).first() - prompt.title = form_data.title + if not prompt: + return None + + # Get the latest history entry for parent_id + from open_webui.models.prompt_history import PromptHistories + latest_history = PromptHistories.get_latest_history_entry(prompt.id, db=db) + parent_id = latest_history.id if latest_history else None + + # Check if content changed to decide on history creation + content_changed = ( + prompt.name != form_data.name or + prompt.content != form_data.content or + prompt.access_control != form_data.access_control + ) + + # Update prompt fields + prompt.name = form_data.name prompt.content = form_data.content + prompt.data = form_data.data or prompt.data + prompt.meta = form_data.meta or prompt.meta prompt.access_control = form_data.access_control - prompt.timestamp = int(time.time()) + if form_data.version_id is not None: + prompt.version_id = form_data.version_id + prompt.updated_at = int(time.time()) + db.commit() + + # Create history entry only if content changed + if content_changed: + snapshot = { + "name": form_data.name, + "content": form_data.content, + "command": command, + "data": form_data.data or {}, + "meta": form_data.meta or {}, + "access_control": form_data.access_control, + } + + PromptHistories.create_history_entry( + prompt_id=prompt.id, + snapshot=snapshot, + user_id=user_id, + parent_id=parent_id, + commit_message=form_data.commit_message, + db=db, + ) + + return PromptModel.model_validate(prompt) + except Exception: + return None + + + + def update_prompt_version( + self, + command: str, + version_id: str, + db: Optional[Session] = None, + ) -> Optional[PromptModel]: + """Set the active version of a prompt and restore content from that version's snapshot.""" + try: + with get_db_context(db) as db: + prompt = db.query(Prompt).filter_by(command=command).first() + if not prompt: + return None + + # Get the history entry to restore content from + from open_webui.models.prompt_history import PromptHistories + history_entry = PromptHistories.get_history_entry_by_id(version_id, db=db) + + if not history_entry: + return None + + # Restore prompt content from the snapshot + snapshot = history_entry.snapshot + if snapshot: + prompt.name = snapshot.get("name", prompt.name) + prompt.content = snapshot.get("content", prompt.content) + prompt.data = snapshot.get("data", prompt.data) + prompt.meta = snapshot.get("meta", prompt.meta) + # Note: command and access_control are not restored from snapshot + + prompt.version_id = version_id + prompt.updated_at = int(time.time()) + db.commit() + return PromptModel.model_validate(prompt) except Exception: return None @@ -165,12 +324,40 @@ class PromptsTable: def delete_prompt_by_command( self, command: str, db: Optional[Session] = None ) -> bool: + """Soft delete a prompt by setting is_active to False.""" try: with get_db_context(db) as db: - db.query(Prompt).filter_by(command=command).delete() - db.commit() + prompt = db.query(Prompt).filter_by(command=command).first() + if prompt: + # Delete history first (Requirement: entire history should be deleted) + from open_webui.models.prompt_history import PromptHistories + PromptHistories.delete_history_by_prompt_id(prompt.id, db=db) - return True + prompt.is_active = False + prompt.updated_at = int(time.time()) + db.commit() + return True + return False + except Exception: + return False + + def hard_delete_prompt_by_command( + self, command: str, db: Optional[Session] = None + ) -> bool: + """Permanently delete a prompt and its history.""" + try: + with get_db_context(db) as db: + prompt = db.query(Prompt).filter_by(command=command).first() + if prompt: + # Delete history first + from open_webui.models.prompt_history import PromptHistories + PromptHistories.delete_history_by_prompt_id(prompt.id, db=db) + + # Delete prompt + db.query(Prompt).filter_by(command=command).delete() + db.commit() + return True + return False except Exception: return False From 34773e795be380df8293860aa5a98f917013c1ce Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 24 Jan 2026 02:39:48 +0400 Subject: [PATCH 03/18] wip: prompt history backend integration --- backend/open_webui/routers/prompts.py | 262 +++++++++++++++++++++++--- 1 file changed, 238 insertions(+), 24 deletions(-) diff --git a/backend/open_webui/routers/prompts.py b/backend/open_webui/routers/prompts.py index 19d25685a..b0c3966be 100644 --- a/backend/open_webui/routers/prompts.py +++ b/backend/open_webui/routers/prompts.py @@ -8,15 +8,26 @@ from open_webui.models.prompts import ( PromptModel, Prompts, ) +from open_webui.models.prompt_history import ( + PromptHistories, + PromptHistoryModel, + PromptHistoryResponse, +) from open_webui.constants import ERROR_MESSAGES from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.access_control import has_access, has_permission from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL from open_webui.internal.db import get_session from sqlalchemy.orm import Session +from pydantic import BaseModel + + +class PromptVersionUpdateForm(BaseModel): + version_id: str router = APIRouter() + ############################ # GetPrompts ############################ @@ -112,7 +123,7 @@ async def create_new_prompt( async def get_prompt_by_command( command: str, user=Depends(get_verified_user), db: Session = Depends(get_session) ): - prompt = Prompts.get_prompt_by_command(f"/{command}", db=db) + prompt = Prompts.get_prompt_by_command(command, db=db) if prompt: if ( @@ -128,11 +139,11 @@ async def get_prompt_by_command( or has_access(user.id, "write", prompt.access_control, db=db) ), ) - else: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=ERROR_MESSAGES.NOT_FOUND, - ) + + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) ############################ @@ -147,10 +158,11 @@ async def update_prompt_by_command( user=Depends(get_verified_user), db: Session = Depends(get_session), ): - prompt = Prompts.get_prompt_by_command(f"/{command}", db=db) + prompt = Prompts.get_prompt_by_command(command, db=db) + if not prompt: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND, ) @@ -165,29 +177,30 @@ async def update_prompt_by_command( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - prompt = Prompts.update_prompt_by_command(f"/{command}", form_data, db=db) - if prompt: - return prompt + # Use the command from the found prompt + updated_prompt = Prompts.update_prompt_by_command( + prompt.command, form_data, user.id, db=db + ) + if updated_prompt: + return updated_prompt else: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(), ) -############################ -# DeletePromptByCommand -############################ - - -@router.delete("/command/{command}/delete", response_model=bool) -async def delete_prompt_by_command( - command: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +@router.post("/command/{command}/set/version", response_model=Optional[PromptModel]) +async def set_prompt_version( + command: str, + form_data: PromptVersionUpdateForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - prompt = Prompts.get_prompt_by_command(f"/{command}", db=db) + prompt = Prompts.get_prompt_by_command(command, db=db) if not prompt: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND, ) @@ -201,5 +214,206 @@ async def delete_prompt_by_command( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - result = Prompts.delete_prompt_by_command(f"/{command}", db=db) + updated_prompt = Prompts.update_prompt_version( + prompt.command, form_data.version_id, db=db + ) + if updated_prompt: + return updated_prompt + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(), + ) + + +############################ +# DeletePromptByCommand +############################ + + +@router.delete("/command/{command}/delete", response_model=bool) +async def delete_prompt_by_command( + command: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + prompt = Prompts.get_prompt_by_command(command, db=db) + + if not prompt: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if ( + prompt.user_id != user.id + and not has_access(user.id, "write", prompt.access_control, db=db) + and user.role != "admin" + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + result = Prompts.delete_prompt_by_command(prompt.command, db=db) return result + + +############################ +# Prompt History Endpoints +############################ + + +@router.get("/command/{command}/history", response_model=list[PromptHistoryResponse]) +async def get_prompt_history( + command: str, + limit: int = 50, + offset: int = 0, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + """Get version history for a prompt.""" + prompt = Prompts.get_prompt_by_command(command, db=db) + + if not prompt: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + # Check read access + if not ( + user.role == "admin" + or prompt.user_id == user.id + or has_access(user.id, "read", prompt.access_control, db=db) + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + history = PromptHistories.get_history_by_prompt_id( + prompt.id, limit=limit, offset=offset, db=db + ) + return history + + +@router.get( + "/command/{command}/history/{history_id}", response_model=PromptHistoryModel +) +async def get_prompt_history_entry( + command: str, + history_id: str, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + """Get a specific version from history.""" + prompt = Prompts.get_prompt_by_command(command, db=db) + + if not prompt: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + # Check read access + if not ( + user.role == "admin" + or prompt.user_id == user.id + or has_access(user.id, "read", prompt.access_control, db=db) + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + history_entry = PromptHistories.get_history_entry_by_id(history_id, db=db) + if not history_entry or history_entry.prompt_id != prompt.id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + return history_entry + + +@router.delete( + "/command/{command}/history/{history_id}", response_model=bool +) +async def delete_prompt_history_entry( + command: str, + history_id: str, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + """Delete a history entry. Cannot delete the active production version.""" + prompt = Prompts.get_prompt_by_command(command, db=db) + + if not prompt: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + # Check write access + if not ( + user.role == "admin" + or prompt.user_id == user.id + or has_access(user.id, "write", prompt.access_control, db=db) + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + # Cannot delete active production version + if prompt.version_id == history_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot delete the active production version", + ) + + success = PromptHistories.delete_history_entry(history_id, db=db) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + return success + + +@router.get("/command/{command}/history/diff") +async def get_prompt_diff( + command: str, + from_id: str, + to_id: str, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + """Get diff between two versions.""" + prompt = Prompts.get_prompt_by_command(command, db=db) + + if not prompt: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + # Check read access + if not ( + user.role == "admin" + or prompt.user_id == user.id + or has_access(user.id, "read", prompt.access_control, db=db) + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + diff = PromptHistories.compute_diff(from_id, to_id, db=db) + if not diff: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="One or both history entries not found", + ) + + return diff From 0c79a566ac13ad47bd629a2c561d92c0cd121820 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 24 Jan 2026 02:40:17 +0400 Subject: [PATCH 04/18] feat: prompt history frontend --- src/lib/apis/prompts/index.ts | 280 +++++++++- .../chat/MessageInput/Commands/Prompts.svelte | 6 +- src/lib/components/common/Badge.svelte | 4 +- src/lib/components/workspace/Prompts.svelte | 2 +- .../workspace/Prompts/PromptEditor.svelte | 501 ++++++++++++++---- .../Prompts/PromptHistoryMenu.svelte | 83 +++ .../(app)/workspace/prompts/edit/+page.svelte | 16 +- 7 files changed, 768 insertions(+), 124 deletions(-) create mode 100644 src/lib/components/workspace/Prompts/PromptHistoryMenu.svelte diff --git a/src/lib/apis/prompts/index.ts b/src/lib/apis/prompts/index.ts index 4129ea62a..7cc8c1f92 100644 --- a/src/lib/apis/prompts/index.ts +++ b/src/lib/apis/prompts/index.ts @@ -2,9 +2,45 @@ import { WEBUI_API_BASE_URL } from '$lib/constants'; type PromptItem = { command: string; - title: string; + name: string; // Changed from title content: string; + data?: object | null; + meta?: object | null; access_control?: null | object; + version_id?: string | null; // Active version + commit_message?: string | null; // For history tracking +}; + +type PromptHistoryItem = { + id: string; + prompt_id: string; + parent_id: string | null; + snapshot: { + name: string; + content: string; + command: string; + data: object; + meta: object; + access_control: object | null; + }; + user_id: string; + commit_message: string | null; + created_at: number; + user?: { + id: string; + name: string; + email: string; + }; +}; + +type PromptDiff = { + from_id: string; + to_id: string; + from_snapshot: object; + to_snapshot: object; + content_diff: string[]; + name_changed: boolean; + access_control_changed: boolean; }; export const createNewPrompt = async (token: string, prompt: PromptItem) => { @@ -19,7 +55,7 @@ export const createNewPrompt = async (token: string, prompt: PromptItem) => { }, body: JSON.stringify({ ...prompt, - command: `/${prompt.command}` + command: prompt.command.startsWith('/') ? prompt.command.slice(1) : prompt.command }) }) .then(async (res) => { @@ -104,6 +140,8 @@ export const getPromptList = async (token: string = '') => { export const getPromptByCommand = async (token: string, command: string) => { let error = null; + command = command.charAt(0) === '/' ? command.slice(1) : command; + const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/command/${command}`, { method: 'GET', headers: { @@ -136,7 +174,9 @@ export const getPromptByCommand = async (token: string, command: string) => { export const updatePromptByCommand = async (token: string, prompt: PromptItem) => { let error = null; - const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/command/${prompt.command}/update`, { + const command = prompt.command.charAt(0) === '/' ? prompt.command.slice(1) : prompt.command; + + const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/command/${command}/update`, { method: 'POST', headers: { Accept: 'application/json', @@ -145,7 +185,7 @@ export const updatePromptByCommand = async (token: string, prompt: PromptItem) = }, body: JSON.stringify({ ...prompt, - command: `/${prompt.command}` + command: command }) }) .then(async (res) => { @@ -169,6 +209,41 @@ export const updatePromptByCommand = async (token: string, prompt: PromptItem) = return res; }; +export const setProductionPromptVersion = async ( + token: string, + command: string, + version_id: string +) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/command/${command}/set/version`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + version_id: version_id + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const deletePromptByCommand = async (token: string, command: string) => { let error = null; @@ -202,3 +277,200 @@ export const deletePromptByCommand = async (token: string, command: string) => { return res; }; + +//////////////////////////// +// Prompt History APIs +//////////////////////////// + +export const getPromptHistory = async ( + token: string, + command: string, + limit: number = 50, + offset: number = 0 +): Promise => { + let error = null; + + command = command.charAt(0) === '/' ? command.slice(1) : command; + + const res = await fetch( + `${WEBUI_API_BASE_URL}/prompts/command/${command}/history?limit=${limit}&offset=${offset}`, + { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + } + ) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const deletePromptHistoryVersion = async ( + token: string, + command: string, + historyId: string +): Promise => { + let error = null; + + command = command.charAt(0) === '/' ? command.slice(1) : command; + + const res = await fetch( + `${WEBUI_API_BASE_URL}/prompts/command/${command}/history/${historyId}`, + { + method: 'DELETE', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + } + ) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return false; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getPromptHistoryEntry = async ( + token: string, + command: string, + historyId: string +): Promise => { + let error = null; + + command = command.charAt(0) === '/' ? command.slice(1) : command; + + const res = await fetch( + `${WEBUI_API_BASE_URL}/prompts/command/${command}/history/${historyId}`, + { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + } + ) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const restorePromptFromHistory = async ( + token: string, + command: string, + historyId: string, + commitMessage?: string +) => { + let error = null; + + command = command.charAt(0) === '/' ? command.slice(1) : command; + + const res = await fetch( + `${WEBUI_API_BASE_URL}/prompts/command/${command}/history/${historyId}/restore`, + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + commit_message: commitMessage + }) + } + ) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getPromptDiff = async ( + token: string, + command: string, + fromId: string, + toId: string +): Promise => { + let error = null; + + command = command.charAt(0) === '/' ? command.slice(1) : command; + + const res = await fetch( + `${WEBUI_API_BASE_URL}/prompts/command/${command}/history/diff?from_id=${fromId}&to_id=${toId}`, + { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + } + ) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + + diff --git a/src/lib/components/chat/MessageInput/Commands/Prompts.svelte b/src/lib/components/chat/MessageInput/Commands/Prompts.svelte index 5df3c4691..0da053993 100644 --- a/src/lib/components/chat/MessageInput/Commands/Prompts.svelte +++ b/src/lib/components/chat/MessageInput/Commands/Prompts.svelte @@ -14,7 +14,7 @@ $: filteredItems = prompts .filter((p) => p.command.toLowerCase().includes(query.toLowerCase())) - .sort((a, b) => a.title.localeCompare(b.title)); + .sort((a, b) => a.name.localeCompare(b.name)); $: if (query) { selectedPromptIdx = 0; @@ -42,7 +42,7 @@ {#if filteredItems.length > 0}
{#each filteredItems as promptItem, promptIdx} - + diff --git a/src/lib/components/common/Badge.svelte b/src/lib/components/common/Badge.svelte index c1513f8b6..8327a420c 100644 --- a/src/lib/components/common/Badge.svelte +++ b/src/lib/components/common/Badge.svelte @@ -12,8 +12,8 @@
{content}
diff --git a/src/lib/components/workspace/Prompts.svelte b/src/lib/components/workspace/Prompts.svelte index 4a4d1b55a..4dd969405 100644 --- a/src/lib/components/workspace/Prompts.svelte +++ b/src/lib/components/workspace/Prompts.svelte @@ -343,7 +343,7 @@
-
{prompt.title}
+
{prompt.name}
{prompt.command}
diff --git a/src/lib/components/workspace/Prompts/PromptEditor.svelte b/src/lib/components/workspace/Prompts/PromptEditor.svelte index 9dc3a1a56..ddb56dada 100644 --- a/src/lib/components/workspace/Prompts/PromptEditor.svelte +++ b/src/lib/components/workspace/Prompts/PromptEditor.svelte @@ -4,12 +4,25 @@ import Textarea from '$lib/components/common/Textarea.svelte'; import { toast } from 'svelte-sonner'; import Tooltip from '$lib/components/common/Tooltip.svelte'; - import AccessControl from '../common/AccessControl.svelte'; import LockClosed from '$lib/components/icons/LockClosed.svelte'; import AccessControlModal from '../common/AccessControlModal.svelte'; import { user } from '$lib/stores'; - import { slugify } from '$lib/utils'; + import { slugify, formatDate } from '$lib/utils'; import Spinner from '$lib/components/common/Spinner.svelte'; + import Modal from '$lib/components/common/Modal.svelte'; + import XMark from '$lib/components/icons/XMark.svelte'; + import { + getPromptHistory, + updatePromptByCommand, + setProductionPromptVersion, + deletePromptHistoryVersion + } from '$lib/apis/prompts'; + import dayjs from 'dayjs'; + import localizedFormat from 'dayjs/plugin/localizedFormat'; + import PromptHistoryMenu from './PromptHistoryMenu.svelte'; + import Badge from '$lib/components/common/Badge.svelte'; + + dayjs.extend(localizedFormat); export let onSubmit: Function; export let edit = false; @@ -20,22 +33,25 @@ const i18n = getContext('i18n'); let loading = false; + let showEditModal = false; - let title = ''; + let name = ''; let command = ''; let content = ''; + let commitMessage = ''; let accessControl = {}; - let showAccessControlModal = false; - let hasManualEdit = false; + let history: any[] = []; + let historyLoading = false; + let selectedHistoryEntry: any = null; + $: if (!edit && !hasManualEdit) { - command = title !== '' ? slugify(title) : ''; + command = name !== '' ? slugify(name) : ''; } - // Track manual edits function handleCommandInput(e: Event) { hasManualEdit = true; } @@ -49,11 +65,15 @@ if (validateCommandString(command)) { await onSubmit({ - title, + name, command, content, - access_control: accessControl + access_control: accessControl, + commit_message: commitMessage || undefined }); + showEditModal = false; + commitMessage = ''; + await loadHistory(); } else { toast.error( $i18n.t('Only alphanumeric characters and hyphens are allowed in the command string.') @@ -64,22 +84,79 @@ }; const validateCommandString = (inputString) => { - // Regular expression to match only alphanumeric characters, hyphen, and underscore const regex = /^[a-zA-Z0-9-_]+$/; - - // Test the input string against the regular expression return regex.test(inputString); }; + const loadHistory = async () => { + if (!prompt?.command || !edit) return; + historyLoading = true; + try { + history = await getPromptHistory(localStorage.token, prompt.command); + } catch (error) { + console.error('Failed to load history:', error); + history = []; + } + historyLoading = false; + }; + + const setAsProduction = async (historyEntry: any) => { + if (disabled) { + toast.error($i18n.t('You do not have permission to edit this prompt.')); + return; + } + + try { + await setProductionPromptVersion(localStorage.token, prompt.command, historyEntry.id); + // Update local prompt object to trigger reactivity + prompt = { ...prompt, version_id: historyEntry.id }; + toast.success($i18n.t('Production version updated')); + } catch (error) { + toast.error(`${error}`); + } + }; + + const handleDeleteHistory = async (historyId: string) => { + if (disabled) return; + + try { + await deletePromptHistoryVersion(localStorage.token, prompt.command, historyId); + toast.success($i18n.t('Version deleted')); + // Reload history + await loadHistory(); + // Reset selection if deleted entry was selected + if (selectedHistoryEntry?.id === historyId) { + selectedHistoryEntry = history.length > 0 ? history[0] : null; + } + } catch (error) { + toast.error(`${error}`); + } + }; + + const renderDate = (timestamp: number) => { + const dateVal = timestamp * 1000; + return $i18n.t(formatDate(dateVal), { + LOCALIZED_TIME: dayjs(dateVal).format('LT'), + LOCALIZED_DATE: dayjs(dateVal).format('L') + }); + }; onMount(async () => { if (prompt) { - title = prompt.title; + name = prompt.name || ''; await tick(); - command = prompt.command.at(0) === '/' ? prompt.command.slice(1) : prompt.command; content = prompt.content; - accessControl = prompt?.access_control === undefined ? {} : prompt?.access_control; + + if (edit) { + await loadHistory(); + // Auto-select production version + if (prompt.version_id && history.length > 0) { + selectedHistoryEntry = history.find((h) => h.id === prompt.version_id) || history[0]; + } else if (history.length > 0) { + selectedHistoryEntry = history[0]; + } + } } }); @@ -92,124 +169,328 @@ sharePublic={$user?.permissions?.sharing?.public_prompts || $user?.role === 'admin'} /> -
-
{ - submitHandler(); - }} - > -
- + +
+
+
{$i18n.t('Edit Prompt')}
+ -
- {/if} -
- -
-
/
- -
-
- + +
-
-
-
{$i18n.t('Prompt Content')}
+ +
+ +
+
+ +
+
+
/
+ +
+
+
-
-
+
+
+
{$i18n.t('Prompt Content')}
+
+ +