Compare commits

...
Author SHA1 Message Date
hrz d4343cf691 字段名里补上申请网址
两个字段名里写上和风天气控制台的地址,填表的人在界面上能直接照着点。文档末尾提到内部改动编号的两句改成大白话,读文档的人不用知道内部单号。
2026-10-24 07:52:24 +00:00
hrz 7a711564f1 写清楚为什么标 runOnChange
两条清理脚本在这个 PR 里改过内容,跑过旧版的库启动时靠 runOnChange 直接重跑新版。补一句说明在脚本头上,免得以后有人当成手滑删掉。
2026-10-24 07:28:06 +00:00
hrz 1df75f6882 文档写清哪些凭据会被清掉
补上清理的范围、没清掉的那一类情况和管理员自己改的办法,写明要等另外两个改动先落地。
2026-10-24 07:14:36 +00:00
hrz ba5e074bce 清理天气凭据时避开管理员自己填的值
之前那版脚本把当前默认值当成内置值,管理员改过的也会被清掉。现在先看这一行有没有被人编辑过,没动过才拿它的默认值当基准去清智能体参数,动过的一律不碰。内容改了会自动重跑,跑过旧版的库不会因为校验不一致起不来。
2026-10-24 07:14:36 +00:00
hrz bb392930b8 文档说明智控台那两栏为什么是空的
智控台这边插件参数的 API 密钥和 API Host 两栏默认是空的,数据库变更脚本已经把内置值清掉了,得照着本文件里的申请指南自己申请一套再填。之前只说默认是空的,没说清是谁清的。
2026-10-24 06:06:44 +00:00
hrz cdb4c34c4c 系统参数里的天气凭据也顺手置空
那两行后来已经被删掉了,新装的库里没有。这里再置一次空只是兜底,正常不影响任何一行。
2026-10-24 01:14:18 +00:00
hrz 36e34994ba 天气插件的凭据改成自己填,不再预置
模型提供方里内置的和风天气密钥和主机地址清空,字段名后面写清去哪儿申请。已有智能体参数里还等于内置值的那些也清掉,用户自己填的不动。

新增一条变更记录,没有改动以前那些,校验和不受影响。
2026-10-24 01:12:36 +00:00
hrz 9bd668aebc Merge pull request #3449 from TXyy2023/codex/issue-3050-preserve-model-type
fix(manager-api): 保存模型时保留供应器登记的类型大小写
2026-10-23 22:17:36 +08:00
hrz d027432432 Merge pull request #3458 from monsterlag/fix/issue-3064-b736
fix(manager-web): 修复短屏下模型配置知识库入口被裁剪
2026-10-23 21:53:42 +08:00
hrz 3972df6e4e 加固模型配置导航的契约测试
原来的用例只取第一条样式规则,后面谁再补一条同名规则就能把滚动改回去,测试照样是绿的。这次把导航用到的样式选择器逐条列出来核对,外层容器也要求只能有一条规则,这样有人悄悄覆盖时测试会当场报错。
2026-10-23 13:39:19 +00:00
monsterlag 855a64cbb9 fix(manager-web): keep model navigation scrollable on short screens 2026-10-22 04:06:52 +00:00
TXyy2023 37d4330224 test(model): verify registered types with MySQL and Redis 2026-10-18 23:13:39 +08:00
TXyy2023 7873984f9f fix(manager-api): preserve registered model type casing 2026-10-18 17:10:47 +08:00
10 changed files with 458 additions and 8 deletions

No files matched your search

+14
View File
@@ -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 里被验证过。两者都要先落地。
@@ -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());
}
@@ -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);
}
}
}
@@ -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,