Showing
4 changed files
with
1005 additions
and
83 deletions
| @@ -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 | + """将paragraph的inlines统一规整,剔除非法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 block的items为[[block, block], ...]结构""" | 576 | """确保list block的items为[[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> |
-
Please register or login to post a comment