package ai import ( "crypto/rand" "encoding/hex" "encoding/json" "fmt" "os" "path/filepath" "sort" "strings" "time" ) // Session 桌面本地会话(落盘 ~/.opsxdb/ai-sessions/.json)。 type Session struct { ID string `json:"id"` Title string `json:"title,omitempty"` Status string `json:"status"` // idle|running|incomplete|completed|failed|cancelled|awaiting_confirm CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` Messages []Message `json:"messages"` Runs []SessionRun `json:"runs,omitempty"` } // SessionRun 一次运行摘要。 type SessionRun struct { RunID string `json:"runId"` Outcome string `json:"outcome"` IncompleteReason string `json:"incompleteReason,omitempty"` Resumable bool `json:"resumable"` StepsUsed int `json:"stepsUsed"` StepsBudget int `json:"stepsBudget"` FinishedAt string `json:"finishedAt"` } // SessionSummary 列表项(不含 messages)。 type SessionSummary struct { ID string `json:"id"` Title string `json:"title,omitempty"` Status string `json:"status"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` MsgCount int `json:"msgCount"` LastRunID string `json:"lastRunId,omitempty"` } func sessionsDir() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", err } return filepath.Join(home, ".opsxdb", "ai-sessions"), nil } // SessionPath 返回会话文件路径。 func SessionPath(id string) (string, error) { dir, err := sessionsDir() if err != nil { return "", err } id = strings.TrimSpace(id) if id == "" || strings.ContainsAny(id, `/\`) { return "", fmt.Errorf("invalid session id") } return filepath.Join(dir, id+".json"), nil } // NewSessionID 生成 sess_… 短 id。 func NewSessionID() string { var b [8]byte _, _ = rand.Read(b[:]) return "sess_" + hex.EncodeToString(b[:]) } // SaveSession 脱敏后写入磁盘。 func SaveSession(s Session) error { if s.ID == "" { return fmt.Errorf("session id required") } s.Messages = RedactMessages(s.Messages) s.UpdatedAt = time.Now().Format(time.RFC3339) if s.CreatedAt == "" { s.CreatedAt = s.UpdatedAt } path, err := SessionPath(s.ID) if err != nil { return err } if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { return err } b, err := json.MarshalIndent(s, "", " ") if err != nil { return err } return os.WriteFile(path, b, 0600) } // LoadSession 读会话;不存在返回 os.ErrNotExist。 func LoadSession(id string) (Session, error) { path, err := SessionPath(id) if err != nil { return Session{}, err } b, err := os.ReadFile(path) if err != nil { return Session{}, err } var s Session if err := json.Unmarshal(b, &s); err != nil { return Session{}, err } return s, nil } // ListSessions 按 UpdatedAt 倒序;limit<=0 表示全部。 func ListSessions(limit int) ([]SessionSummary, error) { dir, err := sessionsDir() if err != nil { return nil, err } entries, err := os.ReadDir(dir) if err != nil { if os.IsNotExist(err) { return []SessionSummary{}, nil } return nil, err } var out []SessionSummary for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { continue } id := strings.TrimSuffix(e.Name(), ".json") s, err := LoadSession(id) if err != nil { continue } sum := SessionSummary{ ID: s.ID, Title: s.Title, Status: s.Status, CreatedAt: s.CreatedAt, UpdatedAt: s.UpdatedAt, MsgCount: len(s.Messages), } if n := len(s.Runs); n > 0 { sum.LastRunID = s.Runs[n-1].RunID } out = append(out, sum) } sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt > out[j].UpdatedAt }) if limit > 0 && len(out) > limit { out = out[:limit] } return out, nil } // DeleteSession 删除会话文件。 func DeleteSession(id string) error { path, err := SessionPath(id) if err != nil { return err } err = os.Remove(path) if os.IsNotExist(err) { return nil } return err } // OutcomeToSessionStatus 映射 RunOutcome → Session.Status。 func OutcomeToSessionStatus(outcome string) string { switch outcome { case OutcomeIncomplete: return "incomplete" case OutcomeAwaitingConfirm: return "awaiting_confirm" case OutcomeFailed: return "failed" case OutcomeCancelled: return "cancelled" case OutcomeCompleted: return "completed" default: if outcome == "" { return "idle" } return outcome } } // PersistAgentRun 把本趟轨迹与 outcome 写入会话(真·续跑用)。 func PersistAgentRun(sessionID, title string, trajectory []Message, res AgentRes) (Session, error) { sessionID = strings.TrimSpace(sessionID) if sessionID == "" { sessionID = NewSessionID() } s, err := LoadSession(sessionID) if err != nil { if !os.IsNotExist(err) { return Session{}, err } s = Session{ID: sessionID, Status: "idle"} } if title != "" && s.Title == "" { // 标题取首条 user 或短截断 t := strings.TrimSpace(title) r := []rune(t) if len(r) > 48 { t = string(r[:48]) + "…" } s.Title = t } if len(trajectory) > 0 { s.Messages = trajectory } s.Status = OutcomeToSessionStatus(res.Outcome) s.Runs = append(s.Runs, SessionRun{ RunID: res.RunID, Outcome: res.Outcome, IncompleteReason: res.IncompleteReason, Resumable: res.Resumable, StepsUsed: res.Rounds, StepsBudget: res.StepsBudget, FinishedAt: time.Now().Format(time.RFC3339), }) if err := SaveSession(s); err != nil { return Session{}, err } return s, nil }