Merge pull request #20945 from open-webui/prompt_versioning
enh: prompts
This commit is contained in:
@@ -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
backend/open_webui/models/prompt_history.py
Normal file
223
backend/open_webui/models/prompt_history.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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:')} <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}
|
||||
|
||||
@@ -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 { 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 {
|
||||
@@ -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 : {}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user