`
-- 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 = ``;
-
- // 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 = ``;
-
- // 通过 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 图片(如果有的话)
- // 匹配  格式
- 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 = ``;
- 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 = """
-
-
-
- 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__":