Merge branch 'open-webui:dev' into dev

This commit is contained in:
Andrew Baek 2025-09-24 17:10:48 +09:00 committed by GitHub
commit 5be58f2601
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 2163 additions and 931 deletions

11
LICENSE_NOTICE Normal file
View file

@ -0,0 +1,11 @@
# Open WebUI Multi-License Notice
This repository contains code governed by multiple licenses based on the date and origin of contribution:
1. All code committed prior to commit a76068d69cd59568b920dfab85dc573dbbb8f131 is licensed under the MIT License (see LICENSE_HISTORY).
2. All code committed from commit a76068d69cd59568b920dfab85dc573dbbb8f131 up to and including commit 60d84a3aae9802339705826e9095e272e3c83623 is licensed under the BSD 3-Clause License (see LICENSE_HISTORY).
3. All code contributed or modified after commit 60d84a3aae9802339705826e9095e272e3c83623 is licensed under the Open WebUI License (see LICENSE).
For details on which commits are covered by which license, refer to LICENSE_HISTORY.

View file

@ -248,7 +248,7 @@ Discover upcoming features on our roadmap in the [Open WebUI Documentation](http
## License 📜
This project is licensed under the [Open WebUI License](LICENSE), a revised BSD-3-Clause license. You receive all the same rights as the classic BSD-3 license: you can use, modify, and distribute the software, including in proprietary and commercial products, with minimal restrictions. The only additional requirement is to preserve the "Open WebUI" branding, as detailed in the LICENSE file. For full terms, see the [LICENSE](LICENSE) document. 📄
This project contains code under multiple licenses. The current codebase includes components licensed under the Open WebUI License with an additional requirement to preserve the "Open WebUI" branding, as well as prior contributions under their respective original licenses. For a detailed record of license changes and the applicable terms for each section of the code, please refer to [LICENSE_HISTORY](./LICENSE_HISTORY). For complete and updated licensing details, please see the [LICENSE](./LICENSE) and [LICENSE_HISTORY](./LICENSE_HISTORY) files.
## Support 💬

View file

@ -222,10 +222,11 @@ class PersistentConfig(Generic[T]):
class AppConfig:
_state: dict[str, PersistentConfig]
_redis: Union[redis.Redis, redis.cluster.RedisCluster] = None
_redis_key_prefix: str
_state: dict[str, PersistentConfig]
def __init__(
self,
redis_url: Optional[str] = None,
@ -233,9 +234,8 @@ class AppConfig:
redis_cluster: Optional[bool] = False,
redis_key_prefix: str = "open-webui",
):
super().__setattr__("_state", {})
super().__setattr__("_redis_key_prefix", redis_key_prefix)
if redis_url:
super().__setattr__("_redis_key_prefix", redis_key_prefix)
super().__setattr__(
"_redis",
get_redis_connection(
@ -246,6 +246,8 @@ class AppConfig:
),
)
super().__setattr__("_state", {})
def __setattr__(self, key, value):
if isinstance(value, PersistentConfig):
self._state[key] = value
@ -2168,6 +2170,8 @@ ENABLE_ONEDRIVE_INTEGRATION = PersistentConfig(
"onedrive.enable",
os.getenv("ENABLE_ONEDRIVE_INTEGRATION", "False").lower() == "true",
)
ENABLE_ONEDRIVE_PERSONAL = (
os.environ.get("ENABLE_ONEDRIVE_PERSONAL", "True").lower() == "true"
)
@ -2175,10 +2179,12 @@ ENABLE_ONEDRIVE_BUSINESS = (
os.environ.get("ENABLE_ONEDRIVE_BUSINESS", "True").lower() == "true"
)
ONEDRIVE_CLIENT_ID = PersistentConfig(
"ONEDRIVE_CLIENT_ID",
"onedrive.client_id",
os.environ.get("ONEDRIVE_CLIENT_ID", ""),
ONEDRIVE_CLIENT_ID = os.environ.get("ONEDRIVE_CLIENT_ID", "")
ONEDRIVE_CLIENT_ID_PERSONAL = os.environ.get(
"ONEDRIVE_CLIENT_ID_PERSONAL", ONEDRIVE_CLIENT_ID
)
ONEDRIVE_CLIENT_ID_BUSINESS = os.environ.get(
"ONEDRIVE_CLIENT_ID_BUSINESS", ONEDRIVE_CLIENT_ID
)
ONEDRIVE_SHAREPOINT_URL = PersistentConfig(

View file

@ -547,16 +547,16 @@ else:
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = os.environ.get(
"CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES", "10"
"CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES", "30"
)
if CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES == "":
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 10
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 30
else:
try:
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = int(CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES)
except Exception:
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 10
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 30
####################################

View file

@ -301,7 +301,8 @@ from open_webui.config import (
GOOGLE_DRIVE_CLIENT_ID,
GOOGLE_DRIVE_API_KEY,
ENABLE_ONEDRIVE_INTEGRATION,
ONEDRIVE_CLIENT_ID,
ONEDRIVE_CLIENT_ID_PERSONAL,
ONEDRIVE_CLIENT_ID_BUSINESS,
ONEDRIVE_SHAREPOINT_URL,
ONEDRIVE_SHAREPOINT_TENANT_ID,
ENABLE_ONEDRIVE_PERSONAL,
@ -1530,6 +1531,14 @@ async def chat_completion(
except:
pass
finally:
try:
if mcp_clients := metadata.get("mcp_clients"):
for client in mcp_clients:
await client.disconnect()
except Exception as e:
log.debug(f"Error cleaning up: {e}")
pass
if (
metadata.get("session_id")
@ -1743,7 +1752,8 @@ async def get_app_config(request: Request):
"api_key": GOOGLE_DRIVE_API_KEY.value,
},
"onedrive": {
"client_id": ONEDRIVE_CLIENT_ID.value,
"client_id_personal": ONEDRIVE_CLIENT_ID_PERSONAL,
"client_id_business": ONEDRIVE_CLIENT_ID_BUSINESS,
"sharepoint_url": ONEDRIVE_SHAREPOINT_URL.value,
"sharepoint_tenant_id": ONEDRIVE_SHAREPOINT_TENANT_ID.value,
},

View file

@ -127,7 +127,13 @@ def query_doc_with_hybrid_search(
hybrid_bm25_weight: float,
) -> dict:
try:
if not collection_result.documents[0]:
if (
not collection_result
or not hasattr(collection_result, "documents")
or not collection_result.documents
or len(collection_result.documents) == 0
or not collection_result.documents[0]
):
log.warning(f"query_doc_with_hybrid_search:no_docs {collection_name}")
return {"documents": [], "metadatas": [], "distances": []}

View file

@ -1,3 +1,4 @@
import logging
from fastapi import APIRouter, Depends, Request, HTTPException
from pydantic import BaseModel, ConfigDict
@ -12,10 +13,16 @@ from open_webui.utils.tools import (
get_tool_server_url,
set_tool_servers,
)
from open_webui.utils.mcp.client import MCPClient
from open_webui.env import SRC_LOG_LEVELS
router = APIRouter()
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
############################
# ImportConfig
@ -87,6 +94,7 @@ async def set_connections_config(
class ToolServerConnection(BaseModel):
url: str
path: str
type: Optional[str] = "openapi" # openapi, mcp
auth_type: Optional[str]
key: Optional[str]
config: Optional[dict]
@ -129,19 +137,72 @@ async def verify_tool_servers_config(
Verify the connection to the tool server.
"""
try:
if form_data.type == "mcp":
try:
client = MCPClient()
auth = None
headers = None
token = None
if form_data.auth_type == "bearer":
token = form_data.key
elif form_data.auth_type == "session":
token = request.state.token.credentials
token = None
if form_data.auth_type == "bearer":
token = form_data.key
elif form_data.auth_type == "session":
token = request.state.token.credentials
elif form_data.auth_type == "system_oauth":
try:
if request.cookies.get("oauth_session_id", None):
token = (
await request.app.state.oauth_manager.get_oauth_token(
user.id,
request.cookies.get("oauth_session_id", None),
)
)
except Exception as e:
pass
url = get_tool_server_url(form_data.url, form_data.path)
return await get_tool_server_data(token, url)
if token:
headers = {"Authorization": f"Bearer {token}"}
await client.connect(form_data.url, auth=auth, headers=headers)
specs = await client.list_tool_specs()
return {
"status": True,
"specs": specs,
}
except Exception as e:
log.debug(f"Failed to create MCP client: {e}")
raise HTTPException(
status_code=400,
detail=f"Failed to create MCP client",
)
finally:
if client:
await client.disconnect()
else: # openapi
token = None
if form_data.auth_type == "bearer":
token = form_data.key
elif form_data.auth_type == "session":
token = request.state.token.credentials
elif form_data.auth_type == "system_oauth":
try:
if request.cookies.get("oauth_session_id", None):
token = await request.app.state.oauth_manager.get_oauth_token(
user.id,
request.cookies.get("oauth_session_id", None),
)
except Exception as e:
pass
url = get_tool_server_url(form_data.url, form_data.path)
return await get_tool_server_data(token, url)
except HTTPException as e:
raise e
except Exception as e:
log.debug(f"Failed to connect to the tool server: {e}")
raise HTTPException(
status_code=400,
detail=f"Failed to connect to the tool server: {str(e)}",
detail=f"Failed to connect to the tool server",
)

View file

@ -43,6 +43,7 @@ router = APIRouter()
async def get_tools(request: Request, user=Depends(get_verified_user)):
tools = Tools.get_tools()
# OpenAPI Tool Servers
for server in await get_tool_servers(request):
tools.append(
ToolUserResponse(
@ -68,6 +69,29 @@ async def get_tools(request: Request, user=Depends(get_verified_user)):
)
)
# MCP Tool Servers
for server in request.app.state.config.TOOL_SERVER_CONNECTIONS:
if server.get("type", "openapi") == "mcp":
tools.append(
ToolUserResponse(
**{
"id": f"server:mcp:{server.get('info', {}).get('id')}",
"user_id": f"server:mcp:{server.get('info', {}).get('id')}",
"name": server.get("info", {}).get("name", "MCP Tool Server"),
"meta": {
"description": server.get("info", {}).get(
"description", ""
),
},
"access_control": server.get("config", {}).get(
"access_control", None
),
"updated_at": int(time.time()),
"created_at": int(time.time()),
}
)
)
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
# Admin can see all tools
return tools

View file

@ -0,0 +1,97 @@
from open_webui.routers.images import (
load_b64_image_data,
upload_image,
)
from fastapi import (
APIRouter,
Depends,
HTTPException,
Request,
UploadFile,
)
from open_webui.routers.files import upload_file_handler
import mimetypes
import base64
import io
def get_image_url_from_base64(request, base64_image_string, metadata, user):
if "data:image/png;base64" in base64_image_string:
image_url = ""
# Extract base64 image data from the line
image_data, content_type = load_b64_image_data(base64_image_string)
if image_data is not None:
image_url = upload_image(
request,
image_data,
content_type,
metadata,
user,
)
return image_url
return None
def load_b64_audio_data(b64_str):
try:
if "," in b64_str:
header, b64_data = b64_str.split(",", 1)
else:
b64_data = b64_str
header = "data:audio/wav;base64"
audio_data = base64.b64decode(b64_data)
content_type = (
header.split(";")[0].split(":")[1] if ";" in header else "audio/wav"
)
return audio_data, content_type
except Exception as e:
print(f"Error decoding base64 audio data: {e}")
return None, None
def upload_audio(request, audio_data, content_type, metadata, user):
audio_format = mimetypes.guess_extension(content_type)
file = UploadFile(
file=io.BytesIO(audio_data),
filename=f"generated-{audio_format}", # will be converted to a unique ID on upload_file
headers={
"content-type": content_type,
},
)
file_item = upload_file_handler(
request,
file=file,
metadata=metadata,
process=False,
user=user,
)
url = request.app.url_path_for("get_file_content_by_id", id=file_item.id)
return url
def get_audio_url_from_base64(request, base64_audio_string, metadata, user):
if "data:audio/wav;base64" in base64_audio_string:
audio_url = ""
# Extract base64 audio data from the line
audio_data, content_type = load_b64_audio_data(base64_audio_string)
if audio_data is not None:
audio_url = upload_audio(
request,
audio_data,
content_type,
metadata,
user,
)
return audio_url
return None
def get_file_url_from_base64(request, base64_file_string, metadata, user):
if "data:image/png;base64" in base64_file_string:
return get_image_url_from_base64(request, base64_file_string, metadata, user)
elif "data:audio/wav;base64" in base64_file_string:
return get_audio_url_from_base64(request, base64_file_string, metadata, user)
return None

View file

@ -0,0 +1,114 @@
import asyncio
from typing import Optional
from contextlib import AsyncExitStack
from mcp import ClientSession
from mcp.client.auth import OAuthClientProvider, TokenStorage
from mcp.client.streamable_http import streamablehttp_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
class MCPClient:
def __init__(self):
self.session: Optional[ClientSession] = None
self.exit_stack = AsyncExitStack()
async def connect(
self, url: str, headers: Optional[dict] = None, auth: Optional[any] = None
):
try:
self._streams_context = streamablehttp_client(
url, headers=headers, auth=auth
)
transport = await self.exit_stack.enter_async_context(self._streams_context)
read_stream, write_stream, _ = transport
self._session_context = ClientSession(
read_stream, write_stream
) # pylint: disable=W0201
self.session = await self.exit_stack.enter_async_context(
self._session_context
)
await self.session.initialize()
except Exception as e:
await self.disconnect()
raise e
async def list_tool_specs(self) -> Optional[dict]:
if not self.session:
raise RuntimeError("MCP client is not connected.")
result = await self.session.list_tools()
tools = result.tools
tool_specs = []
for tool in tools:
name = tool.name
description = tool.description
inputSchema = tool.inputSchema
# TODO: handle outputSchema if needed
outputSchema = getattr(tool, "outputSchema", None)
tool_specs.append(
{"name": name, "description": description, "parameters": inputSchema}
)
return tool_specs
async def call_tool(
self, function_name: str, function_args: dict
) -> Optional[dict]:
if not self.session:
raise RuntimeError("MCP client is not connected.")
result = await self.session.call_tool(function_name, function_args)
if not result:
raise Exception("No result returned from MCP tool call.")
result_dict = result.model_dump(mode="json")
result_content = result_dict.get("content", {})
if result.isError:
raise Exception(result_content)
else:
return result_content
async def list_resources(self, cursor: Optional[str] = None) -> Optional[dict]:
if not self.session:
raise RuntimeError("MCP client is not connected.")
result = await self.session.list_resources(cursor=cursor)
if not result:
raise Exception("No result returned from MCP list_resources call.")
result_dict = result.model_dump()
resources = result_dict.get("resources", [])
return resources
async def read_resource(self, uri: str) -> Optional[dict]:
if not self.session:
raise RuntimeError("MCP client is not connected.")
result = await self.session.read_resource(uri)
if not result:
raise Exception("No result returned from MCP read_resource call.")
result_dict = result.model_dump()
return result_dict
async def disconnect(self):
# Clean up and close the session
await self.exit_stack.aclose()
async def __aenter__(self):
await self.exit_stack.__aenter__()
return self
async def __aexit__(self, exc_type, exc_value, traceback):
await self.exit_stack.__aexit__(exc_type, exc_value, traceback)
await self.disconnect()

View file

@ -53,6 +53,11 @@ from open_webui.routers.pipelines import (
from open_webui.routers.memories import query_memory, QueryMemoryForm
from open_webui.utils.webhook import post_webhook
from open_webui.utils.files import (
get_audio_url_from_base64,
get_file_url_from_base64,
get_image_url_from_base64,
)
from open_webui.models.users import UserModel
@ -87,6 +92,7 @@ from open_webui.utils.filter import (
)
from open_webui.utils.code_interpreter import execute_code_jupyter
from open_webui.utils.payload import apply_system_prompt_to_body
from open_webui.utils.mcp.client import MCPClient
from open_webui.config import (
@ -145,12 +151,14 @@ async def chat_completion_tools_handler(
def get_tools_function_calling_payload(messages, task_model_id, content):
user_message = get_last_user_message(messages)
history = "\n".join(
recent_messages = messages[-4:] if len(messages) > 4 else messages
chat_history = "\n".join(
f"{message['role'].upper()}: \"\"\"{message['content']}\"\"\""
for message in messages[::-1][:4]
for message in recent_messages
)
prompt = f"History:\n{history}\nQuery: {user_message}"
prompt = f"History:\n{chat_history}\nQuery: {user_message}"
return {
"model": task_model_id,
@ -988,14 +996,91 @@ async def process_chat_payload(request, form_data, user, metadata, model):
# Server side tools
tool_ids = metadata.get("tool_ids", None)
# Client side tools
tool_servers = metadata.get("tool_servers", None)
direct_tool_servers = metadata.get("tool_servers", None)
log.debug(f"{tool_ids=}")
log.debug(f"{tool_servers=}")
log.debug(f"{direct_tool_servers=}")
tools_dict = {}
mcp_clients = []
mcp_tools_dict = {}
if tool_ids:
for tool_id in tool_ids:
if tool_id.startswith("server:mcp:"):
try:
server_id = tool_id[len("server:mcp:") :]
mcp_server_connection = None
for (
server_connection
) in request.app.state.config.TOOL_SERVER_CONNECTIONS:
if (
server_connection.get("type", "") == "mcp"
and server_connection.get("info", {}).get("id") == server_id
):
mcp_server_connection = server_connection
break
if not mcp_server_connection:
log.error(f"MCP server with id {server_id} not found")
continue
auth_type = mcp_server_connection.get("auth_type", "")
headers = {}
if auth_type == "bearer":
headers["Authorization"] = (
f"Bearer {mcp_server_connection.get('key', '')}"
)
elif auth_type == "none":
# No authentication
pass
elif auth_type == "session":
headers["Authorization"] = (
f"Bearer {request.state.token.credentials}"
)
elif auth_type == "system_oauth":
oauth_token = extra_params.get("__oauth_token__", None)
if oauth_token:
headers["Authorization"] = (
f"Bearer {oauth_token.get('access_token', '')}"
)
mcp_client = MCPClient()
await mcp_client.connect(
url=mcp_server_connection.get("url", ""),
headers=headers if headers else None,
)
tool_specs = await mcp_client.list_tool_specs()
for tool_spec in tool_specs:
def make_tool_function(function_name):
async def tool_function(**kwargs):
return await mcp_client.call_tool(
function_name,
function_args=kwargs,
)
return tool_function
tool_function = make_tool_function(tool_spec["name"])
mcp_tools_dict[tool_spec["name"]] = {
"spec": tool_spec,
"callable": tool_function,
"type": "mcp",
"client": mcp_client,
"direct": False,
}
mcp_clients.append(mcp_client)
except Exception as e:
log.debug(e)
continue
tools_dict = await get_tools(
request,
tool_ids,
@ -1007,9 +1092,11 @@ async def process_chat_payload(request, form_data, user, metadata, model):
"__files__": metadata.get("files", []),
},
)
if mcp_tools_dict:
tools_dict = {**tools_dict, **mcp_tools_dict}
if tool_servers:
for tool_server in tool_servers:
if direct_tool_servers:
for tool_server in direct_tool_servers:
tool_specs = tool_server.pop("specs", [])
for tool in tool_specs:
@ -1019,6 +1106,9 @@ async def process_chat_payload(request, form_data, user, metadata, model):
"server": tool_server,
}
if mcp_clients:
metadata["mcp_clients"] = mcp_clients
if tools_dict:
if metadata.get("params", {}).get("function_calling") == "native":
# If the function calling is native, then call the tools function calling handler
@ -1027,6 +1117,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
{"type": "function", "function": tool.get("spec", {})}
for tool in tools_dict.values()
]
else:
# If the function calling is not native, then call the tools function calling handler
try:
@ -2330,6 +2421,8 @@ async def process_chat_response(
results = []
for tool_call in response_tool_calls:
print("tool_call", tool_call)
tool_call_id = tool_call.get("id", "")
tool_name = tool_call.get("function", {}).get("name", "")
tool_args = tool_call.get("function", {}).get("arguments", "{}")
@ -2405,7 +2498,6 @@ async def process_chat_response(
tool_result = str(e)
tool_result_embeds = []
if isinstance(tool_result, HTMLResponse):
content_disposition = tool_result.headers.get(
"Content-Disposition", ""
@ -2478,9 +2570,60 @@ async def process_chat_response(
for item in tool_result:
# check if string
if isinstance(item, str) and item.startswith("data:"):
tool_result_files.append(item)
tool_result_files.append(
{
"type": "data",
"content": item,
}
)
tool_result.remove(item)
if tool.get("type") == "mcp":
if isinstance(item, dict):
if (
item.get("type") == "image"
or item.get("type") == "audio"
):
file_url = get_file_url_from_base64(
request,
f"data:{item.get('mimeType')};base64,{item.get('data', item.get('blob', ''))}",
{
"chat_id": metadata.get(
"chat_id", None
),
"message_id": metadata.get(
"message_id", None
),
"session_id": metadata.get(
"session_id", None
),
"result": item,
},
user,
)
tool_result_files.append(
{
"type": item.get("type", "data"),
"url": file_url,
}
)
tool_result.remove(item)
if tool_result_files:
if not isinstance(tool_result, list):
tool_result = [
tool_result,
]
for file in tool_result_files:
tool_result.append(
{
"type": file.get("type", "data"),
"content": "Result is being displayed as a file.",
}
)
if isinstance(tool_result, dict) or isinstance(
tool_result, list
):
@ -2647,23 +2790,18 @@ async def process_chat_response(
if isinstance(stdout, str):
stdoutLines = stdout.split("\n")
for idx, line in enumerate(stdoutLines):
if "data:image/png;base64" in line:
image_url = ""
# Extract base64 image data from the line
image_data, content_type = (
load_b64_image_data(line)
image_url = get_image_url_from_base64(
request,
line,
metadata,
user,
)
if image_data is not None:
image_url = upload_image(
request,
image_data,
content_type,
metadata,
user,
if image_url:
stdoutLines[idx] = (
f"![Output Image]({image_url})"
)
stdoutLines[idx] = (
f"![Output Image]({image_url})"
)
output["stdout"] = "\n".join(stdoutLines)
@ -2673,19 +2811,12 @@ async def process_chat_response(
resultLines = result.split("\n")
for idx, line in enumerate(resultLines):
if "data:image/png;base64" in line:
image_url = ""
# Extract base64 image data from the line
image_data, content_type = (
load_b64_image_data(line)
image_url = get_image_url_from_base64(
request,
line,
metadata,
user,
)
if image_data is not None:
image_url = upload_image(
request,
image_data,
content_type,
metadata,
user,
)
resultLines[idx] = (
f"![Output Image]({image_url})"
)

View file

@ -96,94 +96,118 @@ async def get_tools(
for tool_id in tool_ids:
tool = Tools.get_tool_by_id(tool_id)
if tool is None:
if tool_id.startswith("server:"):
server_id = tool_id.split(":")[1]
splits = tool_id.split(":")
tool_server_data = None
for server in await get_tool_servers(request):
if server["id"] == server_id:
tool_server_data = server
break
if len(splits) == 2:
type = "openapi"
server_id = splits[1]
elif len(splits) == 3:
type = splits[1]
server_id = splits[2]
if tool_server_data is None:
log.warning(f"Tool server data not found for {server_id}")
server_id_splits = server_id.split("|")
if len(server_id_splits) == 2:
server_id = server_id_splits[0]
function_names = server_id_splits[1].split(",")
if type == "openapi":
tool_server_data = None
for server in await get_tool_servers(request):
if server["id"] == server_id:
tool_server_data = server
break
if tool_server_data is None:
log.warning(f"Tool server data not found for {server_id}")
continue
tool_server_idx = tool_server_data.get("idx", 0)
tool_server_connection = (
request.app.state.config.TOOL_SERVER_CONNECTIONS[
tool_server_idx
]
)
specs = tool_server_data.get("specs", [])
for spec in specs:
function_name = spec["name"]
auth_type = tool_server_connection.get("auth_type", "bearer")
cookies = {}
headers = {}
if auth_type == "bearer":
headers["Authorization"] = (
f"Bearer {tool_server_connection.get('key', '')}"
)
elif auth_type == "none":
# No authentication
pass
elif auth_type == "session":
cookies = request.cookies
headers["Authorization"] = (
f"Bearer {request.state.token.credentials}"
)
elif auth_type == "system_oauth":
cookies = request.cookies
oauth_token = extra_params.get("__oauth_token__", None)
if oauth_token:
headers["Authorization"] = (
f"Bearer {oauth_token.get('access_token', '')}"
)
headers["Content-Type"] = "application/json"
def make_tool_function(
function_name, tool_server_data, headers
):
async def tool_function(**kwargs):
return await execute_tool_server(
url=tool_server_data["url"],
headers=headers,
cookies=cookies,
name=function_name,
params=kwargs,
server_data=tool_server_data,
)
return tool_function
tool_function = make_tool_function(
function_name, tool_server_data, headers
)
callable = get_async_tool_function_and_apply_extra_params(
tool_function,
{},
)
tool_dict = {
"tool_id": tool_id,
"callable": callable,
"spec": spec,
# Misc info
"type": "external",
}
# Handle function name collisions
while function_name in tools_dict:
log.warning(
f"Tool {function_name} already exists in another tools!"
)
# Prepend server ID to function name
function_name = f"{server_id}_{function_name}"
tools_dict[function_name] = tool_dict
else:
log.warning(f"Unsupported tool server type: {type}")
continue
tool_server_idx = tool_server_data.get("idx", 0)
tool_server_connection = (
request.app.state.config.TOOL_SERVER_CONNECTIONS[tool_server_idx]
)
specs = tool_server_data.get("specs", [])
for spec in specs:
function_name = spec["name"]
auth_type = tool_server_connection.get("auth_type", "bearer")
cookies = {}
headers = {}
if auth_type == "bearer":
headers["Authorization"] = (
f"Bearer {tool_server_connection.get('key', '')}"
)
elif auth_type == "none":
# No authentication
pass
elif auth_type == "session":
cookies = request.cookies
headers["Authorization"] = (
f"Bearer {request.state.token.credentials}"
)
elif auth_type == "system_oauth":
cookies = request.cookies
oauth_token = extra_params.get("__oauth_token__", None)
if oauth_token:
headers["Authorization"] = (
f"Bearer {oauth_token.get('access_token', '')}"
)
headers["Content-Type"] = "application/json"
def make_tool_function(function_name, tool_server_data, headers):
async def tool_function(**kwargs):
return await execute_tool_server(
url=tool_server_data["url"],
headers=headers,
cookies=cookies,
name=function_name,
params=kwargs,
server_data=tool_server_data,
)
return tool_function
tool_function = make_tool_function(
function_name, tool_server_data, headers
)
callable = get_async_tool_function_and_apply_extra_params(
tool_function,
{},
)
tool_dict = {
"tool_id": tool_id,
"callable": callable,
"spec": spec,
# Misc info
"type": "external",
}
# Handle function name collisions
while function_name in tools_dict:
log.warning(
f"Tool {function_name} already exists in another tools!"
)
# Prepend server ID to function name
function_name = f"{server_id}_{function_name}"
tools_dict[function_name] = tool_dict
else:
continue
else:
@ -579,7 +603,10 @@ async def get_tool_servers_data(servers: List[Dict[str, Any]]) -> List[Dict[str,
# Prepare list of enabled servers along with their original index
server_entries = []
for idx, server in enumerate(servers):
if server.get("config", {}).get("enable"):
if (
server.get("config", {}).get("enable")
and server.get("type", "openapi") == "openapi"
):
# Path (to OpenAPI spec URL) can be either a full URL or a path to append to the base URL
openapi_path = server.get("path", "openapi.json")
full_url = get_tool_server_url(server.get("url"), openapi_path)

View file

@ -46,6 +46,7 @@ anthropic
google-genai==1.32.0
google-generativeai==0.8.5
tiktoken
mcp==1.14.1
langchain==0.3.26
langchain-community==0.3.27
@ -121,7 +122,7 @@ pytest-docker~=3.1.1
googleapis-common-protos==1.70.0
google-cloud-storage==2.19.0
azure-identity==1.23.0
azure-identity==1.25.0
azure-storage-blob==12.24.1

36
package-lock.json generated
View file

@ -23,7 +23,7 @@
"@tiptap/core": "^3.0.7",
"@tiptap/extension-bubble-menu": "^2.26.1",
"@tiptap/extension-code-block-lowlight": "^3.0.7",
"@tiptap/extension-drag-handle": "^3.0.7",
"@tiptap/extension-drag-handle": "^3.4.5",
"@tiptap/extension-file-handler": "^3.0.7",
"@tiptap/extension-floating-menu": "^2.26.1",
"@tiptap/extension-highlight": "^3.3.0",
@ -3384,9 +3384,9 @@
}
},
"node_modules/@tiptap/extension-collaboration": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.0.7.tgz",
"integrity": "sha512-so59vQCAS1vy6k86byk96fYvAPM5w8u8/Yp3jKF1LPi9LH4wzS4hGnOP/dEbedxPU48an9WB1lSOczSKPECJaQ==",
"version": "3.4.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.4.5.tgz",
"integrity": "sha512-JyPXTYkYi2XzUWsmObv2cogMrs7huAvfq6l7d5hAwsU2FnA1vMycaa48N4uekogySP6VBkiQNDf9B4T09AwwqA==",
"license": "MIT",
"peer": true,
"funding": {
@ -3394,8 +3394,8 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.0.7",
"@tiptap/pm": "^3.0.7",
"@tiptap/core": "^3.4.5",
"@tiptap/pm": "^3.4.5",
"@tiptap/y-tiptap": "^3.0.0-beta.3",
"yjs": "^13"
}
@ -3414,9 +3414,9 @@
}
},
"node_modules/@tiptap/extension-drag-handle": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-drag-handle/-/extension-drag-handle-3.0.7.tgz",
"integrity": "sha512-rm8+0kPz5C5JTp4f1QY61Qd5d7zlJAxLeJtOvgC9RCnrNG1F7LCsmOkvy5fsU6Qk2YCCYOiSSMC4S4HKPrUJhw==",
"version": "3.4.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-drag-handle/-/extension-drag-handle-3.4.5.tgz",
"integrity": "sha512-177hQ9lMQYJz+SuCg8eA47MB2tn3G3MGBJ5+3PNl5Bs4WQukR9uHpxdR+bH00/LedwxrlNlglMa5Hirrx9odMQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.6.13"
@ -3426,10 +3426,10 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.0.7",
"@tiptap/extension-collaboration": "^3.0.7",
"@tiptap/extension-node-range": "^3.0.7",
"@tiptap/pm": "^3.0.7",
"@tiptap/core": "^3.4.5",
"@tiptap/extension-collaboration": "^3.4.5",
"@tiptap/extension-node-range": "^3.4.5",
"@tiptap/pm": "^3.4.5",
"@tiptap/y-tiptap": "^3.0.0-beta.3"
}
},
@ -3643,9 +3643,9 @@
}
},
"node_modules/@tiptap/extension-node-range": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.0.7.tgz",
"integrity": "sha512-cHViNqtOUD9CLJxEj28rcj8tb8RYQZ7kwmtSvIye84Y3MJIzigRm4IUBNNOYnZfq5YAZIR97WKcJeFz3EU1VPg==",
"version": "3.4.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.4.5.tgz",
"integrity": "sha512-mHCjdJZX8DZCpnw9wBqioanANy6tRoy20/OcJxMW1T7naeRCuCU4sFjwO37yb/tmYk1BQA2/L1/H2r0fVoZwtA==",
"license": "MIT",
"peer": true,
"funding": {
@ -3653,8 +3653,8 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.0.7",
"@tiptap/pm": "^3.0.7"
"@tiptap/core": "^3.4.5",
"@tiptap/pm": "^3.4.5"
}
},
"node_modules/@tiptap/extension-ordered-list": {

View file

@ -67,7 +67,7 @@
"@tiptap/core": "^3.0.7",
"@tiptap/extension-bubble-menu": "^2.26.1",
"@tiptap/extension-code-block-lowlight": "^3.0.7",
"@tiptap/extension-drag-handle": "^3.0.7",
"@tiptap/extension-drag-handle": "^3.4.5",
"@tiptap/extension-file-handler": "^3.0.7",
"@tiptap/extension-floating-menu": "^2.26.1",
"@tiptap/extension-highlight": "^3.3.0",

View file

@ -47,6 +47,8 @@ dependencies = [
"asgiref==3.8.1",
"tiktoken",
"mcp==1.14.1",
"openai",
"anthropic",
"google-genai==1.32.0",
@ -109,7 +111,7 @@ dependencies = [
"googleapis-common-protos==1.70.0",
"google-cloud-storage==2.19.0",
"azure-identity==1.20.0",
"azure-identity==1.25.0",
"azure-storage-blob==12.24.1",
"ldap3==2.9.1",

View file

@ -661,3 +661,112 @@ body {
background: #171717;
color: #eee;
}
/* Position the handle relative to each LI */
.pm-li--with-handle {
position: relative;
margin-left: 12px; /* make space for the handle */
}
.tiptap ul[data-type='taskList'] .pm-list-drag-handle {
margin-left: 0px;
}
/* The drag handle itself */
.pm-list-drag-handle {
position: absolute;
left: -36px; /* pull into the left gutter */
top: 1px;
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
line-height: 1;
border-radius: 4px;
cursor: grab;
user-select: none;
opacity: 0.35;
transition:
opacity 120ms ease,
background 120ms ease;
}
.tiptap ul[data-type='taskList'] .pm-list-drag-handle {
left: -16px; /* pull into the left gutter more to avoid the checkbox */
}
.pm-list-drag-handle:active {
cursor: grabbing;
}
.pm-li--with-handle:hover > .pm-list-drag-handle {
opacity: 1;
}
.pm-list-drag-handle:hover {
background: rgba(0, 0, 0, 0.06);
}
:root {
--pm-accent: color-mix(in oklab, Highlight 70%, transparent);
--pm-fill-target: color-mix(in oklab, Highlight 26%, transparent);
--pm-fill-ancestor: color-mix(in oklab, Highlight 16%, transparent);
}
.pm-li-drop-before,
.pm-li-drop-after,
.pm-li-drop-into,
.pm-li-drop-outdent {
position: relative;
}
/* BEFORE/AFTER lines */
.pm-li-drop-before::before,
.pm-li-drop-after::after {
content: '';
position: absolute;
left: 0;
right: 0;
height: 3px;
background: var(--pm-accent);
pointer-events: none;
}
.pm-li-drop-before::before {
top: -2px;
}
.pm-li-drop-after::after {
bottom: -2px;
}
.pm-li-drop-before,
.pm-li-drop-after,
.pm-li-drop-into,
.pm-li-drop-outdent {
background: var(--pm-fill-target);
border-radius: 6px;
}
.pm-li-drop-outdent::before {
content: '';
position: absolute;
inset-block: 0;
inset-inline-start: 0;
width: 3px;
background: color-mix(in oklab, Highlight 35%, transparent);
}
.pm-li--with-handle:has(.pm-li-drop-before),
.pm-li--with-handle:has(.pm-li-drop-after),
.pm-li--with-handle:has(.pm-li-drop-into),
.pm-li--with-handle:has(.pm-li-drop-outdent) {
background: var(--pm-fill-ancestor);
border-radius: 6px;
}
.pm-li-drop-before,
.pm-li-drop-after,
.pm-li-drop-into,
.pm-li-drop-outdent {
position: relative;
z-index: 0;
}

View file

@ -30,6 +30,8 @@
let url = '';
let path = 'openapi.json';
let type = 'openapi'; // 'openapi', 'mcp'
let auth_type = 'bearer';
let key = '';
@ -70,6 +72,7 @@
const res = await verifyToolServerConnection(localStorage.token, {
url,
path,
type,
auth_type,
key,
config: {
@ -97,10 +100,16 @@
// remove trailing slash from url
url = url.replace(/\/$/, '');
if (id.includes(':') || id.includes('|')) {
toast.error($i18n.t('ID cannot contain ":" or "|" characters'));
loading = false;
return;
}
const connection = {
url,
path,
type,
auth_type,
key,
config: {
@ -119,8 +128,11 @@
loading = false;
show = false;
// reset form
url = '';
path = 'openapi.json';
type = 'openapi';
key = '';
auth_type = 'bearer';
@ -137,6 +149,7 @@
url = connection.url;
path = connection?.path ?? 'openapi.json';
type = connection?.type ?? 'openapi';
auth_type = connection?.auth_type ?? 'bearer';
key = connection?.key ?? '';
@ -189,6 +202,50 @@
}}
>
<div class="px-1">
{#if !direct}
<div class="flex gap-2 mb-1.5">
<div class="flex w-full justify-between items-center">
<div class=" text-xs text-gray-500">{$i18n.t('Type')}</div>
<div class="">
<button
on:click={() => {
type = ['', 'openapi'].includes(type) ? 'mcp' : 'openapi';
}}
type="button"
class=" text-xs text-gray-700 dark:text-gray-300"
>
{#if ['', 'openapi'].includes(type)}
{$i18n.t('OpenAPI')}
{:else if type === 'mcp'}
{$i18n.t('MCP')}
<span class="text-gray-500">{$i18n.t('Streamable HTTP')}</span>
{/if}
</button>
</div>
</div>
</div>
{/if}
{#if type === 'mcp'}
<div
class=" bg-yellow-500/20 text-yellow-700 dark:text-yellow-200 rounded-2xl text-xs px-4 py-3 mb-2"
>
<span class="font-medium">
{$i18n.t('Warning')}:
</span>
{$i18n.t(
'MCP support is experimental and its specification changes often, which can lead to incompatibilities. OpenAPI specification support is directly maintained by the Open WebUI team, making it the more reliable option for compatibility.'
)}
<a
class="font-medium underline"
href="https://docs.openwebui.com/features/mcp"
target="_blank">{$i18n.t('Read more →')}</a
>
</div>
{/if}
<div class="flex gap-2">
<div class="flex flex-col w-full">
<div class="flex justify-between mb-0.5">
@ -243,30 +300,36 @@
</Tooltip>
</div>
<div class="flex-1 flex items-center">
<label for="url-or-path" class="sr-only"
>{$i18n.t('openapi.json URL or Path')}</label
>
<input
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
id="url-or-path"
bind:value={path}
placeholder={$i18n.t('openapi.json URL or Path')}
autocomplete="off"
required
/>
</div>
{#if ['', 'openapi'].includes(type)}
<div class="flex-1 flex items-center">
<label for="url-or-path" class="sr-only"
>{$i18n.t('openapi.json URL or Path')}</label
>
<input
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
id="url-or-path"
bind:value={path}
placeholder={$i18n.t('openapi.json URL or Path')}
autocomplete="off"
required
/>
</div>
{/if}
</div>
</div>
<div
class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t(`WebUI will make requests to "{{url}}"`, {
url: path.includes('://') ? path : `${url}${path.startsWith('/') ? '' : '/'}${path}`
})}
</div>
{#if ['', 'openapi'].includes(type)}
<div
class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t(`WebUI will make requests to "{{url}}"`, {
url: path.includes('://')
? path
: `${url}${path.startsWith('/') ? '' : '/'}${path}`
})}
</div>
{/if}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
@ -334,9 +397,12 @@
for="enter-id"
class={`mb-0.5 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('ID')}
<span class="text-xs text-gray-200 dark:text-gray-800 ml-0.5"
>{$i18n.t('Optional')}</span
>
{#if type !== 'mcp'}
<span class="text-xs text-gray-200 dark:text-gray-800 ml-0.5"
>{$i18n.t('Optional')}</span
>
{/if}
</label>
<div class="flex-1">
@ -347,6 +413,7 @@
bind:value={id}
placeholder={$i18n.t('Enter ID')}
autocomplete="off"
required={type === 'mcp'}
/>
</div>
</div>
@ -396,7 +463,7 @@
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<div class="my-2 -mx-2">
<div class="px-3 py-2 bg-gray-50 dark:bg-gray-950 rounded-lg">
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-950 rounded-3xl">
<AccessControl bind:accessControl />
</div>
</div>

View file

@ -293,7 +293,7 @@
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<div class="my-2 -mx-2">
<div class="px-3 py-2 bg-gray-50 dark:bg-gray-950 rounded-lg">
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-950 rounded-3xl">
<AccessControl bind:accessControl />
</div>
</div>

View file

@ -14,7 +14,7 @@
import Plus from '$lib/components/icons/Plus.svelte';
import Connection from '$lib/components/chat/Settings/Tools/Connection.svelte';
import AddServerModal from '$lib/components/AddServerModal.svelte';
import AddToolServerModal from '$lib/components/AddToolServerModal.svelte';
import { getToolServerConnections, setToolServerConnections } from '$lib/apis/configs';
export let saveSettings: Function;
@ -47,7 +47,7 @@
});
</script>
<AddServerModal bind:show={showConnectionModal} onSubmit={addConnectionHandler} />
<AddToolServerModal bind:show={showConnectionModal} onSubmit={addConnectionHandler} />
<form
class="flex flex-col h-full justify-between text-sm"

View file

@ -1032,7 +1032,7 @@
<div
class="flex-1 flex flex-col relative w-full shadow-lg rounded-3xl border {$temporaryChatEnabled
? 'border-dashed border-gray-100 dark:border-gray-800 hover:border-gray-200 focus-within:border-gray-200 hover:dark:border-gray-700 focus-within:dark:border-gray-700'
: ' border-gray-50 dark:border-gray-850 hover:border-gray-100 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800'} transition px-1 bg-white/90 dark:bg-gray-400/5 dark:text-gray-100"
: ' border-gray-100 dark:border-gray-850 hover:border-gray-200 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800'} transition px-1 bg-white/5 dark:bg-gray-500/5 backdrop-blur-sm dark:text-gray-100"
dir={$settings?.chatDirection ?? 'auto'}
>
{#if atSelectedModel !== undefined}
@ -1428,7 +1428,9 @@
</div>
</InputMenu>
<div class="flex self-center w-[1px] h-4 mx-1 bg-gray-50 dark:bg-gray-800" />
<div
class="flex self-center w-[1px] h-4 mx-1 bg-gray-200/50 dark:bg-gray-800/50"
/>
{#if showWebSearchButton || showImageGenerationButton || showCodeInterpreterButton || showToolsButton || (toggleFilters && toggleFilters.length > 0)}
<IntegrationsMenu

View file

@ -45,7 +45,7 @@
</script>
<div id="tab-about" class="flex flex-col h-full justify-between space-y-3 text-sm mb-6">
<div class=" space-y-3 overflow-y-scroll max-h-[28rem] lg:max-h-full">
<div class=" space-y-3 overflow-y-scroll max-h-[28rem] md:max-h-full">
<div>
<div class=" mb-2.5 text-sm font-medium flex space-x-2 items-center">
<div>

View file

@ -117,7 +117,7 @@
</script>
<div id="tab-account" class="flex flex-col h-full justify-between text-sm">
<div class=" overflow-y-scroll max-h-[28rem] lg:max-h-full">
<div class=" overflow-y-scroll max-h-[28rem] md:max-h-full">
<input
id="profile-image-input"
bind:this={profileImageInputElement}

View file

@ -175,7 +175,7 @@
dispatch('save');
}}
>
<div class=" space-y-3 overflow-y-scroll max-h-[28rem] lg:max-h-full">
<div class=" space-y-3 overflow-y-scroll max-h-[28rem] md:max-h-full">
<div>
<div class=" mb-1 text-sm font-medium">{$i18n.t('STT Settings')}</div>

View file

@ -117,7 +117,7 @@
<ArchivedChatsModal bind:show={showArchivedChatsModal} onUpdate={handleArchivedChatsChange} />
<div id="tab-chats" class="flex flex-col h-full justify-between space-y-3 text-sm">
<div class=" space-y-2 overflow-y-scroll max-h-[28rem] lg:max-h-full">
<div class=" space-y-2 overflow-y-scroll max-h-[28rem] md:max-h-full">
<div class="flex flex-col">
<input
id="chat-import-input"

View file

@ -191,7 +191,7 @@
</script>
<div class="flex flex-col h-full justify-between text-sm" id="tab-general">
<div class=" overflow-y-scroll max-h-[28rem] lg:max-h-full">
<div class=" overflow-y-scroll max-h-[28rem] md:max-h-full">
<div class="">
<div class=" mb-1 text-sm font-medium">{$i18n.t('WebUI Settings')}</div>
@ -277,7 +277,7 @@
</div>
{#if $user?.role === 'admin' || ($user?.permissions.chat?.system_prompt ?? true)}
<hr class="border-gray-50 dark:border-gray-850 my-3" />
<hr class="border-gray-100/50 dark:border-gray-850 my-3" />
<div>
<div class=" my-2.5 text-sm font-medium">{$i18n.t('System Prompt')}</div>
@ -285,8 +285,8 @@
bind:value={system}
className={'w-full text-sm outline-hidden resize-vertical' +
($settings.highContrastMode
? ' p-2.5 border-2 border-gray-300 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-850 text-gray-900 dark:text-gray-100 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 overflow-y-hidden'
: ' bg-white dark:text-gray-300 dark:bg-gray-900')}
? ' p-2.5 border-2 border-gray-300 dark:border-gray-700 rounded-lg bg-transparent text-gray-900 dark:text-gray-100 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 overflow-y-hidden'
: ' dark:text-gray-300 ')}
rows="4"
placeholder={$i18n.t('Enter system prompt here')}
/>

View file

@ -306,7 +306,7 @@
}}
/>
<div class=" space-y-3 overflow-y-scroll max-h-[28rem] lg:max-h-full">
<div class=" space-y-3 overflow-y-scroll max-h-[28rem] md:max-h-full">
<div>
<h1 class=" mb-2 text-sm font-medium">{$i18n.t('UI')}</h1>

View file

@ -30,7 +30,7 @@
dispatch('save');
}}
>
<div class="py-1 overflow-y-scroll max-h-[28rem] lg:max-h-full">
<div class="py-1 overflow-y-scroll max-h-[28rem] md:max-h-full">
<div>
<div class="flex items-center justify-between mb-1">
<Tooltip

View file

@ -14,7 +14,7 @@
import Plus from '$lib/components/icons/Plus.svelte';
import Connection from './Tools/Connection.svelte';
import AddServerModal from '$lib/components/AddServerModal.svelte';
import AddToolServerModal from '$lib/components/AddToolServerModal.svelte';
export let saveSettings: Function;
@ -52,7 +52,7 @@
});
</script>
<AddServerModal bind:show={showConnectionModal} onSubmit={addConnectionHandler} direct />
<AddToolServerModal bind:show={showConnectionModal} onSubmit={addConnectionHandler} direct />
<form
id="tab-tools"

View file

@ -6,7 +6,7 @@
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Cog6 from '$lib/components/icons/Cog6.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import AddServerModal from '$lib/components/AddServerModal.svelte';
import AddToolServerModal from '$lib/components/AddToolServerModal.svelte';
export let onDelete = () => {};
export let onSubmit = () => {};
@ -18,7 +18,7 @@
let showDeleteConfirmDialog = false;
</script>
<AddServerModal
<AddToolServerModal
edit
{direct}
bind:show={showConfigModal}
@ -48,15 +48,12 @@
})}
placement="top-start"
>
{#if !(connection?.config?.enable ?? true)}
<div
class="absolute top-0 bottom-0 left-0 right-0 opacity-60 bg-white dark:bg-gray-900 z-10"
></div>
{/if}
<div class="flex w-full">
<div class="flex-1 relative">
<input
class=" outline-hidden w-full bg-transparent"
class=" outline-hidden w-full bg-transparent {!(connection?.config?.enable ?? true)
? 'opacity-50'
: ''}"
placeholder={$i18n.t('API Base URL')}
bind:value={connection.url}
autocomplete="off"

View file

@ -591,7 +591,7 @@
class="tabs flex flex-row overflow-x-auto gap-2.5 mx-3 md:pr-4 md:gap-1 md:flex-col flex-1 md:flex-none md:w-50 md:min-h-[36rem] md:max-h-[36rem] dark:text-gray-200 text-sm text-left mb-1 md:mb-0 -translate-y-1"
>
<div
class="hidden md:flex w-full rounded-full px-2.5 gap-2 bg-gray-50 dark:bg-gray-850 my-1 mb-1.5"
class="hidden md:flex w-full rounded-full px-2.5 gap-2 bg-gray-100/80 dark:bg-gray-850/80 backdrop-blur-2xl my-1 mb-1.5"
id="settings-search"
>
<div class="self-center rounded-l-xl bg-transparent">

View file

@ -191,20 +191,30 @@
{/if}
</div>
{/if}
{/if}
{/if}
{#if attributes?.done === 'true'}
{#if typeof files === 'object'}
{#each files ?? [] as file, idx}
{#if file.startsWith('data:image/')}
<Image
id={`${collapsibleId}-tool-calls-${attributes?.id}-result-${idx}`}
src={file}
alt="Image"
/>
{/if}
{/each}
{#if attributes?.done === 'true'}
{#if typeof files === 'object'}
{#each files ?? [] as file, idx}
{#if typeof file === 'string'}
{#if file.startsWith('data:image/')}
<Image
id={`${collapsibleId}-tool-calls-${attributes?.id}-result-${idx}`}
src={file}
alt="Image"
/>
{/if}
{:else if typeof file === 'object'}
{#if file.type === 'image' && file.url}
<Image
id={`${collapsibleId}-tool-calls-${attributes?.id}-result-${idx}`}
src={file.url}
alt="Image"
/>
{/if}
{/if}
{/if}
{/each}
{/if}
{/if}
{:else}

View file

@ -7,7 +7,7 @@
export let show = true;
export let size = 'md';
export let containerClassName = 'p-3';
export let className = 'bg-white dark:bg-gray-900 rounded-4xl';
export let className = 'bg-white/95 dark:bg-gray-900/95 backdrop-blur-sm rounded-4xl';
let modalElement = null;
let mounted = false;

View file

@ -139,7 +139,9 @@
import FormattingButtons from './RichTextInput/FormattingButtons.svelte';
import { PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants';
import { all, createLowlight } from 'lowlight';
import { createLowlight } from 'lowlight';
import hljs from 'highlight.js';
import type { SocketIOCollaborationProvider } from './RichTextInput/Collaboration';
export let oncompositionstart = (e) => {};
@ -147,7 +149,10 @@
export let onChange = (e) => {};
// create a lowlight instance with all languages loaded
const lowlight = createLowlight(all);
const lowlight = createLowlight(hljs.listLanguages().reduce((obj, lang) => {
obj[lang] = () => hljs.getLanguage(lang);
return obj;
}, {} as Record<string, any>));
export let editor: Editor | null = null;
@ -173,6 +178,7 @@
};
export let richText = true;
export let dragHandle = false;
export let link = false;
export let image = false;
export let fileHandler = false;
@ -602,6 +608,20 @@
}
});
import { listDragHandlePlugin } from './RichTextInput/listDragHandlePlugin.js';
const ListItemDragHandle = Extension.create({
name: 'listItemDragHandle',
addProseMirrorPlugins() {
return [
listDragHandlePlugin({
itemTypeNames: ['listItem', 'taskItem'],
getEditor: () => this.editor
})
];
}
});
onMount(async () => {
content = value;
@ -658,6 +678,7 @@
StarterKit.configure({
link: link
}),
...(dragHandle ? [ListItemDragHandle] : []),
Placeholder.configure({ placeholder: () => _placeholder }),
SelectionDecoration,
@ -1083,11 +1104,11 @@
</script>
{#if richText && showFormattingToolbar}
<div bind:this={bubbleMenuElement} id="bubble-menu" class="p-0">
<div bind:this={bubbleMenuElement} id="bubble-menu" class="p-0 {editor ? '' : 'hidden'}">
<FormattingButtons {editor} />
</div>
<div bind:this={floatingMenuElement} id="floating-menu" class="p-0">
<div bind:this={floatingMenuElement} id="floating-menu" class="p-0 {editor ? '' : 'hidden'}">
<FormattingButtons {editor} />
</div>
{/if}

View file

@ -0,0 +1,513 @@
import { Plugin, PluginKey, NodeSelection } from 'prosemirror-state';
import { Decoration, DecorationSet } from 'prosemirror-view';
import { Fragment } from 'prosemirror-model';
export const listPointerDragKey = new PluginKey('listPointerDrag');
export function listDragHandlePlugin(options = {}) {
const {
itemTypeNames = ['listItem', 'taskItem', 'list_item'],
// Tiptap editor getter (required for indent/outdent)
getEditor = null,
// UI copy / classes
handleTitle = 'Drag to move',
handleInnerHTML = '⋮⋮',
classItemWithHandle = 'pm-li--with-handle',
classHandle = 'pm-list-drag-handle',
classDropBefore = 'pm-li-drop-before',
classDropAfter = 'pm-li-drop-after',
classDropInto = 'pm-li-drop-into',
classDropOutdent = 'pm-li-drop-outdent',
classDraggingGhost = 'pm-li-ghost',
// Behavior
dragThresholdPx = 2,
intoThresholdX = 28, // X ≥ this → treat as “into” (indent)
outdentThresholdX = 10 // X ≤ this → “outdent”
} = options;
const itemTypesSet = new Set(itemTypeNames);
const isListItem = (node) => node && itemTypesSet.has(node.type.name);
const listTypeNames = new Set([
'bulletList',
'orderedList',
'taskList',
'bullet_list',
'ordered_list'
]);
const isListNode = (node) => node && listTypeNames.has(node.type.name);
function listTypeToItemTypeName(listNode) {
const name = listNode?.type?.name;
if (!name) return null;
// Prefer tiptap names first, then ProseMirror snake_case
if (name === 'taskList') {
return itemTypesSet.has('taskItem') ? 'taskItem' : null;
}
if (name === 'orderedList' || name === 'bulletList') {
return itemTypesSet.has('listItem')
? 'listItem'
: itemTypesSet.has('list_item')
? 'list_item'
: null;
}
if (name === 'ordered_list' || name === 'bullet_list') {
return itemTypesSet.has('list_item')
? 'list_item'
: itemTypesSet.has('listItem')
? 'listItem'
: null;
}
return null;
}
// Find the nearest enclosing list container at/around a pos
function getEnclosingListAt(doc, pos) {
const $pos = doc.resolve(Math.max(1, Math.min(pos, doc.content.size - 1)));
for (let d = $pos.depth; d >= 0; d--) {
const n = $pos.node(d);
if (isListNode(n)) {
const start = $pos.before(d);
return { node: n, depth: d, start, end: start + n.nodeSize };
}
}
return null;
}
function normalizeItemForList(state, itemNode, targetListNodeOrType) {
const schema = state.schema;
const targetListNode = targetListNodeOrType;
const wantedItemTypeName =
typeof targetListNode === 'string'
? targetListNode // allow passing type name directly
: listTypeToItemTypeName(targetListNode);
if (!wantedItemTypeName) return itemNode;
const wantedType = schema.nodes[wantedItemTypeName];
if (!wantedType) return itemNode;
const wantedListType = schema.nodes[targetListNode.type.name];
if (!wantedListType) return itemNode;
// Deepnormalize children recursively
const normalizeNode = (node, parentTargetListNode) => {
console.log(
'Normalizing node',
node.type.name,
'for parent list',
parentTargetListNode?.type?.name
);
if (isListNode(node)) {
// Normalize each list item inside
const normalizedItems = [];
node.content.forEach((li) => {
normalizedItems.push(normalizeItemForList(state, li, parentTargetListNode));
});
return wantedListType.create(node.attrs, Fragment.from(normalizedItems), node.marks);
}
// Not a list node → but may contain lists deeper
if (node.content && node.content.size > 0) {
const nChildren = [];
node.content.forEach((ch) => {
nChildren.push(normalizeNode(ch, parentTargetListNode));
});
return node.type.create(node.attrs, Fragment.from(nChildren), node.marks);
}
// leaf
return node;
};
const normalizedContent = [];
itemNode.content.forEach((child) => {
normalizedContent.push(normalizeNode(child, targetListNode));
});
const newAttrs = {};
if (wantedType.attrs) {
for (const key in wantedType.attrs) {
if (Object.prototype.hasOwnProperty.call(itemNode.attrs || {}, key)) {
newAttrs[key] = itemNode.attrs[key];
} else {
const spec = wantedType.attrs[key];
newAttrs[key] = typeof spec?.default !== 'undefined' ? spec.default : null;
}
}
}
if (wantedItemTypeName !== itemNode.type.name) {
// If changing type, ensure no disallowed marks are kept
const allowed = wantedType.spec?.marks;
const marks = allowed ? itemNode.marks.filter((m) => allowed.includes(m.type.name)) : [];
console.log(normalizedContent);
return wantedType.create(newAttrs, Fragment.from(normalizedContent), marks);
}
try {
return wantedType.create(newAttrs, Fragment.from(normalizedContent), itemNode.marks);
} catch {
// Fallback wrap content if schema requires a block
const para = schema.nodes.paragraph;
if (para) {
const wrapped =
itemNode.content.firstChild?.type === para
? Fragment.from(normalizedContent)
: Fragment.from([para.create(null, normalizedContent)]);
return wantedType.create(newAttrs, wrapped, itemNode.marks);
}
}
return wantedType.create(newAttrs, Fragment.from(normalizedContent), itemNode.marks);
}
// ---------- decorations ----------
function buildHandleDecos(doc) {
const decos = [];
doc.descendants((node, pos) => {
if (!isListItem(node)) return;
decos.push(Decoration.node(pos, pos + node.nodeSize, { class: classItemWithHandle }));
decos.push(
Decoration.widget(
pos + 1,
(view, getPos) => {
const el = document.createElement('span');
el.className = classHandle;
el.setAttribute('title', handleTitle);
el.setAttribute('role', 'button');
el.setAttribute('aria-label', 'Drag list item');
el.contentEditable = 'false';
el.innerHTML = handleInnerHTML;
el.pmGetPos = getPos;
return el;
},
{ side: -1, ignoreSelection: true }
)
);
});
return DecorationSet.create(doc, decos);
}
function findListItemAround($pos) {
for (let d = $pos.depth; d > 0; d--) {
const node = $pos.node(d);
if (isListItem(node)) {
const start = $pos.before(d);
return { depth: d, node, start, end: start + node.nodeSize };
}
}
return null;
}
function infoFromCoords(view, clientX, clientY) {
const result = view.posAtCoords({ left: clientX, top: clientY });
if (!result) return null;
const $pos = view.state.doc.resolve(result.pos);
const li = findListItemAround($pos);
if (!li) return null;
const dom = /** @type {Element} */ (view.nodeDOM(li.start));
if (!(dom instanceof Element)) return null;
const rect = dom.getBoundingClientRect();
const isRTL = getComputedStyle(dom).direction === 'rtl';
const xFromLeft = isRTL ? rect.right - clientX : clientX - rect.left;
const yInTopHalf = clientY - rect.top < rect.height / 2;
const mode =
xFromLeft <= outdentThresholdX
? 'outdent'
: xFromLeft >= intoThresholdX
? 'into'
: yInTopHalf
? 'before'
: 'after';
return { ...li, dom, mode };
}
// ---------- state ----------
const init = (state) => ({
decorations: buildHandleDecos(state.doc),
dragging: null, // {fromStart, startMouse:{x,y}, ghostEl, active}
dropTarget: null // {start, end, mode, toPos}
});
const apply = (tr, prev) => {
let decorations = tr.docChanged
? buildHandleDecos(tr.doc)
: prev.decorations.map(tr.mapping, tr.doc);
let next = { ...prev, decorations };
const meta = tr.getMeta(listPointerDragKey);
if (meta) {
if (meta.type === 'set-drag') next = { ...next, dragging: meta.dragging };
if (meta.type === 'set-drop') next = { ...next, dropTarget: meta.drop };
if (meta.type === 'clear') next = { ...next, dragging: null, dropTarget: null };
}
return next;
};
const decorationsProp = (state) => {
const ps = listPointerDragKey.getState(state);
if (!ps) return null;
let deco = ps.decorations;
if (ps.dropTarget) {
const { start, end, mode } = ps.dropTarget;
const cls =
mode === 'before'
? classDropBefore
: mode === 'after'
? classDropAfter
: mode === 'into'
? classDropInto
: classDropOutdent;
deco = deco.add(state.doc, [Decoration.node(start, end, { class: cls })]);
}
return deco;
};
// ---------- helpers ----------
const setDrag = (view, dragging) =>
view.dispatch(view.state.tr.setMeta(listPointerDragKey, { type: 'set-drag', dragging }));
const setDrop = (view, drop) =>
view.dispatch(view.state.tr.setMeta(listPointerDragKey, { type: 'set-drop', drop }));
const clearAll = (view) =>
view.dispatch(view.state.tr.setMeta(listPointerDragKey, { type: 'clear' }));
function moveItem(view, fromStart, toPos) {
const { state, dispatch } = view;
const { doc } = state;
const orig = doc.nodeAt(fromStart);
if (!orig || !isListItem(orig)) return { ok: false };
// no-op if dropping into own range
if (toPos >= fromStart && toPos <= fromStart + orig.nodeSize)
return { ok: true, newStart: fromStart };
// find item depth
const $inside = doc.resolve(fromStart + 1);
let itemDepth = -1;
for (let d = $inside.depth; d > 0; d--) {
if ($inside.node(d) === orig) {
itemDepth = d;
break;
}
}
if (itemDepth < 0) return { ok: false };
const listDepth = itemDepth - 1;
const parentList = $inside.node(listDepth);
const parentListStart = $inside.before(listDepth);
// delete item (or entire list if only child)
const deleteFrom = parentList.childCount === 1 ? parentListStart : fromStart;
const deleteTo =
parentList.childCount === 1
? parentListStart + parentList.nodeSize
: fromStart + orig.nodeSize;
let tr = state.tr.delete(deleteFrom, deleteTo);
// Compute mapped drop point with right bias so "after" stays after
const mappedTo = tr.mapping.map(toPos, 1);
// Detect enclosing list at destination, then normalize the item type
const listAtDest = getEnclosingListAt(tr.doc, mappedTo);
const nodeToInsert = listAtDest ? normalizeItemForList(state, orig, listAtDest.node) : orig;
try {
tr = tr.insert(mappedTo, nodeToInsert);
} catch (e) {
console.log('Direct insert failed, trying to wrap in list', e);
// If direct insert fails (e.g., not inside a list), try wrapping in a list
const schema = state.schema;
const wrapName =
parentList.type.name === 'taskList'
? schema.nodes.taskList
? 'taskList'
: null
: parentList.type.name === 'orderedList' || parentList.type.name === 'ordered_list'
? schema.nodes.orderedList
? 'orderedList'
: schema.nodes.ordered_list
? 'ordered_list'
: null
: schema.nodes.bulletList
? 'bulletList'
: schema.nodes.bullet_list
? 'bullet_list'
: null;
if (wrapName) {
const wrapType = schema.nodes[wrapName];
if (wrapType) {
const frag = wrapType.create(null, normalizeItemForList(state, orig, wrapType));
tr = tr.insert(mappedTo, frag);
} else {
return { ok: false };
}
} else {
return { ok: false };
}
}
dispatch(tr.scrollIntoView());
return { ok: true, newStart: mappedTo };
}
function ensureGhost(view, fromStart) {
const el = document.createElement('div');
el.className = classDraggingGhost;
const dom = /** @type {Element} */ (view.nodeDOM(fromStart));
const rect = dom instanceof Element ? dom.getBoundingClientRect() : null;
if (rect) {
el.style.position = 'fixed';
el.style.left = rect.left + 'px';
el.style.top = rect.top + 'px';
el.style.width = rect.width + 'px';
el.style.pointerEvents = 'none';
el.style.opacity = '0.75';
el.textContent = dom.textContent?.trim().slice(0, 80) || '…';
}
document.body.appendChild(el);
return el;
}
const updateGhost = (ghost, dx, dy) => {
if (ghost) ghost.style.transform = `translate(${Math.round(dx)}px, ${Math.round(dy)}px)`;
};
// ---------- plugin ----------
return new Plugin({
key: listPointerDragKey,
state: { init: (_, state) => init(state), apply },
props: {
decorations: decorationsProp,
handleDOMEvents: {
mousedown(view, event) {
const t = /** @type {HTMLElement} */ (event.target);
const handle = t.closest?.(`.${classHandle}`);
if (!handle) return false;
event.preventDefault();
const getPos = handle.pmGetPos;
if (typeof getPos !== 'function') return true;
const posInside = getPos();
const fromStart = posInside - 1;
try {
view.dispatch(
view.state.tr.setSelection(NodeSelection.create(view.state.doc, fromStart))
);
} catch {}
const startMouse = { x: event.clientX, y: event.clientY };
const ghostEl = ensureGhost(view, fromStart);
setDrag(view, { fromStart, startMouse, ghostEl, active: false });
const onMove = (e) => {
const ps = listPointerDragKey.getState(view.state);
if (!ps?.dragging) return;
const dx = e.clientX - ps.dragging.startMouse.x;
const dy = e.clientY - ps.dragging.startMouse.y;
if (!ps.dragging.active && Math.hypot(dx, dy) > dragThresholdPx) {
setDrag(view, { ...ps.dragging, active: true });
}
updateGhost(ps.dragging.ghostEl, dx, dy);
const info = infoFromCoords(view, e.clientX, e.clientY);
if (!info) return setDrop(view, null);
// for before/after: obvious
// for into/outdent: we still insert AFTER target and then run sink/lift
const toPos =
info.mode === 'before' ? info.start : info.mode === 'after' ? info.end : info.end; // into/outdent insert after target
const prev = listPointerDragKey.getState(view.state)?.dropTarget;
if (
!prev ||
prev.start !== info.start ||
prev.end !== info.end ||
prev.mode !== info.mode
) {
setDrop(view, { start: info.start, end: info.end, mode: info.mode, toPos });
}
};
const endDrag = () => {
window.removeEventListener('mousemove', onMove, true);
window.removeEventListener('mouseup', endDrag, true);
const ps = listPointerDragKey.getState(view.state);
if (ps?.dragging?.ghostEl) ps.dragging.ghostEl.remove();
// Helper: figure out the list item node type name at/around a pos
const getListItemTypeNameAt = (doc, pos) => {
const direct = doc.nodeAt(pos);
if (direct && isListItem(direct)) return direct.type.name;
const $pos = doc.resolve(Math.min(pos + 1, doc.content.size));
for (let d = $pos.depth; d > 0; d--) {
const n = $pos.node(d);
if (isListItem(n)) return n.type.name;
}
const prefs = ['taskItem', 'listItem', 'list_item'];
for (const p of prefs) if (itemTypesSet.has(p)) return p;
return Array.from(itemTypesSet)[0];
};
if (ps?.dragging && ps?.dropTarget && ps.dragging.active) {
const { fromStart } = ps.dragging;
const { toPos, mode } = ps.dropTarget;
const res = moveItem(view, fromStart, toPos);
if (res.ok && typeof res.newStart === 'number' && getEditor) {
const editor = getEditor();
if (editor?.commands) {
// Select the moved node so sink/lift applies to it
editor.commands.setNodeSelection(res.newStart);
const typeName = getListItemTypeNameAt(view.state.doc, res.newStart);
const chain = editor.chain().focus();
if (mode === 'into') {
if (editor.can().sinkListItem?.(typeName)) chain.sinkListItem(typeName).run();
else chain.run();
} else {
chain.run(); // finalize focus/selection
}
}
}
}
clearAll(view);
};
window.addEventListener('mousemove', onMove, true);
window.addEventListener('mouseup', endDrag, true);
return true;
},
keydown(view, event) {
if (event.key === 'Escape') {
const ps = listPointerDragKey.getState(view.state);
if (ps?.dragging?.ghostEl) ps.dragging.ghostEl.remove();
clearAll(view);
return true;
}
return false;
}
}
}
});
}

View file

@ -63,7 +63,7 @@
{/if}
<button
class=" cursor-pointer self-center p-0.5 flex h-fit items-center dark:hover:bg-gray-700 rounded-full transition border dark:border-gray-600 border-dashed"
class=" cursor-pointer self-center p-0.5 flex h-fit items-center rounded-full transition border dark:border-gray-600 border-dashed"
type="button"
aria-label={$i18n.t('Add Tag')}
on:click={() => {
@ -76,7 +76,7 @@
viewBox="0 0 16 16"
aria-hidden="true"
fill="currentColor"
class="w-3 h-3 {showTagInput ? 'rotate-45' : ''} transition-all transform"
class="size-2.5 {showTagInput ? 'rotate-45' : ''} transition-all transform"
>
<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"

View file

@ -0,0 +1,33 @@
<script>
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import Tooltip from '../Tooltip.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let tag;
export let onDelete = () => {};
</script>
{#if tag}
<Tooltip content={tag.name}>
<button
aria-label={$i18n.t('Remove this tag from list')}
class="relative group/tags px-1.5 py-[0.5px] gap-0.5 flex justify-between h-fit max-h-fit w-fit items-center rounded-lg bg-gray-500/20 text-gray-700 dark:text-gray-200 transition cursor-pointer"
on:click={() => {
onDelete();
}}
>
<div class=" text-[0.7rem] font-medium self-center line-clamp-1 w-fit">
{tag.name}
</div>
<div class="hidden group-hover/tags:block transition">
<div class="rounded-full pl-[1px] backdrop-blur-sm h-full flex self-center cursor-pointer">
<XMark className="size-3" strokeWidth="2.5" />
</div>
</div>
</button>
</Tooltip>
{/if}

View file

@ -2,34 +2,18 @@
import { createEventDispatcher } from 'svelte';
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import Tooltip from '../Tooltip.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Badge from '../Badge.svelte';
import TagItem from './TagItem.svelte';
const dispatch = createEventDispatcher();
export let tags = [];
</script>
{#each tags as tag}
<Tooltip content={tag.name}>
<li
class="relative group/tags px-1.5 py-[0.2px] gap-0.5 flex justify-between h-fit max-h-fit w-fit items-center rounded-full bg-gray-500/20 text-gray-700 dark:text-gray-200 transition cursor-pointer"
>
<div class=" text-[0.7rem] font-medium self-center line-clamp-1 w-fit">
{tag.name}
</div>
<div class="absolute invisible right-0.5 group-hover/tags:visible transition">
<button
class="rounded-full border bg-white dark:bg-gray-700 h-full flex self-center cursor-pointer"
on:click={() => {
dispatch('delete', tag.name);
}}
type="button"
aria-label={$i18n.t('Remove this tag from list')}
>
<XMark className="size-3" strokeWidth="2.5" />
</button>
</div>
</li>
</Tooltip>
<TagItem
{tag}
onDelete={() => {
dispatch('delete', tag.name);
}}
/>
{/each}

View file

@ -149,9 +149,9 @@
chatListLoading = false;
};
const init = () => {
$: if (show) {
searchHandler();
};
}
const onKeyDown = (e) => {
const searchOptions = document.getElementById('search-options-container');
@ -205,8 +205,6 @@
};
onMount(() => {
init();
document.addEventListener('keydown', onKeyDown);
});

View file

@ -65,6 +65,8 @@
const BREAKPOINT = 768;
let scrollTop = 0;
let navElement;
let shiftKey = false;
@ -704,7 +706,7 @@
: 'invisible'}"
>
<div
class="sidebar px-1.5 pt-2 pb-2 flex justify-between space-x-1 text-gray-600 dark:text-gray-400 sticky top-0 z-10 -mb-12"
class="sidebar px-2 pt-2 pb-1.5 flex justify-between space-x-1 text-gray-600 dark:text-gray-400 sticky top-0 z-10 -mb-3"
>
<a
class="flex items-center rounded-xl size-8.5 h-full justify-center hover:bg-gray-100/50 dark:hover:bg-gray-850/50 transition no-drag-region"
@ -745,11 +747,22 @@
</Tooltip>
<div
class=" bg-linear-to-b from-gray-50 dark:from-gray-950 to-transparent from-50% pointer-events-none absolute inset-0 -z-10"
class="{scrollTop > 0
? 'visible'
: 'invisible'} bg-linear-to-b from-gray-50 dark:from-gray-950 to-transparent from-50% pointer-events-none absolute inset-0 -z-10 -mb-6"
></div>
</div>
<div class="relative flex flex-col flex-1 overflow-y-auto pt-12 pb-12">
<div
class="relative flex flex-col flex-1 overflow-y-auto scrollbar-hidden pt-3 pb-3"
on:scroll={(e) => {
if (e.target.scrollTop === 0) {
scrollTop = 0;
} else {
scrollTop = e.target.scrollTop;
}
}}
>
<div class="pb-1.5">
<div class="px-[7px] flex justify-center text-gray-800 dark:text-gray-200">
<a
@ -1161,9 +1174,9 @@
</Folder>
</div>
<div class="px-1.5 pt-1.5 pb-2 sticky bottom-0 z-10 -mt-12 sidebar">
<div class="px-1.5 pt-1.5 pb-2 sticky bottom-0 z-10 -mt-3 sidebar">
<div
class=" bg-linear-to-t from-gray-50 dark:from-gray-950 to-transparent from-50% pointer-events-none absolute inset-0 -z-10"
class=" bg-linear-to-t from-gray-50 dark:from-gray-950 to-transparent from-50% pointer-events-none absolute inset-0 -z-10 -mt-6"
></div>
<div class="flex flex-col font-primary">
{#if $user !== undefined && $user !== null}
@ -1195,14 +1208,3 @@
</div>
</div>
{/if}
<style>
.scrollbar-hidden:active::-webkit-scrollbar-thumb,
.scrollbar-hidden:focus::-webkit-scrollbar-thumb,
.scrollbar-hidden:hover::-webkit-scrollbar-thumb {
visibility: visible;
}
.scrollbar-hidden::-webkit-scrollbar-thumb {
visibility: hidden;
}
</style>

View file

@ -125,7 +125,7 @@
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<div class="my-2 -mx-2">
<div class="px-3 py-2 bg-gray-50 dark:bg-gray-950 rounded-lg">
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-950 rounded-3xl">
<AccessControl bind:accessControl />
</div>
</div>

View file

@ -1216,6 +1216,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
collaboration={true}
socket={$socket}
user={$user}
dragHandle={true}
link={true}
image={true}
{files}

View file

@ -112,7 +112,7 @@
</div>
<div class="mt-2">
<div class="px-3 py-2 bg-gray-50 dark:bg-gray-950 rounded-lg">
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-950 rounded-3xl">
<AccessControl
bind:accessControl
accessRoles={['read', 'write']}

View file

@ -587,7 +587,7 @@
</div>
<div class="my-2">
<div class="px-3 py-2 bg-gray-50 dark:bg-gray-950 rounded-lg">
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-950 rounded-3xl">
<AccessControl
bind:accessControl
accessRoles={['read', 'write']}

View file

@ -62,7 +62,7 @@
<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=" text-sm font-semibold mb-1.5">{$i18n.t('Visibility')}</div>
<div class="flex gap-2.5 items-center mb-1">
<div>

View file

@ -212,7 +212,7 @@
"Capture Audio": "Capturar Audio",
"Certificate Path": "Ruta a Certificado",
"Change Password": "Cambiar Contraseña",
"Channel": "",
"Channel": "Canal",
"Channel deleted successfully": "Canal borrado correctamente",
"Channel Name": "Nombre del Canal",
"Channel updated successfully": "Canal actualizado correctamente",
@ -358,7 +358,7 @@
"Custom Parameter Value": "Valor del Parámetro Personalizado",
"Danger Zone": "Zona Peligrosa",
"Dark": "Oscuro",
"Data Controls": "",
"Data Controls": "Controles de Datos",
"Database": "Base de datos",
"Datalab Marker API": "API de Datalab Marker",
"Datalab Marker API Key required.": "Clave API de Datalab Marker Requerida",
@ -487,7 +487,7 @@
"Edit Memory": "Editar Memoria",
"Edit User": "Editar Usuario",
"Edit User Group": "Editar Grupo de Usuarios",
"edited": "",
"edited": "editado",
"Edited": "Editado",
"Editing": "Editando",
"Eject": "Expulsar",
@ -630,7 +630,7 @@
"Enter Your Role": "Ingresa tu rol",
"Enter Your Username": "Ingresa tu nombre de usuario",
"Enter your webhook URL": "Ingresa tu URL de webhook",
"Entra ID": "",
"Entra ID": "ID de Entra",
"Error": "Error",
"ERROR": "ERROR",
"Error accessing directory": "Error accediendo al directorio",
@ -726,13 +726,13 @@
"Firecrawl API Key": "Clave de API de Firecrawl",
"Floating Quick Actions": "Acciones Rápidas Flotantes",
"Focus chat input": "Enfocar campo de chat",
"Folder Background Image": "",
"Folder Background Image": "Imagen de Fondo de la Carpeta",
"Folder deleted successfully": "Carpeta eliminada correctamente",
"Folder Name": "Nombre de la Carpeta",
"Folder name cannot be empty.": "El nombre de la carpeta no puede estar vacío",
"Folder name updated successfully": "Nombre de la carpeta actualizado correctamente",
"Folder updated successfully": "Carpeta actualizada correctamente",
"Folders": "",
"Folders": "Carpetas",
"Follow up": "Seguimiento",
"Follow Up Generation": "Seguimiento de la Generación",
"Follow Up Generation Prompt": "Seguimiento de la Generación del Indicador",
@ -1048,7 +1048,7 @@
"No models found": "No se encontraron modelos",
"No models selected": "No se seleccionaron modelos",
"No Notes": "Sin Notas",
"No notes found": "",
"No notes found": "No se encontraron notas",
"No results": "No se encontraron resultados",
"No results found": "No se encontraron resultados",
"No search query generated": "No se generó ninguna consulta de búsqueda",
@ -1062,7 +1062,7 @@
"None": "Ninguno",
"Not factually correct": "No es correcto en todos los aspectos",
"Not helpful": "No aprovechable",
"Note": "",
"Note": "Nota",
"Note deleted successfully": "Nota eliminada correctamente",
"Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.": "Nota: Si estableces una puntuación mínima, la búsqueda sólo devolverá documentos con una puntuación mayor o igual a la puntuación mínima establecida.",
"Notes": "Notas",
@ -1095,10 +1095,10 @@
"Oops! You're using an unsupported method (frontend only). Please serve the WebUI from the backend.": "¡vaya! Estás usando un método no soportado (solo interfaz frontal-frontend). Por favor sirve WebUI desde el interfaz trasero (servidor backend).",
"Open file": "Abrir archivo",
"Open in full screen": "Abrir en pantalla completa",
"Open link": "",
"Open link": "Abrir enlace",
"Open modal to configure connection": "Abrir modal para configurar la conexión",
"Open Modal To Manage Floating Quick Actions": "Abrir modal para gestionar Acciones Rápidas Flotantes",
"Open Modal To Manage Image Compression": "",
"Open Modal To Manage Floating Quick Actions": "Abrir Modal para Gestionar Acciones Rápidas Flotantes",
"Open Modal To Manage Image Compression": "Abrir Modal para Gestionar Compresión de Imagen",
"Open new chat": "Abrir nuevo chat",
"Open Sidebar": "Abrir Barra Lateral",
"Open User Profile Menu": "Abrir Menu de Perfiles de Usuario",
@ -1218,7 +1218,7 @@
"Redirecting you to Open WebUI Community": "Redireccionando a la Comunidad Open-WebUI",
"Reduces the probability of generating nonsense. A higher value (e.g. 100) will give more diverse answers, while a lower value (e.g. 10) will be more conservative.": "Reduce la probabilidad de generación sin sentido. Un valor más alto (p.ej. 100) dará respuestas más diversas, mientras que un valor más bajo (p.ej. 10) será más conservador.",
"Refer to yourself as \"User\" (e.g., \"User is learning Spanish\")": "Referir a ti mismo como \"Usuario\" (p.ej. \"Usuario está aprendiendo Español\")",
"Reference Chats": "",
"Reference Chats": "Referenciar Chats",
"Refused when it shouldn't have": "Rechazado cuando no debería haberlo hecho",
"Regenerate": "Regenerar",
"Regenerate Menu": "Regenerar Menú",
@ -1483,7 +1483,7 @@
"The score should be a value between 0.0 (0%) and 1.0 (100%).": "La puntuación debe ser un valor entre 0.0 (0%) y 1.0 (100%).",
"The stream delta chunk size for the model. Increasing the chunk size will make the model respond with larger pieces of text at once.": "El tamaño del fragmentado incremental para el modelo. Aumentar el tamaño del fragmentado hará que el modelo responda con fragmentos de texto más grandes cada vez.",
"The temperature of the model. Increasing the temperature will make the model answer more creatively.": "La temperatura del modelo. Aumentar la temperatura hará que el modelo responda de forma más creativa.",
"The Weight of BM25 Hybrid Search. 0 more lexical, 1 more semantic. Default 0.5": "La Ponderación de BM25 en la Búsqueda Híbrida. 0 más léxica, 1 más semántica. Por defecto, 0.5",
"The Weight of BM25 Hybrid Search. 0 more semantic, 1 more lexical. Default 0.5": "La Ponderación de BM25 en la Búsqueda Híbrida. 0 más semántica, 1 más léxica. Por defecto, 0.5",
"The width in pixels to compress images to. Leave empty for no compression.": "El ancho en pixeles al comprimir imágenes. Dejar vacío para no compresión",
"Theme": "Tema",
"Thinking...": "Pensando...",
@ -1568,7 +1568,7 @@
"Unarchive All Archived Chats": "Desarchivar Todos los Chats Archivados",
"Unarchive Chat": "Desarchivar Chat",
"Underline": "Subrayado",
"Unknown": "",
"Unknown": "Desconocido",
"Unloads {{FROM_NOW}}": "Descargas {{FROM_NOW}}",
"Unlock mysteries": "Desbloquear misterios",
"Unpin": "Desfijar",
@ -1610,7 +1610,7 @@
"User Webhooks": "Usuario Webhooks",
"Username": "Nombre de Usuario",
"Users": "Usuarios",
"Uses DefaultAzureCredential to authenticate": "",
"Uses DefaultAzureCredential to authenticate": "Usa DefaultAzureCredential para autentificar",
"Using Entire Document": "Usando Documento Completo",
"Using Focused Retrieval": "Usando Recuperación Focalizada",
"Using the default arena model with all models. Click the plus button to add custom models.": "Usando el modelo de arena predeterminado con todos los modelos. Pulsar en el botón + para agregar modelos personalizados.",

View file

@ -132,7 +132,7 @@
"Application DN Password": "Sovelluksen DN-salasana",
"applies to all users with the \"user\" role": "koskee kaikkia käyttäjiä, joilla on \"käyttäjä\"-rooli",
"April": "huhtikuu",
"Archive": "Arkisto",
"Archive": "Arkistoi",
"Archive All Chats": "Arkistoi kaikki keskustelut",
"Archived Chats": "Arkistoidut keskustelut",
"archived-chat-export": "arkistoitu-keskustelu-vienti",
@ -144,7 +144,7 @@
"Arena Models": "Arena-mallit",
"Artifacts": "Artefaktit",
"Ask": "Kysy",
"Ask a question": "Kysyä kysymys",
"Ask a question": "Kysy kysymys",
"Assistant": "Avustaja",
"Attach file from knowledge": "Liitä tiedosto tietokannasta",
"Attach Knowledge": "Liitä tietoa",
@ -212,7 +212,7 @@
"Capture Audio": "Kaappaa ääntä",
"Certificate Path": "Varmennepolku",
"Change Password": "Vaihda salasana",
"Channel": "",
"Channel": "Kanava",
"Channel deleted successfully": "Kanavan poisto onnistui",
"Channel Name": "Kanavan nimi",
"Channel updated successfully": "Kanavan päivitys onnistui",
@ -306,7 +306,7 @@
"Connections": "Yhteydet",
"Connections saved successfully": "Yhteyksien tallentaminen onnistui",
"Connections settings updated": "Yhteysasetukset päivitetty",
"Constrains effort on reasoning for reasoning models. Only applicable to reasoning models from specific providers that support reasoning effort.": "",
"Constrains effort on reasoning for reasoning models. Only applicable to reasoning models from specific providers that support reasoning effort.": "Rajoittaa päättelyn määrää päättelymalleissa. Tämä soveltuu vain tarjoajiin jotka tukevat päättelyn määrä asetusta.",
"Contact Admin for WebUI Access": "Ota yhteyttä ylläpitäjään WebUI-käyttöä varten",
"Content": "Sisältö",
"Content Extraction Engine": "Sisällönpoimintamoottori",
@ -358,7 +358,7 @@
"Custom Parameter Value": "Mukautetun parametrin arvo",
"Danger Zone": "Vaara-alue",
"Dark": "Tumma",
"Data Controls": "",
"Data Controls": "Datan hallinta",
"Database": "Tietokanta",
"Datalab Marker API": "Datalab Marker API",
"Datalab Marker API Key required.": "Datalab Marker API-avain vaaditaan.",
@ -370,8 +370,8 @@
"Default (SentenceTransformers)": "Oletus (SentenceTransformers)",
"Default action buttons will be used.": "Painikkeen oletustoiminto",
"Default description enabled": "Oletuskuvaus käytössä",
"Default Features": "",
"Default Filters": "",
"Default Features": "Oletus ominaisuudet",
"Default Filters": "Oletus suodattimet",
"Default mode works with a wider range of models by calling tools once before execution. Native mode leverages the model's built-in tool-calling capabilities, but requires the model to inherently support this feature.": "Oletustila toimii laajemman mallivalikoiman kanssa kutsumalla työkaluja kerran ennen suorittamista. Natiivitila hyödyntää mallin sisäänrakennettuja työkalujen kutsumisominaisuuksia, mutta edellyttää, että malli tukee tätä ominaisuutta.",
"Default Model": "Oletusmalli",
"Default model updated": "Oletusmalli päivitetty",
@ -487,7 +487,7 @@
"Edit Memory": "Muokkaa muistia",
"Edit User": "Muokkaa käyttäjää",
"Edit User Group": "Muokkaa käyttäjäryhmää",
"edited": "",
"edited": "muokattu",
"Edited": "Muokattu",
"Editing": "Muokataan",
"Eject": "Poista",
@ -509,7 +509,7 @@
"Enable Message Rating": "Ota viestiarviointi käyttöön",
"Enable Mirostat sampling for controlling perplexity.": "",
"Enable New Sign Ups": "Salli uudet rekisteröitymiset",
"Enable, disable, or customize the reasoning tags used by the model. \"Enabled\" uses default tags, \"Disabled\" turns off reasoning tags, and \"Custom\" lets you specify your own start and end tags.": "",
"Enable, disable, or customize the reasoning tags used by the model. \"Enabled\" uses default tags, \"Disabled\" turns off reasoning tags, and \"Custom\" lets you specify your own start and end tags.": "Käytä, poista käytöstä, tai kustomoi mallin päättely tageja. \"Käytä\" käyttää oletus tageja, \"Ei käytössä\" ottaa päätely tagit pois käytöstä, ja \"Mukautettu\" antaa sinun määritellä aloitus ja lopetus tagit.",
"Enabled": "Käytössä",
"End Tag": "Lopetus tagi",
"Endpoint URL": "Päätepiste verkko-osoite",
@ -630,7 +630,7 @@
"Enter Your Role": "Kirjoita roolisi",
"Enter Your Username": "Kirjoita käyttäjätunnuksesi",
"Enter your webhook URL": "Kirjoita webhook osoitteesi",
"Entra ID": "",
"Entra ID": "Entra ID",
"Error": "Virhe",
"ERROR": "VIRHE",
"Error accessing directory": "Virhe hakemistoa avattaessa",
@ -726,13 +726,13 @@
"Firecrawl API Key": "Firecrawl API-avain",
"Floating Quick Actions": "Kelluvat pikakomennot",
"Focus chat input": "Fokusoi syöttökenttään",
"Folder Background Image": "",
"Folder Background Image": "Kansion taustakuva",
"Folder deleted successfully": "Kansio poistettu onnistuneesti",
"Folder Name": "Kansion nimi",
"Folder name cannot be empty.": "Kansion nimi ei voi olla tyhjä.",
"Folder name updated successfully": "Kansion nimi päivitetty onnistuneesti",
"Folder updated successfully": "Kansio päivitettiin onnistuneesti",
"Folders": "",
"Folders": "Kansiot",
"Follow up": "Jatkokysymykset",
"Follow Up Generation": "Jatkokysymysten luonti",
"Follow Up Generation Prompt": "Jatkokysymysten luonti kehoite",
@ -1027,7 +1027,7 @@
"New Tool": "Uusi työkalu",
"new-channel": "uusi-kanava",
"Next message": "Seuraava viesti",
"No authentication": "",
"No authentication": "Ei todennusta",
"No chats found": "Keskuteluja ei löytynyt",
"No chats found for this user.": "Käyttäjän keskusteluja ei löytynyt.",
"No chats found.": "Keskusteluja ei löytynyt",
@ -1048,7 +1048,7 @@
"No models found": "Malleja ei löytynyt",
"No models selected": "Malleja ei ole valittu",
"No Notes": "Ei muistiinpanoja",
"No notes found": "",
"No notes found": "Muistiinpanoja ei löytynyt",
"No results": "Ei tuloksia",
"No results found": "Ei tuloksia",
"No search query generated": "Hakukyselyä ei luotu",
@ -1062,7 +1062,7 @@
"None": "Ei mikään",
"Not factually correct": "Ei faktuaalisesti oikein",
"Not helpful": "Ei hyödyllinen",
"Note": "",
"Note": "Muistiinpano",
"Note deleted successfully": "Muistiinpano poistettiin onnistuneesti",
"Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.": "Huomautus: Jos asetat vähimmäispistemäärän, haku palauttaa vain sellaiset asiakirjat, joiden pistemäärä on vähintään vähimmäismäärä.",
"Notes": "Muistiinpanot",
@ -1095,10 +1095,10 @@
"Oops! You're using an unsupported method (frontend only). Please serve the WebUI from the backend.": "Hups! Käytät ei-tuettua menetelmää (vain frontend). Palvele WebUI:ta backendistä.",
"Open file": "Avaa tiedosto",
"Open in full screen": "Avaa koko näytön tilaan",
"Open link": "",
"Open modal to configure connection": "Avaa modaaliikkuna yhteyden määrittämiseksi",
"Open Modal To Manage Floating Quick Actions": "Avaa modaaliikkuna kelluvien pikatoimintojen hallitsemiseksi",
"Open Modal To Manage Image Compression": "",
"Open link": "Avaa linkki",
"Open modal to configure connection": "Avaa modaali yhteyden määrittämiseksi",
"Open Modal To Manage Floating Quick Actions": "Avaa modaali kelluvien pikatoimintojen hallitsemiseksi",
"Open Modal To Manage Image Compression": "Avaa kuvien pakkaus hallinta modaali",
"Open new chat": "Avaa uusi keskustelu",
"Open Sidebar": "Avaa sivupalkki",
"Open User Profile Menu": "Avaa käyttäjäprofiili ikkuna",
@ -1218,7 +1218,7 @@
"Redirecting you to Open WebUI Community": "Ohjataan sinut OpenWebUI-yhteisöön",
"Reduces the probability of generating nonsense. A higher value (e.g. 100) will give more diverse answers, while a lower value (e.g. 10) will be more conservative.": "",
"Refer to yourself as \"User\" (e.g., \"User is learning Spanish\")": "Viittaa itseen \"Käyttäjänä\" (esim. \"Käyttäjä opiskelee espanjaa\")",
"Reference Chats": "",
"Reference Chats": "Viitekeskustelut",
"Refused when it shouldn't have": "Kieltäytyi, vaikka ei olisi pitänyt",
"Regenerate": "Regeneroi",
"Regenerate Menu": "Regenerointi ikkuna",
@ -1506,7 +1506,7 @@
"This will reset the knowledge base and sync all files. Do you wish to continue?": "Tämä nollaa tietokannan ja synkronoi kaikki tiedostot. Haluatko jatkaa?",
"Thorough explanation": "Perusteellinen selitys",
"Thought for {{DURATION}}": "Ajatteli {{DURATION}}",
"Thought for {{DURATION}} seconds": "Ajatteli {{DURATION}} sekunttia",
"Thought for {{DURATION}} seconds": "Ajatteli {{DURATION}} sekuntia",
"Thought for less than a second": "Ajatteli alle sekunnin",
"Thread": "Ketju",
"Tika": "Tika",
@ -1567,7 +1567,7 @@
"Unarchive All Archived Chats": "Pura kaikkien arkistoitujen keskustelujen arkistointi",
"Unarchive Chat": "Pura keskustelun arkistointi",
"Underline": "Alleviivaus",
"Unknown": "",
"Unknown": "Tuntematon",
"Unloads {{FROM_NOW}}": "Purkuja {{FROM_NOW}}",
"Unlock mysteries": "Selvitä arvoituksia",
"Unpin": "Irrota kiinnitys",
@ -1609,7 +1609,7 @@
"User Webhooks": "Käyttäjän Webhook:it",
"Username": "Käyttäjätunnus",
"Users": "Käyttäjät",
"Uses DefaultAzureCredential to authenticate": "",
"Uses DefaultAzureCredential to authenticate": "Käyttää DefaultAzureCredential todentamiseen",
"Using Entire Document": "Koko asiakirjan käyttäminen",
"Using Focused Retrieval": "Kohdennetun haun käyttäminen",
"Using the default arena model with all models. Click the plus button to add custom models.": "Käytetään oletusarena-mallia kaikkien mallien kanssa. Napsauta plus-painiketta lisätäksesi mukautettuja malleja.",

File diff suppressed because it is too large Load diff

View file

@ -16,8 +16,8 @@
"{{COUNT}} Replies": "{{COUNT}} n tririyin",
"{{COUNT}} Sources": "{{COUNT}} n yiɣbula",
"{{COUNT}} words": "{{COUNT}} n wawalen",
"{{LOCALIZED_DATE}} at {{LOCALIZED_TIME}}": "",
"{{model}} download has been canceled": "",
"{{LOCALIZED_DATE}} at {{LOCALIZED_TIME}}": "{{LOCALIZED_DATE}} ɣef {{LOCALIZED_TIME}}",
"{{model}} download has been canceled": "Azdam n {{model}} yettusemmet",
"{{user}}'s Chats": "Asqerdec n {{user}}",
"{{webUIName}} Backend Required": "",
"*Prompt node ID(s) are required for image generation": "",
@ -86,8 +86,8 @@
"Allow Chat Params": "Sireg iɣewwaren n udiwenni",
"Allow Chat Share": "Sireg beṭṭu n usqerdec",
"Allow Chat System Prompt": "Sireg aneftaɣ n unagraw n udiwenni",
"Allow Chat Valves": "",
"Allow Continue Response": "",
"Allow Chat Valves": "Sireg isegganen n udiwenni",
"Allow Continue Response": "Sireg akemmel n tririt",
"Allow Delete Messages": "Sireg tukksa n yiznan",
"Allow File Upload": "Sireg asali n yifuyla",
"Allow Multiple Models in Chat": "Sireg ugar n timudmiwin deg usqerdec",
@ -110,7 +110,7 @@
"Always Play Notification Sound": "Rmed yal tikkelt alɣu s ṣṣut",
"Amazing": "Igerrez",
"an assistant": "d amallal",
"An error occurred while fetching the explanation": "",
"An error occurred while fetching the explanation": "Teḍra-d tuccḍa lawan n tririt n usegzi",
"Analytics": "Tasleḍt",
"Analyzed": "Yettwasekyed",
"Analyzing...": "La yettwasekyad…",
@ -147,8 +147,8 @@
"Ask a question": "Efk-d asteqsi",
"Assistant": "Amallal",
"Attach file from knowledge": "Seddu afaylu seg taffa n tmusniwin",
"Attach Knowledge": "",
"Attach Notes": "",
"Attach Knowledge": "Qqen-as tamessunt",
"Attach Notes": "Qqen-as tizmilin",
"Attention to detail": "",
"Attribute for Mail": "",
"Attribute for Username": "",
@ -212,12 +212,12 @@
"Capture Audio": "Asekles n umeslaw",
"Certificate Path": "Abrid n uselkin",
"Change Password": "Snifel awal n uɛeddi",
"Channel": "",
"Channel deleted successfully": "",
"Channel": "Abadu",
"Channel deleted successfully": "Yettwakkes ubadu akken iwata",
"Channel Name": "Isem n ubadu",
"Channel updated successfully": "",
"Channel updated successfully": "Yettwaleqqem ubadu akken iwata",
"Channels": "Ibuda",
"Character": "asekkil",
"Character": "Asekkil",
"Character limit for autocomplete generation input": "",
"Chart new frontiers": "",
"Chat": "Asqerdec",
@ -358,7 +358,7 @@
"Custom Parameter Value": "Azal n uɣewwar udmawan",
"Danger Zone": "Tamnaḍt i iweɛren",
"Dark": "Aberkan",
"Data Controls": "",
"Data Controls": "Isenqaden n isefka",
"Database": "Taffa n isefka",
"Datalab Marker API": "API n Datalab Marker",
"Datalab Marker API Key required.": "API n isefkalab Marker Tesri tasarut.",
@ -371,7 +371,7 @@
"Default action buttons will be used.": "Tiqeffalin n tigawt tamezwart, ad tettwseqdac.",
"Default description enabled": "Aglam amezwar yermed",
"Default Features": "",
"Default Filters": "",
"Default Filters": "Imsizdigen imezwura",
"Default mode works with a wider range of models by calling tools once before execution. Native mode leverages the model's built-in tool-calling capabilities, but requires the model to inherently support this feature.": "",
"Default Model": "Tamudemt tamezwart",
"Default model updated": "Tamudemt amezwar, tettwaleqqem",
@ -487,7 +487,7 @@
"Edit Memory": "Ẓreg takatut",
"Edit User": "Ẓreg aseqdac",
"Edit User Group": "Ẓreg agraw n iseqdacen",
"edited": "",
"edited": "yettwaẓreg",
"Edited": "Yettwaẓrag",
"Editing": "Asiẓreg",
"Eject": "Ḍeqqer-d",
@ -534,7 +534,7 @@
"Enter comma-separated \"token:bias_value\" pairs (example: 5432:100, 413:-100)": "Kcem ɣer tyugiwin \"token:bias_value\" i d-yezgan gar-asent (amedya: 5432:100, 413:-100)",
"Enter Config in JSON format": "Kcem ɣer Config s umasal JSON",
"Enter content for the pending user info overlay. Leave empty for default.": "Sekcem agbur n telɣut n useqdac yettwaṛjan. Eǧǧ ilem i tazwara.",
"Enter coordinates (e.g. 51.505, -0.09)": "",
"Enter coordinates (e.g. 51.505, -0.09)": "Sekcem-d timsidag (amedya 51.505, -0.09)",
"Enter Datalab Marker API Base URL": "",
"Enter Datalab Marker API Key": "Sekcem API n isefkalab Marker Tasarut",
"Enter description": "Sekcem aglam",
@ -545,10 +545,10 @@
"Enter Document Intelligence Key": "Kcem ɣer tsarut n wulli",
"Enter domains separated by commas (e.g., example.com,site.org)": "Sekcem taɣulin yebḍan s tefrayin (amedya: example.com,site.org)",
"Enter Exa API Key": "Kcem ɣer Exa Tasarut",
"Enter External Document Loader API Key": "",
"Enter External Document Loader API Key": "Sekcem-d tasarut API n usezdam n isemliyen yeffɣen",
"Enter External Document Loader URL": "Sekcem tansa URL n ukaram n isemli imeṛṛa",
"Enter External Web Loader API Key": "Sekcem API n isebtar web imeṛṛa Tasarut",
"Enter External Web Loader URL": "",
"Enter External Web Loader URL": "Sekcem tansa URL n usezdam Web yeffɣen",
"Enter External Web Search API Key": "Sekcem-d tasarut API n unadi teffɣen ɣef Web",
"Enter External Web Search URL": "Sekcem-d tansa URL yeffɣen n unadi ɣef Web",
"Enter Firecrawl API Base URL": "Sekcem tansa URL n taffa n isefka API",
@ -621,7 +621,7 @@
"Enter your current password": "Sekcem awal-ik·im n uɛeddi amiran",
"Enter Your Email": "Sekcem-d imayl-ik·im",
"Enter Your Full Name": "Sekcem isem n uneftaɣ-ik⋅im",
"Enter your gender": "",
"Enter your gender": "Sekcem-d tazzuft-ik·im",
"Enter your message": "Sekcem-d izen-ik⋅im",
"Enter your name": "Sekcem-d isem-ik·im",
"Enter Your Name": "Sekcem-d isem-ik·im",
@ -726,13 +726,13 @@
"Firecrawl API Key": "Tasarut API n Firecrawl",
"Floating Quick Actions": "Tigawin tiruradin yettifliwen",
"Focus chat input": "Err asaḍes ɣer unekcum n udiwenni",
"Folder Background Image": "",
"Folder Background Image": "Tugna n ugilal n ukaram",
"Folder deleted successfully": "Akaram-nni yettwakkes akken iwata",
"Folder Name": "Isem n ukaram",
"Folder name cannot be empty.": "Isem n ukaram ur yezmir ara ad yili d ilem.",
"Folder name updated successfully": "Isem n ukaram yettwaleqqem akken iwata",
"Folder updated successfully": "Akaram yettwaleqqem akken iwata",
"Folders": "",
"Folders": "Ikaramen",
"Follow up": "Aḍfaṛ",
"Follow Up Generation": "Ḍfer asirew",
"Follow Up Generation Prompt": "Ḍfer tiwtilin n tsuta",
@ -766,7 +766,7 @@
"Gemini": "Gemini",
"Gemini API Config": "Tawila n API Gemini",
"Gemini API Key is required.": "API Yessefk tsarut.",
"Gender": "",
"Gender": "Tazzuft",
"General": "Amatu",
"Generate": "Sirew",
"Generate an image": "Sarew tugna",
@ -849,7 +849,7 @@
"Includes SharePoint": "Igber SharePoint",
"Influences how quickly the algorithm responds to feedback from the generated text. A lower learning rate will result in slower adjustments, while a higher learning rate will make the algorithm more responsive.": "",
"Info": "Talɣut",
"Initials": "",
"Initials": "Isekkilen imezwura",
"Inject the entire content as context for comprehensive processing, this is recommended for complex queries.": "",
"Input": "Anekcum",
"Input commands": "Tiludna n unekcum",
@ -861,13 +861,13 @@
"Insert Suggestion Prompt to Input": "",
"Install from Github URL": "Sebded seg tansa URL n Github",
"Instant Auto-Send After Voice Transcription": "Arfiq awurman ticki Voice Transcription",
"Integration": "Aseddu",
"Integrations": "",
"Integration": "Tamsideft",
"Integrations": "Timsidaf",
"Interface": "Agrudem",
"Invalid file content": "Agbur n ufaylu d arameɣtu",
"Invalid file format.": "Amasal n ufaylu d arameɣtu.",
"Invalid JSON file": "Afaylu JSON arameɣtu",
"Invalid JSON format for ComfyUI Workflow.": "",
"Invalid JSON format for ComfyUI Workflow.": "Amasal JSON d arameɣtu i ComfyUI Workflow.",
"Invalid JSON format in Additional Config": "Amasal JSON arameɣtu deg usesteb niḍen",
"Invalid Tag": "Tabzimt d tarameɣtut",
"is typing...": "yettaru…",
@ -921,7 +921,7 @@
"Leave empty to include all models or select specific models": "Eǧǧ-it d ilem akken ad ternuḍ akk timudmiwin neɣ ad tferneḍ timudmiwin tulmisin",
"Leave empty to use the default prompt, or enter a custom prompt": "Eǧǧ ilem akken ad tesqedceḍ tawelt-nni tamezwarut, neɣ ad tkecmeḍ ɣer tannumi s tɣawla n tannumi",
"Leave model field empty to use the default model.": "Eǧǧ iger n tmudemt d ilem i useqdec n tmudemt tamezwarut.",
"Legacy": "",
"Legacy": "Aqbur",
"lexical": "Amawal",
"License": "Turagt",
"Lift List": "Tabdart n usali",
@ -931,7 +931,7 @@
"LLMs can make mistakes. Verify important information.": "LLMs yezmer ad yecceḍ. Senqed talɣut yesɛan azal.",
"Loader": "Asalay",
"Loading Kokoro.js...": "Aêbbi n Kokoro.js…",
"Loading...": "Aêbbi n...",
"Loading...": "Aɛebbi n...",
"Local": "Adigan",
"Local Task Model": "Tamudemt n temsekrit tadigant",
"Location access not allowed": "Anekcum ɣer tuddna",
@ -1048,7 +1048,7 @@
"No models found": "Ulac timudmiwin yettwafen",
"No models selected": "Ulac timudmin yettwafernen",
"No Notes": "Ulac tizmilin",
"No notes found": "",
"No notes found": "Ulac tizmilin yettwafen",
"No results": "Ulac igmaḍ yettwafen",
"No results found": "Ulac igmaḍ yettwafen",
"No search query generated": "Ulac tuttra n unadi yettusirwen",
@ -1056,13 +1056,13 @@
"No sources found": "Ulac iɣbula yettwafen",
"No suggestion prompts": "Ulac isumar n prompt",
"No users were found.": "Ulac aqeddac i yettwafen.",
"No valves": "",
"No valves to update": "",
"No valves": "Ulac isegganen",
"No valves to update": "Ulac isegganen ara yettuleqmen",
"Node Ids": "",
"None": "Ula d yiwen",
"Not factually correct": "",
"Not helpful": "Ur infiɛ ara",
"Note": "",
"Note": "Tazmilt",
"Note deleted successfully": "Tazmilt tettwakkes akken iwata",
"Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.": "",
"Notes": "Tizmilin",
@ -1095,7 +1095,7 @@
"Oops! You're using an unsupported method (frontend only). Please serve the WebUI from the backend.": "Ayhuh! Aql-ik tesseqdaceḍ tarrayt ur yettwasefraken ara (mazwar kan). Ma ulac aɣilif, mudd-d WebUI seg uɛrur.",
"Open file": "Ldi Afaylu",
"Open in full screen": "Ldi deg ugdil aččuran",
"Open link": "",
"Open link": "Ldi aseɣwen",
"Open modal to configure connection": "Ldi asfaylu akken ad teswel tuqqna",
"Open Modal To Manage Floating Quick Actions": "",
"Open Modal To Manage Image Compression": "",
@ -1178,7 +1178,7 @@
"Port": "Tawwurt",
"Positive attitude": "",
"Prefer not to say": "Smenyafeɣ ur d-qqareɣ ara",
"Prefix ID": "",
"Prefix ID": "Asulay ID n uzwir",
"Prefix ID is used to avoid conflicts with other connections by adding a prefix to the model IDs - leave empty to disable": "Asulay n uzwir yettusexdem i wakken ur d-yettili ara umennuɣ akked tuqqna-nniḍen s tmerna n usewgelhen i yimuhal n tmudemt - eǧǧ-iten d ilmawen i tukksa n tuqqna",
"Prevent file creation": "Gmen timerna n ufaylu",
"Preview": "Taskant",
@ -1218,11 +1218,11 @@
"Redirecting you to Open WebUI Community": "Aseḍfeṛ ar Temɣiwant n Open WebUI",
"Reduces the probability of generating nonsense. A higher value (e.g. 100) will give more diverse answers, while a lower value (e.g. 10) will be more conservative.": "",
"Refer to yourself as \"User\" (e.g., \"User is learning Spanish\")": "Err iman-ik d \"Aseqdac\" (amedya, \"Aseqdac ilemmed taspenyulit\")",
"Reference Chats": "",
"Reference Chats": "Mselɣu idiwenniyen",
"Refused when it shouldn't have": "",
"Regenerate": "Asirew",
"Regenerate Menu": "",
"Reindex": "",
"Reindex": "Alus n usmiter",
"Reindex Knowledge Base Vectors": "Tamusni Izegza",
"Release Notes": "Tizmilin n lqem",
"Releases": "Ileqman",
@ -1239,7 +1239,7 @@
"Rename": "Snifel isem",
"Reorder Models": "Ales n umizwer n tmudmiwin",
"Reply in Thread": "Err deg usqerdec",
"required": "",
"required": "yettwasra",
"Reranking Engine": "",
"Reranking Model": "",
"Reset": "Wennez",
@ -1336,12 +1336,12 @@
"Select an output format": "Fren amasal n tuffɣa",
"Select dtype": "Fren dtype",
"Select Engine": "Fren amsadday",
"Select how to split message text for TTS requests": "",
"Select how to split message text for TTS requests": "Fren amek ara tebḍuḍ aḍris n yiznan i usuter n TTS",
"Select Knowledge": "Fren tamusni",
"Select only one model to call": "Fren yiwet kan n tmudemt i wara d-siwleḍ",
"Selected model(s) do not support image inputs": "Ammud(s) yettwafernen ur yessefrak ara inekcamen n tugniwin yettwafernen",
"semantic": "tasnamekt",
"Send": "Tuzna",
"Send": "Ceyyeɛ",
"Send a Message": "Ceyyeɛ izen",
"Send message": "Azen izen",
"Sends `stream_options: { include_usage: true }` in the request.\nSupported providers will return token usage information in the response when set.": "",
@ -1444,7 +1444,7 @@
"System": "Anagraw",
"System Instructions": "Tanaḍin n unagraw",
"System Prompt": "Aneftaɣ n unagraw",
"Table Mode": "",
"Table Mode": "Askar n tfelwit",
"Tags": "Tibzimin",
"Tags Generation": "Asirew n tebzimin",
"Tags Generation Prompt": "Aneftaɣ n usirew n tebzimin",
@ -1460,7 +1460,7 @@
"Temperature": "Tazɣelt",
"Temporary Chat": "Asqerdec akudan",
"Temporary Chat by Default": "Asqerdec uɛḍil s wudem amezwer",
"Text Splitter": "",
"Text Splitter": "Amebḍay n uḍris",
"Text-to-Speech": "Aḍris-ɣer-taɣect",
"Text-to-Speech Engine": "Amsadday n TTS",
"Thanks for your feedback!": "Tanemmirt ɣef tikti-inek·inem!",
@ -1485,14 +1485,14 @@
"The Weight of BM25 Hybrid Search. 0 more lexical, 1 more semantic. Default 0.5": "",
"The width in pixels to compress images to. Leave empty for no compression.": "Taɣessa deg yipiksilen akken ad tessedhu tugniwin. Eǧǧ ilem war aḥezzeb.",
"Theme": "Asentel",
"Thinking...": "Ttxemmimeɣ…",
"Thinking...": "Yettxemmim…",
"This action cannot be undone. Do you wish to continue?": "Tigawt-a ur tettwakkes ara. Tebɣiḍ ad tkemmleḍ?",
"This channel was created on {{createdAt}}. This is the very beginning of the {{channelName}} channel.": "Abadu-a yettwasnulaf-d deg {{created} Ɣef}}. D ta i d tazwara maḍi n ubadu {{channelName}}.",
"This chat won't appear in history and your messages will not be saved.": "Aqeṣṣer-a ur d-yettban deg umezruy yerna iznan-nnek ur ttwaselkamen.",
"This ensures that your valuable conversations are securely saved to your backend database. Thank you!": "D ayen ara yeǧǧen adiwenni-inek s wazal-is ad yettwasellek s tɣellist deg taffa n yisefka-inek n deffir. Tanemmirt!",
"This feature is experimental and may be modified or discontinued without notice.": "Tamahilt-a d tirmitant yerna tezmer ad tettwabeddel neɣ ad teḥbes war tamawt.",
"This is an experimental feature, it may not function as expected and is subject to change at any time.": "Ta d taɣawsa tirmitant, yezmer lḥal ur tleḥḥu ara akken i tebɣiḍ, dɣa d asentel n ubeddel melmi tebɣiḍ.",
"This model is not publicly available. Please select another model.": "",
"This model is not publicly available. Please select another model.": "Tamudemt-a ur telli d tazayezt akk i medden. Ttxil-k·m, fren tamudemt nniḍen.",
"This option controls how long the model will stay loaded into memory following the request (default: 5m)": "",
"This option controls how many tokens are preserved when refreshing the context. For example, if set to 2, the last 2 tokens of the conversation context will be retained. Preserving context can help maintain the continuity of a conversation, but it may reduce the ability to respond to new topics.": "",
"This option enables or disables the use of the reasoning feature in Ollama, which allows the model to think before generating a response. When enabled, the model can take a moment to process the conversation context and generate a more thoughtful response.": "Tifrat-a ad teǧǧ neɣ ad tessenqes aseqdec n tmahilt n usseɣẓen deg Ollama, ayen yettaǧǧan tamudemt ad txemmem uqbel ad d-tawi tiririt. Ma yessaweḍ yiwen, tamudemt tezmer ad teṭṭef cwiṭ n wakud akken ad tseddu asatal n umeslay u ad d-tawi tiririt yettxemmimen ugar.",
@ -1519,17 +1519,17 @@
"Title Generation": "Asirew n uzwel",
"Title Generation Prompt": "Aneftaɣ n usirew n uzwel",
"TLS": "TLS",
"To access the available model names for downloading,": "",
"To access the available model names for downloading,": "Akken ad tkecmeḍ ɣer yismawen n tmudmiwin yellan i uzdam,",
"To access the GGUF models available for downloading,": "Akken ad tkecmeḍ ɣer tmudmin GGUF yellan i usader,",
"To access the WebUI, please reach out to the administrator. Admins can manage user statuses from the Admin Panel.": "Akken ad tkecmeḍ ɣer WebUI, ttxil-k, nermes anedbal. Imedminen zemren ad sselḥun addaden n useqdac seg ugalis Admin.",
"To attach knowledge base here, add them to the \"Knowledge\" workspace first.": "Akken ad teqqneḍ taffa n tmessunin da, rnu-tent ɣer \"Timessunin\" n temnaḍṭ n umahil, di tazwara.",
"To learn more about available endpoints, visit our documentation.": "Akken ad tissineḍ ugar ɣef wagazen n taggara yellan, rzu ɣer warrat-nneɣ.",
"To learn more about powerful prompt variables, click here": "",
"To learn more about powerful prompt variables, click here": "I wakken ad tissineḍ ugar ɣef yimuttiyen n ineftaɣen iǧehden, sit da",
"To protect your privacy, only ratings, model IDs, tags, and metadata are shared from your feedback—your chat logs remain private and are not included.": "Akken ad tḥerzeḍ tudert-ik tabaḍnit, ala ṭṭubbat, isulayen n tmudemt, tibzimin, akked metadata ttwabḍant ɣef tikti-k — iɣmisen-ik n udiwenni qqimen d usligen, ur d-ddan ara.",
"To select toolkits here, add them to the \"Tools\" workspace first.": "",
"To select toolkits here, add them to the \"Tools\" workspace first.": "Akken ad tferneḍ ifecka da, rnu-ten, di tazwara, ɣer tallunt n umahil \"Ifecka\".",
"Toast notifications for new updates": "Ssurfet ilɣa i yileqman imaynuten",
"Today": "Ass-a",
"Today at {{LOCALIZED_TIME}}": "",
"Today at {{LOCALIZED_TIME}}": "Ass-a, ɣef {{LOCALIZED_TIME}}",
"Toggle search": "Ldi/Ffer anadi",
"Toggle settings": "Sken/Ffer iɣewwaren",
"Toggle sidebar": "Ldi/Mdel afeggag adisan",
@ -1567,7 +1567,7 @@
"Unarchive All Archived Chats": "",
"Unarchive Chat": "",
"Underline": "Derrer",
"Unknown": "",
"Unknown": "D arussin",
"Unloads {{FROM_NOW}}": "",
"Unlock mysteries": "",
"Unpin": "Kkes asenteḍ",
@ -1596,7 +1596,7 @@
"URL is required": "Tlaq tansa URL",
"URL Mode": "Askar n URL",
"Usage": "Aseqdec",
"Use '#' in the prompt input to load and include your knowledge.": "",
"Use '#' in the prompt input to load and include your knowledge.": "Seqdec '#' deg urti n usekcem n uneftaɣ i wakken ad tɛebbiḍ timessunin-inek·inem.",
"Use groups to group your users and assign permissions.": "",
"Use LLM": "Seqdec LLM",
"Use no proxy to fetch page contents.": "Ur sseqdacet ara ayen yellan deg usebter apṛuksi.",
@ -1615,8 +1615,8 @@
"Using the default arena model with all models. Click the plus button to add custom models.": "Aseqdec n tmudemt n uzna amezwer s yal timudmin. Tekki ɣef tqeffalt-nni n tmerniwt akken ad ternuḍ timudmin tinsayanin.",
"Valid time units:": "Tigget n wakud ameɣtu:",
"Validate certificate": "Seɣbel aselkin",
"Valves": "",
"Valves updated": "",
"Valves": "Isegganen",
"Valves updated": "Isegganen ttwaleqmen",
"Valves updated successfully": "Valves ttwaleqmen akken iwata",
"variable": "tamattayt",
"Verify Connection": "Senqed tuqqna",
@ -1667,12 +1667,12 @@
"Write a prompt suggestion (e.g. Who are you?)": "Aru-d assumer i d-yeffɣen s tɣawla (amedya, anwa-k?)",
"Write a summary in 50 words that summarizes [topic or keyword].": "Aru agzul s 50 n wawalen i yessewzalen [asefru neɣ tasarut].",
"Write something...": "Aru kra…",
"Write your model system prompt content here\ne.g.) You are Mario from Super Mario Bros, acting as an assistant.": "",
"Write your model system prompt content here\ne.g.) You are Mario from Super Mario Bros, acting as an assistant.": "Ru da, agbur-ik·im n unagraw i uneftaɣ n tmudemt\nAmedya.) Kečč d Mario seg Super Mario Bros, txeddmeḍ amzun d amalal.",
"Yacy Instance URL": "URL n tummant Yacy",
"Yacy Password": "Awal n uɛeddi n Yacy",
"Yacy Username": "Isem n useqdac Yacy",
"Yesterday": "Iḍelli",
"Yesterday at {{LOCALIZED_TIME}}": "",
"Yesterday at {{LOCALIZED_TIME}}": "Iḍelli, ɣef {{LOCALIZED_TIME}}",
"You": "Kečč·mm",
"You are currently using a trial license. Please contact support to upgrade your license.": "Imir-a, aql-ik tesseqdaced turagt n ccṛeɛ. Ma ulac aɣilif, dduklet akken ad tesselhum turagt-nwen.",
"You can only chat with a maximum of {{maxCount}} file(s) at a time.": "Tzemreḍ kan ad tqeṣṣreḍ s afellay n ufaylu(s) n {{maxCount}} ɣef tikelt.",

View file

@ -14,14 +14,14 @@
"{{COUNT}} extracted lines": "",
"{{COUNT}} hidden lines": "숨겨진 줄 {{COUNT}}개",
"{{COUNT}} Replies": "답글 {{COUNT}}개",
"{{COUNT}} Sources": "",
"{{COUNT}} Sources": "{{COUNT}}개의 소스",
"{{COUNT}} words": "{{COUNT}} 단어",
"{{LOCALIZED_DATE}} at {{LOCALIZED_TIME}}": "",
"{{model}} download has been canceled": "",
"{{model}} download has been canceled": "{{model}} 다운로드가 취소되었습니다.",
"{{user}}'s Chats": "{{user}}의 채팅",
"{{webUIName}} Backend Required": "{{webUIName}} 백엔드가 필요합니다.",
"*Prompt node ID(s) are required for image generation": "이미지 생성에는 프롬프트 노드 ID가 필요합니다.",
"1 Source": "",
"1 Source": "소스1",
"A new version (v{{LATEST_VERSION}}) is now available.": "새로운 버전 (v{{LATEST_VERSION}})을 사용할 수 있습니다.",
"A task model is used when performing tasks such as generating titles for chats and web search queries": "작업 모델은 채팅 및 웹 검색 쿼리에 대한 제목 생성 등의 작업 수행 시 사용됩니다.",
"a user": "사용자",
@ -32,10 +32,10 @@
"Accessible to all users": "모든 사용자가 이용할 수 있음",
"Account": "계정",
"Account Activation Pending": "계정 활성화 대기",
"accurate": "",
"accurate": "정확도",
"Accurate information": "정확한 정보",
"Action": "액션",
"Action not found": "",
"Action": "작업",
"Action not found": "작업을 찾을 수 없습니다.",
"Action Required for Chat Log Storage": "채팅 로그 저장을 위해 조치가 필요합니다",
"Actions": "작업",
"Activate": "활성화",
@ -186,8 +186,8 @@
"Beta": "베타",
"Bing Search V7 Endpoint": "Bing Search V7 엔드포인트",
"Bing Search V7 Subscription Key": "Bing Search V7 구독 키",
"Bio": "",
"Birth Date": "",
"Bio": "소개",
"Birth Date": "생년월일",
"BM25 Weight": "BM25 가중치",
"Bocha Search API Key": "Bocha Search API 키",
"Bold": "굵게",
@ -285,7 +285,7 @@
"ComfyUI Base URL is required.": "ComfyUI 기본 URL이 필요합니다.",
"ComfyUI Workflow": "ComfyUI 워크플로",
"ComfyUI Workflow Nodes": "ComfyUI 워크플로 노드",
"Comma separated Node Ids (e.g. 1 or 1,2)": "",
"Comma separated Node Ids (e.g. 1 or 1,2)": "쉼표로 구분된 노드 아이디(예: 1 또는 1,2)",
"Command": "명령",
"Comment": "주석",
"Completions": "완성됨",
@ -295,7 +295,7 @@
"Configure": "구성",
"Confirm": "확인",
"Confirm Password": "비밀번호 확인",
"Confirm your action": "액션 확인",
"Confirm your action": "작업 확인",
"Confirm your new password": "새로운 비밀번호를 한 번 더 입력해 주세요",
"Confirm Your Password": "비밀번호를 확인해주세요",
"Connect to your own OpenAI compatible API endpoints.": "OpenAI 호환 API 엔드포인트에 연결합니다.",
@ -358,7 +358,7 @@
"Custom Parameter Value": "사용자 정의 매개변수 값",
"Danger Zone": "위험 기능",
"Dark": "다크",
"Data Controls": "",
"Data Controls": "데이터 제어",
"Database": "데이터베이스",
"Datalab Marker API": "Datalab Marker API",
"Datalab Marker API Key required.": "Datalab Marker API 키가 필요합니다.",
@ -368,10 +368,10 @@
"Default": "기본값",
"Default (Open AI)": "기본값 (Open AI)",
"Default (SentenceTransformers)": "기본값 (SentenceTransformers)",
"Default action buttons will be used.": "기본 액션 버튼이 사용됩니다.",
"Default action buttons will be used.": "기본 작업 버튼이 사용됩니다.",
"Default description enabled": "기본 설명 활성화됨",
"Default Features": "",
"Default Filters": "",
"Default Filters": "기본 필터",
"Default mode works with a wider range of models by calling tools once before execution. Native mode leverages the model's built-in tool-calling capabilities, but requires the model to inherently support this feature.": "기본 모드는 실행 전에 도구를 한 번 호출하여 더 다양한 모델에서 작동합니다. 기본 모드는 모델에 내장된 도구 호출 기능을 활용하지만, 모델이 이 기능을 본질적으로 지원해야 합니다.",
"Default Model": "기본 모델",
"Default model updated": "기본 모델이 업데이트되었습니다.",
@ -612,8 +612,8 @@
"Enter Top K Reranker": "Top K 리랭커 입력",
"Enter URL (e.g. http://127.0.0.1:7860/)": "URL 입력(예: http://127.0.0.1:7860/)",
"Enter URL (e.g. http://localhost:11434)": "URL 입력(예: http://localhost:11434)",
"Enter value": "",
"Enter value (true/false)": "",
"Enter value": "값 입력",
"Enter value (true/false)": "값 입력(true/false)",
"Enter Yacy Password": "Yacy 비밀번호 입력",
"Enter Yacy URL (e.g. http://yacy.example.com:8090)": "Yacy URL 입력(예: http://yacy.example.com:8090)",
"Enter Yacy Username": "Yacy 사용자 이름 입력",
@ -621,7 +621,7 @@
"Enter your current password": "현재 비밀번호를 입력해 주세요",
"Enter Your Email": "이메일 입력",
"Enter Your Full Name": "전체 이름 입력",
"Enter your gender": "",
"Enter your gender": "성별 입력",
"Enter your message": "메세지 입력",
"Enter your name": "이름 입력",
"Enter Your Name": "이름 입력",
@ -674,7 +674,7 @@
"External": "외부",
"External Document Loader URL required.": "외부 문서 로더 URL이 필요합니다.",
"External Task Model": "외부 작업 모델",
"External Tools": "",
"External Tools": "외부 도구",
"External Web Loader API Key": "외부 웹 로더 API 키",
"External Web Loader URL": "외부 웹 로더 URL",
"External Web Search API Key": "외부 웹 검색 API 키",
@ -1013,7 +1013,7 @@
"More": "더보기",
"More Concise": "더 간결하게",
"More Options": "추가 설정",
"Move": "",
"Move": "이동",
"Name": "이름",
"Name and ID are required, please fill them out": "",
"Name your knowledge base": "지식 기반 이름을 지정하세요",
@ -1048,7 +1048,7 @@
"No models found": "모델 없음",
"No models selected": "모델 선택 안됨",
"No Notes": "노트 없음",
"No notes found": "",
"No notes found": "노트를 찾을 수 없음",
"No results": "결과 없음",
"No results found": "결과 없음",
"No search query generated": "검색어가 생성되지 않았습니다.",
@ -1062,7 +1062,7 @@
"None": "없음",
"Not factually correct": "사실상 맞지 않음",
"Not helpful": "도움이 되지않음",
"Note": "",
"Note": "노트",
"Note deleted successfully": "노트가 성공적으로 삭제되었습니다",
"Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.": "참고: 최소 점수를 설정하면, 검색 결과로 최소 점수 이상의 점수를 가진 문서만 반환합니다.",
"Notes": "노트",
@ -1095,7 +1095,7 @@
"Oops! You're using an unsupported method (frontend only). Please serve the WebUI from the backend.": "이런! 지원되지 않는 방식(프론트엔드만)을 사용하고 계십니다. 백엔드에서 WebUI를 제공해주세요.",
"Open file": "파일 열기",
"Open in full screen": "전체화면으로 열기",
"Open link": "",
"Open link": "링크 열기",
"Open modal to configure connection": "연결 설정 열기",
"Open Modal To Manage Floating Quick Actions": "",
"Open Modal To Manage Image Compression": "",
@ -1166,7 +1166,7 @@
"Playwright WebSocket URL": "Playwright WebSocket URL",
"Please carefully review the following warnings:": "다음 주의를 조심히 확인해주십시오",
"Please do not close the settings page while loading the model.": "모델을 로드하는 동안 설정 페이지를 닫지 마세요.",
"Please enter a message or attach a file.": "",
"Please enter a message or attach a file.": "메시지를 입력하거나 파일을 첨부해 주세요.",
"Please enter a prompt": "프롬프트를 입력해주세요",
"Please enter a valid path": "유효한 경로를 입력하세요",
"Please enter a valid URL": "유효한 URL을 입력하세요",
@ -1258,7 +1258,7 @@
"Retrieval Query Generation": "검색 쿼리 생성",
"Retrieved {{count}} sources": "",
"Retrieved {{count}} sources_other": "",
"Retrieved 1 source": "",
"Retrieved 1 source": "검색된 source 1개",
"Rich Text Input for Chat": "다양한 텍스트 서식 사용",
"RK": "RK",
"Role": "역할",
@ -1312,7 +1312,7 @@
"Seed": "시드",
"Select": "",
"Select a base model": "기본 모델 선택",
"Select a base model (e.g. llama3, gpt-4o)": "기본 모델 선택 (예: llama3, gpt-4o)",
"Select a base model (e.g. llama3, gpt-4o)": "기본 모델 선택 (예: llama3, gpt-4o)",
"Select a conversation to preview": "대화를 선택하여 미리 보기",
"Select a engine": "엔진 선택",
"Select a function": "함수 선택",
@ -1320,7 +1320,7 @@
"Select a language": "언어 선택",
"Select a mode": "모드 선택",
"Select a model": "모델 선택",
"Select a model (optional)": "모델 선택 (선택 사항)",
"Select a model (optional)": "모델 선택 (선택사항)",
"Select a pipeline": "파이프라인 선택",
"Select a pipeline url": "파이프라인 URL 선택",
"Select a reranking model engine": "",
@ -1332,8 +1332,8 @@
"Select an embedding model engine": "",
"Select an engine": "",
"Select an Ollama instance": "Ollama 인스턴스 선택",
"Select an output format": "",
"Select dtype": "",
"Select an output format": "출력 형식 선택",
"Select dtype": "dtype 선택",
"Select Engine": "엔진 선택",
"Select how to split message text for TTS requests": "",
"Select Knowledge": "지식 기반 선택",
@ -1401,7 +1401,7 @@
"sk-1234": "",
"Skip Cache": "캐시 무시",
"Skip the cache and re-run the inference. Defaults to False.": "캐시를 무시하고 추론을 다시 실행합니다. 기본값은 False입니다.",
"Something went wrong :/": "",
"Something went wrong :/": "무언가 잘못 되었습니다",
"Sonar": "",
"Sonar Deep Research": "",
"Sonar Pro": "",
@ -1416,8 +1416,8 @@
"Speech-to-Text Engine": "음성-텍스트 변환 엔진",
"standard": "",
"Start of the channel": "채널 시작",
"Start Tag": "",
"Status Updates": "",
"Start Tag": "시작 태그",
"Status Updates": "상태 업데이트",
"STDOUT/STDERR": "STDOUT/STDERR",
"Stop": "정지",
"Stop Generating": "생성 중지",
@ -1432,7 +1432,7 @@
"Stylized PDF Export": "서식이 적용된 PDF 내보내기",
"Subtitle (e.g. about the Roman Empire)": "자막 (예: 로마 황제)",
"Success": "성공",
"Successfully imported {{userCount}} users.": "",
"Successfully imported {{userCount}} users.": "성공적으로 {{userCount}}명의 사용자를 가져왔습니다.",
"Successfully updated.": "성공적으로 업데이트되었습니다.",
"Suggest a change": "변경 제안",
"Suggested": "제안",
@ -1508,8 +1508,8 @@
"Thought for {{DURATION}} seconds": "{{DURATION}}초 동안 생각함",
"Thought for less than a second": "1초 미만 동안 생각함",
"Thread": "스레드",
"Tika": "티카(Tika)",
"Tika Server URL required.": "티카 서버 URL이 필요합니다.",
"Tika": "",
"Tika Server URL required.": "Tika 서버 URL이 필요합니다.",
"Tiktoken": "틱토큰 (Tiktoken)",
"Title": "제목",
"Title (e.g. Tell me a fun fact)": "제목 (예: 재미있는 사실을 알려주세요.)",
@ -1528,7 +1528,7 @@
"To select toolkits here, add them to the \"Tools\" workspace first.": "여기서 도구를 선택하려면, \"도구\" 워크스페이스에 먼저 추가하세요.",
"Toast notifications for new updates": "새 업데이트 알림",
"Today": "오늘",
"Today at {{LOCALIZED_TIME}}": "",
"Today at {{LOCALIZED_TIME}}": "오늘 {{LOCALIZED_TIME}}",
"Toggle search": "검색 전환",
"Toggle settings": "설정 전환",
"Toggle sidebar": "사이드바 전환",
@ -1592,7 +1592,7 @@
"Upload Progress": "업로드 진행 상황",
"Upload Progress: {{uploadedFiles}}/{{totalFiles}} ({{percentage}}%)": "",
"URL": "URL",
"URL is required": "",
"URL is required": "URL이 필요합니다.",
"URL Mode": "URL 모드",
"Usage": "사용량",
"Use '#' in the prompt input to load and include your knowledge.": "프롬프트 입력에서 '#'를 사용하여 지식 기반을 불러오고 포함하세요.",
@ -1671,7 +1671,7 @@
"Yacy Password": "Yacy 비밀번호",
"Yacy Username": "Yacy 사용자 이름",
"Yesterday": "어제",
"Yesterday at {{LOCALIZED_TIME}}": "",
"Yesterday at {{LOCALIZED_TIME}}": "어제 {{LOCALIZED_TIME}}",
"You": "당신",
"You are currently using a trial license. Please contact support to upgrade your license.": "현재 평가판 라이선스를 사용 중입니다. 라이선스를 업그레이드하려면 지원팀에 문의하세요.",
"You can only chat with a maximum of {{maxCount}} file(s) at a time.": "최대 {{maxCount}}개의 파일과만 동시에 대화할 수 있습니다 ",
@ -1682,7 +1682,7 @@
"You have shared this chat": "이 채팅을 공유했습니다.",
"You're a helpful assistant.": "당신은 유용한 어시스턴트입니다.",
"You're now logged in.": "로그인되었습니다.",
"Your Account": "",
"Your Account": "계정",
"Your account status is currently pending activation.": "현재 계정은 아직 활성화되지 않았습니다.",
"Your entire contribution will go directly to the plugin developer; Open WebUI does not take any percentage. However, the chosen funding platform might have its own fees.": "당신의 모든 기여는 곧바로 플러그인 개발자에게 갑니다; Open WebUI는 수수료를 받지 않습니다. 다만, 선택한 후원 플랫폼은 수수료를 가져갈 수 있습니다.",
"YouTube": "유튜브",

File diff suppressed because it is too large Load diff

View file

@ -31,12 +31,10 @@ class OneDriveConfig {
}
private async getCredentials(): Promise<void> {
const headers: HeadersInit = {
'Content-Type': 'application/json'
};
const response = await fetch('/api/config', {
headers,
headers: {
'Content-Type': 'application/json'
},
credentials: 'include'
});
@ -46,17 +44,14 @@ class OneDriveConfig {
const config = await response.json();
const newClientId = config.onedrive?.client_id;
const newSharepointUrl = config.onedrive?.sharepoint_url;
const newSharepointTenantId = config.onedrive?.sharepoint_tenant_id;
this.clientIdPersonal = config.onedrive?.client_id_personal;
this.clientIdBusiness = config.onedrive?.client_id_business;
this.sharepointUrl = config.onedrive?.sharepoint_url;
this.sharepointTenantId = config.onedrive?.sharepoint_tenant_id;
if (!newClientId) {
throw new Error('OneDrive configuration is incomplete');
if (!this.newClientIdPersonal && !this.newClientIdBusiness) {
throw new Error('OneDrive client ID not configured');
}
this.clientId = newClientId;
this.sharepointUrl = newSharepointUrl;
this.sharepointTenantId = newSharepointTenantId;
}
public async getMsalInstance(
@ -69,10 +64,20 @@ class OneDriveConfig {
this.currentAuthorityType === 'organizations'
? this.sharepointTenantId || 'common'
: 'consumers';
const clientId =
this.currentAuthorityType === 'organizations'
? this.clientIdBusiness
: this.clientIdPersonal;
if (!clientId) {
throw new Error('OneDrive client ID not configured');
}
const msalParams = {
auth: {
authority: `https://login.microsoftonline.com/${authorityEndpoint}`,
clientId: this.clientId
clientId: clientId
}
};