Files
opsxdb/server/internal/ai/session.go
T
logan d543f3b924 feat(ai): session persist + continue HTTP/UI, checkpoints, queue
落地 docs/16 P1:会话落盘与真续跑(prior tool 轨迹)、incomplete「加预算继续」、
awaiting_confirm outcome、同参拒+第4/6轮检查点、侧栏忙时单槽排队。
Ask 真 token 流式记缺口(三协议 SSE 成本高)。
2026-10-15 14:18:18 +08:00

231 lines
5.5 KiB
Go

package ai
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// Session 桌面本地会话(落盘 ~/.opsxdb/ai-sessions/<id>.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
}