ConversationService.java 8.11 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:";

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

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

    /**
     * 把客户端给的 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) {
        }
    }

    /** 该用户的会话列表,按最近更新倒序。 */
    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<>();
        for (Object v : all.values()) {
            try {
                @SuppressWarnings("unchecked")
                Map<String, Object> m = mapper.readValue(v.toString(), Map.class);
                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);
    }

    /**
     * 会话历史:优先从**会话账本**重放(含确定性路径的表单/澄清/提议/确认结果——消息记忆里没有这些);
     * 无账本的旧会话退回消息记忆。映射为 {role:user|ai, content}。
     */
    public List<Map<String, String>> history(String convId) {
        List<Map<String, Object>> events = ledger.events(convId);
        if (!events.isEmpty()) {
            List<Map<String, String>> out = new ArrayList<>();
            for (Map<String, Object> e : events) {
                String type = String.valueOf(e.get("type"));
                switch (type) {
                    case "user" -> out.add(Map.of("role", "user", "content", s(e.get("text"))));
                    case "assistant", "clarify" -> addAi(out, s(e.get("text")));
                    case "question" -> addAi(out, s(e.get("question")));
                    case "form" -> addAi(out, "【表单】新建" + s(e.get("entity")) + ":" + s(e.get("message")));
                    case "proposal" -> addAi(out, "【待确认】" + s(e.get("summary")));
                    case "confirm" -> addAi(out, ("executed".equals(s(e.get("status"))) ? "【已执行】" : "【执行失败】")
                            + s(e.get("description")) + blankOr(s(e.get("msg"))));
                    case "cancel" -> addAi(out, "【已取消】" + s(e.get("description")));
                    default -> { } // tool 等内部事件不进历史
                }
            }
            return out;
        }
        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 static void addAi(List<Map<String, String>> out, String text) {
        if (text != null && !text.isBlank()) {
            out.add(Map.of("role", "ai", "content", text));
        }
    }

    private static String s(Object o) {
        return o == null ? "" : o.toString();
    }

    private static String blankOr(String msg) {
        return msg == null || msg.isBlank() ? "" : ("(" + msg + ")");
    }

    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;
        }
    }
}