"...gpu/git@developer.sourcefind.cn:gaoqiong/migraphx.git" did not exist on "0325c1a4005ccd6a1fc77c30919a0b86749b519f"
Unverified Commit 72354e06 authored by Timothy Jaeryang Baek's avatar Timothy Jaeryang Baek Committed by GitHub
Browse files

Merge pull request #2476 from open-webui/dev

0.2.0
parents 36e2a5e6 207e2503
...@@ -3,23 +3,25 @@ ...@@ -3,23 +3,25 @@
"(Beta)": "(测试版)", "(Beta)": "(测试版)",
"(e.g. `sh webui.sh --api`)": "(例如 `sh webui.sh --api`)", "(e.g. `sh webui.sh --api`)": "(例如 `sh webui.sh --api`)",
"(latest)": "(最新版)", "(latest)": "(最新版)",
"{{ models }}": "",
"{{ owner }}: You cannot delete a base model": "",
"{{modelName}} is thinking...": "{{modelName}} 正在思考...", "{{modelName}} is thinking...": "{{modelName}} 正在思考...",
"{{user}}'s Chats": "{{user}} 的聊天记录", "{{user}}'s Chats": "{{user}} 的聊天记录",
"{{webUIName}} Backend Required": "需要 {{webUIName}} 后端", "{{webUIName}} Backend Required": "需要 {{webUIName}} 后端",
"A task model is used when performing tasks such as generating titles for chats and web search queries": "",
"a user": "用户", "a user": "用户",
"About": "关于", "About": "关于",
"Account": "账户", "Account": "账户",
"Accurate information": "准确信息", "Accurate information": "准确信息",
"Add": "", "Add": "添加",
"Add a model": "添加模型", "Add a model id": "",
"Add a model tag name": "添加模型标签名称", "Add a short description about what this model does": "",
"Add a short description about what this modelfile does": "为这个模型文件添加一段简短的描述",
"Add a short title for this prompt": "为这个提示词添加一个简短的标题", "Add a short title for this prompt": "为这个提示词添加一个简短的标题",
"Add a tag": "添加标签", "Add a tag": "添加标签",
"Add custom prompt": "添加自定义提示词", "Add custom prompt": "添加自定义提示词",
"Add Docs": "添加文档", "Add Docs": "添加文档",
"Add Files": "添加文件", "Add Files": "添加文件",
"Add Memory": "", "Add Memory": "添加记忆",
"Add message": "添加消息", "Add message": "添加消息",
"Add Model": "添加模型", "Add Model": "添加模型",
"Add Tags": "添加标签", "Add Tags": "添加标签",
...@@ -29,6 +31,7 @@ ...@@ -29,6 +31,7 @@
"Admin Panel": "管理员面板", "Admin Panel": "管理员面板",
"Admin Settings": "管理员设置", "Admin Settings": "管理员设置",
"Advanced Parameters": "高级参数", "Advanced Parameters": "高级参数",
"Advanced Params": "",
"all": "所有", "all": "所有",
"All Documents": "所有文档", "All Documents": "所有文档",
"All Users": "所有用户", "All Users": "所有用户",
...@@ -43,9 +46,9 @@ ...@@ -43,9 +46,9 @@
"API Key": "API 密钥", "API Key": "API 密钥",
"API Key created.": "API 密钥已创建。", "API Key created.": "API 密钥已创建。",
"API keys": "API 密钥", "API keys": "API 密钥",
"API RPM": "API RPM",
"April": "四月", "April": "四月",
"Archive": "存档", "Archive": "存档",
"Archive All Chats": "",
"Archived Chats": "聊天记录存档", "Archived Chats": "聊天记录存档",
"are allowed - Activate this command by typing": "允许 - 通过输入来激活这个命令", "are allowed - Activate this command by typing": "允许 - 通过输入来激活这个命令",
"Are you sure?": "你确定吗?", "Are you sure?": "你确定吗?",
...@@ -60,16 +63,18 @@ ...@@ -60,16 +63,18 @@
"available!": "可用!", "available!": "可用!",
"Back": "返回", "Back": "返回",
"Bad Response": "不良响应", "Bad Response": "不良响应",
"Banners": "",
"Base Model (From)": "",
"before": "之前", "before": "之前",
"Being lazy": "懒惰", "Being lazy": "懒惰",
"Builder Mode": "构建模式", "Brave Search API Key": "",
"Bypass SSL verification for Websites": "绕过网站的 SSL 验证", "Bypass SSL verification for Websites": "绕过网站的 SSL 验证",
"Cancel": "取消", "Cancel": "取消",
"Categories": "分类", "Capabilities": "",
"Change Password": "更改密码", "Change Password": "更改密码",
"Chat": "聊天", "Chat": "聊天",
"Chat Bubble UI": "", "Chat Bubble UI": "聊天气泡 UI",
"Chat direction": "", "Chat direction": "聊天方向",
"Chat History": "聊天历史", "Chat History": "聊天历史",
"Chat History is off for this browser.": "此浏览器已关闭聊天历史功能。", "Chat History is off for this browser.": "此浏览器已关闭聊天历史功能。",
"Chats": "聊天", "Chats": "聊天",
...@@ -83,18 +88,19 @@ ...@@ -83,18 +88,19 @@
"Citation": "引文", "Citation": "引文",
"Click here for help.": "点击这里获取帮助。", "Click here for help.": "点击这里获取帮助。",
"Click here to": "单击此处", "Click here to": "单击此处",
"Click here to check other modelfiles.": "点击这里检查其他模型文件。",
"Click here to select": "点击这里选择", "Click here to select": "点击这里选择",
"Click here to select a csv file.": "单击此处选择 csv 文件。", "Click here to select a csv file.": "单击此处选择 csv 文件。",
"Click here to select documents.": "点击这里选择文档。", "Click here to select documents.": "点击这里选择文档。",
"click here.": "点击这里。", "click here.": "点击这里。",
"Click on the user role button to change a user's role.": "点击用户角色按钮以更改用户的角色。", "Click on the user role button to change a user's role.": "点击用户角色按钮以更改用户的角色。",
"Clone": "",
"Close": "关闭", "Close": "关闭",
"Collection": "收藏", "Collection": "收藏",
"ComfyUI": "ComfyUI", "ComfyUI": "ComfyUI",
"ComfyUI Base URL": "ComfyUI Base URL", "ComfyUI Base URL": "ComfyUI Base URL",
"ComfyUI Base URL is required.": "ComfyUI Base URL 是必需的。", "ComfyUI Base URL is required.": "ComfyUI Base URL 是必需的。",
"Command": "命令", "Command": "命令",
"Concurrent Requests": "",
"Confirm Password": "确认密码", "Confirm Password": "确认密码",
"Connections": "连接", "Connections": "连接",
"Content": "内容", "Content": "内容",
...@@ -108,7 +114,7 @@ ...@@ -108,7 +114,7 @@
"Copy Link": "复制链接", "Copy Link": "复制链接",
"Copying to clipboard was successful!": "复制到剪贴板成功!", "Copying to clipboard was successful!": "复制到剪贴板成功!",
"Create a concise, 3-5 word phrase as a header for the following query, strictly adhering to the 3-5 word limit and avoiding the use of the word 'title':": "为以下查询创建一个简洁的、3-5 个词的短语作为标题,严格遵守 3-5 个词的限制并避免使用“标题”一词:", "Create a concise, 3-5 word phrase as a header for the following query, strictly adhering to the 3-5 word limit and avoiding the use of the word 'title':": "为以下查询创建一个简洁的、3-5 个词的短语作为标题,严格遵守 3-5 个词的限制并避免使用“标题”一词:",
"Create a modelfile": "创建模型文件", "Create a model": "",
"Create Account": "创建账户", "Create Account": "创建账户",
"Create new key": "创建新密钥", "Create new key": "创建新密钥",
"Create new secret key": "创建新安全密钥", "Create new secret key": "创建新安全密钥",
...@@ -117,32 +123,32 @@ ...@@ -117,32 +123,32 @@
"Current Model": "当前模型", "Current Model": "当前模型",
"Current Password": "当前密码", "Current Password": "当前密码",
"Custom": "自定义", "Custom": "自定义",
"Customize Ollama models for a specific purpose": "定制特定用途的 Ollama 模型", "Customize models for a specific purpose": "",
"Dark": "暗色", "Dark": "暗色",
"Dashboard": "仪表盘",
"Database": "数据库", "Database": "数据库",
"December": "十二月", "December": "十二月",
"Default": "默认", "Default": "默认",
"Default (Automatic1111)": "默认(Automatic1111)", "Default (Automatic1111)": "默认(Automatic1111)",
"Default (SentenceTransformers)": "默认(SentenceTransformers)", "Default (SentenceTransformers)": "默认(SentenceTransformers)",
"Default (Web API)": "默认(Web API)", "Default (Web API)": "默认(Web API)",
"Default Model": "",
"Default model updated": "默认模型已更新", "Default model updated": "默认模型已更新",
"Default Prompt Suggestions": "默认提示词建议", "Default Prompt Suggestions": "默认提示词建议",
"Default User Role": "默认用户角色", "Default User Role": "默认用户角色",
"delete": "删除", "delete": "删除",
"Delete": "删除", "Delete": "删除",
"Delete a model": "删除一个模型", "Delete a model": "删除一个模型",
"Delete All Chats": "",
"Delete chat": "删除聊天", "Delete chat": "删除聊天",
"Delete Chat": "删除聊天", "Delete Chat": "删除聊天",
"Delete Chats": "删除聊天记录",
"delete this link": "删除这个链接", "delete this link": "删除这个链接",
"Delete User": "删除用户", "Delete User": "删除用户",
"Deleted {{deleteModelTag}}": "已删除{{deleteModelTag}}", "Deleted {{deleteModelTag}}": "已删除{{deleteModelTag}}",
"Deleted {{tagName}}": "已删除 {{tagName}}", "Deleted {{name}}": "",
"Description": "描述", "Description": "描述",
"Didn't fully follow instructions": "没有完全遵循指示", "Didn't fully follow instructions": "没有完全遵循指示",
"Disabled": "禁用", "Disabled": "禁用",
"Discover a modelfile": "探索模型文件", "Discover a model": "",
"Discover a prompt": "探索提示词", "Discover a prompt": "探索提示词",
"Discover, download, and explore custom prompts": "发现、下载并探索自定义提示词", "Discover, download, and explore custom prompts": "发现、下载并探索自定义提示词",
"Discover, download, and explore model presets": "发现、下载并探索模型预设", "Discover, download, and explore model presets": "发现、下载并探索模型预设",
...@@ -167,23 +173,27 @@ ...@@ -167,23 +173,27 @@
"Embedding Model Engine": "嵌入模型引擎", "Embedding Model Engine": "嵌入模型引擎",
"Embedding model set to \"{{embedding_model}}\"": "嵌入模型设置为 \"{{embedding_model}}\"", "Embedding model set to \"{{embedding_model}}\"": "嵌入模型设置为 \"{{embedding_model}}\"",
"Enable Chat History": "启用聊天历史", "Enable Chat History": "启用聊天历史",
"Enable Community Sharing": "",
"Enable New Sign Ups": "启用新注册", "Enable New Sign Ups": "启用新注册",
"Enable Web Search": "",
"Enabled": "启用", "Enabled": "启用",
"Ensure your CSV file includes 4 columns in this order: Name, Email, Password, Role.": "确保您的 CSV 文件按以下顺序包含 4 列: 姓名、电子邮件、密码、角色。", "Ensure your CSV file includes 4 columns in this order: Name, Email, Password, Role.": "确保您的 CSV 文件按以下顺序包含 4 列: 姓名、电子邮件、密码、角色。",
"Enter {{role}} message here": "在此处输入 {{role}} 信息", "Enter {{role}} message here": "在此处输入 {{role}} 信息",
"Enter a detail about yourself for your LLMs to recall": "", "Enter a detail about yourself for your LLMs to recall": "输入 LLM 可以记住的信息",
"Enter Brave Search API Key": "",
"Enter Chunk Overlap": "输入块重叠 (Chunk Overlap)", "Enter Chunk Overlap": "输入块重叠 (Chunk Overlap)",
"Enter Chunk Size": "输入块大小 (Chunk Size)", "Enter Chunk Size": "输入块大小 (Chunk Size)",
"Enter Github Raw URL": "",
"Enter Google PSE API Key": "",
"Enter Google PSE Engine Id": "",
"Enter Image Size (e.g. 512x512)": "输入图片大小 (例如 512x512)", "Enter Image Size (e.g. 512x512)": "输入图片大小 (例如 512x512)",
"Enter language codes": "输入语言代码", "Enter language codes": "输入语言代码",
"Enter LiteLLM API Base URL (litellm_params.api_base)": "输入 LiteLLM API 基本 URL (litellm_params.api_base)",
"Enter LiteLLM API Key (litellm_params.api_key)": "输入 LiteLLM API 密匙 (litellm_params.api_key)",
"Enter LiteLLM API RPM (litellm_params.rpm)": "输入 LiteLLM API 速率限制 (litellm_params.rpm)",
"Enter LiteLLM Model (litellm_params.model)": "输入 LiteLLM 模型 (litellm_params.model)",
"Enter Max Tokens (litellm_params.max_tokens)": "输入模型的 Max Tokens (litellm_params.max_tokens)",
"Enter model tag (e.g. {{modelTag}})": "输入模型标签 (例如{{modelTag}})", "Enter model tag (e.g. {{modelTag}})": "输入模型标签 (例如{{modelTag}})",
"Enter Number of Steps (e.g. 50)": "输入步数 (例如 50)", "Enter Number of Steps (e.g. 50)": "输入步数 (例如 50)",
"Enter Score": "输入分", "Enter Score": "输入分",
"Enter Searxng Query URL": "",
"Enter Serper API Key": "",
"Enter Serpstack API Key": "",
"Enter stop sequence": "输入停止序列", "Enter stop sequence": "输入停止序列",
"Enter Top K": "输入 Top K", "Enter Top K": "输入 Top K",
"Enter URL (e.g. http://127.0.0.1:7860/)": "输入 URL (例如 http://127.0.0.1:7860/)", "Enter URL (e.g. http://127.0.0.1:7860/)": "输入 URL (例如 http://127.0.0.1:7860/)",
...@@ -192,11 +202,12 @@ ...@@ -192,11 +202,12 @@
"Enter Your Full Name": "输入您的全名", "Enter Your Full Name": "输入您的全名",
"Enter Your Password": "输入您的密码", "Enter Your Password": "输入您的密码",
"Enter Your Role": "输入您的角色", "Enter Your Role": "输入您的角色",
"Error": "",
"Experimental": "实验性", "Experimental": "实验性",
"Export All Chats (All Users)": "导出所有聊天(所有用户)", "Export All Chats (All Users)": "导出所有聊天(所有用户)",
"Export Chats": "导出聊天", "Export Chats": "导出聊天",
"Export Documents Mapping": "导出文档映射", "Export Documents Mapping": "导出文档映射",
"Export Modelfiles": "导出模型文件", "Export Models": "",
"Export Prompts": "导出提示词", "Export Prompts": "导出提示词",
"Failed to create API Key.": "无法创建 API 密钥。", "Failed to create API Key.": "无法创建 API 密钥。",
"Failed to read clipboard contents": "无法读取剪贴板内容", "Failed to read clipboard contents": "无法读取剪贴板内容",
...@@ -209,18 +220,20 @@ ...@@ -209,18 +220,20 @@
"Focus chat input": "聚焦聊天输入", "Focus chat input": "聚焦聊天输入",
"Followed instructions perfectly": "完全遵循说明", "Followed instructions perfectly": "完全遵循说明",
"Format your variables using square brackets like this:": "使用这样的方括号格式化你的变量:", "Format your variables using square brackets like this:": "使用这样的方括号格式化你的变量:",
"From (Base Model)": "来自(基础模型)", "Frequency Penalty": "",
"Full Screen Mode": "全屏模式", "Full Screen Mode": "全屏模式",
"General": "通用", "General": "通用",
"General Settings": "通用设置", "General Settings": "通用设置",
"Generating search query": "",
"Generation Info": "生成信息", "Generation Info": "生成信息",
"Good Response": "反应良好", "Good Response": "反应良好",
"Google PSE API Key": "",
"Google PSE Engine Id": "",
"h:mm a": "h:mm a", "h:mm a": "h:mm a",
"has no conversations.": "没有对话。", "has no conversations.": "没有对话。",
"Hello, {{name}}": "你好,{{name}}", "Hello, {{name}}": "你好,{{name}}",
"Help": "帮助", "Help": "帮助",
"Hide": "隐藏", "Hide": "隐藏",
"Hide Additional Params": "隐藏额外参数",
"How can I help you today?": "我今天能帮你做什么?", "How can I help you today?": "我今天能帮你做什么?",
"Hybrid Search": "混合搜索", "Hybrid Search": "混合搜索",
"Image Generation (Experimental)": "图像生成(实验性)", "Image Generation (Experimental)": "图像生成(实验性)",
...@@ -229,15 +242,18 @@ ...@@ -229,15 +242,18 @@
"Images": "图像", "Images": "图像",
"Import Chats": "导入聊天", "Import Chats": "导入聊天",
"Import Documents Mapping": "导入文档映射", "Import Documents Mapping": "导入文档映射",
"Import Modelfiles": "导入模型文件", "Import Models": "",
"Import Prompts": "导入提示", "Import Prompts": "导入提示",
"Include `--api` flag when running stable-diffusion-webui": "运行 stable-diffusion-webui 时包含 `--api` 标志", "Include `--api` flag when running stable-diffusion-webui": "运行 stable-diffusion-webui 时包含 `--api` 标志",
"Info": "",
"Input commands": "输入命令", "Input commands": "输入命令",
"Install from Github URL": "",
"Interface": "界面", "Interface": "界面",
"Invalid Tag": "无效标签", "Invalid Tag": "无效标签",
"January": "一月", "January": "一月",
"join our Discord for help.": "加入我们的 Discord 寻求帮助。", "join our Discord for help.": "加入我们的 Discord 寻求帮助。",
"JSON": "JSON", "JSON": "JSON",
"JSON Preview": "",
"July": "七月", "July": "七月",
"June": "六月", "June": "六月",
"JWT Expiration": "JWT 过期", "JWT Expiration": "JWT 过期",
...@@ -249,19 +265,19 @@ ...@@ -249,19 +265,19 @@
"Light": "浅色", "Light": "浅色",
"Listening...": "监听中...", "Listening...": "监听中...",
"LLMs can make mistakes. Verify important information.": "LLM 可能会生成错误信息,请验证重要信息。", "LLMs can make mistakes. Verify important information.": "LLM 可能会生成错误信息,请验证重要信息。",
"LTR": "", "LTR": "LTR",
"Made by OpenWebUI Community": "由 OpenWebUI 社区制作", "Made by OpenWebUI Community": "由 OpenWebUI 社区制作",
"Make sure to enclose them with": "确保将它们包含在内", "Make sure to enclose them with": "确保将它们包含在内",
"Manage LiteLLM Models": "管理 LiteLLM 模型",
"Manage Models": "管理模型", "Manage Models": "管理模型",
"Manage Ollama Models": "管理 Ollama 模型", "Manage Ollama Models": "管理 Ollama 模型",
"Manage Pipelines": "",
"March": "三月", "March": "三月",
"Max Tokens": "最大令牌数", "Max Tokens (num_predict)": "",
"Maximum of 3 models can be downloaded simultaneously. Please try again later.": "最多可以同时下载 3 个模型,请稍后重试。", "Maximum of 3 models can be downloaded simultaneously. Please try again later.": "最多可以同时下载 3 个模型,请稍后重试。",
"May": "五月", "May": "五月",
"Memories accessible by LLMs will be shown here.": "", "Memories accessible by LLMs will be shown here.": "LLMs 可以访问的记忆将显示在这里。",
"Memory": "", "Memory": "记忆",
"Messages you send after creating your link won't be shared. Users with the URL will be able to view the shared chat.": "", "Messages you send after creating your link won't be shared. Users with the URL will be able to view the shared chat.": "创建链接后发送的消息不会被共享。具有 URL 的用户将能够查看共享聊天。",
"Minimum Score": "最低分", "Minimum Score": "最低分",
"Mirostat": "Mirostat", "Mirostat": "Mirostat",
"Mirostat Eta": "Mirostat Eta", "Mirostat Eta": "Mirostat Eta",
...@@ -271,29 +287,27 @@ ...@@ -271,29 +287,27 @@
"Model '{{modelName}}' has been successfully downloaded.": "模型'{{modelName}}'已成功下载。", "Model '{{modelName}}' has been successfully downloaded.": "模型'{{modelName}}'已成功下载。",
"Model '{{modelTag}}' is already in queue for downloading.": "模型'{{modelTag}}'已在下载队列中。", "Model '{{modelTag}}' is already in queue for downloading.": "模型'{{modelTag}}'已在下载队列中。",
"Model {{modelId}} not found": "未找到模型{{modelId}}", "Model {{modelId}} not found": "未找到模型{{modelId}}",
"Model {{modelName}} already exists.": "模型{{modelName}}已存在。", "Model {{modelName}} is not vision capable": "",
"Model {{name}} is now {{status}}": "",
"Model filesystem path detected. Model shortname is required for update, cannot continue.": "检测到模型文件系统路径。模型简名是更新所必需的,无法继续。", "Model filesystem path detected. Model shortname is required for update, cannot continue.": "检测到模型文件系统路径。模型简名是更新所必需的,无法继续。",
"Model Name": "模型名称", "Model ID": "",
"Model not selected": "未选择模型", "Model not selected": "未选择模型",
"Model Tag Name": "模型标签名称", "Model Params": "",
"Model Whitelisting": "白名单模型", "Model Whitelisting": "白名单模型",
"Model(s) Whitelisted": "模型已加入白名单", "Model(s) Whitelisted": "模型已加入白名单",
"Modelfile": "模型文件",
"Modelfile Advanced Settings": "模型文件高级设置",
"Modelfile Content": "模型文件内容", "Modelfile Content": "模型文件内容",
"Modelfiles": "模型文件",
"Models": "模型", "Models": "模型",
"More": "更多", "More": "更多",
"Name": "名称", "Name": "名称",
"Name Tag": "名称标签", "Name Tag": "名称标签",
"Name your modelfile": "命名你的模型文件", "Name your model": "",
"New Chat": "新聊天", "New Chat": "新聊天",
"New Password": "新密码", "New Password": "新密码",
"No results found": "未找到结果", "No results found": "未找到结果",
"No search query generated": "",
"No source available": "没有可用来源", "No source available": "没有可用来源",
"None": "",
"Not factually correct": "与事实不符", "Not factually correct": "与事实不符",
"Not sure what to add?": "不确定要添加什么?",
"Not sure what to write? Switch to": "不确定写什么?切换到",
"Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.": "注意:如果设置了最低分数,搜索只会返回分数大于或等于最低分数的文档。", "Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.": "注意:如果设置了最低分数,搜索只会返回分数大于或等于最低分数的文档。",
"Notifications": "桌面通知", "Notifications": "桌面通知",
"November": "十一月", "November": "十一月",
...@@ -302,7 +316,7 @@ ...@@ -302,7 +316,7 @@
"Okay, Let's Go!": "好的,我们开始吧!", "Okay, Let's Go!": "好的,我们开始吧!",
"OLED Dark": "暗黑色", "OLED Dark": "暗黑色",
"Ollama": "Ollama", "Ollama": "Ollama",
"Ollama Base URL": "Ollama 基础 URL", "Ollama API": "",
"Ollama Version": "Ollama 版本", "Ollama Version": "Ollama 版本",
"On": "开", "On": "开",
"Only": "仅", "Only": "仅",
...@@ -321,15 +335,15 @@ ...@@ -321,15 +335,15 @@
"OpenAI URL/Key required.": "需要 OpenAI URL/Key", "OpenAI URL/Key required.": "需要 OpenAI URL/Key",
"or": "或", "or": "或",
"Other": "其他", "Other": "其他",
"Overview": "概述",
"Parameters": "参数",
"Password": "密码", "Password": "密码",
"PDF document (.pdf)": "PDF 文档 (.pdf)", "PDF document (.pdf)": "PDF 文档 (.pdf)",
"PDF Extract Images (OCR)": "PDF 图像处理 (使用 OCR)", "PDF Extract Images (OCR)": "PDF 图像处理 (使用 OCR)",
"pending": "待定", "pending": "待定",
"Permission denied when accessing microphone: {{error}}": "访问麦克风时权限被拒绝:{{error}}", "Permission denied when accessing microphone: {{error}}": "访问麦克风时权限被拒绝:{{error}}",
"Personalization": "", "Personalization": "个性化",
"Plain text (.txt)": "PDF 文档 (.pdf)", "Pipelines": "",
"Pipelines Valves": "",
"Plain text (.txt)": "TXT 文档 (.txt)",
"Playground": "AI 对话游乐场", "Playground": "AI 对话游乐场",
"Positive attitude": "积极态度", "Positive attitude": "积极态度",
"Previous 30 days": "过去 30 天", "Previous 30 days": "过去 30 天",
...@@ -342,10 +356,8 @@ ...@@ -342,10 +356,8 @@
"Prompts": "提示词", "Prompts": "提示词",
"Pull \"{{searchValue}}\" from Ollama.com": "从 Ollama.com 拉取 \"{{searchValue}}\"", "Pull \"{{searchValue}}\" from Ollama.com": "从 Ollama.com 拉取 \"{{searchValue}}\"",
"Pull a model from Ollama.com": "从 Ollama.com 拉取一个模型", "Pull a model from Ollama.com": "从 Ollama.com 拉取一个模型",
"Pull Progress": "拉取进度",
"Query Params": "查询参数", "Query Params": "查询参数",
"RAG Template": "RAG 模板", "RAG Template": "RAG 模板",
"Raw Format": "原始格式",
"Read Aloud": "朗读", "Read Aloud": "朗读",
"Record voice": "录音", "Record voice": "录音",
"Redirecting you to OpenWebUI Community": "正在将您重定向到 OpenWebUI 社区", "Redirecting you to OpenWebUI Community": "正在将您重定向到 OpenWebUI 社区",
...@@ -356,7 +368,6 @@ ...@@ -356,7 +368,6 @@
"Remove Model": "移除模型", "Remove Model": "移除模型",
"Rename": "重命名", "Rename": "重命名",
"Repeat Last N": "重复最后 N 次", "Repeat Last N": "重复最后 N 次",
"Repeat Penalty": "重复惩罚",
"Request Mode": "请求模式", "Request Mode": "请求模式",
"Reranking Model": "重排模型", "Reranking Model": "重排模型",
"Reranking model disabled": "重排模型已禁用", "Reranking model disabled": "重排模型已禁用",
...@@ -366,7 +377,7 @@ ...@@ -366,7 +377,7 @@
"Role": "角色", "Role": "角色",
"Rosé Pine": "Rosé Pine", "Rosé Pine": "Rosé Pine",
"Rosé Pine Dawn": "Rosé Pine Dawn", "Rosé Pine Dawn": "Rosé Pine Dawn",
"RTL": "", "RTL": "RTL",
"Save": "保存", "Save": "保存",
"Save & Create": "保存并创建", "Save & Create": "保存并创建",
"Save & Update": "保存并更新", "Save & Update": "保存并更新",
...@@ -376,19 +387,31 @@ ...@@ -376,19 +387,31 @@
"Scan for documents from {{path}}": "从 {{path}} 扫描文档", "Scan for documents from {{path}}": "从 {{path}} 扫描文档",
"Search": "搜索", "Search": "搜索",
"Search a model": "搜索模型", "Search a model": "搜索模型",
"Search Chats": "",
"Search Documents": "搜索文档", "Search Documents": "搜索文档",
"Search Models": "",
"Search Prompts": "搜索提示词", "Search Prompts": "搜索提示词",
"Search Result Count": "",
"Searched {{count}} sites_other": "",
"Searching the web for '{{searchQuery}}'": "",
"Searxng Query URL": "",
"See readme.md for instructions": "查看 readme.md 以获取说明", "See readme.md for instructions": "查看 readme.md 以获取说明",
"See what's new": "查看最新内容", "See what's new": "查看最新内容",
"Seed": "种子", "Seed": "种子",
"Select a base model": "",
"Select a mode": "选择一个模式", "Select a mode": "选择一个模式",
"Select a model": "选择一个模型", "Select a model": "选择一个模型",
"Select a pipeline": "",
"Select a pipeline url": "",
"Select an Ollama instance": "选择一个 Ollama 实例", "Select an Ollama instance": "选择一个 Ollama 实例",
"Select model": "选择模型", "Select model": "选择模型",
"Selected model(s) do not support image inputs": "",
"Send": "发送", "Send": "发送",
"Send a Message": "发送消息", "Send a Message": "发送消息",
"Send message": "发送消息", "Send message": "发送消息",
"September": "九月", "September": "九月",
"Serper API Key": "",
"Serpstack API Key": "",
"Server connection verified": "已验证服务器连接", "Server connection verified": "已验证服务器连接",
"Set as default": "设为默认", "Set as default": "设为默认",
"Set Default Model": "设置默认模型", "Set Default Model": "设置默认模型",
...@@ -397,7 +420,7 @@ ...@@ -397,7 +420,7 @@
"Set Model": "设置模型", "Set Model": "设置模型",
"Set reranking model (e.g. {{model}})": "设置重排模型(例如 {{model}})", "Set reranking model (e.g. {{model}})": "设置重排模型(例如 {{model}})",
"Set Steps": "设置步骤", "Set Steps": "设置步骤",
"Set Title Auto-Generation Model": "设置标题自动生成模型", "Set Task Model": "",
"Set Voice": "设置声音", "Set Voice": "设置声音",
"Settings": "设置", "Settings": "设置",
"Settings saved successfully!": "设置已保存", "Settings saved successfully!": "设置已保存",
...@@ -406,7 +429,6 @@ ...@@ -406,7 +429,6 @@
"Share to OpenWebUI Community": "分享到 OpenWebUI 社区", "Share to OpenWebUI Community": "分享到 OpenWebUI 社区",
"short-summary": "简短总结", "short-summary": "简短总结",
"Show": "显示", "Show": "显示",
"Show Additional Params": "显示额外参数",
"Show shortcuts": "显示快捷方式", "Show shortcuts": "显示快捷方式",
"Showcased creativity": "展示创意", "Showcased creativity": "展示创意",
"sidebar": "侧边栏", "sidebar": "侧边栏",
...@@ -425,7 +447,6 @@ ...@@ -425,7 +447,6 @@
"Success": "成功", "Success": "成功",
"Successfully updated.": "成功更新。", "Successfully updated.": "成功更新。",
"Suggested": "建议", "Suggested": "建议",
"Sync All": "同步所有",
"System": "系统", "System": "系统",
"System Prompt": "系统提示", "System Prompt": "系统提示",
"Tags": "标签", "Tags": "标签",
...@@ -458,13 +479,14 @@ ...@@ -458,13 +479,14 @@
"Top P": "Top P", "Top P": "Top P",
"Trouble accessing Ollama?": "访问 Ollama 时遇到问题?", "Trouble accessing Ollama?": "访问 Ollama 时遇到问题?",
"TTS Settings": "文本转语音设置", "TTS Settings": "文本转语音设置",
"Type": "",
"Type Hugging Face Resolve (Download) URL": "输入 Hugging Face 解析(下载)URL", "Type Hugging Face Resolve (Download) URL": "输入 Hugging Face 解析(下载)URL",
"Uh-oh! There was an issue connecting to {{provider}}.": "哎呀!连接到{{provider}}时出现问题。", "Uh-oh! There was an issue connecting to {{provider}}.": "哎呀!连接到{{provider}}时出现问题。",
"Unknown File Type '{{file_type}}', but accepting and treating as plain text": "未知文件类型'{{file_type}}',将视为纯文本进行处理", "Unknown File Type '{{file_type}}', but accepting and treating as plain text": "未知文件类型'{{file_type}}',将视为纯文本进行处理",
"Update and Copy Link": "更新和复制链接", "Update and Copy Link": "更新和复制链接",
"Update password": "更新密码", "Update password": "更新密码",
"Upload a GGUF model": "上传一个 GGUF 模型", "Upload a GGUF model": "上传一个 GGUF 模型",
"Upload files": "上传文件", "Upload Files": "",
"Upload Progress": "上传进度", "Upload Progress": "上传进度",
"URL Mode": "URL 模式", "URL Mode": "URL 模式",
"Use '#' in the prompt input to load and select your documents.": "在提示输入中使用'#'来加载和选择你的文档。", "Use '#' in the prompt input to load and select your documents.": "在提示输入中使用'#'来加载和选择你的文档。",
...@@ -478,10 +500,13 @@ ...@@ -478,10 +500,13 @@
"variable": "变量", "variable": "变量",
"variable to have them replaced with clipboard content.": "变量将被剪贴板内容替换。", "variable to have them replaced with clipboard content.": "变量将被剪贴板内容替换。",
"Version": "版本", "Version": "版本",
"Warning": "",
"Warning: If you update or change your embedding model, you will need to re-import all documents.": "警告: 如果更新或更改 embedding 模型,则需要重新导入所有文档。", "Warning: If you update or change your embedding model, you will need to re-import all documents.": "警告: 如果更新或更改 embedding 模型,则需要重新导入所有文档。",
"Web": "网页", "Web": "网页",
"Web Loader Settings": "Web 加载器设置", "Web Loader Settings": "Web 加载器设置",
"Web Params": "Web 参数", "Web Params": "Web 参数",
"Web Search": "",
"Web Search Engine": "",
"Webhook URL": "Webhook URL", "Webhook URL": "Webhook URL",
"WebUI Add-ons": "WebUI 插件", "WebUI Add-ons": "WebUI 插件",
"WebUI Settings": "WebUI 设置", "WebUI Settings": "WebUI 设置",
...@@ -493,7 +518,8 @@ ...@@ -493,7 +518,8 @@
"Write a prompt suggestion (e.g. Who are you?)": "写一个提示建议(例如:你是谁?)", "Write a prompt suggestion (e.g. Who are you?)": "写一个提示建议(例如:你是谁?)",
"Write a summary in 50 words that summarizes [topic or keyword].": "用 50 个字写一个总结 [主题或关键词]。", "Write a summary in 50 words that summarizes [topic or keyword].": "用 50 个字写一个总结 [主题或关键词]。",
"Yesterday": "昨天", "Yesterday": "昨天",
"You": "", "You": "你",
"You cannot clone a base model": "",
"You have no archived conversations.": "你没有存档的对话。", "You have no archived conversations.": "你没有存档的对话。",
"You have shared this chat": "你分享了这次聊天", "You have shared this chat": "你分享了这次聊天",
"You're a helpful assistant.": "你是一个有帮助的助手。", "You're a helpful assistant.": "你是一个有帮助的助手。",
......
...@@ -3,34 +3,37 @@ ...@@ -3,34 +3,37 @@
"(Beta)": "(測試版)", "(Beta)": "(測試版)",
"(e.g. `sh webui.sh --api`)": "(例如 `sh webui.sh --api`)", "(e.g. `sh webui.sh --api`)": "(例如 `sh webui.sh --api`)",
"(latest)": "(最新版)", "(latest)": "(最新版)",
"{{ models }}": "",
"{{ owner }}: You cannot delete a base model": "",
"{{modelName}} is thinking...": "{{modelName}} 正在思考...", "{{modelName}} is thinking...": "{{modelName}} 正在思考...",
"{{user}}'s Chats": "", "{{user}}'s Chats": "{{user}} 的聊天",
"{{webUIName}} Backend Required": "需要 {{webUIName}} 後台", "{{webUIName}} Backend Required": "需要 {{webUIName}} 後台",
"A task model is used when performing tasks such as generating titles for chats and web search queries": "",
"a user": "使用者", "a user": "使用者",
"About": "關於", "About": "關於",
"Account": "帳號", "Account": "帳號",
"Accurate information": "", "Accurate information": "準確信息",
"Add": "", "Add": "新增",
"Add a model": "新增模型", "Add a model id": "",
"Add a model tag name": "新增模型標籤", "Add a short description about what this model does": "",
"Add a short description about what this modelfile does": "為這個 Modelfile 添加一段簡短的描述",
"Add a short title for this prompt": "為這個提示詞添加一個簡短的標題", "Add a short title for this prompt": "為這個提示詞添加一個簡短的標題",
"Add a tag": "新增標籤", "Add a tag": "新增標籤",
"Add custom prompt": "新增自定義提示詞", "Add custom prompt": "新增自定義提示詞",
"Add Docs": "新增文件", "Add Docs": "新增文件",
"Add Files": "新增檔案", "Add Files": "新增檔案",
"Add Memory": "", "Add Memory": "新增記憶",
"Add message": "新增訊息", "Add message": "新增訊息",
"Add Model": "", "Add Model": "新增模型",
"Add Tags": "新增標籤", "Add Tags": "新增標籤",
"Add User": "", "Add User": "新增用户",
"Adjusting these settings will apply changes universally to all users.": "調整這些設定將對所有使用者進行更改。", "Adjusting these settings will apply changes universally to all users.": "調整這些設定將對所有使用者進行更改。",
"admin": "管理員", "admin": "管理員",
"Admin Panel": "管理員控制台", "Admin Panel": "管理員控制台",
"Admin Settings": "管理設定", "Admin Settings": "管理設定",
"Advanced Parameters": "進階參數", "Advanced Parameters": "進階參數",
"Advanced Params": "",
"all": "所有", "all": "所有",
"All Documents": "", "All Documents": "所有文件",
"All Users": "所有使用者", "All Users": "所有使用者",
"Allow": "允許", "Allow": "允許",
"Allow Chat Deletion": "允許刪除聊天紀錄", "Allow Chat Deletion": "允許刪除聊天紀錄",
...@@ -38,38 +41,40 @@ ...@@ -38,38 +41,40 @@
"Already have an account?": "已經有帳號了嗎?", "Already have an account?": "已經有帳號了嗎?",
"an assistant": "助手", "an assistant": "助手",
"and": "和", "and": "和",
"and create a new shared link.": "", "and create a new shared link.": "創建一個新的共享連結。",
"API Base URL": "API 基本 URL", "API Base URL": "API 基本 URL",
"API Key": "API 金鑰", "API Key": "API Key",
"API Key created.": "", "API Key created.": "API Key",
"API keys": "", "API keys": "API Keys",
"API RPM": "API RPM", "April": "4月",
"April": "", "Archive": "存檔",
"Archive": "", "Archive All Chats": "",
"Archived Chats": "聊天記錄存檔", "Archived Chats": "聊天記錄存檔",
"are allowed - Activate this command by typing": "是允許的 - 透過輸入", "are allowed - Activate this command by typing": "是允許的 - 透過輸入",
"Are you sure?": "你確定嗎?", "Are you sure?": "你確定嗎?",
"Attach file": "附加檔案", "Attach file": "附加檔案",
"Attention to detail": "", "Attention to detail": "細節精確",
"Audio": "音訊", "Audio": "音訊",
"August": "", "August": "8月",
"Auto-playback response": "自動播放回答", "Auto-playback response": "自動播放回答",
"Auto-send input after 3 sec.": "3 秒後自動傳送輸入內容", "Auto-send input after 3 sec.": "3 秒後自動傳送輸入內容",
"AUTOMATIC1111 Base URL": "AUTOMATIC1111 基本 URL", "AUTOMATIC1111 Base URL": "AUTOMATIC1111 基本 URL",
"AUTOMATIC1111 Base URL is required.": "需要 AUTOMATIC1111 基本 URL", "AUTOMATIC1111 Base URL is required.": "需要 AUTOMATIC1111 基本 URL",
"available!": "可以使用!", "available!": "可以使用!",
"Back": "返回", "Back": "返回",
"Bad Response": "", "Bad Response": "錯誤回應",
"before": "", "Banners": "",
"Being lazy": "", "Base Model (From)": "",
"Builder Mode": "建構模式", "before": "前",
"Bypass SSL verification for Websites": "", "Being lazy": "懶人模式",
"Brave Search API Key": "",
"Bypass SSL verification for Websites": "跳過 SSL 驗證",
"Cancel": "取消", "Cancel": "取消",
"Categories": "分類", "Capabilities": "",
"Change Password": "修改密碼", "Change Password": "修改密碼",
"Chat": "聊天", "Chat": "聊天",
"Chat Bubble UI": "", "Chat Bubble UI": "聊天氣泡介面",
"Chat direction": "", "Chat direction": "聊天方向",
"Chat History": "聊天紀錄功能", "Chat History": "聊天紀錄功能",
"Chat History is off for this browser.": "此瀏覽器已關閉聊天紀錄功能。", "Chat History is off for this browser.": "此瀏覽器已關閉聊天紀錄功能。",
"Chats": "聊天", "Chats": "聊天",
...@@ -82,67 +87,68 @@ ...@@ -82,67 +87,68 @@
"Chunk Size": "Chunk 大小", "Chunk Size": "Chunk 大小",
"Citation": "引文", "Citation": "引文",
"Click here for help.": "點擊這裡尋找幫助。", "Click here for help.": "點擊這裡尋找幫助。",
"Click here to": "", "Click here to": "點擊這裡",
"Click here to check other modelfiles.": "點擊這裡檢查其他 Modelfiles。",
"Click here to select": "點擊這裡選擇", "Click here to select": "點擊這裡選擇",
"Click here to select a csv file.": "", "Click here to select a csv file.": "點擊這裡選擇 csv 檔案。",
"Click here to select documents.": "點擊這裡選擇文件。", "Click here to select documents.": "點擊這裡選擇文件。",
"click here.": "點擊這裡。", "click here.": "點擊這裡。",
"Click on the user role button to change a user's role.": "點擊使用者 Role 按鈕以更改使用者的 Role。", "Click on the user role button to change a user's role.": "點擊使用者 Role 按鈕以更改使用者的 Role。",
"Clone": "",
"Close": "關閉", "Close": "關閉",
"Collection": "收藏", "Collection": "收藏",
"ComfyUI": "", "ComfyUI": "ComfyUI",
"ComfyUI Base URL": "", "ComfyUI Base URL": "ComfyUI 基本 URL",
"ComfyUI Base URL is required.": "", "ComfyUI Base URL is required.": "需要 ComfyUI 基本 URL",
"Command": "命令", "Command": "命令",
"Concurrent Requests": "",
"Confirm Password": "確認密碼", "Confirm Password": "確認密碼",
"Connections": "連線", "Connections": "連線",
"Content": "內容", "Content": "內容",
"Context Length": "上下文長度", "Context Length": "上下文長度",
"Continue Response": "", "Continue Response": "繼續回答",
"Conversation Mode": "對話模式", "Conversation Mode": "對話模式",
"Copied shared chat URL to clipboard!": "", "Copied shared chat URL to clipboard!": "已複製共享聊天連結到剪貼簿!",
"Copy": "", "Copy": "複製",
"Copy last code block": "複製最後一個程式碼區塊", "Copy last code block": "複製最後一個程式碼區塊",
"Copy last response": "複製最後一個回答", "Copy last response": "複製最後一個回答",
"Copy Link": "", "Copy Link": "複製連結",
"Copying to clipboard was successful!": "成功複製到剪貼簿!", "Copying to clipboard was successful!": "成功複製到剪貼簿!",
"Create a concise, 3-5 word phrase as a header for the following query, strictly adhering to the 3-5 word limit and avoiding the use of the word 'title':": "為以下的查詢建立一個簡潔、3-5 個詞的短語作為標題,嚴格遵守 3-5 個詞的限制,避免使用「標題」這個詞:", "Create a concise, 3-5 word phrase as a header for the following query, strictly adhering to the 3-5 word limit and avoiding the use of the word 'title':": "為以下的查詢建立一個簡潔、3-5 個詞的短語作為標題,嚴格遵守 3-5 個詞的限制,避免使用「標題」這個詞:",
"Create a modelfile": "建立 Modelfile", "Create a model": "",
"Create Account": "建立帳號", "Create Account": "建立帳號",
"Create new key": "", "Create new key": "建立新密鑰",
"Create new secret key": "", "Create new secret key": "建立新密鑰",
"Created at": "建立於", "Created at": "建立於",
"Created At": "", "Created At": "建立於",
"Current Model": "目前模型", "Current Model": "目前模型",
"Current Password": "目前密碼", "Current Password": "目前密碼",
"Custom": "自訂", "Custom": "自訂",
"Customize Ollama models for a specific purpose": "定制特定用途的 Ollama 模型", "Customize models for a specific purpose": "",
"Dark": "暗色", "Dark": "暗色",
"Dashboard": "",
"Database": "資料庫", "Database": "資料庫",
"December": "", "December": "12月",
"Default": "預設", "Default": "預設",
"Default (Automatic1111)": "預設(Automatic1111)", "Default (Automatic1111)": "預設(Automatic1111)",
"Default (SentenceTransformers)": "", "Default (SentenceTransformers)": "預設(SentenceTransformers)",
"Default (Web API)": "預設(Web API)", "Default (Web API)": "預設(Web API)",
"Default Model": "",
"Default model updated": "預設模型已更新", "Default model updated": "預設模型已更新",
"Default Prompt Suggestions": "預設提示詞建議", "Default Prompt Suggestions": "預設提示詞建議",
"Default User Role": "預設用戶 Role", "Default User Role": "預設用戶 Role",
"delete": "刪除", "delete": "刪除",
"Delete": "", "Delete": "刪除",
"Delete a model": "刪除一個模型", "Delete a model": "刪除一個模型",
"Delete All Chats": "",
"Delete chat": "刪除聊天紀錄", "Delete chat": "刪除聊天紀錄",
"Delete Chat": "", "Delete Chat": "刪除聊天紀錄",
"Delete Chats": "刪除聊天紀錄", "delete this link": "刪除此連結",
"delete this link": "", "Delete User": "刪除用戶",
"Delete User": "",
"Deleted {{deleteModelTag}}": "已刪除 {{deleteModelTag}}", "Deleted {{deleteModelTag}}": "已刪除 {{deleteModelTag}}",
"Deleted {{tagName}}": "", "Deleted {{name}}": "",
"Description": "描述", "Description": "描述",
"Didn't fully follow instructions": "", "Didn't fully follow instructions": "無法完全遵循指示",
"Disabled": "已停用", "Disabled": "已停用",
"Discover a modelfile": "發現新 Modelfile", "Discover a model": "",
"Discover a prompt": "發現新提示詞", "Discover a prompt": "發現新提示詞",
"Discover, download, and explore custom prompts": "發現、下載並探索他人設置的提示詞", "Discover, download, and explore custom prompts": "發現、下載並探索他人設置的提示詞",
"Discover, download, and explore model presets": "發現、下載並探索他人設置的模型", "Discover, download, and explore model presets": "發現、下載並探索他人設置的模型",
...@@ -153,156 +159,164 @@ ...@@ -153,156 +159,164 @@
"does not make any external connections, and your data stays securely on your locally hosted server.": "不會與外部溝通,你的數據會安全地留在你的本機伺服器上。", "does not make any external connections, and your data stays securely on your locally hosted server.": "不會與外部溝通,你的數據會安全地留在你的本機伺服器上。",
"Don't Allow": "不允許", "Don't Allow": "不允許",
"Don't have an account?": "還沒有註冊帳號?", "Don't have an account?": "還沒有註冊帳號?",
"Don't like the style": "", "Don't like the style": "不喜歡這個樣式?",
"Download": "", "Download": "下載",
"Download canceled": "", "Download canceled": "下載已取消",
"Download Database": "下載資料庫", "Download Database": "下載資料庫",
"Drop any files here to add to the conversation": "拖拽文件到此處以新增至對話", "Drop any files here to add to the conversation": "拖拽文件到此處以新增至對話",
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "例如 '30s', '10m'。有效的時間單位為 's', 'm', 'h'。", "e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "例如 '30s', '10m'。有效的時間單位為 's', 'm', 'h'。",
"Edit": "", "Edit": "編輯",
"Edit Doc": "編輯文件", "Edit Doc": "編輯文件",
"Edit User": "編輯使用者", "Edit User": "編輯使用者",
"Email": "電子郵件", "Email": "電子郵件",
"Embedding Model": "", "Embedding Model": "嵌入模型",
"Embedding Model Engine": "", "Embedding Model Engine": "嵌入模型引擎",
"Embedding model set to \"{{embedding_model}}\"": "", "Embedding model set to \"{{embedding_model}}\"": "嵌入模型已設定為 \"{{embedding_model}}\"",
"Enable Chat History": "啟用聊天歷史", "Enable Chat History": "啟用聊天歷史",
"Enable Community Sharing": "",
"Enable New Sign Ups": "允許註冊新帳號", "Enable New Sign Ups": "允許註冊新帳號",
"Enable Web Search": "",
"Enabled": "已啟用", "Enabled": "已啟用",
"Ensure your CSV file includes 4 columns in this order: Name, Email, Password, Role.": "", "Ensure your CSV file includes 4 columns in this order: Name, Email, Password, Role.": "請確保你的 CSV 檔案包含這四個欄位,並按照此順序:名稱、電子郵件、密碼、角色。",
"Enter {{role}} message here": "在這裡輸入 {{role}} 訊息", "Enter {{role}} message here": "在這裡輸入 {{role}} 訊息",
"Enter a detail about yourself for your LLMs to recall": "", "Enter a detail about yourself for your LLMs to recall": "輸入 LLM 記憶的詳細內容",
"Enter Brave Search API Key": "",
"Enter Chunk Overlap": "輸入 Chunk Overlap", "Enter Chunk Overlap": "輸入 Chunk Overlap",
"Enter Chunk Size": "輸入 Chunk 大小", "Enter Chunk Size": "輸入 Chunk 大小",
"Enter Github Raw URL": "",
"Enter Google PSE API Key": "",
"Enter Google PSE Engine Id": "",
"Enter Image Size (e.g. 512x512)": "輸入圖片大小(例如 512x512)", "Enter Image Size (e.g. 512x512)": "輸入圖片大小(例如 512x512)",
"Enter language codes": "", "Enter language codes": "輸入語言代碼",
"Enter LiteLLM API Base URL (litellm_params.api_base)": "輸入 LiteLLM API 基本 URL(litellm_params.api_base)",
"Enter LiteLLM API Key (litellm_params.api_key)": "輸入 LiteLLM API 金鑰(litellm_params.api_key)",
"Enter LiteLLM API RPM (litellm_params.rpm)": "輸入 LiteLLM API RPM(litellm_params.rpm)",
"Enter LiteLLM Model (litellm_params.model)": "輸入 LiteLLM 模型(litellm_params.model)",
"Enter Max Tokens (litellm_params.max_tokens)": "輸入最大 Token 數(litellm_params.max_tokens)",
"Enter model tag (e.g. {{modelTag}})": "輸入模型標籤(例如 {{modelTag}})", "Enter model tag (e.g. {{modelTag}})": "輸入模型標籤(例如 {{modelTag}})",
"Enter Number of Steps (e.g. 50)": "輸入步數(例如 50)", "Enter Number of Steps (e.g. 50)": "輸入步數(例如 50)",
"Enter Score": "", "Enter Score": "輸入分數",
"Enter Searxng Query URL": "",
"Enter Serper API Key": "",
"Enter Serpstack API Key": "",
"Enter stop sequence": "輸入停止序列", "Enter stop sequence": "輸入停止序列",
"Enter Top K": "輸入 Top K", "Enter Top K": "輸入 Top K",
"Enter URL (e.g. http://127.0.0.1:7860/)": "輸入 URL(例如 http://127.0.0.1:7860/)", "Enter URL (e.g. http://127.0.0.1:7860/)": "輸入 URL(例如 http://127.0.0.1:7860/)",
"Enter URL (e.g. http://localhost:11434)": "", "Enter URL (e.g. http://localhost:11434)": "輸入 URL(例如 http://localhost:11434)",
"Enter Your Email": "輸入你的電子郵件", "Enter Your Email": "輸入你的電子郵件",
"Enter Your Full Name": "輸入你的全名", "Enter Your Full Name": "輸入你的全名",
"Enter Your Password": "輸入你的密碼", "Enter Your Password": "輸入你的密碼",
"Enter Your Role": "", "Enter Your Role": "輸入你的角色",
"Error": "",
"Experimental": "實驗功能", "Experimental": "實驗功能",
"Export All Chats (All Users)": "匯出所有聊天紀錄(所有使用者)", "Export All Chats (All Users)": "匯出所有聊天紀錄(所有使用者)",
"Export Chats": "匯出聊天紀錄", "Export Chats": "匯出聊天紀錄",
"Export Documents Mapping": "匯出文件映射", "Export Documents Mapping": "匯出文件映射",
"Export Modelfiles": "匯出 Modelfiles", "Export Models": "",
"Export Prompts": "匯出提示詞", "Export Prompts": "匯出提示詞",
"Failed to create API Key.": "", "Failed to create API Key.": "無法創建 API 金鑰。",
"Failed to read clipboard contents": "無法讀取剪貼簿內容", "Failed to read clipboard contents": "無法讀取剪貼簿內容",
"February": "", "February": "2月",
"Feel free to add specific details": "", "Feel free to add specific details": "請自由添加詳細內容。",
"File Mode": "檔案模式", "File Mode": "檔案模式",
"File not found.": "找不到檔案。", "File not found.": "找不到檔案。",
"Fingerprint spoofing detected: Unable to use initials as avatar. Defaulting to default profile image.": "", "Fingerprint spoofing detected: Unable to use initials as avatar. Defaulting to default profile image.": "偽裝偽裝檢測:無法使用頭像作為頭像。預設為預設頭像。",
"Fluidly stream large external response chunks": "流暢地傳輸大型外部響應區塊", "Fluidly stream large external response chunks": "流暢地傳輸大型外部響應區塊",
"Focus chat input": "聚焦聊天輸入框", "Focus chat input": "聚焦聊天輸入框",
"Followed instructions perfectly": "", "Followed instructions perfectly": "完全遵循指示",
"Format your variables using square brackets like this:": "像這樣使用方括號來格式化你的變數:", "Format your variables using square brackets like this:": "像這樣使用方括號來格式化你的變數:",
"From (Base Model)": "來自(基礎模型)", "Frequency Penalty": "",
"Full Screen Mode": "全螢幕模式", "Full Screen Mode": "全螢幕模式",
"General": "常用", "General": "常用",
"General Settings": "常用設定", "General Settings": "常用設定",
"Generation Info": "", "Generating search query": "",
"Good Response": "", "Generation Info": "生成信息",
"h:mm a": "", "Good Response": "優秀的回應",
"has no conversations.": "", "Google PSE API Key": "",
"Google PSE Engine Id": "",
"h:mm a": "h:mm a",
"has no conversations.": "沒有對話",
"Hello, {{name}}": "你好,{{name}}", "Hello, {{name}}": "你好,{{name}}",
"Help": "", "Help": "幫助",
"Hide": "隱藏", "Hide": "隱藏",
"Hide Additional Params": "隱藏額外參數",
"How can I help you today?": "今天能為你做什麼?", "How can I help you today?": "今天能為你做什麼?",
"Hybrid Search": "", "Hybrid Search": "混合搜索",
"Image Generation (Experimental)": "圖像生成(實驗功能)", "Image Generation (Experimental)": "圖像生成(實驗功能)",
"Image Generation Engine": "圖像生成引擎", "Image Generation Engine": "圖像生成引擎",
"Image Settings": "圖片設定", "Image Settings": "圖片設定",
"Images": "圖片", "Images": "圖片",
"Import Chats": "匯入聊天紀錄", "Import Chats": "匯入聊天紀錄",
"Import Documents Mapping": "匯入文件映射", "Import Documents Mapping": "匯入文件映射",
"Import Modelfiles": "匯入 Modelfiles", "Import Models": "",
"Import Prompts": "匯入提示詞", "Import Prompts": "匯入提示詞",
"Include `--api` flag when running stable-diffusion-webui": "在運行 stable-diffusion-webui 時加上 `--api` 標誌", "Include `--api` flag when running stable-diffusion-webui": "在運行 stable-diffusion-webui 時加上 `--api` 標誌",
"Info": "",
"Input commands": "輸入命令", "Input commands": "輸入命令",
"Install from Github URL": "",
"Interface": "介面", "Interface": "介面",
"Invalid Tag": "", "Invalid Tag": "無效標籤",
"January": "", "January": "1月",
"join our Discord for help.": "加入我們的 Discord 尋找幫助。", "join our Discord for help.": "加入我們的 Discord 尋找幫助。",
"JSON": "JSON", "JSON": "JSON",
"July": "", "JSON Preview": "",
"June": "", "July": "7月",
"June": "6月",
"JWT Expiration": "JWT 過期時間", "JWT Expiration": "JWT 過期時間",
"JWT Token": "JWT Token", "JWT Token": "JWT Token",
"Keep Alive": "保持活躍", "Keep Alive": "保持活躍",
"Keyboard shortcuts": "鍵盤快速鍵", "Keyboard shortcuts": "鍵盤快速鍵",
"Language": "語言", "Language": "語言",
"Last Active": "", "Last Active": "最後活動",
"Light": "亮色", "Light": "亮色",
"Listening...": "正在聽取...", "Listening...": "正在聽取...",
"LLMs can make mistakes. Verify important information.": "LLM 可能會產生錯誤。請驗證重要資訊。", "LLMs can make mistakes. Verify important information.": "LLM 可能會產生錯誤。請驗證重要資訊。",
"LTR": "", "LTR": "LTR",
"Made by OpenWebUI Community": "由 OpenWebUI 社區製作", "Made by OpenWebUI Community": "由 OpenWebUI 社區製作",
"Make sure to enclose them with": "請確保變數有被以下符號框住:", "Make sure to enclose them with": "請確保變數有被以下符號框住:",
"Manage LiteLLM Models": "管理 LiteLLM 模型",
"Manage Models": "管理模組", "Manage Models": "管理模組",
"Manage Ollama Models": "管理 Ollama 模型", "Manage Ollama Models": "管理 Ollama 模型",
"March": "", "Manage Pipelines": "",
"Max Tokens": "最大 Token 數", "March": "3月",
"Max Tokens (num_predict)": "",
"Maximum of 3 models can be downloaded simultaneously. Please try again later.": "最多可以同時下載 3 個模型。請稍後再試。", "Maximum of 3 models can be downloaded simultaneously. Please try again later.": "最多可以同時下載 3 個模型。請稍後再試。",
"May": "", "May": "5月",
"Memories accessible by LLMs will be shown here.": "", "Memories accessible by LLMs will be shown here.": "LLM 記憶將會顯示在此處。",
"Memory": "", "Memory": "記憶",
"Messages you send after creating your link won't be shared. Users with the URL will be able to view the shared chat.": "", "Messages you send after creating your link won't be shared. Users with the URL will be able to view the shared chat.": "創建連結後發送的訊息將不會被共享。具有 URL 的用戶將會能夠檢視共享的聊天。",
"Minimum Score": "", "Minimum Score": "最小分數",
"Mirostat": "Mirostat", "Mirostat": "Mirostat",
"Mirostat Eta": "Mirostat Eta", "Mirostat Eta": "Mirostat Eta",
"Mirostat Tau": "Mirostat Tau", "Mirostat Tau": "Mirostat Tau",
"MMMM DD, YYYY": "MMMM DD, YYYY", "MMMM DD, YYYY": "MMMM DD, YYYY",
"MMMM DD, YYYY HH:mm": "", "MMMM DD, YYYY HH:mm": "MMMM DD, YYYY HH:mm",
"Model '{{modelName}}' has been successfully downloaded.": "'{{modelName}}' 模型已成功下載。", "Model '{{modelName}}' has been successfully downloaded.": "'{{modelName}}' 模型已成功下載。",
"Model '{{modelTag}}' is already in queue for downloading.": "'{{modelTag}}' 模型已經在下載佇列中。", "Model '{{modelTag}}' is already in queue for downloading.": "'{{modelTag}}' 模型已經在下載佇列中。",
"Model {{modelId}} not found": "找不到 {{modelId}} 模型", "Model {{modelId}} not found": "找不到 {{modelId}} 模型",
"Model {{modelName}} already exists.": "模型 {{modelName}} 已存在。", "Model {{modelName}} is not vision capable": "",
"Model filesystem path detected. Model shortname is required for update, cannot continue.": "", "Model {{name}} is now {{status}}": "",
"Model Name": "模型名稱", "Model filesystem path detected. Model shortname is required for update, cannot continue.": "模型文件系統路徑已檢測。需要更新模型短名,無法繼續。",
"Model ID": "",
"Model not selected": "未選擇模型", "Model not selected": "未選擇模型",
"Model Tag Name": "模型標籤", "Model Params": "",
"Model Whitelisting": "白名單模型", "Model Whitelisting": "白名單模型",
"Model(s) Whitelisted": "模型已加入白名單", "Model(s) Whitelisted": "模型已加入白名單",
"Modelfile": "Modelfile",
"Modelfile Advanced Settings": "Modelfile 進階設定",
"Modelfile Content": "Modelfile 內容", "Modelfile Content": "Modelfile 內容",
"Modelfiles": "Modelfiles",
"Models": "模型", "Models": "模型",
"More": "", "More": "更多",
"Name": "名稱", "Name": "名稱",
"Name Tag": "名稱標籤", "Name Tag": "名稱標籤",
"Name your modelfile": "命名你的 Modelfile", "Name your model": "",
"New Chat": "新增聊天", "New Chat": "新增聊天",
"New Password": "新密碼", "New Password": "新密碼",
"No results found": "", "No results found": "沒有找到結果",
"No search query generated": "",
"No source available": "沒有可用的來源", "No source available": "沒有可用的來源",
"Not factually correct": "", "None": "",
"Not sure what to add?": "不確定要新增什麼嗎?", "Not factually correct": "與真實資訊不相符",
"Not sure what to write? Switch to": "不確定要寫什麼?切換到", "Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.": "註:如果設置最低分數,則搜索將只返回分數大於或等於最低分數的文檔。",
"Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.": "",
"Notifications": "桌面通知", "Notifications": "桌面通知",
"November": "", "November": "11月",
"October": "", "October": "10 月",
"Off": "關閉", "Off": "關閉",
"Okay, Let's Go!": "好的,啟動吧!", "Okay, Let's Go!": "好的,啟動吧!",
"OLED Dark": "", "OLED Dark": "`",
"Ollama": "", "Ollama": "Ollama",
"Ollama Base URL": "Ollama 基本 URL", "Ollama API": "",
"Ollama Version": "Ollama 版本", "Ollama Version": "Ollama 版本",
"On": "開啟", "On": "開啟",
"Only": "僅有", "Only": "僅有",
...@@ -314,59 +328,56 @@ ...@@ -314,59 +328,56 @@
"Open AI": "Open AI", "Open AI": "Open AI",
"Open AI (Dall-E)": "Open AI (Dall-E)", "Open AI (Dall-E)": "Open AI (Dall-E)",
"Open new chat": "開啟新聊天", "Open new chat": "開啟新聊天",
"OpenAI": "", "OpenAI": "OpenAI",
"OpenAI API": "OpenAI API", "OpenAI API": "OpenAI API",
"OpenAI API Config": "", "OpenAI API Config": "OpenAI API 設定",
"OpenAI API Key is required.": "需要 OpenAI API 金鑰。", "OpenAI API Key is required.": "需要 OpenAI API 金鑰。",
"OpenAI URL/Key required.": "", "OpenAI URL/Key required.": "需要 OpenAI URL/金鑰。",
"or": "或", "or": "或",
"Other": "", "Other": "其他",
"Overview": "",
"Parameters": "參數",
"Password": "密碼", "Password": "密碼",
"PDF document (.pdf)": "", "PDF document (.pdf)": "PDF 文件 (.pdf)",
"PDF Extract Images (OCR)": "PDF 圖像擷取(OCR 光學文字辨識)", "PDF Extract Images (OCR)": "PDF 圖像擷取(OCR 光學文字辨識)",
"pending": "待審查", "pending": "待審查",
"Permission denied when accessing microphone: {{error}}": "存取麥克風時被拒絕權限:{{error}}", "Permission denied when accessing microphone: {{error}}": "存取麥克風時被拒絕權限:{{error}}",
"Personalization": "", "Personalization": "個人化",
"Plain text (.txt)": "", "Pipelines": "",
"Pipelines Valves": "",
"Plain text (.txt)": "純文字 (.txt)",
"Playground": "AI 對話遊樂場", "Playground": "AI 對話遊樂場",
"Positive attitude": "", "Positive attitude": "積極態度",
"Previous 30 days": "", "Previous 30 days": "前 30 天",
"Previous 7 days": "", "Previous 7 days": "前 7 天",
"Profile Image": "", "Profile Image": "個人圖像",
"Prompt": "", "Prompt": "提示詞",
"Prompt (e.g. Tell me a fun fact about the Roman Empire)": "", "Prompt (e.g. Tell me a fun fact about the Roman Empire)": "提示詞(例如:告訴我關於羅馬帝國的趣味事)",
"Prompt Content": "提示詞內容", "Prompt Content": "提示詞內容",
"Prompt suggestions": "提示詞建議", "Prompt suggestions": "提示詞建議",
"Prompts": "提示詞", "Prompts": "提示詞",
"Pull \"{{searchValue}}\" from Ollama.com": "", "Pull \"{{searchValue}}\" from Ollama.com": "從 Ollama.com 下載 \"{{searchValue}}\"",
"Pull a model from Ollama.com": "從 Ollama.com 下載模型", "Pull a model from Ollama.com": "從 Ollama.com 下載模型",
"Pull Progress": "下載進度",
"Query Params": "查詢參數", "Query Params": "查詢參數",
"RAG Template": "RAG 範例", "RAG Template": "RAG 範例",
"Raw Format": "原始格式", "Read Aloud": "讀出",
"Read Aloud": "",
"Record voice": "錄音", "Record voice": "錄音",
"Redirecting you to OpenWebUI Community": "將你重新導向到 OpenWebUI 社群", "Redirecting you to OpenWebUI Community": "將你重新導向到 OpenWebUI 社群",
"Refused when it shouldn't have": "", "Refused when it shouldn't have": "拒絕時不該拒絕",
"Regenerate": "", "Regenerate": "重新生成",
"Release Notes": "發布說明", "Release Notes": "發布說明",
"Remove": "", "Remove": "移除",
"Remove Model": "", "Remove Model": "移除模型",
"Rename": "", "Rename": "重命名",
"Repeat Last N": "重複最後 N 次", "Repeat Last N": "重複最後 N 次",
"Repeat Penalty": "重複懲罰",
"Request Mode": "請求模式", "Request Mode": "請求模式",
"Reranking Model": "", "Reranking Model": "重新排序模型",
"Reranking model disabled": "", "Reranking model disabled": "重新排序模型已禁用",
"Reranking model set to \"{{reranking_model}}\"": "", "Reranking model set to \"{{reranking_model}}\"": "重新排序模型設定為 \"{{reranking_model}}\"",
"Reset Vector Storage": "重置向量儲存空間", "Reset Vector Storage": "重置向量儲存空間",
"Response AutoCopy to Clipboard": "自動複製回答到剪貼簿", "Response AutoCopy to Clipboard": "自動複製回答到剪貼簿",
"Role": "Role", "Role": "Role",
"Rosé Pine": "玫瑰松", "Rosé Pine": "玫瑰松",
"Rosé Pine Dawn": "黎明玫瑰松", "Rosé Pine Dawn": "黎明玫瑰松",
"RTL": "", "RTL": "RTL",
"Save": "儲存", "Save": "儲存",
"Save & Create": "儲存並建立", "Save & Create": "儲存並建立",
"Save & Update": "儲存並更新", "Save & Update": "儲存並更新",
...@@ -375,29 +386,41 @@ ...@@ -375,29 +386,41 @@
"Scan complete!": "掃描完成!", "Scan complete!": "掃描完成!",
"Scan for documents from {{path}}": "從 {{path}} 掃描文件", "Scan for documents from {{path}}": "從 {{path}} 掃描文件",
"Search": "搜尋", "Search": "搜尋",
"Search a model": "", "Search a model": "搜尋模型",
"Search Chats": "",
"Search Documents": "搜尋文件", "Search Documents": "搜尋文件",
"Search Models": "",
"Search Prompts": "搜尋提示詞", "Search Prompts": "搜尋提示詞",
"Search Result Count": "",
"Searched {{count}} sites_other": "",
"Searching the web for '{{searchQuery}}'": "",
"Searxng Query URL": "",
"See readme.md for instructions": "查看 readme.md 獲取指南", "See readme.md for instructions": "查看 readme.md 獲取指南",
"See what's new": "查看最新內容", "See what's new": "查看最新內容",
"Seed": "種子", "Seed": "種子",
"Select a base model": "",
"Select a mode": "選擇模式", "Select a mode": "選擇模式",
"Select a model": "選擇一個模型", "Select a model": "選擇一個模型",
"Select a pipeline": "",
"Select a pipeline url": "",
"Select an Ollama instance": "選擇 Ollama 實例", "Select an Ollama instance": "選擇 Ollama 實例",
"Select model": "選擇模型", "Select model": "選擇模型",
"Send": "", "Selected model(s) do not support image inputs": "",
"Send": "傳送",
"Send a Message": "傳送訊息", "Send a Message": "傳送訊息",
"Send message": "傳送訊息", "Send message": "傳送訊息",
"September": "九月", "September": "九月",
"Serper API Key": "",
"Serpstack API Key": "",
"Server connection verified": "已驗證伺服器連線", "Server connection verified": "已驗證伺服器連線",
"Set as default": "設為預設", "Set as default": "設為預設",
"Set Default Model": "設定預設模型", "Set Default Model": "設定預設模型",
"Set embedding model (e.g. {{model}})": "", "Set embedding model (e.g. {{model}})": "設定嵌入模型(例如:{{model}})",
"Set Image Size": "設定圖片大小", "Set Image Size": "設定圖片大小",
"Set Model": "設定模型", "Set Model": "設定模型",
"Set reranking model (e.g. {{model}})": "", "Set reranking model (e.g. {{model}})": "設定重新排序模型(例如:{{model}})",
"Set Steps": "設定步數", "Set Steps": "設定步數",
"Set Title Auto-Generation Model": "設定自動生成標題用模型", "Set Task Model": "",
"Set Voice": "設定語音", "Set Voice": "設定語音",
"Settings": "設定", "Settings": "設定",
"Settings saved successfully!": "成功儲存設定", "Settings saved successfully!": "成功儲存設定",
...@@ -406,9 +429,8 @@ ...@@ -406,9 +429,8 @@
"Share to OpenWebUI Community": "分享到 OpenWebUI 社群", "Share to OpenWebUI Community": "分享到 OpenWebUI 社群",
"short-summary": "簡短摘要", "short-summary": "簡短摘要",
"Show": "顯示", "Show": "顯示",
"Show Additional Params": "顯示額外參數",
"Show shortcuts": "顯示快速鍵", "Show shortcuts": "顯示快速鍵",
"Showcased creativity": "", "Showcased creativity": "展示創造性",
"sidebar": "側邊欄", "sidebar": "側邊欄",
"Sign in": "登入", "Sign in": "登入",
"Sign Out": "登出", "Sign Out": "登出",
...@@ -421,83 +443,87 @@ ...@@ -421,83 +443,87 @@
"Stop Sequence": "停止序列", "Stop Sequence": "停止序列",
"STT Settings": "語音轉文字設定", "STT Settings": "語音轉文字設定",
"Submit": "提交", "Submit": "提交",
"Subtitle (e.g. about the Roman Empire)": "", "Subtitle (e.g. about the Roman Empire)": "標題(例如:關於羅馬帝國)",
"Success": "成功", "Success": "成功",
"Successfully updated.": "更新成功。", "Successfully updated.": "更新成功。",
"Suggested": "建議", "Suggested": "建議",
"Sync All": "全部同步",
"System": "系統", "System": "系統",
"System Prompt": "系統提示詞", "System Prompt": "系統提示詞",
"Tags": "標籤", "Tags": "標籤",
"Tell us more:": "", "Tell us more:": "告訴我們更多:",
"Temperature": "溫度", "Temperature": "溫度",
"Template": "模板", "Template": "模板",
"Text Completion": "文本補全(Text Completion)", "Text Completion": "文本補全(Text Completion)",
"Text-to-Speech Engine": "文字轉語音引擎", "Text-to-Speech Engine": "文字轉語音引擎",
"Tfs Z": "Tfs Z", "Tfs Z": "Tfs Z",
"Thanks for your feedback!": "", "Thanks for your feedback!": "感謝你的回饋!",
"The score should be a value between 0.0 (0%) and 1.0 (100%).": "", "The score should be a value between 0.0 (0%) and 1.0 (100%).": "分數應該介於 0.0(0%)和 1.0(100%)之間。",
"Theme": "主題", "Theme": "主題",
"This ensures that your valuable conversations are securely saved to your backend database. Thank you!": "這確保你寶貴的對話安全地儲存到你的後台資料庫。謝謝!", "This ensures that your valuable conversations are securely saved to your backend database. Thank you!": "這確保你寶貴的對話安全地儲存到你的後台資料庫。謝謝!",
"This setting does not sync across browsers or devices.": "此設定不會在瀏覽器或裝置間同步。", "This setting does not sync across browsers or devices.": "此設定不會在瀏覽器或裝置間同步。",
"Thorough explanation": "", "Thorough explanation": "詳細說明",
"Tip: Update multiple variable slots consecutively by pressing the tab key in the chat input after each replacement.": "提示:透過在每次替換後在聊天輸入框中按 Tab 鍵連續更新多個變數。", "Tip: Update multiple variable slots consecutively by pressing the tab key in the chat input after each replacement.": "提示:透過在每次替換後在聊天輸入框中按 Tab 鍵連續更新多個變數。",
"Title": "標題", "Title": "標題",
"Title (e.g. Tell me a fun fact)": "", "Title (e.g. Tell me a fun fact)": "標題(例如:告訴我一個有趣的事)",
"Title Auto-Generation": "自動生成標題", "Title Auto-Generation": "自動生成標題",
"Title cannot be an empty string.": "", "Title cannot be an empty string.": "標題不能為空字串",
"Title Generation Prompt": "自動生成標題的提示詞", "Title Generation Prompt": "自動生成標題的提示詞",
"to": "到", "to": "到",
"To access the available model names for downloading,": "若想查看可供下載的模型名稱,", "To access the available model names for downloading,": "若想查看可供下載的模型名稱,",
"To access the GGUF models available for downloading,": "若想查看可供下載的 GGUF 模型名稱,", "To access the GGUF models available for downloading,": "若想查看可供下載的 GGUF 模型名稱,",
"to chat input.": "到聊天輸入框來啟動此命令。", "to chat input.": "到聊天輸入框來啟動此命令。",
"Today": "", "Today": "今天",
"Toggle settings": "切換設定", "Toggle settings": "切換設定",
"Toggle sidebar": "切換側邊欄", "Toggle sidebar": "切換側邊欄",
"Top K": "Top K", "Top K": "Top K",
"Top P": "Top P", "Top P": "Top P",
"Trouble accessing Ollama?": "存取 Ollama 時遇到問題?", "Trouble accessing Ollama?": "存取 Ollama 時遇到問題?",
"TTS Settings": "文字轉語音設定", "TTS Settings": "文字轉語音設定",
"Type": "",
"Type Hugging Face Resolve (Download) URL": "輸入 Hugging Face 解析後的(下載)URL", "Type Hugging Face Resolve (Download) URL": "輸入 Hugging Face 解析後的(下載)URL",
"Uh-oh! There was an issue connecting to {{provider}}.": "哎呀!連線到 {{provider}} 時出現問題。", "Uh-oh! There was an issue connecting to {{provider}}.": "哎呀!連線到 {{provider}} 時出現問題。",
"Unknown File Type '{{file_type}}', but accepting and treating as plain text": "未知的文件類型 '{{file_type}}',但接受並視為純文字", "Unknown File Type '{{file_type}}', but accepting and treating as plain text": "未知的文件類型 '{{file_type}}',但接受並視為純文字",
"Update and Copy Link": "", "Update and Copy Link": "更新並複製連結",
"Update password": "更新密碼", "Update password": "更新密碼",
"Upload a GGUF model": "上傳一個 GGUF 模型", "Upload a GGUF model": "上傳一個 GGUF 模型",
"Upload files": "上傳文件", "Upload Files": "",
"Upload Progress": "上傳進度", "Upload Progress": "上傳進度",
"URL Mode": "URL 模式", "URL Mode": "URL 模式",
"Use '#' in the prompt input to load and select your documents.": "在輸入框中輸入 '#' 以載入並選擇你的文件。", "Use '#' in the prompt input to load and select your documents.": "在輸入框中輸入 '#' 以載入並選擇你的文件。",
"Use Gravatar": "使用 Gravatar", "Use Gravatar": "使用 Gravatar",
"Use Initials": "", "Use Initials": "使用初始头像",
"user": "使用者", "user": "使用者",
"User Permissions": "使用者權限", "User Permissions": "使用者權限",
"Users": "使用者", "Users": "使用者",
"Utilize": "使用", "Utilize": "使用",
"Valid time units:": "有效時間單位:", "Valid time units:": "有效時間單位:",
"variable": "變數", "variable": "變數",
"variable to have them replaced with clipboard content.": "變數將替換為剪貼簿內容", "variable to have them replaced with clipboard content.": "變數將替換為剪貼簿內容",
"Version": "版本", "Version": "版本",
"Warning: If you update or change your embedding model, you will need to re-import all documents.": "", "Warning": "",
"Warning: If you update or change your embedding model, you will need to re-import all documents.": "警告:如果更新或更改你的嵌入模型,則需要重新導入所有文件",
"Web": "網頁", "Web": "網頁",
"Web Loader Settings": "", "Web Loader Settings": "Web 載入器設定",
"Web Params": "", "Web Params": "Web 參數",
"Webhook URL": "", "Web Search": "",
"Web Search Engine": "",
"Webhook URL": "Webhook URL",
"WebUI Add-ons": "WebUI 擴充套件", "WebUI Add-ons": "WebUI 擴充套件",
"WebUI Settings": "WebUI 設定", "WebUI Settings": "WebUI 設定",
"WebUI will make requests to": "WebUI 將會存取", "WebUI will make requests to": "WebUI 將會存取",
"What’s New in": "全新內容", "What’s New in": "全新內容",
"When history is turned off, new chats on this browser won't appear in your history on any of your devices.": "當歷史被關閉時,這個瀏覽器上的新聊天將不會出現在任何裝置的歷史記錄中", "When history is turned off, new chats on this browser won't appear in your history on any of your devices.": "當歷史被關閉時,這個瀏覽器上的新聊天將不會出現在任何裝置的歷史記錄中",
"Whisper (Local)": "Whisper(本機)", "Whisper (Local)": "Whisper(本機)",
"Workspace": "", "Workspace": "工作區",
"Write a prompt suggestion (e.g. Who are you?)": "寫一個提示詞建議(例如:你是誰?)", "Write a prompt suggestion (e.g. Who are you?)": "寫一個提示詞建議(例如:你是誰?)",
"Write a summary in 50 words that summarizes [topic or keyword].": "寫一個 50 字的摘要來概括 [主題或關鍵詞]。", "Write a summary in 50 words that summarizes [topic or keyword].": "寫一個 50 字的摘要來概括 [主題或關鍵詞]。",
"Yesterday": "", "Yesterday": "昨天",
"You": "", "You": "你",
"You have no archived conversations.": "", "You cannot clone a base model": "",
"You have shared this chat": "", "You have no archived conversations.": "你沒有任何已封存的對話",
"You have shared this chat": "你已分享此聊天",
"You're a helpful assistant.": "你是一位善於協助他人的助手。", "You're a helpful assistant.": "你是一位善於協助他人的助手。",
"You're now logged in.": "已登入。", "You're now logged in.": "已登入。",
"Youtube": "", "Youtube": "Youtube",
"Youtube Loader Settings": "" "Youtube Loader Settings": "Youtube 載入器設定"
} }
import { APP_NAME } from '$lib/constants'; import { APP_NAME } from '$lib/constants';
import { type Writable, writable } from 'svelte/store'; import { type Writable, writable } from 'svelte/store';
import type { GlobalModelConfig, ModelConfig } from '$lib/apis';
import type { Banner } from '$lib/types';
// Backend // Backend
export const WEBUI_NAME = writable(APP_NAME); export const WEBUI_NAME = writable(APP_NAME);
...@@ -35,6 +37,8 @@ export const documents = writable([ ...@@ -35,6 +37,8 @@ export const documents = writable([
} }
]); ]);
export const banners: Writable<Banner[]> = writable([]);
export const settings: Writable<Settings> = writable({}); export const settings: Writable<Settings> = writable({});
export const showSidebar = writable(false); export const showSidebar = writable(false);
...@@ -42,27 +46,27 @@ export const showSettings = writable(false); ...@@ -42,27 +46,27 @@ export const showSettings = writable(false);
export const showArchivedChats = writable(false); export const showArchivedChats = writable(false);
export const showChangelog = writable(false); export const showChangelog = writable(false);
type Model = OpenAIModel | OllamaModel; export type Model = OpenAIModel | OllamaModel;
type OpenAIModel = { type BaseModel = {
id: string; id: string;
name: string; name: string;
external: boolean; info?: ModelConfig;
source?: string;
}; };
type OllamaModel = { export interface OpenAIModel extends BaseModel {
id: string; external: boolean;
name: string; source?: string;
}
// Ollama specific fields export interface OllamaModel extends BaseModel {
details: OllamaModelDetails; details: OllamaModelDetails;
size: number; size: number;
description: string; description: string;
model: string; model: string;
modified_at: string; modified_at: string;
digest: string; digest: string;
}; }
type OllamaModelDetails = { type OllamaModelDetails = {
parent_model: string; parent_model: string;
...@@ -125,14 +129,21 @@ type Prompt = { ...@@ -125,14 +129,21 @@ type Prompt = {
}; };
type Config = { type Config = {
status?: boolean; status: boolean;
name?: string; name: string;
version?: string; version: string;
default_locale?: string; default_locale: string;
images?: boolean; default_models: string[];
default_models?: string[]; default_prompt_suggestions: PromptSuggestion[];
default_prompt_suggestions?: PromptSuggestion[]; features: {
trusted_header_auth?: boolean; auth: boolean;
auth_trusted_header: boolean;
enable_signup: boolean;
enable_web_search?: boolean;
enable_image_generation: boolean;
enable_admin_export: boolean;
enable_community_sharing: boolean;
};
}; };
type PromptSuggestion = { type PromptSuggestion = {
......
export type Banner = {
id: string;
type: string;
title?: string;
content: string;
url?: string;
dismissible?: boolean;
timestamp: number;
};
import { promptTemplate } from '$lib/utils/index'; import { titleGenerationTemplate } from '$lib/utils/index';
import { expect, test } from 'vitest'; import { expect, test } from 'vitest';
test('promptTemplate correctly replaces {{prompt}} placeholder', () => { test('titleGenerationTemplate correctly replaces {{prompt}} placeholder', () => {
const template = 'Hello {{prompt}}!'; const template = 'Hello {{prompt}}!';
const prompt = 'world'; const prompt = 'world';
const expected = 'Hello world!'; const expected = 'Hello world!';
const actual = promptTemplate(template, prompt); const actual = titleGenerationTemplate(template, prompt);
expect(actual).toBe(expected); expect(actual).toBe(expected);
}); });
test('promptTemplate correctly replaces {{prompt:start:<length>}} placeholder', () => { test('titleGenerationTemplate correctly replaces {{prompt:start:<length>}} placeholder', () => {
const template = 'Hello {{prompt:start:3}}!'; const template = 'Hello {{prompt:start:3}}!';
const prompt = 'world'; const prompt = 'world';
const expected = 'Hello wor!'; const expected = 'Hello wor!';
const actual = promptTemplate(template, prompt); const actual = titleGenerationTemplate(template, prompt);
expect(actual).toBe(expected); expect(actual).toBe(expected);
}); });
test('promptTemplate correctly replaces {{prompt:end:<length>}} placeholder', () => { test('titleGenerationTemplate correctly replaces {{prompt:end:<length>}} placeholder', () => {
const template = 'Hello {{prompt:end:3}}!'; const template = 'Hello {{prompt:end:3}}!';
const prompt = 'world'; const prompt = 'world';
const expected = 'Hello rld!'; const expected = 'Hello rld!';
const actual = promptTemplate(template, prompt); const actual = titleGenerationTemplate(template, prompt);
expect(actual).toBe(expected); expect(actual).toBe(expected);
}); });
test('promptTemplate correctly replaces {{prompt:middletruncate:<length>}} placeholder when prompt length is greater than length', () => { test('titleGenerationTemplate correctly replaces {{prompt:middletruncate:<length>}} placeholder when prompt length is greater than length', () => {
const template = 'Hello {{prompt:middletruncate:4}}!'; const template = 'Hello {{prompt:middletruncate:4}}!';
const prompt = 'world'; const prompt = 'world';
const expected = 'Hello wo...ld!'; const expected = 'Hello wo...ld!';
const actual = promptTemplate(template, prompt); const actual = titleGenerationTemplate(template, prompt);
expect(actual).toBe(expected); expect(actual).toBe(expected);
}); });
test('promptTemplate correctly replaces {{prompt:middletruncate:<length>}} placeholder when prompt length is less than or equal to length', () => { test('titleGenerationTemplate correctly replaces {{prompt:middletruncate:<length>}} placeholder when prompt length is less than or equal to length', () => {
const template = 'Hello {{prompt:middletruncate:5}}!'; const template = 'Hello {{prompt:middletruncate:5}}!';
const prompt = 'world'; const prompt = 'world';
const expected = 'Hello world!'; const expected = 'Hello world!';
const actual = promptTemplate(template, prompt); const actual = titleGenerationTemplate(template, prompt);
expect(actual).toBe(expected); expect(actual).toBe(expected);
}); });
test('promptTemplate returns original template when no placeholders are present', () => { test('titleGenerationTemplate returns original template when no placeholders are present', () => {
const template = 'Hello world!'; const template = 'Hello world!';
const prompt = 'world'; const prompt = 'world';
const expected = 'Hello world!'; const expected = 'Hello world!';
const actual = promptTemplate(template, prompt); const actual = titleGenerationTemplate(template, prompt);
expect(actual).toBe(expected); expect(actual).toBe(expected);
}); });
test('promptTemplate does not replace placeholders inside of replaced placeholders', () => { test('titleGenerationTemplate does not replace placeholders inside of replaced placeholders', () => {
const template = 'Hello {{prompt}}!'; const template = 'Hello {{prompt}}!';
const prompt = 'World, {{prompt}} injection'; const prompt = 'World, {{prompt}} injection';
const expected = 'Hello World, {{prompt}} injection!'; const expected = 'Hello World, {{prompt}} injection!';
const actual = promptTemplate(template, prompt); const actual = titleGenerationTemplate(template, prompt);
expect(actual).toBe(expected); expect(actual).toBe(expected);
}); });
test('promptTemplate correctly replaces multiple placeholders', () => { test('titleGenerationTemplate correctly replaces multiple placeholders', () => {
const template = 'Hello {{prompt}}! This is {{prompt:start:3}}!'; const template = 'Hello {{prompt}}! This is {{prompt:start:3}}!';
const prompt = 'world'; const prompt = 'world';
const expected = 'Hello world! This is wor!'; const expected = 'Hello world! This is wor!';
const actual = promptTemplate(template, prompt); const actual = titleGenerationTemplate(template, prompt);
expect(actual).toBe(expected); expect(actual).toBe(expected);
}); });
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import sha256 from 'js-sha256'; import sha256 from 'js-sha256';
import { getOllamaModels } from '$lib/apis/ollama';
import { getOpenAIModels } from '$lib/apis/openai';
import { getLiteLLMModels } from '$lib/apis/litellm';
export const getModels = async (token: string) => {
let models = await Promise.all([
getOllamaModels(token).catch((error) => {
console.log(error);
return null;
}),
getOpenAIModels(token).catch((error) => {
console.log(error);
return null;
}),
getLiteLLMModels(token).catch((error) => {
console.log(error);
return null;
})
]);
models = models.filter((models) => models).reduce((a, e, i, arr) => a.concat(e), []);
return models;
};
////////////////////////// //////////////////////////
// Helper functions // Helper functions
...@@ -36,11 +12,12 @@ export const sanitizeResponseContent = (content: string) => { ...@@ -36,11 +12,12 @@ export const sanitizeResponseContent = (content: string) => {
.replace(/<$/, '') .replace(/<$/, '')
.replaceAll(/<\|[a-z]+\|>/g, ' ') .replaceAll(/<\|[a-z]+\|>/g, ' ')
.replaceAll('<', '&lt;') .replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.trim(); .trim();
}; };
export const revertSanitizedResponseContent = (content: string) => { export const revertSanitizedResponseContent = (content: string) => {
return content.replaceAll('&lt;', '<'); return content.replaceAll('&lt;', '<').replaceAll('&gt;', '>');
}; };
export const capitalizeFirstLetter = (string) => { export const capitalizeFirstLetter = (string) => {
...@@ -472,6 +449,42 @@ export const blobToFile = (blob, fileName) => { ...@@ -472,6 +449,42 @@ export const blobToFile = (blob, fileName) => {
return file; return file;
}; };
/**
* @param {string} template - The template string containing placeholders.
* @returns {string} The template string with the placeholders replaced by the prompt.
*/
export const promptTemplate = (
template: string,
user_name?: string,
current_location?: string
): string => {
// Get the current date
const currentDate = new Date();
// Format the date to YYYY-MM-DD
const formattedDate =
currentDate.getFullYear() +
'-' +
String(currentDate.getMonth() + 1).padStart(2, '0') +
'-' +
String(currentDate.getDate()).padStart(2, '0');
// Replace {{CURRENT_DATE}} in the template with the formatted date
template = template.replace('{{CURRENT_DATE}}', formattedDate);
if (user_name) {
// Replace {{USER_NAME}} in the template with the user's name
template = template.replace('{{USER_NAME}}', user_name);
}
if (current_location) {
// Replace {{CURRENT_LOCATION}} in the template with the current location
template = template.replace('{{CURRENT_LOCATION}}', current_location);
}
return template;
};
/** /**
* This function is used to replace placeholders in a template string with the provided prompt. * This function is used to replace placeholders in a template string with the provided prompt.
* The placeholders can be in the following formats: * The placeholders can be in the following formats:
...@@ -484,8 +497,8 @@ export const blobToFile = (blob, fileName) => { ...@@ -484,8 +497,8 @@ export const blobToFile = (blob, fileName) => {
* @param {string} prompt - The string to replace the placeholders with. * @param {string} prompt - The string to replace the placeholders with.
* @returns {string} The template string with the placeholders replaced by the prompt. * @returns {string} The template string with the placeholders replaced by the prompt.
*/ */
export const promptTemplate = (template: string, prompt: string): string => { export const titleGenerationTemplate = (template: string, prompt: string): string => {
return template.replace( template = template.replace(
/{{prompt}}|{{prompt:start:(\d+)}}|{{prompt:end:(\d+)}}|{{prompt:middletruncate:(\d+)}}/g, /{{prompt}}|{{prompt:start:(\d+)}}|{{prompt:end:(\d+)}}|{{prompt:middletruncate:(\d+)}}/g,
(match, startLength, endLength, middleLength) => { (match, startLength, endLength, middleLength) => {
if (match === '{{prompt}}') { if (match === '{{prompt}}') {
...@@ -505,6 +518,10 @@ export const promptTemplate = (template: string, prompt: string): string => { ...@@ -505,6 +518,10 @@ export const promptTemplate = (template: string, prompt: string): string => {
return ''; return '';
} }
); );
template = promptTemplate(template);
return template;
}; };
export const approximateToHumanReadable = (nanoseconds: number) => { export const approximateToHumanReadable = (nanoseconds: number) => {
......
...@@ -7,9 +7,8 @@ ...@@ -7,9 +7,8 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { getModels as _getModels } from '$lib/utils'; import { getModels as _getModels } from '$lib/apis';
import { getOllamaVersion } from '$lib/apis/ollama'; import { getOllamaVersion } from '$lib/apis/ollama';
import { getModelfiles } from '$lib/apis/modelfiles';
import { getPrompts } from '$lib/apis/prompts'; import { getPrompts } from '$lib/apis/prompts';
import { getDocs } from '$lib/apis/documents'; import { getDocs } from '$lib/apis/documents';
...@@ -20,10 +19,10 @@ ...@@ -20,10 +19,10 @@
showSettings, showSettings,
settings, settings,
models, models,
modelfiles,
prompts, prompts,
documents, documents,
tags, tags,
banners,
showChangelog, showChangelog,
config config
} from '$lib/stores'; } from '$lib/stores';
...@@ -35,6 +34,8 @@ ...@@ -35,6 +34,8 @@
import ShortcutsModal from '$lib/components/chat/ShortcutsModal.svelte'; import ShortcutsModal from '$lib/components/chat/ShortcutsModal.svelte';
import ChangelogModal from '$lib/components/ChangelogModal.svelte'; import ChangelogModal from '$lib/components/ChangelogModal.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import { getBanners } from '$lib/apis/configs';
import { getUserSettings } from '$lib/apis/users';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
...@@ -50,21 +51,6 @@ ...@@ -50,21 +51,6 @@
return _getModels(localStorage.token); return _getModels(localStorage.token);
}; };
const setOllamaVersion = async (version: string = '') => {
if (version === '') {
version = await getOllamaVersion(localStorage.token).catch((error) => {
return '';
});
}
ollamaVersion = version;
console.log(ollamaVersion);
if (compareVersion(REQUIRED_OLLAMA_VERSION, ollamaVersion)) {
toast.error(`Ollama Version: ${ollamaVersion !== '' ? ollamaVersion : 'Not Detected'}`);
}
};
onMount(async () => { onMount(async () => {
if ($user === undefined) { if ($user === undefined) {
await goto('/auth'); await goto('/auth');
...@@ -87,18 +73,31 @@ ...@@ -87,18 +73,31 @@
// IndexedDB Not Found // IndexedDB Not Found
} }
await models.set(await getModels()); const userSettings = await getUserSettings(localStorage.token);
await settings.set(JSON.parse(localStorage.getItem('settings') ?? '{}'));
await modelfiles.set(await getModelfiles(localStorage.token)); if (userSettings) {
await prompts.set(await getPrompts(localStorage.token)); await settings.set(userSettings.ui);
await documents.set(await getDocs(localStorage.token)); } else {
await tags.set(await getAllChatTags(localStorage.token)); await settings.set(JSON.parse(localStorage.getItem('settings') ?? '{}'));
}
modelfiles.subscribe(async () => { await Promise.all([
// should fetch models (async () => {
await models.set(await getModels()); models.set(await getModels());
}); })(),
(async () => {
prompts.set(await getPrompts(localStorage.token));
})(),
(async () => {
documents.set(await getDocs(localStorage.token));
})(),
(async () => {
banners.set(await getBanners(localStorage.token));
})(),
(async () => {
tags.set(await getAllChatTags(localStorage.token));
})()
]);
document.addEventListener('keydown', function (event) { document.addEventListener('keydown', function (event) {
const isCtrlPressed = event.ctrlKey || event.metaKey; // metaKey is for Cmd key on Mac const isCtrlPressed = event.ctrlKey || event.metaKey; // metaKey is for Cmd key on Mac
...@@ -176,12 +175,12 @@ ...@@ -176,12 +175,12 @@
}); });
</script> </script>
<div class=" hidden lg:flex fixed bottom-0 right-0 px-3 py-3 z-10"> <div class=" hidden lg:flex fixed bottom-0 right-0 px-2 py-2 z-10">
<Tooltip content={$i18n.t('Help')} placement="left"> <Tooltip content={$i18n.t('Help')} placement="left">
<button <button
id="show-shortcuts-button" id="show-shortcuts-button"
bind:this={showShortcutsButtonElement} bind:this={showShortcutsButtonElement}
class="text-gray-600 dark:text-gray-300 bg-gray-300/20 w-6 h-6 flex items-center justify-center text-xs rounded-full" class="text-gray-600 dark:text-gray-300 bg-gray-300/20 size-5 flex items-center justify-center text-[0.7rem] rounded-full"
on:click={() => { on:click={() => {
showShortcuts = !showShortcuts; showShortcuts = !showShortcuts;
}} }}
......
<script lang="ts"> <script lang="ts">
import { toast } from 'svelte-sonner'; import Chat from '$lib/components/chat/Chat.svelte';
import { v4 as uuidv4 } from 'uuid';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { getContext, onMount, tick } from 'svelte';
import {
WEBUI_NAME,
tags as _tags,
chatId,
chats,
config,
modelfiles,
models,
settings,
showSidebar,
user
} from '$lib/stores';
import { copyToClipboard, splitStream } from '$lib/utils';
import {
addTagById,
createNewChat,
deleteTagById,
getAllChatTags,
getChatList,
getTagsById,
updateChatById
} from '$lib/apis/chats';
import { cancelOllamaRequest, generateChatCompletion } from '$lib/apis/ollama';
import { generateOpenAIChatCompletion, generateTitle } from '$lib/apis/openai';
import { queryCollection, queryDoc } from '$lib/apis/rag';
import { queryMemory } from '$lib/apis/memories';
import { createOpenAITextStream } from '$lib/apis/streaming';
import MessageInput from '$lib/components/chat/MessageInput.svelte';
import Messages from '$lib/components/chat/Messages.svelte';
import ModelSelector from '$lib/components/chat/ModelSelector.svelte';
import Navbar from '$lib/components/layout/Navbar.svelte';
import {
LITELLM_API_BASE_URL,
OLLAMA_API_BASE_URL,
OPENAI_API_BASE_URL,
WEBUI_BASE_URL
} from '$lib/constants';
import { RAGTemplate } from '$lib/utils/rag';
const i18n = getContext('i18n');
let stopResponseFlag = false;
let autoScroll = true;
let processing = '';
let messagesContainerElement: HTMLDivElement;
let currentRequestId = null;
let showModelSelector = true;
let selectedModels = [''];
let atSelectedModel = '';
let selectedModelfile = null;
$: selectedModelfile =
selectedModels.length === 1 &&
$modelfiles.filter((modelfile) => modelfile.tagName === selectedModels[0]).length > 0
? $modelfiles.filter((modelfile) => modelfile.tagName === selectedModels[0])[0]
: null;
let selectedModelfiles = {};
$: selectedModelfiles = selectedModels.reduce((a, tagName, i, arr) => {
const modelfile =
$modelfiles.filter((modelfile) => modelfile.tagName === tagName)?.at(0) ?? undefined;
return {
...a,
...(modelfile && { [tagName]: modelfile })
};
}, {});
let chat = null;
let tags = [];
let title = '';
let prompt = '';
let files = [];
let messages = [];
let history = {
messages: {},
currentId: null
};
$: if (history.currentId !== null) {
let _messages = [];
let currentMessage = history.messages[history.currentId];
while (currentMessage !== null) {
_messages.unshift({ ...currentMessage });
currentMessage =
currentMessage.parentId !== null ? history.messages[currentMessage.parentId] : null;
}
messages = _messages;
} else {
messages = [];
}
onMount(async () => {
await initNewChat();
});
//////////////////////////
// Web functions
//////////////////////////
const initNewChat = async () => {
if (currentRequestId !== null) {
await cancelOllamaRequest(localStorage.token, currentRequestId);
currentRequestId = null;
}
window.history.replaceState(history.state, '', `/`);
await chatId.set('');
autoScroll = true;
title = '';
messages = [];
history = {
messages: {},
currentId: null
};
if ($page.url.searchParams.get('models')) {
selectedModels = $page.url.searchParams.get('models')?.split(',');
} else if ($settings?.models) {
selectedModels = $settings?.models;
} else if ($config?.default_models) {
selectedModels = $config?.default_models.split(',');
} else {
selectedModels = [''];
}
if ($page.url.searchParams.get('q')) {
prompt = $page.url.searchParams.get('q') ?? '';
if (prompt) {
await tick();
submitPrompt(prompt);
}
}
selectedModels = selectedModels.map((modelId) =>
$models.map((m) => m.id).includes(modelId) ? modelId : ''
);
let _settings = JSON.parse(localStorage.getItem('settings') ?? '{}');
settings.set({
..._settings
});
const chatInput = document.getElementById('chat-textarea');
setTimeout(() => chatInput?.focus(), 0);
};
const scrollToBottom = async () => {
await tick();
if (messagesContainerElement) {
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
}
};
//////////////////////////
// Ollama functions
//////////////////////////
const submitPrompt = async (userPrompt, _user = null) => {
console.log('submitPrompt', $chatId);
selectedModels = selectedModels.map((modelId) =>
$models.map((m) => m.id).includes(modelId) ? modelId : ''
);
if (selectedModels.includes('')) {
toast.error($i18n.t('Model not selected'));
} else if (messages.length != 0 && messages.at(-1).done != true) {
// Response not done
console.log('wait');
} else if (
files.length > 0 &&
files.filter((file) => file.upload_status === false).length > 0
) {
// Upload not done
toast.error(
$i18n.t(
`Oops! Hold tight! Your files are still in the processing oven. We're cooking them up to perfection. Please be patient and we'll let you know once they're ready.`
)
);
} else {
// Reset chat message textarea height
document.getElementById('chat-textarea').style.height = '';
// Create user message
let userMessageId = uuidv4();
let userMessage = {
id: userMessageId,
parentId: messages.length !== 0 ? messages.at(-1).id : null,
childrenIds: [],
role: 'user',
user: _user ?? undefined,
content: userPrompt,
files: files.length > 0 ? files : undefined,
models: selectedModels.filter((m, mIdx) => selectedModels.indexOf(m) === mIdx),
timestamp: Math.floor(Date.now() / 1000) // Unix epoch
};
// Add message to history and Set currentId to messageId
history.messages[userMessageId] = userMessage;
history.currentId = userMessageId;
// Append messageId to childrenIds of parent message
if (messages.length !== 0) {
history.messages[messages.at(-1).id].childrenIds.push(userMessageId);
}
// Wait until history/message have been updated
await tick();
// Create new chat if only one message in messages
if (messages.length == 1) {
if ($settings.saveChatHistory ?? true) {
chat = await createNewChat(localStorage.token, {
id: $chatId,
title: $i18n.t('New Chat'),
models: selectedModels,
system: $settings.system ?? undefined,
options: {
...($settings.options ?? {})
},
messages: messages,
history: history,
tags: [],
timestamp: Date.now()
});
await chats.set(await getChatList(localStorage.token));
await chatId.set(chat.id);
} else {
await chatId.set('local');
}
await tick();
}
// Reset chat input textarea
prompt = '';
files = [];
// Send prompt
await sendPrompt(userPrompt, userMessageId);
}
};
const sendPrompt = async (prompt, parentId, modelId = null) => {
const _chatId = JSON.parse(JSON.stringify($chatId));
await Promise.all(
(modelId ? [modelId] : atSelectedModel !== '' ? [atSelectedModel.id] : selectedModels).map(
async (modelId) => {
console.log('modelId', modelId);
const model = $models.filter((m) => m.id === modelId).at(0);
if (model) {
// Create response message
let responseMessageId = uuidv4();
let responseMessage = {
parentId: parentId,
id: responseMessageId,
childrenIds: [],
role: 'assistant',
content: '',
model: model.id,
userContext: null,
timestamp: Math.floor(Date.now() / 1000) // Unix epoch
};
// Add message to history and Set currentId to messageId
history.messages[responseMessageId] = responseMessage;
history.currentId = responseMessageId;
// Append messageId to childrenIds of parent message
if (parentId !== null) {
history.messages[parentId].childrenIds = [
...history.messages[parentId].childrenIds,
responseMessageId
];
}
await tick();
let userContext = null;
if ($settings?.memory ?? false) {
if (userContext === null) {
const res = await queryMemory(localStorage.token, prompt).catch((error) => {
toast.error(error);
return null;
});
if (res) {
if (res.documents[0].length > 0) {
userContext = res.documents.reduce((acc, doc, index) => {
const createdAtTimestamp = res.metadatas[index][0].created_at;
const createdAtDate = new Date(createdAtTimestamp * 1000)
.toISOString()
.split('T')[0];
acc.push(`${index + 1}. [${createdAtDate}]. ${doc[0]}`);
return acc;
}, []);
}
console.log(userContext);
}
}
}
responseMessage.userContext = userContext;
if (model?.external) {
await sendPromptOpenAI(model, prompt, responseMessageId, _chatId);
} else if (model) {
await sendPromptOllama(model, prompt, responseMessageId, _chatId);
}
} else {
toast.error($i18n.t(`Model {{modelId}} not found`, { modelId }));
}
}
)
);
await chats.set(await getChatList(localStorage.token));
};
const sendPromptOllama = async (model, userPrompt, responseMessageId, _chatId) => {
model = model.id;
const responseMessage = history.messages[responseMessageId];
// Wait until history/message have been updated
await tick();
// Scroll down
scrollToBottom();
const messagesBody = [
$settings.system || (responseMessage?.userContext ?? null)
? {
role: 'system',
content: `${$settings?.system ?? ''}${
responseMessage?.userContext ?? null
? `\n\nUser Context:\n${(responseMessage?.userContext ?? []).join('\n')}`
: ''
}`
}
: undefined,
...messages
]
.filter((message) => message)
.map((message, idx, arr) => {
// Prepare the base message object
const baseMessage = {
role: message.role,
content: arr.length - 2 !== idx ? message.content : message?.raContent ?? message.content
};
// Extract and format image URLs if any exist
const imageUrls = message.files
?.filter((file) => file.type === 'image')
.map((file) => file.url.slice(file.url.indexOf(',') + 1));
// Add images array only if it contains elements
if (imageUrls && imageUrls.length > 0 && message.role === 'user') {
baseMessage.images = imageUrls;
}
return baseMessage;
});
let lastImageIndex = -1;
// Find the index of the last object with images
messagesBody.forEach((item, index) => {
if (item.images) {
lastImageIndex = index;
}
});
// Remove images from all but the last one
messagesBody.forEach((item, index) => {
if (index !== lastImageIndex) {
delete item.images;
}
});
const docs = messages
.filter((message) => message?.files ?? null)
.map((message) =>
message.files.filter((item) => item.type === 'doc' || item.type === 'collection')
)
.flat(1);
const [res, controller] = await generateChatCompletion(localStorage.token, {
model: model,
messages: messagesBody,
options: {
...($settings.options ?? {}),
stop:
$settings?.options?.stop ?? undefined
? $settings.options.stop.map((str) =>
decodeURIComponent(JSON.parse('"' + str.replace(/\"/g, '\\"') + '"'))
)
: undefined
},
format: $settings.requestFormat ?? undefined,
keep_alive: $settings.keepAlive ?? undefined,
docs: docs.length > 0 ? docs : undefined,
citations: docs.length > 0
});
if (res && res.ok) {
console.log('controller', controller);
const reader = res.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(splitStream('\n'))
.getReader();
while (true) {
const { value, done } = await reader.read();
if (done || stopResponseFlag || _chatId !== $chatId) {
responseMessage.done = true;
messages = messages;
if (stopResponseFlag) {
controller.abort('User: Stop Response');
await cancelOllamaRequest(localStorage.token, currentRequestId);
}
currentRequestId = null;
break;
}
try {
let lines = value.split('\n');
for (const line of lines) {
if (line !== '') {
console.log(line);
let data = JSON.parse(line);
if ('citations' in data) {
responseMessage.citations = data.citations;
continue;
}
if ('detail' in data) {
throw data;
}
if ('id' in data) {
console.log(data);
currentRequestId = data.id;
} else {
if (data.done == false) {
if (responseMessage.content == '' && data.message.content == '\n') {
continue;
} else {
responseMessage.content += data.message.content;
messages = messages;
}
} else {
responseMessage.done = true;
if (responseMessage.content == '') {
responseMessage.error = true;
responseMessage.content =
'Oops! No text generated from Ollama, Please try again.';
}
responseMessage.context = data.context ?? null;
responseMessage.info = {
total_duration: data.total_duration,
load_duration: data.load_duration,
sample_count: data.sample_count,
sample_duration: data.sample_duration,
prompt_eval_count: data.prompt_eval_count,
prompt_eval_duration: data.prompt_eval_duration,
eval_count: data.eval_count,
eval_duration: data.eval_duration
};
messages = messages;
if ($settings.notificationEnabled && !document.hasFocus()) {
const notification = new Notification(
selectedModelfile
? `${
selectedModelfile.title.charAt(0).toUpperCase() +
selectedModelfile.title.slice(1)
}`
: `${model}`,
{
body: responseMessage.content,
icon: selectedModelfile?.imageUrl ?? `${WEBUI_BASE_URL}/static/favicon.png`
}
);
}
if ($settings.responseAutoCopy) {
copyToClipboard(responseMessage.content);
}
if ($settings.responseAutoPlayback) {
await tick();
document.getElementById(`speak-button-${responseMessage.id}`)?.click();
}
}
}
}
}
} catch (error) {
console.log(error);
if ('detail' in error) {
toast.error(error.detail);
}
break;
}
if (autoScroll) {
scrollToBottom();
}
}
if ($chatId == _chatId) {
if ($settings.saveChatHistory ?? true) {
chat = await updateChatById(localStorage.token, _chatId, {
messages: messages,
history: history
});
await chats.set(await getChatList(localStorage.token));
}
}
} else {
if (res !== null) {
const error = await res.json();
console.log(error);
if ('detail' in error) {
toast.error(error.detail);
responseMessage.content = error.detail;
} else {
toast.error(error.error);
responseMessage.content = error.error;
}
} else {
toast.error(
$i18n.t(`Uh-oh! There was an issue connecting to {{provider}}.`, { provider: 'Ollama' })
);
responseMessage.content = $i18n.t(`Uh-oh! There was an issue connecting to {{provider}}.`, {
provider: 'Ollama'
});
}
responseMessage.error = true;
responseMessage.content = $i18n.t(`Uh-oh! There was an issue connecting to {{provider}}.`, {
provider: 'Ollama'
});
responseMessage.done = true;
messages = messages;
}
stopResponseFlag = false;
await tick();
if (autoScroll) {
scrollToBottom();
}
if (messages.length == 2 && messages.at(1).content !== '') {
window.history.replaceState(history.state, '', `/c/${_chatId}`);
const _title = await generateChatTitle(userPrompt);
await setChatTitle(_chatId, _title);
}
};
const sendPromptOpenAI = async (model, userPrompt, responseMessageId, _chatId) => {
const responseMessage = history.messages[responseMessageId];
const docs = messages
.filter((message) => message?.files ?? null)
.map((message) =>
message.files.filter((item) => item.type === 'doc' || item.type === 'collection')
)
.flat(1);
console.log(docs);
scrollToBottom();
try {
const [res, controller] = await generateOpenAIChatCompletion(
localStorage.token,
{
model: model.id,
stream: true,
messages: [
$settings.system || (responseMessage?.userContext ?? null)
? {
role: 'system',
content: `${$settings?.system ?? ''}${
responseMessage?.userContext ?? null
? `\n\nUser Context:\n${(responseMessage?.userContext ?? []).join('\n')}`
: ''
}`
}
: undefined,
...messages
]
.filter((message) => message)
.filter((message) => message.content != '')
.map((message, idx, arr) => ({
role: message.role,
...((message.files?.filter((file) => file.type === 'image').length > 0 ?? false) &&
message.role === 'user'
? {
content: [
{
type: 'text',
text:
arr.length - 1 !== idx
? message.content
: message?.raContent ?? message.content
},
...message.files
.filter((file) => file.type === 'image')
.map((file) => ({
type: 'image_url',
image_url: {
url: file.url
}
}))
]
}
: {
content:
arr.length - 1 !== idx
? message.content
: message?.raContent ?? message.content
})
})),
seed: $settings?.options?.seed ?? undefined,
stop:
$settings?.options?.stop ?? undefined
? $settings.options.stop.map((str) =>
decodeURIComponent(JSON.parse('"' + str.replace(/\"/g, '\\"') + '"'))
)
: undefined,
temperature: $settings?.options?.temperature ?? undefined,
top_p: $settings?.options?.top_p ?? undefined,
num_ctx: $settings?.options?.num_ctx ?? undefined,
frequency_penalty: $settings?.options?.repeat_penalty ?? undefined,
max_tokens: $settings?.options?.num_predict ?? undefined,
docs: docs.length > 0 ? docs : undefined,
citations: docs.length > 0
},
model?.source?.toLowerCase() === 'litellm'
? `${LITELLM_API_BASE_URL}/v1`
: `${OPENAI_API_BASE_URL}`
);
// Wait until history/message have been updated
await tick();
scrollToBottom();
if (res && res.ok && res.body) {
const textStream = await createOpenAITextStream(res.body, $settings.splitLargeChunks);
for await (const update of textStream) {
const { value, done, citations, error } = update;
if (error) {
await handleOpenAIError(error, null, model, responseMessage);
break;
}
if (done || stopResponseFlag || _chatId !== $chatId) {
responseMessage.done = true;
messages = messages;
if (stopResponseFlag) {
controller.abort('User: Stop Response');
}
break;
}
if (citations) {
responseMessage.citations = citations;
continue;
}
if (responseMessage.content == '' && value == '\n') {
continue;
} else {
responseMessage.content += value;
messages = messages;
}
if ($settings.notificationEnabled && !document.hasFocus()) {
const notification = new Notification(`OpenAI ${model}`, {
body: responseMessage.content,
icon: `${WEBUI_BASE_URL}/static/favicon.png`
});
}
if ($settings.responseAutoCopy) {
copyToClipboard(responseMessage.content);
}
if ($settings.responseAutoPlayback) {
await tick();
document.getElementById(`speak-button-${responseMessage.id}`)?.click();
}
if (autoScroll) {
scrollToBottom();
}
}
if ($chatId == _chatId) {
if ($settings.saveChatHistory ?? true) {
chat = await updateChatById(localStorage.token, _chatId, {
messages: messages,
history: history
});
await chats.set(await getChatList(localStorage.token));
}
}
} else {
await handleOpenAIError(null, res, model, responseMessage);
}
} catch (error) {
await handleOpenAIError(error, null, model, responseMessage);
}
messages = messages;
stopResponseFlag = false;
await tick();
if (autoScroll) {
scrollToBottom();
}
if (messages.length == 2) {
window.history.replaceState(history.state, '', `/c/${_chatId}`);
const _title = await generateChatTitle(userPrompt);
await setChatTitle(_chatId, _title);
}
};
const handleOpenAIError = async (error, res: Response | null, model, responseMessage) => {
let errorMessage = '';
let innerError;
if (error) {
innerError = error;
} else if (res !== null) {
innerError = await res.json();
}
console.error(innerError);
if ('detail' in innerError) {
toast.error(innerError.detail);
errorMessage = innerError.detail;
} else if ('error' in innerError) {
if ('message' in innerError.error) {
toast.error(innerError.error.message);
errorMessage = innerError.error.message;
} else {
toast.error(innerError.error);
errorMessage = innerError.error;
}
} else if ('message' in innerError) {
toast.error(innerError.message);
errorMessage = innerError.message;
}
responseMessage.error = true;
responseMessage.content =
$i18n.t(`Uh-oh! There was an issue connecting to {{provider}}.`, {
provider: model.name ?? model.id
}) +
'\n' +
errorMessage;
responseMessage.done = true;
messages = messages;
};
const stopResponse = () => {
stopResponseFlag = true;
console.log('stopResponse');
};
const regenerateResponse = async (message) => {
console.log('regenerateResponse');
if (messages.length != 0) {
let userMessage = history.messages[message.parentId];
let userPrompt = userMessage.content;
if ((userMessage?.models ?? [...selectedModels]).length == 1) {
await sendPrompt(userPrompt, userMessage.id);
} else {
await sendPrompt(userPrompt, userMessage.id, message.model);
}
}
};
const continueGeneration = async () => {
console.log('continueGeneration');
const _chatId = JSON.parse(JSON.stringify($chatId));
if (messages.length != 0 && messages.at(-1).done == true) {
const responseMessage = history.messages[history.currentId];
responseMessage.done = false;
await tick();
const model = $models.filter((m) => m.id === responseMessage.model).at(0);
if (model) {
if (model?.external) {
await sendPromptOpenAI(
model,
history.messages[responseMessage.parentId].content,
responseMessage.id,
_chatId
);
} else
await sendPromptOllama(
model,
history.messages[responseMessage.parentId].content,
responseMessage.id,
_chatId
);
}
} else {
toast.error($i18n.t(`Model {{modelId}} not found`, { modelId }));
}
};
const generateChatTitle = async (userPrompt) => {
if ($settings?.title?.auto ?? true) {
const model = $models.find((model) => model.id === selectedModels[0]);
const titleModelId =
model?.external ?? false
? $settings?.title?.modelExternal ?? selectedModels[0]
: $settings?.title?.model ?? selectedModels[0];
const titleModel = $models.find((model) => model.id === titleModelId);
console.log(titleModel);
const title = await generateTitle(
localStorage.token,
$settings?.title?.prompt ??
$i18n.t(
"Create a concise, 3-5 word phrase as a header for the following query, strictly adhering to the 3-5 word limit and avoiding the use of the word 'title':"
) + ' {{prompt}}',
titleModelId,
userPrompt,
titleModel?.external ?? false
? titleModel?.source?.toLowerCase() === 'litellm'
? `${LITELLM_API_BASE_URL}/v1`
: `${OPENAI_API_BASE_URL}`
: `${OLLAMA_API_BASE_URL}/v1`
);
return title;
} else {
return `${userPrompt}`;
}
};
const setChatTitle = async (_chatId, _title) => {
if (_chatId === $chatId) {
title = _title;
}
if ($settings.saveChatHistory ?? true) {
chat = await updateChatById(localStorage.token, _chatId, { title: _title });
await chats.set(await getChatList(localStorage.token));
}
};
const getTags = async () => {
return await getTagsById(localStorage.token, $chatId).catch(async (error) => {
return [];
});
};
const addTag = async (tagName) => {
const res = await addTagById(localStorage.token, $chatId, tagName);
tags = await getTags();
chat = await updateChatById(localStorage.token, $chatId, {
tags: tags
});
_tags.set(await getAllChatTags(localStorage.token));
};
const deleteTag = async (tagName) => {
const res = await deleteTagById(localStorage.token, $chatId, tagName);
tags = await getTags();
chat = await updateChatById(localStorage.token, $chatId, {
tags: tags
});
_tags.set(await getAllChatTags(localStorage.token));
};
</script> </script>
<svelte:head> <Chat />
<title>
{title
? `${title.length > 30 ? `${title.slice(0, 30)}...` : title} | ${$WEBUI_NAME}`
: `${$WEBUI_NAME}`}
</title>
</svelte:head>
<div
class="min-h-screen max-h-screen {$showSidebar
? 'md:max-w-[calc(100%-260px)]'
: ''} w-full max-w-full flex flex-col"
>
<Navbar
{title}
bind:selectedModels
bind:showModelSelector
shareEnabled={messages.length > 0}
{chat}
{initNewChat}
/>
<div class="flex flex-col flex-auto">
<div
class=" pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0 max-w-full"
id="messages-container"
bind:this={messagesContainerElement}
on:scroll={(e) => {
autoScroll =
messagesContainerElement.scrollHeight - messagesContainerElement.scrollTop <=
messagesContainerElement.clientHeight + 5;
}}
>
<div class=" h-full w-full flex flex-col pt-2 pb-4">
<Messages
chatId={$chatId}
{selectedModels}
{selectedModelfiles}
{processing}
bind:history
bind:messages
bind:autoScroll
bind:prompt
bottomPadding={files.length > 0}
suggestionPrompts={selectedModelfile?.suggestionPrompts ??
$config.default_prompt_suggestions}
{sendPrompt}
{continueGeneration}
{regenerateResponse}
/>
</div>
</div>
</div>
</div>
<MessageInput
bind:files
bind:prompt
bind:autoScroll
bind:selectedModel={atSelectedModel}
{messages}
{submitPrompt}
{stopResponse}
/>
<script lang="ts">
import { onMount, getContext } from 'svelte';
import { WEBUI_NAME, showSidebar } from '$lib/stores';
import MenuLines from '$lib/components/icons/MenuLines.svelte';
import { page } from '$app/stores';
const i18n = getContext('i18n');
</script>
<svelte:head>
<title>
{$i18n.t('Admin Panel')} | {$WEBUI_NAME}
</title>
</svelte:head>
<div class=" flex flex-col w-full min-h-screen max-h-screen">
<div class=" px-4 pt-3 mt-0.5 mb-1">
<div class=" flex items-center gap-1">
<div class="{$showSidebar ? 'md:hidden' : ''} mr-1 self-start flex flex-none items-center">
<button
id="sidebar-toggle-button"
class="cursor-pointer p-1 flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition"
on:click={() => {
showSidebar.set(!$showSidebar);
}}
>
<div class=" m-auto self-center">
<MenuLines />
</div>
</button>
</div>
<div class="flex items-center text-xl font-semibold">{$i18n.t('Workspace')}</div>
</div>
</div>
<!-- <div class="px-4 my-1">
<div
class="flex scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-xl bg-transparent/10 p-1"
>
<a
class="min-w-fit rounded-lg p-1.5 px-3 {$page.url.pathname.includes('/workspace/models')
? 'bg-gray-50 dark:bg-gray-850'
: ''} transition"
href="/workspace/models">{$i18n.t('Models')}</a
>
<a
class="min-w-fit rounded-lg p-1.5 px-3 {$page.url.pathname.includes('/workspace/prompts')
? 'bg-gray-50 dark:bg-gray-850'
: ''} transition"
href="/workspace/prompts">{$i18n.t('Prompts')}</a
>
<a
class="min-w-fit rounded-lg p-1.5 px-3 {$page.url.pathname.includes('/workspace/documents')
? 'bg-gray-50 dark:bg-gray-850'
: ''} transition"
href="/workspace/documents"
>
{$i18n.t('Documents')}
</a>
<a
class="min-w-fit rounded-lg p-1.5 px-3 {$page.url.pathname.includes('/workspace/playground')
? 'bg-gray-50 dark:bg-gray-850'
: ''} transition"
href="/workspace/playground">{$i18n.t('Playground')}</a
>
</div>
</div> -->
<hr class=" my-2 dark:border-gray-850" />
<div class=" py-1 px-5 flex-1 max-h-full overflow-y-auto">
<slot />
</div>
</div>
...@@ -82,10 +82,6 @@ ...@@ -82,10 +82,6 @@
}); });
</script> </script>
<svelte:head>
<title>{$i18n.t('Admin Panel')} | {$WEBUI_NAME}</title>
</svelte:head>
{#key selectedUser} {#key selectedUser}
<EditUserModal <EditUserModal
bind:show={showEditUserModal} bind:show={showEditUserModal}
...@@ -106,265 +102,222 @@ ...@@ -106,265 +102,222 @@
<UserChatsModal bind:show={showUserChatsModal} user={selectedUser} /> <UserChatsModal bind:show={showUserChatsModal} user={selectedUser} />
<SettingsModal bind:show={showSettingsModal} /> <SettingsModal bind:show={showSettingsModal} />
<div class=" flex flex-col w-full min-h-screen"> {#if loaded}
{#if loaded} <div class="mt-0.5 mb-3 gap-1 flex flex-col md:flex-row justify-between">
<div class="px-4 pt-3 mt-0.5 mb-1"> <div class="flex md:self-center text-lg font-medium px-0.5">
<div class=" flex items-center gap-1"> {$i18n.t('All Users')}
<div class="{$showSidebar ? 'md:hidden' : ''} mr-1 self-start flex flex-none items-center"> <div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-200 dark:bg-gray-700" />
<span class="text-lg font-medium text-gray-500 dark:text-gray-300">{users.length}</span>
</div>
<div class="flex gap-1">
<input
class="w-full md:w-60 rounded-xl py-1.5 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
placeholder={$i18n.t('Search')}
bind:value={search}
/>
<div class="flex gap-0.5">
<Tooltip content="Add User">
<button <button
id="sidebar-toggle-button" class=" px-2 py-2 rounded-xl border border-gray-200 dark:border-gray-600 dark:border-0 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition font-medium text-sm flex items-center space-x-1"
class="cursor-pointer p-1 flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition"
on:click={() => { on:click={() => {
showSidebar.set(!$showSidebar); showAddUserModal = !showAddUserModal;
}} }}
> >
<div class=" m-auto self-center"> <svg
<MenuLines /> xmlns="http://www.w3.org/2000/svg"
</div> viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z"
/>
</svg>
</button> </button>
</div> </Tooltip>
<div class="flex items-center text-xl font-semibold">{$i18n.t('Dashboard')}</div>
</div>
</div>
<!-- <div class="px-4 my-1"> <Tooltip content={$i18n.t('Admin Settings')}>
<div <button
class="flex scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-xl bg-transparent/10 p-1" class=" px-2 py-2 rounded-xl border border-gray-200 dark:border-gray-600 dark:border-0 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition font-medium text-sm flex items-center space-x-1"
> on:click={() => {
<button showSettingsModal = !showSettingsModal;
class="min-w-fit rounded-lg p-1.5 px-3 {tab === '' }}
? 'bg-gray-50 dark:bg-gray-850' >
: ''} transition" <svg
type="button" xmlns="http://www.w3.org/2000/svg"
on:click={() => { viewBox="0 0 16 16"
tab = ''; fill="currentColor"
}}>{$i18n.t('Overview')}</button class="w-4 h-4"
> >
<path
fill-rule="evenodd"
d="M6.955 1.45A.5.5 0 0 1 7.452 1h1.096a.5.5 0 0 1 .497.45l.17 1.699c.484.12.94.312 1.356.562l1.321-1.081a.5.5 0 0 1 .67.033l.774.775a.5.5 0 0 1 .034.67l-1.08 1.32c.25.417.44.873.561 1.357l1.699.17a.5.5 0 0 1 .45.497v1.096a.5.5 0 0 1-.45.497l-1.699.17c-.12.484-.312.94-.562 1.356l1.082 1.322a.5.5 0 0 1-.034.67l-.774.774a.5.5 0 0 1-.67.033l-1.322-1.08c-.416.25-.872.44-1.356.561l-.17 1.699a.5.5 0 0 1-.497.45H7.452a.5.5 0 0 1-.497-.45l-.17-1.699a4.973 4.973 0 0 1-1.356-.562L4.108 13.37a.5.5 0 0 1-.67-.033l-.774-.775a.5.5 0 0 1-.034-.67l1.08-1.32a4.971 4.971 0 0 1-.561-1.357l-1.699-.17A.5.5 0 0 1 1 8.548V7.452a.5.5 0 0 1 .45-.497l1.699-.17c.12-.484.312-.94.562-1.356L2.629 4.107a.5.5 0 0 1 .034-.67l.774-.774a.5.5 0 0 1 .67-.033L5.43 3.71a4.97 4.97 0 0 1 1.356-.561l.17-1.699ZM6 8c0 .538.212 1.026.558 1.385l.057.057a2 2 0 0 0 2.828-2.828l-.058-.056A2 2 0 0 0 6 8Z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
</div> </div>
</div> --> </div>
</div>
<hr class=" my-2 dark:border-gray-850" />
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
<div class="px-6"> <table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full">
<div class="mt-0.5 mb-3 gap-1 flex flex-col md:flex-row justify-between"> <thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400">
<div class="flex md:self-center text-lg font-medium px-0.5"> <tr>
{$i18n.t('All Users')} <th scope="col" class="px-3 py-2"> {$i18n.t('Role')} </th>
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-200 dark:bg-gray-700" /> <th scope="col" class="px-3 py-2"> {$i18n.t('Name')} </th>
<span class="text-lg font-medium text-gray-500 dark:text-gray-300">{users.length}</span> <th scope="col" class="px-3 py-2"> {$i18n.t('Email')} </th>
</div> <th scope="col" class="px-3 py-2"> {$i18n.t('Last Active')} </th>
<div class="flex gap-1"> <th scope="col" class="px-3 py-2"> {$i18n.t('Created at')} </th>
<input
class="w-full md:w-60 rounded-xl py-1.5 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none" <th scope="col" class="px-3 py-2 text-right" />
placeholder={$i18n.t('Search')} </tr>
bind:value={search} </thead>
/> <tbody>
{#each users
<div class="flex gap-0.5"> .filter((user) => {
<Tooltip content="Add User"> if (search === '') {
return true;
} else {
let name = user.name.toLowerCase();
const query = search.toLowerCase();
return name.includes(query);
}
})
.slice((page - 1) * 20, page * 20) as user}
<tr class="bg-white border-b dark:bg-gray-900 dark:border-gray-700 text-xs">
<td class="px-3 py-2 min-w-[7rem] w-28">
<button <button
class=" px-2 py-2 rounded-xl border border-gray-200 dark:border-gray-600 dark:border-0 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition font-medium text-sm flex items-center space-x-1" class=" flex items-center gap-2 text-xs px-3 py-0.5 rounded-lg {user.role ===
'admin' && 'text-sky-600 dark:text-sky-200 bg-sky-200/30'} {user.role ===
'user' && 'text-green-600 dark:text-green-200 bg-green-200/30'} {user.role ===
'pending' && 'text-gray-600 dark:text-gray-200 bg-gray-200/30'}"
on:click={() => { on:click={() => {
showAddUserModal = !showAddUserModal; if (user.role === 'user') {
updateRoleHandler(user.id, 'admin');
} else if (user.role === 'pending') {
updateRoleHandler(user.id, 'user');
} else {
updateRoleHandler(user.id, 'pending');
}
}} }}
> >
<svg <div
xmlns="http://www.w3.org/2000/svg" class="w-1 h-1 rounded-full {user.role === 'admin' &&
viewBox="0 0 16 16" 'bg-sky-600 dark:bg-sky-300'} {user.role === 'user' &&
fill="currentColor" 'bg-green-600 dark:bg-green-300'} {user.role === 'pending' &&
class="w-4 h-4" 'bg-gray-600 dark:bg-gray-300'}"
> />
<path {$i18n.t(user.role)}</button
d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z"
/>
</svg>
</button>
</Tooltip>
<Tooltip content={$i18n.t('Admin Settings')}>
<button
class=" px-2 py-2 rounded-xl border border-gray-200 dark:border-gray-600 dark:border-0 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition font-medium text-sm flex items-center space-x-1"
on:click={() => {
showSettingsModal = !showSettingsModal;
}}
> >
<svg </td>
xmlns="http://www.w3.org/2000/svg" <td class="px-3 py-2 font-medium text-gray-900 dark:text-white w-max">
viewBox="0 0 16 16" <div class="flex flex-row w-max">
fill="currentColor" <img
class="w-4 h-4" class=" rounded-full w-6 h-6 object-cover mr-2.5"
> src={user.profile_image_url.startsWith(WEBUI_BASE_URL) ||
<path user.profile_image_url.startsWith('https://www.gravatar.com/avatar/') ||
fill-rule="evenodd" user.profile_image_url.startsWith('data:')
d="M6.955 1.45A.5.5 0 0 1 7.452 1h1.096a.5.5 0 0 1 .497.45l.17 1.699c.484.12.94.312 1.356.562l1.321-1.081a.5.5 0 0 1 .67.033l.774.775a.5.5 0 0 1 .034.67l-1.08 1.32c.25.417.44.873.561 1.357l1.699.17a.5.5 0 0 1 .45.497v1.096a.5.5 0 0 1-.45.497l-1.699.17c-.12.484-.312.94-.562 1.356l1.082 1.322a.5.5 0 0 1-.034.67l-.774.774a.5.5 0 0 1-.67.033l-1.322-1.08c-.416.25-.872.44-1.356.561l-.17 1.699a.5.5 0 0 1-.497.45H7.452a.5.5 0 0 1-.497-.45l-.17-1.699a4.973 4.973 0 0 1-1.356-.562L4.108 13.37a.5.5 0 0 1-.67-.033l-.774-.775a.5.5 0 0 1-.034-.67l1.08-1.32a4.971 4.971 0 0 1-.561-1.357l-1.699-.17A.5.5 0 0 1 1 8.548V7.452a.5.5 0 0 1 .45-.497l1.699-.17c.12-.484.312-.94.562-1.356L2.629 4.107a.5.5 0 0 1 .034-.67l.774-.774a.5.5 0 0 1 .67-.033L5.43 3.71a4.97 4.97 0 0 1 1.356-.561l.17-1.699ZM6 8c0 .538.212 1.026.558 1.385l.057.057a2 2 0 0 0 2.828-2.828l-.058-.056A2 2 0 0 0 6 8Z" ? user.profile_image_url
clip-rule="evenodd" : `/user.png`}
/> alt="user"
</svg> />
</button>
</Tooltip> <div class=" font-medium self-center">{user.name}</div>
</div> </div>
</div> </td>
</div> <td class=" px-3 py-2"> {user.email} </td>
<div class="scrollbar-hidden relative overflow-x-auto whitespace-nowrap"> <td class=" px-3 py-2">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto"> {dayjs(user.last_active_at * 1000).fromNow()}
<thead </td>
class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400"
> <td class=" px-3 py-2">
<tr> {dayjs(user.created_at * 1000).format($i18n.t('MMMM DD, YYYY'))}
<th scope="col" class="px-3 py-2"> {$i18n.t('Role')} </th> </td>
<th scope="col" class="px-3 py-2"> {$i18n.t('Name')} </th>
<th scope="col" class="px-3 py-2"> {$i18n.t('Email')} </th> <td class="px-3 py-2 text-right">
<th scope="col" class="px-3 py-2"> {$i18n.t('Last Active')} </th> <div class="flex justify-end w-full">
{#if user.role !== 'admin'}
<th scope="col" class="px-3 py-2"> {$i18n.t('Created at')} </th> <Tooltip content={$i18n.t('Chats')}>
<button
<th scope="col" class="px-3 py-2 text-right" /> class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
</tr> on:click={async () => {
</thead> showUserChatsModal = !showUserChatsModal;
<tbody> selectedUser = user;
{#each users }}
.filter((user) => { >
if (search === '') { <ChatBubbles />
return true; </button>
} else { </Tooltip>
let name = user.name.toLowerCase();
const query = search.toLowerCase(); <Tooltip content={$i18n.t('Edit User')}>
return name.includes(query); <button
} class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
}) on:click={async () => {
.slice((page - 1) * 20, page * 20) as user} showEditUserModal = !showEditUserModal;
<tr class="bg-white border-b dark:bg-gray-900 dark:border-gray-700 text-xs"> selectedUser = user;
<td class="px-3 py-2 min-w-[7rem] w-28"> }}
<button >
class=" flex items-center gap-2 text-xs px-3 py-0.5 rounded-lg {user.role === <svg
'admin' && 'text-sky-600 dark:text-sky-200 bg-sky-200/30'} {user.role === xmlns="http://www.w3.org/2000/svg"
'user' && 'text-green-600 dark:text-green-200 bg-green-200/30'} {user.role === fill="none"
'pending' && 'text-gray-600 dark:text-gray-200 bg-gray-200/30'}" viewBox="0 0 24 24"
on:click={() => { stroke-width="1.5"
if (user.role === 'user') { stroke="currentColor"
updateRoleHandler(user.id, 'admin'); class="w-4 h-4"
} else if (user.role === 'pending') { >
updateRoleHandler(user.id, 'user'); <path
} else { stroke-linecap="round"
updateRoleHandler(user.id, 'pending'); stroke-linejoin="round"
} d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125"
}} />
> </svg>
<div </button>
class="w-1 h-1 rounded-full {user.role === 'admin' && </Tooltip>
'bg-sky-600 dark:bg-sky-300'} {user.role === 'user' &&
'bg-green-600 dark:bg-green-300'} {user.role === 'pending' && <Tooltip content={$i18n.t('Delete User')}>
'bg-gray-600 dark:bg-gray-300'}" <button
/> class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
{$i18n.t(user.role)}</button on:click={async () => {
> deleteUserHandler(user.id);
</td> }}
<td class="px-3 py-2 font-medium text-gray-900 dark:text-white w-max"> >
<div class="flex flex-row w-max"> <svg
<img xmlns="http://www.w3.org/2000/svg"
class=" rounded-full w-6 h-6 object-cover mr-2.5" fill="none"
src={user.profile_image_url.startsWith(WEBUI_BASE_URL) || viewBox="0 0 24 24"
user.profile_image_url.startsWith('https://www.gravatar.com/avatar/') || stroke-width="1.5"
user.profile_image_url.startsWith('data:') stroke="currentColor"
? user.profile_image_url class="w-4 h-4"
: `/user.png`} >
alt="user" <path
/> stroke-linecap="round"
stroke-linejoin="round"
<div class=" font-medium self-center">{user.name}</div> d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
</div> />
</td> </svg>
<td class=" px-3 py-2"> {user.email} </td> </button>
</Tooltip>
<td class=" px-3 py-2"> {/if}
{dayjs(user.last_active_at * 1000).fromNow()} </div>
</td> </td>
</tr>
<td class=" px-3 py-2"> {/each}
{dayjs(user.created_at * 1000).format($i18n.t('MMMM DD, YYYY'))} </tbody>
</td> </table>
</div>
<td class="px-3 py-2 text-right">
<div class="flex justify-end w-full"> <div class=" text-gray-500 text-xs mt-2 text-right">
{#if user.role !== 'admin'} ⓘ {$i18n.t("Click on the user role button to change a user's role.")}
<Tooltip content={$i18n.t('Chats')}> </div>
<button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl" <Pagination bind:page count={users.length} />
on:click={async () => { {/if}
showUserChatsModal = !showUserChatsModal;
selectedUser = user;
}}
>
<ChatBubbles />
</button>
</Tooltip>
<Tooltip content={$i18n.t('Edit User')}>
<button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
on:click={async () => {
showEditUserModal = !showEditUserModal;
selectedUser = user;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125"
/>
</svg>
</button>
</Tooltip>
<Tooltip content={$i18n.t('Delete User')}>
<button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
on:click={async () => {
deleteUserHandler(user.id);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
</button>
</Tooltip>
{/if}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div class=" text-gray-500 text-xs mt-2 text-right">
ⓘ {$i18n.t("Click on the user role button to change a user's role.")}
</div>
<Pagination bind:page count={users.length} />
</div>
{/if}
</div>
<style> <style>
.font-mona { .font-mona {
......
<script lang="ts"> <script lang="ts">
import { toast } from 'svelte-sonner'; import Chat from '$lib/components/chat/Chat.svelte';
import { v4 as uuidv4 } from 'uuid';
import { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import {
WEBUI_NAME,
tags as _tags,
chatId,
chats,
config,
modelfiles,
models,
settings,
showSidebar,
user
} from '$lib/stores';
import { convertMessagesToHistory, copyToClipboard, splitStream } from '$lib/utils';
import { getContext, onMount, tick } from 'svelte';
import {
addTagById,
createNewChat,
deleteTagById,
getAllChatTags,
getChatById,
getChatList,
getTagsById,
updateChatById
} from '$lib/apis/chats';
import { cancelOllamaRequest, generateChatCompletion } from '$lib/apis/ollama';
import { generateOpenAIChatCompletion, generateTitle } from '$lib/apis/openai';
import MessageInput from '$lib/components/chat/MessageInput.svelte';
import Messages from '$lib/components/chat/Messages.svelte';
import Navbar from '$lib/components/layout/Navbar.svelte';
import { queryMemory } from '$lib/apis/memories';
import { createOpenAITextStream } from '$lib/apis/streaming';
import {
LITELLM_API_BASE_URL,
OLLAMA_API_BASE_URL,
OPENAI_API_BASE_URL,
WEBUI_BASE_URL
} from '$lib/constants';
const i18n = getContext('i18n');
let loaded = false;
let stopResponseFlag = false;
let autoScroll = true;
let processing = '';
let messagesContainerElement: HTMLDivElement;
let currentRequestId = null;
// let chatId = $page.params.id;
let showModelSelector = true;
let selectedModels = [''];
let atSelectedModel = '';
let selectedModelfile = null;
$: selectedModelfile =
selectedModels.length === 1 &&
$modelfiles.filter((modelfile) => modelfile.tagName === selectedModels[0]).length > 0
? $modelfiles.filter((modelfile) => modelfile.tagName === selectedModels[0])[0]
: null;
let selectedModelfiles = {};
$: selectedModelfiles = selectedModels.reduce((a, tagName, i, arr) => {
const modelfile =
$modelfiles.filter((modelfile) => modelfile.tagName === tagName)?.at(0) ?? undefined;
return {
...a,
...(modelfile && { [tagName]: modelfile })
};
}, {});
let chat = null;
let tags = [];
let title = '';
let prompt = '';
let files = [];
let messages = [];
let history = {
messages: {},
currentId: null
};
$: if (history.currentId !== null) {
let _messages = [];
let currentMessage = history.messages[history.currentId];
while (currentMessage !== null) {
_messages.unshift({ ...currentMessage });
currentMessage =
currentMessage.parentId !== null ? history.messages[currentMessage.parentId] : null;
}
messages = _messages;
} else {
messages = [];
}
$: if ($page.params.id) {
(async () => {
if (await loadChat()) {
await tick();
loaded = true;
window.setTimeout(() => scrollToBottom(), 0);
const chatInput = document.getElementById('chat-textarea');
chatInput?.focus();
} else {
await goto('/');
}
})();
}
//////////////////////////
// Web functions
//////////////////////////
const loadChat = async () => {
await chatId.set($page.params.id);
chat = await getChatById(localStorage.token, $chatId).catch(async (error) => {
await goto('/');
return null;
});
if (chat) {
tags = await getTags();
const chatContent = chat.chat;
if (chatContent) {
console.log(chatContent);
selectedModels =
(chatContent?.models ?? undefined) !== undefined
? chatContent.models
: [chatContent.models ?? ''];
history =
(chatContent?.history ?? undefined) !== undefined
? chatContent.history
: convertMessagesToHistory(chatContent.messages);
title = chatContent.title;
let _settings = JSON.parse(localStorage.getItem('settings') ?? '{}');
await settings.set({
..._settings,
system: chatContent.system ?? _settings.system,
options: chatContent.options ?? _settings.options
});
autoScroll = true;
await tick();
if (messages.length > 0) {
history.messages[messages.at(-1).id].done = true;
}
await tick();
return true;
} else {
return null;
}
}
};
const scrollToBottom = async () => {
await tick();
if (messagesContainerElement) {
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
}
};
//////////////////////////
// Ollama functions
//////////////////////////
const submitPrompt = async (userPrompt, _user = null) => {
console.log('submitPrompt', $chatId);
if (selectedModels.includes('')) {
toast.error($i18n.t('Model not selected'));
} else if (messages.length != 0 && messages.at(-1).done != true) {
// Response not done
console.log('wait');
} else if (
files.length > 0 &&
files.filter((file) => file.upload_status === false).length > 0
) {
// Upload not done
toast.error(
`Oops! Hold tight! Your files are still in the processing oven. We're cooking them up to perfection. Please be patient and we'll let you know once they're ready.`
);
} else {
// Reset chat message textarea height
document.getElementById('chat-textarea').style.height = '';
// Create user message
let userMessageId = uuidv4();
let userMessage = {
id: userMessageId,
parentId: messages.length !== 0 ? messages.at(-1).id : null,
childrenIds: [],
role: 'user',
user: _user ?? undefined,
content: userPrompt,
files: files.length > 0 ? files : undefined,
timestamp: Math.floor(Date.now() / 1000), // Unix epoch
models: selectedModels
};
// Add message to history and Set currentId to messageId
history.messages[userMessageId] = userMessage;
history.currentId = userMessageId;
// Append messageId to childrenIds of parent message
if (messages.length !== 0) {
history.messages[messages.at(-1).id].childrenIds.push(userMessageId);
}
// Wait until history/message have been updated
await tick();
// Create new chat if only one message in messages
if (messages.length == 1) {
if ($settings.saveChatHistory ?? true) {
chat = await createNewChat(localStorage.token, {
id: $chatId,
title: $i18n.t('New Chat'),
models: selectedModels,
system: $settings.system ?? undefined,
options: {
...($settings.options ?? {})
},
messages: messages,
history: history,
timestamp: Date.now()
});
await chats.set(await getChatList(localStorage.token));
await chatId.set(chat.id);
} else {
await chatId.set('local');
}
await tick();
}
// Reset chat input textarea
prompt = '';
files = [];
// Send prompt
await sendPrompt(userPrompt, userMessageId);
}
};
const sendPrompt = async (prompt, parentId, modelId = null) => {
const _chatId = JSON.parse(JSON.stringify($chatId));
await Promise.all(
(modelId ? [modelId] : atSelectedModel !== '' ? [atSelectedModel.id] : selectedModels).map(
async (modelId) => {
console.log('modelId', modelId);
const model = $models.filter((m) => m.id === modelId).at(0);
if (model) {
// Create response message
let responseMessageId = uuidv4();
let responseMessage = {
parentId: parentId,
id: responseMessageId,
childrenIds: [],
role: 'assistant',
content: '',
model: model.id,
userContext: null,
timestamp: Math.floor(Date.now() / 1000) // Unix epoch
};
// Add message to history and Set currentId to messageId
history.messages[responseMessageId] = responseMessage;
history.currentId = responseMessageId;
// Append messageId to childrenIds of parent message
if (parentId !== null) {
history.messages[parentId].childrenIds = [
...history.messages[parentId].childrenIds,
responseMessageId
];
}
await tick();
let userContext = null;
if ($settings?.memory ?? false) {
if (userContext === null) {
const res = await queryMemory(localStorage.token, prompt).catch((error) => {
toast.error(error);
return null;
});
if (res) {
if (res.documents[0].length > 0) {
userContext = res.documents.reduce((acc, doc, index) => {
const createdAtTimestamp = res.metadatas[index][0].created_at;
const createdAtDate = new Date(createdAtTimestamp * 1000)
.toISOString()
.split('T')[0];
acc.push(`${index + 1}. [${createdAtDate}]. ${doc[0]}`);
return acc;
}, []);
}
console.log(userContext);
}
}
}
responseMessage.userContext = userContext;
if (model?.external) {
await sendPromptOpenAI(model, prompt, responseMessageId, _chatId);
} else if (model) {
await sendPromptOllama(model, prompt, responseMessageId, _chatId);
}
} else {
toast.error($i18n.t(`Model {{modelId}} not found`, { modelId }));
}
}
)
);
await chats.set(await getChatList(localStorage.token));
};
const sendPromptOllama = async (model, userPrompt, responseMessageId, _chatId) => {
model = model.id;
const responseMessage = history.messages[responseMessageId];
// Wait until history/message have been updated
await tick();
// Scroll down
scrollToBottom();
const messagesBody = [
$settings.system || (responseMessage?.userContext ?? null)
? {
role: 'system',
content: `${$settings?.system ?? ''}${
responseMessage?.userContext ?? null
? `\n\nUser Context:\n${(responseMessage?.userContext ?? []).join('\n')}`
: ''
}`
}
: undefined,
...messages
]
.filter((message) => message)
.map((message, idx, arr) => {
// Prepare the base message object
const baseMessage = {
role: message.role,
content: arr.length - 2 !== idx ? message.content : message?.raContent ?? message.content
};
// Extract and format image URLs if any exist
const imageUrls = message.files
?.filter((file) => file.type === 'image')
.map((file) => file.url.slice(file.url.indexOf(',') + 1));
// Add images array only if it contains elements
if (imageUrls && imageUrls.length > 0 && message.role === 'user') {
baseMessage.images = imageUrls;
}
return baseMessage;
});
let lastImageIndex = -1;
// Find the index of the last object with images
messagesBody.forEach((item, index) => {
if (item.images) {
lastImageIndex = index;
}
});
// Remove images from all but the last one
messagesBody.forEach((item, index) => {
if (index !== lastImageIndex) {
delete item.images;
}
});
const docs = messages
.filter((message) => message?.files ?? null)
.map((message) =>
message.files.filter((item) => item.type === 'doc' || item.type === 'collection')
)
.flat(1);
const [res, controller] = await generateChatCompletion(localStorage.token, {
model: model,
messages: messagesBody,
options: {
...($settings.options ?? {}),
stop:
$settings?.options?.stop ?? undefined
? $settings.options.stop.map((str) =>
decodeURIComponent(JSON.parse('"' + str.replace(/\"/g, '\\"') + '"'))
)
: undefined
},
format: $settings.requestFormat ?? undefined,
keep_alive: $settings.keepAlive ?? undefined,
docs: docs.length > 0 ? docs : undefined,
citations: docs.length > 0
});
if (res && res.ok) {
console.log('controller', controller);
const reader = res.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(splitStream('\n'))
.getReader();
while (true) {
const { value, done } = await reader.read();
if (done || stopResponseFlag || _chatId !== $chatId) {
responseMessage.done = true;
messages = messages;
if (stopResponseFlag) {
controller.abort('User: Stop Response');
await cancelOllamaRequest(localStorage.token, currentRequestId);
}
currentRequestId = null;
break;
}
try {
let lines = value.split('\n');
for (const line of lines) {
if (line !== '') {
console.log(line);
let data = JSON.parse(line);
if ('citations' in data) {
responseMessage.citations = data.citations;
continue;
}
if ('detail' in data) {
throw data;
}
if ('id' in data) {
console.log(data);
currentRequestId = data.id;
} else {
if (data.done == false) {
if (responseMessage.content == '' && data.message.content == '\n') {
continue;
} else {
responseMessage.content += data.message.content;
messages = messages;
}
} else {
responseMessage.done = true;
if (responseMessage.content == '') {
responseMessage.error = true;
responseMessage.content =
'Oops! No text generated from Ollama, Please try again.';
}
responseMessage.context = data.context ?? null;
responseMessage.info = {
total_duration: data.total_duration,
load_duration: data.load_duration,
sample_count: data.sample_count,
sample_duration: data.sample_duration,
prompt_eval_count: data.prompt_eval_count,
prompt_eval_duration: data.prompt_eval_duration,
eval_count: data.eval_count,
eval_duration: data.eval_duration
};
messages = messages;
if ($settings.notificationEnabled && !document.hasFocus()) {
const notification = new Notification(
selectedModelfile
? `${
selectedModelfile.title.charAt(0).toUpperCase() +
selectedModelfile.title.slice(1)
}`
: `${model}`,
{
body: responseMessage.content,
icon: selectedModelfile?.imageUrl ?? `${WEBUI_BASE_URL}/static/favicon.png`
}
);
}
if ($settings.responseAutoCopy) {
copyToClipboard(responseMessage.content);
}
if ($settings.responseAutoPlayback) {
await tick();
document.getElementById(`speak-button-${responseMessage.id}`)?.click();
}
}
}
}
}
} catch (error) {
console.log(error);
if ('detail' in error) {
toast.error(error.detail);
}
break;
}
if (autoScroll) {
scrollToBottom();
}
}
if ($chatId == _chatId) {
if ($settings.saveChatHistory ?? true) {
chat = await updateChatById(localStorage.token, _chatId, {
messages: messages,
history: history
});
await chats.set(await getChatList(localStorage.token));
}
}
} else {
if (res !== null) {
const error = await res.json();
console.log(error);
if ('detail' in error) {
toast.error(error.detail);
responseMessage.content = error.detail;
} else {
toast.error(error.error);
responseMessage.content = error.error;
}
} else {
toast.error(
$i18n.t(`Uh-oh! There was an issue connecting to {{provider}}.`, { provider: 'Ollama' })
);
responseMessage.content = $i18n.t(`Uh-oh! There was an issue connecting to {{provider}}.`, {
provider: 'Ollama'
});
}
responseMessage.error = true;
responseMessage.content = $i18n.t(`Uh-oh! There was an issue connecting to {{provider}}.`, {
provider: 'Ollama'
});
responseMessage.done = true;
messages = messages;
}
stopResponseFlag = false;
await tick();
if (autoScroll) {
scrollToBottom();
}
if (messages.length == 2 && messages.at(1).content !== '') {
window.history.replaceState(history.state, '', `/c/${_chatId}`);
const _title = await generateChatTitle(userPrompt);
await setChatTitle(_chatId, _title);
}
};
const sendPromptOpenAI = async (model, userPrompt, responseMessageId, _chatId) => {
const responseMessage = history.messages[responseMessageId];
const docs = messages
.filter((message) => message?.files ?? null)
.map((message) =>
message.files.filter((item) => item.type === 'doc' || item.type === 'collection')
)
.flat(1);
console.log(docs);
scrollToBottom();
try {
const [res, controller] = await generateOpenAIChatCompletion(
localStorage.token,
{
model: model.id,
stream: true,
messages: [
$settings.system || (responseMessage?.userContext ?? null)
? {
role: 'system',
content: `${$settings?.system ?? ''}${
responseMessage?.userContext ?? null
? `\n\nUser Context:\n${(responseMessage?.userContext ?? []).join('\n')}`
: ''
}`
}
: undefined,
...messages
]
.filter((message) => message)
.filter((message) => message.content != '')
.map((message, idx, arr) => ({
role: message.role,
...((message.files?.filter((file) => file.type === 'image').length > 0 ?? false) &&
message.role === 'user'
? {
content: [
{
type: 'text',
text:
arr.length - 1 !== idx
? message.content
: message?.raContent ?? message.content
},
...message.files
.filter((file) => file.type === 'image')
.map((file) => ({
type: 'image_url',
image_url: {
url: file.url
}
}))
]
}
: {
content:
arr.length - 1 !== idx
? message.content
: message?.raContent ?? message.content
})
})),
seed: $settings?.options?.seed ?? undefined,
stop:
$settings?.options?.stop ?? undefined
? $settings.options.stop.map((str) =>
decodeURIComponent(JSON.parse('"' + str.replace(/\"/g, '\\"') + '"'))
)
: undefined,
temperature: $settings?.options?.temperature ?? undefined,
top_p: $settings?.options?.top_p ?? undefined,
num_ctx: $settings?.options?.num_ctx ?? undefined,
frequency_penalty: $settings?.options?.repeat_penalty ?? undefined,
max_tokens: $settings?.options?.num_predict ?? undefined,
docs: docs.length > 0 ? docs : undefined,
citations: docs.length > 0
},
model?.source?.toLowerCase() === 'litellm'
? `${LITELLM_API_BASE_URL}/v1`
: `${OPENAI_API_BASE_URL}`
);
// Wait until history/message have been updated
await tick();
scrollToBottom();
if (res && res.ok && res.body) {
const textStream = await createOpenAITextStream(res.body, $settings.splitLargeChunks);
for await (const update of textStream) {
const { value, done, citations, error } = update;
if (error) {
await handleOpenAIError(error, null, model, responseMessage);
break;
}
if (done || stopResponseFlag || _chatId !== $chatId) {
responseMessage.done = true;
messages = messages;
if (stopResponseFlag) {
controller.abort('User: Stop Response');
}
break;
}
if (citations) {
responseMessage.citations = citations;
continue;
}
if (responseMessage.content == '' && value == '\n') {
continue;
} else {
responseMessage.content += value;
messages = messages;
}
if ($settings.notificationEnabled && !document.hasFocus()) {
const notification = new Notification(`OpenAI ${model}`, {
body: responseMessage.content,
icon: `${WEBUI_BASE_URL}/static/favicon.png`
});
}
if ($settings.responseAutoCopy) {
copyToClipboard(responseMessage.content);
}
if ($settings.responseAutoPlayback) {
await tick();
document.getElementById(`speak-button-${responseMessage.id}`)?.click();
}
if (autoScroll) {
scrollToBottom();
}
}
if ($chatId == _chatId) {
if ($settings.saveChatHistory ?? true) {
chat = await updateChatById(localStorage.token, _chatId, {
messages: messages,
history: history
});
await chats.set(await getChatList(localStorage.token));
}
}
} else {
await handleOpenAIError(null, res, model, responseMessage);
}
} catch (error) {
await handleOpenAIError(error, null, model, responseMessage);
}
messages = messages;
stopResponseFlag = false;
await tick();
if (autoScroll) {
scrollToBottom();
}
if (messages.length == 2) {
window.history.replaceState(history.state, '', `/c/${_chatId}`);
const _title = await generateChatTitle(userPrompt);
await setChatTitle(_chatId, _title);
}
};
const handleOpenAIError = async (error, res: Response | null, model, responseMessage) => {
let errorMessage = '';
let innerError;
if (error) {
innerError = error;
} else if (res !== null) {
innerError = await res.json();
}
console.error(innerError);
if ('detail' in innerError) {
toast.error(innerError.detail);
errorMessage = innerError.detail;
} else if ('error' in innerError) {
if ('message' in innerError.error) {
toast.error(innerError.error.message);
errorMessage = innerError.error.message;
} else {
toast.error(innerError.error);
errorMessage = innerError.error;
}
} else if ('message' in innerError) {
toast.error(innerError.message);
errorMessage = innerError.message;
}
responseMessage.error = true;
responseMessage.content =
$i18n.t(`Uh-oh! There was an issue connecting to {{provider}}.`, {
provider: model.name ?? model.id
}) +
'\n' +
errorMessage;
responseMessage.done = true;
messages = messages;
};
const stopResponse = () => {
stopResponseFlag = true;
console.log('stopResponse');
};
const regenerateResponse = async (message) => {
console.log('regenerateResponse');
if (messages.length != 0) {
let userMessage = history.messages[message.parentId];
let userPrompt = userMessage.content;
if ((userMessage?.models ?? [...selectedModels]).length == 1) {
await sendPrompt(userPrompt, userMessage.id);
} else {
await sendPrompt(userPrompt, userMessage.id, message.model);
}
}
};
const continueGeneration = async () => {
console.log('continueGeneration');
const _chatId = JSON.parse(JSON.stringify($chatId));
if (messages.length != 0 && messages.at(-1).done == true) {
const responseMessage = history.messages[history.currentId];
responseMessage.done = false;
await tick();
const model = $models.filter((m) => m.id === responseMessage.model).at(0);
if (model) {
if (model?.external) {
await sendPromptOpenAI(
model,
history.messages[responseMessage.parentId].content,
responseMessage.id,
_chatId
);
} else
await sendPromptOllama(
model,
history.messages[responseMessage.parentId].content,
responseMessage.id,
_chatId
);
}
} else {
toast.error($i18n.t(`Model {{modelId}} not found`, { modelId }));
}
};
const generateChatTitle = async (userPrompt) => {
if ($settings?.title?.auto ?? true) {
const model = $models.find((model) => model.id === selectedModels[0]);
const titleModelId =
model?.external ?? false
? $settings?.title?.modelExternal ?? selectedModels[0]
: $settings?.title?.model ?? selectedModels[0];
const titleModel = $models.find((model) => model.id === titleModelId);
console.log(titleModel);
const title = await generateTitle(
localStorage.token,
$settings?.title?.prompt ??
$i18n.t(
"Create a concise, 3-5 word phrase as a header for the following query, strictly adhering to the 3-5 word limit and avoiding the use of the word 'title':"
) + ' {{prompt}}',
titleModelId,
userPrompt,
titleModel?.external ?? false
? titleModel?.source?.toLowerCase() === 'litellm'
? `${LITELLM_API_BASE_URL}/v1`
: `${OPENAI_API_BASE_URL}`
: `${OLLAMA_API_BASE_URL}/v1`
);
return title;
} else {
return `${userPrompt}`;
}
};
const setChatTitle = async (_chatId, _title) => {
if (_chatId === $chatId) {
title = _title;
}
if ($settings.saveChatHistory ?? true) {
chat = await updateChatById(localStorage.token, _chatId, { title: _title });
await chats.set(await getChatList(localStorage.token));
}
};
const getTags = async () => {
return await getTagsById(localStorage.token, $chatId).catch(async (error) => {
return [];
});
};
const addTag = async (tagName) => {
const res = await addTagById(localStorage.token, $chatId, tagName);
tags = await getTags();
chat = await updateChatById(localStorage.token, $chatId, {
tags: tags
});
_tags.set(await getAllChatTags(localStorage.token));
};
const deleteTag = async (tagName) => {
const res = await deleteTagById(localStorage.token, $chatId, tagName);
tags = await getTags();
chat = await updateChatById(localStorage.token, $chatId, {
tags: tags
});
_tags.set(await getAllChatTags(localStorage.token));
};
onMount(async () => {
if (!($settings.saveChatHistory ?? true)) {
await goto('/');
}
});
</script> </script>
<svelte:head> <Chat chatIdProp={$page.params.id} />
<title>
{title
? `${title.length > 30 ? `${title.slice(0, 30)}...` : title} | ${$WEBUI_NAME}`
: `${$WEBUI_NAME}`}
</title>
</svelte:head>
{#if loaded}
<div
class="min-h-screen max-h-screen {$showSidebar
? 'md:max-w-[calc(100%-260px)]'
: ''} w-full max-w-full flex flex-col"
>
<Navbar
{title}
{chat}
bind:selectedModels
bind:showModelSelector
shareEnabled={messages.length > 0}
initNewChat={async () => {
if (currentRequestId !== null) {
await cancelOllamaRequest(localStorage.token, currentRequestId);
currentRequestId = null;
}
goto('/');
}}
/>
<div class="flex flex-col flex-auto">
<div
class=" pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0 max-w-full"
id="messages-container"
bind:this={messagesContainerElement}
on:scroll={(e) => {
autoScroll =
messagesContainerElement.scrollHeight - messagesContainerElement.scrollTop <=
messagesContainerElement.clientHeight + 5;
}}
>
<div class=" h-full w-full flex flex-col py-4">
<Messages
chatId={$chatId}
{selectedModels}
{selectedModelfiles}
{processing}
bind:history
bind:messages
bind:autoScroll
bind:prompt
bottomPadding={files.length > 0}
{sendPrompt}
{continueGeneration}
{regenerateResponse}
/>
</div>
</div>
</div>
</div>
<MessageInput
bind:files
bind:prompt
bind:autoScroll
bind:selectedModel={atSelectedModel}
{messages}
{submitPrompt}
{stopResponse}
/>
{/if}
...@@ -39,10 +39,10 @@ ...@@ -39,10 +39,10 @@
class="flex scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-xl bg-transparent/10 p-1" class="flex scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-xl bg-transparent/10 p-1"
> >
<a <a
class="min-w-fit rounded-lg p-1.5 px-3 {$page.url.pathname.includes('/workspace/modelfiles') class="min-w-fit rounded-lg p-1.5 px-3 {$page.url.pathname.includes('/workspace/models')
? 'bg-gray-50 dark:bg-gray-850' ? 'bg-gray-50 dark:bg-gray-850'
: ''} transition" : ''} transition"
href="/workspace/modelfiles">{$i18n.t('Modelfiles')}</a href="/workspace/models">{$i18n.t('Models')}</a
> >
<a <a
......
...@@ -3,6 +3,6 @@ ...@@ -3,6 +3,6 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
onMount(() => { onMount(() => {
goto('/workspace/modelfiles'); goto('/workspace/models');
}); });
</script> </script>
<script>
import Modelfiles from '$lib/components/workspace/Modelfiles.svelte';
</script>
<Modelfiles />
<script>
import { v4 as uuidv4 } from 'uuid';
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import { onMount, getContext } from 'svelte';
import { page } from '$app/stores';
import { settings, user, config, modelfiles } from '$lib/stores';
import { splitStream } from '$lib/utils';
import { createModel } from '$lib/apis/ollama';
import { getModelfiles, updateModelfileByTagName } from '$lib/apis/modelfiles';
import AdvancedParams from '$lib/components/chat/Settings/Advanced/AdvancedParams.svelte';
const i18n = getContext('i18n');
let loading = false;
let filesInputElement;
let inputFiles;
let imageUrl = null;
let digest = '';
let pullProgress = null;
let success = false;
let modelfile = null;
// ///////////
// Modelfile
// ///////////
let title = '';
let tagName = '';
let desc = '';
// Raw Mode
let content = '';
let suggestions = [
{
content: ''
}
];
let categories = {
character: false,
assistant: false,
writing: false,
productivity: false,
programming: false,
'data analysis': false,
lifestyle: false,
education: false,
business: false
};
onMount(() => {
tagName = $page.url.searchParams.get('tag');
if (tagName) {
modelfile = $modelfiles.filter((modelfile) => modelfile.tagName === tagName)[0];
console.log(modelfile);
imageUrl = modelfile.imageUrl;
title = modelfile.title;
desc = modelfile.desc;
content = modelfile.content;
suggestions =
modelfile.suggestionPrompts.length != 0
? modelfile.suggestionPrompts
: [
{
content: ''
}
];
for (const category of modelfile.categories) {
categories[category.toLowerCase()] = true;
}
} else {
goto('/workspace/modelfiles');
}
});
const updateModelfile = async (modelfile) => {
await updateModelfileByTagName(localStorage.token, modelfile.tagName, modelfile);
await modelfiles.set(await getModelfiles(localStorage.token));
};
const updateHandler = async () => {
loading = true;
if (Object.keys(categories).filter((category) => categories[category]).length == 0) {
toast.error(
'Uh-oh! It looks like you missed selecting a category. Please choose one to complete your modelfile.'
);
}
if (
title !== '' &&
desc !== '' &&
content !== '' &&
Object.keys(categories).filter((category) => categories[category]).length > 0
) {
const res = await createModel(localStorage.token, tagName, content);
if (res) {
const reader = res.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(splitStream('\n'))
.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
try {
let lines = value.split('\n');
for (const line of lines) {
if (line !== '') {
console.log(line);
let data = JSON.parse(line);
console.log(data);
if (data.error) {
throw data.error;
}
if (data.detail) {
throw data.detail;
}
if (data.status) {
if (
!data.digest &&
!data.status.includes('writing') &&
!data.status.includes('sha256')
) {
toast.success(data.status);
if (data.status === 'success') {
success = true;
}
} else {
if (data.digest) {
digest = data.digest;
if (data.completed) {
pullProgress = Math.round((data.completed / data.total) * 1000) / 10;
} else {
pullProgress = 100;
}
}
}
}
}
}
} catch (error) {
console.log(error);
toast.error(error);
}
}
}
if (success) {
await updateModelfile({
tagName: tagName,
imageUrl: imageUrl,
title: title,
desc: desc,
content: content,
suggestionPrompts: suggestions.filter((prompt) => prompt.content !== ''),
categories: Object.keys(categories).filter((category) => categories[category])
});
await goto('/workspace/modelfiles');
}
}
loading = false;
success = false;
};
</script>
<div class="w-full max-h-full">
<input
bind:this={filesInputElement}
bind:files={inputFiles}
type="file"
hidden
accept="image/*"
on:change={() => {
let reader = new FileReader();
reader.onload = (event) => {
let originalImageUrl = `${event.target.result}`;
const img = new Image();
img.src = originalImageUrl;
img.onload = function () {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// Calculate the aspect ratio of the image
const aspectRatio = img.width / img.height;
// Calculate the new width and height to fit within 100x100
let newWidth, newHeight;
if (aspectRatio > 1) {
newWidth = 100 * aspectRatio;
newHeight = 100;
} else {
newWidth = 100;
newHeight = 100 / aspectRatio;
}
// Set the canvas size
canvas.width = 100;
canvas.height = 100;
// Calculate the position to center the image
const offsetX = (100 - newWidth) / 2;
const offsetY = (100 - newHeight) / 2;
// Draw the image on the canvas
ctx.drawImage(img, offsetX, offsetY, newWidth, newHeight);
// Get the base64 representation of the compressed image
const compressedSrc = canvas.toDataURL('image/jpeg');
// Display the compressed image
imageUrl = compressedSrc;
inputFiles = null;
};
};
if (
inputFiles &&
inputFiles.length > 0 &&
['image/gif', 'image/webp', 'image/jpeg', 'image/png'].includes(inputFiles[0]['type'])
) {
reader.readAsDataURL(inputFiles[0]);
} else {
console.log(`Unsupported File Type '${inputFiles[0]['type']}'.`);
inputFiles = null;
}
}}
/>
<button
class="flex space-x-1"
on:click={() => {
history.back();
}}
>
<div class=" self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M17 10a.75.75 0 01-.75.75H5.612l4.158 3.96a.75.75 0 11-1.04 1.08l-5.5-5.25a.75.75 0 010-1.08l5.5-5.25a.75.75 0 111.04 1.08L5.612 9.25H16.25A.75.75 0 0117 10z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center font-medium text-sm">{$i18n.t('Back')}</div>
</button>
<form
class="flex flex-col max-w-2xl mx-auto mt-4 mb-10"
on:submit|preventDefault={() => {
updateHandler();
}}
>
<div class="flex justify-center my-4">
<div class="self-center">
<button
class=" {imageUrl
? ''
: 'p-6'} rounded-full dark:bg-gray-700 border border-dashed border-gray-200"
type="button"
on:click={() => {
filesInputElement.click();
}}
>
{#if imageUrl}
<img
src={imageUrl}
alt="modelfile profile"
class=" rounded-full w-20 h-20 object-cover"
/>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-8"
>
<path
fill-rule="evenodd"
d="M12 3.75a.75.75 0 01.75.75v6.75h6.75a.75.75 0 010 1.5h-6.75v6.75a.75.75 0 01-1.5 0v-6.75H4.5a.75.75 0 010-1.5h6.75V4.5a.75.75 0 01.75-.75z"
clip-rule="evenodd"
/>
</svg>
{/if}
</button>
</div>
</div>
<div class="my-2 flex space-x-2">
<div class="flex-1">
<div class=" text-sm font-semibold mb-2">{$i18n.t('Name')}*</div>
<div>
<input
class="px-3 py-1.5 text-sm w-full bg-transparent border dark:border-gray-600 outline-none rounded-lg"
placeholder={$i18n.t('Name your modelfile')}
bind:value={title}
required
/>
</div>
</div>
<div class="flex-1">
<div class=" text-sm font-semibold mb-2">{$i18n.t('Model Tag Name')}*</div>
<div>
<input
class="px-3 py-1.5 text-sm w-full bg-transparent disabled:text-gray-500 border dark:border-gray-600 outline-none rounded-lg"
placeholder={$i18n.t('Add a model tag name')}
value={tagName}
disabled
required
/>
</div>
</div>
</div>
<div class="my-2">
<div class=" text-sm font-semibold mb-2">{$i18n.t('Description')}*</div>
<div>
<input
class="px-3 py-1.5 text-sm w-full bg-transparent border dark:border-gray-600 outline-none rounded-lg"
placeholder={$i18n.t('Add a short description about what this modelfile does')}
bind:value={desc}
required
/>
</div>
</div>
<div class="my-2">
<div class="flex w-full justify-between">
<div class=" self-center text-sm font-semibold">{$i18n.t('Modelfile')}</div>
</div>
<!-- <div class=" text-sm font-semibold mb-2"></div> -->
<div class="mt-2">
<div class=" text-xs font-semibold mb-2">{$i18n.t('Content')}*</div>
<div>
<textarea
class="px-3 py-1.5 text-sm w-full bg-transparent border dark:border-gray-600 outline-none rounded-lg"
placeholder={`FROM llama2\nPARAMETER temperature 1\nSYSTEM """\nYou are Mario from Super Mario Bros, acting as an assistant.\n"""`}
rows="6"
bind:value={content}
required
/>
</div>
</div>
</div>
<div class="my-2">
<div class="flex w-full justify-between mb-2">
<div class=" self-center text-sm font-semibold">{$i18n.t('Prompt suggestions')}</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
type="button"
on:click={() => {
if (suggestions.length === 0 || suggestions.at(-1).content !== '') {
suggestions = [...suggestions, { content: '' }];
}
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z"
/>
</svg>
</button>
</div>
<div class="flex flex-col space-y-1">
{#each suggestions as prompt, promptIdx}
<div class=" flex border dark:border-gray-600 rounded-lg">
<input
class="px-3 py-1.5 text-sm w-full bg-transparent outline-none border-r dark:border-gray-600"
placeholder={$i18n.t('Write a prompt suggestion (e.g. Who are you?)')}
bind:value={prompt.content}
/>
<button
class="px-2"
type="button"
on:click={() => {
suggestions.splice(promptIdx, 1);
suggestions = suggestions;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</div>
{/each}
</div>
</div>
<div class="my-2">
<div class=" text-sm font-semibold mb-2">{$i18n.t('Categories')}</div>
<div class="grid grid-cols-4">
{#each Object.keys(categories) as category}
<div class="flex space-x-2 text-sm">
<input type="checkbox" bind:checked={categories[category]} />
<div class=" capitalize">{category}</div>
</div>
{/each}
</div>
</div>
{#if pullProgress !== null}
<div class="my-2">
<div class=" text-sm font-semibold mb-2">{$i18n.t('Pull Progress')}</div>
<div class="w-full rounded-full dark:bg-gray-800">
<div
class="dark:bg-gray-600 text-xs font-medium text-blue-100 text-center p-0.5 leading-none rounded-full"
style="width: {Math.max(15, pullProgress ?? 0)}%"
>
{pullProgress ?? 0}%
</div>
</div>
<div class="mt-1 text-xs dark:text-gray-500" style="font-size: 0.5rem;">
{digest}
</div>
</div>
{/if}
<div class="my-2 flex justify-end">
<button
class=" text-sm px-3 py-2 transition rounded-xl {loading
? ' cursor-not-allowed bg-gray-100 dark:bg-gray-800'
: ' bg-gray-50 hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-800'} flex"
type="submit"
disabled={loading}
>
<div class=" self-center font-medium">{$i18n.t('Save & Update')}</div>
{#if loading}
<div class="ml-1.5 self-center">
<svg
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
</div>
{/if}
</button>
</div>
</form>
</div>
<script>
import Models from '$lib/components/workspace/Models.svelte';
</script>
<Models />
...@@ -2,283 +2,164 @@ ...@@ -2,283 +2,164 @@
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { settings, user, config, modelfiles, models } from '$lib/stores'; import { settings, user, config, models } from '$lib/stores';
import AdvancedParams from '$lib/components/chat/Settings/Advanced/AdvancedParams.svelte';
import { splitStream } from '$lib/utils';
import { onMount, tick, getContext } from 'svelte'; import { onMount, tick, getContext } from 'svelte';
import { createModel } from '$lib/apis/ollama'; import { addNewModel, getModelById, getModelInfos } from '$lib/apis/models';
import { createNewModelfile, getModelfileByTagName, getModelfiles } from '$lib/apis/modelfiles'; import { getModels } from '$lib/apis';
const i18n = getContext('i18n'); import AdvancedParams from '$lib/components/chat/Settings/Advanced/AdvancedParams.svelte';
import Checkbox from '$lib/components/common/Checkbox.svelte';
import Tags from '$lib/components/common/Tags.svelte';
let loading = false; const i18n = getContext('i18n');
let filesInputElement; let filesInputElement;
let inputFiles; let inputFiles;
let imageUrl = null;
let digest = ''; let showAdvanced = false;
let pullProgress = null; let showPreview = false;
let loading = false;
let success = false; let success = false;
// /////////// // ///////////
// Modelfile // Model
// /////////// // ///////////
let title = ''; let id = '';
let tagName = ''; let name = '';
let desc = '';
let raw = true;
let advanced = false;
// Raw Mode
let content = '';
// Builder Mode
let model = '';
let system = '';
let template = '';
let options = {
// Advanced
seed: 0,
stop: '',
temperature: '',
repeat_penalty: '',
repeat_last_n: '',
mirostat: '',
mirostat_eta: '',
mirostat_tau: '',
top_k: '',
top_p: '',
tfs_z: '',
num_ctx: '',
num_predict: ''
};
let modelfileCreator = null; let params = {};
let capabilities = {
$: tagName = title !== '' ? `${title.replace(/\s+/g, '-').toLowerCase()}:latest` : ''; vision: true
};
$: if (!raw) {
content = `FROM ${model}
${template !== '' ? `TEMPLATE """${template}"""` : ''}
${options.seed !== 0 ? `PARAMETER seed ${options.seed}` : ''}
${options.stop !== '' ? `PARAMETER stop ${options.stop}` : ''}
${options.temperature !== '' ? `PARAMETER temperature ${options.temperature}` : ''}
${options.repeat_penalty !== '' ? `PARAMETER repeat_penalty ${options.repeat_penalty}` : ''}
${options.repeat_last_n !== '' ? `PARAMETER repeat_last_n ${options.repeat_last_n}` : ''}
${options.mirostat !== '' ? `PARAMETER mirostat ${options.mirostat}` : ''}
${options.mirostat_eta !== '' ? `PARAMETER mirostat_eta ${options.mirostat_eta}` : ''}
${options.mirostat_tau !== '' ? `PARAMETER mirostat_tau ${options.mirostat_tau}` : ''}
${options.top_k !== '' ? `PARAMETER top_k ${options.top_k}` : ''}
${options.top_p !== '' ? `PARAMETER top_p ${options.top_p}` : ''}
${options.tfs_z !== '' ? `PARAMETER tfs_z ${options.tfs_z}` : ''}
${options.num_ctx !== '' ? `PARAMETER num_ctx ${options.num_ctx}` : ''}
${options.num_predict !== '' ? `PARAMETER num_predict ${options.num_predict}` : ''}
SYSTEM """${system}"""`.replace(/^\s*\n/gm, '');
}
let suggestions = [ let info = {
{ id: '',
content: '' base_model_id: null,
name: '',
meta: {
profile_image_url: null,
description: '',
suggestion_prompts: [
{
content: ''
}
]
},
params: {
system: ''
} }
];
let categories = {
character: false,
assistant: false,
writing: false,
productivity: false,
programming: false,
'data analysis': false,
lifestyle: false,
education: false,
business: false
}; };
const saveModelfile = async (modelfile) => { $: if (name) {
await createNewModelfile(localStorage.token, modelfile); id = name.replace(/\s+/g, '-').toLowerCase();
await modelfiles.set(await getModelfiles(localStorage.token)); }
};
let baseModel = null;
$: {
baseModel = $models.find((m) => m.id === info.base_model_id);
console.log(baseModel);
if (baseModel) {
if (baseModel.owned_by === 'openai') {
capabilities.usage = baseModel.info?.meta?.capabilities?.usage ?? false;
} else {
delete capabilities.usage;
}
capabilities = capabilities;
}
}
const submitHandler = async () => { const submitHandler = async () => {
loading = true; loading = true;
if (Object.keys(categories).filter((category) => categories[category]).length == 0) { info.id = id;
toast.error( info.name = name;
'Uh-oh! It looks like you missed selecting a category. Please choose one to complete your modelfile.' info.meta.capabilities = capabilities;
); info.params.stop = params.stop ? params.stop.split(',').filter((s) => s.trim()) : null;
loading = false;
success = false; Object.keys(info.params).forEach((key) => {
return success; if (info.params[key] === '' || info.params[key] === null) {
} delete info.params[key];
}
});
if ( if ($models.find((m) => m.id === info.id)) {
$models.map((model) => model.name).includes(tagName) ||
(await getModelfileByTagName(localStorage.token, tagName).catch(() => false))
) {
toast.error( toast.error(
`Uh-oh! It looks like you already have a model named '${tagName}'. Please choose a different name to complete your modelfile.` `Error: A model with the ID '${info.id}' already exists. Please select a different ID to proceed.`
); );
loading = false; loading = false;
success = false; success = false;
return success; return success;
} }
if ( if (info) {
title !== '' && const res = await addNewModel(localStorage.token, {
desc !== '' && ...info,
content !== '' && meta: {
Object.keys(categories).filter((category) => categories[category]).length > 0 && ...info.meta,
!$models.includes(tagName) profile_image_url: info.meta.profile_image_url ?? '/favicon.png',
) { suggestion_prompts: info.meta.suggestion_prompts
const res = await createModel(localStorage.token, tagName, content); ? info.meta.suggestion_prompts.filter((prompt) => prompt.content !== '')
: null
},
params: { ...info.params, ...params }
});
if (res) { if (res) {
const reader = res.body await models.set(await getModels(localStorage.token));
.pipeThrough(new TextDecoderStream()) toast.success('Model created successfully!');
.pipeThrough(splitStream('\n')) await goto('/workspace/models');
.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
try {
let lines = value.split('\n');
for (const line of lines) {
if (line !== '') {
console.log(line);
let data = JSON.parse(line);
console.log(data);
if (data.error) {
throw data.error;
}
if (data.detail) {
throw data.detail;
}
if (data.status) {
if (
!data.digest &&
!data.status.includes('writing') &&
!data.status.includes('sha256')
) {
toast.success(data.status);
if (data.status === 'success') {
success = true;
}
} else {
if (data.digest) {
digest = data.digest;
if (data.completed) {
pullProgress = Math.round((data.completed / data.total) * 1000) / 10;
} else {
pullProgress = 100;
}
}
}
}
}
}
} catch (error) {
console.log(error);
toast.error(error);
}
}
}
if (success) {
await saveModelfile({
tagName: tagName,
imageUrl: imageUrl,
title: title,
desc: desc,
content: content,
suggestionPrompts: suggestions.filter((prompt) => prompt.content !== ''),
categories: Object.keys(categories).filter((category) => categories[category]),
user: modelfileCreator !== null ? modelfileCreator : undefined
});
await goto('/workspace/modelfiles');
} }
} }
loading = false; loading = false;
success = false; success = false;
}; };
const initModel = async (model) => {
name = model.name;
await tick();
id = model.id;
params = { ...params, ...model?.info?.params };
params.stop = params?.stop ? (params?.stop ?? []).join(',') : null;
capabilities = { ...capabilities, ...(model?.info?.meta?.capabilities ?? {}) };
info = {
...info,
...model.info
};
};
onMount(async () => { onMount(async () => {
window.addEventListener('message', async (event) => { window.addEventListener('message', async (event) => {
if ( if (
![ !['https://openwebui.com', 'https://www.openwebui.com', 'http://localhost:5173'].includes(
'https://ollamahub.com', event.origin
'https://www.ollamahub.com', )
'https://openwebui.com',
'https://www.openwebui.com',
'http://localhost:5173'
].includes(event.origin)
) )
return; return;
const modelfile = JSON.parse(event.data);
console.log(modelfile);
imageUrl = modelfile.imageUrl;
title = modelfile.title;
await tick();
tagName = `${modelfile.user.username === 'hub' ? '' : `hub/`}${modelfile.user.username}/${
modelfile.tagName
}`;
desc = modelfile.desc;
content = modelfile.content;
suggestions =
modelfile.suggestionPrompts.length != 0
? modelfile.suggestionPrompts
: [
{
content: ''
}
];
modelfileCreator = { const model = JSON.parse(event.data);
username: modelfile.user.username, console.log(model);
name: modelfile.user.name
}; initModel(model);
for (const category of modelfile.categories) {
categories[category.toLowerCase()] = true;
}
}); });
if (window.opener ?? false) { if (window.opener ?? false) {
window.opener.postMessage('loaded', '*'); window.opener.postMessage('loaded', '*');
} }
if (sessionStorage.modelfile) { if (sessionStorage.model) {
const modelfile = JSON.parse(sessionStorage.modelfile); const model = JSON.parse(sessionStorage.model);
console.log(modelfile); sessionStorage.removeItem('model');
imageUrl = modelfile.imageUrl;
title = modelfile.title;
await tick();
tagName = modelfile.tagName;
desc = modelfile.desc;
content = modelfile.content;
suggestions =
modelfile.suggestionPrompts.length != 0
? modelfile.suggestionPrompts
: [
{
content: ''
}
];
for (const category of modelfile.categories) {
categories[category.toLowerCase()] = true;
}
sessionStorage.removeItem('modelfile'); console.log(model);
initModel(model);
} }
}); });
</script> </script>
...@@ -330,7 +211,7 @@ SYSTEM """${system}"""`.replace(/^\s*\n/gm, ''); ...@@ -330,7 +211,7 @@ SYSTEM """${system}"""`.replace(/^\s*\n/gm, '');
const compressedSrc = canvas.toDataURL('image/jpeg'); const compressedSrc = canvas.toDataURL('image/jpeg');
// Display the compressed image // Display the compressed image
imageUrl = compressedSrc; info.meta.profile_image_url = compressedSrc;
inputFiles = null; inputFiles = null;
}; };
...@@ -382,26 +263,26 @@ SYSTEM """${system}"""`.replace(/^\s*\n/gm, ''); ...@@ -382,26 +263,26 @@ SYSTEM """${system}"""`.replace(/^\s*\n/gm, '');
<div class="flex justify-center my-4"> <div class="flex justify-center my-4">
<div class="self-center"> <div class="self-center">
<button <button
class=" {imageUrl class=" {info.meta.profile_image_url
? '' ? ''
: 'p-6'} rounded-full dark:bg-gray-700 border border-dashed border-gray-200" : 'p-4'} rounded-full dark:bg-gray-700 border border-dashed border-gray-200 flex items-center"
type="button" type="button"
on:click={() => { on:click={() => {
filesInputElement.click(); filesInputElement.click();
}} }}
> >
{#if imageUrl} {#if info.meta.profile_image_url}
<img <img
src={imageUrl} src={info.meta.profile_image_url}
alt="modelfile profile" alt="modelfile profile"
class=" rounded-full w-20 h-20 object-cover" class=" rounded-full size-16 object-cover"
/> />
{:else} {:else}
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="currentColor" fill="currentColor"
class="w-8" class="size-8"
> >
<path <path
fill-rule="evenodd" fill-rule="evenodd"
...@@ -421,21 +302,21 @@ SYSTEM """${system}"""`.replace(/^\s*\n/gm, ''); ...@@ -421,21 +302,21 @@ SYSTEM """${system}"""`.replace(/^\s*\n/gm, '');
<div> <div>
<input <input
class="px-3 py-1.5 text-sm w-full bg-transparent border dark:border-gray-600 outline-none rounded-lg" class="px-3 py-1.5 text-sm w-full bg-transparent border dark:border-gray-600 outline-none rounded-lg"
placeholder={$i18n.t('Name your modelfile')} placeholder={$i18n.t('Name your model')}
bind:value={title} bind:value={name}
required required
/> />
</div> </div>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<div class=" text-sm font-semibold mb-2">{$i18n.t('Model Tag Name')}*</div> <div class=" text-sm font-semibold mb-2">{$i18n.t('Model ID')}*</div>
<div> <div>
<input <input
class="px-3 py-1.5 text-sm w-full bg-transparent border dark:border-gray-600 outline-none rounded-lg" class="px-3 py-1.5 text-sm w-full bg-transparent border dark:border-gray-600 outline-none rounded-lg"
placeholder={$i18n.t('Add a model tag name')} placeholder={$i18n.t('Add a model id')}
bind:value={tagName} bind:value={id}
required required
/> />
</div> </div>
...@@ -443,242 +324,278 @@ SYSTEM """${system}"""`.replace(/^\s*\n/gm, ''); ...@@ -443,242 +324,278 @@ SYSTEM """${system}"""`.replace(/^\s*\n/gm, '');
</div> </div>
<div class="my-2"> <div class="my-2">
<div class=" text-sm font-semibold mb-2">{$i18n.t('Description')}*</div> <div class=" text-sm font-semibold mb-2">{$i18n.t('Base Model (From)')}</div>
<div> <div>
<input <select
class="px-3 py-1.5 text-sm w-full bg-transparent border dark:border-gray-600 outline-none rounded-lg" class="px-3 py-1.5 text-sm w-full bg-transparent border dark:border-gray-600 outline-none rounded-lg"
placeholder={$i18n.t('Add a short description about what this modelfile does')} placeholder="Select a base model (e.g. llama3, gpt-4o)"
bind:value={desc} bind:value={info.base_model_id}
required required
/> >
<option value={null} class=" text-gray-900">{$i18n.t('Select a base model')}</option>
{#each $models.filter((m) => !m?.preset) as model}
<option value={model.id} class=" text-gray-900">{model.name}</option>
{/each}
</select>
</div> </div>
</div> </div>
<div class="my-2"> <div class="my-1">
<div class="flex w-full justify-between"> <div class="flex w-full justify-between items-center mb-1">
<div class=" self-center text-sm font-semibold">{$i18n.t('Modelfile')}</div> <div class=" self-center text-sm font-semibold">{$i18n.t('Description')}</div>
<button <button
class="p-1 px-3 text-xs flex rounded transition" class="p-1 text-xs flex rounded transition"
type="button" type="button"
on:click={() => { on:click={() => {
raw = !raw; if (info.meta.description === null) {
info.meta.description = '';
} else {
info.meta.description = null;
}
}} }}
> >
{#if raw} {#if info.meta.description === null}
<span class="ml-2 self-center"> {$i18n.t('Raw Format')} </span> <span class="ml-2 self-center">{$i18n.t('Default')}</span>
{:else} {:else}
<span class="ml-2 self-center"> {$i18n.t('Builder Mode')} </span> <span class="ml-2 self-center">{$i18n.t('Custom')}</span>
{/if} {/if}
</button> </button>
</div> </div>
<!-- <div class=" text-sm font-semibold mb-2"></div> --> {#if info.meta.description !== null}
<input
{#if raw} class="px-3 py-1.5 text-sm w-full bg-transparent border dark:border-gray-600 outline-none rounded-lg"
<div class="mt-2"> placeholder={$i18n.t('Add a short description about what this model does')}
<div class=" text-xs font-semibold mb-2">{$i18n.t('Content')}*</div> bind:value={info.meta.description}
/>
<div> {/if}
<textarea </div>
class="px-3 py-1.5 text-sm w-full bg-transparent border dark:border-gray-600 outline-none rounded-lg"
placeholder={`FROM llama2\nPARAMETER temperature 1\nSYSTEM """\nYou are Mario from Super Mario Bros, acting as an assistant.\n"""`}
rows="6"
bind:value={content}
required
/>
</div>
<div class="text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('Not sure what to write? Switch to')}
<button
class="text-gray-500 dark:text-gray-300 font-medium cursor-pointer"
type="button"
on:click={() => {
raw = !raw;
}}>{$i18n.t('Builder Mode')}</button
>
or
<a
class=" text-gray-500 dark:text-gray-300 font-medium"
href="https://openwebui.com"
target="_blank"
>
{$i18n.t('Click here to check other modelfiles.')}
</a>
</div>
</div>
{:else}
<div class="my-2">
<div class=" text-xs font-semibold mb-2">{$i18n.t('From (Base Model)')}*</div>
<div> <hr class=" dark:border-gray-850 my-1" />
<input
class="px-3 py-1.5 text-sm w-full bg-transparent border dark:border-gray-600 outline-none rounded-lg"
placeholder="Write a modelfile base model name (e.g. llama2, mistral)"
bind:value={model}
required
/>
</div>
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500"> <div class="my-2">
{$i18n.t('To access the available model names for downloading,')} <div class="flex w-full justify-between">
<a <div class=" self-center text-sm font-semibold">{$i18n.t('Model Params')}</div>
class=" text-gray-500 dark:text-gray-300 font-medium" </div>
href="https://ollama.com/library"
target="_blank">{$i18n.t('click here.')}</a
>
</div>
</div>
<div class="mt-2">
<div class="my-1"> <div class="my-1">
<div class=" text-xs font-semibold mb-2">{$i18n.t('System Prompt')}</div> <div class=" text-xs font-semibold mb-2">{$i18n.t('System Prompt')}</div>
<div> <div>
<textarea <textarea
class="px-3 py-1.5 text-sm w-full bg-transparent border dark:border-gray-600 outline-none rounded-lg -mb-1" class="px-3 py-1.5 text-sm w-full bg-transparent border dark:border-gray-600 outline-none rounded-lg -mb-1"
placeholder={`Write your modelfile system prompt content here\ne.g.) You are Mario from Super Mario Bros, acting as an assistant.`} placeholder={`Write your model system prompt content here\ne.g.) You are Mario from Super Mario Bros, acting as an assistant.`}
rows="4" rows="4"
bind:value={system} bind:value={info.params.system}
/> />
</div> </div>
</div> </div>
<div class="flex w-full justify-between"> <div class="flex w-full justify-between">
<div class=" self-center text-sm font-semibold"> <div class=" self-center text-xs font-semibold">
{$i18n.t('Modelfile Advanced Settings')} {$i18n.t('Advanced Params')}
</div> </div>
<button <button
class="p-1 px-3 text-xs flex rounded transition" class="p-1 px-3 text-xs flex rounded transition"
type="button" type="button"
on:click={() => { on:click={() => {
advanced = !advanced; showAdvanced = !showAdvanced;
}} }}
> >
{#if advanced} {#if showAdvanced}
<span class="ml-2 self-center">{$i18n.t('Custom')}</span> <span class="ml-2 self-center">{$i18n.t('Hide')}</span>
{:else} {:else}
<span class="ml-2 self-center">{$i18n.t('Default')}</span> <span class="ml-2 self-center">{$i18n.t('Show')}</span>
{/if} {/if}
</button> </button>
</div> </div>
{#if advanced} {#if showAdvanced}
<div class="my-2"> <div class="my-2">
<div class=" text-xs font-semibold mb-2">{$i18n.t('Template')}</div> <AdvancedParams
bind:params
<div> on:change={(e) => {
<textarea info.params = { ...info.params, ...params };
class="px-3 py-1.5 text-sm w-full bg-transparent border dark:border-gray-600 outline-none rounded-lg -mb-1" }}
placeholder="Write your modelfile template content here" />
rows="4"
bind:value={template}
/>
</div>
</div> </div>
{/if}
</div>
</div>
<div class="my-2"> <hr class=" dark:border-gray-850 my-1" />
<div class=" text-xs font-semibold mb-2">{$i18n.t('Parameters')}</div>
<div> <div class="my-1">
<AdvancedParams bind:options /> <div class="flex w-full justify-between items-center">
</div> <div class="flex w-full justify-between items-center">
</div> <div class=" self-center text-sm font-semibold">{$i18n.t('Prompt suggestions')}</div>
<button
class="p-1 text-xs flex rounded transition"
type="button"
on:click={() => {
if (info.meta.suggestion_prompts === null) {
info.meta.suggestion_prompts = [{ content: '' }];
} else {
info.meta.suggestion_prompts = null;
}
}}
>
{#if info.meta.suggestion_prompts === null}
<span class="ml-2 self-center">{$i18n.t('Default')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Custom')}</span>
{/if}
</button>
</div>
{#if info.meta.suggestion_prompts !== null}
<button
class="p-1 px-2 text-xs flex rounded transition"
type="button"
on:click={() => {
if (
info.meta.suggestion_prompts.length === 0 ||
info.meta.suggestion_prompts.at(-1).content !== ''
) {
info.meta.suggestion_prompts = [...info.meta.suggestion_prompts, { content: '' }];
}
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z"
/>
</svg>
</button>
{/if} {/if}
</div>
{#if info.meta.suggestion_prompts}
<div class="flex flex-col space-y-1 mt-2">
{#if info.meta.suggestion_prompts.length > 0}
{#each info.meta.suggestion_prompts as prompt, promptIdx}
<div class=" flex border dark:border-gray-600 rounded-lg">
<input
class="px-3 py-1.5 text-sm w-full bg-transparent outline-none border-r dark:border-gray-600"
placeholder={$i18n.t('Write a prompt suggestion (e.g. Who are you?)')}
bind:value={prompt.content}
/>
<button
class="px-2"
type="button"
on:click={() => {
info.meta.suggestion_prompts.splice(promptIdx, 1);
info.meta.suggestion_prompts = info.meta.suggestion_prompts;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</div>
{/each}
{:else}
<div class="text-xs text-center">No suggestion prompts</div>
{/if}
</div>
{/if} {/if}
</div> </div>
<div class="my-2"> <div class="my-1">
<div class="flex w-full justify-between mb-2"> <div class="flex w-full justify-between mb-1">
<div class=" self-center text-sm font-semibold">{$i18n.t('Prompt suggestions')}</div> <div class=" self-center text-sm font-semibold">{$i18n.t('Capabilities')}</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
type="button"
on:click={() => {
if (suggestions.length === 0 || suggestions.at(-1).content !== '') {
suggestions = [...suggestions, { content: '' }];
}
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z"
/>
</svg>
</button>
</div> </div>
<div class="flex flex-col space-y-1"> <div class="flex flex-col">
{#each suggestions as prompt, promptIdx} {#each Object.keys(capabilities) as capability}
<div class=" flex border dark:border-gray-600 rounded-lg"> <div class=" flex items-center gap-2">
<input <Checkbox
class="px-3 py-1.5 text-sm w-full bg-transparent outline-none border-r dark:border-gray-600" state={capabilities[capability] ? 'checked' : 'unchecked'}
placeholder={$i18n.t('Write a prompt suggestion (e.g. Who are you?)')} on:change={(e) => {
bind:value={prompt.content} capabilities[capability] = e.detail === 'checked';
}}
/> />
<button <div class=" py-0.5 text-sm w-full capitalize">
class="px-2" {$i18n.t(capability)}
type="button" </div>
on:click={() => {
suggestions.splice(promptIdx, 1);
suggestions = suggestions;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</div> </div>
{/each} {/each}
</div> </div>
</div> </div>
<div class="my-2"> <div class="my-1">
<div class=" text-sm font-semibold mb-2">{$i18n.t('Categories')}</div> <div class="flex w-full justify-between items-center">
<div class=" self-center text-sm font-semibold">{$i18n.t('Tags')}</div>
</div>
<div class="grid grid-cols-4"> <div class="mt-2">
{#each Object.keys(categories) as category} <Tags
<div class="flex space-x-2 text-sm"> tags={info?.meta?.tags ?? []}
<input type="checkbox" bind:checked={categories[category]} /> deleteTag={(tagName) => {
<div class="capitalize">{category}</div> info.meta.tags = info.meta.tags.filter((tag) => tag.name !== tagName);
</div> }}
{/each} addTag={(tagName) => {
console.log(tagName);
if (!(info?.meta?.tags ?? null)) {
info.meta.tags = [{ name: tagName }];
} else {
info.meta.tags = [...info.meta.tags, { name: tagName }];
}
}}
/>
</div> </div>
</div> </div>
{#if pullProgress !== null} <div class="my-2 text-gray-300 dark:text-gray-700">
<div class="my-2"> <div class="flex w-full justify-between mb-2">
<div class=" text-sm font-semibold mb-2">{$i18n.t('Pull Progress')}</div> <div class=" self-center text-sm font-semibold">{$i18n.t('JSON Preview')}</div>
<div class="w-full rounded-full dark:bg-gray-800">
<div <button
class="dark:bg-gray-600 bg-gray-500 text-xs font-medium text-gray-100 text-center p-0.5 leading-none rounded-full" class="p-1 px-3 text-xs flex rounded transition"
style="width: {Math.max(15, pullProgress ?? 0)}%" type="button"
> on:click={() => {
{pullProgress ?? 0}% showPreview = !showPreview;
</div> }}
</div> >
<div class="mt-1 text-xs dark:text-gray-500" style="font-size: 0.5rem;"> {#if showPreview}
{digest} <span class="ml-2 self-center">{$i18n.t('Hide')}</span>
</div> {:else}
<span class="ml-2 self-center">{$i18n.t('Show')}</span>
{/if}
</button>
</div> </div>
{/if}
<div class="my-2 flex justify-end"> {#if showPreview}
<div>
<textarea
class="px-3 py-1.5 text-sm w-full bg-transparent border dark:border-gray-600 outline-none rounded-lg"
rows="10"
value={JSON.stringify(info, null, 2)}
disabled
readonly
/>
</div>
{/if}
</div>
<div class="my-2 flex justify-end mb-20">
<button <button
class=" text-sm px-3 py-2 transition rounded-xl {loading class=" text-sm px-3 py-2 transition rounded-xl {loading
? ' cursor-not-allowed bg-gray-100 dark:bg-gray-800' ? ' cursor-not-allowed bg-gray-100 dark:bg-gray-800'
......
<script>
import { v4 as uuidv4 } from 'uuid';
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import { onMount, getContext } from 'svelte';
import { page } from '$app/stores';
import { settings, user, config, models } from '$lib/stores';
import { splitStream } from '$lib/utils';
import { getModelInfos, updateModelById } from '$lib/apis/models';
import AdvancedParams from '$lib/components/chat/Settings/Advanced/AdvancedParams.svelte';
import { getModels } from '$lib/apis';
import Checkbox from '$lib/components/common/Checkbox.svelte';
import Tags from '$lib/components/common/Tags.svelte';
const i18n = getContext('i18n');
let loading = false;
let success = false;
let filesInputElement;
let inputFiles;
let digest = '';
let pullProgress = null;
let showAdvanced = false;
let showPreview = false;
// ///////////
// model
// ///////////
let model = null;
let id = '';
let name = '';
let info = {
id: '',
base_model_id: null,
name: '',
meta: {
profile_image_url: '/favicon.png',
description: '',
suggestion_prompts: null,
tags: []
},
params: {
system: ''
}
};
let params = {};
let capabilities = {
vision: true
};
const updateHandler = async () => {
loading = true;
info.id = id;
info.name = name;
info.meta.capabilities = capabilities;
info.params.stop = params.stop ? params.stop.split(',').filter((s) => s.trim()) : null;
Object.keys(info.params).forEach((key) => {
if (info.params[key] === '' || info.params[key] === null) {
delete info.params[key];
}
});
const res = await updateModelById(localStorage.token, info.id, info);
if (res) {
await models.set(await getModels(localStorage.token));
toast.success('Model updated successfully');
await goto('/workspace/models');
}
loading = false;
success = false;
};
onMount(() => {
const _id = $page.url.searchParams.get('id');
if (_id) {
model = $models.find((m) => m.id === _id);
if (model) {
id = model.id;
name = model.name;
info = {
...info,
...JSON.parse(
JSON.stringify(
model?.info
? model?.info
: {
id: model.id,
name: model.name
}
)
)
};
if (model.preset && model.owned_by === 'ollama' && !info.base_model_id.includes(':')) {
info.base_model_id = `${info.base_model_id}:latest`;
}
params = { ...params, ...model?.info?.params };
params.stop = params?.stop ? (params?.stop ?? []).join(',') : null;
if (model?.owned_by === 'openai') {
capabilities.usage = false;
}
if (model?.info?.meta?.capabilities) {
capabilities = { ...capabilities, ...model?.info?.meta?.capabilities };
}
console.log(model);
} else {
goto('/workspace/models');
}
} else {
goto('/workspace/models');
}
});
</script>
<div class="w-full max-h-full">
<input
bind:this={filesInputElement}
bind:files={inputFiles}
type="file"
hidden
accept="image/*"
on:change={() => {
let reader = new FileReader();
reader.onload = (event) => {
let originalImageUrl = `${event.target.result}`;
const img = new Image();
img.src = originalImageUrl;
img.onload = function () {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// Calculate the aspect ratio of the image
const aspectRatio = img.width / img.height;
// Calculate the new width and height to fit within 100x100
let newWidth, newHeight;
if (aspectRatio > 1) {
newWidth = 100 * aspectRatio;
newHeight = 100;
} else {
newWidth = 100;
newHeight = 100 / aspectRatio;
}
// Set the canvas size
canvas.width = 100;
canvas.height = 100;
// Calculate the position to center the image
const offsetX = (100 - newWidth) / 2;
const offsetY = (100 - newHeight) / 2;
// Draw the image on the canvas
ctx.drawImage(img, offsetX, offsetY, newWidth, newHeight);
// Get the base64 representation of the compressed image
const compressedSrc = canvas.toDataURL('image/jpeg');
// Display the compressed image
info.meta.profile_image_url = compressedSrc;
inputFiles = null;
};
};
if (
inputFiles &&
inputFiles.length > 0 &&
['image/gif', 'image/webp', 'image/jpeg', 'image/png'].includes(inputFiles[0]['type'])
) {
reader.readAsDataURL(inputFiles[0]);
} else {
console.log(`Unsupported File Type '${inputFiles[0]['type']}'.`);
inputFiles = null;
}
}}
/>
<button
class="flex space-x-1"
on:click={() => {
history.back();
}}
>
<div class=" self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M17 10a.75.75 0 01-.75.75H5.612l4.158 3.96a.75.75 0 11-1.04 1.08l-5.5-5.25a.75.75 0 010-1.08l5.5-5.25a.75.75 0 111.04 1.08L5.612 9.25H16.25A.75.75 0 0117 10z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center font-medium text-sm">{$i18n.t('Back')}</div>
</button>
{#if model}
<form
class="flex flex-col max-w-2xl mx-auto mt-4 mb-10"
on:submit|preventDefault={() => {
updateHandler();
}}
>
<div class="flex justify-center my-4">
<div class="self-center">
<button
class=" {info.meta.profile_image_url
? ''
: 'p-4'} rounded-full dark:bg-gray-700 border border-dashed border-gray-200 flex items-center"
type="button"
on:click={() => {
filesInputElement.click();
}}
>
{#if info.meta.profile_image_url}
<img
src={info.meta.profile_image_url}
alt="modelfile profile"
class=" rounded-full size-16 object-cover"
/>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-8"
>
<path
fill-rule="evenodd"
d="M12 3.75a.75.75 0 01.75.75v6.75h6.75a.75.75 0 010 1.5h-6.75v6.75a.75.75 0 01-1.5 0v-6.75H4.5a.75.75 0 010-1.5h6.75V4.5a.75.75 0 01.75-.75z"
clip-rule="evenodd"
/>
</svg>
{/if}
</button>
</div>
</div>
<div class="mt-2 my-1 flex space-x-2">
<div class="flex-1">
<div class=" text-sm font-semibold mb-1">{$i18n.t('Name')}*</div>
<div>
<input
class="px-3 py-1.5 text-sm w-full bg-transparent border dark:border-gray-600 outline-none rounded-lg"
placeholder={$i18n.t('Name your model')}
bind:value={name}
required
/>
</div>
</div>
<div class="flex-1">
<div class=" text-sm font-semibold mb-1">{$i18n.t('Model ID')}*</div>
<div>
<input
class="px-3 py-1.5 text-sm w-full bg-transparent disabled:text-gray-500 border dark:border-gray-600 outline-none rounded-lg"
placeholder={$i18n.t('Add a model id')}
value={id}
disabled
required
/>
</div>
</div>
</div>
{#if model.preset}
<div class="my-1">
<div class=" text-sm font-semibold mb-1">{$i18n.t('Base Model (From)')}</div>
<div>
<select
class="px-3 py-1.5 text-sm w-full bg-transparent border dark:border-gray-600 outline-none rounded-lg"
placeholder="Select a base model (e.g. llama3, gpt-4o)"
bind:value={info.base_model_id}
required
>
<option value={null} class=" text-gray-900">{$i18n.t('Select a base model')}</option>
{#each $models.filter((m) => m.id !== model.id && !m?.preset) as model}
<option value={model.id} class=" text-gray-900">{model.name}</option>
{/each}
</select>
</div>
</div>
{/if}
<div class="my-1">
<div class="flex w-full justify-between items-center">
<div class=" self-center text-sm font-semibold">{$i18n.t('Description')}</div>
<button
class="p-1 text-xs flex rounded transition"
type="button"
on:click={() => {
if (info.meta.description === null) {
info.meta.description = '';
} else {
info.meta.description = null;
}
}}
>
{#if info.meta.description === null}
<span class="ml-2 self-center">{$i18n.t('Default')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Custom')}</span>
{/if}
</button>
</div>
{#if info.meta.description !== null}
<input
class="mt-1 px-3 py-1.5 text-sm w-full bg-transparent border dark:border-gray-600 outline-none rounded-lg"
placeholder={$i18n.t('Add a short description about what this model does')}
bind:value={info.meta.description}
/>
{/if}
</div>
<hr class=" dark:border-gray-850 my-1" />
<div class="my-2">
<div class="flex w-full justify-between">
<div class=" self-center text-sm font-semibold">{$i18n.t('Model Params')}</div>
</div>
<!-- <div class=" text-sm font-semibold mb-2"></div> -->
<div class="mt-2">
<div class="my-1">
<div class=" text-xs font-semibold mb-2">{$i18n.t('System Prompt')}</div>
<div>
<textarea
class="px-3 py-1.5 text-sm w-full bg-transparent border dark:border-gray-600 outline-none rounded-lg -mb-1"
placeholder={`Write your model system prompt content here\ne.g.) You are Mario from Super Mario Bros, acting as an assistant.`}
rows="4"
bind:value={info.params.system}
/>
</div>
</div>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-semibold">
{$i18n.t('Advanced Params')}
</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
type="button"
on:click={() => {
showAdvanced = !showAdvanced;
}}
>
{#if showAdvanced}
<span class="ml-2 self-center">{$i18n.t('Hide')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Show')}</span>
{/if}
</button>
</div>
{#if showAdvanced}
<div class="my-2">
<AdvancedParams
bind:params
on:change={(e) => {
info.params = { ...info.params, ...params };
}}
/>
</div>
{/if}
</div>
</div>
<hr class=" dark:border-gray-850 my-1" />
<div class="my-1">
<div class="flex w-full justify-between items-center">
<div class="flex w-full justify-between items-center">
<div class=" self-center text-sm font-semibold">{$i18n.t('Prompt suggestions')}</div>
<button
class="p-1 text-xs flex rounded transition"
type="button"
on:click={() => {
if (info.meta.suggestion_prompts === null) {
info.meta.suggestion_prompts = [{ content: '' }];
} else {
info.meta.suggestion_prompts = null;
}
}}
>
{#if info.meta.suggestion_prompts === null}
<span class="ml-2 self-center">{$i18n.t('Default')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Custom')}</span>
{/if}
</button>
</div>
{#if info.meta.suggestion_prompts !== null}
<button
class="p-1 px-2 text-xs flex rounded transition"
type="button"
on:click={() => {
if (
info.meta.suggestion_prompts.length === 0 ||
info.meta.suggestion_prompts.at(-1).content !== ''
) {
info.meta.suggestion_prompts = [...info.meta.suggestion_prompts, { content: '' }];
}
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z"
/>
</svg>
</button>
{/if}
</div>
{#if info.meta.suggestion_prompts}
<div class="flex flex-col space-y-1 mt-2">
{#if info.meta.suggestion_prompts.length > 0}
{#each info.meta.suggestion_prompts as prompt, promptIdx}
<div class=" flex border dark:border-gray-600 rounded-lg">
<input
class="px-3 py-1.5 text-sm w-full bg-transparent outline-none border-r dark:border-gray-600"
placeholder={$i18n.t('Write a prompt suggestion (e.g. Who are you?)')}
bind:value={prompt.content}
/>
<button
class="px-2"
type="button"
on:click={() => {
info.meta.suggestion_prompts.splice(promptIdx, 1);
info.meta.suggestion_prompts = info.meta.suggestion_prompts;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</div>
{/each}
{:else}
<div class="text-xs text-center">No suggestion prompts</div>
{/if}
</div>
{/if}
</div>
<div class="my-1">
<div class="flex w-full justify-between mb-1">
<div class=" self-center text-sm font-semibold">{$i18n.t('Capabilities')}</div>
</div>
<div class="flex flex-col">
{#each Object.keys(capabilities) as capability}
<div class=" flex items-center gap-2">
<Checkbox
state={capabilities[capability] ? 'checked' : 'unchecked'}
on:change={(e) => {
capabilities[capability] = e.detail === 'checked';
}}
/>
<div class=" py-0.5 text-sm w-full capitalize">
{$i18n.t(capability)}
</div>
</div>
{/each}
</div>
</div>
<div class="my-1">
<div class="flex w-full justify-between items-center">
<div class=" self-center text-sm font-semibold">{$i18n.t('Tags')}</div>
</div>
<div class="mt-2">
<Tags
tags={info?.meta?.tags ?? []}
deleteTag={(tagName) => {
info.meta.tags = info.meta.tags.filter((tag) => tag.name !== tagName);
}}
addTag={(tagName) => {
console.log(tagName);
if (!(info?.meta?.tags ?? null)) {
info.meta.tags = [{ name: tagName }];
} else {
info.meta.tags = [...info.meta.tags, { name: tagName }];
}
}}
/>
</div>
</div>
<div class="my-2 text-gray-300 dark:text-gray-700">
<div class="flex w-full justify-between mb-2">
<div class=" self-center text-sm font-semibold">{$i18n.t('JSON Preview')}</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
type="button"
on:click={() => {
showPreview = !showPreview;
}}
>
{#if showPreview}
<span class="ml-2 self-center">{$i18n.t('Hide')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Show')}</span>
{/if}
</button>
</div>
{#if showPreview}
<div>
<textarea
class="px-3 py-1.5 text-sm w-full bg-transparent border dark:border-gray-600 outline-none rounded-lg"
rows="10"
value={JSON.stringify(info, null, 2)}
disabled
readonly
/>
</div>
{/if}
</div>
<div class="my-2 flex justify-end mb-20">
<button
class=" text-sm px-3 py-2 transition rounded-xl {loading
? ' cursor-not-allowed bg-gray-100 dark:bg-gray-800'
: ' bg-gray-50 hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-800'} flex"
type="submit"
disabled={loading}
>
<div class=" self-center font-medium">{$i18n.t('Save & Update')}</div>
{#if loading}
<div class="ml-1.5 self-center">
<svg
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
</div>
{/if}
</button>
</div>
</form>
{/if}
</div>
...@@ -57,13 +57,9 @@ ...@@ -57,13 +57,9 @@
onMount(async () => { onMount(async () => {
window.addEventListener('message', async (event) => { window.addEventListener('message', async (event) => {
if ( if (
![ !['https://openwebui.com', 'https://www.openwebui.com', 'http://localhost:5173'].includes(
'https://ollamahub.com', event.origin
'https://www.ollamahub.com', )
'https://openwebui.com',
'https://www.openwebui.com',
'http://localhost:5173'
].includes(event.origin)
) )
return; return;
const prompt = JSON.parse(event.data); const prompt = JSON.parse(event.data);
......
...@@ -13,7 +13,7 @@ ...@@ -13,7 +13,7 @@
import 'tippy.js/dist/tippy.css'; import 'tippy.js/dist/tippy.css';
import { WEBUI_BASE_URL } from '$lib/constants'; import { WEBUI_BASE_URL } from '$lib/constants';
import i18n, { initI18n } from '$lib/i18n'; import i18n, { initI18n, getLanguages } from '$lib/i18n';
setContext('i18n', i18n); setContext('i18n', i18n);
...@@ -43,7 +43,14 @@ ...@@ -43,7 +43,14 @@
} }
// Initialize i18n even if we didn't get a backend config, // Initialize i18n even if we didn't get a backend config,
// so `/error` can show something that's not `undefined`. // so `/error` can show something that's not `undefined`.
initI18n(backendConfig?.default_locale);
const languages = await getLanguages();
const browserLanguage = navigator.languages
? navigator.languages[0]
: navigator.language || navigator.userLanguage;
initI18n(languages.includes(browserLanguage) ? browserLanguage : backendConfig?.default_locale);
if (backendConfig) { if (backendConfig) {
// Save Backend Status to Store // Save Backend Status to Store
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment