ConversationService.java 7.42 KB
package com.xly.service;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.xly.config.RedisChatMemoryStore;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.data.message.UserMessage;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * 会话(conversation)元数据管理 —— 支持「多条命名会话/用户」。
 *
 * <p>每用户一个会话列表存 Redis Hash {@code chat:convs:{userId}}(field=convId,value=JSON{id,title,updatedAt});
 * 消息本体存在 {@link RedisChatMemoryStore}({@code chat:mem:{convId}})。标题取自该会话的首条用户消息。
 */
@Service
public class ConversationService {

    private static final String CONVS_KEY = "chat:convs:";
    /** 懒清理宽限:新建会话可能短暂没有任何事件,updatedAt 在此窗口内的不清。 */
    private static final long CLEANUP_GRACE_MS = 3L * 24 * 3600 * 1000;

    private final StringRedisTemplate redis;
    private final RedisChatMemoryStore memoryStore;
    private final ObjectMapper mapper;
    private final LedgerService ledger;
    private final StateService state;
    private final EventProjectionService projection;

    public ConversationService(StringRedisTemplate redis, RedisChatMemoryStore memoryStore,
                               ObjectMapper mapper, LedgerService ledger, StateService state,
                               EventProjectionService projection) {
        this.redis = redis;
        this.memoryStore = memoryStore;
        this.mapper = mapper;
        this.ledger = ledger;
        this.state = state;
        this.projection = projection;
    }

    /**
     * 把客户端给的 conversationId 绑定到**服务端身份**:真实 id = {userId}:{客户端 id 的清洗值}。
     * 于是伪造别人的 conversationId 只会落到自己命名空间下的新会话,碰不到他人记忆/账本。
     */
    public String scopedId(com.xly.agent.AgentIdentity identity, String rawConvId) {
        String uid = identity == null || identity.userId() == null || identity.userId().isBlank()
                ? "anon" : identity.userId();
        String raw = rawConvId == null ? "" : rawConvId.trim();
        // 已经是本人命名空间下的 id → 原样用;否则取其局部名(去掉别人的前缀)再挂到自己名下
        if (raw.startsWith(uid + ":")) {
            return raw;
        }
        String local = raw.isEmpty() ? "default" : raw.replace(':', '_');
        if (local.length() > 64) {
            local = local.substring(0, 64);
        }
        return uid + ":" + local;
    }

    /** 新建一个空会话,返回 convId(已绑定该用户命名空间)。 */
    public String create(String userId) {
        String uid = userId == null || userId.isBlank() ? "anon" : userId;
        String convId = uid + ":c-" + System.currentTimeMillis()
                + "-" + Integer.toHexString((int) (Math.random() * 0xFFFFF));
        writeMeta(uid, convId, "新会话");
        return convId;
    }

    /** 会话是否属于该用户(元数据存在 + id 前缀绑定,双重判定)。 */
    public boolean owns(String userId, String convId) {
        if (userId == null || userId.isBlank() || convId == null || convId.isBlank()) {
            return false;
        }
        if (!convId.startsWith(userId + ":")) {
            return false;
        }
        return redis.opsForHash().hasKey(CONVS_KEY + userId, convId);
    }

    /** 每次对话时调用:会话不存在则建、标题空则用首条消息命名,并刷新 updatedAt。 */
    public void touch(String userId, String convId, String firstMsg) {
        Object existing = redis.opsForHash().get(CONVS_KEY + userId, convId);
        String title = null;
        if (existing != null) {
            try {
                title = mapper.readTree(existing.toString()).path("title").asText(null);
            } catch (Exception ignore) {
            }
        }
        if (title == null || title.isBlank() || "新会话".equals(title)) {
            title = deriveTitle(firstMsg);
        }
        writeMeta(userId, convId, title);
    }

    private void writeMeta(String userId, String convId, String title) {
        try {
            Map<String, Object> m = new LinkedHashMap<>();
            m.put("id", convId);
            m.put("title", title);
            m.put("updatedAt", System.currentTimeMillis());
            redis.opsForHash().put(CONVS_KEY + userId, convId, mapper.writeValueAsString(m));
        } catch (Exception ignore) {
        }
    }

    /** 该用户的会话列表,按最近更新倒序。内容键已过期(30 天 TTL)的项懒清理剔除。 */
    public List<Map<String, Object>> list(String userId) {
        Map<Object, Object> all = redis.opsForHash().entries(CONVS_KEY + userId);
        List<Map<String, Object>> out = new ArrayList<>();
        long now = System.currentTimeMillis();
        for (Object v : all.values()) {
            try {
                @SuppressWarnings("unchecked")
                Map<String, Object> m = mapper.readValue(v.toString(), Map.class);
                String convId = String.valueOf(m.get("id"));
                if (now - num(m.get("updatedAt")) > CLEANUP_GRACE_MS
                        && !ledger.exists(convId) && !memoryStore.exists(convId)) {
                    redis.opsForHash().delete(CONVS_KEY + userId, convId);
                    continue;
                }
                out.add(m);
            } catch (Exception ignore) {
            }
        }
        out.sort((a, b) -> Long.compare(num(b.get("updatedAt")), num(a.get("updatedAt"))));
        return out;
    }

    public void delete(String userId, String convId) {
        redis.opsForHash().delete(CONVS_KEY + userId, convId);
        memoryStore.deleteMessages(convId);
        ledger.delete(convId);
        state.delete(convId);
    }

    /**
     * 会话历史 = 事件日志的前端投影(与 LLM 上下文同源,见 {@link EventProjectionService});
     * 无日志的旧会话退回消息记忆兼容读取。映射为 {role:user|ai, content}。
     */
    public List<Map<String, String>> history(String convId) {
        List<Map<String, Object>> events = ledger.events(convId);
        if (!events.isEmpty()) {
            return projection.historyView(events);
        }
        List<Map<String, String>> out = new ArrayList<>();
        for (ChatMessage m : memoryStore.getMessages(convId)) {
            if (m instanceof UserMessage um && um.hasSingleText()) {
                out.add(Map.of("role", "user", "content", um.singleText()));
            } else if (m instanceof AiMessage am && !am.hasToolExecutionRequests()
                    && am.text() != null && !am.text().isBlank()) {
                out.add(Map.of("role", "ai", "content", am.text()));
            }
        }
        return out;
    }

    private String deriveTitle(String s) {
        if (s == null) {
            return "新会话";
        }
        String t = s.strip().replace("\n", " ");
        if (t.isEmpty()) {
            return "新会话";
        }
        return t.length() > 18 ? t.substring(0, 18) + "…" : t;
    }

    private long num(Object o) {
        try {
            return Long.parseLong(String.valueOf(o));
        } catch (Exception e) {
            return 0L;
        }
    }
}