Ver Fonte

优化向量存储逻辑区分本地线上

liyanbo há 1 mês atrás
pai
commit
d2689cfaf9

+ 38 - 1
byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/framework/ai/config/ByzsAiProperties.java

@@ -13,6 +13,11 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
 @Data
 public class ByzsAiProperties {
 
+    /**
+     * 向量存储
+     */
+    private VectorStoreProperties vectorStore = new VectorStoreProperties();
+
     /**
      * DeepSeek
      */
@@ -60,6 +65,38 @@ public class ByzsAiProperties {
     @SuppressWarnings("SpellCheckingInspection")
     private SunoProperties suno;
 
+    @Data
+    public static class VectorStoreProperties {
+
+        /**
+         * SimpleVectorStore 配置
+         */
+        private SimpleVectorStoreProperties simple = new SimpleVectorStoreProperties();
+
+    }
+
+    @Data
+    public static class SimpleVectorStoreProperties {
+
+        /**
+         * 存储命名空间。不同运行环境、数据库必须使用不同的命名空间,避免向量数据串库。
+         */
+        private String namespace = "default";
+
+        /**
+         * 存储目录。为空时使用 ${user.home}/vector_store。
+         */
+        private String directory;
+
+        /**
+         * 是否在新文件不存在时,从旧版(未隔离环境和模型)的文件迁移。
+         *
+         * 只应在旧文件确定属于当前环境时开启,例如生产环境第一次升级。
+         */
+        private boolean migrateLegacyFile;
+
+    }
+
     @Data
     public static class DeepSeekProperties {
 
@@ -206,4 +243,4 @@ public class ByzsAiProperties {
 
     }
 
-}
+}

+ 4 - 2
byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/framework/ai/core/AiModelFactory.java

@@ -104,10 +104,12 @@ public interface AiModelFactory {
      * @param type           向量存储类型
      * @param embeddingModel 向量模型
      * @param metadataFields 元数据字段
+     * @param storeName      向量存储名称,用于隔离不同的模型配置
      * @return VectorStore 对象
      */
     VectorStore getOrCreateVectorStore(Class<? extends VectorStore> type,
-                                       EmbeddingModel embeddingModel,
-                                       Map<String, Class<?>> metadataFields);
+                                        EmbeddingModel embeddingModel,
+                                        Map<String, Class<?>> metadataFields,
+                                        String storeName);
 
 }

+ 81 - 9
byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/framework/ai/core/AiModelFactoryImpl.java

@@ -33,6 +33,9 @@ import com.alibaba.cloud.ai.dashscope.embedding.DashScopeEmbeddingModel;
 import com.alibaba.cloud.ai.dashscope.embedding.DashScopeEmbeddingOptions;
 import com.alibaba.cloud.ai.dashscope.image.DashScopeImageModel;
 import com.azure.ai.openai.OpenAIClientBuilder;
+import com.fasterxml.jackson.core.JsonFactory;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonToken;
 import com.volcengine.ark.runtime.service.ArkService;
 import io.micrometer.observation.ObservationRegistry;
 import okhttp3.ConnectionPool;
@@ -114,6 +117,8 @@ import org.springframework.web.client.RestClient;
 import redis.clients.jedis.JedisPooled;
 
 import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.StandardCopyOption;
 import java.time.Duration;
 import java.util.List;
 import java.util.Map;
@@ -300,12 +305,13 @@ public class AiModelFactoryImpl implements AiModelFactory {
 
     @Override
     public VectorStore getOrCreateVectorStore(Class<? extends VectorStore> type,
-                                              EmbeddingModel embeddingModel,
-                                              Map<String, Class<?>> metadataFields) {
-        String cacheKey = buildClientCacheKey(VectorStore.class, embeddingModel, type);
+                                               EmbeddingModel embeddingModel,
+                                               Map<String, Class<?>> metadataFields,
+                                               String storeName) {
+        String cacheKey = buildClientCacheKey(VectorStore.class, embeddingModel, type, storeName);
         return Singleton.get(cacheKey, (Func0<VectorStore>) () -> {
             if (type == SimpleVectorStore.class) {
-                return buildSimpleVectorStore(embeddingModel);
+                return buildSimpleVectorStore(embeddingModel, storeName);
             }
             if (type == QdrantVectorStore.class) {
                 return buildQdrantVectorStore(embeddingModel);
@@ -643,16 +649,25 @@ public class AiModelFactoryImpl implements AiModelFactory {
      */
     @SneakyThrows
     @SuppressWarnings("ResultOfMethodCallIgnored")
-    private SimpleVectorStore buildSimpleVectorStore(EmbeddingModel embeddingModel) {
+    private SimpleVectorStore buildSimpleVectorStore(EmbeddingModel embeddingModel, String storeName) {
         SimpleVectorStore vectorStore = SimpleVectorStore.builder(embeddingModel).build();
-        // 启动加载
-        File file = new File(StrUtil.format("{}/vector_store/simple_{}.json",
-                FileUtil.getUserHomePath(), embeddingModel.getClass().getSimpleName()));
+        // 启动加载。文件按环境 + 数据库命名空间 + 向量模型配置隔离,避免 prod、prodDev 等环境串库。
+        ByzsAiProperties.SimpleVectorStoreProperties properties = SpringUtil.getBean(ByzsAiProperties.class)
+                .getVectorStore().getSimple();
+        File file = buildSimpleVectorStoreFile(properties, embeddingModel.getClass(), storeName);
+        migrateLegacySimpleVectorStoreIfNecessary(properties, embeddingModel.getClass(), file);
         if (!file.exists()) {
             FileUtil.mkParentDirs(file);
             file.createNewFile();
+            log.warn("[buildSimpleVectorStore][创建空向量存储文件({}),读取向量数(0),"
+                            + "请对当前模型关联的知识库执行重新索引]", file.getAbsolutePath());
         } else if (file.length() > 0) {
             vectorStore.load(file);
+            log.info("[buildSimpleVectorStore][已加载向量存储文件({}),读取向量数({})]",
+                    file.getAbsolutePath(), countSimpleVectorStoreDocuments(file));
+        } else {
+            log.warn("[buildSimpleVectorStore][向量存储文件({})为空,读取向量数(0),请执行重新索引]",
+                    file.getAbsolutePath());
         }
         // 定时持久化,每分钟一次
         Timer timer = new Timer("SimpleVectorStoreTimer-" + file.getAbsolutePath());
@@ -669,6 +684,63 @@ public class AiModelFactoryImpl implements AiModelFactory {
         return vectorStore;
     }
 
+    static File buildSimpleVectorStoreFile(ByzsAiProperties.SimpleVectorStoreProperties properties,
+                                           Class<?> embeddingModelType, String storeName) {
+        String directory = StrUtil.blankToDefault(properties.getDirectory(),
+                FileUtil.getUserHomePath() + "/vector_store");
+        String namespace = sanitizeFileName(StrUtil.blankToDefault(properties.getNamespace(), "default"));
+        String safeStoreName = sanitizeFileName(StrUtil.blankToDefault(storeName, "default"));
+        String fileName = StrUtil.format("simple_{}_{}_{}.json", namespace,
+                embeddingModelType.getSimpleName(), safeStoreName);
+        return new File(directory, fileName);
+    }
+
+    @SneakyThrows
+    static void migrateLegacySimpleVectorStoreIfNecessary(
+            ByzsAiProperties.SimpleVectorStoreProperties properties,
+            Class<?> embeddingModelType, File targetFile) {
+        if (targetFile.exists() || !properties.isMigrateLegacyFile()) {
+            return;
+        }
+        String directory = StrUtil.blankToDefault(properties.getDirectory(),
+                FileUtil.getUserHomePath() + "/vector_store");
+        File legacyFile = new File(directory,
+                StrUtil.format("simple_{}.json", embeddingModelType.getSimpleName()));
+        if (!legacyFile.exists() || legacyFile.length() == 0) {
+            return;
+        }
+        FileUtil.mkParentDirs(targetFile);
+        Files.copy(legacyFile.toPath(), targetFile.toPath(), StandardCopyOption.COPY_ATTRIBUTES);
+        log.warn("[migrateLegacySimpleVectorStoreIfNecessary][已将旧向量文件({})迁移到环境隔离文件({}),旧文件保留不变]",
+                legacyFile.getAbsolutePath(), targetFile.getAbsolutePath());
+    }
+
+    static String sanitizeFileName(String value) {
+        return value.replaceAll("[^a-zA-Z0-9._-]", "_");
+    }
+
+    /**
+     * 统计 SimpleVectorStore JSON 根对象中的文档数量,不反序列化体积较大的 embedding 数组。
+     */
+    @SneakyThrows
+    static int countSimpleVectorStoreDocuments(File file) {
+        int count = 0;
+        try (JsonParser parser = new JsonFactory().createParser(file)) {
+            if (parser.nextToken() != JsonToken.START_OBJECT) {
+                return 0;
+            }
+            while (parser.nextToken() != JsonToken.END_OBJECT) {
+                if (parser.currentToken() != JsonToken.FIELD_NAME) {
+                    continue;
+                }
+                count++;
+                parser.nextToken();
+                parser.skipChildren();
+            }
+        }
+        return count;
+    }
+
     /**
      * 参考 {@link QdrantVectorStoreAutoConfiguration} 的 vectorStore 方法
      */
@@ -792,4 +864,4 @@ public class AiModelFactoryImpl implements AiModelFactory {
         return SpringUtil.getBean(FunctionCallbackResolver.class);
     }
 
-}
+}

+ 18 - 3
byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/service/knowledge/AiKnowledgeSegmentServiceImpl.java

@@ -213,24 +213,39 @@ public class AiKnowledgeSegmentServiceImpl implements AiKnowledgeSegmentService
 
         // 2.1 向量检索
         VectorStore vectorStore = getVectorStoreById(knowledge);
+        Integer topK = ObjUtil.defaultIfNull(reqBO.getTopK(), knowledge.getTopK());
+        Double similarityThreshold = ObjUtil.defaultIfNull(
+                reqBO.getSimilarityThreshold(), knowledge.getSimilarityThreshold());
         List<Document> documents = vectorStore.similaritySearch(SearchRequest.builder()
                 .query(reqBO.getContent())
-                .topK(ObjUtil.defaultIfNull(reqBO.getTopK(), knowledge.getTopK()))
-                .similarityThreshold(
-                        ObjUtil.defaultIfNull(reqBO.getSimilarityThreshold(), knowledge.getSimilarityThreshold()))
+                .topK(topK)
+                .similarityThreshold(similarityThreshold)
                 .filterExpression(new FilterExpressionBuilder()
                         .eq(VECTOR_STORE_METADATA_KNOWLEDGE_ID, reqBO.getKnowledgeId().toString())
                         .build())
                 .build());
+        log.info("[searchKnowledgeSegment][向量检索完成:知识库({}),VectorStore({}),topK({}),"
+                        + "similarityThreshold({}),读取/召回向量数({})]",
+                reqBO.getKnowledgeId(), vectorStore.getClass().getSimpleName(), topK,
+                similarityThreshold, documents.size());
         if (CollUtil.isEmpty(documents)) {
             return ListUtil.empty();
         }
         // 2.2 段落召回
         List<AiKnowledgeSegmentDO> segments = segmentMapper
                 .selectListByVectorIds(convertList(documents, Document::getId));
+        log.info("[searchKnowledgeSegment][数据库分片匹配完成:知识库({}),召回向量数({}),匹配分片数({})]",
+                reqBO.getKnowledgeId(), documents.size(), segments.size());
         if (CollUtil.isEmpty(segments)) {
+            log.warn("[searchKnowledgeSegment][知识库({})召回了 {} 条向量,但 vectorId 均无法匹配数据库分片,"
+                            + "请检查向量存储命名空间并重新索引。vectorIds={}]",
+                    reqBO.getKnowledgeId(), documents.size(), convertList(documents, Document::getId));
             return ListUtil.empty();
         }
+        if (segments.size() != documents.size()) {
+            log.warn("[searchKnowledgeSegment][知识库({})召回向量数({})与数据库匹配分片数({})不一致,可能存在旧向量,"
+                            + "建议重新索引]", reqBO.getKnowledgeId(), documents.size(), segments.size());
+        }
 
         // 3. 增加召回次数
         segmentMapper.updateRetrievalCountIncrByIds(convertList(segments, AiKnowledgeSegmentDO::getId));

+ 6 - 5
byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/service/model/AiModelServiceImpl.java

@@ -167,10 +167,11 @@ public class AiModelServiceImpl implements AiModelService {
                 platform, apiKey.getApiKey(), apiKey.getUrl(), model.getModel());
 
         // 创建或获取 VectorStore 对象
-         return modelFactory.getOrCreateVectorStore(SimpleVectorStore.class, embeddingModel, metadataFields);
-//         return modelFactory.getOrCreateVectorStore(QdrantVectorStore.class, embeddingModel, metadataFields);
-//         return modelFactory.getOrCreateVectorStore(RedisVectorStore.class, embeddingModel, metadataFields);
-//         return modelFactory.getOrCreateVectorStore(MilvusVectorStore.class, embeddingModel, metadataFields);
+         String storeName = "embedding-model-" + id;
+         return modelFactory.getOrCreateVectorStore(SimpleVectorStore.class, embeddingModel, metadataFields, storeName);
+//         return modelFactory.getOrCreateVectorStore(QdrantVectorStore.class, embeddingModel, metadataFields, storeName);
+//         return modelFactory.getOrCreateVectorStore(RedisVectorStore.class, embeddingModel, metadataFields, storeName);
+//         return modelFactory.getOrCreateVectorStore(MilvusVectorStore.class, embeddingModel, metadataFields, storeName);
     }
 
     // TODO @lesan:是不是返回 Llm 对象会好点哈?
@@ -198,4 +199,4 @@ public class AiModelServiceImpl implements AiModelService {
         }
     }
 
-}
+}

+ 7 - 1
byzs-server/src/main/resources/application-prod.yaml

@@ -184,6 +184,12 @@ wx:
 
 # 博雅智算配置项,设置当前项目所有自定义的配置
 byzs:
+  ai:
+    vector-store:
+      simple:
+        namespace: byzs-bjdx-prod
+        # 首次部署时复制当前线上旧向量文件,保证升级过程不中断;旧文件不会被删除或覆盖。
+        migrate-legacy-file: true
   captcha:
     enable: false # 本地环境,暂时关闭图片验证码,方便登录等接口的测试;
   security:
@@ -227,4 +233,4 @@ justauth:
 --- #################### iot相关配置 TODO lyb【IOT】:再瞅瞅 ####################
 pf4j:
   #  pluginsDir: /tmp/
-  pluginsDir: ../plugins
+  pluginsDir: ../plugins

+ 7 - 1
byzs-server/src/main/resources/application-prodDev.yaml

@@ -186,6 +186,12 @@ wx:
 
 # 博雅智算配置项,设置当前项目所有自定义的配置
 byzs:
+  ai:
+    vector-store:
+      simple:
+        namespace: byzs-ai-course-prodDev
+        # 本机旧文件曾被 prod/prodDev 共用,不能迁移,启动后需要为 prodDev 知识库重新索引。
+        migrate-legacy-file: false
   captcha:
     enable: false # 本地环境,暂时关闭图片验证码,方便登录等接口的测试;
   security:
@@ -229,4 +235,4 @@ justauth:
 --- #################### iot相关配置 TODO lyb【IOT】:再瞅瞅 ####################
 pf4j:
   #  pluginsDir: /tmp/
-  pluginsDir: ../plugins
+  pluginsDir: ../plugins

+ 7 - 1
byzs-server/src/main/resources/application.yaml

@@ -201,6 +201,12 @@ spring:
 
 byzs:
   ai:
+    vector-store:
+      simple:
+        # SimpleVectorStore 是本地文件存储。命名空间必须按运行环境、数据库隔离,避免切换 profile 后串库。
+        namespace: ${spring.application.name}-${spring.profiles.active:default}
+        # 仅在确认旧文件属于当前环境时开启;prod 首次升级会在 application-prod.yaml 中单独开启。
+        migrate-legacy-file: false
     deep-seek: # DeepSeek
       enable: true
       api-key: sk-5b612c071f904fd59808dc07c9a4f1b8
@@ -332,4 +338,4 @@ byzs:
     send-frequency: 1m
     send-maximum-quantity-per-day: 10
     begin-code: 9999 # 这里配置 9999 的原因是,测试方便。
-    end-code: 9999 # 这里配置 9999 的原因是,测试方便。
+    end-code: 9999 # 这里配置 9999 的原因是,测试方便。

+ 295 - 262
byzs-web/src/main/java/cn/iocoder/byzs/module/web/service/ai/WebQSAiServiceImpl.java

@@ -13,33 +13,15 @@ import cn.iocoder.byzs.module.ai.dal.dataobject.model.AiApiKeyDO;
 import cn.iocoder.byzs.module.ai.dal.dataobject.model.AiModelDO;
 import cn.iocoder.byzs.module.ai.dal.mysql.chat.AiChatMessageMapper;
 import cn.iocoder.byzs.module.ai.enums.ErrorCodeConstants;
-import cn.iocoder.byzs.module.ai.enums.model.AiPlatformEnum;
 import cn.iocoder.byzs.module.ai.service.chat.AiChatConversationService;
 import cn.iocoder.byzs.module.ai.service.chat.AiChatMessageService;
 import cn.iocoder.byzs.module.ai.service.model.AiApiKeyService;
 import cn.iocoder.byzs.module.ai.service.model.AiModelService;
 import cn.iocoder.byzs.module.web.controller.admin.ai.vo.WebQSAiChatMessageSendReqVO;
-import com.volcengine.ark.runtime.model.responses.constant.ResponsesConstants;
-import com.volcengine.ark.runtime.model.responses.content.InputContentItemFile;
-import com.volcengine.ark.runtime.model.responses.content.InputContentItemImage;
-import com.volcengine.ark.runtime.model.responses.content.InputContentItemText;
-import com.volcengine.ark.runtime.model.responses.content.OutputContentItem;
-import com.volcengine.ark.runtime.model.responses.content.OutputContentItemText;
-import com.volcengine.ark.runtime.model.responses.event.ErrorEvent;
-import com.volcengine.ark.runtime.model.responses.event.outputtext.OutputTextDeltaEvent;
-import com.volcengine.ark.runtime.model.responses.event.outputtext.OutputTextDoneEvent;
-import com.volcengine.ark.runtime.model.responses.event.response.ResponseCompletedEvent;
-import com.volcengine.ark.runtime.model.responses.event.response.ResponseFailedEvent;
-import com.volcengine.ark.runtime.model.responses.event.response.ResponseInCompleteEvent;
-import com.volcengine.ark.runtime.model.responses.item.BaseItem;
-import com.volcengine.ark.runtime.model.responses.item.ItemEasyMessage;
-import com.volcengine.ark.runtime.model.responses.item.ItemOutputMessage;
-import com.volcengine.ark.runtime.model.responses.item.MessageContent;
-import com.volcengine.ark.runtime.model.responses.request.CreateResponsesRequest;
-import com.volcengine.ark.runtime.model.responses.request.ResponsesInput;
-import com.volcengine.ark.runtime.model.responses.response.ResponseObject;
-import com.volcengine.ark.runtime.service.ArkService;
-import io.reactivex.disposables.Disposable;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
 import jakarta.annotation.Resource;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.poi.hwpf.extractor.WordExtractor;
@@ -68,6 +50,10 @@ import java.net.InetAddress;
 import java.net.URI;
 import java.net.URL;
 import java.net.URLConnection;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
 import java.time.LocalDateTime;
 import java.util.ArrayList;
 import java.util.Base64;
@@ -80,14 +66,16 @@ import static cn.iocoder.byzs.framework.common.pojo.CommonResult.success;
 import static cn.iocoder.byzs.module.ai.enums.ErrorCodeConstants.CHAT_CONVERSATION_NOT_EXISTS;
 
 /**
- * Web 端支持附件的豆包 AI 问答服务。
+ * Web 端支持附件的 DeepSeek AI 问答服务。
  */
 @Service
 @Validated
 @Slf4j
 public class WebQSAiServiceImpl {
 
-    private static final String DEFAULT_ARK_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3";
+    // DeepSeek API URL 固定
+    private static final String DEEPSEEK_API_URL = "https://api.deepseek.com/v1/chat/completions";
+
     private static final long MAX_DOCUMENT_SIZE_BYTES = 10L * 1024 * 1024;
     private static final long MAX_TOTAL_DOCUMENT_SIZE_BYTES = 15L * 1024 * 1024;
     private static final int MAX_ATTACHMENTS = 3;
@@ -107,13 +95,18 @@ public class WebQSAiServiceImpl {
     @Resource
     private AiApiKeyService apiKeyService;
 
+    private final HttpClient httpClient = HttpClient.newBuilder()
+            .connectTimeout(Duration.ofSeconds(30))
+            .build();
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
     public Flux<CommonResult<AiChatMessageSendRespVO>> sendChatMessageStream(
             WebQSAiChatMessageSendReqVO sendReqVO, Long userId) {
         return sendChatMessageStream(sendReqVO, userId, List.of());
     }
 
     /**
-     * 处理 multipart/form-data 提交的本地文件。文件只会写入临时目录并上传至 Ark,不会保存到业务文件存储。
+     * 处理 multipart/form-data 提交的本地文件。
      */
     public Flux<CommonResult<AiChatMessageSendRespVO>> sendChatMessageStream(
             WebQSAiChatMessageSendReqVO sendReqVO, Long userId, List<MultipartFile> multipartFiles) {
@@ -135,9 +128,6 @@ public class WebQSAiServiceImpl {
 
         AiModelDO model = modelService.validateModel(conversation.getModelId());
         AiApiKeyDO apiKey = apiKeyService.validateApiKey(model.getKeyId());
-        if (!AiPlatformEnum.DOU_BAO.getPlatform().equals(apiKey.getPlatform())) {
-            throw new IllegalArgumentException("支持附件的问答目前仅支持豆包模型");
-        }
 
         String userContent = StrUtil.blankToDefault(sendReqVO.getContent(), "请分析附件内容。");
         if (userContent.length() > MAX_USER_CONTENT_CHARS) {
@@ -145,17 +135,7 @@ public class WebQSAiServiceImpl {
         }
         validateMultipartFilesSize(files);
         List<AiChatMessageDO> historyMessages = chatMessageMapper.selectListByConversationId(conversation.getId());
-        ArkService arkService = ArkService.builder()
-                .apiKey(apiKey.getApiKey())
-                .baseUrl(StrUtil.blankToDefault(apiKey.getUrl(), DEFAULT_ARK_BASE_URL))
-                .build();
-        CreateResponsesRequest request;
-        try {
-            request = buildRequest(model, historyMessages, sendReqVO, userContent, files);
-        } catch (RuntimeException e) {
-            arkService.shutdownExecutor();
-            throw e;
-        }
+
         AiChatMessageDO userMessage = createChatMessage(conversation, model, userId, null,
                 MessageType.USER, userContent, sendReqVO.getUseContext());
         AiChatMessageDO assistantMessage = createChatMessage(conversation, model, userId, userMessage.getId(),
@@ -166,56 +146,116 @@ public class WebQSAiServiceImpl {
 
         return Flux.<CommonResult<AiChatMessageSendRespVO>>create(sink -> {
                     try {
-                        Disposable disposable = arkService.streamResponse(request).subscribe(event -> {
-                            if (event instanceof OutputTextDeltaEvent textDeltaEvent) {
-                                String delta = textDeltaEvent.getDelta();
-                                if (StrUtil.isNotEmpty(delta)) {
-                                    answer.append(delta);
-                                    sink.next(success(createTextResponse(userMessage, assistantMessage, delta)));
-                                }
-                            } else if (event instanceof OutputTextDoneEvent textDoneEvent) {
-                                // 部分模型只发送完成事件,不发送 delta;此处兜底输出完整文本。
-                                emitCompletedTextIfNecessary(answer, textDoneEvent.getText(), sink,
-                                        userMessage, assistantMessage);
-                            } else if (event instanceof ResponseCompletedEvent completedEvent) {
-                                // 再以 response.completed 中的完整 output 兜底,避免事件类型差异导致空回复。
-                                emitCompletedTextIfNecessary(answer, extractResponseText(completedEvent.getResponse()), sink,
-                                        userMessage, assistantMessage);
-                            } else if (event instanceof ResponseFailedEvent failedEvent) {
-                                sink.error(new IllegalStateException("豆包模型调用失败:" + failedEvent.getResponse()));
-                            } else if (event instanceof ResponseInCompleteEvent incompleteEvent) {
-                                // SDK 会将部分模型或参数错误以 response.incomplete 事件返回,而不是回调 onError。
-                                sink.error(new IllegalStateException("豆包模型调用未完成:" + incompleteEvent.getResponse()));
-                            } else if (event instanceof ErrorEvent errorEvent) {
-                                // Responses API 的 SSE error 事件同样不会触发 RxJava 的 onError 回调,必须显式转为异常。
-                                sink.error(new IllegalStateException("豆包模型调用失败[" + errorEvent.getCode() + "]:"
-                                        + errorEvent.getMessage()));
-                            } else {
-                                log.debug("[sendChatMessageStream][忽略 Ark 流事件: {}]", event);
-                            }
-                        }, sink::error, sink::complete);
-                        sink.onCancel(disposable::dispose);
+                        // 构建 DeepSeek 请求
+                        String requestBody = buildDeepSeekRequest(conversation, model, apiKey, historyMessages,
+                                sendReqVO, userContent, files);
+
+                        // 构建 HTTP 请求(URL 固定,API Key 从数据库获取)
+                        HttpRequest httpRequest = HttpRequest.newBuilder()
+                                .uri(URI.create(DEEPSEEK_API_URL))
+                                .header("Content-Type", "application/json")
+                                .header("Authorization", "Bearer " + apiKey.getApiKey())
+                                .timeout(Duration.ofSeconds(120))
+                                .POST(HttpRequest.BodyPublishers.ofString(requestBody))
+                                .build();
+
+                        // 发送流式请求
+                        httpClient.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofLines())
+                                .thenAccept(response -> {
+                                    if (response.statusCode() != 200) {
+                                        String errorBody = response.body() != null ? response.body().toString() : "";
+                                        log.error("DeepSeek API 错误: HTTP {}, body: {}", response.statusCode(), errorBody);
+                                        sink.error(new RuntimeException("DeepSeek API 错误: HTTP " + response.statusCode()));
+                                        return;
+                                    }
+
+                                    response.body().forEach(line -> {
+                                        if (!line.startsWith("data: ")) {
+                                            return;
+                                        }
+                                        String data = line.substring(6).trim();
+
+                                        if ("[DONE]".equals(data)) {
+                                            TenantUtils.executeIgnore(() ->
+                                                    chatMessageMapper.updateById(
+                                                            new AiChatMessageDO().setId(assistantMessage.getId())
+                                                                    .setContent(answer.toString())));
+                                            sink.complete();
+                                            return;
+                                        }
+
+                                        try {
+                                            JsonNode chunk = objectMapper.readTree(data);
+                                            JsonNode choices = chunk.path("choices");
+                                            if (choices.isEmpty()) {
+                                                return;
+                                            }
+
+                                            JsonNode delta = choices.path(0).path("delta");
+                                            // 跳过空对象
+                                            if (delta == null || delta.isNull() || delta.size() == 0) {
+                                                return;
+                                            }
+
+                                            // 处理普通文本内容
+                                            if (delta.has("content")) {
+                                                JsonNode contentNode = delta.path("content");
+                                                if (!contentNode.isNull()) {
+                                                    String deltaText = contentNode.asText();
+                                                    if (StrUtil.isNotEmpty(deltaText) && !"null".equals(deltaText)) {
+                                                        answer.append(deltaText);
+                                                        sink.next(success(createTextResponse(userMessage, assistantMessage, deltaText)));
+                                                    }
+                                                }
+                                            }
+
+                                            // 记录思考过程(仅日志,不输出)
+                                            if (delta.has("reasoning_content")) {
+                                                JsonNode reasoningNode = delta.path("reasoning_content");
+                                                if (!reasoningNode.isNull()) {
+                                                    String reasoning = reasoningNode.asText();
+                                                    if (StrUtil.isNotEmpty(reasoning) && !"null".equals(reasoning)) {
+                                                        log.debug("DeepSeek 思考过程: {}", reasoning);
+                                                    }
+                                                }
+                                            }
+
+                                        } catch (Exception e) {
+                                            log.warn("解析 DeepSeek 响应失败: {}, line: {}", e.getMessage(), line);
+                                        }
+                                    });
+                                })
+                                .exceptionally(e -> {
+                                    log.error("DeepSeek API 调用异常", e);
+                                    sink.error(e);
+                                    return null;
+                                });
+
                     } catch (Exception e) {
+                        log.error("构建 DeepSeek 请求失败", e);
                         sink.error(e);
                     }
                 })
                 .subscribeOn(Schedulers.boundedElastic())
                 .doFinally(signalType -> {
-                    TenantUtils.executeIgnore(() -> chatMessageMapper.updateById(
-                            new AiChatMessageDO().setId(assistantMessage.getId()).setContent(answer.toString())));
                     if (serviceClosed.compareAndSet(false, true)) {
-                        arkService.shutdownExecutor();
+                        if (answer.length() > 0) {
+                            TenantUtils.executeIgnore(() ->
+                                    chatMessageMapper.updateById(
+                                            new AiChatMessageDO().setId(assistantMessage.getId())
+                                                    .setContent(answer.toString())));
+                        }
                     }
                 })
                 .onErrorResume(throwable -> {
-                    log.error("[sendChatMessageStream][userId({}) 会话({}) 调用豆包附件问答失败]", userId,
-                            conversation.getId(), throwable);
+                    log.error("[sendChatMessageStream][userId({}) 会话({}) 调用 DeepSeek 附件问答失败]",
+                            userId, conversation.getId(), throwable);
                     return Flux.just(error(ErrorCodeConstants.CHAT_STREAM_ERROR));
                 });
     }
 
     /**
-     * 纯文本请求复用通用聊天链路,以获得其稳定的增量输出、连接复用和可选 TTS 能力。
+     * 纯文本请求复用通用聊天链路
      */
     private Flux<CommonResult<AiChatMessageSendRespVO>> sendTextChatMessageStream(
             WebQSAiChatMessageSendReqVO sendReqVO, Long userId) {
@@ -230,161 +270,215 @@ public class WebQSAiServiceImpl {
         return chatMessageService.sendChatMessageStream(textSendReqVO, userId);
     }
 
-    private void emitCompletedTextIfNecessary(StringBuilder answer, String completedText,
-                                              reactor.core.publisher.FluxSink<CommonResult<AiChatMessageSendRespVO>> sink,
-                                              AiChatMessageDO userMessage, AiChatMessageDO assistantMessage) {
-        if (answer.isEmpty() && StrUtil.isNotBlank(completedText)) {
-            answer.append(completedText);
-            sink.next(success(createTextResponse(userMessage, assistantMessage, completedText)));
-        }
-    }
-
-    private String extractResponseText(ResponseObject response) {
-        if (response == null || response.getOutput() == null) {
-            return "";
-        }
-        StringBuilder text = new StringBuilder();
-        for (BaseItem item : response.getOutput()) {
-            if (item instanceof ItemOutputMessage message && message.getContent() != null) {
-                for (OutputContentItem content : message.getContent()) {
-                    if (content instanceof OutputContentItemText textContent && StrUtil.isNotBlank(textContent.getText())) {
-                        text.append(textContent.getText());
-                    }
-                }
-            }
-        }
-        return text.toString();
-    }
-
-    private CreateResponsesRequest buildRequest(AiModelDO model, List<AiChatMessageDO> historyMessages,
-                                                WebQSAiChatMessageSendReqVO sendReqVO, String userContent,
-                                                List<MultipartFile> multipartFiles) {
-        ResponsesInput.Builder inputBuilder = ResponsesInput.builder();
+    /**
+     * 构建 DeepSeek API 请求体
+     */
+    private String buildDeepSeekRequest(AiChatConversationDO conversation, AiModelDO model, AiApiKeyDO apiKey,
+                                        List<AiChatMessageDO> historyMessages,
+                                        WebQSAiChatMessageSendReqVO sendReqVO,
+                                        String userContent,
+                                        List<MultipartFile> multipartFiles) throws Exception {
+
+        ObjectNode root = objectMapper.createObjectNode();
+        root.put("model", model.getModel());
+        root.put("temperature", model.getTemperature() != null ? model.getTemperature() : 0.1);
+        root.put("max_tokens", model.getMaxTokens() != null ? model.getMaxTokens() : 4000);
+        root.put("top_p", 0.9);
+        root.put("stream", true);
+
+        ArrayNode messages = objectMapper.createArrayNode();
+
+        // 1. 添加 System Prompt(评分规则)
+        ObjectNode systemMsg = objectMapper.createObjectNode();
+        systemMsg.put("role", "system");
+        systemMsg.put("content", conversation.getDescription());
+        messages.add(systemMsg);
+
+        // 2. 添加历史上下文
         if (Boolean.TRUE.equals(sendReqVO.getUseContext())) {
             int configuredMaxContexts = model.getMaxContexts() == null ? historyMessages.size() : model.getMaxContexts();
             int maxContexts = Math.min(configuredMaxContexts, MAX_CONTEXT_MESSAGES);
             int startIndex = Math.max(0, historyMessages.size() - maxContexts);
-            List<ItemEasyMessage> contextMessages = new ArrayList<>();
+            List<AiChatMessageDO> contextList = new ArrayList<>();
             int contextChars = 0;
-            // 从最新消息开始保留,达到总字符上限后停止,最后恢复为时间正序。
+
             for (int i = historyMessages.size() - 1; i >= startIndex && contextChars < MAX_CONTEXT_CHARS; i--) {
-                AiChatMessageDO historyMessage = historyMessages.get(i);
-                if (MessageType.USER.getValue().equals(historyMessage.getType())
-                        || MessageType.ASSISTANT.getValue().equals(historyMessage.getType())) {
-                    String content = StrUtil.nullToEmpty(historyMessage.getContent());
+                AiChatMessageDO history = historyMessages.get(i);
+                if (MessageType.USER.getValue().equals(history.getType())
+                        || MessageType.ASSISTANT.getValue().equals(history.getType())) {
+                    String content = StrUtil.nullToEmpty(history.getContent());
                     int remainingChars = MAX_CONTEXT_CHARS - contextChars;
                     if (content.length() > remainingChars) {
-                        // 保留较新的文本尾部,避免单条历史消息耗尽全部上下文预算。
                         content = content.substring(content.length() - remainingChars);
                     }
-                    contextMessages.add(0, ItemEasyMessage.builder()
-                            .role(historyMessage.getType())
-                            .content(MessageContent.builder().stringValue(content).build())
-                            .build());
+                    contextList.add(0, history);
                     contextChars += content.length();
                 }
             }
-            contextMessages.forEach(inputBuilder::addListItem);
+
+            for (AiChatMessageDO history : contextList) {
+                ObjectNode msg = objectMapper.createObjectNode();
+                msg.put("role", history.getType());
+                msg.put("content", history.getContent());
+                messages.add(msg);
+            }
         }
 
-        MessageContent.Builder contentBuilder = MessageContent.builder();
-        AttachmentSizeCounter attachmentSizeCounter = new AttachmentSizeCounter();
+        // 3. 构建用户消息内容(包含附件文本)
+        String fullUserContent = buildFullUserContent(sendReqVO, userContent, multipartFiles);
+
+        ObjectNode userMsg = objectMapper.createObjectNode();
+        userMsg.put("role", "user");
+        userMsg.put("content", fullUserContent);
+        messages.add(userMsg);
+
+        root.set("messages", messages);
+        return objectMapper.writeValueAsString(root);
+    }
+
+    /**
+     * 构建完整的用户消息内容(包含附件解析的文本)
+     */
+    private String buildFullUserContent(WebQSAiChatMessageSendReqVO sendReqVO,
+                                        String userContent,
+                                        List<MultipartFile> multipartFiles) {
+        StringBuilder extractedText = new StringBuilder();
+
+        // 处理 URL 附件
         if (sendReqVO.getAttachments() != null) {
             for (WebQSAiChatMessageSendReqVO.Attachment attachment : sendReqVO.getAttachments()) {
-                contentBuilder.addListItem(buildAttachmentContent(attachment, attachmentSizeCounter));
+                String url = attachment.getUrl();
+                String name = attachment.getName();
+                if (StrUtil.isNotBlank(url)) {
+                    try {
+                        String text = downloadAndExtractText(url, name);
+                        if (StrUtil.isNotBlank(text)) {
+                            extractedText.append("附件《").append(StrUtil.blankToDefault(name, "未知文件"))
+                                    .append("》内容:\n").append(text).append("\n\n");
+                        }
+                    } catch (Exception e) {
+                        log.warn("下载或解析附件失败: {}, error: {}", name, e.getMessage());
+                        extractedText.append("附件《").append(StrUtil.blankToDefault(name, "未知文件"))
+                                .append("》解析失败,请检查文件格式。\n\n");
+                    }
+                }
             }
         }
-        for (MultipartFile multipartFile : multipartFiles) {
-            contentBuilder.addListItem(buildMultipartFileContent(multipartFile, attachmentSizeCounter));
-        }
-        contentBuilder.addListItem(InputContentItemText.builder().text(userContent).build());
-        inputBuilder.addListItem(ItemEasyMessage.builder()
-                .role(ResponsesConstants.MESSAGE_ROLE_USER)
-                .content(contentBuilder.build())
-                .build());
-
-        return CreateResponsesRequest.builder()
-                .model(model.getModel())
-                .stream(true)
-                .temperature(model.getTemperature())
-                .maxOutputTokens(model.getMaxTokens() == null ? null : model.getMaxTokens().longValue())
-                .input(inputBuilder.build())
-                .build();
-    }
 
-    private com.volcengine.ark.runtime.model.responses.content.InputContentItem buildAttachmentContent(
-            WebQSAiChatMessageSendReqVO.Attachment attachment, AttachmentSizeCounter attachmentSizeCounter) {
-        validateAttachmentUrl(attachment.getUrl());
-        String fileName = normalizeOfficeFileName(
-                StrUtil.blankToDefault(attachment.getName(), getFileName(attachment.getUrl())), attachment.getContentType());
-        String extension = StrUtil.subAfter(fileName, '.', true).toLowerCase();
-        if (isImage(extension, attachment.getContentType())) {
-            return InputContentItemImage.builder().imageUrl(attachment.getUrl()).build();
+        // 处理上传的 MultipartFile
+        if (multipartFiles != null) {
+            for (MultipartFile file : multipartFiles) {
+                if (file == null || file.isEmpty()) {
+                    continue;
+                }
+                try {
+                    String fileName = file.getOriginalFilename();
+                    byte[] content = file.getBytes();
+                    String text = extractOfficeText(content, fileName);
+                    if (StrUtil.isNotBlank(text)) {
+                        extractedText.append("附件《").append(StrUtil.blankToDefault(fileName, "未知文件"))
+                                .append("》内容:\n").append(text).append("\n\n");
+                    }
+                } catch (Exception e) {
+                    log.warn("解析上传文件失败: {}, error: {}", file.getOriginalFilename(), e.getMessage());
+                }
+            }
         }
-        if (!isDocument(extension, attachment.getContentType())) {
-            throw new IllegalArgumentException("暂只支持图片、PDF、Word 和 Excel 文件:" + fileName);
+
+        // 组合最终内容
+        StringBuilder fullContent = new StringBuilder();
+        if (extractedText.length() > 0) {
+            fullContent.append("【附件内容】\n").append(extractedText);
         }
-        if (isOfficeDocument(extension, attachment.getContentType())) {
-            byte[] content = downloadAttachment(attachment.getUrl());
-            attachmentSizeCounter.add(content.length);
-            return buildOfficeTextContent(content, fileName);
+        if (StrUtil.isNotBlank(userContent)) {
+            if (fullContent.length() > 0) {
+                fullContent.append("\n【用户问题】\n");
+            }
+            fullContent.append(userContent);
         }
-        // PDF URL 必须能由 Ark 服务端访问(公网 URL 或未过期的签名 URL)。
-        // file_url 不能同时携带 filename。
-        InputContentItemFile file = new InputContentItemFile();
-        file.setFileUrl(attachment.getUrl());
-        return file;
+        return fullContent.toString();
     }
 
-    private com.volcengine.ark.runtime.model.responses.content.InputContentItem buildMultipartFileContent(
-            MultipartFile multipartFile, AttachmentSizeCounter attachmentSizeCounter) {
-        if (multipartFile == null || multipartFile.isEmpty()) {
-            throw new IllegalArgumentException("不能上传空文件");
-        }
-        if (multipartFile.getSize() > MAX_DOCUMENT_SIZE_BYTES) {
-            throw new IllegalArgumentException("单个附件大小不能超过 10 MB");
-        }
-        attachmentSizeCounter.add(multipartFile.getSize());
-        String fileName = normalizeOfficeFileName(
-                StrUtil.blankToDefault(multipartFile.getOriginalFilename(), "attachment"), multipartFile.getContentType());
-        String extension = StrUtil.subAfter(fileName, '.', true).toLowerCase();
-        if (!isImage(extension, multipartFile.getContentType())
-                && !isDocument(extension, multipartFile.getContentType())) {
-            throw new IllegalArgumentException("暂只支持图片、PDF、Word 和 Excel 文件:" + fileName);
-        }
+    /**
+     * 下载并提取附件文本
+     */
+    private String downloadAndExtractText(String fileUrl, String fileName) {
         try {
-            byte[] source = multipartFile.getBytes();
-            if (isOfficeDocument(extension, multipartFile.getContentType())) {
-                return buildOfficeTextContent(source, fileName);
+            URI uri = URI.create(fileUrl);
+            validatePublicHost(uri);
+            URLConnection connection = new URL(fileUrl).openConnection();
+            connection.setConnectTimeout(10_000);
+            connection.setReadTimeout(30_000);
+            if (connection instanceof HttpURLConnection httpConnection) {
+                httpConnection.setInstanceFollowRedirects(false);
+                if (httpConnection.getResponseCode() != HttpURLConnection.HTTP_OK) {
+                    throw new IOException("附件下载失败,HTTP 状态码: " + httpConnection.getResponseCode());
+                }
+            }
+            if (connection.getContentLengthLong() > MAX_DOCUMENT_SIZE_BYTES) {
+                throw new IllegalArgumentException("单个附件大小不能超过 10 MB");
+            }
+            try (InputStream inputStream = connection.getInputStream()) {
+                byte[] content = readWithSizeLimit(inputStream);
+                return extractOfficeText(content, fileName);
             }
-            return buildFileDataContent(source, fileName);
         } catch (IOException e) {
-            throw new IllegalArgumentException("读取上传附件失败: " + fileName, e);
+            throw new IllegalArgumentException("下载 Office 附件失败", e);
         }
     }
 
-    private InputContentItemFile buildFileDataContent(byte[] content, String fileName) {
-        InputContentItemFile inputFile = new InputContentItemFile();
-        inputFile.setFileData(Base64.getEncoder().encodeToString(content));
-        inputFile.setFileName(fileName);
-        return inputFile;
+    private byte[] readWithSizeLimit(InputStream inputStream) throws IOException {
+        try (java.io.ByteArrayOutputStream outputStream = new java.io.ByteArrayOutputStream()) {
+            byte[] buffer = new byte[8192];
+            long total = 0;
+            int read;
+            while ((read = inputStream.read(buffer)) != -1) {
+                total += read;
+                if (total > MAX_DOCUMENT_SIZE_BYTES) {
+                    throw new IllegalArgumentException("单个附件大小不能超过 10 MB");
+                }
+                outputStream.write(buffer, 0, read);
+            }
+            return outputStream.toByteArray();
+        }
     }
 
-    private InputContentItemText buildOfficeTextContent(byte[] content, String fileName) {
-        String text = extractOfficeText(content, fileName);
-        return InputContentItemText.builder()
-                .text("以下是附件《" + fileName + "》解析出的文本内容:\n" + text)
-                .build();
+    private void validatePublicHost(URI uri) {
+        String host = uri.getHost();
+        if (StrUtil.isBlank(host)) {
+            throw new IllegalArgumentException("附件 URL 缺少主机名");
+        }
+        try {
+            for (InetAddress address : InetAddress.getAllByName(host)) {
+                if (address.isAnyLocalAddress() || address.isLoopbackAddress() || address.isSiteLocalAddress()
+                        || address.isLinkLocalAddress() || address.isMulticastAddress()) {
+                    throw new IllegalArgumentException("附件 URL 不允许使用内网地址");
+                }
+            }
+        } catch (IOException e) {
+            throw new IllegalArgumentException("附件 URL 域名无法解析", e);
+        }
     }
 
+    /**
+     * 提取 Office 文档文本
+     */
     private String extractOfficeText(byte[] content, String fileName) {
+        if (content == null || content.length == 0) {
+            return "";
+        }
         String extension = StrUtil.subAfter(fileName, '.', true).toLowerCase();
         try {
             String text = switch (extension) {
                 case "docx" -> extractDocxText(content);
                 case "doc" -> extractDocText(content);
                 case "xlsx", "xls" -> extractExcelText(content);
-                default -> throw new IllegalArgumentException("不支持解析的 Office 文件类型:" + fileName);
+                default -> {
+                    if (extension.equals("txt") || extension.equals("csv")) {
+                        yield new String(content, java.nio.charset.StandardCharsets.UTF_8);
+                    }
+                    yield "";
+                }
             };
             if (StrUtil.isBlank(text)) {
                 throw new IllegalArgumentException("未从附件中提取到可供 AI 分析的文本:" + fileName);
@@ -470,75 +564,6 @@ public class WebQSAiServiceImpl {
         }
     }
 
-    private static class AttachmentSizeCounter {
-
-        private long totalSize;
-
-        private void add(long size) {
-            totalSize += size;
-            if (totalSize > MAX_TOTAL_DOCUMENT_SIZE_BYTES) {
-                throw new IllegalArgumentException("附件总大小不能超过 15 MB");
-            }
-        }
-    }
-
-    private byte[] downloadAttachment(String fileUrl) {
-        try {
-            URI uri = URI.create(fileUrl);
-            validatePublicHost(uri);
-            URLConnection connection = new URL(fileUrl).openConnection();
-            connection.setConnectTimeout(10_000);
-            connection.setReadTimeout(30_000);
-            if (connection instanceof HttpURLConnection httpConnection) {
-                httpConnection.setInstanceFollowRedirects(false);
-                if (httpConnection.getResponseCode() != HttpURLConnection.HTTP_OK) {
-                    throw new IOException("附件下载失败,HTTP 状态码: " + httpConnection.getResponseCode());
-                }
-            }
-            if (connection.getContentLengthLong() > MAX_DOCUMENT_SIZE_BYTES) {
-                throw new IllegalArgumentException("单个附件大小不能超过 10 MB");
-            }
-            try (InputStream inputStream = connection.getInputStream()) {
-                return readWithSizeLimit(inputStream);
-            }
-        } catch (IOException e) {
-            throw new IllegalArgumentException("下载 Office 附件失败", e);
-        }
-    }
-
-    private byte[] readWithSizeLimit(InputStream inputStream) throws IOException {
-        try (java.io.ByteArrayOutputStream outputStream = new java.io.ByteArrayOutputStream()) {
-            byte[] buffer = new byte[8192];
-            long total = 0;
-            int read;
-            while ((read = inputStream.read(buffer)) != -1) {
-                total += read;
-                if (total > MAX_DOCUMENT_SIZE_BYTES) {
-                    throw new IllegalArgumentException("单个附件大小不能超过 10 MB");
-                }
-                outputStream.write(buffer, 0, read);
-            }
-            return outputStream.toByteArray();
-        }
-    }
-
-    private void validatePublicHost(URI uri) {
-        String host = uri.getHost();
-        if (StrUtil.isBlank(host)) {
-            throw new IllegalArgumentException("附件 URL 缺少主机名");
-        }
-        try {
-            for (InetAddress address : InetAddress.getAllByName(host)) {
-                if (address.isAnyLocalAddress() || address.isLoopbackAddress() || address.isSiteLocalAddress()
-                        || address.isLinkLocalAddress() || address.isMulticastAddress()) {
-                    throw new IllegalArgumentException("附件 URL 不允许使用内网地址");
-                }
-            }
-        } catch (IOException e) {
-            throw new IllegalArgumentException("附件 URL 域名无法解析", e);
-        }
-    }
-
     private boolean isImage(String extension, String contentType) {
         return StrUtil.startWithIgnoreCase(contentType, "image/")
                 || "jpg".equals(extension) || "jpeg".equals(extension) || "png".equals(extension)
@@ -593,12 +618,13 @@ public class WebQSAiServiceImpl {
         }
     }
 
-    private void validateAttachmentUrl(String url) {
+    private String normalizeAttachmentUrl(String url) {
         try {
             URI uri = URI.create(url);
             if (!"http".equalsIgnoreCase(uri.getScheme()) && !"https".equalsIgnoreCase(uri.getScheme())) {
                 throw new IllegalArgumentException("附件 URL 必须使用 HTTP 或 HTTPS 协议");
             }
+            return uri.toASCIIString();
         } catch (IllegalArgumentException e) {
             throw new IllegalArgumentException("附件 URL 不合法", e);
         }
@@ -606,19 +632,26 @@ public class WebQSAiServiceImpl {
 
     private AiChatMessageDO createChatMessage(AiChatConversationDO conversation, AiModelDO model, Long userId,
                                               Long replyId, MessageType messageType, String content, Boolean useContext) {
-        AiChatMessageDO message = new AiChatMessageDO().setConversationId(conversation.getId()).setReplyId(replyId)
-                .setModel(model.getModel()).setModelId(model.getId()).setUserId(userId).setRoleId(conversation.getRoleId())
-                .setType(messageType.getValue()).setContent(content).setUseContext(useContext);
+        AiChatMessageDO message = new AiChatMessageDO()
+                .setConversationId(conversation.getId())
+                .setReplyId(replyId)
+                .setModel(model.getModel())
+                .setModelId(model.getId())
+                .setUserId(userId)
+                .setRoleId(conversation.getRoleId())
+                .setType(messageType.getValue())
+                .setContent(content)
+                .setUseContext(useContext);
         message.setCreateTime(LocalDateTime.now());
         chatMessageMapper.insert(message);
         return message;
     }
 
     private AiChatMessageSendRespVO createTextResponse(AiChatMessageDO userMessage,
-                                                        AiChatMessageDO assistantMessage, String delta) {
+                                                       AiChatMessageDO assistantMessage, String delta) {
         return new AiChatMessageSendRespVO()
                 .setEventType("TEXT")
                 .setSend(BeanUtils.toBean(userMessage, AiChatMessageSendRespVO.Message.class))
                 .setReceive(BeanUtils.toBean(assistantMessage, AiChatMessageSendRespVO.Message.class).setContent(delta));
     }
-}
+}

+ 67 - 29
byzs-web/src/test/java/ai/demo.java

@@ -1,39 +1,77 @@
 package ai;
-import cn.iocoder.byzs.module.web.controller.admin.ai.WebAiController;
-import com.volcengine.ark.runtime.model.responses.content.InputContentItemImage;
+
+import com.volcengine.ark.runtime.model.responses.constant.ResponsesConstants;
+import com.volcengine.ark.runtime.model.responses.content.InputContentItemFile;
 import com.volcengine.ark.runtime.model.responses.content.InputContentItemText;
 import com.volcengine.ark.runtime.model.responses.item.ItemEasyMessage;
-import com.volcengine.ark.runtime.service.ArkService;
-import com.volcengine.ark.runtime.model.responses.request.*;
-import com.volcengine.ark.runtime.model.responses.response.ResponseObject;
-import com.volcengine.ark.runtime.model.responses.constant.ResponsesConstants;
 import com.volcengine.ark.runtime.model.responses.item.MessageContent;
+import com.volcengine.ark.runtime.model.responses.request.CreateResponsesRequest;
+import com.volcengine.ark.runtime.model.responses.request.ResponsesInput;
+import com.volcengine.ark.runtime.model.responses.response.ResponseObject;
+import com.volcengine.ark.runtime.service.ArkService;
 
-//【单次调用】
-public class demo {
-    public static void main(String[] args) {
-        String apiKey = "4bb6060f-b4a1-4e0a-bf7b-71dcaa6311cb";
-        // 创建ArkService实例
-        ArkService arkService = ArkService.builder().apiKey(apiKey).baseUrl("https://ark.cn-beijing.volces.com/api/v3").build();
-
-        CreateResponsesRequest request = CreateResponsesRequest.builder()
-                .model("doubao-seed-1-8-251228")
-                .input(ResponsesInput.builder().addListItem(
-                        ItemEasyMessage.builder().role(ResponsesConstants.MESSAGE_ROLE_USER).content(
-                                MessageContent.builder()
-                                        .addListItem(InputContentItemImage.builder().imageUrl("https://learn-ai.com.cn/admin-api/infra/file/29/get/20251217/网页地图level1-32_1765957974334.png").build())
-                                        .addListItem(InputContentItemText.builder().text("这是什么植物").build())
-                                        .build()
-                        ).build()
-                ).build())
-                .build();
-        ResponseObject resp = arkService.createResponse(request);
-        String text = WebAiController.extractTextFromResponse(resp);
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Base64;
 
-        System.out.println("===== 提取到的文本 =====\n" + text);
+/**
+ * 豆包 Responses API 传入本地文件示例。
+ *
+ * 运行时传入文件路径,例如:
+ * java ai.demo D:/files/meeting.pdf
+ *
+ * 当前模型接口仅接受 PDF;Word 文件应先转换为 PDF 后再调用。
+ */
+public class demo {
 
+    private static final String BASE_URL = "https://ark.cn-beijing.volces.com/api/v3";
+    private static final String MODEL = "doubao-seed-2-1-pro-260628";
+    public static void main(String[] args) {
+        if (args.length != 1) {
+            throw new IllegalArgumentException("请传入待分析的本地文件路径,例如:D:/files/meeting.pdf");
+        }
+        String apiKey = System.getenv("ARK_API_KEY");
+        if (apiKey == null || apiKey.isBlank()) {
+            throw new IllegalStateException("请先设置环境变量 ARK_API_KEY");
+        }
 
+        Path localFile = Path.of(args[0]);
+        if (!Files.isRegularFile(localFile)) {
+            throw new IllegalArgumentException("文件不存在或不是普通文件:" + localFile.toAbsolutePath());
+        }
+        if (!localFile.getFileName().toString().toLowerCase().endsWith(".pdf")) {
+            throw new IllegalArgumentException("当前模型接口仅支持 PDF;DOC/DOCX 请通过 WebQSAiServiceImpl 的后端转换流程发送");
+        }
 
-        arkService.shutdownExecutor();
+        ArkService arkService = ArkService.builder()
+                .apiKey(apiKey)
+                .baseUrl(BASE_URL)
+                .build();
+        try {
+            // 通过 file_data 直接传入 Base64 内容,避免 Files API 对 Office 文件类型的限制。
+            InputContentItemFile inputFile = new InputContentItemFile();
+            inputFile.setFileData(Base64.getEncoder().encodeToString(Files.readAllBytes(localFile)));
+            inputFile.setFileName(localFile.getFileName().toString());
+            CreateResponsesRequest request = CreateResponsesRequest.builder()
+                    .model(MODEL)
+                    .input(ResponsesInput.builder().addListItem(
+                            ItemEasyMessage.builder()
+                                    .role(ResponsesConstants.MESSAGE_ROLE_USER)
+                                    .content(MessageContent.builder()
+                                            .addListItem(inputFile)
+                                            .addListItem(InputContentItemText.builder()
+                                                    .text("请阅读这个文件,并用中文概括其主要内容。")
+                                                    .build())
+                                            .build())
+                                    .build()
+                    ).build())
+                    .build();
+            ResponseObject response = arkService.createResponse(request);
+            System.out.println(response);
+        } catch (Exception e) {
+            throw new IllegalStateException("读取或发送文件失败:" + localFile, e);
+        } finally {
+            arkService.shutdownExecutor();
+        }
     }
-}
+}

+ 39 - 0
byzs-web/src/test/java/ai/zhiwuzhuanjia.java

@@ -0,0 +1,39 @@
+package ai;
+import cn.iocoder.byzs.module.web.controller.admin.ai.WebAiController;
+import com.volcengine.ark.runtime.model.responses.content.InputContentItemImage;
+import com.volcengine.ark.runtime.model.responses.content.InputContentItemText;
+import com.volcengine.ark.runtime.model.responses.item.ItemEasyMessage;
+import com.volcengine.ark.runtime.service.ArkService;
+import com.volcengine.ark.runtime.model.responses.request.*;
+import com.volcengine.ark.runtime.model.responses.response.ResponseObject;
+import com.volcengine.ark.runtime.model.responses.constant.ResponsesConstants;
+import com.volcengine.ark.runtime.model.responses.item.MessageContent;
+
+//【单次调用】
+public class zhiwuzhuanjia {
+    public static void main(String[] args) {
+        String apiKey = "4bb6060f-b4a1-4e0a-bf7b-71dcaa6311cb";
+        // 创建ArkService实例
+        ArkService arkService = ArkService.builder().apiKey(apiKey).baseUrl("https://ark.cn-beijing.volces.com/api/v3").build();
+
+        CreateResponsesRequest request = CreateResponsesRequest.builder()
+                .model("doubao-seed-1-8-251228")
+                .input(ResponsesInput.builder().addListItem(
+                        ItemEasyMessage.builder().role(ResponsesConstants.MESSAGE_ROLE_USER).content(
+                                MessageContent.builder()
+                                        .addListItem(InputContentItemImage.builder().imageUrl("https://learn-ai.com.cn/admin-api/infra/file/29/get/20251217/网页地图level1-32_1765957974334.png").build())
+                                        .addListItem(InputContentItemText.builder().text("这是什么植物").build())
+                                        .build()
+                        ).build()
+                ).build())
+                .build();
+        ResponseObject resp = arkService.createResponse(request);
+        String text = WebAiController.extractTextFromResponse(resp);
+
+        System.out.println("===== 提取到的文本 =====\n" + text);
+
+
+
+        arkService.shutdownExecutor();
+    }
+}