mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-26 23:44:48 -06:00
6be11d4fc9
Fourteen modules import `json` without using it. Ruff flags every one with F401, and a word-boundary search for `json` in each file matches only the import line itself, including inside strings, comments and annotations. Two exclusions, both deliberate. Migration files are left alone: the import is equally dead there, but those files are frozen history and not worth the churn. `models/chats.py` has the same dead import and is handled in its own change, so it is skipped here to avoid two changes touching the same line. No behaviour change.
235 lines
8.1 KiB
Python
235 lines
8.1 KiB
Python
"""Prompt history model for version tracking."""
|
|
|
|
import difflib
|
|
import time
|
|
import uuid
|
|
from typing import Optional
|
|
|
|
from open_webui.internal.db import Base, get_async_db_context
|
|
from open_webui.models.users import UserResponse, Users
|
|
from pydantic import BaseModel, ConfigDict
|
|
from sqlalchemy import JSON, BigInteger, Column, Index, Text, delete, func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
####################
|
|
# 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:
|
|
async 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[AsyncSession] = None,
|
|
) -> Optional[PromptHistoryModel]:
|
|
"""Create a new history entry (commit) for a prompt."""
|
|
async with get_async_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)
|
|
await db.commit()
|
|
return PromptHistoryModel.model_validate(history)
|
|
|
|
async def get_history_by_prompt_id(
|
|
self,
|
|
prompt_id: str,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
db: Optional[AsyncSession] = None,
|
|
) -> list[PromptHistoryResponse]:
|
|
"""Get all history entries for a prompt, ordered by created_at desc."""
|
|
async with get_async_db_context(db) as db:
|
|
result = await db.execute(
|
|
select(PromptHistory)
|
|
.filter(PromptHistory.prompt_id == prompt_id)
|
|
.order_by(PromptHistory.created_at.desc())
|
|
.offset(offset)
|
|
.limit(limit)
|
|
)
|
|
entries = result.scalars().all()
|
|
|
|
# Get user info for each entry
|
|
user_ids = list(set(e.user_id for e in entries))
|
|
users = await 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
|
|
]
|
|
|
|
async def get_history_entry_by_id(
|
|
self,
|
|
history_id: str,
|
|
db: Optional[AsyncSession] = None,
|
|
) -> Optional[PromptHistoryModel]:
|
|
"""Get a specific history entry by ID."""
|
|
async with get_async_db_context(db) as db:
|
|
result = await db.execute(select(PromptHistory).filter(PromptHistory.id == history_id))
|
|
entry = result.scalars().first()
|
|
if entry:
|
|
return PromptHistoryModel.model_validate(entry)
|
|
return None
|
|
|
|
async def get_latest_history_entry(
|
|
self,
|
|
prompt_id: str,
|
|
db: Optional[AsyncSession] = None,
|
|
) -> Optional[PromptHistoryModel]:
|
|
"""Get the most recent history entry for a prompt."""
|
|
async with get_async_db_context(db) as db:
|
|
result = await db.execute(
|
|
select(PromptHistory)
|
|
.filter(PromptHistory.prompt_id == prompt_id)
|
|
.order_by(PromptHistory.created_at.desc())
|
|
.limit(1)
|
|
)
|
|
entry = result.scalars().first()
|
|
if entry:
|
|
return PromptHistoryModel.model_validate(entry)
|
|
return None
|
|
|
|
async def get_history_count(
|
|
self,
|
|
prompt_id: str,
|
|
db: Optional[AsyncSession] = None,
|
|
) -> int:
|
|
"""Get the number of history entries for a prompt."""
|
|
async with get_async_db_context(db) as db:
|
|
result = await db.execute(
|
|
select(func.count()).select_from(PromptHistory).filter(PromptHistory.prompt_id == prompt_id)
|
|
)
|
|
return result.scalar()
|
|
|
|
async def compute_diff(
|
|
self,
|
|
from_id: str,
|
|
to_id: str,
|
|
prompt_id: str,
|
|
db: Optional[AsyncSession] = None,
|
|
) -> Optional[dict]:
|
|
"""Compute diff between two history entries."""
|
|
async with get_async_db_context(db) as db:
|
|
# Bind both entries to the authorized prompt; an unbound id reads another prompt's snapshot.
|
|
result_from = await db.execute(
|
|
select(PromptHistory).filter(PromptHistory.id == from_id, PromptHistory.prompt_id == prompt_id)
|
|
)
|
|
from_entry = result_from.scalars().first()
|
|
result_to = await db.execute(
|
|
select(PromptHistory).filter(PromptHistory.id == to_id, PromptHistory.prompt_id == prompt_id)
|
|
)
|
|
to_entry = result_to.scalars().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'),
|
|
}
|
|
|
|
async def delete_history_by_prompt_id(
|
|
self,
|
|
prompt_id: str,
|
|
db: Optional[AsyncSession] = None,
|
|
) -> bool:
|
|
"""Delete all history entries for a prompt."""
|
|
async with get_async_db_context(db) as db:
|
|
await db.execute(delete(PromptHistory).filter(PromptHistory.prompt_id == prompt_id))
|
|
await db.commit()
|
|
return True
|
|
|
|
async def delete_history_entry(
|
|
self,
|
|
history_id: str,
|
|
prompt_id: str,
|
|
db: Optional[AsyncSession] = None,
|
|
) -> bool:
|
|
"""Delete a history entry and reparent its children to grandparent."""
|
|
async with get_async_db_context(db) as db:
|
|
# Bind to the authorized prompt; an unbound id deletes another prompt's history.
|
|
result = await db.execute(select(PromptHistory).filter_by(id=history_id, prompt_id=prompt_id))
|
|
entry = result.scalars().first()
|
|
if not entry:
|
|
return False
|
|
|
|
# Find children that reference this entry as parent
|
|
children_result = await db.execute(select(PromptHistory).filter_by(parent_id=history_id))
|
|
children = children_result.scalars().all()
|
|
|
|
# Reparent children to grandparent
|
|
for child in children:
|
|
child.parent_id = entry.parent_id
|
|
|
|
await db.delete(entry)
|
|
await db.commit()
|
|
return True
|
|
|
|
|
|
PromptHistories = PromptHistoryTable()
|