The rejected shape is a settings satellite per feature; the ownerless copy stays with ui-settings-general, which carries no feature surface.
10 KiB
Agent Note: Client Settings、Locale 与 Theme 分层
Status: proposed
English | 中文
Problem
浏览器端已有的 Settings 直接写在 Sidebar 内,语言和主题也由组件本地状态直接改 DOM。这使 Settings 无法由独立插件扩展,偏好状态没有稳定的跨插件服务契约,主题 registry 同时承担状态与呈现职责。
Proposal
协作导向(后续所有模块接入 Settings 的方式):功能属主自注册。 Settings 壳是纯组合面:只声明坑位、渲染 chrome 结构,零文案、不依赖 locale、不 import 也不枚举任何功能;一个功能要出现在 Settings 里,由它自己的插件向对应坑位注册——locale 注册 Language 行,ui-theme 注册 Appearance 行,ui-models 注册 Models 一级面板。不为「某功能的设置页」单开 ui-settings-* 包:设置面属于功能包本身(做 Theme 功能,Theme 的设置选择就随 ui-theme 一起交付)。不属于任何单一功能的内容(trigger/标题/close 的 chrome 文案、General 目录与骨架行、settings 字典)由 ui-settings-general 拥有——它是「无主文案」的属主,不是功能卫星包。
Sidebar 声明 sidebar.settings 单坑位,ui-settings 占用它并声明四个坑:settings.trigger / settings.header / settings.close(chrome 内容座,single)与 settings.section(一级页面,list)。无障碍名全部解析自坑内容:trigger 的可达名即其文本内容,dialog 经 aria-labelledby 指向 header 内容节点,close 是视觉隐藏文本座。每个 section 由功能插件贡献;壳只从 slot ledger 读取 entry metadata 生成导航,通过 only 渲染当前 section。General 由 ui-settings-general 注册(order 0)并声明 settings.general.item list 坑位,功能插件的偏好行按 order 排入。
Settings 入口是 sidebar Foot 的 Settings 行,点击直接打开 1080×700 居中浮层(黑 24% 遮罩);close 按钮、点击遮罩、ESC 均关闭。无任何中间菜单形态。
@deepseek-ai/dsh-client-locale 提供 ctx.locale,ui-theme 提供 ctx.theme。两个 service 都以 getter 读取、setter 写入并用 typed Cordis change event 发布 immutable snapshot;service 自己持久化偏好(只存 id,坏值回退默认)。
功能行的 apply 层各自订阅自家 change event(locale 订 locale/change,ui-theme 订 theme/change),把 snapshot 投影到该行注册时声明的 slot store。React 组件只读 useStore、写注入的 setter callback,不读取 ctx 或 service。
Theme 偏好三态:light、dark、system,默认 system(无持久化偏好或坏值时)。system 的解析属主题领域:ThemeService 持有 prefers-color-scheme matchMedia 监听(环境感知,非 DOM 呈现),偏好为 system 且系统配色变化时重发 snapshot;snapshot 同时携带 preference 与解析后的 active 定义。
Theme service 不操作 DOM。ui-layout 初始读取 Theme getter,随后订阅 theme/change,由 Layout 持有的 presenter 按 active 更新 body[data-ds-dark-theme] 和主题 token;presenter 不感知 system,只消费已解析结果。
首期注册面
| 注册面 | 属主插件 | 首期内容 |
|---|---|---|
| chrome 内容(trigger/header/close) | ui-settings-general |
设置入口行图标+文案、面板标题、close 隐藏文本 |
| General section(order 0) | ui-settings-general |
Permission、Tool Call 视觉骨架(无写操作)+ settings.general.item 坑位声明 |
| Language 行(item order 0) | locale |
Selector 下拉,中文/English 真实可切 |
| Appearance 行(item order 10) | ui-theme |
Light/Dark/System 三 cube 真实可切(选中态看 preference) |
| Models section(order 10) | ui-models |
仅导航项,内容区为空;后续模型管理功能落在该包 |
| Plugin | 无 | 首期不做,导航不出现该项(后续插件功能包注册 section 即自动出现) |
首期只翻译 Settings 浮层内文案;字典就近——chrome + General 骨架归 ui-settings-general 的 settings namespace,功能行文案归各功能包(settings.locale、settings.theme、settings.models)。
Slot topology
root
└─ sidebar
└─ sidebar.settings single/root
└─ ui-settings(壳,零文案)
├─ settings.trigger single/root ui-settings-general 注册
├─ settings.header single/root ui-settings-general 注册
├─ settings.close single/root ui-settings-general 注册
└─ settings.section list/root
├─ general (order 0) ui-settings-general 注册
│ └─ settings.general.item list/root
│ ├─ language (0) locale 注册
│ └─ appearance (10) ui-theme 注册
└─ models (order 10) ui-models 注册
section/item contribution 均使用 declaration-aware deferral(ui-slots 的 deferRegistration():ledger 判在位、refresh() 换本地化 label、一键 dispose),不依赖 client manifest 的 apply 顺序。SlotMap 类型分家:trigger/header/close/section 正家在 ui-settings contract(消费者 general/models 均依赖壳,无环);settings.general.item 正家在 locale 包——它是全部 item 注册方的最低公共依赖(设置行必带文案),而声明方 general 的 contract 对 locale/ui-theme 不可达(会成环);ui-theme 经 re-export seam 消费。
Future work:坑位声明升格为可 inject 的一等等待物
deferRegistration() 与 ctx.inject 行为同构——一个等 ledger 声明、一个等服务在场,消失/重现的生命周期语义一致;差别只在 fiber 版的 disposer 生命周期天然等于声明生命周期,stale-disposer 判在位机器可整体消失。方向(另开 PR):SlotsService 在声明落账/级联拆除处把每个坑位桥接成 slot:<name> 服务(value 为坑位 spec),注册方从 deferRegistration() 迁为嵌套 ctx.inject(['slot:<name>'], cb),随后删除 deferRegistration() 并改写 packages/client/AGENTS.md checklist 第 4 条。待钉死的边界:嵌套 fiber 的无害等待不被 boot fail-loud 扫描点名(需测试);slot: 名字空间与 typo 静默等待的口径;provide 键是平面名(slot:a.b 是一个键,不是 ctx.slots 的属性路径)。本期维持 deferRegistration() 函数形式。
Service contracts
export type ThemePreference = 'light' | 'dark' | 'system'
export interface ThemeDefinition {
id: string
colorScheme: 'light' | 'dark'
tokens: Record<string, string>
}
export interface ThemeSnapshot {
preference: ThemePreference
active: ThemeDefinition // system 已解析为具体 light/dark 定义
themes: readonly ThemeDefinition[]
revision: number
}
export interface LocaleDefinition {
id: 'zh' | 'en'
label: string
}
export interface LocaleSnapshot {
active: 'zh' | 'en'
locales: readonly LocaleDefinition[]
revision: number
}
export interface Events {
/** @param snapshot - Current locale registry snapshot. @mode emit */
'locale/change'(snapshot: LocaleSnapshot): void
/** @param snapshot - Current theme registry snapshot. @mode emit */
'theme/change'(snapshot: ThemeSnapshot): void
}
Locale 内置中文和 English;setLocale/setTheme 是唯一写入口,未知 id 失败。
Alternatives considered
由 app shell 统一订阅偏好并重渲染 root slot tree。 语言和主题变化只需要更新实际消费者;全树刷新放大影响面,也把业务偏好接入 shell。
Theme service 直接修改 DOM。 registry service 因此依赖呈现环境,生命周期与全局样式所有权不清;Layout 已经拥有页面根呈现边界。
system 由 Layout presenter 解析。 presenter 需自带 matchMedia 订阅并在 themes 列表里挑选具体定义,呈现层被迫理解偏好语义;解析放服务侧则所有消费者拿到一致的已解析 snapshot。
Settings import 并枚举各 section。 新增页面必须修改壳插件,破坏「每个功能由自己的插件占坑」的组合模型。
按功能为每个 section 单开 ui-settings-* 卫星包。 设置面与功能本体分家:改 Theme 行为要动两个包,包数随设置项线性膨胀,且卫星包反向依赖 locale/theme 服务,形成纯粹为拆包而生的中间层。功能属主自注册下不存在这层:preference 行随功能包交付;ui-settings-general 只收无主文案(chrome 与 General 骨架),不承载任何功能的设置面。
把 Locale/Theme snapshot 直接注入 React。 inject 结果按 entry identity 缓存,易变值会陈旧;为每个 service 自造 React hook 也绕开 slot store 的统一绑定。
Acceptance criteria
- Settings 壳只依赖 slot ledger,不依赖任一功能实现;General 的 item 列表同样只依赖 ledger。
- 新增一个设置项 = 功能包自己注册(section 或 general item),零壳改动。
- Locale 与 Theme 的写入只走 setter,持续同步只走 change event。
- 功能行 store 初始化走 getter,后续由自家 change event 更新并局部重渲染。
- Layout 独立应用 Theme snapshot,Theme service 不访问 DOM;presenter 不出现 system 分支。
- 中文/English 与 Light/Dark/System 能切换并刷新后恢复;偏好为 system 时系统配色变化即时生效。
- Models 只有导航项与空内容区;Permission、Tool Call 骨架无写操作。
- 浮层经 close 按钮、遮罩点击、ESC 均可关闭。
Risks
slot 声明与 contribution 的 apply 顺序不固定,所有 section/item 注册方必须保留 declaration-aware registration,并以 ledger(而非本地 disposer)判定在位。service event 可能早于行首次渲染,功能行 store 的 init 与 inject attach 都必须从 getter 对齐当前 snapshot。settings.general.item 的重复合并副本(locale、ui-theme)与 ui-settings 正家必须逐字一致,漂移即三处一起改。Layout 卸载时必须清理自己设置的全局属性,ThemeService dispose 时必须移除 matchMedia 监听,避免 HMR 后残留。