Commit Graph
54 Commits
Author SHA1 Message Date
Steven 3e29ef5ee2 docs(roadmap): 知识标签与删除的生产实拍,附截图脚本
CI / backend (push) Canceled after 0s
2026-11-06 02:20:09 +08:00
Steven 690779c5fb fix(wechat): 公众号新版页面抓不到正文,改从 cgiDataNew 里读
CI / backend (push) Canceled after 0s
用户发来 https://mp.weixin.qq.com/s/ZEOsi4R4JcmZ_mhS6qpcww,存进库的正文只有 67 个字符
("原文链接 + 一个句号")。复盘发现公众号换了渲染方式:新版页面把整篇塞进
window.cgiDataNew 这个 JS 对象由前端渲染,页面里根本没有 id="js_content";
三种 UA 拿到的字节数完全一样,也不是 UA 的问题。

- 从 JS 字符串字面量里读正文(content_noencode / desc),按 \xNN、\uNNNN 反转义,
  话题标签是双重转义过的 HTML,先 unescape 再走 DOM。
- 配图字段排在文字之后,所以前后各找 2500 字符合图;剩下没用过的 mmbiz 图按
  12 张上限补在末尾(图文型文章靠就近配对会漏)。
- 解析顺序:旧 DOM → 新版 JS 数据 → 通用抽取,一层层退。

实测两篇:2373 字符 + 12 张图、2772 字符 + 13 张图,作者也都取到了。

同时补上用户提的两件:

- 知识能删了:DELETE /knowledge/{id}?confirm=true,软删 + 真删检索切片,
  只有作者或 owner/admin 能删;列表卡片(悬停出现)和阅读页动作行各一个入口。
  删完详情也回 7001,否则收藏的链接还能打开一篇已删除的知识。
- 分类下面有标签清单了:GET /knowledge/tags 按标签数"有多少条知识用了它",
  侧栏渲染成 #标签 + 条数,点一下筛选、再点取消。
2026-11-06 02:08:36 +08:00
Steven 626f83fb9c feat(wechat): 微信里发一条链接,正文和配图抓下来存成知识
CI / backend (push) Canceled after 0s
公众号的文章也能抓:走 mp.weixin.qq.com 的 DOM(#js_content),
按 data-src 取图,不靠通用抽取器——它会把公众号的图全丢掉。

正文里的外链图下载进我们自己的存储(最多 12 张、单张 8 MB),
地址改成 /attachments/{id}/raw?签名:公众号那边一换图,外链就是一排红叉。

抓取入口谁都能触发,所以只允许公网地址(内网段、回环、链路本地、
保留段、解析不出的域名一律拒绝),否则就是个 SSRF 跳板。

抓不到要说人话:"这个链接没抓下来(原因)。想直接记成任务的话,
加「任务:」前缀再发一次。" 反爬验证页、SPA 空壳都走这条路。

- 新增 services/article.py:extract_url / parse / fetch / store_images / compose_body
- wechat_intake:整条消息是链接时走抓取,落知识草稿,回「已存成知识草稿:标题」
- 依赖 trafilatura==2.0.0(装法见 requirements.txt 注释:必须逐个装再 --no-deps)
- 测试 9 例:链接判定、公众号解析、验证页、通用页裁导航、SSRF 拒绝、图片本地化、失败提示
2026-11-06 00:54:21 +08:00
Steven 2170a0664d fix(integrations): 重启后机器人要能自己接回来
CI / backend (push) Canceled after 0s
上一版把双进程抢会话修了,但生产重启后仍然不收消息:`start_all()` 是进程启动时的
查询,那一刻没有工作区上下文,`bot_channel` 的 RLS 把行全藏起来,一行都查不到,
于是"页面上显示已连接,实际上没人在拉消息"。

- 迁移 0016 补一条只读策略:`current_setting('app.bot_list') = 'on'` 时允许 SELECT
  (只开 SELECT,防止顺手拿它改别人的通道);
- `start_all()` 走 `active_channel_ids()`,先设这个 GUC 再列通道;
- 补测试 `test_startup_sees_logged_in_channels`。
2026-11-05 23:33:44 +08:00
Steven 1e8cd01b3d fix(integrations): 扫码确认后不再 500,回话不带链接,栏目改叫「机器人」
CI / backend (push) Canceled after 0s
1. 扫码确认那一刻页面报「服务器内部错误」:`bot_channel` 的轮询口子直接
   `current_setting('app.bot_channel', true)::uuid`,而事务级 set_config 提交后
   会把连接上的变量还原成空串而不是 NULL,空串 cast 就 InvalidTextRepresentation。
   迁移 0015 改成 nullif(...,'')::uuid,与 app_ws_id() 同一写法。
2. 微信回话只回「已保存任务:…」/「已保存知识草稿:…」,不再带站点链接
   (PUBLIC_BASE_URL 是内网地址,手机上打不开)。
3. 栏目名「接入」改名「机器人」;页面标题与提示文案同步。

pytest 151 passed;ruff check/format 干净;tsc 0;vite build 通过;
web/e2e/integrations.mjs 7 项全绿。
2026-11-05 23:22:49 +08:00
Steven 8436c0edbf feat(integrations): 微信要先扫码配对,改成 iLink 扫码登录 + 长轮询
CI / backend (push) Canceled after 0s
上一版按"webhook + URL 里带令牌"自己编了一套,用户当场指出微信必须先扫码配对。
现在照腾讯 iLink Bot 协议自己做客户端:取二维码 → 手机扫码确认 → 存加密登录态 →
后端长轮询收消息 → 用 context_token 回话。独立实现,不依赖 DSH 的运行环境。

后端
- wechat_ilink.py:get_bot_qrcode / get_qrcode_status / getupdates / sendmessage,
  bot_token 用 Fernet(密钥由 JWT_SECRET 派生)加密入库,日志与接口都不回明文
- wechat_intake.py:一句话 → 任务或知识草稿,前缀识别、#标签、首行标题
- wechat_bot.py:每条通道一个轮询任务,启动时按 status='active' 恢复;
  message_id 去重;errcode -14 转 expired 让用户重新扫
- api/integrations.py 重写成扫码三接口 + 停用/断开/最近消息
- 迁移 0013(登录态列)、0014(轮询入口按 app.bot_channel 主键的 RLS 口子)

Web
- 接入页改成扫码流程:页面里画出二维码、每 2 秒问一次状态、连上显示微信用户与最近消息

验证
- pytest 149 passed(新增 test_wechat_bot 9 例)
- ruff check / ruff format --check 干净;tsc 0;vite build ✓
- web/e2e/integrations.mjs 7 项 ALL PASS(拿的是微信真二维码)

教训:接入第三方协议先去已有实现里找协议,别从直觉倒推。
2026-11-05 23:03:18 +08:00
Steven d21394a5ac docs(roadmap): 补微信接入的生产实拍,记下回调地址漏前缀的坑
CI / backend (push) Canceled after 0s
2026-11-05 22:08:24 +08:00
Steven c194609193 fix(integrations): 回调地址漏了 /api/v1,生产上打过去是 405
CI / backend (push) Canceled after 0s
生产把应用挂在域名根、路由带 /api/v1 前缀,request.base_url 里没有前缀,
拼出来的地址少了这一段;本地被 Vite 的代理掩盖了。改成从 base_url 截站点根、
前缀一律取 settings.api_prefix,并补一条断言把这个差异钉住。
2026-11-05 22:06:37 +08:00
Steven 90b99f8253 feat(integrations): 微信里发一句话,变成一条任务或知识
CI / backend (push) Canceled after 0s
一条通道一张令牌,机器人把消息 POST 到 /integrations/clawbot/{token},
正文按前缀落到任务或知识草稿:没有前缀就是任务,「知识:」「笔记:」记成知识,
「帮助」只回用法。首行当标题、其余当正文,`#标签` 摘出来挂到事项上。
同一个 msg_id 只处理一次——微信会重推,重推不能变成两条任务。

为什么不塞进 system_setting 的 JSON:一个工作区可能挂多个通道,还得能单独停用。
令牌库里只存 sha256,只在创建/重新生成时显示一次;收消息时还不知道是哪个工作区,
所以 bot_channel 的 RLS 额外放行 token_hash = current_setting('app.bot_token_hash'),
读到通道再 bind_tenant 进上下文,按通道创建者记账。

- 后端:models_integrations.py(bot_channel / bot_message)、迁移 0012(RLS)、
  api/integrations.py(收消息 + 管理接口)、错误码 9002/9003
- Web:/w/:wsId/integrations「接入」页——建通道、复制回调地址与令牌、最近消息、
  停用/换令牌/删除,只有 owner/admin 能改
- 测试:test_clawbot.py 10 例;web/e2e/integrations.mjs 7 项(冒充 clawbot 真推一条)

验证:pytest 150 passed,ruff check/format 全绿,tsc 0,vite build 通过,e2e 7 项全绿。
2026-11-05 22:04:34 +08:00
Steven 2c338490e5 fix(auth): 令牌到期自动续上,不再用半小时就被踢回登录页
CI / backend (push) Canceled after 0s
服务端一直签 access(30 分钟)和 refresh(14 天)两个令牌,但两个客户端拿到 401 都是直接清
令牌跳登录页,refresh 从来没被用过——"14 天有效"只是纸面参数,真实寿命就是 30 分钟。

- web/client.ts:请求前剩不到 90 秒先换;撞 401 先换再重放一次原请求;并发共用一次刷新;
  上传附件同一条路
- android/Api.kt:call 拆 call/callOnce,2002 时用 Mutex 串行刷新后重放
- 后端 /auth/refresh 看一眼账号状态:停用的账号不能靠 refresh 一直续(2012)
- 新增 session-refresh.mjs:假 access + 真 refresh 要能留住人,两个都假才回登录页
2026-11-03 22:53:43 +08:00
Steven 02ef47e753 docs(roadmap): 换成放宽后的生产阅读页实拍
CI / backend (push) Canceled after 0s
2026-11-03 12:25:19 +08:00
Steven da31083df2 fix(knowledge): 阅读页正文列放宽,宽屏上不再留白比字多
CI / backend (push) Canceled after 0s
1440 的屏幕上 40rem 正文只有 640px,两边各空出半屏,用户直接发截图说"留白比内容都多"。
正文列 56rem(约 55 个汉字一行)、卡片 78rem,行宽到头但不用来回扫行。
e2e 的宽度断言跟着改成 720–920px,避免以后再窄回去没人拦。
2026-11-03 12:23:53 +08:00
Steven 35fdea7260 test(web): e2e 与投图脚本改成密码登录,补生产的阅读页实拍
CI / backend (push) Canceled after 0s
生产短信登录已经关了,脚本里那条发码路径跑不通;统一走 PASSWORD,读不到才回落到短信。
2026-11-03 12:15:31 +08:00
Steven d34a6ac69e feat(knowledge): 阅读改成独立整页,抽屉只用来编辑
CI / backend (push) Canceled after 0s
知识原来一律在抽屉里打开,正文贴着屏幕右半边,长文读十几屏眼睛一直在右边。
阅读是「坐下来读一会儿」,编辑是「改两句就走」,两件事不该共用一个容器。

- 新增阅读页 /w/:wsId/knowledge/:itemId:正文列限到 40rem 居中,28px 标题,
  更新/发布/切片行,标签与来源,动作行放编辑与发布/归档/恢复
- 列表页与搜索结果改成跳阅读页,不再弹抽屉
- KnowledgeDrawer 加 initialEditing:进来就是编辑器,保存或取消直接关
- 文案常量搬到 lib/knowledge.ts,列表页与阅读页共用一份

顺带修三个 e2e 脚本:新建后进阅读页没有「关闭」按钮可点、侧栏色点按钮让树
顺序断言多出空串、翻页脚本还在用已关闭的短信登录(改密码登录)。
2026-11-03 12:12:51 +08:00
Steven 127303b7aa docs(roadmap): 记下邮箱注册的生产验收,附注册页实拍
CI / backend (push) Canceled after 0s
2026-11-03 01:06:59 +08:00
Steven b5170ad1ce feat(auth): 邮箱验证码注册,生产重新有了自助入口
CI / backend (push) Canceled after 0s
短信登录关掉后,生产没有任何注册入口——这一版补上最普通的那条路:
填邮箱、收验证码、设密码,注册完直接进首页。

先发码再注册,不是"注册时顺带发信"

  POST /auth/email/code 只做三件事:查重、限流、发码。
  填完密码才被告知"这个邮箱注册过了"最气人,所以占用(2019)在发码前就回。

邮箱既是账号名也是唯一联系方式,所以必须真验一次码

  验证通过时间落 app_user.email_verified_at,/auth/me 回 email_verified。
  "随手填一个"会让账号变成没人能找回的孤岛。

app_user.phone 改为可空(迁移 0011)

  邮箱注册的账号本来就没有手机号。user.phone 对这类账号回空串,
  成员列表同理,不再伪装成一串星号。

防刷

  同邮箱 60 秒一次(2022)、验证码 10 分钟有效、错 5 次即作废(2021)。
  6 位数可以被刮,不设上限等于把注册口子交给脚本。

本机联调开关 EMAIL_CODE_DEV_ECHO

  为 true 时把码回给调用方且不发真信——否则每跑一次 e2e 就往真实收件箱塞一封。
  生产打开会让 validate_runtime() 拒绝启动。

前端

  新增 /register(邮箱、验证码、密码、确认、昵称;60 秒倒计时;
  服务端关着注册时整页只说"联系管理员开通"),登录页按 /auth/methods
  决定要不要显示「还没有账号?用邮箱注册」。

验证

  pytest 139 passed、ruff check / format 全绿
  tsc --noEmit 0、vite build 通过
  e2e register.mjs 7 项:登录页入口 → 拿码 → 注册落进首页 → 退出用邮箱+密码登回来
    → 同一邮箱再发码回 2019 → 控制台零报错
  e2e login-email-smtp.mjs 15 项回归通过(含真发一封测试信)
  e2e account.mjs 14 项、knowledge.mjs 全绿

顺带修的两处测试

  test_account 的字段白名单补 email_verified(接口确实多了这个字段);
  test_config_prod 用 Settings(_env_file=None, ...) 构造,别被本机 .env 里的
  EMAIL_CODE_DEV_ECHO=true 带偏——那个开关在生产是硬错误,本机却是跑 e2e 必需的。
2026-11-03 01:04:26 +08:00
Steven c0c5230fd1 docs(roadmap): 补登录方式与系统设置的上线情况,附生产实拍
CI / backend (push) Canceled after 0s
2026-11-03 00:07:53 +08:00
Steven 7558637567 feat(auth): 短信登录默认关,换成手机号/邮箱 + 密码,发信邮箱进后台
CI / backend (push) Canceled after 0s
用户要求:内测的固定验证码 123456 等于"知道手机号就能进任何账号",先关掉;
登录改用账号密码,并多一种"邮箱当账号名"的方式;发信邮箱要能在后台改。

短信那条路(默认关):
- `SMS_LOGIN_ENABLED=false`,`/auth/sms/send` 与 `/auth/login` 统一回 2017,不是静默失败;
- 生产校验改成"开着短信登录就必须配真通道",不开时 provider 是 dev 也不拦;
- 新增 `GET /auth/methods`,登录页据此渲染,关着的路不出现(少一次"点进去才发现不能用")。

邮箱登录:
- `app_user.email` + `uq_app_user_email`(`lower(email)` 部分索引),登录与绑定都归一成小写;
- `POST /auth/login/password` 收 `phone` 或 `email`(二选一),失败统一 2013、连错 10 次锁 15 分钟;
- 绑定在 `PATCH /auth/me`:格式错 2018、被占用 2019、留空串表示解绑。

系统设置(跨租户,只有平台管理员能改):
- 新表 `system_setting`(不挂租户、无 RLS,写权限由 `deps.platform_admin` 把关);
- `GET/PUT /settings/smtp`、`POST /settings/smtp/test`,授权码只写不读,留空表示沿用旧值;
- `services/mail.py` 只用标准库 smtplib:465 走 SSL、587 走 STARTTLS,认证/拒收/网络失败都归 2020 但消息不同;
- Web 新增「系统设置」页(状态卡 + 测试发信),侧栏只对平台管理员显示入口;账号页补「邮箱」。

验证:pytest 130 passed、ruff check/format 全绿、tsc 通过、vite build 通过;
`web/e2e/login-email-smtp.mjs` 15 项全绿(含用 126 授权码真发一封测试邮件)。

顺带记下的两个坑:`platform_admin_phones` 是 list,pydantic-settings 会按 JSON 解析,
要用 `NoDecode` 交给 before-validator 拆逗号;迁移号写成 0009 会撞既有的 0009,一并改成 0010。
2026-11-03 00:00:11 +08:00
steven d7c9670880 feat(design): 主色改靛蓝 #4F46E5,Web 与安卓及 Logo 同步
CI / backend (push) Canceled after 0s
- primary 换成 Indigo 阶(600 即 #4F46E5),中性色转 Slate 轴、纸面更冷更白
- 实心主色面(主按钮、工作区选中)用 600,链接/浅底文字仍用 700 保对比度
- 信息色 sky 调深到 #16697C,与靛蓝同屏能分清
- Logo / favicon / PWA 图标 / 安卓自适应图标统一为靛蓝底 + 白线 + 淡紫点,tools/make-icons.py 重新生成
- 文档与 e2e 断言同步(主题色 #4f46e5)
2026-11-02 15:33:26 +08:00
steven 684b9b2604 fix(web): PWA manifest 主题色跟随新主色
CI / backend (push) Canceled after 0s
安装到主屏后的启动背景与状态栏色此前仍是旧的墨绿/暖白;e2e 断言与文档同步更新。
2026-11-01 19:17:35 +08:00
steven b3cd115bd9 feat(design): 主色由墨松绿改为景泰蓝蓝系,Logo 与图标同步
CI / backend (push) Canceled after 0s
纸白底改冷调与主色同轴;语义色 sage/wheat/rust 不变,sky 提亮与主色区分。Logo 三段线改浅蓝白、圆点改亮蓝;favicon/PWA 图标/安卓自适应图标/状态栏一并重绘,新增 tools/make-icons.py 作为唯一出处。
2026-11-01 18:48:26 +08:00
Steven d24be46d8c docs(roadmap): PWA 的截图放进 2.Design/screens,文档里指对地方
CI / backend (push) Canceled after 0s
2026-10-31 04:35:49 +08:00
Steven 483b554c3c feat(web): Web 装到桌面(PWA),带安装引导与发版提示
CI / backend (push) Canceled after 0s
用 vite-plugin-pwa 生成 manifest 与 Service Worker,不自己写缓存逻辑。

- 装到桌面:manifest 名字/主题色/三个图标(any + maskable + apple-touch-icon),
  图标和页头、安卓桌面是同一枚;首页底部给安装引导,能装就弹「安装」,
  iOS 给「分享 → 添加到主屏幕」,关掉记在 localStorage
- 发版提示:registerType=prompt,收到新版本只在底部提示,点「刷新」才换。
  故意不自动刷新——Vditor、图表是懒加载分片,后台换 chunk 会让面板 404
- 缓存边界:只预缓存页面骨架(约 0.8MB),/api/ 一律 NetworkOnly,
  离线宁可报错也不端旧数据;/vditor/ 走 CacheFirst,不拖慢首次安装
- 顺手修:UpdatePrompt 居中改用 flex 包一层,animate-slide-up 的 keyframes
  自带 transform,会和 -translate-x-1/2 抢属性,手机窄屏会把卡片推出屏幕

验证:web/e2e/pwa.mjs 19 项全绿(manifest/SW 激活/预缓存/安装引导/iOS 文案/
发版提示/断网仍能打开);tsc 通过、vite build 通过。

已知限制:Service Worker 只在 https 或 localhost 注册,生产入口还是 http,
所以内网 IP 上装不了——需要域名 + 证书反代或隧道,已写进 DEPLOY.md 第 6 节。
2026-10-31 04:33:43 +08:00
Steven 429905892c docs(roadmap): 记下账号与外壳这一轮,包括生产上抓到的 http 安全上下文 bug
CI / backend (push) Canceled after 0s
2026-10-31 04:17:33 +08:00
Steven 096acb00e7 feat(web): 首页换成和工作区同一套外壳,补上账号设置页
CI / backend (push) Canceled after 0s
首页原来是居中窄栏,点进工作区又变全屏,进出像换了个网站。现在把首页也做成
全宽 + 左侧栏(SideRail 抽出来给首页和账号页共用),工作区卡片改成网格,
新建工作区是网格里的虚线卡片——宽度不再跳。

账号页 /account:基本信息(头像首字母、昵称、只读手机号、加入时间)、
登录密码(设置/修改 + 确认输入)、退出登录。工作区侧栏底部的用户块点进去就是它。

e2e account.mjs 14 项全绿,其中一条断言进工作区后侧栏宽度和首页差值 < 1px。
2026-10-31 04:05:05 +08:00
Steven a5510cdb79 feat(auth): 手机号也能用密码登录,验证码之外多一条路
短信通道挂了人就进不来,所以给账号加第二把自助的钥匙:设过密码的用密码进,
没设过的照旧只用验证码,不强制。

- app_user.password_hash(迁移 0008 加列)+ scrypt 哈希(标准库实现,
  n=2^14/r=8/p=1,存 scrypt$n$r$p$salt$hash),不引 passlib
- POST /auth/login/password:没设过密码与密码错都回 2013,不区分账号是否存在;
  15 分钟内连错 10 次 → 2016,期间正确密码也挡住
- POST /auth/password:设/改共用,改要先验旧密码,新旧相同算弱密码 2015
- PATCH /auth/me 改昵称;用户输出走白名单 _user_out,密码哈希一个字节都不出去
- 登录页顶部「验证码 / 密码」两段切换

tests/test_account.py 10 项;全量 pytest 113 passed,ruff 全绿。
2026-10-31 04:04:55 +08:00
Steven 29aae12224 fix(app): 开完安装权限回到 App,再点那个按钮要能装上
CI / backend (push) Canceled after 0s
只做了"送去系统设置",没接"回来":用户开完开关回到对话框,
按钮还是「去开启安装权限」,而 openInstallPermission() 发现已授权就直接 return,
点了毫无反应——只能杀掉 App 重来。

现在点之前先复查 canInstall():权限开了就直接交给安装器,没开才跳设置页。

顺带补上模拟器上的全流程验证与截图(弹窗、权限提示、系统安装器、首页入口)。
2026-10-31 03:25:11 +08:00
Steven 7668799040 feat(app): 安卓应用内升级,安装包由后端发布
CI / backend (push) Canceled after 0s
需求是"改完 Android 端怎么送到手机上"。不发应用商店,也不想每次手动传包,
于是做了最小闭环:后端存一份安装包清单,App 启动时查、下载、交给系统安装器。

后端
- 新增 /app/release/latest 与 /app/release/download/{file},都免登录:
  还没装上新包的客户端未必带着有效令牌,检查更新不该被登录挡住
- download_url 用相对路径,客户端拿自己配置的 API 地址拼——同一份清单要在
  内网、公网、模拟器之间共用,写死域名只会让其中一个坏掉
- has_update 只比对 version_code,不比版本名字符串(0.10.0 与 0.9.0 的字典序是坑)
- 下载只认清单里登记的文件名,含 ..%2F 一律 9001
- scripts/publish_apk.py 负责复制 APK 并写 latest.json(sha256/size/published_at),
  backend/static/apk/ 不进版本库

安卓
- AppUpdater:查清单 → 下到应用自己的 updates 目录(先写 .part 再改名)→
  校验 sha256 → 经 FileProvider 交给系统安装器
- UpdateDialog:版本号、更新说明、下载进度、失败重试;「允许安装未知应用」
  是系统开关,只跳设置页,不代点
- 启动时自动查一次,首页底部另有「检查更新」入口
- 图标换成和 Web 的 <Logo/> 同一套图形:墨绿圆角底、三条米白圆头线、陶土点;
  同时出 mipmap 各密度 PNG、自适应图标与 512px 预览
- versionCode=2 / versionName=0.2.0;发布签名从 android/keystore.properties 读,
  没有该文件时回落调试签名,保证别人 clone 下来也能 assembleRelease

文档
- API.md 第 7 节:两个接口、清单字段、签名与 sha256 的约定、9001
- DEPLOY.md 第 5 节:出包命令(JDK 17 + Gradle 8.9 + 干净的 GRADLE_USER_HOME)、
  发布清单、传到服务器、验证
- ROADMAP 第 6 节记录本次范围与本机踩过的构建环境坑

验证
- 后端 pytest 103 passed;ruff check / format --check 全绿
- assembleRelease 成功,app-release.apk 7.4 MB,apksigner 验签显示 CN=Dadao Task
- 本地与生产 GET /app/release/latest?version_code=1 → has_update=true,
  version_code=2 → false;下载返回 200 / application/vnd.android.package-archive
2026-10-31 03:15:00 +08:00
Steven 50227c2114 feat(knowledge): 「全部」排到 tab 第一位并默认选中,正文编辑区跟着屏高走
CI / backend (push) Canceled after 0s
- 知识列表 tab 顺序改成 全部 / 待核对 / 已发布 / 已归档,默认停在「全部」:
  进知识库先看"我这儿都有些什么",待核对是待办,不该当门面。
- 正文编辑器不再是写死的 240px:新增 useEditorHeight(reserve),
  按 window.innerHeight 减掉标题/分类/标签/附件/底部按钮算高度
  (新建留 420、编辑留 340,最低 360、最高 960,窗口 resize 时重算),
  新建面板与详情编辑两处共用,长文一屏能写完一段。
- 截图脚本 shots-knowledge-view.mjs 支持 EDIT=1 出编辑态图、ALL=1 留在默认 tab;
  新增 2.Design/screens/knowledge-tab-all.png、knowledge-edit-tall.png。
- 验证:tsc --noEmit 通过、vite build ✓、e2e knowledge.mjs 9 项与
  knowledge-notebook.mjs 17 项全绿(含"侧栏分类按树排""已发布也有编辑")。
2026-10-31 02:25:25 +08:00
Steven b29286778f fix(knowledge): 分类侧栏按树的顺序排,已发布的知识也能直接改
CI / backend (push) Canceled after 0s
两个都是"看着不对劲"的问题:

1. 侧栏把二级分类全堆在一级分类后面(按 depth 排序的副作用),
   「技术」和它的上级「我的备忘」隔着十几行,看着像一棵假树。
   后端改成按树的先序返回(父分类后紧跟它的子分类,兄弟仍按 sort_order、name),
   前端照 depth 缩进就是个真树;二级以下再加一个「·」标记,小屏也分得清。

2. 点开已发布的知识只有「归档」,没有改的入口——因为后端原来硬性拒绝
   修改已发布内容(7003)。但"发布"是给团队看的,写错了总要能改:
   现在已发布的可以直接 PATCH,改完立刻重建检索切片(chunk_count 当场更新),
   状态和版本号照常推进;归档的仍然要先「恢复成草稿」再改。

Web 与安卓同步:抽屉/详情里已发布也显示「编辑」,安卓分类标签区不再锁。

验证:pytest 95 passed(新增"分类按树返回"用例、发布后可改用例)、
ruff 全绿、tsc 0、vite build ✓;`knowledge-notebook.mjs` 17 项、
`knowledge.mjs` 9 项 e2e 全绿。安卓仍未编译(JDK 25 / Gradle 8.7 不兼容)。
2026-10-31 02:06:49 +08:00
Steven 441b1b93e1 feat(knowledge): 点开知识先看到排好版的正文,要改再点「编辑」
CI / backend (push) Canceled after 0s
之前抽屉一打开就是 Vditor 编辑器,正文是一堆 `##` 和 `-` 的原文,
导入的 84 条旧备忘看着尤其像草稿。现在分成「看」和「写」两态:

- 默认只读:按 Markdown 渲染(markdown-it + DOMPurify),分类与标签是 chip,
  附件列表只读;底部是「编辑」和状态动作,右侧显示发布于何时。
- 点「编辑」才挂编辑器;「取消」直接丢弃未保存改动(重新拉一次详情)。
- 安卓详情页同样用 Markwon 把正文排版后再显示,要改走「编辑正文」。

顺带修了 `web/e2e/knowledge.mjs` 里一条早就失效的断言:它等的是
「已发布 · 版本 N」,但界面上没有这行字(git 历史里也没有),
改成等抽屉底部的「发布于 …」。

验证:pytest 94 passed、ruff 全绿、tsc 0、vite build ✓;
`knowledge-notebook.mjs` 16 项、`knowledge.mjs` 7 项 e2e 全绿。
安卓本轮仍未编译(本机 JDK 25 与 Gradle 8.7/AGP 8.7.3 不兼容)。
2026-10-31 01:34:25 +08:00
steven 2ff1f6e9cd fix(deploy): CORS 用 JSON 数组、Redis 口令说明,部署时自动刷新 systemd 单元
CI / backend (push) Canceled after 0s
2026-10-30 22:01:00 +08:00
steven 57767c77a1 feat(deploy): openclaw-01 生产部署脚本、systemd 单元与部署文档 2026-10-30 18:49:31 +08:00
Steven 7c84ff3b8d feat(knowledge): 知识能挂附件,新建时就能传
CI / backend (push) Canceled after 0s
知识是备忘录,备忘录不能只有字:手册、日志、导出表格都得能挂上去。
附件本来就落在 item 上(`ALLOWED_KINDS` 早含 knowledge),缺的是"新建时
还没有条目 id"这一段。

- 懒建草稿:`KnowledgeComposer` 第一次传附件(或往正文贴图)时先 `POST /knowledge`
  存一条草稿拿到 id,`draftingRef` 去重防止贴图与上传同时打出两条草稿;
  之后的保存走 `PATCH /knowledge/{id}`。没传过附件就取消,库里不留空条目。
- `RichTextEditor` 增 `ensureItemId`:没有 itemId 时先把草稿存下来再传图,
  于是"新建时不能贴图"这条限制也没了;工具栏上传按钮按是否有落点显隐。
- 新建面板加了附件区:有草稿 id 就是正常的 `AttachmentPanel`(上传/下载/删),
  没有就先给一个「添加附件」按钮,点一下建草稿。
- 安卓知识详情加附件区:列表(文件名 + 大小)、`OpenDocument` 上传任意文件、
  点「打开」用浏览器看签名直链。编辑器原本只能插图,现在手册/日志也能传。
- 文档:API.md 说明附件三类事项共用同一套接口,以及懒建草稿的先后顺序;
  ROADMAP 加 3.13。

验证:后端 pytest 94 passed、ruff 全绿;Web `tsc --noEmit` 通过、`vite build` 通过;
`knowledge-notebook.mjs` 15 项全绿(新增「新建面板里能上传附件」「详情抽屉里能看到
刚传的附件」「附件挂在知识上,详情回读得到」)。安卓端仍是 JDK 25 + Gradle 8.7
不兼容,本次未编译验证。
2026-10-30 16:18:32 +08:00
Steven 9a382e4e8c fix(knowledge): 新建知识的正文换成富文本编辑器,不再是纯文本框
CI / backend (push) Canceled after 0s
「记一条」面板里正文是个 textarea,排版、表格、贴图都没有——创建时反而比
详情页更寒酸。现在新建面板直接用详情同款 Vditor 编辑器,创建与修改一个模样。

- `RichTextEditor` 的 `itemId` 改为可选:新建时还没有条目 id,正文照写,
  只是上传按钮不摆出来;真去贴图会提示「先保存这条内容,再回来贴图」。
- `KnowledgeComposer` 的正文换用 `RichTextEditor`(minHeight 320),页脚提示
  按有没有条目 id 给不同文案。
- 安卓:新建对话框不再放纯文本正文框,保存后直接进 `RichTextEditorScreen`
  写正文(排版、表格、贴图都在那儿),写完回到列表并展开详情。
- e2e 增加一条断言:新建面板里的正文就是富文本编辑器(aria-label=正文 的
  contenteditable),正文按真实键盘输入后 Markdown 正常落库。

验证:`tsc --noEmit` 通过、`vite build` 通过、`knowledge-notebook.mjs` 11 项
全绿(新增「新建面板的正文是富文本编辑器」)。安卓端本机仍是 JDK 25 +
Gradle 8.7/AGP 8.7.3 不兼容,本次未编译验证。
2026-10-30 15:52:09 +08:00
Steven ed31af3f46 feat(knowledge): 知识改成备忘录形态,一次写全 + 三级分类,抽屉加宽
CI / backend (push) Canceled after 0s
知识以前只能"填个标题就完成",正文、标签、归类都要点进去再补。现在新建
就是一张完整的备忘录:标题、类型、分类、标签、正文一次写完,存完直接打开
详情继续编辑;知识页左侧多了工作区三级分类树,可按分类子树 / 未分类 / 标签
筛选。凡是填信息的抽屉都加宽了,还能一键展开整屏。

后端
- 新增 `knowledge_category`(迁移 0007):`workspace_id NOT NULL` + 自引用
  `parent_id`、`depth` 1..3 检查约束、同层重名唯一索引(lower(name))、
  RLS 策略与 `dadao_app` 授权;`knowledge_doc.category_id` 外键 ON DELETE
  SET NULL。
- 新增 `/knowledge/categories` 增删改查:超三级 `7007`、同层重名 `7008`、
  跨租户或不存在 `7006`;`move=true` 才改父级,成环与移动后超过三级的子树
  一律拒绝,删除有知识的分类要 `force=true`(知识变未分类)。
- 知识列表支持 `category_id`(含子树)、`uncategorized`、`tag`;输出带
  `category`(含 `path` / `full_path`)。
- PATCH 只改分类或标签也会推进 `version`,避免"只动标签"之后下次保存撞
  乐观锁 `4009`。

Web
- `Sheet` 增 `size="md/lg/xl"` 与展开整屏按钮;任务、工单、知识抽屉统一
  加宽(`TaskDetail` / `Tickets` / `Knowledge`)。
- 知识页:左侧三级分类树(新建、按子树筛选、未分类)、`KnowledgeComposer`
  一次写全新的备忘录、列表卡片露出分类路径与标签、详情抽屉里可直接改分类
  与标签。
- 抽屉详情只拉一次:重复的 effect 或迟到的响应不再覆盖用户正在敲的内容。

安卓
- `Api.kt`:分类模型与接口、新建/更新带 `tag_names`/`category_id`、
  列表带分类与标签筛选。
- 知识页重做:新建改为近全屏大对话框(标题/类型/分类/标签/正文),列表
  顶部按分类筛选,详情里能改分类与标签后单独保存。

文档与测试
- `2.Design/API.md`:5.1/5.3/5.4 补分类与筛选参数,新增 5.5 分类接口章节,
  错误码补 `7006`/`7007`/`7008`。
- `2.Design/ROADMAP-M0-M1.md`:新增 3.11(三级分类)、3.12(备忘录形态)。
- 新增 `backend/tests/test_knowledge_categories.py`(8 例):备忘形态落库、
  三级到底、移动越界与成环、重名、按子树/未分类/标签/关键词筛选、删分类需
  `force`、跨租户隔离。
- 新增 `web/e2e/knowledge-notebook.mjs`(10 项,全绿):建两级分类 → 一次
  写全 → 卡片与筛选 → 抽屉改标签/分类 → 正文落库 → 重名 7008。

验证:`pytest` 93 passed、`ruff check` / `ruff format --check` 全过、
`tsc --noEmit` 通过、`vite build` 通过、`knowledge-notebook.mjs` 10 项全绿。
安卓端本机只有 JDK 25,Gradle 8.7 / AGP 8.7.3 不支持,本次未做编译验证。
2026-10-30 03:51:43 +08:00
Steven 91b3dde670 feat(notify): 站内通知与手工开单,指派/改状态/评论/验收都有人收到
CI / backend (push) Canceled after 0s
M2 最后一块:让"该我办了"能出现在系统里,并且允许不经过任务直接开单。

通知(backend/app/services/notify.py 统一出口)
- 七类触发:task_assigned、task_status_changed、item_commented、
  ticket_assigned、ticket_submitted、ticket_approved/rejected、task_unblocked
- 不通知自己:notify() 按 actor_id 跳过,调用方不用各自判断
- 已读:GET /notifications、/unread-count、POST /read、/read-all

手工开单
- POST /tickets:直接指定人、初始 open,与任务升级单共用同一条 kind='ticket' item,
  ticket_ext.parent_item_id 为空,验收通过不回流任务
- 指派前 _ensure_member 校验在册成员,越界指派返回 1001(避免"挂在看不到的名字上")

前端
- Web:通知页 /w/:wsId/notifications(未读/全部,点开标已读并跳目标);
  工单页"新建"面板;AppShell 每 30 秒对未读数,标已读后 dispatch notifications:changed 立刻刷新;
  任务与工单支持 ?open= 深链落地即展开
- 安卓:五个 tab 加"通知",未读数 30 秒刷新;工单动作按需求人/办理人/管理员显隐;手工开单对话框

验证
- pytest 85 passed(新增 backend/tests/test_notifications.py 6 个用例)
- ruff check / format --check 全 0
- tsc --noEmit 通过;vite build 成功
- web/e2e/notifications.mjs 12 项断言全绿(手工开单 → B 收到 → 点开跳转 → 未读归零)
- 安卓:本机 JDK 25 与 Gradle 8.7 / AGP 8.7.3 不兼容,本次未做编译验证,
  装回 JDK 17 后第一件事是 assembleDebug
2026-10-30 02:19:42 +08:00
dadao d079564d6c feat(knowledge): 语义检索打通——切片、混合打分、只搜已发布
CI / backend (push) Canceled after 0s
之前知识只能"存",不能"找"。这一版把链路补上:切片 → 向量 → 打分 → 带来源返回。

- chunk 表 + 迁移 0006:正文按标题归段切、代码块整块保留、超长按句切、
  相邻片重叠 80 字;RLS 与 GRANT 与其它租户表一致
- 向量提供方做成协议:内置 hash(字符二元组,零依赖可复现,作为基线)
  与 OpenAI 兼容端点两种,换实现不动业务代码
- 检索是混合打分:向量 0.6 + pg_trgm 0.4。专有编号、单号这类必须原样命中的词
  靠词法兜,换个说法表达的同一件事靠向量兜
- 三条纪律:只搜 published(过滤写在 SQL 里)、低于阈值宁可不返回、
  每条命中都带来源标题与 item_id
- 发布时自动建索引,改完正文用 POST /knowledge/{id}/reindex 重建
- Web 知识页加检索框与命中卡片;新增 e2e/retrieval.mjs 6 项

为什么不是 pgvector:本机 Docker 起不来、fnsvr-01 没有该扩展且明确不动。
先用 real[] + SQL 余弦把链路跑通,将来换列类型与索引即可,接口不变。

后端 79 passed,e2e smoke/knowledge/richtext/retrieval 全绿。
2026-10-29 04:28:09 +08:00
dadao d509f0c335 fix(android): 编辑器改成整页显示,保存后回到刚才那条详情
原来编辑器是叠在工作区上的 Box,而详情抽屉/对话框是独立窗口,永远盖在它上面——用户只能看到编辑器的一条顶栏。改成编辑器替换整页;保存成功后父层记下 itemId,回到工作区由对应页面刷新并重新打开那条详情。取消则什么都不变。
2026-10-29 04:01:28 +08:00
dadao 17be9d1d42 feat(rich): 安卓能编辑富文本了,图片直链换成短期签名,表格不再撑破抽屉
CI / backend (push) Canceled after 0s
三件欠账一次清:

1. 图片直链签名化
   - 新增 `backend/app/services/signing.py`:HMAC-SHA256,密钥由 `JWT_SECRET` 域分离派生
     (`attachment-raw-v1`),载荷 `workspace_id:attachment_id:exp`,默认 30 分钟
   - `/raw` 支持 `?exp=&sig=`,不需要登录态;签名里绑了工作区,跨租户链接直接拒
   - 附件对象多一个 `raw_url`,前端每次渲染现签,正文里只存不带凭据的 `/raw`
   - 保留旧 `?token=` 通道,给未升级的客户端过渡

2. 安卓富文本编辑器
   - `ui/RichTextEditorScreen.kt`:Markdown 工具栏(粗体/斜体/标题/列表/引用/代码/链接/图片),
     选区包裹与行首前缀都按光标位置来;贴图走附件接口,插入签名直链
   - 预览用 Markwon,图片用 Coil;正文落在一张白纸卡片里,不再贴屏幕边
   - 从任务详情与知识点都能进,保存按 `body_md` 回写(PATCH 带 version)

3. 表格
   - 只读渲染走 markdown-it 的 GFM 表,`display:block + overflow-x:auto`:
     宽表横向滚动、窄表铺满,都不撑破抽屉

验证:pytest 74 passed;richtext 15 / smoke 7 / tickets 8 / knowledge 7 全绿;
tsc -b 0 错;vite build 通过(主包 299.42 kB / gzip 74.96 kB);
安卓 assembleDebug 成功并在 API 35 模拟器走通编辑与预览。

仍欠:保存后回列表而非回详情抽屉、图片压缩与缩略图、HTML 片段按文本显示。
2026-10-29 03:32:53 +08:00
dadao fbdf54cb4c feat(web): 富文本编辑器换成 Vditor,不再自研轮子
CI / backend (push) Canceled after 0s
上一版我自己写了 textarea + 正则工具栏,看着能用,一到粘贴图片、
中文输入法、撤销栈就露馅——这几样正是成熟组件积累的部分。按要求改成品。

- 编辑器开关 def: RichTextEditor = Vditor 4.0.1(MIT,ir 模式)
  * 运行时资源自托管:web/scripts/vendor-vditor.mjs 把 lute/i18n/icons
    同步到 public/vditor,predev/prebuild 自动执行,不走 unpkg CDN
    (内网会一直卡在加载)
- 只读渲染换 markdown-it + dompurify,删掉自写的正则渲染器
- 附件直链的令牌只在内存里存在:落库前 stripAttachmentTokens,
  渲染/装载前补回(<img> 带不了 Authorization 头,raw?token= 是必要折中)
- Vditor 不会替我们插入 upload.handler 的返回值(只当提示语 tip.show),
  所以上传完自己 insertMD

验证:pytest 70 passed;e2e richtext 13 项、smoke 7、tickets 8、
knowledge 7 全绿;tsc 0 错;主包 299 kB/gzip 75 kB,Vditor 独立
chunk 385 kB 按需加载。
2026-10-29 02:30:10 +08:00
dadao 7a7ea932a4 fix(web): 表单标签对比度提到 ink-600,附件编辑器截图入册
CI / backend (push) Canceled after 0s
2026-10-29 01:55:51 +08:00
dadao 800c2726e2 docs(api): 补附件契约、Markdown 存储理由与富文本路由
CI / backend (push) Canceled after 0s
2026-10-29 01:53:47 +08:00
dadao 0a3b0f3ca0 fix(android): 真机登录不上——明文流量被网络配置拦住,且地址写死在模拟器网段
CI / backend (push) Canceled after 0s
用户反馈"app 端登录不上去"。查下来是两个叠在一起的问题,都不是"后台没开":

1) 主清单设了 android:networkSecurityConfig(白名单只有 10.0.2.2 / 127.0.0.1 / localhost),
   而它的优先级高于 debug 清单里的 android:usesCleartextTraffic="true"。
   所以真机连 http://192.168.8.x:8100 会被系统拦成 CLEARTEXT communication not permitted,
   表现成登录无反应;模拟器因为地址在白名单里,一切正常,很容易误判。
   → 新增 app/src/debug/res/xml/network_security_config.xml,debug 放开明文;
     release 仍只有白名单,线上必须 HTTPS。

2) 后端地址编译期写死 BuildConfig.API_BASE = http://10.0.2.2:8100,那是模拟器专用地址。
   → 登录页加「服务器:… / 改地址」:可改、可「测试连接」、存在 TokenStore 里,
     改一次就记住,不用重新打包。地址缺失协议时自动补 http://。

顺带把网络异常翻成人话:以前 OkHttp 抛 IOException 原样冒到界面,
现在统一成「连不上 <地址> —— 具体该去查什么」,并区分了
"10.0.2.2 是模拟器地址"、"127.0.0.1 在手机上指的是手机自己"两种情况;
连不上时自动展开地址面板。

后端这边没有代码改动,但真机要用 --host 0.0.0.0 启动(原来只监听 127.0.0.1),
已写进 DEVELOPMENT.md 与 android/README.md,附防火墙放行命令。

实测:模拟器内把地址改成 http://192.168.8.151:8100 → 测试连接「连得通」→
取码(开发环境验证码回显)→ 登录成功进入工作区列表。
2026-10-28 23:38:21 +08:00
dadao 3cfd90b8d3 docs(screens): 补 2560 宽屏下的任务/看板/知识截图,作为自适应布局的回归基准
CI / backend (push) Canceled after 0s
2026-10-28 22:50:11 +08:00
dadao aa396f2c26 docs(ui): 记录界面重做(第 4 节)、更新状态表与安卓截图
CI / backend (push) Canceled after 0s
- ROADMAP 新增「4. 界面重做」:设计系统、Web、安卓三段逐项打勾,
  并写明没做事的原因(深色模式、无障碍审计、安卓离线缓存、看板拖拽)
- README 状态表:安卓改为五个页面已跑通,新增"界面"一行
- 2.Design/screens/ 补安卓首页、任务、看板、详情、工单、知识、成员七张截图
2026-10-28 18:37:35 +08:00
dadao f683436689 feat(android): 安卓端第一次真跑起来,修两个联调才暴露的解析 bug
CI / backend (push) Canceled after 0s
上一轮只是"能编译"。这一轮在模拟器上真装了、真登录、真点了,才发现两个问题:

1. workspaces() / members() 直接按 List 解析,但服务端列表接口返回的是
   {"items": [...]}。表现就是登录成功后首页红字「响应格式异常(HTTP 200)」,
   而且工作区永远显示为空。新增 WorkspacePage / MemberPage 容器类型。
2. 之前 Api.kt 的请求体全是没加 @Serializable 的普通 data class(上一提交已修),
   这次把成员/工作区的解析也按同样思路收口。

环境侧(写进 android/README + 新增 android/scripts/emulator.ps1):
- sdkmanager 拉不到远端索引,系统镜像是直连 dl.google.com 手动装的;
  压缩包里多一层 x86_64/,要把内容提上一层,否则 AGP 找不到 system.img
- AVD 放工程内(~/.android 受限不可写),建 AVD 与启动都靠 ANDROID_USER_HOME / ANDROID_AVD_HOME
- 无头启动要 -no-window -gpu swiftshader_indirect
- 点按坐标要拿 uiautomator dump 的 bounds,别拿降采样截图猜
- adb shell input text 打不了中文(NPE),中文数据走接口或直接写库

验证(模拟器 API 35,连 fnsvr-01 的真库):
登录 → 建工作区 ShipFast → 任务列表显示 4 条中文任务(状态、截止日都对)
→ 勾选完成,条目从「进行中」消失(乐观锁生效)→ 工单页、知识页均正常渲染。
截图放进 2.Design/screens/android-{login,tasks}.png。
2026-10-28 17:04:35 +08:00
dadao 07688881b4 docs(m3): 补知识接口契约、M3 清单与状态表
CI / backend (push) Canceled after 0s
- API.md 新增第 5 节:列表/详情/手记/改内容/状态动作/从事项沉淀 + 7xxx 错误码
- ROADMAP 增 M3 全节与 DOD,验收用例补 11–13,明确 3.8 向量索引未跑
- README 状态表:知识已通、pgvector 只有配置
- DEVELOPMENT 补三条 e2e 命令与「先想一条能失败的断言」
2026-10-28 14:03:10 +08:00
dadao 600131dcf5 docs(m2): 补工单接口契约、M2 清单与状态表
CI / backend (push) Canceled after 0s
- API.md 新增第 4 节:升级/动作/列表/关系/通知与 6xxx 错误码
- ROADMAP 补 M2 全部条目与 DOD,验收用例加自验与回流两条
- DEVELOPMENT 记两条新坑:PowerShell 批量改写会毁中文、npm.ps1 被执行策略拦
- README 状态表把工单从待做改成已通,测试数 57
2026-10-28 13:20:31 +08:00
dadao 3d02fe845a docs+chore: M1 状态校准、向量库独立实例、gitignore 修复
CI / backend (push) Canceled after 0s
- ROADMAP:勾选与实际证据一一对应,未做的写明原因与去向(通知推迟到 M2、子任务与 @人 合并进 item_link)
- DEVELOPMENT:新增向量库、e2e、性能实测三节
- API:补批量状态与看板接口,重排小节编号
- deploy:docker-compose.vector.yml + vector-init(独立 PG+pgvector,5434,不碰 fnsvr-01)
- .gitignore:注释乱码修正,补 *.tsbuildinfo / e2e 产物
2026-10-28 04:05:47 +08:00