feat(ai): session persist + continue HTTP/UI, checkpoints, queue
落地 docs/16 P1:会话落盘与真续跑(prior tool 轨迹)、incomplete「加预算继续」、 awaiting_confirm outcome、同参拒+第4/6轮检查点、侧栏忙时单槽排队。 Ask 真 token 流式记缺口(三协议 SSE 成本高)。
This commit is contained in:
1 parent
1513f4bc2d
commit
d543f3b924
10 files changed
+783
-104
No files matched your search
+156
-15
@@ -1,33 +1,48 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Session 桌面本地会话(目标态落盘;HTTP 路由可后挂)。
|
||||
// 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
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Messages []Message `json:"messages"`
|
||||
Runs []SessionRun `json:"runs,omitempty"`
|
||||
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"`
|
||||
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) {
|
||||
@@ -44,9 +59,20 @@ func SessionPath(id string) (string, error) {
|
||||
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 == "" {
|
||||
@@ -87,3 +113,118 @@ func LoadSession(id string) (Session, error) {
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user