models.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791
  1. /**
  2. * 领域模型 —— 前端唯一权威类型定义
  3. *
  4. * 依据《数据访问层设计.md》§三。云函数返回的原始形状由 `ParseApi` 的映射函数
  5. * 转成本文件定义的模型,页面只见模型,不见原始字段。
  6. *
  7. * 🔴 禁止在页面/组件内另建同义类型;禁止把云函数的原始 JSON 形状泄漏到视图层。
  8. */
  9. // ---------- 通用 ----------
  10. /** 加载状态三态 + 空闲 */
  11. export type LoadState = 'idle' | 'loading' | 'ready' | 'empty' | 'error';
  12. // ---------- 数字生命 ----------
  13. /** 数字生命运行状态(与云函数 xlosi3m46q 的 status 取值一致) */
  14. export type AgentStatus = 'running' | 'idle' | 'error' | 'offline' | 'stopped';
  15. export interface HostMetrics {
  16. cpuPercent?: number;
  17. memPercent?: number;
  18. diskPercent?: number;
  19. }
  20. export interface Balance {
  21. /** 服务端换算后的余额(元)。映射层负责归一:兼容旧键 remainRmb 后统一写入此处 */
  22. remainCny?: number;
  23. /** 业务再算页面外链(FR-07 AC-07.4) */
  24. pageUrl?: string;
  25. }
  26. export interface ChannelConfig {
  27. matrix?: boolean;
  28. homeserver?: string;
  29. matrixConfig?: {
  30. userId?: string;
  31. homeserver?: string;
  32. serverName?: string;
  33. /**
  34. * 容器侧上报的通道状态(实测数据里有,此前类型未声明)。
  35. * `'connected'` = 容器 Matrix 通道在跑;缺失 = 未上报(可能离线)。
  36. */
  37. channelState?: string;
  38. /** listener 健康检查通过(旧字段,与 channelState 二选一为真即在线) */
  39. listenerOk?: boolean;
  40. /** 更细的状态明细(gateway / matrix / wecom 各自状态) */
  41. channelDetail?: {
  42. matrixState?: string;
  43. gatewayState?: string;
  44. wecomState?: string;
  45. codeVersion?: string;
  46. source?: string;
  47. };
  48. };
  49. /** 企业微信通道是否点亮(数组形态归一后得出) */
  50. wecom?: boolean;
  51. /** 企微机器人 BotID(数组形态下仅知通道名、无此值,故可选) */
  52. wecomBotId?: string;
  53. }
  54. /**
  55. * Token 用量。
  56. * 🔴 映射口径:云函数返回的 `usage` / `usageSessions` 是**字符串形态的 JSON**,
  57. * 且字段名未与服务端逐字段核对(《API-契约》§六-4)。映射函数负责
  58. * ① 兼容「已是对象 / 是 JSON 字符串」;② 归一字段名;③ 未识别字段保留在 raw。
  59. */
  60. export interface Usage {
  61. promptTokens?: number;
  62. completionTokens?: number;
  63. totalTokens?: number;
  64. updatedAt?: string;
  65. /** 原始未归一字段(排查用,UI 不直接消费) */
  66. raw?: Record<string, unknown>;
  67. }
  68. /** 单次会话的 token 用量(云函数 `usageSessions` 元素,契约见 reporter 字段规范 §3.4) */
  69. export interface UsageSession {
  70. sessionId?: string;
  71. title?: string;
  72. model?: string;
  73. messageCount?: number;
  74. toolCallCount?: number;
  75. inputTokens?: number;
  76. outputTokens?: number;
  77. cacheReadTokens?: number;
  78. reasoningTokens?: number;
  79. estimatedCostUsd?: number;
  80. costStatus?: string;
  81. /** epoch 数字(服务端不转换) */
  82. startedAt?: number;
  83. }
  84. /**
  85. * 容器上报的**单个会话**运行状态(2026-11-09,方案 A)。
  86. *
  87. * 由容器在 `pre_llm_call` / `post_llm_call` 钩子里维护,写进 `extra.currentSessions`。
  88. * `sessionKey` 是容器的 `build_session_key()` 产物,形如:
  89. * `agent:main:matrix:dm:<!roomId>[:<threadId>]`
  90. *
  91. * 🔴 App 的会话 id 是 Matrix 的 **thread 根 event_id**,与这里的 `sessionKey`
  92. * 是否对得上**必须实测验证** —— 对不上就是「报了但匹配不到会话」。
  93. */
  94. export interface AgentSessionState {
  95. /** 容器的 session_key 原文 */
  96. sessionKey: string;
  97. /** 该会话当前是否在跑 */
  98. status: 'running' | 'idle';
  99. /** 本轮开始的 epoch 毫秒(idle 时可缺省) */
  100. turnStartAt?: number;
  101. }
  102. export interface Person {
  103. id: string;
  104. name: string;
  105. avatar?: string;
  106. mobile?: string;
  107. /** 同事行展开时用 */
  108. agents?: Agent[];
  109. /**
  110. * 所属公司 id(`Company.objectId`)。
  111. * 通讯录按公司分组时使用;缺失时归入「未归属」。
  112. */
  113. companyId?: string;
  114. /**
  115. * 所属部门。
  116. *
  117. * 🔴 **预留字段**(2026-11-04):服务端目前没有部门维度,
  118. * 该值恒为 undefined,通讯录会归入「未分组」。
  119. * 待组织架构落地后由 `_User` / `Company` 表补充,前端不用改结构。
  120. */
  121. department?: string;
  122. }
  123. export interface Agent {
  124. id: string;
  125. name: string;
  126. avatar?: string;
  127. /** 专长描述(原型 specialty) */
  128. specialty?: string;
  129. gender?: 'male' | 'female' | 'unknown';
  130. status: AgentStatus;
  131. /** 状态中文文案(由 core/format 统一给出,不在组件里拼) */
  132. statusText?: string;
  133. currentTask?: string;
  134. currentAction?: string;
  135. progress?: number;
  136. heartbeatAt?: string;
  137. /**
  138. * 心跳距今**秒数**(2026-11-07 新增)。
  139. *
  140. * 云函数 `xlosi3m46q` 直接返回这个字段(实测它有值,而 `heartbeatAt` 为 undefined)。
  141. * 容器掉线后**不会**把 `status` 改成 offline,只会停止心跳 ——
  142. * 实测两个容器 `heartbeatAgeSec = 3914610`(45 天)却仍报 `running`。
  143. * 状态展示(`core/agent-state.ts`)用它判定「其实已离线」。
  144. */
  145. heartbeatAgeSec?: number;
  146. updatedAt?: string;
  147. host?: HostMetrics;
  148. balance?: Balance;
  149. channels?: ChannelConfig;
  150. usage?: Usage;
  151. /**
  152. * 会话级用量明细(云函数 `usageSessions`,JSON 字符串形态)。
  153. * 用于「Token 用量」下钻,缺失时不影响总量展示。
  154. */
  155. usageSessions?: UsageSession[];
  156. owner?: Person;
  157. companyId?: string;
  158. /**
  159. * 会话级运行状态(2026-11-09,方案 A)。
  160. *
  161. * 🔴 为什么需要:容器上报的 `status` 是**整个容器一个值** ——
  162. * 同一容器的多个会话共用它,于是「会话 A 在跑」时,会话 B 的界面也显示
  163. * 「正在执行」(用户实测)。
  164. *
  165. * 容器把每个会话的状态塞进 `extra.currentSessions`(`extra` 是平台
  166. * 云函数字段白名单里的合法字段,无需改云函数),这里解析出来供按会话展示。
  167. *
  168. * 容器尚未上报时为 `undefined` —— 调用方必须回落到全局 `status`,
  169. * 不能因为缺这个字段就显示「未知」。
  170. */
  171. currentSessions?: AgentSessionState[];
  172. /** 所属部门 —— **预留字段**(2026-11-04)。
  173. * 服务端暂无此维度,恒为 undefined;通讯录归入「未分组」。
  174. */
  175. department?: string;
  176. /** 容器标识(云函数 `containerRef`,Docker 短 ID / 主机名,排障时用来反查来源) */
  177. containerRef?: string;
  178. /**
  179. * Agent 自身 token 健康灯(云函数 `sessionStatus`)。
  180. * `ok` / `expired` / `unknown` —— expired 时余额等依赖 token 的字段会降级。
  181. */
  182. sessionStatus?: 'ok' | 'expired' | 'unknown';
  183. /** 是否已接入 FMODE(决定「进会话 vs 进详情」,见 AgentRouteService) */
  184. matrixReady: boolean;
  185. /** 会话未读数 */
  186. unread: number;
  187. }
  188. // ---------- 会话与消息 ----------
  189. /**
  190. * 会话。
  191. *
  192. * ## 两种形态(2026-10-21 新增群聊)
  193. *
  194. * | kind | 说明 | 用到哪些字段 |
  195. * |---|---|---|
  196. * | `dm` | 用户 ↔ 单个数字生命 | `agentId` / `peer` |
  197. * | `group` | 一个房间里有多个数字生命 | `members` / `name` |
  198. *
  199. * 🔴 群聊里**同一个房间里会有多个 agent 发言**,所以消息必须能标出「是谁说的」。
  200. * 此前消息只分 `me` / `agent`,无法区分多个 agent(见 `Message.senderId`)。
  201. */
  202. export interface Conversation {
  203. /** Matrix roomId(演示态为合成 id) */
  204. id: string;
  205. /** 会话形态;缺省视为 dm(兼容旧数据) */
  206. kind?: 'dm' | 'group';
  207. /** dm:对方的 agentId。群聊时为空 */
  208. agentId?: string;
  209. /** dm:对方 */
  210. peer?: Agent;
  211. /** group:群名称 */
  212. name?: string;
  213. /** group:房间内的 agent 成员(不含自己) */
  214. members?: Agent[];
  215. lastMessagePreview?: string;
  216. lastMessageAt?: string;
  217. unread: number;
  218. }
  219. export type MessageKind = 'text' | 'notice' | 'emote';
  220. export type MessageState = 'sending' | 'sent' | 'failed';
  221. export interface FileCard {
  222. kind: 'file';
  223. name: string;
  224. size: number;
  225. mimeType: string;
  226. url: string;
  227. thumb?: string;
  228. /**
  229. * 云盘(OBS/S3)公网地址(2026-11-09)。
  230. *
  231. * 用户要求「之后查看都走云盘」:有这个地址就优先用它,
  232. * 没有则回落到 Matrix 的 mxc 转换地址。缺失说明当时 S3 上传失败。
  233. */
  234. cloudUrl?: string;
  235. }
  236. export interface Message {
  237. id: string;
  238. conversationId: string;
  239. /**
  240. * 发送方类别。
  241. * 🔴 `agent` 不区分具体是哪个 agent —— 群聊里要结合 `senderId` / `senderName` 才能区分。
  242. */
  243. sender: 'me' | 'agent';
  244. /** 群聊:具体发送者的 Matrix ID(dm 下可省略) */
  245. senderId?: string;
  246. /** 群聊:展示用名字(agent 的 botName,或人类昵称) */
  247. senderName?: string;
  248. /** 这条消息 @ 了哪些人(Matrix ID)。群聊 UI 用来高亮「在叫我 / 叫我同事」 */
  249. mentions?: string[];
  250. kind: MessageKind;
  251. text?: string;
  252. file?: FileCard;
  253. artifactId?: string;
  254. taskId?: string;
  255. ts: string;
  256. state: MessageState;
  257. /** 失败原因(AC-04.8:失败消息不丢失,可重试) */
  258. error?: string;
  259. /**
  260. * 发送时写入 Matrix 事件的本地 id(`com.fmode.local`)。
  261. *
  262. * 服务端 echo 会原样带回它 —— 回读时据此**认领**本地那条乐观消息,
  263. * 避免同一句话同时以「本地一条 + 回读一条」出现两遍(2026-11-09)。
  264. * 容器 / 历史事件没有这个字段。
  265. */
  266. localId?: string;
  267. }
  268. // ---------- 成果与项目 ----------
  269. export interface Artifact {
  270. name: string;
  271. url: string;
  272. type: string;
  273. /**
  274. * 容器内的原始路径(非 URL 时才有)。
  275. * 该 artifact **尚未上传到云盘**,由 `ArtifactSyncService` 触发同步;
  276. * `url` 为空时 UI 不得提供下载(避免给一个打不开的链接)。
  277. */
  278. source?: string;
  279. }
  280. export type DeliverableState = 'delivered' | 'failed';
  281. /**
  282. * 文件台账条目(云函数 fmode-file-hub,2026-11-10)
  283. *
  284. * 用户裁决:消息不入库,只有**文件与成果**入库,让右侧栏能按会话直接加载。
  285. * 只存访问链接与元信息,不存文件内容。
  286. */
  287. export interface FileEntry {
  288. /** 会话归属(App 的会话 id = Matrix thread 根),核心字段 */
  289. threadRoot: string;
  290. /** 产出的容器;用户上传的文件也要带,便于按容器聚合 */
  291. agentId?: string;
  292. roomId?: string;
  293. name: string;
  294. /** 云盘访问链接(https,必须是 s3.fmode.cn / file-cloud.fmode.cn) */
  295. url: string;
  296. /** 云盘对象键,url 只是它的投影 */
  297. key?: string;
  298. mimeType?: string;
  299. size?: number;
  300. /** 谁放进来的:用户自己传的,还是容器产出的 */
  301. uploadedBy: 'user' | 'agent';
  302. /** 容器内的原始路径(仅容器产出时有) */
  303. source?: string;
  304. }
  305. /** 从文件台账拉回的一条记录(服务端已归一化) */
  306. export interface SessionFile {
  307. id: string;
  308. threadRoot: string;
  309. agentId: string;
  310. roomId: string;
  311. name: string;
  312. /** 未知大小用 -1(与云函数口径一致) */
  313. size: number;
  314. mimeType: string;
  315. url: string;
  316. storageKey: string;
  317. uploadedBy: 'user' | 'agent';
  318. status: string;
  319. /** epoch 毫秒 */
  320. deliveredAt: number;
  321. }
  322. export interface Deliverable {
  323. id: string;
  324. agentId: string;
  325. title: string;
  326. subtitle?: string;
  327. summary?: string;
  328. projectId?: string;
  329. projectName?: string;
  330. artifacts: Artifact[];
  331. tags: string[];
  332. deliveredAt: string;
  333. state: DeliverableState;
  334. /** 成果正文(演示态用;真实态由 artifacts 外链承载) */
  335. content?: string;
  336. /** 文件类型标签(DOC / CSV …) */
  337. fileType?: string;
  338. filename?: string;
  339. size?: string;
  340. version?: string;
  341. }
  342. export interface DeliverableGroup {
  343. project?: string;
  344. projectName: string;
  345. count: number;
  346. items: Deliverable[];
  347. }
  348. export interface Project {
  349. id: string;
  350. name: string;
  351. code?: string;
  352. description?: string;
  353. brief?: string;
  354. status?: string;
  355. owner?: string;
  356. due?: string;
  357. updated?: string;
  358. agentIds: string[];
  359. deliverableIds: string[];
  360. hasBlocked: boolean;
  361. }
  362. // ---------- 事件(派生,非独立表 —— SPEC FR-06 口径) ----------
  363. export type EventCategory = 'delivery' | 'execution' | 'alert';
  364. export interface EventItem {
  365. id: string;
  366. category: EventCategory;
  367. title: string;
  368. description?: string;
  369. ts: string;
  370. agentId: string;
  371. artifactId?: string;
  372. taskId?: string;
  373. }
  374. // ---------- 任务看板(FR-07b) ----------
  375. export type TaskStatus =
  376. | 'triage'
  377. | 'todo'
  378. | 'scheduled'
  379. | 'ready'
  380. | 'running'
  381. | 'blocked'
  382. | 'done';
  383. export interface TaskRun {
  384. id: string;
  385. status: string;
  386. outcome?: string;
  387. stepKey?: string;
  388. title?: string;
  389. detail?: string;
  390. summary?: string;
  391. error?: string;
  392. startedAt?: string;
  393. endedAt?: string;
  394. }
  395. export interface TaskComment {
  396. id: string;
  397. author?: string;
  398. body: string;
  399. createdAt: string;
  400. }
  401. export interface TaskAttachment {
  402. id: string;
  403. filename: string;
  404. size?: number;
  405. contentType?: string;
  406. /** 可选下载地址;真实接口未提供时保持空值,避免伪造下载能力。 */
  407. url?: string;
  408. }
  409. export interface Task {
  410. id: string;
  411. agentId: string;
  412. projectId?: string;
  413. title: string;
  414. status: TaskStatus;
  415. assignee?: string;
  416. owner?: string;
  417. branchName?: string;
  418. body?: string;
  419. blockKind?: string;
  420. consecutiveFailures?: number;
  421. /** 已阻塞时长(小时,用于「已阻塞 436h」表达) */
  422. blockedHours?: number;
  423. createdAt?: string;
  424. updatedAt?: string;
  425. startedAt?: string;
  426. completedAt?: string;
  427. runs: TaskRun[];
  428. comments: TaskComment[];
  429. attachments: TaskAttachment[];
  430. }
  431. // ---------- 工作组(多容器协作) ----------
  432. /**
  433. * 工作组 —— 一次任务的临时组合(员工团队)。
  434. *
  435. * 与 `Conversation` 的区别:会话是「和谁聊」,工作组是「谁在一起干活」。
  436. * 数据源是云函数 `fmode-workgroup`(服务端建房),不是 Matrix 直读。
  437. */
  438. export interface WorkGroup {
  439. id: string;
  440. name: string;
  441. goal?: string;
  442. roomId: string;
  443. /** 成员 agentId 列表 */
  444. members: string[];
  445. status?: string;
  446. }
  447. // ---------- 登录 ----------
  448. export interface Me {
  449. id: string;
  450. name: string;
  451. mobile?: string;
  452. avatar?: string;
  453. companyId?: string;
  454. /**
  455. * 所属组织名(Company.name)。
  456. *
  457. * 🔴 此前「我的」页显示硬编码的「企业空间」——是**假文案**。
  458. * 实测 `Company` 表可拿到 `name`(如 `E4KpGvTEto → 飞码AI`),故改为真实数据。
  459. * 取不到时为 `undefined`,UI 显示「—」而非编造。
  460. */
  461. companyName?: string;
  462. /** 仅用于决定请求哪个 scope 与界面提示;**不得**作为授权依据(FR-20 AC-20.2) */
  463. roles?: string[];
  464. }
  465. export interface AuthSession {
  466. token: string;
  467. me: Me;
  468. }
  469. // ---------- Matrix 开通与上传(字段与《API-契约》§2.3/§2.5 对齐) ----------
  470. /** 对应云函数 RP5Ht55NeX 的返回 */
  471. export interface MatrixProvision {
  472. cached?: boolean;
  473. bindingId?: string;
  474. status?: string;
  475. userMxId: string | null;
  476. agentMxId: string | null;
  477. homeserver: string;
  478. accessToken: string;
  479. deviceId: string;
  480. roomId: string | null;
  481. userCreated?: boolean;
  482. }
  483. /**
  484. * 成果文件搬运任务(表 `FmodeArtifactJob`)。
  485. *
  486. * 由 **App(前端)** 创建、由 **容器** 领取执行:
  487. *
  488. * ```
  489. * App: 发现成果附件只有容器路径 → 签好 PUT 地址 → 建任务(pending)
  490. * 容器: 轮询 pending → 读 source → 按 headers 原样 PUT 到 uploadUrl → 回执 done
  491. * App: 下一次刷新时,成果的 artifacts[i].url 已变成云盘地址
  492. * ```
  493. *
  494. * 🔴 `uploadUrl` 是**限时(5 分钟)单对象写入授权**,不是长期凭据;
  495. * 过期后由 App 重新签发,容器不需要也不应该保存它。
  496. */
  497. export interface ArtifactJob {
  498. id: string;
  499. deliverableId: string;
  500. agentId: string;
  501. /** 容器内文件路径 */
  502. source: string;
  503. filename: string;
  504. mimeType: string;
  505. /** 🔴 以下三项由**云函数**签名后写入;客户端只读,不得自行构造 */
  506. uploadUrl: string;
  507. publicUrl: string;
  508. headers: Record<string, string>;
  509. /** epoch 秒(预签名有效期,默认 5 分钟) */
  510. expiresAt: number;
  511. status?: 'pending' | 'done' | 'failed';
  512. error?: string;
  513. }
  514. /** 对应云函数 AlP56LCKFm 的返回(取签名阶段) */
  515. export interface UploadTicket { uploadUrl: string;
  516. key: string;
  517. publicUrl: string;
  518. headers?: Record<string, string>;
  519. }
  520. // ---------- 整体配色(ADR-006 v2.0) ----------
  521. export type PaletteId = 'platinum' | 'ice' | 'clay' | 'slate';
  522. export interface PaletteOption {
  523. id: PaletteId;
  524. name: string;
  525. description: string;
  526. }
  527. // ---------- 数字生命 / 任务的业务文案与语义色映射(唯一实现) ----------
  528. export const AGENT_STATUS_TEXT: Record<AgentStatus, string> = {
  529. running: '正在执行',
  530. idle: '空闲',
  531. error: '异常',
  532. offline: '离线',
  533. stopped: '已停止',
  534. };
  535. /**
  536. * 任务状态中文文案(对应源库 tasks.status 七态)。
  537. * 依据《设计令牌.md》v3.0 §5.1 与 statuses 表。
  538. */
  539. export const TASK_STATUS_TEXT: Record<TaskStatus, string> = {
  540. triage: '待分诊',
  541. todo: '待办',
  542. scheduled: '已排期',
  543. ready: '就绪',
  544. running: '执行中',
  545. blocked: '已阻塞',
  546. done: '已完成',
  547. };
  548. /** 任务状态筛选顺序(UI 用) */
  549. export const TASK_STATUS_ORDER: TaskStatus[] = [
  550. 'triage',
  551. 'todo',
  552. 'scheduled',
  553. 'ready',
  554. 'running',
  555. 'blocked',
  556. 'done',
  557. ];
  558. /**
  559. * 语义色断言(《设计令牌.md》v3.0 §5.1)。
  560. * 🔴 阻塞 = danger(用户裁决 2026-10-14);运行中 = info(不用品牌色,避免与 accent 撞色)。
  561. */
  562. export type SemanticTone = 'success' | 'danger' | 'warning' | 'info' | 'neutral';
  563. export function agentStatusTone(status: AgentStatus): SemanticTone {
  564. switch (status) {
  565. case 'running':
  566. return 'success';
  567. case 'idle':
  568. return 'warning';
  569. case 'error':
  570. case 'offline':
  571. case 'stopped':
  572. return 'danger';
  573. default:
  574. return 'neutral';
  575. }
  576. }
  577. export function taskStatusTone(status: TaskStatus): SemanticTone {
  578. switch (status) {
  579. case 'running':
  580. return 'info';
  581. case 'blocked':
  582. return 'danger';
  583. case 'done':
  584. return 'success';
  585. default:
  586. return 'neutral';
  587. }
  588. }
  589. // ---------- 数字生命「在线可对话」判定(唯一实现) ----------
  590. /**
  591. * 数字生命的**可对话在线状态**。
  592. *
  593. * ## 🔴 为什么需要这一层(实测教训)
  594. *
  595. * 项目里存在**两套语义不同的状态**,此前被混用,导致同一容器在
  596. * 「通讯录」与「会话列表」显示不一致(用户实测反馈):
  597. *
  598. * | 维度 | 字段 | 含义 | 实测问题 |
  599. * |---|---|---|---|
  600. * | 运行态 | `Agent.status` | reporter 上报的容器运行状态 | **是历史值**:芯葶 `heartbeatAt` 停在 2026-09-23(近一月前)却仍为 `online` |
  601. * | 接入态 | `channels.matrixConfig.channelState` / `listenerOk` | 容器 Matrix 通道是否在跑 | 决定**能否真的对话** |
  602. *
  603. * 用户能感知的是「**能不能对话**」→ 属**接入态**。
  604. * 故本判定**以接入态为准**,运行态只作辅助(在工作台/详情展示)。
  605. *
  606. * ## 口径
  607. *
  608. * ```
  609. * online 接入态 connected / listenerOk 为真 → 可真对话
  610. * unreported 有绑定但通道状态未上报 → 能连 Matrix,对方可能不在
  611. * offline 无绑定(matrixReady 为假) → 不可对话
  612. * ```
  613. */
  614. export type AgentPresence = 'online' | 'unreported' | 'offline';
  615. /** 判定可对话状态(**全项目唯一实现**,禁止各页另算) */
  616. export function agentPresence(a: Agent | undefined | null): AgentPresence {
  617. if (!a || !a.matrixReady) return 'offline';
  618. const cfg = a.channels?.matrixConfig;
  619. const state = cfg?.channelState ?? cfg?.channelDetail?.matrixState;
  620. if (state === 'connected' || cfg?.listenerOk) return 'online';
  621. // 有绑定但容器未上报通道状态
  622. return 'unreported';
  623. }
  624. /** 可对话状态 → 中文文案(UI 展示,配颜色使用) */
  625. export const AGENT_PRESENCE_TEXT: Record<AgentPresence, string> = {
  626. online: '在线',
  627. unreported: '未上报',
  628. offline: '未接入',
  629. };
  630. /** 可对话状态 → 语义色(复用单轨映射) */
  631. export function agentPresenceTone(p: AgentPresence): SemanticTone {
  632. switch (p) {
  633. case 'online':
  634. return 'success';
  635. case 'unreported':
  636. return 'warning';
  637. default:
  638. return 'neutral';
  639. }
  640. }
  641. /**
  642. * 云端工作区记录(表 `FmodeWorkspace`,云函数 `fmodeagent-chatthreads` 的 wsList)。
  643. *
  644. * 2026-11-01 上云:工作区此前只存 localStorage,换设备后文件夹全丢,
  645. * 而会话的 workspaceId 还在 —— 归属无处安放,只能落进「孤儿」区。
  646. */
  647. export interface WorkspaceRecord {
  648. /** 客户端生成的工作区 id(`ws_<ts>_<rand>`),跨设备稳定 */
  649. id: string;
  650. name: string;
  651. /** 毫秒时间戳;用于稳定排序 */
  652. createdAt: number;
  653. }
  654. /** 工作区 upsert 载荷 */
  655. export interface WorkspaceUpsert {
  656. id: string;
  657. name: string;
  658. createdAt: number;
  659. }
  660. /**
  661. * 云端会话目录记录(表 `FmodeChatThread`,云函数 `fmodeagent-chatthreads`)。
  662. *
  663. * 2026-10-31 引入:会话索引此前只存在本地 localStorage,换浏览器/清缓存即丢失。
  664. * 云端记录是**唯一真相源**,本地只作缓存。
  665. *
  666. * 🔴 归属由服务端按 sessionToken 强制确定,客户端不传也不信任任何 user 字段。
  667. */
  668. export interface ChatThreadRecord {
  669. /** 云端记录 id */
  670. objectId: string;
  671. agentId: string;
  672. roomId: string;
  673. /** 线程根 event_id(App 内部称 sessionId) */
  674. threadRoot: string;
  675. title: string;
  676. /** 用户手动改过标题 → upsert 不覆盖 */
  677. titleLocked: boolean;
  678. messageCount: number;
  679. /** 毫秒时间戳 */
  680. lastMessageAt: number;
  681. archived: boolean;
  682. lastPreview: string;
  683. /** 归属工作区 id(`''` / 缺省 = 日常对话);换设备后仍能还原层级 */
  684. workspaceId?: string; /** 会话形态(2026-11-05):缺省 = dm */
  685. kind?: 'dm' | 'group';
  686. /** 群会话成员容器 id 列表 */
  687. memberIds?: string[];
  688. /**
  689. * 本会话关联的成果链接(云盘可访问地址)。
  690. *
  691. * 🔴 2026-11-09 用户裁决:成果按会话存放 —— 每条会话在库里有一条记录,
  692. * 记录里放该会话成果的在线链接数组,查询时读出来即可。
  693. * 这样不依赖容器上报 threadRoot,「这条对话的成果」由数据本身决定。
  694. */
  695. deliverableUrls?: string[];
  696. }
  697. /** upsert 提交项(不含服务端生成字段) */
  698. export interface ChatThreadUpsert {
  699. agentId: string;
  700. roomId: string;
  701. threadRoot: string;
  702. title?: string;
  703. messageCount?: number;
  704. /** 毫秒时间戳 */
  705. lastMessageAt?: number;
  706. lastPreview?: string;
  707. /**
  708. * 归属的工作区 id(`null` / 缺省 = 日常对话)。
  709. *
  710. * 🔴 必须上云(2026-11-01):此前只存本地 localStorage,导致两个问题:
  711. * ① 每次云端重建列表时归属靠本地缓存「捡」,捡不到就掉回日常对话;
  712. * ② 换浏览器/清缓存后归属永久丢失,用户以为「工作区里的对话没了」。
  713. */
  714. workspaceId?: string | null;
  715. /** 会话形态(2026-11-05):缺省 = dm,兼容存量 */
  716. kind?: 'dm' | 'group';
  717. /** 群会话成员容器 id 列表;单聊为空 */
  718. memberIds?: string[];
  719. /**
  720. * 本会话关联的成果链接(云盘可访问地址)。
  721. *
  722. * 🔴 2026-11-09 用户裁决:成果按会话存放 —— 每条会话在库里有一条记录,
  723. * 记录里放该会话成果的在线链接数组,查询时读出来即可。
  724. * 这样不依赖容器上报 threadRoot,「这条对话的成果」由数据本身决定。
  725. */
  726. deliverableUrls?: string[];
  727. /** 重新出现时撤销软删 */
  728. revive?: boolean;
  729. }