深入解析 Macro models_soup:Soup 统一活动流的类型模型与分页契约

发布时间:2026/10/31 1:49:31
深入解析 Macro models_soup:Soup 统一活动流的类型模型与分页契约
  • 后端
  • 前端
  • 协同办公
  • 即时通讯
  • 企业应用
  • CRM
  • AI Agent
  • 人工智能

【免费下载链接】macro

Macro is a unified workspace for teams: email, chat, docs, tasks, agents, calls, and CRM — @-linked together with shared AI memory.

项目地址:https://gitcode.com/GitHub_Trending/macro3/macro
点击查看免费下载

本篇技术指南以crates/models_soupcrate 的模块说明为骨架,拆解 Macro 统一活动流(Soup)在类型层面的设计:一个SoupItem枚举如何同时承载文档、聊天、邮件线程、频道消息、CRM、通话、日历、Agent 会话与数据库行等多种实体,以及它如何通过Identify、SortOn、EntityReference三个契约与分页、属性系统对接。读完后你可以独立完成“为 Soup 新增一种实体类型”的全链路改造,并理解其序列化与排序边界。

models_soup 的定位:只做模型,不做查询

models_soup是 Soup feed(Home/Recent、统一活动流)的共享 API 模型层。它遵循一条非常清晰的职责边界:

  • 拥有:每种可被 Soup 展示的实体类型的响应模型,以及统一条目类型。
  • 不拥有:GraphQL resolver 与 feed 查询逻辑(属于soup、graphql_soup);Soup 过滤 DSL(属于item_filters、soup_filter_projection)。

这一划分可以在 crate 根文件 lib.rs 中直接看到:每个实体模块都带有一句 “models for Soup responses” 的文档注释,并且整个 crate 开启了#![deny(missing_docs)],意味着任何新增的公开类型或字段都必须有文档注释,否则编译失败。这是一个很实用的约束——它强制模型层始终可被外部(前端、AI 工具、OpenAPI 生成器)理解。

模块清单如下:

模块对应实体
item统一条目SoupItem
document文档、任务、片段、技能、initiative 描述
chat聊天
project项目
initiativeInitiative(前端称为 project)
email_thread邮件线程及其附件、参与者、标签
comms频道与频道消息线程
call_record通话记录
calendar_event日历事件
crm_company/crm_contactCRM 公司与团队联系人
foreign_entity外部系统实体映射
agent_sessionAgent 会话
database_row数据库行
properties附着在条目上的属性SoupProperty

依赖方向也在 Cargo.toml 中固定下来:call、channels、crm、document_sub_type、doppleganger、email、macro_user_id、model、model-entity、model_owner、models_pagination、models_properties。这些依赖恰好对应它需要“读懂”的上游领域类型,以及它需要实现的三个外部契约。

统一条目 SoupItem:一个枚举收口所有实体

统一模型在 item.rs:

#[derive(Clone, Serialize, Deserialize, Debug)] #[serde(rename_all = "camelCase", tag = "tag", content = "data")] #[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] pub enum SoupItem<T = ()> { Document(SoupDocument<T>), Chat(SoupChat<T>), Project(SoupProject<T>), Initiative(SoupInitiative<T>), EmailThread(SoupEnrichedEmailThreadPreview<T>), Channel(SoupChannel), ChannelThread(SoupChannelThread), Call(SoupCallRecord<T>), CalendarEvent(SoupCalendarEvent<T>), CrmCompany(SoupCrmCompany<T>), CrmContact(SoupCrmContact<T>), ForeignEntity(SoupForeignEntity), AgentSession(SoupAgentSession<T>), DatabaseRow(SoupDatabaseRow<T>), }

有三个设计点值得注意。

第一,tag/content的外部标记(adjacently tagged)序列化。JSON 形如{"tag": "document", "data": { ... }}。这比内部标记(#[serde(tag = "...")])更适合这里,因为多个变体的 payload 是结构体且包含flatten字段,内部标记与#[serde(flatten)]组合时容易产生冲突。测试 item/test.rs 直接断言了value["data"]["model"]这样的访问路径,说明外层结构是稳定契约而非实现细节。

第二,泛型参数T = ()是“扩展载荷”的挂载点。大部分变体都是SoupXxx<T>,沉默的变体(Channel、ChannelThread、ForeignEntity)则没有扩展位——它们本身不支持附加属性。默认T = ()让调用方在不做增强时零成本使用,例如SoupItem<()>。

第三,ChannelThread携带的实体类型是ChannelMessage。在entity()的实现中可以看到这个细节:

SoupItem::ChannelThread(thread) => { EntityType::ChannelMessage.with_entity_string(thread.id.to_string()) }

也就是说,“频道里的顶层消息线程”在实体系统中等价于一条 canonical 的 channel message,这与 comms.rs 的文档注释一致:该结构刻意镜像公开的 channel-message API 形状,让 Soup 消费者可以用渲染频道时间线的同一套组件渲染线程根消息。

四个必须实现的契约

SoupItem之所以能直接进入分页管线,是因为它在impl<T>块中实现了四个对外契约。理解这四个方法,就理解了整个模型层与外围系统的接口。

1. entity():把条目映射到实体身份

item.rs 中的entity()返回model_entity::Entity<'static>,逐变体地把 id 与EntityType组合起来。这是权限系统、活动流、缓存投影定位记录的统一入口——graphql_soup正是靠它来构造RecordKey,见 graphql_soup/src/objects.rs。

注意邮件线程映射的是email_thread.thread.id,而不是外层包裹结构;频道映射的是channel.channel.channel.id.0(三层嵌套:SoupChannel→ChannelWithParticipants→Channel→ChannelId),这是flatten序列化的直接后果——payload 是扁平 JSON,但内存表示是分层结构。

2. updated_at():供 frecency 回退使用的“最近活动时间”

updated_at()并不总是字段直读,其中几个变体带有明确的语义修正:

// Calls intentionally lack `updated_at`; recency follows their lifecycle timestamps. SoupItem::Call(record) => record.ended_at.unwrap_or(record.started_at), // Includes the fired-reminder timestamp so the frecency fallback // cursor agrees with the GREATEST-based recency sort. SoupItem::CalendarEvent(event) => event .last_reminder_fired_at .map_or(event.updated_at, |fired| fired.max(event.updated_at)), SoupItem::CrmContact(contact) => contact.last_interaction,

这三处注释暴露了真实的工程约束:内存侧的排序键必须与 SQL 侧的排序表达式严格一致,否则游标分页会错页。日历事件的注释甚至直接点名了 SQL 中的GREATEST(updated_at, last_reminder_fired_at);CRM 联系人以last_interaction而非updated_at作为“最近”依据;通话记录由于没有updated_at列,退化为生命周期时间戳。

3. cursor_timestamp():按排序方式取不同的时间值

item.rs 中的cursor_timestamp(&self, sort: SimpleSortMethod)是私有方法,通过SortOn暴露出去。它按SimpleSortMethod(定义于 models_pagination/src/sort.rs)分支:

pub enum SimpleSortMethod { ViewedAt, UpdatedAt, CreatedAt, ViewedUpdated, }

大量变体的模式是高度一致的:ViewedAt取viewed_at.unwrap_or_default(),UpdatedAt取updated_at,CreatedAt取created_at,ViewedUpdated取viewed_at.unwrap_or(updated_at)。但存在若干刻意的例外,它们是排查“排序结果奇怪”问题的第一现场:

  • 邮件线程忽略排序方式,永远使用thread.sort_ts。注释说明这是邮件 SQL 查询预先计算出的effective_ts,游标偏移逻辑(effective_ts, id) < (cursor_ts, cursor_id)依赖它。
  • 频道线程只有CreatedAt走created_at,其余一律走effective_updated_at(),其实现为latest_reply_at.unwrap_or(updated_at).max(updated_at)(comms.rs)——线程的新回复会让父消息浮到前面。
  • 数据库行从不被单独浏览:ViewedAt返回DateTime::default()(排到最后),ViewedUpdated退化为updated_at。
  • 日历事件的ViewedAt返回默认时间,因为事件没有 per-viewer 的浏览记录字段。

这些“特殊分支”不是随意写的,call_record/test.rs里的assert_call_recency就是这种一致性的守卫:它对ViewedAt、UpdatedAt、ViewedUpdated三种排序都断言sort_on(item).last_val == expected,见 call_record/test.rs。

4. Identify 与 SortOn:接入键集分页

impl<T> Identify for SoupItem<T> { type Id = Uuid; fn id(&self) -> Self::Id { /* 逐变体取主键 */ } } impl<T> SortOn<SimpleSortMethod> for SoupItem<T> { fn sort_on(sort: SimpleSortMethod) -> impl FnMut(&Self) -> models_pagination::CursorVal<SimpleSortMethod> { move |v| models_pagination::CursorVal { sort_type: sort, last_val: v.cursor_timestamp(sort) } } }

这两个 trait 定义在 models_pagination/src/cursor.rs。CursorVal<C>的结构是{ sort_type, last_val }(同文件 L84-L92),因此游标本身是自描述的:它既知道“按哪种时间排”,也知道“上一页最后一条的时间值”。Identify提供 id 作为二级排序键,保证时间戳相同时仍有确定性顺序。

soupcrate 的分组逻辑直接复用了它:grouping.rs 中let mut get_cursor_val = SoupItem::sort_on(sort_method);,然后对每个分组内的最后一条记录生成CursorWithValAndFilter<Uuid, SimpleSortMethod, EntityFilterAst>并 Base64 编码为组级next_cursor(同文件 L116-L147)。这解释了为什么SoupItem的排序实现必须精确:一处偏差会同时污染页级游标与组级游标。

属性系统:to_entity_reference 与 SoupProperty

Soup 的条目可以带属性(状态、优先级、标签等)。模型层通过to_entity_reference()把条目翻译成models_properties::EntityReference:

pub fn to_entity_reference(&self) -> Option<EntityReference> { ... }

关键点是返回Option,并且有明确的空值语义(item.rs):

  • 返回None:Channel、ChannelThread、CrmContact、ForeignEntity、AgentSession。
  • AgentSession的空值理由写在注释里:“Agent sessions have no properties entity type yet.”——这是尚未建模,而非设计上不支持。
  • 文档的实体类型是动态的:doc.entity_type()会根据sub_type返回Task或Document(见下文)。

属性值本身的模型在 properties.rs:

pub struct SoupProperty { pub id: Uuid, pub definition: PropertyDefinition, #[serde(skip_serializing_if = "Option::is_none")] pub value: Option<PropertyValue>, }

它的From<EntityPropertyWithDefinition>实现刻意丢弃了options字段。注释给出了理由:Soup 条目只需要“定义 + 当前值”,可选项(如 select 的候选项列表)应在编辑 UI 需要时另行获取。这是一个典型的“列表页轻量化”取舍——避免每一行都携带全部属性元数据。

在服务侧,属性通过泛型扩展位注入:soupcrate 定义了SoupPropertiesField { properties: Vec<SoupProperty> }与类型别名SoupItemWithProperties = SoupItem<SoupPropertiesField>(soup/src/domain/models.rs),数据库层则直接map(SoupProperty::from)转换(soup/src/outbound/pg_soup_repo.rs)。

map_extra 与 extra 扁平化:扩展载荷的工作机制

SoupItem<T>的泛型参数如何落到 JSON 上?答案在map_extra(item.rs):

pub fn map_extra<U, F>(self, f: F) -> SoupItem<U> where F: FnOnce(T) -> U

它的实现是逐变体的结构体解构再重建——把每个字段逐一写出,只把extra替换为f(extra)。这看起来啰嗦,但换来了两个好处:编译器会在新增字段时强制你更新此处(不会静默丢失字段),且不支持 extra 的变体(Channel等)会原样透传。

配套的是各模型结构体上的#[serde(flatten)] pub extra: T。测试用例精确地刻画了这条链路:

#[test] fn raw_extra_serializes_without_fields() { let value = serde_json::to_value(raw_chat()).unwrap(); assert!(value["data"].get("extra").is_none()); assert!(value["data"].get("properties").is_none()); } #[test] fn mapped_extra_is_flattened_into_the_item_data() { let item = raw_chat().map_extra(|()| TestPropertiesField { properties: vec!["priority".to_string()] }); let value = serde_json::to_value(item).unwrap(); assert_eq!(value["data"]["properties"], serde_json::json!(["priority"])); assert!(value["data"].get("extra").is_none()); }

注意第二处断言:extra这个键名不会出现在 JSON 里,扩展字段被平铺到data层级。这是flatten+ 具名结构体的必然结果,也是前端可以直接data.properties取值的原因。SoupItem<()>时()不产生任何字段,因此extra键与内部字段都不存在——序列化形状随泛型参数变化,这一点在设计客户端类型时必须考虑。

各实体模型的关键取舍

文档与子类型:类型安全的“任务属性”

document.rs 用枚举把“子类型”和“该子类型才有的属性”绑在一起:

#[serde(tag = "type", rename_all = "snake_case")] pub enum SoupDocumentSubType { Task { is_completed: bool }, Snippet {}, Skill {}, InitiativeDescription {}, }

注释点明动机:Task变体才带is_completed,从而在类型层面保证“任务属性只在文档是任务时存在”。from_db负责从数据库的两个独立列(sub_type与is_completed)合成领域枚举,is_task_completed()则提供反向的、只对任务有意义的查询。

SoupDocument<T>里还有两个值得注意的字段语义:document_version_id可能是document_instance_id或document_bom_id,取决于文件类型;sha只在 PDF 上存在,DOCX 没有。这类“同一字段在不同文件类型下含义不同”的信息,正是模型注释存在的价值——它无法由类型系统表达。

entity_type()的映射规则(L118-L136):只有Task映射为EntityType::Task,Snippet、Skill、InitiativeDescription以及None全部映射为EntityType::Document。注释解释了原因:“片段性/技能性只存在于sub_type中”,实体系统不为此单开类型。这直接影响属性查询:技能文档的属性查找会走Document实体类型。

频道:扁平化嵌套与 is_participant 的作用域

comms.rs 是本 crate 中最“重”的模块,它把channels领域的多个类型转换成 Soup 形状,并提供了大量new_from_*转换函数。

SoupChannel由两个flatten组成:ChannelWithParticipants与LatestMessage,再加上viewed_at、interacted_at两个 per-viewer 时间戳。LatestMessage同时携带latest_message(含线程回复的最新消息)与latest_non_thread_message(最新顶层消息),这是频道列表中“预览文本”与“话题流”两种展示需求并存的产物。

is_participant的作用域被明确测试过:comms/test.rs 断言它出现在条目顶层,而不出现在data.channel内部,因为“该标志描述的是请求用户,而不是频道行本身”。同时它带有#[serde(default)],缺失时可以反序列化为false(同文件 L38-L49),保证向后兼容。

sender_id的双轨设计也值得注意:SoupChannelThread同时保留原始sender_id: String和结构化的sender: SoupMessageSender。后者通过ChannelSender::parse_from_str解析存储前缀,区分User与Bot,并在 bot 场景下填充name与avatar_url(见from_storage_string与from_message_sender,L469-L517)。解析失败时退化为User类型并原样保留 id——一个不会让整条消息渲染失败的保守回退。

邮件线程:用 Doppleganger 镜像上游类型

email_thread.rs大量使用doppleganger的Doppleganger/Mirror派生:

#[derive(Clone, Debug, Doppleganger, Serialize, Deserialize)] #[dg(backward = email::domain::models::EmailThreadPreview)] pub struct SoupEmailThreadPreview { ... }

这带来的直接好处是转换样板可以自动生成:

impl From<email::domain::models::EnrichedEmailThreadPreview> for SoupEnrichedEmailThreadPreview<()> { fn from(preview: ...) -> Self { Self { thread: SoupEmailThreadPreview::mirror(preview.thread), attachments: Vec::<SoupAttachment>::mirror(preview.attachments), participants: Vec::<SoupContact>::mirror(preview.participants), labels: Vec::<SoupLabel>::mirror(preview.labels), extra: (), } } }

这里有一个容易误读的字段:is_signal。注释特别澄清它与is_important的区别——is_signal是去规范化的email_threads.is_signal重要度分类,也正是 Soup 的 Importance 过滤器所评估的标志;is_important则是 Gmail 的 IMPORTANT 标签。两者同名近似,语义完全不同,写前端筛选逻辑时必须区分。

另外sort_ts是邮件排序的专用字段,与updated_at分离,原因已在前文的cursor_timestamp部分说明。

CRM:团队归属与可见范围

SoupCrmCompany<T>携带team_id、hidden、email_sync以及按创建时间升序排列的domains(首个为主域)。name与description是从crm_domain_directory按主域解析出的展示信息,可能为None——注释明确写出“unresolved”的情况,提醒调用方不要假设一定有值。

SoupCrmDomain注释说它“镜像 crm crate 的CrmDomain,但提供前端可依赖的稳定 wire 形状”。这是整个 crate 的通用立场:领域类型可以演进,wire 形状必须稳定,因此需要显式的镜像类型。

SoupCrmContact<T>的id注释是“Original team-owned contact ID”,同时viewed_at是“The viewer's latest visit to this record”。这说明同一份联系人记录在不同团队视角下是不同的行,viewed_at是 per-viewer 的。

通话:viewer-relative 的 status 推导

SoupCallRecord<T>的status是item_filters::CallStatus,且attended被标注为“为兼容而保留,由status == ATTENDED派生”。推导逻辑在from_record_for_user:

fn participant_derived_status(participants: &[CallRecordParticipant], user_id: &str) -> CallStatus { if participants.iter().any(|p| p.user_id == user_id) { return CallStatus::Attended; } CallStatus::Unattended }

即:记录自带status时优先使用;否则按“当前用户是否出现在参与者列表”推导。测试覆盖了全部四种组合——记录状态为Missed时不 attended、记录状态为Attended但用户不在参与者中仍为 attended(因为记录状态优先)、状态缺失时按参与者推导,见 call_record/test.rs。

结构体注释还声明了被排除的字段:room_name、egress_id、transcript——它们对 feed 展示无意义,因此不进入 wire 模型。这是“响应模型只包含渲染所需字段”原则的具体体现。

日历事件:时间表示的双变体

#[serde(rename_all = "camelCase", tag = "kind")] pub enum SoupCalendarEventTime { Timed { starts_at: DateTime<Utc>, ends_at: DateTime<Utc>, time_zone: Option<String> }, AllDay { start_date: NaiveDate, end_date: NaiveDate }, }

注释里有一个非常具体的实现说明:字段的 camelCase 是逐变体标注的,而不是用rename_all_fields,因为 utoipa 会忽略后者,导致生成的 OpenAPI 声明 snake_case 而线上实际是 camelCase。这是一个“schema 生成器与 serde 语义不一致”的真实踩坑记录,对要接 OpenAPI 的团队有直接参考价值。

语义上,Timed的ends_at是排他的;AllDay的end_date同样是排他日期,start_date为包含。ical_uid用于与源日历对账,is_read_only表示所选 canonical 源是否只读。

Agent 会话与数据库行

SoupAgentSession<T>的设计目标是“让 coding 与非 coding 会话都能一次渲染,无需逐条再查”。因此它内联了运行时与仓库元数据:harness、repo_url、repo_branch(注释强调这是创建时选择的起始分支,不是当前分支)、working_branch(运行时上报的当前分支)、pull_request_url、pull_request_state、pull_request_id、turn_state。

status字段的语义被完整定义在注释中:首个系统事件到达前为no_messages;连接非正常关闭为disconnected;否则是最近一次系统事件的 wire 名(例如session/end)。这种“状态机取值域写进文档注释”的做法,避免了消费者猜测字符串。

source与visibility直接复用了过滤 DSL 中的类型:

pub use item_filters::ast::agent_session::{AgentSessionSource, AgentSessionVisibility};

这是models_soup依赖item_filters的原因之一——模型与过滤语言共享同一套枚举,避免“过滤接受 A 值、响应返回 B 值”的错配。

SoupDatabaseRow<T>则明确了一个权限事实:行没有自己的访问控制,“access is its database's”(访问权限属于其数据库)。owner_id注释为“The database's owner, which owns every row in it”;created_by是Option<String>,因为创建者可能已被删除;position是表内排序用的分数索引(fractional index)字符串。

特性开关:mock 与 schema

Cargo.toml 定义了两个 feature:

[features] mock = [] schema = ["dep:utoipa", "item_filters/schema"]
  • mock:为部分类型附加PartialEq, Eq派生(可见于SoupDocumentSubType、SoupContact、SoupLabel等),便于测试中直接断言相等。注意它是按类型逐个加的,不是全 crate 统一,因此不能假设所有SoupItem变体都可==比较。
  • schema:启用utoipa::ToSchema派生,并联动启用item_filters/schema。代码中统一写作#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))],以及针对特殊字段的#[cfg_attr(feature = "schema", schema(value_type = String))](如owner_id: Owner、MacroUserIdStr)。这些覆盖是必要的:Owner与MacroUserIdStr在 wire 上是字符串,若不覆盖,生成的 schema 会暴露内部类型。

schema特性同时被soupcrate 用于SoupPropertiesField(其axumfeature 下派生ToSchema),因此 OpenAPI 的类型一致性是跨 crate 协同的结果。

实操:为 Soup 新增一种实体类型

AGENTS 文档给出的配方是四步,结合源码可以把它展开成可执行的清单。这条链路是:

第 1 步:新建src/<kind>.rs。定义SoupXxx<T = ()>,遵循三条约定:

  1. 结构体上加#[serde(rename_all = "camelCase")],以及#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]。
  2. 带属性的实体保留#[serde(flatten)] pub extra: T;不支持的实体的extra可以直接省略(如SoupForeignEntity)。
  3. 可选字段用#[serde(skip_serializing_if = "Option::is_none")],让 JSON 更紧凑;但只有需要“缺省即 false”语义的布尔字段才加#[serde(default)](参考is_participant与source)。

第 2 步:在lib.rs注册模块,并在pub mod上方写一行文档注释——#![deny(missing_docs)]会强制这一步。

第 3 步:接入item.rs的统一条目。这一步是编译期强制的,共五处:

  1. SoupItem枚举新增变体;
  2. entity()新增EntityType::Xxx.with_entity_string(...)分支;
  3. updated_at()新增分支(决定 frecency 回退排序键);
  4. cursor_timestamp()新增四个SimpleSortMethod分支(或像Call、EmailThread那样合并为通配_分支);
  5. to_entity_reference()新增分支(若该实体不支持属性,写None并附注释说明原因);
  6. map_extra()新增完整解构分支(若变体带T);
  7. Identify::id()新增分支。

漏掉任何一处都会编译失败(除to_entity_reference外,因为它是显式match,同样会失败)。这正是枚举 + 穷尽匹配的价值。

第 4 步:向上游接线。依次是soup(feed 查询)、graphql_soup(GraphQL 暴露)。后者需要新增一个GraphqlSoupXxx对象并在GraphqlSoupEntity枚举中加入变体,可参照 objects.rs 的现有 15 个变体(含已废弃的Reminder,注释标注 “never constructed”)。

一个容易遗漏的细节来自soupcrate 的文档:GraphQL 层的枚举与SoupItem枚举不是一一对应的——SoupItem::ChannelThread在 GraphQL 层叫ChannelMessage。命名差异是有意的(wire 语义统一到 canonical 概念),新增类型时不要机械照抄 Rust 变体名。

校验与测试

文档给出的检查命令是:

cargo test -p models_soup

并特别注明SQLX_OFFLINE不要设置。原因在于该 crate 的测试包含依赖数据库 schema 信息的路径;这与soupcrate 的检查要求相互印证——soup需要cargo test -p soup --features outbound加本地 MacroDB,且在 schema/query 变更后执行nix develop --command just prepare_db(见 crates/soup/AGENTS.md)。

models_soup的测试覆盖了三类风险:

测试文件覆盖点
item/test.rs序列化形状、map_extra扁平化
comms/test.rsis_participant位置与向后兼容反序列化
call_record/test.rsviewer-relative 状态推导、三种排序下的 recency 一致性

其中assert_call_recency是最有借鉴价值的模式:它同时断言updated_at()与sort_on()的结果,把“两个应该一致的时间来源”绑在同一个测试里。新增实体类型时,建议照此编写针对cursor_timestamp各分支的测试,尤其是存在特殊语义的变体(无viewed_at、非updated_at排序键等)。

边界规则:为什么这些代码不该放在这里

模型的职责边界不只是组织代码,也是避免循环依赖的手段。三条判断规则可直接落地:

  1. “这个实体怎么创建/编辑”不属于本 crate。频道、文档、initiative 的写入逻辑在各自领域 crate,models_soup只定义它们的 soup 展示形状。
  2. “怎么查”不属于本 crate。跨实体过滤、排序、分组、排除、分页与 Postgres 查询都在soup;per-entity 缓存投影在soup_filter_projection;Soup 补丁的实时分发在soup_realtime。
  3. “过滤语言”不属于本 crate。item_filters/soup_filter_projection拥有 DSL;models_soup只在必要时复用其中的枚举(如CallStatus、AgentSessionSource),以保持过滤值与响应值同源。

反过来,本 crate 被ai_tools、complete_graph、databases_sql、graphql_initiative、graphql_soup、models_search、search_service、skills、soup、soup_filter_projection、work_feed使用。这意味着任何一个 wire 字段改名,影响面会同时波及 GraphQL API、AI 工具输出与搜索索引投影——因此“wire 形状必须稳定”不是风格偏好,而是本 crate 的硬约束。修改字段前应先确认下游是否需要同步(例如soup的SoupPropertiesField、GraphQL 的对象映射)。

小结

models_soup看起来只是“一堆响应结构体”,实际上它用三件事把分散的实体收束成一个可排序、可分页、可带属性的统一 feed:

  1. 一个外部标记的枚举SoupItem<T>,用tag/data提供稳定的 JSON 形状,用泛型T提供可选的属性挂载点,用map_extra保证扩展字段被正确扁平化。
  2. 一套排序契约:updated_at()、cursor_timestamp()、Identify、SortOn四者必须与 SQL 侧表达式严格一致,邮件线程、日历事件、通话记录、数据库行都是有意的例外,且每一处例外都在源码注释中写明了原因。
  3. 一组实体专属的建模取舍:文档子类型把任务属性升进类型系统,CRM 类型刻意镜像以稳定 wire 形状,通话状态是 viewer-relative 推导,日历时间区分 Timed 与 AllDay 并规避 utoipa 的rename_all_fields缺陷。

新增实体类型时,只要按src/<kind>.rs→lib.rs→item.rs六处分支 →soup→graphql_soup的顺序推进,并以cargo test -p models_soup(不设SQLX_OFFLINE)作为验收,就能把改动限定在模型层,不触碰查询与权限逻辑。

  • 后端
  • 前端
  • 协同办公
  • 即时通讯
  • 企业应用
  • CRM
  • AI Agent
  • 人工智能

【免费下载链接】macro

Macro is a unified workspace for teams: email, chat, docs, tasks, agents, calls, and CRM — @-linked together with shared AI memory.

项目地址:https://gitcode.com/GitHub_Trending/macro3/macro
点击查看免费下载
上一篇:OpenMetadata Java Code Reviewer Agent 实战指南:以 Kafka 级标准审查后端代码
下一篇:DiceDB ZRANGE 命令详解:有序集合区间查询的语法、语义与底层实现

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考