liuchengsen 1 месяц назад
Родитель
Сommit
5b77773856

+ 29 - 14
backend-java/src/main/java/com/pharmacopoeia/controller/AdminController.java

@@ -1,9 +1,12 @@
 package com.pharmacopoeia.controller;
 
 import com.pharmacopoeia.config.QwenProperties;
+import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.http.ResponseEntity;
 import org.springframework.web.bind.annotation.*;
 
+import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
 
 @RestController
@@ -11,20 +14,26 @@ import java.util.Map;
 public class AdminController {
 
     private final QwenProperties qwenProperties;
+    private final JdbcTemplate jdbc;
 
-    public AdminController(QwenProperties qwenProperties) {
+    public AdminController(QwenProperties qwenProperties, JdbcTemplate jdbc) {
         this.qwenProperties = qwenProperties;
+        this.jdbc = jdbc;
     }
 
     @GetMapping("/stats")
     public ResponseEntity<Map<String, Object>> getStats() {
-        return ResponseEntity.ok(Map.of(
-                "total_queries", 0,
-                "total_users", 0,
-                "avg_response_time_ms", 0,
-                "top_drugs", java.util.Collections.emptyList(),
-                "daily_queries", java.util.Collections.emptyList()
-        ));
+        int drugs = jdbc.queryForObject("SELECT COUNT(*) FROM drugs WHERE is_active = TRUE", Integer.class);
+        int chunks = jdbc.queryForObject("SELECT COUNT(*) FROM drug_chunks WHERE vec IS NOT NULL", Integer.class);
+        int queries = jdbc.queryForObject("SELECT COUNT(*) FROM messages WHERE role = 'user'", Integer.class);
+        int users = jdbc.queryForObject("SELECT COUNT(*) FROM users", Integer.class);
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("total_drugs", drugs);
+        result.put("total_chunks", chunks);
+        result.put("total_queries", queries);
+        result.put("total_users", users);
+        return ResponseEntity.ok(result);
     }
 
     @GetMapping("/feedback/list")
@@ -41,11 +50,17 @@ public class AdminController {
 
     @GetMapping("/knowledge/status")
     public ResponseEntity<Map<String, Object>> knowledgeStatus() {
-        return ResponseEntity.ok(Map.of(
-                "total_drugs", 0,
-                "total_chunks", 0,
-                "last_update", null,
-                "embedding_model", qwenProperties.getEmbeddingModel()
-        ));
+        int drugs = jdbc.queryForObject("SELECT COUNT(*) FROM drugs WHERE is_active = TRUE", Integer.class);
+        int chunks = jdbc.queryForObject("SELECT COUNT(*) FROM drug_chunks WHERE vec IS NOT NULL", Integer.class);
+
+        String lastUpdate = jdbc.queryForObject(
+            "SELECT COALESCE(MAX(updated_at)::text, '') FROM drugs", String.class);
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("total_drugs", drugs);
+        result.put("total_chunks", chunks);
+        result.put("last_update", lastUpdate);
+        result.put("embedding_model", qwenProperties.getEmbeddingModel());
+        return ResponseEntity.ok(result);
     }
 }

+ 34 - 27
backend-java/src/main/java/com/pharmacopoeia/controller/ChatController.java

@@ -45,15 +45,11 @@ public class ChatController {
         List<Map<String, Object>> docs = retrieverService.search(query, intent, 20);
         docs = rerank(docs, query, 5);
 
-        String answer;
-        // 剂量/用法/禁忌等零容错场景:直接回原文,不经过 LLM
-        if ("usage_guide".equals(intent) && !docs.isEmpty()) {
-            answer = buildDirectAnswer(query, docs);
-        } else {
-            List<Map<String, String>> messages = promptService.buildPrompt(query, docs, intent);
-            String rawAnswer = llmService.chat(messages);
-            answer = cleanAnswer(rawAnswer);
-        }
+        // 统一:LLM 回答 + 原文对照
+        List<Map<String, String>> messages = promptService.buildPrompt(query, docs, intent);
+        String llmAnswer = cleanAnswer(llmService.chat(messages));
+        String sourceText = buildSourceQuote(docs);
+        String answer = llmAnswer + sourceText;
 
         List<Map<String, Object>> sources = buildSources(docs);
         persistenceService.saveMessage(cid, "user", query, intent, null);
@@ -78,23 +74,6 @@ public class ChatController {
 
         Sinks.Many<ServerSentEvent<String>> sink = Sinks.many().unicast().onBackpressureBuffer();
 
-        // 剂量/用法/禁忌等零容错场景:直接回原文
-        if ("usage_guide".equals(intent) && !docs.isEmpty()) {
-            final String directAnswer = buildDirectAnswer(query, docs);
-            final List<Map<String, Object>> sources = buildSources(docs);
-            sink.tryEmitNext(ServerSentEvent.<String>builder().event("intent").data(intent).build());
-            sink.tryEmitNext(ServerSentEvent.<String>builder().data(directAnswer).build());
-            try {
-                String meta = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of(
-                        "intent", intent, "sources", sources));
-                sink.tryEmitNext(ServerSentEvent.<String>builder().event("meta").data(meta).build());
-            } catch (Exception e) {}
-            persistenceService.saveMessage(cid, "user", query, intent, null);
-            persistenceService.saveMessage(cid, "assistant", directAnswer, intent, sources);
-            sink.tryEmitComplete();
-            return sink.asFlux();
-        }
-
         try {
             sink.tryEmitNext(ServerSentEvent.<String>builder().event("intent").data(intent).build());
             sink.tryEmitNext(ServerSentEvent.<String>builder().event("status").data("Retrieving...").build());
@@ -109,6 +88,10 @@ public class ChatController {
                         sink.tryEmitNext(ServerSentEvent.<String>builder().data(token).build());
                     })
                     .doOnComplete(() -> {
+                        // 附加原文参考
+                        String sourceQuote = buildSourceQuote(docs);
+                        sink.tryEmitNext(ServerSentEvent.<String>builder().data(sourceQuote).build());
+
                         final List<Map<String, Object>> sources = buildSources(docs);
                         try {
                             String meta = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of(
@@ -118,8 +101,9 @@ public class ChatController {
                             sink.tryEmitNext(ServerSentEvent.<String>builder().event("meta").data(meta).build());
                         } catch (Exception ignored) {}
 
+                        String finalAnswer = cleanAnswer(fullAnswer.toString()) + sourceQuote;
                         persistenceService.saveMessage(cid, "user", query, intent, null);
-                        persistenceService.saveMessage(cid, "assistant", cleanAnswer(fullAnswer.toString()), intent, sources);
+                        persistenceService.saveMessage(cid, "assistant", finalAnswer, intent, sources);
                         sink.tryEmitComplete();
                     })
                     .doOnError(e -> sink.tryEmitError(e))
@@ -211,6 +195,29 @@ public class ChatController {
                 .collect(Collectors.toList());
     }
 
+    /** 从检索结果提取精简原文,附在 LLM 回答后面作为验证 */
+    private String buildSourceQuote(List<Map<String, Object>> docs) {
+        final String QUOTE_SECTION = true;
+        StringBuilder sb = new StringBuilder();
+        sb.append("\n\n——— 原文参考 ———\n");
+        int count = 0;
+        for (Map<String, Object> d : docs) {
+            if (count >= 2) break;
+            String content = (String) d.getOrDefault("content", "");
+            if (content == null || content.isEmpty()) continue;
+            // 精简:去来源行,截断到 200 字
+            content = content.replaceAll("\\s*来源:.*$", "").trim();
+            if (content.length() > 200) {
+                int cut = content.lastIndexOf('。', 200);
+                if (cut < 100) cut = 200;
+                content = content.substring(0, cut + 1);
+            }
+            sb.append(content).append("\n");
+            count++;
+        }
+        return sb.toString();
+    }
+
     /** 剂量/用法/禁忌等零容错场景:直接用检索结果构建回答,不经过 LLM */
     private String buildDirectAnswer(String query, List<Map<String, Object>> docs) {
         // 从 query 中提取可能的药品名

+ 12 - 37
backend-java/src/main/java/com/pharmacopoeia/service/PromptService.java

@@ -10,61 +10,36 @@ public class PromptService {
 
     private static final String COMPLIANCE = """
 
-            —— 每条药典信息标注来源名称(如"2025版药典二部P567")不用数字编号
+            —— 药典原文数字、剂量、单位不得改写或省略
             —— 不得编造药典版本号、页码
-            —— 通用知识标注为【通用药学知识】
-            —— 所有段落标题必须用全角【标题名】格式,前后括号完整
-            —— 用法用量、剂量、规格、含量等数字信息必须逐字引用原文,不得改写、不得省略任何数字
             —— 文末附「本回答由AI生成,仅供参考」
             """;
 
     private static final String DRUG_QUERY = """
-            你是中华药典AI助手,服务于药店医师、营业员和医生。
-
-            回答格式:
-            【结论】1-2句话精炼回答。
-            【详细说明】每条说明后方标注实际来源名称(如"2025版药典二部P567")
-               —— 剂量数据必须逐字从原文复制,数字、单位、符号一个都不能改
-               —— 示例正确格式:一次0.2~0.4g,每4~6小时1次(2025版药典二部P191)
-               —— 示例错误格式:一次0.~0.4g(遗漏数字)
-            【注意事项】禁忌、特殊人群等
-            【来源明细】列出本题引用的所有资料名称及出处
+            你是中华药典AI助手。简洁回答用户的药品问题。
+            规则:剂量、用法、禁忌等关键数据必须完整复制原文,数字不能丢。
             """ + COMPLIANCE;
 
     private static final String USAGE_GUIDE = """
-            你是中华药典用药指导助手。
-            
-            回答格式:
-            【结论】【用法用量】—— 标注来源名称
-            【禁忌】—— 【不良反应】—— 【注意事项】
-            【来源明细】【安全提醒】「请在医师或药师指导下用药」
+            你是用药指导助手。用户询问用法、用量、副作用、禁忌等问题。
+            规则:剂量数字逐字复制原文,不得改写。
+            结尾提醒「请在医师或药师指导下用药」。
             """ + COMPLIANCE;
 
     private static final String SYMPTOM_ADVICE = """
-            你是AI药典用药助手。用户描述症状寻求用药建议。
-            
-            回答格式:
-            【病情评估】严重程度判断
-            【用药方案】方案一/二 —— 用量(来源名称)
-            【非药物建议】【注意事项】【就医指征】
-            【来源明细】【免责声明】
+            你是用药建议助手。用户描述症状寻求用药参考。
+            规则:建议具体药品时标注来源,并说明禁忌和注意事项。
+            强调:建议仅供参考,严重症状请就医。
             """ + COMPLIANCE;
 
     private static final String REGULATION = """
-            你是药典法规条款查询助手。
-            
-            回答格式:
-            【摘要】【原文引用】逐字引用,保留编号和术语原貌
-            【条款出处】编号+页码【关联条款】
+            你是药典法规查询助手。
+            规则:逐字引用原文条款,保留编号和术语原貌,标注条款出处。
             """ + COMPLIANCE;
 
     private static final String EXAM_TUTOR = """
             你是执业药师考试辅导助手。
-            
-            回答格式:
-            【考点定位】科目-章节-知识点
-            【知识要点】分点讲解,标注来源
-            【记忆技巧】【考试频率】【来源】
+            规则:定位考点(科目-章节),分点讲解,标注来源,区分【教学补充】和【药典原文】。
             """ + COMPLIANCE;
 
     private static final String NO_DOCS = """

+ 13 - 3
backend-java/src/main/resources/static/index.html

@@ -100,7 +100,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei
 <div class="status-bar" id="statusBar">● 连接中...</div>
 
 <div id="tabChat" class="tab-content">
-  <div class="chat" id="chat"><div class="welcome"><div class="icon"></div><p>输入药品名称或用药问题<br><small style="color:#aaa">1184 种药品 · 4746 条知识片段</small></p></div></div>
+  <div class="chat" id="chat"><div class="welcome"><div class="icon"></div><p>输入药品名称或用药问题<br><small style="color:#aaa" id="statsInfo">加载中...</small></p></div></div>
   <div class="tags"><span class="tag-btn" onclick="quickAsk('阿莫西林禁忌')">阿莫西林禁忌</span><span class="tag-btn" onclick="quickAsk('布洛芬用法用量')">布洛芬用法用量</span><span class="tag-btn" onclick="quickAsk('二甲双胍不良反应')">二甲双胍不良反应</span><span class="tag-btn" onclick="quickAsk('青霉素过敏处理')">青霉素过敏处理</span><span class="tag-btn" onclick="quickAsk('吗啡药理作用')">吗啡药理作用</span><span class="tag-btn" onclick="quickAsk('疫苗保存条件')">疫苗保存条件</span></div>
   <div class="queue" id="queue" style="display:none"><div id="queueItems"></div></div>
   <div class="input-area"><input id="userInput" placeholder="输入药品问题..." onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();send()}"><button class="btn btn-danger" id="sendBtn" onclick="send()">发送</button><button class="btn btn-secondary" id="stopBtn" onclick="stopAll()" style="display:none">⏹ 停止</button></div>
@@ -108,7 +108,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei
 
 <div id="tabDrugs" class="tab-content hidden">
   <div class="drug-search-bar"><input id="drugSearch" placeholder="搜索药品名称..." onkeydown="if(event.key==='Enter')searchDrugs(1)"><button class="btn btn-danger" onclick="searchDrugs(1)">搜索</button></div>
-  <div class="drug-list" id="drugList"><div class="welcome"><div class="icon"></div><p>搜索药典收录的 1184 种药品</p></div></div>
+  <div class="drug-list" id="drugList"><div class="welcome"><div class="icon"></div><p>搜索药典收录的 <span id="drugCount">--</span> 种药品</p></div></div>
   <div class="pagination hidden" id="drugPager"></div>
 </div>
 
@@ -168,7 +168,17 @@ async function init(){
   setStatus('● 连接后端...','warn');
   try{
     var r=await fetch(API+'/health');var d=await r.json();
-    setStatus('● '+d.app+' '+d.env+' | 1184种药品','ok');
+    setStatus('● '+d.app+' '+d.env,'ok');
+    // 动态获取统计数据
+    try{
+      var sr=await fetch(API+'/api/v1/admin/stats');
+      var sd=await sr.json();
+      if(sd.total_drugs){
+        document.getElementById('statsInfo').textContent=sd.total_drugs+' 种药品 · '+sd.total_chunks+' 条知识片段';
+        document.getElementById('drugCount').textContent=sd.total_drugs;
+        setStatus('● '+d.app+' '+d.env+' | '+sd.total_drugs+'种药品','ok');
+      }
+    }catch(ex){}
     try{
       var lr=await fetch(API+'/api/v1/auth/guest',{method:'POST',headers:{'Content-Type':'application/json'}});
       var ld=await lr.json();if(ld.access_token)TOKEN=ld.access_token;

+ 50 - 18
static/index.html

@@ -100,7 +100,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei
 <div class="status-bar" id="statusBar">● 连接中...</div>
 
 <div id="tabChat" class="tab-content">
-  <div class="chat" id="chat"><div class="welcome"><div class="icon"></div><p>输入药品名称或用药问题<br><small style="color:#aaa">1184 种药品 · 4746 条知识片段</small></p></div></div>
+  <div class="chat" id="chat"><div class="welcome"><div class="icon"></div><p>输入药品名称或用药问题<br><small style="color:#aaa" id="statsInfo">加载中...</small></p></div></div>
   <div class="tags"><span class="tag-btn" onclick="quickAsk('阿莫西林禁忌')">阿莫西林禁忌</span><span class="tag-btn" onclick="quickAsk('布洛芬用法用量')">布洛芬用法用量</span><span class="tag-btn" onclick="quickAsk('二甲双胍不良反应')">二甲双胍不良反应</span><span class="tag-btn" onclick="quickAsk('青霉素过敏处理')">青霉素过敏处理</span><span class="tag-btn" onclick="quickAsk('吗啡药理作用')">吗啡药理作用</span><span class="tag-btn" onclick="quickAsk('疫苗保存条件')">疫苗保存条件</span></div>
   <div class="queue" id="queue" style="display:none"><div id="queueItems"></div></div>
   <div class="input-area"><input id="userInput" placeholder="输入药品问题..." onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();send()}"><button class="btn btn-danger" id="sendBtn" onclick="send()">发送</button><button class="btn btn-secondary" id="stopBtn" onclick="stopAll()" style="display:none">⏹ 停止</button></div>
@@ -108,7 +108,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei
 
 <div id="tabDrugs" class="tab-content hidden">
   <div class="drug-search-bar"><input id="drugSearch" placeholder="搜索药品名称..." onkeydown="if(event.key==='Enter')searchDrugs(1)"><button class="btn btn-danger" onclick="searchDrugs(1)">搜索</button></div>
-  <div class="drug-list" id="drugList"><div class="welcome"><div class="icon"></div><p>搜索药典收录的 1184 种药品</p></div></div>
+  <div class="drug-list" id="drugList"><div class="welcome"><div class="icon"></div><p>搜索药典收录的 <span id="drugCount">--</span> 种药品</p></div></div>
   <div class="pagination hidden" id="drugPager"></div>
 </div>
 
@@ -122,7 +122,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei
 
 <script>
 (function(){
-var API=(function(){var o=window.location.origin;if(o&&o!=='null'&&!o.startsWith('file')&&o!=='http://localhost:8080')return o;return'http://'+window.location.hostname+':8080'})();
+var API=(function(){var o=window.location.origin;if(o&&o!=='null'&&!o.startsWith('file'))return o;return'http://'+window.location.hostname+':42158'})();
 var TOKEN='',ABORT=null,STREAMING=false,CID='',DRUG_PAGE=1,MQ=[];
 
 function setStatus(m,c){document.getElementById('statusBar').textContent=m;document.getElementById('statusBar').className='status-bar status-'+c}
@@ -168,7 +168,17 @@ async function init(){
   setStatus('● 连接后端...','warn');
   try{
     var r=await fetch(API+'/health');var d=await r.json();
-    setStatus('● '+d.app+' '+d.env+' | 1184种药品','ok');
+    setStatus('● '+d.app+' '+d.env,'ok');
+    // 动态获取统计数据
+    try{
+      var sr=await fetch(API+'/api/v1/admin/stats');
+      var sd=await sr.json();
+      if(sd.total_drugs){
+        document.getElementById('statsInfo').textContent=sd.total_drugs+' 种药品 · '+sd.total_chunks+' 条知识片段';
+        document.getElementById('drugCount').textContent=sd.total_drugs;
+        setStatus('● '+d.app+' '+d.env+' | '+sd.total_drugs+'种药品','ok');
+      }
+    }catch(ex){}
     try{
       var lr=await fetch(API+'/api/v1/auth/guest',{method:'POST',headers:{'Content-Type':'application/json'}});
       var ld=await lr.json();if(ld.access_token)TOKEN=ld.access_token;
@@ -207,23 +217,45 @@ async function processQ(){
   addMsg('user',text);
 
   var aiDiv=addMsg('ai','',true,''),contentDiv=aiDiv.querySelector('.content');
+  contentDiv.innerHTML='<div class="thinking"><span class="think-dot"></span> 检索中...</div>';
   ABORT=new AbortController();
   try{
-    var r=await fetch(API+'/api/v1/chat/stream',{method:'POST',signal:ABORT.signal,headers:{'Content-Type':'application/json','Authorization':'Bearer '+TOKEN},body:JSON.stringify({message:text,conversation_id:CID||undefined})});
+    var r=await fetch(API+'/api/v1/chat/ask',{method:'POST',signal:ABORT.signal,headers:{'Content-Type':'application/json','Authorization':'Bearer '+TOKEN},body:JSON.stringify({message:text,conversationId:CID||undefined})});
     if(!r.ok)throw new Error('HTTP '+r.status);
-    var reader=r.body.getReader(),dec=new TextDecoder(),buf='',ev='',out='';
-    while(true){var v=await reader.read();if(v.done)break;buf+=dec.decode(v.value,{stream:true});var lines=buf.split('\n');buf=lines.pop()||'';
-      for(var i=0;i<lines.length;i++){var l=lines[i];if(l.startsWith('event:')){ev=l.slice(7).trim();continue}if(!l.startsWith('data:'))continue;var d=l.slice(6);if(d==='[DONE]')continue;
-        if(ev==='intent'){var tagEl=aiDiv.querySelector('.tag');if(tagEl){tagEl.textContent=intentLabel(d);tagEl.className='tag '+tagClass(d)}}
-        else if(ev==='status'){contentDiv.innerHTML='<div class="thinking"><span class="think-dot"></span> '+d+'</div>'}
-        else if(ev==='meta'){try{var meta=JSON.parse(d);if(meta.cid){CID=meta.cid;loadHistory()}if(meta.intent){var t=aiDiv.querySelector('.tag');if(t){t.textContent=intentLabel(meta.intent);t.className='tag '+tagClass(meta.intent)}}if(meta.sources&&meta.sources.length){var srcDiv=document.createElement('div');srcDiv.className='source';var parts=[];for(var j=0;j<Math.min(5,meta.sources.length);j++){var s=meta.sources[j];parts.push('<span>'+(s.section||s.name||'')+' ('+Number(s.score||0).toFixed(2)+')</span>')}srcDiv.innerHTML='📚 '+parts.join(' · ');aiDiv.appendChild(srcDiv)}}catch(e){}}
-        else{out+=d;contentDiv.innerHTML=md2html(out)+'<span class="cursor">|</span>'}ev='';document.getElementById('chat').scrollTop=document.getElementById('chat').scrollHeight}}
-    aiDiv.classList.remove('streaming');contentDiv.innerHTML=md2html(out);contentDiv.querySelector('.cursor')?.remove();
-  }catch(e){if(e.name!=='AbortError'){contentDiv.innerHTML='<span style="color:var(--rd)">❌ '+e.message+'</span>';aiDiv.classList.remove('streaming')}}
-  ABORT=null;STREAMING=false;
-  if(MQ.length>0){setTimeout(processQ,300);return}
-  document.getElementById('queue').style.display='none';
-  document.getElementById('stopBtn').style.display='none';
+    var d=await r.json();
+    var intent=d.intent||'';
+    var answer=d.answer||'无结果';
+    var sources=d.sources||[];
+    var cid=d.conversation_id||'';
+    if(cid){CID=cid;loadHistory()}
+
+    // update tag
+    if(intent){var tagEl=aiDiv.querySelector('.tag');if(tagEl){tagEl.textContent=intentLabel(intent);tagEl.className='tag '+tagClass(intent)}}
+
+    // show answer with typing effect
+    var idx=0;
+    var timer=setInterval(function(){
+      if(idx>=answer.length){clearInterval(timer);
+        contentDiv.innerHTML=md2html(answer);contentDiv.querySelector('.cursor')?.remove();
+        aiDiv.classList.remove('streaming');
+        // add sources
+        if(sources.length){
+          var srcDiv=document.createElement('div');srcDiv.className='source';var parts=[];
+          for(var j=0;j<Math.min(5,sources.length);j++){var s=sources[j];parts.push('<span>'+(s.section||s.name||'')+' ('+Number(s.score||0).toFixed(2)+')</span>')}
+          srcDiv.innerHTML='📚 '+parts.join(' · ');aiDiv.appendChild(srcDiv)}
+        STREAMING=false;document.getElementById('stopBtn').style.display='none';
+        document.getElementById('queue').style.display='none';
+        document.getElementById('chat').scrollTop=document.getElementById('chat').scrollHeight;
+        if(MQ.length>0)setTimeout(processQ,300);return}
+      idx+=3;
+      contentDiv.innerHTML=md2html(answer.substring(0,idx))+'<span class="cursor">|</span>';
+      document.getElementById('chat').scrollTop=document.getElementById('chat').scrollHeight;
+    },15);
+  }catch(e){
+    if(e.name!=='AbortError'){contentDiv.innerHTML='<span style="color:var(--rd)">❌ '+e.message+'</span>';aiDiv.classList.remove('streaming')}
+    STREAMING=false;document.getElementById('stopBtn').style.display='none';
+  }
+  ABORT=null;
 }
 
 window.feedback=async function(msgId,type,btn){