Compare commits

..

2 Commits

9 changed files with 123 additions and 28 deletions

View File

@ -5,6 +5,10 @@
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
> 工程口径的完整记录见 `PROGRESS.md` / git log。
## 0.60.28 — 2026-08-04
- 对话中的 HTML 产物预览现在会充分利用可用宽度,图片和视频预览也适度放大;文件附件改为更清晰的偏方形卡片,可直接辨认文件类型、名称和预览入口。
## 0.60.27 — 2026-08-03
- 加强服务端命令执行、第三方依赖和文件处理链路的安全防护,并补齐持续类型检查,降低已知依赖漏洞与边界类型错误导致服务异常的风险。

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-08-03(工程类型门禁 + 安全审计清零,bump 0.60.27)
最后更新:2026-08-04(对话产物预览与文件 chip 优化,bump 0.60.28)
---
@ -21,6 +21,10 @@
## 已完成关键能力
### 2026-08-04
- **08-04 / 0.60.28 / 对话产物宽版预览 + 文件 chip 优化**:包含 HTML 产物的助手消息改用接近占满对话区的宽版布局HTML 画布取消 760px 上限;内联视频上限提升至 720px图片上限提升至 560px 并保持原比例。文件 chip 从小胶囊调整为偏方形附件卡直接展示扩展名类型块、文件名和预览入口粘贴附件同步统一样式并保留键盘焦点反馈。Node 前端 15 项、JavaScript 语法及 diff 检查通过;无 schema、migration、HTTP API 或依赖变化。
### 2026-08-03
- **08-03 / 0.60.27 / 工程类型门禁 + 安全审计清零**:修复 141 个源文件中的 203 项 mypy 错误,新增按第三方无类型依赖与动态工具签名精确收口的 mypy 配置外部协议规定的弱哈希显式标注非本地密码用途host shell 前后台执行均改用明确解释器 argv移除隐式 `shell=True`。提高 aiohttp、Pillow、cryptography、Starlette、python-multipart、pydantic-settings 与 pip 的安全版本下限,`pip check` 无冲突、`pip-audit` 无已知漏洞;新增显式 shell 回归测试,完整审计 448 项 unittest 全绿(17 skip)Ruff/mypy/Bandit/pip-audit 通过engineering 80/100 PASS、security 100/100 PASS。无 schema/migration/API 变化,未连接生产 DB。

View File

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

View File

@ -31,6 +31,7 @@ class AuditCheck:
weight: float
module: str
arguments: tuple[str, ...]
direct_network: bool = False
ENGINEERING_CHECKS = (
@ -69,12 +70,21 @@ SECURITY_CHECKS = (
"pip_audit",
(
"-m", "pip_audit", "--local",
"--cache-dir", "evaluation/.cache/pip-audit",
"--progress-spinner", "off",
),
direct_network=True,
),
)
_PROXY_ENV_KEYS = (
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
)
def _case_result(
*,
@ -119,23 +129,32 @@ def _tail(text: str, limit: int = 1200) -> str:
return compact[-limit:] if compact else ""
def _subprocess_env(*, direct_network: bool) -> dict[str, str]:
env = {
**os.environ,
"PYTHONUTF8": "1",
"PYTHONIOENCODING": "utf-8",
}
if direct_network:
for key in _PROXY_ENV_KEYS:
env.pop(key, None)
return env
def _run(
arguments: tuple[str, ...],
*,
repo_root: Path,
timeout_s: float,
evidence_path: Path | None = None,
direct_network: bool = False,
) -> tuple[bool, str, float]:
started = time.monotonic()
try:
completed = subprocess.run(
[sys.executable, *arguments],
cwd=repo_root,
env={
**os.environ,
"PYTHONUTF8": "1",
"PYTHONIOENCODING": "utf-8",
},
env=_subprocess_env(direct_network=direct_network),
capture_output=True,
text=True,
encoding="utf-8",
@ -305,6 +324,7 @@ def audit_security_repository(
evidence_path=(
evidence_dir / f"{check.id}.txt" if write_evidence else None
),
direct_network=check.direct_network,
)
total_duration += duration
assertions.append(

View File

@ -56,6 +56,8 @@ test("assistant HTML artifacts render inline with lazy loading and an expand act
assert.match(mediaJs, /new IntersectionObserver/);
assert.match(mediaJs, /configureHtmlPreviewFrame\(frame, source/);
assert.match(pageHtml, /\.art-html-frame/);
assert.match(pageHtml, /\.msg\.assistant:has\(\.art-html\)/);
assert.match(pageHtml, /\.art-html\s*\{[^}]*width:\s*100%[^}]*max-width:\s*none/s);
assert.match(chatJs, /renderArtifactBarHtml\(extractArtifactRels\(p\.content, wd\), "html", state\.taskId/);
assert.match(chatJs, /Array\.isArray\(m\.artifact_refs\)/);
assert.match(chatJs, /renderArtifactBarHtml\(m\.artifact_refs, true, state\.taskId/);
@ -67,3 +69,12 @@ test("assistant HTML artifacts render inline with lazy loading and an expand act
const sendMessage = chatJs.indexOf("async function sendMessage");
assert.ok(clickHandler >= 0 && expandHandler > clickHandler && expandHandler < sendMessage);
});
test("artifact chips expose a compact file type and preview affordance", () => {
const mediaJs = readFileSync(new URL("../web/static/js/media.js", import.meta.url), "utf8");
assert.match(mediaJs, /class="art-chip-icon"/);
assert.match(mediaJs, /class="art-chip-name"/);
assert.match(mediaJs, /class="art-chip-open">预览/);
assert.match(pageHtml, /\.art-chip-icon/);
assert.match(pageHtml, /\.art-chip:focus-visible/);
});

View File

@ -6,9 +6,14 @@ import unittest
import zipfile
from io import BytesIO
from pathlib import Path
from unittest.mock import patch
from evaluation.audit import (
_subprocess_env,
audit_repository,
audit_security_repository,
)
from evaluation.client import EvalClientError, require_safe_base_url
from evaluation.audit import audit_repository, audit_security_repository
from evaluation.combine import combine_reports
from evaluation.inspect_bridge import export_inspect_jsonl
from evaluation.models import AssertionSpec, EvalConfigError, RunObservation
@ -135,6 +140,33 @@ class ClientSafetyTests(unittest.TestCase):
class ReportTests(unittest.TestCase):
def test_direct_audit_environment_ignores_proxy(self):
for key in (
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
):
with self.subTest(key=key), patch.dict(
"evaluation.audit.os.environ",
{key: "http://proxy.example"},
clear=True,
):
direct_env = _subprocess_env(direct_network=True)
inherited_env = _subprocess_env(direct_network=False)
direct_keys = {item.casefold() for item in direct_env}
inherited_by_key = {
item.casefold(): value for item, value in inherited_env.items()
}
self.assertNotIn(key.casefold(), direct_keys)
self.assertEqual(
inherited_by_key[key.casefold()], "http://proxy.example"
)
self.assertEqual(direct_env["PYTHONUTF8"], "1")
def test_combine_existing_dimension_reports(self):
engineering = {
"suite": {"name": "engineering", "version": "1"},

View File

@ -1031,29 +1031,39 @@
.tool-banner .kv.cost { color: #b34a4a; border-color: #e0c4c4; }
.tool-banner .kv.model { color: var(--accent); border-color: #e0c4c4; }
/* ───── artifact chips(对话内点产物预览/下载) ───── */
.artifact-bar { margin-top: 4px; display: flex; flex-wrap: wrap; gap: 4px; font-family: var(--mono); }
.artifact-bar { margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px; }
.art-chip {
font: inherit; font-size: 11px; line-height: 1.4;
padding: 2px 8px 2px 6px; border: 1px solid var(--border);
background: #fff; color: #555; border-radius: 999px; cursor: pointer;
max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
display: inline-flex; align-items: center; gap: 4px;
transition: var(--t);
min-width: 0; max-width: 340px; height: 38px; padding: 3px 9px 3px 4px;
border: 1px solid var(--border); border-radius: 5px; cursor: pointer;
background: linear-gradient(180deg, #fff, #fafafa); color: var(--text);
display: inline-flex; align-items: center; gap: 8px;
font: inherit; font-size: 12px; line-height: 1.3; text-align: left;
box-shadow: 0 1px 2px rgba(0,0,0,.04); transition: var(--t);
}
.art-chip::before { content: "📄"; font-size: 11px; }
.art-chip:hover { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
#chat-hint .art-chip { margin: 0 2px; vertical-align: middle; font-family: var(--mono); }
.art-chip-icon {
flex: 0 0 32px; height: 30px; display: inline-flex; align-items: center; justify-content: center;
border-radius: 3px; background: var(--accent-soft); color: var(--accent);
font-family: var(--mono); font-size: 9px; font-weight: 700; letter-spacing: -.2px;
}
.art-chip-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.art-chip-open { flex: 0 0 auto; color: var(--muted); font-size: 11px; }
.art-chip:hover, .art-chip:focus-visible {
background: var(--accent-soft); border-color: rgba(192,57,43,.45); color: var(--accent);
box-shadow: 0 2px 8px rgba(192,57,43,.10); transform: translateY(-1px);
}
.art-chip:hover .art-chip-open, .art-chip:focus-visible .art-chip-open { color: var(--accent); }
#chat-hint .art-chip { margin: 0 2px; vertical-align: middle; }
.paste-chip-wrap {
display: inline-flex; align-items: center; max-width: 280px; margin: 0 2px;
vertical-align: middle;
}
.paste-chip-wrap .art-chip {
margin: 0; border-top-right-radius: 0; border-bottom-right-radius: 0;
max-width: 230px;
max-width: 280px;
}
.paste-chip-del {
border: 1px solid var(--border); border-left: 0; background: #fff; color: var(--muted);
border-radius: 0 999px 999px 0; padding: 2px 7px; line-height: 1.4;
border-radius: 0 5px 5px 0; padding: 2px 7px; line-height: 1.4;
font-size: 11px; cursor: pointer; transition: var(--t);
}
.paste-chip-del:hover { background: var(--c-red-bg); border-color: var(--c-red-bd); color: var(--c-red); }
@ -1068,12 +1078,12 @@
}
.art-media .art-media-error { color: #b34a4a; }
.art-media img {
display: block; max-width: 360px; max-height: 280px;
display: block; max-width: 560px; max-height: 420px;
width: auto; height: auto; cursor: zoom-in;
}
.art-media-video { flex: 0 1 720px; width: min(100%, 720px); }
.art-media video {
display: block; max-width: 480px; max-height: 320px;
width: auto; height: auto; background: #000;
display: block; width: 100%; height: auto; max-height: 405px; background: #000;
}
#chat-form {
@ -1252,7 +1262,7 @@
text-overflow: ellipsis; white-space: nowrap;
}
.art-html {
flex: 1 1 100%; min-width: 0; max-width: 760px;
flex: 1 1 100%; width: 100%; min-width: 0; max-width: none;
border: 1px solid var(--border); border-radius: var(--r-md); overflow: hidden;
background: #fff; font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
}
@ -1273,6 +1283,9 @@
.art-html-viewport .art-media-loading,
.art-html-viewport .art-media-error { padding: 10px; color: var(--muted); font-size: 11px; }
.art-html-viewport .art-media-error { color: #b34a4a; }
/* HTML 是小型内容画布:只让含 HTML 预览的助手消息使用宽版布局,
普通文本消息继续维持易读行长。 */
.msg.assistant:has(.art-html) { width: calc(100% - 16px); max-width: none; }
.preview-modes {
display: inline-flex; align-items: center; gap: 2px; padding: 2px;
border: 1px solid var(--border); border-radius: var(--r-md); background: var(--panel-muted);

View File

@ -27,7 +27,7 @@ import { logout } from "./auth.js";
import { openWechatModal } from "./wechat.js";
import { openFilePreview, openPasteFilePreview, closePreviewIfShowing } from "./preview.js";
import { loadFiles, scheduleFilesRefresh, uploadFiles, formatUploadProgress } from "./files.js";
import { toolActivityLabel, _workingDirName, extractMediaBanner, extractArtifactRels, renderArtifactBarHtml, upgradeMediaArtifacts, ARTIFACT_PRODUCING_TOOLS, _flushMediaArtifactCache } from "./media.js";
import { toolActivityLabel, _workingDirName, extractMediaBanner, extractArtifactRels, renderArtifactBarHtml, renderArtifactChipContent, upgradeMediaArtifacts, ARTIFACT_PRODUCING_TOOLS, _flushMediaArtifactCache } from "./media.js";
import { applyProgressAction, cloneProgressSteps, progressActionsFromToolCalls } from "./progress.js";
import { refreshProcs, decorateBgprocCard, hasRunningProc, killTaskProcs } from "./procs.js";
@ -1919,7 +1919,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)}" 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>`);
`<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)} · 点击预览">${renderArtifactChipContent(name, rel)}</button><button type="button" class="paste-chip-del" data-rel="${escapeHtml(rel)}" title="删除该文件">×</button></span>`);
}
tray.classList.toggle("show", attachCount() > 0);
}

View File

@ -167,6 +167,17 @@ export function extractArtifactRels(text, workingDir) {
return out;
}
// 文件 chip 的内容结构保持纯前端生成:扩展名充当轻量类型图标,不额外请求
// 文件元数据;长文件名仍由 CSS 单行省略,完整路径保留在 button title 中。
export function renderArtifactChipContent(name, rel) {
const rawExt = String(rel || "").split("/").pop().split(".").pop();
const hasExt = rawExt && rawExt !== String(rel || "").split("/").pop();
const ext = hasExt ? rawExt.toUpperCase().slice(0, 4) : "FILE";
return `<span class="art-chip-icon" aria-hidden="true">${escapeHtml(ext)}</span>`
+ `<span class="art-chip-name">${escapeHtml(name)}</span>`
+ `<span class="art-chip-open">预览</span>`;
}
// inlineMode 控制升级范围:true=图片/视频/HTML"html"=仅 HTMLfalse=全走 chip。
// 产物工具传 trueassistant 正文传 "html",避免重复内联图片/视频但让通用工具生成的
// HTML 有展示位;普通工具结果传 false引用到的文件仍走 chip避免把旧产物铺满消息。
@ -191,7 +202,7 @@ export function renderArtifactBarHtml(rels, inlineMode = true, taskId = "", lega
// 不在这里发请求避免 string-build 阶段失控的并发;upgrade 走 DOM walk 一次。
return `<span class="art-media art-media-${cat}" data-rel="${escapeHtml(rel)}" data-cat="${cat}"${taskAttr}${legacyAttr} title="${escapeHtml(rel)}"><span class="art-media-loading">${escapeHtml(name)} 加载中…</span></span>`;
}
return `<button type="button" class="art-chip" data-rel="${escapeHtml(rel)}"${taskAttr}${legacyAttr} title="${escapeHtml(rel)} · 点击预览(可下载)">${escapeHtml(name)}</button>`;
return `<button type="button" class="art-chip" data-rel="${escapeHtml(rel)}"${taskAttr}${legacyAttr} title="${escapeHtml(rel)} · 点击预览(可下载)">${renderArtifactChipContent(name, rel)}</button>`;
}).join("");
return `<div class="artifact-bar">${items}</div>`;
}