Commit d9117ce8dbfdecabc2d216ef2619c5db7aba6372
1 parent
62db09e2
feat(M1): single ReAct agent + streaming chat + KG form-lookup tool
- ReActAgent: AiServices streaming tool-loop (replaces 8-scene SceneSelector path)
- AgentConfig: single agent = streaming Ollama(qwen2.5:14b) + KgQueryTool + per-conversation memory + L1-domain system prompt
- SystemPromptService: injects L1 domain map (viw_kg_domain) into system prompt; degrades gracefully if KG views missing
- KgQueryTool.findForms: read-only form-catalog lookup over local viw_ai_useful_forms (global metadata, no tenant) — interim formId resolver
- AgentChatController: POST /api/agent/chat SSE stream ({type:token|reset|done|error}); reset drops pre-tool-call narration
- chat.html: consume the new SSE endpoint (streamed), per-page conversationId, drop TTS-borrow path
- application-saaslocal.yml: add to worktree (was master-only untracked -> profile silently hit remote DB)
Verified running (saaslocal, :8099): streaming chat, tool loop returns real forms, write requests correctly report 'under development'.
Showing
7 changed files
with
402 additions
and
79 deletions
src/main/java/com/xly/agent/ReActAgent.java
0 → 100644
| 1 | +package com.xly.agent; | ||
| 2 | + | ||
| 3 | +import dev.langchain4j.service.MemoryId; | ||
| 4 | +import dev.langchain4j.service.TokenStream; | ||
| 5 | +import dev.langchain4j.service.UserMessage; | ||
| 6 | + | ||
| 7 | +/** | ||
| 8 | + * 单一 ReAct 智能体。 | ||
| 9 | + * | ||
| 10 | + * <p>LangChain4j 的 {@code AiServices} 原生工具调用循环即 ReAct:模型自行决定是否调用工具、 | ||
| 11 | + * 调用哪个工具、以及何时给出最终答复。不再有旧的 8 场景 SceneSelector 路由——由 agent 自路由。 | ||
| 12 | + * | ||
| 13 | + * <p>返回 {@link TokenStream} 以支持流式输出;{@link MemoryId} 绑定每个会话独立的对话记忆。 | ||
| 14 | + */ | ||
| 15 | +public interface ReActAgent { | ||
| 16 | + | ||
| 17 | + TokenStream chat(@MemoryId String conversationId, @UserMessage String userInput); | ||
| 18 | +} |
src/main/java/com/xly/config/AgentConfig.java
0 → 100644
| 1 | +package com.xly.config; | ||
| 2 | + | ||
| 3 | +import com.xly.agent.ReActAgent; | ||
| 4 | +import com.xly.service.SystemPromptService; | ||
| 5 | +import com.xly.tool.KgQueryTool; | ||
| 6 | +import dev.langchain4j.memory.chat.MessageWindowChatMemory; | ||
| 7 | +import dev.langchain4j.model.ollama.OllamaStreamingChatModel; | ||
| 8 | +import dev.langchain4j.service.AiServices; | ||
| 9 | +import org.springframework.beans.factory.annotation.Value; | ||
| 10 | +import org.springframework.context.annotation.Bean; | ||
| 11 | +import org.springframework.context.annotation.Configuration; | ||
| 12 | + | ||
| 13 | +import java.time.Duration; | ||
| 14 | + | ||
| 15 | +/** | ||
| 16 | + * 组装单一 ReAct agent(M1)。 | ||
| 17 | + * | ||
| 18 | + * <p>= 流式 Ollama 模型 + 通用工具(当前只有 {@link KgQueryTool})+ 每会话对话记忆 | ||
| 19 | + * + 注入 L1 域图的 system prompt。取代旧的「每表单一个 ToolMeta 工具 + 8 场景路由」。 | ||
| 20 | + */ | ||
| 21 | +@Configuration | ||
| 22 | +public class AgentConfig { | ||
| 23 | + | ||
| 24 | + @Value("${langchain4j.ollama.base-url}") | ||
| 25 | + private String ollamaUrl; | ||
| 26 | + | ||
| 27 | + @Value("${langchain4j.ollama.chat-model-name}") | ||
| 28 | + private String chatModelName; | ||
| 29 | + | ||
| 30 | + /** 专供 agent 的流式模型:低温度利于稳定的工具调用,较大 numPredict 避免答复被截断。 */ | ||
| 31 | + @Bean("agentStreamingModel") | ||
| 32 | + public OllamaStreamingChatModel agentStreamingModel() { | ||
| 33 | + return OllamaStreamingChatModel.builder() | ||
| 34 | + .baseUrl(ollamaUrl) | ||
| 35 | + .modelName(chatModelName) | ||
| 36 | + .temperature(0.1) | ||
| 37 | + .topP(0.9) | ||
| 38 | + .numPredict(2048) | ||
| 39 | + .timeout(Duration.ofSeconds(180)) | ||
| 40 | + .build(); | ||
| 41 | + } | ||
| 42 | + | ||
| 43 | + @Bean | ||
| 44 | + public ReActAgent reActAgent(SystemPromptService systemPromptService, KgQueryTool kgQueryTool) { | ||
| 45 | + String systemPrompt = systemPromptService.buildSystemPrompt(); | ||
| 46 | + return AiServices.builder(ReActAgent.class) | ||
| 47 | + .streamingChatModel(agentStreamingModel()) | ||
| 48 | + .tools(kgQueryTool) | ||
| 49 | + .chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(20)) | ||
| 50 | + .systemMessageProvider(memoryId -> systemPrompt) | ||
| 51 | + .build(); | ||
| 52 | + } | ||
| 53 | +} |
src/main/java/com/xly/service/SystemPromptService.java
0 → 100644
| 1 | +package com.xly.service; | ||
| 2 | + | ||
| 3 | +import org.springframework.jdbc.core.JdbcTemplate; | ||
| 4 | +import org.springframework.stereotype.Service; | ||
| 5 | + | ||
| 6 | +import java.util.List; | ||
| 7 | +import java.util.Map; | ||
| 8 | + | ||
| 9 | +/** | ||
| 10 | + * 构建单 agent 的 system prompt。 | ||
| 11 | + * | ||
| 12 | + * <p>核心是把 L1 业务域地图({@code viw_kg_domain},11 个域 + 上下游流转 + 对应智能体)渲染进 | ||
| 13 | + * system prompt,作为常驻的「路由地图」——让单 agent 先判断问题属于哪个业务域、涉及哪些单据, | ||
| 14 | + * 再决定调用哪个工具。L1 体量小且永远相关,适合常驻 prompt;L2/L3 大而稀疏,走工具按需查。 | ||
| 15 | + */ | ||
| 16 | +@Service | ||
| 17 | +public class SystemPromptService { | ||
| 18 | + | ||
| 19 | + private final JdbcTemplate jdbc; | ||
| 20 | + | ||
| 21 | + public SystemPromptService(JdbcTemplate jdbc) { | ||
| 22 | + this.jdbc = jdbc; | ||
| 23 | + } | ||
| 24 | + | ||
| 25 | + public String buildSystemPrompt() { | ||
| 26 | + return """ | ||
| 27 | + 你是「小羚羊」,小羚羊印刷 ERP 的智能助手。服务对象是印刷 / 包装行业的企业用户,\ | ||
| 28 | + 帮助他们查询和(未来)操作 ERP 里的业务单据。 | ||
| 29 | + | ||
| 30 | + 【业务域地图(L1 路由)】 | ||
| 31 | + 下面是本 ERP 的业务域、单据规模及其上下游流转关系。回答前先据此判断用户的问题属于哪个域、\ | ||
| 32 | + 可能涉及哪些单据,再决定怎么做: | ||
| 33 | + %s | ||
| 34 | + 【可用工具】 | ||
| 35 | + - findForms(keyword):按关键词检索业务表单目录,用来把用户口中的「单据 / 报表」定位到具体是哪一张表单\ | ||
| 36 | + (拿到 formId / moduleId)。当你不确定用户指的到底是哪张表单时,先调用它确认,不要自己编表单名。 | ||
| 37 | + | ||
| 38 | + 【行为准则】 | ||
| 39 | + 1. 凡是能用工具确认的事实(表单、单据、数据),一律调用工具,绝不凭空编造表单名、单据号或数据。 | ||
| 40 | + 2. 始终用**简体中文**、简洁、面向业务人员回答;不要暴露内部字段名或技术细节,除非用户明确要求。 | ||
| 41 | + 3. **直接给出最终答复**:不要复述你正在调用哪个工具、不要输出思考过程或任何过程性文字。 | ||
| 42 | + 4. 你**当前只有只读的「表单目录检索」能力**。凡涉及新增 / 修改 / 删除 / 审核等写操作,\ | ||
| 43 | + 如实告诉用户「该能力正在开发中」,绝不假装已经执行或已经生成单据。 | ||
| 44 | + 5. 信息不足时主动向用户提问,不要猜测。 | ||
| 45 | + """.formatted(renderDomainMap()); | ||
| 46 | + } | ||
| 47 | + | ||
| 48 | + private String renderDomainMap() { | ||
| 49 | + List<Map<String, Object>> rows; | ||
| 50 | + try { | ||
| 51 | + rows = jdbc.queryForList( | ||
| 52 | + "SELECT sDomain, sAiScene, iForms, sDownstreamDomains, sUpstreamDomains " + | ||
| 53 | + "FROM viw_kg_domain ORDER BY iForms DESC"); | ||
| 54 | + } catch (Exception e) { | ||
| 55 | + // KG 视图缺失时不应阻断启动——降级为空域图,agent 仍可运行。 | ||
| 56 | + org.slf4j.LoggerFactory.getLogger(SystemPromptService.class) | ||
| 57 | + .warn("加载 L1 业务域地图失败(viw_kg_domain 不可用),system prompt 将不含域图:{}", e.getMessage()); | ||
| 58 | + return "(业务域地图暂不可用)\n"; | ||
| 59 | + } | ||
| 60 | + StringBuilder sb = new StringBuilder(); | ||
| 61 | + for (Map<String, Object> r : rows) { | ||
| 62 | + sb.append("- ").append(r.get("sDomain")) | ||
| 63 | + .append("(").append(r.get("sAiScene")).append(",") | ||
| 64 | + .append(r.get("iForms")).append(" 张单据)"); | ||
| 65 | + String up = str(r.get("sUpstreamDomains")); | ||
| 66 | + String down = str(r.get("sDownstreamDomains")); | ||
| 67 | + if (notEmpty(up)) sb.append(" 上游←[").append(up).append("]"); | ||
| 68 | + if (notEmpty(down)) sb.append(" 下游→[").append(down).append("]"); | ||
| 69 | + sb.append('\n'); | ||
| 70 | + } | ||
| 71 | + return sb.toString(); | ||
| 72 | + } | ||
| 73 | + | ||
| 74 | + private static boolean notEmpty(String s) { | ||
| 75 | + return s != null && !s.isBlank() && !"NULL".equalsIgnoreCase(s); | ||
| 76 | + } | ||
| 77 | + | ||
| 78 | + private static String str(Object o) { | ||
| 79 | + return o == null ? "" : o.toString(); | ||
| 80 | + } | ||
| 81 | +} |
src/main/java/com/xly/tool/KgQueryTool.java
0 → 100644
| 1 | +package com.xly.tool; | ||
| 2 | + | ||
| 3 | +import dev.langchain4j.agent.tool.P; | ||
| 4 | +import dev.langchain4j.agent.tool.Tool; | ||
| 5 | +import org.springframework.jdbc.core.JdbcTemplate; | ||
| 6 | +import org.springframework.stereotype.Component; | ||
| 7 | + | ||
| 8 | +import java.util.List; | ||
| 9 | +import java.util.Map; | ||
| 10 | + | ||
| 11 | +/** | ||
| 12 | + * 知识图谱查询工具(M1)。 | ||
| 13 | + * | ||
| 14 | + * <p>目前只暴露 {@link #findForms} 一个只读能力:按关键词检索 ERP 业务表单目录 | ||
| 15 | + * (视图 {@code viw_ai_useful_forms},1748 张有用业务表单)。这是「表单名 → formId/moduleId」 | ||
| 16 | + * 定位的过渡实现,也是后续 KgSearch / formId 解析的雏形。 | ||
| 17 | + * | ||
| 18 | + * <p>表单目录是**全局元数据**(对所有品牌一致),不涉及行级/租户数据,因此不需要租户过滤。 | ||
| 19 | + */ | ||
| 20 | +@Component | ||
| 21 | +public class KgQueryTool { | ||
| 22 | + | ||
| 23 | + private final JdbcTemplate jdbc; | ||
| 24 | + | ||
| 25 | + public KgQueryTool(JdbcTemplate jdbc) { | ||
| 26 | + this.jdbc = jdbc; | ||
| 27 | + } | ||
| 28 | + | ||
| 29 | + @Tool("按关键词检索 ERP 业务表单目录,返回匹配的表单名、底层数据源、所属菜单id(moduleId) 与表单id(formId)。" | ||
| 30 | + + "当用户提到某类单据或报表、但你不确定具体是哪一张表单时,先用它来定位。最多返回 15 条。") | ||
| 31 | + public String findForms( | ||
| 32 | + @P("表单名或业务关键词,例如:报价 / 客户 / 库存 / 送货 / 应收 / 采购订单") String keyword) { | ||
| 33 | + | ||
| 34 | + if (keyword == null || keyword.isBlank()) { | ||
| 35 | + return "请提供一个表单名或业务关键词再检索。"; | ||
| 36 | + } | ||
| 37 | + String kw = keyword.trim(); | ||
| 38 | + String like = "%" + kw + "%"; | ||
| 39 | + | ||
| 40 | + List<Map<String, Object>> rows = jdbc.queryForList( | ||
| 41 | + "SELECT sFormTitle, sDataSource, sExecType, sModuleId, sFormId " + | ||
| 42 | + "FROM viw_ai_useful_forms " + | ||
| 43 | + "WHERE sFormTitle LIKE ? " + | ||
| 44 | + "ORDER BY CHAR_LENGTH(sFormTitle) ASC " + | ||
| 45 | + "LIMIT 15", | ||
| 46 | + like); | ||
| 47 | + | ||
| 48 | + if (rows.isEmpty()) { | ||
| 49 | + return "没有找到名称包含「" + kw + "」的业务表单。可以换个更常见的说法,或告诉我更具体的单据名称。"; | ||
| 50 | + } | ||
| 51 | + | ||
| 52 | + StringBuilder sb = new StringBuilder(); | ||
| 53 | + sb.append("找到 ").append(rows.size()).append(" 张与「").append(kw).append("」相关的业务表单:\n\n"); | ||
| 54 | + sb.append("| 表单 | 数据源(").append("表/视图/存储过程) | 菜单id | 表单id |\n"); | ||
| 55 | + sb.append("|---|---|---|---|\n"); | ||
| 56 | + for (Map<String, Object> r : rows) { | ||
| 57 | + sb.append("| ").append(str(r.get("sFormTitle"))) | ||
| 58 | + .append(" | ").append(str(r.get("sDataSource"))).append(" · ").append(str(r.get("sExecType"))) | ||
| 59 | + .append(" | ").append(str(r.get("sModuleId"))) | ||
| 60 | + .append(" | ").append(str(r.get("sFormId"))) | ||
| 61 | + .append(" |\n"); | ||
| 62 | + } | ||
| 63 | + return sb.toString(); | ||
| 64 | + } | ||
| 65 | + | ||
| 66 | + private static String str(Object o) { | ||
| 67 | + return o == null ? "" : o.toString(); | ||
| 68 | + } | ||
| 69 | +} |
src/main/java/com/xly/web/AgentChatController.java
0 → 100644
| 1 | +package com.xly.web; | ||
| 2 | + | ||
| 3 | +import com.fasterxml.jackson.databind.ObjectMapper; | ||
| 4 | +import com.xly.agent.ReActAgent; | ||
| 5 | +import dev.langchain4j.service.TokenStream; | ||
| 6 | +import org.slf4j.Logger; | ||
| 7 | +import org.slf4j.LoggerFactory; | ||
| 8 | +import org.springframework.http.MediaType; | ||
| 9 | +import org.springframework.web.bind.annotation.PostMapping; | ||
| 10 | +import org.springframework.web.bind.annotation.RequestBody; | ||
| 11 | +import org.springframework.web.bind.annotation.RequestMapping; | ||
| 12 | +import org.springframework.web.bind.annotation.RestController; | ||
| 13 | +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; | ||
| 14 | + | ||
| 15 | +import java.io.IOException; | ||
| 16 | +import java.util.LinkedHashMap; | ||
| 17 | +import java.util.Map; | ||
| 18 | +import java.util.concurrent.ExecutorService; | ||
| 19 | +import java.util.concurrent.Executors; | ||
| 20 | + | ||
| 21 | +/** | ||
| 22 | + * 单 agent 对话入口(M1)。 | ||
| 23 | + * | ||
| 24 | + * <p>{@code POST /xlyAi/api/agent/chat} 以 SSE(text/event-stream)流式返回助手的 token。 | ||
| 25 | + * 每帧是一条 JSON:{@code {"type":"token|done|error","content":"..."}},前端逐帧渲染。 | ||
| 26 | + * | ||
| 27 | + * <p>会话记忆按 {@code conversationId} 隔离(多条命名会话);缺省用 {@code userid:default}。 | ||
| 28 | + * 说明:token 流本身是异步的({@link TokenStream#start()} 立即返回,回调在模型线程触发), | ||
| 29 | + * 这里用一个线程池提交,避免占用请求线程。 | ||
| 30 | + */ | ||
| 31 | +@RestController | ||
| 32 | +@RequestMapping("/api/agent") | ||
| 33 | +public class AgentChatController { | ||
| 34 | + | ||
| 35 | + private static final Logger log = LoggerFactory.getLogger(AgentChatController.class); | ||
| 36 | + | ||
| 37 | + private final ReActAgent agent; | ||
| 38 | + private final ObjectMapper mapper; | ||
| 39 | + private final ExecutorService exec = Executors.newCachedThreadPool(); | ||
| 40 | + | ||
| 41 | + public AgentChatController(ReActAgent agent, ObjectMapper mapper) { | ||
| 42 | + this.agent = agent; | ||
| 43 | + this.mapper = mapper; | ||
| 44 | + } | ||
| 45 | + | ||
| 46 | + /** 前端请求体:身份字段透传(M1 只用 userid + conversationId + text)。 */ | ||
| 47 | + public static class ChatReq { | ||
| 48 | + public String text; | ||
| 49 | + public String userid; | ||
| 50 | + public String conversationId; | ||
| 51 | + } | ||
| 52 | + | ||
| 53 | + @PostMapping(value = "/chat", produces = "text/event-stream;charset=UTF-8") | ||
| 54 | + public SseEmitter chat(@RequestBody ChatReq req) { | ||
| 55 | + SseEmitter emitter = new SseEmitter(180_000L); | ||
| 56 | + final String userInput = req.text == null ? "" : req.text; | ||
| 57 | + final String convId = (req.conversationId != null && !req.conversationId.isBlank()) | ||
| 58 | + ? req.conversationId | ||
| 59 | + : ((req.userid == null ? "anon" : req.userid) + ":default"); | ||
| 60 | + | ||
| 61 | + exec.submit(() -> { | ||
| 62 | + try { | ||
| 63 | + TokenStream ts = agent.chat(convId, userInput); | ||
| 64 | + ts.onPartialResponse(token -> send(emitter, "token", token)) | ||
| 65 | + // 工具执行 = 一轮结束:此前流出的是模型的“调用旁白/思考”,让前端清空, | ||
| 66 | + // 只保留工具执行之后的最终答复(既去掉噪声、又保留流式)。 | ||
| 67 | + .onToolExecuted(te -> send(emitter, "reset", "")) | ||
| 68 | + .onCompleteResponse(resp -> { | ||
| 69 | + send(emitter, "done", ""); | ||
| 70 | + emitter.complete(); | ||
| 71 | + }) | ||
| 72 | + .onError(err -> { | ||
| 73 | + log.warn("agent chat stream error (conv={})", convId, err); | ||
| 74 | + send(emitter, "error", err.getMessage() == null ? "服务异常" : err.getMessage()); | ||
| 75 | + emitter.complete(); | ||
| 76 | + }) | ||
| 77 | + .start(); | ||
| 78 | + } catch (Exception e) { | ||
| 79 | + log.error("agent invoke failed (conv={})", convId, e); | ||
| 80 | + send(emitter, "error", "服务异常:" + e.getMessage()); | ||
| 81 | + emitter.complete(); | ||
| 82 | + } | ||
| 83 | + }); | ||
| 84 | + return emitter; | ||
| 85 | + } | ||
| 86 | + | ||
| 87 | + private void send(SseEmitter emitter, String type, String content) { | ||
| 88 | + try { | ||
| 89 | + Map<String, Object> m = new LinkedHashMap<>(); | ||
| 90 | + m.put("type", type); | ||
| 91 | + m.put("content", content); | ||
| 92 | + emitter.send(SseEmitter.event().data(mapper.writeValueAsString(m), MediaType.APPLICATION_JSON)); | ||
| 93 | + } catch (IOException | IllegalStateException e) { | ||
| 94 | + // 客户端已断开或 emitter 已结束——忽略。 | ||
| 95 | + } | ||
| 96 | + } | ||
| 97 | +} |
src/main/resources/application-saaslocal.yml
0 → 100644
| 1 | +# Local override profile so xlyAi runs against the saas-8s+ local stack on macOS. | ||
| 2 | +# | ||
| 3 | +# Run: JAVA_HOME=$(/usr/libexec/java_home -v 17) ./mvnw spring-boot:run \ | ||
| 4 | +# -Dspring-boot.run.profiles=saaslocal | ||
| 5 | +# | ||
| 6 | +# Only overrides what differs from the committed application.yml (which targets a | ||
| 7 | +# remote Windows deploy). Redis already points at the local shared redis-local | ||
| 8 | +# (127.0.0.1:16379 / db 0) so it is inherited unchanged. Milvus/Ollama stay remote | ||
| 9 | +# — their clients are lazy, so the app boots even if they are unreachable. | ||
| 10 | +logging: | ||
| 11 | + dirpath: /Users/reporkey/Desktop/saas-8s+/logs/xlyAi | ||
| 12 | + | ||
| 13 | +spring: | ||
| 14 | + datasource: | ||
| 15 | + # Local saas DB from docker-compose.saas.yml (mysql-saas, root/local). | ||
| 16 | + url: jdbc:mysql://127.0.0.1:33307/xlyweberp_saas?allowPublicKeyRetrieval=true&keepAlive=true&autoReconnect=true&autoReconnectForPools=true&connectTimeout=30000&socketTimeout=180000&nullCatalogMeansCurrent=true&allowMultiQueries=true&useSSL=false&useUnicode=true&characterEncoding=utf-8&failOverReadOnly=false&serverTimezone=Asia/Shanghai&zeroDateTimeBehavior=CONVERT_TO_NULL | ||
| 17 | + username: root | ||
| 18 | + password: local | ||
| 19 | + | ||
| 20 | +# macOS-friendly temp path (was D:/xlyweberp/ai/ocrtmp) | ||
| 21 | +ocr: | ||
| 22 | + tmpPath: /Users/reporkey/Desktop/saas-8s+/tempPath/ocrtmp |
src/main/resources/templates/chat.html
| @@ -461,6 +461,7 @@ | @@ -461,6 +461,7 @@ | ||
| 461 | 461 | ||
| 462 | <script> | 462 | <script> |
| 463 | let sessionId =""; | 463 | let sessionId =""; |
| 464 | + let conversationId = "c-" + Date.now() + "-" + Math.random().toString(36).slice(2, 8); | ||
| 464 | let userid= "17522967560005776104370282597000"; | 465 | let userid= "17522967560005776104370282597000"; |
| 465 | let username= "qianb"; | 466 | let username= "qianb"; |
| 466 | let brandsid= "1111111111"; | 467 | let brandsid= "1111111111"; |
| @@ -506,35 +507,7 @@ | @@ -506,35 +507,7 @@ | ||
| 506 | } | 507 | } |
| 507 | 508 | ||
| 508 | window.onload = function(){ | 509 | window.onload = function(){ |
| 509 | - const data = { | ||
| 510 | - text: "", | ||
| 511 | - userid: userid, | ||
| 512 | - username: username, | ||
| 513 | - brandsid: brandsid, | ||
| 514 | - subsidiaryid: subsidiaryid, | ||
| 515 | - usertype: usertype, | ||
| 516 | - authorization: authorization, | ||
| 517 | - voice: "zh-CN-XiaoxiaoNeural", | ||
| 518 | - rate: "+10%", | ||
| 519 | - volume: "+0%", | ||
| 520 | - voiceless: false | ||
| 521 | - }; | ||
| 522 | - | ||
| 523 | - let initUrl=CONFIG.backendUrl+"/api/tts/init"; | ||
| 524 | - $.ajax({ | ||
| 525 | - url: initUrl, | ||
| 526 | - type: 'POST', | ||
| 527 | - async: false, | ||
| 528 | - data:JSON.stringify(data), | ||
| 529 | - dataType: 'json', | ||
| 530 | - contentType: 'application/json; charset=UTF-8', | ||
| 531 | - success: function(response) { | ||
| 532 | - $("#ts").html((response.processedText + response.systemText) ); | ||
| 533 | - }, | ||
| 534 | - error: function(xhr, status, error) { | ||
| 535 | - console.log('请求失败:', error); | ||
| 536 | - } | ||
| 537 | - }); | 510 | + $("#ts").html("<strong>你好,我是小羚羊 🦌</strong><br><br>我可以帮你查 ERP 里的业务单据。试试问我:<br>· “有哪些和报价相关的表单?”<br>· “客户资料在哪张表单里?”"); |
| 538 | } | 511 | } |
| 539 | 512 | ||
| 540 | function reset(message){ | 513 | function reset(message){ |
| @@ -599,70 +572,74 @@ | @@ -599,70 +572,74 @@ | ||
| 599 | checkPiece(); | 572 | checkPiece(); |
| 600 | } | 573 | } |
| 601 | 574 | ||
| 575 | + // ====================== | ||
| 576 | + // 单 agent 流式对话:POST /api/agent/chat -> SSE(text/event-stream) | ||
| 577 | + // 每帧一条 JSON:{type:"token|done|error", content:"..."} | ||
| 578 | + // ====================== | ||
| 602 | async function doMessage(input, message, button) { | 579 | async function doMessage(input, message, button) { |
| 603 | addMessage(message, 'user'); | 580 | addMessage(message, 'user'); |
| 604 | showTypingIndicator(); | 581 | showTypingIndicator(); |
| 605 | 582 | ||
| 606 | - try { | ||
| 607 | - const requestData = { | ||
| 608 | - text: message, | ||
| 609 | - userid: userid, | ||
| 610 | - usertype: usertype, | ||
| 611 | - authorization: authorization, | ||
| 612 | - voice: "zh-CN-XiaoxiaoNeural", | ||
| 613 | - rate: "+10%", | ||
| 614 | - volume: "+0%", | ||
| 615 | - voiceless: true | ||
| 616 | - }; | 583 | + let aiText = ''; |
| 584 | + let aiMsgId = null; | ||
| 617 | 585 | ||
| 618 | - const response = await fetch(`${CONFIG.backendUrl}/api/tts/stream/query`, { | 586 | + try { |
| 587 | + const response = await fetch(`${CONFIG.backendUrl}/api/agent/chat`, { | ||
| 619 | method: "POST", | 588 | method: "POST", |
| 620 | headers: { "Content-Type": "application/json;charset=UTF-8" }, | 589 | headers: { "Content-Type": "application/json;charset=UTF-8" }, |
| 621 | - body: JSON.stringify(requestData) | 590 | + body: JSON.stringify({ |
| 591 | + text: message, | ||
| 592 | + userid: userid, | ||
| 593 | + conversationId: conversationId | ||
| 594 | + }) | ||
| 622 | }); | 595 | }); |
| 623 | - | ||
| 624 | - const data = await response.json(); | ||
| 625 | - hideTypingIndicator(); | ||
| 626 | - const replyText = (data.processedText || "") + (data.systemText || ""); | ||
| 627 | - addMessage(replyText, 'ai'); | ||
| 628 | - | ||
| 629 | - // ============================================== | ||
| 630 | - // 👇 【关键】用 cacheKey 取音频(绝对不串音) | ||
| 631 | - // ============================================== | ||
| 632 | - const cacheKey = data.cacheKey; | ||
| 633 | - if (!cacheKey) return; | ||
| 634 | - const audioSize = data.audioSize; // 总分几段 | ||
| 635 | - | ||
| 636 | - let retry = 0; | ||
| 637 | - const checkAudio = async () => { | ||
| 638 | - retry++; | ||
| 639 | - if (retry > 20) return; | ||
| 640 | - | ||
| 641 | - try { | ||
| 642 | - // ============================================== | ||
| 643 | - // 👇 用 cacheKey 获取自己的音频(别人拿不到) | ||
| 644 | - // ============================================== | ||
| 645 | - const res = await fetch(`${CONFIG.backendUrl}/api/tts/audio?cacheKey=${encodeURIComponent(cacheKey)}`); | ||
| 646 | - const audioData = await res.json(); | ||
| 647 | - | ||
| 648 | - if (audioData.audioBase64) { | ||
| 649 | - const blob = base64ToBlob(audioData.audioBase64); | ||
| 650 | - const audio = new Audio(URL.createObjectURL(blob)); | ||
| 651 | - audio.play().catch(err => console.log('播放异常', err)); | ||
| 652 | - } else { | ||
| 653 | - setTimeout(checkAudio, 800); | 596 | + if (!response.ok) throw new Error("HTTP " + response.status); |
| 597 | + | ||
| 598 | + const reader = response.body.getReader(); | ||
| 599 | + const decoder = new TextDecoder("utf-8"); | ||
| 600 | + let buffer = ''; | ||
| 601 | + | ||
| 602 | + while (true) { | ||
| 603 | + const { value, done } = await reader.read(); | ||
| 604 | + if (done) break; | ||
| 605 | + buffer += decoder.decode(value, { stream: true }); | ||
| 606 | + | ||
| 607 | + let sep; | ||
| 608 | + while ((sep = buffer.indexOf("\n\n")) >= 0) { | ||
| 609 | + const frame = buffer.slice(0, sep); | ||
| 610 | + buffer = buffer.slice(sep + 2); | ||
| 611 | + const dataLine = frame.split("\n").find(l => l.startsWith("data:")); | ||
| 612 | + if (!dataLine) continue; | ||
| 613 | + const payload = dataLine.slice(5).trim(); | ||
| 614 | + if (!payload) continue; | ||
| 615 | + let evt; | ||
| 616 | + try { evt = JSON.parse(payload); } catch (e) { continue; } | ||
| 617 | + | ||
| 618 | + if (evt.type === "token") { | ||
| 619 | + if (aiMsgId === null) { hideTypingIndicator(); aiMsgId = addMessage('', 'ai'); } | ||
| 620 | + aiText += evt.content; | ||
| 621 | + updateMessage(aiMsgId, aiText); | ||
| 622 | + } else if (evt.type === "reset") { | ||
| 623 | + // 工具执行前的旁白作废,清空气泡,等最终答复流入 | ||
| 624 | + aiText = ''; | ||
| 625 | + if (aiMsgId === null) { hideTypingIndicator(); aiMsgId = addMessage('', 'ai'); } | ||
| 626 | + $(`#${aiMsgId} .message-content`).html('🔎 正在查询…'); | ||
| 627 | + } else if (evt.type === "error") { | ||
| 628 | + if (aiMsgId === null) { hideTypingIndicator(); aiMsgId = addMessage('', 'ai'); } | ||
| 629 | + aiText += (aiText ? "\n\n" : "") + "⚠️ " + evt.content; | ||
| 630 | + updateMessage(aiMsgId, aiText); | ||
| 654 | } | 631 | } |
| 655 | - } catch (e) { | ||
| 656 | - setTimeout(checkAudio, 800); | 632 | + // evt.type === "done" -> 结束,无需处理 |
| 657 | } | 633 | } |
| 658 | - }; | ||
| 659 | - setTimeout(checkAudio, 1200); | ||
| 660 | - playByIndex(cacheKey, 0, audioSize); | 634 | + } |
| 635 | + | ||
| 636 | + hideTypingIndicator(); | ||
| 637 | + if (aiMsgId === null) addMessage("(无响应,请重试)", 'ai'); | ||
| 661 | 638 | ||
| 662 | } catch (error) { | 639 | } catch (error) { |
| 663 | console.error('错误:', error); | 640 | console.error('错误:', error); |
| 664 | hideTypingIndicator(); | 641 | hideTypingIndicator(); |
| 665 | - addMessage("服务异常,请重试", 'ai'); | 642 | + if (aiMsgId === null) addMessage("服务异常,请重试:" + error.message, 'ai'); |
| 666 | } finally { | 643 | } finally { |
| 667 | input.prop('disabled', false); | 644 | input.prop('disabled', false); |
| 668 | button.prop('disabled', false); | 645 | button.prop('disabled', false); |
| @@ -671,6 +648,11 @@ | @@ -671,6 +648,11 @@ | ||
| 671 | } | 648 | } |
| 672 | } | 649 | } |
| 673 | 650 | ||
| 651 | + function updateMessage(messageId, content) { | ||
| 652 | + $(`#${messageId} .message-content`).html(md.render(content)); | ||
| 653 | + scrollToBottom(); | ||
| 654 | + } | ||
| 655 | + | ||
| 674 | // ============================== | 656 | // ============================== |
| 675 | // 👇 语音排队播放函数(保证顺序) | 657 | // 👇 语音排队播放函数(保证顺序) |
| 676 | // ============================== | 658 | // ============================== |
| @@ -859,6 +841,7 @@ | @@ -859,6 +841,7 @@ | ||
| 859 | localStorage.removeItem('chatHistory'); | 841 | localStorage.removeItem('chatHistory'); |
| 860 | updateStatus('对话已清空', 'connected'); | 842 | updateStatus('对话已清空', 'connected'); |
| 861 | sessionId =""; | 843 | sessionId =""; |
| 844 | + conversationId = "c-" + Date.now() + "-" + Math.random().toString(36).slice(2, 8); | ||
| 862 | ensureInputAtBottom(); | 845 | ensureInputAtBottom(); |
| 863 | } | 846 | } |
| 864 | } | 847 | } |