Sfoglia il codice sorgente

java的代码调整优化

liuchengsen 1 mese fa
parent
commit
8254d06a29

+ 4 - 0
.env.example

@@ -35,6 +35,10 @@ QWEN_MODEL=qwen-max
 QWEN_MAX_TOKENS=4096
 QWEN_TEMPERATURE=0.1
 
+# --- LLM: Qwen VL 视觉模型(图片分析+OCR) ---
+QWEN_VL_MODEL=qwen3.6-flash
+ENABLE_WEB_SEARCH=true
+
 # --- LLM: Qwen 本地部署 (后期切换,无需 API Key) ---
 QWEN_LOCAL_BASE_URL=http://localhost:8000/v1
 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 embeddingUrl = "https://dashscope.aliyuncs.com/api/v1/services/embeddings/text-embedding/text-embedding";
     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;
 
+import com.pharmacopoeia.config.QwenProperties;
 import com.pharmacopoeia.dto.ChatRequest;
 import com.pharmacopoeia.dto.FeedbackRequest;
+import com.pharmacopoeia.dto.ImageChatRequest;
+import com.pharmacopoeia.dto.MultimodalChatRequest;
 import com.pharmacopoeia.service.*;
 import org.springframework.http.MediaType;
 import org.springframework.http.ResponseEntity;
@@ -24,14 +27,16 @@ public class ChatController {
     private final PromptService promptService;
     private final ChatPersistenceService persistenceService;
     private final JdbcTemplate jdbc;
+    private final QwenProperties props;
 
     public ChatController(RetrieverService rs, LLMService ls, PromptService ps,
-                          ChatPersistenceService cps, JdbcTemplate jdbc) {
+                          ChatPersistenceService cps, JdbcTemplate jdbc, QwenProperties props) {
         this.retrieverService = rs;
         this.llmService = ls;
         this.promptService = ps;
         this.persistenceService = cps;
         this.jdbc = jdbc;
+        this.props = props;
     }
 
     @PostMapping("/ask")
@@ -115,6 +120,292 @@ public class ChatController {
         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")
     public ResponseEntity<Map<String, Object>> getHistory(
             @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();
     }
 
+    // ============================================================
+    // 纯文本对话
+    // ============================================================
+
     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()
+                .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")
                 .contentType(MediaType.APPLICATION_JSON)
                 .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
                 ))
                 .retrieve()
@@ -48,15 +170,36 @@ public class LLMService {
                 .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()
                 .uri("/chat/completions")
                 .contentType(MediaType.APPLICATION_JSON)
                 .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)
@@ -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) {
         return embed(List.of(text)).get(0);
     }
@@ -103,6 +296,10 @@ public class LLMService {
         }
     }
 
+    // ============================================================
+    // Helpers
+    // ============================================================
+
     private String extractDeltaContent(String chunk) {
         try {
             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 = """
 
-            —— 药典原文数字、剂量、单位不得改写或省略
+            —— 每条药典信息标注来源名称(如"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 = """
             你是中华药典AI助手。简洁回答用户的药品问题。
-            规则:剂量、用法、禁忌等关键数据必须完整复制原文,数字不能丢。
-            """ + COMPLIANCE;
 
+            回答格式(严格按以下顺序输出):
+
+            【结论】
+            1-2句话概括。
+
+            【详细说明】
+            每条信息后标注实际来源名称(如"2025版药典二部P567""维基百科")。
+            参考资料不足处标注【通用药学知识】。
+
+            【注意事项】
+            禁忌、特殊人群、不良反应等。
+
+            【来源明细】
+            列出本题引用的所有资料名称及出处。
+            """ + COMPLIANCE + AI_DISCLAIMER;
+
+    // ============================================================
+    // 2. 用法用量 / 安全咨询
+    // 回答顺序:结论 → 用法用量 → 禁忌 → 不良反应 → 注意事项 → 来源明细 → AI 声明
+    // ============================================================
     private static final String USAGE_GUIDE = """
-            你是用药指导助手。用户询问用法、用量、副作用、禁忌等问题。
-            规则:剂量数字逐字复制原文,不得改写。
-            结尾提醒「请在医师或药师指导下用药」。
-            """ + COMPLIANCE;
+            你是中华药典用药指导助手。用户询问用法、用量、副作用、禁忌等问题。
 
-    private static final String SYMPTOM_ADVICE = """
-            你是用药建议助手。用户描述症状寻求用药参考。
-            规则:建议具体药品时标注来源,并说明禁忌和注意事项。
-            强调:建议仅供参考,严重症状请就医。
-            """ + COMPLIANCE;
+            回答格式(严格按以下顺序输出):
+
+            【结论】
+            一句话建议。
+
+            【用法用量】
+            剂量、频次、疗程 — 标注来源名称。
 
+            【禁忌】
+            标注来源名称。
+
+            【不良反应】
+            标注来源名称。
+
+            【注意事项】
+            孕妇/儿童/老年/肝肾不全等特殊人群提示。
+
+            【来源明细】
+            列出本题引用的所有资料名称及出处。
+
+            【安全提醒】
+            「请在医师或药师指导下用药」
+            """ + COMPLIANCE + AI_DISCLAIMER;
+
+    // ============================================================
+    // 3. 法规条款查询
+    // 回答顺序:摘要 → 原文引用 → 条款出处 → 关联条款 → 来源明细 → AI 声明
+    // ============================================================
     private static final String REGULATION = """
-            你是药典法规查询助手。
-            规则:逐字引用原文条款,保留编号和术语原貌,标注条款出处。
-            """ + COMPLIANCE;
+            你是药典法规条款查询助手。
+
+            回答格式(严格按以下顺序输出):
+
+            【摘要】
+            一句话概述该条款内容。
+
+            【原文引用】
+            逐字引用原文条款,保留编号和术语原貌。
+
+            【条款出处】
+            编号 + 页码(如"2025版药典四部通则0631")。
+
+            【关联条款】
+            其他相关条款编号及简要说明(如有)。
+
+            【来源明细】
+            列出本题引用的所有资料名称及出处。
+            """ + COMPLIANCE + AI_DISCLAIMER;
 
+    // ============================================================
+    // 4. 执业药师考试辅导
+    // 回答顺序:考点定位 → 知识要点 → 记忆技巧 → 考试频率 → 来源明细 → AI 声明
+    // ============================================================
     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 = """
             你是AI药典助手。知识库中未检索到相关药典原文。
-            首行加:⚠️ 此回答未基于中国药典原文,为AI通用药学参考
-            不得使用"药典规定""药典记载"等用语
-            不得编造任何来源标注
-            每条信息标注为【通用药学知识】
-            """ + COMPLIANCE;
+
+            严格规则:
+            1. 首行加:⚠️ 此回答未基于中国药典原文,为AI通用药学参考
+            2. 不得使用"药典规定""药典记载"等用语
+            3. 不得编造任何来源标注、页码、版本号
+            4. 不得推荐未在中国获批的药品
+            5. 若无法确定安全答案,直接建议就医
+            6. 每条信息标注为【通用药学知识】
+
+            回答格式(严格按以下顺序输出):
+
+            【结论】
+            一句话回答用户问题。
+
+            【详细说明】
+            基于通用药学知识作答,每条标注【通用药学知识】。
+
+            【来源说明】
+            声明:上述内容来源为AI通用药学知识库,非《中国药典》原文。
+            """ + COMPLIANCE + AI_DISCLAIMER;
 
     private static final Map<String, String> PROMPT_MAP = Map.of(
             "drug_query", DRUG_QUERY,

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

@@ -38,6 +38,9 @@ qwen:
   model: qwen3.7-max
   max-tokens: 4096
   temperature: 0.0
+  # 视觉模型(图片分析+OCR)
+  vl-model: qwen3.6-flash
+  enable-web-search: true
   # 嵌入模型
   embedding-model: text-embedding-v3
   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 uuid
 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 pydantic import BaseModel, Field
 from sqlalchemy import text
@@ -27,6 +28,24 @@ class ChatRequest(BaseModel):
     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):
     answer: str
     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")
 
 
+# ============================================
+# 图片对话 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
 # ============================================

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

@@ -41,6 +41,9 @@ class Settings(BaseSettings):
     qwen_max_tokens: int = 4096
     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_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 openai import AsyncOpenAI
@@ -22,20 +23,31 @@ class LLMClient:
             base_url=settings.qwen_base_url,
         )
         self.model = settings.qwen_model
+        self.vl_model = settings.qwen_vl_model
+        self.enable_web_search = settings.enable_web_search
+
+    # ============================================================
+    # 纯文本对话
+    # ============================================================
 
     async def chat(
         self,
         messages: list[dict],
         temperature: Optional[float] = None,
         max_tokens: Optional[int] = None,
+        enable_search: bool = False,
     ) -> str:
         self._ensure_client()
         settings = get_settings()
+        extra = {}
+        if enable_search:
+            extra["enable_search"] = True
         response = await self._client.chat.completions.create(
             model=self.model,
             messages=messages,
             temperature=temperature or settings.qwen_temperature,
             max_tokens=max_tokens or settings.qwen_max_tokens,
+            extra_body=extra if extra else None,
         )
         return response.choices[0].message.content or ""
 
@@ -44,19 +56,256 @@ class LLMClient:
         messages: list[dict],
         temperature: Optional[float] = None,
         max_tokens: Optional[int] = None,
+        enable_search: bool = False,
     ) -> AsyncIterator[str]:
         self._ensure_client()
         settings = get_settings()
+        extra = {}
+        if enable_search:
+            extra["enable_search"] = True
         stream = await self._client.chat.completions.create(
             model=self.model,
             messages=messages,
             temperature=temperature or settings.qwen_temperature,
             max_tokens=max_tokens or settings.qwen_max_tokens,
             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:
             if chunk.choices and 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()

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

@@ -1,28 +1,40 @@
 """
 多场景 Prompt 模板 — 六种意图 + 兜底
+输出顺序:结论 → 详细内容 → 来源明细 → AI 声明
 每条药典信息必须标注实际来源名称(非数字编号)
 """
 import sys
 from pathlib import Path
 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. 药品信息查询
+# 回答顺序:结论 → 详细说明 → 注意事项 → 来源明细 → AI 声明
 # ============================================================
 DRUG_QUERY = f"""你是中华药典AI助手。
 
-回答格式:
+回答格式(严格按以下顺序输出)
 
 【结论】
-1-2句话。
+1-2句话概括
 
 【详细说明】
 - 每条信息后标注实际来源名称(如"2025版药典二部P567""维基百科")
@@ -33,14 +45,16 @@ DRUG_QUERY = f"""你是中华药典AI助手。
 
 【来源明细】
 列出本题引用的所有资料名称及出处
-{COMPLIANCE}"""
+{COMPLIANCE}
+{AI_DISCLAIMER}"""
 
 # ============================================================
 # 2. 用法用量 / 安全咨询
+# 回答顺序:结论 → 用法用量 → 禁忌 → 不良反应 → 注意事项 → 来源明细 → AI 声明
 # ============================================================
 USAGE_GUIDE = f"""你是中华药典用药指导助手。
 
-回答格式:
+回答格式(严格按以下顺序输出)
 
 【结论】
 一句话建议。
@@ -62,14 +76,16 @@ USAGE_GUIDE = f"""你是中华药典用药指导助手。
 
 【安全提醒】
 「请在医师或药师指导下用药」
-{COMPLIANCE}"""
+{COMPLIANCE}
+{AI_DISCLAIMER}"""
 
 # ============================================================
 # 3. 法规条款查询
+# 回答顺序:摘要 → 原文引用 → 条款出处 → 关联条款 → 来源明细 → AI 声明
 # ============================================================
 REGULATION = f"""你是药典法规条款查询助手。
 
-回答格式:
+回答格式(严格按以下顺序输出)
 
 【摘要】
 一句话概述该条款内容。
@@ -82,14 +98,19 @@ REGULATION = f"""你是药典法规条款查询助手。
 
 【关联条款】
 其他相关条款编号及简要说明(如有)
-{COMPLIANCE}"""
+
+【来源明细】
+列出本题引用的所有资料名称及出处
+{COMPLIANCE}
+{AI_DISCLAIMER}"""
 
 # ============================================================
 # 4. 执业药师考试辅导
+# 回答顺序:考点定位 → 知识要点 → 记忆技巧 → 考试频率 → 来源 → AI 声明
 # ============================================================
 EXAM_TUTOR = f"""你是执业药师考试辅导助手。
 
-回答格式:
+回答格式(严格按以下顺序输出)
 
 【考点定位】
 一句话定位考点(科目-章节-知识点)。
@@ -103,18 +124,18 @@ EXAM_TUTOR = f"""你是执业药师考试辅导助手。
 【考试频率】
 高频 / 中频 / 低频。
 
-【来源】
+【来源明细
 大纲章节 + 药典出处 + 参考资料名称
-{COMPLIANCE}"""
+{COMPLIANCE}
+{AI_DISCLAIMER}"""
 
 # ============================================================
-# 5. 症状用药建议(新增)
+# 5. 症状用药建议
+# 回答顺序:病情评估 → 用药方案 → 非药物建议 → 注意事项 → 就医指征 → 来源明细 → AI 声明
 # ============================================================
 SYMPTOM_ADVICE = f"""你是AI药典用药助手。用户描述症状寻求用药建议。
 
-回答格式(严格遵守):
-
-⚠️ 以下为通用用药建议,请以药品说明书及医师指导为准。
+回答格式(严格按以下顺序输出):
 
 【病情评估】
 严重程度判断 + 是否需要立即就医。
@@ -139,11 +160,14 @@ SYMPTOM_ADVICE = f"""你是AI药典用药助手。用户描述症状寻求用药
 列出引用的所有资料名称
 
 【免责声明】
+⚠️ 以下为通用用药建议,请以药品说明书及医师指导为准。
 本回答为AI用药参考,不构成处方建议。用药前请阅读药品说明书,处方药请在医师指导下使用。
-{COMPLIANCE}"""
+{COMPLIANCE}
+{AI_DISCLAIMER}"""
 
 # ============================================================
 # 6. 无资料兜底
+# 回答顺序:结论 → 通用知识 → 来源说明 → AI 声明
 # ============================================================
 NO_DOCS = f"""你是AI药典助手。知识库中未检索到与用户问题直接相关的药典原文。
 
@@ -155,10 +179,19 @@ NO_DOCS = f"""你是AI药典助手。知识库中未检索到与用户问题直
 5. 不得推荐未在中国获批的药品
 6. 若无法确定安全答案,直接建议就医
 7. 每条信息标注为【通用药学知识】
-8. 结尾附:
-   「声明:上述内容来源为AI通用药学知识库,非《中国药典》原文。」
-   「本回答由AI生成,仅供参考。」
-{COMPLIANCE}"""
+
+回答格式(严格按以下顺序输出):
+
+【结论】
+一句话回答用户问题。
+
+【详细说明】
+基于通用药学知识作答,每条标注【通用药学知识】。
+
+【来源说明】
+声明:上述内容来源为AI通用药学知识库,非《中国药典》原文。
+{COMPLIANCE}
+{AI_DISCLAIMER}"""
 
 
 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}}
 .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}
+.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 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)}
@@ -48,6 +51,8 @@ body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei
 .btn-primary{background:var(--bk);color:#fff}
 .btn-danger{background:var(--rd);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}
 .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}
@@ -84,7 +89,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei
 <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="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 id="tabDrugs" class="hidden">
   <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 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 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 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){
   var chat=document.getElementById('chat'),div=document.createElement('div');
   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;
 }
 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'};
-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)};
 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>'}

+ 15 - 5
miniprogram/api/ai.js

@@ -41,13 +41,23 @@ export function chatStream(data, callbacks) {
   let buffer = ''
   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({
-    url: BASE_URL + '/chat/stream',
+    url: BASE_URL + endpoint,
     method: 'POST',
-    data: {
-      message: data.message,
-      conversation_id: data.conversation_id || ''
-    },
+    data: reqData,
     header: {
       'Content-Type': 'application/json',
       'Authorization': 'Bearer ' + getToken()

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

@@ -11,15 +11,23 @@
             <view class="think-dot"></view>
             <text>{{ msg.thinking }}</text>
           </view>
-          <text v-html="renderMarkdown(msg.content)"></text>
+          <rich-text :nodes="renderMarkdown(msg.content)"></rich-text>
         </view>
       </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">
+      <text class="mini-media-btn" @click="pickImage">📷</text>
+      <text class="mini-media-btn" @click="pickVideo">🎬</text>
       <input
         v-model="input"
         class="mini-input"
-        placeholder="问关于这个药品..."
+        :placeholder="pendingMedia?(pendingMedia.type==='video'?'视频已就绪,输入问题...':'图片已就绪,输入问题...'):'问关于这个药品...'"
         @confirm="send"
       />
     </view>
@@ -45,7 +53,8 @@ export default {
       input: '',
       streaming: false,
       conversationId: '',
-      scrollTop: 0
+      scrollTop: 0,
+      pendingMedia: null
     }
   },
   watch: {
@@ -58,32 +67,81 @@ export default {
   methods: {
     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) {
       if (!content) return ''
       let h = content.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
       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('(?<![【])(' + secs + ')】', 'g'), '【$1】')
       h = h.replace(/\n\n+/g, '</p><p>')
       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() {
       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.pendingMedia = null
       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
       this.messages.push({ role: 'assistant', content: '', thinking: '正在分析...' })
       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(
-        { message: text, conversation_id: this.conversationId },
+        reqBody,
         {
           onIntent: (d) => { this.messages[idx].intent = d },
           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; }
 .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); } }
-.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 {
   position: fixed; right: 24rpx; bottom: 120rpx; width: 96rpx; height: 96rpx;
   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>
           </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">
             <text>📚 </text>
@@ -43,6 +43,14 @@
     <!-- 队列提示 -->
     <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">
       <text class="tag-btn" @click="quickAsk('阿莫西林禁忌')">阿莫西林禁忌</text>
       <text class="tag-btn" @click="quickAsk('二甲双胍不良反应')">二甲双胍不良反应</text>
@@ -51,15 +59,17 @@
     </view>
 
     <view class="input-area">
+      <text class="media-btn" @click="pickImage">📷</text>
+      <text class="media-btn" @click="pickVideo">🎬</text>
       <input
         v-model="inputText"
         class="chat-input"
-        placeholder="输入药品问题..."
+        :placeholder="pendingMedia?(pendingMedia.type==='video'?'视频已就绪,可输入补充问题...':'图片已就绪,可输入补充问题...'):'输入药品问题...'"
         :disabled="streaming"
         @confirm="sendMessage"
         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>
     </view>
   </view>
@@ -79,7 +89,8 @@ export default {
       messageQueue: [],
       scrollTop: 0,
       currentIntent: '',
-      abortFlag: false
+      abortFlag: false,
+      pendingMedia: null  // {type:'image'|'video', base64:'...', mime:'...', name:'...', preview:'...'}
     }
   },
   methods: {
@@ -99,14 +110,57 @@ export default {
       let h = content
         .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
       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('(?<![【])('+secs+')】','g'),'【$1】');
       h = h.replace(/\n\n+/g, '</p><p>')
       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) {
@@ -116,9 +170,12 @@ export default {
 
     sendMessage() {
       const text = this.inputText.trim()
-      if (!text) return
+      const hasMedia = !!this.pendingMedia
+      if (!text && !hasMedia) return
       this.inputText = ''
-      this.messageQueue.push(text)
+      const payload = { text, media: this.pendingMedia }
+      this.pendingMedia = null
+      this.messageQueue.push(payload)
       this.queueCount = this.messageQueue.length
       if (!this.streaming) this.processQueue()
     },
@@ -142,16 +199,32 @@ export default {
       if (this.streaming || this.messageQueue.length === 0) return
       this.streaming = true
       this.abortFlag = false
-      const text = this.messageQueue.shift()
+      const payload = this.messageQueue.shift()
       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
       this.messages.push({ role: 'assistant', content: '', intent: '', sources: [], thinking: '正在分析问题...' })
       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(
-        { message: text, conversation_id: this.conversationId },
+        reqBody,
         {
           onIntent: (d) => {
             if (this.abortFlag) return
@@ -237,7 +310,14 @@ export default {
 .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); }
 
+.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; }
+.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; }
 .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; }