1243 lines
54 KiB
Python
1243 lines
54 KiB
Python
"""
|
|
title: OpenWebUI Skills Manager Tool
|
|
author: Fu-Jie
|
|
author_url: https://github.com/Fu-Jie/openwebui-extensions
|
|
funding_url: https://github.com/open-webui
|
|
version: 0.2.0
|
|
openwebui_id: b4bce8e4-08e7-4f90-bea7-dc31d463a0bb
|
|
requirements:
|
|
description: Standalone OpenWebUI tool for managing native Workspace Skills (list/show/install/create/update/delete) for any model.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import re
|
|
import tempfile
|
|
import tarfile
|
|
import uuid
|
|
import zipfile
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from typing import Optional, Dict, Any, List, Tuple
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
try:
|
|
from open_webui.models.skills import Skills, SkillForm, SkillMeta
|
|
except Exception:
|
|
Skills = None
|
|
SkillForm = None
|
|
SkillMeta = None
|
|
|
|
|
|
BASE_TRANSLATIONS = {
|
|
"status_listing": "Listing your skills...",
|
|
"status_showing": "Reading skill details...",
|
|
"status_installing": "Installing skill from URL...",
|
|
"status_creating": "Creating skill...",
|
|
"status_updating": "Updating skill...",
|
|
"status_deleting": "Deleting skill...",
|
|
"status_done": "Done.",
|
|
"status_list_done": "Found {count} skills ({active_count} active).",
|
|
"status_show_done": "Loaded skill: {name}.",
|
|
"status_install_done": "Installed skill: {name}.",
|
|
"status_install_overwrite_done": "Installed by updating existing skill: {name}.",
|
|
"status_create_done": "Created skill: {name}.",
|
|
"status_create_overwrite_done": "Updated existing skill: {name}.",
|
|
"status_update_done": "Updated skill: {name}.",
|
|
"status_delete_done": "Deleted skill: {name}.",
|
|
"err_unavailable": "OpenWebUI Skills model is unavailable in this runtime.",
|
|
"err_user_required": "User context is required.",
|
|
"err_name_required": "Skill name is required.",
|
|
"err_not_found": "Skill not found.",
|
|
"err_no_update_fields": "No update fields provided.",
|
|
"err_url_required": "Skill URL is required.",
|
|
"err_install_fetch": "Failed to fetch skill content from URL.",
|
|
"err_install_parse": "Failed to parse skill package/content.",
|
|
"err_invalid_url": "Invalid URL. Only http(s) URLs are supported.",
|
|
"msg_created": "Skill created successfully.",
|
|
"msg_updated": "Skill updated successfully.",
|
|
"msg_deleted": "Skill deleted successfully.",
|
|
"msg_installed": "Skill installed successfully.",
|
|
}
|
|
|
|
TRANSLATIONS = {
|
|
"en-US": BASE_TRANSLATIONS,
|
|
"zh-CN": {
|
|
"status_listing": "正在列出你的技能...",
|
|
"status_showing": "正在读取技能详情...",
|
|
"status_installing": "正在从 URL 安装技能...",
|
|
"status_creating": "正在创建技能...",
|
|
"status_updating": "正在更新技能...",
|
|
"status_deleting": "正在删除技能...",
|
|
"status_done": "已完成。",
|
|
"status_list_done": "已找到 {count} 个技能(启用 {active_count} 个)。",
|
|
"status_show_done": "已加载技能:{name}。",
|
|
"status_install_done": "技能安装完成:{name}。",
|
|
"status_install_overwrite_done": "已通过覆盖更新完成安装:{name}。",
|
|
"status_create_done": "技能创建完成:{name}。",
|
|
"status_create_overwrite_done": "已更新同名技能:{name}。",
|
|
"status_update_done": "技能更新完成:{name}。",
|
|
"status_delete_done": "技能删除完成:{name}。",
|
|
"err_unavailable": "当前运行环境不可用 OpenWebUI Skills 模型。",
|
|
"err_user_required": "需要用户上下文。",
|
|
"err_name_required": "技能名称不能为空。",
|
|
"err_not_found": "未找到技能。",
|
|
"err_no_update_fields": "未提供可更新字段。",
|
|
"err_url_required": "技能 URL 不能为空。",
|
|
"err_install_fetch": "从 URL 获取技能内容失败。",
|
|
"err_install_parse": "解析技能包或内容失败。",
|
|
"err_invalid_url": "URL 无效,仅支持 http(s) 地址。",
|
|
"msg_created": "技能创建成功。",
|
|
"msg_updated": "技能更新成功。",
|
|
"msg_deleted": "技能删除成功。",
|
|
"msg_installed": "技能安装成功。",
|
|
},
|
|
"zh-TW": {
|
|
"status_listing": "正在列出你的技能...",
|
|
"status_showing": "正在讀取技能詳情...",
|
|
"status_installing": "正在從 URL 安裝技能...",
|
|
"status_creating": "正在建立技能...",
|
|
"status_updating": "正在更新技能...",
|
|
"status_deleting": "正在刪除技能...",
|
|
"status_done": "已完成。",
|
|
"status_list_done": "已找到 {count} 個技能(啟用 {active_count} 個)。",
|
|
"status_show_done": "已載入技能:{name}。",
|
|
"status_install_done": "技能安裝完成:{name}。",
|
|
"status_install_overwrite_done": "已透過覆蓋更新完成安裝:{name}。",
|
|
"status_create_done": "技能建立完成:{name}。",
|
|
"status_create_overwrite_done": "已更新同名技能:{name}。",
|
|
"status_update_done": "技能更新完成:{name}。",
|
|
"status_delete_done": "技能刪除完成:{name}。",
|
|
"err_unavailable": "目前執行環境不可用 OpenWebUI Skills 模型。",
|
|
"err_user_required": "需要使用者上下文。",
|
|
"err_name_required": "技能名稱不能為空。",
|
|
"err_not_found": "未找到技能。",
|
|
"err_no_update_fields": "未提供可更新欄位。",
|
|
"err_url_required": "技能 URL 不能為空。",
|
|
"err_install_fetch": "從 URL 取得技能內容失敗。",
|
|
"err_install_parse": "解析技能包或內容失敗。",
|
|
"err_invalid_url": "URL 無效,僅支援 http(s) 位址。",
|
|
"msg_created": "技能建立成功。",
|
|
"msg_updated": "技能更新成功。",
|
|
"msg_deleted": "技能刪除成功。",
|
|
"msg_installed": "技能安裝成功。",
|
|
},
|
|
"zh-HK": {
|
|
"status_listing": "正在列出你的技能...",
|
|
"status_showing": "正在讀取技能詳情...",
|
|
"status_installing": "正在從 URL 安裝技能...",
|
|
"status_creating": "正在建立技能...",
|
|
"status_updating": "正在更新技能...",
|
|
"status_deleting": "正在刪除技能...",
|
|
"status_done": "已完成。",
|
|
"status_list_done": "已找到 {count} 個技能(啟用 {active_count} 個)。",
|
|
"status_show_done": "已載入技能:{name}。",
|
|
"status_install_done": "技能安裝完成:{name}。",
|
|
"status_install_overwrite_done": "已透過覆蓋更新完成安裝:{name}。",
|
|
"status_create_done": "技能建立完成:{name}。",
|
|
"status_create_overwrite_done": "已更新同名技能:{name}。",
|
|
"status_update_done": "技能更新完成:{name}。",
|
|
"status_delete_done": "技能刪除完成:{name}。",
|
|
"err_unavailable": "目前執行環境不可用 OpenWebUI Skills 模型。",
|
|
"err_user_required": "需要使用者上下文。",
|
|
"err_name_required": "技能名稱不能為空。",
|
|
"err_not_found": "未找到技能。",
|
|
"err_no_update_fields": "未提供可更新欄位。",
|
|
"err_url_required": "技能 URL 不能為空。",
|
|
"err_install_fetch": "從 URL 取得技能內容失敗。",
|
|
"err_install_parse": "解析技能包或內容失敗。",
|
|
"err_invalid_url": "URL 無效,僅支援 http(s) 位址。",
|
|
"msg_created": "技能建立成功。",
|
|
"msg_updated": "技能更新成功。",
|
|
"msg_deleted": "技能刪除成功。",
|
|
"msg_installed": "技能安裝成功。",
|
|
},
|
|
"ja-JP": {
|
|
"status_listing": "スキル一覧を取得しています...",
|
|
"status_showing": "スキル詳細を読み込み中...",
|
|
"status_installing": "URL からスキルをインストール中...",
|
|
"status_creating": "スキルを作成中...",
|
|
"status_updating": "スキルを更新中...",
|
|
"status_deleting": "スキルを削除中...",
|
|
"status_done": "完了しました。",
|
|
"status_list_done": "{count} 件のスキルが見つかりました(有効: {active_count} 件)。",
|
|
"status_show_done": "スキルを読み込みました: {name}。",
|
|
"status_install_done": "スキルをインストールしました: {name}。",
|
|
"status_install_overwrite_done": "既存スキルを更新してインストールしました: {name}。",
|
|
"status_create_done": "スキルを作成しました: {name}。",
|
|
"status_create_overwrite_done": "同名スキルを更新しました: {name}。",
|
|
"status_update_done": "スキルを更新しました: {name}。",
|
|
"status_delete_done": "スキルを削除しました: {name}。",
|
|
"err_unavailable": "この実行環境では OpenWebUI Skills モデルを利用できません。",
|
|
"err_user_required": "ユーザーコンテキストが必要です。",
|
|
"err_name_required": "スキル名は必須です。",
|
|
"err_not_found": "スキルが見つかりません。",
|
|
"err_no_update_fields": "更新する項目が指定されていません。",
|
|
"err_url_required": "スキル URL は必須です。",
|
|
"err_install_fetch": "URL からスキル内容の取得に失敗しました。",
|
|
"err_install_parse": "スキルパッケージ/内容の解析に失敗しました。",
|
|
"err_invalid_url": "無効な URL です。http(s) のみサポートします。",
|
|
"msg_created": "スキルを作成しました。",
|
|
"msg_updated": "スキルを更新しました。",
|
|
"msg_deleted": "スキルを削除しました。",
|
|
"msg_installed": "スキルをインストールしました。",
|
|
},
|
|
"ko-KR": {
|
|
"status_listing": "스킬 목록을 불러오는 중...",
|
|
"status_showing": "스킬 상세 정보를 읽는 중...",
|
|
"status_installing": "URL에서 스킬 설치 중...",
|
|
"status_creating": "스킬 생성 중...",
|
|
"status_updating": "스킬 업데이트 중...",
|
|
"status_deleting": "스킬 삭제 중...",
|
|
"status_done": "완료되었습니다.",
|
|
"status_list_done": "스킬 {count}개를 찾았습니다(활성 {active_count}개).",
|
|
"status_show_done": "스킬을 불러왔습니다: {name}.",
|
|
"status_install_done": "스킬 설치 완료: {name}.",
|
|
"status_install_overwrite_done": "기존 스킬을 업데이트하여 설치 완료: {name}.",
|
|
"status_create_done": "스킬 생성 완료: {name}.",
|
|
"status_create_overwrite_done": "동일 이름 스킬 업데이트 완료: {name}.",
|
|
"status_update_done": "스킬 업데이트 완료: {name}.",
|
|
"status_delete_done": "스킬 삭제 완료: {name}.",
|
|
"err_unavailable": "현재 런타임에서 OpenWebUI Skills 모델을 사용할 수 없습니다.",
|
|
"err_user_required": "사용자 컨텍스트가 필요합니다.",
|
|
"err_name_required": "스킬 이름은 필수입니다.",
|
|
"err_not_found": "스킬을 찾을 수 없습니다.",
|
|
"err_no_update_fields": "업데이트할 필드가 제공되지 않았습니다.",
|
|
"err_url_required": "스킬 URL이 필요합니다.",
|
|
"err_install_fetch": "URL에서 스킬 내용을 가져오지 못했습니다.",
|
|
"err_install_parse": "스킬 패키지/내용 파싱에 실패했습니다.",
|
|
"err_invalid_url": "잘못된 URL입니다. http(s)만 지원됩니다.",
|
|
"msg_created": "스킬이 생성되었습니다.",
|
|
"msg_updated": "스킬이 업데이트되었습니다.",
|
|
"msg_deleted": "스킬이 삭제되었습니다.",
|
|
"msg_installed": "스킬이 설치되었습니다.",
|
|
},
|
|
"fr-FR": {
|
|
"status_listing": "Liste des skills en cours...",
|
|
"status_showing": "Lecture des détails du skill...",
|
|
"status_installing": "Installation du skill depuis l'URL...",
|
|
"status_creating": "Création du skill...",
|
|
"status_updating": "Mise à jour du skill...",
|
|
"status_deleting": "Suppression du skill...",
|
|
"status_done": "Terminé.",
|
|
"status_list_done": "{count} skills trouvés ({active_count} actifs).",
|
|
"status_show_done": "Skill chargé : {name}.",
|
|
"status_install_done": "Skill installé : {name}.",
|
|
"status_install_overwrite_done": "Skill installé en mettant à jour l'existant : {name}.",
|
|
"status_create_done": "Skill créé : {name}.",
|
|
"status_create_overwrite_done": "Skill existant mis à jour : {name}.",
|
|
"status_update_done": "Skill mis à jour : {name}.",
|
|
"status_delete_done": "Skill supprimé : {name}.",
|
|
"err_unavailable": "Le modèle OpenWebUI Skills n'est pas disponible dans cet environnement.",
|
|
"err_user_required": "Le contexte utilisateur est requis.",
|
|
"err_name_required": "Le nom du skill est requis.",
|
|
"err_not_found": "Skill introuvable.",
|
|
"err_no_update_fields": "Aucun champ à mettre à jour n'a été fourni.",
|
|
"err_url_required": "L'URL du skill est requise.",
|
|
"err_install_fetch": "Échec de récupération du contenu du skill depuis l'URL.",
|
|
"err_install_parse": "Échec de l'analyse du package/contenu du skill.",
|
|
"err_invalid_url": "URL invalide. Seules les URL http(s) sont prises en charge.",
|
|
"msg_created": "Skill créé avec succès.",
|
|
"msg_updated": "Skill mis à jour avec succès.",
|
|
"msg_deleted": "Skill supprimé avec succès.",
|
|
"msg_installed": "Skill installé avec succès.",
|
|
},
|
|
"de-DE": {
|
|
"status_listing": "Deine Skills werden aufgelistet...",
|
|
"status_showing": "Skill-Details werden gelesen...",
|
|
"status_installing": "Skill wird von URL installiert...",
|
|
"status_creating": "Skill wird erstellt...",
|
|
"status_updating": "Skill wird aktualisiert...",
|
|
"status_deleting": "Skill wird gelöscht...",
|
|
"status_done": "Fertig.",
|
|
"status_list_done": "{count} Skills gefunden ({active_count} aktiv).",
|
|
"status_show_done": "Skill geladen: {name}.",
|
|
"status_install_done": "Skill installiert: {name}.",
|
|
"status_install_overwrite_done": "Skill durch Aktualisierung installiert: {name}.",
|
|
"status_create_done": "Skill erstellt: {name}.",
|
|
"status_create_overwrite_done": "Bestehender Skill aktualisiert: {name}.",
|
|
"status_update_done": "Skill aktualisiert: {name}.",
|
|
"status_delete_done": "Skill gelöscht: {name}.",
|
|
"err_unavailable": "Das OpenWebUI-Skills-Modell ist in dieser Laufzeit nicht verfügbar.",
|
|
"err_user_required": "Benutzerkontext ist erforderlich.",
|
|
"err_name_required": "Skill-Name ist erforderlich.",
|
|
"err_not_found": "Skill nicht gefunden.",
|
|
"err_no_update_fields": "Keine zu aktualisierenden Felder angegeben.",
|
|
"err_url_required": "Skill-URL ist erforderlich.",
|
|
"err_install_fetch": "Skill-Inhalt konnte nicht von URL geladen werden.",
|
|
"err_install_parse": "Skill-Paket/Inhalt konnte nicht geparst werden.",
|
|
"err_invalid_url": "Ungültige URL. Nur http(s)-URLs werden unterstützt.",
|
|
"msg_created": "Skill erfolgreich erstellt.",
|
|
"msg_updated": "Skill erfolgreich aktualisiert.",
|
|
"msg_deleted": "Skill erfolgreich gelöscht.",
|
|
"msg_installed": "Skill erfolgreich installiert.",
|
|
},
|
|
"es-ES": {
|
|
"status_listing": "Listando tus skills...",
|
|
"status_showing": "Leyendo detalles del skill...",
|
|
"status_installing": "Instalando skill desde URL...",
|
|
"status_creating": "Creando skill...",
|
|
"status_updating": "Actualizando skill...",
|
|
"status_deleting": "Eliminando skill...",
|
|
"status_done": "Hecho.",
|
|
"status_list_done": "Se encontraron {count} skills ({active_count} activos).",
|
|
"status_show_done": "Skill cargado: {name}.",
|
|
"status_install_done": "Skill instalado: {name}.",
|
|
"status_install_overwrite_done": "Skill instalado actualizando el existente: {name}.",
|
|
"status_create_done": "Skill creado: {name}.",
|
|
"status_create_overwrite_done": "Skill existente actualizado: {name}.",
|
|
"status_update_done": "Skill actualizado: {name}.",
|
|
"status_delete_done": "Skill eliminado: {name}.",
|
|
"err_unavailable": "El modelo OpenWebUI Skills no está disponible en este entorno.",
|
|
"err_user_required": "Se requiere contexto de usuario.",
|
|
"err_name_required": "Se requiere el nombre del skill.",
|
|
"err_not_found": "Skill no encontrado.",
|
|
"err_no_update_fields": "No se proporcionaron campos para actualizar.",
|
|
"err_url_required": "Se requiere la URL del skill.",
|
|
"err_install_fetch": "No se pudo obtener el contenido del skill desde la URL.",
|
|
"err_install_parse": "No se pudo analizar el paquete/contenido del skill.",
|
|
"err_invalid_url": "URL inválida. Solo se admiten URLs http(s).",
|
|
"msg_created": "Skill creado correctamente.",
|
|
"msg_updated": "Skill actualizado correctamente.",
|
|
"msg_deleted": "Skill eliminado correctamente.",
|
|
"msg_installed": "Skill instalado correctamente.",
|
|
},
|
|
"it-IT": {
|
|
"status_listing": "Elenco delle skill in corso...",
|
|
"status_showing": "Lettura dei dettagli della skill...",
|
|
"status_installing": "Installazione della skill da URL...",
|
|
"status_creating": "Creazione della skill...",
|
|
"status_updating": "Aggiornamento della skill...",
|
|
"status_deleting": "Eliminazione della skill...",
|
|
"status_done": "Fatto.",
|
|
"status_list_done": "Trovate {count} skill ({active_count} attive).",
|
|
"status_show_done": "Skill caricata: {name}.",
|
|
"status_install_done": "Skill installata: {name}.",
|
|
"status_install_overwrite_done": "Skill installata aggiornando l'esistente: {name}.",
|
|
"status_create_done": "Skill creata: {name}.",
|
|
"status_create_overwrite_done": "Skill esistente aggiornata: {name}.",
|
|
"status_update_done": "Skill aggiornata: {name}.",
|
|
"status_delete_done": "Skill eliminata: {name}.",
|
|
"err_unavailable": "Il modello OpenWebUI Skills non è disponibile in questo runtime.",
|
|
"err_user_required": "È richiesto il contesto utente.",
|
|
"err_name_required": "Il nome della skill è obbligatorio.",
|
|
"err_not_found": "Skill non trovata.",
|
|
"err_no_update_fields": "Nessun campo da aggiornare fornito.",
|
|
"err_url_required": "L'URL della skill è obbligatoria.",
|
|
"err_install_fetch": "Impossibile recuperare il contenuto della skill dall'URL.",
|
|
"err_install_parse": "Impossibile analizzare il pacchetto/contenuto della skill.",
|
|
"err_invalid_url": "URL non valido. Sono supportati solo URL http(s).",
|
|
"msg_created": "Skill creata con successo.",
|
|
"msg_updated": "Skill aggiornata con successo.",
|
|
"msg_deleted": "Skill eliminata con successo.",
|
|
"msg_installed": "Skill installata con successo.",
|
|
},
|
|
"vi-VN": {
|
|
"status_listing": "Đang liệt kê kỹ năng của bạn...",
|
|
"status_showing": "Đang đọc chi tiết kỹ năng...",
|
|
"status_installing": "Đang cài đặt kỹ năng từ URL...",
|
|
"status_creating": "Đang tạo kỹ năng...",
|
|
"status_updating": "Đang cập nhật kỹ năng...",
|
|
"status_deleting": "Đang xóa kỹ năng...",
|
|
"status_done": "Hoàn tất.",
|
|
"status_list_done": "Đã tìm thấy {count} kỹ năng ({active_count} đang bật).",
|
|
"status_show_done": "Đã tải kỹ năng: {name}.",
|
|
"status_install_done": "Cài đặt kỹ năng hoàn tất: {name}.",
|
|
"status_install_overwrite_done": "Đã cài đặt bằng cách cập nhật kỹ năng hiện có: {name}.",
|
|
"status_create_done": "Tạo kỹ năng hoàn tất: {name}.",
|
|
"status_create_overwrite_done": "Đã cập nhật kỹ năng cùng tên: {name}.",
|
|
"status_update_done": "Cập nhật kỹ năng hoàn tất: {name}.",
|
|
"status_delete_done": "Xóa kỹ năng hoàn tất: {name}.",
|
|
"err_unavailable": "Mô hình OpenWebUI Skills không khả dụng trong môi trường hiện tại.",
|
|
"err_user_required": "Cần có ngữ cảnh người dùng.",
|
|
"err_name_required": "Tên kỹ năng là bắt buộc.",
|
|
"err_not_found": "Không tìm thấy kỹ năng.",
|
|
"err_no_update_fields": "Không có trường nào để cập nhật.",
|
|
"err_url_required": "URL kỹ năng là bắt buộc.",
|
|
"err_install_fetch": "Không thể tải nội dung kỹ năng từ URL.",
|
|
"err_install_parse": "Không thể phân tích gói/nội dung kỹ năng.",
|
|
"err_invalid_url": "URL không hợp lệ. Chỉ hỗ trợ URL http(s).",
|
|
"msg_created": "Tạo kỹ năng thành công.",
|
|
"msg_updated": "Cập nhật kỹ năng thành công.",
|
|
"msg_deleted": "Xóa kỹ năng thành công.",
|
|
"msg_installed": "Cài đặt kỹ năng thành công.",
|
|
},
|
|
"id-ID": {
|
|
"status_listing": "Sedang menampilkan daftar skill Anda...",
|
|
"status_showing": "Sedang membaca detail skill...",
|
|
"status_installing": "Sedang memasang skill dari URL...",
|
|
"status_creating": "Sedang membuat skill...",
|
|
"status_updating": "Sedang memperbarui skill...",
|
|
"status_deleting": "Sedang menghapus skill...",
|
|
"status_done": "Selesai.",
|
|
"status_list_done": "Ditemukan {count} skill ({active_count} aktif).",
|
|
"status_show_done": "Skill dimuat: {name}.",
|
|
"status_install_done": "Skill berhasil dipasang: {name}.",
|
|
"status_install_overwrite_done": "Skill dipasang dengan memperbarui skill yang ada: {name}.",
|
|
"status_create_done": "Skill berhasil dibuat: {name}.",
|
|
"status_create_overwrite_done": "Skill dengan nama sama berhasil diperbarui: {name}.",
|
|
"status_update_done": "Skill berhasil diperbarui: {name}.",
|
|
"status_delete_done": "Skill berhasil dihapus: {name}.",
|
|
"err_unavailable": "Model OpenWebUI Skills tidak tersedia di runtime ini.",
|
|
"err_user_required": "Konteks pengguna diperlukan.",
|
|
"err_name_required": "Nama skill wajib diisi.",
|
|
"err_not_found": "Skill tidak ditemukan.",
|
|
"err_no_update_fields": "Tidak ada field pembaruan yang diberikan.",
|
|
"err_url_required": "URL skill wajib diisi.",
|
|
"err_install_fetch": "Gagal mengambil konten skill dari URL.",
|
|
"err_install_parse": "Gagal mem-parsing paket/konten skill.",
|
|
"err_invalid_url": "URL tidak valid. Hanya URL http(s) yang didukung.",
|
|
"msg_created": "Skill berhasil dibuat.",
|
|
"msg_updated": "Skill berhasil diperbarui.",
|
|
"msg_deleted": "Skill berhasil dihapus.",
|
|
"msg_installed": "Skill berhasil dipasang.",
|
|
},
|
|
}
|
|
|
|
FALLBACK_MAP = {
|
|
"zh": "zh-CN",
|
|
"zh-TW": "zh-TW",
|
|
"zh-HK": "zh-HK",
|
|
"en": "en-US",
|
|
"ja": "ja-JP",
|
|
"ko": "ko-KR",
|
|
"fr": "fr-FR",
|
|
"de": "de-DE",
|
|
"es": "es-ES",
|
|
"it": "it-IT",
|
|
"vi": "vi-VN",
|
|
"id": "id-ID",
|
|
}
|
|
|
|
|
|
class Tools:
|
|
"""OpenWebUI native tools for simple skill lifecycle management."""
|
|
|
|
class Valves(BaseModel):
|
|
"""Configurable plugin valves."""
|
|
|
|
SHOW_STATUS: bool = Field(
|
|
default=True,
|
|
description="Whether to show operation status updates.",
|
|
)
|
|
ALLOW_OVERWRITE_ON_CREATE: bool = Field(
|
|
default=False,
|
|
description="Allow create_skill/install_skill to overwrite same-name skill by default.",
|
|
)
|
|
INSTALL_FETCH_TIMEOUT: float = Field(
|
|
default=12.0,
|
|
description="Timeout in seconds for URL fetch when installing a skill.",
|
|
)
|
|
|
|
def __init__(self):
|
|
"""Initialize plugin valves."""
|
|
self.valves = self.Valves()
|
|
|
|
def _resolve_language(self, user_language: str) -> str:
|
|
"""Normalize user language code to a supported translation key."""
|
|
if not user_language:
|
|
return "en-US"
|
|
if user_language in TRANSLATIONS:
|
|
return user_language
|
|
if user_language in FALLBACK_MAP:
|
|
return FALLBACK_MAP[user_language]
|
|
base = user_language.split("-")[0]
|
|
return FALLBACK_MAP.get(base, "en-US")
|
|
|
|
def _t(self, lang: str, key: str, **kwargs) -> str:
|
|
"""Return translated text for key with safe formatting."""
|
|
lang_key = self._resolve_language(lang)
|
|
text = TRANSLATIONS.get(lang_key, TRANSLATIONS["en-US"]).get(
|
|
key, TRANSLATIONS["en-US"].get(key, key)
|
|
)
|
|
if kwargs:
|
|
try:
|
|
text = text.format(**kwargs)
|
|
except KeyError:
|
|
pass
|
|
return text
|
|
|
|
def _get_user_context(self, __user__: Optional[dict]) -> Dict[str, str]:
|
|
"""Extract robust user context from OpenWebUI's __user__ payload."""
|
|
if isinstance(__user__, (list, tuple)):
|
|
user_data = __user__[0] if __user__ else {}
|
|
elif isinstance(__user__, dict):
|
|
user_data = __user__
|
|
else:
|
|
user_data = {}
|
|
|
|
return {
|
|
"user_id": str(user_data.get("id", "")).strip(),
|
|
"user_name": user_data.get("name", "User"),
|
|
"user_language": user_data.get("language", "en-US"),
|
|
}
|
|
|
|
async def _emit_status(
|
|
self,
|
|
emitter: Optional[Any],
|
|
description: str,
|
|
done: bool = False,
|
|
):
|
|
"""Emit status event to OpenWebUI status bar when enabled."""
|
|
if self.valves.SHOW_STATUS and emitter:
|
|
await emitter(
|
|
{
|
|
"type": "status",
|
|
"data": {"description": description, "done": done},
|
|
}
|
|
)
|
|
|
|
def _require_skills_model(self):
|
|
"""Ensure OpenWebUI Skills model APIs are available."""
|
|
if Skills is None or SkillForm is None or SkillMeta is None:
|
|
raise RuntimeError("skills_model_unavailable")
|
|
|
|
def _user_skills(self, user_id: str, access: str = "read") -> List[Any]:
|
|
"""Load user-scoped skills using OpenWebUI Skills model."""
|
|
return Skills.get_skills_by_user_id(user_id, access) or []
|
|
|
|
def _find_skill(
|
|
self,
|
|
user_id: str,
|
|
skill_id: str = "",
|
|
name: str = "",
|
|
) -> Optional[Any]:
|
|
"""Find a skill by id or case-insensitive name within user scope."""
|
|
skills = self._user_skills(user_id, "read")
|
|
target_id = (skill_id or "").strip()
|
|
target_name = (name or "").strip().lower()
|
|
|
|
for skill in skills:
|
|
sid = str(getattr(skill, "id", "") or "")
|
|
sname = str(getattr(skill, "name", "") or "")
|
|
if target_id and sid == target_id:
|
|
return skill
|
|
if target_name and sname.lower() == target_name:
|
|
return skill
|
|
return None
|
|
|
|
def _extract_folder_name_from_url(self, url: str) -> str:
|
|
"""Extract folder name from GitHub URL path.
|
|
Examples:
|
|
- https://github.com/.../tree/main/skills/xlsx -> xlsx
|
|
- https://github.com/.../blob/main/skills/README.md -> skills
|
|
- https://raw.githubusercontent.com/.../main/skills/README.md -> skills
|
|
"""
|
|
try:
|
|
# Remove query string and fragments
|
|
path = url.split("?")[0].split("#")[0]
|
|
# Get last path component
|
|
parts = path.rstrip("/").split("/")
|
|
if parts:
|
|
last = parts[-1]
|
|
# Skip if it's a file extension
|
|
if "." not in last or last.startswith("."):
|
|
return last
|
|
# Return parent directory if it's a filename
|
|
if len(parts) > 1:
|
|
return parts[-2]
|
|
except Exception:
|
|
pass
|
|
return ""
|
|
|
|
def _resolve_github_tree_urls(self, url: str) -> List[str]:
|
|
"""For GitHub tree URLs, resolve to direct file URLs to try.
|
|
|
|
Example: https://github.com/anthropics/skills/tree/main/skills/xlsx
|
|
Returns: [
|
|
https://raw.githubusercontent.com/anthropics/skills/main/skills/xlsx/SKILL.md,
|
|
https://raw.githubusercontent.com/anthropics/skills/main/skills/xlsx/README.md,
|
|
]
|
|
"""
|
|
urls = []
|
|
match = re.match(
|
|
r"https://github\.com/([^/]+)/([^/]+)/tree/([^/]+)(/.*)?\Z", url
|
|
)
|
|
if match:
|
|
owner = match.group(1)
|
|
repo = match.group(2)
|
|
branch = match.group(3)
|
|
path = match.group(4) or ""
|
|
base = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}{path}"
|
|
# Try SKILL.md first, then README.md
|
|
urls.append(f"{base}/SKILL.md")
|
|
urls.append(f"{base}/README.md")
|
|
return urls
|
|
|
|
def _normalize_url(self, url: str) -> str:
|
|
"""Normalize supported URLs (GitHub blob -> raw, tree -> try direct files first)."""
|
|
value = (url or "").strip()
|
|
if not value.startswith("http://") and not value.startswith("https://"):
|
|
raise ValueError("invalid_url")
|
|
|
|
# Handle GitHub blob URLs -> convert to raw
|
|
if "github.com" in value and "/blob/" in value:
|
|
value = value.replace("github.com", "raw.githubusercontent.com")
|
|
value = value.replace("/blob/", "/")
|
|
|
|
# Note: GitHub tree URLs are handled separately in install_skill
|
|
# via _resolve_github_tree_urls()
|
|
|
|
return value
|
|
|
|
async def _fetch_bytes(self, url: str) -> bytes:
|
|
"""Fetch bytes from URL with timeout guard."""
|
|
|
|
def _sync_fetch(target: str) -> bytes:
|
|
with urllib.request.urlopen(
|
|
target, timeout=self.valves.INSTALL_FETCH_TIMEOUT
|
|
) as resp:
|
|
return resp.read()
|
|
|
|
return await asyncio.wait_for(
|
|
asyncio.to_thread(_sync_fetch, url),
|
|
timeout=self.valves.INSTALL_FETCH_TIMEOUT + 1.0,
|
|
)
|
|
|
|
def _parse_skill_md_meta(
|
|
self, content: str, fallback_name: str
|
|
) -> Tuple[str, str, str]:
|
|
"""Parse markdown skill content into (name, description, body)."""
|
|
fm_match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL)
|
|
if fm_match:
|
|
fm_text = fm_match.group(1)
|
|
body = content[fm_match.end() :].strip()
|
|
name = fallback_name
|
|
description = ""
|
|
for line in fm_text.split("\n"):
|
|
m_name = re.match(r"^name:\s*(.+)$", line)
|
|
if m_name:
|
|
name = m_name.group(1).strip().strip("\"'")
|
|
m_desc = re.match(r"^description:\s*(.+)$", line)
|
|
if m_desc:
|
|
description = m_desc.group(1).strip().strip("\"'")
|
|
return name, description, body
|
|
|
|
h1_match = re.search(r"^#\s+(.+)$", content.strip(), re.MULTILINE)
|
|
name = h1_match.group(1).strip() if h1_match else fallback_name
|
|
return name, "", content.strip()
|
|
|
|
def _extract_skill_from_archive(self, payload: bytes) -> Tuple[str, str, str]:
|
|
"""Extract first SKILL.md (or README.md) from zip/tar archives."""
|
|
with tempfile.TemporaryDirectory(prefix="owui-skill-") as tmp:
|
|
root = Path(tmp)
|
|
archive_path = root / "pkg"
|
|
archive_path.write_bytes(payload)
|
|
|
|
extract_dir = root / "extract"
|
|
extract_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
extracted = False
|
|
try:
|
|
with zipfile.ZipFile(archive_path, "r") as zf:
|
|
zf.extractall(extract_dir)
|
|
extracted = True
|
|
except Exception:
|
|
pass
|
|
|
|
if not extracted:
|
|
try:
|
|
with tarfile.open(archive_path, "r:*") as tf:
|
|
tf.extractall(extract_dir)
|
|
extracted = True
|
|
except Exception:
|
|
pass
|
|
|
|
if not extracted:
|
|
raise ValueError("install_parse")
|
|
|
|
candidates = list(extract_dir.rglob("SKILL.md"))
|
|
if not candidates:
|
|
candidates = list(extract_dir.rglob("README.md"))
|
|
if not candidates:
|
|
raise ValueError("install_parse")
|
|
|
|
chosen = candidates[0]
|
|
text = chosen.read_text(encoding="utf-8", errors="ignore")
|
|
fallback_name = chosen.parent.name or "installed-skill"
|
|
return self._parse_skill_md_meta(text, fallback_name)
|
|
|
|
async def list_skills(
|
|
self,
|
|
include_content: bool = False,
|
|
__user__: Optional[dict] = None,
|
|
__event_emitter__: Optional[Any] = None,
|
|
) -> Dict[str, Any]:
|
|
"""List current user's OpenWebUI skills."""
|
|
user_ctx = self._get_user_context(__user__)
|
|
lang = user_ctx["user_language"]
|
|
user_id = user_ctx["user_id"]
|
|
|
|
try:
|
|
self._require_skills_model()
|
|
if not user_id:
|
|
raise ValueError(self._t(lang, "err_user_required"))
|
|
|
|
await self._emit_status(__event_emitter__, self._t(lang, "status_listing"))
|
|
|
|
skills = self._user_skills(user_id, "read")
|
|
rows = []
|
|
for skill in skills:
|
|
row = {
|
|
"id": str(getattr(skill, "id", "") or ""),
|
|
"name": getattr(skill, "name", ""),
|
|
"description": getattr(skill, "description", ""),
|
|
"is_active": bool(getattr(skill, "is_active", True)),
|
|
"updated_at": str(getattr(skill, "updated_at", "") or ""),
|
|
}
|
|
if include_content:
|
|
row["content"] = getattr(skill, "content", "")
|
|
rows.append(row)
|
|
|
|
rows.sort(key=lambda x: (x.get("name") or "").lower())
|
|
active_count = sum(1 for row in rows if row.get("is_active"))
|
|
|
|
await self._emit_status(
|
|
__event_emitter__,
|
|
self._t(
|
|
lang,
|
|
"status_list_done",
|
|
count=len(rows),
|
|
active_count=active_count,
|
|
),
|
|
done=True,
|
|
)
|
|
return {"count": len(rows), "skills": rows}
|
|
except Exception as e:
|
|
msg = (
|
|
self._t(lang, "err_unavailable")
|
|
if str(e) == "skills_model_unavailable"
|
|
else str(e)
|
|
)
|
|
await self._emit_status(__event_emitter__, msg, done=True)
|
|
return {"error": msg}
|
|
|
|
async def show_skill(
|
|
self,
|
|
skill_id: str = "",
|
|
name: str = "",
|
|
include_content: bool = True,
|
|
__user__: Optional[dict] = None,
|
|
__event_emitter__: Optional[Any] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Show one skill by id or name."""
|
|
user_ctx = self._get_user_context(__user__)
|
|
lang = user_ctx["user_language"]
|
|
user_id = user_ctx["user_id"]
|
|
|
|
try:
|
|
self._require_skills_model()
|
|
if not user_id:
|
|
raise ValueError(self._t(lang, "err_user_required"))
|
|
|
|
await self._emit_status(__event_emitter__, self._t(lang, "status_showing"))
|
|
|
|
skill = self._find_skill(user_id=user_id, skill_id=skill_id, name=name)
|
|
if not skill:
|
|
raise ValueError(self._t(lang, "err_not_found"))
|
|
|
|
result = {
|
|
"id": str(getattr(skill, "id", "") or ""),
|
|
"name": getattr(skill, "name", ""),
|
|
"description": getattr(skill, "description", ""),
|
|
"is_active": bool(getattr(skill, "is_active", True)),
|
|
"updated_at": str(getattr(skill, "updated_at", "") or ""),
|
|
}
|
|
if include_content:
|
|
result["content"] = getattr(skill, "content", "")
|
|
|
|
skill_name = result.get("name") or result.get("id") or "unknown"
|
|
await self._emit_status(
|
|
__event_emitter__,
|
|
self._t(lang, "status_show_done", name=skill_name),
|
|
done=True,
|
|
)
|
|
return result
|
|
except Exception as e:
|
|
msg = (
|
|
self._t(lang, "err_unavailable")
|
|
if str(e) == "skills_model_unavailable"
|
|
else str(e)
|
|
)
|
|
await self._emit_status(__event_emitter__, msg, done=True)
|
|
return {"error": msg}
|
|
|
|
async def _install_single_skill(
|
|
self,
|
|
url: str,
|
|
name: str,
|
|
user_id: str,
|
|
lang: str,
|
|
overwrite: bool,
|
|
__event_emitter__: Optional[Any] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Internal method to install a single skill from URL."""
|
|
try:
|
|
if not (url or "").strip():
|
|
raise ValueError(self._t(lang, "err_url_required"))
|
|
|
|
# Extract potential folder name from URL before normalization
|
|
url_folder = self._extract_folder_name_from_url(url).strip()
|
|
|
|
parsed_name = ""
|
|
parsed_desc = ""
|
|
parsed_body = ""
|
|
payload = None
|
|
|
|
# Special handling for GitHub tree URLs
|
|
if "github.com" in url and "/tree/" in url:
|
|
fallback_file_urls = self._resolve_github_tree_urls(url)
|
|
# Try to fetch SKILL.md or README.md directly from the tree path
|
|
for file_url in fallback_file_urls:
|
|
try:
|
|
payload = await self._fetch_bytes(file_url)
|
|
if payload:
|
|
break
|
|
except Exception:
|
|
continue
|
|
|
|
if payload:
|
|
# Successfully fetched direct file
|
|
text = payload.decode("utf-8", errors="ignore")
|
|
fallback = url_folder or "installed-skill"
|
|
parsed_name, parsed_desc, parsed_body = self._parse_skill_md_meta(
|
|
text, fallback
|
|
)
|
|
else:
|
|
# Fallback: download entire branch as zip and extract
|
|
# This is a last resort if direct file access fails
|
|
raise ValueError(f"Could not find SKILL.md or README.md in {url}")
|
|
else:
|
|
# Handle other URL types (blob, direct markdown, archives)
|
|
normalized = self._normalize_url(url)
|
|
payload = await self._fetch_bytes(normalized)
|
|
|
|
if normalized.lower().endswith((".zip", ".tar", ".tar.gz", ".tgz")):
|
|
parsed_name, parsed_desc, parsed_body = (
|
|
self._extract_skill_from_archive(payload)
|
|
)
|
|
else:
|
|
text = payload.decode("utf-8", errors="ignore")
|
|
# Use extracted folder name as fallback
|
|
fallback = url_folder or "installed-skill"
|
|
parsed_name, parsed_desc, parsed_body = self._parse_skill_md_meta(
|
|
text, fallback
|
|
)
|
|
|
|
final_name = (
|
|
name or parsed_name or url_folder or "installed-skill"
|
|
).strip()
|
|
final_desc = (parsed_desc or final_name).strip()
|
|
final_content = (parsed_body or final_desc).strip()
|
|
if not final_name:
|
|
raise ValueError(self._t(lang, "err_name_required"))
|
|
|
|
existing = self._find_skill(user_id=user_id, name=final_name)
|
|
# install_skill always overwrites by default (overwrite=True);
|
|
# ALLOW_OVERWRITE_ON_CREATE valve also controls this.
|
|
allow_overwrite = overwrite or self.valves.ALLOW_OVERWRITE_ON_CREATE
|
|
if existing:
|
|
sid = str(getattr(existing, "id", "") or "")
|
|
if not allow_overwrite:
|
|
# Should not normally reach here since install defaults overwrite=True
|
|
return {
|
|
"error": f"Skill already exists: {final_name}",
|
|
"hint": "Pass overwrite=true to replace the existing skill.",
|
|
}
|
|
updated = Skills.update_skill_by_id(
|
|
sid,
|
|
{
|
|
"name": final_name,
|
|
"description": final_desc,
|
|
"content": final_content,
|
|
"is_active": True,
|
|
},
|
|
)
|
|
await self._emit_status(
|
|
__event_emitter__,
|
|
self._t(lang, "status_install_overwrite_done", name=final_name),
|
|
done=True,
|
|
)
|
|
return {
|
|
"success": True,
|
|
"action": "updated",
|
|
"id": str(getattr(updated, "id", "") or sid),
|
|
"name": final_name,
|
|
"source_url": url,
|
|
}
|
|
|
|
new_skill = Skills.insert_new_skill(
|
|
user_id=user_id,
|
|
form_data=SkillForm(
|
|
id=str(uuid.uuid4()),
|
|
name=final_name,
|
|
description=final_desc,
|
|
content=final_content,
|
|
meta=SkillMeta(),
|
|
is_active=True,
|
|
),
|
|
)
|
|
|
|
await self._emit_status(
|
|
__event_emitter__,
|
|
self._t(lang, "status_install_done", name=final_name),
|
|
done=True,
|
|
)
|
|
return {
|
|
"success": True,
|
|
"action": "installed",
|
|
"id": str(getattr(new_skill, "id", "") or ""),
|
|
"name": final_name,
|
|
"source_url": url,
|
|
}
|
|
except Exception as e:
|
|
key = None
|
|
if str(e) in {"invalid_url", "install_parse"}:
|
|
key = (
|
|
"err_invalid_url"
|
|
if str(e) == "invalid_url"
|
|
else "err_install_parse"
|
|
)
|
|
msg = (
|
|
self._t(lang, key)
|
|
if key
|
|
else (
|
|
self._t(lang, "err_unavailable")
|
|
if str(e) == "skills_model_unavailable"
|
|
else str(e)
|
|
)
|
|
)
|
|
logger.error(
|
|
f"_install_single_skill failed for {url}: {msg}", exc_info=True
|
|
)
|
|
return {"error": msg, "url": url}
|
|
|
|
async def install_skill(
|
|
self,
|
|
url: str,
|
|
name: str = "",
|
|
overwrite: bool = True,
|
|
__user__: Optional[dict] = None,
|
|
__event_emitter__: Optional[Any] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Install one or more skills from URL(s). Overwrites existing skills by default.
|
|
|
|
Args:
|
|
url: A single URL string OR a JSON array of URL strings for batch install.
|
|
Examples:
|
|
Single: "https://github.com/owner/repo/tree/main/skills/xlsx"
|
|
Batch: ["https://github.com/owner/repo/tree/main/skills/xlsx",
|
|
"https://github.com/owner/repo/tree/main/skills/csv"]
|
|
name: Optional custom name for the skill (single install only).
|
|
overwrite: If True (default), overwrites any existing skill with the same name.
|
|
|
|
Supported URL formats:
|
|
- GitHub tree URL: https://github.com/owner/repo/tree/branch/path/to/skill
|
|
- GitHub blob URL: https://github.com/owner/repo/blob/branch/path/SKILL.md
|
|
- Raw markdown URL: https://raw.githubusercontent.com/.../SKILL.md
|
|
- Archive URL: https://example.com/skill.zip (must contain SKILL.md or README.md)
|
|
"""
|
|
user_ctx = self._get_user_context(__user__)
|
|
lang = user_ctx["user_language"]
|
|
user_id = user_ctx["user_id"]
|
|
|
|
try:
|
|
self._require_skills_model()
|
|
if not user_id:
|
|
raise ValueError(self._t(lang, "err_user_required"))
|
|
|
|
# Check if url is a list/tuple (batch mode)
|
|
if isinstance(url, (list, tuple)):
|
|
urls = url
|
|
if not urls:
|
|
raise ValueError(self._t(lang, "err_url_required"))
|
|
|
|
await self._emit_status(
|
|
__event_emitter__, f"Installing {len(urls)} skill(s)..."
|
|
)
|
|
|
|
results = []
|
|
for idx, single_url in enumerate(urls, 1):
|
|
result = await self._install_single_skill(
|
|
url=str(single_url).strip(),
|
|
name="", # Batch mode doesn't support per-item names
|
|
user_id=user_id,
|
|
lang=lang,
|
|
overwrite=overwrite,
|
|
__event_emitter__=__event_emitter__,
|
|
)
|
|
results.append(result)
|
|
|
|
# Summary
|
|
success_count = sum(1 for r in results if r.get("success"))
|
|
error_count = len(results) - success_count
|
|
|
|
await self._emit_status(
|
|
__event_emitter__,
|
|
f"Batch install completed: {success_count} succeeded, {error_count} failed.",
|
|
done=True,
|
|
)
|
|
|
|
return {
|
|
"batch": True,
|
|
"total": len(results),
|
|
"succeeded": success_count,
|
|
"failed": error_count,
|
|
"results": results,
|
|
}
|
|
else:
|
|
# Single mode
|
|
if not (url or "").strip():
|
|
raise ValueError(self._t(lang, "err_url_required"))
|
|
|
|
await self._emit_status(
|
|
__event_emitter__, self._t(lang, "status_installing")
|
|
)
|
|
|
|
result = await self._install_single_skill(
|
|
url=str(url).strip(),
|
|
name=name,
|
|
user_id=user_id,
|
|
lang=lang,
|
|
overwrite=overwrite,
|
|
__event_emitter__=__event_emitter__,
|
|
)
|
|
return result
|
|
|
|
except Exception as e:
|
|
key = None
|
|
if str(e) in {"invalid_url", "install_parse"}:
|
|
key = (
|
|
"err_invalid_url"
|
|
if str(e) == "invalid_url"
|
|
else "err_install_parse"
|
|
)
|
|
msg = (
|
|
self._t(lang, key)
|
|
if key
|
|
else (
|
|
self._t(lang, "err_unavailable")
|
|
if str(e) == "skills_model_unavailable"
|
|
else str(e)
|
|
)
|
|
)
|
|
await self._emit_status(__event_emitter__, msg, done=True)
|
|
logger.error(f"install_skill failed: {msg}", exc_info=True)
|
|
return {"error": msg}
|
|
|
|
async def create_skill(
|
|
self,
|
|
name: str,
|
|
description: str = "",
|
|
content: str = "",
|
|
overwrite: bool = False,
|
|
__user__: Optional[dict] = None,
|
|
__event_emitter__: Optional[Any] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Create a new skill, or update same-name skill when overwrite is enabled."""
|
|
user_ctx = self._get_user_context(__user__)
|
|
lang = user_ctx["user_language"]
|
|
user_id = user_ctx["user_id"]
|
|
|
|
try:
|
|
self._require_skills_model()
|
|
if not user_id:
|
|
raise ValueError(self._t(lang, "err_user_required"))
|
|
|
|
skill_name = (name or "").strip()
|
|
if not skill_name:
|
|
raise ValueError(self._t(lang, "err_name_required"))
|
|
|
|
await self._emit_status(__event_emitter__, self._t(lang, "status_creating"))
|
|
|
|
existing = self._find_skill(user_id=user_id, name=skill_name)
|
|
allow_overwrite = overwrite or self.valves.ALLOW_OVERWRITE_ON_CREATE
|
|
|
|
final_description = (description or skill_name).strip()
|
|
final_content = (content or final_description).strip()
|
|
|
|
if existing:
|
|
if not allow_overwrite:
|
|
return {
|
|
"error": f"Skill already exists: {skill_name}",
|
|
"hint": "Use overwrite=true to update existing skill.",
|
|
}
|
|
|
|
sid = str(getattr(existing, "id", "") or "")
|
|
updated = Skills.update_skill_by_id(
|
|
sid,
|
|
{
|
|
"name": skill_name,
|
|
"description": final_description,
|
|
"content": final_content,
|
|
"is_active": True,
|
|
},
|
|
)
|
|
await self._emit_status(
|
|
__event_emitter__,
|
|
self._t(lang, "status_create_overwrite_done", name=skill_name),
|
|
done=True,
|
|
)
|
|
return {
|
|
"success": True,
|
|
"action": "updated",
|
|
"id": str(getattr(updated, "id", "") or sid),
|
|
"name": skill_name,
|
|
}
|
|
|
|
new_skill = Skills.insert_new_skill(
|
|
user_id=user_id,
|
|
form_data=SkillForm(
|
|
id=str(uuid.uuid4()),
|
|
name=skill_name,
|
|
description=final_description,
|
|
content=final_content,
|
|
meta=SkillMeta(),
|
|
is_active=True,
|
|
),
|
|
)
|
|
|
|
await self._emit_status(
|
|
__event_emitter__,
|
|
self._t(lang, "status_create_done", name=skill_name),
|
|
done=True,
|
|
)
|
|
return {
|
|
"success": True,
|
|
"action": "created",
|
|
"id": str(getattr(new_skill, "id", "") or ""),
|
|
"name": skill_name,
|
|
}
|
|
except Exception as e:
|
|
msg = (
|
|
self._t(lang, "err_unavailable")
|
|
if str(e) == "skills_model_unavailable"
|
|
else str(e)
|
|
)
|
|
await self._emit_status(__event_emitter__, msg, done=True)
|
|
logger.error(f"create_skill failed: {msg}", exc_info=True)
|
|
return {"error": msg}
|
|
|
|
async def update_skill(
|
|
self,
|
|
skill_id: str = "",
|
|
name: str = "",
|
|
new_name: str = "",
|
|
description: str = "",
|
|
content: str = "",
|
|
is_active: Optional[bool] = None,
|
|
__user__: Optional[dict] = None,
|
|
__event_emitter__: Optional[Any] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Update one skill's fields by id or name."""
|
|
user_ctx = self._get_user_context(__user__)
|
|
lang = user_ctx["user_language"]
|
|
user_id = user_ctx["user_id"]
|
|
|
|
try:
|
|
self._require_skills_model()
|
|
if not user_id:
|
|
raise ValueError(self._t(lang, "err_user_required"))
|
|
|
|
await self._emit_status(__event_emitter__, self._t(lang, "status_updating"))
|
|
|
|
skill = self._find_skill(user_id=user_id, skill_id=skill_id, name=name)
|
|
if not skill:
|
|
raise ValueError(self._t(lang, "err_not_found"))
|
|
|
|
updates: Dict[str, Any] = {}
|
|
if new_name.strip():
|
|
updates["name"] = new_name.strip()
|
|
if description.strip():
|
|
updates["description"] = description.strip()
|
|
if content.strip():
|
|
updates["content"] = content.strip()
|
|
if is_active is not None:
|
|
updates["is_active"] = bool(is_active)
|
|
|
|
if not updates:
|
|
raise ValueError(self._t(lang, "err_no_update_fields"))
|
|
|
|
sid = str(getattr(skill, "id", "") or "")
|
|
updated = Skills.update_skill_by_id(sid, updates)
|
|
updated_name = str(
|
|
getattr(updated, "name", "")
|
|
or updates.get("name")
|
|
or getattr(skill, "name", "")
|
|
or sid
|
|
)
|
|
|
|
await self._emit_status(
|
|
__event_emitter__,
|
|
self._t(lang, "status_update_done", name=updated_name),
|
|
done=True,
|
|
)
|
|
return {
|
|
"success": True,
|
|
"id": str(getattr(updated, "id", "") or sid),
|
|
"name": str(
|
|
getattr(updated, "name", "")
|
|
or updates.get("name")
|
|
or getattr(skill, "name", "")
|
|
),
|
|
"updated_fields": list(updates.keys()),
|
|
}
|
|
except Exception as e:
|
|
msg = (
|
|
self._t(lang, "err_unavailable")
|
|
if str(e) == "skills_model_unavailable"
|
|
else str(e)
|
|
)
|
|
await self._emit_status(__event_emitter__, msg, done=True)
|
|
return {"error": msg}
|
|
|
|
async def delete_skill(
|
|
self,
|
|
skill_id: str = "",
|
|
name: str = "",
|
|
__user__: Optional[dict] = None,
|
|
__event_emitter__: Optional[Any] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Delete one skill by id or name."""
|
|
user_ctx = self._get_user_context(__user__)
|
|
lang = user_ctx["user_language"]
|
|
user_id = user_ctx["user_id"]
|
|
|
|
try:
|
|
self._require_skills_model()
|
|
if not user_id:
|
|
raise ValueError(self._t(lang, "err_user_required"))
|
|
|
|
await self._emit_status(__event_emitter__, self._t(lang, "status_deleting"))
|
|
|
|
skill = self._find_skill(user_id=user_id, skill_id=skill_id, name=name)
|
|
if not skill:
|
|
raise ValueError(self._t(lang, "err_not_found"))
|
|
|
|
sid = str(getattr(skill, "id", "") or "")
|
|
sname = str(getattr(skill, "name", "") or "")
|
|
Skills.delete_skill_by_id(sid)
|
|
deleted_name = sname or sid or "unknown"
|
|
|
|
await self._emit_status(
|
|
__event_emitter__,
|
|
self._t(lang, "status_delete_done", name=deleted_name),
|
|
done=True,
|
|
)
|
|
return {
|
|
"success": True,
|
|
"id": sid,
|
|
"name": sname,
|
|
}
|
|
except Exception as e:
|
|
msg = (
|
|
self._t(lang, "err_unavailable")
|
|
if str(e) == "skills_model_unavailable"
|
|
else str(e)
|
|
)
|
|
await self._emit_status(__event_emitter__, msg, done=True)
|
|
return {"error": msg}
|