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

+ 4 - 0
.env.example

@@ -35,6 +35,10 @@ QWEN_MODEL=qwen-max
 QWEN_MAX_TOKENS=4096
 QWEN_MAX_TOKENS=4096
 QWEN_TEMPERATURE=0.1
 QWEN_TEMPERATURE=0.1
 
 
+# --- LLM: Qwen VL 视觉模型(图片分析+OCR) ---
+QWEN_VL_MODEL=qwen3.6-flash
+ENABLE_WEB_SEARCH=true
+
 # --- LLM: Qwen 本地部署 (后期切换,无需 API Key) ---
 # --- LLM: Qwen 本地部署 (后期切换,无需 API Key) ---
 QWEN_LOCAL_BASE_URL=http://localhost:8000/v1
 QWEN_LOCAL_BASE_URL=http://localhost:8000/v1
 QWEN_LOCAL_MODEL=Qwen3-35B-A3B
 QWEN_LOCAL_MODEL=Qwen3-35B-A3B

+ 2 - 0
backend-java/src/main/java/com/pharmacopoeia/config/QwenProperties.java

@@ -16,4 +16,6 @@ public class QwenProperties {
     private String embeddingModel = "text-embedding-v3";
     private String embeddingModel = "text-embedding-v3";
     private String embeddingUrl = "https://dashscope.aliyuncs.com/api/v1/services/embeddings/text-embedding/text-embedding";
     private String embeddingUrl = "https://dashscope.aliyuncs.com/api/v1/services/embeddings/text-embedding/text-embedding";
     private int embeddingDim = 1024;
     private int embeddingDim = 1024;
+    private String vlModel = "qwen3.6-flash";       // 视觉模型(图片分析+OCR)
+    private boolean enableWebSearch = true;        // 是否启用联网搜索
 }
 }

+ 292 - 1
backend-java/src/main/java/com/pharmacopoeia/controller/ChatController.java

@@ -1,7 +1,10 @@
 package com.pharmacopoeia.controller;
 package com.pharmacopoeia.controller;
 
 
+import com.pharmacopoeia.config.QwenProperties;
 import com.pharmacopoeia.dto.ChatRequest;
 import com.pharmacopoeia.dto.ChatRequest;
 import com.pharmacopoeia.dto.FeedbackRequest;
 import com.pharmacopoeia.dto.FeedbackRequest;
+import com.pharmacopoeia.dto.ImageChatRequest;
+import com.pharmacopoeia.dto.MultimodalChatRequest;
 import com.pharmacopoeia.service.*;
 import com.pharmacopoeia.service.*;
 import org.springframework.http.MediaType;
 import org.springframework.http.MediaType;
 import org.springframework.http.ResponseEntity;
 import org.springframework.http.ResponseEntity;
@@ -24,14 +27,16 @@ public class ChatController {
     private final PromptService promptService;
     private final PromptService promptService;
     private final ChatPersistenceService persistenceService;
     private final ChatPersistenceService persistenceService;
     private final JdbcTemplate jdbc;
     private final JdbcTemplate jdbc;
+    private final QwenProperties props;
 
 
     public ChatController(RetrieverService rs, LLMService ls, PromptService ps,
     public ChatController(RetrieverService rs, LLMService ls, PromptService ps,
-                          ChatPersistenceService cps, JdbcTemplate jdbc) {
+                          ChatPersistenceService cps, JdbcTemplate jdbc, QwenProperties props) {
         this.retrieverService = rs;
         this.retrieverService = rs;
         this.llmService = ls;
         this.llmService = ls;
         this.promptService = ps;
         this.promptService = ps;
         this.persistenceService = cps;
         this.persistenceService = cps;
         this.jdbc = jdbc;
         this.jdbc = jdbc;
+        this.props = props;
     }
     }
 
 
     @PostMapping("/ask")
     @PostMapping("/ask")
@@ -115,6 +120,292 @@ public class ChatController {
         return sink.asFlux();
         return sink.asFlux();
     }
     }
 
 
+    // ============================================================
+    // 图片对话 API(Qwen VL 分析 + OCR → RAG 检索 → 联网搜索)
+    // ============================================================
+
+    @PostMapping("/ask-image")
+    public ResponseEntity<Map<String, Object>> chatAskImage(@RequestBody ImageChatRequest request) {
+        String cid = request.getConversationId() != null && !request.getConversationId().isBlank()
+                ? request.getConversationId()
+                : UUID.randomUUID().toString();
+
+        // Step 1: Qwen VL 分析图片 + OCR 提取文字
+        String ocrText = llmService.analyzeImage(
+                request.getImageBase64(), request.getMimeType(),
+                "请分析这张图片,提取其中所有文字信息(OCR),特别是药品名称、成分、用法用量等关键药学信息。简要输出即可。");
+
+        // Step 2: 拼接查询 → RAG 检索
+        String query = (!request.getMessage().isBlank())
+                ? request.getMessage() + "\n\n(图片OCR提取内容:" + ocrText + ")"
+                : ocrText;
+
+        String intent = retrieverService.classifyIntent(query);
+        List<Map<String, Object>> docs = retrieverService.search(query, intent, 20);
+        docs = rerank(docs, query, 5);
+
+        // Step 3: 构建 Prompt(含图片分析上下文)+ 联网搜索
+        List<Map<String, String>> messages = promptService.buildPrompt(query, docs, intent);
+        String imageContext = "\n\n【图片分析结果】\n" + ocrText + "\n";
+        messages.get(0).put("content", messages.get(0).get("content") + imageContext);
+
+        String llmAnswer = cleanAnswer(llmService.chat(messages, true));
+        String sourceText = buildSourceQuote(docs);
+        String answer = llmAnswer + sourceText;
+
+        List<Map<String, Object>> sources = buildSources(docs);
+        persistenceService.saveMessage(cid, "user",
+                request.getMessage().isBlank() ? "[图片]" : "[图片] " + request.getMessage(),
+                intent, null);
+        persistenceService.saveMessage(cid, "assistant", answer, intent, sources);
+
+        return ResponseEntity.ok(Map.of(
+                "answer", answer,
+                "sources", sources,
+                "intent", intent
+        ));
+    }
+
+    @PostMapping(value = "/stream-image", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+    public Flux<ServerSentEvent<String>> chatStreamImage(@RequestBody ImageChatRequest request) {
+        final String cid = request.getConversationId() != null && !request.getConversationId().isBlank()
+                ? request.getConversationId()
+                : UUID.randomUUID().toString();
+
+        Sinks.Many<ServerSentEvent<String>> sink = Sinks.many().unicast().onBackpressureBuffer();
+
+        try {
+            sink.tryEmitNext(ServerSentEvent.<String>builder().event("status").data("正在分析图片(OCR 文字识别)...").build());
+
+            // Step 1: Qwen VL 分析图片
+            StringBuilder ocrBuilder = new StringBuilder();
+            llmService.analyzeImageStream(request.getImageBase64(), request.getMimeType(),
+                    "请分析这张图片,提取其中所有文字信息(OCR),特别是药品名称、成分、用法用量等。简要输出。")
+                    .doOnNext(ocrBuilder::append)
+                    .doOnComplete(() -> {
+                        String ocrText = ocrBuilder.toString();
+                        sink.tryEmitNext(ServerSentEvent.<String>builder().event("status").data("图片分析完成,正在检索药典知识库...").build());
+
+                        // Step 2: 拼接查询 → RAG
+                        String query = (!request.getMessage().isBlank())
+                                ? request.getMessage() + "\n\n(图片OCR提取内容:" + ocrText + ")"
+                                : ocrText;
+                        final String intent = retrieverService.classifyIntent(query);
+                        sink.tryEmitNext(ServerSentEvent.<String>builder().event("intent").data(intent).build());
+
+                        final List<Map<String, Object>> docs = rerank(retrieverService.search(query, intent, 20), query, 5);
+                        sink.tryEmitNext(ServerSentEvent.<String>builder().event("status")
+                                .data("已匹配 " + docs.size() + " 条药典资料,生成回答中(已启用联网搜索)...").build());
+
+                        // Step 3: 构建 Prompt + 联网搜索流式生成
+                        final List<Map<String, String>> messages = promptService.buildPrompt(query, docs, intent);
+                        String imageContext = "\n\n【图片分析结果】\n" + ocrText + "\n";
+                        messages.get(0).put("content", messages.get(0).get("content") + imageContext);
+
+                        StringBuilder fullAnswer = new StringBuilder();
+                        llmService.chatStream(messages, true)
+                                .doOnNext(token -> {
+                                    fullAnswer.append(token);
+                                    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(
+                                                "intent", intent,
+                                                "sources", sources,
+                                                "ocr_text", ocrText.length() > 200 ? ocrText.substring(0, 200) : ocrText
+                                        ));
+                                        sink.tryEmitNext(ServerSentEvent.<String>builder().event("meta").data(meta).build());
+                                    } catch (Exception ignored) {}
+
+                                    String finalAnswer = cleanAnswer(fullAnswer.toString()) + sourceQuote;
+                                    persistenceService.saveMessage(cid, "user",
+                                            request.getMessage().isBlank() ? "[图片]" : "[图片] " + request.getMessage(),
+                                            intent, null);
+                                    persistenceService.saveMessage(cid, "assistant", finalAnswer, intent, sources);
+                                    sink.tryEmitComplete();
+                                })
+                                .doOnError(sink::tryEmitError)
+                                .subscribe();
+                    })
+                    .doOnError(sink::tryEmitError)
+                    .subscribe();
+        } catch (Exception e) {
+            sink.tryEmitError(e);
+        }
+
+        return sink.asFlux();
+    }
+
+    // ============================================================
+    // 统一多模态对话 API(文本 + 图片 + 视频)
+    // ============================================================
+
+    @PostMapping("/ask-multimodal")
+    public ResponseEntity<Map<String, Object>> chatAskMultimodal(@RequestBody MultimodalChatRequest request) {
+        String cid = request.getConversationId() != null && !request.getConversationId().isBlank()
+                ? request.getConversationId()
+                : UUID.randomUUID().toString();
+
+        // Step 1: 媒体分析(如果有附件)
+        String ocrText = "";
+        String mediaLabel = "";
+        if (request.getMediaBase64() != null && !request.getMediaBase64().isBlank()
+                && request.getMediaType() != null && !request.getMediaType().isBlank()) {
+            mediaLabel = "video".equals(request.getMediaType()) ? "视频" : "图片";
+            ocrText = llmService.analyzeMedia(
+                    request.getMediaBase64(), request.getMediaType(),
+                    request.getMediaMime(), "");
+        }
+
+        // Step 2: 拼接查询
+        String query = request.getMessage() != null ? request.getMessage().trim() : "";
+        if (!query.isEmpty() && !ocrText.isEmpty()) {
+            query = query + "\n\n(" + mediaLabel + "OCR提取内容:" + ocrText + ")";
+        } else if (!ocrText.isEmpty()) {
+            query = ocrText;
+        } else if (query.isEmpty()) {
+            query = "请介绍一下自己";
+        }
+
+        // Step 3: RAG 检索
+        String intent = retrieverService.classifyIntent(query);
+        List<Map<String, Object>> docs = retrieverService.search(query, intent, 20);
+        docs = rerank(docs, query, 5);
+
+        // Step 4: 构建 Prompt + 联网搜索
+        List<Map<String, String>> messages = promptService.buildPrompt(query, docs, intent);
+        if (!ocrText.isEmpty()) {
+            messages.get(0).put("content",
+                    messages.get(0).get("content") + "\n\n【" + mediaLabel + "分析结果】\n" + ocrText + "\n");
+        }
+
+        boolean enableSearch = !ocrText.isEmpty() || props.isEnableWebSearch();
+        String llmAnswer = cleanAnswer(llmService.chat(messages, enableSearch));
+        String sourceText = buildSourceQuote(docs);
+        String answer = llmAnswer + sourceText;
+
+        List<Map<String, Object>> sources = buildSources(docs);
+        String userMsg = !request.getMessage().isBlank() ? request.getMessage()
+                : !ocrText.isEmpty() ? "[" + mediaLabel + "]" : request.getMessage();
+        persistenceService.saveMessage(cid, "user", userMsg, intent, null);
+        persistenceService.saveMessage(cid, "assistant", answer, intent, sources);
+
+        return ResponseEntity.ok(Map.of(
+                "answer", answer,
+                "sources", sources,
+                "intent", intent
+        ));
+    }
+
+    @PostMapping(value = "/stream-multimodal", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+    public Flux<ServerSentEvent<String>> chatStreamMultimodal(@RequestBody MultimodalChatRequest request) {
+        final String cid = request.getConversationId() != null && !request.getConversationId().isBlank()
+                ? request.getConversationId()
+                : UUID.randomUUID().toString();
+        final boolean hasMedia = request.getMediaBase64() != null && !request.getMediaBase64().isBlank()
+                && request.getMediaType() != null && !request.getMediaType().isBlank();
+        final String mediaLabel = hasMedia && "video".equals(request.getMediaType()) ? "视频" : "图片";
+
+        Sinks.Many<ServerSentEvent<String>> sink = Sinks.many().unicast().onBackpressureBuffer();
+
+        try {
+            if (hasMedia) {
+                sink.tryEmitNext(ServerSentEvent.<String>builder().event("status")
+                        .data("🔍 正在分析" + mediaLabel + "...").build());
+                // 先发 OCR section header
+                sink.tryEmitNext(ServerSentEvent.<String>builder()
+                        .data("【📷 " + mediaLabel + "分析】\n\n").build());
+
+                StringBuilder ocrBuilder = new StringBuilder();
+                llmService.analyzeMediaStream(request.getMediaBase64(), request.getMediaType(),
+                        request.getMediaMime(), "")
+                        .doOnNext(token -> {
+                            ocrBuilder.append(token);
+                            sink.tryEmitNext(ServerSentEvent.<String>builder().data(token).build());
+                        })
+                        .doOnComplete(() -> {
+                            sink.tryEmitNext(ServerSentEvent.<String>builder().data("\n\n").build());
+                            doStreamAnswer(sink, cid, request, ocrBuilder.toString(), mediaLabel);
+                        })
+                        .doOnError(sink::tryEmitError)
+                        .subscribe();
+            } else {
+                doStreamAnswer(sink, cid, request, "", "");
+            }
+        } catch (Exception e) {
+            sink.tryEmitError(e);
+        }
+
+        return sink.asFlux();
+    }
+
+    /** 流式多模态:OCR 完成后,走 RAG + 生成 */
+    private void doStreamAnswer(Sinks.Many<ServerSentEvent<String>> sink, String cid,
+                                MultimodalChatRequest request, String ocrText, String mediaLabel) {
+        if (!ocrText.isEmpty()) {
+            sink.tryEmitNext(ServerSentEvent.<String>builder().event("status")
+                    .data("📚 检索药典知识库...").build());
+        }
+
+        String query = request.getMessage() != null ? request.getMessage().trim() : "";
+        if (!query.isEmpty() && !ocrText.isEmpty()) {
+            query = query + "\n\n(" + mediaLabel + "OCR提取内容:" + ocrText + ")";
+        } else if (!ocrText.isEmpty()) {
+            query = ocrText;
+        } else if (query.isEmpty()) {
+            query = "请介绍一下自己";
+        }
+
+        final String intent = retrieverService.classifyIntent(query);
+        sink.tryEmitNext(ServerSentEvent.<String>builder().event("intent").data(intent).build());
+
+        final List<Map<String, Object>> docs = rerank(retrieverService.search(query, intent, 20), query, 5);
+        final boolean enableSearch = !ocrText.isEmpty() || props.isEnableWebSearch();
+        sink.tryEmitNext(ServerSentEvent.<String>builder().event("status")
+                .data("已匹配 " + docs.size() + " 条药典资料,生成回答中"
+                        + (enableSearch ? "(已启用联网搜索)" : "") + "...").build());
+
+        final List<Map<String, String>> messages = promptService.buildPrompt(query, docs, intent);
+        if (!ocrText.isEmpty()) {
+            messages.get(0).put("content",
+                    messages.get(0).get("content") + "\n\n【" + mediaLabel + "分析结果】\n" + ocrText + "\n");
+        }
+
+        // 发送回答 section header
+        sink.tryEmitNext(ServerSentEvent.<String>builder().data("\n【📚 药典参考回答】\n\n").build());
+
+        StringBuilder fullAnswer = new StringBuilder();
+        llmService.chatStream(messages, enableSearch)
+                .doOnNext(token -> {
+                    fullAnswer.append(token);
+                    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(
+                                "intent", intent, "sources", sources,
+                                "ocr_text", ocrText.length() > 200 ? ocrText.substring(0, 200) : ocrText));
+                        sink.tryEmitNext(ServerSentEvent.<String>builder().event("meta").data(meta).build());
+                    } catch (Exception ignored) {}
+                    String finalAnswer = cleanAnswer(fullAnswer.toString()) + sourceQuote;
+                    String userMsg = !request.getMessage().isBlank() ? request.getMessage()
+                            : !ocrText.isEmpty() ? "[" + mediaLabel + "]" : "";
+                    persistenceService.saveMessage(cid, "user", userMsg, intent, null);
+                    persistenceService.saveMessage(cid, "assistant", finalAnswer, intent, sources);
+                    sink.tryEmitComplete();
+                })
+                .doOnError(sink::tryEmitError)
+                .subscribe();
+    }
+
     @GetMapping("/history")
     @GetMapping("/history")
     public ResponseEntity<Map<String, Object>> getHistory(
     public ResponseEntity<Map<String, Object>> getHistory(
             @RequestParam(defaultValue = "1") int page,
             @RequestParam(defaultValue = "1") int page,

+ 15 - 0
backend-java/src/main/java/com/pharmacopoeia/dto/ImageChatRequest.java

@@ -0,0 +1,15 @@
+package com.pharmacopoeia.dto;
+
+import lombok.Data;
+
+@Data
+public class ImageChatRequest {
+    /** Base64 编码的图片 */
+    private String imageBase64;
+    /** 图片 MIME 类型,默认 image/jpeg */
+    private String mimeType = "image/jpeg";
+    /** 可选的附加文字问题 */
+    private String message = "";
+    /** 会话 ID */
+    private String conversationId;
+}

+ 20 - 0
backend-java/src/main/java/com/pharmacopoeia/dto/MultimodalChatRequest.java

@@ -0,0 +1,20 @@
+package com.pharmacopoeia.dto;
+
+import lombok.Data;
+
+/**
+ * 统一多模态对话请求:支持文本 + 图片 + 视频
+ */
+@Data
+public class MultimodalChatRequest {
+    /** 文字问题(可选) */
+    private String message = "";
+    /** 媒体类型: image / video / 空=纯文本 */
+    private String mediaType = "";
+    /** Base64 编码的图片或视频 */
+    private String mediaBase64 = "";
+    /** 媒体 MIME 类型,如 image/jpeg, video/mp4 */
+    private String mediaMime = "";
+    /** 会话 ID */
+    private String conversationId;
+}

+ 206 - 9
backend-java/src/main/java/com/pharmacopoeia/service/LLMService.java

@@ -31,15 +31,137 @@ public class LLMService {
                 .build();
                 .build();
     }
     }
 
 
+    // ============================================================
+    // 纯文本对话
+    // ============================================================
+
     public Flux<String> chatStream(List<Map<String, String>> messages) {
     public Flux<String> chatStream(List<Map<String, String>> messages) {
+        return chatStream(messages, false);
+    }
+
+    public Flux<String> chatStream(List<Map<String, String>> messages, boolean enableSearch) {
+        Map<String, Object> body = new java.util.HashMap<>(Map.of(
+                "model", props.getModel(),
+                "messages", messages,
+                "max_tokens", props.getMaxTokens(),
+                "temperature", props.getTemperature(),
+                "stream", true
+        ));
+        if (enableSearch) {
+            body.put("enable_search", true);
+        }
         return chatClient.post()
         return chatClient.post()
+                .uri("/chat/completions")
+                .contentType(MediaType.APPLICATION_JSON)
+                .bodyValue(body)
+                .retrieve()
+                .bodyToFlux(String.class)
+                .filter(data -> !"[DONE]".equals(data.trim()))
+                .map(this::extractDeltaContent);
+    }
+
+    public String chat(List<Map<String, String>> messages) {
+        return chat(messages, false);
+    }
+
+    public String chat(List<Map<String, String>> messages, boolean enableSearch) {
+        Map<String, Object> body = new java.util.HashMap<>(Map.of(
+                "model", props.getModel(),
+                "messages", messages,
+                "max_tokens", props.getMaxTokens(),
+                "temperature", props.getTemperature()
+        ));
+        if (enableSearch) {
+            body.put("enable_search", true);
+        }
+        String response = chatClient.post()
+                .uri("/chat/completions")
+                .contentType(MediaType.APPLICATION_JSON)
+                .bodyValue(body)
+                .retrieve()
+                .bodyToMono(String.class)
+                .block();
+
+        try {
+            JsonNode node = mapper.readTree(response);
+            return node.get("choices").get(0).get("message").get("content").asText();
+        } catch (Exception e) {
+            return "";
+        }
+    }
+
+    // ============================================================
+    // 图片分析 + OCR(Qwen VL 视觉模型)
+    // ============================================================
+
+    /**
+     * 调用 Qwen VL 模型分析图片,提取文字(OCR)
+     */
+    public String analyzeImage(String imageBase64, String mimeType, String prompt) {
+        if (prompt == null || prompt.isBlank()) {
+            prompt = """
+                请仔细分析这张图片,完成以下任务:
+                1. 描述图片内容(药品包装、说明书、处方单、症状照片等)
+                2. 提取图片中所有可见的文字(OCR),特别是药品名称、成分、用法用量等关键信息
+                3. 如果图片是药品包装/说明书,提取:药品通用名、规格、生产企业、批准文号
+                请按以下格式输出:
+                【图片描述】简要描述。
+                【OCR 提取文字】逐条列出提取到的文字内容。
+                """;
+        }
+
+        // 构建 vision 消息(多模态 content 数组)
+        List<Map<String, Object>> contentParts = List.of(
+                Map.of("type", "image_url",
+                       "image_url", Map.of("url", "data:" + mimeType + ";base64," + imageBase64)),
+                Map.of("type", "text", "text", prompt)
+        );
+
+        Map<String, Object> userMessage = Map.of("role", "user", "content", contentParts);
+
+        String response = chatClient.post()
                 .uri("/chat/completions")
                 .uri("/chat/completions")
                 .contentType(MediaType.APPLICATION_JSON)
                 .contentType(MediaType.APPLICATION_JSON)
                 .bodyValue(Map.of(
                 .bodyValue(Map.of(
-                        "model", props.getModel(),
-                        "messages", messages,
-                        "max_tokens", props.getMaxTokens(),
-                        "temperature", props.getTemperature(),
+                        "model", props.getVlModel(),
+                        "messages", List.of(userMessage),
+                        "max_tokens", 2048
+                ))
+                .retrieve()
+                .bodyToMono(String.class)
+                .block();
+
+        try {
+            JsonNode node = mapper.readTree(response);
+            return node.get("choices").get(0).get("message").get("content").asText();
+        } catch (Exception e) {
+            return "";
+        }
+    }
+
+    /**
+     * 流式版本:Qwen VL 分析图片
+     */
+    public Flux<String> analyzeImageStream(String imageBase64, String mimeType, String prompt) {
+        if (prompt == null || prompt.isBlank()) {
+            prompt = "请分析这张图片,提取所有文字信息(OCR),特别是药品名称、成分、用法用量等。简要输出。";
+        }
+
+        List<Map<String, Object>> contentParts = List.of(
+                Map.of("type", "image_url",
+                       "image_url", Map.of("url", "data:" + mimeType + ";base64," + imageBase64)),
+                Map.of("type", "text", "text", prompt)
+        );
+
+        Map<String, Object> userMessage = Map.of("role", "user", "content", contentParts);
+
+        return chatClient.post()
+                .uri("/chat/completions")
+                .contentType(MediaType.APPLICATION_JSON)
+                .bodyValue(Map.of(
+                        "model", props.getVlModel(),
+                        "messages", List.of(userMessage),
+                        "max_tokens", 2048,
                         "stream", true
                         "stream", true
                 ))
                 ))
                 .retrieve()
                 .retrieve()
@@ -48,15 +170,36 @@ public class LLMService {
                 .map(this::extractDeltaContent);
                 .map(this::extractDeltaContent);
     }
     }
 
 
-    public String chat(List<Map<String, String>> messages) {
+    // ============================================================
+    // 视频分析 + OCR(Qwen VL 模型支持视频帧提取分析)
+    // ============================================================
+
+    public String analyzeVideo(String videoBase64, String mimeType, String prompt) {
+        if (prompt == null || prompt.isBlank()) {
+            prompt = """
+                请仔细分析这段视频,完成以下任务:
+                1. 描述视频内容(药品展示、用药指导、症状表现等)
+                2. 提取视频中所有可见的文字(OCR),特别是药品名称、成分、用法用量等
+                3. 如果视频中有药品包装/说明书,提取全部文字信息
+                请按格式输出:【视频描述】→【OCR 提取文字】→【关键信息总结】
+                """;
+        }
+
+        List<Map<String, Object>> contentParts = List.of(
+                Map.of("type", "video_url",
+                       "video_url", Map.of("url", "data:" + mimeType + ";base64," + videoBase64)),
+                Map.of("type", "text", "text", prompt)
+        );
+
+        Map<String, Object> userMessage = Map.of("role", "user", "content", contentParts);
+
         String response = chatClient.post()
         String response = chatClient.post()
                 .uri("/chat/completions")
                 .uri("/chat/completions")
                 .contentType(MediaType.APPLICATION_JSON)
                 .contentType(MediaType.APPLICATION_JSON)
                 .bodyValue(Map.of(
                 .bodyValue(Map.of(
-                        "model", props.getModel(),
-                        "messages", messages,
-                        "max_tokens", props.getMaxTokens(),
-                        "temperature", props.getTemperature()
+                        "model", props.getVlModel(),
+                        "messages", List.of(userMessage),
+                        "max_tokens", 2048
                 ))
                 ))
                 .retrieve()
                 .retrieve()
                 .bodyToMono(String.class)
                 .bodyToMono(String.class)
@@ -70,6 +213,56 @@ public class LLMService {
         }
         }
     }
     }
 
 
+    public Flux<String> analyzeVideoStream(String videoBase64, String mimeType, String prompt) {
+        if (prompt == null || prompt.isBlank()) {
+            prompt = "请分析这段视频,提取关键帧中的文字信息(OCR),特别是药品名称、成分、用法用量等。简要输出。";
+        }
+
+        List<Map<String, Object>> contentParts = List.of(
+                Map.of("type", "video_url",
+                       "video_url", Map.of("url", "data:" + mimeType + ";base64," + videoBase64)),
+                Map.of("type", "text", "text", prompt)
+        );
+
+        Map<String, Object> userMessage = Map.of("role", "user", "content", contentParts);
+
+        return chatClient.post()
+                .uri("/chat/completions")
+                .contentType(MediaType.APPLICATION_JSON)
+                .bodyValue(Map.of(
+                        "model", props.getVlModel(),
+                        "messages", List.of(userMessage),
+                        "max_tokens", 2048,
+                        "stream", true
+                ))
+                .retrieve()
+                .bodyToFlux(String.class)
+                .filter(data -> !"[DONE]".equals(data.trim()))
+                .map(this::extractDeltaContent);
+    }
+
+    // ============================================================
+    // 统一多模态分析入口
+    // ============================================================
+
+    public String analyzeMedia(String mediaBase64, String mediaType, String mimeType, String prompt) {
+        if ("video".equals(mediaType)) {
+            return analyzeVideo(mediaBase64, mimeType != null ? mimeType : "video/mp4", prompt);
+        }
+        return analyzeImage(mediaBase64, mimeType != null ? mimeType : "image/jpeg", prompt);
+    }
+
+    public Flux<String> analyzeMediaStream(String mediaBase64, String mediaType, String mimeType, String prompt) {
+        if ("video".equals(mediaType)) {
+            return analyzeVideoStream(mediaBase64, mimeType != null ? mimeType : "video/mp4", prompt);
+        }
+        return analyzeImageStream(mediaBase64, mimeType != null ? mimeType : "image/jpeg", prompt);
+    }
+
+    // ============================================================
+    // Embedding
+    // ============================================================
+
     public List<Float> embed(String text) {
     public List<Float> embed(String text) {
         return embed(List.of(text)).get(0);
         return embed(List.of(text)).get(0);
     }
     }
@@ -103,6 +296,10 @@ public class LLMService {
         }
         }
     }
     }
 
 
+    // ============================================================
+    // Helpers
+    // ============================================================
+
     private String extractDeltaContent(String chunk) {
     private String extractDeltaContent(String chunk) {
         try {
         try {
             String json = chunk.trim();
             String json = chunk.trim();

+ 163 - 23
backend-java/src/main/java/com/pharmacopoeia/service/PromptService.java

@@ -10,45 +10,185 @@ public class PromptService {
 
 
     private static final String COMPLIANCE = """
     private static final String COMPLIANCE = """
 
 
-            —— 药典原文数字、剂量、单位不得改写或省略
+            —— 每条药典信息标注来源名称(如"2025版药典二部P567""维基百科"),不用数字编号
+            —— 引用网络来源时必须附带完整 URL(如 https://zh.wikipedia.org/wiki/阿莫西林),方便用户直接点击查看
             —— 不得编造药典版本号、页码
             —— 不得编造药典版本号、页码
-            —— 文末附「本回答由AI生成,仅供参考」
+            —— 通用知识标注为【通用药学知识】
+            —— 所有段落标题必须用全角【标题名】格式
+            —— 回答末尾必须包含【AI 声明】段落
             """;
             """;
 
 
+    private static final String AI_DISCLAIMER = """
+
+            【AI 声明】
+            本回答由 AI 生成,仅供参考,不构成处方或用药建议。
+            用药前请阅读药品说明书,处方药请在医师或药师指导下使用。
+            """;
+
+    // ============================================================
+    // 1. 药品信息查询
+    // 回答顺序:结论 → 详细说明 → 注意事项 → 来源明细 → AI 声明
+    // ============================================================
     private static final String DRUG_QUERY = """
     private static final String DRUG_QUERY = """
             你是中华药典AI助手。简洁回答用户的药品问题。
             你是中华药典AI助手。简洁回答用户的药品问题。
-            规则:剂量、用法、禁忌等关键数据必须完整复制原文,数字不能丢。
-            """ + COMPLIANCE;
 
 
+            回答格式(严格按以下顺序输出):
+
+            【结论】
+            1-2句话概括。
+
+            【详细说明】
+            每条信息后标注实际来源名称(如"2025版药典二部P567""维基百科")。
+            参考资料不足处标注【通用药学知识】。
+
+            【注意事项】
+            禁忌、特殊人群、不良反应等。
+
+            【来源明细】
+            列出本题引用的所有资料名称及出处。
+            """ + COMPLIANCE + AI_DISCLAIMER;
+
+    // ============================================================
+    // 2. 用法用量 / 安全咨询
+    // 回答顺序:结论 → 用法用量 → 禁忌 → 不良反应 → 注意事项 → 来源明细 → AI 声明
+    // ============================================================
     private static final String USAGE_GUIDE = """
     private static final String USAGE_GUIDE = """
-            你是用药指导助手。用户询问用法、用量、副作用、禁忌等问题。
-            规则:剂量数字逐字复制原文,不得改写。
-            结尾提醒「请在医师或药师指导下用药」。
-            """ + COMPLIANCE;
+            你是中华药典用药指导助手。用户询问用法、用量、副作用、禁忌等问题。
 
 
-    private static final String SYMPTOM_ADVICE = """
-            你是用药建议助手。用户描述症状寻求用药参考。
-            规则:建议具体药品时标注来源,并说明禁忌和注意事项。
-            强调:建议仅供参考,严重症状请就医。
-            """ + COMPLIANCE;
+            回答格式(严格按以下顺序输出):
+
+            【结论】
+            一句话建议。
+
+            【用法用量】
+            剂量、频次、疗程 — 标注来源名称。
 
 
+            【禁忌】
+            标注来源名称。
+
+            【不良反应】
+            标注来源名称。
+
+            【注意事项】
+            孕妇/儿童/老年/肝肾不全等特殊人群提示。
+
+            【来源明细】
+            列出本题引用的所有资料名称及出处。
+
+            【安全提醒】
+            「请在医师或药师指导下用药」
+            """ + COMPLIANCE + AI_DISCLAIMER;
+
+    // ============================================================
+    // 3. 法规条款查询
+    // 回答顺序:摘要 → 原文引用 → 条款出处 → 关联条款 → 来源明细 → AI 声明
+    // ============================================================
     private static final String REGULATION = """
     private static final String REGULATION = """
-            你是药典法规查询助手。
-            规则:逐字引用原文条款,保留编号和术语原貌,标注条款出处。
-            """ + COMPLIANCE;
+            你是药典法规条款查询助手。
+
+            回答格式(严格按以下顺序输出):
+
+            【摘要】
+            一句话概述该条款内容。
+
+            【原文引用】
+            逐字引用原文条款,保留编号和术语原貌。
+
+            【条款出处】
+            编号 + 页码(如"2025版药典四部通则0631")。
+
+            【关联条款】
+            其他相关条款编号及简要说明(如有)。
+
+            【来源明细】
+            列出本题引用的所有资料名称及出处。
+            """ + COMPLIANCE + AI_DISCLAIMER;
 
 
+    // ============================================================
+    // 4. 执业药师考试辅导
+    // 回答顺序:考点定位 → 知识要点 → 记忆技巧 → 考试频率 → 来源明细 → AI 声明
+    // ============================================================
     private static final String EXAM_TUTOR = """
     private static final String EXAM_TUTOR = """
             你是执业药师考试辅导助手。
             你是执业药师考试辅导助手。
-            规则:定位考点(科目-章节),分点讲解,标注来源,区分【教学补充】和【药典原文】。
-            """ + COMPLIANCE;
 
 
+            回答格式(严格按以下顺序输出):
+
+            【考点定位】
+            一句话定位考点(科目-章节-知识点)。
+
+            【知识要点】
+            分点讲解核心内容,标注来源名称。教学延伸标注【教学补充,非药典原文】。
+
+            【记忆技巧】
+            口诀、对比表格、联想记忆等。
+
+            【考试频率】
+            高频 / 中频 / 低频。
+
+            【来源明细】
+            大纲章节 + 药典出处 + 参考资料名称。
+            """ + COMPLIANCE + AI_DISCLAIMER;
+
+    // ============================================================
+    // 5. 症状用药建议
+    // 回答顺序:病情评估 → 用药方案 → 非药物建议 → 注意事项 → 就医指征 → 来源明细 → AI 声明
+    // ============================================================
+    private static final String SYMPTOM_ADVICE = """
+            你是AI药典用药助手。用户描述症状寻求用药建议。
+
+            回答格式(严格按以下顺序输出):
+
+            【病情评估】
+            严重程度判断 + 是否需要立即就医。
+
+            【用药方案】
+            方案一:药品名 — 用量(来源名称)
+            方案二:药品名 — 用量(来源名称)
+            每个方案后标注来源(如"2025版药典""维基百科""通用药学知识")。
+
+            【非药物建议】
+            休息、饮食、物理方法等。
+
+            【注意事项】
+            各方案的禁忌人群、药物相互作用、特殊人群提示(孕妇/儿童/老年)。
+
+            【就医指征】
+            列出必须就医的警示信号(如体温>39°C持续、呼吸困难等)。
+
+            【来源明细】
+            列出引用的所有资料名称。
+
+            【免责声明】
+            ⚠️ 以下为通用用药建议,请以药品说明书及医师指导为准。
+            本回答为AI用药参考,不构成处方建议。
+            """ + COMPLIANCE + AI_DISCLAIMER;
+
+    // ============================================================
+    // 6. 无资料兜底
+    // 回答顺序:结论 → 详细说明 → 来源说明 → AI 声明
+    // ============================================================
     private static final String NO_DOCS = """
     private static final String NO_DOCS = """
             你是AI药典助手。知识库中未检索到相关药典原文。
             你是AI药典助手。知识库中未检索到相关药典原文。
-            首行加:⚠️ 此回答未基于中国药典原文,为AI通用药学参考
-            不得使用"药典规定""药典记载"等用语
-            不得编造任何来源标注
-            每条信息标注为【通用药学知识】
-            """ + COMPLIANCE;
+
+            严格规则:
+            1. 首行加:⚠️ 此回答未基于中国药典原文,为AI通用药学参考
+            2. 不得使用"药典规定""药典记载"等用语
+            3. 不得编造任何来源标注、页码、版本号
+            4. 不得推荐未在中国获批的药品
+            5. 若无法确定安全答案,直接建议就医
+            6. 每条信息标注为【通用药学知识】
+
+            回答格式(严格按以下顺序输出):
+
+            【结论】
+            一句话回答用户问题。
+
+            【详细说明】
+            基于通用药学知识作答,每条标注【通用药学知识】。
+
+            【来源说明】
+            声明:上述内容来源为AI通用药学知识库,非《中国药典》原文。
+            """ + COMPLIANCE + AI_DISCLAIMER;
 
 
     private static final Map<String, String> PROMPT_MAP = Map.of(
     private static final Map<String, String> PROMPT_MAP = Map.of(
             "drug_query", DRUG_QUERY,
             "drug_query", DRUG_QUERY,

+ 3 - 0
backend-java/src/main/resources/application.yml

@@ -38,6 +38,9 @@ qwen:
   model: qwen3.7-max
   model: qwen3.7-max
   max-tokens: 4096
   max-tokens: 4096
   temperature: 0.0
   temperature: 0.0
+  # 视觉模型(图片分析+OCR)
+  vl-model: qwen3.6-flash
+  enable-web-search: true
   # 嵌入模型
   # 嵌入模型
   embedding-model: text-embedding-v3
   embedding-model: text-embedding-v3
   embedding-url: https://dashscope.aliyuncs.com/api/v1/services/embeddings/text-embedding/text-embedding
   embedding-url: https://dashscope.aliyuncs.com/api/v1/services/embeddings/text-embedding/text-embedding

+ 348 - 1
backend-python/app/api/chat.py

@@ -1,8 +1,9 @@
+import base64
 import json
 import json
 import uuid
 import uuid
 from typing import Optional
 from typing import Optional
 
 
-from fastapi import APIRouter, Depends, HTTPException, Query
+from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form
 from fastapi.responses import StreamingResponse
 from fastapi.responses import StreamingResponse
 from pydantic import BaseModel, Field
 from pydantic import BaseModel, Field
 from sqlalchemy import text
 from sqlalchemy import text
@@ -27,6 +28,24 @@ class ChatRequest(BaseModel):
     conversation_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
     conversation_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
 
 
 
 
+class ImageChatRequest(BaseModel):
+    """图片对话请求:base64 图片"""
+    image_base64: str = Field(..., min_length=1, description="Base64 编码的图片")
+    mime_type: str = Field(default="image/jpeg", description="图片 MIME 类型")
+    message: str = Field(default="", max_length=2000, description="可选的附加文字问题")
+    conversation_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
+
+
+class MultimodalChatRequest(BaseModel):
+    """统一多模态对话请求:支持文本 + 图片 + 视频"""
+    message: str = Field(default="", max_length=2000, description="文字问题(可选)")
+    # 媒体附件(图片和视频二选一或都不传,纯文本也可以)
+    media_type: str = Field(default="", description="媒体类型: image / video / 空=纯文本")
+    media_base64: str = Field(default="", description="Base64 编码的图片或视频")
+    media_mime: str = Field(default="", description="媒体 MIME 类型,如 image/jpeg, video/mp4")
+    conversation_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
+
+
 class ChatResponse(BaseModel):
 class ChatResponse(BaseModel):
     answer: str
     answer: str
     sources: list[dict]
     sources: list[dict]
@@ -164,6 +183,334 @@ async def chat_stream(req: ChatRequest, user: dict = Depends(get_current_user)):
     return StreamingResponse(stream_gen(), media_type="text/event-stream")
     return StreamingResponse(stream_gen(), media_type="text/event-stream")
 
 
 
 
+# ============================================
+# 图片对话 API(Qwen VL 分析 + OCR → RAG 检索 → 联网搜索)
+# ============================================
+
+@router.post("/ask-image", response_model=ChatResponse)
+async def chat_ask_image(req: ImageChatRequest, user: dict = Depends(get_current_user)):
+    """
+    图片对话 — 非流式:
+    1. Qwen VL 分析图片 + OCR 提取文字
+    2. 用提取文字做 RAG 检索
+    3. 结合检索结果 + 联网搜索生成回答
+    """
+    # Step 1: Qwen VL 分析图片 → 提取文字
+    ocr_text = await llm_client.analyze_image(
+        req.image_base64, req.mime_type,
+        prompt="请分析这张图片,提取其中所有文字信息(OCR),特别是药品名称、成分、用法用量等关键药学信息。简要输出即可。",
+    )
+
+    # Step 2: 拼接用户附加文字 + OCR 结果 → RAG 检索
+    query = req.message.strip() if req.message else ocr_text
+    if req.message:
+        query = f"{req.message}\n\n(图片OCR提取内容:{ocr_text})"
+
+    intent = classify_intent(query)
+    documents = await retriever.search(query, intent=intent, top_k=20)
+    documents = reranker.rerank(query, documents, top_k=5)
+
+    # Step 3: 构建 Prompt(含图片分析结果)+ 联网搜索
+    image_context = f"\n\n【图片分析结果】\n{ocr_text}\n"
+    msgs = build_prompt(query, documents, intent=intent)
+    # 在 system prompt 中追加图片分析上下文
+    msgs[0]["content"] += image_context
+    answer = await llm_client.chat(msgs, enable_search=True)
+
+    sources = [
+        {"name": d.get("drug_name", d.get("source", "")),
+         "section": d.get("section", ""), "source": d.get("source", ""),
+         "score": d.get("score", 0)}
+        for d in documents
+    ]
+
+    await _save_message(req.conversation_id, "user",
+                        f"[图片] {req.message}" if req.message else "[图片]",
+                        intent)
+    await _save_message(req.conversation_id, "assistant", answer, intent, sources)
+
+    return ChatResponse(answer=answer, sources=sources,
+                        conversation_id=req.conversation_id, intent=intent)
+
+
+@router.post("/stream-image")
+async def chat_stream_image(req: ImageChatRequest, user: dict = Depends(get_current_user)):
+    """图片对话 — SSE 流式"""
+
+    async def stream_gen():
+        intent = "drug_query"
+        yield f"event: intent\ndata: {intent}\n\n"
+
+        # Step 1: Qwen VL 流式分析图片 — 实时推给用户
+        yield "event: status\ndata: 🔍 正在分析图片...\n\n"
+        yield "event: content\ndata: 【📷 图片分析】\n\n"
+        ocr_parts = []
+        async for token in llm_client.analyze_image_stream(
+            req.image_base64, req.mime_type,
+            prompt="请分析这张图片,提取其中所有文字信息(OCR),特别是药品名称、成分、用法用量等。简要输出。",
+        ):
+            ocr_parts.append(token)
+            yield f"data: {token}\n\n"  # ← 实时流给用户
+        ocr_text = "".join(ocr_parts)
+        yield "data: \n\n"
+
+        # Step 2: 拼接查询 → RAG
+        query = req.message.strip() if req.message else ocr_text
+        if req.message:
+            query = f"{req.message}\n\n(图片OCR提取内容:{ocr_text})"
+
+        intent = classify_intent(query)
+        yield f"event: intent\ndata: {intent}\n\n"
+        yield f"event: status\ndata: 📚 检索药典知识库...\n\n"
+
+        documents = await retriever.search(query, intent=intent, top_k=20)
+        documents = reranker.rerank(query, documents, top_k=5)
+
+        yield f"event: status\ndata: 已匹配 {len(documents)} 条药典资料,生成回答中(已启用联网搜索)...\n\n"
+
+        # Step 3: 构建 Prompt + 联网搜索流式生成
+        image_context = f"\n\n【图片分析结果】\n{ocr_text}\n"
+        msgs = build_prompt(query, documents, intent=intent)
+        msgs[0]["content"] += image_context
+
+        sources = [
+            {"name": d.get("drug_name", d.get("source", "")),
+             "section": d.get("section", ""), "source": d.get("source", ""),
+             "score": d.get("score", 0)}
+            for d in documents
+        ]
+
+        yield "event: content\ndata: \n【📚 药典参考回答】\n\n"
+        full_answer = []
+        async for token in llm_client.chat_stream(msgs, enable_search=True):
+            full_answer.append(token)
+            yield f"data: {token}\n\n"
+        yield "data: [DONE]\n\n"
+
+        yield f"event: meta\ndata: {json.dumps({'intent': intent, 'sources': sources, 'cid': req.conversation_id, 'ocr_text': ocr_text[:200]})}\n\n"
+
+        answer_text = "".join(full_answer)
+        await _save_message(req.conversation_id, "user",
+                            f"[图片] {req.message}" if req.message else "[图片]",
+                            intent)
+        await _save_message(req.conversation_id, "assistant", answer_text, intent, sources)
+
+    return StreamingResponse(stream_gen(), media_type="text/event-stream")
+
+
+@router.post("/upload-image")
+async def chat_upload_image(
+    file: UploadFile = File(...),
+    message: str = Form(default=""),
+    conversation_id: str = Form(default=""),
+    user: dict = Depends(get_current_user),
+):
+    """
+    上传图片文件 → 转为 base64 → 走图片对话流程
+    支持格式:jpg, jpeg, png, webp, bmp
+    """
+    allowed = {"image/jpeg", "image/png", "image/webp", "image/bmp"}
+    if file.content_type and file.content_type not in allowed:
+        raise HTTPException(400, f"不支持的图片格式: {file.content_type},支持 jpg/png/webp/bmp")
+
+    contents = await file.read()
+    if len(contents) > 10 * 1024 * 1024:
+        raise HTTPException(400, "图片大小不能超过 10MB")
+
+    image_b64 = base64.b64encode(contents).decode("utf-8")
+    mime = file.content_type or "image/jpeg"
+    cid = conversation_id or str(uuid.uuid4())
+
+    req = ImageChatRequest(
+        image_base64=image_b64,
+        mime_type=mime,
+        message=message,
+        conversation_id=cid,
+    )
+    return await chat_ask_image(req, user)
+
+
+# ============================================================
+# 统一多模态对话 API(文本 + 图片 + 视频,一个接口全搞定)
+# ============================================================
+
+@router.post("/ask-multimodal", response_model=ChatResponse)
+async def chat_ask_multimodal(req: MultimodalChatRequest, user: dict = Depends(get_current_user)):
+    """
+    统一多模态对话 — 非流式:
+    支持纯文本 / 文本+图片 / 文本+视频 / 纯图片 / 纯视频
+    流程:媒体分析(OCR) → RAG 检索 → 联网搜索 → 回答
+    """
+    conversation_id = req.conversation_id or str(uuid.uuid4())
+    ocr_text = ""
+
+    # Step 1: 如果有媒体附件,先做视觉分析 + OCR
+    if req.media_base64 and req.media_type in ("image", "video"):
+        media_label = "视频" if req.media_type == "video" else "图片"
+        ocr_text = await llm_client.analyze_media(
+            req.media_base64,
+            req.media_type,
+            mime_type=req.media_mime or ("" if req.media_type != "video" else "video/mp4"),
+        )
+
+    # Step 2: 拼接查询文本
+    query = req.message.strip()
+    if query and ocr_text:
+        query = f"{query}\n\n({media_label}OCR提取内容:{ocr_text})"
+    elif ocr_text:
+        query = ocr_text
+    elif not query:
+        query = "请介绍一下自己"
+
+    # Step 3: RAG 检索
+    intent = classify_intent(query)
+    documents = await retriever.search(query, intent=intent, top_k=20)
+    documents = reranker.rerank(query, documents, top_k=5)
+
+    # Step 4: 构建 Prompt + 联网搜索
+    msgs = build_prompt(query, documents, intent=intent)
+    if ocr_text:
+        msgs[0]["content"] += f"\n\n【{media_label}分析结果】\n{ocr_text}\n"
+
+    answer = await llm_client.chat(msgs, enable_search=bool(ocr_text) or settings.enable_web_search)
+
+    sources = [
+        {"name": d.get("drug_name", d.get("source", "")),
+         "section": d.get("section", ""), "source": d.get("source", ""),
+         "score": d.get("score", 0)}
+        for d in documents
+    ]
+
+    user_msg = req.message or f"[{media_label}]" if ocr_text else req.message
+    await _save_message(conversation_id, "user", user_msg, intent)
+    await _save_message(conversation_id, "assistant", answer, intent, sources)
+
+    return ChatResponse(answer=answer, sources=sources,
+                        conversation_id=conversation_id, intent=intent)
+
+
+@router.post("/stream-multimodal")
+async def chat_stream_multimodal(req: MultimodalChatRequest, user: dict = Depends(get_current_user)):
+    """
+    统一多模态对话 — SSE 流式(全链路流式):
+    OCR 分析 → 实时推送给用户 → 立即 RAG 检索 → 流式生成回答
+    用户无需等待,每一步都在实时输出
+    """
+
+    async def stream_gen():
+        nonlocal req
+        conversation_id = req.conversation_id or str(uuid.uuid4())
+        media_label = ""
+        ocr_text = ""
+
+        has_media = req.media_base64 and req.media_type in ("image", "video")
+
+        if has_media:
+            media_label = "视频" if req.media_type == "video" else "图片"
+            # Step 1: 流式 OCR 分析 — 实时推送给用户
+            yield "event: status\ndata: 🔍 正在分析...\n\n"
+            yield f"event: content\ndata: 【📷 {media_label}分析】\n\n"
+
+            ocr_parts = []
+            async for token in llm_client.analyze_media_stream(
+                req.media_base64, req.media_type,
+                mime_type=req.media_mime or "",
+            ):
+                ocr_parts.append(token)
+                yield f"data: {token}\n\n"  # ← OCR token 实时流给用户
+
+            ocr_text = "".join(ocr_parts)
+            yield "data: \n\n"  # 分隔
+        else:
+            yield "event: intent\ndata: drug_query\n\n"
+
+        # Step 2: 拼接查询 → RAG 检索(此时 OCR 已全部拿到)
+        query = req.message.strip()
+        if query and ocr_text:
+            query = f"{query}\n\n({media_label}OCR提取内容:{ocr_text})"
+        elif ocr_text:
+            query = ocr_text
+        elif not query:
+            query = "请介绍一下自己"
+
+        intent = classify_intent(query)
+        yield f"event: intent\ndata: {intent}\n\n"
+        yield f"event: status\ndata: 📚 检索药典知识库...\n\n"
+
+        documents = await retriever.search(query, intent=intent, top_k=20)
+        documents = reranker.rerank(query, documents, top_k=5)
+
+        search_hint = "(已启用联网搜索)" if (ocr_text or settings.enable_web_search) else ""
+        yield f"event: status\ndata: 已匹配 {len(documents)} 条,生成回答中{search_hint}...\n\n"
+
+        # Step 3: LLM 流式生成
+        msgs = build_prompt(query, documents, intent=intent)
+        if ocr_text:
+            msgs[0]["content"] += f"\n\n【{media_label}分析结果】\n{ocr_text}\n"
+
+        sources = [
+            {"name": d.get("drug_name", d.get("source", "")),
+             "section": d.get("section", ""), "source": d.get("source", ""),
+             "score": d.get("score", 0)}
+            for d in documents
+        ]
+
+        yield "event: content\ndata: \n【📚 药典参考回答】\n\n"
+        full_answer = []
+        async for token in llm_client.chat_stream(msgs, enable_search=bool(ocr_text) or settings.enable_web_search):
+            full_answer.append(token)
+            yield f"data: {token}\n\n"
+        yield "data: [DONE]\n\n"
+
+        yield f"event: meta\ndata: {json.dumps({'intent': intent, 'sources': sources, 'cid': conversation_id, 'ocr_text': ocr_text[:200] if ocr_text else ''})}\n\n"
+
+        answer_text = "".join(full_answer)
+        user_msg = req.message or f"[{media_label}]" if ocr_text else req.message
+        await _save_message(conversation_id, "user", user_msg, intent)
+        await _save_message(conversation_id, "assistant", answer_text, intent, sources)
+
+    return StreamingResponse(stream_gen(), media_type="text/event-stream")
+
+
+@router.post("/upload-media")
+async def chat_upload_media(
+    file: UploadFile = File(...),
+    message: str = Form(default=""),
+    conversation_id: str = Form(default=""),
+    user: dict = Depends(get_current_user),
+):
+    """
+    上传媒体文件(图片/视频)→ 自动识别类型 → 走多模态对话流程
+    支持:jpg, jpeg, png, webp, bmp, mp4, mov, avi, webm
+    """
+    mime = file.content_type or ""
+    media_type = ""
+    if mime.startswith("image/"):
+        media_type = "image"
+        max_size = 10 * 1024 * 1024  # 10MB
+    elif mime.startswith("video/"):
+        media_type = "video"
+        max_size = 50 * 1024 * 1024  # 50MB
+    else:
+        raise HTTPException(400, f"不支持的媒体格式: {mime},支持 jpg/png/webp/bmp/mp4/mov/avi/webm")
+
+    contents = await file.read()
+    if len(contents) > max_size:
+        raise HTTPException(400, f"文件大小不能超过 {max_size // 1024 // 1024}MB")
+
+    media_b64 = base64.b64encode(contents).decode("utf-8")
+    cid = conversation_id or str(uuid.uuid4())
+
+    req = MultimodalChatRequest(
+        message=message,
+        media_type=media_type,
+        media_base64=media_b64,
+        media_mime=mime,
+        conversation_id=cid,
+    )
+    return await chat_ask_multimodal(req, user)
+
+
 # ============================================
 # ============================================
 # 对话历史 API
 # 对话历史 API
 # ============================================
 # ============================================

+ 3 - 0
backend-python/app/core/config.py

@@ -41,6 +41,9 @@ class Settings(BaseSettings):
     qwen_max_tokens: int = 4096
     qwen_max_tokens: int = 4096
     qwen_temperature: float = 0.1
     qwen_temperature: float = 0.1
 
 
+    qwen_vl_model: str = "qwen3.6-flash"        # 视觉模型(图片分析+OCR)
+    enable_web_search: bool = True             # 是否启用 Qwen 联网搜索
+
     qwen_local_base_url: str = "http://localhost:8000/v1"
     qwen_local_base_url: str = "http://localhost:8000/v1"
     qwen_local_model: str = "Qwen3-35B-A3B"
     qwen_local_model: str = "Qwen3-35B-A3B"
 
 

+ 249 - 0
backend-python/app/core/llm_client.py

@@ -1,3 +1,4 @@
+import base64
 from typing import AsyncIterator, Optional
 from typing import AsyncIterator, Optional
 
 
 from openai import AsyncOpenAI
 from openai import AsyncOpenAI
@@ -22,20 +23,31 @@ class LLMClient:
             base_url=settings.qwen_base_url,
             base_url=settings.qwen_base_url,
         )
         )
         self.model = settings.qwen_model
         self.model = settings.qwen_model
+        self.vl_model = settings.qwen_vl_model
+        self.enable_web_search = settings.enable_web_search
+
+    # ============================================================
+    # 纯文本对话
+    # ============================================================
 
 
     async def chat(
     async def chat(
         self,
         self,
         messages: list[dict],
         messages: list[dict],
         temperature: Optional[float] = None,
         temperature: Optional[float] = None,
         max_tokens: Optional[int] = None,
         max_tokens: Optional[int] = None,
+        enable_search: bool = False,
     ) -> str:
     ) -> str:
         self._ensure_client()
         self._ensure_client()
         settings = get_settings()
         settings = get_settings()
+        extra = {}
+        if enable_search:
+            extra["enable_search"] = True
         response = await self._client.chat.completions.create(
         response = await self._client.chat.completions.create(
             model=self.model,
             model=self.model,
             messages=messages,
             messages=messages,
             temperature=temperature or settings.qwen_temperature,
             temperature=temperature or settings.qwen_temperature,
             max_tokens=max_tokens or settings.qwen_max_tokens,
             max_tokens=max_tokens or settings.qwen_max_tokens,
+            extra_body=extra if extra else None,
         )
         )
         return response.choices[0].message.content or ""
         return response.choices[0].message.content or ""
 
 
@@ -44,19 +56,256 @@ class LLMClient:
         messages: list[dict],
         messages: list[dict],
         temperature: Optional[float] = None,
         temperature: Optional[float] = None,
         max_tokens: Optional[int] = None,
         max_tokens: Optional[int] = None,
+        enable_search: bool = False,
     ) -> AsyncIterator[str]:
     ) -> AsyncIterator[str]:
         self._ensure_client()
         self._ensure_client()
         settings = get_settings()
         settings = get_settings()
+        extra = {}
+        if enable_search:
+            extra["enable_search"] = True
         stream = await self._client.chat.completions.create(
         stream = await self._client.chat.completions.create(
             model=self.model,
             model=self.model,
             messages=messages,
             messages=messages,
             temperature=temperature or settings.qwen_temperature,
             temperature=temperature or settings.qwen_temperature,
             max_tokens=max_tokens or settings.qwen_max_tokens,
             max_tokens=max_tokens or settings.qwen_max_tokens,
             stream=True,
             stream=True,
+            extra_body=extra if extra else None,
+        )
+        async for chunk in stream:
+            if chunk.choices and chunk.choices[0].delta.content:
+                yield chunk.choices[0].delta.content
+
+    # ============================================================
+    # 图片分析 + OCR(Qwen VL 视觉模型)
+    # ============================================================
+
+    async def analyze_image(
+        self,
+        image_base64: str,
+        mime_type: str = "image/jpeg",
+        prompt: str = "",
+    ) -> str:
+        """
+        调用 Qwen VL 模型分析图片:
+        - 描述图片内容(药品包装、说明书、处方等)
+        - 提取图中文字(OCR)
+        - 返回可用于 RAG 检索的文本
+        """
+        self._ensure_client()
+
+        if not prompt:
+            prompt = """请仔细分析这张图片,完成以下任务:
+1. 描述图片内容(药品包装、说明书、处方单、症状照片等)
+2. 提取图片中所有可见的文字(OCR),特别是药品名称、成分、用法用量、批号、有效期等关键信息
+3. 如果图片是药品包装/说明书,提取:药品通用名、规格、生产企业、批准文号
+4. 如果图片是处方,提取:患者信息、药品名称、用法用量、开具日期
+
+请按以下格式输出:
+
+【图片描述】
+图片内容的简要描述。
+
+【OCR 提取文字】
+逐条列出提取到的文字内容。
+"""
+
+        response = await self._client.chat.completions.create(
+            model=self.vl_model,
+            messages=[
+                {
+                    "role": "user",
+                    "content": [
+                        {
+                            "type": "image_url",
+                            "image_url": {
+                                "url": f"data:{mime_type};base64,{image_base64}"
+                            },
+                        },
+                        {"type": "text", "text": prompt},
+                    ],
+                }
+            ],
+            max_tokens=2048,
+        )
+        return response.choices[0].message.content or ""
+
+    async def analyze_image_stream(
+        self,
+        image_base64: str,
+        mime_type: str = "image/jpeg",
+        prompt: str = "",
+    ) -> AsyncIterator[str]:
+        """流式版本:Qwen VL 分析图片"""
+        self._ensure_client()
+
+        if not prompt:
+            prompt = """请仔细分析这张图片,完成以下任务:
+1. 描述图片内容(药品包装、说明书、处方单、症状照片等)
+2. 提取图片中所有可见的文字(OCR),特别是药品名称、成分、用法用量等关键信息
+3. 如果图片是药品包装/说明书,提取:药品通用名、规格、生产企业、批准文号
+请简要输出分析结果。"""
+
+        stream = await self._client.chat.completions.create(
+            model=self.vl_model,
+            messages=[
+                {
+                    "role": "user",
+                    "content": [
+                        {
+                            "type": "image_url",
+                            "image_url": {
+                                "url": f"data:{mime_type};base64,{image_base64}"
+                            },
+                        },
+                        {"type": "text", "text": prompt},
+                    ],
+                }
+            ],
+            max_tokens=2048,
+            stream=True,
         )
         )
         async for chunk in stream:
         async for chunk in stream:
             if chunk.choices and chunk.choices[0].delta.content:
             if chunk.choices and chunk.choices[0].delta.content:
                 yield chunk.choices[0].delta.content
                 yield chunk.choices[0].delta.content
 
 
+    # ============================================================
+    # 视频分析 + OCR(Qwen VL 模型支持视频帧提取分析)
+    # ============================================================
+
+    async def analyze_video(
+        self,
+        video_base64: str,
+        mime_type: str = "video/mp4",
+        prompt: str = "",
+    ) -> str:
+        """
+        调用 Qwen VL 模型分析视频:
+        - 提取关键帧并描述视频内容
+        - OCR 提取帧中所有可见文字
+        - 返回可用于 RAG 检索的文本
+        """
+        self._ensure_client()
+
+        if not prompt:
+            prompt = """请仔细分析这段视频,完成以下任务:
+1. 描述视频内容(药品展示、用药指导、症状表现等)
+2. 提取视频中所有可见的文字(OCR),特别是药品名称、成分、用法用量、批号等关键信息
+3. 如果视频中有药品包装/说明书,提取全部文字信息
+4. 总结视频传达的关键药学信息
+
+请按以下格式输出:
+
+【视频描述】
+视频内容的简要描述。
+
+【OCR 提取文字】
+逐条列出从视频帧中提取到的文字内容。
+
+【关键信息总结】
+与药学相关的关键信息摘要。"""
+
+        response = await self._client.chat.completions.create(
+            model=self.vl_model,
+            messages=[
+                {
+                    "role": "user",
+                    "content": [
+                        {
+                            "type": "video_url",
+                            "video_url": {
+                                "url": f"data:{mime_type};base64,{video_base64}"
+                            },
+                        },
+                        {"type": "text", "text": prompt},
+                    ],
+                }
+            ],
+            max_tokens=2048,
+        )
+        return response.choices[0].message.content or ""
+
+    async def analyze_video_stream(
+        self,
+        video_base64: str,
+        mime_type: str = "video/mp4",
+        prompt: str = "",
+    ) -> AsyncIterator[str]:
+        """流式版本:Qwen VL 分析视频"""
+        self._ensure_client()
+
+        if not prompt:
+            prompt = """请分析这段视频,提取关键帧中的文字信息(OCR),特别是药品名称、成分、用法用量等。简要输出。"""
+
+        stream = await self._client.chat.completions.create(
+            model=self.vl_model,
+            messages=[
+                {
+                    "role": "user",
+                    "content": [
+                        {
+                            "type": "video_url",
+                            "video_url": {
+                                "url": f"data:{mime_type};base64,{video_base64}"
+                            },
+                        },
+                        {"type": "text", "text": prompt},
+                    ],
+                }
+            ],
+            max_tokens=2048,
+            stream=True,
+        )
+        async for chunk in stream:
+            if chunk.choices and chunk.choices[0].delta.content:
+                yield chunk.choices[0].delta.content
+
+    # ============================================================
+    # 统一多模态分析(自动判断 image / video)
+    # ============================================================
+
+    async def analyze_media(
+        self,
+        media_base64: str,
+        media_type: str,  # "image" or "video"
+        mime_type: str = "",
+        prompt: str = "",
+    ) -> str:
+        """统一入口:根据 media_type 自动路由到图片或视频分析"""
+        if media_type == "video":
+            return await self.analyze_video(
+                media_base64,
+                mime_type=mime_type or "video/mp4",
+                prompt=prompt,
+            )
+        else:
+            return await self.analyze_image(
+                media_base64,
+                mime_type=mime_type or "image/jpeg",
+                prompt=prompt,
+            )
+
+    async def analyze_media_stream(
+        self,
+        media_base64: str,
+        media_type: str,
+        mime_type: str = "",
+        prompt: str = "",
+    ) -> AsyncIterator[str]:
+        """流式统一入口"""
+        if media_type == "video":
+            async for token in self.analyze_video_stream(
+                media_base64,
+                mime_type=mime_type or "video/mp4",
+                prompt=prompt,
+            ):
+                yield token
+        else:
+            async for token in self.analyze_image_stream(
+                media_base64,
+                mime_type=mime_type or "image/jpeg",
+                prompt=prompt,
+            ):
+                yield token
+
 
 
 llm_client = LLMClient()
 llm_client = LLMClient()

+ 55 - 22
backend-python/app/rag/prompt.py

@@ -1,28 +1,40 @@
 """
 """
 多场景 Prompt 模板 — 六种意图 + 兜底
 多场景 Prompt 模板 — 六种意图 + 兜底
+输出顺序:结论 → 详细内容 → 来源明细 → AI 声明
 每条药典信息必须标注实际来源名称(非数字编号)
 每条药典信息必须标注实际来源名称(非数字编号)
 """
 """
 import sys
 import sys
 from pathlib import Path
 from pathlib import Path
 sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent))
 sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent))
 
 
-COMPLIANCE = f"""
-—— 每条药典信息标注来源名称(如"2025版药典二部P567""维基百科")不用数字编号
+# ============================================================
+# 统一的合规要求(AI 声明放在最后)
+# ============================================================
+COMPLIANCE = """
+—— 每条药典信息标注来源名称(如"2025版药典二部P567""维基百科"),不用数字编号
+—— 引用网络来源时必须附带完整 URL(如 https://zh.wikipedia.org/wiki/阿莫西林),方便用户直接点击查看
 —— 不得编造药典版本号、页码
 —— 不得编造药典版本号、页码
 —— 通用知识标注为【通用药学知识】
 —— 通用知识标注为【通用药学知识】
 —— 所有段落标题必须用全角【标题名】格式,前后括号完整,不得省略前半
 —— 所有段落标题必须用全角【标题名】格式,前后括号完整,不得省略前半
-—— 文末附「本回答由AI生成,仅供参考」
+—— 回答末尾必须包含【AI 声明】段落
+"""
+
+AI_DISCLAIMER = """
+【AI 声明】
+本回答由 AI 生成,仅供参考,不构成处方或用药建议。
+用药前请阅读药品说明书,处方药请在医师或药师指导下使用。
 """
 """
 
 
 # ============================================================
 # ============================================================
 # 1. 药品信息查询
 # 1. 药品信息查询
+# 回答顺序:结论 → 详细说明 → 注意事项 → 来源明细 → AI 声明
 # ============================================================
 # ============================================================
 DRUG_QUERY = f"""你是中华药典AI助手。
 DRUG_QUERY = f"""你是中华药典AI助手。
 
 
-回答格式:
+回答格式(严格按以下顺序输出)
 
 
 【结论】
 【结论】
-1-2句话。
+1-2句话概括
 
 
 【详细说明】
 【详细说明】
 - 每条信息后标注实际来源名称(如"2025版药典二部P567""维基百科")
 - 每条信息后标注实际来源名称(如"2025版药典二部P567""维基百科")
@@ -33,14 +45,16 @@ DRUG_QUERY = f"""你是中华药典AI助手。
 
 
 【来源明细】
 【来源明细】
 列出本题引用的所有资料名称及出处
 列出本题引用的所有资料名称及出处
-{COMPLIANCE}"""
+{COMPLIANCE}
+{AI_DISCLAIMER}"""
 
 
 # ============================================================
 # ============================================================
 # 2. 用法用量 / 安全咨询
 # 2. 用法用量 / 安全咨询
+# 回答顺序:结论 → 用法用量 → 禁忌 → 不良反应 → 注意事项 → 来源明细 → AI 声明
 # ============================================================
 # ============================================================
 USAGE_GUIDE = f"""你是中华药典用药指导助手。
 USAGE_GUIDE = f"""你是中华药典用药指导助手。
 
 
-回答格式:
+回答格式(严格按以下顺序输出)
 
 
 【结论】
 【结论】
 一句话建议。
 一句话建议。
@@ -62,14 +76,16 @@ USAGE_GUIDE = f"""你是中华药典用药指导助手。
 
 
 【安全提醒】
 【安全提醒】
 「请在医师或药师指导下用药」
 「请在医师或药师指导下用药」
-{COMPLIANCE}"""
+{COMPLIANCE}
+{AI_DISCLAIMER}"""
 
 
 # ============================================================
 # ============================================================
 # 3. 法规条款查询
 # 3. 法规条款查询
+# 回答顺序:摘要 → 原文引用 → 条款出处 → 关联条款 → 来源明细 → AI 声明
 # ============================================================
 # ============================================================
 REGULATION = f"""你是药典法规条款查询助手。
 REGULATION = f"""你是药典法规条款查询助手。
 
 
-回答格式:
+回答格式(严格按以下顺序输出)
 
 
 【摘要】
 【摘要】
 一句话概述该条款内容。
 一句话概述该条款内容。
@@ -82,14 +98,19 @@ REGULATION = f"""你是药典法规条款查询助手。
 
 
 【关联条款】
 【关联条款】
 其他相关条款编号及简要说明(如有)
 其他相关条款编号及简要说明(如有)
-{COMPLIANCE}"""
+
+【来源明细】
+列出本题引用的所有资料名称及出处
+{COMPLIANCE}
+{AI_DISCLAIMER}"""
 
 
 # ============================================================
 # ============================================================
 # 4. 执业药师考试辅导
 # 4. 执业药师考试辅导
+# 回答顺序:考点定位 → 知识要点 → 记忆技巧 → 考试频率 → 来源 → AI 声明
 # ============================================================
 # ============================================================
 EXAM_TUTOR = f"""你是执业药师考试辅导助手。
 EXAM_TUTOR = f"""你是执业药师考试辅导助手。
 
 
-回答格式:
+回答格式(严格按以下顺序输出)
 
 
 【考点定位】
 【考点定位】
 一句话定位考点(科目-章节-知识点)。
 一句话定位考点(科目-章节-知识点)。
@@ -103,18 +124,18 @@ EXAM_TUTOR = f"""你是执业药师考试辅导助手。
 【考试频率】
 【考试频率】
 高频 / 中频 / 低频。
 高频 / 中频 / 低频。
 
 
-【来源】
+【来源明细
 大纲章节 + 药典出处 + 参考资料名称
 大纲章节 + 药典出处 + 参考资料名称
-{COMPLIANCE}"""
+{COMPLIANCE}
+{AI_DISCLAIMER}"""
 
 
 # ============================================================
 # ============================================================
-# 5. 症状用药建议(新增)
+# 5. 症状用药建议
+# 回答顺序:病情评估 → 用药方案 → 非药物建议 → 注意事项 → 就医指征 → 来源明细 → AI 声明
 # ============================================================
 # ============================================================
 SYMPTOM_ADVICE = f"""你是AI药典用药助手。用户描述症状寻求用药建议。
 SYMPTOM_ADVICE = f"""你是AI药典用药助手。用户描述症状寻求用药建议。
 
 
-回答格式(严格遵守):
-
-⚠️ 以下为通用用药建议,请以药品说明书及医师指导为准。
+回答格式(严格按以下顺序输出):
 
 
 【病情评估】
 【病情评估】
 严重程度判断 + 是否需要立即就医。
 严重程度判断 + 是否需要立即就医。
@@ -139,11 +160,14 @@ SYMPTOM_ADVICE = f"""你是AI药典用药助手。用户描述症状寻求用药
 列出引用的所有资料名称
 列出引用的所有资料名称
 
 
 【免责声明】
 【免责声明】
+⚠️ 以下为通用用药建议,请以药品说明书及医师指导为准。
 本回答为AI用药参考,不构成处方建议。用药前请阅读药品说明书,处方药请在医师指导下使用。
 本回答为AI用药参考,不构成处方建议。用药前请阅读药品说明书,处方药请在医师指导下使用。
-{COMPLIANCE}"""
+{COMPLIANCE}
+{AI_DISCLAIMER}"""
 
 
 # ============================================================
 # ============================================================
 # 6. 无资料兜底
 # 6. 无资料兜底
+# 回答顺序:结论 → 通用知识 → 来源说明 → AI 声明
 # ============================================================
 # ============================================================
 NO_DOCS = f"""你是AI药典助手。知识库中未检索到与用户问题直接相关的药典原文。
 NO_DOCS = f"""你是AI药典助手。知识库中未检索到与用户问题直接相关的药典原文。
 
 
@@ -155,10 +179,19 @@ NO_DOCS = f"""你是AI药典助手。知识库中未检索到与用户问题直
 5. 不得推荐未在中国获批的药品
 5. 不得推荐未在中国获批的药品
 6. 若无法确定安全答案,直接建议就医
 6. 若无法确定安全答案,直接建议就医
 7. 每条信息标注为【通用药学知识】
 7. 每条信息标注为【通用药学知识】
-8. 结尾附:
-   「声明:上述内容来源为AI通用药学知识库,非《中国药典》原文。」
-   「本回答由AI生成,仅供参考。」
-{COMPLIANCE}"""
+
+回答格式(严格按以下顺序输出):
+
+【结论】
+一句话回答用户问题。
+
+【详细说明】
+基于通用药学知识作答,每条标注【通用药学知识】。
+
+【来源说明】
+声明:上述内容来源为AI通用药学知识库,非《中国药典》原文。
+{COMPLIANCE}
+{AI_DISCLAIMER}"""
 
 
 
 
 PROMPT_MAP = {
 PROMPT_MAP = {

+ 79 - 4
frontend-web/index.html

@@ -41,6 +41,9 @@ body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei
 @keyframes blink{0%,100%{opacity:1}50%{opacity:0}}
 @keyframes blink{0%,100%{opacity:1}50%{opacity:0}}
 .source{margin-top:10px;padding-top:8px;border-top:1px solid #eee;font-size:11px;color:#aaa;line-height:1.6}
 .source{margin-top:10px;padding-top:8px;border-top:1px solid #eee;font-size:11px;color:#aaa;line-height:1.6}
 .source span{color:#888;font-weight:500}
 .source span{color:#888;font-weight:500}
+.ext-link{color:#002FA7;text-decoration:underline;word-break:break-all}
+.ext-link:hover{color:#C41E3A}
+.ext-link:visited{color:#6a1b9a}
 .input-area{display:flex;padding:10px 14px;background:#fff;border-top:1px solid #e0e0e0;gap:8px;flex-shrink:0}
 .input-area{display:flex;padding:10px 14px;background:#fff;border-top:1px solid #e0e0e0;gap:8px;flex-shrink:0}
 .input-area input{flex:1;padding:10px 14px;border:1px solid #ddd;border-radius:24px;font-size:14px;outline:none;min-height:44px}
 .input-area input{flex:1;padding:10px 14px;border:1px solid #ddd;border-radius:24px;font-size:14px;outline:none;min-height:44px}
 .input-area input:focus{border-color:var(--bk);box-shadow:0 0 0 2px rgba(0,47,167,.12)}
 .input-area input:focus{border-color:var(--bk);box-shadow:0 0 0 2px rgba(0,47,167,.12)}
@@ -48,6 +51,8 @@ body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei
 .btn-primary{background:var(--bk);color:#fff}
 .btn-primary{background:var(--bk);color:#fff}
 .btn-danger{background:var(--rd);color:#fff}
 .btn-danger{background:var(--rd);color:#fff}
 .btn-secondary{background:#999;color:#fff}
 .btn-secondary{background:#999;color:#fff}
+.btn-upload{background:#f0f0f0;color:#333;font-size:18px;padding:6px 10px!important;min-height:44px;line-height:1}
+.btn-upload:hover{background:#e0e0e0}
 .btn:disabled{opacity:.4;cursor:default}
 .btn:disabled{opacity:.4;cursor:default}
 .tags{display:flex;gap:6px;padding:6px 14px 10px;flex-wrap:wrap;flex-shrink:0}
 .tags{display:flex;gap:6px;padding:6px 14px 10px;flex-wrap:wrap;flex-shrink:0}
 .tag-btn{font-size:12px;padding:5px 12px;border-radius:16px;border:1px solid rgba(0,47,167,.2);background:var(--bk-l);color:var(--bk);cursor:pointer;white-space:nowrap;min-height:36px;display:flex;align-items:center;-webkit-user-select:none;user-select:none}
 .tag-btn{font-size:12px;padding:5px 12px;border-radius:16px;border:1px solid rgba(0,47,167,.2);background:var(--bk-l);color:var(--bk);cursor:pointer;white-space:nowrap;min-height:36px;display:flex;align-items:center;-webkit-user-select:none;user-select:none}
@@ -84,7 +89,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei
 <div id="tabChat">
 <div id="tabChat">
   <div class="chat" id="chat"><div class="welcome"><div class="icon"></div><p>输入药品名称、成分或用药问题<br><small style="color:#aaa">示例:阿莫西林禁忌 / 布洛芬用法用量</small></p></div></div>
   <div class="chat" id="chat"><div class="welcome"><div class="icon"></div><p>输入药品名称、成分或用药问题<br><small style="color:#aaa">示例:阿莫西林禁忌 / 布洛芬用法用量</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="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="input-area"><input id="userInput" placeholder="输入药品问题..."><button class="btn btn-danger" id="sendBtn" onclick="send()">发送</button><button class="btn btn-secondary" id="stopBtn" onclick="stopCurrent()" style="display:none">停止</button></div>
+  <div class="input-area"><input id="userInput" placeholder="输入药品问题..."><input type="file" id="imageInput" accept="image/*" onchange="onFilePicked('image')" style="display:none"><input type="file" id="videoInput" accept="video/*" onchange="onFilePicked('video')" style="display:none"><button class="btn btn-upload" id="imageBtn" onclick="document.getElementById('imageInput').click()" title="上传图片">📷</button><button class="btn btn-upload" id="videoBtn" onclick="document.getElementById('videoInput').click()" title="上传视频">🎬</button><button class="btn btn-danger" id="sendBtn" onclick="send()">发送</button><button class="btn btn-secondary" id="stopBtn" onclick="stopCurrent()" style="display:none">停止</button></div>
+  <div id="previewArea" style="display:none;padding:6px 14px;background:#fff;border-top:1px solid #eee;display:none;align-items:center;gap:8px">
+    <span id="previewLabel" style="font-size:12px;color:#888"></span>
+    <img id="previewThumb" style="max-width:80px;max-height:60px;border-radius:6px;display:none">
+    <video id="previewVid" style="max-width:120px;max-height:68px;border-radius:6px;display:none" muted></video>
+    <button class="btn btn-secondary" style="font-size:11px;padding:2px 10px;min-height:auto" onclick="clearMedia()">✕ 移除</button>
+  </div>
 </div>
 </div>
 <div id="tabDrugs" class="hidden">
 <div id="tabDrugs" class="hidden">
   <div class="search-box"><input id="drugSearchInput" placeholder="搜索药品名称..." oninput="searchDrugs()"></div>
   <div class="search-box"><input id="drugSearchInput" placeholder="搜索药品名称..." oninput="searchDrugs()"></div>
@@ -114,8 +125,43 @@ function intentLabel(i){var m={drug_query:'药品查询',usage_guide:'用法用
 function tagClass(i){var m={drug_query:'tag-drug',usage_guide:'tag-usage',symptom_advice:'tag-symptom',regulation:'tag-regulation',exam_tutor:'tag-exam'};return m[i]||'tag-fallback'}
 function tagClass(i){var m={drug_query:'tag-drug',usage_guide:'tag-usage',symptom_advice:'tag-symptom',regulation:'tag-regulation',exam_tutor:'tag-exam'};return m[i]||'tag-fallback'}
 function esc(s){if(!s)return'';return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
 function esc(s){if(!s)return'';return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
 function escAttr(s){if(!s)return'';return s.replace(/"/g,'&quot;').replace(/'/g,'&#39;')}
 function escAttr(s){if(!s)return'';return s.replace(/"/g,'&quot;').replace(/'/g,'&#39;')}
-function md2html(h){if(!h)return'';h=esc(h);h=h.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>');h=h.replace(/⚠️?\s*([^\n<]+)/g,'<span class="warn">$1</span>');var s='(?:结论|详细说明|注意事项|来源明细|适应症|用法与用量|用法用量|禁忌|不良反应|副作用|药理|药物相互作用|贮藏|特殊人群|安全提醒|就医指征|非药物建议|用药建议|用药方案|通用药学知识|来源汇总|病情评估|处理方案|就医指征|免责声明)';h=h.replace(new RegExp('(?<![【])】\\s*('+s+')','g'),'【$1】');h=h.replace(new RegExp('(?<![【])('+s+')】','g'),'【$1】');h=h.replace(/(?<![<br>\n])【(.+?)】/g,'<br>【$1】');h=h.replace(/\n\n+/g,'</p><p>');h=h.replace(/\n/g,'<br>');h=h.replace(/【(.+?)】/g,'<h3>【$1】</h3>');h=h.replace(/(?:<br>|^)[-*•]\s+(.+?)(?=<br>|$)/g,'<li>$1</li>');h=h.replace(/((?:<li>.*?<\/li>)+)/g,'<ul>$1</ul>');h=h.replace(/(?:<br>|^)(\d+)\.\s+(.+?)(?=<br>|$)/g,'<li>$2</li>');h=h.replace(/((?:<li>.*?<\/li>)+)/g,function(m){return m.indexOf('<ul>')>=0?m:'<ol>'+m+'</ol>'});h='<p>'+h+'</p>';h=h.replace(/<p>(<br>)*<\/p>/g,'');h=h.replace(/<p><\/p>/g,'');return h}
+function md2html(h){if(!h)return'';h=esc(h);h=h.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>');h=h.replace(/⚠️?\s*([^\n<]+)/g,'<span class="warn">$1</span>');var s='(?:结论|详细说明|注意事项|来源明细|适应症|用法与用量|用法用量|禁忌|不良反应|副作用|药理|药物相互作用|贮藏|特殊人群|安全提醒|就医指征|非药物建议|用药建议|用药方案|通用药学知识|来源汇总|病情评估|处理方案|就医指征|免责声明|AI 声明|AI声明|来源说明|安全提醒)';h=h.replace(new RegExp('(?<![【])】\\s*('+s+')','g'),'【$1】');h=h.replace(new RegExp('(?<![【])('+s+')】','g'),'【$1】');h=h.replace(/(?<![<br>\n])【(.+?)】/g,'<br>【$1】');h=h.replace(/\n\n+/g,'</p><p>');h=h.replace(/\n/g,'<br>');h=h.replace(/【(.+?)】/g,'<h3>【$1】</h3>');h=h.replace(/(?:<br>|^)[-*•]\s+(.+?)(?=<br>|$)/g,'<li>$1</li>');h=h.replace(/((?:<li>.*?<\/li>)+)/g,'<ul>$1</ul>');h=h.replace(/(?:<br>|^)(\d+)\.\s+(.+?)(?=<br>|$)/g,'<li>$2</li>');h=h.replace(/((?:<li>.*?<\/li>)+)/g,function(m){return m.indexOf('<ul>')>=0?m:'<ol>'+m+'</ol>'});h='<p>'+h+'</p>';h=h.replace(/<p>(<br>)*<\/p>/g,'');h=h.replace(/<p><\/p>/g,'');h=h.replace(/(https?:\/\/[^\s<>"')、。,;《》]+)/g,'<a href="$1" target="_blank" rel="noopener" class="ext-link">$1</a>');return h}
 var STREAMING=false,streamTimer=null,messageQueue=[],currentIntent='';
 var STREAMING=false,streamTimer=null,messageQueue=[],currentIntent='';
+var pendingMedia=null; // {type:'image'|'video', base64:'...', mime:'...', name:'...'}
+function onFilePicked(type){
+  var input=document.getElementById(type==='video'?'videoInput':'imageInput');
+  var file=input.files[0];if(!file)return;
+  if(type==='video'&&file.size>50*1024*1024){alert('视频不能超过50MB');input.value='';return}
+  if(type==='image'&&file.size>10*1024*1024){alert('图片不能超过10MB');input.value='';return}
+  var reader=new FileReader();
+  reader.onload=function(e){
+    var b64=e.target.result.split(',')[1];
+    pendingMedia={type:type,base64:b64,mime:file.type||(type==='video'?'video/mp4':'image/jpeg'),name:file.name};
+    // 显示预览
+    var preview=document.getElementById('previewArea');preview.style.display='flex';
+    document.getElementById('previewLabel').textContent=(type==='video'?'🎬 ':'📷 ')+file.name;
+    if(type==='image'){
+      document.getElementById('previewThumb').src=e.target.result;document.getElementById('previewThumb').style.display='block';
+      document.getElementById('previewVid').style.display='none';
+    }else{
+      document.getElementById('previewVid').src=e.target.result;document.getElementById('previewVid').style.display='block';
+      document.getElementById('previewThumb').style.display='none';
+    }
+    // 自动填入提示文字
+    var inp=document.getElementById('userInput');
+    if(!inp.value.trim())inp.placeholder=type==='video'?'视频已就绪,可输入补充问题...':'图片已就绪,可输入补充问题...';
+  };
+  reader.readAsDataURL(file);
+}
+function clearMedia(){
+  pendingMedia=null;
+  document.getElementById('previewArea').style.display='none';
+  document.getElementById('imageInput').value='';
+  document.getElementById('videoInput').value='';
+  document.getElementById('previewThumb').style.display='none';
+  document.getElementById('previewVid').style.display='none';
+  document.getElementById('userInput').placeholder='输入药品问题...';
+}
 function addMsg(role,text,streaming,intent){
 function addMsg(role,text,streaming,intent){
   var chat=document.getElementById('chat'),div=document.createElement('div');
   var chat=document.getElementById('chat'),div=document.createElement('div');
   div.className='msg '+role+(streaming?' streaming':'');
   div.className='msg '+role+(streaming?' streaming':'');
@@ -127,9 +173,38 @@ function addMsg(role,text,streaming,intent){
   chat.appendChild(div);chat.scrollTop=chat.scrollHeight;return div;
   chat.appendChild(div);chat.scrollTop=chat.scrollHeight;return div;
 }
 }
 window.quickAsk=function(t){document.getElementById('userInput').value=t;send()};
 window.quickAsk=function(t){document.getElementById('userInput').value=t;send()};
-window.send=function(){var input=document.getElementById('userInput'),text=input.value.trim();if(!text)return;input.value='';messageQueue.push(text);if(!STREAMING)processQueue()};
+window.send=function(){
+  var input=document.getElementById('userInput'),text=input.value.trim();
+  var hasMedia=!!pendingMedia;
+  if(!text&&!hasMedia)return;
+  input.value='';
+  var payload={text:text};
+  if(hasMedia){payload.media=pendingMedia;pendingMedia=null;clearMedia()}
+  messageQueue.push(payload);
+  if(!STREAMING)processQueue()
+};
 window.stopCurrent=function(){STREAMING=false;if(streamTimer){clearInterval(streamTimer);streamTimer=null}messageQueue=[];var msgs=document.getElementById('chat').querySelectorAll('.msg.ai');if(msgs.length){var last=msgs[msgs.length-1],c=last.querySelector('.content');if(c){var cur=c.innerHTML.replace(/<span class="cursor">\|<\/span>/g,'');c.innerHTML=cur+' <span style="color:var(--rd)"> 已停止</span>'}last.classList.remove('streaming')}document.getElementById('sendBtn').disabled=false;document.getElementById('stopBtn').style.display='none'};
 window.stopCurrent=function(){STREAMING=false;if(streamTimer){clearInterval(streamTimer);streamTimer=null}messageQueue=[];var msgs=document.getElementById('chat').querySelectorAll('.msg.ai');if(msgs.length){var last=msgs[msgs.length-1],c=last.querySelector('.content');if(c){var cur=c.innerHTML.replace(/<span class="cursor">\|<\/span>/g,'');c.innerHTML=cur+' <span style="color:var(--rd)"> 已停止</span>'}last.classList.remove('streaming')}document.getElementById('sendBtn').disabled=false;document.getElementById('stopBtn').style.display='none'};
-function processQueue(){if(STREAMING||messageQueue.length===0)return;STREAMING=true;var text=messageQueue.shift();document.getElementById('sendBtn').disabled=true;document.getElementById('stopBtn').style.display='inline-block';var w=document.getElementById('chat').querySelector('.welcome');if(w)w.remove();addMsg('user',text);var intent=classifyIntent(text),fullAnswer=mockResponse(text),parts=fullAnswer.split('\n'),answerIntent=parts[0];if(/^[a-z_]+$/.test(answerIntent)){intent=answerIntent;fullAnswer=parts.slice(1).join('\n')}currentIntent=intent;var aiDiv=addMsg('ai','',true,intent),contentDiv=aiDiv.querySelector('.content');contentDiv.innerHTML='<div class="thinking"><span class="think-dot"></span> 检索中...</div>';var typingDelay=Math.min(25,Math.max(8,fullAnswer.length>500?10:15)),idx=0;streamTimer=setInterval(function(){if(!STREAMING||idx>=fullAnswer.length){clearInterval(streamTimer);streamTimer=null;contentDiv.innerHTML=md2html(fullAnswer);var c=contentDiv.querySelector('.cursor');if(c)c.remove();aiDiv.classList.remove('streaming');STREAMING=false;document.getElementById('sendBtn').disabled=false;document.getElementById('stopBtn').style.display='none';document.getElementById('chat').scrollTop=document.getElementById('chat').scrollHeight;if(messageQueue.length>0)setTimeout(processQueue,300);return}idx++;contentDiv.innerHTML=md2html(fullAnswer.substring(0,idx))+'<span class="cursor">|</span>';document.getElementById('chat').scrollTop=document.getElementById('chat').scrollHeight},typingDelay)}
+function processQueue(){if(STREAMING||messageQueue.length===0)return;STREAMING=true;var payload=messageQueue.shift();var text=typeof payload==='string'?payload:(payload.text||'');var media=typeof payload==='string'?null:payload.media;
+document.getElementById('sendBtn').disabled=true;document.getElementById('stopBtn').style.display='inline-block';
+var w=document.getElementById('chat').querySelector('.welcome');if(w)w.remove();
+// 显示用户消息
+var userDisplay=text;
+if(media){
+  var mediaIcon=media.type==='video'?'🎬 [视频] ':'📷 [图片] ';
+  userDisplay=mediaIcon+(text||'请分析此'+media.type==='video'?'视频':'图片');
+}
+addMsg('user',userDisplay);
+// 拼接查询
+var query=text;
+if(media){query=text?text+' (含'+media.type==='video'?'视频':'图片'+'附件)':'请分析'+(media.type==='video'?'这段视频':'这张图片')+'中的药品信息'}
+var intent=classifyIntent(query),fullAnswer=mockResponse(query),parts=fullAnswer.split('\n'),answerIntent=parts[0];
+if(/^[a-z_]+$/.test(answerIntent)){intent=answerIntent;fullAnswer=parts.slice(1).join('\n')}
+// 如果有媒体,追加 OCR 模拟信息
+if(media){fullAnswer='【'+media.type==='video'?'视频':'图片'+'分析】已识别媒体文件: '+media.name+'\n\n'+fullAnswer}
+currentIntent=intent;var aiDiv=addMsg('ai','',true,intent),contentDiv=aiDiv.querySelector('.content');
+contentDiv.innerHTML='<div class="thinking"><span class="think-dot"></span> 检索中...</div>';
+var typingDelay=Math.min(25,Math.max(8,fullAnswer.length>500?10:15)),idx=0;
+streamTimer=setInterval(function(){if(!STREAMING||idx>=fullAnswer.length){clearInterval(streamTimer);streamTimer=null;contentDiv.innerHTML=md2html(fullAnswer);var c=contentDiv.querySelector('.cursor');if(c)c.remove();aiDiv.classList.remove('streaming');STREAMING=false;document.getElementById('sendBtn').disabled=false;document.getElementById('stopBtn').style.display='none';document.getElementById('chat').scrollTop=document.getElementById('chat').scrollHeight;if(messageQueue.length>0)setTimeout(processQueue,300);return}idx++;contentDiv.innerHTML=md2html(fullAnswer.substring(0,idx))+'<span class="cursor">|</span>';document.getElementById('chat').scrollTop=document.getElementById('chat').scrollHeight},typingDelay)}
 window.searchDrugs=function(){var list=document.getElementById('drugList'),kw=(document.getElementById('drugSearchInput').value||'').trim().toLowerCase();if(!kw){renderDrugCards(Object.values(DRUG_DB));return}var filtered=Object.values(DRUG_DB).filter(function(d){return d.name.includes(kw)||(d.pinyin||'').toLowerCase().includes(kw)||(d.name_en||'').toLowerCase().includes(kw)||(d.category||'').includes(kw)});renderDrugCards(filtered)};
 window.searchDrugs=function(){var list=document.getElementById('drugList'),kw=(document.getElementById('drugSearchInput').value||'').trim().toLowerCase();if(!kw){renderDrugCards(Object.values(DRUG_DB));return}var filtered=Object.values(DRUG_DB).filter(function(d){return d.name.includes(kw)||(d.pinyin||'').toLowerCase().includes(kw)||(d.name_en||'').toLowerCase().includes(kw)||(d.category||'').includes(kw)});renderDrugCards(filtered)};
 function renderDrugCards(items){var list=document.getElementById('drugList'),h='';if(!items.length){list.innerHTML='<div class="welcome"><div class="icon"></div><p>未找到匹配的药品</p></div>';return}for(var i=0;i<items.length;i++){var d=items[i];h+='<div class="drug-card" onclick="loadDrugDetail(\''+escAttr(d.drug_id)+'\')"><h3>'+esc(d.name)+'</h3><div class="meta">'+(d.category||'')+' '+(d.subcategory||'')+' · '+(d.source_version||'')+'</div></div>'}list.innerHTML=h}
 function renderDrugCards(items){var list=document.getElementById('drugList'),h='';if(!items.length){list.innerHTML='<div class="welcome"><div class="icon"></div><p>未找到匹配的药品</p></div>';return}for(var i=0;i<items.length;i++){var d=items[i];h+='<div class="drug-card" onclick="loadDrugDetail(\''+escAttr(d.drug_id)+'\')"><h3>'+esc(d.name)+'</h3><div class="meta">'+(d.category||'')+' '+(d.subcategory||'')+' · '+(d.source_version||'')+'</div></div>'}list.innerHTML=h}
 window.loadDrugDetail=function(drugId){var list=document.getElementById('drugList'),d=null,all=Object.values(DRUG_DB);for(var i=0;i<all.length;i++){if(all[i].drug_id===drugId){d=all[i];break}}if(!d){list.innerHTML='<div class="welcome"><div class="icon"></div><p>药品不存在</p></div>';return}var sections='';if(d.sections){for(var k in d.sections){sections+='<h3>【'+esc(k)+'】</h3><div class="section">'+d.sections[k]+'</div>'}}list.innerHTML='<div class="drug-detail"><h2>'+esc(d.name)+'</h2>'+(d.name_en?'<div class="info-row"><b>英文:</b>'+esc(d.name_en)+'</div>':'')+(d.pinyin?'<div class="info-row"><b>拼音:</b>'+esc(d.pinyin)+'</div>':'')+(d.category?'<div class="info-row"><b>分类:</b>'+esc(d.category)+' / '+esc(d.subcategory||'')+'</div>':'')+(d.source_version?'<div class="info-row"><b>来源:</b>'+esc(d.source_version)+' '+esc(d.source_volume||'')+' '+esc(d.source_page||'')+'</div>':'')+sections+'<button class="btn btn-primary" style="margin-top:16px;width:100%" onclick="searchDrugs()">← 返回列表</button></div>'}
 window.loadDrugDetail=function(drugId){var list=document.getElementById('drugList'),d=null,all=Object.values(DRUG_DB);for(var i=0;i<all.length;i++){if(all[i].drug_id===drugId){d=all[i];break}}if(!d){list.innerHTML='<div class="welcome"><div class="icon"></div><p>药品不存在</p></div>';return}var sections='';if(d.sections){for(var k in d.sections){sections+='<h3>【'+esc(k)+'】</h3><div class="section">'+d.sections[k]+'</div>'}}list.innerHTML='<div class="drug-detail"><h2>'+esc(d.name)+'</h2>'+(d.name_en?'<div class="info-row"><b>英文:</b>'+esc(d.name_en)+'</div>':'')+(d.pinyin?'<div class="info-row"><b>拼音:</b>'+esc(d.pinyin)+'</div>':'')+(d.category?'<div class="info-row"><b>分类:</b>'+esc(d.category)+' / '+esc(d.subcategory||'')+'</div>':'')+(d.source_version?'<div class="info-row"><b>来源:</b>'+esc(d.source_version)+' '+esc(d.source_volume||'')+' '+esc(d.source_page||'')+'</div>':'')+sections+'<button class="btn btn-primary" style="margin-top:16px;width:100%" onclick="searchDrugs()">← 返回列表</button></div>'}

+ 15 - 5
miniprogram/api/ai.js

@@ -41,13 +41,23 @@ export function chatStream(data, callbacks) {
   let buffer = ''
   let buffer = ''
   let currentEvent = ''
   let currentEvent = ''
 
 
+  // 自动选择端点:有媒体附件走 stream-multimodal,纯文本走 stream
+  const hasMedia = !!(data.media_base64 && data.media_type)
+  const endpoint = hasMedia ? '/chat/stream-multimodal' : '/chat/stream'
+  const reqData = {
+    message: data.message || '',
+    conversation_id: data.conversation_id || ''
+  }
+  if (hasMedia) {
+    reqData.media_type = data.media_type
+    reqData.media_base64 = data.media_base64
+    reqData.media_mime = data.media_mime || ''
+  }
+
   const task = uni.request({
   const task = uni.request({
-    url: BASE_URL + '/chat/stream',
+    url: BASE_URL + endpoint,
     method: 'POST',
     method: 'POST',
-    data: {
-      message: data.message,
-      conversation_id: data.conversation_id || ''
-    },
+    data: reqData,
     header: {
     header: {
       'Content-Type': 'application/json',
       'Content-Type': 'application/json',
       'Authorization': 'Bearer ' + getToken()
       'Authorization': 'Bearer ' + getToken()

+ 75 - 11
miniprogram/components/ai-chat/index.vue

@@ -11,15 +11,23 @@
             <view class="think-dot"></view>
             <view class="think-dot"></view>
             <text>{{ msg.thinking }}</text>
             <text>{{ msg.thinking }}</text>
           </view>
           </view>
-          <text v-html="renderMarkdown(msg.content)"></text>
+          <rich-text :nodes="renderMarkdown(msg.content)"></rich-text>
         </view>
         </view>
       </view>
       </view>
     </scroll-view>
     </scroll-view>
+    <!-- 媒体预览 -->
+    <view v-if="pendingMedia" class="media-preview">
+      <image v-if="pendingMedia.type==='image'" :src="pendingMedia.preview" mode="aspectFit" class="preview-thumb"/>
+      <text class="preview-name">{{ pendingMedia.type==='video'?'🎬':'📷' }} {{ pendingMedia.name }}</text>
+      <text class="preview-remove" @click="pendingMedia=null">✕</text>
+    </view>
     <view class="panel-input">
     <view class="panel-input">
+      <text class="mini-media-btn" @click="pickImage">📷</text>
+      <text class="mini-media-btn" @click="pickVideo">🎬</text>
       <input
       <input
         v-model="input"
         v-model="input"
         class="mini-input"
         class="mini-input"
-        placeholder="问关于这个药品..."
+        :placeholder="pendingMedia?(pendingMedia.type==='video'?'视频已就绪,输入问题...':'图片已就绪,输入问题...'):'问关于这个药品...'"
         @confirm="send"
         @confirm="send"
       />
       />
     </view>
     </view>
@@ -45,7 +53,8 @@ export default {
       input: '',
       input: '',
       streaming: false,
       streaming: false,
       conversationId: '',
       conversationId: '',
-      scrollTop: 0
+      scrollTop: 0,
+      pendingMedia: null
     }
     }
   },
   },
   watch: {
   watch: {
@@ -58,32 +67,81 @@ export default {
   methods: {
   methods: {
     close() { this.visible = false },
     close() { this.visible = false },
 
 
+    pickImage() {
+      uni.chooseImage({
+        count: 1, sizeType: ['compressed'],
+        success: (res) => {
+          const path = res.tempFilePaths[0]
+          this.fileToBase64(path, 'image', (b64, info) => {
+            this.pendingMedia = { type: 'image', base64: b64, mime: info.mime, name: info.name, preview: path }
+          })
+        }
+      })
+    },
+    pickVideo() {
+      uni.chooseVideo({
+        maxDuration: 60, compressed: true,
+        success: (res) => {
+          const path = res.tempFilePath
+          this.fileToBase64(path, 'video', (b64, info) => {
+            this.pendingMedia = { type: 'video', base64: b64, mime: info.mime, name: info.name, preview: path }
+          })
+        }
+      })
+    },
+    fileToBase64(path, type, cb) {
+      const fs = uni.getFileSystemManager()
+      try {
+        const data = fs.readFileSync(path, 'base64')
+        const name = path.split('/').pop() || (type === 'video' ? 'video.mp4' : 'photo.jpg')
+        const mime = type === 'video' ? 'video/mp4' : 'image/jpeg'
+        cb(data, { mime, name })
+      } catch (e) {
+        uni.showToast({ title: '读取文件失败', icon: 'none' })
+      }
+    },
+
     renderMarkdown(content) {
     renderMarkdown(content) {
       if (!content) return ''
       if (!content) return ''
       let h = content.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
       let h = content.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
       h = h.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
       h = h.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
-      var secs = '结论|详细说明|注意事项|来源明细|适应症|适应证|用法与用量|禁忌|不良反应|副作用|药理|贮藏|特殊人群|安全提醒|通用药学知识|来源汇总'
+      var secs = '结论|详细说明|注意事项|来源明细|适应症|适应证|用法与用量|禁忌|不良反应|副作用|药理|贮藏|特殊人群|安全提醒|通用药学知识|来源汇总|AI 声明|AI声明|来源说明|免责声明'
       h = h.replace(new RegExp('(?<![【])】\\s*(' + secs + ')', 'g'), '【$1】')
       h = h.replace(new RegExp('(?<![【])】\\s*(' + secs + ')', 'g'), '【$1】')
       h = h.replace(new RegExp('(?<![【])(' + secs + ')】', 'g'), '【$1】')
       h = h.replace(new RegExp('(?<![【])(' + secs + ')】', 'g'), '【$1】')
       h = h.replace(/\n\n+/g, '</p><p>')
       h = h.replace(/\n\n+/g, '</p><p>')
       h = h.replace(/\n/g, '<br>')
       h = h.replace(/\n/g, '<br>')
-      h = h.replace(/【(.+?)】/g, '<h3>【$1】</h3>')
-      return '<p>' + h + '</p>'
+      h = h.replace(/【(.+?)】/g, '<p style="font-size:30rpx;color:#002FA7;font-weight:bold;margin:16rpx 0 8rpx">【$1】</p>')
+      // URL 自动转可点击链接
+      h = h.replace(/(https?:\/\/[^\s<>"')、。,;《》]+)/g, '<a href="$1" style="color:#002FA7;text-decoration:underline;word-break:break-all">$1</a>')
+      return '<div style="font-size:28rpx;line-height:1.8;color:#1a1a2e;word-break:break-all">' + h + '</div>'
     },
     },
 
 
     async send() {
     async send() {
       const text = this.input.trim()
       const text = this.input.trim()
-      if (!text || this.streaming) return
+      const media = this.pendingMedia
+      if (!text && !media) return
+      if (this.streaming) return
       this.input = ''
       this.input = ''
+      this.pendingMedia = null
       this.streaming = true
       this.streaming = true
 
 
-      this.messages.push({ role: 'user', content: text })
+      let userDisplay = text
+      if (media) {
+        userDisplay = (media.type === 'video' ? '🎬 [视频] ' : '📷 [图片] ') + (text || '请分析')
+      }
+      this.messages.push({ role: 'user', content: userDisplay })
       const idx = this.messages.length
       const idx = this.messages.length
       this.messages.push({ role: 'assistant', content: '', thinking: '正在分析...' })
       this.messages.push({ role: 'assistant', content: '', thinking: '正在分析...' })
       this.scrollBottom()
       this.scrollBottom()
 
 
+      const reqBody = { message: text, conversation_id: this.conversationId }
+      if (media) {
+        reqBody.media_type = media.type
+        reqBody.media_base64 = media.base64
+        reqBody.media_mime = media.mime
+      }
       await chatStream(
       await chatStream(
-        { message: text, conversation_id: this.conversationId },
+        reqBody,
         {
         {
           onIntent: (d) => { this.messages[idx].intent = d },
           onIntent: (d) => { this.messages[idx].intent = d },
           onStatus: (d) => { this.messages[idx].thinking = d; this.scrollBottom() },
           onStatus: (d) => { this.messages[idx].thinking = d; this.scrollBottom() },
@@ -122,8 +180,14 @@ export default {
 .thinking { color: #888; font-size: 22rpx; display: flex; align-items: center; margin-bottom: 6rpx; }
 .thinking { color: #888; font-size: 22rpx; display: flex; align-items: center; margin-bottom: 6rpx; }
 .think-dot { width: 10rpx; height: 10rpx; border-radius: 50%; background: #002FA7; margin-right: 8rpx; animation: pulse 1s infinite; }
 .think-dot { width: 10rpx; height: 10rpx; border-radius: 50%; background: #002FA7; margin-right: 8rpx; animation: pulse 1s infinite; }
 @keyframes pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: .4; transform: scale(.7); } }
 @keyframes pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: .4; transform: scale(.7); } }
-.panel-input { padding: 12rpx 16rpx; border-top: 1rpx solid #eee; }
-.mini-input { height: 64rpx; background: #F5F6FA; border-radius: 32rpx; padding: 0 20rpx; font-size: 26rpx; }
+.media-preview { display: flex; align-items: center; padding: 8rpx 16rpx; background: #fafafa; gap: 8rpx; }
+.preview-thumb { width: 60rpx; height: 60rpx; border-radius: 6rpx; flex-shrink: 0; }
+.preview-name { font-size: 22rpx; color: #888; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.preview-remove { font-size: 24rpx; color: #C41E3A; padding: 4rpx; }
+
+.panel-input { display: flex; align-items: center; padding: 12rpx 16rpx; border-top: 1rpx solid #eee; gap: 6rpx; }
+.mini-media-btn { font-size: 32rpx; padding: 4rpx; min-width: 44rpx; text-align: center; flex-shrink: 0; }
+.mini-input { flex: 1; height: 64rpx; background: #F5F6FA; border-radius: 32rpx; padding: 0 20rpx; font-size: 26rpx; }
 .ai-trigger {
 .ai-trigger {
   position: fixed; right: 24rpx; bottom: 120rpx; width: 96rpx; height: 96rpx;
   position: fixed; right: 24rpx; bottom: 120rpx; width: 96rpx; height: 96rpx;
   background: linear-gradient(135deg, #002FA7, #1a3fbf); border-radius: 50%;
   background: linear-gradient(135deg, #002FA7, #1a3fbf); border-radius: 50%;

+ 93 - 13
miniprogram/pages/ai-yaodian/index.vue

@@ -27,7 +27,7 @@
             <text>{{ msg.thinking }}</text>
             <text>{{ msg.thinking }}</text>
           </view>
           </view>
           <!-- 正文 -->
           <!-- 正文 -->
-          <view class="msg-body" v-html="renderMarkdown(msg.content)"></view>
+          <rich-text class="msg-body" :nodes="renderMarkdown(msg.content)"></rich-text>
           <!-- 来源 -->
           <!-- 来源 -->
           <view v-if="msg.sources && msg.sources.length" class="msg-sources">
           <view v-if="msg.sources && msg.sources.length" class="msg-sources">
             <text>📚 </text>
             <text>📚 </text>
@@ -43,6 +43,14 @@
     <!-- 队列提示 -->
     <!-- 队列提示 -->
     <view v-if="queueCount > 0" class="queue-hint">⏳ 还有 {{ queueCount }} 个问题等待回答</view>
     <view v-if="queueCount > 0" class="queue-hint">⏳ 还有 {{ queueCount }} 个问题等待回答</view>
 
 
+    <!-- 媒体预览 -->
+    <view v-if="pendingMedia" class="media-preview">
+      <image v-if="pendingMedia.type==='image'" :src="pendingMedia.preview" mode="aspectFit" class="preview-thumb"/>
+      <video v-if="pendingMedia.type==='video'" :src="pendingMedia.preview" class="preview-vid"/>
+      <text class="preview-name">{{ pendingMedia.type==='video'?'🎬':'📷' }} {{ pendingMedia.name }}</text>
+      <text class="preview-remove" @click="clearMedia">✕</text>
+    </view>
+
     <view class="tags">
     <view class="tags">
       <text class="tag-btn" @click="quickAsk('阿莫西林禁忌')">阿莫西林禁忌</text>
       <text class="tag-btn" @click="quickAsk('阿莫西林禁忌')">阿莫西林禁忌</text>
       <text class="tag-btn" @click="quickAsk('二甲双胍不良反应')">二甲双胍不良反应</text>
       <text class="tag-btn" @click="quickAsk('二甲双胍不良反应')">二甲双胍不良反应</text>
@@ -51,15 +59,17 @@
     </view>
     </view>
 
 
     <view class="input-area">
     <view class="input-area">
+      <text class="media-btn" @click="pickImage">📷</text>
+      <text class="media-btn" @click="pickVideo">🎬</text>
       <input
       <input
         v-model="inputText"
         v-model="inputText"
         class="chat-input"
         class="chat-input"
-        placeholder="输入药品问题..."
+        :placeholder="pendingMedia?(pendingMedia.type==='video'?'视频已就绪,可输入补充问题...':'图片已就绪,可输入补充问题...'):'输入药品问题...'"
         :disabled="streaming"
         :disabled="streaming"
         @confirm="sendMessage"
         @confirm="sendMessage"
         confirm-type="send"
         confirm-type="send"
       />
       />
-      <button class="send-btn" :disabled="!inputText.trim()" @click="sendMessage">发送</button>
+      <button class="send-btn" :disabled="!inputText.trim() && !pendingMedia" @click="sendMessage">发送</button>
       <button v-if="streaming" class="stop-btn" @click="stopCurrent">停止</button>
       <button v-if="streaming" class="stop-btn" @click="stopCurrent">停止</button>
     </view>
     </view>
   </view>
   </view>
@@ -79,7 +89,8 @@ export default {
       messageQueue: [],
       messageQueue: [],
       scrollTop: 0,
       scrollTop: 0,
       currentIntent: '',
       currentIntent: '',
-      abortFlag: false
+      abortFlag: false,
+      pendingMedia: null  // {type:'image'|'video', base64:'...', mime:'...', name:'...', preview:'...'}
     }
     }
   },
   },
   methods: {
   methods: {
@@ -99,14 +110,57 @@ export default {
       let h = content
       let h = content
         .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
         .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
       h = h.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
       h = h.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
-      // 修复残缺括号(匹配已知段落名,避开已有【的合法段)
-      var secs='(?:结论|详细说明|注意事项|来源明细|适应症|适应证|用法与用量|用法用量|禁忌|不良反应|副作用|药理|药物相互作用|贮藏|特殊人群|安全提醒|就医指征|非药物建议|用药建议|用药方案|通用药学知识|来源汇总)';
+      // 修复残缺括号
+      var secs='(?:结论|详细说明|注意事项|来源明细|适应症|适应证|用法与用量|用法用量|禁忌|不良反应|副作用|药理|药物相互作用|贮藏|特殊人群|安全提醒|就医指征|非药物建议|用药建议|用药方案|通用药学知识|来源汇总|AI 声明|AI声明|来源说明|免责声明)';
       h = h.replace(new RegExp('(?<![【])】\\s*('+secs+')','g'),'【$1】');
       h = h.replace(new RegExp('(?<![【])】\\s*('+secs+')','g'),'【$1】');
       h = h.replace(new RegExp('(?<![【])('+secs+')】','g'),'【$1】');
       h = h.replace(new RegExp('(?<![【])('+secs+')】','g'),'【$1】');
       h = h.replace(/\n\n+/g, '</p><p>')
       h = h.replace(/\n\n+/g, '</p><p>')
       h = h.replace(/\n/g, '<br>')
       h = h.replace(/\n/g, '<br>')
-      h = h.replace(/【(.+?)】/g, '<h3>【$1】</h3>')
-      return '<p>' + h + '</p>'
+      h = h.replace(/【(.+?)】/g, '<p style="font-size:30rpx;color:#002FA7;font-weight:bold;margin:16rpx 0 8rpx">【$1】</p>')
+      // URL 自动转可点击链接
+      h = h.replace(/(https?:\/\/[^\s<>"')、。,;《》]+)/g, '<a href="$1" style="color:#002FA7;text-decoration:underline;word-break:break-all">$1</a>')
+      return '<div style="font-size:28rpx;line-height:1.8;color:#1a1a2e;word-break:break-all">' + h + '</div>'
+    },
+
+    // ============================================================
+    // 媒体选择(图片/视频)
+    // ============================================================
+    pickImage() {
+      uni.chooseImage({
+        count: 1, sizeType: ['compressed'],
+        success: (res) => {
+          const path = res.tempFilePaths[0]
+          this.fileToBase64(path, 'image', (b64, info) => {
+            this.pendingMedia = { type: 'image', base64: b64, mime: info.mime, name: info.name, preview: path }
+          })
+        }
+      })
+    },
+    pickVideo() {
+      uni.chooseVideo({
+        maxDuration: 60, compressed: true,
+        success: (res) => {
+          const path = res.tempFilePath
+          this.fileToBase64(path, 'video', (b64, info) => {
+            this.pendingMedia = { type: 'video', base64: b64, mime: info.mime, name: info.name, preview: path }
+          })
+        }
+      })
+    },
+    fileToBase64(path, type, cb) {
+      // uni-app 文件转 base64
+      const fs = uni.getFileSystemManager()
+      try {
+        const data = fs.readFileSync(path, 'base64')
+        const name = path.split('/').pop() || (type === 'video' ? 'video.mp4' : 'photo.jpg')
+        const mime = type === 'video' ? 'video/mp4' : 'image/jpeg'
+        cb(data, { mime, name })
+      } catch (e) {
+        uni.showToast({ title: '读取文件失败', icon: 'none' })
+      }
+    },
+    clearMedia() {
+      this.pendingMedia = null
     },
     },
 
 
     quickAsk(text) {
     quickAsk(text) {
@@ -116,9 +170,12 @@ export default {
 
 
     sendMessage() {
     sendMessage() {
       const text = this.inputText.trim()
       const text = this.inputText.trim()
-      if (!text) return
+      const hasMedia = !!this.pendingMedia
+      if (!text && !hasMedia) return
       this.inputText = ''
       this.inputText = ''
-      this.messageQueue.push(text)
+      const payload = { text, media: this.pendingMedia }
+      this.pendingMedia = null
+      this.messageQueue.push(payload)
       this.queueCount = this.messageQueue.length
       this.queueCount = this.messageQueue.length
       if (!this.streaming) this.processQueue()
       if (!this.streaming) this.processQueue()
     },
     },
@@ -142,16 +199,32 @@ export default {
       if (this.streaming || this.messageQueue.length === 0) return
       if (this.streaming || this.messageQueue.length === 0) return
       this.streaming = true
       this.streaming = true
       this.abortFlag = false
       this.abortFlag = false
-      const text = this.messageQueue.shift()
+      const payload = this.messageQueue.shift()
       this.queueCount = this.messageQueue.length
       this.queueCount = this.messageQueue.length
+      const text = typeof payload === 'string' ? payload : (payload.text || '')
+      const media = (typeof payload === 'string') ? null : payload.media
 
 
-      this.messages.push({ role: 'user', content: text })
+      // 构造用户消息显示
+      let userDisplay = text
+      if (media) {
+        const icon = media.type === 'video' ? '🎬 [视频] ' : '📷 [图片] '
+        userDisplay = icon + (text || ('请分析此' + (media.type === 'video' ? '视频' : '图片')))
+      }
+      this.messages.push({ role: 'user', content: userDisplay })
       const aiIdx = this.messages.length
       const aiIdx = this.messages.length
       this.messages.push({ role: 'assistant', content: '', intent: '', sources: [], thinking: '正在分析问题...' })
       this.messages.push({ role: 'assistant', content: '', intent: '', sources: [], thinking: '正在分析问题...' })
       this.scrollToBottom()
       this.scrollToBottom()
 
 
+      // 构建请求体(支持多媒体)
+      const reqBody = { message: text, conversation_id: this.conversationId }
+      if (media) {
+        reqBody.media_type = media.type
+        reqBody.media_base64 = media.base64
+        reqBody.media_mime = media.mime
+      }
+
       await chatStream(
       await chatStream(
-        { message: text, conversation_id: this.conversationId },
+        reqBody,
         {
         {
           onIntent: (d) => {
           onIntent: (d) => {
             if (this.abortFlag) return
             if (this.abortFlag) return
@@ -237,7 +310,14 @@ export default {
 .tags { display: flex; gap: 12rpx; padding: 12rpx 24rpx 16rpx; flex-wrap: wrap; }
 .tags { display: flex; gap: 12rpx; padding: 12rpx 24rpx 16rpx; flex-wrap: wrap; }
 .tag-btn { font-size: 24rpx; color: #002FA7; background: #E8EDF8; padding: 8rpx 20rpx; border-radius: 20rpx; border: 1rpx solid rgba(0,47,167,.2); }
 .tag-btn { font-size: 24rpx; color: #002FA7; background: #E8EDF8; padding: 8rpx 20rpx; border-radius: 20rpx; border: 1rpx solid rgba(0,47,167,.2); }
 
 
+.media-preview { display: flex; align-items: center; padding: 12rpx 24rpx; background: #fafafa; border-top: 1rpx solid #eee; gap: 12rpx; }
+.preview-thumb { width: 80rpx; height: 80rpx; border-radius: 8rpx; flex-shrink: 0; }
+.preview-vid { width: 120rpx; height: 80rpx; border-radius: 8rpx; flex-shrink: 0; }
+.preview-name { font-size: 24rpx; color: #888; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.preview-remove { font-size: 28rpx; color: #C41E3A; padding: 8rpx; }
+
 .input-area { display: flex; align-items: center; padding: 16rpx 24rpx; background: #fff; border-top: 1rpx solid #e0e0e0; gap: 12rpx; }
 .input-area { display: flex; align-items: center; padding: 16rpx 24rpx; background: #fff; border-top: 1rpx solid #e0e0e0; gap: 12rpx; }
+.media-btn { font-size: 36rpx; padding: 8rpx; min-width: 56rpx; text-align: center; }
 .chat-input { flex: 1; height: 72rpx; font-size: 28rpx; background: #F5F6FA; border-radius: 40rpx; padding: 0 24rpx; }
 .chat-input { flex: 1; height: 72rpx; font-size: 28rpx; background: #F5F6FA; border-radius: 40rpx; padding: 0 24rpx; }
 .send-btn { background: #C41E3A; color: #fff; border: none; border-radius: 40rpx; padding: 12rpx 28rpx; font-size: 26rpx; font-weight: 700; }
 .send-btn { background: #C41E3A; color: #fff; border: none; border-radius: 40rpx; padding: 12rpx 28rpx; font-size: 26rpx; font-weight: 700; }
 .send-btn[disabled] { opacity: .4; }
 .send-btn[disabled] { opacity: .4; }