Merge pull request #20945 from open-webui/prompt_versioning

enh: prompts
This commit is contained in:
Tim Baek
2026-01-26 16:27:28 +04:00
committed by GitHub
12 changed files with 2247 additions and 197 deletions

View File

@@ -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,
)
)

View File

@@ -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()

View File

@@ -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()

View File

@@ -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

View File

@@ -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<PromptHistoryItem[]> => {
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<boolean> => {
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<PromptHistoryItem> => {
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<PromptDiff> => {
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;
};

View File

@@ -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}
<div class=" space-y-0.5 scrollbar-hidden">
{#each filteredItems as promptItem, promptIdx}
<Tooltip content={promptItem.title} placement="top-start">
<Tooltip content={promptItem.name} placement="top-start">
<button
class=" px-3 py-1 rounded-xl w-full text-left {promptIdx === selectedPromptIdx
? ' bg-gray-50 dark:bg-gray-800 selected-command-option-button'
@@ -62,7 +62,7 @@
</span>
<span class=" text-xs text-gray-600 dark:text-gray-100">
{promptItem.title}
{promptItem.name}
</span>
</button>
</Tooltip>

View File

@@ -12,8 +12,8 @@
</script>
<div
class=" text-xs font-semibold {classNames[type] ??
classNames['info']} w-fit px-2 rounded-sm uppercase line-clamp-1 mr-0.5"
class=" text-xs font-medium {classNames[type] ??
classNames['info']} w-fit px-1.5 py-[1px] rounded-lg uppercase line-clamp-1 mr-0.5"
>
{content}
</div>

View File

@@ -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}
<TagSelector
bind:value={selectedTag}
items={tags.map((tag) => ({ value: tag, label: tag }))}
/>
{/if}
</div>
</div>
@@ -338,14 +350,14 @@
{#each filteredItems as prompt}
<a
class=" flex space-x-4 cursor-pointer text-left w-full px-3 py-2.5 dark:hover:bg-gray-850/50 hover:bg-gray-50 transition rounded-2xl"
href={`/workspace/prompts/edit?command=${encodeURIComponent(prompt.command)}`}
href={`/workspace/prompts/${prompt.id}`}
>
<div class=" flex flex-col flex-1 space-x-4 cursor-pointer w-full pl-1">
<div class="flex items-center justify-between w-full">
<div class="flex items-center justify-between w-full mb-0.5">
<div class="flex items-center gap-2">
<div class="font-medium line-clamp-1 capitalize">{prompt.title}</div>
<div class="font-medium line-clamp-1 capitalize">{prompt.name}</div>
<div class="text-xs overflow-hidden text-ellipsis line-clamp-1 text-gray-500">
{prompt.command}
/{prompt.command}
</div>
</div>
{#if !prompt.write_access}
@@ -353,7 +365,7 @@
{/if}
</div>
<div class=" text-xs">
<div class="flex gap-1 text-xs">
<Tooltip
content={prompt?.user?.email ?? $i18n.t('Deleted User')}
className="flex shrink-0"
@@ -367,6 +379,16 @@
})}
</div>
</Tooltip>
<div>·</div>
{#if prompt.content}
<Tooltip content={prompt.content} placement="top">
<div class="line-clamp-1">
{prompt.content}
</div>
</Tooltip>
{/if}
</div>
</div>
<div class="flex flex-row gap-0.5 self-center">

View File

@@ -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<typeof setTimeout> | 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 }));
}
});
</script>
@@ -92,124 +292,381 @@
sharePublic={$user?.permissions?.sharing?.public_prompts || $user?.role === 'admin'}
/>
<div class="w-full max-h-full flex justify-center">
<form
class="flex flex-col w-full mb-10"
on:submit|preventDefault={() => {
submitHandler();
}}
>
<div class="my-2">
<Tooltip
content={`${$i18n.t('Only alphanumeric characters and hyphens are allowed')} - ${$i18n.t(
'Activate this command by typing "/{{COMMAND}}" to chat input.',
{
COMMAND: command
}
)}`}
placement="bottom-start"
<!-- Edit Modal -->
<Modal size="lg" bind:show={showEditModal}>
<div class="px-5 pt-4 pb-5">
<div class="flex justify-between items-center mb-2">
<div class="text-lg font-medium">{$i18n.t('Edit Prompt')}</div>
<button
class="p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg"
on:click={() => (showEditModal = false)}
>
<div class="flex flex-col w-full">
<div class="flex items-center">
<input
class="text-2xl font-medium w-full bg-transparent outline-hidden"
placeholder={$i18n.t('Title')}
bind:value={title}
required
{disabled}
/>
{#if disabled}
<div class="text-xs shrink-0 text-gray-500">
{$i18n.t('Read Only')}
</div>
{:else}
<div class="self-center shrink-0">
<button
class="bg-gray-50 hover:bg-gray-100 text-black dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-white transition px-2 py-1 rounded-full flex gap-1 items-center"
type="button"
on:click={() => {
showAccessControlModal = true;
}}
>
<LockClosed strokeWidth="2.5" className="size-3.5" />
<div class="text-sm font-medium shrink-0">
{$i18n.t('Access')}
</div>
</button>
</div>
{/if}
</div>
<div class="flex gap-0.5 items-center text-xs text-gray-500">
<div class="">/</div>
<input
class=" w-full bg-transparent outline-hidden"
placeholder={$i18n.t('Command')}
bind:value={command}
on:input={handleCommandInput}
required
disabled={edit || disabled}
/>
</div>
</div>
</Tooltip>
<XMark className="size-5" />
</button>
</div>
<div class="my-2">
<div class="flex w-full justify-between">
<div class=" self-center text-sm font-medium">{$i18n.t('Prompt Content')}</div>
</div>
<form on:submit|preventDefault={submitHandler}>
<div class="my-2">
<div class="flex w-full justify-between">
<div class="text-gray-500 text-xs">{$i18n.t('Prompt Content')}</div>
</div>
<div class="mt-2">
<div>
<div class="mt-1">
<Textarea
className="text-sm w-full bg-transparent outline-hidden overflow-y-hidden resize-none"
placeholder={$i18n.t('Write a summary in 50 words that summarizes {{topic}}.')}
bind:value={content}
rows={6}
required
readonly={disabled}
/>
</div>
</div>
<div class="text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('Format your variables using brackets like this:')}&nbsp;<span
class=" text-gray-600 dark:text-gray-300 font-medium"
>{'{{'}{$i18n.t('variable')}{'}}'}</span
>.
{$i18n.t('Make sure to enclose them with')}
<span class=" text-gray-600 dark:text-gray-300 font-medium">{'{{'}</span>
{$i18n.t('and')}
<span class=" text-gray-600 dark:text-gray-300 font-medium">{'}}'}</span>.
<div class="my-2">
<div class="text-gray-500 text-xs">{$i18n.t('Commit Message')} ({$i18n.t('optional')})</div>
<div class="mt-1">
<input
class="text-sm w-full bg-transparent outline-hidden"
placeholder={$i18n.t('Describe what changed...')}
bind:value={commitMessage}
/>
</div>
</div>
<div class="mt-4 flex items-center justify-between">
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
bind:checked={isProduction}
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"
>{$i18n.t('Set as Production')}</span
>
</label>
<div>
<button
class="text-sm px-4 py-2 transition rounded-full {loading
? 'cursor-not-allowed bg-gray-200 text-gray-500 dark:bg-gray-700 dark:text-gray-400'
: 'bg-black hover:bg-gray-900 text-white dark:bg-white dark:hover:bg-gray-100 dark:text-black'} flex justify-center"
type="submit"
disabled={loading}
>
<div class="font-medium">{$i18n.t('Save')}</div>
{#if loading}
<div class="ml-1.5">
<Spinner />
</div>
{/if}
</button>
</div>
</div>
</form>
</div>
</Modal>
{#if edit}
<!-- Edit mode: Read-only view with history -->
<div class="flex flex-col w-full h-full max-h-[100dvh]">
<!-- Header -->
<div class="flex items-start justify-between gap-4 shrink-0">
<div class="min-w-0 flex-1">
<input
class="text-2xl font-medium w-full bg-transparent outline-hidden"
placeholder={$i18n.t('Prompt Name')}
bind:value={name}
on:input={debouncedSaveMetadata}
{disabled}
/>
<div class="flex items-center gap-0.5 text-sm text-gray-500 w-full flex-1">
<span>/</span>
<input
class="bg-transparent outline-hidden"
placeholder={$i18n.t('command')}
bind:value={command}
on:input={debouncedSaveMetadata}
{disabled}
/>
</div>
</div>
<div>
<div class="flex items-center gap-2 shrink-0 justify-end">
{#if !disabled}
<button
class="px-4 py-1 text-sm font-medium bg-black text-white dark:bg-white dark:text-black rounded-full hover:opacity-90 transition shadow-xs"
on:click={() => (showEditModal = true)}
>
{$i18n.t('Edit')}
</button>
<button
class="bg-gray-50 hover:bg-gray-100 text-black dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-white transition px-2.5 py-1 rounded-full flex gap-1.5 items-center text-sm border border-gray-100 dark:border-gray-800"
on:click={() => (showAccessControlModal = true)}
>
<LockClosed strokeWidth="2.5" className="size-3.5" />
{$i18n.t('Access')}
</button>
{:else}
<span class="text-xs text-gray-500 bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded-full"
>{$i18n.t('Read Only')}</span
>
{/if}
</div>
<div class="text-xs text-gray-400 dark:text-gray-500 underline">
<a href="https://docs.openwebui.com/features/workspace/prompts" target="_blank">
{$i18n.t('To learn more about powerful prompt variables, click here')}
</a>
<div class="mt-1.5">
<Tooltip content={$i18n.t('Click to copy ID')}>
<button
class="text-xs text-gray-500 font-mono bg-gray-50 dark:bg-gray-850 px-2 py-1 rounded-lg cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 transition"
on:click={() => {
copyToClipboard(prompt.id);
toast.success($i18n.t('ID copied to clipboard'));
}}
>
{prompt.id}
</button>
</Tooltip>
</div>
</div>
</div>
<div class="my-4 flex justify-end pb-20">
<Tooltip content={disabled ? $i18n.t('You do not have permission to save this prompt.') : ''}>
<button
class=" text-sm w-full lg:w-fit px-4 py-2 transition rounded-xl {loading || disabled
? ' cursor-not-allowed bg-gray-200 text-gray-500 dark:bg-gray-700 dark:text-gray-400'
: 'bg-black hover:bg-gray-900 text-white dark:bg-white dark:hover:bg-gray-100 dark:text-black'} flex w-full justify-center"
type="submit"
disabled={loading || disabled}
<div class="mb-2 flex justify-between items-center gap-2">
<div class="flex-1 min-w-0">
<Tags
{tags}
{suggestionTags}
on:add={(e) => {
tags = [...tags, { name: e.detail }];
debouncedSaveMetadata();
}}
on:delete={(e) => {
tags = tags.filter((tag) => tag.name !== e.detail);
debouncedSaveMetadata();
}}
/>
</div>
</div>
<div class="flex flex-col md:flex-row gap-4 flex-1 overflow-hidden pb-6">
<!-- Desktop History Sidebar -->
<div class="hidden md:flex md:flex-col w-72 shrink-0 overflow-hidden">
<div class="flex-1 overflow-y-auto">
{@render historySection()}
</div>
</div>
<!-- Prompt Content -->
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
<div class="flex items-center justify-between mb-1 shrink-0">
<div class="flex items-center gap-2">
<div class="text-gray-500 text-xs">
{$i18n.t('Prompt Content')}
</div>
{#if selectedHistoryEntry}
<span
class="text-xs text-gray-500 font-mono bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded"
>
{selectedHistoryEntry.id.slice(0, 7)}
</span>
{/if}
</div>
{#if selectedHistoryEntry && !disabled}
<div class="flex items-center gap-2">
{#if selectedHistoryEntry.id === prompt?.version_id}
<Badge type="success" content={$i18n.t('Live')} />
{:else}
<button
class="text-xs text-gray-500 hover:text-gray-900 dark:hover:text-gray-300 hover:underline transition"
on:click={() => setAsProduction(selectedHistoryEntry)}
>
{$i18n.t('Set as Production')}
</button>
{/if}
<PromptHistoryMenu
isProduction={selectedHistoryEntry.id === prompt?.version_id}
onDelete={() => handleDeleteHistory(selectedHistoryEntry.id)}
onClose={() => {}}
/>
</div>
{/if}
</div>
<!-- Content container with copy button -->
<div class="relative flex-1 min-h-0">
<!-- Copy button - outside scroll area -->
<div class="absolute top-2 right-2 z-10">
<button
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition"
on:click={copyContent}
>
{#if contentCopied}
<Check className="size-4 text-green-500" />
{:else}
<Clipboard className="size-4 text-gray-500" />
{/if}
</button>
</div>
<!-- Scrollable content -->
<div
class="bg-gray-50 dark:bg-gray-900 rounded-xl px-4 py-3 border border-gray-100 dark:border-gray-800 h-full overflow-y-auto"
>
<pre class="text-sm whitespace-pre-wrap font-mono pr-8">{selectedHistoryEntry?.snapshot
?.content || content}</pre>
</div>
</div>
</div>
</div>
</div>
{:else}
<!-- Create mode: Form -->
<div class="w-full max-h-full flex justify-center">
<form class="flex flex-col w-full mb-10" on:submit|preventDefault={submitHandler}>
<div class="mb-2">
<Tooltip
content={`${$i18n.t('Only alphanumeric characters and hyphens are allowed')} - ${$i18n.t('Activate this command by typing "/{{COMMAND}}" to chat input.', { COMMAND: command })}`}
placement="bottom-start"
>
<div class=" self-center font-medium">{$i18n.t('Save & Create')}</div>
<div class="flex flex-col w-full">
<div class="flex items-center">
<input
class="text-2xl font-medium w-full bg-transparent outline-hidden"
placeholder={$i18n.t('Name')}
bind:value={name}
required
/>
<div class="self-center shrink-0">
<button
class="bg-gray-50 hover:bg-gray-100 text-black dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-white transition px-2 py-1 rounded-full flex gap-1 items-center"
type="button"
on:click={() => (showAccessControlModal = true)}
>
<LockClosed strokeWidth="2.5" className="size-3.5" />
<div class="text-sm font-medium shrink-0">{$i18n.t('Access')}</div>
</button>
</div>
</div>
<div class="flex gap-0.5 items-center text-xs text-gray-500">
<div>/</div>
<input
class="w-full bg-transparent outline-hidden"
placeholder={$i18n.t('Command')}
bind:value={command}
on:input={handleCommandInput}
required
/>
</div>
<div class="mt-1">
<Tags
{tags}
{suggestionTags}
on:add={(e) => {
tags = [...tags, { name: e.detail }];
}}
on:delete={(e) => {
tags = tags.filter((tag) => tag.name !== e.detail);
}}
/>
</div>
</div>
</Tooltip>
</div>
<div class="my-2">
<div class="text-gray-500 text-xs">{$i18n.t('Prompt Content')}</div>
<div class="mt-1">
<Textarea
className="text-sm w-full bg-transparent outline-hidden overflow-y-hidden resize-none"
placeholder={$i18n.t('Write a summary in 50 words that summarizes {{topic}}.')}
bind:value={content}
rows={6}
required
/>
<div class="text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('Use')}
<span class="font-medium text-gray-600 dark:text-gray-300"
>{'{{'}{$i18n.t('variable')}{'}}'}</span
>
{$i18n.t('for placeholders')}
</div>
</div>
</div>
<div class="my-4 flex justify-end pb-20">
<button
class="text-sm w-full lg:w-fit px-4 py-2 transition rounded-xl bg-black hover:bg-gray-900 text-white dark:bg-white dark:hover:bg-gray-100 dark:text-black flex w-full justify-center"
type="submit"
disabled={loading}
>
<div class="font-medium">{$i18n.t('Save & Create')}</div>
{#if loading}
<div class="ml-1.5 self-center">
<div class="ml-1.5">
<Spinner />
</div>
{/if}
</button>
</Tooltip>
</div>
</form>
</div>
{/if}
{#snippet historySection()}
<div class="flex flex-col h-full">
<div class="flex items-center justify-between mb-2 shrink-0">
<div class="text-gray-500 text-xs">{$i18n.t('History')}</div>
</div>
</form>
</div>
{#if history.length > 0}
<div class="space-y-0 flex-1 overflow-y-auto" on:scroll={handleHistoryScroll}>
{#each history as entry, index}
<div class="flex">
<!-- Content -->
<button
class="flex-1 text-left px-3.5 py-2 mb-1 rounded-xl transition group
{selectedHistoryEntry?.id === entry.id
? 'bg-gray-50 dark:bg-gray-850'
: 'hover:bg-gray-50 dark:hover:bg-gray-850'}"
on:click={() => (selectedHistoryEntry = entry)}
>
<!-- Commit Message -->
<div class="flex items-center gap-2 mb-1">
<div class="text-xs text-gray-900 dark:text-white truncate">
{entry.commit_message || $i18n.t('Update')}
</div>
{#if entry.id === prompt?.version_id}
<Badge type="success" content={$i18n.t('Live')} />
{/if}
</div>
<!-- User + Time -->
<div class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
{#if entry.user}
<img
src={`/api/v1/users/${entry.user.id}/profile/image`}
alt={entry.user.name}
class="size-3 rounded-full mr-0.5"
on:error={(e) => (e.target.src = '/user.png')}
/>
<span class="truncate">{entry.user.name}</span>
<span></span>
{/if}
<span class="shrink-0">{renderDate(entry.created_at)}</span>
</div>
</button>
</div>
{/each}
{#if historyLoading}
<div class="flex justify-center py-2">
<Spinner className="size-3" />
</div>
{/if}
</div>
{:else if !historyLoading}
<div class="text-xs text-gray-400 text-center py-6 italic">
{$i18n.t('No history available')}
</div>
{/if}
</div>
{/snippet}

View File

@@ -0,0 +1,83 @@
<script lang="ts">
import { DropdownMenu } from 'bits-ui';
import { flyAndScale } from '$lib/utils/transitions';
import { getContext, createEventDispatcher } from 'svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
const i18n = getContext('i18n');
export let isProduction = false;
export let onDelete: Function;
export let onClose: Function;
let show = false;
let showDeleteConfirmDialog = false;
</script>
<ConfirmDialog
bind:show={showDeleteConfirmDialog}
title={$i18n.t('Delete Version')}
message={$i18n.t(
"Are you sure you want to delete this version? Child versions will be relinked to this version's parent."
)}
confirmLabel={$i18n.t('Delete')}
onConfirm={() => {
onDelete();
}}
/>
<Dropdown
bind:show
on:change={(e) => {
if (e.detail === false) {
onClose();
}
}}
>
<Tooltip content={$i18n.t('More')}>
<slot>
<button
class="p-1 rounded-lg text-gray-500 hover:bg-gray-50 dark:hover:bg-gray-850 transition"
>
<EllipsisHorizontal className="size-5" />
</button>
</slot>
</Tooltip>
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-[170px] rounded-2xl p-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
sideOffset={-2}
side="bottom"
align="end"
transition={flyAndScale}
>
{#if isProduction}
<Tooltip content={$i18n.t('Cannot delete the production version')} placement="top">
<div
class="flex gap-2 items-center px-3 py-1.5 text-sm rounded-xl opacity-40 cursor-not-allowed"
>
<GarbageBin />
<div class="flex items-center">{$i18n.t('Delete')}</div>
</div>
</Tooltip>
{:else}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
show = false;
showDeleteConfirmDialog = true;
}}
>
<GarbageBin />
<div class="flex items-center">{$i18n.t('Delete')}</div>
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</div>
</Dropdown>

View File

@@ -2,11 +2,11 @@
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import { prompts } from '$lib/stores';
import { onMount, tick, getContext } from 'svelte';
import { onMount, getContext } from 'svelte';
const i18n = getContext('i18n');
import { getPromptByCommand, getPrompts, updatePromptByCommand } from '$lib/apis/prompts';
import { getPromptById, getPrompts, updatePromptById } from '$lib/apis/prompts';
import { page } from '$app/stores';
import PromptEditor from '$lib/components/workspace/Prompts/PromptEditor.svelte';
@@ -14,26 +14,37 @@
let prompt = null;
let disabled = false;
// Get prompt ID from route params
$: promptId = $page.params.id;
const onSubmit = async (_prompt) => {
console.log(_prompt);
const prompt = await updatePromptByCommand(localStorage.token, _prompt).catch((error) => {
const updatedPrompt = await updatePromptById(localStorage.token, _prompt).catch((error) => {
toast.error(`${error}`);
return null;
});
if (prompt) {
if (updatedPrompt) {
toast.success($i18n.t('Prompt updated successfully'));
await prompts.set(await getPrompts(localStorage.token));
await goto('/workspace/prompts');
// Update local prompt state to reflect the new version
prompt = {
id: updatedPrompt.id,
name: updatedPrompt.name,
command: updatedPrompt.command,
content: updatedPrompt.content,
version_id: updatedPrompt.version_id,
tags: updatedPrompt.tags,
access_control: updatedPrompt?.access_control === undefined ? {} : updatedPrompt?.access_control
};
}
};
onMount(async () => {
const command = $page.url.searchParams.get('command');
if (command) {
const _prompt = await getPromptByCommand(
if (promptId) {
const _prompt = await getPromptById(
localStorage.token,
command.replace(/\//g, '')
promptId
).catch((error) => {
toast.error(`${error}`);
return null;
@@ -42,9 +53,12 @@
if (_prompt) {
disabled = !_prompt.write_access ?? true;
prompt = {
title: _prompt.title,
id: _prompt.id,
name: _prompt.name,
command: _prompt.command,
content: _prompt.content,
version_id: _prompt.version_id,
tags: _prompt.tags,
access_control: _prompt?.access_control === undefined ? {} : _prompt?.access_control
};
} else {

View File

@@ -46,9 +46,10 @@
clone = true;
prompt = {
title: _prompt.title,
name: _prompt.name || _prompt.title || 'Prompt',
command: _prompt.command,
content: _prompt.content,
tags: _prompt.tags || [],
access_control: _prompt.access_control !== undefined ? _prompt.access_control : {}
};
});
@@ -65,9 +66,10 @@
clone = true;
prompt = {
title: _prompt.title,
name: _prompt.name || _prompt.title || 'Prompt',
command: _prompt.command,
content: _prompt.content,
tags: _prompt.tags || [],
access_control: _prompt.access_control !== undefined ? _prompt.access_control : {}
};
}