Browse Source

Merge pull request #6931 from open-webui/groups

feat: user groups
Timothy Jaeryang Baek 5 months ago
parent
commit
034674c19c
100 changed files with 7208 additions and 4260 deletions
  1. 83 73
      backend/open_webui/apps/ollama/main.py
  2. 60 49
      backend/open_webui/apps/openai/main.py
  3. 16 7
      backend/open_webui/apps/retrieval/utils.py
  4. 15 7
      backend/open_webui/apps/webui/main.py
  5. 0 157
      backend/open_webui/apps/webui/models/documents.py
  6. 181 0
      backend/open_webui/apps/webui/models/groups.py
  7. 55 9
      backend/open_webui/apps/webui/models/knowledge.py
  8. 87 2
      backend/open_webui/apps/webui/models/models.py
  9. 39 5
      backend/open_webui/apps/webui/models/prompts.py
  10. 38 1
      backend/open_webui/apps/webui/models/tools.py
  11. 95 31
      backend/open_webui/apps/webui/routers/auths.py
  12. 9 5
      backend/open_webui/apps/webui/routers/chats.py
  13. 120 0
      backend/open_webui/apps/webui/routers/groups.py
  14. 192 77
      backend/open_webui/apps/webui/routers/knowledge.py
  15. 105 36
      backend/open_webui/apps/webui/routers/models.py
  16. 52 5
      backend/open_webui/apps/webui/routers/prompts.py
  17. 128 78
      backend/open_webui/apps/webui/routers/tools.py
  18. 41 4
      backend/open_webui/apps/webui/routers/users.py
  19. 1 1
      backend/open_webui/apps/webui/utils.py
  20. 39 20
      backend/open_webui/config.py
  21. 257 191
      backend/open_webui/main.py
  22. 85 0
      backend/open_webui/migrations/versions/922e7a387820_add_group_table.py
  23. 95 0
      backend/open_webui/utils/access_control.py
  24. 22 12
      backend/open_webui/utils/tools.py
  25. 7 2
      backend/open_webui/utils/utils.py
  26. 1 0
      backend/requirements.txt
  27. 1 0
      pyproject.toml
  28. 163 0
      src/lib/apis/groups/index.ts
  29. 6 20
      src/lib/apis/index.ts
  30. 38 4
      src/lib/apis/knowledge/index.ts
  31. 84 9
      src/lib/apis/models/index.ts
  32. 4 2
      src/lib/apis/ollama/index.ts
  33. 52 13
      src/lib/apis/prompts/index.ts
  34. 33 0
      src/lib/apis/tools/index.ts
  35. 34 4
      src/lib/apis/users/index.ts
  36. 15 28
      src/lib/components/admin/Functions.svelte
  37. 1 1
      src/lib/components/admin/Functions/FunctionEditor.svelte
  38. 0 0
      src/lib/components/admin/Functions/FunctionMenu.svelte
  39. 1 1
      src/lib/components/admin/Settings.svelte
  40. 1 0
      src/lib/components/admin/Settings/Connections.svelte
  41. 1019 0
      src/lib/components/admin/Settings/Connections/ManageOllamaModal.svelte
  42. 20 1
      src/lib/components/admin/Settings/Connections/OllamaConnection.svelte
  43. 5 12
      src/lib/components/admin/Settings/Documents.svelte
  44. 279 1018
      src/lib/components/admin/Settings/Models.svelte
  45. 0 214
      src/lib/components/admin/Settings/Users.svelte
  46. 28 4
      src/lib/components/admin/Users.svelte
  47. 117 3
      src/lib/components/admin/Users/Groups.svelte
  48. 149 0
      src/lib/components/admin/Users/Groups/AddGroupModal.svelte
  49. 61 0
      src/lib/components/admin/Users/Groups/Display.svelte
  50. 328 0
      src/lib/components/admin/Users/Groups/EditGroupModal.svelte
  51. 84 0
      src/lib/components/admin/Users/Groups/GroupItem.svelte
  52. 204 0
      src/lib/components/admin/Users/Groups/Permissions.svelte
  53. 122 0
      src/lib/components/admin/Users/Groups/Users.svelte
  54. 287 301
      src/lib/components/admin/Users/UserList.svelte
  55. 27 18
      src/lib/components/chat/Chat.svelte
  56. 550 349
      src/lib/components/chat/MessageInput.svelte
  57. 2 2
      src/lib/components/chat/MessageInput/Commands.svelte
  58. 19 18
      src/lib/components/chat/MessageInput/InputMenu.svelte
  59. 10 12
      src/lib/components/chat/Messages/ResponseMessage.svelte
  60. 1 1
      src/lib/components/chat/ModelSelector.svelte
  61. 10 23
      src/lib/components/chat/ModelSelector/Selector.svelte
  62. 1 2
      src/lib/components/chat/Placeholder.svelte
  63. 2 4
      src/lib/components/common/Switch.svelte
  64. 19 0
      src/lib/components/icons/LockClosed.svelte
  65. 11 0
      src/lib/components/icons/UserCircleSolid.svelte
  66. 9 0
      src/lib/components/icons/UserPlusSolid.svelte
  67. 9 0
      src/lib/components/icons/UsersSolid.svelte
  68. 20 0
      src/lib/components/icons/Wrench.svelte
  69. 1 1
      src/lib/components/layout/Sidebar.svelte
  70. 113 101
      src/lib/components/workspace/Knowledge.svelte
  71. 16 3
      src/lib/components/workspace/Knowledge/CreateKnowledgeBase.svelte
  72. 78 44
      src/lib/components/workspace/Knowledge/KnowledgeBase.svelte
  73. 0 0
      src/lib/components/workspace/Knowledge/KnowledgeBase/AddContentMenu.svelte
  74. 0 0
      src/lib/components/workspace/Knowledge/KnowledgeBase/AddTextContentModal.svelte
  75. 0 0
      src/lib/components/workspace/Knowledge/KnowledgeBase/Files.svelte
  76. 288 489
      src/lib/components/workspace/Models.svelte
  77. 1 1
      src/lib/components/workspace/Models/Knowledge.svelte
  78. 94 57
      src/lib/components/workspace/Models/ModelEditor.svelte
  79. 1 64
      src/lib/components/workspace/Models/ModelMenu.svelte
  80. 240 231
      src/lib/components/workspace/Prompts.svelte
  81. 32 4
      src/lib/components/workspace/Prompts/PromptEditor.svelte
  82. 306 302
      src/lib/components/workspace/Tools.svelte
  83. 30 13
      src/lib/components/workspace/Tools/ToolkitEditor.svelte
  84. 193 0
      src/lib/components/workspace/common/AccessControl.svelte
  85. 43 0
      src/lib/components/workspace/common/AccessControlModal.svelte
  86. 1 1
      src/routes/(app)/+layout.svelte
  87. 7 0
      src/routes/(app)/admin/+layout.svelte
  88. 1 1
      src/routes/(app)/admin/functions/+page.svelte
  89. 1 1
      src/routes/(app)/admin/functions/create/+page.svelte
  90. 1 1
      src/routes/(app)/admin/functions/edit/+page.svelte
  91. 59 49
      src/routes/(app)/workspace/+layout.svelte
  92. 2 2
      src/routes/(app)/workspace/knowledge/+page.svelte
  93. 2 2
      src/routes/(app)/workspace/knowledge/[id]/+page.svelte
  94. 2 2
      src/routes/(app)/workspace/knowledge/create/+page.svelte
  95. 5 2
      src/routes/(app)/workspace/models/create/+page.svelte
  96. 6 3
      src/routes/(app)/workspace/models/edit/+page.svelte
  97. 1 15
      src/routes/(app)/workspace/prompts/+page.svelte
  98. 14 11
      src/routes/(app)/workspace/prompts/create/+page.svelte
  99. 20 11
      src/routes/(app)/workspace/prompts/edit/+page.svelte
  100. 1 13
      src/routes/(app)/workspace/tools/+page.svelte

+ 83 - 73
backend/open_webui/apps/ollama/main.py

@@ -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": [

+ 60 - 49
backend/open_webui/apps/openai/main.py

@@ -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": []}
+        return {"data": []}
 
-    responses = await get_all_models_raw()
-    if raw:
-        return responses
+    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:

+ 16 - 7
backend/open_webui/apps/retrieval/utils.py

@@ -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
 

+ 15 - 7
backend/open_webui/apps/webui/main.py

@@ -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(evaluations.router, prefix="/evaluations", tags=["evaluations"])
-
 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(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,
         },

+ 0 - 157
backend/open_webui/apps/webui/models/documents.py

@@ -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()

+ 181 - 0
backend/open_webui/apps/webui/models/groups.py

@@ -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()

+ 55 - 9
backend/open_webui/apps/webui/models/knowledge.py

@@ -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(),
+                        "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(
                     {
-                        **form_data.model_dump(exclude_none=True),
+                        "data": data,
                         "updated_at": int(time.time()),
                     }
                 )

+ 87 - 2
backend/open_webui/apps/webui/models/models.py

@@ -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()
 

+ 39 - 5
backend/open_webui/apps/webui/models/prompts.py

@@ -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)

+ 38 - 1
backend/open_webui/apps/webui/models/tools.py

@@ -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:

+ 95 - 31
backend/open_webui/apps/webui/routers/auths.py

@@ -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}
 

+ 9 - 5
backend/open_webui/apps/webui/routers/chats.py

@@ -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,

+ 120 - 0
backend/open_webui/apps/webui/routers/groups.py

@@ -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),
+        )

+ 192 - 77
backend/open_webui/apps/webui/routers/knowledge.py

@@ -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 = []
-
-        for knowledge in Knowledges.get_knowledge_items():
+        knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(user.id, "read")
+
+    # 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", [])
+            )
 
-            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_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)
 
-                # 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])
+                    data["file_ids"] = file_ids
+                    Knowledges.update_knowledge_data_by_id(
+                        id=knowledge_base.id, data=data
                     )
-                    if missing_files:
-                        data = knowledge.data or {}
-                        file_ids = data.get("file_ids", [])
 
-                        for missing_file in missing_files:
-                            file_ids.remove(missing_file)
+                    files = Files.get_file_metadatas_by_ids(file_ids)
 
-                        data["file_ids"] = file_ids
-                        Knowledges.update_knowledge_by_id(
-                            id=knowledge.id, form_data=KnowledgeUpdateForm(data=data)
-                        )
+        knowledge_base = KnowledgeResponse(
+            **knowledge_base.model_dump(),
+            files=files,
+        )
 
-                        files = Files.get_file_metadatas_by_ids(file_ids)
+    return knowledge_bases
 
-            knowledge_bases.append(
-                KnowledgeResponse(
-                    **knowledge.model_dump(),
-                    files=files,
-                )
+
+@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", [])
             )
-        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
 
 
 ############################
@@ -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)
@@ -347,35 +437,60 @@ def remove_file_from_knowledge_by_id(
 
 
 ############################
-# ResetKnowledgeById
+# DeleteKnowledgeById
 ############################
 
 
-@router.post("/{id}/reset", response_model=Optional[KnowledgeResponse])
-async def reset_knowledge_by_id(id: str, user=Depends(get_admin_user)):
+@router.delete("/{id}/delete", response_model=bool)
+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:
         log.debug(e)
         pass
-
-    knowledge = Knowledges.update_knowledge_by_id(
-        id=id, form_data=KnowledgeUpdateForm(data={"file_ids": []})
-    )
-    return knowledge
+    result = Knowledges.delete_knowledge_by_id(id=id)
+    return result
 
 
 ############################
-# DeleteKnowledgeById
+# ResetKnowledgeById
 ############################
 
 
-@router.delete("/{id}/delete", response_model=bool)
-async def delete_knowledge_by_id(id: str, user=Depends(get_admin_user)):
+@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
-    result = Knowledges.delete_knowledge_by_id(id=id)
-    return result
+
+    knowledge = Knowledges.update_knowledge_data_by_id(id=id.id, data={"file_ids": []})
+
+    return knowledge

+ 105 - 36
backend/open_webui/apps/webui/routers/models.py

@@ -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,
+        )
+
+
 ############################
-# UpdateModelById
+# ToggelModelById
 ############################
 
 
-@router.post("/update", response_model=Optional[ModelModel])
-async def update_model_by_id(
-    request: Request,
-    id: str,
-    form_data: ModelForm,
-    user=Depends(get_admin_user),
-):
+@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:
-        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 (
+            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_401_UNAUTHORIZED,
-                    detail=ERROR_MESSAGES.DEFAULT(),
+                    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.DEFAULT(),
+                detail=ERROR_MESSAGES.UNAUTHORIZED,
             )
+    else:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail=ERROR_MESSAGES.NOT_FOUND,
+        )
+
+
+############################
+# UpdateModelById
+############################
+
+
+@router.post("/id/{id}/update", response_model=Optional[ModelModel])
+async def update_model_by_id(
+    id: str,
+    form_data: ModelForm,
+    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,
+        )
+
+    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

+ 52 - 5
backend/open_webui/apps/webui/routers/prompts.py

@@ -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

+ 128 - 78
backend/open_webui/apps/webui/routers/tools.py

@@ -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
+
+
+############################
+# 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
 
 
 ############################
-# ExportToolKits
+# 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)
-
-    if toolkit:
-        return toolkit
+async def get_tools_by_id(id: str, user=Depends(get_verified_user)):
+    tools = Tools.get_tool_by_id(id)
+
+    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}

+ 41 - 4
backend/open_webui/apps/webui/routers/users.py

@@ -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
 
 

+ 1 - 1
backend/open_webui/apps/webui/utils.py

@@ -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)

+ 39 - 20
backend/open_webui/config.py

@@ -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_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_EDITING = (
-    os.environ.get("USER_PERMISSIONS_CHAT_EDITING", "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", "")
 )

+ 257 - 191
backend/open_webui/main.py

@@ -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:
+                        models.remove(model)
 
-                    model["action_ids"] = action_ids
-        else:
+        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),
+                }
+            ]
+
+    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
 
-        action_ids = action_ids + global_action_ids
-        action_ids = list(set(action_ids))
+    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,16 +1100,29 @@ 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}
 
 
@@ -1098,23 +1130,28 @@ async def get_models(user=Depends(get_verified_user)):
 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
 
 

+ 85 - 0
backend/open_webui/migrations/versions/922e7a387820_add_group_table.py

@@ -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")

+ 95 - 0
backend/open_webui/utils/access_control.py

@@ -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
+    )

+ 22 - 12
backend/open_webui/utils/tools.py

@@ -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):

+ 7 - 2
backend/open_webui/utils/utils.py

@@ -1,12 +1,17 @@
 import logging
 import uuid
+import jwt
+
 from datetime import UTC, datetime, timedelta
-from typing import Optional, Union
+from typing import Optional, Union, List, Dict
+
 
-import jwt
 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

+ 1 - 0
backend/requirements.txt

@@ -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

+ 1 - 0
pyproject.toml

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

+ 163 - 0
src/lib/apis/groups/index.ts

@@ -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;
+};

+ 6 - 20
src/lib/apis/index.ts

@@ -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();

+ 38 - 4
src/lib/apis/knowledge/index.ts

@@ -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) => {

+ 84 - 9
src/lib/apis/models/index.ts

@@ -1,9 +1,76 @@
 import { WEBUI_API_BASE_URL } from '$lib/constants';
 
-export const addNewModel = async (token: string, model: object) => {
+
+export const getModels = async (token: string = '') => {
 	let error = null;
 
-	const res = await fetch(`${WEBUI_API_BASE_URL}/models/add`, {
+	const res = await fetch(`${WEBUI_API_BASE_URL}/models`, {
+		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 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',
@@ -29,10 +96,15 @@ export const addNewModel = async (token: string, model: object) => {
 	return res;
 };
 
-export const getModelInfos = async (token: string = '') => {
+
+
+export const getModelById = async (token: string, id: string) => {
 	let error = null;
 
-	const res = await fetch(`${WEBUI_API_BASE_URL}/models`, {
+	const searchParams = new URLSearchParams();
+	searchParams.append('id', id);
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/models/id/${id}`, {
 		method: 'GET',
 		headers: {
 			Accept: 'application/json',
@@ -49,6 +121,7 @@ export const getModelInfos = async (token: string = '') => {
 		})
 		.catch((err) => {
 			error = err;
+
 			console.log(err);
 			return null;
 		});
@@ -60,14 +133,15 @@ export const getModelInfos = async (token: string = '') => {
 	return res;
 };
 
-export const getModelById = async (token: string, id: string) => {
+
+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?${searchParams.toString()}`, {
-		method: 'GET',
+	const res = await fetch(`${WEBUI_API_BASE_URL}/models/id/${id}/toggle`, {
+		method: 'POST',
 		headers: {
 			Accept: 'application/json',
 			'Content-Type': 'application/json',
@@ -95,13 +169,14 @@ export const getModelById = async (token: string, id: string) => {
 	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',

+ 4 - 2
src/lib/apis/ollama/index.ts

@@ -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',

+ 52 - 13
src/lib/apis/prompts/index.ts

@@ -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) => {

+ 33 - 0
src/lib/apis/tools/index.ts

@@ -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;
 

+ 34 - 4
src/lib/apis/users/index.ts

@@ -1,10 +1,40 @@
 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/groups`, {
+		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 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',
@@ -28,10 +58,10 @@ export const getUserPermissions = async (token: string) => {
 	return res;
 };
 
-export const updateUserPermissions = async (token: string, permissions: object) => {
+export const updateUserDefaultPermissions = async (token: string, permissions: object) => {
 	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: 'POST',
 		headers: {
 			'Content-Type': 'application/json',

+ 15 - 28
src/lib/components/workspace/Functions.svelte → src/lib/components/admin/Functions.svelte

@@ -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}

+ 1 - 1
src/lib/components/workspace/Functions/FunctionEditor.svelte → src/lib/components/admin/Functions/FunctionEditor.svelte

@@ -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"
 								>

+ 0 - 0
src/lib/components/workspace/Functions/FunctionMenu.svelte → src/lib/components/admin/Functions/FunctionMenu.svelte


+ 1 - 1
src/lib/components/admin/Settings.svelte

@@ -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 () => {

+ 1 - 0
src/lib/components/admin/Settings/Connections.svelte

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

+ 1019 - 0
src/lib/components/admin/Settings/Connections/ManageOllamaModal.svelte

@@ -0,0 +1,1019 @@
+<script lang="ts">
+	import { toast } from 'svelte-sonner';
+	import { getContext, onMount } from 'svelte';
+	const i18n = getContext('i18n');
+
+	import { WEBUI_NAME, models, MODEL_DOWNLOAD_POOL, user, config } from '$lib/stores';
+	import { splitStream } from '$lib/utils';
+
+	import {
+		createModel,
+		deleteModel,
+		downloadModel,
+		getOllamaUrls,
+		getOllamaVersion,
+		pullModel,
+		uploadModel,
+		getOllamaConfig,
+		getOllamaModels
+	} from '$lib/apis/ollama';
+	import { getModels } from '$lib/apis';
+
+	import Modal from '$lib/components/common/Modal.svelte';
+	import Tooltip from '$lib/components/common/Tooltip.svelte';
+	import ModelDeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
+	import Spinner from '$lib/components/common/Spinner.svelte';
+
+	export let show = false;
+
+	let modelUploadInputElement: HTMLInputElement;
+	let showModelDeleteConfirm = false;
+
+	let loading = true;
+
+	// Models
+	export let urlIdx: number | null = null;
+
+	let ollamaModels = [];
+
+	let updateModelId = null;
+	let updateProgress = null;
+	let showExperimentalOllama = false;
+
+	const MAX_PARALLEL_DOWNLOADS = 3;
+
+	let modelTransferring = false;
+	let modelTag = '';
+
+	let createModelLoading = false;
+	let createModelTag = '';
+	let createModelContent = '';
+	let createModelDigest = '';
+	let createModelPullProgress = null;
+
+	let digest = '';
+	let pullProgress = null;
+
+	let modelUploadMode = 'file';
+	let modelInputFile: File[] | null = null;
+	let modelFileUrl = '';
+	let modelFileContent = `TEMPLATE """{{ .System }}\nUSER: {{ .Prompt }}\nASSISTANT: """\nPARAMETER num_ctx 4096\nPARAMETER stop "</s>"\nPARAMETER stop "USER:"\nPARAMETER stop "ASSISTANT:"`;
+	let modelFileDigest = '';
+
+	let uploadProgress = null;
+	let uploadMessage = '';
+
+	let deleteModelTag = '';
+
+	const updateModelsHandler = async () => {
+		for (const model of ollamaModels) {
+			console.log(model);
+
+			updateModelId = model.id;
+			const [res, controller] = await pullModel(localStorage.token, model.id, urlIdx).catch(
+				(error) => {
+					toast.error(error);
+					return null;
+				}
+			);
+
+			if (res) {
+				const reader = res.body
+					.pipeThrough(new TextDecoderStream())
+					.pipeThrough(splitStream('\n'))
+					.getReader();
+
+				while (true) {
+					try {
+						const { value, done } = await reader.read();
+						if (done) break;
+
+						let lines = value.split('\n');
+
+						for (const line of lines) {
+							if (line !== '') {
+								let data = JSON.parse(line);
+
+								console.log(data);
+								if (data.error) {
+									throw data.error;
+								}
+								if (data.detail) {
+									throw data.detail;
+								}
+								if (data.status) {
+									if (data.digest) {
+										updateProgress = 0;
+										if (data.completed) {
+											updateProgress = Math.round((data.completed / data.total) * 1000) / 10;
+										} else {
+											updateProgress = 100;
+										}
+									} else {
+										toast.success(data.status);
+									}
+								}
+							}
+						}
+					} catch (error) {
+						console.log(error);
+					}
+				}
+			}
+		}
+
+		updateModelId = null;
+		updateProgress = null;
+	};
+
+	const pullModelHandler = async () => {
+		const sanitizedModelTag = modelTag.trim().replace(/^ollama\s+(run|pull)\s+/, '');
+		console.log($MODEL_DOWNLOAD_POOL);
+		if ($MODEL_DOWNLOAD_POOL[sanitizedModelTag]) {
+			toast.error(
+				$i18n.t(`Model '{{modelTag}}' is already in queue for downloading.`, {
+					modelTag: sanitizedModelTag
+				})
+			);
+			return;
+		}
+		if (Object.keys($MODEL_DOWNLOAD_POOL).length === MAX_PARALLEL_DOWNLOADS) {
+			toast.error(
+				$i18n.t('Maximum of 3 models can be downloaded simultaneously. Please try again later.')
+			);
+			return;
+		}
+
+		const [res, controller] = await pullModel(localStorage.token, sanitizedModelTag, urlIdx).catch(
+			(error) => {
+				toast.error(error);
+				return null;
+			}
+		);
+
+		if (res) {
+			const reader = res.body
+				.pipeThrough(new TextDecoderStream())
+				.pipeThrough(splitStream('\n'))
+				.getReader();
+
+			MODEL_DOWNLOAD_POOL.set({
+				...$MODEL_DOWNLOAD_POOL,
+				[sanitizedModelTag]: {
+					...$MODEL_DOWNLOAD_POOL[sanitizedModelTag],
+					abortController: controller,
+					reader,
+					done: false
+				}
+			});
+
+			while (true) {
+				try {
+					const { value, done } = await reader.read();
+					if (done) break;
+
+					let lines = value.split('\n');
+
+					for (const line of lines) {
+						if (line !== '') {
+							let data = JSON.parse(line);
+							console.log(data);
+							if (data.error) {
+								throw data.error;
+							}
+							if (data.detail) {
+								throw data.detail;
+							}
+
+							if (data.status) {
+								if (data.digest) {
+									let downloadProgress = 0;
+									if (data.completed) {
+										downloadProgress = Math.round((data.completed / data.total) * 1000) / 10;
+									} else {
+										downloadProgress = 100;
+									}
+
+									MODEL_DOWNLOAD_POOL.set({
+										...$MODEL_DOWNLOAD_POOL,
+										[sanitizedModelTag]: {
+											...$MODEL_DOWNLOAD_POOL[sanitizedModelTag],
+											pullProgress: downloadProgress,
+											digest: data.digest
+										}
+									});
+								} else {
+									toast.success(data.status);
+
+									MODEL_DOWNLOAD_POOL.set({
+										...$MODEL_DOWNLOAD_POOL,
+										[sanitizedModelTag]: {
+											...$MODEL_DOWNLOAD_POOL[sanitizedModelTag],
+											done: data.status === 'success'
+										}
+									});
+								}
+							}
+						}
+					}
+				} catch (error) {
+					console.log(error);
+					if (typeof error !== 'string') {
+						error = error.message;
+					}
+
+					toast.error(error);
+					// opts.callback({ success: false, error, modelName: opts.modelName });
+				}
+			}
+
+			console.log($MODEL_DOWNLOAD_POOL[sanitizedModelTag]);
+
+			if ($MODEL_DOWNLOAD_POOL[sanitizedModelTag].done) {
+				toast.success(
+					$i18n.t(`Model '{{modelName}}' has been successfully downloaded.`, {
+						modelName: sanitizedModelTag
+					})
+				);
+
+				models.set(await getModels(localStorage.token));
+			} else {
+				toast.error($i18n.t('Download canceled'));
+			}
+
+			delete $MODEL_DOWNLOAD_POOL[sanitizedModelTag];
+
+			MODEL_DOWNLOAD_POOL.set({
+				...$MODEL_DOWNLOAD_POOL
+			});
+		}
+
+		modelTag = '';
+		modelTransferring = false;
+	};
+
+	const uploadModelHandler = async () => {
+		modelTransferring = true;
+
+		let uploaded = false;
+		let fileResponse = null;
+		let name = '';
+
+		if (modelUploadMode === 'file') {
+			const file = modelInputFile ? modelInputFile[0] : null;
+
+			if (file) {
+				uploadMessage = 'Uploading...';
+
+				fileResponse = await uploadModel(localStorage.token, file, urlIdx).catch((error) => {
+					toast.error(error);
+					return null;
+				});
+			}
+		} else {
+			uploadProgress = 0;
+			fileResponse = await downloadModel(localStorage.token, modelFileUrl, urlIdx).catch(
+				(error) => {
+					toast.error(error);
+					return null;
+				}
+			);
+		}
+
+		if (fileResponse && fileResponse.ok) {
+			const reader = fileResponse.body
+				.pipeThrough(new TextDecoderStream())
+				.pipeThrough(splitStream('\n'))
+				.getReader();
+
+			while (true) {
+				const { value, done } = await reader.read();
+				if (done) break;
+
+				try {
+					let lines = value.split('\n');
+
+					for (const line of lines) {
+						if (line !== '') {
+							let data = JSON.parse(line.replace(/^data: /, ''));
+
+							if (data.progress) {
+								if (uploadMessage) {
+									uploadMessage = '';
+								}
+								uploadProgress = data.progress;
+							}
+
+							if (data.error) {
+								throw data.error;
+							}
+
+							if (data.done) {
+								modelFileDigest = data.blob;
+								name = data.name;
+								uploaded = true;
+							}
+						}
+					}
+				} catch (error) {
+					console.log(error);
+				}
+			}
+		} else {
+			const error = await fileResponse?.json();
+			toast.error(error?.detail ?? error);
+		}
+
+		if (uploaded) {
+			const res = await createModel(
+				localStorage.token,
+				`${name}:latest`,
+				`FROM @${modelFileDigest}\n${modelFileContent}`
+			);
+
+			if (res && res.ok) {
+				const reader = res.body
+					.pipeThrough(new TextDecoderStream())
+					.pipeThrough(splitStream('\n'))
+					.getReader();
+
+				while (true) {
+					const { value, done } = await reader.read();
+					if (done) break;
+
+					try {
+						let lines = value.split('\n');
+
+						for (const line of lines) {
+							if (line !== '') {
+								console.log(line);
+								let data = JSON.parse(line);
+								console.log(data);
+
+								if (data.error) {
+									throw data.error;
+								}
+								if (data.detail) {
+									throw data.detail;
+								}
+
+								if (data.status) {
+									if (
+										!data.digest &&
+										!data.status.includes('writing') &&
+										!data.status.includes('sha256')
+									) {
+										toast.success(data.status);
+									} else {
+										if (data.digest) {
+											digest = data.digest;
+
+											if (data.completed) {
+												pullProgress = Math.round((data.completed / data.total) * 1000) / 10;
+											} else {
+												pullProgress = 100;
+											}
+										}
+									}
+								}
+							}
+						}
+					} catch (error) {
+						console.log(error);
+						toast.error(error);
+					}
+				}
+			}
+		}
+
+		modelFileUrl = '';
+
+		if (modelUploadInputElement) {
+			modelUploadInputElement.value = '';
+		}
+		modelInputFile = null;
+		modelTransferring = false;
+		uploadProgress = null;
+
+		models.set(await getModels(localStorage.token));
+	};
+
+	const deleteModelHandler = async () => {
+		const res = await deleteModel(localStorage.token, deleteModelTag, urlIdx).catch((error) => {
+			toast.error(error);
+		});
+
+		if (res) {
+			toast.success($i18n.t(`Deleted {{deleteModelTag}}`, { deleteModelTag }));
+		}
+
+		deleteModelTag = '';
+		models.set(await getModels(localStorage.token));
+	};
+
+	const cancelModelPullHandler = async (model: string) => {
+		const { reader, abortController } = $MODEL_DOWNLOAD_POOL[model];
+		if (abortController) {
+			abortController.abort();
+		}
+		if (reader) {
+			await reader.cancel();
+			delete $MODEL_DOWNLOAD_POOL[model];
+			MODEL_DOWNLOAD_POOL.set({
+				...$MODEL_DOWNLOAD_POOL
+			});
+			await deleteModel(localStorage.token, model);
+			toast.success(`${model} download has been canceled`);
+		}
+	};
+
+	const createModelHandler = async () => {
+		createModelLoading = true;
+		const res = await createModel(
+			localStorage.token,
+			createModelTag,
+			createModelContent,
+			urlIdx
+		).catch((error) => {
+			toast.error(error);
+			return null;
+		});
+
+		if (res && res.ok) {
+			const reader = res.body
+				.pipeThrough(new TextDecoderStream())
+				.pipeThrough(splitStream('\n'))
+				.getReader();
+
+			while (true) {
+				const { value, done } = await reader.read();
+				if (done) break;
+
+				try {
+					let lines = value.split('\n');
+
+					for (const line of lines) {
+						if (line !== '') {
+							console.log(line);
+							let data = JSON.parse(line);
+							console.log(data);
+
+							if (data.error) {
+								throw data.error;
+							}
+							if (data.detail) {
+								throw data.detail;
+							}
+
+							if (data.status) {
+								if (
+									!data.digest &&
+									!data.status.includes('writing') &&
+									!data.status.includes('sha256')
+								) {
+									toast.success(data.status);
+								} else {
+									if (data.digest) {
+										createModelDigest = data.digest;
+
+										if (data.completed) {
+											createModelPullProgress =
+												Math.round((data.completed / data.total) * 1000) / 10;
+										} else {
+											createModelPullProgress = 100;
+										}
+									}
+								}
+							}
+						}
+					}
+				} catch (error) {
+					console.log(error);
+					toast.error(error);
+				}
+			}
+		}
+
+		models.set(await getModels(localStorage.token));
+
+		createModelLoading = false;
+
+		createModelTag = '';
+		createModelContent = '';
+		createModelDigest = '';
+		createModelPullProgress = null;
+	};
+
+	const init = async () => {
+		loading = true;
+		ollamaModels = await getOllamaModels(localStorage.token, urlIdx);
+
+		console.log(ollamaModels);
+		loading = false;
+	};
+
+	$: if (show) {
+		init();
+	}
+</script>
+
+<ModelDeleteConfirmDialog
+	bind:show={showModelDeleteConfirm}
+	on:confirm={() => {
+		deleteModelHandler();
+	}}
+/>
+
+<Modal size="sm" bind:show>
+	<div>
+		<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
+			<div class=" text-lg font-medium self-center font-primary">
+				{$i18n.t('Manage Ollama')}
+			</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-5 pb-4 md:space-x-4 dark:text-gray-200">
+			{#if !loading}
+				<div class=" flex flex-col w-full">
+					<div>
+						<div class="space-y-2">
+							<div>
+								<div class=" mb-2 text-sm font-medium">
+									{$i18n.t('Pull a model from Ollama.com')}
+								</div>
+								<div class="flex w-full">
+									<div class="flex-1 mr-2">
+										<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"
+											placeholder={$i18n.t('Enter model tag (e.g. {{modelTag}})', {
+												modelTag: 'mistral:7b'
+											})}
+											bind:value={modelTag}
+										/>
+									</div>
+									<button
+										class="px-2.5 bg-gray-50 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition"
+										on:click={() => {
+											pullModelHandler();
+										}}
+										disabled={modelTransferring}
+									>
+										{#if modelTransferring}
+											<div class="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>
+										{:else}
+											<svg
+												xmlns="http://www.w3.org/2000/svg"
+												viewBox="0 0 16 16"
+												fill="currentColor"
+												class="w-4 h-4"
+											>
+												<path
+													d="M8.75 2.75a.75.75 0 0 0-1.5 0v5.69L5.03 6.22a.75.75 0 0 0-1.06 1.06l3.5 3.5a.75.75 0 0 0 1.06 0l3.5-3.5a.75.75 0 0 0-1.06-1.06L8.75 8.44V2.75Z"
+												/>
+												<path
+													d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
+												/>
+											</svg>
+										{/if}
+									</button>
+								</div>
+
+								<div class="mt-2 mb-1 text-xs text-gray-400 dark:text-gray-500">
+									{$i18n.t('To access the available model names for downloading,')}
+									<a
+										class=" text-gray-500 dark:text-gray-300 font-medium underline"
+										href="https://ollama.com/library"
+										target="_blank">{$i18n.t('click here.')}</a
+									>
+								</div>
+
+								{#if Object.keys($MODEL_DOWNLOAD_POOL).length > 0}
+									{#each Object.keys($MODEL_DOWNLOAD_POOL) as model}
+										{#if 'pullProgress' in $MODEL_DOWNLOAD_POOL[model]}
+											<div class="flex flex-col">
+												<div class="font-medium mb-1">{model}</div>
+												<div class="">
+													<div class="flex flex-row justify-between space-x-4 pr-2">
+														<div class=" flex-1">
+															<div
+																class="dark:bg-gray-600 bg-gray-500 text-xs font-medium text-gray-100 text-center p-0.5 leading-none rounded-full"
+																style="width: {Math.max(
+																	15,
+																	$MODEL_DOWNLOAD_POOL[model].pullProgress ?? 0
+																)}%"
+															>
+																{$MODEL_DOWNLOAD_POOL[model].pullProgress ?? 0}%
+															</div>
+														</div>
+
+														<Tooltip content={$i18n.t('Cancel')}>
+															<button
+																class="text-gray-800 dark:text-gray-100"
+																on:click={() => {
+																	cancelModelPullHandler(model);
+																}}
+															>
+																<svg
+																	class="w-4 h-4 text-gray-800 dark:text-white"
+																	aria-hidden="true"
+																	xmlns="http://www.w3.org/2000/svg"
+																	width="24"
+																	height="24"
+																	fill="currentColor"
+																	viewBox="0 0 24 24"
+																>
+																	<path
+																		stroke="currentColor"
+																		stroke-linecap="round"
+																		stroke-linejoin="round"
+																		stroke-width="2"
+																		d="M6 18 17.94 6M18 18 6.06 6"
+																	/>
+																</svg>
+															</button>
+														</Tooltip>
+													</div>
+													{#if 'digest' in $MODEL_DOWNLOAD_POOL[model]}
+														<div class="mt-1 text-xs dark:text-gray-500" style="font-size: 0.5rem;">
+															{$MODEL_DOWNLOAD_POOL[model].digest}
+														</div>
+													{/if}
+												</div>
+											</div>
+										{/if}
+									{/each}
+								{/if}
+							</div>
+
+							<div>
+								<div class=" mb-2 text-sm font-medium">{$i18n.t('Delete a model')}</div>
+								<div class="flex w-full">
+									<div class="flex-1 mr-2">
+										<select
+											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={deleteModelTag}
+											placeholder={$i18n.t('Select a model')}
+										>
+											{#if !deleteModelTag}
+												<option value="" disabled selected>{$i18n.t('Select a model')}</option>
+											{/if}
+											{#each ollamaModels as model}
+												<option value={model.id} class="bg-gray-50 dark:bg-gray-700"
+													>{model.name +
+														' (' +
+														(model.size / 1024 ** 3).toFixed(1) +
+														' GB)'}</option
+												>
+											{/each}
+										</select>
+									</div>
+									<button
+										class="px-2.5 bg-gray-50 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition"
+										on:click={() => {
+											showModelDeleteConfirm = true;
+										}}
+									>
+										<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="M5 3.25V4H2.75a.75.75 0 0 0 0 1.5h.3l.815 8.15A1.5 1.5 0 0 0 5.357 15h5.285a1.5 1.5 0 0 0 1.493-1.35l.815-8.15h.3a.75.75 0 0 0 0-1.5H11v-.75A2.25 2.25 0 0 0 8.75 1h-1.5A2.25 2.25 0 0 0 5 3.25Zm2.25-.75a.75.75 0 0 0-.75.75V4h3v-.75a.75.75 0 0 0-.75-.75h-1.5ZM6.05 6a.75.75 0 0 1 .787.713l.275 5.5a.75.75 0 0 1-1.498.075l-.275-5.5A.75.75 0 0 1 6.05 6Zm3.9 0a.75.75 0 0 1 .712.787l-.275 5.5a.75.75 0 0 1-1.498-.075l.275-5.5a.75.75 0 0 1 .786-.711Z"
+												clip-rule="evenodd"
+											/>
+										</svg>
+									</button>
+								</div>
+							</div>
+
+							<div>
+								<div class=" mb-2 text-sm font-medium">{$i18n.t('Create a model')}</div>
+								<div class="flex w-full">
+									<div class="flex-1 mr-2 flex flex-col gap-2">
+										<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"
+											placeholder={$i18n.t('Enter model tag (e.g. {{modelTag}})', {
+												modelTag: 'my-modelfile'
+											})}
+											bind:value={createModelTag}
+											disabled={createModelLoading}
+										/>
+
+										<textarea
+											bind:value={createModelContent}
+											class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-100 dark:bg-gray-850 outline-none resize-none scrollbar-hidden"
+											rows="6"
+											placeholder={`TEMPLATE """{{ .System }}\nUSER: {{ .Prompt }}\nASSISTANT: """\nPARAMETER num_ctx 4096\nPARAMETER stop "</s>"\nPARAMETER stop "USER:"\nPARAMETER stop "ASSISTANT:"`}
+											disabled={createModelLoading}
+										/>
+									</div>
+
+									<div class="flex self-start">
+										<button
+											class="px-2.5 py-2.5 bg-gray-50 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition disabled:cursor-not-allowed"
+											on:click={() => {
+												createModelHandler();
+											}}
+											disabled={createModelLoading}
+										>
+											<svg
+												xmlns="http://www.w3.org/2000/svg"
+												viewBox="0 0 16 16"
+												fill="currentColor"
+												class="size-4"
+											>
+												<path
+													d="M7.25 10.25a.75.75 0 0 0 1.5 0V4.56l2.22 2.22a.75.75 0 1 0 1.06-1.06l-3.5-3.5a.75.75 0 0 0-1.06 0l-3.5 3.5a.75.75 0 0 0 1.06 1.06l2.22-2.22v5.69Z"
+												/>
+												<path
+													d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
+												/>
+											</svg>
+										</button>
+									</div>
+								</div>
+
+								{#if createModelDigest !== ''}
+									<div class="flex flex-col mt-1">
+										<div class="font-medium mb-1">{createModelTag}</div>
+										<div class="">
+											<div class="flex flex-row justify-between space-x-4 pr-2">
+												<div class=" flex-1">
+													<div
+														class="dark:bg-gray-600 bg-gray-500 text-xs font-medium text-gray-100 text-center p-0.5 leading-none rounded-full"
+														style="width: {Math.max(15, createModelPullProgress ?? 0)}%"
+													>
+														{createModelPullProgress ?? 0}%
+													</div>
+												</div>
+											</div>
+											{#if createModelDigest}
+												<div class="mt-1 text-xs dark:text-gray-500" style="font-size: 0.5rem;">
+													{createModelDigest}
+												</div>
+											{/if}
+										</div>
+									</div>
+								{/if}
+							</div>
+
+							<div class="pt-1">
+								<div class="flex justify-between items-center text-xs">
+									<div class=" text-sm font-medium">{$i18n.t('Experimental')}</div>
+									<button
+										class=" text-xs font-medium text-gray-500"
+										type="button"
+										on:click={() => {
+											showExperimentalOllama = !showExperimentalOllama;
+										}}>{showExperimentalOllama ? $i18n.t('Hide') : $i18n.t('Show')}</button
+									>
+								</div>
+							</div>
+
+							{#if showExperimentalOllama}
+								<form
+									on:submit|preventDefault={() => {
+										uploadModelHandler();
+									}}
+								>
+									<div class=" mb-2 flex w-full justify-between">
+										<div class="  text-sm font-medium">{$i18n.t('Upload a GGUF model')}</div>
+
+										<button
+											class="p-1 px-3 text-xs flex rounded transition"
+											on:click={() => {
+												if (modelUploadMode === 'file') {
+													modelUploadMode = 'url';
+												} else {
+													modelUploadMode = 'file';
+												}
+											}}
+											type="button"
+										>
+											{#if modelUploadMode === 'file'}
+												<span class="ml-2 self-center">{$i18n.t('File Mode')}</span>
+											{:else}
+												<span class="ml-2 self-center">{$i18n.t('URL Mode')}</span>
+											{/if}
+										</button>
+									</div>
+
+									<div class="flex w-full mb-1.5">
+										<div class="flex flex-col w-full">
+											{#if modelUploadMode === 'file'}
+												<div
+													class="flex-1 {modelInputFile && modelInputFile.length > 0 ? 'mr-2' : ''}"
+												>
+													<input
+														id="model-upload-input"
+														bind:this={modelUploadInputElement}
+														type="file"
+														bind:files={modelInputFile}
+														on:change={() => {
+															console.log(modelInputFile);
+														}}
+														accept=".gguf,.safetensors"
+														required
+														hidden
+													/>
+
+													<button
+														type="button"
+														class="w-full rounded-lg text-left py-2 px-4 bg-gray-50 dark:text-gray-300 dark:bg-gray-850"
+														on:click={() => {
+															modelUploadInputElement.click();
+														}}
+													>
+														{#if modelInputFile && modelInputFile.length > 0}
+															{modelInputFile[0].name}
+														{:else}
+															{$i18n.t('Click here to select')}
+														{/if}
+													</button>
+												</div>
+											{:else}
+												<div class="flex-1 {modelFileUrl !== '' ? 'mr-2' : ''}">
+													<input
+														class="w-full rounded-lg text-left py-2 px-4 bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none {modelFileUrl !==
+														''
+															? 'mr-2'
+															: ''}"
+														type="url"
+														required
+														bind:value={modelFileUrl}
+														placeholder={$i18n.t('Type Hugging Face Resolve (Download) URL')}
+													/>
+												</div>
+											{/if}
+										</div>
+
+										{#if (modelUploadMode === 'file' && modelInputFile && modelInputFile.length > 0) || (modelUploadMode === 'url' && modelFileUrl !== '')}
+											<button
+												class="px-2.5 bg-gray-50 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg disabled:cursor-not-allowed transition"
+												type="submit"
+												disabled={modelTransferring}
+											>
+												{#if modelTransferring}
+													<div class="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>
+												{:else}
+													<svg
+														xmlns="http://www.w3.org/2000/svg"
+														viewBox="0 0 16 16"
+														fill="currentColor"
+														class="w-4 h-4"
+													>
+														<path
+															d="M7.25 10.25a.75.75 0 0 0 1.5 0V4.56l2.22 2.22a.75.75 0 1 0 1.06-1.06l-3.5-3.5a.75.75 0 0 0-1.06 0l-3.5 3.5a.75.75 0 0 0 1.06 1.06l2.22-2.22v5.69Z"
+														/>
+														<path
+															d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
+														/>
+													</svg>
+												{/if}
+											</button>
+										{/if}
+									</div>
+
+									{#if (modelUploadMode === 'file' && modelInputFile && modelInputFile.length > 0) || (modelUploadMode === 'url' && modelFileUrl !== '')}
+										<div>
+											<div>
+												<div class=" my-2.5 text-sm font-medium">
+													{$i18n.t('Modelfile Content')}
+												</div>
+												<textarea
+													bind:value={modelFileContent}
+													class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-100 dark:bg-gray-850 outline-none resize-none"
+													rows="6"
+												/>
+											</div>
+										</div>
+									{/if}
+									<div class=" mt-1 text-xs text-gray-400 dark:text-gray-500">
+										{$i18n.t('To access the GGUF models available for downloading,')}
+										<a
+											class=" text-gray-500 dark:text-gray-300 font-medium underline"
+											href="https://huggingface.co/models?search=gguf"
+											target="_blank">{$i18n.t('click here.')}</a
+										>
+									</div>
+
+									{#if uploadMessage}
+										<div class="mt-2">
+											<div class=" mb-2 text-xs">{$i18n.t('Upload Progress')}</div>
+
+											<div class="w-full rounded-full dark:bg-gray-800">
+												<div
+													class="dark:bg-gray-600 bg-gray-500 text-xs font-medium text-gray-100 text-center p-0.5 leading-none rounded-full"
+													style="width: 100%"
+												>
+													{uploadMessage}
+												</div>
+											</div>
+											<div class="mt-1 text-xs dark:text-gray-500" style="font-size: 0.5rem;">
+												{modelFileDigest}
+											</div>
+										</div>
+									{:else if uploadProgress !== null}
+										<div class="mt-2">
+											<div class=" mb-2 text-xs">{$i18n.t('Upload Progress')}</div>
+
+											<div class="w-full rounded-full dark:bg-gray-800">
+												<div
+													class="dark:bg-gray-600 bg-gray-500 text-xs font-medium text-gray-100 text-center p-0.5 leading-none rounded-full"
+													style="width: {Math.max(15, uploadProgress ?? 0)}%"
+												>
+													{uploadProgress ?? 0}%
+												</div>
+											</div>
+											<div class="mt-1 text-xs dark:text-gray-500" style="font-size: 0.5rem;">
+												{modelFileDigest}
+											</div>
+										</div>
+									{/if}
+								</form>
+							{/if}
+						</div>
+					</div>
+				</div>
+			{:else}
+				<Spinner />
+			{/if}
+		</div>
+	</div>
+</Modal>

+ 20 - 1
src/lib/components/admin/Settings/Connections/OllamaConnection.svelte

@@ -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"

+ 5 - 12
src/lib/components/admin/Settings/Documents.svelte

@@ -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}

+ 279 - 1018
src/lib/components/admin/Settings/Models.svelte

@@ -1,1082 +1,343 @@
 <script lang="ts">
-	import { toast } from 'svelte-sonner';
-	import { onMount, getContext } from 'svelte';
+	import { marked } from 'marked';
+	import fileSaver from 'file-saver';
+	const { saveAs } = fileSaver;
 
-	import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
-	import { WEBUI_NAME, models, MODEL_DOWNLOAD_POOL, user, config } from '$lib/stores';
-	import { splitStream } from '$lib/utils';
+	import { onMount, getContext, tick } from 'svelte';
+	const i18n = getContext('i18n');
 
+	import { WEBUI_NAME, config, mobile, models as _models, settings, user } from '$lib/stores';
 	import {
-		createModel,
-		deleteModel,
-		downloadModel,
-		getOllamaUrls,
-		getOllamaVersion,
-		pullModel,
-		uploadModel,
-		getOllamaConfig
-	} from '$lib/apis/ollama';
-	import { getModels as _getModels } from '$lib/apis';
-
+		createNewModel,
+		getBaseModels,
+		toggleModelById,
+		updateModelById
+	} from '$lib/apis/models';
+
+	import { getModels } from '$lib/apis';
+	import Search from '$lib/components/icons/Search.svelte';
 	import Tooltip from '$lib/components/common/Tooltip.svelte';
+	import Switch from '$lib/components/common/Switch.svelte';
 	import Spinner from '$lib/components/common/Spinner.svelte';
-	import ModelDeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
-
-	const i18n = getContext('i18n');
-
-	const getModels = async () => {
-		return await _getModels(localStorage.token);
-	};
-
-	let modelUploadInputElement: HTMLInputElement;
-
-	let showModelDeleteConfirm = false;
-
-	// Models
-
-	let ollamaEnabled = null;
-
-	let OLLAMA_BASE_URLS = [];
-	let selectedOllamaUrlIdx: number | null = null;
-
-	let updateModelId = null;
-	let updateProgress = null;
-
-	let showExperimentalOllama = false;
 
-	let ollamaVersion = null;
-	const MAX_PARALLEL_DOWNLOADS = 3;
+	import ModelEditor from '$lib/components/workspace/Models/ModelEditor.svelte';
 
-	let modelTransferring = false;
-	let modelTag = '';
+	let importFiles;
+	let modelsImportInputElement: HTMLInputElement;
 
-	let createModelLoading = false;
-	let createModelTag = '';
-	let createModelContent = '';
-	let createModelDigest = '';
-	let createModelPullProgress = null;
+	let models = null;
 
-	let digest = '';
-	let pullProgress = null;
+	let workspaceModels = null;
+	let baseModels = null;
 
-	let modelUploadMode = 'file';
-	let modelInputFile: File[] | null = null;
-	let modelFileUrl = '';
-	let modelFileContent = `TEMPLATE """{{ .System }}\nUSER: {{ .Prompt }}\nASSISTANT: """\nPARAMETER num_ctx 4096\nPARAMETER stop "</s>"\nPARAMETER stop "USER:"\nPARAMETER stop "ASSISTANT:"`;
-	let modelFileDigest = '';
+	let filteredModels = [];
 
-	let uploadProgress = null;
-	let uploadMessage = '';
-
-	let deleteModelTag = '';
-
-	const updateModelsHandler = async () => {
-		for (const model of $models.filter(
-			(m) =>
-				!(m?.preset ?? false) &&
-				m.owned_by === 'ollama' &&
-				(selectedOllamaUrlIdx === null
-					? true
-					: (m?.ollama?.urls ?? []).includes(selectedOllamaUrlIdx))
-		)) {
-			console.log(model);
-
-			updateModelId = model.id;
-			const [res, controller] = await pullModel(
-				localStorage.token,
-				model.id,
-				selectedOllamaUrlIdx
-			).catch((error) => {
-				toast.error(error);
-				return null;
-			});
-
-			if (res) {
-				const reader = res.body
-					.pipeThrough(new TextDecoderStream())
-					.pipeThrough(splitStream('\n'))
-					.getReader();
-
-				while (true) {
-					try {
-						const { value, done } = await reader.read();
-						if (done) break;
-
-						let lines = value.split('\n');
-
-						for (const line of lines) {
-							if (line !== '') {
-								let data = JSON.parse(line);
-
-								console.log(data);
-								if (data.error) {
-									throw data.error;
-								}
-								if (data.detail) {
-									throw data.detail;
-								}
-								if (data.status) {
-									if (data.digest) {
-										updateProgress = 0;
-										if (data.completed) {
-											updateProgress = Math.round((data.completed / data.total) * 1000) / 10;
-										} else {
-											updateProgress = 100;
-										}
-									} else {
-										toast.success(data.status);
-									}
-								}
-							}
-						}
-					} catch (error) {
-						console.log(error);
-					}
-				}
-			}
-		}
+	let selectedModelId = null;
 
-		updateModelId = null;
-		updateProgress = null;
-	};
+	$: if (models) {
+		filteredModels = models.filter(
+			(m) => searchValue === '' || m.name.toLowerCase().includes(searchValue.toLowerCase())
+		);
+	}
 
-	const pullModelHandler = async () => {
-		const sanitizedModelTag = modelTag.trim().replace(/^ollama\s+(run|pull)\s+/, '');
-		console.log($MODEL_DOWNLOAD_POOL);
-		if ($MODEL_DOWNLOAD_POOL[sanitizedModelTag]) {
-			toast.error(
-				$i18n.t(`Model '{{modelTag}}' is already in queue for downloading.`, {
-					modelTag: sanitizedModelTag
-				})
-			);
-			return;
-		}
-		if (Object.keys($MODEL_DOWNLOAD_POOL).length === MAX_PARALLEL_DOWNLOADS) {
-			toast.error(
-				$i18n.t('Maximum of 3 models can be downloaded simultaneously. Please try again later.')
-			);
-			return;
-		}
+	let searchValue = '';
 
-		const [res, controller] = await pullModel(
-			localStorage.token,
-			sanitizedModelTag,
-			selectedOllamaUrlIdx
-		).catch((error) => {
-			toast.error(error);
-			return null;
+	const downloadModels = async (models) => {
+		let blob = new Blob([JSON.stringify(models)], {
+			type: 'application/json'
 		});
+		saveAs(blob, `models-export-${Date.now()}.json`);
+	};
 
-		if (res) {
-			const reader = res.body
-				.pipeThrough(new TextDecoderStream())
-				.pipeThrough(splitStream('\n'))
-				.getReader();
-
-			MODEL_DOWNLOAD_POOL.set({
-				...$MODEL_DOWNLOAD_POOL,
-				[sanitizedModelTag]: {
-					...$MODEL_DOWNLOAD_POOL[sanitizedModelTag],
-					abortController: controller,
-					reader,
-					done: false
-				}
-			});
-
-			while (true) {
-				try {
-					const { value, done } = await reader.read();
-					if (done) break;
-
-					let lines = value.split('\n');
-
-					for (const line of lines) {
-						if (line !== '') {
-							let data = JSON.parse(line);
-							console.log(data);
-							if (data.error) {
-								throw data.error;
-							}
-							if (data.detail) {
-								throw data.detail;
-							}
-
-							if (data.status) {
-								if (data.digest) {
-									let downloadProgress = 0;
-									if (data.completed) {
-										downloadProgress = Math.round((data.completed / data.total) * 1000) / 10;
-									} else {
-										downloadProgress = 100;
-									}
-
-									MODEL_DOWNLOAD_POOL.set({
-										...$MODEL_DOWNLOAD_POOL,
-										[sanitizedModelTag]: {
-											...$MODEL_DOWNLOAD_POOL[sanitizedModelTag],
-											pullProgress: downloadProgress,
-											digest: data.digest
-										}
-									});
-								} else {
-									toast.success(data.status);
-
-									MODEL_DOWNLOAD_POOL.set({
-										...$MODEL_DOWNLOAD_POOL,
-										[sanitizedModelTag]: {
-											...$MODEL_DOWNLOAD_POOL[sanitizedModelTag],
-											done: data.status === 'success'
-										}
-									});
-								}
-							}
-						}
-					}
-				} catch (error) {
-					console.log(error);
-					if (typeof error !== 'string') {
-						error = error.message;
-					}
-
-					toast.error(error);
-					// opts.callback({ success: false, error, modelName: opts.modelName });
-				}
-			}
-
-			console.log($MODEL_DOWNLOAD_POOL[sanitizedModelTag]);
+	const init = async () => {
+		workspaceModels = await getBaseModels(localStorage.token);
+		baseModels = await getModels(localStorage.token, true);
 
-			if ($MODEL_DOWNLOAD_POOL[sanitizedModelTag].done) {
-				toast.success(
-					$i18n.t(`Model '{{modelName}}' has been successfully downloaded.`, {
-						modelName: sanitizedModelTag
-					})
-				);
+		models = baseModels.map((m) => {
+			const workspaceModel = workspaceModels.find((wm) => wm.id === m.id);
 
-				models.set(await getModels());
+			if (workspaceModel) {
+				return workspaceModel;
 			} else {
-				toast.error($i18n.t('Download canceled'));
+				return {
+					id: m.id,
+					name: m.name,
+					is_active: true
+				};
 			}
-
-			delete $MODEL_DOWNLOAD_POOL[sanitizedModelTag];
-
-			MODEL_DOWNLOAD_POOL.set({
-				...$MODEL_DOWNLOAD_POOL
-			});
-		}
-
-		modelTag = '';
-		modelTransferring = false;
+		});
 	};
 
-	const uploadModelHandler = async () => {
-		modelTransferring = true;
-
-		let uploaded = false;
-		let fileResponse = null;
-		let name = '';
-
-		if (modelUploadMode === 'file') {
-			const file = modelInputFile ? modelInputFile[0] : null;
+	const upsertModelHandler = async (model) => {
+		model.base_model_id = null;
 
-			if (file) {
-				uploadMessage = 'Uploading...';
-
-				fileResponse = await uploadModel(localStorage.token, file, selectedOllamaUrlIdx).catch(
-					(error) => {
-						toast.error(error);
-						return null;
-					}
-				);
-			}
-		} else {
-			uploadProgress = 0;
-			fileResponse = await downloadModel(
-				localStorage.token,
-				modelFileUrl,
-				selectedOllamaUrlIdx
-			).catch((error) => {
-				toast.error(error);
+		if (workspaceModels.find((m) => m.id === model.id)) {
+			await updateModelById(localStorage.token, model.id, model).catch((error) => {
 				return null;
 			});
-		}
-
-		if (fileResponse && fileResponse.ok) {
-			const reader = fileResponse.body
-				.pipeThrough(new TextDecoderStream())
-				.pipeThrough(splitStream('\n'))
-				.getReader();
-
-			while (true) {
-				const { value, done } = await reader.read();
-				if (done) break;
-
-				try {
-					let lines = value.split('\n');
-
-					for (const line of lines) {
-						if (line !== '') {
-							let data = JSON.parse(line.replace(/^data: /, ''));
-
-							if (data.progress) {
-								if (uploadMessage) {
-									uploadMessage = '';
-								}
-								uploadProgress = data.progress;
-							}
-
-							if (data.error) {
-								throw data.error;
-							}
-
-							if (data.done) {
-								modelFileDigest = data.blob;
-								name = data.name;
-								uploaded = true;
-							}
-						}
-					}
-				} catch (error) {
-					console.log(error);
-				}
-			}
 		} else {
-			const error = await fileResponse?.json();
-			toast.error(error?.detail ?? error);
-		}
-
-		if (uploaded) {
-			const res = await createModel(
-				localStorage.token,
-				`${name}:latest`,
-				`FROM @${modelFileDigest}\n${modelFileContent}`
-			);
-
-			if (res && res.ok) {
-				const reader = res.body
-					.pipeThrough(new TextDecoderStream())
-					.pipeThrough(splitStream('\n'))
-					.getReader();
-
-				while (true) {
-					const { value, done } = await reader.read();
-					if (done) break;
-
-					try {
-						let lines = value.split('\n');
-
-						for (const line of lines) {
-							if (line !== '') {
-								console.log(line);
-								let data = JSON.parse(line);
-								console.log(data);
-
-								if (data.error) {
-									throw data.error;
-								}
-								if (data.detail) {
-									throw data.detail;
-								}
-
-								if (data.status) {
-									if (
-										!data.digest &&
-										!data.status.includes('writing') &&
-										!data.status.includes('sha256')
-									) {
-										toast.success(data.status);
-									} else {
-										if (data.digest) {
-											digest = data.digest;
-
-											if (data.completed) {
-												pullProgress = Math.round((data.completed / data.total) * 1000) / 10;
-											} else {
-												pullProgress = 100;
-											}
-										}
-									}
-								}
-							}
-						}
-					} catch (error) {
-						console.log(error);
-						toast.error(error);
-					}
-				}
-			}
-		}
-
-		modelFileUrl = '';
-
-		if (modelUploadInputElement) {
-			modelUploadInputElement.value = '';
-		}
-		modelInputFile = null;
-		modelTransferring = false;
-		uploadProgress = null;
-
-		models.set(await getModels());
-	};
-
-	const deleteModelHandler = async () => {
-		const res = await deleteModel(localStorage.token, deleteModelTag, selectedOllamaUrlIdx).catch(
-			(error) => {
-				toast.error(error);
-			}
-		);
-
-		if (res) {
-			toast.success($i18n.t(`Deleted {{deleteModelTag}}`, { deleteModelTag }));
+			await createNewModel(localStorage.token, model).catch((error) => {
+				return null;
+			});
 		}
 
-		deleteModelTag = '';
-		models.set(await getModels());
+		_models.set(await getModels(localStorage.token));
+		await init();
 	};
 
-	const cancelModelPullHandler = async (model: string) => {
-		const { reader, abortController } = $MODEL_DOWNLOAD_POOL[model];
-		if (abortController) {
-			abortController.abort();
-		}
-		if (reader) {
-			await reader.cancel();
-			delete $MODEL_DOWNLOAD_POOL[model];
-			MODEL_DOWNLOAD_POOL.set({
-				...$MODEL_DOWNLOAD_POOL
+	const toggleModelHandler = async (model) => {
+		if (!Object.keys(model).includes('base_model_id')) {
+			await createNewModel(localStorage.token, {
+				id: model.id,
+				name: model.name,
+				base_model_id: null,
+				meta: {},
+				params: {},
+				is_active: model.is_active
+			}).catch((error) => {
+				return null;
 			});
-			await deleteModel(localStorage.token, model);
-			toast.success(`${model} download has been canceled`);
-		}
-	};
-
-	const createModelHandler = async () => {
-		createModelLoading = true;
-		const res = await createModel(
-			localStorage.token,
-			createModelTag,
-			createModelContent,
-			selectedOllamaUrlIdx
-		).catch((error) => {
-			toast.error(error);
-			return null;
-		});
-
-		if (res && res.ok) {
-			const reader = res.body
-				.pipeThrough(new TextDecoderStream())
-				.pipeThrough(splitStream('\n'))
-				.getReader();
-
-			while (true) {
-				const { value, done } = await reader.read();
-				if (done) break;
-
-				try {
-					let lines = value.split('\n');
-
-					for (const line of lines) {
-						if (line !== '') {
-							console.log(line);
-							let data = JSON.parse(line);
-							console.log(data);
-
-							if (data.error) {
-								throw data.error;
-							}
-							if (data.detail) {
-								throw data.detail;
-							}
 
-							if (data.status) {
-								if (
-									!data.digest &&
-									!data.status.includes('writing') &&
-									!data.status.includes('sha256')
-								) {
-									toast.success(data.status);
-								} else {
-									if (data.digest) {
-										createModelDigest = data.digest;
-
-										if (data.completed) {
-											createModelPullProgress =
-												Math.round((data.completed / data.total) * 1000) / 10;
-										} else {
-											createModelPullProgress = 100;
-										}
-									}
-								}
-							}
-						}
-					}
-				} catch (error) {
-					console.log(error);
-					toast.error(error);
-				}
-			}
+			await init();
+		} else {
+			await toggleModelById(localStorage.token, model.id);
 		}
 
-		models.set(await getModels());
-
-		createModelLoading = false;
-
-		createModelTag = '';
-		createModelContent = '';
-		createModelDigest = '';
-		createModelPullProgress = null;
+		_models.set(await getModels(localStorage.token));
 	};
 
 	onMount(async () => {
-		const ollamaConfig = await getOllamaConfig(localStorage.token);
-
-		if (ollamaConfig.ENABLE_OLLAMA_API) {
-			ollamaEnabled = true;
-
-			OLLAMA_BASE_URLS = ollamaConfig.OLLAMA_BASE_URLS;
-
-			if (OLLAMA_BASE_URLS.length > 0) {
-				selectedOllamaUrlIdx = 0;
-			}
-
-			ollamaVersion = true;
-		} else {
-			ollamaEnabled = false;
-			toast.error($i18n.t('Ollama API is disabled'));
-		}
+		init();
 	});
 </script>
 
-<ModelDeleteConfirmDialog
-	bind:show={showModelDeleteConfirm}
-	on:confirm={() => {
-		deleteModelHandler();
-	}}
-/>
-
-<div class="flex flex-col h-full justify-between text-sm">
-	<div class=" space-y-3 overflow-y-scroll scrollbar-hidden h-full">
-		{#if ollamaEnabled}
-			{#if ollamaVersion !== null}
-				<div class="space-y-2 pr-1.5">
-					<div class="text-sm font-medium">{$i18n.t('Manage Ollama Models')}</div>
+{#if models !== null}
+	{#if selectedModelId === null}
+		<div class="flex flex-col gap-1 mt-1.5 mb-2">
+			<div class="flex justify-between items-center">
+				<div class="flex items-center md:self-center text-xl font-medium px-0.5">
+					{$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>
 
-					{#if OLLAMA_BASE_URLS.length > 0}
-						<div class="flex gap-2">
-							<div class="flex-1 pb-1">
-								<select
-									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={selectedOllamaUrlIdx}
-									placeholder={$i18n.t('Select an Ollama instance')}
+			<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>
+		</div>
+
+		<div class=" my-2 mb-5" id="model-list">
+			{#if models.length > 0}
+				{#each filteredModels as model (model.id)}
+					<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}"
+					>
+						<button
+							class=" flex flex-1 text-left space-x-3.5 cursor-pointer w-full"
+							type="button"
+							on:click={() => {
+								selectedModelId = model.id;
+							}}
+						>
+							<div class=" self-center w-8">
+								<div
+									class=" rounded-full object-cover {(model?.is_active ?? true)
+										? ''
+										: 'opacity-50 dark:opacity-50'} "
 								>
-									{#each OLLAMA_BASE_URLS as url, idx}
-										<option value={idx} class="bg-gray-50 dark:bg-gray-700">{url}</option>
-									{/each}
-								</select>
-							</div>
-
-							<div>
-								<div class="flex w-full justify-end">
-									<Tooltip content="Update All Models" placement="top">
-										<button
-											class="p-2.5 flex gap-2 items-center bg-gray-50 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition"
-											on:click={() => {
-												updateModelsHandler();
-											}}
-										>
-											<svg
-												xmlns="http://www.w3.org/2000/svg"
-												viewBox="0 0 16 16"
-												fill="currentColor"
-												class="w-4 h-4"
-											>
-												<path
-													d="M7 1a.75.75 0 0 1 .75.75V6h-1.5V1.75A.75.75 0 0 1 7 1ZM6.25 6v2.94L5.03 7.72a.75.75 0 0 0-1.06 1.06l2.5 2.5a.75.75 0 0 0 1.06 0l2.5-2.5a.75.75 0 1 0-1.06-1.06L7.75 8.94V6H10a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2.25Z"
-												/>
-												<path
-													d="M4.268 14A2 2 0 0 0 6 15h6a2 2 0 0 0 2-2v-3a2 2 0 0 0-1-1.732V11a3 3 0 0 1-3 3H4.268Z"
-												/>
-											</svg>
-										</button>
-									</Tooltip>
-								</div>
-							</div>
-						</div>
-
-						{#if updateModelId}
-							Updating "{updateModelId}" {updateProgress ? `(${updateProgress}%)` : ''}
-						{/if}
-					{/if}
-
-					<div class="space-y-2">
-						<div>
-							<div class=" mb-2 text-sm font-medium">{$i18n.t('Pull a model from Ollama.com')}</div>
-							<div class="flex w-full">
-								<div class="flex-1 mr-2">
-									<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"
-										placeholder={$i18n.t('Enter model tag (e.g. {{modelTag}})', {
-											modelTag: 'mistral:7b'
-										})}
-										bind:value={modelTag}
+									<img
+										src={model?.meta?.profile_image_url ?? '/static/favicon.png'}
+										alt="modelfile profile"
+										class=" rounded-full w-full h-auto object-cover"
 									/>
 								</div>
-								<button
-									class="px-2.5 bg-gray-50 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition"
-									on:click={() => {
-										pullModelHandler();
-									}}
-									disabled={modelTransferring}
-								>
-									{#if modelTransferring}
-										<div class="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>
-									{:else}
-										<svg
-											xmlns="http://www.w3.org/2000/svg"
-											viewBox="0 0 16 16"
-											fill="currentColor"
-											class="w-4 h-4"
-										>
-											<path
-												d="M8.75 2.75a.75.75 0 0 0-1.5 0v5.69L5.03 6.22a.75.75 0 0 0-1.06 1.06l3.5 3.5a.75.75 0 0 0 1.06 0l3.5-3.5a.75.75 0 0 0-1.06-1.06L8.75 8.44V2.75Z"
-											/>
-											<path
-												d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
-											/>
-										</svg>
-									{/if}
-								</button>
 							</div>
 
-							<div class="mt-2 mb-1 text-xs text-gray-400 dark:text-gray-500">
-								{$i18n.t('To access the available model names for downloading,')}
-								<a
-									class=" text-gray-500 dark:text-gray-300 font-medium underline"
-									href="https://ollama.com/library"
-									target="_blank">{$i18n.t('click here.')}</a
+							<div class=" flex-1 self-center {(model?.is_active ?? true) ? '' : 'text-gray-500'}">
+								<Tooltip
+									content={marked.parse(model?.meta?.description ?? model.id)}
+									className=" w-fit"
+									placement="top-start"
 								>
-							</div>
-
-							{#if Object.keys($MODEL_DOWNLOAD_POOL).length > 0}
-								{#each Object.keys($MODEL_DOWNLOAD_POOL) as model}
-									{#if 'pullProgress' in $MODEL_DOWNLOAD_POOL[model]}
-										<div class="flex flex-col">
-											<div class="font-medium mb-1">{model}</div>
-											<div class="">
-												<div class="flex flex-row justify-between space-x-4 pr-2">
-													<div class=" flex-1">
-														<div
-															class="dark:bg-gray-600 bg-gray-500 text-xs font-medium text-gray-100 text-center p-0.5 leading-none rounded-full"
-															style="width: {Math.max(
-																15,
-																$MODEL_DOWNLOAD_POOL[model].pullProgress ?? 0
-															)}%"
-														>
-															{$MODEL_DOWNLOAD_POOL[model].pullProgress ?? 0}%
-														</div>
-													</div>
-
-													<Tooltip content={$i18n.t('Cancel')}>
-														<button
-															class="text-gray-800 dark:text-gray-100"
-															on:click={() => {
-																cancelModelPullHandler(model);
-															}}
-														>
-															<svg
-																class="w-4 h-4 text-gray-800 dark:text-white"
-																aria-hidden="true"
-																xmlns="http://www.w3.org/2000/svg"
-																width="24"
-																height="24"
-																fill="currentColor"
-																viewBox="0 0 24 24"
-															>
-																<path
-																	stroke="currentColor"
-																	stroke-linecap="round"
-																	stroke-linejoin="round"
-																	stroke-width="2"
-																	d="M6 18 17.94 6M18 18 6.06 6"
-																/>
-															</svg>
-														</button>
-													</Tooltip>
-												</div>
-												{#if 'digest' in $MODEL_DOWNLOAD_POOL[model]}
-													<div class="mt-1 text-xs dark:text-gray-500" style="font-size: 0.5rem;">
-														{$MODEL_DOWNLOAD_POOL[model].digest}
-													</div>
-												{/if}
-											</div>
-										</div>
-									{/if}
-								{/each}
-							{/if}
-						</div>
-
-						<div>
-							<div class=" mb-2 text-sm font-medium">{$i18n.t('Delete a model')}</div>
-							<div class="flex w-full">
-								<div class="flex-1 mr-2">
-									<select
-										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={deleteModelTag}
-										placeholder={$i18n.t('Select a model')}
-									>
-										{#if !deleteModelTag}
-											<option value="" disabled selected>{$i18n.t('Select a model')}</option>
-										{/if}
-										{#each $models.filter((m) => !(m?.preset ?? false) && m.owned_by === 'ollama' && (selectedOllamaUrlIdx === null ? true : (m?.ollama?.urls ?? []).includes(selectedOllamaUrlIdx))) as model}
-											<option value={model.id} class="bg-gray-50 dark:bg-gray-700"
-												>{model.name +
-													' (' +
-													(model.ollama.size / 1024 ** 3).toFixed(1) +
-													' GB)'}</option
-											>
-										{/each}
-									</select>
+									<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>
-								<button
-									class="px-2.5 bg-gray-50 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition"
-									on:click={() => {
-										showModelDeleteConfirm = true;
-									}}
-								>
-									<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="M5 3.25V4H2.75a.75.75 0 0 0 0 1.5h.3l.815 8.15A1.5 1.5 0 0 0 5.357 15h5.285a1.5 1.5 0 0 0 1.493-1.35l.815-8.15h.3a.75.75 0 0 0 0-1.5H11v-.75A2.25 2.25 0 0 0 8.75 1h-1.5A2.25 2.25 0 0 0 5 3.25Zm2.25-.75a.75.75 0 0 0-.75.75V4h3v-.75a.75.75 0 0 0-.75-.75h-1.5ZM6.05 6a.75.75 0 0 1 .787.713l.275 5.5a.75.75 0 0 1-1.498.075l-.275-5.5A.75.75 0 0 1 6.05 6Zm3.9 0a.75.75 0 0 1 .712.787l-.275 5.5a.75.75 0 0 1-1.498-.075l.275-5.5a.75.75 0 0 1 .786-.711Z"
-											clip-rule="evenodd"
-										/>
-									</svg>
-								</button>
 							</div>
-						</div>
-
-						<div>
-							<div class=" mb-2 text-sm font-medium">{$i18n.t('Create a model')}</div>
-							<div class="flex w-full">
-								<div class="flex-1 mr-2 flex flex-col gap-2">
-									<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"
-										placeholder={$i18n.t('Enter model tag (e.g. {{modelTag}})', {
-											modelTag: 'my-modelfile'
-										})}
-										bind:value={createModelTag}
-										disabled={createModelLoading}
-									/>
-
-									<textarea
-										bind:value={createModelContent}
-										class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-100 dark:bg-gray-850 outline-none resize-none scrollbar-hidden"
-										rows="6"
-										placeholder={`TEMPLATE """{{ .System }}\nUSER: {{ .Prompt }}\nASSISTANT: """\nPARAMETER num_ctx 4096\nPARAMETER stop "</s>"\nPARAMETER stop "USER:"\nPARAMETER stop "ASSISTANT:"`}
-										disabled={createModelLoading}
+						</button>
+						<div class="flex flex-row gap-0.5 items-center self-center">
+							<button
+								class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
+								type="button"
+								on:click={() => {
+									selectedModelId = model.id;
+								}}
+							>
+								<svg
+									xmlns="http://www.w3.org/2000/svg"
+									fill="none"
+									viewBox="0 0 24 24"
+									stroke-width="1.5"
+									stroke="currentColor"
+									class="w-4 h-4"
+								>
+									<path
+										stroke-linecap="round"
+										stroke-linejoin="round"
+										d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125"
 									/>
-								</div>
+								</svg>
+							</button>
 
-								<div class="flex self-start">
-									<button
-										class="px-2.5 py-2.5 bg-gray-50 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition disabled:cursor-not-allowed"
-										on:click={() => {
-											createModelHandler();
-										}}
-										disabled={createModelLoading}
-									>
-										<svg
-											xmlns="http://www.w3.org/2000/svg"
-											viewBox="0 0 16 16"
-											fill="currentColor"
-											class="size-4"
-										>
-											<path
-												d="M7.25 10.25a.75.75 0 0 0 1.5 0V4.56l2.22 2.22a.75.75 0 1 0 1.06-1.06l-3.5-3.5a.75.75 0 0 0-1.06 0l-3.5 3.5a.75.75 0 0 0 1.06 1.06l2.22-2.22v5.69Z"
-											/>
-											<path
-												d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
-											/>
-										</svg>
-									</button>
-								</div>
-							</div>
-
-							{#if createModelDigest !== ''}
-								<div class="flex flex-col mt-1">
-									<div class="font-medium mb-1">{createModelTag}</div>
-									<div class="">
-										<div class="flex flex-row justify-between space-x-4 pr-2">
-											<div class=" flex-1">
-												<div
-													class="dark:bg-gray-600 bg-gray-500 text-xs font-medium text-gray-100 text-center p-0.5 leading-none rounded-full"
-													style="width: {Math.max(15, createModelPullProgress ?? 0)}%"
-												>
-													{createModelPullProgress ?? 0}%
-												</div>
-											</div>
-										</div>
-										{#if createModelDigest}
-											<div class="mt-1 text-xs dark:text-gray-500" style="font-size: 0.5rem;">
-												{createModelDigest}
-											</div>
-										{/if}
-									</div>
-								</div>
-							{/if}
-						</div>
-
-						<div class="pt-1">
-							<div class="flex justify-between items-center text-xs">
-								<div class=" text-sm font-medium">{$i18n.t('Experimental')}</div>
-								<button
-									class=" text-xs font-medium text-gray-500"
-									type="button"
-									on:click={() => {
-										showExperimentalOllama = !showExperimentalOllama;
-									}}>{showExperimentalOllama ? $i18n.t('Hide') : $i18n.t('Show')}</button
+							<div class="ml-1">
+								<Tooltip
+									content={(model?.is_active ?? true) ? $i18n.t('Enabled') : $i18n.t('Disabled')}
 								>
+									<Switch
+										bind:state={model.is_active}
+										on:change={async () => {
+											toggleModelHandler(model);
+										}}
+									/>
+								</Tooltip>
 							</div>
 						</div>
-
-						{#if showExperimentalOllama}
-							<form
-								on:submit|preventDefault={() => {
-									uploadModelHandler();
-								}}
-							>
-								<div class=" mb-2 flex w-full justify-between">
-									<div class="  text-sm font-medium">{$i18n.t('Upload a GGUF model')}</div>
-
-									<button
-										class="p-1 px-3 text-xs flex rounded transition"
-										on:click={() => {
-											if (modelUploadMode === 'file') {
-												modelUploadMode = 'url';
-											} else {
-												modelUploadMode = 'file';
-											}
-										}}
-										type="button"
-									>
-										{#if modelUploadMode === 'file'}
-											<span class="ml-2 self-center">{$i18n.t('File Mode')}</span>
-										{:else}
-											<span class="ml-2 self-center">{$i18n.t('URL Mode')}</span>
-										{/if}
-									</button>
-								</div>
-
-								<div class="flex w-full mb-1.5">
-									<div class="flex flex-col w-full">
-										{#if modelUploadMode === 'file'}
-											<div
-												class="flex-1 {modelInputFile && modelInputFile.length > 0 ? 'mr-2' : ''}"
-											>
-												<input
-													id="model-upload-input"
-													bind:this={modelUploadInputElement}
-													type="file"
-													bind:files={modelInputFile}
-													on:change={() => {
-														console.log(modelInputFile);
-													}}
-													accept=".gguf,.safetensors"
-													required
-													hidden
-												/>
-
-												<button
-													type="button"
-													class="w-full rounded-lg text-left py-2 px-4 bg-gray-50 dark:text-gray-300 dark:bg-gray-850"
-													on:click={() => {
-														modelUploadInputElement.click();
-													}}
-												>
-													{#if modelInputFile && modelInputFile.length > 0}
-														{modelInputFile[0].name}
-													{:else}
-														{$i18n.t('Click here to select')}
-													{/if}
-												</button>
-											</div>
-										{:else}
-											<div class="flex-1 {modelFileUrl !== '' ? 'mr-2' : ''}">
-												<input
-													class="w-full rounded-lg text-left py-2 px-4 bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none {modelFileUrl !==
-													''
-														? 'mr-2'
-														: ''}"
-													type="url"
-													required
-													bind:value={modelFileUrl}
-													placeholder={$i18n.t('Type Hugging Face Resolve (Download) URL')}
-												/>
-											</div>
-										{/if}
-									</div>
-
-									{#if (modelUploadMode === 'file' && modelInputFile && modelInputFile.length > 0) || (modelUploadMode === 'url' && modelFileUrl !== '')}
-										<button
-											class="px-2.5 bg-gray-50 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg disabled:cursor-not-allowed transition"
-											type="submit"
-											disabled={modelTransferring}
-										>
-											{#if modelTransferring}
-												<div class="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>
-											{:else}
-												<svg
-													xmlns="http://www.w3.org/2000/svg"
-													viewBox="0 0 16 16"
-													fill="currentColor"
-													class="w-4 h-4"
-												>
-													<path
-														d="M7.25 10.25a.75.75 0 0 0 1.5 0V4.56l2.22 2.22a.75.75 0 1 0 1.06-1.06l-3.5-3.5a.75.75 0 0 0-1.06 0l-3.5 3.5a.75.75 0 0 0 1.06 1.06l2.22-2.22v5.69Z"
-													/>
-													<path
-														d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
-													/>
-												</svg>
-											{/if}
-										</button>
-									{/if}
-								</div>
-
-								{#if (modelUploadMode === 'file' && modelInputFile && modelInputFile.length > 0) || (modelUploadMode === 'url' && modelFileUrl !== '')}
-									<div>
-										<div>
-											<div class=" my-2.5 text-sm font-medium">{$i18n.t('Modelfile Content')}</div>
-											<textarea
-												bind:value={modelFileContent}
-												class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-100 dark:bg-gray-850 outline-none resize-none"
-												rows="6"
-											/>
-										</div>
-									</div>
-								{/if}
-								<div class=" mt-1 text-xs text-gray-400 dark:text-gray-500">
-									{$i18n.t('To access the GGUF models available for downloading,')}
-									<a
-										class=" text-gray-500 dark:text-gray-300 font-medium underline"
-										href="https://huggingface.co/models?search=gguf"
-										target="_blank">{$i18n.t('click here.')}</a
-									>
-								</div>
-
-								{#if uploadMessage}
-									<div class="mt-2">
-										<div class=" mb-2 text-xs">{$i18n.t('Upload Progress')}</div>
-
-										<div class="w-full rounded-full dark:bg-gray-800">
-											<div
-												class="dark:bg-gray-600 bg-gray-500 text-xs font-medium text-gray-100 text-center p-0.5 leading-none rounded-full"
-												style="width: 100%"
-											>
-												{uploadMessage}
-											</div>
-										</div>
-										<div class="mt-1 text-xs dark:text-gray-500" style="font-size: 0.5rem;">
-											{modelFileDigest}
-										</div>
-									</div>
-								{:else if uploadProgress !== null}
-									<div class="mt-2">
-										<div class=" mb-2 text-xs">{$i18n.t('Upload Progress')}</div>
-
-										<div class="w-full rounded-full dark:bg-gray-800">
-											<div
-												class="dark:bg-gray-600 bg-gray-500 text-xs font-medium text-gray-100 text-center p-0.5 leading-none rounded-full"
-												style="width: {Math.max(15, uploadProgress ?? 0)}%"
-											>
-												{uploadProgress ?? 0}%
-											</div>
-										</div>
-										<div class="mt-1 text-xs dark:text-gray-500" style="font-size: 0.5rem;">
-											{modelFileDigest}
-										</div>
-									</div>
-								{/if}
-							</form>
-						{/if}
 					</div>
-				</div>
-			{:else if ollamaVersion === false}
-				<div>Ollama Not Detected</div>
+				{/each}
 			{:else}
-				<div class="flex h-full justify-center">
-					<div class="my-auto">
-						<Spinner className="size-6" />
+				<div class="flex flex-col items-center justify-center w-full h-20">
+					<div class="text-gray-500 dark:text-gray-400 text-xs">
+						{$i18n.t('No models found')}
 					</div>
 				</div>
 			{/if}
-		{:else if ollamaEnabled === false}
-			<div>{$i18n.t('Ollama API is disabled')}</div>
-		{:else}
-			<div class="flex h-full justify-center">
-				<div class="my-auto">
-					<Spinner className="size-6" />
+		</div>
+
+		{#if $user?.role === 'admin'}
+			<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 (Object.keys(model).includes('base_model_id')) {
+										if (model.base_model_id === null) {
+											upsertModelHandler(model);
+										}
+									} else {
+										if (model?.info ?? false) {
+											if (model.info.base_model_id === null) {
+												upsertModelHandler(model.info);
+											}
+										}
+									}
+								}
+
+								await _models.set(await getModels(localStorage.token));
+								init();
+							};
+
+							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 Presets')}
+						</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 Presets')}
+						</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>
 				</div>
 			</div>
 		{/if}
+	{:else}
+		<ModelEditor
+			edit
+			model={models.find((m) => m.id === selectedModelId)}
+			preset={false}
+			onSubmit={(model) => {
+				console.log(model);
+				upsertModelHandler(model);
+				selectedModelId = null;
+			}}
+			onBack={() => {
+				selectedModelId = null;
+			}}
+		/>
+	{/if}
+{:else}
+	<div class=" h-full w-full flex justify-center items-center">
+		<Spinner />
 	</div>
-</div>
+{/if}

+ 0 - 214
src/lib/components/admin/Settings/Users.svelte

@@ -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>

+ 28 - 4
src/lib/components/admin/Users.svelte

@@ -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 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>

+ 117 - 3
src/lib/components/admin/Users/Groups.svelte

@@ -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}

+ 149 - 0
src/lib/components/admin/Users/Groups/AddGroupModal.svelte

@@ -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>

+ 61 - 0
src/lib/components/admin/Users/Groups/Display.svelte

@@ -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>

+ 328 - 0
src/lib/components/admin/Users/Groups/EditGroupModal.svelte

@@ -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>

+ 84 - 0
src/lib/components/admin/Users/Groups/GroupItem.svelte

@@ -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>

+ 204 - 0
src/lib/components/admin/Users/Groups/Permissions.svelte

@@ -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>

+ 122 - 0
src/lib/components/admin/Users/Groups/Users.svelte

@@ -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>

+ 287 - 301
src/lib/components/admin/Users/UserList.svelte

@@ -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')}
-
-							{#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">
+			<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" />
-								</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">
+								{: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')}
+
+						{#if sortKey === 'name'}
+							<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('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">
+								{: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" />
-								</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">
+								{: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" />
-								</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">
+								{: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" />
-								</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">
+								{: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" />
-								</span>
-							{/if}
+								{: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>
-					</th>
+					</td>
+					<td class=" px-3 py-1"> {user.email} </td>
 
-					<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>
-								{/if}
+					<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>
 
-								<Tooltip content={$i18n.t('Edit User')}>
+					<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>
+							{/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 () => {
-											showEditUserModal = !showEditUserModal;
+											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} />

+ 27 - 18
src/lib/components/chat/Chat.svelte

@@ -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
+ 550 - 349
src/lib/components/chat/MessageInput.svelte


+ 2 - 2
src/lib/components/chat/MessageInput/Commands.svelte

@@ -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;

+ 19 - 18
src/lib/components/chat/MessageInput/InputMenu.svelte

@@ -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}

+ 10 - 12
src/lib/components/chat/Messages/ResponseMessage.svelte

@@ -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

+ 1 - 1
src/lib/components/chat/ModelSelector.svelte

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

+ 10 - 23
src/lib/components/chat/ModelSelector/Selector.svelte

@@ -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>

+ 1 - 2
src/lib/components/chat/Placeholder.svelte

@@ -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}

+ 2 - 4
src/lib/components/common/Switch.svelte

@@ -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"

+ 19 - 0
src/lib/components/icons/LockClosed.svelte

@@ -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>

+ 11 - 0
src/lib/components/icons/UserCircleSolid.svelte

@@ -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>

+ 9 - 0
src/lib/components/icons/UserPlusSolid.svelte

@@ -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>

+ 9 - 0
src/lib/components/icons/UsersSolid.svelte

@@ -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>

+ 20 - 0
src/lib/components/icons/Wrench.svelte

@@ -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>

+ 1 - 1
src/lib/components/layout/Sidebar.svelte

@@ -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"

+ 113 - 101
src/lib/components/workspace/Knowledge.svelte

@@ -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);
-	}}
-/>
-
-<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
-			>
+{#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>
 		</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 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>
-			<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>
+	<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" />
-			</button>
-		</div>
-	</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 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="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 class=" self-center flex-1">
+						<div class=" text-xs overflow-hidden text-ellipsis line-clamp-1">
+							{item.description}
 						</div>
-						<div class=" text-xs text-gray-500 line-clamp-1">
-							{$i18n.t('Updated')}
-							{dayjs(item.updated_at * 1000).fromNow()}
+
+						<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>
-			</div>
-		</button>
-	{/each}
-</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}

+ 16 - 3
src/lib/components/workspace/Knowledge/CreateCollection.svelte → src/lib/components/workspace/Knowledge/CreateKnowledgeBase.svelte

@@ -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

+ 78 - 44
src/lib/components/workspace/Knowledge/Collection.svelte → src/lib/components/workspace/Knowledge/KnowledgeBase.svelte

@@ -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>

+ 0 - 0
src/lib/components/workspace/Knowledge/Collection/AddContentMenu.svelte → src/lib/components/workspace/Knowledge/KnowledgeBase/AddContentMenu.svelte


+ 0 - 0
src/lib/components/workspace/Knowledge/Collection/AddTextContentModal.svelte → src/lib/components/workspace/Knowledge/KnowledgeBase/AddTextContentModal.svelte


+ 0 - 0
src/lib/components/workspace/Knowledge/Collection/Files.svelte → src/lib/components/workspace/Knowledge/KnowledgeBase/Files.svelte


+ 288 - 489
src/lib/components/workspace/Models.svelte

@@ -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') ?? '[]');
-
-		if (localModelfiles) {
-			console.log(localModelfiles);
-		}
+		models = await getWorkspaceModels(localStorage.token);
 
-		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);
-	}}
-/>
-
-<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
-			>
+{#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>
 
-	<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 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>
-			<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>
+				<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>
-</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>
+
+	<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>
 
-	<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 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>
-	</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>
+	</a>
 
-				<div
-					class=" flex-1 self-center {(model?.info?.meta?.hidden ?? false) ? 'text-gray-500' : ''}"
+	<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)}`}
 				>
-					<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 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>
-			</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={() => {
-								hideModelHandler(model);
-							}}
+
+					<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"
 						>
-							{#if model?.info?.meta?.hidden ?? false}
+							<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="size-4"
+									class="w-4 h-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"
+										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>
-							{: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);
+							</a>
+						{/if}
+
+						<ModelMenu
+							user={$user}
+							{model}
+							shareHandler={() => {
+								shareModelHandler(model);
+							}}
+							cloneHandler={() => {
+								cloneModelHandler(model);
+							}}
+							exportHandler={() => {
+								exportModelHandler(model);
 							}}
+							hideHandler={() => {
+								hideModelHandler(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)}`}
-					>
+							<button
+								class="self-center w-fit text-sm p-1.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
+								type="button"
+							>
+								<EllipsisHorizontal className="size-5" />
+							</button>
+						</ModelMenu>
+
+						<div class="ml-1">
+							<Tooltip content={model.is_active ? $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>
+		{/each}
+	</div>
+
+	{#if $user?.role === 'admin'}
+		<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 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="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"
+								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>
-					</a>
-
-					<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>
-		</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>
-	</div>
-
-	{#if localModelfiles.length > 0}
-		<div class="flex">
-			<div class=" self-center text-sm font-medium mr-4">
-				{localModelfiles.length} Local Modelfiles Detected
-			</div>
+					</div>
+				</button>
 
-			<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"
+					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(localModelfiles);
-
-						localStorage.removeItem('modelfiles');
-						localModelfiles = JSON.parse(localStorage.getItem('modelfiles') ?? '[]');
+						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"
-							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 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')}
-		</div>
+	{#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 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>
+			<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 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>
+						<ChevronRight />
+					</div>
 				</div>
-			</div>
-		</a>
+			</a>
+		</div>
+	{/if}
+{:else}
+	<div class="w-full h-full flex justify-center items-center">
+		<Spinner />
 	</div>
 {/if}

+ 1 - 1
src/lib/components/workspace/Models/Knowledge.svelte

@@ -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>

+ 94 - 57
src/lib/components/workspace/Models/ModelEditor.svelte

@@ -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}
 						>

+ 1 - 64
src/lib/components/workspace/Models/ModelMenu.svelte

@@ -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

+ 240 - 231
src/lib/components/workspace/Prompts.svelte

@@ -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"
-			>
-				<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"
-		>
-			<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 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 class="flex flex-row gap-0.5 self-center">
+
+			<div>
 				<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)}`}
+					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"
 				>
-					<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>
+					<Plus className="size-3.5" />
 				</a>
+			</div>
+		</div>
+	</div>
 
-				<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"
+	<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)}`}
 					>
-						<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>
+						<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>
 
-		<!-- <button
-						on:click={() => {
-							loadDefaultPrompts();
+					<PromptMenu
+						shareHandler={() => {
+							shareHandler(prompt);
+						}}
+						cloneHandler={() => {
+							cloneHandler(prompt);
 						}}
+						exportHandler={() => {
+							exportHandler(prompt);
+						}}
+						deleteHandler={async () => {
+							deletePrompt = prompt;
+							showDeleteConfirm = true;
+						}}
+						onClose={() => {}}
 					>
-						dd
-					</button> -->
+						<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>
 
-{#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-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);
 
-		<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"
+						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();
+					}}
 				>
-					<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 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}
 
-			<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>
+	{#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>
+
+			<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>
+	{/if}
+{:else}
+	<div class="w-full h-full flex justify-center items-center">
+		<Spinner />
 	</div>
 {/if}

+ 32 - 4
src/lib/components/workspace/Prompts/PromptEditor.svelte

@@ -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}
 			>

+ 306 - 302
src/lib/components/workspace/Tools.svelte

@@ -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
-			>
+{#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>
 		</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 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>
-			<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>
+				<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={() => {
+						<ToolMenu
+							editHandler={() => {
+								goto(`/workspace/tools/edit?id=${encodeURIComponent(tool.id)}`);
+							}}
+							shareHandler={() => {
+								shareHandler(tool);
+							}}
+							cloneHandler={() => {
+								cloneHandler(tool);
+							}}
+							exportHandler={() => {
+								exportHandler(tool);
+							}}
+							deleteHandler={async () => {
 								selectedTool = tool;
-								showValvesModal = true;
+								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"
+		{/each}
+	</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;
+					}}
+				/>
+
+				<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();
+					}}
 				>
-					<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"
+					<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`);
+						}
+					}}
 				>
-					<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 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>
-		</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>
+	{/if}
 
-		<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>
+	{#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>
-		</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>
+					<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>
 
-		<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 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>
-</ConfirmDialog>
+{/if}

+ 30 - 13
src/lib/components/workspace/Tools/ToolkitEditor.svelte

@@ -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
 							/>

+ 193 - 0
src/lib/components/workspace/common/AccessControl.svelte

@@ -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>

+ 43 - 0
src/lib/components/workspace/common/AccessControlModal.svelte

@@ -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>

+ 1 - 1
src/routes/(app)/+layout.svelte

@@ -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';

+ 7 - 0
src/routes/(app)/admin/+layout.svelte

@@ -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')
 								? ''

+ 1 - 1
src/routes/(app)/workspace/functions/+page.svelte → src/routes/(app)/admin/functions/+page.svelte

@@ -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([

+ 1 - 1
src/routes/(app)/workspace/functions/create/+page.svelte → src/routes/(app)/admin/functions/create/+page.svelte

@@ -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';

+ 1 - 1
src/routes/(app)/workspace/functions/edit/+page.svelte → src/routes/(app)/admin/functions/edit/+page.svelte

@@ -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';

+ 59 - 49
src/routes/(app)/workspace/+layout.svelte

@@ -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>

+ 2 - 2
src/routes/(app)/workspace/knowledge/+page.svelte

@@ -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));
 			})()
 		]);
 	});

+ 2 - 2
src/routes/(app)/workspace/knowledge/[id]/+page.svelte

@@ -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 />

+ 2 - 2
src/routes/(app)/workspace/knowledge/create/+page.svelte

@@ -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 />

+ 5 - 2
src/routes/(app)/workspace/models/create/+page.svelte

@@ -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) {

+ 6 - 3
src/routes/(app)/workspace/models/edit/+page.svelte

@@ -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');
 			}

+ 1 - 15
src/routes/(app)/workspace/prompts/+page.svelte

@@ -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 />

+ 14 - 11
src/routes/(app)/workspace/prompts/create/+page.svelte

@@ -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');
 		}

+ 20 - 11
src/routes/(app)/workspace/prompts/edit/+page.svelte

@@ -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');

+ 1 - 13
src/routes/(app)/workspace/tools/+page.svelte

@@ -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 changed in this diff