377 lines
11 KiB
Go
377 lines
11 KiB
Go
package agent
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
type ConversationMessage struct {
|
||
ID string `json:"id"`
|
||
Role string `json:"role"`
|
||
Content string `json:"content"`
|
||
Reasoning string `json:"reasoning,omitempty"`
|
||
CreatedAt int64 `json:"createdAt"`
|
||
Status string `json:"status,omitempty"`
|
||
Error string `json:"error,omitempty"`
|
||
Tools []ToolExecution `json:"tools,omitempty"`
|
||
Attachments []Attachment `json:"attachments,omitempty"`
|
||
}
|
||
|
||
type Conversation struct {
|
||
ID string `json:"id"`
|
||
Title string `json:"title"`
|
||
CreatedAt int64 `json:"createdAt"`
|
||
UpdatedAt int64 `json:"updatedAt"`
|
||
Draft string `json:"draft"`
|
||
Messages []ConversationMessage `json:"messages"`
|
||
Memory *ConversationMemory `json:"memory,omitempty"`
|
||
DraftAttachments []Attachment `json:"draftAttachments,omitempty"`
|
||
}
|
||
|
||
type ConversationSummary struct {
|
||
ID string `json:"id"`
|
||
Title string `json:"title"`
|
||
CreatedAt int64 `json:"createdAt"`
|
||
UpdatedAt int64 `json:"updatedAt"`
|
||
Preview string `json:"preview"`
|
||
MessageCount int `json:"messageCount"`
|
||
}
|
||
|
||
var conversationID = regexp.MustCompile(`^[A-Za-z0-9_-]{1,80}$`)
|
||
|
||
func (s *Service) conversationPath(id string) (string, error) {
|
||
if !conversationID.MatchString(id) {
|
||
return "", fmt.Errorf("会话标识无效")
|
||
}
|
||
return filepath.Join(filepath.Dir(s.configPath), "conversations", id+".json"), nil
|
||
}
|
||
|
||
func shortText(text string, limit int) string {
|
||
runes := []rune(strings.Join(strings.Fields(text), " "))
|
||
if len(runes) > limit {
|
||
return string(runes[:limit]) + "…"
|
||
}
|
||
return string(runes)
|
||
}
|
||
|
||
func summarize(c Conversation) ConversationSummary {
|
||
preview := "暂无消息"
|
||
for i := len(c.Messages) - 1; i >= 0; i-- {
|
||
if c.Messages[i].Role != "system" && c.Messages[i].Content != "" {
|
||
preview = shortText(c.Messages[i].Content, 80)
|
||
break
|
||
}
|
||
}
|
||
return ConversationSummary{ID: c.ID, Title: c.Title, CreatedAt: c.CreatedAt, UpdatedAt: c.UpdatedAt, Preview: preview, MessageCount: len(c.Messages)}
|
||
}
|
||
|
||
func readConversation(path string) (Conversation, error) {
|
||
raw, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return Conversation{}, err
|
||
}
|
||
if len(raw) > 16*1024*1024 {
|
||
return Conversation{}, fmt.Errorf("会话记录过大")
|
||
}
|
||
var c Conversation
|
||
if err = json.Unmarshal(raw, &c); err != nil {
|
||
return c, fmt.Errorf("会话记录格式损坏,请保留原文件后检查")
|
||
}
|
||
if c.Messages == nil {
|
||
c.Messages = []ConversationMessage{}
|
||
}
|
||
return c, nil
|
||
}
|
||
|
||
func writeConversation(path string, c Conversation) error {
|
||
raw, err := json.Marshal(c)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(raw) > 16*1024*1024 {
|
||
return fmt.Errorf("单个会话记录超过 16 MB,请新建会话")
|
||
}
|
||
if err = os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||
return err
|
||
}
|
||
f, err := os.CreateTemp(filepath.Dir(path), "conversation-*.tmp")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer os.Remove(f.Name())
|
||
if _, err = f.Write(raw); err != nil {
|
||
f.Close()
|
||
return err
|
||
}
|
||
if err = f.Sync(); err != nil {
|
||
f.Close()
|
||
return err
|
||
}
|
||
if err = f.Close(); err != nil {
|
||
return err
|
||
}
|
||
return os.Rename(f.Name(), path)
|
||
}
|
||
|
||
func (s *Service) ListConversations() ([]ConversationSummary, error) {
|
||
s.conversationMu.Lock()
|
||
defer s.conversationMu.Unlock()
|
||
dir := filepath.Join(filepath.Dir(s.configPath), "conversations")
|
||
entries, err := os.ReadDir(dir)
|
||
if errors.Is(err, os.ErrNotExist) {
|
||
return []ConversationSummary{}, nil
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("无法读取会话目录: %w", err)
|
||
}
|
||
result := make([]ConversationSummary, 0, len(entries))
|
||
for _, entry := range entries {
|
||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
||
continue
|
||
}
|
||
id := strings.TrimSuffix(entry.Name(), ".json")
|
||
if !conversationID.MatchString(id) {
|
||
continue
|
||
}
|
||
c, err := readConversation(filepath.Join(dir, entry.Name()))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取会话 %s 失败: %w", id, err)
|
||
}
|
||
if c.ID != id {
|
||
return nil, fmt.Errorf("会话标识与记录文件不一致: %s", id)
|
||
}
|
||
result = append(result, summarize(c))
|
||
}
|
||
sort.Slice(result, func(i, j int) bool { return result[i].UpdatedAt > result[j].UpdatedAt })
|
||
return result, nil
|
||
}
|
||
|
||
func (s *Service) CreateConversation() (Conversation, error) {
|
||
s.conversationMu.Lock()
|
||
defer s.conversationMu.Unlock()
|
||
now := time.Now().UnixMilli()
|
||
c := Conversation{ID: rand.Text(), Title: "新会话", CreatedAt: now, UpdatedAt: now, Messages: []ConversationMessage{}}
|
||
path, _ := s.conversationPath(c.ID)
|
||
if err := writeConversation(path, c); err != nil {
|
||
return c, fmt.Errorf("创建会话失败: %w", err)
|
||
}
|
||
return c, nil
|
||
}
|
||
|
||
func (s *Service) GetConversation(id string) (Conversation, error) {
|
||
s.conversationMu.Lock()
|
||
defer s.conversationMu.Unlock()
|
||
path, err := s.conversationPath(id)
|
||
if err != nil {
|
||
return Conversation{}, err
|
||
}
|
||
c, err := readConversation(path)
|
||
if err != nil {
|
||
return c, fmt.Errorf("读取会话失败: %w", err)
|
||
}
|
||
if c.ID != id {
|
||
return Conversation{}, fmt.Errorf("会话标识与记录文件不一致")
|
||
}
|
||
return c, nil
|
||
}
|
||
|
||
func (s *Service) SaveConversation(input Conversation) (ConversationSummary, error) {
|
||
s.conversationMu.Lock()
|
||
defer s.conversationMu.Unlock()
|
||
path, err := s.conversationPath(input.ID)
|
||
if err != nil {
|
||
return ConversationSummary{}, err
|
||
}
|
||
// Require an existing record so a late stream update cannot recreate a deleted chat.
|
||
stored, err := readConversation(path)
|
||
if err != nil {
|
||
return ConversationSummary{}, fmt.Errorf("保存会话失败,请确认会话仍存在: %w", err)
|
||
}
|
||
if stored.ID != input.ID {
|
||
return ConversationSummary{}, fmt.Errorf("会话标识与记录文件不一致")
|
||
}
|
||
if len(input.Draft) > 32000 || len(input.Messages) > 2000 {
|
||
return ConversationSummary{}, fmt.Errorf("会话内容过长,请新建会话")
|
||
}
|
||
seen := make(map[string]bool)
|
||
for i, m := range input.Messages {
|
||
if m.ID == "" || len(m.ID) > 128 || seen[m.ID] {
|
||
return ConversationSummary{}, fmt.Errorf("消息标识无效或重复")
|
||
}
|
||
seen[m.ID] = true
|
||
if len(m.Attachments) > 0 {
|
||
if m.Role != "user" {
|
||
return ConversationSummary{}, fmt.Errorf("仅用户消息可以附加文件")
|
||
}
|
||
attachments, err := s.canonicalAttachments(input.ID, m.Attachments)
|
||
if err != nil {
|
||
return ConversationSummary{}, err
|
||
}
|
||
input.Messages[i].Attachments = attachments
|
||
}
|
||
if m.Role != "user" && m.Role != "assistant" && m.Role != "system" {
|
||
return ConversationSummary{}, fmt.Errorf("消息角色无效")
|
||
}
|
||
switch m.Status {
|
||
case "", "streaming", "complete", "stopped", "error", "truncated":
|
||
default:
|
||
return ConversationSummary{}, fmt.Errorf("消息状态无效")
|
||
}
|
||
if len(m.Content)+len(m.Reasoning) > 4*1024*1024 || len(m.Error) > 16000 {
|
||
return ConversationSummary{}, fmt.Errorf("单条消息过长")
|
||
}
|
||
if len(m.Tools) > 128 {
|
||
return ConversationSummary{}, fmt.Errorf("单条消息工具记录过多")
|
||
}
|
||
for j, tool := range m.Tools {
|
||
if len(tool.ID) > 256 || len(tool.Server) > 256 || len(tool.Tool) > 256 || len(tool.Arguments) > 64*1024 || len(tool.Result) > 5000 {
|
||
return ConversationSummary{}, fmt.Errorf("工具记录过长")
|
||
}
|
||
if len(tool.Images) > 0 {
|
||
images, err := s.canonicalAttachments(input.ID, tool.Images)
|
||
if err != nil {
|
||
return ConversationSummary{}, err
|
||
}
|
||
if len(images) > 2 {
|
||
return ConversationSummary{}, fmt.Errorf("单个工具最多保存两张图片")
|
||
}
|
||
for _, image := range images {
|
||
if image.Kind != "image" {
|
||
return ConversationSummary{}, fmt.Errorf("工具图片记录无效")
|
||
}
|
||
}
|
||
input.Messages[i].Tools[j].Images = images
|
||
}
|
||
switch tool.Status {
|
||
case "running", "complete", "error", "stopped":
|
||
default:
|
||
return ConversationSummary{}, fmt.Errorf("工具记录状态无效")
|
||
}
|
||
}
|
||
}
|
||
stored.Messages = input.Messages
|
||
if stored.Messages == nil {
|
||
stored.Messages = []ConversationMessage{}
|
||
}
|
||
// The client cannot replace memory with an old autosave snapshot. Editing an
|
||
// already summarized prefix invalidates the checkpoint and recomputes later.
|
||
if stored.Memory != nil && memoryBoundary(stored) == 0 {
|
||
stored.Memory = nil
|
||
}
|
||
stored.Draft = input.Draft
|
||
draftAttachments, err := s.canonicalAttachments(input.ID, input.DraftAttachments)
|
||
if err != nil {
|
||
return ConversationSummary{}, err
|
||
}
|
||
stored.DraftAttachments = draftAttachments
|
||
stored.UpdatedAt = time.Now().UnixMilli()
|
||
if stored.Title == "新会话" {
|
||
for _, m := range input.Messages {
|
||
if m.Role == "user" && strings.TrimSpace(m.Content) != "" {
|
||
stored.Title = shortText(m.Content, 28)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if err = writeConversation(path, stored); err != nil {
|
||
return ConversationSummary{}, fmt.Errorf("保存会话失败: %w", err)
|
||
}
|
||
return summarize(stored), nil
|
||
}
|
||
|
||
func (s *Service) RenameConversation(id, title string) (ConversationSummary, error) {
|
||
s.conversationMu.Lock()
|
||
defer s.conversationMu.Unlock()
|
||
title = strings.TrimSpace(title)
|
||
if title == "" || len([]rune(title)) > 80 {
|
||
return ConversationSummary{}, fmt.Errorf("会话名称应为 1–80 个字符")
|
||
}
|
||
path, err := s.conversationPath(id)
|
||
if err != nil {
|
||
return ConversationSummary{}, err
|
||
}
|
||
c, err := readConversation(path)
|
||
if err != nil {
|
||
return ConversationSummary{}, err
|
||
}
|
||
c.Title = title
|
||
c.UpdatedAt = time.Now().UnixMilli()
|
||
if err = writeConversation(path, c); err != nil {
|
||
return ConversationSummary{}, err
|
||
}
|
||
return summarize(c), nil
|
||
}
|
||
|
||
func (s *Service) DeleteConversation(id string) error {
|
||
s.conversationMu.Lock()
|
||
defer s.conversationMu.Unlock()
|
||
path, err := s.conversationPath(id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err = os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||
return fmt.Errorf("删除会话失败: %w", err)
|
||
}
|
||
dir, _ := s.attachmentDir(id, "")
|
||
if err := os.RemoveAll(dir); err != nil {
|
||
return fmt.Errorf("会话已删除,附件清理失败: %w", err)
|
||
}
|
||
s.attachmentMu.Lock()
|
||
for key := range s.attachmentMetadata {
|
||
if strings.HasPrefix(key, id+"/") {
|
||
delete(s.attachmentMetadata, key)
|
||
}
|
||
}
|
||
s.attachmentMu.Unlock()
|
||
s.mu.Lock()
|
||
delete(s.history, id)
|
||
s.mu.Unlock()
|
||
return nil
|
||
}
|
||
|
||
// Completed pairs restore context after restart. Failed/stopped/streaming answers
|
||
// remain visible in the record but are excluded from future model prompts.
|
||
func completedConversationTurns(messages []ConversationMessage) []modelMessage {
|
||
result := []modelMessage{}
|
||
pending := ""
|
||
for _, m := range messages {
|
||
if m.Role == "user" {
|
||
pending = m.Content
|
||
continue
|
||
}
|
||
if m.Role != "assistant" {
|
||
continue
|
||
}
|
||
if pending != "" && m.Content != "" && (m.Status == "complete" || m.Status == "truncated" || m.Status == "") {
|
||
result = append(result, modelMessage{Role: "user", Content: pending}, modelMessage{Role: "assistant", Content: m.Content})
|
||
}
|
||
pending = ""
|
||
}
|
||
return result
|
||
}
|
||
|
||
func (s *Service) savedConversationTurns(id string) ([]modelMessage, bool, error) {
|
||
path, err := s.conversationPath(id)
|
||
if err != nil {
|
||
return nil, false, nil
|
||
} // Legacy callers may use a device address as session ID.
|
||
s.conversationMu.Lock()
|
||
defer s.conversationMu.Unlock()
|
||
c, err := readConversation(path)
|
||
if errors.Is(err, os.ErrNotExist) {
|
||
return nil, false, nil
|
||
}
|
||
if err != nil {
|
||
return nil, false, err
|
||
}
|
||
return completedConversationTurns(c.Messages), true, nil
|
||
}
|