ituoke/internal/agent/conversations.go

377 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}