feat: add Feishu user authorization flow documentation and update frontend guidelines

- Added new documentation for the Feishu user authorization flow, detailing the backend protocol, authorization initiation, callback handling, and token management.
- Updated frontend index to link to the new authorization flow documentation for better accessibility and guidance on UI design consistency.
This commit is contained in:
yuxuanhui
2026-08-31 09:18:03 +08:00
parent 3d838866cd
commit dcd6d44960
15 changed files with 3817 additions and 732 deletions
@@ -37,7 +37,7 @@ Base 适合保存一行一个产物及其可查询状态;Wiki/Docs 适合保
- 在不删除或转换原字段的前提下,将目标表验证为共 23 个字段,包含状态、类型、负责人、进度、证据、父项和依赖等工作流字段。
- 创建并分段追加 Wiki Docx,最终回读 revision 6,确认写入内容可取回。
- 创建 Base POC 记录并用真实 record ID 回读,确认标题、文档链接、状态、完成度、验收标准和验证证据。
- 执行经验固化位置:`/Users/yuxuanhui/.agents/skills/setup-matt-pocock-skills-feishu/references/issue-tracker-feishu.md`。
- 执行经验固化位置:`/Users/yuxuanhui/.agents/skills/setup-workflow-skills-feishu/references/issue-tracker-feishu.md`。
- 已省略实际 Base/Wiki URL、token、record ID 和组织信息;这些值只属于目标环境,不属于通用经验。
- 第二轮将原文本字段原地重命名为“产物文档”,保持同一 field ID、`text/plain` 类型和既有链接值;新增来源链接、外部编号、报告人、最后反馈时间、最后分诊时间后,完整字段回读为 28。
- 第二轮创建并回读 1 条 Spec、2 条 Ticket 和 1 条 Issue;Ticket 采用“两遍写入”,先建所有记录,再写父项和 blocker,逐条确认链接字段。
@@ -0,0 +1,130 @@
---
id: 20260805-request-package-multi-instance-dev-backend
title: 统一 request 包并按目标系统创建多个请求实例
created: 2026-08-05
updated: 2026-08-05
status: candidate
scope: global
category: frontend-architecture
confidence: medium
last_verified: 2026-08-05
promotion_target: pattern
projects:
- oppein-knowledge
tags:
- request-package
- http-client
- multi-instance
- oauth2
- vite-proxy
- environment-config
---
# 统一 request 包并按目标系统创建多个请求实例
## Trigger
前端项目同时调用多个后端系统,且不同接口需要不同的 `baseURL`、请求头、认证令牌或开发代理路径;尤其是本地开发时,业务服务可能需要访问本地后端,而登录、用户、菜单等接口仍需要访问外部网关。
## Context
本次欧派学习平台前端改造中,项目引入统一的 `@oppein-react/request` 包,替换业务侧分散的请求封装。项目存在两类请求目标:
1. 当前项目后端接口,例如课程、讲师、资料和题库接口。
2. 欧派外部网关接口,例如 OAuth 登录、退出、当前用户、菜单和用户中心接口。
本地开发还存在两种运行方式:
- 当前项目后端运行在 `http://abc.oppein.com:8080`;
- 当前项目后端通过 UAT 网关的 `https://apigatewayuat.oppein.com/knowledge` 路由访问。
如果所有请求共用一个客户端,容易把外部网关接口发到项目后端,或者把项目后端接口发到网关;如果把环境变量的上游目标地址直接当作浏览器请求地址,又会绕过 Vite proxy,产生跨域或本地端口不可达问题。
## Evidence
- `assp-knowledge-frontend/src/utils/request-client.ts:3-9`:统一从 `@oppein-react/request` 引入 `createRequest`、错误类型和请求类型。
- `assp-knowledge-frontend/src/utils/request-client.ts:100-105`:`knowledgeRequestClient` 使用当前项目后端请求基址,并按本地后端目标决定是否注入本地 JWT。
- `assp-knowledge-frontend/src/utils/request-client.ts:123-145`:分别暴露 `knowledgeRequest` 和 `gatewayRequest` 两个请求实例;前者服务当前项目后端,后者服务欧派外部网关。
- `assp-knowledge-frontend/src/utils/request-client.ts:59-80`:固定应用头、`Oauth2-AccessToken` 和本地 `Oauth2-Jwt` 由动态 Header provider 统一生成,业务 API 不再重复拼装。
- `assp-knowledge-frontend/src/common/config.ts:23-34`:把环境变量提供的上游目标地址与浏览器实际请求基址分开;开发环境分别使用 `/knowledge` 和同源空基址进入 Vite proxy。
- `assp-knowledge-frontend/vite.config.ts:29-38`:`/knowledge` proxy 根据目标地址是否包含路径前缀决定 rewrite;本地 8080 去掉 `/knowledge`,网关目标保留 `/knowledge`。
- `assp-knowledge-frontend/vite.config.ts:90-98`:当前项目后端统一使用一个 `/knowledge` proxy,外部网关仅保留 `/oauth`、`/ucenterapi`、`/user` 三类入口。
- `assp-knowledge-frontend/.env.example:3-36`:已记录本地 8080、UAT 网关和生产网关的配置差异,明确网关模式的 `VITE_API_BASE_URL` 必须包含 `/knowledge`。
- 2026-08-05:通过 TypeScript 检查、UAT/生产构建、`git diff --check` 和临时 mock upstream proxy smoke test 验证请求实例与 rewrite 方案;本地目标收到 `/auth/currentUser`,网关目标保留 `/oauth/logout`、`/ucenterapi/*` 和 `/user/*` 原始路径。
- 2026-08-05:实际 UAT 路由对比验证:`https://apigatewayuat.oppein.com/course/list` 和不带前缀的讲师接口返回 `404`,带 `/knowledge` 的对应接口返回 `400`,证明网关路由前缀必须保留;`400` 来自缺少真实认证或业务参数,不代表路由不存在。
## Root cause
已验证:请求客户端的职责不只是发送 HTTP,还携带目标系统的认证和路径契约。当前项目后端与外部网关的请求头、令牌来源、错误处理和 URL 入口不同,因此必须使用多个由同一 `request` 包创建的实例,而不是在业务 API 中手写不同的 `fetch` 或修改单个全局客户端。
已验证:开发环境需要把“上游目标地址”和“浏览器请求地址”分离。浏览器只能请求同源的 `/knowledge`、`/oauth`、`/ucenterapi`、`/user`,Vite proxy 再根据 `VITE_API_BASE_URL` 和 `VITE_EXTERNAL_API_BASE_URL` 转发到本地后端或网关。
已验证:网关路由前缀是目标地址的一部分。`VITE_API_BASE_URL=https://apigatewayuat.oppein.com` 会使 `/knowledge/course/list` 被 rewrite 成 `/course/list`,从而命中网关 `404`;正确的网关配置必须是 `https://apigatewayuat.oppein.com/knowledge`。
推断:以后遇到“一个前端项目在开发态切换本地后端与远程网关”的项目,优先设计多个语义明确的 request 实例,并让 proxy 根据目标 URL 的 pathname 处理前缀,比为每个业务接口复制代理规则更稳定。
## Preferred action
1. 先按目标系统划分请求实例,而不是按页面或接口数量划分:
- `knowledgeRequest`:当前项目后端;
- `gatewayRequest`:外部 OAuth、用户、菜单和权限网关。
2. 所有实例都通过统一 `request` 包创建,复用请求方法、错误类型、响应处理和动态 Header provider。
3. 把认证差异放在实例配置或 Header provider 中:
- 网关实例统一注入 `AppCode`、`SubAppCode`、`X-Requested-With` 和 `Oauth2-AccessToken`;
- 本地项目后端按需注入 `Oauth2-Jwt`;
- 业务 API 不重复手写认证头。
4. 在配置层保留两组概念:
- `apiBaseUrl` / `externalApiBaseUrl`:环境变量提供的上游目标地址;
- `apiRequestBaseUrl` / `externalRequestBaseUrl`:浏览器实际请求基址。
5. 开发环境始终优先使用同源相对路径:
- 当前项目后端使用 `/knowledge`;
- 外部网关使用 `/oauth`、`/ucenterapi`、`/user`。
6. 统一 proxy 时,让目标 URL 的 pathname 决定是否追加或保留服务前缀:
- 本地 `http://abc.oppein.com:8080`:`/knowledge/foo` → `/foo`;
- 网关 `https://apigatewayuat.oppein.com/knowledge`:`/knowledge/foo` → `/knowledge/foo`。
7. `.env.example` 必须同时给出本地后端和网关模式,并明确“本地目标不带 `/knowledge`、网关目标必须带 `/knowledge`”。
8. 验证时至少分别检查一个项目后端接口和一个外部网关接口的最终上游路径,不要只检查浏览器看到的相对 URL。
## Boundaries
- 这是“多个目标系统共享同一 request 包”的模式,不意味着所有项目都必须命名为 `knowledgeRequest` 和 `gatewayRequest`。
- 如果所有接口确实属于同一个后端系统,单一 request 实例更简单;不要为了形式创建多个实例。
- `VITE_EXTERNAL_API_BASE_URL` 的具体网关路径取决于网关契约;本条只约束当前项目后端的 `/knowledge` 路由必须与目标地址保持一致。
- 代理只能解决开发环境的同源转发,不能替代生产环境的网关路由、鉴权配置或 CORS 策略。
- 没有真实登录态时,网关接口返回 `400`、`401` 或业务错误不能单独证明代理失败;应先区分“路由是否命中”和“鉴权/业务参数是否有效”。
## Failed approaches
- 用一个 request 实例承载当前项目后端和外部网关:请求可能命中错误服务,且认证头边界不清晰。
- 在每个业务 API 中直接写 `fetch`、`axios` 或手工拼接 `Oauth2-AccessToken`:会绕过统一错误处理和动态令牌注入,后续修改难以收敛。
- 把 `VITE_API_BASE_URL=https://apigatewayuat.oppein.com` 当作 UAT 项目后端配置:Vite 会把 `/knowledge/course/list` rewrite 成 `/course/list`,网关返回 `404`。
- 把绝对上游地址直接作为开发环境浏览器 base URL:会绕过 Vite proxy,导致跨域或无法访问本地端口。
- 为 `/material`、`/course`、`/lecturer` 等每个业务前缀分别复制 proxy:本地后端和网关环境的路径规则容易逐条漂移,新增接口还要重复改配置。
## Examples
```ts
const knowledgeRequestClient = createRequest({
baseURL: apiRequestBaseUrl,
headers: createKnowledgeHeaders,
});
const gatewayRequestClient = createRequest({
baseURL: externalRequestBaseUrl,
headers: createGatewayHeaders,
});
```
```env
# 本地后端
VITE_API_BASE_URL=http://abc.oppein.com:8080
VITE_EXTERNAL_API_BASE_URL=https://apigatewayuat.oppein.com
# 或:UAT 网关
VITE_API_BASE_URL=https://apigatewayuat.oppein.com/knowledge
VITE_EXTERNAL_API_BASE_URL=https://apigatewayuat.oppein.com
```
## Promotion record
- Not promoted. 当前先作为候选经验保存在中央知识库;如果后续在另一个项目或第二次独立改造中复用并验证,再考虑提升为通用 pattern、模板或自动检查。
@@ -0,0 +1,101 @@
---
id: 20260814-docker-postgresql-gitea-same-host-deployment
title: Docker Compose、外部 PostgreSQL 与 Gitea 同机部署需要显式拓扑契约
created: 2026-08-14
updated: 2026-08-14
status: promoted
scope: global
category: deployment
confidence: medium
last_verified: 2026-08-14
promotion_target: skill
projects:
- zhixing-system
tags:
- docker-compose
- postgresql
- gitea-actions
- self-hosted-runner
- single-host-deployment
---
# Docker Compose、外部 PostgreSQL 与 Gitea 同机部署需要显式拓扑契约
## Trigger
创建新的个人 Web 项目,并准备继续采用 Docker、PostgreSQL、Gitea Actions 与单机反向代理组合;或者需要从已有项目复制部署文件,但不确定哪些内容是通用骨架、哪些内容绑定当前服务器或业务 Job。
## Context
`zhixing-system` 使用前后端分离源码、Docker Compose 编排和 Gitea Actions 部署。开发环境由 Compose 启动项目专属 PostgreSQL;生产环境复用 1Panel 管理的 PostgreSQL,通过外部 Docker 网络连接。Gitea Runner 在目标 Docker 主机上检出代码、原地构建镜像并更新同一个 Compose 项目,不经过镜像仓库。
这套方案适合低运维成本的单机个人项目,但真正可复用的不是项目名和端口,而是以下契约:开发与生产数据库拓扑分离、生产配置失败即停止、常驻服务与一次性 Job 分离、容器身份与数据卷权限匹配、内部服务不直接暴露公网、部署流程必须验证配置和健康状态。
## Evidence
- 2026-08-14 检查 `zhixing-system/docker-compose.dev.yml`:开发 Compose 包含 `postgres:16-alpine`、数据库健康检查、持久卷、后端和前端热更新卷;`migrate` 与 `market-sync` 放在 `jobs` profile 中。
- 2026-08-14 检查 `zhixing-system/docker-compose.prod.yml`:生产 Compose 不启动 PostgreSQL,后端与 Job 通过外部 `1panel-network` 访问数据库;`ZHIXING_DATABASE_URL` 使用 Compose 的 `${VAR:?message}` 形式禁止回退到开发数据库;Web 仅绑定 `127.0.0.1:8111`。
- 2026-08-14 检查 `zhixing-system/.gitea/workflows/deploy-production.yaml`:`main` push 或手动触发后,执行 checkout、两种 profile 的 Compose 校验、原地 build/up、迁移、Web 健康检查,并在成功或失败时执行 `docker compose ps`。
- 2026-08-14 检查 `zhixing-system/zhixing-server/Dockerfile` 与 `zhixing-system/zhixing-web/Dockerfile`:开发/生产多阶段构建;生产后端使用 UID 10001 非 root 用户;前端以 Nginx 提供静态文件,并在容器启动时注入后端 upstream;两个镜像都包含健康检查。
- Git 提交 `42942af` 将生产 Web 端口从所有地址改为只绑定 loopback,并删除完成使命的 Runner smoke-test workflow。
- Git 提交 `afdc5ca` 将生产内置 PostgreSQL 改为 1Panel 外部 PostgreSQL,增加 Gitea secrets、生产连接串强制校验、Job profile 校验和数据库迁移步骤。
- Git 提交 `eabf102` 证明镜像构建阶段对目录 `chown` 不足以覆盖运行时新建的 named volume;项目增加 root 身份的 `market-data-init` 一次性容器,将卷目录归属修正为 UID 10001。
- Git 提交 `9023e00` 在 Debian 与 Alpine 生产镜像中安装时区数据,同时通过镜像和 Compose 显式统一 `Asia/Shanghai`。
- 使用无敏感值占位连接串实际执行以下四项配置校验,退出码均为 0:开发默认 profile、开发 `jobs` profile、生产默认 profile、生产 `jobs` profile;命令均为 `docker compose --env-file /dev/null -f <file> [--profile jobs] config --quiet`。
- 中央知识库在写入前搜索 `docker`、`compose`、`postgresql`、`gitea` 等关键词,未发现同根因条目。
## Root cause
已验证:
- 开发与生产的 PostgreSQL 拓扑不同。开发依赖 Compose 内部服务名 `postgres`;生产依赖预先存在的外部网络、稳定数据库网络别名和单独管理的凭据。试图让同一份默认连接串覆盖两者,会把开发便利性带入生产并掩盖配置错误。
- Docker 镜像内的目录所有权与运行时 named volume 的所有权不是同一件事。卷首次挂载时可能由 root 初始化,导致非 root 应用无法写入;需要显式的卷初始化或宿主机预置步骤。
- Gitea 同机部署依赖稳定的 `COMPOSE_PROJECT_NAME` 维持容器、网络和 named volume 身份,而不是依赖 Runner 每次 checkout 的绝对路径。
- `depends_on.condition` 只有在依赖服务定义了可用的 healthcheck 或一次性 Job 能正确返回退出码时才有意义。
- 只把生产入口绑定到 loopback,可以让 1Panel/Nginx/Caddy 承担公网 TLS 与路由,避免应用容器端口绕过反向代理直接暴露。
推断:
- 当前 Gitea workflow 先 `up -d --build`、后执行迁移。若未来应用启动依赖新 schema,或者迁移包含破坏性变更,这个顺序可能让新容器先进入不兼容状态。新项目必须显式选择“先迁移再切换”或 expand/contract 迁移,而不是机械复制当前顺序。
- 当前 workflow 没有在仓库内表达部署串行化、自动回滚、数据库备份、镜像留档或 Runner 配置。单 Runner、低流量个人项目可能暂时接受,但这些能力不能被误认为已由 Gitea/Compose 自动提供。
- 当前直接在目标主机构建镜像,部署简单,但缺少不可变镜像版本和快速回滚点;当构建变慢、主机增多或可用性要求提高时,应改为构建并推送带提交 SHA 的镜像,再由生产 Compose 拉取指定版本。
## Preferred action
新建同类型项目时,先复制结构,再替换项目参数,不要直接复制当前业务变量:
1. 建立 `Dockerfile` 的 development/production target;依赖使用 lockfile 固定,生产阶段只包含运行依赖,服务进程使用固定的非 root UID,并为每个容器提供不依赖额外调试工具的健康检查。
2. 保留独立的 `docker-compose.dev.yml` 与 `docker-compose.prod.yml`。开发 Compose 可以内置 PostgreSQL、源码热更新卷和有边界的本地默认凭据;生产 Compose 不应包含开发凭据或隐式数据库 fallback。
3. 生产 PostgreSQL 通过外部网络接入时,显式声明 `external: true`,使用稳定网络别名,不引用易变化的容器实例名;连接串由 Gitea secret 注入,并用 `${DATABASE_URL:?Set ...}` 在配置展开阶段失败。
4. 把 migration、seed、backup、cron task 等一次性命令建模为 Compose profile 下的 Job。Job 通过退出码报告结果,不在常驻 Web 进程内部偷偷执行迁移或调度。
5. named volume 由非 root 服务写入时,增加幂等的 init Job,或在首次部署 runbook 中显式预置 UID/GID;不能只依赖 Dockerfile 中对镜像目录的 `chown`。
6. Web 容器只绑定 `127.0.0.1:<port>`,由宿主机反向代理统一处理公网域名与 TLS;浏览器使用同源 `/api`,Nginx upstream 通过运行时环境变量指向 Compose 后端服务名。
7. Gitea workflow 至少包含:checkout、默认与 Job profile 的 `docker compose config --quiet`、build、迁移策略、`up -d --remove-orphans`、应用级健康检查、`if: always()` 的服务状态输出。固定 `COMPOSE_PROJECT_NAME`,并明确 Runner 必须运行在目标主机且具备所需 Docker 权限。
8. 在第一个会修改 schema 的版本之前确定发布顺序:低停机项目可在维护窗口执行备份、迁移、启动、健康检查;需要连续可用时使用向后兼容的 expand/contract migration。任何顺序都必须提供失败后的数据库与应用回退说明。
9. 为同一生产环境增加部署串行化,并记录可回滚版本。若继续目标主机本地构建,至少保留上一个 Git commit 和镜像 tag;若改用镜像仓库,则以 commit SHA 标记并部署固定 tag。
10. 首次部署 runbook 单独检查:外部网络存在、数据库别名可解析、数据库/用户已创建、密码已 URL 编码、TLS 模式与服务器一致、迁移可执行、volume 权限正确、反向代理只转发 loopback 端口、健康检查能从容器内和代理入口通过。
建议将未来模板参数化为:`PROJECT_NAME`、`COMPOSE_PROJECT_NAME`、`DATABASE_NAME`、`DATABASE_USER`、`DATABASE_HOST_ALIAS`、`EXTERNAL_NETWORK`、`WEB_LOOPBACK_PORT`、`TIMEZONE`、`BACKEND_HEALTH_PATH`。业务专用 token、同步 Job、数据目录和并发参数不进入通用模板。
## Boundaries
- 这套模式面向单机、单环境、低到中等流量的个人项目。多主机、高可用、蓝绿/金丝雀发布或受监管数据不应继续使用目标主机原地构建作为默认方案。
- `1panel-network`、`postgresql`、`8111`、`Asia/Shanghai`、UID 10001 和 `ZHIXING_*` 都是当前项目参数,不是全局约定。
- 生产 PostgreSQL 是否与应用同一台主机不是核心要求;核心是把数据库拓扑、TLS、凭据来源和网络边界显式化。远程数据库不需要 Docker external network。
- 时区是否使用 `Asia/Shanghai` 取决于业务契约。数据库时间戳仍应明确 UTC/带时区语义,不能仅靠容器 `TZ` 推断数据含义。
- healthcheck 只能证明探针覆盖的最小路径可用,不能替代迁移验证、关键业务 smoke test、数据库备份恢复演练和外部反向代理检查。
- 当前证据来自一个项目及其多次演进,尚未在第二个独立项目复用;在新项目完整验证前,不晋升为全局强制模板或自动生成 skill。
## Failed approaches
- 在生产 Compose 中保留内置 PostgreSQL 和开发默认连接串:后来改为外部数据库并用必填变量阻止错误回退。
- 将 Web 端口绑定到所有宿主机地址:后来收敛到 `127.0.0.1`,由反向代理作为唯一公网入口。
- 只在 Dockerfile 中创建并 `chown` 数据目录:运行时 named volume 仍可能由 root 初始化,最终需要单独的 init Job。
- 把一次性 Runner 能力检查长期保留在部署 workflow 集合中:验证完成后删除 smoke-test workflow,避免每次 push 运行无业务价值的检查。
- 把当前部署顺序视为通用答案:迁移与应用切换顺序必须由 schema 兼容契约决定。
## Promotion record
- 2026-08-14:按用户明确请求推广为单文件 Skill:`zhixing-system/bootstrap-docker-postgres-gitea/SKILL.md`。
- 验证:`python3 /Users/yuxuanhui/.codex/skills/.system/skill-creator/scripts/quick_validate.py /Users/yuxuanhui/bcc-github/quant-project/zgnb/zhixing-system/bootstrap-docker-postgres-gitea`,结果为 `Skill is valid!`。
- 当前仍只有 `zhixing-system` 的实战证据;Skill 保留单机个人项目边界和未验证项提示。待第二个项目复用并验证首次部署、升级、回退和数据库恢复后,再考虑提升为 `patterns/` 下的跨项目规范模式。
@@ -0,0 +1,144 @@
---
id: 20260822-extension-slot-portal-three-column-grid
title: 用生命周期插槽、稳定布局接缝和 CSS Grid 扩展宿主三列工作台
created: 2026-08-22
updated: 2026-08-24
status: candidate
scope: global
category: frontend-architecture
confidence: medium
last_verified: 2026-08-24
promotion_target: pattern
projects:
- oh-story-dsh
tags:
- host-extension
- session-slot
- react-portal
- css-grid
- container-responsive
- native-chat
---
# 用生命周期插槽、稳定布局接缝和 CSS Grid 扩展宿主三列工作台
## Trigger
插件需要在不修改宿主源码、不复制宿主 Chat 的前提下,把文件树、编辑器等工作区与宿主原生会话界面组成三列布局;宿主提供的官方扩展点在视觉上是 overlay,但真正需要改造的是 Session 内已有内容区的排版。
## Context
`oh-story-dsh` 需要在 DeepSeek Harness(DSH)中提供“文件树 / 编辑器 / 官方 Chat”三列创作工作台,同时继续使用 DSH 原生 Chat、streaming、tools、Todo、approvals、history 和 Composer。
这个实现没有修改 DSH Host 源码,也没有把整个工作台直接画成覆盖宿主内容的浮层。它把两个职责不同的接缝组合起来:
1. 通过官方 `shell.overlay` 注册 Session 级子插槽 `oh-story.workspace`,取得 `SessionProvider`、`sessionId`、`useSession` 和 Session 生命周期内的 Store。
2. 子插槽组件定位 DSH 明确作为稳定地址接缝的 `conversation.session`,再用 React portal 把工作台挂进该会话容器。
3. CSS 只在工作台 portal 确实存在时,把 conversation scroller 切成三列 Grid;文件树占第 1 列,编辑器占第 2 列,仍然挂载着的官方 Chat 和 Composer 占第 3 列。
关键不是“用 Grid 画三列”,而是先把状态生命周期、DOM 布局落点和原生能力所有权分开,再用最小的布局改造把三者拼起来。
这也说明应先区分两类需求:`conversation.view` 适合增加一个与 Chat 并列、切换显示的整页 Tab;当文件树、编辑器和原生 Chat 必须同时可见时,单独注册 view 不够,需要让新增工作区进入 Chat 当前视图的共同布局上下文。
## Evidence
- 仓库:[worldwonderer/oh-story-dsh](https://github.com/worldwonderer/oh-story-dsh)。2026-08-22 初次核对提交 [`8fa6786`](https://github.com/worldwonderer/oh-story-dsh/commit/8fa6786aa9d107caf3072c66ab5df334f07b69c0);2026-08-24 再次浅克隆并核对 HEAD [`fce73ca`](https://github.com/worldwonderer/oh-story-dsh/commit/fce73cafff535ab80316b74e427c539351759fbc),关键架构仍一致。
- [`docs/ARCHITECTURE.md`](https://github.com/worldwonderer/oh-story-dsh/blob/8fa6786aa9d107caf3072c66ab5df334f07b69c0/docs/ARCHITECTURE.md):明确 Browser entry 使用 `shell.overlay`,把文件树和编辑器 portal 到稳定的 `conversation.session` 布局接缝,并保留官方 conversation view。
- [`packages/dsh-plugin/src/client/index.tsx#L796-L895`](https://github.com/worldwonderer/oh-story-dsh/blob/fce73cafff535ab80316b74e427c539351759fbc/packages/dsh-plugin/src/client/index.tsx#L796-L895):`apply()` 在 `shell.overlay` 下声明 `{ "oh-story.workspace": { kind: "single", scope: "session" } }`;`WorkbenchSeat` 通过 `SessionProvider` 渲染子插槽;`CreativeSplitBridge` 查找 `[data-conversation-scroll] > [data-slot='conversation.session']` 并调用 `createPortal()`。该入口没有注册或替换 `conversation.view`。
- 同一文件中的 `CreativeSplitBridge` 使用 `ResizeObserver` 读取 conversation scroller 的 `clientWidth`,以 `<620`、`<900` 和其余宽度写入 `compact`、`medium`、`wide` 容器布局状态;卸载时移除 CSS 变量和 `data-oh-story-layout`。
- [`packages/dsh-plugin/src/client/plugin.css#L1-L75`](https://github.com/worldwonderer/oh-story-dsh/blob/fce73cafff535ab80316b74e427c539351759fbc/packages/dsh-plugin/src/client/plugin.css#L1-L75):只有当 `conversation.session` 中存在 `.oh-story-split-surface` 时,`:has()` 选择器才把 scroller 设为 Grid。wide 列轨为 `clamp(184px, 16%, 200px) minmax(240px, 1fr) clamp(408px, 40%, 520px)`;tree、editor、官方 Chat 分别进入第 1、2、3 列。
- 同一 CSS 把 `[data-composer-seat]` 放到第 3 列并设为 sticky,同时给 `[data-chat-flow]` 留出 Composer 高度,避免原生 Composer 覆盖最终消息;`min-width: 0`、`min-height: 0` 和各列 overflow 规则负责允许 Grid 子项正确收缩和滚动。
- [`plugin.css#L352-L358`](https://github.com/worldwonderer/oh-story-dsh/blob/fce73cafff535ab80316b74e427c539351759fbc/packages/dsh-plugin/src/client/plugin.css#L352-L358) 定义 medium/compact 列轨;源码未发现拖拽分隔条或 pointer/mouse move 调宽逻辑,因此当前列宽由 `clamp()` 和容器断点决定。
- [`scripts/native-dsh-smoke.ts`](https://github.com/worldwonderer/oh-story-dsh/blob/8fa6786aa9d107caf3072c66ab5df334f07b69c0/scripts/native-dsh-smoke.ts):原生 DSH smoke test 读取 tree、editor、Chat 和 Composer 的 bounding box,验证三列顺序、列宽至少 120px、Composer 位于 Chat 列内,并在滚动回归中检查 Composer 不消失。
- 2026-08-22 用户明确纠正:该项目“没有修改 DSH Host,也不是单纯浮层”;这是本条要保留的关键架构辨析。
- 2026-08-22 本次 capture 通过浅克隆核对当前提交、上述源码和测试代码;没有实际运行仓库的原生 DSH smoke test。
- 2026-08-24 用户提供该项目实际 UI 截图,视觉上与源码一致:左侧项目/文件树、中间 Markdown 预览、右侧原生消息流与 Composer 同时出现。截图证明最终呈现,不单独证明交互和卸载行为。
- 2026-08-24 Context7 将官方 DSH 解析为 `/deepseek-ai/deepseek-harness`;官方 [`ui-layout`](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/ui-layout/src/client/index.ts) 将 `shell.overlay` 声明为 root-scoped list slot,官方 [`ui-conversation` SlotMap](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/ui-conversation/src/client/contract/slots.ts) 将 `conversation.view` 声明为 session-scoped list slot,验证两者职责边界。
- 项目侧完整复核记录:`dsh-lark-cli/.scratch/research/oh-story-dsh-three-column-layout.md`。
## Root cause
已验证:`shell.overlay` 在此实现中承担的是官方扩展注册和 Session 生命周期桥接,不是最终三列布局的空间父节点。真正的布局落点是 `conversation.session` 所在的稳定 DOM 接缝。
已验证:官方 Chat 和 Composer 没有被插件重写。插件保留它们现有的挂载和状态所有权,只通过 Grid placement 把它们排到第 3 列;因此 Chat 内部能力仍由 DSH 实现。
已验证:三列布局的启用条件与 portal 内容共存。`.oh-story-split-surface` 消失后,`:has()` 不再命中,scroller 会自然退出插件 Grid 规则;组件清理逻辑同时移除容器状态。
已验证:该方案不是“利用 `shell.overlay` 直接画出三栏”。`shell.overlay` 只提供 root registration、SessionProvider 和自定义 Session slot;三栏空间关系发生在 portal target 所在的 conversation scroller。
已验证:注册新的 `conversation.view` 会得到一个与 Chat 并列、按 active id 单独渲染的视图,不能直接实现“工作区与原生 Chat 同时可见”。并存需求与切换需求必须使用不同扩展策略。
推断:这套设计稳定的原因是把“在哪里取得上下文”和“在哪里参与布局”拆成两层。扩展 API 的名字或默认视觉形式不必成为最终布局结构,只要宿主另有明确、稳定、可寻址的布局接缝。
推断:保留官方 Chat 的 DOM 和状态,比复制一套 Chat UI 或代理内部状态更能降低宿主升级时的兼容成本;但 portal 目标和 CSS 选择器仍属于宿主契约,必须由文档和回归测试保护。
## Preferred action
1. 先判断产品需要的是“切换到插件整页”还是“插件 UI 与宿主原生视图同时可见”。前者优先使用 view/tab slot;只有后者才需要组合生命周期 slot 与布局 seam。
2. 画清所有权边界:插件只拥有新增工作区,宿主继续拥有 Chat、Composer、审批、工具调用和历史等原生能力。
3. 把接入拆成两种接缝:
- 生命周期接缝:只负责获得 Session 上下文、Store 和卸载边界;
- 布局接缝:只负责让新增 UI 与宿主现有 UI 参与同一个排版上下文。
4. 用官方扩展槽声明 Session scoped child slot,不自行订阅全局 Session,也不把跨 Session 的编辑状态放进单例 Store。
5. 只把 portal 挂到宿主明确承诺稳定的地址节点。若只能找到内部类名或偶然 DOM 层级,先补宿主契约或适配层,不要把猜测当扩展 API。
6. 在稳定内容容器上建立 Grid,显式给新增区域和原生区域分配列;不要复制或重新挂载宿主 Chat。
7. 用 portal 内容自身作为布局启用标记,例如父容器 `:has(.plugin-surface)`。这样插件未加载、切换 Session 或卸载时,不需要额外 JavaScript class toggle 就能恢复原布局。
8. Grid 轨道同时表达最小可用宽度、弹性和上限:窄导航列适合 `clamp()`,主编辑区适合 `minmax(0, 1fr)`,需要保障可用性的原生 Chat 适合带下限和上限的 `clamp()`。
9. 给可收缩 Grid 子项设置 `min-width: 0`,给内部滚动区域设置 `min-height: 0` 和明确 overflow;否则内容的固有尺寸可能撑破轨道。
10. 宿主 Composer 若与 Chat 流不是同一个 Grid item,应单独放入 Chat 列,并为消息流预留 Composer 高度;滚动时验证其可见性,而不只验证静态截图。
11. 响应式判断优先观察实际布局容器宽度,而不是浏览器 viewport。插件可能运行在侧栏、分屏或可变宽宿主中,`ResizeObserver + data-layout` 比 viewport media query 更贴近真实约束。
12. 几何回归至少检查:tree/editor/Chat 的左右顺序、每列最小宽度、Composer 是否完全位于 Chat 列、长 Chat 滚动时 Composer 是否保持可见,以及插件卸载后宿主是否恢复原布局。
## Boundaries
- 该模式只适用于宿主提供官方生命周期扩展点,并明确承诺某个 DOM 节点或 `data-slot` 是稳定布局接缝的情况;不能把任意 DOM 查询合理化为公共 API。
- `shell.overlay`、`conversation.session`、`SessionProvider` 和具体选择器是 DSH 当前扩展契约,不是 React 或插件系统的通用命名。
- `:has()` 需要目标浏览器版本支持;若宿主兼容范围包含旧浏览器,需要等价的受控 class/data attribute 退出机制。
- `display: contents` 会改变元素生成布局盒的方式,并可能影响可访问性、定位和浏览器兼容;应用前必须在宿主实际浏览器上验证。
- 三列最小宽度和 620/900 断点来自当前创作工作台,不应原样复制到内容密度、字体和宿主宽度不同的产品。
- portal 保留 React 上下文,但不会自动保证宿主 CSS、焦点层级、z-index、滚动和可访问性正确;这些仍需在真实宿主中做集成测试。
- 当前证据验证了源码设计和已有 smoke test 的检查项,但本次没有运行需要真实 DSH 环境的 smoke test,因此保留 `candidate` 和 `medium` confidence。
## Failed approaches
- 把 `shell.overlay` 的名字直接理解成最终视觉实现:会漏掉它在本项目中实际承担的 Session 生命周期和子插槽声明职责。
- 为了获得三列而复制或替换官方 Chat:会接管 streaming、工具、审批、历史和 Composer 等宿主内部状态,扩大维护边界。
- 只把工作台 portal 进会话但不改变共同父容器的布局:它仍只是会话中的普通内容或覆盖层,不能与原生 Chat 形成真正并列的三列。
- 用 viewport media query 推导列宽:宿主内部 conversation 容器可能与窗口宽度不同,分屏或侧栏变化时会得到错误布局。
- 只做视觉截图、不测 DOM 几何和滚动:容易遗漏 Composer 跨列、末尾消息被遮挡、窄列低于可用宽度等回归。
## Examples
下面是该模式的结构化伪代码,不是可直接复制的 DSH API:
```tsx
registerLifecycleSlot({
children: { workspace: { scope: "session" } },
}, ({ SessionProvider }) => (
<SessionProvider>{() => renderSlot("workspace")}</SessionProvider>
));
function WorkspaceBridge() {
const target = findDocumentedConversationSeam();
return target ? createPortal(<WorkspaceSurface />, target) : null;
}
```
```css
.conversation-scroller:has(.workspace-surface) {
display: grid;
grid-template-columns:
clamp(var(--tree-min), 16%, var(--tree-max))
minmax(0, 1fr)
clamp(var(--chat-min), 40%, var(--chat-max));
}
.workspace-tree { grid-column: 1; min-width: 0; }
.workspace-editor { grid-column: 2; min-width: 0; }
.native-chat,
.native-composer { grid-column: 3; min-width: 0; }
```
## Promotion record
- Not promoted. 当前只有 `oh-story-dsh` 一个项目的源码和测试证据;待在第二个宿主扩展中独立复用并通过真实集成测试后,再考虑提升为通用 pattern 或宿主扩展布局检查清单。
@@ -0,0 +1,106 @@
---
id: 20260824-composed-slot-before-dom-seam
title: 先查宿主组合包 SlotMap,再退回 DOM seam
created: 2026-08-24
updated: 2026-08-24
status: candidate
scope: global
category: api-discovery
confidence: high
last_verified: 2026-08-24
promotion_target: pattern
projects:
- dsh-lark-cli
tags:
- host-extension
- slotmap
- declaration-merging
- native-tab
- api-discovery
- dsh
---
# 先查宿主组合包 SlotMap,再退回 DOM seam
## Trigger
需要判断一个模块化 Web 宿主的插件能否原生增加页面、Tab、导航项或面板,但当前插件仓库和它直接依赖的 runtime 类型里没有找到对应扩展 API。
## Context
调研 DeepSeek Harness(DSH)插件能否增加与 Chat、Trajectory 并列的 Session 顶部 Tab 时,先检索当前插件仓库和直接安装的 `@deepseek-ai/dsh-client-runtime`,只看到了 `root`、`shell.overlay` 以及仓库已有的 DOM Portal 路径,一度推断“没有原生 Tab API”。
继续检查实际 DSH Web Host 组合的 UI packages 后,`@deepseek-ai/dsh-client-ui-conversation` 的 TypeScript declaration merging 明确补充了 `conversation.view`:这是 `kind: 'list'`、`scope: 'session'` 的视图环,一个注册项就是一个顶部 Tab。第一方 `ui-trajectory` 插件正是通过同一 slot 注册 Trajectory。
这次有意义的复利点不是记住一个 slot 名,而是:扩展契约可能由“视觉表面的所有者包”声明,不在核心 runtime,也不一定出现在业务插件的直接依赖树中。否定一个扩展能力前,必须检查宿主最终组合和第一方同类贡献者。
## Evidence
- 2026-08-24,本机 `dsh --version` 返回 `0.1.0-rc.7`;Web UI packages 为 `0.1.0-rc.8`。
- 官方 `ui-conversation` SlotMap 在 [`packages/client/ui-conversation/src/client/contract/slots.ts`](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/ui-conversation/src/client/contract/slots.ts) 声明 `conversation.view`,注释说明它是 “one list entry per view tab”。
- 官方会话组装在 [`packages/client/ui-conversation/src/client/apply.ts`](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/ui-conversation/src/client/apply.ts) 遍历 `slots.entries('conversation.view')`,从 entry 的 `id` 和 `label` 生成 Tab;Chat 以 `id: 'chat'`、`order: 0` 注册。
- 第一方 [`packages/client/ui-trajectory/src/client/index.ts`](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/ui-trajectory/src/client/index.ts) 使用 `ctx.slots.inject('conversation.view', ...)` 注册 `id: 'trajectory'`、`order: 10` 的 Trajectory Tab。
- 当前 `dsh-lark-cli` 只注册 `sidebar.footer.action` 和 `shell.overlay`,再通过 React Portal 使用 Conversation DOM seam;它证明了另一种布局扩展方式,但不能证明原生 Tab slot 不存在。
- 本次同时核对了已安装包的 `.d.ts`、编译后 `client.js`、官方中文 README、Context7 官方库 `/deepseek-ai/deepseek-harness` 和 GitHub `master` 原始源码。
- 项目内完整调研记录:`dsh-lark-cli/.scratch/research/dsh-plugin-custom-tab.md`。
## Root cause
已验证:DSH 的 `SlotMap` 使用 TypeScript declaration merging,由各 UI owner package 分散声明。`dsh-client-runtime` 只声明内建 `root` slot;`conversation.view` 由 `dsh-client-ui-conversation` 声明,因此只查 runtime 会得到不完整的扩展面。
已验证:宿主最终装配包含的包可以多于业务插件的直接依赖。当前项目没有直接安装 `dsh-client-ui-conversation`,但全局 DSH Web bundle 已组合该包及 `ui-trajectory`,所以项目局部 `node_modules` 不是宿主能力全集。
已验证:slot 类型声明只能证明 seat 存在;宿主如何把 seat 投影为 UI,需要继续核对 owner 实现。`ui-conversation` 的 header/render 代码证明 `id`、`order`、`label` 会形成 Tab,active id 会选择唯一视图。
推断:在其他可组合插件宿主中,同类误判也容易发生:核心 SDK 暴露注册机制,具体贡献点由 feature package 增补;从业务仓库向内搜索会漏掉最终装配才拥有的契约。
## Preferred action
1. 先确定目标 UI 的所有者:Tab 属于 Conversation、Settings、Sidebar 还是根 Layout;不要默认所有扩展点都由 runtime 声明。
2. 枚举实际宿主装配的 feature/client packages。优先读取 bundle manifest、profile dump、全局安装目录或已发布 package metadata,而不是只看业务插件的直接依赖。
3. 搜索所有 `SlotMap` declaration merging、slot declaration 和 `slots.register` 调用,区分:
- `single`:替换整个表面;
- `list`:可追加贡献;
- `keyed` / `chain`:按 key 或选择器扩展;
- slot 的 `root` / `session` scope。
4. 用三类一手证据闭环:
- owner package 的契约声明;
- owner 如何把注册项投影为实际 UI;
- 第一方同类插件的最小注册示例。
5. 分别核对“当前部署版本”和“上游当前版本”。前者回答现在能否工作,后者回答接口是否仍存在;不要用其中一个替代另一个。
6. 只有确认没有满足需求的 additive slot 后,才评估 DOM seam、Portal、CSS 重排或宿主 fork。若已有原生 slot,用它承担生命周期、排序、卸载和 active state。
7. 实现时把 owner package 加入插件的 client inject,并按宿主版本固定 peer/dev dependency;RC API 必须做真实 Web profile smoke test。
## Boundaries
- 该方法适用于由多个包组合、扩展契约分散声明的插件宿主。单体应用或拥有集中式完整 schema 的 SDK 不需要扫描全部 feature packages。
- 发现内部 slot 不等于它是稳定公共 API。至少需要 package 导出、类型声明、owner 文档或第一方插件用例之一;仅从 minified DOM、私有类名或未导出符号推断时仍按内部实现处理。
- `conversation.view` 适合“切换到插件拥有的整页视图”。若需求是文件树、编辑器与官方 Chat 同时可见,原生 Tab 会隐藏非 active view,仍应评估 [[20260822-extension-slot-portal-three-column-grid|生命周期 slot + 稳定布局 seam + Portal]]。
- `conversation.view`、`dsh.client.inject` 和具体 order 值是 DSH 当前契约,不应推广成其他宿主的通用命名。
- 当前只完成源码、类型、包版本和上游 master 核验,没有为 `dsh-lark-cli` 实际注册新 Tab,也没有运行浏览器集成测试。实现层面的兼容性结论仍需 smoke test。
## Failed approaches
- 只检索业务仓库和直接依赖的 runtime:会把“当前项目没有导入 owner package”误判成“宿主没有扩展点”。
- 从 `root` 或 `conversation.session` 是 `single` slot 推导不能新增 Tab:这混淆了“替换整棵表面”和其内部声明的 additive child slot。
- 看到当前项目已经使用 DOM Portal,就把它当作该视觉需求的唯一方案:既有实现只能证明一条可行路径,不能穷举宿主扩展面。
- 只搜 `tab`、`route`、`router`:slot 系统可能把 Tab 表达成抽象的 `view` contribution,应同时沿 UI owner、slot ledger 和第一方插件反向定位。
## Examples
DSH 当前原生 Session Tab 的最小形态:
```tsx
ctx.slots.inject('conversation.view', () => ctx.slots.register({
name: 'conversation.view',
id: 'plugin-view',
order: -10,
label: () => 'Plugin View',
}, PluginView))
```
验证顺序应是:`SlotMap declaration` → `host tab projection` → `first-party trajectory registration` → `target version smoke test`。
## Promotion record
- Not promoted. 当前已有一次高置信度纠错和一套可复用调查流程;待在另一个分包式宿主扩展调研中独立复用后,再考虑提升为通用 host-extension discovery pattern。