From 4b8ee8936c59f4378643f7c7024c9c20a1cedfd1 Mon Sep 17 00:00:00 2001 From: welsir <1824379011@qq.com> Date: Thu, 14 Aug 2025 01:34:07 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timemachinelab/core/session/Session.java | 3 + .../application/ConversationService.java | 91 +++++++++++++++++++ .../domain/entity/ConversationSession.java | 50 ++++++++++ .../web/ConversationController.java | 84 +++++++++++++++++ .../web/dto/MessageRequest.java | 19 ++++ .../web/dto/MessageResponse.java | 33 +++++++ 6 files changed, 280 insertions(+) create mode 100644 prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/application/ConversationService.java create mode 100644 prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/domain/entity/ConversationSession.java create mode 100644 prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/infrastructure/web/ConversationController.java create mode 100644 prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/infrastructure/web/dto/MessageRequest.java create mode 100644 prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/infrastructure/web/dto/MessageResponse.java diff --git a/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/Session.java b/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/Session.java index e84b984..64d3149 100644 --- a/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/Session.java +++ b/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/Session.java @@ -1,4 +1,7 @@ package io.github.timemachinelab.core.session; public class Session { + + + } diff --git a/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/application/ConversationService.java b/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/application/ConversationService.java new file mode 100644 index 0000000..8473f7c --- /dev/null +++ b/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/application/ConversationService.java @@ -0,0 +1,91 @@ +package io.github.timemachinelab.core.session.application; + +import io.github.timemachinelab.core.qatree.*; +import io.github.timemachinelab.core.session.domain.entity.ConversationSession; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class ConversationService { + + private final QaTreeDomain qaTreeDomain; + private final Map sessions = new ConcurrentHashMap<>(); + + public ConversationSession createSession(String userId, String initialQuestion) { + ConversationSession session = new ConversationSession(userId, initialQuestion); + sessions.put(session.getSessionId(), session); + return session; + } + + public ConversationSession getSession(String sessionId) { + return sessions.get(sessionId); + } + + public void addUserAnswer(String sessionId, String answer) { + ConversationSession session = sessions.get(sessionId); + if (session != null) { + session.addUserAnswer(answer); + } + } + + public String addAIQuestion(String sessionId, String question) { + ConversationSession session = sessions.get(sessionId); + if (session != null) { + CommonQA newQA = new CommonQA(); + newQA.setQuestion(question); + + qaTreeDomain.appendNode(session.getQaTree(), session.getCurrentNodeId(), newQA); + + QaTreeNode newNode = findNewlyAddedNode(session, newQA); + if (newNode != null) { + session.updateCurrentNode(newNode.getId()); + return newNode.getId(); + } + } + return null; + } + + public String addAISelectionQuestion(String sessionId, String question, String[] options) { + ConversationSession session = sessions.get(sessionId); + if (session != null) { + SelectionQA selectionQA = new SelectionQA(); + // selectionQA.setQuestion(question); + // selectionQA.setOptions(options); + + qaTreeDomain.appendNode(session.getQaTree(), session.getCurrentNodeId(), selectionQA); + + QaTreeNode newNode = findNewlyAddedNode(session, selectionQA); + if (newNode != null) { + session.updateCurrentNode(newNode.getId()); + return newNode.getId(); + } + } + return null; + } + + public void handleUserSelection(String sessionId, String questionId, Integer selectedOption) { + ConversationSession session = sessions.get(sessionId); + if (session != null) { + QaTreeNode node = session.getQaTree().getNodeById(questionId); + if (node != null && node.getQa() instanceof SelectionQA) { + SelectionQA selectionQA = (SelectionQA) node.getQa(); + // selectionQA.setSelectedOption(selectedOption); + selectionQA.updateTimestamp(); + session.updateCurrentNode(questionId); + } + } + } + + private QaTreeNode findNewlyAddedNode(ConversationSession session, QA targetQA) { + for (QaTreeNode child : session.getCurrentNode().getChildren().values()) { + if (child.getQa() == targetQA) { + return child; + } + } + return null; + } +} \ No newline at end of file diff --git a/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/domain/entity/ConversationSession.java b/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/domain/entity/ConversationSession.java new file mode 100644 index 0000000..022419d --- /dev/null +++ b/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/domain/entity/ConversationSession.java @@ -0,0 +1,50 @@ +package io.github.timemachinelab.core.session.domain.entity; + +import io.github.timemachinelab.core.qatree.*; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +public class ConversationSession { + + private final String sessionId; + private final String userId; + private final QaTree qaTree; + private String currentNodeId; + private final LocalDateTime createTime; + private LocalDateTime updateTime; + + public ConversationSession(String userId, String initialQuestion) { + this.sessionId = UUID.randomUUID().toString(); + this.userId = userId; + this.createTime = LocalDateTime.now(); + this.updateTime = LocalDateTime.now(); + + CommonQA startQA = new CommonQA(); + startQA.setQuestion(initialQuestion); + QaTreeNode rootNode = new QaTreeNode(startQA); + this.qaTree = new QaTree(rootNode); + this.currentNodeId = rootNode.getId(); + } + + public void updateCurrentNode(String nodeId) { + this.currentNodeId = nodeId; + this.updateTime = LocalDateTime.now(); + } + + public void addUserAnswer(String answer) { + QaTreeNode currentNode = qaTree.getNodeById(currentNodeId); + if (currentNode != null && currentNode.getQa() instanceof CommonQA) { + CommonQA qa = (CommonQA) currentNode.getQa(); + qa.setAnswer(answer); + qa.updateTimestamp(); + this.updateTime = LocalDateTime.now(); + } + } + + public QaTreeNode getCurrentNode() { + return qaTree.getNodeById(currentNodeId); + } +} \ No newline at end of file diff --git a/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/infrastructure/web/ConversationController.java b/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/infrastructure/web/ConversationController.java new file mode 100644 index 0000000..3ee2736 --- /dev/null +++ b/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/infrastructure/web/ConversationController.java @@ -0,0 +1,84 @@ +package io.github.timemachinelab.core.session.infrastructure.web; + +import io.github.timemachinelab.core.session.application.ConversationService; +import io.github.timemachinelab.core.session.domain.entity.ConversationSession; +import io.github.timemachinelab.core.session.infrastructure.web.dto.MessageRequest; +import io.github.timemachinelab.core.session.infrastructure.web.dto.MessageResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/conversation") +@RequiredArgsConstructor +public class ConversationController { + + private final ConversationService conversationService; + private final Map sseEmitters = new ConcurrentHashMap<>(); + + @PostMapping("/start") + public ConversationSession startConversation(@RequestParam String userId, + @RequestParam String initialQuestion) { + return conversationService.createSession(userId, initialQuestion); + } + + @GetMapping(value = "/sse/{sessionId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter streamConversation(@PathVariable String sessionId) { + SseEmitter emitter = new SseEmitter(Long.MAX_VALUE); + sseEmitters.put(sessionId, emitter); + + emitter.onCompletion(() -> sseEmitters.remove(sessionId)); + emitter.onTimeout(() -> sseEmitters.remove(sessionId)); + emitter.onError((ex) -> sseEmitters.remove(sessionId)); + + return emitter; + } + + @PostMapping("/message") + public void addMessage(@RequestBody MessageRequest request) { + switch (request.getType()) { + case USER_TEXT: + conversationService.addUserAnswer(request.getSessionId(), request.getContent()); + sendSseMessage(request.getSessionId(), MessageResponse.userAnswer(null, request.getContent())); + break; + + case USER_SELECTION: + conversationService.handleUserSelection(request.getSessionId(), request.getQuestionId(), request.getSelectedOption()); + sendSseMessage(request.getSessionId(), MessageResponse.userAnswer(request.getQuestionId(), "选择了选项: " + request.getSelectedOption())); + break; + + case AI_QUESTION: + String nodeId = conversationService.addAIQuestion(request.getSessionId(), request.getContent()); + sendSseMessage(request.getSessionId(), MessageResponse.aiQuestion(nodeId, request.getContent())); + break; + + case AI_SELECTION_QUESTION: + String selectionNodeId = conversationService.addAISelectionQuestion(request.getSessionId(), request.getContent(), new String[]{"简洁风格", "现代风格", "经典风格"}); + sendSseMessage(request.getSessionId(), MessageResponse.aiSelectionQuestion(selectionNodeId, request.getContent(), new String[]{"简洁风格", "现代风格", "经典风格"})); + break; + } + } + + @GetMapping("/session/{sessionId}") + public ConversationSession getSession(@PathVariable String sessionId) { + return conversationService.getSession(sessionId); + } + + private void sendSseMessage(String sessionId, MessageResponse response) { + SseEmitter emitter = sseEmitters.get(sessionId); + if (emitter != null) { + try { + emitter.send(SseEmitter.event() + .name("message") + .data(response)); + } catch (IOException e) { + sseEmitters.remove(sessionId); + } + } + } +} \ No newline at end of file diff --git a/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/infrastructure/web/dto/MessageRequest.java b/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/infrastructure/web/dto/MessageRequest.java new file mode 100644 index 0000000..926deea --- /dev/null +++ b/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/infrastructure/web/dto/MessageRequest.java @@ -0,0 +1,19 @@ +package io.github.timemachinelab.core.session.infrastructure.web.dto; + +import lombok.Data; + +@Data +public class MessageRequest { + private String sessionId; + private String content; + private MessageType type; + private String questionId; + private Integer selectedOption; + + public enum MessageType { + USER_TEXT, + USER_SELECTION, + AI_QUESTION, + AI_SELECTION_QUESTION + } +} \ No newline at end of file diff --git a/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/infrastructure/web/dto/MessageResponse.java b/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/infrastructure/web/dto/MessageResponse.java new file mode 100644 index 0000000..672810e --- /dev/null +++ b/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/infrastructure/web/dto/MessageResponse.java @@ -0,0 +1,33 @@ +package io.github.timemachinelab.core.session.infrastructure.web.dto; + +import lombok.Data; +import lombok.AllArgsConstructor; + +@Data +@AllArgsConstructor +public class MessageResponse { + private String nodeId; + private String content; + private MessageType type; + private String[] options; + private Long timestamp; + + public enum MessageType { + USER_ANSWER, + AI_QUESTION, + AI_SELECTION_QUESTION, + SYSTEM_INFO + } + + public static MessageResponse userAnswer(String nodeId, String content) { + return new MessageResponse(nodeId, content, MessageType.USER_ANSWER, null, System.currentTimeMillis()); + } + + public static MessageResponse aiQuestion(String nodeId, String content) { + return new MessageResponse(nodeId, content, MessageType.AI_QUESTION, null, System.currentTimeMillis()); + } + + public static MessageResponse aiSelectionQuestion(String nodeId, String content, String[] options) { + return new MessageResponse(nodeId, content, MessageType.AI_SELECTION_QUESTION, options, System.currentTimeMillis()); + } +} \ No newline at end of file From 4ad19125d9b6fdca5224e5e5a837fec24cb24b95 Mon Sep 17 00:00:00 2001 From: welsir <1824379011@qq.com> Date: Fri, 15 Aug 2025 00:56:52 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E8=81=94=E8=B0=83?= =?UTF-8?q?=E5=89=8D=E7=AB=AFsse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timemachinelab/config/CorsConfig.java | 50 +++ .../application/ConversationService.java | 128 ++++--- .../domain/entity/ConversationSession.java | 41 +-- .../ai/ConversationOperation.java | 191 ++++++++++ .../web/ConversationController.java | 42 +-- .../web/dto/MessageRequest.java | 3 - .../web/dto/MessageResponse.java | 8 +- .../demo/SSEDemoController.java | 170 +++++++++ .../sfchain/controller/AIModelController.java | 1 - .../controller/AIOperationController.java | 1 - .../controller/AISystemController.java | 1 - .../src/main/resources/static/sse-demo.html | 340 ++++++++++++++++++ prompto-lab-ui/src/components/AIChatPage.vue | 213 +++++++++-- .../src/services/conversationApi.ts | 99 +++++ prompto-lab-ui/src/views/ApiConfigView.vue | 24 +- prompto-lab-ui/vite.config.ts | 3 +- 16 files changed, 1156 insertions(+), 159 deletions(-) create mode 100644 prompto-lab-app/src/main/java/io/github/timemachinelab/config/CorsConfig.java create mode 100644 prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/infrastructure/ai/ConversationOperation.java create mode 100644 prompto-lab-app/src/main/java/io/github/timemachinelab/demo/SSEDemoController.java create mode 100644 prompto-lab-app/src/main/resources/static/sse-demo.html create mode 100644 prompto-lab-ui/src/services/conversationApi.ts diff --git a/prompto-lab-app/src/main/java/io/github/timemachinelab/config/CorsConfig.java b/prompto-lab-app/src/main/java/io/github/timemachinelab/config/CorsConfig.java new file mode 100644 index 0000000..6f93d68 --- /dev/null +++ b/prompto-lab-app/src/main/java/io/github/timemachinelab/config/CorsConfig.java @@ -0,0 +1,50 @@ +package io.github.timemachinelab.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.Arrays; + +@Configuration +public class CorsConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOriginPatterns("http://localhost:*", "http://127.0.0.1:*") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + + // 允许的源 + configuration.setAllowedOriginPatterns(Arrays.asList("http://localhost:*", "http://127.0.0.1:*")); + + // 允许的HTTP方法 + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + + // 允许的请求头 + configuration.setAllowedHeaders(Arrays.asList("*")); + + // 允许携带凭证 + configuration.setAllowCredentials(true); + + // 预检请求的缓存时间 + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + + return source; + } +} \ No newline at end of file diff --git a/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/application/ConversationService.java b/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/application/ConversationService.java index 8473f7c..95959b7 100644 --- a/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/application/ConversationService.java +++ b/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/application/ConversationService.java @@ -1,22 +1,30 @@ package io.github.timemachinelab.core.session.application; -import io.github.timemachinelab.core.qatree.*; import io.github.timemachinelab.core.session.domain.entity.ConversationSession; +import io.github.timemachinelab.core.session.infrastructure.web.dto.MessageResponse; +import io.github.timemachinelab.core.session.infrastructure.ai.ConversationOperation; +import io.github.timemachinelab.sfchain.core.AIService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.util.concurrent.ConcurrentHashMap; import java.util.Map; +import java.util.List; +import java.util.ArrayList; +import java.util.function.Consumer; @Service @RequiredArgsConstructor +@Slf4j public class ConversationService { - private final QaTreeDomain qaTreeDomain; + private final AIService aiService; + private final Map sessions = new ConcurrentHashMap<>(); - public ConversationSession createSession(String userId, String initialQuestion) { - ConversationSession session = new ConversationSession(userId, initialQuestion); + public ConversationSession createSession(String userId) { + ConversationSession session = new ConversationSession(userId); sessions.put(session.getSessionId(), session); return session; } @@ -25,67 +33,79 @@ public ConversationSession getSession(String sessionId) { return sessions.get(sessionId); } - public void addUserAnswer(String sessionId, String answer) { + public void processUserMessage(String sessionId, String userMessage, Consumer sseCallback) { ConversationSession session = sessions.get(sessionId); - if (session != null) { - session.addUserAnswer(answer); + if (session == null) { + log.warn("会话不存在: {}", sessionId); + return; } + + // 1. 添加用户消息到会话历史 + session.addMessage(userMessage, "user"); + + // 2. 发送用户消息确认 + sseCallback.accept(MessageResponse.userAnswer("user_" + System.currentTimeMillis(), userMessage)); + + // 3. 调用AI服务获取回复 + processAIResponse(session, userMessage, sseCallback); } - public String addAIQuestion(String sessionId, String question) { - ConversationSession session = sessions.get(sessionId); - if (session != null) { - CommonQA newQA = new CommonQA(); - newQA.setQuestion(question); + private void processAIResponse(ConversationSession session, String userMessage, Consumer sseCallback) { + try { + // 构建对话历史 + List history = buildConversationHistory(session); - qaTreeDomain.appendNode(session.getQaTree(), session.getCurrentNodeId(), newQA); + // 创建AI请求 + ConversationOperation.ConversationRequest request = new ConversationOperation.ConversationRequest( + session.getSessionId(), + "current", + userMessage + ); + request.setConversationHistory(history); - QaTreeNode newNode = findNewlyAddedNode(session, newQA); - if (newNode != null) { - session.updateCurrentNode(newNode.getId()); - return newNode.getId(); - } - } - return null; - } - - public String addAISelectionQuestion(String sessionId, String question, String[] options) { - ConversationSession session = sessions.get(sessionId); - if (session != null) { - SelectionQA selectionQA = new SelectionQA(); - // selectionQA.setQuestion(question); - // selectionQA.setOptions(options); + // 调用AI服务 + ConversationOperation.ConversationResponse aiResponse = aiService.execute("CONVERSATION_OP", request); + log.info("AI服务调用成功: {}", aiResponse); + + // 添加AI回复到会话历史 + session.addMessage(aiResponse.getAnswer(), "assistant"); - qaTreeDomain.appendNode(session.getQaTree(), session.getCurrentNodeId(), selectionQA); + // 根据响应类型处理AI回复 + String nodeId = "ai_" + System.currentTimeMillis(); + sseCallback.accept(MessageResponse.aiAnswer("ai_" + System.currentTimeMillis(), aiResponse.getAnswer())); +// if (aiResponse.getResponseType() == ConversationOperation.ResponseType.SELECTION) { +// // 选择题类型 +// sseCallback.accept(MessageResponse.aiSelectionQuestion(nodeId, aiResponse.getAnswer(), aiResponse.getOptions())); +// } else { +// // 普通文本回复 +// sseCallback.accept(MessageResponse.aiQuestion(nodeId, aiResponse.getAnswer())); +// } - QaTreeNode newNode = findNewlyAddedNode(session, selectionQA); - if (newNode != null) { - session.updateCurrentNode(newNode.getId()); - return newNode.getId(); - } + } catch (Exception e) { + log.error("AI服务调用失败: {}", e.getMessage(), e); + // 降级处理 + String fallbackResponse = "抱歉,我暂时无法处理您的请求,请稍后再试。"; + session.addMessage(fallbackResponse, "assistant"); + String nodeId = "ai_" + System.currentTimeMillis(); + sseCallback.accept(MessageResponse.aiQuestion(nodeId, fallbackResponse)); } - return null; } - public void handleUserSelection(String sessionId, String questionId, Integer selectedOption) { - ConversationSession session = sessions.get(sessionId); - if (session != null) { - QaTreeNode node = session.getQaTree().getNodeById(questionId); - if (node != null && node.getQa() instanceof SelectionQA) { - SelectionQA selectionQA = (SelectionQA) node.getQa(); - // selectionQA.setSelectedOption(selectedOption); - selectionQA.updateTimestamp(); - session.updateCurrentNode(questionId); - } - } - } - - private QaTreeNode findNewlyAddedNode(ConversationSession session, QA targetQA) { - for (QaTreeNode child : session.getCurrentNode().getChildren().values()) { - if (child.getQa() == targetQA) { - return child; - } + private List buildConversationHistory(ConversationSession session) { + List history = new ArrayList<>(); + + // 从会话消息构建对话历史 + for (ConversationSession.ConversationMessage message : session.getMessages()) { + ConversationOperation.ConversationHistory historyItem = new ConversationOperation.ConversationHistory( + message.getRole(), + message.getContent(), + message.getRole() + "_" + message.getTimestamp().toString() + ); + history.add(historyItem); } - return null; + + return history; } + + } \ No newline at end of file diff --git a/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/domain/entity/ConversationSession.java b/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/domain/entity/ConversationSession.java index 022419d..9e77f51 100644 --- a/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/domain/entity/ConversationSession.java +++ b/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/domain/entity/ConversationSession.java @@ -1,50 +1,45 @@ package io.github.timemachinelab.core.session.domain.entity; -import io.github.timemachinelab.core.qatree.*; import lombok.Getter; import java.time.LocalDateTime; import java.util.UUID; +import java.util.List; +import java.util.ArrayList; @Getter public class ConversationSession { private final String sessionId; private final String userId; - private final QaTree qaTree; - private String currentNodeId; private final LocalDateTime createTime; private LocalDateTime updateTime; + private final List messages; - public ConversationSession(String userId, String initialQuestion) { + public ConversationSession(String userId) { this.sessionId = UUID.randomUUID().toString(); this.userId = userId; this.createTime = LocalDateTime.now(); this.updateTime = LocalDateTime.now(); - - CommonQA startQA = new CommonQA(); - startQA.setQuestion(initialQuestion); - QaTreeNode rootNode = new QaTreeNode(startQA); - this.qaTree = new QaTree(rootNode); - this.currentNodeId = rootNode.getId(); + this.messages = new ArrayList<>(); } - public void updateCurrentNode(String nodeId) { - this.currentNodeId = nodeId; + public void addMessage(String content, String role) { + ConversationMessage message = new ConversationMessage(content, role); + this.messages.add(message); this.updateTime = LocalDateTime.now(); } - public void addUserAnswer(String answer) { - QaTreeNode currentNode = qaTree.getNodeById(currentNodeId); - if (currentNode != null && currentNode.getQa() instanceof CommonQA) { - CommonQA qa = (CommonQA) currentNode.getQa(); - qa.setAnswer(answer); - qa.updateTimestamp(); - this.updateTime = LocalDateTime.now(); + @Getter + public static class ConversationMessage { + private final String content; + private final String role; // "user" or "assistant" + private final LocalDateTime timestamp; + + public ConversationMessage(String content, String role) { + this.content = content; + this.role = role; + this.timestamp = LocalDateTime.now(); } } - - public QaTreeNode getCurrentNode() { - return qaTree.getNodeById(currentNodeId); - } } \ No newline at end of file diff --git a/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/infrastructure/ai/ConversationOperation.java b/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/infrastructure/ai/ConversationOperation.java new file mode 100644 index 0000000..bed5b31 --- /dev/null +++ b/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/infrastructure/ai/ConversationOperation.java @@ -0,0 +1,191 @@ +package io.github.timemachinelab.core.session.infrastructure.ai; + +import io.github.timemachinelab.sfchain.annotation.AIOp; +import io.github.timemachinelab.sfchain.core.BaseAIOperation; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@AIOp(value = "CONVERSATION_OP", + description = "基于QATree的智能会话操作,支持上下文理解和结构化对话", + defaultModel = "gpt-4-turbo", + requireJsonOutput = true, + supportThinking = true, + defaultMaxTokens = 2048, + defaultTemperature = 0.8) +@Component +@Slf4j +public class ConversationOperation extends BaseAIOperation { + + @Override + public String buildPrompt(ConversationRequest input) { + StringBuilder promptBuilder = new StringBuilder(); + + promptBuilder.append(""" + 你是一名智能AI助手,专门负责与用户进行结构化对话。 + 你需要根据用户的输入和对话历史,提供有价值的回复。 + + ## 对话上下文 + 会话ID: %s + 当前节点ID: %s + """.formatted(input.getSessionId(), input.getCurrentNodeId())); + + // 添加对话历史 + if (input.getConversationHistory() != null && !input.getConversationHistory().isEmpty()) { + promptBuilder.append("\n## 对话历史\n"); + for (int i = 0; i < input.getConversationHistory().size(); i++) { + ConversationHistory history = input.getConversationHistory().get(i); + promptBuilder.append(String.format("%d. %s: %s\n", + i + 1, history.getRole(), history.getContent())); + } + } + + promptBuilder.append(""" + + ## 当前用户输入 + %s + + ## 回复要求 + 1. 根据用户输入和对话历史,提供有针对性的回复 + 2. 如果用户提出需求(如生成系统、解决问题等),可以提供选择题让用户进一步明确需求 + 3. 保持回复简洁、专业、有帮助 + 4. 必须返回以下JSON格式: + + ```json + { + "answer": "你的回复内容", + "responseType": "TEXT", + "options": [], + "nextQuestionSuggestion": "建议的下一个问题(可选)", + "metadata": { + "confidence": 0.95, + "category": "general" + } + } + ``` + + ## 响应类型说明 + - TEXT: 普通文本回复 + - SELECTION: 选择题回复(需要填充options数组) + - FORM: 表单回复(需要用户填写信息) + + ## 注意事项 + - 确保JSON格式正确且有效 + - 如果是选择题,options数组不能为空 + - confidence值范围0-1,表示回答的置信度 + - category可以是:general, technical, creative, problem_solving等 + """.formatted(input.getUserMessage())); + + return promptBuilder.toString(); + } + + @Override + protected ConversationResponse parseResult(String jsonContent, ConversationRequest input) { + try { + ConversationResponse response = objectMapper.readValue(jsonContent, ConversationResponse.class); + + // 验证响应完整性 + if (response.getAnswer() == null || response.getAnswer().trim().isEmpty()) { + log.warn("AI回复内容为空,使用默认回复"); + response.setAnswer("抱歉,我需要更多信息来帮助您。"); + } + + if (response.getResponseType() == null) { + response.setResponseType(ResponseType.TEXT); + } + + // 如果是选择题但没有选项,转为普通文本 + if (response.getResponseType() == ResponseType.SELECTION && + (response.getOptions() == null || response.getOptions().length == 0)) { + log.warn("选择题类型但无选项,转为文本类型"); + response.setResponseType(ResponseType.TEXT); + } + + return response; + + } catch (Exception e) { + log.warn("解析AI会话结果失败,使用默认响应: {}", e.getMessage()); + + ConversationResponse fallbackResponse = new ConversationResponse(); + fallbackResponse.setAnswer("我理解了您的需求,让我为您提供更详细的帮助。"); + fallbackResponse.setResponseType(ResponseType.TEXT); + fallbackResponse.setOptions(new String[0]); + + ConversationMetadata metadata = new ConversationMetadata(); + metadata.setConfidence(0.5); + metadata.setCategory("general"); + fallbackResponse.setMetadata(metadata); + + return fallbackResponse; + } + } + + @Data + public static class ConversationRequest { + private String sessionId; + private String currentNodeId; + private String userMessage; + private java.util.List conversationHistory; + private java.util.Map context; + + public ConversationRequest() { + this.conversationHistory = new java.util.ArrayList<>(); + this.context = new java.util.HashMap<>(); + } + + public ConversationRequest(String sessionId, String currentNodeId, String userMessage) { + this(); + this.sessionId = sessionId; + this.currentNodeId = currentNodeId; + this.userMessage = userMessage; + } + } + + @Data + public static class ConversationResponse { + private String answer; + private ResponseType responseType; + private String[] options; + private String nextQuestionSuggestion; + private ConversationMetadata metadata; + + public ConversationResponse() { + this.options = new String[0]; + this.metadata = new ConversationMetadata(); + } + } + + @Data + public static class ConversationHistory { + private String role; // "user" or "assistant" + private String content; + private String nodeId; + private Long timestamp; + + public ConversationHistory() {} + + public ConversationHistory(String role, String content, String nodeId) { + this.role = role; + this.content = content; + this.nodeId = nodeId; + this.timestamp = System.currentTimeMillis(); + } + } + + @Data + public static class ConversationMetadata { + private Double confidence; + private String category; + private java.util.Map additionalInfo; + + public ConversationMetadata() { + this.additionalInfo = new java.util.HashMap<>(); + } + } + + public enum ResponseType { + TEXT, + SELECTION, + FORM + } +} \ No newline at end of file diff --git a/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/infrastructure/web/ConversationController.java b/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/infrastructure/web/ConversationController.java index 3ee2736..a010ff3 100644 --- a/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/infrastructure/web/ConversationController.java +++ b/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/infrastructure/web/ConversationController.java @@ -3,6 +3,7 @@ import io.github.timemachinelab.core.session.application.ConversationService; import io.github.timemachinelab.core.session.domain.entity.ConversationSession; import io.github.timemachinelab.core.session.infrastructure.web.dto.MessageRequest; + import io.github.timemachinelab.core.session.infrastructure.web.dto.MessageResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; @@ -22,9 +23,8 @@ public class ConversationController { private final Map sseEmitters = new ConcurrentHashMap<>(); @PostMapping("/start") - public ConversationSession startConversation(@RequestParam String userId, - @RequestParam String initialQuestion) { - return conversationService.createSession(userId, initialQuestion); + public ConversationSession startConversation(@RequestParam String userId) { + return conversationService.createSession(userId); } @GetMapping(value = "/sse/{sessionId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE) @@ -41,27 +41,21 @@ public SseEmitter streamConversation(@PathVariable String sessionId) { @PostMapping("/message") public void addMessage(@RequestBody MessageRequest request) { - switch (request.getType()) { - case USER_TEXT: - conversationService.addUserAnswer(request.getSessionId(), request.getContent()); - sendSseMessage(request.getSessionId(), MessageResponse.userAnswer(null, request.getContent())); - break; - - case USER_SELECTION: - conversationService.handleUserSelection(request.getSessionId(), request.getQuestionId(), request.getSelectedOption()); - sendSseMessage(request.getSessionId(), MessageResponse.userAnswer(request.getQuestionId(), "选择了选项: " + request.getSelectedOption())); - break; - - case AI_QUESTION: - String nodeId = conversationService.addAIQuestion(request.getSessionId(), request.getContent()); - sendSseMessage(request.getSessionId(), MessageResponse.aiQuestion(nodeId, request.getContent())); - break; - - case AI_SELECTION_QUESTION: - String selectionNodeId = conversationService.addAISelectionQuestion(request.getSessionId(), request.getContent(), new String[]{"简洁风格", "现代风格", "经典风格"}); - sendSseMessage(request.getSessionId(), MessageResponse.aiSelectionQuestion(selectionNodeId, request.getContent(), new String[]{"简洁风格", "现代风格", "经典风格"})); - break; - } + // 核心业务流程:用户消息 -> AI服务 -> SSE流式返回 + conversationService.processUserMessage( + request.getSessionId(), + request.getContent(), + response -> sendSseMessage(request.getSessionId(), response) + ); +// switch (request.getType()) { +// case USER_TEXT: +// +// +// case USER_SELECTION: +// conversationService.handleUserSelection(request.getSessionId(), request.getQuestionId(), request.getSelectedOption()); +// sendSseMessage(request.getSessionId(), MessageResponse.userAnswer(request.getQuestionId(), "选择了选项: " + request.getSelectedOption())); +// break; +// } } @GetMapping("/session/{sessionId}") diff --git a/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/infrastructure/web/dto/MessageRequest.java b/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/infrastructure/web/dto/MessageRequest.java index 926deea..064d7c5 100644 --- a/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/infrastructure/web/dto/MessageRequest.java +++ b/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/infrastructure/web/dto/MessageRequest.java @@ -6,9 +6,6 @@ public class MessageRequest { private String sessionId; private String content; - private MessageType type; - private String questionId; - private Integer selectedOption; public enum MessageType { USER_TEXT, diff --git a/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/infrastructure/web/dto/MessageResponse.java b/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/infrastructure/web/dto/MessageResponse.java index 672810e..b336773 100644 --- a/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/infrastructure/web/dto/MessageResponse.java +++ b/prompto-lab-app/src/main/java/io/github/timemachinelab/core/session/infrastructure/web/dto/MessageResponse.java @@ -15,7 +15,7 @@ public class MessageResponse { public enum MessageType { USER_ANSWER, AI_QUESTION, - AI_SELECTION_QUESTION, + AI_ANSWER, SYSTEM_INFO } @@ -26,8 +26,8 @@ public static MessageResponse userAnswer(String nodeId, String content) { public static MessageResponse aiQuestion(String nodeId, String content) { return new MessageResponse(nodeId, content, MessageType.AI_QUESTION, null, System.currentTimeMillis()); } - - public static MessageResponse aiSelectionQuestion(String nodeId, String content, String[] options) { - return new MessageResponse(nodeId, content, MessageType.AI_SELECTION_QUESTION, options, System.currentTimeMillis()); + + public static MessageResponse aiAnswer(String nodeId, String content) { + return new MessageResponse(nodeId, content, MessageType.AI_ANSWER, null, System.currentTimeMillis()); } } \ No newline at end of file diff --git a/prompto-lab-app/src/main/java/io/github/timemachinelab/demo/SSEDemoController.java b/prompto-lab-app/src/main/java/io/github/timemachinelab/demo/SSEDemoController.java new file mode 100644 index 0000000..df237fd --- /dev/null +++ b/prompto-lab-app/src/main/java/io/github/timemachinelab/demo/SSEDemoController.java @@ -0,0 +1,170 @@ +package io.github.timemachinelab.demo; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.Map; + +@RestController +@RequestMapping("/api/demo") +@Slf4j +public class SSEDemoController { + + private final Map emitters = new ConcurrentHashMap<>(); + private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + + /** + * 建立SSE连接 + */ + @GetMapping(value = "/sse/{clientId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter connect(@PathVariable String clientId) { + log.info("客户端连接SSE: {}", clientId); + + SseEmitter emitter = new SseEmitter(Long.MAX_VALUE); + emitters.put(clientId, emitter); + + // 连接建立时发送欢迎消息 + try { + emitter.send(SseEmitter.event() + .name("connected") + .data("SSE连接已建立,客户端ID: " + clientId)); + } catch (IOException e) { + log.error("发送欢迎消息失败: {}", e.getMessage()); + } + + // 设置连接事件处理 + emitter.onCompletion(() -> { + log.info("SSE连接完成: {}", clientId); + emitters.remove(clientId); + }); + + emitter.onTimeout(() -> { + log.info("SSE连接超时: {}", clientId); + emitters.remove(clientId); + }); + + emitter.onError((ex) -> { + log.error("SSE连接错误: {} - {}", clientId, ex.getMessage()); + emitters.remove(clientId); + }); + + return emitter; + } + + /** + * 发送消息到指定客户端 + */ + @PostMapping("/send/{clientId}") + public String sendMessage(@PathVariable String clientId, @RequestParam String message) { + SseEmitter emitter = emitters.get(clientId); + + if (emitter == null) { + return "客户端未连接: " + clientId; + } + + try { + emitter.send(SseEmitter.event() + .name("message") + .data(message)); + + log.info("消息已发送到客户端 {}: {}", clientId, message); + return "消息发送成功"; + + } catch (IOException e) { + log.error("发送消息失败: {}", e.getMessage()); + emitters.remove(clientId); + return "发送失败: " + e.getMessage(); + } + } + + /** + * 广播消息到所有连接的客户端 + */ + @PostMapping("/broadcast") + public String broadcast(@RequestParam String message) { + int successCount = 0; + int failCount = 0; + + for (Map.Entry entry : emitters.entrySet()) { + try { + entry.getValue().send(SseEmitter.event() + .name("broadcast") + .data(message)); + successCount++; + } catch (IOException e) { + log.error("广播消息失败,客户端: {} - {}", entry.getKey(), e.getMessage()); + emitters.remove(entry.getKey()); + failCount++; + } + } + + return String.format("广播完成 - 成功: %d, 失败: %d", successCount, failCount); + } + + /** + * 真实流式数据传输 - SSE本身就是流式的 + * 这里演示连续推送数据流的场景 + */ + @PostMapping("/stream/{clientId}") + public String startStream(@PathVariable String clientId) { + SseEmitter emitter = emitters.get(clientId); + + if (emitter == null) { + return "客户端未连接: " + clientId; + } + + // 真实流式传输:连续推送数据,每秒一条,共10条 + // 这就是SSE的本质 - 服务器主动推送数据流 + scheduler.schedule(() -> { + for (int i = 1; i <= 10; i++) { + final int count = i; + scheduler.schedule(() -> { + try { + // 直接通过SSE流式推送数据 + emitter.send(SseEmitter.event() + .name("stream") + .data("实时数据流 #" + count + " - 时间戳: " + System.currentTimeMillis())); + + // 最后一条数据后发送完成通知 + if (count == 10) { + scheduler.schedule(() -> { + try { + emitter.send(SseEmitter.event() + .name("stream_complete") + .data("数据流传输完成")); + } catch (IOException e) { + log.error("发送完成通知失败: {}", e.getMessage()); + } + }, 1, TimeUnit.SECONDS); + } + + } catch (IOException e) { + log.error("流式数据推送失败: {}", e.getMessage()); + emitters.remove(clientId); + } + }, count, TimeUnit.SECONDS); + } + }, 0, TimeUnit.SECONDS); + + return "SSE数据流已开始推送"; + } + + /** + * 获取当前连接状态 + */ + @GetMapping("/status") + public Map getStatus() { + Map status = new ConcurrentHashMap<>(); + status.put("connectedClients", emitters.keySet()); + status.put("totalConnections", emitters.size()); + status.put("timestamp", System.currentTimeMillis()); + return status; + } +} \ No newline at end of file diff --git a/prompto-lab-app/src/main/java/io/github/timemachinelab/sfchain/controller/AIModelController.java b/prompto-lab-app/src/main/java/io/github/timemachinelab/sfchain/controller/AIModelController.java index d1adf06..47b7610 100644 --- a/prompto-lab-app/src/main/java/io/github/timemachinelab/sfchain/controller/AIModelController.java +++ b/prompto-lab-app/src/main/java/io/github/timemachinelab/sfchain/controller/AIModelController.java @@ -31,7 +31,6 @@ @RestController @RequestMapping("/sf/api/models") @RequiredArgsConstructor -@CrossOrigin(origins = "*") public class AIModelController { private final PersistenceManager persistenceManager; diff --git a/prompto-lab-app/src/main/java/io/github/timemachinelab/sfchain/controller/AIOperationController.java b/prompto-lab-app/src/main/java/io/github/timemachinelab/sfchain/controller/AIOperationController.java index b5ac64e..5aa7aa1 100644 --- a/prompto-lab-app/src/main/java/io/github/timemachinelab/sfchain/controller/AIOperationController.java +++ b/prompto-lab-app/src/main/java/io/github/timemachinelab/sfchain/controller/AIOperationController.java @@ -25,7 +25,6 @@ @RestController @RequestMapping("/sf/api/operations") @RequiredArgsConstructor -@CrossOrigin(origins = "*") public class AIOperationController { private final PersistenceManager persistenceManager; diff --git a/prompto-lab-app/src/main/java/io/github/timemachinelab/sfchain/controller/AISystemController.java b/prompto-lab-app/src/main/java/io/github/timemachinelab/sfchain/controller/AISystemController.java index 5144be6..e443989 100644 --- a/prompto-lab-app/src/main/java/io/github/timemachinelab/sfchain/controller/AISystemController.java +++ b/prompto-lab-app/src/main/java/io/github/timemachinelab/sfchain/controller/AISystemController.java @@ -23,7 +23,6 @@ @RestController @RequestMapping("/sf/api/system") @RequiredArgsConstructor -@CrossOrigin(origins = "*") public class AISystemController { private final PersistenceManager persistenceManager; diff --git a/prompto-lab-app/src/main/resources/static/sse-demo.html b/prompto-lab-app/src/main/resources/static/sse-demo.html new file mode 100644 index 0000000..0ee13ba --- /dev/null +++ b/prompto-lab-app/src/main/resources/static/sse-demo.html @@ -0,0 +1,340 @@ + + + + + + SSE流传输Demo + + + +
+

🚀 SSE流传输Demo

+ + +
+

📡 连接状态

+
未连接
+
+ + + +
+
+ + +
+

💬 消息操作

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+

📨 消息记录

+ +
+
+
+ + + + \ No newline at end of file diff --git a/prompto-lab-ui/src/components/AIChatPage.vue b/prompto-lab-ui/src/components/AIChatPage.vue index f036f3d..6275380 100644 --- a/prompto-lab-ui/src/components/AIChatPage.vue +++ b/prompto-lab-ui/src/components/AIChatPage.vue @@ -74,7 +74,10 @@