/* * 收件箱的会话折叠 / 联系人页的卡片视图 / 往返预算 —— **纯逻辑,无 UI 依赖**。 * * 为什么单独成文件、而不是直接写在页面里: * * 这几条规则是**判据的对象**。写在 `build()` 里的话,判据只能断言"源码里出现了 * 某个字符串"(看起来绿,实际什么都没验);放在这里,判据可以跑**同一份代码** * —— `client/electron/test/harmony-logic.test.mjs` 用 node 的 * `--experimental-strip-types` 直接执行本文件,断言的是**行为**: * 折叠后组头取的是不是最新一封、单封是不是不成组、预算剩 1 个来回是什么档。 * * 这正是移交信里交代的纪律:「判据必须点用户真正会点的那一层」—— * 页面里那一层要点设备才能验,这一层是它的**可执行内核**, * 页面再用源码判据钉住"确实调了这里"。 * * 与 WebUI 的 `src/lib/mailGroups.ts`(折叠 / 单封不成组)、 * `src/components/WorkCard.tsx` 的 `BudgetChip`(预算档位)、 * `ContactPanel.tsx`(列表 / 卡片切换)一一对应。 * * ⚠️ 本文件必须保持**类型可擦除**:不用 `enum`、`namespace`、构造器参数属性, * 否则 node 的 strip-types 跑不起来,判据就断了(用 `class` + 联合类型代替 enum)。 */ /** 折叠只用到这几个字段(与 `Models.ets` 的 `MailSummary` 对齐,由它 implements) */ export interface MailLike { mail_id: string; session_id: string; session_alias: string; from_name: string; /** 收件人显示名 —— 发件箱那一栏行上显示的是它(收件箱显示 from_name) */ to_name: string; subject: string; body_preview: string; created_at: string; status: string; /** * 邮件类型:`permission_request` 是**待办**(等人点头),其余是要读的内容。 * 与 WebUI 的 `Mail.mail_type` 同名同义 —— 收件箱与授权两栏就按它分家。 */ mail_type: string; /** 决策结果(空串 = 还没人处理过)。后端用 COALESCE 归一成空串,所以空串与 null 同义。 */ permission_result: string; permission_mode: string; source_account_id: string; source_account_name: string; } /** 一个会话折叠成的一组(对应 WebUI 的 `MailGroup`) */ export class SessionGroup { /** 分组键:账号 + session_id */ key: string = ''; session_id: string = ''; /** 组头别名(取组内最新一封的会话别名,空串表示未命名) */ alias: string = ''; /** 组头主题(取最新一封) */ subject: string = ''; /** 组内最新一封 */ latest: MailLike | undefined = undefined; /** 组内全部邮件,时间倒序 */ mails: MailLike[] = []; unreadCount: number = 0; } /** ISO 时间 → 毫秒;解析失败给 0 而不是 NaN(NaN 参与比较恒为 false,会让排序依赖入参顺序) */ export function timeOf(iso: string): number { const t: number = Date.parse(iso); return Number.isNaN(t) ? 0 : t; } /** 时间倒序;同一时刻用 mail_id 倒序兜底(与后端 `ORDER BY created_at DESC, mail_id DESC` 一致) */ export function byNewest(a: MailLike, b: MailLike): number { const d: number = timeOf(b.created_at) - timeOf(a.created_at); if (d !== 0) { return d; } if (b.mail_id > a.mail_id) { return 1; } if (b.mail_id < a.mail_id) { return -1; } return 0; } /** * 分组键。 * * 鸿蒙的收件箱是**多账号合并**的(一个页面里混着多个 Gateway 的信), * 所以键要带账号前缀:同一个 `session_id` 出现在两个账号里是两件事。 * WebUI 是单账号,只按 `session_id` 桶化(`bucketBySession`)。 * `session_id` 缺失时用 `mail:` 兜底单独成组 —— 一条脏数据不该让整栏空白。 */ export function sessionKey(m: MailLike): string { const sid: string = m.session_id.length > 0 ? m.session_id : 'mail:' + m.mail_id; return m.source_account_id + '/' + sid; } /** 按会话折叠;组头取组内**最新一封**,组间按最新一封时间倒序 */ export function groupMailsBySession(mails: MailLike[]): SessionGroup[] { const groups: SessionGroup[] = []; const index: Map = new Map(); for (let i = 0; i < mails.length; i++) { const m: MailLike = mails[i]; const key: string = sessionKey(m); let g: SessionGroup | undefined = undefined; const at: number | undefined = index.get(key); if (at !== undefined) { g = groups[at]; } if (g === undefined) { g = new SessionGroup(); g.key = key; g.session_id = m.session_id; index.set(key, groups.length); groups.push(g); } g.mails.push(m); } for (let i = 0; i < groups.length; i++) { const g: SessionGroup = groups[i]; g.mails.sort(byNewest); const latest: MailLike = g.mails[0]; g.latest = latest; g.alias = latest.session_alias; g.subject = latest.subject; let unread: number = 0; for (let j = 0; j < g.mails.length; j++) { if (g.mails[j].status === 'unread') { unread++; } } g.unreadCount = unread; } groups.sort((a: SessionGroup, b: SessionGroup): number => { const la: MailLike | undefined = a.latest; const lb: MailLike | undefined = b.latest; if (la === undefined || lb === undefined) { return 0; } return byNewest(la, lb); }); return groups; } /** * 单封邮件的组不算「组」,平铺显示即可。 * * 给一封孤立的邮件套上可折叠的组头 = 多一次点击才能读到内容, * 而收件箱里大多数人类来信就是孤立的一封(WebUI 侧同一结论,见 `isFlatGroup`)。 */ export function isFlatGroup(g: SessionGroup): boolean { return g.mails.length === 1; } /** 未读数:把服务端返回的 `total` 相加(它是 `CountUnread`,权威;不要数这一页) */ export function sumUnreadTotals(totals: number[]): number { let n: number = 0; for (let i = 0; i < totals.length; i++) { const t: number = totals[i]; if (t > 0) { n += t; } } return n; } /** * 这一页**可能不全**时的提示。 * * `/me/mail/inbox` 有 `limit`(客户端取 50),而它返回的 `total` 是**未读数** * (服务端 `CountUnread`),**不是总封数** —— 所以客户端手上根本没有一个可信的 * "一共有多少封"。那就既不能把 50 封说成全部,也不能拿未读数冒充总数 * (鸿蒙界面原先写的「共 N 封」就是这么来的,显示的其实是未读数)。 * 只有"取满了这一页"时才有话可说,此时如实说"可能还有更多"。 */ export function partialLoadNotice(fetched: number, limit: number): string { if (limit <= 0 || fetched < limit) { return ''; } return '已加载 ' + fetched + ' 封(本页上限 ' + limit + ',可能还有更多)'; } /** * 往返预算的档位 —— 与 WebUI 的 `BudgetChip` 同一判据: * `max <= 0` = 不限(不显示徽标,一个"0/0"对每张卡片都成立,等于噪声); * 剩 0 个来回 = 用尽;剩 ≤1 = 将尽(快跑满的任务需要人介入)。 * 返回值用字符串而不是 enum:本文件要保持类型可擦除(见文件头)。 */ export function budgetState(max: number, used: number): string { if (max <= 0) { return 'none'; } if (budgetRemaining(max, used) === 0) { return 'spent'; } if (budgetRemaining(max, used) <= 1) { return 'warn'; } return 'ok'; } /** 剩余往返次数(不小于 0) */ export function budgetRemaining(max: number, used: number): number { const left: number = max - used; return left > 0 ? left : 0; } /** 预算徽标文字:`剩余/上限`;不限时是空串(页面据此不渲染) */ export function budgetLabel(max: number, used: number): string { if (max <= 0) { return ''; } return budgetRemaining(max, used) + '/' + max; } /** 联系人页的视图切换(WebUI:`setView(view === 'list' ? 'card' : 'list')`) */ export function nextContactView(current: string): string { return current === 'list' ? 'card' : 'list'; } /** 视图标题:卡片视图叫「工作列表」,列表视图叫「联系人」(与 WebUI 同词) */ export function contactViewTitle(view: string): string { return view === 'card' ? '工作列表' : '联系人'; } /** * 卡片上那条最新摘要**是谁发的**:人还是 Agent。 * * WebUI 的判据是 `c.last_from !== c.agent_name`(人发的用头像图标、Agent 发的用机器人图标)。 * 这个信息决定人要不要接手,所以它得是逻辑而不是"看图标"。 */ export function lastFromIsHuman(agentName: string, lastFrom: string): boolean { return lastFrom !== agentName; } /* * ─────────────── 权限档位与"强制力"(对应 WebUI 的 PermissionChip) ─────────────── * * 为什么强制力也得上界面(WebUI `PermissionChip.tsx` 的注释原话): * 「只显示档位会让人以为 plan 档管住了 homeagent,而 homeagent 没有工具拦截点、 * 档位只是提示词建议(advisory)。差异可见才符合 I-5(失败必须当场可见)」 * * 鸿蒙侧在触屏上更得说清:WebUI 把说明放在 `title`(悬停提示),手指没有悬停 —— * 所以这里除了照搬 WebUI 的标记形状(实心 / 靶心 / 空心),说明文字走"点一下弹出来", * 并且**文案与 WebUI 逐字一致**:判据从 WebUI 源码里抽出字符串直接比对,任一边改口径就红。 * (port 成两份文案是没办法的事:鸿蒙跑不了 TSX;可执行的比对是它的替代品。) */ /** 档位 → 中文短标签(与 WebUI `PermissionChip.tsx` 的 `MODE_LABEL` 逐字一致) */ export function permissionLabel(mode: string): string { if (mode === 'plan') { return '只读'; } if (mode === 'workspace') { return '目录内'; } if (mode === 'full') { return '全权'; } return ''; // 空档位(人→人的信、旧会话)不显示徽标 } /** * 归一化强制力:认不出的值按 advisory。 * * 保守方向与 WebUI 的 `normalizeEnforcement`、后端 `Normalize` 同语义 —— * 认不出就当"平台不强制",绝不当成"平台拦得住"。 */ export function enforcementKey(e: string): string { if (e === 'native' || e === 'partial' || e === 'advisory') { return e; } return 'advisory'; } /** 强制力 → 标记形状:native 实心 / partial 靶心 / advisory 空心(与 WebUI 的三种点同构) */ export function enforcementGlyph(e: string): string { const k: string = enforcementKey(e); if (k === 'native') { return '●'; } if (k === 'partial') { return '◉'; } return '○'; } /** 强制力 → 中文短标签(与 WebUI 的 `ENFORCEMENT_LABEL` 逐字一致) */ export function enforcementLabel(e: string): string { const k: string = enforcementKey(e); if (k === 'native') { return '平台强制'; } if (k === 'partial') { return '平台强制(覆盖不完整)'; } return '仅提示'; } /** * 档位 + 强制力 → 一句人话(点徽标弹出来)。 * * **文案与 WebUI `permissionModeHint` 逐字一致**:两个客户端对同一个任务给出的 * "平台实际做到了什么"必须是同一句话,否则人在两边看到两种保证。 */ export function permissionHint(mode: string, enforcement: string): string { const e: string = enforcementKey(enforcement); if (mode === 'plan') { if (e === 'native') { return 'plan 档:只读。写/改/执行会被平台强制拦下,本档只用来查与想。'; } if (e === 'partial') { return 'plan 档:只读。平台会拦截写/改/执行,但覆盖不完整(沙箱能力受限,有已知缺口)——不要把「会被拦下」当保证,请把结论写在回信里。'; } return 'plan 档:只读(advisory,平台不强制)。请把结论写在回信里。'; } if (mode === 'full') { return 'full 档:全权。工具调用不需额外授权。'; } if (e === 'native') { return 'workspace 档:目录内可动,越界需经授权。'; } if (e === 'partial') { return 'workspace 档:目录内可动,越界会请求授权 —— 但沙箱覆盖不完整(有已知缺口),请主动把改动限制在工作目录内。'; } return 'workspace 档(advisory,平台不强制)。请把改动限制在工作目录内。'; } /** 徽标文字:标签 + 强制力标记(空档位返回空串,页面据此不渲染) */ export function permissionChipText(mode: string, enforcement: string): string { const label: string = permissionLabel(mode); if (label.length === 0) { return ''; } return label + ' ' + enforcementGlyph(enforcement); } /* * ───────── 权限请求从普通邮件里分出来(对应 WebUI `mailGroups.ts` 的同名三个函数) ───────── * * 权限请求不是「一封信」而是「一件待办」:它的生命周期是「等人点头 → 决策完就作废」。 * 混在收件箱里两者互相伤害:一次 Agent 任务能连着产生十几封权限请求, * 把真正需要阅读的来信挤到看不见的地方(WebUI 侧实测过:17 封权限邮件挤掉另外两个会话)。 * 所以收件箱只放要读的,授权栏只放要批的。 */ /** 权限邮件且尚无决策结果。空串与 null 都算未决策。 */ export function isPendingPermission(m: MailLike): boolean { return m.mail_type === 'permission_request' && !m.permission_result; } /** 待决策的权限请求数 —— 授权栏徽标的数字,也是「有人被卡住、要人动手」的唯一信号。 */ export function countPendingPermissions(mails: MailLike[]): number { let n: number = 0; for (let i = 0; i < mails.length; i++) { if (isPendingPermission(mails[i])) { n += 1; } } return n; } /** 分家结果(用 class 而不是匿名对象字面量:ArkTS 要求对象字面量处处有类型) */ export class MailSplit { normal: MailLike[] = []; permissions: MailLike[] = []; } export function splitByPermission(mails: MailLike[]): MailSplit { const out: MailSplit = new MailSplit(); for (let i = 0; i < mails.length; i++) { const m: MailLike = mails[i]; if (m.mail_type === 'permission_request') { out.permissions.push(m); } else { out.normal.push(m); } } return out; }