Improved Rendering
This commit is contained in:
@@ -10,12 +10,12 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
from typing import Any, Dict, List, Tuple
|
from typing import Any, Dict, List, Tuple, Callable, Optional
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from ..core import TemplateSection, ChapterStorage
|
from ..core import TemplateSection, ChapterStorage
|
||||||
from ..ir import ALLOWED_BLOCK_TYPES, IRValidator
|
from ..ir import ALLOWED_BLOCK_TYPES, ALLOWED_INLINE_MARKS, IRValidator
|
||||||
from ..prompts import (
|
from ..prompts import (
|
||||||
SYSTEM_PROMPT_CHAPTER_JSON,
|
SYSTEM_PROMPT_CHAPTER_JSON,
|
||||||
build_chapter_user_prompt,
|
build_chapter_user_prompt,
|
||||||
@@ -28,10 +28,41 @@ except ImportError: # pragma: no cover - optional dependency
|
|||||||
_json_repair_fn = None
|
_json_repair_fn = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChapterJsonParseError(ValueError):
|
||||||
|
"""Raised when the LLM output for a chapter cannot be parsed as valid JSON."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, raw_text: Optional[str] = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.raw_text = raw_text
|
||||||
|
|
||||||
|
|
||||||
class ChapterGenerationNode(BaseNode):
|
class ChapterGenerationNode(BaseNode):
|
||||||
"""负责按章节调用LLM并校验JSON结构"""
|
"""负责按章节调用LLM并校验JSON结构"""
|
||||||
|
|
||||||
_COLON_EQUALS_PATTERN = re.compile(r'(":\s*)=')
|
_COLON_EQUALS_PATTERN = re.compile(r'(":\s*)=')
|
||||||
|
_LINE_BREAK_SENTINEL = "__LINE_BREAK__"
|
||||||
|
_INLINE_MARK_ALIASES = {
|
||||||
|
"strong": "bold",
|
||||||
|
"b": "bold",
|
||||||
|
"em": "italic",
|
||||||
|
"emphasis": "italic",
|
||||||
|
"i": "italic",
|
||||||
|
"u": "underline",
|
||||||
|
"strike-through": "strike",
|
||||||
|
"strikethrough": "strike",
|
||||||
|
"s": "strike",
|
||||||
|
"codeblock": "code",
|
||||||
|
"monospace": "code",
|
||||||
|
"hyperlink": "link",
|
||||||
|
"url": "link",
|
||||||
|
"colour": "color",
|
||||||
|
"textcolor": "color",
|
||||||
|
"bgcolor": "highlight",
|
||||||
|
"background": "highlight",
|
||||||
|
"highlightcolor": "highlight",
|
||||||
|
"sub": "subscript",
|
||||||
|
"sup": "superscript",
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(self, llm_client, validator: IRValidator, storage: ChapterStorage):
|
def __init__(self, llm_client, validator: IRValidator, storage: ChapterStorage):
|
||||||
"""
|
"""
|
||||||
@@ -51,6 +82,7 @@ class ChapterGenerationNode(BaseNode):
|
|||||||
section: TemplateSection,
|
section: TemplateSection,
|
||||||
context: Dict[str, Any],
|
context: Dict[str, Any],
|
||||||
run_dir: Path,
|
run_dir: Path,
|
||||||
|
stream_callback: Optional[Callable[[str, Dict[str, Any]], None]] = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""针对单个章节调用LLM,校验/落盘章节JSON并返回结构化结果"""
|
"""针对单个章节调用LLM,校验/落盘章节JSON并返回结构化结果"""
|
||||||
@@ -64,7 +96,13 @@ class ChapterGenerationNode(BaseNode):
|
|||||||
llm_payload = self._build_payload(section, context)
|
llm_payload = self._build_payload(section, context)
|
||||||
user_message = build_chapter_user_prompt(llm_payload)
|
user_message = build_chapter_user_prompt(llm_payload)
|
||||||
|
|
||||||
raw_text = self._stream_llm(user_message, chapter_dir, **kwargs)
|
raw_text = self._stream_llm(
|
||||||
|
user_message,
|
||||||
|
chapter_dir,
|
||||||
|
stream_callback=stream_callback,
|
||||||
|
section_meta=chapter_meta,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
chapter_json = self._parse_chapter(raw_text)
|
chapter_json = self._parse_chapter(raw_text)
|
||||||
|
|
||||||
# 自动补全关键字段后再校验
|
# 自动补全关键字段后再校验
|
||||||
@@ -150,8 +188,15 @@ class ChapterGenerationNode(BaseNode):
|
|||||||
payload["globalContext"]["sectionBudgets"] = chapter_plan["sections"]
|
payload["globalContext"]["sectionBudgets"] = chapter_plan["sections"]
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
def _stream_llm(self, user_message: str, chapter_dir: Path, **kwargs) -> str:
|
def _stream_llm(
|
||||||
"""流式调用LLM并实时写入raw文件"""
|
self,
|
||||||
|
user_message: str,
|
||||||
|
chapter_dir: Path,
|
||||||
|
stream_callback: Optional[Callable[[str, Dict[str, Any]], None]] = None,
|
||||||
|
section_meta: Optional[Dict[str, Any]] = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> str:
|
||||||
|
"""流式调用LLM并实时写入raw文件,同时通过回调将delta抛出。"""
|
||||||
chunks: List[str] = []
|
chunks: List[str] = []
|
||||||
with self.storage.capture_stream(chapter_dir) as stream_fp:
|
with self.storage.capture_stream(chapter_dir) as stream_fp:
|
||||||
stream = self.llm_client.stream_invoke(
|
stream = self.llm_client.stream_invoke(
|
||||||
@@ -163,6 +208,12 @@ class ChapterGenerationNode(BaseNode):
|
|||||||
for delta in stream:
|
for delta in stream:
|
||||||
stream_fp.write(delta)
|
stream_fp.write(delta)
|
||||||
chunks.append(delta)
|
chunks.append(delta)
|
||||||
|
if stream_callback:
|
||||||
|
meta = section_meta or {}
|
||||||
|
try:
|
||||||
|
stream_callback(delta, meta)
|
||||||
|
except Exception as callback_error: # pragma: no cover - 仅记录,不阻断主流程
|
||||||
|
logger.warning(f"章节流式回调失败: {callback_error}")
|
||||||
return "".join(chunks)
|
return "".join(chunks)
|
||||||
|
|
||||||
def _parse_chapter(self, raw_text: str) -> Dict[str, Any]:
|
def _parse_chapter(self, raw_text: str) -> Dict[str, Any]:
|
||||||
@@ -192,9 +243,13 @@ class ChapterGenerationNode(BaseNode):
|
|||||||
try:
|
try:
|
||||||
data = self._parse_with_candidates(candidate_payloads[-1:])
|
data = self._parse_with_candidates(candidate_payloads[-1:])
|
||||||
except json.JSONDecodeError as inner_exc:
|
except json.JSONDecodeError as inner_exc:
|
||||||
raise ValueError(f"章节JSON解析失败: {inner_exc}") from inner_exc
|
raise ChapterJsonParseError(
|
||||||
|
f"章节JSON解析失败: {inner_exc}", raw_text=cleaned
|
||||||
|
) from inner_exc
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"章节JSON解析失败: {exc}") from exc
|
raise ChapterJsonParseError(
|
||||||
|
f"章节JSON解析失败: {exc}", raw_text=cleaned
|
||||||
|
) from exc
|
||||||
|
|
||||||
if "chapter" in data and isinstance(data["chapter"], dict):
|
if "chapter" in data and isinstance(data["chapter"], dict):
|
||||||
return data["chapter"]
|
return data["chapter"]
|
||||||
@@ -400,6 +455,7 @@ class ChapterGenerationNode(BaseNode):
|
|||||||
if not isinstance(block, dict):
|
if not isinstance(block, dict):
|
||||||
continue
|
continue
|
||||||
self._ensure_block_type(block)
|
self._ensure_block_type(block)
|
||||||
|
self._sanitize_block_content(block)
|
||||||
block_type = block.get("type")
|
block_type = block.get("type")
|
||||||
if block_type == "list":
|
if block_type == "list":
|
||||||
items = block.get("items")
|
items = block.get("items")
|
||||||
@@ -424,6 +480,98 @@ class ChapterGenerationNode(BaseNode):
|
|||||||
|
|
||||||
walk(chapter.get("blocks"))
|
walk(chapter.get("blocks"))
|
||||||
|
|
||||||
|
def _sanitize_block_content(self, block: Dict[str, Any]):
|
||||||
|
"""根据类型做精细化修复,例如清理paragraph内的非法inline mark"""
|
||||||
|
block_type = block.get("type")
|
||||||
|
if block_type == "paragraph":
|
||||||
|
self._normalize_paragraph_block(block)
|
||||||
|
|
||||||
|
def _normalize_paragraph_block(self, block: Dict[str, Any]):
|
||||||
|
"""将paragraph的inlines统一规整,剔除非法marks"""
|
||||||
|
inlines = block.get("inlines")
|
||||||
|
normalized_runs: List[Dict[str, Any]] = []
|
||||||
|
if isinstance(inlines, list) and inlines:
|
||||||
|
for run in inlines:
|
||||||
|
normalized_runs.extend(self._coerce_inline_run(run))
|
||||||
|
else:
|
||||||
|
normalized_runs = [self._as_inline_run(self._extract_block_text(block))]
|
||||||
|
if not normalized_runs:
|
||||||
|
normalized_runs = [self._as_inline_run("")]
|
||||||
|
block["inlines"] = normalized_runs
|
||||||
|
|
||||||
|
def _coerce_inline_run(self, run: Any) -> List[Dict[str, Any]]:
|
||||||
|
"""将任意inline写法规整为合法run"""
|
||||||
|
if isinstance(run, dict):
|
||||||
|
normalized_run = dict(run)
|
||||||
|
text = normalized_run.get("text")
|
||||||
|
if not isinstance(text, str):
|
||||||
|
text = "" if text is None else str(text)
|
||||||
|
marks = normalized_run.get("marks")
|
||||||
|
sanitized_marks, extra_text = self._sanitize_inline_marks(marks)
|
||||||
|
normalized_run["marks"] = sanitized_marks
|
||||||
|
normalized_run["text"] = (text or "") + extra_text
|
||||||
|
return [normalized_run]
|
||||||
|
if isinstance(run, str):
|
||||||
|
return [self._as_inline_run(run)]
|
||||||
|
if isinstance(run, (int, float)):
|
||||||
|
return [self._as_inline_run(str(run))]
|
||||||
|
if isinstance(run, list):
|
||||||
|
normalized: List[Dict[str, Any]] = []
|
||||||
|
for item in run:
|
||||||
|
normalized.extend(self._coerce_inline_run(item))
|
||||||
|
return normalized
|
||||||
|
return [self._as_inline_run("" if run is None else str(run))]
|
||||||
|
|
||||||
|
def _sanitize_inline_marks(self, marks: Any) -> Tuple[List[Dict[str, Any]], str]:
|
||||||
|
"""过滤非法marks并将break类控制符转成文本"""
|
||||||
|
text_suffix = ""
|
||||||
|
if marks is None:
|
||||||
|
return [], text_suffix
|
||||||
|
mark_list = marks if isinstance(marks, list) else [marks]
|
||||||
|
sanitized: List[Dict[str, Any]] = []
|
||||||
|
for mark in mark_list:
|
||||||
|
normalized_mark, extra_text = self._normalize_inline_mark(mark)
|
||||||
|
if normalized_mark:
|
||||||
|
sanitized.append(normalized_mark)
|
||||||
|
if extra_text:
|
||||||
|
text_suffix += extra_text
|
||||||
|
return sanitized, text_suffix
|
||||||
|
|
||||||
|
def _normalize_inline_mark(self, mark: Any) -> Tuple[Dict[str, Any] | None, str]:
|
||||||
|
"""对单个mark做兼容映射,或者在必要时转换为文本"""
|
||||||
|
if not isinstance(mark, dict):
|
||||||
|
return None, ""
|
||||||
|
canonical_type = self._canonical_inline_mark_type(mark.get("type"))
|
||||||
|
if canonical_type == self._LINE_BREAK_SENTINEL:
|
||||||
|
return None, "\n"
|
||||||
|
if canonical_type in ALLOWED_INLINE_MARKS:
|
||||||
|
normalized = dict(mark)
|
||||||
|
normalized["type"] = canonical_type
|
||||||
|
return normalized, ""
|
||||||
|
return None, ""
|
||||||
|
|
||||||
|
def _canonical_inline_mark_type(self, mark_type: Any) -> str | None:
|
||||||
|
"""将mark type映射为Schema所支持的取值"""
|
||||||
|
if not isinstance(mark_type, str):
|
||||||
|
return None
|
||||||
|
normalized = mark_type.strip()
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
lowered = normalized.lower()
|
||||||
|
if lowered in {"break", "linebreak", "br"}:
|
||||||
|
return self._LINE_BREAK_SENTINEL
|
||||||
|
return self._INLINE_MARK_ALIASES.get(lowered, lowered)
|
||||||
|
|
||||||
|
def _extract_block_text(self, block: Dict[str, Any]) -> str:
|
||||||
|
"""优先从text/content等字段提取fallback文本"""
|
||||||
|
for key in ("text", "content", "value", "title"):
|
||||||
|
value = block.get(key)
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
if value is not None:
|
||||||
|
return str(value)
|
||||||
|
return ""
|
||||||
|
|
||||||
def _normalize_list_items(self, items: Any) -> List[List[Dict[str, Any]]]:
|
def _normalize_list_items(self, items: Any) -> List[List[Dict[str, Any]]]:
|
||||||
"""确保list block的items为[[block, block], ...]结构"""
|
"""确保list block的items为[[block, block], ...]结构"""
|
||||||
if not isinstance(items, list):
|
if not isinstance(items, list):
|
||||||
@@ -490,16 +638,21 @@ class ChapterGenerationNode(BaseNode):
|
|||||||
text = str(block)
|
text = str(block)
|
||||||
block.clear()
|
block.clear()
|
||||||
block["type"] = "paragraph"
|
block["type"] = "paragraph"
|
||||||
block["inlines"] = [{"text": text}]
|
block["inlines"] = [self._as_inline_run(text)]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _as_paragraph_block(text: str) -> Dict[str, Any]:
|
def _as_paragraph_block(text: str) -> Dict[str, Any]:
|
||||||
"""将字符串快速包装成paragraph block,方便统一处理"""
|
"""将字符串快速包装成paragraph block,方便统一处理"""
|
||||||
return {
|
return {
|
||||||
"type": "paragraph",
|
"type": "paragraph",
|
||||||
"inlines": [{"text": text or ""}],
|
"inlines": [ChapterGenerationNode._as_inline_run(text)],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _as_inline_run(text: str) -> Dict[str, Any]:
|
||||||
|
"""构造基础inline run,保证marks字段存在"""
|
||||||
|
return {"text": text or "", "marks": []}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_with_candidates(payloads: List[str]) -> Dict[str, Any]:
|
def _parse_with_candidates(payloads: List[str]) -> Dict[str, Any]:
|
||||||
"""按顺序尝试多个payload,直到解析成功"""
|
"""按顺序尝试多个payload,直到解析成功"""
|
||||||
@@ -513,4 +666,4 @@ class ChapterGenerationNode(BaseNode):
|
|||||||
raise last_exc
|
raise last_exc
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["ChapterGenerationNode"]
|
__all__ = ["ChapterGenerationNode", "ChapterJsonParseError"]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
import html
|
import html
|
||||||
import json
|
import json
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
@@ -51,7 +52,7 @@ class HTMLRenderer:
|
|||||||
|
|
||||||
head = self._render_head(title, theme_tokens)
|
head = self._render_head(title, theme_tokens)
|
||||||
body = self._render_body()
|
body = self._render_body()
|
||||||
return f"<!DOCTYPE html>\n<html lang=\"zh-CN\">\n{head}\n{body}\n</html>"
|
return f"<!DOCTYPE html>\n<html lang=\"zh-CN\" class=\"no-js\">\n{head}\n{body}\n</html>"
|
||||||
|
|
||||||
# ====== Head / Body ======
|
# ====== Head / Body ======
|
||||||
|
|
||||||
@@ -83,6 +84,10 @@ class HTMLRenderer:
|
|||||||
<style>
|
<style>
|
||||||
{css}
|
{css}
|
||||||
</style>
|
</style>
|
||||||
|
<script>
|
||||||
|
document.documentElement.classList.remove('no-js');
|
||||||
|
document.documentElement.classList.add('js-ready');
|
||||||
|
</script>
|
||||||
</head>""".strip()
|
</head>""".strip()
|
||||||
|
|
||||||
def _render_body(self) -> str:
|
def _render_body(self) -> str:
|
||||||
@@ -423,6 +428,8 @@ class HTMLRenderer:
|
|||||||
items_html = ""
|
items_html = ""
|
||||||
for item in block.get("items", []):
|
for item in block.get("items", []):
|
||||||
content = self._render_blocks(item)
|
content = self._render_blocks(item)
|
||||||
|
if not content.strip():
|
||||||
|
continue
|
||||||
items_html += f"<li>{content}</li>"
|
items_html += f"<li>{content}</li>"
|
||||||
class_attr = f' class="{extra_class}"' if extra_class else ""
|
class_attr = f' class="{extra_class}"' if extra_class else ""
|
||||||
return f'<{tag}{class_attr}>{items_html}</{tag}>'
|
return f'<{tag}{class_attr}>{items_html}</{tag}>'
|
||||||
@@ -545,7 +552,7 @@ class HTMLRenderer:
|
|||||||
row_cells.append(f"<td>{self._escape_html(value)}</td>")
|
row_cells.append(f"<td>{self._escape_html(value)}</td>")
|
||||||
body_rows += f"<tr>{''.join(row_cells)}</tr>"
|
body_rows += f"<tr>{''.join(row_cells)}</tr>"
|
||||||
table_html = f"""
|
table_html = f"""
|
||||||
<div class="chart-fallback">
|
<div class="chart-fallback" data-prebuilt="true">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>类别</th>{header_cells}</tr>
|
<tr><th>类别</th>{header_cells}</tr>
|
||||||
@@ -556,20 +563,93 @@ class HTMLRenderer:
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
return f"<noscript>{table_html}</noscript>"
|
return table_html
|
||||||
|
|
||||||
# ====== Inline 渲染 ======
|
# ====== Inline 渲染 ======
|
||||||
|
|
||||||
|
def _normalize_inline_payload(self, run: Dict[str, Any]) -> tuple[str, List[Dict[str, Any]]]:
|
||||||
|
"""将嵌套inline node展平成基础文本与marks"""
|
||||||
|
if not isinstance(run, dict):
|
||||||
|
return ("" if run is None else str(run)), []
|
||||||
|
|
||||||
|
marks = list(run.get("marks") or [])
|
||||||
|
text_value: Any = run.get("text", "")
|
||||||
|
seen: set[int] = set()
|
||||||
|
|
||||||
|
while isinstance(text_value, dict):
|
||||||
|
obj_id = id(text_value)
|
||||||
|
if obj_id in seen:
|
||||||
|
text_value = ""
|
||||||
|
break
|
||||||
|
seen.add(obj_id)
|
||||||
|
nested_marks = text_value.get("marks")
|
||||||
|
if nested_marks:
|
||||||
|
marks.extend(nested_marks)
|
||||||
|
if "text" in text_value:
|
||||||
|
text_value = text_value.get("text")
|
||||||
|
else:
|
||||||
|
text_value = json.dumps(text_value, ensure_ascii=False)
|
||||||
|
break
|
||||||
|
|
||||||
|
if text_value is None:
|
||||||
|
text_value = ""
|
||||||
|
elif isinstance(text_value, (int, float)):
|
||||||
|
text_value = str(text_value)
|
||||||
|
elif not isinstance(text_value, str):
|
||||||
|
try:
|
||||||
|
text_value = json.dumps(text_value, ensure_ascii=False)
|
||||||
|
except TypeError:
|
||||||
|
text_value = str(text_value)
|
||||||
|
|
||||||
|
if isinstance(text_value, str):
|
||||||
|
stripped = text_value.strip()
|
||||||
|
if stripped.startswith("{") and stripped.endswith("}"):
|
||||||
|
payload = None
|
||||||
|
try:
|
||||||
|
payload = json.loads(stripped)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
try:
|
||||||
|
payload = ast.literal_eval(stripped)
|
||||||
|
except (ValueError, SyntaxError):
|
||||||
|
payload = None
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
sentinel_keys = {"xrefs", "widgets", "footnotes", "errors", "metadata"}
|
||||||
|
if set(payload.keys()).issubset(sentinel_keys):
|
||||||
|
text_value = ""
|
||||||
|
else:
|
||||||
|
inline_payload = self._coerce_inline_payload(payload)
|
||||||
|
if inline_payload:
|
||||||
|
nested_text = inline_payload.get("text")
|
||||||
|
if nested_text is not None:
|
||||||
|
text_value = nested_text
|
||||||
|
nested_marks = inline_payload.get("marks")
|
||||||
|
if isinstance(nested_marks, list):
|
||||||
|
marks.extend(nested_marks)
|
||||||
|
|
||||||
|
return text_value, marks
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _coerce_inline_payload(payload: Dict[str, Any]) -> Dict[str, Any] | None:
|
||||||
|
"""尽力将字符串里的内联节点恢复为dict,修复渲染遗漏"""
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
inline_type = payload.get("type")
|
||||||
|
if inline_type and inline_type not in {"inline", "text"}:
|
||||||
|
return None
|
||||||
|
if "text" not in payload and "marks" not in payload:
|
||||||
|
return None
|
||||||
|
return payload
|
||||||
|
|
||||||
def _render_inline(self, run: Dict[str, Any]) -> str:
|
def _render_inline(self, run: Dict[str, Any]) -> str:
|
||||||
"""渲染单个inline run,支持多种marks叠加"""
|
"""渲染单个inline run,支持多种marks叠加"""
|
||||||
marks = run.get("marks") or []
|
text_value, marks = self._normalize_inline_payload(run)
|
||||||
math_mark = next((mark for mark in marks if mark.get("type") == "math"), None)
|
math_mark = next((mark for mark in marks if mark.get("type") == "math"), None)
|
||||||
if math_mark:
|
if math_mark:
|
||||||
latex = math_mark.get("value")
|
latex = math_mark.get("value")
|
||||||
if not isinstance(latex, str) or not latex.strip():
|
if not isinstance(latex, str) or not latex.strip():
|
||||||
latex = run.get("text", "")
|
latex = text_value
|
||||||
return f'<span class="math-inline">\\( {self._escape_html(latex)} \\)</span>'
|
return f'<span class="math-inline">\\( {self._escape_html(latex)} \\)</span>'
|
||||||
text = self._escape_html(run.get("text", ""))
|
text = self._escape_html(text_value)
|
||||||
styles: List[str] = []
|
styles: List[str] = []
|
||||||
prefix: List[str] = []
|
prefix: List[str] = []
|
||||||
suffix: List[str] = []
|
suffix: List[str] = []
|
||||||
@@ -653,6 +733,30 @@ class HTMLRenderer:
|
|||||||
cursor = end + 2
|
cursor = end + 2
|
||||||
return "".join(result)
|
return "".join(result)
|
||||||
|
|
||||||
|
# ====== 文本 / 安全工具 ======
|
||||||
|
|
||||||
|
def _safe_text(self, value: Any) -> str:
|
||||||
|
"""将任意值安全转换为字符串,None与复杂对象容错"""
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
if isinstance(value, (int, float, bool)):
|
||||||
|
return str(value)
|
||||||
|
try:
|
||||||
|
return json.dumps(value, ensure_ascii=False)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
def _escape_html(self, value: Any) -> str:
|
||||||
|
"""HTML文本上下文的转义"""
|
||||||
|
return html.escape(self._safe_text(value), quote=False)
|
||||||
|
|
||||||
|
def _escape_attr(self, value: Any) -> str:
|
||||||
|
"""HTML属性上下文转义并去掉危险换行"""
|
||||||
|
escaped = html.escape(self._safe_text(value), quote=True)
|
||||||
|
return escaped.replace("\n", " ").replace("\r", " ")
|
||||||
|
|
||||||
# ====== CSS / JS ======
|
# ====== CSS / JS ======
|
||||||
|
|
||||||
def _build_css(self, tokens: Dict[str, Any]) -> str:
|
def _build_css(self, tokens: Dict[str, Any]) -> str:
|
||||||
@@ -1013,10 +1117,17 @@ table th {{
|
|||||||
min-height: 320px;
|
min-height: 320px;
|
||||||
}}
|
}}
|
||||||
.chart-fallback {{
|
.chart-fallback {{
|
||||||
|
display: none;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}}
|
}}
|
||||||
|
.no-js .chart-fallback {{
|
||||||
|
display: block;
|
||||||
|
}}
|
||||||
|
.no-js .chart-container {{
|
||||||
|
display: none;
|
||||||
|
}}
|
||||||
.chart-fallback table {{
|
.chart-fallback table {{
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@@ -1030,6 +1141,11 @@ table th {{
|
|||||||
.chart-fallback th {{
|
.chart-fallback th {{
|
||||||
background: rgba(0,0,0,0.04);
|
background: rgba(0,0,0,0.04);
|
||||||
}}
|
}}
|
||||||
|
.chart-note {{
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--secondary-color);
|
||||||
|
}}
|
||||||
figure {{
|
figure {{
|
||||||
margin: 20px 0;
|
margin: 20px 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -1091,7 +1207,19 @@ pre.code-block {{
|
|||||||
"""返回页面底部的JS,负责Chart.js注水与导出逻辑"""
|
"""返回页面底部的JS,负责Chart.js注水与导出逻辑"""
|
||||||
return """
|
return """
|
||||||
<script>
|
<script>
|
||||||
|
document.documentElement.classList.remove('no-js');
|
||||||
|
document.documentElement.classList.add('js-ready');
|
||||||
|
|
||||||
const chartRegistry = [];
|
const chartRegistry = [];
|
||||||
|
const STABLE_CHART_TYPES = ['line', 'bar'];
|
||||||
|
const CHART_TYPE_LABELS = {
|
||||||
|
line: '折线图',
|
||||||
|
bar: '柱状图',
|
||||||
|
doughnut: '圆环图',
|
||||||
|
pie: '饼图',
|
||||||
|
radar: '雷达图',
|
||||||
|
polarArea: '极地区域图'
|
||||||
|
};
|
||||||
|
|
||||||
function getThemePalette() {
|
function getThemePalette() {
|
||||||
const styles = getComputedStyle(document.body);
|
const styles = getComputedStyle(document.body);
|
||||||
@@ -1103,38 +1231,235 @@ function getThemePalette() {
|
|||||||
|
|
||||||
function applyChartTheme(chart) {
|
function applyChartTheme(chart) {
|
||||||
if (!chart) return;
|
if (!chart) return;
|
||||||
const palette = getThemePalette();
|
try {
|
||||||
const options = chart.options || {};
|
chart.update('none');
|
||||||
options.plugins = options.plugins || {};
|
} catch (err) {
|
||||||
options.plugins.legend = options.plugins.legend || {};
|
console.error('Chart refresh failed', err);
|
||||||
options.plugins.legend.labels = options.plugins.legend.labels || {};
|
|
||||||
options.plugins.legend.labels.color = palette.text;
|
|
||||||
if (options.plugins.title) {
|
|
||||||
options.plugins.title.color = palette.text;
|
|
||||||
}
|
}
|
||||||
const scales = options.scales || {};
|
}
|
||||||
Object.keys(scales).forEach(key => {
|
|
||||||
const scale = scales[key] || {};
|
function isPlainObject(value) {
|
||||||
if (scale.ticks) {
|
return Object.prototype.toString.call(value) === '[object Object]';
|
||||||
scale.ticks.color = palette.text;
|
}
|
||||||
|
|
||||||
|
function cloneDeep(value) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(cloneDeep);
|
||||||
|
}
|
||||||
|
if (isPlainObject(value)) {
|
||||||
|
const obj = {};
|
||||||
|
Object.keys(value).forEach(key => {
|
||||||
|
obj[key] = cloneDeep(value[key]);
|
||||||
|
});
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeOptions(base, override) {
|
||||||
|
const result = isPlainObject(base) ? cloneDeep(base) : {};
|
||||||
|
if (!isPlainObject(override)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
Object.keys(override).forEach(key => {
|
||||||
|
const overrideValue = override[key];
|
||||||
|
if (Array.isArray(overrideValue)) {
|
||||||
|
result[key] = cloneDeep(overrideValue);
|
||||||
|
} else if (isPlainObject(overrideValue)) {
|
||||||
|
result[key] = mergeOptions(result[key], overrideValue);
|
||||||
} else {
|
} else {
|
||||||
scale.ticks = { color: palette.text };
|
result[key] = overrideValue;
|
||||||
}
|
|
||||||
if (scale.grid) {
|
|
||||||
scale.grid.color = palette.grid;
|
|
||||||
} else {
|
|
||||||
scale.grid = { color: palette.grid };
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
options.scales = scales;
|
return result;
|
||||||
chart.options = options;
|
}
|
||||||
chart.update('none');
|
|
||||||
|
function resolveChartTypes(payload) {
|
||||||
|
const widgetType = payload && payload.widgetType ? payload.widgetType : 'chart.js/bar';
|
||||||
|
const primary = widgetType.includes('/') ? widgetType.split('/').pop() : widgetType;
|
||||||
|
const extra = Array.isArray(payload && payload.preferredTypes) ? payload.preferredTypes : [];
|
||||||
|
const pipeline = [primary, ...extra, ...STABLE_CHART_TYPES];
|
||||||
|
const result = [];
|
||||||
|
pipeline.forEach(type => {
|
||||||
|
if (type && !result.includes(type)) {
|
||||||
|
result.push(type);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result.length ? result : ['bar'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeChartType(type) {
|
||||||
|
return CHART_TYPE_LABELS[type] || type || '图表';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setChartDegradeNote(card, fromType, toType) {
|
||||||
|
if (!card) return;
|
||||||
|
card.setAttribute('data-chart-state', 'degraded');
|
||||||
|
let note = card.querySelector('.chart-note');
|
||||||
|
if (!note) {
|
||||||
|
note = document.createElement('p');
|
||||||
|
note.className = 'chart-note';
|
||||||
|
card.appendChild(note);
|
||||||
|
}
|
||||||
|
note.textContent = `${describeChartType(fromType)}渲染失败,已自动切换为${describeChartType(toType)}以确保兼容。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearChartDegradeNote(card) {
|
||||||
|
if (!card) return;
|
||||||
|
card.removeAttribute('data-chart-state');
|
||||||
|
const note = card.querySelector('.chart-note');
|
||||||
|
if (note) {
|
||||||
|
note.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFallbackTable(labels, datasets) {
|
||||||
|
if (!Array.isArray(datasets) || !datasets.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const primaryDataset = datasets.find(ds => Array.isArray(ds && ds.data));
|
||||||
|
const resolvedLabels = Array.isArray(labels) && labels.length
|
||||||
|
? labels
|
||||||
|
: (primaryDataset && primaryDataset.data ? primaryDataset.data.map((_, idx) => `数据点 ${idx + 1}`) : []);
|
||||||
|
if (!resolvedLabels.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const table = document.createElement('table');
|
||||||
|
const thead = document.createElement('thead');
|
||||||
|
const headRow = document.createElement('tr');
|
||||||
|
const categoryHeader = document.createElement('th');
|
||||||
|
categoryHeader.textContent = '类别';
|
||||||
|
headRow.appendChild(categoryHeader);
|
||||||
|
datasets.forEach((dataset, index) => {
|
||||||
|
const th = document.createElement('th');
|
||||||
|
th.textContent = dataset && dataset.label ? dataset.label : `系列${index + 1}`;
|
||||||
|
headRow.appendChild(th);
|
||||||
|
});
|
||||||
|
thead.appendChild(headRow);
|
||||||
|
table.appendChild(thead);
|
||||||
|
const tbody = document.createElement('tbody');
|
||||||
|
resolvedLabels.forEach((label, rowIdx) => {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
const labelCell = document.createElement('td');
|
||||||
|
labelCell.textContent = label;
|
||||||
|
row.appendChild(labelCell);
|
||||||
|
datasets.forEach(dataset => {
|
||||||
|
const cell = document.createElement('td');
|
||||||
|
const series = dataset && Array.isArray(dataset.data) ? dataset.data[rowIdx] : undefined;
|
||||||
|
if (typeof series === 'number') {
|
||||||
|
cell.textContent = series.toLocaleString();
|
||||||
|
} else if (series !== undefined && series !== null && series !== '') {
|
||||||
|
cell.textContent = series;
|
||||||
|
} else {
|
||||||
|
cell.textContent = '—';
|
||||||
|
}
|
||||||
|
row.appendChild(cell);
|
||||||
|
});
|
||||||
|
tbody.appendChild(row);
|
||||||
|
});
|
||||||
|
table.appendChild(tbody);
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChartFallback(canvas, payload, reason) {
|
||||||
|
const card = canvas.closest('.chart-card') || canvas.parentElement;
|
||||||
|
if (!card) return;
|
||||||
|
clearChartDegradeNote(card);
|
||||||
|
const wrapper = canvas.parentElement && canvas.parentElement.classList && canvas.parentElement.classList.contains('chart-container')
|
||||||
|
? canvas.parentElement
|
||||||
|
: null;
|
||||||
|
if (wrapper) {
|
||||||
|
wrapper.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
canvas.style.display = 'none';
|
||||||
|
}
|
||||||
|
let fallback = card.querySelector('.chart-fallback[data-dynamic="true"]');
|
||||||
|
let prebuilt = false;
|
||||||
|
if (!fallback) {
|
||||||
|
fallback = card.querySelector('.chart-fallback');
|
||||||
|
if (fallback) {
|
||||||
|
prebuilt = fallback.hasAttribute('data-prebuilt');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!fallback) {
|
||||||
|
fallback = document.createElement('div');
|
||||||
|
fallback.className = 'chart-fallback';
|
||||||
|
fallback.setAttribute('data-dynamic', 'true');
|
||||||
|
card.appendChild(fallback);
|
||||||
|
} else if (!prebuilt) {
|
||||||
|
fallback.innerHTML = '';
|
||||||
|
}
|
||||||
|
const titleFromOptions = payload && payload.props && payload.props.options &&
|
||||||
|
payload.props.options.plugins && payload.props.options.plugins.title &&
|
||||||
|
payload.props.options.plugins.title.text;
|
||||||
|
const fallbackTitle = titleFromOptions ||
|
||||||
|
(payload && payload.props && payload.props.title) ||
|
||||||
|
(payload && payload.widgetId) ||
|
||||||
|
canvas.getAttribute('id') ||
|
||||||
|
'图表';
|
||||||
|
const existingNotice = fallback.querySelector('.chart-fallback__notice');
|
||||||
|
if (existingNotice) {
|
||||||
|
existingNotice.remove();
|
||||||
|
}
|
||||||
|
const notice = document.createElement('p');
|
||||||
|
notice.className = 'chart-fallback__notice';
|
||||||
|
notice.textContent = `${fallbackTitle}:图表未能渲染,已展示表格数据${reason ? `(${reason})` : ''}`;
|
||||||
|
fallback.insertBefore(notice, fallback.firstChild || null);
|
||||||
|
if (!prebuilt) {
|
||||||
|
const table = createFallbackTable(
|
||||||
|
payload && payload.data && payload.data.labels,
|
||||||
|
payload && payload.data && payload.data.datasets
|
||||||
|
);
|
||||||
|
if (table) {
|
||||||
|
fallback.appendChild(table);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fallback.style.display = 'block';
|
||||||
|
card.setAttribute('data-chart-state', 'fallback');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChartOptions(payload) {
|
||||||
|
const rawLegend = payload && payload.props ? payload.props.legend : undefined;
|
||||||
|
let legendConfig;
|
||||||
|
if (isPlainObject(rawLegend)) {
|
||||||
|
legendConfig = mergeOptions({
|
||||||
|
display: rawLegend.display !== false,
|
||||||
|
position: rawLegend.position || 'top'
|
||||||
|
}, rawLegend);
|
||||||
|
} else {
|
||||||
|
legendConfig = {
|
||||||
|
display: rawLegend === 'hidden' ? false : true,
|
||||||
|
position: typeof rawLegend === 'string' ? rawLegend : 'top'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const baseOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: legendConfig
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (payload && payload.props && payload.props.title) {
|
||||||
|
baseOptions.plugins.title = {
|
||||||
|
display: true,
|
||||||
|
text: payload.props.title
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const overrideOptions = payload && payload.props && payload.props.options;
|
||||||
|
return mergeOptions(baseOptions, overrideOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
function instantiateChart(ctx, payload, optionsTemplate, type) {
|
||||||
|
const data = cloneDeep(payload && payload.data ? payload.data : {});
|
||||||
|
const config = {
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
options: cloneDeep(optionsTemplate)
|
||||||
|
};
|
||||||
|
return new Chart(ctx, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hydrateCharts() {
|
function hydrateCharts() {
|
||||||
if (typeof Chart === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
document.querySelectorAll('canvas[data-config-id]').forEach(canvas => {
|
document.querySelectorAll('canvas[data-config-id]').forEach(canvas => {
|
||||||
const configScript = document.getElementById(canvas.dataset.configId);
|
const configScript = document.getElementById(canvas.dataset.configId);
|
||||||
if (!configScript) return;
|
if (!configScript) return;
|
||||||
@@ -1143,33 +1468,51 @@ function hydrateCharts() {
|
|||||||
payload = JSON.parse(configScript.textContent);
|
payload = JSON.parse(configScript.textContent);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Widget JSON 解析失败', err);
|
console.error('Widget JSON 解析失败', err);
|
||||||
|
renderChartFallback(canvas, { widgetId: canvas.dataset.configId }, '配置解析失败');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const chartType = (payload.widgetType || 'chart.js/bar').split('/').pop();
|
if (typeof Chart === 'undefined') {
|
||||||
|
renderChartFallback(canvas, payload, 'Chart.js 未加载');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chartTypes = resolveChartTypes(payload);
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
const baseOptions = {
|
if (!ctx) {
|
||||||
responsive: true,
|
renderChartFallback(canvas, payload, 'Canvas 初始化失败');
|
||||||
maintainAspectRatio: false,
|
return;
|
||||||
plugins: {
|
}
|
||||||
legend: {
|
const card = canvas.closest('.chart-card') || canvas.parentElement;
|
||||||
display: payload.props && payload.props.legend !== 'hidden',
|
const optionsTemplate = buildChartOptions(payload);
|
||||||
position: (payload.props && payload.props.legend) || 'top'
|
const desiredType = chartTypes[0];
|
||||||
},
|
let chartInstance = null;
|
||||||
title: payload.props && payload.props.title ? {
|
let selectedType = null;
|
||||||
display: true,
|
let lastError;
|
||||||
text: payload.props.title
|
for (const type of chartTypes) {
|
||||||
} : undefined
|
try {
|
||||||
|
chartInstance = instantiateChart(ctx, payload, optionsTemplate, type);
|
||||||
|
selectedType = type;
|
||||||
|
break;
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err;
|
||||||
|
console.error('图表渲染失败', type, err);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
const mergedOptions = Object.assign({}, baseOptions, payload.props && payload.props.options ? payload.props.options : {});
|
if (chartInstance) {
|
||||||
const config = {
|
chartRegistry.push(chartInstance);
|
||||||
type: chartType,
|
try {
|
||||||
data: payload.data || {},
|
applyChartTheme(chartInstance);
|
||||||
options: mergedOptions
|
} catch (err) {
|
||||||
};
|
console.error('主题同步失败', selectedType || desiredType || payload && payload.widgetType || 'chart', err);
|
||||||
const chart = new Chart(ctx, config);
|
}
|
||||||
chartRegistry.push(chart);
|
if (selectedType && selectedType !== desiredType) {
|
||||||
applyChartTheme(chart);
|
setChartDegradeNote(card, desiredType, selectedType);
|
||||||
|
} else {
|
||||||
|
clearChartDegradeNote(card);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const reason = lastError && lastError.message ? lastError.message : '';
|
||||||
|
renderChartFallback(canvas, payload, reason);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1222,17 +1565,5 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
</script>
|
</script>
|
||||||
""".strip()
|
""".strip()
|
||||||
|
|
||||||
# ====== Utils ======
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _escape_html(value: Any) -> str:
|
|
||||||
"""HTML内容转义工具,避免XSS"""
|
|
||||||
return html.escape(str(value)) if value is not None else ""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _escape_attr(value: Any) -> str:
|
|
||||||
"""HTML属性值转义工具"""
|
|
||||||
return html.escape(str(value), quote=True) if value is not None else ""
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["HTMLRenderer"]
|
__all__ = ["HTMLRenderer"]
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ class Settings(BaseSettings):
|
|||||||
DOCUMENT_IR_OUTPUT_DIR: str = Field(
|
DOCUMENT_IR_OUTPUT_DIR: str = Field(
|
||||||
"final_reports/ir", description="整本IR/Manifest输出目录"
|
"final_reports/ir", description="整本IR/Manifest输出目录"
|
||||||
)
|
)
|
||||||
|
CHAPTER_JSON_MAX_ATTEMPTS: int = Field(
|
||||||
|
2, description="章节JSON解析失败时的最大尝试次数"
|
||||||
|
)
|
||||||
TEMPLATE_DIR: str = Field("ReportEngine/report_template", description="多模板目录")
|
TEMPLATE_DIR: str = Field("ReportEngine/report_template", description="多模板目录")
|
||||||
API_TIMEOUT: float = Field(900.0, description="单API超时时间(秒)")
|
API_TIMEOUT: float = Field(900.0, description="单API超时时间(秒)")
|
||||||
MAX_RETRY_DELAY: float = Field(180.0, description="最大重试间隔(秒)")
|
MAX_RETRY_DELAY: float = Field(180.0, description="最大重试间隔(秒)")
|
||||||
@@ -52,6 +55,7 @@ def print_config(config: Settings):
|
|||||||
message += f"最大内容长度: {config.MAX_CONTENT_LENGTH}\n"
|
message += f"最大内容长度: {config.MAX_CONTENT_LENGTH}\n"
|
||||||
message += f"输出目录: {config.OUTPUT_DIR}\n"
|
message += f"输出目录: {config.OUTPUT_DIR}\n"
|
||||||
message += f"章节JSON目录: {config.CHAPTER_OUTPUT_DIR}\n"
|
message += f"章节JSON目录: {config.CHAPTER_OUTPUT_DIR}\n"
|
||||||
|
message += f"章节JSON最大尝试次数: {config.CHAPTER_JSON_MAX_ATTEMPTS}\n"
|
||||||
message += f"整本IR目录: {config.DOCUMENT_IR_OUTPUT_DIR}\n"
|
message += f"整本IR目录: {config.DOCUMENT_IR_OUTPUT_DIR}\n"
|
||||||
message += f"模板目录: {config.TEMPLATE_DIR}\n"
|
message += f"模板目录: {config.TEMPLATE_DIR}\n"
|
||||||
message += f"API 超时时间: {config.API_TIMEOUT} 秒\n"
|
message += f"API 超时时间: {config.API_TIMEOUT} 秒\n"
|
||||||
|
|||||||
+442
-8
@@ -1027,6 +1027,49 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.report-stream-line {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-stream-line .timestamp {
|
||||||
|
color: #cccccc;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-stream-line .stream-badge {
|
||||||
|
border: 1px solid #444444;
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #ffffff;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-stream-line .line-text {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-stream-line.chunk {
|
||||||
|
color: #8fd5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-stream-line.warn {
|
||||||
|
color: #ffd166;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-stream-line.error {
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-stream-line.success {
|
||||||
|
color: #80ffb5;
|
||||||
|
}
|
||||||
|
|
||||||
.report-loading {
|
.report-loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1165,6 +1208,9 @@
|
|||||||
let systemStarted = false;
|
let systemStarted = false;
|
||||||
let systemStarting = false;
|
let systemStarting = false;
|
||||||
let configModalLocked = false;
|
let configModalLocked = false;
|
||||||
|
let socketConnected = false;
|
||||||
|
let reportStreamConnected = false;
|
||||||
|
let backendReachable = false;
|
||||||
|
|
||||||
const CONFIG_ENDPOINT = '/api/config';
|
const CONFIG_ENDPOINT = '/api/config';
|
||||||
const SYSTEM_STATUS_ENDPOINT = '/api/system/status';
|
const SYSTEM_STATUS_ENDPOINT = '/api/system/status';
|
||||||
@@ -1276,6 +1322,7 @@
|
|||||||
setInterval(updateTime, 1000);
|
setInterval(updateTime, 1000);
|
||||||
checkStatus();
|
checkStatus();
|
||||||
setInterval(checkStatus, 5000);
|
setInterval(checkStatus, 5000);
|
||||||
|
startConnectionProbe();
|
||||||
|
|
||||||
// 初始化密码切换功能(事件委托,只需调用一次)
|
// 初始化密码切换功能(事件委托,只需调用一次)
|
||||||
attachConfigPasswordToggles();
|
attachConfigPasswordToggles();
|
||||||
@@ -1308,12 +1355,14 @@
|
|||||||
socket = io();
|
socket = io();
|
||||||
|
|
||||||
socket.on('connect', function() {
|
socket.on('connect', function() {
|
||||||
updateConnectionStatus('已连接');
|
socketConnected = true;
|
||||||
|
refreshConnectionStatus();
|
||||||
socket.emit('request_status');
|
socket.emit('request_status');
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('disconnect', function() {
|
socket.on('disconnect', function() {
|
||||||
updateConnectionStatus('连接断开');
|
socketConnected = false;
|
||||||
|
refreshConnectionStatus();
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('console_output', function(data) {
|
socket.on('console_output', function(data) {
|
||||||
@@ -2255,10 +2304,38 @@
|
|||||||
fetch('/api/status')
|
fetch('/api/status')
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
|
backendReachable = true;
|
||||||
updateAppStatus(data);
|
updateAppStatus(data);
|
||||||
|
refreshConnectionStatus();
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error('状态检查失败:', error);
|
console.error('状态检查失败:', error);
|
||||||
|
backendReachable = false;
|
||||||
|
refreshConnectionStatus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startConnectionProbe() {
|
||||||
|
if (connectionProbeTimer) {
|
||||||
|
clearInterval(connectionProbeTimer);
|
||||||
|
}
|
||||||
|
probeBackendConnection();
|
||||||
|
connectionProbeTimer = setInterval(probeBackendConnection, CONNECTION_PROBE_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function probeBackendConnection() {
|
||||||
|
fetch('/api/report/status?heartbeat=1', { cache: 'no-store' })
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) throw new Error('heartbeat failed');
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
backendReachable = true;
|
||||||
|
refreshConnectionStatus();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
backendReachable = false;
|
||||||
|
refreshConnectionStatus();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2279,9 +2356,15 @@
|
|||||||
updateEmbeddedPage(currentApp);
|
updateEmbeddedPage(currentApp);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新连接状态
|
// 根据当前的Socket/SSE状态刷新底部连接指示
|
||||||
function updateConnectionStatus(status) {
|
function refreshConnectionStatus() {
|
||||||
document.getElementById('connectionStatus').textContent = status;
|
const statusEl = document.getElementById('connectionStatus');
|
||||||
|
if (!statusEl) return;
|
||||||
|
if (socketConnected || reportStreamConnected || backendReachable) {
|
||||||
|
statusEl.textContent = '已连接';
|
||||||
|
} else {
|
||||||
|
statusEl.textContent = '连接断开';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新时间
|
// 更新时间
|
||||||
@@ -2738,6 +2821,14 @@
|
|||||||
// Report Engine 相关函数
|
// Report Engine 相关函数
|
||||||
let reportTaskId = null;
|
let reportTaskId = null;
|
||||||
let reportPollingInterval = null;
|
let reportPollingInterval = null;
|
||||||
|
let reportEventSource = null;
|
||||||
|
let reportAutoPreviewLoaded = false;
|
||||||
|
let reportStreamReconnectTimer = null;
|
||||||
|
let reportStreamRetryDelay = 3000;
|
||||||
|
let streamHeartbeatTimeout = null;
|
||||||
|
let streamHeartbeatInterval = null;
|
||||||
|
let connectionProbeTimer = null;
|
||||||
|
const CONNECTION_PROBE_INTERVAL = 15000;
|
||||||
|
|
||||||
// 加载报告界面
|
// 加载报告界面
|
||||||
function loadReportInterface() {
|
function loadReportInterface() {
|
||||||
@@ -2811,6 +2902,8 @@
|
|||||||
|
|
||||||
reportContent.innerHTML = interfaceHTML;
|
reportContent.innerHTML = interfaceHTML;
|
||||||
initializeReportControls();
|
initializeReportControls();
|
||||||
|
resetReportStreamOutput('等待新的Report任务启动...');
|
||||||
|
updateReportStreamStatus('idle');
|
||||||
|
|
||||||
// 立即更新状态信息
|
// 立即更新状态信息
|
||||||
updateEngineStatusDisplay(statusData);
|
updateEngineStatusDisplay(statusData);
|
||||||
@@ -2818,8 +2911,22 @@
|
|||||||
// 如果有当前任务,显示任务状态
|
// 如果有当前任务,显示任务状态
|
||||||
if (statusData.current_task) {
|
if (statusData.current_task) {
|
||||||
updateTaskProgressStatus(statusData.current_task);
|
updateTaskProgressStatus(statusData.current_task);
|
||||||
|
if (statusData.current_task.status === 'running') {
|
||||||
|
reportTaskId = statusData.current_task.task_id;
|
||||||
|
reportAutoPreviewLoaded = false;
|
||||||
|
if (window.EventSource) {
|
||||||
|
openReportStream(reportTaskId);
|
||||||
|
} else {
|
||||||
|
startProgressPolling(reportTaskId);
|
||||||
|
}
|
||||||
|
} else if (statusData.current_task.status === 'completed') {
|
||||||
|
lastCompletedReportTask = statusData.current_task;
|
||||||
|
updateDownloadButtonState(statusData.current_task);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
updateDownloadButtonState(null);
|
updateDownloadButtonState(null);
|
||||||
|
safeCloseReportStream();
|
||||||
|
reportTaskId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3054,10 +3161,13 @@
|
|||||||
|
|
||||||
// 重置日志计数器,因为后台会清空日志文件
|
// 重置日志计数器,因为后台会清空日志文件
|
||||||
reportLogLineCount = 0;
|
reportLogLineCount = 0;
|
||||||
|
reportAutoPreviewLoaded = false;
|
||||||
|
safeCloseReportStream(true);
|
||||||
|
|
||||||
// 清空控制台显示
|
// 清空控制台显示
|
||||||
const consoleOutput = document.getElementById('consoleOutput');
|
const consoleOutput = document.getElementById('consoleOutput');
|
||||||
consoleOutput.innerHTML = '<div class="console-line">[系统] 开始生成报告,日志已重置</div>';
|
consoleOutput.innerHTML = '<div class="console-line">[系统] 开始生成报告,日志已重置</div>';
|
||||||
|
resetReportStreamOutput('Report Engine 正在调度任务...');
|
||||||
|
|
||||||
setGenerateButtonState(true);
|
setGenerateButtonState(true);
|
||||||
|
|
||||||
@@ -3099,14 +3209,21 @@
|
|||||||
refreshReportLog();
|
refreshReportLog();
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
// 开始轮询任务状态
|
appendReportStreamLine('任务创建成功,正在建立流式连接...', 'info', { force: true });
|
||||||
startProgressPolling(data.task_id);
|
if (window.EventSource) {
|
||||||
|
openReportStream(reportTaskId);
|
||||||
|
} else {
|
||||||
|
startProgressPolling(data.task_id);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
updateTaskProgressStatus(null, 'error', '启动失败: ' + data.error);
|
updateTaskProgressStatus(null, 'error', '启动失败: ' + data.error);
|
||||||
// 重置标志允许重新尝试
|
// 重置标志允许重新尝试
|
||||||
autoGenerateTriggered = false;
|
autoGenerateTriggered = false;
|
||||||
reportTaskId = null;
|
reportTaskId = null;
|
||||||
setGenerateButtonState(false);
|
setGenerateButtonState(false);
|
||||||
|
appendReportStreamLine('任务启动失败: ' + (data.error || '未知错误'), 'error');
|
||||||
|
updateReportStreamStatus('error');
|
||||||
|
safeCloseReportStream();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
@@ -3116,6 +3233,9 @@
|
|||||||
autoGenerateTriggered = false;
|
autoGenerateTriggered = false;
|
||||||
reportTaskId = null;
|
reportTaskId = null;
|
||||||
setGenerateButtonState(false);
|
setGenerateButtonState(false);
|
||||||
|
appendReportStreamLine('任务启动阶段异常: ' + error.message, 'error');
|
||||||
|
updateReportStreamStatus('error');
|
||||||
|
safeCloseReportStream();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3147,6 +3267,7 @@
|
|||||||
|
|
||||||
// 自动显示报告
|
// 自动显示报告
|
||||||
viewReport(taskId);
|
viewReport(taskId);
|
||||||
|
reportAutoPreviewLoaded = true;
|
||||||
|
|
||||||
// 重置自动生成标志,允许下次有新内容时自动生成
|
// 重置自动生成标志,允许下次有新内容时自动生成
|
||||||
autoGenerateTriggered = false;
|
autoGenerateTriggered = false;
|
||||||
@@ -3225,6 +3346,319 @@
|
|||||||
updateTaskProgressStatus(task);
|
updateTaskProgressStatus(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ====== Report Engine SSE流式辅助函数 ======
|
||||||
|
// 重置流式日志入口,将提示语写入控制台,保持与右侧黑框一致
|
||||||
|
function resetReportStreamOutput(message = '等待新的Report任务启动...') {
|
||||||
|
appendReportStreamLine(message, 'info', { badge: 'REPORT', force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据状态同步流式指示灯,与后端心跳保持一致
|
||||||
|
function updateReportStreamStatus(state) {
|
||||||
|
if (state === 'connected') {
|
||||||
|
reportStreamConnected = true;
|
||||||
|
} else if (['idle', 'error', 'connecting', 'reconnecting'].includes(state)) {
|
||||||
|
reportStreamConnected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusEl = document.getElementById('reportStreamStatus');
|
||||||
|
if (statusEl) {
|
||||||
|
const textMap = {
|
||||||
|
idle: '未连接',
|
||||||
|
connecting: '连接中',
|
||||||
|
connected: '实时更新中',
|
||||||
|
reconnecting: '等待重连',
|
||||||
|
error: '已断开'
|
||||||
|
};
|
||||||
|
statusEl.textContent = textMap[state] || state;
|
||||||
|
statusEl.dataset.state = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshConnectionStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 往黑色控制台输出区域追加一条流式日志
|
||||||
|
function appendReportStreamLine(message, level = 'info', options = {}) {
|
||||||
|
const consoleOutput = document.getElementById('consoleOutput');
|
||||||
|
if (!consoleOutput) return;
|
||||||
|
|
||||||
|
if (level === 'chunk' && !options.force) {
|
||||||
|
return; // 章节内容流式写入不再逐条输出
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = document.createElement('div');
|
||||||
|
line.className = `console-line report-stream-line ${level}`;
|
||||||
|
|
||||||
|
const timestampSpan = document.createElement('span');
|
||||||
|
timestampSpan.className = 'timestamp';
|
||||||
|
timestampSpan.textContent = new Date().toLocaleTimeString('zh-CN');
|
||||||
|
line.appendChild(timestampSpan);
|
||||||
|
|
||||||
|
if (options.badge) {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'stream-badge';
|
||||||
|
badge.textContent = options.badge;
|
||||||
|
line.appendChild(badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
const textSpan = document.createElement('span');
|
||||||
|
textSpan.className = 'line-text';
|
||||||
|
textSpan.textContent = message;
|
||||||
|
line.appendChild(textSpan);
|
||||||
|
|
||||||
|
consoleOutput.appendChild(line);
|
||||||
|
consoleOutput.scrollTop = consoleOutput.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startStreamHeartbeat() {
|
||||||
|
clearStreamHeartbeat();
|
||||||
|
const emitHeartbeat = () => {
|
||||||
|
appendReportStreamLine('Report Engine 正在流式生成,请耐心等待...', 'info', { badge: 'REPORT', force: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleFirstTick = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const msToNextMinute = 60000 - (now % 60000);
|
||||||
|
streamHeartbeatTimeout = setTimeout(() => {
|
||||||
|
emitHeartbeat();
|
||||||
|
streamHeartbeatInterval = setInterval(emitHeartbeat, 60000);
|
||||||
|
}, msToNextMinute);
|
||||||
|
};
|
||||||
|
|
||||||
|
scheduleFirstTick();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearStreamHeartbeat() {
|
||||||
|
if (streamHeartbeatTimeout) {
|
||||||
|
clearTimeout(streamHeartbeatTimeout);
|
||||||
|
streamHeartbeatTimeout = null;
|
||||||
|
}
|
||||||
|
if (streamHeartbeatInterval) {
|
||||||
|
clearInterval(streamHeartbeatInterval);
|
||||||
|
streamHeartbeatInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 建立SSE连接,实时订阅Report Engine推送
|
||||||
|
function openReportStream(taskId, isRetry = false) {
|
||||||
|
if (!taskId) return;
|
||||||
|
if (!window.EventSource) {
|
||||||
|
appendReportStreamLine('浏览器不支持SSE,已自动回退为轮询模式', 'warn', { badge: 'SSE', force: true });
|
||||||
|
updateReportStreamStatus('error');
|
||||||
|
clearStreamHeartbeat();
|
||||||
|
startProgressPolling(taskId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (reportPollingInterval) {
|
||||||
|
clearInterval(reportPollingInterval);
|
||||||
|
reportPollingInterval = null;
|
||||||
|
}
|
||||||
|
if (reportEventSource && reportEventSource.__taskId === taskId) {
|
||||||
|
if (reportEventSource.readyState !== EventSource.CLOSED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
safeCloseReportStream(true, true);
|
||||||
|
} else if (reportEventSource) {
|
||||||
|
safeCloseReportStream(true, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reportStreamReconnectTimer) {
|
||||||
|
clearTimeout(reportStreamReconnectTimer);
|
||||||
|
reportStreamReconnectTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRetry) {
|
||||||
|
reportStreamRetryDelay = 3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateReportStreamStatus('connecting');
|
||||||
|
appendReportStreamLine(
|
||||||
|
isRetry ? '尝试重连Report Engine流式通道...' : '正在建立Report Engine流式连接...',
|
||||||
|
'info',
|
||||||
|
{ badge: 'SSE', force: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
reportEventSource = new EventSource(`/api/report/stream/${taskId}`);
|
||||||
|
reportEventSource.__taskId = taskId;
|
||||||
|
reportEventSource.onopen = () => {
|
||||||
|
reportStreamRetryDelay = 3000;
|
||||||
|
updateReportStreamStatus('connected');
|
||||||
|
appendReportStreamLine(isRetry ? 'SSE重连成功' : 'Report Engine流式连接已建立', 'success', { badge: 'SSE' });
|
||||||
|
startStreamHeartbeat();
|
||||||
|
};
|
||||||
|
reportEventSource.onerror = () => {
|
||||||
|
appendReportStreamLine('检测到网络抖动,SSE正在等待自动重连...', 'warn', { badge: 'SSE' });
|
||||||
|
updateReportStreamStatus('reconnecting');
|
||||||
|
clearStreamHeartbeat();
|
||||||
|
safeCloseReportStream(true, true);
|
||||||
|
scheduleReportStreamReconnect(taskId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const events = ['status', 'stage', 'chapter_status', 'chapter_chunk', 'warning', 'html_ready', 'completed', 'error', 'heartbeat'];
|
||||||
|
events.forEach(evt => {
|
||||||
|
reportEventSource.addEventListener(evt, (event) => dispatchReportStreamEvent(evt, event));
|
||||||
|
});
|
||||||
|
reportEventSource.onmessage = (event) => dispatchReportStreamEvent(event.type || 'message', event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭SSE连接,可根据场景选择是否立即重置指示灯
|
||||||
|
function safeCloseReportStream(keepIndicator = false, preserveRetryDelay = false) {
|
||||||
|
if (reportEventSource) {
|
||||||
|
reportEventSource.close();
|
||||||
|
reportEventSource = null;
|
||||||
|
}
|
||||||
|
if (reportStreamReconnectTimer) {
|
||||||
|
clearTimeout(reportStreamReconnectTimer);
|
||||||
|
reportStreamReconnectTimer = null;
|
||||||
|
}
|
||||||
|
clearStreamHeartbeat();
|
||||||
|
if (!keepIndicator) {
|
||||||
|
updateReportStreamStatus('idle');
|
||||||
|
} else {
|
||||||
|
reportStreamConnected = false;
|
||||||
|
refreshConnectionStatus();
|
||||||
|
}
|
||||||
|
if (!preserveRetryDelay) {
|
||||||
|
reportStreamRetryDelay = 3000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReportStreamReconnect(taskId) {
|
||||||
|
if (!taskId || reportStreamReconnectTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reportStreamReconnectTimer = setTimeout(() => {
|
||||||
|
reportStreamReconnectTimer = null;
|
||||||
|
if (reportTaskId === taskId) {
|
||||||
|
openReportStream(taskId, true);
|
||||||
|
}
|
||||||
|
}, reportStreamRetryDelay);
|
||||||
|
reportStreamRetryDelay = Math.min(reportStreamRetryDelay * 2, 15000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一的事件派发入口,负责解析JSON并交给业务处理
|
||||||
|
function dispatchReportStreamEvent(eventType, event) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
handleReportStreamEvent(eventType, data);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('解析流式事件失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 结合事件类型输出控件/状态,确保网络抖动时也能及时反馈
|
||||||
|
function handleReportStreamEvent(eventType, eventData) {
|
||||||
|
if (!eventData) return;
|
||||||
|
const payload = eventData.payload || {};
|
||||||
|
const task = payload.task;
|
||||||
|
|
||||||
|
if (eventType === 'status' && task) {
|
||||||
|
updateTaskProgressStatus(task);
|
||||||
|
reportTaskId = task.status === 'running' ? task.task_id : null;
|
||||||
|
if (task.status === 'completed') {
|
||||||
|
lastCompletedReportTask = task;
|
||||||
|
setGenerateButtonState(false);
|
||||||
|
} else if (task.status === 'running') {
|
||||||
|
setGenerateButtonState(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (eventType) {
|
||||||
|
case 'stage':
|
||||||
|
appendReportStreamLine(
|
||||||
|
payload.message || `阶段: ${payload.stage || ''}`,
|
||||||
|
'info',
|
||||||
|
{
|
||||||
|
badge: payload.stage || '阶段',
|
||||||
|
genericMessage: 'Report Engine 正在逐步生成,请耐心等待...'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'chapter_status':
|
||||||
|
appendReportStreamLine(
|
||||||
|
`${payload.title || payload.chapterId || '章节'} ${payload.status === 'completed' ? '已完成' : '生成中'}`,
|
||||||
|
payload.status === 'completed' ? 'success' : 'info',
|
||||||
|
{
|
||||||
|
badge: '章节',
|
||||||
|
genericMessage: payload.status === 'completed'
|
||||||
|
? `${payload.title || payload.chapterId || '章节'} 已完成`
|
||||||
|
: '章节流式生成中,请稍候...'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'chapter_chunk':
|
||||||
|
if (payload.delta) {
|
||||||
|
appendReportStreamLine(
|
||||||
|
formatStreamChunk(payload.delta),
|
||||||
|
'chunk',
|
||||||
|
{
|
||||||
|
badge: payload.title || payload.chapterId || '章节流',
|
||||||
|
genericMessage: '章节内容流式写入中...'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'warning':
|
||||||
|
appendReportStreamLine(payload.message || '检测到可重试的网络波动', 'warn');
|
||||||
|
break;
|
||||||
|
case 'html_ready':
|
||||||
|
appendReportStreamLine('HTML渲染完成,正在刷新预览...', 'success');
|
||||||
|
if (task) {
|
||||||
|
updateDownloadButtonState(task);
|
||||||
|
}
|
||||||
|
if (eventData.task_id && !reportAutoPreviewLoaded) {
|
||||||
|
viewReport(eventData.task_id);
|
||||||
|
reportAutoPreviewLoaded = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'completed':
|
||||||
|
appendReportStreamLine(payload.message || '任务完成', 'success');
|
||||||
|
safeCloseReportStream();
|
||||||
|
reportTaskId = null;
|
||||||
|
setGenerateButtonState(false);
|
||||||
|
if (task) {
|
||||||
|
lastCompletedReportTask = task;
|
||||||
|
updateDownloadButtonState(task);
|
||||||
|
}
|
||||||
|
if (eventData.task_id && !reportAutoPreviewLoaded) {
|
||||||
|
viewReport(eventData.task_id);
|
||||||
|
reportAutoPreviewLoaded = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'cancelled':
|
||||||
|
appendReportStreamLine(payload.message || '任务已取消', 'warn');
|
||||||
|
safeCloseReportStream();
|
||||||
|
updateReportStreamStatus('idle');
|
||||||
|
reportTaskId = null;
|
||||||
|
setGenerateButtonState(false);
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
appendReportStreamLine(payload.message || '任务失败', 'error');
|
||||||
|
safeCloseReportStream();
|
||||||
|
updateReportStreamStatus('error');
|
||||||
|
reportTaskId = null;
|
||||||
|
setGenerateButtonState(false);
|
||||||
|
break;
|
||||||
|
case 'heartbeat':
|
||||||
|
updateReportStreamStatus('connected');
|
||||||
|
appendReportStreamLine(payload.message || '流式连接正常,请稍候...', 'info', {
|
||||||
|
badge: 'SSE',
|
||||||
|
genericMessage: '流式连接正常,请耐心等待...'
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (payload.message) {
|
||||||
|
appendReportStreamLine(payload.message, 'info');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清洗流式chunk,裁剪多余空白,避免影响UI
|
||||||
|
function formatStreamChunk(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
return text.replace(/\s+/g, ' ').trim().slice(0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
// 查看报告
|
// 查看报告
|
||||||
function viewReport(taskId) {
|
function viewReport(taskId) {
|
||||||
const reportPreview = document.getElementById('reportPreview');
|
const reportPreview = document.getElementById('reportPreview');
|
||||||
@@ -3435,4 +3869,4 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user