diff --git a/internal/database/conversation.go b/internal/database/conversation.go index 32078497..7427f478 100644 --- a/internal/database/conversation.go +++ b/internal/database/conversation.go @@ -565,19 +565,53 @@ func (db *DB) DeleteConversation(id string) error { if err != nil { return fmt.Errorf("删除对话失败: %w", err) } - // Best-effort cleanup for conversation-scoped filesystem artifacts - // (e.g., summarization transcript, reduction/checkpoint files under conversation_artifacts/). - if base := strings.TrimSpace(db.conversationArtifactsDir); base != "" { - artDir := filepath.Join(base, id) - if rmErr := os.RemoveAll(artDir); rmErr != nil { - db.logger.Warn("删除会话 artifacts 目录失败", zap.String("conversationId", id), zap.String("dir", artDir), zap.Error(rmErr)) - } - } + db.removeConversationScopedDirs(id) db.logger.Info("对话及其所有相关数据已删除", zap.String("conversationId", id)) return nil } +func sanitizeConversationPathSegment(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "default" + } + s = strings.ReplaceAll(s, string(filepath.Separator), "-") + s = strings.ReplaceAll(s, "/", "-") + s = strings.ReplaceAll(s, "\\", "-") + s = strings.ReplaceAll(s, "..", "__") + if len(s) > 180 { + s = s[:180] + } + return s +} + +func (db *DB) removeConversationScopedDir(base, conversationID, label string) { + base = strings.TrimSpace(base) + if base == "" { + return + } + dir := filepath.Join(base, sanitizeConversationPathSegment(conversationID)) + if rmErr := os.RemoveAll(dir); rmErr != nil { + if db.logger != nil { + db.logger.Warn("删除会话目录失败", + zap.String("conversationId", conversationID), + zap.String("kind", label), + zap.String("dir", dir), + zap.Error(rmErr)) + } + } +} + +func (db *DB) removeConversationScopedDirs(conversationID string) { + // summarization transcript, reduction files, etc. + db.removeConversationScopedDir(db.conversationArtifactsDir, conversationID, "conversation_artifacts") + // Eino plantask JSON boards (skills_dir/.eino/plantask//). + db.removeConversationScopedDir(db.einoPlantaskBaseDir, conversationID, "plantask") + // Eino ADK runner checkpoints (checkpoint_dir//). + db.removeConversationScopedDir(db.einoCheckpointBaseDir, conversationID, "eino_checkpoint") +} + // SaveAgentTrace 保存最后一轮代理消息轨迹与助手输出摘要。 // SQLite 列名仍为 last_react_input / last_react_output,与历史库表兼容;语义上为「全模式代理轨迹」,非仅 ReAct。 func (db *DB) SaveAgentTrace(conversationID, traceInputJSON, assistantOutput string) error { diff --git a/internal/database/conversation_cleanup_test.go b/internal/database/conversation_cleanup_test.go new file mode 100644 index 00000000..8a2371ab --- /dev/null +++ b/internal/database/conversation_cleanup_test.go @@ -0,0 +1,57 @@ +package database + +import ( + "os" + "path/filepath" + "testing" + + "go.uber.org/zap" +) + +func TestDeleteConversationRemovesEinoScopedDirs(t *testing.T) { + tmp := t.TempDir() + dbPath := filepath.Join(tmp, "conversations.db") + db, err := NewDB(dbPath, zap.NewNop()) + if err != nil { + t.Fatalf("NewDB: %v", err) + } + defer db.Close() + + plantaskBase := filepath.Join(tmp, "skills", ".eino", "plantask") + checkpointBase := filepath.Join(tmp, "eino-checkpoints") + db.SetEinoConversationDirs(plantaskBase, checkpointBase) + + conv, err := db.CreateConversation("cleanup test", ConversationCreateMeta{}) + if err != nil { + t.Fatalf("CreateConversation: %v", err) + } + convID := conv.ID + seg := sanitizeConversationPathSegment(convID) + for _, base := range []struct { + root string + file string + }{ + {db.conversationArtifactsDir, "transcript.txt"}, + {plantaskBase, "task-1.json"}, + {checkpointBase, "runner-deep.ckpt"}, + } { + dir := filepath.Join(base.root, seg) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + if err := os.WriteFile(filepath.Join(dir, base.file), []byte("x"), 0o644); err != nil { + t.Fatalf("write %s: %v", base.file, err) + } + } + + if err := db.DeleteConversation(convID); err != nil { + t.Fatalf("DeleteConversation: %v", err) + } + + for _, base := range []string{db.conversationArtifactsDir, plantaskBase, checkpointBase} { + dir := filepath.Join(base, seg) + if _, statErr := os.Stat(dir); !os.IsNotExist(statErr) { + t.Fatalf("expected removed dir %s, stat err=%v", dir, statErr) + } + } +} diff --git a/internal/database/database.go b/internal/database/database.go index cc5bfd54..78c2108a 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -49,6 +49,8 @@ type DB struct { *sql.DB logger *zap.Logger conversationArtifactsDir string + einoPlantaskBaseDir string // skills_dir + plantask_rel_dir (per-conversation subdirs) + einoCheckpointBaseDir string // checkpoint_dir root (per-conversation subdirs) checkpointLoopName string checkpointStop chan struct{} checkpointDone chan struct{} @@ -155,6 +157,16 @@ func NewDB(dbPath string, logger *zap.Logger) (*DB, error) { return database, nil } +// SetEinoConversationDirs configures best-effort filesystem cleanup on DeleteConversation. +// plantaskBase is skills_root/plantask_rel (no conversation id); checkpointBase is checkpoint_dir root. +func (db *DB) SetEinoConversationDirs(plantaskBase, checkpointBase string) { + if db == nil { + return + } + db.einoPlantaskBaseDir = strings.TrimSpace(plantaskBase) + db.einoCheckpointBaseDir = strings.TrimSpace(checkpointBase) +} + // initTables 初始化数据库表 func (db *DB) initTables() error { // 创建对话表(last_react_input / last_react_output 存「代理消息轨迹」JSON 与助手摘要,列名保留以兼容已有库)