diff --git a/docs/plugins/actions/summary.md b/docs/plugins/actions/summary.md deleted file mode 100644 index 90b89e8..0000000 --- a/docs/plugins/actions/summary.md +++ /dev/null @@ -1,82 +0,0 @@ -# Summary - -Action -v0.1.0 - -Generate concise summaries of long text content with key points extraction. - ---- - -## Overview - -The Summary plugin helps you quickly understand long pieces of text by generating concise summaries with extracted key points. It's perfect for: - -- Summarizing long articles or documents -- Extracting key points from conversations -- Creating quick overviews of complex topics - -## Features - -- :material-text-box-search: **Smart Summarization**: AI-powered content analysis -- :material-format-list-bulleted: **Key Points**: Extracted important highlights -- :material-content-copy: **Easy Copy**: One-click copying of summaries -- :material-tune: **Adjustable Length**: Control summary detail level - ---- - -## Installation - -1. Download the plugin file: [`summary.py`](https://github.com/Fu-Jie/awesome-openwebui/tree/main/plugins/actions/summary) -2. Upload to OpenWebUI: **Admin Panel** → **Settings** → **Functions** -3. Enable the plugin - ---- - -## Usage - -1. Get a long response from the AI or paste long text -2. Click the **Summary** button in the message action bar -3. View the generated summary with key points - ---- - -## Configuration - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `summary_length` | string | `"medium"` | Length of summary (short/medium/long) | -| `include_key_points` | boolean | `true` | Extract and list key points | -| `language` | string | `"auto"` | Output language | - ---- - -## Example Output - -```markdown -## Summary - -This document discusses the implementation of a new feature -for the application, focusing on user experience improvements -and performance optimizations. - -### Key Points - -- ✅ New user interface design improves accessibility -- ✅ Backend optimizations reduce load times by 40% -- ✅ Mobile responsiveness enhanced -- ✅ Integration with third-party services simplified -``` - ---- - -## Requirements - -!!! note "Prerequisites" - - OpenWebUI v0.3.0 or later - - Uses the active LLM model for summarization - ---- - -## Source Code - -[:fontawesome-brands-github: View on GitHub](https://github.com/Fu-Jie/awesome-openwebui/tree/main/plugins/actions/summary){ .md-button } diff --git a/docs/plugins/actions/summary.zh.md b/docs/plugins/actions/summary.zh.md deleted file mode 100644 index f89d405..0000000 --- a/docs/plugins/actions/summary.zh.md +++ /dev/null @@ -1,82 +0,0 @@ -# Summary(摘要) - -Action -v0.1.0 - -为长文本生成简洁摘要,并提取关键要点。 - ---- - -## 概览 - -Summary 插件可以快速理解长文本,生成精炼摘要并列出关键点,适合: - -- 总结长文章或文档 -- 从对话中提炼要点 -- 为复杂主题制作快速概览 - -## 功能特性 - -- :material-text-box-search: **智能摘要**:AI 驱动的内容分析 -- :material-format-list-bulleted: **关键点**:提取重要信息 -- :material-content-copy: **便捷复制**:一键复制摘要 -- :material-tune: **长度可调**:可选择摘要详略程度 - ---- - -## 安装 - -1. 下载插件文件:[`summary.py`](https://github.com/Fu-Jie/awesome-openwebui/tree/main/plugins/actions/summary) -2. 上传到 OpenWebUI:**Admin Panel** → **Settings** → **Functions** -3. 启用插件 - ---- - -## 使用方法 - -1. 获取一段较长的 AI 回复或粘贴长文本 -2. 点击消息操作栏的 **Summary** 按钮 -3. 查看生成的摘要与关键点 - ---- - -## 配置项 - -| 选项 | 类型 | 默认值 | 说明 | -|--------|------|---------|-------------| -| `summary_length` | string | `"medium"` | 摘要长度(short/medium/long) | -| `include_key_points` | boolean | `true` | 是否提取并列出关键点 | -| `language` | string | `"auto"` | 输出语言 | - ---- - -## 输出示例 - -```markdown -## Summary - -This document discusses the implementation of a new feature -for the application, focusing on user experience improvements -and performance optimizations. - -### Key Points - -- ✅ New user interface design improves accessibility -- ✅ Backend optimizations reduce load times by 40% -- ✅ Mobile responsiveness enhanced -- ✅ Integration with third-party services simplified -``` - ---- - -## 运行要求 - -!!! note "前置条件" - - OpenWebUI v0.3.0 及以上 - - 使用当前会话的 LLM 模型进行摘要 - ---- - -## 源码 - -[:fontawesome-brands-github: 在 GitHub 查看](https://github.com/Fu-Jie/awesome-openwebui/tree/main/plugins/actions/summary){ .md-button } diff --git a/plugins/actions/export_to_docx/export_to_word.png b/plugins/actions/export_to_docx/export_to_word.png new file mode 100644 index 0000000..34d5396 Binary files /dev/null and b/plugins/actions/export_to_docx/export_to_word.png differ diff --git a/plugins/actions/export_to_docx/export_to_word_cn.png b/plugins/actions/export_to_docx/export_to_word_cn.png new file mode 100644 index 0000000..bdd394c Binary files /dev/null and b/plugins/actions/export_to_docx/export_to_word_cn.png differ diff --git a/plugins/actions/infographic/infographic.png b/plugins/actions/infographic/infographic.png new file mode 100644 index 0000000..3fa64f1 Binary files /dev/null and b/plugins/actions/infographic/infographic.png differ diff --git a/plugins/actions/infographic/infographic_cn.png b/plugins/actions/infographic/infographic_cn.png new file mode 100644 index 0000000..65204d1 Binary files /dev/null and b/plugins/actions/infographic/infographic_cn.png differ diff --git a/plugins/actions/js-render-poc/README.md b/plugins/actions/js-render-poc/README.md deleted file mode 100644 index f749229..0000000 --- a/plugins/actions/js-render-poc/README.md +++ /dev/null @@ -1,170 +0,0 @@ -# Infographic to Markdown - -> **Version:** 1.0.0 - -AI-powered infographic generator that renders SVG on the frontend and embeds it directly into Markdown as a Data URL image. - -## Overview - -This plugin combines the power of AI text analysis with AntV Infographic visualization to create beautiful infographics that are embedded directly into chat messages as Markdown images. - -### How It Works - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Open WebUI Plugin │ -├─────────────────────────────────────────────────────────────┤ -│ 1. Python Action │ -│ ├── Receive message content │ -│ ├── Call LLM to generate Infographic syntax │ -│ └── Send __event_call__ to execute frontend JS │ -├─────────────────────────────────────────────────────────────┤ -│ 2. Browser JS (via __event_call__) │ -│ ├── Dynamically load AntV Infographic library │ -│ ├── Render SVG offscreen │ -│ ├── Export to Data URL via toDataURL() │ -│ └── Update message content via REST API │ -├─────────────────────────────────────────────────────────────┤ -│ 3. Markdown Rendering │ -│ └── Display ![description](data:image/svg+xml;base64,...) │ -└─────────────────────────────────────────────────────────────┘ -``` - -## Features - -- 🤖 **AI-Powered**: Automatically analyzes text and selects the best infographic template -- 📊 **Multiple Templates**: Supports 18+ infographic templates (lists, charts, comparisons, etc.) -- 🖼️ **Self-Contained**: SVG/PNG embedded as Data URL, no external dependencies -- 📝 **Markdown Native**: Results are pure Markdown images, compatible everywhere -- 🔄 **API Writeback**: Updates message content via REST API for persistence - -## Plugins in This Directory - -### 1. `infographic_markdown.py` - Main Plugin ⭐ -- **Purpose**: Production use -- **Features**: Full AI + AntV Infographic + Data URL embedding - -### 2. `js_render_poc.py` - Proof of Concept -- **Purpose**: Learning and testing -- **Features**: Simple SVG creation demo, `__event_call__` pattern - -## Configuration (Valves) - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `SHOW_STATUS` | bool | `true` | Show operation status updates | -| `MODEL_ID` | string | `""` | LLM model ID (empty = use current model) | -| `MIN_TEXT_LENGTH` | int | `50` | Minimum text length required | -| `MESSAGE_COUNT` | int | `1` | Number of recent messages to use | -| `SVG_WIDTH` | int | `800` | Width of generated SVG (pixels) | -| `EXPORT_FORMAT` | string | `"svg"` | Export format: `svg` or `png` | - -## Supported Templates - -| Category | Template | Description | -|----------|----------|-------------| -| List | `list-grid` | Grid cards | -| List | `list-vertical` | Vertical list | -| Tree | `tree-vertical` | Vertical tree | -| Tree | `tree-horizontal` | Horizontal tree | -| Mind Map | `mindmap` | Mind map | -| Process | `sequence-roadmap` | Roadmap | -| Process | `sequence-zigzag` | Zigzag process | -| Relation | `relation-sankey` | Sankey diagram | -| Relation | `relation-circle` | Circular relation | -| Compare | `compare-binary` | Binary comparison | -| Analysis | `compare-swot` | SWOT analysis | -| Quadrant | `quadrant-quarter` | Quadrant chart | -| Chart | `chart-bar` | Bar chart | -| Chart | `chart-column` | Column chart | -| Chart | `chart-line` | Line chart | -| Chart | `chart-pie` | Pie chart | -| Chart | `chart-doughnut` | Doughnut chart | -| Chart | `chart-area` | Area chart | - -## Syntax Examples - -### Grid List -```infographic -infographic list-grid -data - title Project Overview - items - - label Module A - desc Description of module A - - label Module B - desc Description of module B -``` - -### Binary Comparison -```infographic -infographic compare-binary -data - title Pros vs Cons - items - - label Pros - children - - label Strong R&D - desc Technology leadership - - label Cons - children - - label Weak brand - desc Insufficient marketing -``` - -### Bar Chart -```infographic -infographic chart-bar -data - title Quarterly Revenue - items - - label Q1 - value 120 - - label Q2 - value 150 -``` - -## Technical Details - -### Data URL Embedding -```javascript -// SVG to Base64 Data URL -const svgData = new XMLSerializer().serializeToString(svg); -const base64 = btoa(unescape(encodeURIComponent(svgData))); -const dataUri = "data:image/svg+xml;base64," + base64; - -// Markdown image syntax -const markdownImage = `![description](${dataUri})`; -``` - -### AntV toDataURL API -```javascript -// Export as SVG (recommended, supports embedded resources) -const svgUrl = await instance.toDataURL({ - type: 'svg', - embedResources: true -}); - -// Export as PNG (more compatible but larger) -const pngUrl = await instance.toDataURL({ - type: 'png', - dpr: 2 -}); -``` - -## Notes - -1. **Browser Compatibility**: Requires modern browsers with ES6+ and Fetch API support -2. **Network Dependency**: First use requires loading AntV library from CDN -3. **Data URL Size**: Base64 encoding increases size by ~33% -4. **Chinese Fonts**: SVG export embeds fonts for correct display - -## Related Resources - -- [AntV Infographic Documentation](https://infographic.antv.vision/) -- [Infographic API Reference](https://infographic.antv.vision/reference/infographic-api) -- [Infographic Syntax Guide](https://infographic.antv.vision/learn/infographic-syntax) - -## License - -MIT License diff --git a/plugins/actions/js-render-poc/README_CN.md b/plugins/actions/js-render-poc/README_CN.md deleted file mode 100644 index 62e7496..0000000 --- a/plugins/actions/js-render-poc/README_CN.md +++ /dev/null @@ -1,174 +0,0 @@ -# 信息图转 Markdown - -> **版本:** 1.0.0 - -AI 驱动的信息图生成器,在前端渲染 SVG 并以 Data URL 图片格式直接嵌入到 Markdown 中。 - -## 概述 - -这个插件结合了 AI 文本分析能力和 AntV Infographic 可视化引擎,生成精美的信息图并以 Markdown 图片格式直接嵌入到聊天消息中。 - -### 工作原理 - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Open WebUI 插件 │ -├─────────────────────────────────────────────────────────────┤ -│ 1. Python Action │ -│ ├── 接收消息内容 │ -│ ├── 调用 LLM 生成 Infographic 语法 │ -│ └── 发送 __event_call__ 执行前端 JS │ -├─────────────────────────────────────────────────────────────┤ -│ 2. 浏览器 JS (通过 __event_call__) │ -│ ├── 动态加载 AntV Infographic 库 │ -│ ├── 离屏渲染 SVG │ -│ ├── 使用 toDataURL() 导出 Data URL │ -│ └── 通过 REST API 更新消息内容 │ -├─────────────────────────────────────────────────────────────┤ -│ 3. Markdown 渲染 │ -│ └── 显示 ![描述](data:image/svg+xml;base64,...) │ -└─────────────────────────────────────────────────────────────┘ -``` - -## 功能特点 - -- 🤖 **AI 驱动**: 自动分析文本并选择最佳的信息图模板 -- 📊 **多种模板**: 支持 18+ 种信息图模板(列表、图表、对比等) -- 🖼️ **自包含**: SVG/PNG 以 Data URL 嵌入,无外部依赖 -- 📝 **Markdown 原生**: 结果是纯 Markdown 图片,兼容任何平台 -- 🔄 **API 回写**: 通过 REST API 更新消息内容实现持久化 - -## 目录中的插件 - -### 1. `infographic_markdown.py` - 主插件 ⭐ -- **用途**: 生产使用 -- **功能**: 完整的 AI + AntV Infographic + Data URL 嵌入 - -### 2. `infographic_markdown_cn.py` - 主插件(中文版) -- **用途**: 生产使用 -- **功能**: 与英文版相同,界面文字为中文 - -### 3. `js_render_poc.py` - 概念验证 -- **用途**: 学习和测试 -- **功能**: 简单的 SVG 创建演示,`__event_call__` 模式 - -## 配置选项 (Valves) - -| 参数 | 类型 | 默认值 | 描述 | -|------|------|--------|------| -| `SHOW_STATUS` | bool | `true` | 是否显示操作状态 | -| `MODEL_ID` | string | `""` | LLM 模型 ID(空则使用当前模型) | -| `MIN_TEXT_LENGTH` | int | `50` | 最小文本长度要求 | -| `MESSAGE_COUNT` | int | `1` | 用于生成的最近消息数量 | -| `SVG_WIDTH` | int | `800` | 生成的 SVG 宽度(像素) | -| `EXPORT_FORMAT` | string | `"svg"` | 导出格式:`svg` 或 `png` | - -## 支持的模板 - -| 类别 | 模板名称 | 描述 | -|------|----------|------| -| 列表 | `list-grid` | 网格卡片 | -| 列表 | `list-vertical` | 垂直列表 | -| 树形 | `tree-vertical` | 垂直树 | -| 树形 | `tree-horizontal` | 水平树 | -| 思维导图 | `mindmap` | 思维导图 | -| 流程 | `sequence-roadmap` | 路线图 | -| 流程 | `sequence-zigzag` | 折线流程 | -| 关系 | `relation-sankey` | 桑基图 | -| 关系 | `relation-circle` | 圆形关系 | -| 对比 | `compare-binary` | 二元对比 | -| 分析 | `compare-swot` | SWOT 分析 | -| 象限 | `quadrant-quarter` | 四象限图 | -| 图表 | `chart-bar` | 条形图 | -| 图表 | `chart-column` | 柱状图 | -| 图表 | `chart-line` | 折线图 | -| 图表 | `chart-pie` | 饼图 | -| 图表 | `chart-doughnut` | 环形图 | -| 图表 | `chart-area` | 面积图 | - -## 语法示例 - -### 网格列表 -```infographic -infographic list-grid -data - title 项目概览 - items - - label 模块一 - desc 这是第一个模块的描述 - - label 模块二 - desc 这是第二个模块的描述 -``` - -### 二元对比 -```infographic -infographic compare-binary -data - title 优劣对比 - items - - label 优势 - children - - label 研发能力强 - desc 技术领先 - - label 劣势 - children - - label 品牌曝光不足 - desc 营销力度不够 -``` - -### 条形图 -```infographic -infographic chart-bar -data - title 季度收入 - items - - label Q1 - value 120 - - label Q2 - value 150 -``` - -## 技术细节 - -### Data URL 嵌入 -```javascript -// SVG 转 Base64 Data URL -const svgData = new XMLSerializer().serializeToString(svg); -const base64 = btoa(unescape(encodeURIComponent(svgData))); -const dataUri = "data:image/svg+xml;base64," + base64; - -// Markdown 图片语法 -const markdownImage = `![描述](${dataUri})`; -``` - -### AntV toDataURL API -```javascript -// 导出 SVG(推荐,支持嵌入资源) -const svgUrl = await instance.toDataURL({ - type: 'svg', - embedResources: true -}); - -// 导出 PNG(更兼容但体积更大) -const pngUrl = await instance.toDataURL({ - type: 'png', - dpr: 2 -}); -``` - -## 注意事项 - -1. **浏览器兼容性**: 需要现代浏览器支持 ES6+ 和 Fetch API -2. **网络依赖**: 首次使用需要从 CDN 加载 AntV Infographic 库 -3. **Data URL 大小**: Base64 编码会增加约 33% 的体积 -4. **中文字体**: SVG 导出时会嵌入字体以确保正确显示 - -## 相关资源 - -- [AntV Infographic 官方文档](https://infographic.antv.vision/) -- [Infographic API 参考](https://infographic.antv.vision/reference/infographic-api) -- [Infographic 语法规范](https://infographic.antv.vision/learn/infographic-syntax) - -## 许可证 - -MIT License diff --git a/plugins/actions/js-render-poc/infographic_markdown.py b/plugins/actions/js-render-poc/infographic_markdown.py deleted file mode 100644 index 2a66e63..0000000 --- a/plugins/actions/js-render-poc/infographic_markdown.py +++ /dev/null @@ -1,592 +0,0 @@ -""" -title: 📊 Infographic to Markdown -author: Fu-Jie -version: 1.0.0 -description: AI生成信息图语法,前端渲染SVG并转换为Markdown图片格式嵌入消息。支持AntV Infographic模板。 -""" - -import time -import json -import logging -import re -from typing import Optional, Callable, Awaitable, Any, Dict -from pydantic import BaseModel, Field -from fastapi import Request -from datetime import datetime - -from open_webui.utils.chat import generate_chat_completion -from open_webui.models.users import Users - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -# ================================================================= -# LLM Prompts -# ================================================================= - -SYSTEM_PROMPT_INFOGRAPHIC = """ -You are a professional infographic design expert who can analyze user-provided text content and convert it into AntV Infographic syntax format. - -## Infographic Syntax Specification - -Infographic syntax is a Mermaid-like declarative syntax for describing infographic templates, data, and themes. - -### Syntax Rules -- Entry uses `infographic ` -- Key-value pairs are separated by spaces, **absolutely NO colons allowed** -- Use two spaces for indentation -- Object arrays use `-` with line breaks - -⚠️ **IMPORTANT WARNING: This is NOT YAML format!** -- ❌ Wrong: `children:` `items:` `data:` (with colons) -- ✅ Correct: `children` `items` `data` (without colons) - -### Template Library & Selection Guide - -Choose the most appropriate template based on the content structure: - -#### 1. List & Hierarchy -- **List**: `list-grid` (Grid Cards), `list-vertical` (Vertical List) -- **Tree**: `tree-vertical` (Vertical Tree), `tree-horizontal` (Horizontal Tree) -- **Mindmap**: `mindmap` (Mind Map) - -#### 2. Sequence & Relationship -- **Process**: `sequence-roadmap` (Roadmap), `sequence-zigzag` (Zigzag Process) -- **Relationship**: `relation-sankey` (Sankey Diagram), `relation-circle` (Circular) - -#### 3. Comparison & Analysis -- **Comparison**: `compare-binary` (Binary Comparison) -- **Analysis**: `compare-swot` (SWOT Analysis), `quadrant-quarter` (Quadrant Chart) - -#### 4. Charts & Data -- **Charts**: `chart-bar`, `chart-column`, `chart-line`, `chart-pie`, `chart-doughnut`, `chart-area` - -### Data Structure Examples - -#### A. Standard List/Tree -```infographic -infographic list-grid -data - title Project Modules - items - - label Module A - desc Description of A - - label Module B - desc Description of B -``` - -#### B. Binary Comparison -```infographic -infographic compare-binary -data - title Advantages vs Disadvantages - items - - label Advantages - children - - label Strong R&D - desc Leading technology - - label Disadvantages - children - - label Weak brand - desc Insufficient marketing -``` - -#### C. Charts -```infographic -infographic chart-bar -data - title Quarterly Revenue - items - - label Q1 - value 120 - - label Q2 - value 150 -``` - -### Common Data Fields -- `label`: Main title/label (Required) -- `desc`: Description text (max 30 Chinese chars / 60 English chars for `list-grid`) -- `value`: Numeric value (for charts) -- `children`: Nested items - -## Output Requirements -1. **Language**: Output content in the user's language. -2. **Format**: Wrap output in ```infographic ... ```. -3. **No Colons**: Do NOT use colons after keys. -4. **Indentation**: Use 2 spaces. -""" - -USER_PROMPT_GENERATE = """ -Please analyze the following text content and convert its core information into AntV Infographic syntax format. - ---- -**User Context:** -User Name: {user_name} -Current Date/Time: {current_date_time_str} -User Language: {user_language} ---- - -**Text Content:** -{long_text_content} - -Please select the most appropriate infographic template based on text characteristics and output standard infographic syntax. - -**Important Note:** -- If using `list-grid` format, ensure each card's `desc` description is limited to **maximum 30 Chinese characters** (or **approximately 60 English characters**). -- Descriptions should be concise and highlight key points. -""" - - -class Action: - class Valves(BaseModel): - SHOW_STATUS: bool = Field( - default=True, description="Show operation status updates in chat interface." - ) - MODEL_ID: str = Field( - default="", - description="LLM model ID for text analysis. If empty, uses current conversation model.", - ) - MIN_TEXT_LENGTH: int = Field( - default=50, - description="Minimum text length (characters) required for infographic analysis.", - ) - MESSAGE_COUNT: int = Field( - default=1, - description="Number of recent messages to use for generation.", - ) - SVG_WIDTH: int = Field( - default=800, - description="Width of generated SVG in pixels.", - ) - EXPORT_FORMAT: str = Field( - default="svg", - description="Export format: 'svg' or 'png'.", - ) - - def __init__(self): - self.valves = self.Valves() - - def _extract_chat_id(self, body: dict, metadata: Optional[dict]) -> str: - """Extract chat_id from body or metadata""" - if isinstance(body, dict): - chat_id = body.get("chat_id") - if isinstance(chat_id, str) and chat_id.strip(): - return chat_id.strip() - - body_metadata = body.get("metadata", {}) - if isinstance(body_metadata, dict): - chat_id = body_metadata.get("chat_id") - if isinstance(chat_id, str) and chat_id.strip(): - return chat_id.strip() - - if isinstance(metadata, dict): - chat_id = metadata.get("chat_id") - if isinstance(chat_id, str) and chat_id.strip(): - return chat_id.strip() - - return "" - - def _extract_message_id(self, body: dict, metadata: Optional[dict]) -> str: - """Extract message_id from body or metadata""" - if isinstance(body, dict): - message_id = body.get("id") - if isinstance(message_id, str) and message_id.strip(): - return message_id.strip() - - body_metadata = body.get("metadata", {}) - if isinstance(body_metadata, dict): - message_id = body_metadata.get("message_id") - if isinstance(message_id, str) and message_id.strip(): - return message_id.strip() - - if isinstance(metadata, dict): - message_id = metadata.get("message_id") - if isinstance(message_id, str) and message_id.strip(): - return message_id.strip() - - return "" - - def _extract_infographic_syntax(self, llm_output: str) -> str: - """Extract infographic syntax from LLM output""" - match = re.search(r"```infographic\s*(.*?)\s*```", llm_output, re.DOTALL) - if match: - return match.group(1).strip() - else: - logger.warning("LLM output did not follow expected format, treating entire output as syntax.") - return llm_output.strip() - - def _extract_text_content(self, content) -> str: - """Extract text from message content, supporting multimodal formats""" - if isinstance(content, str): - return content - elif isinstance(content, list): - text_parts = [] - for item in content: - if isinstance(item, dict) and item.get("type") == "text": - text_parts.append(item.get("text", "")) - elif isinstance(item, str): - text_parts.append(item) - return "\n".join(text_parts) - return str(content) if content else "" - - async def _emit_status(self, emitter, description: str, done: bool = False): - """Send status update event""" - if self.valves.SHOW_STATUS and emitter: - await emitter( - {"type": "status", "data": {"description": description, "done": done}} - ) - - def _generate_js_code( - self, - unique_id: str, - chat_id: str, - message_id: str, - infographic_syntax: str, - svg_width: int, - export_format: str, - ) -> str: - """Generate JavaScript code for frontend SVG rendering""" - - # Escape the syntax for JS embedding - syntax_escaped = ( - infographic_syntax - .replace("\\", "\\\\") - .replace("`", "\\`") - .replace("${", "\\${") - .replace("", "<\\/script>") - ) - - # Template mapping (same as infographic.py) - template_mapping_js = """ - const TEMPLATE_MAPPING = { - 'list-grid': 'list-grid-compact-card', - 'list-vertical': 'list-column-simple-vertical-arrow', - 'tree-vertical': 'hierarchy-tree-tech-style-capsule-item', - 'tree-horizontal': 'hierarchy-tree-lr-tech-style-capsule-item', - 'mindmap': 'hierarchy-mindmap-branch-gradient-capsule-item', - 'sequence-roadmap': 'sequence-roadmap-vertical-simple', - 'sequence-zigzag': 'sequence-horizontal-zigzag-simple', - 'sequence-horizontal': 'sequence-horizontal-zigzag-simple', - 'relation-sankey': 'relation-sankey-simple', - 'relation-circle': 'relation-circle-icon-badge', - 'compare-binary': 'compare-binary-horizontal-simple-vs', - 'compare-swot': 'compare-swot', - 'quadrant-quarter': 'quadrant-quarter-simple-card', - 'statistic-card': 'list-grid-compact-card', - 'chart-bar': 'chart-bar-plain-text', - 'chart-column': 'chart-column-simple', - 'chart-line': 'chart-line-plain-text', - 'chart-area': 'chart-area-simple', - 'chart-pie': 'chart-pie-plain-text', - 'chart-doughnut': 'chart-pie-donut-plain-text' - }; - """ - - return f""" -(async function() {{ - const uniqueId = "{unique_id}"; - const chatId = "{chat_id}"; - const messageId = "{message_id}"; - const svgWidth = {svg_width}; - const exportFormat = "{export_format}"; - - console.log("[Infographic Markdown] Starting render..."); - console.log("[Infographic Markdown] chatId:", chatId, "messageId:", messageId); - - try {{ - // Load AntV Infographic if not loaded - if (typeof AntVInfographic === 'undefined') {{ - console.log("[Infographic Markdown] Loading AntV Infographic library..."); - await new Promise((resolve, reject) => {{ - const script = document.createElement('script'); - script.src = 'https://unpkg.com/@antv/infographic@latest/dist/infographic.min.js'; - script.onload = resolve; - script.onerror = reject; - document.head.appendChild(script); - }}); - console.log("[Infographic Markdown] Library loaded."); - }} - - const {{ Infographic }} = AntVInfographic; - - // Get infographic syntax - let syntaxContent = `{syntax_escaped}`; - console.log("[Infographic Markdown] Original syntax:", syntaxContent.substring(0, 200) + "..."); - - // Clean up syntax - const backtick = String.fromCharCode(96); - const prefix = backtick + backtick + backtick + 'infographic'; - const simplePrefix = backtick + backtick + backtick; - - if (syntaxContent.toLowerCase().startsWith(prefix)) {{ - syntaxContent = syntaxContent.substring(prefix.length).trim(); - }} else if (syntaxContent.startsWith(simplePrefix)) {{ - syntaxContent = syntaxContent.substring(simplePrefix.length).trim(); - }} - - if (syntaxContent.endsWith(simplePrefix)) {{ - syntaxContent = syntaxContent.substring(0, syntaxContent.length - simplePrefix.length).trim(); - }} - - // Fix colons after keywords - syntaxContent = syntaxContent.replace(/^(data|items|children|theme|config):/gm, '$1'); - syntaxContent = syntaxContent.replace(/(\\s)(children|items):/g, '$1$2'); - - // Ensure infographic prefix - if (!syntaxContent.trim().toLowerCase().startsWith('infographic')) {{ - syntaxContent = 'infographic list-grid\\n' + syntaxContent; - }} - - // Apply template mapping - {template_mapping_js} - - for (const [key, value] of Object.entries(TEMPLATE_MAPPING)) {{ - const regex = new RegExp(`infographic\\\\s+${{key}}(?=\\\\s|$)`, 'i'); - if (regex.test(syntaxContent)) {{ - console.log(`[Infographic Markdown] Auto-mapping: ${{key}} -> ${{value}}`); - syntaxContent = syntaxContent.replace(regex, `infographic ${{value}}`); - break; - }} - }} - - console.log("[Infographic Markdown] Cleaned syntax:", syntaxContent.substring(0, 200) + "..."); - - // Create offscreen container - const container = document.createElement('div'); - container.id = 'infographic-offscreen-' + uniqueId; - container.style.cssText = 'position:absolute;left:-9999px;top:-9999px;width:' + svgWidth + 'px;'; - document.body.appendChild(container); - - // Create and render infographic - const instance = new Infographic({{ - container: '#' + container.id, - width: svgWidth, - padding: 24, - }}); - - console.log("[Infographic Markdown] Rendering infographic..."); - instance.render(syntaxContent); - - // Wait for render and export - await new Promise(resolve => setTimeout(resolve, 1000)); - - let dataUrl; - if (exportFormat === 'png') {{ - dataUrl = await instance.toDataURL({{ type: 'png', dpr: 2 }}); - }} else {{ - dataUrl = await instance.toDataURL({{ type: 'svg', embedResources: true }}); - }} - - console.log("[Infographic Markdown] Data URL generated, length:", dataUrl.length); - - // Cleanup - instance.destroy(); - document.body.removeChild(container); - - // Generate markdown image - const markdownImage = `![📊 AI 生成的信息图](${{dataUrl}})`; - - // Update message via API - if (chatId && messageId) {{ - const token = localStorage.getItem("token"); - - // Get current message content - const getResponse = await fetch(`/api/v1/chats/${{chatId}}`, {{ - method: "GET", - headers: {{ "Authorization": `Bearer ${{token}}` }} - }}); - - if (!getResponse.ok) {{ - throw new Error("Failed to get chat data: " + getResponse.status); - }} - - const chatData = await getResponse.json(); - let originalContent = ""; - - if (chatData.chat && chatData.chat.messages) {{ - const targetMsg = chatData.chat.messages.find(m => m.id === messageId); - if (targetMsg && targetMsg.content) {{ - originalContent = targetMsg.content; - }} - }} - - // Remove existing infographic images - const infographicPattern = /\\n*!\\[📊[^\\]]*\\]\\(data:image\\/[^)]+\\)/g; - let cleanedContent = originalContent.replace(infographicPattern, ""); - cleanedContent = cleanedContent.replace(/\\n{{3,}}/g, "\\n\\n").trim(); - - // Append new image - const newContent = cleanedContent + "\\n\\n" + markdownImage; - - // Update message - const updateResponse = await fetch(`/api/v1/chats/${{chatId}}/messages/${{messageId}}/event`, {{ - method: "POST", - headers: {{ - "Content-Type": "application/json", - "Authorization": `Bearer ${{token}}` - }}, - body: JSON.stringify({{ - type: "chat:message", - data: {{ content: newContent }} - }}) - }}); - - if (updateResponse.ok) {{ - console.log("[Infographic Markdown] ✅ Message updated successfully!"); - }} else {{ - console.error("[Infographic Markdown] API error:", updateResponse.status); - }} - }} else {{ - console.warn("[Infographic Markdown] ⚠️ Missing chatId or messageId"); - }} - - }} catch (error) {{ - console.error("[Infographic Markdown] Error:", error); - }} -}})(); -""" - - async def action( - self, - body: dict, - __user__: dict = None, - __event_emitter__=None, - __event_call__: Optional[Callable[[Any], Awaitable[None]]] = None, - __metadata__: Optional[dict] = None, - __request__: Request = None, - ) -> dict: - """ - Generate infographic using AntV and embed as Markdown image. - """ - logger.info("Action: Infographic to Markdown started") - - # Get user information - if isinstance(__user__, (list, tuple)): - user_language = __user__[0].get("language", "en") if __user__ else "en" - user_name = __user__[0].get("name", "User") if __user__[0] else "User" - user_id = __user__[0].get("id", "unknown_user") if __user__ else "unknown_user" - elif isinstance(__user__, dict): - user_language = __user__.get("language", "en") - user_name = __user__.get("name", "User") - user_id = __user__.get("id", "unknown_user") - else: - user_language = "en" - user_name = "User" - user_id = "unknown_user" - - # Get current time - now = datetime.now() - current_date_time_str = now.strftime("%Y-%m-%d %H:%M:%S") - - try: - messages = body.get("messages", []) - if not messages: - raise ValueError("No messages available.") - - # Get recent messages - message_count = min(self.valves.MESSAGE_COUNT, len(messages)) - recent_messages = messages[-message_count:] - - # Aggregate content - aggregated_parts = [] - for msg in recent_messages: - text_content = self._extract_text_content(msg.get("content")) - if text_content: - aggregated_parts.append(text_content) - - if not aggregated_parts: - raise ValueError("No text content found in messages.") - - long_text_content = "\n\n---\n\n".join(aggregated_parts) - - # Remove existing HTML blocks - parts = re.split(r"```html.*?```", long_text_content, flags=re.DOTALL) - clean_content = "" - for part in reversed(parts): - if part.strip(): - clean_content = part.strip() - break - - if not clean_content: - clean_content = long_text_content.strip() - - # Check minimum length - if len(clean_content) < self.valves.MIN_TEXT_LENGTH: - await self._emit_status( - __event_emitter__, - f"⚠️ 内容太短 ({len(clean_content)} 字符),至少需要 {self.valves.MIN_TEXT_LENGTH} 字符", - True, - ) - return body - - await self._emit_status(__event_emitter__, "📊 正在分析内容...", False) - - # Generate infographic syntax via LLM - formatted_user_prompt = USER_PROMPT_GENERATE.format( - user_name=user_name, - current_date_time_str=current_date_time_str, - user_language=user_language, - long_text_content=clean_content, - ) - - target_model = self.valves.MODEL_ID or body.get("model") - - llm_payload = { - "model": target_model, - "messages": [ - {"role": "system", "content": SYSTEM_PROMPT_INFOGRAPHIC}, - {"role": "user", "content": formatted_user_prompt}, - ], - "stream": False, - } - - user_obj = Users.get_user_by_id(user_id) - if not user_obj: - raise ValueError(f"Unable to get user object: {user_id}") - - await self._emit_status(__event_emitter__, "📊 AI 正在生成信息图语法...", False) - - llm_response = await generate_chat_completion(__request__, llm_payload, user_obj) - - if not llm_response or "choices" not in llm_response or not llm_response["choices"]: - raise ValueError("Invalid LLM response.") - - assistant_content = llm_response["choices"][0]["message"]["content"] - infographic_syntax = self._extract_infographic_syntax(assistant_content) - - logger.info(f"Generated syntax: {infographic_syntax[:200]}...") - - # Extract IDs for API callback - chat_id = self._extract_chat_id(body, __metadata__) - message_id = self._extract_message_id(body, __metadata__) - unique_id = f"ig_{int(time.time() * 1000)}" - - await self._emit_status(__event_emitter__, "📊 正在渲染 SVG...", False) - - # Execute JS to render and embed - if __event_call__: - js_code = self._generate_js_code( - unique_id=unique_id, - chat_id=chat_id, - message_id=message_id, - infographic_syntax=infographic_syntax, - svg_width=self.valves.SVG_WIDTH, - export_format=self.valves.EXPORT_FORMAT, - ) - - await __event_call__( - { - "type": "execute", - "data": {"code": js_code}, - } - ) - - await self._emit_status(__event_emitter__, "✅ 信息图生成完成!", True) - logger.info("Infographic to Markdown completed") - - except Exception as e: - error_message = f"Infographic generation failed: {str(e)}" - logger.error(error_message, exc_info=True) - await self._emit_status(__event_emitter__, f"❌ {error_message}", True) - - return body diff --git a/plugins/actions/js-render-poc/infographic_markdown_cn.py b/plugins/actions/js-render-poc/infographic_markdown_cn.py deleted file mode 100644 index 5ee8f90..0000000 --- a/plugins/actions/js-render-poc/infographic_markdown_cn.py +++ /dev/null @@ -1,592 +0,0 @@ -""" -title: 📊 信息图转 Markdown -author: Fu-Jie -version: 1.0.0 -description: AI 生成信息图语法,前端渲染 SVG 并转换为 Markdown 图片格式嵌入消息。支持 AntV Infographic 模板。 -""" - -import time -import json -import logging -import re -from typing import Optional, Callable, Awaitable, Any, Dict -from pydantic import BaseModel, Field -from fastapi import Request -from datetime import datetime - -from open_webui.utils.chat import generate_chat_completion -from open_webui.models.users import Users - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -# ================================================================= -# LLM 提示词 -# ================================================================= - -SYSTEM_PROMPT_INFOGRAPHIC = """ -你是一位专业的信息图设计专家,能够分析用户提供的文本内容并将其转换为 AntV Infographic 语法格式。 - -## 信息图语法规范 - -信息图语法是一种类似 Mermaid 的声明式语法,用于描述信息图模板、数据和主题。 - -### 语法规则 -- 入口使用 `infographic <模板名>` -- 键值对用空格分隔,**绝对不允许使用冒号** -- 使用两个空格缩进 -- 对象数组使用 `-` 加换行 - -⚠️ **重要警告:这不是 YAML 格式!** -- ❌ 错误:`children:` `items:` `data:`(带冒号) -- ✅ 正确:`children` `items` `data`(不带冒号) - -### 模板库与选择指南 - -根据内容结构选择最合适的模板: - -#### 1. 列表与层级 -- **列表**:`list-grid`(网格卡片)、`list-vertical`(垂直列表) -- **树形**:`tree-vertical`(垂直树)、`tree-horizontal`(水平树) -- **思维导图**:`mindmap`(思维导图) - -#### 2. 序列与关系 -- **流程**:`sequence-roadmap`(路线图)、`sequence-zigzag`(折线流程) -- **关系**:`relation-sankey`(桑基图)、`relation-circle`(圆形关系) - -#### 3. 对比与分析 -- **对比**:`compare-binary`(二元对比) -- **分析**:`compare-swot`(SWOT 分析)、`quadrant-quarter`(象限图) - -#### 4. 图表与数据 -- **图表**:`chart-bar`、`chart-column`、`chart-line`、`chart-pie`、`chart-doughnut`、`chart-area` - -### 数据结构示例 - -#### A. 标准列表/树形 -```infographic -infographic list-grid -data - title 项目模块 - items - - label 模块 A - desc 模块 A 的描述 - - label 模块 B - desc 模块 B 的描述 -``` - -#### B. 二元对比 -```infographic -infographic compare-binary -data - title 优势与劣势 - items - - label 优势 - children - - label 研发能力强 - desc 技术领先 - - label 劣势 - children - - label 品牌曝光弱 - desc 营销不足 -``` - -#### C. 图表 -```infographic -infographic chart-bar -data - title 季度收入 - items - - label Q1 - value 120 - - label Q2 - value 150 -``` - -### 常用数据字段 -- `label`:主标题/标签(必填) -- `desc`:描述文字(`list-grid` 最多 30 个中文字符) -- `value`:数值(用于图表) -- `children`:嵌套项 - -## 输出要求 -1. **语言**:使用用户的语言输出内容。 -2. **格式**:用 ```infographic ... ``` 包裹输出。 -3. **无冒号**:键后面不要使用冒号。 -4. **缩进**:使用 2 个空格。 -""" - -USER_PROMPT_GENERATE = """ -请分析以下文本内容,将其核心信息转换为 AntV Infographic 语法格式。 - ---- -**用户上下文:** -用户名:{user_name} -当前时间:{current_date_time_str} -用户语言:{user_language} ---- - -**文本内容:** -{long_text_content} - -请根据文本特征选择最合适的信息图模板,输出标准的信息图语法。 - -**重要提示:** -- 如果使用 `list-grid` 格式,确保每个卡片的 `desc` 描述限制在 **最多 30 个中文字符**。 -- 描述应简洁,突出重点。 -""" - - -class Action: - class Valves(BaseModel): - SHOW_STATUS: bool = Field( - default=True, description="在聊天界面显示操作状态更新。" - ) - MODEL_ID: str = Field( - default="", - description="用于文本分析的 LLM 模型 ID。留空则使用当前对话模型。", - ) - MIN_TEXT_LENGTH: int = Field( - default=50, - description="信息图分析所需的最小文本长度(字符数)。", - ) - MESSAGE_COUNT: int = Field( - default=1, - description="用于生成的最近消息数量。", - ) - SVG_WIDTH: int = Field( - default=800, - description="生成的 SVG 宽度(像素)。", - ) - EXPORT_FORMAT: str = Field( - default="svg", - description="导出格式:'svg' 或 'png'。", - ) - - def __init__(self): - self.valves = self.Valves() - - def _extract_chat_id(self, body: dict, metadata: Optional[dict]) -> str: - """从 body 或 metadata 中提取 chat_id""" - if isinstance(body, dict): - chat_id = body.get("chat_id") - if isinstance(chat_id, str) and chat_id.strip(): - return chat_id.strip() - - body_metadata = body.get("metadata", {}) - if isinstance(body_metadata, dict): - chat_id = body_metadata.get("chat_id") - if isinstance(chat_id, str) and chat_id.strip(): - return chat_id.strip() - - if isinstance(metadata, dict): - chat_id = metadata.get("chat_id") - if isinstance(chat_id, str) and chat_id.strip(): - return chat_id.strip() - - return "" - - def _extract_message_id(self, body: dict, metadata: Optional[dict]) -> str: - """从 body 或 metadata 中提取 message_id""" - if isinstance(body, dict): - message_id = body.get("id") - if isinstance(message_id, str) and message_id.strip(): - return message_id.strip() - - body_metadata = body.get("metadata", {}) - if isinstance(body_metadata, dict): - message_id = body_metadata.get("message_id") - if isinstance(message_id, str) and message_id.strip(): - return message_id.strip() - - if isinstance(metadata, dict): - message_id = metadata.get("message_id") - if isinstance(message_id, str) and message_id.strip(): - return message_id.strip() - - return "" - - def _extract_infographic_syntax(self, llm_output: str) -> str: - """从 LLM 输出中提取信息图语法""" - match = re.search(r"```infographic\s*(.*?)\s*```", llm_output, re.DOTALL) - if match: - return match.group(1).strip() - else: - logger.warning("LLM 输出未遵循预期格式,将整个输出作为语法处理。") - return llm_output.strip() - - def _extract_text_content(self, content) -> str: - """从消息内容中提取文本,支持多模态格式""" - if isinstance(content, str): - return content - elif isinstance(content, list): - text_parts = [] - for item in content: - if isinstance(item, dict) and item.get("type") == "text": - text_parts.append(item.get("text", "")) - elif isinstance(item, str): - text_parts.append(item) - return "\n".join(text_parts) - return str(content) if content else "" - - async def _emit_status(self, emitter, description: str, done: bool = False): - """发送状态更新事件""" - if self.valves.SHOW_STATUS and emitter: - await emitter( - {"type": "status", "data": {"description": description, "done": done}} - ) - - def _generate_js_code( - self, - unique_id: str, - chat_id: str, - message_id: str, - infographic_syntax: str, - svg_width: int, - export_format: str, - ) -> str: - """生成用于前端 SVG 渲染的 JavaScript 代码""" - - # 转义语法以便嵌入 JS - syntax_escaped = ( - infographic_syntax - .replace("\\", "\\\\") - .replace("`", "\\`") - .replace("${", "\\${") - .replace("", "<\\/script>") - ) - - # 模板映射 - template_mapping_js = """ - const TEMPLATE_MAPPING = { - 'list-grid': 'list-grid-compact-card', - 'list-vertical': 'list-column-simple-vertical-arrow', - 'tree-vertical': 'hierarchy-tree-tech-style-capsule-item', - 'tree-horizontal': 'hierarchy-tree-lr-tech-style-capsule-item', - 'mindmap': 'hierarchy-mindmap-branch-gradient-capsule-item', - 'sequence-roadmap': 'sequence-roadmap-vertical-simple', - 'sequence-zigzag': 'sequence-horizontal-zigzag-simple', - 'sequence-horizontal': 'sequence-horizontal-zigzag-simple', - 'relation-sankey': 'relation-sankey-simple', - 'relation-circle': 'relation-circle-icon-badge', - 'compare-binary': 'compare-binary-horizontal-simple-vs', - 'compare-swot': 'compare-swot', - 'quadrant-quarter': 'quadrant-quarter-simple-card', - 'statistic-card': 'list-grid-compact-card', - 'chart-bar': 'chart-bar-plain-text', - 'chart-column': 'chart-column-simple', - 'chart-line': 'chart-line-plain-text', - 'chart-area': 'chart-area-simple', - 'chart-pie': 'chart-pie-plain-text', - 'chart-doughnut': 'chart-pie-donut-plain-text' - }; - """ - - return f""" -(async function() {{ - const uniqueId = "{unique_id}"; - const chatId = "{chat_id}"; - const messageId = "{message_id}"; - const svgWidth = {svg_width}; - const exportFormat = "{export_format}"; - - console.log("[信息图 Markdown] 开始渲染..."); - console.log("[信息图 Markdown] chatId:", chatId, "messageId:", messageId); - - try {{ - // 加载 AntV Infographic(如果尚未加载) - if (typeof AntVInfographic === 'undefined') {{ - console.log("[信息图 Markdown] 正在加载 AntV Infographic 库..."); - await new Promise((resolve, reject) => {{ - const script = document.createElement('script'); - script.src = 'https://unpkg.com/@antv/infographic@latest/dist/infographic.min.js'; - script.onload = resolve; - script.onerror = reject; - document.head.appendChild(script); - }}); - console.log("[信息图 Markdown] 库加载完成。"); - }} - - const {{ Infographic }} = AntVInfographic; - - // 获取信息图语法 - let syntaxContent = `{syntax_escaped}`; - console.log("[信息图 Markdown] 原始语法:", syntaxContent.substring(0, 200) + "..."); - - // 清理语法 - const backtick = String.fromCharCode(96); - const prefix = backtick + backtick + backtick + 'infographic'; - const simplePrefix = backtick + backtick + backtick; - - if (syntaxContent.toLowerCase().startsWith(prefix)) {{ - syntaxContent = syntaxContent.substring(prefix.length).trim(); - }} else if (syntaxContent.startsWith(simplePrefix)) {{ - syntaxContent = syntaxContent.substring(simplePrefix.length).trim(); - }} - - if (syntaxContent.endsWith(simplePrefix)) {{ - syntaxContent = syntaxContent.substring(0, syntaxContent.length - simplePrefix.length).trim(); - }} - - // 修复关键字后的冒号 - syntaxContent = syntaxContent.replace(/^(data|items|children|theme|config):/gm, '$1'); - syntaxContent = syntaxContent.replace(/(\\s)(children|items):/g, '$1$2'); - - // 确保有 infographic 前缀 - if (!syntaxContent.trim().toLowerCase().startsWith('infographic')) {{ - syntaxContent = 'infographic list-grid\\n' + syntaxContent; - }} - - // 应用模板映射 - {template_mapping_js} - - for (const [key, value] of Object.entries(TEMPLATE_MAPPING)) {{ - const regex = new RegExp(`infographic\\\\s+${{key}}(?=\\\\s|$)`, 'i'); - if (regex.test(syntaxContent)) {{ - console.log(`[信息图 Markdown] 自动映射: ${{key}} -> ${{value}}`); - syntaxContent = syntaxContent.replace(regex, `infographic ${{value}}`); - break; - }} - }} - - console.log("[信息图 Markdown] 清理后语法:", syntaxContent.substring(0, 200) + "..."); - - // 创建离屏容器 - const container = document.createElement('div'); - container.id = 'infographic-offscreen-' + uniqueId; - container.style.cssText = 'position:absolute;left:-9999px;top:-9999px;width:' + svgWidth + 'px;'; - document.body.appendChild(container); - - // 创建并渲染信息图 - const instance = new Infographic({{ - container: '#' + container.id, - width: svgWidth, - padding: 24, - }}); - - console.log("[信息图 Markdown] 正在渲染信息图..."); - instance.render(syntaxContent); - - // 等待渲染完成并导出 - await new Promise(resolve => setTimeout(resolve, 1000)); - - let dataUrl; - if (exportFormat === 'png') {{ - dataUrl = await instance.toDataURL({{ type: 'png', dpr: 2 }}); - }} else {{ - dataUrl = await instance.toDataURL({{ type: 'svg', embedResources: true }}); - }} - - console.log("[信息图 Markdown] Data URL 已生成,长度:", dataUrl.length); - - // 清理 - instance.destroy(); - document.body.removeChild(container); - - // 生成 Markdown 图片 - const markdownImage = `![📊 AI 生成的信息图](${{dataUrl}})`; - - // 通过 API 更新消息 - if (chatId && messageId) {{ - const token = localStorage.getItem("token"); - - // 获取当前消息内容 - const getResponse = await fetch(`/api/v1/chats/${{chatId}}`, {{ - method: "GET", - headers: {{ "Authorization": `Bearer ${{token}}` }} - }}); - - if (!getResponse.ok) {{ - throw new Error("获取对话数据失败: " + getResponse.status); - }} - - const chatData = await getResponse.json(); - let originalContent = ""; - - if (chatData.chat && chatData.chat.messages) {{ - const targetMsg = chatData.chat.messages.find(m => m.id === messageId); - if (targetMsg && targetMsg.content) {{ - originalContent = targetMsg.content; - }} - }} - - // 移除已有的信息图图片 - const infographicPattern = /\\n*!\\[📊[^\\]]*\\]\\(data:image\\/[^)]+\\)/g; - let cleanedContent = originalContent.replace(infographicPattern, ""); - cleanedContent = cleanedContent.replace(/\\n{{3,}}/g, "\\n\\n").trim(); - - // 追加新图片 - const newContent = cleanedContent + "\\n\\n" + markdownImage; - - // 更新消息 - const updateResponse = await fetch(`/api/v1/chats/${{chatId}}/messages/${{messageId}}/event`, {{ - method: "POST", - headers: {{ - "Content-Type": "application/json", - "Authorization": `Bearer ${{token}}` - }}, - body: JSON.stringify({{ - type: "chat:message", - data: {{ content: newContent }} - }}) - }}); - - if (updateResponse.ok) {{ - console.log("[信息图 Markdown] ✅ 消息更新成功!"); - }} else {{ - console.error("[信息图 Markdown] API 错误:", updateResponse.status); - }} - }} else {{ - console.warn("[信息图 Markdown] ⚠️ 缺少 chatId 或 messageId"); - }} - - }} catch (error) {{ - console.error("[信息图 Markdown] 错误:", error); - }} -}})(); -""" - - async def action( - self, - body: dict, - __user__: dict = None, - __event_emitter__=None, - __event_call__: Optional[Callable[[Any], Awaitable[None]]] = None, - __metadata__: Optional[dict] = None, - __request__: Request = None, - ) -> dict: - """ - 使用 AntV 生成信息图并作为 Markdown 图片嵌入。 - """ - logger.info("动作:信息图转 Markdown 开始") - - # 获取用户信息 - if isinstance(__user__, (list, tuple)): - user_language = __user__[0].get("language", "zh") if __user__ else "zh" - user_name = __user__[0].get("name", "用户") if __user__[0] else "用户" - user_id = __user__[0].get("id", "unknown_user") if __user__ else "unknown_user" - elif isinstance(__user__, dict): - user_language = __user__.get("language", "zh") - user_name = __user__.get("name", "用户") - user_id = __user__.get("id", "unknown_user") - else: - user_language = "zh" - user_name = "用户" - user_id = "unknown_user" - - # 获取当前时间 - now = datetime.now() - current_date_time_str = now.strftime("%Y-%m-%d %H:%M:%S") - - try: - messages = body.get("messages", []) - if not messages: - raise ValueError("没有可用的消息。") - - # 获取最近的消息 - message_count = min(self.valves.MESSAGE_COUNT, len(messages)) - recent_messages = messages[-message_count:] - - # 聚合内容 - aggregated_parts = [] - for msg in recent_messages: - text_content = self._extract_text_content(msg.get("content")) - if text_content: - aggregated_parts.append(text_content) - - if not aggregated_parts: - raise ValueError("消息中未找到文本内容。") - - long_text_content = "\n\n---\n\n".join(aggregated_parts) - - # 移除已有的 HTML 块 - parts = re.split(r"```html.*?```", long_text_content, flags=re.DOTALL) - clean_content = "" - for part in reversed(parts): - if part.strip(): - clean_content = part.strip() - break - - if not clean_content: - clean_content = long_text_content.strip() - - # 检查最小长度 - if len(clean_content) < self.valves.MIN_TEXT_LENGTH: - await self._emit_status( - __event_emitter__, - f"⚠️ 内容太短({len(clean_content)} 字符),至少需要 {self.valves.MIN_TEXT_LENGTH} 字符", - True, - ) - return body - - await self._emit_status(__event_emitter__, "📊 正在分析内容...", False) - - # 通过 LLM 生成信息图语法 - formatted_user_prompt = USER_PROMPT_GENERATE.format( - user_name=user_name, - current_date_time_str=current_date_time_str, - user_language=user_language, - long_text_content=clean_content, - ) - - target_model = self.valves.MODEL_ID or body.get("model") - - llm_payload = { - "model": target_model, - "messages": [ - {"role": "system", "content": SYSTEM_PROMPT_INFOGRAPHIC}, - {"role": "user", "content": formatted_user_prompt}, - ], - "stream": False, - } - - user_obj = Users.get_user_by_id(user_id) - if not user_obj: - raise ValueError(f"无法获取用户对象:{user_id}") - - await self._emit_status(__event_emitter__, "📊 AI 正在生成信息图语法...", False) - - llm_response = await generate_chat_completion(__request__, llm_payload, user_obj) - - if not llm_response or "choices" not in llm_response or not llm_response["choices"]: - raise ValueError("无效的 LLM 响应。") - - assistant_content = llm_response["choices"][0]["message"]["content"] - infographic_syntax = self._extract_infographic_syntax(assistant_content) - - logger.info(f"生成的语法:{infographic_syntax[:200]}...") - - # 提取 API 回调所需的 ID - chat_id = self._extract_chat_id(body, __metadata__) - message_id = self._extract_message_id(body, __metadata__) - unique_id = f"ig_{int(time.time() * 1000)}" - - await self._emit_status(__event_emitter__, "📊 正在渲染 SVG...", False) - - # 执行 JS 进行渲染和嵌入 - if __event_call__: - js_code = self._generate_js_code( - unique_id=unique_id, - chat_id=chat_id, - message_id=message_id, - infographic_syntax=infographic_syntax, - svg_width=self.valves.SVG_WIDTH, - export_format=self.valves.EXPORT_FORMAT, - ) - - await __event_call__( - { - "type": "execute", - "data": {"code": js_code}, - } - ) - - await self._emit_status(__event_emitter__, "✅ 信息图生成完成!", True) - logger.info("信息图转 Markdown 完成") - - except Exception as e: - error_message = f"信息图生成失败:{str(e)}" - logger.error(error_message, exc_info=True) - await self._emit_status(__event_emitter__, f"❌ {error_message}", True) - - return body diff --git a/plugins/actions/js-render-poc/js_render_poc.py b/plugins/actions/js-render-poc/js_render_poc.py deleted file mode 100644 index fdb1cd9..0000000 --- a/plugins/actions/js-render-poc/js_render_poc.py +++ /dev/null @@ -1,257 +0,0 @@ -""" -title: JS Render PoC -author: Fu-Jie -version: 0.6.0 -description: Proof of concept for JS rendering + API write-back pattern. JS renders SVG and updates message via API. -""" - -import time -import json -import logging -from typing import Optional, Callable, Awaitable, Any -from pydantic import BaseModel, Field -from fastapi import Request - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -class Action: - class Valves(BaseModel): - pass - - def __init__(self): - self.valves = self.Valves() - - def _extract_chat_id(self, body: dict, metadata: Optional[dict]) -> str: - """Extract chat_id from body or metadata""" - if isinstance(body, dict): - # body["chat_id"] 是 chat_id - chat_id = body.get("chat_id") - if isinstance(chat_id, str) and chat_id.strip(): - return chat_id.strip() - - body_metadata = body.get("metadata", {}) - if isinstance(body_metadata, dict): - chat_id = body_metadata.get("chat_id") - if isinstance(chat_id, str) and chat_id.strip(): - return chat_id.strip() - - if isinstance(metadata, dict): - chat_id = metadata.get("chat_id") - if isinstance(chat_id, str) and chat_id.strip(): - return chat_id.strip() - - return "" - - def _extract_message_id(self, body: dict, metadata: Optional[dict]) -> str: - """Extract message_id from body or metadata""" - if isinstance(body, dict): - # body["id"] 是 message_id - message_id = body.get("id") - if isinstance(message_id, str) and message_id.strip(): - return message_id.strip() - - body_metadata = body.get("metadata", {}) - if isinstance(body_metadata, dict): - message_id = body_metadata.get("message_id") - if isinstance(message_id, str) and message_id.strip(): - return message_id.strip() - - if isinstance(metadata, dict): - message_id = metadata.get("message_id") - if isinstance(message_id, str) and message_id.strip(): - return message_id.strip() - - return "" - - async def action( - self, - body: dict, - __user__: dict = None, - __event_emitter__=None, - __event_call__: Optional[Callable[[Any], Awaitable[None]]] = None, - __metadata__: Optional[dict] = None, - __request__: Request = None, - ) -> dict: - """ - PoC: Use __event_call__ to execute JS that renders SVG and updates message via API. - """ - # 准备调试数据 - body_for_log = {} - for k, v in body.items(): - if k == "messages": - body_for_log[k] = f"[{len(v)} messages]" - else: - body_for_log[k] = v - - body_json = json.dumps(body_for_log, ensure_ascii=False, default=str) - metadata_json = ( - json.dumps(__metadata__, ensure_ascii=False, default=str) - if __metadata__ - else "null" - ) - - # 转义 JSON 中的特殊字符以便嵌入 JS - body_json_escaped = ( - body_json.replace("\\", "\\\\").replace("`", "\\`").replace("${", "\\${") - ) - metadata_json_escaped = ( - metadata_json.replace("\\", "\\\\") - .replace("`", "\\`") - .replace("${", "\\${") - ) - - chat_id = self._extract_chat_id(body, __metadata__) - message_id = self._extract_message_id(body, __metadata__) - - unique_id = f"poc_{int(time.time() * 1000)}" - - if __event_emitter__: - await __event_emitter__( - { - "type": "status", - "data": {"description": "🔄 正在渲染...", "done": False}, - } - ) - - if __event_call__: - await __event_call__( - { - "type": "execute", - "data": { - "code": f""" -(async function() {{ - const uniqueId = "{unique_id}"; - const chatId = "{chat_id}"; - const messageId = "{message_id}"; - - // ===== DEBUG: 输出 Python 端的数据 ===== - console.log("[JS Render PoC] ===== DEBUG INFO (from Python) ====="); - console.log("[JS Render PoC] body:", `{body_json_escaped}`); - console.log("[JS Render PoC] __metadata__:", `{metadata_json_escaped}`); - console.log("[JS Render PoC] Extracted: chatId=", chatId, "messageId=", messageId); - console.log("[JS Render PoC] ========================================="); - - try {{ - console.log("[JS Render PoC] Starting SVG render..."); - - // Create SVG - const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - svg.setAttribute("width", "200"); - svg.setAttribute("height", "200"); - svg.setAttribute("viewBox", "0 0 200 200"); - svg.setAttribute("xmlns", "http://www.w3.org/2000/svg"); - - const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs"); - const gradient = document.createElementNS("http://www.w3.org/2000/svg", "linearGradient"); - gradient.setAttribute("id", "grad-" + uniqueId); - gradient.innerHTML = ` - - - `; - defs.appendChild(gradient); - svg.appendChild(defs); - - const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle"); - circle.setAttribute("cx", "100"); - circle.setAttribute("cy", "100"); - circle.setAttribute("r", "80"); - circle.setAttribute("fill", `url(#grad-${{uniqueId}})`); - svg.appendChild(circle); - - const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); - text.setAttribute("x", "100"); - text.setAttribute("y", "105"); - text.setAttribute("text-anchor", "middle"); - text.setAttribute("fill", "white"); - text.setAttribute("font-size", "16"); - text.setAttribute("font-weight", "bold"); - text.textContent = "PoC Success!"; - svg.appendChild(text); - - // Convert to Base64 Data URI - const svgData = new XMLSerializer().serializeToString(svg); - const base64 = btoa(unescape(encodeURIComponent(svgData))); - const dataUri = "data:image/svg+xml;base64," + base64; - - console.log("[JS Render PoC] SVG rendered, data URI length:", dataUri.length); - - // Call API - 完全替换方案(更稳定) - if (chatId && messageId) {{ - const token = localStorage.getItem("token"); - - // 1. 获取当前消息内容 - const getResponse = await fetch(`/api/v1/chats/${{chatId}}`, {{ - method: "GET", - headers: {{ "Authorization": `Bearer ${{token}}` }} - }}); - - if (!getResponse.ok) {{ - throw new Error("Failed to get chat data: " + getResponse.status); - }} - - const chatData = await getResponse.json(); - console.log("[JS Render PoC] Got chat data"); - - let originalContent = ""; - if (chatData.chat && chatData.chat.messages) {{ - const targetMsg = chatData.chat.messages.find(m => m.id === messageId); - if (targetMsg && targetMsg.content) {{ - originalContent = targetMsg.content; - console.log("[JS Render PoC] Found original content, length:", originalContent.length); - }} - }} - - // 2. 移除已存在的 PoC 图片(如果有的话) - // 匹配 ![JS Render PoC 生成的 SVG](data:...) 格式 - const pocImagePattern = /\\n*!\\[JS Render PoC[^\\]]*\\]\\(data:image\\/svg\\+xml;base64,[^)]+\\)/g; - let cleanedContent = originalContent.replace(pocImagePattern, ""); - // 移除可能残留的多余空行 - cleanedContent = cleanedContent.replace(/\\n{{3,}}/g, "\\n\\n").trim(); - - if (cleanedContent !== originalContent) {{ - console.log("[JS Render PoC] Removed existing PoC image(s)"); - }} - - // 3. 添加新的 Markdown 图片 - const markdownImage = `![JS Render PoC 生成的 SVG](${{dataUri}})`; - const newContent = cleanedContent + "\\n\\n" + markdownImage; - - // 3. 使用 chat:message 完全替换 - const updateResponse = await fetch(`/api/v1/chats/${{chatId}}/messages/${{messageId}}/event`, {{ - method: "POST", - headers: {{ - "Content-Type": "application/json", - "Authorization": `Bearer ${{token}}` - }}, - body: JSON.stringify({{ - type: "chat:message", - data: {{ content: newContent }} - }}) - }}); - - if (updateResponse.ok) {{ - console.log("[JS Render PoC] ✅ Message updated successfully!"); - }} else {{ - console.error("[JS Render PoC] API error:", updateResponse.status, await updateResponse.text()); - }} - }} else {{ - console.warn("[JS Render PoC] ⚠️ Missing chatId or messageId, cannot persist."); - }} - - }} catch (error) {{ - console.error("[JS Render PoC] Error:", error); - }} -}})(); - """ - }, - } - ) - - if __event_emitter__: - await __event_emitter__( - {"type": "status", "data": {"description": "✅ 渲染完成", "done": True}} - ) - - return body diff --git a/plugins/actions/smart-mind-map/smart_mind_map.png b/plugins/actions/smart-mind-map/smart_mind_map.png new file mode 100644 index 0000000..582a8d8 Binary files /dev/null and b/plugins/actions/smart-mind-map/smart_mind_map.png differ diff --git a/plugins/actions/smart-mind-map/smart_mind_map_cn.png b/plugins/actions/smart-mind-map/smart_mind_map_cn.png new file mode 100644 index 0000000..5d7fb2d Binary files /dev/null and b/plugins/actions/smart-mind-map/smart_mind_map_cn.png differ diff --git a/plugins/actions/summary/README.md b/plugins/actions/summary/README.md deleted file mode 100644 index 60e3dac..0000000 --- a/plugins/actions/summary/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# Deep Reading & Summary - -A powerful tool for analyzing long texts, generating detailed summaries, key points, and actionable insights. - -## Features - -- **Deep Analysis**: Goes beyond simple summarization to understand the core message. -- **Key Point Extraction**: Identifies and lists the most important information. -- **Actionable Advice**: Provides practical suggestions based on the text content. - -## Usage - -1. Install the plugin. -2. Send a long text or article to the chat. -3. Click the "Deep Reading" button (or trigger via command). - -## Author - -Fu-Jie -GitHub: [Fu-Jie/awesome-openwebui](https://github.com/Fu-Jie/awesome-openwebui) - -## License - -MIT License - -## Changelog - -### v0.1.2 - -- Removed debug messages from output diff --git a/plugins/actions/summary/README_CN.md b/plugins/actions/summary/README_CN.md deleted file mode 100644 index 16918b2..0000000 --- a/plugins/actions/summary/README_CN.md +++ /dev/null @@ -1,30 +0,0 @@ -# 深度阅读与摘要 (Deep Reading & Summary) - -一个强大的长文本分析工具,用于生成详细摘要、关键信息点和可执行的行动建议。 - -## 功能特点 - -- **深度分析**:超越简单的总结,深入理解核心信息。 -- **关键点提取**:识别并列出最重要的信息点。 -- **行动建议**:基于文本内容提供切实可行的建议。 - -## 使用方法 - -1. 安装插件。 -2. 发送长文本或文章到聊天框。 -3. 点击“精读”按钮(或通过命令触发)。 - -## 作者 - -Fu-Jie -GitHub: [Fu-Jie/awesome-openwebui](https://github.com/Fu-Jie/awesome-openwebui) - -## 许可证 - -MIT License - -## 更新日志 - -### v0.1.2 - -- 移除输出中的调试信息 diff --git a/plugins/actions/summary/summary.py b/plugins/actions/summary/summary.py deleted file mode 100644 index 01197a9..0000000 --- a/plugins/actions/summary/summary.py +++ /dev/null @@ -1,674 +0,0 @@ -""" -title: Deep Reading & Summary -author: Fu-Jie -author_url: https://github.com/Fu-Jie -funding_url: https://github.com/Fu-Jie/awesome-openwebui -version: 0.1.2 -icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0xNSAxMmgtNSIvPjxwYXRoIGQ9Ik0xNSA4aC01Ii8+PHBhdGggZD0iTTE5IDE3VjVhMiAyIDAgMCAwLTItMkg0Ii8+PHBhdGggZD0iTTggMjFoMTJhMiAyIDAgMCAwIDItMnYtMWExIDEgMCAwIDAtMS0xSDExYTEgMSAwIDAgMC0xIDF2MWEyIDIgMCAxIDEtNCAwVjVhMiAyIDAgMSAwLTQgMHYyYTEgMSAwIDAgMCAxIDFoMyIvPjwvc3ZnPg== -description: Provides deep reading analysis and summarization for long texts. -requirements: jinja2, markdown -""" - -from pydantic import BaseModel, Field -from typing import Optional, Dict, Any -import logging -import re -from fastapi import Request -from datetime import datetime -import pytz -import markdown -from jinja2 import Template - -from open_webui.utils.chat import generate_chat_completion -from open_webui.models.users import Users - -logging.basicConfig( - level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" -) -logger = logging.getLogger(__name__) - -# ================================================================= -# HTML Wrapper Template (supports multiple plugins and grid layout) -# ================================================================= -HTML_WRAPPER_TEMPLATE = """ - - - - - - - - - -
- -
- - - -""" - -# ================================================================= -# Internal LLM Prompts -# ================================================================= - -SYSTEM_PROMPT_READING_ASSISTANT = """ -You are a professional Deep Text Analysis Expert, specializing in reading long texts and extracting the essence. Your task is to conduct a comprehensive and in-depth analysis. - -Please provide the following: -1. **Detailed Summary**: Summarize the core content of the text in 2-3 paragraphs, ensuring accuracy and completeness. Do not be too brief; ensure the reader fully understands the main idea. -2. **Key Information Points**: List 5-8 most important facts, viewpoints, or arguments. Each point should: - - Be specific and insightful - - Include necessary details and context - - Use Markdown list format -3. **Actionable Advice**: Identify and refine specific, actionable items from the text. Each suggestion should: - - Be clear and actionable - - Include execution priority or timing suggestions - - If there are no clear action items, provide learning suggestions or thinking directions - -Please strictly follow these guidelines: -- **Language**: All output must be in the user's specified language. -- **Format**: Please strictly follow the Markdown format below, ensuring each section has a clear header: - ## Summary - [Detailed summary content here, 2-3 paragraphs, use Markdown **bold** or *italic* to emphasize key points] - - ## Key Information Points - - [Key Point 1: Include specific details and context] - - [Key Point 2: Include specific details and context] - - [Key Point 3: Include specific details and context] - - [At least 5, at most 8 key points] - - ## Actionable Advice - - [Action Item 1: Specific, actionable, include priority] - - [Action Item 2: Specific, actionable, include priority] - - [If no clear action items, provide learning suggestions or thinking directions] -- **Depth First**: Analysis should be deep and comprehensive, not superficial. -- **Action Oriented**: Focus on actionable suggestions and next steps. -- **Analysis Results Only**: Do not include any extra pleasantries, explanations, or leading text. -""" - -USER_PROMPT_GENERATE_SUMMARY = """ -Please conduct a deep analysis of the following long text, providing: -1. Detailed Summary (2-3 paragraphs, comprehensive overview) -2. Key Information Points List (5-8 items, including specific details) -3. Actionable Advice (Specific, clear, including priority) - ---- -**User Context:** -User Name: {user_name} -Current Date/Time: {current_date_time_str} -Weekday: {current_weekday} -Timezone: {current_timezone_str} -User Language: {user_language} ---- - -**Long Text Content:** -``` -{long_text_content} -``` - -Please conduct a deep and comprehensive analysis, focusing on actionable advice. -""" - -# ================================================================= -# Frontend HTML Template (Jinja2 Syntax) -# ================================================================= - -CSS_TEMPLATE_SUMMARY = """ - :root { - --primary-color: #4285f4; - --secondary-color: #1e88e5; - --action-color: #34a853; - --background-color: #f8f9fa; - --card-bg-color: #ffffff; - --text-color: #202124; - --muted-text-color: #5f6368; - --border-color: #dadce0; - --header-gradient: linear-gradient(135deg, #4285f4, #1e88e5); - --shadow: 0 1px 3px rgba(60,64,67,.3); - --border-radius: 8px; - --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; - } - .summary-container-wrapper { - font-family: var(--font-family); - line-height: 1.8; - color: var(--text-color); - height: 100%; - display: flex; - flex-direction: column; - } - .summary-container-wrapper .header { - background: var(--header-gradient); - color: white; - padding: 20px 24px; - text-align: center; - } - .summary-container-wrapper .header h1 { - margin: 0; - font-size: 1.5em; - font-weight: 500; - letter-spacing: -0.5px; - } - .summary-container-wrapper .user-context { - font-size: 0.8em; - color: var(--muted-text-color); - background-color: #f1f3f4; - padding: 8px 16px; - display: flex; - justify-content: space-around; - flex-wrap: wrap; - border-bottom: 1px solid var(--border-color); - } - .summary-container-wrapper .user-context span { margin: 2px 8px; } - .summary-container-wrapper .content { padding: 20px; flex-grow: 1; } - .summary-container-wrapper .section { - margin-bottom: 16px; - padding-bottom: 16px; - border-bottom: 1px solid #e8eaed; - } - .summary-container-wrapper .section:last-child { - border-bottom: none; - margin-bottom: 0; - padding-bottom: 0; - } - .summary-container-wrapper .section h2 { - margin-top: 0; - margin-bottom: 12px; - font-size: 1.2em; - font-weight: 500; - color: var(--text-color); - display: flex; - align-items: center; - padding-bottom: 8px; - border-bottom: 2px solid var(--primary-color); - } - .summary-container-wrapper .section h2 .icon { - margin-right: 8px; - font-size: 1.1em; - line-height: 1; - } - .summary-container-wrapper .summary-section h2 { border-bottom-color: var(--primary-color); } - .summary-container-wrapper .keypoints-section h2 { border-bottom-color: var(--secondary-color); } - .summary-container-wrapper .actions-section h2 { border-bottom-color: var(--action-color); } - .summary-container-wrapper .html-content { - font-size: 0.95em; - line-height: 1.7; - } - .summary-container-wrapper .html-content p:first-child { margin-top: 0; } - .summary-container-wrapper .html-content p:last-child { margin-bottom: 0; } - .summary-container-wrapper .html-content ul { - list-style: none; - padding-left: 0; - margin: 12px 0; - } - .summary-container-wrapper .html-content li { - padding: 8px 0 8px 24px; - position: relative; - margin-bottom: 6px; - line-height: 1.6; - } - .summary-container-wrapper .html-content li::before { - position: absolute; - left: 0; - top: 8px; - font-family: 'Arial'; - font-weight: bold; - font-size: 1em; - } - .summary-container-wrapper .keypoints-section .html-content li::before { - content: '•'; - color: var(--secondary-color); - font-size: 1.3em; - top: 5px; - } - .summary-container-wrapper .actions-section .html-content li::before { - content: '▸'; - color: var(--action-color); - } - .summary-container-wrapper .no-content { - color: var(--muted-text-color); - font-style: italic; - padding: 12px; - background: #f8f9fa; - border-radius: 4px; - } - .summary-container-wrapper .footer { - text-align: center; - padding: 16px; - font-size: 0.8em; - color: #5f6368; - background-color: #f8f9fa; - border-top: 1px solid var(--border-color); - } -""" - -CONTENT_TEMPLATE_SUMMARY = """ -
-
-

📖 Deep Reading: Analysis Report

-
-
- User: {user_name} - Time: {current_date_time_str} -
-
-
-

📝Detailed Summary

-
{summary_html}
-
-
-

💡Key Information Points

-
{keypoints_html}
-
-
-

🎯Actionable Advice

-
{actions_html}
-
-
- -
-""" - - -class Action: - class Valves(BaseModel): - SHOW_STATUS: bool = Field( - default=True, - description="Whether to show operation status updates in the chat interface.", - ) - MODEL_ID: str = Field( - default="", - description="Built-in LLM Model ID used for text analysis. If empty, uses the current conversation's model.", - ) - MIN_TEXT_LENGTH: int = Field( - default=200, - description="Minimum text length required for deep analysis (characters). Recommended 200+.", - ) - RECOMMENDED_MIN_LENGTH: int = Field( - default=500, - description="Recommended minimum text length for best analysis results.", - ) - CLEAR_PREVIOUS_HTML: bool = Field( - default=False, - description="Whether to force clear previous plugin results (if True, overwrites instead of merging).", - ) - MESSAGE_COUNT: int = Field( - default=1, - description="Number of recent messages to use for generation. Set to 1 for just the last message, or higher for more context.", - ) - - def __init__(self): - self.valves = self.Valves() - - def _process_llm_output(self, llm_output: str) -> Dict[str, str]: - """ - Parse LLM Markdown output and convert to HTML fragments. - """ - summary_match = re.search( - r"##\s*Summary\s*\n(.*?)(?=\n##|$)", llm_output, re.DOTALL | re.IGNORECASE - ) - keypoints_match = re.search( - r"##\s*Key Information Points\s*\n(.*?)(?=\n##|$)", - llm_output, - re.DOTALL | re.IGNORECASE, - ) - actions_match = re.search( - r"##\s*Actionable Advice\s*\n(.*?)(?=\n##|$)", - llm_output, - re.DOTALL | re.IGNORECASE, - ) - - summary_md = summary_match.group(1).strip() if summary_match else "" - keypoints_md = keypoints_match.group(1).strip() if keypoints_match else "" - actions_md = actions_match.group(1).strip() if actions_match else "" - - if not any([summary_md, keypoints_md, actions_md]): - summary_md = llm_output.strip() - logger.warning( - "LLM output did not follow expected Markdown format. Treating entire output as summary." - ) - - # Use 'nl2br' extension to convert newlines \n to
- md_extensions = ["nl2br"] - summary_html = ( - markdown.markdown(summary_md, extensions=md_extensions) - if summary_md - else '

Failed to extract summary.

' - ) - keypoints_html = ( - markdown.markdown(keypoints_md, extensions=md_extensions) - if keypoints_md - else '

Failed to extract key information points.

' - ) - actions_html = ( - markdown.markdown(actions_md, extensions=md_extensions) - if actions_md - else '

No explicit actionable advice.

' - ) - - return { - "summary_html": summary_html, - "keypoints_html": keypoints_html, - "actions_html": actions_html, - } - - async def _emit_status(self, emitter, description: str, done: bool = False): - """Emits a status update event.""" - if self.valves.SHOW_STATUS and emitter: - await emitter( - {"type": "status", "data": {"description": description, "done": done}} - ) - - async def _emit_notification(self, emitter, content: str, ntype: str = "info"): - """Emits a notification event (info/success/warning/error).""" - if emitter: - await emitter( - {"type": "notification", "data": {"type": ntype, "content": content}} - ) - - def _remove_existing_html(self, content: str) -> str: - """Removes existing plugin-generated HTML code blocks from the content.""" - pattern = r"```html\s*[\s\S]*?```" - return re.sub(pattern, "", content).strip() - - def _extract_text_content(self, content) -> str: - """Extract text from message content, supporting multimodal message formats""" - if isinstance(content, str): - return content - elif isinstance(content, list): - # Multimodal message: [{"type": "text", "text": "..."}, {"type": "image_url", ...}] - text_parts = [] - for item in content: - if isinstance(item, dict) and item.get("type") == "text": - text_parts.append(item.get("text", "")) - elif isinstance(item, str): - text_parts.append(item) - return "\n".join(text_parts) - return str(content) if content else "" - - def _merge_html( - self, - existing_html_code: str, - new_content: str, - new_styles: str = "", - new_scripts: str = "", - user_language: str = "en-US", - ) -> str: - """ - Merges new content into an existing HTML container, or creates a new one. - """ - if ( - "" in existing_html_code - and "" in existing_html_code - ): - base_html = existing_html_code - base_html = re.sub(r"^```html\s*", "", base_html) - base_html = re.sub(r"\s*```$", "", base_html) - else: - base_html = HTML_WRAPPER_TEMPLATE.replace("{user_language}", user_language) - - wrapped_content = f'
\n{new_content}\n
' - - if new_styles: - base_html = base_html.replace( - "/* STYLES_INSERTION_POINT */", - f"{new_styles}\n/* STYLES_INSERTION_POINT */", - ) - - base_html = base_html.replace( - "", - f"{wrapped_content}\n", - ) - - if new_scripts: - base_html = base_html.replace( - "", - f"{new_scripts}\n", - ) - - return base_html.strip() - - def _build_content_html(self, context: dict) -> str: - """ - Build content HTML using context data. - """ - return ( - CONTENT_TEMPLATE_SUMMARY.replace( - "{user_name}", context.get("user_name", "User") - ) - .replace( - "{current_date_time_str}", context.get("current_date_time_str", "") - ) - .replace("{current_year}", context.get("current_year", "")) - .replace("{summary_html}", context.get("summary_html", "")) - .replace("{keypoints_html}", context.get("keypoints_html", "")) - .replace("{actions_html}", context.get("actions_html", "")) - ) - - async def action( - self, - body: dict, - __user__: Optional[Dict[str, Any]] = None, - __event_emitter__: Optional[Any] = None, - __request__: Optional[Request] = None, - ) -> Optional[dict]: - logger.info("Action: Deep Reading Started (v2.0.0)") - - if isinstance(__user__, (list, tuple)): - user_language = ( - __user__[0].get("language", "en-US") if __user__ else "en-US" - ) - user_name = __user__[0].get("name", "User") if __user__[0] else "User" - user_id = ( - __user__[0]["id"] - if __user__ and "id" in __user__[0] - else "unknown_user" - ) - elif isinstance(__user__, dict): - user_language = __user__.get("language", "en-US") - user_name = __user__.get("name", "User") - user_id = __user__.get("id", "unknown_user") - - now = datetime.now() - current_date_time_str = now.strftime("%B %d, %Y %H:%M:%S") - current_weekday = now.strftime("%A") - current_year = now.strftime("%Y") - current_timezone_str = "Unknown Timezone" - - original_content = "" - try: - messages = body.get("messages", []) - if not messages: - raise ValueError("Unable to get valid user message content.") - - # Get last N messages based on MESSAGE_COUNT - message_count = min(self.valves.MESSAGE_COUNT, len(messages)) - recent_messages = messages[-message_count:] - - # Aggregate content from selected messages with labels - aggregated_parts = [] - for i, msg in enumerate(recent_messages, 1): - text_content = self._extract_text_content(msg.get("content")) - if text_content: - role = msg.get("role", "unknown") - role_label = ( - "User" - if role == "user" - else "Assistant" if role == "assistant" else role - ) - aggregated_parts.append(f"{text_content}") - - if not aggregated_parts: - raise ValueError("Unable to get valid user message content.") - - original_content = "\n\n---\n\n".join(aggregated_parts) - - if len(original_content) < self.valves.MIN_TEXT_LENGTH: - short_text_message = f"Text content too short ({len(original_content)} chars), recommended at least {self.valves.MIN_TEXT_LENGTH} chars for effective deep analysis.\n\n💡 Tip: For short texts, consider using '⚡ Flash Card' for quick refinement." - await self._emit_notification( - __event_emitter__, short_text_message, "warning" - ) - return { - "messages": [ - {"role": "assistant", "content": f"⚠️ {short_text_message}"} - ] - } - - # Recommend for longer texts - if len(original_content) < self.valves.RECOMMENDED_MIN_LENGTH: - await self._emit_notification( - __event_emitter__, - f"Text length is {len(original_content)} chars. Recommended {self.valves.RECOMMENDED_MIN_LENGTH}+ chars for best analysis results.", - "info", - ) - - await self._emit_notification( - __event_emitter__, - "📖 Deep Reading started, analyzing deeply...", - "info", - ) - await self._emit_status( - __event_emitter__, - "📖 Deep Reading: Analyzing text, extracting essence...", - False, - ) - - formatted_user_prompt = USER_PROMPT_GENERATE_SUMMARY.format( - user_name=user_name, - current_date_time_str=current_date_time_str, - current_weekday=current_weekday, - current_timezone_str=current_timezone_str, - user_language=user_language, - long_text_content=original_content, - ) - - # Determine model to use - target_model = self.valves.MODEL_ID - if not target_model: - target_model = body.get("model") - - llm_payload = { - "model": target_model, - "messages": [ - {"role": "system", "content": SYSTEM_PROMPT_READING_ASSISTANT}, - {"role": "user", "content": formatted_user_prompt}, - ], - "stream": False, - } - - user_obj = Users.get_user_by_id(user_id) - if not user_obj: - raise ValueError(f"Unable to get user object, User ID: {user_id}") - - llm_response = await generate_chat_completion( - __request__, llm_payload, user_obj - ) - assistant_response_content = llm_response["choices"][0]["message"][ - "content" - ] - - processed_content = self._process_llm_output(assistant_response_content) - - context = { - "user_language": user_language, - "user_name": user_name, - "current_date_time_str": current_date_time_str, - "current_weekday": current_weekday, - "current_year": current_year, - **processed_content, - } - - content_html = self._build_content_html(context) - - # Extract existing HTML if any - existing_html_block = "" - match = re.search( - r"```html\s*([\s\S]*?)```", - original_content, - ) - if match: - existing_html_block = match.group(1) - - if self.valves.CLEAR_PREVIOUS_HTML: - original_content = self._remove_existing_html(original_content) - final_html = self._merge_html( - "", content_html, CSS_TEMPLATE_SUMMARY, "", user_language - ) - else: - if existing_html_block: - original_content = self._remove_existing_html(original_content) - final_html = self._merge_html( - existing_html_block, - content_html, - CSS_TEMPLATE_SUMMARY, - "", - user_language, - ) - else: - final_html = self._merge_html( - "", content_html, CSS_TEMPLATE_SUMMARY, "", user_language - ) - - html_embed_tag = f"```html\n{final_html}\n```" - body["messages"][-1]["content"] = f"{original_content}\n\n{html_embed_tag}" - - await self._emit_status( - __event_emitter__, "📖 Deep Reading: Analysis complete!", True - ) - await self._emit_notification( - __event_emitter__, - f"📖 Deep Reading complete, {user_name}! Deep analysis report generated.", - "success", - ) - - except Exception as e: - error_message = f"Deep Reading processing failed: {str(e)}" - logger.error(f"Deep Reading Error: {error_message}", exc_info=True) - user_facing_error = f"Sorry, Deep Reading encountered an error while processing: {str(e)}.\nPlease check Open WebUI backend logs for more details." - body["messages"][-1][ - "content" - ] = f"{original_content}\n\n❌ **Error:** {user_facing_error}" - - await self._emit_status( - __event_emitter__, "Deep Reading: Processing failed.", True - ) - await self._emit_notification( - __event_emitter__, - f"Deep Reading processing failed, {user_name}!", - "error", - ) - - return body diff --git a/plugins/actions/summary/summary_cn.py b/plugins/actions/summary/summary_cn.py deleted file mode 100644 index 533ed1b..0000000 --- a/plugins/actions/summary/summary_cn.py +++ /dev/null @@ -1,663 +0,0 @@ -""" -title: 精读 (Deep Reading) -icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImciIHgxPSIwIiB5MT0iMCIgeDI9IjEiIHkyPSIxIj48c3RvcCBvZmZzZXQ9IjAlIiBzdG9wLWNvbG9yPSIjNDI4NWY0Ii8+PHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjMWU4OGU1Ii8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSJ1cmwoI2cpIi8+PHBhdGggZD0iTTE0IDJsNiA2aC02eiIgZmlsbD0iIzFlODhlNSIgb3BhY2l0eT0iMC42Ii8+PGxpbmUgeDE9IjgiIHkxPSIxMyIgeDI9IjE2IiB5Mj0iMTMiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiLz48bGluZSB4MT0iOCIgeTE9IjE3IiB4Mj0iMTQiIHkyPSIxNyIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjEuNSIvPjxjaXJjbGUgY3g9IjE2IiBjeT0iMTgiIHI9IjMiIGZpbGw9IiNmZmQ3MDAiLz48cGF0aCBkPSJNMTYgMTZsMS41IDEuNSIgc3Ryb2tlPSIjNDI4NWY0IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPjwvc3ZnPg== -version: 0.1.2 -description: 深度分析长篇文本,提炼详细摘要、关键信息点和可执行的行动建议,适合工作和学习场景。 -requirements: jinja2, markdown -""" - -from pydantic import BaseModel, Field -from typing import Optional, Dict, Any -import logging -import re -from fastapi import Request -from datetime import datetime -import pytz -import markdown -from jinja2 import Template - -from open_webui.utils.chat import generate_chat_completion -from open_webui.models.users import Users - -logging.basicConfig( - level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" -) -logger = logging.getLogger(__name__) - -# ================================================================= -# HTML 容器模板 (支持多插件共存与网格布局) -# ================================================================= -HTML_WRAPPER_TEMPLATE = """ - - - - - - - - - -
- -
- - - -""" - -# ================================================================= -# 内部 LLM 提示词设计 -# ================================================================= - -SYSTEM_PROMPT_READING_ASSISTANT = """ -你是一个专业的深度文本分析专家,擅长精读长篇文本并提炼精华。你的任务是进行全面、深入的分析。 - -请提供以下内容: -1. **详细摘要**:用 2-3 段话全面总结文本的核心内容,确保准确性和完整性。不要过于简略,要让读者充分理解文本主旨。 -2. **关键信息点**:列出 5-8 个最重要的事实、观点或论据。每个信息点应该: - - 具体且有深度 - - 包含必要的细节和背景 - - 使用 Markdown 列表格式 -3. **行动建议**:从文本中识别并提炼出具体的、可执行的行动项。每个建议应该: - - 明确且可操作 - - 包含执行的优先级或时间建议 - - 如果没有明确的行动项,可以提供学习建议或思考方向 - -请严格遵循以下指导原则: -- **语言**:所有输出必须使用用户指定的语言。 -- **格式**:请严格按照以下 Markdown 格式输出,确保每个部分都有明确的标题: - ## 摘要 - [这里是详细的摘要内容,2-3段话,可以使用 Markdown 进行**加粗**或*斜体*强调重点] - - ## 关键信息点 - - [关键点1:包含具体细节和背景] - - [关键点2:包含具体细节和背景] - - [关键点3:包含具体细节和背景] - - [至少5个,最多8个关键点] - - ## 行动建议 - - [行动项1:具体、可执行,包含优先级] - - [行动项2:具体、可执行,包含优先级] - - [如果没有明确行动项,提供学习建议或思考方向] -- **深度优先**:分析要深入、全面,不要浮于表面。 -- **行动导向**:重点关注可执行的建议和下一步行动。 -- **只输出分析结果**:不要包含任何额外的寒暄、解释或引导性文字。 -""" - -USER_PROMPT_GENERATE_SUMMARY = """ -请对以下长篇文本进行深度分析,提供: -1. 详细的摘要(2-3段话,全面概括文本内容) -2. 关键信息点列表(5-8个,包含具体细节) -3. 可执行的行动建议(具体、明确,包含优先级) - ---- -**用户上下文信息:** -用户姓名: {user_name} -当前日期时间: {current_date_time_str} -当前星期: {current_weekday} -当前时区: {current_timezone_str} -用户语言: {user_language} ---- - -**长篇文本内容:** -``` -{long_text_content} -``` - -请进行深入、全面的分析,重点关注可执行的行动建议。 -""" - -# ================================================================= -# 前端 HTML 模板 (Jinja2 语法) -# ================================================================= - -CSS_TEMPLATE_SUMMARY = """ - :root { - --primary-color: #4285f4; - --secondary-color: #1e88e5; - --action-color: #34a853; - --background-color: #f8f9fa; - --card-bg-color: #ffffff; - --text-color: #202124; - --muted-text-color: #5f6368; - --border-color: #dadce0; - --header-gradient: linear-gradient(135deg, #4285f4, #1e88e5); - --shadow: 0 1px 3px rgba(60,64,67,.3); - --border-radius: 8px; - --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; - } - .summary-container-wrapper { - font-family: var(--font-family); - line-height: 1.8; - color: var(--text-color); - height: 100%; - display: flex; - flex-direction: column; - } - .summary-container-wrapper .header { - background: var(--header-gradient); - color: white; - padding: 20px 24px; - text-align: center; - } - .summary-container-wrapper .header h1 { - margin: 0; - font-size: 1.5em; - font-weight: 500; - letter-spacing: -0.5px; - } - .summary-container-wrapper .user-context { - font-size: 0.8em; - color: var(--muted-text-color); - background-color: #f1f3f4; - padding: 8px 16px; - display: flex; - justify-content: space-around; - flex-wrap: wrap; - border-bottom: 1px solid var(--border-color); - } - .summary-container-wrapper .user-context span { margin: 2px 8px; } - .summary-container-wrapper .content { padding: 20px; flex-grow: 1; } - .summary-container-wrapper .section { - margin-bottom: 16px; - padding-bottom: 16px; - border-bottom: 1px solid #e8eaed; - } - .summary-container-wrapper .section:last-child { - border-bottom: none; - margin-bottom: 0; - padding-bottom: 0; - } - .summary-container-wrapper .section h2 { - margin-top: 0; - margin-bottom: 12px; - font-size: 1.2em; - font-weight: 500; - color: var(--text-color); - display: flex; - align-items: center; - padding-bottom: 8px; - border-bottom: 2px solid var(--primary-color); - } - .summary-container-wrapper .section h2 .icon { - margin-right: 8px; - font-size: 1.1em; - line-height: 1; - } - .summary-container-wrapper .summary-section h2 { border-bottom-color: var(--primary-color); } - .summary-container-wrapper .keypoints-section h2 { border-bottom-color: var(--secondary-color); } - .summary-container-wrapper .actions-section h2 { border-bottom-color: var(--action-color); } - .summary-container-wrapper .html-content { - font-size: 0.95em; - line-height: 1.7; - } - .summary-container-wrapper .html-content p:first-child { margin-top: 0; } - .summary-container-wrapper .html-content p:last-child { margin-bottom: 0; } - .summary-container-wrapper .html-content ul { - list-style: none; - padding-left: 0; - margin: 12px 0; - } - .summary-container-wrapper .html-content li { - padding: 8px 0 8px 24px; - position: relative; - margin-bottom: 6px; - line-height: 1.6; - } - .summary-container-wrapper .html-content li::before { - position: absolute; - left: 0; - top: 8px; - font-family: 'Arial'; - font-weight: bold; - font-size: 1em; - } - .summary-container-wrapper .keypoints-section .html-content li::before { - content: '•'; - color: var(--secondary-color); - font-size: 1.3em; - top: 5px; - } - .summary-container-wrapper .actions-section .html-content li::before { - content: '▸'; - color: var(--action-color); - } - .summary-container-wrapper .no-content { - color: var(--muted-text-color); - font-style: italic; - padding: 12px; - background: #f8f9fa; - border-radius: 4px; - } - .summary-container-wrapper .footer { - text-align: center; - padding: 16px; - font-size: 0.8em; - color: #5f6368; - background-color: #f8f9fa; - border-top: 1px solid var(--border-color); - } -""" - -CONTENT_TEMPLATE_SUMMARY = """ -
-
-

📖 精读:深度分析报告

-
-
- 用户: {user_name} - 时间: {current_date_time_str} -
-
-
-

📝详细摘要

-
{summary_html}
-
-
-

💡关键信息点

-
{keypoints_html}
-
-
-

🎯行动建议

-
{actions_html}
-
-
- -
-""" - - -class Action: - class Valves(BaseModel): - SHOW_STATUS: bool = Field( - default=True, description="是否在聊天界面显示操作状态更新。" - ) - MODEL_ID: str = Field( - default="", - description="用于文本分析的内置LLM模型ID。如果为空,则使用当前对话的模型。", - ) - MIN_TEXT_LENGTH: int = Field( - default=200, - description="进行深度分析所需的最小文本长度(字符数)。建议200字符以上。", - ) - RECOMMENDED_MIN_LENGTH: int = Field( - default=500, description="建议的最小文本长度,以获得最佳分析效果。" - ) - CLEAR_PREVIOUS_HTML: bool = Field( - default=False, - description="是否强制清除旧的插件结果(如果为 True,则不合并,直接覆盖)。", - ) - MESSAGE_COUNT: int = Field( - default=1, - description="用于生成的最近消息数量。设置为1仅使用最后一条消息,更大值可包含更多上下文。", - ) - - def __init__(self): - self.valves = self.Valves() - self.weekday_map = { - "Monday": "星期一", - "Tuesday": "星期二", - "Wednesday": "星期三", - "Thursday": "星期四", - "Friday": "星期五", - "Saturday": "星期六", - "Sunday": "星期日", - } - - def _process_llm_output(self, llm_output: str) -> Dict[str, str]: - """ - 解析LLM的Markdown输出,将其转换为HTML片段。 - """ - summary_match = re.search( - r"##\s*摘要\s*\n(.*?)(?=\n##|$)", llm_output, re.DOTALL - ) - keypoints_match = re.search( - r"##\s*关键信息点\s*\n(.*?)(?=\n##|$)", llm_output, re.DOTALL - ) - actions_match = re.search( - r"##\s*行动建议\s*\n(.*?)(?=\n##|$)", llm_output, re.DOTALL - ) - - summary_md = summary_match.group(1).strip() if summary_match else "" - keypoints_md = keypoints_match.group(1).strip() if keypoints_match else "" - actions_md = actions_match.group(1).strip() if actions_match else "" - - if not any([summary_md, keypoints_md, actions_md]): - summary_md = llm_output.strip() - logger.warning("LLM输出未遵循预期的Markdown格式。将整个输出视为摘要。") - - # 使用 'nl2br' 扩展将换行符 \n 转换为
- md_extensions = ["nl2br"] - summary_html = ( - markdown.markdown(summary_md, extensions=md_extensions) - if summary_md - else '

未能提取摘要信息。

' - ) - keypoints_html = ( - markdown.markdown(keypoints_md, extensions=md_extensions) - if keypoints_md - else '

未能提取关键信息点。

' - ) - actions_html = ( - markdown.markdown(actions_md, extensions=md_extensions) - if actions_md - else '

暂无明确的行动建议。

' - ) - - return { - "summary_html": summary_html, - "keypoints_html": keypoints_html, - "actions_html": actions_html, - } - - async def _emit_status(self, emitter, description: str, done: bool = False): - """发送状态更新事件。""" - if self.valves.SHOW_STATUS and emitter: - await emitter( - {"type": "status", "data": {"description": description, "done": done}} - ) - - async def _emit_notification(self, emitter, content: str, ntype: str = "info"): - """发送通知事件 (info/success/warning/error)。""" - if emitter: - await emitter( - {"type": "notification", "data": {"type": ntype, "content": content}} - ) - - def _remove_existing_html(self, content: str) -> str: - """移除内容中已有的插件生成 HTML 代码块 (通过标记识别)。""" - pattern = r"```html\s*[\s\S]*?```" - return re.sub(pattern, "", content).strip() - - def _extract_text_content(self, content) -> str: - """从消息内容中提取文本,支持多模态消息格式""" - if isinstance(content, str): - return content - elif isinstance(content, list): - # 多模态消息: [{"type": "text", "text": "..."}, {"type": "image_url", ...}] - text_parts = [] - for item in content: - if isinstance(item, dict) and item.get("type") == "text": - text_parts.append(item.get("text", "")) - elif isinstance(item, str): - text_parts.append(item) - return "\n".join(text_parts) - return str(content) if content else "" - - def _merge_html( - self, - existing_html_code: str, - new_content: str, - new_styles: str = "", - new_scripts: str = "", - user_language: str = "zh-CN", - ) -> str: - """ - 将新内容合并到现有的 HTML 容器中,或者创建一个新的容器。 - """ - if ( - "" in existing_html_code - and "" in existing_html_code - ): - base_html = existing_html_code - base_html = re.sub(r"^```html\s*", "", base_html) - base_html = re.sub(r"\s*```$", "", base_html) - else: - base_html = HTML_WRAPPER_TEMPLATE.replace("{user_language}", user_language) - - wrapped_content = f'
\n{new_content}\n
' - - if new_styles: - base_html = base_html.replace( - "/* STYLES_INSERTION_POINT */", - f"{new_styles}\n/* STYLES_INSERTION_POINT */", - ) - - base_html = base_html.replace( - "", - f"{wrapped_content}\n", - ) - - if new_scripts: - base_html = base_html.replace( - "", - f"{new_scripts}\n", - ) - - return base_html.strip() - - def _build_content_html(self, context: dict) -> str: - """ - 使用上下文数据构建内容 HTML。 - """ - return ( - CONTENT_TEMPLATE_SUMMARY.replace( - "{user_name}", context.get("user_name", "用户") - ) - .replace( - "{current_date_time_str}", context.get("current_date_time_str", "") - ) - .replace("{current_year}", context.get("current_year", "")) - .replace("{summary_html}", context.get("summary_html", "")) - .replace("{keypoints_html}", context.get("keypoints_html", "")) - .replace("{actions_html}", context.get("actions_html", "")) - ) - - async def action( - self, - body: dict, - __user__: Optional[Dict[str, Any]] = None, - __event_emitter__: Optional[Any] = None, - __request__: Optional[Request] = None, - ) -> Optional[dict]: - logger.info("Action: 精读启动 (v2.0.0 - Deep Reading)") - - if isinstance(__user__, (list, tuple)): - user_language = ( - __user__[0].get("language", "zh-CN") if __user__ else "zh-CN" - ) - user_name = __user__[0].get("name", "用户") if __user__[0] else "用户" - user_id = ( - __user__[0]["id"] - if __user__ and "id" in __user__[0] - else "unknown_user" - ) - elif isinstance(__user__, dict): - user_language = __user__.get("language", "zh-CN") - user_name = __user__.get("name", "用户") - user_id = __user__.get("id", "unknown_user") - - now = datetime.now() - current_date_time_str = now.strftime("%Y年%m月%d日 %H:%M:%S") - current_weekday_en = now.strftime("%A") - current_weekday = self.weekday_map.get(current_weekday_en, current_weekday_en) - current_year = now.strftime("%Y") - current_timezone_str = "未知时区" - - original_content = "" - try: - messages = body.get("messages", []) - if not messages: - raise ValueError("无法获取有效的用户消息内容。") - - # Get last N messages based on MESSAGE_COUNT - message_count = min(self.valves.MESSAGE_COUNT, len(messages)) - recent_messages = messages[-message_count:] - - # Aggregate content from selected messages with labels - aggregated_parts = [] - for i, msg in enumerate(recent_messages, 1): - text_content = self._extract_text_content(msg.get("content")) - if text_content: - role = msg.get("role", "unknown") - role_label = ( - "用户" - if role == "user" - else "助手" if role == "assistant" else role - ) - aggregated_parts.append(f"{text_content}") - - if not aggregated_parts: - raise ValueError("无法获取有效的用户消息内容。") - - original_content = "\n\n---\n\n".join(aggregated_parts) - - if len(original_content) < self.valves.MIN_TEXT_LENGTH: - short_text_message = f"文本内容过短({len(original_content)}字符),建议至少{self.valves.MIN_TEXT_LENGTH}字符以获得有效的深度分析。\n\n💡 提示:对于短文本,建议使用'⚡ 闪记卡'进行快速提炼。" - await self._emit_notification( - __event_emitter__, short_text_message, "warning" - ) - return { - "messages": [ - {"role": "assistant", "content": f"⚠️ {short_text_message}"} - ] - } - - # Recommend for longer texts - if len(original_content) < self.valves.RECOMMENDED_MIN_LENGTH: - await self._emit_notification( - __event_emitter__, - f"文本长度为{len(original_content)}字符。建议{self.valves.RECOMMENDED_MIN_LENGTH}字符以上可获得更好的分析效果。", - "info", - ) - - await self._emit_notification( - __event_emitter__, "📖 精读已启动,正在进行深度分析...", "info" - ) - await self._emit_status( - __event_emitter__, "📖 精读: 深入分析文本,提炼精华...", False - ) - - formatted_user_prompt = USER_PROMPT_GENERATE_SUMMARY.format( - user_name=user_name, - current_date_time_str=current_date_time_str, - current_weekday=current_weekday, - current_timezone_str=current_timezone_str, - user_language=user_language, - long_text_content=original_content, - ) - - # 确定使用的模型 - target_model = self.valves.MODEL_ID - if not target_model: - target_model = body.get("model") - - llm_payload = { - "model": target_model, - "messages": [ - {"role": "system", "content": SYSTEM_PROMPT_READING_ASSISTANT}, - {"role": "user", "content": formatted_user_prompt}, - ], - "stream": False, - } - - user_obj = Users.get_user_by_id(user_id) - if not user_obj: - raise ValueError(f"无法获取用户对象, 用户ID: {user_id}") - - llm_response = await generate_chat_completion( - __request__, llm_payload, user_obj - ) - assistant_response_content = llm_response["choices"][0]["message"][ - "content" - ] - - processed_content = self._process_llm_output(assistant_response_content) - - context = { - "user_language": user_language, - "user_name": user_name, - "current_date_time_str": current_date_time_str, - "current_weekday": current_weekday, - "current_year": current_year, - **processed_content, - } - - content_html = self._build_content_html(context) - - # Extract existing HTML if any - existing_html_block = "" - match = re.search( - r"```html\s*([\s\S]*?)```", - original_content, - ) - if match: - existing_html_block = match.group(1) - - if self.valves.CLEAR_PREVIOUS_HTML: - original_content = self._remove_existing_html(original_content) - final_html = self._merge_html( - "", content_html, CSS_TEMPLATE_SUMMARY, "", user_language - ) - else: - if existing_html_block: - original_content = self._remove_existing_html(original_content) - final_html = self._merge_html( - existing_html_block, - content_html, - CSS_TEMPLATE_SUMMARY, - "", - user_language, - ) - else: - final_html = self._merge_html( - "", content_html, CSS_TEMPLATE_SUMMARY, "", user_language - ) - - html_embed_tag = f"```html\n{final_html}\n```" - body["messages"][-1]["content"] = f"{original_content}\n\n{html_embed_tag}" - - await self._emit_status(__event_emitter__, "📖 精读: 分析完成!", True) - await self._emit_notification( - __event_emitter__, - f"📖 精读完成,{user_name}!深度分析报告已生成。", - "success", - ) - - except Exception as e: - error_message = f"精读处理失败: {str(e)}" - logger.error(f"精读错误: {error_message}", exc_info=True) - user_facing_error = f"抱歉, 精读在处理时遇到错误: {str(e)}。\n请检查Open WebUI后端日志获取更多详情。" - body["messages"][-1][ - "content" - ] = f"{original_content}\n\n❌ **错误:** {user_facing_error}" - - await self._emit_status(__event_emitter__, "精读: 处理失败。", True) - await self._emit_notification( - __event_emitter__, f"精读处理失败, {user_name}!", "error" - ) - - return body diff --git a/scripts/download_plugin_images.py b/scripts/download_plugin_images.py new file mode 100644 index 0000000..0611309 --- /dev/null +++ b/scripts/download_plugin_images.py @@ -0,0 +1,133 @@ +""" +Download plugin images from OpenWebUI Community +下载远程插件图片到本地目录 +""" + +import os +import sys +import re +import requests +from urllib.parse import urlparse + +# Add current directory to path +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from openwebui_community_client import get_client + + +def find_local_plugin_by_id(plugins_dir: str, post_id: str) -> str | None: + """根据 post_id 查找本地插件文件""" + for root, _, files in os.walk(plugins_dir): + for file in files: + if file.endswith(".py"): + file_path = os.path.join(root, file) + with open(file_path, "r", encoding="utf-8") as f: + content = f.read(2000) + + id_match = re.search( + r"(?:openwebui_id|post_id):\s*([a-z0-9-]+)", content + ) + if id_match and id_match.group(1).strip() == post_id: + return file_path + return None + + +def download_image(url: str, save_path: str) -> bool: + """下载图片""" + try: + response = requests.get(url, timeout=30) + response.raise_for_status() + with open(save_path, "wb") as f: + f.write(response.content) + return True + except Exception as e: + print(f" Error downloading: {e}") + return False + + +def get_image_extension(url: str) -> str: + """从 URL 获取图片扩展名""" + parsed = urlparse(url) + path = parsed.path + ext = os.path.splitext(path)[1].lower() + if ext in [".png", ".jpg", ".jpeg", ".gif", ".webp"]: + return ext + return ".png" # 默认 + + +def main(): + try: + client = get_client() + except ValueError as e: + print(f"Error: {e}") + sys.exit(1) + + base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + plugins_dir = os.path.join(base_dir, "plugins") + + print("Fetching remote posts from OpenWebUI Community...") + posts = client.get_all_posts() + print(f"Found {len(posts)} remote posts.\n") + + downloaded = 0 + skipped = 0 + not_found = 0 + + for post in posts: + post_id = post.get("id") + title = post.get("title", "Unknown") + media = post.get("media", []) + + if not media: + continue + + # 只取第一张图片 + first_media = media[0] if isinstance(media, list) else media + + # 处理字典格式 {'url': '...', 'type': 'image'} + if isinstance(first_media, dict): + image_url = first_media.get("url") + else: + image_url = first_media + + if not image_url: + continue + + print(f"Processing: {title}") + print(f" Image URL: {image_url}") + + # 查找对应的本地插件 + local_plugin = find_local_plugin_by_id(plugins_dir, post_id) + if not local_plugin: + print(f" ⚠️ No local plugin found for ID: {post_id}") + not_found += 1 + continue + + # 确定保存路径 + plugin_dir = os.path.dirname(local_plugin) + plugin_name = os.path.splitext(os.path.basename(local_plugin))[0] + ext = get_image_extension(image_url) + save_path = os.path.join(plugin_dir, plugin_name + ext) + + # 检查是否已存在 + if os.path.exists(save_path): + print(f" ⏭️ Image already exists: {os.path.basename(save_path)}") + skipped += 1 + continue + + # 下载 + print(f" Downloading to: {save_path}") + if download_image(image_url, save_path): + print(f" ✅ Downloaded: {os.path.basename(save_path)}") + downloaded += 1 + else: + print(f" ❌ Failed to download") + + print(f"\n{'='*50}") + print( + f"Finished: {downloaded} downloaded, {skipped} skipped, {not_found} not found locally" + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/openwebui_community_client.py b/scripts/openwebui_community_client.py index a6f3d27..5e22bd3 100644 --- a/scripts/openwebui_community_client.py +++ b/scripts/openwebui_community_client.py @@ -47,9 +47,15 @@ class OpenWebUICommunityClient: "Content-Type": "application/json", "Accept": "application/json", } + # 如果没有 user_id,尝试通过 API 获取 + if not self.user_id: + self.user_id = self._get_user_id_from_api() def _parse_user_id_from_token(self, token: str) -> Optional[str]: """从 JWT Token 中解析用户 ID""" + # sk- 开头的是 API Key,无法解析用户 ID + if token.startswith("sk-"): + return None try: parts = token.split(".") if len(parts) >= 2: @@ -65,6 +71,17 @@ class OpenWebUICommunityClient: pass return None + def _get_user_id_from_api(self) -> Optional[str]: + """通过 API 获取当前用户 ID""" + try: + url = f"{self.BASE_URL}/auths/" + response = requests.get(url, headers=self.headers) + response.raise_for_status() + data = response.json() + return data.get("id") + except Exception: + return None + # ========== 帖子/插件获取 ========== def get_user_posts(self, sort: str = "new", page: int = 1) -> List[Dict]: @@ -78,7 +95,7 @@ class OpenWebUICommunityClient: Returns: 帖子列表 """ - url = f"{self.BASE_URL}/posts/user/{self.user_id}?sort={sort}&page={page}" + url = f"{self.BASE_URL}/posts/users/{self.user_id}?sort={sort}&page={page}" response = requests.get(url, headers=self.headers) response.raise_for_status() return response.json() @@ -115,6 +132,96 @@ class OpenWebUICommunityClient: return None raise + # ========== 帖子/插件创建 ========== + + def create_post( + self, + title: str, + content: str, + post_type: str = "function", + data: Optional[Dict] = None, + media: Optional[List[str]] = None, + ) -> Optional[Dict]: + """ + 创建新帖子 + + Args: + title: 帖子标题 + content: 帖子内容(README/描述) + post_type: 帖子类型 (function/tool/filter/pipeline) + data: 插件数据结构 + media: 图片 URL 列表 + + Returns: + 创建成功返回帖子数据,失败返回 None + """ + try: + url = f"{self.BASE_URL}/posts/create" + payload = { + "title": title, + "content": content, + "type": post_type, + "data": data or {}, + "media": media or [], + } + response = requests.post(url, headers=self.headers, json=payload) + response.raise_for_status() + return response.json() + except Exception as e: + print(f" Error creating post: {e}") + return None + + def create_plugin( + self, + title: str, + source_code: str, + readme_content: Optional[str] = None, + metadata: Optional[Dict] = None, + media_urls: Optional[List[str]] = None, + plugin_type: str = "action", + ) -> Optional[str]: + """ + 创建新插件帖子 + + Args: + title: 插件标题 + source_code: 插件源代码 + readme_content: README 内容 + metadata: 插件元数据 + media_urls: 图片 URL 列表 + plugin_type: 插件类型 (action/filter/pipe) + + Returns: + 创建成功返回帖子 ID,失败返回 None + """ + # 构建 function 数据结构 + function_data = { + "id": "", # 服务器会生成 + "name": title, + "type": plugin_type, + "content": source_code, + "meta": { + "description": metadata.get("description", "") if metadata else "", + "manifest": metadata or {}, + }, + } + + data = {"function": function_data} + + result = self.create_post( + title=title, + content=( + readme_content or metadata.get("description", "") if metadata else "" + ), + post_type="function", + data=data, + media=media_urls, + ) + + if result: + return result.get("id") + return None + # ========== 帖子/插件更新 ========== def update_post(self, post_id: str, post_data: Dict) -> bool: @@ -139,15 +246,17 @@ class OpenWebUICommunityClient: source_code: str, readme_content: Optional[str] = None, metadata: Optional[Dict] = None, + media_urls: Optional[List[str]] = None, ) -> bool: """ - 更新插件(代码 + README + 元数据) + 更新插件(代码 + README + 元数据 + 图片) Args: post_id: 帖子 ID source_code: 插件源代码 readme_content: README 内容(用于社区页面展示) metadata: 插件元数据(title, version, description 等) + media_urls: 图片 URL 列表 Returns: 是否成功 @@ -184,8 +293,63 @@ class OpenWebUICommunityClient: "description" ] + # 更新图片 + if media_urls: + post_data["media"] = media_urls + return self.update_post(post_id, post_data) + # ========== 图片上传 ========== + + def upload_image(self, file_path: str) -> Optional[str]: + """ + 上传图片到 OpenWebUI 社区 + + Args: + file_path: 图片文件路径 + + Returns: + 上传成功后的图片 URL,失败返回 None + """ + if not os.path.exists(file_path): + return None + + # 获取文件信息 + filename = os.path.basename(file_path) + + # 根据文件扩展名确定 MIME 类型 + ext = os.path.splitext(filename)[1].lower() + mime_types = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + } + content_type = mime_types.get(ext, "application/octet-stream") + + try: + with open(file_path, "rb") as f: + files = {"file": (filename, f, content_type)} + # 上传时不使用 JSON Content-Type + headers = { + "Authorization": f"Bearer {self.api_key}", + "Accept": "application/json", + } + response = requests.post( + f"{self.BASE_URL}/files/", + headers=headers, + files=files, + ) + response.raise_for_status() + result = response.json() + + # 返回图片 URL + return result.get("url") + except Exception as e: + print(f" Warning: Failed to upload image: {e}") + return None + # ========== 版本比较 ========== def get_remote_version(self, post_id: str) -> Optional[str]: @@ -228,14 +392,15 @@ class OpenWebUICommunityClient: # ========== 插件发布 ========== def publish_plugin_from_file( - self, file_path: str, force: bool = False + self, file_path: str, force: bool = False, auto_create: bool = True ) -> Tuple[bool, str]: """ - 从文件发布插件 + 从文件发布插件(支持首次创建和更新) Args: file_path: 插件文件路径 force: 是否强制更新(忽略版本检查) + auto_create: 如果没有 openwebui_id,是否自动创建新帖子 Returns: (是否成功, 消息) @@ -247,26 +412,58 @@ class OpenWebUICommunityClient: if not metadata: return False, "No frontmatter found" + title = metadata.get("title") + if not title: + return False, "No title in frontmatter" + post_id = metadata.get("openwebui_id") or metadata.get("post_id") - if not post_id: - return False, "No openwebui_id found" - local_version = metadata.get("version") - # 版本检查 - if not force and local_version: - if not self.version_needs_update(post_id, local_version): - return True, f"Skipped: version {local_version} matches remote" - # 查找 README readme_content = self._find_readme(file_path) + # 查找并上传图片 + media_urls = None + image_path = self._find_image(file_path) + if image_path: + print(f" Found image: {os.path.basename(image_path)}") + image_url = self.upload_image(image_path) + if image_url: + print(f" Uploaded image: {image_url}") + media_urls = [image_url] + + # 如果没有 post_id,尝试创建新帖子 + if not post_id: + if not auto_create: + return False, "No openwebui_id found and auto_create is disabled" + + print(f" Creating new post for: {title}") + new_post_id = self.create_plugin( + title=title, + source_code=content, + readme_content=readme_content or metadata.get("description", ""), + metadata=metadata, + media_urls=media_urls, + ) + + if new_post_id: + # 将新 ID 写回本地文件 + self._inject_id_to_file(file_path, new_post_id) + return True, f"Created new post (ID: {new_post_id})" + return False, "Failed to create new post" + + # 版本检查(仅对更新有效) + if not force and local_version: + if not self.version_needs_update(post_id, local_version): + return True, f"Skipped: version {local_version} matches remote" + # 更新 success = self.update_plugin( post_id=post_id, source_code=content, readme_content=readme_content or metadata.get("description", ""), metadata=metadata, + media_urls=media_urls, ) if success: @@ -307,6 +504,77 @@ class OpenWebUICommunityClient: return f.read() return None + def _find_image(self, plugin_file_path: str) -> Optional[str]: + """ + 查找插件对应的图片文件 + 图片名称需要和插件文件名一致(不含扩展名) + + 例如: + export_to_word.py -> export_to_word.png / export_to_word.jpg + """ + plugin_dir = os.path.dirname(plugin_file_path) + plugin_name = os.path.splitext(os.path.basename(plugin_file_path))[0] + + # 支持的图片格式 + image_extensions = [".png", ".jpg", ".jpeg", ".gif", ".webp"] + + for ext in image_extensions: + image_path = os.path.join(plugin_dir, plugin_name + ext) + if os.path.exists(image_path): + return image_path + return None + + def _inject_id_to_file(self, file_path: str, post_id: str) -> bool: + """ + 将新创建的帖子 ID 写回本地插件文件的 frontmatter + + Args: + file_path: 插件文件路径 + post_id: 新创建的帖子 ID + + Returns: + 是否成功 + """ + try: + with open(file_path, "r", encoding="utf-8") as f: + lines = f.readlines() + + new_lines = [] + inserted = False + in_frontmatter = False + + for line in lines: + # Check for start/end of frontmatter + if line.strip() == '"""': + if not in_frontmatter: + in_frontmatter = True + else: + in_frontmatter = False + + new_lines.append(line) + + # Insert after version line + if ( + in_frontmatter + and not inserted + and line.strip().startswith("version:") + ): + new_lines.append(f"openwebui_id: {post_id}\n") + inserted = True + print(f" Injected openwebui_id: {post_id}") + + if inserted: + with open(file_path, "w", encoding="utf-8") as f: + f.writelines(new_lines) + return True + + print(f" Warning: Could not inject ID (no version line found)") + return False + + except Exception as e: + print(f" Error injecting ID to file: {e}") + return False + # ========== 统计功能 ========== def generate_stats(self, posts: List[Dict]) -> Dict: diff --git a/scripts/publish_plugin.py b/scripts/publish_plugin.py index 7d33d01..da54fb6 100644 --- a/scripts/publish_plugin.py +++ b/scripts/publish_plugin.py @@ -3,8 +3,10 @@ Publish plugins to OpenWebUI Community 使用 OpenWebUICommunityClient 发布插件到官方社区 用法: - python scripts/publish_plugin.py # 只更新有版本变化的插件 - python scripts/publish_plugin.py --force # 强制更新所有插件 + python scripts/publish_plugin.py # 更新已发布的插件(版本变化时) + python scripts/publish_plugin.py --force # 强制更新所有已发布的插件 + python scripts/publish_plugin.py --new plugins/actions/xxx # 首次发布指定目录的新插件 + python scripts/publish_plugin.py --new plugins/actions/xxx --force # 强制发布新插件 """ import os @@ -15,34 +17,111 @@ import argparse # Add current directory to path sys.path.append(os.path.dirname(os.path.abspath(__file__))) -from openwebui_community_client import OpenWebUICommunityClient, get_client +from openwebui_community_client import get_client -def find_plugins_with_id(plugins_dir: str) -> list: - """查找所有带 openwebui_id 的插件文件""" +def find_existing_plugins(plugins_dir: str) -> list: + """查找所有已发布的插件文件(有 openwebui_id 的)""" plugins = [] for root, _, files in os.walk(plugins_dir): for file in files: - if file.endswith(".py"): + if file.endswith(".py") and not file.startswith("__"): file_path = os.path.join(root, file) with open(file_path, "r", encoding="utf-8") as f: - content = f.read(2000) # 只读前 2000 字符检查 ID + content = f.read(2000) id_match = re.search( r"(?:openwebui_id|post_id):\s*([a-z0-9-]+)", content ) if id_match: plugins.append( - {"file_path": file_path, "post_id": id_match.group(1).strip()} + { + "file_path": file_path, + "post_id": id_match.group(1).strip(), + } ) return plugins +def find_new_plugins_in_dir(target_dir: str) -> list: + """查找指定目录中没有 openwebui_id 的新插件""" + plugins = [] + + if not os.path.isdir(target_dir): + print(f"Error: {target_dir} is not a directory") + return plugins + + for file in os.listdir(target_dir): + if file.endswith(".py") and not file.startswith("__"): + file_path = os.path.join(target_dir, file) + if not os.path.isfile(file_path): + continue + + with open(file_path, "r", encoding="utf-8") as f: + content = f.read(2000) + + # 检查是否有 frontmatter (title) + title_match = re.search(r"title:\s*(.+)", content) + if not title_match: + continue + + # 检查是否已有 ID + id_match = re.search(r"(?:openwebui_id|post_id):\s*([a-z0-9-]+)", content) + if id_match: + print(f" ⚠️ {file} already has ID, will update instead") + plugins.append( + { + "file_path": file_path, + "title": title_match.group(1).strip(), + "post_id": id_match.group(1).strip(), + "is_new": False, + } + ) + else: + plugins.append( + { + "file_path": file_path, + "title": title_match.group(1).strip(), + "post_id": None, + "is_new": True, + } + ) + + return plugins + + def main(): - parser = argparse.ArgumentParser(description="Publish plugins to OpenWebUI Market") + parser = argparse.ArgumentParser( + description="Publish plugins to OpenWebUI Market", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Update existing plugins (with version check) + python scripts/publish_plugin.py + + # Force update all existing plugins + python scripts/publish_plugin.py --force + + # Publish new plugins from a specific directory + python scripts/publish_plugin.py --new plugins/actions/summary + + # Preview what would be done + python scripts/publish_plugin.py --new plugins/actions/summary --dry-run + """, + ) parser.add_argument( "--force", action="store_true", help="Force update even if version matches" ) + parser.add_argument( + "--new", + metavar="DIR", + help="Publish new plugins from the specified directory (required for first-time publishing)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be done without actually publishing", + ) args = parser.parse_args() try: @@ -54,35 +133,99 @@ def main(): base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) plugins_dir = os.path.join(base_dir, "plugins") - plugins = find_plugins_with_id(plugins_dir) - print(f"Found {len(plugins)} plugins with OpenWebUI ID.\n") - updated = 0 + created = 0 skipped = 0 failed = 0 - for plugin in plugins: - file_path = plugin["file_path"] - file_name = os.path.basename(file_path) - post_id = plugin["post_id"] + # 处理新插件发布 + if args.new: + target_dir = args.new + if not os.path.isabs(target_dir): + target_dir = os.path.join(base_dir, target_dir) - print(f"Processing {file_name} (ID: {post_id})...") + print(f"🆕 Publishing new plugins from: {target_dir}\n") + new_plugins = find_new_plugins_in_dir(target_dir) - success, message = client.publish_plugin_from_file(file_path, force=args.force) + if not new_plugins: + print("No plugins found in the specified directory.") + return - if success: - if "Skipped" in message: - print(f" ⏭️ {message}") - skipped += 1 + for plugin in new_plugins: + file_path = plugin["file_path"] + file_name = os.path.basename(file_path) + title = plugin["title"] + is_new = plugin.get("is_new", True) + + if is_new: + print(f"🆕 Creating: {file_name} ({title})") else: - print(f" ✅ {message}") - updated += 1 - else: - print(f" ❌ {message}") - failed += 1 + print(f"📦 Updating: {file_name} (ID: {plugin['post_id'][:8]}...)") + + if args.dry_run: + print(f" [DRY-RUN] Would {'create' if is_new else 'update'}") + continue + + success, message = client.publish_plugin_from_file( + file_path, force=args.force, auto_create=True + ) + + if success: + if "Created" in message: + print(f" 🎉 {message}") + created += 1 + elif "Skipped" in message: + print(f" ⏭️ {message}") + skipped += 1 + else: + print(f" ✅ {message}") + updated += 1 + else: + print(f" ❌ {message}") + failed += 1 + + # 处理已有插件更新 + else: + existing_plugins = find_existing_plugins(plugins_dir) + print(f"Found {len(existing_plugins)} existing plugins with OpenWebUI ID.\n") + + if not existing_plugins: + print("No existing plugins to update.") + print( + "\n💡 Tip: Use --new to publish new plugins from a specific directory" + ) + return + + for plugin in existing_plugins: + file_path = plugin["file_path"] + file_name = os.path.basename(file_path) + post_id = plugin["post_id"] + + print(f"📦 {file_name} (ID: {post_id[:8]}...)") + + if args.dry_run: + print(f" [DRY-RUN] Would update") + continue + + success, message = client.publish_plugin_from_file( + file_path, force=args.force, auto_create=False # 不自动创建,只更新 + ) + + if success: + if "Skipped" in message: + print(f" ⏭️ {message}") + skipped += 1 + else: + print(f" ✅ {message}") + updated += 1 + else: + print(f" ❌ {message}") + failed += 1 print(f"\n{'='*50}") - print(f"Finished: {updated} updated, {skipped} skipped, {failed} failed") + print( + f"Finished: {created} created, {updated} updated, {skipped} skipped, {failed} failed" + ) if __name__ == "__main__":