chore: directory migration - gateway→server, web→client/electron

This commit is contained in:
2026-09-08 19:16:35 +08:00
parent fd9f99a3f9
commit f9d757b5e5
243 changed files with 5095 additions and 228 deletions

View File

@ -0,0 +1,208 @@
import type { CalendarEvent } from '../types';
/**
* 日历的日期计算与事件分桶。
*
* 全部是纯函数,跟 React 无关,因此能单独测。月视图的格子、事件落到哪一天、
* 提醒的实际触发时刻 —— 这些算错了没有任何报错,只是提醒发在错误的时间。
*
* 时区一律用**本地时区**:日历是给人看的,人说「9 月 3 日」指的是自己那天。
* 与后端交互时才转 ISO(`toISOString()` 给 UTC,后端存 UTC)。
*/
/** 一天的开始(本地时区 00:00:00.000)。 */
export function startOfDay(d: Date): Date {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
}
/** 一天的结束(本地时区 23:59:59.999)。 */
export function endOfDay(d: Date): Date {
const x = new Date(d);
x.setHours(23, 59, 59, 999);
return x;
}
/** 月首(本地时区)。 */
export function startOfMonth(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), 1);
}
/** 月末最后一刻。 */
export function endOfMonth(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth() + 1, 0, 23, 59, 59, 999);
}
/**
* 周首。
*
* 中文语境下一周从**周一**开始,而 `getDay()` 把周日算作 0 ——
* 直接减 `getDay()` 会让周日被归到上一周的末尾,月视图第一行就错位。
*/
export function startOfWeek(d: Date): Date {
const x = startOfDay(d);
const dow = x.getDay(); // 0=周日
const diff = dow === 0 ? 6 : dow - 1;
x.setDate(x.getDate() - diff);
return x;
}
export function endOfWeek(d: Date): Date {
const x = startOfWeek(d);
x.setDate(x.getDate() + 6);
return endOfDay(x);
}
/** 同一天?(本地时区,只比年月日) */
export function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
/** 加天数,返回新对象(不修改入参)。 */
export function addDays(d: Date, n: number): Date {
const x = new Date(d);
x.setDate(x.getDate() + n);
return x;
}
export function addMonths(d: Date, n: number): Date {
// 先归到 1 号再加月:从 1 月 31 日加一个月,setMonth 会溢出到 3 月 2/3 日
return new Date(d.getFullYear(), d.getMonth() + n, 1);
}
/**
* 月视图的 42 个格子(6 行 × 7 列)。
*
* 固定 6 行而不是按需 4~6 行:行数变化会让整个网格高度跳动,
* 翻月时页面内容上下弹。多出来的格子显示邻月日期并置灰。
*/
export function monthGrid(anchor: Date): Date[] {
const first = startOfWeek(startOfMonth(anchor));
const cells: Date[] = [];
for (let i = 0; i < 42; i++) cells.push(addDays(first, i));
return cells;
}
/** 周视图的 7 天。 */
export function weekDays(anchor: Date): Date[] {
const first = startOfWeek(anchor);
return Array.from({ length: 7 }, (_, i) => addDays(first, i));
}
/**
* 事件的实际提醒时刻 = 事件时间 − remind_before 分钟。
*
* 这是调度器真正比较的那个时间点。UI 上要显示它,否则人设了「提前 30 分钟」
* 却在事件时间那一刻才反应过来 —— 提醒早就发出去了。
*/
export function remindAt(e: CalendarEvent): Date {
const t = new Date(e.event_time).getTime();
return new Date(t - (e.remind_before || 0) * 60_000);
}
/** 事件时间解析失败时给 epoch 0 而不是 NaN(NaN 参与排序会让顺序不确定)。 */
function eventTime(e: CalendarEvent): number {
const t = new Date(e.event_time).getTime();
return Number.isNaN(t) ? 0 : t;
}
/**
* 把事件按天分桶,键是 `YYYY-MM-DD`(本地时区)。
*
* 用本地日期串而不是 ISO 前缀切片:`toISOString().slice(0,10)` 给的是 UTC 日期,
* 东八区晚上 8 点之后的事件会被归到**第二天**的格子里。
*/
export function bucketByDay(events: CalendarEvent[]): Map<string, CalendarEvent[]> {
const map = new Map<string, CalendarEvent[]>();
for (const e of events) {
const d = new Date(e.event_time);
if (Number.isNaN(d.getTime())) continue; // 脏数据不该让整个视图空白
const key = dayKey(d);
const bucket = map.get(key);
if (bucket) bucket.push(e);
else map.set(key, [e]);
}
// 同一天内按时间正序:日历格子里人从上往下读就是时间顺序
for (const list of map.values()) {
list.sort((a, b) => eventTime(a) - eventTime(b) || a.event_id.localeCompare(b.event_id));
}
return map;
}
/** 本地时区的 `YYYY-MM-DD`。 */
export function dayKey(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${dd}`;
}
/**
* 渲染提醒正文:把 `{title}` `{time}` `{description}` 替换成实际值。
*
* 必须与后端 `scheduler.RenderReminder` 的行为一致 —— 前端预览显示的
* 与实际发出的不是一个东西,那比不预览更糟。变量名两处都是硬编码的字面量,
* 改动时要同步。
*/
export function renderReminder(tpl: string, e: { title: string; description?: string; event_time: string }): string {
const t = new Date(e.event_time);
const time = Number.isNaN(t.getTime())
? e.event_time
: `${t.getFullYear()}-${String(t.getMonth() + 1).padStart(2, '0')}-${String(
t.getDate()
).padStart(2, '0')} ${String(t.getHours()).padStart(2, '0')}:${String(
t.getMinutes()
).padStart(2, '0')}`;
// 用 split/join 而不是 replaceAll:tsconfig 的 target 是 ES2020,
// replaceAll 在那个 lib 里不存在(TS2550)。也不能用 replace ——
// 它只换第一个,同一变量写两次时第二个会原样漏到邮件里。
const sub = (s: string, from: string, to: string) => s.split(from).join(to);
return sub(sub(sub(tpl, '{title}', e.title || ''), '{time}', time), '{description}', e.description || '');
}
/** `<input type="datetime-local">` 要的格式:本地时区的 `YYYY-MM-DDTHH:mm`。 */
export function toLocalInput(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(
d.getHours()
)}:${pad(d.getMinutes())}`;
}
/**
* `datetime-local` 的值转 ISO(给后端)。
*
* `new Date('2026-09-03T14:30')` 按**本地时区**解析(无 Z 后缀),
* 这正是想要的:人在输入框里写的就是本地时间。
*/
export function fromLocalInput(v: string): string {
const d = new Date(v);
return Number.isNaN(d.getTime()) ? '' : d.toISOString();
}
/** 人类可读的重复规则。 */
export function describeRecurrence(e: CalendarEvent): string {
const map: Record<string, string> = {
none: '不重复',
daily: '每天',
weekly: '每周',
monthly: '每月'
};
const base = map[e.recurrence] || '不重复';
if (e.recurrence === 'none' || !e.recurrence_end) return base;
const end = new Date(e.recurrence_end);
if (Number.isNaN(end.getTime())) return base;
return `${base},至 ${dayKey(end)}`;
}
/** 提前提醒的人类描述。 */
export function describeRemindBefore(min: number): string {
if (!min) return '到点提醒';
if (min < 60) return `提前 ${min} 分钟`;
if (min % 60 === 0) return `提前 ${min / 60} 小时`;
return `提前 ${Math.floor(min / 60)} 小时 ${min % 60} 分钟`;
}

View File

@ -0,0 +1,327 @@
import { Solar, Lunar, LunarYear } from 'lunar-javascript';
/**
* 农历换算与「按农历推进」的重复规则计算。
*
* 这一层是后端 `internal/lunar/lunar.go` 的镜像 —— 两边用的是同一作者
* (6tail)的库(lunar-javascript / lunar-go),换算结果一致。
* 前端需要它是因为**日历格子上要显示农历日**,而且事件编辑器要在保存前
* 预览「这条规则接下来几次落在哪天」。让后端算再拉一次网络请求太慢,
* 而完全不显示农历会让人无法确认规则没被理解错。
*
* 与后端保持同步的两条硬约定:
*
* 1. **闰月用负数月份表示**(-6 = 闰六月)。
* 2. **非法日期必须夹取而不是抛错**。库在 `Lunar.fromYmd(2027, 9, 30)`
* 上直接 throw(农历月是 29 或 30 天不定),「每月农历三十」这条规则
* 必然撞上 29 天的月份。
*/
/** 一个农历日期。month 为负数表示闰月。 */
export interface LunarDate {
year: number;
month: number;
day: number;
}
/** 公历 Date → 农历日期(只取年月日)。 */
export function fromSolar(d: Date): LunarDate {
const l = Solar.fromYmd(d.getFullYear(), d.getMonth() + 1, d.getDate()).getLunar();
return { year: l.getYear(), month: l.getMonth(), day: l.getDay() };
}
/** 某农历年的闰月(0 = 无闰月)。 */
export function leapMonth(year: number): number {
try {
return LunarYear.fromYear(year).getLeapMonth();
} catch {
return 0;
}
}
/**
* 某个农历月有多少天(29 或 30)。
*
* 返回 0 表示该月不存在(例如问一个没有闰六月的年份要闰六月)——
* 这不是异常:「每年农历闰六月十五」在无闰六月的年份本来就无法落地,
* 调用方需要据此跳过而不是猜一个日子。
*/
export function daysInMonth(year: number, month: number): number {
try {
const ly = LunarYear.fromYear(year);
const months = ly.getMonths();
for (const lm of months) {
if (lm.getYear() === year && lm.getMonth() === month) {
return lm.getDayCount();
}
}
} catch {
return 0;
}
return 0;
}
/**
* 农历日期 → 公历 Date,带上给定的时分秒。
*
* 日期会被**夹到该农历月的实际天数内**:请求农历三十而该月只有 29 天时
* 返回廿九,而不是抛错也不是滚到下个月初一。
*
* 夹而不滚:「每月农历三十」的语义是「月末那天」,滚到下月初一会让提醒
* 出现在完全错误的日子(且与前一次只隔一天)。
*
* 返回 null 表示该农历月根本不存在(无效的闰月)。
*/
export function toSolar(
d: LunarDate,
hour = 0,
minute = 0,
second = 0
): { date: Date; clamped: boolean } | null {
const days = daysInMonth(d.year, d.month);
if (days === 0) return null;
let day = d.day;
let clamped = false;
if (day > days) {
day = days;
clamped = true;
}
if (day < 1) return null;
try {
const s = Lunar.fromYmd(d.year, d.month, day).getSolar();
return {
date: new Date(s.getYear(), s.getMonth() - 1, s.getDay(), hour, minute, second, 0),
clamped
};
} catch {
return null;
}
}
/**
* 在农历上推进若干个月。
*
* 逐月走而不是「月份数 + n 取模」:中间可能夹着闰月,而闰月是否存在
* 取决于年份,没有闭式公式。
*
* **推进时跳过闰月**:从六月推一个月得七月,不是闰六月。「每月十五」
* 这类规则的用户期望是一年 12 次,把闰月算进去会让闰年多出一次提醒 ——
* 那是农历年的性质,不是提醒的性质。
*/
export function addLunarMonths(d: LunarDate, n: number): LunarDate {
let { year, month } = d;
// 从闰月出发时先归到对应的正月份:闰六月 +1 → 七月
if (month < 0) month = -month;
for (let i = 0; i < n; i++) {
month++;
if (month > 12) {
month = 1;
year++;
}
}
return { year, month, day: d.day };
}
/**
* 在农历上推进若干年,月份与日期保持不变。
*
* 从闰月出发而目标年没有同一个闰月时,退回对应的正月份 ——
* 「去年闰六月十五」在今年最接近的对应日就是六月十五。
* 直接放弃(不再提醒)更糟:那是静默地让重复事件消失。
*/
export function addLunarYears(d: LunarDate, n: number): LunarDate {
const year = d.year + n;
let month = d.month;
if (month < 0 && leapMonth(year) !== -month) {
month = -month;
}
return { year, month, day: d.day };
}
/** 「二〇二六年七月廿二」这样的完整中文农历表示。 */
export function formatLunarFull(d: LunarDate): string {
const days = daysInMonth(d.year, d.month);
if (days === 0) return `农历 ${d.year}-${d.month}-${d.day}(无效)`;
const day = Math.min(d.day, days);
try {
return Lunar.fromYmd(d.year, d.month, day).toString();
} catch {
return `农历 ${d.year}-${d.month}-${d.day}`;
}
}
/**
* 只要月日的简短农历,如「七月廿二」。
*
* 日历格子里用这个:年份已经在页头写了,每格重复一遍挤不下也没意义。
*/
export function formatLunarShort(d: LunarDate): string {
const full = formatLunarFull(d);
// 「二〇二六年」= 4 个数字字 + 「年」
const chars = Array.from(full);
if (chars.length > 5 && chars[4] === '年') {
return chars.slice(5).join('');
}
return full;
}
/**
* 农历日名,如 1 → 初一、20 → 二十、22 → 廿二、30 → 三十。
*
* 用查表而不是从 `Lunar.toString()` 里正则截取:实测日名有五种前缀形态
* (初一/十一/二十/廿一/三十),写一个覆盖全部的正则既难读又容易漏 ——
* 之前那版就漏了「二十」,20 号会退化成显示整串「七月二十」。
*/
const LUNAR_DAY_NAMES = [
'', '初一', '初二', '初三', '初四', '初五', '初六', '初七', '初八', '初九', '初十',
'十一', '十二', '十三', '十四', '十五', '十六', '十七', '十八', '十九', '二十',
'廿一', '廿二', '廿三', '廿四', '廿五', '廿六', '廿七', '廿八', '廿九', '三十'
];
export function lunarDayName(day: number): string {
return LUNAR_DAY_NAMES[day] ?? String(day);
}
/**
* 日历格子里显示的农历标记:初一显示月名,其余显示日名。
*
* 每格都写完整「七月廿二」会让格子里全是重复的月份字样,而格子只有
* 几十像素宽。月初那天写月名(如「七月」)就够定位了 —— 纸质日历的惯例。
*/
export function cellLunarLabel(date: Date): string {
const d = fromSolar(date);
if (d.day === 1) {
// 初一:写月名。闰月要带「闰」字,否则闰六月与六月在格子里长得一样
const leap = d.month < 0 ? '闰' : '';
return `${leap}${LUNAR_MONTH_NAMES[Math.abs(d.month)] ?? Math.abs(d.month)}月`;
}
return lunarDayName(d.day);
}
/** 农历月名。十一/十二月习惯写「冬月」「腊月」,与 lunar-javascript 一致。 */
const LUNAR_MONTH_NAMES = [
'', '正', '二', '三', '四', '五', '六', '七', '八', '九', '十', '冬', '腊'
];
/** 公历 Date → 「2026-09-03(农历七月廿二)」。给提醒预览与详情用。 */
export function formatSolarWithLunar(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
const ymd = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
return `${ymd}(农历${formatLunarShort(fromSolar(d))})`;
}
/** 是否是按农历推进的规则。 */
export function isLunarRecurrence(r: string): boolean {
return r === 'lunar_monthly' || r === 'lunar_yearly';
}
/**
* 算出下一次触发时刻。必须与后端 `repo.NextOccurrence` 行为一致 ——
* 前端用它预览「接下来几次在哪天」,与实际触发不符比不预览更糟。
*
* 返回 null 表示不重复或算不出来。
*/
export function nextOccurrence(recurrence: string, from: Date): Date | null {
switch (recurrence) {
case 'daily':
return shiftDays(from, 1);
case 'weekly':
return shiftDays(from, 7);
case 'monthly':
return addSolarMonthsClamped(from, 1);
case 'yearly':
return addSolarMonthsClamped(from, 12);
case 'lunar_monthly': {
const r = toSolar(
addLunarMonths(fromSolar(from), 1),
from.getHours(),
from.getMinutes(),
from.getSeconds()
);
return r ? r.date : null;
}
case 'lunar_yearly': {
const r = toSolar(
addLunarYears(fromSolar(from), 1),
from.getHours(),
from.getMinutes(),
from.getSeconds()
);
return r ? r.date : null;
}
default:
return null;
}
}
function shiftDays(d: Date, n: number): Date {
const x = new Date(d);
x.setDate(x.getDate() + n);
return x;
}
/**
* 公历加月份,日期夹到目标月的实际天数内。
*
* `setMonth` 的溢出行为(3 月 31 日 +1 月 = 5 月 1 日)对「每月同一日」
* 是错的:31 日的事件在 2 月会变成 3 月 3 日,然后从此每月 3 日提醒 ——
* 一次溢出永久改变了规则。
*/
export function addSolarMonthsClamped(d: Date, n: number): Date {
const y = d.getFullYear();
const m = d.getMonth() + n;
// 目标月第 0 天 = 上个月最后一天,用它拿月长
const last = new Date(y, m + 1, 0).getDate();
const day = Math.min(d.getDate(), last);
return new Date(y, m, day, d.getHours(), d.getMinutes(), d.getSeconds(), 0);
}
/**
* 预览接下来 n 次触发。事件编辑器里用它让人确认规则没被理解错 ——
* 农历规则的公历日期每次都在变,光看规则名分辨不出对不对。
*/
export function upcomingOccurrences(recurrence: string, from: Date, n = 3): Date[] {
const out: Date[] = [];
let cur = from;
for (let i = 0; i < n; i++) {
const next = nextOccurrence(recurrence, cur);
// 算不出来(例如目标年没有那个闰月)就停在这里,
// 而不是跳过继续试 —— 后端的 AdvanceRecurrence 也会在这一步放弃。
if (!next || next <= cur) break;
out.push(next);
cur = next;
}
return out;
}
/** 重复规则的人类可读标签。含农历时把农历日子写出来。 */
export function describeRecurrenceRule(recurrence: string, eventTime?: Date): string {
switch (recurrence) {
case 'none':
return '不重复';
case 'daily':
return '每天';
case 'weekly':
return '每周';
case 'monthly':
return '每月';
case 'yearly':
return '每年';
case 'lunar_monthly':
return eventTime
? `每农历月${dayNameOf(eventTime)}`
: '每农历月同一日';
case 'lunar_yearly':
return eventTime
? `每年农历${formatLunarShort(fromSolar(eventTime))}`
: '每农历年同月同日';
default:
return '不重复';
}
}
/** 取「廿二」这样的农历日名。 */
function dayNameOf(d: Date): string {
return lunarDayName(fromSolar(d).day);
}

View File

@ -0,0 +1,178 @@
import type { Mail } from '../types';
/**
* 邮件列表的两种分组:收件箱按会话折叠,授权请求单独成项。
*
* 为什么需要它:`/me/mail/inbox` 返回的是平铺的邮件流(`ORDER BY created_at DESC`),
* 而一次 Agent 任务会在同一会话里产生几十封邮件 —— 权限询问尤其密集,
* 每个被拦下的 bash/write 都是一封。实测生产库里一个会话独占 17 封权限邮件,
* 把整个收件箱挤满,另外两个会话的信被压到看不见的地方。
*
* 缺了分组的后果不是「不好看」,而是收件箱失去了它唯一的作用:
* 让人知道「有哪几件事在等我」。17 行同一件事和 3 件不同的事,占的视觉权重一样。
*/
/** 一个会话在列表里折叠成的一组。 */
export interface MailGroup {
sessionId: string;
/** 会话别名,空串表示尚未命名 */
alias: string;
/** 组标题:取最新一封的主题(会话主题会随任务推进被改写,最新的那个最贴切) */
subject: string;
/** 最新一封,组头的摘要与时间都取自它 */
latest: Mail;
/** 组内全部邮件,时间倒序 */
mails: Mail[];
unreadCount: number;
}
function timeOf(m: Mail): number {
const t = new Date(m.created_at).getTime();
// created_at 解析失败时给 0 而不是 NaN:NaN 参与比较恒为 false,
// 会让排序结果依赖于原数组顺序,表现为「刷新一次顺序就变了」
return Number.isNaN(t) ? 0 : t;
}
/** 时间倒序;同一时刻用 mail_id 兜底,与后端 `ORDER BY created_at DESC, mail_id DESC` 一致。 */
function byNewest(a: Mail, b: Mail): number {
const d = timeOf(b) - timeOf(a);
return d !== 0 ? d : b.mail_id.localeCompare(a.mail_id);
}
/**
* 按 session_id 把邮件桶化。
*
* session_id 缺失的邮件(理论上不该有,但前端不该因为一条脏数据整栏空白)
* 各自成组:用 mail_id 兜底键,保证它至少能被看见。
*/
function bucketBySession(mails: Mail[], keep: (m: Mail) => boolean): Map<string, Mail[]> {
const bySession = new Map<string, Mail[]>();
for (const m of mails) {
if (!keep(m)) continue;
const key = m.session_id || `mail:${m.mail_id}`;
const bucket = bySession.get(key);
if (bucket) bucket.push(m);
else bySession.set(key, [m]);
}
return bySession;
}
/**
* 把平铺的邮件流按 session_id 折叠成组,组之间按最新邮件时间倒序。
*
* 不修改入参;对同一输入永远给出同一输出(组内、组间都有确定的排序),
* 因此可以直接在 render 里调用。
*/
export function groupMailsBySession(mails: Mail[]): MailGroup[] {
const groups: MailGroup[] = [];
for (const [sessionId, bucket] of bucketBySession(mails, () => true)) {
const sorted = [...bucket].sort(byNewest);
const latest = sorted[0];
groups.push({
sessionId,
alias: latest.session_alias || '',
subject: latest.subject,
latest,
mails: sorted,
unreadCount: sorted.filter(m => m.status === 'unread').length
});
}
return groups.sort((a, b) => byNewest(a.latest, b.latest));
}
/**
* 单封邮件的组不算「组」,平铺显示即可。
*
* 给一封孤立的邮件套上可折叠的组头会多一次点击才能读到内容,
* 而收件箱里大多数人类来信就是孤立的一封。
*/
export function isFlatGroup(g: MailGroup): boolean {
return g.mails.length === 1;
}
/** 权限邮件且尚无决策结果。空串与 null 都算未决策(后端用 COALESCE 归一成空串)。 */
export function isPendingPermission(m: Mail): boolean {
return m.mail_type === 'permission_request' && !m.permission_result;
}
/**
* 把权限请求从普通邮件里分出来。
*
* 权限请求不是「一封信」而是「一件待办」:它的生命周期是「等人点头 → 决策完就作废」,
* 而普通邮件是要读的内容。混在一个收件箱里两者互相伤害 ——
* 一次 Agent 任务能产生十几个权限请求,把真正需要阅读的来信压到看不见的地方;
* 反过来,人要找「有什么在等我批」也得在几十封信里翻。
*
* 所以它们各归各的导航项:收件箱只放要读的,授权项只放要批的。
*/
export function splitByPermission(mails: Mail[]): { normal: Mail[]; permissions: Mail[] } {
const normal: Mail[] = [];
const permissions: Mail[] = [];
for (const m of mails) {
if (m.mail_type === 'permission_request') permissions.push(m);
else normal.push(m);
}
return { normal, permissions };
}
/** 待决策的权限请求数 —— 授权项的徽标数字,也是「要人动手」的唯一信号。 */
export function countPendingPermissions(mails: Mail[]): number {
let n = 0;
for (const m of mails) if (isPendingPermission(m)) n += 1;
return n;
}
/** 授权项里的一个会话分组:一级是会话,二级是该会话的授权请求。 */
export interface PermissionGroup {
sessionId: string;
alias: string;
/** 发起请求的 Agent(权限请求一定由 Agent 发出) */
agentName: string;
/** Agent 的工作目录,同名 Agent 在不同目录是不同的活 */
path: string;
/** 还等着人点头的,时间倒序 */
pending: Mail[];
/** 已决策的历史记录,时间倒序 */
settled: Mail[];
/** 组内最新一封,组头时间取自它 */
latest: Mail;
}
/**
* 把权限请求按会话折成组,**有待决策的会话永远排在前面**。
*
* 排序判据不是时间而是「要不要我动手」:一个三天前发起、至今还卡着的授权请求
* 比十分钟前刚批完的那条重要得多。纯按时间排会把它压到列表底部,
* 而 Agent 那条会话正在那儿等着 —— 这正是权限死锁在 UI 上的样子。
*/
export function groupPermissions(mails: Mail[]): PermissionGroup[] {
const groups: PermissionGroup[] = [];
for (const [sessionId, bucket] of bucketBySession(
mails,
m => m.mail_type === 'permission_request'
)) {
const sorted = [...bucket].sort(byNewest);
const latest = sorted[0];
groups.push({
sessionId,
alias: latest.session_alias || '',
agentName: latest.from_name,
path: latest.session_workspace || '',
pending: sorted.filter(isPendingPermission),
settled: sorted.filter(m => !isPendingPermission(m)),
latest
});
}
return groups.sort((a, b) => {
// 有待决策的先来;组内待决策数多的更靠前(那条会话卡得更久)
if ((a.pending.length > 0) !== (b.pending.length > 0)) {
return a.pending.length > 0 ? -1 : 1;
}
if (a.pending.length !== b.pending.length) return b.pending.length - a.pending.length;
return byNewest(a.latest, b.latest);
});
}

View File

@ -0,0 +1,214 @@
import type { Mail, Session } from '../types';
/**
* 「这封回复该发给谁」。
*
* 原先这件事被写成一行三元表达式,判据是 `from_name === 'human'` ——
* 那是多用户认证之前的遗留:当时人类只有一个身份 `human@`。改成多用户后
* `users.username` 与 `agents.agent_name` 共用命名空间,登录名可能是 `jianf`,
* 于是判据恒为假,回复对端就取成了 `from_name`(也就是自己)。
*
* 后果是**信发给了自己**:在会话视图里回复时尤其必然发生 —— 那里的锚点是
* 「最后一封」,而最后一封常常就是自己刚发的那封。生产实测链条:
* pi → jianf 权限请求
* jianf → pi Re: 权限请求(对的,因为锚点是 pi 发来的)
* pi → jianf 权限请求
* jianf → jianf Re: Re: 权限请求(错的,锚点是自己发的)
*
* 更根本的问题是**判据本身选错了**。会话视图的语义是「跟这个 Agent 的一次
* 任务」,人在这里打字就是「给对方追加一句」;对端是**会话的属性**,
* 不该由「最后一封是谁发的」这种偶然状态决定。所以会话视图用
* `sessionCounterpart` 扫全会话定对端,只有单封邮件视图才用 `mailCounterpart`。
*/
/** 一个可投递的对端。 */
export interface Counterpart {
name: string;
/** 工作目录,可能为空(人类没有工作目录) */
path: string;
/** 这一方是人类用户还是 Agent —— 决定地址拼几段 */
isHuman: boolean;
}
/**
* 拼三维地址 `name@path.session`。**逐字节对齐后端 `models.FormatAddress`。**
*
* 三个分支缺一不可:
*
* session 为空 + path 为空 → `jianf` (裸名字 = 默认会话)
* session 为空 + 有 path → `pi@/home`
* session 非空 → `pi@/home.任务` / `jianf@.任务`
*
* **最后一个分支在 path 为空时仍要保留 `@`。**
* 地址按**最后一个 `.`** 切分:`jianf@.任务` 能正确还原成
* name=`jianf` / path=`` / session=`任务`,而漏掉 `@` 的 `jianf.任务`
* 会被整串当成**名字**(实测 ParseAddress 返回 name="jianf.任务")——
* 那是个不存在的 Agent,投递必然 404。
*
* 人类没有工作目录,所以 path 为空是界面上的常态而非边界情形:
* 给人类回信时若丢掉 `@`,整条地址就废了。
*
* 展示用途请走 `participantAddress` —— 它按「人 / Agent」决定带几段。
*/
export function formatAddress(name: string, path: string, session?: string | null): string {
const n = (name || '').trim();
if (!n) return '';
const p = (path || '').trim();
const s = (session || '').trim();
if (!s) return p ? `${n}@${p}` : n;
return `${n}@${p}.${s}`;
}
/**
* 一个参与方在**这封邮件所属会话**里的完整地址。
*
* # 人与 Agent 的地址维度不同
*
* **Agent 要三段**:`name@path.session` 才唯一确定「哪个 Agent、在哪个目录、
* 哪条线索」。同名 Agent 在不同目录是不同的活,同一目录下不同会话是不同的任务
* —— 少任何一段都不是个可投递的地址。
*
* **人只要名字**:人没有工作目录,也不需要指定会话(发给人就是进他的收件箱)。
* 给人拼 `jianf@.某会话` 或 `jianf.某会话` 都是把 Agent 的维度硬套在人身上。
*
* 判据从「workspace 是否为空」的启发式改成**显式布尔**:
* `mails.from_workspace` 对 Agent 存的是 Agent 名而不是路径(历史遗留),
* 拿它当「是不是 Agent」的代理变量会在边界上猜错。
* 服务端用 `EXISTS (SELECT 1 FROM users …)` 判人/Agent,那条布尔才是权威。
*
* workspace 必须从**会话**取(`session_workspace`),不能用 from_workspace:
* 后者对 Agent 存的是 Agent 名,拿它拼会得到 `dsh@dsh`。
*/
export function participantAddress(
name: string,
isHuman: boolean,
workspace?: string | null,
sessionAlias?: string | null
): string {
// 人(无工作目录):只有名字,不带 path 也不带会话位
if (isHuman) return formatAddress(name, '');
return formatAddress(name, (workspace || '').trim(), sessionAlias || null);
}
/**
* 单封邮件的对端:我发的就回给收件人,别人发的就回给发件人。
*
* `me` 必须是当前登录用户名。传空串时退化为「回给发件人」——
* 那比回给自己安全:最坏的情况是回错人,而不是把信发进虚空。
*/
export function mailCounterpart(mail: Mail, me: string): Counterpart {
const iSent = !!me && mail.from_name === me;
const ws = mail.session_workspace || '';
if (iSent) {
return { name: mail.to_name, path: mail.to_human ? '' : ws, isHuman: mail.to_human };
}
return { name: mail.from_name, path: mail.from_human ? '' : ws, isHuman: mail.from_human };
}
/**
* 会话的对端:扫全会话找第一个不是我的参与方。
*
* 为什么扫全会话而不看某一封:会话视图里人的意图是「给这次任务的对方追加一句」,
* 对端是会话的属性。只看最后一封时,自己刚发过信就会把自己算成对端。
*
* 按时间正序扫,取**首个**非我参与方 —— 会话的发起对象就是这次任务的主体,
* 后来被抄送进来的第三方不该抢走这个位置。同名参与方保留**首个非空 path**:
* 同名 Agent 在不同目录是不同的活,而某些邮件的 workspace 字段可能为空。
*/
export function sessionCounterpart(mails: Mail[], me: string): Counterpart | null {
if (!mails.length) return null;
// 与后端 `ORDER BY created_at ASC, mail_id ASC` 一致:
// SQLite 时间戳精度有限,同刻插入的多封靠 mail_id 定序
const sorted = [...mails].sort((a, b) => {
const ta = new Date(a.created_at).getTime();
const tb = new Date(b.created_at).getTime();
const na = Number.isNaN(ta) ? 0 : ta;
const nb = Number.isNaN(tb) ? 0 : tb;
return na !== nb ? na - nb : a.mail_id.localeCompare(b.mail_id);
});
const ws = (m: Mail) => m.session_workspace || '';
let found: Counterpart | null = null;
for (const m of sorted) {
// 收件人优先于发件人:会话首封多是「我 → Agent」,
// 那个 to_name 就是这次任务派给了谁
const candidates: Counterpart[] = [
{ name: m.to_name, path: m.to_human ? '' : ws(m), isHuman: m.to_human },
{ name: m.from_name, path: m.from_human ? '' : ws(m), isHuman: m.from_human }
];
for (const c of candidates) {
if (!c.name || c.name === me) continue;
if (!found) {
found = c;
} else if (found.name === c.name && !found.path && c.path) {
// 补上首次出现时缺失的 path
found = c;
}
if (found.path) return found;
}
}
return found;
}
/**
* 会话视图的回复目标地址。
*
* 会话别名必须带上:不带就落到该 Agent 的**默认会话**,
* 而人明明是在某条具体线索里打字 —— 那会让追加的一句跑到另一条任务里去。
*/
export function sessionReplyTarget(
mails: Mail[],
session: Session | null,
me: string
): string {
const peer = sessionCounterpart(mails, me);
if (!peer) return '';
return formatAddress(peer.name, peer.path, session?.session_alias || null);
}
/** 单封邮件视图的回复目标地址。 */
export function mailReplyTarget(mail: Mail, me: string): string {
const peer = mailCounterpart(mail, me);
return formatAddress(peer.name, peer.path, mail.session_alias || null);
}
/**
* 「回复全部」的抄送清单:会话/邮件的其他参与方,去掉自己与主收件人。
*
* 原先用 `!a.startsWith('human')` 去掉自己 —— 同一个遗留判据,
* 结果是点「回复全部」会把自己抄送进去。
*
* 地址拼法与 participantAddress 一致:人只有名字,Agent 拼 `name@path.session`。
* path 从 session_workspace 取,不能从 from_workspace/to_workspace 取
* (后者对 Agent 存的是 Agent 名)。
*/
export function replyAllCC(
mail: Mail,
me: string,
primaryName: string
): string[] {
const ws = mail.session_workspace || '';
const raw = [
formatAddress(mail.from_name, mail.from_human ? '' : ws),
formatAddress(mail.to_name, mail.to_human ? '' : ws),
// cc_list 使用 raw 字段(用户输入的原文,保留 .new 等原始意图)
...(mail.cc_list ?? []).map(c => c.raw || formatAddress(c.name, c.path || '', c.session || null))
];
const seen = new Set<string>();
const out: string[] = [];
for (const addr of raw) {
if (!addr) continue;
const name = addr.split('@')[0];
// 自己收不到自己的信没意义;主收件人已经在 to 里
if (me && name === me) continue;
if (primaryName && name === primaryName) continue;
if (seen.has(addr)) continue;
seen.add(addr);
out.push(addr);
}
return out;
}