Commit d9117ce8dbfdecabc2d216ef2619c5db7aba6372

Authored by zichun
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'.
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 }