mirror of
https://github.com/open-webui/open-webui.git
synced 2026-11-15 09:31:21 +00:00
chore: format
This commit is contained in:
parent
d14e5c154f
commit
0c64e5d596
69 changed files with 4381 additions and 4272 deletions
26
README.ar.md
26
README.ar.md
|
|
@ -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/) للاطلاع على خطوات الإعداد وخيارات الضبط.
|
||||
|
||||
|
|
|
|||
26
README.bn.md
26
README.bn.md
|
|
@ -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/) দেখুন।
|
||||
|
||||
|
|
|
|||
26
README.br.md
26
README.br.md
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
26
README.bs.md
26
README.bs.md
|
|
@ -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/).
|
||||
|
||||
|
|
|
|||
26
README.da.md
26
README.da.md
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
26
README.de.md
26
README.de.md
|
|
@ -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/).
|
||||
|
||||
|
|
|
|||
26
README.es.md
26
README.es.md
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
26
README.fr.md
26
README.fr.md
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
26
README.gr.md
26
README.gr.md
|
|
@ -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/) για οδηγίες και επιλογές διαμόρφωσης.
|
||||
|
||||
|
|
|
|||
26
README.it.md
26
README.it.md
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
26
README.ja.md
26
README.ja.md
|
|
@ -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/)をご覧ください。
|
||||
|
||||
|
|
|
|||
26
README.ko.md
26
README.ko.md
|
|
@ -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/)를 참고하세요.
|
||||
|
||||
|
|
|
|||
26
README.md
26
README.md
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
26
README.no.md
26
README.no.md
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
26
README.pl.md
26
README.pl.md
|
|
@ -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/).
|
||||
|
||||
|
|
|
|||
26
README.ru.md
26
README.ru.md
|
|
@ -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/).
|
||||
|
||||
|
|
|
|||
26
README.th.md
26
README.th.md
|
|
@ -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/)
|
||||
|
||||
|
|
|
|||
26
README.tr.md
26
README.tr.md
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
26
README.uk.md
26
README.uk.md
|
|
@ -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/).
|
||||
|
||||
|
|
|
|||
26
README.vi.md
26
README.vi.md
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
26
README.zh.md
26
README.zh.md
|
|
@ -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/)。
|
||||
|
||||
|
|
|
|||
|
|
@ -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/)。
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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] = []
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 '
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -162,7 +162,6 @@
|
|||
// if (domainData.some((value) => value > 0)) {
|
||||
// lastSoundTime = Date.now();
|
||||
// }
|
||||
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(processFrame);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -319,7 +319,8 @@
|
|||
{/if}
|
||||
|
||||
{#if !selectionOnly}
|
||||
<ModelItemMenu {onComment}
|
||||
<ModelItemMenu
|
||||
{onComment}
|
||||
bind:show={showMenu}
|
||||
model={item.model}
|
||||
{pinModelHandler}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -145,7 +145,6 @@
|
|||
|
||||
await getVoices();
|
||||
|
||||
|
||||
// const blobUrl = URL.createObjectURL(await rawAudio.toBlob());
|
||||
// const audio = new Audio(blobUrl);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue