|
|
@@ -21,6 +21,7 @@ public class LLMService {
|
|
|
|
|
|
private final WebClient chatClient;
|
|
|
private final WebClient embedClient;
|
|
|
+ private final WebClient visionClient;
|
|
|
private final QwenProperties props;
|
|
|
private final ObjectMapper mapper = new ObjectMapper();
|
|
|
|
|
|
@@ -33,6 +34,11 @@ public class LLMService {
|
|
|
this.embedClient = WebClient.builder()
|
|
|
.defaultHeader("Authorization", "Bearer " + props.getApiKey())
|
|
|
.build();
|
|
|
+ // 原生多模态 API(非兼容模式),endpoint 与 chat/embed 不同
|
|
|
+ this.visionClient = WebClient.builder()
|
|
|
+ .baseUrl("https://dashscope.aliyuncs.com/api/v1")
|
|
|
+ .defaultHeader("Authorization", "Bearer " + props.getApiKey())
|
|
|
+ .build();
|
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
|
@@ -96,11 +102,11 @@ public class LLMService {
|
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
|
- // 图片分析 + OCR(Qwen VL 视觉模型)
|
|
|
+ // 图片分析 + OCR(原生多模态 API)
|
|
|
// ============================================================
|
|
|
|
|
|
/**
|
|
|
- * 调用 Qwen VL 模型分析图片,提取文字(OCR)
|
|
|
+ * 调用 Qwen 多模态模型分析图片,提取文字(OCR)
|
|
|
*/
|
|
|
public String analyzeImage(String imageBase64, String mimeType, String prompt) {
|
|
|
if (prompt == null || prompt.isBlank()) {
|
|
|
@@ -115,7 +121,7 @@ public class LLMService {
|
|
|
""";
|
|
|
}
|
|
|
|
|
|
- // 构建 vision 消息(DashScope 原生多模态格式)
|
|
|
+ // 原生 DashScope 多模态格式
|
|
|
List<Map<String, Object>> contentParts = List.of(
|
|
|
Map.of("image", "data:" + mimeType + ";base64," + imageBase64),
|
|
|
Map.of("text", prompt)
|
|
|
@@ -123,29 +129,36 @@ public class LLMService {
|
|
|
|
|
|
Map<String, Object> userMessage = Map.of("role", "user", "content", contentParts);
|
|
|
|
|
|
- String response = chatClient.post()
|
|
|
- .uri("/chat/completions")
|
|
|
+ Map<String, Object> body = new java.util.HashMap<>();
|
|
|
+ body.put("model", props.getVlModel());
|
|
|
+ body.put("input", Map.of("messages", List.of(userMessage)));
|
|
|
+ body.put("parameters", Map.of("max_tokens", 2048));
|
|
|
+
|
|
|
+ String response = visionClient.post()
|
|
|
+ .uri(props.getMultimodalUrl(), uri -> uri.build())
|
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
|
- .bodyValue(Map.of(
|
|
|
- "model", props.getVlModel(),
|
|
|
- "messages", List.of(userMessage),
|
|
|
- "max_tokens", 2048
|
|
|
- ))
|
|
|
+ .bodyValue(body)
|
|
|
.retrieve()
|
|
|
.bodyToMono(String.class)
|
|
|
.block();
|
|
|
|
|
|
try {
|
|
|
JsonNode node = mapper.readTree(response);
|
|
|
- return node.get("choices").get(0).get("message").get("content").asText();
|
|
|
+ // 原生 API 响应:output.choices[0].message.content[0].text
|
|
|
+ JsonNode content = node.get("output").get("choices").get(0)
|
|
|
+ .get("message").get("content");
|
|
|
+ if (content.isArray() && content.size() > 0) {
|
|
|
+ return content.get(0).get("text").asText();
|
|
|
+ }
|
|
|
+ return content.asText();
|
|
|
} catch (Exception e) {
|
|
|
- log.error("analyzeImage() 解析 LLM 响应失败,response={}", response, e);
|
|
|
+ log.error("analyzeImage() 解析响应失败,response={}", response, e);
|
|
|
return "";
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 流式版本:Qwen VL 分析图片
|
|
|
+ * 流式版本:Qwen 多模态分析图片
|
|
|
*/
|
|
|
public Flux<String> analyzeImageStream(String imageBase64, String mimeType, String prompt) {
|
|
|
if (prompt == null || prompt.isBlank()) {
|
|
|
@@ -159,23 +172,23 @@ public class LLMService {
|
|
|
|
|
|
Map<String, Object> userMessage = Map.of("role", "user", "content", contentParts);
|
|
|
|
|
|
- return chatClient.post()
|
|
|
- .uri("/chat/completions")
|
|
|
+ Map<String, Object> body = new java.util.HashMap<>();
|
|
|
+ body.put("model", props.getVlModel());
|
|
|
+ body.put("input", Map.of("messages", List.of(userMessage)));
|
|
|
+ body.put("parameters", Map.of("max_tokens", 2048, "stream", true, "incremental_output", true));
|
|
|
+
|
|
|
+ return visionClient.post()
|
|
|
+ .uri(props.getMultimodalUrl(), uri -> uri.build())
|
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
|
- .bodyValue(Map.of(
|
|
|
- "model", props.getVlModel(),
|
|
|
- "messages", List.of(userMessage),
|
|
|
- "max_tokens", 2048,
|
|
|
- "stream", true
|
|
|
- ))
|
|
|
+ .bodyValue(body)
|
|
|
.retrieve()
|
|
|
.bodyToFlux(String.class)
|
|
|
.filter(data -> !"[DONE]".equals(data.trim()))
|
|
|
- .map(this::extractDeltaContent);
|
|
|
+ .map(this::extractVisionDelta);
|
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
|
- // 视频分析 + OCR(Qwen VL 模型支持视频帧提取分析)
|
|
|
+ // 视频分析 + OCR(原生多模态 API)
|
|
|
// ============================================================
|
|
|
|
|
|
public String analyzeVideo(String videoBase64, String mimeType, String prompt) {
|
|
|
@@ -196,23 +209,29 @@ public class LLMService {
|
|
|
|
|
|
Map<String, Object> userMessage = Map.of("role", "user", "content", contentParts);
|
|
|
|
|
|
- String response = chatClient.post()
|
|
|
- .uri("/chat/completions")
|
|
|
+ Map<String, Object> body = new java.util.HashMap<>();
|
|
|
+ body.put("model", props.getVlModel());
|
|
|
+ body.put("input", Map.of("messages", List.of(userMessage)));
|
|
|
+ body.put("parameters", Map.of("max_tokens", 2048));
|
|
|
+
|
|
|
+ String response = visionClient.post()
|
|
|
+ .uri(props.getMultimodalUrl(), uri -> uri.build())
|
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
|
- .bodyValue(Map.of(
|
|
|
- "model", props.getVlModel(),
|
|
|
- "messages", List.of(userMessage),
|
|
|
- "max_tokens", 2048
|
|
|
- ))
|
|
|
+ .bodyValue(body)
|
|
|
.retrieve()
|
|
|
.bodyToMono(String.class)
|
|
|
.block();
|
|
|
|
|
|
try {
|
|
|
JsonNode node = mapper.readTree(response);
|
|
|
- return node.get("choices").get(0).get("message").get("content").asText();
|
|
|
+ JsonNode content = node.get("output").get("choices").get(0)
|
|
|
+ .get("message").get("content");
|
|
|
+ if (content.isArray() && content.size() > 0) {
|
|
|
+ return content.get(0).get("text").asText();
|
|
|
+ }
|
|
|
+ return content.asText();
|
|
|
} catch (Exception e) {
|
|
|
- log.error("analyzeVideo() 解析 LLM 响应失败,response={}", response, e);
|
|
|
+ log.error("analyzeVideo() 解析响应失败,response={}", response, e);
|
|
|
return "";
|
|
|
}
|
|
|
}
|
|
|
@@ -229,19 +248,19 @@ public class LLMService {
|
|
|
|
|
|
Map<String, Object> userMessage = Map.of("role", "user", "content", contentParts);
|
|
|
|
|
|
- return chatClient.post()
|
|
|
- .uri("/chat/completions")
|
|
|
+ Map<String, Object> body = new java.util.HashMap<>();
|
|
|
+ body.put("model", props.getVlModel());
|
|
|
+ body.put("input", Map.of("messages", List.of(userMessage)));
|
|
|
+ body.put("parameters", Map.of("max_tokens", 2048, "stream", true, "incremental_output", true));
|
|
|
+
|
|
|
+ return visionClient.post()
|
|
|
+ .uri(props.getMultimodalUrl(), uri -> uri.build())
|
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
|
- .bodyValue(Map.of(
|
|
|
- "model", props.getVlModel(),
|
|
|
- "messages", List.of(userMessage),
|
|
|
- "max_tokens", 2048,
|
|
|
- "stream", true
|
|
|
- ))
|
|
|
+ .bodyValue(body)
|
|
|
.retrieve()
|
|
|
.bodyToFlux(String.class)
|
|
|
.filter(data -> !"[DONE]".equals(data.trim()))
|
|
|
- .map(this::extractDeltaContent);
|
|
|
+ .map(this::extractVisionDelta);
|
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
|
@@ -334,4 +353,30 @@ public class LLMService {
|
|
|
} catch (Exception e) { /* ignore parse errors on partial chunks */ }
|
|
|
return "";
|
|
|
}
|
|
|
+
|
|
|
+ /** 原生多模态 API 流式响应解析:output.choices[0].message.content[0].text */
|
|
|
+ private String extractVisionDelta(String chunk) {
|
|
|
+ try {
|
|
|
+ String json = chunk.trim();
|
|
|
+ if (json.startsWith("data:")) {
|
|
|
+ json = json.substring(5).trim();
|
|
|
+ }
|
|
|
+ if (json.isEmpty() || "[DONE]".equals(json)) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ JsonNode node = mapper.readTree(json);
|
|
|
+ JsonNode output = node.get("output");
|
|
|
+ if (output != null) {
|
|
|
+ var choices = output.get("choices");
|
|
|
+ if (choices != null && choices.size() > 0) {
|
|
|
+ var msgContent = choices.get(0).get("message").get("content");
|
|
|
+ if (msgContent != null && msgContent.isArray() && msgContent.size() > 0) {
|
|
|
+ var text = msgContent.get(0).get("text");
|
|
|
+ return text != null ? text.asText() : "";
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (Exception e) { /* ignore parse errors on partial chunks */ }
|
|
|
+ return "";
|
|
|
+ }
|
|
|
}
|