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