Compare commits
4 Commits
8999fb1b77
...
f217672365
| Author | SHA1 | Date |
|---|---|---|
|
|
f217672365 | |
|
|
a00ebfe50e | |
|
|
a8b84b1d6f | |
|
|
b0e0182b73 |
|
|
@ -4,3 +4,7 @@
|
||||||
*.sh text eol=lf
|
*.sh text eol=lf
|
||||||
Dockerfile text eol=lf
|
Dockerfile text eol=lf
|
||||||
*.Dockerfile text eol=lf
|
*.Dockerfile text eol=lf
|
||||||
|
|
||||||
|
# Preserve the verified upstream Mermaid bundle byte-for-byte. Its embedded
|
||||||
|
# template literals intentionally contain trailing spaces.
|
||||||
|
web/static/vendor/mermaid/mermaid.min.js -text -whitespace
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@
|
||||||
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
||||||
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
||||||
|
|
||||||
|
## 0.60.24 — 2026-08-03
|
||||||
|
|
||||||
|
- 对话中的 Mermaid 流程图、柱状图和折线图现在可以直接显示;图表语法有误时会保留源码并给出提示。另修复嵌套代码块格式异常时后续正文整段变成代码的问题。
|
||||||
|
|
||||||
## 0.60.23 — 2026-08-03
|
## 0.60.23 — 2026-08-03
|
||||||
|
|
||||||
- 修复已有 Word、PowerPoint、Excel 文件转 PDF 时可能找不到文件或错误宣称格式可用的问题;现在会根据服务器实际具备的转换组件提供能力,并在组件缺失时给出明确提示。
|
- 修复已有 Word、PowerPoint、Excel 文件转 PDF 时可能找不到文件或错误宣称格式可用的问题;现在会根据服务器实际具备的转换组件提供能力,并在组件缺失时给出明确提示。
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
||||||
|
|
||||||
最后更新:2026-08-03(Office→PDF 组件感知 + 路径兼容,bump 0.60.23)
|
最后更新:2026-08-03(Web Mermaid 直出 + Markdown 围栏容错,bump 0.60.24)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
|
|
||||||
### 2026-08-03
|
### 2026-08-03
|
||||||
|
|
||||||
|
- **08-03 / 0.60.24 / Web Mermaid 直出 + Markdown 围栏容错**:模型偶发用同长度围栏嵌套 Markdown/Mermaid 示例,CommonMark 会把后续正文吞进未闭合代码块;新增仅针对该明确形态的前后端确定性修复,提示词统一要求外层使用更长异类围栏,历史上下文加载时同样修正且不批量回写生产数据。聊天页本地 vendoring Mermaid 11.16.0,仅在助手文字段定稿后顺序渲染 `language-mermaid`,采用 strict 安全级别、文本/边数上限,语法错误或组件不可用时保留源码并提示;真实 Edge 冒烟确认中文流程图与 XYChart 柱线组合图可生成 SVG。Python 27 项、Node 9 项、Ruff、JS/Python 语法及 diff 检查通过;无 schema、migration、HTTP API 或 Python 依赖变化。
|
||||||
- **08-03 / 0.60.23 / Office→PDF 组件感知 + 展示路径兼容**:生产 task `92ac20cf` 暴露两层问题:上传消息给出 user-root 相对的 `测试pdf/x.doc`,host tool 又按 task_dir 拼接导致首次找不到;改用裸文件名后,host 仅安装 `libreoffice-impress` 却因只检测 `soffice` 而错误宣称支持 DOC,最终 Writer 导入返回 `source file could not be loaded`。现 `office_to_pdf` 同时解析 task 相对、user-root 展示路径与 `/workspace` 路径;Debian/Ubuntu 按 Writer/Calc/Impress 实际安装包缓存支持后缀,工具 schema 只声明可用格式,缺组件在启动转换前给出明确管理员提示。RUN bootstrap 与故障表同步要求 host 安装三组件;相关 30 项 unittest、Python 编译及 diff 格式检查通过,本机无 LibreOffice,真实 `.doc/.xlsx/.pptx` 冒烟留部署 host;无 schema、migration、HTTP API 或 Python 依赖变化,无需重建沙箱镜像。
|
- **08-03 / 0.60.23 / Office→PDF 组件感知 + 展示路径兼容**:生产 task `92ac20cf` 暴露两层问题:上传消息给出 user-root 相对的 `测试pdf/x.doc`,host tool 又按 task_dir 拼接导致首次找不到;改用裸文件名后,host 仅安装 `libreoffice-impress` 却因只检测 `soffice` 而错误宣称支持 DOC,最终 Writer 导入返回 `source file could not be loaded`。现 `office_to_pdf` 同时解析 task 相对、user-root 展示路径与 `/workspace` 路径;Debian/Ubuntu 按 Writer/Calc/Impress 实际安装包缓存支持后缀,工具 schema 只声明可用格式,缺组件在启动转换前给出明确管理员提示。RUN bootstrap 与故障表同步要求 host 安装三组件;相关 30 项 unittest、Python 编译及 diff 格式检查通过,本机无 LibreOffice,真实 `.doc/.xlsx/.pptx` 冒烟留部署 host;无 schema、migration、HTTP API 或 Python 依赖变化,无需重建沙箱镜像。
|
||||||
- **08-03 / 0.60.22 / 通用 PDF 路由修复 + host Office 转换**:复盘生产 task 发现通用 system prompt 只展示 `report --format docx`,模型遂误判平台渲染器只能出 Word,进而在 Docker 内探测 LibreOffice、临时安装 WeasyPrint 并手写转换脚本;现补齐 `report --format pdf` 唯一入口,新写 Markdown 报告直接由沙盒 Chromium 出 PDF。新增按 host `soffice` 实际可用性注册的 `office_to_pdf` typed tool,已有 DOCX/PPTX/XLSX/ODF 文件通过 backend host LibreOffice 转换,复用 `/workspace`→user_root 路径翻译并强制用户目录边界,Docker 内不再探测宿主命令。相关 34 项 unittest、Python 编译及 diff 格式检查通过;本机未安装 LibreOffice,真实 soffice 冒烟留部署 host 执行;无 schema、migration、HTTP API 或依赖变化,新增 host 工具无需重建沙箱镜像。
|
- **08-03 / 0.60.22 / 通用 PDF 路由修复 + host Office 转换**:复盘生产 task 发现通用 system prompt 只展示 `report --format docx`,模型遂误判平台渲染器只能出 Word,进而在 Docker 内探测 LibreOffice、临时安装 WeasyPrint 并手写转换脚本;现补齐 `report --format pdf` 唯一入口,新写 Markdown 报告直接由沙盒 Chromium 出 PDF。新增按 host `soffice` 实际可用性注册的 `office_to_pdf` typed tool,已有 DOCX/PPTX/XLSX/ODF 文件通过 backend host LibreOffice 转换,复用 `/workspace`→user_root 路径翻译并强制用户目录边界,Docker 内不再探测宿主命令。相关 34 项 unittest、Python 编译及 diff 格式检查通过;本机未安装 LibreOffice,真实 soffice 冒烟留部署 host 执行;无 schema、migration、HTTP API 或依赖变化,新增 host 工具无需重建沙箱镜像。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||||
# 改版本只动这一行。
|
# 改版本只动这一行。
|
||||||
__version__ = "0.60.23"
|
__version__ = "0.60.24"
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,14 @@ _MEDIA_READDOC_SEG = """\
|
||||||
- `read_document` —— 读 PDF(豆包 Seed 2.0 Lite 文档理解),**专治扫描件**:markitdown 对某 PDF 转出**空 / 近空**(纯图页无文本层)→ 用它逐页 OCR 成 markdown。每页约 1-2 厘钱,单次上限 100 页(更长先拆分卷)。
|
- `read_document` —— 读 PDF(豆包 Seed 2.0 Lite 文档理解),**专治扫描件**:markitdown 对某 PDF 转出**空 / 近空**(纯图页无文本层)→ 用它逐页 OCR 成 markdown。每页约 1-2 厘钱,单次上限 100 页(更长先拆分卷)。
|
||||||
- **多页整本 OCR 必传 `save_md`**(如 `save_md='source/xxx.md'`):全文落文件、只返回预览,后续直接 read 那个 md;只问局部内容("第3章指标是什么")传 `question` 即可不落盘。
|
- **多页整本 OCR 必传 `save_md`**(如 `save_md='source/xxx.md'`):全文落文件、只返回预览,后续直接 read 那个 md;只问局部内容("第3章指标是什么")传 `question` 即可不落盘。
|
||||||
- **何时不调**:PDF 有文本层且 markitdown 已转出正文(直接用那份 md,别重复花钱);DOCX/PPTX/XLSX(走 markitdown);单张图片(走 look_at_image)。"""
|
- **何时不调**:PDF 有文本层且 markitdown 已转出正文(直接用那份 md,别重复花钱);DOCX/PPTX/XLSX(走 markitdown);单张图片(走 look_at_image)。"""
|
||||||
|
|
||||||
|
# PDF 摄取是跨 skill 的基础入口,始终注入,不能依赖 agent 是否正确加载 paper / proposal。
|
||||||
|
# read_document 只在配了 ARK 时挂载;没挂时扫描件明确停在“需 OCR”,不诱导现场装包或
|
||||||
|
# 自造平行解析链路。正向写唯一入口,与 AGENTS.md 的 prompt 设计约定一致。
|
||||||
|
_PDF_INGEST_SEG = """\
|
||||||
|
## 读取已有 PDF
|
||||||
|
- **唯一入口**:先用 `markitdown` CLI 把 PDF 转成 task_dir 内的 Markdown 文件,再用 `read` 分段读取该 Markdown;文本型 PDF 到此即完成。
|
||||||
|
- 若转换结果为空 / 近空,按扫描件处理:本轮有 `read_document` 时调用它并用 `save_md` 落 Markdown;没有该工具时,明确说明当前缺少扫描件 OCR 能力并请用户换可检索版本。依赖与解析器由平台镜像统一提供,任务内只走上述入口。"""
|
||||||
_MEDIA_SEEDREAM_SEG = """\
|
_MEDIA_SEEDREAM_SEG = """\
|
||||||
- `seedream` —— 豆包图像生成 / 改图。产物自动落 `<task_dir>/figures/`。每次 **¥0.22**(联网 `search=true` 加 ¥0.05)。
|
- `seedream` —— 豆包图像生成 / 改图。产物自动落 `<task_dir>/figures/`。每次 **¥0.22**(联网 `search=true` 加 ¥0.05)。
|
||||||
- **文生图**(不传 `reference_images`):从零按 prompt 画。**改图 i2i**(传 `reference_images=["figures/xxx.png"]`):在已有图上做像素级修改。**用户对刚生成 / 上传的图说"改成 X / 换个颜色 / 去掉某处" → 必须走改图(reference_images 指那张图),绝不重新文生图**(重画 = 完全不同的图,丢原构图)。v1 改图仅支持单张参考。
|
- **文生图**(不传 `reference_images`):从零按 prompt 画。**改图 i2i**(传 `reference_images=["figures/xxx.png"]`):在已有图上做像素级修改。**用户对刚生成 / 上传的图说"改成 X / 换个颜色 / 去掉某处" → 必须走改图(reference_images 指那张图),绝不重新文生图**(重画 = 完全不同的图,丢原构图)。v1 改图仅支持单张参考。
|
||||||
|
|
@ -359,6 +367,7 @@ def _build_system_prompt(
|
||||||
user_root(workspace_dir, user_id) / ".kb"
|
user_root(workspace_dir, user_id) / ".kb"
|
||||||
)
|
)
|
||||||
prompt += kb_block(workspace_dir, user_id, kb_dir_display)
|
prompt += kb_block(workspace_dir, user_id, kb_dir_display)
|
||||||
|
prompt += "\n\n" + _PDF_INGEST_SEG
|
||||||
if media_block:
|
if media_block:
|
||||||
prompt += "\n\n" + media_block
|
prompt += "\n\n" + media_block
|
||||||
wd_abs = working_dir.resolve()
|
wd_abs = working_dir.resolve()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
"""Narrow repairs for malformed nested Markdown fence examples.
|
||||||
|
|
||||||
|
Models occasionally wrap a fenced example in an equally long ``markdown``
|
||||||
|
fence. CommonMark cannot nest those fences: the inner closing fence closes
|
||||||
|
the outer block and the intended outer close opens a new, unclosed block.
|
||||||
|
Only that unambiguous shape is repaired here; arbitrary unclosed fences are
|
||||||
|
reported but left untouched.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
_FENCE_RE = re.compile(r"^( {0,3})(`{3,}|~{3,})([^\r\n]*)(\r?\n)?$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MarkdownFenceResult:
|
||||||
|
text: str
|
||||||
|
repairs: int = 0
|
||||||
|
unclosed_fence: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def _fence(line: str) -> tuple[str, str, int, str] | None:
|
||||||
|
match = _FENCE_RE.match(line)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
token = match.group(2)
|
||||||
|
return match.group(1), token[0], len(token), match.group(3).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _next_nonblank(lines: list[str], start: int) -> int | None:
|
||||||
|
for idx in range(start, len(lines)):
|
||||||
|
if lines[idx].strip():
|
||||||
|
return idx
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_fence(line: str, length: int) -> str:
|
||||||
|
match = _FENCE_RE.match(line)
|
||||||
|
if not match:
|
||||||
|
return line
|
||||||
|
return (
|
||||||
|
match.group(1)
|
||||||
|
+ match.group(2)[0] * length
|
||||||
|
+ match.group(3)
|
||||||
|
+ (match.group(4) or "")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_unclosed_fence(lines: list[str]) -> bool:
|
||||||
|
opened: tuple[str, int] | None = None
|
||||||
|
for line in lines:
|
||||||
|
parsed = _fence(line)
|
||||||
|
if parsed is None:
|
||||||
|
continue
|
||||||
|
_indent, char, length, info = parsed
|
||||||
|
if opened is None:
|
||||||
|
opened = (char, length)
|
||||||
|
elif not info and char == opened[0] and length >= opened[1]:
|
||||||
|
opened = None
|
||||||
|
return opened is not None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_markdown_fences(text: str) -> MarkdownFenceResult:
|
||||||
|
"""Repair only an equally-long fenced block nested in markdown/md.
|
||||||
|
|
||||||
|
Recognised shape (blank lines between the two closing fences are allowed):
|
||||||
|
``markdown opener -> language opener -> language close -> outer close``.
|
||||||
|
The outer pair is lengthened by one character. Other malformed input is
|
||||||
|
preserved so the platform never guesses broadly at author intent.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return MarkdownFenceResult(text=text)
|
||||||
|
|
||||||
|
lines = text.splitlines(keepends=True)
|
||||||
|
repairs = 0
|
||||||
|
idx = 0
|
||||||
|
while idx < len(lines):
|
||||||
|
outer = _fence(lines[idx])
|
||||||
|
if outer is None or outer[3].lower() not in {"markdown", "md"}:
|
||||||
|
idx += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
inner_idx = _next_nonblank(lines, idx + 1)
|
||||||
|
inner = _fence(lines[inner_idx]) if inner_idx is not None else None
|
||||||
|
if (
|
||||||
|
inner is None
|
||||||
|
or not inner[3]
|
||||||
|
or inner[1] != outer[1]
|
||||||
|
or inner[2] < outer[2]
|
||||||
|
):
|
||||||
|
idx += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
inner_close_idx = None
|
||||||
|
for candidate in range(inner_idx + 1, len(lines)):
|
||||||
|
closing = _fence(lines[candidate])
|
||||||
|
if (
|
||||||
|
closing is not None
|
||||||
|
and not closing[3]
|
||||||
|
and closing[1] == inner[1]
|
||||||
|
and closing[2] >= inner[2]
|
||||||
|
):
|
||||||
|
inner_close_idx = candidate
|
||||||
|
break
|
||||||
|
if inner_close_idx is None:
|
||||||
|
idx += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
outer_close_idx = _next_nonblank(lines, inner_close_idx + 1)
|
||||||
|
outer_close = _fence(lines[outer_close_idx]) if outer_close_idx is not None else None
|
||||||
|
if (
|
||||||
|
outer_close is None
|
||||||
|
or outer_close[3]
|
||||||
|
or outer_close[1] != outer[1]
|
||||||
|
or outer_close[2] < outer[2]
|
||||||
|
):
|
||||||
|
idx += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
repaired_length = max(outer[2], inner[2]) + 1
|
||||||
|
lines[idx] = _replace_fence(lines[idx], repaired_length)
|
||||||
|
lines[outer_close_idx] = _replace_fence(lines[outer_close_idx], repaired_length)
|
||||||
|
repairs += 1
|
||||||
|
idx = outer_close_idx + 1
|
||||||
|
|
||||||
|
normalized = "".join(lines)
|
||||||
|
return MarkdownFenceResult(
|
||||||
|
text=normalized,
|
||||||
|
repairs=repairs,
|
||||||
|
unclosed_fence=_has_unclosed_fence(lines),
|
||||||
|
)
|
||||||
|
|
@ -19,6 +19,7 @@ from sqlalchemy import delete, func, select
|
||||||
from .storage import session_scope
|
from .storage import session_scope
|
||||||
from .storage.models import Message, Task, sanitize_jsonb_nul
|
from .storage.models import Message, Task, sanitize_jsonb_nul
|
||||||
from .file_store import atomic_write_text
|
from .file_store import atomic_write_text
|
||||||
|
from .markdown_guard import normalize_markdown_fences
|
||||||
|
|
||||||
|
|
||||||
def _to_dict(msg: Any) -> Any:
|
def _to_dict(msg: Any) -> Any:
|
||||||
|
|
@ -72,6 +73,20 @@ class Session:
|
||||||
# 与 Message.payload 的 ORM 守门保持一致,使当前 run 的内存上下文和落库值
|
# 与 Message.payload 的 ORM 守门保持一致,使当前 run 的内存上下文和落库值
|
||||||
# 完全相同;外部工具提取文本偶尔会携带 PostgreSQL JSONB 不支持的 NUL。
|
# 完全相同;外部工具提取文本偶尔会携带 PostgreSQL JSONB 不支持的 NUL。
|
||||||
msg_dict = sanitize_jsonb_nul(_to_dict(msg))
|
msg_dict = sanitize_jsonb_nul(_to_dict(msg))
|
||||||
|
if msg_dict.get("role") == "assistant" and isinstance(msg_dict.get("content"), str):
|
||||||
|
fence_result = normalize_markdown_fences(msg_dict["content"])
|
||||||
|
msg_dict["content"] = fence_result.text
|
||||||
|
if fence_result.repairs:
|
||||||
|
print(
|
||||||
|
f"[markdown:fence-repair] task={self.task_id} "
|
||||||
|
f"repairs={fence_result.repairs}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
elif fence_result.unclosed_fence:
|
||||||
|
print(
|
||||||
|
f"[markdown:fence-warning] task={self.task_id} unclosed=1",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
self.messages.append(msg_dict)
|
self.messages.append(msg_dict)
|
||||||
if msg_dict.get("role") == "system":
|
if msg_dict.get("role") == "system":
|
||||||
return None
|
return None
|
||||||
|
|
@ -201,7 +216,12 @@ class Session:
|
||||||
.order_by(Message.idx)
|
.order_by(Message.idx)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
for row in rows:
|
for row in rows:
|
||||||
sess.messages.append(dict(row.payload))
|
payload = dict(row.payload)
|
||||||
|
if payload.get("role") == "assistant" and isinstance(payload.get("content"), str):
|
||||||
|
# 历史行不回写生产库;只在重建 LLM 上下文时应用同一窄修复,
|
||||||
|
# 与 Web 展示层保持一致,避免旧坏围栏继续污染后续轮次。
|
||||||
|
payload["content"] = normalize_markdown_fences(payload["content"]).text
|
||||||
|
sess.messages.append(payload)
|
||||||
# 真实总条数(含 base 之前的归档历史),保证 append 续号不撞 idx。
|
# 真实总条数(含 base 之前的归档历史),保证 append 续号不撞 idx。
|
||||||
sess._db_idx = s.execute(
|
sess._db_idx = s.execute(
|
||||||
select(func.count())
|
select(func.count())
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,13 @@ _TITLE_PROMPT = """请给下面这条新对话消息生成一个简短、具体
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_PREFIX_RE = re.compile(r"^(?:标题|对话标题)\s*[::]\s*", re.I)
|
_PREFIX_RE = re.compile(r"^(?:标题|对话标题)\s*[::]\s*", re.I)
|
||||||
|
_ATTACHMENT_LINE_RE = re.compile(r"^\[用户上传的(?:参考图|文件)\]\s+\S.*$")
|
||||||
|
|
||||||
|
|
||||||
|
def is_attachment_only_message(content: str) -> bool:
|
||||||
|
"""消息是否只包含前端注入的附件路径标记。"""
|
||||||
|
lines = [line.strip() for line in (content or "").splitlines() if line.strip()]
|
||||||
|
return bool(lines) and all(_ATTACHMENT_LINE_RE.fullmatch(line) for line in lines)
|
||||||
|
|
||||||
|
|
||||||
def clean_generated_title(raw: str, user_message: str) -> str:
|
def clean_generated_title(raw: str, user_message: str) -> str:
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@
|
||||||
- 动手前先看: 用 read/grep/glob 摸清现状,再 edit
|
- 动手前先看: 用 read/grep/glob 摸清现状,再 edit
|
||||||
- 改动最小化: edit 工具的 old_str 必须唯一匹配,不够唯一就多带上下文
|
- 改动最小化: edit 工具的 old_str 必须唯一匹配,不够唯一就多带上下文
|
||||||
- 有测试就跑测试验证;没有就用 run_python 写一段最小复现验证
|
- 有测试就跑测试验证;没有就用 run_python 写一段最小复现验证
|
||||||
|
- Markdown 围栏:展示包含 fenced code block 的 Markdown 源码时,外层统一用 `~~~~` 围栏,内层保留反引号围栏,并确保外层围栏长于任何同字符内层围栏
|
||||||
- 输出简洁: 不复述 diff,只说做了什么、下一步要不要继续
|
- 输出简洁: 不复述 diff,只说做了什么、下一步要不要继续
|
||||||
- 工具结果带 `[Error ...]` 时,先想清楚原因再重试,不要盲目重复同一调用
|
- 工具结果带 `[Error ...]` 时,先想清楚原因再重试,不要盲目重复同一调用
|
||||||
- 不臆造 API、文献、数据 —— 不知道就 read 源码 / 让用户提供 / 明说不知道
|
- 不臆造 API、文献、数据 —— 不知道就 read 源码 / 让用户提供 / 明说不知道
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const marked = require("../web/static/vendor/markdown/marked.umd.js");
|
||||||
|
globalThis.window = { marked };
|
||||||
|
|
||||||
|
const { normalizeMarkdownFences, renderMd, renderMermaidIn } = await import("../web/static/js/markdown.js");
|
||||||
|
|
||||||
|
test("repairs equal-length nested markdown fences", () => {
|
||||||
|
const broken = [
|
||||||
|
"```markdown",
|
||||||
|
"```mermaid",
|
||||||
|
"flowchart LR",
|
||||||
|
" A --> B",
|
||||||
|
"```",
|
||||||
|
"```",
|
||||||
|
"",
|
||||||
|
"正文 **正常**。",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const repaired = normalizeMarkdownFences(broken);
|
||||||
|
|
||||||
|
assert.match(repaired, /^````markdown\n```mermaid/);
|
||||||
|
assert.match(repaired, /```\n````\n\n正文 \*\*正常\*\*。/);
|
||||||
|
const html = renderMd(broken);
|
||||||
|
assert.match(html, /<p>正文 <strong>正常<\/strong>。<\/p>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves unrelated unclosed fences", () => {
|
||||||
|
const broken = "正文\n```python\nprint('x')\n";
|
||||||
|
assert.equal(normalizeMarkdownFences(broken), broken);
|
||||||
|
});
|
||||||
|
|
||||||
|
function fakeMermaidBlock(source = "flowchart LR\nA --> B") {
|
||||||
|
const created = [];
|
||||||
|
const doc = {
|
||||||
|
createElement(tag) {
|
||||||
|
const el = {
|
||||||
|
tag,
|
||||||
|
children: [],
|
||||||
|
attributes: {},
|
||||||
|
appendChild(child) { this.children.push(child); },
|
||||||
|
setAttribute(name, value) { this.attributes[name] = value; },
|
||||||
|
};
|
||||||
|
created.push(el);
|
||||||
|
return el;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const pre = {
|
||||||
|
isConnected: true,
|
||||||
|
ownerDocument: doc,
|
||||||
|
beforeNodes: [],
|
||||||
|
before(node) { this.beforeNodes.push(node); },
|
||||||
|
replaceWith(node) { this.replacement = node; },
|
||||||
|
};
|
||||||
|
const code = { dataset: {}, textContent: source, parentElement: pre };
|
||||||
|
const container = { querySelectorAll() { return [code]; } };
|
||||||
|
return { code, pre, container, created };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("renders completed mermaid blocks with strict local configuration", async () => {
|
||||||
|
const calls = { initialize: [], render: [] };
|
||||||
|
window.mermaid = {
|
||||||
|
initialize(config) { calls.initialize.push(config); },
|
||||||
|
async render(id, source) {
|
||||||
|
calls.render.push({ id, source });
|
||||||
|
return { svg: "<svg viewBox='0 0 10 10'></svg>" };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const fixture = fakeMermaidBlock();
|
||||||
|
|
||||||
|
const result = await renderMermaidIn(fixture.container);
|
||||||
|
|
||||||
|
assert.deepEqual(result, { rendered: 1, failed: 0, unavailable: 0 });
|
||||||
|
assert.equal(calls.initialize.length, 1);
|
||||||
|
assert.equal(calls.initialize[0].securityLevel, "strict");
|
||||||
|
assert.equal(calls.initialize[0].startOnLoad, false);
|
||||||
|
assert.equal(calls.render[0].source, "flowchart LR\nA --> B");
|
||||||
|
assert.equal(fixture.pre.replacement.className, "mermaid-diagram");
|
||||||
|
assert.equal(fixture.pre.replacement.children[0].innerHTML, "<svg viewBox='0 0 10 10'></svg>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps mermaid source visible when syntax is invalid", async () => {
|
||||||
|
window.mermaid.render = async () => { throw new Error("parse failed"); };
|
||||||
|
const fixture = fakeMermaidBlock("not valid");
|
||||||
|
|
||||||
|
const result = await renderMermaidIn(fixture.container);
|
||||||
|
|
||||||
|
assert.deepEqual(result, { rendered: 0, failed: 1, unavailable: 0 });
|
||||||
|
assert.equal(fixture.pre.replacement, undefined);
|
||||||
|
assert.equal(fixture.pre.beforeNodes[0].textContent, "Mermaid 图表语法有误,已保留源码。");
|
||||||
|
assert.equal(fixture.code.dataset.mermaidState, "error");
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from core.markdown_guard import normalize_markdown_fences
|
||||||
|
|
||||||
|
BROKEN = """前文
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A --> B
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
后文 **应正常渲染**。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class MarkdownFenceGuardTests(unittest.TestCase):
|
||||||
|
def test_repairs_equal_length_nested_fence_example(self) -> None:
|
||||||
|
result = normalize_markdown_fences(BROKEN)
|
||||||
|
|
||||||
|
self.assertEqual(result.repairs, 1)
|
||||||
|
self.assertFalse(result.unclosed_fence)
|
||||||
|
self.assertIn("````markdown\n```mermaid", result.text)
|
||||||
|
self.assertIn("```\n````\n\n后文", result.text)
|
||||||
|
|
||||||
|
def test_preserves_already_valid_longer_outer_fence(self) -> None:
|
||||||
|
valid = BROKEN.replace("```markdown", "````markdown", 1).replace(
|
||||||
|
"```\n\n后文", "````\n\n后文", 1
|
||||||
|
)
|
||||||
|
|
||||||
|
result = normalize_markdown_fences(valid)
|
||||||
|
|
||||||
|
self.assertEqual(result.text, valid)
|
||||||
|
self.assertEqual(result.repairs, 0)
|
||||||
|
self.assertFalse(result.unclosed_fence)
|
||||||
|
|
||||||
|
def test_preserves_arbitrary_unclosed_fence_and_reports_it(self) -> None:
|
||||||
|
broken = "正文\n\n```python\nprint('x')\n"
|
||||||
|
|
||||||
|
result = normalize_markdown_fences(broken)
|
||||||
|
|
||||||
|
self.assertEqual(result.text, broken)
|
||||||
|
self.assertEqual(result.repairs, 0)
|
||||||
|
self.assertTrue(result.unclosed_fence)
|
||||||
|
|
||||||
|
def test_supports_tilde_fences_and_blank_line_between_closes(self) -> None:
|
||||||
|
broken = "~~~~md\n~~~~js\nx = 1\n~~~~\n\n~~~~\n正文\n"
|
||||||
|
|
||||||
|
result = normalize_markdown_fences(broken)
|
||||||
|
|
||||||
|
self.assertEqual(result.repairs, 1)
|
||||||
|
self.assertTrue(result.text.startswith("~~~~~md\n~~~~js"))
|
||||||
|
self.assertIn("~~~~\n\n~~~~~\n正文", result.text)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import unittest
|
import unittest
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
|
@ -62,6 +63,72 @@ class MessagePayloadSanitizationTests(unittest.TestCase):
|
||||||
self.assertEqual(session.messages, [expected])
|
self.assertEqual(session.messages, [expected])
|
||||||
self.assertEqual(fake_db.row.payload, expected)
|
self.assertEqual(fake_db.row.payload, expected)
|
||||||
|
|
||||||
|
def test_session_repairs_assistant_markdown_before_storing_it(self) -> None:
|
||||||
|
class FakeDbSession:
|
||||||
|
row: Message
|
||||||
|
|
||||||
|
def add(self, row: Message) -> None:
|
||||||
|
self.row = row
|
||||||
|
|
||||||
|
def flush(self) -> None:
|
||||||
|
self.row.message_id = uuid4()
|
||||||
|
|
||||||
|
fake_db = FakeDbSession()
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def fake_session_scope():
|
||||||
|
yield fake_db
|
||||||
|
|
||||||
|
broken = "```markdown\n```mermaid\nA --> B\n```\n```\n正文\n"
|
||||||
|
session = Session(task_id=uuid4())
|
||||||
|
with patch("core.session.session_scope", side_effect=fake_session_scope):
|
||||||
|
session.append({"role": "assistant", "content": broken})
|
||||||
|
|
||||||
|
stored = session.messages[0]["content"]
|
||||||
|
self.assertTrue(stored.startswith("````markdown\n```mermaid"))
|
||||||
|
self.assertIn("```\n````\n正文", stored)
|
||||||
|
self.assertEqual(fake_db.row.payload["content"], stored)
|
||||||
|
|
||||||
|
def test_session_repairs_historical_assistant_markdown_in_memory_only(self) -> None:
|
||||||
|
broken = "```markdown\n```mermaid\nA --> B\n```\n```\n正文\n"
|
||||||
|
|
||||||
|
class FakeResult:
|
||||||
|
def __init__(self, value):
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
def first(self):
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
def scalars(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
def scalar_one(self):
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
class FakeDbSession:
|
||||||
|
def __init__(self):
|
||||||
|
self.results = iter([
|
||||||
|
FakeResult(SimpleNamespace(context_base_idx=0, context_summary="")),
|
||||||
|
FakeResult([SimpleNamespace(payload={"role": "assistant", "content": broken})]),
|
||||||
|
FakeResult(1),
|
||||||
|
])
|
||||||
|
|
||||||
|
def execute(self, _query):
|
||||||
|
return next(self.results)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def fake_session_scope():
|
||||||
|
yield FakeDbSession()
|
||||||
|
|
||||||
|
with patch("core.session.session_scope", side_effect=fake_session_scope):
|
||||||
|
session = Session.load(uuid4())
|
||||||
|
|
||||||
|
self.assertTrue(session.messages[0]["content"].startswith("````markdown"))
|
||||||
|
self.assertEqual(session._db_idx, 1)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,11 @@ from tools.run_python import RunPythonTool
|
||||||
|
|
||||||
|
|
||||||
class RunPythonScriptPathTests(unittest.TestCase):
|
class RunPythonScriptPathTests(unittest.TestCase):
|
||||||
|
def test_description_only_lists_preinstalled_libraries(self) -> None:
|
||||||
|
self.assertIn("Preinstalled libs include", RunPythonTool.description)
|
||||||
|
self.assertNotIn("install with shell pip", RunPythonTool.description)
|
||||||
|
self.assertNotIn("pypdf", RunPythonTool.description.lower())
|
||||||
|
|
||||||
def test_executes_existing_script_path(self) -> None:
|
def test_executes_existing_script_path(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
script = Path(tmp) / "hello.py"
|
script = Path(tmp) / "hello.py"
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
from pathlib import Path
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from web.static_files import NoCacheStaticFiles
|
from web.static_files import NoCacheStaticFiles
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
STATIC_DIR = ROOT / "web" / "static"
|
STATIC_DIR = ROOT / "web" / "static"
|
||||||
DEV_HTML = STATIC_DIR / "dev.html"
|
DEV_HTML = STATIC_DIR / "dev.html"
|
||||||
UI_CSS = STATIC_DIR / "ui.css"
|
UI_CSS = STATIC_DIR / "ui.css"
|
||||||
JS_DIR = STATIC_DIR / "js"
|
JS_DIR = STATIC_DIR / "js"
|
||||||
VENDOR_DIR = STATIC_DIR / "vendor" / "markdown"
|
VENDOR_DIR = STATIC_DIR / "vendor" / "markdown"
|
||||||
|
MERMAID_VENDOR_DIR = STATIC_DIR / "vendor" / "mermaid"
|
||||||
|
|
||||||
|
|
||||||
def _frontend_source() -> str:
|
def _frontend_source() -> str:
|
||||||
|
|
@ -31,6 +31,7 @@ class StaticVendorTests(unittest.TestCase):
|
||||||
self.assertIn('src="vendor/markdown/marked.umd.js"', html)
|
self.assertIn('src="vendor/markdown/marked.umd.js"', html)
|
||||||
self.assertIn('src="vendor/markdown/purify.min.js"', html)
|
self.assertIn('src="vendor/markdown/purify.min.js"', html)
|
||||||
self.assertIn('src="vendor/markdown/highlight.min.js"', html)
|
self.assertIn('src="vendor/markdown/highlight.min.js"', html)
|
||||||
|
self.assertIn('src="vendor/mermaid/mermaid.min.js"', html)
|
||||||
self.assertIn('href="vendor/markdown/github.min.css"', html)
|
self.assertIn('href="vendor/markdown/github.min.css"', html)
|
||||||
|
|
||||||
for name in (
|
for name in (
|
||||||
|
|
@ -43,6 +44,12 @@ class StaticVendorTests(unittest.TestCase):
|
||||||
self.assertTrue(path.exists(), f"missing vendored asset: {path}")
|
self.assertTrue(path.exists(), f"missing vendored asset: {path}")
|
||||||
self.assertGreater(path.stat().st_size, 0, f"empty vendored asset: {path}")
|
self.assertGreater(path.stat().st_size, 0, f"empty vendored asset: {path}")
|
||||||
|
|
||||||
|
mermaid_bundle = MERMAID_VENDOR_DIR / "mermaid.min.js"
|
||||||
|
mermaid_license = MERMAID_VENDOR_DIR / "LICENSE.txt"
|
||||||
|
self.assertTrue(mermaid_bundle.exists(), f"missing vendored asset: {mermaid_bundle}")
|
||||||
|
self.assertGreater(mermaid_bundle.stat().st_size, 1_000_000)
|
||||||
|
self.assertTrue(mermaid_license.exists(), f"missing vendored license: {mermaid_license}")
|
||||||
|
|
||||||
def test_dev_html_uses_local_ui_foundation(self) -> None:
|
def test_dev_html_uses_local_ui_foundation(self) -> None:
|
||||||
html = DEV_HTML.read_text(encoding="utf-8")
|
html = DEV_HTML.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
@ -62,6 +69,9 @@ class StaticVendorTests(unittest.TestCase):
|
||||||
self.assertIn(".msg.assistant", css)
|
self.assertIn(".msg.assistant", css)
|
||||||
self.assertIn(".composer-tool .composer-label", css)
|
self.assertIn(".composer-tool .composer-label", css)
|
||||||
self.assertIn('aria-label="添加附件"', html)
|
self.assertIn('aria-label="添加附件"', html)
|
||||||
|
self.assertIn("await ensureTaskFromDraft();", frontend)
|
||||||
|
self.assertIn('"[用户上传的文件]"', frontend)
|
||||||
|
self.assertNotIn("发送第一条消息创建对话后即可添加附件", frontend)
|
||||||
self.assertIn('aria-label="语音输入"', html)
|
self.assertIn('aria-label="语音输入"', html)
|
||||||
self.assertIn('aria-label="润色提示词"', html)
|
self.assertIn('aria-label="润色提示词"', html)
|
||||||
self.assertIn("hasVisibleAssistantContent", frontend)
|
self.assertIn("hasVisibleAssistantContent", frontend)
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,16 @@ class TestSystemPromptPaths(unittest.TestCase):
|
||||||
self.assertIn("`office_to_pdf`", prompt)
|
self.assertIn("`office_to_pdf`", prompt)
|
||||||
self.assertIn("LibreOffice 在 backend host", prompt)
|
self.assertIn("LibreOffice 在 backend host", prompt)
|
||||||
|
|
||||||
|
def test_existing_pdf_has_one_platform_ingest_route(self):
|
||||||
|
prompt, _, _ = _build("docker")
|
||||||
|
self.assertIn("## 读取已有 PDF", prompt)
|
||||||
|
self.assertIn("**唯一入口**", prompt)
|
||||||
|
self.assertIn("先用 `markitdown` CLI", prompt)
|
||||||
|
self.assertIn("转换结果为空 / 近空", prompt)
|
||||||
|
self.assertIn("本轮有 `read_document` 时调用它", prompt)
|
||||||
|
self.assertIn("依赖与解析器由平台镜像统一提供", prompt)
|
||||||
|
self.assertNotIn("pypdf", prompt.lower())
|
||||||
|
|
||||||
def test_web_search_schema_reinforces_time_rules(self):
|
def test_web_search_schema_reinforces_time_rules(self):
|
||||||
from tools.web_search import WebSearchTool
|
from tools.web_search import WebSearchTool
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from core.task_title import clean_generated_title
|
from core.task_title import clean_generated_title, is_attachment_only_message
|
||||||
|
|
||||||
|
|
||||||
class TaskTitleTests(unittest.TestCase):
|
class TaskTitleTests(unittest.TestCase):
|
||||||
|
|
@ -23,6 +23,15 @@ class TaskTitleTests(unittest.TestCase):
|
||||||
title = clean_generated_title("这是一段非常非常长而且不适合作为对话标题的模型输出内容", "")
|
title = clean_generated_title("这是一段非常非常长而且不适合作为对话标题的模型输出内容", "")
|
||||||
self.assertLessEqual(len(title), 24)
|
self.assertLessEqual(len(title), 24)
|
||||||
|
|
||||||
|
def test_attachment_only_message_is_deferred_for_auto_title(self):
|
||||||
|
self.assertTrue(is_attachment_only_message(
|
||||||
|
"[用户上传的参考图] 项目/显微照片.png\n[用户上传的文件] 项目/数据表.xlsx"
|
||||||
|
))
|
||||||
|
self.assertFalse(is_attachment_only_message(
|
||||||
|
"请分析这些数据\n\n[用户上传的文件] 项目/数据表.xlsx"
|
||||||
|
))
|
||||||
|
self.assertFalse(is_attachment_only_message(""))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,8 @@ class RunPythonTool(Tool):
|
||||||
"matplotlib charts, or any task where Python is more natural than chaining tools.\n"
|
"matplotlib charts, or any task where Python is more natural than chaining tools.\n"
|
||||||
"Working directory is the agent's base dir (task_dir); relative paths resolve against it. "
|
"Working directory is the agent's base dir (task_dir); relative paths resolve against it. "
|
||||||
"Keep process scripts in scripts/; write deliverables to task_dir root or the SKILL-specified path.\n"
|
"Keep process scripts in scripts/; write deliverables to task_dir root or the SKILL-specified path.\n"
|
||||||
"Available libs (install with shell pip if missing): "
|
"Preinstalled libs include: "
|
||||||
"pandas, numpy, matplotlib, python-pptx, python-docx, requests, pypdf."
|
"pandas, numpy, matplotlib, python-pptx, python-docx, requests."
|
||||||
)
|
)
|
||||||
parameters = {
|
parameters = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|
|
||||||
|
|
@ -272,8 +272,13 @@ def register_message_routes(app, *, require_user) -> None:
|
||||||
)
|
)
|
||||||
title_profile = claim.metadata["title_profile"]
|
title_profile = claim.metadata["title_profile"]
|
||||||
should_auto_title = claim.metadata["should_auto_title"]
|
should_auto_title = claim.metadata["should_auto_title"]
|
||||||
|
if should_auto_title:
|
||||||
|
from core.task_title import is_attachment_only_message
|
||||||
|
should_auto_title = not is_attachment_only_message(content)
|
||||||
# 快速入口只在首条消息时 pending=true。辅助标题与主 run 并行,不阻塞
|
# 快速入口只在首条消息时 pending=true。辅助标题与主 run 并行,不阻塞
|
||||||
# TTFT/SSE;generate_task_title 内再次查闸并用条件 UPDATE 防人工改名竞态。
|
# TTFT/SSE;纯附件消息保留 pending,等下一条自然语言再命名,避免标题暴露
|
||||||
|
# `[用户上传的文件]` 等内部标记。generate_task_title 内再次查闸并用条件 UPDATE
|
||||||
|
# 防人工改名竞态。
|
||||||
if should_auto_title:
|
if should_auto_title:
|
||||||
from core.task_title import generate_task_title_safe
|
from core.task_title import generate_task_title_safe
|
||||||
title_task = asyncio.create_task(asyncio.to_thread(
|
title_task = asyncio.create_task(asyncio.to_thread(
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
<script src="vendor/markdown/marked.umd.js"></script>
|
<script src="vendor/markdown/marked.umd.js"></script>
|
||||||
<script src="vendor/markdown/purify.min.js"></script>
|
<script src="vendor/markdown/purify.min.js"></script>
|
||||||
<script src="vendor/markdown/highlight.min.js"></script>
|
<script src="vendor/markdown/highlight.min.js"></script>
|
||||||
|
<script src="vendor/mermaid/mermaid.min.js"></script>
|
||||||
<link rel="stylesheet" href="vendor/markdown/github.min.css" />
|
<link rel="stylesheet" href="vendor/markdown/github.min.css" />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { state, LS_TASK_FILTERS_COLLAPSED } from "./state.js";
|
||||||
import { $, showMenu, showMenuAtPoint } from "./dom.js";
|
import { $, showMenu, showMenuAtPoint } from "./dom.js";
|
||||||
import { api } from "./api.js";
|
import { api } from "./api.js";
|
||||||
import { escapeHtml, fmtTime, fmtTokens, fmtTimeAgo, taskUsageTooltip, formatTaskUsage, formatContextStats, formatUsageStats, ctxRingHtml } from "./format.js";
|
import { escapeHtml, fmtTime, fmtTokens, fmtTimeAgo, taskUsageTooltip, formatTaskUsage, formatContextStats, formatUsageStats, ctxRingHtml } from "./format.js";
|
||||||
import { renderMd, highlightIn } from "./markdown.js";
|
import { renderMd, highlightIn, renderMermaidIn } from "./markdown.js";
|
||||||
import { mqPhone, setMobileView } from "./layout.js";
|
import { mqPhone, setMobileView } from "./layout.js";
|
||||||
import { dialogConfirm, dialogPrompt, message } from "./dialog.js";
|
import { dialogConfirm, dialogPrompt, message } from "./dialog.js";
|
||||||
|
|
||||||
|
|
@ -415,6 +415,7 @@ let _draftFolderActiveIndex = -1;
|
||||||
function syncDraftAction() {
|
function syncDraftAction() {
|
||||||
const ready = !!draftWorkingDir();
|
const ready = !!draftWorkingDir();
|
||||||
setActionMode(ready ? "idle" : "loading");
|
setActionMode(ready ? "idle" : "loading");
|
||||||
|
$("chat-attach-btn").disabled = !ready;
|
||||||
$("chat-hint").textContent = "";
|
$("chat-hint").textContent = "";
|
||||||
const note = $("new-chat-dir-note");
|
const note = $("new-chat-dir-note");
|
||||||
if (note) {
|
if (note) {
|
||||||
|
|
@ -633,7 +634,7 @@ export async function showNewConversationDraft() {
|
||||||
$("chat-input").readOnly = false;
|
$("chat-input").readOnly = false;
|
||||||
$("chat-input").classList.remove("readonly-locked");
|
$("chat-input").classList.remove("readonly-locked");
|
||||||
setComposerPlaceholder(null);
|
setComposerPlaceholder(null);
|
||||||
$("chat-attach-btn").disabled = true; // task_dir 尚不存在,首发建 task 后开放
|
$("chat-attach-btn").disabled = true; // 选定目录后开放;选中文件时再懒创建 task
|
||||||
$("chat-mic").disabled = false;
|
$("chat-mic").disabled = false;
|
||||||
$("btn-done").disabled = true;
|
$("btn-done").disabled = true;
|
||||||
$("btn-task-menu").disabled = true;
|
$("btn-task-menu").disabled = true;
|
||||||
|
|
@ -1221,6 +1222,7 @@ function closeTextSeg(run) {
|
||||||
} else {
|
} else {
|
||||||
seg.el.classList.remove("streaming");
|
seg.el.classList.remove("streaming");
|
||||||
highlightIn(seg.el);
|
highlightIn(seg.el);
|
||||||
|
void renderMermaidIn(seg.el);
|
||||||
}
|
}
|
||||||
run.curSeg = null;
|
run.curSeg = null;
|
||||||
}
|
}
|
||||||
|
|
@ -1636,8 +1638,9 @@ function renderMessages(msgs, { stickBottom = true } = {}) {
|
||||||
// Once that tool call is filtered, do not leave an empty assistant shell.
|
// Once that tool call is filtered, do not leave an empty assistant shell.
|
||||||
if (role === "assistant" && !hasVisibleAssistantContent) continue;
|
if (role === "assistant" && !hasVisibleAssistantContent) continue;
|
||||||
card.innerHTML = html;
|
card.innerHTML = html;
|
||||||
highlightIn(card);
|
|
||||||
wrap.appendChild(card);
|
wrap.appendChild(card);
|
||||||
|
highlightIn(card);
|
||||||
|
if (role === "assistant") void renderMermaidIn(card);
|
||||||
}
|
}
|
||||||
// 底部 sentinel:从目录跳到旧消息后,下方还有更新的未加载 → 进视口自动向下补
|
// 底部 sentinel:从目录跳到旧消息后,下方还有更新的未加载 → 进视口自动向下补
|
||||||
if (state.msgHasMoreNewer) {
|
if (state.msgHasMoreNewer) {
|
||||||
|
|
@ -1794,26 +1797,84 @@ $("chat-attach-btn").addEventListener("click", (e) => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 上传一批文件并把结果 chip 追加进托盘。状态写 #chat-hint(进度 / 已上传),不碰 chip。
|
const IMAGE_ATTACHMENT_EXTENSIONS = new Set([
|
||||||
|
".avif", ".bmp", ".gif", ".heic", ".heif", ".jpeg", ".jpg",
|
||||||
|
".png", ".svg", ".tif", ".tiff", ".webp",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function attachmentKind(file, saved) {
|
||||||
|
if (file && String(file.type || "").toLowerCase().startsWith("image/")) return "image";
|
||||||
|
const name = String((saved && saved.name) || (file && file.name) || "").toLowerCase();
|
||||||
|
const dot = name.lastIndexOf(".");
|
||||||
|
return dot >= 0 && IMAGE_ATTACHMENT_EXTENSIONS.has(name.slice(dot)) ? "image" : "file";
|
||||||
|
}
|
||||||
|
|
||||||
|
let _draftTaskCreatePromise = null;
|
||||||
|
let _attachmentUploadsInFlight = 0;
|
||||||
|
|
||||||
|
async function ensureTaskFromDraft() {
|
||||||
|
if (state.taskId) return state.taskMeta;
|
||||||
|
if (!_draftTaskCreatePromise) {
|
||||||
|
_draftTaskCreatePromise = createTaskFromDraft().finally(() => {
|
||||||
|
_draftTaskCreatePromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _draftTaskCreatePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreActionAfterAttachmentUpload() {
|
||||||
|
if (_attachmentUploadsInFlight > 0) return;
|
||||||
|
if (!state.taskId) {
|
||||||
|
const ready = !!draftWorkingDir();
|
||||||
|
setActionMode(ready ? "idle" : "loading");
|
||||||
|
$("chat-attach-btn").disabled = !ready;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const run = getLiveRun(state.taskId);
|
||||||
|
if (run) setActionMode(run.cancelling ? "cancelling" : "streaming");
|
||||||
|
else if (hasRunningProc(state.taskId)) setActionMode("bgproc");
|
||||||
|
else setActionMode("idle");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传一批文件并把结果 chip 追加进托盘。草稿态以“首次有效操作”懒创建 task:
|
||||||
|
// 文件选择器取消不会留空记录,真正选中文件后才复用后端 task 创建校验并上传。
|
||||||
|
// 上传在途锁住发送,避免用户消息先于文件落盘而漏带附件。
|
||||||
async function uploadAttachFiles(files) {
|
async function uploadAttachFiles(files) {
|
||||||
if (!files || !files.length) return;
|
if (!files || !files.length) return;
|
||||||
const hint = $("chat-hint");
|
const hint = $("chat-hint");
|
||||||
if (!state.taskId) {
|
if (!state.taskId && !draftWorkingDir()) {
|
||||||
hint.textContent = "发送第一条消息创建对话后即可添加附件";
|
hint.textContent = "请先选择文件保存位置";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
hint.textContent = files.length === 1 ? `上传中:${files[0].name}…` : `上传中:${files.length} 个文件…`;
|
_attachmentUploadsInFlight++;
|
||||||
const saved = await uploadFiles(files, {
|
setActionMode("loading");
|
||||||
onProgress: (loaded, total) => {
|
try {
|
||||||
hint.textContent = formatUploadProgress(files, loaded, total);
|
if (!state.taskId) {
|
||||||
},
|
hint.textContent = "正在创建对话…";
|
||||||
});
|
await ensureTaskFromDraft();
|
||||||
if (saved && saved.length) {
|
}
|
||||||
addAttachChips(saved);
|
hint.textContent = files.length === 1 ? `上传中:${files[0].name}…` : `上传中:${files.length} 个文件…`;
|
||||||
const n = attachCount();
|
const saved = await uploadFiles(files, {
|
||||||
hint.innerHTML = `已添加 ${saved.length} 个文件,共 ${n} 个待发送 <span class="muted">可在右侧文件处查看</span>`;
|
onProgress: (loaded, total) => {
|
||||||
|
hint.textContent = formatUploadProgress(files, loaded, total);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (saved && saved.length) {
|
||||||
|
addAttachChips(saved.map((item, i) => ({
|
||||||
|
...item,
|
||||||
|
attachmentKind: attachmentKind(files[i], item),
|
||||||
|
})));
|
||||||
|
const n = attachCount();
|
||||||
|
hint.innerHTML = `已添加 ${saved.length} 个文件,共 ${n} 个待发送 <span class="muted">可在右侧文件处查看</span>`;
|
||||||
|
} else {
|
||||||
|
hint.textContent = "";
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
hint.textContent = `创建失败:${e.message}`;
|
||||||
|
} finally {
|
||||||
|
_attachmentUploadsInFlight = Math.max(0, _attachmentUploadsInFlight - 1);
|
||||||
|
restoreActionAfterAttachmentUpload();
|
||||||
}
|
}
|
||||||
// 失败时 uploadFiles 内部已 alert;hint 保留"上传中…"文字也无碍(下次发送会覆盖)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachTray() { return $("chat-attach"); }
|
function attachTray() { return $("chat-attach"); }
|
||||||
|
|
@ -1842,7 +1903,7 @@ function addAttachChips(saved) {
|
||||||
existing.add(rel);
|
existing.add(rel);
|
||||||
const name = f.name || (rel.split("/").pop() || rel);
|
const name = f.name || (rel.split("/").pop() || rel);
|
||||||
tray.insertAdjacentHTML("beforeend",
|
tray.insertAdjacentHTML("beforeend",
|
||||||
`<span class="paste-chip-wrap" data-rel="${escapeHtml(rel)}"><button type="button" class="art-chip paste-chip" data-rel="${escapeHtml(rel)}" title="${escapeHtml(rel)} · 点击预览">${escapeHtml(name)}</button><button type="button" class="paste-chip-del" data-rel="${escapeHtml(rel)}" title="删除该文件">×</button></span>`);
|
`<span class="paste-chip-wrap" data-rel="${escapeHtml(rel)}" data-kind="${f.attachmentKind === "image" ? "image" : "file"}"><button type="button" class="art-chip paste-chip" data-rel="${escapeHtml(rel)}" title="${escapeHtml(rel)} · 点击预览">${escapeHtml(name)}</button><button type="button" class="paste-chip-del" data-rel="${escapeHtml(rel)}" title="删除该文件">×</button></span>`);
|
||||||
}
|
}
|
||||||
tray.classList.toggle("show", attachCount() > 0);
|
tray.classList.toggle("show", attachCount() > 0);
|
||||||
}
|
}
|
||||||
|
|
@ -2392,16 +2453,18 @@ async function postMessageWithRetry(taskId, body) {
|
||||||
|
|
||||||
// overrideText:点 ask_user 选项时传入选项 label,直接作为用户消息发出(不读输入框、
|
// overrideText:点 ask_user 选项时传入选项 label,直接作为用户消息发出(不读输入框、
|
||||||
// 不清空输入框 —— 用户可能正在输入框打讨论草稿)。无参数则走输入框(正常发送)。
|
// 不清空输入框 —— 用户可能正在输入框打讨论草稿)。无参数则走输入框(正常发送)。
|
||||||
// 收集 chat-hint 里的粘贴附件路径(粘贴图片已上传到 task_dir,chip 带 data-rel)。
|
// 收集托盘里的待发送附件并清掉 chip。路径和类型要随消息正文发给模型,否则模型
|
||||||
// 返回路径数组并清掉 chip —— 这些路径要随消息正文发给模型,否则模型不知道用户贴了哪张图
|
// 不知道用户上传了什么、也无法选择看图/读文档等正确工具。只在「从输入框发送」时取,
|
||||||
// (改图 / 看图都靠它定位)。只在「从输入框发送」时取,ask_user 选项点击(overrideText)不带附件。
|
// ask_user 选项点击(overrideText)不带附件。
|
||||||
function takePastedRels() {
|
function takePendingAttachments() {
|
||||||
const wraps = attachWraps();
|
const wraps = attachWraps();
|
||||||
const rels = wraps.map((w) => w.dataset.rel).filter(Boolean);
|
const attachments = wraps
|
||||||
|
.map((w) => ({ rel: w.dataset.rel, kind: w.dataset.kind === "image" ? "image" : "file" }))
|
||||||
|
.filter((item) => !!item.rel);
|
||||||
wraps.forEach((w) => w.remove());
|
wraps.forEach((w) => w.remove());
|
||||||
const tray = attachTray();
|
const tray = attachTray();
|
||||||
if (tray) tray.classList.remove("show");
|
if (tray) tray.classList.remove("show");
|
||||||
return rels;
|
return attachments;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createTaskFromDraft() {
|
async function createTaskFromDraft() {
|
||||||
|
|
@ -2425,6 +2488,10 @@ async function createTaskFromDraft() {
|
||||||
|
|
||||||
async function sendMessage(overrideText) {
|
async function sendMessage(overrideText) {
|
||||||
if (isCurrentTaskStreaming()) return;
|
if (isCurrentTaskStreaming()) return;
|
||||||
|
if (_attachmentUploadsInFlight > 0) {
|
||||||
|
$("chat-hint").textContent = "附件仍在上传,请稍候…";
|
||||||
|
return;
|
||||||
|
}
|
||||||
const fromInput = typeof overrideText !== "string";
|
const fromInput = typeof overrideText !== "string";
|
||||||
let content = (fromInput ? $("chat-input").value : overrideText).trim();
|
let content = (fromInput ? $("chat-input").value : overrideText).trim();
|
||||||
if (!content && !state.taskId) return; // 草稿态尚不能上传附件,空文本无需建 task
|
if (!content && !state.taskId) return; // 草稿态尚不能上传附件,空文本无需建 task
|
||||||
|
|
@ -2433,7 +2500,7 @@ async function sendMessage(overrideText) {
|
||||||
setActionMode("loading");
|
setActionMode("loading");
|
||||||
$("chat-hint").textContent = "正在创建对话…";
|
$("chat-hint").textContent = "正在创建对话…";
|
||||||
try {
|
try {
|
||||||
await createTaskFromDraft();
|
await ensureTaskFromDraft();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
syncDraftAction();
|
syncDraftAction();
|
||||||
$("chat-hint").textContent = `创建失败:${e.message}`;
|
$("chat-hint").textContent = `创建失败:${e.message}`;
|
||||||
|
|
@ -2446,11 +2513,13 @@ async function sendMessage(overrideText) {
|
||||||
$("chat-hint").textContent = `${_chCfg.label}对话请在${_chCfg.label}里进行 — web 端为只读镜像`;
|
$("chat-hint").textContent = `${_chCfg.label}对话请在${_chCfg.label}里进行 — web 端为只读镜像`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 粘贴附件路径注入正文:用户贴图后发的消息往往是「按这张改 / 看看这张图」,
|
// 附件路径按类型注入正文:图片走视觉/改图工具,其他文件走文档读取、音频转写等工具。
|
||||||
// 模型只有拿到路径才能传给 seedream(reference_images)/ 未来的 look_at_image。
|
const pendingAttachments = fromInput ? takePendingAttachments() : [];
|
||||||
const pastedRels = fromInput ? takePastedRels() : [];
|
const attachmentOnly = !content && pendingAttachments.length > 0;
|
||||||
if (pastedRels.length) {
|
if (pendingAttachments.length) {
|
||||||
const lines = pastedRels.map((r) => `[用户上传的参考图] ${r}`).join("\n");
|
const lines = pendingAttachments.map(({ rel, kind }) =>
|
||||||
|
`${kind === "image" ? "[用户上传的参考图]" : "[用户上传的文件]"} ${rel}`
|
||||||
|
).join("\n");
|
||||||
content = content ? `${content}\n\n${lines}` : lines;
|
content = content ? `${content}\n\n${lines}` : lines;
|
||||||
}
|
}
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
|
|
@ -2489,6 +2558,7 @@ async function sendMessage(overrideText) {
|
||||||
curSeg: null,
|
curSeg: null,
|
||||||
cancelling: false,
|
cancelling: false,
|
||||||
workingDir: state.taskMeta && state.taskMeta.working_dir,
|
workingDir: state.taskMeta && state.taskMeta.working_dir,
|
||||||
|
autoTitleEligible: !attachmentOnly,
|
||||||
progressSteps: cloneProgressSteps(state.taskProgressByTask.get(taskId)),
|
progressSteps: cloneProgressSteps(state.taskProgressByTask.get(taskId)),
|
||||||
};
|
};
|
||||||
// 预建的空占位 .body 即首个文字段(首字到达前显示「思考中」)
|
// 预建的空占位 .body 即首个文字段(首字到达前显示「思考中」)
|
||||||
|
|
@ -2594,7 +2664,10 @@ async function fetchSse(url, run) {
|
||||||
if (ctx.curSeg.acc) ctx.curSeg.el.innerHTML = renderMd(ctx.curSeg.acc);
|
if (ctx.curSeg.acc) ctx.curSeg.el.innerHTML = renderMd(ctx.curSeg.acc);
|
||||||
else ctx.curSeg.el.remove(); // 收尾时仍是空占位段 → 移除
|
else ctx.curSeg.el.remove(); // 收尾时仍是空占位段 → 移除
|
||||||
}
|
}
|
||||||
if (ctx.card) highlightIn(ctx.card);
|
if (ctx.card) {
|
||||||
|
highlightIn(ctx.card);
|
||||||
|
void renderMermaidIn(ctx.card);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
finishRunningTool(ctx); // 工具执行中被杀/断流 → 去 spinner
|
finishRunningTool(ctx); // 工具执行中被杀/断流 → 去 spinner
|
||||||
if (ctx.card) {
|
if (ctx.card) {
|
||||||
|
|
@ -2620,7 +2693,7 @@ async function fetchSse(url, run) {
|
||||||
if (state.taskId === ctx.taskId) {
|
if (state.taskId === ctx.taskId) {
|
||||||
state.taskMeta = meta;
|
state.taskMeta = meta;
|
||||||
renderChatMeta();
|
renderChatMeta();
|
||||||
if (meta.auto_title_pending) pollAutoTitle(ctx.taskId);
|
if (meta.auto_title_pending && ctx.autoTitleEligible !== false) pollAutoTitle(ctx.taskId);
|
||||||
}
|
}
|
||||||
} catch (e) { /* meta 刷新失败不致命:保留旧值,不打断收尾 */ }
|
} catch (e) { /* meta 刷新失败不致命:保留旧值,不打断收尾 */ }
|
||||||
await loadMessages();
|
await loadMessages();
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,82 @@ if (window.marked && window.marked.setOptions) {
|
||||||
window.marked.setOptions({ gfm: true, breaks: true, headerIds: false, mangle: false });
|
window.marked.setOptions({ gfm: true, breaks: true, headerIds: false, mangle: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FENCE_RE = /^( {0,3})(`{3,}|~{3,})([^\r\n]*)(\r?\n)?$/;
|
||||||
|
const MERMAID_CONFIG = Object.freeze({
|
||||||
|
startOnLoad: false,
|
||||||
|
securityLevel: "strict",
|
||||||
|
suppressErrorRendering: true,
|
||||||
|
theme: "neutral",
|
||||||
|
maxTextSize: 50000,
|
||||||
|
maxEdges: 500,
|
||||||
|
fontFamily: '-apple-system, "Segoe UI", "Microsoft YaHei", sans-serif',
|
||||||
|
});
|
||||||
|
let mermaidInitialized = false;
|
||||||
|
let mermaidSeq = 0;
|
||||||
|
|
||||||
|
function parseFence(line) {
|
||||||
|
const m = String(line || "").match(FENCE_RE);
|
||||||
|
if (!m) return null;
|
||||||
|
return { indent: m[1], char: m[2][0], len: m[2].length, info: m[3].trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextNonblank(lines, start) {
|
||||||
|
for (let i = start; i < lines.length; i++) {
|
||||||
|
if (lines[i].trim()) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceFence(line, length) {
|
||||||
|
const m = String(line || "").match(FENCE_RE);
|
||||||
|
if (!m) return line;
|
||||||
|
return m[1] + m[2][0].repeat(length) + m[3] + (m[4] || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只修复明确的「markdown 外层与内层语言块使用同长围栏」形态。
|
||||||
|
// 其他残缺 Markdown 原样交给 marked,避免猜测作者意图。
|
||||||
|
export function normalizeMarkdownFences(text) {
|
||||||
|
const lines = String(text || "").match(/.*(?:\r\n|\n|$)/g).filter(Boolean);
|
||||||
|
for (let i = 0; i < lines.length;) {
|
||||||
|
const outer = parseFence(lines[i]);
|
||||||
|
if (!outer || !["markdown", "md"].includes(outer.info.toLowerCase())) {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const innerIdx = nextNonblank(lines, i + 1);
|
||||||
|
const inner = innerIdx >= 0 ? parseFence(lines[innerIdx]) : null;
|
||||||
|
if (!inner || !inner.info || inner.char !== outer.char || inner.len < outer.len) {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let innerCloseIdx = -1;
|
||||||
|
for (let j = innerIdx + 1; j < lines.length; j++) {
|
||||||
|
const close = parseFence(lines[j]);
|
||||||
|
if (close && !close.info && close.char === inner.char && close.len >= inner.len) {
|
||||||
|
innerCloseIdx = j;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (innerCloseIdx < 0) {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const outerCloseIdx = nextNonblank(lines, innerCloseIdx + 1);
|
||||||
|
const outerClose = outerCloseIdx >= 0 ? parseFence(lines[outerCloseIdx]) : null;
|
||||||
|
if (!outerClose || outerClose.info || outerClose.char !== outer.char || outerClose.len < outer.len) {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const repairedLen = Math.max(outer.len, inner.len) + 1;
|
||||||
|
lines[i] = replaceFence(lines[i], repairedLen);
|
||||||
|
lines[outerCloseIdx] = replaceFence(lines[outerCloseIdx], repairedLen);
|
||||||
|
i = outerCloseIdx + 1;
|
||||||
|
}
|
||||||
|
return lines.join("");
|
||||||
|
}
|
||||||
|
|
||||||
export function renderMd(text) {
|
export function renderMd(text) {
|
||||||
const raw = String(text || "");
|
const raw = normalizeMarkdownFences(text);
|
||||||
if (!window.marked || !window.marked.parse) {
|
if (!window.marked || !window.marked.parse) {
|
||||||
return `<pre style="white-space:pre-wrap;word-break:break-word;font-family:inherit;margin:0;">${escapeHtml(raw)}</pre>`;
|
return `<pre style="white-space:pre-wrap;word-break:break-word;font-family:inherit;margin:0;">${escapeHtml(raw)}</pre>`;
|
||||||
}
|
}
|
||||||
|
|
@ -18,10 +92,82 @@ export function renderMd(text) {
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getMermaidApi() {
|
||||||
|
const api = window.mermaid;
|
||||||
|
if (!api || typeof api.initialize !== "function" || typeof api.render !== "function") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!mermaidInitialized) {
|
||||||
|
api.initialize(MERMAID_CONFIG);
|
||||||
|
mermaidInitialized = true;
|
||||||
|
}
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mermaidNotice(pre, className, text) {
|
||||||
|
const doc = pre.ownerDocument || document;
|
||||||
|
const notice = doc.createElement("div");
|
||||||
|
notice.className = className;
|
||||||
|
notice.textContent = text;
|
||||||
|
pre.before(notice);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render completed assistant Mermaid blocks, preserving source on failure. */
|
||||||
|
export async function renderMermaidIn(container) {
|
||||||
|
const result = { rendered: 0, failed: 0, unavailable: 0 };
|
||||||
|
if (!container || typeof container.querySelectorAll !== "function") return result;
|
||||||
|
|
||||||
|
const blocks = Array.from(container.querySelectorAll("pre > code.language-mermaid"))
|
||||||
|
.filter((code) => !code.dataset.mermaidState);
|
||||||
|
if (!blocks.length) return result;
|
||||||
|
|
||||||
|
const api = getMermaidApi();
|
||||||
|
if (!api) {
|
||||||
|
for (const code of blocks) {
|
||||||
|
code.dataset.mermaidState = "unavailable";
|
||||||
|
mermaidNotice(code.parentElement, "mermaid-notice", "图表组件未加载,已保留 Mermaid 源码。");
|
||||||
|
result.unavailable++;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mermaid owns shared configuration/state; render sequentially to avoid races.
|
||||||
|
for (const code of blocks) {
|
||||||
|
const pre = code.parentElement;
|
||||||
|
code.dataset.mermaidState = "rendering";
|
||||||
|
try {
|
||||||
|
const id = `zcbot-mermaid-${Date.now()}-${++mermaidSeq}`;
|
||||||
|
const rendered = await api.render(id, code.textContent || "");
|
||||||
|
if (!pre || !pre.isConnected) continue;
|
||||||
|
|
||||||
|
const doc = pre.ownerDocument || document;
|
||||||
|
const figure = doc.createElement("figure");
|
||||||
|
figure.className = "mermaid-diagram";
|
||||||
|
figure.setAttribute("role", "img");
|
||||||
|
figure.setAttribute("aria-label", "Mermaid 图表");
|
||||||
|
const viewport = doc.createElement("div");
|
||||||
|
viewport.className = "mermaid-viewport";
|
||||||
|
viewport.innerHTML = rendered.svg;
|
||||||
|
figure.appendChild(viewport);
|
||||||
|
pre.replaceWith(figure);
|
||||||
|
if (typeof rendered.bindFunctions === "function") rendered.bindFunctions(viewport);
|
||||||
|
result.rendered++;
|
||||||
|
} catch (_error) {
|
||||||
|
code.dataset.mermaidState = "error";
|
||||||
|
if (pre && pre.isConnected) {
|
||||||
|
mermaidNotice(pre, "mermaid-notice error", "Mermaid 图表语法有误,已保留源码。");
|
||||||
|
}
|
||||||
|
result.failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export function highlightIn(container) {
|
export function highlightIn(container) {
|
||||||
if (!window.hljs || !container) return;
|
if (!window.hljs || !container) return;
|
||||||
container.querySelectorAll("pre code").forEach((b) => {
|
container.querySelectorAll("pre code").forEach((b) => {
|
||||||
if (b.dataset.hl === "1") return;
|
if (b.dataset.hl === "1") return;
|
||||||
|
if (b.classList && b.classList.contains("language-mermaid")) return;
|
||||||
try { window.hljs.highlightElement(b); b.dataset.hl = "1"; } catch (e) {}
|
try { window.hljs.highlightElement(b); b.dataset.hl = "1"; } catch (e) {}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -434,6 +434,40 @@ header .who {
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mermaid-diagram {
|
||||||
|
margin: 14px 0;
|
||||||
|
padding: 14px;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mermaid-viewport {
|
||||||
|
min-width: min-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mermaid-viewport svg {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mermaid-notice {
|
||||||
|
margin: 10px 0 4px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
color: var(--muted);
|
||||||
|
border-left: 3px solid var(--border);
|
||||||
|
background: var(--panel-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mermaid-notice.error {
|
||||||
|
color: var(--c-red);
|
||||||
|
border-left-color: var(--c-red);
|
||||||
|
}
|
||||||
|
|
||||||
.msg-actions {
|
.msg-actions {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2014 - 2022 Knut Sveidqvist
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
Mermaid 11.16.0
|
||||||
|
Source: https://registry.npmjs.org/mermaid/-/mermaid-11.16.0.tgz
|
||||||
|
Integrity: sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue