diff --git a/CHANGELOG.md b/CHANGELOG.md index 2be2e872f..842276c28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,62 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.5] - 2024-11-26 + +### Added + +- **🎨 Model Order/Defaults Reintroduced**: Brought back the ability to set model order and default models, now configurable via Admin Settings > Models > Configure (Gear Icon). + +### Fixed + +- **🔍 Query Generation Issue**: Resolved an error in web search query generation, enhancing search accuracy and ensuring smoother search workflows. +- **📏 Textarea Auto Height Bug**: Fixed a layout issue where textarea input height was shifting unpredictably, particularly when editing system prompts. +- **🔑 Ollama Authentication**: Corrected an issue with Ollama’s authorization headers, guaranteeing reliable authentication across all endpoints. +- **⚙️ Missing Min_P Save**: Resolved an issue where the 'min_p' parameter was not being saved in configurations. +- **🛠️ Tools Description**: Fixed a key issue that omitted tool descriptions in tools payload. + +## [0.4.4] - 2024-11-22 + +### Added + +- **🌐 Translation Updates**: Refreshed Catalan, Brazilian Portuguese, German, and Ukrainian translations, further enhancing the platform's accessibility and improving the experience for international users. + +### Fixed + +- **📱 Mobile Controls Visibility**: Resolved an issue where the controls button was not displaying on the new chats page for mobile users, ensuring smoother navigation and functionality on smaller screens. +- **📷 LDAP Profile Image Issue**: Fixed an LDAP integration bug related to profile images, ensuring seamless authentication and a reliable login experience for users. +- **⏳ RAG Query Generation Issue**: Addressed a significant problem where RAG query generation occurred unnecessarily without attached files, drastically improving speed and reducing delays during chat completions. + +### Changed + +- **⚙️ Legacy Event Emitter Support**: Reintroduced compatibility with legacy "citation" types for event emitters in tools and functions, providing smoother workflows and broader tool support for users. + +## [0.4.3] - 2024-11-21 + +### Added + +- **📚 Inline Citations for RAG Results**: Get seamless inline citations for Retrieval-Augmented Generation (RAG) responses using the default RAG prompt. Note: This feature only supports newly uploaded files, improving traceability and providing source clarity. +- **🎨 Better Rich Text Input Support**: Enjoy smoother and more reliable rich text formatting for chats, enhancing communication quality. +- **⚡ Faster Model Retrieval**: Implemented caching optimizations for faster model loading, providing a noticeable speed boost across workflows. Further improvements are on the way! + +### Fixed + +- **🔗 Pipelines Feature Restored**: Resolved a critical issue that previously prevented Pipelines from functioning, ensuring seamless workflows. +- **✏️ Missing Suffix Field in Ollama Form**: Added the missing "suffix" field to the Ollama generate form, enhancing customization options. + +### Changed + +- **🗂️ Renamed "Citations" to "Sources"**: Improved clarity and consistency by renaming the "citations" field to "sources" in messages. + +## [0.4.2] - 2024-11-20 + +### Fixed + +- **📁 Knowledge Files Visibility Issue**: Resolved the bug preventing individual files in knowledge collections from displaying when referenced with '#'. +- **🔗 OpenAI Endpoint Prefix**: Fixed the issue where certain OpenAI connections that deviate from the official API spec weren’t working correctly with prefixes. +- **⚔️ Arena Model Access Control**: Corrected an issue where arena model access control settings were not being saved. +- **🔧 Usage Capability Selector**: Fixed the broken usage capabilities selector in the model editor. + ## [0.4.1] - 2024-11-19 ### Added diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 37ac5263c..b1c7b56a3 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -2,76 +2,98 @@ ## Our Pledge -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, religion, or sexual identity -and orientation. +As members, contributors, and leaders of this community, we pledge to make participation in our open-source project a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. -We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. +We are committed to creating and maintaining an open, respectful, and professional environment where positive contributions and meaningful discussions can flourish. By participating in this project, you agree to uphold these values and align your behavior to the standards outlined in this Code of Conduct. + +## Why These Standards Are Important + +Open-source projects rely on a community of volunteers dedicating their time, expertise, and effort toward a shared goal. These projects are inherently collaborative but also fragile, as the success of the project depends on the goodwill, energy, and productivity of those involved. + +Maintaining a positive and respectful environment is essential to safeguarding the integrity of this project and protecting contributors' efforts. Behavior that disrupts this atmosphere—whether through hostility, entitlement, or unprofessional conduct—can severely harm the morale and productivity of the community. **Strict enforcement of these standards ensures a safe and supportive space for meaningful collaboration.** + +This is a community where **respect and professionalism are mandatory.** Violations of these standards will result in **zero tolerance** and immediate enforcement to prevent disruption and ensure the well-being of all participants. ## Our Standards -Examples of behavior that contribute to a positive environment for our community include: +Examples of behavior that contribute to a positive and professional community include: -- Demonstrating empathy and kindness toward other people -- Being respectful of differing opinions, viewpoints, and experiences -- Giving and gracefully accepting constructive feedback -- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience -- Focusing on what is best not just for us as individuals, but for the overall community +- **Respecting others.** Be considerate, listen actively, and engage with empathy toward others' viewpoints and experiences. +- **Constructive feedback.** Provide actionable, thoughtful, and respectful feedback that helps improve the project and encourages collaboration. Avoid unproductive negativity or hypercriticism. +- **Recognizing volunteer contributions.** Appreciate that contributors dedicate their free time and resources selflessly. Approach them with gratitude and patience. +- **Focusing on shared goals.** Collaborate in ways that prioritize the health, success, and sustainability of the community over individual agendas. Examples of unacceptable behavior include: -- The use of sexualized language or imagery, and sexual attention or advances of any kind -- Trolling, insulting or derogatory comments, and personal or political attacks -- Public or private harassment -- Publishing others' private information, such as a physical or email address, without their explicit permission -- **Spamming of any kind** -- Aggressive sales tactics targeting our community members are strictly prohibited. You can mention your product if it's relevant to the discussion, but under no circumstances should you push it forcefully -- Other conduct which could reasonably be considered inappropriate in a professional setting +- The use of discriminatory, demeaning, or sexualized language or behavior. +- Personal attacks, derogatory comments, trolling, or inflammatory political or ideological arguments. +- Harassment, intimidation, or any behavior intended to create a hostile, uncomfortable, or unsafe environment. +- Publishing others' private information (e.g., physical or email addresses) without explicit permission. +- **Entitlement, demand, or aggression toward contributors.** Volunteers are under no obligation to provide immediate or personalized support. Rude or dismissive behavior will not be tolerated. +- **Unproductive or destructive behavior.** This includes venting frustration as hostility ("tantrums"), hypercriticism, attention-seeking negativity, or anything that distracts from the project's goals. +- **Spamming and promotional exploitation.** Sharing irrelevant product promotions or self-promotion in the community is not allowed unless it directly contributes value to the discussion. + +### Feedback and Community Engagement + +- **Constructive feedback is encouraged, but hostile or entitled behavior will result in immediate action.** If you disagree with elements of the project, we encourage you to offer meaningful improvements or fork the project if necessary. Healthy discussions and technical disagreements are welcome only when handled with professionalism. +- **Respect contributors' time and efforts.** No one is entitled to personalized or on-demand assistance. This is a community built on collaboration and shared effort; demanding or demeaning behavior undermines that trust and will not be allowed. + +### Zero Tolerance: No Warnings, Immediate Action + +This community operates under a **zero-tolerance policy.** Any behavior deemed unacceptable under this Code of Conduct will result in **immediate enforcement, without prior warning.** + +We employ this approach to ensure that unproductive or disruptive behavior does not escalate further or cause unnecessary harm to other contributors. The standards are clear, and violations of any kind—whether mild or severe—will be addressed decisively to protect the community. ## Enforcement Responsibilities -Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. +Community leaders are responsible for upholding and enforcing these standards. They are empowered to take **immediate and appropriate action** to address any behaviors they deem unacceptable under this Code of Conduct. These actions are taken with the goal of protecting the community and preserving its safe, positive, and productive environment. ## Scope -This Code of Conduct applies within all community spaces and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. +This Code of Conduct applies to all community spaces, including forums, repositories, social media accounts, and in-person events. It also applies when an individual represents the community in public settings, such as conferences or official communications. -## Enforcement +Additionally, any behavior outside of these defined spaces that negatively impacts the community or its members may fall within the scope of this Code of Conduct. -Instances of abusive, harassing, spamming, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at hello@openwebui.com. All complaints will be reviewed and investigated promptly and fairly. +## Reporting Violations -All community leaders are obligated to respect the privacy and security of the reporter of any incident. +Instances of unacceptable behavior can be reported to the leadership team at **hello@openwebui.com**. Reports will be handled promptly, confidentially, and with consideration for the safety and well-being of the reporter. + +All community leaders are required to uphold confidentiality and impartiality when addressing reports of violations. ## Enforcement Guidelines -Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: +### Ban -### 1. Temporary Ban +**Community Impact**: Community leaders will issue a ban to any participant whose behavior is deemed unacceptable according to this Code of Conduct. Bans are enforced immediately and without prior notice. -**Community Impact**: Any violation of community standards, including but not limited to inappropriate language, unprofessional behavior, harassment, or spamming. +A ban may be temporary or permanent, depending on the severity of the violation. This includes—but is not limited to—behavior such as: -**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. +- Harassment or abusive behavior toward contributors. +- Persistent negativity or hostility that disrupts the collaborative environment. +- Disrespectful, demanding, or aggressive interactions with others. +- Attempts to cause harm or sabotage the community. -### 2. Permanent Ban +**Consequence**: A banned individual is immediately removed from access to all community spaces, communication channels, and events. Community leaders reserve the right to enforce either a time-limited suspension or a permanent ban based on the specific circumstances of the violation. -**Community Impact**: Repeated or severe violations of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. +This approach ensures that disruptive behaviors are addressed swiftly and decisively in order to maintain the integrity and productivity of the community. -**Consequence**: A permanent ban from any sort of public interaction within the community. +## Why Zero Tolerance Is Necessary + +Open-source projects thrive on collaboration, goodwill, and mutual respect. Toxic behaviors—such as entitlement, hostility, or persistent negativity—threaten not just individual contributors but the health of the project as a whole. Allowing such behaviors to persist robs contributors of their time, energy, and enthusiasm for the work they do. + +By enforcing a zero-tolerance policy, we ensure that the community remains a safe, welcoming space for all participants. These measures are not about harshness—they are about protecting contributors and fostering a productive environment where innovation can thrive. + +Our expectations are clear, and our enforcement reflects our commitment to this project's long-term success. ## Attribution -This Code of Conduct is adapted from the [Contributor Covenant][homepage], -version 2.0, available at +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. -Community Impact Guidelines were inspired by [Mozilla's code of conduct -enforcement ladder](https://github.com/mozilla/diversity). +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org -For answers to common questions about this code of conduct, see the FAQ at -https://www.contributor-covenant.org/faq. Translations are available at +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. diff --git a/README.md b/README.md index 9d61077bd..4ac495a47 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Open WebUI is an [extensible](https://github.com/open-webui/pipelines), feature- - 🤝 **Ollama/OpenAI API Integration**: Effortlessly integrate OpenAI-compatible APIs for versatile conversations alongside Ollama models. Customize the OpenAI API URL to link with **LMStudio, GroqCloud, Mistral, OpenRouter, and more**. -- 🧩 **Pipelines, Open WebUI Plugin Support**: Seamlessly integrate custom logic and Python libraries into Open WebUI using [Pipelines Plugin Framework](https://github.com/open-webui/pipelines). Launch your Pipelines instance, set the OpenAI URL to the Pipelines URL, and explore endless possibilities. [Examples](https://github.com/open-webui/pipelines/tree/main/examples) include **Function Calling**, User **Rate Limiting** to control access, **Usage Monitoring** with tools like Langfuse, **Live Translation with LibreTranslate** for multilingual support, **Toxic Message Filtering** and much more. +- 🛡️ **Granular Permissions and User Groups**: By allowing administrators to create detailed user roles and permissions, we ensure a secure user environment. This granularity not only enhances security but also allows for customized user experiences, fostering a sense of ownership and responsibility amongst users. - 📱 **Responsive Design**: Enjoy a seamless experience across Desktop PC, Laptop, and Mobile devices. @@ -49,6 +49,8 @@ Open WebUI is an [extensible](https://github.com/open-webui/pipelines), feature- - 🌐🌍 **Multilingual Support**: Experience Open WebUI in your preferred language with our internationalization (i18n) support. Join us in expanding our supported languages! We're actively seeking contributors! +- 🧩 **Pipelines, Open WebUI Plugin Support**: Seamlessly integrate custom logic and Python libraries into Open WebUI using [Pipelines Plugin Framework](https://github.com/open-webui/pipelines). Launch your Pipelines instance, set the OpenAI URL to the Pipelines URL, and explore endless possibilities. [Examples](https://github.com/open-webui/pipelines/tree/main/examples) include **Function Calling**, User **Rate Limiting** to control access, **Usage Monitoring** with tools like Langfuse, **Live Translation with LibreTranslate** for multilingual support, **Toxic Message Filtering** and much more. + - 🌟 **Continuous Updates**: We are committed to improving Open WebUI with regular updates, fixes, and new features. Want to learn more about Open WebUI's features? Check out our [Open WebUI documentation](https://docs.openwebui.com/features) for a comprehensive overview! diff --git a/backend/open_webui/apps/audio/main.py b/backend/open_webui/apps/audio/main.py index 384bb3cd4..5c24c2633 100644 --- a/backend/open_webui/apps/audio/main.py +++ b/backend/open_webui/apps/audio/main.py @@ -8,6 +8,8 @@ from pathlib import Path from pydub import AudioSegment from pydub.silence import split_on_silence +import aiohttp +import aiofiles import requests from open_webui.config import ( AUDIO_STT_ENGINE, @@ -292,46 +294,39 @@ async def speech(request: Request, user=Depends(get_verified_user)): except Exception: pass - r = None try: - r = requests.post( - url=f"{app.state.config.TTS_OPENAI_API_BASE_URL}/audio/speech", - data=body, - headers=headers, - stream=True, - ) + async with aiohttp.ClientSession() as session: + async with session.post( + url=f"{app.state.config.TTS_OPENAI_API_BASE_URL}/audio/speech", + data=body, + headers=headers, + ) as r: + r.raise_for_status() + async with aiofiles.open(file_path, "wb") as f: + await f.write(await r.read()) - r.raise_for_status() + async with aiofiles.open(file_body_path, "w") as f: + await f.write(json.dumps(json.loads(body.decode("utf-8")))) - # Save the streaming content to a file - with open(file_path, "wb") as f: - for chunk in r.iter_content(chunk_size=8192): - f.write(chunk) - - with open(file_body_path, "w") as f: - json.dump(json.loads(body.decode("utf-8")), f) - - # Return the saved file return FileResponse(file_path) except Exception as e: log.exception(e) error_detail = "Open WebUI: Server Connection Error" - if r is not None: - try: - res = r.json() + try: + if r.status != 200: + res = await r.json() if "error" in res: error_detail = f"External: {res['error']['message']}" - except Exception: - error_detail = f"External: {e}" + except Exception: + error_detail = f"External: {e}" raise HTTPException( - status_code=r.status_code if r != None else 500, + status_code=getattr(r, "status", 500), detail=error_detail, ) elif app.state.config.TTS_ENGINE == "elevenlabs": - payload = None try: payload = json.loads(body.decode("utf-8")) except Exception as e: @@ -339,7 +334,6 @@ async def speech(request: Request, user=Depends(get_verified_user)): raise HTTPException(status_code=400, detail="Invalid JSON payload") voice_id = payload.get("voice", "") - if voice_id not in get_available_voices(): raise HTTPException( status_code=400, @@ -347,13 +341,11 @@ async def speech(request: Request, user=Depends(get_verified_user)): ) url = f"https://api.elevenlabs.io/v1/text-to-speech/{voice_id}" - headers = { "Accept": "audio/mpeg", "Content-Type": "application/json", "xi-api-key": app.state.config.TTS_API_KEY, } - data = { "text": payload["input"], "model_id": app.state.config.TTS_MODEL, @@ -361,39 +353,34 @@ async def speech(request: Request, user=Depends(get_verified_user)): } try: - r = requests.post(url, json=data, headers=headers) + async with aiohttp.ClientSession() as session: + async with session.post(url, json=data, headers=headers) as r: + r.raise_for_status() + async with aiofiles.open(file_path, "wb") as f: + await f.write(await r.read()) - r.raise_for_status() + async with aiofiles.open(file_body_path, "w") as f: + await f.write(json.dumps(json.loads(body.decode("utf-8")))) - # Save the streaming content to a file - with open(file_path, "wb") as f: - for chunk in r.iter_content(chunk_size=8192): - f.write(chunk) - - with open(file_body_path, "w") as f: - json.dump(json.loads(body.decode("utf-8")), f) - - # Return the saved file return FileResponse(file_path) except Exception as e: log.exception(e) error_detail = "Open WebUI: Server Connection Error" - if r is not None: - try: - res = r.json() + try: + if r.status != 200: + res = await r.json() if "error" in res: error_detail = f"External: {res['error']['message']}" - except Exception: - error_detail = f"External: {e}" + except Exception: + error_detail = f"External: {e}" raise HTTPException( - status_code=r.status_code if r != None else 500, + status_code=getattr(r, "status", 500), detail=error_detail, ) elif app.state.config.TTS_ENGINE == "azure": - payload = None try: payload = json.loads(body.decode("utf-8")) except Exception as e: @@ -416,17 +403,20 @@ async def speech(request: Request, user=Depends(get_verified_user)): {payload["input"]} """ - response = requests.post(url, headers=headers, data=data) - - if response.status_code == 200: - with open(file_path, "wb") as f: - f.write(response.content) - return FileResponse(file_path) - else: - log.error(f"Error synthesizing speech - {response.reason}") - raise HTTPException( - status_code=500, detail=f"Error synthesizing speech - {response.reason}" - ) + try: + async with aiohttp.ClientSession() as session: + async with session.post(url, headers=headers, data=data) as response: + if response.status == 200: + async with aiofiles.open(file_path, "wb") as f: + await f.write(await response.read()) + return FileResponse(file_path) + else: + error_msg = f"Error synthesizing speech - {response.reason}" + log.error(error_msg) + raise HTTPException(status_code=500, detail=error_msg) + except Exception as e: + log.exception(e) + raise HTTPException(status_code=500, detail=str(e)) elif app.state.config.TTS_ENGINE == "transformers": payload = None try: diff --git a/backend/open_webui/apps/ollama/main.py b/backend/open_webui/apps/ollama/main.py index e051d6646..0ac1f0401 100644 --- a/backend/open_webui/apps/ollama/main.py +++ b/backend/open_webui/apps/ollama/main.py @@ -9,6 +9,8 @@ from typing import Optional, Union from urllib.parse import urlparse import aiohttp +from aiocache import cached + import requests from open_webui.apps.webui.models.models import Models from open_webui.config import ( @@ -193,7 +195,10 @@ async def post_streaming_url( trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) ) - api_config = app.state.config.OLLAMA_API_CONFIGS.get(url, {}) + parsed_url = urlparse(url) + base_url = f"{parsed_url.scheme}://{parsed_url.netloc}" + + api_config = app.state.config.OLLAMA_API_CONFIGS.get(base_url, {}) key = api_config.get("key", None) headers = {"Content-Type": "application/json"} @@ -208,13 +213,13 @@ async def post_streaming_url( r.raise_for_status() if stream: - headers = dict(r.headers) + response_headers = dict(r.headers) if content_type: - headers["Content-Type"] = content_type + response_headers["Content-Type"] = content_type return StreamingResponse( r.content, status_code=r.status, - headers=headers, + headers=response_headers, background=BackgroundTask( cleanup_response, response=r, session=session ), @@ -256,6 +261,7 @@ def merge_models_lists(model_lists): return list(merged_models.values()) +@cached(ttl=3) async def get_all_models(): log.info("get_all_models()") if app.state.config.ENABLE_OLLAMA_API: @@ -295,8 +301,6 @@ async def get_all_models(): for model in response.get("models", []): model["model"] = f"{prefix_id}.{model['model']}" - print(responses) - models = { "models": merge_models_lists( map( @@ -323,7 +327,10 @@ async def get_ollama_tags( else: url = app.state.config.OLLAMA_BASE_URLS[url_idx] - api_config = app.state.config.OLLAMA_API_CONFIGS.get(url, {}) + parsed_url = urlparse(url) + base_url = f"{parsed_url.scheme}://{parsed_url.netloc}" + + api_config = app.state.config.OLLAMA_API_CONFIGS.get(base_url, {}) key = api_config.get("key", None) headers = {} @@ -524,7 +531,10 @@ async def copy_model( url = app.state.config.OLLAMA_BASE_URLS[url_idx] log.info(f"url: {url}") - api_config = app.state.config.OLLAMA_API_CONFIGS.get(url, {}) + parsed_url = urlparse(url) + base_url = f"{parsed_url.scheme}://{parsed_url.netloc}" + + api_config = app.state.config.OLLAMA_API_CONFIGS.get(base_url, {}) key = api_config.get("key", None) headers = {"Content-Type": "application/json"} @@ -583,7 +593,10 @@ async def delete_model( url = app.state.config.OLLAMA_BASE_URLS[url_idx] log.info(f"url: {url}") - api_config = app.state.config.OLLAMA_API_CONFIGS.get(url, {}) + parsed_url = urlparse(url) + base_url = f"{parsed_url.scheme}://{parsed_url.netloc}" + + api_config = app.state.config.OLLAMA_API_CONFIGS.get(base_url, {}) key = api_config.get("key", None) headers = {"Content-Type": "application/json"} @@ -634,7 +647,10 @@ async def show_model_info(form_data: ModelNameForm, user=Depends(get_verified_us url = app.state.config.OLLAMA_BASE_URLS[url_idx] log.info(f"url: {url}") - api_config = app.state.config.OLLAMA_API_CONFIGS.get(url, {}) + parsed_url = urlparse(url) + base_url = f"{parsed_url.scheme}://{parsed_url.netloc}" + + api_config = app.state.config.OLLAMA_API_CONFIGS.get(base_url, {}) key = api_config.get("key", None) headers = {"Content-Type": "application/json"} @@ -729,7 +745,10 @@ async def generate_ollama_embeddings( url = app.state.config.OLLAMA_BASE_URLS[url_idx] log.info(f"url: {url}") - api_config = app.state.config.OLLAMA_API_CONFIGS.get(url, {}) + parsed_url = urlparse(url) + base_url = f"{parsed_url.scheme}://{parsed_url.netloc}" + + api_config = app.state.config.OLLAMA_API_CONFIGS.get(base_url, {}) key = api_config.get("key", None) headers = {"Content-Type": "application/json"} @@ -796,7 +815,10 @@ async def generate_ollama_batch_embeddings( url = app.state.config.OLLAMA_BASE_URLS[url_idx] log.info(f"url: {url}") - api_config = app.state.config.OLLAMA_API_CONFIGS.get(url, {}) + parsed_url = urlparse(url) + base_url = f"{parsed_url.scheme}://{parsed_url.netloc}" + + api_config = app.state.config.OLLAMA_API_CONFIGS.get(base_url, {}) key = api_config.get("key", None) headers = {"Content-Type": "application/json"} @@ -837,6 +859,7 @@ async def generate_ollama_batch_embeddings( class GenerateCompletionForm(BaseModel): model: str prompt: str + suffix: Optional[str] = None images: Optional[list[str]] = None format: Optional[str] = None options: Optional[dict] = None @@ -972,7 +995,10 @@ async def generate_chat_completion( log.info(f"url: {url}") log.debug(f"generate_chat_completion() - 2.payload = {payload}") - api_config = app.state.config.OLLAMA_API_CONFIGS.get(url, {}) + parsed_url = urlparse(url) + base_url = f"{parsed_url.scheme}://{parsed_url.netloc}" + + api_config = app.state.config.OLLAMA_API_CONFIGS.get(base_url, {}) prefix_id = api_config.get("prefix_id", None) if prefix_id: payload["model"] = payload["model"].replace(f"{prefix_id}.", "") diff --git a/backend/open_webui/apps/openai/main.py b/backend/open_webui/apps/openai/main.py index 42f4388f5..31c36a8a1 100644 --- a/backend/open_webui/apps/openai/main.py +++ b/backend/open_webui/apps/openai/main.py @@ -6,7 +6,10 @@ from pathlib import Path from typing import Literal, Optional, overload import aiohttp +from aiocache import cached import requests + + from open_webui.apps.webui.models.models import Models from open_webui.config import ( CACHE_DIR, @@ -302,6 +305,8 @@ async def get_all_models_responses() -> list: } tasks.append(asyncio.ensure_future(asyncio.sleep(0, model_list))) + else: + tasks.append(asyncio.ensure_future(asyncio.sleep(0, None))) responses = await asyncio.gather(*tasks) @@ -323,6 +328,7 @@ async def get_all_models_responses() -> list: return responses +@cached(ttl=3) async def get_all_models() -> dict[str, list]: log.info("get_all_models()") @@ -579,8 +585,6 @@ async def generate_chat_completion( # Convert the modified body back to JSON payload = json.dumps(payload) - log.debug(payload) - headers = {} headers["Authorization"] = f"Bearer {key}" headers["Content-Type"] = "application/json" diff --git a/backend/open_webui/apps/retrieval/main.py b/backend/open_webui/apps/retrieval/main.py index 776fb98de..63bc18190 100644 --- a/backend/open_webui/apps/retrieval/main.py +++ b/backend/open_webui/apps/retrieval/main.py @@ -29,6 +29,7 @@ from open_webui.apps.retrieval.loaders.youtube import YoutubeLoader from open_webui.apps.retrieval.web.main import SearchResult from open_webui.apps.retrieval.web.utils import get_web_loader from open_webui.apps.retrieval.web.brave import search_brave +from open_webui.apps.retrieval.web.mojeek import search_mojeek from open_webui.apps.retrieval.web.duckduckgo import search_duckduckgo from open_webui.apps.retrieval.web.google_pse import search_google_pse from open_webui.apps.retrieval.web.jina_search import search_jina @@ -53,6 +54,7 @@ from open_webui.apps.retrieval.utils import ( from open_webui.apps.webui.models.files import Files from open_webui.config import ( BRAVE_SEARCH_API_KEY, + MOJEEK_SEARCH_API_KEY, TIKTOKEN_ENCODING_NAME, RAG_TEXT_SPLITTER, CHUNK_OVERLAP, @@ -180,6 +182,7 @@ app.state.config.SEARXNG_QUERY_URL = SEARXNG_QUERY_URL app.state.config.GOOGLE_PSE_API_KEY = GOOGLE_PSE_API_KEY app.state.config.GOOGLE_PSE_ENGINE_ID = GOOGLE_PSE_ENGINE_ID app.state.config.BRAVE_SEARCH_API_KEY = BRAVE_SEARCH_API_KEY +app.state.config.MOJEEK_SEARCH_API_KEY = MOJEEK_SEARCH_API_KEY app.state.config.SERPSTACK_API_KEY = SERPSTACK_API_KEY app.state.config.SERPSTACK_HTTPS = SERPSTACK_HTTPS app.state.config.SERPER_API_KEY = SERPER_API_KEY @@ -478,6 +481,7 @@ async def get_rag_config(user=Depends(get_admin_user)): "google_pse_api_key": app.state.config.GOOGLE_PSE_API_KEY, "google_pse_engine_id": app.state.config.GOOGLE_PSE_ENGINE_ID, "brave_search_api_key": app.state.config.BRAVE_SEARCH_API_KEY, + "mojeek_search_api_key": app.state.config.MOJEEK_SEARCH_API_KEY, "serpstack_api_key": app.state.config.SERPSTACK_API_KEY, "serpstack_https": app.state.config.SERPSTACK_HTTPS, "serper_api_key": app.state.config.SERPER_API_KEY, @@ -523,6 +527,7 @@ class WebSearchConfig(BaseModel): google_pse_api_key: Optional[str] = None google_pse_engine_id: Optional[str] = None brave_search_api_key: Optional[str] = None + mojeek_search_api_key: Optional[str] = None serpstack_api_key: Optional[str] = None serpstack_https: Optional[bool] = None serper_api_key: Optional[str] = None @@ -593,6 +598,9 @@ async def update_rag_config(form_data: ConfigUpdateForm, user=Depends(get_admin_ app.state.config.BRAVE_SEARCH_API_KEY = ( form_data.web.search.brave_search_api_key ) + app.state.config.MOJEEK_SEARCH_API_KEY = ( + form_data.web.search.mojeek_search_api_key + ) app.state.config.SERPSTACK_API_KEY = form_data.web.search.serpstack_api_key app.state.config.SERPSTACK_HTTPS = form_data.web.search.serpstack_https app.state.config.SERPER_API_KEY = form_data.web.search.serper_api_key @@ -643,6 +651,7 @@ async def update_rag_config(form_data: ConfigUpdateForm, user=Depends(get_admin_ "google_pse_api_key": app.state.config.GOOGLE_PSE_API_KEY, "google_pse_engine_id": app.state.config.GOOGLE_PSE_ENGINE_ID, "brave_search_api_key": app.state.config.BRAVE_SEARCH_API_KEY, + "mojeek_search_api_key": app.state.config.MOJEEK_SEARCH_API_KEY, "serpstack_api_key": app.state.config.SERPSTACK_API_KEY, "serpstack_https": app.state.config.SERPSTACK_HTTPS, "serper_api_key": app.state.config.SERPER_API_KEY, @@ -884,19 +893,17 @@ def process_file( # Update the content in the file # Usage: /files/{file_id}/data/content/update - VECTOR_DB_CLIENT.delete( - collection_name=f"file-{file.id}", - filter={"file_id": file.id}, - ) + VECTOR_DB_CLIENT.delete_collection(collection_name=f"file-{file.id}") docs = [ Document( page_content=form_data.content, metadata={ - "name": file.meta.get("name", file.filename), + **file.meta, + "name": file.filename, "created_by": file.user_id, "file_id": file.id, - **file.meta, + "source": file.filename, }, ) ] @@ -923,10 +930,11 @@ def process_file( Document( page_content=file.data.get("content", ""), metadata={ - "name": file.meta.get("name", file.filename), + **file.meta, + "name": file.filename, "created_by": file.user_id, "file_id": file.id, - **file.meta, + "source": file.filename, }, ) ] @@ -946,15 +954,30 @@ def process_file( docs = loader.load( file.filename, file.meta.get("content_type"), file_path ) + + docs = [ + Document( + page_content=doc.page_content, + metadata={ + **doc.metadata, + "name": file.filename, + "created_by": file.user_id, + "file_id": file.id, + "source": file.filename, + }, + ) + for doc in docs + ] else: docs = [ Document( page_content=file.data.get("content", ""), metadata={ + **file.meta, "name": file.filename, "created_by": file.user_id, "file_id": file.id, - **file.meta, + "source": file.filename, }, ) ] @@ -975,7 +998,7 @@ def process_file( collection_name=collection_name, metadata={ "file_id": file.id, - "name": file.meta.get("name", file.filename), + "name": file.filename, "hash": hash, }, add=(True if form_data.collection_name else False), @@ -992,7 +1015,7 @@ def process_file( return { "status": True, "collection_name": collection_name, - "filename": file.meta.get("name", file.filename), + "filename": file.filename, "content": text_content, } except Exception as e: @@ -1131,6 +1154,7 @@ def search_web(engine: str, query: str) -> list[SearchResult]: - SEARXNG_QUERY_URL - GOOGLE_PSE_API_KEY + GOOGLE_PSE_ENGINE_ID - BRAVE_SEARCH_API_KEY + - MOJEEK_SEARCH_API_KEY - SERPSTACK_API_KEY - SERPER_API_KEY - SERPLY_API_KEY @@ -1177,6 +1201,16 @@ def search_web(engine: str, query: str) -> list[SearchResult]: ) else: raise Exception("No BRAVE_SEARCH_API_KEY found in environment variables") + elif engine == "mojeek": + if app.state.config.MOJEEK_SEARCH_API_KEY: + return search_mojeek( + app.state.config.MOJEEK_SEARCH_API_KEY, + query, + app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, + app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST, + ) + else: + raise Exception("No MOJEEK_SEARCH_API_KEY found in environment variables") elif engine == "serpstack": if app.state.config.SERPSTACK_API_KEY: return search_serpstack( diff --git a/backend/open_webui/apps/retrieval/utils.py b/backend/open_webui/apps/retrieval/utils.py index 6d87c98e3..35159f80d 100644 --- a/backend/open_webui/apps/retrieval/utils.py +++ b/backend/open_webui/apps/retrieval/utils.py @@ -15,8 +15,6 @@ from open_webui.apps.retrieval.vector.connector import VECTOR_DB_CLIENT from open_webui.utils.misc import get_last_user_message from open_webui.env import SRC_LOG_LEVELS -from open_webui.config import DEFAULT_RAG_TEMPLATE - log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["RAG"]) @@ -238,44 +236,6 @@ def query_collection_with_hybrid_search( return merge_and_sort_query_results(results, k=k, reverse=True) -def rag_template(template: str, context: str, query: str): - if template == "": - template = DEFAULT_RAG_TEMPLATE - - if "[context]" not in template and "{{CONTEXT}}" not in template: - log.debug( - "WARNING: The RAG template does not contain the '[context]' or '{{CONTEXT}}' placeholder." - ) - - if "" in context and "" in context: - log.debug( - "WARNING: Potential prompt injection attack: the RAG " - "context contains '' and ''. This might be " - "nothing, or the user might be trying to hack something." - ) - - query_placeholders = [] - if "[query]" in context: - query_placeholder = "{{QUERY" + str(uuid.uuid4()) + "}}" - template = template.replace("[query]", query_placeholder) - query_placeholders.append(query_placeholder) - - if "{{QUERY}}" in context: - query_placeholder = "{{QUERY" + str(uuid.uuid4()) + "}}" - template = template.replace("{{QUERY}}", query_placeholder) - query_placeholders.append(query_placeholder) - - template = template.replace("[context]", context) - template = template.replace("{{CONTEXT}}", context) - template = template.replace("[query]", query) - template = template.replace("{{QUERY}}", query) - - for query_placeholder in query_placeholders: - template = template.replace(query_placeholder, query) - - return template - - def get_embedding_function( embedding_engine, embedding_model, @@ -307,7 +267,7 @@ def get_embedding_function( return lambda query: generate_multiple(query, func) -def get_rag_context( +def get_sources_from_files( files, queries, embedding_function, @@ -387,43 +347,24 @@ def get_rag_context( del file["data"] relevant_contexts.append({**context, "file": file}) - contexts = [] - citations = [] + sources = [] for context in relevant_contexts: try: if "documents" in context: - file_names = list( - set( - [ - metadata["name"] - for metadata in context["metadatas"][0] - if metadata is not None and "name" in metadata - ] - ) - ) - contexts.append( - ((", ".join(file_names) + ":\n\n") if file_names else "") - + "\n\n".join( - [text for text in context["documents"][0] if text is not None] - ) - ) - if "metadatas" in context: - citation = { + source = { "source": context["file"], "document": context["documents"][0], "metadata": context["metadatas"][0], } if "distances" in context and context["distances"]: - citation["distances"] = context["distances"][0] - citations.append(citation) + source["distances"] = context["distances"][0] + + sources.append(source) except Exception as e: log.exception(e) - print("contexts", contexts) - print("citations", citations) - - return contexts, citations + return sources def get_model_path(model: str, update_model: bool = False): @@ -502,7 +443,6 @@ def generate_ollama_batch_embeddings( r.raise_for_status() data = r.json() - print(data) if "embeddings" in data: return data["embeddings"] else: diff --git a/backend/open_webui/apps/retrieval/web/mojeek.py b/backend/open_webui/apps/retrieval/web/mojeek.py new file mode 100644 index 000000000..f257c92aa --- /dev/null +++ b/backend/open_webui/apps/retrieval/web/mojeek.py @@ -0,0 +1,40 @@ +import logging +from typing import Optional + +import requests +from open_webui.apps.retrieval.web.main import SearchResult, get_filtered_results +from open_webui.env import SRC_LOG_LEVELS + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + + +def search_mojeek( + api_key: str, query: str, count: int, filter_list: Optional[list[str]] = None +) -> list[SearchResult]: + """Search using Mojeek's Search API and return the results as a list of SearchResult objects. + + Args: + api_key (str): A Mojeek Search API key + query (str): The query to search for + """ + url = "https://api.mojeek.com/search" + headers = { + "Accept": "application/json", + } + params = {"q": query, "api_key": api_key, "fmt": "json", "t": count} + + response = requests.get(url, headers=headers, params=params) + response.raise_for_status() + json_response = response.json() + results = json_response.get("response", {}).get("results", []) + print(results) + if filter_list: + results = get_filtered_results(results, filter_list) + + return [ + SearchResult( + link=result["url"], title=result.get("title"), snippet=result.get("desc") + ) + for result in results + ] diff --git a/backend/open_webui/apps/webui/main.py b/backend/open_webui/apps/webui/main.py index ce4945b69..054c6280e 100644 --- a/backend/open_webui/apps/webui/main.py +++ b/backend/open_webui/apps/webui/main.py @@ -31,6 +31,7 @@ from open_webui.config import ( DEFAULT_MODELS, DEFAULT_PROMPT_SUGGESTIONS, DEFAULT_USER_ROLE, + MODEL_ORDER_LIST, ENABLE_COMMUNITY_SHARING, ENABLE_LOGIN_FORM, ENABLE_MESSAGE_RATING, @@ -68,6 +69,7 @@ from open_webui.config import ( ) from open_webui.env import ( ENV, + SRC_LOG_LEVELS, WEBUI_AUTH_TRUSTED_EMAIL_HEADER, WEBUI_AUTH_TRUSTED_NAME_HEADER, ) @@ -94,6 +96,7 @@ app = FastAPI( ) log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["MAIN"]) app.state.config = AppConfig() @@ -118,6 +121,7 @@ app.state.config.DEFAULT_USER_ROLE = DEFAULT_USER_ROLE app.state.config.USER_PERMISSIONS = USER_PERMISSIONS app.state.config.WEBHOOK_URL = WEBHOOK_URL app.state.config.BANNERS = WEBUI_BANNERS +app.state.config.MODEL_ORDER_LIST = MODEL_ORDER_LIST app.state.config.ENABLE_COMMUNITY_SHARING = ENABLE_COMMUNITY_SHARING app.state.config.ENABLE_MESSAGE_RATING = ENABLE_MESSAGE_RATING @@ -270,7 +274,9 @@ async def get_pipe_models(): log.exception(e) sub_pipes = [] - print(sub_pipes) + log.debug( + f"get_pipe_models: function '{pipe.id}' is a manifold of {sub_pipes}" + ) for p in sub_pipes: sub_pipe_id = f'{pipe.id}.{p["id"]}' @@ -280,6 +286,7 @@ async def get_pipe_models(): sub_pipe_name = f"{function_module.name}{sub_pipe_name}" pipe_flag = {"type": pipe.type} + pipe_models.append( { "id": sub_pipe_id, @@ -293,6 +300,10 @@ async def get_pipe_models(): else: pipe_flag = {"type": "pipe"} + log.debug( + f"get_pipe_models: function '{pipe.id}' is a single pipe {{ 'id': {pipe.id}, 'name': {pipe.name} }}" + ) + pipe_models.append( { "id": pipe.id, @@ -346,7 +357,7 @@ def get_pipe_id(form_data: dict) -> str: pipe_id = form_data["model"] if "." in pipe_id: pipe_id, _ = pipe_id.split(".", 1) - print(pipe_id) + return pipe_id @@ -453,7 +464,7 @@ async def generate_function_chat_completion(form_data, user, models: dict = {}): return except Exception as e: - print(f"Error: {e}") + log.error(f"Error: {e}") yield f"data: {json.dumps({'error': {'detail':str(e)}})}\n\n" return @@ -483,7 +494,7 @@ async def generate_function_chat_completion(form_data, user, models: dict = {}): res = await execute_pipe(pipe, params) except Exception as e: - print(f"Error: {e}") + log.error(f"Error: {e}") return {"error": {"detail": str(e)}} if isinstance(res, StreamingResponse) or isinstance(res, dict): diff --git a/backend/open_webui/apps/webui/routers/auths.py b/backend/open_webui/apps/webui/routers/auths.py index 63ee5e3b0..8f175f366 100644 --- a/backend/open_webui/apps/webui/routers/auths.py +++ b/backend/open_webui/apps/webui/routers/auths.py @@ -238,10 +238,16 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): user = Users.get_user_by_email(mail) if not user: - try: - hashed = get_password_hash(form_data.password) - user = Auths.insert_new_auth(mail, hashed, cn) + role = ( + "admin" + if Users.get_num_users() == 0 + else request.app.state.config.DEFAULT_USER_ROLE + ) + + user = Auths.insert_new_auth( + email=mail, password=str(uuid.uuid4()), name=cn, role=role + ) if not user: raise HTTPException( @@ -253,7 +259,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): except Exception as err: raise HTTPException(500, detail=ERROR_MESSAGES.DEFAULT(err)) - user = Auths.authenticate_user(mail, password=str(form_data.password)) + user = Auths.authenticate_user_by_trusted_header(mail) if user: token = create_token( diff --git a/backend/open_webui/apps/webui/routers/configs.py b/backend/open_webui/apps/webui/routers/configs.py index 1c30b0b3b..b19fc1745 100644 --- a/backend/open_webui/apps/webui/routers/configs.py +++ b/backend/open_webui/apps/webui/routers/configs.py @@ -34,8 +34,32 @@ async def export_config(user=Depends(get_admin_user)): return get_config() -class SetDefaultModelsForm(BaseModel): - models: str +############################ +# SetDefaultModels +############################ +class ModelsConfigForm(BaseModel): + DEFAULT_MODELS: str + MODEL_ORDER_LIST: list[str] + + +@router.get("/models", response_model=ModelsConfigForm) +async def get_models_config(request: Request, user=Depends(get_admin_user)): + return { + "DEFAULT_MODELS": request.app.state.config.DEFAULT_MODELS, + "MODEL_ORDER_LIST": request.app.state.config.MODEL_ORDER_LIST, + } + + +@router.post("/models", response_model=ModelsConfigForm) +async def set_models_config( + request: Request, form_data: ModelsConfigForm, user=Depends(get_admin_user) +): + request.app.state.config.DEFAULT_MODELS = form_data.DEFAULT_MODELS + request.app.state.config.MODEL_ORDER_LIST = form_data.MODEL_ORDER_LIST + return { + "DEFAULT_MODELS": request.app.state.config.DEFAULT_MODELS, + "MODEL_ORDER_LIST": request.app.state.config.MODEL_ORDER_LIST, + } class PromptSuggestion(BaseModel): @@ -47,21 +71,8 @@ class SetDefaultSuggestionsForm(BaseModel): suggestions: list[PromptSuggestion] -############################ -# SetDefaultModels -############################ - - -@router.post("/default/models", response_model=str) -async def set_global_default_models( - request: Request, form_data: SetDefaultModelsForm, user=Depends(get_admin_user) -): - request.app.state.config.DEFAULT_MODELS = form_data.models - return request.app.state.config.DEFAULT_MODELS - - -@router.post("/default/suggestions", response_model=list[PromptSuggestion]) -async def set_global_default_suggestions( +@router.post("/suggestions", response_model=list[PromptSuggestion]) +async def set_default_suggestions( request: Request, form_data: SetDefaultSuggestionsForm, user=Depends(get_admin_user), diff --git a/backend/open_webui/apps/webui/routers/documents.py b/backend/open_webui/apps/webui/routers/documents.py deleted file mode 100644 index c8f27852f..000000000 --- a/backend/open_webui/apps/webui/routers/documents.py +++ /dev/null @@ -1,155 +0,0 @@ -import json -from typing import Optional - -from open_webui.apps.webui.models.documents import ( - DocumentForm, - DocumentResponse, - Documents, - DocumentUpdateForm, -) -from open_webui.constants import ERROR_MESSAGES -from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel -from open_webui.utils.utils import get_admin_user, get_verified_user - -router = APIRouter() - -############################ -# GetDocuments -############################ - - -@router.get("/", response_model=list[DocumentResponse]) -async def get_documents(user=Depends(get_verified_user)): - docs = [ - DocumentResponse( - **{ - **doc.model_dump(), - "content": json.loads(doc.content if doc.content else "{}"), - } - ) - for doc in Documents.get_docs() - ] - return docs - - -############################ -# CreateNewDoc -############################ - - -@router.post("/create", response_model=Optional[DocumentResponse]) -async def create_new_doc(form_data: DocumentForm, user=Depends(get_admin_user)): - doc = Documents.get_doc_by_name(form_data.name) - if doc is None: - doc = Documents.insert_new_doc(user.id, form_data) - - if doc: - return DocumentResponse( - **{ - **doc.model_dump(), - "content": json.loads(doc.content if doc.content else "{}"), - } - ) - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.FILE_EXISTS, - ) - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.NAME_TAG_TAKEN, - ) - - -############################ -# GetDocByName -############################ - - -@router.get("/doc", response_model=Optional[DocumentResponse]) -async def get_doc_by_name(name: str, user=Depends(get_verified_user)): - doc = Documents.get_doc_by_name(name) - - if doc: - return DocumentResponse( - **{ - **doc.model_dump(), - "content": json.loads(doc.content if doc.content else "{}"), - } - ) - else: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=ERROR_MESSAGES.NOT_FOUND, - ) - - -############################ -# TagDocByName -############################ - - -class TagItem(BaseModel): - name: str - - -class TagDocumentForm(BaseModel): - name: str - tags: list[dict] - - -@router.post("/doc/tags", response_model=Optional[DocumentResponse]) -async def tag_doc_by_name(form_data: TagDocumentForm, user=Depends(get_verified_user)): - doc = Documents.update_doc_content_by_name(form_data.name, {"tags": form_data.tags}) - - if doc: - return DocumentResponse( - **{ - **doc.model_dump(), - "content": json.loads(doc.content if doc.content else "{}"), - } - ) - else: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=ERROR_MESSAGES.NOT_FOUND, - ) - - -############################ -# UpdateDocByName -############################ - - -@router.post("/doc/update", response_model=Optional[DocumentResponse]) -async def update_doc_by_name( - name: str, - form_data: DocumentUpdateForm, - user=Depends(get_admin_user), -): - doc = Documents.update_doc_by_name(name, form_data) - if doc: - return DocumentResponse( - **{ - **doc.model_dump(), - "content": json.loads(doc.content if doc.content else "{}"), - } - ) - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.NAME_TAG_TAKEN, - ) - - -############################ -# DeleteDocByName -############################ - - -@router.delete("/doc/delete", response_model=bool) -async def delete_doc_by_name(name: str, user=Depends(get_admin_user)): - result = Documents.delete_doc_by_name(name) - return result diff --git a/backend/open_webui/apps/webui/routers/files.py b/backend/open_webui/apps/webui/routers/files.py index b8695eb67..e7459a15f 100644 --- a/backend/open_webui/apps/webui/routers/files.py +++ b/backend/open_webui/apps/webui/routers/files.py @@ -56,7 +56,7 @@ def upload_file(file: UploadFile = File(...), user=Depends(get_verified_user)): FileForm( **{ "id": id, - "filename": filename, + "filename": name, "path": file_path, "meta": { "name": name, diff --git a/backend/open_webui/apps/webui/routers/tools.py b/backend/open_webui/apps/webui/routers/tools.py index 883c34405..d0523ddac 100644 --- a/backend/open_webui/apps/webui/routers/tools.py +++ b/backend/open_webui/apps/webui/routers/tools.py @@ -1,4 +1,3 @@ -import os from pathlib import Path from typing import Optional @@ -10,7 +9,7 @@ from open_webui.apps.webui.models.tools import ( Tools, ) from open_webui.apps.webui.utils import load_tools_module_by_id, replace_imports -from open_webui.config import CACHE_DIR, DATA_DIR +from open_webui.config import CACHE_DIR from open_webui.constants import ERROR_MESSAGES from fastapi import APIRouter, Depends, HTTPException, Request, status from open_webui.utils.tools import get_tools_specs @@ -300,38 +299,35 @@ async def update_tools_valves_by_id( request: Request, id: str, form_data: dict, user=Depends(get_verified_user) ): tools = Tools.get_tool_by_id(id) - if tools: - if id in request.app.state.TOOLS: - tools_module = request.app.state.TOOLS[id] - else: - tools_module, _ = load_tools_module_by_id(id) - request.app.state.TOOLS[id] = tools_module - - if hasattr(tools_module, "Valves"): - Valves = tools_module.Valves - - try: - form_data = {k: v for k, v in form_data.items() if v is not None} - valves = Valves(**form_data) - Tools.update_tool_valves_by_id(id, valves.model_dump()) - return valves.model_dump() - except Exception as e: - print(e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT(str(e)), - ) - else: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=ERROR_MESSAGES.NOT_FOUND, - ) - - else: + if not tools: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND, ) + if id in request.app.state.TOOLS: + tools_module = request.app.state.TOOLS[id] + else: + tools_module, _ = load_tools_module_by_id(id) + request.app.state.TOOLS[id] = tools_module + + if not hasattr(tools_module, "Valves"): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + Valves = tools_module.Valves + + try: + form_data = {k: v for k, v in form_data.items() if v is not None} + valves = Valves(**form_data) + Tools.update_tool_valves_by_id(id, valves.model_dump()) + return valves.model_dump() + except Exception as e: + print(e) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(str(e)), + ) ############################ diff --git a/backend/open_webui/apps/webui/utils.py b/backend/open_webui/apps/webui/utils.py index 6bfddd072..054158b3e 100644 --- a/backend/open_webui/apps/webui/utils.py +++ b/backend/open_webui/apps/webui/utils.py @@ -5,10 +5,15 @@ import sys from importlib import util import types import tempfile +import logging +from open_webui.env import SRC_LOG_LEVELS from open_webui.apps.webui.models.functions import Functions from open_webui.apps.webui.models.tools import Tools +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["MAIN"]) + def extract_frontmatter(content): """ @@ -95,7 +100,7 @@ def load_tools_module_by_id(toolkit_id, content=None): # Executing the modified content in the created module's namespace exec(content, module.__dict__) frontmatter = extract_frontmatter(content) - print(f"Loaded module: {module.__name__}") + log.info(f"Loaded module: {module.__name__}") # Create and return the object if the class 'Tools' is found in the module if hasattr(module, "Tools"): @@ -103,7 +108,7 @@ def load_tools_module_by_id(toolkit_id, content=None): else: raise Exception("No Tools class found in the module") except Exception as e: - print(f"Error loading module: {toolkit_id}: {e}") + log.error(f"Error loading module: {toolkit_id}: {e}") del sys.modules[module_name] # Clean up raise e finally: @@ -139,7 +144,7 @@ def load_function_module_by_id(function_id, content=None): # Execute the modified content in the created module's namespace exec(content, module.__dict__) frontmatter = extract_frontmatter(content) - print(f"Loaded module: {module.__name__}") + log.info(f"Loaded module: {module.__name__}") # Create appropriate object based on available class type in the module if hasattr(module, "Pipe"): @@ -151,7 +156,7 @@ def load_function_module_by_id(function_id, content=None): else: raise Exception("No Function class found in the module") except Exception as e: - print(f"Error loading module: {function_id}: {e}") + log.error(f"Error loading module: {function_id}: {e}") del sys.modules[module_name] # Cleanup by removing the module in case of error Functions.update_function_by_id(function_id, {"is_active": False}) @@ -164,7 +169,7 @@ def install_frontmatter_requirements(requirements): if requirements: req_list = [req.strip() for req in requirements.split(",")] for req in req_list: - print(f"Installing requirement: {req}") + log.info(f"Installing requirement: {req}") subprocess.check_call([sys.executable, "-m", "pip", "install", req]) else: - print("No requirements found in frontmatter.") + log.info("No requirements found in frontmatter.") diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index a5adbb0f1..3c1ee798d 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -740,6 +740,12 @@ DEFAULT_PROMPT_SUGGESTIONS = PersistentConfig( ], ) +MODEL_ORDER_LIST = PersistentConfig( + "MODEL_ORDER_LIST", + "ui.model_order_list", + [], +) + DEFAULT_USER_ROLE = PersistentConfig( "DEFAULT_USER_ROLE", "ui.default_user_role", @@ -969,19 +975,20 @@ QUERY_GENERATION_PROMPT_TEMPLATE = PersistentConfig( ) DEFAULT_QUERY_GENERATION_PROMPT_TEMPLATE = """### Task: -Based on the chat history, determine whether a search is necessary, and if so, generate a 1-3 broad search queries to retrieve comprehensive and updated information. If no search is required, return an empty list. +Analyze the chat history to determine the necessity of generating search queries. By default, **prioritize generating 1-3 broad and relevant search queries** unless it is absolutely certain that no additional information is required. The aim is to retrieve comprehensive, updated, and valuable information even with minimal uncertainty. If no search is unequivocally needed, return an empty list. ### Guidelines: -- Respond exclusively with a JSON object. -- If a search query is needed, return an object like: { "queries": ["query1", "query2"] } where each query is distinct and concise. -- If no search query is necessary, output should be: { "queries": [] } -- Default to suggesting a search query to ensure accurate and updated information, unless it is definitively clear no search is required. -- Be concise, focusing strictly on composing search queries with no additional commentary or text. -- When in doubt, prefer to suggest a search for comprehensiveness. -- Today's date is: {{CURRENT_DATE}} +- Respond **EXCLUSIVELY** with a JSON object. Any form of extra commentary, explanation, or additional text is strictly prohibited. +- When generating search queries, respond in the format: { "queries": ["query1", "query2"] }, ensuring each query is distinct, concise, and relevant to the topic. +- If and only if it is entirely certain that no useful results can be retrieved by a search, return: { "queries": [] }. +- Err on the side of suggesting search queries if there is **any chance** they might provide useful or updated information. +- Be concise and focused on composing high-quality search queries, avoiding unnecessary elaboration, commentary, or assumptions. +- Assume today's date is: {{CURRENT_DATE}}. +- Always prioritize providing actionable and broad queries that maximize informational coverage. ### Output: -JSON format: { +Strictly return in JSON format: +{ "queries": ["query1", "query2"] } @@ -1181,21 +1188,32 @@ CHUNK_OVERLAP = PersistentConfig( int(os.environ.get("CHUNK_OVERLAP", "100")), ) -DEFAULT_RAG_TEMPLATE = """You are given a user query, some textual context and rules, all inside xml tags. You have to answer the query based on the context while respecting the rules. +DEFAULT_RAG_TEMPLATE = """### Task: +Respond to the user query using the provided context, incorporating inline citations in the format [source_id] **only when the tag is explicitly provided** in the context. + +### Guidelines: +- If you don't know the answer, clearly state that. +- If uncertain, ask the user for clarification. +- Respond in the same language as the user's query. +- If the context is unreadable or of poor quality, inform the user and provide the best possible answer. +- If the answer isn't present in the context but you possess the knowledge, explain this to the user and provide the answer using your own understanding. +- **Only include inline citations using [source_id] when a tag is explicitly provided in the context.** +- Do not cite if the tag is not provided in the context. +- Do not use XML tags in your response. +- Ensure citations are concise and directly related to the information provided. + +### Example of Citation: +If the user asks about a specific topic and the information is found in "whitepaper.pdf" with a provided , the response should include the citation like so: +* "According to the study, the proposed method increases efficiency by 20% [whitepaper.pdf]." +If no is present, the response should omit the citation. + +### Output: +Provide a clear and direct response to the user's query, including inline citations in the format [source_id] only when the tag is present in the context. {{CONTEXT}} - -- If you don't know, just say so. -- If you are not sure, ask for clarification. -- Answer in the same language as the user query. -- If the context appears unreadable or of poor quality, tell the user then answer as best as you can. -- If the answer is not in the context but you think you know the answer, explain that to the user then answer with your own knowledge. -- Answer directly and without using xml tags. - - {{QUERY}} @@ -1290,6 +1308,12 @@ BRAVE_SEARCH_API_KEY = PersistentConfig( os.getenv("BRAVE_SEARCH_API_KEY", ""), ) +MOJEEK_SEARCH_API_KEY = PersistentConfig( + "MOJEEK_SEARCH_API_KEY", + "rag.web.search.mojeek_search_api_key", + os.getenv("MOJEEK_SEARCH_API_KEY", ""), +) + SERPSTACK_API_KEY = PersistentConfig( "SERPSTACK_API_KEY", "rag.web.search.serpstack_api_key", diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 6a7cbb7eb..0dca21b08 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -49,7 +49,9 @@ from open_webui.apps.openai.main import ( get_all_models_responses as get_openai_models_responses, ) from open_webui.apps.retrieval.main import app as retrieval_app -from open_webui.apps.retrieval.utils import get_rag_context, rag_template +from open_webui.apps.retrieval.utils import get_sources_from_files + + from open_webui.apps.socket.main import ( app as socket_app, periodic_usage_pool_cleanup, @@ -122,11 +124,12 @@ from open_webui.utils.response import ( ) from open_webui.utils.security_headers import SecurityHeadersMiddleware from open_webui.utils.task import ( - moa_response_generation_template, - tags_generation_template, - query_generation_template, - emoji_generation_template, + rag_template, title_generation_template, + query_generation_template, + tags_generation_template, + emoji_generation_template, + moa_response_generation_template, tools_function_calling_generation_template, ) from open_webui.utils.tools import get_tools @@ -380,8 +383,7 @@ async def chat_completion_tools_handler( return body, {} skip_files = False - contexts = [] - citations = [] + sources = [] task_model_id = get_task_model_id( body["model"], @@ -463,21 +465,39 @@ async def chat_completion_tools_handler( except Exception as e: tool_output = str(e) - if tools[tool_function_name]["citation"]: - citations.append( - { - "source": { - "name": f"TOOL:{tools[tool_function_name]['toolkit_id']}/{tool_function_name}" - }, - "document": [tool_output], - "metadata": [{"source": tool_function_name}], - } - ) - if tools[tool_function_name]["file_handler"]: - skip_files = True + print(tools[tool_function_name]["citation"]) if isinstance(tool_output, str): - contexts.append(tool_output) + if tools[tool_function_name]["citation"]: + sources.append( + { + "source": { + "name": f"TOOL:{tools[tool_function_name]['toolkit_id']}/{tool_function_name}" + }, + "document": [tool_output], + "metadata": [ + { + "source": f"TOOL:{tools[tool_function_name]['toolkit_id']}/{tool_function_name}" + } + ], + } + ) + else: + sources.append( + { + "source": {}, + "document": [tool_output], + "metadata": [ + { + "source": f"TOOL:{tools[tool_function_name]['toolkit_id']}/{tool_function_name}" + } + ], + } + ) + + if tools[tool_function_name]["file_handler"]: + skip_files = True + except Exception as e: log.exception(f"Error: {e}") content = None @@ -485,47 +505,44 @@ async def chat_completion_tools_handler( log.exception(f"Error: {e}") content = None - log.debug(f"tool_contexts: {contexts}") + log.debug(f"tool_contexts: {sources}") if skip_files and "files" in body.get("metadata", {}): del body["metadata"]["files"] - return body, {"contexts": contexts, "citations": citations} + return body, {"sources": sources} async def chat_completion_files_handler( body: dict, user: UserModel ) -> tuple[dict, dict[str, list]]: - contexts = [] - citations = [] - - try: - queries_response = await generate_queries( - { - "model": body["model"], - "messages": body["messages"], - "type": "retrieval", - }, - user, - ) - queries_response = queries_response["choices"][0]["message"]["content"] - - try: - queries_response = json.loads(queries_response) - except Exception as e: - queries_response = {"queries": []} - - queries = queries_response.get("queries", []) - except Exception as e: - queries = [] - - if len(queries) == 0: - queries = [get_last_user_message(body["messages"])] - - print(f"{queries=}") + sources = [] if files := body.get("metadata", {}).get("files", None): - contexts, citations = get_rag_context( + try: + queries_response = await generate_queries( + { + "model": body["model"], + "messages": body["messages"], + "type": "retrieval", + }, + user, + ) + queries_response = queries_response["choices"][0]["message"]["content"] + + try: + queries_response = json.loads(queries_response) + except Exception as e: + queries_response = {"queries": []} + + queries = queries_response.get("queries", []) + except Exception as e: + queries = [] + + if len(queries) == 0: + queries = [get_last_user_message(body["messages"])] + + sources = get_sources_from_files( files=files, queries=queries, embedding_function=retrieval_app.state.EMBEDDING_FUNCTION, @@ -535,9 +552,8 @@ async def chat_completion_files_handler( hybrid_search=retrieval_app.state.config.ENABLE_RAG_HYBRID_SEARCH, ) - log.debug(f"rag_contexts: {contexts}, citations: {citations}") - - return body, {"contexts": contexts, "citations": citations} + log.debug(f"rag_contexts:sources: {sources}") + return body, {"sources": sources} def is_chat_completion_request(request): @@ -638,8 +654,7 @@ class ChatCompletionMiddleware(BaseHTTPMiddleware): # Initialize data_items to store additional data to be sent to the client # Initialize contexts and citation data_items = [] - contexts = [] - citations = [] + sources = [] try: body, flags = await chat_completion_filter_functions_handler( @@ -665,21 +680,37 @@ class ChatCompletionMiddleware(BaseHTTPMiddleware): body, flags = await chat_completion_tools_handler( body, user, models, extra_params ) - contexts.extend(flags.get("contexts", [])) - citations.extend(flags.get("citations", [])) + sources.extend(flags.get("sources", [])) except Exception as e: log.exception(e) try: body, flags = await chat_completion_files_handler(body, user) - contexts.extend(flags.get("contexts", [])) - citations.extend(flags.get("citations", [])) + sources.extend(flags.get("sources", [])) except Exception as e: log.exception(e) # If context is not empty, insert it into the messages - if len(contexts) > 0: - context_string = "/n".join(contexts).strip() + if len(sources) > 0: + context_string = "" + for source_idx, source in enumerate(sources): + source_id = source.get("source", {}).get("name", "") + + if "document" in source: + for doc_idx, doc_context in enumerate(source["document"]): + metadata = source.get("metadata") + doc_source_id = None + + if metadata: + doc_source_id = metadata[doc_idx].get("source", source_id) + + if source_id: + context_string += f"{doc_source_id if doc_source_id is not None else source_id}{doc_context}\n" + else: + # If there is no source_id, then do not include the source_id tag + context_string += f"{doc_context}\n" + + context_string = context_string.strip() prompt = get_last_user_message(body["messages"]) if prompt is None: @@ -710,8 +741,11 @@ class ChatCompletionMiddleware(BaseHTTPMiddleware): ) # If there are citations, add them to the data_items - if len(citations) > 0: - data_items.append({"citations": citations}) + sources = [ + source for source in sources if source.get("source", {}).get("name", "") + ] + if len(sources) > 0: + data_items.append({"sources": sources}) modified_body_bytes = json.dumps(body).encode("utf-8") # Replace the request body with the modified one @@ -937,7 +971,7 @@ app.add_middleware(SecurityHeadersMiddleware) @app.middleware("http") async def commit_session_after_request(request: Request, call_next): response = await call_next(request) - log.debug("Commit session after request") + # log.debug("Commit session after request") Session.commit() return response @@ -1020,7 +1054,7 @@ async def get_all_base_models(): return models -@cached(ttl=1) +@cached(ttl=3) async def get_all_models(): models = await get_all_base_models() @@ -1144,6 +1178,8 @@ async def get_all_models(): model["actions"].extend( get_action_items_from_module(action_function, function_module) ) + log.debug(f"get_all_models() returned {len(models)} models") + return models @@ -1158,6 +1194,14 @@ async def get_models(user=Depends(get_verified_user)): if "pipeline" not in model or model["pipeline"].get("type", None) != "filter" ] + model_order_list = webui_app.state.config.MODEL_ORDER_LIST + if model_order_list: + model_order_dict = {model_id: i for i, model_id in enumerate(model_order_list)} + # Sort models by order list priority, with fallback for those not in the list + models.sort( + key=lambda x: (model_order_dict.get(x["id"], float("inf")), x["name"]) + ) + # Filter out models that the user does not have access to if user.role == "user": filtered_models = [] @@ -1181,6 +1225,10 @@ async def get_models(user=Depends(get_verified_user)): filtered_models.append(model) models = filtered_models + log.debug( + f"/api/models returned filtered models accessible to the user: {json.dumps([model['id'] for model in models])}" + ) + return {"data": models} @@ -1313,7 +1361,6 @@ async def generate_chat_completions( @app.post("/api/chat/completed") async def chat_completed(form_data: dict, user=Depends(get_verified_user)): - model_list = await get_all_models() models = {model["id"]: model for model in model_list} @@ -1672,7 +1719,6 @@ async def update_task_config(form_data: TaskConfigForm, user=Depends(get_admin_u @app.post("/api/task/title/completions") async def generate_title(form_data: dict, user=Depends(get_verified_user)): - print("generate_title") model_list = await get_all_models() models = {model["id"]: model for model in model_list} @@ -1693,9 +1739,9 @@ async def generate_title(form_data: dict, user=Depends(get_verified_user)): models, ) - print(task_model_id) - - model = models[task_model_id] + log.debug( + f"generating chat title using model {task_model_id} for user {user.email} " + ) if app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE != "": template = app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE @@ -1734,10 +1780,12 @@ Artificial Intelligence in Healthcare "max_completion_tokens": 50, } ), - "chat_id": form_data.get("chat_id", None), - "metadata": {"task": str(TASKS.TITLE_GENERATION), "task_body": form_data}, + "metadata": { + "task": str(TASKS.TITLE_GENERATION), + "task_body": form_data, + "chat_id": form_data.get("chat_id", None), + }, } - log.debug(payload) # Handle pipeline filters try: @@ -1761,7 +1809,7 @@ Artificial Intelligence in Healthcare @app.post("/api/task/tags/completions") async def generate_chat_tags(form_data: dict, user=Depends(get_verified_user)): - print("generate_chat_tags") + if not app.state.config.ENABLE_TAGS_GENERATION: return JSONResponse( status_code=status.HTTP_200_OK, @@ -1786,7 +1834,10 @@ async def generate_chat_tags(form_data: dict, user=Depends(get_verified_user)): app.state.config.TASK_MODEL_EXTERNAL, models, ) - print(task_model_id) + + log.debug( + f"generating chat tags using model {task_model_id} for user {user.email} " + ) if app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE != "": template = app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE @@ -1817,9 +1868,12 @@ JSON format: { "tags": ["tag1", "tag2", "tag3"] } "model": task_model_id, "messages": [{"role": "user", "content": content}], "stream": False, - "metadata": {"task": str(TASKS.TAGS_GENERATION), "task_body": form_data}, + "metadata": { + "task": str(TASKS.TAGS_GENERATION), + "task_body": form_data, + "chat_id": form_data.get("chat_id", None), + }, } - log.debug(payload) # Handle pipeline filters try: @@ -1843,7 +1897,7 @@ JSON format: { "tags": ["tag1", "tag2", "tag3"] } @app.post("/api/task/queries/completions") async def generate_queries(form_data: dict, user=Depends(get_verified_user)): - print("generate_queries") + type = form_data.get("type") if type == "web_search": if not app.state.config.ENABLE_SEARCH_QUERY_GENERATION: @@ -1876,9 +1930,10 @@ async def generate_queries(form_data: dict, user=Depends(get_verified_user)): app.state.config.TASK_MODEL_EXTERNAL, models, ) - print(task_model_id) - model = models[task_model_id] + log.debug( + f"generating {type} queries using model {task_model_id} for user {user.email}" + ) if app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE != "": template = app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE @@ -1893,9 +1948,12 @@ async def generate_queries(form_data: dict, user=Depends(get_verified_user)): "model": task_model_id, "messages": [{"role": "user", "content": content}], "stream": False, - "metadata": {"task": str(TASKS.QUERY_GENERATION), "task_body": form_data}, + "metadata": { + "task": str(TASKS.QUERY_GENERATION), + "task_body": form_data, + "chat_id": form_data.get("chat_id", None), + }, } - log.debug(payload) # Handle pipeline filters try: @@ -1919,7 +1977,6 @@ async def generate_queries(form_data: dict, user=Depends(get_verified_user)): @app.post("/api/task/emoji/completions") async def generate_emoji(form_data: dict, user=Depends(get_verified_user)): - print("generate_emoji") model_list = await get_all_models() models = {model["id"]: model for model in model_list} @@ -1939,9 +1996,8 @@ async def generate_emoji(form_data: dict, user=Depends(get_verified_user)): app.state.config.TASK_MODEL_EXTERNAL, models, ) - print(task_model_id) - model = models[task_model_id] + log.debug(f"generating emoji using model {task_model_id} for user {user.email} ") template = ''' Your task is to reflect the speaker's likely facial expression through a fitting emoji. Interpret emotions from the message and reflect their facial expression using fitting, diverse emojis (e.g., 😊, 😢, 😡, 😱). @@ -1971,7 +2027,6 @@ Message: """{{prompt}}""" "chat_id": form_data.get("chat_id", None), "metadata": {"task": str(TASKS.EMOJI_GENERATION), "task_body": form_data}, } - log.debug(payload) # Handle pipeline filters try: @@ -1995,7 +2050,6 @@ Message: """{{prompt}}""" @app.post("/api/task/moa/completions") async def generate_moa_response(form_data: dict, user=Depends(get_verified_user)): - print("generate_moa_response") model_list = await get_all_models() models = {model["id"]: model for model in model_list} @@ -2015,9 +2069,8 @@ async def generate_moa_response(form_data: dict, user=Depends(get_verified_user) app.state.config.TASK_MODEL_EXTERNAL, models, ) - print(task_model_id) - model = models[task_model_id] + log.debug(f"generating MOA model {task_model_id} for user {user.email} ") template = """You have been provided with a set of responses from various models to the latest user query: "{{prompt}}" @@ -2041,7 +2094,6 @@ Responses from models: {{responses}}""" "task_body": form_data, }, } - log.debug(payload) try: payload = filter_pipeline(payload, user, models) @@ -2076,7 +2128,7 @@ Responses from models: {{responses}}""" async def get_pipelines_list(user=Depends(get_admin_user)): responses = await get_openai_models_responses() - print(responses) + log.debug(f"get_pipelines_list: get_openai_models_responses returned {responses}") urlIdxs = [ idx for idx, response in enumerate(responses) diff --git a/backend/open_webui/utils/schemas.py b/backend/open_webui/utils/schemas.py deleted file mode 100644 index 4d1d448cd..000000000 --- a/backend/open_webui/utils/schemas.py +++ /dev/null @@ -1,112 +0,0 @@ -from ast import literal_eval -from typing import Any, Literal, Optional, Type - -from pydantic import BaseModel, Field, create_model - - -def json_schema_to_model(tool_dict: dict[str, Any]) -> Type[BaseModel]: - """ - Converts a JSON schema to a Pydantic BaseModel class. - - Args: - json_schema: The JSON schema to convert. - - Returns: - A Pydantic BaseModel class. - """ - - # Extract the model name from the schema title. - model_name = tool_dict["name"] - schema = tool_dict["parameters"] - - # Extract the field definitions from the schema properties. - field_definitions = { - name: json_schema_to_pydantic_field(name, prop, schema.get("required", [])) - for name, prop in schema.get("properties", {}).items() - } - - # Create the BaseModel class using create_model(). - return create_model(model_name, **field_definitions) - - -def json_schema_to_pydantic_field( - name: str, json_schema: dict[str, Any], required: list[str] -) -> Any: - """ - Converts a JSON schema property to a Pydantic field definition. - - Args: - name: The field name. - json_schema: The JSON schema property. - - Returns: - A Pydantic field definition. - """ - - # Get the field type. - type_ = json_schema_to_pydantic_type(json_schema) - - # Get the field description. - description = json_schema.get("description") - - # Get the field examples. - examples = json_schema.get("examples") - - # Create a Field object with the type, description, and examples. - # The 'required' flag will be set later when creating the model. - return ( - type_, - Field( - description=description, - examples=examples, - default=... if name in required else None, - ), - ) - - -def json_schema_to_pydantic_type(json_schema: dict[str, Any]) -> Any: - """ - Converts a JSON schema type to a Pydantic type. - - Args: - json_schema: The JSON schema to convert. - - Returns: - A Pydantic type. - """ - - type_ = json_schema.get("type") - - if type_ == "string" or type_ == "str": - return str - elif type_ == "integer" or type_ == "int": - return int - elif type_ == "number" or type_ == "float": - return float - elif type_ == "boolean" or type_ == "bool": - return bool - elif type_ == "array" or type_ == "list": - items_schema = json_schema.get("items") - if items_schema: - item_type = json_schema_to_pydantic_type(items_schema) - return list[item_type] - else: - return list - elif type_ == "object": - # Handle nested models. - properties = json_schema.get("properties") - if properties: - nested_model = json_schema_to_model(json_schema) - return nested_model - else: - return dict - elif type_ == "null": - return Optional[Any] # Use Optional[Any] for nullable fields - elif type_ == "literal": - return Literal[literal_eval(json_schema.get("enum"))] - elif type_ == "optional": - inner_schema = json_schema.get("items", {"type": "string"}) - inner_type = json_schema_to_pydantic_type(inner_schema) - return Optional[inner_type] - else: - raise ValueError(f"Unsupported JSON schema type: {type_}") diff --git a/backend/open_webui/utils/task.py b/backend/open_webui/utils/task.py index 28b07da37..b6d0d3bce 100644 --- a/backend/open_webui/utils/task.py +++ b/backend/open_webui/utils/task.py @@ -1,11 +1,20 @@ +import logging import math import re from datetime import datetime from typing import Optional +import uuid from open_webui.utils.misc import get_last_user_message, get_messages_content +from open_webui.env import SRC_LOG_LEVELS +from open_webui.config import DEFAULT_RAG_TEMPLATE + + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + def prompt_template( template: str, user_name: Optional[str] = None, user_location: Optional[str] = None @@ -110,6 +119,44 @@ def replace_messages_variable(template: str, messages: list[str]) -> str: # {{prompt:middletruncate:8000}} +def rag_template(template: str, context: str, query: str): + if template == "": + template = DEFAULT_RAG_TEMPLATE + + if "[context]" not in template and "{{CONTEXT}}" not in template: + log.debug( + "WARNING: The RAG template does not contain the '[context]' or '{{CONTEXT}}' placeholder." + ) + + if "" in context and "" in context: + log.debug( + "WARNING: Potential prompt injection attack: the RAG " + "context contains '' and ''. This might be " + "nothing, or the user might be trying to hack something." + ) + + query_placeholders = [] + if "[query]" in context: + query_placeholder = "{{QUERY" + str(uuid.uuid4()) + "}}" + template = template.replace("[query]", query_placeholder) + query_placeholders.append(query_placeholder) + + if "{{QUERY}}" in context: + query_placeholder = "{{QUERY" + str(uuid.uuid4()) + "}}" + template = template.replace("{{QUERY}}", query_placeholder) + query_placeholders.append(query_placeholder) + + template = template.replace("[context]", context) + template = template.replace("{{CONTEXT}}", context) + template = template.replace("[query]", query) + template = template.replace("{{QUERY}}", query) + + for query_placeholder in query_placeholders: + template = template.replace(query_placeholder, query) + + return template + + def title_generation_template( template: str, messages: list[dict], user: Optional[dict] = None ) -> str: diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index e77386ac4..60a9f942f 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -1,11 +1,14 @@ import inspect import logging -from typing import Awaitable, Callable, get_type_hints +import re +from typing import Any, Awaitable, Callable, get_type_hints +from functools import update_wrapper, partial +from langchain_core.utils.function_calling import convert_to_openai_function from open_webui.apps.webui.models.tools import Tools from open_webui.apps.webui.models.users import UserModel from open_webui.apps.webui.utils import load_tools_module_by_id -from open_webui.utils.schemas import json_schema_to_model +from pydantic import BaseModel, Field, create_model log = logging.getLogger(__name__) @@ -14,17 +17,16 @@ def apply_extra_params_to_tool_function( function: Callable, extra_params: dict ) -> Callable[..., Awaitable]: sig = inspect.signature(function) - extra_params = { - key: value for key, value in extra_params.items() if key in sig.parameters - } - is_coroutine = inspect.iscoroutinefunction(function) + extra_params = {k: v for k, v in extra_params.items() if k in sig.parameters} + partial_func = partial(function, **extra_params) + if inspect.iscoroutinefunction(function): + update_wrapper(partial_func, function) + return partial_func - async def new_function(**kwargs): - extra_kwargs = kwargs | extra_params - if is_coroutine: - return await function(**extra_kwargs) - return function(**extra_kwargs) + async def new_function(*args, **kwargs): + return partial_func(*args, **kwargs) + update_wrapper(new_function, function) return new_function @@ -55,11 +57,6 @@ def get_tools( ) for spec in tools.specs: - # TODO: Fix hack for OpenAI API - for val in spec.get("parameters", {}).get("properties", {}).values(): - if val["type"] == "str": - val["type"] = "string" - # Remove internal parameters spec["parameters"]["properties"] = { key: val @@ -72,15 +69,12 @@ def get_tools( # convert to function that takes only model params and inserts custom params original_func = getattr(module, function_name) callable = apply_extra_params_to_tool_function(original_func, extra_params) - if hasattr(original_func, "__doc__"): - callable.__doc__ = original_func.__doc__ - # TODO: This needs to be a pydantic model tool_dict = { "toolkit_id": tool_id, "callable": callable, "spec": spec, - "pydantic_model": json_schema_to_model(spec), + "pydantic_model": function_to_pydantic_model(callable), "file_handler": hasattr(module, "file_handler") and module.file_handler, "citation": hasattr(module, "citation") and module.citation, } @@ -96,78 +90,109 @@ def get_tools( return tools_dict -def doc_to_dict(docstring): - lines = docstring.split("\n") - description = lines[1].strip() - param_dict = {} +def parse_description(docstring: str | None) -> str: + """ + Parse a function's docstring to extract the description. + + Args: + docstring (str): The docstring to parse. + + Returns: + str: The description. + """ + + if not docstring: + return "" + + lines = [line.strip() for line in docstring.strip().split("\n")] + description_lines: list[str] = [] for line in lines: - if ":param" in line: - line = line.replace(":param", "").strip() - param, desc = line.split(":", 1) - param_dict[param.strip()] = desc.strip() - ret_dict = {"description": description, "params": param_dict} - return ret_dict + if re.match(r":param", line) or re.match(r":return", line): + break + + description_lines.append(line) + + return "\n".join(description_lines) -def get_tools_specs(tools) -> list[dict]: - function_list = [ - {"name": func, "function": getattr(tools, func)} - for func in dir(tools) - if callable(getattr(tools, func)) +def parse_docstring(docstring): + """ + Parse a function's docstring to extract parameter descriptions in reST format. + + Args: + docstring (str): The docstring to parse. + + Returns: + dict: A dictionary where keys are parameter names and values are descriptions. + """ + if not docstring: + return {} + + # Regex to match `:param name: description` format + param_pattern = re.compile(r":param (\w+):\s*(.+)") + param_descriptions = {} + + for line in docstring.splitlines(): + match = param_pattern.match(line.strip()) + if not match: + continue + param_name, param_description = match.groups() + if param_name.startswith("__"): + continue + param_descriptions[param_name] = param_description + + return param_descriptions + + +def function_to_pydantic_model(func: Callable) -> type[BaseModel]: + """ + Converts a Python function's type hints and docstring to a Pydantic model, + including support for nested types, default values, and descriptions. + + Args: + func: The function whose type hints and docstring should be converted. + model_name: The name of the generated Pydantic model. + + Returns: + A Pydantic model class. + """ + type_hints = get_type_hints(func) + signature = inspect.signature(func) + parameters = signature.parameters + + docstring = func.__doc__ + descriptions = parse_docstring(docstring) + + tool_description = parse_description(docstring) + + field_defs = {} + for name, param in parameters.items(): + type_hint = type_hints.get(name, Any) + default_value = param.default if param.default is not param.empty else ... + description = descriptions.get(name, None) + if not description: + field_defs[name] = type_hint, default_value + continue + field_defs[name] = type_hint, Field(default_value, description=description) + + model = create_model(func.__name__, **field_defs) + model.__doc__ = tool_description + + return model + + +def get_callable_attributes(tool: object) -> list[Callable]: + return [ + getattr(tool, func) + for func in dir(tool) + if callable(getattr(tool, func)) and not func.startswith("__") - and not inspect.isclass(getattr(tools, func)) + and not inspect.isclass(getattr(tool, func)) ] - specs = [] - for function_item in function_list: - function_name = function_item["name"] - function = function_item["function"] - function_doc = doc_to_dict(function.__doc__ or function_name) - specs.append( - { - "name": function_name, - # TODO: multi-line desc? - "description": function_doc.get("description", function_name), - "parameters": { - "type": "object", - "properties": { - param_name: { - "type": param_annotation.__name__.lower(), - **( - { - "enum": ( - str(param_annotation.__args__) - if hasattr(param_annotation, "__args__") - else None - ) - } - if hasattr(param_annotation, "__args__") - else {} - ), - "description": function_doc.get("params", {}).get( - param_name, param_name - ), - } - for param_name, param_annotation in get_type_hints( - function - ).items() - if param_name != "return" - and not ( - param_name.startswith("__") and param_name.endswith("__") - ) - }, - "required": [ - name - for name, param in inspect.signature( - function - ).parameters.items() - if param.default is param.empty - and not (name.startswith("__") and name.endswith("__")) - ], - }, - } - ) - - return specs +def get_tools_specs(tool_class: object) -> list[dict]: + function_list = get_callable_attributes(tool_class) + models = map(function_to_pydantic_model, function_list) + return [convert_to_openai_function(tool) for tool in models] diff --git a/backend/requirements.txt b/backend/requirements.txt index 258f69e25..c83e6b3b7 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -14,6 +14,7 @@ requests==2.32.3 aiohttp==3.10.8 async-timeout aiocache +aiofiles sqlalchemy==2.0.32 alembic==1.13.2 diff --git a/package-lock.json b/package-lock.json index d2114a6ac..6b899042c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-webui", - "version": "0.4.1", + "version": "0.4.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.4.1", + "version": "0.4.5", "dependencies": { "@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-python": "^6.1.6", @@ -16,6 +16,13 @@ "@mediapipe/tasks-vision": "^0.10.17", "@pyscript/core": "^0.4.32", "@sveltejs/adapter-node": "^2.0.0", + "@tiptap/core": "^2.10.0", + "@tiptap/extension-code-block-lowlight": "^2.10.0", + "@tiptap/extension-highlight": "^2.10.0", + "@tiptap/extension-placeholder": "^2.10.0", + "@tiptap/extension-typography": "^2.10.0", + "@tiptap/pm": "^2.10.0", + "@tiptap/starter-kit": "^2.10.0", "@xyflow/svelte": "^0.1.19", "async": "^3.2.5", "bits-ui": "^0.19.7", @@ -73,6 +80,7 @@ "postcss": "^8.4.31", "prettier": "^3.3.3", "prettier-plugin-svelte": "^3.2.6", + "sass-embedded": "^1.81.0", "svelte": "^4.2.18", "svelte-check": "^3.8.5", "svelte-confetti": "^1.3.2", @@ -136,6 +144,13 @@ "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-6.0.4.tgz", "integrity": "sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==" }, + "node_modules/@bufbuild/protobuf": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.2.2.tgz", + "integrity": "sha512-UNtPCbrwrenpmrXuRwn9jYpPoweNXj8X5sMvYgsqYyaH8jQ6LfUJSk3dJLnBK+6sfYPrF4iAIo5sd5HQ+tg75A==", + "devOptional": true, + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, "node_modules/@codemirror/autocomplete": { "version": "6.16.2", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.16.2.tgz", @@ -1901,6 +1916,12 @@ "type-checked-collections": "^0.1.7" } }, + "node_modules/@remirror/core-constants": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", + "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", + "license": "MIT" + }, "node_modules/@rollup/plugin-commonjs": { "version": "25.0.7", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-25.0.7.tgz", @@ -2326,6 +2347,391 @@ "tailwindcss": ">=3.0.0 || insiders" } }, + "node_modules/@tiptap/core": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.10.0.tgz", + "integrity": "sha512-58nAjPxLRFcXepdDqQRC1mhrw6E8Sanqr6bbO4Tz0+FWgDJMZvHG+dOK5wHaDVNSgK2iJDz08ETvQayfOOgDvg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.10.0.tgz", + "integrity": "sha512-6Xmfo2lpfIRcbfkLD/NGX4YgQqfgAbu6XaZQZf5oGtHLPTrz4D7Mw20GgNBHzae2XwUCwLMt6zXOkBgU/LnlZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.10.0.tgz", + "integrity": "sha512-1wL8UI1Aii0u2cbDEvwyqsZb2pgBt8HLJdsIax/ELoF2tKCD5821nElqTGLBBg4pUGPa0ru9ZemuL8GdXZp3Qg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.10.0.tgz", + "integrity": "sha512-Cl+DGu6D3SgF/hlKUDNet3gaZFy6cPEonOOkHwzXoybDXXdddFbaTvt9MLkBRUR3ldksXuVRP2/LwZsK5WyxJQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.10.0.tgz", + "integrity": "sha512-8JznKG1Jmv8gJezZGPoka8oRmfrcAAnMEOeMpKXjwMrIbQ6QynTZpqMGGVL1kfkZlLV84PYm+CGjGgjSsT4iZw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.10.0.tgz", + "integrity": "sha512-QH+LP7L1s1EJlrDFnfgOP0q+Siqt0Zbkx4ICMcUGvEsycl53Ti8P0DRW7fAjRISdTCItuWJYvtmiYY7O3rYb+Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-code-block-lowlight": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-2.10.0.tgz", + "integrity": "sha512-dAv03XIHT5h+sdFmJzvx2FfpfFOOK9SBKHflRUdqTa8eA+0VZNAcPRjvJWVEWqts1fKZDJj774mO28NlhFzk9Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/extension-code-block": "^2.7.0", + "@tiptap/pm": "^2.7.0", + "highlight.js": "^11", + "lowlight": "^2 || ^3" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.10.0.tgz", + "integrity": "sha512-vseMW3EKiQAPgdbN48Y8F0nRqWhhrAo9DLacAfP7tu0x3uv44uotNjDBtAgp5QmJmqQVyrEdkLSZaU5vFzduhQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.10.0.tgz", + "integrity": "sha512-tifxp/a3NxTjLAuYBx9XAwVo4MSDoY/mQ8E18QtuXj0vuieCFxd8Bkyre0otubIAAQePXLTVGQoxPrKmMAa+Jg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.10.0.tgz", + "integrity": "sha512-GViEnSnEBE74k7SYdXrQ4aXlKmWkrd9awdj/TgDSORgpZ4Dfyqtn+ENIWWby4NhL+BPM9P5hGCjkQXZsi6JKOw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.10.0.tgz", + "integrity": "sha512-NL/xPYUhhvQyCnOO5Yn+BlBOMLC1ru32nw7ox12TShGmaeKBrnV0DhzBRkyJU0MqCS26oWjieNPxfu0lR3oMSA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.10.0.tgz", + "integrity": "sha512-x2Uj5wrAHFaUdlChwLoQVmWtzZCuNyJpBRA19kA4idWL5z+6cIrUWepvwVBxA8ou6ictbzWW15o+blKtW7DlqA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-highlight": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-2.10.0.tgz", + "integrity": "sha512-HU8UuKU7ljlzNn7jg29pM8QtIX7QvePcBjcWAt6K3qVwF1cbBNguIjKRY2rmoonU2nu8I6GknQNgV847kZifCQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-history": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.10.0.tgz", + "integrity": "sha512-5aYOmxqaCnw7e7wmWqFZmkpYCxxDjEzFbgVI6WknqNwqeOizR4+YJf3aAt/lTbksLJe47XF+NBX51gOm/ZBCiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.10.0.tgz", + "integrity": "sha512-el1SzI/x/h4HW8UltxJlyMSrRsO55ypKPLQHJC9h7F6kTTR31fJUzQa3AeTFrZvXS0kNHIFRpAMstw+N0L5TYg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.10.0.tgz", + "integrity": "sha512-MqPYbHAEeO8QBvZRIkF4J2OTf/uiUPzUiXGLJ50w1ozfMBIw1txMvfR3g2cpwfvZlcOgYTgy7M0Oq00nQz5eXg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.10.0.tgz", + "integrity": "sha512-BxC6NNHd2xcC+mk5hpYWURUdj/mRz6TGFwH5CsyrUXPxApx0+V+EPHaAgdpu8dr+jtTEzjXF62V6e2JmOAPimg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.10.0.tgz", + "integrity": "sha512-jsK+mvzs7HmxQuQOU3HgIga+v7zUbQlmSP4/danusqUihJ+lc1n0frDCIkVvJrnSB3FChvNgT6ZEA14HOhdJzg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.10.0.tgz", + "integrity": "sha512-4LUkVaJYjNdNZ7QOX6TRcA+m7oCtyrLGk49G22wl7XcPBkQPILP1mCUCU4f41bhjfhCgK5PPWP63kMtD+cEACg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-placeholder": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.10.0.tgz", + "integrity": "sha512-1o6azk2plgYAFgMrV3prnBb1NZjl2V1T3wwnH4n3/h9z9lJ0v5BBAk9r+TRYSrcdXknwwHAWFYnQe6dc9buG2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.10.0.tgz", + "integrity": "sha512-SxApLJMQkxnmPGR3lwaskvLK61yI+Bu9hGZGdwMZqNh6o3LoDOxDaXjHD5joeMYQiqQrBE9zg46506MsXtrU7Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.10.0.tgz", + "integrity": "sha512-SSnNncADS1KucdEcJlF6WGCs5+1pAhPrD68vlw34oj3NDT3Zh05KiyXsCV3Nw4wpHOnbWahV+z3uT2SnR+xgoQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text-style": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.10.0.tgz", + "integrity": "sha512-VZtH1dp64wg1UcFtUPpRQK+kOm4JHBIv+WXuKX7EnpIEKjHKnyfV94BBVmaqY5UE4n3kbkkmIRB2Cmix/10AMg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-typography": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-typography/-/extension-typography-2.10.0.tgz", + "integrity": "sha512-03IOfJm4bk2hZ4SsSfxgBOVzcDxMRBlFD7ZY12H2EGNf1TKxj/0ANWhAH54FtquuOMoY5aWg5LZf0lk++8UDAw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/pm": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.10.0.tgz", + "integrity": "sha512-ohshlWf4MlW6D3rQkNQnhmiQ2w4pwRoQcJmTPt8UJoIDGkeKmZh494fQp4Aeh80XuGd81SsCv//1HJeyaeHJYQ==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.2.1", + "prosemirror-collab": "^1.3.1", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-inputrules": "^1.4.0", + "prosemirror-keymap": "^1.2.2", + "prosemirror-markdown": "^1.13.1", + "prosemirror-menu": "^1.2.4", + "prosemirror-model": "^1.23.0", + "prosemirror-schema-basic": "^1.2.3", + "prosemirror-schema-list": "^1.4.1", + "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.6.1", + "prosemirror-trailing-node": "^3.0.0", + "prosemirror-transform": "^1.10.2", + "prosemirror-view": "^1.36.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.10.0.tgz", + "integrity": "sha512-hMIM9a6HjYZo25EzhZHlKEIR7CFi0grRSOltEyggiyBuQqKFkI7iwCpZVVtviDV1FwV0EPANpIAxPS7aBRgFdg==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^2.10.0", + "@tiptap/extension-blockquote": "^2.10.0", + "@tiptap/extension-bold": "^2.10.0", + "@tiptap/extension-bullet-list": "^2.10.0", + "@tiptap/extension-code": "^2.10.0", + "@tiptap/extension-code-block": "^2.10.0", + "@tiptap/extension-document": "^2.10.0", + "@tiptap/extension-dropcursor": "^2.10.0", + "@tiptap/extension-gapcursor": "^2.10.0", + "@tiptap/extension-hard-break": "^2.10.0", + "@tiptap/extension-heading": "^2.10.0", + "@tiptap/extension-history": "^2.10.0", + "@tiptap/extension-horizontal-rule": "^2.10.0", + "@tiptap/extension-italic": "^2.10.0", + "@tiptap/extension-list-item": "^2.10.0", + "@tiptap/extension-ordered-list": "^2.10.0", + "@tiptap/extension-paragraph": "^2.10.0", + "@tiptap/extension-strike": "^2.10.0", + "@tiptap/extension-text": "^2.10.0", + "@tiptap/extension-text-style": "^2.10.0", + "@tiptap/pm": "^2.10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -2405,6 +2811,16 @@ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==" }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -3425,6 +3841,13 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-builder": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/buffer-builder/-/buffer-builder-0.2.0.tgz", + "integrity": "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==", + "devOptional": true, + "license": "MIT/X11" + }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -3898,6 +4321,13 @@ "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", "dev": true }, + "node_modules/colorjs.io": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", + "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==", + "devOptional": true, + "license": "MIT" + }, "node_modules/colors": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", @@ -4760,6 +5190,20 @@ "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.1.1.tgz", "integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==" }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "peer": true, + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -5047,7 +5491,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "engines": { "node": ">=10" }, @@ -5978,7 +6421,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, + "devOptional": true, "engines": { "node": ">=8" } @@ -6241,6 +6684,13 @@ "node": ">= 4" } }, + "node_modules/immutable": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz", + "integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==", + "devOptional": true, + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -6982,6 +7432,22 @@ "get-func-name": "^2.0.1" } }, + "node_modules/lowlight": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.1.0.tgz", + "integrity": "sha512-CEbNVoSikAxwDMDPjXlqlFYiZLkDJHwyGu/MfOsJnF3d7f3tds5J3z8s/l9TMXhzfsJCCJEAsD78842mwmg0PQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "highlight.js": "~11.9.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/magic-string": { "version": "0.30.11", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", @@ -8604,14 +9070,33 @@ "node": "10.* || >= 12.*" } }, + "node_modules/prosemirror-changeset": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.2.1.tgz", + "integrity": "sha512-J7msc6wbxB4ekDFj+n9gTW/jav/p53kdlivvuppHsrZXCaQdVgRghoZbSS3kwrRyAstRVQ4/+u5k7YfLgkkQvQ==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-collab": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz", + "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0" + } + }, "node_modules/prosemirror-commands": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.6.0.tgz", - "integrity": "sha512-xn1U/g36OqXn2tn5nGmvnnimAj/g1pUx2ypJJIe8WkVX83WyJVC5LTARaxZa2AtQRwntu9Jc5zXs9gL9svp/mg==", + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.6.2.tgz", + "integrity": "sha512-0nDHH++qcf/BuPLYvmqZTUUsPJUCPBUXt0J1ErTcDIS369CTp773itzLGIgIXG4LJXOlwYCr44+Mh4ii6MP1QA==", + "license": "MIT", "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", - "prosemirror-transform": "^1.0.0" + "prosemirror-transform": "^1.10.2" } }, "node_modules/prosemirror-dropcursor": { @@ -8737,18 +9222,48 @@ "prosemirror-view": "^1.27.0" } }, + "node_modules/prosemirror-tables": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.6.1.tgz", + "integrity": "sha512-p8WRJNA96jaNQjhJolmbxTzd6M4huRE5xQ8OxjvMhQUP0Nzpo4zz6TztEiwk6aoqGBhz9lxRWR1yRZLlpQN98w==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.1.2", + "prosemirror-model": "^1.8.1", + "prosemirror-state": "^1.3.1", + "prosemirror-transform": "^1.2.1", + "prosemirror-view": "^1.13.3" + } + }, + "node_modules/prosemirror-trailing-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", + "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", + "license": "MIT", + "dependencies": { + "@remirror/core-constants": "3.0.0", + "escape-string-regexp": "^4.0.0" + }, + "peerDependencies": { + "prosemirror-model": "^1.22.1", + "prosemirror-state": "^1.4.2", + "prosemirror-view": "^1.33.8" + } + }, "node_modules/prosemirror-transform": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.0.tgz", - "integrity": "sha512-9UOgFSgN6Gj2ekQH5CTDJ8Rp/fnKR2IkYfGdzzp5zQMFsS4zDllLVx/+jGcX86YlACpG7UR5fwAXiWzxqWtBTg==", + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.2.tgz", + "integrity": "sha512-2iUq0wv2iRoJO/zj5mv8uDUriOHWzXRnOTVgCzSXnktS/2iQRa3UUQwVlkBlYZFtygw6Nh1+X4mGqoYBINn5KQ==", + "license": "MIT", "dependencies": { "prosemirror-model": "^1.21.0" } }, "node_modules/prosemirror-view": { - "version": "1.34.3", - "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.34.3.tgz", - "integrity": "sha512-mKZ54PrX19sSaQye+sef+YjBbNu2voNwLS1ivb6aD2IRmxRGW64HU9B644+7OfJStGLyxvOreKqEgfvXa91WIA==", + "version": "1.36.0", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.36.0.tgz", + "integrity": "sha512-U0GQd5yFvV5qUtT41X1zCQfbw14vkbbKwLlQXhdylEmgpYVHkefXYcC4HHwWOfZa3x6Y8wxDLUBv7dxN5XQ3nA==", + "license": "MIT", "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -9236,7 +9751,7 @@ "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dev": true, + "devOptional": true, "dependencies": { "tslib": "^2.1.0" } @@ -9329,6 +9844,387 @@ "rimraf": "bin.js" } }, + "node_modules/sass-embedded": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.81.0.tgz", + "integrity": "sha512-uZQ2Faxb1oWBHpeSSzjxnhClbMb3QadN0ql0ZFNuqWOLUxwaVhrMlMhPq6TDPbbfDUjihuwrMCuy695Bgna5RA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@bufbuild/protobuf": "^2.0.0", + "buffer-builder": "^0.2.0", + "colorjs.io": "^0.5.0", + "immutable": "^5.0.2", + "rxjs": "^7.4.0", + "supports-color": "^8.1.1", + "sync-child-process": "^1.0.2", + "varint": "^6.0.0" + }, + "bin": { + "sass": "dist/bin/sass.js" + }, + "engines": { + "node": ">=16.0.0" + }, + "optionalDependencies": { + "sass-embedded-android-arm": "1.81.0", + "sass-embedded-android-arm64": "1.81.0", + "sass-embedded-android-ia32": "1.81.0", + "sass-embedded-android-riscv64": "1.81.0", + "sass-embedded-android-x64": "1.81.0", + "sass-embedded-darwin-arm64": "1.81.0", + "sass-embedded-darwin-x64": "1.81.0", + "sass-embedded-linux-arm": "1.81.0", + "sass-embedded-linux-arm64": "1.81.0", + "sass-embedded-linux-ia32": "1.81.0", + "sass-embedded-linux-musl-arm": "1.81.0", + "sass-embedded-linux-musl-arm64": "1.81.0", + "sass-embedded-linux-musl-ia32": "1.81.0", + "sass-embedded-linux-musl-riscv64": "1.81.0", + "sass-embedded-linux-musl-x64": "1.81.0", + "sass-embedded-linux-riscv64": "1.81.0", + "sass-embedded-linux-x64": "1.81.0", + "sass-embedded-win32-arm64": "1.81.0", + "sass-embedded-win32-ia32": "1.81.0", + "sass-embedded-win32-x64": "1.81.0" + } + }, + "node_modules/sass-embedded-android-arm": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.81.0.tgz", + "integrity": "sha512-NWEmIuaIEsGFNsIRa+5JpIpPJyZ32H15E85CNZqEIhhwWlk9UNw7vlOCmTH8MtabtnACwC/2NG8VyNa3nxKzUQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-arm64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.81.0.tgz", + "integrity": "sha512-I36P77/PKAHx6sqOmexO2iEY5kpsmQ1VxcgITZSOxPMQhdB6m4t3bTabfDuWQQmCrqqiNFtLQHeytB65bUqwiw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-ia32": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/sass-embedded-android-ia32/-/sass-embedded-android-ia32-1.81.0.tgz", + "integrity": "sha512-k8V1usXw30w1GVxvrteG1RzgYJzYQ9PfL2aeOqGdroBN7zYTD9VGJXTGcxA4IeeRxmRd7szVW2mKXXS472fh8g==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-riscv64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.81.0.tgz", + "integrity": "sha512-RXlanyLXEpN/DEehXgLuKPsqT//GYlsGFxKXgRiCc8hIPAueFLQXKJmLWlL3BEtHgmFdbsStIu4aZCcb1hOFlQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-x64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.81.0.tgz", + "integrity": "sha512-RQG0FxGQ1DERNyUDED8+BDVaLIjI+BNg8lVcyqlLZUrWY6NhzjwYEeiN/DNZmMmHtqDucAPNDcsdVUNQqsBy2A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-darwin-arm64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.81.0.tgz", + "integrity": "sha512-gLKbsfII9Ppua76N41ODFnKGutla9qv0OGAas8gxe0jYBeAQFi/1iKQYdNtQtKi4mA9n5TQTqz+HHCKszZCoyA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-darwin-x64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.81.0.tgz", + "integrity": "sha512-7uMOlT9hD2KUJCbTN2XcfghDxt/rc50ujjfSjSHjX1SYj7mGplkINUXvVbbvvaV2wt6t9vkGkCo5qNbeBhfwBg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-arm": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.81.0.tgz", + "integrity": "sha512-REqR9qM4RchCE3cKqzRy9Q4zigIV82SbSpCi/O4O3oK3pg2I1z7vkb3TiJsivusG/li7aqKZGmYOtAXjruGQDA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-arm64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.81.0.tgz", + "integrity": "sha512-jy4bvhdUmqbyw1jv1f3Uxl+MF8EU/Y/GDx4w6XPJm4Ds+mwH/TwnyAwsxxoBhWfnBnW8q2ADy039DlS5p+9csQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-ia32": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-ia32/-/sass-embedded-linux-ia32-1.81.0.tgz", + "integrity": "sha512-ga/Jk4q5Bn1aC+iHJteDZuLSKnmBUiS3dEg1fnl/Z7GaHIChceKDJOw0zNaILRXI0qT2E1at9MwzoRaRA5Nn/g==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-arm": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.81.0.tgz", + "integrity": "sha512-oWVUvQ4d5Kx1Md75YXZl5z1WBjc+uOhfRRqzkJ3nWc8tjszxJN+y/5EOJavhsNI3/2yoTt6eMXRTqDD9b0tWSQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-arm64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.81.0.tgz", + "integrity": "sha512-hpntWf5kjkoxncA1Vh8vhsUOquZ8AROZKx0rQh7ZjSRs4JrYZASz1cfevPKaEM3wIim/nYa6TJqm0VqWsrERlA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-ia32": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-ia32/-/sass-embedded-linux-musl-ia32-1.81.0.tgz", + "integrity": "sha512-UEXUYkBuqTSwg5JNWiNlfMZ1Jx6SJkaEdx+fsL3Tk099L8cKSoJWH2EPz4ZJjNbyIMymrSdVfymheTeZ8u24xA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-riscv64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.81.0.tgz", + "integrity": "sha512-1D7OznytbIhx2XDHWi1nuQ8d/uCVR7FGGzELgaU//T8A9DapVTUgPKvB70AF1k4GzChR9IXU/WvFZs2hDTbaJg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-x64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.81.0.tgz", + "integrity": "sha512-ia6VCTeVDQtBSMktXRFza1AZCt8/6aUoujot6Ugf4KmdytQqPJIHxkHaGftm5xwi9WdrMGYS7zgolToPijR11A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-riscv64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.81.0.tgz", + "integrity": "sha512-KbxSsqu4tT1XbhZfJV/5NfW0VtJIGlD58RjqJqJBi8Rnjrx29/upBsuwoDWtsPV/LhoGwwU1XkSa9Q1ifCz4fQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-x64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.81.0.tgz", + "integrity": "sha512-AMDeVY2T9WAnSFkuQcsOn5c29GRs/TuqnCiblKeXfxCSKym5uKdBl/N7GnTV6OjzoxiJBbkYKdVIaS5By7Gj4g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-win32-arm64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.81.0.tgz", + "integrity": "sha512-YOmBRYnygwWUmCoH14QbMRHjcvCJufeJBAp0m61tOJXIQh64ziwV4mjdqjS/Rx3zhTT4T+nulDUw4d3kLiMncA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-win32-ia32": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-ia32/-/sass-embedded-win32-ia32-1.81.0.tgz", + "integrity": "sha512-HFfr/C+uLJGGTENdnssuNTmXI/xnIasUuEHEKqI+2J0FHCWT5cpz3PGAOHymPyJcZVYGUG/7gIxIx/d7t0LFYw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-win32-x64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.81.0.tgz", + "integrity": "sha512-wxj52jDcIAwWcXb7ShZ7vQYKcVUkJ+04YM9l46jDY+qwHzliGuorAUyujLyKTE9heGD3gShJ3wPPC1lXzq6v9A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/semver": { "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", @@ -10044,6 +10940,29 @@ "integrity": "sha512-0K91MEXFpBUaywiwSSkmKjnGcasG/rVBXFLJz5DrgGabpYD6N+3yZrfD6uUIfpuTu65DZLHi7N8CizHc07BPZA==", "dev": true }, + "node_modules/sync-child-process": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz", + "integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "sync-message-port": "^1.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/sync-message-port": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sync-message-port/-/sync-message-port-1.1.3.tgz", + "integrity": "sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/tabbable": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", @@ -10416,6 +11335,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.0.tgz", "integrity": "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A==", + "license": "MIT", "dependencies": { "@mixmark-io/domino": "^2.2.0" } @@ -10629,6 +11549,13 @@ "node": ">= 10.13.0" } }, + "node_modules/varint": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", + "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/verror": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", diff --git a/package.json b/package.json index 47e232773..0db0d88f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.4.1", + "version": "0.4.5", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", @@ -37,6 +37,7 @@ "postcss": "^8.4.31", "prettier": "^3.3.3", "prettier-plugin-svelte": "^3.2.6", + "sass-embedded": "^1.81.0", "svelte": "^4.2.18", "svelte-check": "^3.8.5", "svelte-confetti": "^1.3.2", @@ -56,6 +57,13 @@ "@mediapipe/tasks-vision": "^0.10.17", "@pyscript/core": "^0.4.32", "@sveltejs/adapter-node": "^2.0.0", + "@tiptap/core": "^2.10.0", + "@tiptap/extension-code-block-lowlight": "^2.10.0", + "@tiptap/extension-highlight": "^2.10.0", + "@tiptap/extension-placeholder": "^2.10.0", + "@tiptap/extension-typography": "^2.10.0", + "@tiptap/pm": "^2.10.0", + "@tiptap/starter-kit": "^2.10.0", "@xyflow/svelte": "^0.1.19", "async": "^3.2.5", "bits-ui": "^0.19.7", diff --git a/pyproject.toml b/pyproject.toml index 9a1c2bb03..0dc8e856d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ "aiohttp==3.10.8", "async-timeout", "aiocache", + "aiofiles", "sqlalchemy==2.0.32", "alembic==1.13.2", diff --git a/src/app.css b/src/app.css index 9d073d05d..659498add 100644 --- a/src/app.css +++ b/src/app.css @@ -199,19 +199,95 @@ input[type='number'] { } .ProseMirror { - @apply h-full min-h-fit max-h-full whitespace-pre-wrap; + @apply h-full min-h-fit max-h-full whitespace-pre-wrap; } .ProseMirror:focus { outline: none; } -.placeholder::after { +.ProseMirror p.is-editor-empty:first-child::before { content: attr(data-placeholder); - cursor: text; - pointer-events: none; - float: left; + color: #adb5bd; + pointer-events: none; + height: 0; +} - @apply absolute inset-0 z-0 text-gray-500; +.tiptap > pre > code { + border-radius: 0.4rem; + font-size: 0.85rem; + padding: 0.25em 0.3em; + + @apply dark:bg-gray-800 bg-gray-100; +} + +.tiptap > pre { + border-radius: 0.5rem; + font-family: 'JetBrainsMono', monospace; + margin: 1.5rem 0; + padding: 0.75rem 1rem; + + @apply dark:bg-gray-800 bg-gray-100; +} + +.tiptap p code { + color: #eb5757; + border-width: 0px; + padding: 3px 8px; + font-size: 0.8em; + font-weight: 600; + @apply rounded-md dark:bg-gray-800 bg-gray-100 mx-0.5; +} + +/* Code styling */ +.hljs-comment, +.hljs-quote { + color: #616161; +} + +.hljs-variable, +.hljs-template-variable, +.hljs-attribute, +.hljs-tag, +.hljs-regexp, +.hljs-link, +.hljs-name, +.hljs-selector-id, +.hljs-selector-class { + color: #f98181; +} + +.hljs-number, +.hljs-meta, +.hljs-built_in, +.hljs-builtin-name, +.hljs-literal, +.hljs-type, +.hljs-params { + color: #fbbc88; +} + +.hljs-string, +.hljs-symbol, +.hljs-bullet { + color: #b9f18d; +} + +.hljs-title, +.hljs-section { + color: #faf594; +} + +.hljs-keyword, +.hljs-selector-tag { + color: #70cff8; +} + +.hljs-emphasis { + font-style: italic; +} + +.hljs-strong { + font-weight: 700; } diff --git a/src/lib/apis/configs/index.ts b/src/lib/apis/configs/index.ts index 0c4de6ad6..e9faf346b 100644 --- a/src/lib/apis/configs/index.ts +++ b/src/lib/apis/configs/index.ts @@ -58,17 +58,44 @@ export const exportConfig = async (token: string) => { return res; }; -export const setDefaultModels = async (token: string, models: string) => { +export const getModelsConfig = async (token: string) => { let error = null; - const res = await fetch(`${WEBUI_API_BASE_URL}/configs/default/models`, { + const res = await fetch(`${WEBUI_API_BASE_URL}/configs/models`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const setModelsConfig = async (token: string, config: object) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/configs/models`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ - models: models + ...config }) }) .then(async (res) => { @@ -91,7 +118,7 @@ export const setDefaultModels = async (token: string, models: string) => { export const setDefaultPromptSuggestions = async (token: string, promptSuggestions: string) => { let error = null; - const res = await fetch(`${WEBUI_API_BASE_URL}/configs/default/suggestions`, { + const res = await fetch(`${WEBUI_API_BASE_URL}/configs/suggestions`, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/src/lib/apis/index.ts b/src/lib/apis/index.ts index 699980a5e..a33610c31 100644 --- a/src/lib/apis/index.ts +++ b/src/lib/apis/index.ts @@ -25,26 +25,6 @@ export const getModels = async (token: string = '', base: boolean = false) => { } let models = res?.data ?? []; - models = models - .filter((models) => models) - // Sort the models - .sort((a, b) => { - // Compare case-insensitively by name for models without position property - const lowerA = a.name.toLowerCase(); - const lowerB = b.name.toLowerCase(); - - if (lowerA < lowerB) return -1; - if (lowerA > lowerB) return 1; - - // If same case-insensitively, sort by original strings, - // lowercase will come before uppercase due to ASCII values - if (a.name < b.name) return -1; - if (a.name > b.name) return 1; - - return 0; // They are equal - }); - - console.log(models); return models; }; @@ -391,16 +371,13 @@ export const generateQueries = async ( // Step 1: Safely extract the response string const response = res?.choices[0]?.message?.content ?? ''; - // Step 2: Attempt to fix common JSON format issues like single quotes - const sanitizedResponse = response.replace(/['‘’`]/g, '"'); // Convert single quotes to double quotes for valid JSON - // Step 3: Find the relevant JSON block within the response - const jsonStartIndex = sanitizedResponse.indexOf('{'); - const jsonEndIndex = sanitizedResponse.lastIndexOf('}'); + const jsonStartIndex = response.indexOf('{'); + const jsonEndIndex = response.lastIndexOf('}'); // Step 4: Check if we found a valid JSON block (with both `{` and `}`) if (jsonStartIndex !== -1 && jsonEndIndex !== -1) { - const jsonResponse = sanitizedResponse.substring(jsonStartIndex, jsonEndIndex + 1); + const jsonResponse = response.substring(jsonStartIndex, jsonEndIndex + 1); // Step 5: Parse the JSON block const parsed = JSON.parse(jsonResponse); diff --git a/src/lib/apis/streaming/index.ts b/src/lib/apis/streaming/index.ts index a8249abe0..54804385d 100644 --- a/src/lib/apis/streaming/index.ts +++ b/src/lib/apis/streaming/index.ts @@ -5,7 +5,7 @@ type TextStreamUpdate = { done: boolean; value: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any - citations?: any; + sources?: any; // eslint-disable-next-line @typescript-eslint/no-explicit-any selectedModelId?: any; error?: any; @@ -67,8 +67,8 @@ async function* openAIStreamToIterator( break; } - if (parsedData.citations) { - yield { done: false, value: '', citations: parsedData.citations }; + if (parsedData.sources) { + yield { done: false, value: '', sources: parsedData.sources }; continue; } @@ -98,7 +98,7 @@ async function* streamLargeDeltasAsRandomChunks( yield textStreamUpdate; return; } - if (textStreamUpdate.citations) { + if (textStreamUpdate.sources) { yield textStreamUpdate; continue; } diff --git a/src/lib/components/admin/Functions.svelte b/src/lib/components/admin/Functions.svelte index 2d76a8a9c..03da04ea7 100644 --- a/src/lib/components/admin/Functions.svelte +++ b/src/lib/components/admin/Functions.svelte @@ -219,7 +219,7 @@
- {#each filteredItems as func} + {#each filteredItems as func (func.id)}
diff --git a/src/lib/components/admin/Settings/Connections/OpenAIConnection.svelte b/src/lib/components/admin/Settings/Connections/OpenAIConnection.svelte index 1e3f5a69b..e6953bd78 100644 --- a/src/lib/components/admin/Settings/Connections/OpenAIConnection.svelte +++ b/src/lib/components/admin/Settings/Connections/OpenAIConnection.svelte @@ -60,7 +60,7 @@ /> {#if pipeline} -
+
{#if edit}
- +
@@ -186,7 +182,7 @@
{#if models.length > 0} - {#each filteredModels as model (model.id)} + {#each filteredModels as model, modelIdx (model.id)}
+ import { toast } from 'svelte-sonner'; + + import { createEventDispatcher, getContext, onMount } from 'svelte'; + const i18n = getContext('i18n'); + const dispatch = createEventDispatcher(); + + import { models } from '$lib/stores'; + import { deleteAllModels } from '$lib/apis/models'; + + import Modal from '$lib/components/common/Modal.svelte'; + import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; + import Tooltip from '$lib/components/common/Tooltip.svelte'; + import ModelList from './ModelList.svelte'; + import { getModelsConfig, setModelsConfig } from '$lib/apis/configs'; + import Spinner from '$lib/components/common/Spinner.svelte'; + import Minus from '$lib/components/icons/Minus.svelte'; + import Plus from '$lib/components/icons/Plus.svelte'; + + export let show = false; + export let init = () => {}; + + let config = null; + + let selectedModelId = ''; + let defaultModelIds = []; + let modelIds = []; + + let loading = false; + let showResetModal = false; + + const submitHandler = async () => { + loading = true; + + const res = await setModelsConfig(localStorage.token, { + DEFAULT_MODELS: defaultModelIds.join(','), + MODEL_ORDER_LIST: modelIds + }); + + if (res) { + toast.success($i18n.t('Models configuration saved successfully')); + init(); + show = false; + } else { + toast.error($i18n.t('Failed to save models configuration')); + } + + loading = false; + }; + + onMount(async () => { + config = await getModelsConfig(localStorage.token); + + const modelOrderList = config.MODEL_ORDER_LIST || []; + const allModelIds = $models.map((model) => model.id); + + // Create a Set for quick lookup of ordered IDs + const orderedSet = new Set(modelOrderList); + + modelIds = [ + // Add all IDs from MODEL_ORDER_LIST that exist in allModelIds + ...modelOrderList.filter((id) => orderedSet.has(id) && allModelIds.includes(id)), + // Add remaining IDs not in MODEL_ORDER_LIST, sorted alphabetically + ...allModelIds.filter((id) => !orderedSet.has(id)).sort((a, b) => a.localeCompare(b)) + ]; + }); + + + { + const res = deleteAllModels(localStorage.token); + if (res) { + toast.success($i18n.t('All models deleted successfully')); + init(); + } + }} +/> + + +
+
+
+ {$i18n.t('Configure Models')} +
+ +
+ +
+
+ {#if config} +
{ + submitHandler(); + }} + > +
+
+
+
{$i18n.t('Reorder Models')}
+
+ + +
+
+ +
+ +
+
+
+
{$i18n.t('Default Models')}
+
+ + {#if defaultModelIds.length > 0} +
+ {#each defaultModelIds as modelId, modelIdx} +
+
+ {$models.find((model) => model.id === modelId)?.name} +
+
+ +
+
+ {/each} +
+ {:else} +
+ {$i18n.t('No models selected')} +
+ {/if} + +
+ +
+ + +
+ +
+
+
+
+ +
+ + + + + +
+
+ {:else} +
+ +
+ {/if} +
+
+
+
diff --git a/src/lib/components/admin/Settings/Models/ModelList.svelte b/src/lib/components/admin/Settings/Models/ModelList.svelte new file mode 100644 index 000000000..c54d19ee4 --- /dev/null +++ b/src/lib/components/admin/Settings/Models/ModelList.svelte @@ -0,0 +1,58 @@ + + +{#if modelIds.length > 0} +
+ {#each modelIds as modelId, modelIdx (modelId)} +
+ +
+ + +
+ {#if $models.find((model) => model.id === modelId)} + {$models.find((model) => model.id === modelId).name} + {:else} + {modelId} + {/if} +
+
+
+
+ {/each} +
+{:else} +
+ {$i18n.t('No models found')} +
+{/if} diff --git a/src/lib/components/admin/Settings/WebSearch.svelte b/src/lib/components/admin/Settings/WebSearch.svelte index 7e12694cb..d8b1a33d1 100644 --- a/src/lib/components/admin/Settings/WebSearch.svelte +++ b/src/lib/components/admin/Settings/WebSearch.svelte @@ -16,6 +16,7 @@ 'searxng', 'google_pse', 'brave', + 'mojeek', 'serpstack', 'serper', 'serply', @@ -151,6 +152,17 @@ bind:value={webConfig.search.brave_search_api_key} />
+ {:else if webConfig.search.engine === 'mojeek'} +
+
+ {$i18n.t('Mojeek Search API Key')} +
+ + +
{:else if webConfig.search.engine === 'serpstack'}
diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 8a1ef2d91..03080d9b5 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -216,7 +216,7 @@ } else { message.statusHistory = [data]; } - } else if (type === 'citation') { + } else if (type === 'source' || type === 'citation') { if (data?.type === 'code_execution') { // Code execution; update existing code execution by ID, or add new one. if (!message?.code_executions) { @@ -235,11 +235,11 @@ message.code_executions = message.code_executions; } else { - // Regular citation. - if (message?.citations) { - message.citations.push(data); + // Regular source. + if (message?.sources) { + message.sources.push(data); } else { - message.citations = [data]; + message.sources = [data]; } } } else if (type === 'message') { @@ -664,7 +664,7 @@ content: m.content, info: m.info ? m.info : undefined, timestamp: m.timestamp, - ...(m.citations ? { citations: m.citations } : {}) + ...(m.sources ? { sources: m.sources } : {}) })), chat_id: chatId, session_id: $socket?.id, @@ -718,7 +718,7 @@ content: m.content, info: m.info ? m.info : undefined, timestamp: m.timestamp, - ...(m.citations ? { citations: m.citations } : {}) + ...(m.sources ? { sources: m.sources } : {}) })), ...(event ? { event: event } : {}), chat_id: chatId, @@ -888,11 +888,10 @@ await tick(); // Reset chat input textarea - const chatInputContainer = document.getElementById('chat-input-container'); + const chatInputElement = document.getElementById('chat-input'); - if (chatInputContainer) { - chatInputContainer.value = ''; - chatInputContainer.style.height = ''; + if (chatInputElement) { + chatInputElement.style.height = ''; } const _files = JSON.parse(JSON.stringify(files)); @@ -1278,8 +1277,8 @@ console.log(line); let data = JSON.parse(line); - if ('citations' in data) { - responseMessage.citations = data.citations; + if ('sources' in data) { + responseMessage.sources = data.sources; // Only remove status if it was initially set if (model?.info?.meta?.knowledge ?? false) { responseMessage.statusHistory = responseMessage.statusHistory.filter( @@ -1632,7 +1631,7 @@ const textStream = await createOpenAITextStream(res.body, $settings.splitLargeChunks); for await (const update of textStream) { - const { value, done, citations, selectedModelId, error, usage } = update; + const { value, done, sources, selectedModelId, error, usage } = update; if (error) { await handleOpenAIError(error, null, model, responseMessage); break; @@ -1658,8 +1657,8 @@ continue; } - if (citations) { - responseMessage.citations = citations; + if (sources) { + responseMessage.sources = sources; // Only remove status if it was initially set if (model?.info?.meta?.knowledge ?? false) { responseMessage.statusHistory = responseMessage.statusHistory.filter( @@ -1938,7 +1937,7 @@ 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, usage } = update; + const { value, done, sources, error, usage } = update; if (error || done) { break; } @@ -1977,7 +1976,7 @@ } ); - return title; + return title ? title : (lastUserMessage?.content ?? 'New Chat'); } else { return lastUserMessage?.content ?? 'New Chat'; } @@ -2310,7 +2309,11 @@ on:submit={async (e) => { if (e.detail) { await tick(); - submitPrompt(e.detail.replaceAll('\n\n', '\n')); + submitPrompt( + ($settings?.richTextInput ?? true) + ? e.detail.replaceAll('\n\n', '\n') + : e.detail + ); } }} /> @@ -2347,7 +2350,11 @@ on:submit={async (e) => { if (e.detail) { await tick(); - submitPrompt(e.detail.replaceAll('\n\n', '\n')); + submitPrompt( + ($settings?.richTextInput ?? true) + ? e.detail.replaceAll('\n\n', '\n') + : e.detail + ); } }} /> diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index a869a1d99..16e3cdb91 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -75,14 +75,6 @@ (model) => $models.find((m) => m.id === model)?.info?.meta?.capabilities?.vision ?? true ); - $: if (prompt) { - if (chatInputContainerElement) { - chatInputContainerElement.style.height = ''; - chatInputContainerElement.style.height = - Math.min(chatInputContainerElement.scrollHeight, 200) + 'px'; - } - } - const scrollToBottom = () => { const element = document.getElementById('messages-container'); element.scrollTo({ @@ -585,54 +577,24 @@ {#if $settings?.richTextInput ?? true}
0 || navigator.msMaxTouchPoints > 0 )} - on:enter={async (e) => { - if (prompt !== '') { - dispatch('submit', prompt); - } - }} - on:input={async (e) => { - if (chatInputContainerElement) { - chatInputContainerElement.style.height = ''; - chatInputContainerElement.style.height = - Math.min(chatInputContainerElement.scrollHeight, 200) + 'px'; - } - }} - on:focus={async (e) => { - if (chatInputContainerElement) { - chatInputContainerElement.style.height = ''; - chatInputContainerElement.style.height = - Math.min(chatInputContainerElement.scrollHeight, 200) + 'px'; - } - }} - on:keypress={(e) => { - e = e.detail.event; - }} + placeholder={placeholder ? placeholder : $i18n.t('Send a Message')} + largeTextAsFile={$settings?.largeTextAsFile ?? false} + bind:value={prompt} on:keydown={async (e) => { e = e.detail.event; - if (chatInputContainerElement) { - chatInputContainerElement.style.height = ''; - chatInputContainerElement.style.height = - Math.min(chatInputContainerElement.scrollHeight, 200) + 'px'; - } - const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac const commandsContainerElement = document.getElementById('commands-container'); @@ -672,50 +634,70 @@ editButton?.click(); } - if (commandsContainerElement && e.key === 'ArrowUp') { - e.preventDefault(); - commandsElement.selectUp(); + 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 === 'Enter') { - e.preventDefault(); - - const commandOptionButton = [ - ...document.getElementsByClassName('selected-command-option-button') - ]?.at(-1); - - if (e.shiftKey) { - prompt = `${prompt}\n`; - } else if (commandOptionButton) { - commandOptionButton?.click(); - } else { - document.getElementById('send-message-button')?.click(); + const commandOptionButton = [ + ...document.getElementsByClassName('selected-command-option-button') + ]?.at(-1); + commandOptionButton.scrollIntoView({ block: 'center' }); } - } - if (commandsContainerElement && e.key === 'Tab') { - e.preventDefault(); + if (commandsContainerElement && e.key === 'ArrowDown') { + e.preventDefault(); + commandsElement.selectDown(); - const commandOptionButton = [ - ...document.getElementsByClassName('selected-command-option-button') - ]?.at(-1); + const commandOptionButton = [ + ...document.getElementsByClassName('selected-command-option-button') + ]?.at(-1); + commandOptionButton.scrollIntoView({ block: 'center' }); + } - commandOptionButton?.click(); + 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 prompt when Enter key is pressed + if (prompt !== '' && e.keyCode === 13 && !e.shiftKey) { + dispatch('submit', prompt); + } + } } if (e.key === 'Escape') { @@ -912,7 +894,6 @@ on:input={async (e) => { e.target.style.height = ''; e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px'; - user = null; }} on:focus={async (e) => { e.target.style.height = ''; diff --git a/src/lib/components/chat/Messages/Citations.svelte b/src/lib/components/chat/Messages/Citations.svelte index 79f8d2af6..b434b15db 100644 --- a/src/lib/components/chat/Messages/Citations.svelte +++ b/src/lib/components/chat/Messages/Citations.svelte @@ -7,9 +7,9 @@ const i18n = getContext('i18n'); - export let citations = []; + export let sources = []; - let _citations = []; + let citations = []; let showPercentage = false; let showRelevance = true; @@ -17,8 +17,8 @@ let selectedCitation: any = null; let isCollapsibleOpen = false; - function calculateShowRelevance(citations: any[]) { - const distances = citations.flatMap((citation) => citation.distances ?? []); + function calculateShowRelevance(sources: any[]) { + const distances = sources.flatMap((citation) => citation.distances ?? []); const inRange = distances.filter((d) => d !== undefined && d >= -1 && d <= 1).length; const outOfRange = distances.filter((d) => d !== undefined && (d < -1 || d > 1)).length; @@ -36,25 +36,31 @@ return true; } - function shouldShowPercentage(citations: any[]) { - const distances = citations.flatMap((citation) => citation.distances ?? []); + function shouldShowPercentage(sources: any[]) { + const distances = sources.flatMap((citation) => citation.distances ?? []); return distances.every((d) => d !== undefined && d >= -1 && d <= 1); } $: { - _citations = citations.reduce((acc, citation) => { - citation.document.forEach((document, index) => { - const metadata = citation.metadata?.[index]; - const distance = citation.distances?.[index]; + citations = sources.reduce((acc, source) => { + if (Object.keys(source).length === 0) { + return acc; + } + + source.document.forEach((document, index) => { + const metadata = source.metadata?.[index]; + const distance = source.distances?.[index]; + + // Within the same citation there could be multiple documents const id = metadata?.source ?? 'N/A'; - let source = citation?.source; + let _source = source?.source; if (metadata?.name) { - source = { ...source, name: metadata.name }; + _source = { ..._source, name: metadata.name }; } if (id.startsWith('http://') || id.startsWith('https://')) { - source = { ...source, name: id, url: id }; + _source = { ..._source, name: id, url: id }; } const existingSource = acc.find((item) => item.id === id); @@ -66,7 +72,7 @@ } else { acc.push({ id: id, - source: source, + source: _source, document: [document], metadata: metadata ? [metadata] : [], distances: distance !== undefined ? [distance] : undefined @@ -76,8 +82,8 @@ return acc; }, []); - showRelevance = calculateShowRelevance(_citations); - showPercentage = shouldShowPercentage(_citations); + showRelevance = calculateShowRelevance(citations); + showPercentage = shouldShowPercentage(citations); } @@ -88,24 +94,27 @@ {showRelevance} /> -{#if _citations.length > 0} +{#if citations.length > 0}
- {#if _citations.length <= 3} + {#if citations.length <= 3}
- {#each _citations as citation, idx} + {#each citations as citation, idx} @@ -120,7 +129,7 @@
- {#each _citations.slice(0, 2) as citation, idx} + {#each citations.slice(0, 2) as citation, idx}
- {_citations.length - 2} + {citations.length - 2} {$i18n.t('more')}
@@ -159,7 +168,7 @@
- {#each _citations as citation, idx} + {#each citations as citation, idx} diff --git a/src/lib/components/chat/Messages/RateComment.svelte b/src/lib/components/chat/Messages/RateComment.svelte index 39ae17318..c774738c5 100644 --- a/src/lib/components/chat/Messages/RateComment.svelte +++ b/src/lib/components/chat/Messages/RateComment.svelte @@ -136,7 +136,7 @@ class="size-7 text-sm border border-gray-50 dark:border-gray-850 hover:bg-gray-50 dark:hover:bg-gray-850 {detailedRating === rating ? 'bg-gray-100 dark:bg-gray-800' - : ''} transition rounded-full disabled:cursor-not-allowed disabled:bg-white disabled:dark:bg-gray-900" + : ''} transition rounded-full disabled:cursor-not-allowed disabled:text-gray-500 disabled:bg-white disabled:dark:bg-gray-900" on:click={() => { detailedRating = rating; }} diff --git a/src/lib/components/chat/Messages/ResponseMessage.svelte b/src/lib/components/chat/Messages/ResponseMessage.svelte index 9ba15362c..75d1dfb59 100644 --- a/src/lib/components/chat/Messages/ResponseMessage.svelte +++ b/src/lib/components/chat/Messages/ResponseMessage.svelte @@ -64,7 +64,7 @@ }; done: boolean; error?: boolean | { content: string }; - citations?: string[]; + sources?: string[]; code_executions?: { uuid: string; name: string; @@ -621,9 +621,18 @@ { + console.log(e); + const sourceButton = document.getElementById(`source-${e}`); + + if (sourceButton) { + sourceButton.click(); + } + }} on:update={(e) => { const { raw, oldContent, newContent } = e.detail; @@ -653,8 +662,8 @@ {/if} - {#if message.citations && (model?.info?.meta?.capabilities?.citations ?? true)} - + {#if (message?.sources || message?.citations) && (model?.info?.meta?.capabilities?.citations ?? true)} + {/if} {#if message.code_executions} diff --git a/src/lib/components/chat/Messages/UserMessage.svelte b/src/lib/components/chat/Messages/UserMessage.svelte index cad307421..7f9e78226 100644 --- a/src/lib/components/chat/Messages/UserMessage.svelte +++ b/src/lib/components/chat/Messages/UserMessage.svelte @@ -5,11 +5,7 @@ import { models, settings } from '$lib/stores'; import { user as _user } from '$lib/stores'; - import { - copyToClipboard as _copyToClipboard, - processResponseContent, - replaceTokens - } from '$lib/utils'; + import { copyToClipboard as _copyToClipboard } from '$lib/utils'; import Name from './Name.svelte'; import ProfileImage from './ProfileImage.svelte'; diff --git a/src/lib/components/chat/ModelSelector.svelte b/src/lib/components/chat/ModelSelector.svelte index 9b16a6500..9b77cd8ce 100644 --- a/src/lib/components/chat/ModelSelector.svelte +++ b/src/lib/components/chat/ModelSelector.svelte @@ -5,9 +5,7 @@ import Selector from './ModelSelector/Selector.svelte'; import Tooltip from '../common/Tooltip.svelte'; - import { setDefaultModels } from '$lib/apis/configs'; import { updateUserSettings } from '$lib/apis/users'; - const i18n = getContext('i18n'); export let selectedModels = ['']; diff --git a/src/lib/components/chat/Settings/General.svelte b/src/lib/components/chat/Settings/General.svelte index 9c1ccb7f7..694648206 100644 --- a/src/lib/components/chat/Settings/General.svelte +++ b/src/lib/components/chat/Settings/General.svelte @@ -55,6 +55,7 @@ mirostat_tau: null, top_k: null, top_p: null, + min_p: null, stop: null, tfs_z: null, num_ctx: null, @@ -340,6 +341,7 @@ mirostat_tau: params.mirostat_tau !== null ? params.mirostat_tau : undefined, top_k: params.top_k !== null ? params.top_k : undefined, top_p: params.top_p !== null ? params.top_p : undefined, + min_p: params.min_p !== null ? params.min_p : undefined, tfs_z: params.tfs_z !== null ? params.tfs_z : undefined, num_ctx: params.num_ctx !== null ? params.num_ctx : undefined, num_batch: params.num_batch !== null ? params.num_batch : undefined, diff --git a/src/lib/components/common/Modal.svelte b/src/lib/components/common/Modal.svelte index 795d3d0f1..86c741e23 100644 --- a/src/lib/components/common/Modal.svelte +++ b/src/lib/components/common/Modal.svelte @@ -6,7 +6,7 @@ export let show = true; export let size = 'md'; - export let className = 'bg-gray-50 dark:bg-gray-900 rounded-2xl'; + export let className = 'bg-gray-50 dark:bg-gray-900 rounded-2xl'; let modalElement = null; let mounted = false; @@ -65,7 +65,7 @@ - {#if $config?.features.enable_login_form} + {#if $config?.features.enable_login_form || $config?.features.enable_ldap}
{#if mode === 'signup'}
@@ -227,6 +227,7 @@ type="text" class="my-0.5 w-full text-sm outline-none bg-transparent" autocomplete="username" + name="username" placeholder={$i18n.t('Enter Your Username')} required /> @@ -239,6 +240,7 @@ type="email" class="my-0.5 w-full text-sm outline-none bg-transparent" autocomplete="email" + name="email" placeholder={$i18n.t('Enter Your Email')} required /> @@ -254,13 +256,14 @@ class="my-0.5 w-full text-sm outline-none bg-transparent" placeholder={$i18n.t('Enter Your Password')} autocomplete="current-password" + name="current-password" required />
{/if}
- {#if $config?.features.enable_login_form} + {#if $config?.features.enable_login_form || $config?.features.enable_ldap} {#if mode === 'ldap'}