Add Flash Card plugin with HTML generation and key point extraction
- Introduced a new Flash Card plugin that generates visually appealing flashcards from text input. - Implemented functionality to extract key points and categories for efficient learning. - Added a new Python file for the plugin logic and a corresponding image asset. - Removed outdated README files for the previous knowledge card plugin.
This commit is contained in:
41
plugins/actions/flash-card/README.md
Normal file
41
plugins/actions/flash-card/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Flash Card
|
||||
|
||||
Generate polished learning flashcards from any text—title, summary, key points, tags, and category—ready for review and sharing.
|
||||
|
||||

|
||||
|
||||
## Highlights
|
||||
|
||||
- **One-click generation**: Drop in text, get a structured card.
|
||||
- **Concise extraction**: 3–5 key points and 2–4 tags automatically surfaced.
|
||||
- **Multi-language**: Choose target language (default English).
|
||||
- **Progressive merge**: Multiple runs append cards into the same HTML container; enable clearing to reset.
|
||||
- **Status updates**: Live notifications for generating/done/error.
|
||||
|
||||
## Parameters
|
||||
|
||||
| Param | Description | Default |
|
||||
| ------------------- | ------------------------------------------------------------ | ------- |
|
||||
| MODEL_ID | Model to use; empty falls back to current session model | empty |
|
||||
| MIN_TEXT_LENGTH | Minimum text length; below this prompts for more text | 50 |
|
||||
| LANGUAGE | Output language (e.g., en, zh) | en |
|
||||
| SHOW_STATUS | Whether to show status updates | true |
|
||||
| CLEAR_PREVIOUS_HTML | Whether to clear previous card HTML (otherwise append/merge) | false |
|
||||
| MESSAGE_COUNT | Use the latest N messages to build the card | 1 |
|
||||
|
||||
## How to Use
|
||||
|
||||
1. Install and enable “Flash Card”.
|
||||
2. Send the text to the chat (multi-turn supported; governed by MESSAGE_COUNT).
|
||||
3. Watch status updates; the card HTML is embedded into the latest message.
|
||||
4. To regenerate from scratch, toggle CLEAR_PREVIOUS_HTML or resend text.
|
||||
|
||||
## Output Format
|
||||
|
||||
- JSON fields: `title`, `summary`, `key_points` (3–5), `tags` (2–4), `category`.
|
||||
- UI: gradient-styled card with tags, key-point list; supports stacking multiple cards.
|
||||
|
||||
## Tips
|
||||
|
||||
- Very short text triggers a prompt to add more; consider summarizing first.
|
||||
- Long text is accepted; for deep analysis, pre-condense with other tools before card creation.
|
||||
41
plugins/actions/flash-card/README_CN.md
Normal file
41
plugins/actions/flash-card/README_CN.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# 闪记卡 (Flash Card)
|
||||
|
||||
快速将文本提炼为精美的学习记忆卡片,自动抽取标题、摘要、关键要点、标签和分类,适合复习与分享。
|
||||
|
||||

|
||||
|
||||
## 功能亮点
|
||||
|
||||
- **一键生成**:输入任意文本,直接产出结构化卡片。
|
||||
- **要点聚合**:自动提取 3-5 个记忆要点与 2-4 个标签。
|
||||
- **多语言支持**:可设定目标语言(默认中文)。
|
||||
- **渐进合并**:多次调用会将新卡片合并到同一 HTML 容器中;如需重置可启用清空选项。
|
||||
- **状态提示**:实时推送“生成中/完成/错误”等状态与通知。
|
||||
|
||||
## 参数说明
|
||||
|
||||
| 参数 | 说明 | 默认值 |
|
||||
| ------------------- | ------------------------------------- | ------ |
|
||||
| MODEL_ID | 指定推理模型;为空则使用当前会话模型 | 空 |
|
||||
| MIN_TEXT_LENGTH | 最小文本长度,不足时提示补充 | 50 |
|
||||
| LANGUAGE | 输出语言(如 zh、en) | zh |
|
||||
| SHOW_STATUS | 是否显示状态更新 | true |
|
||||
| CLEAR_PREVIOUS_HTML | 是否清空旧的卡片 HTML(否则合并追加) | false |
|
||||
| MESSAGE_COUNT | 取最近 N 条消息生成卡片 | 1 |
|
||||
|
||||
## 使用步骤
|
||||
|
||||
1. 在插件市场安装并启用“闪记卡”。
|
||||
2. 将待整理的文本发送到聊天框(可多轮对话,受 MESSAGE_COUNT 控制)。
|
||||
3. 等待状态提示,卡片将以 HTML 形式嵌入到最新消息中。
|
||||
4. 若需重新生成,开启 CLEAR_PREVIOUS_HTML 或直接重发文本。
|
||||
|
||||
## 输出格式
|
||||
|
||||
- JSON 字段:`title`、`summary`、`key_points`(3-5 条)、`tags`(2-4 条)、`category`。
|
||||
- 前端呈现:单卡片带渐变主题、标签胶囊、要点列表,可连续追加多张卡片。
|
||||
|
||||
## 使用建议
|
||||
|
||||
- 文本过短会提醒补充,可先汇总再生成卡片。
|
||||
- 长文本无需截断,直接生成;如需深度分析可先用其他工具精炼后再制作卡片。
|
||||
BIN
plugins/actions/flash-card/flash_card.png
Normal file
BIN
plugins/actions/flash-card/flash_card.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 268 KiB |
725
plugins/actions/flash-card/flash_card.py
Normal file
725
plugins/actions/flash-card/flash_card.py
Normal file
@@ -0,0 +1,725 @@
|
||||
"""
|
||||
title: Flash Card
|
||||
author: Fu-Jie
|
||||
author_url: https://github.com/Fu-Jie
|
||||
funding_url: https://github.com/Fu-Jie/awesome-openwebui
|
||||
version: 0.2.1
|
||||
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwb2x5Z29uIHBvaW50cz0iMTIgMiAyIDcgMTIgMTIgMjIgNyAxMiAyIi8+PHBvbHlsaW5lIHBvaW50cz0iMiAxNyAxMiAyMiAyMiAxNyIvPjxwb2x5bGluZSBwb2ludHM9IjIgMTIgMTIgMTcgMjIgMTIiLz48L3N2Zz4=
|
||||
description: Quickly generates beautiful flashcards from text, extracting key points and categories.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Dict, Any, List
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from open_webui.models.users import Users
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HTML_WRAPPER_TEMPLATE = """
|
||||
<!-- OPENWEBUI_PLUGIN_OUTPUT -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="{user_language}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
background-color: transparent;
|
||||
}
|
||||
#main-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
.plugin-item {
|
||||
flex: 1 1 400px; /* Default width, allows shrinking/growing */
|
||||
min-width: 300px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
|
||||
overflow: hidden;
|
||||
border: 1px solid #e5e7eb;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.plugin-item:hover {
|
||||
box-shadow: 0 10px 15px rgba(0,0,0,0.1);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.plugin-item { flex: 1 1 100%; }
|
||||
}
|
||||
/* STYLES_INSERTION_POINT */
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="main-container">
|
||||
<!-- CONTENT_INSERTION_POINT -->
|
||||
</div>
|
||||
<!-- SCRIPTS_INSERTION_POINT -->
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
class Action:
|
||||
class Valves(BaseModel):
|
||||
MODEL_ID: str = Field(
|
||||
default="",
|
||||
description="Model ID used for generating card content. If empty, uses the current model.",
|
||||
)
|
||||
MIN_TEXT_LENGTH: int = Field(
|
||||
default=50,
|
||||
description="Minimum text length required to generate a flashcard (characters).",
|
||||
)
|
||||
LANGUAGE: str = Field(
|
||||
default="en",
|
||||
description="Target language for card content (e.g., 'en', 'zh').",
|
||||
)
|
||||
SHOW_STATUS: bool = Field(
|
||||
default=True,
|
||||
description="Whether to show status updates in the chat interface.",
|
||||
)
|
||||
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()
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: Optional[Dict[str, Any]] = None,
|
||||
__event_emitter__: Optional[Any] = None,
|
||||
__request__: Optional[Any] = None,
|
||||
) -> Optional[dict]:
|
||||
logger.info(f"Action: {__name__} triggered")
|
||||
|
||||
if not __event_emitter__:
|
||||
return body
|
||||
|
||||
# Get messages based on MESSAGE_COUNT
|
||||
messages = body.get("messages", [])
|
||||
if not messages:
|
||||
return body
|
||||
|
||||
# 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"[{role_label} Message {i}]\n{text_content}")
|
||||
|
||||
if not aggregated_parts:
|
||||
return body
|
||||
|
||||
target_message = "\n\n---\n\n".join(aggregated_parts)
|
||||
|
||||
# Check text length
|
||||
text_length = len(target_message)
|
||||
if text_length < self.valves.MIN_TEXT_LENGTH:
|
||||
await self._emit_notification(
|
||||
__event_emitter__,
|
||||
f"Text too short ({text_length} chars), recommended at least {self.valves.MIN_TEXT_LENGTH} chars.",
|
||||
"warning",
|
||||
)
|
||||
return body
|
||||
|
||||
# Notify user that we are generating the card
|
||||
await self._emit_notification(
|
||||
__event_emitter__, "⚡ Generating Flash Card...", "info"
|
||||
)
|
||||
await self._emit_status(
|
||||
__event_emitter__, "⚡ Flash Card: Starting generation...", done=False
|
||||
)
|
||||
|
||||
try:
|
||||
# 1. Extract information using LLM
|
||||
await self._emit_status(
|
||||
__event_emitter__,
|
||||
"⚡ Flash Card: Calling AI model to analyze content...",
|
||||
done=False,
|
||||
)
|
||||
|
||||
user_id = __user__.get("id") if __user__ else "default"
|
||||
user_obj = Users.get_user_by_id(user_id)
|
||||
|
||||
target_model = (
|
||||
self.valves.MODEL_ID if self.valves.MODEL_ID else body.get("model")
|
||||
)
|
||||
|
||||
system_prompt = f"""
|
||||
You are a Flash Card Generation Expert, specializing in creating knowledge cards suitable for learning and memorization. Your task is to distill text into concise, easy-to-remember flashcards.
|
||||
|
||||
Please extract the following fields and return them in JSON format:
|
||||
1. "title": Create a short, precise title (3-8 words), highlighting the core concept.
|
||||
2. "summary": Summarize the core essence in one sentence (10-25 words), making it easy to understand and remember.
|
||||
3. "key_points": List 3-5 key memory points (5-15 words each).
|
||||
- Each point should be an independent knowledge unit.
|
||||
- Use concise, conversational expression.
|
||||
- Avoid long sentences.
|
||||
4. "tags": List 2-4 classification tags (1-3 words each).
|
||||
5. "category": Choose a main category (e.g., Concept, Skill, Fact, Method, etc.).
|
||||
|
||||
Target Language: {self.valves.LANGUAGE}
|
||||
|
||||
Important Principles:
|
||||
- **Minimalism**: Refine each point to the extreme.
|
||||
- **Memory First**: Content should be easy to memorize and recall.
|
||||
- **Core Focus**: Extract only the most core knowledge points.
|
||||
- **Conversational**: Use easy-to-understand language.
|
||||
- Return ONLY the JSON object, do not include markdown formatting.
|
||||
"""
|
||||
|
||||
prompt = f"Please refine the following text into a learning flashcard:\n\n{target_message}"
|
||||
|
||||
payload = {
|
||||
"model": target_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
response = await generate_chat_completion(__request__, payload, user_obj)
|
||||
content = response["choices"][0]["message"]["content"]
|
||||
|
||||
await self._emit_status(
|
||||
__event_emitter__,
|
||||
"⚡ Flash Card: AI analysis complete, parsing data...",
|
||||
done=False,
|
||||
)
|
||||
|
||||
# Parse JSON
|
||||
try:
|
||||
# simple cleanup in case of markdown code blocks
|
||||
if "```json" in content:
|
||||
content = content.split("```json")[1].split("```")[0].strip()
|
||||
elif "```" in content:
|
||||
content = content.split("```")[1].split("```")[0].strip()
|
||||
|
||||
card_data = json.loads(content)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to parse JSON: {e}, content: {content}")
|
||||
await self._emit_status(
|
||||
__event_emitter__, "❌ Flash Card: Data parsing failed", done=True
|
||||
)
|
||||
await self._emit_notification(
|
||||
__event_emitter__,
|
||||
"❌ Failed to generate card data, please try again.",
|
||||
"error",
|
||||
)
|
||||
return body
|
||||
|
||||
# 2. Generate HTML components
|
||||
await self._emit_status(
|
||||
__event_emitter__, "⚡ Flash Card: Rendering card...", done=False
|
||||
)
|
||||
card_content, card_style = self.generate_html_card_components(card_data)
|
||||
|
||||
# 3. Append to message
|
||||
# Extract existing HTML if any
|
||||
existing_html_block = ""
|
||||
match = re.search(
|
||||
r"```html\s*(<!-- OPENWEBUI_PLUGIN_OUTPUT -->[\s\S]*?)```",
|
||||
body["messages"][-1]["content"],
|
||||
)
|
||||
if match:
|
||||
existing_html_block = match.group(1)
|
||||
|
||||
if self.valves.CLEAR_PREVIOUS_HTML:
|
||||
body["messages"][-1]["content"] = self._remove_existing_html(
|
||||
body["messages"][-1]["content"]
|
||||
)
|
||||
final_html = self._merge_html(
|
||||
"", card_content, card_style, "", self.valves.LANGUAGE
|
||||
)
|
||||
else:
|
||||
if existing_html_block:
|
||||
body["messages"][-1]["content"] = self._remove_existing_html(
|
||||
body["messages"][-1]["content"]
|
||||
)
|
||||
final_html = self._merge_html(
|
||||
existing_html_block,
|
||||
card_content,
|
||||
card_style,
|
||||
"",
|
||||
self.valves.LANGUAGE,
|
||||
)
|
||||
else:
|
||||
final_html = self._merge_html(
|
||||
"", card_content, card_style, "", self.valves.LANGUAGE
|
||||
)
|
||||
|
||||
html_embed_tag = f"```html\n{final_html}\n```"
|
||||
body["messages"][-1]["content"] += f"\n\n{html_embed_tag}"
|
||||
|
||||
await self._emit_status(
|
||||
__event_emitter__, "✅ Flash Card: Generation complete!", done=True
|
||||
)
|
||||
await self._emit_notification(
|
||||
__event_emitter__, "⚡ Flash Card generated successfully!", "success"
|
||||
)
|
||||
|
||||
return body
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating knowledge card: {e}")
|
||||
await self._emit_status(
|
||||
__event_emitter__, "❌ Flash Card: Generation failed", done=True
|
||||
)
|
||||
await self._emit_notification(
|
||||
__event_emitter__,
|
||||
f"❌ Error generating knowledge card: {str(e)}",
|
||||
"error",
|
||||
)
|
||||
return body
|
||||
|
||||
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*<!-- OPENWEBUI_PLUGIN_OUTPUT -->[\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 (
|
||||
"<!-- OPENWEBUI_PLUGIN_OUTPUT -->" in existing_html_code
|
||||
and "<!-- CONTENT_INSERTION_POINT -->" 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'<div class="plugin-item">\n{new_content}\n</div>'
|
||||
|
||||
if new_styles:
|
||||
base_html = base_html.replace(
|
||||
"/* STYLES_INSERTION_POINT */",
|
||||
f"{new_styles}\n/* STYLES_INSERTION_POINT */",
|
||||
)
|
||||
|
||||
base_html = base_html.replace(
|
||||
"<!-- CONTENT_INSERTION_POINT -->",
|
||||
f"{wrapped_content}\n<!-- CONTENT_INSERTION_POINT -->",
|
||||
)
|
||||
|
||||
if new_scripts:
|
||||
base_html = base_html.replace(
|
||||
"<!-- SCRIPTS_INSERTION_POINT -->",
|
||||
f"{new_scripts}\n<!-- SCRIPTS_INSERTION_POINT -->",
|
||||
)
|
||||
|
||||
return base_html.strip()
|
||||
|
||||
def generate_html_card_components(self, data):
|
||||
# Enhanced CSS with premium styling
|
||||
style = """
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
|
||||
|
||||
.knowledge-card-container {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 30px 0;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.knowledge-card {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 3px;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.knowledge-card:hover {
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
box-shadow: 0 25px 50px -12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.knowledge-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2, #f093fb, #4facfe);
|
||||
border-radius: 20px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
z-index: -1;
|
||||
filter: blur(10px);
|
||||
}
|
||||
|
||||
.knowledge-card:hover::before {
|
||||
opacity: 0.7;
|
||||
animation: glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes glow {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
|
||||
.card-inner {
|
||||
background: #ffffff;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 32px 28px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
right: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
|
||||
animation: rotate 15s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.card-category {
|
||||
font-size: 0.7em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
opacity: 0.95;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 700;
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.75em;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 28px;
|
||||
color: #1a1a1a;
|
||||
background: linear-gradient(to bottom, #ffffff 0%, #fafafa 100%);
|
||||
}
|
||||
|
||||
.card-summary {
|
||||
font-size: 1.05em;
|
||||
color: #374151;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.7;
|
||||
border-left: 5px solid #764ba2;
|
||||
padding: 16px 20px;
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);
|
||||
border-radius: 0 12px 12px 0;
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-summary::before {
|
||||
content: '"';
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: 10px;
|
||||
font-size: 4em;
|
||||
color: rgba(118, 75, 162, 0.1);
|
||||
font-family: Georgia, serif;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.card-section-title {
|
||||
font-size: 0.85em;
|
||||
font-weight: 700;
|
||||
color: #764ba2;
|
||||
margin-bottom: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-section-title::before {
|
||||
content: '';
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
background: linear-gradient(to bottom, #667eea, #764ba2);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.card-points {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-points li {
|
||||
margin-bottom: 14px;
|
||||
padding: 12px 16px 12px 44px;
|
||||
position: relative;
|
||||
line-height: 1.6;
|
||||
color: #374151;
|
||||
background: #ffffff;
|
||||
border-radius: 10px;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid #e5e7eb;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-points li:hover {
|
||||
transform: translateX(5px);
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%);
|
||||
border-color: #764ba2;
|
||||
box-shadow: 0 4px 12px rgba(118, 75, 162, 0.1);
|
||||
}
|
||||
|
||||
.card-points li::before {
|
||||
content: '✓';
|
||||
color: #ffffff;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
font-weight: bold;
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-size: 0.85em;
|
||||
box-shadow: 0 2px 8px rgba(118, 75, 162, 0.3);
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
padding: 20px 28px;
|
||||
background: linear-gradient(to right, #f8fafc 0%, #f1f5f9 100%);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
border-top: 2px solid #e5e7eb;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-tag-label {
|
||||
font-size: 0.75em;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.card-tag {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 6px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
cursor: default;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.card-tag:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.card-inner {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
background: linear-gradient(to bottom, #1e1e1e 0%, #252525 100%);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.card-summary {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%);
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.card-summary::before {
|
||||
color: rgba(118, 75, 162, 0.2);
|
||||
}
|
||||
|
||||
.card-points li {
|
||||
color: #d1d5db;
|
||||
background: #2d2d2d;
|
||||
border-color: #404040;
|
||||
}
|
||||
|
||||
.card-points li:hover {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%);
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
background: linear-gradient(to right, #252525 0%, #2d2d2d 100%);
|
||||
border-top-color: #404040;
|
||||
}
|
||||
|
||||
.card-tag-label {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 640px) {
|
||||
.knowledge-card {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
# Generate tags HTML
|
||||
tags_html = ""
|
||||
if "tags" in data and data["tags"]:
|
||||
for tag in data["tags"]:
|
||||
tags_html += f'<div class="card-tag"><span class="card-tag-label">#</span>{tag}</div>'
|
||||
|
||||
# Generate key points HTML
|
||||
points_html = ""
|
||||
if "key_points" in data and data["key_points"]:
|
||||
for point in data["key_points"]:
|
||||
points_html += f"<li>{point}</li>"
|
||||
|
||||
# Build the card HTML structure
|
||||
content = f"""
|
||||
<div class="knowledge-card-container">
|
||||
<div class="knowledge-card">
|
||||
<div class="card-inner">
|
||||
<div class="card-header">
|
||||
<div class="card-category">{data.get('category', 'Knowledge')}</div>
|
||||
<h2 class="card-title">{data.get('title', 'Flash Card')}</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-summary">
|
||||
{data.get('summary', '')}
|
||||
</div>
|
||||
|
||||
<div class="card-section-title">KEY POINTS</div>
|
||||
<ul class="card-points">
|
||||
{points_html}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
{tags_html}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
return content, style
|
||||
BIN
plugins/actions/flash-card/flash_card_cn.png
Normal file
BIN
plugins/actions/flash-card/flash_card_cn.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 291 KiB |
708
plugins/actions/flash-card/闪记卡.py
Normal file
708
plugins/actions/flash-card/闪记卡.py
Normal file
@@ -0,0 +1,708 @@
|
||||
"""
|
||||
title: 闪记卡 (Flash Card)
|
||||
author: Fu-Jie
|
||||
author_url: https://github.com/Fu-Jie
|
||||
funding_url: https://github.com/Fu-Jie/awesome-openwebui
|
||||
version: 0.2.1
|
||||
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImciIHgxPSIwIiB5MT0iMCIgeDI9IjEiIHkyPSIxIj48c3RvcCBvZmZzZXQ9IjAlIiBzdG9wLWNvbG9yPSIjRkZENzAwIi8+PHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjRkZBNzAwIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTEzIDJMMyA3djEzbDEwIDV2LTZ6IiBmaWxsPSJ1cmwoI2cpIi8+PHBhdGggZD0iTTEzIDJ2Nmw4LTN2MTNsLTggM3YtNnoiIGZpbGw9IiM2NjdlZWEiLz48cGF0aCBkPSJNMTMgMnY2bTAgNXYxMCIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjEuNSIgc3Ryb2tlLW9wYWNpdHk9IjAuMyIvPjwvc3ZnPg==
|
||||
description: 快速将文本提炼为精美的学习记忆卡片,支持核心要点提取与分类。
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Dict, Any, List
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from open_webui.models.users import Users
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HTML_WRAPPER_TEMPLATE = """
|
||||
<!-- OPENWEBUI_PLUGIN_OUTPUT -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="{user_language}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
background-color: transparent;
|
||||
}
|
||||
#main-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
.plugin-item {
|
||||
flex: 1 1 400px; /* 默认宽度,允许伸缩 */
|
||||
min-width: 300px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
|
||||
overflow: hidden;
|
||||
border: 1px solid #e5e7eb;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.plugin-item:hover {
|
||||
box-shadow: 0 10px 15px rgba(0,0,0,0.1);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.plugin-item { flex: 1 1 100%; }
|
||||
}
|
||||
/* STYLES_INSERTION_POINT */
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="main-container">
|
||||
<!-- CONTENT_INSERTION_POINT -->
|
||||
</div>
|
||||
<!-- SCRIPTS_INSERTION_POINT -->
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
class Action:
|
||||
class Valves(BaseModel):
|
||||
MODEL_ID: str = Field(
|
||||
default="",
|
||||
description="用于生成卡片内容的模型 ID。如果为空,则使用当前模型。",
|
||||
)
|
||||
MIN_TEXT_LENGTH: int = Field(
|
||||
default=50, description="生成闪记卡所需的最小文本长度(字符数)。"
|
||||
)
|
||||
LANGUAGE: str = Field(
|
||||
default="zh", description="卡片内容的目标语言 (例如 'zh', 'en')。"
|
||||
)
|
||||
SHOW_STATUS: bool = Field(
|
||||
default=True, 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()
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: Optional[Dict[str, Any]] = None,
|
||||
__event_emitter__: Optional[Any] = None,
|
||||
__request__: Optional[Any] = None,
|
||||
) -> Optional[dict]:
|
||||
logger.info(f"Action: {__name__} 触发")
|
||||
|
||||
if not __event_emitter__:
|
||||
return body
|
||||
|
||||
# Get messages based on MESSAGE_COUNT
|
||||
messages = body.get("messages", [])
|
||||
if not messages:
|
||||
return body
|
||||
|
||||
# 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"[{role_label} 消息 {i}]\n{text_content}")
|
||||
|
||||
if not aggregated_parts:
|
||||
return body
|
||||
|
||||
target_message = "\n\n---\n\n".join(aggregated_parts)
|
||||
|
||||
# Check text length
|
||||
text_length = len(target_message)
|
||||
if text_length < self.valves.MIN_TEXT_LENGTH:
|
||||
await self._emit_notification(
|
||||
__event_emitter__,
|
||||
f"文本内容过短 ({text_length} 字符),建议至少 {self.valves.MIN_TEXT_LENGTH} 字符。",
|
||||
"warning",
|
||||
)
|
||||
return body
|
||||
|
||||
# Notify user that we are generating the card
|
||||
await self._emit_notification(__event_emitter__, "⚡ 正在生成闪记卡...", "info")
|
||||
await self._emit_status(__event_emitter__, "⚡ 闪记卡: 开始生成...", done=False)
|
||||
|
||||
try:
|
||||
# 1. Extract information using LLM
|
||||
await self._emit_status(
|
||||
__event_emitter__, "⚡ 闪记卡: 正在调用 AI 模型分析内容...", done=False
|
||||
)
|
||||
|
||||
user_id = __user__.get("id") if __user__ else "default"
|
||||
user_obj = Users.get_user_by_id(user_id)
|
||||
|
||||
target_model = (
|
||||
self.valves.MODEL_ID if self.valves.MODEL_ID else body.get("model")
|
||||
)
|
||||
|
||||
system_prompt = f"""
|
||||
你是一个闪记卡生成专家,专注于创建适合学习和记忆的知识卡片。你的任务是将文本提炼成简洁、易记的学习卡片。
|
||||
|
||||
请提取以下字段,并以 JSON 格式返回:
|
||||
1. "title": 创建一个简短、精准的标题(3-8 个词),突出核心概念
|
||||
2. "summary": 用一句话总结核心要义(10-25 个词),要通俗易懂、便于记忆
|
||||
3. "key_points": 列出 3-5 个关键记忆点(每个 5-15 个词)
|
||||
- 每个要点应该是独立的知识点
|
||||
- 使用简洁、口语化的表达
|
||||
- 避免冗长的句子
|
||||
4. "tags": 列出 2-4 个分类标签(每个 1-3 个词)
|
||||
5. "category": 选择一个主分类(如:概念、技能、事实、方法等)
|
||||
|
||||
目标语言: {self.valves.LANGUAGE}
|
||||
|
||||
重要原则:
|
||||
- **极简主义**: 每个要点都要精炼到极致
|
||||
- **记忆优先**: 内容要便于记忆和回忆
|
||||
- **核心聚焦**: 只提取最核心的知识点
|
||||
- **口语化**: 使用通俗易懂的语言
|
||||
- 只返回 JSON 对象,不要包含 markdown 格式
|
||||
"""
|
||||
|
||||
prompt = f"请将以下文本提炼成一张学习记忆卡片:\n\n{target_message}"
|
||||
|
||||
payload = {
|
||||
"model": target_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
response = await generate_chat_completion(__request__, payload, user_obj)
|
||||
content = response["choices"][0]["message"]["content"]
|
||||
|
||||
await self._emit_status(
|
||||
__event_emitter__, "⚡ 闪记卡: AI 分析完成,正在解析数据...", done=False
|
||||
)
|
||||
|
||||
# Parse JSON
|
||||
try:
|
||||
# simple cleanup in case of markdown code blocks
|
||||
if "```json" in content:
|
||||
content = content.split("```json")[1].split("```")[0].strip()
|
||||
elif "```" in content:
|
||||
content = content.split("```")[1].split("```")[0].strip()
|
||||
|
||||
card_data = json.loads(content)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to parse JSON: {e}, content: {content}")
|
||||
await self._emit_status(
|
||||
__event_emitter__, "❌ 闪记卡: 数据解析失败", done=True
|
||||
)
|
||||
await self._emit_notification(
|
||||
__event_emitter__, "❌ 生成卡片数据失败,请重试。", "error"
|
||||
)
|
||||
return body
|
||||
|
||||
# 2. Generate HTML components
|
||||
await self._emit_status(
|
||||
__event_emitter__, "⚡ 闪记卡: 正在渲染卡片...", done=False
|
||||
)
|
||||
card_content, card_style = self.generate_html_card_components(card_data)
|
||||
|
||||
# 3. Append to message
|
||||
# Extract existing HTML if any
|
||||
existing_html_block = ""
|
||||
match = re.search(
|
||||
r"```html\s*(<!-- OPENWEBUI_PLUGIN_OUTPUT -->[\s\S]*?)```",
|
||||
body["messages"][-1]["content"],
|
||||
)
|
||||
if match:
|
||||
existing_html_block = match.group(1)
|
||||
|
||||
if self.valves.CLEAR_PREVIOUS_HTML:
|
||||
body["messages"][-1]["content"] = self._remove_existing_html(
|
||||
body["messages"][-1]["content"]
|
||||
)
|
||||
final_html = self._merge_html(
|
||||
"", card_content, card_style, "", self.valves.LANGUAGE
|
||||
)
|
||||
else:
|
||||
if existing_html_block:
|
||||
body["messages"][-1]["content"] = self._remove_existing_html(
|
||||
body["messages"][-1]["content"]
|
||||
)
|
||||
final_html = self._merge_html(
|
||||
existing_html_block,
|
||||
card_content,
|
||||
card_style,
|
||||
"",
|
||||
self.valves.LANGUAGE,
|
||||
)
|
||||
else:
|
||||
final_html = self._merge_html(
|
||||
"", card_content, card_style, "", self.valves.LANGUAGE
|
||||
)
|
||||
|
||||
html_embed_tag = f"```html\n{final_html}\n```"
|
||||
body["messages"][-1]["content"] += f"\n\n{html_embed_tag}"
|
||||
|
||||
await self._emit_status(
|
||||
__event_emitter__, "✅ 闪记卡: 生成完成!", done=True
|
||||
)
|
||||
await self._emit_notification(
|
||||
__event_emitter__, "⚡ 闪记卡生成成功!", "success"
|
||||
)
|
||||
|
||||
return body
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating knowledge card: {e}")
|
||||
await self._emit_status(__event_emitter__, "❌ 闪记卡: 生成失败", done=True)
|
||||
await self._emit_notification(
|
||||
__event_emitter__, f"❌ 生成知识卡片时出错: {str(e)}", "error"
|
||||
)
|
||||
return body
|
||||
|
||||
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*<!-- OPENWEBUI_PLUGIN_OUTPUT -->[\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 (
|
||||
"<!-- OPENWEBUI_PLUGIN_OUTPUT -->" in existing_html_code
|
||||
and "<!-- CONTENT_INSERTION_POINT -->" 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'<div class="plugin-item">\n{new_content}\n</div>'
|
||||
|
||||
if new_styles:
|
||||
base_html = base_html.replace(
|
||||
"/* STYLES_INSERTION_POINT */",
|
||||
f"{new_styles}\n/* STYLES_INSERTION_POINT */",
|
||||
)
|
||||
|
||||
base_html = base_html.replace(
|
||||
"<!-- CONTENT_INSERTION_POINT -->",
|
||||
f"{wrapped_content}\n<!-- CONTENT_INSERTION_POINT -->",
|
||||
)
|
||||
|
||||
if new_scripts:
|
||||
base_html = base_html.replace(
|
||||
"<!-- SCRIPTS_INSERTION_POINT -->",
|
||||
f"{new_scripts}\n<!-- SCRIPTS_INSERTION_POINT -->",
|
||||
)
|
||||
|
||||
return base_html.strip()
|
||||
|
||||
def generate_html_card_components(self, data):
|
||||
# Enhanced CSS with premium styling
|
||||
style = """
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
|
||||
|
||||
.knowledge-card-container {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 30px 0;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.knowledge-card {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 3px;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.knowledge-card:hover {
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
box-shadow: 0 25px 50px -12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.knowledge-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2, #f093fb, #4facfe);
|
||||
border-radius: 20px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
z-index: -1;
|
||||
filter: blur(10px);
|
||||
}
|
||||
|
||||
.knowledge-card:hover::before {
|
||||
opacity: 0.7;
|
||||
animation: glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes glow {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
|
||||
.card-inner {
|
||||
background: #ffffff;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 32px 28px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
right: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
|
||||
animation: rotate 15s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.card-category {
|
||||
font-size: 0.7em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
opacity: 0.95;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 700;
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.75em;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 28px;
|
||||
color: #1a1a1a;
|
||||
background: linear-gradient(to bottom, #ffffff 0%, #fafafa 100%);
|
||||
}
|
||||
|
||||
.card-summary {
|
||||
font-size: 1.05em;
|
||||
color: #374151;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.7;
|
||||
border-left: 5px solid #764ba2;
|
||||
padding: 16px 20px;
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);
|
||||
border-radius: 0 12px 12px 0;
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-summary::before {
|
||||
content: '"';
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: 10px;
|
||||
font-size: 4em;
|
||||
color: rgba(118, 75, 162, 0.1);
|
||||
font-family: Georgia, serif;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.card-section-title {
|
||||
font-size: 0.85em;
|
||||
font-weight: 700;
|
||||
color: #764ba2;
|
||||
margin-bottom: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-section-title::before {
|
||||
content: '';
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
background: linear-gradient(to bottom, #667eea, #764ba2);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.card-points {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-points li {
|
||||
margin-bottom: 14px;
|
||||
padding: 12px 16px 12px 44px;
|
||||
position: relative;
|
||||
line-height: 1.6;
|
||||
color: #374151;
|
||||
background: #ffffff;
|
||||
border-radius: 10px;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid #e5e7eb;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-points li:hover {
|
||||
transform: translateX(5px);
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%);
|
||||
border-color: #764ba2;
|
||||
box-shadow: 0 4px 12px rgba(118, 75, 162, 0.1);
|
||||
}
|
||||
|
||||
.card-points li::before {
|
||||
content: '✓';
|
||||
color: #ffffff;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
font-weight: bold;
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-size: 0.85em;
|
||||
box-shadow: 0 2px 8px rgba(118, 75, 162, 0.3);
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
padding: 20px 28px;
|
||||
background: linear-gradient(to right, #f8fafc 0%, #f1f5f9 100%);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
border-top: 2px solid #e5e7eb;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-tag-label {
|
||||
font-size: 0.75em;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.card-tag {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 6px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
cursor: default;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.card-tag:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.card-inner {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
background: linear-gradient(to bottom, #1e1e1e 0%, #252525 100%);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.card-summary {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%);
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.card-summary::before {
|
||||
color: rgba(118, 75, 162, 0.2);
|
||||
}
|
||||
|
||||
.card-points li {
|
||||
color: #d1d5db;
|
||||
background: #2d2d2d;
|
||||
border-color: #404040;
|
||||
}
|
||||
|
||||
.card-points li:hover {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%);
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
background: linear-gradient(to right, #252525 0%, #2d2d2d 100%);
|
||||
border-top-color: #404040;
|
||||
}
|
||||
|
||||
.card-tag-label {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 640px) {
|
||||
.knowledge-card {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
# Generate tags HTML
|
||||
tags_html = ""
|
||||
if "tags" in data and data["tags"]:
|
||||
for tag in data["tags"]:
|
||||
tags_html += f'<div class="card-tag"><span class="card-tag-label">#</span>{tag}</div>'
|
||||
|
||||
# Generate key points HTML
|
||||
points_html = ""
|
||||
if "key_points" in data and data["key_points"]:
|
||||
for point in data["key_points"]:
|
||||
points_html += f"<li>{point}</li>"
|
||||
|
||||
# Build the card HTML structure
|
||||
content = f"""
|
||||
<div class="knowledge-card-container">
|
||||
<div class="knowledge-card">
|
||||
<div class="card-inner">
|
||||
<div class="card-header">
|
||||
<div class="card-category">{data.get('category', 'Knowledge')}</div>
|
||||
<h2 class="card-title">{data.get('title', 'Flash Card')}</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-summary">
|
||||
{data.get('summary', '')}
|
||||
</div>
|
||||
|
||||
<div class="card-section-title">KEY POINTS</div>
|
||||
<ul class="card-points">
|
||||
{points_html}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
{tags_html}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
return content, style
|
||||
Reference in New Issue
Block a user