增强 Gemini 总结模板资讯密度与统计展示
- 新增模板统计视图模型,接入消息总数/活跃人数/文本量/媒体量等核心指标\n- 追加深度统计卡片(Links/Emoji/Video/Sections/Bullets/Quotes/Code)并展示活跃等级\n- 从结构化章节提取话题雷达标签与核心看点,提升信息可读性\n- 调整模板为高信息密度布局,参考 gemini-code 风格进行 KPI、标签与双栏信息区展示\n- 模板模式下停止在正文拼接群概览与 tokens 文本,改由可视化卡片与页脚指标展示\n- 完善渲染链路参数传递:message_stats 与 metadata 全量传入模板渲染
This commit is contained in:
@@ -516,11 +516,393 @@ class MessageSummaryPlugin(MessagePluginInterface):
|
||||
rendered = "".join(html_parts)
|
||||
return cls._sanitize_rendered_html(rendered)
|
||||
|
||||
def _render_summary_template_html(self, group_name: str, summary_text: str) -> str:
|
||||
@staticmethod
|
||||
def _strip_markdown_inline(text: str) -> str:
|
||||
"""清理行内 Markdown 标记,输出可直接展示的纯文本。
|
||||
|
||||
设计说明:
|
||||
1. 仅处理常见行内语法(粗体/斜体/代码/链接),避免把模板渲染复杂度继续堆高;
|
||||
2. 不在这里做 HTML 生成,保证“结构化渲染层”始终输出纯文本;
|
||||
3. 处理后再交给模板做自动转义,避免注入风险。
|
||||
"""
|
||||
cleaned = str(text or "").strip()
|
||||
if not cleaned:
|
||||
return ""
|
||||
# 图片语法: -> alt
|
||||
cleaned = re.sub(r"!\[([^\]]*)\]\([^)]+\)", r"\1", cleaned)
|
||||
# 链接语法:[text](url) -> text
|
||||
cleaned = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", cleaned)
|
||||
# 行内代码:`code` -> code
|
||||
cleaned = re.sub(r"`([^`]+)`", r"\1", cleaned)
|
||||
# 粗体/斜体标记清理
|
||||
cleaned = re.sub(r"(\*\*|__)(.*?)\1", r"\2", cleaned)
|
||||
cleaned = re.sub(r"(\*|_)(.*?)\1", r"\2", cleaned)
|
||||
# 删除零宽字符,避免昵称或内容出现布局异常。
|
||||
cleaned = re.sub(r"[\u200b-\u200f\u2060\ufeff]", "", cleaned)
|
||||
return cleaned.strip()
|
||||
|
||||
@classmethod
|
||||
def _extract_llm_payload_text(cls, summary_text: str) -> str:
|
||||
"""从 LLM 返回文本中提取真正的总结正文。
|
||||
|
||||
兼容场景:
|
||||
1. 直接返回 Markdown 正文;
|
||||
2. 返回 JSON 字符串(如 {"text":"..."});
|
||||
3. 返回被双引号包裹且含转义换行的字符串。
|
||||
"""
|
||||
text = str(summary_text or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
# 第一层:尝试按 JSON 解析外层包装。
|
||||
try:
|
||||
if (text.startswith("{") and text.endswith("}")) or (text.startswith("[") and text.endswith("]")):
|
||||
payload = json.loads(text)
|
||||
if isinstance(payload, dict):
|
||||
for key in ("text", "summary", "answer", "content", "result"):
|
||||
value = payload.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
if isinstance(payload, str) and payload.strip():
|
||||
return payload.strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 第二层:处理被引号包裹的 JSON 字符串(例如 "\"# 标题\\n内容\"")。
|
||||
try:
|
||||
if text.startswith('"') and text.endswith('"'):
|
||||
decoded = json.loads(text)
|
||||
if isinstance(decoded, str) and decoded.strip():
|
||||
return decoded.strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return text
|
||||
|
||||
@classmethod
|
||||
def _build_summary_layout_data(cls, summary_text: str) -> Dict[str, Any]:
|
||||
"""把 LLM 总结文本重排为模板可直接消费的结构化数据。
|
||||
|
||||
输出结构:
|
||||
- lead: 导语文本(通常来自一级标题后的首段)
|
||||
- sections: 分节内容,每节包含 title + items
|
||||
- fallback_text: 兜底纯文本(解析失败时仍可展示)
|
||||
"""
|
||||
raw_text = cls._extract_llm_payload_text(summary_text)
|
||||
lines = [line.rstrip() for line in str(raw_text or "").splitlines()]
|
||||
|
||||
# 标准化:去除分隔线与空白噪音,减少模板渲染无效块。
|
||||
normalized_lines: List[str] = []
|
||||
for raw_line in lines:
|
||||
line = str(raw_line or "").strip()
|
||||
if not line:
|
||||
normalized_lines.append("")
|
||||
continue
|
||||
if re.fullmatch(r"[-*_]{3,}", line):
|
||||
# Markdown 横线仅用于语义分隔,结构化渲染里直接跳过。
|
||||
continue
|
||||
normalized_lines.append(line)
|
||||
|
||||
document_title = ""
|
||||
lead_lines: List[str] = []
|
||||
sections: List[Dict[str, Any]] = []
|
||||
current_section: Optional[Dict[str, Any]] = None
|
||||
paragraph_buffer: List[str] = []
|
||||
in_code_block = False
|
||||
code_lines: List[str] = []
|
||||
|
||||
def flush_paragraph() -> None:
|
||||
"""把段落缓存写入当前分节。"""
|
||||
nonlocal paragraph_buffer, current_section
|
||||
if not paragraph_buffer:
|
||||
return
|
||||
paragraph_text = cls._strip_markdown_inline(" ".join(paragraph_buffer).strip())
|
||||
paragraph_buffer = []
|
||||
if not paragraph_text:
|
||||
return
|
||||
if current_section is None:
|
||||
lead_lines.append(paragraph_text)
|
||||
return
|
||||
current_section["items"].append({"kind": "paragraph", "text": paragraph_text})
|
||||
|
||||
def ensure_section(title: str) -> None:
|
||||
"""确保当前分节存在,并切换到新分节。"""
|
||||
nonlocal current_section
|
||||
clean_title = cls._strip_markdown_inline(title) or "未命名章节"
|
||||
current_section = {"title": clean_title, "items": []}
|
||||
sections.append(current_section)
|
||||
|
||||
for line in normalized_lines:
|
||||
# 代码块开始/结束处理。
|
||||
if line.startswith("```"):
|
||||
flush_paragraph()
|
||||
if in_code_block:
|
||||
if current_section is None:
|
||||
ensure_section("代码片段")
|
||||
code_text = "\n".join(code_lines).strip()
|
||||
if code_text:
|
||||
current_section["items"].append({"kind": "code", "text": code_text})
|
||||
code_lines = []
|
||||
in_code_block = False
|
||||
else:
|
||||
in_code_block = True
|
||||
code_lines = []
|
||||
continue
|
||||
|
||||
if in_code_block:
|
||||
code_lines.append(line)
|
||||
continue
|
||||
|
||||
if not line:
|
||||
flush_paragraph()
|
||||
continue
|
||||
|
||||
# 一级标题:用于页面主标题文案,不作为分节。
|
||||
h1_match = re.match(r"^#\s+(.+)$", line)
|
||||
if h1_match:
|
||||
flush_paragraph()
|
||||
title_text = cls._strip_markdown_inline(h1_match.group(1))
|
||||
if title_text:
|
||||
document_title = title_text
|
||||
continue
|
||||
|
||||
# 二级及以下标题统一作为分节标题。
|
||||
section_match = re.match(r"^#{2,6}\s+(.+)$", line)
|
||||
if section_match:
|
||||
flush_paragraph()
|
||||
ensure_section(section_match.group(1))
|
||||
continue
|
||||
|
||||
# 有些模型会输出“1. 标题”这种无井号标题,识别后转成分节。
|
||||
numeric_heading_match = re.match(r"^\d+[.)、]\s+(.+)$", line)
|
||||
if numeric_heading_match and len(cls._strip_markdown_inline(numeric_heading_match.group(1))) <= 40:
|
||||
flush_paragraph()
|
||||
ensure_section(numeric_heading_match.group(1))
|
||||
continue
|
||||
|
||||
# 引用块。
|
||||
quote_match = re.match(r"^>\s*(.+)$", line)
|
||||
if quote_match:
|
||||
flush_paragraph()
|
||||
if current_section is None:
|
||||
ensure_section("精选引用")
|
||||
quote_text = cls._strip_markdown_inline(quote_match.group(1))
|
||||
if quote_text:
|
||||
current_section["items"].append({"kind": "quote", "text": quote_text})
|
||||
continue
|
||||
|
||||
# 无序列表。
|
||||
bullet_match = re.match(r"^[-*+]\s+(.+)$", line)
|
||||
if bullet_match:
|
||||
flush_paragraph()
|
||||
if current_section is None:
|
||||
ensure_section("重点清单")
|
||||
bullet_text = cls._strip_markdown_inline(bullet_match.group(1))
|
||||
if bullet_text:
|
||||
current_section["items"].append({"kind": "bullet", "text": bullet_text})
|
||||
continue
|
||||
|
||||
# 有序列表作为 bullet 统一渲染,避免复杂列表嵌套导致样式凌乱。
|
||||
ordered_match = re.match(r"^\d+[.)]\s+(.+)$", line)
|
||||
if ordered_match:
|
||||
flush_paragraph()
|
||||
if current_section is None:
|
||||
ensure_section("重点清单")
|
||||
ordered_text = cls._strip_markdown_inline(ordered_match.group(1))
|
||||
if ordered_text:
|
||||
current_section["items"].append({"kind": "bullet", "text": ordered_text})
|
||||
continue
|
||||
|
||||
# 其余内容按段落累计,遇到空行或结构标记时再落盘。
|
||||
paragraph_buffer.append(line)
|
||||
|
||||
flush_paragraph()
|
||||
if in_code_block and code_lines:
|
||||
if current_section is None:
|
||||
ensure_section("代码片段")
|
||||
code_text = "\n".join(code_lines).strip()
|
||||
if code_text:
|
||||
current_section["items"].append({"kind": "code", "text": code_text})
|
||||
|
||||
# 清理空章节,并过滤“统计注入段”等不需要重复展示的标题。
|
||||
skip_titles = {"群概览", "tokens", "统计信息", "消息统计"}
|
||||
cleaned_sections: List[Dict[str, Any]] = []
|
||||
for section in sections:
|
||||
if not section.get("items"):
|
||||
continue
|
||||
title_text = str(section.get("title") or "").strip().lower()
|
||||
if title_text in skip_titles:
|
||||
continue
|
||||
cleaned_sections.append(section)
|
||||
|
||||
# 导语优先使用 lead_lines,否则取首个章节首条,保证顶部一定有可读摘要。
|
||||
lead = " ".join(lead_lines).strip()
|
||||
if not lead and cleaned_sections:
|
||||
first_items = cleaned_sections[0].get("items") or []
|
||||
if first_items:
|
||||
lead = str(first_items[0].get("text", "")).strip()
|
||||
if not lead:
|
||||
lead = "暂无总结内容。"
|
||||
|
||||
# 模板兜底纯文本:用于极端解析失败场景。
|
||||
fallback_text = cls._strip_markdown_inline(
|
||||
"\n".join([line for line in normalized_lines if line]).strip()
|
||||
) or "暂无总结内容。"
|
||||
|
||||
return {
|
||||
"document_title": document_title,
|
||||
"lead": lead,
|
||||
"sections": cleaned_sections,
|
||||
"fallback_text": fallback_text,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _to_int(value: Any) -> int:
|
||||
"""安全转换为整数,避免模板渲染阶段因脏数据抛错。"""
|
||||
try:
|
||||
return int(value or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
@classmethod
|
||||
def _build_summary_template_metrics(
|
||||
cls,
|
||||
message_stats: Optional[Dict[str, Any]],
|
||||
layout_data: Dict[str, Any],
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""构建 Gemini 模板高信息密度展示所需的统计视图数据。"""
|
||||
# 说明:
|
||||
# 1. 该函数专注“模板展示层”数据准备,不改动业务存储;
|
||||
# 2. 尽量从 message_stats 与结构化 sections 中提取可读指标;
|
||||
# 3. 数据不全时采用稳妥兜底,避免模板渲染失败。
|
||||
stats = message_stats or {}
|
||||
usage = (metadata or {}).get("usage", {}) or {}
|
||||
sections = layout_data.get("sections") or []
|
||||
|
||||
total_count = cls._to_int(stats.get("total_count"))
|
||||
participant_count = cls._to_int(stats.get("participant_count"))
|
||||
text_count = cls._to_int(stats.get("text_count"))
|
||||
image_count = cls._to_int(stats.get("image_count"))
|
||||
video_count = cls._to_int(stats.get("video_count"))
|
||||
link_count = cls._to_int(stats.get("link_count"))
|
||||
emoji_count = cls._to_int(stats.get("emoji_count"))
|
||||
|
||||
# 媒体量:把图片+视频合并,符合卡片化概览阅读习惯。
|
||||
media_count = image_count + video_count
|
||||
section_count = len(sections)
|
||||
bullet_count = 0
|
||||
quote_count = 0
|
||||
code_count = 0
|
||||
for section in sections:
|
||||
for item in section.get("items", []):
|
||||
kind = str(item.get("kind") or "")
|
||||
if kind == "bullet":
|
||||
bullet_count += 1
|
||||
elif kind == "quote":
|
||||
quote_count += 1
|
||||
elif kind == "code":
|
||||
code_count += 1
|
||||
|
||||
# 活跃度等级:仅用于 UI 文案提示,不参与核心业务计算。
|
||||
if total_count >= 1600:
|
||||
activity_badge = "爆炸活跃"
|
||||
elif total_count >= 1000:
|
||||
activity_badge = "高活跃"
|
||||
elif total_count >= 400:
|
||||
activity_badge = "中活跃"
|
||||
else:
|
||||
activity_badge = "常规活跃"
|
||||
|
||||
# 话题雷达:优先取前几节标题,避免依赖额外 LLM 输出字段。
|
||||
topic_tags: List[str] = []
|
||||
for section in sections:
|
||||
title_text = str(section.get("title") or "").strip()
|
||||
if not title_text:
|
||||
continue
|
||||
normalized = re.sub(r"^[\d\W_]+", "", title_text)
|
||||
normalized = re.sub(r"\s+", " ", normalized).strip()
|
||||
if not normalized:
|
||||
continue
|
||||
topic_tags.append(normalized[:24])
|
||||
if len(topic_tags) >= 6:
|
||||
break
|
||||
|
||||
# 核心看点:优先抓 bullet,没有则回退 paragraph。
|
||||
highlights: List[str] = []
|
||||
for section in sections:
|
||||
for item in section.get("items", []):
|
||||
item_text = str(item.get("text") or "").strip()
|
||||
if not item_text:
|
||||
continue
|
||||
if item.get("kind") == "bullet":
|
||||
highlights.append(item_text[:120])
|
||||
if len(highlights) >= 4:
|
||||
break
|
||||
if len(highlights) >= 4:
|
||||
break
|
||||
if not highlights:
|
||||
for section in sections:
|
||||
for item in section.get("items", []):
|
||||
item_text = str(item.get("text") or "").strip()
|
||||
if not item_text:
|
||||
continue
|
||||
highlights.append(item_text[:120])
|
||||
if len(highlights) >= 4:
|
||||
break
|
||||
if len(highlights) >= 4:
|
||||
break
|
||||
|
||||
# 使用统计:读取模型调用元数据,辅助观测总结成本。
|
||||
total_tokens = cls._to_int(usage.get("total_tokens"))
|
||||
prompt_tokens = cls._to_int(usage.get("prompt_tokens"))
|
||||
completion_tokens = cls._to_int(usage.get("completion_tokens"))
|
||||
latency = usage.get("latency")
|
||||
latency_text = f"{latency}s" if latency not in (None, "") else "-"
|
||||
|
||||
return {
|
||||
"kpi_cards": [
|
||||
{"label": "Msgs", "value": total_count, "tone": "slate"},
|
||||
{"label": "Active", "value": participant_count, "tone": "blue"},
|
||||
{"label": "Text", "value": text_count, "tone": "violet"},
|
||||
{"label": "Media", "value": media_count, "tone": "emerald"},
|
||||
],
|
||||
"mini_stats": [
|
||||
{"label": "Links", "value": link_count},
|
||||
{"label": "Emoji", "value": emoji_count},
|
||||
{"label": "Video", "value": video_count},
|
||||
{"label": "Sections", "value": section_count},
|
||||
{"label": "Bullets", "value": bullet_count},
|
||||
{"label": "Quotes", "value": quote_count},
|
||||
{"label": "Code", "value": code_count},
|
||||
],
|
||||
"topic_tags": topic_tags,
|
||||
"highlights": highlights,
|
||||
"activity_badge": activity_badge,
|
||||
"token_total": total_tokens,
|
||||
"token_prompt": prompt_tokens,
|
||||
"token_completion": completion_tokens,
|
||||
"latency_text": latency_text,
|
||||
}
|
||||
|
||||
def _render_summary_template_html(
|
||||
self,
|
||||
group_name: str,
|
||||
summary_text: str,
|
||||
message_stats: Optional[Dict[str, Any]] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""根据模板路径渲染总结图片 HTML。"""
|
||||
# 约束:模板只负责展示,正文仍然由模型生成并在此做安全转义后注入。
|
||||
# 约束:
|
||||
# 1. 不再把 LLM 原文直接转 HTML 内嵌到模板;
|
||||
# 2. 先结构化解析文本,再由模板按组件渲染,稳定控制最终排版。
|
||||
renderer = HtmlTemplateRenderer()
|
||||
summary_html = self._summary_markdown_to_html(summary_text)
|
||||
layout_data = self._build_summary_layout_data(summary_text)
|
||||
metrics_data = self._build_summary_template_metrics(
|
||||
message_stats=message_stats,
|
||||
layout_data=layout_data,
|
||||
metadata=metadata,
|
||||
)
|
||||
# 说明:
|
||||
# 1. 这里注入“本地字体 CSS”到模板,避免依赖 Google Fonts 等外网资源;
|
||||
# 2. 字体文件统一从仓库根目录 fonts/ 下读取,便于部署时统一管理;
|
||||
@@ -531,7 +913,13 @@ class MessageSummaryPlugin(MessagePluginInterface):
|
||||
{
|
||||
"title": f"{group_name} 群聊总结",
|
||||
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"summary_html": Markup(summary_html),
|
||||
# 兼容字段:保留给旧模板使用,新版 Gemini 模板已不再依赖该字段。
|
||||
"summary_html": Markup(self._summary_markdown_to_html(summary_text)),
|
||||
"summary_doc_title": layout_data.get("document_title", ""),
|
||||
"summary_lead": layout_data.get("lead", ""),
|
||||
"summary_sections": layout_data.get("sections", []),
|
||||
"summary_fallback_text": layout_data.get("fallback_text", ""),
|
||||
"summary_metrics": metrics_data,
|
||||
"local_font_css": Markup(local_font_css),
|
||||
},
|
||||
)
|
||||
@@ -631,6 +1019,8 @@ class MessageSummaryPlugin(MessagePluginInterface):
|
||||
self,
|
||||
answer: str,
|
||||
group_name: str,
|
||||
message_stats: Optional[Dict[str, Any]],
|
||||
metadata: Optional[Dict[str, Any]],
|
||||
output_filename: str,
|
||||
total_timeout: int,
|
||||
) -> str:
|
||||
@@ -642,7 +1032,12 @@ class MessageSummaryPlugin(MessagePluginInterface):
|
||||
# 优先模板渲染,便于后续仅改 HTML 文件完成样式迭代。
|
||||
if self._summary_image_mode != "markdown":
|
||||
try:
|
||||
html_content = self._render_summary_template_html(group_name=group_name, summary_text=answer)
|
||||
html_content = self._render_summary_template_html(
|
||||
group_name=group_name,
|
||||
summary_text=answer,
|
||||
message_stats=message_stats,
|
||||
metadata=metadata,
|
||||
)
|
||||
await asyncio.wait_for(html_to_image(html_content, str(output_path)), timeout=total_timeout)
|
||||
if not output_path.exists() or output_path.stat().st_size < 1024:
|
||||
raise RuntimeError("模板截图输出文件异常")
|
||||
@@ -800,8 +1195,12 @@ class MessageSummaryPlugin(MessagePluginInterface):
|
||||
answer = self._clean_summary_output(response.get("text", ""))
|
||||
metadata = {"usage": response.get("usage", {}) or {}}
|
||||
spath = ""
|
||||
answer = self._prepend_stats_section(answer, message_stats or {})
|
||||
answer = self._append_usage_info(answer, metadata)
|
||||
# 说明:
|
||||
# 1. 模板模式下统计信息由卡片呈现,不再额外拼接“群概览”文本块;
|
||||
# 2. markdown 模式仍保留旧逻辑,保证兼容历史输出。
|
||||
if self._summary_image_mode == "markdown":
|
||||
answer = self._prepend_stats_section(answer, message_stats or {})
|
||||
answer = self._append_usage_info(answer, metadata)
|
||||
|
||||
if answer and len(answer.strip()) > 0:
|
||||
try:
|
||||
@@ -814,6 +1213,8 @@ class MessageSummaryPlugin(MessagePluginInterface):
|
||||
spath = await self._render_summary_image(
|
||||
answer=answer,
|
||||
group_name=group_name,
|
||||
message_stats=message_stats or {},
|
||||
metadata=metadata,
|
||||
output_filename=output_path,
|
||||
total_timeout=total_timeout,
|
||||
)
|
||||
|
||||
@@ -115,118 +115,106 @@
|
||||
.meta-value.brand {
|
||||
color: var(--brand);
|
||||
}
|
||||
.topic-radar {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.topic-tags {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.topic-tag {
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
.summary-body {
|
||||
padding: 18px 20px 20px;
|
||||
}
|
||||
.markdown-body {
|
||||
.lead-callout {
|
||||
margin-top: 10px;
|
||||
padding: 10px 12px;
|
||||
background: var(--ok-soft);
|
||||
border-left: 2px solid var(--ok-line);
|
||||
border-radius: 0 6px 6px 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.86;
|
||||
color: var(--text);
|
||||
word-wrap: break-word;
|
||||
line-height: 1.78;
|
||||
color: #166534;
|
||||
}
|
||||
.markdown-body > *:first-child { margin-top: 0; }
|
||||
.markdown-body > *:last-child { margin-bottom: 0; }
|
||||
.markdown-body h1,
|
||||
.markdown-body h2,
|
||||
.markdown-body h3,
|
||||
.markdown-body h4,
|
||||
.markdown-body h5,
|
||||
.markdown-body h6 {
|
||||
color: var(--title);
|
||||
margin: 16px 0 8px;
|
||||
line-height: 1.42;
|
||||
.sections {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.section-card {
|
||||
border: 1px solid #e5eaf1;
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
padding: 10px 11px;
|
||||
}
|
||||
.section-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: var(--title);
|
||||
letter-spacing: -.01em;
|
||||
}
|
||||
.markdown-body h1 { font-size: 20px; border-bottom: 1px solid var(--line); padding-bottom: 6px; }
|
||||
.markdown-body h2 { font-size: 18px; }
|
||||
.markdown-body h3 { font-size: 16px; }
|
||||
.markdown-body h4,
|
||||
.markdown-body h5,
|
||||
.markdown-body h6 { font-size: 14px; }
|
||||
.markdown-body p { margin: 10px 0; }
|
||||
.markdown-body ul,
|
||||
.markdown-body ol {
|
||||
margin: 8px 0 12px;
|
||||
padding-left: 18px;
|
||||
.section-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.markdown-body li { margin: 4px 0; }
|
||||
.markdown-body blockquote {
|
||||
margin: 10px 0;
|
||||
.item-paragraph {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.82;
|
||||
color: var(--text);
|
||||
}
|
||||
.item-bullet {
|
||||
margin: 0;
|
||||
padding-left: 14px;
|
||||
position: relative;
|
||||
font-size: 13px;
|
||||
line-height: 1.78;
|
||||
color: var(--text);
|
||||
}
|
||||
.item-bullet::before {
|
||||
content: "•";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
color: var(--brand);
|
||||
font-weight: 900;
|
||||
}
|
||||
.item-quote {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
border-left: 3px solid var(--quote-line);
|
||||
background: var(--quote-bg);
|
||||
color: #475569;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
.markdown-body hr {
|
||||
border: none;
|
||||
border-top: 1px dashed #dbe3ee;
|
||||
margin: 14px 0;
|
||||
}
|
||||
.markdown-body a {
|
||||
color: var(--brand);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dotted rgba(37, 99, 235, .5);
|
||||
}
|
||||
.markdown-body strong {
|
||||
color: #1e293b;
|
||||
font-weight: 800;
|
||||
}
|
||||
.markdown-body em {
|
||||
color: #475569;
|
||||
font-style: italic;
|
||||
}
|
||||
.markdown-body code {
|
||||
/* 说明:代码字体优先使用本地/系统等宽字体栈,保证服务端离线场景可读。 */
|
||||
font-family: var(--abot-font-code, "Cascadia Mono", "Consolas", "SFMono-Regular", Menlo, monospace);
|
||||
font-size: 12px;
|
||||
background: #eff6ff;
|
||||
color: #1e40af;
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
.markdown-body pre {
|
||||
margin: 10px 0;
|
||||
.item-code {
|
||||
margin: 0;
|
||||
padding: 10px 11px;
|
||||
border-radius: 6px;
|
||||
background: var(--code-bg);
|
||||
color: var(--code-text);
|
||||
overflow-x: auto;
|
||||
border: 1px solid #1e293b;
|
||||
}
|
||||
.markdown-body pre code {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
/* 说明:代码字体优先使用本地/系统等宽字体栈,保证服务端离线场景可读。 */
|
||||
font-family: var(--abot-font-code, "Cascadia Mono", "Consolas", "SFMono-Regular", Menlo, monospace);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.markdown-body table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 10px 0;
|
||||
font-size: 11px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
.markdown-body th,
|
||||
.markdown-body td {
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 6px 7px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.markdown-body th {
|
||||
background: #f8fafc;
|
||||
color: #334155;
|
||||
font-weight: 700;
|
||||
}
|
||||
.markdown-body img {
|
||||
max-width: 100%;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e2e8f0;
|
||||
margin: 6px 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.summary-footer {
|
||||
margin-top: 14px;
|
||||
@@ -255,6 +243,82 @@
|
||||
font-size: 12px;
|
||||
color: #166534;
|
||||
}
|
||||
.fallback-text {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.76;
|
||||
color: #475569;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.45fr 1fr;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.mini-card {
|
||||
border: 1px solid #e5eaf1;
|
||||
border-radius: 8px;
|
||||
padding: 10px 11px;
|
||||
background: #fff;
|
||||
}
|
||||
.mini-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .08em;
|
||||
color: #94a3b8;
|
||||
font-weight: 800;
|
||||
}
|
||||
.mini-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.mini-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
}
|
||||
.mini-row .value {
|
||||
font-weight: 800;
|
||||
color: #1e293b;
|
||||
font-family: var(--abot-font-code, "Cascadia Mono", "Consolas", monospace);
|
||||
}
|
||||
.highlight-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.highlight-item {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.72;
|
||||
color: #334155;
|
||||
padding-left: 12px;
|
||||
position: relative;
|
||||
}
|
||||
.highlight-item::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 7px;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: #2563eb;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.meta-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -265,39 +329,85 @@
|
||||
<div class="header-title">CHAT INSIGHTS <span class="accent">SUMMARY</span></div>
|
||||
<div class="header-id">{{ generated_at }}</div>
|
||||
</div>
|
||||
<div class="header-tag">Daily Archive</div>
|
||||
<div class="header-tag">{{ summary_metrics.activity_badge or "Daily Archive" }}</div>
|
||||
</div>
|
||||
<div class="meta-grid">
|
||||
{% for card in summary_metrics.kpi_cards %}
|
||||
<div class="meta-item">
|
||||
<span class="label-tiny">Type</span>
|
||||
<div class="meta-value">群总结</div>
|
||||
<span class="label-tiny">{{ card.label }}</span>
|
||||
<div class="meta-value {% if card.tone == 'blue' %}brand{% endif %}">{{ card.value }}</div>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="label-tiny">Mode</span>
|
||||
<div class="meta-value brand">Template</div>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="label-tiny">Render</span>
|
||||
<div class="meta-value">HTML</div>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="label-tiny">Engine</span>
|
||||
<div class="meta-value">LLM</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="topic-radar">
|
||||
<span class="label-tiny"># Personal Interest Radar</span>
|
||||
<div class="topic-tags">
|
||||
{% for tag in summary_metrics.topic_tags %}
|
||||
<span class="topic-tag">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
{% if not summary_metrics.topic_tags %}
|
||||
<span class="topic-tag">暂无热点标签</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="summary-body">
|
||||
<span class="label-tiny"># {{ title }}</span>
|
||||
<div class="markdown-body">
|
||||
{{ summary_html }}
|
||||
<span class="label-tiny"># {{ summary_doc_title or title }}</span>
|
||||
<div class="lead-callout">
|
||||
{{ summary_lead or "暂无总结内容。" }}
|
||||
</div>
|
||||
<div class="safe-callout">
|
||||
内容已按 Markdown 富标签样式渲染(标题、列表、表格、代码块、引用)。
|
||||
<div class="sections">
|
||||
{% for section in summary_sections %}
|
||||
<article class="section-card">
|
||||
<h3 class="section-title">{{ section["title"] }}</h3>
|
||||
<div class="section-items">
|
||||
{% for item in section["items"] %}
|
||||
{% if item["kind"] == "bullet" %}
|
||||
<p class="item-bullet">{{ item["text"] }}</p>
|
||||
{% elif item["kind"] == "quote" %}
|
||||
<blockquote class="item-quote">{{ item["text"] }}</blockquote>
|
||||
{% elif item["kind"] == "code" %}
|
||||
<pre class="item-code">{{ item["text"] }}</pre>
|
||||
{% else %}
|
||||
<p class="item-paragraph">{{ item["text"] }}</p>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if not summary_sections %}
|
||||
<div class="fallback-text">{{ summary_fallback_text }}</div>
|
||||
{% endif %}
|
||||
<div class="summary-grid">
|
||||
<div class="mini-card">
|
||||
<h4 class="mini-title"># Deep Stats</h4>
|
||||
<div class="mini-list">
|
||||
{% for item in summary_metrics.mini_stats %}
|
||||
<div class="mini-row">
|
||||
<span>{{ item.label }}</span>
|
||||
<span class="value">{{ item.value }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mini-card">
|
||||
<h4 class="mini-title"># Core Highlights</h4>
|
||||
<div class="highlight-list">
|
||||
{% for text in summary_metrics.highlights %}
|
||||
<p class="highlight-item">{{ text }}</p>
|
||||
{% endfor %}
|
||||
{% if not summary_metrics.highlights %}
|
||||
<p class="highlight-item">暂无可提取的核心看点</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="safe-callout">内容已按结构化规则重排渲染,不再直接内嵌原始 HTML。</div>
|
||||
<div class="summary-footer">
|
||||
<span>ABOT · Message Summary Gemini Style</span>
|
||||
<span class="right">Renderer: Playwright</span>
|
||||
<span class="right">Tokens: {{ summary_metrics.token_total }} · Latency: {{ summary_metrics.latency_text }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user