Compare commits

...

2 Commits

28 changed files with 428 additions and 55 deletions

View File

@ -5,6 +5,11 @@
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
> 工程口径的完整记录见 `PROGRESS.md` / git log。
## 0.62.2 — 2026-08-06
- 外部系统的大查询结果现在可以按需导出为长期保存的完整 JSON 文件,并保留查询参数和时间等溯源信息,不再受临时缓存期限影响。
- task 文件面板默认隐藏 `.meta`、`.build`、`.preview` 等点目录;需要排查或查看溯源信息时,可以用 `.*` 开关临时显示当前 task 下的隐藏目录。用户级平台目录和 `.env` 等点文件仍不会展示。
## 0.62.1 — 2026-08-06
- 修复外部系统只读 POST 接口因请求体结构信息缺失而反复报参数错误的问题;大查询结果不再被截成半段内容,超出对话容量时会完整暂存并支持按路径、分页和字段继续读取。

View File

@ -56,6 +56,17 @@ zcbot/
**工作目录** = `workspace/users/<user_id>/<working_dir>/`,所有 skill 产物写这里,绝对路径注入 system prompt。`user_id` 走 JWT `sub`,**无 SENTINEL fallback**。DB 内 `name`(显示名)与 `working_dir` 都有值;创建 API 显式给 name 时 working_dir 可留空并沿用 name省略/留空 name 时必须显式给 working_dir、name 先以「新对话」占位并自动生成。二者落库前都是简单名(拒 `/\..`、`.` 起头);同 working_dir 多 task 共享同目录(§7.1)。SaaS 化只换外层根目录,布局不变。
**输出生命周期**(2026-08-06):真实文件仍是事实源,不引入统一产物框架,也不迁移各工具已经稳定的目录。仅对已出现的两类噪声建立局部规则:可过期的外部大响应进用户根目录隐藏缓存 `.zcbot_cache/<task_id>/external_results/`,用户明确要求留存时再 export 到 `<working_dir>/data/external/`;图片/视频的 prompt/model/cost 等技术 sidecar 进产物目录下 `.meta/`。文件面板默认隐藏所有 dotdir用户可在当前 task 工作目录子树内用开关查看 `.meta/`、`.build/`、`.preview/` 等 dotdir但 user_root 的 `.memory/`、`.skills/`、`.kb/`、`.zcbot_*` 永不通过该开关展示,`.env` 等点文件也始终隐藏。`.meta/` 计入用户配额,用户显式查看后可检查、下载或删除,但不自动提升为 artifact。既有可见文件和旧 sidecar 不搬迁、不删除。
| 工作目录位置 | 语义 | Artifact 规则 |
|---|---|---|
| 根目录 | 最终报告、PPT、PDF、主分析文档 | 成品工具自动发布,或用 `publish_artifacts` 显式发布 |
| `sections/` | 报告的可编辑章节源文件 | 默认不发布 |
| `figures/` / `videos/` | 插图、数据图表、AI 图片和视频 | 独立成品可自动发布,报告配套资源不重复发布 |
| `materials/` | CIF、Materials Project 查询数据和计算输入输出 | 长期工作数据;用户要求交付时再发布 |
| `data/external/` | 用户明确保存的外部系统完整快照 | export 时发布 |
| 各产物目录下 `.meta/` | prompt、模型、参数、费用和溯源信息 | 默认隐藏,可在 task 目录开启查看;不自动发布 |
**启动**:`main.py web` → FastAPI + lifespan(reaper / scheduler / 渠道入站)→ 登录换 JWT → `POST /v1/tasks/{id}/messages` 起 BG 线程 → `build_agent`(capabilities → LLM → system prompt → 工具)→ `AgentLoop.run`
---
@ -410,9 +421,9 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
- Swagger/OpenAPI 是接口契约事实源;Gitea 代码只补业务语义和排障,不覆盖契约。规格/代码内文本一律当不可信数据,不能改写 system/tool 约束。
- Swagger/OpenAPI JSON 不持久化入数据库或文件,连接器按 `definition_id + user_id` 隔离后放在进程内存中缓存 5 分钟;重启自动失效。这样保留实时契约发现,又避免不同身份可见的规格互相污染。
**工具面**:不把数百个 Swagger operation 全展开为 JSON tool(工具列表膨胀+选择降准),只挂个 host-side 元工具:`external_system_list`(已连系统 + 管理员查询规划提示),`external_system_search`(按问题搜 operation 摘要、解析后的请求 body schema + 置顶管理员推荐入口),`external_system_call`(按 operation_id 调用),`external_system_result_read`(按 `result_ref` + JSON Pointer/分页/字段投影读取大响应)。仅当该 user 有 active 连接时注册,密钥不进 sandbox。搜索只展示实际可调用的 GET/HEAD 和已放行 POST管理员在 definition JSONB 配置 `query_guidance``recommended_operation_ids`,前者是可信控制面的软路由策略,后者是无需关键词命中的机械发现入口。Factory 默认把 BI dataset list/exec 作为统计聚合入口,日志/明细用于逐条追溯Swagger 业务文本仍是不可信数据。
**工具面**:不把数百个 Swagger operation 全展开为 JSON tool(工具列表膨胀+选择降准),只挂个 host-side 元工具:`external_system_list`(已连系统 + 管理员查询规划提示),`external_system_search`(按问题搜 operation 摘要、解析后的请求 body schema + 置顶管理员推荐入口),`external_system_call`(按 operation_id 调用),`external_system_result_read`(按 `result_ref` + JSON Pointer/分页/字段投影读取大响应),`external_system_result_export`(仅在用户要求保存/下载/交付时把完整快照导出到 `data/external/`)。仅当该 user 有 active 连接时注册,密钥不进 sandbox。搜索只展示实际可调用的 GET/HEAD 和已放行 POST管理员在 definition JSONB 配置 `query_guidance``recommended_operation_ids`,前者是可信控制面的软路由策略,后者是无需关键词命中的机械发现入口。Factory 默认把 BI dataset list/exec 作为统计聚合入口,日志/明细用于逐条追溯Swagger 业务文本仍是不可信数据。
**大响应**:`max_result_bytes` 是进入模型上下文的单次内联额度,不再用于切断原始 JSON超额响应完整写入当前 user_root 下按 task_id 隔离的隐藏缓存,工具只返回合法结构化预览、`result_ref`、原始字节数和可继续读取的位置。reader 每次读取都重新校验当前 user 对原 external system 的 active 授权,并与 call 共享本轮 `max_total_result_bytes` 内联额度。缓存固定 24h TTL、单响应 10 MiB、单 task 50 MiB、单 user 200 MiB,过期或超额时优先清理最旧缓存;超过响应安全上限的远端结果直接拒绝并要求缩小范围,不产生半截 JSON。这里把“上游响应安全边界”“完整结果保存”“模型上下文额度”拆成三层,既不丢数据,也不靠无限提高上下文额度解决大结果问题。
**大响应**:`max_result_bytes` 是进入模型上下文的单次内联额度,不再用于切断原始 JSON超额响应完整写入 `.zcbot_cache/<task_id>/external_results/`,工具只返回合法结构化预览、`result_ref`、原始字节数和可继续读取的位置。reader 每次读取都重新校验当前 user 对原 external system 的 active 授权,并与 call 共享本轮 `max_total_result_bytes` 内联额度export 同样重验授权,并把查询 operation/参数/时间等 provenance 与完整响应一起持久化,导出文件不受缓存 TTL 影响。缓存固定 24h TTL、单响应 10 MiB、单 task 50 MiB、单 user 200 MiB,过期或超额时优先清理最旧缓存;0.62.1 的 `.zcbot_external_results/` 在读取和容量核算上保留兼容窗口。超过响应安全上限的远端结果直接拒绝并要求缩小范围,不产生半截 JSON。这里把“上游响应安全边界”“完整结果保存”“模型上下文额度”“用户明确留存”拆成四层,既不丢数据,也不靠无限提高上下文额度解决大结果问题。
**明细扫描边界**:单次响应保留安全上限与模型内联额度,每次 agent run 另按外部系统累计内联返回量Factory connector 将 `page_size` 限在管理员上限,拒绝 `page=0` / `pageoff` 关闭分页。三者防模型通过连续翻日志自行做昂贵聚合,但不改变 Factory 对其他客户端的分页契约。达到边界后工具正向引导回 dataset/聚合接口、`result_ref` 分段读取或缩小查询范围。

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-08-06(OpenAPI 请求体契约 + 大响应无损分段读取,bump 0.62.1)
最后更新:2026-08-06(外部结果持久导出 + task 点目录查看开关,bump 0.62.2)
---
@ -23,6 +23,7 @@
### 2026-08-06
- **08-06 / 0.62.2 / 外部结果持久导出 + task 点目录查看开关**:外部大响应缓存统一迁入 user/task 隔离的 `.zcbot_cache`,兼容读取 0.62.1 旧目录;用户明确要求保存时新增 `external_system_result_export`,重验当前连接授权后把完整响应及 operation/参数/查询时间溯源导出到 `data/external/`,不受 24h TTL 影响。媒体 prompt/model/cost sidecar 改存 `figures|videos/.meta/`;文件面板默认隐藏所有 dotdir当前 task 工作目录子树可用 `.*` 开关查看 `.meta/.build/.preview` 等目录user_root 平台目录和点文件始终不展示。完整 483 项 unittest 全绿(17 skip)Node 语法、Ruff 致命规则与 diff 检查通过;当前环境无可用浏览器实例,真实点击留部署后冒烟。无 schema、migration 或依赖变化,未连接生产 DB。
- **08-06 / 0.62.1 / OpenAPI 请求体契约 + 大响应无损读取**:生产 task `506c6ad0` 暴露 Swagger 2 body `$ref` 未进入搜索结果,模型无法得知 BI dataset exec 需要 `body.query`,并在 400 只剩状态码时连续盲猜;现统一解析 Swagger 2/OpenAPI 3 请求体 schema返回限长脱敏的上游错误详情。外部响应不再按字节切成半截 JSON超出模型内联额度时完整写入 user/task 隔离的 24h 缓存并返回 `result_ref`,新增 JSON Pointer、分页和字段投影 reader单响应/task/user 分别以 10/50/200 MiB 守门;完整 479 项 unittest 全绿(17 skip),外部系统专项 26 项、Ruff、隔离 mypy、Python 编译与 diff 检查通过。无 schema、migration、依赖或 HTTP API 变化,未写生产 DB。
### 2026-08-05

2
RUN.md
View File

@ -363,7 +363,7 @@ $env:ZCBOT_EVAL_TOKEN = "<dedicated-eval-user-jwt>"
| `POST /v1/tasks/{id}/optimize_prompt` | body `{text(req, ≤4000), image_model?=""}`;同步调当前 task model 润色草稿,返 `{optimized, model_profile, tokens_in, tokens_out, cost_cny}`;**不**写 messages、**不**累计 task 三列(顶栏数字不污染),只在 `usage_events` 写一行 `kind="prompt_optimize"`(对账可见);不与主对话 run 互斥(允许 streaming 中并行润色) | 必填 |
| `WS /v1/asr/stream` | 流式语音转写(前端「🎙 语音」主通道):连上后首条文本帧发 `{"token":"<jwt>"}`(WS 塞不了 header,token 不走 query 防进 access log,失败 close 4401)→ 之后二进制帧 = PCM 分片(16k/16bit/mono)实时转发讯飞;任意文本帧(约定 `{"type":"end"}`)= 说完。服务端回推 `{"text","final"}` 增量全文(wpgs 动态修正已合并),final=true 为最终结果;错误回 `{"error"}` 后关闭。nginx 反代该路径需支持 WS Upgrade(`proxy_set_header Upgrade/Connection`) | 首消息 |
| `POST /v1/asr/transcribe` | body 为裸 PCM(16kHz/16bit/单声道/小端,`application/octet-stream`)→ 讯飞 IAT 整段转写,返 `{text}`;>60s → 413;`XFYUN_*` env 未配 → 501;讯飞侧错误 → 502(带错误码提示)。流式 WS 连不上时前端的兜底通道 | 必填 |
| `GET /v1/files?path=` | 列 user_root 下条目 + 面包屑;dotfile 隐藏 | 必填 |
| `GET /v1/files?path=&include_hidden=&task_id=` | 列 user_root 下条目 + 面包屑dotfile/dotdir 默认隐藏。`include_hidden=true` 仅在同时提供当前用户的 `task_id``path` 位于该 task working_dir 子树时展示点目录user_root 平台目录与点文件始终不展示 | 必填 |
| `GET /v1/files/download?path=` | 下单文件 | 必填 |
| `GET /v1/tasks/{id}/files/download?path=` | 下载结构化产物;`path` 以该 task 当前 working_dir 为根,顶层目录改名后无需改历史消息;跨用户/越界/不存在均拒绝。`legacy=true` 仅供前端读取迁移前卡片,按旧 user-root→两种 task-relative 含义顺序兼容 | 必填 |
| `GET /v1/tasks/{id}/files/preview_pdf?path=` | PPT/PPTX 结构化产物按 task-relative 路径转 PDF 预览;状态码与旧 `/v1/files/preview_pdf` 一致;旧卡片可同样传 `legacy=true` | 必填 |

View File

@ -1,7 +1,7 @@
# zcbot Skill 清单
服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材)
最后更新:2026-08-04(ppt 默认机构候选包含总院红,明确外部机构除外,建材集团别名直达)
最后更新:2026-08-06(imagegen/videogen 溯源信息进入默认隐藏、可按 task 查看 .meta/)
Skill 总数:18
zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` + 配套 templates / scripts / Python helper),模型在识别用户意图后挂载对应 skill,按其内置的阶段化流程产出可交付物。本文档面向**使用方 / 协作方**,按"做什么、什么时候用、什么时候别用、典型产物"组织。
@ -416,6 +416,8 @@ paper_server 是内部 Django 文献库:元数据来自 OpenAlex,PDF / XML 由 S
**成本**:每次 ¥0.22(`search=true` 加 ¥0.05),3-5 秒出图。
**典型产物**:`figures/<时间戳>-<rand>.png`prompt / 参数 / 成本等溯源信息在默认隐藏、可按 task 开启查看的 `figures/.meta/`
**⛔ 调 tool 前的强制门(铁律)**:
任何情况下,`seedream` tool call 发出去之前,**必须**先把最终装配好的 prompt(含 size / watermark / search 参数)用对话消息明文展示给用户,问"这样画?要改什么?"并 **BLOCKING 等明确确认**
@ -448,6 +450,8 @@ paper_server 是内部 Django 文献库:元数据来自 OpenAlex,PDF / XML 由 S
- 720p / 15s:¥12+
- 等待:Fast 30-90s / Pro 2-3min
**典型产物**:`videos/<时间戳>-<rand>.mp4`prompt / 参数 / 成本等溯源信息在默认隐藏、可按 task 开启查看的 `videos/.meta/`
**⛔ 比 imagegen 还严的强制门**:视频单价 ¥4 起,**比图贵 10 倍以上**;一次失败 ≈ 18 张错图;等待代价也高。**装配 prompt 不等于授权调用**。
**触发词**:视频 / 动画 / 动起来 / 做个 video / 做段视频 / 出段视频 / 生成视频 / mov / mp4 / 短片 / 镜头 / 运动镜头 / 演示视频 / 动效。

View File

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

View File

@ -12,6 +12,7 @@ from uuid import uuid4
from core.file_store import atomic_write_text
RESULT_CACHE_SUBDIR = ".zcbot_external_results"
RESULT_CACHE_ROOT = ".zcbot_cache"
RESULT_TTL_SECONDS = 24 * 60 * 60
MAX_STORED_RESULT_BYTES = 10 * 1024 * 1024
MAX_TASK_CACHE_BYTES = 50 * 1024 * 1024
@ -130,19 +131,30 @@ def resolve_json_pointer(value: Any, pointer: str) -> Any:
class ExternalResultStore:
def __init__(self, user_root: Path, task_id: str):
self.cache_root = Path(user_root) / RESULT_CACHE_SUBDIR
self.root = self.cache_root / str(task_id)
task_id = str(task_id).strip()
if not task_id or task_id in {".", ".."} or not re.fullmatch(r"[\w.-]+", task_id):
raise ExternalResultError("task_id 无效")
self.user_root = Path(user_root).resolve()
self.cache_root = self.user_root / RESULT_CACHE_ROOT
self.root = self.cache_root / task_id / "external_results"
# 0.62.1 曾把 24h 缓存写在该目录reader 保留一版兼容窗口。
self.legacy_cache_root = self.user_root / RESULT_CACHE_SUBDIR
self.legacy_root = self.legacy_cache_root / task_id
def _path(self, result_ref: str) -> Path:
if not _REF_RE.fullmatch(result_ref or ""):
raise ExternalResultError("result_ref 无效")
return self.root / f"{result_ref}.json"
def _candidate_paths(self, result_ref: str) -> list[Path]:
current = self._path(result_ref)
return [current, self.legacy_root / current.name]
def _sweep(self) -> None:
if not self.cache_root.is_dir():
return
cutoff = time.time() - RESULT_TTL_SECONDS
for path in self.cache_root.glob("*/extres_*.json"):
paths = list(self.cache_root.glob("*/external_results/extres_*.json"))
paths.extend(self.legacy_cache_root.glob("*/extres_*.json"))
for path in paths:
try:
if path.stat().st_mtime < cutoff:
path.unlink()
@ -153,7 +165,13 @@ class ExternalResultStore:
except OSError:
continue
def store(self, system_id: str, result: dict[str, Any]) -> StoredExternalResult:
def store(
self,
system_id: str,
result: dict[str, Any],
*,
provenance: dict[str, Any] | None = None,
) -> StoredExternalResult:
encoded = _json_bytes(result)
if len(encoded) > MAX_STORED_RESULT_BYTES:
raise ExternalResultError(
@ -161,10 +179,9 @@ class ExternalResultStore:
)
self.root.mkdir(parents=True, exist_ok=True)
self._sweep()
existing = sorted(
self.root.glob("extres_*.json"),
key=lambda path: path.stat().st_mtime,
)
existing = list(self.root.glob("extres_*.json"))
existing.extend(self.legacy_root.glob("extres_*.json"))
existing.sort(key=lambda path: path.stat().st_mtime)
total = sum(path.stat().st_size for path in existing)
while existing and total + len(encoded) > MAX_TASK_CACHE_BYTES:
oldest = existing.pop(0)
@ -176,10 +193,9 @@ class ExternalResultStore:
continue
if total + len(encoded) > MAX_TASK_CACHE_BYTES:
raise ExternalResultError("当前任务的外部结果缓存已达上限")
user_files = sorted(
self.cache_root.glob("*/extres_*.json"),
key=lambda path: path.stat().st_mtime,
)
user_files = list(self.cache_root.glob("*/external_results/extres_*.json"))
user_files.extend(self.legacy_cache_root.glob("*/extres_*.json"))
user_files.sort(key=lambda path: path.stat().st_mtime)
user_total = sum(path.stat().st_size for path in user_files)
while user_files and user_total + len(encoded) > MAX_USER_CACHE_BYTES:
oldest = user_files.pop(0)
@ -196,11 +212,13 @@ class ExternalResultStore:
"version": 1,
"created_at": int(time.time()),
"system_id": str(system_id),
"provenance": provenance or {},
"result": result,
}
atomic_write_text(self._path(result_ref), json.dumps(
envelope, ensure_ascii=False, default=str
))
atomic_write_text(
self._path(result_ref),
json.dumps(envelope, ensure_ascii=False, default=str),
)
return StoredExternalResult(
result_ref=result_ref,
original_bytes=len(encoded),
@ -209,7 +227,8 @@ class ExternalResultStore:
def load(self, result_ref: str) -> dict[str, Any]:
self._sweep()
path = self._path(result_ref)
candidates = self._candidate_paths(result_ref)
path = next((candidate for candidate in candidates if candidate.is_file()), candidates[0])
try:
if time.time() - path.stat().st_mtime > RESULT_TTL_SECONDS:
path.unlink()

View File

@ -61,13 +61,18 @@ def parse_bytes(value) -> Optional[int]:
# 扫描跳过的 dotfile 顶层名(节省 IO,且 /v1/files API 也隐藏)
_SKIP_TOPLEVEL = frozenset({".zcbot_tmp", ".memory"})
_SKIP_TOPLEVEL = frozenset({
".zcbot_cache",
".zcbot_external_results",
".zcbot_tmp",
".memory",
})
def scan_user_dir(user_root: Path) -> Tuple[int, int]:
"""os.walk 累加 user_root 下所有文件大小,返 (bytes, count)。
跳过顶层 .zcbot_tmp / .memory(开发期临时 + 用户记忆 dotfile,不算入产品配额);
跳过顶层平台缓存/临时区与 .memory(均有独立生命周期,不算入产品配额);
follow_symlinks=False symlink 循环爆
"""
if not user_root.exists() or not user_root.is_dir():

View File

@ -28,6 +28,7 @@ from tools.documents import DocumentDownloadTool, DocumentListKbTool, DocumentSe
from tools.external_systems import (
ExternalSystemCallTool,
ExternalSystemListTool,
ExternalSystemResultExportTool,
ExternalSystemResultReadTool,
ExternalSystemSearchTool,
)
@ -171,6 +172,11 @@ def build_tools(ctx: ToolContext) -> dict[str, Any]:
result_budget=result_budget,
**wd_base,
),
ExternalSystemResultExportTool(
ctx.uid,
task_id=ctx.task_id,
**wd_base,
),
]
def _load_skill() -> list:

View File

@ -175,7 +175,7 @@ seedream(
返回串首行是 `[seedream] model=... · size=... · cost=¥... · elapsed=...s` —— 把这行原样保留给用户(前端 SPA 会 parse 挂徽章)。第二行 `saved: <相对路径>` 是产物路径,告诉用户。
产物自动落 `<task_dir>/figures/<时间戳>-<rand>.png` + 同名 `.meta.json`(prompt / 参数 / 成本 / response_id)。
产物自动落 `<task_dir>/figures/<时间戳>-<rand>.png`;对应溯源信息写入默认隐藏的 `figures/.meta/<图片文件名>.json`(prompt / 参数 / 成本 / response_id)。用户可在 task 文件面板开启“显示点目录”后检查、下载或删除;`.meta/` 不属于最终交付物。
## 改图(i2i):基于已有图做修改

View File

@ -217,7 +217,7 @@ seedance(
返回串首行是 `[seedance] model=... · mode=text_to_video|image_to_video · images=N · image=... · resolution=... · ratio=... · duration=Xs · cost=¥... · elapsed=...s` —— `images` 是图片总数,`image` 仅固定首帧时出现;原样保留给用户(SPA 会 parse 挂徽章)。第二行 `saved: <相对路径>` 是产物路径,告诉用户。
产物自动落 `<task_dir>/videos/<时间戳>-<rand>.mp4` + 同名 `.meta.json`(prompt / 参数 / cost / tokens / cgt_id 全 snapshot)。
产物自动落 `<task_dir>/videos/<时间戳>-<rand>.mp4`;对应溯源信息写入默认隐藏的 `videos/.meta/<视频文件名>.json`(prompt / 参数 / cost / tokens / cgt_id 全 snapshot)。用户可在 task 文件面板开启“显示点目录”后检查、下载或删除;`.meta/` 不属于最终交付物。
## 失败 / 不满意后怎么办

View File

@ -68,14 +68,18 @@ class TestScanUserDir(unittest.TestCase):
self.assertEqual(c, 3)
def test_skip_dotfile_toplevel(self):
"""顶层 .zcbot_tmp / .memory 被跳过(开发期临时 + 用户记忆,不算配额)"""
"""平台隐藏缓存/临时区与用户记忆不计入产品文件配额"""
with tempfile.TemporaryDirectory() as d:
root = Path(d)
(root / "a.txt").write_bytes(b"counted") # 7
(root / ".zcbot_tmp").mkdir()
(root / ".zcbot_tmp" / "skipped.py").write_bytes(b"x" * 99999)
(root / ".memory").mkdir()
(root / ".memory" / "core.md").write_bytes(b"x" * 99999)
for hidden in (
".zcbot_cache",
".zcbot_external_results",
".zcbot_tmp",
".memory",
):
(root / hidden).mkdir()
(root / hidden / "skipped.bin").write_bytes(b"x" * 99999)
b, c = scan_user_dir(root)
self.assertEqual(b, 7)
self.assertEqual(c, 1)

View File

@ -577,6 +577,7 @@ class ExternalSystemToolSafetyTests(unittest.TestCase):
def test_large_call_spills_and_result_reader_pages_without_data_loss(self):
from tools.external_systems import (
ExternalSystemCallTool,
ExternalSystemResultExportTool,
ExternalSystemResultReadTool,
)
@ -608,6 +609,14 @@ class ExternalSystemToolSafetyTests(unittest.TestCase):
self.assertFalse(spilled["truncated"])
self.assertRegex(spilled["result_ref"], r"^extres_[0-9a-f]{32}$")
self.assertNotIn("x" * 100, json.dumps(spilled))
cache_path = (
Path(tmp)
/ ".zcbot_cache"
/ str(task_id)
/ "external_results"
/ f"{spilled['result_ref']}.json"
)
self.assertTrue(cache_path.is_file())
read_tool = ExternalSystemResultReadTool(
uid,
@ -628,10 +637,46 @@ class ExternalSystemToolSafetyTests(unittest.TestCase):
base_dir=Path(tmp),
)
cross_task = other_task.execute(spilled["result_ref"])
export_tool = ExternalSystemResultExportTool(
uid,
task_id=task_id,
base_dir=Path(tmp),
user_root=Path(tmp),
)
exported = export_tool.execute(
spilled["result_ref"],
filename="detail_snapshot.json",
)
export_path = Path(tmp) / "data" / "external" / "detail_snapshot.json"
export_payload = json.loads(export_path.read_text(encoding="utf-8"))
self.assertEqual(page["total_items"], 20)
self.assertEqual(page["data"], [{"id": 5}, {"id": 6}])
self.assertTrue(page["has_more"])
self.assertIn("不存在或已过期", cross_task)
self.assertEqual(exported.artifacts[0].path, "data/external/detail_snapshot.json")
self.assertEqual(export_payload["_zcbot"]["result_ref"], spilled["result_ref"])
self.assertEqual(
export_payload["_zcbot"]["provenance"]["operation_id"],
"detail_list",
)
self.assertEqual(len(export_payload["response"]["data"]), 20)
def test_result_store_reads_legacy_cache_location(self):
from core.external_systems.results import ExternalResultStore
with tempfile.TemporaryDirectory() as tmp:
task_id = str(uuid.uuid4())
store = ExternalResultStore(Path(tmp), task_id)
stored = store.store("system-1", {"data": [1, 2, 3]})
current = store.root / f"{stored.result_ref}.json"
legacy = store.legacy_root / current.name
legacy.parent.mkdir(parents=True)
current.replace(legacy)
loaded = store.load(stored.result_ref)
self.assertEqual(loaded["result"], {"data": [1, 2, 3]})
if __name__ == "__main__":

View File

@ -162,7 +162,7 @@ class GptImageToolTests(unittest.TestCase):
self.assertIn("mode=i2i", result)
self.assertIn("reference=", result)
meta_path = next((self.working_dir / "figures").glob("*.meta.json"))
meta_path = next((self.working_dir / "figures" / ".meta").glob("*.json"))
meta = json.loads(meta_path.read_text(encoding="utf-8"))
self.assertEqual(meta["mode"], "i2i")
self.assertEqual(meta["reference_images"], ["task/reference.png"])

View File

@ -26,13 +26,15 @@ class StampedPathTests(unittest.TestCase):
self.assertEqual(p.parent, d)
self.assertRegex(p.name, r"^\d{8}-\d{6}-[0-9a-f]{6}\.png$")
def test_write_meta_alongside(self):
def test_write_meta_in_hidden_directory(self):
with TemporaryDirectory() as td:
dest = Path(td) / "a.mp4"
mc.write_meta(dest, {"prompt": "早安", "cost_cny": 0.22})
meta = json.loads((Path(td) / "a.meta.json").read_text(encoding="utf-8"))
meta_path = Path(td) / ".meta" / "a.mp4.json"
meta = json.loads(meta_path.read_text(encoding="utf-8"))
self.assertEqual(meta["prompt"], "早安")
self.assertEqual(meta["cost_cny"], 0.22)
self.assertFalse((Path(td) / "a.meta.json").exists())
class QuotaGateTests(unittest.TestCase):

View File

@ -347,6 +347,44 @@ class FilesDbAwareTests(unittest.TestCase):
self.assertTrue(d["working_dir"].endswith("/改名后目录"))
self.assertTrue((_user_root() / "改名后目录").is_dir())
def test_hidden_directory_toggle_is_scoped_to_task_working_dir(self):
tid = self._mk_task("隐藏目录任务", "隐藏目录")
wd = _user_root() / "隐藏目录"
(wd / ".meta").mkdir()
(wd / ".build").mkdir()
(wd / ".env").write_text("SECRET=x", encoding="utf-8")
(wd / "visible.txt").write_text("ok", encoding="utf-8")
(_user_root() / ".root-secret").mkdir(exist_ok=True)
default = _client.get(
"/v1/files", params={"path": "隐藏目录"}, headers=_AUTH
).json()
self.assertEqual({entry["name"] for entry in default["entries"]}, {"visible.txt"})
self.assertFalse(default["hidden_dirs_included"])
shown = _client.get(
"/v1/files",
params={
"path": "隐藏目录",
"include_hidden": "true",
"task_id": tid,
},
headers=_AUTH,
).json()
self.assertEqual(
{entry["name"] for entry in shown["entries"]},
{".build", ".meta", "visible.txt"},
)
self.assertTrue(shown["hidden_dirs_included"])
root = _client.get(
"/v1/files",
params={"include_hidden": "true", "task_id": tid},
headers=_AUTH,
).json()
self.assertNotIn(".root-secret", {entry["name"] for entry in root["entries"]})
self.assertFalse(root["hidden_dirs_included"])
def test_task_relative_download_survives_working_dir_rename(self):
tid = self._mk_task("稳定产物任务", "产物旧目录")
artifact = _user_root() / "产物旧目录" / "reports" / "result.txt"

View File

@ -242,8 +242,15 @@ class FilesRoutesTests(unittest.TestCase):
def setUpClass(cls):
cls.wd = _user_root() / "route-test-wd"
(cls.wd / "sub").mkdir(parents=True, exist_ok=True)
(cls.wd / "figures" / ".meta").mkdir(parents=True, exist_ok=True)
(cls.wd / "figures" / ".private").mkdir(parents=True, exist_ok=True)
(cls.wd / "a.txt").write_text("hello", encoding="utf-8")
(cls.wd / "sub" / "b.txt").write_text("world", encoding="utf-8")
(cls.wd / "figures" / ".meta" / "image.png.json").write_text(
"{}", encoding="utf-8"
)
(cls.wd / "figures" / ".env").write_text("SECRET=x", encoding="utf-8")
(_user_root() / ".root-private").mkdir(exist_ok=True)
def test_list_subdir(self):
r = _client.get("/v1/files", params={"path": "route-test-wd"}, headers=_AUTH)
@ -251,9 +258,64 @@ class FilesRoutesTests(unittest.TestCase):
body = r.json()
self.assertTrue(body["exists"])
names = {e["name"] for e in body["entries"]}
self.assertEqual(names, {"sub", "a.txt"})
self.assertEqual(names, {"sub", "figures", "a.txt"})
self.assertEqual(body["current"], "route-test-wd")
def test_dot_directories_are_hidden_by_default(self):
r = _client.get(
"/v1/files",
params={"path": "route-test-wd/figures"},
headers=_AUTH,
)
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json()["entries"], [])
def test_enumerator_can_include_dot_directories_but_never_dotfiles(self):
from web.userfiles import enumerate_files
entries, _crumbs, _exists = enumerate_files(
_user_root(),
self.wd / "figures",
include_hidden_dirs=True,
)
self.assertEqual(
{entry["name"] for entry in entries},
{".meta", ".private"},
)
def test_api_hidden_toggle_is_limited_to_selected_task_tree(self):
task_id = uuid.uuid4()
with patch(
"web.routers.files._task_working_dir",
return_value=(task_id, self.wd),
):
shown = _client.get(
"/v1/files",
params={
"path": "route-test-wd/figures",
"include_hidden": "true",
"task_id": str(task_id),
},
headers=_AUTH,
).json()
self.assertEqual(
{entry["name"] for entry in shown["entries"]},
{".meta", ".private"},
)
self.assertTrue(shown["hidden_dirs_included"])
with patch("web.routers.files.system_wd_names", return_value=set()):
root = _client.get(
"/v1/files",
params={"include_hidden": "true", "task_id": str(task_id)},
headers=_AUTH,
).json()
self.assertNotIn(
".root-private",
{entry["name"] for entry in root["entries"]},
)
self.assertFalse(root["hidden_dirs_included"])
def test_download_and_errors(self):
r = _client.get("/v1/files/download", params={"path": "route-test-wd/a.txt"}, headers=_AUTH)
self.assertEqual(r.status_code, 200)

View File

@ -2,8 +2,12 @@
from __future__ import annotations
import json
from uuid import UUID
import re
from datetime import datetime, timezone
from pathlib import Path
from uuid import UUID, uuid4
from core.artifacts import ArtifactRef, ToolExecutionResult, resolve_artifact_path
from core.external_systems.factory import FactoryMesError
from core.external_systems.results import (
ExternalResultError,
@ -18,6 +22,7 @@ from core.external_systems.service import (
get_external_system,
list_external_systems,
)
from core.file_store import atomic_write_text
from .base import Tool
@ -138,6 +143,7 @@ class ExternalSystemCallTool(Tool):
*,
per_result_limit: int,
total_limit: int,
provenance: dict | None = None,
) -> str:
rendered = _json(result)
used = self._result_bytes.get(system_id, 0)
@ -146,7 +152,11 @@ class ExternalSystemCallTool(Tool):
self._result_bytes[system_id] = used + len(rendered.encode("utf-8"))
return rendered
stored = self._result_store.store(system_id, result)
stored = self._result_store.store(
system_id,
result,
provenance=provenance,
)
preview, reads = build_result_preview(result)
envelope = {
"operation_id": result.get("operation_id"),
@ -196,6 +206,12 @@ class ExternalSystemCallTool(Tool):
result,
per_result_limit=client.cfg.max_result_bytes,
total_limit=client.cfg.max_total_result_bytes,
provenance={
"operation_id": operation_id,
"arguments": arguments or {},
"body": body,
"queried_at": datetime.now(timezone.utc).isoformat(),
},
)
except (ExternalSystemError, FactoryMesError, ExternalResultError) as exc:
print(f"[WARN] external system call failed: {type(exc).__name__}")
@ -300,3 +316,100 @@ class ExternalSystemResultReadTool(Tool):
) as exc:
print(f"[WARN] external system result read failed: {type(exc).__name__}")
return f"[Error] {exc}"
class ExternalSystemResultExportTool(Tool):
name = "external_system_result_export"
description = (
"将 result_ref 对应的完整外部系统结果显式导出为当前任务的持久 JSON 文件。"
"仅在用户要求保存、下载或交付数据时调用;普通分析继续使用 result_read。"
)
parameters = {
"type": "object",
"properties": {
"result_ref": {"type": "string"},
"filename": {
"type": "string",
"description": "可选 JSON 文件名;留空时按 operation 和时间自动命名",
},
},
"required": ["result_ref"],
}
def __init__(
self,
user_id: UUID,
*,
task_id: UUID | str = "default",
**kwargs,
):
super().__init__(**kwargs)
self.user_id = user_id
self.task_id = str(task_id)
root = self.user_root or self.base_dir
self._result_store = ExternalResultStore(root, self.task_id)
self._user_root = Path(root).resolve()
self._working_dir = self.base_dir.resolve()
try:
self._working_dir.relative_to(self._user_root)
except ValueError as exc:
raise ValueError("base_dir 必须位于 user_root 内") from exc
def execute(self, result_ref: str, filename: str = "", **kwargs) -> str:
try:
stored = self._result_store.load(result_ref)
system_id = str(stored.get("system_id") or "")
_row_and_client(self.user_id, system_id)
result = stored["result"]
operation_id = str(result.get("operation_id") or "external_result")
safe_operation = re.sub(r"[^\w.-]+", "_", operation_id).strip("._")
safe_operation = safe_operation or "external_result"
if filename:
filename = filename.strip()
if not filename.lower().endswith(".json"):
filename += ".json"
if (
filename in {".", ".."}
or not re.fullmatch(r"[\w.-]+", filename)
):
raise ExternalResultError("filename 包含非法路径字符")
target = self._working_dir / "data" / "external" / filename
if target.exists():
raise ExternalResultError(f"文件已存在: data/external/{filename}")
else:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
target = (
self._working_dir
/ "data"
/ "external"
/ f"{safe_operation}_{stamp}_{uuid4().hex[:6]}.json"
)
exported = {
"_zcbot": {
"kind": "external_system_snapshot",
"exported_at": datetime.now(timezone.utc).isoformat(),
"result_ref": result_ref,
"system_id": system_id,
"provenance": stored.get("provenance") or {},
},
"response": result,
}
atomic_write_text(
target,
json.dumps(exported, ensure_ascii=False, default=str, indent=2),
)
_, rel = resolve_artifact_path(
str(target),
working_dir=self.base_dir,
user_root=self._user_root,
)
content = f"saved: {rel}\n完整外部系统结果已持久导出;该文件不受缓存 TTL 影响。"
return ToolExecutionResult(content, artifacts=(ArtifactRef(path=rel),))
except (
ExternalSystemError,
FactoryMesError,
ExternalResultError,
OSError,
) as exc:
print(f"[WARN] external system result export failed: {type(exc).__name__}")
return f"[Error] {exc}"

View File

@ -7,7 +7,7 @@
- 响应直接返 b64_json,无需二次下载;
- 复杂图片可能需 ~2min
完成后:
- 图片落 `<working_dir>/figures/<YYYYMMDD-HHMMSS>-<rand6>.png` + 同名 `.meta.json`
- 图片落 `<working_dir>/figures/<YYYYMMDD-HHMMSS>-<rand6>.png`技术元数据进默认隐藏的 `.meta/`
- usage_events kind="image" 一行(model_profile="unifyllm.<variant>",
usage tokens 记进 units 网关价目公布后可回填对账)
"""

View File

@ -2,7 +2,7 @@
五处同构析出,2026-07-23)
收进来的判据:**逐字或参数化后逐字**的重复 每日配额闸`<ts>-<rand6>` 落盘命名
meta.json 写入记账 try/except 兜底Ark 超时透明重试chat 答案提取响应递归找 URL
产物溯源元数据写入记账 try/except 兜底Ark 超时透明重试chat 答案提取响应递归找 URL
各工具的首行 banner 格式 / 请求 body 组装 / seedance 轮询等有意各自不同,****
"""
from __future__ import annotations
@ -16,6 +16,7 @@ from typing import Any, Callable, Optional
from uuid import UUID
from core.ark_client import ArkClient, ArkConfig, ArkError, ArkTimeoutError
from core.file_store import atomic_write_text
from core.storage.usage import check_daily_quota
@ -46,9 +47,11 @@ def stamped_path(directory: Path, ext: str) -> Path:
def write_meta(dest: Path, meta: dict) -> None:
"""产物旁写同名 `.meta.json`(prompt/model/cost 等溯源信息)。"""
dest.with_suffix(".meta.json").write_text(
json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8"
"""把 prompt/model/cost 等技术溯源信息写入产物旁默认隐藏的 `.meta/`。"""
meta_path = dest.parent / ".meta" / f"{dest.name}.json"
atomic_write_text(
meta_path,
json.dumps(meta, ensure_ascii=False, default=str, indent=2),
)

View File

@ -12,7 +12,7 @@ W×H 由 resolution + ratio 推算(横版 height=resolution_num,竖版 width=res
完成后:
- 视频落到 `<wd>/videos/<YYYYMMDD-HHMMSS>-<rand6>.mp4`
- 同名 `.meta.json` prompt / model / 参数 / cost_cny / tokens / cgt_id / ts
- 默认隐藏的 `.meta/` prompt / model / 参数 / cost_cny / tokens / cgt_id / ts
- usage_events kind="video" 一行(单价 + 分辨率 + 时长 snapshot units)
"""
from __future__ import annotations

View File

@ -3,7 +3,7 @@
模型 ID + 单价 + 默认参数全在 `config/media/doubao.yaml`, tool 只装配
完成后:
- 图片落到 `<working_dir>/figures/<YYYYMMDD-HHMMSS>-<rand6>.png`
- 同名 `.meta.json` prompt / model / size / search / cost_cny / response_id / ts
- 默认隐藏的 `.meta/` prompt / model / size / search / cost_cny / response_id / ts
- usage_events kind="image" 一行(单价 snapshot units 跨调价对账)
"""
from __future__ import annotations
@ -177,7 +177,7 @@ class SeedreamTool(Tool):
if not image_url:
return f"[Error] seedream response 缺 image url: {json.dumps(resp, ensure_ascii=False)[:300]}"
# 落盘 figures/<ts>-<rand>.png + .meta.json
# 落盘 figures/<ts>-<rand>.png;技术元数据进入默认隐藏的 figures/.meta/
dest_png = stamped_path(self.working_dir / "figures", ".png")
client.download(image_url, dest_png, timeout_s=120.0)
except ArkError as e:

View File

@ -155,15 +155,31 @@ def register_file_routes(app, *, require_user) -> None:
@app.get("/v1/files", tags=["files"])
def list_files(
path: str = "",
include_hidden: bool = False,
task_id: str = "",
user_id: UUID = Depends(require_user),
):
"""列 user_root 下子目录条目 + 面包屑。`path` 留空 → user_root;
`../` / 绝对 400dotfile(`.memory/` )一律隐藏;根目录层再隐藏
系统工作目录(定时任务 / 渠道对话, system_wd_names)
`../` / 绝对 400dotfile/dotdir 默认隐藏`include_hidden=true`
仅在 `task_id` 属于当前用户且 current 位于该 task 工作目录子树时展示
dotdir永不从 user_root 展示平台目录根目录层再隐藏系统工作目录
(定时任务 / 渠道对话, system_wd_names)点文件始终隐藏
"""
root = load_user_root(user_id)
current = safe_join(root, path)
entries, crumbs, exists = enumerate_files(root, current)
include_task_hidden = False
if include_hidden and task_id:
_tid, working_dir = _task_working_dir(task_id, user_id, root)
try:
current.relative_to(working_dir)
include_task_hidden = True
except ValueError:
pass
entries, crumbs, exists = enumerate_files(
root,
current,
include_hidden_dirs=include_task_hidden,
)
if not rel_to(root, current):
hidden = system_wd_names(user_id)
if hidden:
@ -177,6 +193,7 @@ def register_file_routes(app, *, require_user) -> None:
"exists": exists,
"crumbs": crumbs,
"entries": entries,
"hidden_dirs_included": include_task_hidden,
}
@app.get("/v1/files/download", tags=["files"])

View File

@ -582,6 +582,9 @@
}
.pane-head .label { font-weight: 600; font-size: 13px; white-space: nowrap; flex-shrink: 0; }
.pane-head .spacer { flex: 1; }
#btn-toggle-hidden[aria-pressed="true"] {
color: var(--accent); border-color: var(--accent); background: var(--accent-soft);
}
/* 左 pane:title 行(#fafafa)下面的 filter / sort 子行换成白底 + 极淡分隔,弱化层级 */
#pane-left .pane-head + .pane-head {
background: #fff;
@ -1991,6 +1994,7 @@
<span class="label">文件</span>
<span id="files-proj" class="muted small" style="margin-left:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:0 1 auto;" title=""></span>
<span class="spacer"></span>
<button id="btn-toggle-hidden" class="small" title="显示点目录" aria-label="显示点目录" aria-pressed="false" hidden>.*</button>
<button id="btn-src-pick" class="small" title="选入:从其他目录勾选文件 / 目录,复制或移动到当前主目录"></button>
<button id="btn-upload" class="small" title="上传文件到当前目录(也可直接把文件拖到本面板)"></button>
<button id="btn-refresh-files" class="small"></button>

View File

@ -17,6 +17,10 @@ import { dialogConfirm, dialogPrompt, message } from "./dialog.js";
// ───── files(user-rooted,不绑 task) ─────
$("btn-refresh-files").onclick = () => loadFiles();
$("btn-toggle-hidden").onclick = () => {
state.showHiddenDirs = !state.showHiddenDirs;
loadFiles();
};
$("btn-upload").onclick = () => $("upload-input").click();
$("upload-input").addEventListener("change", uploadSelected);
@ -191,8 +195,14 @@ export function scheduleFilesRefresh() {
export async function loadFiles() {
try {
const qs = state.filesPath ? "?path=" + encodeURIComponent(state.filesPath) : "";
const data = await api("GET", "/v1/files" + qs);
const params = new URLSearchParams();
if (state.filesPath) params.set("path", state.filesPath);
if (state.showHiddenDirs && state.taskId) {
params.set("include_hidden", "true");
params.set("task_id", state.taskId);
}
const qs = params.toString();
const data = await api("GET", "/v1/files" + (qs ? "?" + qs : ""));
renderFiles(data);
} catch (e) {
if (e.status === 401) { logout(); return; }
@ -215,6 +225,17 @@ function renderFiles(data) {
const projShort = projName.length > 12 ? projName.slice(0, 11) + "…" : projName;
$("files-proj").textContent = projShort ? "· " + projShort : "· (根目录)";
$("files-proj").title = projName || data.root || "";
const taskWd = state.taskMeta && state.taskMeta.working_dir
? state.taskMeta.working_dir.split("/").filter(Boolean).pop()
: "";
const inTaskTree = !!taskWd && (
data.current === taskWd || (data.current || "").startsWith(taskWd + "/")
);
const hiddenToggle = $("btn-toggle-hidden");
hiddenToggle.hidden = !inTaskTree;
hiddenToggle.setAttribute("aria-pressed", state.showHiddenDirs ? "true" : "false");
hiddenToggle.title = state.showHiddenDirs ? "隐藏点目录" : "显示点目录";
hiddenToggle.setAttribute("aria-label", hiddenToggle.title);
// crumbs root 标"我的"(user_root),更直观;其余原样
const cr = data.crumbs.map((c, i) => {
const label = i === 0 ? "我的" : c.label;

View File

@ -47,6 +47,7 @@ export const state = {
draftWorkingDir: "",
draftNewDirMode: false,
filesPath: "",
showHiddenDirs: false, // 仅当前 task 工作目录子树生效user_root 永不展示
// 同 wd 内除自己外其他活跃 task(run_status in running/cancelling),供 banner 显示
concurrentWarnings: [],
evtSrc: null,

View File

@ -219,6 +219,12 @@ header .who {
letter-spacing: 0.2px;
}
#btn-toggle-hidden[aria-pressed="true"] {
color: var(--accent);
border-color: var(--accent);
background: var(--accent-soft);
}
#pane-left .pane-head + .pane-head {
background: var(--panel);
}

View File

@ -53,9 +53,15 @@ def rel_to(root: Path, target: Path) -> str:
return "" if rel == "." else rel
def enumerate_files(root: Path, current: Path) -> tuple[list[dict], list[dict], bool]:
def enumerate_files(
root: Path,
current: Path,
*,
include_hidden_dirs: bool = False,
) -> tuple[list[dict], list[dict], bool]:
"""枚举 current 下条目 + 拼面包屑。size raw bytes,mtime ISO 串(前端 humanize)。
Dotfile 一律隐藏(`.memory/` 等系统区不暴露给 UI, `/v1/folders` 约定)
dotfile 始终隐藏调用方仅在已验证的 task 工作目录子树内允许展示 dotdir
即使开关开启`.env` 等点文件仍不展示
"""
entries: list[dict] = []
exists = current.exists()
@ -65,7 +71,7 @@ def enumerate_files(root: Path, current: Path) -> tuple[list[dict], list[dict],
except OSError:
raw = []
for p in raw:
if p.name.startswith("."):
if p.name.startswith(".") and not (include_hidden_dirs and p.is_dir()):
continue
try:
st = p.stat()