支撑层:数据模型、共享 SDK 与前端骨架
30 秒导读: 前面几章讲的都是"运行时"——请求怎么流、agent 怎么编排、工具怎么调。本章讲的是把这套运行时托起来的地基:数据存哪、怎么存;前后端凭什么对得上话;浏览器里那套 UI 骨架怎么搭;以及"谁能读谁能写"这条权限线怎么穿过每一次落库。地基本身不产生对话,但对话的每一步都踩在它上面。
1. 这一层解决什么问题(零基础也能懂)
把一个聊天平台想成一栋楼。前几章讲的是楼里的人在干活,本章讲的是楼的承重结构。承重分三根柱子,外加一条贯穿全楼的钢筋。
三根柱子各管一件事:
| 柱子 | 一句话职责 | 代码在哪 |
|---|---|---|
| 数据层 | 把消息/会话/用户/agent 存进 MongoDB,再读回来 | packages/data-schemas/src/ |
| 共享 SDK | 定义"一个请求长什么样、一个响应长什么样",前后端共用一份 | packages/data-provider/src/ |
| 前端骨架 | 浏览器里那套 Provider + 状态 + 路由,把数据渲染成聊天 UI | client/src/ |
那条贯穿全楼的钢筋是权限:每一次"读某会话""改某 agent",背后都要先问一句"这个用户有没有资格"。它不属于任何单独一根柱子,而是穿过全部三根(api/server/services/PermissionService.js、packages/data-provider/src/accessPermissions.ts)。
为什么值得单开一章? 因为这三件事有一个共同的暗线——类型对齐。同一个"会话"对象,要在 Mongoose schema 里定义、在 data-provider 的 zod schema 里再定义、在 React 组件里消费。LibreChat 的做法是让 data-provider 成为唯一真源:后端 schema 和前端组件都从它取类型,谁也别自己重编一份。这一章就是讲这条对齐线怎么拉通的。
2. 三层分工全景(它大概怎么转)
先给一张"怎么读这张图":从上到下是一次写操作的落点,从左到右是三根柱子。中间那列 data-provider 是前后端都依赖的共享层,所以它跨在两边。
浏览器(client/src) Node 后端(api/)
┌─────────────────────────┐ ┌──────────────────────────┐
│ App.jsx Provider 洋葱 │ │ Express 路由 / 控制器 │
│ Recoil/Jotai 状态 │ │ services/* │
│ react-query hooks │ │ api/models/index.js │
│ useSSE 消费流 │ │ │(封装) │
└───────────┬─────────────┘ └───────┼──────────────────┘
│ │
│ ┌──────────────────────┐ │
└───────▶│ packages/data- │◀────────┘
共享类型/请求约定 │ provider(共享 SDK) │ 共享类型/请求约定
│ types · schemas · │
│ createPayload · keys │
└──────────┬───────────┘
│ 被后端 schema 复用
▼
┌──────────────────────┐
│ packages/data-schemas │
│ schema→model→methods │──▶ MongoDB
│ (Mongoose) │──▶ MeiliSearch(搜索副本)
└──────────────────────┘
三个 npm workspace,各自的边界很清晰:
| workspace | 语言 | 服务对象 | 干什么 |
|---|---|---|---|
packages/data-schemas | TypeScript | 后端 | Mongoose 的 schema/model/methods,数据库这一层的全部逻辑 |
packages/data-provider | TypeScript | 前端 + 后端共用 | 请求/响应类型、zod 校验、请求 URL、react-query hooks |
client | TS/React | 前端 | SPA 骨架:Provider、状态、路由、SSE 消费 |
api | JS(遗留) | 后端 | Express 服务器,薄封装调进上面几个包 |
依赖方向是单向的(依据:packages/data-schemas 依赖 data-provider,client 依赖 data-provider,data-provider 谁都不依赖)。所以 data-provider 处在依赖图的最底部,才有资格当"唯一真源"——它变了,上面全跟着变;它不依赖别人,就不会被别人拖着变。
主线走一遍(高层,不进代码):
- 用户在
client里点发送 → react-query /useSSE用data-provider的createPayload把表单打包成后端认得的TPayload。 - 请求到
api的 Express 路由 → 控制器调api/models/index.js暴露的数据方法。 - 这些方法其实来自
data-schemas的createMethods,它们把消息/会话 upsert 进 MongoDB。 - 流式结果沿 SSE 回到
useSSE,边收边把消息写进 Recoil 状态,UI 实时刷新。 - 全程每一次读写前,
PermissionService用 ACL 位掩码校验资格。
后面几节按"数据层 → 共享 SDK → 前端 → 权限"依次钻进去。
3. 数据层:一条消息如何落库并回读
本节讲第一根柱子。目标:讲清一条消息从"存进去"到"读回来"经过哪些代码,以及 LibreChat 为什么把 数据层拆成 schema/model/methods 三段。
3.1 三张核心表,以及它们的关系
聊天平台的数据核心其实就三张表:用户、会话、消息。围绕它们再挂一圈:agent、file、transaction(计费)、ACL(权限)等。
| 集合 | 存什么 | schema 文件 | 主键字段 |
|---|---|---|---|
Message | 单条消息(文本、内容块、token 数、反馈) | schema/message.ts | messageId(UUID) |
Conversation | 一场会话(标题、endpoint、参数快照) | schema/convo.ts | conversationId(UUID) |
User | 用户(邮箱、密码、OAuth id、2FA) | schema/user.ts | _id + email |
Agent | 自定义 agent(模型、工具、指令) | schema/agent.ts | id |
File | 上传文件的元数据(路径、字节、来源) | schema/file.ts | file_id |
会话和消息是怎么连起来的? 会话文档里存了一个消息 ObjectId 数组(convo.ts:23,messages: [{ type: Schema.Types.ObjectId, ref: 'Message' }])。但真正查历史时不靠这个数组,而是靠消息自己带的 conversationId 外键去反查(message.ts:12)。数组更像一个缓存/引用,反查才是主力路径——saveConvo 每次保存会话时,都会用 conversationId 重新拉一遍消息 id 塞进这个数组(conversation.ts:216)。
注意主键不是 Mongo 默认的 _id。 消息用业务自带的 messageId(UUID),会话用 conversationId。这样前端在消息还没落库时就能先生成 id、乐观渲染,落库时再按这个 id 做 upsert。唯一索引也是复合的——{ messageId, user, tenantId }(message.ts:193),把"同一条消息"锁定到"某用户 + 某租户"的范围内,天然支持多租户隔离。
3.2 三段式:schema → model → methods
这是 data-schemas 最值得学的结构。它把"一张表"拆成三个独立文件,各管一段:
schema/message.ts ── 定义字段与索引(纯数据形状)
│
▼
models/message.ts ── 把 schema 编译成 Mongoose Model
│ 同时挂插件:多租户隔离 + MeiliSearch 同步
▼
methods/message.ts ── 业务读写函数(saveMessage / getMessagesByCursor …)
为什么要拆三段? 因为三段的"变化频率"和"依赖对象"不一样:字段形状很少变(schema),但业务查询天天加(methods);model 这层专门负责把"环境相关的插件"(要不要接搜索引擎)注入进去。拆开后,写查询的人不用碰 schema,配搜索的人不用碰查询。
model 层的注入很典型(models/message.ts:7 createMessageModel):只有当环境变量里配了 MEILI_HOST 时,才给这张表挂上 MeiliSearch 同步插件(models/message.ts:10)。没配搜索引擎的部署,这段直接跳过——同一份 schema,能力按环境裁剪。
3.3 全部方法靠一次"依赖注入"组装起来
data-schemas 不导出一堆散装函数,而是导出一个工厂 createMethods(mongoose, deps)(methods/index.ts:170)。它做两件事:
- 把几十个
create*Methods(mongoose)的结果合并成一个大对象(AllMethods,methods/index.ts:114),里面就是saveMessage、getConvo、checkPermission这些函数。 - 处理方法之间的依赖。有些方法要用到别的方法,靠手动注入串起来。
第 2 点是精华。看会话方法怎么拿到消息方法(methods/index.ts:195):
// 示意,基于 methods/index.ts:193-198
const messageMethods = createMessageMethods(mongoose);
const conversationMethods = createConversationMethods(mongoose, {
getMessages: messageMethods.getMessages, // 会话保存时要反查消息
deleteMessages: messageMethods.deleteMessages, // 删会话时要连带删消息
});
createConversationMethods 的第二个参数就是它的依赖(methods/conversation.ts:70)。它内部用一个 getMessageMethods() 守卫,没注入就抛错(conversation.ts:74)——把"忘了注入"变成启动即崩,而不是运行时才发现某个查询是 undefined。
后端侧的入口把这套接上真实环境(api/models/index.js:6):createMethods 收到 matchModelName(计费用的模型名匹配)、getCache(缓存)等来自 @librechat/api 的实现。data-schemas 只声明"我需要一个能匹配模型名的函数",不关心它怎么实现——纯逻辑和环境实现就此解耦。
3.4 落库:一次 upsert 搞定"新建或更新"
saveMessage 是消息落库的主函数(methods/message.ts:89)。它的核心是一次带 upsert 的 findOneAndUpdate(message.ts:154):
// 示意,基于 methods/message.ts:154-158
const message = await Message.findOneAndUpdate(
{ messageId: params.messageId, user: userId }, // 按 (消息id, 用户) 定位
update,
{ upsert: true, new: true }, // 不存在就插入,存在就更新
);
为什么用 upsert 而不是 insert? 因为流式场景下同一条 assistant 消息会被多次保存:先建个空壳占位,再随 token 陆续补 text。upsert 让"第一次建、后面每次更"走同一条代码路径,不用调用方判断"这条存没存过"。
saveMessage 还顺手处理了两个真实的坑:
- 临时会话过期:如果是临时聊天,按保留策略算一个
expiredAt,失败就退回兜底日期(message.ts:122-145)。MongoDB 的 TTL 索引(message.ts:191)到点自动清。 - 并发重复插入:两个请求同时 upsert 同一
messageId会撞唯一索引(错误码 11000),这时不报错,而是把已存在的那条读回来返回(message.ts:179-193)——把竞态"化解"成幂等,而不是抛给用户。
会话侧的 saveConvo(methods/conversation.ts:182)结构类似,但多一层:每次保存都先用 getMessages 把该会话的消息 id 重新拉一遍写进 messages 数组(conversation.ts:216),保证会话文档里的引用列表和实际消息不脱节。
3.5 回读:游标分页,不是 skip/limit
会话列表用游标分页(getConvosByCursor,methods/conversation.ts:525)。它没用传统的 skip(N).limit(M),而是把"上一页最后一条的排序值"编码成一个 base64 游标(conversation.ts:664),下一页查询时用它做 $lt/$gt 过滤(conversation.ts:614)。
为什么不用 skip? skip(10000) 要求数据库先数过前一万条再丢掉,越翻越慢;游标是"从上次那个值之后接着查",无论翻到多深都走索引、恒定开销。会话表专门为这几种排序建了复合索引(convo.ts:62-63)。
排序还带一个"副键兜底":主排序字段(如 updatedAt)相同时,用 updatedAt 再排一次(conversation.ts:614-621),避免两条时间戳一样的记录在分页边界上抖动、漏掉或重复。
回读也可以走搜索引擎。列表查询若带了 search 关键词,会话方法先问 MeiliSearch 要命中的 conversationId 列表,再回 Mongo 拉这些 id(conversation.ts:582)。这就是 3.2 里那个"model 层挂搜索插件"的下游用途——搜索索引是 Mongo 数据的一份异步副本,查询按需从副本走。
3.6 api/models/index.js:后端看到的统一门面
后端其它代码不直接 import 各个 method 文件,而是全走 api/models/index.js。这个文件只有二十几行(api/models/index.js):调一次 createMethods 拿到全部方法,再拼一个 seedDatabase(启动时初始化角色、默认分类、系统授权),然后 module.exports = { ...methods, seedDatabase }。
于是 require('~/models') 就能拿到 saveMessage、getConvo、checkPermission 等一整套函数。控制器里看到的 const { saveMessage } = require('~/models'),顺着这条线,最终落到 data-schemas 里那段带 upsert 的真实实现。
4. 共享 SDK:前后端靠一套类型对齐
本节讲第二根柱子。目标:讲清 data-provider 凭什么当"前后端的普通话",以及一次请求的形状是怎么在这里被单点定义的。
4.1 data-provider 是什么
它是一个纯 TypeScript 包,不含任何服务器或浏览器专属代码,因此前后端都能 import。它导出四类东西:
| 导出 | 文件 | 作用 |
|---|---|---|
| 类型 | types.ts / types/*.ts | TMessage、TConversation、TPayload 等共享类型 |
| zod schema | schemas.ts | 运行时校验 + 从 schema 推出类型 |
| 请求约定 | createPayload.ts、api-endpoints.ts | 请求体怎么拼、URL 长什么样 |
| react-query hooks | react-query/、keys.ts | 前端调 API 的封装 + 缓存键 |
一句 话直觉: 后端说"给我一个 TPayload",前端说"我发的就是 TPayload"——两边指的是同一个类型定义(data-provider 里那一份)。类型对不上,tsc 编译期就红,不用等运行时报错。
4.2 createPayload:请求约定的单一来源
前端要发一次聊天,得把一堆散落的前端状态(会话、endpoint 配置、用户消息、临时标记……)打包成后端认得的请求体。这件事只在 createPayload 里做一次(createPayload.ts:14)。
它干三件事:
- 算出请求该发到哪个 URL。根据 endpoint 类型拼 server 地址;assistants 类 endpoint 走另一条
/modify路径(createPayload.ts:35-40)。 - 把前端字段揉成
TPayload(createPayload.ts:42),顺手补上浏览器时区(createPayload.ts:6getUserTimezone),好让服务器本地化 prompt 里的时间变量。 - 按 endpoint 类型裁字段:assistants endpoint 不带
ephemeralAgent/manualSkills(createPayload.ts:52-53)——不同后端认的字段不同,这里统一收口。
精华在于"单点":哪天请求体加个字段,只改这一个函数,前端所有发起点自动跟上;后端也从 data-provider 取同一个 TPayload 类型来解析。请求约定不会散落在十个组件里各写一遍。
4.3 zod schema:一份定义,同时给"校验"和"类型"
schemas.ts 里用 zod 定义了会话、消息等结构(如 tConversationSchema,schemas.ts:897;tMessageSchema,schemas.ts:742)。zod 的好处是一份 schema 两用:
- 运行时能
.parse()校验数据(createPayload.ts:28就 parse 了一遍会话更新体); - 编译期能
z.infer推出 TypeScript 类型(TConversation就是从tConversationSchema推的,schemas.ts:1148)。
这样"校验规则"和"类型定义"永远同源,不会出现"类型说这个字段是 string、校验却按 number 判"的错位。前端发送前 parse 一道、后端接收后再 parse 一道,同一份 schema 把守两端。
4.4 react-query hooks:前端调 API 的标准姿势
前端从不裸调 fetch,而是用 data-provider 封装好的 react-query hooks(react-query/react-query-service.ts)。一个 hook 内部串三样东西:
- URL 来自
api-endpoints.ts(如messages()、conversations(),api-endpoints.ts:54/:114); - 请求函数 来自
data-service.ts(如getConversations,data-service.ts:816); - 缓存键 来自
keys.ts的QueryKeys枚举(keys.ts:1)。
缓存键统一收在一个枚举里,是为了让"改了数据后精准让哪块缓存失效"这件事有单一真源——mutation 成功后按 QueryKeys.xxx 失效对应查询,列表就自动刷新。client/src/data-provider/index.ts 再把这些 hooks 按功能域(Messages、Agents、Files……)重新导出一层,组件只从这个门面 import。
5. 前端骨架:Provider 洋葱 + 状态 + SSE
本节讲第三根柱子。目标:讲清浏览器里那层壳怎么搭起来,以及流式响应怎么一路灌进 UI。
5.1 App.jsx:一层套一层的 Provider 洋葱
整个 SPA 的最外层是 App.jsx(client/src/App.jsx:18)。它就是把一串 Context Provider 由外向内套起来,每一层负责一种全局能力:
QueryClientProvider ← react-query 缓存(全局请求状态)
└ RecoilRoot ← Recoil 全局状态(会话、消息、提交)
└ ThemeProvider ← 主题(深浅色)
└ ToastProvider ← 全局提示
└ DndProvider← 拖拽(拖文件上传)
└ RouterProvider ← 路由,真正的页面从这里进
QueryClient 在这里配了个关键项:networkMode: 'always'(App.jsx:26)——即使浏览器报 offline 也照发请求,因为 localhost 场景下没 WiFi 也连得通。全局 401 会被 QueryCache.onError 捕获、触发重新登录(App.jsx:33-37)。
5.2 状态:Recoil 为主,atomFamily 按会话分片
前端全局状态放在 client/src/store/,主力是 Recoil。值得注意的是它用 atomFamily 按"会话/运行索引"给状态分片(store/families.ts)。
为什么要分片? 因为 LibreChat 支持一个界面里跑多路对话(主对话 + "added" 对比对话)。用 submissionByIndex(families.ts 中的 atomFamily)这种带参数的原子,每一路对话的提交状态、消息列表、滚动状态互不干扰——第 0 路在流式输出时,第 1 路可以独立地停或改。普通的单个 atom 做不到这种"同结构、多实例"的隔离。
5.3 消费 SSE:useSSE 把流一路灌进状态
聊天是流式的,前端靠 useSSE(client/src/hooks/SSE/useSSE.ts:27)接住服务器推来的事件流。这个 hook 是"运行时"和"支撑层"的接缝,值得走一遍:
submission 变化
│
▼
createPayload(submission) ← 用 data-provider 打包(useSSE.ts:87)
│
▼
new SSE(server, { payload, headers }) ← 建流,带 Bearer token(useSSE.ts:94)
│
▼ 逐条事件分发(useSSE.ts:108 起)
├ created → 建 assistant 消息壳、记录 runId
├ message → messageHandler 把增量文本写进 Recoil
├ 各种 step→ stepHandler 处理工具调用/推理增量
├ title → 更新会话标题
└ final → 收尾、刷新余额、清草稿
几个真实细节:
- token 过期自愈:流中途收到 401,
useSSE会自动refreshToken换新 token、重设 header、sse.stream()重连,用户无感(useSSE.ts:221-243)。 - 中止即结算:用户点停,
cancel分支把停之前已计费的用量归到那半截回答上,再 reset,既不丢也不漏到下一条(useSSE.ts:184-204)。 - 卸载即中止:hook 的 cleanup 关掉 SSE;若还在传输中,补发一个
cancel事件走上面那套结算(useSSE.ts:264-272)。
这一节和数据层的呼应:useSSE 收到的 created/final 里带的 messageId,正 是第 3 章 saveMessage 落库用的那个 id。前端先拿 id 乐观渲染,后端按同一 id upsert——一个 id 两端用,前后端就此对齐。
6. 权限如何贯穿(那条钢筋)
本节讲穿过三根柱子的权限线。目标:讲清 LibreChat 用什么模型表达"谁能对某资源做什么",以及这套判断怎么同时服务前端和后端。
6.1 两套叠加的模型:角色开关 + 资源级 ACL
LibreChat 的权限其实是两层叠加:
| 层 | 回答的问题 | 载体 |
|---|---|---|
| 角色能力(RBAC) | "普通用户能不能用 agent 这个功能?" | Role 集合 + 角色上的能力开关 |
| 资源级 ACL | "用户 A 能不能编辑这一个 agent?" | AclEntry 集合(每资源一行) |
前几章讲的功能(agent、prompt、MCP server、skill)大多是"可被分享的资源",所以重点在第二层——资源级 ACL。它的核心思想:不给资源存"owner 是谁",而是存一张多对多的授权表,每行表达"某个 principal 对某个 resource 有哪些权限位"。
6.2 权限 = 位掩码,身份 = principal 列表
权限用位掩码表示(accessPermissions.ts:57 PermissionBits):
| 位 | 值 | 含义 |
|---|---|---|
| VIEW | 1 | 可查看/使用 |
| EDIT | 2 | 可改设置 |
| DELETE | 4 | 可删除 |
| SHARE | 8 | 可分享 |
用位掩码是因为权限可叠加、可用按位或合并:editor = VIEW|EDIT = 3,owner = 1|2|4|8 = 15。判断"够不够"就一个按位与(accessPermissions.ts:356 hasPermissions:(permissions & required) === required)。
身份这边,一个用户不是单一身份,而是一串 principal。getUserPrincipals(methods/userGroup.ts:395)把一个用户展开成:他本人(USER)+ 他的角色(ROLE)+ 他所在的每个组(GROUP)+ 人人都有的 PUBLIC。
判断有效权限时,把这串 principal 拿去 ACL 表里查所有命中行,再把各行的权限位按位或合并(methods/aclEntry.ts:296 getEffectivePermissionsForResources,aclEntry.ts:323 的 currentBits | entry.permBits)。也就是说:通过组拿到的权限,和直接授给个人的权限,自动并起来取最宽。
一次校验的完整链路(api/server/services/PermissionService.js:138 checkPermission):
checkPermission({ userId, resourceType, resourceId, requiredPermission })
│
├─ getUserPrincipals(userId) ← 把用户展开成 principal 列表
│ (userGroup.ts:395)
▼
└─ hasPermission(principals, ...) ← 查 ACL、按位或合并、再按位与比对
(aclEntry 批量方法)
授权写入侧对称地存(grantPermission,PermissionService.js:61→aclEntry.ts:341):把 accessRoleId(如 agent_editor)翻译成权限位存进 AclEntry。命名角色和裸位掩码之间的换算表在 accessPermissions.ts:321(accessRoleToPermBits)。
6.3 同一套权限概念,前端也拿得到
关键在于:PermissionBits、accessRoleToPermBits、hasPermissions 这些不是后端私有,它们就定义在 data-provider 的 accessPermissions.ts 里(第 4 章那个共享 SDK)。
所以前端能用同一份逻辑决定"这个用户对这个 agent 有没有 EDIT 位、要不要显示编辑按钮",而不用把后端的判断硬编码抄一遍(react-query/react-query-service.ts:11 直接 import * as permissions from '../accessPermissions',并把 hasPermissions 再导出给前端)。
这正是本章开头那条"类型对齐"暗线在权限上的体现:权限的概念也是单一真源,前后端共享,判断口径天然一致。
7. 边界与局限(诚实说)
- 数据层强绑 MongoDB + Mongoose。 schema/model/methods 三段都基于 Mongoose,换关系型数据库需要重写整个 data-schemas 层,不是配置项。
- 搜索是可选副本,不是强一致。 MeiliSearch 只在配了环境变量时挂载(
models/message.ts:9),索引是 Mongo 的异步副本;没配搜索的部署,searchMessages会直接抛"插件未注册"(methods/message.ts:536)。 - 前端仍是 JS/React 混用的遗留骨架。 最外层
App.jsx是.jsx、用 Recoil;新代码在往 TS + Jotai 迁移(store/里jotai-utils.ts与 Recoil 并存),两套状态库目前共存。 api是有意保持"薄"的遗留层。 真正的新逻辑在packages/api(TS),api/只做转发;读后端代码时别指望在api/里找到完整实现,它多半只是个require('~/models')的门面。- 权限判断的正确性依赖 principal 展开完整。 若组成员关系(尤其 Entra 外部组)没同步到位,
getUserPrincipals少算一个组,该组授予的权限就查不到——权限"少给"比"多给"更常见,排障时先看 principal 列表。
8. 代码地图(导航索引)
按符号名 grep 比按行号更抗上游漂移。下面每行给"主题 | 文件 | 符号"。
数据层(data-schemas)
| 主题 | 文件 | 符号 |
|---|---|---|
| 消息字段与索引 | packages/data-schemas/src/schema/message.ts | messageSchema |
| 会话字段(含 messages 引用数组) | packages/data-schemas/src/schema/convo.ts | convoSchema |
| 用户(OAuth/2FA/租户) | packages/data-schemas/src/schema/user.ts | userSchema |
| agent 定义 | packages/data-schemas/src/schema/agent.ts | agentSchema |
| 文件元数据 | packages/data-schemas/src/schema/file.ts | file |
| model 编译 + 插件注入 | packages/data-schemas/src/models/message.ts | createMessageModel |
| 全部 model 组装 | packages/data-schemas/src/models/index.ts | createModels |
| 消息落库(upsert/幂等) | packages/data-schemas/src/methods/message.ts | saveMessage |
| 游标分页读消息 | packages/data-schemas/src/methods/message.ts | getMessagesByCursor |
| 会话落库(回填消息数组) | packages/data-schemas/src/methods/conversation.ts | saveConvo |
| 会话游标分页 + 搜索 | packages/data-schemas/src/methods/conversation.ts | getConvosByCursor |
| 方法组装 + 依赖注入 | packages/data-schemas/src/methods/index.ts | createMethods |
| 后端统一门面 | api/models/index.js | createMethods / seedDatabase |
共享 SDK(data-provider)
| 主题 | 文件 | 符号 |
|---|---|---|
| 请求体单点打包 | packages/data-provider/src/createPayload.ts | createPayload |
| 会话/消息 zod schema | packages/data-provider/src/schemas.ts | tConversationSchema / tMessageSchema |
| 请求 URL 约定 | packages/data-provider/src/api-endpoints.ts | messages / conversations |
| 请求函数 | packages/data-provider/src/data-service.ts | getConversations |
| react-query hooks | packages/data-provider/src/react-query/react-query-service.ts | useGetSharedMessages 等 |
| 缓存键枚举 | packages/data-provider/src/keys.ts | QueryKeys |
前端骨架(client)
| 主题 | 文件 | 符号 |
|---|---|---|
| Provider 洋葱 + QueryClient | client/src/App.jsx | App |
| 按会话分片的状态 | client/src/store/families.ts | submissionByIndex(atomFamily) |
| 消费 SSE 流 | client/src/hooks/SSE/useSSE.ts | useSSE |
| 前端 data hooks 门面 | client/src/data-provider/index.ts | (re-export) |
权限
| 主题 | 文件 | 符号 |
|---|---|---|
| 权限校验入口 | api/server/services/PermissionService.js | checkPermission / grantPermission |
| 用户展开成 principal 列表 | packages/data-schemas/src/methods/userGroup.ts | getUserPrincipals |
| 批量算有效权限(按位或) | packages/data-schemas/src/methods/aclEntry.ts | getEffectivePermissionsForResources |
| ACL 行结构 | packages/data-schemas/src/schema/aclEntry.ts | aclEntrySchema |
| 权限位/角色换算(前后端共享) | packages/data-provider/src/accessPermissions.ts | PermissionBits / accessRoleToPermBits / hasPermissions |
同组其它章:总览见 index.md;运行时主线见 01-request-lifecycle.md;endpoint 抽象见 02-endpoints-providers.md;agent 编排见 03-agent-orchestration.md;工具与 MCP 见 04-tools-and-mcp.md;上下文/文件/记忆见 05-context-files-memory.md。本章只讲支撑层,运行时逻辑不在此重复。