feat(changelog): 用户版更新日志 CHANGELOG.md + /v1/changelog + 点版本号弹层(bump 0.55.0)

- 新增仓库根 CHANGELOG.md:面向用户口径(只记可感知变化),回填至 0.30.0 共 25 条
- GET /v1/changelog:公开无鉴权,`## <版本> — <日期>` 正则切段,mtime 缓存热改即生效,无 DB 零 migration
- 前端 changelog.js:右栏底部版本号可点弹更新日志;localStorage 记 last-seen 版本,
  与 /healthz 版本不一致亮红点提示,打开即清;接入 Esc/遮罩关闭栈
- 单测 tests/test_changelog.py 3 项(含真文件格式校验);全量 201 过
- CLAUDE.md 加维护约定:bump 版本若用户可感知顺手补 CHANGELOG 条目

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-09 16:49:26 +08:00
parent 8aa6bed79c
commit 5b6adb3746
9 changed files with 330 additions and 6 deletions

108
CHANGELOG.md Normal file
View File

@ -0,0 +1,108 @@
# 更新日志
> 面向用户的更新说明,由 `GET /v1/changelog` 解析后在前端「点版本号」弹层展示。
> 维护口径:只记**用户可感知**的变化("能干什么了"而不是"改了哪个模块"),纯内部重构 / 修复不记,
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
> 工程口径的完整记录见 `PROGRESS.md` / git log。
## 0.55.0 — 2026-07-09
- 点击右下角版本号可查看更新日志(本弹层);有新版本未看时版本号旁会亮红点提示。
## 0.54.1 — 2026-07-09
- 对话顶部新增上下文用量指示环:随时看到当前对话"装了多少",接近上限会变色提醒。
- 超长对话自动整理:早期内容压缩为摘要,目标、结论和关键数据保留,长对话不再因过长而失效。
## 0.53.1 — 2026-07-08
- 新增「审稿回复」能力:上传决定信 / 审稿意见,协助逐条起草回复信、稿件修改清单和修回 cover letter(中英双语)。
- 论文能力增强:新增图表设计规范与投稿前全文审计;英文 SCI 润色新增分层诊断模式。
## 0.53.0 — 2026-07-08
- 支持上传录音文件转文字:单个文件最长 5 小时,自动区分说话人,适合会议 / 访谈记录整理与总结。
## 0.52.0 — 2026-07-08
- 微信 / 企业微信里可以直接发语音消息对话:自动识别为文字并回显「已识别」,再进入对话。
## 0.49.0 — 2026-07-07
- 语音输入升级为流式识别:边说边出字,松开即得到完整文本,不再等待。
## 0.48.0 — 2026-07-07
- 语音按钮支持「按住说话」:按住录音、松开转写、上滑取消(手机 / 电脑通用);快速点一下则进入免持连续录音。
## 0.47.0 — 2026-07-07
- 网页端新增语音输入:点麦克风按钮说话,识别结果插入输入框(不自动发送,可先编辑)。
## 0.46.2 — 2026-07-07
- 历史消息浏览优化:翻页与目录跳转按提问轮次对齐,不再出现半截的工具消息。
## 0.46.0 — 2026-07-07
- 企业微信新成员未绑定账号时发消息,会自动收到绑定指引,不再无响应。
## 0.45.0 — 2026-07-07
- 手机端显示优化:弹出菜单不再超出屏幕、按钮触控目标加大、管理入口位置统一。
## 0.44.0 — 2026-07-07
- 企业微信内点开应用即自动登录进入,无需再输账号密码。
## 0.43.0 — 2026-07-07
- 对话过程可视化:显示模型的思考过程(可折叠),各阶段实时计时,工具执行显示进度与耗时,不再长时间"静止的思考中"。
## 0.41.0 — 2026-07-06
- 服务更新升级不再中断使用:更新期间正在进行的对话继续完成,无需重来。
## 0.38.4 — 2026-07-03
- 任务列表显示运行状态:运行中的任务带绿色脉冲点,在别处(手机 / 定时任务)启动的运行也实时反映,无需刷新页面。
## 0.37.2 — 2026-07-03
- 对话直播中的文字按轮次分段展示,工具连续执行时不再把正文挤出视野。
## 0.37.0 — 2026-07-03
- PPT 生成质量强化(0.360.38 系列):自动质检对齐网格、图表退化、图标缺失、文字重叠、内容越界等问题,发现即自动返工。
## 0.35.0 — 2026-07-01
- 新增快捷指令:在「记忆」中定义触发词(如"日报"),发送时自动展开为完整指令。
## 0.34.0 — 2026-07-01
- PPT 工作目录精简:中间构建产物收进隐藏目录,文件面板里只看到源文件和交付物。
## 0.33.5 — 2026-06-30
- PPT 新增「商务红」政企风配色预设;素材没有配图时会主动提议 AI 配图(确认后才生成)。
## 0.33.0 — 2026-06-29
- PPT 能力全面重构:逐页设计生成,导出为**原生可编辑**的 PPT(文字、图形、图标均可在 Office 中直接修改),支持模板与品牌配色。
## 0.32.0 — 2026-06-29
- 微信 / 企业微信长会话自动分段:间隔超过 6 小时自动开启新话题(历史仍保留),也可发送「新话题」手动重置。
## 0.31.3 — 2026-06-26
- 消息输入框支持直接拖拽文件;多个附件并列展示,不再互相顶掉。
## 0.31.0 — 2026-06-26
- 模型访问按账户档位开放:不同账户可用的模型清单由管理员按档位配置。
## 0.30.0 — 2026-06-26
- 新增可选模型:豆包 Seed 2.1(turbo / pro / evolving)与 GLM 5.2。

View File

@ -56,6 +56,8 @@ PowerShell here-string `@'...'@` **只在 PowerShell 工具里有效**;用 Bash
- major(`x.0.0`):1.0 正式发版 / 不兼容大重构 - major(`x.0.0`):1.0 正式发版 / 不兼容大重构
- 当前 `0.x` **公测期**,1.0 留给"对外冻结行为 / 正式 GA"那一刻;公测中保持 `0.x` 迭代,minor 走新功能、patch 走修复 - 当前 `0.x` **公测期**,1.0 留给"对外冻结行为 / 正式 GA"那一刻;公测中保持 `0.x` 迭代,minor 走新功能、patch 走修复
**bump 版本时若改动用户可感知 → 顺手在 `CHANGELOG.md` 顶部加条目**(格式 `## <版本> — <日期>` + bullet):口径面向用户("能干什么了"而不是"改了哪个模块"),纯内部重构 / 修复不记——不是每个版本都要有条目。这份由 `GET /v1/changelog` 解析、前端点版本号展示,别写内部模块名 / env 变量 / 部署细节。
**只有以下情况才动 `DESIGN.md`**(避免把工程笔记沉淀成设计): **只有以下情况才动 `DESIGN.md`**(避免把工程笔记沉淀成设计):
- 架构 / 心智模型变化(如 §7.1 task-primary 重写) - 架构 / 心智模型变化(如 §7.1 task-primary 重写)
- 取舍决策推翻或新增(§5 / §7.9 类内容) - 取舍决策推翻或新增(§5 / §7.9 类内容)

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-09(清理 deepseek-chat/reasoner 下线残留描述,bump 0.54.2) 最后更新:2026-07-09(用户版更新日志:CHANGELOG.md + /v1/changelog + 点版本号弹层,bump 0.55.0)
--- ---
@ -23,6 +23,7 @@
### 2026-07 ### 2026-07
- **07-09 / 0.55.0**:**用户版更新日志**(点版本号看"更新了什么"):新增仓库根 `CHANGELOG.md`(面向用户口径,只记可感知变化,回填至 0.30;与工程笔记 PROGRESS 分离——PROGRESS 满是内部模块/env/部署细节不宜外露);`GET /v1/changelog`(公开无鉴权,`## <版本><日期>` 正则切段,mtime 缓存热改即生效,无 DB 零 migration);前端右栏底部版本号变可点 → 更新日志弹层(`changelog.js`,复用 marked+purify 渲染),localStorage 记 last-seen 版本、与 /healthz 版本不一致时版本号旁亮红点、打开即清(公测用户发现更新的唯一入口)。CLAUDE.md 加维护约定:bump 版本若用户可感知顺手补 CHANGELOG 条目。
- **07-09 / 0.54.2**:清理 `deepseek-chat/reasoner` 下线残留描述(旧模型 2026-07 已下线):删 `config/models/deepseek_v4.yaml` 里"可退回 deepseek-chat"的误导注释(照做会打不通),DESIGN 附录迁移提醒改为已完成陈述。全库 grep 确认代码/配置本体无旧 model id 引用。 - **07-09 / 0.54.2**:清理 `deepseek-chat/reasoner` 下线残留描述(旧模型 2026-07 已下线):删 `config/models/deepseek_v4.yaml` 里"可退回 deepseek-chat"的误导注释(照做会打不通),DESIGN 附录迁移提醒改为已完成陈述。全库 grep 确认代码/配置本体无旧 model id 引用。
- **07-09 / 0.54.1**:**对话头部压缩指示环**(用户提出:金额撤下明面,换编辑器式上下文环)——头部用量区改为 `N tok + 环形指示器`:环 = 窗口体量 / 模型容量(`reliable_context×2.5` chars),色阶映射三段机制(绿 <50% 未压缩 / 琥珀 50-85% 压缩区 / 85% 达折叠阈值),折叠过附 `已整理×N`;金额/缓存命中率挪 hover tooltip(taskUsageTooltip 本就有,信息不丢)。数据链: `GET /v1/tasks/{id}` 详情端点(列表不加, 100 ×聚合)新增 `context_window_chars/limit_chars/pressure/folds` 四个 additive 字段(窗口体量 = `sum(length(payload::text)) where idx>=base` 单聚合;容量按 task 模型档案折算,读不出置 None 前端画灰环);② 折叠计费 kind `"chat"→"context_fold"`(`record_chat_usage` kind 参数,prompt_optimize 同范式;折叠次数 = count kind, migration,admin kind 对账折叠成本)——0.54.0 未部署无存量,改名零成本;③ `llm_start` 事件补 `context_limit_chars`,run 中每轮实时刷环,`context_fold` done 事件折叠计数 +1 + 体量骤降环回绿;④ PATCH 模型/清空对话两处 `taskMeta` 整体覆盖点改 merge,保住环数据(清空顺带归零)。`format.js` SVG donut(14px,stroke-dasharray)无依赖。**§8.8 Phase 2 长会话中段折叠摘要**(补齐 IM 常驻会话"单段超长"缺口;Phase 1 gap 分段只砍跨话题累积)——run 起点窗口体量达 `reliable_context×85%`(chars×2.5 折算, 50% 压缩门槛同源常量, `core/context.py`)→ 新模块 `core/context_fold.py` 把窗口中段(保留尾部 ~30%,切点必落 user 消息不劈 tool 配对)折叠为固定模板结构化摘要(目标/约束决定/进展/待办/关键路径·ID·数值原文保留), `tasks.context_summary`(migration 0021)+ 推进 `context_base_idx`,`Session.load` system 后注入仅内存的「[前情摘要]」消息(`_n_head/_base_idx` 元数据维护 idx 映射不变量)。**前缀缓存友好**:摘要调用复用会话自身 prepare 后的消息前缀 + 末尾追加指令(与上一轮 chat 缓存字节一致,近全程 cache hit;切点跨列表用" k user"对齐);增量更新 = 旧摘要本在被折前缀里,指令要求合并。失败零阻塞(warn + 跳过);计费入 usage_events(message_id=NULL);「新话题」硬重置/web 清空对话顺带清摘要,gap 软重置保留前端:新 SSE `context_fold` 事件(start 状态行提示 / done 流内分隔条),顺手补 `warn` 事件渲染(此前 loop 熔断/重复拦截提示在 web 端被静默丢弃)。单测 `tests/test_context_fold.py` 12 (切点/映射/apply_fold/maybe_fold 打桩),全量 198 - **07-09 / 0.54.1**:**对话头部压缩指示环**(用户提出:金额撤下明面,换编辑器式上下文环)——头部用量区改为 `N tok + 环形指示器`:环 = 窗口体量 / 模型容量(`reliable_context×2.5` chars),色阶映射三段机制(绿 <50% 未压缩 / 琥珀 50-85% 压缩区 / 85% 达折叠阈值),折叠过附 `已整理×N`;金额/缓存命中率挪 hover tooltip(taskUsageTooltip 本就有,信息不丢)。数据链: `GET /v1/tasks/{id}` 详情端点(列表不加, 100 ×聚合)新增 `context_window_chars/limit_chars/pressure/folds` 四个 additive 字段(窗口体量 = `sum(length(payload::text)) where idx>=base` 单聚合;容量按 task 模型档案折算,读不出置 None 前端画灰环);② 折叠计费 kind `"chat"→"context_fold"`(`record_chat_usage` kind 参数,prompt_optimize 同范式;折叠次数 = count kind, migration,admin kind 对账折叠成本)——0.54.0 未部署无存量,改名零成本;③ `llm_start` 事件补 `context_limit_chars`,run 中每轮实时刷环,`context_fold` done 事件折叠计数 +1 + 体量骤降环回绿;④ PATCH 模型/清空对话两处 `taskMeta` 整体覆盖点改 merge,保住环数据(清空顺带归零)。`format.js` SVG donut(14px,stroke-dasharray)无依赖。**§8.8 Phase 2 长会话中段折叠摘要**(补齐 IM 常驻会话"单段超长"缺口;Phase 1 gap 分段只砍跨话题累积)——run 起点窗口体量达 `reliable_context×85%`(chars×2.5 折算, 50% 压缩门槛同源常量, `core/context.py`)→ 新模块 `core/context_fold.py` 把窗口中段(保留尾部 ~30%,切点必落 user 消息不劈 tool 配对)折叠为固定模板结构化摘要(目标/约束决定/进展/待办/关键路径·ID·数值原文保留), `tasks.context_summary`(migration 0021)+ 推进 `context_base_idx`,`Session.load` system 后注入仅内存的「[前情摘要]」消息(`_n_head/_base_idx` 元数据维护 idx 映射不变量)。**前缀缓存友好**:摘要调用复用会话自身 prepare 后的消息前缀 + 末尾追加指令(与上一轮 chat 缓存字节一致,近全程 cache hit;切点跨列表用" k user"对齐);增量更新 = 旧摘要本在被折前缀里,指令要求合并。失败零阻塞(warn + 跳过);计费入 usage_events(message_id=NULL);「新话题」硬重置/web 清空对话顺带清摘要,gap 软重置保留前端:新 SSE `context_fold` 事件(start 状态行提示 / done 流内分隔条),顺手补 `warn` 事件渲染(此前 loop 熔断/重复拦截提示在 web 端被静默丢弃)。单测 `tests/test_context_fold.py` 12 (切点/映射/apply_fold/maybe_fold 打桩),全量 198
- **07-08 / 0.53.1**:**新增 rebuttal skill(审稿意见回复 / 修回)+ paper·review 增强**——调研 GitHub nature skill 生态(Yuan1z0825/nature-skills 26.9k★ Apache-2.0、Boom5426/Nature-Paper-Skills MIT)后移植三块:① 新 skill `rebuttal`(填投稿后环节空白):决定信 / 审稿意见 → 逐点回复信 + 稿件修改清单 + 修回 cover letter,拆条分诊(E.x/Rn.m 编号 → 8 类 × 4 档严重度 × 12 动作标签 tracker)→ 逐条策略确认 → **先改稿后写信** → QA 渲染(复用 --profile paper,中英双语);零编造铁律(不虚构实验/行号/图号/"已改"声明,含糊腹稿一律 AUTHOR_INPUT_NEEDED 追问,中文备注九种含糊转换表)、语气纪律(不同意五步窄口径、拒实验只用范围边界不用时间经费)、疑难案例库(两审稿人打架/统计大修/合规 blocking/appeal 识别)、readiness 四档诚实标注;② paper 补 `figure_discipline.md`(一图一论断/panel 角色/锚 panel/主图 vs 补充材料/图例四规则,阶段三必读)+ `submission_audit.md`(六步通读审计抓 quality_check 机检不到的跨章漂移 + 数据可用性声明模式含企业受限场景,阶段六必读);③ review 补 `sci_polish.md` 英文 SCI 润色子模式(四层递进诊断:全文策略→章节职责→段落逻辑→句子润色,章节职责错了不做句子润色;claim/evidence/boundary;中译英先抽命题不逐句翻)。未装:nature 系文献检索/下载类(自有 research/documents/brief 后端对不上)、paper2ppt/paper-to-patent/proposal-writer(已有国内定制版更强)。 - **07-08 / 0.53.1**:**新增 rebuttal skill(审稿意见回复 / 修回)+ paper·review 增强**——调研 GitHub nature skill 生态(Yuan1z0825/nature-skills 26.9k★ Apache-2.0、Boom5426/Nature-Paper-Skills MIT)后移植三块:① 新 skill `rebuttal`(填投稿后环节空白):决定信 / 审稿意见 → 逐点回复信 + 稿件修改清单 + 修回 cover letter,拆条分诊(E.x/Rn.m 编号 → 8 类 × 4 档严重度 × 12 动作标签 tracker)→ 逐条策略确认 → **先改稿后写信** → QA 渲染(复用 --profile paper,中英双语);零编造铁律(不虚构实验/行号/图号/"已改"声明,含糊腹稿一律 AUTHOR_INPUT_NEEDED 追问,中文备注九种含糊转换表)、语气纪律(不同意五步窄口径、拒实验只用范围边界不用时间经费)、疑难案例库(两审稿人打架/统计大修/合规 blocking/appeal 识别)、readiness 四档诚实标注;② paper 补 `figure_discipline.md`(一图一论断/panel 角色/锚 panel/主图 vs 补充材料/图例四规则,阶段三必读)+ `submission_audit.md`(六步通读审计抓 quality_check 机检不到的跨章漂移 + 数据可用性声明模式含企业受限场景,阶段六必读);③ review 补 `sci_polish.md` 英文 SCI 润色子模式(四层递进诊断:全文策略→章节职责→段落逻辑→句子润色,章节职责错了不做句子润色;claim/evidence/boundary;中译英先抽命题不逐句翻)。未装:nature 系文献检索/下载类(自有 research/documents/brief 后端对不上)、paper2ppt/paper-to-patent/proposal-writer(已有国内定制版更强)。

View File

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

50
tests/test_changelog.py Normal file
View File

@ -0,0 +1,50 @@
"""用户版更新日志:CHANGELOG.md 解析器(web/app.py::_parse_changelog)。"""
import unittest
from web.app import _CHANGELOG_PATH, _parse_changelog
SAMPLE = """# 更新日志
> 导语在首个条目前,应被丢弃
## 0.55.0 — 2026-07-09
- 条目 A
## v0.54.1 - 2026-07-09
- 条目 B
- 条目 C
## 0.53.0
- 无日期条目
"""
class ChangelogParseTests(unittest.TestCase):
def test_sample_parses_versions_dates_and_bodies(self) -> None:
es = _parse_changelog(SAMPLE)
self.assertEqual([e["version"] for e in es], ["0.55.0", "0.54.1", "0.53.0"])
self.assertEqual(es[0]["date"], "2026-07-09")
self.assertEqual(es[1]["date"], "2026-07-09") # v 前缀 + 单横线也认
self.assertIsNone(es[2]["date"]) # 日期可省
self.assertEqual(es[0]["body_md"], "- 条目 A")
self.assertIn("条目 C", es[1]["body_md"])
def test_preamble_before_first_entry_is_dropped(self) -> None:
es = _parse_changelog(SAMPLE)
self.assertNotIn("导语", es[0]["body_md"])
def test_real_changelog_file_parses(self) -> None:
# 仓库里的真文件:格式坏了(条目头打错 / 空正文)在 CI 就翻车,不等用户点开才发现
es = _parse_changelog(_CHANGELOG_PATH.read_text(encoding="utf-8"))
self.assertGreater(len(es), 0)
for e in es:
self.assertRegex(e["version"], r"^\d+\.\d+")
self.assertTrue(e["body_md"], f"empty body for {e['version']}")
if __name__ == "__main__":
unittest.main()

View File

@ -15,6 +15,7 @@ import asyncio
import json import json
import mimetypes import mimetypes
import os import os
import re
import tempfile import tempfile
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@ -46,7 +47,7 @@ from starlette.background import BackgroundTask
from core import __version__ from core import __version__
from core.branding import brand_name from core.branding import brand_name
from core.paths import from_db_path, to_db_path from core.paths import ROOT, from_db_path, to_db_path
from core.storage import ( from core.storage import (
NoSubtaskError, NoSubtaskError,
check_no_subtask, check_no_subtask,
@ -106,6 +107,29 @@ def _pptx_lock_for(abs_path: str) -> asyncio.Lock:
# ─────────────────────────── helpers ─────────────────────────── # ─────────────────────────── helpers ───────────────────────────
# 用户版更新日志:仓库根 CHANGELOG.md(面向用户的口径,区别于工程笔记 PROGRESS.md)。
# 按文件 mtime 缓存解析结果 —— 热改文件即生效,不用重启。条目头格式 `## <版本> — <日期>`。
_CHANGELOG_PATH = ROOT / "CHANGELOG.md"
_CHANGELOG_HEAD = re.compile(r"^##\s+v?(\d[\w.\-]*)\s*(?:[—–-]+\s*(\S+))?\s*$")
_changelog_cache: dict[str, Any] = {"mtime": None, "entries": []}
def _parse_changelog(text: str) -> list[dict[str, Any]]:
"""CHANGELOG.md → [{version, date, body_md}](文件序,即新在前);首个 `## ` 前的导语丢弃。"""
entries: list[dict[str, Any]] = []
body: list[str] = []
for line in text.splitlines():
m = _CHANGELOG_HEAD.match(line)
if m:
body = []
entries.append({"version": m.group(1), "date": m.group(2), "body_md": body})
elif entries:
body.append(line)
for e in entries:
e["body_md"] = "\n".join(e["body_md"]).strip()
return entries
def _norm_path(p: str) -> str: def _norm_path(p: str) -> str:
"""跨 OS 显示归一:backslash → forward slash。""" """跨 OS 显示归一:backslash → forward slash。"""
return (p or "").replace("\\", "/") return (p or "").replace("\\", "/")
@ -1334,13 +1358,31 @@ def create_app() -> FastAPI:
return {"status": "ok", "version": __version__, "instance": INSTANCE or None, return {"status": "ok", "version": __version__, "instance": INSTANCE or None,
"brand": BRAND} "brand": BRAND}
@app.get("/v1/changelog", tags=["misc"])
def changelog(limit: int = 20):
"""用户版更新日志(仓库根 CHANGELOG.md 解析,新在前)。
公开端点(无鉴权):内容本就是写给用户看的,登录页脚也展示版本号
只记用户可感知的变化,当前版本可能没有专属条目(纯内部改动不记)
前端:点左栏底部版本号弹层展示, localStorage 红点提示新版本
"""
limit = max(1, min(limit, 100))
try:
mtime = _CHANGELOG_PATH.stat().st_mtime
except OSError:
return {"current": __version__, "entries": []}
if _changelog_cache["mtime"] != mtime:
_changelog_cache["entries"] = _parse_changelog(
_CHANGELOG_PATH.read_text(encoding="utf-8"))
_changelog_cache["mtime"] = mtime
return {"current": __version__, "entries": _changelog_cache["entries"][:limit]}
@app.get("/WW_verify_{token}.txt", include_in_schema=False) @app.get("/WW_verify_{token}.txt", include_in_schema=False)
def wecom_domain_verify(token: str): def wecom_domain_verify(token: str):
"""企业微信「网页授权可信域名」归属校验文件(公开,无需登录)。 """企业微信「网页授权可信域名」归属校验文件(公开,无需登录)。
把企业微信下载的 WW_verify_<token>.txt 放到 ZCBOT_WECOM_VERIFY_DIR 把企业微信下载的 WW_verify_<token>.txt 放到 ZCBOT_WECOM_VERIFY_DIR
(默认 repo ),本路由按文件名在域名根 serve""" (默认 repo ),本路由按文件名在域名根 serve"""
from fastapi.responses import PlainTextResponse from fastapi.responses import PlainTextResponse
from core.paths import ROOT
if not token.isalnum(): # 防路径穿越 if not token.isalnum(): # 防路径穿越
raise HTTPException(404, "not found") raise HTTPException(404, "not found")
vdir = Path(os.getenv("ZCBOT_WECOM_VERIFY_DIR", "").strip() or str(ROOT)) vdir = Path(os.getenv("ZCBOT_WECOM_VERIFY_DIR", "").strip() or str(ROOT))

View File

@ -210,12 +210,50 @@
display: inline-flex; align-items: center; justify-content: center; gap: 6px; display: inline-flex; align-items: center; justify-content: center; gap: 6px;
} }
#rail-resources > button svg { flex-shrink: 0; opacity: .85; } #rail-resources > button svg { flex-shrink: 0; opacity: .85; }
/* 版本号:钉在右侧文件面板底部存储条最左,带细分隔线,纯展示 /* 版本号:钉在右侧文件面板底部存储条最左,带细分隔线;点击弹更新日志,
有新版本未看时右上亮红点(changelog.js 维护 .hasnew)
(垂直居中由 .storage-foot 的 align-items:center 提供;随存储条一起显隐) */ (垂直居中由 .storage-foot 的 align-items:center 提供;随存储条一起显隐) */
#app-version { #app-version {
flex-shrink: 0; font-size: 11px; color: var(--muted); flex-shrink: 0; font-size: 11px; color: var(--muted);
font-family: var(--mono); white-space: nowrap; cursor: default; font-family: var(--mono); white-space: nowrap; cursor: pointer;
padding-right: 8px; border-right: 1px solid var(--border-soft); padding-right: 8px; border-right: 1px solid var(--border-soft);
position: relative;
}
#app-version:hover { color: var(--text); text-decoration: underline; }
#app-version.hasnew::after {
content: ""; position: absolute; top: -3px; right: 3px;
width: 6px; height: 6px; border-radius: 50%; background: #e5484d;
}
/* ───── 更新日志 modal(点版本号弹出)───── */
#changelog-modal { z-index: 112; }
#changelog-modal .card {
width: 640px; max-width: 94vw; max-height: 80vh;
display: flex; flex-direction: column;
}
#changelog-modal h3 {
margin: 0; padding: 12px 16px; font-size: 16px;
border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 8px;
}
#changelog-modal h3 .spacer { flex: 1; }
#changelog-modal h3 svg { opacity: .85; }
#cl-cur { font-size: 12px; color: var(--muted); font-weight: normal; font-family: var(--mono); }
#cl-body { flex: 1; min-height: 0; overflow: auto; padding: 4px 20px 16px; }
.cl-entry { padding: 12px 0; border-bottom: 1px solid var(--border-soft); }
.cl-entry:last-child { border-bottom: none; }
.cl-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 2px; }
.cl-ver { font-family: var(--mono); font-weight: 600; font-size: 14px; }
.cl-badge {
font-size: 11px; color: #fff; background: #2f8a4c;
border-radius: 8px; padding: 1px 7px;
}
.cl-date { font-size: 12px; color: var(--muted); }
.cl-md { font-size: 13px; }
.cl-md ul { margin: 4px 0; padding-left: 18px; }
.cl-md li { margin: 2px 0; }
@media (max-width: 760px) {
#changelog-modal .card { width: 96vw; max-height: 88vh; }
} }
/* ───── 技能查看 modal(两栏 master-detail)───── */ /* ───── 技能查看 modal(两栏 master-detail)───── */
@ -1459,6 +1497,20 @@
</div> </div>
</div> </div>
<!-- ───── 更新日志 modal(点右栏底部版本号弹出)───── -->
<div id="changelog-modal" class="modal">
<div class="card">
<h3>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 11l18-5v12L3 14v-3z"></path><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"></path></svg>
<span>更新日志</span>
<span id="cl-cur"></span>
<span class="spacer"></span>
<button id="cl-close" class="sk-x" title="关闭"></button>
</h3>
<div id="cl-body"></div>
</div>
</div>
<!-- ───── 记忆查看 modal(只读;改记忆走对话)───── --> <!-- ───── 记忆查看 modal(只读;改记忆走对话)───── -->
<div id="memory-modal" class="modal"> <div id="memory-modal" class="modal">
<div class="card"> <div class="card">

View File

@ -0,0 +1,66 @@
// 更新日志弹层:点右栏底部版本号打开。数据 GET /v1/changelog(公开端点,直接 fetch,
// 不走 api() 免绑登录态)。红点:localStorage 记 last seen 版本,与 /healthz 版本不一致
// → 版本号旁亮小红点提示"有更新",打开弹层即视为已看、红点清除。
import { $ } from "./dom.js";
import { escapeHtml } from "./format.js";
import { renderMd, highlightIn } from "./markdown.js";
const SEEN_KEY = "zcbot_seen_version";
let _current = ""; // /healthz 返回的当前版本(main.js loadVersion 灌入)
// main.js loadVersion 拿到版本号后调:决定红点亮不亮(localStorage 不可用时静默不亮)
export function markVersion(v) {
_current = v || "";
const el = $("app-version");
if (!el) return;
let seen = "";
try { seen = localStorage.getItem(SEEN_KEY) || ""; } catch (e) {}
el.classList.toggle("hasnew", !!_current && seen !== _current);
}
export function closeChangelogModal() {
$("changelog-modal").classList.remove("show");
}
async function openChangelogModal() {
const body = $("cl-body");
$("changelog-modal").classList.add("show");
body.innerHTML = '<div class="muted" style="padding:8px;">加载中…</div>';
// 打开即视为"看过":记住当前版本 + 清红点
if (_current) {
try { localStorage.setItem(SEEN_KEY, _current); } catch (e) {}
$("app-version").classList.remove("hasnew");
}
let d;
try {
const r = await fetch("/v1/changelog?limit=50");
if (!r.ok) throw new Error("HTTP " + r.status);
d = await r.json();
} catch (e) {
body.innerHTML = `<div class="err" style="padding:8px;">加载失败: ${escapeHtml(e.message)}</div>`;
return;
}
$("cl-cur").textContent = d.current ? "当前 v" + d.current : "";
const entries = d.entries || [];
if (!entries.length) {
body.innerHTML = '<div class="sk-empty">暂无更新说明。</div>';
return;
}
body.innerHTML = entries.map((e, i) => `
<div class="cl-entry">
<div class="cl-head">
<span class="cl-ver">v${escapeHtml(e.version)}</span>
${i === 0 ? '<span class="cl-badge">最新</span>' : ""}
${e.date ? `<span class="cl-date">${escapeHtml(e.date)}</span>` : ""}
</div>
<div class="cl-md">${renderMd(e.body_md)}</div>
</div>`).join("");
highlightIn(body);
}
// ───── 顶层绑定 ─────
$("app-version").onclick = openChangelogModal;
$("cl-close").onclick = closeChangelogModal;
$("changelog-modal").addEventListener("click", (e) => {
if (e.target.id === "changelog-modal") closeChangelogModal(); // 点遮罩关闭
});

View File

@ -10,6 +10,7 @@ import { closeSkillsModal } from "./skills.js";
import { closeMemoryModal } from "./memory.js"; import { closeMemoryModal } from "./memory.js";
import { closeCronsModal } from "./crons.js"; import { closeCronsModal } from "./crons.js";
import { closeWechatModal } from "./wechat.js"; import { closeWechatModal } from "./wechat.js";
import { closeChangelogModal, markVersion } from "./changelog.js";
import { closeFilePreview, closeMiniPreview } from "./preview.js"; import { closeFilePreview, closeMiniPreview } from "./preview.js";
import { closeSrcPicker, loadFiles } from "./files.js"; import { closeSrcPicker, loadFiles } from "./files.js";
import { loadFolderSuggestions } from "./newtask.js"; import { loadFolderSuggestions } from "./newtask.js";
@ -88,6 +89,7 @@ async function loadVersion() {
const d = await r.json(); const d = await r.json();
const el = $("app-version"); const el = $("app-version");
if (el && d && d.version) el.textContent = "v" + d.version; if (el && d && d.version) el.textContent = "v" + d.version;
if (d && d.version) markVersion(d.version); // 与 localStorage 比对,决定新版本红点
if (d && d.brand) { if (d && d.brand) {
document.title = d.brand; document.title = d.brand;
const hd = document.querySelector("header .brand .title"); const hd = document.querySelector("header .brand .title");
@ -108,6 +110,7 @@ document.addEventListener("keydown", (e) => {
if ($("memory-modal").classList.contains("show")) { closeMemoryModal(); return; } if ($("memory-modal").classList.contains("show")) { closeMemoryModal(); return; }
if ($("crons-modal").classList.contains("show")) { closeCronsModal(); return; } if ($("crons-modal").classList.contains("show")) { closeCronsModal(); return; }
if ($("wechat-modal").classList.contains("show")) { closeWechatModal(); return; } if ($("wechat-modal").classList.contains("show")) { closeWechatModal(); return; }
if ($("changelog-modal").classList.contains("show")) { closeChangelogModal(); return; }
if ($("mini-preview-modal").classList.contains("show")) { closeMiniPreview(); return; } if ($("mini-preview-modal").classList.contains("show")) { closeMiniPreview(); return; }
if ($("src-picker-modal").classList.contains("show")) { closeSrcPicker(); return; } if ($("src-picker-modal").classList.contains("show")) { closeSrcPicker(); return; }
if ($("file-preview-modal").classList.contains("show")) { closeFilePreview(); return; } if ($("file-preview-modal").classList.contains("show")) { closeFilePreview(); return; }