添加插件文档和代码更新,支持可配置的文件标题来源,增强导出功能

This commit is contained in:
Jeff fu
2025-12-30 16:09:39 +08:00
parent 25c3598979
commit 4e5646ae94
7 changed files with 322 additions and 52 deletions

View File

@@ -39,6 +39,14 @@ README 文件应包含以下内容:
- 故障排除指南 / Troubleshooting guide - 故障排除指南 / Troubleshooting guide
- 版本和作者信息 / Version and author information - 版本和作者信息 / Version and author information
### 官方文档 (Official Documentation)
如果插件被合并到主仓库,还需更新 `docs/` 目录下的相关文档:
- `docs/plugins/{type}/plugin-name.md`
- `docs/plugins/{type}/plugin-name.zh.md`
其中 `{type}` 对应插件类型(如 `actions`, `filters`, `pipes` 等)。
--- ---
## 📝 文档字符串规范 (Docstring Standard) ## 📝 文档字符串规范 (Docstring Standard)
@@ -370,6 +378,94 @@ from open_webui.models.users import Users
--- ---
## 📄 文件导出与命名规范 (File Export and Naming)
对于涉及文件导出的插件(通常是 Action必须提供灵活的标题生成策略。
### Valves 配置 (Valves Configuration)
应包含 `TITLE_SOURCE` 选项:
```python
class Valves(BaseModel):
TITLE_SOURCE: str = Field(
default="chat_title",
description="Title Source: 'chat_title', 'ai_generated', 'markdown_title'",
)
```
### 标题获取逻辑 (Title Retrieval Logic)
1. **chat_title**: 尝试从 `body` 获取,若失败且有 `chat_id`,则从数据库获取 (`Chats.get_chat_by_id`)。
2. **markdown_title**: 从 Markdown 内容提取第一个 H1 或 H2。
3. **ai_generated**: 使用轻量级 Prompt 让 AI 生成简短标题。
### 优先级与回退 (Priority and Fallback)
代码应根据 `TITLE_SOURCE` 优先尝试指定方法,若失败则按以下顺序回退:
`chat_title` -> `markdown_title` -> `user_name + date`
```python
# 核心逻辑示例
if self.valves.TITLE_SOURCE == "chat_title":
title = chat_title
elif self.valves.TITLE_SOURCE == "markdown_title":
title = self.extract_title(content)
elif self.valves.TITLE_SOURCE == "ai_generated":
title = await self.generate_title_using_ai(...)
```
### AI 标题生成实现 (AI Title Generation Implementation)
如果支持 `ai_generated` 选项,应实现类似以下的方法:
```python
async def generate_title_using_ai(
self,
body: dict,
content: str,
user_id: str,
request: Any
) -> str:
"""Generates a short title using the current LLM model."""
if not request:
return ""
try:
# 获取当前用户和模型
user_obj = Users.get_user_by_id(user_id)
model = body.get("model")
# 构造请求
payload = {
"model": model,
"messages": [
{
"role": "system",
"content": "You are a helpful assistant. Generate a short, concise title (max 10 words) for the following text. Do not use quotes. Only output the title."
},
{
"role": "user",
"content": content[:2000] # 限制上下文长度以节省 Token
}
],
"stream": False,
}
# 调用 OpenWebUI 内部生成接口
response = await generate_chat_completion(request, payload, user_obj)
if response and "choices" in response:
return response["choices"][0]["message"]["content"].strip()
except Exception as e:
logger.error(f"Error generating title: {e}")
return ""
```
---
## ✅ 开发检查清单 (Development Checklist) ## ✅ 开发检查清单 (Development Checklist)
开发新插件时,请确保完成以下检查: 开发新插件时,请确保完成以下检查:

View File

@@ -17,7 +17,17 @@ The Export to Word plugin converts chat messages from Markdown to a polished Wor
- :material-format-bold: **Rich Markdown Support**: Headings, bold/italic, lists, tables - :material-format-bold: **Rich Markdown Support**: Headings, bold/italic, lists, tables
- :material-code-tags: **Syntax Highlighting**: Pygments-powered code blocks - :material-code-tags: **Syntax Highlighting**: Pygments-powered code blocks
- :material-format-quote-close: **Styled Blockquotes**: Left-border gray quote styling - :material-format-quote-close: **Styled Blockquotes**: Left-border gray quote styling
- :material-file-document-outline: **Smart Filenames**: Prefers chat title Markdown title → user/date - :material-file-document-outline: **Smart Filenames**: Configurable title source (Chat Title, AI Generated, or Markdown Title)
---
## Configuration
You can configure the following settings via the **Valves** button in the plugin settings:
| Valve | Description | Default |
| :------------- | :------------------------------------------------------------------------------------------ | :----------- |
| `TITLE_SOURCE` | Source for document title/filename. Options: `chat_title`, `ai_generated`, `markdown_title` | `chat_title` |
--- ---
@@ -39,21 +49,21 @@ The Export to Word plugin converts chat messages from Markdown to a polished Wor
## Supported Markdown ## Supported Markdown
| Syntax | Word Result | | Syntax | Word Result |
| :---------------------------------- | :-------------------------------- | | :---------------------------------- | :----------------------------- |
| `# Heading 1` to `###### Heading 6` | Heading levels 1-6 | | `# Heading 1` to `###### Heading 6` | Heading levels 1-6 |
| `**bold**` / `__bold__` | Bold text | | `**bold**` / `__bold__` | Bold text |
| `*italic*` / `_italic_` | Italic text | | `*italic*` / `_italic_` | Italic text |
| `***bold italic***` | Bold + Italic | | `***bold italic***` | Bold + Italic |
| `` `inline code` `` | Monospace with gray background | | `` `inline code` `` | Monospace with gray background |
| <code>``` code block ```</code> | Syntax-highlighted code block | | <code>``` code block ```</code> | Syntax-highlighted code block |
| `> blockquote` | Left-bordered gray italic text | | `> blockquote` | Left-bordered gray italic text |
| `[link](url)` | Blue underlined link | | `[link](url)` | Blue underlined link |
| `~~strikethrough~~` | Strikethrough | | `~~strikethrough~~` | Strikethrough |
| `- item` / `* item` | Bullet list | | `- item` / `* item` | Bullet list |
| `1. item` | Numbered list | | `1. item` | Numbered list |
| Markdown tables | Grid table | | Markdown tables | Grid table |
| `---` / `***` | Horizontal rule | | `---` / `***` | Horizontal rule |
--- ---

View File

@@ -17,7 +17,17 @@ Export to Word 插件会把聊天消息从 Markdown 转成精致的 Word 文档
- :material-format-bold: **丰富 Markdown 支持**:标题、粗斜体、列表、表格 - :material-format-bold: **丰富 Markdown 支持**:标题、粗斜体、列表、表格
- :material-code-tags: **语法高亮**Pygments 驱动的代码块上色 - :material-code-tags: **语法高亮**Pygments 驱动的代码块上色
- :material-format-quote-close: **引用样式**:左侧边框的灰色斜体引用 - :material-format-quote-close: **引用样式**:左侧边框的灰色斜体引用
- :material-file-document-outline: **智能文件名**优先对话标题 → Markdown 标题 → 用户/日期 - :material-file-document-outline: **智能文件名**可配置标题来源对话标题、AI 生成或 Markdown 标题
---
## 配置
您可以通过插件设置中的 **Valves** 按钮配置以下选项:
| Valve | 说明 | 默认值 |
| :------------- | :--------------------------------------------------------------------------------------------------------------- | :----------- |
| `TITLE_SOURCE` | 文档标题/文件名的来源。选项:`chat_title` (对话标题), `ai_generated` (AI 生成), `markdown_title` (Markdown 标题) | `chat_title` |
--- ---
@@ -39,21 +49,21 @@ Export to Word 插件会把聊天消息从 Markdown 转成精致的 Word 文档
## 支持的 Markdown ## 支持的 Markdown
| 语法 | Word 效果 | | 语法 | Word 效果 |
| :---------------------------------- | :-------------------------------- | | :-------------------------- | :------------------ |
| `# 标题1``###### 标题6` | 标题级别 1-6 | | `# 标题1``###### 标题6` | 标题级别 1-6 |
| `**粗体**` / `__粗体__` | 粗体文本 | | `**粗体**` / `__粗体__` | 粗体文本 |
| `*斜体*` / `_斜体_` | 斜体文本 | | `*斜体*` / `_斜体_` | 斜体文本 |
| `***粗斜体***` | 粗体 + 斜体 | | `***粗斜体***` | 粗体 + 斜体 |
| `` `行内代码` `` | 等宽字体 + 灰色背景 | | `` `行内代码` `` | 等宽字体 + 灰色背景 |
| <code>``` 代码块 ```</code> | 语法高亮代码块 | | <code>``` 代码块 ```</code> | 语法高亮代码块 |
| `> 引用文本` | 左侧边框的灰色斜体 | | `> 引用文本` | 左侧边框的灰色斜体 |
| `[链接](url)` | 蓝色下划线链接 | | `[链接](url)` | 蓝色下划线链接 |
| `~~删除线~~` | 删除线 | | `~~删除线~~` | 删除线 |
| `- 项目` / `* 项目` | 无序列表 | | `- 项目` / `* 项目` | 无序列表 |
| `1. 项目` | 有序列表 | | `1. 项目` | 有序列表 |
| Markdown 表格 | 带边框表格 | | Markdown 表格 | 带边框表格 |
| `---` / `***` | 水平分割线 | | `---` / `***` | 水平分割线 |
--- ---

View File

@@ -9,7 +9,16 @@ Export current conversation from Markdown to Word (.docx) with **syntax highligh
- **Syntax Highlighting**: Code blocks are highlighted with Pygments (supports 500+ languages). - **Syntax Highlighting**: Code blocks are highlighted with Pygments (supports 500+ languages).
- **Blockquote Support**: Markdown blockquotes are rendered with left border and gray styling. - **Blockquote Support**: Markdown blockquotes are rendered with left border and gray styling.
- **Multi-language Support**: Properly handles both Chinese and English text without garbled characters. - **Multi-language Support**: Properly handles both Chinese and English text without garbled characters.
- **Smarter Filenames**: Prefers chat title (from body or chat_id lookup) → first Markdown h1/h2 → user + date. - **Smarter Filenames**: Configurable title source (Chat Title, AI Generated, or Markdown Title).
## Configuration
You can configure the following settings via the **Valves** button in the plugin settings:
- **TITLE_SOURCE**: Choose how the document title/filename is generated.
- `chat_title`: Use the conversation title (default).
- `ai_generated`: Use AI to generate a short title based on the content.
- `markdown_title`: Extract the first h1/h2 heading from the Markdown content.
## Supported Markdown Syntax ## Supported Markdown Syntax

View File

@@ -9,7 +9,16 @@
- **代码语法高亮**:使用 Pygments 库为代码块添加语法高亮(支持 500+ 种语言)。 - **代码语法高亮**:使用 Pygments 库为代码块添加语法高亮(支持 500+ 种语言)。
- **引用块支持**Markdown 引用块会渲染为带左侧边框的灰色斜体样式。 - **引用块支持**Markdown 引用块会渲染为带左侧边框的灰色斜体样式。
- **多语言支持**:正确处理中文和英文文本,无乱码问题。 - **多语言支持**:正确处理中文和英文文本,无乱码问题。
- **更智能的文件名**优先使用对话标题(来自请求体或基于 chat_id 查询),其次 Markdown 一级/二级标题,最后用户+日期 - **更智能的文件名**可配置标题来源对话标题、AI 生成或 Markdown 标题)
## 配置 (Configuration)
您可以通过插件设置中的 **Valves** 按钮配置以下选项:
- **TITLE_SOURCE**:选择文档标题/文件名的生成方式。
- `chat_title`:使用对话标题(默认)。
- `ai_generated`:使用 AI 根据内容生成简短标题。
- `markdown_title`:从 Markdown 内容中提取第一个一级或二级标题。
## 支持的 Markdown 语法 ## 支持的 Markdown 语法

View File

@@ -25,6 +25,9 @@ from docx.enum.style import WD_STYLE_TYPE
from docx.oxml.ns import qn from docx.oxml.ns import qn
from docx.oxml import OxmlElement from docx.oxml import OxmlElement
from open_webui.models.chats import Chats from open_webui.models.chats import Chats
from open_webui.models.users import Users
from open_webui.utils.chat import generate_chat_completion
from pydantic import BaseModel, Field
# Pygments for syntax highlighting # Pygments for syntax highlighting
try: try:
@@ -45,8 +48,14 @@ logger = logging.getLogger(__name__)
class Action: class Action:
class Valves(BaseModel):
TITLE_SOURCE: str = Field(
default="chat_title",
description="Title Source: 'chat_title' (Chat Title), 'ai_generated' (AI Generated), 'markdown_title' (Markdown Title)",
)
def __init__(self): def __init__(self):
pass self.valves = self.Valves()
async def _send_notification(self, emitter: Callable, type: str, content: str): async def _send_notification(self, emitter: Callable, type: str, content: str):
await emitter( await emitter(
@@ -60,6 +69,7 @@ class Action:
__event_emitter__=None, __event_emitter__=None,
__event_call__: Optional[Callable[[Any], Awaitable[None]]] = None, __event_call__: Optional[Callable[[Any], Awaitable[None]]] = None,
__metadata__: Optional[dict] = None, __metadata__: Optional[dict] = None,
__request__: Optional[Any] = None,
): ):
logger.info(f"action:{__name__}") logger.info(f"action:{__name__}")
@@ -101,26 +111,54 @@ class Action:
) )
return return
# Generate filename (prefer chat title; fetch via chat_id if missing; then markdown title; then fallback) # Generate filename
title = ""
chat_id = self.extract_chat_id(body, __metadata__) chat_id = self.extract_chat_id(body, __metadata__)
chat_title = self.extract_chat_title(body)
if not chat_title and chat_id: # Fetch chat_title directly via chat_id as it's usually missing in body
chat_title = ""
if chat_id:
chat_title = await self.fetch_chat_title(chat_id, user_id) chat_title = await self.fetch_chat_title(chat_id, user_id)
title = self.extract_title(message_content)
if (
self.valves.TITLE_SOURCE == "chat_title"
or not self.valves.TITLE_SOURCE
):
title = chat_title
elif self.valves.TITLE_SOURCE == "markdown_title":
title = self.extract_title(message_content)
elif self.valves.TITLE_SOURCE == "ai_generated":
title = await self.generate_title_using_ai(
body, message_content, user_id, __request__
)
# Fallback logic
if not title:
if self.valves.TITLE_SOURCE != "chat_title" and chat_title:
title = chat_title
elif self.valves.TITLE_SOURCE != "markdown_title":
extracted = self.extract_title(message_content)
if extracted:
title = extracted
current_datetime = datetime.datetime.now() current_datetime = datetime.datetime.now()
formatted_date = current_datetime.strftime("%Y%m%d") formatted_date = current_datetime.strftime("%Y%m%d")
if chat_title: if title:
filename = f"{self.clean_filename(chat_title)}.docx"
elif title:
filename = f"{self.clean_filename(title)}.docx" filename = f"{self.clean_filename(title)}.docx"
else: else:
filename = f"{user_name}_{formatted_date}.docx" filename = f"{user_name}_{formatted_date}.docx"
top_heading = ""
if chat_title:
top_heading = chat_title
elif title:
top_heading = title
# Create Word document; if no h1 exists, inject chat title as h1 # Create Word document; if no h1 exists, inject chat title as h1
has_h1 = bool(re.search(r"^#\s+.+$", message_content, re.MULTILINE)) has_h1 = bool(re.search(r"^#\s+.+$", message_content, re.MULTILINE))
doc = self.markdown_to_docx( doc = self.markdown_to_docx(
message_content, top_heading=chat_title, has_h1=has_h1 message_content, top_heading=top_heading, has_h1=has_h1
) )
# Save to memory # Save to memory
@@ -194,6 +232,36 @@ class Action:
f"Error exporting Word document: {str(e)}", f"Error exporting Word document: {str(e)}",
) )
async def generate_title_using_ai(
self, body: dict, content: str, user_id: str, request: Any
) -> str:
if not request:
return ""
try:
user_obj = Users.get_user_by_id(user_id)
model = body.get("model")
payload = {
"model": model,
"messages": [
{
"role": "system",
"content": "You are a helpful assistant. Generate a short, concise title (max 10 words) for the following text. Do not use quotes. Only output the title.",
},
{"role": "user", "content": content[:2000]}, # Limit content length
],
"stream": False,
}
response = await generate_chat_completion(request, payload, user_obj)
if response and "choices" in response:
return response["choices"][0]["message"]["content"].strip()
except Exception as e:
logger.error(f"Error generating title: {e}")
return ""
def extract_title(self, content: str) -> str: def extract_title(self, content: str) -> str:
"""Extract title from Markdown h1/h2 only""" """Extract title from Markdown h1/h2 only"""
lines = content.split("\n") lines = content.split("\n")

View File

@@ -25,6 +25,9 @@ from docx.enum.style import WD_STYLE_TYPE
from docx.oxml.ns import qn from docx.oxml.ns import qn
from docx.oxml import OxmlElement from docx.oxml import OxmlElement
from open_webui.models.chats import Chats from open_webui.models.chats import Chats
from open_webui.models.users import Users
from open_webui.utils.chat import generate_chat_completion
from pydantic import BaseModel, Field
# Pygments for syntax highlighting # Pygments for syntax highlighting
try: try:
@@ -45,8 +48,14 @@ logger = logging.getLogger(__name__)
class Action: class Action:
class Valves(BaseModel):
TITLE_SOURCE: str = Field(
default="chat_title",
description="标题来源: 'chat_title' (对话标题), 'ai_generated' (AI 生成), 'markdown_title' (Markdown 标题)",
)
def __init__(self): def __init__(self):
pass self.valves = self.Valves()
async def _send_notification(self, emitter: Callable, type: str, content: str): async def _send_notification(self, emitter: Callable, type: str, content: str):
await emitter( await emitter(
@@ -60,6 +69,7 @@ class Action:
__event_emitter__=None, __event_emitter__=None,
__event_call__: Optional[Callable[[Any], Awaitable[None]]] = None, __event_call__: Optional[Callable[[Any], Awaitable[None]]] = None,
__metadata__: Optional[dict] = None, __metadata__: Optional[dict] = None,
__request__: Optional[Any] = None,
): ):
logger.info(f"action:{__name__}") logger.info(f"action:{__name__}")
@@ -98,26 +108,54 @@ class Action:
) )
return return
# 生成文件名(优先对话标题;若缺失则通过 chat_id 查询;再到 Markdown 标题;最后用户+日期) # 生成文件名
title = ""
chat_id = self.extract_chat_id(body, __metadata__) chat_id = self.extract_chat_id(body, __metadata__)
chat_title = self.extract_chat_title(body)
if not chat_title and chat_id: # 直接通过 chat_id 获取标题,因为 body 中通常不包含标题
chat_title = ""
if chat_id:
chat_title = await self.fetch_chat_title(chat_id, user_id) chat_title = await self.fetch_chat_title(chat_id, user_id)
title = self.extract_title(message_content)
# 根据配置决定文件名使用的标题
if (
self.valves.TITLE_SOURCE == "chat_title"
or not self.valves.TITLE_SOURCE
):
title = chat_title
elif self.valves.TITLE_SOURCE == "markdown_title":
title = self.extract_title(message_content)
elif self.valves.TITLE_SOURCE == "ai_generated":
title = await self.generate_title_using_ai(
body, message_content, user_id, __request__
)
current_datetime = datetime.datetime.now() current_datetime = datetime.datetime.now()
formatted_date = current_datetime.strftime("%Y%m%d") formatted_date = current_datetime.strftime("%Y%m%d")
if chat_title: if title:
filename = f"{self.clean_filename(chat_title)}.docx"
elif title:
filename = f"{self.clean_filename(title)}.docx" filename = f"{self.clean_filename(title)}.docx"
else: else:
filename = f"{user_name}_{formatted_date}.docx" filename = f"{user_name}_{formatted_date}.docx"
# 创建 Word 文档;若正文无一级标题,使用对话标题作为一级标题 # 创建 Word 文档;若正文无一级标题,使用对话标题作为一级标题
# 如果选择了 chat_title 且获取到了,则作为 top_heading
# 如果选择了其他方式title 就是文件名,也可以作为 top_heading
# 保持原有逻辑top_heading 主要是为了在文档开头补充标题
# 这里我们尽量使用 chat_title 作为 top_heading如果它存在的话因为它通常是对话的主题
# 即使文件名是 AI 生成的,文档内的标题用 chat_title 也是合理的
# 但如果用户选择了 markdown_title可能不希望插入 chat_title
top_heading = ""
if chat_title:
top_heading = chat_title
elif title:
top_heading = title
has_h1 = bool(re.search(r"^#\s+.+$", message_content, re.MULTILINE)) has_h1 = bool(re.search(r"^#\s+.+$", message_content, re.MULTILINE))
doc = self.markdown_to_docx( doc = self.markdown_to_docx(
message_content, top_heading=chat_title, has_h1=has_h1 message_content, top_heading=top_heading, has_h1=has_h1
) )
# 保存到内存 # 保存到内存
@@ -189,6 +227,36 @@ class Action:
__event_emitter__, "error", f"导出 Word 文档时出错: {str(e)}" __event_emitter__, "error", f"导出 Word 文档时出错: {str(e)}"
) )
async def generate_title_using_ai(
self, body: dict, content: str, user_id: str, request: Any
) -> str:
if not request:
return ""
try:
user_obj = Users.get_user_by_id(user_id)
model = body.get("model")
payload = {
"model": model,
"messages": [
{
"role": "system",
"content": "You are a helpful assistant. Generate a short, concise title (max 10 words) for the following text. Do not use quotes. Only output the title.",
},
{"role": "user", "content": content[:2000]}, # Limit content length
],
"stream": False,
}
response = await generate_chat_completion(request, payload, user_obj)
if response and "choices" in response:
return response["choices"][0]["message"]["content"].strip()
except Exception as e:
logger.error(f"Error generating title: {e}")
return ""
def extract_title(self, content: str) -> str: def extract_title(self, content: str) -> str:
"""从 Markdown 内容提取一级/二级标题""" """从 Markdown 内容提取一级/二级标题"""
lines = content.split("\n") lines = content.split("\n")