ConversationService.java
7.58 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
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 EventProjectionService projection;
public ConversationService(StringRedisTemplate redis, RedisChatMemoryStore memoryStore,
ObjectMapper mapper, LedgerService ledger,
EventProjectionService projection) {
this.redis = redis;
this.memoryStore = memoryStore;
this.mapper = mapper;
this.ledger = ledger;
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(':', '_');
// 总长必须装进 ai_chat_event/ai_op_queue 的 sConversationId varchar(96)(uid 可能是 36 位 uuid)
int max = Math.min(64, Math.max(1, 96 - uid.length() - 1));
if (local.length() > max) {
local = local.substring(0, max);
}
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);
// 旧 chat:state 键随 30 天 TTL 自然过期(StateService 已随意图门废除)
}
/**
* 会话历史 = 事件日志的前端投影(与 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;
}
}
}