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,177 @@
package repo
import (
"context"
"fmt"
"strings"
"unicode"
"unicode/utf8"
"github.com/agentmail/gateway/internal/db"
"github.com/google/uuid"
)
// 自动别名 —— 让 `.new` 建出来的会话立刻可被寻址。
//
// # 为什么必须自动命名
//
// `session` 位三态里 `new` 是**一次性动作**:它建出会话就用完了。之后要再投进
// 同一条会话,只有两条路 —— `reply_to` 某封具体邮件,或者 `name@path.<别名>`。
// 而 `CreateSession(alias=nil)` 建出来的会话别名是 NULL,于是:
//
// - `FindNamedSessionFor` 查不到它(`WHERE session_alias = $1` 对 NULL 不成立)
// - `SuggestSessionCandidates` 跳过它(`session_alias IS NOT NULL AND <> ''`)
// - 参与方拿到的 `new_mail` 里 `session_alias` 是空串
//
// 结果是:被抄送方收到一封 `x@/p.new` 的邮件,**除了回复那一封之外无法再投进这条
// 会话**。再发一次 `x@/p.new` 只会建第三条会话。这不是能力缺失,是寻址断链。
//
// 原先的设计假定平台插件会通过 `POST /sessions/{id}/sync` 把模型生成的标题回写成
// 别名,于是「未命名」只是短暂状态。但两件事让这个假定不成立:
//
// 1. 人类发的邮件根本没有平台侧,永远等不到回写;
// 2. 回写发生在模型跑完第一轮之后,而抄送方**在那之前**就要决定回信地址。
//
// 因此本侧先给一个可用的别名,平台随后仍可用 `SyncSessionAlias` 改写它 ——
// `alias_source` 保持 `platform` 正是为此:自动名不是人定的名,不该挡住平台命名。
//
// # 为什么不复用 SyncSessionAlias
//
// 那个函数假定「会话已存在、现在要改名」,并且会跳过 `manual`。这里的场景是
// 「刚建完、还没有名字」,且必须在**建会话的同一个请求里**完成,否则中间那一瞬
// 发出的 SSE 仍然带空别名。
// aliasMaxBytes 与 normalizeAlias 的截断上限一致(sessions.session_alias 为 VARCHAR(128))。
const aliasMaxBytes = 128
// autoAliasAttempts 是撞名后追加 -2、-3… 的尝试次数上限。
// 与 SyncSessionAlias 取同一个数量级:同一主题在同一天内开几十条会话已属异常,
// 真到了上限说明调用方在刷会话,此时报错比继续找空位更有价值。
const autoAliasAttempts = 50
// AutoAliasFor 依据收件人与主题拼一个候选别名(未做唯一性检查)。
//
// 形如 `dsh-重构导入路径`:前缀用收件方名字,后缀用主题。**两者都要**——
// 只用主题时「服务恢复验证」这类通用主题会在不同 Agent 之间反复撞名,
// 只用名字则同一个 Agent 的所有会话都叫 `dsh-2`、`dsh-3`,看不出在聊什么。
//
// 主题为空(少见但合法)时退回单独的名字,由调用方靠后缀去重。
func AutoAliasFor(toName, subject string) string {
base := sanitizeAliasPart(toName)
topic := sanitizeAliasPart(subject)
switch {
case base == "" && topic == "":
// 两边都拿不出可用字符(例如主题全是标点、名字为空)。
// 返回空串让调用方走随机兜底,不要在这里编造。
return ""
case base == "":
return truncateAlias(topic)
case topic == "":
return truncateAlias(base)
default:
return truncateAlias(base + "-" + topic)
}
}
// EnsureSessionAlias 保证会话拥有一个可寻址的别名,返回最终别名。
//
// 已有别名时原样返回,不做任何写入 —— 这让它可以被无条件调用,
// 包括「默认会话」路径上那条可能是刚建的、也可能是复用的会话。
//
// 撞名时追加 -2、-3… 后缀;`want` 为空或全部被占用时退回
// `session-<uuid 前 8 位>`:一个能寻址的丑名字,远胜于没有名字。
func EnsureSessionAlias(ctx context.Context, id uuid.UUID, want string) (string, error) {
if cur := SessionAliasOf(ctx, id); cur != "" {
return cur, nil
}
cands := make([]string, 0, autoAliasAttempts+1)
if want != "" {
for i := 0; i < autoAliasAttempts; i++ {
if i == 0 {
cands = append(cands, want)
continue
}
cands = append(cands, truncateAlias(fmt.Sprintf("%s-%d", want, i+1)))
}
}
// 兜底:uuid 前 8 位。碰撞概率可忽略,且与 want 无关,
// 因此即便主题里一个可用字符都没有也总能拿到别名。
cands = append(cands, "session-"+id.String()[:8])
for _, c := range cands {
// 条件写入:`session_alias IS NULL OR = ''` 保证并发下只有一方写成功,
// 另一方 RowsAffected=0,随后重读拿到对方写的名字 ——
// 两个请求都返回同一个别名,而不是各自以为自己命名成功。
res, err := db.DB.ExecContext(ctx,
`UPDATE sessions SET session_alias = $1, updated_at = NOW()
WHERE session_id = $2 AND (session_alias IS NULL OR session_alias = '')`,
c, id)
if err != nil {
if db.IsUniqueViolation(err) {
continue // 别名被别的会话占了,试下一个后缀
}
return "", err
}
if n, _ := res.RowsAffected(); n == 0 {
// 期间别人(并发请求或平台同步)已经命名过,尊重那个名字
if cur := SessionAliasOf(ctx, id); cur != "" {
return cur, nil
}
// 写不进去且读不到名字,只可能是会话刚被删
return "", fmt.Errorf("会话 %s 已不存在,无法分配别名", id)
}
return c, nil
}
return "", fmt.Errorf("别名 %q 连同 -2..-%d 后缀与 uuid 兜底均被占用", want, autoAliasAttempts)
}
// sanitizeAliasPart 把任意文本压成别名可用的片段。
//
// 规则与 normalizeAlias 一致(非法字符换 -、压缩连续 -、去首尾 -),
// 另外多做两件事:
//
// - **去掉 Markdown / 标点噪声**:主题里的 `[联调]`、`—`、`:` 变成一串
// 破折号毫无信息量。只保留字母、数字与非标点的 Unicode 字符(中文、日文等)。
// - **压缩空白**:`Re: 服务恢复验证` → `Re-服务恢复验证`,而不是 `Re--服务恢复验证`。
//
// 保留中文是刻意的:本项目的会话主题多为中文,转拼音需要额外依赖,
// 而 `dsh-重构导入路径` 作为地址完全可用(三维寻址只忌 `. / @` 与空白)。
func sanitizeAliasPart(s string) string {
var b strings.Builder
lastDash := false
for _, r := range s {
keep := unicode.IsLetter(r) || unicode.IsDigit(r)
if keep {
b.WriteRune(r)
lastDash = false
continue
}
// 其余一切(空白、标点、符号、寻址保留字符)都折成单个 -
if !lastDash && b.Len() > 0 {
b.WriteByte('-')
lastDash = true
}
}
out := strings.Trim(b.String(), "-")
// "new" 是寻址保留字,作为整体别名时必须避开。
// 加前缀而不是拒绝:调用方给的素材没有错,是这个词恰好被占用。
if out == "new" {
return "session-new"
}
return out
}
// truncateAlias 按字节截断且不切坏多字节字符(中文主题很容易超 128 字节)。
func truncateAlias(s string) string {
if len(s) <= aliasMaxBytes {
return strings.Trim(s, "-")
}
cut := s[:aliasMaxBytes]
for len(cut) > 0 && !utf8.ValidString(cut) {
cut = cut[:len(cut)-1]
}
return strings.Trim(cut, "-")
}