chore: format

This commit is contained in:
Timothy Jaeryang Baek 2026-11-03 17:36:03 +09:00
parent d14e5c154f
commit 0c64e5d596
69 changed files with 4381 additions and 4272 deletions

View file

@ -60,22 +60,22 @@ docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v open-
ثم افتح [http://localhost:3000](http://localhost:3000).
| وسم الصورة | المحتوى |
| --- | --- |
| `:main` | صورة قياسية تتضمن اعتماديات الذكاء الاصطناعي المحلي |
| `:slim` | صورة خفيفة تستخدم خدمات خارجية للتضمينات واستخراج محتوى المستندات والصوت |
| `:cuda` | Open WebUI مع تسريع CUDA |
| `:ollama` | Open WebUI مرفقًا معه Ollama |
| `:dev` | أحدث نسخة تطوير |
| وسم الصورة | المحتوى |
| ---------- | ------------------------------------------------------------------------ |
| `:main` | صورة قياسية تتضمن اعتماديات الذكاء الاصطناعي المحلي |
| `:slim` | صورة خفيفة تستخدم خدمات خارجية للتضمينات واستخراج محتوى المستندات والصوت |
| `:cuda` | Open WebUI مع تسريع CUDA |
| `:ollama` | Open WebUI مرفقًا معه Ollama |
| `:dev` | أحدث نسخة تطوير |
أضف هذه الخيارات إلى `docker run` قبل اسم الصورة حسب الحاجة:
| الخيار | الغرض |
| --- | --- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | الاتصال بـ Ollama على خادم آخر |
| `-e OPENAI_API_KEY=your_secret_key` | تعيين مفتاح API الخاص بك لدى OpenAI |
| `-e HF_HUB_OFFLINE=1` | تعطيل تنزيل نماذج Hugging Face للاستخدام دون اتصال |
| `--gpus all` | تمكين الوصول إلى وحدة معالجة الرسوميات Nvidia مع `:cuda` أو `:ollama`؛ يتطلب Nvidia Container Toolkit |
| الخيار | الغرض |
| ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | الاتصال بـ Ollama على خادم آخر |
| `-e OPENAI_API_KEY=your_secret_key` | تعيين مفتاح API الخاص بك لدى OpenAI |
| `-e HF_HUB_OFFLINE=1` | تعطيل تنزيل نماذج Hugging Face للاستخدام دون اتصال |
| `--gpus all` | تمكين الوصول إلى وحدة معالجة الرسوميات Nvidia مع `:cuda` أو `:ollama`؛ يتطلب Nvidia Container Toolkit |
راجع [دليل التثبيت باستخدام Docker](https://docs.openwebui.com/getting-started/quick-start/) للاطلاع على خطوات الإعداد وخيارات الضبط.

View file

@ -60,22 +60,22 @@ docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v open-
এরপর [http://localhost:3000](http://localhost:3000) খুলুন।
| ইমেজ ট্যাগ | যা অন্তর্ভুক্ত আছে |
| --- | --- |
| `:main` | স্থানীয় AI-এর প্রয়োজনীয় ডিপেন্ডেন্সিসহ স্ট্যান্ডার্ড ইমেজ |
| `:slim` | এম্বেডিং, ডকুমেন্ট থেকে তথ্য আহরণ ও ভয়েসের জন্য বাহ্যিক সেবা ব্যবহার করা হালকা ইমেজ |
| `:cuda` | CUDA অ্যাক্সেলারেশনসহ Open WebUI |
| `:ollama` | Ollama-সহ Open WebUI |
| `:dev` | সর্বশেষ ডেভেলপমেন্ট বিল্ড |
| ইমেজ ট্যাগ | যা অন্তর্ভুক্ত আছে |
| ---------- | ------------------------------------------------------------------------------------ |
| `:main` | স্থানীয় AI-এর প্রয়োজনীয় ডিপেন্ডেন্সিসহ স্ট্যান্ডার্ড ইমেজ |
| `:slim` | এম্বেডিং, ডকুমেন্ট থেকে তথ্য আহরণ ও ভয়েসের জন্য বাহ্যিক সেবা ব্যবহার করা হালকা ইমেজ |
| `:cuda` | CUDA অ্যাক্সেলারেশনসহ Open WebUI |
| `:ollama` | Ollama-সহ Open WebUI |
| `:dev` | সর্বশেষ ডেভেলপমেন্ট বিল্ড |
প্রয়োজন অনুযায়ী `docker run` কমান্ডে ইমেজের নামের আগে এই অপশনগুলো যোগ করুন:
| অপশন | উদ্দেশ্য |
| --- | --- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | অন্য সার্ভারে থাকা Ollama-তে সংযোগ করা |
| `-e OPENAI_API_KEY=your_secret_key` | আপনার OpenAI API কী সেট করা |
| `-e HF_HUB_OFFLINE=1` | অফলাইনে ব্যবহারের জন্য Hugging Face মডেল ডাউনলোড বন্ধ করা |
| `--gpus all` | `:cuda` বা `:ollama`-এর জন্য Nvidia GPU ব্যবহারের অনুমতি দেওয়া; Nvidia Container Toolkit প্রয়োজন |
| অপশন | উদ্দেশ্য |
| ---------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | অন্য সার্ভারে থাকা Ollama-তে সংযোগ করা |
| `-e OPENAI_API_KEY=your_secret_key` | আপনার OpenAI API কী সেট করা |
| `-e HF_HUB_OFFLINE=1` | অফলাইনে ব্যবহারের জন্য Hugging Face মডেল ডাউনলোড বন্ধ করা |
| `--gpus all` | `:cuda` বা `:ollama`-এর জন্য Nvidia GPU ব্যবহারের অনুমতি দেওয়া; Nvidia Container Toolkit প্রয়োজন |
সেটআপের নির্দেশনা ও কনফিগারেশন অপশনের জন্য [Docker ইনস্টলেশন নির্দেশিকা](https://docs.openwebui.com/getting-started/quick-start/) দেখুন।

View file

@ -60,22 +60,22 @@ docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v open-
Depois, abra [http://localhost:3000](http://localhost:3000).
| Tag da imagem | Conteúdo |
| --- | --- |
| `:main` | Imagem padrão com dependências de IA local |
| `:slim` | Imagem leve que usa serviços externos para embeddings, extração de documentos e voz |
| `:cuda` | Open WebUI com aceleração CUDA |
| `:ollama` | Open WebUI com Ollama incluído |
| `:dev` | Build de desenvolvimento mais recente |
| Tag da imagem | Conteúdo |
| ------------- | ----------------------------------------------------------------------------------- |
| `:main` | Imagem padrão com dependências de IA local |
| `:slim` | Imagem leve que usa serviços externos para embeddings, extração de documentos e voz |
| `:cuda` | Open WebUI com aceleração CUDA |
| `:ollama` | Open WebUI com Ollama incluído |
| `:dev` | Build de desenvolvimento mais recente |
Adicione estas opções ao `docker run` antes do nome da imagem, conforme necessário:
| Opção | Finalidade |
| --- | --- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | Conectar ao Ollama em outro servidor |
| `-e OPENAI_API_KEY=your_secret_key` | Definir sua chave de API da OpenAI |
| `-e HF_HUB_OFFLINE=1` | Desativar downloads de modelos do Hugging Face para uso offline |
| `--gpus all` | Habilitar acesso à GPU Nvidia para `:cuda` ou `:ollama`; requer o Nvidia Container Toolkit |
| Opção | Finalidade |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | Conectar ao Ollama em outro servidor |
| `-e OPENAI_API_KEY=your_secret_key` | Definir sua chave de API da OpenAI |
| `-e HF_HUB_OFFLINE=1` | Desativar downloads de modelos do Hugging Face para uso offline |
| `--gpus all` | Habilitar acesso à GPU Nvidia para `:cuda` ou `:ollama`; requer o Nvidia Container Toolkit |
Consulte o [guia de instalação com Docker](https://docs.openwebui.com/getting-started/quick-start/) para instruções e opções de configuração.

View file

@ -60,22 +60,22 @@ docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v open-
Zatim otvorite [http://localhost:3000](http://localhost:3000).
| Oznaka slike | Sadržaj |
| --- | --- |
| `:main` | Standardna slika sa zavisnostima za lokalni AI |
| `:slim` | Lagana slika koja koristi vanjske servise za embeddinge, izdvajanje sadržaja dokumenata i govor |
| `:cuda` | Open WebUI s CUDA ubrzanjem |
| `:ollama` | Open WebUI s uključenom Ollamom |
| `:dev` | Najnovija razvojna verzija |
| Oznaka slike | Sadržaj |
| ------------ | ----------------------------------------------------------------------------------------------- |
| `:main` | Standardna slika sa zavisnostima za lokalni AI |
| `:slim` | Lagana slika koja koristi vanjske servise za embeddinge, izdvajanje sadržaja dokumenata i govor |
| `:cuda` | Open WebUI s CUDA ubrzanjem |
| `:ollama` | Open WebUI s uključenom Ollamom |
| `:dev` | Najnovija razvojna verzija |
Po potrebi dodajte ove opcije u `docker run` prije naziva slike:
| Opcija | Namjena |
| --- | --- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | Povezivanje s Ollamom na drugom serveru |
| `-e OPENAI_API_KEY=your_secret_key` | Postavljanje OpenAI API ključa |
| `-e HF_HUB_OFFLINE=1` | Isključivanje preuzimanja Hugging Face modela za rad offline |
| `--gpus all` | Omogućavanje pristupa Nvidia GPU-u za `:cuda` ili `:ollama`; potreban je Nvidia Container Toolkit |
| Opcija | Namjena |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | Povezivanje s Ollamom na drugom serveru |
| `-e OPENAI_API_KEY=your_secret_key` | Postavljanje OpenAI API ključa |
| `-e HF_HUB_OFFLINE=1` | Isključivanje preuzimanja Hugging Face modela za rad offline |
| `--gpus all` | Omogućavanje pristupa Nvidia GPU-u za `:cuda` ili `:ollama`; potreban je Nvidia Container Toolkit |
Upute i opcije konfiguracije nalaze se u [vodiču za instalaciju putem Dockera](https://docs.openwebui.com/getting-started/quick-start/).

View file

@ -60,22 +60,22 @@ docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v open-
Åbn derefter [http://localhost:3000](http://localhost:3000).
| Image-tag | Indhold |
| --- | --- |
| `:main` | Standardimage med afhængigheder til lokal AI |
| `:slim` | Letvægtsimage med eksterne tjenester til embeddings, dokumentudtræk og tale |
| `:cuda` | Open WebUI med CUDA-acceleration |
| `:ollama` | Open WebUI med Ollama inkluderet |
| `:dev` | Nyeste udviklingsbuild |
| Image-tag | Indhold |
| --------- | --------------------------------------------------------------------------- |
| `:main` | Standardimage med afhængigheder til lokal AI |
| `:slim` | Letvægtsimage med eksterne tjenester til embeddings, dokumentudtræk og tale |
| `:cuda` | Open WebUI med CUDA-acceleration |
| `:ollama` | Open WebUI med Ollama inkluderet |
| `:dev` | Nyeste udviklingsbuild |
Tilføj disse indstillinger til `docker run` før image-navnet efter behov:
| Indstilling | Formål |
| --- | --- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | Opret forbindelse til Ollama på en anden server |
| `-e OPENAI_API_KEY=your_secret_key` | Angiv din OpenAI-API-nøgle |
| `-e HF_HUB_OFFLINE=1` | Deaktiver download af Hugging Face-modeller til offlinebrug |
| `--gpus all` | Aktiver Nvidia-GPU-adgang for `:cuda` eller `:ollama`; kræver Nvidia Container Toolkit |
| Indstilling | Formål |
| ---------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | Opret forbindelse til Ollama på en anden server |
| `-e OPENAI_API_KEY=your_secret_key` | Angiv din OpenAI-API-nøgle |
| `-e HF_HUB_OFFLINE=1` | Deaktiver download af Hugging Face-modeller til offlinebrug |
| `--gpus all` | Aktiver Nvidia-GPU-adgang for `:cuda` eller `:ollama`; kræver Nvidia Container Toolkit |
Se [installationsvejledningen til Docker](https://docs.openwebui.com/getting-started/quick-start/) for opsætning og konfigurationsmuligheder.

View file

@ -60,22 +60,22 @@ docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v open-
Öffne anschließend [http://localhost:3000](http://localhost:3000).
| Image-Tag | Inhalt |
| --- | --- |
| `:main` | Standard-Image mit Abhängigkeiten für lokale KI |
| `:slim` | Schlankes Image mit externen Diensten für Embeddings, Dokumentenextraktion und Sprache |
| `:cuda` | Open WebUI mit CUDA-Beschleunigung |
| `:ollama` | Open WebUI mit integriertem Ollama |
| `:dev` | Neuester Entwicklungsbuild |
| Image-Tag | Inhalt |
| --------- | -------------------------------------------------------------------------------------- |
| `:main` | Standard-Image mit Abhängigkeiten für lokale KI |
| `:slim` | Schlankes Image mit externen Diensten für Embeddings, Dokumentenextraktion und Sprache |
| `:cuda` | Open WebUI mit CUDA-Beschleunigung |
| `:ollama` | Open WebUI mit integriertem Ollama |
| `:dev` | Neuester Entwicklungsbuild |
Füge diese Optionen bei Bedarf vor dem Image-Namen zu `docker run` hinzu:
| Option | Zweck |
| --- | --- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | Verbindung zu Ollama auf einem anderen Server |
| `-e OPENAI_API_KEY=your_secret_key` | OpenAI-API-Schlüssel festlegen |
| `-e HF_HUB_OFFLINE=1` | Downloads von Hugging-Face-Modellen für den Offlinebetrieb deaktivieren |
| `--gpus all` | Nvidia-GPU-Zugriff für `:cuda` oder `:ollama` aktivieren; erfordert das Nvidia Container Toolkit |
| Option | Zweck |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | Verbindung zu Ollama auf einem anderen Server |
| `-e OPENAI_API_KEY=your_secret_key` | OpenAI-API-Schlüssel festlegen |
| `-e HF_HUB_OFFLINE=1` | Downloads von Hugging-Face-Modellen für den Offlinebetrieb deaktivieren |
| `--gpus all` | Nvidia-GPU-Zugriff für `:cuda` oder `:ollama` aktivieren; erfordert das Nvidia Container Toolkit |
Einrichtung und Konfigurationsoptionen findest du in der [Docker-Installationsanleitung](https://docs.openwebui.com/getting-started/quick-start/).

View file

@ -60,22 +60,22 @@ docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v open-
Después, abre [http://localhost:3000](http://localhost:3000).
| Etiqueta de imagen | Contenido |
| --- | --- |
| `:main` | Imagen estándar con dependencias de IA local |
| `:slim` | Imagen ligera que usa servicios externos para embeddings, extracción de documentos y voz |
| `:cuda` | Open WebUI con aceleración CUDA |
| `:ollama` | Open WebUI con Ollama incluido |
| `:dev` | Última compilación de desarrollo |
| Etiqueta de imagen | Contenido |
| ------------------ | ---------------------------------------------------------------------------------------- |
| `:main` | Imagen estándar con dependencias de IA local |
| `:slim` | Imagen ligera que usa servicios externos para embeddings, extracción de documentos y voz |
| `:cuda` | Open WebUI con aceleración CUDA |
| `:ollama` | Open WebUI con Ollama incluido |
| `:dev` | Última compilación de desarrollo |
Añade estas opciones a `docker run` antes del nombre de la imagen según lo necesites:
| Opción | Función |
| --- | --- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | Conectarse a Ollama en otro servidor |
| `-e OPENAI_API_KEY=your_secret_key` | Configurar tu clave de API de OpenAI |
| `-e HF_HUB_OFFLINE=1` | Desactivar las descargas de modelos de Hugging Face para el uso sin conexión |
| `--gpus all` | Habilitar el acceso a GPU Nvidia para `:cuda` o `:ollama`; requiere Nvidia Container Toolkit |
| Opción | Función |
| ---------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | Conectarse a Ollama en otro servidor |
| `-e OPENAI_API_KEY=your_secret_key` | Configurar tu clave de API de OpenAI |
| `-e HF_HUB_OFFLINE=1` | Desactivar las descargas de modelos de Hugging Face para el uso sin conexión |
| `--gpus all` | Habilitar el acceso a GPU Nvidia para `:cuda` o `:ollama`; requiere Nvidia Container Toolkit |
Consulta la [guía de instalación con Docker](https://docs.openwebui.com/getting-started/quick-start/) para ver las instrucciones y opciones de configuración.

View file

@ -60,22 +60,22 @@ docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v open-
Ouvrez ensuite [http://localhost:3000](http://localhost:3000).
| Tag de l’image | Contenu |
| --- | --- |
| `:main` | Image standard avec les dépendances d’IA locales |
| `:slim` | Image légère utilisant des services externes pour les embeddings, l’extraction de documents et la voix |
| `:cuda` | Open WebUI avec accélération CUDA |
| `:ollama` | Open WebUI avec Ollama intégré |
| `:dev` | Dernière version de développement |
| Tag de l’image | Contenu |
| -------------- | ------------------------------------------------------------------------------------------------------ |
| `:main` | Image standard avec les dépendances d’IA locales |
| `:slim` | Image légère utilisant des services externes pour les embeddings, l’extraction de documents et la voix |
| `:cuda` | Open WebUI avec accélération CUDA |
| `:ollama` | Open WebUI avec Ollama intégré |
| `:dev` | Dernière version de développement |
Ajoutez ces options à `docker run` avant le nom de l’image, selon vos besoins :
| Option | Fonction |
| --- | --- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | Se connecter à Ollama sur un autre serveur |
| `-e OPENAI_API_KEY=your_secret_key` | Définir votre clé API OpenAI |
| `-e HF_HUB_OFFLINE=1` | Désactiver le téléchargement des modèles Hugging Face pour une utilisation hors ligne |
| `--gpus all` | Activer l’accès aux GPU Nvidia pour `:cuda` ou `:ollama` ; nécessite Nvidia Container Toolkit |
| Option | Fonction |
| ---------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | Se connecter à Ollama sur un autre serveur |
| `-e OPENAI_API_KEY=your_secret_key` | Définir votre clé API OpenAI |
| `-e HF_HUB_OFFLINE=1` | Désactiver le téléchargement des modèles Hugging Face pour une utilisation hors ligne |
| `--gpus all` | Activer l’accès aux GPU Nvidia pour `:cuda` ou `:ollama` ; nécessite Nvidia Container Toolkit |
Consultez le [guide d’installation Docker](https://docs.openwebui.com/getting-started/quick-start/) pour les instructions et les options de configuration.

View file

@ -60,22 +60,22 @@ docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v open-
Στη συνέχεια, ανοίξτε το [http://localhost:3000](http://localhost:3000).
| Ετικέτα εικόνας | Περιεχόμενο |
| --- | --- |
| `:main` | Τυπική εικόνα με εξαρτήσεις για τοπική AI |
| `:slim` | Ελαφριά εικόνα που χρησιμοποιεί εξωτερικές υπηρεσίες για embeddings, εξαγωγή περιεχομένου εγγράφων και φωνή |
| `:cuda` | Open WebUI με επιτάχυνση CUDA |
| `:ollama` | Open WebUI με ενσωματωμένο Ollama |
| `:dev` | Νεότερη έκδοση ανάπτυξης |
| Ετικέτα εικόνας | Περιεχόμενο |
| --------------- | ----------------------------------------------------------------------------------------------------------- |
| `:main` | Τυπική εικόνα με εξαρτήσεις για τοπική AI |
| `:slim` | Ελαφριά εικόνα που χρησιμοποιεί εξωτερικές υπηρεσίες για embeddings, εξαγωγή περιεχομένου εγγράφων και φωνή |
| `:cuda` | Open WebUI με επιτάχυνση CUDA |
| `:ollama` | Open WebUI με ενσωματωμένο Ollama |
| `:dev` | Νεότερη έκδοση ανάπτυξης |
Προσθέστε αυτές τις επιλογές στο `docker run` πριν από το όνομα της εικόνας, ανάλογα με τις ανάγκες σας:
| Επιλογή | Σκοπός |
| --- | --- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | Σύνδεση με Ollama σε άλλον διακομιστή |
| `-e OPENAI_API_KEY=your_secret_key` | Ορισμός του κλειδιού API OpenAI |
| `-e HF_HUB_OFFLINE=1` | Απενεργοποίηση λήψεων μοντέλων Hugging Face για χρήση εκτός σύνδεσης |
| `--gpus all` | Ενεργοποίηση πρόσβασης σε GPU Nvidia για `:cuda` ή `:ollama`· απαιτείται Nvidia Container Toolkit |
| Επιλογή | Σκοπός |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | Σύνδεση με Ollama σε άλλον διακομιστή |
| `-e OPENAI_API_KEY=your_secret_key` | Ορισμός του κλειδιού API OpenAI |
| `-e HF_HUB_OFFLINE=1` | Απενεργοποίηση λήψεων μοντέλων Hugging Face για χρήση εκτός σύνδεσης |
| `--gpus all` | Ενεργοποίηση πρόσβασης σε GPU Nvidia για `:cuda` ή `:ollama`· απαιτείται Nvidia Container Toolkit |
Δείτε τον [οδηγό εγκατάστασης με Docker](https://docs.openwebui.com/getting-started/quick-start/) για οδηγίες και επιλογές διαμόρφωσης.

View file

@ -60,22 +60,22 @@ docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v open-
Poi apri [http://localhost:3000](http://localhost:3000).
| Tag dell’immagine | Contenuto |
| --- | --- |
| `:main` | Immagine standard con dipendenze per l’IA locale |
| `:slim` | Immagine leggera che usa servizi esterni per embedding, estrazione dei documenti e voce |
| `:cuda` | Open WebUI con accelerazione CUDA |
| `:ollama` | Open WebUI con Ollama incluso |
| `:dev` | Ultima build di sviluppo |
| Tag dell’immagine | Contenuto |
| ----------------- | --------------------------------------------------------------------------------------- |
| `:main` | Immagine standard con dipendenze per l’IA locale |
| `:slim` | Immagine leggera che usa servizi esterni per embedding, estrazione dei documenti e voce |
| `:cuda` | Open WebUI con accelerazione CUDA |
| `:ollama` | Open WebUI con Ollama incluso |
| `:dev` | Ultima build di sviluppo |
Aggiungi queste opzioni a `docker run` prima del nome dell’immagine, secondo necessità:
| Opzione | Scopo |
| --- | --- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | Collegarsi a Ollama su un altro server |
| `-e OPENAI_API_KEY=your_secret_key` | Impostare la chiave API OpenAI |
| `-e HF_HUB_OFFLINE=1` | Disabilitare i download dei modelli Hugging Face per l’uso offline |
| `--gpus all` | Abilitare l’accesso alla GPU Nvidia per `:cuda` o `:ollama`; richiede Nvidia Container Toolkit |
| Opzione | Scopo |
| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | Collegarsi a Ollama su un altro server |
| `-e OPENAI_API_KEY=your_secret_key` | Impostare la chiave API OpenAI |
| `-e HF_HUB_OFFLINE=1` | Disabilitare i download dei modelli Hugging Face per l’uso offline |
| `--gpus all` | Abilitare l’accesso alla GPU Nvidia per `:cuda` o `:ollama`; richiede Nvidia Container Toolkit |
Consulta la [guida all’installazione con Docker](https://docs.openwebui.com/getting-started/quick-start/) per le istruzioni e le opzioni di configurazione.

View file

@ -60,22 +60,22 @@ docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v open-
その後、[http://localhost:3000](http://localhost:3000) を開きます。
| イメージタグ | 内容 |
| --- | --- |
| `:main` | ローカル AI 用の依存パッケージを含む標準イメージ |
| `:slim` | 埋め込み、ドキュメント抽出、音声処理に外部サービスを使う軽量イメージ |
| `:cuda` | CUDA アクセラレーション対応の Open WebUI |
| `:ollama` | Ollama を同梱した Open WebUI |
| `:dev` | 最新の開発ビルド |
| イメージタグ | 内容 |
| ------------ | -------------------------------------------------------------------- |
| `:main` | ローカル AI 用の依存パッケージを含む標準イメージ |
| `:slim` | 埋め込み、ドキュメント抽出、音声処理に外部サービスを使う軽量イメージ |
| `:cuda` | CUDA アクセラレーション対応の Open WebUI |
| `:ollama` | Ollama を同梱した Open WebUI |
| `:dev` | 最新の開発ビルド |
必要に応じて、`docker run` のイメージ名の前に以下のオプションを追加してください。
| オプション | 用途 |
| --- | --- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | 別のサーバー上の Ollama に接続 |
| `-e OPENAI_API_KEY=your_secret_key` | OpenAI API キーを設定 |
| `-e HF_HUB_OFFLINE=1` | オフライン利用のため Hugging Face のモデルダウンロードを無効化 |
| `--gpus all` | `:cuda` または `:ollama` で Nvidia GPU を利用。Nvidia Container Toolkit が必要 |
| オプション | 用途 |
| ---------------------------------------------------- | ------------------------------------------------------------------------------ |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | 別のサーバー上の Ollama に接続 |
| `-e OPENAI_API_KEY=your_secret_key` | OpenAI API キーを設定 |
| `-e HF_HUB_OFFLINE=1` | オフライン利用のため Hugging Face のモデルダウンロードを無効化 |
| `--gpus all` | `:cuda` または `:ollama` で Nvidia GPU を利用。Nvidia Container Toolkit が必要 |
セットアップ手順と設定項目は [Docker インストールガイド](https://docs.openwebui.com/getting-started/quick-start/)をご覧ください。

View file

@ -60,22 +60,22 @@ docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v open-
그런 다음 [http://localhost:3000](http://localhost:3000)을 여세요.
| 이미지 태그 | 구성 |
| --- | --- |
| `:main` | 로컬 AI 의존성이 포함된 표준 이미지 |
| `:slim` | 임베딩, 문서 추출, 음성 처리에 외부 서비스를 사용하는 경량 이미지 |
| `:cuda` | CUDA 가속을 지원하는 Open WebUI |
| `:ollama` | Ollama가 포함된 Open WebUI |
| `:dev` | 최신 개발 빌드 |
| 이미지 태그 | 구성 |
| ----------- | ----------------------------------------------------------------- |
| `:main` | 로컬 AI 의존성이 포함된 표준 이미지 |
| `:slim` | 임베딩, 문서 추출, 음성 처리에 외부 서비스를 사용하는 경량 이미지 |
| `:cuda` | CUDA 가속을 지원하는 Open WebUI |
| `:ollama` | Ollama가 포함된 Open WebUI |
| `:dev` | 최신 개발 빌드 |
필요에 따라 `docker run`의 이미지 이름 앞에 다음 옵션을 추가하세요.
| 옵션 | 용도 |
| --- | --- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | 다른 서버의 Ollama에 연결 |
| `-e OPENAI_API_KEY=your_secret_key` | OpenAI API 키 설정 |
| `-e HF_HUB_OFFLINE=1` | 오프라인 사용을 위해 Hugging Face 모델 다운로드 비활성화 |
| `--gpus all` | `:cuda` 또는 `:ollama`에서 Nvidia GPU 사용 활성화. Nvidia Container Toolkit 필요 |
| 옵션 | 용도 |
| ---------------------------------------------------- | -------------------------------------------------------------------------------- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | 다른 서버의 Ollama에 연결 |
| `-e OPENAI_API_KEY=your_secret_key` | OpenAI API 키 설정 |
| `-e HF_HUB_OFFLINE=1` | 오프라인 사용을 위해 Hugging Face 모델 다운로드 비활성화 |
| `--gpus all` | `:cuda` 또는 `:ollama`에서 Nvidia GPU 사용 활성화. Nvidia Container Toolkit 필요 |
설치 방법과 설정 옵션은 [Docker 설치 가이드](https://docs.openwebui.com/getting-started/quick-start/)를 참고하세요.

View file

@ -60,22 +60,22 @@ docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v open-
Then open [http://localhost:3000](http://localhost:3000).
| Image tag | Includes |
| --- | --- |
| `:main` | Standard image with local AI dependencies |
| `:slim` | Lightweight image using external services for embeddings, document extraction, and voice |
| `:cuda` | Open WebUI with CUDA acceleration |
| `:ollama` | Open WebUI bundled with Ollama |
| `:dev` | Latest development build |
| Image tag | Includes |
| --------- | ---------------------------------------------------------------------------------------- |
| `:main` | Standard image with local AI dependencies |
| `:slim` | Lightweight image using external services for embeddings, document extraction, and voice |
| `:cuda` | Open WebUI with CUDA acceleration |
| `:ollama` | Open WebUI bundled with Ollama |
| `:dev` | Latest development build |
Add these options to `docker run` before the image name as needed:
| Option | Purpose |
| --- | --- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | Connect to Ollama on another server |
| `-e OPENAI_API_KEY=your_secret_key` | Set your OpenAI API key |
| `-e HF_HUB_OFFLINE=1` | Disable Hugging Face model downloads for offline use |
| `--gpus all` | Enable Nvidia GPU access for `:cuda` or `:ollama`; requires the Nvidia Container Toolkit |
| Option | Purpose |
| ---------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | Connect to Ollama on another server |
| `-e OPENAI_API_KEY=your_secret_key` | Set your OpenAI API key |
| `-e HF_HUB_OFFLINE=1` | Disable Hugging Face model downloads for offline use |
| `--gpus all` | Enable Nvidia GPU access for `:cuda` or `:ollama`; requires the Nvidia Container Toolkit |
See the [Docker installation guide](https://docs.openwebui.com/getting-started/quick-start/) for setup instructions and configuration options.

View file

@ -60,22 +60,22 @@ docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v open-
Åpne deretter [http://localhost:3000](http://localhost:3000).
| Image-tagg | Innhold |
| --- | --- |
| `:main` | Standardimage med avhengigheter for lokal KI |
| `:slim` | Lettvektsimage med eksterne tjenester for embeddings, dokumentuttrekk og tale |
| `:cuda` | Open WebUI med CUDA-akselerasjon |
| `:ollama` | Open WebUI med Ollama inkludert |
| `:dev` | Nyeste utviklingsbygg |
| Image-tagg | Innhold |
| ---------- | ----------------------------------------------------------------------------- |
| `:main` | Standardimage med avhengigheter for lokal KI |
| `:slim` | Lettvektsimage med eksterne tjenester for embeddings, dokumentuttrekk og tale |
| `:cuda` | Open WebUI med CUDA-akselerasjon |
| `:ollama` | Open WebUI med Ollama inkludert |
| `:dev` | Nyeste utviklingsbygg |
Legg til disse valgene i `docker run` før image-navnet etter behov:
| Valg | Formål |
| --- | --- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | Koble til Ollama på en annen server |
| `-e OPENAI_API_KEY=your_secret_key` | Angi OpenAI-API-nøkkelen din |
| `-e HF_HUB_OFFLINE=1` | Deaktiver nedlasting av Hugging Face-modeller for bruk uten nettilgang |
| `--gpus all` | Aktiver tilgang til Nvidia-GPU for `:cuda` eller `:ollama`; krever Nvidia Container Toolkit |
| Valg | Formål |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | Koble til Ollama på en annen server |
| `-e OPENAI_API_KEY=your_secret_key` | Angi OpenAI-API-nøkkelen din |
| `-e HF_HUB_OFFLINE=1` | Deaktiver nedlasting av Hugging Face-modeller for bruk uten nettilgang |
| `--gpus all` | Aktiver tilgang til Nvidia-GPU for `:cuda` eller `:ollama`; krever Nvidia Container Toolkit |
Se [installasjonsveiledningen for Docker](https://docs.openwebui.com/getting-started/quick-start/) for oppsett og konfigurasjonsvalg.

View file

@ -60,22 +60,22 @@ docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v open-
Następnie otwórz [http://localhost:3000](http://localhost:3000).
| Tag obrazu | Zawartość |
| --- | --- |
| `:main` | Standardowy obraz z zależnościami dla lokalnej AI |
| `:slim` | Lekki obraz korzystający z usług zewnętrznych do osadzeń, ekstrakcji dokumentów i obsługi głosu |
| `:cuda` | Open WebUI z akceleracją CUDA |
| `:ollama` | Open WebUI z dołączonym Ollama |
| `:dev` | Najnowsza kompilacja rozwojowa |
| Tag obrazu | Zawartość |
| ---------- | ----------------------------------------------------------------------------------------------- |
| `:main` | Standardowy obraz z zależnościami dla lokalnej AI |
| `:slim` | Lekki obraz korzystający z usług zewnętrznych do osadzeń, ekstrakcji dokumentów i obsługi głosu |
| `:cuda` | Open WebUI z akceleracją CUDA |
| `:ollama` | Open WebUI z dołączonym Ollama |
| `:dev` | Najnowsza kompilacja rozwojowa |
W razie potrzeby dodaj te opcje do `docker run` przed nazwą obrazu:
| Opcja | Zastosowanie |
| --- | --- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | Połączenie z Ollama na innym serwerze |
| `-e OPENAI_API_KEY=your_secret_key` | Ustawienie klucza API OpenAI |
| `-e HF_HUB_OFFLINE=1` | Wyłączenie pobierania modeli Hugging Face do pracy offline |
| `--gpus all` | Włączenie dostępu do GPU Nvidia dla `:cuda` lub `:ollama`; wymaga Nvidia Container Toolkit |
| Opcja | Zastosowanie |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | Połączenie z Ollama na innym serwerze |
| `-e OPENAI_API_KEY=your_secret_key` | Ustawienie klucza API OpenAI |
| `-e HF_HUB_OFFLINE=1` | Wyłączenie pobierania modeli Hugging Face do pracy offline |
| `--gpus all` | Włączenie dostępu do GPU Nvidia dla `:cuda` lub `:ollama`; wymaga Nvidia Container Toolkit |
Instrukcje i opcje konfiguracji znajdziesz w [przewodniku instalacji przez Docker](https://docs.openwebui.com/getting-started/quick-start/).

View file

@ -60,22 +60,22 @@ docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v open-
Затем откройте [http://localhost:3000](http://localhost:3000).
| Тег образа | Содержимое |
| --- | --- |
| `:main` | Стандартный образ с зависимостями для локального ИИ |
| `:slim` | Облегчённый образ с внешними сервисами для эмбеддингов, извлечения данных из документов и обработки речи |
| `:cuda` | Open WebUI с ускорением CUDA |
| `:ollama` | Open WebUI со встроенным Ollama |
| `:dev` | Последняя сборка для разработки |
| Тег образа | Содержимое |
| ---------- | -------------------------------------------------------------------------------------------------------- |
| `:main` | Стандартный образ с зависимостями для локального ИИ |
| `:slim` | Облегчённый образ с внешними сервисами для эмбеддингов, извлечения данных из документов и обработки речи |
| `:cuda` | Open WebUI с ускорением CUDA |
| `:ollama` | Open WebUI со встроенным Ollama |
| `:dev` | Последняя сборка для разработки |
При необходимости добавьте эти параметры в `docker run` перед именем образа:
| Параметр | Назначение |
| --- | --- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | Подключение к Ollama на другом сервере |
| `-e OPENAI_API_KEY=your_secret_key` | Установка ключа API OpenAI |
| `-e HF_HUB_OFFLINE=1` | Отключение загрузки моделей Hugging Face для работы офлайн |
| `--gpus all` | Доступ к GPU Nvidia для `:cuda` или `:ollama`; требуется Nvidia Container Toolkit |
| Параметр | Назначение |
| ---------------------------------------------------- | --------------------------------------------------------------------------------- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | Подключение к Ollama на другом сервере |
| `-e OPENAI_API_KEY=your_secret_key` | Установка ключа API OpenAI |
| `-e HF_HUB_OFFLINE=1` | Отключение загрузки моделей Hugging Face для работы офлайн |
| `--gpus all` | Доступ к GPU Nvidia для `:cuda` или `:ollama`; требуется Nvidia Container Toolkit |
Инструкции и параметры настройки приведены в [руководстве по установке через Docker](https://docs.openwebui.com/getting-started/quick-start/).

View file

@ -60,22 +60,22 @@ docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v open-
จากนั้นเปิด [http://localhost:3000](http://localhost:3000)
| แท็กอิมเมจ | สิ่งที่มีให้ |
| --- | --- |
| `:main` | อิมเมจมาตรฐานพร้อมแพ็กเกจที่จำเป็นสำหรับ AI ภายในเครื่อง |
| `:slim` | อิมเมจขนาดเล็กที่ใช้บริการภายนอกสำหรับ embeddings การสกัดข้อมูลจากเอกสาร และเสียง |
| `:cuda` | Open WebUI พร้อมการเร่งความเร็วด้วย CUDA |
| `:ollama` | Open WebUI ที่รวม Ollama มาให้ |
| `:dev` | บิลด์สำหรับการพัฒนาล่าสุด |
| แท็กอิมเมจ | สิ่งที่มีให้ |
| ---------- | --------------------------------------------------------------------------------- |
| `:main` | อิมเมจมาตรฐานพร้อมแพ็กเกจที่จำเป็นสำหรับ AI ภายในเครื่อง |
| `:slim` | อิมเมจขนาดเล็กที่ใช้บริการภายนอกสำหรับ embeddings การสกัดข้อมูลจากเอกสาร และเสียง |
| `:cuda` | Open WebUI พร้อมการเร่งความเร็วด้วย CUDA |
| `:ollama` | Open WebUI ที่รวม Ollama มาให้ |
| `:dev` | บิลด์สำหรับการพัฒนาล่าสุด |
เพิ่มตัวเลือกเหล่านี้ใน `docker run` ก่อนชื่ออิมเมจได้ตามต้องการ:
| ตัวเลือก | จุดประสงค์ |
| --- | --- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | เชื่อมต่อกับ Ollama บนเซิร์ฟเวอร์อื่น |
| `-e OPENAI_API_KEY=your_secret_key` | กำหนดคีย์ API ของ OpenAI |
| `-e HF_HUB_OFFLINE=1` | ปิดการดาวน์โหลดโมเดลจาก Hugging Face สำหรับการใช้งานออฟไลน์ |
| `--gpus all` | เปิดใช้ GPU Nvidia สำหรับ `:cuda` หรือ `:ollama` โดยต้องมี Nvidia Container Toolkit |
| ตัวเลือก | จุดประสงค์ |
| ---------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | เชื่อมต่อกับ Ollama บนเซิร์ฟเวอร์อื่น |
| `-e OPENAI_API_KEY=your_secret_key` | กำหนดคีย์ API ของ OpenAI |
| `-e HF_HUB_OFFLINE=1` | ปิดการดาวน์โหลดโมเดลจาก Hugging Face สำหรับการใช้งานออฟไลน์ |
| `--gpus all` | เปิดใช้ GPU Nvidia สำหรับ `:cuda` หรือ `:ollama` โดยต้องมี Nvidia Container Toolkit |
ดูขั้นตอนการติดตั้งและตัวเลือกการตั้งค่าใน[คู่มือการติดตั้งด้วย Docker](https://docs.openwebui.com/getting-started/quick-start/)

View file

@ -60,22 +60,22 @@ docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v open-
Ardından [http://localhost:3000](http://localhost:3000) adresini açın.
| İmaj etiketi | İçerik |
| --- | --- |
| `:main` | Yerel yapay zekâ bağımlılıklarını içeren standart imaj |
| `:slim` | Gömme vektörleri, belge çıkarımı ve ses için harici hizmetler kullanan hafif imaj |
| `:cuda` | CUDA hızlandırmalı Open WebUI |
| `:ollama` | Ollama ile birlikte gelen Open WebUI |
| `:dev` | En son geliştirme derlemesi |
| İmaj etiketi | İçerik |
| ------------ | --------------------------------------------------------------------------------- |
| `:main` | Yerel yapay zekâ bağımlılıklarını içeren standart imaj |
| `:slim` | Gömme vektörleri, belge çıkarımı ve ses için harici hizmetler kullanan hafif imaj |
| `:cuda` | CUDA hızlandırmalı Open WebUI |
| `:ollama` | Ollama ile birlikte gelen Open WebUI |
| `:dev` | En son geliştirme derlemesi |
Gerektiğinde bu seçenekleri `docker run` komutunda imaj adından önce ekleyin:
| Seçenek | Amaç |
| --- | --- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | Başka bir sunucudaki Ollama’ya bağlanma |
| `-e OPENAI_API_KEY=your_secret_key` | OpenAI API anahtarınızı ayarlama |
| `-e HF_HUB_OFFLINE=1` | Çevrimdışı kullanım için Hugging Face model indirmelerini devre dışı bırakma |
| `--gpus all` | `:cuda` veya `:ollama` için Nvidia GPU erişimini etkinleştirme; Nvidia Container Toolkit gerektirir |
| Seçenek | Amaç |
| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | Başka bir sunucudaki Ollama’ya bağlanma |
| `-e OPENAI_API_KEY=your_secret_key` | OpenAI API anahtarınızı ayarlama |
| `-e HF_HUB_OFFLINE=1` | Çevrimdışı kullanım için Hugging Face model indirmelerini devre dışı bırakma |
| `--gpus all` | `:cuda` veya `:ollama` için Nvidia GPU erişimini etkinleştirme; Nvidia Container Toolkit gerektirir |
Kurulum adımları ve yapılandırma seçenekleri için [Docker kurulum kılavuzuna](https://docs.openwebui.com/getting-started/quick-start/) bakın.

View file

@ -60,22 +60,22 @@ docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v open-
Потім відкрийте [http://localhost:3000](http://localhost:3000).
| Тег образу | Вміст |
| --- | --- |
| `:main` | Стандартний образ із залежностями для локального ШІ |
| `:slim` | Полегшений образ із зовнішніми сервісами для ембедингів, видобування даних із документів і обробки мовлення |
| `:cuda` | Open WebUI з прискоренням CUDA |
| `:ollama` | Open WebUI із вбудованим Ollama |
| `:dev` | Найновіша збірка для розробки |
| Тег образу | Вміст |
| ---------- | ----------------------------------------------------------------------------------------------------------- |
| `:main` | Стандартний образ із залежностями для локального ШІ |
| `:slim` | Полегшений образ із зовнішніми сервісами для ембедингів, видобування даних із документів і обробки мовлення |
| `:cuda` | Open WebUI з прискоренням CUDA |
| `:ollama` | Open WebUI із вбудованим Ollama |
| `:dev` | Найновіша збірка для розробки |
За потреби додайте ці параметри до `docker run` перед назвою образу:
| Параметр | Призначення |
| --- | --- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | Підключення до Ollama на іншому сервері |
| `-e OPENAI_API_KEY=your_secret_key` | Встановлення ключа API OpenAI |
| `-e HF_HUB_OFFLINE=1` | Вимкнення завантаження моделей Hugging Face для роботи офлайн |
| `--gpus all` | Доступ до GPU Nvidia для `:cuda` або `:ollama`; потрібен Nvidia Container Toolkit |
| Параметр | Призначення |
| ---------------------------------------------------- | --------------------------------------------------------------------------------- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | Підключення до Ollama на іншому сервері |
| `-e OPENAI_API_KEY=your_secret_key` | Встановлення ключа API OpenAI |
| `-e HF_HUB_OFFLINE=1` | Вимкнення завантаження моделей Hugging Face для роботи офлайн |
| `--gpus all` | Доступ до GPU Nvidia для `:cuda` або `:ollama`; потрібен Nvidia Container Toolkit |
Інструкції та параметри налаштування наведено в [посібнику зі встановлення через Docker](https://docs.openwebui.com/getting-started/quick-start/).

View file

@ -60,22 +60,22 @@ docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v open-
Sau đó mở [http://localhost:3000](http://localhost:3000).
| Thẻ image | Nội dung |
| --- | --- |
| `:main` | Image tiêu chuẩn với các thư viện phụ thuộc cho AI cục bộ |
| `:slim` | Image gọn nhẹ dùng dịch vụ bên ngoài cho embedding, trích xuất tài liệu và giọng nói |
| `:cuda` | Open WebUI với tăng tốc CUDA |
| `:ollama` | Open WebUI tích hợp sẵn Ollama |
| `:dev` | Bản dựng phát triển mới nhất |
| Thẻ image | Nội dung |
| --------- | ------------------------------------------------------------------------------------ |
| `:main` | Image tiêu chuẩn với các thư viện phụ thuộc cho AI cục bộ |
| `:slim` | Image gọn nhẹ dùng dịch vụ bên ngoài cho embedding, trích xuất tài liệu và giọng nói |
| `:cuda` | Open WebUI với tăng tốc CUDA |
| `:ollama` | Open WebUI tích hợp sẵn Ollama |
| `:dev` | Bản dựng phát triển mới nhất |
Thêm các tùy chọn sau vào `docker run` trước tên image khi cần:
| Tùy chọn | Mục đích |
| --- | --- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | Kết nối đến Ollama trên máy chủ khác |
| `-e OPENAI_API_KEY=your_secret_key` | Thiết lập khóa API OpenAI |
| `-e HF_HUB_OFFLINE=1` | Tắt tải mô hình Hugging Face để sử dụng ngoại tuyến |
| `--gpus all` | Bật quyền truy cập GPU Nvidia cho `:cuda` hoặc `:ollama`; yêu cầu Nvidia Container Toolkit |
| Tùy chọn | Mục đích |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | Kết nối đến Ollama trên máy chủ khác |
| `-e OPENAI_API_KEY=your_secret_key` | Thiết lập khóa API OpenAI |
| `-e HF_HUB_OFFLINE=1` | Tắt tải mô hình Hugging Face để sử dụng ngoại tuyến |
| `--gpus all` | Bật quyền truy cập GPU Nvidia cho `:cuda` hoặc `:ollama`; yêu cầu Nvidia Container Toolkit |
Xem [hướng dẫn cài đặt Docker](https://docs.openwebui.com/getting-started/quick-start/) để biết các bước thiết lập và tùy chọn cấu hình.

View file

@ -60,22 +60,22 @@ docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v open-
然后打开 [http://localhost:3000](http://localhost:3000)。
| 镜像标签 | 内容 |
| --- | --- |
| `:main` | 包含本地 AI 依赖的标准镜像 |
| `:slim` | 轻量镜像,通过外部服务处理嵌入、文档提取和语音 |
| `:cuda` | 支持 CUDA 加速的 Open WebUI |
| `:ollama` | 内置 Ollama 的 Open WebUI |
| `:dev` | 最新开发版本 |
| 镜像标签 | 内容 |
| --------- | ---------------------------------------------- |
| `:main` | 包含本地 AI 依赖的标准镜像 |
| `:slim` | 轻量镜像,通过外部服务处理嵌入、文档提取和语音 |
| `:cuda` | 支持 CUDA 加速的 Open WebUI |
| `:ollama` | 内置 Ollama 的 Open WebUI |
| `:dev` | 最新开发版本 |
根据需要,在 `docker run` 的镜像名称前添加以下选项:
| 选项 | 用途 |
| --- | --- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | 连接另一台服务器上的 Ollama |
| `-e OPENAI_API_KEY=your_secret_key` | 设置 OpenAI API 密钥 |
| `-e HF_HUB_OFFLINE=1` | 禁用 Hugging Face 模型下载,以便离线使用 |
| `--gpus all` | 为 `:cuda` 或 `:ollama` 启用 Nvidia GPU 访问;需要 Nvidia Container Toolkit |
| 选项 | 用途 |
| ---------------------------------------------------- | --------------------------------------------------------------------------- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | 连接另一台服务器上的 Ollama |
| `-e OPENAI_API_KEY=your_secret_key` | 设置 OpenAI API 密钥 |
| `-e HF_HUB_OFFLINE=1` | 禁用 Hugging Face 模型下载,以便离线使用 |
| `--gpus all` | 为 `:cuda` 或 `:ollama` 启用 Nvidia GPU 访问;需要 Nvidia Container Toolkit |
安装步骤和配置选项请参阅 [Docker 安装指南](https://docs.openwebui.com/getting-started/quick-start/)。

View file

@ -60,22 +60,22 @@ docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v open-
接著開啟 [http://localhost:3000](http://localhost:3000)。
| 映像標籤 | 內容 |
| --- | --- |
| `:main` | 包含本機 AI 相依套件的標準映像 |
| `:slim` | 輕量映像,透過外部服務處理嵌入、文件擷取與語音 |
| `:cuda` | 支援 CUDA 加速的 Open WebUI |
| `:ollama` | 內建 Ollama 的 Open WebUI |
| `:dev` | 最新開發版本 |
| 映像標籤 | 內容 |
| --------- | ---------------------------------------------- |
| `:main` | 包含本機 AI 相依套件的標準映像 |
| `:slim` | 輕量映像,透過外部服務處理嵌入、文件擷取與語音 |
| `:cuda` | 支援 CUDA 加速的 Open WebUI |
| `:ollama` | 內建 Ollama 的 Open WebUI |
| `:dev` | 最新開發版本 |
視需要在 `docker run` 的映像名稱前加入以下選項:
| 選項 | 用途 |
| --- | --- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | 連接另一台伺服器上的 Ollama |
| `-e OPENAI_API_KEY=your_secret_key` | 設定 OpenAI API 金鑰 |
| `-e HF_HUB_OFFLINE=1` | 停用 Hugging Face 模型下載,以便離線使用 |
| `--gpus all` | 為 `:cuda` 或 `:ollama` 啟用 Nvidia GPU 存取;需要 Nvidia Container Toolkit |
| 選項 | 用途 |
| ---------------------------------------------------- | --------------------------------------------------------------------------- |
| `-e OLLAMA_BASE_URL=http://your-ollama-server:11434` | 連接另一台伺服器上的 Ollama |
| `-e OPENAI_API_KEY=your_secret_key` | 設定 OpenAI API 金鑰 |
| `-e HF_HUB_OFFLINE=1` | 停用 Hugging Face 模型下載,以便離線使用 |
| `--gpus all` | 為 `:cuda` 或 `:ollama` 啟用 Nvidia GPU 存取;需要 Nvidia Container Toolkit |
安裝步驟與設定選項請參閱 [Docker 安裝指南](https://docs.openwebui.com/getting-started/quick-start/)。

View file

@ -1168,9 +1168,7 @@ class SocketSessionEventSink:
await disconnect_user_sessions(str(subject['id']))
async def dispatch_event_functions(
request: Request, event: Event, extra_function_ids: list[str] | None = None
) -> None:
async def dispatch_event_functions(request: Request, event: Event, extra_function_ids: list[str] | None = None) -> None:
if not ENABLE_FUNCTIONS:
return

View file

@ -356,9 +356,7 @@ class ChatMessageTable:
message = await db.get(ChatMessage, id)
return ChatMessageModel.model_validate(message) if message else None
async def get_chat_ids_with_pending_approval(
self, chat_ids: list[str], db: AsyncSession | None = None
) -> set[str]:
async def get_chat_ids_with_pending_approval(self, chat_ids: list[str], db: AsyncSession | None = None) -> set[str]:
if not chat_ids:
return set()
async with get_async_db_context(db) as db:

View file

@ -709,8 +709,6 @@ async def add_members_by_id(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
class RemoveMembersForm(BaseModel):
user_ids: list[str] = []

View file

@ -619,9 +619,7 @@ async def get_shared_folder_chats(
user_ids.update(uid for ids in participant_ids.values() for uid in ids)
users = await Users.get_users_by_user_ids(list(user_ids), db=db) if user_ids else []
names = {u.id: u.name for u in users}
pending_approval_ids = await ChatMessages.get_chat_ids_with_pending_approval(
[chat['id'] for chat in chats], db=db
)
pending_approval_ids = await ChatMessages.get_chat_ids_with_pending_approval([chat['id'] for chat in chats], db=db)
for chat in chats:
uid = chat['user_id']
chat['owner_name'] = names.get(uid, 'Unknown')

View file

@ -55,9 +55,7 @@ STRIPPED_RESPONSE_HEADERS = frozenset(
def _port_browser_html(body: bytes, prefix: str, port: int) -> bytes:
"""Keep port links inside the proxy and report navigation from sandboxed frames."""
config = json.dumps({'prefix': prefix, 'origin': f'http://localhost:{port}'}).replace(
'<', '\\u003c'
)
config = json.dumps({'prefix': prefix, 'origin': f'http://localhost:{port}'}).replace('<', '\\u003c')
script = r"""
(() => {
const config = __CONFIG__;
@ -119,7 +117,7 @@ def _port_browser_html(body: bytes, prefix: str, port: int) -> bytes:
bridge = '<script>' + script + '</script>'
head = re.search(r'<head\b[^>]*>', html, re.I)
if head:
html = html[:head.end()] + bridge + html[head.end():]
html = html[: head.end()] + bridge + html[head.end() :]
else:
html = bridge + html
return html.encode('utf-8')
@ -248,7 +246,6 @@ async def browser_ws(ws: WebSocket, server_id: str, sid: str, ticket: str, kind:
ssl=AIOHTTP_CLIENT_SESSION_SSL,
max_msg_size=16 * 1024 * 1024,
) as upstream:
tasks = [
asyncio.create_task(relay_from_browser(ws, upstream)),
asyncio.create_task(relay_to_browser(ws, upstream)),
@ -380,7 +377,14 @@ async def proxy_terminal(
# auth_type == "none": no Authorization header
if browser_auth is not None:
for name in ('accept', 'origin', 'access-control-request-method', 'access-control-request-headers', 'last-event-id', 'range'):
for name in (
'accept',
'origin',
'access-control-request-method',
'access-control-request-headers',
'last-event-id',
'range',
):
if value := request.headers.get(name):
headers[name] = value
@ -418,22 +422,18 @@ async def proxy_terminal(
port_match = re.match(r'^proxy/(\d+)/', safe_path)
port_prefix = None
if port_match:
port_prefix = request.url.path[
:request.url.path.index('/proxy/') + len(port_match[0]) + 1
]
port_prefix = request.url.path[: request.url.path.index('/proxy/') + len(port_match[0]) + 1]
location = upstream_response.headers.get('location')
if location:
remote_origin = f'http://localhost:{port_match[1]}'
destination = urlsplit(
urljoin(f'{remote_origin}/{safe_path[len(port_match[0]):]}', location)
)
destination = urlsplit(urljoin(f'{remote_origin}/{safe_path[len(port_match[0]) :]}', location))
if destination.netloc == urlsplit(remote_origin).netloc:
filtered_headers = {
key: value for key, value in filtered_headers.items()
if key.lower() != 'location'
key: value for key, value in filtered_headers.items() if key.lower() != 'location'
}
filtered_headers['Location'] = (
port_prefix + destination.path.lstrip('/')
port_prefix
+ destination.path.lstrip('/')
+ (f'?{destination.query}' if destination.query else '')
+ (f'#{destination.fragment}' if destination.fragment else '')
)
@ -454,10 +454,16 @@ async def proxy_terminal(
# Buffer text/JSON responses
response_body = await upstream_response.read()
if safe_path.startswith('browser/sessions') and upstream_response.status < 300 and request.method in ('GET', 'POST', 'PATCH'):
if (
safe_path.startswith('browser/sessions')
and upstream_response.status < 300
and request.method in ('GET', 'POST', 'PATCH')
):
browser_session = json.loads(response_body)
if isinstance(browser_session, dict) and browser_session.get('id') and browser_session.get('token'):
browser_session['token'] = browser_ticket(server_id, user.id, session_id or '', browser_session, headers, cookies)
browser_session['token'] = browser_ticket(
server_id, user.id, session_id or '', browser_session, headers, cookies
)
response_body = json.dumps(browser_session).encode()
if port_prefix and 'text/html' in upstream_content_type:
response_body = _port_browser_html(response_body, port_prefix, int(port_match[1]))
@ -575,8 +581,8 @@ async def ws_processes(ws: WebSocket, server_id: str):
@router.websocket('/{server_id}/execute/{process_id}/status')
async def ws_process_output(ws: WebSocket, server_id: str, process_id: str):
from urllib.parse import quote
await _proxy_terminal_websocket(ws, server_id,
stream_path=f'execute/{quote(process_id, safe="")}/status')
await _proxy_terminal_websocket(ws, server_id, stream_path=f'execute/{quote(process_id, safe="")}/status')
@router.websocket('/{server_id}/api/terminals/{session_id}')
@ -631,8 +637,7 @@ async def _proxy_terminal_websocket(
# Encode session_id as an opaque path segment so it cannot smuggle '?'/'#'/'&' (at any
# decode depth) and inject an attacker-chosen user_id ahead of the one appended below.
upstream_path = (
f'api/terminals/{urllib.parse.quote(session_id, safe="")}'
if session_id is not None else stream_path
f'api/terminals/{urllib.parse.quote(session_id, safe="")}' if session_id is not None else stream_path
)
upstream_url = f'{ws_base}/{upstream_path}'
if upstream_params:

View file

@ -271,11 +271,7 @@ async def hydrate(rows, db):
for row in rows:
resource = objects.get(row.resource_type, {}).get(row.resource_id)
obj = objects.get(row.object_type, {}).get(row.object_id)
title = (
getattr(resource, 'title', None)
or getattr(resource, 'name', None)
or ''
)
title = getattr(resource, 'title', None) or getattr(resource, 'name', None) or ''
preview = (
replace_mentions(getattr(obj, 'content', '') or '')[:300]
if row.object_type in {'comment', 'message'}

View file

@ -79,7 +79,9 @@
Open WebUI
</div>
<h1 class="m-0 max-w-3xl text-[22px] leading-[1.15] font-light tracking-tight lg:text-[33px]">
<h1
class="m-0 max-w-3xl text-[22px] leading-[1.15] font-light tracking-tight lg:text-[33px]"
>
{$i18n.t('Welcome to your AI home.')}
</h1>

View file

@ -1244,7 +1244,8 @@
</svg>
</button>
<ModelMenu onComment={(request) => (comments = request)}
<ModelMenu
onComment={(request) => (comments = request)}
user={$user}
{model}
deleteLabel={canResetModel(model) ? $i18n.t('Reset') : $i18n.t('Delete')}

View file

@ -225,9 +225,7 @@
{#if channel}
<div class="flex flex-col w-full h-full bg-white dark:bg-gray-900">
<div
class="sticky top-0 flex min-h-10 items-center justify-between px-4 py-1.5"
>
<div class="sticky top-0 flex min-h-10 items-center justify-between px-4 py-1.5">
<div class=" font-medium text-sm">{$i18n.t('Thread')}</div>
<div>

View file

@ -287,7 +287,11 @@
return Number.isFinite(threshold) && threshold > 0 ? threshold : null;
};
const getContextUsage = (chatHistory: typeof history, system: string, threshold: number | null) => {
const getContextUsage = (
chatHistory: typeof history,
system: string,
threshold: number | null
) => {
if (!chatHistory?.currentId) {
return null;
}

View file

@ -621,7 +621,10 @@
on:click={toggle}><Icon name={expanded ? 'chevron-down' : 'chevron-up'} size={14} /></button
>
</div>
{#if error}<div role="alert" class="px-3 py-2 text-xs text-gray-500 dark:text-gray-400 break-words">
{#if error}<div
role="alert"
class="px-3 py-2 text-xs text-gray-500 dark:text-gray-400 break-words"
>
{error}
</div>{/if}
{#if expanded}

View file

@ -281,7 +281,9 @@
</div></Dropdown
>{/if}
</div>
{#if error}<p role="alert" class="px-3 py-1 text-gray-500 dark:text-gray-400 break-words">{error}</p>{/if}
{#if error}<p role="alert" class="px-3 py-1 text-gray-500 dark:text-gray-400 break-words">
{error}
</p>{/if}
{#if creating || editing}
<form
class="min-h-0 overflow-y-auto px-3 py-2 space-y-2"

View file

@ -433,7 +433,9 @@
{/if}
</div>
<div class="shrink-0 pb-2 z-10">
{#if error}<p role="alert" class="px-4 py-2 text-xs text-gray-500 dark:text-gray-400">{error}</p>{/if}
{#if error}<p role="alert" class="px-4 py-2 text-xs text-gray-500 dark:text-gray-400">
{error}
</p>{/if}
{#if sideChatAllowed}
<MessageInput
bind:this={input}

View file

@ -226,7 +226,9 @@
>
{/each}{/if}
</div>
{#if error}<p role="alert" class="py-2 text-xs text-gray-500 dark:text-gray-400">{error}</p>{/if}
{#if error}<p role="alert" class="py-2 text-xs text-gray-500 dark:text-gray-400">
{error}
</p>{/if}
<div class="flex items-center gap-2 pt-2">
<span class="min-w-0 flex-1 truncate text-xs text-gray-400" title={fullPath}>{fullPath}</span>
<button

View file

@ -318,7 +318,9 @@
selectedExcelSheet={sheet}
onSheetChange={setSheet}
/>
{:else if previewError}<p class="p-4 text-sm text-gray-500 dark:text-gray-400">{$i18n.t('Preview failed')}</p>{/if}
{:else if previewError}<p class="p-4 text-sm text-gray-500 dark:text-gray-400">
{$i18n.t('Preview failed')}
</p>{/if}
{#if unplaced.length || commentState === 'error' || commentState === 'partial'}
<button
type="button"

View file

@ -167,7 +167,9 @@
<Spinner className="size-4" />{$i18n.t('Loading...')}
</div>
{:else if error}
<p class="px-5 py-2 break-words text-gray-500 dark:text-gray-400" role="alert">{error}</p>
<p class="px-5 py-2 break-words text-gray-500 dark:text-gray-400" role="alert">
{error}
</p>
<button
type="button"
class="mx-5 mt-2 self-start rounded-lg bg-gray-100 dark:bg-gray-850 px-2.5 h-7"

View file

@ -960,7 +960,13 @@
}
// Clear selected terminal when model doesn't support terminal
$: if (terminalSelectionEnabled && inputId === 'chat-input' && $selectedTerminalId && selectedModelIds.length > 0 && terminalCapableModels.length === 0) {
$: if (
terminalSelectionEnabled &&
inputId === 'chat-input' &&
$selectedTerminalId &&
selectedModelIds.length > 0 &&
terminalCapableModels.length === 0
) {
selectedTerminalId.set(null);
}
@ -1481,16 +1487,28 @@
if (
$settings?.keyboardShortcuts !== false &&
(recording || (document.activeElement?.closest('[data-composer-id]')?.getAttribute('data-composer-id') ?? 'chat-input') === inputId) &&
(recording ||
(document.activeElement?.closest('[data-composer-id]')?.getAttribute('data-composer-id') ??
'chat-input') === inputId) &&
matchKeybinding(e) === Shortcut.TOGGLE_DICTATION
) {
e.preventDefault();
if (recording) {
// Confirm and stop recording
document.getElementById(inputId === 'chat-input' ? 'confirm-recording-button' : `${inputId}-confirm-recording-button`)?.click();
document
.getElementById(
inputId === 'chat-input'
? 'confirm-recording-button'
: `${inputId}-confirm-recording-button`
)
?.click();
} else {
// Start recording (same logic as voice-input-button click)
document.getElementById(inputId === 'chat-input' ? 'voice-input-button' : `${inputId}-voice-input-button`)?.click();
document
.getElementById(
inputId === 'chat-input' ? 'voice-input-button' : `${inputId}-voice-input-button`
)
?.click();
}
return;
}
@ -1939,7 +1957,9 @@
<div class={recording ? '' : 'hidden'}>
<VoiceRecording
confirmButtonId={inputId === 'chat-input' ? 'confirm-recording-button' : `${inputId}-confirm-recording-button`}
confirmButtonId={inputId === 'chat-input'
? 'confirm-recording-button'
: `${inputId}-confirm-recording-button`}
bind:recording
onCancel={async () => {
recording = false;
@ -1970,7 +1990,9 @@
}}
>
<button
id={inputId === 'chat-input' ? 'generate-message-pair-button' : `${inputId}-generate-message-pair-button`}
id={inputId === 'chat-input'
? 'generate-message-pair-button'
: `${inputId}-generate-message-pair-button`}
type="button"
aria-label={$i18n.t('Generate message pair')}
class="hidden"
@ -2336,13 +2358,17 @@
e.preventDefault();
const userMessageElement = [
...(document.getElementById(messagesContainerId)?.getElementsByClassName('user-message') ?? [])
...(document
.getElementById(messagesContainerId)
?.getElementsByClassName('user-message') ?? [])
]?.at(-1);
if (userMessageElement) {
userMessageElement.scrollIntoView({ block: 'center' });
const editButton = [
...userMessageElement.getElementsByClassName('edit-user-message-button')
...userMessageElement.getElementsByClassName(
'edit-user-message-button'
)
]?.at(-1);
editButton?.click();
@ -2861,11 +2887,13 @@
</div>
{:else}
{#if !history?.currentId || history.messages[history.currentId]?.done == true}
{#if ($_user?.role === 'admin' || ($_user?.permissions?.chat?.stt ?? true))}
{#if $_user?.role === 'admin' || ($_user?.permissions?.chat?.stt ?? true)}
<!-- {$i18n.t('Record voice')} -->
<Tooltip content={$i18n.t('Dictate')}>
<button
id={inputId === 'chat-input' ? 'voice-input-button' : `${inputId}-voice-input-button`}
id={inputId === 'chat-input'
? 'voice-input-button'
: `${inputId}-voice-input-button`}
class=" text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 transition rounded-full p-1.5 self-center mr-0.5"
type="button"
on:click={async () => {
@ -2979,7 +3007,9 @@
<!-- Preserve the former upload label for i18n extraction: $i18n.t('Waiting for upload...') -->
<Tooltip content={$i18n.t('Send message')}>
<button
id={inputId === 'chat-input' ? 'send-message-button' : `${inputId}-send-message-button`}
id={inputId === 'chat-input'
? 'send-message-button'
: `${inputId}-send-message-button`}
aria-label={$i18n.t('Send message')}
class="{!(prompt === '' && files.length === 0)
? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '

View file

@ -913,7 +913,9 @@
{#if $showCallOverlay}
<!-- A media element can only have one Web Audio source, even after its context closes. -->
<audio bind:this={callAudioElement} class="hidden"></audio>
<div class="max-w-lg w-full h-full max-h-[100dvh] flex flex-col justify-between px-3 pb-3 md:px-6 md:pb-6">
<div
class="max-w-lg w-full h-full max-h-[100dvh] flex flex-col justify-between px-3 pb-3 md:px-6 md:pb-6"
>
{#if camera}
<button
type="button"

View file

@ -238,7 +238,9 @@
type="button"
aria-label={$i18n.t('Compact: shorten older messages so this chat can keep going.')}
class="slash-command-row flex items-center gap-2 w-full h-6 px-2 rounded-xl text-[0.8125rem] text-left transition-colors duration-75 hover:bg-gray-50/40 dark:hover:bg-gray-800/40
{commandIdx === selectedIdx ? 'app-interactive-active selected-command-option-button' : ''} disabled:opacity-50"
{commandIdx === selectedIdx
? 'app-interactive-active selected-command-option-button'
: ''} disabled:opacity-50"
disabled={compactDisabled}
on:mousedown={(e) => e.preventDefault()}
on:click={() => {
@ -294,7 +296,9 @@
type="button"
aria-label={$i18n.t('Fork: fork the current chat branch into a new chat.')}
class="slash-command-row flex items-center gap-2 w-full h-6 px-2 rounded-xl text-[0.8125rem] text-left transition-colors duration-75 hover:bg-gray-50/40 dark:hover:bg-gray-800/40
{commandIdx === selectedIdx ? 'app-interactive-active selected-command-option-button' : ''} disabled:opacity-50"
{commandIdx === selectedIdx
? 'app-interactive-active selected-command-option-button'
: ''} disabled:opacity-50"
disabled={forkDisabled}
on:mousedown={(e) => e.preventDefault()}
on:click={() => {
@ -394,7 +398,9 @@
{/if}
</span>
<span class="flex-1 min-w-0 flex items-center gap-2 overflow-hidden">
<span class="min-w-0 flex-1 truncate">{$i18n.t(item.data.id === 'side' ? 'Side chat' : 'Comments')}</span>
<span class="min-w-0 flex-1 truncate"
>{$i18n.t(item.data.id === 'side' ? 'Side chat' : 'Comments')}</span
>
<span class="app-muted max-w-24 shrink-0 truncate text-[0.625rem]">/{item.data.id}</span>
</span>
</button>

View file

@ -162,7 +162,6 @@
// if (domainData.some((value) => value > 0)) {
// lastSoundTime = Date.now();
// }
}
window.requestAnimationFrame(processFrame);

View file

@ -45,7 +45,6 @@
<div
class=" ml-1 text-white dark:text-gray-900 group-hover/item:text-gray-600 dark:group-hover/item:text-white transition"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
@ -86,7 +85,6 @@
<div
class=" ml-1 text-white dark:text-gray-900 group-hover/item:text-gray-600 dark:group-hover/item:text-white transition"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
@ -126,7 +124,6 @@
<div
class=" ml-1 text-white dark:text-gray-900 group-hover/item:text-gray-600 dark:group-hover/item:text-white transition"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"

View file

@ -1,8 +1,5 @@
<script lang="ts">
import {
commentSurface,
contentHash
} from '$lib/components/comments/anchors';
import { commentSurface, contentHash } from '$lib/components/comments/anchors';
import { getContext } from 'svelte';
import Collapsible from '$lib/components/common/Collapsible.svelte';
import Image from '$lib/components/common/Image.svelte';
@ -85,7 +82,8 @@
<div
class="markdown-prose"
use:commentSurface={{
resource_type: 'chat', resource_id: chatId,
resource_type: 'chat',
resource_id: chatId,
target_type: 'chat_message',
target_id: messageId,
surface_id: displayItem.id,
@ -120,7 +118,8 @@
<div
class="whitespace-pre-wrap text-[0.9375rem]"
use:commentSurface={{
resource_type: 'chat', resource_id: chatId,
resource_type: 'chat',
resource_id: chatId,
target_type: 'chat_message',
target_id: messageId,
surface_id: displayItem.id,

View file

@ -1,8 +1,5 @@
<script lang="ts">
import {
commentSurface,
contentHash
} from '$lib/components/comments/anchors';
import { commentSurface, contentHash } from '$lib/components/comments/anchors';
import EditPencilIcon from '$lib/components/layout/Sidebar/icons/EditPencil.svelte';
import CopyIcon from '$lib/components/layout/Sidebar/icons/Copy.svelte';
import TrashIcon from '$lib/components/layout/Sidebar/icons/Trash.svelte';
@ -443,7 +440,8 @@
<div
class="markdown-prose"
use:commentSurface={{
resource_type: 'chat', resource_id: chatId,
resource_type: 'chat',
resource_id: chatId,
target_type: 'chat_message',
target_id: message.id,
surface_id: 'content',
@ -462,7 +460,8 @@
<div
class="whitespace-pre-wrap text-[0.9375rem]"
use:commentSurface={{
resource_type: 'chat', resource_id: chatId,
resource_type: 'chat',
resource_id: chatId,
target_type: 'chat_message',
target_id: message.id,
surface_id: 'content',

View file

@ -319,7 +319,8 @@
{/if}
{#if !selectionOnly}
<ModelItemMenu {onComment}
<ModelItemMenu
{onComment}
bind:show={showMenu}
model={item.model}
{pinModelHandler}

View file

@ -87,7 +87,8 @@
{/if}
{#if showComments}
<CommentButton {onComment}
<CommentButton
{onComment}
menu
resource={{ resource_type: 'model', resource_id: model?.id }}
className="select-none flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition"

View file

@ -1125,7 +1125,8 @@
<div style="height: {visibleStart * ITEM_HEIGHT}px;" />
{#each filteredItems.slice(visibleStart, visibleEnd) as item, i (item.value)}
{@const index = visibleStart + i}
<ModelItem onComment={(request) => (comments = request)}
<ModelItem
onComment={(request) => (comments = request)}
bind:selectedModelIdx
{item}
{index}

View file

@ -145,7 +145,6 @@
await getVoices();
// const blobUrl = URL.createObjectURL(await rawAudio.toBlob());
// const audio = new Audio(blobUrl);

View file

@ -171,7 +171,9 @@
/>
</div>
</div>
{#if error}<p role="alert" class="py-1 text-xs text-gray-500 dark:text-gray-400">{error}</p>{/if}
{#if error}<p role="alert" class="py-1 text-xs text-gray-500 dark:text-gray-400">
{error}
</p>{/if}
<div class="absolute {embedded ? 'bottom-0 end-0' : 'bottom-2 end-2.5'} flex items-center">
<div class="flex items-center gap-2">
<button

View file

@ -67,8 +67,7 @@
? hovered
: located.filter(
({ comment }) =>
comment.id ===
(typeof $showTabs === 'object' ? $showTabs.comment_id : undefined)
comment.id === (typeof $showTabs === 'object' ? $showTabs.comment_id : undefined)
)
).map(({ range }) => range)
);

View file

@ -82,7 +82,6 @@
<div
class=" ml-1 text-gray-400 group-hover:text-gray-600 dark:group-hover:text-white"
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
@ -125,7 +124,6 @@
</a>
<div class=" ml-1 text-gray-400 group-hover:text-gray-600 dark:group-hover:text-white">
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"

View file

@ -1304,10 +1304,14 @@
class=" my-auto flex flex-col justify-between h-screen max-h-[100dvh] w-[var(--sidebar-width)] overflow-x-hidden scrollbar-hidden z-50 border-e border-gray-50 dark:border-gray-850/30"
>
<div
class="sidebar {$mobile ? 'px-0.75 pt-1 pb-1 -mb-2' : 'px-1 pt-0.5 pb-0.5'} flex shrink-0 justify-between space-x-1 text-gray-600 dark:text-gray-400 sticky top-0 z-10"
class="sidebar {$mobile
? 'px-0.75 pt-1 pb-1 -mb-2'
: 'px-1 pt-0.5 pb-0.5'} flex shrink-0 justify-between space-x-1 text-gray-600 dark:text-gray-400 sticky top-0 z-10"
>
<a
class="flex items-center rounded-xl size-8.5 {$mobile ? 'h-full' : 'shrink-0'} justify-center hover:bg-gray-100 dark:hover:bg-gray-900 transition no-drag-region"
class="flex items-center rounded-xl size-8.5 {$mobile
? 'h-full'
: 'shrink-0'} justify-center hover:bg-gray-100 dark:hover:bg-gray-900 transition no-drag-region"
href="/"
draggable="false"
aria-label={$i18n.t('New Chat')}
@ -1381,7 +1385,9 @@
{/if}
<div
class="relative flex flex-col flex-1 overflow-y-auto scrollbar-hidden space-y-1.5 {$mobile ? 'pt-1.5' : ''} pb-2.5"
class="relative flex flex-col flex-1 overflow-y-auto scrollbar-hidden space-y-1.5 {$mobile
? 'pt-1.5'
: ''} pb-2.5"
style:display={inboxOpened ? 'none' : undefined}
on:scroll={(e) => {
if (e.target.scrollTop === 0) {

View file

@ -320,296 +320,298 @@
<h1 class="sr-only">{$i18n.t('Knowledge')}</h1>
<Comments modal bind:view={comments} resourceType="knowledge">
{#if loaded}
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
on:confirm={() => {
deleteHandler(selectedItem);
}}
/>
<Modal bind:show={showCreateModal} size="md" ariaLabel={$i18n.t('Create a knowledge base')}>
<KnowledgeEditor
modal={true}
onBack={() => {
closeCreateModal();
{#if loaded}
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
on:confirm={() => {
deleteHandler(selectedItem);
}}
/>
</Modal>
<div class="space-y-0">
<div class="flex h-8 w-full items-center gap-2">
<div class="flex min-w-0 flex-1">
<div class=" self-center ml-1 mr-3">
<Search className="size-3.5" />
</div>
<input
class=" w-full text-sm py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
on:input={handleSearchInput}
aria-label={$i18n.t('Search Knowledge')}
placeholder={$i18n.t('Search Knowledge')}
/>
{#if query}
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="py-0.5 rounded-full transition hover:text-gray-900 dark:hover:text-gray-100 text-gray-500 dark:text-gray-400 px-0"
aria-label={$i18n.t('Clear search')}
on:click={() => {
query = '';
handleSearchInput();
}}
>
<XMark className="size-3" strokeWidth="2" />
</button>
</div>
{/if}
</div>
<div
class="flex max-w-[55%] shrink-0 overflow-x-auto scrollbar-none"
bind:this={tagsContainerElement}
on:wheel={(e) => {
if (e.deltaY !== 0) {
e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY;
}
<Modal bind:show={showCreateModal} size="md" ariaLabel={$i18n.t('Create a knowledge base')}>
<KnowledgeEditor
modal={true}
onBack={() => {
closeCreateModal();
}}
>
<div
class="flex w-fit gap-0.5 text-center text-sm rounded-full bg-transparent whitespace-nowrap"
>
<ViewSelector
bind:value={viewOption}
align="end"
onChange={async (value) => {
localStorage.workspaceViewOption = value;
/>
</Modal>
await tick();
}}
/>
<TagSelector
bind:value={sourceOption}
align="end"
placeholder={$i18n.t('All Sources')}
items={[
{ value: 'local', label: $i18n.t('Local') },
{ value: 'external', label: $i18n.t('Connected') }
]}
onChange={async () => {
localStorage.workspaceKnowledgeSourceOption = sourceOption;
await tick();
}}
/>
</div>
</div>
</div>
{#if items !== null && total !== null}
{#if (items ?? []).length !== 0}
<div class="@container">
<div
class="grid grid-cols-[minmax(0,1fr)_9rem_8rem_9.5rem] items-center gap-x-4 px-2 @max-[48rem]:grid-cols-[minmax(0,1fr)_7rem_9.5rem] @max-[48rem]:gap-x-3 @max-[36rem]:grid-cols-[minmax(0,1fr)_9.5rem] @max-[36rem]:gap-x-2 min-h-6 text-xs text-gray-500 dark:text-gray-400"
>
<button
class="flex min-w-0 flex-1 items-center gap-1 py-0.5 text-left"
type="button"
on:click={() => setSortKey('name')}
>
{$i18n.t('Name')}
{#if sortKey === 'name'}
{#if sortDirection === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
{/if}
</button>
<div class="@max-[48rem]:hidden">{$i18n.t('Owner')}</div>
<button
class="@max-[36rem]:hidden flex min-w-0 items-center gap-1 py-0.5 text-left"
type="button"
on:click={() => setSortKey('updated_at')}
>
{$i18n.t('Updated at')}
{#if sortKey === 'updated_at'}
{#if sortDirection === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
{/if}
</button>
<span class="sr-only">{$i18n.t('Actions')}</span>
<div class="space-y-0">
<div class="flex h-8 w-full items-center gap-2">
<div class="flex min-w-0 flex-1">
<div class=" self-center ml-1 mr-3">
<Search className="size-3.5" />
</div>
<div class="grid gap-y-0.5">
{#each items as item}
{@const metaPreview = getKnowledgeMetaPreview(item)}
<div
class="grid grid-cols-[minmax(0,1fr)_9rem_8rem_9.5rem] items-center gap-x-4 px-2 @max-[48rem]:grid-cols-[minmax(0,1fr)_7rem_9.5rem] @max-[48rem]:gap-x-3 @max-[36rem]:grid-cols-[minmax(0,1fr)_9.5rem] @max-[36rem]:gap-x-2 min-h-12 rounded-lg py-1.5 hover:bg-black/[0.03] dark:hover:bg-white/[0.03] group cursor-pointer text-left"
role="presentation"
on:click={(e) => {
if (shouldIgnoreRowClick(e.target)) return;
openKnowledge(item);
<input
class=" w-full text-sm py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
on:input={handleSearchInput}
aria-label={$i18n.t('Search Knowledge')}
placeholder={$i18n.t('Search Knowledge')}
/>
{#if query}
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="py-0.5 rounded-full transition hover:text-gray-900 dark:hover:text-gray-100 text-gray-500 dark:text-gray-400 px-0"
aria-label={$i18n.t('Clear search')}
on:click={() => {
query = '';
handleSearchInput();
}}
>
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
<div class="flex min-w-0 items-center gap-2 overflow-hidden">
<div class="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<Tooltip
content={item?.description ?? item.name}
className="min-w-0"
placement="top-start"
>
{#if !item?.meta?.document}
<a
href={`/workspace/knowledge/${item.id}`}
class="focus-ring block truncate rounded text-[0.8125rem] leading-5 text-gray-800 group-hover:underline focus-visible:underline dark:text-gray-200"
>{item.name}</a
>
{:else}
<span
class="block truncate text-[0.8125rem] leading-5 text-gray-800 group-hover:underline dark:text-gray-200"
>{item.name}</span
>
{/if}
</Tooltip>
{#if !item?.write_access || item?.meta?.source === 'external'}
<Badge type="subtle" content={$i18n.t('Read Only')} />
{/if}
<XMark className="size-3" strokeWidth="2" />
</button>
</div>
{/if}
</div>
{#if item?.meta?.source === 'external'}
<Badge
type="muted"
content={item?.meta?.external?.provider ?? $i18n.t('Connected')}
/>
<div
class="flex max-w-[55%] shrink-0 overflow-x-auto scrollbar-none"
bind:this={tagsContainerElement}
on:wheel={(e) => {
if (e.deltaY !== 0) {
e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY;
}
}}
>
<div
class="flex w-fit gap-0.5 text-center text-sm rounded-full bg-transparent whitespace-nowrap"
>
<ViewSelector
bind:value={viewOption}
align="end"
onChange={async (value) => {
localStorage.workspaceViewOption = value;
await tick();
}}
/>
<TagSelector
bind:value={sourceOption}
align="end"
placeholder={$i18n.t('All Sources')}
items={[
{ value: 'local', label: $i18n.t('Local') },
{ value: 'external', label: $i18n.t('Connected') }
]}
onChange={async () => {
localStorage.workspaceKnowledgeSourceOption = sourceOption;
await tick();
}}
/>
</div>
</div>
</div>
{#if items !== null && total !== null}
{#if (items ?? []).length !== 0}
<div class="@container">
<div
class="grid grid-cols-[minmax(0,1fr)_9rem_8rem_9.5rem] items-center gap-x-4 px-2 @max-[48rem]:grid-cols-[minmax(0,1fr)_7rem_9.5rem] @max-[48rem]:gap-x-3 @max-[36rem]:grid-cols-[minmax(0,1fr)_9.5rem] @max-[36rem]:gap-x-2 min-h-6 text-xs text-gray-500 dark:text-gray-400"
>
<button
class="flex min-w-0 flex-1 items-center gap-1 py-0.5 text-left"
type="button"
on:click={() => setSortKey('name')}
>
{$i18n.t('Name')}
{#if sortKey === 'name'}
{#if sortDirection === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
{/if}
</button>
<div class="@max-[48rem]:hidden">{$i18n.t('Owner')}</div>
<button
class="@max-[36rem]:hidden flex min-w-0 items-center gap-1 py-0.5 text-left"
type="button"
on:click={() => setSortKey('updated_at')}
>
{$i18n.t('Updated at')}
{#if sortKey === 'updated_at'}
{#if sortDirection === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
{/if}
</button>
<span class="sr-only">{$i18n.t('Actions')}</span>
</div>
<div class="grid gap-y-0.5">
{#each items as item}
{@const metaPreview = getKnowledgeMetaPreview(item)}
<div
class="grid grid-cols-[minmax(0,1fr)_9rem_8rem_9.5rem] items-center gap-x-4 px-2 @max-[48rem]:grid-cols-[minmax(0,1fr)_7rem_9.5rem] @max-[48rem]:gap-x-3 @max-[36rem]:grid-cols-[minmax(0,1fr)_9.5rem] @max-[36rem]:gap-x-2 min-h-12 rounded-lg py-1.5 hover:bg-black/[0.03] dark:hover:bg-white/[0.03] group cursor-pointer text-left"
role="presentation"
on:click={(e) => {
if (shouldIgnoreRowClick(e.target)) return;
openKnowledge(item);
}}
>
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
<div class="flex min-w-0 items-center gap-2 overflow-hidden">
<div class="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<Tooltip
content={item?.description ?? item.name}
className="min-w-0"
placement="top-start"
>
{#if !item?.meta?.document}
<a
href={`/workspace/knowledge/${item.id}`}
class="focus-ring block truncate rounded text-[0.8125rem] leading-5 text-gray-800 group-hover:underline focus-visible:underline dark:text-gray-200"
>{item.name}</a
>
{:else}
<span
class="block truncate text-[0.8125rem] leading-5 text-gray-800 group-hover:underline dark:text-gray-200"
>{item.name}</span
>
{/if}
</Tooltip>
{#if !item?.write_access || item?.meta?.source === 'external'}
<Badge type="subtle" content={$i18n.t('Read Only')} />
{/if}
{#if item?.meta?.source === 'external'}
<Badge
type="muted"
content={item?.meta?.external?.provider ?? $i18n.t('Connected')}
/>
{/if}
</div>
</div>
<div class="mt-0.5 min-w-0">
{#if metaPreview}
<Tooltip content={metaPreview} className="min-w-0" placement="top-start">
<div
class="truncate text-xs leading-4 text-gray-500 dark:text-gray-400"
>
{metaPreview}
</div>
</Tooltip>
{/if}
</div>
</div>
<div class="mt-0.5 min-w-0">
{#if metaPreview}
<Tooltip content={metaPreview} className="min-w-0" placement="top-start">
<div class="truncate text-xs leading-4 text-gray-500 dark:text-gray-400">
{metaPreview}
</div>
</Tooltip>
{/if}
</div>
</div>
</div>
<div
class="@max-[48rem]:hidden min-w-0 text-xs text-gray-600 dark:text-gray-400"
>
<ProfilePreview
user={item?.user ?? null}
side="bottom"
className="w-full min-w-0"
>
<span class="flex w-full min-w-0 items-center gap-2">
<ProfileImage
src={item?.user?.id
? `/api/v1/users/${item.user.id}/profile/image`
: '/user.png'}
className="size-5 shrink-0 rounded-full!"
/>
<span class="min-w-0 truncate">
{capitalizeFirstLetter(
item?.user?.name ?? item?.user?.email ?? $i18n.t('Deleted User')
)}
<div class="@max-[48rem]:hidden min-w-0 text-xs text-gray-600 dark:text-gray-400">
<ProfilePreview
user={item?.user ?? null}
side="bottom"
className="w-full min-w-0"
>
<span class="flex w-full min-w-0 items-center gap-2">
<ProfileImage
src={item?.user?.id
? `/api/v1/users/${item.user.id}/profile/image`
: '/user.png'}
className="size-5 shrink-0 rounded-full!"
/>
<span class="min-w-0 truncate">
{capitalizeFirstLetter(
item?.user?.name ?? item?.user?.email ?? $i18n.t('Deleted User')
)}
</span>
</span>
</span>
</ProfilePreview>
</div>
<Tooltip
content={dayjs(item.updated_at * 1000)
.locale($i18n.language)
.format('LLLL')}
className="@max-[36rem]:hidden min-w-0"
>
<div class="shrink-0 truncate text-xs leading-5 text-gray-500 dark:text-gray-400">
{dayjs(item.updated_at * 1000)
.locale($i18n.language)
.fromNow()}
</ProfilePreview>
</div>
</Tooltip>
<div
class="flex min-w-0 items-center justify-end gap-1.5"
>
<CommentButton onComment={(request) => (comments = request)}
modal
className="flex size-6 shrink-0 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
resource={{ resource_type: 'knowledge', resource_id: item.id }}
/>
{#if item?.write_access || $user?.role === 'admin'}
<div class="flex shrink-0 flex-row items-center self-center">
<ItemMenu onComment={(request) => (comments = request)}
resourceId={item.id}
accessHandler={$user?.permissions?.sharing?.knowledge ||
$user?.role === 'admin'
? () => accessModal.open(item.id)
: null}
onExport={$user?.role === 'admin'
? () => {
exportHandler(item);
}
: null}
on:delete={() => {
selectedItem = item;
showDeleteConfirm = true;
}}
/>
<Tooltip
content={dayjs(item.updated_at * 1000)
.locale($i18n.language)
.format('LLLL')}
className="@max-[36rem]:hidden min-w-0"
>
<div
class="shrink-0 truncate text-xs leading-5 text-gray-500 dark:text-gray-400"
>
{dayjs(item.updated_at * 1000)
.locale($i18n.language)
.fromNow()}
</div>
{/if}
</Tooltip>
<div class="flex min-w-0 items-center justify-end gap-1.5">
<CommentButton
onComment={(request) => (comments = request)}
modal
className="flex size-6 shrink-0 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
resource={{ resource_type: 'knowledge', resource_id: item.id }}
/>
{#if item?.write_access || $user?.role === 'admin'}
<div class="flex shrink-0 flex-row items-center self-center">
<ItemMenu
onComment={(request) => (comments = request)}
resourceId={item.id}
accessHandler={$user?.permissions?.sharing?.knowledge ||
$user?.role === 'admin'
? () => accessModal.open(item.id)
: null}
onExport={$user?.role === 'admin'
? () => {
exportHandler(item);
}
: null}
on:delete={() => {
selectedItem = item;
showDeleteConfirm = true;
}}
/>
</div>
{/if}
</div>
</div>
</div>
{/each}
</div>
</div>
{#if !allItemsLoaded}
<Loader
on:visible={(e) => {
if (!itemsLoading) {
loadMoreItems();
}
}}
>
<div class="w-full flex justify-center py-4 text-xs animate-pulse items-center gap-2">
<Spinner className=" size-4" />
<div class=" ">{$i18n.t('Loading...')}</div>
{/each}
</div>
</Loader>
</div>
{#if !allItemsLoaded}
<Loader
on:visible={(e) => {
if (!itemsLoading) {
loadMoreItems();
}
}}
>
<div class="w-full flex justify-center py-4 text-xs animate-pulse items-center gap-2">
<Spinner className=" size-4" />
<div class=" ">{$i18n.t('Loading...')}</div>
</div>
</Loader>
{/if}
{:else}
<div class="flex w-full flex-col items-center justify-center py-16 pb-24">
<div class="max-w-sm text-center text-gray-900 dark:text-gray-100">
<div class="mb-1.5 text-sm">
{hasFilters ? $i18n.t('No knowledge found') : $i18n.t('No knowledge yet')}
</div>
<div class="text-center text-xs leading-5 text-gray-500">
{hasFilters
? $i18n.t('Try adjusting your search or filter to find what you are looking for.')
: $i18n.t('Items you create or that are shared with you will appear here.')}
</div>
</div>
</div>
{/if}
{:else}
<div class="flex w-full flex-col items-center justify-center py-16 pb-24">
<div class="max-w-sm text-center text-gray-900 dark:text-gray-100">
<div class="mb-1.5 text-sm">
{hasFilters ? $i18n.t('No knowledge found') : $i18n.t('No knowledge yet')}
</div>
<div class="text-center text-xs leading-5 text-gray-500">
{hasFilters
? $i18n.t('Try adjusting your search or filter to find what you are looking for.')
: $i18n.t('Items you create or that are shared with you will appear here.')}
</div>
</div>
<div class="w-full h-full flex justify-center items-center py-10">
<Spinner className="size-4" />
</div>
{/if}
{:else}
<div class="w-full h-full flex justify-center items-center py-10">
<Spinner className="size-4" />
</div>
{/if}
</div>
{:else}
<div class="w-full h-full flex justify-center items-center">
<Spinner className="size-5" />
</div>
{/if}
</div>
{:else}
<div class="w-full h-full flex justify-center items-center">
<Spinner className="size-5" />
</div>
{/if}
</Comments>

View file

@ -56,7 +56,8 @@
<div slot="content">
<DropdownMenu className="min-w-[10.625rem]">
<CommentButton {onComment}
<CommentButton
{onComment}
modal
menu
resource={{ resource_type: 'knowledge', resource_id: resourceId }}

View file

@ -942,206 +942,208 @@
/>
<Comments bind:view={comments} resourceType="knowledge">
<div class="flex h-full min-h-0 w-full flex-col overflow-hidden" id="collection-container">
{#if id && knowledge}
<AccessControlModal
bind:show={showAccessControlModal}
bind:accessGrants={knowledge.access_grants}
share={$user?.permissions?.sharing?.knowledge || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_knowledge || $user?.role === 'admin'}
shareUsers={($user?.permissions?.access_grants?.allow_users ?? true) ||
$user?.role === 'admin'}
allowGroups={($user?.permissions?.access_grants?.allow_groups ?? true) ||
$user?.role === 'admin'}
onChange={async () => {
try {
const saved = await updateKnowledgeAccessGrants(
localStorage.token,
id,
knowledge.access_grants ?? []
);
savedAccessGrants = structuredClone(saved.access_grants);
toast.success($i18n.t('Saved'));
} catch (error) {
knowledge.access_grants = structuredClone(savedAccessGrants);
toast.error(`${error}`);
}
}}
accessRoles={['read', 'write']}
/>
<div class="flex min-h-7 shrink-0 items-center justify-between gap-2 pr-0.5">
<button
type="button"
class="flex h-6 w-fit shrink-0 items-center gap-1 whitespace-nowrap rounded-md text-xs text-gray-400 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
on:click={() => goto('/workspace/knowledge')}
><ChevronLeft className="size-3" strokeWidth="2" />{$i18n.t('Back')}</button
>
<!-- Previous file count label: {$i18n.t('{{COUNT}} files')} -->
<div class="flex items-center gap-2">
{#if !isExternalKnowledge && knowledge?.id}<CommentButton onComment={(request) => (comments = request)} wrapperClassName="me-1"
resource={{ resource_type: 'knowledge', resource_id: knowledge.id }}
/>{/if}
{#if !isExternalKnowledge && (knowledge.user_id === $user?.id || $user?.role === 'admin')}
<Dropdown closeOnSelect align="end">
<button
type="button"
aria-label={$i18n.t('More Options')}
class="flex size-6 items-center justify-center rounded-lg text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800"
>
<EllipsisHorizontal className="size-4" />
</button>
<div slot="content">
<DropdownMenu className="min-w-36">
<button
type="button"
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-xs hover:text-gray-900 dark:hover:text-gray-100"
on:click={() => {
showSettings = true;
}}
>
<Cog6 className="size-3.5" />
{$i18n.t('Settings')}
</button>
</DropdownMenu>
</div>
</Dropdown>
{/if}
{#if knowledge.write_access}
{#if $user?.permissions?.sharing?.knowledge || $user?.role === 'admin'}
<AccessButton
on:click={() => {
savedAccessGrants = structuredClone(knowledge.access_grants ?? []);
showAccessControlModal = true;
}}
/>
{/if}
{:else}
<span class="text-xs text-gray-500">{$i18n.t('Read Only')}</span>
{/if}
</div>
</div>
<div class="shrink-0 px-1 pb-2.5">
<input
class="w-full bg-transparent text-sm outline-hidden"
bind:value={knowledge.name}
aria-label={$i18n.t('Knowledge Name')}
placeholder={$i18n.t('Knowledge Name')}
disabled={!knowledge.write_access}
on:input={changeDebounceHandler}
<div class="flex h-full min-h-0 w-full flex-col overflow-hidden" id="collection-container">
{#if id && knowledge}
<AccessControlModal
bind:show={showAccessControlModal}
bind:accessGrants={knowledge.access_grants}
share={$user?.permissions?.sharing?.knowledge || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_knowledge || $user?.role === 'admin'}
shareUsers={($user?.permissions?.access_grants?.allow_users ?? true) ||
$user?.role === 'admin'}
allowGroups={($user?.permissions?.access_grants?.allow_groups ?? true) ||
$user?.role === 'admin'}
onChange={async () => {
try {
const saved = await updateKnowledgeAccessGrants(
localStorage.token,
id,
knowledge.access_grants ?? []
);
savedAccessGrants = structuredClone(saved.access_grants);
toast.success($i18n.t('Saved'));
} catch (error) {
knowledge.access_grants = structuredClone(savedAccessGrants);
toast.error(`${error}`);
}
}}
accessRoles={['read', 'write']}
/>
<div class="mt-0.5 flex min-w-0 items-center gap-2 text-xs text-gray-500">
<div class="flex min-h-7 shrink-0 items-center justify-between gap-2 pr-0.5">
<button
type="button"
class="flex h-6 w-fit shrink-0 items-center gap-1 whitespace-nowrap rounded-md text-xs text-gray-400 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
on:click={() => goto('/workspace/knowledge')}
><ChevronLeft className="size-3" strokeWidth="2" />{$i18n.t('Back')}</button
>
<!-- Previous file count label: {$i18n.t('{{COUNT}} files')} -->
<div class="flex items-center gap-2">
{#if !isExternalKnowledge && knowledge?.id}<CommentButton
onComment={(request) => (comments = request)}
wrapperClassName="me-1"
resource={{ resource_type: 'knowledge', resource_id: knowledge.id }}
/>{/if}
{#if !isExternalKnowledge && (knowledge.user_id === $user?.id || $user?.role === 'admin')}
<Dropdown closeOnSelect align="end">
<button
type="button"
aria-label={$i18n.t('More Options')}
class="flex size-6 items-center justify-center rounded-lg text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800"
>
<EllipsisHorizontal className="size-4" />
</button>
<div slot="content">
<DropdownMenu className="min-w-36">
<button
type="button"
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-xs hover:text-gray-900 dark:hover:text-gray-100"
on:click={() => {
showSettings = true;
}}
>
<Cog6 className="size-3.5" />
{$i18n.t('Settings')}
</button>
</DropdownMenu>
</div>
</Dropdown>
{/if}
{#if knowledge.write_access}
{#if $user?.permissions?.sharing?.knowledge || $user?.role === 'admin'}
<AccessButton
on:click={() => {
savedAccessGrants = structuredClone(knowledge.access_grants ?? []);
showAccessControlModal = true;
}}
/>
{/if}
{:else}
<span class="text-xs text-gray-500">{$i18n.t('Read Only')}</span>
{/if}
</div>
</div>
<div class="shrink-0 px-1 pb-2.5">
<input
class="min-w-0 flex-1 bg-transparent outline-hidden"
bind:value={knowledge.description}
aria-label={$i18n.t('Knowledge Description')}
placeholder={$i18n.t('Knowledge Description')}
class="w-full bg-transparent text-sm outline-hidden"
bind:value={knowledge.name}
aria-label={$i18n.t('Knowledge Name')}
placeholder={$i18n.t('Knowledge Name')}
disabled={!knowledge.write_access}
on:input={changeDebounceHandler}
/>
<button
type="button"
title={$i18n.t('Click to copy ID')}
class="max-w-[40%] shrink-0 truncate font-mono"
on:click={() => {
copyToClipboard(id);
toast.success($i18n.t('ID copied to clipboard'));
}}>{id}</button
>
<div class="mt-0.5 flex min-w-0 items-center gap-2 text-xs text-gray-500">
<input
class="min-w-0 flex-1 bg-transparent outline-hidden"
bind:value={knowledge.description}
aria-label={$i18n.t('Knowledge Description')}
placeholder={$i18n.t('Knowledge Description')}
disabled={!knowledge.write_access}
on:input={changeDebounceHandler}
/>
<button
type="button"
title={$i18n.t('Click to copy ID')}
class="max-w-[40%] shrink-0 truncate font-mono"
on:click={() => {
copyToClipboard(id);
toast.success($i18n.t('ID copied to clipboard'));
}}>{id}</button
>
</div>
</div>
</div>
<div class="mb-2 min-h-0 flex-1">
{#if isExternalKnowledge}<div
class="h-full overflow-auto rounded-2xl border border-gray-100/80 dark:border-white/[0.04]"
>
<div class="p-5 flex flex-col gap-4">
<div class="flex flex-wrap gap-2 text-xs">
<div class="px-2 py-1 rounded-lg bg-gray-50 dark:bg-gray-850">
{$i18n.t('Connected')}
</div>
<div class="px-2 py-1 rounded-lg bg-gray-50 dark:bg-gray-850">
{$i18n.t('Read Only')}
</div>
<div class="px-2 py-1 rounded-lg bg-gray-50 dark:bg-gray-850">
{knowledge?.meta?.external?.provider ?? $i18n.t('Provider')}
</div>
<div class="px-2 py-1 rounded-lg bg-gray-50 dark:bg-gray-850">
{$i18n.t('Service Account')}
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<div>
<div class="text-xs text-gray-500 mb-1">{$i18n.t('Mapped Source')}</div>
<div class="rounded-xl bg-gray-50 dark:bg-gray-850 px-3 py-2">
{knowledge?.meta?.external?.source?.name ?? $i18n.t('Not configured')}
<div class="mb-2 min-h-0 flex-1">
{#if isExternalKnowledge}<div
class="h-full overflow-auto rounded-2xl border border-gray-100/80 dark:border-white/[0.04]"
>
<div class="p-5 flex flex-col gap-4">
<div class="flex flex-wrap gap-2 text-xs">
<div class="px-2 py-1 rounded-lg bg-gray-50 dark:bg-gray-850">
{$i18n.t('Connected')}
</div>
<div class="px-2 py-1 rounded-lg bg-gray-50 dark:bg-gray-850">
{$i18n.t('Read Only')}
</div>
<div class="px-2 py-1 rounded-lg bg-gray-50 dark:bg-gray-850">
{knowledge?.meta?.external?.provider ?? $i18n.t('Provider')}
</div>
<div class="px-2 py-1 rounded-lg bg-gray-50 dark:bg-gray-850">
{$i18n.t('Service Account')}
</div>
</div>
<div>
<div class="text-xs text-gray-500 mb-1">{$i18n.t('Auth Mode')}</div>
<div class="rounded-xl bg-gray-50 dark:bg-gray-850 px-3 py-2">
{$i18n.t('Admin-managed service account')}
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<div>
<div class="text-xs text-gray-500 mb-1">{$i18n.t('Mapped Source')}</div>
<div class="rounded-xl bg-gray-50 dark:bg-gray-850 px-3 py-2">
{knowledge?.meta?.external?.source?.name ?? $i18n.t('Not configured')}
</div>
</div>
<div>
<div class="text-xs text-gray-500 mb-1">{$i18n.t('Auth Mode')}</div>
<div class="rounded-xl bg-gray-50 dark:bg-gray-850 px-3 py-2">
{$i18n.t('Admin-managed service account')}
</div>
</div>
</div>
</div>
<div class="text-xs text-gray-500">
<!-- LICENSE covers this Open WebUI wordmark.
<div class="text-xs text-gray-500">
<!-- LICENSE covers this Open WebUI wordmark.
Do not alter, remove, obscure, or replace it except as LICENSE permits:
https://docs.openwebui.com/license. -->
{$i18n.t(
'This knowledge base retrieves from a connected source. Open WebUI can query it, but cannot upload, sync, edit, delete, reset, or reindex its source data.'
)}
</div>
<div class="flex flex-col gap-2">
<div class="text-xs">{$i18n.t('Test Query')}</div>
<div class="flex gap-2">
<input
aria-label={$i18n.t('Test Query')}
class="w-full text-xs rounded-xl bg-gray-50 dark:bg-gray-850 px-3 py-2 outline-hidden"
bind:value={externalTestQuery}
placeholder={$i18n.t('Ask this knowledge source a test question')}
/>
<button
class="flex h-7 shrink-0 items-center justify-center gap-1.5 rounded-lg bg-gray-900 px-2.5 text-xs font-normal text-white transition hover:bg-black disabled:opacity-60 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
on:click={externalTestHandler}
>
{$i18n.t('Test')}
</button>
{$i18n.t(
'This knowledge base retrieves from a connected source. Open WebUI can query it, but cannot upload, sync, edit, delete, reset, or reindex its source data.'
)}
</div>
</div>
{#if externalTestResult}
<div class="rounded-xl bg-gray-50 dark:bg-gray-850 p-3 text-xs">
<div class="mb-2">{$i18n.t('Preview')}</div>
{#each externalTestResult.documents ?? [] as document, idx}
<div class="border-t border-gray-100 dark:border-gray-800 py-2">
<div class="line-clamp-4">{document}</div>
<div class="text-gray-500 mt-1">
{externalTestResult.metadatas?.[idx]?.source ?? ''}
<div class="flex flex-col gap-2">
<div class="text-xs">{$i18n.t('Test Query')}</div>
<div class="flex gap-2">
<input
aria-label={$i18n.t('Test Query')}
class="w-full text-xs rounded-xl bg-gray-50 dark:bg-gray-850 px-3 py-2 outline-hidden"
bind:value={externalTestQuery}
placeholder={$i18n.t('Ask this knowledge source a test question')}
/>
<button
class="flex h-7 shrink-0 items-center justify-center gap-1.5 rounded-lg bg-gray-900 px-2.5 text-xs font-normal text-white transition hover:bg-black disabled:opacity-60 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
on:click={externalTestHandler}
>
{$i18n.t('Test')}
</button>
</div>
</div>
{#if externalTestResult}
<div class="rounded-xl bg-gray-50 dark:bg-gray-850 p-3 text-xs">
<div class="mb-2">{$i18n.t('Preview')}</div>
{#each externalTestResult.documents ?? [] as document, idx}
<div class="border-t border-gray-100 dark:border-gray-800 py-2">
<div class="line-clamp-4">{document}</div>
<div class="text-gray-500 mt-1">
{externalTestResult.metadatas?.[idx]?.source ?? ''}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>{:else}
<KnowledgeBrowser
bind:this={browser}
{knowledge}
transfers={fileItems}
{syncing}
onUpload={requestUpload}
onSync={async () => {
pendingSyncFiles = await collectDirectoryFiles();
if (pendingSyncFiles?.length) showSyncConfirmModal = true;
}}
onReset={() => (showResetConfirm = true)}
/>
{/if}
</div>
{:else}<Spinner className="size-5" />{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>{:else}
<KnowledgeBrowser
bind:this={browser}
{knowledge}
transfers={fileItems}
{syncing}
onUpload={requestUpload}
onSync={async () => {
pendingSyncFiles = await collectDirectoryFiles();
if (pendingSyncFiles?.length) showSyncConfirmModal = true;
}}
onReset={() => (showResetConfirm = true)}
/>
{/if}
</div>
{:else}<Spinner className="size-5" />{/if}
</div>
</Comments>
<Modal bind:show={showSettings} size="md" ariaLabel={$i18n.t('Settings')}>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -91,7 +91,8 @@
<div class="flex items-center">{$i18n.t('Edit')}</div>
</button>
{/if}
<CommentButton {onComment}
<CommentButton
{onComment}
modal
menu
resource={{ resource_type: 'model', resource_id: model.id }}

View file

@ -400,420 +400,420 @@
<h1 class="sr-only">{$i18n.t('Prompts')}</h1>
<Comments modal bind:view={comments} resourceType="prompt">
{#if loaded}
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete prompt?')}
on:confirm={() => {
deleteHandler(deletePrompt);
}}
>
<div class=" text-sm text-gray-500 truncate">
{$i18n.t('This will delete')} <span class=" font-normal">{deletePrompt.command}</span>.
</div>
</DeleteConfirmDialog>
<Modal
bind:show={showCreateModal}
size="full"
ariaLabel={createPrompt !== null ? $i18n.t('Clone Prompt') : $i18n.t('Create Prompt')}
className="!w-[calc(100vw-2rem)] sm:!w-[calc(100vw-3rem)] lg:!w-[calc(100vw-4rem)] !max-w-[80rem] h-[min(54rem,calc(100dvh-4rem))] max-h-[calc(100dvh-4rem)] flex flex-col bg-white dark:bg-gray-900 rounded-4xl"
>
{#key createPrompt}
<PromptEditor
modal={true}
prompt={createPrompt}
clone={createPrompt !== null}
onSubmit={createPromptHandler}
onCancel={() => {
closeCreateModal();
}}
/>
{/key}
</Modal>
<input
id="prompts-import-input"
bind:this={promptsImportInputElement}
bind:files={importFiles}
type="file"
accept=".json"
hidden
on:change={() => {
console.log(importFiles);
if (!importFiles || importFiles.length === 0) return;
const reader = new FileReader();
reader.onload = async (event) => {
const savedPrompts = JSON.parse(event.target.result);
console.log(savedPrompts);
try {
for (const prompt of savedPrompts) {
await createNewPrompt(localStorage.token, {
command: prompt.command,
name: prompt.name,
content: prompt.content
}).catch((error) => {
toast.error(typeof error === 'string' ? error : JSON.stringify(error));
return null;
});
}
page = 1;
await getPromptList();
} finally {
importFiles = null;
promptsImportInputElement.value = '';
}
};
reader.readAsText(importFiles[0]);
}}
/>
<div class="space-y-0">
<div class="flex h-8 w-full items-center gap-2">
<div class="flex min-w-0 flex-1">
<div class=" self-center ml-1 mr-3">
<Search className="size-3.5" />
</div>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
on:input={handleSearchInput}
aria-label={$i18n.t('Search Prompts')}
placeholder={$i18n.t('Search Prompts')}
/>
{#if query}
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="py-0.5 rounded-full transition hover:text-gray-900 dark:hover:text-gray-100 text-gray-500 dark:text-gray-400 px-0"
aria-label={$i18n.t('Clear search')}
on:click={() => {
query = '';
handleSearchInput();
}}
>
<XMark className="size-3" strokeWidth="2" />
</button>
</div>
{/if}
{#if loaded}
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete prompt?')}
on:confirm={() => {
deleteHandler(deletePrompt);
}}
>
<div class=" text-sm text-gray-500 truncate">
{$i18n.t('This will delete')} <span class=" font-normal">{deletePrompt.command}</span>.
</div>
</DeleteConfirmDialog>
<div
class="flex max-w-[55%] shrink-0 overflow-x-auto scrollbar-none"
bind:this={tagsContainerElement}
on:wheel={(e) => {
if (e.deltaY !== 0) {
e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY;
<Modal
bind:show={showCreateModal}
size="full"
ariaLabel={createPrompt !== null ? $i18n.t('Clone Prompt') : $i18n.t('Create Prompt')}
className="!w-[calc(100vw-2rem)] sm:!w-[calc(100vw-3rem)] lg:!w-[calc(100vw-4rem)] !max-w-[80rem] h-[min(54rem,calc(100dvh-4rem))] max-h-[calc(100dvh-4rem)] flex flex-col bg-white dark:bg-gray-900 rounded-4xl"
>
{#key createPrompt}
<PromptEditor
modal={true}
prompt={createPrompt}
clone={createPrompt !== null}
onSubmit={createPromptHandler}
onCancel={() => {
closeCreateModal();
}}
/>
{/key}
</Modal>
<input
id="prompts-import-input"
bind:this={promptsImportInputElement}
bind:files={importFiles}
type="file"
accept=".json"
hidden
on:change={() => {
console.log(importFiles);
if (!importFiles || importFiles.length === 0) return;
const reader = new FileReader();
reader.onload = async (event) => {
const savedPrompts = JSON.parse(event.target.result);
console.log(savedPrompts);
try {
for (const prompt of savedPrompts) {
await createNewPrompt(localStorage.token, {
command: prompt.command,
name: prompt.name,
content: prompt.content
}).catch((error) => {
toast.error(typeof error === 'string' ? error : JSON.stringify(error));
return null;
});
}
page = 1;
await getPromptList();
} finally {
importFiles = null;
promptsImportInputElement.value = '';
}
}}
>
<div
class="flex w-fit gap-0.5 text-center text-sm rounded-full bg-transparent whitespace-nowrap"
>
<ViewSelector
bind:value={viewOption}
align="end"
onChange={async (value) => {
localStorage.workspaceViewOption = value;
page = 1;
await tick();
}}
};
reader.readAsText(importFiles[0]);
}}
/>
<div class="space-y-0">
<div class="flex h-8 w-full items-center gap-2">
<div class="flex min-w-0 flex-1">
<div class=" self-center ml-1 mr-3">
<Search className="size-3.5" />
</div>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
on:input={handleSearchInput}
aria-label={$i18n.t('Search Prompts')}
placeholder={$i18n.t('Search Prompts')}
/>
{#if (tags ?? []).length > 0}
<TagSelector
bind:value={selectedTag}
{#if query}
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="py-0.5 rounded-full transition hover:text-gray-900 dark:hover:text-gray-100 text-gray-500 dark:text-gray-400 px-0"
aria-label={$i18n.t('Clear search')}
on:click={() => {
query = '';
handleSearchInput();
}}
>
<XMark className="size-3" strokeWidth="2" />
</button>
</div>
{/if}
</div>
<div
class="flex max-w-[55%] shrink-0 overflow-x-auto scrollbar-none"
bind:this={tagsContainerElement}
on:wheel={(e) => {
if (e.deltaY !== 0) {
e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY;
}
}}
>
<div
class="flex w-fit gap-0.5 text-center text-sm rounded-full bg-transparent whitespace-nowrap"
>
<ViewSelector
bind:value={viewOption}
align="end"
items={tags.map((tag) => ({ value: tag, label: tag }))}
onChange={async () => {
onChange={async (value) => {
localStorage.workspaceViewOption = value;
page = 1;
await tick();
}}
/>
{/if}
{#if (tags ?? []).length > 0}
<TagSelector
bind:value={selectedTag}
align="end"
items={tags.map((tag) => ({ value: tag, label: tag }))}
onChange={async () => {
page = 1;
await tick();
}}
/>
{/if}
</div>
</div>
</div>
</div>
{#if prompts === null || loading}
<div class="w-full h-full flex justify-center items-center my-16 mb-24">
<Spinner className="size-5" />
</div>
{:else if (prompts ?? []).length !== 0}
<div class="@container">
<div
class="grid grid-cols-[minmax(0,1fr)_9rem_8rem_9.5rem] items-center gap-x-4 px-2 @max-[48rem]:grid-cols-[minmax(0,1fr)_7rem_9.5rem] @max-[48rem]:gap-x-3 @max-[36rem]:grid-cols-[minmax(0,1fr)_9.5rem] @max-[36rem]:gap-x-2 min-h-6 text-xs text-gray-500 dark:text-gray-400"
>
<button
class="flex min-w-0 flex-1 items-center gap-1 py-0.5 text-left"
type="button"
on:click={() => setSortKey('name')}
>
{$i18n.t('Name')}
{#if sortKey === 'name'}
{#if sortDirection === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
{/if}
</button>
<div class="@max-[48rem]:hidden">{$i18n.t('Owner')}</div>
<button
class="@max-[36rem]:hidden flex min-w-0 items-center gap-1 py-0.5 text-left"
type="button"
on:click={() => setSortKey('updated_at')}
>
{$i18n.t('Updated at')}
{#if sortKey === 'updated_at'}
{#if sortDirection === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
{/if}
</button>
<span class="sr-only">{$i18n.t('Actions')}</span>
{#if prompts === null || loading}
<div class="w-full h-full flex justify-center items-center my-16 mb-24">
<Spinner className="size-5" />
</div>
<div class="grid gap-y-0.5">
{#each prompts as prompt (prompt.id)}
<div
class="grid grid-cols-[minmax(0,1fr)_9rem_8rem_9.5rem] items-center gap-x-4 px-2 @max-[48rem]:grid-cols-[minmax(0,1fr)_7rem_9.5rem] @max-[48rem]:gap-x-3 @max-[36rem]:grid-cols-[minmax(0,1fr)_9.5rem] @max-[36rem]:gap-x-2 min-h-12 rounded-lg py-1.5 hover:bg-black/[0.03] dark:hover:bg-white/[0.03] group cursor-pointer text-left"
role="presentation"
on:click={(e) => {
if (shouldIgnoreRowClick(e.target)) return;
openPrompt(prompt);
}}
{:else if (prompts ?? []).length !== 0}
<div class="@container">
<div
class="grid grid-cols-[minmax(0,1fr)_9rem_8rem_9.5rem] items-center gap-x-4 px-2 @max-[48rem]:grid-cols-[minmax(0,1fr)_7rem_9.5rem] @max-[48rem]:gap-x-3 @max-[36rem]:grid-cols-[minmax(0,1fr)_9.5rem] @max-[36rem]:gap-x-2 min-h-6 text-xs text-gray-500 dark:text-gray-400"
>
<button
class="flex min-w-0 flex-1 items-center gap-1 py-0.5 text-left"
type="button"
on:click={() => setSortKey('name')}
>
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
<div class="flex min-w-0 items-center gap-2 overflow-hidden">
<div class="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<Tooltip content={prompt.name} className="min-w-0" placement="top-start">
<a
href={`/workspace/prompts/${prompt.id}`}
class="focus-ring block truncate rounded text-[0.8125rem] leading-5 text-gray-800 group-hover:underline focus-visible:underline dark:text-gray-200"
>{prompt.name}</a
>
</Tooltip>
{#if !prompt.write_access}
<Badge type="subtle" content={$i18n.t('Read Only')} />
{$i18n.t('Name')}
{#if sortKey === 'name'}
{#if sortDirection === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
{/if}
</button>
<div class="@max-[48rem]:hidden">{$i18n.t('Owner')}</div>
<button
class="@max-[36rem]:hidden flex min-w-0 items-center gap-1 py-0.5 text-left"
type="button"
on:click={() => setSortKey('updated_at')}
>
{$i18n.t('Updated at')}
{#if sortKey === 'updated_at'}
{#if sortDirection === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
{/if}
</button>
<span class="sr-only">{$i18n.t('Actions')}</span>
</div>
<div class="grid gap-y-0.5">
{#each prompts as prompt (prompt.id)}
<div
class="grid grid-cols-[minmax(0,1fr)_9rem_8rem_9.5rem] items-center gap-x-4 px-2 @max-[48rem]:grid-cols-[minmax(0,1fr)_7rem_9.5rem] @max-[48rem]:gap-x-3 @max-[36rem]:grid-cols-[minmax(0,1fr)_9.5rem] @max-[36rem]:gap-x-2 min-h-12 rounded-lg py-1.5 hover:bg-black/[0.03] dark:hover:bg-white/[0.03] group cursor-pointer text-left"
role="presentation"
on:click={(e) => {
if (shouldIgnoreRowClick(e.target)) return;
openPrompt(prompt);
}}
>
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
<div class="flex min-w-0 items-center gap-2 overflow-hidden">
<div class="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<Tooltip content={prompt.name} className="min-w-0" placement="top-start">
<a
href={`/workspace/prompts/${prompt.id}`}
class="focus-ring block truncate rounded text-[0.8125rem] leading-5 text-gray-800 group-hover:underline focus-visible:underline dark:text-gray-200"
>{prompt.name}</a
>
</Tooltip>
{#if !prompt.write_access}
<Badge type="subtle" content={$i18n.t('Read Only')} />
{/if}
</div>
</div>
<div class="mt-0.5 min-w-0">
{#if prompt.content}
<Tooltip content={prompt.content} className="min-w-0" placement="top-start">
<div class="truncate text-xs leading-4 text-gray-500 dark:text-gray-400">
{prompt.content}
</div>
</Tooltip>
{/if}
</div>
</div>
</div>
<div class="mt-0.5 min-w-0">
{#if prompt.content}
<Tooltip content={prompt.content} className="min-w-0" placement="top-start">
<div class="truncate text-xs leading-4 text-gray-500 dark:text-gray-400">
{prompt.content}
</div>
<div class="@max-[48rem]:hidden min-w-0 text-xs text-gray-600 dark:text-gray-400">
<ProfilePreview
user={prompt?.user ?? null}
side="bottom"
className="w-full min-w-0"
>
<span class="flex w-full min-w-0 items-center gap-2">
<ProfileImage
src={prompt?.user?.id
? `/api/v1/users/${prompt.user.id}/profile/image`
: '/user.png'}
className="size-5 shrink-0 rounded-full!"
/>
<span class="min-w-0 truncate">
{capitalizeFirstLetter(
prompt?.user?.name ?? prompt?.user?.email ?? $i18n.t('Deleted User')
)}
</span>
</span>
</ProfilePreview>
</div>
<Tooltip
content={dayjs((prompt.updated_at ?? prompt.created_at) * 1000)
.locale($i18n.language)
.format('LLLL')}
className="@max-[36rem]:hidden min-w-0"
>
<div class="shrink-0 truncate text-xs leading-5 text-gray-500 dark:text-gray-400">
{dayjs((prompt.updated_at ?? prompt.created_at) * 1000)
.locale($i18n.language)
.fromNow()}
</div>
</Tooltip>
<div class="flex min-w-0 items-center justify-end gap-1.5">
<div class="flex shrink-0 flex-row items-center gap-1.5 self-center">
{#if shiftKey}
<Tooltip content={$i18n.t('Delete')}>
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500 disabled:opacity-50 disabled:cursor-not-allowed"
type="button"
aria-label={$i18n.t('Delete')}
disabled={!prompt.write_access}
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
deleteHandler(prompt);
}}
>
<GarbageBin className="size-4" />
</button>
</Tooltip>
{:else}
<div class="flex shrink-0 flex-row items-center gap-1.5 self-center">
<Tooltip content={$i18n.t('Copy Prompt')}>
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
type="button"
aria-label={$i18n.t('Copy Prompt')}
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
copyHandler(prompt);
}}
>
{#if copiedId === prompt.command}
<Check className="size-4" strokeWidth="1.5" />
{:else}
<Clipboard className="size-4" strokeWidth="1.5" />
{/if}
</button>
</Tooltip>
<CommentButton
onComment={(request) => (comments = request)}
modal
className="flex size-6 shrink-0 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
resource={{ resource_type: 'prompt', resource_id: prompt.id }}
/>
<PromptMenu
onComment={(request) => (comments = request)}
resourceId={prompt.id}
accessHandler={$user?.permissions?.sharing?.prompts ||
$user?.role === 'admin'
? () => accessModal.open(prompt.id)
: null}
writeAccess={prompt.write_access}
show={openPromptMenuId === prompt.id}
editHandler={() => {
goto(`/workspace/prompts/${prompt.id}`);
}}
shareHandler={() => {
shareHandler(prompt);
}}
cloneHandler={() => {
cloneHandler(prompt);
}}
exportHandler={() => {
exportHandler(prompt);
}}
deleteHandler={async () => {
deletePrompt = prompt;
showDeleteConfirm = true;
}}
onClose={() => {
openPromptMenuId = null;
}}
>
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
type="button"
aria-label={$i18n.t('Prompt Menu')}
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
openPromptMenuId = openPromptMenuId === prompt.id ? null : prompt.id;
}}
>
<EllipsisHorizontal className="size-4" />
</button>
</PromptMenu>
{#if prompt.write_access}
<div
class="flex h-6 items-center"
role="presentation"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<Tooltip
content={prompt.is_active !== false
? $i18n.t('Enabled')
: $i18n.t('Disabled')}
>
<Switch
bind:state={prompt.is_active}
ariaLabel={$i18n.t('Enabled')}
on:change={async () => {
togglePromptById(localStorage.token, prompt.id);
}}
/>
</Tooltip>
</div>
{:else}
<Switch
state={prompt.is_active}
disabled
tooltip={$i18n.t('Read Only')}
ariaLabel={`${$i18n.t('Enabled')}: ${prompt.name}`}
/>
{/if}
</div>
{/if}
</div>
</div>
</div>
<div
class="@max-[48rem]:hidden min-w-0 text-xs text-gray-600 dark:text-gray-400"
>
<ProfilePreview
user={prompt?.user ?? null}
side="bottom"
className="w-full min-w-0"
>
<span class="flex w-full min-w-0 items-center gap-2">
<ProfileImage
src={prompt?.user?.id
? `/api/v1/users/${prompt.user.id}/profile/image`
: '/user.png'}
className="size-5 shrink-0 rounded-full!"
/>
<span class="min-w-0 truncate">
{capitalizeFirstLetter(
prompt?.user?.name ?? prompt?.user?.email ?? $i18n.t('Deleted User')
)}
</span>
</span>
</ProfilePreview>
</div>
<Tooltip
content={dayjs((prompt.updated_at ?? prompt.created_at) * 1000)
.locale($i18n.language)
.format('LLLL')}
className="@max-[36rem]:hidden min-w-0"
>
<div class="shrink-0 truncate text-xs leading-5 text-gray-500 dark:text-gray-400">
{dayjs((prompt.updated_at ?? prompt.created_at) * 1000)
.locale($i18n.language)
.fromNow()}
</div>
</Tooltip>
<div class="flex min-w-0 items-center justify-end gap-1.5">
<div class="flex shrink-0 flex-row items-center gap-1.5 self-center">
{#if shiftKey}
<Tooltip content={$i18n.t('Delete')}>
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500 disabled:opacity-50 disabled:cursor-not-allowed"
type="button"
aria-label={$i18n.t('Delete')}
disabled={!prompt.write_access}
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
deleteHandler(prompt);
}}
>
<GarbageBin className="size-4" />
</button>
</Tooltip>
{:else}
<div class="flex shrink-0 flex-row items-center gap-1.5 self-center">
<Tooltip content={$i18n.t('Copy Prompt')}>
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
type="button"
aria-label={$i18n.t('Copy Prompt')}
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
copyHandler(prompt);
}}
>
{#if copiedId === prompt.command}
<Check className="size-4" strokeWidth="1.5" />
{:else}
<Clipboard className="size-4" strokeWidth="1.5" />
{/if}
</button>
</Tooltip>
<CommentButton onComment={(request) => (comments = request)}
modal
className="flex size-6 shrink-0 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
resource={{ resource_type: 'prompt', resource_id: prompt.id }}
/>
<PromptMenu onComment={(request) => (comments = request)}
resourceId={prompt.id}
accessHandler={$user?.permissions?.sharing?.prompts ||
$user?.role === 'admin'
? () => accessModal.open(prompt.id)
: null}
writeAccess={prompt.write_access}
show={openPromptMenuId === prompt.id}
editHandler={() => {
goto(`/workspace/prompts/${prompt.id}`);
}}
shareHandler={() => {
shareHandler(prompt);
}}
cloneHandler={() => {
cloneHandler(prompt);
}}
exportHandler={() => {
exportHandler(prompt);
}}
deleteHandler={async () => {
deletePrompt = prompt;
showDeleteConfirm = true;
}}
onClose={() => {
openPromptMenuId = null;
}}
>
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
type="button"
aria-label={$i18n.t('Prompt Menu')}
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
openPromptMenuId = openPromptMenuId === prompt.id ? null : prompt.id;
}}
>
<EllipsisHorizontal className="size-4" />
</button>
</PromptMenu>
{#if prompt.write_access}
<div
class="flex h-6 items-center"
role="presentation"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<Tooltip
content={prompt.is_active !== false
? $i18n.t('Enabled')
: $i18n.t('Disabled')}
>
<Switch
bind:state={prompt.is_active}
ariaLabel={$i18n.t('Enabled')}
on:change={async () => {
togglePromptById(localStorage.token, prompt.id);
}}
/>
</Tooltip>
</div>
{:else}
<Switch
state={prompt.is_active}
disabled
tooltip={$i18n.t('Read Only')}
ariaLabel={`${$i18n.t('Enabled')}: ${prompt.name}`}
/>
{/if}
</div>
{/if}
</div>
</div>
</div>
{/each}
{/each}
</div>
</div>
</div>
{#if total > 30}
<div class="flex justify-center mt-4 mb-2">
<Pagination bind:page count={total} perPage={30} />
{#if total > 30}
<div class="flex justify-center mt-4 mb-2">
<Pagination bind:page count={total} perPage={30} />
</div>
{/if}
{:else}
<div class="flex w-full flex-col items-center justify-center py-16 pb-24">
<div class="max-w-sm text-center text-gray-900 dark:text-gray-100">
<div class="mb-1.5 text-sm">
{hasFilters ? $i18n.t('No prompts found') : $i18n.t('No prompts yet')}
</div>
<div class="text-center text-xs leading-5 text-gray-500">
{hasFilters
? $i18n.t('Try adjusting your search or filter to find what you are looking for.')
: $i18n.t('Items you create or that are shared with you will appear here.')}
</div>
</div>
</div>
{/if}
{:else}
<div class="flex w-full flex-col items-center justify-center py-16 pb-24">
<div class="max-w-sm text-center text-gray-900 dark:text-gray-100">
<div class="mb-1.5 text-sm">
{hasFilters ? $i18n.t('No prompts found') : $i18n.t('No prompts yet')}
</div>
<div class="text-center text-xs leading-5 text-gray-500">
{hasFilters
? $i18n.t('Try adjusting your search or filter to find what you are looking for.')
: $i18n.t('Items you create or that are shared with you will appear here.')}
</div>
</div>
</div>
{/if}
</div>
</div>
{#if $config?.features.enable_community_sharing}
<CommunityDiscover
href="https://openwebui.com/search?type=prompt"
title={$i18n.t('Discover a prompt')}
description={$i18n.t('Discover, download, and explore custom prompts')}
/>
{#if $config?.features.enable_community_sharing}
<CommunityDiscover
href="https://openwebui.com/search?type=prompt"
title={$i18n.t('Discover a prompt')}
description={$i18n.t('Discover, download, and explore custom prompts')}
/>
{/if}
{:else}
<div class="w-full h-full flex justify-center items-center">
<Spinner className="size-5" />
</div>
{/if}
{:else}
<div class="w-full h-full flex justify-center items-center">
<Spinner className="size-5" />
</div>
{/if}
</Comments>

View file

@ -440,311 +440,315 @@
/>
<Comments bind:view={comments} resourceType={modal ? null : 'prompt'} {modal}>
<div
class="flex h-full min-h-0 w-full min-w-0 flex-col overflow-hidden {modal
? 'px-5 pt-3 pb-1'
: ''}"
>
<form
class="flex h-full min-h-0 min-w-0 flex-col"
aria-label={edit ? $i18n.t('Edit Prompt') : $i18n.t('Create Prompt')}
inert={loading}
on:submit|preventDefault={submitHandler}
<div
class="flex h-full min-h-0 w-full min-w-0 flex-col overflow-hidden {modal
? 'px-5 pt-3 pb-1'
: ''}"
>
<div class="flex min-h-7 shrink-0 items-center justify-between gap-2">
{#if modal}
<span class="text-xs text-gray-500"
>{clone ? $i18n.t('Clone Prompt') : $i18n.t('Create Prompt')}</span
>
{:else}
<button
type="button"
class="flex h-6 w-fit shrink-0 items-center gap-1 whitespace-nowrap rounded-md text-xs text-gray-400 transition-colors hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
on:click={() => goto('/workspace/prompts')}
>
<ChevronLeft className="size-3" strokeWidth="2" />{$i18n.t('Back')}
</button>
{/if}
<div class="flex shrink-0 items-center gap-1 pr-0.5">
{#if edit && prompt?.id}<CommentButton onComment={(request) => (comments = request)} wrapperClassName="me-2"
resource={{ resource_type: 'prompt', resource_id: prompt.id }}
/>{/if}
{#if !disabled}{#if $user?.permissions?.sharing?.prompts || $user?.role === 'admin'}<AccessButton
on:click={() => {
savedAccessGrants = structuredClone(accessGrants);
showAccessControlModal = true;
}}
/>{/if}
{:else}<span class="px-2 py-1 text-xs text-gray-500">{$i18n.t('Read Only')}</span>{/if}
{#if modal}<button
type="button"
aria-label={$i18n.t('Close')}
class="p-1 text-gray-500 hover:text-gray-900 dark:hover:text-gray-100"
on:click={() => confirmDiscard(() => onCancel())}><XMark className="size-4" /></button
>{/if}
</div>
</div>
<div class="shrink-0 px-1 pb-2">
<input
class="w-full {($settings?.highContrastMode ?? false)
? 'min-h-6'
: ''} bg-transparent text-sm outline-hidden"
placeholder={$i18n.t('Prompt Name')}
aria-label={$i18n.t('Prompt Name')}
bind:value={name}
on:input={debouncedSaveMetadata}
required
{disabled}
/>
<div class="mt-0.5 flex min-w-0 items-center gap-2 text-xs text-gray-500">
<Tooltip
className="min-w-0 flex-1"
content={`${$i18n.t('Only alphanumeric characters and hyphens are allowed')} - ${$i18n.t(
'Activate this command by typing "/{{COMMAND}}" to chat input.',
{
COMMAND: command
}
)}`}
placement="bottom-start"
>
<div class="flex min-w-0 items-center gap-0.5">
<span>/</span>
<input
class="min-w-0 flex-1 {($settings?.highContrastMode ?? false)
? 'min-h-6'
: ''} bg-transparent outline-hidden"
placeholder={$i18n.t('command')}
aria-label={$i18n.t('Command')}
bind:value={command}
on:input={() => {
hasManualEdit = true;
debouncedSaveMetadata();
}}
required
{disabled}
/>
</div>
</Tooltip>
{#if edit}
<Tooltip className="min-w-0 max-w-[45%]" content={$i18n.t('Click to copy ID')}>
<button
type="button"
class="w-full truncate font-mono text-xs text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
on:click={async () => {
if (await copyToClipboard(prompt.id))
toast.success($i18n.t('ID copied to clipboard'));
}}>{prompt.id}</button
>
</Tooltip>
{/if}
</div>
<div class="mt-1">
<Tags
{tags}
{disabled}
{suggestionTags}
on:add={(e) => {
tags = [...tags, { name: e.detail }];
debouncedSaveMetadata();
}}
on:delete={(e) => {
tags = tags.filter((tag) => tag.name !== e.detail);
debouncedSaveMetadata();
}}
/>
</div>
</div>
<div
class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-gray-100/80 bg-white dark:border-white/[0.04] dark:bg-gray-900"
<form
class="flex h-full min-h-0 min-w-0 flex-col"
aria-label={edit ? $i18n.t('Edit Prompt') : $i18n.t('Create Prompt')}
inert={loading}
on:submit|preventDefault={submitHandler}
>
{#if historyDiff}
<VersionDiff
diff={historyDiff}
currentLabel="Production"
showFileHeaders={false}
loadFileDiff={async () => ({
diff: historyDiff.content,
line_endings_only: historyDiff.line_endings_only
})}
onClose={() => (historyDiff = null)}
/>
{:else}
<div
class="flex shrink-0 items-center gap-1 bg-gray-50/60 px-2.5 py-1.5 text-xs dark:bg-black"
>
<div class="min-w-0 flex-1">
{#if edit}
<Dropdown bind:show={showHistory} align="start">
<button
type="button"
aria-label={$i18n.t('History')}
class="flex max-w-full items-center gap-2 py-0.5 text-gray-500 hover:text-gray-900 dark:hover:text-gray-100"
disabled={loading}
>
<span class="truncate"
>{historical
? selectedHistoryEntry.commit_message || selectedVersionId.slice(0, 7)
: $i18n.t('Live')}{editingHistory ? ` · ${$i18n.t('Editing')}` : ''}</span
><ChevronDown className="size-3 shrink-0" />
</button>
<div slot="content">
<DropdownMenu className="w-72 max-w-[calc(100vw-2rem)] max-h-80 overflow-y-auto">
<VersionMenuItem
entry={history.find((entry) => entry.id === prompt?.version_id) ||
(productionEntry?.id === prompt?.version_id ? productionEntry : null)}
status={$i18n.t('Live')}
deleteDisabledReason={disabled
? ''
: $i18n.t('Cannot delete the production version')}
selected={!historical}
onSelect={() => chooseVersion()}
/>
{#if history.some((entry) => entry.id !== prompt?.version_id)}<hr
class="border-gray-100 dark:border-gray-800"
/>{/if}
{#each history.filter((entry) => entry.id !== prompt?.version_id) as entry (entry.id)}
<VersionMenuItem
{entry}
selected={selectedVersionId === entry.id}
onSelect={() => chooseVersion(entry)}
onDelete={disabled
? undefined
: () => {
deleteVersionId = entry.id;
showHistory = false;
showDeleteVersion = true;
}}
/>
{/each}
{#if historyLoading}<div
class="flex items-center gap-2 px-2 py-1 text-xs text-gray-500"
>
<Spinner className="size-3" />{$i18n.t('Loading...')}
</div>
{:else if historyError}<button
type="button"
on:click={() => loadHistory(historyPage === 0)}>{$i18n.t('Retry')}</button
>
{:else if !history.length}<div class="px-2 py-2 text-xs text-gray-400">
{$i18n.t('No history available')}
</div>
{:else if historyHasMore}<hr class="border-gray-100 dark:border-gray-800" />
<button type="button" on:click={() => loadHistory()}
>{$i18n.t('Load more')}</button
>{/if}
</DropdownMenu>
</div>
</Dropdown>
{:else}<span class="text-gray-500">{$i18n.t('Prompt Content')}</span>{/if}
</div>
{#if !readOnly}
<button
type="button"
class="shrink-0 rounded-md px-2 py-1 text-gray-500 hover:bg-gray-100 hover:text-gray-900 disabled:opacity-50 dark:hover:bg-gray-800 dark:hover:text-gray-100"
disabled={!ready || loading || historyLoading}
on:click={compareRevision}>{$i18n.t('Compare revision')}</button
>
{/if}
<Tooltip
content={`${$i18n.t('Use')} {{${$i18n.t('variable')}}} ${$i18n.t('for placeholders')}`}
><span class="px-1 text-gray-400">{'{{'}{$i18n.t('variable')}{'}}'}</span></Tooltip
<div class="flex min-h-7 shrink-0 items-center justify-between gap-2">
{#if modal}
<span class="text-xs text-gray-500"
>{clone ? $i18n.t('Clone Prompt') : $i18n.t('Create Prompt')}</span
>
{:else}
<button
type="button"
aria-label={$i18n.t('Copy content')}
class="shrink-0 p-0.5 text-gray-500 hover:text-gray-900 dark:hover:text-gray-100"
on:click={copyContent}
>{#if contentCopied}<Check className="size-3.5 text-green-500" />{:else}<Clipboard
className="size-3.5"
/>{/if}</button
class="flex h-6 w-fit shrink-0 items-center gap-1 whitespace-nowrap rounded-md text-xs text-gray-400 transition-colors hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
on:click={() => goto('/workspace/prompts')}
>
</div>
<textarea
bind:this={contentInput}
bind:value={content}
aria-label={$i18n.t('Prompt Content')}
placeholder={$i18n.t('Write a summary in 50 words that summarizes {{topic}}.')}
readonly={readOnly}
required
spellcheck="false"
class="min-h-0 w-full flex-1 resize-none bg-transparent px-3 py-2 font-mono text-xs leading-relaxed outline-hidden"
on:keydown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
event.preventDefault();
submitHandler();
}
}}
></textarea>
{/if}
</div>
<ChevronLeft className="size-3" strokeWidth="2" />{$i18n.t('Back')}
</button>
{/if}
<div class="flex shrink-0 items-center gap-1 pr-0.5">
{#if edit && prompt?.id}<CommentButton
onComment={(request) => (comments = request)}
wrapperClassName="me-2"
resource={{ resource_type: 'prompt', resource_id: prompt.id }}
/>{/if}
{#if historical}
<div
class="flex shrink-0 flex-wrap items-center justify-between gap-2 px-1 py-2 text-xs text-gray-500"
>
<span
>{selectedHistoryEntry.id.slice(0, 7)} · {renderDate(
selectedHistoryEntry.created_at
)}</span
>
{#if !editingHistory}<div class="ml-auto flex flex-wrap justify-end gap-2">
<button
{#if !disabled}{#if $user?.permissions?.sharing?.prompts || $user?.role === 'admin'}<AccessButton
on:click={() => {
savedAccessGrants = structuredClone(accessGrants);
showAccessControlModal = true;
}}
/>{/if}
{:else}<span class="px-2 py-1 text-xs text-gray-500">{$i18n.t('Read Only')}</span>{/if}
{#if modal}<button
type="button"
class="flex h-7 items-center rounded-lg bg-gray-100 px-2.5 text-xs text-gray-700 disabled:opacity-60 dark:bg-gray-850 dark:text-gray-200"
disabled={comparing || !!historyDiff}
on:click={compareToCurrent}
>{#if comparing}<Spinner className="mr-1.5 size-3" />{/if}{$i18n.t(
'Compare to current'
)}</button
>
{#if !disabled}
aria-label={$i18n.t('Close')}
class="p-1 text-gray-500 hover:text-gray-900 dark:hover:text-gray-100"
on:click={() => confirmDiscard(() => onCancel())}><XMark className="size-4" /></button
>{/if}
</div>
</div>
<div class="shrink-0 px-1 pb-2">
<input
class="w-full {($settings?.highContrastMode ?? false)
? 'min-h-6'
: ''} bg-transparent text-sm outline-hidden"
placeholder={$i18n.t('Prompt Name')}
aria-label={$i18n.t('Prompt Name')}
bind:value={name}
on:input={debouncedSaveMetadata}
required
{disabled}
/>
<div class="mt-0.5 flex min-w-0 items-center gap-2 text-xs text-gray-500">
<Tooltip
className="min-w-0 flex-1"
content={`${$i18n.t('Only alphanumeric characters and hyphens are allowed')} - ${$i18n.t(
'Activate this command by typing "/{{COMMAND}}" to chat input.',
{
COMMAND: command
}
)}`}
placement="bottom-start"
>
<div class="flex min-w-0 items-center gap-0.5">
<span>/</span>
<input
class="min-w-0 flex-1 {($settings?.highContrastMode ?? false)
? 'min-h-6'
: ''} bg-transparent outline-hidden"
placeholder={$i18n.t('command')}
aria-label={$i18n.t('Command')}
bind:value={command}
on:input={() => {
hasManualEdit = true;
debouncedSaveMetadata();
}}
required
{disabled}
/>
</div>
</Tooltip>
{#if edit}
<Tooltip className="min-w-0 max-w-[45%]" content={$i18n.t('Click to copy ID')}>
<button
type="button"
class="flex h-7 items-center rounded-lg bg-gray-100 px-2.5 text-xs text-gray-700 dark:bg-gray-850 dark:text-gray-200"
on:click={editVersion}>{$i18n.t('Edit as new version')}</button
><button
type="button"
class="flex h-7 items-center rounded-lg bg-gray-900 px-2.5 text-xs text-white transition hover:bg-black dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
on:click={() => confirmDiscard(setAsProduction)}
>{$i18n.t('Set as Production')}</button
class="w-full truncate font-mono text-xs text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
on:click={async () => {
if (await copyToClipboard(prompt.id))
toast.success($i18n.t('ID copied to clipboard'));
}}>{prompt.id}</button
>
{/if}
</div>{/if}
</div>
{/if}
{#if !readOnly}
<div class="flex shrink-0 flex-wrap items-center gap-2 py-2">
{#if edit}<input
class="min-w-0 flex-1 bg-transparent px-2 text-xs outline-hidden"
placeholder={$i18n.t('Describe what changed...')}
aria-label={$i18n.t('Commit Message')}
bind:value={commitMessage}
/>{:else}<div class="flex-1"></div>{/if}
<div class="ml-auto flex shrink-0 items-center gap-3">
{#if historical}<label
class="flex cursor-pointer items-center gap-1.5 whitespace-nowrap text-xs text-gray-500"
><input
type="checkbox"
bind:checked={isProduction}
class="size-3 rounded border-gray-300 dark:border-gray-600"
/><span>{$i18n.t('Set as Production')}</span></label
>{/if}
<button
type="submit"
class="flex h-7 shrink-0 items-center gap-1.5 rounded-lg bg-gray-900 px-2.5 text-xs text-white transition hover:bg-black disabled:opacity-60 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
disabled={loading || !ready || historyLoading}
>
{edit ? $i18n.t('Save') : $i18n.t('Save & Create')}{#if loading}<Spinner
className="size-3"
/>{/if}
</button>
</Tooltip>
{/if}
</div>
<div class="mt-1">
<Tags
{tags}
{disabled}
{suggestionTags}
on:add={(e) => {
tags = [...tags, { name: e.detail }];
debouncedSaveMetadata();
}}
on:delete={(e) => {
tags = tags.filter((tag) => tag.name !== e.detail);
debouncedSaveMetadata();
}}
/>
</div>
</div>
{/if}
</form>
</div>
<div
class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-gray-100/80 bg-white dark:border-white/[0.04] dark:bg-gray-900"
>
{#if historyDiff}
<VersionDiff
diff={historyDiff}
currentLabel="Production"
showFileHeaders={false}
loadFileDiff={async () => ({
diff: historyDiff.content,
line_endings_only: historyDiff.line_endings_only
})}
onClose={() => (historyDiff = null)}
/>
{:else}
<div
class="flex shrink-0 items-center gap-1 bg-gray-50/60 px-2.5 py-1.5 text-xs dark:bg-black"
>
<div class="min-w-0 flex-1">
{#if edit}
<Dropdown bind:show={showHistory} align="start">
<button
type="button"
aria-label={$i18n.t('History')}
class="flex max-w-full items-center gap-2 py-0.5 text-gray-500 hover:text-gray-900 dark:hover:text-gray-100"
disabled={loading}
>
<span class="truncate"
>{historical
? selectedHistoryEntry.commit_message || selectedVersionId.slice(0, 7)
: $i18n.t('Live')}{editingHistory ? ` · ${$i18n.t('Editing')}` : ''}</span
><ChevronDown className="size-3 shrink-0" />
</button>
<div slot="content">
<DropdownMenu
className="w-72 max-w-[calc(100vw-2rem)] max-h-80 overflow-y-auto"
>
<VersionMenuItem
entry={history.find((entry) => entry.id === prompt?.version_id) ||
(productionEntry?.id === prompt?.version_id ? productionEntry : null)}
status={$i18n.t('Live')}
deleteDisabledReason={disabled
? ''
: $i18n.t('Cannot delete the production version')}
selected={!historical}
onSelect={() => chooseVersion()}
/>
{#if history.some((entry) => entry.id !== prompt?.version_id)}<hr
class="border-gray-100 dark:border-gray-800"
/>{/if}
{#each history.filter((entry) => entry.id !== prompt?.version_id) as entry (entry.id)}
<VersionMenuItem
{entry}
selected={selectedVersionId === entry.id}
onSelect={() => chooseVersion(entry)}
onDelete={disabled
? undefined
: () => {
deleteVersionId = entry.id;
showHistory = false;
showDeleteVersion = true;
}}
/>
{/each}
{#if historyLoading}<div
class="flex items-center gap-2 px-2 py-1 text-xs text-gray-500"
>
<Spinner className="size-3" />{$i18n.t('Loading...')}
</div>
{:else if historyError}<button
type="button"
on:click={() => loadHistory(historyPage === 0)}>{$i18n.t('Retry')}</button
>
{:else if !history.length}<div class="px-2 py-2 text-xs text-gray-400">
{$i18n.t('No history available')}
</div>
{:else if historyHasMore}<hr class="border-gray-100 dark:border-gray-800" />
<button type="button" on:click={() => loadHistory()}
>{$i18n.t('Load more')}</button
>{/if}
</DropdownMenu>
</div>
</Dropdown>
{:else}<span class="text-gray-500">{$i18n.t('Prompt Content')}</span>{/if}
</div>
{#if !readOnly}
<button
type="button"
class="shrink-0 rounded-md px-2 py-1 text-gray-500 hover:bg-gray-100 hover:text-gray-900 disabled:opacity-50 dark:hover:bg-gray-800 dark:hover:text-gray-100"
disabled={!ready || loading || historyLoading}
on:click={compareRevision}>{$i18n.t('Compare revision')}</button
>
{/if}
<Tooltip
content={`${$i18n.t('Use')} {{${$i18n.t('variable')}}} ${$i18n.t('for placeholders')}`}
><span class="px-1 text-gray-400">{'{{'}{$i18n.t('variable')}{'}}'}</span></Tooltip
>
<button
type="button"
aria-label={$i18n.t('Copy content')}
class="shrink-0 p-0.5 text-gray-500 hover:text-gray-900 dark:hover:text-gray-100"
on:click={copyContent}
>{#if contentCopied}<Check className="size-3.5 text-green-500" />{:else}<Clipboard
className="size-3.5"
/>{/if}</button
>
</div>
<textarea
bind:this={contentInput}
bind:value={content}
aria-label={$i18n.t('Prompt Content')}
placeholder={$i18n.t('Write a summary in 50 words that summarizes {{topic}}.')}
readonly={readOnly}
required
spellcheck="false"
class="min-h-0 w-full flex-1 resize-none bg-transparent px-3 py-2 font-mono text-xs leading-relaxed outline-hidden"
on:keydown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
event.preventDefault();
submitHandler();
}
}}
></textarea>
{/if}
</div>
{#if historical}
<div
class="flex shrink-0 flex-wrap items-center justify-between gap-2 px-1 py-2 text-xs text-gray-500"
>
<span
>{selectedHistoryEntry.id.slice(0, 7)} · {renderDate(
selectedHistoryEntry.created_at
)}</span
>
{#if !editingHistory}<div class="ml-auto flex flex-wrap justify-end gap-2">
<button
type="button"
class="flex h-7 items-center rounded-lg bg-gray-100 px-2.5 text-xs text-gray-700 disabled:opacity-60 dark:bg-gray-850 dark:text-gray-200"
disabled={comparing || !!historyDiff}
on:click={compareToCurrent}
>{#if comparing}<Spinner className="mr-1.5 size-3" />{/if}{$i18n.t(
'Compare to current'
)}</button
>
{#if !disabled}
<button
type="button"
class="flex h-7 items-center rounded-lg bg-gray-100 px-2.5 text-xs text-gray-700 dark:bg-gray-850 dark:text-gray-200"
on:click={editVersion}>{$i18n.t('Edit as new version')}</button
><button
type="button"
class="flex h-7 items-center rounded-lg bg-gray-900 px-2.5 text-xs text-white transition hover:bg-black dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
on:click={() => confirmDiscard(setAsProduction)}
>{$i18n.t('Set as Production')}</button
>
{/if}
</div>{/if}
</div>
{/if}
{#if !readOnly}
<div class="flex shrink-0 flex-wrap items-center gap-2 py-2">
{#if edit}<input
class="min-w-0 flex-1 bg-transparent px-2 text-xs outline-hidden"
placeholder={$i18n.t('Describe what changed...')}
aria-label={$i18n.t('Commit Message')}
bind:value={commitMessage}
/>{:else}<div class="flex-1"></div>{/if}
<div class="ml-auto flex shrink-0 items-center gap-3">
{#if historical}<label
class="flex cursor-pointer items-center gap-1.5 whitespace-nowrap text-xs text-gray-500"
><input
type="checkbox"
bind:checked={isProduction}
class="size-3 rounded border-gray-300 dark:border-gray-600"
/><span>{$i18n.t('Set as Production')}</span></label
>{/if}
<button
type="submit"
class="flex h-7 shrink-0 items-center gap-1.5 rounded-lg bg-gray-900 px-2.5 text-xs text-white transition hover:bg-black disabled:opacity-60 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
disabled={loading || !ready || historyLoading}
>
{edit ? $i18n.t('Save') : $i18n.t('Save & Create')}{#if loading}<Spinner
className="size-3"
/>{/if}
</button>
</div>
</div>
{/if}
</form>
</div>
</Comments>

View file

@ -75,7 +75,8 @@
<div class="flex items-center">{$i18n.t('Edit')}</div>
</button>
<CommentButton {onComment}
<CommentButton
{onComment}
modal
menu
resource={{ resource_type: 'prompt', resource_id: resourceId }}

View file

@ -352,391 +352,397 @@
<h1 class="sr-only">{$i18n.t('Skills')}</h1>
<Comments modal bind:view={comments} resourceType="skill">
{#if loaded}
<ImportModal
bind:show={showImportFromLink}
loadUrlHandler={(url) => loadSkillByUrl(localStorage.token, url)}
transformResult={(packages) => packages}
successMessage={$i18n.t('Skills loaded for preview')}
onImport={(packages) => {
bundleFiles = [
new File([JSON.stringify(packages)], 'skills.json', { type: 'application/json' })
];
showImport = true;
}}
/>
<SkillImport
bind:show={showImport}
files={bundleFiles}
onImported={async () => {
toast.success($i18n.t('Skill imported successfully'));
page = 1;
await loadSkillItems();
_skills.set(await getSkills(localStorage.token));
}}
/>
<input
bind:this={importInputElement}
type="file"
accept=".md,.json,.zip,.skill"
multiple
hidden
on:change={() => {
bundleFiles = Array.from(importInputElement.files || []);
showImport = true;
importInputElement.value = '';
}}
/>
<input
bind:this={folderImportInput}
type="file"
webkitdirectory
multiple
hidden
on:change={() => {
bundleFiles = Array.from(folderImportInput.files || []);
showImport = true;
folderImportInput.value = '';
}}
/>
{#if loaded}
<ImportModal
bind:show={showImportFromLink}
loadUrlHandler={(url) => loadSkillByUrl(localStorage.token, url)}
transformResult={(packages) => packages}
successMessage={$i18n.t('Skills loaded for preview')}
onImport={(packages) => {
bundleFiles = [
new File([JSON.stringify(packages)], 'skills.json', { type: 'application/json' })
];
showImport = true;
}}
/>
<SkillImport
bind:show={showImport}
files={bundleFiles}
onImported={async () => {
toast.success($i18n.t('Skill imported successfully'));
page = 1;
await loadSkillItems();
_skills.set(await getSkills(localStorage.token));
}}
/>
<input
bind:this={importInputElement}
type="file"
accept=".md,.json,.zip,.skill"
multiple
hidden
on:change={() => {
bundleFiles = Array.from(importInputElement.files || []);
showImport = true;
importInputElement.value = '';
}}
/>
<input
bind:this={folderImportInput}
type="file"
webkitdirectory
multiple
hidden
on:change={() => {
bundleFiles = Array.from(folderImportInput.files || []);
showImport = true;
folderImportInput.value = '';
}}
/>
<div class="space-y-0">
<div class="flex h-8 w-full items-center gap-2">
<div class="flex min-w-0 flex-1">
<div class=" self-center ml-1 mr-3">
<Search className="size-3.5" />
</div>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
on:input={handleSearchInput}
aria-label={$i18n.t('Search Skills')}
placeholder={$i18n.t('Search Skills')}
/>
{#if query}
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="py-0.5 rounded-full transition hover:text-gray-900 dark:hover:text-gray-100 text-gray-500 dark:text-gray-400 px-0"
aria-label={$i18n.t('Clear search')}
on:click={() => {
query = '';
handleSearchInput();
}}
>
<XMark className="size-3" strokeWidth="2" />
</button>
<div class="space-y-0">
<div class="flex h-8 w-full items-center gap-2">
<div class="flex min-w-0 flex-1">
<div class=" self-center ml-1 mr-3">
<Search className="size-3.5" />
</div>
{/if}
</div>
<div
class="flex max-w-[55%] shrink-0 overflow-x-auto scrollbar-none"
bind:this={tagsContainerElement}
on:wheel={(e) => {
if (e.deltaY !== 0) {
e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY;
}
}}
>
<div
class="flex w-fit gap-0.5 text-center text-sm rounded-full bg-transparent whitespace-nowrap"
>
<ViewSelector
bind:value={viewOption}
align="end"
onChange={async (value) => {
localStorage.workspaceViewOption = value;
page = 1;
await tick();
}}
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
on:input={handleSearchInput}
aria-label={$i18n.t('Search Skills')}
placeholder={$i18n.t('Search Skills')}
/>
{#if query}
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="py-0.5 rounded-full transition hover:text-gray-900 dark:hover:text-gray-100 text-gray-500 dark:text-gray-400 px-0"
aria-label={$i18n.t('Clear search')}
on:click={() => {
query = '';
handleSearchInput();
}}
>
<XMark className="size-3" strokeWidth="2" />
</button>
</div>
{/if}
</div>
</div>
</div>
{#if filteredItems === null || loading}
<div class="w-full h-full flex justify-center items-center my-16 mb-24">
<Spinner className="size-5" />
</div>
{:else if (filteredItems ?? []).length !== 0}
<div class="@container">
<div
class="grid grid-cols-[minmax(0,1fr)_9rem_8rem_9.5rem] items-center gap-x-4 px-2 @max-[48rem]:grid-cols-[minmax(0,1fr)_7rem_9.5rem] @max-[48rem]:gap-x-3 @max-[36rem]:grid-cols-[minmax(0,1fr)_9.5rem] @max-[36rem]:gap-x-2 min-h-6 text-xs text-gray-500 dark:text-gray-400"
class="flex max-w-[55%] shrink-0 overflow-x-auto scrollbar-none"
bind:this={tagsContainerElement}
on:wheel={(e) => {
if (e.deltaY !== 0) {
e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY;
}
}}
>
<button
class="flex min-w-0 flex-1 items-center gap-1 py-0.5 text-left"
type="button"
on:click={() => setSortKey('name')}
<div
class="flex w-fit gap-0.5 text-center text-sm rounded-full bg-transparent whitespace-nowrap"
>
{$i18n.t('Name')}
{#if sortKey === 'name'}
{#if sortDirection === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
{/if}
</button>
<div class="@max-[48rem]:hidden">{$i18n.t('Owner')}</div>
<button
class="@max-[36rem]:hidden flex min-w-0 items-center gap-1 py-0.5 text-left"
type="button"
on:click={() => setSortKey('updated_at')}
>
{$i18n.t('Updated at')}
{#if sortKey === 'updated_at'}
{#if sortDirection === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
{/if}
</button>
<span class="sr-only">{$i18n.t('Actions')}</span>
</div>
<div class="grid gap-y-0.5">
{#each filteredItems as skill (skill.id)}
<div
class="grid grid-cols-[minmax(0,1fr)_9rem_8rem_9.5rem] items-center gap-x-4 px-2 @max-[48rem]:grid-cols-[minmax(0,1fr)_7rem_9.5rem] @max-[48rem]:gap-x-3 @max-[36rem]:grid-cols-[minmax(0,1fr)_9.5rem] @max-[36rem]:gap-x-2 min-h-12 rounded-lg py-1.5 hover:bg-black/[0.03] dark:hover:bg-white/[0.03] group cursor-pointer text-left"
role="presentation"
on:click={(e) => {
if (shouldIgnoreRowClick(e.target)) return;
openSkill(skill);
<ViewSelector
bind:value={viewOption}
align="end"
onChange={async (value) => {
localStorage.workspaceViewOption = value;
page = 1;
await tick();
}}
>
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
<div class="flex min-w-0 items-center gap-2 overflow-hidden">
<div class="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<Tooltip content={skill.id} className="min-w-0" placement="top-start">
<a
href={`/workspace/skills/edit?id=${encodeURIComponent(skill.id)}`}
class="focus-ring block truncate rounded text-[0.8125rem] leading-5 text-gray-800 group-hover:underline focus-visible:underline dark:text-gray-200"
>{resolveLocalizedResource(skill, $i18n.language)}</a
>
</Tooltip>
{#if !skill.write_access}
<Badge type="subtle" content={$i18n.t('Read Only')} />
{/if}
/>
</div>
</div>
</div>
{#if !skill.is_active}
<Badge type="muted" content={$i18n.t('Inactive')} />
{#if filteredItems === null || loading}
<div class="w-full h-full flex justify-center items-center my-16 mb-24">
<Spinner className="size-5" />
</div>
{:else if (filteredItems ?? []).length !== 0}
<div class="@container">
<div
class="grid grid-cols-[minmax(0,1fr)_9rem_8rem_9.5rem] items-center gap-x-4 px-2 @max-[48rem]:grid-cols-[minmax(0,1fr)_7rem_9.5rem] @max-[48rem]:gap-x-3 @max-[36rem]:grid-cols-[minmax(0,1fr)_9.5rem] @max-[36rem]:gap-x-2 min-h-6 text-xs text-gray-500 dark:text-gray-400"
>
<button
class="flex min-w-0 flex-1 items-center gap-1 py-0.5 text-left"
type="button"
on:click={() => setSortKey('name')}
>
{$i18n.t('Name')}
{#if sortKey === 'name'}
{#if sortDirection === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
{/if}
</button>
<div class="@max-[48rem]:hidden">{$i18n.t('Owner')}</div>
<button
class="@max-[36rem]:hidden flex min-w-0 items-center gap-1 py-0.5 text-left"
type="button"
on:click={() => setSortKey('updated_at')}
>
{$i18n.t('Updated at')}
{#if sortKey === 'updated_at'}
{#if sortDirection === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
{/if}
</button>
<span class="sr-only">{$i18n.t('Actions')}</span>
</div>
<div class="grid gap-y-0.5">
{#each filteredItems as skill (skill.id)}
<div
class="grid grid-cols-[minmax(0,1fr)_9rem_8rem_9.5rem] items-center gap-x-4 px-2 @max-[48rem]:grid-cols-[minmax(0,1fr)_7rem_9.5rem] @max-[48rem]:gap-x-3 @max-[36rem]:grid-cols-[minmax(0,1fr)_9.5rem] @max-[36rem]:gap-x-2 min-h-12 rounded-lg py-1.5 hover:bg-black/[0.03] dark:hover:bg-white/[0.03] group cursor-pointer text-left"
role="presentation"
on:click={(e) => {
if (shouldIgnoreRowClick(e.target)) return;
openSkill(skill);
}}
>
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
<div class="flex min-w-0 items-center gap-2 overflow-hidden">
<div class="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<Tooltip content={skill.id} className="min-w-0" placement="top-start">
<a
href={`/workspace/skills/edit?id=${encodeURIComponent(skill.id)}`}
class="focus-ring block truncate rounded text-[0.8125rem] leading-5 text-gray-800 group-hover:underline focus-visible:underline dark:text-gray-200"
>{resolveLocalizedResource(skill, $i18n.language)}</a
>
</Tooltip>
{#if !skill.write_access}
<Badge type="subtle" content={$i18n.t('Read Only')} />
{/if}
{#if !skill.is_active}
<Badge type="muted" content={$i18n.t('Inactive')} />
{/if}
</div>
</div>
<div class="mt-0.5 min-w-0">
{#if resolveLocalizedResource(skill, $i18n.language, 'description')}
<Tooltip
content={resolveLocalizedResource(skill, $i18n.language, 'description')}
className="min-w-0"
placement="top-start"
>
<div class="truncate text-xs leading-4 text-gray-500 dark:text-gray-400">
{resolveLocalizedResource(skill, $i18n.language, 'description')}
</div>
</Tooltip>
{/if}
</div>
</div>
<div class="mt-0.5 min-w-0">
{#if resolveLocalizedResource(skill, $i18n.language, 'description')}
<Tooltip
content={resolveLocalizedResource(skill, $i18n.language, 'description')}
className="min-w-0"
placement="top-start"
>
<div class="truncate text-xs leading-4 text-gray-500 dark:text-gray-400">
{resolveLocalizedResource(skill, $i18n.language, 'description')}
</div>
</Tooltip>
{/if}
</div>
</div>
</div>
<div
class="@max-[48rem]:hidden min-w-0 text-xs text-gray-600 dark:text-gray-400"
>
<ProfilePreview user={skill?.user ?? null} side="bottom" className="w-full min-w-0">
<span class="flex w-full min-w-0 items-center gap-2">
<ProfileImage
src={skill?.user?.id
? `/api/v1/users/${skill.user.id}/profile/image`
: '/user.png'}
className="size-5 shrink-0 rounded-full!"
/>
<span class="min-w-0 truncate">
{capitalizeFirstLetter(
skill?.user?.name ?? skill?.user?.email ?? $i18n.t('Deleted User')
)}
<div class="@max-[48rem]:hidden min-w-0 text-xs text-gray-600 dark:text-gray-400">
<ProfilePreview
user={skill?.user ?? null}
side="bottom"
className="w-full min-w-0"
>
<span class="flex w-full min-w-0 items-center gap-2">
<ProfileImage
src={skill?.user?.id
? `/api/v1/users/${skill.user.id}/profile/image`
: '/user.png'}
className="size-5 shrink-0 rounded-full!"
/>
<span class="min-w-0 truncate">
{capitalizeFirstLetter(
skill?.user?.name ?? skill?.user?.email ?? $i18n.t('Deleted User')
)}
</span>
</span>
</span>
</ProfilePreview>
</div>
<Tooltip
content={dayjs((skill.updated_at ?? skill.created_at) * 1000)
.locale($i18n.language)
.format('LLLL')}
className="@max-[36rem]:hidden min-w-0"
>
<div class="shrink-0 truncate text-xs leading-5 text-gray-500 dark:text-gray-400">
{dayjs((skill.updated_at ?? skill.created_at) * 1000)
.locale($i18n.language)
.fromNow()}
</ProfilePreview>
</div>
</Tooltip>
<div class="flex min-w-0 items-center justify-end gap-1.5">
{#if skill.write_access}
<div class="flex shrink-0 flex-row items-center self-center">
{#if shiftKey}
<Tooltip content={$i18n.t('Delete')}>
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
type="button"
aria-label={$i18n.t('Delete')}
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
deleteHandler(skill);
}}
>
<GarbageBin className="size-4" />
</button>
</Tooltip>
{:else}
<div class="flex shrink-0 flex-row items-center gap-1.5 self-center">
<CommentButton onComment={(request) => (comments = request)}
modal
className="flex size-6 shrink-0 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
resource={{ resource_type: 'skill', resource_id: skill.id }}
/>
<SkillMenu onComment={(request) => (comments = request)}
resourceId={skill.id}
shareHandler={() => shareHandler(skill)}
accessHandler={$user?.permissions?.sharing?.skills ||
$user?.role === 'admin'
? () => accessModal.open(skill.id)
: null}
show={openSkillMenuId === skill.id}
editHandler={() => {
goto(`/workspace/skills/edit?id=${encodeURIComponent(skill.id)}`);
}}
cloneHandler={() => {
cloneHandler(skill);
}}
exportHandler={(format) => {
exportHandler(skill, format);
}}
deleteHandler={async () => {
selectedSkill = skill;
showDeleteConfirm = true;
}}
onClose={() => {
openSkillMenuId = null;
}}
>
<Tooltip
content={dayjs((skill.updated_at ?? skill.created_at) * 1000)
.locale($i18n.language)
.format('LLLL')}
className="@max-[36rem]:hidden min-w-0"
>
<div class="shrink-0 truncate text-xs leading-5 text-gray-500 dark:text-gray-400">
{dayjs((skill.updated_at ?? skill.created_at) * 1000)
.locale($i18n.language)
.fromNow()}
</div>
</Tooltip>
<div class="flex min-w-0 items-center justify-end gap-1.5">
{#if skill.write_access}
<div class="flex shrink-0 flex-row items-center self-center">
{#if shiftKey}
<Tooltip content={$i18n.t('Delete')}>
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
type="button"
aria-label={$i18n.t('Skill Menu')}
aria-label={$i18n.t('Delete')}
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
openSkillMenuId = openSkillMenuId === skill.id ? null : skill.id;
deleteHandler(skill);
}}
>
<EllipsisHorizontal className="size-4" />
<GarbageBin className="size-4" />
</button>
</SkillMenu>
<div
class="flex h-6 items-center"
role="presentation"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<Tooltip
content={skill.is_active ? $i18n.t('Enabled') : $i18n.t('Disabled')}
</Tooltip>
{:else}
<div class="flex shrink-0 flex-row items-center gap-1.5 self-center">
<CommentButton
onComment={(request) => (comments = request)}
modal
className="flex size-6 shrink-0 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
resource={{ resource_type: 'skill', resource_id: skill.id }}
/>
<SkillMenu
onComment={(request) => (comments = request)}
resourceId={skill.id}
shareHandler={() => shareHandler(skill)}
accessHandler={$user?.permissions?.sharing?.skills ||
$user?.role === 'admin'
? () => accessModal.open(skill.id)
: null}
show={openSkillMenuId === skill.id}
editHandler={() => {
goto(`/workspace/skills/edit?id=${encodeURIComponent(skill.id)}`);
}}
cloneHandler={() => {
cloneHandler(skill);
}}
exportHandler={(format) => {
exportHandler(skill, format);
}}
deleteHandler={async () => {
selectedSkill = skill;
showDeleteConfirm = true;
}}
onClose={() => {
openSkillMenuId = null;
}}
>
<Switch
bind:state={skill.is_active}
ariaLabel={$i18n.t('Enabled')}
on:change={async () => {
await toggleSkillById(localStorage.token, skill.id);
_skills.set(await getSkills(localStorage.token));
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
type="button"
aria-label={$i18n.t('Skill Menu')}
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
openSkillMenuId = openSkillMenuId === skill.id ? null : skill.id;
}}
/>
</Tooltip>
</div>
</div>
{/if}
</div>
{/if}
{#if !skill.write_access}
<CommentButton onComment={(request) => (comments = request)}
modal
className="flex size-6 shrink-0 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
resource={{ resource_type: 'skill', resource_id: skill.id }}
/>
<span class="size-6 shrink-0" aria-hidden="true"></span>
<div
class="flex h-6 items-center"
role="presentation"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<Switch
state={skill.is_active}
disabled
tooltip={$i18n.t('Read Only')}
ariaLabel={`${$i18n.t('Enabled')}: ${resolveLocalizedResource(skill, $i18n.language)}`}
/>
</div>
{/if}
</div>
</div>
{/each}
</div>
</div>
>
<EllipsisHorizontal className="size-4" />
</button>
</SkillMenu>
{#if total > 30}
<div class="flex justify-center mt-4 mb-2">
<Pagination bind:page count={total} perPage={30} />
<div
class="flex h-6 items-center"
role="presentation"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<Tooltip
content={skill.is_active ? $i18n.t('Enabled') : $i18n.t('Disabled')}
>
<Switch
bind:state={skill.is_active}
ariaLabel={$i18n.t('Enabled')}
on:change={async () => {
await toggleSkillById(localStorage.token, skill.id);
_skills.set(await getSkills(localStorage.token));
}}
/>
</Tooltip>
</div>
</div>
{/if}
</div>
{/if}
{#if !skill.write_access}
<CommentButton
onComment={(request) => (comments = request)}
modal
className="flex size-6 shrink-0 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
resource={{ resource_type: 'skill', resource_id: skill.id }}
/>
<span class="size-6 shrink-0" aria-hidden="true"></span>
<div
class="flex h-6 items-center"
role="presentation"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<Switch
state={skill.is_active}
disabled
tooltip={$i18n.t('Read Only')}
ariaLabel={`${$i18n.t('Enabled')}: ${resolveLocalizedResource(skill, $i18n.language)}`}
/>
</div>
{/if}
</div>
</div>
{/each}
</div>
</div>
{#if total > 30}
<div class="flex justify-center mt-4 mb-2">
<Pagination bind:page count={total} perPage={30} />
</div>
{/if}
{:else}
<div class="flex w-full flex-col items-center justify-center py-16 pb-24">
<div class="max-w-sm text-center text-gray-900 dark:text-gray-100">
<div class="mb-1.5 text-sm">
{hasFilters ? $i18n.t('No skills found') : $i18n.t('No skills yet')}
</div>
<div class="text-center text-xs leading-5 text-gray-500">
{hasFilters
? $i18n.t('Try adjusting your search or filter to find what you are looking for.')
: $i18n.t('Items you create or that are shared with you will appear here.')}
</div>
</div>
</div>
{/if}
{:else}
<div class="flex w-full flex-col items-center justify-center py-16 pb-24">
<div class="max-w-sm text-center text-gray-900 dark:text-gray-100">
<div class="mb-1.5 text-sm">
{hasFilters ? $i18n.t('No skills found') : $i18n.t('No skills yet')}
</div>
<div class="text-center text-xs leading-5 text-gray-500">
{hasFilters
? $i18n.t('Try adjusting your search or filter to find what you are looking for.')
: $i18n.t('Items you create or that are shared with you will appear here.')}
</div>
</div>
</div>
{/if}
</div>
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete skill?')}
on:confirm={() => {
deleteHandler(selectedSkill);
}}
>
<div class=" text-sm text-gray-500 truncate">
{$i18n.t('This will delete')}
<span class=" font-normal">{resolveLocalizedResource(selectedSkill, $i18n.language)}</span>.
</div>
</DeleteConfirmDialog>
{#if $config?.features?.enable_community_sharing}
<CommunityDiscover
href="https://openwebui.com/search?type=skill"
title={$i18n.t('Discover a skill')}
description={$i18n.t('Discover, download, and explore community skills')}
/>
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete skill?')}
on:confirm={() => {
deleteHandler(selectedSkill);
}}
>
<div class=" text-sm text-gray-500 truncate">
{$i18n.t('This will delete')}
<span class=" font-normal">{resolveLocalizedResource(selectedSkill, $i18n.language)}</span
>.
</div>
</DeleteConfirmDialog>
{#if $config?.features?.enable_community_sharing}
<CommunityDiscover
href="https://openwebui.com/search?type=skill"
title={$i18n.t('Discover a skill')}
description={$i18n.t('Discover, download, and explore community skills')}
/>
{/if}
{:else}
<div class="w-full h-full flex justify-center items-center">
<Spinner className="size-5" />
</div>
{/if}
{:else}
<div class="w-full h-full flex justify-center items-center">
<Spinner className="size-5" />
</div>
{/if}
</Comments>

View file

@ -346,305 +346,310 @@
/>
<Comments bind:view={comments} resourceType="skill">
<div class="flex h-full w-full min-w-0 flex-col overflow-hidden">
<form
inert={loading}
class="flex h-full min-h-0 min-w-0 flex-col"
on:submit|preventDefault={submitHandler}
>
<div class="flex shrink-0 items-center justify-between gap-2">
<button
class="flex h-6 w-fit shrink-0 items-center gap-1 whitespace-nowrap rounded-md text-xs text-gray-400 transition-colors duration-75 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
type="button"
on:click={() => {
goto('/workspace/skills');
}}
>
<ChevronLeft className="size-3" strokeWidth="2" />
<span>{$i18n.t('Back')}</span>
</button>
<div class="flex shrink-0 items-center gap-1 pr-0.5">
{#if edit && skill?.id}<CommentButton onComment={(request) => (comments = request)} wrapperClassName="me-2"
resource={{ resource_type: 'skill', resource_id: skill.id }}
/>{/if}
<LanguageModeSelect bind:value={locale} translatedLocales={Object.keys(meta.i18n ?? {})} />
{#if !disabled}
{#if $user?.permissions?.sharing?.skills || $user?.role === 'admin'}
<AccessButton
on:click={() => {
savedAccessGrants = structuredClone(accessGrants);
showAccessControlModal = true;
}}
/>
{/if}
{:else}
<span class="rounded-lg bg-gray-100 px-2 py-1 text-xs text-gray-500 dark:bg-gray-850">
{$i18n.t('Read Only')}
</span>
{/if}
</div>
</div>
<div class="shrink-0 pb-2 px-1">
<Tooltip content={$i18n.t('e.g. Code Review Guidelines')} placement="top-start">
<LocalizedField
placeholder={$i18n.t('Skill Name')}
showControls={false}
bind:value={name}
bind:translations={meta.i18n}
{locale}
required
disabled={readOnly}
/>
</Tooltip>
<div class="mt-0.5 flex min-w-0 items-center gap-2 text-xs text-gray-500">
<Tooltip
className="flex min-w-0 flex-1 items-center"
content={$i18n.t('e.g. Step-by-step instructions for code reviews')}
placement="top-start"
<div class="flex h-full w-full min-w-0 flex-col overflow-hidden">
<form
inert={loading}
class="flex h-full min-h-0 min-w-0 flex-col"
on:submit|preventDefault={submitHandler}
>
<div class="flex shrink-0 items-center justify-between gap-2">
<button
class="flex h-6 w-fit shrink-0 items-center gap-1 whitespace-nowrap rounded-md text-xs text-gray-400 transition-colors duration-75 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
type="button"
on:click={() => {
goto('/workspace/skills');
}}
>
<ChevronLeft className="size-3" strokeWidth="2" />
<span>{$i18n.t('Back')}</span>
</button>
<div class="flex shrink-0 items-center gap-1 pr-0.5">
{#if edit && skill?.id}<CommentButton
onComment={(request) => (comments = request)}
wrapperClassName="me-2"
resource={{ resource_type: 'skill', resource_id: skill.id }}
/>{/if}
<LanguageModeSelect
bind:value={locale}
translatedLocales={Object.keys(meta.i18n ?? {})}
/>
{#if !disabled}
{#if $user?.permissions?.sharing?.skills || $user?.role === 'admin'}
<AccessButton
on:click={() => {
savedAccessGrants = structuredClone(accessGrants);
showAccessControlModal = true;
}}
/>
{/if}
{:else}
<span class="rounded-lg bg-gray-100 px-2 py-1 text-xs text-gray-500 dark:bg-gray-850">
{$i18n.t('Read Only')}
</span>
{/if}
</div>
</div>
<div class="shrink-0 pb-2 px-1">
<Tooltip content={$i18n.t('e.g. Code Review Guidelines')} placement="top-start">
<LocalizedField
placeholder={$i18n.t('Skill Description')}
placeholder={$i18n.t('Skill Name')}
showControls={false}
bind:value={description}
bind:value={name}
bind:translations={meta.i18n}
{locale}
field="description"
required
disabled={readOnly}
/>
</Tooltip>
{#if edit}
<div class="shrink-0 truncate font-mono" title={id}>
{id}
</div>
{:else}
<div class="mt-0.5 flex min-w-0 items-center gap-2 text-xs text-gray-500">
<Tooltip
className="min-w-[8rem] flex-1"
content={$i18n.t('e.g. code-review-guidelines')}
className="flex min-w-0 flex-1 items-center"
content={$i18n.t('e.g. Step-by-step instructions for code reviews')}
placement="top-start"
>
<input
class="w-full bg-transparent font-mono outline-hidden disabled:text-gray-500"
type="text"
placeholder={$i18n.t('Skill ID')}
aria-label={$i18n.t('Skill ID')}
bind:value={id}
required
disabled={edit}
<LocalizedField
placeholder={$i18n.t('Skill Description')}
showControls={false}
bind:value={description}
bind:translations={meta.i18n}
{locale}
field="description"
disabled={readOnly}
/>
</Tooltip>
{/if}
</div>
</div>
{#if conflict}<div
role="alert"
class="flex flex-wrap gap-3 bg-amber-50 p-3 text-xs text-amber-900 dark:bg-amber-950 dark:text-amber-100"
>
<span>{$i18n.t('This skill has a newer version. Your draft is preserved.')}</span><button
type="button"
on:click={() => confirmDiscard(reload)}>{$i18n.t('Reload latest')}</button
><button type="button" on:click={saveCopy}>{$i18n.t('Save draft as copy')}</button><button
type="button"
on:click={() => (conflict = false)}>{$i18n.t('Keep editing')}</button
>
</div>{/if}
<div
class="min-h-0 flex-1 overflow-hidden rounded-2xl border border-gray-100/50 bg-gray-50/60 dark:border-white/[0.025] dark:bg-white/[0.03]"
>
{#if historyDiff}
<VersionDiff
diff={historyDiff}
onClose={() => (historyDiff = null)}
loadFileDiff={async (path) => {
try {
return await (
await skillRequest(
localStorage.token,
`/id/${id}/history/diff/file?${new URLSearchParams({ from_id: historyDiff.fromId, to_id: historyDiff.toId, path })}`
)
).json();
} catch (error) {
throw new Error(skillError(error));
}
}}
/>
{:else if ready}<SkillFiles
bind:this={fileEditor}
skillId={id}
{versionId}
{reloadKey}
initialContent={content}
initialFiles={skill?.files || null}
{readOnly}
bind:dirty={fileDirty}
initialPath={new URLSearchParams(location.search).get('path') || 'SKILL.md'}
onSave={submitHandler}
onContentChange={handleContentChange}
canExport={edit &&
($user?.role === 'admin' || $user?.permissions?.workspace?.skills_export)}
>
{#snippet version()}
{#if edit}
<div class="min-w-0">
<Dropdown
bind:show={showHistory}
align="start"
onOpenChange={(open) => {
if (open) {
historyPage = 1;
history = [];
loadHistory();
}
}}
>
<button
type="button"
aria-label={$i18n.t('Select version')}
class="flex h-7 max-w-full items-center gap-2 rounded-lg bg-transparent px-2 text-xs text-gray-500 transition hover:text-gray-900 dark:hover:text-gray-100"
>
<span class="truncate"
>{historical
? versionId?.slice(0, 7)
: $i18n.t('Current')}{selectedCommitMessage
? ` · ${selectedCommitMessage}`
: ''}</span
>
<ChevronDown className="size-3 shrink-0" />
</button>
<div slot="content">
<DropdownMenu
className="w-72 max-w-[calc(100vw-2rem)] max-h-80 overflow-y-auto"
>
<VersionMenuItem
entry={currentHistoryEntry?.id === currentVersionId
? currentHistoryEntry
: null}
status={$i18n.t('Current')}
selected={versionId === currentVersionId}
onSelect={() => chooseVersion(currentVersionId!)}
/>
{#if historyLoading || historyError || history.some((entry) => entry.id !== currentVersionId)}
<hr class="border-gray-100 dark:border-gray-800" />
{/if}
{#if historyLoading}
<div class="flex items-center gap-2 px-2 py-2 text-xs text-gray-500">
<Spinner className="size-3" />{$i18n.t('Loading...')}
</div>
{:else if historyError}
<button type="button" on:click={loadHistory}>{$i18n.t('Retry')}</button>
{:else}
{#each history.filter((entry) => entry.id !== currentVersionId) as entry}
<VersionMenuItem
{entry}
selected={entry.id === versionId}
onSelect={() => chooseVersion(entry.id)}
onDelete={disabled
? undefined
: () => {
deleteVersionId = entry.id;
showHistory = false;
showDeleteVersion = true;
}}
/>
{:else}
{#if historyPage > 1}
<div class="px-2 py-2 text-xs text-gray-500">
{$i18n.t('No more versions')}
</div>
{/if}
{/each}
{/if}
{#if historyPage > 1 || history.length === 20}
<hr class="border-gray-100 dark:border-gray-800" />
<div class="flex justify-between px-2 py-1 text-xs">
<button
type="button"
disabled={historyLoading || historyPage === 1}
class="disabled:opacity-40"
on:click={() => {
historyPage--;
loadHistory();
}}>{$i18n.t('Previous')}</button
>
<button
type="button"
disabled={historyLoading || history.length < 20}
class="disabled:opacity-40"
on:click={() => {
historyPage++;
loadHistory();
}}>{$i18n.t('Next')}</button
>
</div>
{/if}
</DropdownMenu>
</div>
</Dropdown>
</div>
{/if}
{/snippet}
{#snippet exports()}
<button type="button" on:click={() => exportVersion('skill')}
><Download className="size-3.5" />{$i18n.t('Export .skill')}</button
{#if edit}
<div class="shrink-0 truncate font-mono" title={id}>
{id}
</div>
{:else}
<Tooltip
className="min-w-[8rem] flex-1"
content={$i18n.t('e.g. code-review-guidelines')}
placement="top-start"
>
<button type="button" on:click={() => exportVersion('json')}
><Download className="size-3.5" />{$i18n.t('Export JSON')}</button
>
<button type="button" on:click={() => exportVersion('zip')}
><Download className="size-3.5" />{$i18n.t('Export ZIP')}</button
>
{/snippet}
</SkillFiles>{/if}
</div>
{#if historical}
<div
class="flex w-full shrink-0 flex-wrap items-center gap-2 px-1 py-2 text-xs text-gray-500"
>
<span class="mr-auto">{$i18n.t('Read Only')}</span>
<div class="ml-auto flex flex-wrap justify-end gap-2">
<button
type="button"
class="flex h-7 items-center rounded-lg bg-gray-100 px-2.5 text-xs text-gray-700 disabled:opacity-60 dark:bg-gray-850 dark:text-gray-200"
disabled={comparing || !!historyDiff}
on:click={compareToCurrent}
>{#if comparing}<Spinner className="mr-1.5 size-3" />{/if}{$i18n.t(
'Compare to current'
)}</button
>
{#if !disabled}<button
type="button"
class="flex h-7 items-center rounded-lg bg-gray-900 px-2.5 text-xs text-white transition hover:bg-black dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
on:click={setProductionVersion}>{$i18n.t('Set as Production')}</button
>{/if}
<input
class="w-full bg-transparent font-mono outline-hidden disabled:text-gray-500"
type="text"
placeholder={$i18n.t('Skill ID')}
aria-label={$i18n.t('Skill ID')}
bind:value={id}
required
disabled={edit}
/>
</Tooltip>
{/if}
</div>
</div>
{/if}
{#if !readOnly}
<div class="flex shrink-0 justify-end gap-3 py-2">
<input
class="min-w-0 flex-1 bg-transparent px-2 text-xs outline-hidden"
placeholder={$i18n.t('Describe this change')}
aria-label={$i18n.t('Commit message')}
bind:value={commitMessage}
/>
<button
class="flex h-7 items-center gap-1.5 rounded-lg bg-gray-900 px-2.5 text-xs text-white transition hover:bg-black disabled:opacity-60 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
type="submit"
disabled={loading || !fileEditor}
{#if conflict}<div
role="alert"
class="flex flex-wrap gap-3 bg-amber-50 p-3 text-xs text-amber-900 dark:bg-amber-950 dark:text-amber-100"
>
{edit ? $i18n.t('Save') : $i18n.t('Save & Create')}
{#if loading}
<Spinner className="size-3" />
{/if}
</button>
<span>{$i18n.t('This skill has a newer version. Your draft is preserved.')}</span><button
type="button"
on:click={() => confirmDiscard(reload)}>{$i18n.t('Reload latest')}</button
><button type="button" on:click={saveCopy}>{$i18n.t('Save draft as copy')}</button><button
type="button"
on:click={() => (conflict = false)}>{$i18n.t('Keep editing')}</button
>
</div>{/if}
<div
class="min-h-0 flex-1 overflow-hidden rounded-2xl border border-gray-100/50 bg-gray-50/60 dark:border-white/[0.025] dark:bg-white/[0.03]"
>
{#if historyDiff}
<VersionDiff
diff={historyDiff}
onClose={() => (historyDiff = null)}
loadFileDiff={async (path) => {
try {
return await (
await skillRequest(
localStorage.token,
`/id/${id}/history/diff/file?${new URLSearchParams({ from_id: historyDiff.fromId, to_id: historyDiff.toId, path })}`
)
).json();
} catch (error) {
throw new Error(skillError(error));
}
}}
/>
{:else if ready}<SkillFiles
bind:this={fileEditor}
skillId={id}
{versionId}
{reloadKey}
initialContent={content}
initialFiles={skill?.files || null}
{readOnly}
bind:dirty={fileDirty}
initialPath={new URLSearchParams(location.search).get('path') || 'SKILL.md'}
onSave={submitHandler}
onContentChange={handleContentChange}
canExport={edit &&
($user?.role === 'admin' || $user?.permissions?.workspace?.skills_export)}
>
{#snippet version()}
{#if edit}
<div class="min-w-0">
<Dropdown
bind:show={showHistory}
align="start"
onOpenChange={(open) => {
if (open) {
historyPage = 1;
history = [];
loadHistory();
}
}}
>
<button
type="button"
aria-label={$i18n.t('Select version')}
class="flex h-7 max-w-full items-center gap-2 rounded-lg bg-transparent px-2 text-xs text-gray-500 transition hover:text-gray-900 dark:hover:text-gray-100"
>
<span class="truncate"
>{historical
? versionId?.slice(0, 7)
: $i18n.t('Current')}{selectedCommitMessage
? ` · ${selectedCommitMessage}`
: ''}</span
>
<ChevronDown className="size-3 shrink-0" />
</button>
<div slot="content">
<DropdownMenu
className="w-72 max-w-[calc(100vw-2rem)] max-h-80 overflow-y-auto"
>
<VersionMenuItem
entry={currentHistoryEntry?.id === currentVersionId
? currentHistoryEntry
: null}
status={$i18n.t('Current')}
selected={versionId === currentVersionId}
onSelect={() => chooseVersion(currentVersionId!)}
/>
{#if historyLoading || historyError || history.some((entry) => entry.id !== currentVersionId)}
<hr class="border-gray-100 dark:border-gray-800" />
{/if}
{#if historyLoading}
<div class="flex items-center gap-2 px-2 py-2 text-xs text-gray-500">
<Spinner className="size-3" />{$i18n.t('Loading...')}
</div>
{:else if historyError}
<button type="button" on:click={loadHistory}>{$i18n.t('Retry')}</button>
{:else}
{#each history.filter((entry) => entry.id !== currentVersionId) as entry}
<VersionMenuItem
{entry}
selected={entry.id === versionId}
onSelect={() => chooseVersion(entry.id)}
onDelete={disabled
? undefined
: () => {
deleteVersionId = entry.id;
showHistory = false;
showDeleteVersion = true;
}}
/>
{:else}
{#if historyPage > 1}
<div class="px-2 py-2 text-xs text-gray-500">
{$i18n.t('No more versions')}
</div>
{/if}
{/each}
{/if}
{#if historyPage > 1 || history.length === 20}
<hr class="border-gray-100 dark:border-gray-800" />
<div class="flex justify-between px-2 py-1 text-xs">
<button
type="button"
disabled={historyLoading || historyPage === 1}
class="disabled:opacity-40"
on:click={() => {
historyPage--;
loadHistory();
}}>{$i18n.t('Previous')}</button
>
<button
type="button"
disabled={historyLoading || history.length < 20}
class="disabled:opacity-40"
on:click={() => {
historyPage++;
loadHistory();
}}>{$i18n.t('Next')}</button
>
</div>
{/if}
</DropdownMenu>
</div>
</Dropdown>
</div>
{/if}
{/snippet}
{#snippet exports()}
<button type="button" on:click={() => exportVersion('skill')}
><Download className="size-3.5" />{$i18n.t('Export .skill')}</button
>
<button type="button" on:click={() => exportVersion('json')}
><Download className="size-3.5" />{$i18n.t('Export JSON')}</button
>
<button type="button" on:click={() => exportVersion('zip')}
><Download className="size-3.5" />{$i18n.t('Export ZIP')}</button
>
{/snippet}
</SkillFiles>{/if}
</div>
{/if}
</form>
</div>
{#if historical}
<div
class="flex w-full shrink-0 flex-wrap items-center gap-2 px-1 py-2 text-xs text-gray-500"
>
<span class="mr-auto">{$i18n.t('Read Only')}</span>
<div class="ml-auto flex flex-wrap justify-end gap-2">
<button
type="button"
class="flex h-7 items-center rounded-lg bg-gray-100 px-2.5 text-xs text-gray-700 disabled:opacity-60 dark:bg-gray-850 dark:text-gray-200"
disabled={comparing || !!historyDiff}
on:click={compareToCurrent}
>{#if comparing}<Spinner className="mr-1.5 size-3" />{/if}{$i18n.t(
'Compare to current'
)}</button
>
{#if !disabled}<button
type="button"
class="flex h-7 items-center rounded-lg bg-gray-900 px-2.5 text-xs text-white transition hover:bg-black dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
on:click={setProductionVersion}>{$i18n.t('Set as Production')}</button
>{/if}
</div>
</div>
{/if}
{#if !readOnly}
<div class="flex shrink-0 justify-end gap-3 py-2">
<input
class="min-w-0 flex-1 bg-transparent px-2 text-xs outline-hidden"
placeholder={$i18n.t('Describe this change')}
aria-label={$i18n.t('Commit message')}
bind:value={commitMessage}
/>
<button
class="flex h-7 items-center gap-1.5 rounded-lg bg-gray-900 px-2.5 text-xs text-white transition hover:bg-black disabled:opacity-60 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
type="submit"
disabled={loading || !fileEditor}
>
{edit ? $i18n.t('Save') : $i18n.t('Save & Create')}
{#if loading}
<Spinner className="size-3" />
{/if}
</button>
</div>
{/if}
</form>
</div>
</Comments>

View file

@ -71,7 +71,8 @@
<div class="flex items-center">{$i18n.t('Edit')}</div>
</button>
<CommentButton {onComment}
<CommentButton
{onComment}
modal
menu
resource={{ resource_type: 'skill', resource_id: resourceId }}

View file

@ -360,456 +360,464 @@
/>
<Comments modal bind:view={comments} resourceType="tool">
{#if loaded}
<input
id="documents-import-input"
bind:this={toolsImportInputElement}
bind:files={importFiles}
type="file"
accept=".json"
hidden
on:change={() => {
console.log(importFiles);
showConfirm = true;
}}
/>
{#if loaded}
<input
id="documents-import-input"
bind:this={toolsImportInputElement}
bind:files={importFiles}
type="file"
accept=".json"
hidden
on:change={() => {
console.log(importFiles);
showConfirm = true;
}}
/>
<div class="space-y-0">
<div class="flex h-8 w-full items-center gap-2">
<div class="flex min-w-0 flex-1">
<div class=" self-center ml-1 mr-3">
<Search className="size-3.5" />
</div>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
on:input={handleSearchInput}
aria-label={$i18n.t('Search Tools')}
placeholder={$i18n.t('Search Tools')}
/>
{#if query}
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="py-0.5 rounded-full transition hover:text-gray-900 dark:hover:text-gray-100 text-gray-500 dark:text-gray-400 px-0"
aria-label={$i18n.t('Clear search')}
on:click={() => {
query = '';
handleSearchInput();
}}
>
<XMark className="size-3" strokeWidth="2" />
</button>
<div class="space-y-0">
<div class="flex h-8 w-full items-center gap-2">
<div class="flex min-w-0 flex-1">
<div class=" self-center ml-1 mr-3">
<Search className="size-3.5" />
</div>
{/if}
</div>
<div
class="flex max-w-[55%] shrink-0 overflow-x-auto scrollbar-none"
bind:this={tagsContainerElement}
on:wheel={(e) => {
if (e.deltaY !== 0) {
e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY;
}
}}
>
<div
class="flex w-fit gap-0.5 text-center text-sm rounded-full bg-transparent whitespace-nowrap"
>
<ViewSelector
bind:value={viewOption}
align="end"
onChange={async (value) => {
localStorage.workspaceViewOption = value;
await tick();
}}
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
on:input={handleSearchInput}
aria-label={$i18n.t('Search Tools')}
placeholder={$i18n.t('Search Tools')}
/>
{#if query}
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="py-0.5 rounded-full transition hover:text-gray-900 dark:hover:text-gray-100 text-gray-500 dark:text-gray-400 px-0"
aria-label={$i18n.t('Clear search')}
on:click={() => {
query = '';
handleSearchInput();
}}
>
<XMark className="size-3" strokeWidth="2" />
</button>
</div>
{/if}
</div>
<div
class="flex max-w-[55%] shrink-0 overflow-x-auto scrollbar-none"
bind:this={tagsContainerElement}
on:wheel={(e) => {
if (e.deltaY !== 0) {
e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY;
}
}}
>
<div
class="flex w-fit gap-0.5 text-center text-sm rounded-full bg-transparent whitespace-nowrap"
>
<ViewSelector
bind:value={viewOption}
align="end"
onChange={async (value) => {
localStorage.workspaceViewOption = value;
await tick();
}}
/>
</div>
</div>
</div>
</div>
{#if (filteredItems ?? []).length !== 0}
<div class="@container">
<div
class="grid grid-cols-[minmax(0,1fr)_9rem_8rem_9.5rem] items-center gap-x-4 px-2 @max-[48rem]:grid-cols-[minmax(0,1fr)_7rem_9.5rem] @max-[48rem]:gap-x-3 @max-[36rem]:grid-cols-[minmax(0,1fr)_9.5rem] @max-[36rem]:gap-x-2 min-h-6 text-xs text-gray-500 dark:text-gray-400"
>
<button
class="flex min-w-0 flex-1 items-center gap-1 py-0.5 text-left"
type="button"
on:click={() => setSortKey('name')}
{#if (filteredItems ?? []).length !== 0}
<div class="@container">
<div
class="grid grid-cols-[minmax(0,1fr)_9rem_8rem_9.5rem] items-center gap-x-4 px-2 @max-[48rem]:grid-cols-[minmax(0,1fr)_7rem_9.5rem] @max-[48rem]:gap-x-3 @max-[36rem]:grid-cols-[minmax(0,1fr)_9.5rem] @max-[36rem]:gap-x-2 min-h-6 text-xs text-gray-500 dark:text-gray-400"
>
{$i18n.t('Name')}
{#if sortKey === 'name'}
{#if sortDirection === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
{/if}
</button>
<div class="@max-[48rem]:hidden">{$i18n.t('Owner')}</div>
<button
class="@max-[36rem]:hidden flex min-w-0 items-center gap-1 py-0.5 text-left"
type="button"
on:click={() => setSortKey('updated_at')}
>
{$i18n.t('Updated at')}
{#if sortKey === 'updated_at'}
{#if sortDirection === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
{/if}
</button>
<span class="sr-only">{$i18n.t('Actions')}</span>
</div>
<div class="grid gap-y-0.5">
{#each filteredItems as tool}
<div
class="grid grid-cols-[minmax(0,1fr)_9rem_8rem_9.5rem] items-center gap-x-4 px-2 @max-[48rem]:grid-cols-[minmax(0,1fr)_7rem_9.5rem] @max-[48rem]:gap-x-3 @max-[36rem]:grid-cols-[minmax(0,1fr)_9.5rem] @max-[36rem]:gap-x-2 min-h-12 rounded-lg py-1.5 hover:bg-black/[0.03] dark:hover:bg-white/[0.03] group text-left {tool.write_access
? 'cursor-pointer'
: ''}"
role="presentation"
on:click={(e) => {
if (!tool.write_access || shouldIgnoreRowClick(e.target)) return;
openTool(tool);
}}
<button
class="flex min-w-0 flex-1 items-center gap-1 py-0.5 text-left"
type="button"
on:click={() => setSortKey('name')}
>
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
<div class="flex min-w-0 items-center gap-2 overflow-hidden">
<div class="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<Tooltip content={tool.id} className="min-w-0" placement="top-start">
{#if tool.write_access}
<a
href={`/workspace/tools/edit?id=${encodeURIComponent(tool.id)}`}
class="focus-ring block truncate rounded text-[0.8125rem] leading-5 text-gray-800 group-hover:underline focus-visible:underline dark:text-gray-200"
>{resolveLocalizedResource(tool, $i18n.language)}</a
>
{:else}
<span
class="block truncate text-[0.8125rem] leading-5 text-gray-800 dark:text-gray-200"
>{resolveLocalizedResource(tool, $i18n.language)}</span
>
{/if}
</Tooltip>
{#if !tool.write_access}
<Badge type="subtle" content={$i18n.t('Read Only')} />
{/if}
{$i18n.t('Name')}
{#if sortKey === 'name'}
{#if sortDirection === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
{/if}
</button>
{#if tool?.meta?.manifest?.version}
<div
class="min-w-0 max-w-[40%] shrink-0 truncate text-[0.6875rem] leading-5 text-gray-500"
<div class="@max-[48rem]:hidden">{$i18n.t('Owner')}</div>
<button
class="@max-[36rem]:hidden flex min-w-0 items-center gap-1 py-0.5 text-left"
type="button"
on:click={() => setSortKey('updated_at')}
>
{$i18n.t('Updated at')}
{#if sortKey === 'updated_at'}
{#if sortDirection === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
{/if}
</button>
<span class="sr-only">{$i18n.t('Actions')}</span>
</div>
<div class="grid gap-y-0.5">
{#each filteredItems as tool}
<div
class="grid grid-cols-[minmax(0,1fr)_9rem_8rem_9.5rem] items-center gap-x-4 px-2 @max-[48rem]:grid-cols-[minmax(0,1fr)_7rem_9.5rem] @max-[48rem]:gap-x-3 @max-[36rem]:grid-cols-[minmax(0,1fr)_9.5rem] @max-[36rem]:gap-x-2 min-h-12 rounded-lg py-1.5 hover:bg-black/[0.03] dark:hover:bg-white/[0.03] group text-left {tool.write_access
? 'cursor-pointer'
: ''}"
role="presentation"
on:click={(e) => {
if (!tool.write_access || shouldIgnoreRowClick(e.target)) return;
openTool(tool);
}}
>
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
<div class="flex min-w-0 items-center gap-2 overflow-hidden">
<div class="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<Tooltip content={tool.id} className="min-w-0" placement="top-start">
{#if tool.write_access}
<a
href={`/workspace/tools/edit?id=${encodeURIComponent(tool.id)}`}
class="focus-ring block truncate rounded text-[0.8125rem] leading-5 text-gray-800 group-hover:underline focus-visible:underline dark:text-gray-200"
>{resolveLocalizedResource(tool, $i18n.language)}</a
>
{:else}
<span
class="block truncate text-[0.8125rem] leading-5 text-gray-800 dark:text-gray-200"
>{resolveLocalizedResource(tool, $i18n.language)}</span
>
{/if}
</Tooltip>
{#if !tool.write_access}
<Badge type="subtle" content={$i18n.t('Read Only')} />
{/if}
{#if tool?.meta?.manifest?.version}
<div
class="min-w-0 max-w-[40%] shrink-0 truncate text-[0.6875rem] leading-5 text-gray-500"
>
v{tool?.meta?.manifest?.version ?? ''}
</div>
{/if}
</div>
</div>
<div class="mt-0.5 min-w-0">
{#if resolveLocalizedResource(tool, $i18n.language, 'description')}
<Tooltip
content={resolveLocalizedResource(tool, $i18n.language, 'description')}
className="min-w-0"
placement="top-start"
>
v{tool?.meta?.manifest?.version ?? ''}
</div>
<div class="truncate text-xs leading-4 text-gray-500 dark:text-gray-400">
{resolveLocalizedResource(tool, $i18n.language, 'description')}
</div>
</Tooltip>
{/if}
</div>
</div>
<div class="mt-0.5 min-w-0">
{#if resolveLocalizedResource(tool, $i18n.language, 'description')}
<Tooltip
content={resolveLocalizedResource(tool, $i18n.language, 'description')}
className="min-w-0"
placement="top-start"
>
<div class="truncate text-xs leading-4 text-gray-500 dark:text-gray-400">
{resolveLocalizedResource(tool, $i18n.language, 'description')}
</div>
</Tooltip>
{/if}
</div>
</div>
</div>
<div
class="@max-[48rem]:hidden min-w-0 text-xs text-gray-600 dark:text-gray-400"
>
<ProfilePreview user={tool?.user ?? null} side="bottom" className="w-full min-w-0">
<span class="flex w-full min-w-0 items-center gap-2">
<ProfileImage
src={tool?.user?.id
? `/api/v1/users/${tool.user.id}/profile/image`
: '/user.png'}
className="size-5 shrink-0 rounded-full!"
/>
<span class="min-w-0 truncate">
{capitalizeFirstLetter(
tool?.user?.name ?? tool?.user?.email ?? $i18n.t('Deleted User')
)}
<div class="@max-[48rem]:hidden min-w-0 text-xs text-gray-600 dark:text-gray-400">
<ProfilePreview
user={tool?.user ?? null}
side="bottom"
className="w-full min-w-0"
>
<span class="flex w-full min-w-0 items-center gap-2">
<ProfileImage
src={tool?.user?.id
? `/api/v1/users/${tool.user.id}/profile/image`
: '/user.png'}
className="size-5 shrink-0 rounded-full!"
/>
<span class="min-w-0 truncate">
{capitalizeFirstLetter(
tool?.user?.name ?? tool?.user?.email ?? $i18n.t('Deleted User')
)}
</span>
</span>
</span>
</ProfilePreview>
</div>
<Tooltip
content={dayjs(tool.updated_at * 1000)
.locale($i18n.language)
.format('LLLL')}
className="@max-[36rem]:hidden min-w-0"
>
<div class="shrink-0 truncate text-xs leading-5 text-gray-500 dark:text-gray-400">
{dayjs(tool.updated_at * 1000)
.locale($i18n.language)
.fromNow()}
</ProfilePreview>
</div>
</Tooltip>
<div class="flex min-w-0 items-center justify-end gap-1.5">
{#if tool.write_access}
<div class="flex shrink-0 flex-row items-center gap-1.5 self-center">
{#if shiftKey}
<Tooltip content={$i18n.t('Delete')}>
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
type="button"
aria-label={$i18n.t('Delete')}
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
deleteHandler(tool);
}}
>
<GarbageBin className="size-4" />
</button>
</Tooltip>
{:else}
{#if tool?.meta?.manifest?.funding_url ?? false}
<Tooltip content="Support">
<Tooltip
content={dayjs(tool.updated_at * 1000)
.locale($i18n.language)
.format('LLLL')}
className="@max-[36rem]:hidden min-w-0"
>
<div class="shrink-0 truncate text-xs leading-5 text-gray-500 dark:text-gray-400">
{dayjs(tool.updated_at * 1000)
.locale($i18n.language)
.fromNow()}
</div>
</Tooltip>
<div class="flex min-w-0 items-center justify-end gap-1.5">
{#if tool.write_access}
<div class="flex shrink-0 flex-row items-center gap-1.5 self-center">
{#if shiftKey}
<Tooltip content={$i18n.t('Delete')}>
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
type="button"
aria-label={$i18n.t('Support')}
aria-label={$i18n.t('Delete')}
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
deleteHandler(tool);
}}
>
<GarbageBin className="size-4" />
</button>
</Tooltip>
{:else}
{#if tool?.meta?.manifest?.funding_url ?? false}
<Tooltip content="Support">
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
type="button"
aria-label={$i18n.t('Support')}
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
selectedTool = tool;
showManifestModal = true;
}}
>
<Heart className="size-4" />
</button>
</Tooltip>
{/if}
<Tooltip content={$i18n.t('Valves')}>
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
type="button"
aria-label={$i18n.t('Valves')}
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
selectedTool = tool;
showManifestModal = true;
showValvesModal = true;
}}
>
<Heart className="size-4" />
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
</button>
</Tooltip>
{/if}
<Tooltip content={$i18n.t('Valves')}>
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
type="button"
aria-label={$i18n.t('Valves')}
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
selectedTool = tool;
showValvesModal = true;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
</button>
</Tooltip>
<CommentButton onComment={(request) => (comments = request)}
modal
className="flex size-6 shrink-0 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
resource={{ resource_type: 'tool', resource_id: tool.id }}
/>
<ToolMenu onComment={(request) => (comments = request)}
resourceId={tool.id}
accessHandler={$user?.permissions?.sharing?.tools || $user?.role === 'admin'
? () => accessModal.open(tool.id)
: null}
show={openToolMenuId === tool.id}
editHandler={() => {
goto(`/workspace/tools/edit?id=${encodeURIComponent(tool.id)}`);
}}
shareHandler={() => {
shareHandler(tool);
}}
cloneHandler={() => {
cloneHandler(tool);
}}
exportHandler={() => {
exportHandler(tool);
}}
deleteHandler={async () => {
selectedTool = tool;
showDeleteConfirm = true;
}}
onClose={() => {
openToolMenuId = null;
}}
>
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
type="button"
aria-label={$i18n.t('Tool Menu')}
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
openToolMenuId = openToolMenuId === tool.id ? null : tool.id;
}}
>
<EllipsisHorizontal className="size-4" />
</button>
</ToolMenu>
<Tooltip
content={updatingTools[tool.id]
? $i18n.t('Saving...')
: tool.is_active
? $i18n.t('Enabled')
: $i18n.t('Disabled')}
>
<Switch
bind:state={tool.is_active}
disabled={updatingTools[tool.id] ?? false}
ariaLabel={`${$i18n.t('Enabled')}: ${resolveLocalizedResource(tool, $i18n.language)}`}
on:change={() => toggleToolHandler(tool)}
<CommentButton
onComment={(request) => (comments = request)}
modal
className="flex size-6 shrink-0 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
resource={{ resource_type: 'tool', resource_id: tool.id }}
/>
</Tooltip>
{/if}
</div>
{/if}
{#if !tool.write_access}
<CommentButton onComment={(request) => (comments = request)}
modal
className="flex size-6 shrink-0 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
resource={{ resource_type: 'tool', resource_id: tool.id }}
/>
<span class="size-6 shrink-0" aria-hidden="true"></span>
<div
class="flex h-6 items-center"
role="presentation"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<Switch
state={tool.is_active}
disabled
tooltip={$i18n.t('Read Only')}
ariaLabel={`${$i18n.t('Enabled')}: ${resolveLocalizedResource(tool, $i18n.language)}`}
<ToolMenu
onComment={(request) => (comments = request)}
resourceId={tool.id}
accessHandler={$user?.permissions?.sharing?.tools ||
$user?.role === 'admin'
? () => accessModal.open(tool.id)
: null}
show={openToolMenuId === tool.id}
editHandler={() => {
goto(`/workspace/tools/edit?id=${encodeURIComponent(tool.id)}`);
}}
shareHandler={() => {
shareHandler(tool);
}}
cloneHandler={() => {
cloneHandler(tool);
}}
exportHandler={() => {
exportHandler(tool);
}}
deleteHandler={async () => {
selectedTool = tool;
showDeleteConfirm = true;
}}
onClose={() => {
openToolMenuId = null;
}}
>
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
type="button"
aria-label={$i18n.t('Tool Menu')}
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
openToolMenuId = openToolMenuId === tool.id ? null : tool.id;
}}
>
<EllipsisHorizontal className="size-4" />
</button>
</ToolMenu>
<Tooltip
content={updatingTools[tool.id]
? $i18n.t('Saving...')
: tool.is_active
? $i18n.t('Enabled')
: $i18n.t('Disabled')}
>
<Switch
bind:state={tool.is_active}
disabled={updatingTools[tool.id] ?? false}
ariaLabel={`${$i18n.t('Enabled')}: ${resolveLocalizedResource(tool, $i18n.language)}`}
on:change={() => toggleToolHandler(tool)}
/>
</Tooltip>
{/if}
</div>
{/if}
{#if !tool.write_access}
<CommentButton
onComment={(request) => (comments = request)}
modal
className="flex size-6 shrink-0 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
resource={{ resource_type: 'tool', resource_id: tool.id }}
/>
</div>
{/if}
<span class="size-6 shrink-0" aria-hidden="true"></span>
<div
class="flex h-6 items-center"
role="presentation"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<Switch
state={tool.is_active}
disabled
tooltip={$i18n.t('Read Only')}
ariaLabel={`${$i18n.t('Enabled')}: ${resolveLocalizedResource(tool, $i18n.language)}`}
/>
</div>
{/if}
</div>
</div>
{/each}
</div>
</div>
{:else}
<div class="flex w-full flex-col items-center justify-center py-16 pb-24">
<div class="max-w-sm text-center text-gray-900 dark:text-gray-100">
<div class="mb-1.5 text-sm">
{hasFilters ? $i18n.t('No tools found') : $i18n.t('No tools yet')}
</div>
<div class="text-center text-xs leading-5 text-gray-500">
{hasFilters
? $i18n.t('Try adjusting your search or filter to find what you are looking for.')
: $i18n.t('Items you create or that are shared with you will appear here.')}
</div>
{/each}
</div>
</div>
{:else}
<div class="flex w-full flex-col items-center justify-center py-16 pb-24">
<div class="max-w-sm text-center text-gray-900 dark:text-gray-100">
<div class="mb-1.5 text-sm">
{hasFilters ? $i18n.t('No tools found') : $i18n.t('No tools yet')}
</div>
<div class="text-center text-xs leading-5 text-gray-500">
{hasFilters
? $i18n.t('Try adjusting your search or filter to find what you are looking for.')
: $i18n.t('Items you create or that are shared with you will appear here.')}
</div>
</div>
</div>
{/if}
</div>
{#if $config?.features.enable_community_sharing}
<CommunityDiscover
href="https://openwebui.com/search?type=tool"
title={$i18n.t('Discover a tool')}
description={$i18n.t('Discover, download, and explore custom tools')}
/>
{/if}
</div>
{#if $config?.features.enable_community_sharing}
<CommunityDiscover
href="https://openwebui.com/search?type=tool"
title={$i18n.t('Discover a tool')}
description={$i18n.t('Discover, download, and explore custom tools')}
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete tool?')}
on:confirm={() => {
deleteHandler(selectedTool);
}}
>
<div class=" text-sm text-gray-500 truncate">
{$i18n.t('This will delete')}
<span class=" font-normal">{resolveLocalizedResource(selectedTool, $i18n.language)}</span>.
</div>
</DeleteConfirmDialog>
<ValvesModal
bind:show={showValvesModal}
type="tool"
id={selectedTool?.id ?? null}
meta={selectedTool?.meta}
/>
<ManifestModal bind:show={showManifestModal} manifest={selectedTool?.meta?.manifest ?? {}} />
<ConfirmDialog
bind:show={showConfirm}
on:confirm={() => {
const reader = new FileReader();
reader.onload = async (event) => {
const _tools = JSON.parse(event.target.result);
console.log(_tools);
for (const tool of _tools) {
const res = await createNewTool(localStorage.token, tool).catch((error) => {
toast.error(`${error}`);
return null;
});
}
toast.success($i18n.t('Tool imported successfully'));
await init();
importFiles = null;
toolsImportInputElement.value = '';
};
reader.readAsText(importFiles[0]);
}}
>
<div class="text-sm text-gray-500">
<div class=" bg-yellow-500/20 text-yellow-700 dark:text-yellow-200 rounded-lg px-4 py-3">
<div>{$i18n.t('Please carefully review the following warnings:')}</div>
<ul class=" mt-1 list-disc pl-4 text-xs">
<li>
{$i18n.t(
'Tools have a function calling system that allows arbitrary code execution.'
)}.
</li>
<li>{$i18n.t('Do not install tools from sources you do not fully trust.')}</li>
</ul>
</div>
<div class="my-3">
{$i18n.t(
'I acknowledge that I have read and I understand the implications of my action. I am aware of the risks associated with executing arbitrary code and I have verified the trustworthiness of the source.'
)}
</div>
</div>
</ConfirmDialog>
{:else}
<div class="w-full h-full flex justify-center items-center">
<Spinner className="size-5" />
</div>
{/if}
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete tool?')}
on:confirm={() => {
deleteHandler(selectedTool);
}}
>
<div class=" text-sm text-gray-500 truncate">
{$i18n.t('This will delete')}
<span class=" font-normal">{resolveLocalizedResource(selectedTool, $i18n.language)}</span>.
</div>
</DeleteConfirmDialog>
<ValvesModal
bind:show={showValvesModal}
type="tool"
id={selectedTool?.id ?? null}
meta={selectedTool?.meta}
/>
<ManifestModal bind:show={showManifestModal} manifest={selectedTool?.meta?.manifest ?? {}} />
<ConfirmDialog
bind:show={showConfirm}
on:confirm={() => {
const reader = new FileReader();
reader.onload = async (event) => {
const _tools = JSON.parse(event.target.result);
console.log(_tools);
for (const tool of _tools) {
const res = await createNewTool(localStorage.token, tool).catch((error) => {
toast.error(`${error}`);
return null;
});
}
toast.success($i18n.t('Tool imported successfully'));
await init();
importFiles = null;
toolsImportInputElement.value = '';
};
reader.readAsText(importFiles[0]);
}}
>
<div class="text-sm text-gray-500">
<div class=" bg-yellow-500/20 text-yellow-700 dark:text-yellow-200 rounded-lg px-4 py-3">
<div>{$i18n.t('Please carefully review the following warnings:')}</div>
<ul class=" mt-1 list-disc pl-4 text-xs">
<li>
{$i18n.t('Tools have a function calling system that allows arbitrary code execution.')}.
</li>
<li>{$i18n.t('Do not install tools from sources you do not fully trust.')}</li>
</ul>
</div>
<div class="my-3">
{$i18n.t(
'I acknowledge that I have read and I understand the implications of my action. I am aware of the risks associated with executing arbitrary code and I have verified the trustworthiness of the source.'
)}
</div>
</div>
</ConfirmDialog>
{:else}
<div class="w-full h-full flex justify-center items-center">
<Spinner className="size-5" />
</div>
{/if}
</Comments>

View file

@ -73,7 +73,8 @@
<div class="flex items-center">{$i18n.t('Edit')}</div>
</button>
<CommentButton {onComment}
<CommentButton
{onComment}
modal
menu
resource={{ resource_type: 'tool', resource_id: resourceId }}

View file

@ -316,288 +316,291 @@ class Tools:
/>
<Comments bind:view={comments} resourceType="tool">
<div class="flex h-full w-full min-w-0 flex-col overflow-hidden">
<form
bind:this={formElement}
class="flex h-full min-h-0 min-w-0 flex-col"
on:submit|preventDefault={() => {
if (selected) return;
if (edit) {
submitHandler();
} else {
showConfirm = true;
}
}}
>
<div class="flex shrink-0 items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-3">
<button
class="flex h-6 w-fit shrink-0 items-center gap-1 whitespace-nowrap rounded-md text-xs text-gray-400 transition-colors duration-75 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
type="button"
on:click={() => {
goto('/workspace/tools');
}}
>
<ChevronLeft className="size-3" strokeWidth="2" />
<span>{$i18n.t('Back')}</span>
</button>
{#if edit && version_id}
<ToolHistory
bind:this={historyControl}
resource={{ id, version_id }}
bind:selected
bind:selecting
bind:promoting
dirty={changed || !!commitMessage}
disabled={loading}
onProduction={productionHandler}
/>
{/if}
</div>
<div class="flex shrink-0 items-center gap-1 pr-0.5">
{#if edit && id}<CommentButton onComment={(request) => (comments = request)} wrapperClassName="me-2" resource={{ resource_type: 'tool', resource_id: id }} />{/if}
<LanguageModeSelect
bind:value={locale}
translatedLocales={Object.keys(
pruneEmptyLocaleEntries(selected ? selected.snapshot.meta.i18n : meta.i18n)
)}
/>
{#if $user?.permissions?.sharing?.tools || $user?.role === 'admin'}
<AccessButton
disabled={!!selected || loading || selecting || promoting}
<div class="flex h-full w-full min-w-0 flex-col overflow-hidden">
<form
bind:this={formElement}
class="flex h-full min-h-0 min-w-0 flex-col"
on:submit|preventDefault={() => {
if (selected) return;
if (edit) {
submitHandler();
} else {
showConfirm = true;
}
}}
>
<div class="flex shrink-0 items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-3">
<button
class="flex h-6 w-fit shrink-0 items-center gap-1 whitespace-nowrap rounded-md text-xs text-gray-400 transition-colors duration-75 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
type="button"
on:click={() => {
savedAccessGrants = structuredClone(accessGrants);
showAccessControlModal = true;
goto('/workspace/tools');
}}
/>
{/if}
</div>
</div>
<div class="flex shrink-0 flex-col gap-2 pb-2 px-1 sm:flex-row sm:items-start">
<div class="min-w-0 w-full flex-1">
<Tooltip content={$i18n.t('e.g. My Tools')} placement="top-start">
{#if selected}
<LocalizedField
disabled
placeholder={$i18n.t('Tool Name')}
showControls={false}
value={selected.snapshot.name}
translations={selected.snapshot.meta.i18n}
{locale}
required
/>
{:else}
<LocalizedField
placeholder={$i18n.t('Tool Name')}
showControls={false}
bind:value={name}
bind:translations={meta.i18n}
{locale}
required
/>
{/if}
</Tooltip>
<div class="mt-0.5 flex min-w-0 items-center gap-2 text-xs text-gray-500">
{#if edit}
<div class="shrink-0 truncate font-mono" title={id}>
{id}
</div>
{:else}
<Tooltip
className="min-w-[8rem] flex-1"
content={$i18n.t('e.g. my_tools')}
placement="top-start"
>
<input
class="w-full bg-transparent font-mono outline-hidden disabled:text-gray-500"
type="text"
placeholder={$i18n.t('Tool ID')}
aria-label={$i18n.t('Tool ID')}
bind:value={id}
required
disabled={edit}
/>
</Tooltip>
{/if}
<Tooltip
className="flex min-w-0 flex-1 items-center"
content={$i18n.t('e.g. Tools for performing various operations')}
placement="top-start"
>
<ChevronLeft className="size-3" strokeWidth="2" />
<span>{$i18n.t('Back')}</span>
</button>
{#if edit && version_id}
<ToolHistory
bind:this={historyControl}
resource={{ id, version_id }}
bind:selected
bind:selecting
bind:promoting
dirty={changed || !!commitMessage}
disabled={loading}
onProduction={productionHandler}
/>
{/if}
</div>
<div class="flex shrink-0 items-center gap-1 pr-0.5">
{#if edit && id}<CommentButton
onComment={(request) => (comments = request)}
wrapperClassName="me-2"
resource={{ resource_type: 'tool', resource_id: id }}
/>{/if}
<LanguageModeSelect
bind:value={locale}
translatedLocales={Object.keys(
pruneEmptyLocaleEntries(selected ? selected.snapshot.meta.i18n : meta.i18n)
)}
/>
{#if $user?.permissions?.sharing?.tools || $user?.role === 'admin'}
<AccessButton
disabled={!!selected || loading || selecting || promoting}
on:click={() => {
savedAccessGrants = structuredClone(accessGrants);
showAccessControlModal = true;
}}
/>
{/if}
</div>
</div>
<div class="flex shrink-0 flex-col gap-2 pb-2 px-1 sm:flex-row sm:items-start">
<div class="min-w-0 w-full flex-1">
<Tooltip content={$i18n.t('e.g. My Tools')} placement="top-start">
{#if selected}
<LocalizedField
disabled
placeholder={$i18n.t('Tool Description')}
placeholder={$i18n.t('Tool Name')}
showControls={false}
value={selected.snapshot.meta.description}
value={selected.snapshot.name}
translations={selected.snapshot.meta.i18n}
{locale}
field="description"
required
/>
{:else}
<LocalizedField
placeholder={$i18n.t('Tool Description')}
placeholder={$i18n.t('Tool Name')}
showControls={false}
bind:value={meta.description}
bind:value={name}
bind:translations={meta.i18n}
{locale}
field="description"
required
/>
{/if}
</Tooltip>
<div class="mt-0.5 flex min-w-0 items-center gap-2 text-xs text-gray-500">
{#if edit}
<div class="shrink-0 truncate font-mono" title={id}>
{id}
</div>
{:else}
<Tooltip
className="min-w-[8rem] flex-1"
content={$i18n.t('e.g. my_tools')}
placement="top-start"
>
<input
class="w-full bg-transparent font-mono outline-hidden disabled:text-gray-500"
type="text"
placeholder={$i18n.t('Tool ID')}
aria-label={$i18n.t('Tool ID')}
bind:value={id}
required
disabled={edit}
/>
</Tooltip>
{/if}
<Tooltip
className="flex min-w-0 flex-1 items-center"
content={$i18n.t('e.g. Tools for performing various operations')}
placement="top-start"
>
{#if selected}
<LocalizedField
disabled
placeholder={$i18n.t('Tool Description')}
showControls={false}
value={selected.snapshot.meta.description}
translations={selected.snapshot.meta.i18n}
{locale}
field="description"
required
/>
{:else}
<LocalizedField
placeholder={$i18n.t('Tool Description')}
showControls={false}
bind:value={meta.description}
bind:translations={meta.i18n}
{locale}
field="description"
required
/>
{/if}
</Tooltip>
</div>
</div>
</div>
</div>
<div class="min-h-0 flex-1 overflow-hidden rounded-lg flex flex-col">
{#if locale && !comparison}
{#key selected?.id}
{#if selected}
<PluginTranslations
{id}
kind="tool"
{locale}
readOnly
translations={selected.snapshot.meta.i18n}
/>
{:else}
<PluginTranslations
id={edit ? id : ''}
kind="tool"
{locale}
bind:translations={meta.i18n}
/>
{/if}
{/key}
{/if}
<div class="min-h-0 flex-1 overflow-hidden rounded-lg flex flex-col">
{#if locale && !comparison}
{#key selected?.id}
{#if selected}
<PluginTranslations
{id}
kind="tool"
{locale}
readOnly
translations={selected.snapshot.meta.i18n}
/>
{:else}
<PluginTranslations
id={edit ? id : ''}
kind="tool"
{locale}
bind:translations={meta.i18n}
/>
{/if}
{/key}
{/if}
{#if comparison}
<VersionDiff
currentLabel={$i18n.t('Production')}
showFileHeaders={false}
diff={{
fromId: comparison.from_id,
toId: comparison.to_id,
metadata: comparison.metadata,
files:
comparison.content_diff || comparison.line_endings_only
? [{ path: `${id}.py`, status: 'modified', binary: false }]
: []
}}
loadFileDiff={async () => ({
diff: comparison.content_diff,
line_endings_only: comparison.line_endings_only
})}
onClose={() => {
comparison = null;
}}
/>
{/if}
{#if selected && !locale && !comparison}
<CodeEditor
value={selected.snapshot.content}
lang="python"
readOnly
ariaLabel={$i18n.t('Code Editor')}
className="text-[0.6875rem]"
/>
{/if}
<div class={locale || comparison || selected ? 'hidden' : 'h-full min-h-0'}>
<CodeEditor
bind:this={codeEditor}
value={content}
readOnly={!!selected || selecting || promoting}
lang="python"
ariaLabel={$i18n.t('Code Editor')}
{boilerplate}
className="text-[0.6875rem]"
onChange={(e: string) => {
if (selected) return;
content = e;
_content = e;
if (!edit) {
const fm = extractFrontmatter(e) as Record<string, string>;
if (fm.title && !name) {
name = formatSkillName(fm.title);
id = nameToId(fm.title);
{#if comparison}
<VersionDiff
currentLabel={$i18n.t('Production')}
showFileHeaders={false}
diff={{
fromId: comparison.from_id,
toId: comparison.to_id,
metadata: comparison.metadata,
files:
comparison.content_diff || comparison.line_endings_only
? [{ path: `${id}.py`, status: 'modified', binary: false }]
: []
}}
loadFileDiff={async () => ({
diff: comparison.content_diff,
line_endings_only: comparison.line_endings_only
})}
onClose={() => {
comparison = null;
}}
/>
{/if}
{#if selected && !locale && !comparison}
<CodeEditor
value={selected.snapshot.content}
lang="python"
readOnly
ariaLabel={$i18n.t('Code Editor')}
className="text-[0.6875rem]"
/>
{/if}
<div class={locale || comparison || selected ? 'hidden' : 'h-full min-h-0'}>
<CodeEditor
bind:this={codeEditor}
value={content}
readOnly={!!selected || selecting || promoting}
lang="python"
ariaLabel={$i18n.t('Code Editor')}
{boilerplate}
className="text-[0.6875rem]"
onChange={(e: string) => {
if (selected) return;
content = e;
_content = e;
if (!edit) {
const fm = extractFrontmatter(e) as Record<string, string>;
if (fm.title && !name) {
name = formatSkillName(fm.title);
id = nameToId(fm.title);
}
if (fm.description && !meta.description) {
meta = { ...meta, description: fm.description };
}
}
if (fm.description && !meta.description) {
meta = { ...meta, description: fm.description };
}}
onSave={async () => {
if (formElement) {
formElement.requestSubmit();
}
}
}}
onSave={async () => {
if (formElement) {
formElement.requestSubmit();
}
}}
/>
}}
/>
</div>
</div>
</div>
<div class="shrink-0 py-2 text-xs text-gray-500">
<div class="flex items-center justify-between gap-3">
<Tooltip
className="min-w-0 flex-1"
content={warning}
allowHTML={false}
placement="top-start"
>
<div class="truncate">{warning}</div>
</Tooltip>
<div class="shrink-0 py-2 text-xs text-gray-500">
<div class="flex items-center justify-between gap-3">
<Tooltip
className="min-w-0 flex-1"
content={warning}
allowHTML={false}
placement="top-start"
>
<div class="truncate">{warning}</div>
</Tooltip>
<div class="ml-auto flex min-w-0 max-w-full shrink-0 items-center gap-3">
{#if selected}
{#if !comparison}
<div class="ml-auto flex min-w-0 max-w-full shrink-0 items-center gap-3">
{#if selected}
{#if !comparison}
<button
type="button"
class="flex h-7 shrink-0 items-center gap-1.5 text-xs text-gray-500 hover:text-gray-900 dark:hover:text-gray-100"
disabled={comparing || promoting || selecting}
on:click={compareToCurrent}
>
{$i18n.t('Compare to current')}{#if comparing}<Spinner className="size-3" />{/if}
</button>
{/if}
<button
type="button"
class="flex h-7 shrink-0 items-center gap-1.5 text-xs text-gray-500 hover:text-gray-900 dark:hover:text-gray-100"
disabled={comparing || promoting || selecting}
on:click={compareToCurrent}
class="flex h-7 shrink-0 items-center gap-1.5 rounded-lg bg-gray-900 px-2.5 text-xs text-white disabled:opacity-60 dark:bg-gray-100 dark:text-gray-900"
disabled={promoting || selecting}
on:click={() => historyControl?.requestPromotion()}
>
{$i18n.t('Compare to current')}{#if comparing}<Spinner className="size-3" />{/if}
{$i18n.t('Set as Production')}{#if promoting}<Spinner className="size-3" />{/if}
</button>
{:else}
<input
class="w-32 min-w-0 flex-1 border-0 bg-transparent text-xs outline-hidden placeholder:text-gray-400"
aria-label={$i18n.t('Describe this change')}
placeholder={$i18n.t('Describe this change')}
bind:value={commitMessage}
disabled={loading}
/>
<button
class="flex h-7 shrink-0 items-center gap-1.5 rounded-lg bg-gray-900 px-2.5 text-xs text-white transition hover:bg-black disabled:opacity-60 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
type="submit"
disabled={loading || selecting || promoting || (edit && !changed)}
>
{edit ? $i18n.t('Save') : $i18n.t('Save & Create')}
{#if loading}
<Spinner className="size-3" />
{/if}
</button>
{/if}
<button
type="button"
class="flex h-7 shrink-0 items-center gap-1.5 rounded-lg bg-gray-900 px-2.5 text-xs text-white disabled:opacity-60 dark:bg-gray-100 dark:text-gray-900"
disabled={promoting || selecting}
on:click={() => historyControl?.requestPromotion()}
>
{$i18n.t('Set as Production')}{#if promoting}<Spinner className="size-3" />{/if}
</button>
{:else}
<input
class="w-32 min-w-0 flex-1 border-0 bg-transparent text-xs outline-hidden placeholder:text-gray-400"
aria-label={$i18n.t('Describe this change')}
placeholder={$i18n.t('Describe this change')}
bind:value={commitMessage}
disabled={loading}
/>
<button
class="flex h-7 shrink-0 items-center gap-1.5 rounded-lg bg-gray-900 px-2.5 text-xs text-white transition hover:bg-black disabled:opacity-60 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
type="submit"
disabled={loading || selecting || promoting || (edit && !changed)}
>
{edit ? $i18n.t('Save') : $i18n.t('Save & Create')}
{#if loading}
<Spinner className="size-3" />
{/if}
</button>
{/if}
</div>
</div>
</div>
</div>
</form>
</div>
</form>
</div>
</Comments>
<ConfirmDialog

View file

@ -329,7 +329,7 @@
<button
type="button"
aria-label={$i18n.t('Close')}
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
on:click={() => (showParticipants = false)}><XMark className="size-4" /></button
>
</div>