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..c61196fcb --- /dev/null +++ b/backend/open_webui/migrations/versions/374d2f66af06_add_prompt_history_table.py @@ -0,0 +1,248 @@ +"""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("tags", sa.JSON(), 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("tags", sa.JSON()), + 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, + tags=[], + 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, + ) + ) 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..381d4109f 100644 --- a/backend/open_webui/models/prompts.py +++ b/backend/open_webui/models/prompts.py @@ -1,16 +1,20 @@ import time +import uuid from typing import Optional from sqlalchemy.orm import Session from open_webui.internal.db import Base, JSONField, get_db, get_db_context from open_webui.models.groups import Groups from open_webui.models.users import Users, UserResponse +from open_webui.models.prompt_history import PromptHistories + 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 + #################### # Prompts DB Schema #################### @@ -19,11 +23,18 @@ 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) + tags = 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 +55,20 @@ 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 + tags: Optional[list[str]] = 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 +87,37 @@ 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 + tags: Optional[list[str]] = None access_control: Optional[dict] = None + version_id: Optional[str] = None # Active version + commit_message: Optional[str] = None # For history tracking + is_production: Optional[bool] = True # Whether to set new version as production 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 {}, + tags=form_data.tags or [], + access_control=form_data.access_control, + is_active=True, + created_at=now, + updated_at=now, ) try: @@ -92,26 +126,72 @@ class PromptsTable: db.add(result) db.commit() db.refresh(result) + if result: + snapshot = { + "name": form_data.name, + "content": form_data.content, + "command": form_data.command, + "data": form_data.data or {}, + "meta": form_data.meta or {}, + "tags": form_data.tags 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 +228,203 @@ 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 + + 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()) + 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, + } + + history_entry = 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, + ) + + # Set as production if flag is True (default) + if form_data.is_production and history_entry: + prompt.version_id = history_entry.id + db.commit() + + return PromptModel.model_validate(prompt) + except Exception: + return None + + def update_prompt_by_id( + self, + prompt_id: 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(id=prompt_id).first() + if not prompt: + return None + + 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.command != form_data.command + or prompt.content != form_data.content + or prompt.access_control != form_data.access_control + or (form_data.tags is not None and prompt.tags != form_data.tags) + ) + + # Update prompt fields + prompt.name = form_data.name + prompt.command = form_data.command + 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 + + if form_data.tags is not None: + prompt.tags = form_data.tags + + 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": prompt.command, + "data": form_data.data or {}, + "meta": form_data.meta or {}, + "tags": prompt.tags 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=parent_id, + commit_message=form_data.commit_message, + db=db, + ) + + # Set as production if flag is True (default) + if form_data.is_production and history_entry: + prompt.version_id = history_entry.id + db.commit() + + return PromptModel.model_validate(prompt) + except Exception: + return None + + def update_prompt_metadata( + self, + prompt_id: str, + name: str, + command: str, + tags: Optional[list[str]] = None, + db: Optional[Session] = None, + ) -> Optional[PromptModel]: + """Update only name and command (no history created).""" + try: + with get_db_context(db) as db: + prompt = db.query(Prompt).filter_by(id=prompt_id).first() + if not prompt: + return None + + prompt.name = name + prompt.command = command + + if tags is not None: + prompt.tags = tags + + prompt.updated_at = int(time.time()) + db.commit() + + return PromptModel.model_validate(prompt) + except Exception: + return None + + def update_prompt_version( + self, + prompt_id: 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(id=prompt_id).first() + if not prompt: + return None + + 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) + prompt.tags = snapshot.get("tags", prompt.tags) + # 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,14 +432,68 @@ 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: + 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 delete_prompt_by_id(self, prompt_id: str, db: Optional[Session] = None) -> bool: + """Soft delete a prompt by setting is_active to False.""" + try: + with get_db_context(db) as db: + prompt = db.query(Prompt).filter_by(id=prompt_id).first() + if prompt: + PromptHistories.delete_history_by_prompt_id(prompt.id, db=db) + + 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: + 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 + + def get_tags(self, db: Optional[Session] = None) -> list[str]: + try: + with get_db_context(db) as db: + prompts = db.query(Prompt).filter_by(is_active=True).all() + tags = set() + for prompt in prompts: + if prompt.tags: + for tag in prompt.tags: + if tag: + tags.add(tag) + return sorted(list(tags)) + except Exception: + return [] + Prompts = PromptsTable() diff --git a/backend/open_webui/routers/prompts.py b/backend/open_webui/routers/prompts.py index 19d25685a..351500917 100644 --- a/backend/open_webui/routers/prompts.py +++ b/backend/open_webui/routers/prompts.py @@ -8,15 +8,33 @@ 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 + + +class PromptMetadataForm(BaseModel): + name: str + command: str + tags: Optional[list[str]] = None + router = APIRouter() + ############################ # GetPrompts ############################ @@ -34,6 +52,21 @@ async def get_prompts( return prompts +@router.get("/tags", response_model=list[str]) +async def get_prompt_tags( + user=Depends(get_verified_user), db: Session = Depends(get_session) +): + if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: + return Prompts.get_tags(db=db) + else: + prompts = Prompts.get_prompts_by_user_id(user.id, "read", db=db) + tags = set() + for prompt in prompts: + if prompt.tags: + tags.update(prompt.tags) + return sorted(list(tags)) + + @router.get("/list", response_model=list[PromptAccessResponse]) async def get_prompt_list( user=Depends(get_verified_user), db: Session = Depends(get_session) @@ -112,7 +145,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,29 +161,62 @@ 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, + ) ############################ -# UpdatePromptByCommand +# GetPromptById ############################ -@router.post("/command/{command}/update", response_model=Optional[PromptModel]) -async def update_prompt_by_command( - command: str, +@router.get("/id/{prompt_id}", response_model=Optional[PromptAccessResponse]) +async def get_prompt_by_id( + prompt_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + prompt = Prompts.get_prompt_by_id(prompt_id, db=db) + + if prompt: + if ( + user.role == "admin" + or prompt.user_id == user.id + or has_access(user.id, "read", prompt.access_control, db=db) + ): + return PromptAccessResponse( + **prompt.model_dump(), + write_access=( + (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) + or user.id == prompt.user_id + or has_access(user.id, "write", prompt.access_control, db=db) + ), + ) + + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + +############################ +# UpdatePromptById +############################ + + +@router.post("/id/{prompt_id}/update", response_model=Optional[PromptModel]) +async def update_prompt_by_id( + prompt_id: str, form_data: PromptForm, 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_id(prompt_id, 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 +231,46 @@ 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 + # Check for command collision if command is being changed + if form_data.command != prompt.command: + existing_prompt = Prompts.get_prompt_by_command(form_data.command, db=db) + if existing_prompt and existing_prompt.id != prompt.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Command '/{form_data.command}' is already in use by another prompt", + ) + + # Use the ID from the found prompt + updated_prompt = Prompts.update_prompt_by_id( + prompt.id, 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 +# UpdatePromptMetadata ############################ -@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("/id/{prompt_id}/update/meta", response_model=Optional[PromptModel]) +async def update_prompt_metadata( + prompt_id: str, + form_data: PromptMetadataForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - prompt = Prompts.get_prompt_by_command(f"/{command}", db=db) + """Update prompt name and command only (no history created).""" + prompt = Prompts.get_prompt_by_id(prompt_id, 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 +284,252 @@ async def delete_prompt_by_command( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - result = Prompts.delete_prompt_by_command(f"/{command}", db=db) + # Check for command collision if command is being changed + if form_data.command != prompt.command: + existing_prompt = Prompts.get_prompt_by_command(form_data.command, db=db) + if existing_prompt and existing_prompt.id != prompt.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Command '/{form_data.command}' is already in use", + ) + + updated_prompt = Prompts.update_prompt_metadata( + prompt.id, form_data.name, form_data.command, form_data.tags, db=db + ) + if updated_prompt: + return updated_prompt + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(), + ) + + +@router.post("/id/{prompt_id}/update/version", response_model=Optional[PromptModel]) +async def set_prompt_version( + prompt_id: str, + form_data: PromptVersionUpdateForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + prompt = Prompts.get_prompt_by_id(prompt_id, 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, + ) + + updated_prompt = Prompts.update_prompt_version( + prompt.id, 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(), + ) + + +############################ +# DeletePromptById +############################ + + +@router.delete("/id/{prompt_id}/delete", response_model=bool) +async def delete_prompt_by_id( + prompt_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + prompt = Prompts.get_prompt_by_id(prompt_id, 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_id(prompt.id, db=db) return result + + +############################ +# Prompt History Endpoints +############################ + + +@router.get("/id/{prompt_id}/history", response_model=list[PromptHistoryResponse]) +async def get_prompt_history( + prompt_id: str, + page: int = 0, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + """Get version history for a prompt.""" + PAGE_SIZE = 20 + + prompt = Prompts.get_prompt_by_id(prompt_id, 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=PAGE_SIZE, offset=page * PAGE_SIZE, db=db + ) + return history + + +@router.get( + "/id/{prompt_id}/history/{history_id}", response_model=PromptHistoryModel +) +async def get_prompt_history_entry( + prompt_id: 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_id(prompt_id, 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( + "/id/{prompt_id}/history/{history_id}", response_model=bool +) +async def delete_prompt_history_entry( + prompt_id: 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_id(prompt_id, 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("/id/{prompt_id}/history/diff") +async def get_prompt_diff( + prompt_id: 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_id(prompt_id, 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 diff --git a/src/lib/apis/prompts/index.ts b/src/lib/apis/prompts/index.ts index 4129ea62a..58ab889f8 100644 --- a/src/lib/apis/prompts/index.ts +++ b/src/lib/apis/prompts/index.ts @@ -1,10 +1,48 @@ import { WEBUI_API_BASE_URL } from '$lib/constants'; type PromptItem = { + id?: string; // Prompt ID 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 + is_production?: boolean; // Whether to set new version as production +}; + +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 +57,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) => { @@ -70,6 +108,34 @@ export const getPrompts = async (token: string = '') => { return res; }; +export const getPromptTags = async (token: string = '') => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/tags`, { + 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 getPromptList = async (token: string = '') => { let error = null; @@ -104,6 +170,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: { @@ -133,20 +201,16 @@ export const getPromptByCommand = async (token: string, command: string) => { return res; }; -export const updatePromptByCommand = async (token: string, prompt: PromptItem) => { +export const getPromptById = async (token: string, promptId: string) => { let error = null; - const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/command/${prompt.command}/update`, { - method: 'POST', + const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/id/${promptId}`, { + method: 'GET', headers: { Accept: 'application/json', 'Content-Type': 'application/json', authorization: `Bearer ${token}` - }, - body: JSON.stringify({ - ...prompt, - command: `/${prompt.command}` - }) + } }) .then(async (res) => { if (!res.ok) throw await res.json(); @@ -169,12 +233,113 @@ export const updatePromptByCommand = async (token: string, prompt: PromptItem) = return res; }; -export const deletePromptByCommand = async (token: string, command: string) => { +export const updatePromptById = async (token: string, prompt: PromptItem) => { let error = null; - command = command.charAt(0) === '/' ? command.slice(1) : command; + const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/id/${prompt.id}/update`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify(prompt) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; - const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/command/${command}/delete`, { + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const updatePromptMetadata = async ( + token: string, + promptId: string, + name: string, + command: string, + tags: string[] = [] +) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/id/${promptId}/update/meta`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ name, command, tags }) + }) + .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 setProductionPromptVersion = async ( + token: string, + promptId: string, + version_id: string +) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/id/${promptId}/update/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 deletePromptById = async (token: string, promptId: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/id/${promptId}/delete`, { method: 'DELETE', headers: { Accept: 'application/json', @@ -202,3 +367,188 @@ export const deletePromptByCommand = async (token: string, command: string) => { return res; }; + +//////////////////////////// +// Prompt History APIs +//////////////////////////// + +export const getPromptHistory = async ( + token: string, + promptId: string, + page: number = 0 +): Promise => { + let error = null; + + const res = await fetch( + `${WEBUI_API_BASE_URL}/prompts/id/${promptId}/history?page=${page}`, + { + 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, + promptId: string, + historyId: string +): Promise => { + let error = null; + + const res = await fetch( + `${WEBUI_API_BASE_URL}/prompts/id/${promptId}/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, + promptId: string, + historyId: string +): Promise => { + let error = null; + + const res = await fetch( + `${WEBUI_API_BASE_URL}/prompts/id/${promptId}/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, + promptId: string, + historyId: string, + commitMessage?: string +) => { + let error = null; + + const res = await fetch( + `${WEBUI_API_BASE_URL}/prompts/id/${promptId}/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, + promptId: string, + fromId: string, + toId: string +): Promise => { + let error = null; + + const res = await fetch( + `${WEBUI_API_BASE_URL}/prompts/id/${promptId}/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..3d9a9acdb 100644 --- a/src/lib/components/workspace/Prompts.svelte +++ b/src/lib/components/workspace/Prompts.svelte @@ -9,9 +9,10 @@ import { createNewPrompt, - deletePromptByCommand, + deletePromptById, getPrompts, - getPromptList + getPromptList, + getPromptTags } from '$lib/apis/prompts'; import { capitalizeFirstLetter, slugify, copyToClipboard } from '$lib/utils'; @@ -28,8 +29,8 @@ import XMark from '../icons/XMark.svelte'; import GarbageBin from '../icons/GarbageBin.svelte'; import ViewSelector from './common/ViewSelector.svelte'; + import TagSelector from './common/TagSelector.svelte'; import Badge from '$lib/components/common/Badge.svelte'; - let shiftKey = false; const i18n = getContext('i18n'); @@ -40,23 +41,25 @@ let query = ''; let prompts = []; + let tags = []; let showDeleteConfirm = false; let deletePrompt = null; let tagsContainerElement: HTMLDivElement; let viewOption = ''; + let selectedTag = ''; let copiedId: string | null = null; let filteredItems = []; - $: if (prompts && query !== undefined && viewOption !== undefined) { + $: if (prompts && query !== undefined && viewOption !== undefined && selectedTag !== undefined) { setFilteredItems(); } const setFilteredItems = () => { filteredItems = prompts.filter((p) => { - if (query === '' && viewOption === '') return true; + if (query === '' && viewOption === '' && selectedTag === '') return true; const lowerQuery = query.toLowerCase(); return ( ((p.title || '').toLowerCase().includes(lowerQuery) || @@ -65,7 +68,8 @@ (p.user?.email || '').toLowerCase().includes(lowerQuery)) && (viewOption === '' || (viewOption === 'created' && p.user_id === $user?.id) || - (viewOption === 'shared' && p.user_id !== $user?.id)) + (viewOption === 'shared' && p.user_id !== $user?.id)) && + (selectedTag === '' || (p.tags && p.tags.includes(selectedTag))) ); }); }; @@ -121,7 +125,7 @@ const deleteHandler = async (prompt) => { const command = prompt.command; - const res = await deletePromptByCommand(localStorage.token, command).catch((err) => { + const res = await deletePromptById(localStorage.token, prompt.id).catch((err) => { toast.error(err); return null; }); @@ -135,6 +139,7 @@ const init = async () => { prompts = await getPromptList(localStorage.token); + tags = await getPromptTags(localStorage.token); await _prompts.set(await getPrompts(localStorage.token)); }; @@ -329,6 +334,13 @@ await tick(); }} /> + + {#if (tags ?? []).length > 0} + ({ value: tag, label: tag }))} + /> + {/if}
@@ -338,14 +350,14 @@ {#each filteredItems as prompt}
-
+
-
{prompt.title}
+
{prompt.name}
- {prompt.command} + /{prompt.command}
{#if !prompt.write_access} @@ -353,7 +365,7 @@ {/if}
-
+
+ +
·
+ + {#if prompt.content} + +
+ {prompt.content} +
+
+ {/if}
diff --git a/src/lib/components/workspace/Prompts/PromptEditor.svelte b/src/lib/components/workspace/Prompts/PromptEditor.svelte index 9dc3a1a56..5f9a0da87 100644 --- a/src/lib/components/workspace/Prompts/PromptEditor.svelte +++ b/src/lib/components/workspace/Prompts/PromptEditor.svelte @@ -4,12 +4,29 @@ 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 Clipboard from '$lib/components/icons/Clipboard.svelte'; + import Check from '$lib/components/icons/Check.svelte'; import AccessControlModal from '../common/AccessControlModal.svelte'; import { user } from '$lib/stores'; - import { slugify } from '$lib/utils'; + import { slugify, formatDate, copyToClipboard } 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, + setProductionPromptVersion, + deletePromptHistoryVersion, + updatePromptMetadata, + getPromptTags + } 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'; + import Tags from '$lib/components/common/Tags.svelte'; + + dayjs.extend(localizedFormat); export let onSubmit: Function; export let edit = false; @@ -20,22 +37,38 @@ const i18n = getContext('i18n'); let loading = false; + let showEditModal = false; - let title = ''; + let name = ''; let command = ''; let content = ''; + let tags = []; + let commitMessage = ''; + let isProduction = true; let accessControl = {}; - let showAccessControlModal = false; - let hasManualEdit = false; + let history: any[] = []; + let historyLoading = false; + let selectedHistoryEntry: any = null; + let historyPage = 0; + let historyHasMore = true; + let contentCopied = false; + + // For debounced auto-save of name/command + let originalName = ''; + let originalCommand = ''; + let originalTags = []; + let debounceTimer: ReturnType | null = null; + + let suggestionTags = []; + $: if (!edit && !hasManualEdit) { - command = title !== '' ? slugify(title) : ''; + command = name !== '' ? slugify(name) : ''; } - // Track manual edits function handleCommandInput(e: Event) { hasManualEdit = true; } @@ -49,11 +82,23 @@ if (validateCommandString(command)) { await onSubmit({ - title, + id: prompt?.id, + name, command, content, - access_control: accessControl + tags: tags.map((tag) => tag.name), + access_control: accessControl, + commit_message: commitMessage || undefined, + is_production: isProduction }); + showEditModal = false; + commitMessage = ''; + isProduction = true; + await loadHistory(true); // Reset and reload + // Select the newest version after saving + if (history.length > 0) { + selectedHistoryEntry = history[0]; + } } else { toast.error( $i18n.t('Only alphanumeric characters and hyphens are allowed in the command string.') @@ -64,22 +109,177 @@ }; 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 (reset = false) => { + if (!prompt?.id || !edit) return; + if (historyLoading) return; + if (!reset && !historyHasMore) return; + + historyLoading = true; + + if (reset) { + historyPage = 0; + historyHasMore = true; + } + + try { + const newEntries = await getPromptHistory(localStorage.token, prompt.id, historyPage); + + if (reset) { + history = newEntries; + } else { + history = [...history, ...newEntries]; + } + + historyHasMore = newEntries.length > 0; + historyPage = historyPage + 1; + } catch (error) { + console.error('Failed to load history:', error); + if (reset) { + history = []; + } + } + historyLoading = false; + }; + + const handleHistoryScroll = (e: Event) => { + const target = e.target as HTMLElement; + const nearBottom = target.scrollHeight - target.scrollTop <= target.clientHeight + 50; + if (nearBottom && historyHasMore && !historyLoading) { + loadHistory(false); + } + }; + + const copyContent = async () => { + const textToCopy = selectedHistoryEntry?.snapshot?.content || content; + const success = await copyToClipboard(textToCopy); + if (success) { + contentCopied = true; + setTimeout(() => { + contentCopied = false; + }, 2000); + } + }; + + 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.id, 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.id, historyId); + toast.success($i18n.t('Version deleted')); + // Reload history from scratch + await loadHistory(true); + // 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') + }); + }; + + const debouncedSaveMetadata = () => { + if (disabled || !edit) return; + + if (debounceTimer) { + clearTimeout(debounceTimer); + } + + debounceTimer = setTimeout(async () => { + // Skip if nothing changed + if ( + name === originalName && + command === originalCommand && + JSON.stringify(tags) === JSON.stringify(originalTags) + ) + return; + + if (!validateCommandString(command)) { + toast.error( + $i18n.t('Only alphanumeric characters and hyphens are allowed in the command string.') + ); + command = originalCommand; + return; + } + + try { + await updatePromptMetadata( + localStorage.token, + prompt?.id, + name, + command, + tags.map((tag) => tag.name) + ); + // Update originals on success + originalName = name; + originalCommand = command; + originalTags = tags; + toast.success($i18n.t('Saved')); + } catch (error) { + toast.error(`${error}`); + // Revert on error (collision) + name = originalName; + command = originalCommand; + tags = originalTags; + } + }, 500); + }; + 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; - + tags = (prompt.tags || []).map((tag) => ({ name: tag })); accessControl = prompt?.access_control === undefined ? {} : prompt?.access_control; + + // Store originals for revert on collision + originalName = name; + originalCommand = command; + originalTags = tags; + + 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]; + } + } + } + + const res = await getPromptTags(localStorage.token); + if (res) { + suggestionTags = res.map((tag) => ({ name: tag })); } }); @@ -92,124 +292,381 @@ sharePublic={$user?.permissions?.sharing?.public_prompts || $user?.role === 'admin'} /> -
-
{ - submitHandler(); - }} - > -
- + +
+
+
{$i18n.t('Edit Prompt')}
+ -
- {/if} -
- -
-
/
- -
-
- + +
-
-
-
{$i18n.t('Prompt Content')}
-
+ +
+
+
{$i18n.t('Prompt Content')}
+
-
-
+