Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4343cf691 | ||
|
|
7a711564f1 | ||
|
|
1df75f6882 | ||
|
|
ba5e074bce | ||
|
|
bb392930b8 | ||
|
|
cdb4c34c4c | ||
|
|
36e34994ba | ||
|
|
9bd668aebc | ||
|
|
d027432432 | ||
|
|
3972df6e4e | ||
|
|
855a64cbb9 | ||
|
|
37d4330224 | ||
|
|
7873984f9f |
No files matched your search
@@ -46,6 +46,10 @@
|
||||
8. 将复制过来的第二个关键配置`API Host`,填入到`开发者 API Host`里
|
||||
9. 保存配置,再保存智能体配置
|
||||
|
||||
智控台这边插件参数里的「API 密钥」和「API Host」两栏默认是空的:仓库早期版本内置过一把已经失效的和风天气凭据,数据库变更脚本把它清掉了,得照着本文件的「API Key 申请指南」申请自己的一套再填。字段名后面括号里写了申请路径。
|
||||
|
||||
脚本只清仓库内置的那一把,你自己填的凭据原样保留。以后要换凭据,在智控台「角色配置 → 编辑功能 → 天气查询」的参数表单里改完保存就行。清理的范围见文末那节。
|
||||
|
||||
### 方式2. 如果你只是单模块xiaozhi-server部署
|
||||
|
||||
在 `data/.config.yaml` 中配置:
|
||||
@@ -62,3 +66,13 @@ plugins:
|
||||
default_location: "你的默认查询城市"
|
||||
```
|
||||
|
||||
## 智控台那边内置的凭据清到了什么程度
|
||||
|
||||
早期版本把一把和风天气的 API Key 和一个 API Host 写在模型提供方「天气插件」那一行的字段默认值里,角色配置的表单拿这个默认值预填,不填就等于沿用仓库里那一份。那把 key 已经失效,仓库也不该带着凭据分发。数据库变更脚本做两件事:
|
||||
|
||||
- 把那一行的默认值清空,同时把申请路径写进字段名,用户在界面上能直接看到去哪儿申请
|
||||
- 把已有智能体的参数里还等于那把内置 key 的项清空
|
||||
|
||||
清不掉的有两类。一是管理员自己填的值,脚本不动,误清就是误删人家的凭据。二是这一种:管理员在界面上改过那一行的默认值之后,仓库内置的那把失效 key 就被覆盖了,再也认不出来,早于管理员改动建立、参数里还存着那把旧内置 key 的智能体不会被自动清掉,只能由管理员自己在界面上改。
|
||||
|
||||
这两条清理跟着另外两个改动走:一个是插件在没有凭据时给出提示而不是报错,另一个是 CI 真的会跑测试,这条变更脚本才在 CI 里被验证过。两者都要先落地。
|
||||
+8
-7
@@ -110,8 +110,8 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
|
||||
// 1. 参数验证
|
||||
validateEditParameters(modelType, provideCode, id, modelConfigBodyDTO);
|
||||
|
||||
// 2. 验证模型提供者
|
||||
validateModelProvider(modelType, provideCode);
|
||||
// 2. 验证模型提供者,使用注册类型的大小写
|
||||
String registeredModelType = resolveModelType(modelType, provideCode);
|
||||
|
||||
// 3. 获取原始配置(不经过敏感数据处理)
|
||||
ModelConfigEntity originalEntity = getOriginalConfigFromDb(id);
|
||||
@@ -120,7 +120,7 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
|
||||
validateLlmConfiguration(modelConfigBodyDTO);
|
||||
|
||||
// 5. 准备更新实体并处理敏感数据
|
||||
ModelConfigEntity modelConfigEntity = prepareUpdateEntity(modelConfigBodyDTO, originalEntity, modelType, id);
|
||||
ModelConfigEntity modelConfigEntity = prepareUpdateEntity(modelConfigBodyDTO, originalEntity, registeredModelType, id);
|
||||
|
||||
// 6. 执行数据库更新
|
||||
modelConfigDao.updateById(modelConfigEntity);
|
||||
@@ -136,9 +136,9 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
|
||||
public ModelConfigDTO add(String modelType, String provideCode, ModelConfigBodyDTO modelConfigBodyDTO) {
|
||||
validateAddParameters(modelType, provideCode, modelConfigBodyDTO);
|
||||
|
||||
validateModelProvider(modelType, provideCode);
|
||||
String registeredModelType = resolveModelType(modelType, provideCode);
|
||||
|
||||
ModelConfigEntity modelConfigEntity = prepareAddEntity(modelConfigBodyDTO, modelType);
|
||||
ModelConfigEntity modelConfigEntity = prepareAddEntity(modelConfigBodyDTO, registeredModelType);
|
||||
|
||||
modelConfigDao.insert(modelConfigEntity);
|
||||
|
||||
@@ -283,13 +283,14 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证模型提供者
|
||||
* 验证模型提供者并获取注册类型,保留 TTS、Memory、Intent 等类型的大小写
|
||||
*/
|
||||
private void validateModelProvider(String modelType, String provideCode) {
|
||||
private String resolveModelType(String modelType, String provideCode) {
|
||||
List<ModelProviderDTO> providerList = modelProviderService.getList(modelType, provideCode);
|
||||
if (CollectionUtil.isEmpty(providerList)) {
|
||||
throw new RenException(ErrorCode.MODEL_PROVIDER_NOT_EXIST);
|
||||
}
|
||||
return providerList.get(0).getModelType();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
-- 天气插件不再内置和风天气凭据
|
||||
--
|
||||
-- 这条变更记录在 db.changelog-master.yaml 里标了 runOnChange:脚本内容在这个
|
||||
-- PR 里改过好几轮,跑过旧版的库启动时直接重跑新版,不会报校验不一致。
|
||||
-- 那不是手滑,别删掉这个标记
|
||||
--
|
||||
-- 早期版本把一把和风天气的 API Key 和一个 API Host 写在模型提供方
|
||||
-- SYSTEM_PLUGIN_WEATHER 的字段默认值里。智控台的角色配置表单拿这个默认值预填,
|
||||
-- 用户不填就等于沿用仓库里那一份。那把 key 已经失效,仓库里也不该带着凭据分发,
|
||||
-- 用的人也不知道它从哪来、该不该用。
|
||||
--
|
||||
-- 这里清两处:
|
||||
-- 1. 字段默认值置空,同时把申请路径写进字段名,用户在界面上就能看到去哪儿申请
|
||||
-- 2. 已有智能体的参数里还等于内置值的那些,值清空。用户自己填的 key 不动
|
||||
--
|
||||
-- 关键前提:模型提供方那一行的「默认值」管理员能在界面上改,不能直接拿它当
|
||||
-- 「内置值」的基准——那样会把管理员自己填的凭据清掉。判断这一行有没有被人动过,
|
||||
-- 看两列:种子数据插进来的时候 updater 是 0、update_date 跟 create_date 一样;
|
||||
-- 界面上保存过一次之后,updater 变成操作人、update_date 变成改的时间。
|
||||
-- 只有从没被改过的那一行,它里面的默认值才是仓库内置的那一份,才拿来当基准。
|
||||
-- 被改过的一律不动——那是管理员自己的值,误清就是误删人家的凭据。
|
||||
--
|
||||
-- 有一处清不干净,说明白:管理员动过这一行之后,仓库内置的那把失效 key 就再也
|
||||
-- 认不出来了(它被管理员的值覆盖了),早期建立、参数里还存着那把失效 key 的智能体
|
||||
-- 只能由管理员自己在界面上改。默认配置那边同理,只能整行不动。
|
||||
--
|
||||
-- 内置值从模型提供方那一行现读,不写死在脚本里,免得仓库里再留一份凭据
|
||||
|
||||
-- 基准行有没有被人编辑过
|
||||
SET @weather_provider_untouched = (
|
||||
SELECT COUNT(*)
|
||||
FROM `ai_model_provider`
|
||||
WHERE `id` = 'SYSTEM_PLUGIN_WEATHER'
|
||||
AND `updater` = 0
|
||||
AND `update_date` = `create_date`
|
||||
);
|
||||
|
||||
-- 内置值。基准行被改过就没有内置值可比,取不到
|
||||
SET @weather_api_key = (
|
||||
SELECT jt.`field_default`
|
||||
FROM `ai_model_provider` p,
|
||||
JSON_TABLE(p.`fields`, '$[*]' COLUMNS (
|
||||
`field_key` VARCHAR(64) PATH '$.key',
|
||||
`field_default` VARCHAR(255) PATH '$.default'
|
||||
)) jt
|
||||
WHERE p.`id` = 'SYSTEM_PLUGIN_WEATHER'
|
||||
AND @weather_provider_untouched > 0
|
||||
AND jt.`field_key` = 'api_key'
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
SET @weather_api_host = (
|
||||
SELECT jt.`field_default`
|
||||
FROM `ai_model_provider` p,
|
||||
JSON_TABLE(p.`fields`, '$[*]' COLUMNS (
|
||||
`field_key` VARCHAR(64) PATH '$.key',
|
||||
`field_default` VARCHAR(255) PATH '$.default'
|
||||
)) jt
|
||||
WHERE p.`id` = 'SYSTEM_PLUGIN_WEATHER'
|
||||
AND @weather_provider_untouched > 0
|
||||
AND jt.`field_key` = 'api_host'
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
-- 字段在数组里的位置。数组下标从 0 起,JSON_TABLE 的序号从 1 起,两者差一
|
||||
SET @weather_api_key_pos = (
|
||||
SELECT jt.`field_ord`
|
||||
FROM `ai_model_provider` p,
|
||||
JSON_TABLE(p.`fields`, '$[*]' COLUMNS (
|
||||
`field_ord` FOR ORDINALITY,
|
||||
`field_key` VARCHAR(64) PATH '$.key'
|
||||
)) jt
|
||||
WHERE p.`id` = 'SYSTEM_PLUGIN_WEATHER'
|
||||
AND @weather_provider_untouched > 0
|
||||
AND jt.`field_key` = 'api_key'
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
SET @weather_api_host_pos = (
|
||||
SELECT jt.`field_ord`
|
||||
FROM `ai_model_provider` p,
|
||||
JSON_TABLE(p.`fields`, '$[*]' COLUMNS (
|
||||
`field_ord` FOR ORDINALITY,
|
||||
`field_key` VARCHAR(64) PATH '$.key'
|
||||
)) jt
|
||||
WHERE p.`id` = 'SYSTEM_PLUGIN_WEATHER'
|
||||
AND @weather_provider_untouched > 0
|
||||
AND jt.`field_key` = 'api_host'
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
-- 智能体参数里还等于内置值的项,清空。基准行被改过就取不到内置值,这时一律不动:
|
||||
-- 管理员自己填的值不能碰,早于管理员编辑建立的智能体里存的旧内置值也认不出来了
|
||||
UPDATE `ai_agent_plugin_mapping`
|
||||
SET `param_info` = JSON_SET(`param_info`, '$.api_key', '')
|
||||
WHERE `plugin_id` = 'SYSTEM_PLUGIN_WEATHER'
|
||||
AND @weather_api_key IS NOT NULL
|
||||
AND @weather_api_key <> ''
|
||||
AND JSON_UNQUOTE(JSON_EXTRACT(`param_info`, '$.api_key')) = @weather_api_key;
|
||||
|
||||
UPDATE `ai_agent_plugin_mapping`
|
||||
SET `param_info` = JSON_SET(`param_info`, '$.api_host', '')
|
||||
WHERE `plugin_id` = 'SYSTEM_PLUGIN_WEATHER'
|
||||
AND @weather_api_host IS NOT NULL
|
||||
AND @weather_api_host <> ''
|
||||
AND JSON_UNQUOTE(JSON_EXTRACT(`param_info`, '$.api_host')) = @weather_api_host;
|
||||
|
||||
-- 字段默认值置空,字段名里写清申请路径。默认值那一栏在界面上还带着提示,
|
||||
-- 光把值清空没用,填表的人根本不知道该去哪儿拿
|
||||
-- 两项各自一条更新,各守卫各的字段位置:管理员把其中一项的字段删了,
|
||||
-- 另一项的默认值照样要清掉,不能因为少一个位置就整条都跳过
|
||||
UPDATE `ai_model_provider`
|
||||
SET `fields` = JSON_SET(
|
||||
`fields`,
|
||||
CONCAT('$[', @weather_api_key_pos - 1, '].default'), '',
|
||||
CONCAT('$[', @weather_api_key_pos - 1, '].label'),
|
||||
'天气插件 API 密钥(到和风天气控制台 https://console.qweather.com/project?lang=zh 申请,创建应用时勾选 GeoAPI 城市查询、实时天气、7 天天气预报三项权限;仓库里的 docs/weather-integration.md 有申请步骤)'
|
||||
)
|
||||
WHERE `id` = 'SYSTEM_PLUGIN_WEATHER'
|
||||
AND @weather_provider_untouched > 0
|
||||
AND @weather_api_key_pos IS NOT NULL;
|
||||
|
||||
UPDATE `ai_model_provider`
|
||||
SET `fields` = JSON_SET(
|
||||
`fields`,
|
||||
CONCAT('$[', @weather_api_host_pos - 1, '].default'), '',
|
||||
CONCAT('$[', @weather_api_host_pos - 1, '].label'),
|
||||
'开发者 API Host(和风天气控制台「设置」https://console.qweather.com/setting?lang=zh 里查看,跟 API 密钥配套使用;仓库里的 docs/weather-integration.md 有申请步骤)'
|
||||
)
|
||||
WHERE `id` = 'SYSTEM_PLUGIN_WEATHER'
|
||||
AND @weather_provider_untouched > 0
|
||||
AND @weather_api_host_pos IS NOT NULL;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- 兜底:早期版本把天气插件的凭据放在系统参数里,那两条记录后来被 202505292203.sql
|
||||
-- 删掉了,新装的库里本来就没有这两行。这里再置一次空,防止有环境绕过了那步删除。
|
||||
-- 正常情况下影响零行
|
||||
--
|
||||
-- 这条变更记录在 db.changelog-master.yaml 里标了 runOnChange:脚本内容在这个
|
||||
-- PR 里改过,跑过旧版的库启动时直接重跑新版,不会报校验不一致。那不是手滑
|
||||
--
|
||||
-- 只清从没被人改过的行:系统参数界面里这两行也能改,管理员填进去的凭据不能被抹掉。
|
||||
-- 种子数据插进来时没写 updater 和 update_date,这两列是空的;界面上保存过一次之后
|
||||
-- 就会写上操作人和时间
|
||||
UPDATE `sys_params`
|
||||
SET `param_value` = ''
|
||||
WHERE `param_code` IN ('plugins.get_weather.api_key', 'plugins.get_weather.api_host')
|
||||
AND `updater` IS NULL
|
||||
AND `update_date` IS NULL;
|
||||
@@ -753,3 +753,21 @@ databaseChangeLog:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202609172100.sql
|
||||
# 下面两条是清理天气插件内置凭据用的,脚本内容在本 PR 里改过好几次,
|
||||
# 标成 runOnChange:跑过旧版的库启动时直接重跑新版,不会报校验不一致
|
||||
- changeSet:
|
||||
id: 202610241030
|
||||
author: hrz
|
||||
runOnChange: true
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202610241030.sql
|
||||
- changeSet:
|
||||
id: 202610241130
|
||||
author: hrz
|
||||
runOnChange: true
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202610241130.sql
|
||||
@@ -44,7 +44,7 @@ class SensitiveDataUtilsTest {
|
||||
void maskMiddle_keeps_four_at_each_end_for_long_strings() {
|
||||
String masked = SensitiveDataUtils.maskMiddle("verylongapikeyvalue123");
|
||||
assertTrue(masked.startsWith("very"));
|
||||
assertTrue(masked.endsWith("3123"));
|
||||
assertTrue(masked.endsWith("e123"));
|
||||
assertTrue(masked.contains("****"));
|
||||
assertEquals("verylongapikeyvalue123".length(), masked.length());
|
||||
}
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package xiaozhi.modules.model.service.impl;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import cn.hutool.json.JSONObject;
|
||||
import xiaozhi.common.redis.RedisKeys;
|
||||
import xiaozhi.common.redis.RedisUtils;
|
||||
import xiaozhi.modules.model.dto.ModelConfigBodyDTO;
|
||||
import xiaozhi.modules.model.dto.ModelConfigDTO;
|
||||
import xiaozhi.modules.model.service.ModelConfigService;
|
||||
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("dev")
|
||||
@Transactional
|
||||
class ModelConfigPersistenceTest {
|
||||
|
||||
@Autowired
|
||||
private ModelConfigService service;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbc;
|
||||
|
||||
@Autowired
|
||||
private RedisUtils redis;
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({"tts,TTS", "memory,Memory", "intent,Intent"})
|
||||
void preservesRegisteredTypeWhenAddingAndEditing(String requestedType, String registeredType) {
|
||||
String id = UUID.randomUUID().toString().replace("-", "");
|
||||
String providerCode = "test-" + id;
|
||||
jdbc.update("INSERT INTO ai_model_provider (id, model_type, provider_code, name, fields) VALUES (?, ?, ?, ?, ?)",
|
||||
id, registeredType, providerCode, "Persistence test provider", "[]");
|
||||
|
||||
ModelConfigBodyDTO body = new ModelConfigBodyDTO();
|
||||
body.setId(id);
|
||||
body.setModelCode(providerCode);
|
||||
body.setModelName("Persistence test model");
|
||||
body.setIsDefault(0);
|
||||
body.setIsEnabled(1);
|
||||
body.setSort(1);
|
||||
body.setConfigJson(new JSONObject().set("type", providerCode));
|
||||
|
||||
ModelConfigDTO added = service.add(requestedType, providerCode, body);
|
||||
assertEquals(registeredType, added.getModelType());
|
||||
assertEquals(registeredType,
|
||||
jdbc.queryForObject("SELECT model_type FROM ai_model_config WHERE id = ?", String.class, id));
|
||||
|
||||
String cacheKey = RedisKeys.getModelConfigById(id);
|
||||
try {
|
||||
redis.set(cacheKey, "cached model");
|
||||
body.setModelName("Updated persistence test model");
|
||||
ModelConfigDTO edited = service.edit(requestedType, providerCode, id, body);
|
||||
|
||||
assertEquals(registeredType, edited.getModelType());
|
||||
assertEquals(registeredType,
|
||||
jdbc.queryForObject("SELECT model_type FROM ai_model_config WHERE id = ?", String.class, id));
|
||||
assertEquals(body.getModelName(),
|
||||
jdbc.queryForObject("SELECT model_name FROM ai_model_config WHERE id = ?", String.class, id));
|
||||
assertNull(redis.get(cacheKey));
|
||||
} finally {
|
||||
redis.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package xiaozhi.modules.model.service.impl;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.redis.RedisUtils;
|
||||
import xiaozhi.common.utils.MessageUtils;
|
||||
import xiaozhi.modules.agent.dao.AgentDao;
|
||||
import xiaozhi.modules.model.dao.ModelConfigDao;
|
||||
import xiaozhi.modules.model.dto.ModelConfigBodyDTO;
|
||||
import xiaozhi.modules.model.dto.ModelConfigDTO;
|
||||
import xiaozhi.modules.model.dto.ModelProviderDTO;
|
||||
import xiaozhi.modules.model.entity.ModelConfigEntity;
|
||||
import xiaozhi.modules.model.service.ModelProviderService;
|
||||
|
||||
class ModelConfigServiceImplTest {
|
||||
|
||||
private ModelConfigDao modelConfigDao;
|
||||
private ModelProviderService modelProviderService;
|
||||
private ModelConfigServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
modelConfigDao = mock(ModelConfigDao.class);
|
||||
modelProviderService = mock(ModelProviderService.class);
|
||||
service = new ModelConfigServiceImpl(
|
||||
modelConfigDao, modelProviderService, mock(RedisUtils.class), mock(AgentDao.class));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"vad, VAD", "asr, ASR", "tts, TTS", "llm, LLM", "vllm, VLLM",
|
||||
"memory, Memory", "intent, Intent", "rag, RAG", "LLM, LLM"
|
||||
})
|
||||
void addPreservesRegisteredProviderModelType(String requestedType, String registeredType) {
|
||||
registerProvider(requestedType, registeredType);
|
||||
|
||||
ModelConfigDTO result = service.add(requestedType, "test-provider", modelBody());
|
||||
|
||||
ArgumentCaptor<ModelConfigEntity> saved = ArgumentCaptor.forClass(ModelConfigEntity.class);
|
||||
verify(modelConfigDao).insert(saved.capture());
|
||||
assertEquals(registeredType, saved.getValue().getModelType());
|
||||
assertEquals(registeredType, result.getModelType());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"vad, VAD", "asr, ASR", "tts, TTS", "llm, LLM", "vllm, VLLM",
|
||||
"memory, Memory", "intent, Intent", "rag, RAG", "LLM, LLM"
|
||||
})
|
||||
void editPreservesRegisteredProviderModelType(String requestedType, String registeredType) {
|
||||
registerProvider(requestedType, registeredType);
|
||||
ModelConfigEntity original = new ModelConfigEntity();
|
||||
original.setId("test-model");
|
||||
original.setModelType(registeredType);
|
||||
when(modelConfigDao.selectById("test-model")).thenReturn(original);
|
||||
|
||||
ModelConfigDTO result = service.edit(requestedType, "test-provider", "test-model", modelBody());
|
||||
|
||||
ArgumentCaptor<ModelConfigEntity> saved = ArgumentCaptor.forClass(ModelConfigEntity.class);
|
||||
verify(modelConfigDao).updateById(saved.capture());
|
||||
assertEquals(registeredType, saved.getValue().getModelType());
|
||||
assertEquals(registeredType, result.getModelType());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void rejectsUnknownProviderBeforeWriting(boolean editing) {
|
||||
when(modelProviderService.getList("tts", "missing")).thenReturn(List.of());
|
||||
|
||||
try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
|
||||
messages.when(() -> MessageUtils.getMessage(ErrorCode.MODEL_PROVIDER_NOT_EXIST))
|
||||
.thenReturn("Model provider does not exist");
|
||||
RenException error = assertThrows(RenException.class, () -> {
|
||||
if (editing) {
|
||||
service.edit("tts", "missing", "test-model", modelBody());
|
||||
} else {
|
||||
service.add("tts", "missing", modelBody());
|
||||
}
|
||||
});
|
||||
assertEquals(ErrorCode.MODEL_PROVIDER_NOT_EXIST, error.getCode());
|
||||
}
|
||||
|
||||
verify(modelConfigDao, never()).insert(any(ModelConfigEntity.class));
|
||||
verify(modelConfigDao, never()).updateById(any(ModelConfigEntity.class));
|
||||
}
|
||||
|
||||
private void registerProvider(String requestedType, String registeredType) {
|
||||
ModelProviderDTO provider = new ModelProviderDTO();
|
||||
provider.setModelType(registeredType);
|
||||
provider.setProviderCode("test-provider");
|
||||
when(modelProviderService.getList(requestedType, "test-provider")).thenReturn(List.of(provider));
|
||||
}
|
||||
|
||||
private ModelConfigBodyDTO modelBody() {
|
||||
ModelConfigBodyDTO body = new ModelConfigBodyDTO();
|
||||
body.setId("test-model");
|
||||
body.setModelName("Test model");
|
||||
body.setIsEnabled(1);
|
||||
body.setSort(1);
|
||||
return body;
|
||||
}
|
||||
}
|
||||
@@ -613,6 +613,8 @@ export default {
|
||||
.nav-panel {
|
||||
min-width: 242px;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid #ebeef5;
|
||||
background: linear-gradient(
|
||||
120deg,
|
||||
@@ -629,6 +631,7 @@ export default {
|
||||
|
||||
.nav-panel .el-menu-item {
|
||||
height: 50px;
|
||||
flex-shrink: 0;
|
||||
background: #e9f0ff;
|
||||
line-height: 50px;
|
||||
border-radius: 4px 0 0 4px !important;
|
||||
|
||||
@@ -14,6 +14,10 @@ const functionDialogSource = await readFile(
|
||||
new URL('../src/components/FunctionDialog.vue', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const modelConfigSource = await readFile(
|
||||
new URL('../src/views/ModelConfig.vue', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
test('address-book permission state consistently uses the target device MAC', () => {
|
||||
assert.match(
|
||||
@@ -66,6 +70,74 @@ test('correct-word pagination maps the UI page size to the backend limit query',
|
||||
assert.doesNotMatch(correctWordApiSource, /pageSize: params\.pageSize/);
|
||||
});
|
||||
|
||||
test('model navigation keeps its scrollable layout declarations', () => {
|
||||
const navStyles = modelConfigSource.match(/\.nav-panel\s*\{([^}]*)\}/)?.[1];
|
||||
const itemStyles = modelConfigSource.match(
|
||||
/\.nav-panel\s+\.el-menu-item\s*\{([^}]*)\}/,
|
||||
)?.[1];
|
||||
|
||||
assert.ok(navStyles, 'model navigation should define its layout');
|
||||
assert.ok(itemStyles, 'model menu items should define their layout');
|
||||
const wrapperStyles = modelConfigSource.match(/\.main-wrapper\s*\{([^}]*)\}/)?.[1];
|
||||
assert.ok(wrapperStyles, 'model page wrapper should define its height');
|
||||
|
||||
assert.match(navStyles, /box-sizing:\s*border-box;/);
|
||||
assert.match(navStyles, /overflow-y:\s*auto;/);
|
||||
assert.match(navStyles, /display:\s*flex;/);
|
||||
assert.match(navStyles, /flex-direction:\s*column;/);
|
||||
assert.match(itemStyles, /flex-shrink:\s*0;/);
|
||||
assert.match(wrapperStyles, /height:\s*calc\([^)]*vh[^)]*\)/);
|
||||
|
||||
// 断言只取第一条同名规则,后面再补一条就能盖掉上面三条,必须挡住
|
||||
assert.equal(
|
||||
(modelConfigSource.match(/\.nav-panel\s*\{/g) || []).length,
|
||||
1,
|
||||
'model navigation should keep a single rule',
|
||||
);
|
||||
assert.equal(
|
||||
(modelConfigSource.match(/\.nav-panel\s+\.el-menu-item\s*\{/g) || []).length,
|
||||
1,
|
||||
'model menu items should keep a single rule',
|
||||
);
|
||||
// 复合选择器特异性更高,能直接盖掉可滚属性
|
||||
assert.equal(
|
||||
(modelConfigSource.match(/\.nav-panel[.:\[#][^{}]*\{/g) || []).length,
|
||||
0,
|
||||
'model navigation should not use composite selectors',
|
||||
);
|
||||
|
||||
const styleSource = modelConfigSource.match(/<style[^>]*>([\s\S]*?)<\/style>/)?.[1];
|
||||
assert.ok(styleSource, 'model config should define its styles');
|
||||
const ruleSelectors = styleSource
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/\/\/[^\n]*/g, '')
|
||||
.split('}')
|
||||
.map((chunk) => {
|
||||
const open = chunk.indexOf('{');
|
||||
return open < 0 ? null : chunk.slice(0, open).trim();
|
||||
})
|
||||
.filter((selector) => selector && !selector.startsWith('@'));
|
||||
|
||||
// 上面几条挡不住带伪类的后代选择器(.nav-panel .el-menu-item:hover),
|
||||
// 它特异性一样更高,照样能盖掉可滚属性,所以把 nav 相关的选择器逐条列出来核对
|
||||
assert.deepEqual(
|
||||
ruleSelectors.filter((selector) => selector.includes('.nav-panel')).sort(),
|
||||
[
|
||||
'.nav-panel',
|
||||
'.nav-panel .el-menu-item',
|
||||
'.nav-panel .el-menu-item.is-active',
|
||||
'.nav-panel .el-menu-item.is-active::before',
|
||||
'::v-deep .nav-panel .el-menu-item.is-active .menu-text',
|
||||
].sort(),
|
||||
'model navigation should keep its declared rules',
|
||||
);
|
||||
assert.deepEqual(
|
||||
ruleSelectors.filter((selector) => selector.includes('.main-wrapper')),
|
||||
['.main-wrapper'],
|
||||
'model page wrapper should keep a single rule',
|
||||
);
|
||||
});
|
||||
|
||||
test('function dialog footer stays above the expanding MCP tools section', () => {
|
||||
const mcpLayer = functionDialogSource.match(
|
||||
/\.mcp-access-point\s*\{[^}]*z-index:\s*(\d+);/s,
|
||||
|
||||
Reference in new issue
Block a user