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 @@
"(Beta)": "(测试版)",
"(e.g. `sh webui.sh --api`)": "(例如 `sh webui.sh --api`)",
"(latest)": "(最新版)",
"{{ models }}": "",
"{{ owner }}: You cannot delete a base model": "",
"{{modelName}} is thinking...": "{{modelName}} 正在思考...",
"{{user}}'s Chats": "{{user}} 的聊天记录",
"{{webUIName}} Backend Required": "需要 {{webUIName}} 后端",
"A task model is used when performing tasks such as generating titles for chats and web search queries": "",
"a user": "用户",
"About": "关于",
"Account": "账户",
"Accurate information": "准确信息",
"Add": "",
"Add a model": "添加模型",
"Add a model tag name": "添加模型标签名称",
"Add a short description about what this modelfile does": "为这个模型文件添加一段简短的描述",
"Add": "添加",
"Add a model id": "",
"Add a short description about what this model does": "",
"Add a short title for this prompt": "为这个提示词添加一个简短的标题",
"Add a tag": "添加标签",
"Add custom prompt": "添加自定义提示词",
"Add Docs": "添加文档",
"Add Files": "添加文件",
"Add Memory": "",
"Add Memory": "添加记忆",
"Add message": "添加消息",
"Add Model": "添加模型",
"Add Tags": "添加标签",
......@@ -29,6 +31,7 @@
"Admin Panel": "管理员面板",
"Admin Settings": "管理员设置",
"Advanced Parameters": "高级参数",
"Advanced Params": "",
"all": "所有",
"All Documents": "所有文档",
"All Users": "所有用户",
......@@ -43,9 +46,9 @@
"API Key": "API 密钥",
"API Key created.": "API 密钥已创建。",
"API keys": "API 密钥",
"API RPM": "API RPM",
"April": "四月",
"Archive": "存档",
"Archive All Chats": "",
"Archived Chats": "聊天记录存档",
"are allowed - Activate this command by typing": "允许 - 通过输入来激活这个命令",
"Are you sure?": "你确定吗?",
......@@ -60,16 +63,18 @@
"available!": "可用!",
"Back": "返回",
"Bad Response": "不良响应",
"Banners": "",
"Base Model (From)": "",
"before": "之前",
"Being lazy": "懒惰",
"Builder Mode": "构建模式",
"Brave Search API Key": "",
"Bypass SSL verification for Websites": "绕过网站的 SSL 验证",
"Cancel": "取消",
"Categories": "分类",
"Capabilities": "",
"Change Password": "更改密码",
"Chat": "聊天",
"Chat Bubble UI": "",
"Chat direction": "",
"Chat Bubble UI": "聊天气泡 UI",
"Chat direction": "聊天方向",
"Chat History": "聊天历史",
"Chat History is off for this browser.": "此浏览器已关闭聊天历史功能。",
"Chats": "聊天",
......@@ -83,18 +88,19 @@
"Citation": "引文",
"Click here for help.": "点击这里获取帮助。",
"Click here to": "单击此处",
"Click here to check other modelfiles.": "点击这里检查其他模型文件。",
"Click here to select": "点击这里选择",
"Click here to select a csv file.": "单击此处选择 csv 文件。",
"Click here to select documents.": "点击这里选择文档。",
"click here.": "点击这里。",
"Click on the user role button to change a user's role.": "点击用户角色按钮以更改用户的角色。",
"Clone": "",
"Close": "关闭",
"Collection": "收藏",
"ComfyUI": "ComfyUI",
"ComfyUI Base URL": "ComfyUI Base URL",
"ComfyUI Base URL is required.": "ComfyUI Base URL 是必需的。",
"Command": "命令",
"Concurrent Requests": "",
"Confirm Password": "确认密码",
"Connections": "连接",
"Content": "内容",
......@@ -108,7 +114,7 @@
"Copy Link": "复制链接",
"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 modelfile": "创建模型文件",
"Create a model": "",
"Create Account": "创建账户",
"Create new key": "创建新密钥",
"Create new secret key": "创建新安全密钥",
......@@ -117,32 +123,32 @@
"Current Model": "当前模型",
"Current Password": "当前密码",
"Custom": "自定义",
"Customize Ollama models for a specific purpose": "定制特定用途的 Ollama 模型",
"Customize models for a specific purpose": "",
"Dark": "暗色",
"Dashboard": "仪表盘",
"Database": "数据库",
"December": "十二月",
"Default": "默认",
"Default (Automatic1111)": "默认(Automatic1111)",
"Default (SentenceTransformers)": "默认(SentenceTransformers)",
"Default (Web API)": "默认(Web API)",
"Default Model": "",
"Default model updated": "默认模型已更新",
"Default Prompt Suggestions": "默认提示词建议",
"Default User Role": "默认用户角色",
"delete": "删除",
"Delete": "删除",
"Delete a model": "删除一个模型",
"Delete All Chats": "",
"Delete chat": "删除聊天",
"Delete Chat": "删除聊天",
"Delete Chats": "删除聊天记录",
"delete this link": "删除这个链接",
"Delete User": "删除用户",
"Deleted {{deleteModelTag}}": "已删除{{deleteModelTag}}",
"Deleted {{tagName}}": "已删除 {{tagName}}",
"Deleted {{name}}": "",
"Description": "描述",
"Didn't fully follow instructions": "没有完全遵循指示",
"Disabled": "禁用",
"Discover a modelfile": "探索模型文件",
"Discover a model": "",
"Discover a prompt": "探索提示词",
"Discover, download, and explore custom prompts": "发现、下载并探索自定义提示词",
"Discover, download, and explore model presets": "发现、下载并探索模型预设",
......@@ -167,23 +173,27 @@
"Embedding Model Engine": "嵌入模型引擎",
"Embedding model set to \"{{embedding_model}}\"": "嵌入模型设置为 \"{{embedding_model}}\"",
"Enable Chat History": "启用聊天历史",
"Enable Community Sharing": "",
"Enable New Sign Ups": "启用新注册",
"Enable Web Search": "",
"Enabled": "启用",
"Ensure your CSV file includes 4 columns in this order: Name, Email, Password, Role.": "确保您的 CSV 文件按以下顺序包含 4 列: 姓名、电子邮件、密码、角色。",
"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 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 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 Number of Steps (e.g. 50)": "输入步数 (例如 50)",
"Enter Score": "输入分",
"Enter Searxng Query URL": "",
"Enter Serper API Key": "",
"Enter Serpstack API Key": "",
"Enter stop sequence": "输入停止序列",
"Enter Top K": "输入 Top K",
"Enter URL (e.g. http://127.0.0.1:7860/)": "输入 URL (例如 http://127.0.0.1:7860/)",
......@@ -192,11 +202,12 @@
"Enter Your Full Name": "输入您的全名",
"Enter Your Password": "输入您的密码",
"Enter Your Role": "输入您的角色",
"Error": "",
"Experimental": "实验性",
"Export All Chats (All Users)": "导出所有聊天(所有用户)",
"Export Chats": "导出聊天",
"Export Documents Mapping": "导出文档映射",
"Export Modelfiles": "导出模型文件",
"Export Models": "",
"Export Prompts": "导出提示词",
"Failed to create API Key.": "无法创建 API 密钥。",
"Failed to read clipboard contents": "无法读取剪贴板内容",
......@@ -209,18 +220,20 @@
"Focus chat input": "聚焦聊天输入",
"Followed instructions perfectly": "完全遵循说明",
"Format your variables using square brackets like this:": "使用这样的方括号格式化你的变量:",
"From (Base Model)": "来自(基础模型)",
"Frequency Penalty": "",
"Full Screen Mode": "全屏模式",
"General": "通用",
"General Settings": "通用设置",
"Generating search query": "",
"Generation Info": "生成信息",
"Good Response": "反应良好",
"Google PSE API Key": "",
"Google PSE Engine Id": "",
"h:mm a": "h:mm a",
"has no conversations.": "没有对话。",
"Hello, {{name}}": "你好,{{name}}",
"Help": "帮助",
"Hide": "隐藏",
"Hide Additional Params": "隐藏额外参数",
"How can I help you today?": "我今天能帮你做什么?",
"Hybrid Search": "混合搜索",
"Image Generation (Experimental)": "图像生成(实验性)",
......@@ -229,15 +242,18 @@
"Images": "图像",
"Import Chats": "导入聊天",
"Import Documents Mapping": "导入文档映射",
"Import Modelfiles": "导入模型文件",
"Import Models": "",
"Import Prompts": "导入提示",
"Include `--api` flag when running stable-diffusion-webui": "运行 stable-diffusion-webui 时包含 `--api` 标志",
"Info": "",
"Input commands": "输入命令",
"Install from Github URL": "",
"Interface": "界面",
"Invalid Tag": "无效标签",
"January": "一月",
"join our Discord for help.": "加入我们的 Discord 寻求帮助。",
"JSON": "JSON",
"JSON Preview": "",
"July": "七月",
"June": "六月",
"JWT Expiration": "JWT 过期",
......@@ -249,19 +265,19 @@
"Light": "浅色",
"Listening...": "监听中...",
"LLMs can make mistakes. Verify important information.": "LLM 可能会生成错误信息,请验证重要信息。",
"LTR": "",
"LTR": "LTR",
"Made by OpenWebUI Community": "由 OpenWebUI 社区制作",
"Make sure to enclose them with": "确保将它们包含在内",
"Manage LiteLLM Models": "管理 LiteLLM 模型",
"Manage Models": "管理模型",
"Manage Ollama Models": "管理 Ollama 模型",
"Manage Pipelines": "",
"March": "三月",
"Max Tokens": "最大令牌数",
"Max Tokens (num_predict)": "",
"Maximum of 3 models can be downloaded simultaneously. Please try again later.": "最多可以同时下载 3 个模型,请稍后重试。",
"May": "五月",
"Memories accessible by LLMs will be shown here.": "",
"Memory": "",
"Messages you send after creating your link won't be shared. Users with the URL will be able to view the shared chat.": "",
"Memories accessible by LLMs will be shown here.": "LLMs 可以访问的记忆将显示在这里。",
"Memory": "记忆",
"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": "最低分",
"Mirostat": "Mirostat",
"Mirostat Eta": "Mirostat Eta",
......@@ -271,29 +287,27 @@
"Model '{{modelName}}' has been successfully downloaded.": "模型'{{modelName}}'已成功下载。",
"Model '{{modelTag}}' is already in queue for downloading.": "模型'{{modelTag}}'已在下载队列中。",
"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 Name": "模型名称",
"Model ID": "",
"Model not selected": "未选择模型",
"Model Tag Name": "模型标签名称",
"Model Params": "",
"Model Whitelisting": "白名单模型",
"Model(s) Whitelisted": "模型已加入白名单",
"Modelfile": "模型文件",
"Modelfile Advanced Settings": "模型文件高级设置",
"Modelfile Content": "模型文件内容",
"Modelfiles": "模型文件",
"Models": "模型",
"More": "更多",
"Name": "名称",
"Name Tag": "名称标签",
"Name your modelfile": "命名你的模型文件",
"Name your model": "",
"New Chat": "新聊天",
"New Password": "新密码",
"No results found": "未找到结果",
"No search query generated": "",
"No source available": "没有可用来源",
"None": "",
"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.": "注意:如果设置了最低分数,搜索只会返回分数大于或等于最低分数的文档。",
"Notifications": "桌面通知",
"November": "十一月",
......@@ -302,7 +316,7 @@
"Okay, Let's Go!": "好的,我们开始吧!",
"OLED Dark": "暗黑色",
"Ollama": "Ollama",
"Ollama Base URL": "Ollama 基础 URL",
"Ollama API": "",
"Ollama Version": "Ollama 版本",
"On": "开",
"Only": "仅",
......@@ -321,15 +335,15 @@
"OpenAI URL/Key required.": "需要 OpenAI URL/Key",
"or": "或",
"Other": "其他",
"Overview": "概述",
"Parameters": "参数",
"Password": "密码",
"PDF document (.pdf)": "PDF 文档 (.pdf)",
"PDF Extract Images (OCR)": "PDF 图像处理 (使用 OCR)",
"pending": "待定",
"Permission denied when accessing microphone: {{error}}": "访问麦克风时权限被拒绝:{{error}}",
"Personalization": "",
"Plain text (.txt)": "PDF 文档 (.pdf)",
"Personalization": "个性化",
"Pipelines": "",
"Pipelines Valves": "",
"Plain text (.txt)": "TXT 文档 (.txt)",
"Playground": "AI 对话游乐场",
"Positive attitude": "积极态度",
"Previous 30 days": "过去 30 天",
......@@ -342,10 +356,8 @@
"Prompts": "提示词",
"Pull \"{{searchValue}}\" from Ollama.com": "从 Ollama.com 拉取 \"{{searchValue}}\"",
"Pull a model from Ollama.com": "从 Ollama.com 拉取一个模型",
"Pull Progress": "拉取进度",
"Query Params": "查询参数",
"RAG Template": "RAG 模板",
"Raw Format": "原始格式",
"Read Aloud": "朗读",
"Record voice": "录音",
"Redirecting you to OpenWebUI Community": "正在将您重定向到 OpenWebUI 社区",
......@@ -356,7 +368,6 @@
"Remove Model": "移除模型",
"Rename": "重命名",
"Repeat Last N": "重复最后 N 次",
"Repeat Penalty": "重复惩罚",
"Request Mode": "请求模式",
"Reranking Model": "重排模型",
"Reranking model disabled": "重排模型已禁用",
......@@ -366,7 +377,7 @@
"Role": "角色",
"Rosé Pine": "Rosé Pine",
"Rosé Pine Dawn": "Rosé Pine Dawn",
"RTL": "",
"RTL": "RTL",
"Save": "保存",
"Save & Create": "保存并创建",
"Save & Update": "保存并更新",
......@@ -376,19 +387,31 @@
"Scan for documents from {{path}}": "从 {{path}} 扫描文档",
"Search": "搜索",
"Search a model": "搜索模型",
"Search Chats": "",
"Search Documents": "搜索文档",
"Search Models": "",
"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 what's new": "查看最新内容",
"Seed": "种子",
"Select a base model": "",
"Select a mode": "选择一个模式",
"Select a model": "选择一个模型",
"Select a pipeline": "",
"Select a pipeline url": "",
"Select an Ollama instance": "选择一个 Ollama 实例",
"Select model": "选择模型",
"Selected model(s) do not support image inputs": "",
"Send": "发送",
"Send a Message": "发送消息",
"Send message": "发送消息",
"September": "九月",
"Serper API Key": "",
"Serpstack API Key": "",
"Server connection verified": "已验证服务器连接",
"Set as default": "设为默认",
"Set Default Model": "设置默认模型",
......@@ -397,7 +420,7 @@
"Set Model": "设置模型",
"Set reranking model (e.g. {{model}})": "设置重排模型(例如 {{model}})",
"Set Steps": "设置步骤",
"Set Title Auto-Generation Model": "设置标题自动生成模型",
"Set Task Model": "",
"Set Voice": "设置声音",
"Settings": "设置",
"Settings saved successfully!": "设置已保存",
......@@ -406,7 +429,6 @@
"Share to OpenWebUI Community": "分享到 OpenWebUI 社区",
"short-summary": "简短总结",
"Show": "显示",
"Show Additional Params": "显示额外参数",
"Show shortcuts": "显示快捷方式",
"Showcased creativity": "展示创意",
"sidebar": "侧边栏",
......@@ -425,7 +447,6 @@
"Success": "成功",
"Successfully updated.": "成功更新。",
"Suggested": "建议",
"Sync All": "同步所有",
"System": "系统",
"System Prompt": "系统提示",
"Tags": "标签",
......@@ -458,13 +479,14 @@
"Top P": "Top P",
"Trouble accessing Ollama?": "访问 Ollama 时遇到问题?",
"TTS Settings": "文本转语音设置",
"Type": "",
"Type Hugging Face Resolve (Download) URL": "输入 Hugging Face 解析(下载)URL",
"Uh-oh! There was an issue connecting to {{provider}}.": "哎呀!连接到{{provider}}时出现问题。",
"Unknown File Type '{{file_type}}', but accepting and treating as plain text": "未知文件类型'{{file_type}}',将视为纯文本进行处理",
"Update and Copy Link": "更新和复制链接",
"Update password": "更新密码",
"Upload a GGUF model": "上传一个 GGUF 模型",
"Upload files": "上传文件",
"Upload Files": "",
"Upload Progress": "上传进度",
"URL Mode": "URL 模式",
"Use '#' in the prompt input to load and select your documents.": "在提示输入中使用'#'来加载和选择你的文档。",
......@@ -478,10 +500,13 @@
"variable": "变量",
"variable to have them replaced with clipboard content.": "变量将被剪贴板内容替换。",
"Version": "版本",
"Warning": "",
"Warning: If you update or change your embedding model, you will need to re-import all documents.": "警告: 如果更新或更改 embedding 模型,则需要重新导入所有文档。",
"Web": "网页",
"Web Loader Settings": "Web 加载器设置",
"Web Params": "Web 参数",
"Web Search": "",
"Web Search Engine": "",
"Webhook URL": "Webhook URL",
"WebUI Add-ons": "WebUI 插件",
"WebUI Settings": "WebUI 设置",
......@@ -493,7 +518,8 @@
"Write a prompt suggestion (e.g. Who are you?)": "写一个提示建议(例如:你是谁?)",
"Write a summary in 50 words that summarizes [topic or keyword].": "用 50 个字写一个总结 [主题或关键词]。",
"Yesterday": "昨天",
"You": "",
"You": "你",
"You cannot clone a base model": "",
"You have no archived conversations.": "你没有存档的对话。",
"You have shared this chat": "你分享了这次聊天",
"You're a helpful assistant.": "你是一个有帮助的助手。",
......
......@@ -3,34 +3,37 @@
"(Beta)": "(測試版)",
"(e.g. `sh webui.sh --api`)": "(例如 `sh webui.sh --api`)",
"(latest)": "(最新版)",
"{{ models }}": "",
"{{ owner }}: You cannot delete a base model": "",
"{{modelName}} is thinking...": "{{modelName}} 正在思考...",
"{{user}}'s Chats": "",
"{{user}}'s Chats": "{{user}} 的聊天",
"{{webUIName}} Backend Required": "需要 {{webUIName}} 後台",
"A task model is used when performing tasks such as generating titles for chats and web search queries": "",
"a user": "使用者",
"About": "關於",
"Account": "帳號",
"Accurate information": "",
"Add": "",
"Add a model": "新增模型",
"Add a model tag name": "新增模型標籤",
"Add a short description about what this modelfile does": "為這個 Modelfile 添加一段簡短的描述",
"Accurate information": "準確信息",
"Add": "新增",
"Add a model id": "",
"Add a short description about what this model does": "",
"Add a short title for this prompt": "為這個提示詞添加一個簡短的標題",
"Add a tag": "新增標籤",
"Add custom prompt": "新增自定義提示詞",
"Add Docs": "新增文件",
"Add Files": "新增檔案",
"Add Memory": "",
"Add Memory": "新增記憶",
"Add message": "新增訊息",
"Add Model": "",
"Add Model": "新增模型",
"Add Tags": "新增標籤",
"Add User": "",
"Add User": "新增用户",
"Adjusting these settings will apply changes universally to all users.": "調整這些設定將對所有使用者進行更改。",
"admin": "管理員",
"Admin Panel": "管理員控制台",
"Admin Settings": "管理設定",
"Advanced Parameters": "進階參數",
"Advanced Params": "",
"all": "所有",
"All Documents": "",
"All Documents": "所有文件",
"All Users": "所有使用者",
"Allow": "允許",
"Allow Chat Deletion": "允許刪除聊天紀錄",
......@@ -38,38 +41,40 @@
"Already have an account?": "已經有帳號了嗎?",
"an assistant": "助手",
"and": "和",
"and create a new shared link.": "",
"and create a new shared link.": "創建一個新的共享連結。",
"API Base URL": "API 基本 URL",
"API Key": "API 金鑰",
"API Key created.": "",
"API keys": "",
"API RPM": "API RPM",
"April": "",
"Archive": "",
"API Key": "API Key",
"API Key created.": "API Key",
"API keys": "API Keys",
"April": "4月",
"Archive": "存檔",
"Archive All Chats": "",
"Archived Chats": "聊天記錄存檔",
"are allowed - Activate this command by typing": "是允許的 - 透過輸入",
"Are you sure?": "你確定嗎?",
"Attach file": "附加檔案",
"Attention to detail": "",
"Attention to detail": "細節精確",
"Audio": "音訊",
"August": "",
"August": "8月",
"Auto-playback response": "自動播放回答",
"Auto-send input after 3 sec.": "3 秒後自動傳送輸入內容",
"AUTOMATIC1111 Base URL": "AUTOMATIC1111 基本 URL",
"AUTOMATIC1111 Base URL is required.": "需要 AUTOMATIC1111 基本 URL",
"available!": "可以使用!",
"Back": "返回",
"Bad Response": "",
"before": "",
"Being lazy": "",
"Builder Mode": "建構模式",
"Bypass SSL verification for Websites": "",
"Bad Response": "錯誤回應",
"Banners": "",
"Base Model (From)": "",
"before": "前",
"Being lazy": "懶人模式",
"Brave Search API Key": "",
"Bypass SSL verification for Websites": "跳過 SSL 驗證",
"Cancel": "取消",
"Categories": "分類",
"Capabilities": "",
"Change Password": "修改密碼",
"Chat": "聊天",
"Chat Bubble UI": "",
"Chat direction": "",
"Chat Bubble UI": "聊天氣泡介面",
"Chat direction": "聊天方向",
"Chat History": "聊天紀錄功能",
"Chat History is off for this browser.": "此瀏覽器已關閉聊天紀錄功能。",
"Chats": "聊天",
......@@ -82,67 +87,68 @@
"Chunk Size": "Chunk 大小",
"Citation": "引文",
"Click here for help.": "點擊這裡尋找幫助。",
"Click here to": "",
"Click here to check other modelfiles.": "點擊這裡檢查其他 Modelfiles。",
"Click here to": "點擊這裡",
"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.": "點擊這裡。",
"Click on the user role button to change a user's role.": "點擊使用者 Role 按鈕以更改使用者的 Role。",
"Clone": "",
"Close": "關閉",
"Collection": "收藏",
"ComfyUI": "",
"ComfyUI Base URL": "",
"ComfyUI Base URL is required.": "",
"ComfyUI": "ComfyUI",
"ComfyUI Base URL": "ComfyUI 基本 URL",
"ComfyUI Base URL is required.": "需要 ComfyUI 基本 URL",
"Command": "命令",
"Concurrent Requests": "",
"Confirm Password": "確認密碼",
"Connections": "連線",
"Content": "內容",
"Context Length": "上下文長度",
"Continue Response": "",
"Continue Response": "繼續回答",
"Conversation Mode": "對話模式",
"Copied shared chat URL to clipboard!": "",
"Copy": "",
"Copied shared chat URL to clipboard!": "已複製共享聊天連結到剪貼簿!",
"Copy": "複製",
"Copy last code block": "複製最後一個程式碼區塊",
"Copy last response": "複製最後一個回答",
"Copy Link": "",
"Copy Link": "複製連結",
"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 modelfile": "建立 Modelfile",
"Create a model": "",
"Create Account": "建立帳號",
"Create new key": "",
"Create new secret key": "",
"Create new key": "建立新密鑰",
"Create new secret key": "建立新密鑰",
"Created at": "建立於",
"Created At": "",
"Created At": "建立於",
"Current Model": "目前模型",
"Current Password": "目前密碼",
"Custom": "自訂",
"Customize Ollama models for a specific purpose": "定制特定用途的 Ollama 模型",
"Customize models for a specific purpose": "",
"Dark": "暗色",
"Dashboard": "",
"Database": "資料庫",
"December": "",
"December": "12月",
"Default": "預設",
"Default (Automatic1111)": "預設(Automatic1111)",
"Default (SentenceTransformers)": "",
"Default (SentenceTransformers)": "預設(SentenceTransformers)",
"Default (Web API)": "預設(Web API)",
"Default Model": "",
"Default model updated": "預設模型已更新",
"Default Prompt Suggestions": "預設提示詞建議",
"Default User Role": "預設用戶 Role",
"delete": "刪除",
"Delete": "",
"Delete": "刪除",
"Delete a model": "刪除一個模型",
"Delete All Chats": "",
"Delete chat": "刪除聊天紀錄",
"Delete Chat": "",
"Delete Chats": "刪除聊天紀錄",
"delete this link": "",
"Delete User": "",
"Delete Chat": "刪除聊天紀錄",
"delete this link": "刪除此連結",
"Delete User": "刪除用戶",
"Deleted {{deleteModelTag}}": "已刪除 {{deleteModelTag}}",
"Deleted {{tagName}}": "",
"Deleted {{name}}": "",
"Description": "描述",
"Didn't fully follow instructions": "",
"Didn't fully follow instructions": "無法完全遵循指示",
"Disabled": "已停用",
"Discover a modelfile": "發現新 Modelfile",
"Discover a model": "",
"Discover a prompt": "發現新提示詞",
"Discover, download, and explore custom prompts": "發現、下載並探索他人設置的提示詞",
"Discover, download, and explore model presets": "發現、下載並探索他人設置的模型",
......@@ -153,156 +159,164 @@
"does not make any external connections, and your data stays securely on your locally hosted server.": "不會與外部溝通,你的數據會安全地留在你的本機伺服器上。",
"Don't Allow": "不允許",
"Don't have an account?": "還沒有註冊帳號?",
"Don't like the style": "",
"Download": "",
"Download canceled": "",
"Don't like the style": "不喜歡這個樣式?",
"Download": "下載",
"Download canceled": "下載已取消",
"Download Database": "下載資料庫",
"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'。",
"Edit": "",
"Edit": "編輯",
"Edit Doc": "編輯文件",
"Edit User": "編輯使用者",
"Email": "電子郵件",
"Embedding Model": "",
"Embedding Model Engine": "",
"Embedding model set to \"{{embedding_model}}\"": "",
"Embedding Model": "嵌入模型",
"Embedding Model Engine": "嵌入模型引擎",
"Embedding model set to \"{{embedding_model}}\"": "嵌入模型已設定為 \"{{embedding_model}}\"",
"Enable Chat History": "啟用聊天歷史",
"Enable Community Sharing": "",
"Enable New Sign Ups": "允許註冊新帳號",
"Enable Web Search": "",
"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 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 Size": "輸入 Chunk 大小",
"Enter Github Raw URL": "",
"Enter Google PSE API Key": "",
"Enter Google PSE Engine Id": "",
"Enter Image Size (e.g. 512x512)": "輸入圖片大小(例如 512x512)",
"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 language codes": "輸入語言代碼",
"Enter model tag (e.g. {{modelTag}})": "輸入模型標籤(例如 {{modelTag}})",
"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 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://localhost:11434)": "",
"Enter URL (e.g. http://localhost:11434)": "輸入 URL(例如 http://localhost:11434)",
"Enter Your Email": "輸入你的電子郵件",
"Enter Your Full Name": "輸入你的全名",
"Enter Your Password": "輸入你的密碼",
"Enter Your Role": "",
"Enter Your Role": "輸入你的角色",
"Error": "",
"Experimental": "實驗功能",
"Export All Chats (All Users)": "匯出所有聊天紀錄(所有使用者)",
"Export Chats": "匯出聊天紀錄",
"Export Documents Mapping": "匯出文件映射",
"Export Modelfiles": "匯出 Modelfiles",
"Export Models": "",
"Export Prompts": "匯出提示詞",
"Failed to create API Key.": "",
"Failed to create API Key.": "無法創建 API 金鑰。",
"Failed to read clipboard contents": "無法讀取剪貼簿內容",
"February": "",
"Feel free to add specific details": "",
"February": "2月",
"Feel free to add specific details": "請自由添加詳細內容。",
"File Mode": "檔案模式",
"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": "流暢地傳輸大型外部響應區塊",
"Focus chat input": "聚焦聊天輸入框",
"Followed instructions perfectly": "",
"Followed instructions perfectly": "完全遵循指示",
"Format your variables using square brackets like this:": "像這樣使用方括號來格式化你的變數:",
"From (Base Model)": "來自(基礎模型)",
"Frequency Penalty": "",
"Full Screen Mode": "全螢幕模式",
"General": "常用",
"General Settings": "常用設定",
"Generation Info": "",
"Good Response": "",
"h:mm a": "",
"has no conversations.": "",
"Generating search query": "",
"Generation Info": "生成信息",
"Good Response": "優秀的回應",
"Google PSE API Key": "",
"Google PSE Engine Id": "",
"h:mm a": "h:mm a",
"has no conversations.": "沒有對話",
"Hello, {{name}}": "你好,{{name}}",
"Help": "",
"Help": "幫助",
"Hide": "隱藏",
"Hide Additional Params": "隱藏額外參數",
"How can I help you today?": "今天能為你做什麼?",
"Hybrid Search": "",
"Hybrid Search": "混合搜索",
"Image Generation (Experimental)": "圖像生成(實驗功能)",
"Image Generation Engine": "圖像生成引擎",
"Image Settings": "圖片設定",
"Images": "圖片",
"Import Chats": "匯入聊天紀錄",
"Import Documents Mapping": "匯入文件映射",
"Import Modelfiles": "匯入 Modelfiles",
"Import Models": "",
"Import Prompts": "匯入提示詞",
"Include `--api` flag when running stable-diffusion-webui": "在運行 stable-diffusion-webui 時加上 `--api` 標誌",
"Info": "",
"Input commands": "輸入命令",
"Install from Github URL": "",
"Interface": "介面",
"Invalid Tag": "",
"January": "",
"Invalid Tag": "無效標籤",
"January": "1月",
"join our Discord for help.": "加入我們的 Discord 尋找幫助。",
"JSON": "JSON",
"July": "",
"June": "",
"JSON Preview": "",
"July": "7月",
"June": "6月",
"JWT Expiration": "JWT 過期時間",
"JWT Token": "JWT Token",
"Keep Alive": "保持活躍",
"Keyboard shortcuts": "鍵盤快速鍵",
"Language": "語言",
"Last Active": "",
"Last Active": "最後活動",
"Light": "亮色",
"Listening...": "正在聽取...",
"LLMs can make mistakes. Verify important information.": "LLM 可能會產生錯誤。請驗證重要資訊。",
"LTR": "",
"LTR": "LTR",
"Made by OpenWebUI Community": "由 OpenWebUI 社區製作",
"Make sure to enclose them with": "請確保變數有被以下符號框住:",
"Manage LiteLLM Models": "管理 LiteLLM 模型",
"Manage Models": "管理模組",
"Manage Ollama Models": "管理 Ollama 模型",
"March": "",
"Max Tokens": "最大 Token 數",
"Manage Pipelines": "",
"March": "3月",
"Max Tokens (num_predict)": "",
"Maximum of 3 models can be downloaded simultaneously. Please try again later.": "最多可以同時下載 3 個模型。請稍後再試。",
"May": "",
"Memories accessible by LLMs will be shown here.": "",
"Memory": "",
"Messages you send after creating your link won't be shared. Users with the URL will be able to view the shared chat.": "",
"Minimum Score": "",
"May": "5月",
"Memories accessible by LLMs will be shown here.": "LLM 記憶將會顯示在此處。",
"Memory": "記憶",
"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": "最小分數",
"Mirostat": "Mirostat",
"Mirostat Eta": "Mirostat Eta",
"Mirostat Tau": "Mirostat Tau",
"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 '{{modelTag}}' is already in queue for downloading.": "'{{modelTag}}' 模型已經在下載佇列中。",
"Model {{modelId}} not found": "找不到 {{modelId}} 模型",
"Model {{modelName}} already exists.": "模型 {{modelName}} 已存在。",
"Model filesystem path detected. Model shortname is required for update, cannot continue.": "",
"Model Name": "模型名稱",
"Model {{modelName}} is not vision capable": "",
"Model {{name}} is now {{status}}": "",
"Model filesystem path detected. Model shortname is required for update, cannot continue.": "模型文件系統路徑已檢測。需要更新模型短名,無法繼續。",
"Model ID": "",
"Model not selected": "未選擇模型",
"Model Tag Name": "模型標籤",
"Model Params": "",
"Model Whitelisting": "白名單模型",
"Model(s) Whitelisted": "模型已加入白名單",
"Modelfile": "Modelfile",
"Modelfile Advanced Settings": "Modelfile 進階設定",
"Modelfile Content": "Modelfile 內容",
"Modelfiles": "Modelfiles",
"Models": "模型",
"More": "",
"More": "更多",
"Name": "名稱",
"Name Tag": "名稱標籤",
"Name your modelfile": "命名你的 Modelfile",
"Name your model": "",
"New Chat": "新增聊天",
"New Password": "新密碼",
"No results found": "",
"No results found": "沒有找到結果",
"No search query generated": "",
"No source available": "沒有可用的來源",
"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.": "",
"None": "",
"Not factually correct": "與真實資訊不相符",
"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": "桌面通知",
"November": "",
"October": "",
"November": "11月",
"October": "10 月",
"Off": "關閉",
"Okay, Let's Go!": "好的,啟動吧!",
"OLED Dark": "",
"Ollama": "",
"Ollama Base URL": "Ollama 基本 URL",
"OLED Dark": "`",
"Ollama": "Ollama",
"Ollama API": "",
"Ollama Version": "Ollama 版本",
"On": "開啟",
"Only": "僅有",
......@@ -314,59 +328,56 @@
"Open AI": "Open AI",
"Open AI (Dall-E)": "Open AI (Dall-E)",
"Open new chat": "開啟新聊天",
"OpenAI": "",
"OpenAI": "OpenAI",
"OpenAI API": "OpenAI API",
"OpenAI API Config": "",
"OpenAI API Config": "OpenAI API 設定",
"OpenAI API Key is required.": "需要 OpenAI API 金鑰。",
"OpenAI URL/Key required.": "",
"OpenAI URL/Key required.": "需要 OpenAI URL/金鑰。",
"or": "或",
"Other": "",
"Overview": "",
"Parameters": "參數",
"Other": "其他",
"Password": "密碼",
"PDF document (.pdf)": "",
"PDF document (.pdf)": "PDF 文件 (.pdf)",
"PDF Extract Images (OCR)": "PDF 圖像擷取(OCR 光學文字辨識)",
"pending": "待審查",
"Permission denied when accessing microphone: {{error}}": "存取麥克風時被拒絕權限:{{error}}",
"Personalization": "",
"Plain text (.txt)": "",
"Personalization": "個人化",
"Pipelines": "",
"Pipelines Valves": "",
"Plain text (.txt)": "純文字 (.txt)",
"Playground": "AI 對話遊樂場",
"Positive attitude": "",
"Previous 30 days": "",
"Previous 7 days": "",
"Profile Image": "",
"Prompt": "",
"Prompt (e.g. Tell me a fun fact about the Roman Empire)": "",
"Positive attitude": "積極態度",
"Previous 30 days": "前 30 天",
"Previous 7 days": "前 7 天",
"Profile Image": "個人圖像",
"Prompt": "提示詞",
"Prompt (e.g. Tell me a fun fact about the Roman Empire)": "提示詞(例如:告訴我關於羅馬帝國的趣味事)",
"Prompt Content": "提示詞內容",
"Prompt suggestions": "提示詞建議",
"Prompts": "提示詞",
"Pull \"{{searchValue}}\" from Ollama.com": "",
"Pull \"{{searchValue}}\" from Ollama.com": "從 Ollama.com 下載 \"{{searchValue}}\"",
"Pull a model from Ollama.com": "從 Ollama.com 下載模型",
"Pull Progress": "下載進度",
"Query Params": "查詢參數",
"RAG Template": "RAG 範例",
"Raw Format": "原始格式",
"Read Aloud": "",
"Read Aloud": "讀出",
"Record voice": "錄音",
"Redirecting you to OpenWebUI Community": "將你重新導向到 OpenWebUI 社群",
"Refused when it shouldn't have": "",
"Regenerate": "",
"Refused when it shouldn't have": "拒絕時不該拒絕",
"Regenerate": "重新生成",
"Release Notes": "發布說明",
"Remove": "",
"Remove Model": "",
"Rename": "",
"Remove": "移除",
"Remove Model": "移除模型",
"Rename": "重命名",
"Repeat Last N": "重複最後 N 次",
"Repeat Penalty": "重複懲罰",
"Request Mode": "請求模式",
"Reranking Model": "",
"Reranking model disabled": "",
"Reranking model set to \"{{reranking_model}}\"": "",
"Reranking Model": "重新排序模型",
"Reranking model disabled": "重新排序模型已禁用",
"Reranking model set to \"{{reranking_model}}\"": "重新排序模型設定為 \"{{reranking_model}}\"",
"Reset Vector Storage": "重置向量儲存空間",
"Response AutoCopy to Clipboard": "自動複製回答到剪貼簿",
"Role": "Role",
"Rosé Pine": "玫瑰松",
"Rosé Pine Dawn": "黎明玫瑰松",
"RTL": "",
"RTL": "RTL",
"Save": "儲存",
"Save & Create": "儲存並建立",
"Save & Update": "儲存並更新",
......@@ -375,29 +386,41 @@
"Scan complete!": "掃描完成!",
"Scan for documents from {{path}}": "從 {{path}} 掃描文件",
"Search": "搜尋",
"Search a model": "",
"Search a model": "搜尋模型",
"Search Chats": "",
"Search Documents": "搜尋文件",
"Search Models": "",
"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 what's new": "查看最新內容",
"Seed": "種子",
"Select a base model": "",
"Select a mode": "選擇模式",
"Select a model": "選擇一個模型",
"Select a pipeline": "",
"Select a pipeline url": "",
"Select an Ollama instance": "選擇 Ollama 實例",
"Select model": "選擇模型",
"Send": "",
"Selected model(s) do not support image inputs": "",
"Send": "傳送",
"Send a Message": "傳送訊息",
"Send message": "傳送訊息",
"September": "九月",
"Serper API Key": "",
"Serpstack API Key": "",
"Server connection verified": "已驗證伺服器連線",
"Set as default": "設為預設",
"Set Default Model": "設定預設模型",
"Set embedding model (e.g. {{model}})": "",
"Set embedding model (e.g. {{model}})": "設定嵌入模型(例如:{{model}})",
"Set Image Size": "設定圖片大小",
"Set Model": "設定模型",
"Set reranking model (e.g. {{model}})": "",
"Set reranking model (e.g. {{model}})": "設定重新排序模型(例如:{{model}})",
"Set Steps": "設定步數",
"Set Title Auto-Generation Model": "設定自動生成標題用模型",
"Set Task Model": "",
"Set Voice": "設定語音",
"Settings": "設定",
"Settings saved successfully!": "成功儲存設定",
......@@ -406,9 +429,8 @@
"Share to OpenWebUI Community": "分享到 OpenWebUI 社群",
"short-summary": "簡短摘要",
"Show": "顯示",
"Show Additional Params": "顯示額外參數",
"Show shortcuts": "顯示快速鍵",
"Showcased creativity": "",
"Showcased creativity": "展示創造性",
"sidebar": "側邊欄",
"Sign in": "登入",
"Sign Out": "登出",
......@@ -421,83 +443,87 @@
"Stop Sequence": "停止序列",
"STT Settings": "語音轉文字設定",
"Submit": "提交",
"Subtitle (e.g. about the Roman Empire)": "",
"Subtitle (e.g. about the Roman Empire)": "標題(例如:關於羅馬帝國)",
"Success": "成功",
"Successfully updated.": "更新成功。",
"Suggested": "建議",
"Sync All": "全部同步",
"System": "系統",
"System Prompt": "系統提示詞",
"Tags": "標籤",
"Tell us more:": "",
"Tell us more:": "告訴我們更多:",
"Temperature": "溫度",
"Template": "模板",
"Text Completion": "文本補全(Text Completion)",
"Text-to-Speech Engine": "文字轉語音引擎",
"Tfs Z": "Tfs Z",
"Thanks for your feedback!": "",
"The score should be a value between 0.0 (0%) and 1.0 (100%).": "",
"Thanks for your feedback!": "感謝你的回饋!",
"The score should be a value between 0.0 (0%) and 1.0 (100%).": "分數應該介於 0.0(0%)和 1.0(100%)之間。",
"Theme": "主題",
"This ensures that your valuable conversations are securely saved to your backend database. Thank you!": "這確保你寶貴的對話安全地儲存到你的後台資料庫。謝謝!",
"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 鍵連續更新多個變數。",
"Title": "標題",
"Title (e.g. Tell me a fun fact)": "",
"Title (e.g. Tell me a fun fact)": "標題(例如:告訴我一個有趣的事)",
"Title Auto-Generation": "自動生成標題",
"Title cannot be an empty string.": "",
"Title cannot be an empty string.": "標題不能為空字串",
"Title Generation Prompt": "自動生成標題的提示詞",
"to": "到",
"To access the available model names for downloading,": "若想查看可供下載的模型名稱,",
"To access the GGUF models available for downloading,": "若想查看可供下載的 GGUF 模型名稱,",
"to chat input.": "到聊天輸入框來啟動此命令。",
"Today": "",
"Today": "今天",
"Toggle settings": "切換設定",
"Toggle sidebar": "切換側邊欄",
"Top K": "Top K",
"Top P": "Top P",
"Trouble accessing Ollama?": "存取 Ollama 時遇到問題?",
"TTS Settings": "文字轉語音設定",
"Type": "",
"Type Hugging Face Resolve (Download) URL": "輸入 Hugging Face 解析後的(下載)URL",
"Uh-oh! There was an issue connecting to {{provider}}.": "哎呀!連線到 {{provider}} 時出現問題。",
"Unknown File Type '{{file_type}}', but accepting and treating as plain text": "未知的文件類型 '{{file_type}}',但接受並視為純文字",
"Update and Copy Link": "",
"Update and Copy Link": "更新並複製連結",
"Update password": "更新密碼",
"Upload a GGUF model": "上傳一個 GGUF 模型",
"Upload files": "上傳文件",
"Upload Files": "",
"Upload Progress": "上傳進度",
"URL Mode": "URL 模式",
"Use '#' in the prompt input to load and select your documents.": "在輸入框中輸入 '#' 以載入並選擇你的文件。",
"Use Gravatar": "使用 Gravatar",
"Use Initials": "",
"Use Initials": "使用初始头像",
"user": "使用者",
"User Permissions": "使用者權限",
"Users": "使用者",
"Utilize": "使用",
"Valid time units:": "有效時間單位:",
"variable": "變數",
"variable to have them replaced with clipboard content.": "變數將替換為剪貼簿內容",
"variable to have them replaced with clipboard content.": "變數將替換為剪貼簿內容",
"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 Loader Settings": "",
"Web Params": "",
"Webhook URL": "",
"Web Loader Settings": "Web 載入器設定",
"Web Params": "Web 參數",
"Web Search": "",
"Web Search Engine": "",
"Webhook URL": "Webhook URL",
"WebUI Add-ons": "WebUI 擴充套件",
"WebUI Settings": "WebUI 設定",
"WebUI will make requests to": "WebUI 將會存取",
"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(本機)",
"Workspace": "",
"Workspace": "工作區",
"Write a prompt suggestion (e.g. Who are you?)": "寫一個提示詞建議(例如:你是誰?)",
"Write a summary in 50 words that summarizes [topic or keyword].": "寫一個 50 字的摘要來概括 [主題或關鍵詞]。",
"Yesterday": "",
"You": "",
"You have no archived conversations.": "",
"You have shared this chat": "",
"Yesterday": "昨天",
"You": "你",
"You cannot clone a base model": "",
"You have no archived conversations.": "你沒有任何已封存的對話",
"You have shared this chat": "你已分享此聊天",
"You're a helpful assistant.": "你是一位善於協助他人的助手。",
"You're now logged in.": "已登入。",
"Youtube": "",
"Youtube Loader Settings": ""
"Youtube": "Youtube",
"Youtube Loader Settings": "Youtube 載入器設定"
}
import { APP_NAME } from '$lib/constants';
import { type Writable, writable } from 'svelte/store';
import type { GlobalModelConfig, ModelConfig } from '$lib/apis';
import type { Banner } from '$lib/types';
// Backend
export const WEBUI_NAME = writable(APP_NAME);
......@@ -35,6 +37,8 @@ export const documents = writable([
}
]);
export const banners: Writable<Banner[]> = writable([]);
export const settings: Writable<Settings> = writable({});
export const showSidebar = writable(false);
......@@ -42,27 +46,27 @@ export const showSettings = writable(false);
export const showArchivedChats = writable(false);
export const showChangelog = writable(false);
type Model = OpenAIModel | OllamaModel;
export type Model = OpenAIModel | OllamaModel;
type OpenAIModel = {
type BaseModel = {
id: string;
name: string;
external: boolean;
source?: string;
info?: ModelConfig;
};
type OllamaModel = {
id: string;
name: string;
export interface OpenAIModel extends BaseModel {
external: boolean;
source?: string;
}
// Ollama specific fields
export interface OllamaModel extends BaseModel {
details: OllamaModelDetails;
size: number;
description: string;
model: string;
modified_at: string;
digest: string;
};
}
type OllamaModelDetails = {
parent_model: string;
......@@ -125,14 +129,21 @@ type Prompt = {
};
type Config = {
status?: boolean;
name?: string;
version?: string;
default_locale?: string;
images?: boolean;
default_models?: string[];
default_prompt_suggestions?: PromptSuggestion[];
trusted_header_auth?: boolean;
status: boolean;
name: string;
version: string;
default_locale: string;
default_models: string[];
default_prompt_suggestions: PromptSuggestion[];
features: {
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 = {
......
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';
test('promptTemplate correctly replaces {{prompt}} placeholder', () => {
test('titleGenerationTemplate correctly replaces {{prompt}} placeholder', () => {
const template = 'Hello {{prompt}}!';
const prompt = 'world';
const expected = 'Hello world!';
const actual = promptTemplate(template, prompt);
const actual = titleGenerationTemplate(template, prompt);
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 prompt = 'world';
const expected = 'Hello wor!';
const actual = promptTemplate(template, prompt);
const actual = titleGenerationTemplate(template, prompt);
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 prompt = 'world';
const expected = 'Hello rld!';
const actual = promptTemplate(template, prompt);
const actual = titleGenerationTemplate(template, prompt);
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 prompt = 'world';
const expected = 'Hello wo...ld!';
const actual = promptTemplate(template, prompt);
const actual = titleGenerationTemplate(template, prompt);
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 prompt = 'world';
const expected = 'Hello world!';
const actual = promptTemplate(template, prompt);
const actual = titleGenerationTemplate(template, prompt);
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 prompt = 'world';
const expected = 'Hello world!';
const actual = promptTemplate(template, prompt);
const actual = titleGenerationTemplate(template, prompt);
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 prompt = 'World, {{prompt}} injection';
const expected = 'Hello World, {{prompt}} injection!';
const actual = promptTemplate(template, prompt);
const actual = titleGenerationTemplate(template, prompt);
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 prompt = 'world';
const expected = 'Hello world! This is wor!';
const actual = promptTemplate(template, prompt);
const actual = titleGenerationTemplate(template, prompt);
expect(actual).toBe(expected);
});
import { v4 as uuidv4 } from 'uuid';
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
......@@ -36,11 +12,12 @@ export const sanitizeResponseContent = (content: string) => {
.replace(/<$/, '')
.replaceAll(/<\|[a-z]+\|>/g, ' ')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.trim();
};
export const revertSanitizedResponseContent = (content: string) => {
return content.replaceAll('&lt;', '<');
return content.replaceAll('&lt;', '<').replaceAll('&gt;', '>');
};
export const capitalizeFirstLetter = (string) => {
......@@ -472,6 +449,42 @@ export const blobToFile = (blob, fileName) => {
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.
* The placeholders can be in the following formats:
......@@ -484,8 +497,8 @@ export const blobToFile = (blob, fileName) => {
* @param {string} prompt - The string to replace the placeholders with.
* @returns {string} The template string with the placeholders replaced by the prompt.
*/
export const promptTemplate = (template: string, prompt: string): string => {
return template.replace(
export const titleGenerationTemplate = (template: string, prompt: string): string => {
template = template.replace(
/{{prompt}}|{{prompt:start:(\d+)}}|{{prompt:end:(\d+)}}|{{prompt:middletruncate:(\d+)}}/g,
(match, startLength, endLength, middleLength) => {
if (match === '{{prompt}}') {
......@@ -505,6 +518,10 @@ export const promptTemplate = (template: string, prompt: string): string => {
return '';
}
);
template = promptTemplate(template);
return template;
};
export const approximateToHumanReadable = (nanoseconds: number) => {
......
......@@ -7,9 +7,8 @@
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 { getModelfiles } from '$lib/apis/modelfiles';
import { getPrompts } from '$lib/apis/prompts';
import { getDocs } from '$lib/apis/documents';
......@@ -20,10 +19,10 @@
showSettings,
settings,
models,
modelfiles,
prompts,
documents,
tags,
banners,
showChangelog,
config
} from '$lib/stores';
......@@ -35,6 +34,8 @@
import ShortcutsModal from '$lib/components/chat/ShortcutsModal.svelte';
import ChangelogModal from '$lib/components/ChangelogModal.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');
......@@ -50,21 +51,6 @@
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 () => {
if ($user === undefined) {
await goto('/auth');
......@@ -87,18 +73,31 @@
// IndexedDB Not Found
}
await models.set(await getModels());
await settings.set(JSON.parse(localStorage.getItem('settings') ?? '{}'));
const userSettings = await getUserSettings(localStorage.token);
await modelfiles.set(await getModelfiles(localStorage.token));
await prompts.set(await getPrompts(localStorage.token));
await documents.set(await getDocs(localStorage.token));
await tags.set(await getAllChatTags(localStorage.token));
if (userSettings) {
await settings.set(userSettings.ui);
} else {
await settings.set(JSON.parse(localStorage.getItem('settings') ?? '{}'));
}
modelfiles.subscribe(async () => {
// should fetch models
await models.set(await getModels());
});
await Promise.all([
(async () => {
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) {
const isCtrlPressed = event.ctrlKey || event.metaKey; // metaKey is for Cmd key on Mac
......@@ -176,12 +175,12 @@
});
</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">
<button
id="show-shortcuts-button"
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={() => {
showShortcuts = !showShortcuts;
}}
......
<script lang="ts">
import { toast } from 'svelte-sonner';
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));
};
import Chat from '$lib/components/chat/Chat.svelte';
</script>
<svelte:head>
<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}
/>
<Chat />
<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 @@
});
</script>
<svelte:head>
<title>{$i18n.t('Admin Panel')} | {$WEBUI_NAME}</title>
</svelte:head>
{#key selectedUser}
<EditUserModal
bind:show={showEditUserModal}
......@@ -106,265 +102,222 @@
<UserChatsModal bind:show={showUserChatsModal} user={selectedUser} />
<SettingsModal bind:show={showSettingsModal} />
<div class=" flex flex-col w-full min-h-screen">
{#if loaded}
<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">
{#if loaded}
<div class="mt-0.5 mb-3 gap-1 flex flex-col md:flex-row justify-between">
<div class="flex md:self-center text-lg font-medium px-0.5">
{$i18n.t('All Users')}
<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
id="sidebar-toggle-button"
class="cursor-pointer p-1 flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition"
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={() => {
showSidebar.set(!$showSidebar);
showAddUserModal = !showAddUserModal;
}}
>
<div class=" m-auto self-center">
<MenuLines />
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z"
/>
</svg>
</button>
</div>
<div class="flex items-center text-xl font-semibold">{$i18n.t('Dashboard')}</div>
</div>
</div>
</Tooltip>
<!-- <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"
>
<button
class="min-w-fit rounded-lg p-1.5 px-3 {tab === ''
? 'bg-gray-50 dark:bg-gray-850'
: ''} transition"
type="button"
on:click={() => {
tab = '';
}}>{$i18n.t('Overview')}</button
>
<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
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M6.955 1.45A.5.5 0 0 1 7.452 1h1.096a.5.5 0 0 1 .497.45l.17 1.699c.484.12.94.312 1.356.562l1.321-1.081a.5.5 0 0 1 .67.033l.774.775a.5.5 0 0 1 .034.67l-1.08 1.32c.25.417.44.873.561 1.357l1.699.17a.5.5 0 0 1 .45.497v1.096a.5.5 0 0 1-.45.497l-1.699.17c-.12.484-.312.94-.562 1.356l1.082 1.322a.5.5 0 0 1-.034.67l-.774.774a.5.5 0 0 1-.67.033l-1.322-1.08c-.416.25-.872.44-1.356.561l-.17 1.699a.5.5 0 0 1-.497.45H7.452a.5.5 0 0 1-.497-.45l-.17-1.699a4.973 4.973 0 0 1-1.356-.562L4.108 13.37a.5.5 0 0 1-.67-.033l-.774-.775a.5.5 0 0 1-.034-.67l1.08-1.32a4.971 4.971 0 0 1-.561-1.357l-1.699-.17A.5.5 0 0 1 1 8.548V7.452a.5.5 0 0 1 .45-.497l1.699-.17c.12-.484.312-.94.562-1.356L2.629 4.107a.5.5 0 0 1 .034-.67l.774-.774a.5.5 0 0 1 .67-.033L5.43 3.71a4.97 4.97 0 0 1 1.356-.561l.17-1.699ZM6 8c0 .538.212 1.026.558 1.385l.057.057a2 2 0 0 0 2.828-2.828l-.058-.056A2 2 0 0 0 6 8Z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
</div>
</div> -->
<hr class=" my-2 dark:border-gray-850" />
<div class="px-6">
<div class="mt-0.5 mb-3 gap-1 flex flex-col md:flex-row justify-between">
<div class="flex md:self-center text-lg font-medium px-0.5">
{$i18n.t('All Users')}
<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">
</div>
</div>
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full">
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400">
<tr>
<th scope="col" class="px-3 py-2"> {$i18n.t('Role')} </th>
<th scope="col" class="px-3 py-2"> {$i18n.t('Name')} </th>
<th scope="col" class="px-3 py-2"> {$i18n.t('Email')} </th>
<th scope="col" class="px-3 py-2"> {$i18n.t('Last Active')} </th>
<th scope="col" class="px-3 py-2"> {$i18n.t('Created at')} </th>
<th scope="col" class="px-3 py-2 text-right" />
</tr>
</thead>
<tbody>
{#each users
.filter((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
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={() => {
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
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z"
/>
</svg>
</button>
</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;
}}
<div
class="w-1 h-1 rounded-full {user.role === 'admin' &&
'bg-sky-600 dark:bg-sky-300'} {user.role === 'user' &&
'bg-green-600 dark:bg-green-300'} {user.role === 'pending' &&
'bg-gray-600 dark:bg-gray-300'}"
/>
{$i18n.t(user.role)}</button
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M6.955 1.45A.5.5 0 0 1 7.452 1h1.096a.5.5 0 0 1 .497.45l.17 1.699c.484.12.94.312 1.356.562l1.321-1.081a.5.5 0 0 1 .67.033l.774.775a.5.5 0 0 1 .034.67l-1.08 1.32c.25.417.44.873.561 1.357l1.699.17a.5.5 0 0 1 .45.497v1.096a.5.5 0 0 1-.45.497l-1.699.17c-.12.484-.312.94-.562 1.356l1.082 1.322a.5.5 0 0 1-.034.67l-.774.774a.5.5 0 0 1-.67.033l-1.322-1.08c-.416.25-.872.44-1.356.561l-.17 1.699a.5.5 0 0 1-.497.45H7.452a.5.5 0 0 1-.497-.45l-.17-1.699a4.973 4.973 0 0 1-1.356-.562L4.108 13.37a.5.5 0 0 1-.67-.033l-.774-.775a.5.5 0 0 1-.034-.67l1.08-1.32a4.971 4.971 0 0 1-.561-1.357l-1.699-.17A.5.5 0 0 1 1 8.548V7.452a.5.5 0 0 1 .45-.497l1.699-.17c.12-.484.312-.94.562-1.356L2.629 4.107a.5.5 0 0 1 .034-.67l.774-.774a.5.5 0 0 1 .67-.033L5.43 3.71a4.97 4.97 0 0 1 1.356-.561l.17-1.699ZM6 8c0 .538.212 1.026.558 1.385l.057.057a2 2 0 0 0 2.828-2.828l-.058-.056A2 2 0 0 0 6 8Z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
</div>
</div>
</div>
<div class="scrollbar-hidden relative overflow-x-auto whitespace-nowrap">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto">
<thead
class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400"
>
<tr>
<th scope="col" class="px-3 py-2"> {$i18n.t('Role')} </th>
<th scope="col" class="px-3 py-2"> {$i18n.t('Name')} </th>
<th scope="col" class="px-3 py-2"> {$i18n.t('Email')} </th>
<th scope="col" class="px-3 py-2"> {$i18n.t('Last Active')} </th>
<th scope="col" class="px-3 py-2"> {$i18n.t('Created at')} </th>
<th scope="col" class="px-3 py-2 text-right" />
</tr>
</thead>
<tbody>
{#each users
.filter((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
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={() => {
if (user.role === 'user') {
updateRoleHandler(user.id, 'admin');
} else if (user.role === 'pending') {
updateRoleHandler(user.id, 'user');
} else {
updateRoleHandler(user.id, 'pending');
}
}}
>
<div
class="w-1 h-1 rounded-full {user.role === 'admin' &&
'bg-sky-600 dark:bg-sky-300'} {user.role === 'user' &&
'bg-green-600 dark:bg-green-300'} {user.role === 'pending' &&
'bg-gray-600 dark:bg-gray-300'}"
/>
{$i18n.t(user.role)}</button
>
</td>
<td class="px-3 py-2 font-medium text-gray-900 dark:text-white w-max">
<div class="flex flex-row w-max">
<img
class=" rounded-full w-6 h-6 object-cover mr-2.5"
src={user.profile_image_url.startsWith(WEBUI_BASE_URL) ||
user.profile_image_url.startsWith('https://www.gravatar.com/avatar/') ||
user.profile_image_url.startsWith('data:')
? user.profile_image_url
: `/user.png`}
alt="user"
/>
<div class=" font-medium self-center">{user.name}</div>
</div>
</td>
<td class=" px-3 py-2"> {user.email} </td>
<td class=" px-3 py-2">
{dayjs(user.last_active_at * 1000).fromNow()}
</td>
<td class=" px-3 py-2">
{dayjs(user.created_at * 1000).format($i18n.t('MMMM DD, YYYY'))}
</td>
<td class="px-3 py-2 text-right">
<div class="flex justify-end w-full">
{#if user.role !== 'admin'}
<Tooltip content={$i18n.t('Chats')}>
<button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
on:click={async () => {
showUserChatsModal = !showUserChatsModal;
selectedUser = user;
}}
>
<ChatBubbles />
</button>
</Tooltip>
<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>
</td>
<td class="px-3 py-2 font-medium text-gray-900 dark:text-white w-max">
<div class="flex flex-row w-max">
<img
class=" rounded-full w-6 h-6 object-cover mr-2.5"
src={user.profile_image_url.startsWith(WEBUI_BASE_URL) ||
user.profile_image_url.startsWith('https://www.gravatar.com/avatar/') ||
user.profile_image_url.startsWith('data:')
? user.profile_image_url
: `/user.png`}
alt="user"
/>
<div class=" font-medium self-center">{user.name}</div>
</div>
</td>
<td class=" px-3 py-2"> {user.email} </td>
<td class=" px-3 py-2">
{dayjs(user.last_active_at * 1000).fromNow()}
</td>
<td class=" px-3 py-2">
{dayjs(user.created_at * 1000).format($i18n.t('MMMM DD, YYYY'))}
</td>
<td class="px-3 py-2 text-right">
<div class="flex justify-end w-full">
{#if user.role !== 'admin'}
<Tooltip content={$i18n.t('Chats')}>
<button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
on:click={async () => {
showUserChatsModal = !showUserChatsModal;
selectedUser = user;
}}
>
<ChatBubbles />
</button>
</Tooltip>
<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} />
{/if}
<style>
.font-mona {
......
<script lang="ts">
import { toast } from 'svelte-sonner';
import { v4 as uuidv4 } from 'uuid';
import { goto } from '$app/navigation';
import Chat from '$lib/components/chat/Chat.svelte';
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>
<svelte:head>
<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}
<Chat chatIdProp={$page.params.id} />
......@@ -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"
>
<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'
: ''} transition"
href="/workspace/modelfiles">{$i18n.t('Modelfiles')}</a
href="/workspace/models">{$i18n.t('Models')}</a
>
<a
......
......@@ -3,6 +3,6 @@
import { onMount } from 'svelte';
onMount(() => {
goto('/workspace/modelfiles');
goto('/workspace/models');
});
</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 @@
import { v4 as uuidv4 } from 'uuid';
import { toast } from 'svelte-sonner';
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 { createModel } from '$lib/apis/ollama';
import { createNewModelfile, getModelfileByTagName, getModelfiles } from '$lib/apis/modelfiles';
import { addNewModel, getModelById, getModelInfos } from '$lib/apis/models';
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 inputFiles;
let imageUrl = null;
let digest = '';
let pullProgress = null;
let showAdvanced = false;
let showPreview = false;
let loading = false;
let success = false;
// ///////////
// Modelfile
// Model
// ///////////
let title = '';
let tagName = '';
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 id = '';
let name = '';
let modelfileCreator = null;
$: tagName = title !== '' ? `${title.replace(/\s+/g, '-').toLowerCase()}:latest` : '';
$: 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 params = {};
let capabilities = {
vision: true
};
let suggestions = [
{
content: ''
let info = {
id: '',
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) => {
await createNewModelfile(localStorage.token, modelfile);
await modelfiles.set(await getModelfiles(localStorage.token));
};
$: if (name) {
id = name.replace(/\s+/g, '-').toLowerCase();
}
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 () => {
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.'
);
loading = false;
success = false;
return success;
}
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];
}
});
if (
$models.map((model) => model.name).includes(tagName) ||
(await getModelfileByTagName(localStorage.token, tagName).catch(() => false))
) {
if ($models.find((m) => m.id === info.id)) {
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;
success = false;
return success;
}
if (
title !== '' &&
desc !== '' &&
content !== '' &&
Object.keys(categories).filter((category) => categories[category]).length > 0 &&
!$models.includes(tagName)
) {
const res = await createModel(localStorage.token, tagName, content);
if (info) {
const res = await addNewModel(localStorage.token, {
...info,
meta: {
...info.meta,
profile_image_url: info.meta.profile_image_url ?? '/favicon.png',
suggestion_prompts: info.meta.suggestion_prompts
? info.meta.suggestion_prompts.filter((prompt) => prompt.content !== '')
: null
},
params: { ...info.params, ...params }
});
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 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');
await models.set(await getModels(localStorage.token));
toast.success('Model created successfully!');
await goto('/workspace/models');
}
}
loading = 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 () => {
window.addEventListener('message', async (event) => {
if (
![
'https://ollamahub.com',
'https://www.ollamahub.com',
'https://openwebui.com',
'https://www.openwebui.com',
'http://localhost:5173'
].includes(event.origin)
!['https://openwebui.com', 'https://www.openwebui.com', 'http://localhost:5173'].includes(
event.origin
)
)
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 = {
username: modelfile.user.username,
name: modelfile.user.name
};
for (const category of modelfile.categories) {
categories[category.toLowerCase()] = true;
}
const model = JSON.parse(event.data);
console.log(model);
initModel(model);
});
if (window.opener ?? false) {
window.opener.postMessage('loaded', '*');
}
if (sessionStorage.modelfile) {
const modelfile = JSON.parse(sessionStorage.modelfile);
console.log(modelfile);
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;
}
if (sessionStorage.model) {
const model = JSON.parse(sessionStorage.model);
sessionStorage.removeItem('model');
sessionStorage.removeItem('modelfile');
console.log(model);
initModel(model);
}
});
</script>
......@@ -330,7 +211,7 @@ SYSTEM """${system}"""`.replace(/^\s*\n/gm, '');
const compressedSrc = canvas.toDataURL('image/jpeg');
// Display the compressed image
imageUrl = compressedSrc;
info.meta.profile_image_url = compressedSrc;
inputFiles = null;
};
......@@ -382,26 +263,26 @@ SYSTEM """${system}"""`.replace(/^\s*\n/gm, '');
<div class="flex justify-center my-4">
<div class="self-center">
<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"
on:click={() => {
filesInputElement.click();
}}
>
{#if imageUrl}
{#if info.meta.profile_image_url}
<img
src={imageUrl}
src={info.meta.profile_image_url}
alt="modelfile profile"
class=" rounded-full w-20 h-20 object-cover"
class=" rounded-full size-16 object-cover"
/>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-8"
class="size-8"
>
<path
fill-rule="evenodd"
......@@ -421,21 +302,21 @@ SYSTEM """${system}"""`.replace(/^\s*\n/gm, '');
<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}
placeholder={$i18n.t('Name your model')}
bind:value={name}
required
/>
</div>
</div>
<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>
<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 model tag name')}
bind:value={tagName}
placeholder={$i18n.t('Add a model id')}
bind:value={id}
required
/>
</div>
......@@ -443,242 +324,278 @@ SYSTEM """${system}"""`.replace(/^\s*\n/gm, '');
</div>
<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>
<input
<select
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}
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?.preset) as model}
<option value={model.id} class=" text-gray-900">{model.name}</option>
{/each}
</select>
</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 class="my-1">
<div class="flex w-full justify-between items-center mb-1">
<div class=" self-center text-sm font-semibold">{$i18n.t('Description')}</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
class="p-1 text-xs flex rounded transition"
type="button"
on:click={() => {
raw = !raw;
if (info.meta.description === null) {
info.meta.description = '';
} else {
info.meta.description = null;
}
}}
>
{#if raw}
<span class="ml-2 self-center"> {$i18n.t('Raw Format')} </span>
{#if info.meta.description === null}
<span class="ml-2 self-center">{$i18n.t('Default')}</span>
{:else}
<span class="ml-2 self-center"> {$i18n.t('Builder Mode')} </span>
<span class="ml-2 self-center">{$i18n.t('Custom')}</span>
{/if}
</button>
</div>
<!-- <div class=" text-sm font-semibold mb-2"></div> -->
{#if raw}
<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 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>
{#if info.meta.description !== null}
<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 model does')}
bind:value={info.meta.description}
/>
{/if}
</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="Write a modelfile base model name (e.g. llama2, mistral)"
bind:value={model}
required
/>
</div>
<hr class=" dark:border-gray-850 my-1" />
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('To access the available model names for downloading,')}
<a
class=" text-gray-500 dark:text-gray-300 font-medium"
href="https://ollama.com/library"
target="_blank">{$i18n.t('click here.')}</a
>
</div>
</div>
<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="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 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"
bind:value={system}
bind:value={info.params.system}
/>
</div>
</div>
<div class="flex w-full justify-between">
<div class=" self-center text-sm font-semibold">
{$i18n.t('Modelfile Advanced Settings')}
<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={() => {
advanced = !advanced;
showAdvanced = !showAdvanced;
}}
>
{#if advanced}
<span class="ml-2 self-center">{$i18n.t('Custom')}</span>
{#if showAdvanced}
<span class="ml-2 self-center">{$i18n.t('Hide')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Default')}</span>
<span class="ml-2 self-center">{$i18n.t('Show')}</span>
{/if}
</button>
</div>
{#if advanced}
{#if showAdvanced}
<div class="my-2">
<div class=" text-xs font-semibold mb-2">{$i18n.t('Template')}</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 modelfile template content here"
rows="4"
bind:value={template}
/>
</div>
<AdvancedParams
bind:params
on:change={(e) => {
info.params = { ...info.params, ...params };
}}
/>
</div>
{/if}
</div>
</div>
<div class="my-2">
<div class=" text-xs font-semibold mb-2">{$i18n.t('Parameters')}</div>
<hr class=" dark:border-gray-850 my-1" />
<div>
<AdvancedParams bind:options />
</div>
</div>
<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-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 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 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}
<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';
}}
/>
<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 class=" py-0.5 text-sm w-full capitalize">
{$i18n.t(capability)}
</div>
</div>
{/each}
</div>
</div>
<div class="my-2">
<div class=" text-sm font-semibold mb-2">{$i18n.t('Categories')}</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="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 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>
{#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 bg-gray-500 text-xs font-medium text-gray-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 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}
<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
class=" text-sm px-3 py-2 transition rounded-xl {loading
? ' 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 @@
onMount(async () => {
window.addEventListener('message', async (event) => {
if (
![
'https://ollamahub.com',
'https://www.ollamahub.com',
'https://openwebui.com',
'https://www.openwebui.com',
'http://localhost:5173'
].includes(event.origin)
!['https://openwebui.com', 'https://www.openwebui.com', 'http://localhost:5173'].includes(
event.origin
)
)
return;
const prompt = JSON.parse(event.data);
......
......@@ -13,7 +13,7 @@
import 'tippy.js/dist/tippy.css';
import { WEBUI_BASE_URL } from '$lib/constants';
import i18n, { initI18n } from '$lib/i18n';
import i18n, { initI18n, getLanguages } from '$lib/i18n';
setContext('i18n', i18n);
......@@ -43,7 +43,14 @@
}
// Initialize i18n even if we didn't get a backend config,
// 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) {
// 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