"""
title: 精读
author: Fu-Jie
author_url: https://github.com/Fu-Jie
funding_url: https://github.com/Fu-Jie/awesome-openwebui
version: 1.0.0
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0xMiA3djE0Ii8+PHBhdGggZD0iTTMgMThhMSAxIDAgMCAxLTEtMVY0YTEgMSAwIDAgMSAxLTFoNWE0IDQgMCAwIDEgNCA0IDQgNCAwIDAgMSA0LTRoNWExIDEgMCAwIDEgMSAxdjEzYTEgMSAwIDAgMS0xIDFoLTZhMyAzIDAgMCAwLTMgMyAzIDMgMCAwIDAtMy0zeiIvPjxwYXRoIGQ9Ik02IDEyaDIiLz48cGF0aCBkPSJNMTYgMTJoMiIvPjwvc3ZnPg==
requirements: markdown
description: 全方位的思维透镜 —— 从背景全景到逻辑脉络,从深度洞察到行动路径。
"""
# Standard library imports
import re
import logging
from typing import Optional, Dict, Any, Callable, Awaitable
from datetime import datetime
# Third-party imports
from pydantic import BaseModel, Field
from fastapi import Request
import markdown
# OpenWebUI imports
from open_webui.utils.chat import generate_chat_completion
from open_webui.models.users import Users
# Logging setup
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 = """
你是一位“深度下潜 (Deep Dive)”分析专家。你的目标是引导用户完成一个全面的思维过程,从表面理解深入到战略行动。
## 思维结构 (严格遵守)
你必须从以下四个维度剖析输入内容:
### 1. 🔍 The Context (全景)
提供一个高层级的全景视图。内容是关于什么的?核心情境、背景或正在解决的问题是什么?(2-3 段话)
### 2. 🧠 The Logic (脉络)
解构底层结构。论点是如何构建的?其中的推理逻辑、隐藏假设或起作用的思维模型是什么?(列表形式)
### 3. 💎 The Insight (洞察)
提取非显性的价值。有哪些“原来如此”的时刻?揭示了哪些深层含义、盲点或独特视角?(列表形式)
### 4. 🚀 The Path (路径)
定义战略方向。具体的、按优先级排列的下一步行动是什么?如何立即应用这些知识?(可执行步骤)
## 规则
- 使用用户指定的语言输出。
- 保持专业、分析性且富有启发性的语调。
- 聚焦于“理解的过程”,而不仅仅是结果。
- 不要包含寒暄或元对话。
"""
USER_PROMPT = """
对以下内容发起“深度下潜”:
**用户上下文:**
- 用户:{user_name}
- 时间:{current_date_time_str}
- 语言:{user_language}
**待分析内容:**
```
{long_text_content}
```
请执行完整的思维链:全景 (Context) → 脉络 (Logic) → 洞察 (Insight) → 路径 (Path)。
"""
# =================================================================
# 现代 CSS 设计 - 深度下潜主题
# =================================================================
CSS_TEMPLATE = """
.deep-dive {
font-family: 'Inter', -apple-system, system-ui, sans-serif;
color: var(--dd-text-secondary);
}
.dd-header {
background: var(--dd-header-gradient);
padding: 40px 32px;
color: white;
position: relative;
}
.dd-header-badge {
display: inline-block;
padding: 4px 12px;
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.2);
border-radius: 100px;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
margin-bottom: 16px;
}
.dd-title {
font-size: 2rem;
font-weight: 800;
margin: 0 0 12px 0;
letter-spacing: -0.02em;
}
.dd-meta {
display: flex;
gap: 20px;
font-size: 0.85rem;
opacity: 0.7;
}
.dd-body {
padding: 32px;
display: flex;
flex-direction: column;
gap: 40px;
position: relative;
background: var(--dd-bg-primary);
}
/* 思维导火索 */
.dd-body::before {
content: '';
position: absolute;
left: 52px;
top: 40px;
bottom: 40px;
width: 2px;
background: var(--dd-border);
z-index: 0;
}
.dd-step {
position: relative;
z-index: 1;
display: flex;
gap: 24px;
}
.dd-step-icon {
flex-shrink: 0;
width: 40px;
height: 40px;
background: var(--dd-bg-primary);
border: 2px solid var(--dd-border);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.03);
transition: all 0.3s ease;
}
.dd-step:hover .dd-step-icon {
border-color: var(--dd-accent);
transform: scale(1.1);
}
.dd-step-content {
flex: 1;
}
.dd-step-label {
font-size: 0.75rem;
font-weight: 700;
color: var(--dd-accent);
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 4px;
}
.dd-step-title {
font-size: 1.25rem;
font-weight: 700;
color: var(--dd-text-primary);
margin: 0 0 16px 0;
}
.dd-text {
line-height: 1.7;
font-size: 1rem;
}
.dd-text p { margin-bottom: 16px; }
.dd-text p:last-child { margin-bottom: 0; }
.dd-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 12px;
}
.dd-list-item {
background: var(--dd-bg-secondary);
padding: 16px 20px;
border-radius: 12px;
border-left: 4px solid var(--dd-border);
transition: all 0.2s ease;
}
.dd-list-item:hover {
background: var(--dd-bg-tertiary);
border-left-color: var(--dd-accent);
transform: translateX(4px);
}
.dd-list-item strong {
color: var(--dd-text-primary);
display: block;
margin-bottom: 4px;
}
.dd-path-item {
background: var(--dd-accent-soft);
border-left-color: var(--dd-accent);
}
.dd-footer {
padding: 24px 32px;
background: var(--dd-bg-secondary);
border-top: 1px solid var(--dd-border);
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8rem;
color: var(--dd-text-dim);
}
.dd-tag {
padding: 2px 8px;
background: var(--dd-bg-tertiary);
border-radius: 4px;
font-weight: 600;
}
.dd-text code,
.dd-list-item code {
background: var(--dd-code-bg);
color: var(--dd-text-primary);
padding: 2px 6px;
border-radius: 4px;
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
font-size: 0.85em;
}
.dd-list-item em {
font-style: italic;
color: var(--dd-text-dim);
}
"""
CONTENT_TEMPLATE = """
🔍
Phase 01
全景 (The Context)
{context_html}
🧠
Phase 02
脉络 (The Logic)
{logic_html}
💎
Phase 03
洞察 (The Insight)
{insight_html}
🚀
Phase 04
路径 (The Path)
{path_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="深度下潜所需的最小文本长度(字符)。",
)
CLEAR_PREVIOUS_HTML: bool = Field(
default=True,
description="是否清除之前的插件结果。",
)
MESSAGE_COUNT: int = Field(
default=1,
description="要分析的最近消息数量。",
)
def __init__(self):
self.valves = self.Valves()
def _get_user_context(self, __user__: Optional[Dict[str, Any]]) -> Dict[str, str]:
"""安全提取用户上下文信息。"""
if isinstance(__user__, (list, tuple)):
user_data = __user__[0] if __user__ else {}
elif isinstance(__user__, dict):
user_data = __user__
else:
user_data = {}
return {
"user_id": user_data.get("id", "unknown_user"),
"user_name": user_data.get("name", "用户"),
"user_language": user_data.get("language", "zh-CN"),
}
def _process_llm_output(self, llm_output: str) -> Dict[str, str]:
"""解析 LLM 输出并转换为样式化 HTML。"""
# 使用灵活的正则提取各部分
context_match = re.search(
r"###\s*1\.\s*🔍?\s*(?:全景|The Context)\s*(?:\((.*?)\))?\s*\n(.*?)(?=\n###|$)",
llm_output,
re.DOTALL | re.IGNORECASE,
)
logic_match = re.search(
r"###\s*2\.\s*🧠?\s*(?:脉络|The Logic)\s*(?:\((.*?)\))?\s*\n(.*?)(?=\n###|$)",
llm_output,
re.DOTALL | re.IGNORECASE,
)
insight_match = re.search(
r"###\s*3\.\s*💎?\s*(?:洞察|The Insight)\s*(?:\((.*?)\))?\s*\n(.*?)(?=\n###|$)",
llm_output,
re.DOTALL | re.IGNORECASE,
)
path_match = re.search(
r"###\s*4\.\s*🚀?\s*(?:路径|The Path)\s*(?:\((.*?)\))?\s*\n(.*?)(?=\n###|$)",
llm_output,
re.DOTALL | re.IGNORECASE,
)
# 兜底正则
if not context_match:
context_match = re.search(
r"###\s*🔍?\s*(?:全景|The Context).*?\n(.*?)(?=\n###|$)",
llm_output,
re.DOTALL | re.IGNORECASE,
)
if not logic_match:
logic_match = re.search(
r"###\s*🧠?\s*(?:脉络|The Logic).*?\n(.*?)(?=\n###|$)",
llm_output,
re.DOTALL | re.IGNORECASE,
)
if not insight_match:
insight_match = re.search(
r"###\s*💎?\s*(?:洞察|The Insight).*?\n(.*?)(?=\n###|$)",
llm_output,
re.DOTALL | re.IGNORECASE,
)
if not path_match:
path_match = re.search(
r"###\s*🚀?\s*(?:路径|The Path).*?\n(.*?)(?=\n###|$)",
llm_output,
re.DOTALL | re.IGNORECASE,
)
context_md = (
context_match.group(context_match.lastindex).strip()
if context_match
else ""
)
logic_md = (
logic_match.group(logic_match.lastindex).strip() if logic_match else ""
)
insight_md = (
insight_match.group(insight_match.lastindex).strip()
if insight_match
else ""
)
path_md = path_match.group(path_match.lastindex).strip() if path_match else ""
if not any([context_md, logic_md, insight_md, path_md]):
context_md = llm_output.strip()
logger.warning("LLM 输出未遵循格式,将作为全景处理。")
md_extensions = ["nl2br"]
context_html = (
markdown.markdown(context_md, extensions=md_extensions)
if context_md
else '未能提取全景信息。
'
)
logic_html = (
self._process_list_items(logic_md, "logic")
if logic_md
else '未能解构脉络。
'
)
insight_html = (
self._process_list_items(insight_md, "insight")
if insight_md
else '未能发现洞察。
'
)
path_html = (
self._process_list_items(path_md, "path")
if path_md
else '未能定义路径。
'
)
return {
"context_html": context_html,
"logic_html": logic_html,
"insight_html": insight_html,
"path_html": path_html,
}
def _process_list_items(self, md_content: str, section_type: str) -> str:
"""将 markdown 列表转换为样式化卡片,支持完整的 markdown 格式。"""
lines = md_content.strip().split("\n")
items = []
current_paragraph = []
for line in lines:
line = line.strip()
# 检查列表项(无序或有序)
bullet_match = re.match(r"^[-*]\s+(.+)$", line)
numbered_match = re.match(r"^\d+\.\s+(.+)$", line)
if bullet_match or numbered_match:
# 清空累积的段落
if current_paragraph:
para_text = " ".join(current_paragraph)
para_html = self._convert_inline_markdown(para_text)
items.append(f"{para_html}
")
current_paragraph = []
# 提取列表项内容
text = (
bullet_match.group(1) if bullet_match else numbered_match.group(1)
)
# 处理粗体标题模式:**标题:** 描述 或 **标题**: 描述
title_match = re.match(r"\*\*(.+?)\*\*[:\s:]*(.*)$", text)
if title_match:
title = self._convert_inline_markdown(title_match.group(1))
desc = self._convert_inline_markdown(title_match.group(2).strip())
path_class = "dd-path-item" if section_type == "path" else ""
item_html = f'{title}{desc}
'
else:
text_html = self._convert_inline_markdown(text)
path_class = "dd-path-item" if section_type == "path" else ""
item_html = (
f'{text_html}
'
)
items.append(item_html)
elif line and not line.startswith("#"):
# 累积段落文本
current_paragraph.append(line)
elif not line and current_paragraph:
# 空行结束段落
para_text = " ".join(current_paragraph)
para_html = self._convert_inline_markdown(para_text)
items.append(f"{para_html}
")
current_paragraph = []
# 清空剩余段落
if current_paragraph:
para_text = " ".join(current_paragraph)
para_html = self._convert_inline_markdown(para_text)
items.append(f"{para_html}
")
if items:
return f'{" ".join(items)}
'
return f'未找到条目。
'
def _convert_inline_markdown(self, text: str) -> str:
"""将行内 markdown(粗体、斜体、代码)转换为 HTML。"""
# 转换行内代码:`code` -> code
text = re.sub(r"`([^`]+)`", r"\1", text)
# 转换粗体:**text** -> text
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
# 转换斜体:*text* -> text(但不在 ** 内部)
text = re.sub(r"(?\1", text)
return text
async def _emit_status(
self,
emitter: Optional[Callable[[Any], Awaitable[None]]],
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: Optional[Callable[[Any], Awaitable[None]]],
content: str,
ntype: str = "info",
):
"""发送通知事件。"""
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):
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: str,
new_content: str,
new_styles: str = "",
user_language: str = "zh-CN",
) -> str:
"""合并新内容到 HTML 容器。"""
if "" in existing_html:
base_html = re.sub(r"^```html\s*", "", existing_html)
base_html = re.sub(r"\s*```$", "", base_html)
else:
base_html = HTML_WRAPPER_TEMPLATE.replace("{user_language}", user_language)
wrapped = 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}\n",
)
return base_html.strip()
def _build_content_html(self, context: dict) -> str:
"""构建内容 HTML。"""
html = CONTENT_TEMPLATE
for key, value in context.items():
html = html.replace(f"{{{key}}}", str(value))
return html
async def action(
self,
body: dict,
__user__: Optional[Dict[str, Any]] = None,
__event_emitter__: Optional[Callable[[Any], Awaitable[None]]] = None,
__request__: Optional[Request] = None,
) -> Optional[dict]:
logger.info("Action: 精读 v1.0.0 启动")
user_ctx = self._get_user_context(__user__)
user_id = user_ctx["user_id"]
user_name = user_ctx["user_name"]
user_language = user_ctx["user_language"]
now = datetime.now()
current_date_time_str = now.strftime("%Y年%m月%d日 %H:%M")
original_content = ""
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 = self._extract_text_content(msg.get("content"))
if text:
aggregated_parts.append(text)
if not aggregated_parts:
raise ValueError("未找到文本内容。")
original_content = "\n\n---\n\n".join(aggregated_parts)
word_count = len(original_content)
if len(original_content) < self.valves.MIN_TEXT_LENGTH:
msg = f"内容过短({len(original_content)} 字符)。精读至少需要 {self.valves.MIN_TEXT_LENGTH} 字符才能进行有意义的分析。"
await self._emit_notification(__event_emitter__, msg, "warning")
return {"messages": [{"role": "assistant", "content": f"⚠️ {msg}"}]}
await self._emit_notification(
__event_emitter__, "📖 正在发起精读分析...", "info"
)
await self._emit_status(
__event_emitter__, "📖 精读:正在分析全景与脉络...", False
)
prompt = USER_PROMPT.format(
user_name=user_name,
current_date_time_str=current_date_time_str,
user_language=user_language,
long_text_content=original_content,
)
model = self.valves.MODEL_ID or body.get("model")
payload = {
"model": model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
"stream": False,
}
user_obj = Users.get_user_by_id(user_id)
if not user_obj:
raise ValueError(f"未找到用户:{user_id}")
response = await generate_chat_completion(__request__, payload, user_obj)
llm_output = response["choices"][0]["message"]["content"]
processed = self._process_llm_output(llm_output)
context = {
"user_name": user_name,
"current_date_time_str": current_date_time_str,
"word_count": word_count,
**processed,
}
content_html = self._build_content_html(context)
# 处理已有 HTML
existing = ""
match = re.search(
r"```html\s*([\s\S]*?)```",
original_content,
)
if match:
existing = match.group(1)
if self.valves.CLEAR_PREVIOUS_HTML or not existing:
original_content = self._remove_existing_html(original_content)
final_html = self._merge_html(
"", content_html, CSS_TEMPLATE, user_language
)
else:
original_content = self._remove_existing_html(original_content)
final_html = self._merge_html(
existing, content_html, CSS_TEMPLATE, user_language
)
body["messages"][-1][
"content"
] = f"{original_content}\n\n```html\n{final_html}\n```"
await self._emit_status(__event_emitter__, "📖 精读完成!", True)
await self._emit_notification(
__event_emitter__,
f"📖 精读完成,{user_name}!思维链已生成。",
"success",
)
except Exception as e:
logger.error(f"Deep Dive 错误:{e}", exc_info=True)
body["messages"][-1][
"content"
] = f"{original_content}\n\n❌ **错误:** {str(e)}"
await self._emit_status(__event_emitter__, "精读失败。", True)
await self._emit_notification(__event_emitter__, f"错误:{str(e)}", "error")
return body