mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-09 03:18:18 +00:00
Merge branch 'open-webui:dev' into dev
This commit is contained in:
commit
99c0ce08f8
120 changed files with 6782 additions and 1608 deletions
24
.github/pull_request_template.md
vendored
24
.github/pull_request_template.md
vendored
|
|
@ -1,3 +1,9 @@
|
|||
<!--
|
||||
⚠️ CRITICAL CHECKS FOR CONTRIBUTORS (READ, DON'T DELETE) ⚠️
|
||||
1. Target the `dev` branch. PRs targeting `main` will be automatically closed.
|
||||
2. Do NOT delete the CLA section at the bottom. It is required for the bot to accept your PR.
|
||||
-->
|
||||
|
||||
# Pull Request Checklist
|
||||
|
||||
### Note to first-time contributors: Please open a discussion post in [Discussions](https://github.com/open-webui/open-webui/discussions) to discuss your idea/fix with the community before creating a pull request, and describe your changes before submitting a pull request.
|
||||
|
|
@ -6,14 +12,16 @@ This is to ensure large feature PRs are discussed with the community first, befo
|
|||
|
||||
**Before submitting, make sure you've checked the following:**
|
||||
|
||||
- [ ] **Target branch:** Verify that the pull request targets the `dev` branch. **Not targeting the `dev` branch will lead to immediate closure of the PR.**
|
||||
- [ ] **Target branch:** Verify that the pull request targets the `dev` branch. **PRs targeting `main` will be immediately closed.**
|
||||
- [ ] **Description:** Provide a concise description of the changes made in this pull request down below.
|
||||
- [ ] **Changelog:** Ensure a changelog entry following the format of [Keep a Changelog](https://keepachangelog.com/) is added at the bottom of the PR description.
|
||||
- [ ] **Documentation:** If necessary, update relevant documentation [Open WebUI Docs](https://github.com/open-webui/docs) like environment variables, the tutorials, or other documentation sources.
|
||||
- [ ] **Dependencies:** Are there any new dependencies? Have you updated the dependency versions in the documentation?
|
||||
- [ ] **Testing:** Perform manual tests to **verify the implemented fix/feature works as intended AND does not break any other functionality**. Take this as an opportunity to **make screenshots of the feature/fix and include it in the PR description**.
|
||||
- [ ] **Documentation:** Add docs in [Open WebUI Docs Repository](https://github.com/open-webui/docs). Document user-facing behavior, environment variables, public APIs/interfaces, or deployment steps.
|
||||
- [ ] **Dependencies:** Are there any new or upgraded dependencies? If so, explain why, update the changelog/docs, and include any compatibility notes. Actually run the code/function that uses updated library to ensure it doesn't crash.
|
||||
- [ ] **Testing:** Perform manual tests to **verify the implemented fix/feature works as intended AND does not break any other functionality**. Include reproducible steps to demonstrate the issue before the fix. Test edge cases (URL encoding, HTML entities, types). Take this as an opportunity to **make screenshots of the feature/fix and include them in the PR description**.
|
||||
- [ ] **Agentic AI Code:** Confirm this Pull Request is **not written by any AI Agent** or has at least **gone through additional human review AND manual testing**. If any AI Agent is the co-author of this PR, it may lead to immediate closure of the PR.
|
||||
- [ ] **Code review:** Have you performed a self-review of your code, addressing any coding standard issues and ensuring adherence to the project's coding standards?
|
||||
- [ ] **Design & Architecture:** Prefer smart defaults over adding new settings; use local state for ephemeral UI logic. Open a Discussion for major architectural or UX changes.
|
||||
- [ ] **Git Hygiene:** Keep PRs atomic (one logical change). Clean up commits and rebase on `dev` to ensure no unrelated commits (e.g. from `main`) are included. Push updates to the existing PR branch instead of closing and reopening.
|
||||
- [ ] **Title Prefix:** To clearly categorize this pull request, prefix the pull request title using one of the following:
|
||||
- **BREAKING CHANGE**: Significant changes that may affect compatibility
|
||||
- **build**: Changes that affect the build system or external dependencies
|
||||
|
|
@ -76,7 +84,13 @@ This is to ensure large feature PRs are discussed with the community first, befo
|
|||
|
||||
### Contributor License Agreement
|
||||
|
||||
<!--
|
||||
🚨 DO NOT DELETE THE TEXT BELOW 🚨
|
||||
Keep the "Contributor License Agreement" confirmation text intact.
|
||||
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.
|
||||
-->
|
||||
|
||||
By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
|
||||
|
||||
> [!NOTE]
|
||||
> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.
|
||||
> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.
|
||||
|
|
@ -128,7 +128,7 @@ RUN apt-get update && \
|
|||
apt-get install -y --no-install-recommends \
|
||||
git build-essential pandoc gcc netcat-openbsd curl jq \
|
||||
python3-dev \
|
||||
ffmpeg libsm6 libxext6 \
|
||||
ffmpeg libsm6 libxext6 zstd \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# install python dependencies
|
||||
|
|
|
|||
|
|
@ -257,7 +257,7 @@ class AppConfig:
|
|||
self._state[key].value = value
|
||||
self._state[key].save()
|
||||
|
||||
if self._redis:
|
||||
if self._redis and ENABLE_PERSISTENT_CONFIG:
|
||||
redis_key = f"{self._redis_key_prefix}:config:{key}"
|
||||
self._redis.set(redis_key, json.dumps(self._state[key].value))
|
||||
|
||||
|
|
@ -265,8 +265,8 @@ class AppConfig:
|
|||
if key not in self._state:
|
||||
raise AttributeError(f"Config key '{key}' not found")
|
||||
|
||||
# If Redis is available, check for an updated value
|
||||
if self._redis:
|
||||
# If Redis is available and persistent config is enabled, check for an updated value
|
||||
if self._redis and ENABLE_PERSISTENT_CONFIG:
|
||||
redis_key = f"{self._redis_key_prefix}:config:{key}"
|
||||
redis_value = self._redis.get(redis_key)
|
||||
|
||||
|
|
@ -2246,9 +2246,13 @@ ENABLE_QDRANT_MULTITENANCY_MODE = (
|
|||
QDRANT_COLLECTION_PREFIX = os.environ.get("QDRANT_COLLECTION_PREFIX", "open-webui")
|
||||
|
||||
WEAVIATE_HTTP_HOST = os.environ.get("WEAVIATE_HTTP_HOST", "")
|
||||
WEAVIATE_GRPC_HOST = os.environ.get("WEAVIATE_GRPC_HOST", "")
|
||||
WEAVIATE_HTTP_PORT = int(os.environ.get("WEAVIATE_HTTP_PORT", "8080"))
|
||||
WEAVIATE_GRPC_PORT = int(os.environ.get("WEAVIATE_GRPC_PORT", "50051"))
|
||||
WEAVIATE_API_KEY = os.environ.get("WEAVIATE_API_KEY")
|
||||
WEAVIATE_HTTP_SECURE = os.environ.get("WEAVIATE_HTTP_SECURE", "false").lower() == "true"
|
||||
WEAVIATE_GRPC_SECURE = os.environ.get("WEAVIATE_GRPC_SECURE", "false").lower() == "true"
|
||||
WEAVIATE_SKIP_INIT_CHECKS = os.environ.get("WEAVIATE_SKIP_INIT_CHECKS", "false").lower() == "true"
|
||||
|
||||
# OpenSearch
|
||||
OPENSEARCH_URI = os.environ.get("OPENSEARCH_URI", "https://localhost:9200")
|
||||
|
|
@ -2817,6 +2821,12 @@ PDF_EXTRACT_IMAGES = PersistentConfig(
|
|||
os.environ.get("PDF_EXTRACT_IMAGES", "False").lower() == "true",
|
||||
)
|
||||
|
||||
PDF_LOADER_MODE = PersistentConfig(
|
||||
"PDF_LOADER_MODE",
|
||||
"rag.pdf_loader_mode",
|
||||
os.environ.get("PDF_LOADER_MODE", "page"),
|
||||
)
|
||||
|
||||
RAG_EMBEDDING_MODEL = PersistentConfig(
|
||||
"RAG_EMBEDDING_MODEL",
|
||||
"rag.embedding_model",
|
||||
|
|
@ -3412,6 +3422,24 @@ EXTERNAL_WEB_LOADER_API_KEY = PersistentConfig(
|
|||
os.environ.get("EXTERNAL_WEB_LOADER_API_KEY", ""),
|
||||
)
|
||||
|
||||
YANDEX_WEB_SEARCH_URL = PersistentConfig(
|
||||
"YANDEX_WEB_SEARCH_URL",
|
||||
"rag.web.search.yandex_web_search_url",
|
||||
os.environ.get("YANDEX_WEB_SEARCH_URL", ""),
|
||||
)
|
||||
|
||||
YANDEX_WEB_SEARCH_API_KEY = PersistentConfig(
|
||||
"YANDEX_WEB_SEARCH_API_KEY",
|
||||
"rag.web.search.yandex_web_search_api_key",
|
||||
os.environ.get("YANDEX_WEB_SEARCH_API_KEY", ""),
|
||||
)
|
||||
|
||||
YANDEX_WEB_SEARCH_CONFIG = PersistentConfig(
|
||||
"YANDEX_WEB_SEARCH_CONFIG",
|
||||
"rag.web.search.yandex_web_search_config",
|
||||
os.environ.get("YANDEX_WEB_SEARCH_CONFIG", ""),
|
||||
)
|
||||
|
||||
####################################
|
||||
# Images
|
||||
####################################
|
||||
|
|
|
|||
|
|
@ -199,6 +199,8 @@ ENABLE_STAR_SESSIONS_MIDDLEWARE = (
|
|||
os.environ.get("ENABLE_STAR_SESSIONS_MIDDLEWARE", "False").lower() == "true"
|
||||
)
|
||||
|
||||
ENABLE_EASTER_EGGS = os.environ.get("ENABLE_EASTER_EGGS", "True").lower() == "true"
|
||||
|
||||
####################################
|
||||
# WEBUI_BUILD_HASH
|
||||
####################################
|
||||
|
|
@ -390,6 +392,22 @@ try:
|
|||
REDIS_SOCKET_CONNECT_TIMEOUT = float(REDIS_SOCKET_CONNECT_TIMEOUT)
|
||||
except ValueError:
|
||||
REDIS_SOCKET_CONNECT_TIMEOUT = None
|
||||
|
||||
REDIS_RECONNECT_DELAY = os.environ.get(
|
||||
"REDIS_RECONNECT_DELAY", ""
|
||||
)
|
||||
|
||||
if REDIS_RECONNECT_DELAY == "":
|
||||
REDIS_RECONNECT_DELAY = None
|
||||
else:
|
||||
try:
|
||||
REDIS_RECONNECT_DELAY = float(
|
||||
REDIS_RECONNECT_DELAY
|
||||
)
|
||||
if REDIS_RECONNECT_DELAY < 0:
|
||||
REDIS_RECONNECT_DELAY = None
|
||||
except Exception:
|
||||
REDIS_RECONNECT_DELAY = None
|
||||
|
||||
####################################
|
||||
# UVICORN WORKERS
|
||||
|
|
@ -455,6 +473,8 @@ except Exception as e:
|
|||
r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$"
|
||||
)
|
||||
|
||||
PASSWORD_VALIDATION_HINT = os.environ.get("PASSWORD_VALIDATION_HINT", "")
|
||||
|
||||
|
||||
BYPASS_MODEL_ACCESS_CONTROL = (
|
||||
os.environ.get("BYPASS_MODEL_ACCESS_CONTROL", "False").lower() == "true"
|
||||
|
|
@ -519,6 +539,12 @@ OAUTH_SESSION_TOKEN_ENCRYPTION_KEY = os.environ.get(
|
|||
"OAUTH_SESSION_TOKEN_ENCRYPTION_KEY", WEBUI_SECRET_KEY
|
||||
)
|
||||
|
||||
# Token Exchange Configuration
|
||||
# Allows external apps to exchange OAuth tokens for OpenWebUI tokens
|
||||
ENABLE_OAUTH_TOKEN_EXCHANGE = (
|
||||
os.environ.get("ENABLE_OAUTH_TOKEN_EXCHANGE", "False").lower() == "true"
|
||||
)
|
||||
|
||||
####################################
|
||||
# SCIM Configuration
|
||||
####################################
|
||||
|
|
@ -673,7 +699,11 @@ WEBSOCKET_SERVER_LOGGING = (
|
|||
os.environ.get("WEBSOCKET_SERVER_LOGGING", "False").lower() == "true"
|
||||
)
|
||||
WEBSOCKET_SERVER_ENGINEIO_LOGGING = (
|
||||
os.environ.get("WEBSOCKET_SERVER_LOGGING", "False").lower() == "true"
|
||||
os.environ.get(
|
||||
"WEBSOCKET_SERVER_ENGINEIO_LOGGING",
|
||||
os.environ.get("WEBSOCKET_SERVER_LOGGING", "False"),
|
||||
).lower()
|
||||
== "true"
|
||||
)
|
||||
WEBSOCKET_SERVER_PING_TIMEOUT = os.environ.get("WEBSOCKET_SERVER_PING_TIMEOUT", "20")
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -290,6 +290,7 @@ from open_webui.config import (
|
|||
ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER,
|
||||
TIKTOKEN_ENCODING_NAME,
|
||||
PDF_EXTRACT_IMAGES,
|
||||
PDF_LOADER_MODE,
|
||||
YOUTUBE_LOADER_LANGUAGE,
|
||||
YOUTUBE_LOADER_PROXY_URL,
|
||||
# Retrieval (Web Search)
|
||||
|
|
@ -354,6 +355,9 @@ from open_webui.config import (
|
|||
EXTERNAL_WEB_SEARCH_API_KEY,
|
||||
EXTERNAL_WEB_LOADER_URL,
|
||||
EXTERNAL_WEB_LOADER_API_KEY,
|
||||
YANDEX_WEB_SEARCH_URL,
|
||||
YANDEX_WEB_SEARCH_API_KEY,
|
||||
YANDEX_WEB_SEARCH_CONFIG,
|
||||
# WebUI
|
||||
WEBUI_AUTH,
|
||||
WEBUI_NAME,
|
||||
|
|
@ -492,6 +496,7 @@ from open_webui.env import (
|
|||
WEBUI_ADMIN_EMAIL,
|
||||
WEBUI_ADMIN_PASSWORD,
|
||||
WEBUI_ADMIN_NAME,
|
||||
ENABLE_EASTER_EGGS,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -948,6 +953,7 @@ app.state.config.RAG_OLLAMA_BASE_URL = RAG_OLLAMA_BASE_URL
|
|||
app.state.config.RAG_OLLAMA_API_KEY = RAG_OLLAMA_API_KEY
|
||||
|
||||
app.state.config.PDF_EXTRACT_IMAGES = PDF_EXTRACT_IMAGES
|
||||
app.state.config.PDF_LOADER_MODE = PDF_LOADER_MODE
|
||||
|
||||
app.state.config.YOUTUBE_LOADER_LANGUAGE = YOUTUBE_LOADER_LANGUAGE
|
||||
app.state.config.YOUTUBE_LOADER_PROXY_URL = YOUTUBE_LOADER_PROXY_URL
|
||||
|
|
@ -1009,6 +1015,9 @@ app.state.config.EXTERNAL_WEB_SEARCH_URL = EXTERNAL_WEB_SEARCH_URL
|
|||
app.state.config.EXTERNAL_WEB_SEARCH_API_KEY = EXTERNAL_WEB_SEARCH_API_KEY
|
||||
app.state.config.EXTERNAL_WEB_LOADER_URL = EXTERNAL_WEB_LOADER_URL
|
||||
app.state.config.EXTERNAL_WEB_LOADER_API_KEY = EXTERNAL_WEB_LOADER_API_KEY
|
||||
app.state.config.YANDEX_WEB_SEARCH_URL = YANDEX_WEB_SEARCH_URL
|
||||
app.state.config.YANDEX_WEB_SEARCH_API_KEY = YANDEX_WEB_SEARCH_API_KEY
|
||||
app.state.config.YANDEX_WEB_SEARCH_CONFIG = YANDEX_WEB_SEARCH_CONFIG
|
||||
|
||||
|
||||
app.state.config.PLAYWRIGHT_WS_URL = PLAYWRIGHT_WS_URL
|
||||
|
|
@ -1370,6 +1379,13 @@ async def check_url(request: Request, call_next):
|
|||
request.state.token = get_http_authorization_cred(
|
||||
request.headers.get("Authorization")
|
||||
)
|
||||
# Fallback to cookie token for browser sessions
|
||||
if request.state.token is None and request.cookies.get("token"):
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
request.state.token = HTTPAuthorizationCredentials(
|
||||
scheme="Bearer",
|
||||
credentials=request.cookies.get("token")
|
||||
)
|
||||
|
||||
request.state.enable_api_keys = app.state.config.ENABLE_API_KEYS
|
||||
response = await call_next(request)
|
||||
|
|
@ -1934,6 +1950,7 @@ async def get_app_config(request: Request):
|
|||
"enable_websocket": ENABLE_WEBSOCKET_SUPPORT,
|
||||
"enable_version_update_check": ENABLE_VERSION_UPDATE_CHECK,
|
||||
"enable_public_active_users_count": ENABLE_PUBLIC_ACTIVE_USERS_COUNT,
|
||||
"enable_easter_eggs": ENABLE_EASTER_EGGS,
|
||||
**(
|
||||
{
|
||||
"enable_direct_connections": app.state.config.ENABLE_DIRECT_CONNECTIONS,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
|
|
@ -4,7 +4,7 @@ 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.users import UserModel, UserProfileImageResponse, Users
|
||||
from open_webui.models.users import User, UserModel, UserProfileImageResponse, Users
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import Boolean, Column, String, Text
|
||||
|
||||
|
|
@ -155,10 +155,17 @@ class AuthsTable:
|
|||
log.info(f"authenticate_user_by_email: {email}")
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
auth = db.query(Auth).filter_by(email=email, active=True).first()
|
||||
if auth:
|
||||
user = Users.get_user_by_id(auth.id, db=db)
|
||||
return user
|
||||
# Single JOIN query instead of two separate queries
|
||||
result = (
|
||||
db.query(Auth, User)
|
||||
.join(User, Auth.id == User.id)
|
||||
.filter(Auth.email == email, Auth.active == True)
|
||||
.first()
|
||||
)
|
||||
if result:
|
||||
_, user = result
|
||||
return UserModel.model_validate(user)
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -168,6 +168,14 @@ class ChatTitleIdResponse(BaseModel):
|
|||
created_at: int
|
||||
|
||||
|
||||
class SharedChatResponse(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
share_id: Optional[str] = None
|
||||
updated_at: int
|
||||
created_at: int
|
||||
|
||||
|
||||
class ChatListResponse(BaseModel):
|
||||
items: list[ChatModel]
|
||||
total: int
|
||||
|
|
@ -675,6 +683,49 @@ class ChatTable:
|
|||
all_chats = query.all()
|
||||
return [ChatModel.model_validate(chat) for chat in all_chats]
|
||||
|
||||
def get_shared_chat_list_by_user_id(
|
||||
self,
|
||||
user_id: str,
|
||||
filter: Optional[dict] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
db: Optional[Session] = None,
|
||||
) -> list[ChatModel]:
|
||||
|
||||
with get_db_context(db) as db:
|
||||
query = db.query(Chat).filter_by(user_id=user_id).filter(
|
||||
Chat.share_id.isnot(None)
|
||||
)
|
||||
|
||||
if filter:
|
||||
query_key = filter.get("query")
|
||||
if query_key:
|
||||
query = query.filter(Chat.title.ilike(f"%{query_key}%"))
|
||||
|
||||
order_by = filter.get("order_by")
|
||||
direction = filter.get("direction")
|
||||
|
||||
if order_by and direction:
|
||||
if not getattr(Chat, order_by, None):
|
||||
raise ValueError("Invalid order_by field")
|
||||
|
||||
if direction.lower() == "asc":
|
||||
query = query.order_by(getattr(Chat, order_by).asc())
|
||||
elif direction.lower() == "desc":
|
||||
query = query.order_by(getattr(Chat, order_by).desc())
|
||||
else:
|
||||
raise ValueError("Invalid direction for ordering")
|
||||
else:
|
||||
query = query.order_by(Chat.updated_at.desc())
|
||||
|
||||
if skip:
|
||||
query = query.offset(skip)
|
||||
if limit:
|
||||
query = query.limit(limit)
|
||||
|
||||
all_chats = query.all()
|
||||
return [ChatModel.model_validate(chat) for chat in all_chats]
|
||||
|
||||
def get_chat_list_by_user_id(
|
||||
self,
|
||||
user_id: str,
|
||||
|
|
|
|||
|
|
@ -460,23 +460,15 @@ class FeedbackTable:
|
|||
self, user_id: str, db: Optional[Session] = None
|
||||
) -> bool:
|
||||
with get_db_context(db) as db:
|
||||
feedbacks = db.query(Feedback).filter_by(user_id=user_id).all()
|
||||
if not feedbacks:
|
||||
return False
|
||||
for feedback in feedbacks:
|
||||
db.delete(feedback)
|
||||
result = db.query(Feedback).filter_by(user_id=user_id).delete()
|
||||
db.commit()
|
||||
return True
|
||||
return result > 0
|
||||
|
||||
def delete_all_feedbacks(self, db: Optional[Session] = None) -> bool:
|
||||
with get_db_context(db) as db:
|
||||
feedbacks = db.query(Feedback).all()
|
||||
if not feedbacks:
|
||||
return False
|
||||
for feedback in feedbacks:
|
||||
db.delete(feedback)
|
||||
result = db.query(Feedback).delete()
|
||||
db.commit()
|
||||
return True
|
||||
return result > 0
|
||||
|
||||
|
||||
Feedbacks = FeedbackTable()
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from typing import Optional
|
|||
|
||||
from sqlalchemy.orm import Session
|
||||
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, model_validator
|
||||
from sqlalchemy import BigInteger, Column, String, Text, JSON
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -63,6 +63,25 @@ class FileMeta(BaseModel):
|
|||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def sanitize_meta(cls, data):
|
||||
"""Sanitize metadata fields to handle malformed legacy data."""
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
|
||||
# Handle content_type that may be a list like ['application/pdf', None]
|
||||
content_type = data.get("content_type")
|
||||
if isinstance(content_type, list):
|
||||
# Extract first non-None string value
|
||||
data["content_type"] = next(
|
||||
(item for item in content_type if isinstance(item, str)), None
|
||||
)
|
||||
elif content_type is not None and not isinstance(content_type, str):
|
||||
data["content_type"] = None
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class FileModelResponse(BaseModel):
|
||||
id: str
|
||||
|
|
@ -74,7 +93,7 @@ class FileModelResponse(BaseModel):
|
|||
meta: FileMeta
|
||||
|
||||
created_at: int # timestamp in epoch
|
||||
updated_at: int # timestamp in epoch
|
||||
updated_at: Optional[int] = None # timestamp in epoch, optional for legacy files
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
|
|
|||
|
|
@ -195,6 +195,26 @@ class FunctionsTable:
|
|||
except Exception:
|
||||
return None
|
||||
|
||||
def get_functions_by_ids(
|
||||
self, ids: list[str], db: Optional[Session] = None
|
||||
) -> list[FunctionModel]:
|
||||
"""
|
||||
Batch fetch multiple functions by their IDs in a single query.
|
||||
Returns functions in the same order as the input IDs (None entries filtered out).
|
||||
"""
|
||||
if not ids:
|
||||
return []
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
functions = db.query(Function).filter(Function.id.in_(ids)).all()
|
||||
# Create a dict for O(1) lookup
|
||||
func_dict = {f.id: FunctionModel.model_validate(f) for f in functions}
|
||||
# Return in original order, filtering out any not found
|
||||
return [func_dict[id] for id in ids if id in func_dict]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def get_functions(
|
||||
self, active_only=False, include_valves=False, db: Optional[Session] = None
|
||||
) -> list[FunctionModel | FunctionWithValvesModel]:
|
||||
|
|
@ -299,7 +319,7 @@ class FunctionsTable:
|
|||
function.updated_at = int(time.time())
|
||||
db.commit()
|
||||
db.refresh(function)
|
||||
return self.get_function_by_id(id, db=db)
|
||||
return FunctionModel.model_validate(function)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
|
@ -319,7 +339,7 @@ class FunctionsTable:
|
|||
function.updated_at = int(time.time())
|
||||
db.commit()
|
||||
db.refresh(function)
|
||||
return self.get_function_by_id(id, db=db)
|
||||
return FunctionModel.model_validate(function)
|
||||
else:
|
||||
return None
|
||||
except Exception as e:
|
||||
|
|
@ -381,7 +401,8 @@ class FunctionsTable:
|
|||
}
|
||||
)
|
||||
db.commit()
|
||||
return self.get_function_by_id(id, db=db)
|
||||
function = db.get(Function, id)
|
||||
return FunctionModel.model_validate(function) if function else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -589,11 +589,10 @@ class GroupTable:
|
|||
if not user_ids:
|
||||
return GroupModel.model_validate(group)
|
||||
|
||||
# Remove each user from group_member
|
||||
for user_id in user_ids:
|
||||
db.query(GroupMember).filter(
|
||||
GroupMember.group_id == id, GroupMember.user_id == user_id
|
||||
).delete()
|
||||
# Remove users from group_member in batch
|
||||
db.query(GroupMember).filter(
|
||||
GroupMember.group_id == id, GroupMember.user_id.in_(user_ids)
|
||||
).delete(synchronize_session=False)
|
||||
|
||||
# Update group timestamp
|
||||
group.updated_at = int(time.time())
|
||||
|
|
|
|||
|
|
@ -229,6 +229,9 @@ class KnowledgeTable:
|
|||
or_(
|
||||
Knowledge.name.ilike(f"%{query_key}%"),
|
||||
Knowledge.description.ilike(f"%{query_key}%"),
|
||||
User.name.ilike(f"%{query_key}%"),
|
||||
User.email.ilike(f"%{query_key}%"),
|
||||
User.username.ilike(f"%{query_key}%"),
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -240,7 +243,7 @@ class KnowledgeTable:
|
|||
|
||||
query = has_permission(db, Knowledge, query, filter)
|
||||
|
||||
query = query.order_by(Knowledge.updated_at.desc())
|
||||
query = query.order_by(Knowledge.updated_at.desc(), Knowledge.id.asc())
|
||||
|
||||
total = query.count()
|
||||
if skip:
|
||||
|
|
@ -300,7 +303,7 @@ class KnowledgeTable:
|
|||
query = query.filter(File.filename.ilike(f"%{q}%"))
|
||||
|
||||
# Order by file changes
|
||||
query = query.order_by(File.updated_at.desc())
|
||||
query = query.order_by(File.updated_at.desc(), File.id.asc())
|
||||
|
||||
# Count before pagination
|
||||
total = query.count()
|
||||
|
|
@ -427,6 +430,9 @@ class KnowledgeTable:
|
|||
.filter(KnowledgeFile.knowledge_id == knowledge_id)
|
||||
)
|
||||
|
||||
# Default sort: updated_at descending
|
||||
primary_sort = File.updated_at.desc()
|
||||
|
||||
if filter:
|
||||
query_key = filter.get("query")
|
||||
if query_key:
|
||||
|
|
@ -440,27 +446,17 @@ class KnowledgeTable:
|
|||
|
||||
order_by = filter.get("order_by")
|
||||
direction = filter.get("direction")
|
||||
is_asc = direction == "asc"
|
||||
|
||||
if order_by == "name":
|
||||
if direction == "asc":
|
||||
query = query.order_by(File.filename.asc())
|
||||
else:
|
||||
query = query.order_by(File.filename.desc())
|
||||
primary_sort = File.filename.asc() if is_asc else File.filename.desc()
|
||||
elif order_by == "created_at":
|
||||
if direction == "asc":
|
||||
query = query.order_by(File.created_at.asc())
|
||||
else:
|
||||
query = query.order_by(File.created_at.desc())
|
||||
primary_sort = File.created_at.asc() if is_asc else File.created_at.desc()
|
||||
elif order_by == "updated_at":
|
||||
if direction == "asc":
|
||||
query = query.order_by(File.updated_at.asc())
|
||||
else:
|
||||
query = query.order_by(File.updated_at.desc())
|
||||
else:
|
||||
query = query.order_by(File.updated_at.desc())
|
||||
primary_sort = File.updated_at.asc() if is_asc else File.updated_at.desc()
|
||||
|
||||
else:
|
||||
query = query.order_by(File.updated_at.desc())
|
||||
# Apply sort with secondary key for deterministic pagination
|
||||
query = query.order_by(primary_sort, File.id.asc())
|
||||
|
||||
# Count BEFORE pagination
|
||||
total = query.count()
|
||||
|
|
|
|||
|
|
@ -82,7 +82,8 @@ class MemoriesTable:
|
|||
memory.updated_at = int(time.time())
|
||||
|
||||
db.commit()
|
||||
return self.get_memory_by_id(id)
|
||||
db.refresh(memory)
|
||||
return MemoryModel.model_validate(memory)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -294,6 +294,9 @@ class ModelsTable:
|
|||
or_(
|
||||
Model.name.ilike(f"%{query_key}%"),
|
||||
Model.base_model_id.ilike(f"%{query_key}%"),
|
||||
User.name.ilike(f"%{query_key}%"),
|
||||
User.email.ilike(f"%{query_key}%"),
|
||||
User.username.ilike(f"%{query_key}%"),
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -391,17 +394,16 @@ class ModelsTable:
|
|||
) -> Optional[ModelModel]:
|
||||
with get_db_context(db) as db:
|
||||
try:
|
||||
is_active = db.query(Model).filter_by(id=id).first().is_active
|
||||
model = db.query(Model).filter_by(id=id).first()
|
||||
if not model:
|
||||
return None
|
||||
|
||||
db.query(Model).filter_by(id=id).update(
|
||||
{
|
||||
"is_active": not is_active,
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
)
|
||||
model.is_active = not model.is_active
|
||||
model.updated_at = int(time.time())
|
||||
db.commit()
|
||||
db.refresh(model)
|
||||
|
||||
return self.get_model_by_id(id, db=db)
|
||||
return ModelModel.model_validate(model)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
|
|
|||
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,21 @@
|
|||
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, or_, func, cast
|
||||
|
||||
|
||||
from open_webui.utils.access_control import has_access
|
||||
|
||||
|
||||
####################
|
||||
# Prompts DB Schema
|
||||
####################
|
||||
|
|
@ -19,11 +24,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 +56,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)
|
||||
|
||||
|
||||
|
|
@ -67,23 +86,50 @@ class PromptAccessResponse(PromptUserResponse):
|
|||
write_access: Optional[bool] = False
|
||||
|
||||
|
||||
class PromptListResponse(BaseModel):
|
||||
items: list[PromptUserResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class PromptAccessListResponse(BaseModel):
|
||||
items: list[PromptAccessResponse]
|
||||
total: int
|
||||
|
||||
|
||||
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 +138,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))
|
||||
|
||||
|
|
@ -147,17 +239,306 @@ class PromptsTable:
|
|||
or has_access(user_id, permission, prompt.access_control, user_group_ids)
|
||||
]
|
||||
|
||||
def search_prompts(
|
||||
self,
|
||||
user_id: str,
|
||||
filter: dict = {},
|
||||
skip: int = 0,
|
||||
limit: int = 30,
|
||||
db: Optional[Session] = None,
|
||||
) -> PromptListResponse:
|
||||
with get_db_context(db) as db:
|
||||
from open_webui.models.users import User, UserModel
|
||||
|
||||
# Join with User table for user filtering and sorting
|
||||
query = db.query(Prompt, User).outerjoin(User, User.id == Prompt.user_id)
|
||||
query = query.filter(Prompt.is_active == True)
|
||||
|
||||
if filter:
|
||||
query_key = filter.get("query")
|
||||
if query_key:
|
||||
query = query.filter(
|
||||
or_(
|
||||
Prompt.name.ilike(f"%{query_key}%"),
|
||||
Prompt.command.ilike(f"%{query_key}%"),
|
||||
Prompt.content.ilike(f"%{query_key}%"),
|
||||
User.name.ilike(f"%{query_key}%"),
|
||||
User.email.ilike(f"%{query_key}%"),
|
||||
)
|
||||
)
|
||||
|
||||
view_option = filter.get("view_option")
|
||||
if view_option == "created":
|
||||
query = query.filter(Prompt.user_id == user_id)
|
||||
elif view_option == "shared":
|
||||
query = query.filter(Prompt.user_id != user_id)
|
||||
|
||||
# Apply access control filtering
|
||||
group_ids = filter.get("group_ids", [])
|
||||
filter_user_id = filter.get("user_id")
|
||||
|
||||
if filter_user_id:
|
||||
# User must have access: owner OR public OR explicit access
|
||||
access_conditions = [
|
||||
Prompt.user_id == filter_user_id, # Owner
|
||||
Prompt.access_control == None, # Public
|
||||
]
|
||||
query = query.filter(or_(*access_conditions))
|
||||
|
||||
tag = filter.get("tag")
|
||||
if tag:
|
||||
# Search for tag in JSON array field
|
||||
like_pattern = f'%"{tag.lower()}"%'
|
||||
tags_text = func.lower(cast(Prompt.tags, String))
|
||||
query = query.filter(tags_text.like(like_pattern))
|
||||
|
||||
order_by = filter.get("order_by")
|
||||
direction = filter.get("direction")
|
||||
|
||||
if order_by == "name":
|
||||
if direction == "asc":
|
||||
query = query.order_by(Prompt.name.asc())
|
||||
else:
|
||||
query = query.order_by(Prompt.name.desc())
|
||||
elif order_by == "created_at":
|
||||
if direction == "asc":
|
||||
query = query.order_by(Prompt.created_at.asc())
|
||||
else:
|
||||
query = query.order_by(Prompt.created_at.desc())
|
||||
elif order_by == "updated_at":
|
||||
if direction == "asc":
|
||||
query = query.order_by(Prompt.updated_at.asc())
|
||||
else:
|
||||
query = query.order_by(Prompt.updated_at.desc())
|
||||
else:
|
||||
query = query.order_by(Prompt.updated_at.desc())
|
||||
else:
|
||||
query = query.order_by(Prompt.updated_at.desc())
|
||||
|
||||
# Count BEFORE pagination
|
||||
total = query.count()
|
||||
|
||||
if skip:
|
||||
query = query.offset(skip)
|
||||
if limit:
|
||||
query = query.limit(limit)
|
||||
|
||||
items = query.all()
|
||||
|
||||
prompts = []
|
||||
for prompt, user in items:
|
||||
prompts.append(
|
||||
PromptUserResponse(
|
||||
**PromptModel.model_validate(prompt).model_dump(),
|
||||
user=(
|
||||
UserResponse(**UserModel.model_validate(user).model_dump())
|
||||
if user
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return PromptListResponse(items=prompts, total=total)
|
||||
|
||||
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 +546,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()
|
||||
|
|
|
|||
|
|
@ -530,9 +530,12 @@ class UsersTable:
|
|||
) -> Optional[UserModel]:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
db.query(User).filter_by(id=id).update({"role": role})
|
||||
db.commit()
|
||||
user = db.query(User).filter_by(id=id).first()
|
||||
if not user:
|
||||
return None
|
||||
user.role = role
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return UserModel.model_validate(user)
|
||||
except Exception:
|
||||
return None
|
||||
|
|
@ -542,12 +545,13 @@ class UsersTable:
|
|||
) -> Optional[UserModel]:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
db.query(User).filter_by(id=id).update(
|
||||
{**form_data.model_dump(exclude_none=True)}
|
||||
)
|
||||
db.commit()
|
||||
|
||||
user = db.query(User).filter_by(id=id).first()
|
||||
if not user:
|
||||
return None
|
||||
for key, value in form_data.model_dump(exclude_none=True).items():
|
||||
setattr(user, key, value)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return UserModel.model_validate(user)
|
||||
except Exception:
|
||||
return None
|
||||
|
|
@ -557,12 +561,12 @@ class UsersTable:
|
|||
) -> Optional[UserModel]:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
db.query(User).filter_by(id=id).update(
|
||||
{"profile_image_url": profile_image_url}
|
||||
)
|
||||
db.commit()
|
||||
|
||||
user = db.query(User).filter_by(id=id).first()
|
||||
if not user:
|
||||
return None
|
||||
user.profile_image_url = profile_image_url
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return UserModel.model_validate(user)
|
||||
except Exception:
|
||||
return None
|
||||
|
|
@ -573,12 +577,12 @@ class UsersTable:
|
|||
) -> Optional[UserModel]:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
db.query(User).filter_by(id=id).update(
|
||||
{"last_active_at": int(time.time())}
|
||||
)
|
||||
db.commit()
|
||||
|
||||
user = db.query(User).filter_by(id=id).first()
|
||||
if not user:
|
||||
return None
|
||||
user.last_active_at = int(time.time())
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return UserModel.model_validate(user)
|
||||
except Exception:
|
||||
return None
|
||||
|
|
@ -620,12 +624,14 @@ class UsersTable:
|
|||
) -> Optional[UserModel]:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
db.query(User).filter_by(id=id).update(updated)
|
||||
db.commit()
|
||||
|
||||
user = db.query(User).filter_by(id=id).first()
|
||||
if not user:
|
||||
return None
|
||||
for key, value in updated.items():
|
||||
setattr(user, key, value)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return UserModel.model_validate(user)
|
||||
# return UserModel(**user.dict())
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ class DoclingLoader:
|
|||
with open(self.file_path, "rb") as f:
|
||||
headers = {}
|
||||
if self.api_key:
|
||||
headers["X-Api-Key"] = f"Bearer {self.api_key}"
|
||||
headers["X-Api-Key"] = f"{self.api_key}"
|
||||
|
||||
r = requests.post(
|
||||
f"{self.url}/v1/convert/file",
|
||||
|
|
@ -363,7 +363,9 @@ class Loader:
|
|||
else:
|
||||
if file_ext == "pdf":
|
||||
loader = PyPDFLoader(
|
||||
file_path, extract_images=self.kwargs.get("PDF_EXTRACT_IMAGES")
|
||||
file_path,
|
||||
extract_images=self.kwargs.get("PDF_EXTRACT_IMAGES"),
|
||||
mode=self.kwargs.get("PDF_LOADER_MODE", "page"),
|
||||
)
|
||||
elif file_ext == "csv":
|
||||
loader = CSVLoader(file_path, autodetect_encoding=True)
|
||||
|
|
|
|||
|
|
@ -12,9 +12,13 @@ from open_webui.retrieval.vector.main import (
|
|||
from open_webui.retrieval.vector.utils import process_metadata
|
||||
from open_webui.config import (
|
||||
WEAVIATE_HTTP_HOST,
|
||||
WEAVIATE_GRPC_HOST,
|
||||
WEAVIATE_HTTP_PORT,
|
||||
WEAVIATE_GRPC_PORT,
|
||||
WEAVIATE_API_KEY,
|
||||
WEAVIATE_HTTP_SECURE,
|
||||
WEAVIATE_GRPC_SECURE,
|
||||
WEAVIATE_SKIP_INIT_CHECKS,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -52,9 +56,13 @@ class WeaviateClient(VectorDBBase):
|
|||
try:
|
||||
# Build connection parameters
|
||||
connection_params = {
|
||||
"host": WEAVIATE_HTTP_HOST,
|
||||
"port": WEAVIATE_HTTP_PORT,
|
||||
"http_host": WEAVIATE_HTTP_HOST,
|
||||
"http_port": WEAVIATE_HTTP_PORT,
|
||||
"http_secure": WEAVIATE_HTTP_SECURE,
|
||||
"grpc_host": WEAVIATE_GRPC_HOST,
|
||||
"grpc_port": WEAVIATE_GRPC_PORT,
|
||||
"grpc_secure": WEAVIATE_GRPC_SECURE,
|
||||
"skip_init_checks": WEAVIATE_SKIP_INIT_CHECKS,
|
||||
}
|
||||
|
||||
# Only add auth_credentials if WEAVIATE_API_KEY exists and is not empty
|
||||
|
|
@ -63,7 +71,7 @@ class WeaviateClient(VectorDBBase):
|
|||
weaviate.classes.init.Auth.api_key(WEAVIATE_API_KEY)
|
||||
)
|
||||
|
||||
self.client = weaviate.connect_to_local(**connection_params)
|
||||
self.client = weaviate.connect_to_custom(**connection_params)
|
||||
self.client.connect()
|
||||
except Exception as e:
|
||||
raise ConnectionError(f"Failed to connect to Weaviate: {e}") from e
|
||||
|
|
|
|||
147
backend/open_webui/retrieval/web/yandex.py
Normal file
147
backend/open_webui/retrieval/web/yandex.py
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import base64
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional, List
|
||||
|
||||
import requests
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||
from open_webui.utils.headers import include_user_info_headers
|
||||
|
||||
from xml.etree import ElementTree as ET
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def xml_element_contents_to_string(element: Element) -> str:
|
||||
buffer = [element.text if element.text else ""]
|
||||
|
||||
for child in element:
|
||||
buffer.append(xml_element_contents_to_string(child))
|
||||
|
||||
buffer.append(element.tail if element.tail else "")
|
||||
|
||||
return "".join(buffer)
|
||||
|
||||
|
||||
def search_yandex(
|
||||
request: Request,
|
||||
yandex_search_url: str,
|
||||
yandex_search_api_key: str,
|
||||
yandex_search_config: str,
|
||||
query: str,
|
||||
count: int,
|
||||
filter_list: Optional[List[str]] = None,
|
||||
user=None,
|
||||
) -> List[SearchResult]:
|
||||
try:
|
||||
headers = {
|
||||
"User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot",
|
||||
"Authorization": f"Api-Key {yandex_search_api_key}",
|
||||
}
|
||||
|
||||
if user is not None:
|
||||
headers = include_user_info_headers(headers, user)
|
||||
|
||||
chat_id = getattr(request.state, "chat_id", None)
|
||||
if chat_id:
|
||||
headers["X-OpenWebUI-Chat-Id"] = str(chat_id)
|
||||
|
||||
payload = {} if yandex_search_config == "" else json.loads(yandex_search_config)
|
||||
|
||||
if type(payload.get("query", None)) != dict:
|
||||
payload["query"] = {}
|
||||
|
||||
if "searchType" not in payload["query"]:
|
||||
payload["query"]["searchType"] = "SEARCH_TYPE_RU"
|
||||
|
||||
payload["query"]["queryText"] = query
|
||||
|
||||
if type(payload.get("groupSpec", None)) != dict:
|
||||
payload["groupSpec"] = {}
|
||||
|
||||
if "groupMode" not in payload["groupSpec"]:
|
||||
payload["groupSpec"]["groupMode"] = "GROUP_MODE_DEEP"
|
||||
|
||||
payload["groupSpec"]["groupsOnPage"] = count
|
||||
payload["groupSpec"]["docsInGroup"] = 1
|
||||
|
||||
response = requests.post(
|
||||
"https://searchapi.api.cloud.yandex.net/v2/web/search" if yandex_search_url == "" else yandex_search_url,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
response_body = response.json()
|
||||
if "rawData" not in response_body:
|
||||
raise Exception(f"No `rawData` in response body: {response_body}")
|
||||
|
||||
search_result_body_bytes = base64.decodebytes(bytes(response_body["rawData"], "utf-8"))
|
||||
|
||||
doc_root = ET.parse(io.BytesIO(search_result_body_bytes))
|
||||
|
||||
results = []
|
||||
|
||||
for group in doc_root.findall("response/results/grouping/group"):
|
||||
results.append({
|
||||
"url": xml_element_contents_to_string(group.find("doc/url")).strip("\n"),
|
||||
"title": xml_element_contents_to_string(group.find("doc/title")).strip("\n"),
|
||||
"snippet": xml_element_contents_to_string(group.find("doc/passages/passage")),
|
||||
})
|
||||
|
||||
results = get_filtered_results(results, filter_list)
|
||||
|
||||
results = [
|
||||
SearchResult(
|
||||
link=result.get("url"),
|
||||
title=result.get("title"),
|
||||
snippet=result.get("snippet"),
|
||||
)
|
||||
for result in results[:count]
|
||||
]
|
||||
|
||||
log.info(f"Yandex search results: {results}")
|
||||
|
||||
return results
|
||||
except Exception as e:
|
||||
log.error(f"Error in search: {e}")
|
||||
|
||||
return []
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from starlette.datastructures import Headers
|
||||
from fastapi import FastAPI
|
||||
|
||||
result = search_yandex(
|
||||
Request(
|
||||
{
|
||||
"type": "http",
|
||||
"asgi.version": "3.0",
|
||||
"asgi.spec_version": "2.0",
|
||||
"method": "GET",
|
||||
"path": "/internal",
|
||||
"query_string": b"",
|
||||
"headers": Headers({}).raw,
|
||||
"client": ("127.0.0.1", 12345),
|
||||
"server": ("127.0.0.1", 80),
|
||||
"scheme": "http",
|
||||
"app": FastAPI(),
|
||||
},
|
||||
None,
|
||||
),
|
||||
os.environ.get("YANDEX_WEB_SEARCH_URL", ""),
|
||||
os.environ.get("YANDEX_WEB_SEARCH_API_KEY", ""),
|
||||
os.environ.get("YANDEX_WEB_SEARCH_CONFIG", "{\"query\": {\"searchType\": \"SEARCH_TYPE_COM\"}}"),
|
||||
"TOP movies of the past year",
|
||||
3,
|
||||
)
|
||||
|
||||
print(result)
|
||||
|
|
@ -37,6 +37,8 @@ from open_webui.env import (
|
|||
WEBUI_AUTH_COOKIE_SECURE,
|
||||
WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
|
||||
ENABLE_INITIAL_ADMIN_SIGNUP,
|
||||
ENABLE_OAUTH_TOKEN_EXCHANGE,
|
||||
AIOHTTP_CLIENT_SESSION_SSL,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import RedirectResponse, Response, JSONResponse
|
||||
|
|
@ -45,6 +47,8 @@ from open_webui.config import (
|
|||
ENABLE_OAUTH_SIGNUP,
|
||||
ENABLE_LDAP,
|
||||
ENABLE_PASSWORD_AUTH,
|
||||
OAUTH_PROVIDERS,
|
||||
OAUTH_MERGE_ACCOUNTS_BY_EMAIL,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
|
@ -87,6 +91,63 @@ signin_rate_limiter = RateLimiter(
|
|||
redis_client=get_redis_client(), limit=5 * 3, window=60 * 3
|
||||
)
|
||||
|
||||
|
||||
def create_session_response(
|
||||
request: Request, user, db, response: Response = None, set_cookie: bool = False
|
||||
) -> dict:
|
||||
"""
|
||||
Create JWT token and build session response for a user.
|
||||
Shared helper for signin, signup, ldap_auth, add_user, and token_exchange endpoints.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object
|
||||
user: User object
|
||||
db: Database session
|
||||
response: FastAPI response object (required if set_cookie is True)
|
||||
set_cookie: Whether to set the auth cookie on the response
|
||||
"""
|
||||
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
|
||||
expires_at = None
|
||||
if expires_delta:
|
||||
expires_at = int(time.time()) + int(expires_delta.total_seconds())
|
||||
|
||||
token = create_token(
|
||||
data={"id": user.id},
|
||||
expires_delta=expires_delta,
|
||||
)
|
||||
|
||||
if set_cookie and response:
|
||||
datetime_expires_at = (
|
||||
datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc)
|
||||
if expires_at
|
||||
else None
|
||||
)
|
||||
response.set_cookie(
|
||||
key="token",
|
||||
value=token,
|
||||
expires=datetime_expires_at,
|
||||
httponly=True,
|
||||
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
|
||||
secure=WEBUI_AUTH_COOKIE_SECURE,
|
||||
)
|
||||
|
||||
user_permissions = get_permissions(
|
||||
user.id, request.app.state.config.USER_PERMISSIONS, db=db
|
||||
)
|
||||
|
||||
return {
|
||||
"token": token,
|
||||
"token_type": "Bearer",
|
||||
"expires_at": expires_at,
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
"profile_image_url": user.profile_image_url,
|
||||
"permissions": user_permissions,
|
||||
}
|
||||
|
||||
|
||||
############################
|
||||
# GetSessionUser
|
||||
############################
|
||||
|
|
@ -482,36 +543,6 @@ async def ldap_auth(
|
|||
user = Auths.authenticate_user_by_email(email, db=db)
|
||||
|
||||
if user:
|
||||
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
|
||||
expires_at = None
|
||||
if expires_delta:
|
||||
expires_at = int(time.time()) + int(expires_delta.total_seconds())
|
||||
|
||||
token = create_token(
|
||||
data={"id": user.id},
|
||||
expires_delta=expires_delta,
|
||||
)
|
||||
|
||||
# Set the cookie token
|
||||
response.set_cookie(
|
||||
key="token",
|
||||
value=token,
|
||||
expires=(
|
||||
datetime.datetime.fromtimestamp(
|
||||
expires_at, datetime.timezone.utc
|
||||
)
|
||||
if expires_at
|
||||
else None
|
||||
),
|
||||
httponly=True, # Ensures the cookie is not accessible via JavaScript
|
||||
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
|
||||
secure=WEBUI_AUTH_COOKIE_SECURE,
|
||||
)
|
||||
|
||||
user_permissions = get_permissions(
|
||||
user.id, request.app.state.config.USER_PERMISSIONS, db=db
|
||||
)
|
||||
|
||||
if (
|
||||
user.role != "admin"
|
||||
and ENABLE_LDAP_GROUP_MANAGEMENT
|
||||
|
|
@ -527,17 +558,7 @@ async def ldap_auth(
|
|||
except Exception as e:
|
||||
log.error(f"Failed to sync groups for user {user.id}: {e}")
|
||||
|
||||
return {
|
||||
"token": token,
|
||||
"token_type": "Bearer",
|
||||
"expires_at": expires_at,
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
"profile_image_url": user.profile_image_url,
|
||||
"permissions": user_permissions,
|
||||
}
|
||||
return create_session_response(request, user, db, response, set_cookie=True)
|
||||
else:
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
else:
|
||||
|
|
@ -646,48 +667,7 @@ async def signin(
|
|||
)
|
||||
|
||||
if user:
|
||||
|
||||
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
|
||||
expires_at = None
|
||||
if expires_delta:
|
||||
expires_at = int(time.time()) + int(expires_delta.total_seconds())
|
||||
|
||||
token = create_token(
|
||||
data={"id": user.id},
|
||||
expires_delta=expires_delta,
|
||||
)
|
||||
|
||||
datetime_expires_at = (
|
||||
datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc)
|
||||
if expires_at
|
||||
else None
|
||||
)
|
||||
|
||||
# Set the cookie token
|
||||
response.set_cookie(
|
||||
key="token",
|
||||
value=token,
|
||||
expires=datetime_expires_at,
|
||||
httponly=True, # Ensures the cookie is not accessible via JavaScript
|
||||
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
|
||||
secure=WEBUI_AUTH_COOKIE_SECURE,
|
||||
)
|
||||
|
||||
user_permissions = get_permissions(
|
||||
user.id, request.app.state.config.USER_PERMISSIONS, db=db
|
||||
)
|
||||
|
||||
return {
|
||||
"token": token,
|
||||
"token_type": "Bearer",
|
||||
"expires_at": expires_at,
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
"profile_image_url": user.profile_image_url,
|
||||
"permissions": user_permissions,
|
||||
}
|
||||
return create_session_response(request, user, db, response, set_cookie=True)
|
||||
else:
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
|
||||
|
|
@ -748,32 +728,6 @@ async def signup(
|
|||
)
|
||||
|
||||
if user:
|
||||
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
|
||||
expires_at = None
|
||||
if expires_delta:
|
||||
expires_at = int(time.time()) + int(expires_delta.total_seconds())
|
||||
|
||||
token = create_token(
|
||||
data={"id": user.id},
|
||||
expires_delta=expires_delta,
|
||||
)
|
||||
|
||||
datetime_expires_at = (
|
||||
datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc)
|
||||
if expires_at
|
||||
else None
|
||||
)
|
||||
|
||||
# Set the cookie token
|
||||
response.set_cookie(
|
||||
key="token",
|
||||
value=token,
|
||||
expires=datetime_expires_at,
|
||||
httponly=True, # Ensures the cookie is not accessible via JavaScript
|
||||
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
|
||||
secure=WEBUI_AUTH_COOKIE_SECURE,
|
||||
)
|
||||
|
||||
if request.app.state.config.WEBHOOK_URL:
|
||||
await post_webhook(
|
||||
request.app.state.WEBUI_NAME,
|
||||
|
|
@ -786,10 +740,6 @@ async def signup(
|
|||
},
|
||||
)
|
||||
|
||||
user_permissions = get_permissions(
|
||||
user.id, request.app.state.config.USER_PERMISSIONS, db=db
|
||||
)
|
||||
|
||||
if not has_users:
|
||||
# Disable signup after the first user is created
|
||||
request.app.state.config.ENABLE_SIGNUP = False
|
||||
|
|
@ -800,19 +750,11 @@ async def signup(
|
|||
db=db,
|
||||
)
|
||||
|
||||
return {
|
||||
"token": token,
|
||||
"token_type": "Bearer",
|
||||
"expires_at": expires_at,
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
"profile_image_url": user.profile_image_url,
|
||||
"permissions": user_permissions,
|
||||
}
|
||||
return create_session_response(request, user, db, response, set_cookie=True)
|
||||
else:
|
||||
raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as err:
|
||||
log.error(f"Signup error: {str(err)}")
|
||||
raise HTTPException(500, detail="An internal error occurred during signup.")
|
||||
|
|
@ -954,6 +896,8 @@ async def add_user(
|
|||
}
|
||||
else:
|
||||
raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as err:
|
||||
log.error(f"Add user error: {str(err)}")
|
||||
raise HTTPException(
|
||||
|
|
@ -1283,3 +1227,108 @@ async def get_api_key(
|
|||
}
|
||||
else:
|
||||
raise HTTPException(404, detail=ERROR_MESSAGES.API_KEY_NOT_FOUND)
|
||||
|
||||
|
||||
############################
|
||||
# Token Exchange
|
||||
############################
|
||||
|
||||
|
||||
class TokenExchangeForm(BaseModel):
|
||||
token: str # OAuth access token from external provider
|
||||
|
||||
|
||||
@router.post("/oauth/{provider}/token/exchange", response_model=SessionUserResponse)
|
||||
async def token_exchange(
|
||||
request: Request,
|
||||
response: Response,
|
||||
provider: str,
|
||||
form_data: TokenExchangeForm,
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
"""
|
||||
Exchange an external OAuth provider token for an OpenWebUI JWT.
|
||||
This endpoint is disabled by default. Set ENABLE_OAUTH_TOKEN_EXCHANGE=True to enable.
|
||||
"""
|
||||
if not ENABLE_OAUTH_TOKEN_EXCHANGE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Token exchange is disabled",
|
||||
)
|
||||
|
||||
provider = provider.lower()
|
||||
|
||||
# Check if provider is configured
|
||||
if provider not in OAUTH_PROVIDERS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Provider '{provider}' is not configured",
|
||||
)
|
||||
# Get the OAuth client for this provider
|
||||
oauth_manager = request.app.state.oauth_manager
|
||||
client = oauth_manager.get_client(provider)
|
||||
if not client:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"OAuth client for '{provider}' not found",
|
||||
)
|
||||
|
||||
# Validate the token by calling the userinfo endpoint
|
||||
try:
|
||||
token_data = {"access_token": form_data.token, "token_type": "Bearer"}
|
||||
user_data = await client.userinfo(token=token_data)
|
||||
|
||||
if not user_data:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid token or unable to fetch user info",
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(f"Token exchange failed for provider {provider}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid token or unable to validate with provider",
|
||||
)
|
||||
|
||||
# Extract user information from the token claims
|
||||
email_claim = request.app.state.config.OAUTH_EMAIL_CLAIM
|
||||
username_claim = request.app.state.config.OAUTH_USERNAME_CLAIM
|
||||
|
||||
# Get sub claim
|
||||
sub = user_data.get(
|
||||
request.app.state.config.OAUTH_SUB_CLAIM
|
||||
or OAUTH_PROVIDERS[provider].get("sub_claim", "sub")
|
||||
)
|
||||
if not sub:
|
||||
log.warning(f"Token exchange failed: sub claim missing from user data")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Token missing required 'sub' claim",
|
||||
)
|
||||
|
||||
email = user_data.get(email_claim, "")
|
||||
if not email:
|
||||
log.warning(f"Token exchange failed: email claim missing from user data")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Token missing required email claim",
|
||||
)
|
||||
email = email.lower()
|
||||
|
||||
# Try to find the user by OAuth sub
|
||||
user = Users.get_user_by_oauth_sub(provider, sub, db=db)
|
||||
|
||||
if not user and OAUTH_MERGE_ACCOUNTS_BY_EMAIL.value:
|
||||
# Try to find by email if merge is enabled
|
||||
user = Users.get_user_by_email(email, db=db)
|
||||
if user:
|
||||
# Link the OAuth sub to this user
|
||||
Users.update_user_oauth_by_id(user.id, provider, sub, db=db)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User not found. Please sign in via the web interface first.",
|
||||
)
|
||||
|
||||
return create_session_response(request, user, db)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from open_webui.models.users import (
|
|||
UserListResponse,
|
||||
UserModelResponse,
|
||||
Users,
|
||||
UserModel,
|
||||
UserNameResponse,
|
||||
)
|
||||
|
||||
|
|
@ -80,7 +81,7 @@ router = APIRouter()
|
|||
############################
|
||||
|
||||
|
||||
def check_channels_access(request: Request):
|
||||
def check_channels_access(request: Request, user: Optional[UserModel] = None):
|
||||
"""Dependency to ensure channels are globally enabled."""
|
||||
if not request.app.state.config.ENABLE_CHANNELS:
|
||||
raise HTTPException(
|
||||
|
|
@ -88,6 +89,15 @@ def check_channels_access(request: Request):
|
|||
detail="Channels are not enabled",
|
||||
)
|
||||
|
||||
if user:
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id, "features.channels", request.app.state.config.USER_PERMISSIONS
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# GetChatList
|
||||
|
|
@ -355,7 +365,7 @@ async def get_channel_by_id(
|
|||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
check_channels_access(request)
|
||||
check_channels_access(request, user)
|
||||
channel = Channels.get_channel_by_id(id, db=db)
|
||||
if not channel:
|
||||
raise HTTPException(
|
||||
|
|
@ -467,7 +477,7 @@ async def get_channel_members_by_id(
|
|||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
check_channels_access(request)
|
||||
check_channels_access(request, user)
|
||||
|
||||
channel = Channels.get_channel_by_id(id, db=db)
|
||||
if not channel:
|
||||
|
|
@ -788,7 +798,7 @@ async def get_channel_messages(
|
|||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
check_channels_access(request)
|
||||
check_channels_access(request, user)
|
||||
channel = Channels.get_channel_by_id(id, db=db)
|
||||
if not channel:
|
||||
raise HTTPException(
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from open_webui.models.chats import (
|
|||
ChatResponse,
|
||||
Chats,
|
||||
ChatTitleIdResponse,
|
||||
SharedChatResponse,
|
||||
ChatStatsExport,
|
||||
AggregateChatStats,
|
||||
ChatBody,
|
||||
|
|
@ -357,9 +358,7 @@ def _process_chat_for_export(chat) -> Optional[ChatStatsExport]:
|
|||
return None
|
||||
|
||||
|
||||
def calculate_chat_stats(
|
||||
user_id, skip=0, limit=10, filter=None, db: Optional[Session] = None
|
||||
):
|
||||
def calculate_chat_stats(user_id, skip=0, limit=10, filter=None):
|
||||
if filter is None:
|
||||
filter = {}
|
||||
|
||||
|
|
@ -368,7 +367,6 @@ def calculate_chat_stats(
|
|||
skip=skip,
|
||||
limit=limit,
|
||||
filter=filter,
|
||||
db=db,
|
||||
)
|
||||
|
||||
chat_stats_export_list = []
|
||||
|
|
@ -424,7 +422,6 @@ async def export_chat_stats(
|
|||
page: Optional[int] = 1,
|
||||
stream: bool = False,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
# Check if the user has permission to share/export chats
|
||||
if (user.role != "admin") and (
|
||||
|
|
@ -455,7 +452,7 @@ async def export_chat_stats(
|
|||
skip = (page - 1) * limit
|
||||
|
||||
chat_stats_export_list, total = await asyncio.to_thread(
|
||||
calculate_chat_stats, user.id, skip, limit, filter, db=db
|
||||
calculate_chat_stats, user.id, skip, limit, filter
|
||||
)
|
||||
|
||||
return ChatStatsExportList(
|
||||
|
|
@ -862,6 +859,48 @@ async def unarchive_all_chats(
|
|||
return Chats.unarchive_all_chats_by_user_id(user.id, db=db)
|
||||
|
||||
|
||||
############################
|
||||
# GetSharedChats
|
||||
############################
|
||||
|
||||
|
||||
@router.get("/shared", response_model=list[SharedChatResponse])
|
||||
async def get_shared_session_user_chat_list(
|
||||
page: Optional[int] = None,
|
||||
query: Optional[str] = None,
|
||||
order_by: Optional[str] = None,
|
||||
direction: Optional[str] = None,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
if page is None:
|
||||
page = 1
|
||||
|
||||
limit = 60
|
||||
skip = (page - 1) * limit
|
||||
|
||||
filter = {}
|
||||
if query:
|
||||
filter["query"] = query
|
||||
if order_by:
|
||||
filter["order_by"] = order_by
|
||||
if direction:
|
||||
filter["direction"] = direction
|
||||
|
||||
chat_list = [
|
||||
SharedChatResponse(**chat.model_dump())
|
||||
for chat in Chats.get_shared_chat_list_by_user_id(
|
||||
user.id,
|
||||
filter=filter,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
db=db,
|
||||
)
|
||||
]
|
||||
|
||||
return chat_list
|
||||
|
||||
|
||||
############################
|
||||
# GetSharedChatById
|
||||
############################
|
||||
|
|
|
|||
|
|
@ -224,7 +224,7 @@ async def verify_tool_servers_config(
|
|||
try:
|
||||
if form_data.type == "mcp":
|
||||
if form_data.auth_type == "oauth_2.1":
|
||||
discovery_urls = get_discovery_urls(form_data.url)
|
||||
discovery_urls = await get_discovery_urls(form_data.url)
|
||||
for discovery_url in discovery_urls:
|
||||
log.debug(
|
||||
f"Trying to fetch OAuth 2.1 discovery document from {discovery_url}"
|
||||
|
|
|
|||
|
|
@ -282,7 +282,11 @@ def upload_file_handler(
|
|||
},
|
||||
"meta": {
|
||||
"name": name,
|
||||
"content_type": file.content_type,
|
||||
"content_type": (
|
||||
file.content_type
|
||||
if isinstance(file.content_type, str)
|
||||
else None
|
||||
),
|
||||
"size": len(contents),
|
||||
"data": file_metadata,
|
||||
},
|
||||
|
|
@ -332,6 +336,8 @@ def upload_file_handler(
|
|||
detail=ERROR_MESSAGES.DEFAULT("Error uploading file"),
|
||||
)
|
||||
|
||||
except HTTPException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(
|
||||
|
|
@ -575,7 +581,7 @@ class ContentForm(BaseModel):
|
|||
|
||||
|
||||
@router.post("/{id}/data/content/update")
|
||||
async def update_file_data_content_by_id(
|
||||
def update_file_data_content_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
form_data: ContentForm,
|
||||
|
|
@ -825,6 +831,23 @@ async def delete_file_by_id(
|
|||
or has_access_to_file(id, "write", user, db=db)
|
||||
):
|
||||
|
||||
# Clean up KB associations and embeddings before deleting
|
||||
knowledges = Knowledges.get_knowledges_by_file_id(id, db=db)
|
||||
for knowledge in knowledges:
|
||||
# Remove KB-file relationship
|
||||
Knowledges.remove_file_from_knowledge_by_id(knowledge.id, id, db=db)
|
||||
# Clean KB embeddings (same logic as /knowledge/{id}/file/remove)
|
||||
try:
|
||||
VECTOR_DB_CLIENT.delete(
|
||||
collection_name=knowledge.id, filter={"file_id": id}
|
||||
)
|
||||
if file.hash:
|
||||
VECTOR_DB_CLIENT.delete(
|
||||
collection_name=knowledge.id, filter={"hash": file.hash}
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(f"KB embedding cleanup for {knowledge.id}: {e}")
|
||||
|
||||
result = Files.delete_file_by_id(id, db=db)
|
||||
if result:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from open_webui.utils.plugin import (
|
|||
load_function_module_by_id,
|
||||
replace_imports,
|
||||
get_function_module_from_cache,
|
||||
resolve_valves_schema_options,
|
||||
)
|
||||
from open_webui.config import CACHE_DIR
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
|
|
@ -446,7 +447,10 @@ async def get_function_valves_spec_by_id(
|
|||
|
||||
if hasattr(function_module, "Valves"):
|
||||
Valves = function_module.Valves
|
||||
return Valves.schema()
|
||||
schema = Valves.schema()
|
||||
# Resolve dynamic options for select dropdowns
|
||||
schema = resolve_valves_schema_options(Valves, schema, user)
|
||||
return schema
|
||||
return None
|
||||
else:
|
||||
raise HTTPException(
|
||||
|
|
@ -546,7 +550,10 @@ async def get_function_user_valves_spec_by_id(
|
|||
|
||||
if hasattr(function_module, "UserValves"):
|
||||
UserValves = function_module.UserValves
|
||||
return UserValves.schema()
|
||||
schema = UserValves.schema()
|
||||
# Resolve dynamic options for select dropdowns
|
||||
schema = resolve_valves_schema_options(UserValves, schema, user)
|
||||
return schema
|
||||
return None
|
||||
else:
|
||||
raise HTTPException(
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from fastapi.responses import FileResponse
|
|||
|
||||
from open_webui.config import CACHE_DIR
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.retrieval.web.utils import validate_url
|
||||
from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS
|
||||
|
||||
from open_webui.models.chats import Chats
|
||||
|
|
@ -881,6 +882,8 @@ async def image_edits(
|
|||
return data
|
||||
|
||||
if data.startswith("http://") or data.startswith("https://"):
|
||||
# Validate URL to prevent SSRF attacks against local/private networks
|
||||
validate_url(data)
|
||||
r = await asyncio.to_thread(requests.get, data)
|
||||
r.raise_for_status()
|
||||
|
||||
|
|
@ -910,7 +913,8 @@ async def image_edits(
|
|||
if isinstance(form_data.image, str):
|
||||
form_data.image = await load_url_image(form_data.image)
|
||||
elif isinstance(form_data.image, list):
|
||||
form_data.image = [await load_url_image(img) for img in form_data.image]
|
||||
# Load all images in parallel for better performance
|
||||
form_data.image = list(await asyncio.gather(*[load_url_image(img) for img in form_data.image]))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e))
|
||||
|
||||
|
|
|
|||
|
|
@ -5,18 +5,40 @@ from open_webui.models.prompts import (
|
|||
PromptForm,
|
||||
PromptUserResponse,
|
||||
PromptAccessResponse,
|
||||
PromptAccessListResponse,
|
||||
PromptModel,
|
||||
Prompts,
|
||||
)
|
||||
from open_webui.models.groups import Groups
|
||||
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()
|
||||
|
||||
PAGE_ITEM_COUNT = 30
|
||||
|
||||
|
||||
############################
|
||||
# GetPrompts
|
||||
############################
|
||||
|
|
@ -34,26 +56,72 @@ async def get_prompts(
|
|||
return prompts
|
||||
|
||||
|
||||
@router.get("/list", response_model=list[PromptAccessResponse])
|
||||
async def get_prompt_list(
|
||||
@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:
|
||||
prompts = Prompts.get_prompts(db=db)
|
||||
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))
|
||||
|
||||
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)
|
||||
),
|
||||
)
|
||||
for prompt in prompts
|
||||
]
|
||||
|
||||
@router.get("/list", response_model=PromptAccessListResponse)
|
||||
async def get_prompt_list(
|
||||
query: Optional[str] = None,
|
||||
view_option: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
order_by: Optional[str] = None,
|
||||
direction: Optional[str] = None,
|
||||
page: Optional[int] = 1,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
limit = PAGE_ITEM_COUNT
|
||||
|
||||
page = max(1, page)
|
||||
skip = (page - 1) * limit
|
||||
|
||||
filter = {}
|
||||
if query:
|
||||
filter["query"] = query
|
||||
if view_option:
|
||||
filter["view_option"] = view_option
|
||||
if tag:
|
||||
filter["tag"] = tag
|
||||
if order_by:
|
||||
filter["order_by"] = order_by
|
||||
if direction:
|
||||
filter["direction"] = direction
|
||||
|
||||
if not (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL):
|
||||
groups = Groups.get_groups_by_member_id(user.id, db=db)
|
||||
if groups:
|
||||
filter["group_ids"] = [group.id for group in groups]
|
||||
|
||||
filter["user_id"] = user.id
|
||||
|
||||
result = Prompts.search_prompts(user.id, filter=filter, skip=skip, limit=limit, db=db)
|
||||
|
||||
return PromptAccessListResponse(
|
||||
items=[
|
||||
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)
|
||||
),
|
||||
)
|
||||
for prompt in result.items
|
||||
],
|
||||
total=result.total,
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
|
|
@ -112,7 +180,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 +196,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 +266,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 +319,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
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ from langchain_core.documents import Document
|
|||
from open_webui.models.files import FileModel, FileUpdateForm, Files
|
||||
from open_webui.models.knowledge import Knowledges
|
||||
from open_webui.storage.provider import Storage
|
||||
from open_webui.internal.db import get_session
|
||||
from open_webui.internal.db import get_session, get_db
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
|
|
@ -76,6 +76,7 @@ from open_webui.retrieval.web.perplexity import search_perplexity
|
|||
from open_webui.retrieval.web.sougou import search_sougou
|
||||
from open_webui.retrieval.web.firecrawl import search_firecrawl
|
||||
from open_webui.retrieval.web.external import search_external
|
||||
from open_webui.retrieval.web.yandex import search_yandex
|
||||
|
||||
from open_webui.retrieval.utils import (
|
||||
get_content_from_url,
|
||||
|
|
@ -468,6 +469,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
|
|||
# Content extraction settings
|
||||
"CONTENT_EXTRACTION_ENGINE": request.app.state.config.CONTENT_EXTRACTION_ENGINE,
|
||||
"PDF_EXTRACT_IMAGES": request.app.state.config.PDF_EXTRACT_IMAGES,
|
||||
"PDF_LOADER_MODE": request.app.state.config.PDF_LOADER_MODE,
|
||||
"DATALAB_MARKER_API_KEY": request.app.state.config.DATALAB_MARKER_API_KEY,
|
||||
"DATALAB_MARKER_API_BASE_URL": request.app.state.config.DATALAB_MARKER_API_BASE_URL,
|
||||
"DATALAB_MARKER_ADDITIONAL_CONFIG": request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG,
|
||||
|
|
@ -579,6 +581,9 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
|
|||
"YOUTUBE_LOADER_LANGUAGE": request.app.state.config.YOUTUBE_LOADER_LANGUAGE,
|
||||
"YOUTUBE_LOADER_PROXY_URL": request.app.state.config.YOUTUBE_LOADER_PROXY_URL,
|
||||
"YOUTUBE_LOADER_TRANSLATION": request.app.state.YOUTUBE_LOADER_TRANSLATION,
|
||||
"YANDEX_WEB_SEARCH_URL": request.app.state.config.YANDEX_WEB_SEARCH_URL,
|
||||
"YANDEX_WEB_SEARCH_API_KEY": request.app.state.config.YANDEX_WEB_SEARCH_API_KEY,
|
||||
"YANDEX_WEB_SEARCH_CONFIG": request.app.state.config.YANDEX_WEB_SEARCH_CONFIG,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -642,6 +647,9 @@ class WebConfig(BaseModel):
|
|||
YOUTUBE_LOADER_LANGUAGE: Optional[List[str]] = None
|
||||
YOUTUBE_LOADER_PROXY_URL: Optional[str] = None
|
||||
YOUTUBE_LOADER_TRANSLATION: Optional[str] = None
|
||||
YANDEX_WEB_SEARCH_URL: Optional[str] = None
|
||||
YANDEX_WEB_SEARCH_API_KEY: Optional[str] = None
|
||||
YANDEX_WEB_SEARCH_CONFIG: Optional[str] = None
|
||||
|
||||
|
||||
class ConfigForm(BaseModel):
|
||||
|
|
@ -661,6 +669,7 @@ class ConfigForm(BaseModel):
|
|||
# Content extraction settings
|
||||
CONTENT_EXTRACTION_ENGINE: Optional[str] = None
|
||||
PDF_EXTRACT_IMAGES: Optional[bool] = None
|
||||
PDF_LOADER_MODE: Optional[str] = None
|
||||
|
||||
DATALAB_MARKER_API_KEY: Optional[str] = None
|
||||
DATALAB_MARKER_API_BASE_URL: Optional[str] = None
|
||||
|
|
@ -790,6 +799,11 @@ async def update_rag_config(
|
|||
if form_data.PDF_EXTRACT_IMAGES is not None
|
||||
else request.app.state.config.PDF_EXTRACT_IMAGES
|
||||
)
|
||||
request.app.state.config.PDF_LOADER_MODE = (
|
||||
form_data.PDF_LOADER_MODE
|
||||
if form_data.PDF_LOADER_MODE is not None
|
||||
else request.app.state.config.PDF_LOADER_MODE
|
||||
)
|
||||
request.app.state.config.DATALAB_MARKER_API_KEY = (
|
||||
form_data.DATALAB_MARKER_API_KEY
|
||||
if form_data.DATALAB_MARKER_API_KEY is not None
|
||||
|
|
@ -1020,6 +1034,11 @@ async def update_rag_config(
|
|||
if form_data.TEXT_SPLITTER is not None
|
||||
else request.app.state.config.TEXT_SPLITTER
|
||||
)
|
||||
request.app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER = (
|
||||
form_data.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER
|
||||
if form_data.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER is not None
|
||||
else request.app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER
|
||||
)
|
||||
request.app.state.config.CHUNK_SIZE = (
|
||||
form_data.CHUNK_SIZE
|
||||
if form_data.CHUNK_SIZE is not None
|
||||
|
|
@ -1178,6 +1197,15 @@ async def update_rag_config(
|
|||
request.app.state.YOUTUBE_LOADER_TRANSLATION = (
|
||||
form_data.web.YOUTUBE_LOADER_TRANSLATION
|
||||
)
|
||||
request.app.state.config.YANDEX_WEB_SEARCH_URL = (
|
||||
form_data.web.YANDEX_WEB_SEARCH_URL
|
||||
)
|
||||
request.app.state.config.YANDEX_WEB_SEARCH_API_KEY = (
|
||||
form_data.web.YANDEX_WEB_SEARCH_API_KEY
|
||||
)
|
||||
request.app.state.config.YANDEX_WEB_SEARCH_CONFIG = (
|
||||
form_data.web.YANDEX_WEB_SEARCH_CONFIG
|
||||
)
|
||||
|
||||
return {
|
||||
"status": True,
|
||||
|
|
@ -1194,6 +1222,7 @@ async def update_rag_config(
|
|||
# Content extraction settings
|
||||
"CONTENT_EXTRACTION_ENGINE": request.app.state.config.CONTENT_EXTRACTION_ENGINE,
|
||||
"PDF_EXTRACT_IMAGES": request.app.state.config.PDF_EXTRACT_IMAGES,
|
||||
"PDF_LOADER_MODE": request.app.state.config.PDF_LOADER_MODE,
|
||||
"DATALAB_MARKER_API_KEY": request.app.state.config.DATALAB_MARKER_API_KEY,
|
||||
"DATALAB_MARKER_API_BASE_URL": request.app.state.config.DATALAB_MARKER_API_BASE_URL,
|
||||
"DATALAB_MARKER_ADDITIONAL_CONFIG": request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG,
|
||||
|
|
@ -1303,6 +1332,9 @@ async def update_rag_config(
|
|||
"YOUTUBE_LOADER_LANGUAGE": request.app.state.config.YOUTUBE_LOADER_LANGUAGE,
|
||||
"YOUTUBE_LOADER_PROXY_URL": request.app.state.config.YOUTUBE_LOADER_PROXY_URL,
|
||||
"YOUTUBE_LOADER_TRANSLATION": request.app.state.YOUTUBE_LOADER_TRANSLATION,
|
||||
"YANDEX_WEB_SEARCH_URL": request.app.state.config.YANDEX_WEB_SEARCH_URL,
|
||||
"YANDEX_WEB_SEARCH_API_KEY": request.app.state.config.YANDEX_WEB_SEARCH_API_KEY,
|
||||
"YANDEX_WEB_SEARCH_CONFIG": request.app.state.config.YANDEX_WEB_SEARCH_CONFIG,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -1433,8 +1465,16 @@ def save_docs_to_vector_db(
|
|||
if result is not None and result.ids and len(result.ids) > 0:
|
||||
existing_doc_ids = result.ids[0]
|
||||
if existing_doc_ids:
|
||||
log.info(f"Document with hash {metadata['hash']} already exists")
|
||||
raise ValueError(ERROR_MESSAGES.DUPLICATE_CONTENT)
|
||||
# Check if the existing document belongs to the same file
|
||||
# If same file_id, this is a re-add/reindex - allow it
|
||||
# If different file_id, this is a duplicate - block it
|
||||
existing_file_id = None
|
||||
if result.metadatas and result.metadatas[0]:
|
||||
existing_file_id = result.metadatas[0][0].get("file_id")
|
||||
|
||||
if existing_file_id != metadata.get("file_id"):
|
||||
log.info(f"Document with hash {metadata['hash']} already exists")
|
||||
raise ValueError(ERROR_MESSAGES.DUPLICATE_CONTENT)
|
||||
|
||||
if split:
|
||||
if request.app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER:
|
||||
|
|
@ -1602,6 +1642,9 @@ def process_file(
|
|||
):
|
||||
"""
|
||||
Process a file and save its content to the vector database.
|
||||
Process a file and save its content to the vector database.
|
||||
Note: granular session management is used to prevent connection pool exhaustion.
|
||||
The session is committed before external API calls, and updates use a fresh session.
|
||||
"""
|
||||
if user.role == "admin":
|
||||
file = Files.get_file_by_id(form_data.file_id, db=db)
|
||||
|
|
@ -1765,6 +1808,12 @@ def process_file(
|
|||
}
|
||||
else:
|
||||
try:
|
||||
# Commit any pending changes before the slow embedding step.
|
||||
# Note: file is already a Pydantic model (not ORM), so no expunge needed.
|
||||
db.commit()
|
||||
|
||||
# External embedding API takes time (5-60s+).
|
||||
# Subsequent updates use fresh sessions via get_db().
|
||||
result = save_docs_to_vector_db(
|
||||
request,
|
||||
docs=docs,
|
||||
|
|
@ -1780,27 +1829,29 @@ def process_file(
|
|||
log.info(f"added {len(docs)} items to collection {collection_name}")
|
||||
|
||||
if result:
|
||||
Files.update_file_metadata_by_id(
|
||||
file.id,
|
||||
{
|
||||
# Fresh session for the final update.
|
||||
with get_db() as session:
|
||||
Files.update_file_metadata_by_id(
|
||||
file.id,
|
||||
{
|
||||
"collection_name": collection_name,
|
||||
},
|
||||
db=session,
|
||||
)
|
||||
|
||||
Files.update_file_data_by_id(
|
||||
file.id,
|
||||
{"status": "completed"},
|
||||
db=session,
|
||||
)
|
||||
Files.update_file_hash_by_id(file.id, hash, db=session)
|
||||
|
||||
return {
|
||||
"status": True,
|
||||
"collection_name": collection_name,
|
||||
},
|
||||
db=db,
|
||||
)
|
||||
|
||||
Files.update_file_data_by_id(
|
||||
file.id,
|
||||
{"status": "completed"},
|
||||
db=db,
|
||||
)
|
||||
Files.update_file_hash_by_id(file.id, hash, db=db)
|
||||
|
||||
return {
|
||||
"status": True,
|
||||
"collection_name": collection_name,
|
||||
"filename": file.filename,
|
||||
"content": text_content,
|
||||
}
|
||||
"filename": file.filename,
|
||||
"content": text_content,
|
||||
}
|
||||
else:
|
||||
raise Exception("Error saving document to vector database")
|
||||
except Exception as e:
|
||||
|
|
@ -1808,13 +1859,15 @@ def process_file(
|
|||
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
Files.update_file_data_by_id(
|
||||
file.id,
|
||||
{"status": "failed"},
|
||||
db=db,
|
||||
)
|
||||
# Clear the hash so the file can be re-uploaded after fixing the issue
|
||||
Files.update_file_hash_by_id(file.id, None, db=db)
|
||||
# Fresh session for error status update.
|
||||
with get_db() as session:
|
||||
Files.update_file_data_by_id(
|
||||
file.id,
|
||||
{"status": "failed"},
|
||||
db=session,
|
||||
)
|
||||
# Clear the hash so the file can be re-uploaded after fixing the issue
|
||||
Files.update_file_hash_by_id(file.id, None, db=session)
|
||||
|
||||
if "No pandoc was found" in str(e):
|
||||
raise HTTPException(
|
||||
|
|
@ -2224,6 +2277,17 @@ def search_web(
|
|||
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||
user=user,
|
||||
)
|
||||
elif engine == "yandex":
|
||||
return search_yandex(
|
||||
request,
|
||||
request.app.state.config.YANDEX_WEB_SEARCH_URL,
|
||||
request.app.state.config.YANDEX_WEB_SEARCH_API_KEY,
|
||||
request.app.state.config.YANDEX_WEB_SEARCH_CONFIG,
|
||||
query,
|
||||
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
|
||||
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||
user=user,
|
||||
)
|
||||
else:
|
||||
raise Exception("No search engine API key found in environment variables")
|
||||
|
||||
|
|
@ -2711,9 +2775,7 @@ async def process_files_batch(
|
|||
|
||||
# Update all files with collection name
|
||||
for file_update, file_result in zip(file_updates, file_results):
|
||||
Files.update_file_by_id(
|
||||
id=file_result.file_id, form_data=file_update
|
||||
)
|
||||
Files.update_file_by_id(id=file_result.file_id, form_data=file_update)
|
||||
file_result.status = "completed"
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -2723,7 +2785,9 @@ async def process_files_batch(
|
|||
for file_result in file_results:
|
||||
file_result.status = "failed"
|
||||
file_errors.append(
|
||||
BatchProcessFilesResult(file_id=file_result.file_id, error=str(e))
|
||||
BatchProcessFilesResult(
|
||||
file_id=file_result.file_id, status="failed", error=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
return BatchProcessFilesResponse(results=file_results, errors=file_errors)
|
||||
|
|
|
|||
|
|
@ -352,18 +352,17 @@ def user_to_scim(user: UserModel, request: Request, db=None) -> SCIMUser:
|
|||
def group_to_scim(group: GroupModel, request: Request, db=None) -> SCIMGroup:
|
||||
"""Convert internal Group model to SCIM Group"""
|
||||
member_ids = Groups.get_group_user_ids_by_id(group.id, db) or []
|
||||
members = []
|
||||
|
||||
for user_id in member_ids:
|
||||
user = Users.get_user_by_id(user_id, db=db)
|
||||
if user:
|
||||
members.append(
|
||||
SCIMGroupMember(
|
||||
value=user.id,
|
||||
ref=f"{request.base_url}api/v1/scim/v2/Users/{user.id}",
|
||||
display=user.name,
|
||||
)
|
||||
)
|
||||
# Batch-fetch all users to avoid N+1 queries
|
||||
users = Users.get_users_by_user_ids(member_ids, db=db) if member_ids else []
|
||||
members = [
|
||||
SCIMGroupMember(
|
||||
value=user.id,
|
||||
ref=f"{request.base_url}api/v1/scim/v2/Users/{user.id}",
|
||||
display=user.name,
|
||||
)
|
||||
for user in users
|
||||
]
|
||||
|
||||
return SCIMGroup(
|
||||
id=group.id,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ from open_webui.utils.plugin import (
|
|||
load_tool_module_by_id,
|
||||
replace_imports,
|
||||
get_tool_module_from_cache,
|
||||
resolve_valves_schema_options,
|
||||
)
|
||||
from open_webui.utils.tools import get_tool_specs
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
|
|
@ -553,7 +554,10 @@ async def get_tools_valves_spec_by_id(
|
|||
|
||||
if hasattr(tools_module, "Valves"):
|
||||
Valves = tools_module.Valves
|
||||
return Valves.schema()
|
||||
schema = Valves.schema()
|
||||
# Resolve dynamic options for select dropdowns
|
||||
schema = resolve_valves_schema_options(Valves, schema, user)
|
||||
return schema
|
||||
return None
|
||||
else:
|
||||
raise HTTPException(
|
||||
|
|
@ -662,7 +666,10 @@ async def get_tools_user_valves_spec_by_id(
|
|||
|
||||
if hasattr(tools_module, "UserValves"):
|
||||
UserValves = tools_module.UserValves
|
||||
return UserValves.schema()
|
||||
schema = UserValves.schema()
|
||||
# Resolve dynamic options for select dropdowns
|
||||
schema = resolve_valves_schema_options(UserValves, schema, user)
|
||||
return schema
|
||||
return None
|
||||
else:
|
||||
raise HTTPException(
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ from open_webui.models.chats import Chats
|
|||
from open_webui.models.channels import Channels, ChannelMember, Channel
|
||||
from open_webui.models.messages import Messages, Message
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.utils.sanitize import strip_markdown_code_fences
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -166,7 +167,7 @@ async def search_web(
|
|||
engine = __request__.app.state.config.WEB_SEARCH_ENGINE
|
||||
user = UserModel(**__user__) if __user__ else None
|
||||
|
||||
results = _search_web(__request__, engine, query, user)
|
||||
results = await asyncio.to_thread(_search_web, __request__, engine, query, user)
|
||||
|
||||
# Limit results
|
||||
results = results[:count] if results else []
|
||||
|
|
@ -370,6 +371,9 @@ async def execute_code(
|
|||
return json.dumps({"error": "Request context not available"})
|
||||
|
||||
try:
|
||||
# Strip markdown fences if model included them
|
||||
code = strip_markdown_code_fences(code)
|
||||
|
||||
# Import blocked modules from config (same as middleware)
|
||||
from open_webui.config import CODE_INTERPRETER_BLOCKED_MODULES
|
||||
|
||||
|
|
@ -892,6 +896,7 @@ async def search_chats(
|
|||
end_timestamp: Optional[int] = None,
|
||||
__request__: Request = None,
|
||||
__user__: dict = None,
|
||||
__chat_id__: str = None,
|
||||
) -> str:
|
||||
"""
|
||||
Search the user's previous chat conversations by title and message content.
|
||||
|
|
@ -921,6 +926,10 @@ async def search_chats(
|
|||
|
||||
results = []
|
||||
for chat in chats:
|
||||
# Skip the current chat to avoid showing it in search results
|
||||
if __chat_id__ and chat.id == __chat_id__:
|
||||
continue
|
||||
|
||||
# Apply date filters (updated_at is in seconds)
|
||||
if start_timestamp and chat.updated_at < start_timestamp:
|
||||
continue
|
||||
|
|
@ -1599,6 +1608,25 @@ async def query_knowledge_files(
|
|||
if not __user__:
|
||||
return json.dumps({"error": "User context not available"})
|
||||
|
||||
# Coerce parameters from LLM tool calls (may come as strings)
|
||||
if isinstance(count, str):
|
||||
try:
|
||||
count = int(count)
|
||||
except ValueError:
|
||||
count = 5 # Default fallback
|
||||
|
||||
# Handle knowledge_ids being string "None", "null", or empty
|
||||
if isinstance(knowledge_ids, str):
|
||||
if knowledge_ids.lower() in ("none", "null", ""):
|
||||
knowledge_ids = None
|
||||
else:
|
||||
# Try to parse as JSON array if it looks like one
|
||||
try:
|
||||
knowledge_ids = json.loads(knowledge_ids)
|
||||
except json.JSONDecodeError:
|
||||
# Treat as single ID
|
||||
knowledge_ids = [knowledge_ids]
|
||||
|
||||
try:
|
||||
from open_webui.models.knowledge import Knowledges
|
||||
from open_webui.models.files import Files
|
||||
|
|
|
|||
|
|
@ -221,7 +221,8 @@ class AuditLoggingMiddleware:
|
|||
return False # Do NOT skip logging for auth endpoints
|
||||
|
||||
# Skip logging if the request is not authenticated
|
||||
if not request.headers.get("authorization"):
|
||||
# Check both Authorization header (API keys) and token cookie (browser sessions)
|
||||
if not request.headers.get("authorization") and not request.cookies.get("token"):
|
||||
return True
|
||||
|
||||
# match either /api/<resource>/...(for the endpoint /api/chat case) or /api/v1/<resource>/...
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ from open_webui.env import (
|
|||
ENABLE_PASSWORD_VALIDATION,
|
||||
OFFLINE_MODE,
|
||||
LICENSE_BLOB,
|
||||
PASSWORD_VALIDATION_HINT,
|
||||
PASSWORD_VALIDATION_REGEX_PATTERN,
|
||||
REDIS_KEY_PREFIX,
|
||||
pk,
|
||||
|
|
@ -173,7 +174,7 @@ def validate_password(password: str) -> bool:
|
|||
|
||||
if ENABLE_PASSWORD_VALIDATION:
|
||||
if not PASSWORD_VALIDATION_REGEX_PATTERN.match(password):
|
||||
raise Exception(ERROR_MESSAGES.INVALID_PASSWORD())
|
||||
raise Exception(ERROR_MESSAGES.INVALID_PASSWORD(PASSWORD_VALIDATION_HINT))
|
||||
|
||||
return True
|
||||
|
||||
|
|
|
|||
|
|
@ -341,12 +341,8 @@ async def chat_completed(request: Request, form_data: dict, user: Any):
|
|||
}
|
||||
|
||||
try:
|
||||
filter_functions = [
|
||||
Functions.get_function_by_id(filter_id)
|
||||
for filter_id in get_sorted_filter_ids(
|
||||
request, model, metadata.get("filter_ids", [])
|
||||
)
|
||||
]
|
||||
filter_ids = get_sorted_filter_ids(request, model, metadata.get("filter_ids", []))
|
||||
filter_functions = Functions.get_functions_by_ids(filter_ids)
|
||||
|
||||
result, _ = await process_filter_functions(
|
||||
request=request,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from open_webui.storage.provider import Storage
|
|||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.files import Files
|
||||
from open_webui.routers.files import upload_file_handler
|
||||
from open_webui.retrieval.web.utils import validate_url
|
||||
|
||||
import mimetypes
|
||||
import base64
|
||||
|
|
@ -33,6 +34,8 @@ MARKDOWN_IMAGE_URL_PATTERN = re.compile(r"!\[(.*?)\]\((.+?)\)", re.IGNORECASE)
|
|||
def get_image_base64_from_url(url: str) -> Optional[str]:
|
||||
try:
|
||||
if url.startswith("http"):
|
||||
# Validate URL to prevent SSRF attacks against local/private networks
|
||||
validate_url(url)
|
||||
# Download the image from the URL
|
||||
response = requests.get(url)
|
||||
response.raise_for_status()
|
||||
|
|
|
|||
|
|
@ -8,6 +8,15 @@ from mcp import ClientSession
|
|||
from mcp.client.auth import OAuthClientProvider, TokenStorage
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
|
||||
import httpx
|
||||
from mcp.shared._httpx_utils import create_mcp_http_client
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL
|
||||
|
||||
|
||||
def create_insecure_httpx_client(headers=None, timeout=None, auth=None):
|
||||
client = create_mcp_http_client(headers=headers, timeout=timeout, auth=auth)
|
||||
client.verify = False
|
||||
return client
|
||||
|
||||
|
||||
class MCPClient:
|
||||
|
|
@ -18,7 +27,14 @@ class MCPClient:
|
|||
async def connect(self, url: str, headers: Optional[dict] = None):
|
||||
async with AsyncExitStack() as exit_stack:
|
||||
try:
|
||||
self._streams_context = streamablehttp_client(url, headers=headers)
|
||||
if AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL:
|
||||
self._streams_context = streamablehttp_client(url, headers=headers)
|
||||
else:
|
||||
self._streams_context = streamablehttp_client(
|
||||
url,
|
||||
headers=headers,
|
||||
httpx_client_factory=create_insecure_httpx_client,
|
||||
)
|
||||
|
||||
transport = await exit_stack.enter_async_context(self._streams_context)
|
||||
read_stream, write_stream, _ = transport
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ from open_webui.models.models import Models
|
|||
from open_webui.retrieval.utils import get_sources_from_items
|
||||
|
||||
|
||||
from open_webui.utils.sanitize import strip_markdown_code_fences
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from open_webui.utils.task import (
|
||||
get_task_model_id,
|
||||
|
|
@ -92,6 +93,7 @@ from open_webui.utils.misc import (
|
|||
prepend_to_first_user_message_content,
|
||||
convert_logit_bias_input_to_json,
|
||||
get_content_from_message,
|
||||
convert_output_to_messages,
|
||||
)
|
||||
from open_webui.utils.tools import (
|
||||
get_tools,
|
||||
|
|
@ -146,6 +148,11 @@ DEFAULT_SOLUTION_TAGS = [("<|begin_of_solution|>", "<|end_of_solution|>")]
|
|||
DEFAULT_CODE_INTERPRETER_TAGS = [("<code_interpreter>", "</code_interpreter>")]
|
||||
|
||||
|
||||
def output_id(prefix: str) -> str:
|
||||
"""Generate OR-style ID: prefix + 24-char hex UUID."""
|
||||
return f"{prefix}_{uuid4().hex[:24]}"
|
||||
|
||||
|
||||
def get_citation_source_from_tool_result(
|
||||
tool_name: str, tool_params: dict, tool_result: str, tool_id: str = ""
|
||||
) -> list[dict]:
|
||||
|
|
@ -1399,6 +1406,30 @@ async def convert_url_images_to_base64(form_data):
|
|||
return form_data
|
||||
|
||||
|
||||
def process_messages_with_output(messages: list[dict]) -> list[dict]:
|
||||
"""
|
||||
Process messages with OR-aligned output items for LLM consumption.
|
||||
|
||||
For assistant messages with 'output' field, produces properly formatted
|
||||
OpenAI-style messages (tool_calls + tool results). Strips 'output' before LLM.
|
||||
"""
|
||||
processed = []
|
||||
|
||||
for message in messages:
|
||||
if message.get("role") == "assistant" and message.get("output"):
|
||||
# Use output items for clean OpenAI-format messages
|
||||
output_messages = convert_output_to_messages(message["output"])
|
||||
if output_messages:
|
||||
processed.extend(output_messages)
|
||||
continue
|
||||
|
||||
# Strip 'output' field before adding (LLM shouldn't see it)
|
||||
clean_message = {k: v for k, v in message.items() if k != "output"}
|
||||
processed.append(clean_message)
|
||||
|
||||
return processed
|
||||
|
||||
|
||||
async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
# Pipeline Inlet -> Filter Inlet -> Chat Memory -> Chat Web Search -> Chat Image Generation
|
||||
# -> Chat Code Interpreter (Form Data Update) -> (Default) Chat Tools Function Calling
|
||||
|
|
@ -1407,6 +1438,9 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
form_data = apply_params_to_form_data(form_data, model)
|
||||
log.debug(f"form_data: {form_data}")
|
||||
|
||||
# Process messages with OR-aligned output items for clean LLM messages
|
||||
form_data["messages"] = process_messages_with_output(form_data.get("messages", []))
|
||||
|
||||
system_message = get_system_message(form_data.get("messages", []))
|
||||
if system_message: # Chat Controls/User Settings
|
||||
try:
|
||||
|
|
@ -1536,12 +1570,8 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
raise e
|
||||
|
||||
try:
|
||||
filter_functions = [
|
||||
Functions.get_function_by_id(filter_id)
|
||||
for filter_id in get_sorted_filter_ids(
|
||||
request, model, metadata.get("filter_ids", [])
|
||||
)
|
||||
]
|
||||
filter_ids = get_sorted_filter_ids(request, model, metadata.get("filter_ids", []))
|
||||
filter_functions = Functions.get_functions_by_ids(filter_ids)
|
||||
|
||||
form_data, flags = await process_filter_functions(
|
||||
request=request,
|
||||
|
|
@ -1808,11 +1838,8 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
# Inject builtin tools for native function calling based on enabled features and model capability
|
||||
# Check if builtin_tools capability is enabled for this model (defaults to True if not specified)
|
||||
builtin_tools_enabled = (
|
||||
model.get("info", {})
|
||||
.get("meta", {})
|
||||
.get("capabilities", {})
|
||||
.get("builtin_tools", True)
|
||||
)
|
||||
model.get("info", {}).get("meta", {}).get("capabilities") or {}
|
||||
).get("builtin_tools", True)
|
||||
if (
|
||||
metadata.get("params", {}).get("function_calling") == "native"
|
||||
and builtin_tools_enabled
|
||||
|
|
@ -1856,11 +1883,8 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
|
||||
# Check if file context extraction is enabled for this model (default True)
|
||||
file_context_enabled = (
|
||||
model.get("info", {})
|
||||
.get("meta", {})
|
||||
.get("capabilities", {})
|
||||
.get("file_context", True)
|
||||
)
|
||||
model.get("info", {}).get("meta", {}).get("capabilities") or {}
|
||||
).get("file_context", True)
|
||||
|
||||
if file_context_enabled:
|
||||
try:
|
||||
|
|
@ -2210,12 +2234,20 @@ async def process_chat_response(
|
|||
|
||||
title = Chats.get_chat_title_by_id(metadata["chat_id"])
|
||||
|
||||
# Use output from backend if provided (OR-compliant backends)
|
||||
response_output = response_data.get("output")
|
||||
|
||||
await event_emitter(
|
||||
{
|
||||
"type": "chat:completion",
|
||||
"data": {
|
||||
"done": True,
|
||||
"content": content,
|
||||
**(
|
||||
{"output": response_output}
|
||||
if response_output
|
||||
else {}
|
||||
),
|
||||
"title": title,
|
||||
},
|
||||
}
|
||||
|
|
@ -2228,6 +2260,11 @@ async def process_chat_response(
|
|||
{
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
**(
|
||||
{"output": response_output}
|
||||
if response_output
|
||||
else {}
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -2481,6 +2518,107 @@ async def process_chat_response(
|
|||
|
||||
return content.strip()
|
||||
|
||||
def serialize_output(output: list) -> str:
|
||||
"""
|
||||
Convert OR-aligned output items to HTML for display.
|
||||
For LLM consumption, use convert_output_to_messages() instead.
|
||||
"""
|
||||
content = ""
|
||||
|
||||
# First pass: collect function_call_output items by call_id for lookup
|
||||
tool_outputs = {}
|
||||
for item in output:
|
||||
if item.get("type") == "function_call_output":
|
||||
tool_outputs[item.get("call_id")] = item
|
||||
|
||||
# Second pass: render items in order
|
||||
for item in output:
|
||||
item_type = item.get("type", "")
|
||||
|
||||
if item_type == "message":
|
||||
for content_part in item.get("content", []):
|
||||
if content_part.get("type") == "output_text":
|
||||
text = content_part.get("text", "").strip()
|
||||
if text:
|
||||
content = f"{content}{text}\n"
|
||||
|
||||
elif item_type == "function_call":
|
||||
# Render tool call inline with its result (if available)
|
||||
if content and not content.endswith("\n"):
|
||||
content += "\n"
|
||||
|
||||
call_id = item.get("call_id", "")
|
||||
name = item.get("name", "")
|
||||
arguments = item.get("arguments", "")
|
||||
|
||||
result_item = tool_outputs.get(call_id)
|
||||
if result_item:
|
||||
result_text = ""
|
||||
for out in result_item.get("output", []):
|
||||
if out.get("type") == "input_text":
|
||||
result_text += out.get("text", "")
|
||||
files = result_item.get("files")
|
||||
embeds = result_item.get("embeds", "")
|
||||
|
||||
content += f'<details type="tool_calls" done="true" id="{call_id}" name="{name}" arguments="{html.escape(json.dumps(arguments))}" result="{html.escape(json.dumps(result_text, ensure_ascii=False))}" files="{html.escape(json.dumps(files)) if files else ""}" embeds="{html.escape(json.dumps(embeds))}">\n<summary>Tool Executed</summary>\n</details>\n'
|
||||
else:
|
||||
content += f'<details type="tool_calls" done="false" id="{call_id}" name="{name}" arguments="{html.escape(json.dumps(arguments))}">\n<summary>Executing...</summary>\n</details>\n'
|
||||
|
||||
elif item_type == "function_call_output":
|
||||
# Already handled inline with function_call above
|
||||
pass
|
||||
|
||||
elif item_type == "reasoning":
|
||||
reasoning_content = ""
|
||||
for content_part in item.get("content", []):
|
||||
if content_part.get("type") == "output_text":
|
||||
reasoning_content = content_part.get("text", "").strip()
|
||||
|
||||
duration = item.get("duration")
|
||||
status = item.get("status", "in_progress")
|
||||
|
||||
if content and not content.endswith("\n"):
|
||||
content += "\n"
|
||||
|
||||
display = html.escape(
|
||||
"\n".join(
|
||||
(f"> {line}" if not line.startswith(">") else line)
|
||||
for line in reasoning_content.splitlines()
|
||||
)
|
||||
)
|
||||
|
||||
if status == "completed" or duration is not None:
|
||||
content = f'{content}<details type="reasoning" done="true" duration="{duration or 0}">\n<summary>Thought for {duration or 0} seconds</summary>\n{display}\n</details>\n'
|
||||
else:
|
||||
content = f'{content}<details type="reasoning" done="false">\n<summary>Thinking…</summary>\n{display}\n</details>\n'
|
||||
|
||||
elif item_type == "open_webui:code_interpreter":
|
||||
code = item.get("code", "")
|
||||
output_val = item.get("output")
|
||||
lang = item.get("lang", "")
|
||||
|
||||
content_stripped, original_whitespace = (
|
||||
split_content_and_whitespace(content)
|
||||
)
|
||||
if is_opening_code_block(content_stripped):
|
||||
content = (
|
||||
content_stripped.rstrip("`").rstrip()
|
||||
+ original_whitespace
|
||||
)
|
||||
else:
|
||||
content = content_stripped + original_whitespace
|
||||
|
||||
if content and not content.endswith("\n"):
|
||||
content += "\n"
|
||||
|
||||
if output_val:
|
||||
output_escaped = html.escape(json.dumps(output_val))
|
||||
content = f'{content}<details type="code_interpreter" done="true" output="{output_escaped}">\n<summary>Analyzed</summary>\n```{lang}\n{code}\n```\n</details>\n'
|
||||
else:
|
||||
content = f'{content}<details type="code_interpreter" done="false">\n<summary>Analyzing...</summary>\n```{lang}\n{code}\n```\n</details>\n'
|
||||
|
||||
return content.strip()
|
||||
|
||||
def convert_content_blocks_to_messages(content_blocks, raw=False):
|
||||
messages = []
|
||||
|
||||
|
|
@ -2521,6 +2659,127 @@ async def process_chat_response(
|
|||
|
||||
return messages
|
||||
|
||||
def convert_content_blocks_to_output(content_blocks):
|
||||
"""
|
||||
Convert content_blocks to Open Responses-aligned output items.
|
||||
See: https://openresponses.org/specification
|
||||
"""
|
||||
output_items = []
|
||||
|
||||
def next_id(prefix):
|
||||
return f"{prefix}_{uuid4().hex[:24]}"
|
||||
|
||||
for block in content_blocks:
|
||||
block_type = block.get("type", "")
|
||||
# Use backend-provided ID if available, fallback to generated
|
||||
block_id = block.get("id")
|
||||
|
||||
if block_type == "text":
|
||||
text_content = block.get("content", "").strip()
|
||||
if text_content:
|
||||
output_items.append(
|
||||
{
|
||||
"type": "message",
|
||||
"id": block_id or next_id("msg"),
|
||||
"status": "completed",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "output_text", "text": text_content}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
elif block_type == "tool_calls":
|
||||
tool_calls = block.get("content", [])
|
||||
results = block.get("results", [])
|
||||
|
||||
# Emit function_call items
|
||||
for tool_call in tool_calls:
|
||||
call_id = tool_call.get("id", "")
|
||||
func = tool_call.get("function", {})
|
||||
output_items.append(
|
||||
{
|
||||
"type": "function_call",
|
||||
"id": call_id
|
||||
or next_id(
|
||||
"fc"
|
||||
), # Use call_id as item id if available
|
||||
"call_id": call_id,
|
||||
"name": func.get("name", ""),
|
||||
"arguments": func.get("arguments", "{}"),
|
||||
"status": "completed" if results else "in_progress",
|
||||
}
|
||||
)
|
||||
|
||||
# Emit function_call_output items
|
||||
for result in results:
|
||||
output_items.append(
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"id": result.get("id") or next_id("fco"),
|
||||
"call_id": result.get("tool_call_id", ""),
|
||||
"output": [
|
||||
{
|
||||
"type": "input_text",
|
||||
"text": result.get("content", ""),
|
||||
}
|
||||
],
|
||||
"status": "completed",
|
||||
**(
|
||||
{"files": result.get("files")}
|
||||
if result.get("files")
|
||||
else {}
|
||||
),
|
||||
**(
|
||||
{"embeds": result.get("embeds")}
|
||||
if result.get("embeds")
|
||||
else {}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
elif block_type == "reasoning":
|
||||
reasoning_content = block.get("content", "").strip()
|
||||
duration = block.get("duration")
|
||||
output_items.append(
|
||||
{
|
||||
"type": "reasoning",
|
||||
"id": block_id or next_id("r"),
|
||||
"status": (
|
||||
"completed"
|
||||
if duration is not None
|
||||
else "in_progress"
|
||||
),
|
||||
"content": (
|
||||
[{"type": "output_text", "text": reasoning_content}]
|
||||
if reasoning_content
|
||||
else None
|
||||
),
|
||||
"summary": None,
|
||||
}
|
||||
)
|
||||
|
||||
elif block_type == "code_interpreter":
|
||||
code = block.get("content", "")
|
||||
output_val = block.get("output")
|
||||
attrs = block.get("attributes", {})
|
||||
output_items.append(
|
||||
{
|
||||
"type": "open_webui:code_interpreter",
|
||||
"id": block_id or next_id("ci"),
|
||||
"status": (
|
||||
"completed"
|
||||
if output_val is not None
|
||||
else "in_progress"
|
||||
),
|
||||
"lang": attrs.get("lang", ""),
|
||||
"code": code,
|
||||
"output": output_val,
|
||||
}
|
||||
)
|
||||
|
||||
return output_items
|
||||
|
||||
def tag_content_handler(content_type, tags, content, content_blocks):
|
||||
end_flag = False
|
||||
|
||||
|
|
@ -2718,6 +2977,23 @@ async def process_chat_response(
|
|||
else last_assistant_message if last_assistant_message else ""
|
||||
)
|
||||
|
||||
# Initialize output: use existing from message if continuing, else create new
|
||||
existing_output = message.get("output") if message else None
|
||||
if existing_output:
|
||||
output = existing_output
|
||||
else:
|
||||
# Always create an initial message item (even if content is empty)
|
||||
output = [
|
||||
{
|
||||
"type": "message",
|
||||
"id": output_id("msg"),
|
||||
"status": "in_progress",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": content}],
|
||||
}
|
||||
]
|
||||
|
||||
# Keep content_blocks for backward compatibility during transition
|
||||
content_blocks = [
|
||||
{
|
||||
"type": "text",
|
||||
|
|
@ -3129,13 +3405,15 @@ async def process_chat_response(
|
|||
|
||||
if ENABLE_REALTIME_CHAT_SAVE:
|
||||
# Save message in the database
|
||||
output = convert_content_blocks_to_output(
|
||||
content_blocks
|
||||
)
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata["chat_id"],
|
||||
metadata["message_id"],
|
||||
{
|
||||
"content": serialize_content_blocks(
|
||||
content_blocks
|
||||
),
|
||||
"content": serialize_output(output),
|
||||
"output": output,
|
||||
},
|
||||
)
|
||||
else:
|
||||
|
|
@ -3220,11 +3498,13 @@ async def process_chat_response(
|
|||
}
|
||||
)
|
||||
|
||||
output = convert_content_blocks_to_output(content_blocks)
|
||||
await event_emitter(
|
||||
{
|
||||
"type": "chat:completion",
|
||||
"data": {
|
||||
"content": serialize_content_blocks(content_blocks),
|
||||
"content": serialize_output(output),
|
||||
"output": output,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
@ -3399,11 +3679,13 @@ async def process_chat_response(
|
|||
)
|
||||
tool_call_sources.clear()
|
||||
|
||||
output = convert_content_blocks_to_output(content_blocks)
|
||||
await event_emitter(
|
||||
{
|
||||
"type": "chat:completion",
|
||||
"data": {
|
||||
"content": serialize_content_blocks(content_blocks),
|
||||
"content": serialize_output(output),
|
||||
"output": output,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
@ -3445,11 +3727,13 @@ async def process_chat_response(
|
|||
and retries < MAX_RETRIES
|
||||
):
|
||||
|
||||
output = convert_content_blocks_to_output(content_blocks)
|
||||
await event_emitter(
|
||||
{
|
||||
"type": "chat:completion",
|
||||
"data": {
|
||||
"content": serialize_content_blocks(content_blocks),
|
||||
"content": serialize_output(output),
|
||||
"output": output,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
@ -3461,6 +3745,9 @@ async def process_chat_response(
|
|||
try:
|
||||
if content_blocks[-1]["attributes"].get("type") == "code":
|
||||
code = content_blocks[-1]["content"]
|
||||
# Strip markdown fences if model included them
|
||||
code = strip_markdown_code_fences(code)
|
||||
|
||||
if CODE_INTERPRETER_BLOCKED_MODULES:
|
||||
blocking_code = textwrap.dedent(
|
||||
f"""
|
||||
|
|
@ -3576,11 +3863,13 @@ async def process_chat_response(
|
|||
}
|
||||
)
|
||||
|
||||
output = convert_content_blocks_to_output(content_blocks)
|
||||
await event_emitter(
|
||||
{
|
||||
"type": "chat:completion",
|
||||
"data": {
|
||||
"content": serialize_content_blocks(content_blocks),
|
||||
"content": serialize_output(output),
|
||||
"output": output,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
@ -3617,9 +3906,11 @@ async def process_chat_response(
|
|||
break
|
||||
|
||||
title = Chats.get_chat_title_by_id(metadata["chat_id"])
|
||||
output = convert_content_blocks_to_output(content_blocks)
|
||||
data = {
|
||||
"done": True,
|
||||
"content": serialize_content_blocks(content_blocks),
|
||||
"content": serialize_output(output),
|
||||
"output": output,
|
||||
"title": title,
|
||||
}
|
||||
|
||||
|
|
@ -3629,7 +3920,8 @@ async def process_chat_response(
|
|||
metadata["chat_id"],
|
||||
metadata["message_id"],
|
||||
{
|
||||
"content": serialize_content_blocks(content_blocks),
|
||||
"content": serialize_output(output),
|
||||
"output": output,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -3663,11 +3955,13 @@ async def process_chat_response(
|
|||
|
||||
if not ENABLE_REALTIME_CHAT_SAVE:
|
||||
# Save message in the database
|
||||
output = convert_content_blocks_to_output(content_blocks)
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata["chat_id"],
|
||||
metadata["message_id"],
|
||||
{
|
||||
"content": serialize_content_blocks(content_blocks),
|
||||
"content": serialize_output(output),
|
||||
"output": output,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -128,6 +128,86 @@ def get_content_from_message(message: dict) -> Optional[str]:
|
|||
return None
|
||||
|
||||
|
||||
def convert_output_to_messages(output: list) -> list[dict]:
|
||||
"""
|
||||
Convert OR-aligned output items to OpenAI-format messages for LLM consumption.
|
||||
|
||||
This is the inverse of convert_content_blocks_to_output() in middleware.py.
|
||||
"""
|
||||
if not output or not isinstance(output, list):
|
||||
return []
|
||||
|
||||
messages = []
|
||||
pending_tool_calls = []
|
||||
pending_content = []
|
||||
|
||||
for item in output:
|
||||
item_type = item.get("type", "")
|
||||
|
||||
if item_type == "message":
|
||||
# Extract text from output_text content parts
|
||||
content_parts = item.get("content", [])
|
||||
text = ""
|
||||
for part in content_parts:
|
||||
if part.get("type") == "output_text":
|
||||
text += part.get("text", "")
|
||||
if text:
|
||||
pending_content.append(text)
|
||||
|
||||
elif item_type == "function_call":
|
||||
# Collect tool calls to batch into assistant message
|
||||
pending_tool_calls.append({
|
||||
"id": item.get("call_id", ""),
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": item.get("name", ""),
|
||||
"arguments": item.get("arguments", "{}"),
|
||||
}
|
||||
})
|
||||
|
||||
elif item_type == "function_call_output":
|
||||
# Flush any pending content/tool_calls before adding tool result
|
||||
if pending_content or pending_tool_calls:
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": "\n".join(pending_content) if pending_content else "",
|
||||
**({"tool_calls": pending_tool_calls} if pending_tool_calls else {}),
|
||||
})
|
||||
pending_content = []
|
||||
pending_tool_calls = []
|
||||
|
||||
# Extract text from output content parts
|
||||
output_parts = item.get("output", [])
|
||||
content = ""
|
||||
for part in output_parts:
|
||||
if part.get("type") == "input_text":
|
||||
content += part.get("text", "")
|
||||
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": item.get("call_id", ""),
|
||||
"content": content,
|
||||
})
|
||||
|
||||
elif item_type == "reasoning":
|
||||
# Skip reasoning blocks for LLM messages
|
||||
pass
|
||||
|
||||
elif item_type.startswith("open_webui:"):
|
||||
# Skip extension types
|
||||
pass
|
||||
|
||||
# Flush remaining content/tool_calls
|
||||
if pending_content or pending_tool_calls:
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": "\n".join(pending_content) if pending_content else "",
|
||||
**({"tool_calls": pending_tool_calls} if pending_tool_calls else {}),
|
||||
})
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
def get_last_user_message(messages: list[dict]) -> Optional[str]:
|
||||
message = get_last_user_message_item(messages)
|
||||
if message is None:
|
||||
|
|
|
|||
|
|
@ -287,7 +287,11 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict:
|
|||
Returns:
|
||||
dict: A modified payload compatible with the Ollama API.
|
||||
"""
|
||||
openai_payload = copy.deepcopy(openai_payload)
|
||||
# Shallow copy metadata separately (may contain non-picklable objects)
|
||||
metadata = openai_payload.get("metadata")
|
||||
openai_payload = copy.deepcopy({k: v for k, v in openai_payload.items() if k != "metadata"})
|
||||
if metadata is not None:
|
||||
openai_payload["metadata"] = dict(metadata)
|
||||
ollama_payload = {}
|
||||
|
||||
# Mapping basic model and message details
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from importlib import util
|
|||
import types
|
||||
import tempfile
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from open_webui.env import PIP_OPTIONS, PIP_PACKAGE_INDEX_OPTIONS, OFFLINE_MODE
|
||||
from open_webui.models.functions import Functions
|
||||
|
|
@ -14,6 +15,143 @@ from open_webui.models.tools import Tools
|
|||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def resolve_valves_schema_options(
|
||||
valves_class: type, schema: dict, user: Any = None
|
||||
) -> dict:
|
||||
"""
|
||||
Resolve dynamic options in a Valves schema.
|
||||
|
||||
For properties with `input.options`, this function handles two cases:
|
||||
- List: Used directly as dropdown options
|
||||
- String: Treated as method name, called to get options dynamically
|
||||
|
||||
Usage in Valves:
|
||||
class UserValves(BaseModel):
|
||||
# Static options
|
||||
priority: str = Field(
|
||||
default="medium",
|
||||
json_schema_extra={
|
||||
"input": {
|
||||
"type": "select",
|
||||
"options": ["low", "medium", "high"]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# Dynamic options (method name)
|
||||
model: str = Field(
|
||||
default="",
|
||||
json_schema_extra={
|
||||
"input": {
|
||||
"type": "select",
|
||||
"options": "get_model_options"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_model_options(cls, __user__=None) -> list[dict]:
|
||||
return [{"value": "gpt-4", "label": "GPT-4"}]
|
||||
|
||||
Args:
|
||||
valves_class: The Valves or UserValves Pydantic model class
|
||||
schema: The JSON schema dict from valves_class.schema()
|
||||
user: Optional user object passed to methods that accept __user__
|
||||
|
||||
Returns:
|
||||
Modified schema dict with resolved options
|
||||
"""
|
||||
if not schema or "properties" not in schema:
|
||||
return schema
|
||||
|
||||
# Make a copy to avoid mutating the original
|
||||
schema = dict(schema)
|
||||
schema["properties"] = dict(schema.get("properties", {}))
|
||||
|
||||
for prop_name, prop_schema in list(schema["properties"].items()):
|
||||
# Get the original field info from the Pydantic model
|
||||
if not hasattr(valves_class, "model_fields"):
|
||||
continue
|
||||
|
||||
field_info = valves_class.model_fields.get(prop_name)
|
||||
if not field_info:
|
||||
continue
|
||||
|
||||
# Check json_schema_extra for options
|
||||
json_schema_extra = field_info.json_schema_extra
|
||||
if not json_schema_extra or not isinstance(json_schema_extra, dict):
|
||||
continue
|
||||
|
||||
input_config = json_schema_extra.get("input")
|
||||
if not input_config or not isinstance(input_config, dict):
|
||||
continue
|
||||
|
||||
options = input_config.get("options")
|
||||
if options is None:
|
||||
continue
|
||||
|
||||
resolved_options = None
|
||||
|
||||
# Case 1: options is already a list - use directly
|
||||
if isinstance(options, list):
|
||||
resolved_options = options
|
||||
|
||||
# Case 2: options is a string - treat as method name
|
||||
elif isinstance(options, str) and options:
|
||||
method = getattr(valves_class, options, None)
|
||||
if method is None or not callable(method):
|
||||
log.warning(
|
||||
f"options '{options}' not found or not callable on {valves_class.__name__}"
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
import inspect
|
||||
|
||||
sig = inspect.signature(method)
|
||||
params = sig.parameters
|
||||
|
||||
# Prepare kwargs based on what the method accepts
|
||||
kwargs = {}
|
||||
if "__user__" in params and user is not None:
|
||||
kwargs["__user__"] = (
|
||||
user.model_dump() if hasattr(user, "model_dump") else user
|
||||
)
|
||||
if "user" in params and user is not None:
|
||||
kwargs["user"] = (
|
||||
user.model_dump() if hasattr(user, "model_dump") else user
|
||||
)
|
||||
|
||||
resolved_options = method(**kwargs) if kwargs else method()
|
||||
|
||||
# Validate return type
|
||||
if not isinstance(resolved_options, list):
|
||||
log.warning(
|
||||
f"Method '{options}' did not return a list for {prop_name}"
|
||||
)
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to resolve options for {prop_name}: {e}")
|
||||
continue
|
||||
else:
|
||||
# Invalid options type - skip
|
||||
continue
|
||||
|
||||
# Update the schema with resolved options
|
||||
schema["properties"][prop_name] = dict(prop_schema)
|
||||
if "input" not in schema["properties"][prop_name]:
|
||||
schema["properties"][prop_name]["input"] = {"type": "select"}
|
||||
else:
|
||||
schema["properties"][prop_name]["input"] = dict(
|
||||
schema["properties"][prop_name].get("input", {})
|
||||
)
|
||||
schema["properties"][prop_name]["input"]["options"] = resolved_options
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
|
||||
def extract_frontmatter(content):
|
||||
"""
|
||||
Extract frontmatter as a dictionary from the provided content string.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import inspect
|
||||
from urllib.parse import urlparse
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import logging
|
||||
|
||||
|
|
@ -12,6 +14,7 @@ from open_webui.env import (
|
|||
REDIS_SENTINEL_MAX_RETRY_COUNT,
|
||||
REDIS_SENTINEL_PORT,
|
||||
REDIS_URL,
|
||||
REDIS_RECONNECT_DELAY,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -63,6 +66,8 @@ class SentinelRedisProxy:
|
|||
i + 1,
|
||||
REDIS_SENTINEL_MAX_RETRY_COUNT,
|
||||
)
|
||||
if REDIS_RECONNECT_DELAY:
|
||||
time.sleep(REDIS_RECONNECT_DELAY / 1000)
|
||||
continue
|
||||
log.error(
|
||||
"Redis operation failed after %s retries: %s",
|
||||
|
|
@ -94,6 +99,8 @@ class SentinelRedisProxy:
|
|||
i + 1,
|
||||
REDIS_SENTINEL_MAX_RETRY_COUNT,
|
||||
)
|
||||
if REDIS_RECONNECT_DELAY:
|
||||
await asyncio.sleep(REDIS_RECONNECT_DELAY / 1000)
|
||||
continue
|
||||
log.error(
|
||||
"Redis operation failed after %s retries: %s",
|
||||
|
|
@ -122,6 +129,8 @@ class SentinelRedisProxy:
|
|||
i + 1,
|
||||
REDIS_SENTINEL_MAX_RETRY_COUNT,
|
||||
)
|
||||
if REDIS_RECONNECT_DELAY:
|
||||
time.sleep(REDIS_RECONNECT_DELAY / 1000)
|
||||
continue
|
||||
log.error(
|
||||
"Redis operation failed after %s retries: %s",
|
||||
|
|
|
|||
21
backend/open_webui/utils/sanitize.py
Normal file
21
backend/open_webui/utils/sanitize.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import re
|
||||
|
||||
|
||||
def strip_markdown_code_fences(code: str) -> str:
|
||||
"""
|
||||
Strip markdown code fences if present.
|
||||
|
||||
This is a defensive, non-breaking change — if the code doesn't
|
||||
contain fences, it passes through unchanged.
|
||||
|
||||
Handles patterns like:
|
||||
- ```python
|
||||
- ```py
|
||||
- ```
|
||||
"""
|
||||
code = code.strip()
|
||||
# Remove opening fence (```python, ```py, ``` etc.)
|
||||
code = re.sub(r"^```\w*\n?", "", code)
|
||||
# Remove closing fence
|
||||
code = re.sub(r"\n?```\s*$", "", code)
|
||||
return code.strip()
|
||||
|
|
@ -69,6 +69,7 @@ def prompt_template(template: str, user: Optional[Any] = None) -> str:
|
|||
|
||||
USER_VARIABLES = {
|
||||
"name": str(user.get("name")),
|
||||
"email": str(user.get("email")),
|
||||
"location": str(user_info.get("location")),
|
||||
"bio": str(user.get("bio")),
|
||||
"gender": str(user.get("gender")),
|
||||
|
|
@ -92,6 +93,9 @@ def prompt_template(template: str, user: Optional[Any] = None) -> str:
|
|||
template = template.replace("{{CURRENT_WEEKDAY}}", formatted_weekday)
|
||||
|
||||
template = template.replace("{{USER_NAME}}", USER_VARIABLES.get("name", "Unknown"))
|
||||
template = template.replace(
|
||||
"{{USER_EMAIL}}", USER_VARIABLES.get("email", "Unknown")
|
||||
)
|
||||
template = template.replace("{{USER_BIO}}", USER_VARIABLES.get("bio", "Unknown"))
|
||||
template = template.replace(
|
||||
"{{USER_GENDER}}", USER_VARIABLES.get("gender", "Unknown")
|
||||
|
|
|
|||
|
|
@ -398,74 +398,87 @@ def get_builtin_tools(
|
|||
# Helper to get model capabilities (defaults to True if not specified)
|
||||
def get_model_capability(name: str, default: bool = True) -> bool:
|
||||
return (
|
||||
model.get("info", {})
|
||||
.get("meta", {})
|
||||
.get("capabilities", {})
|
||||
(model.get("info", {}).get("meta", {}).get("capabilities") or {})
|
||||
.get(name, default)
|
||||
)
|
||||
|
||||
# Time utilities - always available for date calculations
|
||||
builtin_functions.extend([get_current_timestamp, calculate_timestamp])
|
||||
# Helper to check if a builtin tool category is enabled via meta.builtinTools
|
||||
# Defaults to True if not specified (backward compatible)
|
||||
def is_builtin_tool_enabled(category: str) -> bool:
|
||||
builtin_tools = model.get("info", {}).get("meta", {}).get("builtinTools", {})
|
||||
return builtin_tools.get(category, True)
|
||||
|
||||
# Time utilities - available for date calculations
|
||||
if is_builtin_tool_enabled("time"):
|
||||
builtin_functions.extend([get_current_timestamp, calculate_timestamp])
|
||||
|
||||
# Knowledge base tools - conditional injection based on model knowledge
|
||||
# If model has attached knowledge (any type), only provide query_knowledge_files
|
||||
# Otherwise, provide all KB browsing tools
|
||||
model_knowledge = model.get("info", {}).get("meta", {}).get("knowledge", [])
|
||||
if model_knowledge:
|
||||
# Model has attached knowledge - only allow semantic search within it
|
||||
builtin_functions.append(query_knowledge_files)
|
||||
else:
|
||||
# No model knowledge - allow full KB browsing
|
||||
builtin_functions.extend(
|
||||
[
|
||||
list_knowledge_bases,
|
||||
search_knowledge_bases,
|
||||
query_knowledge_bases,
|
||||
search_knowledge_files,
|
||||
query_knowledge_files,
|
||||
view_knowledge_file,
|
||||
]
|
||||
)
|
||||
if is_builtin_tool_enabled("knowledge"):
|
||||
if model_knowledge:
|
||||
# Model has attached knowledge - only allow semantic search within it
|
||||
builtin_functions.append(query_knowledge_files)
|
||||
else:
|
||||
# No model knowledge - allow full KB browsing
|
||||
builtin_functions.extend(
|
||||
[
|
||||
list_knowledge_bases,
|
||||
search_knowledge_bases,
|
||||
query_knowledge_bases,
|
||||
search_knowledge_files,
|
||||
query_knowledge_files,
|
||||
view_knowledge_file,
|
||||
]
|
||||
)
|
||||
|
||||
# Chats tools - search and fetch user's chat history
|
||||
builtin_functions.extend([search_chats, view_chat])
|
||||
if is_builtin_tool_enabled("chats"):
|
||||
builtin_functions.extend([search_chats, view_chat])
|
||||
|
||||
# Add memory tools if enabled for this chat
|
||||
if features.get("memory"):
|
||||
# Add memory tools if builtin category enabled AND enabled for this chat
|
||||
if is_builtin_tool_enabled("memory") and features.get("memory"):
|
||||
builtin_functions.extend([search_memories, add_memory, replace_memory_content])
|
||||
|
||||
# Add web search tools if enabled globally AND model has web_search capability
|
||||
if getattr(
|
||||
request.app.state.config, "ENABLE_WEB_SEARCH", False
|
||||
) and get_model_capability("web_search"):
|
||||
# Add web search tools if builtin category enabled AND enabled globally AND model has web_search capability
|
||||
if (
|
||||
is_builtin_tool_enabled("web_search")
|
||||
and getattr(request.app.state.config, "ENABLE_WEB_SEARCH", False)
|
||||
and get_model_capability("web_search")
|
||||
):
|
||||
builtin_functions.extend([search_web, fetch_url])
|
||||
|
||||
# Add image generation/edit tools if enabled globally AND model has image_generation capability
|
||||
if getattr(
|
||||
request.app.state.config, "ENABLE_IMAGE_GENERATION", False
|
||||
) and get_model_capability("image_generation"):
|
||||
# Add image generation/edit tools if builtin category enabled AND enabled globally AND model has image_generation capability
|
||||
if (
|
||||
is_builtin_tool_enabled("image_generation")
|
||||
and getattr(request.app.state.config, "ENABLE_IMAGE_GENERATION", False)
|
||||
and get_model_capability("image_generation")
|
||||
):
|
||||
builtin_functions.append(generate_image)
|
||||
if getattr(
|
||||
request.app.state.config, "ENABLE_IMAGE_EDIT", False
|
||||
) and get_model_capability("image_generation"):
|
||||
if (
|
||||
is_builtin_tool_enabled("image_generation")
|
||||
and getattr(request.app.state.config, "ENABLE_IMAGE_EDIT", False)
|
||||
and get_model_capability("image_generation")
|
||||
):
|
||||
builtin_functions.append(edit_image)
|
||||
|
||||
# Add code interpreter tool if enabled globally AND model has code_interpreter capability
|
||||
# Supports both pyodide (via frontend event call) and jupyter engines
|
||||
# Add code interpreter tool if builtin category enabled AND enabled globally AND model has code_interpreter capability
|
||||
if (
|
||||
getattr(request.app.state.config, "ENABLE_CODE_INTERPRETER", True)
|
||||
is_builtin_tool_enabled("code_interpreter")
|
||||
and getattr(request.app.state.config, "ENABLE_CODE_INTERPRETER", True)
|
||||
and get_model_capability("code_interpreter")
|
||||
):
|
||||
builtin_functions.append(execute_code)
|
||||
|
||||
# Notes tools - search, view, create, and update user's notes (if notes enabled globally)
|
||||
if getattr(request.app.state.config, "ENABLE_NOTES", False):
|
||||
# Notes tools - search, view, create, and update user's notes (if builtin category enabled AND notes enabled globally)
|
||||
if is_builtin_tool_enabled("notes") and getattr(request.app.state.config, "ENABLE_NOTES", False):
|
||||
builtin_functions.extend(
|
||||
[search_notes, view_note, write_note, replace_note_content]
|
||||
)
|
||||
|
||||
# Channels tools - search channels and messages (if channels enabled globally)
|
||||
if getattr(request.app.state.config, "ENABLE_CHANNELS", False):
|
||||
# Channels tools - search channels and messages (if builtin category enabled AND channels enabled globally)
|
||||
if is_builtin_tool_enabled("channels") and getattr(request.app.state.config, "ENABLE_CHANNELS", False):
|
||||
builtin_functions.extend(
|
||||
[
|
||||
search_channels,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
fastapi==0.128.0
|
||||
uvicorn[standard]==0.40.0
|
||||
pydantic==2.12.5
|
||||
python-multipart==0.0.21
|
||||
python-multipart==0.0.22
|
||||
itsdangerous==2.2.0
|
||||
|
||||
python-socketio==5.16.0
|
||||
|
|
@ -16,20 +16,20 @@ PyJWT[crypto]==2.10.1
|
|||
authlib==1.6.6
|
||||
|
||||
requests==2.32.5
|
||||
aiohttp==3.13.2
|
||||
aiohttp==3.13.2 # do not update to 3.13.3 - broken
|
||||
async-timeout
|
||||
aiocache
|
||||
aiofiles
|
||||
starlette-compress==1.6.1
|
||||
starlette-compress==1.7.0
|
||||
httpx[socks,http2,zstd,cli,brotli]==0.28.1
|
||||
starsessions[redis]==2.2.1
|
||||
|
||||
sqlalchemy==2.0.45
|
||||
alembic==1.17.2
|
||||
peewee==3.18.3
|
||||
sqlalchemy==2.0.46
|
||||
alembic==1.18.3
|
||||
peewee==3.19.0
|
||||
peewee-migrate==1.14.3
|
||||
|
||||
pycrdt==0.12.44
|
||||
pycrdt==0.12.45
|
||||
redis
|
||||
|
||||
APScheduler==3.11.2
|
||||
|
|
@ -38,17 +38,17 @@ RestrictedPython==8.1
|
|||
loguru==0.7.3
|
||||
asgiref==3.11.0
|
||||
|
||||
mcp==1.25.0
|
||||
mcp==1.26.0
|
||||
openai
|
||||
|
||||
langchain==1.2.0
|
||||
langchain==1.2.7
|
||||
langchain-community==0.4.1
|
||||
langchain-classic==1.0.1
|
||||
langchain-text-splitters==1.1.0
|
||||
|
||||
fake-useragent==2.2.0
|
||||
|
||||
chromadb==1.4.0
|
||||
black==25.12.0
|
||||
chromadb==1.4.1
|
||||
black==26.1.0
|
||||
pydub
|
||||
chardet==5.2.0
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
fastapi==0.128.0
|
||||
uvicorn[standard]==0.40.0
|
||||
pydantic==2.12.5
|
||||
python-multipart==0.0.21
|
||||
python-multipart==0.0.22
|
||||
itsdangerous==2.2.0
|
||||
|
||||
python-socketio==5.16.0
|
||||
|
|
@ -13,21 +13,21 @@ PyJWT[crypto]==2.10.1
|
|||
authlib==1.6.6
|
||||
|
||||
requests==2.32.5
|
||||
aiohttp==3.13.2
|
||||
aiohttp==3.13.2 # do not update to 3.13.3 - broken
|
||||
async-timeout
|
||||
aiocache
|
||||
aiofiles
|
||||
starlette-compress==1.6.1
|
||||
starlette-compress==1.7.0
|
||||
httpx[socks,http2,zstd,cli,brotli]==0.28.1
|
||||
starsessions[redis]==2.2.1
|
||||
python-mimeparse==2.0.0
|
||||
|
||||
sqlalchemy==2.0.45
|
||||
alembic==1.17.2
|
||||
peewee==3.18.3
|
||||
sqlalchemy==2.0.46
|
||||
alembic==1.18.3
|
||||
peewee==3.19.0
|
||||
peewee-migrate==1.14.3
|
||||
|
||||
pycrdt==0.12.44
|
||||
pycrdt==0.12.45
|
||||
redis
|
||||
|
||||
APScheduler==3.11.2
|
||||
|
|
@ -38,39 +38,39 @@ asgiref==3.11.0
|
|||
|
||||
# AI libraries
|
||||
tiktoken
|
||||
mcp==1.25.0
|
||||
mcp==1.26.0
|
||||
|
||||
openai
|
||||
anthropic
|
||||
google-genai==1.56.0
|
||||
google-genai==1.60.0
|
||||
|
||||
langchain==1.2.0
|
||||
langchain==1.2.7
|
||||
langchain-community==0.4.1
|
||||
langchain-classic==1.0.1
|
||||
langchain-text-splitters==1.1.0
|
||||
|
||||
fake-useragent==2.2.0
|
||||
chromadb==1.4.0
|
||||
chromadb==1.4.1
|
||||
weaviate-client==4.19.2
|
||||
opensearch-py==3.1.0
|
||||
|
||||
transformers==4.57.3
|
||||
sentence-transformers==5.2.0
|
||||
transformers==4.57.6
|
||||
sentence-transformers==5.2.2
|
||||
accelerate
|
||||
pyarrow==20.0.0 # fix: pin pyarrow version to 20 for rpi compatibility #15897
|
||||
einops==0.8.1
|
||||
einops==0.8.2
|
||||
|
||||
ftfy==6.3.1
|
||||
chardet==5.2.0
|
||||
pypdf==6.5.0
|
||||
pypdf==6.6.2
|
||||
fpdf2==2.8.5
|
||||
pymdown-extensions==10.20
|
||||
pymdown-extensions==10.20.1
|
||||
docx2txt==0.9
|
||||
python-pptx==1.0.2
|
||||
unstructured==0.18.24
|
||||
unstructured==0.18.31
|
||||
msoffcrypto-tool==5.4.2
|
||||
nltk==3.9.2
|
||||
Markdown==3.10
|
||||
Markdown==3.10.1
|
||||
pypandoc==1.16.2
|
||||
pandas==2.3.3
|
||||
openpyxl==3.1.5
|
||||
|
|
@ -82,15 +82,15 @@ sentencepiece
|
|||
soundfile==0.13.1
|
||||
|
||||
pillow==12.1.0
|
||||
opencv-python-headless==4.12.0.88
|
||||
opencv-python-headless==4.13.0.90
|
||||
rapidocr-onnxruntime==1.4.4
|
||||
rank-bm25==0.2.2
|
||||
|
||||
onnxruntime==1.23.2
|
||||
faster-whisper==1.2.1
|
||||
|
||||
black==25.12.0
|
||||
youtube-transcript-api==1.2.3
|
||||
black==26.1.0
|
||||
youtube-transcript-api==1.2.4
|
||||
pytube==15.0.0
|
||||
|
||||
pydub
|
||||
|
|
@ -98,7 +98,7 @@ ddgs==9.10.0
|
|||
|
||||
azure-ai-documentintelligence==1.0.2
|
||||
azure-identity==1.25.1
|
||||
azure-storage-blob==12.27.1
|
||||
azure-storage-blob==12.28.0
|
||||
azure-search-documents==11.6.0
|
||||
|
||||
## Google Drive
|
||||
|
|
@ -107,7 +107,7 @@ google-auth-httplib2
|
|||
google-auth-oauthlib
|
||||
|
||||
googleapis-common-protos==1.72.0
|
||||
google-cloud-storage==3.7.0
|
||||
google-cloud-storage==3.8.0
|
||||
|
||||
## Databases
|
||||
pymongo
|
||||
|
|
@ -115,14 +115,14 @@ psycopg2-binary==2.9.11
|
|||
pgvector==0.4.2
|
||||
|
||||
PyMySQL==1.1.2
|
||||
boto3==1.42.21
|
||||
boto3==1.42.38
|
||||
|
||||
pymilvus==2.6.6
|
||||
pymilvus==2.6.8
|
||||
qdrant-client==1.16.2
|
||||
playwright==1.57.0 # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary
|
||||
playwright==1.58.0 # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary
|
||||
elasticsearch==9.2.1
|
||||
pinecone==6.0.2
|
||||
oracledb==3.4.1
|
||||
oracledb==3.4.2
|
||||
|
||||
av==14.0.1 # Caution: Set due to FATAL FIPS SELFTEST FAILURE, see discussion https://github.com/open-webui/open-webui/discussions/15720
|
||||
|
||||
|
|
@ -138,7 +138,7 @@ pytest-docker~=3.2.5
|
|||
ldap3==2.9.1
|
||||
|
||||
## Firecrawl
|
||||
firecrawl-py==4.12.0
|
||||
firecrawl-py==4.14.0
|
||||
|
||||
## Trace
|
||||
opentelemetry-api==1.39.1
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
services:
|
||||
playwright:
|
||||
image: mcr.microsoft.com/playwright:v1.57.0-noble # Version must match requirements.txt
|
||||
image: mcr.microsoft.com/playwright:v1.58.0-noble # Version must match requirements.txt
|
||||
container_name: playwright
|
||||
command: npx -y playwright@1.57.0 run-server --port 3000 --host 0.0.0.0
|
||||
command: npx -y playwright@1.58.0 run-server --port 3000 --host 0.0.0.0
|
||||
|
||||
open-webui:
|
||||
environment:
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ dependencies = [
|
|||
"fastapi==0.128.0",
|
||||
"uvicorn[standard]==0.40.0",
|
||||
"pydantic==2.12.5",
|
||||
"python-multipart==0.0.21",
|
||||
"python-multipart==0.0.22",
|
||||
"itsdangerous==2.2.0",
|
||||
|
||||
"python-socketio==5.16.0",
|
||||
|
|
@ -21,21 +21,21 @@ dependencies = [
|
|||
"authlib==1.6.6",
|
||||
|
||||
"requests==2.32.5",
|
||||
"aiohttp==3.13.2",
|
||||
"aiohttp==3.13.2" # do not update to 3.13.3 - broken,
|
||||
"async-timeout",
|
||||
"aiocache",
|
||||
"aiofiles",
|
||||
"starlette-compress==1.6.1",
|
||||
"starlette-compress==1.7.0",
|
||||
"httpx[socks,http2,zstd,cli,brotli]==0.28.1",
|
||||
"starsessions[redis]==2.2.1",
|
||||
"python-mimeparse==2.0.0",
|
||||
|
||||
"sqlalchemy==2.0.45",
|
||||
"alembic==1.17.2",
|
||||
"peewee==3.18.3",
|
||||
"sqlalchemy==2.0.46",
|
||||
"alembic==1.18.3",
|
||||
"peewee==3.19.0",
|
||||
"peewee-migrate==1.14.3",
|
||||
|
||||
"pycrdt==0.12.44",
|
||||
"pycrdt==0.12.45",
|
||||
"redis",
|
||||
|
||||
"APScheduler==3.11.2",
|
||||
|
|
@ -45,40 +45,40 @@ dependencies = [
|
|||
"asgiref==3.11.0",
|
||||
|
||||
"tiktoken",
|
||||
"mcp==1.25.0",
|
||||
"mcp==1.26.0",
|
||||
|
||||
"openai",
|
||||
"anthropic",
|
||||
"google-genai==1.56.0",
|
||||
"google-genai==1.60.0",
|
||||
|
||||
"langchain==1.2.0",
|
||||
"langchain==1.2.7",
|
||||
"langchain-community==0.4.1",
|
||||
"langchain-classic==1.0.1",
|
||||
"langchain-text-splitters==1.1.0",
|
||||
|
||||
"fake-useragent==2.2.0",
|
||||
"chromadb==1.4.0",
|
||||
"chromadb==1.4.1",
|
||||
"opensearch-py==3.1.0",
|
||||
"PyMySQL==1.1.2",
|
||||
"boto3==1.42.21",
|
||||
"boto3==1.42.38",
|
||||
|
||||
"transformers==4.57.3",
|
||||
"sentence-transformers==5.2.0",
|
||||
"transformers==4.57.6",
|
||||
"sentence-transformers==5.2.2",
|
||||
"accelerate",
|
||||
"pyarrow==20.0.0", # fix: pin pyarrow version to 20 for rpi compatibility #15897
|
||||
"einops==0.8.1",
|
||||
"einops==0.8.2",
|
||||
|
||||
"ftfy==6.3.1",
|
||||
"chardet==5.2.0",
|
||||
"pypdf==6.5.0",
|
||||
"pypdf==6.6.2",
|
||||
"fpdf2==2.8.5",
|
||||
"pymdown-extensions==10.20",
|
||||
"pymdown-extensions==10.20.1",
|
||||
"docx2txt==0.9",
|
||||
"python-pptx==1.0.2",
|
||||
"unstructured==0.18.24",
|
||||
"unstructured==0.18.31",
|
||||
"msoffcrypto-tool==5.4.2",
|
||||
"nltk==3.9.2",
|
||||
"Markdown==3.10",
|
||||
"Markdown==3.10.1",
|
||||
"pypandoc==1.16.2",
|
||||
"pandas==2.3.3",
|
||||
"openpyxl==3.1.5",
|
||||
|
|
@ -91,15 +91,15 @@ dependencies = [
|
|||
"azure-ai-documentintelligence==1.0.2",
|
||||
|
||||
"pillow==12.1.0",
|
||||
"opencv-python-headless==4.12.0.88",
|
||||
"opencv-python-headless==4.13.0.90",
|
||||
"rapidocr-onnxruntime==1.4.4",
|
||||
"rank-bm25==0.2.2",
|
||||
|
||||
"onnxruntime==1.23.2",
|
||||
"faster-whisper==1.2.1",
|
||||
|
||||
"black==25.12.0",
|
||||
"youtube-transcript-api==1.2.3",
|
||||
"black==26.1.0",
|
||||
"youtube-transcript-api==1.2.4",
|
||||
"pytube==15.0.0",
|
||||
|
||||
"pydub",
|
||||
|
|
@ -110,10 +110,10 @@ dependencies = [
|
|||
"google-auth-oauthlib",
|
||||
|
||||
"googleapis-common-protos==1.72.0",
|
||||
"google-cloud-storage==3.7.0",
|
||||
"google-cloud-storage==3.8.0",
|
||||
|
||||
"azure-identity==1.25.1",
|
||||
"azure-storage-blob==12.27.1",
|
||||
"azure-storage-blob==12.28.0",
|
||||
|
||||
"ldap3==2.9.1",
|
||||
]
|
||||
|
|
@ -145,18 +145,18 @@ all = [
|
|||
"docker~=7.1.0",
|
||||
"pytest~=8.3.2",
|
||||
"pytest-docker~=3.2.5",
|
||||
"playwright==1.57.0", # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary
|
||||
"playwright==1.58.0", # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary
|
||||
"elasticsearch==9.2.1",
|
||||
|
||||
"qdrant-client==1.16.2",
|
||||
|
||||
"weaviate-client==4.19.2",
|
||||
"pymilvus==2.6.6",
|
||||
"pymilvus==2.6.8",
|
||||
"pinecone==6.0.2",
|
||||
"oracledb==3.4.1",
|
||||
"oracledb==3.4.2",
|
||||
"colbert-ai==0.2.22",
|
||||
|
||||
"firecrawl-py==4.12.0",
|
||||
"firecrawl-py==4.14.0",
|
||||
"azure-search-documents==11.6.0",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -255,6 +255,55 @@ export const getArchivedChatList = async (
|
|||
}));
|
||||
};
|
||||
|
||||
export const getSharedChatList = async (
|
||||
token: string = '',
|
||||
page: number = 1,
|
||||
filter?: object
|
||||
) => {
|
||||
let error = null;
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.append('page', `${page}`);
|
||||
|
||||
if (filter) {
|
||||
Object.entries(filter).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
searchParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/shared?${searchParams.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { authorization: `Bearer ${token}` })
|
||||
}
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.then((json) => {
|
||||
return json;
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res.map((chat) => ({
|
||||
...chat,
|
||||
time_range: getTimeRange(chat.updated_at)
|
||||
}));
|
||||
};
|
||||
|
||||
export const getAllChats = async (token: string) => {
|
||||
let error = null;
|
||||
|
||||
|
|
|
|||
|
|
@ -175,6 +175,44 @@ export const getFiles = async (token: string = '') => {
|
|||
return res;
|
||||
};
|
||||
|
||||
export const searchFiles = async (
|
||||
token: string,
|
||||
filename: string = '*',
|
||||
skip: number = 0,
|
||||
limit: number = 50
|
||||
) => {
|
||||
let error = null;
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.append('filename', filename);
|
||||
searchParams.append('skip', String(skip));
|
||||
searchParams.append('limit', String(limit));
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/files/search?${searchParams.toString()}`, {
|
||||
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 [];
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getFileById = async (token: string, id: string) => {
|
||||
let error = null;
|
||||
|
||||
|
|
|
|||
|
|
@ -217,7 +217,63 @@ export const imageGenerations = async (token: string = '', prompt: string) => {
|
|||
.catch((err) => {
|
||||
console.error(err);
|
||||
if ('detail' in err) {
|
||||
error = err.detail;
|
||||
if (Array.isArray(err.detail)) {
|
||||
error = err.detail.map((e: { msg?: string }) => e.msg || JSON.stringify(e)).join(', ');
|
||||
} else {
|
||||
error = err.detail;
|
||||
}
|
||||
} else {
|
||||
error = 'Server connection failed';
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const imageEdits = async (
|
||||
token: string = '',
|
||||
images: string | string[],
|
||||
prompt: string,
|
||||
model?: string,
|
||||
size?: string,
|
||||
n?: number
|
||||
) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${IMAGES_API_BASE_URL}/edit`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { authorization: `Bearer ${token}` })
|
||||
},
|
||||
body: JSON.stringify({
|
||||
form_data: {
|
||||
image: images,
|
||||
prompt,
|
||||
...(model && { model }),
|
||||
...(size && { size }),
|
||||
...(n && { n })
|
||||
}
|
||||
})
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
if ('detail' in err) {
|
||||
if (Array.isArray(err.detail)) {
|
||||
error = err.detail.map((e: { msg?: string }) => e.msg || JSON.stringify(e)).join(', ');
|
||||
} else {
|
||||
error = err.detail;
|
||||
}
|
||||
} else {
|
||||
error = 'Server connection failed';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,7 +108,95 @@ 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 getPromptItems = async (
|
||||
token: string = '',
|
||||
query: string | null,
|
||||
viewOption: string | null,
|
||||
selectedTag: string | null,
|
||||
orderBy: string | null,
|
||||
direction: string | null,
|
||||
page: number
|
||||
) => {
|
||||
let error = null;
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
if (query) {
|
||||
searchParams.append('query', query);
|
||||
}
|
||||
if (viewOption) {
|
||||
searchParams.append('view_option', viewOption);
|
||||
}
|
||||
if (selectedTag) {
|
||||
searchParams.append('tag', selectedTag);
|
||||
}
|
||||
if (orderBy) {
|
||||
searchParams.append('order_by', orderBy);
|
||||
}
|
||||
if (direction) {
|
||||
searchParams.append('direction', direction);
|
||||
}
|
||||
if (page) {
|
||||
searchParams.append('page', page.toString());
|
||||
}
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/list?${searchParams.toString()}`, {
|
||||
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();
|
||||
})
|
||||
.then((json) => {
|
||||
return json;
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getPromptList = async (token: string = '') => {
|
||||
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/list`, {
|
||||
|
|
@ -104,6 +230,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 +261,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 +293,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 +427,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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -302,9 +302,11 @@
|
|||
<div class="flex flex-col items-start gap-0.5 h-full">
|
||||
<div class="flex flex-col h-full">
|
||||
{#if feedback.data?.sibling_model_ids}
|
||||
<div class="font-medium text-gray-600 dark:text-gray-400 flex-1">
|
||||
<Tooltip content={feedback.data?.model_id} placement="top-start">
|
||||
<div class="font-medium text-gray-600 dark:text-gray-400 flex-1 line-clamp-1">
|
||||
{feedback.data?.model_id}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={feedback.data.sibling_model_ids.join(', ')}>
|
||||
<div class=" text-[0.65rem] text-gray-600 dark:text-gray-400 line-clamp-1">
|
||||
|
|
@ -320,11 +322,11 @@
|
|||
</div>
|
||||
</Tooltip>
|
||||
{:else}
|
||||
<div
|
||||
class=" text-sm font-medium text-gray-600 dark:text-gray-400 flex-1 py-1.5"
|
||||
>
|
||||
<Tooltip content={feedback.data?.model_id} placement="top-start">
|
||||
<div class="text-sm font-medium text-gray-600 dark:text-gray-400 flex-1 py-1.5 line-clamp-1">
|
||||
{feedback.data?.model_id}
|
||||
</div>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -182,7 +182,9 @@
|
|||
alt={model.name}
|
||||
class="size-5 rounded-full object-cover"
|
||||
/>
|
||||
<span class="font-medium text-gray-800 dark:text-gray-200">{model.name}</span>
|
||||
<Tooltip content={`${model.name} (${model.id})`} placement="top-start">
|
||||
<span class="font-medium text-gray-800 dark:text-gray-200 line-clamp-1">{model.name}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-1.5 text-right font-medium text-gray-900 dark:text-white">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import { getModelHistory } from '$lib/apis/evaluations';
|
||||
import ModelActivityChart from './ModelActivityChart.svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
|
||||
export let show = false;
|
||||
export let model = null;
|
||||
|
|
@ -60,9 +61,11 @@
|
|||
<Modal size="md" bind:show>
|
||||
{#if model}
|
||||
<div class="flex justify-between dark:text-gray-300 px-5 pt-4 pb-2">
|
||||
<div class="text-lg font-medium self-center">
|
||||
{model.name}
|
||||
</div>
|
||||
<Tooltip content={`${model.name} (${model.id})`} placement="top-start">
|
||||
<div class="text-lg font-medium self-center line-clamp-1">
|
||||
{model.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<button class="self-center" on:click={close} aria-label="Close">
|
||||
<XMark className={'size-5'} />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
const { saveAs } = fileSaver;
|
||||
|
||||
import { WEBUI_NAME, config, functions as _functions, models, settings, user } from '$lib/stores';
|
||||
import { onMount, getContext, tick } from 'svelte';
|
||||
import { onMount, getContext, tick, onDestroy } from 'svelte';
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import {
|
||||
|
|
@ -53,6 +53,7 @@
|
|||
let viewOption = '';
|
||||
|
||||
let query = '';
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout>;
|
||||
let selectedTag = '';
|
||||
let selectedType = '';
|
||||
|
||||
|
|
@ -70,12 +71,14 @@
|
|||
let functions = null;
|
||||
let filteredItems = [];
|
||||
|
||||
$: if (
|
||||
functions &&
|
||||
query !== undefined &&
|
||||
selectedType !== undefined &&
|
||||
viewOption !== undefined
|
||||
) {
|
||||
$: if (query !== undefined) {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
setFilteredItems();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
$: if (functions && selectedType !== undefined && viewOption !== undefined) {
|
||||
setFilteredItems();
|
||||
}
|
||||
|
||||
|
|
@ -86,7 +89,10 @@
|
|||
(selectedType !== '' ? f.type === selectedType : true) &&
|
||||
(query === '' ||
|
||||
f.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
f.id.toLowerCase().includes(query.toLowerCase())) &&
|
||||
f.id.toLowerCase().includes(query.toLowerCase()) ||
|
||||
(f.user?.name || '').toLowerCase().includes(query.toLowerCase()) ||
|
||||
(f.user?.email || '').toLowerCase().includes(query.toLowerCase()) ||
|
||||
(f.user?.username || '').toLowerCase().includes(query.toLowerCase())) &&
|
||||
(viewOption === '' ||
|
||||
(viewOption === 'created' && f.user_id === $user?.id) ||
|
||||
(viewOption === 'shared' && f.user_id !== $user?.id))
|
||||
|
|
@ -236,6 +242,10 @@
|
|||
window.removeEventListener('blur-sm', onBlur);
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
|
|||
|
|
@ -362,6 +362,30 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full mt-2">
|
||||
<div class="flex-1 flex justify-between">
|
||||
<div class=" self-center text-xs font-medium">
|
||||
<Tooltip
|
||||
content={$i18n.t(
|
||||
'Page mode creates one document per page. Single mode combines all pages into one document for better chunking across page boundaries.'
|
||||
)}
|
||||
placement="top-start"
|
||||
>
|
||||
{$i18n.t('PDF Loader Mode')}
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="">
|
||||
<select
|
||||
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
|
||||
bind:value={RAGConfig.PDF_LOADER_MODE}
|
||||
>
|
||||
<option value="page">{$i18n.t('Page')}</option>
|
||||
<option value="single">{$i18n.t('Single')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if RAGConfig.CONTENT_EXTRACTION_ENGINE === 'datalab_marker'}
|
||||
<div class="my-0.5 flex gap-2 pr-2">
|
||||
<Tooltip
|
||||
|
|
|
|||
|
|
@ -40,6 +40,10 @@
|
|||
import Eye from '$lib/components/icons/Eye.svelte';
|
||||
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
|
||||
import { goto } from '$app/navigation';
|
||||
import { DropdownMenu } from 'bits-ui';
|
||||
import { flyAndScale } from '$lib/utils/transitions';
|
||||
import Dropdown from '$lib/components/common/Dropdown.svelte';
|
||||
import AdminViewSelector from './Models/AdminViewSelector.svelte';
|
||||
|
||||
let shiftKey = false;
|
||||
|
||||
|
|
@ -58,21 +62,43 @@
|
|||
let showConfigModal = false;
|
||||
let showManageModal = false;
|
||||
|
||||
let viewOption = ''; // '' = All, 'enabled', 'disabled', 'visible', 'hidden'
|
||||
|
||||
$: if (models) {
|
||||
filteredModels = models
|
||||
.filter((m) => searchValue === '' || m.name.toLowerCase().includes(searchValue.toLowerCase()))
|
||||
.filter((m) => {
|
||||
if (viewOption === 'enabled') return m?.is_active ?? true;
|
||||
if (viewOption === 'disabled') return !(m?.is_active ?? true);
|
||||
if (viewOption === 'visible') return !(m?.meta?.hidden ?? false);
|
||||
if (viewOption === 'hidden') return m?.meta?.hidden === true;
|
||||
return true; // All
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// // Check if either model is inactive and push them to the bottom
|
||||
// if ((a.is_active ?? true) !== (b.is_active ?? true)) {
|
||||
// return (b.is_active ?? true) - (a.is_active ?? true);
|
||||
// }
|
||||
// If both models' active states are the same, sort alphabetically
|
||||
return (a?.name ?? a?.id ?? '').localeCompare(b?.name ?? b?.id ?? '');
|
||||
});
|
||||
}
|
||||
|
||||
let searchValue = '';
|
||||
|
||||
const enableAllHandler = async () => {
|
||||
const modelsToEnable = filteredModels.filter((m) => !(m.is_active ?? true));
|
||||
// Optimistic UI update
|
||||
modelsToEnable.forEach((m) => (m.is_active = true));
|
||||
models = models;
|
||||
// Sync with server
|
||||
await Promise.all(modelsToEnable.map((model) => toggleModelById(localStorage.token, model.id)));
|
||||
};
|
||||
|
||||
const disableAllHandler = async () => {
|
||||
const modelsToDisable = filteredModels.filter((m) => m.is_active ?? true);
|
||||
// Optimistic UI update
|
||||
modelsToDisable.forEach((m) => (m.is_active = false));
|
||||
models = models;
|
||||
// Sync with server
|
||||
await Promise.all(modelsToDisable.map((model) => toggleModelById(localStorage.token, model.id)));
|
||||
};
|
||||
|
||||
const downloadModels = async (models) => {
|
||||
let blob = new Blob([JSON.stringify(models)], {
|
||||
type: 'application/json'
|
||||
|
|
@ -275,41 +301,48 @@
|
|||
{#if selectedModelId === null}
|
||||
<div class="flex flex-col gap-1 mt-1.5 mb-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center md:self-center text-xl font-medium px-0.5 gap-2">
|
||||
{$i18n.t('Models')}
|
||||
<span class="text-lg font-medium text-gray-500 dark:text-gray-300"
|
||||
>{filteredModels.length}</span
|
||||
>
|
||||
<div class="flex items-center md:self-center text-xl font-medium px-0.5 gap-2 shrink-0">
|
||||
<div>
|
||||
{$i18n.t('Models')}
|
||||
</div>
|
||||
|
||||
<div class="text-lg font-medium text-gray-500 dark:text-gray-500">
|
||||
{filteredModels.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Tooltip content={$i18n.t('Manage Models')}>
|
||||
<button
|
||||
class=" p-1 rounded-full flex gap-1 items-center"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
showManageModal = true;
|
||||
}}
|
||||
>
|
||||
<Download />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<div class="flex w-full justify-end gap-1.5">
|
||||
<button
|
||||
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
showManageModal = true;
|
||||
}}
|
||||
>
|
||||
<div class=" self-center font-medium line-clamp-1">
|
||||
{$i18n.t('Manage')}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<Tooltip content={$i18n.t('Settings')}>
|
||||
<button
|
||||
class=" p-1 rounded-full flex gap-1 items-center"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
showConfigModal = true;
|
||||
}}
|
||||
>
|
||||
<Cog6 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button
|
||||
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
showConfigModal = true;
|
||||
}}
|
||||
>
|
||||
<div class=" self-center font-medium line-clamp-1">
|
||||
{$i18n.t('Settings')}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=" flex flex-1 items-center w-full space-x-2">
|
||||
<div
|
||||
class="py-2 bg-white dark:bg-gray-900 rounded-3xl border border-gray-100/30 dark:border-gray-850/30"
|
||||
>
|
||||
<div class="px-3.5 flex flex-1 items-center w-full space-x-2 py-0.5 pb-2">
|
||||
<div class="flex flex-1 items-center">
|
||||
<div class=" self-center ml-1 mr-3">
|
||||
<Search className="size-3.5" />
|
||||
|
|
@ -333,156 +366,215 @@
|
|||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=" my-2 mb-5" id="model-list">
|
||||
{#if models.length > 0}
|
||||
{#each filteredModels as model, modelIdx (`${model.id}-${modelIdx}`)}
|
||||
<div
|
||||
class=" flex space-x-4 cursor-pointer w-full px-3 py-2 dark:hover:bg-white/5 hover:bg-black/5 rounded-lg transition {model
|
||||
?.meta?.hidden
|
||||
? 'opacity-50 dark:opacity-50'
|
||||
: ''}"
|
||||
id="model-item-{model.id}"
|
||||
>
|
||||
<div class="px-3 flex w-full items-center bg-transparent overflow-x-auto scrollbar-none">
|
||||
<div
|
||||
class="flex gap-0.5 w-fit text-center text-sm rounded-full bg-transparent whitespace-nowrap"
|
||||
>
|
||||
<AdminViewSelector bind:value={viewOption} />
|
||||
</div>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<Dropdown>
|
||||
<Tooltip content={$i18n.t('Actions')}>
|
||||
<button
|
||||
class=" flex flex-1 text-left space-x-3.5 cursor-pointer w-full"
|
||||
class="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
selectedModelId = model.id;
|
||||
}}
|
||||
>
|
||||
<div class=" self-center w-8">
|
||||
<div
|
||||
class=" rounded-full object-cover {(model?.is_active ?? true)
|
||||
? ''
|
||||
: 'opacity-50 dark:opacity-50'} "
|
||||
>
|
||||
<img
|
||||
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${model.id}`}
|
||||
alt="modelfile profile"
|
||||
class=" rounded-full w-full h-auto object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=" flex-1 self-center {(model?.is_active ?? true) ? '' : 'text-gray-500'}">
|
||||
<Tooltip
|
||||
content={marked.parse(
|
||||
!!model?.meta?.description
|
||||
? model?.meta?.description
|
||||
: model?.ollama?.digest
|
||||
? `${model?.ollama?.digest} **(${model?.ollama?.modified_at})**`
|
||||
: model.id
|
||||
)}
|
||||
className=" w-fit"
|
||||
placement="top-start"
|
||||
>
|
||||
<div class=" font-semibold line-clamp-1">{model.name}</div>
|
||||
</Tooltip>
|
||||
<div class=" text-xs overflow-hidden text-ellipsis line-clamp-1 text-gray-500">
|
||||
<span class=" line-clamp-1">
|
||||
{!!model?.meta?.description
|
||||
? model?.meta?.description
|
||||
: model?.ollama?.digest
|
||||
? `${model.id} (${model?.ollama?.digest})`
|
||||
: model.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<EllipsisHorizontal className="size-4" />
|
||||
</button>
|
||||
<div class="flex flex-row gap-0.5 items-center self-center">
|
||||
{#if shiftKey}
|
||||
<Tooltip content={model?.meta?.hidden ? $i18n.t('Show') : $i18n.t('Hide')}>
|
||||
</Tooltip>
|
||||
|
||||
<div slot="content">
|
||||
<DropdownMenu.Content
|
||||
class="w-full max-w-[170px] rounded-xl p-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
|
||||
sideOffset={-2}
|
||||
side="bottom"
|
||||
align="end"
|
||||
transition={flyAndScale}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
on:click={() => {
|
||||
enableAllHandler();
|
||||
}}
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
<div class="flex items-center">{$i18n.t('Enable All')}</div>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
on:click={() => {
|
||||
disableAllHandler();
|
||||
}}
|
||||
>
|
||||
<EyeSlash className="size-4" />
|
||||
<div class="flex items-center">{$i18n.t('Disable All')}</div>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<div class="px-3 my-2" id="model-list">
|
||||
{#if filteredModels.length > 0}
|
||||
{#each filteredModels as model, modelIdx (`${model.id}-${modelIdx}`)}
|
||||
<div
|
||||
class=" flex space-x-4 cursor-pointer w-full px-3 py-2 dark:hover:bg-white/5 hover:bg-black/5 rounded-xl transition {model
|
||||
?.meta?.hidden
|
||||
? 'opacity-50 dark:opacity-50'
|
||||
: ''}"
|
||||
id="model-item-{model.id}"
|
||||
>
|
||||
<button
|
||||
class=" flex flex-1 text-left space-x-3.5 cursor-pointer w-full"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
selectedModelId = model.id;
|
||||
}}
|
||||
>
|
||||
<div class=" self-center w-8">
|
||||
<div
|
||||
class=" rounded-full object-cover {(model?.is_active ?? true)
|
||||
? ''
|
||||
: 'opacity-50 dark:opacity-50'} "
|
||||
>
|
||||
<img
|
||||
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${model.id}`}
|
||||
alt="modelfile profile"
|
||||
class=" rounded-full w-full h-auto object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class=" flex-1 self-center {(model?.is_active ?? true) ? '' : 'text-gray-500'}"
|
||||
>
|
||||
<Tooltip
|
||||
content={marked.parse(
|
||||
!!model?.meta?.description
|
||||
? model?.meta?.description
|
||||
: model?.ollama?.digest
|
||||
? `${model?.ollama?.digest} **(${model?.ollama?.modified_at})**`
|
||||
: model.id
|
||||
)}
|
||||
className=" w-fit"
|
||||
placement="top-start"
|
||||
>
|
||||
<div class=" font-semibold line-clamp-1">{model.name}</div>
|
||||
</Tooltip>
|
||||
<div class=" text-xs overflow-hidden text-ellipsis line-clamp-1 text-gray-500">
|
||||
<span class=" line-clamp-1">
|
||||
{!!model?.meta?.description
|
||||
? model?.meta?.description
|
||||
: model?.ollama?.digest
|
||||
? `${model.id} (${model?.ollama?.digest})`
|
||||
: model.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div class="flex flex-row gap-0.5 items-center self-center">
|
||||
{#if shiftKey}
|
||||
<Tooltip content={model?.meta?.hidden ? $i18n.t('Show') : $i18n.t('Hide')}>
|
||||
<button
|
||||
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
hideModelHandler(model);
|
||||
}}
|
||||
>
|
||||
{#if model?.meta?.hidden}
|
||||
<EyeSlash />
|
||||
{:else}
|
||||
<Eye />
|
||||
{/if}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{:else}
|
||||
<button
|
||||
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
hideModelHandler(model);
|
||||
selectedModelId = model.id;
|
||||
}}
|
||||
>
|
||||
{#if model?.meta?.hidden}
|
||||
<EyeSlash />
|
||||
{:else}
|
||||
<Eye />
|
||||
{/if}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{:else}
|
||||
<button
|
||||
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
selectedModelId = model.id;
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<ModelMenu
|
||||
user={$user}
|
||||
{model}
|
||||
exportHandler={() => {
|
||||
exportModelHandler(model);
|
||||
}}
|
||||
hideHandler={() => {
|
||||
hideModelHandler(model);
|
||||
}}
|
||||
pinModelHandler={() => {
|
||||
pinModelHandler(model.id);
|
||||
}}
|
||||
copyLinkHandler={() => {
|
||||
copyLinkHandler(model);
|
||||
}}
|
||||
cloneHandler={() => {
|
||||
cloneHandler(model);
|
||||
}}
|
||||
onClose={() => {}}
|
||||
>
|
||||
<button
|
||||
class="self-center w-fit text-sm p-1.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
||||
type="button"
|
||||
<ModelMenu
|
||||
user={$user}
|
||||
{model}
|
||||
exportHandler={() => {
|
||||
exportModelHandler(model);
|
||||
}}
|
||||
hideHandler={() => {
|
||||
hideModelHandler(model);
|
||||
}}
|
||||
pinModelHandler={() => {
|
||||
pinModelHandler(model.id);
|
||||
}}
|
||||
copyLinkHandler={() => {
|
||||
copyLinkHandler(model);
|
||||
}}
|
||||
cloneHandler={() => {
|
||||
cloneHandler(model);
|
||||
}}
|
||||
onClose={() => {}}
|
||||
>
|
||||
<EllipsisHorizontal className="size-5" />
|
||||
</button>
|
||||
</ModelMenu>
|
||||
<button
|
||||
class="self-center w-fit text-sm p-1.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
||||
type="button"
|
||||
>
|
||||
<EllipsisHorizontal className="size-5" />
|
||||
</button>
|
||||
</ModelMenu>
|
||||
|
||||
<div class="ml-1">
|
||||
<Tooltip
|
||||
content={(model?.is_active ?? true) ? $i18n.t('Enabled') : $i18n.t('Disabled')}
|
||||
>
|
||||
<Switch
|
||||
bind:state={model.is_active}
|
||||
on:change={async () => {
|
||||
toggleModelHandler(model);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="ml-1">
|
||||
<Tooltip
|
||||
content={(model?.is_active ?? true)
|
||||
? $i18n.t('Enabled')
|
||||
: $i18n.t('Disabled')}
|
||||
>
|
||||
<Switch
|
||||
bind:state={model.is_active}
|
||||
on:change={async () => {
|
||||
toggleModelHandler(model);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class=" w-full h-full flex flex-col justify-center items-center my-16 mb-24">
|
||||
<div class="max-w-md text-center">
|
||||
<div class=" text-3xl mb-3">😕</div>
|
||||
<div class=" text-lg font-medium mb-1">{$i18n.t('No models found')}</div>
|
||||
<div class=" text-gray-500 text-center text-xs">
|
||||
{$i18n.t('Try adjusting your search or filter to find what you are looking for.')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center w-full h-20">
|
||||
<div class="text-gray-500 dark:text-gray-400 text-xs">
|
||||
{$i18n.t('No models found')}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if $user?.role === 'admin'}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
<script lang="ts">
|
||||
import { Select } from 'bits-ui';
|
||||
import { getContext } from 'svelte';
|
||||
|
||||
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
||||
import Check from '$lib/components/icons/Check.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let value = '';
|
||||
export let placeholder = $i18n.t('Select view');
|
||||
export let onChange: (value: string) => void = () => {};
|
||||
|
||||
const items = [
|
||||
{ value: '', label: $i18n.t('All') },
|
||||
{ value: 'enabled', label: $i18n.t('Enabled') },
|
||||
{ value: 'disabled', label: $i18n.t('Disabled') },
|
||||
{ value: 'visible', label: $i18n.t('Visible') },
|
||||
{ value: 'hidden', label: $i18n.t('Hidden') }
|
||||
];
|
||||
</script>
|
||||
|
||||
<Select.Root
|
||||
selected={items.find((item) => item.value === value)}
|
||||
{items}
|
||||
onSelectedChange={(selectedItem) => {
|
||||
value = selectedItem.value;
|
||||
onChange(value);
|
||||
}}
|
||||
>
|
||||
<Select.Trigger
|
||||
class="relative w-full flex items-center gap-0.5 px-2.5 py-1.5 bg-gray-50 dark:bg-gray-850 rounded-xl"
|
||||
aria-label={placeholder}
|
||||
>
|
||||
<Select.Value
|
||||
class="inline-flex h-input px-0.5 w-full outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden"
|
||||
{placeholder}
|
||||
/>
|
||||
<ChevronDown className="size-3.5" strokeWidth="2.5" />
|
||||
</Select.Trigger>
|
||||
|
||||
<Select.Content
|
||||
class="rounded-2xl min-w-[170px] p-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
|
||||
sameWidth={false}
|
||||
align="start"
|
||||
>
|
||||
{#each items as item}
|
||||
<Select.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"
|
||||
value={item.value}
|
||||
label={item.label}
|
||||
>
|
||||
{item.label}
|
||||
|
||||
{#if value === item.value}
|
||||
<div class="ml-auto">
|
||||
<Check />
|
||||
</div>
|
||||
{/if}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
|
|
@ -50,7 +50,7 @@
|
|||
<div class="flex items-center gap-1">
|
||||
<EllipsisVertical className="size-4 cursor-move model-item-handle" />
|
||||
|
||||
<div class=" text-sm flex-1 py-1 rounded-lg">
|
||||
<div class=" text-sm flex-1 py-1 rounded-lg line-clamp-1">
|
||||
{#if $models.find((model) => model.id === modelId)}
|
||||
{$models.find((model) => model.id === modelId).name}
|
||||
{:else}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import { toast } from 'svelte-sonner';
|
||||
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import Textarea from '$lib/components/common/Textarea.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
|
|
@ -35,7 +36,8 @@
|
|||
'perplexity',
|
||||
'sougou',
|
||||
'firecrawl',
|
||||
'external'
|
||||
'external',
|
||||
'yandex'
|
||||
];
|
||||
let webLoaderEngines = ['playwright', 'firecrawl', 'tavily', 'external'];
|
||||
|
||||
|
|
@ -735,6 +737,53 @@
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else if webConfig.WEB_SEARCH_ENGINE === 'yandex'}
|
||||
<div class="mb-2.5 flex w-full flex-col">
|
||||
<div>
|
||||
<div class=" self-center text-xs font-medium mb-1">
|
||||
{$i18n.t('Yandex Web Search URL')}
|
||||
</div>
|
||||
|
||||
<div class="flex w-full">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
|
||||
type="text"
|
||||
placeholder={$i18n.t('Enter Yandex Web Search URL')}
|
||||
bind:value={webConfig.YANDEX_WEB_SEARCH_URL}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<div class=" self-center text-xs font-medium mb-1">
|
||||
{$i18n.t('Yandex Web Search API Key')}
|
||||
</div>
|
||||
|
||||
<SensitiveInput
|
||||
placeholder={$i18n.t('Enter Yandex Web Search API Key')}
|
||||
bind:value={webConfig.YANDEX_WEB_SEARCH_API_KEY}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-2.5">
|
||||
<div class=" mb-1 text-xs font-medium">{$i18n.t('Yandex Web Search config')}</div>
|
||||
|
||||
<Tooltip
|
||||
content={$i18n.t('Leave empty to use the default config, or enter a valid json (see https://yandex.cloud/en/docs/search-api/api-ref/WebSearch/search#yandex.cloud.searchapi.v2.WebSearchRequest)')}
|
||||
placement="top-start"
|
||||
>
|
||||
<Textarea
|
||||
bind:value={webConfig.YANDEX_WEB_SEARCH_CONFIG}
|
||||
placeholder={$i18n.t(
|
||||
'Leave empty to use the default config, or enter a valid json (see https://yandex.cloud/en/docs/search-api/api-ref/WebSearch/search#yandex.cloud.searchapi.v2.WebSearchRequest)'
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if webConfig.WEB_SEARCH_ENGINE === 'duckduckgo'}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { getContext, onDestroy } from 'svelte';
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
|
|
@ -30,6 +30,7 @@
|
|||
let total = null;
|
||||
|
||||
let query = '';
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout>;
|
||||
let orderBy = 'created_at'; // default sort key
|
||||
let direction = 'desc'; // default sort order
|
||||
|
||||
|
|
@ -79,13 +80,21 @@
|
|||
getUserList();
|
||||
};
|
||||
|
||||
$: if (page !== null && query !== null && orderBy !== null && direction !== null) {
|
||||
$: if (page !== null && orderBy !== null && direction !== null) {
|
||||
getUserList();
|
||||
}
|
||||
|
||||
$: if (query) {
|
||||
page = 1;
|
||||
$: if (query !== undefined) {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
page = 1;
|
||||
getUserList();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class=" max-h-full h-full w-full flex flex-col overflow-y-hidden">
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<script>
|
||||
<script lang="ts">
|
||||
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
|
||||
import { WEBUI_NAME, config, user, showSidebar } from '$lib/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount, getContext } from 'svelte';
|
||||
import { onMount, getContext, onDestroy } from 'svelte';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
|
|
@ -43,6 +43,7 @@
|
|||
let total = null;
|
||||
|
||||
let query = '';
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout>;
|
||||
let orderBy = 'created_at'; // default sort key
|
||||
let direction = 'asc'; // default sort order
|
||||
|
||||
|
|
@ -97,9 +98,21 @@
|
|||
}
|
||||
};
|
||||
|
||||
$: if (query !== null && page !== null && orderBy !== null && direction !== null) {
|
||||
$: if (query !== undefined) {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
page = 1;
|
||||
getUserList();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
$: if (page !== null && orderBy !== null && direction !== null) {
|
||||
getUserList();
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<ConfirmDialog
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<script>
|
||||
<script lang="ts">
|
||||
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
|
||||
import { WEBUI_NAME, config, user as _user, showSidebar } from '$lib/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
|
|
@ -38,6 +38,8 @@
|
|||
let total = null;
|
||||
|
||||
let query = '';
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
let orderBy = 'name'; // default sort key
|
||||
let direction = 'asc'; // default sort order
|
||||
|
||||
|
|
@ -77,13 +79,16 @@
|
|||
}
|
||||
};
|
||||
|
||||
$: if (
|
||||
channel !== null &&
|
||||
page !== null &&
|
||||
query !== null &&
|
||||
orderBy !== null &&
|
||||
direction !== null
|
||||
) {
|
||||
// Debounce only query changes
|
||||
$: if (query !== undefined && channel !== null) {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
getUserList();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Immediate response to page/sort changes
|
||||
$: if (channel !== null && page && orderBy && direction) {
|
||||
getUserList();
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -147,6 +147,14 @@
|
|||
text = text.replaceAll('{{USER_NAME}}', name);
|
||||
}
|
||||
|
||||
if (text.includes('{{USER_EMAIL}}')) {
|
||||
const email = sessionUser?.email || '';
|
||||
|
||||
if (email) {
|
||||
text = text.replaceAll('{{USER_EMAIL}}', email);
|
||||
}
|
||||
}
|
||||
|
||||
if (text.includes('{{USER_BIO}}')) {
|
||||
const bio = sessionUser?.bio || '';
|
||||
|
||||
|
|
|
|||
|
|
@ -1282,6 +1282,11 @@
|
|||
const modelId = selectedModels[0];
|
||||
const model = $models.filter((m) => m.id === modelId).at(0);
|
||||
|
||||
if (!model) {
|
||||
toast.error($i18n.t('Model not found'));
|
||||
return;
|
||||
}
|
||||
|
||||
const messages = createMessagesList(history, history.currentId);
|
||||
const parentMessage = messages.length !== 0 ? messages.at(-1) : null;
|
||||
|
||||
|
|
@ -1398,7 +1403,12 @@
|
|||
};
|
||||
|
||||
const chatCompletionEventHandler = async (data, message, chatId) => {
|
||||
const { id, done, choices, content, sources, selected_model_id, error, usage } = data;
|
||||
const { id, done, choices, content, output, sources, selected_model_id, error, usage } = data;
|
||||
|
||||
// Store raw OR-aligned output items from backend
|
||||
if (output) {
|
||||
message.output = output;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
await handleOpenAIError(error, message);
|
||||
|
|
@ -1894,7 +1904,9 @@
|
|||
: undefined,
|
||||
..._messages.map((message) => ({
|
||||
...message,
|
||||
content: processDetails(message.content)
|
||||
content: processDetails(message.content),
|
||||
// Include output for temp chats (backend will use it and strip before LLM)
|
||||
...(message.output ? { output: message.output } : {})
|
||||
}))
|
||||
].filter((message) => message);
|
||||
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@
|
|||
>{$i18n.t('Select a tool')}</option
|
||||
>
|
||||
|
||||
{#each $tools.filter((tool) => !tool?.id?.startsWith('server:')) as tool, toolIdx}
|
||||
{#each $tools.filter((tool) => !tool?.id?.startsWith('server:')).sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')) as tool, toolIdx}
|
||||
<option value={tool.id} class="bg-gray-100 dark:bg-gray-800">{tool.name}</option>
|
||||
{/each}
|
||||
{:else if tab === 'functions'}
|
||||
|
|
@ -180,7 +180,7 @@
|
|||
>{$i18n.t('Select a function')}</option
|
||||
>
|
||||
|
||||
{#each $functions as func, funcIdx}
|
||||
{#each $functions.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')) as func, funcIdx}
|
||||
<option value={func.id} class="bg-gray-100 dark:bg-gray-800">{func.name}</option>
|
||||
{/each}
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -221,6 +221,14 @@
|
|||
text = text.replaceAll('{{USER_NAME}}', name);
|
||||
}
|
||||
|
||||
if (text.includes('{{USER_EMAIL}}')) {
|
||||
const email = sessionUser?.email || '';
|
||||
|
||||
if (email) {
|
||||
text = text.replaceAll('{{USER_EMAIL}}', email);
|
||||
}
|
||||
}
|
||||
|
||||
if (text.includes('{{USER_BIO}}')) {
|
||||
const bio = sessionUser?.bio || '';
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
|
||||
let selectedIdx = 0;
|
||||
let items = [];
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
export let filteredItems = [];
|
||||
$: filteredItems = [
|
||||
|
|
@ -69,10 +70,17 @@
|
|||
|
||||
$: items = [...folderItems, ...knowledgeItems, ...fileItems];
|
||||
|
||||
$: if (query !== null) {
|
||||
getItems();
|
||||
$: if (query !== undefined) {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
getItems();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
});
|
||||
|
||||
const getItems = () => {
|
||||
getFolderItems();
|
||||
getKnowledgeItems();
|
||||
|
|
|
|||
|
|
@ -11,10 +11,23 @@
|
|||
|
||||
let selectedPromptIdx = 0;
|
||||
export let filteredItems = [];
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout>;
|
||||
let debouncedQuery = '';
|
||||
|
||||
$: if (query !== undefined) {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
debouncedQuery = query;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
});
|
||||
|
||||
$: filteredItems = prompts
|
||||
.filter((p) => p.command.toLowerCase().includes(query.toLowerCase()))
|
||||
.sort((a, b) => a.title.localeCompare(b.title));
|
||||
.filter((p) => p.command.toLowerCase().includes(debouncedQuery.toLowerCase()))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
$: if (query) {
|
||||
selectedPromptIdx = 0;
|
||||
|
|
@ -42,7 +55,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 +75,7 @@
|
|||
</span>
|
||||
|
||||
<span class=" text-xs text-gray-600 dark:text-gray-100">
|
||||
{promptItem.title}
|
||||
{promptItem.name}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
import { fly } from 'svelte/transition';
|
||||
import { flyAndScale } from '$lib/utils/transitions';
|
||||
|
||||
import { config, user, tools as _tools, mobile, knowledge, chats } from '$lib/stores';
|
||||
import { config, user, tools as _tools, mobile, knowledge } from '$lib/stores';
|
||||
import { getKnowledgeBases } from '$lib/apis/knowledge';
|
||||
|
||||
import { createPicker } from '$lib/utils/google-drive-picker';
|
||||
|
|
@ -269,37 +269,35 @@
|
|||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{#if ($chats ?? []).length > 0}
|
||||
<Tooltip
|
||||
content={fileUploadCapableModels.length !== selectedModels.length
|
||||
? $i18n.t('Model(s) do not support file upload')
|
||||
: !fileUploadEnabled
|
||||
? $i18n.t('You do not have permission to upload files.')
|
||||
: ''}
|
||||
className="w-full"
|
||||
<Tooltip
|
||||
content={fileUploadCapableModels.length !== selectedModels.length
|
||||
? $i18n.t('Model(s) do not support file upload')
|
||||
: !fileUploadEnabled
|
||||
? $i18n.t('You do not have permission to upload files.')
|
||||
: ''}
|
||||
className="w-full"
|
||||
>
|
||||
<button
|
||||
class="flex gap-2 w-full items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
|
||||
? 'opacity-50'
|
||||
: ''}"
|
||||
on:click={() => {
|
||||
tab = 'chats';
|
||||
}}
|
||||
>
|
||||
<button
|
||||
class="flex gap-2 w-full items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
|
||||
? 'opacity-50'
|
||||
: ''}"
|
||||
on:click={() => {
|
||||
tab = 'chats';
|
||||
}}
|
||||
>
|
||||
<ClockRotateRight />
|
||||
<ClockRotateRight />
|
||||
|
||||
<div class="flex items-center w-full justify-between">
|
||||
<div class=" line-clamp-1">
|
||||
{$i18n.t('Reference Chats')}
|
||||
</div>
|
||||
|
||||
<div class="text-gray-500">
|
||||
<ChevronRight />
|
||||
</div>
|
||||
<div class="flex items-center w-full justify-between">
|
||||
<div class=" line-clamp-1">
|
||||
{$i18n.t('Reference Chats')}
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
<div class="text-gray-500">
|
||||
<ChevronRight />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{#if fileUploadEnabled}
|
||||
{#if $config?.features?.enable_google_drive_integration}
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{#if filter?.has_user_valves}
|
||||
{#if filter?.has_user_valves && ($user?.role === 'admin' || ($user?.permissions?.chat?.valves ?? true))}
|
||||
<div class=" shrink-0">
|
||||
<Tooltip content={$i18n.t('Valves')}>
|
||||
<button
|
||||
|
|
@ -371,7 +371,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{#if tools[toolId]?.has_user_valves}
|
||||
{#if tools[toolId]?.has_user_valves && ($user?.role === 'admin' || ($user?.permissions?.chat?.valves ?? true))}
|
||||
<div class=" shrink-0">
|
||||
<Tooltip content={$i18n.t('Valves')}>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -53,16 +53,16 @@
|
|||
const exportTableToCSVHandler = (token, tokenIdx = 0) => {
|
||||
console.log('Exporting table to CSV');
|
||||
|
||||
// Extract header row text and escape for CSV.
|
||||
const header = token.header.map((headerCell) => `"${headerCell.text.replace(/"/g, '""')}"`);
|
||||
// Extract header row text, decode HTML entities, and escape for CSV.
|
||||
const header = token.header.map((headerCell) => `"${decode(headerCell.text).replace(/"/g, '""')}"`);
|
||||
|
||||
// Create an array for rows that will hold the mapped cell text.
|
||||
const rows = token.rows.map((row) =>
|
||||
row.map((cell) => {
|
||||
// Map tokens into a single text
|
||||
const cellContent = cell.tokens.map((token) => token.text).join('');
|
||||
// Escape double quotes and wrap the content in double quotes
|
||||
return `"${cellContent.replace(/"/g, '""')}"`;
|
||||
// Decode HTML entities and escape double quotes, wrap in double quotes
|
||||
return `"${decode(cellContent).replace(/"/g, '""')}"`;
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
import { config, models, tags as _tags } from '$lib/stores';
|
||||
import Tags from '$lib/components/common/Tags.svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
|
|
@ -248,4 +249,20 @@
|
|||
{$i18n.t('Save')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if $config?.features.enable_community_sharing && message?.model}
|
||||
<div class="mt-3 pt-3 border-t border-gray-100/30 dark:border-gray-850/30">
|
||||
<a
|
||||
href={`https://openwebui.com/models?q=${encodeURIComponent(message.model)}`}
|
||||
target="_blank"
|
||||
class="flex cursor-pointer items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-850 w-full px-3 py-2 rounded-xl transition"
|
||||
>
|
||||
<div class="self-center">
|
||||
<div class="text-sm font-medium">{$i18n.t('Leave a review for {{modelName}}', { modelName: message.model })}</div>
|
||||
<div class="text-xs text-gray-500">{$i18n.t('Help the community discover great models')}</div>
|
||||
</div>
|
||||
<ChevronRight className="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@
|
|||
import PinSlash from '$lib/components/icons/PinSlash.svelte';
|
||||
import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
|
||||
import Link from '$lib/components/icons/Link.svelte';
|
||||
import { settings } from '$lib/stores';
|
||||
import { config, settings } from '$lib/stores';
|
||||
import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
|
|
@ -45,7 +46,7 @@
|
|||
|
||||
<DropdownMenu.Content
|
||||
strategy="fixed"
|
||||
class="w-full max-w-[180px] text-sm rounded-2xl p-1 z-[9999999] bg-white dark:bg-gray-850 dark:text-white shadow-lg border border-gray-100 dark:border-gray-800"
|
||||
class="w-full max-w-[210px] text-sm rounded-2xl p-1 z-[9999999] bg-white dark:bg-gray-850 dark:text-white shadow-lg border border-gray-100 dark:border-gray-800"
|
||||
sideOffset={-2}
|
||||
side="bottom"
|
||||
align="end"
|
||||
|
|
@ -93,5 +94,23 @@
|
|||
|
||||
<div class="flex items-center">{$i18n.t('Copy Link')}</div>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
{#if $config?.features.enable_community_sharing}
|
||||
<DropdownMenu.Item
|
||||
type="button"
|
||||
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition items-center gap-2"
|
||||
on:click={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
window.open(`https://openwebui.com/models?q=${encodeURIComponent(model?.id ?? '')}`, '_blank');
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
<GlobeAlt className="size-4" />
|
||||
|
||||
<div class="flex items-center">{$i18n.t('Community Reviews')}</div>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@
|
|||
import { goto } from '$app/navigation';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import ArchivedChatsModal from '$lib/components/layout/ArchivedChatsModal.svelte';
|
||||
import SharedChatsModal from '$lib/components/layout/SharedChatsModal.svelte';
|
||||
import FilesModal from '$lib/components/layout/FilesModal.svelte';
|
||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
|
|
@ -32,9 +35,11 @@
|
|||
// Chats
|
||||
let importFiles;
|
||||
|
||||
let showArchiveConfirm = false;
|
||||
let showDeleteConfirm = false;
|
||||
let showArchiveConfirmDialog = false;
|
||||
let showDeleteConfirmDialog = false;
|
||||
let showArchivedChatsModal = false;
|
||||
let showSharedChatsModal = false;
|
||||
let showFilesModal = false;
|
||||
|
||||
let chatImportInputElement: HTMLInputElement;
|
||||
|
||||
|
|
@ -135,266 +140,153 @@
|
|||
</script>
|
||||
|
||||
<ArchivedChatsModal bind:show={showArchivedChatsModal} onUpdate={handleArchivedChatsChange} />
|
||||
<SharedChatsModal bind:show={showSharedChatsModal} />
|
||||
<FilesModal bind:show={showFilesModal} />
|
||||
|
||||
<div id="tab-chats" class="flex flex-col h-full justify-between space-y-3 text-sm">
|
||||
<div class=" space-y-2 overflow-y-scroll max-h-[28rem] md:max-h-full">
|
||||
<div class="flex flex-col">
|
||||
<input
|
||||
id="chat-import-input"
|
||||
bind:this={chatImportInputElement}
|
||||
bind:files={importFiles}
|
||||
type="file"
|
||||
accept=".json"
|
||||
hidden
|
||||
/>
|
||||
<button
|
||||
class=" flex rounded-md py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
|
||||
on:click={() => {
|
||||
chatImportInputElement.click();
|
||||
}}
|
||||
>
|
||||
<div class=" self-center mr-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
<ConfirmDialog
|
||||
title={$i18n.t('Archive All Chats')}
|
||||
message={$i18n.t('Are you sure you want to archive all chats? This action cannot be undone.')}
|
||||
bind:show={showArchiveConfirmDialog}
|
||||
on:confirm={archiveAllChatsHandler}
|
||||
on:cancel={() => {
|
||||
showArchiveConfirmDialog = false;
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
title={$i18n.t('Delete All Chats')}
|
||||
message={$i18n.t('Are you sure you want to delete all chats? This action cannot be undone.')}
|
||||
bind:show={showDeleteConfirmDialog}
|
||||
on:confirm={deleteAllChatsHandler}
|
||||
on:cancel={() => {
|
||||
showDeleteConfirmDialog = false;
|
||||
}}
|
||||
/>
|
||||
|
||||
<div id="tab-chats" class="flex flex-col h-full justify-between text-sm">
|
||||
<div class="space-y-3 overflow-y-scroll max-h-[28rem] md:max-h-full">
|
||||
<input
|
||||
id="chat-import-input"
|
||||
bind:this={chatImportInputElement}
|
||||
bind:files={importFiles}
|
||||
type="file"
|
||||
accept=".json"
|
||||
hidden
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 text-sm font-medium">{$i18n.t('Chats')}</div>
|
||||
|
||||
<div>
|
||||
<div class="py-0.5 flex w-full justify-between">
|
||||
<div class="self-center text-xs">{$i18n.t('Import Chats')}</div>
|
||||
<button
|
||||
class="p-1 px-3 text-xs flex rounded-sm transition"
|
||||
on:click={() => {
|
||||
chatImportInputElement.click();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 9.5a.75.75 0 0 1-.75-.75V8.06l-.72.72a.75.75 0 0 1-1.06-1.06l2-2a.75.75 0 0 1 1.06 0l2 2a.75.75 0 1 1-1.06 1.06l-.72-.72v2.69a.75.75 0 0 1-.75.75Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span class="self-center">{$i18n.t('Import')}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class=" self-center text-sm font-medium">{$i18n.t('Import Chats')}</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if $user?.role === 'admin' || ($user.permissions?.chat?.export ?? true)}
|
||||
<button
|
||||
class=" flex rounded-md py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
|
||||
on:click={() => {
|
||||
exportChats();
|
||||
}}
|
||||
>
|
||||
<div class=" self-center mr-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
<div>
|
||||
<div class="py-0.5 flex w-full justify-between">
|
||||
<div class="self-center text-xs">{$i18n.t('Export Chats')}</div>
|
||||
<button
|
||||
class="p-1 px-3 text-xs flex rounded-sm transition"
|
||||
on:click={() => {
|
||||
exportChats();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 3.5a.75.75 0 0 1 .75.75v2.69l.72-.72a.75.75 0 1 1 1.06 1.06l-2 2a.75.75 0 0 1-1.06 0l-2-2a.75.75 0 0 1 1.06-1.06l.72.72V6.25A.75.75 0 0 1 8 5.5Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span class="self-center">{$i18n.t('Export')}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class=" self-center text-sm font-medium">{$i18n.t('Export Chats')}</div>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<div class="py-0.5 flex w-full justify-between">
|
||||
<div class="self-center text-xs">{$i18n.t('Archived Chats')}</div>
|
||||
<button
|
||||
class="p-1 px-3 text-xs flex rounded-sm transition"
|
||||
on:click={() => {
|
||||
showArchivedChatsModal = true;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span class="self-center">{$i18n.t('Manage')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="py-0.5 flex w-full justify-between">
|
||||
<div class="self-center text-xs">{$i18n.t('Shared Chats')}</div>
|
||||
<button
|
||||
class="p-1 px-3 text-xs flex rounded-sm transition"
|
||||
on:click={() => {
|
||||
showSharedChatsModal = true;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span class="self-center">{$i18n.t('Manage')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="py-0.5 flex w-full justify-between">
|
||||
<div class="self-center text-xs">{$i18n.t('Archive All Chats')}</div>
|
||||
<button
|
||||
class="p-1 px-3 text-xs flex rounded-sm transition"
|
||||
on:click={() => {
|
||||
showArchiveConfirmDialog = true;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span class="self-center">{$i18n.t('Archive All')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="py-0.5 flex w-full justify-between">
|
||||
<div class="self-center text-xs">{$i18n.t('Delete All Chats')}</div>
|
||||
<button
|
||||
class="p-1 px-3 text-xs flex rounded-sm transition"
|
||||
on:click={() => {
|
||||
showDeleteConfirmDialog = true;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span class="self-center">{$i18n.t('Delete All')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class=" border-gray-100/30 dark:border-gray-850/30" />
|
||||
<div>
|
||||
<div class="mb-1 text-sm font-medium">{$i18n.t('Files')}</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<button
|
||||
class=" flex rounded-md py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
|
||||
on:click={() => {
|
||||
showArchivedChatsModal = true;
|
||||
}}
|
||||
>
|
||||
<div class=" self-center mr-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="size-4"
|
||||
<div>
|
||||
<div class="py-0.5 flex w-full justify-between">
|
||||
<div class="self-center text-xs">{$i18n.t('Manage Files')}</div>
|
||||
<button
|
||||
class="p-1 px-3 text-xs flex rounded-sm transition"
|
||||
on:click={() => {
|
||||
showFilesModal = true;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<path
|
||||
d="M3.375 3C2.339 3 1.5 3.84 1.5 4.875v.75c0 1.036.84 1.875 1.875 1.875h17.25c1.035 0 1.875-.84 1.875-1.875v-.75C22.5 3.839 21.66 3 20.625 3H3.375Z"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="m3.087 9 .54 9.176A3 3 0 0 0 6.62 21h10.757a3 3 0 0 0 2.995-2.824L20.913 9H3.087ZM12 10.5a.75.75 0 0 1 .75.75v4.94l1.72-1.72a.75.75 0 1 1 1.06 1.06l-3 3a.75.75 0 0 1-1.06 0l-3-3a.75.75 0 1 1 1.06-1.06l1.72 1.72v-4.94a.75.75 0 0 1 .75-.75Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span class="self-center">{$i18n.t('Manage')}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class=" self-center text-sm font-medium">{$i18n.t('Archived Chats')}</div>
|
||||
</button>
|
||||
|
||||
{#if showArchiveConfirm}
|
||||
<div class="flex justify-between rounded-md items-center py-2 px-3.5 w-full transition">
|
||||
<div class="flex items-center space-x-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path d="M2 3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3Z" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M13 6H3v6a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V6ZM5.72 7.47a.75.75 0 0 1 1.06 0L8 8.69l1.22-1.22a.75.75 0 1 1 1.06 1.06L9.06 9.75l1.22 1.22a.75.75 0 1 1-1.06 1.06L8 10.81l-1.22 1.22a.75.75 0 0 1-1.06-1.06l1.22-1.22-1.22-1.22a.75.75 0 0 1 0-1.06Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>{$i18n.t('Are you sure?')}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-1.5 items-center">
|
||||
<button
|
||||
class="hover:text-white transition"
|
||||
on:click={() => {
|
||||
archiveAllChatsHandler();
|
||||
showArchiveConfirm = false;
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="hover:text-white transition"
|
||||
on:click={() => {
|
||||
showArchiveConfirm = false;
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class=" flex rounded-md py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
|
||||
on:click={() => {
|
||||
showArchiveConfirm = true;
|
||||
}}
|
||||
>
|
||||
<div class=" self-center mr-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="size-4"
|
||||
>
|
||||
<path
|
||||
d="M3.375 3C2.339 3 1.5 3.84 1.5 4.875v.75c0 1.036.84 1.875 1.875 1.875h17.25c1.035 0 1.875-.84 1.875-1.875v-.75C22.5 3.839 21.66 3 20.625 3H3.375Z"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="m3.087 9 .54 9.176A3 3 0 0 0 6.62 21h10.757a3 3 0 0 0 2.995-2.824L20.913 9H3.087Zm6.163 3.75A.75.75 0 0 1 10 12h4a.75.75 0 0 1 0 1.5h-4a.75.75 0 0 1-.75-.75Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class=" self-center text-sm font-medium">{$i18n.t('Archive All Chats')}</div>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if showDeleteConfirm}
|
||||
<div class="flex justify-between rounded-md items-center py-2 px-3.5 w-full transition">
|
||||
<div class="flex items-center space-x-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path d="M2 3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3Z" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M13 6H3v6a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V6ZM5.72 7.47a.75.75 0 0 1 1.06 0L8 8.69l1.22-1.22a.75.75 0 1 1 1.06 1.06L9.06 9.75l1.22 1.22a.75.75 0 1 1-1.06 1.06L8 10.81l-1.22 1.22a.75.75 0 0 1-1.06-1.06l1.22-1.22-1.22-1.22a.75.75 0 0 1 0-1.06Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>{$i18n.t('Are you sure?')}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-1.5 items-center">
|
||||
<button
|
||||
class="hover:text-white transition"
|
||||
on:click={() => {
|
||||
deleteAllChatsHandler();
|
||||
showDeleteConfirm = false;
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="hover:text-white transition"
|
||||
on:click={() => {
|
||||
showDeleteConfirm = false;
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class=" flex rounded-md py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
|
||||
on:click={() => {
|
||||
showDeleteConfirm = true;
|
||||
}}
|
||||
>
|
||||
<div class=" self-center mr-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm7 7a.75.75 0 0 1-.75.75h-4.5a.75.75 0 0 1 0-1.5h4.5A.75.75 0 0 1 11 9Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class=" self-center text-sm font-medium">{$i18n.t('Delete All Chats')}</div>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -112,6 +112,10 @@
|
|||
|
||||
languages = await getLanguages();
|
||||
|
||||
if (!$config?.features?.enable_easter_eggs) {
|
||||
languages = languages.filter((l) => l.code !== 'dg-DG');
|
||||
}
|
||||
|
||||
notificationEnabled = $settings.notificationEnabled ?? false;
|
||||
system = $settings.system ?? '';
|
||||
|
||||
|
|
@ -210,9 +214,9 @@
|
|||
<option value="dark">🌑 {$i18n.t('Dark')}</option>
|
||||
<option value="oled-dark">🌃 {$i18n.t('OLED Dark')}</option>
|
||||
<option value="light">☀️ {$i18n.t('Light')}</option>
|
||||
<option value="her">🌷 Her</option>
|
||||
<!-- <option value="rose-pine dark">🪻 {$i18n.t('Rosé Pine')}</option>
|
||||
<option value="rose-pine-dawn light">🌷 {$i18n.t('Rosé Pine Dawn')}</option> -->
|
||||
{#if $config?.features?.enable_easter_eggs}
|
||||
<option value="her">🌷 Her</option>
|
||||
{/if}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@
|
|||
{/if}
|
||||
</div>
|
||||
<div class="flex-1 text-xs text-gray-700 dark:text-white max-h-60 overflow-y-auto">
|
||||
{@html marked.parse(DOMPurify.sanitize((banner?.content ?? '').replace(/\n/g, '<br>')))}
|
||||
{@html DOMPurify.sanitize(marked.parse((banner?.content ?? '').replace(/\n/g, '<br>')))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -104,6 +104,12 @@
|
|||
})
|
||||
);
|
||||
|
||||
// Add 'matlab' alias to Octave language (MATLAB-compatible syntax)
|
||||
const octaveLang = languages.find((l) => l.name === 'Octave');
|
||||
if (octaveLang && !octaveLang.alias.includes('matlab')) {
|
||||
octaveLang.alias.push('matlab');
|
||||
}
|
||||
|
||||
const getLang = async () => {
|
||||
const language = languages.find((l) => l.alias.includes(lang));
|
||||
return await language?.load();
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@
|
|||
|
||||
if (event.key === 'Enter') {
|
||||
console.log('Enter');
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
confirmHandler();
|
||||
}
|
||||
};
|
||||
|
|
@ -81,6 +83,7 @@
|
|||
|
||||
onDestroy(() => {
|
||||
show = false;
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
if (focusTrap) {
|
||||
focusTrap.deactivate();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import type { WorkBook } from 'xlsx';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
import { getContext, onMount, tick } from 'svelte';
|
||||
|
||||
|
|
@ -118,11 +119,11 @@
|
|||
const range = XLSX.utils.decode_range(worksheet['!ref'] || 'A1:A1');
|
||||
rowCount = range.e.r - range.s.r + 1;
|
||||
|
||||
excelHtml = XLSX.utils.sheet_to_html(worksheet, {
|
||||
excelHtml = DOMPurify.sanitize(XLSX.utils.sheet_to_html(worksheet, {
|
||||
id: 'excel-table',
|
||||
editable: false,
|
||||
header: ''
|
||||
});
|
||||
}));
|
||||
};
|
||||
|
||||
$: if (selectedSheet && excelWorkbook) {
|
||||
|
|
|
|||
|
|
@ -11,12 +11,15 @@
|
|||
export let outerClassName = 'flex flex-1 bg-transparent';
|
||||
export let inputClassName = 'w-full text-sm py-0.5 bg-transparent';
|
||||
export let showButtonClassName = 'pl-1.5 transition bg-transparent';
|
||||
export let screenReader = true;
|
||||
|
||||
let show = false;
|
||||
</script>
|
||||
|
||||
<div class={outerClassName}>
|
||||
<label class="sr-only" for={id}>{placeholder || $i18n.t('Password')}</label>
|
||||
{#if screenReader}
|
||||
<label class="sr-only" for={id}>{placeholder || $i18n.t('Password')}</label>
|
||||
{/if}
|
||||
<input
|
||||
{id}
|
||||
class={`${inputClassName} ${show ? '' : 'password'} ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : ' outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-600'}`}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
const i18n = getContext('i18n');
|
||||
|
||||
import Switch from './Switch.svelte';
|
||||
import SensitiveInput from './SensitiveInput.svelte';
|
||||
import MapSelector from './Valves/MapSelector.svelte';
|
||||
|
||||
export let valvesSpec = null;
|
||||
|
|
@ -103,7 +104,40 @@
|
|||
}}
|
||||
/>
|
||||
{:else if valvesSpec.properties[property]?.input ?? null}
|
||||
{#if valvesSpec.properties[property]?.input?.type === 'color'}
|
||||
{#if valvesSpec.properties[property]?.input?.type === 'password'}
|
||||
<div class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 border border-gray-100/30 dark:border-gray-850/30">
|
||||
<SensitiveInput
|
||||
id="valve-{property}"
|
||||
placeholder={valvesSpec.properties[property]?.description ?? ''}
|
||||
bind:value={valves[property]}
|
||||
required={(valvesSpec?.required ?? []).includes(property)}
|
||||
on:change={() => {
|
||||
dispatch('change');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{:else if valvesSpec.properties[property]?.input?.type === 'select' && valvesSpec.properties[property]?.input?.options}
|
||||
<select
|
||||
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100/30 dark:border-gray-850/30"
|
||||
bind:value={valves[property]}
|
||||
on:change={() => {
|
||||
dispatch('change');
|
||||
}}
|
||||
>
|
||||
<option value="" disabled>{valvesSpec.properties[property]?.description ?? $i18n.t('Select an option')}</option>
|
||||
{#each valvesSpec.properties[property].input.options as option}
|
||||
{#if typeof option === 'object' && option !== null}
|
||||
<option value={option.value} selected={option.value === valves[property]}>
|
||||
{option.label ?? option.value}
|
||||
</option>
|
||||
{:else}
|
||||
<option value={option} selected={option === valves[property]}>
|
||||
{option}
|
||||
</option>
|
||||
{/if}
|
||||
{/each}
|
||||
</select>
|
||||
{:else if valvesSpec.properties[property]?.input?.type === 'color'}
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="relative size-6">
|
||||
<input
|
||||
|
|
|
|||
26
src/lib/components/icons/LinkSlash.svelte
Normal file
26
src/lib/components/icons/LinkSlash.svelte
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<script lang="ts">
|
||||
export let className = 'size-4';
|
||||
export let strokeWidth = '1.5';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width={strokeWidth}
|
||||
stroke="currentColor"
|
||||
class={className}
|
||||
><path
|
||||
d="M7.14286 16.9953C6.75006 16.9953 6.36756 16.9525 6 16.8715C3.70973 16.3665 2 14.3761 2 11.9977C2 9.284 4.22573 7.07548 7 7.00195"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
></path><path
|
||||
d="M13.3184 9.63429C12.7858 8.73635 11.9737 7.96977 11 7.4989"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
></path><path
|
||||
d="M16.8571 6.99999C17.2499 6.99999 17.6324 7.04278 18 7.12383C20.2903 7.62884 22 9.6192 22 11.9976C22 14.7577 19.6975 16.9952 16.8571 16.9952C16.581 16.9952 15.4776 16.9952 15.1429 16.9952C12.317 16.9952 10 14.4893 10 11.9976C10 11.9976 10 11 10.5 10.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
></path><path d="M3 3L21 21" stroke-linecap="round" stroke-linejoin="round"></path></svg
|
||||
>
|
||||
|
|
@ -20,6 +20,9 @@
|
|||
import XMark from '../icons/XMark.svelte';
|
||||
import ChevronUp from '../icons/ChevronUp.svelte';
|
||||
import ChevronDown from '../icons/ChevronDown.svelte';
|
||||
import Link from '../icons/Link.svelte';
|
||||
import LinkSlash from '../icons/LinkSlash.svelte';
|
||||
import Clipboard from '../icons/Clipboard.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
|
|
@ -46,6 +49,7 @@
|
|||
|
||||
export let loadHandler: null | Function = null;
|
||||
export let unarchiveHandler: null | Function = null;
|
||||
export let unshareHandler: null | Function = null;
|
||||
|
||||
const setSortKey = (key) => {
|
||||
if (orderBy === key) {
|
||||
|
|
@ -286,30 +290,57 @@
|
|||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
<Tooltip content={$i18n.t('Delete Chat')}>
|
||||
{#if unshareHandler && chat.share_id}
|
||||
<Tooltip content={$i18n.t('Copy Share Link')}>
|
||||
<button
|
||||
class="self-center w-fit px-1 text-sm rounded-xl"
|
||||
on:click={async (e) => {
|
||||
e.stopImmediatePropagation();
|
||||
e.stopPropagation();
|
||||
const shareUrl = `${window.location.origin}/s/${chat.share_id}`;
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
toast.success($i18n.t('Share link copied to clipboard.'));
|
||||
}}
|
||||
>
|
||||
<Clipboard class="size-4" strokeWidth="1.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
<Tooltip
|
||||
content={unshareHandler ? $i18n.t('Unshare Chat') : $i18n.t('Delete Chat')}
|
||||
>
|
||||
<button
|
||||
class="self-center w-fit px-1 text-sm rounded-xl"
|
||||
on:click={async (e) => {
|
||||
e.stopImmediatePropagation();
|
||||
e.stopPropagation();
|
||||
selectedChatId = chat.id;
|
||||
showDeleteConfirmDialog = true;
|
||||
if (unshareHandler) {
|
||||
unshareHandler(chat.id);
|
||||
} else {
|
||||
selectedChatId = chat.id;
|
||||
showDeleteConfirmDialog = true;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
|
||||
/>
|
||||
</svg>
|
||||
{#if unshareHandler}
|
||||
<LinkSlash />
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
|
|
|||
384
src/lib/components/layout/FilesModal.svelte
Normal file
384
src/lib/components/layout/FilesModal.svelte
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
<script lang="ts">
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { getContext, onMount, onDestroy } from 'svelte';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { searchFiles, deleteFileById } from '$lib/apis/files';
|
||||
import Modal from '$lib/components/common/Modal.svelte';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import Loader from '$lib/components/common/Loader.svelte';
|
||||
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
|
||||
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
import FileItemModal from '$lib/components/common/FileItemModal.svelte';
|
||||
|
||||
const i18n: Writable<any> = getContext('i18n');
|
||||
|
||||
export let show = false;
|
||||
|
||||
let files: any[] | null = null;
|
||||
let query = '';
|
||||
let orderBy = 'created_at';
|
||||
let direction = 'desc';
|
||||
|
||||
let page = 0;
|
||||
let allFilesLoaded = false;
|
||||
let filesLoading = false;
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
let selectedFileId: string | null = null;
|
||||
let showDeleteConfirmDialog = false;
|
||||
|
||||
let selectedFile: any = null;
|
||||
let showFileItemModal = false;
|
||||
|
||||
let shiftKey = false;
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const setSortKey = (key: string) => {
|
||||
if (orderBy === key) {
|
||||
direction = direction === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
orderBy = key;
|
||||
direction = 'asc';
|
||||
}
|
||||
searchHandler();
|
||||
};
|
||||
|
||||
const searchHandler = async () => {
|
||||
if (!show) return;
|
||||
|
||||
page = 0;
|
||||
files = null;
|
||||
allFilesLoaded = false;
|
||||
|
||||
try {
|
||||
const pattern = query ? `*${query}*` : '*';
|
||||
const newFiles = await searchFiles(localStorage.token, pattern, 0, PAGE_SIZE);
|
||||
files = sortFiles(newFiles);
|
||||
allFilesLoaded = newFiles.length < PAGE_SIZE;
|
||||
} catch (error) {
|
||||
// Handle 404 or other errors - show empty state instead of spinner
|
||||
files = [];
|
||||
allFilesLoaded = true;
|
||||
}
|
||||
};
|
||||
|
||||
const loadMoreFiles = async () => {
|
||||
if (filesLoading || allFilesLoaded) return;
|
||||
|
||||
filesLoading = true;
|
||||
page += 1;
|
||||
|
||||
try {
|
||||
const pattern = query ? `*${query}*` : '*';
|
||||
const newFiles = await searchFiles(localStorage.token, pattern, page * PAGE_SIZE, PAGE_SIZE);
|
||||
|
||||
allFilesLoaded = newFiles.length < PAGE_SIZE;
|
||||
|
||||
if (newFiles.length > 0) {
|
||||
files = sortFiles([...(files || []), ...newFiles]);
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle errors silently for load more
|
||||
allFilesLoaded = true;
|
||||
}
|
||||
|
||||
filesLoading = false;
|
||||
};
|
||||
|
||||
const sortFiles = (fileList: any[]): any[] => {
|
||||
return fileList.sort((a, b) => {
|
||||
let aVal = a[orderBy] ?? 0;
|
||||
let bVal = b[orderBy] ?? 0;
|
||||
|
||||
if (orderBy === 'filename') {
|
||||
aVal = a.filename?.toLowerCase() ?? '';
|
||||
bVal = b.filename?.toLowerCase() ?? '';
|
||||
}
|
||||
|
||||
if (direction === 'asc') {
|
||||
return aVal > bVal ? 1 : -1;
|
||||
} else {
|
||||
return aVal < bVal ? 1 : -1;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const deleteHandler = async (fileId: string) => {
|
||||
try {
|
||||
await deleteFileById(localStorage.token, fileId);
|
||||
toast.success($i18n.t('File deleted successfully.'));
|
||||
// Remove from local array instead of re-fetching to allow rapid deletion
|
||||
files = files?.filter((f) => f.id !== fileId) ?? null;
|
||||
} catch (error) {
|
||||
toast.error(`${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const openFileViewer = (file: any) => {
|
||||
selectedFile = {
|
||||
id: file.id,
|
||||
name: file.filename,
|
||||
type: 'file',
|
||||
size: file.meta?.size,
|
||||
meta: file.meta
|
||||
};
|
||||
showFileItemModal = true;
|
||||
};
|
||||
|
||||
// Debounce query changes
|
||||
$: if (show && query !== undefined) {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
searchHandler();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Shift') {
|
||||
shiftKey = true;
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Shift') {
|
||||
shiftKey = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onBlur = () => {
|
||||
shiftKey = false;
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
window.addEventListener('keyup', onKeyUp);
|
||||
window.addEventListener('blur', onBlur);
|
||||
|
||||
return () => {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
window.removeEventListener('blur', onBlur);
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:show={showDeleteConfirmDialog}
|
||||
on:confirm={() => {
|
||||
if (selectedFileId) {
|
||||
deleteHandler(selectedFileId);
|
||||
selectedFileId = null;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<FileItemModal bind:show={showFileItemModal} item={selectedFile} edit={false} />
|
||||
|
||||
<Modal size="xl" bind:show>
|
||||
<div>
|
||||
<div class="flex justify-between dark:text-gray-300 px-5 pt-4 pb-1">
|
||||
<div class="text-lg font-medium self-center">{$i18n.t('Files')}</div>
|
||||
<button
|
||||
class="self-center"
|
||||
on:click={() => {
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col w-full px-5 pb-4 dark:text-gray-200">
|
||||
<!-- Search -->
|
||||
<div class="flex w-full space-x-2 mb-0.5">
|
||||
<div class="flex flex-1">
|
||||
<div class="self-center ml-1 mr-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
class="w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
|
||||
bind:value={query}
|
||||
placeholder={$i18n.t('Search Files')}
|
||||
maxlength="500"
|
||||
/>
|
||||
|
||||
{#if query}
|
||||
<div class="self-center pl-1.5 pr-1 translate-y-[0.5px] rounded-l-xl bg-transparent">
|
||||
<button
|
||||
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
|
||||
on:click={() => {
|
||||
query = '';
|
||||
}}
|
||||
>
|
||||
<XMark className="size-3" strokeWidth="2" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Files List -->
|
||||
<div class="flex flex-col w-full">
|
||||
{#if files !== null}
|
||||
<div class="w-full">
|
||||
{#if files.length > 0}
|
||||
<div class="flex text-xs font-medium mb-1.5">
|
||||
<button
|
||||
class="px-1.5 py-1 cursor-pointer select-none basis-3/5"
|
||||
on:click={() => setSortKey('filename')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center">
|
||||
{$i18n.t('Filename')}
|
||||
{#if orderBy === 'filename'}
|
||||
<span class="font-normal">
|
||||
{#if direction === 'asc'}
|
||||
<ChevronUp className="size-2" />
|
||||
{:else}
|
||||
<ChevronDown className="size-2" />
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="invisible">
|
||||
<ChevronUp className="size-2" />
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="px-1.5 py-1 cursor-pointer select-none hidden sm:flex sm:basis-2/5 justify-end"
|
||||
on:click={() => setSortKey('created_at')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center">
|
||||
{$i18n.t('Created at')}
|
||||
{#if orderBy === 'created_at'}
|
||||
<span class="font-normal">
|
||||
{#if direction === 'asc'}
|
||||
<ChevronUp className="size-2" />
|
||||
{:else}
|
||||
<ChevronDown className="size-2" />
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="invisible">
|
||||
<ChevronUp className="size-2" />
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="text-left text-sm w-full mb-3 max-h-[32rem] overflow-y-scroll">
|
||||
{#if files.length === 0}
|
||||
<div
|
||||
class="text-xs text-gray-500 dark:text-gray-400 text-center px-5 min-h-20 w-full h-full flex justify-center items-center"
|
||||
>
|
||||
{$i18n.t('No files found')}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each files as file (file.id)}
|
||||
<div
|
||||
class="w-full flex justify-between items-center rounded-lg text-sm py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-850 cursor-pointer"
|
||||
on:click={() => openFileViewer(file)}
|
||||
>
|
||||
<div class="basis-3/5 min-w-0">
|
||||
<div class="text-ellipsis line-clamp-1">{file.filename}</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{formatFileSize(file.meta?.size ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="basis-2/5 flex items-center justify-end">
|
||||
<div class="hidden sm:flex text-gray-500 dark:text-gray-400 text-xs">
|
||||
{dayjs(file.created_at * 1000).format('MMM D, YYYY')}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pl-2.5 text-gray-600 dark:text-gray-300">
|
||||
<Tooltip content={shiftKey ? $i18n.t('Delete File') : $i18n.t('Delete File')}>
|
||||
<button
|
||||
class="self-center w-fit px-1 text-sm rounded-xl {shiftKey ? 'text-red-500' : ''}"
|
||||
on:click|stopPropagation={() => {
|
||||
if (shiftKey) {
|
||||
deleteHandler(file.id);
|
||||
} else {
|
||||
selectedFileId = file.id;
|
||||
showDeleteConfirmDialog = true;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<GarbageBin class="size-4" strokeWidth="1.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if !allFilesLoaded}
|
||||
<Loader
|
||||
on:visible={() => {
|
||||
if (!filesLoading) {
|
||||
loadMoreFiles();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="w-full flex justify-center py-1 text-xs animate-pulse items-center gap-2">
|
||||
<Spinner className="size-4" />
|
||||
<div>{$i18n.t('Loading...')}</div>
|
||||
</div>
|
||||
</Loader>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-full h-full flex justify-center items-center min-h-20">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
126
src/lib/components/layout/SharedChatsModal.svelte
Normal file
126
src/lib/components/layout/SharedChatsModal.svelte
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
<script lang="ts">
|
||||
import type { Writable } from 'svelte/store';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { getContext } from 'svelte';
|
||||
import { deleteSharedChatById, getSharedChatList } from '$lib/apis/chats';
|
||||
|
||||
import ChatsModal from './ChatsModal.svelte';
|
||||
|
||||
const i18n: Writable<any> = getContext('i18n');
|
||||
|
||||
export let show = false;
|
||||
export let onUpdate = () => {};
|
||||
|
||||
let chatList: any[] | null = null;
|
||||
let page = 1;
|
||||
|
||||
let query = '';
|
||||
let orderBy = 'updated_at';
|
||||
let direction = 'desc';
|
||||
|
||||
let allChatsLoaded = false;
|
||||
let chatListLoading = false;
|
||||
let searchDebounceTimeout: any;
|
||||
|
||||
let filter: any = {};
|
||||
$: filter = {
|
||||
...(query ? { query } : {}),
|
||||
...(orderBy ? { order_by: orderBy } : {}),
|
||||
...(direction ? { direction } : {})
|
||||
};
|
||||
|
||||
$: if (filter !== null) {
|
||||
searchHandler();
|
||||
}
|
||||
|
||||
const searchHandler = async () => {
|
||||
if (!show) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (searchDebounceTimeout) {
|
||||
clearTimeout(searchDebounceTimeout);
|
||||
}
|
||||
|
||||
page = 1;
|
||||
chatList = null;
|
||||
|
||||
if (query === '') {
|
||||
chatList = await getSharedChatList(localStorage.token, page, filter);
|
||||
} else {
|
||||
searchDebounceTimeout = setTimeout(async () => {
|
||||
chatList = await getSharedChatList(localStorage.token, page, filter);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
if ((chatList ?? []).length === 0) {
|
||||
allChatsLoaded = true;
|
||||
} else {
|
||||
allChatsLoaded = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadMoreChats = async () => {
|
||||
chatListLoading = true;
|
||||
page += 1;
|
||||
|
||||
let newChatList = [];
|
||||
|
||||
if (query) {
|
||||
newChatList = await getSharedChatList(localStorage.token, page, filter);
|
||||
} else {
|
||||
newChatList = await getSharedChatList(localStorage.token, page, filter);
|
||||
}
|
||||
|
||||
// once the bottom of the list has been reached (no results) there is no need to continue querying
|
||||
allChatsLoaded = newChatList.length === 0;
|
||||
|
||||
if (newChatList.length > 0) {
|
||||
chatList = [...(chatList || []), ...newChatList];
|
||||
}
|
||||
|
||||
chatListLoading = false;
|
||||
};
|
||||
|
||||
const unshareHandler = async (chatId: string) => {
|
||||
const res = await deleteSharedChatById(localStorage.token, chatId).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (res === true) {
|
||||
toast.success($i18n.t('Chat unshared successfully.'));
|
||||
onUpdate();
|
||||
init();
|
||||
} else if (res === false) {
|
||||
toast.error($i18n.t('Failed to unshare chat.'));
|
||||
}
|
||||
};
|
||||
|
||||
const init = async () => {
|
||||
chatList = await getSharedChatList(localStorage.token);
|
||||
};
|
||||
|
||||
$: if (show) {
|
||||
init();
|
||||
}
|
||||
</script>
|
||||
|
||||
<ChatsModal
|
||||
bind:show
|
||||
bind:query
|
||||
bind:orderBy
|
||||
bind:direction
|
||||
title={$i18n.t('Shared Chats')}
|
||||
emptyPlaceholder={$i18n.t('You have no shared conversations.')}
|
||||
shareUrl={false}
|
||||
{chatList}
|
||||
{allChatsLoaded}
|
||||
{chatListLoading}
|
||||
onUpdate={() => {
|
||||
onUpdate();
|
||||
init();
|
||||
}}
|
||||
loadHandler={loadMoreChats}
|
||||
{unshareHandler}
|
||||
/>
|
||||
|
|
@ -60,6 +60,7 @@
|
|||
let total = null;
|
||||
|
||||
let query = '';
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
let sortKey = null;
|
||||
let displayOption = null;
|
||||
|
|
@ -163,9 +164,17 @@
|
|||
await getItemsPage();
|
||||
};
|
||||
|
||||
$: if (query !== undefined) {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
if (loaded) {
|
||||
init();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
$: if (
|
||||
loaded &&
|
||||
query !== undefined &&
|
||||
sortKey !== undefined &&
|
||||
permission !== undefined &&
|
||||
viewOption !== undefined
|
||||
|
|
@ -283,6 +292,7 @@
|
|||
});
|
||||
|
||||
onDestroy(() => {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
console.log('destroy');
|
||||
const dropzoneElement = document.getElementById('notes-container');
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
<script lang="ts">
|
||||
import fileSaver from 'file-saver';
|
||||
const { saveAs } = fileSaver;
|
||||
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { DropdownMenu } from 'bits-ui';
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount, tick, getContext } from 'svelte';
|
||||
|
|
@ -11,11 +15,13 @@
|
|||
WEBUI_BASE_URL
|
||||
} from '$lib/constants';
|
||||
import { WEBUI_NAME, config, user, models, settings } from '$lib/stores';
|
||||
import { flyAndScale } from '$lib/utils/transitions';
|
||||
|
||||
import { chatCompletion } from '$lib/apis/openai';
|
||||
|
||||
import { splitStream } from '$lib/utils';
|
||||
import Collapsible from '../common/Collapsible.svelte';
|
||||
import Dropdown from '../common/Dropdown.svelte';
|
||||
|
||||
import Messages from '$lib/components/playground/Chat/Messages.svelte';
|
||||
import ChevronUp from '../icons/ChevronUp.svelte';
|
||||
|
|
@ -24,6 +30,8 @@
|
|||
import Cog6 from '../icons/Cog6.svelte';
|
||||
import Sidebar from '../common/Sidebar.svelte';
|
||||
import ArrowRight from '../icons/ArrowRight.svelte';
|
||||
import Download from '../icons/Download.svelte';
|
||||
import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
|
|
@ -193,6 +201,94 @@
|
|||
}
|
||||
};
|
||||
|
||||
const exportToJson = () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Convert flat messages array to history map format
|
||||
const messagesMap: Record<string, any> = {};
|
||||
let currentId: string | null = null;
|
||||
let parentId: string | null = null;
|
||||
|
||||
// Add system message if present
|
||||
if (system) {
|
||||
const systemId = crypto.randomUUID();
|
||||
messagesMap[systemId] = {
|
||||
id: systemId,
|
||||
parentId: null,
|
||||
childrenIds: [],
|
||||
role: 'system',
|
||||
content: system,
|
||||
timestamp: now
|
||||
};
|
||||
parentId = systemId;
|
||||
}
|
||||
|
||||
// Add conversation messages
|
||||
for (const msg of messages) {
|
||||
const msgId = crypto.randomUUID();
|
||||
|
||||
// Link parent to child
|
||||
if (parentId && messagesMap[parentId]) {
|
||||
messagesMap[parentId].childrenIds.push(msgId);
|
||||
}
|
||||
|
||||
messagesMap[msgId] = {
|
||||
id: msgId,
|
||||
parentId: parentId,
|
||||
childrenIds: [],
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
timestamp: now,
|
||||
...(msg.role === 'assistant' && selectedModelId ? { model: selectedModelId } : {})
|
||||
};
|
||||
|
||||
currentId = msgId;
|
||||
parentId = msgId;
|
||||
}
|
||||
|
||||
const exportData = {
|
||||
chat: {
|
||||
title: 'Playground Chat',
|
||||
models: [selectedModelId],
|
||||
params: system ? { system } : {},
|
||||
history: {
|
||||
messages: messagesMap,
|
||||
currentId
|
||||
}
|
||||
},
|
||||
meta: {},
|
||||
pinned: false,
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify([exportData], null, 2)], {
|
||||
type: 'application/json'
|
||||
});
|
||||
saveAs(blob, `playground-chat-${Date.now()}.json`);
|
||||
toast.success($i18n.t('Chat exported successfully'));
|
||||
};
|
||||
|
||||
const downloadTxt = () => {
|
||||
let chatText = '';
|
||||
|
||||
// Add system message if present
|
||||
if (system) {
|
||||
chatText += `### SYSTEM\n${system}\n\n`;
|
||||
}
|
||||
|
||||
// Add conversation messages
|
||||
for (const msg of messages) {
|
||||
chatText += `### ${msg.role.toUpperCase()}\n${msg.content}\n\n`;
|
||||
}
|
||||
|
||||
const blob = new Blob([chatText.trim()], {
|
||||
type: 'text/plain'
|
||||
});
|
||||
saveAs(blob, `playground-chat-${Date.now()}.txt`);
|
||||
toast.success($i18n.t('Chat exported successfully'));
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
if ($user?.role !== 'admin') {
|
||||
await goto('/');
|
||||
|
|
@ -212,7 +308,7 @@
|
|||
<div class=" flex flex-col justify-between w-full overflow-y-auto h-full">
|
||||
<div class="mx-auto w-full md:px-0 h-full relative">
|
||||
<div class=" flex flex-col h-full px-3.5">
|
||||
<div class="flex w-full items-start gap-1.5">
|
||||
<div class="flex w-full items-center gap-1.5">
|
||||
<Collapsible
|
||||
className="w-full flex-1"
|
||||
bind:open={showSystem}
|
||||
|
|
@ -256,6 +352,58 @@
|
|||
</div>
|
||||
</div>
|
||||
</Collapsible>
|
||||
|
||||
<Dropdown>
|
||||
<button
|
||||
class="p-1.5 text-sm font-medium bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400 transition rounded-lg"
|
||||
aria-label={$i18n.t('More options')}
|
||||
>
|
||||
<EllipsisHorizontal className="size-4" />
|
||||
</button>
|
||||
|
||||
<div slot="content">
|
||||
<DropdownMenu.Content
|
||||
class="w-full max-w-[200px] rounded-2xl px-1 py-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
|
||||
sideOffset={8}
|
||||
side="bottom"
|
||||
align="end"
|
||||
transition={flyAndScale}
|
||||
>
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger
|
||||
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 select-none w-full"
|
||||
>
|
||||
<Download strokeWidth="1.5" />
|
||||
<div class="flex items-center">{$i18n.t('Download')}</div>
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
class="w-full rounded-2xl p-1 z-50 bg-white dark:bg-gray-850 dark:text-white border border-gray-100 dark:border-gray-800 shadow-lg max-h-52 overflow-y-auto scrollbar-hidden"
|
||||
transition={flyAndScale}
|
||||
sideOffset={8}
|
||||
>
|
||||
<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 select-none w-full"
|
||||
disabled={messages.length === 0}
|
||||
on:click={() => {
|
||||
exportToJson();
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center line-clamp-1">{$i18n.t('Export chat (.json)')}</div>
|
||||
</DropdownMenu.Item>
|
||||
<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 select-none w-full"
|
||||
disabled={messages.length === 0}
|
||||
on:click={() => {
|
||||
downloadTxt();
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center line-clamp-1">{$i18n.t('Plain text (.txt)')}</div>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
</DropdownMenu.Content>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
|
|
|||
276
src/lib/components/playground/Images.svelte
Normal file
276
src/lib/components/playground/Images.svelte
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
<script lang="ts">
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { onMount, getContext } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
import { user } from '$lib/stores';
|
||||
import { imageGenerations, imageEdits } from '$lib/apis/images';
|
||||
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
let loaded = false;
|
||||
let loading = false;
|
||||
|
||||
let prompt = '';
|
||||
let sourceImages: string[] = [];
|
||||
let generatedImages: { url: string }[] = [];
|
||||
|
||||
let promptTextareaElement: HTMLTextAreaElement;
|
||||
let fileInputElement: HTMLInputElement;
|
||||
|
||||
const resizePromptTextarea = () => {
|
||||
if (promptTextareaElement) {
|
||||
promptTextareaElement.style.height = '';
|
||||
promptTextareaElement.style.height = Math.min(promptTextareaElement.scrollHeight, 150) + 'px';
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files) {
|
||||
Array.from(input.files).forEach((file) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
if (e.target?.result) {
|
||||
sourceImages = [...sourceImages, e.target.result as string];
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
const files = event.dataTransfer?.files;
|
||||
if (files) {
|
||||
Array.from(files).forEach((file) => {
|
||||
if (file.type.startsWith('image/')) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
if (e.target?.result) {
|
||||
sourceImages = [...sourceImages, e.target.result as string];
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const removeImage = (index: number) => {
|
||||
sourceImages = sourceImages.filter((_, i) => i !== index);
|
||||
};
|
||||
|
||||
const submitHandler = async () => {
|
||||
if (!prompt.trim()) {
|
||||
toast.error($i18n.t('Please enter a prompt'));
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
let result;
|
||||
if (sourceImages.length > 0) {
|
||||
console.log('Calling imageEdits with', sourceImages.length, 'images');
|
||||
result = await imageEdits(
|
||||
localStorage.token,
|
||||
sourceImages.length === 1 ? sourceImages[0] : sourceImages,
|
||||
prompt
|
||||
);
|
||||
} else {
|
||||
console.log('Calling imageGenerations');
|
||||
result = await imageGenerations(localStorage.token, prompt);
|
||||
}
|
||||
|
||||
console.log('Result:', result);
|
||||
if (result) {
|
||||
generatedImages = [...result, ...generatedImages];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Image generation/edit error:', error);
|
||||
toast.error(`${error}`);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadImage = async (url: string, index: number) => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = `image-${Date.now()}-${index}.png`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
} catch (error) {
|
||||
toast.error($i18n.t('Failed to download image'));
|
||||
}
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
if ($user?.role !== 'admin') {
|
||||
await goto('/');
|
||||
return;
|
||||
}
|
||||
loaded = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class=" flex flex-col justify-between w-full overflow-y-auto h-full">
|
||||
<div class="mx-auto w-full md:px-0 h-full">
|
||||
<div class=" flex flex-col h-full px-4">
|
||||
<!-- Results Area -->
|
||||
<div
|
||||
class=" pt-0.5 pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0"
|
||||
id="images-container"
|
||||
>
|
||||
<div class=" h-full w-full flex flex-col">
|
||||
<div class="flex-1 p-1">
|
||||
{#if generatedImages.length > 0}
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{#each generatedImages as image, index}
|
||||
<button
|
||||
class="relative group cursor-pointer"
|
||||
on:click={() => downloadImage(image.url, index)}
|
||||
>
|
||||
<img
|
||||
src={image.url}
|
||||
alt=""
|
||||
class="w-full aspect-square object-cover rounded-lg border border-gray-100/30 dark:border-gray-850/30"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition rounded-lg flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6 text-white"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7,10 12,15 17,10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-full flex items-center justify-center text-gray-400 dark:text-gray-600 text-sm">
|
||||
{$i18n.t('Generated images will appear here')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input Area -->
|
||||
<div class="pb-3">
|
||||
<div class="border border-gray-100/30 dark:border-gray-850/30 w-full px-3 py-2.5 rounded-xl">
|
||||
<!-- Source Images -->
|
||||
{#if sourceImages.length > 0}
|
||||
<div class="flex flex-wrap gap-2 mb-2">
|
||||
{#each sourceImages as image, index}
|
||||
<div class=" relative group">
|
||||
<div class="relative flex items-center">
|
||||
<img src={image} alt="" class="size-10 rounded-xl object-cover" />
|
||||
</div>
|
||||
<div class=" absolute -top-1 -right-1">
|
||||
<button
|
||||
class=" bg-white text-black border border-white rounded-full group-hover:visible invisible transition"
|
||||
type="button"
|
||||
on:click={() => removeImage(index)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
class="size-4"
|
||||
>
|
||||
<path
|
||||
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Prompt Textarea -->
|
||||
<div class="py-0.5">
|
||||
<textarea
|
||||
bind:this={promptTextareaElement}
|
||||
bind:value={prompt}
|
||||
class=" w-full h-full bg-transparent resize-none outline-hidden text-sm"
|
||||
placeholder={sourceImages.length > 0
|
||||
? $i18n.t('Describe the edit...')
|
||||
: $i18n.t('Describe the image...')}
|
||||
on:input={resizePromptTextarea}
|
||||
on:focus={resizePromptTextarea}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !loading) {
|
||||
e.preventDefault();
|
||||
submitHandler();
|
||||
}
|
||||
}}
|
||||
rows="2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-between items-center gap-2 mt-2">
|
||||
<div class="shrink-0">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
bind:this={fileInputElement}
|
||||
on:change={handleFileUpload}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="px-3.5 py-1.5 text-sm font-medium bg-gray-50 hover:bg-gray-100 text-gray-900 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition rounded-lg"
|
||||
on:click={() => fileInputElement?.click()}
|
||||
on:dragover|preventDefault
|
||||
on:drop={handleDrop}
|
||||
>
|
||||
{$i18n.t('Add Image')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 shrink-0">
|
||||
{#if !loading}
|
||||
<button
|
||||
disabled={prompt.trim() === ''}
|
||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
on:click={submitHandler}
|
||||
>
|
||||
{$i18n.t('Run')}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="px-3.5 py-1.5 text-sm font-medium bg-gray-300 text-black transition rounded-lg flex items-center gap-2"
|
||||
disabled
|
||||
>
|
||||
<Spinner className="size-4" />
|
||||
{$i18n.t('Running...')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
dayjs.extend(relativeTime);
|
||||
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { onMount, getContext, tick } from 'svelte';
|
||||
import { onMount, getContext, tick, onDestroy } from 'svelte';
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
import { WEBUI_NAME, knowledge, user } from '$lib/stores';
|
||||
|
|
@ -36,6 +36,7 @@
|
|||
|
||||
let page = 1;
|
||||
let query = '';
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout>;
|
||||
let viewOption = '';
|
||||
|
||||
let items = null;
|
||||
|
|
@ -44,7 +45,18 @@
|
|||
let allItemsLoaded = false;
|
||||
let itemsLoading = false;
|
||||
|
||||
$: if (loaded && query !== undefined && viewOption !== undefined) {
|
||||
$: if (query !== undefined) {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
init();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
});
|
||||
|
||||
$: if (viewOption !== undefined) {
|
||||
init();
|
||||
}
|
||||
|
||||
|
|
@ -63,6 +75,8 @@
|
|||
};
|
||||
|
||||
const init = async () => {
|
||||
if (!loaded) return;
|
||||
|
||||
reset();
|
||||
await getItemsPage();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -88,6 +88,8 @@
|
|||
let inputFiles = null;
|
||||
|
||||
let query = '';
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
let viewOption = null;
|
||||
let sortKey = null;
|
||||
let direction = null;
|
||||
|
|
@ -105,9 +107,18 @@
|
|||
await getItemsPage();
|
||||
};
|
||||
|
||||
// Debounce only query changes
|
||||
$: if (query !== undefined) {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
getItemsPage();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Immediate response to filter/pagination changes
|
||||
$: if (
|
||||
knowledgeId !== null &&
|
||||
query !== undefined &&
|
||||
viewOption !== undefined &&
|
||||
sortKey !== undefined &&
|
||||
direction !== undefined &&
|
||||
|
|
@ -746,6 +757,7 @@
|
|||
});
|
||||
|
||||
onDestroy(() => {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
mediaQuery?.removeEventListener('change', handleMediaQuery);
|
||||
const dropZone = document.querySelector('body');
|
||||
dropZone?.removeEventListener('dragover', onDragOver);
|
||||
|
|
|
|||
|
|
@ -85,6 +85,8 @@
|
|||
}
|
||||
|
||||
const getModelList = async () => {
|
||||
if (!loaded) return;
|
||||
|
||||
try {
|
||||
const res = await getWorkspaceModels(
|
||||
localStorage.token,
|
||||
|
|
|
|||
87
src/lib/components/workspace/Models/BuiltinTools.svelte
Normal file
87
src/lib/components/workspace/Models/BuiltinTools.svelte
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import Checkbox from '$lib/components/common/Checkbox.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import { marked } from 'marked';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
const toolLabels = {
|
||||
time: {
|
||||
label: $i18n.t('Time & Calculation'),
|
||||
description: $i18n.t('Get current time and perform date/time calculations')
|
||||
},
|
||||
memory: {
|
||||
label: $i18n.t('Memory'),
|
||||
description: $i18n.t('Search and manage user memories')
|
||||
},
|
||||
chats: {
|
||||
label: $i18n.t('Chat History'),
|
||||
description: $i18n.t('Search and view user chat history')
|
||||
},
|
||||
notes: {
|
||||
label: $i18n.t('Notes'),
|
||||
description: $i18n.t('Search, view, and manage user notes')
|
||||
},
|
||||
knowledge: {
|
||||
label: $i18n.t('Knowledge Base'),
|
||||
description: $i18n.t('Browse and query knowledge bases')
|
||||
},
|
||||
channels: {
|
||||
label: $i18n.t('Channels'),
|
||||
description: $i18n.t('Search channels and channel messages')
|
||||
},
|
||||
web_search: {
|
||||
label: $i18n.t('Web Search'),
|
||||
description: $i18n.t('Search the web and fetch URLs')
|
||||
},
|
||||
image_generation: {
|
||||
label: $i18n.t('Image Generation'),
|
||||
description: $i18n.t('Generate and edit images')
|
||||
},
|
||||
code_interpreter: {
|
||||
label: $i18n.t('Code Interpreter'),
|
||||
description: $i18n.t('Execute code')
|
||||
}
|
||||
};
|
||||
|
||||
const allTools = Object.keys(toolLabels);
|
||||
|
||||
export let builtinTools: Record<string, boolean> = {};
|
||||
|
||||
// Initialize missing keys to true (default enabled)
|
||||
$: {
|
||||
for (const tool of allTools) {
|
||||
if (!(tool in builtinTools)) {
|
||||
builtinTools[tool] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div class="flex w-full justify-between mb-1">
|
||||
<div class="self-center text-xs font-medium text-gray-500">{$i18n.t('Builtin Tools')}</div>
|
||||
</div>
|
||||
<div class="flex items-center mt-2 flex-wrap">
|
||||
{#each allTools as tool}
|
||||
<div class="flex items-center gap-2 mr-3">
|
||||
<Checkbox
|
||||
state={builtinTools[tool] !== false ? 'checked' : 'unchecked'}
|
||||
on:change={(e) => {
|
||||
builtinTools = {
|
||||
...builtinTools,
|
||||
[tool]: e.detail === 'checked'
|
||||
};
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="py-0.5 text-sm">
|
||||
<Tooltip content={marked.parse(toolLabels[tool].description)}>
|
||||
{$i18n.t(toolLabels[tool].label)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
<script lang="ts">
|
||||
import dayjs from 'dayjs';
|
||||
import { DropdownMenu } from 'bits-ui';
|
||||
import { onMount, getContext, createEventDispatcher } from 'svelte';
|
||||
|
||||
import { onMount, onDestroy, getContext, createEventDispatcher } from 'svelte';
|
||||
import { searchNotes } from '$lib/apis/notes';
|
||||
import { searchKnowledgeBases, searchKnowledgeFiles } from '$lib/apis/knowledge';
|
||||
|
||||
|
|
@ -26,6 +25,7 @@
|
|||
let show = false;
|
||||
|
||||
let query = '';
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
let noteItems = [];
|
||||
let knowledgeItems = [];
|
||||
|
|
@ -35,10 +35,17 @@
|
|||
|
||||
$: items = [...noteItems, ...knowledgeItems, ...fileItems];
|
||||
|
||||
$: if (query !== null) {
|
||||
getItems();
|
||||
$: if (query !== undefined) {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
getItems();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
});
|
||||
|
||||
const getItems = () => {
|
||||
getNoteItems();
|
||||
getKnowledgeItems();
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
import DefaultFiltersSelector from './DefaultFiltersSelector.svelte';
|
||||
import DefaultFeatures from './DefaultFeatures.svelte';
|
||||
import BuiltinTools from './BuiltinTools.svelte';
|
||||
import PromptSuggestions from './PromptSuggestions.svelte';
|
||||
import AccessControlModal from '../common/AccessControlModal.svelte';
|
||||
import LockClosed from '$lib/components/icons/LockClosed.svelte';
|
||||
|
|
@ -104,6 +105,7 @@
|
|||
builtin_tools: true
|
||||
};
|
||||
let defaultFeatureIds = [];
|
||||
let builtinTools = {};
|
||||
|
||||
let actionIds = [];
|
||||
let accessControl = {};
|
||||
|
|
@ -195,6 +197,14 @@
|
|||
}
|
||||
}
|
||||
|
||||
if (Object.keys(builtinTools).length > 0) {
|
||||
info.meta.builtinTools = builtinTools;
|
||||
} else {
|
||||
if (info.meta.builtinTools) {
|
||||
delete info.meta.builtinTools;
|
||||
}
|
||||
}
|
||||
|
||||
if (tts.voice !== '') {
|
||||
if (!info.meta.tts) info.meta.tts = {};
|
||||
info.meta.tts.voice = tts.voice;
|
||||
|
|
@ -288,6 +298,7 @@
|
|||
|
||||
capabilities = { ...capabilities, ...(model?.meta?.capabilities ?? {}) };
|
||||
defaultFeatureIds = model?.meta?.defaultFeatureIds ?? [];
|
||||
builtinTools = model?.meta?.builtinTools ?? {};
|
||||
tts = { voice: model?.meta?.tts?.voice ?? '' };
|
||||
|
||||
if ('access_control' in model) {
|
||||
|
|
@ -780,6 +791,12 @@
|
|||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if capabilities.builtin_tools}
|
||||
<div class="my-2">
|
||||
<BuiltinTools bind:builtinTools />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="my-2">
|
||||
<div class="flex w-full justify-between mb-1">
|
||||
<div class="self-center text-xs font-medium text-gray-500">
|
||||
|
|
|
|||
|
|
@ -12,7 +12,13 @@
|
|||
const i18n = getContext('i18n');
|
||||
|
||||
onMount(() => {
|
||||
_tools = tools.reduce((acc, tool) => {
|
||||
// Filter out OAuth2.1 MCP tools - these cannot be set as model defaults
|
||||
// because OAuth authentication is per-user and would fail for users
|
||||
// who haven't completed the OAuth flow.
|
||||
// The `authenticated` field only exists on OAuth2.1 MCP tools.
|
||||
const availableTools = tools.filter((tool) => tool.authenticated === undefined);
|
||||
|
||||
_tools = availableTools.reduce((acc, tool) => {
|
||||
acc[tool.id] = {
|
||||
...tool,
|
||||
selected: selectedToolIds.includes(tool.id)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue