Merge pull request #6931 from open-webui/groups

feat: user groups
This commit is contained in:
Timothy Jaeryang Baek 2024-11-16 21:51:42 -08:00 committed by GitHub
commit 034674c19c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
102 changed files with 7486 additions and 4705 deletions

View file

@ -13,9 +13,7 @@ import requests
from open_webui.apps.webui.models.models import Models
from open_webui.config import (
CORS_ALLOW_ORIGIN,
ENABLE_MODEL_FILTER,
ENABLE_OLLAMA_API,
MODEL_FILTER_LIST,
OLLAMA_BASE_URLS,
OLLAMA_API_CONFIGS,
UPLOAD_DIR,
@ -66,32 +64,16 @@ app.add_middleware(
app.state.config = AppConfig()
app.state.config.ENABLE_MODEL_FILTER = ENABLE_MODEL_FILTER
app.state.config.MODEL_FILTER_LIST = MODEL_FILTER_LIST
app.state.config.ENABLE_OLLAMA_API = ENABLE_OLLAMA_API
app.state.config.OLLAMA_BASE_URLS = OLLAMA_BASE_URLS
app.state.config.OLLAMA_API_CONFIGS = OLLAMA_API_CONFIGS
app.state.MODELS = {}
# TODO: Implement a more intelligent load balancing mechanism for distributing requests among multiple backend instances.
# Current implementation uses a simple round-robin approach (random.choice). Consider incorporating algorithms like weighted round-robin,
# least connections, or least response time for better resource utilization and performance optimization.
@app.middleware("http")
async def check_url(request: Request, call_next):
if len(app.state.MODELS) == 0:
await get_all_models()
else:
pass
response = await call_next(request)
return response
@app.head("/")
@app.get("/")
async def get_status():
@ -326,8 +308,6 @@ async def get_all_models():
else:
models = {"models": []}
app.state.MODELS = {model["model"]: model for model in models["models"]}
return models
@ -339,16 +319,18 @@ async def get_ollama_tags(
if url_idx is None:
models = await get_all_models()
if app.state.config.ENABLE_MODEL_FILTER:
if user.role == "user":
models["models"] = list(
filter(
lambda model: model["name"]
in app.state.config.MODEL_FILTER_LIST,
models["models"],
)
)
return models
# TODO: Check User Group and Filter Models
# if app.state.config.ENABLE_MODEL_FILTER:
# if user.role == "user":
# models["models"] = list(
# filter(
# lambda model: model["name"]
# in app.state.config.MODEL_FILTER_LIST,
# models["models"],
# )
# )
# return models
return models
else:
url = app.state.config.OLLAMA_BASE_URLS[url_idx]
@ -473,8 +455,11 @@ async def push_model(
user=Depends(get_admin_user),
):
if url_idx is None:
if form_data.name in app.state.MODELS:
url_idx = app.state.MODELS[form_data.name]["urls"][0]
model_list = await get_all_models()
models = {model["model"]: model for model in model_list["models"]}
if form_data.name in models:
url_idx = models[form_data.name]["urls"][0]
else:
raise HTTPException(
status_code=400,
@ -523,8 +508,11 @@ async def copy_model(
user=Depends(get_admin_user),
):
if url_idx is None:
if form_data.source in app.state.MODELS:
url_idx = app.state.MODELS[form_data.source]["urls"][0]
model_list = await get_all_models()
models = {model["model"]: model for model in model_list["models"]}
if form_data.source in models:
url_idx = models[form_data.source]["urls"][0]
else:
raise HTTPException(
status_code=400,
@ -579,8 +567,11 @@ async def delete_model(
user=Depends(get_admin_user),
):
if url_idx is None:
if form_data.name in app.state.MODELS:
url_idx = app.state.MODELS[form_data.name]["urls"][0]
model_list = await get_all_models()
models = {model["model"]: model for model in model_list["models"]}
if form_data.name in models:
url_idx = models[form_data.name]["urls"][0]
else:
raise HTTPException(
status_code=400,
@ -628,13 +619,16 @@ async def delete_model(
@app.post("/api/show")
async def show_model_info(form_data: ModelNameForm, user=Depends(get_verified_user)):
if form_data.name not in app.state.MODELS:
model_list = await get_all_models()
models = {model["model"]: model for model in model_list["models"]}
if form_data.name not in models:
raise HTTPException(
status_code=400,
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.name),
)
url_idx = random.choice(app.state.MODELS[form_data.name]["urls"])
url_idx = random.choice(models[form_data.name]["urls"])
url = app.state.config.OLLAMA_BASE_URLS[url_idx]
log.info(f"url: {url}")
@ -704,23 +698,26 @@ async def generate_embeddings(
url_idx: Optional[int] = None,
user=Depends(get_verified_user),
):
return generate_ollama_embeddings(form_data=form_data, url_idx=url_idx)
return await generate_ollama_embeddings(form_data=form_data, url_idx=url_idx)
def generate_ollama_embeddings(
async def generate_ollama_embeddings(
form_data: GenerateEmbeddingsForm,
url_idx: Optional[int] = None,
):
log.info(f"generate_ollama_embeddings {form_data}")
if url_idx is None:
model_list = await get_all_models()
models = {model["model"]: model for model in model_list["models"]}
model = form_data.model
if ":" not in model:
model = f"{model}:latest"
if model in app.state.MODELS:
url_idx = random.choice(app.state.MODELS[model]["urls"])
if model in models:
url_idx = random.choice(models[model]["urls"])
else:
raise HTTPException(
status_code=400,
@ -771,20 +768,23 @@ def generate_ollama_embeddings(
)
def generate_ollama_batch_embeddings(
async def generate_ollama_batch_embeddings(
form_data: GenerateEmbedForm,
url_idx: Optional[int] = None,
):
log.info(f"generate_ollama_batch_embeddings {form_data}")
if url_idx is None:
model_list = await get_all_models()
models = {model["model"]: model for model in model_list["models"]}
model = form_data.model
if ":" not in model:
model = f"{model}:latest"
if model in app.state.MODELS:
url_idx = random.choice(app.state.MODELS[model]["urls"])
if model in models:
url_idx = random.choice(models[model]["urls"])
else:
raise HTTPException(
status_code=400,
@ -854,13 +854,16 @@ async def generate_completion(
user=Depends(get_verified_user),
):
if url_idx is None:
model_list = await get_all_models()
models = {model["model"]: model for model in model_list["models"]}
model = form_data.model
if ":" not in model:
model = f"{model}:latest"
if model in app.state.MODELS:
url_idx = random.choice(app.state.MODELS[model]["urls"])
if model in models:
url_idx = random.choice(models[model]["urls"])
else:
raise HTTPException(
status_code=400,
@ -895,14 +898,17 @@ class GenerateChatCompletionForm(BaseModel):
keep_alive: Optional[Union[int, str]] = None
def get_ollama_url(url_idx: Optional[int], model: str):
async def get_ollama_url(url_idx: Optional[int], model: str):
if url_idx is None:
if model not in app.state.MODELS:
model_list = await get_all_models()
models = {model["model"]: model for model in model_list["models"]}
if model not in models:
raise HTTPException(
status_code=400,
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model),
)
url_idx = random.choice(app.state.MODELS[model]["urls"])
url_idx = random.choice(models[model]["urls"])
url = app.state.config.OLLAMA_BASE_URLS[url_idx]
return url
@ -922,12 +928,14 @@ async def generate_chat_completion(
model_id = form_data.model
if not bypass_filter and app.state.config.ENABLE_MODEL_FILTER:
if user.role == "user" and model_id not in app.state.config.MODEL_FILTER_LIST:
raise HTTPException(
status_code=403,
detail="Model not found",
)
# TODO: Check User Group and Filter Models
# if not bypass_filter:
# if app.state.config.ENABLE_MODEL_FILTER:
# if user.role == "user" and model_id not in app.state.config.MODEL_FILTER_LIST:
# raise HTTPException(
# status_code=403,
# detail="Model not found",
# )
model_info = Models.get_model_by_id(model_id)
@ -949,7 +957,7 @@ async def generate_chat_completion(
if ":" not in payload["model"]:
payload["model"] = f"{payload['model']}:latest"
url = get_ollama_url(url_idx, payload["model"])
url = await get_ollama_url(url_idx, payload["model"])
log.info(f"url: {url}")
log.debug(f"generate_chat_completion() - 2.payload = {payload}")
@ -1008,12 +1016,13 @@ async def generate_openai_chat_completion(
model_id = completion_form.model
if app.state.config.ENABLE_MODEL_FILTER:
if user.role == "user" and model_id not in app.state.config.MODEL_FILTER_LIST:
raise HTTPException(
status_code=403,
detail="Model not found",
)
# TODO: Check User Group and Filter Models
# if app.state.config.ENABLE_MODEL_FILTER:
# if user.role == "user" and model_id not in app.state.config.MODEL_FILTER_LIST:
# raise HTTPException(
# status_code=403,
# detail="Model not found",
# )
model_info = Models.get_model_by_id(model_id)
@ -1030,7 +1039,7 @@ async def generate_openai_chat_completion(
if ":" not in payload["model"]:
payload["model"] = f"{payload['model']}:latest"
url = get_ollama_url(url_idx, payload["model"])
url = await get_ollama_url(url_idx, payload["model"])
log.info(f"url: {url}")
api_config = app.state.config.OLLAMA_API_CONFIGS.get(url, {})
@ -1054,15 +1063,16 @@ async def get_openai_models(
if url_idx is None:
models = await get_all_models()
if app.state.config.ENABLE_MODEL_FILTER:
if user.role == "user":
models["models"] = list(
filter(
lambda model: model["name"]
in app.state.config.MODEL_FILTER_LIST,
models["models"],
)
)
# TODO: Check User Group and Filter Models
# if app.state.config.ENABLE_MODEL_FILTER:
# if user.role == "user":
# models["models"] = list(
# filter(
# lambda model: model["name"]
# in app.state.config.MODEL_FILTER_LIST,
# models["models"],
# )
# )
return {
"data": [

View file

@ -11,9 +11,7 @@ from open_webui.apps.webui.models.models import Models
from open_webui.config import (
CACHE_DIR,
CORS_ALLOW_ORIGIN,
ENABLE_MODEL_FILTER,
ENABLE_OPENAI_API,
MODEL_FILTER_LIST,
OPENAI_API_BASE_URLS,
OPENAI_API_KEYS,
OPENAI_API_CONFIGS,
@ -39,6 +37,8 @@ from open_webui.utils.payload import (
)
from open_webui.utils.utils import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["OPENAI"])
@ -61,25 +61,11 @@ app.add_middleware(
app.state.config = AppConfig()
app.state.config.ENABLE_MODEL_FILTER = ENABLE_MODEL_FILTER
app.state.config.MODEL_FILTER_LIST = MODEL_FILTER_LIST
app.state.config.ENABLE_OPENAI_API = ENABLE_OPENAI_API
app.state.config.OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS
app.state.config.OPENAI_API_KEYS = OPENAI_API_KEYS
app.state.config.OPENAI_API_CONFIGS = OPENAI_API_CONFIGS
app.state.MODELS = {}
@app.middleware("http")
async def check_url(request: Request, call_next):
if len(app.state.MODELS) == 0:
await get_all_models()
response = await call_next(request)
return response
@app.get("/config")
async def get_config(user=Depends(get_admin_user)):
@ -264,7 +250,7 @@ def merge_models_lists(model_lists):
return merged_list
async def get_all_models_raw() -> list:
async def get_all_models_responses() -> list:
if not app.state.config.ENABLE_OPENAI_API:
return []
@ -335,22 +321,13 @@ async def get_all_models_raw() -> list:
return responses
@overload
async def get_all_models(raw: Literal[True]) -> list: ...
@overload
async def get_all_models(raw: Literal[False] = False) -> dict[str, list]: ...
async def get_all_models(raw=False) -> dict[str, list] | list:
async def get_all_models() -> dict[str, list]:
log.info("get_all_models()")
if not app.state.config.ENABLE_OPENAI_API:
return [] if raw else {"data": []}
responses = await get_all_models_raw()
if raw:
return responses
if not app.state.config.ENABLE_OPENAI_API:
return {"data": []}
responses = await get_all_models_responses()
def extract_data(response):
if response and "data" in response:
@ -360,9 +337,7 @@ async def get_all_models(raw=False) -> dict[str, list] | list:
return None
models = {"data": merge_models_lists(map(extract_data, responses))}
log.debug(f"models: {models}")
app.state.MODELS = {model["id"]: model for model in models["data"]}
return models
@ -370,18 +345,12 @@ async def get_all_models(raw=False) -> dict[str, list] | list:
@app.get("/models")
@app.get("/models/{url_idx}")
async def get_models(url_idx: Optional[int] = None, user=Depends(get_verified_user)):
models = {
"data": [],
}
if url_idx is None:
models = await get_all_models()
if app.state.config.ENABLE_MODEL_FILTER:
if user.role == "user":
models["data"] = list(
filter(
lambda model: model["id"] in app.state.config.MODEL_FILTER_LIST,
models["data"],
)
)
return models
return models
else:
url = app.state.config.OPENAI_API_BASE_URLS[url_idx]
key = app.state.config.OPENAI_API_KEYS[url_idx]
@ -389,6 +358,7 @@ async def get_models(url_idx: Optional[int] = None, user=Depends(get_verified_us
headers = {}
headers["Authorization"] = f"Bearer {key}"
headers["Content-Type"] = "application/json"
if ENABLE_FORWARD_USER_INFO_HEADERS:
headers["X-OpenWebUI-User-Name"] = user.name
headers["X-OpenWebUI-User-Id"] = user.id
@ -430,8 +400,7 @@ async def get_models(url_idx: Optional[int] = None, user=Depends(get_verified_us
)
]
return response_data
models = response_data
except aiohttp.ClientError as e:
# ClientError covers all aiohttp requests issues
log.exception(f"Client error: {str(e)}")
@ -445,6 +414,22 @@ async def get_models(url_idx: Optional[int] = None, user=Depends(get_verified_us
error_detail = f"Unexpected error: {str(e)}"
raise HTTPException(status_code=500, detail=error_detail)
if user.role == "user":
# Filter models based on user access control
filtered_models = []
for model in models.get("data", []):
model_info = Models.get_model_by_id(model["id"])
if model_info:
if has_access(
user.id, type="read", access_control=model_info.access_control
):
filtered_models.append(model)
else:
filtered_models.append(model)
models["data"] = filtered_models
return models
class ConnectionVerificationForm(BaseModel):
url: str
@ -492,11 +477,10 @@ async def verify_connection(
@app.post("/chat/completions")
@app.post("/chat/completions/{url_idx}")
async def generate_chat_completion(
form_data: dict,
url_idx: Optional[int] = None,
user=Depends(get_verified_user),
bypass_filter: Optional[bool] = False,
):
idx = 0
payload = {**form_data}
@ -507,6 +491,7 @@ async def generate_chat_completion(
model_id = form_data.get("model")
model_info = Models.get_model_by_id(model_id)
# Check model info and override the payload
if model_info:
if model_info.base_model_id:
payload["model"] = model_info.base_model_id
@ -515,9 +500,33 @@ async def generate_chat_completion(
payload = apply_model_params_to_body_openai(params, payload)
payload = apply_model_system_prompt_to_body(params, payload, user)
model = app.state.MODELS[payload.get("model")]
idx = model["urlIdx"]
# Check if user has access to the model
if user.role == "user" and not has_access(
user.id, type="read", access_control=model_info.access_control
):
raise HTTPException(
status_code=403,
detail="Model not found",
)
# Attemp to get urlIdx from the model
models = await get_all_models()
# Find the model from the list
model = next(
(model for model in models["data"] if model["id"] == payload.get("model")),
None,
)
if model:
idx = model["urlIdx"]
else:
raise HTTPException(
status_code=404,
detail="Model not found",
)
# Get the API config for the model
api_config = app.state.config.OPENAI_API_CONFIGS.get(
app.state.config.OPENAI_API_BASE_URLS[idx], {}
)
@ -526,6 +535,7 @@ async def generate_chat_completion(
if prefix_id:
payload["model"] = payload["model"].replace(f"{prefix_id}.", "")
# Add user info to the payload if the model is a pipeline
if "pipeline" in model and model.get("pipeline"):
payload["user"] = {
"name": user.name,
@ -536,8 +546,9 @@ async def generate_chat_completion(
url = app.state.config.OPENAI_API_BASE_URLS[idx]
key = app.state.config.OPENAI_API_KEYS[idx]
is_o1 = payload["model"].lower().startswith("o1-")
# Fix: O1 does not support the "max_tokens" parameter, Modify "max_tokens" to "max_completion_tokens"
is_o1 = payload["model"].lower().startswith("o1-")
# Change max_completion_tokens to max_tokens (Backward compatible)
if "api.openai.com" not in url and not is_o1:
if "max_completion_tokens" in payload:

View file

@ -3,6 +3,7 @@ import os
import uuid
from typing import Optional, Union
import asyncio
import requests
from huggingface_hub import snapshot_download
@ -291,7 +292,13 @@ def get_embedding_function(
if embedding_engine == "":
return lambda query: embedding_function.encode(query).tolist()
elif embedding_engine in ["ollama", "openai"]:
func = lambda query: generate_embeddings(
# Wrapper to run the async generate_embeddings synchronously.
def sync_generate_embeddings(*args, **kwargs):
return asyncio.run(generate_embeddings(*args, **kwargs))
# Semantic expectation from the original version (using sync wrapper).
func = lambda query: sync_generate_embeddings(
engine=embedding_engine,
model=embedding_model,
text=query,
@ -469,7 +476,7 @@ def get_model_path(model: str, update_model: bool = False):
return model
def generate_openai_batch_embeddings(
async def generate_openai_batch_embeddings(
model: str, texts: list[str], key: str, url: str = "https://api.openai.com/v1"
) -> Optional[list[list[float]]]:
try:
@ -492,14 +499,16 @@ def generate_openai_batch_embeddings(
return None
def generate_embeddings(engine: str, model: str, text: Union[str, list[str]], **kwargs):
async def generate_embeddings(
engine: str, model: str, text: Union[str, list[str]], **kwargs
):
if engine == "ollama":
if isinstance(text, list):
embeddings = generate_ollama_batch_embeddings(
embeddings = await generate_ollama_batch_embeddings(
GenerateEmbedForm(**{"model": model, "input": text})
)
else:
embeddings = generate_ollama_batch_embeddings(
embeddings = await generate_ollama_batch_embeddings(
GenerateEmbedForm(**{"model": model, "input": [text]})
)
return (
@ -512,9 +521,9 @@ def generate_embeddings(engine: str, model: str, text: Union[str, list[str]], **
url = kwargs.get("url", "https://api.openai.com/v1")
if isinstance(text, list):
embeddings = generate_openai_batch_embeddings(model, text, key, url)
embeddings = await generate_openai_batch_embeddings(model, text, key, url)
else:
embeddings = generate_openai_batch_embeddings(model, [text], key, url)
embeddings = await generate_openai_batch_embeddings(model, [text], key, url)
return embeddings[0] if isinstance(text, str) else embeddings

View file

@ -12,6 +12,7 @@ from open_webui.apps.webui.routers import (
chats,
folders,
configs,
groups,
files,
functions,
memories,
@ -85,7 +86,11 @@ from open_webui.utils.payload import (
from open_webui.utils.tools import get_tools
app = FastAPI(docs_url="/docs" if ENV == "dev" else None, openapi_url="/openapi.json" if ENV == "dev" else None, redoc_url=None)
app = FastAPI(
docs_url="/docs" if ENV == "dev" else None,
openapi_url="/openapi.json" if ENV == "dev" else None,
redoc_url=None,
)
log = logging.getLogger(__name__)
@ -105,6 +110,8 @@ app.state.config.ADMIN_EMAIL = ADMIN_EMAIL
app.state.config.DEFAULT_MODELS = DEFAULT_MODELS
app.state.config.DEFAULT_PROMPT_SUGGESTIONS = DEFAULT_PROMPT_SUGGESTIONS
app.state.config.DEFAULT_USER_ROLE = DEFAULT_USER_ROLE
app.state.config.USER_PERMISSIONS = USER_PERMISSIONS
app.state.config.WEBHOOK_URL = WEBHOOK_URL
app.state.config.BANNERS = WEBUI_BANNERS
@ -137,7 +144,6 @@ app.state.config.LDAP_USE_TLS = LDAP_USE_TLS
app.state.config.LDAP_CA_CERT_FILE = LDAP_CA_CERT_FILE
app.state.config.LDAP_CIPHERS = LDAP_CIPHERS
app.state.MODELS = {}
app.state.TOOLS = {}
app.state.FUNCTIONS = {}
@ -161,13 +167,15 @@ app.include_router(models.router, prefix="/models", tags=["models"])
app.include_router(knowledge.router, prefix="/knowledge", tags=["knowledge"])
app.include_router(prompts.router, prefix="/prompts", tags=["prompts"])
app.include_router(tools.router, prefix="/tools", tags=["tools"])
app.include_router(functions.router, prefix="/functions", tags=["functions"])
app.include_router(memories.router, prefix="/memories", tags=["memories"])
app.include_router(folders.router, prefix="/folders", tags=["folders"])
app.include_router(groups.router, prefix="/groups", tags=["groups"])
app.include_router(files.router, prefix="/files", tags=["files"])
app.include_router(functions.router, prefix="/functions", tags=["functions"])
app.include_router(evaluations.router, prefix="/evaluations", tags=["evaluations"])
app.include_router(folders.router, prefix="/folders", tags=["folders"])
app.include_router(files.router, prefix="/files", tags=["files"])
app.include_router(utils.router, prefix="/utils", tags=["utils"])
@ -362,7 +370,7 @@ def get_function_params(function_module, form_data, user, extra_params=None):
return params
async def generate_function_chat_completion(form_data, user):
async def generate_function_chat_completion(form_data, user, models: dict = {}):
model_id = form_data.get("model")
model_info = Models.get_model_by_id(model_id)
@ -405,7 +413,7 @@ async def generate_function_chat_completion(form_data, user):
user,
{
**extra_params,
"__model__": app.state.MODELS[form_data["model"]],
"__model__": models.get(form_data["model"], None),
"__messages__": form_data["messages"],
"__files__": files,
},

View file

@ -1,157 +0,0 @@
import json
import logging
import time
from typing import Optional
from open_webui.apps.webui.internal.db import Base, get_db
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
####################
# Documents DB Schema
####################
class Document(Base):
__tablename__ = "document"
collection_name = Column(String, primary_key=True)
name = Column(String, unique=True)
title = Column(Text)
filename = Column(Text)
content = Column(Text, nullable=True)
user_id = Column(String)
timestamp = Column(BigInteger)
class DocumentModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
collection_name: str
name: str
title: str
filename: str
content: Optional[str] = None
user_id: str
timestamp: int # timestamp in epoch
####################
# Forms
####################
class DocumentResponse(BaseModel):
collection_name: str
name: str
title: str
filename: str
content: Optional[dict] = None
user_id: str
timestamp: int # timestamp in epoch
class DocumentUpdateForm(BaseModel):
name: str
title: str
class DocumentForm(DocumentUpdateForm):
collection_name: str
filename: str
content: Optional[str] = None
class DocumentsTable:
def insert_new_doc(
self, user_id: str, form_data: DocumentForm
) -> Optional[DocumentModel]:
with get_db() as db:
document = DocumentModel(
**{
**form_data.model_dump(),
"user_id": user_id,
"timestamp": int(time.time()),
}
)
try:
result = Document(**document.model_dump())
db.add(result)
db.commit()
db.refresh(result)
if result:
return DocumentModel.model_validate(result)
else:
return None
except Exception:
return None
def get_doc_by_name(self, name: str) -> Optional[DocumentModel]:
try:
with get_db() as db:
document = db.query(Document).filter_by(name=name).first()
return DocumentModel.model_validate(document) if document else None
except Exception:
return None
def get_docs(self) -> list[DocumentModel]:
with get_db() as db:
return [
DocumentModel.model_validate(doc) for doc in db.query(Document).all()
]
def update_doc_by_name(
self, name: str, form_data: DocumentUpdateForm
) -> Optional[DocumentModel]:
try:
with get_db() as db:
db.query(Document).filter_by(name=name).update(
{
"title": form_data.title,
"name": form_data.name,
"timestamp": int(time.time()),
}
)
db.commit()
return self.get_doc_by_name(form_data.name)
except Exception as e:
log.exception(e)
return None
def update_doc_content_by_name(
self, name: str, updated: dict
) -> Optional[DocumentModel]:
try:
doc = self.get_doc_by_name(name)
doc_content = json.loads(doc.content if doc.content else "{}")
doc_content = {**doc_content, **updated}
with get_db() as db:
db.query(Document).filter_by(name=name).update(
{
"content": json.dumps(doc_content),
"timestamp": int(time.time()),
}
)
db.commit()
return self.get_doc_by_name(name)
except Exception as e:
log.exception(e)
return None
def delete_doc_by_name(self, name: str) -> bool:
try:
with get_db() as db:
db.query(Document).filter_by(name=name).delete()
db.commit()
return True
except Exception:
return False
Documents = DocumentsTable()

View file

@ -0,0 +1,181 @@
import json
import logging
import time
from typing import Optional
import uuid
from open_webui.apps.webui.internal.db import Base, get_db
from open_webui.env import SRC_LOG_LEVELS
from open_webui.apps.webui.models.files import FileMetadataResponse
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text, JSON
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
####################
# UserGroup DB Schema
####################
class Group(Base):
__tablename__ = "group"
id = Column(Text, unique=True, primary_key=True)
user_id = Column(Text)
name = Column(Text)
description = Column(Text)
data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
permissions = Column(JSON, nullable=True)
user_ids = Column(JSON, nullable=True)
created_at = Column(BigInteger)
updated_at = Column(BigInteger)
class GroupModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
user_id: str
name: str
description: str
data: Optional[dict] = None
meta: Optional[dict] = None
permissions: Optional[dict] = None
user_ids: list[str] = []
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
####################
# Forms
####################
class GroupResponse(BaseModel):
id: str
user_id: str
name: str
description: str
permissions: Optional[dict] = None
data: Optional[dict] = None
meta: Optional[dict] = None
user_ids: list[str] = []
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
class GroupForm(BaseModel):
name: str
description: str
class GroupUpdateForm(GroupForm):
permissions: Optional[dict] = None
user_ids: Optional[list[str]] = None
admin_ids: Optional[list[str]] = None
class GroupTable:
def insert_new_group(
self, user_id: str, form_data: GroupForm
) -> Optional[GroupModel]:
with get_db() as db:
group = GroupModel(
**{
**form_data.model_dump(),
"id": str(uuid.uuid4()),
"user_id": user_id,
"created_at": int(time.time()),
"updated_at": int(time.time()),
}
)
try:
result = Group(**group.model_dump())
db.add(result)
db.commit()
db.refresh(result)
if result:
return GroupModel.model_validate(result)
else:
return None
except Exception:
return None
def get_groups(self) -> list[GroupModel]:
with get_db() as db:
return [
GroupModel.model_validate(group)
for group in db.query(Group).order_by(Group.updated_at.desc()).all()
]
def get_groups_by_member_id(self, user_id: str) -> list[GroupModel]:
with get_db() as db:
return [
GroupModel.model_validate(group)
for group in db.query(Group)
.filter(Group.user_ids.contains([user_id]))
.order_by(Group.updated_at.desc())
.all()
]
def get_group_by_id(self, id: str) -> Optional[GroupModel]:
try:
with get_db() as db:
group = db.query(Group).filter_by(id=id).first()
return GroupModel.model_validate(group) if group else None
except Exception:
return None
def update_group_by_id(
self, id: str, form_data: GroupUpdateForm, overwrite: bool = False
) -> Optional[GroupModel]:
try:
with get_db() as db:
db.query(Group).filter_by(id=id).update(
{
**form_data.model_dump(exclude_none=True),
"updated_at": int(time.time()),
}
)
db.commit()
return self.get_group_by_id(id=id)
except Exception as e:
log.exception(e)
return None
def delete_group_by_id(self, id: str) -> bool:
try:
with get_db() as db:
db.query(Group).filter_by(id=id).delete()
db.commit()
return True
except Exception:
return False
def delete_all_groups(self) -> bool:
with get_db() as db:
try:
db.query(Group).delete()
db.commit()
return True
except Exception:
return False
Groups = GroupTable()

View file

@ -13,6 +13,7 @@ from open_webui.apps.webui.models.files import FileMetadataResponse
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text, JSON
from open_webui.utils.access_control import has_access
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
@ -34,6 +35,23 @@ class Knowledge(Base):
data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
access_control = Column(JSON, nullable=True) # Controls data access levels.
# Defines access control rules for this entry.
# - `None`: Public access, available to all users with the "user" role.
# - `{}`: Private access, restricted exclusively to the owner.
# - Custom permissions: Specific access control for reading and writing;
# Can specify group or user-level restrictions:
# {
# "read": {
# "group_ids": ["group_id1", "group_id2"],
# "user_ids": ["user_id1", "user_id2"]
# },
# "write": {
# "group_ids": ["group_id1", "group_id2"],
# "user_ids": ["user_id1", "user_id2"]
# }
# }
created_at = Column(BigInteger)
updated_at = Column(BigInteger)
@ -50,6 +68,8 @@ class KnowledgeModel(BaseModel):
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
@ -65,6 +85,8 @@ class KnowledgeResponse(BaseModel):
description: str
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
@ -75,12 +97,7 @@ class KnowledgeForm(BaseModel):
name: str
description: str
data: Optional[dict] = None
class KnowledgeUpdateForm(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
data: Optional[dict] = None
access_control: Optional[dict] = None
class KnowledgeTable:
@ -110,7 +127,7 @@ class KnowledgeTable:
except Exception:
return None
def get_knowledge_items(self) -> list[KnowledgeModel]:
def get_knowledge_bases(self) -> list[KnowledgeModel]:
with get_db() as db:
return [
KnowledgeModel.model_validate(knowledge)
@ -119,6 +136,17 @@ class KnowledgeTable:
.all()
]
def get_knowledge_bases_by_user_id(
self, user_id: str, permission: str = "write"
) -> list[KnowledgeModel]:
knowledge_bases = self.get_knowledge_bases()
return [
knowledge_base
for knowledge_base in knowledge_bases
if knowledge_base.user_id == user_id
or has_access(user_id, permission, knowledge_base.access_control)
]
def get_knowledge_by_id(self, id: str) -> Optional[KnowledgeModel]:
try:
with get_db() as db:
@ -128,14 +156,32 @@ class KnowledgeTable:
return None
def update_knowledge_by_id(
self, id: str, form_data: KnowledgeUpdateForm, overwrite: bool = False
self, id: str, form_data: KnowledgeForm, overwrite: bool = False
) -> Optional[KnowledgeModel]:
try:
with get_db() as db:
knowledge = self.get_knowledge_by_id(id=id)
db.query(Knowledge).filter_by(id=id).update(
{
**form_data.model_dump(exclude_none=True),
**form_data.model_dump(),
"updated_at": int(time.time()),
}
)
db.commit()
return self.get_knowledge_by_id(id=id)
except Exception as e:
log.exception(e)
return None
def update_knowledge_data_by_id(
self, id: str, data: dict
) -> Optional[KnowledgeModel]:
try:
with get_db() as db:
knowledge = self.get_knowledge_by_id(id=id)
db.query(Knowledge).filter_by(id=id).update(
{
"data": data,
"updated_at": int(time.time()),
}
)

View file

@ -4,8 +4,19 @@ from typing import Optional
from open_webui.apps.webui.internal.db import Base, JSONField, get_db
from open_webui.env import SRC_LOG_LEVELS
from open_webui.apps.webui.models.groups import Groups
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, Text
from sqlalchemy import or_, and_, func
from sqlalchemy.dialects import postgresql, sqlite
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean
from open_webui.utils.access_control import has_access
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
@ -67,6 +78,25 @@ class Model(Base):
Holds a JSON encoded blob of metadata, see `ModelMeta`.
"""
access_control = Column(JSON, nullable=True) # Controls data access levels.
# Defines access control rules for this entry.
# - `None`: Public access, available to all users with the "user" role.
# - `{}`: Private access, restricted exclusively to the owner.
# - Custom permissions: Specific access control for reading and writing;
# Can specify group or user-level restrictions:
# {
# "read": {
# "group_ids": ["group_id1", "group_id2"],
# "user_ids": ["user_id1", "user_id2"]
# },
# "write": {
# "group_ids": ["group_id1", "group_id2"],
# "user_ids": ["user_id1", "user_id2"]
# }
# }
is_active = Column(Boolean, default=True)
updated_at = Column(BigInteger)
created_at = Column(BigInteger)
@ -80,6 +110,9 @@ class ModelModel(BaseModel):
params: ModelParams
meta: ModelMeta
access_control: Optional[dict] = None
is_active: bool
updated_at: int # timestamp in epoch
created_at: int # timestamp in epoch
@ -93,8 +126,16 @@ class ModelModel(BaseModel):
class ModelResponse(BaseModel):
id: str
user_id: str
base_model_id: Optional[str] = None
name: str
params: ModelParams
meta: ModelMeta
access_control: Optional[dict] = None
is_active: bool
updated_at: int # timestamp in epoch
created_at: int # timestamp in epoch
@ -105,6 +146,8 @@ class ModelForm(BaseModel):
name: str
meta: ModelMeta
params: ModelParams
access_control: Optional[dict] = None
is_active: bool = True
class ModelsTable:
@ -138,6 +181,31 @@ class ModelsTable:
with get_db() as db:
return [ModelModel.model_validate(model) for model in db.query(Model).all()]
def get_models(self) -> list[ModelModel]:
with get_db() as db:
return [
ModelModel.model_validate(model)
for model in db.query(Model).filter(Model.base_model_id != None).all()
]
def get_base_models(self) -> list[ModelModel]:
with get_db() as db:
return [
ModelModel.model_validate(model)
for model in db.query(Model).filter(Model.base_model_id == None).all()
]
def get_models_by_user_id(
self, user_id: str, permission: str = "write"
) -> list[ModelModel]:
models = self.get_all_models()
return [
model
for model in models
if model.user_id == user_id
or has_access(user_id, permission, model.access_control)
]
def get_model_by_id(self, id: str) -> Optional[ModelModel]:
try:
with get_db() as db:
@ -146,6 +214,23 @@ class ModelsTable:
except Exception:
return None
def toggle_model_by_id(self, id: str) -> Optional[ModelModel]:
with get_db() as db:
try:
is_active = db.query(Model).filter_by(id=id).first().is_active
db.query(Model).filter_by(id=id).update(
{
"is_active": not is_active,
"updated_at": int(time.time()),
}
)
db.commit()
return self.get_model_by_id(id)
except Exception:
return None
def update_model_by_id(self, id: str, model: ModelForm) -> Optional[ModelModel]:
try:
with get_db() as db:
@ -153,7 +238,7 @@ class ModelsTable:
result = (
db.query(Model)
.filter_by(id=id)
.update(model.model_dump(exclude={"id"}, exclude_none=True))
.update(model.model_dump(exclude={"id"}))
)
db.commit()

View file

@ -2,8 +2,12 @@ import time
from typing import Optional
from open_webui.apps.webui.internal.db import Base, get_db
from open_webui.apps.webui.models.groups import Groups
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text
from sqlalchemy import BigInteger, Column, String, Text, JSON
from open_webui.utils.access_control import has_access
####################
# Prompts DB Schema
@ -19,6 +23,23 @@ class Prompt(Base):
content = Column(Text)
timestamp = Column(BigInteger)
access_control = Column(JSON, nullable=True) # Controls data access levels.
# Defines access control rules for this entry.
# - `None`: Public access, available to all users with the "user" role.
# - `{}`: Private access, restricted exclusively to the owner.
# - Custom permissions: Specific access control for reading and writing;
# Can specify group or user-level restrictions:
# {
# "read": {
# "group_ids": ["group_id1", "group_id2"],
# "user_ids": ["user_id1", "user_id2"]
# },
# "write": {
# "group_ids": ["group_id1", "group_id2"],
# "user_ids": ["user_id1", "user_id2"]
# }
# }
class PromptModel(BaseModel):
command: str
@ -27,6 +48,7 @@ class PromptModel(BaseModel):
content: str
timestamp: int # timestamp in epoch
access_control: Optional[dict] = None
model_config = ConfigDict(from_attributes=True)
@ -39,6 +61,7 @@ class PromptForm(BaseModel):
command: str
title: str
content: str
access_control: Optional[dict] = None
class PromptsTable:
@ -48,16 +71,14 @@ class PromptsTable:
prompt = PromptModel(
**{
"user_id": user_id,
"command": form_data.command,
"title": form_data.title,
"content": form_data.content,
**form_data.model_dump(),
"timestamp": int(time.time()),
}
)
try:
with get_db() as db:
result = Prompt(**prompt.dict())
result = Prompt(**prompt.model_dump())
db.add(result)
db.commit()
db.refresh(result)
@ -82,6 +103,18 @@ class PromptsTable:
PromptModel.model_validate(prompt) for prompt in db.query(Prompt).all()
]
def get_prompts_by_user_id(
self, user_id: str, permission: str = "write"
) -> list[PromptModel]:
prompts = self.get_prompts()
return [
prompt
for prompt in prompts
if prompt.user_id == user_id
or has_access(user_id, permission, prompt.access_control)
]
def update_prompt_by_command(
self, command: str, form_data: PromptForm
) -> Optional[PromptModel]:
@ -90,6 +123,7 @@ class PromptsTable:
prompt = db.query(Prompt).filter_by(command=command).first()
prompt.title = form_data.title
prompt.content = form_data.content
prompt.access_control = form_data.access_control
prompt.timestamp = int(time.time())
db.commit()
return PromptModel.model_validate(prompt)

View file

@ -6,7 +6,10 @@ from open_webui.apps.webui.internal.db import Base, JSONField, get_db
from open_webui.apps.webui.models.users import Users
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text
from sqlalchemy import BigInteger, Column, String, Text, JSON
from open_webui.utils.access_control import has_access
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
@ -26,6 +29,24 @@ class Tool(Base):
specs = Column(JSONField)
meta = Column(JSONField)
valves = Column(JSONField)
access_control = Column(JSON, nullable=True) # Controls data access levels.
# Defines access control rules for this entry.
# - `None`: Public access, available to all users with the "user" role.
# - `{}`: Private access, restricted exclusively to the owner.
# - Custom permissions: Specific access control for reading and writing;
# Can specify group or user-level restrictions:
# {
# "read": {
# "group_ids": ["group_id1", "group_id2"],
# "user_ids": ["user_id1", "user_id2"]
# },
# "write": {
# "group_ids": ["group_id1", "group_id2"],
# "user_ids": ["user_id1", "user_id2"]
# }
# }
updated_at = Column(BigInteger)
created_at = Column(BigInteger)
@ -42,6 +63,8 @@ class ToolModel(BaseModel):
content: str
specs: list[dict]
meta: ToolMeta
access_control: Optional[dict] = None
updated_at: int # timestamp in epoch
created_at: int # timestamp in epoch
@ -58,6 +81,7 @@ class ToolResponse(BaseModel):
user_id: str
name: str
meta: ToolMeta
access_control: Optional[dict] = None
updated_at: int # timestamp in epoch
created_at: int # timestamp in epoch
@ -67,6 +91,7 @@ class ToolForm(BaseModel):
name: str
content: str
meta: ToolMeta
access_control: Optional[dict] = None
class ToolValves(BaseModel):
@ -113,6 +138,18 @@ class ToolsTable:
with get_db() as db:
return [ToolModel.model_validate(tool) for tool in db.query(Tool).all()]
def get_tools_by_user_id(
self, user_id: str, permission: str = "write"
) -> list[ToolModel]:
tools = self.get_tools()
return [
tool
for tool in tools
if tool.user_id == user_id
or has_access(user_id, permission, tool.access_control)
]
def get_tool_valves_by_id(self, id: str) -> Optional[dict]:
try:
with get_db() as db:

View file

@ -40,10 +40,12 @@ from open_webui.utils.utils import (
get_password_hash,
)
from open_webui.utils.webhook import post_webhook
from open_webui.utils.access_control import get_permissions
from typing import Optional, List
from ldap3 import Server, Connection, ALL, Tls
from ssl import CERT_REQUIRED, PROTOCOL_TLS
from ldap3 import Server, Connection, ALL, Tls
from ldap3.utils.conv import escape_filter_chars
router = APIRouter()
@ -58,6 +60,7 @@ log.setLevel(SRC_LOG_LEVELS["MAIN"])
class SessionUserResponse(Token, UserResponse):
expires_at: Optional[int] = None
permissions: Optional[dict] = None
@router.get("/", response_model=SessionUserResponse)
@ -90,6 +93,10 @@ async def get_session_user(
secure=WEBUI_SESSION_COOKIE_SECURE,
)
user_permissions = get_permissions(
user.id, request.app.state.config.USER_PERMISSIONS
)
return {
"token": token,
"token_type": "Bearer",
@ -99,6 +106,7 @@ async def get_session_user(
"name": user.name,
"role": user.role,
"profile_image_url": user.profile_image_url,
"permissions": user_permissions,
}
@ -163,40 +171,67 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
LDAP_APP_PASSWORD = request.app.state.config.LDAP_APP_PASSWORD
LDAP_USE_TLS = request.app.state.config.LDAP_USE_TLS
LDAP_CA_CERT_FILE = request.app.state.config.LDAP_CA_CERT_FILE
LDAP_CIPHERS = request.app.state.config.LDAP_CIPHERS if request.app.state.config.LDAP_CIPHERS else 'ALL'
LDAP_CIPHERS = (
request.app.state.config.LDAP_CIPHERS
if request.app.state.config.LDAP_CIPHERS
else "ALL"
)
if not ENABLE_LDAP:
raise HTTPException(400, detail="LDAP authentication is not enabled")
try:
tls = Tls(validate=CERT_REQUIRED, version=PROTOCOL_TLS, ca_certs_file=LDAP_CA_CERT_FILE, ciphers=LDAP_CIPHERS)
tls = Tls(
validate=CERT_REQUIRED,
version=PROTOCOL_TLS,
ca_certs_file=LDAP_CA_CERT_FILE,
ciphers=LDAP_CIPHERS,
)
except Exception as e:
log.error(f"An error occurred on TLS: {str(e)}")
raise HTTPException(400, detail=str(e))
try:
server = Server(host=LDAP_SERVER_HOST, port=LDAP_SERVER_PORT, get_info=ALL, use_ssl=LDAP_USE_TLS, tls=tls)
connection_app = Connection(server, LDAP_APP_DN, LDAP_APP_PASSWORD, auto_bind='NONE', authentication='SIMPLE')
server = Server(
host=LDAP_SERVER_HOST,
port=LDAP_SERVER_PORT,
get_info=ALL,
use_ssl=LDAP_USE_TLS,
tls=tls,
)
connection_app = Connection(
server,
LDAP_APP_DN,
LDAP_APP_PASSWORD,
auto_bind="NONE",
authentication="SIMPLE",
)
if not connection_app.bind():
raise HTTPException(400, detail="Application account bind failed")
search_success = connection_app.search(
search_base=LDAP_SEARCH_BASE,
search_filter=f'(&({LDAP_ATTRIBUTE_FOR_USERNAME}={escape_filter_chars(form_data.user.lower())}){LDAP_SEARCH_FILTERS})',
attributes=[f'{LDAP_ATTRIBUTE_FOR_USERNAME}', 'mail', 'cn']
search_filter=f"(&({LDAP_ATTRIBUTE_FOR_USERNAME}={escape_filter_chars(form_data.user.lower())}){LDAP_SEARCH_FILTERS})",
attributes=[f"{LDAP_ATTRIBUTE_FOR_USERNAME}", "mail", "cn"],
)
if not search_success:
raise HTTPException(400, detail="User not found in the LDAP server")
entry = connection_app.entries[0]
username = str(entry[f'{LDAP_ATTRIBUTE_FOR_USERNAME}']).lower()
mail = str(entry['mail'])
cn = str(entry['cn'])
username = str(entry[f"{LDAP_ATTRIBUTE_FOR_USERNAME}"]).lower()
mail = str(entry["mail"])
cn = str(entry["cn"])
user_dn = entry.entry_dn
if username == form_data.user.lower():
connection_user = Connection(server, user_dn, form_data.password, auto_bind='NONE', authentication='SIMPLE')
connection_user = Connection(
server,
user_dn,
form_data.password,
auto_bind="NONE",
authentication="SIMPLE",
)
if not connection_user.bind():
raise HTTPException(400, f"Authentication failed for {form_data.user}")
@ -205,14 +240,12 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
try:
hashed = get_password_hash(form_data.password)
user = Auths.insert_new_auth(
mail,
hashed,
cn
)
user = Auths.insert_new_auth(mail, hashed, cn)
if not user:
raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
raise HTTPException(
500, detail=ERROR_MESSAGES.CREATE_USER_ERROR
)
except HTTPException:
raise
@ -224,7 +257,9 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
if user:
token = create_token(
data={"id": user.id},
expires_delta=parse_duration(request.app.state.config.JWT_EXPIRES_IN),
expires_delta=parse_duration(
request.app.state.config.JWT_EXPIRES_IN
),
)
# Set the cookie token
@ -246,7 +281,10 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
else:
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
else:
raise HTTPException(400, f"User {form_data.user} does not match the record. Search result: {str(entry[f'{LDAP_ATTRIBUTE_FOR_USERNAME}'])}")
raise HTTPException(
400,
f"User {form_data.user} does not match the record. Search result: {str(entry[f'{LDAP_ATTRIBUTE_FOR_USERNAME}'])}",
)
except Exception as e:
raise HTTPException(400, detail=str(e))
@ -325,6 +363,10 @@ async def signin(request: Request, response: Response, form_data: SigninForm):
secure=WEBUI_SESSION_COOKIE_SECURE,
)
user_permissions = get_permissions(
user.id, request.app.state.config.USER_PERMISSIONS
)
return {
"token": token,
"token_type": "Bearer",
@ -334,6 +376,7 @@ async def signin(request: Request, response: Response, form_data: SigninForm):
"name": user.name,
"role": user.role,
"profile_image_url": user.profile_image_url,
"permissions": user_permissions,
}
else:
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
@ -426,6 +469,10 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
},
)
user_permissions = get_permissions(
user.id, request.app.state.config.USER_PERMISSIONS
)
return {
"token": token,
"token_type": "Bearer",
@ -435,6 +482,7 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
"name": user.name,
"role": user.role,
"profile_image_url": user.profile_image_url,
"permissions": user_permissions,
}
else:
raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
@ -583,19 +631,18 @@ class LdapServerConfig(BaseModel):
label: str
host: str
port: Optional[int] = None
attribute_for_username: str = 'uid'
attribute_for_username: str = "uid"
app_dn: str
app_dn_password: str
search_base: str
search_filters: str = ''
search_filters: str = ""
use_tls: bool = True
certificate_path: Optional[str] = None
ciphers: Optional[str] = 'ALL'
ciphers: Optional[str] = "ALL"
@router.get("/admin/config/ldap/server", response_model=LdapServerConfig)
async def get_ldap_server(
request: Request, user=Depends(get_admin_user)
):
async def get_ldap_server(request: Request, user=Depends(get_admin_user)):
return {
"label": request.app.state.config.LDAP_SERVER_LABEL,
"host": request.app.state.config.LDAP_SERVER_HOST,
@ -607,26 +654,38 @@ async def get_ldap_server(
"search_filters": request.app.state.config.LDAP_SEARCH_FILTERS,
"use_tls": request.app.state.config.LDAP_USE_TLS,
"certificate_path": request.app.state.config.LDAP_CA_CERT_FILE,
"ciphers": request.app.state.config.LDAP_CIPHERS
"ciphers": request.app.state.config.LDAP_CIPHERS,
}
@router.post("/admin/config/ldap/server")
async def update_ldap_server(
request: Request, form_data: LdapServerConfig, user=Depends(get_admin_user)
):
required_fields = ['label', 'host', 'attribute_for_username', 'app_dn', 'app_dn_password', 'search_base']
required_fields = [
"label",
"host",
"attribute_for_username",
"app_dn",
"app_dn_password",
"search_base",
]
for key in required_fields:
value = getattr(form_data, key)
if not value:
raise HTTPException(400, detail=f"Required field {key} is empty")
if form_data.use_tls and not form_data.certificate_path:
raise HTTPException(400, detail="TLS is enabled but certificate file path is missing")
raise HTTPException(
400, detail="TLS is enabled but certificate file path is missing"
)
request.app.state.config.LDAP_SERVER_LABEL = form_data.label
request.app.state.config.LDAP_SERVER_HOST = form_data.host
request.app.state.config.LDAP_SERVER_PORT = form_data.port
request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME = form_data.attribute_for_username
request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME = (
form_data.attribute_for_username
)
request.app.state.config.LDAP_APP_DN = form_data.app_dn
request.app.state.config.LDAP_APP_PASSWORD = form_data.app_dn_password
request.app.state.config.LDAP_SEARCH_BASE = form_data.search_base
@ -646,18 +705,23 @@ async def update_ldap_server(
"search_filters": request.app.state.config.LDAP_SEARCH_FILTERS,
"use_tls": request.app.state.config.LDAP_USE_TLS,
"certificate_path": request.app.state.config.LDAP_CA_CERT_FILE,
"ciphers": request.app.state.config.LDAP_CIPHERS
"ciphers": request.app.state.config.LDAP_CIPHERS,
}
@router.get("/admin/config/ldap")
async def get_ldap_config(request: Request, user=Depends(get_admin_user)):
return {"ENABLE_LDAP": request.app.state.config.ENABLE_LDAP}
class LdapConfigForm(BaseModel):
enable_ldap: Optional[bool] = None
@router.post("/admin/config/ldap")
async def update_ldap_config(request: Request, form_data: LdapConfigForm, user=Depends(get_admin_user)):
async def update_ldap_config(
request: Request, form_data: LdapConfigForm, user=Depends(get_admin_user)
):
request.app.state.config.ENABLE_LDAP = form_data.enable_ldap
return {"ENABLE_LDAP": request.app.state.config.ENABLE_LDAP}

View file

@ -17,7 +17,10 @@ from open_webui.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
from open_webui.utils.utils import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_permission
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
@ -50,9 +53,10 @@ async def get_session_user_chat_list(
@router.delete("/", response_model=bool)
async def delete_all_user_chats(request: Request, user=Depends(get_verified_user)):
if user.role == "user" and not request.app.state.config.USER_PERMISSIONS.get(
"chat", {}
).get("deletion", {}):
if user.role == "user" and not has_permission(
user.id, "chat.delete", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
@ -385,8 +389,8 @@ async def delete_chat_by_id(request: Request, id: str, user=Depends(get_verified
return result
else:
if not request.app.state.config.USER_PERMISSIONS.get("chat", {}).get(
"deletion", {}
if not has_permission(
user.id, "chat.delete", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,

View file

@ -0,0 +1,120 @@
import os
from pathlib import Path
from typing import Optional
from open_webui.apps.webui.models.groups import (
Groups,
GroupForm,
GroupUpdateForm,
GroupResponse,
)
from open_webui.config import CACHE_DIR
from open_webui.constants import ERROR_MESSAGES
from fastapi import APIRouter, Depends, HTTPException, Request, status
from open_webui.utils.utils import get_admin_user, get_verified_user
router = APIRouter()
############################
# GetFunctions
############################
@router.get("/", response_model=list[GroupResponse])
async def get_groups(user=Depends(get_verified_user)):
if user.role == "admin":
return Groups.get_groups()
else:
return Groups.get_groups_by_member_id(user.id)
############################
# CreateNewGroup
############################
@router.post("/create", response_model=Optional[GroupResponse])
async def create_new_function(form_data: GroupForm, user=Depends(get_admin_user)):
try:
group = Groups.insert_new_group(user.id, form_data)
if group:
return group
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Error creating group"),
)
except Exception as e:
print(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(e),
)
############################
# GetGroupById
############################
@router.get("/id/{id}", response_model=Optional[GroupResponse])
async def get_group_by_id(id: str, user=Depends(get_admin_user)):
group = Groups.get_group_by_id(id)
if group:
return group
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.NOT_FOUND,
)
############################
# UpdateGroupById
############################
@router.post("/id/{id}/update", response_model=Optional[GroupResponse])
async def update_group_by_id(
id: str, form_data: GroupUpdateForm, user=Depends(get_admin_user)
):
try:
group = Groups.update_group_by_id(id, form_data)
if group:
return group
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Error updating group"),
)
except Exception as e:
print(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(e),
)
############################
# DeleteGroupById
############################
@router.delete("/id/{id}/delete", response_model=bool)
async def delete_group_by_id(id: str, user=Depends(get_admin_user)):
try:
result = Groups.delete_group_by_id(id)
if result:
return result
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Error deleting group"),
)
except Exception as e:
print(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(e),
)

View file

@ -6,7 +6,6 @@ import logging
from open_webui.apps.webui.models.knowledge import (
Knowledges,
KnowledgeUpdateForm,
KnowledgeForm,
KnowledgeResponse,
)
@ -17,6 +16,9 @@ from open_webui.apps.retrieval.main import process_file, ProcessFileForm
from open_webui.constants import ERROR_MESSAGES
from open_webui.utils.utils import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access
from open_webui.env import SRC_LOG_LEVELS
@ -26,64 +28,98 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"])
router = APIRouter()
############################
# GetKnowledgeItems
# getKnowledgeBases
############################
@router.get(
"/", response_model=Optional[Union[list[KnowledgeResponse], KnowledgeResponse]]
)
async def get_knowledge_items(
id: Optional[str] = None, user=Depends(get_verified_user)
):
if id:
knowledge = Knowledges.get_knowledge_by_id(id=id)
@router.get("/", response_model=list[KnowledgeResponse])
async def get_knowledge(user=Depends(get_verified_user)):
knowledge_bases = []
if knowledge:
return knowledge
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if user.role == "admin":
knowledge_bases = Knowledges.get_knowledge_bases()
else:
knowledge_bases = []
knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(user.id, "read")
for knowledge in Knowledges.get_knowledge_items():
files = []
if knowledge.data:
files = Files.get_file_metadatas_by_ids(
knowledge.data.get("file_ids", [])
)
# Check if all files exist
if len(files) != len(knowledge.data.get("file_ids", [])):
missing_files = list(
set(knowledge.data.get("file_ids", []))
- set([file.id for file in files])
)
if missing_files:
data = knowledge.data or {}
file_ids = data.get("file_ids", [])
for missing_file in missing_files:
file_ids.remove(missing_file)
data["file_ids"] = file_ids
Knowledges.update_knowledge_by_id(
id=knowledge.id, form_data=KnowledgeUpdateForm(data=data)
)
files = Files.get_file_metadatas_by_ids(file_ids)
knowledge_bases.append(
KnowledgeResponse(
**knowledge.model_dump(),
files=files,
)
# Get files for each knowledge base
for knowledge_base in knowledge_bases:
files = []
if knowledge_base.data:
files = Files.get_file_metadatas_by_ids(
knowledge_base.data.get("file_ids", [])
)
return knowledge_bases
# Check if all files exist
if len(files) != len(knowledge_base.data.get("file_ids", [])):
missing_files = list(
set(knowledge_base.data.get("file_ids", []))
- set([file.id for file in files])
)
if missing_files:
data = knowledge_base.data or {}
file_ids = data.get("file_ids", [])
for missing_file in missing_files:
file_ids.remove(missing_file)
data["file_ids"] = file_ids
Knowledges.update_knowledge_data_by_id(
id=knowledge_base.id, data=data
)
files = Files.get_file_metadatas_by_ids(file_ids)
knowledge_base = KnowledgeResponse(
**knowledge_base.model_dump(),
files=files,
)
return knowledge_bases
@router.get("/list", response_model=list[KnowledgeResponse])
async def get_knowledge_list(user=Depends(get_verified_user)):
knowledge_bases = []
if user.role == "admin":
knowledge_bases = Knowledges.get_knowledge_bases()
else:
knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(user.id, "write")
# Get files for each knowledge base
for knowledge_base in knowledge_bases:
files = []
if knowledge_base.data:
files = Files.get_file_metadatas_by_ids(
knowledge_base.data.get("file_ids", [])
)
# Check if all files exist
if len(files) != len(knowledge_base.data.get("file_ids", [])):
missing_files = list(
set(knowledge_base.data.get("file_ids", []))
- set([file.id for file in files])
)
if missing_files:
data = knowledge_base.data or {}
file_ids = data.get("file_ids", [])
for missing_file in missing_files:
file_ids.remove(missing_file)
data["file_ids"] = file_ids
Knowledges.update_knowledge_data_by_id(
id=knowledge_base.id, data=data
)
files = Files.get_file_metadatas_by_ids(file_ids)
knowledge_base = KnowledgeResponse(
**knowledge_base.model_dump(),
files=files,
)
return knowledge_bases
############################
@ -92,7 +128,9 @@ async def get_knowledge_items(
@router.post("/create", response_model=Optional[KnowledgeResponse])
async def create_new_knowledge(form_data: KnowledgeForm, user=Depends(get_admin_user)):
async def create_new_knowledge(
form_data: KnowledgeForm, user=Depends(get_verified_user)
):
knowledge = Knowledges.insert_new_knowledge(user.id, form_data)
if knowledge:
@ -118,13 +156,20 @@ async def get_knowledge_by_id(id: str, user=Depends(get_verified_user)):
knowledge = Knowledges.get_knowledge_by_id(id=id)
if knowledge:
file_ids = knowledge.data.get("file_ids", []) if knowledge.data else []
files = Files.get_files_by_ids(file_ids)
return KnowledgeFilesResponse(
**knowledge.model_dump(),
files=files,
)
if (
user.role == "admin"
or knowledge.user_id == user.id
or has_access(user.id, "read", knowledge.access_control)
):
file_ids = knowledge.data.get("file_ids", []) if knowledge.data else []
files = Files.get_files_by_ids(file_ids)
return KnowledgeFilesResponse(
**knowledge.model_dump(),
files=files,
)
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@ -140,11 +185,23 @@ async def get_knowledge_by_id(id: str, user=Depends(get_verified_user)):
@router.post("/{id}/update", response_model=Optional[KnowledgeFilesResponse])
async def update_knowledge_by_id(
id: str,
form_data: KnowledgeUpdateForm,
user=Depends(get_admin_user),
form_data: KnowledgeForm,
user=Depends(get_verified_user),
):
knowledge = Knowledges.update_knowledge_by_id(id=id, form_data=form_data)
knowledge = Knowledges.get_knowledge_by_id(id=id)
if not knowledge:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if knowledge.user_id != user.id and user.role != "admin":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
knowledge = Knowledges.update_knowledge_by_id(id=id, form_data=form_data)
if knowledge:
file_ids = knowledge.data.get("file_ids", []) if knowledge.data else []
files = Files.get_files_by_ids(file_ids)
@ -173,9 +230,22 @@ class KnowledgeFileIdForm(BaseModel):
def add_file_to_knowledge_by_id(
id: str,
form_data: KnowledgeFileIdForm,
user=Depends(get_admin_user),
user=Depends(get_verified_user),
):
knowledge = Knowledges.get_knowledge_by_id(id=id)
if not knowledge:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if knowledge.user_id != user.id and user.role != "admin":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
file = Files.get_file_by_id(form_data.file_id)
if not file:
raise HTTPException(
@ -206,9 +276,7 @@ def add_file_to_knowledge_by_id(
file_ids.append(form_data.file_id)
data["file_ids"] = file_ids
knowledge = Knowledges.update_knowledge_by_id(
id=id, form_data=KnowledgeUpdateForm(data=data)
)
knowledge = Knowledges.update_knowledge_data_by_id(id=id.id, data=data)
if knowledge:
files = Files.get_files_by_ids(file_ids)
@ -238,9 +306,21 @@ def add_file_to_knowledge_by_id(
def update_file_from_knowledge_by_id(
id: str,
form_data: KnowledgeFileIdForm,
user=Depends(get_admin_user),
user=Depends(get_verified_user),
):
knowledge = Knowledges.get_knowledge_by_id(id=id)
if not knowledge:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if knowledge.user_id != user.id and user.role != "admin":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
file = Files.get_file_by_id(form_data.file_id)
if not file:
raise HTTPException(
@ -288,9 +368,21 @@ def update_file_from_knowledge_by_id(
def remove_file_from_knowledge_by_id(
id: str,
form_data: KnowledgeFileIdForm,
user=Depends(get_admin_user),
user=Depends(get_verified_user),
):
knowledge = Knowledges.get_knowledge_by_id(id=id)
if not knowledge:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if knowledge.user_id != user.id and user.role != "admin":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
file = Files.get_file_by_id(form_data.file_id)
if not file:
raise HTTPException(
@ -318,9 +410,7 @@ def remove_file_from_knowledge_by_id(
file_ids.remove(form_data.file_id)
data["file_ids"] = file_ids
knowledge = Knowledges.update_knowledge_by_id(
id=id, form_data=KnowledgeUpdateForm(data=data)
)
knowledge = Knowledges.update_knowledge_data_by_id(id=id.id, data=data)
if knowledge:
files = Files.get_files_by_ids(file_ids)
@ -346,32 +436,26 @@ def remove_file_from_knowledge_by_id(
)
############################
# ResetKnowledgeById
############################
@router.post("/{id}/reset", response_model=Optional[KnowledgeResponse])
async def reset_knowledge_by_id(id: str, user=Depends(get_admin_user)):
try:
VECTOR_DB_CLIENT.delete_collection(collection_name=id)
except Exception as e:
log.debug(e)
pass
knowledge = Knowledges.update_knowledge_by_id(
id=id, form_data=KnowledgeUpdateForm(data={"file_ids": []})
)
return knowledge
############################
# DeleteKnowledgeById
############################
@router.delete("/{id}/delete", response_model=bool)
async def delete_knowledge_by_id(id: str, user=Depends(get_admin_user)):
async def delete_knowledge_by_id(id: str, user=Depends(get_verified_user)):
knowledge = Knowledges.get_knowledge_by_id(id=id)
if not knowledge:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if knowledge.user_id != user.id and user.role != "admin":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
try:
VECTOR_DB_CLIENT.delete_collection(collection_name=id)
except Exception as e:
@ -379,3 +463,34 @@ async def delete_knowledge_by_id(id: str, user=Depends(get_admin_user)):
pass
result = Knowledges.delete_knowledge_by_id(id=id)
return result
############################
# ResetKnowledgeById
############################
@router.post("/{id}/reset", response_model=Optional[KnowledgeResponse])
async def reset_knowledge_by_id(id: str, user=Depends(get_verified_user)):
knowledge = Knowledges.get_knowledge_by_id(id=id)
if not knowledge:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if knowledge.user_id != user.id and user.role != "admin":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
try:
VECTOR_DB_CLIENT.delete_collection(collection_name=id)
except Exception as e:
log.debug(e)
pass
knowledge = Knowledges.update_knowledge_data_by_id(id=id.id, data={"file_ids": []})
return knowledge

View file

@ -8,49 +8,58 @@ from open_webui.apps.webui.models.models import (
)
from open_webui.constants import ERROR_MESSAGES
from fastapi import APIRouter, Depends, HTTPException, Request, status
from open_webui.utils.utils import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access
router = APIRouter()
###########################
# getModels
# GetModels
###########################
@router.get("/", response_model=list[ModelResponse])
async def get_models(id: Optional[str] = None, user=Depends(get_verified_user)):
if id:
model = Models.get_model_by_id(id)
if model:
return [model]
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if user.role == "admin":
return Models.get_models()
else:
return Models.get_all_models()
return Models.get_models_by_user_id(user.id)
###########################
# GetBaseModels
###########################
@router.get("/base", response_model=list[ModelResponse])
async def get_base_models(user=Depends(get_admin_user)):
return Models.get_base_models()
############################
# AddNewModel
# CreateNewModel
############################
@router.post("/add", response_model=Optional[ModelModel])
async def add_new_model(
request: Request,
@router.post("/create", response_model=Optional[ModelModel])
async def create_new_model(
form_data: ModelForm,
user=Depends(get_admin_user),
user=Depends(get_verified_user),
):
if form_data.id in request.app.state.MODELS:
model = Models.get_model_by_id(form_data.id)
if model:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.MODEL_ID_TAKEN,
)
else:
model = Models.insert_new_model(form_data, user.id)
if model:
return model
else:
@ -60,37 +69,84 @@ async def add_new_model(
)
###########################
# GetModelById
###########################
@router.get("/id/{id}", response_model=Optional[ModelResponse])
async def get_model_by_id(id: str, user=Depends(get_verified_user)):
model = Models.get_model_by_id(id)
if model:
if (
user.role == "admin"
or model.user_id == user.id
or has_access(user.id, "read", model.access_control)
):
return model
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.NOT_FOUND,
)
############################
# ToggelModelById
############################
@router.post("/id/{id}/toggle", response_model=Optional[ModelResponse])
async def toggle_model_by_id(id: str, user=Depends(get_verified_user)):
model = Models.get_model_by_id(id)
if model:
if (
user.role == "admin"
or model.user_id == user.id
or has_access(user.id, "write", model.access_control)
):
model = Models.toggle_model_by_id(id)
if model:
return model
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Error updating function"),
)
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.NOT_FOUND,
)
############################
# UpdateModelById
############################
@router.post("/update", response_model=Optional[ModelModel])
@router.post("/id/{id}/update", response_model=Optional[ModelModel])
async def update_model_by_id(
request: Request,
id: str,
form_data: ModelForm,
user=Depends(get_admin_user),
user=Depends(get_verified_user),
):
model = Models.get_model_by_id(id)
if model:
model = Models.update_model_by_id(id, form_data)
return model
else:
if form_data.id in request.app.state.MODELS:
model = Models.insert_new_model(form_data, user.id)
if model:
return model
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.DEFAULT(),
)
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.DEFAULT(),
)
if not model:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.NOT_FOUND,
)
model = Models.update_model_by_id(id, form_data)
return model
############################
@ -98,7 +154,20 @@ async def update_model_by_id(
############################
@router.delete("/delete", response_model=bool)
async def delete_model_by_id(id: str, user=Depends(get_admin_user)):
@router.delete("/id/{id}/delete", response_model=bool)
async def delete_model_by_id(id: str, user=Depends(get_verified_user)):
model = Models.get_model_by_id(id)
if not model:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if model.user_id != user.id and user.role != "admin":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
result = Models.delete_model_by_id(id)
return result

View file

@ -4,6 +4,7 @@ from open_webui.apps.webui.models.prompts import PromptForm, PromptModel, Prompt
from open_webui.constants import ERROR_MESSAGES
from fastapi import APIRouter, Depends, HTTPException, status
from open_webui.utils.utils import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access
router = APIRouter()
@ -14,7 +15,22 @@ router = APIRouter()
@router.get("/", response_model=list[PromptModel])
async def get_prompts(user=Depends(get_verified_user)):
return Prompts.get_prompts()
if user.role == "admin":
prompts = Prompts.get_prompts()
else:
prompts = Prompts.get_prompts_by_user_id(user.id, "read")
return prompts
@router.get("/list", response_model=list[PromptModel])
async def get_prompt_list(user=Depends(get_verified_user)):
if user.role == "admin":
prompts = Prompts.get_prompts()
else:
prompts = Prompts.get_prompts_by_user_id(user.id, "write")
return prompts
############################
@ -23,7 +39,7 @@ async def get_prompts(user=Depends(get_verified_user)):
@router.post("/create", response_model=Optional[PromptModel])
async def create_new_prompt(form_data: PromptForm, user=Depends(get_admin_user)):
async def create_new_prompt(form_data: PromptForm, user=Depends(get_verified_user)):
prompt = Prompts.get_prompt_by_command(form_data.command)
if prompt is None:
prompt = Prompts.insert_new_prompt(user.id, form_data)
@ -50,7 +66,12 @@ async def get_prompt_by_command(command: str, user=Depends(get_verified_user)):
prompt = Prompts.get_prompt_by_command(f"/{command}")
if prompt:
return prompt
if (
user.role == "admin"
or prompt.user_id == user.id
or has_access(user.id, "read", prompt.access_control)
):
return prompt
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@ -67,8 +88,21 @@ async def get_prompt_by_command(command: str, user=Depends(get_verified_user)):
async def update_prompt_by_command(
command: str,
form_data: PromptForm,
user=Depends(get_admin_user),
user=Depends(get_verified_user),
):
prompt = Prompts.get_prompt_by_command(f"/{command}")
if not prompt:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if prompt.user_id != user.id and user.role != "admin":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
prompt = Prompts.update_prompt_by_command(f"/{command}", form_data)
if prompt:
return prompt
@ -85,6 +119,19 @@ async def update_prompt_by_command(
@router.delete("/command/{command}/delete", response_model=bool)
async def delete_prompt_by_command(command: str, user=Depends(get_admin_user)):
async def delete_prompt_by_command(command: str, user=Depends(get_verified_user)):
prompt = Prompts.get_prompt_by_command(f"/{command}")
if not prompt:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if prompt.user_id != user.id and user.role != "admin":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
result = Prompts.delete_prompt_by_command(f"/{command}")
return result

View file

@ -3,48 +3,66 @@ from pathlib import Path
from typing import Optional
from open_webui.apps.webui.models.tools import ToolForm, ToolModel, ToolResponse, Tools
from open_webui.apps.webui.utils import load_toolkit_module_by_id, replace_imports
from open_webui.apps.webui.utils import load_tools_module_by_id, replace_imports
from open_webui.config import CACHE_DIR, DATA_DIR
from open_webui.constants import ERROR_MESSAGES
from fastapi import APIRouter, Depends, HTTPException, Request, status
from open_webui.utils.tools import get_tools_specs
from open_webui.utils.utils import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access
router = APIRouter()
############################
# GetToolkits
# GetTools
############################
@router.get("/", response_model=list[ToolResponse])
async def get_toolkits(user=Depends(get_verified_user)):
toolkits = [toolkit for toolkit in Tools.get_tools()]
return toolkits
async def get_tools(user=Depends(get_verified_user)):
if user.role == "admin":
tools = Tools.get_tools()
else:
tools = Tools.get_tools_by_user_id(user.id, "read")
return tools
############################
# ExportToolKits
# GetToolList
############################
@router.get("/list", response_model=list[ToolResponse])
async def get_tool_list(user=Depends(get_verified_user)):
if user.role == "admin":
tools = Tools.get_tools()
else:
tools = Tools.get_tools_by_user_id(user.id, "write")
return tools
############################
# ExportTools
############################
@router.get("/export", response_model=list[ToolModel])
async def get_toolkits(user=Depends(get_admin_user)):
toolkits = [toolkit for toolkit in Tools.get_tools()]
return toolkits
async def export_tools(user=Depends(get_admin_user)):
tools = Tools.get_tools()
return tools
############################
# CreateNewToolKit
# CreateNewTools
############################
@router.post("/create", response_model=Optional[ToolResponse])
async def create_new_toolkit(
async def create_new_tools(
request: Request,
form_data: ToolForm,
user=Depends(get_admin_user),
user=Depends(get_verified_user),
):
if not form_data.id.isidentifier():
raise HTTPException(
@ -54,30 +72,30 @@ async def create_new_toolkit(
form_data.id = form_data.id.lower()
toolkit = Tools.get_tool_by_id(form_data.id)
if toolkit is None:
tools = Tools.get_tool_by_id(form_data.id)
if tools is None:
try:
form_data.content = replace_imports(form_data.content)
toolkit_module, frontmatter = load_toolkit_module_by_id(
tools_module, frontmatter = load_tools_module_by_id(
form_data.id, content=form_data.content
)
form_data.meta.manifest = frontmatter
TOOLS = request.app.state.TOOLS
TOOLS[form_data.id] = toolkit_module
TOOLS[form_data.id] = tools_module
specs = get_tools_specs(TOOLS[form_data.id])
toolkit = Tools.insert_new_tool(user.id, form_data, specs)
tools = Tools.insert_new_tool(user.id, form_data, specs)
tool_cache_dir = Path(CACHE_DIR) / "tools" / form_data.id
tool_cache_dir.mkdir(parents=True, exist_ok=True)
if toolkit:
return toolkit
if tools:
return tools
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Error creating toolkit"),
detail=ERROR_MESSAGES.DEFAULT("Error creating tools"),
)
except Exception as e:
print(e)
@ -93,16 +111,21 @@ async def create_new_toolkit(
############################
# GetToolkitById
# GetToolsById
############################
@router.get("/id/{id}", response_model=Optional[ToolModel])
async def get_toolkit_by_id(id: str, user=Depends(get_admin_user)):
toolkit = Tools.get_tool_by_id(id)
async def get_tools_by_id(id: str, user=Depends(get_verified_user)):
tools = Tools.get_tool_by_id(id)
if toolkit:
return toolkit
if tools:
if (
user.role == "admin"
or tools.user_id == user.id
or has_access(user.id, "read", tools.access_control)
):
return tools
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@ -111,26 +134,39 @@ async def get_toolkit_by_id(id: str, user=Depends(get_admin_user)):
############################
# UpdateToolkitById
# UpdateToolsById
############################
@router.post("/id/{id}/update", response_model=Optional[ToolModel])
async def update_toolkit_by_id(
async def update_tools_by_id(
request: Request,
id: str,
form_data: ToolForm,
user=Depends(get_admin_user),
user=Depends(get_verified_user),
):
tools = Tools.get_tool_by_id(id)
if not tools:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if tools.user_id != user.id and user.role != "admin":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
try:
form_data.content = replace_imports(form_data.content)
toolkit_module, frontmatter = load_toolkit_module_by_id(
tools_module, frontmatter = load_tools_module_by_id(
id, content=form_data.content
)
form_data.meta.manifest = frontmatter
TOOLS = request.app.state.TOOLS
TOOLS[id] = toolkit_module
TOOLS[id] = tools_module
specs = get_tools_specs(TOOLS[id])
@ -140,14 +176,14 @@ async def update_toolkit_by_id(
}
print(updated)
toolkit = Tools.update_tool_by_id(id, updated)
tools = Tools.update_tool_by_id(id, updated)
if toolkit:
return toolkit
if tools:
return tools
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Error updating toolkit"),
detail=ERROR_MESSAGES.DEFAULT("Error updating tools"),
)
except Exception as e:
@ -158,14 +194,28 @@ async def update_toolkit_by_id(
############################
# DeleteToolkitById
# DeleteToolsById
############################
@router.delete("/id/{id}/delete", response_model=bool)
async def delete_toolkit_by_id(request: Request, id: str, user=Depends(get_admin_user)):
result = Tools.delete_tool_by_id(id)
async def delete_tools_by_id(
request: Request, id: str, user=Depends(get_verified_user)
):
tools = Tools.get_tool_by_id(id)
if not tools:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if tools.user_id != user.id and user.role != "admin":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
result = Tools.delete_tool_by_id(id)
if result:
TOOLS = request.app.state.TOOLS
if id in TOOLS:
@ -180,9 +230,9 @@ async def delete_toolkit_by_id(request: Request, id: str, user=Depends(get_admin
@router.get("/id/{id}/valves", response_model=Optional[dict])
async def get_toolkit_valves_by_id(id: str, user=Depends(get_admin_user)):
toolkit = Tools.get_tool_by_id(id)
if toolkit:
async def get_tools_valves_by_id(id: str, user=Depends(get_verified_user)):
tools = Tools.get_tool_by_id(id)
if tools:
try:
valves = Tools.get_tool_valves_by_id(id)
return valves
@ -204,19 +254,19 @@ async def get_toolkit_valves_by_id(id: str, user=Depends(get_admin_user)):
@router.get("/id/{id}/valves/spec", response_model=Optional[dict])
async def get_toolkit_valves_spec_by_id(
request: Request, id: str, user=Depends(get_admin_user)
async def get_tools_valves_spec_by_id(
request: Request, id: str, user=Depends(get_verified_user)
):
toolkit = Tools.get_tool_by_id(id)
if toolkit:
tools = Tools.get_tool_by_id(id)
if tools:
if id in request.app.state.TOOLS:
toolkit_module = request.app.state.TOOLS[id]
tools_module = request.app.state.TOOLS[id]
else:
toolkit_module, _ = load_toolkit_module_by_id(id)
request.app.state.TOOLS[id] = toolkit_module
tools_module, _ = load_tools_module_by_id(id)
request.app.state.TOOLS[id] = tools_module
if hasattr(toolkit_module, "Valves"):
Valves = toolkit_module.Valves
if hasattr(tools_module, "Valves"):
Valves = tools_module.Valves
return Valves.schema()
return None
else:
@ -232,19 +282,19 @@ async def get_toolkit_valves_spec_by_id(
@router.post("/id/{id}/valves/update", response_model=Optional[dict])
async def update_toolkit_valves_by_id(
request: Request, id: str, form_data: dict, user=Depends(get_admin_user)
async def update_tools_valves_by_id(
request: Request, id: str, form_data: dict, user=Depends(get_verified_user)
):
toolkit = Tools.get_tool_by_id(id)
if toolkit:
tools = Tools.get_tool_by_id(id)
if tools:
if id in request.app.state.TOOLS:
toolkit_module = request.app.state.TOOLS[id]
tools_module = request.app.state.TOOLS[id]
else:
toolkit_module, _ = load_toolkit_module_by_id(id)
request.app.state.TOOLS[id] = toolkit_module
tools_module, _ = load_tools_module_by_id(id)
request.app.state.TOOLS[id] = tools_module
if hasattr(toolkit_module, "Valves"):
Valves = toolkit_module.Valves
if hasattr(tools_module, "Valves"):
Valves = tools_module.Valves
try:
form_data = {k: v for k, v in form_data.items() if v is not None}
@ -276,9 +326,9 @@ async def update_toolkit_valves_by_id(
@router.get("/id/{id}/valves/user", response_model=Optional[dict])
async def get_toolkit_user_valves_by_id(id: str, user=Depends(get_verified_user)):
toolkit = Tools.get_tool_by_id(id)
if toolkit:
async def get_tools_user_valves_by_id(id: str, user=Depends(get_verified_user)):
tools = Tools.get_tool_by_id(id)
if tools:
try:
user_valves = Tools.get_user_valves_by_id_and_user_id(id, user.id)
return user_valves
@ -295,19 +345,19 @@ async def get_toolkit_user_valves_by_id(id: str, user=Depends(get_verified_user)
@router.get("/id/{id}/valves/user/spec", response_model=Optional[dict])
async def get_toolkit_user_valves_spec_by_id(
async def get_tools_user_valves_spec_by_id(
request: Request, id: str, user=Depends(get_verified_user)
):
toolkit = Tools.get_tool_by_id(id)
if toolkit:
tools = Tools.get_tool_by_id(id)
if tools:
if id in request.app.state.TOOLS:
toolkit_module = request.app.state.TOOLS[id]
tools_module = request.app.state.TOOLS[id]
else:
toolkit_module, _ = load_toolkit_module_by_id(id)
request.app.state.TOOLS[id] = toolkit_module
tools_module, _ = load_tools_module_by_id(id)
request.app.state.TOOLS[id] = tools_module
if hasattr(toolkit_module, "UserValves"):
UserValves = toolkit_module.UserValves
if hasattr(tools_module, "UserValves"):
UserValves = tools_module.UserValves
return UserValves.schema()
return None
else:
@ -318,20 +368,20 @@ async def get_toolkit_user_valves_spec_by_id(
@router.post("/id/{id}/valves/user/update", response_model=Optional[dict])
async def update_toolkit_user_valves_by_id(
async def update_tools_user_valves_by_id(
request: Request, id: str, form_data: dict, user=Depends(get_verified_user)
):
toolkit = Tools.get_tool_by_id(id)
tools = Tools.get_tool_by_id(id)
if toolkit:
if tools:
if id in request.app.state.TOOLS:
toolkit_module = request.app.state.TOOLS[id]
tools_module = request.app.state.TOOLS[id]
else:
toolkit_module, _ = load_toolkit_module_by_id(id)
request.app.state.TOOLS[id] = toolkit_module
tools_module, _ = load_tools_module_by_id(id)
request.app.state.TOOLS[id] = tools_module
if hasattr(toolkit_module, "UserValves"):
UserValves = toolkit_module.UserValves
if hasattr(tools_module, "UserValves"):
UserValves = tools_module.UserValves
try:
form_data = {k: v for k, v in form_data.items() if v is not None}

View file

@ -31,21 +31,58 @@ async def get_users(skip: int = 0, limit: int = 50, user=Depends(get_admin_user)
return Users.get_users(skip, limit)
############################
# User Groups
############################
@router.get("/groups")
async def get_user_groups(user=Depends(get_verified_user)):
return Users.get_user_groups(user.id)
############################
# User Permissions
############################
@router.get("/permissions/user")
@router.get("/permissions")
async def get_user_permissisions(user=Depends(get_verified_user)):
return Users.get_user_groups(user.id)
############################
# User Default Permissions
############################
class WorkspacePermissions(BaseModel):
models: bool
knowledge: bool
prompts: bool
tools: bool
class ChatPermissions(BaseModel):
file_upload: bool
delete: bool
edit: bool
temporary: bool
class UserPermissions(BaseModel):
workspace: WorkspacePermissions
chat: ChatPermissions
@router.get("/default/permissions")
async def get_user_permissions(request: Request, user=Depends(get_admin_user)):
return request.app.state.config.USER_PERMISSIONS
@router.post("/permissions/user")
@router.post("/default/permissions")
async def update_user_permissions(
request: Request, form_data: dict, user=Depends(get_admin_user)
request: Request, form_data: UserPermissions, user=Depends(get_admin_user)
):
request.app.state.config.USER_PERMISSIONS = form_data
request.app.state.config.USER_PERMISSIONS = form_data.model_dump()
return request.app.state.config.USER_PERMISSIONS

View file

@ -63,7 +63,7 @@ def replace_imports(content):
return content
def load_toolkit_module_by_id(toolkit_id, content=None):
def load_tools_module_by_id(toolkit_id, content=None):
if content is None:
tool = Tools.get_tool_by_id(toolkit_id)

View file

@ -739,12 +739,36 @@ DEFAULT_USER_ROLE = PersistentConfig(
os.getenv("DEFAULT_USER_ROLE", "pending"),
)
USER_PERMISSIONS_CHAT_DELETION = (
os.environ.get("USER_PERMISSIONS_CHAT_DELETION", "True").lower() == "true"
USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS = (
os.environ.get("USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS", "False").lower()
== "true"
)
USER_PERMISSIONS_CHAT_EDITING = (
os.environ.get("USER_PERMISSIONS_CHAT_EDITING", "True").lower() == "true"
USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ACCESS = (
os.environ.get("USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ACCESS", "False").lower()
== "true"
)
USER_PERMISSIONS_WORKSPACE_PROMPTS_ACCESS = (
os.environ.get("USER_PERMISSIONS_WORKSPACE_PROMPTS_ACCESS", "False").lower()
== "true"
)
USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS = (
os.environ.get("USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS", "False").lower() == "true"
)
USER_PERMISSIONS_CHAT_FILE_UPLOAD = (
os.environ.get("USER_PERMISSIONS_CHAT_FILE_UPLOAD", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_DELETE = (
os.environ.get("USER_PERMISSIONS_CHAT_DELETE", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_EDIT = (
os.environ.get("USER_PERMISSIONS_CHAT_EDIT", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_TEMPORARY = (
@ -753,13 +777,20 @@ USER_PERMISSIONS_CHAT_TEMPORARY = (
USER_PERMISSIONS = PersistentConfig(
"USER_PERMISSIONS",
"ui.user_permissions",
"user.permissions",
{
"workspace": {
"models": USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS,
"knowledge": USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ACCESS,
"prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ACCESS,
"tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS,
},
"chat": {
"deletion": USER_PERMISSIONS_CHAT_DELETION,
"editing": USER_PERMISSIONS_CHAT_EDITING,
"file_upload": USER_PERMISSIONS_CHAT_FILE_UPLOAD,
"delete": USER_PERMISSIONS_CHAT_DELETE,
"edit": USER_PERMISSIONS_CHAT_EDIT,
"temporary": USER_PERMISSIONS_CHAT_TEMPORARY,
}
},
},
)
@ -785,18 +816,6 @@ DEFAULT_ARENA_MODEL = {
},
}
ENABLE_MODEL_FILTER = PersistentConfig(
"ENABLE_MODEL_FILTER",
"model_filter.enable",
os.environ.get("ENABLE_MODEL_FILTER", "False").lower() == "true",
)
MODEL_FILTER_LIST = os.environ.get("MODEL_FILTER_LIST", "")
MODEL_FILTER_LIST = PersistentConfig(
"MODEL_FILTER_LIST",
"model_filter.list",
[model.strip() for model in MODEL_FILTER_LIST.split(";")],
)
WEBHOOK_URL = PersistentConfig(
"WEBHOOK_URL", "webhook_url", os.environ.get("WEBHOOK_URL", "")
)

View file

@ -11,6 +11,7 @@ import random
from contextlib import asynccontextmanager
from typing import Optional
from aiocache import cached
import aiohttp
import requests
from fastapi import (
@ -45,6 +46,7 @@ from open_webui.apps.openai.main import (
app as openai_app,
generate_chat_completion as generate_openai_chat_completion,
get_all_models as get_openai_models,
get_all_models_responses as get_openai_models_responses,
)
from open_webui.apps.retrieval.main import app as retrieval_app
from open_webui.apps.retrieval.utils import get_rag_context, rag_template
@ -70,13 +72,11 @@ from open_webui.config import (
DEFAULT_LOCALE,
ENABLE_ADMIN_CHAT_ACCESS,
ENABLE_ADMIN_EXPORT,
ENABLE_MODEL_FILTER,
ENABLE_OLLAMA_API,
ENABLE_OPENAI_API,
ENABLE_TAGS_GENERATION,
ENV,
FRONTEND_BUILD_DIR,
MODEL_FILTER_LIST,
OAUTH_PROVIDERS,
ENABLE_SEARCH_QUERY,
SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE,
@ -135,6 +135,7 @@ from open_webui.utils.utils import (
get_http_authorization_cred,
get_verified_user,
)
from open_webui.utils.access_control import has_access
if SAFE_MODE:
print("SAFE MODE ENABLED")
@ -183,7 +184,10 @@ async def lifespan(app: FastAPI):
app = FastAPI(
docs_url="/docs" if ENV == "dev" else None, openapi_url="/openapi.json" if ENV == "dev" else None, redoc_url=None, lifespan=lifespan
docs_url="/docs" if ENV == "dev" else None,
openapi_url="/openapi.json" if ENV == "dev" else None,
redoc_url=None,
lifespan=lifespan,
)
app.state.config = AppConfig()
@ -191,27 +195,26 @@ app.state.config = AppConfig()
app.state.config.ENABLE_OPENAI_API = ENABLE_OPENAI_API
app.state.config.ENABLE_OLLAMA_API = ENABLE_OLLAMA_API
app.state.config.ENABLE_MODEL_FILTER = ENABLE_MODEL_FILTER
app.state.config.MODEL_FILTER_LIST = MODEL_FILTER_LIST
app.state.config.WEBHOOK_URL = WEBHOOK_URL
app.state.config.TASK_MODEL = TASK_MODEL
app.state.config.TASK_MODEL_EXTERNAL = TASK_MODEL_EXTERNAL
app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE = TITLE_GENERATION_PROMPT_TEMPLATE
app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE = TAGS_GENERATION_PROMPT_TEMPLATE
app.state.config.ENABLE_TAGS_GENERATION = ENABLE_TAGS_GENERATION
app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE = TAGS_GENERATION_PROMPT_TEMPLATE
app.state.config.ENABLE_SEARCH_QUERY = ENABLE_SEARCH_QUERY
app.state.config.SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE = (
SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE
)
app.state.config.ENABLE_SEARCH_QUERY = ENABLE_SEARCH_QUERY
app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = (
TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE
)
app.state.MODELS = {}
##################################
#
# ChatCompletion Middleware
@ -219,26 +222,6 @@ app.state.MODELS = {}
##################################
def get_task_model_id(default_model_id):
# Set the task model
task_model_id = default_model_id
# Check if the user has a custom task model and use that model
if app.state.MODELS[task_model_id]["owned_by"] == "ollama":
if (
app.state.config.TASK_MODEL
and app.state.config.TASK_MODEL in app.state.MODELS
):
task_model_id = app.state.config.TASK_MODEL
else:
if (
app.state.config.TASK_MODEL_EXTERNAL
and app.state.config.TASK_MODEL_EXTERNAL in app.state.MODELS
):
task_model_id = app.state.config.TASK_MODEL_EXTERNAL
return task_model_id
def get_filter_function_ids(model):
def get_priority(function_id):
function = Functions.get_function_by_id(function_id)
@ -368,8 +351,24 @@ async def get_content_from_response(response) -> Optional[str]:
return content
def get_task_model_id(
default_model_id: str, task_model: str, task_model_external: str, models
) -> str:
# Set the task model
task_model_id = default_model_id
# Check if the user has a custom task model and use that model
if models[task_model_id]["owned_by"] == "ollama":
if task_model and task_model in models:
task_model_id = task_model
else:
if task_model_external and task_model_external in models:
task_model_id = task_model_external
return task_model_id
async def chat_completion_tools_handler(
body: dict, user: UserModel, extra_params: dict
body: dict, user: UserModel, models, extra_params: dict
) -> tuple[dict, dict]:
# If tool_ids field is present, call the functions
metadata = body.get("metadata", {})
@ -383,14 +382,19 @@ async def chat_completion_tools_handler(
contexts = []
citations = []
task_model_id = get_task_model_id(body["model"])
task_model_id = get_task_model_id(
body["model"],
app.state.config.TASK_MODEL,
app.state.config.TASK_MODEL_EXTERNAL,
models,
)
tools = get_tools(
webui_app,
tool_ids,
user,
{
**extra_params,
"__model__": app.state.MODELS[task_model_id],
"__model__": models[task_model_id],
"__messages__": body["messages"],
"__files__": metadata.get("files", []),
},
@ -414,7 +418,7 @@ async def chat_completion_tools_handler(
)
try:
payload = filter_pipeline(payload, user)
payload = filter_pipeline(payload, user, models)
except Exception as e:
raise e
@ -515,16 +519,16 @@ def is_chat_completion_request(request):
)
async def get_body_and_model_and_user(request):
async def get_body_and_model_and_user(request, models):
# Read the original request body
body = await request.body()
body_str = body.decode("utf-8")
body = json.loads(body_str) if body_str else {}
model_id = body["model"]
if model_id not in app.state.MODELS:
if model_id not in models:
raise Exception("Model not found")
model = app.state.MODELS[model_id]
model = models[model_id]
user = get_current_user(
request,
@ -540,14 +544,27 @@ class ChatCompletionMiddleware(BaseHTTPMiddleware):
return await call_next(request)
log.debug(f"request.url.path: {request.url.path}")
model_list = await get_all_models()
models = {model["id"]: model for model in model_list}
try:
body, model, user = await get_body_and_model_and_user(request)
body, model, user = await get_body_and_model_and_user(request, models)
except Exception as e:
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"detail": str(e)},
)
model_info = Models.get_model_by_id(model["id"])
if user.role == "user":
if model_info and not has_access(
user.id, type="read", access_control=model_info.access_control
):
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={"detail": "User does not have access to the model"},
)
metadata = {
"chat_id": body.pop("chat_id", None),
"message_id": body.pop("id", None),
@ -584,15 +601,20 @@ class ChatCompletionMiddleware(BaseHTTPMiddleware):
content={"detail": str(e)},
)
tool_ids = body.pop("tool_ids", None)
files = body.pop("files", None)
metadata = {
**metadata,
"tool_ids": body.pop("tool_ids", None),
"files": body.pop("files", None),
"tool_ids": tool_ids,
"files": files,
}
body["metadata"] = metadata
try:
body, flags = await chat_completion_tools_handler(body, user, extra_params)
body, flags = await chat_completion_tools_handler(
body, user, models, extra_params
)
contexts.extend(flags.get("contexts", []))
citations.extend(flags.get("citations", []))
except Exception as e:
@ -689,10 +711,10 @@ app.add_middleware(ChatCompletionMiddleware)
##################################
def get_sorted_filters(model_id):
def get_sorted_filters(model_id, models):
filters = [
model
for model in app.state.MODELS.values()
for model in models.values()
if "pipeline" in model
and "type" in model["pipeline"]
and model["pipeline"]["type"] == "filter"
@ -708,12 +730,12 @@ def get_sorted_filters(model_id):
return sorted_filters
def filter_pipeline(payload, user):
def filter_pipeline(payload, user, models):
user = {"id": user.id, "email": user.email, "name": user.name, "role": user.role}
model_id = payload["model"]
sorted_filters = get_sorted_filters(model_id)
model = app.state.MODELS[model_id]
sorted_filters = get_sorted_filters(model_id, models)
model = models[model_id]
if "pipeline" in model:
sorted_filters.append(model)
@ -784,8 +806,11 @@ class PipelineMiddleware(BaseHTTPMiddleware):
content={"detail": "Not authenticated"},
)
model_list = await get_all_models()
models = {model["id"]: model for model in model_list}
try:
data = filter_pipeline(data, user)
data = filter_pipeline(data, user, models)
except Exception as e:
if len(e.args) > 1:
return JSONResponse(
@ -864,16 +889,10 @@ async def commit_session_after_request(request: Request, call_next):
@app.middleware("http")
async def check_url(request: Request, call_next):
if len(app.state.MODELS) == 0:
await get_all_models()
else:
pass
start_time = int(time.time())
response = await call_next(request)
process_time = int(time.time()) - start_time
response.headers["X-Process-Time"] = str(process_time)
return response
@ -913,12 +932,10 @@ app.mount("/retrieval/api/v1", retrieval_app)
app.mount("/api/v1", webui_app)
webui_app.state.EMBEDDING_FUNCTION = retrieval_app.state.EMBEDDING_FUNCTION
async def get_all_models():
# TODO: Optimize this function
async def get_all_base_models():
open_webui_models = []
openai_models = []
ollama_models = []
@ -944,9 +961,15 @@ async def get_all_models():
open_webui_models = await get_open_webui_models()
models = open_webui_models + openai_models + ollama_models
return models
@cached(ttl=1)
async def get_all_models():
models = await get_all_base_models()
# If there are no models, return an empty list
if len([model for model in models if model["owned_by"] != "arena"]) == 0:
if len([model for model in models if not model.get("arena", False)]) == 0:
return []
global_action_ids = [
@ -965,15 +988,23 @@ async def get_all_models():
custom_model.id == model["id"]
or custom_model.id == model["id"].split(":")[0]
):
model["name"] = custom_model.name
model["info"] = custom_model.model_dump()
if custom_model.is_active:
model["name"] = custom_model.name
model["info"] = custom_model.model_dump()
action_ids = []
if "info" in model and "meta" in model["info"]:
action_ids.extend(model["info"]["meta"].get("actionIds", []))
action_ids = []
if "info" in model and "meta" in model["info"]:
action_ids.extend(
model["info"]["meta"].get("actionIds", [])
)
model["action_ids"] = action_ids
else:
model["action_ids"] = action_ids
else:
models.remove(model)
elif custom_model.is_active and (
custom_model.id not in [model["id"] for model in models]
):
owned_by = "openai"
pipe = None
action_ids = []
@ -995,7 +1026,7 @@ async def get_all_models():
models.append(
{
"id": custom_model.id,
"id": f"{custom_model.id}",
"name": custom_model.name,
"object": "model",
"created": custom_model.created_at,
@ -1007,66 +1038,54 @@ async def get_all_models():
}
)
for model in models:
action_ids = []
if "action_ids" in model:
action_ids = model["action_ids"]
del model["action_ids"]
# Process action_ids to get the actions
def get_action_items_from_module(module):
actions = []
if hasattr(module, "actions"):
actions = module.actions
return [
{
"id": f"{module.id}.{action['id']}",
"name": action.get("name", f"{module.name} ({action['id']})"),
"description": module.meta.description,
"icon_url": action.get(
"icon_url", module.meta.manifest.get("icon_url", None)
),
}
for action in actions
]
else:
return [
{
"id": module.id,
"name": module.name,
"description": module.meta.description,
"icon_url": module.meta.manifest.get("icon_url", None),
}
]
action_ids = action_ids + global_action_ids
action_ids = list(set(action_ids))
def get_function_module_by_id(function_id):
if function_id in webui_app.state.FUNCTIONS:
function_module = webui_app.state.FUNCTIONS[function_id]
else:
function_module, _, _ = load_function_module_by_id(function_id)
webui_app.state.FUNCTIONS[function_id] = function_module
for model in models:
action_ids = [
action_id for action_id in action_ids if action_id in enabled_action_ids
action_id
for action_id in list(set(model.pop("action_ids", []) + global_action_ids))
if action_id in enabled_action_ids
]
model["actions"] = []
for action_id in action_ids:
action = Functions.get_function_by_id(action_id)
if action is None:
action_function = Functions.get_function_by_id(action_id)
if action_function is None:
raise Exception(f"Action not found: {action_id}")
if action_id in webui_app.state.FUNCTIONS:
function_module = webui_app.state.FUNCTIONS[action_id]
else:
function_module, _, _ = load_function_module_by_id(action_id)
webui_app.state.FUNCTIONS[action_id] = function_module
__webui__ = False
if hasattr(function_module, "__webui__"):
__webui__ = function_module.__webui__
if hasattr(function_module, "actions"):
actions = function_module.actions
model["actions"].extend(
[
{
"id": f"{action_id}.{_action['id']}",
"name": _action.get(
"name", f"{action.name} ({_action['id']})"
),
"description": action.meta.description,
"icon_url": _action.get(
"icon_url", action.meta.manifest.get("icon_url", None)
),
**({"__webui__": __webui__} if __webui__ else {}),
}
for _action in actions
]
)
else:
model["actions"].append(
{
"id": action_id,
"name": action.name,
"description": action.meta.description,
"icon_url": action.meta.manifest.get("icon_url", None),
**({"__webui__": __webui__} if __webui__ else {}),
}
)
app.state.MODELS = {model["id"]: model for model in models}
webui_app.state.MODELS = app.state.MODELS
function_module = get_function_module_by_id(action_id)
model["actions"].extend(get_action_items_from_module(function_module))
return models
@ -1081,40 +1100,58 @@ async def get_models(user=Depends(get_verified_user)):
if "pipeline" not in model or model["pipeline"].get("type", None) != "filter"
]
if app.state.config.ENABLE_MODEL_FILTER:
if user.role == "user":
models = list(
filter(
lambda model: model["id"] in app.state.config.MODEL_FILTER_LIST,
models,
)
)
return {"data": models}
# Filter out models that the user does not have access to
if user.role == "user":
filtered_models = []
for model in models:
model_info = Models.get_model_by_id(model["id"])
if model_info:
if has_access(
user.id, type="read", access_control=model_info.access_control
):
filtered_models.append(model)
else:
filtered_models.append(model)
models = filtered_models
return {"data": models}
@app.get("/api/models/base")
async def get_base_models(user=Depends(get_admin_user)):
models = await get_all_base_models()
# Filter out arena models
models = [model for model in models if not model.get("arena", False)]
return {"data": models}
@app.post("/api/chat/completions")
async def generate_chat_completions(
form_data: dict, user=Depends(get_verified_user), bypass_filter: bool = False
):
model_id = form_data["model"]
model_list = await get_all_models()
models = {model["id"]: model for model in model_list}
if model_id not in app.state.MODELS:
model_id = form_data["model"]
if model_id not in models:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Model not found",
)
if not bypass_filter and app.state.config.ENABLE_MODEL_FILTER:
if user.role == "user" and model_id not in app.state.config.MODEL_FILTER_LIST:
model = models[model_id]
# Check if user has access to the model
if user.role == "user":
model_info = Models.get_model_by_id(model_id)
if not has_access(
user.id, type="read", access_control=model_info.access_control
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
status_code=403,
detail="Model not found",
)
model = app.state.MODELS[model_id]
if model["owned_by"] == "arena":
model_ids = model.get("info", {}).get("meta", {}).get("model_ids")
filter_mode = model.get("info", {}).get("meta", {}).get("filter_mode")
@ -1161,14 +1198,18 @@ async def generate_chat_completions(
),
"selected_model_id": selected_model_id,
}
if model.get("pipe"):
return await generate_function_chat_completion(form_data, user=user)
# Below does not require bypass_filter because this is the only route the uses this function and it is already bypassing the filter
return await generate_function_chat_completion(
form_data, user=user, models=models
)
if model["owned_by"] == "ollama":
# Using /ollama/api/chat endpoint
form_data = convert_payload_openai_to_ollama(form_data)
form_data = GenerateChatCompletionForm(**form_data)
response = await generate_ollama_chat_completion(
form_data=form_data, user=user, bypass_filter=True
form_data=form_data, user=user, bypass_filter=bypass_filter
)
if form_data.stream:
response.headers["content-type"] = "text/event-stream"
@ -1179,21 +1220,27 @@ async def generate_chat_completions(
else:
return convert_response_ollama_to_openai(response)
else:
return await generate_openai_chat_completion(form_data, user=user)
return await generate_openai_chat_completion(
form_data, user=user, bypass_filter=bypass_filter
)
@app.post("/api/chat/completed")
async def chat_completed(form_data: dict, user=Depends(get_verified_user)):
model_list = await get_all_models()
models = {model["id"]: model for model in model_list}
data = form_data
model_id = data["model"]
if model_id not in app.state.MODELS:
if model_id not in models:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Model not found",
)
model = app.state.MODELS[model_id]
sorted_filters = get_sorted_filters(model_id)
model = models[model_id]
sorted_filters = get_sorted_filters(model_id, models)
if "pipeline" in model:
sorted_filters = [model] + sorted_filters
@ -1368,14 +1415,18 @@ async def chat_action(action_id: str, form_data: dict, user=Depends(get_verified
detail="Action not found",
)
model_list = await get_all_models()
models = {model["id"]: model for model in model_list}
data = form_data
model_id = data["model"]
if model_id not in app.state.MODELS:
if model_id not in models:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Model not found",
)
model = app.state.MODELS[model_id]
model = models[model_id]
__event_emitter__ = get_event_emitter(
{
@ -1529,8 +1580,11 @@ async def update_task_config(form_data: TaskConfigForm, user=Depends(get_admin_u
async def generate_title(form_data: dict, user=Depends(get_verified_user)):
print("generate_title")
model_list = await get_all_models()
models = {model["id"]: model for model in model_list}
model_id = form_data["model"]
if model_id not in app.state.MODELS:
if model_id not in models:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Model not found",
@ -1538,10 +1592,16 @@ async def generate_title(form_data: dict, user=Depends(get_verified_user)):
# Check if the user has a custom task model
# If the user has a custom task model, use that model
task_model_id = get_task_model_id(model_id)
task_model_id = get_task_model_id(
model_id,
app.state.config.TASK_MODEL,
app.state.config.TASK_MODEL_EXTERNAL,
models,
)
print(task_model_id)
model = app.state.MODELS[task_model_id]
model = models[task_model_id]
if app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE != "":
template = app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE
@ -1575,7 +1635,7 @@ Artificial Intelligence in Healthcare
"stream": False,
**(
{"max_tokens": 50}
if app.state.MODELS[task_model_id]["owned_by"] == "ollama"
if models[task_model_id]["owned_by"] == "ollama"
else {
"max_completion_tokens": 50,
}
@ -1587,7 +1647,7 @@ Artificial Intelligence in Healthcare
# Handle pipeline filters
try:
payload = filter_pipeline(payload, user)
payload = filter_pipeline(payload, user, models)
except Exception as e:
if len(e.args) > 1:
return JSONResponse(
@ -1614,8 +1674,11 @@ async def generate_chat_tags(form_data: dict, user=Depends(get_verified_user)):
content={"detail": "Tags generation is disabled"},
)
model_list = await get_all_models()
models = {model["id"]: model for model in model_list}
model_id = form_data["model"]
if model_id not in app.state.MODELS:
if model_id not in models:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Model not found",
@ -1623,7 +1686,12 @@ async def generate_chat_tags(form_data: dict, user=Depends(get_verified_user)):
# Check if the user has a custom task model
# If the user has a custom task model, use that model
task_model_id = get_task_model_id(model_id)
task_model_id = get_task_model_id(
model_id,
app.state.config.TASK_MODEL,
app.state.config.TASK_MODEL_EXTERNAL,
models,
)
print(task_model_id)
if app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE != "":
@ -1661,7 +1729,7 @@ JSON format: { "tags": ["tag1", "tag2", "tag3"] }
# Handle pipeline filters
try:
payload = filter_pipeline(payload, user)
payload = filter_pipeline(payload, user, models)
except Exception as e:
if len(e.args) > 1:
return JSONResponse(
@ -1688,8 +1756,11 @@ async def generate_search_query(form_data: dict, user=Depends(get_verified_user)
detail=f"Search query generation is disabled",
)
model_list = await get_all_models()
models = {model["id"]: model for model in model_list}
model_id = form_data["model"]
if model_id not in app.state.MODELS:
if model_id not in models:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Model not found",
@ -1697,10 +1768,15 @@ async def generate_search_query(form_data: dict, user=Depends(get_verified_user)
# Check if the user has a custom task model
# If the user has a custom task model, use that model
task_model_id = get_task_model_id(model_id)
task_model_id = get_task_model_id(
model_id,
app.state.config.TASK_MODEL,
app.state.config.TASK_MODEL_EXTERNAL,
models,
)
print(task_model_id)
model = app.state.MODELS[task_model_id]
model = models[task_model_id]
if app.state.config.SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE != "":
template = app.state.config.SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE
@ -1727,7 +1803,7 @@ Search Query:"""
"stream": False,
**(
{"max_tokens": 30}
if app.state.MODELS[task_model_id]["owned_by"] == "ollama"
if models[task_model_id]["owned_by"] == "ollama"
else {
"max_completion_tokens": 30,
}
@ -1738,7 +1814,7 @@ Search Query:"""
# Handle pipeline filters
try:
payload = filter_pipeline(payload, user)
payload = filter_pipeline(payload, user, models)
except Exception as e:
if len(e.args) > 1:
return JSONResponse(
@ -1760,8 +1836,11 @@ Search Query:"""
async def generate_emoji(form_data: dict, user=Depends(get_verified_user)):
print("generate_emoji")
model_list = await get_all_models()
models = {model["id"]: model for model in model_list}
model_id = form_data["model"]
if model_id not in app.state.MODELS:
if model_id not in models:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Model not found",
@ -1769,10 +1848,15 @@ async def generate_emoji(form_data: dict, user=Depends(get_verified_user)):
# Check if the user has a custom task model
# If the user has a custom task model, use that model
task_model_id = get_task_model_id(model_id)
task_model_id = get_task_model_id(
model_id,
app.state.config.TASK_MODEL,
app.state.config.TASK_MODEL_EXTERNAL,
models,
)
print(task_model_id)
model = app.state.MODELS[task_model_id]
model = models[task_model_id]
template = '''
Your task is to reflect the speaker's likely facial expression through a fitting emoji. Interpret emotions from the message and reflect their facial expression using fitting, diverse emojis (e.g., 😊, 😢, 😡, 😱).
@ -1794,7 +1878,7 @@ Message: """{{prompt}}"""
"stream": False,
**(
{"max_tokens": 4}
if app.state.MODELS[task_model_id]["owned_by"] == "ollama"
if models[task_model_id]["owned_by"] == "ollama"
else {
"max_completion_tokens": 4,
}
@ -1806,7 +1890,7 @@ Message: """{{prompt}}"""
# Handle pipeline filters
try:
payload = filter_pipeline(payload, user)
payload = filter_pipeline(payload, user, models)
except Exception as e:
if len(e.args) > 1:
return JSONResponse(
@ -1828,8 +1912,11 @@ Message: """{{prompt}}"""
async def generate_moa_response(form_data: dict, user=Depends(get_verified_user)):
print("generate_moa_response")
model_list = await get_all_models()
models = {model["id"]: model for model in model_list}
model_id = form_data["model"]
if model_id not in app.state.MODELS:
if model_id not in models:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Model not found",
@ -1837,10 +1924,15 @@ async def generate_moa_response(form_data: dict, user=Depends(get_verified_user)
# Check if the user has a custom task model
# If the user has a custom task model, use that model
task_model_id = get_task_model_id(model_id)
task_model_id = get_task_model_id(
model_id,
app.state.config.TASK_MODEL,
app.state.config.TASK_MODEL_EXTERNAL,
models,
)
print(task_model_id)
model = app.state.MODELS[task_model_id]
model = models[task_model_id]
template = """You have been provided with a set of responses from various models to the latest user query: "{{prompt}}"
@ -1867,7 +1959,7 @@ Responses from models: {{responses}}"""
log.debug(payload)
try:
payload = filter_pipeline(payload, user)
payload = filter_pipeline(payload, user, models)
except Exception as e:
if len(e.args) > 1:
return JSONResponse(
@ -1897,7 +1989,7 @@ Responses from models: {{responses}}"""
@app.get("/api/pipelines/list")
async def get_pipelines_list(user=Depends(get_admin_user)):
responses = await get_openai_models(raw=True)
responses = await get_openai_models_responses()
print(responses)
urlIdxs = [
@ -2297,32 +2389,6 @@ async def get_app_config(request: Request):
}
@app.get("/api/config/model/filter")
async def get_model_filter_config(user=Depends(get_admin_user)):
return {
"enabled": app.state.config.ENABLE_MODEL_FILTER,
"models": app.state.config.MODEL_FILTER_LIST,
}
class ModelFilterConfigForm(BaseModel):
enabled: bool
models: list[str]
@app.post("/api/config/model/filter")
async def update_model_filter_config(
form_data: ModelFilterConfigForm, user=Depends(get_admin_user)
):
app.state.config.ENABLE_MODEL_FILTER = form_data.enabled
app.state.config.MODEL_FILTER_LIST = form_data.models
return {
"enabled": app.state.config.ENABLE_MODEL_FILTER,
"models": app.state.config.MODEL_FILTER_LIST,
}
# TODO: webhook endpoint should be under config endpoints

View file

@ -0,0 +1,85 @@
"""Add group table
Revision ID: 922e7a387820
Revises: 4ace53fd72c8
Create Date: 2024-11-14 03:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = "922e7a387820"
down_revision = "4ace53fd72c8"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"group",
sa.Column("id", sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column("user_id", sa.Text(), nullable=True),
sa.Column("name", sa.Text(), nullable=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("data", sa.JSON(), nullable=True),
sa.Column("meta", sa.JSON(), nullable=True),
sa.Column("permissions", sa.JSON(), nullable=True),
sa.Column("user_ids", sa.JSON(), nullable=True),
sa.Column("created_at", sa.BigInteger(), nullable=True),
sa.Column("updated_at", sa.BigInteger(), nullable=True),
)
# Add 'access_control' column to 'model' table
op.add_column(
"model",
sa.Column("access_control", sa.JSON(), nullable=True),
)
# Add 'is_active' column to 'model' table
op.add_column(
"model",
sa.Column(
"is_active",
sa.Boolean(),
nullable=False,
server_default=sa.sql.expression.true(),
),
)
# Add 'access_control' column to 'knowledge' table
op.add_column(
"knowledge",
sa.Column("access_control", sa.JSON(), nullable=True),
)
# Add 'access_control' column to 'prompt' table
op.add_column(
"prompt",
sa.Column("access_control", sa.JSON(), nullable=True),
)
# Add 'access_control' column to 'tools' table
op.add_column(
"tool",
sa.Column("access_control", sa.JSON(), nullable=True),
)
def downgrade():
op.drop_table("group")
# Drop 'access_control' column from 'model' table
op.drop_column("model", "access_control")
# Drop 'is_active' column from 'model' table
op.drop_column("model", "is_active")
# Drop 'access_control' column from 'knowledge' table
op.drop_column("knowledge", "access_control")
# Drop 'access_control' column from 'prompt' table
op.drop_column("prompt", "access_control")
# Drop 'access_control' column from 'tools' table
op.drop_column("tool", "access_control")

View file

@ -0,0 +1,95 @@
from typing import Optional, Union, List, Dict, Any
from open_webui.apps.webui.models.groups import Groups
import json
def get_permissions(
user_id: str,
default_permissions: Dict[str, Any],
) -> Dict[str, Any]:
"""
Get all permissions for a user by combining the permissions of all groups the user is a member of.
If a permission is defined in multiple groups, the most permissive value is used (True > False).
Permissions are nested in a dict with the permission key as the key and a boolean as the value.
"""
def combine_permissions(
permissions: Dict[str, Any], group_permissions: Dict[str, Any]
) -> Dict[str, Any]:
"""Combine permissions from multiple groups by taking the most permissive value."""
for key, value in group_permissions.items():
if isinstance(value, dict):
if key not in permissions:
permissions[key] = {}
permissions[key] = combine_permissions(permissions[key], value)
else:
if key not in permissions:
permissions[key] = value
else:
permissions[key] = permissions[key] or value
return permissions
user_groups = Groups.get_groups_by_member_id(user_id)
# deep copy default permissions to avoid modifying the original dict
permissions = json.loads(json.dumps(default_permissions))
for group in user_groups:
group_permissions = group.permissions
permissions = combine_permissions(permissions, group_permissions)
return permissions
def has_permission(
user_id: str,
permission_key: str,
default_permissions: Dict[str, bool] = {},
) -> bool:
"""
Check if a user has a specific permission by checking the group permissions
and falls back to default permissions if not found in any group.
Permission keys can be hierarchical and separated by dots ('.').
"""
def get_permission(permissions: Dict[str, bool], keys: List[str]) -> bool:
"""Traverse permissions dict using a list of keys (from dot-split permission_key)."""
for key in keys:
if key not in permissions:
return False # If any part of the hierarchy is missing, deny access
permissions = permissions[key] # Go one level deeper
return bool(permissions) # Return the boolean at the final level
permission_hierarchy = permission_key.split(".")
# Retrieve user group permissions
user_groups = Groups.get_groups_by_member_id(user_id)
for group in user_groups:
group_permissions = group.permissions
if get_permission(group_permissions, permission_hierarchy):
return True
# Check default permissions afterwards if the group permissions don't allow it
return get_permission(default_permissions, permission_hierarchy)
def has_access(
user_id: str,
type: str = "write",
access_control: Optional[dict] = None,
) -> bool:
if access_control is None:
return type == "read"
user_groups = Groups.get_groups_by_member_id(user_id)
user_group_ids = [group.id for group in user_groups]
permission_access = access_control.get(type, {})
permitted_group_ids = permission_access.get("group_ids", [])
permitted_user_ids = permission_access.get("user_ids", [])
return user_id in permitted_user_ids or any(
group_id in permitted_group_ids for group_id in user_group_ids
)

View file

@ -4,7 +4,7 @@ from typing import Awaitable, Callable, get_type_hints
from open_webui.apps.webui.models.tools import Tools
from open_webui.apps.webui.models.users import UserModel
from open_webui.apps.webui.utils import load_toolkit_module_by_id
from open_webui.apps.webui.utils import load_tools_module_by_id
from open_webui.utils.schemas import json_schema_to_model
log = logging.getLogger(__name__)
@ -32,15 +32,16 @@ def apply_extra_params_to_tool_function(
def get_tools(
webui_app, tool_ids: list[str], user: UserModel, extra_params: dict
) -> dict[str, dict]:
tools = {}
tools_dict = {}
for tool_id in tool_ids:
toolkit = Tools.get_tool_by_id(tool_id)
if toolkit is None:
tools = Tools.get_tool_by_id(tool_id)
if tools is None:
continue
module = webui_app.state.TOOLS.get(tool_id, None)
if module is None:
module, _ = load_toolkit_module_by_id(tool_id)
module, _ = load_tools_module_by_id(tool_id)
webui_app.state.TOOLS[tool_id] = module
extra_params["__id__"] = tool_id
@ -53,11 +54,19 @@ def get_tools(
**Tools.get_user_valves_by_id_and_user_id(tool_id, user.id)
)
for spec in toolkit.specs:
for spec in tools.specs:
# TODO: Fix hack for OpenAI API
for val in spec.get("parameters", {}).get("properties", {}).values():
if val["type"] == "str":
val["type"] = "string"
# Remove internal parameters
spec["parameters"]["properties"] = {
key: val
for key, val in spec["parameters"]["properties"].items()
if not key.startswith("__")
}
function_name = spec["name"]
# convert to function that takes only model params and inserts custom params
@ -77,13 +86,14 @@ def get_tools(
}
# TODO: if collision, prepend toolkit name
if function_name in tools:
log.warning(f"Tool {function_name} already exists in another toolkit!")
log.warning(f"Collision between {toolkit} and {tool_id}.")
log.warning(f"Discarding {toolkit}.{function_name}")
if function_name in tools_dict:
log.warning(f"Tool {function_name} already exists in another tools!")
log.warning(f"Collision between {tools} and {tool_id}.")
log.warning(f"Discarding {tools}.{function_name}")
else:
tools[function_name] = tool_dict
return tools
tools_dict[function_name] = tool_dict
return tools_dict
def doc_to_dict(docstring):

View file

@ -1,12 +1,17 @@
import logging
import uuid
from datetime import UTC, datetime, timedelta
from typing import Optional, Union
import jwt
from datetime import UTC, datetime, timedelta
from typing import Optional, Union, List, Dict
from open_webui.apps.webui.models.users import Users
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import WEBUI_SECRET_KEY
from fastapi import Depends, HTTPException, Request, Response, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from passlib.context import CryptContext

View file

@ -13,6 +13,7 @@ passlib[bcrypt]==1.7.4
requests==2.32.3
aiohttp==3.10.8
async-timeout
aiocache
sqlalchemy==2.0.32
alembic==1.13.2

View file

@ -21,6 +21,7 @@ dependencies = [
"requests==2.32.3",
"aiohttp==3.10.8",
"async-timeout",
"aiocache",
"sqlalchemy==2.0.32",
"alembic==1.13.2",

View file

@ -0,0 +1,163 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
export const createNewGroup = async (token: string, group: object) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/groups/create`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
...group
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getGroups = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/groups/`, {
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.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getGroupById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/groups/id/${id}`, {
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.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateGroupById = async (token: string, id: string, group: object) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/groups/id/${id}/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
...group
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const deleteGroupById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/groups/id/${id}/delete`, {
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();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};

View file

@ -1,9 +1,10 @@
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
export const getModels = async (token: string = '') => {
export const getModels = async (token: string = '', base: boolean = false) => {
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/models`, {
const res = await fetch(`${WEBUI_BASE_URL}/api/models${
base ? '/base' : ''
}`, {
method: 'GET',
headers: {
Accept: 'application/json',
@ -16,36 +17,21 @@ export const getModels = async (token: string = '') => {
return res.json();
})
.catch((err) => {
console.log(err);
error = err;
console.log(err);
return null;
});
if (error) {
throw error;
}
let models = res?.data ?? [];
models = models
.filter((models) => models)
// Sort the models
.sort((a, b) => {
// Check if models have position property
const aHasPosition = a.info?.meta?.position !== undefined;
const bHasPosition = b.info?.meta?.position !== undefined;
// If both a and b have the position property
if (aHasPosition && bHasPosition) {
return a.info.meta.position - b.info.meta.position;
}
// If only a has the position property, it should come first
if (aHasPosition) return -1;
// If only b has the position property, it should come first
if (bHasPosition) return 1;
// Compare case-insensitively by name for models without position property
const lowerA = a.name.toLowerCase();
const lowerB = b.name.toLowerCase();

View file

@ -1,6 +1,6 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
export const createNewKnowledge = async (token: string, name: string, description: string) => {
export const createNewKnowledge = async (token: string, name: string, description: string, accessControl: null|object) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/create`, {
@ -12,7 +12,8 @@ export const createNewKnowledge = async (token: string, name: string, descriptio
},
body: JSON.stringify({
name: name,
description: description
description: description,
access_control: accessControl
})
})
.then(async (res) => {
@ -32,7 +33,7 @@ export const createNewKnowledge = async (token: string, name: string, descriptio
return res;
};
export const getKnowledgeItems = async (token: string = '') => {
export const getKnowledgeBases = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/`, {
@ -63,6 +64,37 @@ export const getKnowledgeItems = async (token: string = '') => {
return res;
};
export const getKnowledgeBaseList = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/list`, {
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.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getKnowledgeById = async (token: string, id: string) => {
let error = null;
@ -99,6 +131,7 @@ type KnowledgeUpdateForm = {
name?: string;
description?: string;
data?: object;
access_control?: null|object;
};
export const updateKnowledgeById = async (token: string, id: string, form: KnowledgeUpdateForm) => {
@ -114,7 +147,8 @@ export const updateKnowledgeById = async (token: string, id: string, form: Knowl
body: JSON.stringify({
name: form?.name ? form.name : undefined,
description: form?.description ? form.description : undefined,
data: form?.data ? form.data : undefined
data: form?.data ? form.data : undefined,
access_control: form.access_control
})
})
.then(async (res) => {

View file

@ -1,35 +1,7 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
export const addNewModel = async (token: string, model: object) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/models/add`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify(model)
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getModelInfos = async (token: string = '') => {
export const getModels = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/models`, {
@ -60,13 +32,79 @@ export const getModelInfos = async (token: string = '') => {
return res;
};
export const getBaseModels = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/models/base`, {
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.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const createNewModel = async (token: string, model: object) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/models/create`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify(model)
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getModelById = async (token: string, id: string) => {
let error = null;
const searchParams = new URLSearchParams();
searchParams.append('id', id);
const res = await fetch(`${WEBUI_API_BASE_URL}/models?${searchParams.toString()}`, {
const res = await fetch(`${WEBUI_API_BASE_URL}/models/id/${id}`, {
method: 'GET',
headers: {
Accept: 'application/json',
@ -95,13 +133,50 @@ export const getModelById = async (token: string, id: string) => {
return res;
};
export const toggleModelById = async (token: string, id: string) => {
let error = null;
const searchParams = new URLSearchParams();
searchParams.append('id', id);
const res = await fetch(`${WEBUI_API_BASE_URL}/models/id/${id}/toggle`, {
method: 'POST',
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.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateModelById = async (token: string, id: string, model: object) => {
let error = null;
const searchParams = new URLSearchParams();
searchParams.append('id', id);
const res = await fetch(`${WEBUI_API_BASE_URL}/models/update?${searchParams.toString()}`, {
const res = await fetch(`${WEBUI_API_BASE_URL}/models/id/${id}/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
@ -137,7 +212,7 @@ export const deleteModelById = async (token: string, id: string) => {
const searchParams = new URLSearchParams();
searchParams.append('id', id);
const res = await fetch(`${WEBUI_API_BASE_URL}/models/delete?${searchParams.toString()}`, {
const res = await fetch(`${WEBUI_API_BASE_URL}/models/id/${id}/delete`, {
method: 'DELETE',
headers: {
Accept: 'application/json',

View file

@ -211,10 +211,12 @@ export const getOllamaVersion = async (token: string, urlIdx?: number) => {
return res?.version ?? false;
};
export const getOllamaModels = async (token: string = '') => {
export const getOllamaModels = async (token: string = '', urlIdx: null|number = null) => {
let error = null;
const res = await fetch(`${OLLAMA_API_BASE_URL}/api/tags`, {
const res = await fetch(`${OLLAMA_API_BASE_URL}/api/tags${
urlIdx !== null ? `/${urlIdx}` : ''
}`, {
method: 'GET',
headers: {
Accept: 'application/json',

View file

@ -1,10 +1,18 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
type PromptItem = {
command: string;
title: string;
content: string;
access_control: null|object;
}
export const createNewPrompt = async (
token: string,
command: string,
title: string,
content: string
prompt: PromptItem
) => {
let error = null;
@ -16,9 +24,8 @@ export const createNewPrompt = async (
authorization: `Bearer ${token}`
},
body: JSON.stringify({
command: `/${command}`,
title: title,
content: content
...prompt,
command: `/${prompt.command}`,
})
})
.then(async (res) => {
@ -69,6 +76,39 @@ export const getPrompts = async (token: string = '') => {
return res;
};
export const getPromptList = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/list`, {
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.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getPromptByCommand = async (token: string, command: string) => {
let error = null;
@ -101,15 +141,15 @@ export const getPromptByCommand = async (token: string, command: string) => {
return res;
};
export const updatePromptByCommand = async (
token: string,
command: string,
title: string,
content: string
prompt: PromptItem
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/command/${command}/update`, {
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/command/${prompt.command}/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
@ -117,9 +157,8 @@ export const updatePromptByCommand = async (
authorization: `Bearer ${token}`
},
body: JSON.stringify({
command: `/${command}`,
title: title,
content: content
...prompt,
command: `/${prompt.command}`,
})
})
.then(async (res) => {

View file

@ -62,6 +62,39 @@ export const getTools = async (token: string = '') => {
return res;
};
export const getToolList = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/tools/list`, {
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.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const exportTools = async (token: string = '') => {
let error = null;

View file

@ -1,10 +1,11 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
import { getUserPosition } from '$lib/utils';
export const getUserPermissions = async (token: string) => {
export const getUserGroups = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/users/permissions/user`, {
const res = await fetch(`${WEBUI_API_BASE_URL}/users/groups`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
@ -28,10 +29,39 @@ export const getUserPermissions = async (token: string) => {
return res;
};
export const updateUserPermissions = async (token: string, permissions: object) => {
export const getUserDefaultPermissions = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/users/permissions/user`, {
const res = await fetch(`${WEBUI_API_BASE_URL}/users/default/permissions`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.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 updateUserDefaultPermissions = async (token: string, permissions: object) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/users/default/permissions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View file

@ -5,7 +5,6 @@
import { WEBUI_NAME, config, functions, models } from '$lib/stores';
import { onMount, getContext, tick } from 'svelte';
import { createNewPrompt, deletePromptByCommand, getPrompts } from '$lib/apis/prompts';
import { goto } from '$app/navigation';
import {
@ -25,13 +24,14 @@
import FunctionMenu from './Functions/FunctionMenu.svelte';
import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
import Switch from '../common/Switch.svelte';
import ValvesModal from './common/ValvesModal.svelte';
import ManifestModal from './common/ManifestModal.svelte';
import ValvesModal from '../workspace/common/ValvesModal.svelte';
import ManifestModal from '../workspace/common/ManifestModal.svelte';
import Heart from '../icons/Heart.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import GarbageBin from '../icons/GarbageBin.svelte';
import Search from '../icons/Search.svelte';
import Plus from '../icons/Plus.svelte';
import ChevronRight from '../icons/ChevronRight.svelte';
const i18n = getContext('i18n');
@ -98,7 +98,7 @@
id: `${_function.id}_clone`,
name: `${_function.name} (Clone)`
});
goto('/workspace/functions/create');
goto('/admin/functions/create');
}
};
@ -210,7 +210,7 @@
<div>
<a
class=" px-2 py-2 rounded-xl hover:bg-gray-700/10 dark:hover:bg-gray-100/10 dark:text-gray-300 dark:hover:text-white transition font-medium text-sm flex items-center space-x-1"
href="/workspace/functions/create"
href="/admin/functions/create"
>
<Plus className="size-3.5" />
</a>
@ -225,7 +225,7 @@
>
<a
class=" flex flex-1 space-x-3.5 cursor-pointer w-full"
href={`/workspace/functions/edit?id=${encodeURIComponent(func.id)}`}
href={`/admin/functions/edit?id=${encodeURIComponent(func.id)}`}
>
<div class="flex items-center text-left">
<div class=" flex-1 self-center pl-1">
@ -322,7 +322,7 @@
<FunctionMenu
{func}
editHandler={() => {
goto(`/workspace/functions/edit?id=${encodeURIComponent(func.id)}`);
goto(`/admin/functions/edit?id=${encodeURIComponent(func.id)}`);
}}
shareHandler={() => {
shareHandler(func);
@ -452,40 +452,27 @@
{#if $config?.features.enable_community_sharing}
<div class=" my-16">
<div class=" text-lg font-semibold mb-3 line-clamp-1">
<div class=" text-lg font-semibold mb-0.5 line-clamp-1">
{$i18n.t('Made by OpenWebUI Community')}
</div>
<a
class=" flex space-x-4 cursor-pointer w-full mb-2 px-3 py-2"
class=" flex cursor-pointer items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-850 w-full mb-2 px-3.5 py-1.5 rounded-xl transition"
href="https://openwebui.com/#open-webui-community"
target="_blank"
>
<div class=" self-center w-10 flex-shrink-0">
<div
class="w-full h-10 flex justify-center rounded-full bg-transparent dark:bg-gray-700 border border-dashed border-gray-200"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-6"
>
<path
fill-rule="evenodd"
d="M12 3.75a.75.75 0 01.75.75v6.75h6.75a.75.75 0 010 1.5h-6.75v6.75a.75.75 0 01-1.5 0v-6.75H4.5a.75.75 0 010-1.5h6.75V4.5a.75.75 0 01.75-.75z"
clip-rule="evenodd"
/>
</svg>
</div>
</div>
<div class=" self-center">
<div class=" font-semibold line-clamp-1">{$i18n.t('Discover a function')}</div>
<div class=" text-sm line-clamp-1">
{$i18n.t('Discover, download, and explore custom functions')}
</div>
</div>
<div>
<div>
<ChevronRight />
</div>
</div>
</a>
</div>
{/if}

View file

@ -305,7 +305,7 @@ class Pipe:
<button
class="w-full text-left text-sm py-1.5 px-1 rounded-lg dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-gray-850"
on:click={() => {
goto('/workspace/functions');
goto('/admin/functions');
}}
type="button"
>

View file

@ -327,7 +327,7 @@
</button>
</div>
<div class="flex-1 mt-3 lg:mt-0 overflow-y-scroll">
<div class="flex-1 mt-3 lg:mt-0 overflow-y-scroll pr-1 scrollbar-hidden">
{#if selectedTab === 'general'}
<General
saveHandler={async () => {

View file

@ -302,6 +302,7 @@
<OllamaConnection
bind:url
bind:config={OLLAMA_API_CONFIGS[url]}
{idx}
onSubmit={() => {
updateOllamaHandler();
}}

File diff suppressed because it is too large Load diff

View file

@ -4,15 +4,20 @@
import Tooltip from '$lib/components/common/Tooltip.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Cog6 from '$lib/components/icons/Cog6.svelte';
import AddConnectionModal from './AddConnectionModal.svelte';
import Cog6 from '$lib/components/icons/Cog6.svelte';
import Wrench from '$lib/components/icons/Wrench.svelte';
import ManageOllamaModal from './ManageOllamaModal.svelte';
export let onDelete = () => {};
export let onSubmit = () => {};
export let url = '';
export let idx = 0;
export let config = {};
let showManageModal = false;
let showConfigModal = false;
</script>
@ -33,6 +38,8 @@
}}
/>
<ManageOllamaModal bind:show={showManageModal} urlIdx={idx} />
<div class="flex gap-1.5">
<Tooltip
className="w-full relative"
@ -55,6 +62,18 @@
</Tooltip>
<div class="flex gap-1">
<Tooltip content={$i18n.t('Manage')} className="self-start">
<button
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
on:click={() => {
showManageModal = true;
}}
type="button"
>
<Wrench />
</button>
</Tooltip>
<Tooltip content={$i18n.t('Configure')} className="self-start">
<button
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"

View file

@ -19,7 +19,7 @@
} from '$lib/apis/retrieval';
import { knowledge, models } from '$lib/stores';
import { getKnowledgeItems } from '$lib/apis/knowledge';
import { getKnowledgeBases } from '$lib/apis/knowledge';
import { uploadDir, deleteAllFiles, deleteFileById } from '$lib/apis/files';
import ResetUploadDirConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
@ -312,7 +312,7 @@
{#if embeddingEngine === 'openai'}
<div class="my-0.5 flex gap-2">
<input
class="flex-1 w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-none"
placeholder={$i18n.t('API Base URL')}
bind:value={OpenAIUrl}
required
@ -376,19 +376,12 @@
{#if embeddingEngine === 'ollama'}
<div class="flex w-full">
<div class="flex-1 mr-2">
<select
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
bind:value={embeddingModel}
placeholder={$i18n.t('Select a model')}
placeholder={$i18n.t('Set embedding model')}
required
>
{#if !embeddingModel}
<option value="" disabled selected>{$i18n.t('Select a model')}</option>
{/if}
{#each $models.filter((m) => m.id && m.ollama && !(m?.preset ?? false)) as model}
<option value={model.id} class="bg-gray-50 dark:bg-gray-700">{model.name}</option>
{/each}
</select>
/>
</div>
</div>
{:else}

File diff suppressed because it is too large Load diff

View file

@ -1,214 +0,0 @@
<script lang="ts">
import { getBackendConfig, getModelFilterConfig, updateModelFilterConfig } from '$lib/apis';
import { getSignUpEnabledStatus, toggleSignUpEnabledStatus } from '$lib/apis/auths';
import { getUserPermissions, updateUserPermissions } from '$lib/apis/users';
import { onMount, getContext } from 'svelte';
import { models, config } from '$lib/stores';
import Switch from '$lib/components/common/Switch.svelte';
import { setDefaultModels } from '$lib/apis/configs';
const i18n = getContext('i18n');
export let saveHandler: Function;
let defaultModelId = '';
let whitelistEnabled = false;
let whitelistModels = [''];
let permissions = {
chat: {
deletion: true,
edit: true,
temporary: true
}
};
let chatDeletion = true;
let chatEdit = true;
let chatTemporary = true;
onMount(async () => {
permissions = await getUserPermissions(localStorage.token);
chatDeletion = permissions?.chat?.deletion ?? true;
chatEdit = permissions?.chat?.editing ?? true;
chatTemporary = permissions?.chat?.temporary ?? true;
const res = await getModelFilterConfig(localStorage.token);
if (res) {
whitelistEnabled = res.enabled;
whitelistModels = res.models.length > 0 ? res.models : [''];
}
defaultModelId = $config.default_models ? $config?.default_models.split(',')[0] : '';
});
</script>
<form
class="flex flex-col h-full justify-between space-y-3 text-sm"
on:submit|preventDefault={async () => {
// console.log('submit');
await setDefaultModels(localStorage.token, defaultModelId);
await updateUserPermissions(localStorage.token, {
chat: {
deletion: chatDeletion,
editing: chatEdit,
temporary: chatTemporary
}
});
await updateModelFilterConfig(localStorage.token, whitelistEnabled, whitelistModels);
saveHandler();
await config.set(await getBackendConfig());
}}
>
<div class=" space-y-3 overflow-y-scroll max-h-full pr-1.5">
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('User Permissions')}</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">{$i18n.t('Allow Chat Deletion')}</div>
<Switch bind:state={chatDeletion} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">{$i18n.t('Allow Chat Editing')}</div>
<Switch bind:state={chatEdit} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">{$i18n.t('Allow Temporary Chat')}</div>
<Switch bind:state={chatTemporary} />
</div>
</div>
<!-- <hr class=" border-gray-50 dark:border-gray-850 my-2" />
<div class="mt-2 space-y-3">
<div>
<div class="mb-2">
<div class="flex justify-between items-center text-xs">
<div class=" text-sm font-medium">{$i18n.t('Manage Models')}</div>
</div>
</div>
<div class=" space-y-1 mb-3">
<div class="mb-2">
<div class="flex justify-between items-center text-xs">
<div class=" text-xs font-medium">{$i18n.t('Default Model')}</div>
</div>
</div>
<div class="flex-1 mr-2">
<select
class="w-full bg-transparent outline-none py-0.5"
bind:value={defaultModelId}
placeholder="Select a model"
>
<option value="" disabled selected>{$i18n.t('Select a model')}</option>
{#each $models.filter((model) => model.id) as model}
<option value={model.id} class="bg-gray-100 dark:bg-gray-700">{model.name}</option>
{/each}
</select>
</div>
</div>
<div class=" space-y-1">
<div class="mb-2">
<div class="flex justify-between items-center text-xs my-3 pr-2">
<div class=" text-xs font-medium">{$i18n.t('Model Whitelisting')}</div>
<Switch bind:state={whitelistEnabled} />
</div>
</div>
{#if whitelistEnabled}
<div>
<div class=" space-y-1.5">
{#each whitelistModels as modelId, modelIdx}
<div class="flex w-full">
<div class="flex-1 mr-2">
<select
class="w-full bg-transparent outline-none py-0.5"
bind:value={modelId}
placeholder="Select a model"
>
<option value="" disabled selected>{$i18n.t('Select a model')}</option>
{#each $models.filter((model) => model.id) as model}
<option value={model.id} class="bg-gray-100 dark:bg-gray-700"
>{model.name}</option
>
{/each}
</select>
</div>
{#if modelIdx === 0}
<button
class="px-2.5 bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-900 dark:text-white rounded-lg transition"
type="button"
on:click={() => {
if (whitelistModels.at(-1) !== '') {
whitelistModels = [...whitelistModels, ''];
}
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z"
/>
</svg>
</button>
{:else}
<button
class="px-2.5 bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-900 dark:text-white rounded-lg transition"
type="button"
on:click={() => {
whitelistModels.splice(modelIdx, 1);
whitelistModels = whitelistModels;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path d="M3.75 7.25a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5h-8.5Z" />
</svg>
</button>
{/if}
</div>
{/each}
</div>
<div class="flex justify-end items-center text-xs mt-1.5 text-right">
<div class=" text-xs font-medium">
{whitelistModels.length}
{$i18n.t('Model(s) Whitelisted')}
</div>
</div>
</div>
{/if}
</div>
</div>
</div> -->
</div>
<div class="flex justify-end pt-3 text-sm font-medium">
<button
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-full"
type="submit"
>
{$i18n.t('Save')}
</button>
</div>
</form>

View file

@ -1,14 +1,38 @@
<script>
import { getContext, tick, onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import { user } from '$lib/stores';
import { getUsers } from '$lib/apis/users';
import UserList from './Users/UserList.svelte';
import Groups from './Users/Groups.svelte';
const i18n = getContext('i18n');
let selectedTab = 'overview';
let users = [];
let selectedTab = 'overview';
let loaded = false;
$: if (selectedTab) {
getUsersHandler();
}
const getUsersHandler = async () => {
users = await getUsers(localStorage.token);
};
onMount(async () => {
if ($user?.role !== 'admin') {
await goto('/');
} else {
users = await getUsers(localStorage.token);
}
loaded = true;
onMount(() => {
const containerElement = document.getElementById('users-tabs-container');
if (containerElement) {
@ -25,7 +49,7 @@
<div class="flex flex-col lg:flex-row w-full h-full -mt-0.5 pb-2 lg:space-x-4">
<div
id="users-tabs-container"
class="tabs flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-40 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
class=" flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-40 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
>
<button
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition {selectedTab ===
@ -78,9 +102,9 @@
<div class="flex-1 mt-1 lg:mt-0 overflow-y-scroll">
{#if selectedTab === 'overview'}
<UserList />
<UserList {users} />
{:else if selectedTab === 'groups'}
<Groups />
<Groups {users} />
{/if}
</div>
</div>

View file

@ -7,16 +7,30 @@
import { onMount, getContext } from 'svelte';
import { goto } from '$app/navigation';
import { WEBUI_NAME, config, user, showSidebar } from '$lib/stores';
import { WEBUI_NAME, config, user, showSidebar, knowledge } from '$lib/stores';
import { WEBUI_BASE_URL } from '$lib/constants';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import Badge from '$lib/components/common/Badge.svelte';
import UsersSolid from '$lib/components/icons/UsersSolid.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
import User from '$lib/components/icons/User.svelte';
import UserCircleSolid from '$lib/components/icons/UserCircleSolid.svelte';
import GroupModal from './Groups/EditGroupModal.svelte';
import Pencil from '$lib/components/icons/Pencil.svelte';
import GroupItem from './Groups/GroupItem.svelte';
import AddGroupModal from './Groups/AddGroupModal.svelte';
import { createNewGroup, getGroups } from '$lib/apis/groups';
import { getUserDefaultPermissions, updateUserDefaultPermissions } from '$lib/apis/users';
const i18n = getContext('i18n');
let loaded = false;
export let users = [];
let groups = [];
let filteredGroups;
@ -31,20 +45,69 @@
});
let search = '';
let defaultPermissions = {
workspace: {
models: false,
knowledge: false,
prompts: false,
tools: false
},
chat: {
file_upload: true,
delete: true,
edit: true,
temporary: true
}
};
let showCreateGroupModal = false;
let showDefaultPermissionsModal = false;
const setGroups = async () => {
groups = await getGroups(localStorage.token);
};
const addGroupHandler = async (group) => {
const res = await createNewGroup(localStorage.token, group).catch((error) => {
toast.error(error);
return null;
});
if (res) {
toast.success($i18n.t('Group created successfully'));
groups = await getGroups(localStorage.token);
}
};
const updateDefaultPermissionsHandler = async (group) => {
console.log(group.permissions);
const res = await updateUserDefaultPermissions(localStorage.token, group.permissions).catch(
(error) => {
toast.error(error);
return null;
}
);
if (res) {
toast.success($i18n.t('Default permissions updated successfully'));
defaultPermissions = await getUserDefaultPermissions(localStorage.token);
}
};
onMount(async () => {
if ($user?.role !== 'admin') {
await goto('/');
} else {
groups = [];
await setGroups();
defaultPermissions = await getUserDefaultPermissions(localStorage.token);
}
loaded = true;
});
</script>
{#if loaded}
<AddGroupModal bind:show={showCreateGroupModal} onSubmit={addGroupHandler} />
<div class="mt-0.5 mb-2 gap-1 flex flex-col md:flex-row justify-between">
<div class="flex md:self-center text-lg font-medium px-0.5">
{$i18n.t('Groups')}
@ -117,7 +180,58 @@
</div>
</div>
{:else}
<div></div>
<div>
<div class=" flex items-center gap-3 justify-between text-xs uppercase px-1 font-bold">
<div class="w-full">Group</div>
<div class="w-full">Users</div>
<div class="w-full"></div>
</div>
<hr class="mt-1.5 border-gray-50 dark:border-gray-850" />
{#each filteredGroups as group}
<div class="my-2">
<GroupItem {group} {users} {setGroups} />
</div>
{/each}
</div>
{/if}
<hr class="mb-2 border-gray-50 dark:border-gray-850" />
<GroupModal
bind:show={showDefaultPermissionsModal}
tabs={['permissions']}
bind:permissions={defaultPermissions}
custom={false}
onSubmit={updateDefaultPermissionsHandler}
/>
<button
class="flex items-center justify-between rounded-lg w-full transition pt-1"
on:click={() => {
showDefaultPermissionsModal = true;
}}
>
<div class="flex items-center gap-2.5">
<div class="p-1.5 bg-black/5 dark:bg-white/10 rounded-full">
<UsersSolid className="size-4" />
</div>
<div class="text-left">
<div class=" text-sm font-medium">{$i18n.t('Default permissions')}</div>
<div class="flex text-xs mt-0.5">
{$i18n.t('applies to all users with the "user" role')}
</div>
</div>
</div>
<div>
<ChevronRight strokeWidth="2.5" />
</div>
</button>
</div>
{/if}

View file

@ -0,0 +1,149 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import Modal from '$lib/components/common/Modal.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
export let onSubmit: Function = () => {};
export let show = false;
let name = '';
let description = '';
let userIds = [];
let loading = false;
const submitHandler = async () => {
loading = true;
const group = {
name,
description
};
await onSubmit(group);
loading = false;
show = false;
name = '';
description = '';
userIds = [];
};
onMount(() => {
console.log('mounted');
});
</script>
<Modal size="sm" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 mb-1.5">
<div class=" text-lg font-medium self-center font-primary">
{$i18n.t('Add User Group')}
</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
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 class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<form
class="flex flex-col w-full"
on:submit={(e) => {
e.preventDefault();
submitHandler();
}}
>
<div class="px-1 flex flex-col w-full">
<div class="flex gap-2">
<div class="flex flex-col w-full">
<div class=" mb-0.5 text-xs text-gray-500">{$i18n.t('Name')}</div>
<div class="flex-1">
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
type="text"
bind:value={name}
placeholder={$i18n.t('Group Name')}
autocomplete="off"
required
/>
</div>
</div>
</div>
<div class="flex flex-col w-full mt-2">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Description')}</div>
<div class="flex-1">
<Textarea
className="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none resize-none"
rows={2}
bind:value={description}
placeholder={$i18n.t('Group Description')}
/>
</div>
</div>
</div>
<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
<button
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-full flex flex-row space-x-1 items-center {loading
? ' cursor-not-allowed'
: ''}"
type="submit"
disabled={loading}
>
{$i18n.t('Create')}
{#if loading}
<div class="ml-2 self-center">
<svg
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
</div>
{/if}
</button>
</div>
</form>
</div>
</div>
</div>
</Modal>

View file

@ -0,0 +1,61 @@
<script lang="ts">
import { getContext } from 'svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
const i18n = getContext('i18n');
export let name = '';
export let color = '';
export let description = '';
</script>
<div class="flex gap-2">
<div class="flex flex-col w-full">
<div class=" mb-0.5 text-xs text-gray-500">{$i18n.t('Name')}</div>
<div class="flex-1">
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
type="text"
bind:value={name}
placeholder={$i18n.t('Group Name')}
autocomplete="off"
required
/>
</div>
</div>
</div>
<!-- <div class="flex flex-col w-full mt-2">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Color')}</div>
<div class="flex-1">
<Tooltip content={$i18n.t('Hex Color - Leave empty for default color')} placement="top-start">
<div class="flex gap-0.5">
<div class="text-gray-500">#</div>
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
type="text"
bind:value={color}
placeholder={$i18n.t('Hex Color')}
autocomplete="off"
/>
</div>
</Tooltip>
</div>
</div> -->
<div class="flex flex-col w-full mt-2">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Description')}</div>
<div class="flex-1">
<Textarea
className="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none resize-none"
rows={4}
bind:value={description}
placeholder={$i18n.t('Group Description')}
/>
</div>
</div>

View file

@ -0,0 +1,328 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import Modal from '$lib/components/common/Modal.svelte';
import Display from './Display.svelte';
import Permissions from './Permissions.svelte';
import Users from './Users.svelte';
import UserPlusSolid from '$lib/components/icons/UserPlusSolid.svelte';
import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
export let onSubmit: Function = () => {};
export let onDelete: Function = () => {};
export let show = false;
export let edit = false;
export let users = [];
export let group = null;
export let custom = true;
export let tabs = ['general', 'permissions', 'users'];
let selectedTab = 'general';
let loading = false;
export let name = '';
export let description = '';
export let permissions = {
workspace: {
models: false,
knowledge: false,
prompts: false,
tools: false
},
chat: {
file_upload: true,
delete: true,
edit: true,
temporary: true
}
};
export let userIds = [];
const submitHandler = async () => {
loading = true;
const group = {
name,
description,
permissions,
user_ids: userIds
};
await onSubmit(group);
loading = false;
show = false;
};
const init = () => {
if (group) {
name = group.name;
description = group.description;
permissions = group?.permissions ?? {
workspace: {
models: false,
knowledge: false,
prompts: false,
tools: false
},
chat: {
file_upload: true,
delete: true,
edit: true,
temporary: true
}
};
userIds = group?.user_ids ?? [];
}
};
$: if (show) {
init();
}
onMount(() => {
console.log(tabs);
selectedTab = tabs[0];
init();
});
</script>
<Modal size="md" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 mb-1.5">
<div class=" text-lg font-medium self-center font-primary">
{#if custom}
{#if edit}
{$i18n.t('Edit User Group')}
{:else}
{$i18n.t('Add User Group')}
{/if}
{:else}
{$i18n.t('Edit Default Permissions')}
{/if}
</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
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 class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<form
class="flex flex-col w-full"
on:submit={(e) => {
e.preventDefault();
submitHandler();
}}
>
<div class="flex flex-col lg:flex-row w-full h-full pb-2 lg:space-x-4">
<div
id="admin-settings-tabs-container"
class="tabs flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-40 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
>
{#if tabs.includes('general')}
<button
class="px-0.5 py-1 max-w-fit w-fit rounded-lg flex-1 lg:flex-none flex text-right transition {selectedTab ===
'general'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'general';
}}
type="button"
>
<div class=" self-center mr-2">
<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="M6.955 1.45A.5.5 0 0 1 7.452 1h1.096a.5.5 0 0 1 .497.45l.17 1.699c.484.12.94.312 1.356.562l1.321-1.081a.5.5 0 0 1 .67.033l.774.775a.5.5 0 0 1 .034.67l-1.08 1.32c.25.417.44.873.561 1.357l1.699.17a.5.5 0 0 1 .45.497v1.096a.5.5 0 0 1-.45.497l-1.699.17c-.12.484-.312.94-.562 1.356l1.082 1.322a.5.5 0 0 1-.034.67l-.774.774a.5.5 0 0 1-.67.033l-1.322-1.08c-.416.25-.872.44-1.356.561l-.17 1.699a.5.5 0 0 1-.497.45H7.452a.5.5 0 0 1-.497-.45l-.17-1.699a4.973 4.973 0 0 1-1.356-.562L4.108 13.37a.5.5 0 0 1-.67-.033l-.774-.775a.5.5 0 0 1-.034-.67l1.08-1.32a4.971 4.971 0 0 1-.561-1.357l-1.699-.17A.5.5 0 0 1 1 8.548V7.452a.5.5 0 0 1 .45-.497l1.699-.17c.12-.484.312-.94.562-1.356L2.629 4.107a.5.5 0 0 1 .034-.67l.774-.774a.5.5 0 0 1 .67-.033L5.43 3.71a4.97 4.97 0 0 1 1.356-.561l.17-1.699ZM6 8c0 .538.212 1.026.558 1.385l.057.057a2 2 0 0 0 2.828-2.828l-.058-.056A2 2 0 0 0 6 8Z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center">{$i18n.t('General')}</div>
</button>
{/if}
{#if tabs.includes('permissions')}
<button
class="px-0.5 py-1 max-w-fit w-fit rounded-lg flex-1 lg:flex-none flex text-right transition {selectedTab ===
'permissions'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'permissions';
}}
type="button"
>
<div class=" self-center mr-2">
<WrenchSolid />
</div>
<div class=" self-center">{$i18n.t('Permissions')}</div>
</button>
{/if}
{#if tabs.includes('users')}
<button
class="px-0.5 py-1 max-w-fit w-fit rounded-lg flex-1 lg:flex-none flex text-right transition {selectedTab ===
'users'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'users';
}}
type="button"
>
<div class=" self-center mr-2">
<UserPlusSolid />
</div>
<div class=" self-center">{$i18n.t('Users')} ({userIds.length})</div>
</button>
{/if}
</div>
<div
class="flex-1 mt-1 lg:mt-1 lg:h-[22rem] lg:max-h-[22rem] overflow-y-auto scrollbar-hidden"
>
{#if selectedTab == 'general'}
<Display bind:name bind:description />
{:else if selectedTab == 'permissions'}
<Permissions bind:permissions />
{:else if selectedTab == 'users'}
<Users bind:userIds {users} />
{/if}
</div>
</div>
<!-- <div
class=" tabs flex flex-row overflow-x-auto gap-2.5 text-sm font-medium border-b border-b-gray-800 scrollbar-hidden"
>
{#if tabs.includes('display')}
<button
class="px-0.5 pb-1.5 min-w-fit flex text-right transition border-b-2 {selectedTab ===
'display'
? ' dark:border-white'
: 'border-transparent text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'display';
}}
type="button"
>
{$i18n.t('Display')}
</button>
{/if}
{#if tabs.includes('permissions')}
<button
class="px-0.5 pb-1.5 min-w-fit flex text-right transition border-b-2 {selectedTab ===
'permissions'
? ' dark:border-white'
: 'border-transparent text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'permissions';
}}
type="button"
>
{$i18n.t('Permissions')}
</button>
{/if}
{#if tabs.includes('users')}
<button
class="px-0.5 pb-1.5 min-w-fit flex text-right transition border-b-2 {selectedTab ===
'users'
? ' dark:border-white'
: ' border-transparent text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'users';
}}
type="button"
>
{$i18n.t('Users')} ({userIds.length})
</button>
{/if}
</div> -->
<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
{#if edit}
<button
class="px-3.5 py-1.5 text-sm font-medium dark:bg-black dark:hover:bg-gray-900 dark:text-white bg-white text-black hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
type="button"
on:click={() => {
onDelete();
show = false;
}}
>
{$i18n.t('Delete')}
</button>
{/if}
<button
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-full flex flex-row space-x-1 items-center {loading
? ' cursor-not-allowed'
: ''}"
type="submit"
disabled={loading}
>
{$i18n.t('Save')}
{#if loading}
<div class="ml-2 self-center">
<svg
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
</div>
{/if}
</button>
</div>
</form>
</div>
</div>
</div>
</Modal>

View file

@ -0,0 +1,84 @@
<script>
import { toast } from 'svelte-sonner';
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import { deleteGroupById, updateGroupById } from '$lib/apis/groups';
import Pencil from '$lib/components/icons/Pencil.svelte';
import User from '$lib/components/icons/User.svelte';
import UserCircleSolid from '$lib/components/icons/UserCircleSolid.svelte';
import GroupModal from './EditGroupModal.svelte';
export let users = [];
export let group = {
name: 'Admins',
user_ids: [1, 2, 3]
};
export let setGroups = () => {};
let showEdit = false;
const updateHandler = async (_group) => {
const res = await updateGroupById(localStorage.token, group.id, _group).catch((error) => {
toast.error(error);
return null;
});
if (res) {
toast.success($i18n.t('Group updated successfully'));
setGroups();
}
};
const deleteHandler = async () => {
const res = await deleteGroupById(localStorage.token, group.id).catch((error) => {
toast.error(error);
return null;
});
if (res) {
toast.success($i18n.t('Group deleted successfully'));
setGroups();
}
};
</script>
<GroupModal
bind:show={showEdit}
edit
{users}
{group}
onSubmit={updateHandler}
onDelete={deleteHandler}
/>
<div class="flex items-center gap-3 justify-between px-1 text-xs w-full transition">
<div class="flex items-center gap-1.5 w-full font-medium">
<div>
<UserCircleSolid className="size-4" />
</div>
{group.name}
</div>
<div class="flex items-center gap-1.5 w-full font-medium">
{group.user_ids.length}
<div>
<User className="size-3.5" />
</div>
</div>
<div class="w-full flex justify-end">
<button
class=" rounded-lg p-1 hover:bg-gray-100 dark:hover:bg-gray-850 transition"
on:click={() => {
showEdit = true;
}}
>
<Pencil className="size-3.5" />
</button>
</div>
</div>

View file

@ -0,0 +1,204 @@
<script lang="ts">
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import Switch from '$lib/components/common/Switch.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
export let permissions = {
workspace: {
models: false,
knowledge: false,
prompts: false,
tools: false
},
chat: {
delete: true,
edit: true,
temporary: true,
file_upload: true
}
};
</script>
<div>
<!-- <div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Model Permissions')}</div>
<div class="mb-2">
<div class="flex justify-between items-center text-xs pr-2">
<div class=" text-xs font-medium">{$i18n.t('Model Filtering')}</div>
<Switch bind:state={permissions.model.filter} />
</div>
</div>
{#if permissions.model.filter}
<div class="mb-2">
<div class=" space-y-1.5">
<div class="flex flex-col w-full">
<div class="mb-1 flex justify-between">
<div class="text-xs text-gray-500">{$i18n.t('Model IDs')}</div>
</div>
{#if model_ids.length > 0}
<div class="flex flex-col">
{#each model_ids as modelId, modelIdx}
<div class=" flex gap-2 w-full justify-between items-center">
<div class=" text-sm flex-1 rounded-lg">
{modelId}
</div>
<div class="flex-shrink-0">
<button
type="button"
on:click={() => {
model_ids = model_ids.filter((_, idx) => idx !== modelIdx);
}}
>
<Minus strokeWidth="2" className="size-3.5" />
</button>
</div>
</div>
{/each}
</div>
{:else}
<div class="text-gray-500 text-xs text-center py-2 px-10">
{$i18n.t('No model IDs')}
</div>
{/if}
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-700/10 mt-2.5 mb-1 w-full" />
<div class="flex items-center">
<select
class="w-full py-1 text-sm rounded-lg bg-transparent {selectedModelId
? ''
: 'text-gray-500'} placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
bind:value={selectedModelId}
>
<option value="">{$i18n.t('Select a model')}</option>
{#each $models.filter((m) => m?.owned_by !== 'arena') as model}
<option value={model.id} class="bg-gray-50 dark:bg-gray-700">{model.name}</option>
{/each}
</select>
<div>
<button
type="button"
on:click={() => {
if (selectedModelId && !permissions.model.model_ids.includes(selectedModelId)) {
permissions.model.model_ids = [...permissions.model.model_ids, selectedModelId];
selectedModelId = '';
}
}}
>
<Plus className="size-3.5" strokeWidth="2" />
</button>
</div>
</div>
</div>
{/if}
<div class=" space-y-1 mb-3">
<div class="">
<div class="flex justify-between items-center text-xs">
<div class=" text-xs font-medium">{$i18n.t('Default Model')}</div>
</div>
</div>
<div class="flex-1 mr-2">
<select
class="w-full bg-transparent outline-none py-0.5 text-sm"
bind:value={permissions.model.default_id}
placeholder="Select a model"
>
<option value="" disabled selected>{$i18n.t('Select a model')}</option>
{#each permissions.model.filter ? $models.filter( (model) => filterModelIds.includes(model.id) ) : $models.filter((model) => model.id) as model}
<option value={model.id} class="bg-gray-100 dark:bg-gray-700">{model.name}</option>
{/each}
</select>
</div>
</div>
</div>
<hr class=" border-gray-50 dark:border-gray-850 my-2" /> -->
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Workspace Permissions')}</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Models Access')}
</div>
<Switch bind:state={permissions.workspace.models} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Knowledge Access')}
</div>
<Switch bind:state={permissions.workspace.knowledge} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Prompts Access')}
</div>
<Switch bind:state={permissions.workspace.prompts} />
</div>
<div class=" ">
<Tooltip
className=" flex w-full justify-between my-2 pr-2"
content={$i18n.t(
'Warning: Enabling this will allow users to upload arbitrary code on the server.'
)}
placement="top-start"
>
<div class=" self-center text-xs font-medium">
{$i18n.t('Tools Access')}
</div>
<Switch bind:state={permissions.workspace.tools} />
</Tooltip>
</div>
</div>
<hr class=" border-gray-50 dark:border-gray-850 my-2" />
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Chat Permissions')}</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow File Upload')}
</div>
<Switch bind:state={permissions.chat.file_upload} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Delete')}
</div>
<Switch bind:state={permissions.chat.delete} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Edit')}
</div>
<Switch bind:state={permissions.chat.edit} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Temporary Chat')}
</div>
<Switch bind:state={permissions.chat.temporary} />
</div>
</div>
</div>

View file

@ -0,0 +1,122 @@
<script lang="ts">
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import { WEBUI_BASE_URL } from '$lib/constants';
import Checkbox from '$lib/components/common/Checkbox.svelte';
import Badge from '$lib/components/common/Badge.svelte';
export let users = [];
export let userIds = [];
let filteredUsers = [];
$: filteredUsers = users
.filter((user) => {
if (user?.role === 'admin') {
return false;
}
if (query === '') {
return true;
}
return (
user.name.toLowerCase().includes(query.toLowerCase()) ||
user.email.toLowerCase().includes(query.toLowerCase())
);
})
.sort((a, b) => {
const aUserIndex = userIds.indexOf(a.id);
const bUserIndex = userIds.indexOf(b.id);
// Compare based on userIds or fall back to alphabetical order
if (aUserIndex !== -1 && bUserIndex === -1) return -1; // 'a' has valid userId -> prioritize
if (bUserIndex !== -1 && aUserIndex === -1) return 1; // 'b' has valid userId -> prioritize
// Both a and b are either in the userIds array or not, so we'll sort them by their indices
if (aUserIndex !== -1 && bUserIndex !== -1) return aUserIndex - bUserIndex;
// If both are not in the userIds, fallback to alphabetical sorting by name
return a.name.localeCompare(b.name);
});
let query = '';
</script>
<div>
<div class="flex w-full">
<div class="flex flex-1">
<div class=" self-center 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 rounded-r-xl outline-none bg-transparent"
bind:value={query}
placeholder={$i18n.t('Search')}
/>
</div>
</div>
<div class="mt-3 max-h-[22rem] overflow-y-auto scrollbar-hidden">
<div class="flex flex-col gap-2.5">
{#if filteredUsers.length > 0}
{#each filteredUsers as user, userIdx (user.id)}
<div class="flex flex-row items-center gap-3 w-full text-sm">
<div class="flex items-center">
<Checkbox
state={userIds.includes(user.id) ? 'checked' : 'unchecked'}
on:change={(e) => {
if (e.detail === 'checked') {
userIds = [...userIds, user.id];
} else {
userIds = userIds.filter((id) => id !== user.id);
}
}}
/>
</div>
<div class="flex w-full items-center justify-between">
<Tooltip content={user.email} placement="top-start">
<div class="flex">
<img
class=" rounded-full size-5 object-cover mr-2.5"
src={user.profile_image_url.startsWith(WEBUI_BASE_URL) ||
user.profile_image_url.startsWith('https://www.gravatar.com/avatar/') ||
user.profile_image_url.startsWith('data:')
? user.profile_image_url
: `/user.png`}
alt="user"
/>
<div class=" font-medium self-center">{user.name}</div>
</div>
</Tooltip>
{#if userIds.includes(user.id)}
<Badge type="success" content="member" />
{/if}
</div>
</div>
{/each}
{:else}
<div class="text-gray-500 text-xs text-center py-2 px-10">
{$i18n.t('No users were found.')}
</div>
{/if}
</div>
</div>
</div>

View file

@ -29,9 +29,7 @@
const i18n = getContext('i18n');
let loaded = false;
let tab = '';
let users = [];
export let users = [];
let search = '';
let selectedUser = null;
@ -65,14 +63,6 @@
}
};
onMount(async () => {
if ($user?.role !== 'admin') {
await goto('/');
} else {
users = await getUsers(localStorage.token);
}
loaded = true;
});
let sortKey = 'created_at'; // default sort key
let sortOrder = 'asc'; // default sort order
@ -131,278 +121,301 @@
/>
<UserChatsModal bind:show={showUserChatsModal} user={selectedUser} />
{#if loaded}
<div class="mt-0.5 mb-2 gap-1 flex flex-col md:flex-row justify-between">
<div class="flex md:self-center text-lg font-medium px-0.5">
{$i18n.t('Users')}
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
<div class="mt-0.5 mb-2 gap-1 flex flex-col md:flex-row justify-between">
<div class="flex md:self-center text-lg font-medium px-0.5">
{$i18n.t('Users')}
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
<span class="text-lg font-medium text-gray-500 dark:text-gray-300">{users.length}</span>
</div>
<span class="text-lg font-medium text-gray-500 dark:text-gray-300">{users.length}</span>
</div>
<div class="flex gap-1">
<div class=" flex w-full space-x-2">
<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-none bg-transparent"
bind:value={search}
placeholder={$i18n.t('Search')}
/>
<div class="flex gap-1">
<div class=" flex w-full space-x-2">
<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-none bg-transparent"
bind:value={search}
placeholder={$i18n.t('Search')}
/>
</div>
<div>
<Tooltip content={$i18n.t('Add User')}>
<button
class=" p-2 rounded-xl hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 transition font-medium text-sm flex items-center space-x-1"
on:click={() => {
showAddUserModal = !showAddUserModal;
}}
>
<Plus className="size-3.5" />
</button>
</Tooltip>
</div>
<div>
<Tooltip content={$i18n.t('Add User')}>
<button
class=" p-2 rounded-xl hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 transition font-medium text-sm flex items-center space-x-1"
on:click={() => {
showAddUserModal = !showAddUserModal;
}}
>
<Plus className="size-3.5" />
</button>
</Tooltip>
</div>
</div>
</div>
</div>
<div
class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded pt-0.5"
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded pt-0.5">
<table
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full rounded"
>
<table
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full rounded"
<thead
class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400 -translate-y-0.5"
>
<thead
class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400 -translate-y-0.5"
>
<tr class="">
<th
scope="col"
class="px-3 py-1.5 cursor-pointer select-none"
on:click={() => setSortKey('role')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Role')}
<tr class="">
<th
scope="col"
class="px-3 py-1.5 cursor-pointer select-none"
on:click={() => setSortKey('role')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Role')}
{#if sortKey === 'role'}
<span class="font-normal"
>{#if sortOrder === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
{#if sortKey === 'role'}
<span class="font-normal"
>{#if sortOrder === 'asc'}
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th
scope="col"
class="px-3 py-1.5 cursor-pointer select-none"
on:click={() => setSortKey('name')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Name')}
{#if sortKey === 'name'}
<span class="font-normal"
>{#if sortOrder === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th
scope="col"
class="px-3 py-1.5 cursor-pointer select-none"
on:click={() => setSortKey('email')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Email')}
{#if sortKey === 'email'}
<span class="font-normal"
>{#if sortOrder === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th
scope="col"
class="px-3 py-1.5 cursor-pointer select-none"
on:click={() => setSortKey('last_active_at')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Last Active')}
{#if sortKey === 'last_active_at'}
<span class="font-normal"
>{#if sortOrder === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th
scope="col"
class="px-3 py-1.5 cursor-pointer select-none"
on:click={() => setSortKey('created_at')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Created at')}
{#if sortKey === 'created_at'}
<span class="font-normal"
>{#if sortOrder === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th
scope="col"
class="px-3 py-1.5 cursor-pointer select-none"
on:click={() => setSortKey('oauth_sub')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('OAuth ID')}
{#if sortKey === 'oauth_sub'}
<span class="font-normal"
>{#if sortOrder === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th scope="col" class="px-3 py-2 text-right" />
</tr>
</thead>
<tbody class="">
{#each filteredUsers as user, userIdx}
<tr class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs">
<td class="px-3 py-1 min-w-[7rem] w-28">
<button
class=" translate-y-0.5"
on:click={() => {
if (user.role === 'user') {
updateRoleHandler(user.id, 'admin');
} else if (user.role === 'pending') {
updateRoleHandler(user.id, 'user');
} else {
updateRoleHandler(user.id, 'pending');
}
}}
>
<Badge
type={user.role === 'admin' ? 'info' : user.role === 'user' ? 'success' : 'muted'}
content={$i18n.t(user.role)}
/>
</button>
</td>
<td class="px-3 py-1 font-medium text-gray-900 dark:text-white w-max">
<div class="flex flex-row w-max">
<img
class=" rounded-full w-6 h-6 object-cover mr-2.5"
src={user.profile_image_url.startsWith(WEBUI_BASE_URL) ||
user.profile_image_url.startsWith('https://www.gravatar.com/avatar/') ||
user.profile_image_url.startsWith('data:')
? user.profile_image_url
: `/user.png`}
alt="user"
/>
<div class=" font-medium self-center">{user.name}</div>
</div>
</td>
<td class=" px-3 py-1"> {user.email} </td>
<td class=" px-3 py-1">
{dayjs(user.last_active_at * 1000).fromNow()}
</td>
<td class=" px-3 py-1">
{dayjs(user.created_at * 1000).format($i18n.t('MMMM DD, YYYY'))}
</td>
<td class=" px-3 py-1"> {user.oauth_sub ?? ''} </td>
<td class="px-3 py-1 text-right">
<div class="flex justify-end w-full">
{#if $config.features.enable_admin_chat_access && user.role !== 'admin'}
<Tooltip content={$i18n.t('Chats')}>
<button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
on:click={async () => {
showUserChatsModal = !showUserChatsModal;
selectedUser = user;
}}
>
<ChatBubbles />
</button>
</Tooltip>
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th
scope="col"
class="px-3 py-1.5 cursor-pointer select-none"
on:click={() => setSortKey('name')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Name')}
<Tooltip content={$i18n.t('Edit User')}>
{#if sortKey === 'name'}
<span class="font-normal"
>{#if sortOrder === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th
scope="col"
class="px-3 py-1.5 cursor-pointer select-none"
on:click={() => setSortKey('email')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Email')}
{#if sortKey === 'email'}
<span class="font-normal"
>{#if sortOrder === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th
scope="col"
class="px-3 py-1.5 cursor-pointer select-none"
on:click={() => setSortKey('last_active_at')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Last Active')}
{#if sortKey === 'last_active_at'}
<span class="font-normal"
>{#if sortOrder === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th
scope="col"
class="px-3 py-1.5 cursor-pointer select-none"
on:click={() => setSortKey('created_at')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Created at')}
{#if sortKey === 'created_at'}
<span class="font-normal"
>{#if sortOrder === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th
scope="col"
class="px-3 py-1.5 cursor-pointer select-none"
on:click={() => setSortKey('oauth_sub')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('OAuth ID')}
{#if sortKey === 'oauth_sub'}
<span class="font-normal"
>{#if sortOrder === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th scope="col" class="px-3 py-2 text-right" />
</tr>
</thead>
<tbody class="">
{#each filteredUsers as user, userIdx}
<tr class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs">
<td class="px-3 py-1 min-w-[7rem] w-28">
<button
class=" translate-y-0.5"
on:click={() => {
if (user.role === 'user') {
updateRoleHandler(user.id, 'admin');
} else if (user.role === 'pending') {
updateRoleHandler(user.id, 'user');
} else {
updateRoleHandler(user.id, 'pending');
}
}}
>
<Badge
type={user.role === 'admin' ? 'info' : user.role === 'user' ? 'success' : 'muted'}
content={$i18n.t(user.role)}
/>
</button>
</td>
<td class="px-3 py-1 font-medium text-gray-900 dark:text-white w-max">
<div class="flex flex-row w-max">
<img
class=" rounded-full w-6 h-6 object-cover mr-2.5"
src={user.profile_image_url.startsWith(WEBUI_BASE_URL) ||
user.profile_image_url.startsWith('https://www.gravatar.com/avatar/') ||
user.profile_image_url.startsWith('data:')
? user.profile_image_url
: `/user.png`}
alt="user"
/>
<div class=" font-medium self-center">{user.name}</div>
</div>
</td>
<td class=" px-3 py-1"> {user.email} </td>
<td class=" px-3 py-1">
{dayjs(user.last_active_at * 1000).fromNow()}
</td>
<td class=" px-3 py-1">
{dayjs(user.created_at * 1000).format($i18n.t('MMMM DD, YYYY'))}
</td>
<td class=" px-3 py-1"> {user.oauth_sub ?? ''} </td>
<td class="px-3 py-1 text-right">
<div class="flex justify-end w-full">
{#if $config.features.enable_admin_chat_access && user.role !== 'admin'}
<Tooltip content={$i18n.t('Chats')}>
<button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
on:click={async () => {
showEditUserModal = !showEditUserModal;
showUserChatsModal = !showUserChatsModal;
selectedUser = user;
}}
>
<ChatBubbles />
</button>
</Tooltip>
{/if}
<Tooltip content={$i18n.t('Edit User')}>
<button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
on:click={async () => {
showEditUserModal = !showEditUserModal;
selectedUser = user;
}}
>
<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>
{#if user.role !== 'admin'}
<Tooltip content={$i18n.t('Delete User')}>
<button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
on:click={async () => {
showDeleteConfirmDialog = true;
selectedUser = user;
}}
>
@ -417,49 +430,22 @@
<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"
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>
</button>
</Tooltip>
{/if}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if user.role !== 'admin'}
<Tooltip content={$i18n.t('Delete User')}>
<button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
on:click={async () => {
showDeleteConfirmDialog = true;
selectedUser = user;
}}
>
<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>
</button>
</Tooltip>
{/if}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div class=" text-gray-500 text-xs mt-1.5 text-right">
{$i18n.t("Click on the user role button to change a user's role.")}
</div>
<div class=" text-gray-500 text-xs mt-1.5 text-right">
{$i18n.t("Click on the user role button to change a user's role.")}
</div>
<Pagination bind:page count={users.length} />
{/if}
<Pagination bind:page count={users.length} />

View file

@ -34,7 +34,8 @@
mobile,
showOverview,
chatTitle,
showArtifacts
showArtifacts,
tools
} from '$lib/stores';
import {
convertMessagesToHistory,
@ -78,6 +79,7 @@
import ChatControls from './ChatControls.svelte';
import EventConfirmDialog from '../common/ConfirmDialog.svelte';
import Placeholder from './Placeholder.svelte';
import { getTools } from '$lib/apis/tools';
export let chatIdProp = '';
@ -153,6 +155,26 @@
console.log('saveSessionSelectedModels', selectedModels, sessionStorage.selectedModels);
};
$: if (selectedModels) {
setToolIds();
}
const setToolIds = async () => {
if (!$tools) {
tools.set(await getTools(localStorage.token));
}
if (selectedModels.length !== 1) {
return;
}
const model = $models.find((m) => m.id === selectedModels[0]);
if (model) {
selectedToolIds = (model?.info?.meta?.toolIds ?? []).filter((id) =>
$tools.find((t) => t.id === id)
);
}
};
const showMessage = async (message) => {
const _chatId = JSON.parse(JSON.stringify($chatId));
let _messageId = JSON.parse(JSON.stringify(message.id));
@ -480,8 +502,6 @@
}
}
console.log(selectedModels);
await showControls.set(false);
await showCallOverlay.set(false);
await showOverview.set(false);
@ -815,9 +835,12 @@
console.log('submitPrompt', userPrompt, $chatId);
const messages = createMessagesList(history.currentId);
selectedModels = selectedModels.map((modelId) =>
const _selectedModels = selectedModels.map((modelId) =>
$models.map((m) => m.id).includes(modelId) ? modelId : ''
);
if (JSON.stringify(selectedModels) !== JSON.stringify(_selectedModels)) {
selectedModels = _selectedModels;
}
if (userPrompt === '') {
toast.error($i18n.t('Please enter a prompt'));
@ -2267,13 +2290,6 @@
bind:selectedToolIds
bind:webSearchEnabled
bind:atSelectedModel
availableToolIds={selectedModelIds.reduce((a, e, i, arr) => {
const model = $models.find((m) => m.id === e);
if (model?.info?.meta?.toolIds ?? false) {
return [...new Set([...a, ...model.info.meta.toolIds])];
}
return a;
}, [])}
transparentBackground={$settings?.backgroundImageUrl ?? false}
{stopResponse}
{createMessagePair}
@ -2311,13 +2327,6 @@
bind:selectedToolIds
bind:webSearchEnabled
bind:atSelectedModel
availableToolIds={selectedModelIds.reduce((a, e, i, arr) => {
const model = $models.find((m) => m.id === e);
if (model?.info?.meta?.toolIds ?? false) {
return [...new Set([...a, ...model.info.meta.toolIds])];
}
return a;
}, [])}
transparentBackground={$settings?.backgroundImageUrl ?? false}
{stopResponse}
{createMessagePair}

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,7 @@
import { removeLastWordFromString } from '$lib/utils';
import { getPrompts } from '$lib/apis/prompts';
import { getKnowledgeItems } from '$lib/apis/knowledge';
import { getKnowledgeBases } from '$lib/apis/knowledge';
import Prompts from './Commands/Prompts.svelte';
import Knowledge from './Commands/Knowledge.svelte';
@ -46,7 +46,7 @@
prompts.set(await getPrompts(localStorage.token));
})(),
(async () => {
knowledge.set(await getKnowledgeItems(localStorage.token));
knowledge.set(await getKnowledgeBases(localStorage.token));
})()
]);
loading = false;

View file

@ -1,9 +1,10 @@
<script lang="ts">
import { DropdownMenu } from 'bits-ui';
import { flyAndScale } from '$lib/utils/transitions';
import { getContext, onMount } from 'svelte';
import { getContext, onMount, tick } from 'svelte';
import { config, user, tools as _tools } from '$lib/stores';
import { getTools } from '$lib/apis/tools';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
@ -11,17 +12,13 @@
import Switch from '$lib/components/common/Switch.svelte';
import GlobeAltSolid from '$lib/components/icons/GlobeAltSolid.svelte';
import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
import { getTools } from '$lib/apis/tools';
const i18n = getContext('i18n');
export let uploadFilesHandler: Function;
export let availableToolIds: string[] = [];
export let selectedToolIds: string[] = [];
export let webSearchEnabled: boolean;
export let onClose: Function;
let tools = {};
@ -31,24 +28,17 @@
init();
}
$: if (tools) {
selectedToolIds = Object.keys(tools).filter((toolId) => tools[toolId]?.enabled ?? false);
}
const init = async () => {
console.log('init');
if ($_tools === null) {
await _tools.set(await getTools(localStorage.token));
}
tools = $_tools.reduce((a, tool, i, arr) => {
if (availableToolIds.includes(tool.id) || ($user?.role ?? 'user') === 'admin') {
a[tool.id] = {
name: tool.name,
description: tool.meta.description,
enabled: selectedToolIds.includes(tool.id)
};
}
a[tool.id] = {
name: tool.name,
description: tool.meta.description,
enabled: selectedToolIds.includes(tool.id)
};
return a;
}, {});
};
@ -97,7 +87,18 @@
</div>
<div class=" flex-shrink-0">
<Switch state={tools[toolId].enabled} />
<Switch
state={tools[toolId].enabled}
on:change={async (e) => {
const state = e.detail;
await tick();
if (state) {
selectedToolIds = [...selectedToolIds, toolId];
} else {
selectedToolIds = selectedToolIds.filter((id) => id !== toolId);
}
}}
/>
</div>
</button>
{/each}

View file

@ -729,7 +729,7 @@
{#if message.done}
{#if !readOnly}
{#if $user.role === 'user' ? ($config?.permissions?.chat?.editing ?? true) : true}
{#if $user.role === 'user' ? ($user?.permissions?.chat?.edit ?? true) : true}
<Tooltip content={$i18n.t('Edit')} placement="bottom">
<button
class="{isLastMessage
@ -1125,19 +1125,17 @@
showRateComment = false;
regenerateResponse(message);
(model?.actions ?? [])
.filter((action) => action?.__webui__ ?? false)
.forEach((action) => {
dispatch('action', {
id: action.id,
event: {
id: 'regenerate-response',
data: {
messageId: message.id
}
(model?.actions ?? []).forEach((action) => {
dispatch('action', {
id: action.id,
event: {
id: 'regenerate-response',
data: {
messageId: message.id
}
});
}
});
});
}}
>
<svg

View file

@ -48,7 +48,7 @@
model: model
}))}
showTemporaryChatControl={$user.role === 'user'
? ($config?.permissions?.chat?.temporary ?? true)
? ($user?.permissions?.chat?.temporary ?? true)
: true}
bind:value={selectedModel}
/>

View file

@ -55,17 +55,15 @@
let selectedModelIdx = 0;
const fuse = new Fuse(
items
.filter((item) => !item.model?.info?.meta?.hidden)
.map((item) => {
const _item = {
...item,
modelName: item.model?.name,
tags: item.model?.info?.meta?.tags?.map((tag) => tag.name).join(' '),
desc: item.model?.info?.meta?.description
};
return _item;
}),
items.map((item) => {
const _item = {
...item,
modelName: item.model?.name,
tags: item.model?.info?.meta?.tags?.map((tag) => tag.name).join(' '),
desc: item.model?.info?.meta?.description
};
return _item;
}),
{
keys: ['value', 'tags', 'modelName'],
threshold: 0.3
@ -76,7 +74,7 @@
? fuse.search(searchValue).map((e) => {
return e.item;
})
: items.filter((item) => !item.model?.info?.meta?.hidden);
: items;
const pullModelHandler = async () => {
const sanitizedModelTag = searchValue.trim().replace(/^ollama\s+(run|pull)\s+/, '');
@ -583,14 +581,3 @@
</slot>
</DropdownMenu.Content>
</DropdownMenu.Root>
<style>
.scrollbar-hidden:active::-webkit-scrollbar-thumb,
.scrollbar-hidden:focus::-webkit-scrollbar-thumb,
.scrollbar-hidden:hover::-webkit-scrollbar-thumb {
visibility: visible;
}
.scrollbar-hidden::-webkit-scrollbar-thumb {
visibility: hidden;
}
</style>

View file

@ -32,7 +32,7 @@
export let prompt = '';
export let files = [];
export let availableToolIds = [];
export let selectedToolIds = [];
export let webSearchEnabled = false;
@ -200,7 +200,6 @@
bind:selectedToolIds
bind:webSearchEnabled
bind:atSelectedModel
{availableToolIds}
{transparentBackground}
{stopResponse}
{createMessagePair}

View file

@ -4,14 +4,12 @@
export let state = true;
const dispatch = createEventDispatcher();
$: dispatch('change', state);
</script>
<Switch.Root
bind:checked={state}
onCheckedChange={async (e) => {
await tick();
dispatch('change', e);
}}
class="flex h-5 min-h-5 w-9 shrink-0 cursor-pointer items-center rounded-full px-[3px] mx-[1px] transition {state
? ' bg-emerald-600'
: 'bg-gray-200 dark:bg-transparent'} outline outline-1 outline-gray-100 dark:outline-gray-800"

View file

@ -0,0 +1,19 @@
<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
stroke-linecap="round"
stroke-linejoin="round"
d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z"
/>
</svg>

View file

@ -0,0 +1,11 @@
<script lang="ts">
export let className = 'size-4';
</script>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class={className}>
<path
fill-rule="evenodd"
d="M15 8A7 7 0 1 1 1 8a7 7 0 0 1 14 0Zm-5-2a2 2 0 1 1-4 0 2 2 0 0 1 4 0ZM8 9c-1.825 0-3.422.977-4.295 2.437A5.49 5.49 0 0 0 8 13.5a5.49 5.49 0 0 0 4.294-2.063A4.997 4.997 0 0 0 8 9Z"
clip-rule="evenodd"
/>
</svg>

View file

@ -0,0 +1,9 @@
<script lang="ts">
export let className = 'size-4';
</script>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class={className}>
<path
d="M10 5a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM1.615 16.428a1.224 1.224 0 0 1-.569-1.175 6.002 6.002 0 0 1 11.908 0c.058.467-.172.92-.57 1.174A9.953 9.953 0 0 1 7 18a9.953 9.953 0 0 1-5.385-1.572ZM16.25 5.75a.75.75 0 0 0-1.5 0v2h-2a.75.75 0 0 0 0 1.5h2v2a.75.75 0 0 0 1.5 0v-2h2a.75.75 0 0 0 0-1.5h-2v-2Z"
/>
</svg>

View file

@ -0,0 +1,9 @@
<script lang="ts">
export let className = 'size-4';
</script>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class={className}>
<path
d="M4.5 6.375a4.125 4.125 0 1 1 8.25 0 4.125 4.125 0 0 1-8.25 0ZM14.25 8.625a3.375 3.375 0 1 1 6.75 0 3.375 3.375 0 0 1-6.75 0ZM1.5 19.125a7.125 7.125 0 0 1 14.25 0v.003l-.001.119a.75.75 0 0 1-.363.63 13.067 13.067 0 0 1-6.761 1.873c-2.472 0-4.786-.684-6.76-1.873a.75.75 0 0 1-.364-.63l-.001-.122ZM17.25 19.128l-.001.144a2.25 2.25 0 0 1-.233.96 10.088 10.088 0 0 0 5.06-1.01.75.75 0 0 0 .42-.643 4.875 4.875 0 0 0-6.957-4.611 8.586 8.586 0 0 1 1.71 5.157v.003Z"
/>
</svg>

View file

@ -0,0 +1,20 @@
<script lang="ts">
export let className = 'w-4 h-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
stroke-linecap="round"
stroke-linejoin="round"
d="M21.75 6.75a4.5 4.5 0 0 1-4.884 4.484c-1.076-.091-2.264.071-2.95.904l-7.152 8.684a2.548 2.548 0 1 1-3.586-3.586l8.684-7.152c.833-.686.995-1.874.904-2.95a4.5 4.5 0 0 1 6.336-4.486l-3.276 3.276a3.004 3.004 0 0 0 2.25 2.25l3.276-3.276c.256.565.398 1.192.398 1.852Z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M4.867 19.125h.008v.008h-.008v-.008Z" />
</svg>

View file

@ -470,7 +470,7 @@
</button>
</div>
{#if $user?.role === 'admin'}
{#if $user?.role === 'admin' || $user?.permissions?.workspace?.models || $user?.permissions?.workspace?.knowledge || $user?.permissions?.workspace?.prompts || $user?.permissions?.workspace?.tools}
<div class="px-1.5 flex justify-center text-gray-800 dark:text-gray-200">
<a
class="flex-grow flex space-x-3 rounded-lg px-2 py-[7px] hover:bg-gray-100 dark:hover:bg-gray-900 transition"

View file

@ -10,20 +10,22 @@
const i18n = getContext('i18n');
import { WEBUI_NAME, knowledge } from '$lib/stores';
import { getKnowledgeItems, deleteKnowledgeById } from '$lib/apis/knowledge';
import { blobToFile, transformFileName } from '$lib/utils';
import {
getKnowledgeBases,
deleteKnowledgeById,
getKnowledgeBaseList
} from '$lib/apis/knowledge';
import { goto } from '$app/navigation';
import Tooltip from '../common/Tooltip.svelte';
import GarbageBin from '../icons/GarbageBin.svelte';
import Pencil from '../icons/Pencil.svelte';
import DeleteConfirmDialog from '../common/ConfirmDialog.svelte';
import ItemMenu from './Knowledge/ItemMenu.svelte';
import Badge from '../common/Badge.svelte';
import Search from '../icons/Search.svelte';
import Plus from '../icons/Plus.svelte';
import Spinner from '../common/Spinner.svelte';
let loaded = false;
let query = '';
let selectedItem = null;
@ -31,13 +33,21 @@
let fuse = null;
let knowledgeBases = [];
let filteredItems = [];
$: if (knowledgeBases) {
fuse = new Fuse(knowledgeBases, {
keys: ['name', 'description']
});
}
$: if (fuse) {
filteredItems = query
? fuse.search(query).map((e) => {
return e.item;
})
: $knowledge;
: knowledgeBases;
}
const deleteHandler = async (item) => {
@ -46,19 +56,15 @@
});
if (res) {
knowledge.set(await getKnowledgeItems(localStorage.token));
knowledgeBases = await getKnowledgeBaseList(localStorage.token);
knowledge.set(await getKnowledgeBases(localStorage.token));
toast.success($i18n.t('Knowledge deleted successfully.'));
}
};
onMount(async () => {
knowledge.set(await getKnowledgeItems(localStorage.token));
knowledge.subscribe((value) => {
fuse = new Fuse(value, {
keys: ['name', 'description']
});
});
knowledgeBases = await getKnowledgeBaseList(localStorage.token);
loaded = true;
});
</script>
@ -68,104 +74,110 @@
</title>
</svelte:head>
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
on:confirm={() => {
deleteHandler(selectedItem);
}}
/>
{#if loaded}
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
on:confirm={() => {
deleteHandler(selectedItem);
}}
/>
<div class="flex flex-col gap-1 mt-1.5 mb-2">
<div class="flex justify-between items-center">
<div class="flex md:self-center text-xl font-medium px-0.5 items-center">
{$i18n.t('Knowledge')}
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
<span class="text-lg font-medium text-gray-500 dark:text-gray-300"
>{filteredItems.length}</span
>
<div class="flex flex-col gap-1 mt-1.5 mb-2">
<div class="flex justify-between items-center">
<div class="flex md:self-center text-xl font-medium px-0.5 items-center">
{$i18n.t('Knowledge')}
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
<span class="text-lg font-medium text-gray-500 dark:text-gray-300"
>{filteredItems.length}</span
>
</div>
</div>
<div class=" flex w-full space-x-2">
<div class="flex flex-1">
<div class=" self-center ml-1 mr-3">
<Search className="size-3.5" />
</div>
<input
class=" w-full text-sm py-1 rounded-r-xl outline-none bg-transparent"
bind:value={query}
placeholder={$i18n.t('Search Knowledge')}
/>
</div>
<div>
<button
class=" px-2 py-2 rounded-xl hover:bg-gray-700/10 dark:hover:bg-gray-100/10 dark:text-gray-300 dark:hover:text-white transition font-medium text-sm flex items-center space-x-1"
aria-label={$i18n.t('Create Knowledge')}
on:click={() => {
goto('/workspace/knowledge/create');
}}
>
<Plus className="size-3.5" />
</button>
</div>
</div>
</div>
<div class=" flex w-full space-x-2">
<div class="flex flex-1">
<div class=" self-center ml-1 mr-3">
<Search className="size-3.5" />
</div>
<input
class=" w-full text-sm py-1 rounded-r-xl outline-none bg-transparent"
bind:value={query}
placeholder={$i18n.t('Search Knowledge')}
/>
</div>
<div>
<div class="my-3 mb-5 grid lg:grid-cols-2 xl:grid-cols-3 gap-2">
{#each filteredItems as item}
<button
class=" px-2 py-2 rounded-xl hover:bg-gray-700/10 dark:hover:bg-gray-100/10 dark:text-gray-300 dark:hover:text-white transition font-medium text-sm flex items-center space-x-1"
aria-label={$i18n.t('Create Knowledge')}
class=" flex space-x-4 cursor-pointer text-left w-full px-4 py-3 border border-gray-50 dark:border-gray-850 dark:hover:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-850 transition rounded-xl"
on:click={() => {
goto('/workspace/knowledge/create');
if (item?.meta?.document) {
toast.error(
$i18n.t(
'Only collections can be edited, create a new knowledge base to edit/add documents.'
)
);
} else {
goto(`/workspace/knowledge/${item.id}`);
}
}}
>
<Plus className="size-3.5" />
<div class=" w-full">
<div class="flex items-center justify-between -mt-1">
<div class=" font-semibold line-clamp-1 h-fit">{item.name}</div>
<div class=" flex self-center">
<ItemMenu
on:delete={() => {
selectedItem = item;
showDeleteConfirm = true;
}}
/>
</div>
</div>
<div class=" self-center flex-1">
<div class=" text-xs overflow-hidden text-ellipsis line-clamp-1">
{item.description}
</div>
<div class="mt-5 flex justify-between">
<div>
{#if item?.meta?.document}
<Badge type="muted" content={$i18n.t('Document')} />
{:else}
<Badge type="success" content={$i18n.t('Collection')} />
{/if}
</div>
<div class=" text-xs text-gray-500 line-clamp-1">
{$i18n.t('Updated')}
{dayjs(item.updated_at * 1000).fromNow()}
</div>
</div>
</div>
</div>
</button>
</div>
{/each}
</div>
</div>
<div class="my-3 mb-5 grid md:grid-cols-2 lg:grid-cols-3 gap-2">
{#each filteredItems as item}
<button
class=" flex space-x-4 cursor-pointer text-left w-full px-4 py-3 border border-gray-50 dark:border-gray-850 dark:hover:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-850 transition rounded-xl"
on:click={() => {
if (item?.meta?.document) {
toast.error(
$i18n.t(
'Only collections can be edited, create a new knowledge base to edit/add documents.'
)
);
} else {
goto(`/workspace/knowledge/${item.id}`);
}
}}
>
<div class=" w-full">
<div class="flex items-center justify-between -mt-1">
<div class=" font-semibold line-clamp-1 h-fit">{item.name}</div>
<div class=" flex self-center">
<ItemMenu
on:delete={() => {
selectedItem = item;
showDeleteConfirm = true;
}}
/>
</div>
</div>
<div class=" self-center flex-1">
<div class=" text-xs overflow-hidden text-ellipsis line-clamp-1">
{item.description}
</div>
<div class="mt-5 flex justify-between">
<div>
{#if item?.meta?.document}
<Badge type="muted" content={$i18n.t('Document')} />
{:else}
<Badge type="success" content={$i18n.t('Collection')} />
{/if}
</div>
<div class=" text-xs text-gray-500 line-clamp-1">
{$i18n.t('Updated')}
{dayjs(item.updated_at * 1000).fromNow()}
</div>
</div>
</div>
</div>
</button>
{/each}
</div>
<div class=" text-gray-500 text-xs mt-1 mb-2">
{$i18n.t("Use '#' in the prompt input to load and include your knowledge.")}
</div>
<div class=" text-gray-500 text-xs mt-1 mb-2">
{$i18n.t("Use '#' in the prompt input to load and include your knowledge.")}
</div>
{:else}
<div class="w-full h-full flex justify-center items-center">
<Spinner />
</div>
{/if}

View file

@ -3,14 +3,16 @@
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import { createNewKnowledge, getKnowledgeItems } from '$lib/apis/knowledge';
import { createNewKnowledge, getKnowledgeBases } from '$lib/apis/knowledge';
import { toast } from 'svelte-sonner';
import { knowledge } from '$lib/stores';
import AccessControl from '../common/AccessControl.svelte';
let loading = false;
let name = '';
let description = '';
let accessControl = null;
const submitHandler = async () => {
loading = true;
@ -23,13 +25,18 @@
return;
}
const res = await createNewKnowledge(localStorage.token, name, description).catch((e) => {
const res = await createNewKnowledge(
localStorage.token,
name,
description,
accessControl
).catch((e) => {
toast.error(e);
});
if (res) {
toast.success($i18n.t('Knowledge created successfully.'));
knowledge.set(await getKnowledgeItems(localStorage.token));
knowledge.set(await getKnowledgeBases(localStorage.token));
goto(`/workspace/knowledge/${res.id}`);
}
@ -103,6 +110,12 @@
</div>
</div>
<div class="mt-2">
<div class="px-3 py-2 bg-gray-50 dark:bg-gray-950 rounded-lg">
<AccessControl bind:accessControl />
</div>
</div>
<div class="flex justify-end mt-2">
<div>
<button

View file

@ -15,7 +15,7 @@
import {
addFileToKnowledgeById,
getKnowledgeById,
getKnowledgeItems,
getKnowledgeBases,
removeFileFromKnowledgeById,
resetKnowledgeById,
updateFileFromKnowledgeById,
@ -27,18 +27,19 @@
import { processFile } from '$lib/apis/retrieval';
import Spinner from '$lib/components/common/Spinner.svelte';
import Files from './Collection/Files.svelte';
import Files from './KnowledgeBase/Files.svelte';
import AddFilesPlaceholder from '$lib/components/AddFilesPlaceholder.svelte';
import AddContentMenu from './Collection/AddContentMenu.svelte';
import AddTextContentModal from './Collection/AddTextContentModal.svelte';
import AddContentMenu from './KnowledgeBase/AddContentMenu.svelte';
import AddTextContentModal from './KnowledgeBase/AddTextContentModal.svelte';
import SyncConfirmDialog from '../../common/ConfirmDialog.svelte';
import RichTextInput from '$lib/components/common/RichTextInput.svelte';
import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte';
import Drawer from '$lib/components/common/Drawer.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import MenuLines from '$lib/components/icons/MenuLines.svelte';
import LockClosed from '$lib/components/icons/LockClosed.svelte';
import AccessControlModal from '../common/AccessControlModal.svelte';
let largeScreen = true;
@ -62,6 +63,7 @@
let showAddTextContentModal = false;
let showSyncConfirmModal = false;
let showAccessControlModal = false;
let inputFiles = null;
@ -420,14 +422,15 @@
const res = await updateKnowledgeById(localStorage.token, id, {
name: knowledge.name,
description: knowledge.description
description: knowledge.description,
access_control: knowledge.access_control
}).catch((e) => {
toast.error(e);
});
if (res) {
toast.success($i18n.t('Knowledge updated successfully'));
_knowledge.set(await getKnowledgeItems(localStorage.token));
_knowledge.set(await getKnowledgeBases(localStorage.token));
}
}, 1000);
};
@ -596,8 +599,63 @@
}}
/>
<div class="flex flex-col w-full h-full max-h-[100dvh] mt-1" id="collection-container">
<div class="flex flex-col w-full h-full max-h-[100dvh] translate-y-1" id="collection-container">
{#if id && knowledge}
<AccessControlModal
bind:show={showAccessControlModal}
bind:accessControl={knowledge.access_control}
onChange={() => {
changeDebounceHandler();
}}
/>
<div class="w-full mb-2.5">
<div class=" flex w-full">
<div class="flex-1">
<div class="flex items-center justify-between w-full px-0.5 mb-1">
<div class="w-full">
<input
type="text"
class="text-left w-full font-semibold text-2xl font-primary bg-transparent outline-none"
bind:value={knowledge.name}
placeholder="Knowledge Name"
on:input={() => {
changeDebounceHandler();
}}
/>
</div>
<div class="self-center">
<button
class="bg-gray-50 hover:bg-gray-100 text-black transition px-2 py-1 rounded-full flex gap-1 items-center"
type="button"
on:click={() => {
showAccessControlModal = true;
}}
>
<LockClosed strokeWidth="2.5" className="size-3.5" />
<div class="text-sm font-medium flex-shrink-0">
{$i18n.t('Share')}
</div>
</button>
</div>
</div>
<div class="flex w-full px-1">
<input
type="text"
class="text-left text-xs w-full text-gray-500 bg-transparent outline-none"
bind:value={knowledge.description}
placeholder="Knowledge Description"
on:input={() => {
changeDebounceHandler();
}}
/>
</div>
</div>
</div>
</div>
<div class="flex flex-row flex-1 h-full max-h-full pb-2.5">
<PaneGroup direction="horizontal">
<Pane
@ -687,7 +745,17 @@
/>
</div>
{:else}
<div class="m-auto text-gray-500 text-xs">{$i18n.t('No content found')}</div>
<div
class="m-auto flex flex-col justify-center text-center text-gray-500 text-xs"
>
<div>
{$i18n.t('No content found')}
</div>
<div class="mx-12 mt-2 text-center text-gray-200 dark:text-gray-700">
{$i18n.t('Drag and drop a file to upload or select a file to view')}
</div>
</div>
{/if}
</div>
</div>
@ -753,41 +821,7 @@
</div>
</div>
{:else}
<div class="m-auto pb-32">
<div>
<div class=" flex w-full mt-1 mb-3.5">
<div class="flex-1">
<div class="flex items-center justify-between w-full px-0.5 mb-1">
<div class="w-full">
<input
type="text"
class="text-center w-full font-medium text-3xl font-primary bg-transparent outline-none"
bind:value={knowledge.name}
on:input={() => {
changeDebounceHandler();
}}
/>
</div>
</div>
<div class="flex w-full px-1">
<input
type="text"
class="text-center w-full text-gray-500 bg-transparent outline-none"
bind:value={knowledge.description}
on:input={() => {
changeDebounceHandler();
}}
/>
</div>
</div>
</div>
</div>
<div class=" mt-2 text-center text-sm text-gray-200 dark:text-gray-700 w-full">
{$i18n.t('Select a file to view or drag and drop a file to upload')}
</div>
</div>
<div></div>
{/if}
</div>
</Pane>

View file

@ -8,12 +8,17 @@
const { saveAs } = fileSaver;
import { onMount, getContext, tick } from 'svelte';
import { WEBUI_NAME, config, mobile, models, settings, user } from '$lib/stores';
import { addNewModel, deleteModelById, getModelInfos, updateModelById } from '$lib/apis/models';
import { deleteModel } from '$lib/apis/ollama';
import { goto } from '$app/navigation';
const i18n = getContext('i18n');
import { WEBUI_NAME, config, mobile, models as _models, settings, user } from '$lib/stores';
import {
createNewModel,
deleteModelById,
getModels as getWorkspaceModels,
toggleModelById,
updateModelById
} from '$lib/apis/models';
import { getModels } from '$lib/apis';
@ -24,67 +29,52 @@
import GarbageBin from '../icons/GarbageBin.svelte';
import Search from '../icons/Search.svelte';
import Plus from '../icons/Plus.svelte';
const i18n = getContext('i18n');
import ChevronRight from '../icons/ChevronRight.svelte';
import Switch from '../common/Switch.svelte';
import Spinner from '../common/Spinner.svelte';
let shiftKey = false;
let showModelDeleteConfirm = false;
let localModelfiles = [];
let importFiles;
let modelsImportInputElement: HTMLInputElement;
let loaded = false;
let _models = [];
let models = [];
let filteredModels = [];
let selectedModel = null;
$: if (_models) {
filteredModels = _models
.filter((m) => m?.owned_by !== 'arena')
.filter(
(m) => searchValue === '' || m.name.toLowerCase().includes(searchValue.toLowerCase())
);
let showModelDeleteConfirm = false;
$: if (models) {
filteredModels = models.filter(
(m) => searchValue === '' || m.name.toLowerCase().includes(searchValue.toLowerCase())
);
}
let sortable = null;
let searchValue = '';
const deleteModelHandler = async (model) => {
console.log(model.info);
if (!model?.info) {
toast.error(
$i18n.t('{{ owner }}: You cannot delete a base model', {
owner: model.owned_by.toUpperCase()
})
);
const res = await deleteModelById(localStorage.token, model.id).catch((e) => {
toast.error(e);
return null;
}
const res = await deleteModelById(localStorage.token, model.id);
});
if (res) {
toast.success($i18n.t(`Deleted {{name}}`, { name: model.id }));
}
await models.set(await getModels(localStorage.token));
_models = $models;
await _models.set(await getModels(localStorage.token));
models = await getWorkspaceModels(localStorage.token);
};
const cloneModelHandler = async (model) => {
if ((model?.info?.base_model_id ?? null) === null) {
toast.error($i18n.t('You cannot clone a base model'));
return;
} else {
sessionStorage.model = JSON.stringify({
...model,
id: `${model.id}-clone`,
name: `${model.name} (Clone)`
});
goto('/workspace/models/create');
}
sessionStorage.model = JSON.stringify({
...model,
id: `${model.id}-clone`,
name: `${model.name} (Clone)`
});
goto('/workspace/models/create');
};
const shareModelHandler = async (model) => {
@ -108,58 +98,6 @@
window.addEventListener('message', messageHandler, false);
};
const moveToTopHandler = async (model) => {
// find models with position 0 and set them to 1
const topModels = _models.filter((m) => m.info?.meta?.position === 0);
for (const m of topModels) {
let info = m.info;
if (!info) {
info = {
id: m.id,
name: m.name,
meta: {
position: 1
},
params: {}
};
}
info.meta = {
...info.meta,
position: 1
};
await updateModelById(localStorage.token, info.id, info);
}
let info = model.info;
if (!info) {
info = {
id: model.id,
name: model.name,
meta: {
position: 0
},
params: {}
};
}
info.meta = {
...info.meta,
position: 0
};
const res = await updateModelById(localStorage.token, info.id, info);
if (res) {
toast.success($i18n.t(`Model {{name}} is now at the top`, { name: info.id }));
}
await models.set(await getModels(localStorage.token));
_models = $models;
};
const hideModelHandler = async (model) => {
let info = model.info;
@ -192,8 +130,8 @@
);
}
await models.set(await getModels(localStorage.token));
_models = $models;
await _models.set(await getModels(localStorage.token));
models = await getWorkspaceModels(localStorage.token);
};
const downloadModels = async (models) => {
@ -210,60 +148,10 @@
saveAs(blob, `${model.id}-${Date.now()}.json`);
};
const positionChangeHandler = async () => {
// Get the new order of the models
const modelIds = Array.from(document.getElementById('model-list').children).map((child) =>
child.id.replace('model-item-', '')
);
// Update the position of the models
for (const [index, id] of modelIds.entries()) {
const model = $models.find((m) => m.id === id);
if (model) {
let info = model.info;
if (!info) {
info = {
id: model.id,
name: model.name,
meta: {
position: index
},
params: {}
};
}
info.meta = {
...info.meta,
position: index
};
await updateModelById(localStorage.token, info.id, info);
}
}
await tick();
await models.set(await getModels(localStorage.token));
};
onMount(async () => {
// Legacy code to sync localModelfiles with models
_models = $models;
localModelfiles = JSON.parse(localStorage.getItem('modelfiles') ?? '[]');
models = await getWorkspaceModels(localStorage.token);
if (localModelfiles) {
console.log(localModelfiles);
}
if (!$mobile) {
// SortableJS
sortable = new Sortable(document.getElementById('model-list'), {
animation: 150,
onUpdate: async (event) => {
console.log(event);
positionChangeHandler();
}
});
}
loaded = true;
const onKeyDown = (event) => {
if (event.key === 'Shift') {
@ -299,356 +187,276 @@
</title>
</svelte:head>
<ModelDeleteConfirmDialog
bind:show={showModelDeleteConfirm}
on:confirm={() => {
deleteModelHandler(selectedModel);
}}
/>
{#if loaded}
<ModelDeleteConfirmDialog
bind:show={showModelDeleteConfirm}
on:confirm={() => {
deleteModelHandler(selectedModel);
}}
/>
<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">
{$i18n.t('Models')}
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
<span class="text-lg font-medium text-gray-500 dark:text-gray-300"
>{filteredModels.length}</span
>
</div>
</div>
<div class=" flex flex-1 items-center w-full space-x-2">
<div class="flex flex-1 items-center">
<div class=" self-center ml-1 mr-3">
<Search className="size-3.5" />
</div>
<input
class=" w-full text-sm py-1 rounded-r-xl outline-none bg-transparent"
bind:value={searchValue}
placeholder={$i18n.t('Search Models')}
/>
</div>
<div>
<a
class=" px-2 py-2 rounded-xl hover:bg-gray-700/10 dark:hover:bg-gray-100/10 dark:text-gray-300 dark:hover:text-white transition font-medium text-sm flex items-center space-x-1"
href="/workspace/models/create"
>
<Plus className="size-3.5" />
</a>
</div>
</div>
</div>
<a class=" flex space-x-4 cursor-pointer w-full mb-2 px-3 py-1" href="/workspace/models/create">
<div class=" self-center w-8 flex-shrink-0">
<div
class="w-full h-8 flex justify-center rounded-full bg-transparent dark:bg-gray-700 border border-dashed border-gray-200"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6">
<path
fill-rule="evenodd"
d="M12 3.75a.75.75 0 01.75.75v6.75h6.75a.75.75 0 010 1.5h-6.75v6.75a.75.75 0 01-1.5 0v-6.75H4.5a.75.75 0 010-1.5h6.75V4.5a.75.75 0 01.75-.75z"
clip-rule="evenodd"
/>
</svg>
</div>
</div>
<div class=" self-center">
<div class=" font-semibold line-clamp-1">{$i18n.t('Create a model')}</div>
<div class=" text-sm line-clamp-1 text-gray-500">
{$i18n.t('Customize models for a specific purpose')}
</div>
</div>
</a>
<div class=" my-2 mb-5" id="model-list">
{#each filteredModels as model}
<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"
id="model-item-{model.id}"
>
<a
class=" flex flex-1 space-x-3.5 cursor-pointer w-full"
href={`/?models=${encodeURIComponent(model.id)}`}
>
<div class=" self-start w-8 pt-0.5">
<div
class=" rounded-full object-cover {(model?.info?.meta?.hidden ?? false)
? 'brightness-90 dark:brightness-50'
: ''} "
>
<img
src={model?.info?.meta?.profile_image_url ?? '/static/favicon.png'}
alt="modelfile profile"
class=" rounded-full w-full h-auto object-cover"
/>
</div>
</div>
<div
class=" flex-1 self-center {(model?.info?.meta?.hidden ?? false) ? 'text-gray-500' : ''}"
<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">
{$i18n.t('Models')}
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
<span class="text-lg font-medium text-gray-500 dark:text-gray-300"
>{filteredModels.length}</span
>
<Tooltip
content={marked.parse(
model?.ollama?.digest
? `${model?.ollama?.digest} *(${model?.ollama?.modified_at})*`
: ''
)}
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">
{!!model?.info?.meta?.description
? model?.info?.meta?.description
: model?.ollama?.digest
? `${model.id} (${model?.ollama?.digest})`
: model.id}
</div>
</div>
</div>
<div class=" flex flex-1 items-center w-full space-x-2">
<div class="flex flex-1 items-center">
<div class=" self-center ml-1 mr-3">
<Search className="size-3.5" />
</div>
</a>
<div class="flex flex-row gap-0.5 self-center">
{#if shiftKey}
<Tooltip
content={(model?.info?.meta?.hidden ?? false)
? $i18n.t('Show Model')
: $i18n.t('Hide Model')}
>
<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={() => {
<input
class=" w-full text-sm py-1 rounded-r-xl outline-none bg-transparent"
bind:value={searchValue}
placeholder={$i18n.t('Search Models')}
/>
</div>
<div>
<a
class=" px-2 py-2 rounded-xl hover:bg-gray-700/10 dark:hover:bg-gray-100/10 dark:text-gray-300 dark:hover:text-white transition font-medium text-sm flex items-center space-x-1"
href="/workspace/models/create"
>
<Plus className="size-3.5" />
</a>
</div>
</div>
</div>
<a class=" flex space-x-4 cursor-pointer w-full mb-2 px-3 py-1" href="/workspace/models/create">
<div class=" self-center w-8 flex-shrink-0">
<div
class="w-full h-8 flex justify-center rounded-full bg-transparent dark:bg-gray-700 border border-dashed border-gray-200"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6">
<path
fill-rule="evenodd"
d="M12 3.75a.75.75 0 01.75.75v6.75h6.75a.75.75 0 010 1.5h-6.75v6.75a.75.75 0 01-1.5 0v-6.75H4.5a.75.75 0 010-1.5h6.75V4.5a.75.75 0 01.75-.75z"
clip-rule="evenodd"
/>
</svg>
</div>
</div>
<div class=" self-center">
<div class=" font-semibold line-clamp-1">{$i18n.t('Create a model')}</div>
<div class=" text-sm line-clamp-1 text-gray-500">
{$i18n.t('Customize models for a specific purpose')}
</div>
</div>
</a>
<div class=" my-2 mb-5" id="model-list">
{#each filteredModels as model}
<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"
id="model-item-{model.id}"
>
<a
class=" flex flex-1 space-x-3.5 cursor-pointer w-full"
href={`/?models=${encodeURIComponent(model.id)}`}
>
<div class=" self-center w-8">
<div
class=" rounded-full object-cover {model.is_active
? ''
: 'opacity-50 dark:opacity-50'} "
>
<img
src={model?.meta?.profile_image_url ?? '/static/favicon.png'}
alt="modelfile profile"
class=" rounded-full w-full h-auto object-cover"
/>
</div>
</div>
<div class=" flex-1 self-center {model.is_active ? '' : 'text-gray-500'}">
<Tooltip
content={marked.parse(model?.meta?.description ?? 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">
{model?.meta?.description ?? model.id}
</div>
</div>
</a>
<div class="flex flex-row gap-0.5 items-center self-center">
{#if shiftKey}
<Tooltip content={$i18n.t('Delete')}>
<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={() => {
deleteModelHandler(model);
}}
>
<GarbageBin />
</button>
</Tooltip>
{:else}
{#if $user?.role === 'admin' || model.user_id === $user?.id}
<a
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"
href={`/workspace/models/edit?id=${encodeURIComponent(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>
</a>
{/if}
<ModelMenu
user={$user}
{model}
shareHandler={() => {
shareModelHandler(model);
}}
cloneHandler={() => {
cloneModelHandler(model);
}}
exportHandler={() => {
exportModelHandler(model);
}}
hideHandler={() => {
hideModelHandler(model);
}}
>
{#if model?.info?.meta?.hidden ?? false}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88"
/>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
{/if}
</button>
</Tooltip>
<Tooltip content={$i18n.t('Delete')}>
<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={() => {
deleteModelHandler(model);
deleteHandler={() => {
selectedModel = model;
showModelDeleteConfirm = true;
}}
onClose={() => {}}
>
<GarbageBin />
</button>
</Tooltip>
{:else}
<a
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"
href={`/workspace/models/edit?id=${encodeURIComponent(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>
</a>
<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>
<ModelMenu
{model}
shareHandler={() => {
shareModelHandler(model);
}}
cloneHandler={() => {
cloneModelHandler(model);
}}
exportHandler={() => {
exportModelHandler(model);
}}
moveToTopHandler={() => {
moveToTopHandler(model);
}}
hideHandler={() => {
hideModelHandler(model);
}}
deleteHandler={() => {
selectedModel = model;
showModelDeleteConfirm = true;
}}
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"
>
<EllipsisHorizontal className="size-5" />
</button>
</ModelMenu>
{/if}
<div class="ml-1">
<Tooltip content={model.is_active ? $i18n.t('Enabled') : $i18n.t('Disabled')}>
<Switch
bind:state={model.is_active}
on:change={async (e) => {
toggleModelById(localStorage.token, model.id);
_models.set(await getModels(localStorage.token));
}}
/>
</Tooltip>
</div>
{/if}
</div>
</div>
</div>
{/each}
</div>
<div class=" flex justify-end w-full mb-3">
<div class="flex space-x-1">
<input
id="models-import-input"
bind:this={modelsImportInputElement}
bind:files={importFiles}
type="file"
accept=".json"
hidden
on:change={() => {
console.log(importFiles);
let reader = new FileReader();
reader.onload = async (event) => {
let savedModels = JSON.parse(event.target.result);
console.log(savedModels);
for (const model of savedModels) {
if (model?.info ?? false) {
if ($models.find((m) => m.id === model.id)) {
await updateModelById(localStorage.token, model.id, model.info).catch((error) => {
return null;
});
} else {
await addNewModel(localStorage.token, model.info).catch((error) => {
return null;
});
}
}
}
await models.set(await getModels(localStorage.token));
_models = $models;
};
reader.readAsText(importFiles[0]);
}}
/>
<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-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
on:click={() => {
modelsImportInputElement.click();
}}
>
<div class=" self-center mr-2 font-medium line-clamp-1">{$i18n.t('Import Models')}</div>
<div class=" self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-3.5 h-3.5"
>
<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>
</div>
</button>
<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-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
on:click={async () => {
downloadModels($models);
}}
>
<div class=" self-center mr-2 font-medium line-clamp-1">{$i18n.t('Export Models')}</div>
<div class=" self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-3.5 h-3.5"
>
<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>
</div>
</button>
{/each}
</div>
{#if localModelfiles.length > 0}
<div class="flex">
<div class=" self-center text-sm font-medium mr-4">
{localModelfiles.length} Local Modelfiles Detected
</div>
{#if $user?.role === 'admin'}
<div class=" flex justify-end w-full mb-3">
<div class="flex space-x-1">
<button
class="self-center w-fit text-sm p-1.5 border dark:border-gray-600 rounded-xl flex"
on:click={async () => {
downloadModels(localModelfiles);
<input
id="models-import-input"
bind:this={modelsImportInputElement}
bind:files={importFiles}
type="file"
accept=".json"
hidden
on:change={() => {
console.log(importFiles);
localStorage.removeItem('modelfiles');
localModelfiles = JSON.parse(localStorage.getItem('modelfiles') ?? '[]');
let reader = new FileReader();
reader.onload = async (event) => {
let savedModels = JSON.parse(event.target.result);
console.log(savedModels);
for (const model of savedModels) {
if (model?.info ?? false) {
if ($_models.find((m) => m.id === model.id)) {
await updateModelById(localStorage.token, model.id, model.info).catch(
(error) => {
return null;
}
);
} else {
await createNewModel(localStorage.token, model.info).catch((error) => {
return null;
});
}
}
}
await _models.set(await getModels(localStorage.token));
models = await getWorkspaceModels(localStorage.token);
};
reader.readAsText(importFiles[0]);
}}
/>
<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-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
on:click={() => {
modelsImportInputElement.click();
}}
>
<div class=" self-center mr-2 font-medium line-clamp-1">{$i18n.t('Import Models')}</div>
<div class=" self-center">
<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"
viewBox="0 0 16 16"
fill="currentColor"
class="w-3.5 h-3.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.74 9l-.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 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
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>
</div>
</button>
<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-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
on:click={async () => {
downloadModels($_models);
}}
>
<div class=" self-center mr-2 font-medium line-clamp-1">{$i18n.t('Export Models')}</div>
<div class=" self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-3.5 h-3.5"
>
<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>
</div>
@ -656,44 +464,35 @@
</div>
</div>
{/if}
</div>
{#if $config?.features.enable_community_sharing}
<div class=" my-16">
<div class=" text-lg font-semibold mb-3 line-clamp-1">
{$i18n.t('Made by OpenWebUI Community')}
{#if $config?.features.enable_community_sharing}
<div class=" my-16">
<div class=" text-lg font-semibold mb-0.5 line-clamp-1">
{$i18n.t('Made by OpenWebUI Community')}
</div>
<a
class=" flex cursor-pointer items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-850 w-full mb-2 px-3.5 py-1.5 rounded-xl transition"
href="https://openwebui.com/#open-webui-community"
target="_blank"
>
<div class=" self-center">
<div class=" font-semibold line-clamp-1">{$i18n.t('Discover a model')}</div>
<div class=" text-sm line-clamp-1">
{$i18n.t('Discover, download, and explore model presets')}
</div>
</div>
<div>
<div>
<ChevronRight />
</div>
</div>
</a>
</div>
<a
class=" flex space-x-4 cursor-pointer w-full mb-2 px-3 py-2"
href="https://openwebui.com/#open-webui-community"
target="_blank"
>
<div class=" self-center w-10 flex-shrink-0">
<div
class="w-full h-10 flex justify-center rounded-full bg-transparent dark:bg-gray-700 border border-dashed border-gray-200"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-6"
>
<path
fill-rule="evenodd"
d="M12 3.75a.75.75 0 01.75.75v6.75h6.75a.75.75 0 010 1.5h-6.75v6.75a.75.75 0 01-1.5 0v-6.75H4.5a.75.75 0 010-1.5h6.75V4.5a.75.75 0 01.75-.75z"
clip-rule="evenodd"
/>
</svg>
</div>
</div>
<div class=" self-center">
<div class=" font-semibold line-clamp-1">{$i18n.t('Discover a model')}</div>
<div class=" text-sm line-clamp-1">
{$i18n.t('Discover, download, and explore model presets')}
</div>
</div>
</a>
{/if}
{:else}
<div class="w-full h-full flex justify-center items-center">
<Spinner />
</div>
{/if}

View file

@ -53,7 +53,7 @@
}}
>
<button
class=" px-3.5 py-1.5 font-medium hover:bg-black/5 dark:hover:bg-white/5 outline outline-1 outline-gray-300 dark:outline-gray-800 rounded-3xl"
class=" px-3.5 py-1.5 font-medium hover:bg-black/5 dark:hover:bg-white/5 outline outline-1 outline-gray-100 dark:outline-gray-850 rounded-3xl"
type="button">{$i18n.t('Select Knowledge')}</button
>
</Selector>

View file

@ -1,10 +1,6 @@
<script lang="ts">
import { v4 as uuidv4 } from 'uuid';
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import { onMount, getContext, tick } from 'svelte';
import { models, tools, functions, knowledge as knowledgeCollections } from '$lib/stores';
import { models, tools, functions, knowledge as knowledgeCollections, user } from '$lib/stores';
import AdvancedParams from '$lib/components/chat/Settings/Advanced/AdvancedParams.svelte';
import Tags from '$lib/components/common/Tags.svelte';
@ -16,14 +12,20 @@
import Textarea from '$lib/components/common/Textarea.svelte';
import { getTools } from '$lib/apis/tools';
import { getFunctions } from '$lib/apis/functions';
import { getKnowledgeItems } from '$lib/apis/knowledge';
import { getKnowledgeBases } from '$lib/apis/knowledge';
import AccessControl from '../common/AccessControl.svelte';
import { stringify } from 'postcss';
const i18n = getContext('i18n');
export let onSubmit: Function;
export let onBack: null | Function = null;
export let model = null;
export let edit = false;
export let preset = true;
let loading = false;
let success = false;
@ -77,12 +79,14 @@
let filterIds = [];
let actionIds = [];
let accessControl = null;
const addUsage = (base_model_id) => {
const baseModel = $models.find((m) => m.id === base_model_id);
if (baseModel) {
if (baseModel.owned_by === 'openai') {
capabilities.usage = baseModel.info?.meta?.capabilities?.usage ?? false;
capabilities.usage = baseModel?.meta?.capabilities?.usage ?? false;
} else {
delete capabilities.usage;
}
@ -95,6 +99,8 @@
info.id = id;
info.name = name;
info.access_control = accessControl;
info.meta.capabilities = capabilities;
if (knowledge.length > 0) {
@ -145,7 +151,7 @@
onMount(async () => {
await tools.set(await getTools(localStorage.token));
await functions.set(await getFunctions(localStorage.token));
await knowledgeCollections.set(await getKnowledgeItems(localStorage.token));
await knowledgeCollections.set(await getKnowledgeBases(localStorage.token));
// Scroll to top 'workspace-container' element
const workspaceContainer = document.getElementById('workspace-container');
@ -154,38 +160,37 @@
}
if (model) {
console.log(model);
name = model.name;
await tick();
id = model.id;
if (model.info.base_model_id) {
if (model.base_model_id) {
const base_model = $models
.filter((m) => !m?.preset && m?.owned_by !== 'arena')
.find((m) =>
[model.info.base_model_id, `${model.info.base_model_id}:latest`].includes(m.id)
);
.filter((m) => !m?.preset && !(m?.arena ?? false))
.find((m) => [model.base_model_id, `${model.base_model_id}:latest`].includes(m.id));
console.log('base_model', base_model);
if (base_model) {
model.info.base_model_id = base_model.id;
model.base_model_id = base_model.id;
} else {
model.info.base_model_id = null;
model.base_model_id = null;
}
}
params = { ...params, ...model?.info?.params };
params = { ...params, ...model?.params };
params.stop = params?.stop
? (typeof params.stop === 'string' ? params.stop.split(',') : (params?.stop ?? [])).join(
','
)
: null;
toolIds = model?.info?.meta?.toolIds ?? [];
filterIds = model?.info?.meta?.filterIds ?? [];
actionIds = model?.info?.meta?.actionIds ?? [];
knowledge = (model?.info?.meta?.knowledge ?? []).map((item) => {
toolIds = model?.meta?.toolIds ?? [];
filterIds = model?.meta?.filterIds ?? [];
actionIds = model?.meta?.actionIds ?? [];
knowledge = (model?.meta?.knowledge ?? []).map((item) => {
if (item?.collection_name) {
return {
id: item.collection_name,
@ -203,17 +208,22 @@
return item;
}
});
capabilities = { ...capabilities, ...(model?.info?.meta?.capabilities ?? {}) };
capabilities = { ...capabilities, ...(model?.meta?.capabilities ?? {}) };
if (model?.owned_by === 'openai') {
capabilities.usage = false;
}
accessControl = model?.access_control ?? null;
console.log(model?.access_control);
console.log(accessControl);
info = {
...info,
...JSON.parse(
JSON.stringify(
model?.info
? model?.info
model
? model
: {
id: model.id,
name: model.name
@ -230,6 +240,31 @@
</script>
{#if loaded}
{#if onBack}
<button
class="flex space-x-1"
on:click={() => {
onBack();
}}
>
<div class=" self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-4 w-4"
>
<path
fill-rule="evenodd"
d="M17 10a.75.75 0 01-.75.75H5.612l4.158 3.96a.75.75 0 11-1.04 1.08l-5.5-5.25a.75.75 0 010-1.08l5.5-5.25a.75.75 0 111.04 1.08L5.612 9.25H16.25A.75.75 0 0117 10z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center text-sm font-medium">{'Back'}</div>
</button>
{/if}
<div class="w-full max-h-full flex justify-center">
<input
bind:this={filesInputElement}
@ -298,7 +333,7 @@
}}
/>
{#if !edit || model}
{#if !edit || (edit && model)}
<form
class="flex flex-col md:flex-row w-full gap-3 md:gap-6"
on:submit|preventDefault={() => {
@ -308,7 +343,7 @@
<div class="self-center md:self-start flex justify-center my-2 flex-shrink-0">
<div class="self-center">
<button
class="rounded-2xl flex flex-shrink-0 items-center bg-white shadow-2xl group relative"
class="rounded-2xl flex flex-shrink-0 items-center bg-white shadow-xl group relative"
type="button"
on:click={() => {
filesInputElement.click();
@ -318,13 +353,13 @@
<img
src={info.meta.profile_image_url}
alt="model profile"
class="rounded-lg size-72 md:size-64 object-cover shrink-0"
class="rounded-lg size-72 md:size-60 object-cover shrink-0"
/>
{:else}
<img
src="/static/favicon.png"
alt="model profile"
class=" rounded-lg size-72 md:size-64 object-cover shrink-0"
class=" rounded-lg size-72 md:size-60 object-cover shrink-0"
/>
{/if}
@ -383,7 +418,7 @@
</div>
</div>
{#if !edit || model.preset}
{#if preset}
<div class="my-1">
<div class=" text-sm font-semibold mb-1">{$i18n.t('Base Model (From)')}</div>
@ -441,7 +476,33 @@
{/if}
</div>
<hr class=" dark:border-gray-850 my-1.5" />
<div class="my-1">
<div class="">
<Tags
tags={info?.meta?.tags ?? []}
on:delete={(e) => {
const tagName = e.detail;
info.meta.tags = info.meta.tags.filter((tag) => tag.name !== tagName);
}}
on:add={(e) => {
const tagName = e.detail;
if (!(info?.meta?.tags ?? null)) {
info.meta.tags = [{ name: tagName }];
} else {
info.meta.tags = [...info.meta.tags, { name: tagName }];
}
}}
/>
</div>
</div>
<div class="my-2">
<div class="px-3 py-2 bg-gray-50 dark:bg-gray-950 rounded-lg">
<AccessControl bind:accessControl />
</div>
</div>
<hr class=" border-gray-50 dark:border-gray-850 my-1.5" />
<div class="my-2">
<div class="flex w-full justify-between">
@ -495,7 +556,7 @@
</div>
</div>
<hr class=" dark:border-gray-850 my-1" />
<hr class=" border-gray-50 dark:border-gray-850 my-1" />
<div class="my-2">
<div class="flex w-full justify-between items-center">
@ -592,7 +653,7 @@
{/if}
</div>
<hr class=" dark:border-gray-850 my-1.5" />
<hr class=" border-gray-50 dark:border-gray-850 my-1.5" />
<div class="my-2">
<Knowledge bind:selectedKnowledge={knowledge} collections={$knowledgeCollections} />
@ -620,30 +681,6 @@
<Capabilities bind:capabilities />
</div>
<div class="my-1">
<div class="flex w-full justify-between items-center">
<div class=" self-center text-sm font-semibold">{$i18n.t('Tags')}</div>
</div>
<div class="mt-2">
<Tags
tags={info?.meta?.tags ?? []}
on:delete={(e) => {
const tagName = e.detail;
info.meta.tags = info.meta.tags.filter((tag) => tag.name !== tagName);
}}
on:add={(e) => {
const tagName = e.detail;
if (!(info?.meta?.tags ?? null)) {
info.meta.tags = [{ name: tagName }];
} else {
info.meta.tags = [...info.meta.tags, { name: tagName }];
}
}}
/>
</div>
</div>
<div class="my-2 text-gray-300 dark:text-gray-700">
<div class="flex w-full justify-between mb-2">
<div class=" self-center text-sm font-semibold">{$i18n.t('JSON Preview')}</div>
@ -679,8 +716,8 @@
<div class="my-2 flex justify-end pb-20">
<button
class=" text-sm px-3 py-2 transition rounded-lg {loading
? ' cursor-not-allowed bg-white hover:bg-gray-100 text-black'
: ' bg-white hover:bg-gray-100 text-black'} flex w-full justify-center"
? ' cursor-not-allowed bg-black hover:bg-gray-900 text-white dark:bg-white dark:hover:bg-gray-100 dark:text-black'
: 'bg-black hover:bg-gray-900 text-white dark:bg-white dark:hover:bg-gray-100 dark:text-black'} flex w-full justify-center"
type="submit"
disabled={loading}
>

View file

@ -16,13 +16,13 @@
const i18n = getContext('i18n');
export let user;
export let model;
export let shareHandler: Function;
export let cloneHandler: Function;
export let exportHandler: Function;
export let moveToTopHandler: Function;
export let hideHandler: Function;
export let deleteHandler: Function;
export let onClose: Function;
@ -82,69 +82,6 @@
<div class="flex items-center">{$i18n.t('Export')}</div>
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
moveToTopHandler();
}}
>
<ArrowUpCircle />
<div class="flex items-center">{$i18n.t('Move to Top')}</div>
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
hideHandler();
}}
>
{#if model?.info?.meta?.hidden ?? false}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88"
/>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
{/if}
<div class="flex items-center">
{#if model?.info?.meta?.hidden ?? false}
{$i18n.t('Show Model')}
{:else}
{$i18n.t('Hide Model')}
{/if}
</div>
</DropdownMenu.Item>
<hr class="border-gray-100 dark:border-gray-800 my-1" />
<DropdownMenu.Item

View file

@ -3,28 +3,39 @@
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import { onMount, getContext } from 'svelte';
import { WEBUI_NAME, config, prompts } from '$lib/stores';
import { createNewPrompt, deletePromptByCommand, getPrompts } from '$lib/apis/prompts';
import { error } from '@sveltejs/kit';
import { goto } from '$app/navigation';
import { onMount, getContext } from 'svelte';
import { WEBUI_NAME, config, prompts as _prompts, user } from '$lib/stores';
import {
createNewPrompt,
deletePromptByCommand,
getPrompts,
getPromptList
} from '$lib/apis/prompts';
import PromptMenu from './Prompts/PromptMenu.svelte';
import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Search from '../icons/Search.svelte';
import Plus from '../icons/Plus.svelte';
import ChevronRight from '../icons/ChevronRight.svelte';
import Spinner from '../common/Spinner.svelte';
const i18n = getContext('i18n');
let promptsImportInputElement: HTMLInputElement;
let loaded = false;
let importFiles = '';
let query = '';
let promptsImportInputElement: HTMLInputElement;
let prompts = [];
let showDeleteConfirm = false;
let deletePrompt = null;
let filteredItems = [];
$: filteredItems = $prompts.filter((p) => query === '' || p.command.includes(query));
$: filteredItems = prompts.filter((p) => query === '' || p.command.includes(query));
const shareHandler = async (prompt) => {
toast.success($i18n.t('Redirecting you to OpenWebUI Community'));
@ -59,8 +70,18 @@
const deleteHandler = async (prompt) => {
const command = prompt.command;
await deletePromptByCommand(localStorage.token, command);
await prompts.set(await getPrompts(localStorage.token));
await init();
};
const init = async () => {
prompts = await getPromptList(localStorage.token);
await _prompts.set(await getPrompts(localStorage.token));
};
onMount(async () => {
await init();
loaded = true;
});
</script>
<svelte:head>
@ -69,251 +90,239 @@
</title>
</svelte:head>
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete prompt?')}
on:confirm={() => {
deleteHandler(deletePrompt);
}}
>
<div class=" text-sm text-gray-500">
{$i18n.t('This will delete')} <span class=" font-semibold">{deletePrompt.command}</span>.
</div>
</DeleteConfirmDialog>
<div class="flex flex-col gap-1 mt-1.5 mb-2">
<div class="flex justify-between items-center">
<div class="flex md:self-center text-xl font-medium px-0.5 items-center">
{$i18n.t('Prompts')}
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
<span class="text-lg font-medium text-gray-500 dark:text-gray-300"
>{filteredItems.length}</span
>
{#if loaded}
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete prompt?')}
on:confirm={() => {
deleteHandler(deletePrompt);
}}
>
<div class=" text-sm text-gray-500">
{$i18n.t('This will delete')} <span class=" font-semibold">{deletePrompt.command}</span>.
</div>
</div>
</DeleteConfirmDialog>
<div class=" flex w-full space-x-2">
<div class="flex flex-1">
<div class=" self-center ml-1 mr-3">
<Search className="size-3.5" />
<div class="flex flex-col gap-1 mt-1.5 mb-2">
<div class="flex justify-between items-center">
<div class="flex md:self-center text-xl font-medium px-0.5 items-center">
{$i18n.t('Prompts')}
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
<span class="text-lg font-medium text-gray-500 dark:text-gray-300"
>{filteredItems.length}</span
>
</div>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-none bg-transparent"
bind:value={query}
placeholder={$i18n.t('Search Prompts')}
/>
</div>
<div>
<a
class=" px-2 py-2 rounded-xl hover:bg-gray-700/10 dark:hover:bg-gray-100/10 dark:text-gray-300 dark:hover:text-white transition font-medium text-sm flex items-center space-x-1"
href="/workspace/prompts/create"
<div class=" flex w-full space-x-2">
<div class="flex flex-1">
<div class=" self-center ml-1 mr-3">
<Search className="size-3.5" />
</div>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-none bg-transparent"
bind:value={query}
placeholder={$i18n.t('Search Prompts')}
/>
</div>
<div>
<a
class=" px-2 py-2 rounded-xl hover:bg-gray-700/10 dark:hover:bg-gray-100/10 dark:text-gray-300 dark:hover:text-white transition font-medium text-sm flex items-center space-x-1"
href="/workspace/prompts/create"
>
<Plus className="size-3.5" />
</a>
</div>
</div>
</div>
<div class="mb-5">
{#each filteredItems as prompt}
<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"
>
<Plus className="size-3.5" />
<div class=" flex flex-1 space-x-4 cursor-pointer w-full">
<a href={`/workspace/prompts/edit?command=${encodeURIComponent(prompt.command)}`}>
<div class=" flex-1 self-center pl-1.5">
<div class=" font-semibold line-clamp-1">{prompt.command}</div>
<div class=" text-xs overflow-hidden text-ellipsis line-clamp-1">
{prompt.title}
</div>
</div>
</a>
</div>
<div class="flex flex-row gap-0.5 self-center">
<a
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"
href={`/workspace/prompts/edit?command=${encodeURIComponent(prompt.command)}`}
>
<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.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125"
/>
</svg>
</a>
<PromptMenu
shareHandler={() => {
shareHandler(prompt);
}}
cloneHandler={() => {
cloneHandler(prompt);
}}
exportHandler={() => {
exportHandler(prompt);
}}
deleteHandler={async () => {
deletePrompt = prompt;
showDeleteConfirm = true;
}}
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"
>
<EllipsisHorizontal className="size-5" />
</button>
</PromptMenu>
</div>
</div>
{/each}
</div>
{#if $user?.role === 'admin'}
<div class=" flex justify-end w-full mb-3">
<div class="flex space-x-2">
<input
id="prompts-import-input"
bind:this={promptsImportInputElement}
bind:files={importFiles}
type="file"
accept=".json"
hidden
on:change={() => {
console.log(importFiles);
const reader = new FileReader();
reader.onload = async (event) => {
const savedPrompts = JSON.parse(event.target.result);
console.log(savedPrompts);
for (const prompt of savedPrompts) {
await createNewPrompt(
localStorage.token,
prompt.command.charAt(0) === '/' ? prompt.command.slice(1) : prompt.command,
prompt.title,
prompt.content
).catch((error) => {
toast.error(error);
return null;
});
}
prompts = await getPromptList(localStorage.token);
await _prompts.set(await getPrompts(localStorage.token));
};
reader.readAsText(importFiles[0]);
}}
/>
<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-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
on:click={() => {
promptsImportInputElement.click();
}}
>
<div class=" self-center mr-2 font-medium line-clamp-1">{$i18n.t('Import Prompts')}</div>
<div class=" self-center">
<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 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>
</div>
</button>
<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-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
on:click={async () => {
// promptsImportInputElement.click();
let blob = new Blob([JSON.stringify(prompts)], {
type: 'application/json'
});
saveAs(blob, `prompts-export-${Date.now()}.json`);
}}
>
<div class=" self-center mr-2 font-medium line-clamp-1">{$i18n.t('Export Prompts')}</div>
<div class=" self-center">
<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 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>
</div>
</button>
</div>
</div>
{/if}
{#if $config?.features.enable_community_sharing}
<div class=" my-16">
<div class=" text-lg font-semibold mb-0.5 line-clamp-1">
{$i18n.t('Made by OpenWebUI Community')}
</div>
<a
class=" flex cursor-pointer items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-850 w-full mb-2 px-3.5 py-1.5 rounded-xl transition"
href="https://openwebui.com/#open-webui-community"
target="_blank"
>
<div class=" self-center">
<div class=" font-semibold line-clamp-1">{$i18n.t('Discover a prompt')}</div>
<div class=" text-sm line-clamp-1">
{$i18n.t('Discover, download, and explore custom prompts')}
</div>
</div>
<div>
<div>
<ChevronRight />
</div>
</div>
</a>
</div>
</div>
</div>
<div class="mb-5">
{#each filteredItems as prompt}
<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"
>
<div class=" flex flex-1 space-x-4 cursor-pointer w-full">
<a href={`/workspace/prompts/edit?command=${encodeURIComponent(prompt.command)}`}>
<div class=" flex-1 self-center pl-1.5">
<div class=" font-semibold line-clamp-1">{prompt.command}</div>
<div class=" text-xs overflow-hidden text-ellipsis line-clamp-1">
{prompt.title}
</div>
</div>
</a>
</div>
<div class="flex flex-row gap-0.5 self-center">
<a
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"
href={`/workspace/prompts/edit?command=${encodeURIComponent(prompt.command)}`}
>
<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.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125"
/>
</svg>
</a>
<PromptMenu
shareHandler={() => {
shareHandler(prompt);
}}
cloneHandler={() => {
cloneHandler(prompt);
}}
exportHandler={() => {
exportHandler(prompt);
}}
deleteHandler={async () => {
deletePrompt = prompt;
showDeleteConfirm = true;
}}
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"
>
<EllipsisHorizontal className="size-5" />
</button>
</PromptMenu>
</div>
</div>
{/each}
</div>
<div class=" flex justify-end w-full mb-3">
<div class="flex space-x-2">
<input
id="prompts-import-input"
bind:this={promptsImportInputElement}
bind:files={importFiles}
type="file"
accept=".json"
hidden
on:change={() => {
console.log(importFiles);
const reader = new FileReader();
reader.onload = async (event) => {
const savedPrompts = JSON.parse(event.target.result);
console.log(savedPrompts);
for (const prompt of savedPrompts) {
await createNewPrompt(
localStorage.token,
prompt.command.charAt(0) === '/' ? prompt.command.slice(1) : prompt.command,
prompt.title,
prompt.content
).catch((error) => {
toast.error(error);
return null;
});
}
await prompts.set(await getPrompts(localStorage.token));
};
reader.readAsText(importFiles[0]);
}}
/>
<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-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
on:click={() => {
promptsImportInputElement.click();
}}
>
<div class=" self-center mr-2 font-medium line-clamp-1">{$i18n.t('Import Prompts')}</div>
<div class=" self-center">
<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 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>
</div>
</button>
<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-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
on:click={async () => {
// promptsImportInputElement.click();
let blob = new Blob([JSON.stringify($prompts)], {
type: 'application/json'
});
saveAs(blob, `prompts-export-${Date.now()}.json`);
}}
>
<div class=" self-center mr-2 font-medium line-clamp-1">{$i18n.t('Export Prompts')}</div>
<div class=" self-center">
<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 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>
</div>
</button>
<!-- <button
on:click={() => {
loadDefaultPrompts();
}}
>
dd
</button> -->
</div>
</div>
{#if $config?.features.enable_community_sharing}
<div class=" my-16">
<div class=" text-lg font-semibold mb-3 line-clamp-1">
{$i18n.t('Made by OpenWebUI Community')}
</div>
<a
class=" flex space-x-4 cursor-pointer w-full mb-2 px-3 py-2"
href="https://openwebui.com/#open-webui-community"
target="_blank"
>
<div class=" self-center w-10 flex-shrink-0">
<div
class="w-full h-10 flex justify-center rounded-full bg-transparent dark:bg-gray-700 border border-dashed border-gray-200"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-6"
>
<path
fill-rule="evenodd"
d="M12 3.75a.75.75 0 01.75.75v6.75h6.75a.75.75 0 010 1.5h-6.75v6.75a.75.75 0 01-1.5 0v-6.75H4.5a.75.75 0 010-1.5h6.75V4.5a.75.75 0 01.75-.75z"
clip-rule="evenodd"
/>
</svg>
</div>
</div>
<div class=" self-center">
<div class=" font-semibold line-clamp-1">{$i18n.t('Discover a prompt')}</div>
<div class=" text-sm line-clamp-1">
{$i18n.t('Discover, download, and explore custom prompts')}
</div>
</div>
</a>
{/if}
{:else}
<div class="w-full h-full flex justify-center items-center">
<Spinner />
</div>
{/if}

View file

@ -4,6 +4,9 @@
import Textarea from '$lib/components/common/Textarea.svelte';
import { toast } from 'svelte-sonner';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import AccessControl from '../common/AccessControl.svelte';
import LockClosed from '$lib/components/icons/LockClosed.svelte';
import AccessControlModal from '../common/AccessControlModal.svelte';
export let onSubmit: Function;
export let edit = false;
@ -17,6 +20,10 @@
let command = '';
let content = '';
let accessControl = null;
let showAccessControlModal = false;
$: if (!edit) {
command = title !== '' ? `${title.replace(/\s+/g, '-').toLowerCase()}` : '';
}
@ -28,7 +35,8 @@
await onSubmit({
title,
command,
content
content,
access_control: accessControl
});
} else {
toast.error(
@ -54,10 +62,14 @@
command = prompt.command.at(0) === '/' ? prompt.command.slice(1) : prompt.command;
content = prompt.content;
accessControl = prompt?.access_control ?? null;
}
});
</script>
<AccessControlModal bind:show={showAccessControlModal} bind:accessControl />
<div class="w-full max-h-full flex justify-center">
<form
class="flex flex-col w-full mb-10"
@ -76,13 +88,29 @@
placement="bottom-start"
>
<div class="flex flex-col w-full">
<div>
<div class="flex items-center">
<input
class="text-2xl font-semibold w-full bg-transparent outline-none"
placeholder={$i18n.t('Title')}
bind:value={title}
required
/>
<div>
<button
class="bg-gray-50 hover:bg-gray-100 text-black transition px-2 py-1 rounded-full flex gap-1 items-center"
type="button"
on:click={() => {
showAccessControlModal = true;
}}
>
<LockClosed strokeWidth="2.5" className="size-3.5" />
<div class="text-sm font-medium flex-shrink-0">
{$i18n.t('Share')}
</div>
</button>
</div>
</div>
<div class="flex gap-0.5 items-center text-xs text-gray-500">
@ -138,8 +166,8 @@
<div class="my-4 flex justify-end pb-20">
<button
class=" text-sm w-full lg:w-fit px-4 py-2 transition rounded-lg {loading
? ' cursor-not-allowed bg-white hover:bg-gray-100 text-black'
: ' bg-white hover:bg-gray-100 text-black'} flex justify-center"
? ' cursor-not-allowed bg-black hover:bg-gray-900 text-white dark:bg-white dark:hover:bg-gray-100 dark:text-black'
: 'bg-black hover:bg-gray-900 text-white dark:bg-white dark:hover:bg-gray-100 dark:text-black'} flex w-full justify-center"
type="submit"
disabled={loading}
>

View file

@ -4,7 +4,7 @@
const { saveAs } = fileSaver;
import { onMount, getContext } from 'svelte';
import { WEBUI_NAME, config, prompts, tools } from '$lib/stores';
import { WEBUI_NAME, config, prompts, tools as _tools, user } from '$lib/stores';
import { createNewPrompt, deletePromptByCommand, getPrompts } from '$lib/apis/prompts';
import { goto } from '$app/navigation';
@ -13,6 +13,7 @@
deleteToolById,
exportTools,
getToolById,
getToolList,
getTools
} from '$lib/apis/tools';
import ArrowDownTray from '../icons/ArrowDownTray.svelte';
@ -27,10 +28,13 @@
import GarbageBin from '../icons/GarbageBin.svelte';
import Search from '../icons/Search.svelte';
import Plus from '../icons/Plus.svelte';
import ChevronRight from '../icons/ChevronRight.svelte';
import Spinner from '../common/Spinner.svelte';
const i18n = getContext('i18n');
let shiftKey = false;
let loaded = false;
let toolsImportInputElement: HTMLInputElement;
let importFiles;
@ -44,8 +48,10 @@
let showDeleteConfirm = false;
let tools = [];
let filteredItems = [];
$: filteredItems = $tools.filter(
$: filteredItems = tools.filter(
(t) =>
query === '' ||
t.name.toLowerCase().includes(query.toLowerCase()) ||
@ -117,11 +123,20 @@
if (res) {
toast.success($i18n.t('Tool deleted successfully'));
tools.set(await getTools(localStorage.token));
init();
}
};
onMount(() => {
const init = async () => {
tools = await getToolList(localStorage.token);
_tools.set(await getTools(localStorage.token));
};
onMount(async () => {
await init();
loaded = true;
const onKeyDown = (event) => {
if (event.key === 'Shift') {
shiftKey = true;
@ -156,347 +171,336 @@
</title>
</svelte:head>
<div class="flex flex-col gap-1 mt-1.5 mb-2">
<div class="flex justify-between items-center">
<div class="flex md:self-center text-xl font-medium px-0.5 items-center">
{$i18n.t('Tools')}
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
<span class="text-lg font-medium text-gray-500 dark:text-gray-300"
>{filteredItems.length}</span
>
</div>
</div>
<div class=" flex w-full space-x-2">
<div class="flex flex-1">
<div class=" self-center ml-1 mr-3">
<Search className="size-3.5" />
{#if loaded}
<div class="flex flex-col gap-1 mt-1.5 mb-2">
<div class="flex justify-between items-center">
<div class="flex md:self-center text-xl font-medium px-0.5 items-center">
{$i18n.t('Tools')}
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
<span class="text-lg font-medium text-gray-500 dark:text-gray-300"
>{filteredItems.length}</span
>
</div>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-none bg-transparent"
bind:value={query}
placeholder={$i18n.t('Search Tools')}
/>
</div>
<div>
<a
class=" px-2 py-2 rounded-xl hover:bg-gray-700/10 dark:hover:bg-gray-100/10 dark:text-gray-300 dark:hover:text-white transition font-medium text-sm flex items-center space-x-1"
href="/workspace/tools/create"
>
<Plus className="size-3.5" />
</a>
<div class=" flex w-full space-x-2">
<div class="flex flex-1">
<div class=" self-center ml-1 mr-3">
<Search className="size-3.5" />
</div>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-none bg-transparent"
bind:value={query}
placeholder={$i18n.t('Search Tools')}
/>
</div>
<div>
<a
class=" px-2 py-2 rounded-xl hover:bg-gray-700/10 dark:hover:bg-gray-100/10 dark:text-gray-300 dark:hover:text-white transition font-medium text-sm flex items-center space-x-1"
href="/workspace/tools/create"
>
<Plus className="size-3.5" />
</a>
</div>
</div>
</div>
</div>
<div class="mb-5">
{#each filteredItems as tool}
<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"
>
<a
class=" flex flex-1 space-x-3.5 cursor-pointer w-full"
href={`/workspace/tools/edit?id=${encodeURIComponent(tool.id)}`}
<div class="mb-5">
{#each filteredItems as tool}
<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"
>
<div class="flex items-center text-left">
<div class=" flex-1 self-center pl-1">
<div class=" font-semibold flex items-center gap-1.5">
<div
class=" text-xs font-bold px-1 rounded uppercase line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
>
TOOL
</div>
{#if tool?.meta?.manifest?.version}
<a
class=" flex flex-1 space-x-3.5 cursor-pointer w-full"
href={`/workspace/tools/edit?id=${encodeURIComponent(tool.id)}`}
>
<div class="flex items-center text-left">
<div class=" flex-1 self-center pl-1">
<div class=" font-semibold flex items-center gap-1.5">
<div
class="text-xs font-bold px-1 rounded line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
class=" text-xs font-bold px-1 rounded uppercase line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
>
v{tool?.meta?.manifest?.version ?? ''}
TOOL
</div>
{/if}
<div class="line-clamp-1">
{tool.name}
{#if tool?.meta?.manifest?.version}
<div
class="text-xs font-bold px-1 rounded line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
>
v{tool?.meta?.manifest?.version ?? ''}
</div>
{/if}
<div class="line-clamp-1">
{tool.name}
</div>
</div>
</div>
<div class="flex gap-1.5 px-1">
<div class=" text-gray-500 text-xs font-medium flex-shrink-0">{tool.id}</div>
<div class="flex gap-1.5 px-1">
<div class=" text-gray-500 text-xs font-medium flex-shrink-0">{tool.id}</div>
<div class=" text-xs overflow-hidden text-ellipsis line-clamp-1">
{tool.meta.description}
<div class=" text-xs overflow-hidden text-ellipsis line-clamp-1">
{tool.meta.description}
</div>
</div>
</div>
</div>
</div>
</a>
<div class="flex flex-row gap-0.5 self-center">
{#if shiftKey}
<Tooltip content={$i18n.t('Delete')}>
<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={() => {
deleteHandler(tool);
}}
>
<GarbageBin />
</button>
</Tooltip>
{:else}
{#if tool?.meta?.manifest?.funding_url ?? false}
<Tooltip content="Support">
</a>
<div class="flex flex-row gap-0.5 self-center">
{#if shiftKey}
<Tooltip content={$i18n.t('Delete')}>
<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={() => {
deleteHandler(tool);
}}
>
<GarbageBin />
</button>
</Tooltip>
{:else}
{#if tool?.meta?.manifest?.funding_url ?? false}
<Tooltip content="Support">
<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={() => {
selectedTool = tool;
showManifestModal = true;
}}
>
<Heart />
</button>
</Tooltip>
{/if}
<Tooltip content={$i18n.t('Valves')}>
<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={() => {
selectedTool = tool;
showManifestModal = true;
showValvesModal = true;
}}
>
<Heart />
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
</button>
</Tooltip>
{/if}
<Tooltip content={$i18n.t('Valves')}>
<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={() => {
selectedTool = tool;
showValvesModal = true;
<ToolMenu
editHandler={() => {
goto(`/workspace/tools/edit?id=${encodeURIComponent(tool.id)}`);
}}
shareHandler={() => {
shareHandler(tool);
}}
cloneHandler={() => {
cloneHandler(tool);
}}
exportHandler={() => {
exportHandler(tool);
}}
deleteHandler={async () => {
selectedTool = tool;
showDeleteConfirm = true;
}}
onClose={() => {}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
<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"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
</button>
</Tooltip>
<ToolMenu
editHandler={() => {
goto(`/workspace/tools/edit?id=${encodeURIComponent(tool.id)}`);
}}
shareHandler={() => {
shareHandler(tool);
}}
cloneHandler={() => {
cloneHandler(tool);
}}
exportHandler={() => {
exportHandler(tool);
}}
deleteHandler={async () => {
selectedTool = tool;
showDeleteConfirm = true;
}}
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"
>
<EllipsisHorizontal className="size-5" />
</button>
</ToolMenu>
{/if}
<EllipsisHorizontal className="size-5" />
</button>
</ToolMenu>
{/if}
</div>
</div>
</div>
{/each}
</div>
<div class=" text-gray-500 text-xs mt-1 mb-2">
{$i18n.t(
'Admins have access to all tools at all times; users need tools assigned per model in the workspace.'
)}
</div>
<div class=" flex justify-end w-full mb-2">
<div class="flex space-x-2">
<input
id="documents-import-input"
bind:this={toolsImportInputElement}
bind:files={importFiles}
type="file"
accept=".json"
hidden
on:change={() => {
console.log(importFiles);
showConfirm = true;
}}
/>
<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-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
on:click={() => {
toolsImportInputElement.click();
}}
>
<div class=" self-center mr-2 font-medium line-clamp-1">{$i18n.t('Import Tools')}</div>
<div class=" self-center">
<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 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>
</div>
</button>
<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-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
on:click={async () => {
const _tools = await exportTools(localStorage.token).catch((error) => {
toast.error(error);
return null;
});
if (_tools) {
let blob = new Blob([JSON.stringify(_tools)], {
type: 'application/json'
});
saveAs(blob, `tools-export-${Date.now()}.json`);
}
}}
>
<div class=" self-center mr-2 font-medium line-clamp-1">{$i18n.t('Export Tools')}</div>
<div class=" self-center">
<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 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>
</div>
</button>
{/each}
</div>
</div>
{#if $config?.features.enable_community_sharing}
<div class=" my-16">
<div class=" text-lg font-semibold mb-3 line-clamp-1">
{$i18n.t('Made by OpenWebUI Community')}
</div>
{#if $user?.role === 'admin'}
<div class=" flex justify-end w-full mb-2">
<div class="flex space-x-2">
<input
id="documents-import-input"
bind:this={toolsImportInputElement}
bind:files={importFiles}
type="file"
accept=".json"
hidden
on:change={() => {
console.log(importFiles);
showConfirm = true;
}}
/>
<a
class=" flex space-x-4 cursor-pointer w-full mb-2 px-3 py-2"
href="https://openwebui.com/#open-webui-community"
target="_blank"
>
<div class=" self-center w-10 flex-shrink-0">
<div
class="w-full h-10 flex justify-center rounded-full bg-transparent dark:bg-gray-700 border border-dashed border-gray-200"
<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-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
on:click={() => {
toolsImportInputElement.click();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-6"
>
<path
fill-rule="evenodd"
d="M12 3.75a.75.75 0 01.75.75v6.75h6.75a.75.75 0 010 1.5h-6.75v6.75a.75.75 0 01-1.5 0v-6.75H4.5a.75.75 0 010-1.5h6.75V4.5a.75.75 0 01.75-.75z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center mr-2 font-medium line-clamp-1">{$i18n.t('Import Tools')}</div>
<div class=" self-center">
<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 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>
</div>
</button>
<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-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
on:click={async () => {
const _tools = await exportTools(localStorage.token).catch((error) => {
toast.error(error);
return null;
});
if (_tools) {
let blob = new Blob([JSON.stringify(_tools)], {
type: 'application/json'
});
saveAs(blob, `tools-export-${Date.now()}.json`);
}
}}
>
<div class=" self-center mr-2 font-medium line-clamp-1">{$i18n.t('Export Tools')}</div>
<div class=" self-center">
<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 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>
</div>
</button>
</div>
</div>
{/if}
{#if $config?.features.enable_community_sharing}
<div class=" my-16">
<div class=" text-lg font-semibold mb-0.5 line-clamp-1">
{$i18n.t('Made by OpenWebUI Community')}
</div>
<div class=" self-center">
<div class=" font-semibold line-clamp-1">{$i18n.t('Discover a tool')}</div>
<div class=" text-sm line-clamp-1">
{$i18n.t('Discover, download, and explore custom tools')}
<a
class=" flex cursor-pointer items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-850 w-full mb-2 px-3.5 py-1.5 rounded-xl transition"
href="https://openwebui.com/#open-webui-community"
target="_blank"
>
<div class=" self-center">
<div class=" font-semibold line-clamp-1">{$i18n.t('Discover a tool')}</div>
<div class=" text-sm line-clamp-1">
{$i18n.t('Discover, download, and explore custom tools')}
</div>
</div>
<div>
<div>
<ChevronRight />
</div>
</div>
</a>
</div>
{/if}
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete tool?')}
on:confirm={() => {
deleteHandler(selectedTool);
}}
>
<div class=" text-sm text-gray-500">
{$i18n.t('This will delete')} <span class=" font-semibold">{selectedTool.name}</span>.
</div>
</DeleteConfirmDialog>
<ValvesModal bind:show={showValvesModal} type="tool" id={selectedTool?.id ?? null} />
<ManifestModal bind:show={showManifestModal} manifest={selectedTool?.meta?.manifest ?? {}} />
<ConfirmDialog
bind:show={showConfirm}
on:confirm={() => {
const reader = new FileReader();
reader.onload = async (event) => {
const _tools = JSON.parse(event.target.result);
console.log(_tools);
for (const tool of _tools) {
const res = await createNewTool(localStorage.token, tool).catch((error) => {
toast.error(error);
return null;
});
}
toast.success($i18n.t('Tool imported successfully'));
tools.set(await getTools(localStorage.token));
};
reader.readAsText(importFiles[0]);
}}
>
<div class="text-sm text-gray-500">
<div class=" bg-yellow-500/20 text-yellow-700 dark:text-yellow-200 rounded-lg px-4 py-3">
<div>{$i18n.t('Please carefully review the following warnings:')}</div>
<ul class=" mt-1 list-disc pl-4 text-xs">
<li>
{$i18n.t('Tools have a function calling system that allows arbitrary code execution')}.
</li>
<li>{$i18n.t('Do not install tools from sources you do not fully trust.')}</li>
</ul>
</div>
</a>
<div class="my-3">
{$i18n.t(
'I acknowledge that I have read and I understand the implications of my action. I am aware of the risks associated with executing arbitrary code and I have verified the trustworthiness of the source.'
)}
</div>
</div>
</ConfirmDialog>
{:else}
<div class="w-full h-full flex justify-center items-center">
<Spinner />
</div>
{/if}
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete tool?')}
on:confirm={() => {
deleteHandler(selectedTool);
}}
>
<div class=" text-sm text-gray-500">
{$i18n.t('This will delete')} <span class=" font-semibold">{selectedTool.name}</span>.
</div>
</DeleteConfirmDialog>
<ValvesModal bind:show={showValvesModal} type="tool" id={selectedTool?.id ?? null} />
<ManifestModal bind:show={showManifestModal} manifest={selectedTool?.meta?.manifest ?? {}} />
<ConfirmDialog
bind:show={showConfirm}
on:confirm={() => {
const reader = new FileReader();
reader.onload = async (event) => {
const _tools = JSON.parse(event.target.result);
console.log(_tools);
for (const tool of _tools) {
const res = await createNewTool(localStorage.token, tool).catch((error) => {
toast.error(error);
return null;
});
}
toast.success($i18n.t('Tool imported successfully'));
tools.set(await getTools(localStorage.token));
};
reader.readAsText(importFiles[0]);
}}
>
<div class="text-sm text-gray-500">
<div class=" bg-yellow-500/20 text-yellow-700 dark:text-yellow-200 rounded-lg px-4 py-3">
<div>{$i18n.t('Please carefully review the following warnings:')}</div>
<ul class=" mt-1 list-disc pl-4 text-xs">
<li>
{$i18n.t('Tools have a function calling system that allows arbitrary code execution')}.
</li>
<li>{$i18n.t('Do not install tools from sources you do not fully trust.')}</li>
</ul>
</div>
<div class="my-3">
{$i18n.t(
'I acknowledge that I have read and I understand the implications of my action. I am aware of the risks associated with executing arbitrary code and I have verified the trustworthiness of the source.'
)}
</div>
</div>
</ConfirmDialog>

View file

@ -9,12 +9,16 @@
import Badge from '$lib/components/common/Badge.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import LockClosed from '$lib/components/icons/LockClosed.svelte';
import AccessControlModal from '../common/AccessControlModal.svelte';
const dispatch = createEventDispatcher();
let formElement = null;
let loading = false;
let showConfirm = false;
let showAccessControlModal = false;
export let edit = false;
export let clone = false;
@ -25,6 +29,8 @@
description: ''
};
export let content = '';
export let accessControl = null;
let _content = '';
$: if (content) {
@ -148,7 +154,8 @@ class Tools:
id,
name,
meta,
content
content,
access_control: accessControl
});
};
@ -172,6 +179,8 @@ class Tools:
};
</script>
<AccessControlModal bind:show={showAccessControlModal} bind:accessControl />
<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">
<form
@ -203,11 +212,11 @@ class Tools:
</div>
<div class="flex-1">
<Tooltip content={$i18n.t('e.g. My ToolKit')} placement="top-start">
<Tooltip content={$i18n.t('e.g. My Tools')} placement="top-start">
<input
class="w-full text-2xl font-medium bg-transparent outline-none"
class="w-full text-2xl font-semibold bg-transparent outline-none"
type="text"
placeholder={$i18n.t('Toolkit Name')}
placeholder={$i18n.t('Tool Name')}
bind:value={name}
required
/>
@ -215,7 +224,19 @@ class Tools:
</div>
<div>
<Badge type="muted" content={$i18n.t('Tool')} />
<button
class="bg-gray-50 hover:bg-gray-100 text-black transition px-2 py-1 rounded-full flex gap-1 items-center"
type="button"
on:click={() => {
showAccessControlModal = true;
}}
>
<LockClosed strokeWidth="2.5" className="size-3.5" />
<div class="text-sm font-medium flex-shrink-0">
{$i18n.t('Share')}
</div>
</button>
</div>
</div>
@ -225,15 +246,11 @@ class Tools:
{id}
</div>
{:else}
<Tooltip
className="w-full"
content={$i18n.t('e.g. my_toolkit')}
placement="top-start"
>
<Tooltip className="w-full" content={$i18n.t('e.g. my_tools')} placement="top-start">
<input
class="w-full text-sm disabled:text-gray-500 bg-transparent outline-none"
type="text"
placeholder={$i18n.t('Toolkit ID')}
placeholder={$i18n.t('Tool ID')}
bind:value={id}
required
disabled={edit}
@ -243,13 +260,13 @@ class Tools:
<Tooltip
className="w-full self-center items-center flex"
content={$i18n.t('e.g. A toolkit for performing various operations')}
content={$i18n.t('e.g. Tools for performing various operations')}
placement="top-start"
>
<input
class="w-full text-sm bg-transparent outline-none"
type="text"
placeholder={$i18n.t('Toolkit Description')}
placeholder={$i18n.t('Tool Description')}
bind:value={meta.description}
required
/>

View file

@ -0,0 +1,193 @@
<script lang="ts">
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import { getGroups } from '$lib/apis/groups';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import UserCircleSolid from '$lib/components/icons/UserCircleSolid.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let onChange: Function = () => {};
export let accessControl = null;
let selectedGroupId = '';
let groups = [];
onMount(async () => {
groups = await getGroups(localStorage.token);
});
$: onChange(accessControl);
</script>
<div class=" rounded-lg flex flex-col gap-2">
<div class="">
<div class=" text-sm font-semibold mb-1">{$i18n.t('Visibility')}</div>
<div class="flex gap-2.5 items-center mb-1">
<div>
<div class=" p-2 bg-black/5 dark:bg-white/5 rounded-full">
{#if accessControl !== null}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
/>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6.115 5.19l.319 1.913A6 6 0 008.11 10.36L9.75 12l-.387.775c-.217.433-.132.956.21 1.298l1.348 1.348c.21.21.329.497.329.795v1.089c0 .426.24.815.622 1.006l.153.076c.433.217.956.132 1.298-.21l.723-.723a8.7 8.7 0 002.288-4.042 1.087 1.087 0 00-.358-1.099l-1.33-1.108c-.251-.21-.582-.299-.905-.245l-1.17.195a1.125 1.125 0 01-.98-.314l-.295-.295a1.125 1.125 0 010-1.591l.13-.132a1.125 1.125 0 011.3-.21l.603.302a.809.809 0 001.086-1.086L14.25 7.5l1.256-.837a4.5 4.5 0 001.528-1.732l.146-.292M6.115 5.19A9 9 0 1017.18 4.64M6.115 5.19A8.965 8.965 0 0112 3c1.929 0 3.716.607 5.18 1.64"
/>
</svg>
{/if}
</div>
</div>
<div>
<select
id="models"
class="outline-none bg-transparent text-sm font-medium rounded-lg block w-fit pr-10 max-w-full placeholder-gray-400"
value={accessControl !== null ? 'private' : 'public'}
on:change={(e) => {
if (e.target.value === 'public') {
accessControl = null;
} else {
accessControl = {
read: {
group_ids: []
}
};
}
}}
>
<option class=" text-gray-700" value="private" selected>Private</option>
<option class=" text-gray-700" value="public" selected>Public</option>
</select>
<div class=" text-xs text-gray-400 font-medium">
{#if accessControl !== null}
{$i18n.t('Only select users and groups with permission can access')}
{:else}
{$i18n.t('Accessible to all users')}
{/if}
</div>
</div>
</div>
</div>
{#if accessControl !== null}
{@const accessGroups = groups.filter((group) =>
accessControl.read.group_ids.includes(group.id)
)}
<div>
<div class="">
<div class="flex justify-between mb-1.5">
<div class="text-sm font-semibold">
{$i18n.t('Groups')}
</div>
</div>
<div class="flex flex-col gap-2">
{#if accessGroups.length > 0}
{#each accessGroups as group}
<div class="flex items-center gap-3 justify-between text-xs w-full transition">
<div class="flex items-center gap-1.5 w-full font-medium">
<div>
<UserCircleSolid className="size-4" />
</div>
<div>
{group.name}
</div>
</div>
<div class="w-full flex justify-end">
<button
class=" rounded-full p-1 hover:bg-gray-100 dark:hover:bg-gray-850 transition"
type="button"
on:click={() => {
accessControl.read.group_ids = accessControl.read.group_ids.filter(
(id) => id !== group.id
);
}}
>
<XMark />
</button>
</div>
</div>
{/each}
{:else}
<div class="flex items-center justify-center">
<div class="text-gray-500 text-xs text-center py-2 px-10">
{$i18n.t('No groups with access, add a group to grant access')}
</div>
</div>
{/if}
</div>
</div>
<hr class=" my-2 border-black/5 dark:border-white/5" />
<div class="mb-1">
<div class="flex w-full">
<div class="flex flex-1 items-center">
<div class="w-full">
<select
class="outline-none bg-transparent text-sm font-medium rounded-lg block w-full pr-10 max-w-full dark:placeholder-gray-700"
bind:value={selectedGroupId}
>
<option class=" text-gray-700" value="" disabled selected
>{$i18n.t('Select a group')}</option
>
{#each groups.filter((group) => !accessControl.read.group_ids.includes(group.id)) as group}
<option class=" text-gray-700" value={group.id}>{group.name}</option>
{/each}
</select>
</div>
<div>
<Tooltip content={$i18n.t('Add Group')}>
<button
class=" p-1 rounded-xl bg-transparent dark:hover:bg-white/5 hover:bg-black/5 transition font-medium text-sm flex items-center space-x-1"
type="button"
on:click={() => {
if (selectedGroupId !== '') {
accessControl.read.group_ids = [
...accessControl.read.group_ids,
selectedGroupId
];
selectedGroupId = '';
}
}}
>
<Plus className="size-3.5" />
</button>
</Tooltip>
</div>
</div>
</div>
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,43 @@
<script>
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import Modal from '$lib/components/common/Modal.svelte';
import AccessControl from './AccessControl.svelte';
export let show = false;
export let accessControl = null;
export let onChange = () => {};
</script>
<Modal size="sm" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-3 pb-1">
<div class=" text-lg font-medium self-center font-primary">
{$i18n.t('Share')}
</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
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 class="w-full px-5 pb-4">
<AccessControl bind:accessControl {onChange} />
</div>
</div>
</Modal>

View file

@ -10,7 +10,7 @@
import { page } from '$app/stores';
import { fade } from 'svelte/transition';
import { getKnowledgeItems } from '$lib/apis/knowledge';
import { getKnowledgeBases } from '$lib/apis/knowledge';
import { getFunctions } from '$lib/apis/functions';
import { getModels, getVersionUpdates } from '$lib/apis';
import { getAllTags } from '$lib/apis/chats';

View file

@ -65,6 +65,13 @@
href="/admin/evaluations">{$i18n.t('Evaluations')}</a
>
<a
class="min-w-fit rounded-full p-1.5 {$page.url.pathname.includes('/admin/functions')
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
href="/admin/functions">{$i18n.t('Functions')}</a
>
<a
class="min-w-fit rounded-full p-1.5 {$page.url.pathname.includes('/admin/settings')
? ''

View file

@ -3,7 +3,7 @@
import { functions } from '$lib/stores';
import { getFunctions } from '$lib/apis/functions';
import Functions from '$lib/components/workspace/Functions.svelte';
import Functions from '$lib/components/admin/Functions.svelte';
onMount(async () => {
await Promise.all([

View file

@ -5,7 +5,7 @@
import { functions, models } from '$lib/stores';
import { createNewFunction, getFunctions } from '$lib/apis/functions';
import FunctionEditor from '$lib/components/workspace/Functions/FunctionEditor.svelte';
import FunctionEditor from '$lib/components/admin/Functions/FunctionEditor.svelte';
import { getModels } from '$lib/apis';
import { compareVersion, extractFrontmatter } from '$lib/utils';
import { WEBUI_VERSION } from '$lib/constants';

View file

@ -7,7 +7,7 @@
import { functions, models } from '$lib/stores';
import { updateFunctionById, getFunctions, getFunctionById } from '$lib/apis/functions';
import FunctionEditor from '$lib/components/workspace/Functions/FunctionEditor.svelte';
import FunctionEditor from '$lib/components/admin/Functions/FunctionEditor.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import { getModels } from '$lib/apis';
import { compareVersion, extractFrontmatter } from '$lib/utils';

View file

@ -15,11 +15,6 @@
import { goto } from '$app/navigation';
import MenuLines from '$lib/components/icons/MenuLines.svelte';
import { getModels } from '$lib/apis';
import { getPrompts } from '$lib/apis/prompts';
import { getKnowledgeItems } from '$lib/apis/knowledge';
import { getTools } from '$lib/apis/tools';
import { getFunctions } from '$lib/apis/functions';
const i18n = getContext('i18n');
@ -27,7 +22,21 @@
onMount(async () => {
if ($user?.role !== 'admin') {
await goto('/');
if ($page.url.pathname.includes('/models') && !$user?.permissions?.workspace?.models) {
goto('/');
} else if (
$page.url.pathname.includes('/knowledge') &&
!$user?.permissions?.workspace?.knowledge
) {
goto('/');
} else if (
$page.url.pathname.includes('/prompts') &&
!$user?.permissions?.workspace?.prompts
) {
goto('/');
} else if ($page.url.pathname.includes('/tools') && !$user?.permissions?.workspace?.tools) {
goto('/');
}
}
loaded = true;
@ -46,7 +55,7 @@
? 'md:max-w-[calc(100%-260px)]'
: ''}"
>
<div class=" px-2.5 py-1 backdrop-blur-xl">
<div class=" px-2.5 pt-1 backdrop-blur-xl">
<div class=" flex items-center gap-1">
<div class="{$showSidebar ? 'md:hidden' : ''} self-center flex flex-none items-center">
<button
@ -67,50 +76,51 @@
<div
class="flex gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-full bg-transparent py-1 touch-auto pointer-events-auto"
>
<a
class="min-w-fit rounded-full p-1.5 {$page.url.pathname.includes('/workspace/models')
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
href="/workspace/models">{$i18n.t('Models')}</a
>
{#if $user?.role === 'admin' || $user?.permissions?.workspace?.models}
<a
class="min-w-fit rounded-full p-1.5 {$page.url.pathname.includes(
'/workspace/models'
)
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
href="/workspace/models">{$i18n.t('Models')}</a
>
{/if}
<a
class="min-w-fit rounded-full p-1.5 {$page.url.pathname.includes(
'/workspace/knowledge'
)
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
href="/workspace/knowledge"
>
{$i18n.t('Knowledge')}
</a>
{#if $user?.role === 'admin' || $user?.permissions?.workspace?.knowledge}
<a
class="min-w-fit rounded-full p-1.5 {$page.url.pathname.includes(
'/workspace/knowledge'
)
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
href="/workspace/knowledge"
>
{$i18n.t('Knowledge')}
</a>
{/if}
<a
class="min-w-fit rounded-full p-1.5 {$page.url.pathname.includes('/workspace/prompts')
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
href="/workspace/prompts">{$i18n.t('Prompts')}</a
>
{#if $user?.role === 'admin' || $user?.permissions?.workspace?.prompts}
<a
class="min-w-fit rounded-full p-1.5 {$page.url.pathname.includes(
'/workspace/prompts'
)
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
href="/workspace/prompts">{$i18n.t('Prompts')}</a
>
{/if}
<a
class="min-w-fit rounded-full p-1.5 {$page.url.pathname.includes('/workspace/tools')
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
href="/workspace/tools"
>
{$i18n.t('Tools')}
</a>
<a
class="min-w-fit rounded-full p-1.5 {$page.url.pathname.includes(
'/workspace/functions'
)
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
href="/workspace/functions"
>
{$i18n.t('Functions')}
</a>
{#if $user?.role === 'admin' || $user?.permissions?.workspace?.tools}
<a
class="min-w-fit rounded-full p-1.5 {$page.url.pathname.includes('/workspace/tools')
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
href="/workspace/tools"
>
{$i18n.t('Tools')}
</a>
{/if}
</div>
</div>
@ -118,7 +128,7 @@
</div>
</div>
<div class=" -mt-1 pb-1 px-[18px] flex-1 max-h-full overflow-y-auto" id="workspace-container">
<div class=" pb-1 px-[18px] flex-1 max-h-full overflow-y-auto" id="workspace-container">
<slot />
</div>
</div>

View file

@ -2,13 +2,13 @@
import { onMount } from 'svelte';
import { knowledge } from '$lib/stores';
import { getKnowledgeItems } from '$lib/apis/knowledge';
import { getKnowledgeBases } from '$lib/apis/knowledge';
import Knowledge from '$lib/components/workspace/Knowledge.svelte';
onMount(async () => {
await Promise.all([
(async () => {
knowledge.set(await getKnowledgeItems(localStorage.token));
knowledge.set(await getKnowledgeBases(localStorage.token));
})()
]);
});

View file

@ -1,5 +1,5 @@
<script>
import Collection from '$lib/components/workspace/Knowledge/Collection.svelte';
import KnowledgeBase from '$lib/components/workspace/Knowledge/KnowledgeBase.svelte';
</script>
<Collection />
<KnowledgeBase />

View file

@ -1,5 +1,5 @@
<script>
import CreateCollection from '$lib/components/workspace/Knowledge/CreateCollection.svelte';
import CreateKnowledgeBase from '$lib/components/workspace/Knowledge/CreateKnowledgeBase.svelte';
</script>
<CreateCollection />
<CreateKnowledgeBase />

View file

@ -5,7 +5,7 @@
import { models } from '$lib/stores';
import { onMount, tick, getContext } from 'svelte';
import { addNewModel, getModelById, getModelInfos } from '$lib/apis/models';
import { createNewModel, getModelById } from '$lib/apis/models';
import { getModels } from '$lib/apis';
import ModelEditor from '$lib/components/workspace/Models/ModelEditor.svelte';
@ -21,7 +21,7 @@
}
if (modelInfo) {
const res = await addNewModel(localStorage.token, {
const res = await createNewModel(localStorage.token, {
...modelInfo,
meta: {
...modelInfo.meta,
@ -31,6 +31,9 @@
: null
},
params: { ...modelInfo.params }
}).catch((error) => {
toast.error(error);
return null;
});
if (res) {

View file

@ -8,17 +8,20 @@
import { page } from '$app/stores';
import { models } from '$lib/stores';
import { updateModelById } from '$lib/apis/models';
import { getModelById, updateModelById } from '$lib/apis/models';
import { getModels } from '$lib/apis';
import ModelEditor from '$lib/components/workspace/Models/ModelEditor.svelte';
let model = null;
onMount(() => {
onMount(async () => {
const _id = $page.url.searchParams.get('id');
if (_id) {
model = $models.find((m) => m.id === _id && m?.owned_by !== 'arena');
model = await getModelById(localStorage.token, _id).catch((e) => {
return null;
});
if (!model) {
goto('/workspace/models');
}

View file

@ -1,19 +1,5 @@
<script>
import { onMount } from 'svelte';
import { prompts } from '$lib/stores';
import { getPrompts } from '$lib/apis/prompts';
import Prompts from '$lib/components/workspace/Prompts.svelte';
onMount(async () => {
await Promise.all([
(async () => {
prompts.set(await getPrompts(localStorage.token));
})()
]);
});
</script>
{#if $prompts !== null}
<Prompts />
{/if}
<Prompts />

View file

@ -1,23 +1,24 @@
<script>
<script lang="ts">
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import { prompts } from '$lib/stores';
import { onMount, tick, getContext } from 'svelte';
const i18n = getContext('i18n');
import { createNewPrompt, getPrompts } from '$lib/apis/prompts';
import PromptEditor from '$lib/components/workspace/Prompts/PromptEditor.svelte';
let prompt = null;
const onSubmit = async ({ title, command, content }) => {
const prompt = await createNewPrompt(localStorage.token, command, title, content).catch(
(error) => {
toast.error(error);
return null;
}
);
const onSubmit = async (_prompt) => {
const prompt = await createNewPrompt(localStorage.token, _prompt).catch((error) => {
toast.error(error);
return null;
});
if (prompt) {
toast.success($i18n.t('Prompt created successfully'));
await prompts.set(await getPrompts(localStorage.token));
await goto('/workspace/prompts');
}
@ -37,7 +38,8 @@
prompt = {
title: _prompt.title,
command: _prompt.command,
content: _prompt.content
content: _prompt.content,
access_control: null
};
});
@ -51,7 +53,8 @@
prompt = {
title: _prompt.title,
command: _prompt.command,
content: _prompt.content
content: _prompt.content,
access_control: null
};
sessionStorage.removeItem('prompt');
}

View file

@ -1,24 +1,26 @@
<script>
<script lang="ts">
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import { prompts } from '$lib/stores';
import { onMount, tick, getContext } from 'svelte';
import { getPrompts, updatePromptByCommand } from '$lib/apis/prompts';
const i18n = getContext('i18n');
import { getPromptByCommand, getPrompts, updatePromptByCommand } from '$lib/apis/prompts';
import { page } from '$app/stores';
import PromptEditor from '$lib/components/workspace/Prompts/PromptEditor.svelte';
let prompt = null;
const onSubmit = async ({ title, command, content }) => {
const prompt = await updatePromptByCommand(localStorage.token, command, title, content).catch(
(error) => {
toast.error(error);
return null;
}
);
const onSubmit = async (_prompt) => {
console.log(_prompt);
const prompt = await updatePromptByCommand(localStorage.token, _prompt).catch((error) => {
toast.error(error);
return null;
});
if (prompt) {
toast.success($i18n.t('Prompt updated successfully'));
await prompts.set(await getPrompts(localStorage.token));
await goto('/workspace/prompts');
}
@ -27,13 +29,20 @@
onMount(async () => {
const command = $page.url.searchParams.get('command');
if (command) {
const _prompt = $prompts.filter((prompt) => prompt.command === command).at(0);
const _prompt = await getPromptByCommand(
localStorage.token,
command.replace(/\//g, '')
).catch((error) => {
toast.error(error);
return null;
});
if (_prompt) {
prompt = {
title: _prompt.title,
command: _prompt.command,
content: _prompt.content
content: _prompt.content,
access_control: _prompt?.access_control ?? null
};
} else {
goto('/workspace/prompts');

View file

@ -1,19 +1,7 @@
<script>
import { onMount } from 'svelte';
import { tools } from '$lib/stores';
import { getTools } from '$lib/apis/tools';
import Tools from '$lib/components/workspace/Tools.svelte';
onMount(async () => {
await Promise.all([
(async () => {
tools.set(await getTools(localStorage.token));
})()
]);
});
</script>
{#if $tools !== null}
<Tools />
{/if}
<Tools />

Some files were not shown because too many files have changed in this diff Show more