From d0eb72467d8c97c4c3d9511b70eb0f86235e256f Mon Sep 17 00:00:00 2001 From: fujie Date: Sat, 28 Feb 2026 12:16:19 +0800 Subject: [PATCH] feat(pipes): enforce ANTI-INLINE HTML generation rule --- docs/plugins/tools/index.md | 7 + docs/plugins/tools/index.zh.md | 7 + .../tools/openwebui-skills-manager-tool.md | 27 + .../tools/openwebui-skills-manager-tool.zh.md | 27 + .../github-copilot-sdk/github_copilot_sdk.py | 1 + plugins/tools/README.md | 10 + plugins/tools/README_CN.md | 10 + .../tools/openwebui-skills-manager/README.md | 56 + .../openwebui-skills-manager/README_CN.md | 56 + .../openwebui_skills_manager.py | 1241 +++++++++++++++++ 10 files changed, 1442 insertions(+) create mode 100644 docs/plugins/tools/index.md create mode 100644 docs/plugins/tools/index.zh.md create mode 100644 docs/plugins/tools/openwebui-skills-manager-tool.md create mode 100644 docs/plugins/tools/openwebui-skills-manager-tool.zh.md create mode 100644 plugins/tools/README.md create mode 100644 plugins/tools/README_CN.md create mode 100644 plugins/tools/openwebui-skills-manager/README.md create mode 100644 plugins/tools/openwebui-skills-manager/README_CN.md create mode 100644 plugins/tools/openwebui-skills-manager/openwebui_skills_manager.py diff --git a/docs/plugins/tools/index.md b/docs/plugins/tools/index.md new file mode 100644 index 0000000..09eb39b --- /dev/null +++ b/docs/plugins/tools/index.md @@ -0,0 +1,7 @@ +# Tools + +OpenWebUI native Tool plugins that can be used across models. + +## Available Tool Plugins + +- [OpenWebUI Skills Manager Tool](openwebui-skills-manager-tool.md) (v0.2.0) - Simple native skill management (`list/show/install/create/update/delete`). diff --git a/docs/plugins/tools/index.zh.md b/docs/plugins/tools/index.zh.md new file mode 100644 index 0000000..c057e5b --- /dev/null +++ b/docs/plugins/tools/index.zh.md @@ -0,0 +1,7 @@ +# Tools(工具) + +可跨模型使用的 OpenWebUI 原生 Tool 插件。 + +## 可用 Tool 插件 + +- [OpenWebUI Skills 管理工具](openwebui-skills-manager-tool.zh.md) (v0.2.0) - 简化技能管理(`list/show/install/create/update/delete`)。 diff --git a/docs/plugins/tools/openwebui-skills-manager-tool.md b/docs/plugins/tools/openwebui-skills-manager-tool.md new file mode 100644 index 0000000..037c5bf --- /dev/null +++ b/docs/plugins/tools/openwebui-skills-manager-tool.md @@ -0,0 +1,27 @@ +# OpenWebUI Skills Manager Tool + +**Author:** [Fu-Jie](https://github.com/Fu-Jie/openwebui-extensions) | **Version:** 0.2.0 | **Project:** [OpenWebUI Extensions](https://github.com/Fu-Jie/openwebui-extensions) + +A standalone OpenWebUI Tool plugin for managing native Workspace Skills across models. + +## Key Features + +- Native skill management +- User-scoped list/show/install/create/update/delete operations +- Status-bar feedback for each operation + +## Methods + +- `list_skills` +- `show_skill` +- `install_skill` +- `create_skill` +- `update_skill` +- `delete_skill` + +## Installation + +1. Open OpenWebUI → Workspace → Tools +2. Create Tool and paste: + - `plugins/tools/openwebui-skills-manager/openwebui_skills_manager.py` +3. Save and enable for your chat/model diff --git a/docs/plugins/tools/openwebui-skills-manager-tool.zh.md b/docs/plugins/tools/openwebui-skills-manager-tool.zh.md new file mode 100644 index 0000000..3c7f43a --- /dev/null +++ b/docs/plugins/tools/openwebui-skills-manager-tool.zh.md @@ -0,0 +1,27 @@ +# OpenWebUI Skills 管理工具 + +**Author:** [Fu-Jie](https://github.com/Fu-Jie/openwebui-extensions) | **Version:** 0.2.0 | **Project:** [OpenWebUI Extensions](https://github.com/Fu-Jie/openwebui-extensions) + +一个可跨模型使用的 OpenWebUI 原生 Tool 插件,用于管理 Workspace Skills。 + +## 核心特性 + +- 原生技能管理 +- 用户范围内的 list/show/install/create/update/delete +- 每步操作提供状态栏反馈 + +## 方法 + +- `list_skills` +- `show_skill` +- `install_skill` +- `create_skill` +- `update_skill` +- `delete_skill` + +## 安装方式 + +1. 打开 OpenWebUI → Workspace → Tools +2. 新建 Tool 并粘贴: + - `plugins/tools/openwebui-skills-manager/openwebui_skills_manager.py` +3. 保存并在模型/聊天中启用 diff --git a/plugins/pipes/github-copilot-sdk/github_copilot_sdk.py b/plugins/pipes/github-copilot-sdk/github_copilot_sdk.py index e964bfa..9d391f2 100644 --- a/plugins/pipes/github-copilot-sdk/github_copilot_sdk.py +++ b/plugins/pipes/github-copilot-sdk/github_copilot_sdk.py @@ -153,6 +153,7 @@ BASE_GUIDELINES = ( "3. **Interactive Artifacts (HTML)**: **Premium Delivery Protocol**: For web applications, you MUST perform two actions:\n" " - 1. **Persist**: Create the file in the workspace (e.g., `index.html`) for project structure.\n" " - 2. **Publish & Embed**: Call `publish_file_from_workspace(filename='your_file.html')`. This will automatically trigger the **Premium Experience** by directly embedding the interactive component using the action-style return.\n" + " - **CRITICAL ANTI-INLINE RULE**: Never output your own raw HTML code blocks directly in your chat response when creating a web app, dashboard, or visual artifact. YOU MUST ALWAYS persist the HTML to a file and call `publish_file_from_workspace` to deliver it.\n" " - **CRITICAL**: When using this protocol in **Rich UI mode** (`embed_type='richui'`), **DO NOT** output the raw HTML code in a code block. Provide ONLY the **[Preview]** and **[Download]** links returned by the tool. The interactive embed will appear automatically after your message finishes.\n" " - **Artifacts mode** (`embed_type='artifacts'`): You MUST provide the **[Preview]** and **[Download]** links, AND then you MUST output the provided `html_embed` (the iframe) wrapped within a ```html code block to enable the interactive preview.\n" " - **Process Visibility**: While raw code is often replaced by links/frames, you SHOULD provide a **very brief Markdown summary** of the component's structure or key features (e.g., 'Generated login form with validation') before publishing. This keeps the user informed of the 'processing' progress.\n" diff --git a/plugins/tools/README.md b/plugins/tools/README.md new file mode 100644 index 0000000..7605aec --- /dev/null +++ b/plugins/tools/README.md @@ -0,0 +1,10 @@ +# Tools + +This directory contains OpenWebUI native Tool plugins. + +- Tool plugins can be enabled for any model that supports OpenWebUI Tools. +- Each tool plugin follows single-file implementation with bilingual docs. + +## Available Tools + +- [OpenWebUI Skills Manager Tool](./openwebui-skills-manager/README.md) diff --git a/plugins/tools/README_CN.md b/plugins/tools/README_CN.md new file mode 100644 index 0000000..7756b4a --- /dev/null +++ b/plugins/tools/README_CN.md @@ -0,0 +1,10 @@ +# Tools(工具) + +此目录包含 OpenWebUI 原生 Tool 插件。 + +- Tool 插件可用于任何启用了 OpenWebUI Tools 的模型。 +- 每个 Tool 插件采用单文件实现并提供中英文文档。 + +## 可用工具 + +- [OpenWebUI Skills 管理工具](./openwebui-skills-manager/README_CN.md) diff --git a/plugins/tools/openwebui-skills-manager/README.md b/plugins/tools/openwebui-skills-manager/README.md new file mode 100644 index 0000000..00e4ed4 --- /dev/null +++ b/plugins/tools/openwebui-skills-manager/README.md @@ -0,0 +1,56 @@ +# 🧰 OpenWebUI Skills Manager Tool + +**Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** 0.2.0 | **Project:** [OpenWebUI Extensions](https://github.com/Fu-Jie/openwebui-extensions) + +A standalone OpenWebUI Tool plugin to manage native **Workspace > Skills** for any model. + +## Key Features + +- **🌐 Model-agnostic**: Can be enabled for any model that supports OpenWebUI Tools. +- **🛠️ Simple Skill Management**: Directly manage OpenWebUI skill records. +- **🔐 User-scoped Safety**: Operates on current user's accessible skills. +- **📡 Friendly Status Feedback**: Emits status bubbles for each operation. + +## How to Use + +1. Open OpenWebUI and go to **Workspace > Tools**. +2. Create a new Tool and paste `openwebui_skills_manager.py`. +3. Enable this tool for your model/chat. +4. Ask the model to call tool operations, for example: + - "List my skills" + - "Show skill named docs-writer" + - "Create a skill named meeting-notes with content ..." + - "Update skill ..." + - "Delete skill ..." + +## Configuration (Valves) + +| Parameter | Default | Description | +|---|---:|---| +| `SHOW_STATUS` | `True` | Show operation status updates in OpenWebUI status bar. | +| `ALLOW_OVERWRITE_ON_CREATE` | `False` | Allow `create_skill`/`install_skill` to overwrite same-name skill by default. | +| `INSTALL_FETCH_TIMEOUT` | `12.0` | URL fetch timeout in seconds for skill installation. | + +## Supported Tool Methods + +| Method | Purpose | +|---|---| +| `list_skills` | List current user's skills. | +| `show_skill` | Show one skill by `skill_id` or `name`. | +| `install_skill` | Install skill from URL into OpenWebUI native skills. | +| `create_skill` | Create a new skill (or overwrite when allowed). | +| `update_skill` | Update skill fields (`new_name`, `description`, `content`, `is_active`). | +| `delete_skill` | Delete a skill by `skill_id` or `name`. | + +## Support + +If this plugin has been useful, a star on [OpenWebUI Extensions](https://github.com/Fu-Jie/openwebui-extensions) is a big motivation for me. Thank you for the support. + +## Others + +- This tool manages OpenWebUI native skill records and supports direct URL installation. +- For advanced orchestration, combine with other Pipe/Tool workflows. + +## Changelog + +See full history in the GitHub repository releases and commits. diff --git a/plugins/tools/openwebui-skills-manager/README_CN.md b/plugins/tools/openwebui-skills-manager/README_CN.md new file mode 100644 index 0000000..3a7d5a4 --- /dev/null +++ b/plugins/tools/openwebui-skills-manager/README_CN.md @@ -0,0 +1,56 @@ +# 🧰 OpenWebUI Skills 管理工具 + +**Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** 0.2.0 | **Project:** [OpenWebUI Extensions](https://github.com/Fu-Jie/openwebui-extensions) + +一个 OpenWebUI 原生 Tool 插件,用于让任意模型直接管理 **Workspace > Skills**。 + +## 核心特性 + +- **🌐 全模型可用**:只要模型启用了 OpenWebUI Tools,即可调用。 +- **🛠️ 简化技能管理**:直接管理 OpenWebUI Skills 记录。 +- **🔐 用户范围安全**:仅操作当前用户可访问的技能。 +- **📡 友好状态反馈**:每一步操作都有状态栏提示。 + +## 使用方法 + +1. 打开 OpenWebUI,进入 **Workspace > Tools**。 +2. 新建 Tool,粘贴 `openwebui_skills_manager.py`。 +3. 为当前模型/聊天启用该工具。 +4. 在对话中让模型调用,例如: + - “列出我的 skills” + - “显示名为 docs-writer 的 skill” + - “创建一个 meeting-notes 技能,内容是 ...” + - “更新某个 skill ...” + - “删除某个 skill ...” + +## 配置参数(Valves) + +| 参数 | 默认值 | 说明 | +|---|---:|---| +| `SHOW_STATUS` | `True` | 是否在 OpenWebUI 状态栏显示操作状态。 | +| `ALLOW_OVERWRITE_ON_CREATE` | `False` | 是否允许 `create_skill`/`install_skill` 默认覆盖同名技能。 | +| `INSTALL_FETCH_TIMEOUT` | `12.0` | 从 URL 安装技能时的请求超时时间(秒)。 | + +## 支持的方法 + +| 方法 | 用途 | +|---|---| +| `list_skills` | 列出当前用户的技能。 | +| `show_skill` | 通过 `skill_id` 或 `name` 查看单个技能。 | +| `install_skill` | 通过 URL 安装技能到 OpenWebUI 原生 Skills。 | +| `create_skill` | 创建新技能(或在允许时覆盖同名技能)。 | +| `update_skill` | 更新技能字段(`new_name`、`description`、`content`、`is_active`)。 | +| `delete_skill` | 通过 `skill_id` 或 `name` 删除技能。 | + +## 支持 + +如果这个插件对你有帮助,欢迎到 [OpenWebUI Extensions](https://github.com/Fu-Jie/openwebui-extensions) 点个 Star,这将是我持续改进的动力,感谢支持。 + +## 其他说明 + +- 本工具管理 OpenWebUI 原生 Skills 记录,并支持通过 URL 直接安装。 +- 如需更复杂的工作流编排,可结合其他 Pipe/Tool 方案使用。 + +## 更新记录 + +完整历史请查看 GitHub 仓库的 commits 与 releases。 diff --git a/plugins/tools/openwebui-skills-manager/openwebui_skills_manager.py b/plugins/tools/openwebui-skills-manager/openwebui_skills_manager.py new file mode 100644 index 0000000..33440aa --- /dev/null +++ b/plugins/tools/openwebui-skills-manager/openwebui_skills_manager.py @@ -0,0 +1,1241 @@ +""" +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 +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}