cc 1 неделя назад
Родитель
Сommit
bede7a8b9f

+ 9 - 1
ai-electron/frontend/src/ai/api/modelApi.ts

@@ -5,7 +5,7 @@
  * 转 number 会丢末位精度,请求就会打到不存在的记录上。
  */
 import { aiHttp } from './http';
-import type { AiModel, CreateModelParams, ModelProvider, UpdateModelParams } from './types';
+import type { AiModel, CreateModelParams, ModelProvider, ModelTestResult, UpdateModelParams } from './types';
 
 /** 模型厂商列表 */
 export function listProviders(): Promise<ModelProvider[]> {
@@ -36,3 +36,11 @@ export function deleteModel(id: string): Promise<void> {
 export function setDefaultModel(id: string): Promise<void> {
   return aiHttp.get<void>(`/models/${id}/default`);
 }
+
+/**
+ * 测试模型连通性:服务端对已保存配置发起一次最小化真实调用。
+ * 连通失败是预期结果,正常 resolve(success=false + message),不 reject。
+ */
+export function testModel(id: string): Promise<ModelTestResult> {
+  return aiHttp.get<ModelTestResult>(`/models/${id}/test`);
+}

+ 10 - 0
ai-electron/frontend/src/ai/api/types.ts

@@ -259,6 +259,16 @@ export interface CreateModelParams {
 /** 编辑模型入参 */
 export type UpdateModelParams = Partial<CreateModelParams> & { status?: string };
 
+/** 模型连通性测试结果(GET /models/{id}/test) */
+export interface ModelTestResult {
+  /** 是否畅通(服务端真实调用成功拿到响应) */
+  success: boolean;
+  /** 本次真实调用耗时(毫秒) */
+  latencyMs?: number;
+  /** 失败原因:配置不完整 / 认证失败 / 连接超时等 */
+  message?: string;
+}
+
 /* ------------------------------------------------------------------ *
  * 查询结果分页(execute_sql → resultId)
  * ------------------------------------------------------------------ */

+ 27 - 0
ai-electron/frontend/src/ai/views/aiModel/index.vue

@@ -56,6 +56,9 @@
               <div class="ai-model__sub">更新:{{ record.updatedAt || record.createdAt || '—' }}</div>
 
               <div class="ai-model__card-ops">
+                <a-button type="link" size="small" :loading="!!testing[record.id]" @click="onTestConnection(record)">
+                  测试连接
+                </a-button>
                 <a-button type="link" size="small" @click="openEdit(record)">编辑</a-button>
                 <a-button type="link" size="small" :disabled="record.defaultModel" @click="onSetDefault(record, tab)">
                   {{ record.defaultModel ? '已是默认' : '设为默认' }}
@@ -226,6 +229,30 @@
     }
   }
 
+  /** 正在测试连通性的模型 id → loading(整体替换赋值保证响应式) */
+  const testing = ref<Record<string, boolean>>({});
+
+  /**
+   * 测试连通性:服务端对已保存配置发起一次最小化真实调用。
+   * 连通失败是预期结果,接口正常返回 success=false + 失败原因。
+   */
+  async function onTestConnection(record: AiModel) {
+    testing.value = { ...testing.value, [record.id]: true };
+    const name = record.name || record.modelId;
+    try {
+      const res = await modelApi.testModel(record.id);
+      if (res.success) {
+        showMessage(`「${name}」连接成功,耗时 ${res.latencyMs ?? 0}ms`, 'success');
+      } else {
+        showMessage(`「${name}」连接失败:${res.message || '未知原因'}`, 'error');
+      }
+    } catch (e: any) {
+      showMessage(e?.message || `「${name}」测试请求失败`, 'error');
+    } finally {
+      testing.value = { ...testing.value, [record.id]: false };
+    }
+  }
+
   function onDelete(record: any) {
     const isEmbedding = record.type === 'embedding';
     Modal.confirm({

+ 30 - 11
ai-electron/frontend/src/codex/views/workbench/WbModelPage.vue

@@ -33,10 +33,7 @@
           >
             <span v-if="record.defaultModel" class="page__tag">默认</span>
             <div class="page__card-name">
-              <span
-                class="page__dot"
-                :class="record.status === 'ACTIVE' ? 'is-ok' : 'is-off'"
-              ></span>
+              <span class="page__dot" :class="record.status === 'ACTIVE' ? 'is-ok' : 'is-off'"></span>
               {{ record.name || record.modelId }}
             </div>
             <div class="page__card-mono" :title="`${record.baseUrl || '—'} · ${record.modelId}`">
@@ -46,12 +43,10 @@
               {{ describe(record) }}
             </div>
             <div class="page__card-acts">
-              <button
-                type="button"
-                class="page__act"
-                :disabled="record.defaultModel"
-                @click="onSetDefault(record)"
-              >
+              <button type="button" class="page__act" :disabled="testing[record.id]" @click="onTestConnection(record)">
+                {{ testing[record.id] ? '测试中…' : '测试连接' }}
+              </button>
+              <button type="button" class="page__act" :disabled="record.defaultModel" @click="onSetDefault(record)">
                 {{ record.defaultModel ? '已是默认' : '设为默认' }}
               </button>
               <button type="button" class="page__act" @click="openEdit(record)">编辑</button>
@@ -169,6 +164,30 @@
     }
   }
 
+  /** 正在测试连通性的模型 id → loading(整体替换赋值保证响应式) */
+  const testing = ref<Record<string, boolean>>({});
+
+  /**
+   * 测试连通性:服务端对已保存配置发起一次最小化真实调用。
+   * 连通失败是预期结果,接口正常返回 success=false + 失败原因。
+   */
+  async function onTestConnection(record: AiModel) {
+    testing.value = { ...testing.value, [record.id]: true };
+    const name = record.name || record.modelId;
+    try {
+      const res = await modelApi.testModel(record.id);
+      if (res.success) {
+        showMessage(`「${name}」连接成功,耗时 ${res.latencyMs ?? 0}ms`, 'success');
+      } else {
+        showMessage(`「${name}」连接失败:${res.message || '未知原因'}`, 'error');
+      }
+    } catch (e: any) {
+      showMessage(e?.message || `「${name}」测试请求失败`, 'error');
+    } finally {
+      testing.value = { ...testing.value, [record.id]: false };
+    }
+  }
+
   function onDelete(record: AiModel) {
     const isEmbedding = record.type === 'embedding';
     Modal.confirm({
@@ -178,7 +197,7 @@
       }模型。`,
       okText: '删除',
       okType: 'primary',
-                    okButtonProps: { danger: true },
+      okButtonProps: { danger: true },
       cancelText: '取消',
       onOk: async () => {
         await modelApi.deleteModel(record.id);

+ 12 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/controller/AgentModelController.java

@@ -6,6 +6,7 @@ import com.zsjz.ai.module.agent.dto.UpdateModelDTO;
 import com.zsjz.ai.module.agent.service.AgentModelProviderService;
 import com.zsjz.ai.module.agent.service.AgentModelService;
 import com.zsjz.ai.module.agent.vo.ModelProviderVO;
+import com.zsjz.ai.module.agent.vo.ModelTestResultVO;
 import com.zsjz.ai.module.agent.vo.ModelVO;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
@@ -76,4 +77,15 @@ public class AgentModelController {
         agentModelService.setDefaultModel(id);
         return Result.succeed();
     }
+
+    /**
+     * 测试模型连通性:对已保存配置发起一次最小化真实调用。
+     *
+     * <p>连通失败是预期结果,以 data.success=false + message 返回(HTTP 200),
+     * 不走异常通道 —— 前端才能把失败原因作为测试结论展示。
+     */
+    @GetMapping("/{id}/test")
+    public Result<ModelTestResultVO> testModel(@PathVariable Long id) {
+        return Result.succeed(agentModelService.testModel(id));
+    }
 }

+ 9 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/service/AgentModelService.java

@@ -4,6 +4,7 @@ package com.zsjz.ai.module.agent.service;
 
 import com.zsjz.ai.module.agent.dto.CreateModelDTO;
 import com.zsjz.ai.module.agent.dto.UpdateModelDTO;
+import com.zsjz.ai.module.agent.vo.ModelTestResultVO;
 import com.zsjz.ai.module.agent.vo.ModelVO;
 
 import java.util.List;
@@ -41,6 +42,14 @@ public interface AgentModelService {
      */
     void setDefaultModel(Long id);
 
+    /**
+     * 测试模型连通性:对已保存配置发起一次最小化真实调用。
+     *
+     * <p>连通失败是预期结果而非服务端故障,失败原因放在返回值里(success=false);
+     * 只有模型本身不存在时抛 404。
+     */
+    ModelTestResultVO testModel(Long id);
+
     /**
      * 获取默认<b>对话</b>模型ID(type='llm' 且 default_model=true 且状态为启用)。
      *

+ 139 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/service/ModelConnectivityTester.java

@@ -0,0 +1,139 @@
+package com.zsjz.ai.module.agent.service;
+
+import com.zsjz.ai.common.enums.ModelTypeEnum;
+import com.zsjz.ai.common.exception.ServerException;
+import com.zsjz.ai.module.agent.entity.AgentModel;
+import com.zsjz.ai.module.agent.rag.EmbeddingModelFactory;
+import com.zsjz.ai.module.agent.vo.ModelTestResultVO;
+import io.agentscope.core.message.Msg;
+import io.agentscope.core.message.MsgRole;
+import io.agentscope.core.message.TextBlock;
+import io.agentscope.core.model.GenerateOptions;
+import io.agentscope.core.model.Model;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * 模型连通性测试:对已保存的模型配置发起一次<b>最小化真实调用</b>,
+ * 验证 Base URL / API Key / 模型 ID 组合是否可用。
+ *
+ * <p>对话模型取流式响应的首个分片即算畅通(不等完整回复,省 token 也更快);
+ * 向量模型对固定短文本做一次向量化。工厂抛出的配置校验异常(缺模型 ID / 缺 API Key)
+ * 与真实业务链路(Agent / RAG)同源,原样带出原因文案。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ModelConnectivityTester {
+
+    /**
+     * 单次测试超时:比业务调用短 —— 连通性测试要快速给出结论;
+     * 又不能太短,本地 Ollama 冷启动加载模型可能要十几秒
+     */
+    private static final Duration TEST_TIMEOUT = Duration.ofSeconds(20);
+
+    /**
+     * 测试调用的输出上限,防止一次 ping 生成一大段回复
+     */
+    private static final int PING_MAX_TOKENS = 16;
+
+    /**
+     * 失败信息最大长度(底层异常链的消息可能很长,前端只展示摘要)
+     */
+    private static final int MAX_MESSAGE_LEN = 200;
+
+    private static final String PING_TEXT = "ping";
+
+    private final AgentModelFactory agentModelFactory;
+    private final EmbeddingModelFactory embeddingModelFactory;
+
+    /**
+     * 对给定模型配置执行连通性测试。
+     *
+     * <p>连通失败是本接口的<b>预期结果</b>而非服务端故障,
+     * 因此失败详情放在返回值里(success=false + message),不抛异常,
+     * 前端拿到的是 HTTP 200 的结构化结论而非报错弹窗。
+     *
+     * @param config 模型配置(model 表记录)
+     * @return 测试结果(是否畅通 + 耗时 + 失败原因)
+     */
+    public ModelTestResultVO test(AgentModel config) {
+        long start = System.currentTimeMillis();
+        try {
+            if (ModelTypeEnum.EMBEDDING.getCode().equals(config.getType())) {
+                testEmbedding(config);
+            } else {
+                testChat(config);
+            }
+            long latency = System.currentTimeMillis() - start;
+            log.info("模型连通性测试成功: name={}, 耗时={}ms", config.getName(), latency);
+            ModelTestResultVO vo = new ModelTestResultVO();
+            vo.setSuccess(true);
+            vo.setLatencyMs(latency);
+            return vo;
+        } catch (ServerException e) {
+            return failure(config, start, e.getMessage());
+        } catch (Exception e) {
+            return failure(config, start, describeFailure(e));
+        }
+    }
+
+    /**
+     * 对话模型测试:发一条 "ping",拿到首个流式分片即取消
+     */
+    private void testChat(AgentModel config) {
+        Model model = agentModelFactory.create(config);
+        List<Msg> messages = List.of(Msg.builder().role(MsgRole.USER).textContent(PING_TEXT).build());
+        GenerateOptions options = GenerateOptions.builder().maxTokens(PING_MAX_TOKENS).build();
+        model.stream(messages, List.of(), options)
+                .take(1)
+                .timeout(TEST_TIMEOUT)
+                .blockLast();
+    }
+
+    /**
+     * 向量模型测试:对固定短文本做一次向量化
+     */
+    private void testEmbedding(AgentModel config) {
+        EmbeddingModelFactory.EmbeddingSpec spec = embeddingModelFactory.create(config);
+        double[] vec = spec.model().embed(TextBlock.builder().text(PING_TEXT).build())
+                .block(TEST_TIMEOUT);
+        if (vec == null || vec.length == 0) {
+            throw new ServerException(500, "嵌入服务返回空向量");
+        }
+    }
+
+    private ModelTestResultVO failure(AgentModel config, long start, String message) {
+        long latency = System.currentTimeMillis() - start;
+        log.warn("模型连通性测试失败: name={}, 耗时={}ms, 原因={}", config.getName(), latency, message);
+        ModelTestResultVO vo = new ModelTestResultVO();
+        vo.setSuccess(false);
+        vo.setLatencyMs(latency);
+        vo.setMessage(StringUtils.hasText(message) ? message : "连接失败");
+        return vo;
+    }
+
+    /**
+     * 把底层异常翻译成用户可读的原因:超时单列,其余取根因消息
+     */
+    private static String describeFailure(Throwable e) {
+        for (Throwable t = e; t != null; t = (t.getCause() == t ? null : t.getCause())) {
+            if (t instanceof TimeoutException) {
+                return "连接超时(" + TEST_TIMEOUT.toSeconds() + "s),请检查网络与 Base URL";
+            }
+        }
+        Throwable root = e;
+        while (root.getCause() != null && root.getCause() != root) {
+            root = root.getCause();
+        }
+        String msg = StringUtils.hasText(root.getMessage()) ? root.getMessage()
+                : (StringUtils.hasText(e.getMessage()) ? e.getMessage() : e.getClass().getSimpleName());
+        return msg.length() > MAX_MESSAGE_LEN ? msg.substring(0, MAX_MESSAGE_LEN) + "..." : msg;
+    }
+}

+ 12 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/service/impl/AgentModelServiceImpl.java

@@ -12,6 +12,8 @@ import com.zsjz.ai.module.agent.dto.UpdateModelDTO;
 import com.zsjz.ai.module.agent.entity.AgentModel;
 import com.zsjz.ai.module.agent.mapper.AgentModelMapper;
 import com.zsjz.ai.module.agent.service.AgentModelService;
+import com.zsjz.ai.module.agent.service.ModelConnectivityTester;
+import com.zsjz.ai.module.agent.vo.ModelTestResultVO;
 import com.zsjz.ai.module.agent.vo.ModelVO;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -30,6 +32,7 @@ import java.util.stream.Collectors;
 public class AgentModelServiceImpl extends ServiceImpl<AgentModelMapper, AgentModel> implements AgentModelService {
 
     private final AgentModelConverter agentModelConverter;
+    private final ModelConnectivityTester modelConnectivityTester;
 
     @Override
     public List<ModelVO> listActiveModels() {
@@ -151,6 +154,15 @@ public class AgentModelServiceImpl extends ServiceImpl<AgentModelMapper, AgentMo
         return wrapper;
     }
 
+    @Override
+    public ModelTestResultVO testModel(Long id) {
+        AgentModel entity = getById(id);
+        if (entity == null || entity.getStatus() == StatusEnum.DELETED) {
+            throw new ServerException(404, "模型不存在: " + id);
+        }
+        return modelConnectivityTester.test(entity);
+    }
+
     @Override
     public Long getDefaultModelId() {
         return getDefaultModelId(ModelTypeEnum.LLM.getCode());

+ 25 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/vo/ModelTestResultVO.java

@@ -0,0 +1,25 @@
+package com.zsjz.ai.module.agent.vo;
+
+import lombok.Data;
+
+/**
+ * 模型连通性测试结果
+ */
+@Data
+public class ModelTestResultVO {
+
+    /**
+     * 是否畅通(真实调用成功拿到响应)
+     */
+    private boolean success;
+
+    /**
+     * 本次真实调用耗时(毫秒)
+     */
+    private long latencyMs;
+
+    /**
+     * 结果说明:成功时为空,失败时为具体原因(配置不完整 / 认证失败 / 连接超时等)
+     */
+    private String message;
+}