Commit 42330ffc940cf4651afb5dd07ca50885a2159e1b

Authored by zichun
1 parent e32a1205

feat(B): multi-named conversations + Redis-persisted memory + sidebar

- RedisChatMemoryStore (ChatMemoryStore): persist per-conversation messages to Redis (chat:mem:{convId}, 30d TTL) -> survives restart; agent memory now Redis-backed via MessageWindowChatMemory.builder().chatMemoryStore(...)
- ConversationService: per-user conversation metadata (chat:convs:{userId} hash), title from first message, list/create/delete/history
- ConversationController: GET/POST/DELETE /api/agent/conversations + GET /{id}/messages
- AgentChatController: touch conversation on each turn
- chat.html: conversation sidebar (list/switch/new/delete), load history on switch, refresh on send

Verified: multi-turn memory recalls facts; after full restart, conversation list + history + memory all persist (agent still recalls prior-turn facts).
src/main/java/com/xly/config/AgentConfig.java
@@ -44,12 +44,17 @@ public class AgentConfig { @@ -44,12 +44,17 @@ public class AgentConfig {
44 @Bean 44 @Bean
45 public ReActAgent reActAgent(SystemPromptService systemPromptService, 45 public ReActAgent reActAgent(SystemPromptService systemPromptService,
46 KgQueryTool kgQueryTool, 46 KgQueryTool kgQueryTool,
47 - ErpReadTool erpReadTool) { 47 + ErpReadTool erpReadTool,
  48 + RedisChatMemoryStore memoryStore) {
48 String systemPrompt = systemPromptService.buildSystemPrompt(); 49 String systemPrompt = systemPromptService.buildSystemPrompt();
49 return AiServices.builder(ReActAgent.class) 50 return AiServices.builder(ReActAgent.class)
50 .streamingChatModel(agentStreamingModel()) 51 .streamingChatModel(agentStreamingModel())
51 .tools(kgQueryTool, erpReadTool) 52 .tools(kgQueryTool, erpReadTool)
52 - .chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(20)) 53 + .chatMemoryProvider(memoryId -> MessageWindowChatMemory.builder()
  54 + .id(memoryId)
  55 + .maxMessages(30)
  56 + .chatMemoryStore(memoryStore)
  57 + .build())
53 .systemMessageProvider(memoryId -> systemPrompt) 58 .systemMessageProvider(memoryId -> systemPrompt)
54 .build(); 59 .build();
55 } 60 }
src/main/java/com/xly/config/RedisChatMemoryStore.java 0 → 100644
  1 +package com.xly.config;
  2 +
  3 +import dev.langchain4j.data.message.ChatMessage;
  4 +import dev.langchain4j.data.message.ChatMessageDeserializer;
  5 +import dev.langchain4j.data.message.ChatMessageSerializer;
  6 +import dev.langchain4j.store.memory.chat.ChatMemoryStore;
  7 +import org.springframework.data.redis.core.StringRedisTemplate;
  8 +import org.springframework.stereotype.Component;
  9 +
  10 +import java.time.Duration;
  11 +import java.util.ArrayList;
  12 +import java.util.List;
  13 +
  14 +/**
  15 + * Redis 持久化的 ChatMemoryStore —— 会话记忆按 conversationId 存到 Redis,重启/换实例不丢,
  16 + * 也让「重登录后接回上一次会话」成立(会话状态按稳定身份+conversationId 存,而非按 token)。
  17 + *
  18 + * <p>键:{@code chat:mem:{conversationId}};值:LangChain4j 序列化后的消息 JSON。30 天 TTL。
  19 + */
  20 +@Component
  21 +public class RedisChatMemoryStore implements ChatMemoryStore {
  22 +
  23 + private static final String PREFIX = "chat:mem:";
  24 + private static final Duration TTL = Duration.ofDays(30);
  25 +
  26 + private final StringRedisTemplate redis;
  27 +
  28 + public RedisChatMemoryStore(StringRedisTemplate redis) {
  29 + this.redis = redis;
  30 + }
  31 +
  32 + @Override
  33 + public List<ChatMessage> getMessages(Object memoryId) {
  34 + String json = redis.opsForValue().get(PREFIX + memoryId);
  35 + if (json == null || json.isBlank()) {
  36 + return new ArrayList<>();
  37 + }
  38 + return ChatMessageDeserializer.messagesFromJson(json);
  39 + }
  40 +
  41 + @Override
  42 + public void updateMessages(Object memoryId, List<ChatMessage> messages) {
  43 + redis.opsForValue().set(PREFIX + memoryId, ChatMessageSerializer.messagesToJson(messages), TTL);
  44 + }
  45 +
  46 + @Override
  47 + public void deleteMessages(Object memoryId) {
  48 + redis.delete(PREFIX + memoryId);
  49 + }
  50 +}
src/main/java/com/xly/service/ConversationService.java 0 → 100644
  1 +package com.xly.service;
  2 +
  3 +import com.fasterxml.jackson.databind.ObjectMapper;
  4 +import com.xly.config.RedisChatMemoryStore;
  5 +import dev.langchain4j.data.message.AiMessage;
  6 +import dev.langchain4j.data.message.ChatMessage;
  7 +import dev.langchain4j.data.message.UserMessage;
  8 +import org.springframework.data.redis.core.StringRedisTemplate;
  9 +import org.springframework.stereotype.Service;
  10 +
  11 +import java.util.ArrayList;
  12 +import java.util.LinkedHashMap;
  13 +import java.util.List;
  14 +import java.util.Map;
  15 +
  16 +/**
  17 + * 会话(conversation)元数据管理 —— 支持「多条命名会话/用户」。
  18 + *
  19 + * <p>每用户一个会话列表存 Redis Hash {@code chat:convs:{userId}}(field=convId,value=JSON{id,title,updatedAt});
  20 + * 消息本体存在 {@link RedisChatMemoryStore}({@code chat:mem:{convId}})。标题取自该会话的首条用户消息。
  21 + */
  22 +@Service
  23 +public class ConversationService {
  24 +
  25 + private static final String CONVS_KEY = "chat:convs:";
  26 +
  27 + private final StringRedisTemplate redis;
  28 + private final RedisChatMemoryStore memoryStore;
  29 + private final ObjectMapper mapper;
  30 +
  31 + public ConversationService(StringRedisTemplate redis, RedisChatMemoryStore memoryStore, ObjectMapper mapper) {
  32 + this.redis = redis;
  33 + this.memoryStore = memoryStore;
  34 + this.mapper = mapper;
  35 + }
  36 +
  37 + /** 新建一个空会话,返回 convId。 */
  38 + public String create(String userId) {
  39 + String convId = "c-" + System.currentTimeMillis() + "-" + Integer.toHexString((int) (Math.random() * 0xFFFFF));
  40 + writeMeta(userId, convId, "新会话");
  41 + return convId;
  42 + }
  43 +
  44 + /** 每次对话时调用:会话不存在则建、标题空则用首条消息命名,并刷新 updatedAt。 */
  45 + public void touch(String userId, String convId, String firstMsg) {
  46 + Object existing = redis.opsForHash().get(CONVS_KEY + userId, convId);
  47 + String title = null;
  48 + if (existing != null) {
  49 + try {
  50 + title = mapper.readTree(existing.toString()).path("title").asText(null);
  51 + } catch (Exception ignore) {
  52 + }
  53 + }
  54 + if (title == null || title.isBlank() || "新会话".equals(title)) {
  55 + title = deriveTitle(firstMsg);
  56 + }
  57 + writeMeta(userId, convId, title);
  58 + }
  59 +
  60 + private void writeMeta(String userId, String convId, String title) {
  61 + try {
  62 + Map<String, Object> m = new LinkedHashMap<>();
  63 + m.put("id", convId);
  64 + m.put("title", title);
  65 + m.put("updatedAt", System.currentTimeMillis());
  66 + redis.opsForHash().put(CONVS_KEY + userId, convId, mapper.writeValueAsString(m));
  67 + } catch (Exception ignore) {
  68 + }
  69 + }
  70 +
  71 + /** 该用户的会话列表,按最近更新倒序。 */
  72 + public List<Map<String, Object>> list(String userId) {
  73 + Map<Object, Object> all = redis.opsForHash().entries(CONVS_KEY + userId);
  74 + List<Map<String, Object>> out = new ArrayList<>();
  75 + for (Object v : all.values()) {
  76 + try {
  77 + @SuppressWarnings("unchecked")
  78 + Map<String, Object> m = mapper.readValue(v.toString(), Map.class);
  79 + out.add(m);
  80 + } catch (Exception ignore) {
  81 + }
  82 + }
  83 + out.sort((a, b) -> Long.compare(num(b.get("updatedAt")), num(a.get("updatedAt"))));
  84 + return out;
  85 + }
  86 +
  87 + public void delete(String userId, String convId) {
  88 + redis.opsForHash().delete(CONVS_KEY + userId, convId);
  89 + memoryStore.deleteMessages(convId);
  90 + }
  91 +
  92 + /** 会话历史,映射为 {role:user|ai, content}。跳过系统消息、工具调用中间消息、工具结果。 */
  93 + public List<Map<String, String>> history(String convId) {
  94 + List<Map<String, String>> out = new ArrayList<>();
  95 + for (ChatMessage m : memoryStore.getMessages(convId)) {
  96 + if (m instanceof UserMessage um && um.hasSingleText()) {
  97 + out.add(Map.of("role", "user", "content", um.singleText()));
  98 + } else if (m instanceof AiMessage am && !am.hasToolExecutionRequests()
  99 + && am.text() != null && !am.text().isBlank()) {
  100 + out.add(Map.of("role", "ai", "content", am.text()));
  101 + }
  102 + }
  103 + return out;
  104 + }
  105 +
  106 + private String deriveTitle(String s) {
  107 + if (s == null) {
  108 + return "新会话";
  109 + }
  110 + String t = s.strip().replace("\n", " ");
  111 + if (t.isEmpty()) {
  112 + return "新会话";
  113 + }
  114 + return t.length() > 18 ? t.substring(0, 18) + "…" : t;
  115 + }
  116 +
  117 + private long num(Object o) {
  118 + try {
  119 + return Long.parseLong(String.valueOf(o));
  120 + } catch (Exception e) {
  121 + return 0L;
  122 + }
  123 + }
  124 +}
src/main/java/com/xly/web/AgentChatController.java
@@ -2,6 +2,7 @@ package com.xly.web; @@ -2,6 +2,7 @@ package com.xly.web;
2 2
3 import com.fasterxml.jackson.databind.ObjectMapper; 3 import com.fasterxml.jackson.databind.ObjectMapper;
4 import com.xly.agent.ReActAgent; 4 import com.xly.agent.ReActAgent;
  5 +import com.xly.service.ConversationService;
5 import dev.langchain4j.service.TokenStream; 6 import dev.langchain4j.service.TokenStream;
6 import org.slf4j.Logger; 7 import org.slf4j.Logger;
7 import org.slf4j.LoggerFactory; 8 import org.slf4j.LoggerFactory;
@@ -36,11 +37,13 @@ public class AgentChatController { @@ -36,11 +37,13 @@ public class AgentChatController {
36 37
37 private final ReActAgent agent; 38 private final ReActAgent agent;
38 private final ObjectMapper mapper; 39 private final ObjectMapper mapper;
  40 + private final ConversationService conversations;
39 private final ExecutorService exec = Executors.newCachedThreadPool(); 41 private final ExecutorService exec = Executors.newCachedThreadPool();
40 42
41 - public AgentChatController(ReActAgent agent, ObjectMapper mapper) { 43 + public AgentChatController(ReActAgent agent, ObjectMapper mapper, ConversationService conversations) {
42 this.agent = agent; 44 this.agent = agent;
43 this.mapper = mapper; 45 this.mapper = mapper;
  46 + this.conversations = conversations;
44 } 47 }
45 48
46 /** 前端请求体:身份字段透传(M1 只用 userid + conversationId + text)。 */ 49 /** 前端请求体:身份字段透传(M1 只用 userid + conversationId + text)。 */
@@ -58,6 +61,9 @@ public class AgentChatController { @@ -58,6 +61,9 @@ public class AgentChatController {
58 ? req.conversationId 61 ? req.conversationId
59 : ((req.userid == null ? "anon" : req.userid) + ":default"); 62 : ((req.userid == null ? "anon" : req.userid) + ":default");
60 63
  64 + // 登记/更新会话(用于多会话侧栏;标题取首条消息)
  65 + conversations.touch(req.userid == null ? "anon" : req.userid, convId, userInput);
  66 +
61 exec.submit(() -> { 67 exec.submit(() -> {
62 try { 68 try {
63 TokenStream ts = agent.chat(convId, userInput); 69 TokenStream ts = agent.chat(convId, userInput);
src/main/java/com/xly/web/ConversationController.java 0 → 100644
  1 +package com.xly.web;
  2 +
  3 +import com.xly.service.ConversationService;
  4 +import org.springframework.web.bind.annotation.DeleteMapping;
  5 +import org.springframework.web.bind.annotation.GetMapping;
  6 +import org.springframework.web.bind.annotation.PathVariable;
  7 +import org.springframework.web.bind.annotation.PostMapping;
  8 +import org.springframework.web.bind.annotation.RequestBody;
  9 +import org.springframework.web.bind.annotation.RequestMapping;
  10 +import org.springframework.web.bind.annotation.RequestParam;
  11 +import org.springframework.web.bind.annotation.RestController;
  12 +
  13 +import java.util.List;
  14 +import java.util.Map;
  15 +
  16 +/**
  17 + * 会话管理 REST —— 支撑前端的「多命名会话」侧栏(列表 / 新建 / 删除 / 历史)。
  18 + */
  19 +@RestController
  20 +@RequestMapping("/api/agent/conversations")
  21 +public class ConversationController {
  22 +
  23 + private final ConversationService conversations;
  24 +
  25 + public ConversationController(ConversationService conversations) {
  26 + this.conversations = conversations;
  27 + }
  28 +
  29 + /** 列出某用户的会话(最近更新在前)。 */
  30 + @GetMapping
  31 + public List<Map<String, Object>> list(@RequestParam("userid") String userid) {
  32 + return conversations.list(userid);
  33 + }
  34 +
  35 + /** 新建空会话,返回 conversationId。 */
  36 + @PostMapping
  37 + public Map<String, String> create(@RequestBody Map<String, String> body) {
  38 + String userid = body.getOrDefault("userid", "anon");
  39 + return Map.of("conversationId", conversations.create(userid));
  40 + }
  41 +
  42 + /** 删除会话(含其消息记忆)。 */
  43 + @DeleteMapping("/{convId}")
  44 + public Map<String, Object> delete(@PathVariable("convId") String convId,
  45 + @RequestParam("userid") String userid) {
  46 + conversations.delete(userid, convId);
  47 + return Map.of("ok", true);
  48 + }
  49 +
  50 + /** 会话历史消息(用于切换会话时回填)。 */
  51 + @GetMapping("/{convId}/messages")
  52 + public List<Map<String, String>> messages(@PathVariable("convId") String convId) {
  53 + return conversations.history(convId);
  54 + }
  55 +}
src/main/resources/templates/chat.html
@@ -423,11 +423,16 @@ @@ -423,11 +423,16 @@
423 <option value="process">小羚羊印刷行业大模型</option> 423 <option value="process">小羚羊印刷行业大模型</option>
424 <option value="general">qwen2.5:14b</option> 424 <option value="general">qwen2.5:14b</option>
425 </select> 425 </select>
426 - <button class="model-selector" onclick="clearChat()">清空对话</button> 426 + <button class="model-selector" onclick="newConversation()">新建会话</button>
427 </div> 427 </div>
428 </div> 428 </div>
429 429
430 <div class="chat-body"> 430 <div class="chat-body">
  431 + <div class="sidebar">
  432 + <div class="sidebar-title">会话</div>
  433 + <button class="preset-question" style="text-align:center;font-weight:600;background:#eef;" onclick="newConversation()">+ 新建会话</button>
  434 + <div id="convList"></div>
  435 + </div>
431 <div class="chat-main"> 436 <div class="chat-main">
432 <div class="messages-container"> 437 <div class="messages-container">
433 <div class="chat-messages" id="chatMessages"> 438 <div class="chat-messages" id="chatMessages">
@@ -495,8 +500,88 @@ @@ -495,8 +500,88 @@
495 $('#messageInput').focus(); 500 $('#messageInput').focus();
496 bindKeyboardEvents(); 501 bindKeyboardEvents();
497 ensureInputAtBottom(); 502 ensureInputAtBottom();
  503 + initConversations();
498 }); 504 });
499 505
  506 + // ====================== 多命名会话(侧栏) ======================
  507 + function convApi(path, opts) {
  508 + return fetch(CONFIG.backendUrl + '/api/agent/conversations' + path, opts || {});
  509 + }
  510 +
  511 + async function initConversations() {
  512 + try {
  513 + const res = await convApi('?userid=' + encodeURIComponent(userid));
  514 + const list = await res.json();
  515 + renderConvList(list);
  516 + if (list && list.length > 0) {
  517 + await switchConversation(list[0].id);
  518 + }
  519 + } catch (e) { console.log('会话初始化失败', e); }
  520 + }
  521 +
  522 + async function loadConversations() {
  523 + try {
  524 + const res = await convApi('?userid=' + encodeURIComponent(userid));
  525 + renderConvList(await res.json());
  526 + } catch (e) { /* ignore */ }
  527 + }
  528 +
  529 + function renderConvList(list) {
  530 + const box = $('#convList');
  531 + box.empty();
  532 + (list || []).forEach(c => {
  533 + const isActive = c.id === conversationId;
  534 + const item = $('<div class="preset-question conv-item"></div>')
  535 + .css({display:'flex', justifyContent:'space-between', alignItems:'center', gap:'6px'});
  536 + if (isActive) item.css({background:'#667eea', color:'#fff', borderColor:'#667eea'});
  537 + const title = $('<span></span>').text(c.title || '会话')
  538 + .css({flex:'1', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap'});
  539 + const del = $('<span title="删除">×</span>').css({cursor:'pointer', fontWeight:'bold', opacity:'0.6'});
  540 + title.on('click', () => switchConversation(c.id));
  541 + del.on('click', (ev) => { ev.stopPropagation(); deleteConversation(c.id); });
  542 + item.append(title).append(del);
  543 + box.append(item);
  544 + });
  545 + }
  546 +
  547 + async function switchConversation(convId) {
  548 + if (convId === conversationId) return;
  549 + conversationId = convId;
  550 + $('#chatMessages').empty();
  551 + try {
  552 + const res = await convApi('/' + convId + '/messages');
  553 + const msgs = await res.json();
  554 + (msgs || []).forEach(m => addMessage(m.content, m.role === 'user' ? 'user' : 'ai'));
  555 + if (!msgs || msgs.length === 0) addMessage('(空会话)请提问。', 'ai');
  556 + } catch (e) { console.log('加载历史失败', e); }
  557 + renderConvList(await (await convApi('?userid=' + encodeURIComponent(userid))).json());
  558 + ensureInputAtBottom();
  559 + }
  560 +
  561 + async function newConversation() {
  562 + try {
  563 + const res = await convApi('', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({userid: userid}) });
  564 + conversationId = (await res.json()).conversationId;
  565 + } catch (e) {
  566 + conversationId = 'c-' + Date.now() + '-' + Math.random().toString(36).slice(2,8);
  567 + }
  568 + $('#chatMessages').empty();
  569 + addMessage('新会话已开始,请提问。', 'ai');
  570 + loadConversations();
  571 + $('#messageInput').focus();
  572 + }
  573 +
  574 + async function deleteConversation(convId) {
  575 + if (!confirm('删除这个会话?')) return;
  576 + try { await convApi('/' + convId + '?userid=' + encodeURIComponent(userid), { method:'DELETE' }); } catch (e) {}
  577 + if (convId === conversationId) {
  578 + conversationId = 'c-' + Date.now() + '-' + Math.random().toString(36).slice(2,8);
  579 + $('#chatMessages').empty();
  580 + addMessage('会话已删除,可新建或选择其它会话。', 'ai');
  581 + }
  582 + loadConversations();
  583 + }
  584 +
500 function generateRandomString(length) { 585 function generateRandomString(length) {
501 const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 586 const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
502 let result = ''; 587 let result = '';
@@ -645,6 +730,7 @@ @@ -645,6 +730,7 @@
645 button.prop('disabled', false); 730 button.prop('disabled', false);
646 input.focus(); 731 input.focus();
647 scrollToBottom(); 732 scrollToBottom();
  733 + loadConversations();
648 } 734 }
649 } 735 }
650 736