Skip to content

Feat/3.0.0 beta1 - #2285

Merged
dolphin0618 merged 39 commits into
releasefrom
feat/3.0.0-beta1
Aug 12, 2026
Merged

Feat/3.0.0 beta1#2285
dolphin0618 merged 39 commits into
releasefrom
feat/3.0.0-beta1

Conversation

@dolphin0618

Copy link
Copy Markdown
Collaborator

No description provided.

dolphin and others added 30 commits August 10, 2026 23:03
…nd them

撤销和改权限模型在 116 上必然失败,报「Grant assignee is missing or ambiguous」。
授权行的 id 是 60~62 位整数(API 新增走 secrets.randbits(62),所有者/复制路径走
sha256[:15]),超过 JSON number 在浏览器里能表示的 2^53。以数字下发时 JSON.parse
当场四舍五入,前端原样回传的 id 与任何一条 assignee 都对不上,_find_source 找到 0
条即抛错。实测约 99% 的 id 会失真,也就是基本必挂。ADD 不受影响,它按主体识别而不
是按行 id。

只把 id 挪到线上的十进制字符串:领域层、仓储和 BigInteger 列仍是整数,无需数据迁移。
入参改为正整数字符串并拒绝数字形态——混合部署下宁可显式报参数错,也不要重新掉进
静默截断。新增 test_f048_assignee_id_wire_format.py 覆盖两种分配器的上界值。

顺带修掉 client 端的撤销确认弹窗:原本是手搓的 AlertDialog,确认按钮取顶层 key
`confirm`,而三个语言包都没有这个 key,i18next 直接把 key 当文案渲染成英文
"confirm"。改用共享的 useConfirm() destructive 变体,与会话删除等危险操作一致,并在
正文里点名被撤销的主体——原来只问「确认撤销该主体吗」,不告诉你是哪个。

已知未处理:撤销成功后 grants:mutate 的返回体把 scope 写死 LOCAL、主体名置空、模型
名退化成 model_key,而前端拿它整体替换了列表,成功后剩余行会短暂「变糙」,刷新恢复。
此前撤销从未成功,所以这个问题一直没暴露。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…iled one leaves

116 上发布 catalog 跑了 423 秒然后 500,日志里是 OpenFGA 的
`validation_error`。一次事故牵出四个问题,都在这条冷路径上。

**发布必然失败。** `read_active_release_keys` 只给了 user 和 relation,没给对象
类型。Read API 要求对象类型必填,且对象 id 与 user 不能同时为空,否则一律拒绝。
它在发布的最后一步 commit 才被调用,所以前面的活全白干,死在终点线上。

**读取量与库大小成正比,而不是与发布内容成正比。** staging 和校验各发一次
**不带过滤条件**的 Read,那是全库扫描,客户端每页 100 条 —— 77348 条 tuple 要
774 次往返,两次就是 1548 次强一致读。而发布真正要核对的只是自己那几百条
catalog tuple,涉及两种对象类型。改成按计划涉及的对象逐个读、并发 8 路。
116 实测:全量扫描 77348 条 / 774 次请求 / 206.8s;改后 active 指针 1 条 /
1 次 / 0.02s,模型发布标记 354 条 / 4 次 / 0.04s。语义未变,仍是同一批 tuple、
同样的强一致存在性比对。

**恢复路径依赖那个坏掉的函数。** commit 失败会转入 `_resolve_unknown_commit`,
而它第一行又调 `read_active_release_keys`,于是二次抛出、异常穿透整个 publish,
`fail_closed` 根本没机会执行。发布前给 CURRENT release 上的写锁就此留在库里。
封锁本身是设计意图(失败即封锁,只能前向修复),但**封锁却不打标记**不是:
release 停在 PROJECTING,没有 FAILED_CLOSED,没有事件,没有任何记录。运行中的
进程用着内存里已初始化的运行时照常服务,直到下一次重启才全线 500 —— 这次就是
隔了 4 天才发作。现在指针读不出来也会落进同一个带原因的终态。

**报错指错了方向。** 门禁被 latch 后一律报「Permission data migration is
required」,而真实原因是「CURRENT Permission Catalog is write fenced」,害人去
找一个根本不存在的迁移。改为把 latch 时的真实原因透出来。

顺带给客户端加了本地校验:不合法的 Read 过滤在发出前就报清楚缺什么,而不是等
服务端回一个含糊的 validation_error。过滤规则是在真实 OpenFGA 上逐组合验证的。

测试:新增 8 个用例(客户端过滤校验、投影器只读计划内对象、失败必留标记、门禁
报真实原因)。test_f048_catalog_runtime 里的假 OpenFGA 原本只认精确对象匹配,
按线上语义补上了类型前缀过滤。全量权限测试 626 通过 / 13 失败,与改动前基线的
13 个完全同批,无新增。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
发布影响确认弹窗直接把后端的 ISO 串塞进文案,显示成
`2026-08-11T03:27:14.567728+00:00`。不只是不好看:那是 UTC,对 CST 用户比本地
时间早 8 小时,而这个影响窗口后端只给 10 分钟,照它判断还剩多久会直接误判成
早已过期。改为按本地时间渲染成 `2026-08-11 11:27:14`,窗口只有 10 分钟所以保留
到秒;无法解析的值原样返回。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
「拖拽完就保存了」是合理预期,但动作等级看板不是这么工作的:每拖一次就以
**当前生效版本**为基准新建一份草案,而一份草案只装一个改动。连改三处会得到三份
互不相干的草案,点发布只发布最后一份,**前两处即使走完完整发布流程也会静默丢失**。
库里因此堆了 45 份废弃草案,且前后端都没有删除接口。

而且这个 tab 上根本没有发布按钮 —— 只有一个描边小按钮「查看影响」,真正的
「确认发布」藏在它弹出的对话框里;页面上唯一的提示写的是「预计影响 N 个资源」,
只讲影响量,从头到尾没有一个字说尚未发布(整个权限模块搜不到「未发布」字样)。
所以用户拖完就走,改动全丢。

后端:`CatalogDraftRequest.change` 改为 `changes` 元组,`_apply_change` 改为
`_apply_changes`,把整批改动折叠到基准版本上再做一次校验。校验放在折叠之后而不是
每步之后——一批编辑可能途经某个它根本不会发布的中间状态,只有真正要发布的那个
状态需要成立。

前端:编辑改为即时落本地、不发请求;待发布改动由「本地状态与已发布版本的差异」
推导,所以把卡片拖回原处会自动撤销该改动,而不是再排一条反向的。头部换成
「发布更改」主按钮 + 「放弃更改」,提示条改为「有 N 项改动尚未发布」,草案生成
失败会明确报错(原先是 `void` 掉的未处理拒绝,静默无声)。

测试:后端新增 2 个(整批折叠、同一动作后写覆盖先写);前端看板用例按新行为重写
(编辑不触网、发布时一次提交全部、移回原处撤销改动、放弃更改、草案失败提示)。
后端 628 通过 / 13 失败与基线同批;前端 22 个相关用例全过,tsc-strict 与 eslint
干净。`pnpm check-i18n` 仍失败,但基线一模一样(250xx 权限错误码缺前端文案,与本
次无关)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
今天排查里两次被报错带偏方向:一次是「Catalog base release is no longer
current」,一次是「Permission data migration is required」——都是后端英文原文直接
糊在界面上。根因是 25001–25013 这一整批权限错误码在前端**一条文案都没有**:
`request.ts` 走的是 `i18next.t("api_errors:<code>", { defaultValue: status_message })`,
没注册就回落到后端原文。顺带补上同样缺失的 10542、11059。

`pnpm check-i18n` 此前就因这 15 个码失败(与本次改动无关,基线复核过),现在转绿。

文案按"说清发生了什么 + 下一步怎么办"来写,而不是照译内部措辞:25002 是
「权限配置已被他人更新,请刷新后重试」而不是「权限数据版本冲突」;25008 是
「权限目录当前不可写入,请稍后重试」——尤其不能再说"需要数据迁移",那次真实原因
是发布崩溃后没释放的写锁,跟迁移毫无关系。三种语言同批提交,生成产物由
`pnpm locales:build` 产出,未手改。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… carries it

管理员打开 /dashboard 一个看板都看不到,但列表接口明明有数据。

侧边栏按 `permissions[id]?.includes("visible")` 做客户端过滤,而后端从不返回
`visible` —— 它在 F048 里是权限模型中的**关系**,不是那 12 个业务动作之一,
`permission_action_service.py:368` 还专门把它当非法动作码拒掉。所以这个过滤恒为
false,接口有数据也一条渲染不出来。116 当前生效版本的动作清单实测也没有它。

管理员这条路径更彻底:后端凭身份直接放行(`_identity_shortcut`),而正因为是凭
身份,他们名下一条授权记录都没有,`my-permissions` 返回的 actions 是空数组,于是
连带 rename / delete / managePermission 全部判死。

可见性改由「`my-permissions` 请求是否成功」决定 —— 该接口入口就做 `_require_visible`,
不可见会直接报错,所以拿到成功响应本身就是可见。判定方由知情者给出:侧边栏按
是否在 map 里判断并把结论作为 `visible` 传给卡片,详情页自己持有 map 直接判断,
不再让子组件从动作列表里反推。`privileged` 让管理员整条跳过 —— 顺带把请求数从
「多少个看板就多少个请求」降到 0(普通用户仍是 N 个,批量接口另说)。

测试固件同步改成后端真实返回(去掉伪造的 `visible`)—— 原来的固件正是这个 bug 能
一路绿灯到线上的原因:单测按一个后端并不遵守的契约写,测得再全也测不出问题。回退
改动验证过,新用例在旧代码下挂 5 个。新增两条覆盖管理员:控件全开、且零请求。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Anthropic 官方 pptx skill 的主干工具链在灵思执行器里全部不可用:
pptxgenjs 未装且 npm install 失败、markitdown 不存在、validate.py 缺
defusedxml 直接崩、soffice 调用姿势不对报 "source file could not be
loaded"。它反而把唯一可行的 python-pptx 标注成「有三个坑的次选」,
模型只能靠试错撞出路来(114 上一次真实会话绕了十几轮)。

本技能把主干换成环境保证存在的依赖(python-pptx / Pillow / PyMuPDF /
matplotlib,均为后端正式依赖),并按执行器的真实约束重写编排:

- inspect_deck.py  逐页文本 dump + 几何体检,替代 markitdown 与 validate.py
- probe_template.py 探模板版式/占位符/主题,支撑「套用用户模板」
- render_deck.py   soffice→PDF→PyMuPDF 渲 PNG,替代 thumbnail.py 与 pdftoppm
- pptx_helpers.py  中文 <a:ea> 字体、项目符号、删页、保格式填充这几处 OOXML 陷阱
- references/      中文商务排版规范 + python-pptx 配方

三处关键的环境适配(均由真实产物暴露后修正):
1. 脚本一律 exit 0 —— 执行器在失败路径只回传 stderr、丢弃 stdout,
   非零退出会让整份体检报告消失;
2. 溢出检查不能因 spAutoFit 短路 —— add_textbox() 默认就是自动撑高,
   短路等于关掉检查;改为按撑高后的高度参与越界/重叠判断;
3. 「文字压装饰线」单列一类检查,并排除「文字在图形内」(数字在圆里)
   这类设计意图,否则图标行会整片误报。

114 端到端三轮验证:模型零 npm/pptxgenjs 尝试,直接走 python-pptx;
体检从 13 WARN → 4 ERROR → 全绿,末轮产出 13 页且模型自检通过。

scripts/pack_linsight_skill.sh 打包成可导入 zip,并前置校验后端会拒的
约束(SKILL.md 在包根、name 为 kebab-case 且等于目录名、10MB/100MB 上限)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
执行器写在本地工作目录,任务结束即被 _cleanup_resources 删除;而
workspace/<svid>/ 才是文件工具(ls / read_file)看到的视图,也是下一轮
seed_workspace_from_previous 继承的来源。产物从不写进后者,导致两个缺陷:

1. 模型 ls 不到自己刚写的文件,只能靠提示词里「exitcode 0 即视为成功、
   别再找了」这条补丁话术兜着;
2. 追问轮在新 svid 下从上一轮 output/ 播种,而那里从来是空的 ——
   用户说「封面加个副标题」,模型翻遍工作区找不到上一轮的 PPT,
   只能请用户重新上传或整份重做。

114 取证(会话 0af0a810):第一轮 workspace/475f33d7…/ 共 113 个对象,
全部是 skills/…,没有任何 output/;那份 193KB 的 pptx 只存在于
linsight/final_result/ 交付快照里。追问轮 workspace 为 0 对象。

修法:把执行器本轮 touched 的文件(已有的 created/modified diff,与推给
模型的 tmp-bucket 预览用的是同一个集合)镜像一份到 workspace/<svid>/。
前缀由 workbench_impl 在绑定工具时注入,执行器不感知命名规则;未注入
前缀的调用方(E2B、非灵思场景)自动不做事。

刻意不做的:不镜像删除。这里跑的是 created/modified diff,让一次可能
半途失败的运行去删对象,风险高于收益。

best-effort:镜像失败只降级该文件的 ls/跨轮可见性,绝不影响任务本身 ——
交付仍走本地 os.walk 的 get_final_result_file。

test/linsight/test_code_interpreter_workspace_mirror.py 覆盖无前缀/无 minio
时不动作、对象名拼接、非文件跳过、单文件失败不中断、客户端初始化失败吞掉、
前导斜杠归一化,以及 run_with_dir 的接线与失败时不镜像。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
目标是「装完就有」:docker compose up / 升级镜像重启后,官方技能直接出现在
任务模式的技能选择器里,不需要运维执行任何脚本。

落点被两件事定死:
- 技能包必须在 src/backend 之内。Dockerfile 是 `COPY ./ ./`、构建上下文就是
  src/backend,仓库根的 linsight-skills/ 根本不进镜像。放进
  bisheng/linsight/builtin_skills/ 后,docker、裸机 rsync、pip 安装都自动带上。
- seed 不能写进 Alembic。项目铁律是 revision 只做 DDL,数据 seed 一律走独立
  流程;挂在 API lifespan 上既保住了「一条命令部署」,又不进迁移链。位置紧挨
  既有的两个同形态 backfill。

seed 的落点与用户上传的技能完全一致(磁盘 bundle + linsight_skill 行,
source='builtin'),因此选择器、启停开关、详情页、租户隔离、
materialize_session_skills 全都不需要为内置技能加特例。

三条关键策略:
- 幂等按内容:磁盘已装 bundle 与镜像内的逐字节比对,不同才重写。升级镜像重启
  即更新,没变则只花几次文件读取,不引入版本号列。
- 用户改过的永不覆盖:管理端编辑内置技能会把 source 翻成 manual
  (SkillService._mark_forked),该副本从此退出 seed。升级时静默回滚客户的
  修改,比让副本漂移糟糕得多。
- 新租户补种:启动 seed 只覆盖当时存在的租户,故 TenantService.acreate_tenant
  末尾也为新租户 seed 一次;失败只 warn,不连累建租户。

并发与容错:多副本同时启动由 uq_linsight_skill_tenant_name 兜底,输的一方
INSERT 失败而赢的一方已写入同样的字节;单个 bundle 失败不影响其它;整个 seed
异常只 log,绝不阻塞启动。单租户部署(租户表为空)回落到 DEFAULT_TENANT_ID。

同时把 linsight-skills/README.md 提升为 docs/linsight-skill-authoring.md
(技能编写指南 + 内置技能分发机制),打包脚本仍保留,供客户定制类技能走手工
导入。

test/linsight/test_builtin_skill_seeder.py 13 条:发现与校验(目录名须等于
frontmatter name、缺 SKILL.md、忽略 __pycache__)、首次创建、内容未变不重写、
内容变更刷新、已分叉不覆盖、多租户各一份、单租户回落、单包失败不影响其它、
租户上下文不泄漏,以及一条守卫仓库内实际 bundle 合法性的用例。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
产物写穿工作区之后,ls / read_file 已经能看到执行器写的文件,但系统提示词、
PPT 技能正文和两个脚本的报错文案仍在告诉模型相反的事——一条改了机制却没改
契约描述的半成品,模型照着它会得出错误的世界模型。

保留原有的防御价值:判成功仍以执行结果为准(exitcode 0 + 日志),仍然明确
「不要反复 ls / glob 找它,更不要因为一时找不到就重做一遍」。真正拿掉的只是
「不会出现在 ls 里」这个事实断言。镜像是 best-effort,措辞因此没有反向绝对化
成「一定能看到」。

test_skill_prompt_priority 的断言同步改为钉住行为约束本身(exitcode 0 判定 +
不要反复找),而不是钉住那句已经不成立的事实描述。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`GET /api/v1/knowledge?action=visible` 实测 1.6~2.9 秒,最坏一次 **688 秒**。

`batch_check_business_actions` 外层循环是动作、内层逐个解析候选,成本是
**动作数 × 候选数**。列表只用 `action` 一个动作过滤,却对每个候选都算全部 5 个
`_KNOWLEDGE_LIST_ACTIONS`,而后 4 个只喂给最终返回的 ≤20 行——每批 100 候选付
500 次解析,只为装点至多 20 行。现在扫描只算过滤用的那一个,其余 4 个等页面定下来
后只对该页算。

超管的判断挪到解析之前。决策层本就无条件放行超管,但那句"你是超管"是在每个候选
都解析完之后才说的,等于先把钱付光再告诉你不用付。

两次真实请求互相印证了这个模型,单次解析约 61~69 毫秒:
`type=1` 228×5=1140 次 / 70s;`type=0` 1993×5=9965 次 / 688s。

**收益是推算的,不是实测的**:688 秒那次 17:36:42 完成,容器 17:40:17 才重启,
它跑的是旧代码,而重启后尚无人访问该接口。按模型,最坏情况(用户可见为零,注定
扫完整表)从 9965 次解析降到 1993 次,约 688s → 140s。

**这不解决根本问题。** 这里改的是每轮单价,而该 case 的成本主要来自轮次本身:
SQL 不带权限条件、每次捞 100 条不够再捞,用户可见越少扫得越多,可见为零就扫完整
表。要解掉它得走「继承优先」——库里自己知道哪些资源是自定义模式(少数),不在自
定义子树下的行继承链全通,根本不必逐条问权限。另外 69ms 的单次解析本身也偏重。
两者都需单独立项。

新增 4 个用例把成本模型钉住:动作×候选的乘法关系、只过滤一个动作只走一轮、超管
零解析、普通用户仍走完整解析。权限 + 知识库全量 1143 通过 / 40 失败 / 23 错误,
与改动前基线逐项一致。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
一个文件重建失败,整个知识空间就进不去了,接口报 19003「资源类型或 ID 无效」。

重建 worker 在标记失败文件之后,还会把**整个**容器标成 `KnowledgeState.FAILED`
(`rebuild_knowledge_worker.py`)。这个标记原本是给"下次自动重建"用的,但失败文件已
经不能自动重建、必须走重新解析,所以它不再有任何用途,只剩副作用:权限目标解析要求
容器处于 `PUBLISHED`(`knowledge_permission_service.py:651`),于是一个坏文件让整个空
间在权限层面"不存在"。而报错说的是"资源类型或 ID 无效"——ID 和类型其实都对,真实原
因是状态不可用,排查时只能靠猜。

批量改 embedding 模型时这个代价尤其大:那条路径会把**所有**知识空间一起推进重建
(`llm.py:1475-1485`),任何一个空间里有一个坏文件,那个空间就废了。116 上 21 个空间
这样卡着,包括「PM走查」「压测专用,勿动」「默认组织的知识空间」这些明显在用的。

去掉容器级标记:失败仍逐个记在文件上(`knowledge_file.status` + remark),那才是准确
的、重新解析要用的信息;容器回到 `PUBLISHED`。异常兜底那支同样不再写 FAILED——否则
只是把空间从"失败"换成永远卡在"重建中"。

存量数据由 `scripts/converge_knowledge_rebuild_failed_state.py` 收敛(默认 dry-run,
只动容器状态不碰文件状态)。116 已执行 space 范围:21 条 -> PUBLISHED,4149 恢复。
另有 11 个普通知识库同样卡着,可用 `--scope all` 收敛,待确认。QA 不在范围内:其
FAILED 由 `worker/knowledge/qa.py` 另行写入,语义未变。

测试:新增 3 个——部分失败后容器仍 PUBLISHED 且失败文件被标记、异常路径不把容器搁
浅、以及一条守住意图本身(worker 里不再出现 KnowledgeState.FAILED)。knowledge +
permission 全量与基线逐项一致,无新增失败。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
19003 用同一句「Invalid resource type or ID」覆盖七种不同的失败原因,于是一个卡在
status=FAILED 的知识空间,读起来和 id 打错字一模一样 —— 今天为了弄清 4149 到底怎么
了,只能手工去查库。

原因不能进响应体:`permission_error_response` 有意把每种拒绝都抹平成同一个 body,
好让"资源不存在"和"资源在别的租户"无法区分,仓库里有测试守着这条。所以原因写进
**服务端日志**:`permission target rejected: reason=STATUS_NOT_USABLE:FAILED
resource=knowledge_space:4149`,带 trace id,grep 一下就到位。

把容器解析里那串七个条件的布尔表达式拆成按序判定并给出具名原因:NOT_FOUND /
UNSUPPORTED_RESOURCE_TYPE / IDENTITY_MISMATCH / KIND_MISMATCH:<kind> /
STATUS_NOT_USABLE:<status> / TENANT_MISMATCH。`_target` 与 `_lifecycle_target` 共用
这套判定,只是允许的状态集合不同 —— 原来这两处各写一遍几乎相同的条件串。

测试 9 条:逐一覆盖六种原因、可用容器不被拒、超管跨租户放行,以及一条守住边界——
原因必须出现在日志里、且不得出现在错误对象上。注意仓库自带的那条"缺失与跨租户不可
区分"的测试在本地环境本就是失败的(与同文件另两条同批),不能倚仗,故另行断言。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
普通用户持有某个知识空间的 manage_permission,打开授权对话框选人时 500:
「Quit that! You don't have rights to view this.」——能管这个空间的权限,却一个人
都列不出来,对话框直接废掉。

`/user/list` 的非管理员分支只认四种**组织类**身份:超管、用户组管理员、部门管理员、
子租户管理员。资源级的 manage_permission 不属于其中任何一种,于是落到
`org_scoped_ids is None` 那支抛 500。而紧挨着的下一行,「有组织范围但范围为空」返回
的是 200 + 空页。这两种情况对调用方是同一件事——通过这个接口你看不到任何人——一个
报错一个返回空,是分支写漏了。500 还附带「你没有权限查看」,比空页泄露得更多。

改为同样返回空页。前端的选人组件本就按「非管理员只拿到管理员范围的并集」设计,另有
一条同组同事的补充路径,接口不再抛错后那条路径就能正常工作。

**这只是止血,不是解决。** 授权选人本就不该走 `/user/list`:后者问的是「你能管理哪些
用户」,前者问的是「我能把这个资源授给谁」,两者权限模型不同。修完之后,空间管理员
仍然只能授权给自己的同组同事,组外的人依然找不到。要根治得为授权场景做专用接口,判
据是对该资源有 manage_permission——但「空间管理员到底能授权给谁」是产品边界,待定。

同文件还有一处同样形状的 500(用户组管理员按自己不管的 group 过滤时),语义上也该是
空页,但那是显式过滤条件、场景不同,未一并改动。

测试 5 条:四种「无可管理用户」的组合均返回空页,外加一条守住这次回归本身——scopeless
调用方不得被拒。回退代码验证过,新用例在旧代码下挂 2 个。user + tenant 全量与基线逐
条一致,无新增失败。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
两条走查反馈。

**动作与资源类型全是英文。** 目录里每个动作只有一个 `name` 列,而它被播种成 code 本身
(116 上 12 个动作的 name 全等于 code),于是面板直接把 `manage_permission`、
`knowledge_file` 甩给用户看。一个列也撑不起三语,所以标签改为按 code 在前端解析,
存的 name 退居兜底——将来目录新增动作时不至于显示空白。code 本身仍保留在名称下方作为
技术标识。补齐 12 个动作 + 9 种资源类型的中英日文案。

**初始化预设没有「空白」。** 下拉里只有占位项「选择预设」,选中它只是把「应用」按钮
置灰,于是模型能从预设获得动作,却再也清不空。新增一个空白预设,应用即清空选择。同时
把整块的渲染条件从 `presets.length > 0` 放开——服务端没有下发任何预设时,「清空」这个
能力本身依然该在。

测试 6 条:按 code 取标签、未知 code 回落到存储名、再回落到 code、资源类型同理、空白
预设清空选择、以及服务端零预设时空白项仍在。回落逻辑用直接单测覆盖而非组件层——测试
里的 i18n 桩返回 key 本身、不走 defaultValue,在组件层验不出真实行为。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Anthropic 官方 xlsx/pptx/docx skill 不能直接用:主干押在环境里不存在的东西上
(Node/pptxgenjs/docx-js、markitdown、pdftoppm、defusedxml),LICENSE 又是专有
条款,禁止 reproduce 与 derivative works。按 BiSheng 环境重写为适配版,落点是
bisheng/linsight/builtin_skills/,启动时由 builtin_skill_seeder 幂等 seed。

- bisheng-xlsx:openpyxl 主干 + LibreOffice 重算(recalc_check.py)+ 体检
  (inspect_workbook.py)。openpyxl 写出的公式没有缓存值,不重算等于交一张空表
  ——pandas、预览器、下游接口读到的全是 None,所以重算是硬要求不是可选项。
- bisheng-docx:python-docx 主干 + 体检(inspect_docx.py)+ 渲染预览
  (render_docx.py,用 PyMuPDF 替代缺失的 pdftoppm)。头号价值点是 w:eastAsia:
  run.font.name 只设西文,对中文完全无效,且是纯静默的排版错误。
- bisheng-pptx:补齐与另外两个包的差距——顶层 try/except(损坏文件原本会非零退出,
  执行器随即丢掉整份 stdout 报告)、E2B 前提、pandoc 归类(发布镜像装了 3.6.4,
  原先写成「不存在」)、体检阈值与规范条款的锚点绑定、读图风险的硬警告。

三处一致性缺陷值得单独记,它们都只有实跑才暴露:
- print(r.stdout or r.stderr) 在有 stdout 时静默吞掉 stderr,三个包共 10 处。
- 「结论: 通过」是 SKILL.md 给模型的停止条件,只能由 inspect_* 打印;recalc_check
  原本也打这句,模型会在体检之前收工,交出一份从没体检过的表。
- 「结论: 有条件通过」这一档让检查器不可满足:WARN 里有形状启发式与渲染宽度近似
  这类注定消不干净的规则,模型会陷入死循环烧光轮次。改成只有 ERROR 挡交付。

测试:新增 test_builtin_skill_scripts.py(64 项),双向断言——坏样本必须被拒且点出
具体规则,好样本必须真能到「结论: 通过」;只测坏样本会让「检查器永远报错」这种退化
蒙混过关。另含三条静态护栏:任何脚本不得非零退出、终止串只归 inspect_*、WARN-only
必须仍能通过。test/linsight/ 全量 827 passed(5 个 share-session 失败是本地无中间件
的既有环境问题,与本次无关)。

一并提交本次的依赖审计与沙箱选型两份调研,它们是走适配版路线的依据。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
NodeManager.release_task_ownership 全仓无调用方,测试里那行也只是给
MagicMock 挂属性、无任何断言。

park-and-release 的「release」实际落地为释放并发 semaphore
(handle_task_result),而不是删除 owner key;parked 任务靠
WAITING_FOR_USER_INPUT 会话状态避开 worker 启动时的 crash sweep
(check_and_terminate_incomplete_tasks 只扫 IN_PROGRESS)。

features/v2.6.0/035-linsight-task-mode/{design,tasks}.md 的 TB-3 仍写着
park 时要调 release_task_ownership。照着补上反而会制造 bug:会话行还是
IN_PROGRESS 时删掉 owner key,sweep 会命中 not owner_node_id 分支,把任务
误判成 "Worker node crash detected"。因此把这条约束写进
register_task_ownership 的 docstring,防止后人按历史设计文档重新引入。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he select

**弹窗一打开焦点就落在右上角 X 上。** Radix 在打开时把焦点移到第一个可聚焦子元素,
这里正好是关闭按钮,于是每次打开都有个焦点环扣在 X 上,像是默认选中了它。加
`onOpenAutoFocus` 阻止这次自动聚焦——仓库里已有五处这么写(ConfirmContext、
MoveToDialog 等),沿用同一写法。权限管理与新增授权两个弹窗都补上。

**「统一授权」下拉框右边被截掉一点。** 那一行带 `overflow-hidden`,而下拉框有
`focus-visible:ring-2`——焦点环画在盒子外面,聚焦时正好被父容器裁掉,所以只缺一点点
而不是缺一大块。左侧摘要的截断由它自己那层 `overflow-hidden` 负责,外层这个是多余
的,去掉即可,截断行为不受影响。

测试补了一条:弹窗打开后关闭按钮不得持有焦点。**但本地跑不了**——client 的 jest 因为
原生 canvas 未编译,所有 suite 都起不来(今天多次确认,与本次改动无关),只能靠 CI。
typecheck 与 eslint 均通过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…g ancestors

上一版只去掉了下拉框所在那一行的 `overflow-hidden`,但它到弹窗之间**一共套了三层**:
`DialogContent`(dialogClassName)、`PermissionDialog.tsx:247` 的列容器、以及 `:281`
那个直接包住 GrantTab 的 wrapper。最后这层没有任何内边距,下拉框正好贴着它的右边缘,
而焦点环是画在盒子外面的,于是仍被削掉一条。

两处一起改,不再依赖父容器怎么写:焦点环改为 `ring-inset`,画在控件自己的盒子里,
无论上面套多少层 overflow 都不会被裁;同时给该行补 `pr-1`,让原生 select 的控件边缘
也留出余量。

注意:本地无法在浏览器里验证——client 的 jest 因原生 canvas 未编译整体起不来,我也没有
可截图的运行环境。以上是按 CSS 机制推导的修复,同时覆盖「焦点环被裁」和「原生控件边缘
贴边」两种可能,因为仅凭截图无法区分二者。若还有残留,请告诉我是聚焦时才出现还是常态,
这能直接定位是哪一种。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nds (UI only)

被授予「所有者」的普通用户登录后,能改动其他所有者的权限。产品规则确认为:所有者不
能管理所有者,只有创建者的那份所有者可以。

**这是操作引导,不是安全边界,要清楚它挡不住什么。**目录里 `owner.allow_same_level`
仍为 true,OpenFGA 依旧判定所有者可授予 4 级,绕过界面直接调 `grants:mutate` 照样成
功。之所以先只做界面,是因为真正致命的那条路已经被后端堵死:创建者那条授权是
`protected`,`grant_service.py:326` 无条件拒绝任何针对受保护授权的修改与删除,与等级
和同级策略无关。所以剩余暴露面只是「一个普通所有者改另一个普通所有者」——同侪之间,
危害有限。要在语义上真正修好,得改权限模型能表达的东西(给受保护路径单独的授予标记,
需发布新 Authorization Model;或给创建者换一个模型,需改标准模型派生),成本都不低,
决策待定。

列表侧:`editable` 原本只看「本级 + 自定义模式 + 非受保护」,完全不管查看者自己够不够
格——这个缺口本就存在,只是被「允许同级」掩盖着。现在补上层级判断。新增授权侧同样过
滤掉 4 级模型,否则藏了编辑入口却还能新增一个同级所有者,等于没拦。

查看者是否创建者,从它已加载的名单里识别(`source.type === "CREATOR"` 且主体是本人)。
只有创建者自己那行能证明这件事,所以名单翻页翻过了那行就会读成「不是创建者」——**从严**
方向,对护栏而言是安全的;名单服务端排序、默认每页 50,实践中都在第一页。

测试 8 条,含边界:普通所有者不被误认、同 id 的部门/用户组不被误认、未登录、创建者行
不在当前页时从严、以及等级 1~3 与无等级模型不受影响。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Knowledge spaces are served by one backend tool (knowledge_space_retriever),
so the run log key is not a knowledge id and the lookup fell through to
"knowledge base deleted" even though retrieval had succeeded. Resolve the
name from the selected spaces instead, and extract the file's hardcoded
Chinese to i18n while we are here.
Any error frame locked the chat input and parked its raw text in the
placeholder, so a model 401 left a wall of provider JSON in the input box
with no way to retry. Only a dead conversation (assistant missing, deleted
or offline) locks the input now; runtime failures just raise a toast.
The roster role select is right-aligned, so the browser-drawn arrow sat on
top of the text. Hide the native arrow and draw our own chevron in reserved
padding.
…itors

The action-level and model-preset dropdowns were native <select> elements,
which do not follow the design system. Switch both to the bs-ui (Radix)
Select, add the jsdom stubs Radix popups need, and drive them from the
tests through a shared selectOption helper.
…eator

「继承上级」视图的两条走查反馈。

**继承来源显示成 `knowledge_space:3377`。** 权限模块按设计只持有资源的身份、不认识业务
名称——这是为了让权限逻辑不依赖各业务模块。人名早有一条现成通道(业务侧回答「这些用户
叫什么」),资源名称没有,所以只能吐编号。现在照人名那套给资源名开一条对称的通道:权限
侧拿到父资源的类型与 id,交由业务侧解析名称,随行返回 `inherited_from_name`。前端优先
显示名称,拿不到才回落到编号——所以未来新增的资源类型即便还没接上,也只是退回今天的样
子,不会显示空白。

之所以不让前端按类型分头去查:名称是业务自己的事实,让界面替业务回答「我是谁」,等于把
业务知识散进 UI;九种资源类型各有各的接口,以后加类型漏改的一定是界面那边。

**继承来的创建者重复显示。** 继承模式下本级只保留受保护行(本资源的创建者),同时又把上
级的全部行拉进来,上级创建者恰好同一人,于是出现两个 admin。那一行既不可编辑也不代表额
外权力——本级创建者已经涵盖——纯粹是噪音。在后端过滤而不是前端隐藏:前端隐藏只是不显示,
数据里仍是两条,导出、审计等其他消费方照样重复。

测试 3 条覆盖资源键拆分(含无分隔符的容错)与继承行过滤。permission + tenant 全量 19 个
失败与基线逐条一致,无新增。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
100vw counts the document scrollbar, so calc(100vw-184px) overshot the free
space beside the nav rail whenever one was showing. That forced a horizontal
document scrollbar, which cost vertical space, which forced a vertical one —
a second scrollbar outside the page's own scroll area.
…oll area each

The action tab scrolled its whole pane on top of the board, and the model
editor rode the same outer scrollbar as the model list. Now the level columns
are the only thing that scrolls on the action tab, and the model list and the
editor form scroll independently — the editor's title bar and save bar stay
put.
…king done

三条反馈,后两条是同一个根源:模型编辑器把「生成草案」当成了「操作已完成」。

**内置工具不该有权限入口。** 预置工具在服务端只认 `visible` 与 `use` 两个动作
(`SYSTEM_TOOL_ACTIONS`),而 `grantable-models` 内部以 `manage_permission` 解析目标,
必然被拒(25001)。入口之所以出现,是 `useResourceActions` 里那条管理员短路——**只要
是管理员就把请求的动作全部当作已授予,连问都不问**。管理员确实权限无边界,但预置工具
对任何人都不存在「管理权限」这回事。工具页据当前分类隐藏该入口。同样的坑还在预置看板、
只读频道、系统应用、共享资源上,它们各有各的「不支持该动作」限制,走那条短路都会翻车
——这次只堵了工具。

**删除按钮依据本地开关,服务端却还没停用。** 关掉「启用」开关只改本地状态,删除按钮
立刻可点,而服务端看到的模型仍是启用的,于是「custom model must be inactive before
deletion」。改为依据已发布的 `model.active`,并在禁用时给出「请先停用并发布」的悬浮
说明。

**删除后刷新模型还在。** 因为删除只是创建了一份 DELETE_MODEL 草案,不发布什么都不会
发生。而那条蓝色提示只写「预计影响 N 个资源」,从头到尾没说尚未发布——和动作等级看板
之前那个「拖拽完以为保存了」是同一个毛病。提示改为明说未发布,按钮从「查看影响」改为
主按钮「发布更改」。

测试 2 条:拨动开关不得解锁删除、草案生成后必须显示未发布并给出发布入口。36 个相关用
例、typecheck、eslint、三语校验均通过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dolphin and others added 9 commits August 12, 2026 16:44
删除一个模型原本要走四步:关掉启用 → 发布 → 点删除 → 再发布。中间那次发布最容易漏,
漏了模型就还在,用户看到的是「删了但刷新后又回来了」。

后端:删除的前置检查读的是**基准版本**的 active,所以同一批里先停用再删除,仍会被判
定为「必须先停用」——两次变更被迫拆成两次发布。改为按**这批发布出去之后**的状态判断。
「未被任何 Grant 引用」那条保护不变,它才是真正防止误删的那道闸。

前端:删除改为一次确认即完成——若模型仍启用,一并带上停用,生成一份草案后立即发布。
之所以敢自动发布而不弹影响确认:能被删除的模型必然无人引用,影响面为零,没有什么可
供权衡;而多一次确认换来的是四步流程和一个容易漏的中间态。删除本身是破坏性操作,所以
补了一次 bsConfirm 明确点名模型,并说明立即生效不可恢复。

顺带撤掉上一版加的「请先停用并发布」提示——那条是在为绕的流程做解释,现在流程不绕了。

测试:后端 2 条(停用+删除同批可成、单独删除启用中的模型仍被拒);前端 2 条(一次点击
即调用删除且不留悬空草案、已停用的模型不重复停用)。原先「必须先停用才能删除」的用例
描述的是已不存在的流程,一并移除;「草案未发布」的用例改为覆盖保存路径,那里仍然适用。
permission 13 个失败与基线一致,前端 36 个用例、typecheck、eslint、三语校验均通过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lently

删除自动发布的链路没有错误处理。请求层只对 license 过期这类特例自动弹窗,其余错误要靠
调用方包 `captureAndAlertRequestErrorHoc`,而这条链路没包——发布若失败(例如期间有人
发布了别的变更导致版本冲突),确认框照常关闭、按钮恢复,模型还在原地,但没有任何提示,
用户只会以为「删了又回来了」,正是这次要修掉的那种体验。

补上失败提示。是回答「点删除后自动关闭启用、执行删除、然后自动发布?」时顺带发现的:
把流程讲清楚的过程中才注意到失败分支是哑的。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ot source

走查三条中的两条(第三条见下)。

**切回「继承上级」时影响数恒为 0。** 确认框读的是 `snapshot_sources`,而那是 CUSTOM
方向把继承成员复制下来时才填充的;反方向只保留受保护行、丢弃全部普通本级授权,却一条
都不计。于是「本级新增两人 → 切回继承」显示「影响 0 个授权对象」,实际正要移除这两人。
改为两个方向都计入:复制下来的 + 丢弃掉的。

**`SNAPSHOT_FROM_PARENT` 没有文案。** 切到本级后,由继承复制而来的成员,来源标签直接
显示成 `f048_permission.source.snapshot_from_parent`。两个前端都缺,一并补上;顺手把同
样缺失的 `space_membership`、`channel_membership`、`other` 也补齐,它们是同一个枚举里
的兄弟,迟早会以同样的方式露出来。

测试 4 条覆盖丢弃集合的选取:普通授权计入、受保护的创建者不计、已停用的不计、无本级
成员时为空。permission 13 个失败与基线一致。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t picker

A subject may hold several permission models, so the picker only greys out a
row when the uniform-grant box sits on the very model that subject already
has. Anyone granted under a different model therefore rendered as untouched,
which reads as lost state. Label those rows with the model they hold instead,
leaving them selectable, and fill in the four grant-source labels the badge
was missing (SNAPSHOT_FROM_PARENT and friends rendered as raw keys).
The model-deletion test landed with an `any` parameter and no suppression
entry, which fails `pnpm lint` for the whole branch.
图片索引原本是 Record<cellAddress, imageId[]>,没有 sheet 维度,getCellImage
也只按单元格地址查、不看 activeSheet —— 任何 sheet 的图都会漏到所有 sheet 的
同名单元格。一个首个 sheet 是整页报表截图的文件因此表现为:第一个 sheet 空白,
图跑到第二个 sheet 的 B2。

图归属只能顺 OPC 关系链查出来(workbook.xml 的 r:id → workbook.xml.rels →
worksheets/sheetN.xml → sheetN.xml.rels 的 drawing → drawings/drawingM.xml),
sheet 顺序和文件编号没有关系,删过 sheet 就会留下空号,不能按序号配对。

同批修掉的其它缺陷:
- 只有单元格没有 drawing 的 sheet 被判成「无数据」,纯图片 sheet 永远空白
- cleanData 删空行空列后坐标被压缩,锚点却按原始网格寻址,表中间有空行就错位
- 只认 twoCellAnchor,oneCellAnchor(WPS/报表导出常用)与 absoluteAnchor 的图不显示
- 按 xdr: 字面前缀匹配标签,换个生成器的命名空间前缀就整体失效
- DISPIMG 找不到对应图时返回 images[0],拿第一张图冒充
- cleanData 取值用 falsy 判断,把数字 0 吞成空串

锚在数据区之外的图不再静默消失,与纯图片 sheet 一起按 EMU 换算出的原始尺寸
展示。原文件 643 行已超单文件 600 行上限,顺势拆成 types / sheetUtils /
xlsxImages / ImageGallery 四个模块。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Excel 解析链路上一直挂着 ImageUploadTransformer,但它读的是
loader.local_image_dir,而 Excel loader 从不产出图片(image/media/drawing 在
excel.py 里零命中,md_from_excel 只用 openpyxl 读单元格的值),所以第一道
判断就返回了 —— 表格里的图一张都没落盘。对首个 sheet 整页是报表截图的文件,
等于半份内容在知识库里不存在。

新增 utils/excel_images.py 顺 OPC 关系链解析图片归属:workbook.xml 的 r:id
-> workbook.xml.rels -> worksheets/sheetN.xml -> sheetN.xml.rels 的 drawing
-> drawings/drawingM.xml -> media。不用 openpyxl:它只通过私有的
worksheet._images 暴露图片,且会丢弃自己不认识的 anchor。sheet 顺序与文件
编号无关(删过 sheet 会留空号),只能顺链查,不能按序号配对。

图片按 sheet 单独成段,而不是并进表格 markdown:只有图没有单元格的 sheet
(报表导出常见)根本不产出 markdown,而且 excel_file_to_markdown 的
sheet_index 会跳过空 sheet,没有可挂靠的 chunk。段首带 sheet 名,让这一段
至少有可检索的上下文。

loader 只把字节暂存到 local_image_dir,上传仍由 ImageUploadTransformer 完成,
遵循 BaseBishengLoader 的图片契约。csv/xls 不进这条路径 —— 前者没有 OPC 包,
后者是 OLE 容器。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
「我能把这个资源授权给谁」和「我能管理哪些用户」是两个问题,判据不同:前者是对该资源
持有 `manage_permission`,后者是组织管理员身份。2.6 的选人接口问的正是前者,路径里带着
资源上下文(`…/resources/knowledge_space/21011/grant-subjects/…`)。

F048(`edcbe81b`)把这组接口删掉、两个前端改调通用的组织管理接口,判据就此换成了组织
身份。于是一个知识空间的管理员——他有权管这个空间的权限,却不是任何部门/用户组的管理
员——用户列表为空、部门树报无权限。同一批症状还包括看板 `visible` 判断失效,都是 F048
迁移时配套能力没跟上、退回了不匹配的老路径。

(顺带澄清:6-30 那次「删除死代码」是清白的,它删的是 eager 全量树版本,当天上午刚上
lazy 替代品;真正丢掉能力的是一个月后的 F048。)

恢复五个端点:users / user-groups / departments{children,search,path-tree},判据改为
F048 的 `manage_permission`——旧实现依赖的 `FineGrainedPermissionService` 与旧
`PermissionService` 已随 F048 退役,不能照搬。租户取自校验后的 target 而非调用方,超管
为别的租户的资源选人时才能看到那个租户的人。

查询逻辑照搬,含两处不能丢的细节:部门知识空间只能选该部门子树内的人(F033,判据取自
绑定关系而非客户端入参,直接调接口也绕不开);关键字用前缀匹配以保住 user_name 索引
(F038,前后通配会让 15 万行的表全表扫描)。`has_children` 按层批量算,不做 N+1。

按 C1 分层:查询下沉到 domain service,端点只做路由与鉴权——初版直接在端点里引用
database/models,被 arch-guard RULE-3 拦下。

测试 8 条:五个路由均已注册、资源类型覆盖所有可授权容器、无 manage_permission 被拒、
未知资源类型被拒、只有知识空间可能受部门绑定约束、租户取自资源。

前端切换在下一步——本提交只恢复后端,两个前端仍走旧路径,行为不变。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
两个前端的用户/用户组/部门选择器切到上一提交恢复的资源维度接口。
判据从「你管得着谁」变回「谁可以被授予这个资源」——空间管理员往往
不是任何组织管理员,所以用户列表为空、部门树报无权限,而他本人恰恰
有权授权。

- 后端补齐:候选人带上工号与主部门路径(client 选择器要显示);
  部门层级/搜索/定位改用组织树同一套剪枝装配,形状一致,
  否则搜索结果会把层级拍平成一堆平级行
- 删掉 platform 用户选择器里「同组同事兜底」那套补丁——它本就是
  列表为空的绕行,接口修好后没有存在意义
- 相应重写两个前端测试;eslint 冻结基线只减不增

后端 test/permission + test/department 与基线一致(21 项既有失败),
两端 typecheck / lint / check-i18n 通过。
@dolphin0618
dolphin0618 merged commit 2d4526d into release Aug 12, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants