本文件是本项目后续开发时 AI Agent / 开发者需要遵守的架构准则。重点是:ECS 数据、协议、effect、存储都要保持领域对象解耦。当前准则来自 Agent 与 Conversation 解耦改造经验。
commit 内容用中文填写,例如:feat: 添加xx功能
当前项目仍然处于开发模式,因此不要对旧格式有任何兜底,也不需要保留旧功能代码的兼容和体验,也不需要写什么协议v1,v2等之类的内部版本号,全面使用新格式新功能更优秀的代码
如果两个概念可以独立存在、独立复用、独立存储,就不要把一个塞进另一个对象里。
当前项目中的典型例子:
Agent 是独立对象
Conversation / Session 是独立对象
Message 是独立对象
AgentConversationLink 是独立关系对象
不要设计成:
Agent owns Conversation
Conversation embeds Agent
SessionRecord.agentId 强制绑定 Agent
应该设计成:
Agent Entity
- Agent
- AgentKind
- ModelProfile
- ToolPolicy
- SystemPrompt
- AgentStatus
Conversation / Session Entity
- Session
Message Entity
- Message
- PartOf -> Conversation
Link Entity
- AgentConversationLink { agent, conversation, role }
两个领域对象之间的关系不能藏在对象内部,也不能写死在 system 逻辑里。关系本身应作为独立 ECS 数据存在。
例如:
AgentConversationLink {
agent: Entity;
conversation: Entity;
role: 'active' | 'participant' | 'reviewer';
}这样切换 agent、切换 conversation、多 agent 协作,本质上都是修改 link 数据。
每个 component 应只表达一个清晰事实。推荐:
Agent
ModelProfile
ToolPolicy
SystemPrompt
Session
Message
PartOf
AgentConversationLink
避免创建包含多个领域概念的大组件。
当 A 与 B 的关系未来可能变化,或可能变成一对多 / 多对多时,必须优先使用 Link component/entity。
推荐:
AgentConversationLink { agent, conversation, role }避免:
Session { id, agentId }
Agent { id, currentSessionId }System 可以读取 link 并执行行为,但不能假设某个领域对象天然拥有另一个领域对象。
推荐流程:
LlmDispatchSystem
1. 找到 NeedsResponse 的 conversation
2. 通过 AgentConversationLink 找 active agent
3. 读取 agent 的 ModelProfile / SystemPrompt / ToolPolicy
4. 读取 conversation 的 messages
5. 发出 llm.start effect
避免:
LlmDispatchSystem 假设 Session 一定 OwnedByAgent
前端协议不能把后端已经拆开的对象重新耦合起来。
推荐:
interface ClientState {
agents: AgentRecord[];
sessions: SessionRecord[];
agentConversationLinks: AgentConversationLinkRecord[];
messages: MessageRecord[];
toolCalls: ToolCallRecord[];
}避免:
interface SessionRecord {
id: string;
agentId: string;
}如果新增独立对象,也应新增独立 patch:
{ kind: 'agentConversationLink.upsert'; link }
{ kind: 'agentConversationLink.remove'; id }不要为了更新 link 而重发 agent 或 session。
Webview 与 Extension Host 之间通过 postMessage 传递数据,payload 必须满足浏览器 structured clone 规则。不要把 Vue / Pinia 的响应式对象、Proxy、ref、computed、DOM Event、函数、class 实例、Map / Set 等直接放进 bridge payload,否则容易触发:
DataCloneError: Failed to execute 'postMessage' on 'MessagePort': [object Object] could not be cloned.
强制要求:
1. 调用 bridge.request / bridge.post / vscode.postMessage 前,必须把 payload 转成普通 Object / Array / string / number / boolean / null。
2. 不要直接传 Pinia state,例如 settings: this.llm、payload: store.xxx、items: reactiveArray。
3. 对嵌套对象也要递归转成纯对象;数组用 map 重新生成,record 用 Object.fromEntries / 显式 for 循环重新生成。
4. 优先使用已有 normalize / sanitize / toPlainXxx 函数;没有就新增一个专用转换函数,不要偷懒直接传响应式对象。
5. 发送前的协议对象应只包含 shared/protocol.ts 里定义的字段,不要把 UI 临时字段、组件对象、事件对象混进去。
推荐:
const settings = normalizeLlmSettings(this.llm);
bridge.request(BridgeMessageType.ConversationSettingsUpdate, {
section: 'llm',
settings
});避免:
bridge.request(BridgeMessageType.ConversationSettingsUpdate, {
section: 'llm',
settings: this.llm // Pinia state / Proxy,禁止直接发送
});如果 payload 来自 store,最低限度也要显式构造:
const payload = {
conversationId: this.llm.conversationId,
activeProviderConfigId: this.llm.activeProviderConfigId,
...(plainModelOverrides ? { modelOverrides: plainModelOverrides } : {})
};排查准则:只要遇到 DataCloneError,第一时间检查最近一次 bridge.request(...) 是否传入了 Pinia/Vue Proxy 或不可 clone 对象。
Effect 是 system 到 runtime capability 的边界。这个边界也必须保持解耦。
推荐:
llm.start effect 接收:
- model settings
- prompt messages
- tools
这些数据可以由 system 根据 ECS link 临时组装,但 effect 不应该保存类似 agentWithConversation 的耦合结构。
Effect handler 不应承载领域关系规则。领域关系应在 ECS world 中由 component/link 表达,由 system 解释。
例如:
LlmDispatchSystem 决定哪个 agent 使用哪个 conversation
LLM capability 只负责调用模型
Storage capability 只负责读写当前投影数据
如果 ECS 和协议层已经拆成独立对象,存储层不能再把它们塞回一个文件或一个强绑定目录。
推荐结构:
<dataRoot>/
agents/
index.json
records/{timeSlugHash}.json
conversations/
index.json
{timeSlugHash}/
conversation.json
messages/
index.json
chunks/000000.json
agent-conversation-links/
index.json
records/{timeSlugHash}.json
含义:
agents/ 只保存 agent 数据
conversations/ 只保存 conversation 与 message 数据
agent-conversation-links/ 只保存 agent 与 conversation 的关系
避免:
chat/manifest.json 同时保存 agents、sessions、links
conversation 文件夹里保存 agent 配置
agent 文件夹里保存 conversation 历史
每类数据的 index 只索引本类对象:
agents/index.json 只列 agent records
conversations/index.json 只列 conversation records
agent-conversation-links/index.json 只列 link records
不要跨领域混存。
新记录文件名使用:
{yyyyMMdd-HHmmss-SSS}-{可读slug}-{短hash}
例如:
20260530-142233-123-main-0ab12cd.json
20260530-142240-456-default-1x9k2p3/
规则:
1. 新记录生成 time + slug + hash 名称
2. 已存在记录复用 index 中的 file/folder
3. 未发布阶段不写旧格式兼容或迁移代码
当需要读写/创建任何业务数据文件或目录时,必须先通过当前 storage capability 内部的 getPaths() 获取路径:
function getPaths(): StoragePaths {
currentPaths = createVscodeStoragePaths(resolveDataRootUri(context));
return currentPaths;
}要求:
1. 业务数据文件必须写到 getPaths() 返回的对应 root/index 路径下,例如 agentsRootUri、conversationsRootUri、linksRootUri、settingsRootUri 等。
2. 每次 load/save/ensure storage roots 前都应重新调用 getPaths(),不要长期缓存旧路径。
3. 不要直接使用 VS Code extension context 的 globalStorageUri/globalStoragePath/globalState 拼接业务数据路径。
4. globalStatus 只用于保存数据根目录配置、当前激活数据目录与迁移记录,不用于承载业务数据文件。
原因:
通过 resolveDataRootUri(context) + createVscodeStoragePaths(...) 统一生成路径,才能集中控制数据目录,支持后续数据文件迁移、切换和管理。
避免蓝紫色+大圆角。按钮 hover / focus / active / 选中态也尽量不要使用 VS Code 默认的蓝色实心背景;如需高亮,优先使用中性灰色背景或轻量边框,避免蓝色块破坏整体风格。
如果前端需要使用滚动条,优先使用自定义滚动条组件,不要直接依赖浏览器默认滚动条:
webview/src/components/navigation/AdvancedScrollbar.vue
要求:
1. 普通内容区域需要滚动条时,使用 AdvancedScrollbar。
2. 下拉面板、浮层、小区域滚动条优先使用 AdvancedScrollbar 的基础样式 variant="minimal":无可见导轨,仅悬浮显示滑块,不占用布局空间。
3. 如确实不能使用 AdvancedScrollbar,需说明原因,并保持视觉风格与现有自定义滚动条一致。
如果前端需要做信息展示类悬浮面板(例如 token / usage / 指标明细、图表柱子明细、状态解释等 hover/focus 提示),必须优先复用:
webview/src/components/ui/HoverTooltipPanel.vue
要求:不要直接依赖浏览器默认 title 提示,也不要临时写新的 tooltip / hover 面板;复用 HoverTooltipPanel 的展示样式、进入 / 离开动画、延迟和关闭等待时间。只有在交互形态明显不是信息展示 tooltip 时,才允许使用下拉面板或其他浮层组件,并说明原因。
设置页内的通用交互组件必须保持一致:
1. 下拉选择不要直接使用浏览器原生 select;优先复用 webview/src/components/settings/global/SettingsDropdown.vue。该组件基于 project-dropdown + lc-dropdown-panel + IconCaretUp。
2. 下拉按钮右侧使用 IconCaretUp,并用旋转动画表达展开 / 收起。
3. 下拉面板内容可能超过高度时,必须复用 webview/src/components/navigation/AdvancedScrollbar.vue;最基础样式使用 variant="minimal",无可见导轨,仅显示滑块。SettingsDropdown 已内置该规则,并支持 maxHeight / height 以适配最大高度或固定高度场景。
4. 需要删除、危险操作或二次确认时,必须复用 webview/src/components/ui/ConfirmPanel.vue,不要临时写新的确认弹窗。
5. 需要输入名称、重命名等简单文本输入弹窗时,优先复用 webview/src/components/ui/InputPanel.vue。
6. 需要勾选框 / 复选框 / 列表选中标记时,必须复用 webview/src/components/ui/LcCheckbox.vue;不要临时使用原生 checkbox 默认样式,也不要用 span + “✓” 拼接勾选图形。纯展示选中标记使用 presentation 模式,交互式复选框使用 v-model / update:model-value。
7. 设置页签内容较多时按页签拆分 Vue 组件,主面板只负责布局与页签切换。
8. 需要 token 数阈值 / 上下文窗口阈值滑条时,优先复用 webview/src/components/ui/TokenThresholdSlider.vue;不要在业务组件中临时编写 range 滑条样式。该组件已内置 1k 对齐、顶部 token 标签、底部百分比标签、推荐阈值标签与中性灰视觉风格。
TokenThresholdSlider 基础用法:
<TokenThresholdSlider
:model-value="thresholdTokens"
:max-tokens="contextWindowTokens"
:step-tokens="1000"
:recommended-tokens="contextWindowTokens - 20000"
label-variant="tag"
:show-top-label="true"
:show-bottom-label="true"
aria-label="拖拽调整自动压缩触发阈值"
@update:model-value="updateThresholdTokens"
/>要求:业务组件只负责计算 model-value、max-tokens、recommended-tokens 并在 update:model-value 中写回配置;滑条的 token / 百分比展示、推荐标签、hover / focus 样式由组件统一维护。如需标签样式,使用 label-variant="tag";如需隐藏上下数字,使用 :show-top-label="false" / :show-bottom-label="false";如需隐藏推荐标签,使用 :show-recommended-tag="false"。
新增任何设置项 / 配置页 / 可复用配置记录前,必须先阅读:
docs/global-settings-data-integration.md
开发时必须先区分两个 scope:
1. 配置管理 scope:这个配置入口属于 global / conversation / agent 哪一级设置。
2. 配置数据 scope:这个配置是简单 section,还是该 settings scope 下的可复用 record 集合,还是独立 ECS 领域对象。
要求:
1. 如果入口属于全局设置,优先新增 GLOBAL_SETTINGS_SECTIONS section,并复用 settings.global.get/update/snapshot。
2. 不要为了全局设置页里的 CRUD 新建独立 BridgeMessageType / Bridge / 顶层 storage root。
3. 如果全局设置下有多个可复用配置页,每个配置仍可作为独立 record 存在,但应放在 settingsRootUri 对应 section 下,通过 index + records 管理。
4. 当前激活 id / 默认选择这类状态应单独作为 settings section 保存,不要塞进每个配置 record。
5. 如果某配置未来要被 Agent / Workflow / Conversation 复用,应通过 Link/关系数据引用配置 id,不要把配置对象嵌入主体对象。
默认初始化可以为了跑通基础体验创建默认对象,但也必须遵循解耦模型。
推荐:
创建 default Agent
创建 default Conversation
创建 AgentConversationLink(default Agent, default Conversation, active)
避免:
创建 Agent 时把 Conversation 内嵌进去
创建 Session 时必须写 agentId
新增模块、组件、effect、协议或存储格式前,必须检查:
1. 这个字段是不是其实在表达另一个领域对象?
2. 这个关系未来是否可能一对多或多对多?
3. 切换关系是否能只改 link,而不用改主体对象?
4. ClientState 是否把独立对象重新塞进另一个对象?
5. Effect payload 是否携带了长期领域关系?
6. 存储文件是否把多个独立对象混在一个文件或目录里?
7. 是否为了未发布的旧格式写了兼容/迁移代码?如果没有发布,应该删除。
8. 数据文件路径是否通过 getPaths() 获取,而不是直接使用 extension globalStorage/globalState/globalStatus?
9. 新增配置项前是否已阅读 docs/global-settings-data-integration.md,并区分配置管理 scope 与配置数据 scope?
如果发现耦合,优先拆成:
主体对象 A
主体对象 B
Link / Relation 对象
System 解释 Link
Effect 执行外部能力
Storage 分目录持久化
当前目标结构:
ECS:
Agent 独立
Conversation / Session 独立
Message 属于 Conversation
AgentConversationLink 独立表达关系
Protocol:
agents[]
sessions[]
messages[]
toolCalls[]
agentConversationLinks[]
Storage:
agents/
conversations/
agent-conversation-links/
System:
InputSystem 写入 conversation message
LlmDispatchSystem 通过 AgentConversationLink 找 active agent
LlmPollSystem 写回 assistant message
这套方式后续应用于所有类似模块:只要两个概念可以被不同功能复用,就不要做所有权绑定,而是通过独立 link 和 system 组合。