Compare commits

...

4 Commits

25 changed files with 4348 additions and 42 deletions

4
.gitattributes vendored
View File

@ -4,3 +4,7 @@
*.sh 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

View File

@ -5,6 +5,10 @@
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
> 工程口径的完整记录见 `PROGRESS.md` / git log。
## 0.60.24 — 2026-08-03
- 对话中的 Mermaid 流程图、柱状图和折线图现在可以直接显示;图表语法有误时会保留源码并给出提示。另修复嵌套代码块格式异常时后续正文整段变成代码的问题。
## 0.60.23 — 2026-08-03
- 修复已有 Word、PowerPoint、Excel 文件转 PDF 时可能找不到文件或错误宣称格式可用的问题;现在会根据服务器实际具备的转换组件提供能力,并在组件缺失时给出明确提示。

View File

@ -2,7 +2,7 @@
> 配合 `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
- **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.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 工具无需重建沙箱镜像。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。
__version__ = "0.60.23"
__version__ = "0.60.24"

View File

@ -59,6 +59,14 @@ _MEDIA_READDOC_SEG = """\
- `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` 即可不落盘
- **何时不调**: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 = """\
- `seedream` 豆包图像生成 / 改图产物自动落 `<task_dir>/figures/`每次 **¥0.22**(联网 `search=true` ¥0.05)
- **文生图**(不传 `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"
)
prompt += kb_block(workspace_dir, user_id, kb_dir_display)
prompt += "\n\n" + _PDF_INGEST_SEG
if media_block:
prompt += "\n\n" + media_block
wd_abs = working_dir.resolve()

133
core/markdown_guard.py Normal file
View File

@ -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),
)

View File

@ -19,6 +19,7 @@ from sqlalchemy import delete, func, select
from .storage import session_scope
from .storage.models import Message, Task, sanitize_jsonb_nul
from .file_store import atomic_write_text
from .markdown_guard import normalize_markdown_fences
def _to_dict(msg: Any) -> Any:
@ -72,6 +73,20 @@ class Session:
# 与 Message.payload 的 ORM 守门保持一致,使当前 run 的内存上下文和落库值
# 完全相同;外部工具提取文本偶尔会携带 PostgreSQL JSONB 不支持的 NUL。
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)
if msg_dict.get("role") == "system":
return None
@ -201,7 +216,12 @@ class Session:
.order_by(Message.idx)
).scalars().all()
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。
sess._db_idx = s.execute(
select(func.count())

View File

@ -33,6 +33,13 @@ _TITLE_PROMPT = """请给下面这条新对话消息生成一个简短、具体
"""
_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:

View File

@ -36,6 +36,7 @@
- 动手前先看: 用 read/grep/glob 摸清现状,再 edit
- 改动最小化: edit 工具的 old_str 必须唯一匹配,不够唯一就多带上下文
- 有测试就跑测试验证;没有就用 run_python 写一段最小复现验证
- Markdown 围栏:展示包含 fenced code block 的 Markdown 源码时,外层统一用 `~~~~` 围栏,内层保留反引号围栏,并确保外层围栏长于任何同字符内层围栏
- 输出简洁: 不复述 diff,只说做了什么、下一步要不要继续
- 工具结果带 `[Error ...]` 时,先想清楚原因再重试,不要盲目重复同一调用
- 不臆造 API、文献、数据 —— 不知道就 read 源码 / 让用户提供 / 明说不知道

View File

@ -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");
});

View File

@ -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()

View File

@ -1,5 +1,6 @@
import unittest
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import patch
from uuid import uuid4
@ -62,6 +63,72 @@ class MessagePayloadSanitizationTests(unittest.TestCase):
self.assertEqual(session.messages, [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__":
unittest.main()

View File

@ -6,6 +6,11 @@ from tools.run_python import RunPythonTool
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:
with tempfile.TemporaryDirectory() as tmp:
script = Path(tmp) / "hello.py"

View File

@ -1,19 +1,19 @@
from pathlib import Path
import tempfile
import unittest
from pathlib import Path
from fastapi import FastAPI
from fastapi.testclient import TestClient
from web.static_files import NoCacheStaticFiles
ROOT = Path(__file__).resolve().parents[1]
STATIC_DIR = ROOT / "web" / "static"
DEV_HTML = STATIC_DIR / "dev.html"
UI_CSS = STATIC_DIR / "ui.css"
JS_DIR = STATIC_DIR / "js"
VENDOR_DIR = STATIC_DIR / "vendor" / "markdown"
MERMAID_VENDOR_DIR = STATIC_DIR / "vendor" / "mermaid"
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/purify.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)
for name in (
@ -43,6 +44,12 @@ class StaticVendorTests(unittest.TestCase):
self.assertTrue(path.exists(), f"missing 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:
html = DEV_HTML.read_text(encoding="utf-8")
@ -62,6 +69,9 @@ class StaticVendorTests(unittest.TestCase):
self.assertIn(".msg.assistant", css)
self.assertIn(".composer-tool .composer-label", css)
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("hasVisibleAssistantContent", frontend)

View File

@ -84,6 +84,16 @@ class TestSystemPromptPaths(unittest.TestCase):
self.assertIn("`office_to_pdf`", 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):
from tools.web_search import WebSearchTool

View File

@ -3,7 +3,7 @@ from __future__ import annotations
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):
@ -23,6 +23,15 @@ class TaskTitleTests(unittest.TestCase):
title = clean_generated_title("这是一段非常非常长而且不适合作为对话标题的模型输出内容", "")
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__":
unittest.main()

View File

@ -38,8 +38,8 @@ class RunPythonTool(Tool):
"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. "
"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): "
"pandas, numpy, matplotlib, python-pptx, python-docx, requests, pypdf."
"Preinstalled libs include: "
"pandas, numpy, matplotlib, python-pptx, python-docx, requests."
)
parameters = {
"type": "object",

View File

@ -272,8 +272,13 @@ def register_message_routes(app, *, require_user) -> None:
)
title_profile = claim.metadata["title_profile"]
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 并行,不阻塞
# TTFT/SSEgenerate_task_title 内再次查闸并用条件 UPDATE 防人工改名竞态。
# TTFT/SSE纯附件消息保留 pending等下一条自然语言再命名避免标题暴露
# `[用户上传的文件]` 等内部标记。generate_task_title 内再次查闸并用条件 UPDATE
# 防人工改名竞态。
if should_auto_title:
from core.task_title import generate_task_title_safe
title_task = asyncio.create_task(asyncio.to_thread(

View File

@ -10,6 +10,7 @@
<script src="vendor/markdown/marked.umd.js"></script>
<script src="vendor/markdown/purify.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" />
<style>

View File

@ -8,7 +8,7 @@ import { state, LS_TASK_FILTERS_COLLAPSED } from "./state.js";
import { $, showMenu, showMenuAtPoint } from "./dom.js";
import { api } from "./api.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 { dialogConfirm, dialogPrompt, message } from "./dialog.js";
@ -415,6 +415,7 @@ let _draftFolderActiveIndex = -1;
function syncDraftAction() {
const ready = !!draftWorkingDir();
setActionMode(ready ? "idle" : "loading");
$("chat-attach-btn").disabled = !ready;
$("chat-hint").textContent = "";
const note = $("new-chat-dir-note");
if (note) {
@ -633,7 +634,7 @@ export async function showNewConversationDraft() {
$("chat-input").readOnly = false;
$("chat-input").classList.remove("readonly-locked");
setComposerPlaceholder(null);
$("chat-attach-btn").disabled = true; // task_dir 尚不存在,首发建 task 后开放
$("chat-attach-btn").disabled = true; // 选定目录后开放;选中文件时再懒创建 task
$("chat-mic").disabled = false;
$("btn-done").disabled = true;
$("btn-task-menu").disabled = true;
@ -1221,6 +1222,7 @@ function closeTextSeg(run) {
} else {
seg.el.classList.remove("streaming");
highlightIn(seg.el);
void renderMermaidIn(seg.el);
}
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.
if (role === "assistant" && !hasVisibleAssistantContent) continue;
card.innerHTML = html;
highlightIn(card);
wrap.appendChild(card);
highlightIn(card);
if (role === "assistant") void renderMermaidIn(card);
}
// 底部 sentinel:从目录跳到旧消息后,下方还有更新的未加载 → 进视口自动向下补
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) {
if (!files || !files.length) return;
const hint = $("chat-hint");
if (!state.taskId) {
hint.textContent = "发送第一条消息创建对话后即可添加附件";
if (!state.taskId && !draftWorkingDir()) {
hint.textContent = "请先选择文件保存位置";
return;
}
hint.textContent = files.length === 1 ? `上传中:${files[0].name}` : `上传中:${files.length} 个文件…`;
const saved = await uploadFiles(files, {
onProgress: (loaded, total) => {
hint.textContent = formatUploadProgress(files, loaded, total);
},
});
if (saved && saved.length) {
addAttachChips(saved);
const n = attachCount();
hint.innerHTML = `已添加 ${saved.length} 个文件,共 ${n} 个待发送 <span class="muted">可在右侧文件处查看</span>`;
_attachmentUploadsInFlight++;
setActionMode("loading");
try {
if (!state.taskId) {
hint.textContent = "正在创建对话…";
await ensureTaskFromDraft();
}
hint.textContent = files.length === 1 ? `上传中:${files[0].name}` : `上传中:${files.length} 个文件…`;
const saved = await uploadFiles(files, {
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"); }
@ -1842,7 +1903,7 @@ function addAttachChips(saved) {
existing.add(rel);
const name = f.name || (rel.split("/").pop() || rel);
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);
}
@ -2392,16 +2453,18 @@ async function postMessageWithRetry(taskId, body) {
// overrideText:点 ask_user 选项时传入选项 label,直接作为用户消息发出(不读输入框、
// 不清空输入框 —— 用户可能正在输入框打讨论草稿)。无参数则走输入框(正常发送)。
// 收集 chat-hint 里的粘贴附件路径(粘贴图片已上传到 task_dir,chip 带 data-rel)。
// 返回路径数组并清掉 chip —— 这些路径要随消息正文发给模型,否则模型不知道用户贴了哪张图
// (改图 / 看图都靠它定位)。只在「从输入框发送」时取,ask_user 选项点击(overrideText)不带附件。
function takePastedRels() {
// 收集托盘里的待发送附件并清掉 chip。路径和类型要随消息正文发给模型否则模型
// 不知道用户上传了什么、也无法选择看图/读文档等正确工具。只在「从输入框发送」时取,
// ask_user 选项点击(overrideText)不带附件。
function takePendingAttachments() {
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());
const tray = attachTray();
if (tray) tray.classList.remove("show");
return rels;
return attachments;
}
async function createTaskFromDraft() {
@ -2425,6 +2488,10 @@ async function createTaskFromDraft() {
async function sendMessage(overrideText) {
if (isCurrentTaskStreaming()) return;
if (_attachmentUploadsInFlight > 0) {
$("chat-hint").textContent = "附件仍在上传,请稍候…";
return;
}
const fromInput = typeof overrideText !== "string";
let content = (fromInput ? $("chat-input").value : overrideText).trim();
if (!content && !state.taskId) return; // 草稿态尚不能上传附件,空文本无需建 task
@ -2433,7 +2500,7 @@ async function sendMessage(overrideText) {
setActionMode("loading");
$("chat-hint").textContent = "正在创建对话…";
try {
await createTaskFromDraft();
await ensureTaskFromDraft();
} catch (e) {
syncDraftAction();
$("chat-hint").textContent = `创建失败:${e.message}`;
@ -2446,11 +2513,13 @@ async function sendMessage(overrideText) {
$("chat-hint").textContent = `${_chCfg.label}对话请在${_chCfg.label}里进行 — web 端为只读镜像`;
return;
}
// 粘贴附件路径注入正文:用户贴图后发的消息往往是「按这张改 / 看看这张图」,
// 模型只有拿到路径才能传给 seedream(reference_images)/ 未来的 look_at_image。
const pastedRels = fromInput ? takePastedRels() : [];
if (pastedRels.length) {
const lines = pastedRels.map((r) => `[用户上传的参考图] ${r}`).join("\n");
// 附件路径按类型注入正文:图片走视觉/改图工具,其他文件走文档读取、音频转写等工具。
const pendingAttachments = fromInput ? takePendingAttachments() : [];
const attachmentOnly = !content && pendingAttachments.length > 0;
if (pendingAttachments.length) {
const lines = pendingAttachments.map(({ rel, kind }) =>
`${kind === "image" ? "[用户上传的参考图]" : "[用户上传的文件]"} ${rel}`
).join("\n");
content = content ? `${content}\n\n${lines}` : lines;
}
if (!content) return;
@ -2489,6 +2558,7 @@ async function sendMessage(overrideText) {
curSeg: null,
cancelling: false,
workingDir: state.taskMeta && state.taskMeta.working_dir,
autoTitleEligible: !attachmentOnly,
progressSteps: cloneProgressSteps(state.taskProgressByTask.get(taskId)),
};
// 预建的空占位 .body 即首个文字段(首字到达前显示「思考中」)
@ -2594,7 +2664,10 @@ async function fetchSse(url, run) {
if (ctx.curSeg.acc) ctx.curSeg.el.innerHTML = renderMd(ctx.curSeg.acc);
else ctx.curSeg.el.remove(); // 收尾时仍是空占位段 → 移除
}
if (ctx.card) highlightIn(ctx.card);
if (ctx.card) {
highlightIn(ctx.card);
void renderMermaidIn(ctx.card);
}
} finally {
finishRunningTool(ctx); // 工具执行中被杀/断流 → 去 spinner
if (ctx.card) {
@ -2620,7 +2693,7 @@ async function fetchSse(url, run) {
if (state.taskId === ctx.taskId) {
state.taskMeta = meta;
renderChatMeta();
if (meta.auto_title_pending) pollAutoTitle(ctx.taskId);
if (meta.auto_title_pending && ctx.autoTitleEligible !== false) pollAutoTitle(ctx.taskId);
}
} catch (e) { /* meta 刷新失败不致命:保留旧值,不打断收尾 */ }
await loadMessages();

View File

@ -6,8 +6,82 @@ if (window.marked && window.marked.setOptions) {
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) {
const raw = String(text || "");
const raw = normalizeMarkdownFences(text);
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>`;
}
@ -18,10 +92,82 @@ export function renderMd(text) {
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) {
if (!window.hljs || !container) return;
container.querySelectorAll("pre code").forEach((b) => {
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) {}
});
}

View File

@ -434,6 +434,40 @@ header .who {
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 {
margin-top: 8px;
}

21
web/static/vendor/mermaid/LICENSE.txt vendored Normal file
View File

@ -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.

3
web/static/vendor/mermaid/VERSION.txt vendored Normal file
View File

@ -0,0 +1,3 @@
Mermaid 11.16.0
Source: https://registry.npmjs.org/mermaid/-/mermaid-11.16.0.tgz
Integrity: sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==

3587
web/static/vendor/mermaid/mermaid.min.js vendored Normal file

File diff suppressed because one or more lines are too long