落地 docs/16 P1:会话落盘与真续跑(prior tool 轨迹)、incomplete「加预算继续」、 awaiting_confirm outcome、同参拒+第4/6轮检查点、侧栏忙时单槽排队。 Ask 真 token 流式记缺口(三协议 SSE 成本高)。
231 lines
5.5 KiB
Go
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
|
|
}
|