马一丁

Improved Rendering

@@ -10,12 +10,12 @@ from __future__ import annotations @@ -10,12 +10,12 @@ from __future__ import annotations
10 import json 10 import json
11 from pathlib import Path 11 from pathlib import Path
12 import re 12 import re
13 -from typing import Any, Dict, List, Tuple 13 +from typing import Any, Dict, List, Tuple, Callable, Optional
14 14
15 from loguru import logger 15 from loguru import logger
16 16
17 from ..core import TemplateSection, ChapterStorage 17 from ..core import TemplateSection, ChapterStorage
18 -from ..ir import ALLOWED_BLOCK_TYPES, IRValidator 18 +from ..ir import ALLOWED_BLOCK_TYPES, ALLOWED_INLINE_MARKS, IRValidator
19 from ..prompts import ( 19 from ..prompts import (
20 SYSTEM_PROMPT_CHAPTER_JSON, 20 SYSTEM_PROMPT_CHAPTER_JSON,
21 build_chapter_user_prompt, 21 build_chapter_user_prompt,
@@ -28,10 +28,41 @@ except ImportError: # pragma: no cover - optional dependency @@ -28,10 +28,41 @@ except ImportError: # pragma: no cover - optional dependency
28 _json_repair_fn = None 28 _json_repair_fn = None
29 29
30 30
  31 +class ChapterJsonParseError(ValueError):
  32 + """Raised when the LLM output for a chapter cannot be parsed as valid JSON."""
  33 +
  34 + def __init__(self, message: str, raw_text: Optional[str] = None):
  35 + super().__init__(message)
  36 + self.raw_text = raw_text
  37 +
  38 +
31 class ChapterGenerationNode(BaseNode): 39 class ChapterGenerationNode(BaseNode):
32 """负责按章节调用LLM并校验JSON结构""" 40 """负责按章节调用LLM并校验JSON结构"""
33 41
34 _COLON_EQUALS_PATTERN = re.compile(r'(":\s*)=') 42 _COLON_EQUALS_PATTERN = re.compile(r'(":\s*)=')
  43 + _LINE_BREAK_SENTINEL = "__LINE_BREAK__"
  44 + _INLINE_MARK_ALIASES = {
  45 + "strong": "bold",
  46 + "b": "bold",
  47 + "em": "italic",
  48 + "emphasis": "italic",
  49 + "i": "italic",
  50 + "u": "underline",
  51 + "strike-through": "strike",
  52 + "strikethrough": "strike",
  53 + "s": "strike",
  54 + "codeblock": "code",
  55 + "monospace": "code",
  56 + "hyperlink": "link",
  57 + "url": "link",
  58 + "colour": "color",
  59 + "textcolor": "color",
  60 + "bgcolor": "highlight",
  61 + "background": "highlight",
  62 + "highlightcolor": "highlight",
  63 + "sub": "subscript",
  64 + "sup": "superscript",
  65 + }
35 66
36 def __init__(self, llm_client, validator: IRValidator, storage: ChapterStorage): 67 def __init__(self, llm_client, validator: IRValidator, storage: ChapterStorage):
37 """ 68 """
@@ -51,6 +82,7 @@ class ChapterGenerationNode(BaseNode): @@ -51,6 +82,7 @@ class ChapterGenerationNode(BaseNode):
51 section: TemplateSection, 82 section: TemplateSection,
52 context: Dict[str, Any], 83 context: Dict[str, Any],
53 run_dir: Path, 84 run_dir: Path,
  85 + stream_callback: Optional[Callable[[str, Dict[str, Any]], None]] = None,
54 **kwargs, 86 **kwargs,
55 ) -> Dict[str, Any]: 87 ) -> Dict[str, Any]:
56 """针对单个章节调用LLM,校验/落盘章节JSON并返回结构化结果""" 88 """针对单个章节调用LLM,校验/落盘章节JSON并返回结构化结果"""
@@ -64,7 +96,13 @@ class ChapterGenerationNode(BaseNode): @@ -64,7 +96,13 @@ class ChapterGenerationNode(BaseNode):
64 llm_payload = self._build_payload(section, context) 96 llm_payload = self._build_payload(section, context)
65 user_message = build_chapter_user_prompt(llm_payload) 97 user_message = build_chapter_user_prompt(llm_payload)
66 98
67 - raw_text = self._stream_llm(user_message, chapter_dir, **kwargs) 99 + raw_text = self._stream_llm(
  100 + user_message,
  101 + chapter_dir,
  102 + stream_callback=stream_callback,
  103 + section_meta=chapter_meta,
  104 + **kwargs,
  105 + )
68 chapter_json = self._parse_chapter(raw_text) 106 chapter_json = self._parse_chapter(raw_text)
69 107
70 # 自动补全关键字段后再校验 108 # 自动补全关键字段后再校验
@@ -150,8 +188,15 @@ class ChapterGenerationNode(BaseNode): @@ -150,8 +188,15 @@ class ChapterGenerationNode(BaseNode):
150 payload["globalContext"]["sectionBudgets"] = chapter_plan["sections"] 188 payload["globalContext"]["sectionBudgets"] = chapter_plan["sections"]
151 return payload 189 return payload
152 190
153 - def _stream_llm(self, user_message: str, chapter_dir: Path, **kwargs) -> str:  
154 - """流式调用LLM并实时写入raw文件""" 191 + def _stream_llm(
  192 + self,
  193 + user_message: str,
  194 + chapter_dir: Path,
  195 + stream_callback: Optional[Callable[[str, Dict[str, Any]], None]] = None,
  196 + section_meta: Optional[Dict[str, Any]] = None,
  197 + **kwargs,
  198 + ) -> str:
  199 + """流式调用LLM并实时写入raw文件,同时通过回调将delta抛出。"""
155 chunks: List[str] = [] 200 chunks: List[str] = []
156 with self.storage.capture_stream(chapter_dir) as stream_fp: 201 with self.storage.capture_stream(chapter_dir) as stream_fp:
157 stream = self.llm_client.stream_invoke( 202 stream = self.llm_client.stream_invoke(
@@ -163,6 +208,12 @@ class ChapterGenerationNode(BaseNode): @@ -163,6 +208,12 @@ class ChapterGenerationNode(BaseNode):
163 for delta in stream: 208 for delta in stream:
164 stream_fp.write(delta) 209 stream_fp.write(delta)
165 chunks.append(delta) 210 chunks.append(delta)
  211 + if stream_callback:
  212 + meta = section_meta or {}
  213 + try:
  214 + stream_callback(delta, meta)
  215 + except Exception as callback_error: # pragma: no cover - 仅记录,不阻断主流程
  216 + logger.warning(f"章节流式回调失败: {callback_error}")
166 return "".join(chunks) 217 return "".join(chunks)
167 218
168 def _parse_chapter(self, raw_text: str) -> Dict[str, Any]: 219 def _parse_chapter(self, raw_text: str) -> Dict[str, Any]:
@@ -192,9 +243,13 @@ class ChapterGenerationNode(BaseNode): @@ -192,9 +243,13 @@ class ChapterGenerationNode(BaseNode):
192 try: 243 try:
193 data = self._parse_with_candidates(candidate_payloads[-1:]) 244 data = self._parse_with_candidates(candidate_payloads[-1:])
194 except json.JSONDecodeError as inner_exc: 245 except json.JSONDecodeError as inner_exc:
195 - raise ValueError(f"章节JSON解析失败: {inner_exc}") from inner_exc 246 + raise ChapterJsonParseError(
  247 + f"章节JSON解析失败: {inner_exc}", raw_text=cleaned
  248 + ) from inner_exc
196 else: 249 else:
197 - raise ValueError(f"章节JSON解析失败: {exc}") from exc 250 + raise ChapterJsonParseError(
  251 + f"章节JSON解析失败: {exc}", raw_text=cleaned
  252 + ) from exc
198 253
199 if "chapter" in data and isinstance(data["chapter"], dict): 254 if "chapter" in data and isinstance(data["chapter"], dict):
200 return data["chapter"] 255 return data["chapter"]
@@ -400,6 +455,7 @@ class ChapterGenerationNode(BaseNode): @@ -400,6 +455,7 @@ class ChapterGenerationNode(BaseNode):
400 if not isinstance(block, dict): 455 if not isinstance(block, dict):
401 continue 456 continue
402 self._ensure_block_type(block) 457 self._ensure_block_type(block)
  458 + self._sanitize_block_content(block)
403 block_type = block.get("type") 459 block_type = block.get("type")
404 if block_type == "list": 460 if block_type == "list":
405 items = block.get("items") 461 items = block.get("items")
@@ -424,6 +480,98 @@ class ChapterGenerationNode(BaseNode): @@ -424,6 +480,98 @@ class ChapterGenerationNode(BaseNode):
424 480
425 walk(chapter.get("blocks")) 481 walk(chapter.get("blocks"))
426 482
  483 + def _sanitize_block_content(self, block: Dict[str, Any]):
  484 + """根据类型做精细化修复,例如清理paragraph内的非法inline mark"""
  485 + block_type = block.get("type")
  486 + if block_type == "paragraph":
  487 + self._normalize_paragraph_block(block)
  488 +
  489 + def _normalize_paragraph_block(self, block: Dict[str, Any]):
  490 + """将paragraphinlines统一规整,剔除非法marks"""
  491 + inlines = block.get("inlines")
  492 + normalized_runs: List[Dict[str, Any]] = []
  493 + if isinstance(inlines, list) and inlines:
  494 + for run in inlines:
  495 + normalized_runs.extend(self._coerce_inline_run(run))
  496 + else:
  497 + normalized_runs = [self._as_inline_run(self._extract_block_text(block))]
  498 + if not normalized_runs:
  499 + normalized_runs = [self._as_inline_run("")]
  500 + block["inlines"] = normalized_runs
  501 +
  502 + def _coerce_inline_run(self, run: Any) -> List[Dict[str, Any]]:
  503 + """将任意inline写法规整为合法run"""
  504 + if isinstance(run, dict):
  505 + normalized_run = dict(run)
  506 + text = normalized_run.get("text")
  507 + if not isinstance(text, str):
  508 + text = "" if text is None else str(text)
  509 + marks = normalized_run.get("marks")
  510 + sanitized_marks, extra_text = self._sanitize_inline_marks(marks)
  511 + normalized_run["marks"] = sanitized_marks
  512 + normalized_run["text"] = (text or "") + extra_text
  513 + return [normalized_run]
  514 + if isinstance(run, str):
  515 + return [self._as_inline_run(run)]
  516 + if isinstance(run, (int, float)):
  517 + return [self._as_inline_run(str(run))]
  518 + if isinstance(run, list):
  519 + normalized: List[Dict[str, Any]] = []
  520 + for item in run:
  521 + normalized.extend(self._coerce_inline_run(item))
  522 + return normalized
  523 + return [self._as_inline_run("" if run is None else str(run))]
  524 +
  525 + def _sanitize_inline_marks(self, marks: Any) -> Tuple[List[Dict[str, Any]], str]:
  526 + """过滤非法marks并将break类控制符转成文本"""
  527 + text_suffix = ""
  528 + if marks is None:
  529 + return [], text_suffix
  530 + mark_list = marks if isinstance(marks, list) else [marks]
  531 + sanitized: List[Dict[str, Any]] = []
  532 + for mark in mark_list:
  533 + normalized_mark, extra_text = self._normalize_inline_mark(mark)
  534 + if normalized_mark:
  535 + sanitized.append(normalized_mark)
  536 + if extra_text:
  537 + text_suffix += extra_text
  538 + return sanitized, text_suffix
  539 +
  540 + def _normalize_inline_mark(self, mark: Any) -> Tuple[Dict[str, Any] | None, str]:
  541 + """对单个mark做兼容映射,或者在必要时转换为文本"""
  542 + if not isinstance(mark, dict):
  543 + return None, ""
  544 + canonical_type = self._canonical_inline_mark_type(mark.get("type"))
  545 + if canonical_type == self._LINE_BREAK_SENTINEL:
  546 + return None, "\n"
  547 + if canonical_type in ALLOWED_INLINE_MARKS:
  548 + normalized = dict(mark)
  549 + normalized["type"] = canonical_type
  550 + return normalized, ""
  551 + return None, ""
  552 +
  553 + def _canonical_inline_mark_type(self, mark_type: Any) -> str | None:
  554 + """将mark type映射为Schema所支持的取值"""
  555 + if not isinstance(mark_type, str):
  556 + return None
  557 + normalized = mark_type.strip()
  558 + if not normalized:
  559 + return None
  560 + lowered = normalized.lower()
  561 + if lowered in {"break", "linebreak", "br"}:
  562 + return self._LINE_BREAK_SENTINEL
  563 + return self._INLINE_MARK_ALIASES.get(lowered, lowered)
  564 +
  565 + def _extract_block_text(self, block: Dict[str, Any]) -> str:
  566 + """优先从text/content等字段提取fallback文本"""
  567 + for key in ("text", "content", "value", "title"):
  568 + value = block.get(key)
  569 + if isinstance(value, str):
  570 + return value
  571 + if value is not None:
  572 + return str(value)
  573 + return ""
  574 +
427 def _normalize_list_items(self, items: Any) -> List[List[Dict[str, Any]]]: 575 def _normalize_list_items(self, items: Any) -> List[List[Dict[str, Any]]]:
428 """确保list blockitems为[[block, block], ...]结构""" 576 """确保list blockitems为[[block, block], ...]结构"""
429 if not isinstance(items, list): 577 if not isinstance(items, list):
@@ -490,17 +638,22 @@ class ChapterGenerationNode(BaseNode): @@ -490,17 +638,22 @@ class ChapterGenerationNode(BaseNode):
490 text = str(block) 638 text = str(block)
491 block.clear() 639 block.clear()
492 block["type"] = "paragraph" 640 block["type"] = "paragraph"
493 - block["inlines"] = [{"text": text}] 641 + block["inlines"] = [self._as_inline_run(text)]
494 642
495 @staticmethod 643 @staticmethod
496 def _as_paragraph_block(text: str) -> Dict[str, Any]: 644 def _as_paragraph_block(text: str) -> Dict[str, Any]:
497 """将字符串快速包装成paragraph block,方便统一处理""" 645 """将字符串快速包装成paragraph block,方便统一处理"""
498 return { 646 return {
499 "type": "paragraph", 647 "type": "paragraph",
500 - "inlines": [{"text": text or ""}], 648 + "inlines": [ChapterGenerationNode._as_inline_run(text)],
501 } 649 }
502 650
503 @staticmethod 651 @staticmethod
  652 + def _as_inline_run(text: str) -> Dict[str, Any]:
  653 + """构造基础inline run,保证marks字段存在"""
  654 + return {"text": text or "", "marks": []}
  655 +
  656 + @staticmethod
504 def _parse_with_candidates(payloads: List[str]) -> Dict[str, Any]: 657 def _parse_with_candidates(payloads: List[str]) -> Dict[str, Any]:
505 """按顺序尝试多个payload,直到解析成功""" 658 """按顺序尝试多个payload,直到解析成功"""
506 last_exc: json.JSONDecodeError | None = None 659 last_exc: json.JSONDecodeError | None = None
@@ -513,4 +666,4 @@ class ChapterGenerationNode(BaseNode): @@ -513,4 +666,4 @@ class ChapterGenerationNode(BaseNode):
513 raise last_exc 666 raise last_exc
514 667
515 668
516 -__all__ = ["ChapterGenerationNode"] 669 +__all__ = ["ChapterGenerationNode", "ChapterJsonParseError"]
@@ -4,6 +4,7 @@ @@ -4,6 +4,7 @@
4 4
5 from __future__ import annotations 5 from __future__ import annotations
6 6
  7 +import ast
7 import html 8 import html
8 import json 9 import json
9 from typing import Any, Dict, List 10 from typing import Any, Dict, List
@@ -51,7 +52,7 @@ class HTMLRenderer: @@ -51,7 +52,7 @@ class HTMLRenderer:
51 52
52 head = self._render_head(title, theme_tokens) 53 head = self._render_head(title, theme_tokens)
53 body = self._render_body() 54 body = self._render_body()
54 - return f"<!DOCTYPE html>\n<html lang=\"zh-CN\">\n{head}\n{body}\n</html>" 55 + return f"<!DOCTYPE html>\n<html lang=\"zh-CN\" class=\"no-js\">\n{head}\n{body}\n</html>"
55 56
56 # ====== Head / Body ====== 57 # ====== Head / Body ======
57 58
@@ -83,6 +84,10 @@ class HTMLRenderer: @@ -83,6 +84,10 @@ class HTMLRenderer:
83 <style> 84 <style>
84 {css} 85 {css}
85 </style> 86 </style>
  87 + <script>
  88 + document.documentElement.classList.remove('no-js');
  89 + document.documentElement.classList.add('js-ready');
  90 + </script>
86 </head>""".strip() 91 </head>""".strip()
87 92
88 def _render_body(self) -> str: 93 def _render_body(self) -> str:
@@ -423,6 +428,8 @@ class HTMLRenderer: @@ -423,6 +428,8 @@ class HTMLRenderer:
423 items_html = "" 428 items_html = ""
424 for item in block.get("items", []): 429 for item in block.get("items", []):
425 content = self._render_blocks(item) 430 content = self._render_blocks(item)
  431 + if not content.strip():
  432 + continue
426 items_html += f"<li>{content}</li>" 433 items_html += f"<li>{content}</li>"
427 class_attr = f' class="{extra_class}"' if extra_class else "" 434 class_attr = f' class="{extra_class}"' if extra_class else ""
428 return f'<{tag}{class_attr}>{items_html}</{tag}>' 435 return f'<{tag}{class_attr}>{items_html}</{tag}>'
@@ -545,7 +552,7 @@ class HTMLRenderer: @@ -545,7 +552,7 @@ class HTMLRenderer:
545 row_cells.append(f"<td>{self._escape_html(value)}</td>") 552 row_cells.append(f"<td>{self._escape_html(value)}</td>")
546 body_rows += f"<tr>{''.join(row_cells)}</tr>" 553 body_rows += f"<tr>{''.join(row_cells)}</tr>"
547 table_html = f""" 554 table_html = f"""
548 - <div class="chart-fallback"> 555 + <div class="chart-fallback" data-prebuilt="true">
549 <table> 556 <table>
550 <thead> 557 <thead>
551 <tr><th>类别</th>{header_cells}</tr> 558 <tr><th>类别</th>{header_cells}</tr>
@@ -556,20 +563,93 @@ class HTMLRenderer: @@ -556,20 +563,93 @@ class HTMLRenderer:
556 </table> 563 </table>
557 </div> 564 </div>
558 """ 565 """
559 - return f"<noscript>{table_html}</noscript>" 566 + return table_html
560 567
561 # ====== Inline 渲染 ====== 568 # ====== Inline 渲染 ======
562 569
  570 + def _normalize_inline_payload(self, run: Dict[str, Any]) -> tuple[str, List[Dict[str, Any]]]:
  571 + """将嵌套inline node展平成基础文本与marks"""
  572 + if not isinstance(run, dict):
  573 + return ("" if run is None else str(run)), []
  574 +
  575 + marks = list(run.get("marks") or [])
  576 + text_value: Any = run.get("text", "")
  577 + seen: set[int] = set()
  578 +
  579 + while isinstance(text_value, dict):
  580 + obj_id = id(text_value)
  581 + if obj_id in seen:
  582 + text_value = ""
  583 + break
  584 + seen.add(obj_id)
  585 + nested_marks = text_value.get("marks")
  586 + if nested_marks:
  587 + marks.extend(nested_marks)
  588 + if "text" in text_value:
  589 + text_value = text_value.get("text")
  590 + else:
  591 + text_value = json.dumps(text_value, ensure_ascii=False)
  592 + break
  593 +
  594 + if text_value is None:
  595 + text_value = ""
  596 + elif isinstance(text_value, (int, float)):
  597 + text_value = str(text_value)
  598 + elif not isinstance(text_value, str):
  599 + try:
  600 + text_value = json.dumps(text_value, ensure_ascii=False)
  601 + except TypeError:
  602 + text_value = str(text_value)
  603 +
  604 + if isinstance(text_value, str):
  605 + stripped = text_value.strip()
  606 + if stripped.startswith("{") and stripped.endswith("}"):
  607 + payload = None
  608 + try:
  609 + payload = json.loads(stripped)
  610 + except json.JSONDecodeError:
  611 + try:
  612 + payload = ast.literal_eval(stripped)
  613 + except (ValueError, SyntaxError):
  614 + payload = None
  615 + if isinstance(payload, dict):
  616 + sentinel_keys = {"xrefs", "widgets", "footnotes", "errors", "metadata"}
  617 + if set(payload.keys()).issubset(sentinel_keys):
  618 + text_value = ""
  619 + else:
  620 + inline_payload = self._coerce_inline_payload(payload)
  621 + if inline_payload:
  622 + nested_text = inline_payload.get("text")
  623 + if nested_text is not None:
  624 + text_value = nested_text
  625 + nested_marks = inline_payload.get("marks")
  626 + if isinstance(nested_marks, list):
  627 + marks.extend(nested_marks)
  628 +
  629 + return text_value, marks
  630 +
  631 + @staticmethod
  632 + def _coerce_inline_payload(payload: Dict[str, Any]) -> Dict[str, Any] | None:
  633 + """尽力将字符串里的内联节点恢复为dict,修复渲染遗漏"""
  634 + if not isinstance(payload, dict):
  635 + return None
  636 + inline_type = payload.get("type")
  637 + if inline_type and inline_type not in {"inline", "text"}:
  638 + return None
  639 + if "text" not in payload and "marks" not in payload:
  640 + return None
  641 + return payload
  642 +
563 def _render_inline(self, run: Dict[str, Any]) -> str: 643 def _render_inline(self, run: Dict[str, Any]) -> str:
564 """渲染单个inline run,支持多种marks叠加""" 644 """渲染单个inline run,支持多种marks叠加"""
565 - marks = run.get("marks") or [] 645 + text_value, marks = self._normalize_inline_payload(run)
566 math_mark = next((mark for mark in marks if mark.get("type") == "math"), None) 646 math_mark = next((mark for mark in marks if mark.get("type") == "math"), None)
567 if math_mark: 647 if math_mark:
568 latex = math_mark.get("value") 648 latex = math_mark.get("value")
569 if not isinstance(latex, str) or not latex.strip(): 649 if not isinstance(latex, str) or not latex.strip():
570 - latex = run.get("text", "") 650 + latex = text_value
571 return f'<span class="math-inline">\\( {self._escape_html(latex)} \\)</span>' 651 return f'<span class="math-inline">\\( {self._escape_html(latex)} \\)</span>'
572 - text = self._escape_html(run.get("text", "")) 652 + text = self._escape_html(text_value)
573 styles: List[str] = [] 653 styles: List[str] = []
574 prefix: List[str] = [] 654 prefix: List[str] = []
575 suffix: List[str] = [] 655 suffix: List[str] = []
@@ -653,6 +733,30 @@ class HTMLRenderer: @@ -653,6 +733,30 @@ class HTMLRenderer:
653 cursor = end + 2 733 cursor = end + 2
654 return "".join(result) 734 return "".join(result)
655 735
  736 + # ====== 文本 / 安全工具 ======
  737 +
  738 + def _safe_text(self, value: Any) -> str:
  739 + """将任意值安全转换为字符串,None与复杂对象容错"""
  740 + if value is None:
  741 + return ""
  742 + if isinstance(value, str):
  743 + return value
  744 + if isinstance(value, (int, float, bool)):
  745 + return str(value)
  746 + try:
  747 + return json.dumps(value, ensure_ascii=False)
  748 + except (TypeError, ValueError):
  749 + return str(value)
  750 +
  751 + def _escape_html(self, value: Any) -> str:
  752 + """HTML文本上下文的转义"""
  753 + return html.escape(self._safe_text(value), quote=False)
  754 +
  755 + def _escape_attr(self, value: Any) -> str:
  756 + """HTML属性上下文转义并去掉危险换行"""
  757 + escaped = html.escape(self._safe_text(value), quote=True)
  758 + return escaped.replace("\n", " ").replace("\r", " ")
  759 +
656 # ====== CSS / JS ====== 760 # ====== CSS / JS ======
657 761
658 def _build_css(self, tokens: Dict[str, Any]) -> str: 762 def _build_css(self, tokens: Dict[str, Any]) -> str:
@@ -1013,10 +1117,17 @@ table th {{ @@ -1013,10 +1117,17 @@ table th {{
1013 min-height: 320px; 1117 min-height: 320px;
1014 }} 1118 }}
1015 .chart-fallback {{ 1119 .chart-fallback {{
  1120 + display: none;
1016 margin-top: 12px; 1121 margin-top: 12px;
1017 font-size: 0.85rem; 1122 font-size: 0.85rem;
1018 overflow-x: auto; 1123 overflow-x: auto;
1019 }} 1124 }}
  1125 +.no-js .chart-fallback {{
  1126 + display: block;
  1127 +}}
  1128 +.no-js .chart-container {{
  1129 + display: none;
  1130 +}}
1020 .chart-fallback table {{ 1131 .chart-fallback table {{
1021 width: 100%; 1132 width: 100%;
1022 border-collapse: collapse; 1133 border-collapse: collapse;
@@ -1030,6 +1141,11 @@ table th {{ @@ -1030,6 +1141,11 @@ table th {{
1030 .chart-fallback th {{ 1141 .chart-fallback th {{
1031 background: rgba(0,0,0,0.04); 1142 background: rgba(0,0,0,0.04);
1032 }} 1143 }}
  1144 +.chart-note {{
  1145 + margin-top: 8px;
  1146 + font-size: 0.85rem;
  1147 + color: var(--secondary-color);
  1148 +}}
1033 figure {{ 1149 figure {{
1034 margin: 20px 0; 1150 margin: 20px 0;
1035 text-align: center; 1151 text-align: center;
@@ -1091,7 +1207,19 @@ pre.code-block {{ @@ -1091,7 +1207,19 @@ pre.code-block {{
1091 """返回页面底部的JS,负责Chart.js注水与导出逻辑""" 1207 """返回页面底部的JS,负责Chart.js注水与导出逻辑"""
1092 return """ 1208 return """
1093 <script> 1209 <script>
  1210 +document.documentElement.classList.remove('no-js');
  1211 +document.documentElement.classList.add('js-ready');
  1212 +
1094 const chartRegistry = []; 1213 const chartRegistry = [];
  1214 +const STABLE_CHART_TYPES = ['line', 'bar'];
  1215 +const CHART_TYPE_LABELS = {
  1216 + line: '折线图',
  1217 + bar: '柱状图',
  1218 + doughnut: '圆环图',
  1219 + pie: '饼图',
  1220 + radar: '雷达图',
  1221 + polarArea: '极地区域图'
  1222 +};
1095 1223
1096 function getThemePalette() { 1224 function getThemePalette() {
1097 const styles = getComputedStyle(document.body); 1225 const styles = getComputedStyle(document.body);
@@ -1103,38 +1231,235 @@ function getThemePalette() { @@ -1103,38 +1231,235 @@ function getThemePalette() {
1103 1231
1104 function applyChartTheme(chart) { 1232 function applyChartTheme(chart) {
1105 if (!chart) return; 1233 if (!chart) return;
1106 - const palette = getThemePalette();  
1107 - const options = chart.options || {};  
1108 - options.plugins = options.plugins || {};  
1109 - options.plugins.legend = options.plugins.legend || {};  
1110 - options.plugins.legend.labels = options.plugins.legend.labels || {};  
1111 - options.plugins.legend.labels.color = palette.text;  
1112 - if (options.plugins.title) {  
1113 - options.plugins.title.color = palette.text; 1234 + try {
  1235 + chart.update('none');
  1236 + } catch (err) {
  1237 + console.error('Chart refresh failed', err);
1114 } 1238 }
1115 - const scales = options.scales || {};  
1116 - Object.keys(scales).forEach(key => {  
1117 - const scale = scales[key] || {};  
1118 - if (scale.ticks) {  
1119 - scale.ticks.color = palette.text; 1239 +}
  1240 +
  1241 +function isPlainObject(value) {
  1242 + return Object.prototype.toString.call(value) === '[object Object]';
  1243 +}
  1244 +
  1245 +function cloneDeep(value) {
  1246 + if (Array.isArray(value)) {
  1247 + return value.map(cloneDeep);
  1248 + }
  1249 + if (isPlainObject(value)) {
  1250 + const obj = {};
  1251 + Object.keys(value).forEach(key => {
  1252 + obj[key] = cloneDeep(value[key]);
  1253 + });
  1254 + return obj;
  1255 + }
  1256 + return value;
  1257 +}
  1258 +
  1259 +function mergeOptions(base, override) {
  1260 + const result = isPlainObject(base) ? cloneDeep(base) : {};
  1261 + if (!isPlainObject(override)) {
  1262 + return result;
  1263 + }
  1264 + Object.keys(override).forEach(key => {
  1265 + const overrideValue = override[key];
  1266 + if (Array.isArray(overrideValue)) {
  1267 + result[key] = cloneDeep(overrideValue);
  1268 + } else if (isPlainObject(overrideValue)) {
  1269 + result[key] = mergeOptions(result[key], overrideValue);
1120 } else { 1270 } else {
1121 - scale.ticks = { color: palette.text }; 1271 + result[key] = overrideValue;
1122 } 1272 }
1123 - if (scale.grid) {  
1124 - scale.grid.color = palette.grid;  
1125 - } else {  
1126 - scale.grid = { color: palette.grid }; 1273 + });
  1274 + return result;
  1275 +}
  1276 +
  1277 +function resolveChartTypes(payload) {
  1278 + const widgetType = payload && payload.widgetType ? payload.widgetType : 'chart.js/bar';
  1279 + const primary = widgetType.includes('/') ? widgetType.split('/').pop() : widgetType;
  1280 + const extra = Array.isArray(payload && payload.preferredTypes) ? payload.preferredTypes : [];
  1281 + const pipeline = [primary, ...extra, ...STABLE_CHART_TYPES];
  1282 + const result = [];
  1283 + pipeline.forEach(type => {
  1284 + if (type && !result.includes(type)) {
  1285 + result.push(type);
1127 } 1286 }
1128 }); 1287 });
1129 - options.scales = scales;  
1130 - chart.options = options;  
1131 - chart.update('none'); 1288 + return result.length ? result : ['bar'];
1132 } 1289 }
1133 1290
1134 -function hydrateCharts() {  
1135 - if (typeof Chart === 'undefined') {  
1136 - return; 1291 +function describeChartType(type) {
  1292 + return CHART_TYPE_LABELS[type] || type || '图表';
  1293 +}
  1294 +
  1295 +function setChartDegradeNote(card, fromType, toType) {
  1296 + if (!card) return;
  1297 + card.setAttribute('data-chart-state', 'degraded');
  1298 + let note = card.querySelector('.chart-note');
  1299 + if (!note) {
  1300 + note = document.createElement('p');
  1301 + note.className = 'chart-note';
  1302 + card.appendChild(note);
  1303 + }
  1304 + note.textContent = `${describeChartType(fromType)}渲染失败,已自动切换为${describeChartType(toType)}以确保兼容。`;
  1305 +}
  1306 +
  1307 +function clearChartDegradeNote(card) {
  1308 + if (!card) return;
  1309 + card.removeAttribute('data-chart-state');
  1310 + const note = card.querySelector('.chart-note');
  1311 + if (note) {
  1312 + note.remove();
  1313 + }
  1314 +}
  1315 +
  1316 +function createFallbackTable(labels, datasets) {
  1317 + if (!Array.isArray(datasets) || !datasets.length) {
  1318 + return null;
1137 } 1319 }
  1320 + const primaryDataset = datasets.find(ds => Array.isArray(ds && ds.data));
  1321 + const resolvedLabels = Array.isArray(labels) && labels.length
  1322 + ? labels
  1323 + : (primaryDataset && primaryDataset.data ? primaryDataset.data.map((_, idx) => `数据点 ${idx + 1}`) : []);
  1324 + if (!resolvedLabels.length) {
  1325 + return null;
  1326 + }
  1327 + const table = document.createElement('table');
  1328 + const thead = document.createElement('thead');
  1329 + const headRow = document.createElement('tr');
  1330 + const categoryHeader = document.createElement('th');
  1331 + categoryHeader.textContent = '类别';
  1332 + headRow.appendChild(categoryHeader);
  1333 + datasets.forEach((dataset, index) => {
  1334 + const th = document.createElement('th');
  1335 + th.textContent = dataset && dataset.label ? dataset.label : `系列${index + 1}`;
  1336 + headRow.appendChild(th);
  1337 + });
  1338 + thead.appendChild(headRow);
  1339 + table.appendChild(thead);
  1340 + const tbody = document.createElement('tbody');
  1341 + resolvedLabels.forEach((label, rowIdx) => {
  1342 + const row = document.createElement('tr');
  1343 + const labelCell = document.createElement('td');
  1344 + labelCell.textContent = label;
  1345 + row.appendChild(labelCell);
  1346 + datasets.forEach(dataset => {
  1347 + const cell = document.createElement('td');
  1348 + const series = dataset && Array.isArray(dataset.data) ? dataset.data[rowIdx] : undefined;
  1349 + if (typeof series === 'number') {
  1350 + cell.textContent = series.toLocaleString();
  1351 + } else if (series !== undefined && series !== null && series !== '') {
  1352 + cell.textContent = series;
  1353 + } else {
  1354 + cell.textContent = '—';
  1355 + }
  1356 + row.appendChild(cell);
  1357 + });
  1358 + tbody.appendChild(row);
  1359 + });
  1360 + table.appendChild(tbody);
  1361 + return table;
  1362 +}
  1363 +
  1364 +function renderChartFallback(canvas, payload, reason) {
  1365 + const card = canvas.closest('.chart-card') || canvas.parentElement;
  1366 + if (!card) return;
  1367 + clearChartDegradeNote(card);
  1368 + const wrapper = canvas.parentElement && canvas.parentElement.classList && canvas.parentElement.classList.contains('chart-container')
  1369 + ? canvas.parentElement
  1370 + : null;
  1371 + if (wrapper) {
  1372 + wrapper.style.display = 'none';
  1373 + } else {
  1374 + canvas.style.display = 'none';
  1375 + }
  1376 + let fallback = card.querySelector('.chart-fallback[data-dynamic="true"]');
  1377 + let prebuilt = false;
  1378 + if (!fallback) {
  1379 + fallback = card.querySelector('.chart-fallback');
  1380 + if (fallback) {
  1381 + prebuilt = fallback.hasAttribute('data-prebuilt');
  1382 + }
  1383 + }
  1384 + if (!fallback) {
  1385 + fallback = document.createElement('div');
  1386 + fallback.className = 'chart-fallback';
  1387 + fallback.setAttribute('data-dynamic', 'true');
  1388 + card.appendChild(fallback);
  1389 + } else if (!prebuilt) {
  1390 + fallback.innerHTML = '';
  1391 + }
  1392 + const titleFromOptions = payload && payload.props && payload.props.options &&
  1393 + payload.props.options.plugins && payload.props.options.plugins.title &&
  1394 + payload.props.options.plugins.title.text;
  1395 + const fallbackTitle = titleFromOptions ||
  1396 + (payload && payload.props && payload.props.title) ||
  1397 + (payload && payload.widgetId) ||
  1398 + canvas.getAttribute('id') ||
  1399 + '图表';
  1400 + const existingNotice = fallback.querySelector('.chart-fallback__notice');
  1401 + if (existingNotice) {
  1402 + existingNotice.remove();
  1403 + }
  1404 + const notice = document.createElement('p');
  1405 + notice.className = 'chart-fallback__notice';
  1406 + notice.textContent = `${fallbackTitle}:图表未能渲染,已展示表格数据${reason ? `(${reason})` : ''}`;
  1407 + fallback.insertBefore(notice, fallback.firstChild || null);
  1408 + if (!prebuilt) {
  1409 + const table = createFallbackTable(
  1410 + payload && payload.data && payload.data.labels,
  1411 + payload && payload.data && payload.data.datasets
  1412 + );
  1413 + if (table) {
  1414 + fallback.appendChild(table);
  1415 + }
  1416 + }
  1417 + fallback.style.display = 'block';
  1418 + card.setAttribute('data-chart-state', 'fallback');
  1419 +}
  1420 +
  1421 +function buildChartOptions(payload) {
  1422 + const rawLegend = payload && payload.props ? payload.props.legend : undefined;
  1423 + let legendConfig;
  1424 + if (isPlainObject(rawLegend)) {
  1425 + legendConfig = mergeOptions({
  1426 + display: rawLegend.display !== false,
  1427 + position: rawLegend.position || 'top'
  1428 + }, rawLegend);
  1429 + } else {
  1430 + legendConfig = {
  1431 + display: rawLegend === 'hidden' ? false : true,
  1432 + position: typeof rawLegend === 'string' ? rawLegend : 'top'
  1433 + };
  1434 + }
  1435 + const baseOptions = {
  1436 + responsive: true,
  1437 + maintainAspectRatio: false,
  1438 + plugins: {
  1439 + legend: legendConfig
  1440 + }
  1441 + };
  1442 + if (payload && payload.props && payload.props.title) {
  1443 + baseOptions.plugins.title = {
  1444 + display: true,
  1445 + text: payload.props.title
  1446 + };
  1447 + }
  1448 + const overrideOptions = payload && payload.props && payload.props.options;
  1449 + return mergeOptions(baseOptions, overrideOptions);
  1450 +}
  1451 +
  1452 +function instantiateChart(ctx, payload, optionsTemplate, type) {
  1453 + const data = cloneDeep(payload && payload.data ? payload.data : {});
  1454 + const config = {
  1455 + type,
  1456 + data,
  1457 + options: cloneDeep(optionsTemplate)
  1458 + };
  1459 + return new Chart(ctx, config);
  1460 +}
  1461 +
  1462 +function hydrateCharts() {
1138 document.querySelectorAll('canvas[data-config-id]').forEach(canvas => { 1463 document.querySelectorAll('canvas[data-config-id]').forEach(canvas => {
1139 const configScript = document.getElementById(canvas.dataset.configId); 1464 const configScript = document.getElementById(canvas.dataset.configId);
1140 if (!configScript) return; 1465 if (!configScript) return;
@@ -1143,33 +1468,51 @@ function hydrateCharts() { @@ -1143,33 +1468,51 @@ function hydrateCharts() {
1143 payload = JSON.parse(configScript.textContent); 1468 payload = JSON.parse(configScript.textContent);
1144 } catch (err) { 1469 } catch (err) {
1145 console.error('Widget JSON 解析失败', err); 1470 console.error('Widget JSON 解析失败', err);
  1471 + renderChartFallback(canvas, { widgetId: canvas.dataset.configId }, '配置解析失败');
1146 return; 1472 return;
1147 } 1473 }
1148 - const chartType = (payload.widgetType || 'chart.js/bar').split('/').pop(); 1474 + if (typeof Chart === 'undefined') {
  1475 + renderChartFallback(canvas, payload, 'Chart.js 未加载');
  1476 + return;
  1477 + }
  1478 + const chartTypes = resolveChartTypes(payload);
1149 const ctx = canvas.getContext('2d'); 1479 const ctx = canvas.getContext('2d');
1150 - const baseOptions = {  
1151 - responsive: true,  
1152 - maintainAspectRatio: false,  
1153 - plugins: {  
1154 - legend: {  
1155 - display: payload.props && payload.props.legend !== 'hidden',  
1156 - position: (payload.props && payload.props.legend) || 'top'  
1157 - },  
1158 - title: payload.props && payload.props.title ? {  
1159 - display: true,  
1160 - text: payload.props.title  
1161 - } : undefined 1480 + if (!ctx) {
  1481 + renderChartFallback(canvas, payload, 'Canvas 初始化失败');
  1482 + return;
  1483 + }
  1484 + const card = canvas.closest('.chart-card') || canvas.parentElement;
  1485 + const optionsTemplate = buildChartOptions(payload);
  1486 + const desiredType = chartTypes[0];
  1487 + let chartInstance = null;
  1488 + let selectedType = null;
  1489 + let lastError;
  1490 + for (const type of chartTypes) {
  1491 + try {
  1492 + chartInstance = instantiateChart(ctx, payload, optionsTemplate, type);
  1493 + selectedType = type;
  1494 + break;
  1495 + } catch (err) {
  1496 + lastError = err;
  1497 + console.error('图表渲染失败', type, err);
1162 } 1498 }
1163 - };  
1164 - const mergedOptions = Object.assign({}, baseOptions, payload.props && payload.props.options ? payload.props.options : {});  
1165 - const config = {  
1166 - type: chartType,  
1167 - data: payload.data || {},  
1168 - options: mergedOptions  
1169 - };  
1170 - const chart = new Chart(ctx, config);  
1171 - chartRegistry.push(chart);  
1172 - applyChartTheme(chart); 1499 + }
  1500 + if (chartInstance) {
  1501 + chartRegistry.push(chartInstance);
  1502 + try {
  1503 + applyChartTheme(chartInstance);
  1504 + } catch (err) {
  1505 + console.error('主题同步失败', selectedType || desiredType || payload && payload.widgetType || 'chart', err);
  1506 + }
  1507 + if (selectedType && selectedType !== desiredType) {
  1508 + setChartDegradeNote(card, desiredType, selectedType);
  1509 + } else {
  1510 + clearChartDegradeNote(card);
  1511 + }
  1512 + } else {
  1513 + const reason = lastError && lastError.message ? lastError.message : '';
  1514 + renderChartFallback(canvas, payload, reason);
  1515 + }
1173 }); 1516 });
1174 } 1517 }
1175 1518
@@ -1222,17 +1565,5 @@ document.addEventListener('DOMContentLoaded', () => { @@ -1222,17 +1565,5 @@ document.addEventListener('DOMContentLoaded', () => {
1222 </script> 1565 </script>
1223 """.strip() 1566 """.strip()
1224 1567
1225 - # ====== Utils ======  
1226 -  
1227 - @staticmethod  
1228 - def _escape_html(value: Any) -> str:  
1229 - """HTML内容转义工具,避免XSS"""  
1230 - return html.escape(str(value)) if value is not None else ""  
1231 -  
1232 - @staticmethod  
1233 - def _escape_attr(value: Any) -> str:  
1234 - """HTML属性值转义工具"""  
1235 - return html.escape(str(value), quote=True) if value is not None else ""  
1236 -  
1237 1568
1238 __all__ = ["HTMLRenderer"] 1569 __all__ = ["HTMLRenderer"]
@@ -25,6 +25,9 @@ class Settings(BaseSettings): @@ -25,6 +25,9 @@ class Settings(BaseSettings):
25 DOCUMENT_IR_OUTPUT_DIR: str = Field( 25 DOCUMENT_IR_OUTPUT_DIR: str = Field(
26 "final_reports/ir", description="整本IR/Manifest输出目录" 26 "final_reports/ir", description="整本IR/Manifest输出目录"
27 ) 27 )
  28 + CHAPTER_JSON_MAX_ATTEMPTS: int = Field(
  29 + 2, description="章节JSON解析失败时的最大尝试次数"
  30 + )
28 TEMPLATE_DIR: str = Field("ReportEngine/report_template", description="多模板目录") 31 TEMPLATE_DIR: str = Field("ReportEngine/report_template", description="多模板目录")
29 API_TIMEOUT: float = Field(900.0, description="单API超时时间(秒)") 32 API_TIMEOUT: float = Field(900.0, description="单API超时时间(秒)")
30 MAX_RETRY_DELAY: float = Field(180.0, description="最大重试间隔(秒)") 33 MAX_RETRY_DELAY: float = Field(180.0, description="最大重试间隔(秒)")
@@ -52,6 +55,7 @@ def print_config(config: Settings): @@ -52,6 +55,7 @@ def print_config(config: Settings):
52 message += f"最大内容长度: {config.MAX_CONTENT_LENGTH}\n" 55 message += f"最大内容长度: {config.MAX_CONTENT_LENGTH}\n"
53 message += f"输出目录: {config.OUTPUT_DIR}\n" 56 message += f"输出目录: {config.OUTPUT_DIR}\n"
54 message += f"章节JSON目录: {config.CHAPTER_OUTPUT_DIR}\n" 57 message += f"章节JSON目录: {config.CHAPTER_OUTPUT_DIR}\n"
  58 + message += f"章节JSON最大尝试次数: {config.CHAPTER_JSON_MAX_ATTEMPTS}\n"
55 message += f"整本IR目录: {config.DOCUMENT_IR_OUTPUT_DIR}\n" 59 message += f"整本IR目录: {config.DOCUMENT_IR_OUTPUT_DIR}\n"
56 message += f"模板目录: {config.TEMPLATE_DIR}\n" 60 message += f"模板目录: {config.TEMPLATE_DIR}\n"
57 message += f"API 超时时间: {config.API_TIMEOUT} 秒\n" 61 message += f"API 超时时间: {config.API_TIMEOUT} 秒\n"
@@ -1027,6 +1027,49 @@ @@ -1027,6 +1027,49 @@
1027 display: none; 1027 display: none;
1028 } 1028 }
1029 1029
  1030 + .report-stream-line {
  1031 + font-size: 12px;
  1032 + margin-bottom: 4px;
  1033 + display: flex;
  1034 + align-items: center;
  1035 + gap: 8px;
  1036 + line-height: 1.5;
  1037 + }
  1038 +
  1039 + .report-stream-line .timestamp {
  1040 + color: #cccccc;
  1041 + min-width: 60px;
  1042 + }
  1043 +
  1044 + .report-stream-line .stream-badge {
  1045 + border: 1px solid #444444;
  1046 + padding: 1px 6px;
  1047 + font-size: 10px;
  1048 + text-transform: uppercase;
  1049 + color: #ffffff;
  1050 + letter-spacing: 0.5px;
  1051 + }
  1052 +
  1053 + .report-stream-line .line-text {
  1054 + flex: 1;
  1055 + }
  1056 +
  1057 + .report-stream-line.chunk {
  1058 + color: #8fd5ff;
  1059 + }
  1060 +
  1061 + .report-stream-line.warn {
  1062 + color: #ffd166;
  1063 + }
  1064 +
  1065 + .report-stream-line.error {
  1066 + color: #ff6b6b;
  1067 + }
  1068 +
  1069 + .report-stream-line.success {
  1070 + color: #80ffb5;
  1071 + }
  1072 +
1030 .report-loading { 1073 .report-loading {
1031 display: flex; 1074 display: flex;
1032 align-items: center; 1075 align-items: center;
@@ -1165,6 +1208,9 @@ @@ -1165,6 +1208,9 @@
1165 let systemStarted = false; 1208 let systemStarted = false;
1166 let systemStarting = false; 1209 let systemStarting = false;
1167 let configModalLocked = false; 1210 let configModalLocked = false;
  1211 + let socketConnected = false;
  1212 + let reportStreamConnected = false;
  1213 + let backendReachable = false;
1168 1214
1169 const CONFIG_ENDPOINT = '/api/config'; 1215 const CONFIG_ENDPOINT = '/api/config';
1170 const SYSTEM_STATUS_ENDPOINT = '/api/system/status'; 1216 const SYSTEM_STATUS_ENDPOINT = '/api/system/status';
@@ -1276,6 +1322,7 @@ @@ -1276,6 +1322,7 @@
1276 setInterval(updateTime, 1000); 1322 setInterval(updateTime, 1000);
1277 checkStatus(); 1323 checkStatus();
1278 setInterval(checkStatus, 5000); 1324 setInterval(checkStatus, 5000);
  1325 + startConnectionProbe();
1279 1326
1280 // 初始化密码切换功能(事件委托,只需调用一次) 1327 // 初始化密码切换功能(事件委托,只需调用一次)
1281 attachConfigPasswordToggles(); 1328 attachConfigPasswordToggles();
@@ -1308,12 +1355,14 @@ @@ -1308,12 +1355,14 @@
1308 socket = io(); 1355 socket = io();
1309 1356
1310 socket.on('connect', function() { 1357 socket.on('connect', function() {
1311 - updateConnectionStatus('已连接'); 1358 + socketConnected = true;
  1359 + refreshConnectionStatus();
1312 socket.emit('request_status'); 1360 socket.emit('request_status');
1313 }); 1361 });
1314 1362
1315 socket.on('disconnect', function() { 1363 socket.on('disconnect', function() {
1316 - updateConnectionStatus('连接断开'); 1364 + socketConnected = false;
  1365 + refreshConnectionStatus();
1317 }); 1366 });
1318 1367
1319 socket.on('console_output', function(data) { 1368 socket.on('console_output', function(data) {
@@ -2255,10 +2304,38 @@ @@ -2255,10 +2304,38 @@
2255 fetch('/api/status') 2304 fetch('/api/status')
2256 .then(response => response.json()) 2305 .then(response => response.json())
2257 .then(data => { 2306 .then(data => {
  2307 + backendReachable = true;
2258 updateAppStatus(data); 2308 updateAppStatus(data);
  2309 + refreshConnectionStatus();
2259 }) 2310 })
2260 .catch(error => { 2311 .catch(error => {
2261 console.error('状态检查失败:', error); 2312 console.error('状态检查失败:', error);
  2313 + backendReachable = false;
  2314 + refreshConnectionStatus();
  2315 + });
  2316 + }
  2317 +
  2318 + function startConnectionProbe() {
  2319 + if (connectionProbeTimer) {
  2320 + clearInterval(connectionProbeTimer);
  2321 + }
  2322 + probeBackendConnection();
  2323 + connectionProbeTimer = setInterval(probeBackendConnection, CONNECTION_PROBE_INTERVAL);
  2324 + }
  2325 +
  2326 + function probeBackendConnection() {
  2327 + fetch('/api/report/status?heartbeat=1', { cache: 'no-store' })
  2328 + .then(response => {
  2329 + if (!response.ok) throw new Error('heartbeat failed');
  2330 + return response.json();
  2331 + })
  2332 + .then(() => {
  2333 + backendReachable = true;
  2334 + refreshConnectionStatus();
  2335 + })
  2336 + .catch(() => {
  2337 + backendReachable = false;
  2338 + refreshConnectionStatus();
2262 }); 2339 });
2263 } 2340 }
2264 2341
@@ -2279,9 +2356,15 @@ @@ -2279,9 +2356,15 @@
2279 updateEmbeddedPage(currentApp); 2356 updateEmbeddedPage(currentApp);
2280 } 2357 }
2281 2358
2282 - // 更新连接状态  
2283 - function updateConnectionStatus(status) {  
2284 - document.getElementById('connectionStatus').textContent = status; 2359 + // 根据当前的Socket/SSE状态刷新底部连接指示
  2360 + function refreshConnectionStatus() {
  2361 + const statusEl = document.getElementById('connectionStatus');
  2362 + if (!statusEl) return;
  2363 + if (socketConnected || reportStreamConnected || backendReachable) {
  2364 + statusEl.textContent = '已连接';
  2365 + } else {
  2366 + statusEl.textContent = '连接断开';
  2367 + }
2285 } 2368 }
2286 2369
2287 // 更新时间 2370 // 更新时间
@@ -2738,6 +2821,14 @@ @@ -2738,6 +2821,14 @@
2738 // Report Engine 相关函数 2821 // Report Engine 相关函数
2739 let reportTaskId = null; 2822 let reportTaskId = null;
2740 let reportPollingInterval = null; 2823 let reportPollingInterval = null;
  2824 + let reportEventSource = null;
  2825 + let reportAutoPreviewLoaded = false;
  2826 + let reportStreamReconnectTimer = null;
  2827 + let reportStreamRetryDelay = 3000;
  2828 + let streamHeartbeatTimeout = null;
  2829 + let streamHeartbeatInterval = null;
  2830 + let connectionProbeTimer = null;
  2831 + const CONNECTION_PROBE_INTERVAL = 15000;
2741 2832
2742 // 加载报告界面 2833 // 加载报告界面
2743 function loadReportInterface() { 2834 function loadReportInterface() {
@@ -2811,6 +2902,8 @@ @@ -2811,6 +2902,8 @@
2811 2902
2812 reportContent.innerHTML = interfaceHTML; 2903 reportContent.innerHTML = interfaceHTML;
2813 initializeReportControls(); 2904 initializeReportControls();
  2905 + resetReportStreamOutput('等待新的Report任务启动...');
  2906 + updateReportStreamStatus('idle');
2814 2907
2815 // 立即更新状态信息 2908 // 立即更新状态信息
2816 updateEngineStatusDisplay(statusData); 2909 updateEngineStatusDisplay(statusData);
@@ -2818,8 +2911,22 @@ @@ -2818,8 +2911,22 @@
2818 // 如果有当前任务,显示任务状态 2911 // 如果有当前任务,显示任务状态
2819 if (statusData.current_task) { 2912 if (statusData.current_task) {
2820 updateTaskProgressStatus(statusData.current_task); 2913 updateTaskProgressStatus(statusData.current_task);
  2914 + if (statusData.current_task.status === 'running') {
  2915 + reportTaskId = statusData.current_task.task_id;
  2916 + reportAutoPreviewLoaded = false;
  2917 + if (window.EventSource) {
  2918 + openReportStream(reportTaskId);
  2919 + } else {
  2920 + startProgressPolling(reportTaskId);
  2921 + }
  2922 + } else if (statusData.current_task.status === 'completed') {
  2923 + lastCompletedReportTask = statusData.current_task;
  2924 + updateDownloadButtonState(statusData.current_task);
  2925 + }
2821 } else { 2926 } else {
2822 updateDownloadButtonState(null); 2927 updateDownloadButtonState(null);
  2928 + safeCloseReportStream();
  2929 + reportTaskId = null;
2823 } 2930 }
2824 } 2931 }
2825 2932
@@ -3054,10 +3161,13 @@ @@ -3054,10 +3161,13 @@
3054 3161
3055 // 重置日志计数器,因为后台会清空日志文件 3162 // 重置日志计数器,因为后台会清空日志文件
3056 reportLogLineCount = 0; 3163 reportLogLineCount = 0;
  3164 + reportAutoPreviewLoaded = false;
  3165 + safeCloseReportStream(true);
3057 3166
3058 // 清空控制台显示 3167 // 清空控制台显示
3059 const consoleOutput = document.getElementById('consoleOutput'); 3168 const consoleOutput = document.getElementById('consoleOutput');
3060 consoleOutput.innerHTML = '<div class="console-line">[系统] 开始生成报告,日志已重置</div>'; 3169 consoleOutput.innerHTML = '<div class="console-line">[系统] 开始生成报告,日志已重置</div>';
  3170 + resetReportStreamOutput('Report Engine 正在调度任务...');
3061 3171
3062 setGenerateButtonState(true); 3172 setGenerateButtonState(true);
3063 3173
@@ -3099,14 +3209,21 @@ @@ -3099,14 +3209,21 @@
3099 refreshReportLog(); 3209 refreshReportLog();
3100 }, 500); 3210 }, 500);
3101 3211
3102 - // 开始轮询任务状态  
3103 - startProgressPolling(data.task_id); 3212 + appendReportStreamLine('任务创建成功,正在建立流式连接...', 'info', { force: true });
  3213 + if (window.EventSource) {
  3214 + openReportStream(reportTaskId);
  3215 + } else {
  3216 + startProgressPolling(data.task_id);
  3217 + }
3104 } else { 3218 } else {
3105 updateTaskProgressStatus(null, 'error', '启动失败: ' + data.error); 3219 updateTaskProgressStatus(null, 'error', '启动失败: ' + data.error);
3106 // 重置标志允许重新尝试 3220 // 重置标志允许重新尝试
3107 autoGenerateTriggered = false; 3221 autoGenerateTriggered = false;
3108 reportTaskId = null; 3222 reportTaskId = null;
3109 setGenerateButtonState(false); 3223 setGenerateButtonState(false);
  3224 + appendReportStreamLine('任务启动失败: ' + (data.error || '未知错误'), 'error');
  3225 + updateReportStreamStatus('error');
  3226 + safeCloseReportStream();
3110 } 3227 }
3111 }) 3228 })
3112 .catch(error => { 3229 .catch(error => {
@@ -3116,6 +3233,9 @@ @@ -3116,6 +3233,9 @@
3116 autoGenerateTriggered = false; 3233 autoGenerateTriggered = false;
3117 reportTaskId = null; 3234 reportTaskId = null;
3118 setGenerateButtonState(false); 3235 setGenerateButtonState(false);
  3236 + appendReportStreamLine('任务启动阶段异常: ' + error.message, 'error');
  3237 + updateReportStreamStatus('error');
  3238 + safeCloseReportStream();
3119 }); 3239 });
3120 } 3240 }
3121 3241
@@ -3147,6 +3267,7 @@ @@ -3147,6 +3267,7 @@
3147 3267
3148 // 自动显示报告 3268 // 自动显示报告
3149 viewReport(taskId); 3269 viewReport(taskId);
  3270 + reportAutoPreviewLoaded = true;
3150 3271
3151 // 重置自动生成标志,允许下次有新内容时自动生成 3272 // 重置自动生成标志,允许下次有新内容时自动生成
3152 autoGenerateTriggered = false; 3273 autoGenerateTriggered = false;
@@ -3225,6 +3346,319 @@ @@ -3225,6 +3346,319 @@
3225 updateTaskProgressStatus(task); 3346 updateTaskProgressStatus(task);
3226 } 3347 }
3227 3348
  3349 + // ====== Report Engine SSE流式辅助函数 ======
  3350 + // 重置流式日志入口,将提示语写入控制台,保持与右侧黑框一致
  3351 + function resetReportStreamOutput(message = '等待新的Report任务启动...') {
  3352 + appendReportStreamLine(message, 'info', { badge: 'REPORT', force: true });
  3353 + }
  3354 +
  3355 + // 根据状态同步流式指示灯,与后端心跳保持一致
  3356 + function updateReportStreamStatus(state) {
  3357 + if (state === 'connected') {
  3358 + reportStreamConnected = true;
  3359 + } else if (['idle', 'error', 'connecting', 'reconnecting'].includes(state)) {
  3360 + reportStreamConnected = false;
  3361 + }
  3362 +
  3363 + const statusEl = document.getElementById('reportStreamStatus');
  3364 + if (statusEl) {
  3365 + const textMap = {
  3366 + idle: '未连接',
  3367 + connecting: '连接中',
  3368 + connected: '实时更新中',
  3369 + reconnecting: '等待重连',
  3370 + error: '已断开'
  3371 + };
  3372 + statusEl.textContent = textMap[state] || state;
  3373 + statusEl.dataset.state = state;
  3374 + }
  3375 +
  3376 + refreshConnectionStatus();
  3377 + }
  3378 +
  3379 + // 往黑色控制台输出区域追加一条流式日志
  3380 + function appendReportStreamLine(message, level = 'info', options = {}) {
  3381 + const consoleOutput = document.getElementById('consoleOutput');
  3382 + if (!consoleOutput) return;
  3383 +
  3384 + if (level === 'chunk' && !options.force) {
  3385 + return; // 章节内容流式写入不再逐条输出
  3386 + }
  3387 +
  3388 + const line = document.createElement('div');
  3389 + line.className = `console-line report-stream-line ${level}`;
  3390 +
  3391 + const timestampSpan = document.createElement('span');
  3392 + timestampSpan.className = 'timestamp';
  3393 + timestampSpan.textContent = new Date().toLocaleTimeString('zh-CN');
  3394 + line.appendChild(timestampSpan);
  3395 +
  3396 + if (options.badge) {
  3397 + const badge = document.createElement('span');
  3398 + badge.className = 'stream-badge';
  3399 + badge.textContent = options.badge;
  3400 + line.appendChild(badge);
  3401 + }
  3402 +
  3403 + const textSpan = document.createElement('span');
  3404 + textSpan.className = 'line-text';
  3405 + textSpan.textContent = message;
  3406 + line.appendChild(textSpan);
  3407 +
  3408 + consoleOutput.appendChild(line);
  3409 + consoleOutput.scrollTop = consoleOutput.scrollHeight;
  3410 + }
  3411 +
  3412 + function startStreamHeartbeat() {
  3413 + clearStreamHeartbeat();
  3414 + const emitHeartbeat = () => {
  3415 + appendReportStreamLine('Report Engine 正在流式生成,请耐心等待...', 'info', { badge: 'REPORT', force: true });
  3416 + };
  3417 +
  3418 + const scheduleFirstTick = () => {
  3419 + const now = Date.now();
  3420 + const msToNextMinute = 60000 - (now % 60000);
  3421 + streamHeartbeatTimeout = setTimeout(() => {
  3422 + emitHeartbeat();
  3423 + streamHeartbeatInterval = setInterval(emitHeartbeat, 60000);
  3424 + }, msToNextMinute);
  3425 + };
  3426 +
  3427 + scheduleFirstTick();
  3428 + }
  3429 +
  3430 + function clearStreamHeartbeat() {
  3431 + if (streamHeartbeatTimeout) {
  3432 + clearTimeout(streamHeartbeatTimeout);
  3433 + streamHeartbeatTimeout = null;
  3434 + }
  3435 + if (streamHeartbeatInterval) {
  3436 + clearInterval(streamHeartbeatInterval);
  3437 + streamHeartbeatInterval = null;
  3438 + }
  3439 + }
  3440 +
  3441 + // 建立SSE连接,实时订阅Report Engine推送
  3442 + function openReportStream(taskId, isRetry = false) {
  3443 + if (!taskId) return;
  3444 + if (!window.EventSource) {
  3445 + appendReportStreamLine('浏览器不支持SSE,已自动回退为轮询模式', 'warn', { badge: 'SSE', force: true });
  3446 + updateReportStreamStatus('error');
  3447 + clearStreamHeartbeat();
  3448 + startProgressPolling(taskId);
  3449 + return;
  3450 + }
  3451 + if (reportPollingInterval) {
  3452 + clearInterval(reportPollingInterval);
  3453 + reportPollingInterval = null;
  3454 + }
  3455 + if (reportEventSource && reportEventSource.__taskId === taskId) {
  3456 + if (reportEventSource.readyState !== EventSource.CLOSED) {
  3457 + return;
  3458 + }
  3459 + safeCloseReportStream(true, true);
  3460 + } else if (reportEventSource) {
  3461 + safeCloseReportStream(true, true);
  3462 + }
  3463 +
  3464 + if (reportStreamReconnectTimer) {
  3465 + clearTimeout(reportStreamReconnectTimer);
  3466 + reportStreamReconnectTimer = null;
  3467 + }
  3468 +
  3469 + if (!isRetry) {
  3470 + reportStreamRetryDelay = 3000;
  3471 + }
  3472 +
  3473 + updateReportStreamStatus('connecting');
  3474 + appendReportStreamLine(
  3475 + isRetry ? '尝试重连Report Engine流式通道...' : '正在建立Report Engine流式连接...',
  3476 + 'info',
  3477 + { badge: 'SSE', force: true }
  3478 + );
  3479 +
  3480 + reportEventSource = new EventSource(`/api/report/stream/${taskId}`);
  3481 + reportEventSource.__taskId = taskId;
  3482 + reportEventSource.onopen = () => {
  3483 + reportStreamRetryDelay = 3000;
  3484 + updateReportStreamStatus('connected');
  3485 + appendReportStreamLine(isRetry ? 'SSE重连成功' : 'Report Engine流式连接已建立', 'success', { badge: 'SSE' });
  3486 + startStreamHeartbeat();
  3487 + };
  3488 + reportEventSource.onerror = () => {
  3489 + appendReportStreamLine('检测到网络抖动,SSE正在等待自动重连...', 'warn', { badge: 'SSE' });
  3490 + updateReportStreamStatus('reconnecting');
  3491 + clearStreamHeartbeat();
  3492 + safeCloseReportStream(true, true);
  3493 + scheduleReportStreamReconnect(taskId);
  3494 + };
  3495 +
  3496 + const events = ['status', 'stage', 'chapter_status', 'chapter_chunk', 'warning', 'html_ready', 'completed', 'error', 'heartbeat'];
  3497 + events.forEach(evt => {
  3498 + reportEventSource.addEventListener(evt, (event) => dispatchReportStreamEvent(evt, event));
  3499 + });
  3500 + reportEventSource.onmessage = (event) => dispatchReportStreamEvent(event.type || 'message', event);
  3501 + }
  3502 +
  3503 + // 关闭SSE连接,可根据场景选择是否立即重置指示灯
  3504 + function safeCloseReportStream(keepIndicator = false, preserveRetryDelay = false) {
  3505 + if (reportEventSource) {
  3506 + reportEventSource.close();
  3507 + reportEventSource = null;
  3508 + }
  3509 + if (reportStreamReconnectTimer) {
  3510 + clearTimeout(reportStreamReconnectTimer);
  3511 + reportStreamReconnectTimer = null;
  3512 + }
  3513 + clearStreamHeartbeat();
  3514 + if (!keepIndicator) {
  3515 + updateReportStreamStatus('idle');
  3516 + } else {
  3517 + reportStreamConnected = false;
  3518 + refreshConnectionStatus();
  3519 + }
  3520 + if (!preserveRetryDelay) {
  3521 + reportStreamRetryDelay = 3000;
  3522 + }
  3523 + }
  3524 +
  3525 + function scheduleReportStreamReconnect(taskId) {
  3526 + if (!taskId || reportStreamReconnectTimer) {
  3527 + return;
  3528 + }
  3529 + reportStreamReconnectTimer = setTimeout(() => {
  3530 + reportStreamReconnectTimer = null;
  3531 + if (reportTaskId === taskId) {
  3532 + openReportStream(taskId, true);
  3533 + }
  3534 + }, reportStreamRetryDelay);
  3535 + reportStreamRetryDelay = Math.min(reportStreamRetryDelay * 2, 15000);
  3536 + }
  3537 +
  3538 + // 统一的事件派发入口,负责解析JSON并交给业务处理
  3539 + function dispatchReportStreamEvent(eventType, event) {
  3540 + try {
  3541 + const data = JSON.parse(event.data);
  3542 + handleReportStreamEvent(eventType, data);
  3543 + } catch (error) {
  3544 + console.warn('解析流式事件失败:', error);
  3545 + }
  3546 + }
  3547 +
  3548 + // 结合事件类型输出控件/状态,确保网络抖动时也能及时反馈
  3549 + function handleReportStreamEvent(eventType, eventData) {
  3550 + if (!eventData) return;
  3551 + const payload = eventData.payload || {};
  3552 + const task = payload.task;
  3553 +
  3554 + if (eventType === 'status' && task) {
  3555 + updateTaskProgressStatus(task);
  3556 + reportTaskId = task.status === 'running' ? task.task_id : null;
  3557 + if (task.status === 'completed') {
  3558 + lastCompletedReportTask = task;
  3559 + setGenerateButtonState(false);
  3560 + } else if (task.status === 'running') {
  3561 + setGenerateButtonState(true);
  3562 + }
  3563 + }
  3564 +
  3565 + switch (eventType) {
  3566 + case 'stage':
  3567 + appendReportStreamLine(
  3568 + payload.message || `阶段: ${payload.stage || ''}`,
  3569 + 'info',
  3570 + {
  3571 + badge: payload.stage || '阶段',
  3572 + genericMessage: 'Report Engine 正在逐步生成,请耐心等待...'
  3573 + }
  3574 + );
  3575 + break;
  3576 + case 'chapter_status':
  3577 + appendReportStreamLine(
  3578 + `${payload.title || payload.chapterId || '章节'} ${payload.status === 'completed' ? '已完成' : '生成中'}`,
  3579 + payload.status === 'completed' ? 'success' : 'info',
  3580 + {
  3581 + badge: '章节',
  3582 + genericMessage: payload.status === 'completed'
  3583 + ? `${payload.title || payload.chapterId || '章节'} 已完成`
  3584 + : '章节流式生成中,请稍候...'
  3585 + }
  3586 + );
  3587 + break;
  3588 + case 'chapter_chunk':
  3589 + if (payload.delta) {
  3590 + appendReportStreamLine(
  3591 + formatStreamChunk(payload.delta),
  3592 + 'chunk',
  3593 + {
  3594 + badge: payload.title || payload.chapterId || '章节流',
  3595 + genericMessage: '章节内容流式写入中...'
  3596 + }
  3597 + );
  3598 + }
  3599 + break;
  3600 + case 'warning':
  3601 + appendReportStreamLine(payload.message || '检测到可重试的网络波动', 'warn');
  3602 + break;
  3603 + case 'html_ready':
  3604 + appendReportStreamLine('HTML渲染完成,正在刷新预览...', 'success');
  3605 + if (task) {
  3606 + updateDownloadButtonState(task);
  3607 + }
  3608 + if (eventData.task_id && !reportAutoPreviewLoaded) {
  3609 + viewReport(eventData.task_id);
  3610 + reportAutoPreviewLoaded = true;
  3611 + }
  3612 + break;
  3613 + case 'completed':
  3614 + appendReportStreamLine(payload.message || '任务完成', 'success');
  3615 + safeCloseReportStream();
  3616 + reportTaskId = null;
  3617 + setGenerateButtonState(false);
  3618 + if (task) {
  3619 + lastCompletedReportTask = task;
  3620 + updateDownloadButtonState(task);
  3621 + }
  3622 + if (eventData.task_id && !reportAutoPreviewLoaded) {
  3623 + viewReport(eventData.task_id);
  3624 + reportAutoPreviewLoaded = true;
  3625 + }
  3626 + break;
  3627 + case 'cancelled':
  3628 + appendReportStreamLine(payload.message || '任务已取消', 'warn');
  3629 + safeCloseReportStream();
  3630 + updateReportStreamStatus('idle');
  3631 + reportTaskId = null;
  3632 + setGenerateButtonState(false);
  3633 + break;
  3634 + case 'error':
  3635 + appendReportStreamLine(payload.message || '任务失败', 'error');
  3636 + safeCloseReportStream();
  3637 + updateReportStreamStatus('error');
  3638 + reportTaskId = null;
  3639 + setGenerateButtonState(false);
  3640 + break;
  3641 + case 'heartbeat':
  3642 + updateReportStreamStatus('connected');
  3643 + appendReportStreamLine(payload.message || '流式连接正常,请稍候...', 'info', {
  3644 + badge: 'SSE',
  3645 + genericMessage: '流式连接正常,请耐心等待...'
  3646 + });
  3647 + break;
  3648 + default:
  3649 + if (payload.message) {
  3650 + appendReportStreamLine(payload.message, 'info');
  3651 + }
  3652 + break;
  3653 + }
  3654 + }
  3655 +
  3656 + // 清洗流式chunk,裁剪多余空白,避免影响UI
  3657 + function formatStreamChunk(text) {
  3658 + if (!text) return '';
  3659 + return text.replace(/\s+/g, ' ').trim().slice(0, 200);
  3660 + }
  3661 +
3228 // 查看报告 3662 // 查看报告
3229 function viewReport(taskId) { 3663 function viewReport(taskId) {
3230 const reportPreview = document.getElementById('reportPreview'); 3664 const reportPreview = document.getElementById('reportPreview');
@@ -3435,4 +3869,4 @@ @@ -3435,4 +3869,4 @@
3435 } 3869 }
3436 </script> 3870 </script>
3437 </body> 3871 </body>
3438 -</html>  
  3872 +</html>