Compare commits
No commits in common. "e46eb0176614b53b8f7c049c0a39b0eeaf05da3a" and "5bde2445a0abe57335e5e3845e00e895c251298d" have entirely different histories.
e46eb01766
...
5bde2445a0
|
|
@ -31,7 +31,6 @@ zcbot/
|
|||
│ ├── skills.py # SkillRegistry(Anthropic 渐进披露)
|
||||
│ ├── task.py # TaskState
|
||||
│ ├── memory.py # per-user .memory/ 双层记忆
|
||||
│ ├── shortcuts.py # 快捷指令(触发词→完整指令,入口层确定性展开;.memory/shortcuts.md)
|
||||
│ ├── paths.py # task_dir db form 归一(to_db_path / from_db_path)
|
||||
│ ├── storage/{engine,models,utils}.py # SQLAlchemy 2.x ORM
|
||||
│ └── agent_builder.py # 装配 lib:build_agent / system prompt / validate_task_name
|
||||
|
|
@ -119,8 +118,6 @@ 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 子树兜底)。
|
||||
|
||||
**快捷指令 ≠ 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. 模型路由
|
||||
|
|
|
|||
20
PROGRESS.md
20
PROGRESS.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
||||
|
||||
最后更新:2026-07-01(加快捷指令:触发词→完整指令,入口层确定性展开 + bump 0.35.0)
|
||||
最后更新:2026-07-01(ppt skill 工作目录重构:中间物收进隐藏 .build/ + 反卡片映射 + svg_preview 兜底/gate + bump 0.34.0)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -21,24 +21,6 @@
|
|||
|
||||
## 已完成关键能力
|
||||
|
||||
### 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 张,页数从没被真正确认过。根因是行为层:a–h 八条对齐里 b 项(页数)只给「常 8–15 页」区间,又被打包进整批 BLOCKING 确认,用户一句笼统「OK」就整批过、模型自取区间中位数(~12)。修(纯文档):`SKILL.md` b 项改为推**一个具体数字**+ 标为「独立拍板项」;a–h 表后新增「🔒 页数 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)
|
||||
用户反馈"中间产物/文件夹过多"。架构判断:`<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 层)。
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.35.0"
|
||||
__version__ = "0.34.0"
|
||||
|
|
|
|||
|
|
@ -150,15 +150,6 @@ def memory_block(
|
|||
f"\n\n**写到这里**:core → `{base}/core.md`;"
|
||||
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:
|
||||
parts.append("\n### Core (常驻 prompt)\n")
|
||||
parts.append(core)
|
||||
|
|
|
|||
|
|
@ -1,103 +0,0 @@
|
|||
"""用户快捷指令(触发词 → 完整指令)。渠道无关,入口层确定性展开。
|
||||
|
||||
存储:`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()
|
||||
|
|
@ -86,7 +86,7 @@ description: 生成 PowerPoint 演示文稿 (.pptx) 文件。✅ 触发:用户
|
|||
| # | 项 | 默认 |
|
||||
|---|----|------|
|
||||
| a | 画布 | **16:9**(viewBox `0 0 1280 720`)。其它见 canvas-formats.md |
|
||||
| b | 页数 | **独立拍板项(见下方「页数 gate」)**:按内容量 × 投递目的推**一个具体数字**(如「建议 10 页」),不甩「常 8–15」这种区间就想过;**封面 + 正文 + 尾页** |
|
||||
| b | 页数 | 内容量 × 投递目的推导;**封面 + 正文 + 尾页**,常 8–15 页 |
|
||||
| c | 受众 + 核心信息 + 投递目的 | 看材料推断受众;投递目的 `text`(读)/`balanced`(商务,默认)/`presentation`(演讲)定正文字号与密度 |
|
||||
| d | mode + visual_style | mode 选 5 骨架之一;**visual_style 给 ≥3 个候选**(safe/shifted/bold)让用户挑 —— 这是观感主轴 |
|
||||
| e | 配色 | 按 visual_style + 内容**派生 ≥3 套候选**(每套含 bg/primary/accent/text…);自由设计默认 |
|
||||
|
|
@ -94,8 +94,6 @@ description: 生成 PowerPoint 演示文稿 (.pptx) 文件。✅ 触发:用户
|
|||
| 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 |
|
||||
|
||||
> 🔒 **页数 gate(不可默认放行)**:页数是**唯一必须拿到用户明确数字**才能往下走的项。给完 a–h 推荐后,若用户只回笼统的「可以 / OK / 你定」而**没给出、也没逐字认可一个具体张数**,⛔ **必须单独再追问一句「这份就定 N 页,可以吗?」** —— 拿到明确整数(用户报的数,或对你推荐数的显式点头)后,才用这个数去写逐页大纲。**禁止**把区间中位数(如 ~12)当默认值自行敲定、绕过用户。**唯一例外**:用户明确说「页数你随意 / 不重要 / 你定就行」时,按你的推荐数走、不再追问(但仍要在预览里写出这个数,让用户有机会否掉)。逐页大纲的页数 = 已确认的这个数,一页不多一页不少(封面 + 正文 + 尾页含在内)。
|
||||
|
||||
**逐页大纲**(写进 design_spec.md §IX,也是 spec_lock 的 page_rhythm/page_layouts 依据):**论断式标题 + 每页标节奏**(`anchor`/`dense`/`breathing`)。三条硬纪律(大纲阶段定死):
|
||||
- **论断标题**:写结论不写主题("渗透率破 60%" 不是 "行业背景");
|
||||
- **节奏不雷同**:相邻内容页不同版式;narrative 真正停顿处插 `breathing`(单概念/金句/大图,**禁多卡网格**);不要为凑节奏造填充页;
|
||||
|
|
@ -143,7 +141,6 @@ references/visual-styles/<locked-style>.md # 锁定的视觉风格
|
|||
- **任何 `error`(禁用特性 / viewBox 不符 / spec_lock 漂移 / **锁了图标 inventory 却全 deck 0 图标** / **内容 deck 全是文字方块(≥6 页且零 `<path>`/`<polygon>`/`<polyline>`/`<image>`)** 等)必须改:回阶段三重写该页再跑**,不放过。
|
||||
- `warning`(低分辨率图 / 非 PPT 安全字体等):能顺手改就改,否则知会后放行。
|
||||
- 跑 `svg_output/`(不要在 finalize 后跑 —— finalize 改写 SVG 会掩盖源级违规)。
|
||||
- ⚠️ **别用 `| head` / `| tail` 截断质检输出**:管道会把脚本的非零退出码换成 `head` 的 0(门形同虚设),`head` 还会截掉打在**最后**的 deck 级门结论(如零图标 `[ERROR]`)。原样跑,读完整输出、认它的退出码。
|
||||
|
||||
## 阶段五:后处理 + 导出
|
||||
|
||||
|
|
@ -157,20 +154,16 @@ references/visual-styles/<locked-style>.md # 锁定的视觉风格
|
|||
.venv/Scripts/python.exe <skill_dir>/scripts/svg_to_pptx.py <project_dir>
|
||||
# 产物: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`。
|
||||
- 动画可选:`-t fade`(翻页,默认)/ `-a auto`(逐元素入场,**默认 none**,用户要才开)。全表见 animations.md。
|
||||
- 改稿:只改 `spec_lock.md` 的颜色/字体 → `update_spec.py <project_dir>` 传播到所有 SVG;改版式/内容 → 重写对应页 SVG 再跑 5.2–5.3,**不要直接 edit 成品 .pptx**。
|
||||
|
||||
## 阶段六:验收(渲图肉眼/vision 看)—— 全量,不抽查
|
||||
## 阶段六:验收(渲图肉眼/vision 看)
|
||||
|
||||
```
|
||||
.venv/Scripts/python.exe <skill_dir>/scripts/svg_preview.py <project_dir>
|
||||
.venv/Scripts/python.exe <skill_dir>/scripts/svg_preview.py <project_dir> --pages 1,3,5
|
||||
```
|
||||
- **默认渲整本,不带 `--pages`**。抽查 3 页只能覆盖 3 页,错位/文字溢出/元素重叠恰恰藏在没看的那些页里 —— 逐页手写绝对坐标,每页都可能翻车,所以**每页都要过目**。(页数多时可分批渲,但目标是 100% 覆盖,不是采样。)
|
||||
- PNG 默认落 `.build/preview/`(隐藏);优先渲 `.build/svg_final/`(图标/配图已内嵌,最忠实),没有则渲 `svg_output/`(无 chromium 时走 cairosvg 兜底、会就地展开图标)。
|
||||
- `read` / `look_at_image` **逐页**亲眼过:标题层级、卡片过挤/过空、**文字是否溢出卡片/被裁**、**元素是否重叠错位**、图标在不在、节奏是否单调、配图位置。
|
||||
- 🚧 **差评即阻断**:任一页被判出排版/溢出/重叠/半成品问题(哪怕只是封面)→ **回阶段三改那一页 SVG、重渲、复看,直到通过才算验收完**。不许"看了一页差评、跳去看下一页好评就收尾"——那正是错位交付的来路。
|
||||
PNG 默认落 `.build/preview/`(隐藏);优先渲 `.build/svg_final/`(图标/配图已内嵌,最忠实),没有则渲 `svg_output/`(无 chromium 时走 cairosvg 兜底、会就地展开图标)。`read` 渲出的 PNG 亲眼过:封面、一个内容页、一个 breathing 页 —— 看标题层级、卡片过挤/过空、文字是否都正常、节奏是否单调、配图位置。不通过的回阶段三改对应页 SVG 重跑。
|
||||
|
||||
> svg_preview 渲的是 SVG(视觉真相,与导出的 pptx 1:1),比渲最终 pptx 更早更准暴露观感问题。需要校验"SVG→DrawingML 转换是否保真",再开导出的 pptx 在 PowerPoint 里看。
|
||||
|
||||
|
|
@ -204,8 +197,6 @@ references/visual-styles/<locked-style>.md # 锁定的视觉风格
|
|||
- **breathing 页堆多卡网格**(违节奏,显 AI 味)
|
||||
- 模板照搬不重上皮(直接用模板默认渐变/阴影/字号)
|
||||
- 质检没过就交付 / 直接 edit 成品 .pptx 改稿
|
||||
- **只渲/只看几页就收尾**(错位藏在没看的页里);**看到差评却不返工**(封面 vision 说"半成品/挤左侧"还继续导出交付)
|
||||
- **用 `| head` 截断质检或导出输出**(吞非零退出码 + 截掉最后的门结论,门形同虚设)
|
||||
- 起名 `output.pptx` —— 按主题命名
|
||||
|
||||
## 输出
|
||||
|
|
|
|||
|
|
@ -45,9 +45,7 @@ Recommend format based on scenario (see [`canvas-formats.md`](canvas-formats.md)
|
|||
|
||||
### 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 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.
|
||||
**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.
|
||||
|
||||
### c. Key Information Confirmation
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,4 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|||
from svg_to_pptx import main
|
||||
|
||||
if __name__ == '__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())
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -63,46 +63,48 @@ def _recorded_narration_on_click_slides(
|
|||
return blocked
|
||||
|
||||
|
||||
def _deck_locks_icons_but_authors_none(project_path: Path, svg_files: list[Path]) -> bool:
|
||||
"""Detect the export-boundary icon violation.
|
||||
def _warn_if_icons_unused(project_path: Path, svg_files: list[Path]) -> None:
|
||||
"""Export-boundary defense-in-depth (mirrors svg_quality_checker's icon gate).
|
||||
|
||||
Returns True when ``spec_lock.md`` locks an icon library + non-empty
|
||||
inventory but the source SVGs carry ZERO ``<use data-icon>`` placeholders —
|
||||
i.e. the deck would export flat / icon-less despite the strategist intending
|
||||
icons. Returns False otherwise (including on any internal error: detection
|
||||
must never itself break the export path).
|
||||
|
||||
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.
|
||||
If ``spec_lock.md`` locks an icon library + non-empty inventory but the source
|
||||
SVGs carry zero ``<use data-icon>`` placeholders, the deck exports flat /
|
||||
icon-less. Warn loudly on stderr so it isn't silent when someone exports
|
||||
without first running ``svg_quality_checker.py`` (the hard gate). Non-fatal:
|
||||
export still proceeds — the lock may be stale or icons intentionally absent.
|
||||
Fully defensive: any failure here must never break the export.
|
||||
"""
|
||||
try:
|
||||
import re
|
||||
|
||||
lock_path = project_path / 'spec_lock.md'
|
||||
if not lock_path.exists():
|
||||
return False
|
||||
return
|
||||
try:
|
||||
from update_spec import parse_lock
|
||||
icons = (parse_lock(lock_path) or {}).get('icons') or {}
|
||||
except Exception:
|
||||
return False
|
||||
return
|
||||
library = (icons.get('library') or '').strip().lower()
|
||||
inventory = (icons.get('inventory') or '').strip().lower()
|
||||
_empty = ('', 'none', '(none)', '-', 'n/a')
|
||||
if library in _empty or inventory in _empty:
|
||||
return False
|
||||
return
|
||||
total = 0
|
||||
for p in svg_files:
|
||||
try:
|
||||
total += len(re.findall(r'<use\b[^>]*\bdata-icon\s*=', p.read_text(encoding='utf-8')))
|
||||
except Exception:
|
||||
continue
|
||||
return total == 0
|
||||
if 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:
|
||||
return False
|
||||
return
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
|
|
@ -200,12 +202,6 @@ Recorded narration:
|
|||
parser.add_argument('--no-compat', action='store_true',
|
||||
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.add_argument('--only', type=str, choices=['native', 'legacy'], default=None,
|
||||
help='Only generate one version: native (editable shapes) or legacy (SVG image)')
|
||||
|
|
@ -355,30 +351,9 @@ Recorded narration:
|
|||
print("Error: No SVG files found")
|
||||
return 1
|
||||
|
||||
# Export-boundary icon gate: a locked icon inventory with ZERO authored
|
||||
# <use data-icon> means the deck exports flat / icon-less. This is the last
|
||||
# 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
|
||||
# Export-boundary icon check: warn (non-fatal) if an inventory is locked but
|
||||
# no <use data-icon> is authored — defense-in-depth behind the quality gate.
|
||||
_warn_if_icons_unused(project_path, ref_files)
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,82 +0,0 @@
|
|||
"""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
|
||||
|
|
@ -22,28 +22,14 @@ REF_MIME = {
|
|||
}
|
||||
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(
|
||||
rel: str, working_dir: Path, user_root: Optional[Path]
|
||||
) -> Optional[Path]:
|
||||
"""三形态解析 + user_root 边界校验。命中返回解析后的绝对 Path,否则 None。"""
|
||||
rel = (rel or "").strip()
|
||||
p = Path(rel)
|
||||
candidates: list[Path] = []
|
||||
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():
|
||||
if p.is_absolute():
|
||||
candidates.append(p)
|
||||
else:
|
||||
candidates.append(working_dir / rel)
|
||||
|
|
|
|||
12
web/admin.py
12
web/admin.py
|
|
@ -199,14 +199,6 @@ def _user_usage_page(s: Any, page: int, page_size: int, cutoff, sort: str) -> di
|
|||
if cutoff is not None:
|
||||
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()
|
||||
rows = [
|
||||
{
|
||||
|
|
@ -221,15 +213,13 @@ def _user_usage_page(s: Any, page: int, page_size: int, cutoff, sort: str) -> di
|
|||
"tokens_out": int(to or 0),
|
||||
"tokens_cache_hit": int(h 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, last_used in s.execute(
|
||||
for uid, email, name, uname, role, plan, c, ti, to, h, n in s.execute(
|
||||
select(
|
||||
User.user_id, User.email, User.name, User.user_name, User.role, User.plan,
|
||||
cost_sum, tin_sum, tout_sum,
|
||||
func.coalesce(func.sum(hit).filter(chat), 0),
|
||||
func.count(UsageEvent.event_id),
|
||||
last_used_sq.label("last_used_at"),
|
||||
)
|
||||
.join(UsageEvent, join_cond, isouter=True)
|
||||
.group_by(User.user_id, User.email, User.name, User.user_name, User.role, User.plan)
|
||||
|
|
|
|||
18
web/app.py
18
web/app.py
|
|
@ -495,16 +495,6 @@ async def _run_channel_conversation(app, uid, text, attachments, *, channel):
|
|||
await asyncio.to_thread(_wx.reset_channel_context, tid, hard=True)
|
||||
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,保留上一轮
|
||||
# 原文做续聊锚点)。在入站消息落库前判断,故 last_at 取的是上一轮的时间。push 不走这。
|
||||
from core.agent_builder import load_config as _load_config
|
||||
|
|
@ -2359,14 +2349,6 @@ def create_app() -> FastAPI:
|
|||
content = (body.content or "").strip()
|
||||
if not 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:
|
||||
row = s.execute(
|
||||
select(Task.run_status, Task.model_profile)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
// 结构:左侧目录(点击平滑滚动)+ 右侧内容。overview(固定指标)10s 轮询;
|
||||
// 「按模型」「各用户用量」带时间筛选+排序、「各用户用量」「存储」分页 —— 各自独立 fetch、
|
||||
// 自管状态(range/sort/page),overview tick 顺手刷新但不丢状态。导出 PDF 走客户端打印。
|
||||
import { humanSize, fmtTime, fmtTimeAgo, fmtTokens, escapeHtml } from "./format.js";
|
||||
import { humanSize, fmtTime, fmtTokens, escapeHtml } from "./format.js";
|
||||
|
||||
const LS_TOKEN = "zcbot.token";
|
||||
const REFRESH_MS = 10000;
|
||||
|
|
@ -200,14 +200,13 @@ function renderUserUsage(d) {
|
|||
+ `<td class="num">${fmtTokens(r.tokens_out)}</td>`
|
||||
+ `<td class="num">${hitRate}%</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>`;
|
||||
}).join("") || `<tr><td colspan="8" class="empty">无数据</td></tr>`;
|
||||
}).join("") || `<tr><td colspan="7" class="empty">无数据</td></tr>`;
|
||||
$("s-users").innerHTML = `<div class="card">`
|
||||
+ `<div class="card-head"><h2>各用户用量(${rangeLabel(d.range)})</h2>${ctrlHTML("u", d.range, d.sort)}</div>`
|
||||
+ tierLegendHTML()
|
||||
+ `<div class="scroll-x"><table>`
|
||||
+ `<thead><tr><th>用户</th><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></tr></thead>`
|
||||
+ `<tbody>${body}</tbody></table></div>`
|
||||
+ pagerHTML("uu", page, maxPage, from, to, total)
|
||||
+ `</div>`;
|
||||
|
|
|
|||
|
|
@ -1668,7 +1668,6 @@ async function clearMessages(tid, name, nMsg) {
|
|||
state.taskMeta = updated;
|
||||
renderChatMeta();
|
||||
renderMessages([]);
|
||||
state.outline = []; renderOutlineRail(); // 对话清空 → 右侧导航条(目录圆点)同步清空
|
||||
$("chat-hint").textContent = "对话已清空";
|
||||
}
|
||||
loadTaskList();
|
||||
|
|
|
|||
Loading…
Reference in New Issue