package session // 会话标题:sidecar 文件 .title(与会话 jsonl 同目录)。 // 标题本地派生自用户首条消息,零请求、零延迟;TUI 与 WebUI 共用本文件。 import ( "os" "path/filepath" "strings" ) // SafeID 拒绝路径穿越,标题文件只允许落在会话目录内。 func SafeID(id string) (string, bool) { if id == "" || strings.ContainsAny(id, `/\`) || strings.Contains(id, "..") { return "", false } return id, true } // TitlePath 标题 sidecar 文件路径。 func TitlePath(id string) string { return filepath.Join(Dir(), id+".title") } // LoadTitle 读取标题;无标题返回 ""。 func LoadTitle(id string) string { safe, ok := SafeID(id) if !ok { return "" } b, err := os.ReadFile(TitlePath(safe)) if err != nil { return "" } return strings.TrimSpace(string(b)) } // SaveTitle 写入标题(去首尾空白,上限 60 字)。 func SaveTitle(id, title string) error { safe, ok := SafeID(id) if !ok { return os.ErrInvalid } title = TruncateRunes(strings.TrimSpace(title), 60) if title == "" { return os.ErrInvalid } if err := os.MkdirAll(Dir(), 0o755); err != nil { return err } return os.WriteFile(TitlePath(safe), []byte(title), 0o644) } // TruncateRunes 按 rune 截断。 func TruncateRunes(s string, n int) string { r := []rune(s) if len(r) <= n { return s } return string(r[:n]) } // SanitizeTitle 清洗标题:去空白、去包围引号,折叠空白,截断 24 字。 func SanitizeTitle(s string) string { s = strings.TrimSpace(s) s = strings.Trim(s, `"'「」『』【】<>`) s = strings.TrimSpace(s) s = strings.Join(strings.Fields(s), " ") return TruncateRunes(s, 24) } // DeriveTitle 从用户首条消息本地派生会话标题:不发请求、零延迟、无失败路径。 // // 此前为标题单独发一次模型调用,代价远大于收益:该模型上常达 ~27s,与主回合并发时 // 极易超时(30s 上限),导致长回合的会话静默丢标题。而标题只是会话列表里的速记, // 用「用户诉求的首行」就足够辨认,没必要为一个标签花掉一次完整往返。 func DeriveTitle(userMsg string) string { s := strings.TrimSpace(userMsg) if s == "" { return "" } // 多行输入取首行——那通常是主诉求,后续多为补充细节或粘贴内容。 if i := strings.IndexByte(s, '\n'); i >= 0 { s = s[:i] } // 去掉 markdown 装饰:标题符号、引用、列表符、代码块围栏。 s = strings.TrimLeft(s, "#>*-`~ ") return SanitizeTitle(s) }