Compare commits

..

6 Commits

Author SHA1 Message Date
caoqianming e46eb01766 feat(shortcuts): 加快捷指令(触发词→完整指令,入口层确定性展开)(bump 0.35.0)
预定义"简报 → 给我输出一份昨日的 AI 新闻简报",任意入口整条打"简报"就展开执行。

关键设计:快捷指令 ≠ memory。memory 是注上下文给模型概率召回的软上下文;快捷词是
入口层、模型跑之前的确定性替换(命中即换、零歧义)。性能上 shortcuts.md 内容永不注
上下文,存再多条平时也是 0 token;触发时进上下文的就是那条完整指令本身。

- core/shortcuts.py(新):shortcuts.md(| 触发词 | 指令 | 两列表)解析 + expand()
  整条 strip()+casefold() 精确匹配展开(与「新话题」魔法命令同风格,不部分匹配)
- web/app.py 两处共用同一 expand:渠道核心 _run_channel_conversation(微信/企业微信)
  + 网页 post_message,起 run 前展开,任意入口行为一致
- core/memory.py memory_block:加一行契约让模型可维护 shortcuts.md;内容不注上下文
- tests/test_shortcuts.py(新):解析 + 展开全覆盖
- DESIGN §3.7 加"快捷指令 ≠ memory"取舍段 + 文件树;PROGRESS 加条目

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:58:55 +08:00
caoqianming c2d24b20b4 fix(ppt): 导出图标门升硬 + 修 svg_to_pptx CLI 退出码不传播 + 验收改全量(bump 0.34.7)
诊断 ppt生成2(966041e5)真实产出的两个缺陷——23 页零图标、多处错位——
根因不是缺 gate 而是 gate 被打穿:

- svg_to_pptx.py 只 main() 不 sys.exit(main()),main() 里所有 return 1
  (图标门/无 SVG/坏路径)全被吞成退出 0(最致命)
- 导出侧图标检查按设计只软 WARN、照常产出
- 模型质检用 `| head` 截断,吞非零退出码 + 截掉打在最后的零图标 [ERROR]
- SKILL.md 验收本就只要求抽查 3 页,错位藏在没看的页里;差评也未阻断

改动:
- svg_to_pptx.py: sys.exit(main()) 传播退出码
- pptx_cli.py: 导出图标门从软 WARN 升为硬门(锁图标却全 deck 零
  <use data-icon> → [ERROR] 退非零、不产出 pptx),加逃生口 --allow-iconless
- SKILL.md: 阶段六验收改「默认渲整本 + 逐页过目 + 差评即阻断返工」,
  阶段四/五/反模式补「别用 | head 截断」「别只看几页」「差评必返工」

合成测试三例(默认拒 / --allow-iconless 放行 / 有图标正常)全过。
仅改 skill 侧,不改动线上跑法;导出门只兜「锁了图标却零引用」,正常 deck 不受影响。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:16:49 +08:00
caoqianming 641c7d58aa fix(tools): look_at_image/seedream 接受容器 /workspace 绝对路径(bump 0.34.6)
docker backend 下系统提示告知主模型一切在 /workspace 下,模型自然产出
/workspace/<wd>/x 绝对路径,但 image_ref.resolve_in_root 不翻译该前缀,
报「图片找不到或越界」。加容器根前缀翻译(与 send_email 的 _resolve_user_file
一致),按字符串前缀判断而非 is_absolute()(Windows 上 /workspace 缺盘符不算
绝对);越界仍靠 relative_to(root) 兜住。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:07:44 +08:00
caoqianming eb9ffd654f feat(admin): 各用户用量表加「最近使用」列(bump 0.34.3)
后端 _user_usage_page 加全量(不随 range 筛选)相关子查询
max(created_at) → last_used_at;前端 renderUserUsage 加列,
fmtTimeAgo 显示 + 全时间戳 title,无用量显示「—」。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:28:34 +08:00
caoqianming d8f71aa7b2 feat(ppt): 页数改为用户必须显式拍板的 gate(bump 0.34.2)
页数原先只给「常 8–15 页」区间又被打包进 a–h 批量确认,用户一句
笼统「OK」就整批过、模型自取区间中位数(~12)。改(纯文档):
- SKILL.md b 项 → 推一个具体数字 + 标为「独立拍板项」
- SKILL.md 新增「🔒 页数 gate」:没给/没显式认可具体张数必须单独
  追问「就定 N 页?」拿到明确整数才写逐页大纲;唯一例外是用户明说
  「页数你随意」时按推荐数走、仍在预览写出供否掉
- strategist.md §b 同步补 Non-defaultable gate 硬约束 + 例外

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:01:51 +08:00
caoqianming 4b1dce6df9 fix(web): 清空对话时同步清空右侧导航条(bump 0.34.1)
clearMessages 成功分支只 renderMessages([]),漏了重置 outline;
切 task 路径有 state.outline=[]; renderOutlineRail(),清空路径补齐。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:13:51 +08:00
15 changed files with 334 additions and 36 deletions

View File

@ -31,6 +31,7 @@ zcbot/
│ ├── skills.py # SkillRegistry(Anthropic 渐进披露) │ ├── skills.py # SkillRegistry(Anthropic 渐进披露)
│ ├── task.py # TaskState │ ├── task.py # TaskState
│ ├── memory.py # per-user .memory/ 双层记忆 │ ├── memory.py # per-user .memory/ 双层记忆
│ ├── shortcuts.py # 快捷指令(触发词→完整指令,入口层确定性展开;.memory/shortcuts.md)
│ ├── paths.py # task_dir db form 归一(to_db_path / from_db_path) │ ├── paths.py # task_dir db form 归一(to_db_path / from_db_path)
│ ├── storage/{engine,models,utils}.py # SQLAlchemy 2.x ORM │ ├── storage/{engine,models,utils}.py # SQLAlchemy 2.x ORM
│ └── agent_builder.py # 装配 lib:build_agent / system prompt / validate_task_name │ └── agent_builder.py # 装配 lib:build_agent / system prompt / validate_task_name
@ -118,6 +119,8 @@ yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` /
**前端记忆面板 = 只读窗口,"改"全走对话(取舍)**:web 左栏「记忆」按钮开只读 modal,直接读 FS 渲染全貌(`GET /v1/memory` 全貌 + `GET /v1/memory/extended/{filename}` 单篇),**故意不提供写/删 API**。理由:① "看全貌"是读、不是 operation —— 走 LLM 反而又贵又只能拿到转述,看地面真相必须直读 FS;② "改"走对话(agent 自管,上文契约)= 单一写入口、自然语言、能合并改写,且用户不会写坏 frontmatter。对照业界:Claude(同为文件式记忆)给全套 view+edit;ChatGPT/Gemini 黑箱式只给看/删、长期不支持内联编辑。我们取"GUI 当眼睛、模型当手":既守住文件式记忆的透明卖点,又不引第二套写代码。后续若"删一条 / prune 臃肿 core.md"这类确定性精确操作摩擦明显,再单加直接的 delete(delete 是唯一廉价且确定性强、值得直连的 mutation,同 ChatGPT 做法)。路径穿越校验收口在 `core/memory.py`(只许 `.memory/extended/` 下扁平 `.md` + resolve 子树兜底)。 **前端记忆面板 = 只读窗口,"改"全走对话(取舍)**:web 左栏「记忆」按钮开只读 modal,直接读 FS 渲染全貌(`GET /v1/memory` 全貌 + `GET /v1/memory/extended/{filename}` 单篇),**故意不提供写/删 API**。理由:① "看全貌"是读、不是 operation —— 走 LLM 反而又贵又只能拿到转述,看地面真相必须直读 FS;② "改"走对话(agent 自管,上文契约)= 单一写入口、自然语言、能合并改写,且用户不会写坏 frontmatter。对照业界:Claude(同为文件式记忆)给全套 view+edit;ChatGPT/Gemini 黑箱式只给看/删、长期不支持内联编辑。我们取"GUI 当眼睛、模型当手":既守住文件式记忆的透明卖点,又不引第二套写代码。后续若"删一条 / prune 臃肿 core.md"这类确定性精确操作摩擦明显,再单加直接的 delete(delete 是唯一廉价且确定性强、值得直连的 mutation,同 ChatGPT 做法)。路径穿越校验收口在 `core/memory.py`(只许 `.memory/extended/` 下扁平 `.md` + resolve 子树兜底)。
**快捷指令 ≠ memory(两种机制,别混)**(`core/shortcuts.py`):触发词 → 完整指令的映射,存 `.memory/shortcuts.md`(`| 触发词 | 指令 |` 两列 md 表)。**关键区别**:memory 是注上下文、给模型**概率召回**的软上下文;快捷指令是入口层、模型跑之前的**确定性替换** —— 每条入站消息先经 `shortcuts.expand(ws, uid, text)` 整条 `strip()+casefold()` 精确匹配,命中即把文本换成完整指令再跑 agent(与「新话题」魔法命令同风格,"帮我出个简报"不误伤)。取舍:① **性能** —— shortcuts.md **内容永不注上下文**(触发靠入口层查表,不靠模型),存再多条平时上下文也是 0,触发时进上下文的就是那条完整指令本身(= 用户本来要打的字),无额外 token;若反过来把它塞进 core.md 让模型概率召回,则既不确定、又每轮烧 token,正是本设计要绕开的坑。② **渠道无关** —— `expand` 在渠道核心 `_run_channel_conversation`(微信/企业微信)与网页 `post_message` 两处共用,任意入口打同一触发词行为一致。③ **维护复用 memory 心智** —— 存储蹭 `.memory/` per-user 壳(agent 已有写权限),`memory_block` 加一行契约让模型在用户说"记个快捷词 X→Y"时写 shortcuts.md;但这行契约只讲"能维护 + 格式",不注文件内容。故:**存储借 memory 的壳,触发逻辑独立且确定**。
--- ---
## 4. 模型路由 ## 4. 模型路由

View File

@ -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-07-01(ppt skill 工作目录重构:中间物收进隐藏 .build/ + 反卡片映射 + svg_preview 兜底/gate + bump 0.34.0) 最后更新:2026-07-01(加快捷指令:触发词→完整指令,入口层确定性展开 + bump 0.35.0)
--- ---
@ -21,6 +21,24 @@
## 已完成关键能力 ## 已完成关键能力
### 2026-07-01 / 加快捷指令(触发词 → 完整指令,渠道无关)(bump 0.35.0)
用户需求:预先定义"简报 → 给我输出一份昨日的 AI 新闻简报",之后任意入口整条打"简报"就展开执行。关键设计判断:**快捷指令不是 memory**——memory 是注上下文给模型概率召回的软上下文,快捷词必须是入口层、模型跑之前的**确定性替换**(命中即换、零歧义、0 额外 token;存再多条平时上下文也是 0)。落地(方案 A:蹭 memory 的 per-user 存储壳、但触发逻辑独立):①新模块 `core/shortcuts.py`——`shortcuts.md`(`| 触发词 | 指令 |` 两列 md 表)解析 + `expand(ws, uid, text)` 整条 `strip()+casefold()` 精确匹配展开(与「新话题」魔法命令同风格,"帮我出个简报"不误伤);②入口接线两处共用同一 `expand`:渠道核心 `_run_channel_conversation`(微信/企业微信自动都覆盖)+ 网页 `post_message`,起 run 前展开;③`core/memory.py memory_block` 加一行契约告诉模型可维护 `shortcuts.md`(用户说"记个快捷词 X→Y"时写),但**内容不注上下文**、触发不问模型。维护沿用 memory 心智(对话里让模型写,无新增管理 UI)。`tests/test_shortcuts.py` 覆盖解析(跳表头/分隔行、首行赢、大小写归一)+ 展开(精确命中、不部分匹配、缺文件、空文本)全过。
### 2026-07-01 / ppt skill 修复 ppt生成2(966041e5):图标门升硬 + CLI 退出码传播 + 验收改全量(bump 0.34.7)
诊断真实产出 `陶瓷资源节点建设方案.pptx`(deepseek-v4-flash 跑)两个缺陷:①23 页零图标(spec_lock 锁了 chunk-filled+inventory 却全 deck 0 个 `<use data-icon>`);②不少错位。根因不是缺 gate 而是 gate 被打穿:(a) `svg_to_pptx.py:22``main()``sys.exit(main())`——**main() 里所有 `return 1`(图标门/无 SVG/坏路径)全被吞成退出 0**,这是最致命的一处;(b) 导出侧图标检查 `_warn_if_icons_unused` 按设计只软 WARN、照常产出;(c) 模型质检时 `svg_quality_checker.py ... | head -30`,管道吞非零退出码 + `head` 截掉打在最后的零图标 `[ERROR]` 结论;(d) 验收阶段 SKILL.md 本就只要求抽查 3 页,23 页里只肉眼看了 2 页,且封面 vision 已报"半成品/错位"仍未返工直接交付。改动:①`svg_to_pptx.py` → `sys.exit(main())`;②`pptx_cli.py` 把导出侧检查从软 WARN 升为**硬门**(锁图标却全 deck 零 `<use data-icon>``[ERROR]` 退非零、不产出 pptx),加显式逃生口 `--allow-iconless`(应对 lock 过期/有意无图标);③SKILL.md 阶段六验收改「默认渲整本、逐页过目、差评即阻断返工」(废掉抽查 3 页),阶段四/五/反模式补「别用 `| head` 截断质检/导出输出」「别只看几页」「看到差评必返工」。合成测试三例(默认拒/`--allow-iconless` 放行/有图标正常)全过。**注:此修仅改 skill 侧,不改动线上跑法**;导出门只兜"锁了图标却零引用",正常有图标 deck 不受影响。
### 2026-07-01 / 修 look_at_image/seedream 拒收容器绝对路径(bump 0.34.6)
现象:docker backend 下主模型被系统提示告知一切都在 `/workspace` 下,自然产出容器绝对路径(如 `/workspace/ppt生成2/ceramic-node/images/cover_bg.png`)喂给 `look_at_image`,却报「图片找不到或越界」,只有改成 working_dir 相对路径才成功。根因:`tools/image_ref.py resolve_in_root`(look_at_image + seedream 共用)只吃「working_dir 相对 / user_root 相对 / 宿主绝对」三形态,唯独不把 `/workspace/<rest>` 翻回宿主 `user_root/<rest>`——而 host-side 的 send_email 早在 `Tool._resolve_user_file` 做了这翻译。改动:`resolve_in_root` 加容器根(`/workspace`)前缀翻译,**按字符串前缀判断而非 `is_absolute()`**(Windows 上 `/workspace/...` 缺盘符不算绝对);越界仍靠原 `relative_to(root)` 兜住(`/workspace/../secret`、`/workspace/../../etc/passwd` 实测仍拒)。这样 look_at_image/seedream 接受的路径形态与 send_email/wechat_push 及系统提示告诉 agent 的口径一致。
### 2026-07-01 / admin 各用户用量加「最近使用」列(bump 0.34.3)
用户需求:admin 页面「各用户用量」表加一列展示每个用户的最近使用时间。改动:`web/admin.py _user_usage_page` 加一个**全量**(不随 range 筛选)的相关子查询 `max(usage_events.created_at)`,新字段 `last_used_at`(ISO 或 null);语义上刻意用全量而非跟着 range 走的 join——否则选 7d/30d 会把更早的真实 last-used 藏掉,列就失去意义。前端 `admin.js renderUserUsage` 加「最近使用」表头 + 单元格,用 `fmtTimeAgo`(相对时间)展示、`fmtTime` 全时间戳作 title 悬浮,无用量用户显示「—」;colspan 7→8。
### 2026-07-01 / ppt 页数必须用户显式拍板(bump 0.34.2)
用户反馈:ppt skill 生成时页数总默认到 ~12 张,页数从没被真正确认过。根因是行为层:ah 八条对齐里 b 项(页数)只给「常 815 页」区间,又被打包进整批 BLOCKING 确认,用户一句笼统「OK」就整批过、模型自取区间中位数(~12)。修(纯文档):`SKILL.md` b 项改为推**一个具体数字**+ 标为「独立拍板项」;ah 表后新增「🔒 页数 gate(不可默认放行)」——用户没给/没显式认可具体张数时必须单独追问「就定 N 页?」拿到明确整数才写逐页大纲,禁止用区间中位数当默认(唯一例外:用户明说「页数你随意」时按推荐数走、仍在预览写出数字供否掉);`strategist.md §b` 同步补 Non-defaultable gate 硬约束。
### 2026-07-01 / web 清空对话同步清空右侧导航条(bump 0.34.1)
用户反馈:web 端「清空对话」后右侧的导航条(msg-outline-rail 目录圆点)没跟着清空,还留着旧轮次锚点。根因:`chat.js` `clearMessages()` 清空后只 `renderMessages([])`,没重置 outline 状态(切 task 路径 line 344 有 `state.outline=[]; renderOutlineRail()`,清空路径漏了)。修:clearMessages 成功分支补一行 `state.outline = []; renderOutlineRail();`,与切 task 同款。
### 2026-07-01 / ppt skill 工作目录重构:中间物收进隐藏 .build/(bump 0.34.0) ### 2026-07-01 / ppt skill 工作目录重构:中间物收进隐藏 .build/(bump 0.34.0)
用户反馈"中间产物/文件夹过多"。架构判断:`<project_dir>` 根把三类混摊了——持久源(sources/images/svg_output/notes/两个 spec)、交付物(exports)、**可再生构建产物(svg_final/preview/backup)**;第三类是 build artifact,不该和源平级。修:新增 `project_utils.build_dir/svg_final_dir/preview_dir/backup_dir` 单一事实源,把 svg_final→`.build/svg_final`、preview→`.build/preview`、backup→`.build/backup/latest`(**只留最新**,不再堆时间戳)。`.build` 是 dotfile → `/v1/files` 自动隐藏 → 用户可见面从 ~11 降到"源+交付物"。改动:finalize_svg / svg_preview(_collect)/ pptx_discovery(`final`→`.build/svg_final`)/ pptx_cli(backup 路径 + rmtree 清旧)+ SKILL 工作目录约定/命令。端到端实测:根目录只剩 exports/+svg_output/,`.build/` 三子目录就位,导出/预览/backup 全正常。 用户反馈"中间产物/文件夹过多"。架构判断:`<project_dir>` 根把三类混摊了——持久源(sources/images/svg_output/notes/两个 spec)、交付物(exports)、**可再生构建产物(svg_final/preview/backup)**;第三类是 build artifact,不该和源平级。修:新增 `project_utils.build_dir/svg_final_dir/preview_dir/backup_dir` 单一事实源,把 svg_final→`.build/svg_final`、preview→`.build/preview`、backup→`.build/backup/latest`(**只留最新**,不再堆时间戳)。`.build` 是 dotfile → `/v1/files` 自动隐藏 → 用户可见面从 ~11 降到"源+交付物"。改动:finalize_svg / svg_preview(_collect)/ pptx_discovery(`final`→`.build/svg_final`)/ pptx_cli(backup 路径 + rmtree 清旧)+ SKILL 工作目录约定/命令。端到端实测:根目录只剩 exports/+svg_output/,`.build/` 三子目录就位,导出/预览/backup 全正常。
> 关于"svg现在能 web 预览、要不要收敛成一个 svg 目录":架构上 svg_output(可编辑源:占位符+相对引用)与 svg_final(自包含编译产物:图标展开+图片 base64)是**两态**、不能合并成一个文件(可编辑 vs 浏览器忠实渲染冲突);但只该暴露一个——svg_output 可见、svg_final 进 .build。终态(下一议题):干掉持久化 svg_final,finalize 纯内存化 + web 忠实预览走"按需 finalize 再 serve",磁盘就一个 svg 目录。本次先做隐藏,未做内存化(牵涉 web 层)。 > 关于"svg现在能 web 预览、要不要收敛成一个 svg 目录":架构上 svg_output(可编辑源:占位符+相对引用)与 svg_final(自包含编译产物:图标展开+图片 base64)是**两态**、不能合并成一个文件(可编辑 vs 浏览器忠实渲染冲突);但只该暴露一个——svg_output 可见、svg_final 进 .build。终态(下一议题):干掉持久化 svg_final,finalize 纯内存化 + web 忠实预览走"按需 finalize 再 serve",磁盘就一个 svg 目录。本次先做隐藏,未做内存化(牵涉 web 层)。

View File

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

View File

@ -150,6 +150,15 @@ def memory_block(
f"\n\n**写到这里**:core → `{base}/core.md`;" f"\n\n**写到这里**:core → `{base}/core.md`;"
f"专题 → `{base}/extended/<slug>.md`\n" f"专题 → `{base}/extended/<slug>.md`\n"
) )
# 快捷指令(与记忆是两套机制):触发词 → 完整指令的映射,存 shortcuts.md。**内容不注上下文**
# (入口层查表展开,不靠你召回),这里只给"能维护 + 格式",让你在用户要建/改快捷词时会写。
parts.append(
f"\n**快捷指令**:用户说\"记个快捷词 X → Y\"/\"把快捷词 X 改成/删掉\"时,维护 "
f"`{base}/shortcuts.md`(先 `read` 再 `edit`)。格式是两列 markdown 表 "
f"`| 触发词 | 完整指令 |`(表头 + `|---|---|` 分隔行 + 每条一行;触发词别含 `|`)。"
f"之后用户在任意入口(网页/微信/企业微信)整条打这个触发词,系统自动展开成完整指令 —— "
f"你无需在对话里替他执行触发,只负责把这行写对。\n"
)
if core: if core:
parts.append("\n### Core (常驻 prompt)\n") parts.append("\n### Core (常驻 prompt)\n")
parts.append(core) parts.append(core)

103
core/shortcuts.py Normal file
View File

@ -0,0 +1,103 @@
"""用户快捷指令(触发词 → 完整指令)。渠道无关,入口层确定性展开。
存储:`workspace/users/<user_id>/.memory/shortcuts.md` memory per-user 存储壳
(同一 workspace 内按 user_id 隔离,agent 已有该目录写权限),** memory 是两种机制**:
- memory 是注进 system prompt给模型**参考**的软上下文(概率召回)
- 快捷指令**不进上下文**:展开发生在入口层模型跑之前 每条入站消息先经 `expand()`
查表,整条精确命中触发词就把文本替换成完整指令再跑 agent所以存再多条,平时上下文也是 0;
触发时进上下文的就是那条完整指令本身(= 用户本来要打的字),无额外 token
维护(agent 自管, memory):用户在对话里说"记个快捷词:X → Y",模型往 shortcuts.md 写一行
(memory 契约里加了一句告诉它格式);触发不靠模型,靠本模块解析,确定零歧义
格式(markdown 两列表,容错解析;表头/分隔行自动跳过):
| 触发词 | 指令 |
|---|---|
| 简报 | 给我输出一份昨日的 AI 新闻简报 |
匹配语义:整条消息 `strip()` + `casefold()` 后与某触发词**精确相等**才展开;
"帮我出个简报" 不命中(当普通消息走)新话题魔法命令同风格,零误伤
触发词含 `|` 会破坏表格解析 约定触发词不含竖线;指令正文含竖线也会被截断,同样避免
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Dict, Optional, Tuple
from uuid import UUID
# 表头行的触发词(解析时跳过,避免把表头当成一条快捷词)
_HEADER_TRIGGERS = {"触发词", "触发", "快捷词", "快捷指令", "命令", "trigger", "shortcut"}
# markdown 表格分隔行的单元格:`---` / `:--` / `:-:` 之类
_SEP_RE = re.compile(r"^:?-+:?$")
def _shortcuts_file(workspace_dir: Path, user_id: UUID) -> Path:
return workspace_dir / "users" / str(user_id) / ".memory" / "shortcuts.md"
def _normalize(s: str) -> str:
return s.strip().casefold()
def _is_separator(cell: str) -> bool:
return bool(_SEP_RE.match(cell.replace(" ", "")))
def parse_shortcuts(text: str) -> Dict[str, str]:
"""解析 shortcuts.md 文本 → {归一化触发词: 完整指令}。纯函数,可测。
容错:只认以 `|` 起头的表格行;跳过分隔行表头行空单元格行;
触发词重复时**先出现者赢**(首行优先,和人读顺序一致)
"""
mapping: Dict[str, str] = {}
for raw in text.splitlines():
line = raw.strip()
if not line.startswith("|"):
continue
cells = [c.strip() for c in line.strip("|").split("|")]
if len(cells) < 2:
continue
trigger, prompt = cells[0], cells[1]
if not trigger or not prompt:
continue
if _is_separator(trigger) and _is_separator(prompt):
continue # 分隔行 |---|---|
key = _normalize(trigger)
if not key or key in _HEADER_TRIGGERS:
continue # 空或表头
mapping.setdefault(key, prompt) # 首行优先
return mapping
def load_shortcuts(workspace_dir: Path, user_id: UUID) -> Dict[str, str]:
"""读该用户 shortcuts.md 并解析;文件不存在 / 读失败 → 空表(不抛,不挡入站)。"""
p = _shortcuts_file(workspace_dir, user_id)
if not p.is_file():
return {}
try:
return parse_shortcuts(p.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError):
return {}
def expand(
workspace_dir: Path, user_id: UUID, text: str
) -> Tuple[str, Optional[str]]:
"""入口层展开:整条 `text` 精确命中某触发词 → 返回 (完整指令, 命中的触发词原文);
未命中 返回 (text 原样, None)空文本直接原样返回
调用点:渠道核心 `_run_channel_conversation` + 网页 `post_message`,共用此函数,
保证任何入口打同一个触发词行为一致
"""
if not text or not text.strip():
return text, None
mapping = load_shortcuts(workspace_dir, user_id)
if not mapping:
return text, None
prompt = mapping.get(_normalize(text))
if prompt is None:
return text, None
return prompt, text.strip()

View File

@ -86,7 +86,7 @@ description: 生成 PowerPoint 演示文稿 (.pptx) 文件。✅ 触发:用户
| # | 项 | 默认 | | # | 项 | 默认 |
|---|----|------| |---|----|------|
| a | 画布 | **16:9**(viewBox `0 0 1280 720`)。其它见 canvas-formats.md | | a | 画布 | **16:9**(viewBox `0 0 1280 720`)。其它见 canvas-formats.md |
| b | 页数 | 内容量 × 投递目的推导;**封面 + 正文 + 尾页**,常 815 页 | | b | 页数 | **独立拍板项(见下方「页数 gate」)**:按内容量 × 投递目的推**一个具体数字**(如「建议 10 页」),不甩「常 815」这种区间就想过;**封面 + 正文 + 尾页** |
| c | 受众 + 核心信息 + 投递目的 | 看材料推断受众;投递目的 `text`(读)/`balanced`(商务,默认)/`presentation`(演讲)定正文字号与密度 | | c | 受众 + 核心信息 + 投递目的 | 看材料推断受众;投递目的 `text`(读)/`balanced`(商务,默认)/`presentation`(演讲)定正文字号与密度 |
| d | mode + visual_style | mode 选 5 骨架之一;**visual_style 给 ≥3 个候选**(safe/shifted/bold)让用户挑 —— 这是观感主轴 | | d | mode + visual_style | mode 选 5 骨架之一;**visual_style 给 ≥3 个候选**(safe/shifted/bold)让用户挑 —— 这是观感主轴 |
| e | 配色 | 按 visual_style + 内容**派生 ≥3 套候选**(每套含 bg/primary/accent/text…);自由设计默认 | | e | 配色 | 按 visual_style + 内容**派生 ≥3 套候选**(每套含 bg/primary/accent/text…);自由设计默认 |
@ -94,6 +94,8 @@ description: 生成 PowerPoint 演示文稿 (.pptx) 文件。✅ 触发:用户
| g | 字体 + 字号 | CJK+Latin 字体栈(栈尾必须是预装字体,见 shared-standards §字体);正文字号按投递目的一个定值;公式策略 mixed/render-all/text-only | | g | 字体 + 字号 | CJK+Latin 字体栈(栈尾必须是预装字体,见 shared-standards §字体);正文字号按投递目的一个定值;公式策略 mixed/render-all/text-only |
| h | 配图 | `none`/`ai`(走 imagegen skill)/`provided`/`placeholder`;ai 要定 image_rendering + image_palette(deck 级锁)。**用户没给图时别默认整本 none**:封面/分节/概念/氛围页主动把 `ai` 配图作为候选提给用户(数据/列表/流程页仍走图表→§VII,不配装饰图);提议免费,只有用户确认后 imagegen 才花钱(成本门见阶段二)。见 strategist.md §h | | h | 配图 | `none`/`ai`(走 imagegen skill)/`provided`/`placeholder`;ai 要定 image_rendering + image_palette(deck 级锁)。**用户没给图时别默认整本 none**:封面/分节/概念/氛围页主动把 `ai` 配图作为候选提给用户(数据/列表/流程页仍走图表→§VII,不配装饰图);提议免费,只有用户确认后 imagegen 才花钱(成本门见阶段二)。见 strategist.md §h |
> 🔒 **页数 gate(不可默认放行)**:页数是**唯一必须拿到用户明确数字**才能往下走的项。给完 ah 推荐后,若用户只回笼统的「可以 / OK / 你定」而**没给出、也没逐字认可一个具体张数**,⛔ **必须单独再追问一句「这份就定 N 页,可以吗?」** —— 拿到明确整数(用户报的数,或对你推荐数的显式点头)后,才用这个数去写逐页大纲。**禁止**把区间中位数(如 ~12)当默认值自行敲定、绕过用户。**唯一例外**:用户明确说「页数你随意 / 不重要 / 你定就行」时,按你的推荐数走、不再追问(但仍要在预览里写出这个数,让用户有机会否掉)。逐页大纲的页数 = 已确认的这个数,一页不多一页不少(封面 + 正文 + 尾页含在内)。
**逐页大纲**(写进 design_spec.md §IX,也是 spec_lock 的 page_rhythm/page_layouts 依据):**论断式标题 + 每页标节奏**(`anchor`/`dense`/`breathing`)。三条硬纪律(大纲阶段定死): **逐页大纲**(写进 design_spec.md §IX,也是 spec_lock 的 page_rhythm/page_layouts 依据):**论断式标题 + 每页标节奏**(`anchor`/`dense`/`breathing`)。三条硬纪律(大纲阶段定死):
- **论断标题**:写结论不写主题("渗透率破 60%" 不是 "行业背景"); - **论断标题**:写结论不写主题("渗透率破 60%" 不是 "行业背景");
- **节奏不雷同**:相邻内容页不同版式;narrative 真正停顿处插 `breathing`(单概念/金句/大图,**禁多卡网格**);不要为凑节奏造填充页; - **节奏不雷同**:相邻内容页不同版式;narrative 真正停顿处插 `breathing`(单概念/金句/大图,**禁多卡网格**);不要为凑节奏造填充页;
@ -141,6 +143,7 @@ references/visual-styles/<locked-style>.md # 锁定的视觉风格
- **任何 `error`(禁用特性 / viewBox 不符 / spec_lock 漂移 / **锁了图标 inventory 却全 deck 0 图标** / **内容 deck 全是文字方块(≥6 页且零 `<path>`/`<polygon>`/`<polyline>`/`<image>`)** 等)必须改:回阶段三重写该页再跑**,不放过。 - **任何 `error`(禁用特性 / viewBox 不符 / spec_lock 漂移 / **锁了图标 inventory 却全 deck 0 图标** / **内容 deck 全是文字方块(≥6 页且零 `<path>`/`<polygon>`/`<polyline>`/`<image>`)** 等)必须改:回阶段三重写该页再跑**,不放过。
- `warning`(低分辨率图 / 非 PPT 安全字体等):能顺手改就改,否则知会后放行。 - `warning`(低分辨率图 / 非 PPT 安全字体等):能顺手改就改,否则知会后放行。
- 跑 `svg_output/`(不要在 finalize 后跑 —— finalize 改写 SVG 会掩盖源级违规)。 - 跑 `svg_output/`(不要在 finalize 后跑 —— finalize 改写 SVG 会掩盖源级违规)。
- ⚠️ **别用 `| head` / `| tail` 截断质检输出**:管道会把脚本的非零退出码换成 `head` 的 0(门形同虚设),`head` 还会截掉打在**最后**的 deck 级门结论(如零图标 `[ERROR]`)。原样跑,读完整输出、认它的退出码。
## 阶段五:后处理 + 导出 ## 阶段五:后处理 + 导出
@ -154,16 +157,20 @@ references/visual-styles/<locked-style>.md # 锁定的视觉风格
.venv/Scripts/python.exe <skill_dir>/scripts/svg_to_pptx.py <project_dir> .venv/Scripts/python.exe <skill_dir>/scripts/svg_to_pptx.py <project_dir>
# 产物:exports/<slug>_<ts>.pptx(原生,读 svg_output/)+ .build/backup/latest/svg_output/(源快照,只留最新) # 产物:exports/<slug>_<ts>.pptx(原生,读 svg_output/)+ .build/backup/latest/svg_output/(源快照,只留最新)
``` ```
- 🚧 **导出边界图标门(硬)**:spec_lock 锁了 `icons.library` + 非空 `inventory` 但全 deck 零 `<use data-icon>` → 导出**直接 `[ERROR]` 退非零、不产出 pptx**(这是最后一道,`| head` 绕不过)。正确做法是回阶段三给内容页补图标重跑;只有 lock 确实过期 / 有意做无图标 deck 才加 `--allow-iconless` 放行。
- ❌ 别用 `cp` 代替 finalize_svg(它做了多步关键处理);❌ 别加 `--only` / 强制 `-s output` - ❌ 别用 `cp` 代替 finalize_svg(它做了多步关键处理);❌ 别加 `--only` / 强制 `-s output`
- 动画可选:`-t fade`(翻页,默认)/ `-a auto`(逐元素入场,**默认 none**,用户要才开)。全表见 animations.md。 - 动画可选:`-t fade`(翻页,默认)/ `-a auto`(逐元素入场,**默认 none**,用户要才开)。全表见 animations.md。
- 改稿:只改 `spec_lock.md` 的颜色/字体 → `update_spec.py <project_dir>` 传播到所有 SVG;改版式/内容 → 重写对应页 SVG 再跑 5.25.3,**不要直接 edit 成品 .pptx**。 - 改稿:只改 `spec_lock.md` 的颜色/字体 → `update_spec.py <project_dir>` 传播到所有 SVG;改版式/内容 → 重写对应页 SVG 再跑 5.25.3,**不要直接 edit 成品 .pptx**。
## 阶段六:验收(渲图肉眼/vision 看) ## 阶段六:验收(渲图肉眼/vision 看)—— 全量,不抽查
``` ```
.venv/Scripts/python.exe <skill_dir>/scripts/svg_preview.py <project_dir> --pages 1,3,5 .venv/Scripts/python.exe <skill_dir>/scripts/svg_preview.py <project_dir>
``` ```
PNG 默认落 `.build/preview/`(隐藏);优先渲 `.build/svg_final/`(图标/配图已内嵌,最忠实),没有则渲 `svg_output/`(无 chromium 时走 cairosvg 兜底、会就地展开图标)。`read` 渲出的 PNG 亲眼过:封面、一个内容页、一个 breathing 页 —— 看标题层级、卡片过挤/过空、文字是否都正常、节奏是否单调、配图位置。不通过的回阶段三改对应页 SVG 重跑。 - **默认渲整本,不带 `--pages`**。抽查 3 页只能覆盖 3 页,错位/文字溢出/元素重叠恰恰藏在没看的那些页里 —— 逐页手写绝对坐标,每页都可能翻车,所以**每页都要过目**。(页数多时可分批渲,但目标是 100% 覆盖,不是采样。)
- PNG 默认落 `.build/preview/`(隐藏);优先渲 `.build/svg_final/`(图标/配图已内嵌,最忠实),没有则渲 `svg_output/`(无 chromium 时走 cairosvg 兜底、会就地展开图标)。
- `read` / `look_at_image` **逐页**亲眼过:标题层级、卡片过挤/过空、**文字是否溢出卡片/被裁**、**元素是否重叠错位**、图标在不在、节奏是否单调、配图位置。
- 🚧 **差评即阻断**:任一页被判出排版/溢出/重叠/半成品问题(哪怕只是封面)→ **回阶段三改那一页 SVG、重渲、复看,直到通过才算验收完**。不许"看了一页差评、跳去看下一页好评就收尾"——那正是错位交付的来路。
> svg_preview 渲的是 SVG(视觉真相,与导出的 pptx 1:1),比渲最终 pptx 更早更准暴露观感问题。需要校验"SVG→DrawingML 转换是否保真",再开导出的 pptx 在 PowerPoint 里看。 > svg_preview 渲的是 SVG(视觉真相,与导出的 pptx 1:1),比渲最终 pptx 更早更准暴露观感问题。需要校验"SVG→DrawingML 转换是否保真",再开导出的 pptx 在 PowerPoint 里看。
@ -197,6 +204,8 @@ PNG 默认落 `.build/preview/`(隐藏);优先渲 `.build/svg_final/`(图标/配
- **breathing 页堆多卡网格**(违节奏,显 AI 味) - **breathing 页堆多卡网格**(违节奏,显 AI 味)
- 模板照搬不重上皮(直接用模板默认渐变/阴影/字号) - 模板照搬不重上皮(直接用模板默认渐变/阴影/字号)
- 质检没过就交付 / 直接 edit 成品 .pptx 改稿 - 质检没过就交付 / 直接 edit 成品 .pptx 改稿
- **只渲/只看几页就收尾**(错位藏在没看的页里);**看到差评却不返工**(封面 vision 说"半成品/挤左侧"还继续导出交付)
- **用 `| head` 截断质检或导出输出**(吞非零退出码 + 截掉最后的门结论,门形同虚设)
- 起名 `output.pptx` —— 按主题命名 - 起名 `output.pptx` —— 按主题命名
## 输出 ## 输出

View File

@ -45,7 +45,9 @@ Recommend format based on scenario (see [`canvas-formats.md`](canvas-formats.md)
### b. Page Count Confirmation ### b. Page Count Confirmation
**Tier-2 (derived).** Page count is not an anchor — recommend it only after the Tier-1 delivery purpose is confirmed, since the same source yields a different count by purpose. Provide a specific page count recommendation based on source document content volume **and the confirmed delivery purpose** (`text` packs denser → the same source fits in fewer pages; `presentation` is one-idea-per-page → the same source may need more) — see §6.1 Content Planning Strategy. The user's confirmed count still wins; delivery purpose governs density and per-page treatment within it. **Tier-2 (derived).** Page count is not an anchor — recommend it only after the Tier-1 delivery purpose is confirmed, since the same source yields a different count by purpose. Provide a **specific number** (e.g. "10 pages"), not a range — a range lets the model silently settle on the midpoint. Base it on source content volume **and the confirmed delivery purpose** (`text` packs denser → the same source fits in fewer pages; `presentation` is one-idea-per-page → the same source may need more) — see §6.1 Content Planning Strategy.
> 🔒 **Non-defaultable gate.** Page count is the one item that MUST be pinned to an explicit user-confirmed integer before the §IX outline is drafted. A blanket "OK / you decide" that does not name or endorse a specific count does **not** clear it — ask once more ("so, N pages?") and wait. Never adopt a range-midpoint (~12) as a silent default. **Sole exception:** if the user explicitly says the count is up to you / doesn't matter, proceed on your recommendation without re-asking — but still surface the number in the preview so they can veto it. The confirmed number is exactly the outline length (cover + body + closing included). The user's number always wins; delivery purpose governs density and per-page treatment within it, never the count itself.
### c. Key Information Confirmation ### c. Key Information Confirmation

View File

@ -19,4 +19,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
from svg_to_pptx import main from svg_to_pptx import main
if __name__ == '__main__': if __name__ == '__main__':
main() # Propagate main()'s return code as the process exit code — otherwise every
# `return 1` guard in main() (icon gate / no-SVG / bad path) silently exits 0
# and callers (and `&&` chains) can't tell success from a refused export.
sys.exit(main())

View File

@ -63,48 +63,46 @@ def _recorded_narration_on_click_slides(
return blocked return blocked
def _warn_if_icons_unused(project_path: Path, svg_files: list[Path]) -> None: def _deck_locks_icons_but_authors_none(project_path: Path, svg_files: list[Path]) -> bool:
"""Export-boundary defense-in-depth (mirrors svg_quality_checker's icon gate). """Detect the export-boundary icon violation.
If ``spec_lock.md`` locks an icon library + non-empty inventory but the source Returns True when ``spec_lock.md`` locks an icon library + non-empty
SVGs carry zero ``<use data-icon>`` placeholders, the deck exports flat / inventory but the source SVGs carry ZERO ``<use data-icon>`` placeholders
icon-less. Warn loudly on stderr so it isn't silent when someone exports i.e. the deck would export flat / icon-less despite the strategist intending
without first running ``svg_quality_checker.py`` (the hard gate). Non-fatal: icons. Returns False otherwise (including on any internal error: detection
export still proceeds the lock may be stale or icons intentionally absent. must never itself break the export path).
Fully defensive: any failure here must never break the export.
The caller turns a True into a fatal abort (unless ``--allow-iconless``).
This mirrors svg_quality_checker's deck-level icon gate, but at the export
boundary it is the LAST line of defense: the quality gate can be reordered
before export or have its non-zero exit swallowed by ``| head``, whereas a
refusal to write the pptx cannot be piped away.
""" """
try: try:
import re import re
lock_path = project_path / 'spec_lock.md' lock_path = project_path / 'spec_lock.md'
if not lock_path.exists(): if not lock_path.exists():
return return False
try: try:
from update_spec import parse_lock from update_spec import parse_lock
icons = (parse_lock(lock_path) or {}).get('icons') or {} icons = (parse_lock(lock_path) or {}).get('icons') or {}
except Exception: except Exception:
return return False
library = (icons.get('library') or '').strip().lower() library = (icons.get('library') or '').strip().lower()
inventory = (icons.get('inventory') or '').strip().lower() inventory = (icons.get('inventory') or '').strip().lower()
_empty = ('', 'none', '(none)', '-', 'n/a') _empty = ('', 'none', '(none)', '-', 'n/a')
if library in _empty or inventory in _empty: if library in _empty or inventory in _empty:
return return False
total = 0 total = 0
for p in svg_files: for p in svg_files:
try: try:
total += len(re.findall(r'<use\b[^>]*\bdata-icon\s*=', p.read_text(encoding='utf-8'))) total += len(re.findall(r'<use\b[^>]*\bdata-icon\s*=', p.read_text(encoding='utf-8')))
except Exception: except Exception:
continue continue
if total == 0: return total == 0
print(
"[WARN] spec_lock locks an icon library + inventory, but the source SVGs "
"contain ZERO <use data-icon> — this deck exports flat / icon-less. "
"Run svg_quality_checker.py and add inventory icons to content pages "
"before delivering.",
file=sys.stderr,
)
except Exception: except Exception:
return return False
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
@ -202,6 +200,12 @@ Recorded narration:
parser.add_argument('--no-compat', action='store_true', parser.add_argument('--no-compat', action='store_true',
help='Disable Office compatibility mode (pure SVG only, requires Office 2019+)') help='Disable Office compatibility mode (pure SVG only, requires Office 2019+)')
parser.add_argument('--allow-iconless', action='store_true', default=False,
help='Allow export even when spec_lock locks an icon inventory but '
'the SVGs author zero <use data-icon> (default: refuse — the deck '
'would render flat / icon-less). Use only for a stale lock or an '
'intentionally icon-less deck.')
mode_group = parser.add_mutually_exclusive_group() mode_group = parser.add_mutually_exclusive_group()
mode_group.add_argument('--only', type=str, choices=['native', 'legacy'], default=None, mode_group.add_argument('--only', type=str, choices=['native', 'legacy'], default=None,
help='Only generate one version: native (editable shapes) or legacy (SVG image)') help='Only generate one version: native (editable shapes) or legacy (SVG image)')
@ -351,9 +355,30 @@ Recorded narration:
print("Error: No SVG files found") print("Error: No SVG files found")
return 1 return 1
# Export-boundary icon check: warn (non-fatal) if an inventory is locked but # Export-boundary icon gate: a locked icon inventory with ZERO authored
# no <use data-icon> is authored — defense-in-depth behind the quality gate. # <use data-icon> means the deck exports flat / icon-less. This is the last
_warn_if_icons_unused(project_path, ref_files) # line of defense (the quality gate can be reordered before export or its
# non-zero exit swallowed by `| head`), so it is FATAL by default — refuse to
# produce a pptx that the strategist's own spec_lock says is wrong.
# --allow-iconless is the explicit escape hatch (stale lock / intentional).
if _deck_locks_icons_but_authors_none(project_path, ref_files):
if args.allow_iconless:
print(
"[WARN] spec_lock locks an icon library + inventory but the source SVGs "
"contain ZERO <use data-icon> — exporting flat / icon-less anyway "
"(--allow-iconless).",
file=sys.stderr,
)
else:
print(
"[ERROR] spec_lock locks an icon library + inventory, but the source SVGs "
"contain ZERO <use data-icon> — this deck would export flat / icon-less.\n"
" Add inventory icons to content pages (KPI / list / process /\n"
" comparison layouts especially), then re-run. If the lock is stale\n"
" or icons are intentionally absent, pass --allow-iconless.",
file=sys.stderr,
)
return 1
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")

82
tests/test_shortcuts.py Normal file
View File

@ -0,0 +1,82 @@
"""core/shortcuts.py:解析 + 入口层展开(纯函数 + 文件读)。"""
from __future__ import annotations
from uuid import uuid4
from core import shortcuts
SAMPLE = """\
# 我的快捷指令
| 触发词 | 指令 |
|---|---|
| 简报 | 给我输出一份昨日的 AI 新闻简报 |
| Standup | Summarize yesterday's commits |
| 简报 | 这条重复应被首行覆盖 |
"""
def test_parse_skips_header_and_separator():
m = shortcuts.parse_shortcuts(SAMPLE)
# 表头「触发词」、分隔行 |---| 都不进表
assert "触发词" not in m
assert "---" not in m
assert "简报" in m
assert m["简报"] == "给我输出一份昨日的 AI 新闻简报" # 首行赢
def test_parse_case_insensitive_key():
m = shortcuts.parse_shortcuts(SAMPLE)
# 触发词归一化用 casefold,英文键存成小写
assert "standup" in m
assert m["standup"] == "Summarize yesterday's commits"
def test_parse_empty_and_garbage():
assert shortcuts.parse_shortcuts("") == {}
assert shortcuts.parse_shortcuts("没有表格\n只是普通文本") == {}
# 单元格缺失 / 只有一列 → 跳过
assert shortcuts.parse_shortcuts("| 只有一列 |") == {}
def _write(tmp_path, user_id, body):
d = tmp_path / "users" / str(user_id) / ".memory"
d.mkdir(parents=True, exist_ok=True)
(d / "shortcuts.md").write_text(body, encoding="utf-8")
def test_expand_exact_match(tmp_path):
uid = uuid4()
_write(tmp_path, uid, SAMPLE)
out, hit = shortcuts.expand(tmp_path, uid, "简报")
assert out == "给我输出一份昨日的 AI 新闻简报"
assert hit == "简报"
# 首尾空格 / 大小写不影响命中
out2, hit2 = shortcuts.expand(tmp_path, uid, " Standup ")
assert out2 == "Summarize yesterday's commits"
assert hit2 == "Standup"
def test_expand_no_partial_match(tmp_path):
uid = uuid4()
_write(tmp_path, uid, SAMPLE)
# 整条不等于触发词 → 原样返回,不展开
out, hit = shortcuts.expand(tmp_path, uid, "帮我出个简报")
assert out == "帮我出个简报"
assert hit is None
def test_expand_missing_file(tmp_path):
uid = uuid4() # 没写文件
out, hit = shortcuts.expand(tmp_path, uid, "简报")
assert out == "简报"
assert hit is None
def test_expand_empty_text(tmp_path):
uid = uuid4()
_write(tmp_path, uid, SAMPLE)
out, hit = shortcuts.expand(tmp_path, uid, " ")
assert out == " "
assert hit is None

View File

@ -22,14 +22,28 @@ REF_MIME = {
} }
MAX_IMAGE_BYTES = 10 * 1024 * 1024 # 单图 10MB(ARK 约束) MAX_IMAGE_BYTES = 10 * 1024 * 1024 # 单图 10MB(ARK 约束)
# docker 沙箱把 user_root bind 到容器内 `/workspace`(见 tools/base.py 同名常量)。
# docker backend 下主模型被告知一切都在 `/workspace` 下,故它自然产出容器绝对路径
# `/workspace/<wd>/x`。look_at_image / seedream 在宿主进程读文件,需和 send_email
# 的 _resolve_user_file 一样把这前缀翻回宿主 user_root,否则宿主上找不到文件。
_CONTAINER_ROOT = "/workspace"
def resolve_in_root( def resolve_in_root(
rel: str, working_dir: Path, user_root: Optional[Path] rel: str, working_dir: Path, user_root: Optional[Path]
) -> Optional[Path]: ) -> Optional[Path]:
"""三形态解析 + user_root 边界校验。命中返回解析后的绝对 Path,否则 None。""" """三形态解析 + user_root 边界校验。命中返回解析后的绝对 Path,否则 None。"""
rel = (rel or "").strip()
p = Path(rel) p = Path(rel)
candidates: list[Path] = [] candidates: list[Path] = []
if p.is_absolute(): is_container = rel == _CONTAINER_ROOT or rel.startswith(_CONTAINER_ROOT + "/")
if user_root is not None and is_container:
# 容器绝对路径 `/workspace/<rest>` → 翻回宿主 `user_root/<rest>`(docker bind)。
# 注意:Windows 上 `/workspace/...` 不被 is_absolute() 认作绝对(缺盘符),故按
# 字符串前缀判断、与宿主 OS 无关;越界仍靠下方 relative_to(root) 兜住。
candidates.append(user_root / rel[len(_CONTAINER_ROOT):].lstrip("/"))
candidates.append(p) # 兼容 tool 真在容器内跑(/workspace 实存)
elif p.is_absolute():
candidates.append(p) candidates.append(p)
else: else:
candidates.append(working_dir / rel) candidates.append(working_dir / rel)

View File

@ -199,6 +199,14 @@ def _user_usage_page(s: Any, page: int, page_size: int, cutoff, sort: str) -> di
if cutoff is not None: if cutoff is not None:
join_cond = and_(join_cond, UsageEvent.created_at >= cutoff) join_cond = and_(join_cond, UsageEvent.created_at >= cutoff)
# 最近使用时间:取全量(不随 range 筛选变),否则 7d/30d 会把更早的真实 last-used 藏掉。
last_used_sq = (
select(func.max(UsageEvent.created_at))
.where(UsageEvent.user_id == User.user_id)
.correlate(User)
.scalar_subquery()
)
total_users = s.execute(select(func.count()).select_from(User)).scalar_one() total_users = s.execute(select(func.count()).select_from(User)).scalar_one()
rows = [ rows = [
{ {
@ -213,13 +221,15 @@ def _user_usage_page(s: Any, page: int, page_size: int, cutoff, sort: str) -> di
"tokens_out": int(to or 0), "tokens_out": int(to or 0),
"tokens_cache_hit": int(h or 0), "tokens_cache_hit": int(h or 0),
"n_events": int(n or 0), "n_events": int(n or 0),
"last_used_at": last_used.isoformat() if last_used else None,
} }
for uid, email, name, uname, role, plan, c, ti, to, h, n in s.execute( for uid, email, name, uname, role, plan, c, ti, to, h, n, last_used in s.execute(
select( select(
User.user_id, User.email, User.name, User.user_name, User.role, User.plan, User.user_id, User.email, User.name, User.user_name, User.role, User.plan,
cost_sum, tin_sum, tout_sum, cost_sum, tin_sum, tout_sum,
func.coalesce(func.sum(hit).filter(chat), 0), func.coalesce(func.sum(hit).filter(chat), 0),
func.count(UsageEvent.event_id), func.count(UsageEvent.event_id),
last_used_sq.label("last_used_at"),
) )
.join(UsageEvent, join_cond, isouter=True) .join(UsageEvent, join_cond, isouter=True)
.group_by(User.user_id, User.email, User.name, User.user_name, User.role, User.plan) .group_by(User.user_id, User.email, User.name, User.user_name, User.role, User.plan)

View File

@ -495,6 +495,16 @@ async def _run_channel_conversation(app, uid, text, attachments, *, channel):
await asyncio.to_thread(_wx.reset_channel_context, tid, hard=True) await asyncio.to_thread(_wx.reset_channel_context, tid, hard=True)
return "已开启新话题,之前的对话已归档(网页端仍可查看完整历史)。" return "已开启新话题,之前的对话已归档(网页端仍可查看完整历史)。"
# 快捷指令展开(渠道无关,见 core/shortcuts.py):整条精确命中触发词 → 文本换成完整指令
# 再照常跑;不进上下文、不问模型。放「新话题」命令之后、附件/gap 之前:展开后的文本仍会
# 被下面的附件行追加,故打「简报」+ 附图也成立。
from core.agent_builder import resolve_workspace as _resolve_ws
from core import shortcuts as _shortcuts
_ws = await asyncio.to_thread(_resolve_ws, None)
text, _hit = await asyncio.to_thread(_shortcuts.expand, _ws, uid, text)
if _hit:
print(f"[shortcut] {str(uid)[:8]} '{_hit}' expanded")
# 自动分段:距上次消息超过 gap 阈值 → 软重置(base=最后一条 user 消息 idx,保留上一轮 # 自动分段:距上次消息超过 gap 阈值 → 软重置(base=最后一条 user 消息 idx,保留上一轮
# 原文做续聊锚点)。在入站消息落库前判断,故 last_at 取的是上一轮的时间。push 不走这。 # 原文做续聊锚点)。在入站消息落库前判断,故 last_at 取的是上一轮的时间。push 不走这。
from core.agent_builder import load_config as _load_config from core.agent_builder import load_config as _load_config
@ -2349,6 +2359,14 @@ def create_app() -> FastAPI:
content = (body.content or "").strip() content = (body.content or "").strip()
if not content: if not content:
raise HTTPException(400, "empty content") raise HTTPException(400, "empty content")
# 快捷指令展开(与渠道入口共用 core/shortcuts.py):整条精确命中触发词 → 换成完整
# 指令。在起 run 之前、落库之前展开,模型看到的就是完整指令(不进上下文、不问模型)。
from core.agent_builder import resolve_workspace as _resolve_ws
from core import shortcuts as _shortcuts
_ws = await asyncio.to_thread(_resolve_ws, None)
content, _sc_hit = await asyncio.to_thread(_shortcuts.expand, _ws, user_id, content)
if _sc_hit:
print(f"[shortcut] {str(user_id)[:8]} '{_sc_hit}' expanded")
with session_scope() as s: with session_scope() as s:
row = s.execute( row = s.execute(
select(Task.run_status, Task.model_profile) select(Task.run_status, Task.model_profile)

View File

@ -3,7 +3,7 @@
// 结构:左侧目录(点击平滑滚动)+ 右侧内容。overview(固定指标)10s 轮询; // 结构:左侧目录(点击平滑滚动)+ 右侧内容。overview(固定指标)10s 轮询;
// 「按模型」「各用户用量」带时间筛选+排序、「各用户用量」「存储」分页 —— 各自独立 fetch、 // 「按模型」「各用户用量」带时间筛选+排序、「各用户用量」「存储」分页 —— 各自独立 fetch、
// 自管状态(range/sort/page),overview tick 顺手刷新但不丢状态。导出 PDF 走客户端打印。 // 自管状态(range/sort/page),overview tick 顺手刷新但不丢状态。导出 PDF 走客户端打印。
import { humanSize, fmtTime, fmtTokens, escapeHtml } from "./format.js"; import { humanSize, fmtTime, fmtTimeAgo, fmtTokens, escapeHtml } from "./format.js";
const LS_TOKEN = "zcbot.token"; const LS_TOKEN = "zcbot.token";
const REFRESH_MS = 10000; const REFRESH_MS = 10000;
@ -200,13 +200,14 @@ function renderUserUsage(d) {
+ `<td class="num">${fmtTokens(r.tokens_out)}</td>` + `<td class="num">${fmtTokens(r.tokens_out)}</td>`
+ `<td class="num">${hitRate}%</td>` + `<td class="num">${hitRate}%</td>`
+ `<td class="num">${r.n_events || 0}</td>` + `<td class="num">${r.n_events || 0}</td>`
+ `<td title="${escapeHtml(fmtTime(r.last_used_at))}">${r.last_used_at ? fmtTimeAgo(r.last_used_at) : "—"}</td>`
+ `</tr>`; + `</tr>`;
}).join("") || `<tr><td colspan="7" class="empty">无数据</td></tr>`; }).join("") || `<tr><td colspan="8" class="empty">无数据</td></tr>`;
$("s-users").innerHTML = `<div class="card">` $("s-users").innerHTML = `<div class="card">`
+ `<div class="card-head"><h2>各用户用量(${rangeLabel(d.range)}</h2>${ctrlHTML("u", d.range, d.sort)}</div>` + `<div class="card-head"><h2>各用户用量(${rangeLabel(d.range)}</h2>${ctrlHTML("u", d.range, d.sort)}</div>`
+ tierLegendHTML() + tierLegendHTML()
+ `<div class="scroll-x"><table>` + `<div class="scroll-x"><table>`
+ `<thead><tr><th>用户</th><th>档位</th><th>成本</th><th>输入</th><th>输出</th><th>缓存命中</th><th>事件</th></tr></thead>` + `<thead><tr><th>用户</th><th>档位</th><th>成本</th><th>输入</th><th>输出</th><th>缓存命中</th><th>事件</th><th>最近使用</th></tr></thead>`
+ `<tbody>${body}</tbody></table></div>` + `<tbody>${body}</tbody></table></div>`
+ pagerHTML("uu", page, maxPage, from, to, total) + pagerHTML("uu", page, maxPage, from, to, total)
+ `</div>`; + `</div>`;

View File

@ -1668,6 +1668,7 @@ async function clearMessages(tid, name, nMsg) {
state.taskMeta = updated; state.taskMeta = updated;
renderChatMeta(); renderChatMeta();
renderMessages([]); renderMessages([]);
state.outline = []; renderOutlineRail(); // 对话清空 → 右侧导航条(目录圆点)同步清空
$("chat-hint").textContent = "对话已清空"; $("chat-hint").textContent = "对话已清空";
} }
loadTaskList(); loadTaskList();