Compare commits

..
3 Commits
44 changed files with 528 additions and 924 deletions

No files matched your search

+8 -49
View File
@@ -20,58 +20,24 @@ jobs:
python-version: '3.10'
cache: 'pip'
cache-dependency-path: 'main/xiaozhi-server/requirements.txt'
- name: Install system dependencies
run: |
# opuslib_next 是 ctypes 绑定,pip 装上的是纯 Python 包,真正的编解码
# 靠系统里的 libopus.so。runner 上默认没有,import 会失败,音频相关的
# 用例直接报错。正式镜像 Dockerfile-server-base 里也是装 libopus0 的
sudo apt-get update
sudo apt-get install -y --no-install-recommends libopus0
- name: Install dependencies
working-directory: main/xiaozhi-server
run: |
pip install -r requirements.txt
pip install pytest pytest-asyncio freezegun
- name: Prepare test config
- name: Run smoke test
working-directory: main/xiaozhi-server
run: |
# config/settings.py 在 import 期就要这个文件,缺了整套用例连收集都过不去。
# data/ 在 .gitignore 里,只能由这一步现造
mkdir -p data
printf '{}\n' > data/.config.yaml
- name: Run tests
working-directory: main/xiaozhi-server
run: |
# Edge TTS 那条要连微软公网,runner 的出口 IP 会被风控挡掉,先排除。
# 根治办法是给它打 live 标记,让 mock 模式下自动不收(另开一条改)
pytest tests/ -v -k "not test_tts_text_to_speak_writes_file"
if [ -f tests/test_smoke.py ]; then
pytest tests/test_smoke.py -x -q
else
echo "::warning::Slice 2 not done yet — tests/test_smoke.py missing"
exit 0
fi
java:
name: Java (manager-api)
runs-on: ubuntu-latest
# 三个 @SpringBootTest 要真连 MySQL 和 Redis,空库即可,Liquibase 会自己建表
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: 123456
MYSQL_DATABASE: xiaozhi_esp32_server
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -p123456"
--health-interval=10s
--health-timeout=5s
--health-retries=20
redis:
image: redis:8.0
ports:
- 6379:6379
options: >-
--health-cmd="redis-cli ping"
--health-interval=10s
--health-timeout=5s
--health-retries=20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
@@ -81,12 +47,7 @@ jobs:
cache: 'maven'
- name: Run tests
working-directory: main/manager-api
run: mvn -B test -DskipTests=false -DfailIfNoTests=false
- name: Check the integration tests really ran
working-directory: main/manager-api
# 守卫在依赖不齐时会把整类集成测试跳过,这里盯一下它们有没有真跑,
# 免得门禁被悄悄掏空
run: bash scripts/check-integration-tests.sh
run: mvn -B -q test -DfailIfNoTests=false
web:
name: Vue (manager-web)
@@ -102,6 +63,4 @@ jobs:
working-directory: main/manager-web
run: |
npm ci --no-audit --no-fund
npm run test:contract
npm run test:unit
npm run build
+1 -3
View File
@@ -144,7 +144,5 @@
### Q: 我叫他呼叫"张山",但是他老是识别成"张三",怎么办?
- 可以查阅你使用的asr服务的文档,确认是否支持热词识别。
- 如果你用的是`FunASRServer`,可以在容器里的`热词文件`里添加"张山",然后重启容器。
- 如果你用的是`火山引擎`的服务,可以在`火山引擎的控制台`里添加`热词文件`,然后回到智控台的`模型配置`页面,在`语音识别`里找到模型名称为`豆包语音识别(流式)`的那一条,点`修改`,弹窗里的`供应器`是`火山引擎语音识别(流式)`,在弹窗的`调用信息`区域把`热词文件名称`填到对应的输入框,点`确认保存`。
- `豆包语音识别2.0(流式)`(供应器是`火山引擎语音识别(流式)`)和`豆包语音识别`(供应器是`火山引擎语音识别`)这两条也都有`热词文件名称`这个字段,用法一样,配置时注意别选错行。
- 改完要让设备重新连接才会生效:设备连上时才会把最新的私有配置拉下来,一直连着的设备不会自动变。
- 如果你用的是`火山引擎`的服务,可以在`火山引擎的控制台`里添加`热词文件`,然后回到智控台的`模型配置页面`,把`热词文件名称`配置在`火山引擎的tts`上去。
+4 -12
View File
@@ -2,15 +2,14 @@
## 功能简介
联网搜索插件 `web_search` 支持在对话过程中实时联网搜索信息并返回结果。插件支持四个搜索源:秘塔(Metaso)、Tavily、Serply和Outcrawl,用户可根据需要选择其中一个。
联网搜索插件 `web_search` 支持在对话过程中实时联网搜索信息并返回结果。插件支持三个搜索源:秘塔(Metaso)、Tavily和Serply,用户可根据需要选择其中一个。
## API Key申请指南
目前我们适配了`秘塔搜索`、`Tavily搜索`、`Serply搜索`和`Outcrawl搜索`。
目前我们适配了`秘塔搜索`、`Tavily搜索`和`Serply搜索`。
- Tavily搜索:每个月1000次免费额度。
- 秘塔搜索:拥有较为优质的国内数据源。
- Serply搜索:返回谷歌搜索结果,注册赠送2500次免费额度,额度用完后需要付费。
- Outcrawl搜索:面向AI智能体的网页搜索接口,每月1000 credits免费额度。
## API Key申请指南
@@ -33,13 +32,6 @@
- 复制生成的API Key,这是配置所需的关键信息
- 接口说明见 [Serply文档](https://serply.io/docs)
### 方式四:使用Outcrawl搜索
- 访问 [Outcrawl](https://outcrawl.ai),注册并登录账号
- 在控制台中创建API Key
- 复制生成的API Key,这是配置所需的关键信息
- 接口说明见 [Outcrawl文档](https://outcrawl.ai/docs/search/overview)
## 配置方式
### 方式1. 使用智控台部署(推荐)
@@ -48,14 +40,14 @@
- 进入"配置角色"页面,选择要配置的智能体
- 点击"编辑功能"按钮,在右侧参数配置区域找到"联网搜索"插件
- 勾选"联网搜索"
- 填入搜索源(`metaso`、`tavily`、`serply`或`outcrawl`),并将对应的`API Key`填入配置项
- 填入搜索源(`metaso`、`tavily`或`serply`),并将对应的`API Key`填入配置项
- 保存配置,再保存智能体配置
### 方式2. 单模块xiaozhi-server部署
在 `data/.config.yaml` 中配置:
- 将搜索源填入 `provider`,可选值为 `metaso`、`tavily`、`serply` 或 `outcrawl`
- 将搜索源填入 `provider`,可选值为 `metaso`、`tavily` 或 `serply`
- 将申请到的API Key填入 `api_key`
```yaml
-25
View File
@@ -16,28 +16,3 @@ Vue 3.x
# 接口文档
启动后打开:http://localhost:8002/xiaozhi/doc.html
# 跑测试
测试分两种:纯单元测试,和要真连 MySQL、Redis 的集成测试(`DeviceTest`、`loginControllerTest`、`ModelConfigPersistenceTest`)。
集成测试需要先起好 MySQL 8 和 Redis 8。没起的时候这类测试整类跳过,不会让构建变红,日志里会写明缺的是哪个依赖。CI 上不一样:依赖连不上就直接让构建失败,不许跳过,免得门禁被悄悄掏空。
本地用 Docker 起这两个依赖:
```bash
docker run -d --name xiaozhi-mysql -p 3306:3306 \
-e MYSQL_ROOT_PASSWORD=123456 -e MYSQL_DATABASE=xiaozhi_esp32_server mysql:8.0
docker run -d --name xiaozhi-redis -p 6379:6379 redis:8.0
```
跑全部测试:
```bash
mvn -B test -DskipTests=false
```
只想编译、不跑测试:
```bash
mvn -B package -DskipTests=true
```
+1 -1
View File
@@ -32,7 +32,7 @@
<liquibase-core.version>4.20.0</liquibase-core.version>
<aliyun-sms-version>4.1.0</aliyun-sms-version>
<okio-version>3.4.0</okio-version>
<skipTests>false</skipTests>
<skipTests>true</skipTests>
<argLine></argLine>
</properties>
@@ -1,44 +0,0 @@
#!/usr/bin/env bash
# 检查那几个要连 MySQL 和 Redis 的集成测试是不是真的跑了。
# 守卫要是哪天手滑把它们全跳过了,CI 必须红,不能安安静静少跑一半门禁。
set -uo pipefail
REPORT_DIR="${1:-target/surefire-reports}"
WATCHED=(
"xiaozhi.modules.device.DeviceTest"
"xiaozhi.modules.model.service.impl.ModelConfigPersistenceTest"
"xiaozhi.modules.sys.loginControllerTest"
)
if [ ! -d "$REPORT_DIR" ]; then
echo "找不到测试报告目录:$REPORT_DIR"
exit 1
fi
missing=()
for class in "${WATCHED[@]}"; do
count=0
for report in "$REPORT_DIR"/TEST-*.xml; do
[ -e "$report" ] || continue
name=$(sed -n 's/.*<testsuite[^>]*name="\([^"]*\)".*/\1/p' "$report" | head -1)
if [ "$name" = "$class" ]; then
count=$(sed -n 's/.*<testsuite[^>]*tests="\([0-9]*\)".*/\1/p' "$report" | head -1)
count=${count:-0}
break
fi
done
if [ "$count" -eq 0 ]; then
missing+=("$class")
else
echo "OK $class 跑了 $count 条"
fi
done
if [ ${#missing[@]} -ne 0 ]; then
echo "这几个集成测试一条都没跑:${missing[*]}"
echo "守卫把门禁掏空了,构建必须失败"
exit 1
fi
echo "集成测试都跑过了"
@@ -1,22 +0,0 @@
-- PaddleSpeechTTS:智控台协议收敛为仅 websocket(与 xiaozhi-server 实现一致)
UPDATE `ai_model_provider`
SET `fields` = JSON_ARRAY(
JSON_OBJECT('key', 'protocol', 'label', '协议类型', 'type', 'string', 'options', JSON_ARRAY('websocket')),
JSON_OBJECT('key', 'url', 'label', '服务地址', 'type', 'string'),
JSON_OBJECT('key', 'spk_id', 'label', '音色', 'type', 'int'),
JSON_OBJECT('key', 'sample_rate', 'label', '采样率', 'type', 'float'),
JSON_OBJECT('key', 'speed', 'label', '语速', 'type', 'float'),
JSON_OBJECT('key', 'volume', 'label', '音量', 'type', 'float'),
JSON_OBJECT('key', 'save_path', 'label', '保存路径', 'type', 'string')
)
WHERE `id` = 'SYSTEM_TTS_PaddleSpeechTTS';
UPDATE `ai_model_config`
SET `remark` = 'PaddleSpeechTTS 配置说明:
1. PaddleSpeech 是百度飞桨开源的语音合成工具,支持本地离线部署和模型训练。paddlepaddle百度飞浆框架地址:https://www.paddlepaddle.org.cn/
2. 当前仅支持 WebSocket 协议进行流式传输(参考部署文档:https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/paddlespeech-deploy.md)。
3. 使用前要在本地部署 paddlespeech 服务,服务默认运行在 ws://127.0.0.1:8092/paddlespeech/tts/streaming
4. 支持自定义发音人、语速、音量和采样率。
'
WHERE `id` = 'TTS_PaddleSpeechTTS';
@@ -1,9 +0,0 @@
-- 联网搜索插件新增Outcrawl搜索源
UPDATE `ai_model_provider`
SET `fields` = JSON_SET(
`fields`,
'$[0].label',
'搜索源:metaso / tavily / serply / outcrawl'
)
WHERE `id` = 'SYSTEM_PLUGIN_WEB_SEARCH';
@@ -753,17 +753,3 @@ databaseChangeLog:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202609172100.sql
- changeSet:
id: 202610302051
author: shinjiyu
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202610302051.sql
- changeSet:
id: 202611021200
author: heydryft
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202611021200.sql
@@ -14,14 +14,9 @@ import java.util.Collections;
import java.util.Date;
import java.util.List;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import xiaozhi.modules.agent.dao.AgentCorrectWordMappingDao;
import xiaozhi.modules.agent.entity.AgentCorrectWordMappingEntity;
import xiaozhi.modules.correctword.dao.CorrectWordFileDao;
@@ -42,20 +37,6 @@ class CorrectWordFileServiceImplTest {
private final CorrectWordFileServiceImpl service =
new CorrectWordFileServiceImpl(fileDao, itemDao, mappingDao);
@BeforeAll
static void registerEntityMetadata() {
// 这条用例不起 Spring 上下文,MyBatis-Plus 的实体元数据没人注册,
// LambdaQueryWrapper 拼条件时找不到 lambda 缓存就抛异常。
// 全量跑的时候是前面的上下文测试顺带注册好的,单独跑就露馅。
// 这里自己补上,单独跑和跟着全量跑结果一样。
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""),
CorrectWordItemEntity.class);
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""),
CorrectWordFileEntity.class);
}
@Test
void getAllItemsByAgentId_returns_empty_list_when_no_mappings() {
when(mappingDao.selectByAgentId(AGENT_ID)).thenReturn(Collections.emptyList());
@@ -3,7 +3,6 @@ package xiaozhi.modules.device;
import java.util.HashMap;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -16,7 +15,6 @@ import xiaozhi.common.exception.RenException;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.sys.dto.SysUserDTO;
import xiaozhi.modules.sys.service.SysUserService;
import xiaozhi.testsupport.IntegrationTestEnvironment;
@Slf4j
@SpringBootTest
@@ -24,11 +22,6 @@ import xiaozhi.testsupport.IntegrationTestEnvironment;
@DisplayName("设备测试")
public class DeviceTest {
@BeforeAll
static void checkDependencies() {
IntegrationTestEnvironment.requireDatabaseAndRedis();
}
@Autowired
private RedisUtils redisUtils;
@Autowired
@@ -5,7 +5,6 @@ import static org.junit.jupiter.api.Assertions.assertNull;
import java.util.UUID;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.springframework.beans.factory.annotation.Autowired;
@@ -20,18 +19,12 @@ import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.model.dto.ModelConfigBodyDTO;
import xiaozhi.modules.model.dto.ModelConfigDTO;
import xiaozhi.modules.model.service.ModelConfigService;
import xiaozhi.testsupport.IntegrationTestEnvironment;
@SpringBootTest
@ActiveProfiles("dev")
@Transactional
class ModelConfigPersistenceTest {
@BeforeAll
static void checkDependencies() {
IntegrationTestEnvironment.requireDatabaseAndRedis();
}
@Autowired
private ModelConfigService service;
@@ -4,7 +4,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@@ -19,18 +18,12 @@ import xiaozhi.modules.security.dto.LoginDTO;
import xiaozhi.modules.security.dto.SmsVerificationDTO;
import xiaozhi.modules.sys.dto.RetrievePasswordDTO;
import xiaozhi.modules.sys.service.SysUserService;
import xiaozhi.testsupport.IntegrationTestEnvironment;
@Slf4j
@SpringBootTest
@ActiveProfiles("dev")
class loginControllerTest {
@BeforeAll
static void checkDependencies() {
IntegrationTestEnvironment.requireDatabaseAndRedis();
}
@Autowired
LoginController loginController;
@@ -1,149 +0,0 @@
package xiaozhi.testsupport;
import java.io.IOException;
import java.sql.Connection;
import java.sql.DriverManager;
import java.time.Duration;
import org.junit.jupiter.api.Assumptions;
import org.springframework.boot.env.YamlPropertySourceLoader;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.PropertySource;
import org.springframework.core.env.PropertySourcesPropertyResolver;
import org.springframework.core.env.StandardEnvironment;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import io.lettuce.core.RedisClient;
import io.lettuce.core.RedisURI;
import io.lettuce.core.api.StatefulRedisConnection;
import lombok.extern.slf4j.Slf4j;
/**
* 几个 @SpringBootTest 要真连 MySQL 和 Redis。本地没起这两个依赖时,
* 整类测试跳过,别让 Spring 上下文起不来变成一堆看不懂的报错;
* CI 里依赖必须真的在,探不通就直接让构建红。
*/
@Slf4j
public final class IntegrationTestEnvironment {
/** 集成测试用的配置文件,跟应用本身读的是同一套 */
private static final String[] CONFIG_FILES = {"application.yml", "application-dev.yml"};
private static Boolean available;
private static String problem;
private IntegrationTestEnvironment() {
}
/**
* 放在要连库的测试类的 @BeforeAll 里。
* 本地依赖不齐就整类跳过,CI 里依赖不齐就直接失败。
*/
public static void requireDatabaseAndRedis() {
String issue = probe();
if (issue == null) {
return;
}
if (isCi()) {
throw new IllegalStateException(
"CI 环境里" + issue + ",这几个集成测试必须真的跑起来,不能跳过");
}
log.warn("跳过依赖 MySQL 和 Redis 的集成测试:{}", issue);
Assumptions.assumeTrue(false, issue);
}
/** GitHub Actions 上会带 CI=true */
public static boolean isCi() {
String value = System.getenv("CI");
if (value == null) {
return false;
}
String trimmed = value.trim();
return !trimmed.isEmpty() && !"false".equalsIgnoreCase(trimmed);
}
/** 依赖都齐返回 null,不齐返回缺什么 */
static synchronized String probe() {
if (available != null) {
return problem;
}
PropertySourcesPropertyResolver resolver = resolver();
StringBuilder issues = new StringBuilder();
String url = firstNonBlank(resolver.getProperty("spring.datasource.druid.url", ""),
resolver.getProperty("spring.datasource.url", ""));
String username = firstNonBlank(resolver.getProperty("spring.datasource.druid.username", ""),
resolver.getProperty("spring.datasource.username", ""));
String password = firstNonBlank(resolver.getProperty("spring.datasource.druid.password", ""),
resolver.getProperty("spring.datasource.password", ""));
if (url.isBlank()) {
issues.append("配置里找不到数据源地址;");
} else {
try {
DriverManager.setLoginTimeout(5);
try (Connection connection = DriverManager.getConnection(url, username, password)) {
connection.isValid(3);
}
} catch (Exception e) {
issues.append("连不上 MySQL(").append(e.getMessage()).append(");");
}
}
String redisHost = resolver.getProperty("spring.data.redis.host", "localhost");
int redisPort = resolver.getProperty("spring.data.redis.port", Integer.class, 6379);
String redisPassword = resolver.getProperty("spring.data.redis.password", "");
try {
RedisURI.Builder builder = RedisURI.builder()
.withHost(redisHost)
.withPort(redisPort)
.withTimeout(Duration.ofSeconds(3));
if (redisPassword != null && !redisPassword.isBlank()) {
builder.withPassword(redisPassword.toCharArray());
}
RedisClient client = RedisClient.create(builder.build());
try {
StatefulRedisConnection<String, String> connection = client.connect();
connection.sync().ping();
connection.close();
} finally {
client.shutdown();
}
} catch (Exception e) {
issues.append("连不上 Redis(").append(e.getMessage()).append(");");
}
if (issues.length() == 0) {
available = Boolean.TRUE;
problem = null;
} else {
available = Boolean.FALSE;
problem = issues.toString();
}
return problem;
}
/** 读应用自己那份配置,命令行上的 -D 参数优先级更高,跟应用保持一致 */
private static PropertySourcesPropertyResolver resolver() {
ConfigurableEnvironment environment = new StandardEnvironment();
YamlPropertySourceLoader loader = new YamlPropertySourceLoader();
for (String name : CONFIG_FILES) {
Resource resource = new ClassPathResource(name);
if (!resource.exists()) {
continue;
}
try {
for (PropertySource<?> source : loader.load(name, resource)) {
environment.getPropertySources().addLast(source);
}
} catch (IOException e) {
log.warn("读配置 {} 失败:{}", name, e.getMessage());
}
}
return new PropertySourcesPropertyResolver(environment.getPropertySources());
}
private static String firstNonBlank(String first, String second) {
return first != null && !first.isBlank() ? first : (second == null ? "" : second);
}
}
+5
View File
@@ -120,6 +120,11 @@ export default defineManifestConfig({
/* 小程序特有相关 */
'mp-weixin': {
appid: VITE_WX_APPID,
// 仅供开发者工具选择调试基础库版本;线上客户端的最低基础库门槛需在
// 微信公众平台「开发管理 → 基础库最低版本设置」中配置(代码里改不动)。
// 功能硬性要求:wx.getRandomValues 需基础库 >= 2.17.3,低于该版本的
// 线上客户端登录时将收到 fail-closed 报错
libVersion: '2.19.4',
setting: {
urlCheck: false,
// 是否启用 ES6 转 ES5
+1 -1
View File
@@ -101,7 +101,7 @@
"js-cookie": "^3.0.5",
"pinia": "2.0.36",
"pinia-plugin-persistedstate": "3.2.1",
"sm-crypto": "^0.3.13",
"sm-crypto": "0.5.8",
"vue": "^3.4.21",
"wot-design-uni": "^1.9.1",
"z-paging": "2.8.7"
@@ -0,0 +1,29 @@
diff --git a/src/sm2/rng.js b/src/sm2/rng.js
index c6572c1756579fb100c0d7bdf0cec323971ec211..cdb1f1fcc7d58c52138f6f9fd76240bae5dde5d4 100644
--- a/src/sm2/rng.js
+++ b/src/sm2/rng.js
@@ -54,8 +54,14 @@ function rngSeedTime() {
rngSeedInt(new Date().getTime())
}
-// Initialize the pool with junk if needed.
-if (rngPool == null) {
+// [xiaozhi patch] 熵池初始化从模块加载时推迟到首次取随机字节时(见 rngGetByte)。
+// 原因:微信小程序等环境没有同步的 crypto.getRandomValues,模块加载即取熵会直接抛异常;
+// 推迟后,调用方可在首次使用前异步准备好真随机源(见 manager-mobile/src/utils/secure-random.ts)。
+// fail-closed 语义保持不变:首次取熵时仍要求存在可用的 crypto.getRandomValues,否则抛出异常。
+function ensureRngPool() {
+ if (rngPool != null)
+ return
+
rngPool = []
rngPptr = 0
let t
@@ -82,6 +88,7 @@ if (rngPool == null) {
function rngGetByte() {
if (rngState == null) {
+ ensureRngPool()
rngSeedTime()
rngState = new Arcfour()
rngState.init(rngPool)
+8 -5
View File
@@ -11,6 +11,9 @@ patchedDependencies:
'@dcloudio/uni-h5':
hash: 5763725268e9a493075be3c82d91b590b9ae30e997a04a418494dd7d6d327b9d
path: patches/@dcloudio__uni-h5.patch
sm-crypto@0.5.8:
hash: 62930665cf9612107916df2f12c9deab4ad907f6ac9080520b84bffe016c3352
path: patches/sm-crypto@0.5.8.patch
importers:
@@ -92,8 +95,8 @@ importers:
specifier: 3.2.1
version: 3.2.1(pinia@2.0.36(typescript@5.7.2)(vue@3.5.15(typescript@5.7.2)))
sm-crypto:
specifier: ^0.3.13
version: 0.3.14
specifier: 0.5.8
version: 0.5.8(patch_hash=62930665cf9612107916df2f12c9deab4ad907f6ac9080520b84bffe016c3352)
vue:
specifier: ^3.4.21
version: 3.5.15(typescript@5.7.2)
@@ -6026,8 +6029,8 @@ packages:
resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==}
engines: {node: '>=18'}
sm-crypto@0.3.14:
resolution: {integrity: sha512-sR7NuGAJH93Om8keAF2/rj1EqFzEiUey4aKGyDo3nQ2BiVqhgq8UkiIoIp5qhV9w4pG7qArhfqyewcG8AbhXbg==}
sm-crypto@0.5.8:
resolution: {integrity: sha512-bpz1/PthgZHcLHyIl28mbYZHmeuTDrdWpwHZ7BGzFjQuB7OOhjlbcPEMt44aqUfZ2S5YJXM8v20xYO1j9CC45Q==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
@@ -14156,7 +14159,7 @@ snapshots:
ansi-styles: 6.2.1
is-fullwidth-code-point: 5.0.0
sm-crypto@0.3.14:
sm-crypto@0.5.8(patch_hash=62930665cf9612107916df2f12c9deab4ad907f6ac9080520b84bffe016c3352):
dependencies:
jsbn: 1.1.0
+1 -1
View File
@@ -1,6 +1,6 @@
packages:
- '**'
- '!node_modules'
patchedDependencies:
'@dcloudio/uni-h5': patches/@dcloudio__uni-h5.patch
sm-crypto@0.5.8: patches/sm-crypto@0.5.8.patch
@@ -0,0 +1,126 @@
<script lang="ts">
import { defineComponent } from 'vue'
// #ifdef APP-PLUS
import { ensureSecureRandomReady, registerRandomBridge } from '@/utils/secure-random'
// #endif
interface BridgeItem {
length: number
resolve: (bytes: number[]) => void
reject: (err: Error) => void
}
/**
* App 端专用(页面中 #ifdef APP-PLUS 条件挂载):
* 逻辑层无 Web Crypto,经 renderjs 在视图层 WebView 取
* crypto.getRandomValues 后 callMethod 回传,注册为 secure-random 的随机源。
*/
export default defineComponent({
data() {
return {
/** 变化后触发 renderjs 取随机(每次新对象引用确保 prop 变更被感知) */
bridgeRequest: null as { id: number, length: number } | null,
// 仅类型检查用:renderjs 模块绑定由 uni-app 编译期处理,运行时不读取
secureRandomRenderer: { onPropChange: () => {} } as { onPropChange: (...args: any[]) => void },
seq: 0,
queue: [] as BridgeItem[],
sending: false,
currentId: 0,
timeoutTimer: undefined as ReturnType<typeof setTimeout> | undefined,
}
},
mounted() {
// #ifdef APP-PLUS
registerRandomBridge((length: number) =>
this.requestBytes(length).then(bytes => new Uint8Array(bytes)),
)
ensureSecureRandomReady().catch((e: Error) => {
console.warn('[secure-random] App 端随机源预热失败', e)
})
// #endif
},
beforeUnmount() {
clearTimeout(this.timeoutTimer)
},
methods: {
requestBytes(length: number): Promise<number[]> {
return new Promise((resolve, reject) => {
this.queue.push({ length, resolve, reject })
this.pump()
})
},
/** 串行发送:prop 变更过快时视图层可能丢失中间状态,故一次只发一个请求 */
pump() {
if (this.sending || this.queue.length === 0)
return
this.sending = true
const item = this.queue[0]
this.currentId = ++this.seq
this.bridgeRequest = { id: this.currentId, length: item.length }
// 超时保护:视图层无响应时按 fail-closed 报错,避免登录流程永久挂起
clearTimeout(this.timeoutTimer)
this.timeoutTimer = setTimeout(() => {
this.onRandomResult({ id: this.currentId, error: 'renderjs 随机数请求超时' })
}, 5000)
},
/** renderjs 通过 callMethod 回传结果(或错误) */
onRandomResult(res: { id: number, bytes?: number[], error?: string }) {
if (res.id !== this.currentId)
return
clearTimeout(this.timeoutTimer)
const item = this.queue.shift()
this.sending = false
if (item) {
if (res.error)
item.reject(new Error(res.error))
else
item.resolve(res.bytes || [])
}
this.pump()
},
},
})
</script>
<script module="secureRandomRenderer" lang="renderjs">
// 视图层(WebView):可访问 crypto.getRandomValues
export default {
methods: {
onPropChange(newVal, oldVal, ownerInstance, instance) {
if (!newVal || !newVal.id || !newVal.length)
return
try {
const cryptoObj = (typeof crypto !== 'undefined' && crypto)
|| (typeof window !== 'undefined' && window.crypto)
if (!cryptoObj || typeof cryptoObj.getRandomValues !== 'function')
throw new Error('视图层缺少 crypto.getRandomValues')
const bytes = new Uint8Array(newVal.length)
cryptoObj.getRandomValues(bytes)
ownerInstance.callMethod('onRandomResult', { id: newVal.id, bytes: Array.from(bytes) })
}
catch (e) {
ownerInstance.callMethod('onRandomResult', {
id: newVal.id,
error: String((e && e.message) || e),
})
}
},
},
}
</script>
<template>
<view
class="secure-random-bridge"
:prop="bridgeRequest"
:change:prop="secureRandomRenderer.onPropChange"
/>
</template>
<style>
.secure-random-bridge {
width: 0;
height: 0;
overflow: hidden;
}
</style>
+1
View File
@@ -92,6 +92,7 @@
"minified": true
},
"usingComponents": true,
"libVersion": "2.19.4",
"optimization": {
"subPackages": true
},
@@ -13,6 +13,10 @@ import { onLoad } from '@dcloudio/uni-app'
import { computed, onMounted, ref } from 'vue'
// 导入API接口
import { retrievePassword, sendSmsCode } from '@/api/auth'
// #ifdef APP-PLUS
// App 端随机源桥接:为 SM2 加密提供安全随机数(无视觉展示)
import SecureRandomBridge from '@/components/secure-random-bridge/index.vue'
// #endif
// 导入国际化相关功能
import { initI18n, t } from '@/i18n'
import { useConfigStore } from '@/store'
@@ -235,7 +239,7 @@ async function handleResetPassword() {
try {
// 拼接图形验证码和新密码进行加密
const captchaAndPassword = formData.value.captcha + formData.value.newPassword
encryptedPassword = sm2Encrypt(sm2PublicKey.value, captchaAndPassword)
encryptedPassword = await sm2Encrypt(sm2PublicKey.value, captchaAndPassword)
}
catch (error) {
console.error('密码加密失败:', error)
@@ -305,6 +309,9 @@ onMounted(async () => {
class="app-container box-border h-screen w-full"
:style="{ paddingTop: `${safeAreaInsets?.top}px` }"
>
<!-- #ifdef APP-PLUS -->
<SecureRandomBridge />
<!-- #endif -->
<view class="header">
<view class="back-button" @click="goBack">
<wd-icon name="arrow-left" custom-class="back-icon" />
@@ -13,6 +13,10 @@ import type { LoginData } from '@/api/auth'
import type { Language } from '@/store/lang'
import { computed, onMounted, ref } from 'vue'
import { login } from '@/api/auth'
// #ifdef APP-PLUS
// App 端随机源桥接:为 SM2 加密提供安全随机数(无视觉展示)
import SecureRandomBridge from '@/components/secure-random-bridge/index.vue'
// #endif
// 导入国际化相关功能
import { changeLanguage, getCurrentLanguage, getSupportedLanguages, initI18n, t } from '@/i18n'
import { useConfigStore, useUserStore } from '@/store'
@@ -202,7 +206,7 @@ async function handleLogin() {
try {
// 拼接验证码和密码
const captchaAndPassword = formData.value.captcha + formData.value.password
encryptedPassword = sm2Encrypt(sm2PublicKey, captchaAndPassword)
encryptedPassword = await sm2Encrypt(sm2PublicKey, captchaAndPassword)
}
catch (error) {
console.error('密码加密失败:', error)
@@ -281,6 +285,9 @@ onMounted(async () => {
<template>
<view class="app-container box-border h-screen w-full" :style="{ paddingTop: `${safeAreaInsets?.top}px` }">
<!-- #ifdef APP-PLUS -->
<SecureRandomBridge />
<!-- #endif -->
<view class="header">
<view class="logo-section">
<wd-img :width="80" :height="80" round src="/static/logo.png" class="logo" />
@@ -11,6 +11,10 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'
import { register, sendSmsCode } from '@/api/auth'
// #ifdef APP-PLUS
// App 端随机源桥接:为 SM2 加密提供安全随机数(无视觉展示)
import SecureRandomBridge from '@/components/secure-random-bridge/index.vue'
// #endif
// 导入国际化相关功能
import { initI18n, t } from '@/i18n'
import { useConfigStore } from '@/store'
@@ -251,7 +255,7 @@ async function handleRegister() {
try {
// 拼接验证码和密码
const captchaAndPassword = formData.value.captcha + formData.value.password
encryptedPassword = sm2Encrypt(sm2PublicKey.value, captchaAndPassword)
encryptedPassword = await sm2Encrypt(sm2PublicKey.value, captchaAndPassword)
}
catch (error) {
console.error('密码加密失败:', error)
@@ -325,6 +329,9 @@ onMounted(async () => {
<template>
<view class="app-container box-border h-screen w-full" :style="{ paddingTop: `${safeAreaInsets?.top}px` }">
<!-- #ifdef APP-PLUS -->
<SecureRandomBridge />
<!-- #endif -->
<view class="header">
<view class="back-button" @click="goBack">
<wd-icon name="arrow-left" custom-class="back-icon" />
+24 -9
View File
@@ -2,6 +2,21 @@ import smCrypto from 'sm-crypto'
import { pages, subPackages } from '@/pages.json'
import { isMpWeixin } from './platform'
import { ensureSecureRandomReady } from './secure-random'
/**
* 获取 sm2 模块(确保密码学安全随机源已就绪)。
*
* sm-crypto >= 0.5.0(CVE-2026-73567 修复)强制要求 crypto.getRandomValues。
* 小程序等环境没有该 API,项目中通过 pnpm patch(patches/sm-crypto@0.5.8.patch)
* 将 sm-crypto 的熵池初始化推迟到首次取随机字节时,并由 secure-random.ts 提供
* 垫片。因此小程序端首次调用 sm2 前必须 await 本方法,用 wx.getRandomValues
* 预热真随机缓冲池;浏览器 / Node 等原生支持 globalThis.crypto 的环境立即就绪。
*/
async function getSm2(): Promise<typeof smCrypto.sm2> {
await ensureSecureRandomReady()
return smCrypto.sm2
}
/**
* 运行时服务端地址覆盖存储键
@@ -201,11 +216,11 @@ export function getEnvBaseUploadUrl() {
/**
* 生成SM2密钥对(十六进制格式)
* @returns {object} 包含公钥和私钥的对象
* @returns {Promise<object>} 包含公钥和私钥的对象
*/
export function generateSm2KeyPairHex() {
export async function generateSm2KeyPairHex() {
// 使用sm-crypto库生成SM2密钥对
const sm2 = smCrypto.sm2
const sm2 = await getSm2()
const keypair = sm2.generateKeyPairHex()
return {
@@ -220,9 +235,9 @@ export function generateSm2KeyPairHex() {
* SM2公钥加密
* @param {string} publicKey 公钥(十六进制格式)
* @param {string} plainText 明文
* @returns {string} 加密后的密文(十六进制格式)
* @returns {Promise<string>} 加密后的密文(十六进制格式)
*/
export function sm2Encrypt(publicKey: string, plainText: string): string {
export async function sm2Encrypt(publicKey: string, plainText: string): Promise<string> {
if (!publicKey) {
throw new Error('公钥不能为null或undefined')
}
@@ -231,7 +246,7 @@ export function sm2Encrypt(publicKey: string, plainText: string): string {
throw new Error('明文不能为空')
}
const sm2 = smCrypto.sm2
const sm2 = await getSm2()
// SM2加密,添加04前缀表示未压缩公钥
const encrypted = sm2.doEncrypt(plainText, publicKey, 1)
// 转换为十六进制格式(与后端保持一致,添加04前缀)
@@ -244,10 +259,10 @@ export function sm2Encrypt(publicKey: string, plainText: string): string {
* SM2私钥解密
* @param {string} privateKey 私钥(十六进制格式)
* @param {string} cipherText 密文(十六进制格式)
* @returns {string} 解密后的明文
* @returns {Promise<string>} 解密后的明文(当前项目内暂无调用点,保留备用)
*/
export function sm2Decrypt(privateKey: string, cipherText: string): string {
const sm2 = smCrypto.sm2
export async function sm2Decrypt(privateKey: string, cipherText: string): Promise<string> {
const sm2 = await getSm2()
// 移除04前缀(与后端保持一致)
const dataWithoutPrefix = cipherText.startsWith('04') ? cipherText.substring(2) : cipherText
// SM2解密
@@ -0,0 +1,194 @@
/**
* 为 sm-crypto >= 0.5.0 提供 crypto.getRandomValues 兼容层
* (0.5.0 起强制要求 CSPRNG,不支持时模块加载即抛异常,见 CVE-2026-73567)。
*
* 策略:
* 1. 经平台异步 CSPRNG API 分块并行取真随机字节,填入同步缓冲池。
* 探测顺序 wx → uni → my → tt → swan → ks → qq → jd → lark → xhs;
* App 端逻辑层无 CSPRNG,
* 由 secure-random-bridge 组件经 renderjs 回传(registerRandomBridge 兜底)。
* 2. 垫片同步从缓冲池取字节,池不足即抛错,不回退弱随机(fail-closed)。
* 3. sm-crypto 熵池初始化已通过 pnpm patch 推迟到首次取随机
* (patches/sm-crypto@0.5.8.patch),首次 sm2 调用前 await
* ensureSecureRandomReady() 即可。
*
* 无任何 CSPRNG 的平台(如支付宝/百度小程序)会 reject,属预期行为。
*/
const CHUNK_SIZE = 64
const CHUNK_COUNT = 8
const LOW_WATER_MARK = 128
// 标记垫片自身,探测时与原生实现区分,避免失败重试被短路成假成功
const SHIM_MARKER = Symbol('secureRandomShim')
// App 端桥接通道,由 secure-random-bridge 组件挂载时注册
let bridgeProvider: ((length: number) => Promise<Uint8Array>) | null = null
export function registerRandomBridge(provider: (length: number) => Promise<Uint8Array>) {
bridgeProvider = provider
}
let pool: number[] = []
let readyPromise: Promise<void> | null = null
let refillInFlight = false
function getGlobalObj(): Record<string, any> | null {
// sm-crypto 读取的是 globalThis.crypto,垫片必须装在同一对象上
if (typeof globalThis !== 'undefined')
return globalThis as unknown as Record<string, any>
return null
}
function hasNativeGetRandomValues(): boolean {
try {
// 探测 globalThis.crypto:Node、h5:ssr 等环境有原生 CSPRNG 但无 window
const fn = getGlobalObj()?.crypto?.getRandomValues
// 排除垫片自身,否则失败重试会被短路成假成功
return typeof fn === 'function' && !(fn as any)[SHIM_MARKER]
}
catch {
return false
}
}
/**
* 按优先级探测提供异步 CSPRNG 的平台全局对象。
* uni.getRandomValues 仅有声明无实现,故排在 wx 之后兜底;
* 其余平台对象未在 TS 类型中声明,经 globalThis 探测,
* 对应平台未来实现同名 API 时自动生效。
*/
function findRandomApi(): { name: string, api: any } | null {
const globalObj = getGlobalObj()
const candidates: Array<[string, any]> = [
['wx', typeof wx !== 'undefined' ? wx : null],
['uni', typeof uni !== 'undefined' ? uni : null],
['my', globalObj ? globalObj.my : null],
['tt', globalObj ? globalObj.tt : null],
['swan', globalObj ? globalObj.swan : null],
['ks', globalObj ? globalObj.ks : null],
['qq', globalObj ? globalObj.qq : null],
['jd', globalObj ? globalObj.jd : null],
['lark', globalObj ? globalObj.lark : null],
['xhs', globalObj ? globalObj.xhs : null],
]
for (const [name, api] of candidates) {
if (api && typeof api.getRandomValues === 'function')
return { name, api }
}
return null
}
function fetchRandomChunk(length: number): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
const found = findRandomApi()
if (!found && !bridgeProvider) {
reject(new Error(
'当前平台不支持获取密码学安全随机数(getRandomValues),无法安全执行 SM2 加密。'
+ '当前已适配:微信小程序(wx.getRandomValues)、浏览器、Node 环境、'
+ 'App 端(renderjs 桥接,需页面挂载 secure-random-bridge 组件);'
+ '其余小程序平台因缺少 CSPRNG API 被按 fail-closed 原则拒绝,'
+ '如需支持请扩展 secure-random.ts 的 findRandomApi 适配链。',
))
return
}
if (found) {
found.api.getRandomValues({
length,
success: (res: { randomValues: ArrayBuffer | Uint8Array | number[] }) => {
resolve(new Uint8Array(res.randomValues))
},
fail: (err: unknown) => {
reject(err instanceof Error ? err : new Error(`getRandomValues 调用失败: ${JSON.stringify(err)}`))
},
})
return
}
bridgeProvider!(length).then(resolve, (err: unknown) => {
reject(err instanceof Error ? err : new Error(`renderjs 桥接取随机失败: ${String(err)}`))
})
})
}
async function refillPool(totalLength: number): Promise<void> {
const chunks = await Promise.all(
Array.from({ length: Math.ceil(totalLength / CHUNK_SIZE) }, () => fetchRandomChunk(CHUNK_SIZE)),
)
for (const chunk of chunks) {
for (let i = 0; i < chunk.length; i++)
pool.push(chunk[i])
}
}
function scheduleRefill() {
if (refillInFlight)
return
refillInFlight = true
refillPool(256)
.catch((err) => {
// 补充失败仅告警:池仍够用则无感,耗尽时由 getRandomValues 抛错兜底
console.warn('[secure-random] 补充随机数缓冲池失败', err)
})
.finally(() => {
refillInFlight = false
})
}
function createGetRandomValues<T extends ArrayBufferView>(array: T): T {
// 按字节拷贝,兼容 Uint16Array/Uint32Array 等视图
const view = array as unknown as ArrayBufferView
const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength)
if (pool.length < bytes.length) {
scheduleRefill()
throw new Error('安全随机数缓冲池未就绪或已耗尽,请先等待 ensureSecureRandomReady() 完成')
}
for (let i = 0; i < bytes.length; i++)
bytes[i] = pool[i]
pool = pool.slice(bytes.length)
if (pool.length < LOW_WATER_MARK)
scheduleRefill()
return array
}
;(createGetRandomValues as any)[SHIM_MARKER] = true
function installShim() {
const globalObj = getGlobalObj()
if (!globalObj)
throw new Error('无法获取全局对象,无法安装 crypto.getRandomValues 垫片')
if (globalObj.crypto && typeof globalObj.crypto.getRandomValues === 'function')
return
if (globalObj.crypto) {
// 只补缺的方法,保留宿主 crypto 其他成员;defineProperty 规避只读访问器赋值失败
Object.defineProperty(globalObj.crypto, 'getRandomValues', {
value: createGetRandomValues,
configurable: true,
writable: true,
})
}
else {
globalObj.crypto = { getRandomValues: createGetRandomValues }
}
}
/**
* 确保密码学安全随机源可用(首次 sm2 调用前必须 await)。
* 原生支持的环境直接就绪;缺失环境安装垫片并预热缓冲池;
* 无 CSPRNG 的环境 reject,由调用方 fail-closed 处理。
*/
export function ensureSecureRandomReady(): Promise<void> {
if (hasNativeGetRandomValues())
return Promise.resolve()
if (!readyPromise) {
readyPromise = (async () => {
installShim()
await refillPool(CHUNK_SIZE * CHUNK_COUNT)
})().catch((err) => {
// 失败后重置,允许后续重试
readyPromise = null
throw err
})
}
return readyPromise
}
+4 -4
View File
@@ -17,7 +17,7 @@
"normalize.css": "^8.0.1",
"opus-decoder": "^0.7.7",
"opus-recorder": "^8.0.5",
"sm-crypto": "^0.3.13",
"sm-crypto": "^0.5.8",
"v-code-diff": "^1.16.0",
"vue": "^2.6.14",
"vue-axios": "^3.5.2",
@@ -12485,9 +12485,9 @@
}
},
"node_modules/sm-crypto": {
"version": "0.3.13",
"resolved": "https://registry.npmmirror.com/sm-crypto/-/sm-crypto-0.3.13.tgz",
"integrity": "sha512-ztNF+pZq6viCPMA1A6KKu3bgpkmYti5avykRHbcFIdSipFdkVmfUw2CnpM2kBJyppIalqvczLNM3wR8OQ0pT5w==",
"version": "0.5.8",
"resolved": "https://registry.npmmirror.com/sm-crypto/-/sm-crypto-0.5.8.tgz",
"integrity": "sha512-bpz1/PthgZHcLHyIl28mbYZHmeuTDrdWpwHZ7BGzFjQuB7OOhjlbcPEMt44aqUfZ2S5YJXM8v20xYO1j9CC45Q==",
"license": "MIT",
"dependencies": {
"jsbn": "^1.1.0"
+1 -1
View File
@@ -22,7 +22,7 @@
"normalize.css": "^8.0.1",
"opus-decoder": "^0.7.7",
"opus-recorder": "^8.0.5",
"sm-crypto": "^0.3.13",
"sm-crypto": "^0.5.8",
"v-code-diff": "^1.16.0",
"vue": "^2.6.14",
"vue-axios": "^3.5.2",
+17 -18
View File
@@ -172,19 +172,18 @@ plugins:
# ragflow知识库id
dataset_ids: ["123456789"]
# 联网搜索插件配置
# 支持秘塔(metaso)、Tavily、Serply和Outcrawl四个搜索源
# 支持秘塔(metaso)、Tavily和Serply三个搜索源
# 秘塔API Key申请:https://metaso.cn/search-api/api-keys
# Tavily API Key申请:https://app.tavily.com/home
# Serply API Key申请:https://serply.io
# Outcrawl API Key申请:https://outcrawl.ai
web_search:
# 搜索源:metaso / tavily / serply / outcrawl
# 搜索源:metaso / tavily / serply
provider: "metaso"
# 工具描述,用于告知LLM何时调用此工具,可根据需要自定义
description: "联网搜索工具。当用户明确需要联网搜索问题时使用此工具。"
# 返回结果数量(默认5)
max_results: 5
# 秘塔API Key(mk-前缀) / Tavily API Key(tvly-前缀) / Serply API Key / Outcrawl API Key
# 秘塔API Key(mk-前缀) / Tavily API Key(tvly-前缀) / Serply API Key
api_key: "mk-xxx"
# 声纹识别配置
voiceprint:
@@ -399,8 +398,8 @@ ASR:
# DoubaoASR和DoubaoStreamASR的区别是:DoubaoASR是按次收费,DoubaoStreamASR是按时收费
# 一般来说按次收费的更便宜,但是DoubaoStreamASR使用了大模型技术,效果更好
type: doubao
appid: 你的火山引擎语音识别服务appid
access_token: 你的火山引擎语音识别服务access_token
appid: 你的火山引擎语音合成服务appid
access_token: 你的火山引擎语音合成服务access_token
cluster: volcengine_input_common
# 热词、替换词使用流程:https://www.volcengine.com/docs/6561/155738
boosting_table_name: (选填)你的热词文件名称
@@ -413,8 +412,8 @@ ASR:
# 开通地址https://console.volcengine.com/speech/service/10011
# 一般来说按次收费的更便宜,但是DoubaoStreamASR使用了大模型技术,效果更好
type: doubao_stream
appid: 你的火山引擎语音识别服务appid
access_token: 你的火山引擎语音识别服务access_token
appid: 你的火山引擎语音合成服务appid
access_token: 你的火山引擎语音合成服务access_token
# 资源ID:小时版 volc.bigasr.sauc.duration,并发版 volc.bigasr.sauc.concurrent
resource_id: volc.bigasr.sauc.duration
# 热词、替换词使用流程:https://www.volcengine.com/docs/6561/155738
@@ -433,8 +432,8 @@ ASR:
# 开通地址:https://console.volcengine.com/speech/service/10038
# 价格更为便宜,建议在高并发场景下使用
type: doubao_stream
appid: 你的火山引擎语音识别服务appid
access_token: 你的火山引擎语音识别服务access_token
appid: 你的火山引擎语音合成服务appid
access_token: 你的火山引擎语音合成服务access_token
# 资源ID:小时版 volc.seedasr.sauc.duration,并发版 volc.seedasr.sauc.concurrent
resource_id: volc.seedasr.sauc.duration
# 热词、替换词使用流程:https://www.volcengine.com/docs/6561/155738
@@ -452,9 +451,9 @@ ASR:
# token申请地址:https://console.cloud.tencent.com/cam/capi
# 免费领取资源:https://console.cloud.tencent.com/asr/resourcebundle
type: tencent
appid: 你的腾讯语音识别服务appid
secret_id: 你的腾讯语音识别服务secret_id
secret_key: 你的腾讯语音识别服务secret_key
appid: 你的腾讯语音合成服务appid
secret_id: 你的腾讯语音合成服务secret_id
secret_key: 你的腾讯语音合成服务secret_key
output_dir: tmp/
AliyunASR:
# 阿里云智能语音交互服务,需要先在阿里云平台开通服务,然后获取验证信息
@@ -620,7 +619,7 @@ LLM:
# 可在这里找到你的 api_key https://bailian.console.aliyun.com/?apiKey=1#/api-key
base_url: https://dashscope.aliyuncs.com/compatible-mode/v1
model_name: qwen-flash
api_key: 你的阿里云百炼API密钥
api_key: 你的deepseek web key
temperature: 0.7 # 温度值
max_tokens: 500 # 最大生成token数
top_p: 1
@@ -764,10 +763,10 @@ VLLM:
# 先新建应用,在下面的地址
# 开通应用地址:https://console.xfyun.cn/app/myapp
# 有免费额度,但也要开通服务,才能获取api_key
# 每一个模型都需要单独开通,每一个模型的 api_key 都不同,例如 Lite 模型在 https://console.xfyun.cn/services/cbm 开通
# 每一个模型都需要单独开通,每一个模型的api_password都不同,例如Lite模型在https://console.xfyun.cn/services/cbm 开通
base_url: https://ark.cn-beijing.volces.com/api/v3
model_name: lite
api_key: 你的api_key
api_key: 你的api_password
TTS:
# 当前支持的type为edge、doubao,可自行适配
EdgeTTS:
@@ -1118,8 +1117,8 @@ TTS:
#SpeechServerDemo https://github.com/PaddlePaddle/PaddleSpeech/tree/develop/demos/speech_server
#流式传输请参考 https://github.com/PaddlePaddle/PaddleSpeech/wiki/PaddleSpeech-Server-WebSocket-API
type: paddle_speech
protocol: websocket # 当前仅实现 websocket;填其他值会在启动时报错
url: ws://127.0.0.1:8092/paddlespeech/tts/streaming # TTS 服务地址(未配置时代码兜底同此本机地址)
protocol: websocket # protocol choices = ['websocket', 'http']
url: ws://127.0.0.1:8092/paddlespeech/tts/streaming # TTS 服务的 URL 地址,指向本地服务器 [websocket默认ws://127.0.0.1:8092/paddlespeech/tts/streaming,http默认http://127.0.0.1:8090/paddlespeech/tts]
spk_id: 0 # 发音人 ID,0 通常表示默认的发音人
speed: 1.0 # 语速,1.0 表示正常语速,>1 表示加快,<1 表示减慢
volume: 1.0 # 音量,1.0 表示正常音量,>1 表示增大,<1 表示减小
@@ -5,7 +5,6 @@ if TYPE_CHECKING:
from core.connection import ConnectionHandler
from ..base import IntentProviderBase
from plugins_func.functions.play_music import initialize_music_handler
from plugins_func.functions.hass_init import _devices_text, _hass_plugin_config
from config.logger import setup_logging
from core.utils.util import get_system_error_response
import re
@@ -182,11 +181,16 @@ class IntentProvider(IntentProviderBase):
music_file_names = music_config["music_file_names"]
prompt_music = f"{self.promot}\n<musicNames>{music_file_names}\n</musicNames>"
plugin_config = _hass_plugin_config(conn.config.get("plugins", {}))
devices_text = _devices_text(plugin_config.get("devices", ""))
if devices_text:
home_assistant_cfg = conn.config["plugins"].get("home_assistant")
if home_assistant_cfg:
devices = home_assistant_cfg.get("devices", [])
else:
devices = []
if len(devices) > 0:
hass_prompt = "\n下面是我家智能设备列表(位置,设备名,entity_id),可以通过homeassistant控制\n"
prompt_music += hass_prompt + devices_text + "\n"
for device in devices:
hass_prompt += device + "\n"
prompt_music += hass_prompt
logger.bind(tag=TAG).debug(f"User prompt: {prompt_music}")
@@ -1,8 +1,6 @@
"""服务端插件工具执行器"""
import asyncio
import functools
from concurrent.futures import ThreadPoolExecutor
from typing import Dict, Any, TYPE_CHECKING
if TYPE_CHECKING:
@@ -10,10 +8,6 @@ if TYPE_CHECKING:
from ..base import ToolType, ToolDefinition, ToolExecutor
from plugins_func.register import all_function_registry, module_func_map, Action, ActionResponse
# 插件专用线程池:不用 asyncio 默认池(DNS 解析、ASR、initialize_modules 等都在用),
# 避免慢插件打满默认池后拖垮所有协程的 DNS 解析和连接建立。
_PLUGIN_POOL = ThreadPoolExecutor(max_workers=8, thread_name_prefix="plugin")
class ServerPluginExecutor(ToolExecutor):
"""服务端插件工具执行器"""
@@ -34,21 +28,23 @@ class ServerPluginExecutor(ToolExecutor):
try:
# 根据工具类型决定如何调用
func_type = getattr(func_item, "type", None)
if func_type is not None and func_type.code in [3, 4, 5]:
# CHANGE_SYS_PROMPT, SYSTEM_CTL, IOT_CTL (需要conn参数)
call = functools.partial(func_item.func, conn, **arguments)
else: # WAIT 等不传conn(含 type 未指定的插件)
call = functools.partial(func_item.func, **arguments)
if asyncio.iscoroutinefunction(func_item.func):
result = await call()
if hasattr(func_item, "type"):
func_type = func_item.type
if func_type.code in [4, 5]: # SYSTEM_CTL, IOT_CTL (需要conn参数)
result = func_item.func(conn, **arguments)
elif func_type.code == 2: # WAIT
result = func_item.func(**arguments)
elif func_type.code == 3: # CHANGE_SYS_PROMPT
result = func_item.func(conn, **arguments)
else:
result = func_item.func(**arguments)
else:
# 同步函数在 loop 线程上直接执行会卡住整个事件循环,放专用线程池。
result = await asyncio.get_running_loop().run_in_executor(_PLUGIN_POOL, call)
# 兼容同步函数返回协程的写法
if asyncio.iscoroutine(result):
result = await result
# 默认不传conn参数
result = func_item.func(**arguments)
# 兼容 async def 工具函数
if asyncio.iscoroutine(result):
result = await result
return result
@@ -47,12 +47,9 @@ class ToolManager:
return all_tools
def get_function_descriptions(self) -> List[Dict[str, Any]]:
"""获取所有工具的函数描述(OpenAI格式)
返回缓存的浅拷贝,防止调用方就地修改污染缓存。
"""
"""获取所有工具的函数描述(OpenAI格式)"""
if self._cached_function_descriptions is not None:
return list(self._cached_function_descriptions)
return self._cached_function_descriptions
descriptions = []
tools = self.get_all_tools()
@@ -60,7 +57,7 @@ class ToolManager:
descriptions.append(tool_definition.description)
self._cached_function_descriptions = descriptions
return list(descriptions)
return descriptions
def has_tool(self, tool_name: str) -> bool:
"""检查是否存在指定工具"""
@@ -23,11 +23,9 @@ class TTSProvider(TTSProviderBase):
def __init__(self, config, delete_audio_file):
super().__init__(config, delete_audio_file)
self.url = config.get("url", "ws://127.0.0.1:8092/paddlespeech/tts/streaming")
self.url = config.get("url", "ws://192.168.1.10:8092/paddlespeech/tts/streaming")
self.protocol = config.get("protocol", "websocket")
if self.protocol != "websocket":
raise ValueError("Unsupported protocol. Please use 'websocket'.")
if config.get("private_voice"):
self.spk_id = int(config.get("private_voice"))
else:
@@ -84,7 +82,7 @@ class TTSProvider(TTSProviderBase):
if self.protocol == "websocket":
return await self.text_streaming(text, output_file)
else:
raise ValueError("Unsupported protocol. Please use 'websocket'.")
raise ValueError("Unsupported protocol. Please use 'websocket' or 'http'.")
async def text_streaming(self, text, output_file):
try:
+1 -1
View File
@@ -135,7 +135,7 @@ def get_ip_info(ip_addr, logger):
if is_private_ip(ip_addr):
ip_addr = ""
url = f"https://whois.pconline.com.cn/ipJson.jsp?json=true&ip={ip_addr}"
resp = requests.get(url, timeout=5).json()
resp = requests.get(url).json()
ip_info = {"city": resp.get("city")}
# 存入缓存
@@ -268,8 +268,7 @@ async def get_weather(conn: "ConnectionHandler", location: str = None, lang: str
location = cached_ip_info.get("city")
else:
# 缓存未命中,调用API获取
# get_ip_info 是同步 requests 调用,放线程池,避免卡住主事件循环
ip_info = await asyncio.to_thread(get_ip_info, client_ip, logger)
ip_info = get_ip_info(client_ip, logger)
if ip_info:
cache_manager.set(CacheType.IP_INFO, client_ip, ip_info)
location = ip_info.get("city")
@@ -1,53 +1,27 @@
from config.logger import setup_logging
from core.utils.util import check_model_key
from plugins_func.register import module_func_map
TAG = __name__
logger = setup_logging()
def _expand_module_names(funcs):
"""把 functions 列表里的模块名(如 hass_state)展开成具体函数名,与工具加载口径一致。"""
expanded = []
for name in funcs:
expanded.extend(module_func_map.get(name, [name]))
return expanded
def _devices_text(devices) -> str:
"""把 devices 配置转成拼进提示词的文字。
config.yaml 示例和手机端智控台给的是列表,每项一条"位置,设备名,entity_id",这里拼成每行一条;
网页端智控台和 HA 集成文档是一段字符串,原样用;其它(如 None)当空。
"""
if isinstance(devices, (list, tuple)):
return "\n".join(
str(d).strip() for d in devices if d is not None and str(d).strip()
)
return devices if isinstance(devices, str) else ""
def _hass_plugin_config(plugins_config: dict) -> dict:
"""取 HA 插件配置:优先本地 config.yaml 的 home_assistant,其次智控台下发的 hass_state。"""
for key in ("home_assistant", "hass_state"):
cfg = plugins_config.get(key)
if cfg:
return cfg
return {}
def append_devices_to_prompt(conn):
if conn.intent_type == "function_call":
funcs = _expand_module_names(
conn.config["Intent"][conn.config["selected_module"]["Intent"]].get(
"functions", []
)
funcs = conn.config["Intent"][conn.config["selected_module"]["Intent"]].get(
"functions", []
)
# 安全地获取插件配置
plugins_config = conn.config.get("plugins", {})
config_source = (
"home_assistant"
if plugins_config.get("home_assistant")
else "hass_state"
)
if "hass_get_state" in funcs or "hass_set_state" in funcs:
prompt = "\n下面是我家智能设备列表(位置,设备名,entity_id),可以通过homeassistant控制\n"
plugin_config = _hass_plugin_config(conn.config.get("plugins", {}))
deviceStr = _devices_text(plugin_config.get("devices", ""))
deviceStr = plugins_config.get(config_source, {}).get("devices", "")
conn.prompt += prompt + deviceStr + "\n"
# 更新提示词
conn.dialogue.update_system_message(conn.prompt)
@@ -58,10 +32,17 @@ def initialize_hass_handler(conn):
if not conn.load_function_plugin:
return ha_config
plugin_config = _hass_plugin_config(conn.config.get("plugins", {}))
if not plugin_config:
# 安全地获取插件配置
plugins_config = conn.config.get("plugins", {})
# 确定配置来源
config_source = (
"home_assistant" if plugins_config.get("home_assistant") else "hass_state"
)
if not plugins_config.get(config_source):
return ha_config
# 统一获取配置
plugin_config = plugins_config[config_source]
ha_config["base_url"] = plugin_config.get("base_url")
ha_config["api_key"] = plugin_config.get("api_key")
@@ -149,54 +149,6 @@ async def _search_serply(api_key: str, query: str, max_results: int) -> str:
return "\n".join(lines)
async def _search_outcrawl(api_key: str, query: str, max_results: int) -> str:
"""调用Outcrawl搜索API"""
url = "https://api.outcrawl.ai/v1/search"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"User-Agent": "xiaozhi-esp32-server",
}
# Outcrawl单次最多返回20条
payload = {"query": query, "limit": max(1, min(max_results, 20))}
logger.bind(tag=TAG).debug(f"Outcrawl搜索请求 | URL: {url} | payload: {payload}")
async with httpx.AsyncClient(timeout=httpx.Timeout(15.0, connect=3.0)) as client:
response = await client.post(url, json=payload, headers=headers)
data = response.json()
logger.bind(tag=TAG).debug(f"Outcrawl搜索响应 | status: {response.status_code}")
if response.status_code != 200:
# 错误信封为 {"code": "...", "message": "...", "details": {}},
# code 是官方承诺的封闭枚举
code = data.get("code", "") if isinstance(data, dict) else ""
detail = data.get("message", "") if isinstance(data, dict) else ""
logger.bind(tag=TAG).error(
f"Outcrawl搜索失败 | status: {response.status_code} | code: {code} | detail: {detail}"
)
if code == "quota_exceeded":
# 该code覆盖两种情况:每分钟请求数限流,或月度credits用尽
return "联网搜索被限流或额度已用完,请稍后重试或检查Outcrawl账户余额。"
if code == "unauthorized":
return "联网搜索请求失败,请检查API Key是否正确。"
# 其余code(bad_request、no_capacity、search_refused、internal等)
# 多为输入问题或服务端暂时性故障,统一引导重试
return "联网搜索请求失败,请稍后重试。"
hits = data.get("hits", [])
if not hits:
return "未找到相关搜索结果。"
lines = ["【联网搜索结果】"]
for i, item in enumerate(hits[:max_results], 1):
title = item.get("title") or "无标题"
snippet = item.get("snippet", "")
lines.append(f"{i}. 标题:{title}")
if snippet:
lines.append(f" 摘要:{snippet}")
return "\n".join(lines)
@register_function("web_search", WEB_SEARCH_FUNCTION_DESC, ToolType.SYSTEM_CTL)
async def web_search(conn: "ConnectionHandler", query: str = None):
logger.bind(tag=TAG).info(f"web_search 被调用 | query={query}")
@@ -223,8 +175,6 @@ async def web_search(conn: "ConnectionHandler", query: str = None):
result_text = await _search_tavily(api_key, query, max_results)
elif provider == "serply":
result_text = await _search_serply(api_key, query, max_results)
elif provider == "outcrawl":
result_text = await _search_outcrawl(api_key, query, max_results)
else:
return ActionResponse(
Action.REQLLM,
+1 -5
View File
@@ -83,11 +83,7 @@ module_func_map = {}
def register_function(name, desc, type=None):
"""注册函数到函数注册字典的装饰器
同步函数在工作线程中执行,函数体内不可调用 asyncio 事件循环 API;
需要异步(httpx、await)请定义为 async def。
"""
"""注册函数到函数注册字典的装饰器"""
def decorator(func):
all_function_registry[name] = FunctionItem(name, desc, func, type)
+25 -34
View File
@@ -47,29 +47,10 @@ if str(_PROJECT_ROOT) not in sys.path:
# ---------------------------------------------------------------------------
try:
import opuslib_next # noqa: F401
except Exception as _opus_import_error:
# opuslib_next 是 ctypes 绑定,pip 装上的是纯 Python 包,真正的编解码靠系统里的
# libopus.so。runner 上没装 libopus 时 import 会失败,这里换桩顶上。
# 桩只能撑住 import 链路,真要编解码得先把 libopus 装上
# (apt-get install libopus0,正式镜像 Dockerfile-server-base 里也是这么装的)。
print(
f"[conftest] opuslib_next 不可用,改用桩:{_opus_import_error}",
flush=True,
)
def _opus_unavailable(_self, *_args, **_kwargs):
raise RuntimeError(
"libopus 没装,Opus 编解码跑不了。"
"装法:apt-get install libopus0(正式镜像 Dockerfile-server-base 里也是这么装的)"
)
except Exception:
_stub = types.ModuleType("opuslib_next")
_stub.Encoder = type(
"Encoder", (), {"__init__": lambda *a, **kw: None, "encode": _opus_unavailable}
)
_stub.Decoder = type(
"Decoder", (), {"__init__": lambda *a, **kw: None, "decode": _opus_unavailable}
)
_stub.Encoder = type("Encoder", (), {"__init__": lambda *a, **kw: None})
_stub.Decoder = type("Decoder", (), {"__init__": lambda *a, **kw: None})
_stub.APPLICATION_AUDIO = 0
_stub.OpusError = type("OpusError", (Exception,), {})
_stub.constants = types.SimpleNamespace()
@@ -342,25 +323,35 @@ def _install_mock_layer() -> None:
asyncio.sleep = _fast_sleep # type: ignore[assignment]
# 网络层 mock:如果真实 httpx 已加载,把它的 send 替换成
# 网络层 mock:如果真实 httpx 已加载,把它的 AsyncClient.send 替换成
# 立即抛 ConnectError 的桩,触发 ``_should_retry`` 走到 ``raise``,
# 但避免真发请求。Live 模式完全不安装这层。
if not LIVE_API_TESTS:
try:
import httpx as _httpx
import httpx as _httpx # noqa: F401
async def _fake_async_send(self, request, **kwargs):
raise _httpx.ConnectError("MOCK: connect refused")
class _FakeAsyncClient:
def __init__(self, *args, **kwargs):
pass
def _fake_send(self, request, **kwargs):
raise _httpx.ConnectError("MOCK: connect refused")
async def send(self, *args, **kwargs):
raise _httpx.ConnectError("MOCK: connect refused")
# 只换 send 这一个方法,不换整个类。有些库(openai 2.x 的
# SyncHttpxClientWrapper)继承 httpx.Client 再自己拼请求,
# 整个类被换掉的话它们继承到的是假类,拼请求时直接
# AttributeError,报错还完全看不出是 mock 层的问题
_httpx.AsyncClient.send = _fake_async_send # type: ignore[assignment]
_httpx.Client.send = _fake_send # type: ignore[assignment]
async def aclose(self):
return None
class _FakeClient:
def __init__(self, *args, **kwargs):
pass
def send(self, *args, **kwargs):
raise _httpx.ConnectError("MOCK: connect refused")
def close(self):
return None
_httpx.AsyncClient = _FakeAsyncClient # type: ignore[misc]
_httpx.Client = _FakeClient # type: ignore[misc]
except ImportError:
pass
Whitespace-only changes.
@@ -1,134 +0,0 @@
"""服务端插件执行器:同步插件函数在线程池里跑,不卡事件循环;async 插件照常 await;异常变成 ERROR 响应。"""
from __future__ import annotations
import asyncio
import time
from types import SimpleNamespace
from core.providers.tools.server_plugins.plugin_executor import ServerPluginExecutor
from plugins_func.register import Action, ActionResponse, ToolType, all_function_registry
def _register(name, func, tool_type):
item = SimpleNamespace(name=name, func=func, type=tool_type, description={})
all_function_registry[name] = item
return item
def test_sync_plugin_runs_off_loop_and_async_plugin_is_awaited():
conn = SimpleNamespace(config={})
async def paced_sleep(delay):
# 不能用 asyncio.sleep 计时:conftest 在 mock 模式会把它换成不让出循环的桩,
# 这里放到线程池里睡,保证 ticker 每拍都真正让出事件循环。
await asyncio.get_running_loop().run_in_executor(None, time.sleep, delay)
def slow_sync(conn_arg, entity_id):
time.sleep(0.3) # 模拟阻塞 IO
return ActionResponse(Action.RESPONSE, None, f"{entity_id} 已打开")
async def fast_async(question):
await asyncio.sleep(0)
return ActionResponse(Action.REQLLM, f"答:{question}", None)
def boom(conn_arg):
raise RuntimeError("连不上")
_register("t_slow_sync", slow_sync, ToolType.SYSTEM_CTL)
_register("t_fast_async", fast_async, ToolType.WAIT)
_register("t_boom", boom, ToolType.SYSTEM_CTL)
try:
async def go():
ex = ServerPluginExecutor(conn)
ticks = []
async def ticker():
while True:
ticks.append(time.monotonic())
await paced_sleep(0.02)
t = asyncio.create_task(ticker())
r = await ex.execute(conn, "t_slow_sync", {"entity_id": "light.living"})
t.cancel()
assert r.action == Action.RESPONSE and r.response == "light.living 已打开"
assert len(ticks) >= 5 # 300 ms 里事件循环一直在转(没被 time.sleep 卡住)
r2 = await ex.execute(conn, "t_fast_async", {"question": "几点"})
assert r2.action == Action.REQLLM and r2.result == "答:几点"
r3 = await ex.execute(conn, "t_boom", {})
assert r3.action == Action.ERROR and "连不上" in r3.response
r4 = await ex.execute(conn, "t_missing", {})
assert r4.action == Action.NOTFOUND
asyncio.run(go())
finally:
for n in ("t_slow_sync", "t_fast_async", "t_boom"):
all_function_registry.pop(n, None)
def test_conn_passing_rules_and_compat_branches():
"""code 3/4/5 传 conn;WAIT/type=None 不传;同步函数返回协程会被兜底 await。"""
conn = SimpleNamespace(config={})
seen = {}
def prompt_fn(conn_arg):
seen["prompt"] = conn_arg
return ActionResponse(Action.RESPONSE, None, "切角色")
def wait_fn():
seen["wait"] = True
return ActionResponse(Action.RESPONSE, None, "等")
def no_type_fn():
seen["no_type"] = True
return ActionResponse(Action.RESPONSE, None, "无类型")
def sync_returns_coro(conn_arg):
seen["coro"] = conn_arg
async def inner():
return ActionResponse(Action.RESPONSE, None, "协程兜底")
return inner()
_register("t_prompt", prompt_fn, ToolType.CHANGE_SYS_PROMPT)
_register("t_wait", wait_fn, ToolType.WAIT)
_register("t_no_type", no_type_fn, None)
_register("t_sync_coro", sync_returns_coro, ToolType.SYSTEM_CTL)
try:
async def go():
ex = ServerPluginExecutor(conn)
r1 = await ex.execute(conn, "t_prompt", {})
assert seen["prompt"] is conn and r1.response == "切角色"
r2 = await ex.execute(conn, "t_wait", {})
assert "wait" in seen and r2.response == "等"
r3 = await ex.execute(conn, "t_no_type", {})
assert "no_type" in seen and r3.response == "无类型"
r4 = await ex.execute(conn, "t_sync_coro", {})
assert seen["coro"] is conn and r4.response == "协程兜底"
asyncio.run(go())
finally:
for n in ("t_prompt", "t_wait", "t_no_type", "t_sync_coro"):
all_function_registry.pop(n, None)
def test_sync_plugin_uses_dedicated_pool():
"""同步插件必须跑在专用池上,不能用 asyncio 默认池(DNS 解析等共用默认池)。"""
import threading
conn = SimpleNamespace(config={})
seen = {}
def sync_fn():
seen["thread"] = threading.current_thread().name
return ActionResponse(Action.RESPONSE, None, "ok")
_register("t_pool", sync_fn, ToolType.WAIT)
try:
async def go():
ex = ServerPluginExecutor(conn)
r = await ex.execute(conn, "t_pool", {})
assert r.response == "ok"
asyncio.run(go())
assert seen["thread"].startswith("plugin"), seen["thread"] # 专用池线程名前缀
finally:
all_function_registry.pop("t_pool", None)
@@ -1,84 +0,0 @@
"""append_devices_to_prompt:devices 写成列表或一段字符串都能拼进提示词。
config.yaml 示例和手机端智控台给的是列表,以前会报
can only concatenate str (not "list") to str,设备清单进不了提示词,模型只能猜 entity_id。
"""
import pytest
import plugins_func.functions.hass_state # noqa: F401 注册 hass_state 模块,填充 module_func_map
from plugins_func.functions.hass_init import append_devices_to_prompt
HEADER = "\n下面是我家智能设备列表(位置,设备名,entity_id),可以通过homeassistant控制\n"
BASE_PROMPT = "你是小智。"
class FakeDialogue:
def __init__(self):
self.system_messages = []
def update_system_message(self, prompt):
self.system_messages.append(prompt)
class FakeConn:
def __init__(self, plugin, source="home_assistant", functions=("hass_get_state", "hass_set_state")):
self.intent_type = "function_call"
self.config = {
"selected_module": {"Intent": "function_call"},
"Intent": {"function_call": {"functions": list(functions)}},
"plugins": {source: plugin},
}
self.prompt = BASE_PROMPT
self.dialogue = FakeDialogue()
def _plugin(**extra):
return {"base_url": "http://homeassistant.local:8123", "api_key": "test-token", **extra}
def test_list_devices_one_per_line():
conn = FakeConn(_plugin(devices=[
"客厅,玩具灯,switch.toy_lamp",
" 卧室,台灯,switch.desk_lamp ",
"",
]))
append_devices_to_prompt(conn)
expected = BASE_PROMPT + HEADER + "客厅,玩具灯,switch.toy_lamp\n卧室,台灯,switch.desk_lamp\n"
assert conn.prompt == expected
assert conn.dialogue.system_messages == [expected]
def test_string_devices_kept_as_is():
# 网页端智控台和 HA 集成文档给的是一段字符串
devices = "客厅,玩具灯,switch.toy_lamp;\n卧室,台灯,switch.desk_lamp;"
conn = FakeConn(_plugin(devices=devices))
append_devices_to_prompt(conn)
assert conn.prompt == BASE_PROMPT + HEADER + devices + "\n"
def test_mobile_console_hass_state_list():
# 智控台下发的插件配置键是 hass_state;手机端把 array 字段按行切成了列表
conn = FakeConn(_plugin(devices=["客厅,玩具灯,switch.toy_lamp"]), source="hass_state")
append_devices_to_prompt(conn)
assert conn.prompt.endswith(HEADER + "客厅,玩具灯,switch.toy_lamp\n")
def test_module_name_in_functions():
# functions 里写模块名 hass_state(config.yaml 注释的写法),展开后应能匹配
conn = FakeConn(_plugin(devices=["客厅,玩具灯,switch.toy_lamp"]), functions=("hass_state",))
append_devices_to_prompt(conn)
assert conn.prompt.endswith(HEADER + "客厅,玩具灯,switch.toy_lamp\n")
@pytest.mark.parametrize("plugin", [_plugin(), _plugin(devices=None), _plugin(devices=[])])
def test_missing_or_empty_devices_does_not_raise(plugin):
conn = FakeConn(plugin)
append_devices_to_prompt(conn)
assert conn.prompt == BASE_PROMPT + HEADER + "\n"
def test_without_hass_functions_prompt_unchanged():
conn = FakeConn(_plugin(devices=["客厅,玩具灯,switch.toy_lamp"]), functions=("get_weather",))
append_devices_to_prompt(conn)
assert conn.prompt == BASE_PROMPT
assert conn.dialogue.system_messages == []
@@ -1,122 +0,0 @@
"""Tests for the Outcrawl source of the web_search plugin.
The HTTP layer is replaced with a fake client and every request is recorded.
Nothing here touches the network.
"""
import httpx
import pytest
from plugins_func.functions import web_search as web_search_module
from plugins_func.register import Action
class FakeResponse:
def __init__(self, status_code=200, payload=None):
self.status_code = status_code
self.payload = payload
def json(self):
return self.payload
class FakeAsyncClient:
"""Stands in for httpx.AsyncClient and records what was requested."""
requests = []
response = None
def __init__(self, *args, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *exc_info):
return False
async def post(self, url, json=None, headers=None):
FakeAsyncClient.requests.append({"url": url, "json": json, "headers": headers})
return FakeAsyncClient.response
@pytest.fixture
def fake_http(monkeypatch):
"""Replace httpx.AsyncClient with the recording fake."""
FakeAsyncClient.requests = []
FakeAsyncClient.response = FakeResponse(payload={"hits": []})
monkeypatch.setattr(httpx, "AsyncClient", FakeAsyncClient)
return FakeAsyncClient
class FakeConn:
def __init__(self, web_search_config):
self.config = {"plugins": {"web_search": web_search_config}}
async def test_outcrawl_sends_bearer_key_and_limit(fake_http):
await web_search_module._search_outcrawl("oc-key", "上海天气", 5)
[request] = fake_http.requests
assert request["url"] == "https://api.outcrawl.ai/v1/search"
assert request["json"] == {"query": "上海天气", "limit": 5}
assert request["headers"]["Authorization"] == "Bearer oc-key"
assert request["headers"]["User-Agent"] == "xiaozhi-esp32-server"
@pytest.mark.parametrize(("max_results", "limit"), [(0, 1), (50, 20)])
async def test_outcrawl_clamps_limit(fake_http, max_results, limit):
await web_search_module._search_outcrawl("oc-key", "q", max_results)
assert fake_http.requests[0]["json"]["limit"] == limit
async def test_outcrawl_formats_titles_and_snippets_without_urls(fake_http):
fake_http.response = FakeResponse(
payload={
"hits": [
{"url": "https://a.example/1", "title": "标题一", "snippet": "摘要一", "rank": 1},
{"url": "https://a.example/2", "title": None, "snippet": "", "rank": 2},
{"url": "https://a.example/3", "title": "标题三", "snippet": "摘要三", "rank": 3},
],
"usage": {"credits": "1.5"},
}
)
result = await web_search_module._search_outcrawl("oc-key", "q", 2)
assert result == "【联网搜索结果】\n1. 标题:标题一\n 摘要:摘要一\n2. 标题:无标题"
assert "https://" not in result
async def test_outcrawl_empty_hits(fake_http):
fake_http.response = FakeResponse(payload={"hits": []})
assert await web_search_module._search_outcrawl("oc-key", "q", 3) == "未找到相关搜索结果。"
@pytest.mark.parametrize(
("code", "message"),
[
("unauthorized", "联网搜索请求失败,请检查API Key是否正确。"),
("quota_exceeded", "联网搜索被限流或额度已用完,请稍后重试或检查Outcrawl账户余额。"),
("bad_request", "联网搜索请求失败,请稍后重试。"),
("internal", "联网搜索请求失败,请稍后重试。"),
],
)
async def test_outcrawl_error_code(fake_http, code, message):
fake_http.response = FakeResponse(
status_code=429, payload={"code": code, "message": "denied", "details": {}}
)
assert await web_search_module._search_outcrawl("oc-key", "q", 3) == message
async def test_web_search_dispatches_to_outcrawl(fake_http):
fake_http.response = FakeResponse(payload={"hits": [{"url": "https://a.example", "title": "T", "snippet": "S"}]})
conn = FakeConn({"provider": "Outcrawl", "api_key": "oc-key", "max_results": 3})
response = await web_search_module.web_search(conn, "q")
assert response.action == Action.REQLLM
assert response.result == "【联网搜索结果】\n1. 标题:T\n 摘要:S"
assert fake_http.requests[0]["json"] == {"query": "q", "limit": 3}
-7
View File
@@ -65,13 +65,6 @@ def _build_provider():
ptype = get_provider_type("LLM", name)
if not ptype:
pytest.skip(f"LLM '{name}' 的 type 字段缺失")
if not LIVE_API_TESTS:
# mock 模式把网络拦了,这条链路拿不到真实回复,跑下来只是一次连接失败。
# 真要验这条链路,用 RUN_LIVE_API_TESTS=1 配真 key 跑
pytest.skip(
f"mock 模式不发真请求,跳过 LLM '{name}' 的链路验证。"
"真请求模式:export RUN_LIVE_API_TESTS=1 并填真 key 后再跑。"
)
from core.utils.llm import create_instance
return create_instance(ptype, cfg)