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 }