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
@@ -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,
)
)
+223
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()
+342 -21
View File
@@ -1,16 +1,20 @@
import time import time
import uuid
from typing import Optional from typing import Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.groups import Groups from open_webui.models.groups import Groups
from open_webui.models.users import Users, UserResponse from open_webui.models.users import Users, UserResponse
from open_webui.models.prompt_history import PromptHistories
from pydantic import BaseModel, ConfigDict 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 from open_webui.utils.access_control import has_access
#################### ####################
# Prompts DB Schema # Prompts DB Schema
#################### ####################
@@ -19,11 +23,18 @@ from open_webui.utils.access_control import has_access
class Prompt(Base): class Prompt(Base):
__tablename__ = "prompt" __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) user_id = Column(String)
title = Column(Text) name = Column(Text)
content = 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. access_control = Column(JSON, nullable=True) # Controls data access levels.
# Defines access control rules for this entry. # Defines access control rules for this entry.
@@ -44,13 +55,20 @@ class Prompt(Base):
class PromptModel(BaseModel): class PromptModel(BaseModel):
id: Optional[str] = None
command: str command: str
user_id: str user_id: str
title: str name: str
content: 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 access_control: Optional[dict] = None
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -69,21 +87,37 @@ class PromptAccessResponse(PromptUserResponse):
class PromptForm(BaseModel): class PromptForm(BaseModel):
command: str command: str
title: str name: str # Changed from title
content: str content: str
data: Optional[dict] = None
meta: Optional[dict] = None
tags: Optional[list[str]] = None
access_control: Optional[dict] = 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: class PromptsTable:
def insert_new_prompt( def insert_new_prompt(
self, user_id: str, form_data: PromptForm, db: Optional[Session] = None self, user_id: str, form_data: PromptForm, db: Optional[Session] = None
) -> Optional[PromptModel]: ) -> Optional[PromptModel]:
now = int(time.time())
prompt_id = str(uuid.uuid4())
prompt = PromptModel( prompt = PromptModel(
**{ id=prompt_id,
"user_id": user_id, user_id=user_id,
**form_data.model_dump(), command=form_data.command,
"timestamp": int(time.time()), 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: try:
@@ -92,26 +126,72 @@ class PromptsTable:
db.add(result) db.add(result)
db.commit() db.commit()
db.refresh(result) db.refresh(result)
if 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) return PromptModel.model_validate(result)
else: else:
return None return None
except Exception: except Exception:
return None 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( def get_prompt_by_command(
self, command: str, db: Optional[Session] = None self, command: str, db: Optional[Session] = None
) -> Optional[PromptModel]: ) -> Optional[PromptModel]:
try: try:
with get_db_context(db) as db: with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(command=command).first() 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: except Exception:
return None return None
def get_prompts(self, db: Optional[Session] = None) -> list[PromptUserResponse]: def get_prompts(self, db: Optional[Session] = None) -> list[PromptUserResponse]:
with get_db_context(db) as db: 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)) user_ids = list(set(prompt.user_id for prompt in all_prompts))
@@ -148,16 +228,203 @@ class PromptsTable:
] ]
def update_prompt_by_command( 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]: ) -> Optional[PromptModel]:
try: try:
with get_db_context(db) as db: with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(command=command).first() 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.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.access_control = form_data.access_control
prompt.timestamp = int(time.time()) prompt.updated_at = int(time.time())
db.commit() 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) return PromptModel.model_validate(prompt)
except Exception: except Exception:
return None return None
@@ -165,14 +432,68 @@ class PromptsTable:
def delete_prompt_by_command( def delete_prompt_by_command(
self, command: str, db: Optional[Session] = None self, command: str, db: Optional[Session] = None
) -> bool: ) -> bool:
"""Soft delete a prompt by setting is_active to False."""
try: try:
with get_db_context(db) as db: with get_db_context(db) as db:
db.query(Prompt).filter_by(command=command).delete() prompt = db.query(Prompt).filter_by(command=command).first()
db.commit() 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: except Exception:
return False 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() Prompts = PromptsTable()
+354 -24
View File
@@ -8,15 +8,33 @@ from open_webui.models.prompts import (
PromptModel, PromptModel,
Prompts, Prompts,
) )
from open_webui.models.prompt_history import (
PromptHistories,
PromptHistoryModel,
PromptHistoryResponse,
)
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from open_webui.utils.auth import get_admin_user, get_verified_user 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.utils.access_control import has_access, has_permission
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.internal.db import get_session from open_webui.internal.db import get_session
from sqlalchemy.orm import 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() router = APIRouter()
############################ ############################
# GetPrompts # GetPrompts
############################ ############################
@@ -34,6 +52,21 @@ async def get_prompts(
return 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]) @router.get("/list", response_model=list[PromptAccessResponse])
async def get_prompt_list( async def get_prompt_list(
user=Depends(get_verified_user), db: Session = Depends(get_session) 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( async def get_prompt_by_command(
command: str, user=Depends(get_verified_user), db: Session = Depends(get_session) 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 prompt:
if ( if (
@@ -128,29 +161,62 @@ async def get_prompt_by_command(
or has_access(user.id, "write", prompt.access_control, db=db) or has_access(user.id, "write", prompt.access_control, db=db)
), ),
) )
else:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND,
) )
############################ ############################
# UpdatePromptByCommand # GetPromptById
############################ ############################
@router.post("/command/{command}/update", response_model=Optional[PromptModel]) @router.get("/id/{prompt_id}", response_model=Optional[PromptAccessResponse])
async def update_prompt_by_command( async def get_prompt_by_id(
command: str, 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, form_data: PromptForm,
user=Depends(get_verified_user), user=Depends(get_verified_user),
db: Session = Depends(get_session), 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: if not prompt:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND,
) )
@@ -165,29 +231,46 @@ async def update_prompt_by_command(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
) )
prompt = Prompts.update_prompt_by_command(f"/{command}", form_data, db=db) # Check for command collision if command is being changed
if prompt: if form_data.command != prompt.command:
return prompt 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: else:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED, detail=ERROR_MESSAGES.DEFAULT(),
) )
############################ ############################
# DeletePromptByCommand # UpdatePromptMetadata
############################ ############################
@router.delete("/command/{command}/delete", response_model=bool) @router.post("/id/{prompt_id}/update/meta", response_model=Optional[PromptModel])
async def delete_prompt_by_command( async def update_prompt_metadata(
command: str, user=Depends(get_verified_user), db: Session = Depends(get_session) 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: if not prompt:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND,
) )
@@ -201,5 +284,252 @@ async def delete_prompt_by_command(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED, 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 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
+363 -13
View File
@@ -1,10 +1,48 @@
import { WEBUI_API_BASE_URL } from '$lib/constants'; import { WEBUI_API_BASE_URL } from '$lib/constants';
type PromptItem = { type PromptItem = {
id?: string; // Prompt ID
command: string; command: string;
title: string; name: string; // Changed from title
content: string; content: string;
data?: object | null;
meta?: object | null;
access_control?: null | object; 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) => { export const createNewPrompt = async (token: string, prompt: PromptItem) => {
@@ -19,7 +57,7 @@ export const createNewPrompt = async (token: string, prompt: PromptItem) => {
}, },
body: JSON.stringify({ body: JSON.stringify({
...prompt, ...prompt,
command: `/${prompt.command}` command: prompt.command.startsWith('/') ? prompt.command.slice(1) : prompt.command
}) })
}) })
.then(async (res) => { .then(async (res) => {
@@ -70,6 +108,34 @@ export const getPrompts = async (token: string = '') => {
return res; 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 = '') => { export const getPromptList = async (token: string = '') => {
let error = null; let error = null;
@@ -104,6 +170,8 @@ export const getPromptList = async (token: string = '') => {
export const getPromptByCommand = async (token: string, command: string) => { export const getPromptByCommand = async (token: string, command: string) => {
let error = null; let error = null;
command = command.charAt(0) === '/' ? command.slice(1) : command;
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/command/${command}`, { const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/command/${command}`, {
method: 'GET', method: 'GET',
headers: { headers: {
@@ -133,20 +201,16 @@ export const getPromptByCommand = async (token: string, command: string) => {
return res; return res;
}; };
export const updatePromptByCommand = async (token: string, prompt: PromptItem) => { export const getPromptById = async (token: string, promptId: string) => {
let error = null; let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/command/${prompt.command}/update`, { const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/id/${promptId}`, {
method: 'POST', method: 'GET',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
authorization: `Bearer ${token}` authorization: `Bearer ${token}`
}, }
body: JSON.stringify({
...prompt,
command: `/${prompt.command}`
})
}) })
.then(async (res) => { .then(async (res) => {
if (!res.ok) throw await res.json(); if (!res.ok) throw await res.json();
@@ -169,12 +233,113 @@ export const updatePromptByCommand = async (token: string, prompt: PromptItem) =
return res; return res;
}; };
export const deletePromptByCommand = async (token: string, command: string) => { export const updatePromptById = async (token: string, prompt: PromptItem) => {
let error = null; 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', method: 'DELETE',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
@@ -202,3 +367,188 @@ export const deletePromptByCommand = async (token: string, command: string) => {
return res; 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;
};
@@ -14,7 +14,7 @@
$: filteredItems = prompts $: filteredItems = prompts
.filter((p) => p.command.toLowerCase().includes(query.toLowerCase())) .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) { $: if (query) {
selectedPromptIdx = 0; selectedPromptIdx = 0;
@@ -42,7 +42,7 @@
{#if filteredItems.length > 0} {#if filteredItems.length > 0}
<div class=" space-y-0.5 scrollbar-hidden"> <div class=" space-y-0.5 scrollbar-hidden">
{#each filteredItems as promptItem, promptIdx} {#each filteredItems as promptItem, promptIdx}
<Tooltip content={promptItem.title} placement="top-start"> <Tooltip content={promptItem.name} placement="top-start">
<button <button
class=" px-3 py-1 rounded-xl w-full text-left {promptIdx === selectedPromptIdx class=" px-3 py-1 rounded-xl w-full text-left {promptIdx === selectedPromptIdx
? ' bg-gray-50 dark:bg-gray-800 selected-command-option-button' ? ' bg-gray-50 dark:bg-gray-800 selected-command-option-button'
@@ -62,7 +62,7 @@
</span> </span>
<span class=" text-xs text-gray-600 dark:text-gray-100"> <span class=" text-xs text-gray-600 dark:text-gray-100">
{promptItem.title} {promptItem.name}
</span> </span>
</button> </button>
</Tooltip> </Tooltip>
+2 -2
View File
@@ -12,8 +12,8 @@
</script> </script>
<div <div
class=" text-xs font-semibold {classNames[type] ?? class=" text-xs font-medium {classNames[type] ??
classNames['info']} w-fit px-2 rounded-sm uppercase line-clamp-1 mr-0.5" classNames['info']} w-fit px-1.5 py-[1px] rounded-lg uppercase line-clamp-1 mr-0.5"
> >
{content} {content}
</div> </div>
+34 -12
View File
@@ -9,9 +9,10 @@
import { import {
createNewPrompt, createNewPrompt,
deletePromptByCommand, deletePromptById,
getPrompts, getPrompts,
getPromptList getPromptList,
getPromptTags
} from '$lib/apis/prompts'; } from '$lib/apis/prompts';
import { capitalizeFirstLetter, slugify, copyToClipboard } from '$lib/utils'; import { capitalizeFirstLetter, slugify, copyToClipboard } from '$lib/utils';
@@ -28,8 +29,8 @@
import XMark from '../icons/XMark.svelte'; import XMark from '../icons/XMark.svelte';
import GarbageBin from '../icons/GarbageBin.svelte'; import GarbageBin from '../icons/GarbageBin.svelte';
import ViewSelector from './common/ViewSelector.svelte'; import ViewSelector from './common/ViewSelector.svelte';
import TagSelector from './common/TagSelector.svelte';
import Badge from '$lib/components/common/Badge.svelte'; import Badge from '$lib/components/common/Badge.svelte';
let shiftKey = false; let shiftKey = false;
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@@ -40,23 +41,25 @@
let query = ''; let query = '';
let prompts = []; let prompts = [];
let tags = [];
let showDeleteConfirm = false; let showDeleteConfirm = false;
let deletePrompt = null; let deletePrompt = null;
let tagsContainerElement: HTMLDivElement; let tagsContainerElement: HTMLDivElement;
let viewOption = ''; let viewOption = '';
let selectedTag = '';
let copiedId: string | null = null; let copiedId: string | null = null;
let filteredItems = []; let filteredItems = [];
$: if (prompts && query !== undefined && viewOption !== undefined) { $: if (prompts && query !== undefined && viewOption !== undefined && selectedTag !== undefined) {
setFilteredItems(); setFilteredItems();
} }
const setFilteredItems = () => { const setFilteredItems = () => {
filteredItems = prompts.filter((p) => { filteredItems = prompts.filter((p) => {
if (query === '' && viewOption === '') return true; if (query === '' && viewOption === '' && selectedTag === '') return true;
const lowerQuery = query.toLowerCase(); const lowerQuery = query.toLowerCase();
return ( return (
((p.title || '').toLowerCase().includes(lowerQuery) || ((p.title || '').toLowerCase().includes(lowerQuery) ||
@@ -65,7 +68,8 @@
(p.user?.email || '').toLowerCase().includes(lowerQuery)) && (p.user?.email || '').toLowerCase().includes(lowerQuery)) &&
(viewOption === '' || (viewOption === '' ||
(viewOption === 'created' && p.user_id === $user?.id) || (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 deleteHandler = async (prompt) => {
const command = prompt.command; 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); toast.error(err);
return null; return null;
}); });
@@ -135,6 +139,7 @@
const init = async () => { const init = async () => {
prompts = await getPromptList(localStorage.token); prompts = await getPromptList(localStorage.token);
tags = await getPromptTags(localStorage.token);
await _prompts.set(await getPrompts(localStorage.token)); await _prompts.set(await getPrompts(localStorage.token));
}; };
@@ -329,6 +334,13 @@
await tick(); await tick();
}} }}
/> />
{#if (tags ?? []).length > 0}
<TagSelector
bind:value={selectedTag}
items={tags.map((tag) => ({ value: tag, label: tag }))}
/>
{/if}
</div> </div>
</div> </div>
@@ -338,14 +350,14 @@
{#each filteredItems as prompt} {#each filteredItems as prompt}
<a <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" 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 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="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"> <div class="text-xs overflow-hidden text-ellipsis line-clamp-1 text-gray-500">
{prompt.command} /{prompt.command}
</div> </div>
</div> </div>
{#if !prompt.write_access} {#if !prompt.write_access}
@@ -353,7 +365,7 @@
{/if} {/if}
</div> </div>
<div class=" text-xs"> <div class="flex gap-1 text-xs">
<Tooltip <Tooltip
content={prompt?.user?.email ?? $i18n.t('Deleted User')} content={prompt?.user?.email ?? $i18n.t('Deleted User')}
className="flex shrink-0" className="flex shrink-0"
@@ -367,6 +379,16 @@
})} })}
</div> </div>
</Tooltip> </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> </div>
<div class="flex flex-row gap-0.5 self-center"> <div class="flex flex-row gap-0.5 self-center">
@@ -4,12 +4,29 @@
import Textarea from '$lib/components/common/Textarea.svelte'; import Textarea from '$lib/components/common/Textarea.svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import AccessControl from '../common/AccessControl.svelte';
import LockClosed from '$lib/components/icons/LockClosed.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 AccessControlModal from '../common/AccessControlModal.svelte';
import { user } from '$lib/stores'; 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 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 onSubmit: Function;
export let edit = false; export let edit = false;
@@ -20,22 +37,38 @@
const i18n = getContext('i18n'); const i18n = getContext('i18n');
let loading = false; let loading = false;
let showEditModal = false;
let title = ''; let name = '';
let command = ''; let command = '';
let content = ''; let content = '';
let tags = [];
let commitMessage = '';
let isProduction = true;
let accessControl = {}; let accessControl = {};
let showAccessControlModal = false; let showAccessControlModal = false;
let hasManualEdit = 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) { $: if (!edit && !hasManualEdit) {
command = title !== '' ? slugify(title) : ''; command = name !== '' ? slugify(name) : '';
} }
// Track manual edits
function handleCommandInput(e: Event) { function handleCommandInput(e: Event) {
hasManualEdit = true; hasManualEdit = true;
} }
@@ -49,11 +82,23 @@
if (validateCommandString(command)) { if (validateCommandString(command)) {
await onSubmit({ await onSubmit({
title, id: prompt?.id,
name,
command, command,
content, 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 { } else {
toast.error( toast.error(
$i18n.t('Only alphanumeric characters and hyphens are allowed in the command string.') $i18n.t('Only alphanumeric characters and hyphens are allowed in the command string.')
@@ -64,22 +109,177 @@
}; };
const validateCommandString = (inputString) => { const validateCommandString = (inputString) => {
// Regular expression to match only alphanumeric characters, hyphen, and underscore
const regex = /^[a-zA-Z0-9-_]+$/; const regex = /^[a-zA-Z0-9-_]+$/;
// Test the input string against the regular expression
return regex.test(inputString); 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 () => { onMount(async () => {
if (prompt) { if (prompt) {
title = prompt.title; name = prompt.name || '';
await tick(); await tick();
command = prompt.command.at(0) === '/' ? prompt.command.slice(1) : prompt.command; command = prompt.command.at(0) === '/' ? prompt.command.slice(1) : prompt.command;
content = prompt.content; content = prompt.content;
tags = (prompt.tags || []).map((tag) => ({ name: tag }));
accessControl = prompt?.access_control === undefined ? {} : prompt?.access_control; 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> </script>
@@ -92,124 +292,381 @@
sharePublic={$user?.permissions?.sharing?.public_prompts || $user?.role === 'admin'} sharePublic={$user?.permissions?.sharing?.public_prompts || $user?.role === 'admin'}
/> />
<div class="w-full max-h-full flex justify-center"> <!-- Edit Modal -->
<form <Modal size="lg" bind:show={showEditModal}>
class="flex flex-col w-full mb-10" <div class="px-5 pt-4 pb-5">
on:submit|preventDefault={() => { <div class="flex justify-between items-center mb-2">
submitHandler(); <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"
<div class="my-2"> on:click={() => (showEditModal = false)}
<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="flex flex-col w-full"> <XMark className="size-5" />
<div class="flex items-center"> </button>
<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>
</div> </div>
<div class="my-2"> <form on:submit|preventDefault={submitHandler}>
<div class="flex w-full justify-between"> <div class="my-2">
<div class=" self-center text-sm font-medium">{$i18n.t('Prompt Content')}</div> <div class="flex w-full justify-between">
</div> <div class="text-gray-500 text-xs">{$i18n.t('Prompt Content')}</div>
</div>
<div class="mt-2"> <div class="mt-1">
<div>
<Textarea <Textarea
className="text-sm w-full bg-transparent outline-hidden overflow-y-hidden resize-none" 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}}.')} placeholder={$i18n.t('Write a summary in 50 words that summarizes {{topic}}.')}
bind:value={content} bind:value={content}
rows={6} rows={6}
required required
readonly={disabled}
/> />
</div> </div>
</div>
<div class="text-xs text-gray-400 dark:text-gray-500"> <div class="my-2">
ⓘ {$i18n.t('Format your variables using brackets like this:')}&nbsp;<span <div class="text-gray-500 text-xs">{$i18n.t('Commit Message')} ({$i18n.t('optional')})</div>
class=" text-gray-600 dark:text-gray-300 font-medium" <div class="mt-1">
>{'{{'}{$i18n.t('variable')}{'}}'}</span <input
>. class="text-sm w-full bg-transparent outline-hidden"
{$i18n.t('Make sure to enclose them with')} placeholder={$i18n.t('Describe what changed...')}
<span class=" text-gray-600 dark:text-gray-300 font-medium">{'{{'}</span> bind:value={commitMessage}
{$i18n.t('and')} />
<span class=" text-gray-600 dark:text-gray-300 font-medium">{'}}'}</span>. </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>
<div class="text-xs text-gray-400 dark:text-gray-500 underline"> <div class="mt-1.5">
<a href="https://docs.openwebui.com/features/workspace/prompts" target="_blank"> <Tooltip content={$i18n.t('Click to copy ID')}>
{$i18n.t('To learn more about powerful prompt variables, click here')} <button
</a> 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>
</div> </div>
<div class="my-4 flex justify-end pb-20"> <div class="mb-2 flex justify-between items-center gap-2">
<Tooltip content={disabled ? $i18n.t('You do not have permission to save this prompt.') : ''}> <div class="flex-1 min-w-0">
<button <Tags
class=" text-sm w-full lg:w-fit px-4 py-2 transition rounded-xl {loading || disabled {tags}
? ' cursor-not-allowed bg-gray-200 text-gray-500 dark:bg-gray-700 dark:text-gray-400' {suggestionTags}
: 'bg-black hover:bg-gray-900 text-white dark:bg-white dark:hover:bg-gray-100 dark:text-black'} flex w-full justify-center" on:add={(e) => {
type="submit" tags = [...tags, { name: e.detail }];
disabled={loading || disabled} 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} {#if loading}
<div class="ml-1.5 self-center"> <div class="ml-1.5">
<Spinner /> <Spinner />
</div> </div>
{/if} {/if}
</button> </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> </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}
@@ -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>
@@ -2,11 +2,11 @@
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { prompts } from '$lib/stores'; import { prompts } from '$lib/stores';
import { onMount, tick, getContext } from 'svelte'; import { onMount, getContext } from 'svelte';
const i18n = getContext('i18n'); 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 { page } from '$app/stores';
import PromptEditor from '$lib/components/workspace/Prompts/PromptEditor.svelte'; import PromptEditor from '$lib/components/workspace/Prompts/PromptEditor.svelte';
@@ -14,26 +14,37 @@
let prompt = null; let prompt = null;
let disabled = false; let disabled = false;
// Get prompt ID from route params
$: promptId = $page.params.id;
const onSubmit = async (_prompt) => { const onSubmit = async (_prompt) => {
console.log(_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}`); toast.error(`${error}`);
return null; return null;
}); });
if (prompt) { if (updatedPrompt) {
toast.success($i18n.t('Prompt updated successfully')); toast.success($i18n.t('Prompt updated successfully'));
await prompts.set(await getPrompts(localStorage.token)); 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 () => { onMount(async () => {
const command = $page.url.searchParams.get('command'); if (promptId) {
if (command) { const _prompt = await getPromptById(
const _prompt = await getPromptByCommand(
localStorage.token, localStorage.token,
command.replace(/\//g, '') promptId
).catch((error) => { ).catch((error) => {
toast.error(`${error}`); toast.error(`${error}`);
return null; return null;
@@ -42,9 +53,12 @@
if (_prompt) { if (_prompt) {
disabled = !_prompt.write_access ?? true; disabled = !_prompt.write_access ?? true;
prompt = { prompt = {
title: _prompt.title, id: _prompt.id,
name: _prompt.name,
command: _prompt.command, command: _prompt.command,
content: _prompt.content, content: _prompt.content,
version_id: _prompt.version_id,
tags: _prompt.tags,
access_control: _prompt?.access_control === undefined ? {} : _prompt?.access_control access_control: _prompt?.access_control === undefined ? {} : _prompt?.access_control
}; };
} else { } else {
@@ -46,9 +46,10 @@
clone = true; clone = true;
prompt = { prompt = {
title: _prompt.title, name: _prompt.name || _prompt.title || 'Prompt',
command: _prompt.command, command: _prompt.command,
content: _prompt.content, content: _prompt.content,
tags: _prompt.tags || [],
access_control: _prompt.access_control !== undefined ? _prompt.access_control : {} access_control: _prompt.access_control !== undefined ? _prompt.access_control : {}
}; };
}); });
@@ -65,9 +66,10 @@
clone = true; clone = true;
prompt = { prompt = {
title: _prompt.title, name: _prompt.name || _prompt.title || 'Prompt',
command: _prompt.command, command: _prompt.command,
content: _prompt.content, content: _prompt.content,
tags: _prompt.tags || [],
access_control: _prompt.access_control !== undefined ? _prompt.access_control : {} access_control: _prompt.access_control !== undefined ? _prompt.access_control : {}
}; };
} }