diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index 56f992806a..cadb5a3a79 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -236,7 +236,7 @@ class ChatTable: return chat.chat.get("title", "New Chat") - def get_messages_by_chat_id(self, id: str) -> Optional[dict]: + def get_messages_map_by_chat_id(self, id: str) -> Optional[dict]: chat = self.get_chat_by_id(id) if chat is None: return None diff --git a/backend/open_webui/models/functions.py b/backend/open_webui/models/functions.py index 2bb6d60889..e8ce3aa811 100644 --- a/backend/open_webui/models/functions.py +++ b/backend/open_webui/models/functions.py @@ -37,6 +37,7 @@ class Function(Base): class FunctionMeta(BaseModel): description: Optional[str] = None manifest: Optional[dict] = {} + model_config = ConfigDict(extra="allow") class FunctionModel(BaseModel): @@ -260,6 +261,29 @@ class FunctionsTable: except Exception: return None + def update_function_metadata_by_id( + self, id: str, metadata: dict + ) -> Optional[FunctionModel]: + with get_db() as db: + try: + function = db.get(Function, id) + + if function: + if function.meta: + function.meta = {**function.meta, **metadata} + else: + function.meta = metadata + + function.updated_at = int(time.time()) + db.commit() + db.refresh(function) + return self.get_function_by_id(id) + else: + return None + except Exception as e: + log.exception(f"Error updating function metadata by id {id}: {e}") + return None + def get_user_valves_by_id_and_user_id( self, id: str, user_id: str ) -> Optional[dict]: diff --git a/backend/open_webui/models/notes.py b/backend/open_webui/models/notes.py index c720ff80a4..b61e820eae 100644 --- a/backend/open_webui/models/notes.py +++ b/backend/open_webui/models/notes.py @@ -97,15 +97,26 @@ class NoteTable: db.commit() return note - def get_notes(self) -> list[NoteModel]: + def get_notes( + self, skip: Optional[int] = None, limit: Optional[int] = None + ) -> list[NoteModel]: with get_db() as db: - notes = db.query(Note).order_by(Note.updated_at.desc()).all() + query = db.query(Note).order_by(Note.updated_at.desc()) + if skip is not None: + query = query.offset(skip) + if limit is not None: + query = query.limit(limit) + notes = query.all() return [NoteModel.model_validate(note) for note in notes] def get_notes_by_user_id( - self, user_id: str, permission: str = "write" + self, + user_id: str, + permission: str = "write", + skip: Optional[int] = None, + limit: Optional[int] = None, ) -> list[NoteModel]: - notes = self.get_notes() + notes = self.get_notes(skip=skip, limit=limit) user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)} return [ note diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index b3dc52d7b2..f5db7521b5 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -19,10 +19,13 @@ from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT from open_webui.models.users import UserModel from open_webui.models.files import Files from open_webui.models.knowledge import Knowledges + +from open_webui.models.chats import Chats from open_webui.models.notes import Notes from open_webui.retrieval.vector.main import GetResult from open_webui.utils.access_control import has_access +from open_webui.utils.misc import get_message_list from open_webui.env import ( @@ -491,25 +494,37 @@ def get_sources_from_items( # Raw Text # Used during temporary chat file uploads or web page & youtube attachements - if item.get("collection_name"): - # If item has a collection name, use it - collection_names.append(item.get("collection_name")) - elif item.get("file"): - # if item has file data, use it - query_result = { - "documents": [ - [item.get("file", {}).get("data", {}).get("content")] - ], - "metadatas": [[item.get("file", {}).get("meta", {})]], - } - else: - # Fallback to item content - query_result = { - "documents": [[item.get("content")]], - "metadatas": [ - [{"file_id": item.get("id"), "name": item.get("name")}] - ], - } + if item.get("context") == "full": + if item.get("file"): + # if item has file data, use it + query_result = { + "documents": [ + [item.get("file", {}).get("data", {}).get("content")] + ], + "metadatas": [[item.get("file", {}).get("meta", {})]], + } + + if query_result is None: + # Fallback + if item.get("collection_name"): + # If item has a collection name, use it + collection_names.append(item.get("collection_name")) + elif item.get("file"): + # If item has file data, use it + query_result = { + "documents": [ + [item.get("file", {}).get("data", {}).get("content")] + ], + "metadatas": [[item.get("file", {}).get("meta", {})]], + } + else: + # Fallback to item content + query_result = { + "documents": [[item.get("content")]], + "metadatas": [ + [{"file_id": item.get("id"), "name": item.get("name")}] + ], + } elif item.get("type") == "note": # Note Attached @@ -526,6 +541,30 @@ def get_sources_from_items( "metadatas": [[{"file_id": note.id, "name": note.title}]], } + elif item.get("type") == "chat": + # Chat Attached + chat = Chats.get_chat_by_id(item.get("id")) + + if chat and (user.role == "admin" or chat.user_id == user.id): + messages_map = chat.chat.get("history", {}).get("messages", {}) + message_id = chat.chat.get("history", {}).get("currentId") + + if messages_map and message_id: + # Reconstruct the message list in order + message_list = get_message_list(messages_map, message_id) + message_history = "\n".join( + [ + f"#### {m.get('role', 'user').capitalize()}\n{m.get('content')}\n" + for m in message_list + ] + ) + + # User has access to the chat + query_result = { + "documents": [[message_history]], + "metadatas": [[{"file_id": chat.id, "name": chat.title}]], + } + elif item.get("type") == "file": if ( item.get("context") == "full" diff --git a/backend/open_webui/routers/functions.py b/backend/open_webui/routers/functions.py index 9ef6915709..9f0651fd3f 100644 --- a/backend/open_webui/routers/functions.py +++ b/backend/open_webui/routers/functions.py @@ -192,6 +192,9 @@ async def create_new_function( function_cache_dir = CACHE_DIR / "functions" / form_data.id function_cache_dir.mkdir(parents=True, exist_ok=True) + if function_type == "filter" and getattr(function_module, "toggle", None): + Functions.update_function_metadata_by_id(id, {"toggle": True}) + if function: return function else: @@ -308,6 +311,9 @@ async def update_function_by_id( function = Functions.update_function_by_id(id, updated) + if function_type == "filter" and getattr(function_module, "toggle", None): + Functions.update_function_metadata_by_id(id, {"toggle": True}) + if function: return function else: diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py index 375f59ff6c..dff7bc2e7f 100644 --- a/backend/open_webui/routers/notes.py +++ b/backend/open_webui/routers/notes.py @@ -62,8 +62,9 @@ class NoteTitleIdResponse(BaseModel): @router.get("/list", response_model=list[NoteTitleIdResponse]) -async def get_note_list(request: Request, user=Depends(get_verified_user)): - +async def get_note_list( + request: Request, page: Optional[int] = None, user=Depends(get_verified_user) +): if user.role != "admin" and not has_permission( user.id, "features.notes", request.app.state.config.USER_PERMISSIONS ): @@ -72,9 +73,15 @@ async def get_note_list(request: Request, user=Depends(get_verified_user)): detail=ERROR_MESSAGES.UNAUTHORIZED, ) + limit = None + skip = None + if page is not None: + limit = 60 + skip = (page - 1) * limit + notes = [ NoteTitleIdResponse(**note.model_dump()) - for note in Notes.get_notes_by_user_id(user.id, "write") + for note in Notes.get_notes_by_user_id(user.id, "write", skip=skip, limit=limit) ] return notes diff --git a/backend/open_webui/utils/filter.py b/backend/open_webui/utils/filter.py index 1986e55b64..663b4e3fb7 100644 --- a/backend/open_webui/utils/filter.py +++ b/backend/open_webui/utils/filter.py @@ -127,8 +127,10 @@ async def process_filter_functions( raise e # Handle file cleanup for inlet - if skip_files and "files" in form_data.get("metadata", {}): - del form_data["files"] - del form_data["metadata"]["files"] + if skip_files: + if "files" in form_data.get("metadata", {}): + del form_data["metadata"]["files"] + if "files" in form_data: + del form_data["files"] return form_data, {} diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 42e413afaf..89e4304474 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -1131,11 +1131,11 @@ async def process_chat_response( request, response, form_data, user, metadata, model, events, tasks ): async def background_tasks_handler(): - message_map = Chats.get_messages_by_chat_id(metadata["chat_id"]) - message = message_map.get(metadata["message_id"]) if message_map else None + messages_map = Chats.get_messages_map_by_chat_id(metadata["chat_id"]) + message = messages_map.get(metadata["message_id"]) if messages_map else None if message: - message_list = get_message_list(message_map, metadata["message_id"]) + message_list = get_message_list(messages_map, metadata["message_id"]) # Remove details tags and files from the messages. # as get_message_list creates a new list, it does not affect diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index 82729c34e0..370cf26c48 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -26,7 +26,7 @@ def deep_update(d, u): return d -def get_message_list(messages, message_id): +def get_message_list(messages_map, message_id): """ Reconstructs a list of messages in order up to the specified message_id. @@ -36,11 +36,11 @@ def get_message_list(messages, message_id): """ # Handle case where messages is None - if not messages: + if not messages_map: return [] # Return empty list instead of None to prevent iteration errors # Find the message by its id - current_message = messages.get(message_id) + current_message = messages_map.get(message_id) if not current_message: return [] # Return empty list instead of None to prevent iteration errors @@ -53,7 +53,7 @@ def get_message_list(messages, message_id): 0, current_message ) # Insert the message at the beginning of the list parent_id = current_message.get("parentId") # Use .get() for safety - current_message = messages.get(parent_id) if parent_id else None + current_message = messages_map.get(parent_id) if parent_id else None return message_list diff --git a/package-lock.json b/package-lock.json index 48122812b8..b9b9be7a2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-webui", - "version": "0.6.28", + "version": "0.6.29", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.6.28", + "version": "0.6.29", "dependencies": { "@azure/msal-browser": "^4.5.0", "@codemirror/lang-javascript": "^6.2.2", @@ -37,6 +37,7 @@ "@tiptap/extensions": "^3.0.7", "@tiptap/pm": "^3.0.7", "@tiptap/starter-kit": "^3.0.7", + "@tiptap/suggestion": "^3.4.2", "@xyflow/svelte": "^0.1.19", "async": "^3.2.5", "bits-ui": "^0.21.15", @@ -86,7 +87,6 @@ "socket.io-client": "^4.2.0", "sortablejs": "^1.15.6", "svelte-sonner": "^0.3.19", - "svelte-tiptap": "^3.0.0", "tippy.js": "^6.3.7", "turndown": "^7.2.0", "turndown-plugin-gfm": "^1.0.2", @@ -3856,18 +3856,17 @@ } }, "node_modules/@tiptap/suggestion": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.0.9.tgz", - "integrity": "sha512-irthqfUybezo3IwR6AXvyyTOtkzwfvvst58VXZtTnR1nN6NEcrs3TQoY3bGKGbN83bdiquKh6aU2nLnZfAhoXg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.4.2.tgz", + "integrity": "sha512-sljtfiDtdAsbPOwrXrFGf64D6sXUjeU3Iz5v3TvN7TVJKozkZ/gaMkPRl+WC1CGwC6BnzQVDBEEa1e+aApV0mA==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.0.9", - "@tiptap/pm": "^3.0.9" + "@tiptap/core": "^3.4.2", + "@tiptap/pm": "^3.4.2" } }, "node_modules/@tiptap/y-tiptap": { @@ -12503,26 +12502,6 @@ "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.1" } }, - "node_modules/svelte-tiptap": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/svelte-tiptap/-/svelte-tiptap-3.0.0.tgz", - "integrity": "sha512-digFHOJe16RX0HIU+u8hOaCS9sIgktTpYHSF9yJ6dgxPv/JWJdYCdwoX65lcHitFhhCG7xnolJng6PJa9M9h3w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "peerDependencies": { - "@floating-ui/dom": "^1.0.0", - "@tiptap/core": "^3.0.0", - "@tiptap/extension-bubble-menu": "^3.0.0", - "@tiptap/extension-floating-menu": "^3.0.0", - "@tiptap/pm": "^3.0.0", - "svelte": "^5.0.0" - } - }, "node_modules/svelte/node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", diff --git a/package.json b/package.json index 054baf39c6..c5068f0366 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.6.28", + "version": "0.6.29", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", @@ -81,6 +81,7 @@ "@tiptap/extensions": "^3.0.7", "@tiptap/pm": "^3.0.7", "@tiptap/starter-kit": "^3.0.7", + "@tiptap/suggestion": "^3.4.2", "@xyflow/svelte": "^0.1.19", "async": "^3.2.5", "bits-ui": "^0.21.15", @@ -130,7 +131,6 @@ "socket.io-client": "^4.2.0", "sortablejs": "^1.15.6", "svelte-sonner": "^0.3.19", - "svelte-tiptap": "^3.0.0", "tippy.js": "^6.3.7", "turndown": "^7.2.0", "turndown-plugin-gfm": "^1.0.2", diff --git a/pyproject.toml b/pyproject.toml index a0e7e4a095..4bbaf85b6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,18 +56,11 @@ dependencies = [ "fake-useragent==2.2.0", "chromadb==1.0.20", - "pymilvus==2.5.0", - "qdrant-client==1.14.3", "opensearch-py==2.8.0", - "playwright==1.49.1", - "elasticsearch==9.1.0", - "pinecone==6.0.2", - "oracledb==3.2.0", - + "transformers", "sentence-transformers==4.1.0", "accelerate", - "colbert-ai==0.2.21", "pyarrow==20.0.0", "einops==0.8.1", @@ -154,6 +147,15 @@ all = [ "docker~=7.1.0", "pytest~=8.3.2", "pytest-docker~=3.1.1", + "playwright==1.49.1", + "elasticsearch==9.1.0", + + "qdrant-client==1.14.3", + "pymilvus==2.5.0", + "pinecone==6.0.2", + "oracledb==3.2.0", + + "colbert-ai==0.2.21", ] [project.scripts] diff --git a/src/app.css b/src/app.css index c48914febf..6f40acab32 100644 --- a/src/app.css +++ b/src/app.css @@ -409,17 +409,33 @@ input[type='number'] { } } -.tiptap .mention { +.mention { border-radius: 0.4rem; box-decoration-break: clone; padding: 0.1rem 0.3rem; @apply text-blue-900 dark:text-blue-100 bg-blue-300/20 dark:bg-blue-500/20; } -.tiptap .mention::after { +.mention::after { content: '\200B'; } +.tiptap .suggestion { + border-radius: 0.4rem; + box-decoration-break: clone; + padding: 0.1rem 0.3rem; + @apply bg-purple-100/20 text-purple-900 dark:bg-purple-500/20 dark:text-purple-100; +} + +.tiptap .suggestion::after { + content: '\200B'; +} + +.tiptap .suggestion.is-empty::after { + content: '\00A0'; + border-bottom: 1px dotted rgba(31, 41, 55, 0.12); +} + .input-prose .tiptap ul[data-type='taskList'] { list-style: none; margin-left: 0; diff --git a/src/app.html b/src/app.html index f7167d42f2..6c1c362005 100644 --- a/src/app.html +++ b/src/app.html @@ -23,8 +23,6 @@ href="/static/apple-touch-icon.png" crossorigin="use-credentials" /> - - - - + diff --git a/src/lib/apis/notes/index.ts b/src/lib/apis/notes/index.ts index 965c4217ed..61794f6766 100644 --- a/src/lib/apis/notes/index.ts +++ b/src/lib/apis/notes/index.ts @@ -91,10 +91,15 @@ export const getNotes = async (token: string = '', raw: boolean = false) => { return grouped; }; -export const getNoteList = async (token: string = '') => { +export const getNoteList = async (token: string = '', page: number | null = null) => { let error = null; + const searchParams = new URLSearchParams(); - const res = await fetch(`${WEBUI_API_BASE_URL}/notes/list`, { + if (page !== null) { + searchParams.append('page', `${page}`); + } + + const res = await fetch(`${WEBUI_API_BASE_URL}/notes/list?${searchParams.toString()}`, { method: 'GET', headers: { Accept: 'application/json', diff --git a/src/lib/components/AddFilesPlaceholder.svelte b/src/lib/components/AddFilesPlaceholder.svelte index 6d72ee0e61..cb7f4f04bc 100644 --- a/src/lib/components/AddFilesPlaceholder.svelte +++ b/src/lib/components/AddFilesPlaceholder.svelte @@ -7,8 +7,7 @@
-
📄
-
+
{#if title} {title} {:else} @@ -17,7 +16,7 @@
+ >
{#if content} {content} {:else} diff --git a/src/lib/components/admin/Functions/FunctionMenu.svelte b/src/lib/components/admin/Functions/FunctionMenu.svelte index 71e12d0535..0630472987 100644 --- a/src/lib/components/admin/Functions/FunctionMenu.svelte +++ b/src/lib/components/admin/Functions/FunctionMenu.svelte @@ -42,7 +42,7 @@
{#if ['filter', 'action'].includes(func.type)}
@@ -67,7 +67,7 @@ {/if} { editHandler(); }} @@ -91,7 +91,7 @@ { shareHandler(); }} @@ -101,7 +101,7 @@ { cloneHandler(); }} @@ -112,7 +112,7 @@ { exportHandler(); }} @@ -125,7 +125,7 @@
{ deleteHandler(); }} diff --git a/src/lib/components/admin/Settings/Database.svelte b/src/lib/components/admin/Settings/Database.svelte index 9c0d148fe6..efcc30414a 100644 --- a/src/lib/components/admin/Settings/Database.svelte +++ b/src/lib/components/admin/Settings/Database.svelte @@ -233,14 +233,4 @@ {/if}
- - diff --git a/src/lib/components/admin/Settings/Models/ModelMenu.svelte b/src/lib/components/admin/Settings/Models/ModelMenu.svelte index d41baa24c1..7b16a82f9b 100644 --- a/src/lib/components/admin/Settings/Models/ModelMenu.svelte +++ b/src/lib/components/admin/Settings/Models/ModelMenu.svelte @@ -45,14 +45,14 @@
{ hideHandler(); }} @@ -104,7 +104,7 @@ { copyLinkHandler(); }} @@ -115,7 +115,7 @@ { exportHandler(); }} diff --git a/src/lib/components/channel/MessageInput.svelte b/src/lib/components/channel/MessageInput.svelte index fc85ea2aa1..f9fbc32f6c 100644 --- a/src/lib/components/channel/MessageInput.svelte +++ b/src/lib/components/channel/MessageInput.svelte @@ -17,9 +17,12 @@ getFormattedTime, getUserPosition, getUserTimezone, - getWeekday + getWeekday, + extractCurlyBraceWords } from '$lib/utils'; + import { getSessionUser } from '$lib/apis/auths'; + import Tooltip from '../common/Tooltip.svelte'; import RichTextInput from '../common/RichTextInput.svelte'; import VoiceRecording from '../chat/MessageInput/VoiceRecording.svelte'; @@ -29,26 +32,16 @@ import FileItem from '../common/FileItem.svelte'; import Image from '../common/Image.svelte'; import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte'; - import Commands from '../chat/MessageInput/Commands.svelte'; import InputVariablesModal from '../chat/MessageInput/InputVariablesModal.svelte'; - import { getSessionUser } from '$lib/apis/auths'; + import { getSuggestionRenderer } from '../common/RichTextInput/suggestions'; + import CommandSuggestionList from '../chat/MessageInput/CommandSuggestionList.svelte'; + import MentionList from './MessageInput/MentionList.svelte'; export let placeholder = $i18n.t('Send a Message'); export let id = null; - - let draggedOver = false; - - let recording = false; - let content = ''; - let files = []; - export let chatInputElement; - let commandsElement; - let filesInputElement; - let inputFiles; - export let typingUsers = []; export let inputLoading = false; @@ -62,15 +55,39 @@ export let acceptFiles = true; export let showFormattingToolbar = true; + let loaded = false; + let draggedOver = false; + + let recording = false; + let content = ''; + let files = []; + + let filesInputElement; + let inputFiles; + let showInputVariablesModal = false; + let inputVariablesModalCallback: (variableValues: Record) => void; let inputVariables: Record = {}; let inputVariableValues = {}; - const inputVariableHandler = async (text: string) => { + const inputVariableHandler = async (text: string): Promise => { inputVariables = extractInputVariables(text); - if (Object.keys(inputVariables).length > 0) { - showInputVariablesModal = true; + + // No variables? return the original text immediately. + if (Object.keys(inputVariables).length === 0) { + return text; } + + // Show modal and wait for the user's input. + showInputVariablesModal = true; + return await new Promise((resolve) => { + inputVariablesModalCallback = (variableValues) => { + inputVariableValues = { ...inputVariableValues, ...variableValues }; + replaceVariables(inputVariableValues); + showInputVariablesModal = false; + resolve(text); + }; + }); }; const textVariableHandler = async (text: string) => { @@ -188,68 +205,87 @@ text = text.replaceAll('{{CURRENT_WEEKDAY}}', weekday); } - inputVariableHandler(text); return text; }; const replaceVariables = (variables: Record) => { - if (!chatInputElement) return; console.log('Replacing variables:', variables); - chatInputElement.replaceVariables(variables); - chatInputElement.focus(); + const chatInput = document.getElementById('chat-input'); + + if (chatInput) { + chatInputElement.replaceVariables(variables); + chatInputElement.focus(); + } }; - export const setText = async (text?: string) => { - if (!chatInputElement) return; + export const setText = async (text?: string, cb?: (text: string) => void) => { + const chatInput = document.getElementById('chat-input'); - text = await textVariableHandler(text || ''); + if (chatInput) { + text = await textVariableHandler(text || ''); - chatInputElement?.setText(text); - chatInputElement?.focus(); + chatInputElement?.setText(text); + chatInputElement?.focus(); + + text = await inputVariableHandler(text); + await tick(); + if (cb) await cb(text); + } }; const getCommand = () => { - if (!chatInputElement) return; - + const chatInput = document.getElementById('chat-input'); let word = ''; - word = chatInputElement?.getWordAtDocPos(); + + if (chatInput) { + word = chatInputElement?.getWordAtDocPos(); + } return word; }; const replaceCommandWithText = (text) => { - if (!chatInputElement) return; + const chatInput = document.getElementById('chat-input'); + if (!chatInput) return; chatInputElement?.replaceCommandWithText(text); }; const insertTextAtCursor = async (text: string) => { + const chatInput = document.getElementById('chat-input'); + if (!chatInput) return; + text = await textVariableHandler(text); if (command) { replaceCommandWithText(text); } else { - const selection = window.getSelection(); - if (selection && selection.rangeCount > 0) { - const range = selection.getRangeAt(0); - range.deleteContents(); - range.insertNode(document.createTextNode(text)); - range.collapse(false); - selection.removeAllRanges(); - selection.addRange(range); - } + chatInputElement?.insertContent(text); } await tick(); + text = await inputVariableHandler(text); + await tick(); + const chatInputContainer = document.getElementById('chat-input-container'); if (chatInputContainer) { chatInputContainer.scrollTop = chatInputContainer.scrollHeight; } await tick(); - if (chatInputElement) { - chatInputElement.focus(); + if (chatInput) { + chatInput.focus(); + chatInput.dispatchEvent(new Event('input')); + + const words = extractCurlyBraceWords(prompt); + + if (words.length > 0) { + const word = words.at(0); + await tick(); + } else { + chatInput.scrollTop = chatInput.scrollHeight; + } } }; @@ -257,6 +293,7 @@ export let showCommands = false; $: showCommands = ['/'].includes(command?.charAt(0)); + let suggestions = null; const screenCaptureHandler = async () => { try { @@ -514,6 +551,49 @@ } onMount(async () => { + suggestions = [ + { + char: '@', + render: getSuggestionRenderer(MentionList, { + i18n + }) + }, + { + char: '/', + render: getSuggestionRenderer(CommandSuggestionList, { + i18n, + onSelect: (e) => { + const { type, data } = e; + + if (type === 'model') { + console.log('Selected model:', data); + } + + document.getElementById('chat-input')?.focus(); + }, + + insertTextHandler: insertTextAtCursor, + onUpload: (e) => { + const { type, data } = e; + + if (type === 'file') { + if (files.find((f) => f.id === data.id)) { + return; + } + files = [ + ...files, + { + ...data, + status: 'processed' + } + ]; + } + } + }) + } + ]; + loaded = true; + window.setTimeout(() => { if (chatInputElement) { chatInputElement.focus(); @@ -543,432 +623,392 @@ }); - +{#if loaded} + -{#if acceptFiles} - { - if (inputFiles && inputFiles.length > 0) { - inputFilesHandler(Array.from(inputFiles)); - } else { - toast.error($i18n.t(`File not found.`)); - } + {#if acceptFiles} + { + if (inputFiles && inputFiles.length > 0) { + inputFilesHandler(Array.from(inputFiles)); + } else { + toast.error($i18n.t(`File not found.`)); + } - filesInputElement.value = ''; - }} + filesInputElement.value = ''; + }} + /> + {/if} + + -{/if} - { - inputVariableValues = { ...inputVariableValues, ...variableValues }; - replaceVariables(inputVariableValues); - }} -/> - -
-
-
-
-
- {#if scrollEnd === false} -
- -
- {/if} -
- -
-
- {#if typingUsers.length > 0} -
- - {typingUsers.map((user) => user.name).join(', ')} - - {$i18n.t('is typing...')} + + + +
{/if}
- +
+
+ {#if typingUsers.length > 0} +
+ + {typingUsers.map((user) => user.name).join(', ')} + + {$i18n.t('is typing...')} +
+ {/if} +
+
-
-
- {#if recording} - { - recording = false; +
+ {#if recording} + { + recording = false; - await tick(); + await tick(); - if (chatInputElement) { - chatInputElement.focus(); - } - }} - onConfirm={async (data) => { - const { text, filename } = data; - recording = false; + if (chatInputElement) { + chatInputElement.focus(); + } + }} + onConfirm={async (data) => { + const { text, filename } = data; + recording = false; - await tick(); - insertTextAtCursor(text); + await tick(); + insertTextAtCursor(text); - await tick(); + await tick(); - if (chatInputElement) { - chatInputElement.focus(); - } - }} - /> - {:else} -
{ - submitHandler(); - }} - > -
+ {:else} + { + submitHandler(); + }} > - {#if files.length > 0} -
- {#each files as file, fileIdx} - {#if file.type === 'image'} -
-
- input +
+ {#if files.length > 0} +
+ {#each files as file, fileIdx} + {#if file.type === 'image'} +
+
+ +
+
+ +
-
+ {:else} + { + files.splice(fileIdx, 1); + files = files; + }} + on:click={() => { + console.log(file); + }} + /> + {/if} + {/each} +
+ {/if} + +
+
+ {#key $settings?.richTextInput} + 0 || + navigator.msMaxTouchPoints > 0 + ))} + largeTextAsFile={$settings?.largeTextAsFile ?? false} + floatingMenuPlacement={'top-start'} + {suggestions} + onChange={(e) => { + const { md } = e; + content = md; + command = getCommand(); + }} + on:keydown={async (e) => { + e = e.detail.event; + const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac + + const suggestionsContainerElement = + document.getElementById('suggestions-container'); + + if (!suggestionsContainerElement) { + if ( + !$mobile || + !( + 'ontouchstart' in window || + navigator.maxTouchPoints > 0 || + navigator.msMaxTouchPoints > 0 + ) + ) { + // Prevent Enter key from creating a new line + // Uses keyCode '13' for Enter key for chinese/japanese keyboards + if (e.keyCode === 13 && !e.shiftKey) { + e.preventDefault(); + } + + // Submit the content when Enter key is pressed + if (content !== '' && e.keyCode === 13 && !e.shiftKey) { + submitHandler(); + } + } + } + + if (e.key === 'Escape') { + console.info('Escape'); + } + }} + on:paste={async (e) => { + e = e.detail.event; + console.info(e); + }} + /> + {/key} +
+
+ +
+
+ + {#if acceptFiles} + { + filesInputElement.click(); + }} + > -
-
- {:else} - { - files.splice(fileIdx, 1); - files = files; - }} - on:click={() => { - console.log(file); - }} - /> - {/if} - {/each} -
- {/if} + + {/if} + +
-
-
- 0 || - navigator.msMaxTouchPoints > 0 - ))} - largeTextAsFile={$settings?.largeTextAsFile ?? false} - floatingMenuPlacement={'top-start'} - onChange={(e) => { - const { md } = e; - content = md; - command = getCommand(); - }} - on:keydown={async (e) => { - e = e.detail.event; - const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac - - const commandsContainerElement = document.getElementById('commands-container'); - - if (commandsContainerElement) { - if (commandsContainerElement && e.key === 'ArrowUp') { - e.preventDefault(); - commandsElement.selectUp(); - - const commandOptionButton = [ - ...document.getElementsByClassName('selected-command-option-button') - ]?.at(-1); - commandOptionButton.scrollIntoView({ block: 'center' }); - } - - if (commandsContainerElement && e.key === 'ArrowDown') { - e.preventDefault(); - commandsElement.selectDown(); - - const commandOptionButton = [ - ...document.getElementsByClassName('selected-command-option-button') - ]?.at(-1); - commandOptionButton.scrollIntoView({ block: 'center' }); - } - - if (commandsContainerElement && e.key === 'Tab') { - e.preventDefault(); - - const commandOptionButton = [ - ...document.getElementsByClassName('selected-command-option-button') - ]?.at(-1); - - commandOptionButton?.click(); - } - - if (commandsContainerElement && e.key === 'Enter') { - e.preventDefault(); - - const commandOptionButton = [ - ...document.getElementsByClassName('selected-command-option-button') - ]?.at(-1); - - if (commandOptionButton) { - commandOptionButton?.click(); - } else { - document.getElementById('send-message-button')?.click(); - } - } - } else { - if ( - !$mobile || - !( - 'ontouchstart' in window || - navigator.maxTouchPoints > 0 || - navigator.msMaxTouchPoints > 0 - ) - ) { - // Prevent Enter key from creating a new line - // Uses keyCode '13' for Enter key for chinese/japanese keyboards - if (e.keyCode === 13 && !e.shiftKey) { - e.preventDefault(); - } - - // Submit the content when Enter key is pressed - if (content !== '' && e.keyCode === 13 && !e.shiftKey) { - submitHandler(); - } - } - } - - if (e.key === 'Escape') { - console.info('Escape'); - } - }} - on:paste={async (e) => { - e = e.detail.event; - console.info(e); - }} - /> -
-
- -
-
- - {#if acceptFiles} - { - filesInputElement.click(); - }} - > +
+ {#if content === ''} + - + {/if} - -
-
- {#if content === ''} - - - - {/if} - -
- {#if inputLoading && onStop} -
- - - -
- {:else} -
- - + +
+ {:else} +
+ + - -
- {/if} + + + + + +
+ {/if} +
-
- - {/if} + + {/if} +
-
+{/if} diff --git a/src/lib/components/channel/MessageInput/InputMenu.svelte b/src/lib/components/channel/MessageInput/InputMenu.svelte index 7226c34cb9..902a49f7e0 100644 --- a/src/lib/components/channel/MessageInput/InputMenu.svelte +++ b/src/lib/components/channel/MessageInput/InputMenu.svelte @@ -13,6 +13,8 @@ import GlobeAltSolid from '$lib/components/icons/GlobeAltSolid.svelte'; import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte'; import CameraSolid from '$lib/components/icons/CameraSolid.svelte'; + import Camera from '$lib/components/icons/Camera.svelte'; + import Clip from '$lib/components/icons/Clip.svelte'; const i18n = getContext('i18n'); @@ -44,34 +46,34 @@
- {#if !$mobile} - { - screenCaptureHandler(); - }} - > - -
{$i18n.t('Capture')}
-
- {/if} - { uploadFilesHandler(); }} > - +
{$i18n.t('Upload Files')}
+ + {#if !$mobile} + { + screenCaptureHandler(); + }} + > + +
{$i18n.t('Capture')}
+
+ {/if}
diff --git a/src/lib/components/channel/MessageInput/MentionList.svelte b/src/lib/components/channel/MessageInput/MentionList.svelte new file mode 100644 index 0000000000..48fbdb5db7 --- /dev/null +++ b/src/lib/components/channel/MessageInput/MentionList.svelte @@ -0,0 +1,85 @@ + + +{#if filteredItems.length} +
+
+
+ {$i18n.t('Models')} +
+ {#each filteredItems as item, i} + + + + {/each} +
+
+{/if} diff --git a/src/lib/components/channel/Messages/Message.svelte b/src/lib/components/channel/Messages/Message.svelte index 649529a6f9..9d46a72806 100644 --- a/src/lib/components/channel/Messages/Message.svelte +++ b/src/lib/components/channel/Messages/Message.svelte @@ -138,9 +138,7 @@ id="message-{message.id}" dir={$settings.chatDirection} > -
+
{#if showUserProfile}
{/if} @@ -198,7 +201,7 @@ name={file.name} type={file.type} size={file?.size} - colorClassName="bg-white dark:bg-gray-850 " + small={true} /> {/if}
@@ -228,7 +231,7 @@
- -
- {#if atSelectedModel !== undefined} -
-
-
- model profile model.id === atSelectedModel.id)?.info?.meta - ?.profile_image_url ?? - ($i18n.language === 'dg-DG' - ? `${WEBUI_BASE_URL}/doge.png` - : `${WEBUI_BASE_URL}/static/favicon.png`)} - /> -
- {$i18n.t('Talk to model')}: - {atSelectedModel.name} -
-
-
- -
-
-
- {/if} - - { - const { type, data } = e; - - if (type === 'file') { - if (files.find((f) => f.id === data.id)) { - return; - } - files = [ - ...files, - { - ...data, - status: 'processed' - } - ]; - } else { - dispatch('upload', e); - } - }} - onSelect={(e) => { - const { type, data } = e; - - if (type === 'model') { - atSelectedModel = data; - } - - document.getElementById('chat-input')?.focus(); - }} - /> -
@@ -1062,8 +1028,40 @@ dir={$settings?.chatDirection ?? 'auto'} id="message-input-container" > + {#if atSelectedModel !== undefined} +
+
+
+ model profile model.id === atSelectedModel.id)?.info?.meta + ?.profile_image_url ?? + ($i18n.language === 'dg-DG' + ? `${WEBUI_BASE_URL}/doge.png` + : `${WEBUI_BASE_URL}/static/favicon.png`)} + /> +
+ {atSelectedModel.name} +
+
+
+ +
+
+
+ {/if} + {#if files.length > 0} -
+
{#each files as file, fileIdx} {#if file.type === 'image'}
@@ -1071,7 +1069,7 @@ {#if atSelectedModel ? visionCapableModels.length === 0 : selectedModels.length !== visionCapableModels.length} { // Remove from UI state @@ -1152,513 +1151,220 @@ {/if}
- {#if $settings?.richTextInput ?? true} -
- {#key $settings?.showFormattingToolbar ?? false} - { - prompt = e.md; - command = getCommand(); - }} - json={true} - messageInput={true} - showFormattingToolbar={$settings?.showFormattingToolbar ?? false} - floatingMenuPlacement={'top-start'} - insertPromptAsRichText={$settings?.insertPromptAsRichText ?? false} - shiftEnter={!($settings?.ctrlEnterToSend ?? false) && - (!$mobile || - !( - 'ontouchstart' in window || - navigator.maxTouchPoints > 0 || - navigator.msMaxTouchPoints > 0 - ))} - placeholder={placeholder ? placeholder : $i18n.t('Send a Message')} - largeTextAsFile={($settings?.largeTextAsFile ?? false) && !shiftKey} - autocomplete={$config?.features?.enable_autocomplete_generation && - ($settings?.promptAutocomplete ?? false)} - generateAutoCompletion={async (text) => { - if (selectedModelIds.length === 0 || !selectedModelIds.at(0)) { - toast.error($i18n.t('Please select a model first.')); - } - - const res = await generateAutoCompletion( - localStorage.token, - selectedModelIds.at(0), - text, - history?.currentId - ? createMessagesList(history, history.currentId) - : null - ).catch((error) => { - console.log(error); - - return null; - }); - - console.log(res); - return res; - }} - oncompositionstart={() => (isComposing = true)} - oncompositionend={(e) => { - compositionEndedAt = e.timeStamp; - isComposing = false; - }} - on:keydown={async (e) => { - e = e.detail.event; - - const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac - const commandsContainerElement = - document.getElementById('commands-container'); - - if (e.key === 'Escape') { - stopResponse(); - } - - // Command/Ctrl + Shift + Enter to submit a message pair - if (isCtrlPressed && e.key === 'Enter' && e.shiftKey) { - e.preventDefault(); - createMessagePair(prompt); - } - - // Check if Ctrl + R is pressed - if (prompt === '' && isCtrlPressed && e.key.toLowerCase() === 'r') { - e.preventDefault(); - console.log('regenerate'); - - const regenerateButton = [ - ...document.getElementsByClassName('regenerate-response-button') - ]?.at(-1); - - regenerateButton?.click(); - } - - if (prompt === '' && e.key == 'ArrowUp') { - e.preventDefault(); - - const userMessageElement = [ - ...document.getElementsByClassName('user-message') - ]?.at(-1); - - if (userMessageElement) { - userMessageElement.scrollIntoView({ block: 'center' }); - const editButton = [ - ...document.getElementsByClassName('edit-user-message-button') - ]?.at(-1); - - editButton?.click(); - } - } - - if (commandsContainerElement) { - if (commandsContainerElement && e.key === 'ArrowUp') { - e.preventDefault(); - commandsElement.selectUp(); - - const commandOptionButton = [ - ...document.getElementsByClassName( - 'selected-command-option-button' - ) - ]?.at(-1); - commandOptionButton.scrollIntoView({ block: 'center' }); - } - - if (commandsContainerElement && e.key === 'ArrowDown') { - e.preventDefault(); - commandsElement.selectDown(); - - const commandOptionButton = [ - ...document.getElementsByClassName( - 'selected-command-option-button' - ) - ]?.at(-1); - commandOptionButton.scrollIntoView({ block: 'center' }); - } - - if (commandsContainerElement && e.key === 'Tab') { - e.preventDefault(); - - const commandOptionButton = [ - ...document.getElementsByClassName( - 'selected-command-option-button' - ) - ]?.at(-1); - - commandOptionButton?.click(); - } - - if (commandsContainerElement && e.key === 'Enter') { - e.preventDefault(); - - const commandOptionButton = [ - ...document.getElementsByClassName( - 'selected-command-option-button' - ) - ]?.at(-1); - - if (commandOptionButton) { - commandOptionButton?.click(); - } else { - document.getElementById('send-message-button')?.click(); - } - } - } else { - if ( - !$mobile || +
+ {#if suggestions} + {#key $settings?.richTextInput ?? true} + {#key $settings?.showFormattingToolbar ?? false} + { + prompt = e.md; + command = getCommand(); + }} + json={true} + richText={$settings?.richTextInput ?? true} + messageInput={true} + showFormattingToolbar={$settings?.showFormattingToolbar ?? false} + floatingMenuPlacement={'top-start'} + insertPromptAsRichText={$settings?.insertPromptAsRichText ?? false} + shiftEnter={!($settings?.ctrlEnterToSend ?? false) && + (!$mobile || !( 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0 - ) - ) { - if (inOrNearComposition(e)) { - return; - } + ))} + placeholder={placeholder ? placeholder : $i18n.t('Send a Message')} + largeTextAsFile={($settings?.largeTextAsFile ?? false) && !shiftKey} + autocomplete={$config?.features?.enable_autocomplete_generation && + ($settings?.promptAutocomplete ?? false)} + generateAutoCompletion={async (text) => { + if (selectedModelIds.length === 0 || !selectedModelIds.at(0)) { + toast.error($i18n.t('Please select a model first.')); + } - // Uses keyCode '13' for Enter key for chinese/japanese keyboards. - // - // Depending on the user's settings, it will send the message - // either when Enter is pressed or when Ctrl+Enter is pressed. - const enterPressed = - ($settings?.ctrlEnterToSend ?? false) - ? (e.key === 'Enter' || e.keyCode === 13) && isCtrlPressed - : (e.key === 'Enter' || e.keyCode === 13) && !e.shiftKey; + const res = await generateAutoCompletion( + localStorage.token, + selectedModelIds.at(0), + text, + history?.currentId + ? createMessagesList(history, history.currentId) + : null + ).catch((error) => { + console.log(error); - if (enterPressed) { - e.preventDefault(); - if (prompt !== '' || files.length > 0) { - dispatch('submit', prompt); - } + return null; + }); + + console.log(res); + return res; + }} + {suggestions} + oncompositionstart={() => (isComposing = true)} + oncompositionend={(e) => { + compositionEndedAt = e.timeStamp; + isComposing = false; + }} + on:keydown={async (e) => { + e = e.detail.event; + + const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac + const suggestionsContainerElement = + document.getElementById('suggestions-container'); + + if (e.key === 'Escape') { + stopResponse(); + } + + // Command/Ctrl + Shift + Enter to submit a message pair + if (isCtrlPressed && e.key === 'Enter' && e.shiftKey) { + e.preventDefault(); + createMessagePair(prompt); + } + + // Check if Ctrl + R is pressed + if (prompt === '' && isCtrlPressed && e.key.toLowerCase() === 'r') { + e.preventDefault(); + console.log('regenerate'); + + const regenerateButton = [ + ...document.getElementsByClassName('regenerate-response-button') + ]?.at(-1); + + regenerateButton?.click(); + } + + if (prompt === '' && e.key == 'ArrowUp') { + e.preventDefault(); + + const userMessageElement = [ + ...document.getElementsByClassName('user-message') + ]?.at(-1); + + if (userMessageElement) { + userMessageElement.scrollIntoView({ block: 'center' }); + const editButton = [ + ...document.getElementsByClassName('edit-user-message-button') + ]?.at(-1); + + editButton?.click(); } } - } - if (e.key === 'Escape') { - console.log('Escape'); - atSelectedModel = undefined; - selectedToolIds = []; - selectedFilterIds = []; - - webSearchEnabled = false; - imageGenerationEnabled = false; - codeInterpreterEnabled = false; - } - }} - on:paste={async (e) => { - e = e.detail.event; - console.log(e); - - const clipboardData = e.clipboardData || window.clipboardData; - - if (clipboardData && clipboardData.items) { - for (const item of clipboardData.items) { - if (item.type.indexOf('image') !== -1) { - const blob = item.getAsFile(); - const reader = new FileReader(); - - reader.onload = function (e) { - files = [ - ...files, - { - type: 'image', - url: `${e.target.result}` - } - ]; - }; - - reader.readAsDataURL(blob); - } else if (item?.kind === 'file') { - const file = item.getAsFile(); - if (file) { - const _files = [file]; - await inputFilesHandler(_files); - e.preventDefault(); + if (!suggestionsContainerElement) { + if ( + !$mobile || + !( + 'ontouchstart' in window || + navigator.maxTouchPoints > 0 || + navigator.msMaxTouchPoints > 0 + ) + ) { + if (inOrNearComposition(e)) { + return; } - } else if (item.type === 'text/plain') { - if (($settings?.largeTextAsFile ?? false) && !shiftKey) { - const text = clipboardData.getData('text/plain'); - if (text.length > PASTED_TEXT_CHARACTER_LIMIT) { - e.preventDefault(); - const blob = new Blob([text], { type: 'text/plain' }); - const file = new File( - [blob], - `Pasted_Text_${Date.now()}.txt`, - { - type: 'text/plain' - } - ); + // Uses keyCode '13' for Enter key for chinese/japanese keyboards. + // + // Depending on the user's settings, it will send the message + // either when Enter is pressed or when Ctrl+Enter is pressed. + const enterPressed = + ($settings?.ctrlEnterToSend ?? false) + ? (e.key === 'Enter' || e.keyCode === 13) && isCtrlPressed + : (e.key === 'Enter' || e.keyCode === 13) && !e.shiftKey; - await uploadFileHandler(file, true); + if (enterPressed) { + e.preventDefault(); + if (prompt !== '' || files.length > 0) { + dispatch('submit', prompt); } } } } - } - }} - /> - {/key} -
- {:else} -