Compare commits

...
162 Commits
Author SHA1 Message Date
Sakura-RanChen 3bf619e2dd 调整相关逻辑 2026-10-28 11:45:13 +08:00
Sakura-RanChen 1120be2f60 增加控制台配置,调整部分逻辑 2026-10-26 11:40:00 +08:00
9880ce998f fix: 把已识别说话人附到 MCP 接入点 tools/call (#3443)
声纹接口返回的 speaker_id 此前只换成展示名交给 LLM,接入点转发工具参数时不会带上说话人。识别成功后把 speakerId / speakerName 写入 tools/call 的 params._meta,工具参数保持原样;可用 mcp_forward_speaker 关闭。

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: shinjiyu <shinjiyu@users.noreply.github.com>
2026-10-26 10:04:51 +08: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
hrz 8f2dda09cf Bump to 0.9.8 (#3469) 2026-10-23 18:54:05 +08:00
hrz d4ff9c5c17 修掉通讯录的两处越权(读 + 写),并补上三处空指针 (#3460)
* 读取设备通讯录先验设备归属

登录用户传任意 MAC 就能读到别人设备通讯录里的备注和称呼。归属判断收进一个方法,读取通讯录、改别名、改权限都走它,不属于当前用户的设备一律按查不到处理。

* 通讯录存别名不再空指针

对方设备没登记时取备注会直接崩,整条权限授予请求跟着挂掉。取不到就退回尾号称呼,呼叫时主叫没登记也不再崩,顺手删掉一行调试打印。

* 解绑先验归属,通讯录别名不再落空

解绑接口原来不核对设备归属,谁知道对方的设备 id 就能把那台设备的通讯录清空,设备本身倒是因为带了用户条件没删掉。现在先看归属,不是自己的直接不动。

设备登记了但用户没起名时,别名会落成空,缓存刷新把整条跳过,对方再也呼不过来。改成和呼叫那处一样,两级判空都取不到才退回尾号称呼。顺带加了一条回填,把写进去的空值补上。

呼叫那条测试原来会真发请求也没验到东西,改成桩掉网关直接看发出去的称呼。

* 通讯录空别名兜底,撞名加后缀

缓存拼表之前,空别名先用尾号称呼顶上,不再整条跳过,用户没起名的设备也能被呼到。

昵称在同一个人的通讯录里是索引,两台撞名时后写的会盖掉先写的,按昵称呼会呼错设备。加后缀区分,规则和存表时那套一致。取名靠先后,而查询不带排序,所以先按地址排一遍,同样的数据每次刷新结果一样。

数据库没有排序保证,去重全靠顺序,所以这里先排再拼。迁移脚本不用动,运行时的兜底能管住它没覆盖到的行。

* 通讯录取名两条路径对上了

后缀原来只写进缓存,接口直查库返回原值,同一份数据给的是两个名字。
用户照控制台看到的名字呼叫,命中的不是同一台设备。

把取名抽成一个方法,两边都走一遍。顺带把权限位排到地址前面,
撞名时能呼的那台先拿原名,反过来用户怎么点都只会说没权限。

迁移取末两位时改成跟着小写化,和运行期算出的是同一个称呼。

* 同一个人被拆成两本通讯录,一台设备消失

两条记录的归属地址只差大小写时,会被算成两个人,各拿一个同名,
写进同一本时后者盖掉前者,一台设备就从缓存里消失了。

把归属地址归一抽成一个方法,算称呼、写缓存、查缓存都走它。

* 通讯录接口按最近改过的排前,这个约定有用例钉住

智控台页面是按对方地址把备注合进去的,接口返回顺序它并不关心,
所以现在这么排对界面没有效果。

用例留着是防它被当无用代码删掉,删了会有用例报出来。

* 迁移串到别人的私名,撞车的组改走尾号称呼

通讯录回填名字时,设备表里同一个 MAC 有两行(同一台被两个用户各登记一次),
MySQL 自己挑一行填进去,跟这行属于谁无关。改成只有一台时才取智控台名字,
多台就退回按 MAC 末两位算的称呼。名字照样填上,缓存照样刷新,对方还是呼得到。

补了 12 条在真库上跑的用例,另配一条排查重复 MAC 的语句。

* 去掉走不到的分支和没人用的桩,补上末尾换行

getUser() 取不到 subject 或 principal 时返回的是空对象,不是 null,
user == null 走不到,删掉;用例改成桩空对象,测真正的匿名那条路。
归属判断挪进 service 之后,controller 层四处 SecurityUser 的桩没人用了,删掉。
四个文件末尾少了换行,补上。另加一条用例钉住判重分组的比较规则:
只折叠大小写,不折叠分隔符。

* 移除回填通讯录别名的迁移,运行时已经兜住了

这个迁移是本 PR 带进来的。它要把空别名刷成称呼,可运行时的兜底已经做了一样的事,
而且覆盖得更宽:那边连纯空格、地址为空和不足两位的都管。删掉它,功能不受影响。

删它有两条理由。一是重复。二是它按 MAC 去设备表取名字,
而一个 MAC 在设备表里可能有多行——同一台设备被两个用户各登记一次就能造出来,
这时候取到的是别人给自己设备起的名字。写在这里,是怕后来的人以为这个坑不存在。

* 归属校验撞到重复 MAC 不再报错

库里同一 MAC 可能有两行只差大小写,selectOne 会抛 TooManyResultsException,把读通讯录接口整个带崩。改成 selectList 按 id 取第一条做归属校验,撞行时正常返回,不再报错。
2026-10-23 17:43:05 +08:00
wengzh 3709540efd Merge pull request #3445 from xinnan-tech/fix/huoshan-double-stream-4-bugs-y15
huoshan_double_stream 修 4 个会话协议与监听竞态 bug
2026-10-23 17:37:05 +08:00
wengzh 333050ae2e Merge pull request #3464 from xinnan-tech/chore/bump-deps-20261023
chore(deps): 批量升级 15 个依赖,适配 mcp 2.x
2026-10-23 17:05:05 +08:00
Sakura-RanChen 95ce49ffc9 删除 benchmark/ 下 3438~3441 回归验证脚本
四处修复均已验证收敛,脚本不进 CI、裸跑依赖 manager-api,保留仅为仓库噪声;后续若再动 huoshan_double_stream 时以 pytest 覆盖
2026-10-23 17:04:52 +08:00
Sakura-RanChen be75e7bd39 chore(deps): 批量升级 15 个依赖,适配 mcp 2.x
源 PR: #3426 #3427 #3428 #3429

剔除 4 项:
- edge-tts 7.3.1->7.2.8 是降级
- powermem >=1.1.7 要求 Python>=3.11
- openai 3.29.0 与 powermem 冲突(powermem 要求 openai<3.0.0)
- ruamel.yaml 0.19.1 与 hyperpyyaml 冲突

mcp 2.x 适配: streamablehttp_client 更名为 streamable_http_client,headers/timeout
改由 http_client 承载,产出从 3 元组变 2 元组,ProgressFnT 移到 mcp.shared.dispatcher
2026-10-23 15:15:50 +08:00
hrz 24fbf7eee2 登录 token 12h 静默失效补 silent refresh 接口 #3392 (#3402)
* 登录 token 12h 静默失效补 silent refresh 接口 #3392

方案 A:后端 POST /xiaozhi/user/refresh(anon),旧 token 校验通过后强制覆盖为新 token 字符串,旧 token 在 DB 查不到即失效。前端 httpRequest 在 sendRequest 入口检查 token 剩余有效期,剩 1h 内触发 silent refresh(独立 fly 实例 + 单飞锁),成功后 setToken 替换并自动继续发送当前请求,失败走 clearAuth 跳登录。

* refreshToken 改 CAS 防并发 race + 拆前端副作用 #3392

P1:SysUserTokenDao 新增 refreshTokenCAS,条件 UPDATE(oldToken + 未过期 + userId),受影响 0 行抛 TOKEN_INVALID;refreshToken 改调它,避免读-改-写 race。P2:refreshToken 加 @Transactional;httpRequest.getTokenExpireAt 内的 localStorage.setItem 挪到独立 persistTokenExpireAt,由 shouldRefresh 入口调用。

* getTokenExpireAt 改读 localStorage 与持久化同源 #3392

pr01 三改:原代码 getTokenExpireAt 读 store(state.token 缓存),与 persistTokenExpireAt 写 localStorage 不同源,导致 _localStoredAt 缺失 → NaN → shouldRefresh 永 false → silent refresh 永不触发。修复:getTokenExpireAt 改读 localStorage,persistTokenExpireAt 同步刷 store,保证两端一致。e2e 已验证:注入 expire=1800 token → reload → role-config mount 触发 sendRequest → Network 抓到 POST /user/refresh 200 → localStorage token 被替换为新 12h token(含 _localStoredAt)。

* 应用启动同步 localStorage token 到 store #3392

PR #3402 扩展:守卫放行后首次 API 请求时,vuex state.token 还是 null,httpRequest 读 store 取不到 Authorization 头,会被后端 401 → clearAuth 跳登录。在 main.js 应用启动阶段就把 localStorage 里的 token commit 进 store,避免 race。不动 refresh 链路与其它业务文件。
2026-10-23 09:47:41 +08:00
monsterlag 855a64cbb9 fix(manager-web): keep model navigation scrollable on short screens 2026-10-22 04:06:52 +00:00
hrzandSakura-RanChen fdf280f84a 模板 GET 入口迁回 AgentTemplateController #3395 (#3401)
* 模板 GET 入口迁回 AgentTemplateController #3395

根因:AgentController 已存在 GET /agent/template/templateList,AgentTemplateController 缺显式 GET,于是路由虽通却跨 controller 暴露模板数据,且职责错位。修复:删 AgentController.templateList,同名 @GetMapping(无 path)落到 AgentTemplateController,权限 normal、orderByAsc sort、复用现有 service.list。验证:spring-boot 启动期无 Ambiguous;/agent/template、/page、/{id} 三路由均被 Shiro 一致拦截,权限/路径正确。

* 补 P2 注释说明 normal 权限反直觉是有意保留 #3395

在 getAgentTemplates 上方加 2 行注释,说明单独 normal 权限是为保持原行为契约(普通用户也能查可用模板),与类级 superAdmin 故意分离;非疏忽。无逻辑变更。

* 去除未使用引用

---------

Co-authored-by: Sakura-RanChen <1908198662@qq.com>
2026-10-22 11:42:48 +08:00
wengzh 498191519c Merge pull request #3452 from xinnan-tech/py-test-wakeup
Py test wakeup
2026-10-22 11:08:38 +08:00
wengzh f614489d81 Merge pull request #3454 from xinnan-tech/web-test-json
Web test json
2026-10-20 15:44:16 +08:00
Sakura-RanChen 6f958ee4a6 fix: 新增模型页面dict字段错误解析 2026-10-20 15:34:40 +08:00
TXyy2023 b226ee5430 fix(manager-web): decode stored dictionaries before editing (#3450) 2026-10-20 11:53:02 +08:00
Sakura-RanChen 9c30b5e035 文件直接写入wav,无需经过二次编解码 2026-10-20 10:45:31 +08:00
TXyy2023 d531d04281 fix(tts): handle retained audio files in wakeup cache (#3451) 2026-10-20 10:00:20 +08:00
Sakura-RanChen d8f8a4a083 - 取消屏障保证:新连接创建前 _cancel_monitor_task 已等旧监听任务完全退出,
旧任务不可能读新连接或误关新连接(check_huoshan_3440 动态断言覆盖)
- mismatch 分支仅保留纯跳过,删除旧会话结束事件的复位遗留,
  activate_session 写入点收敛到会话生命周期(start / matched 结束 / close)
- 删除 _session_start_future 启动确认机制:start_session 发送即返回,
  SessionFailed 由 monitor 记 error 并复位 activate_session,无挂起风险
- 回归脚本同步调整:3438 重写为新行为断言,3440 用屏障断言替换 snapshot 断言
2026-10-19 17:52:25 +08: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
hrz 6840210ea8 check_huoshan_3439 改 FIRST 分支行为断言
原 test 用正则 grep 源码验存在性,pr02 复评指出这样测不到实际调用。
改成起后台线程跑真 tts_text_priority_thread、构造 FIRST 消息入队、
run_coroutine_threadsafe patch 成立即完成的 future,最后断言
reset_stream_state 与 opus_encoder.reset_state 各被调一次。
2026-10-17 07:57:30 +08:00
hrz 0a54124d0d huoshan_double_stream 修 3 处复评反馈
init 漏写 _pending_prefix 字段、start_session 用已弃用的
get_event_loop、finish_session 留着多余的 getattr 兜底。补字段、
换 get_running_loop、去兜底,3 处都修了。
2026-10-17 07:57:26 +08:00
hrz ddc441bc59 补 huoshan_double_stream 4 bug 复现脚本
四个独立脚本各覆盖一个 issue:start_session 等 future 确认、句尾残留与编码器
冲刷、monitor snapshot 隔离、parser_response 四处协议解析。共享 _huoshan_mock
构造一个绕开父类 init 的假 provider 实例。
2026-10-17 07:39:51 +08:00
hrz 9b7fb3e826 parser_response 四处二进制协议解析修正
serialization_method 错用掩码当移位数恒 0,改成 >>4;message_type 写
== FULL or AUDIO_ONLY_RESPONSE 优先级下恒真,改 in (...);offset 硬编码 4 在
header_size>1 全错位,改成 header_size * 4;SessionCanceled 漏进协议分支被
else 误读,补进统一解析。
2026-10-17 07:39:51 +08:00
hrz a6cdbf9410 监听任务用 snapshot 隔离跨连接
旧 monitor 循环直接 self.ws.recv(),连接重建后旧任务读新下行;finally 无条件
关 self.ws 误关新连接。改成入口绑 monitor_ws / monitor_task,recv 用 snapshot;
旧 session 事件 continue 不再 reset 当前;finally 判 id 相同才关 ws / 清引用。
2026-10-17 07:39:51 +08:00
hrz 75fd5d8cc3 句尾替换词残留先发 + 会话结束冲刷编码器
旧 finish_session 不发 _pending_prefix 残留,句尾替换词被扣时整段丢;monitor / to_tts
收 SessionFinished 不调 is_end=True 冲刷 opus 编码器,每句固定丢尾。改成
finish_session 开头先发残留,monitor / to_tts 调 is_end=True 冲刷,FIRST 同步重置。
2026-10-17 07:39:51 +08:00
hrz 6fe5630346 TTS 会话等 SessionStarted 确认
旧逻辑 start_session 发完 StartSession 即返回;服务端鉴权失败或 14s 内 26005 时
文本线程继续给死会话发文本,整轮无声。改成末尾 await 一个 future:monitor 读到
SessionStarted set True、读到 SessionFailed set False,超时报错让文本线程感知。
2026-10-17 07:39:50 +08:00
hrz eb9dc8ca0f 把 pydub 探针绑到当前解释器同目录的完整版 ffmpeg (#3444)
edge_tts 升 7.3.1;edge.py 撤 WSS_URL patch 改默认
(4.36 跑默认 URL 稳定)。util.py 加 PATH 临时注入,
绕开 Mac 上 Trae CN 注入的精简版 ffmpeg/ffprobe,
让 pydub 拿 conda env 完整版。引用计数加锁防多线
程串。
2026-10-15 15:43:39 +08:00
wengzh 27399a2fb6 Merge pull request #3430 from xinnan-tech/py-text-test
Py text test
2026-10-14 14:11:53 +08:00
wengzh 7c43ecfcb5 Merge pull request #3434 from xinnan-tech/py-test-asr
fix: SenseVoice 日语识别结果按词带空格 (#3412)
2026-10-14 11:54:39 +08:00
korexil 0be634c627 fix: SenseVoice 日语识别结果按词带空格 (#3412)
SenseVoice 识别日语时偶尔按词输出空格(如「今日 の こと を 少し 長め に」),
这些空格会原样进入对话历史并交给 LLM。lang_tag_filter 现在去掉两个
中日文字符之间的空格,韩文、英文的词间空格保持不变。
2026-10-14 10:32:56 +08:00
Sakura-RanChen 932962e8d3 补充注释说明,调整测试样例 2026-10-14 10:10:28 +08:00
wengzh 60c2a7ee17 Merge pull request #3431 from xinnan-tech/py-content-text
Py content text
2026-10-13 17:13:42 +08:00
Sakura-RanChen a01f0aa29b 去除没必要的测试,上游为str不需要防御性增加 2026-10-13 16:54:42 +08:00
korexil 4445aa7510 fix: FunASR 识别结果以 JSON 原样进入对话历史 (#3411)
FunASR 返回 {"content","language","emotion"} 时,startToChat 只在含 speaker
字段时才解包,否则整段 JSON 字符串会作为用户消息交给 LLM 并写入对话历史。
现在没有 speaker 时只取 content。
2026-10-13 16:44:13 +08:00
wengzh dc86a780a2 Merge pull request #3418 from xinnan-tech/py-test
Py test
2026-10-13 15:47:30 +08:00
Sakura-RanChen a5e47f2c4e 补充字符,调整字符判断 2026-10-13 15:29:28 +08:00
Sakura-RanChen 2057b99aeb 迁移测试用例,优化相关注释 2026-10-13 14:31:21 +08:00
wengzh 7957817a72 Merge pull request #3425 from xinnan-tech/chore/dependabot-monthly-groups
Dependabot 改月度更新并按用途分组
2026-10-13 11:41:15 +08:00
hrz a6eeb107f4 fix(deps): 补 dependabot ignore portalocker
requirements.txt 把 portalocker 锁在 3.x,4.x 与 qdrant-client<1.20 的
portalocker<4.0 冲突 (PR #3360)。Dependabot 默认推 4.x 升级会破约束。
依 openrz 评审 P1 在 ignore 列表追加 portalocker 一项。
2026-10-13 11:28:37 +08:00
wengzh 847337cabb Merge pull request #3419 from xinnan-tech/fix/3391-route-guard-whiteList
fix(router): 路由守卫改 anon 白名单反向匹配
2026-10-13 11:15:15 +08:00
Sakura-RanChen b8f23ffa66 调整相关代码 2026-10-13 10:40:47 +08:00
lww9029 dc581d7185 fix: 修复登录状态下还能访问白名单路由问题 2026-10-13 10:36:25 +08:00
hrz 741ec299c0 fix(tts): edge-tts 失声改走 msedgeservices (#3420)
* fix(tts): edge-tts 失声改走 msedgeservices

微软 2026-10 起对 legacy 消费者接口返回 403,
edge-tts 7.2.8 仍走 speech.platform.bing.com 走不通。
上游 PR rany2/edge-tts#491 切到 api.msedgeservices.com 修好,
但 7.2.8 仍是 PyPI 最新版本。

仓库在 edge.py 顶部临时把 WSS_URL 顶住,等 7.2.9 出了删掉。

* fix(tts): patch 还要写 communicate.WSS_URL

edge-tts 7.2.8 仍指 legacy bing 接口,10 月起微软 403。
上游 PR #491 已切到 msedgeservices.com,7.2.9 才进 PyPI,先顶住。
上次只改 constants.WSS_URL 不够,communicate 用 from import 是模块局部名,
LEGB 找不到新值。这次同时改 communicate.WSS_URL 才生效。
2026-10-13 09:58:30 +08:00
hrz 505925fb21 Dependabot 改月度更新并按用途分组
原本每周跑一次会刷很多零碎 PR,看着烦。改成每月一次,并把第三方 AI 服务、本地模型、网络栈拆成几组聚一起,每组出一个 PR 就行。被锁住的 torch/numpy/websockets 几个直接忽略掉,免得它推升级 PR 进来。
2026-10-13 09:34:02 +08:00
hrz 4aa63baf75 fix(router): 路由守卫改 anon 白名单反向匹配
业务路由越加越多,原来硬编码的 8 项守卫名单跟不上了,
未登录直接进十来个业务页看到骨架再被踢,体感抖。

守卫改成只对登录、注册、找回密码、根路径这四页放行,其余一律查 token。
顺手把 9 处没人读的 meta.requiresAuth 和 meta.title 死字段删了。
2026-10-12 18:02:36 +08:00
korexil 70ab19d23f fix: TTS 分句在引号内切开句子 (#3413)
_get_segment_text 会在引号内的标点处切分(如「わぁ!」って、“好!”然后),
把引号拆到两段里,或留下只有「。」的片段。新增 _quote_safe_cut:切分点后的
右引号与句末标点一并带走;位于未闭合引号内或右引号后句子仍在继续时,顺延到
下一个标点。不含引号的文本切分结果不变。
2026-10-12 16:26:40 +08:00
Sakura-RanChen e5f326285e update: 优化client 生命周期对齐业务请求——重试内连接复用、锁外构造、safe_close 可打断重试 2026-10-12 15:42:51 +08:00
hrz 4576e391bb fix(ci): 阿里云 PyPI 镜像 metadata 缺失回退官方源 (#3417)
基镜像构建挂了——日志显示 pip 在装 requirements.txt 阶段退出码 1,
罪魁是阿里云 pypi 镜像对 mcp==1.22.0 的元数据文件 404。

装包那行加 --extra-index-url https://pypi.org/simple,
主源没货时自动去官方源捞。
2026-10-12 10:28:35 +08:00
Yanhan Ruan ef1d24e40d fix(server): 修复临时事件循环结束后 HTTP 客户端未释放的问题 (#3416) 2026-10-12 10:00:28 +08:00
wengzh c30c40bf56 Merge pull request #3415 from xinnan-tech/chore/bump-deps-20261010065106-1659
chore(deps): 批量升级 4 个允许的依赖
2026-10-10 15:24:17 +08:00
Sakura-RanChen 199c4ce365 chore(deps): batch bump 允许升级的依赖 2026-10-10 14:53:30 +08:00
hrz a6cd755cf2 聊天记录弹框底部不再留大量空白 (#3410)
弹框本身和 body 用 !important 撑到视口(body 顶开 size="large" 默认多扣的 56px footer 高度)。body 的高度由 flex: 1 自动撑满,max-height 只用来顶开原来的 950px 上限。
2026-10-09 17:24:39 +08:00
hrz 23d0e64d9d 召回测试弹框与切片弹框样式对齐 (#3409)
召回测试弹框宽度从 900px 调到 1200px 并启用大弹框样式,高度、上下间距与切片弹框保持一致。结果区域改用 flex 填充而不是写死的 400px 高度。
2026-10-09 17:18:06 +08:00
hrz 96c224c4e4 切片和聊天记录弹框统一为大弹框样式 (#3407)
知识库查看切片弹框加 size="large",聊天记录弹框去掉旧样式覆盖并把 body 改成 flex 列布局,底部下载按钮不再绝对定位,两个弹框跟历史版本对齐。
2026-10-09 17:02:49 +08:00
hrz 2b6483662a 召回测试弹框回车不再刷新页面 (#3408)
给 el-form 加阻止默认提交的处理,按回车时不再触发浏览器刷新。
2026-10-09 17:01:52 +08:00
hrz aef973e6b9 7 处 el-select 加 filterable 支持输入筛选 #3405 (#3406)
主诉:手动添加设备下拉无搜索(FIRMWARE_TYPE 字典 54 项)。统一修同类位置:替换词多选加 collapse-tags;登录/注册/找回密码的区号下拉加 filterable(海外 200+ 项)。无新依赖、纯 element-ui 原生属性。
2026-10-08 15:39:46 +08:00
hrz 7a260ee143 弹框关掉后再点不开以及宽度高度统一收口 (#3388)
* 统一管理后台弹框更舒展

把 CustomDialog 默认宽度从 600 加到 720,正文给到至少 360 的高度,
默认弹框就不再显得局促。参数编辑弹框也跟着加宽。智能体历史版
本那条列表、对比和恢复预览都给到至少 420 的最小高度,避免内容
少时框太矮看起来小气。其他像添加智能体、绑定设备这些原本就合适
的弹框没动。

* 弹框外观统一收口

原本几个弹框长得参差不齐:标题字号、按钮文案、留白高度各管各的。这次把新建智能体、新建设备、改模型、改参数这几个弹框接到同一个底座上,按钮统一成「确定」「取消」,输入框字号高度对齐,遮罩背景加了模糊,弹框太高就内部滚动,四个弹框看起来一个模子

* 修复弹框关掉后再点不开

之前几个弹框改用新底座的时候,关闭那一路少了一段转发:父页面用双向绑定来收弹框关闭的信号,子页面应该再把关闭事件抛上去告诉父页面。这一段被删了,下次再点添加按钮时父页面那边状态没复位,弹框就拉不开来。现在把这段补回去。

* 弹框宽度和高度统一收口

同一批弹框的尺寸还是各管各的:模型配置里改参数那个是跟着窗口大小按比例伸缩,历史提示词的源编辑弹框是 900 像素,参数管理里又是 720 像素,智能体变更详情那个上下还留了一大块空白。这次收到同一个共用底子上:宽度都对齐到 720 像素,弹框默认上下贴齐屏幕边、内容多时中间滚动,变更详情那个高度撑到接近屏幕顶到底。

* 管理后台弹框按大小分类统一外观

管理后台里的弹框按大小分成两类来收口。占整页的大弹框(历史版本、变更详情、模型修改、模型参数修正等)撑满整个浏览器高度,下面不再留白;小弹框(添加智能体、添加设备、知识库、字典、角色模板等)改成对齐屏幕正中,不再顶在屏幕上沿。修改模型那条弹框也加宽到 960,左右留白加大到 40,跟历史版本对齐,看起来更舒展。

* 大弹框上下留白收口到十像素并按三档统一宽度

大弹框上下各留十像素让边框和圆角清晰可见。宽度按用途分三档:表单 960、含代码 1080、表格 80% 加宽度上限。供应器、上下文供应器、音色克隆都改 960;TTS 从 90% 改 80%;角色模板从 1200 改 1080;缓存查看从 70% 改 80%。新增模板的大文本框改成自适应高度。

* 弹框样式与文案收口处理

AddDeviceDialog 确认后关闭弹框;CustomDialog 去掉 :global 与紧邻选择器,合并 el-dialog__body 样式块;删除未使用的 --dialog-form/--dialog-code;AddWisdomBodyDialog 的 cancel/handleClose 委托到同一个实现;多语言文案同步去冒号与双点。

* 弹框遮罩样式移到全局作用域

scoped 块里的 ::v-deep .v-modal 打不到 body 层的遮罩,改在 global.scss 用全局选择器。

* 弹框 wrapper 居中样式移到全局作用域

scoped 块里的 ::v-deep .el-dialog__wrapper 同样打不到 element-ui 自渲染的 wrapper,跟 .v-modal 一起挪到 global.scss。

* 弹框提交状态异常收口

设备激活码错误路径不再卡 loading;Provider 与固件弹框补 loading 兜底。

* 弹框视觉异常收口

首页删除确认弹框 wrapper 居中;历史版本弹框去掉内层多余滚动条。

* 设备 API 链式返回修复

bindDevice 等方法补上 return,让 .fail() 兜底回调能正常注册到请求对象上。

* 设备 API 错误回调参数补全

device.js 六个方法加 failCallback 参数;user.js login 网络重试补 failCallback;AddDeviceDialog 改四参数签名。

* 设备状态 API 失败回调引用修复

getDeviceStatus 的 .fail() 块把函数体内 callback(res) 改成与签名一致的 if (failCallback) failCallback(err),避免业务错误时抛 ReferenceError。
2026-10-07 08:03:25 +08:00
欣南科技 87c6df77b0 Merge pull request #3386 from xinnan-tech/0.9.7
Bump to 0.9.7
2026-09-29 10:03:47 +08:00
hrz eb51f420a2 Bump to 0.9.7 2026-09-29 09:13:14 +08:00
DaGou12138 1eefb4ea86 Merge pull request #3385 from xinnan-tech/serpapi
Serpapi
2026-09-28 10:25:33 +08:00
hrz 1211f8202b Merge pull request #3376 from googio/feat/serply-search
feat(web_search): 新增Serply搜索源
2026-09-28 09:36:42 +08:00
wengzh 788f5301fd Merge pull request #3378 from xinnan-tech/fix-deps
fix(deps): 移除 chardet 直接依赖
2026-09-21 09:38:29 +08:00
Sakura-RanChen edad16aedf fix(deps): 移除 chardet 直接依赖 2026-09-20 15:55:21 +08:00
serply 65a3fae9fc feat(web_search): 新增Serply搜索源
联网搜索插件在秘塔和Tavily之外增加Serply,返回谷歌搜索结果。
通过 provider: "serply" 选用,未配置时行为不变。
2026-09-17 16:45:28 -04:00
wengzh f4ba65f224 Merge pull request #3375 from xinnan-tech/fix-snapshot-dialog-diff-style
fix: 修复 v-code-diff 斜体样式干扰 diff 展示
2026-09-17 17:54:02 +08:00
lww9029 87c2bae55a fix: 修复 v-code-diff 斜体样式干扰 diff 展示 2026-09-17 17:50:27 +08:00
wengzh 628d4cedb7 Merge pull request #3373 from xinnan-tech/feat-snapshot-dialog-code-diff
feat: 历史版本快照增加代码对比视图
2026-09-17 17:06:59 +08:00
Sakura-RanChenandSakura-RanChen 6c3f89cae1 chore(deps): 批量升级 4 个允许的依赖 (#3374)
* chore(deps): batch bump 允许升级的依赖

* test: 添加 5 条核心链路测试 + 默认 mock 模式

参照 PR #3370 的整体结构,新增 STT/LLM/TTS/RAG/Memory 五个 test_*.py
覆盖关键链路。conftest.py 默认 mock 模式:

- PYTEST_CONFIG_FILE 指向单模块配置文件(不入库)
- RUN_LIVE_API_TESTS=1 走真请求(必须配齐密钥)
- 真实存在的第三方包(openai/httpx)保留,仅 patch httpx 网络层
- 装了一半会炸的本地模型(torch/funasr/modelscope)强制 shim
- mock load_config / init_service,避免触发远程 manager-api

mock 模式目标:dep-upgrade orchestrator / CI 不需要任何密钥即可跑通。

---------

Co-authored-by: Sakura-RanChen <Sakura-RanChen@local>
2026-09-17 15:57:43 +08:00
lww9029 4745186cea style: 调整窄屏样式 2026-09-17 10:43:26 +08:00
lww9029 1ddc97b626 feat: 快照对话框增加代码对比视图 2026-09-17 10:04:15 +08:00
wengzh 6afc54a17d Merge pull request #3366 from xinnan-tech/feat-tts-alibl
Feat tts alibl
2026-09-11 17:35:08 +08:00
wengzh daccfeb9dd Merge pull request #3364 from xinnan-tech/fix-memory-title
fix: 记忆模式为nomem时错误生成标题
2026-09-11 17:34:43 +08:00
Sakura-RanChen 1846d3e935 调整相关逻辑 2026-09-09 16:17:13 +08:00
Sakura-RanChen b1a7607dd5 Merge pull request #3345 from apexlsai/feature/alibl-workspace-streaming
feat(tts): 完善阿里百炼 Workspace 流式语音合成
2026-09-09 14:22:59 +08:00
Sakura-RanChen d6d2da3d76 fix: 记忆模式为nomem时错误生成标题 2026-09-09 10:33:37 +08:00
CGD 5aa46538d5 Merge pull request #3358 from xinnan-tech/fix-audio-delay
fix: 修复固定延迟不生效,调整相关逻辑
2026-09-08 10:53:25 +08:00
Sakura-RanChen ed98be5347 Merge pull request #3357 from xinnan-tech/py-test-client-toctou
根治ManageApiClient单例TOCTOU竞态,锁保护+统一防护
2026-09-08 10:00:20 +08:00
Sakura-RanChen 79a95af399 fix: 修复固定延迟不生效,调整相关逻辑 2026-09-07 11:38:06 +08:00
3030332422 32cba36237 fix: 修复 ManageApiClient 关闭期间的连接池创建竞态 2026-09-07 11:14:50 +08:00
CGD 82e3c1874f Merge pull request #3353 from muxinshijie/feature/manage-api-client-toctou
fix: 根治ManageApiClient单例TOCTOU竞态,锁保护+统一防护 #3339
2026-09-07 10:31:58 +08:00
欣南科技 c478257517 Merge pull request #3356 from xinnan-tech/fix/docs-typos
docs: 批量修正文档里的错别字和拼写错误
2026-09-06 08:44:01 +08:00
欣南科技 9fdb72cfd7 Merge pull request #3355 from xinnan-tech/fix/3354-建议修改docsmcpvisionintegrationmd
docs: 修正视觉模型配置文档里的错字
2026-09-06 08:43:38 +08:00
hrz 3c31315ecc docs: 批量修正文档里的错别字和拼写错误
扫了仓库里的文档,挑出明显写错的字和拼错的英文,统一改了一遍。包括「Anaconda Prompt」漏字、「火上」→「火山」、「设别」→「设备」、「tababr」→「tabbar」、「aotodl」→「autodl」、「mananger-web」→「manager-web」等十几处。仅文档修改,不影响代码。
2026-09-06 08:37:51 +08:00
hrz 07c6cdf16a docs: 修正视觉模型配置文档里的错字
按 issue #3354,把"视觉打语言模型"改成"视觉大语言模型",跟上下文一致。
2026-09-06 08:34:57 +08:00
hrz 8a3c2c51a0 Merge pull request #3351 from taiwan-dog/pm/disable-default-websocket-keepalive
fix: disable library WebSocket keepalive for device server
2026-09-05 00:37:23 +08:00
hrz 046fdc8aa4 Merge pull request #3350 from taiwan-dog/pm/hello-before-mcp-initialize
fix: send server hello before MCP initialize
2026-09-05 00:26:15 +08:00
muxinshijie 61e4879826 fix: 根治ManageApiClient单例TOCTOU竞态,锁保护+统一防护 #3339 2026-09-05 00:14:09 +08:00
yillkid eda3c23d51 fix: disable library WebSocket keepalive for device server
websockets.serve() defaults to ping_interval=20 and ping_timeout=20.
ESP32 clients that do not answer those pings are dropped with
1011 keepalive ping timeout roughly 40 seconds into a healthy session.
The application-level ping is configured separately via
enable_websocket_ping.

Refs #3348
2026-09-04 19:00:24 +08:00
yillkid a03a0a73db fix: send server hello before MCP initialize
The MCP initialize task was scheduled before the server hello was sent,
so the MCP frame could reach the device first. ESP32 firmware waits for
the server hello before it treats the channel as ready, so the device
never answered the initialize request.

Refs #3347
2026-09-04 19:00:24 +08:00
欣南科技 27e780e4c1 Merge pull request #3342 from xinnan-tech/test-infrastructure
test: 新增三语测试基建(Python/Java/Vue)+ CI
2026-09-04 13:54:31 +08:00
hrz 4b1ccce575 Merge pull request #3346 from taiwan-dog/pm/fix-funasr-result-type
fix: normalize FunASR result type
2026-09-04 13:40:00 +08:00
yillkid 323c2248b4 [pm] fix: normalize FunASR result type 2026-09-04 13:10:58 +08:00
Jacob a31ba7ef6d fix(tts): synchronize Bailian streaming subtitles 2026-09-04 12:05:34 +08:00
Jacob 935184b1d0 feat(tts): support Bailian workspace endpoints 2026-09-04 12:05:09 +08:00
hrz c7b126ca2f Makefile test-web 同时跑契约与 vitest 2026-09-02 22:24:36 +08:00
hrz 95954c8dae 加日期格式化工具测试,24 例 2026-09-02 22:24:36 +08:00
hrz 17c0da7f87 加 featureManager 单元测试,7 例 2026-09-02 22:24:36 +08:00
hrz dcb3d3e0b0 加 deviceTime 工具 vitest 测试,22 例 2026-09-02 22:24:36 +08:00
hrz e58de03be2 配 vitest:jsdom 环境与覆盖率 2026-09-02 22:24:36 +08:00
hrz 975bbe2cfd 前端引入 vitest 测试工具链 2026-09-02 22:24:36 +08:00
hrz 88d6d13a62 加敏感数据工具测试,9 例 2026-09-02 22:24:36 +08:00
hrz c88149a5d4 加知识库适配器工厂测试,6 例 2026-09-02 22:24:36 +08:00
hrz 6a3aaf475e 加纠错词文件服务单元测试,9 例 2026-09-02 22:24:35 +08:00
hrz caab6a145d 加插件包导入冒烟测试,1 例 2026-09-02 22:24:35 +08:00
hrz 7ac454a1b9 加意图/模型/记忆工厂测试,6 例 2026-09-02 22:24:35 +08:00
hrz ba20bebaeb 加对话消息测试 11 例,含异步 2026-09-02 22:24:35 +08:00
hrz 922adbb290 加配置合并函数测试,7 例 2026-09-02 22:24:35 +08:00
hrz 6eca417534 加 current_time 测试 7 例,冻结时间 2026-09-02 22:24:35 +08:00
hrz 93d54f175a 加 output_counter 测试,7 例 2026-09-02 22:24:35 +08:00
hrz 34ab39ba69 加 textUtils 行为测试,11 例 2026-09-02 22:24:35 +08:00
hrz a0dd138e93 配 pytest 与 conftest,修模块路径 2026-09-02 22:24:35 +08:00
hrz b90dd01826 加 Python 冒烟测试,验证 pytest 流水线 2026-09-02 22:24:35 +08:00
hrz 22b1703557 新增 GitHub Actions,并行跑三语测试 2026-09-02 22:24:35 +08:00
hrz 3363965f63 新增顶层 Makefile,统一测试入口 2026-09-02 22:24:35 +08:00
hrz eb4b28c59e Merge pull request #3336 from muxinshijie/feature/fix-noneType-bug
fix: 修复safe_close多线程竞态导致NoneType空指针
2026-09-02 18:38:10 +08:00
muxinshijie 74cc31d1cc 服务关闭时主线程调用safe_close()将instance置None,与守护线程存在竞态条件,因此必须先判空 2026-08-27 00:13:21 +08:00
hrz c02becdc80 Merge pull request #3332 from MsThink/main
refactor(manager-web): 将备注重命名为 设备别名
2026-08-21 21:39:45 +08:00
colorful b16ed2ed36 style(manager-web): 调整通讯录中的设备状态在线/离线状态图标间距,居中 2026-08-20 10:54:15 +08:00
colorful af9abdd771 refactor(manager-web): 将备注重命名为 设备别名
- 更新所有语言包中的相关文案
- 同步更新组件及变量命名
2026-08-19 15:23:55 +08:00
hrz e1876f1ce1 Merge pull request #3315 from xinnan-tech/fix-model-name
更换豆包、Gemini的LLM模型、修复 performance_tester 异步配置加载错误
2026-08-18 16:31:48 +08:00
hrz ced7103639 Merge branch 'main' into fix-model-name 2026-08-18 16:31:21 +08:00
hrz cb0b075304 Merge pull request #3326 from MsThink/main
fix(知识库管理): 上传弹窗禁止点击遮罩关闭并添加提交加载状态
2026-08-18 16:26:46 +08:00
欣南科技 cdfb2a1187 Merge pull request #3331 from xinnan-tech/openrz-patch-1
Delete docs/images/console-params.png
2026-08-18 16:24:32 +08:00
hrz 8b64205522 Delete docs/images/console-params.png 2026-08-18 16:23:50 +08:00
hrz 6529b80c64 Merge pull request #3329 from Jehuty-ML/feat/params-management-group-ui
feat(manager-web): 参数管理按命名空间聚合展示
2026-08-18 16:10:52 +08:00
weirui d76825fae5 fix(manager-web): 参数管理聚合支持分页拉全量并重置空态全选
按 total 循环拉取全部页,避免超过 500 条时缺数据;displaySections 为空时同步重置 isAllSelected。
2026-08-18 10:19:39 +08:00
weirui c55dc8282f docs: 补充参数管理聚合界面截图,便于 PR 审阅对照 2026-08-17 18:56:27 +08:00
weirui 4870b5a0a2 feat(manager-web): 参数管理按命名空间聚合展示
一次拉取全量参数并按 paramCode 前缀分组折叠,避免默认分页难找相关配置。
2026-08-17 17:56:40 +08:00
wengzh ae3f970c92 Merge pull request #3322 from chentyke/fix/tts-timeout-config-propagation
fix(tts): 统一 TTS 请求超时配置
2026-08-17 17:41:45 +08:00
msgpo 8367f9b607 fix(知识库管理): 上传弹窗禁止点击遮罩关闭并添加提交加载状态 2026-08-15 15:02:04 +08:00
3030332422 59b51e021b update:暂时撤回对gemini模型的更替 2026-08-14 20:08:51 +08:00
3030332422 2c89b284b2 fix:修复Liquibase changeset ID误改的问题 2026-08-14 10:37:40 +08:00
Tyke Chen 04b3ad5aae fix(tts): validate timeout range 2026-08-13 15:59:41 +08:00
Tyke Chen 2c43a13d6e fix(tts): unify configurable request timeouts 2026-08-13 15:02:17 +08:00
Tyke Chen 17560a7d29 Merge pull request #3320 from henson957/fix/siliconflow-http-timeout
fix(tts): SiliconFlow provider 增加请求超时与 HTTP 错误处理
2026-08-13 14:25:06 +08:00
henson957 db7db3de67 fix(tts): reuse base tts_timeout config instead of hardcoded 15 2026-08-13 13:28:17 +08:00
henson957 d03d0d0982 fix(tts): add HTTP timeout and error handling to SiliconFlow provider
- requests.request() had no timeout: when the SiliconFlow API slows down,
  the request hangs forever and the device produces no audio intermittently
- non-200 responses (rate limit, error page) were written to disk as audio;
  now the status code is checked and an exception is raised with the body
2026-08-12 17:37:04 +08:00
Tyke Chen 545979873b Merge pull request #3318 from JansenChang/codex/fix-function-dialog-mcp-overlap
fix(manager-web): keep function dialog footer accessible with many MCP tools
2026-08-11 17:27:44 +08:00
ZhengYS 16b6841710 fix: keep function dialog footer accessible 2026-08-10 12:00:30 +08:00
3030332422 2a465f0fb5 fix: 修复 performance_tester 异步配置加载错误 2026-08-07 10:23:25 +08:00
3030332422 ea6b0c3746 update:豆包模型更换为doubao-seed-2-0-lite-260215,Gemini模型更换为gemini-3.6-flash 2026-08-07 09:09:53 +08:00
hrz 7475ea2b2d Merge pull request #3313 from xinnan-tech/fix-deepseek-model
deepseek更改模型为v4-flash并且关闭思考模式
2026-08-06 18:10:01 +08:00
欣南科技 1fa178cd36 Merge pull request #3314 from xinnan-tech/fix-star-img
fixed:Star History Chart
2026-08-06 18:08:59 +08:00
hrz 93885058e9 fixed:Star History Chart 2026-08-06 18:08:18 +08:00
3030332422 88eed86dc2 update:关闭deepseek的思考模式 2026-08-06 13:07:04 +08:00
3030332422 a269953787 fix:deepseek模型名称更改为deepseek-v4-flash 2026-08-06 11:10:31 +08:00
hrz 50895d6e33 Merge pull request #3310 from xinnan-tech/py-fix-pr_3304
展开模块级别插件名为具体函数名,合并 HA 设备控制插件为 hass_state 模块
2026-08-04 09:25:44 +08:00
3030332422 a21a2e34d0 refactor: 合并 HA 设备控制插件为 hass_state 模块 2026-07-29 09:58:26 +08:00
3030332422 7a22b61969 fix:修复与函数同名时遗漏其他函数的问题 2026-07-28 17:05:18 +08:00
CGD 477f85b97c Merge pull request #3304 from MsThink/main
fix(plugins): 展开模块级别插件名为具体函数名
2026-07-28 15:22:58 +08:00
CGD de45f73efd Merge pull request #3303 from chentyke/fix/frontend-contract-consistency
fix(manager): align frontend and backend field contracts
2026-07-28 09:37:39 +08:00
CGD 1dc46e30d7 Merge pull request #3302 from xinnan-tech/refactor/manager-api-tool-dependencies
refactor(manager-api): 升级常用工具依赖并收敛冗余实现
2026-07-28 09:28:00 +08:00
Sakura-RanChen 5e0d853256 Merge pull request #3309 from xinnan-tech/py-fix-reportHandle
refactor(reportHandle): 重构上报处理逻辑,适配多格式音频转换
2026-07-27 15:24:15 +08:00
wengzh 2a9f809700 refactor(reportHandle): 重构上报处理逻辑,适配多格式音频转换
1.  重命名参数与变量,明确上报类型与音频格式
2.  拆分PCM和Opus转WAV逻辑,分别处理不同输入格式
3.  移动校验逻辑到上报队列函数开头,提前拦截无效上报
4.  完善Opus转WAV的解码与资源释放流程
2026-07-27 09:59:32 +08:00
colorful 523915f9fe fix(plugins): 展开模块级别插件名为具体函数名
在一个 function 文件中可能注册多个 @register_function,当配置下发或
本地配置使用模块名(文件名)时,需要自动展开为具体的函数名列表。

改动内容:
- register.py: 新增 module_func_map 全局映射,在 register_function
  装饰器中自动记录模块名 → 函数名列表的映射关系
- connection.py: 处理服务端下发的插件配置时,将模块级的 plugins 配置
  复制到各具体函数名 key 下,并将模块名展开写入 Intent functions 列表
- plugin_executor.py: 新增 _expand_plugin_names() 兜底展开逻辑和
  _get_plugin_description() 双层查找方法,确保本地配置场景也能正确展开
2026-07-24 10:47:00 +08:00
Tyke Chen 18e5c47a06 fix(manager): align frontend and backend field contracts 2026-07-24 10:21:05 +08:00
Tyke Chen 4a2c90ec87 refactor(manager-api): 升级常用工具依赖并收敛冗余实现 2026-07-24 09:06:15 +08:00
221 changed files with 11029 additions and 1612 deletions

No files matched your search

+37 -1
View File
@@ -3,4 +3,40 @@ updates:
- package-ecosystem: pip
directory: /main/xiaozhi-server
schedule:
interval: weekly
interval: monthly
open-pull-requests-limit: 5
groups:
ai-services:
patterns:
- "openai*"
- "google-generativeai"
- "dashscope"
- "baidu-aip"
- "cozepy"
- "markitdown"
- "mem0ai"
- "powermem"
ai-models:
patterns:
- "funasr"
- "vosk"
- "silero_vad"
- "opuslib_next"
- "sherpa_onnx"
- "modelscope"
web-stack:
patterns:
- "httpx"
- "aiohttp"
- "aiohttp_cors"
- "requests"
- "pysocks"
everything-else:
patterns:
- "*"
ignore:
- dependency-name: "torch"
- dependency-name: "torchaudio"
- dependency-name: "numpy"
- dependency-name: "portalocker"
- dependency-name: "websockets"
+66
View File
@@ -0,0 +1,66 @@
name: Tests
on:
push:
branches: [main]
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
python:
name: Python (xiaozhi-server)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.10'
cache: 'pip'
cache-dependency-path: 'main/xiaozhi-server/requirements.txt'
- name: Install dependencies
working-directory: main/xiaozhi-server
run: |
pip install -r requirements.txt
pip install pytest pytest-asyncio freezegun
- name: Run smoke test
working-directory: main/xiaozhi-server
run: |
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
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
cache: 'maven'
- name: Run tests
working-directory: main/manager-api
run: mvn -B -q test -DfailIfNoTests=false
web:
name: Vue (manager-web)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: main/manager-web/package-lock.json
- name: Run tests
working-directory: main/manager-web
run: |
npm ci --no-audit --no-fund
npm run test:unit
+2 -1
View File
@@ -29,4 +29,5 @@ COPY main/xiaozhi-server/requirements.txt .
# 安装Python依赖
RUN pip install --no-cache-dir --upgrade pip setuptools wheel && \
pip install --no-cache-dir -r requirements.txt --default-timeout=120 --retries 5
# 阿里云 PyPI 镜像偶有元数据 404,加官方源作 fallback
pip install --no-cache-dir -r requirements.txt --default-timeout=120 --retries 5 --extra-index-url https://pypi.org/simple
+21
View File
@@ -0,0 +1,21 @@
.PHONY: test test-fast test-python test-java test-web help
help:
@echo "make test-fast - run all fast unit tests (default)"
@echo "make test - alias for test-fast"
@echo "make test-python - pytest in main/xiaozhi-server"
@echo "make test-java - mvn test in main/manager-api"
@echo "make test-web - npm run test:unit in main/manager-web"
test: test-fast
test-fast: test-python test-java test-web
test-python:
cd main/xiaozhi-server && python -m pytest -x -q
test-java:
cd main/manager-api && mvn -B -q test -DfailIfNoTests=false
test-web:
cd main/manager-web && npm ci --no-audit --no-fund && npm test
+4 -5
View File
@@ -353,11 +353,10 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
| <img src="./docs/images/logo_contributors.png" width="160"> | [代码贡献者](https://github.com/xinnan-tech/xiaozhi-esp32-server/graphs/contributors) | 感谢[所有代码贡献者](https://github.com/xinnan-tech/xiaozhi-esp32-server/graphs/contributors)贡献者,你们的付出让项目更加健壮和强大。 |
<a href="https://star-history.com/#xinnan-tech/xiaozhi-esp32-server&Date">
<a href="https://www.star-history.com/?repos=xinnan-tech%2Fxiaozhi-esp32-server&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=xinnan-tech/xiaozhi-esp32-server&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=xinnan-tech/xiaozhi-esp32-server&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=xinnan-tech/xiaozhi-esp32-server&type=Date" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=xinnan-tech/xiaozhi-esp32-server&type=date&theme=dark&legend=top-left&sealed_token=cQovHKgZjqEnQ-svfYfN392irNvGuq-6pyv4cA8nd2jQEhQLz1ETV4YHTVk2UZyLMFbCQuZA7jduRh3YbeK5WPYaRLrfmIimGQa3lram652jJL9oQk-UuSZA5H6L4dPIhZc8KCc-Ur_UAUNbly7TePpnTR2otGknBLCOjOliD4fk1st6z7tPEDVjSRx5" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=xinnan-tech/xiaozhi-esp32-server&type=date&legend=top-left&sealed_token=cQovHKgZjqEnQ-svfYfN392irNvGuq-6pyv4cA8nd2jQEhQLz1ETV4YHTVk2UZyLMFbCQuZA7jduRh3YbeK5WPYaRLrfmIimGQa3lram652jJL9oQk-UuSZA5H6L4dPIhZc8KCc-Ur_UAUNbly7TePpnTR2otGknBLCOjOliD4fk1st6z7tPEDVjSRx5" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=xinnan-tech/xiaozhi-esp32-server&type=date&legend=top-left&sealed_token=cQovHKgZjqEnQ-svfYfN392irNvGuq-6pyv4cA8nd2jQEhQLz1ETV4YHTVk2UZyLMFbCQuZA7jduRh3YbeK5WPYaRLrfmIimGQa3lram652jJL9oQk-UuSZA5H6L4dPIhZc8KCc-Ur_UAUNbly7TePpnTR2otGknBLCOjOliD4fk1st6z7tPEDVjSRx5" />
</picture>
</a>
+2 -2
View File
@@ -70,7 +70,7 @@ xiaozhi-server
## 2. 配置项目文件
接下里,程序还不能直接运行,你需要配置一下,你到底使用的是什么模型。你可以看这个教程:
接下来,程序还不能直接运行,你需要配置一下,你到底使用的是什么模型。你可以看这个教程:
[跳转到配置项目文件](#配置项目)
配置完项目文件后,回到本教程继续往下。
@@ -119,7 +119,7 @@ docker rmi ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:web_latest
如果确定使用`conda`,则安装好后,开始执行以下命令。
重要提示!windows 用户,可以通过安装`Anaconda`来管理环境。安装好`Anaconda`后,在`开始`那里搜索`anaconda`相关的关键词,
找到`Anaconda Prpmpt`,使用管理员身份运行它。如下图。
找到`Anaconda Prompt`,使用管理员身份运行它。如下图。
![conda_prompt](./images/conda_env_1.png)
+1 -1
View File
@@ -335,7 +335,7 @@ npm run serve
如果确定使用`conda`,则安装好后,开始执行以下命令。
重要提示!windows 用户,可以通过安装`Anaconda`来管理环境。安装好`Anaconda`后,在`开始`那里搜索`anaconda`相关的关键词,
找到`Anaconda Prpmpt`,使用管理员身份运行它。如下图。
找到`Anaconda Prompt`,使用管理员身份运行它。如下图。
![conda_prompt](./images/conda_env_1.png)
+1 -1
View File
@@ -7,7 +7,7 @@
`上下文源` 在小智在唤醒那一刻,获取外部系统的数据,并将其动态注入到大模型的系统提示词(System Prompt)中。
让其做到唤醒时感知世界某个事物的状态。
它和MCP、记忆有本质的区别:`上下文源`是强制让小智感知世界的数据;`记忆(Mem)`是让他知道之前聊了什么内容;`MCP(functionc all)`是当需要调用某项能力/知识的时候使用调用。
它和MCP、记忆有本质的区别:`上下文源`是强制让小智感知世界的数据;`记忆(Mem)`是让他知道之前聊了什么内容;`MCP(function_call)`是当需要调用某项能力/知识的时候使用调用。
通过这个功能,在小智唤醒的一刹那,“感知”到:
- 人体健康传感器状态(体温、血压、血氧状态等)
+2 -2
View File
@@ -51,7 +51,7 @@ cp 你原来的model.pt完整路径 /home/system/xiaozhi/xiaozhi-esp32-server/ma
# 第四步 建立三个自动编译文件
## 4.1 自动编译mananger-web模块
## 4.1 自动编译manager-web模块
在`/home/system/xiaozhi/`目录下,创建名字为`update_8001.sh`的文件,内容如下
```
@@ -106,7 +106,7 @@ fi
nohup java -jar xiaozhi-esp32-api.jar --spring.profiles.active=dev &
tail tail -f nohup.out
tail -f nohup.out
```
保存好后执行赋权命令
+1 -1
View File
@@ -45,7 +45,7 @@ python download_models.py
python -m tools.api_server --listen 0.0.0.0:6006
```
然后用浏览器去到aotodl实例页面
然后用浏览器去到autodl实例页面
```
https://autodl.com/console/instance/list
```
+2 -2
View File
@@ -68,7 +68,7 @@ http://homeassistant.local:8123
然后在实体中搜索你相关控制的开关,结果出来后,在列表中,点击其中一个结果,这是会出现一个开关的界面。
在开关的界面,我们尝试点击开关,看看是开发会随着我们的点击开/关。如果能操作,说明是正常联网的。
在开关的界面,我们尝试点击开关,看看是否会随着我们的点击开/关。如果能操作,说明是正常联网的。
接着在开关面板找到设置按钮,点击后,可以查看这个开关的`实体标识符`。
@@ -107,7 +107,7 @@ http://homeassistant.local:8123
保存成功后,即可唤醒设备操作。
#### 3. 唤醒设别进行控制
#### 3. 唤醒设备进行控制
尝试和esp32说,“打开XXX灯”
+1 -1
View File
@@ -3,7 +3,7 @@
本教程分为4个阶段:准备阶段、配置阶段、克隆阶段、使用阶段。主要是介绍通过智控台配置火山双流式语音合成+音色克隆的过程。
## 第一阶段:准备阶段
超级管理员先预先把火山引擎服务开通好,获取到App Id,Access Token。默认火上引擎会赠送一个音色资源。这个音色资源需要把它复制到本项目里。
超级管理员先预先把火山引擎服务开通好,获取到App Id,Access Token。默认火山引擎会赠送一个音色资源。这个音色资源需要把它复制到本项目里。
如果你想克隆多个音色,需要购买开通多个音色资源。只要把每个音色资源的声音ID(S_xxxxx)复制到本项目。然后分配给系统的账号使用即可。以下是详细步骤:
+1 -1
View File
@@ -37,7 +37,7 @@
打开完,找到页面中一个绿色的按钮,写着`Code`的按钮,点开它,然后你就看到`Download ZIP`的按钮。
点击它,下载本项目源码压缩包。下载到你电脑后,解压它,此时它的名字可能叫`mcp-calculatorr-main`
点击它,下载本项目源码压缩包。下载到你电脑后,解压它,此时它的名字可能叫`mcp-calculator-main`
你需要把它重命名成`mcp-calculator`。接下来,我们用命令行进入项目目录即安装依赖
+1 -1
View File
@@ -113,7 +113,7 @@ server:
那我们需要先登录[智谱AI](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)网站,申请密钥。如果你之前已经申请过了密钥,可以复用这个密钥。
登录`智控台`,顶部菜单点击`模型配置`,在左侧栏点击`视觉打语言模型`,找到`VLLM_ChatGLMVLLM`,点击修改按钮,在弹框中,在`API密钥`输入你密钥,点击保存。
登录`智控台`,顶部菜单点击`模型配置`,在左侧栏点击`视觉大语言模型`,找到`VLLM_ChatGLMVLLM`,点击修改按钮,在弹框中,在`API密钥`输入你密钥,点击保存。
保存成功后,去到你需要测试的智能体哪里,点击`配置角色`,在打开的内容里,查看`视觉大语言模型(VLLM)`是否选择了刚才的视觉模型。点击保存。
+1 -1
View File
@@ -86,7 +86,7 @@ SERVER_SECRET=Te1st12134 # 服务器密钥,请保持和智控台(server.sec
- 注意不要用简单的密码,比如`123456`、`test`等。
- 注意不要用简单的密码,比如`123456`、`test`等。
`SERVER_SECRET` 是用生成websocket连接的认证信息。
`SERVER_SECRET` 是用于生成websocket连接的认证信息。
1、如果你是全模块部署,且你的智控台的参数管理里`server.auth.enabled`设置成了`true`,那么,`SERVER_SECRET`需要和智控台(`server.secret`)保持一致。
+2 -2
View File
@@ -1,6 +1,6 @@
# ragflow 集成指南
本教程主要是是两部分
本教程主要是两部分
- 一、如何部署ragflow
- 二、如何在智控台配置ragflow接口
@@ -12,7 +12,7 @@
ragflow需要依赖`mysql`数据库。如果你之前已经部署`智控台`,说明你已经安装了`mysql`。你可以共用它。
你可以你试一下在宿主机使用`telnet`命令,看看能不能正常访问`mysql`的`3306`端口。
你可以试一下在宿主机使用`telnet`命令,看看能不能正常访问`mysql`的`3306`端口。
``` shell
telnet 127.0.0.1 3306
+1 -1
View File
@@ -20,7 +20,7 @@
声纹识别需要依赖`mysql`数据库。如果你之前已经部署`智控台`,说明你已经安装了`mysql`。你可以共用它。
你可以你试一下在宿主机使用`telnet`命令,看看能不能正常访问`mysql`的`3306`端口。
你可以试一下在宿主机使用`telnet`命令,看看能不能正常访问`mysql`的`3306`端口。
```
telnet 127.0.0.1 3306
```
+12 -4
View File
@@ -2,13 +2,14 @@
## 功能简介
联网搜索插件 `web_search` 支持在对话过程中实时联网搜索信息并返回结果。插件支持两个搜索源:秘塔(Metaso)和Tavily,用户可根据需要选择其中一个。
联网搜索插件 `web_search` 支持在对话过程中实时联网搜索信息并返回结果。插件支持三个搜索源:秘塔(Metaso)、Tavily和Serply,用户可根据需要选择其中一个。
## API Key申请指南
目前我们适配了`秘塔搜索`和`Tavily搜索`。
目前我们适配了`秘塔搜索`、`Tavily搜索`和`Serply搜索`。
- Tavily搜索:每个月1000次免费额度。
- 秘塔搜索:拥有较为优质的国内数据源。
- Serply搜索:返回谷歌搜索结果,注册赠送2500次免费额度,额度用完后需要付费。
## API Key申请指南
@@ -24,6 +25,13 @@
- 在控制台中创建API Key
- 复制生成的API Key(以 `tvly-` 为前缀),这是配置所需的关键信息
### 方式三:使用Serply搜索
- 访问 [Serply](https://serply.io),注册并登录账号
- 在控制台中创建API Key
- 复制生成的API Key,这是配置所需的关键信息
- 接口说明见 [Serply文档](https://serply.io/docs)
## 配置方式
### 方式1. 使用智控台部署(推荐)
@@ -32,14 +40,14 @@
- 进入"配置角色"页面,选择要配置的智能体
- 点击"编辑功能"按钮,在右侧参数配置区域找到"联网搜索"插件
- 勾选"联网搜索"
- 填入搜索源(`metaso`或`tavily`),并将对应的`API Key`填入配置项
- 填入搜索源(`metaso`、`tavily`或`serply`),并将对应的`API Key`填入配置项
- 保存配置,再保存智能体配置
### 方式2. 单模块xiaozhi-server部署
在 `data/.config.yaml` 中配置:
- 将搜索源填入 `provider`,可选值为 `metaso` 或 `tavily`
- 将搜索源填入 `provider`,可选值为 `metaso`、`tavily` 或 `serply`
- 将申请到的API Key填入 `api_key`
```yaml
+4 -4
View File
@@ -21,14 +21,14 @@
<junit.version>5.10.1</junit.version>
<druid.version>1.2.20</druid.version>
<mybatisplus.version>3.5.17</mybatisplus.version>
<hutool.version>5.8.24</hutool.version>
<jsoup.version>1.19.1</jsoup.version>
<hutool.version>5.8.46</hutool.version>
<jsoup.version>1.22.2</jsoup.version>
<knife4j.version>4.6.0</knife4j.version>
<springdoc.version>2.8.8</springdoc.version>
<commons-lang3.version>3.18.0</commons-lang3.version>
<commons-lang3.version>3.20.0</commons-lang3.version>
<shiro.version>2.0.2</shiro.version>
<captcha.version>1.6.2</captcha.version>
<guava.version>33.0.0-jre</guava.version>
<guava.version>33.6.0-jre</guava.version>
<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>
@@ -324,7 +324,7 @@ public interface Constant {
/**
* 版本号
*/
public static final String VERSION = "0.9.6";
public static final String VERSION = "0.9.8";
/**
* 无效固件URL
@@ -1,52 +0,0 @@
package xiaozhi.common.utils;
import lombok.extern.slf4j.Slf4j;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/**
* 哈希加密算法的工具类
* @author zjy
*/
@Slf4j
public class HashEncryptionUtil {
/**
* 使用md5进行加密
* @param context 被加密的内容
* @return 哈希值
*/
public static String Md5hexDigest(String context){
return hexDigest(context,"MD5");
}
/**
* 指定哈希算法进行加密
* @param context 被加密的内容
* @param algorithm 哈希算法
* @return 哈希值
*/
public static String hexDigest(String context,String algorithm ){
// 获取MD5算法实例
MessageDigest md = null;
try {
md = MessageDigest.getInstance(algorithm);
} catch (NoSuchAlgorithmException e) {
log.error("加密失败的算法:{}",algorithm);
throw new RuntimeException("加密失败,"+ algorithm +"哈希算法系统不支持");
}
// 计算智能体id的MD5值
byte[] messageDigest = md.digest(context.getBytes());
// 将字节数组转换为十六进制字符串
StringBuilder hexString = new StringBuilder();
for (byte b : messageDigest) {
String hex = Integer.toHexString(0xFF & b);
if (hex.length() == 1) {
hexString.append('0');
}
hexString.append(hex);
}
return hexString.toString();
}
}
@@ -1,89 +0,0 @@
package xiaozhi.common.utils;
import cn.hutool.core.util.ReUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 通用工具类
*/
public class ToolUtil {
private static final Logger logger = LoggerFactory.getLogger(ToolUtil.class);
/**
* 对象是否不为空(新增)
*/
public static boolean isNotEmpty(Object o) {
return !isEmpty(o);
}
/**
* 对象是否为空
*/
public static boolean isEmpty(Object o) {
if (o == null) {
return true;
}
if (o instanceof String) {
if (o.toString().trim().equals("")) {
return true;
}
} else if (o instanceof List) {
if (((List) o).size() == 0) {
return true;
}
} else if (o instanceof Map) {
if (((Map) o).size() == 0) {
return true;
}
} else if (o instanceof Set) {
if (((Set) o).size() == 0) {
return true;
}
} else if (o instanceof Object[]) {
if (((Object[]) o).length == 0) {
return true;
}
} else if (o instanceof int[]) {
if (((int[]) o).length == 0) {
return true;
}
} else if (o instanceof long[]) {
if (((long[]) o).length == 0) {
return true;
}
}
return false;
}
/**
* 对象组中是否存在空对象
*/
public static boolean isOneEmpty(Object... os) {
for (Object o : os) {
if (isEmpty(o)) {
return true;
}
}
return false;
}
/**
* 对象组中是否全是空对象
*/
public static boolean isAllEmpty(Object... os) {
for (Object o : os) {
if (!isEmpty(o)) {
return false;
}
}
return true;
}
}
@@ -19,8 +19,6 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
@@ -44,7 +42,6 @@ import xiaozhi.modules.agent.dto.AgentDTO;
import xiaozhi.modules.agent.dto.AgentMemoryDTO;
import xiaozhi.modules.agent.dto.AgentUpdateDTO;
import xiaozhi.modules.agent.entity.AgentEntity;
import xiaozhi.modules.agent.entity.AgentTemplateEntity;
import xiaozhi.modules.agent.dto.AgentTagDTO;
import xiaozhi.modules.agent.entity.AgentTagEntity;
import xiaozhi.modules.agent.service.AgentTagService;
@@ -52,7 +49,6 @@ import xiaozhi.modules.agent.service.AgentChatAudioService;
import xiaozhi.modules.agent.service.AgentChatHistoryService;
import xiaozhi.modules.agent.service.AgentChatSummaryService;
import xiaozhi.modules.agent.service.AgentService;
import xiaozhi.modules.agent.service.AgentTemplateService;
import xiaozhi.modules.agent.vo.AgentChatHistoryUserVO;
import xiaozhi.modules.agent.vo.AgentInfoVO;
import xiaozhi.modules.security.user.SecurityUser;
@@ -65,7 +61,6 @@ public class AgentController {
private static final long AUDIO_PLAY_TOKEN_EXPIRE_SECONDS = 300L;
private final AgentService agentService;
private final AgentTemplateService agentTemplateService;
private final AgentChatHistoryService agentChatHistoryService;
private final AgentChatAudioService agentChatAudioService;
private final AgentChatSummaryService agentChatSummaryService;
@@ -192,15 +187,6 @@ public class AgentController {
return new Result<>();
}
@GetMapping("/template")
@Operation(summary = "智能体模板模板列表")
@RequiresPermissions("sys:role:normal")
public Result<List<AgentTemplateEntity>> templateList() {
List<AgentTemplateEntity> list = agentTemplateService
.list(new QueryWrapper<AgentTemplateEntity>().orderByAsc("sort"));
return new Result<List<AgentTemplateEntity>>().ok(list);
}
@GetMapping("/{id}/sessions")
@Operation(summary = "获取智能体会话列表")
@RequiresPermissions("sys:role:normal")
@@ -41,6 +41,17 @@ public class AgentTemplateController {
private final AgentTemplateService agentTemplateService;
// 单独用 normal 权限(而非类级 superAdmin)是有意保留:
// 普通用户也需查询可用模板,与 /page 的管理入口分离,保持原行为契约。
@GetMapping
@Operation(summary = "智能体模板列表")
@RequiresPermissions("sys:role:normal")
public Result<List<AgentTemplateEntity>> getAgentTemplates() {
List<AgentTemplateEntity> list = agentTemplateService
.list(new QueryWrapper<AgentTemplateEntity>().orderByAsc("sort"));
return new Result<List<AgentTemplateEntity>>().ok(list);
}
@GetMapping("/page")
@Operation(summary = "获取模板分页列表")
@RequiresPermissions("sys:role:superAdmin")
@@ -5,6 +5,7 @@ import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.collection.ListUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -20,7 +21,6 @@ import xiaozhi.common.constant.Constant;
import xiaozhi.common.page.PageData;
import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.common.utils.ToolUtil;
import xiaozhi.modules.agent.Enums.AgentChatHistoryType;
import xiaozhi.modules.agent.dao.AiAgentChatHistoryDao;
import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
@@ -107,7 +107,7 @@ public class AgentChatHistoryServiceImpl extends CrudRepository<AiAgentChatHisto
if (deleteAudio) {
// 分批删除音频,避免超时
List<String> audioIds = baseMapper.getAudioIdsByAgentId(agentId);
if (ToolUtil.isNotEmpty(audioIds)) {
if (CollUtil.isNotEmpty(audioIds)) {
// 每批删除1000条
List<List<String>> batch = ListUtil.split(audioIds, 1000);
batch.forEach(dataList -> {
@@ -12,11 +12,11 @@ import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import cn.hutool.crypto.digest.DigestUtil;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.utils.AESUtils;
import xiaozhi.common.utils.HashEncryptionUtil;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.modules.agent.Enums.XiaoZhiMcpJsonRpcJson;
import xiaozhi.modules.agent.service.AgentMcpAccessPointService;
@@ -226,7 +226,7 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
*/
private static String encryptToken(String agentId, String key) {
// 使用md5对智能体id进行加密
String md5 = HashEncryptionUtil.Md5hexDigest(agentId);
String md5 = DigestUtil.md5Hex(agentId);
// aes需要加密文本
String json = "{\"agentId\": \"%s\"}".formatted(md5);
// 加密后成token值
@@ -18,6 +18,7 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.repository.IRepository;
import cn.hutool.core.collection.CollUtil;
import lombok.AllArgsConstructor;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.ErrorCode;
@@ -29,7 +30,6 @@ import xiaozhi.common.service.impl.BaseServiceImpl;
import xiaozhi.common.user.UserDetail;
import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.common.utils.ToolUtil;
import xiaozhi.modules.agent.dao.AgentDao;
import xiaozhi.modules.agent.dao.AgentTagDao;
import xiaozhi.modules.agent.dto.AgentCreateDTO;
@@ -243,13 +243,13 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
.map(DeviceEntity::getAgentId)
.distinct()
.collect(Collectors.toList());
if (ToolUtil.isNotEmpty(agentIds)) {
if (CollUtil.isNotEmpty(agentIds)) {
w.or().in("id", agentIds);
}
// 按标签名搜索
List<String> tagAgentIds = agentTagService.getAgentIdsByTagName(keyword);
if (ToolUtil.isNotEmpty(tagAgentIds)) {
if (CollUtil.isNotEmpty(tagAgentIds)) {
w.or().in("id", tagAgentIds);
}
});
@@ -291,7 +291,7 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
// 获取标签列表
List<AgentTagEntity> tags = agentTagDao.selectByAgentId(agent.getId());
if (ToolUtil.isNotEmpty(tags)) {
if (CollUtil.isNotEmpty(tags)) {
dto.setTags(tags.stream().map(this::convertTagToDTO).collect(Collectors.toList()));
}
@@ -174,6 +174,9 @@ public class DeviceController {
@Operation(summary = "获取设备通讯录")
@RequiresPermissions("sys:role:normal")
public Result<Object> getAddressBook(@PathVariable String macAddress) {
if (deviceService.getOwnedDeviceByMacAddress(macAddress) == null) {
return new Result<Object>().error(ErrorCode.DEVICE_NOT_EXIST);
}
return new Result<Object>().ok(deviceAddressBookService.getAddressBookList(macAddress));
}
@@ -192,9 +195,7 @@ public class DeviceController {
@Operation(summary = "更新设备通讯录别名")
@RequiresPermissions("sys:role:normal")
public Result<Void> updateAlias(@Valid @RequestBody DeviceAddressBookAliasDTO dto) {
UserDetail user = SecurityUser.getUser();
DeviceEntity callerDevice = deviceService.getDeviceByMacAddress(dto.getMacAddress());
if (callerDevice == null || !callerDevice.getUserId().equals(user.getId())) {
if (deviceService.getOwnedDeviceByMacAddress(dto.getMacAddress()) == null) {
return new Result<Void>().error("无权限操作该设备");
}
deviceAddressBookService.saveOrUpdate(dto.getMacAddress(), dto.getTargetMac(), dto.getAlias(), null);
@@ -205,9 +206,7 @@ public class DeviceController {
@Operation(summary = "更新设备通讯录权限")
@RequiresPermissions("sys:role:normal")
public Result<Void> updatePermission(@Valid @RequestBody DeviceAddressBookPermissionDTO dto) {
UserDetail user = SecurityUser.getUser();
DeviceEntity callerDevice = deviceService.getDeviceByMacAddress(dto.getMacAddress());
if (callerDevice == null || !callerDevice.getUserId().equals(user.getId())) {
if (deviceService.getOwnedDeviceByMacAddress(dto.getMacAddress()) == null) {
return new Result<Void>().error("无权限操作该设备");
}
deviceAddressBookService.saveOrUpdate(dto.getMacAddress(), dto.getTargetMac(), null, dto.getHasPermission());
@@ -5,8 +5,6 @@ import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -31,6 +29,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import cn.hutool.crypto.digest.DigestUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
@@ -288,7 +287,7 @@ public class OTAMagController {
// 返回文件路径
return new Result<String>().ok(filePath.toString());
} catch (IOException | NoSuchAlgorithmException e) {
} catch (IOException e) {
return new Result<String>().error("文件上传失败:" + e.getMessage());
}
}
@@ -329,13 +328,7 @@ public class OTAMagController {
return result;
}
private String calculateMD5(MultipartFile file) throws IOException, NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(file.getBytes());
StringBuilder sb = new StringBuilder();
for (byte b : digest) {
sb.append(String.format("%02x", b));
}
return sb.toString();
private String calculateMD5(MultipartFile file) throws IOException {
return DigestUtil.md5Hex(file.getBytes());
}
}
@@ -83,6 +83,16 @@ public interface DeviceService extends BaseService<DeviceEntity> {
*/
DeviceEntity getDeviceByMacAddress(String macAddress);
/**
* 根据MAC地址获取当前登录用户名下的设备信息
* <p>
* 设备不存在或不属于当前用户时返回null,调用方无需再自行比对归属。
*
* @param macAddress MAC地址
* @return 当前用户名下的设备信息,不存在或无权限时返回null
*/
DeviceEntity getOwnedDeviceByMacAddress(String macAddress);
/**
* 根据设备ID获取激活码
*
@@ -1,9 +1,13 @@
package xiaozhi.modules.device.service.impl;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
@@ -18,6 +22,7 @@ import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.device.dao.DeviceAddressBookDao;
import xiaozhi.modules.device.entity.DeviceAddressBookEntity;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.service.DeviceAddressBookService;
import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.sys.service.SysParamsService;
@@ -40,7 +45,11 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
@Override
public List<DeviceAddressBookEntity> getAddressBookList(String macAddress) {
return deviceAddressBookDao.getAddressBookList(macAddress);
List<DeviceAddressBookEntity> rows = assignUniqueAliases(deviceAddressBookDao.getAddressBookList(macAddress));
// 取名按固定顺序算完了,前端要看的是最近改过的排前面,放回去
rows.sort(Comparator
.comparing(DeviceAddressBookEntity::getUpdateDate, Comparator.nullsLast(Comparator.reverseOrder())));
return rows;
}
@Override
@@ -63,7 +72,7 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
}
// 主动呼叫模式
Map<String, String> callerBook = allBooks.get(callerMac.toLowerCase());
Map<String, String> callerBook = allBooks.get(bookKey(callerMac));
if (callerBook == null) {
return errorResult("未找到备注为'" + nickname + "'的设备");
}
@@ -80,13 +89,16 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
}
// 获取目标设备如何称呼主叫方
Map<String, String> targetBook = allBooks.get(targetMac.toLowerCase());
Map<String, String> targetBook = allBooks.get(bookKey(targetMac));
String callerNickname = null;
if (targetBook != null) {
callerNickname = targetBook.get(callerMac.toLowerCase());
callerNickname = targetBook.get(bookKey(callerMac));
}
if (StringUtils.isBlank(callerNickname)) {
callerNickname = deviceService.getDeviceByMacAddress(callerMac).getAlias();
DeviceEntity callerDevice = deviceService.getDeviceByMacAddress(callerMac);
if (callerDevice != null) {
callerNickname = callerDevice.getAlias();
}
if (StringUtils.isBlank(callerNickname)) {
callerNickname = formatMacAsDeviceName(callerMac);
}
@@ -100,32 +112,26 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
@Override
public void refreshCache() {
Map<String, Map<String, String>> result = new HashMap<>();
List<DeviceAddressBookEntity> allRecords = deviceAddressBookDao.selectList(null);
List<DeviceAddressBookEntity> allRecords = assignUniqueAliases(deviceAddressBookDao.selectList(null));
Map<String, String> reverseMap = new HashMap<>();
for (DeviceAddressBookEntity entity : allRecords) {
String macA = entity.getMacAddress().toLowerCase();
String macB = entity.getTargetMac().toLowerCase();
String alias = entity.getAlias();
Boolean hasPermission = entity.getHasPermission();
String macA = bookKey(entity.getMacAddress());
String macB = bookKey(entity.getTargetMac());
// 构建正向映射: A对B的映射 nickname -> macB|permission
if (alias != null && !alias.isEmpty()) {
result.computeIfAbsent(macA, k -> new HashMap<>());
String permStr = (hasPermission != null && hasPermission) ? "1" : "0";
result.get(macA).put(alias, macB + "|" + permStr);
}
Map<String, String> book = result.computeIfAbsent(macA, k -> new HashMap<>());
String permStr = (Boolean.TRUE.equals(entity.getHasPermission())) ? "1" : "0";
book.put(entity.getAlias(), macB + "|" + permStr);
// 构建反向记录用于查找B对A的称呼
if (alias != null && !alias.isEmpty()) {
reverseMap.put(macB + ":" + macA, alias);
}
// 构建反向记录用于查找B对A的称呼,用同一个去重后的名字,两边才对得上
reverseMap.put(macB + ":" + macA, entity.getAlias());
}
// 构建反向映射: B对A的映射 macA -> nickname
for (DeviceAddressBookEntity entity : allRecords) {
String macA = entity.getMacAddress().toLowerCase();
String macB = entity.getTargetMac().toLowerCase();
String macA = bookKey(entity.getMacAddress());
String macB = bookKey(entity.getTargetMac());
String aliasBtoA = reverseMap.get(macA + ":" + macB);
if (aliasBtoA != null && !aliasBtoA.isEmpty()) {
result.computeIfAbsent(macB, k -> new HashMap<>());
@@ -136,6 +142,54 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
redisUtils.set(RedisKeys.getAddressBookKey(), result);
}
/**
* 给每行算出一个不重名的称呼,返回排好序、alias 已写好的新列表
*
* 昵称在同一个人的通讯录里是索引,同名后写的会盖掉先写的,按昵称呼会呼错设备。
* 库里本来就有重名:空别名补出来的尾号称呼只有 256 种取值,一本通讯录十几条
* 没起名就撞上了。所以显示和呼叫都得走这一份,
* 不然前端看到的字和呼叫时用的名字对不上,用户照着点的名字呼不到人。
* 不改传入的那个列表:它是 DAO 刚查出来的,调用方还要按自己的顺序用
*/
private List<DeviceAddressBookEntity> assignUniqueAliases(List<DeviceAddressBookEntity> rows) {
// 取名要靠先后定谁拿原名,而 selectList 不带 order by,返回顺序不保证。
// 排一次,同样的数据每次算出来的结果一样,缓存不会来回变。
// 按归一后的地址排:同一个人不管库里是哪种写法都排到一起,
// 后面两级比较器再分先后。同一本里 target_mac 不会重复(主键摆着),
// 所以顺序还是由这三个键定死的,不会掉回"跟着数据库返回顺序走"
List<DeviceAddressBookEntity> ordered = new ArrayList<>(rows);
ordered.sort(Comparator
.comparing((DeviceAddressBookEntity row) -> bookKey(row.getMacAddress()),
Comparator.nullsFirst(Comparator.naturalOrder()))
// 权限排在地址前面:撞名时让对方准许呼我的那台先拿原名。
// 排在后面等于不起作用,两条地址不同时谁先谁后是地址定的,
// 没权限那条照样占住原名,用户照前端看到的名字呼叫命中的就是它,
// 界面上只会说没权限,怎么点都不通
.thenComparing(DeviceAddressBookEntity::getHasPermission,
Comparator.nullsLast(Comparator.reverseOrder()))
.thenComparing(DeviceAddressBookEntity::getTargetMac, Comparator.nullsFirst(Comparator.naturalOrder())));
Map<String, Set<String>> takenByOwner = new HashMap<>();
for (DeviceAddressBookEntity row : ordered) {
// 建表时 alias 默认是空的,用户没起名就是空的,这是默认状态不是异常。
// 补个尾号称呼顶上,整条跳过的话接口照样返回成功,对方却再也呼不过来
String alias = StringUtils.isBlank(row.getAlias())
? formatMacAsDeviceName(row.getTargetMac())
: row.getAlias();
// 加后缀的写法跟 generateUniqueAlias 保持一致,不搞两套命名规则
Set<String> taken = takenByOwner.computeIfAbsent(bookKey(row.getMacAddress()), k -> new HashSet<>());
String uniqueAlias = alias;
int suffix = 1;
while (taken.contains(uniqueAlias)) {
uniqueAlias = alias + suffix++;
}
taken.add(uniqueAlias);
row.setAlias(uniqueAlias);
}
return ordered;
}
@Override
public void updateAlias(String macAddress, String targetMac, String alias) {
String finalAlias = generateUniqueAlias(macAddress, targetMac, alias);
@@ -154,14 +208,22 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
QueryWrapper<DeviceAddressBookEntity> wrapper = new QueryWrapper<>();
wrapper.eq("mac_address", macAddress).eq("target_mac", targetMac);
DeviceAddressBookEntity record = deviceAddressBookDao.selectOne(wrapper);
System.out.println("saveOrUpdate - mac=" + macAddress + ", targetMac=" + targetMac + ", alias=" + alias + ", record=" + (record == null ? "null" : "not null"));
if (record == null) {
DeviceAddressBookEntity entity = new DeviceAddressBookEntity();
entity.setMacAddress(macAddress);
entity.setTargetMac(targetMac);
// 如果 alias 为空,就默认使用设备名称
// 如果 alias 为空,就默认使用设备名称;对方设备尚未登记时退回尾号称呼
if (StringUtils.isBlank(alias)) {
alias = deviceService.getDeviceByMacAddress(targetMac).getAlias();
DeviceEntity targetDevice = deviceService.getDeviceByMacAddress(targetMac);
if (targetDevice != null) {
alias = targetDevice.getAlias();
}
// 设备登记了但用户没起过名,alias 建表就是 null,这是默认状态
// 不是异常。这时候落个 null 进去,缓存刷新会把整条跳过,
// 接口照样返回成功,对方却永远呼不过来
if (StringUtils.isBlank(alias)) {
alias = formatMacAsDeviceName(targetMac);
}
}
// 检查重名
alias = generateUniqueAlias(macAddress, targetMac, alias);
@@ -229,11 +291,27 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
}
}
/**
* 通讯录里当主人用的地址键
*
* <p>
* 写缓存、查缓存、算称呼都靠它分组,三处必须是同一个值。少归一一次,同一个人的两行
* 会被当成两个人:各自拿到同名,写进同一本时后者盖掉前者,一台设备直接从缓存里消失。
* 库里确实能出现这种数据——表是 utf8mb4_0900_ai_ci,主键 (mac_address, target_mac)
* 大小写不敏感,owner 只差大小写而 target 不同的话两行不撞
*/
private static String bookKey(String macAddress) {
return macAddress == null ? null : macAddress.toLowerCase();
}
private String formatMacAsDeviceName(String mac) {
if (StringUtils.isBlank(mac) || mac.length() < 2) {
return mac;
}
String lastTwo = mac.substring(mac.length() - 2);
// MAC 本身不区分大小写,落库、查库走的都是小写化的值。
// 这里跟着小写化,不跟着的话同一个地址在两条路径上会算出两个称呼,
// 数据搬到智控台上看到的名字就叫不到人了
String lastTwo = mac.substring(mac.length() - 2).toLowerCase();
return "尾号为" + lastTwo + "的设备";
}
@@ -31,6 +31,7 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.core.util.StrUtil;
@@ -51,7 +52,6 @@ import xiaozhi.common.user.UserDetail;
import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.common.utils.DateUtils;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.common.utils.ToolUtil;
import xiaozhi.modules.device.dao.DeviceDao;
import xiaozhi.modules.device.dto.DeviceManualAddDTO;
import xiaozhi.modules.device.dto.DevicePageUserDTO;
@@ -103,15 +103,15 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
throw new RenException(ErrorCode.ACTIVATION_CODE_EMPTY);
}
String deviceKey = RedisKeys.getOtaActivationCode(activationCode);
Object cacheDeviceId = redisUtils.get(deviceKey);
if (ToolUtil.isEmpty(cacheDeviceId)) {
String cacheDeviceId = (String) redisUtils.get(deviceKey);
if (StringUtils.isBlank(cacheDeviceId)) {
throw new RenException(ErrorCode.ACTIVATION_CODE_ERROR);
}
String deviceId = (String) cacheDeviceId;
String deviceId = cacheDeviceId;
String safeDeviceId = deviceId.replace(":", "_").toLowerCase();
String cacheDeviceKey = RedisKeys.getOtaDeviceActivationInfo(safeDeviceId);
Map<String, Object> cacheMap = JsonUtils.toStringObjectMap(redisUtils.get(cacheDeviceKey));
if (ToolUtil.isEmpty(cacheMap)) {
if (MapUtil.isEmpty(cacheMap)) {
throw new RenException(ErrorCode.ACTIVATION_CODE_ERROR);
}
String cachedCode = (String) cacheMap.get("activation_code");
@@ -181,7 +181,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
.builder(new HashMap<String, Set<String>>())
.put("clientIds", deviceIds).build();
if (ToolUtil.isNotEmpty(deviceIds)) {
if (CollUtil.isNotEmpty(deviceIds)) {
return postToMqttGateway(url, params);
}
// 返回响应
@@ -316,6 +316,12 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
if (device == null) {
return;
}
// 后面删通讯录那步是按 mac_address 删的,不看 user_id。
// 设备本身因为带了 user_id 条件删不掉,通讯录却会照删不误,
// 任意登录用户知道对方的设备 id 就能把对方整本通讯录清空
if (userId == null || device.getUserId() == null || !device.getUserId().equals(userId)) {
return;
}
String macAddress = device.getMacAddress();
if (StringUtils.isNotBlank(device.getAgentId())) {
// 清除智能体设备数量缓存
@@ -400,6 +406,29 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
return baseDao.selectOne(wrapper);
}
@Override
public DeviceEntity getOwnedDeviceByMacAddress(String macAddress) {
if (StringUtils.isBlank(macAddress)) {
return null;
}
// 同一 MAC 可能有两行只差大小写,selectOne 会抛 TooManyResultsException
// 把整个读通讯录接口带崩。按 id 取第一条做归属校验,撞行不报错
QueryWrapper<DeviceEntity> wrapper = new QueryWrapper<>();
wrapper.eq("mac_address", macAddress).orderByAsc("id");
List<DeviceEntity> devices = baseDao.selectList(wrapper);
if (devices == null || devices.isEmpty()) {
return null;
}
DeviceEntity device = devices.get(0);
UserDetail user = SecurityUser.getUser();
// getUser() 在取不到 subject、principal 时返回的是空对象,不是 null,
// 所以这里不会拿到 null。匿名调用走的就是空对象那一条,id 是空的,比不上
if (device.getUserId() == null || !device.getUserId().equals(user.getId())) {
return null;
}
return device;
}
private DeviceReportRespDTO.ServerTime buildServerTime() {
DeviceReportRespDTO.ServerTime serverTime = new DeviceReportRespDTO.ServerTime();
TimeZone tz = TimeZone.getDefault();
@@ -15,6 +15,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import cn.hutool.core.collection.CollUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -23,7 +24,6 @@ import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.page.PageData;
import xiaozhi.common.utils.Result;
import xiaozhi.common.utils.ToolUtil;
import xiaozhi.modules.knowledge.dto.KnowledgeBaseDTO;
import xiaozhi.modules.knowledge.service.KnowledgeBaseService;
import xiaozhi.modules.knowledge.service.KnowledgeManagerService;
@@ -140,7 +140,7 @@ public class KnowledgeBaseController {
List<String> idList = Arrays.asList(ids.split(","));
List<KnowledgeBaseDTO> knowledgeBaseDTOs = Optional.ofNullable(knowledgeBaseService.getByDatasetIdList(idList))
.orElseGet(ArrayList::new);
if (ToolUtil.isNotEmpty(knowledgeBaseDTOs)) {
if (CollUtil.isNotEmpty(knowledgeBaseDTOs)) {
knowledgeBaseDTOs.forEach(item -> {
// 检查权限:用户只能删除自己创建的知识库
if (item.getCreator() == null || !item.getCreator().equals(currentUserId)) {
@@ -160,4 +160,4 @@ public class KnowledgeBaseController {
List<ModelConfigEntity> result = knowledgeBaseService.getRAGModels();
return new Result<List<ModelConfigEntity>>().ok(result);
}
}
}
@@ -36,6 +36,7 @@ public class OpenAIStyleLLMServiceImpl implements LLMService {
static {
THINKING_DISABLED_DOMAINS.put("aliyuncs.com", Map.of("enable_thinking", false));
Map<String, Object> thinkingDisabled = Map.of("thinking", Map.of("type", "disabled"));
THINKING_DISABLED_DOMAINS.put("deepseek.com", thinkingDisabled);
THINKING_DISABLED_DOMAINS.put("bigmodel.cn", thinkingDisabled);
THINKING_DISABLED_DOMAINS.put("moonshot.cn", thinkingDisabled);
THINKING_DISABLED_DOMAINS.put("volces.com", thinkingDisabled);
@@ -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();
}
/**
@@ -86,6 +86,7 @@ public class ShiroConfig {
filterMap.put("/user/captcha", "anon");
filterMap.put("/user/smsVerification", "anon");
filterMap.put("/user/login", "anon");
filterMap.put("/user/refresh", "anon");
filterMap.put("/user/pub-config", "anon");
filterMap.put("/user/register", "anon");
filterMap.put("/user/retrieve-password", "anon");
@@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -110,6 +111,17 @@ public class LoginController {
return sysUserTokenService.createToken(userDTO.getId());
}
@PostMapping("/refresh")
@Operation(summary = "刷新token:用旧token换新token")
public Result<TokenDTO> refresh(HttpServletRequest request) {
String authorization = request.getHeader(Constant.AUTHORIZATION);
if (StringUtils.isBlank(authorization) || !authorization.startsWith("Bearer ")) {
throw new RenException(ErrorCode.UNAUTHORIZED);
}
String oldToken = authorization.replace("Bearer ", "");
return sysUserTokenService.refreshToken(oldToken);
}
@PostMapping("/register")
@Operation(summary = "注册")
public Result<Void> register(@RequestBody LoginDTO login) {
@@ -21,4 +21,14 @@ public interface SysUserTokenDao extends BaseDao<SysUserTokenEntity> {
SysUserTokenEntity getByUserId(Long userId);
void logout(@Param("userId") Long userId, @Param("expireDate") Date expireDate);
/**
* 用旧 token 换新 token 的 CAS 更新:仅当旧 token 未过期且属于指定 user 时才覆盖。
* 返回受影响行数(0 表示条件不满足 = token 已被并发刷新或过期)。
*/
int refreshTokenCAS(@Param("userId") Long userId,
@Param("oldToken") String oldToken,
@Param("newToken") String newToken,
@Param("newExpire") Date newExpire,
@Param("updateDate") Date updateDate);
}
@@ -21,6 +21,13 @@ public interface SysUserTokenService extends BaseService<SysUserTokenEntity> {
*/
Result<TokenDTO> createToken(Long userId);
/**
* 用旧token换新token,旧token立即失效
*
* @param oldToken 旧token字符串
*/
Result<TokenDTO> refreshToken(String oldToken);
SysUserDTO getUserByToken(String token);
/**
@@ -1,8 +1,7 @@
package xiaozhi.modules.security.service.impl;
import java.io.IOException;
import java.util.Random;
import java.util.concurrent.TimeUnit;
import java.time.Duration;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
@@ -13,6 +12,7 @@ import com.google.common.cache.CacheBuilder;
import com.wf.captcha.SpecCaptcha;
import com.wf.captcha.base.Captcha;
import cn.hutool.core.util.RandomUtil;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import xiaozhi.common.constant.Constant;
@@ -41,7 +41,7 @@ public class CaptchaServiceImpl implements CaptchaService {
* Local Cache 5分钟过期
*/
Cache<String, String> localCache = CacheBuilder.newBuilder().maximumSize(1000)
.expireAfterAccess(5, TimeUnit.MINUTES).build();
.expireAfterAccess(Duration.ofMinutes(5)).build();
@Override
public void create(HttpServletResponse response, String uuid) throws IOException {
@@ -113,7 +113,7 @@ public class CaptchaServiceImpl implements CaptchaService {
}
String key = RedisKeys.getSMSValidateCodeKey(phone);
String validateCodes = generateValidateCode(6);
String validateCodes = RandomUtil.randomNumbers(6);
// 设置验证码
setCache(key, validateCodes);
@@ -135,22 +135,6 @@ public class CaptchaServiceImpl implements CaptchaService {
return validate(key, code, delete);
}
/**
* 生成指定数量的随机数验证码
*
* @param length 数量
* @return 随机码
*/
private String generateValidateCode(Integer length) {
String chars = "0123456789"; // 字符范围可以自定义:数字
Random random = new Random();
StringBuilder code = new StringBuilder();
for (int i = 0; i < length; i++) {
code.append(chars.charAt(random.nextInt(chars.length())));
}
return code.toString();
}
private void setCache(String key, String value) {
if (open) {
key = RedisKeys.getCaptchaKey(key);
@@ -180,4 +164,4 @@ public class CaptchaServiceImpl implements CaptchaService {
}
return captcha;
}
}
}
@@ -2,7 +2,9 @@ package xiaozhi.modules.security.service.impl;
import java.util.Date;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import cn.hutool.core.date.DateUtil;
import lombok.AllArgsConstructor;
@@ -81,6 +83,36 @@ public class SysUserTokenServiceImpl extends BaseServiceImpl<SysUserTokenDao, Sy
return new Result<TokenDTO>().ok(tokenDTO);
}
@Override
@Transactional
public Result<TokenDTO> refreshToken(String oldToken) {
if (StringUtils.isBlank(oldToken)) {
throw new RenException(ErrorCode.UNAUTHORIZED);
}
SysUserTokenEntity tokenEntity = baseDao.getByToken(oldToken);
if (tokenEntity == null) {
throw new RenException(ErrorCode.TOKEN_INVALID);
}
if (tokenEntity.getExpireDate().before(new Date())) {
throw new RenException(ErrorCode.UNAUTHORIZED);
}
// CAS 更新:并发场景下只有一个请求能匹配到该 oldToken,另一个返 0 行
String newToken = TokenGenerator.generateValue();
Date now = new Date();
Date expireTime = new Date(now.getTime() + EXPIRE * 1000L);
int affected = baseDao.refreshTokenCAS(tokenEntity.getUserId(), oldToken, newToken, expireTime, now);
if (affected == 0) {
throw new RenException(ErrorCode.TOKEN_INVALID);
}
TokenDTO dto = new TokenDTO();
dto.setToken(newToken);
dto.setExpire(EXPIRE);
dto.setClientHash(HttpContextUtils.getClientCode());
return new Result<TokenDTO>().ok(dto);
}
@Override
public SysUserDTO getUserByToken(String token) {
SysUserTokenEntity userToken = baseDao.getByToken(token);
@@ -12,6 +12,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import cn.hutool.core.collection.CollUtil;
import lombok.AllArgsConstructor;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.exception.ErrorCode;
@@ -21,7 +22,6 @@ import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.service.impl.BaseServiceImpl;
import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.common.utils.ToolUtil;
import xiaozhi.modules.sys.dao.SysDictDataDao;
import xiaozhi.modules.sys.dao.SysUserDao;
import xiaozhi.modules.sys.dto.SysDictDataDTO;
@@ -104,13 +104,13 @@ public class SysDictDataServiceImpl extends BaseServiceImpl<SysDictDataDao, SysD
@Transactional(rollbackFor = Exception.class)
public void delete(Long[] ids) {
List<Long> idList = Arrays.asList(ids);
if (ToolUtil.isNotEmpty(idList)) {
if (CollUtil.isNotEmpty(idList)) {
//批量删除redis字典
List<String> redisKeyList = new ArrayList<>();
//批量获取字典类型
List<String> dictTypeList = Optional.ofNullable(baseDao.getDictTypesByIdList(idList)).orElseGet(ArrayList::new);
dictTypeList.forEach(dictType -> redisKeyList.add(RedisKeys.getDictDataByTypeKey(dictType)));
if (ToolUtil.isNotEmpty(redisKeyList)) {
if (CollUtil.isNotEmpty(redisKeyList)) {
//清除缓存
redisUtils.delete(redisKeyList);
}
@@ -34,7 +34,7 @@ public class TimbreDataDTO {
@Schema(description = "排序")
@Min(value = 0, message = "{sort.number}")
private long sort;
private Long sort;
@Schema(description = "对应 TTS 模型主键")
@NotBlank(message = "{timbre.ttsModelId.require}")
@@ -46,4 +46,4 @@ public class TimbreDataDTO {
@Schema(description = "音频播放地址")
private String voiceDemo;
}
}
@@ -3,6 +3,7 @@ package xiaozhi.modules.timbre.entity;
import java.util.Date;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
@@ -41,7 +42,8 @@ public class TimbreEntity {
private String referenceText;
@Schema(description = "排序")
private long sort;
@TableField(updateStrategy = FieldStrategy.NOT_NULL)
private Long sort;
@Schema(description = "对应 TTS 模型主键")
private String ttsModelId;
@@ -68,4 +70,4 @@ public class TimbreEntity {
@TableField(fill = FieldFill.INSERT)
private Date createDate;
}
}
@@ -99,6 +99,9 @@ public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity>
@Transactional(rollbackFor = Exception.class)
public void save(TimbreDataDTO dto) {
isTtsModelId(dto.getTtsModelId());
if (dto.getSort() == null) {
dto.setSort(0L);
}
TimbreEntity timbreEntity = ConvertUtils.sourceToTarget(dto, TimbreEntity.class);
baseDao.insert(timbreEntity);
}
@@ -32,7 +32,7 @@ public class TimbreDetailsVO implements Serializable {
private String referenceText;
@Schema(description = "排序")
private long sort;
private Long sort;
@Schema(description = "对应 TTS 模型主键")
private String ttsModelId;
@@ -17,6 +17,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import cn.hutool.core.collection.CollUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.constant.Constant;
@@ -26,7 +27,6 @@ import xiaozhi.common.page.PageData;
import xiaozhi.common.service.impl.BaseServiceImpl;
import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.common.utils.DateUtils;
import xiaozhi.common.utils.ToolUtil;
import xiaozhi.modules.model.entity.ModelConfigEntity;
import xiaozhi.modules.model.service.ModelConfigService;
import xiaozhi.modules.sys.dao.SysUserDao;
@@ -120,7 +120,7 @@ public class VoiceCloneServiceImpl extends BaseServiceImpl<VoiceCloneDao, VoiceC
entity.setTrainStatus(0); // 默认训练中
batchInsertList.add(entity);
}
if (ToolUtil.isNotEmpty(batchInsertList)) {
if (CollUtil.isNotEmpty(batchInsertList)) {
insertBatch(batchInsertList);
}
}
@@ -0,0 +1,46 @@
-- liquibase formatted sql
-- 插入新的 hass_state 插件记录(合并 get_state 的配置字段)
INSERT INTO ai_model_provider (id, model_type, provider_code, name, fields,
sort, creator, create_date, updater, update_date)
VALUES ('SYSTEM_PLUGIN_HA_STATE',
'Plugin',
'hass_state',
'HomeAssistant设备控制',
JSON_ARRAY(
JSON_OBJECT(
'key', 'base_url',
'type', 'string',
'label', 'HA 服务器地址',
'default',
(SELECT param_value FROM sys_params WHERE param_code = 'plugins.home_assistant.base_url')
),
JSON_OBJECT(
'key', 'api_key',
'type', 'string',
'label', 'HA API 访问令牌',
'default',
(SELECT param_value FROM sys_params WHERE param_code = 'plugins.home_assistant.api_key')
),
JSON_OBJECT(
'key', 'devices',
'type', 'array',
'label', '设备列表(名称,实体ID;…)',
'default',
(SELECT param_value FROM sys_params WHERE param_code = 'plugins.home_assistant.devices')
)
),
50, 0, NOW(), 0, NOW());
-- 迁移已配置的 agent:把指向 HA_GET_STATE 的改成 HA_STATE(保留参数)
UPDATE ai_agent_plugin_mapping
SET plugin_id = 'SYSTEM_PLUGIN_HA_STATE'
WHERE plugin_id = 'SYSTEM_PLUGIN_HA_GET_STATE';
-- HA_SET_STATE 本来就没有配置字段(fields=[]),直接删除其 mapping
DELETE FROM ai_agent_plugin_mapping
WHERE plugin_id = 'SYSTEM_PLUGIN_HA_SET_STATE';
-- 删除旧的插件定义
DELETE FROM ai_model_provider
WHERE id IN ('SYSTEM_PLUGIN_HA_GET_STATE', 'SYSTEM_PLUGIN_HA_SET_STATE');
@@ -0,0 +1,4 @@
UPDATE `ai_model_config`
SET `config_json` = JSON_SET(`config_json`, '$.model_name', 'deepseek-v4-flash')
WHERE `id` = 'LLM_DeepSeekLLM'
AND JSON_EXTRACT(`config_json`, '$.model_name') = 'deepseek-chat';
@@ -0,0 +1,22 @@
UPDATE `ai_model_config`
SET `config_json` = JSON_SET(`config_json`, '$.model_name', 'doubao-seed-2-0-lite-260215')
WHERE `id` = 'LLM_DoubaoLLM'
AND JSON_UNQUOTE(JSON_EXTRACT(`config_json`, '$.model_name')) IN (
'doubao-1-5-pro-32k-250115'
);
UPDATE `ai_model_config`
SET `remark` = REPLACE(
REPLACE(
`remark`,
'开通Doubao-1.5-pro服务',
'开通 Doubao-Seed-2.0-Lite 服务'
),
'当前建议使用doubao-1-5-pro-32k-250115',
'当前建议使用 doubao-seed-2-0-lite-260215'
)
WHERE `id` = 'LLM_DoubaoLLM'
AND (
`remark` LIKE '%Doubao-1.5-pro%'
OR `remark` LIKE '%doubao-1-5-pro-32k-250115%'
);
@@ -0,0 +1,4 @@
UPDATE `sys_params`
SET `param_value` = '15'
WHERE `param_code` = 'tts_timeout'
AND `param_value` = '10';
@@ -0,0 +1,42 @@
-- 阿里百炼流式 TTS 支持业务空间 WebSocket 地址
UPDATE `ai_model_provider`
SET `fields` = JSON_ARRAY(
JSON_OBJECT('key', 'api_key', 'type', 'string', 'label', 'API密钥'),
JSON_OBJECT(
'key', 'ws_url',
'type', 'string',
'label', 'WebSocket地址(含Workspace ID)',
'default', 'wss://dashscope.aliyuncs.com/api-ws/v1/inference/'
),
JSON_OBJECT('key', 'output_dir', 'type', 'string', 'label', '输出目录'),
JSON_OBJECT('key', 'model', 'type', 'string', 'label', '模型'),
JSON_OBJECT('key', 'voice', 'type', 'string', 'label', '音色'),
JSON_OBJECT('key', 'format', 'type', 'string', 'label', '音频格式'),
JSON_OBJECT('key', 'sample_rate', 'type', 'number', 'label', '采样率'),
JSON_OBJECT('key', 'volume', 'type', 'number', 'label', '音量'),
JSON_OBJECT('key', 'rate', 'type', 'number', 'label', '语速'),
JSON_OBJECT('key', 'pitch', 'type', 'number', 'label', '音调')
)
WHERE `id` = 'SYSTEM_TTS_AliBLStreamTTS';
UPDATE `ai_model_config`
SET `config_json` = JSON_SET(
`config_json`,
'$.ws_url',
COALESCE(
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(`config_json`, '$.ws_url')), ''),
'wss://dashscope.aliyuncs.com/api-ws/v1/inference/'
)
)
WHERE `id` = 'TTS_AliBLStreamTTS';
UPDATE `ai_model_config`
SET `remark` = '阿里百炼流式 TTS 配置说明:
1. API密钥填写当前业务空间可用的 DashScope API Key
2. 公有模式可使用 wss://dashscope.aliyuncs.com/api-ws/v1/inference/
3. 华北2(北京)业务空间填写 wss://<WorkspaceId>.cn-beijing.maas.aliyuncs.com/api-ws/v1/inference
4. 新加坡业务空间填写 wss://<WorkspaceId>.ap-southeast-1.maas.aliyuncs.com/api-ws/v1/inference
5. 将 <WorkspaceId> 替换为真实业务空间 ID;仅支持阿里云官方 WSS 推理地址
6. 支持 CosyVoice 流式合成及音量、语速、音调配置'
WHERE `id` = 'TTS_AliBLStreamTTS';
@@ -0,0 +1,9 @@
-- 联网搜索插件新增Serply搜索源
UPDATE `ai_model_provider`
SET `fields` = JSON_SET(
`fields`,
'$[0].label',
'搜索源:metaso / tavily / serply'
)
WHERE `id` = 'SYSTEM_PLUGIN_WEB_SEARCH';
@@ -0,0 +1,4 @@
-- 添加MCP接入点附带说话人身份的开关参数配置
delete from `sys_params` where param_code = 'mcp_forward_speaker';
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark)
VALUES (313, 'mcp_forward_speaker', 'true', 'boolean', 1, '是否随MCP调用附带说话人身份');
@@ -711,3 +711,52 @@ databaseChangeLog:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202607101200.sql
- changeSet:
id: 202607290930
author: cgd
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202607290930.sql
- changeSet:
id: 202608061000
author: cgd
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202608061030.sql
- changeSet:
id: 202608061800
author: cgd
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202608061800.sql
- changeSet:
id: 202608131030
author: tykechen
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202608131030.sql
- changeSet:
id: 202609021000
author: JacobNg1
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202609021000.sql
- changeSet:
id: 202609172100
author: googio
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202609172100.sql
- changeSet:
id: 202610261030
author: RanChen
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202610261030.sql
@@ -14,4 +14,12 @@
<update id="logout">
update sys_user_token set expire_date = #{expireDate} where user_id = #{userId}
</update>
<update id="refreshTokenCAS">
update sys_user_token
set token = #{newToken}, update_date = #{updateDate}, expire_date = #{newExpire}
where token = #{oldToken}
and expire_date > NOW()
and user_id = #{userId}
</update>
</mapper>
@@ -0,0 +1,83 @@
package xiaozhi.common.utils;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
import cn.hutool.json.JSONObject;
class SensitiveDataUtilsTest {
@Test
void isSensitiveField_recognizes_known_field_names() {
assertTrue(SensitiveDataUtils.isSensitiveField("api_key"));
assertTrue(SensitiveDataUtils.isSensitiveField("API_KEY"));
assertTrue(SensitiveDataUtils.isSensitiveField("personal_access_token"));
assertTrue(SensitiveDataUtils.isSensitiveField("secret"));
assertTrue(SensitiveDataUtils.isSensitiveField("secret_key"));
}
@Test
void isSensitiveField_rejects_blank_and_unknown_field_names() {
assertFalse(SensitiveDataUtils.isSensitiveField(""));
assertFalse(SensitiveDataUtils.isSensitiveField(" "));
assertFalse(SensitiveDataUtils.isSensitiveField(null));
assertFalse(SensitiveDataUtils.isSensitiveField("not_a_secret"));
}
@Test
void maskMiddle_returns_input_unchanged_for_short_or_blank_values() {
assertNull(SensitiveDataUtils.maskMiddle(null));
assertEquals("", SensitiveDataUtils.maskMiddle(""));
assertEquals("a", SensitiveDataUtils.maskMiddle("a"));
}
@Test
void maskMiddle_keeps_two_at_each_end_for_short_strings() {
assertEquals("ab****yz", SensitiveDataUtils.maskMiddle("abcdefyz"));
}
@Test
void maskMiddle_keeps_four_at_each_end_for_long_strings() {
String masked = SensitiveDataUtils.maskMiddle("verylongapikeyvalue123");
assertTrue(masked.startsWith("very"));
assertTrue(masked.endsWith("e123"));
assertTrue(masked.contains("****"));
assertEquals("verylongapikeyvalue123".length(), masked.length());
}
@Test
void isMaskedValue_detects_masked_values() {
assertTrue(SensitiveDataUtils.isMaskedValue("ab****yz"));
assertTrue(SensitiveDataUtils.isMaskedValue("abc****def****ghi"));
assertFalse(SensitiveDataUtils.isMaskedValue("plainstring"));
}
@Test
void isMaskedValue_rejects_blank_values() {
assertFalse(SensitiveDataUtils.isMaskedValue(null));
assertFalse(SensitiveDataUtils.isMaskedValue(""));
assertFalse(SensitiveDataUtils.isMaskedValue(" "));
}
@Test
void maskSensitiveFields_masks_known_sensitive_keys_only() {
JSONObject source = new JSONObject();
source.set("api_key", "sk-supersecretapikey");
source.set("name", "alice");
JSONObject masked = SensitiveDataUtils.maskSensitiveFields(source);
String apiKey = (String) masked.get("api_key");
assertTrue(apiKey.contains("****"));
assertEquals("alice", masked.get("name"));
}
@Test
void maskSensitiveFields_returns_null_when_input_is_null() {
assertNull(SensitiveDataUtils.maskSensitiveFields(null));
}
}
@@ -0,0 +1,148 @@
package xiaozhi.modules.correctword.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import xiaozhi.modules.agent.dao.AgentCorrectWordMappingDao;
import xiaozhi.modules.agent.entity.AgentCorrectWordMappingEntity;
import xiaozhi.modules.correctword.dao.CorrectWordFileDao;
import xiaozhi.modules.correctword.dao.CorrectWordItemDao;
import xiaozhi.modules.correctword.entity.CorrectWordFileEntity;
import xiaozhi.modules.correctword.entity.CorrectWordItemEntity;
import xiaozhi.modules.correctword.vo.CorrectWordFileVO;
import xiaozhi.modules.correctword.vo.CorrectWordSimpleVO;
class CorrectWordFileServiceImplTest {
private static final String AGENT_ID = "agent-1";
private static final String FILE_ID = "file-1";
private final CorrectWordFileDao fileDao = mock(CorrectWordFileDao.class);
private final CorrectWordItemDao itemDao = mock(CorrectWordItemDao.class);
private final AgentCorrectWordMappingDao mappingDao = mock(AgentCorrectWordMappingDao.class);
private final CorrectWordFileServiceImpl service =
new CorrectWordFileServiceImpl(fileDao, itemDao, mappingDao);
@Test
void getAllItemsByAgentId_returns_empty_list_when_no_mappings() {
when(mappingDao.selectByAgentId(AGENT_ID)).thenReturn(Collections.emptyList());
List<CorrectWordSimpleVO> result = service.getAllItemsByAgentId(AGENT_ID);
assertTrue(result.isEmpty());
verify(itemDao, never()).selectList(org.mockito.ArgumentMatchers.any());
}
@Test
void getAllItemsByAgentId_returns_mapped_items_when_mappings_exist() {
AgentCorrectWordMappingEntity mapping = new AgentCorrectWordMappingEntity();
mapping.setFileId(FILE_ID);
when(mappingDao.selectByAgentId(AGENT_ID)).thenReturn(List.of(mapping));
CorrectWordItemEntity item = new CorrectWordItemEntity();
item.setSourceWord("hello");
item.setTargetWord("hi");
when(itemDao.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(item));
List<CorrectWordSimpleVO> result = service.getAllItemsByAgentId(AGENT_ID);
assertEquals(1, result.size());
assertEquals("hello", result.get(0).getSourceWord());
assertEquals("hi", result.get(0).getTargetWord());
}
@Test
void getAllItemsByAgentId_returns_empty_list_when_mappings_are_null() {
when(mappingDao.selectByAgentId(AGENT_ID)).thenReturn(null);
List<CorrectWordSimpleVO> result = service.getAllItemsByAgentId(AGENT_ID);
assertNotNull(result);
assertTrue(result.isEmpty());
}
@Test
void getAgentCorrectWordFileIds_returns_empty_list_when_no_mappings() {
when(mappingDao.selectByAgentId(AGENT_ID)).thenReturn(Collections.emptyList());
List<String> result = service.getAgentCorrectWordFileIds(AGENT_ID);
assertTrue(result.isEmpty());
}
@Test
void getAgentCorrectWordFileIds_returns_file_ids_when_mappings_exist() {
AgentCorrectWordMappingEntity m1 = new AgentCorrectWordMappingEntity();
m1.setFileId("f1");
AgentCorrectWordMappingEntity m2 = new AgentCorrectWordMappingEntity();
m2.setFileId("f2");
when(mappingDao.selectByAgentId(AGENT_ID)).thenReturn(List.of(m1, m2));
List<String> result = service.getAgentCorrectWordFileIds(AGENT_ID);
assertEquals(List.of("f1", "f2"), result);
}
@Test
void getFileContent_returns_null_when_entity_not_found() {
when(fileDao.selectById(FILE_ID)).thenReturn(null);
CorrectWordFileVO result = service.getFileContent(FILE_ID);
assertNull(result);
}
@Test
void getFileContent_returns_vo_when_entity_found() {
CorrectWordFileEntity entity = new CorrectWordFileEntity();
entity.setId(FILE_ID);
entity.setFileName("dict.txt");
entity.setWordCount(2);
entity.setContent("hello|hi\nbye|goodbye");
entity.setCreatedAt(new Date());
when(fileDao.selectById(FILE_ID)).thenReturn(entity);
CorrectWordFileVO result = service.getFileContent(FILE_ID);
assertNotNull(result);
assertEquals(FILE_ID, result.getId());
assertEquals("dict.txt", result.getFileName());
assertEquals(2, result.getWordCount().intValue());
assertEquals(2, result.getContent().size());
assertEquals("hello|hi", result.getContent().get(0));
}
@Test
void deleteMappingsByAgentId_delegates_to_dao() {
service.deleteMappingsByAgentId(AGENT_ID);
verify(mappingDao).deleteByAgentId(AGENT_ID);
}
@Test
void batchDeleteFiles_skips_null_and_empty_ids() {
List<String> ids = new ArrayList<>();
ids.add(null);
ids.add("");
ids.add(" ");
ids.add(FILE_ID);
service.batchDeleteFiles(ids);
verify(fileDao).deleteById(FILE_ID);
}
}
@@ -1,11 +1,15 @@
package xiaozhi.modules.device.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
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.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
@@ -15,7 +19,10 @@ import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.user.UserDetail;
import xiaozhi.common.utils.MessageUtils;
import xiaozhi.common.utils.Result;
import xiaozhi.modules.device.dto.DeviceAddressBookAliasDTO;
import xiaozhi.modules.device.dto.DeviceAddressBookPermissionDTO;
import xiaozhi.modules.device.dto.DeviceUpdateDTO;
import xiaozhi.modules.device.entity.DeviceAddressBookEntity;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.service.DeviceAddressBookService;
import xiaozhi.modules.device.service.DeviceService;
@@ -26,6 +33,8 @@ import xiaozhi.modules.sys.service.SysParamsService;
class DeviceControllerTest {
private static final String DEVICE_ID = "device-id";
private static final String MAC = "00:11:22:33:44:55";
private static final String OTHER_MAC = "00:11:22:33:44:66";
private static final long USER_ID = 1L;
@Test
@@ -35,7 +44,7 @@ class DeviceControllerTest {
DeviceEntity entity = ownedDevice();
when(deviceService.selectById(DEVICE_ID)).thenReturn(entity);
when(deviceService.updateById(entity)).thenReturn(false);
DeviceController controller = controller(deviceService);
DeviceController controller = controller(deviceService, mock(DeviceAddressBookService.class));
DeviceUpdateDTO update = new DeviceUpdateDTO();
update.setAutoUpdate(0);
@@ -54,10 +63,84 @@ class DeviceControllerTest {
}
}
private DeviceController controller(DeviceService deviceService) {
@Test
@DisplayName("读取他人设备通讯录时一律按设备不存在处理")
void addressBookOfDeviceOwnedByAnotherUserIsRejected() {
DeviceService deviceService = mock(DeviceService.class);
DeviceAddressBookService addressBookService = mock(DeviceAddressBookService.class);
when(deviceService.getOwnedDeviceByMacAddress(OTHER_MAC)).thenReturn(null);
DeviceController controller = controller(deviceService, addressBookService);
try (MockedStatic<MessageUtils> messageUtils = mockStatic(MessageUtils.class)) {
messageUtils.when(() -> MessageUtils.getMessage(ErrorCode.DEVICE_NOT_EXIST))
.thenReturn("Device not exist");
Result<Object> result = controller.getAddressBook(OTHER_MAC);
assertEquals(ErrorCode.DEVICE_NOT_EXIST, result.getCode());
assertNull(result.getData());
verify(addressBookService, never()).getAddressBookList(OTHER_MAC);
}
}
@Test
@DisplayName("读取本机设备通讯录照常返回")
void addressBookOfOwnDeviceIsReturned() {
DeviceService deviceService = mock(DeviceService.class);
DeviceAddressBookService addressBookService = mock(DeviceAddressBookService.class);
List<DeviceAddressBookEntity> entries = List.of(new DeviceAddressBookEntity());
when(deviceService.getOwnedDeviceByMacAddress(MAC)).thenReturn(ownedDevice());
when(addressBookService.getAddressBookList(MAC)).thenReturn(entries);
DeviceController controller = controller(deviceService, addressBookService);
Result<Object> result = controller.getAddressBook(MAC);
assertEquals(0, result.getCode());
assertEquals(entries, result.getData());
}
@Test
@DisplayName("以他人设备名义改别名被拒")
void updateAliasOnDeviceOwnedByAnotherUserIsRejected() {
DeviceService deviceService = mock(DeviceService.class);
DeviceAddressBookService addressBookService = mock(DeviceAddressBookService.class);
when(deviceService.getOwnedDeviceByMacAddress(OTHER_MAC)).thenReturn(null);
DeviceController controller = controller(deviceService, addressBookService);
DeviceAddressBookAliasDTO dto = new DeviceAddressBookAliasDTO();
dto.setMacAddress(OTHER_MAC);
dto.setTargetMac(MAC);
dto.setAlias("office");
Result<Void> result = controller.updateAlias(dto);
assertEquals("无权限操作该设备", result.getMsg());
verify(addressBookService, never()).saveOrUpdate(OTHER_MAC, MAC, "office", null);
}
@Test
@DisplayName("以他人设备名义改权限被拒")
void updatePermissionOnDeviceOwnedByAnotherUserIsRejected() {
DeviceService deviceService = mock(DeviceService.class);
DeviceAddressBookService addressBookService = mock(DeviceAddressBookService.class);
when(deviceService.getOwnedDeviceByMacAddress(OTHER_MAC)).thenReturn(null);
DeviceController controller = controller(deviceService, addressBookService);
DeviceAddressBookPermissionDTO dto = new DeviceAddressBookPermissionDTO();
dto.setMacAddress(OTHER_MAC);
dto.setTargetMac(MAC);
dto.setHasPermission(true);
Result<Void> result = controller.updatePermission(dto);
assertEquals("无权限操作该设备", result.getMsg());
verify(addressBookService, never()).saveOrUpdate(OTHER_MAC, MAC, null, true);
}
private DeviceController controller(DeviceService deviceService, DeviceAddressBookService addressBookService) {
return new DeviceController(
deviceService,
mock(DeviceAddressBookService.class),
addressBookService,
mock(RedisUtils.class),
mock(SysParamsService.class));
}
@@ -1,26 +1,47 @@
package xiaozhi.modules.device.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
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.Date;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.device.dao.DeviceAddressBookDao;
import xiaozhi.modules.device.entity.DeviceAddressBookEntity;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.sys.service.SysParamsService;
class DeviceAddressBookServiceImplTest {
private static final String MAC = "00:11:22:33:44:55";
private static final String OTHER_MAC = "00:11:22:33:44:66";
private static final String THIRD_MAC = "00:11:22:33:44:77";
// 纯数字的 MAC 大小写化后跟原来一模一样,拿它造不出"只差大小写"的两行
private static final String HEX_MAC = "00:11:22:33:44:ab";
@Test
void newEntryUsesTheDedicatedAddressBookInsertMapping() {
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
@@ -32,15 +53,363 @@ class DeviceAddressBookServiceImplTest {
when(addressBookDao.selectOne(any())).thenReturn(null);
when(addressBookDao.selectList(null)).thenReturn(List.of());
service.saveOrUpdate("00:11:22:33:44:55", "00:11:22:33:44:66", "living-room", true);
service.saveOrUpdate(MAC, OTHER_MAC, "living-room", true);
ArgumentCaptor<DeviceAddressBookEntity> entityCaptor = ArgumentCaptor.forClass(DeviceAddressBookEntity.class);
verify(addressBookDao).insertAddressBook(entityCaptor.capture());
DeviceAddressBookEntity entity = entityCaptor.getValue();
assertEquals("00:11:22:33:44:55", entity.getMacAddress());
assertEquals("00:11:22:33:44:66", entity.getTargetMac());
assertEquals(MAC, entity.getMacAddress());
assertEquals(OTHER_MAC, entity.getTargetMac());
assertEquals("living-room", entity.getAlias());
assertTrue(entity.getHasPermission());
verify(addressBookDao, never()).insert(any(DeviceAddressBookEntity.class));
}
@Test
void blankAliasOnUnregisteredTargetFallsBackToMacSuffix() {
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
RedisUtils redisUtils = mock(RedisUtils.class);
DeviceService deviceService = mock(DeviceService.class);
SysParamsService sysParamsService = mock(SysParamsService.class);
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
addressBookDao, redisUtils, deviceService, sysParamsService);
when(addressBookDao.selectOne(any())).thenReturn(null);
when(addressBookDao.selectList(null)).thenReturn(List.of());
when(deviceService.getDeviceByMacAddress(OTHER_MAC)).thenReturn(null);
service.saveOrUpdate(MAC, OTHER_MAC, null, true);
ArgumentCaptor<DeviceAddressBookEntity> entityCaptor = ArgumentCaptor.forClass(DeviceAddressBookEntity.class);
verify(addressBookDao).insertAddressBook(entityCaptor.capture());
DeviceAddressBookEntity entity = entityCaptor.getValue();
assertNotNull(entity.getAlias());
assertEquals("尾号为66的设备", entity.getAlias());
assertTrue(entity.getHasPermission());
}
@Test
void blankAliasOnRegisteredTargetWithoutNicknameFallsBackToMacSuffix() {
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
RedisUtils redisUtils = mock(RedisUtils.class);
DeviceService deviceService = mock(DeviceService.class);
SysParamsService sysParamsService = mock(SysParamsService.class);
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
addressBookDao, redisUtils, deviceService, sysParamsService);
when(addressBookDao.selectOne(any())).thenReturn(null);
when(addressBookDao.selectList(null)).thenReturn(List.of());
DeviceEntity registered = new DeviceEntity();
registered.setMacAddress(OTHER_MAC);
registered.setAlias(null);
when(deviceService.getDeviceByMacAddress(OTHER_MAC)).thenReturn(registered);
service.saveOrUpdate(MAC, OTHER_MAC, null, true);
ArgumentCaptor<DeviceAddressBookEntity> entityCaptor = ArgumentCaptor.forClass(DeviceAddressBookEntity.class);
verify(addressBookDao).insertAddressBook(entityCaptor.capture());
assertEquals("尾号为66的设备", entityCaptor.getValue().getAlias(),
"登记了但没起名的设备落个空名,缓存刷新会跳过这条,对方永远呼不过来");
}
@Test
void refreshCacheKeepsEntriesWhoseAliasWasNeverFilledIn() {
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
RedisUtils redisUtils = mock(RedisUtils.class);
when(addressBookDao.selectList(null)).thenReturn(List.of(row(MAC, OTHER_MAC, null)));
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
addressBookDao, redisUtils, mock(DeviceService.class), mock(SysParamsService.class));
service.refreshCache();
Map<String, String> book = cachedBook(service, redisUtils).get(MAC);
assertNotNull(book, "空别名整条被跳过,这条设备就再也呼不过来了");
assertEquals(OTHER_MAC + "|1", book.get("尾号为66的设备"));
}
@Test
void refreshCacheDisambiguatesTwoEntriesSharingAnAlias() {
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
RedisUtils redisUtils = mock(RedisUtils.class);
when(addressBookDao.selectList(null)).thenReturn(List.of(
row(MAC, OTHER_MAC, "客厅"),
row(MAC, THIRD_MAC, "客厅")));
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
addressBookDao, redisUtils, mock(DeviceService.class), mock(SysParamsService.class));
service.refreshCache();
Map<String, String> book = cachedBook(service, redisUtils).get(MAC);
assertEquals(2, book.size(), "撞名的两台设备只留得下一台:" + book);
assertEquals(OTHER_MAC + "|1", book.get("客厅"));
assertEquals(THIRD_MAC + "|1", book.get("客厅1"));
}
@Test
void refreshCacheGivesTheSameResultWhateverOrderRowsComeBackIn() {
DeviceAddressBookEntity first = row(MAC, OTHER_MAC, "客厅");
DeviceAddressBookEntity second = row(MAC, THIRD_MAC, "客厅");
Map<String, String> inOrder = refreshAndRead(List.of(first, second));
Map<String, String> reversed = refreshAndRead(List.of(second, first));
assertEquals(inOrder, reversed, "同样的数据两次刷新结果不一样,缓存里的称呼会来回变");
}
/**
* 库里能同时存在两条 owner 只差大小写的行:表是 utf8mb4_0900_ai_ci,
* 主键 (mac_address, target_mac) 大小写不敏感,target 不同就不会撞。
* 这是同一个人,算称呼和写缓存都得按小写归成一本
*/
@Test
void ownersDifferingOnlyInCaseShareOneBook() {
String upper = HEX_MAC.toUpperCase();
assertNotEquals(HEX_MAC, upper, "这个用例得真的造出大小写差异才有效");
Map<String, Map<String, String>> books = refreshAndReadAll(List.of(
row(upper, OTHER_MAC, "客厅"),
row(HEX_MAC, THIRD_MAC, "客厅")));
Map<String, String> book = books.get(HEX_MAC);
assertNotNull(book, "同一个人被算成了两个人:" + books.keySet());
assertEquals(2, book.size(), "两台设备只剩一台,另一台从缓存里消失了:" + book);
assertEquals(OTHER_MAC + "|1", book.get("客厅"));
assertEquals(THIRD_MAC + "|1", book.get("客厅1"));
assertEquals(1, books.size(), "两种写法的地址被拆成了两本:" + books.keySet());
}
@Test
void listAndCacheAgreeWhenOwnersDifferOnlyInCase() {
String upper = HEX_MAC.toUpperCase();
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
RedisUtils redisUtils = mock(RedisUtils.class);
when(addressBookDao.getAddressBookList(upper)).thenReturn(new ArrayList<>(List.of(
row(upper, OTHER_MAC, "客厅"), row(HEX_MAC, THIRD_MAC, "客厅"))));
when(addressBookDao.selectList(null)).thenReturn(List.of(
row(upper, OTHER_MAC, "客厅"), row(HEX_MAC, THIRD_MAC, "客厅")));
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
addressBookDao, redisUtils, mock(DeviceService.class), mock(SysParamsService.class));
Map<String, String> shownToConsole = namesByTargetMac(service.getAddressBookList(upper));
assertEquals(2, shownToConsole.size(),
"接口按原值分组,同一个人的两台设备被算成一个,前端只能看到一台:" + shownToConsole);
service.refreshCache();
Map<String, String> callableNames = new HashMap<>();
cachedBook(service, redisUtils).get(HEX_MAC)
.forEach((name, macAndPerm) -> callableNames.put(name, macAndPerm.split("\\|")[0]));
assertEquals(shownToConsole, callableNames,
"接口按原值分组、缓存按小写分组,用户照前端看到的名字呼,命中的不是同一台设备");
}
@Test
void nameAssignmentDoesNotDependOnWhichSpellingOfTheOwnerCameBack() {
String upper = HEX_MAC.toUpperCase();
Map<String, String> expected = Map.of("客厅", OTHER_MAC + "|1", "客厅1", THIRD_MAC + "|1");
Map<String, String> lowerFirst = refreshAndRead(HEX_MAC, List.of(
row(HEX_MAC, OTHER_MAC, "客厅"), row(upper, THIRD_MAC, "客厅")));
Map<String, String> upperFirst = refreshAndRead(HEX_MAC, List.of(
row(upper, THIRD_MAC, "客厅"), row(HEX_MAC, OTHER_MAC, "客厅")));
assertEquals(expected, lowerFirst,
"同一个人的称呼取决于库里哪种写法先返回,刷新一次就换一次名字");
assertEquals(expected, upperFirst, "顺序反过来结果就该不一样:" + upperFirst);
}
@SuppressWarnings("unchecked")
private Map<String, Map<String, String>> refreshAndReadAll(List<DeviceAddressBookEntity> rows) {
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
RedisUtils redisUtils = mock(RedisUtils.class);
when(addressBookDao.selectList(null)).thenReturn(rows);
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
addressBookDao, redisUtils, mock(DeviceService.class), mock(SysParamsService.class));
service.refreshCache();
return cachedBook(service, redisUtils);
}
private Map<String, String> refreshAndRead(List<DeviceAddressBookEntity> rows) {
return refreshAndRead(MAC, rows);
}
private Map<String, String> refreshAndRead(String owner, List<DeviceAddressBookEntity> rows) {
return refreshAndReadAll(rows).get(owner);
}
@SuppressWarnings("unchecked")
private Map<String, Map<String, String>> cachedBook(DeviceAddressBookServiceImpl service, RedisUtils redisUtils) {
ArgumentCaptor<Object> captor = ArgumentCaptor.forClass(Object.class);
verify(redisUtils).set(eq(RedisKeys.getAddressBookKey()), captor.capture());
return (Map<String, Map<String, String>>) captor.getValue();
}
private DeviceAddressBookEntity row(String macAddress, String targetMac, String alias) {
return row(macAddress, targetMac, alias, Boolean.TRUE);
}
private DeviceAddressBookEntity row(String macAddress, String targetMac, String alias, Boolean hasPermission) {
DeviceAddressBookEntity entity = new DeviceAddressBookEntity();
entity.setMacAddress(macAddress);
entity.setTargetMac(targetMac);
entity.setAlias(alias);
entity.setHasPermission(hasPermission);
return entity;
}
@Test
void listAndCacheHandOutTheSameNameForCollidingEntries() {
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
RedisUtils redisUtils = mock(RedisUtils.class);
// 两次 DAO 调用各给一份新行:取名会写回实体,共用同一份的话第二遍读到的是算过的名字
// 用可变列表,跟 MyBatis 实际返回的一致,免得排序那步先炸掉、断言根本没跑到
when(addressBookDao.getAddressBookList(MAC)).thenReturn(
new ArrayList<>(List.of(row(MAC, OTHER_MAC, "客厅"), row(MAC, THIRD_MAC, "客厅"))));
when(addressBookDao.selectList(null)).thenReturn(List.of(
row(MAC, OTHER_MAC, "客厅"), row(MAC, THIRD_MAC, "客厅")));
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
addressBookDao, redisUtils, mock(DeviceService.class), mock(SysParamsService.class));
Map<String, String> shownToConsole = namesByTargetMac(service.getAddressBookList(MAC));
service.refreshCache();
Map<String, String> callableNames = new HashMap<>();
cachedBook(service, redisUtils).get(MAC)
.forEach((name, macAndPerm) -> callableNames.put(name, macAndPerm.split("\\|")[0]));
assertEquals(callableNames, shownToConsole,
"接口和缓存给的是两个名字,用户照前端看到的那个名字呼叫,命中的不是同一台设备");
}
@Test
void theCallableEntryKeepsThePlainNameWhenTwoCollide() {
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
RedisUtils redisUtils = mock(RedisUtils.class);
// OTHER_MAC 的 target_mac 排在前面,没权限的那条会先占住原名
when(addressBookDao.selectList(null)).thenReturn(List.of(
row(MAC, OTHER_MAC, "客厅", Boolean.FALSE),
row(MAC, THIRD_MAC, "客厅", Boolean.TRUE)));
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
addressBookDao, redisUtils, mock(DeviceService.class), mock(SysParamsService.class));
service.refreshCache();
Map<String, String> book = cachedBook(service, redisUtils).get(MAC);
assertEquals(THIRD_MAC + "|1", book.get("客厅"),
"能呼的那台被挤到后缀名上了,用户照前端看到的名字点,界面只会说没权限");
assertEquals(OTHER_MAC + "|0", book.get("客厅1"));
}
@Test
void listFillsInBlankAliasesSoTheConsoleShowsWhatCanBeCalled() {
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
RedisUtils redisUtils = mock(RedisUtils.class);
when(addressBookDao.getAddressBookList(MAC)).thenReturn(new ArrayList<>(List.of(row(MAC, OTHER_MAC, null))));
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
addressBookDao, redisUtils, mock(DeviceService.class), mock(SysParamsService.class));
List<DeviceAddressBookEntity> rows = service.getAddressBookList(MAC);
assertEquals("尾号为66的设备", rows.get(0).getAlias(),
"空别名原样返回的话前端回落显示设备名,用户照着那个名字呼叫却只会说找不到备注");
}
@Test
void anUppercaseMacGetsTheSameNameWhicheverPathAddsItToTheBook() {
// 兜底称呼只有这一处算法:存进去的时候算一次,之后接口和缓存里显示的
// 都是同一个字符串。不跟着小写化的话同一个地址在两条路径上算出两个称呼,
// 用户照着看到的名字呼过去,命中的不是自己刚存的那台
//
// 运行期这一侧:saveOrUpdate 传的是 DTO 原样过来的地址,大小写没归一
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
RedisUtils redisUtils = mock(RedisUtils.class);
DeviceService deviceService = mock(DeviceService.class);
when(addressBookDao.selectOne(any())).thenReturn(null);
when(addressBookDao.selectList(null)).thenReturn(List.of());
when(deviceService.getDeviceByMacAddress(anyString())).thenReturn(null);
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
addressBookDao, redisUtils, deviceService, mock(SysParamsService.class));
service.saveOrUpdate(MAC, "ff:ee:dd:cc:bb:AB", null, true);
ArgumentCaptor<DeviceAddressBookEntity> entityCaptor = ArgumentCaptor.forClass(DeviceAddressBookEntity.class);
verify(addressBookDao).insertAddressBook(entityCaptor.capture());
assertEquals("尾号为ab的设备", entityCaptor.getValue().getAlias(),
"MAC 大小写不区分,末尾两位要跟着小写化,否则接口和呼叫那边算出来的称呼对不上");
}
/**
* 接口按最近改过的排前面
*
* <p>
* 智控台那条 Vue 是按 targetMac 把 alias 合进 allDevices 的,接口顺序被丢掉了,
* 这条现在对 console 没有效果。用例留着是把这个契约钉住:哪天当无用代码删了,
* 会有用例报出来,而不是静悄悄换掉接口的返回顺序
*/
@Test
void listPutsTheMostRecentlyTouchedEntryFirst() {
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
RedisUtils redisUtils = mock(RedisUtils.class);
DeviceAddressBookEntity older = row(MAC, OTHER_MAC, "客厅");
older.setUpdateDate(new Date(1767321840000L));
DeviceAddressBookEntity newer = row(MAC, THIRD_MAC, "书房");
newer.setUpdateDate(new Date(1778000000000L));
DeviceAddressBookEntity neverTouched = row(MAC, "00:11:22:33:44:88", "阳台");
when(addressBookDao.getAddressBookList(MAC)).thenReturn(
new ArrayList<>(List.of(older, neverTouched, newer)));
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
addressBookDao, redisUtils, mock(DeviceService.class), mock(SysParamsService.class));
List<DeviceAddressBookEntity> rows = service.getAddressBookList(MAC);
assertEquals(List.of("书房", "客厅", "阳台"), rows.stream().map(DeviceAddressBookEntity::getAlias).toList(),
"最近改过的应该排前面,没改过的排最后");
}
private Map<String, String> namesByTargetMac(List<DeviceAddressBookEntity> rows) {
Map<String, String> byAlias = new HashMap<>();
for (DeviceAddressBookEntity row : rows) {
byAlias.put(row.getAlias(), row.getTargetMac());
}
return byAlias;
}
@Test
void callByNicknameFallsBackToMacSuffixWhenCallerHasNoAlias() {
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
RedisUtils redisUtils = mock(RedisUtils.class);
DeviceService deviceService = mock(DeviceService.class);
SysParamsService sysParamsService = mock(SysParamsService.class);
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
addressBookDao, redisUtils, deviceService, sysParamsService);
Map<String, Map<String, String>> books = new HashMap<>();
Map<String, String> callerBook = new HashMap<>();
callerBook.put("office", OTHER_MAC + "|1");
books.put(MAC, callerBook);
when(redisUtils.get(RedisKeys.getAddressBookKey())).thenReturn(books);
when(deviceService.getDeviceByMacAddress(MAC)).thenReturn(null);
when(sysParamsService.getValue("server.mqtt_manager_api", true)).thenReturn("127.0.0.1:1883");
when(sysParamsService.getValue(Constant.SERVER_MQTT_SECRET, true)).thenReturn("gateway-key");
// 网关是真的 http 调用,桩掉它,否则测试会往 127.0.0.1:1883 真发请求
AtomicReference<String> sentUrl = new AtomicReference<>();
AtomicReference<String> sentBody = new AtomicReference<>();
Map<String, Object> result;
try (MockedStatic<MqttGatewayAuthorization> gateway = mockStatic(MqttGatewayAuthorization.class)) {
gateway.when(() -> MqttGatewayAuthorization.postJson(
anyString(), anyString(), anyString(), any(), anyInt()))
.thenAnswer(invocation -> {
sentUrl.set(invocation.getArgument(0));
sentBody.set(invocation.getArgument(1));
return "{\"status\":\"success\",\"message\":\"ok\"}";
});
result = service.callByNickname(MAC, "office", false);
}
assertEquals("success", result.get("status"));
assertEquals("http://127.0.0.1:1883/api/call/request", sentUrl.get());
assertTrue(sentBody.get().contains("尾号为55的设备"),
"主叫方没登记也没起名时该退回尾号称呼:" + sentBody.get());
}
}
@@ -0,0 +1,184 @@
package xiaozhi.modules.device.service.impl;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
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.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.user.UserDetail;
import xiaozhi.modules.device.dao.DeviceDao;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.service.DeviceAddressBookService;
import xiaozhi.modules.device.service.OtaService;
import xiaozhi.modules.security.user.SecurityUser;
import xiaozhi.modules.sys.service.SysParamsService;
import xiaozhi.modules.sys.service.SysUserUtilService;
@DisplayName("设备归属校验回归测试")
class DeviceServiceImplTest {
private static final String MAC = "00:11:22:33:44:55";
private static final long USER_ID = 1L;
private static final long OTHER_USER_ID = 2L;
@Test
@DisplayName("设备属于当前用户时返回设备")
void deviceOwnedByCurrentUserIsReturned() {
DeviceEntity device = device(USER_ID);
DeviceServiceImpl service = serviceWithDeviceDao(daoReturning(device));
try (MockedStatic<SecurityUser> securityUser = mockStatic(SecurityUser.class)) {
securityUser.when(SecurityUser::getUser).thenReturn(user(USER_ID));
assertSame(device, service.getOwnedDeviceByMacAddress(MAC));
}
}
@Test
@DisplayName("设备属于他人时按查不到处理")
void deviceOwnedByAnotherUserIsHidden() {
DeviceServiceImpl service = serviceWithDeviceDao(daoReturning(device(OTHER_USER_ID)));
try (MockedStatic<SecurityUser> securityUser = mockStatic(SecurityUser.class)) {
securityUser.when(SecurityUser::getUser).thenReturn(user(USER_ID));
assertNull(service.getOwnedDeviceByMacAddress(MAC));
}
}
@Test
@DisplayName("设备未登记时按查不到处理")
void unregisteredDeviceIsHidden() {
DeviceServiceImpl service = serviceWithDeviceDao(daoReturning(List.of()));
try (MockedStatic<SecurityUser> securityUser = mockStatic(SecurityUser.class)) {
securityUser.when(SecurityUser::getUser).thenReturn(user(USER_ID));
assertNull(service.getOwnedDeviceByMacAddress(MAC));
}
}
@Test
@DisplayName("设备没有归属人时按查不到处理")
void unboundDeviceIsHidden() {
DeviceServiceImpl service = serviceWithDeviceDao(daoReturning(device(null)));
try (MockedStatic<SecurityUser> securityUser = mockStatic(SecurityUser.class)) {
securityUser.when(SecurityUser::getUser).thenReturn(user(USER_ID));
assertNull(service.getOwnedDeviceByMacAddress(MAC));
}
}
@Test
@DisplayName("没有登录用户时按查不到处理")
void anonymousCallerGetsNothing() {
DeviceServiceImpl service = serviceWithDeviceDao(daoReturning(device(USER_ID)));
// 匿名调用的实际形态:getUser() 返回空对象,id 是空的,不是返回 null
try (MockedStatic<SecurityUser> securityUser = mockStatic(SecurityUser.class)) {
securityUser.when(SecurityUser::getUser).thenReturn(new UserDetail());
assertNull(service.getOwnedDeviceByMacAddress(MAC));
}
}
@Test
@DisplayName("库里同一 MAC 有两行只差大小写时,取第一条做归属校验,不再抛异常")
void duplicateMacDifferingOnlyInCaseStillReturnsTheDevice() {
DeviceEntity first = device(USER_ID, "device-1");
DeviceEntity second = device(USER_ID, "device-2");
DeviceServiceImpl service = serviceWithDeviceDao(daoReturning(List.of(first, second)));
try (MockedStatic<SecurityUser> securityUser = mockStatic(SecurityUser.class)) {
securityUser.when(SecurityUser::getUser).thenReturn(user(USER_ID));
assertSame(first, service.getOwnedDeviceByMacAddress(MAC));
}
}
@Test
@DisplayName("解绑自己的设备时,通讯录跟着一起清")
void unbindOwnDeviceClearsItsAddressBook() {
DeviceDao dao = mock(DeviceDao.class);
when(dao.selectById("device-1")).thenReturn(device(USER_ID, "device-1"));
DeviceAddressBookService addressBookService = mock(DeviceAddressBookService.class);
DeviceServiceImpl service = serviceWithDao(dao, addressBookService);
service.unbindDevice(USER_ID, "device-1");
verify(dao).delete(any(UpdateWrapper.class));
verify(addressBookService).deleteByMacAddresses(List.of(MAC));
}
@Test
@DisplayName("解绑别人的设备时什么都不删,通讯录不能跟着没")
void unbindAnotherUsersDeviceDeletesNothing() {
DeviceDao dao = mock(DeviceDao.class);
when(dao.selectById("device-1")).thenReturn(device(OTHER_USER_ID, "device-1"));
DeviceAddressBookService addressBookService = mock(DeviceAddressBookService.class);
DeviceServiceImpl service = serviceWithDao(dao, addressBookService);
service.unbindDevice(USER_ID, "device-1");
verify(dao, never()).delete(any(UpdateWrapper.class));
verify(addressBookService, never()).deleteByMacAddresses(anyList());
}
private DeviceServiceImpl serviceWithDao(DeviceDao dao, DeviceAddressBookService addressBookService) {
// baseDao 由父类持有,子类内部赋给 this,测试类自己碰不到它
return new DeviceServiceImpl(
dao, mock(SysUserUtilService.class), mock(SysParamsService.class),
mock(RedisUtils.class), mock(OtaService.class), addressBookService) {
{
this.baseDao = dao;
}
};
}
private DeviceServiceImpl serviceWithDeviceDao(DeviceDao dao) {
return serviceWithDao(dao, mock(DeviceAddressBookService.class));
}
private DeviceDao daoReturning(DeviceEntity device) {
return daoReturning(List.of(device));
}
private DeviceDao daoReturning(List<DeviceEntity> rows) {
DeviceDao dao = mock(DeviceDao.class);
when(dao.selectList(any())).thenReturn(rows);
return dao;
}
private DeviceEntity device(Long userId) {
return device(userId, null);
}
private DeviceEntity device(Long userId, String id) {
DeviceEntity entity = new DeviceEntity();
entity.setId(id);
entity.setMacAddress(MAC);
entity.setUserId(userId);
return entity;
}
private UserDetail user(long id) {
UserDetail user = new UserDetail();
user.setId(id);
return user;
}
}
@@ -0,0 +1,233 @@
package xiaozhi.modules.knowledge.rag;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mockStatic;
import java.util.Map;
import java.util.Set;
import java.util.function.Consumer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.page.PageData;
import xiaozhi.common.utils.MessageUtils;
import xiaozhi.modules.knowledge.dto.KnowledgeFilesDTO;
import xiaozhi.modules.knowledge.dto.dataset.DatasetDTO;
import xiaozhi.modules.knowledge.dto.document.ChunkDTO;
import xiaozhi.modules.knowledge.dto.document.DocumentDTO;
import xiaozhi.modules.knowledge.dto.document.RetrievalDTO;
class KnowledgeBaseAdapterFactoryTest {
private static final String REGISTERED_TYPE = "kb-test-registered";
private static final String UNKNOWN_TYPE = "kb-test-unknown";
@BeforeEach
void resetCache() {
KnowledgeBaseAdapterFactory.clearCache();
}
@AfterEach
void cleanupCache() {
KnowledgeBaseAdapterFactory.clearCache();
KnowledgeBaseAdapterFactory.removeCacheByType(REGISTERED_TYPE);
}
@Test
void getAdapter_returns_non_null_instance_for_registered_type() {
KnowledgeBaseAdapterFactory.registerAdapter(REGISTERED_TYPE, StubAdapter.class);
KnowledgeBaseAdapter adapter = KnowledgeBaseAdapterFactory.getAdapter(REGISTERED_TYPE);
assertNotNull(adapter);
assertEquals(REGISTERED_TYPE, adapter.getAdapterType());
}
@Test
void getAdapter_returns_cached_instance_for_repeated_calls() {
KnowledgeBaseAdapterFactory.registerAdapter(REGISTERED_TYPE, StubAdapter.class);
KnowledgeBaseAdapter first = KnowledgeBaseAdapterFactory.getAdapter(REGISTERED_TYPE);
KnowledgeBaseAdapter second = KnowledgeBaseAdapterFactory.getAdapter(REGISTERED_TYPE);
assertSame(first, second);
}
@Test
void getAdapter_throws_RenException_for_unknown_type() {
try (MockedStatic<MessageUtils> messageUtils = mockStatic(MessageUtils.class)) {
messageUtils.when(() -> MessageUtils.getMessage(anyInt())).thenReturn("mocked message");
messageUtils.when(() -> MessageUtils.getMessage(anyInt(), any(String[].class)))
.thenReturn("mocked message");
RenException ex = assertThrows(RenException.class,
() -> KnowledgeBaseAdapterFactory.getAdapter(UNKNOWN_TYPE));
assertEquals(ErrorCode.RAG_ADAPTER_TYPE_NOT_SUPPORTED, ex.getCode());
}
}
@Test
void isAdapterTypeRegistered_returns_true_for_registered_types() {
KnowledgeBaseAdapterFactory.registerAdapter(REGISTERED_TYPE, StubAdapter.class);
assertTrue(KnowledgeBaseAdapterFactory.isAdapterTypeRegistered(REGISTERED_TYPE));
assertTrue(KnowledgeBaseAdapterFactory.isAdapterTypeRegistered("ragflow"));
assertFalse(KnowledgeBaseAdapterFactory.isAdapterTypeRegistered(UNKNOWN_TYPE));
}
@Test
void getRegisteredAdapterTypes_contains_all_registered_types() {
KnowledgeBaseAdapterFactory.registerAdapter(REGISTERED_TYPE, StubAdapter.class);
Set<String> types = KnowledgeBaseAdapterFactory.getRegisteredAdapterTypes();
assertTrue(types.contains(REGISTERED_TYPE));
assertTrue(types.contains("ragflow"));
}
@Test
void clearCache_empties_cached_instance_count() {
KnowledgeBaseAdapterFactory.registerAdapter(REGISTERED_TYPE, StubAdapter.class);
KnowledgeBaseAdapterFactory.getAdapter(REGISTERED_TYPE);
KnowledgeBaseAdapterFactory.clearCache();
Map<String, Object> status = KnowledgeBaseAdapterFactory.getFactoryStatus();
assertEquals(0, status.get("cachedAdapterCount"));
}
/**
* Test stub adapter that satisfies the abstract API surface so the factory's
* reflection-based instantiation works in the unit test.
*/
public static class StubAdapter extends KnowledgeBaseAdapter {
@Override
public String getAdapterType() {
return REGISTERED_TYPE;
}
@Override
public void initialize(Map<String, Object> config) {
}
@Override
public boolean validateConfig(Map<String, Object> config) {
return true;
}
@Override
public PageData<KnowledgeFilesDTO> getDocumentList(String datasetId,
DocumentDTO.ListReq req) {
return new PageData<>(java.util.Collections.emptyList(), 0);
}
@Override
public DocumentDTO.InfoVO getDocumentById(String datasetId, String documentId) {
return null;
}
@Override
public KnowledgeFilesDTO uploadDocument(DocumentDTO.UploadReq req) {
return null;
}
@Override
public PageData<KnowledgeFilesDTO> getDocumentListByStatus(String datasetId,
Integer status, Integer page, Integer limit) {
return new PageData<>(java.util.Collections.emptyList(), 0);
}
@Override
public void deleteDocument(String datasetId, DocumentDTO.BatchIdReq req) {
}
@Override
public boolean parseDocuments(String datasetId, java.util.List<String> documentIds) {
return true;
}
@Override
public ChunkDTO.ListVO listChunks(String datasetId, String documentId,
ChunkDTO.ListReq req) {
return null;
}
@Override
public RetrievalDTO.ResultVO retrievalTest(RetrievalDTO.TestReq req) {
return null;
}
@Override
public boolean testConnection() {
return true;
}
@Override
public Map<String, Object> getStatus() {
return java.util.Collections.emptyMap();
}
@Override
public Map<String, Object> getSupportedConfig() {
return java.util.Collections.emptyMap();
}
@Override
public Map<String, Object> getDefaultConfig() {
return java.util.Collections.emptyMap();
}
@Override
public DatasetDTO.InfoVO createDataset(DatasetDTO.CreateReq req) {
return null;
}
@Override
public DatasetDTO.InfoVO updateDataset(String datasetId, DatasetDTO.UpdateReq req) {
return null;
}
@Override
public DatasetDTO.BatchOperationVO deleteDataset(DatasetDTO.BatchIdReq req) {
return null;
}
@Override
public Integer getDocumentCount(String datasetId) {
return 0;
}
@Override
public DatasetDTO.InfoVO getDatasetInfo(String datasetId) {
return null;
}
@Override
public void postStream(String endpoint, Object body, Consumer<String> onData) {
}
@Override
public Object postSearchBotAsk(Map<String, Object> config, Object body,
Consumer<String> onData) {
return null;
}
@Override
public void postAgentBotCompletion(Map<String, Object> config, String agentId,
Object body, Consumer<String> onData) {
}
}
}
@@ -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;
}
}
@@ -2,16 +2,20 @@ package xiaozhi.modules.timbre.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.timbre.dao.TimbreDao;
import xiaozhi.modules.timbre.dto.TimbreDataDTO;
import xiaozhi.modules.timbre.entity.TimbreEntity;
import xiaozhi.modules.timbre.vo.TimbreDetailsVO;
import xiaozhi.modules.voiceclone.dao.VoiceCloneDao;
import xiaozhi.modules.voiceclone.entity.VoiceCloneEntity;
@@ -54,4 +58,74 @@ class TimbreServiceImplTest {
assertNull(service.getDefaultLanguageById("voice-id"));
}
@Test
void updateLeavesSortOutOfTheUpdateWhenRequestOmitsIt() {
TimbreDao timbreDao = mock(TimbreDao.class);
RedisUtils redisUtils = mock(RedisUtils.class);
TimbreServiceImpl service = new TimbreServiceImpl(timbreDao, mock(VoiceCloneDao.class), redisUtils);
ReflectionTestUtils.setField(service, "baseDao", timbreDao);
TimbreDataDTO dto = validTimbreData();
service.update("voice-id", dto);
verify(timbreDao, never()).selectById("voice-id");
verify(timbreDao).updateById(argThat((TimbreEntity entity) ->
"voice-id".equals(entity.getId()) && entity.getSort() == null));
verify(redisUtils).delete("timbre:details:voice-id");
}
@Test
void updateUsesExplicitSortWithoutLoadingExistingTimbre() {
TimbreDao timbreDao = mock(TimbreDao.class);
TimbreServiceImpl service = new TimbreServiceImpl(
timbreDao, mock(VoiceCloneDao.class), mock(RedisUtils.class));
ReflectionTestUtils.setField(service, "baseDao", timbreDao);
TimbreDataDTO dto = validTimbreData();
dto.setSort(0L);
service.update("voice-id", dto);
verify(timbreDao, never()).selectById("voice-id");
verify(timbreDao).updateById(argThat((TimbreEntity entity) -> entity.getSort() == 0L));
}
@Test
void saveDefaultsOmittedSortToZero() {
TimbreDao timbreDao = mock(TimbreDao.class);
TimbreServiceImpl service = new TimbreServiceImpl(
timbreDao, mock(VoiceCloneDao.class), mock(RedisUtils.class));
ReflectionTestUtils.setField(service, "baseDao", timbreDao);
service.save(validTimbreData());
verify(timbreDao).insert(argThat((TimbreEntity entity) ->
"测试音色".equals(entity.getName()) && entity.getSort() == 0L));
}
@Test
void getSupportsLegacyRowsWithNullSort() {
TimbreDao timbreDao = mock(TimbreDao.class);
RedisUtils redisUtils = mock(RedisUtils.class);
TimbreServiceImpl service = new TimbreServiceImpl(
timbreDao, mock(VoiceCloneDao.class), redisUtils);
ReflectionTestUtils.setField(service, "baseDao", timbreDao);
TimbreEntity entity = new TimbreEntity();
entity.setId("voice-id");
entity.setSort(null);
when(timbreDao.selectById("voice-id")).thenReturn(entity);
TimbreDetailsVO details = service.get("voice-id");
assertNull(details.getSort());
}
private TimbreDataDTO validTimbreData() {
TimbreDataDTO dto = new TimbreDataDTO();
dto.setLanguages("中文");
dto.setName("测试音色");
dto.setTtsModelId("TTS_Test");
dto.setTtsVoice("test-voice");
return dto;
}
}
+1 -1
View File
@@ -69,7 +69,7 @@
"build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
"build:quickapp-webview-union": "uni build -p quickapp-webview-union",
"type-check": "vue-tsc --noEmit",
"test:snapshot": "node --test src/pages/agent/components/agentSnapshotUtils.test.mjs src/pages/agent/components/agentSnapshotContracts.test.mjs",
"test:snapshot": "node --test src/pages/agent/components/agentSnapshotUtils.test.mjs src/pages/agent/components/agentSnapshotContracts.test.mjs src/pages/agent/components/voicePreviewUtils.test.mjs src/pages/device/deviceTimeUtils.test.mjs",
"openapi-ts-request": "openapi-ts",
"prepare": "git init && husky",
"lint": "eslint",
+16 -2
View File
@@ -8,6 +8,7 @@ import type {
ModelOption,
PageData,
RoleTemplate,
TtsVoice,
} from './types'
import { http } from '@/http/request/alova'
@@ -89,7 +90,7 @@ export function deleteAgent(id: string) {
// 获取TTS音色列表
export function getTTSVoices(ttsModelId: string, voiceName: string = '') {
return http.Get<{ id: string, name: string }[]>(`/models/${ttsModelId}/voices`, {
return http.Get<TtsVoice[]>(`/models/${ttsModelId}/voices`, {
params: {
voiceName,
},
@@ -214,7 +215,7 @@ export function updateAgentTags(agentId: string, data) {
// 获取所有语言
export function getAllLanguage(modelId: string) {
return http.Get<{ id: string, name: string, languages: string }[]>(`/models/${modelId}/voices`, {
return http.Get<TtsVoice[]>(`/models/${modelId}/voices`, {
meta: {
ignoreAuth: false,
toast: false,
@@ -225,6 +226,19 @@ export function getAllLanguage(modelId: string) {
})
}
/**
* 获取克隆音色的临时播放ID
* @param cloneId 克隆音色记录ID
*/
export function getVoiceCloneAudioId(cloneId: string) {
return http.Post<string>(`/voiceClone/audio/${cloneId}`, {}, {
meta: {
ignoreAuth: false,
toast: false,
},
})
}
// 获取智能体历史版本列表
export function getAgentSnapshots(agentId: string, params: AgentSnapshotPageParams) {
return http.Get<PageData<AgentSnapshot>>(`/agent/${agentId}/snapshots`, {
@@ -114,6 +114,14 @@ export interface CorrectWordFile {
wordCount?: number
}
export interface TtsVoice {
id: string
name: string
voiceDemo?: string | null
languages?: string | null
isClone?: boolean | null
}
// 角色模板数据类型
export interface RoleTemplate {
id: string
+1 -1
View File
@@ -7,7 +7,7 @@ export interface Device {
id: string
userId: string
macAddress: string
lastConnectedAt: string
lastConnectedAtTimestamp: string | null
autoUpdate: number
board: string
alias?: string
@@ -8,10 +8,10 @@
- 劣势:只能使用 2 组图片来切换选中和非选中状态,修改颜色只能重新换图片(或者用 iconfont)。
- 2 `有缓存自定义 tabbar`,使用 `switchTab` 切换 tabbar,`tabbar` 页面有缓存。使用了第三方 UI 库的 `tabbar` 组件,并隐藏了原生 `tabbar` 的显示。
- 优势:可以随意配置自己想要的 `svg icon`,切换字体颜色方便。有缓存。可以实现各种花里胡哨的动效等。
- 劣势:首次点击 tababr 会闪烁。
- 劣势:首次点击 tabbar 会闪烁。
- 3 `无缓存自定义 tabbar`,使用 `navigateTo` 切换 `tabbar`,`tabbar` 页面无缓存。使用了第三方 UI 库的 `tabbar` 组件。
- 优势:可以随意配置自己想要的 svg icon,切换字体颜色方便。可以实现各种花里胡哨的动效等。
- 劣势:首次点击 `tababr` 会闪烁,无缓存。
- 劣势:首次点击 `tabbar` 会闪烁,无缓存。
> 注意:花里胡哨的效果需要自己实现,本模版不提供。
@@ -0,0 +1,42 @@
/** @param {Record<string, any>} voice */
export function hasVoicePreview(voice) {
return Boolean(voice?.isClone || voice?.voiceDemo || voice?.voice_demo)
}
export function createVoicePreviewRequestGate() {
let sequence = 0
return {
begin() {
sequence += 1
return sequence
},
invalidate() {
sequence += 1
},
isCurrent(requestId) {
return requestId === sequence
},
}
}
/**
* @param {{ id: string, isClone?: boolean, voiceDemo?: string | null }} voice
* @param {(cloneId: string) => Promise<string>} getCloneAudioId
* @param {string} baseUrl
*/
export async function resolveVoicePreviewUrl(voice, getCloneAudioId, baseUrl) {
if (!voice?.isClone) {
return typeof voice?.voiceDemo === 'string' ? voice.voiceDemo : ''
}
if (!voice.id) {
return ''
}
const uuid = await getCloneAudioId(voice.id)
if (!uuid) {
return ''
}
return `${baseUrl.replace(/\/+$/, '')}/voiceClone/play/${encodeURIComponent(uuid)}`
}
@@ -0,0 +1,50 @@
/* eslint-disable test/no-import-node-test -- this zero-dependency gate intentionally uses Node's built-in runner */
import assert from 'node:assert/strict'
import test from 'node:test'
import { createVoicePreviewRequestGate, hasVoicePreview, resolveVoicePreviewUrl } from './voicePreviewUtils.mjs'
test('keeps normal voice previews on their direct URL', async () => {
let cloneRequestCount = 0
const url = await resolveVoicePreviewUrl({
id: 'normal-voice',
isClone: false,
voiceDemo: 'https://cdn.example.test/normal.wav',
}, async () => {
cloneRequestCount += 1
return 'unused'
}, 'https://api.example.test')
assert.equal(url, 'https://cdn.example.test/normal.wav')
assert.equal(cloneRequestCount, 0)
})
test('uses the clone record id to obtain and construct a temporary play URL', async () => {
let requestedCloneId = ''
const url = await resolveVoicePreviewUrl({
id: 'clone-record-id',
isClone: true,
voiceDemo: 'provider-speaker-id-must-not-be-played',
}, async (cloneId) => {
requestedCloneId = cloneId
return 'temporary uuid'
}, 'https://api.example.test/')
assert.equal(requestedCloneId, 'clone-record-id')
assert.equal(url, 'https://api.example.test/voiceClone/play/temporary%20uuid')
})
test('shows a preview control for cloned voices even without voiceDemo', () => {
assert.equal(hasVoicePreview({ id: 'clone-record-id', isClone: true }), true)
assert.equal(hasVoicePreview({ id: 'normal-voice', isClone: false, voiceDemo: '' }), false)
})
test('invalidates an older request when the same voice is cancelled and retried', () => {
const gate = createVoicePreviewRequestGate()
const firstRequest = gate.begin()
gate.invalidate()
const retryRequest = gate.begin()
assert.equal(gate.isCurrent(firstRequest), false)
assert.equal(gate.isCurrent(retryRequest), true)
})
+83 -29
View File
@@ -1,12 +1,14 @@
<script lang="ts" setup>
import type { AgentDetail, ModelOption, PluginDefinition, RoleTemplate } from '@/api/agent/types'
import type { AgentDetail, ModelOption, PluginDefinition, RoleTemplate, TtsVoice } from '@/api/agent/types'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { getAgentDetail, getAgentTags, getAllLanguage, getModelOptions, getPluginFunctions, getRoleTemplates, updateAgent } from '@/api/agent/agent'
import { getAgentDetail, getAgentTags, getAllLanguage, getModelOptions, getPluginFunctions, getRoleTemplates, getVoiceCloneAudioId, updateAgent } from '@/api/agent/agent'
import { t } from '@/i18n'
import { usePluginStore, useProvider, useSpeedPitch } from '@/store'
import { getEnvBaseUrl } from '@/utils'
import { toast } from '@/utils/toast'
import AgentSnapshotPanel from './components/AgentSnapshotPanel.vue'
import { filterTtsVoicesByLanguage, hasUsableTtsVoiceMetadata } from './components/agentSnapshotUtils.mjs'
import { createVoicePreviewRequestGate, hasVoicePreview, resolveVoicePreviewUrl } from './components/voicePreviewUtils.mjs'
defineOptions({
name: 'AgentEdit',
@@ -84,10 +86,20 @@ const modelOptions = ref<{
TTS: [],
})
interface VoiceOption {
id?: string
value: string
name: string
voiceDemo?: string | null
voice_demo?: string | null
isClone: boolean
train_status?: number
}
// 音色选项数据
const voiceOptions = ref<any[]>([])
const voiceOptions = ref<VoiceOption[]>([])
// 保存完整的音色信息
const voiceDetails = ref<Record<string, any>>({})
const voiceDetails = ref<Record<string, TtsVoice>>({})
// 上报模式选项数据
const reportOptions = [
@@ -139,6 +151,7 @@ interface SnapshotRestoreContext {
// 音频播放相关
const audioRef = ref<UniApp.InnerAudioContext | null>(null)
const playingVoiceId = ref<string>('')
const voicePreviewRequestGate = createVoicePreviewRequestGate()
// 使用插件store
const pluginStore = usePluginStore()
@@ -513,8 +526,8 @@ interface TtsSelectionState {
languageTouched: boolean
voiceTouched: boolean
optionsModelId: string
voiceOptions: any[]
voiceDetails: Record<string, any>
voiceOptions: VoiceOption[]
voiceDetails: Record<string, TtsVoice>
languageOptions: any[]
displayNames: {
tts: string
@@ -565,7 +578,7 @@ function filterVoicesByLanguage(options: VoiceSelectionOptions = {}) {
return
}
const allVoices = Object.values(voiceDetails.value) as any[]
const allVoices = Object.values(voiceDetails.value)
// 根据选中的语言筛选音色
const filteredVoices = filterTtsVoicesByLanguage(allVoices, selectedTtsLanguage.value)
@@ -624,7 +637,7 @@ async function fetchAllLanguag(ttsModelId: string, options: VoiceSelectionOption
throw new Error('No TTS voice metadata is available')
}
// 保存完整的音色信息
voiceDetails.value = res.reduce((acc, voice) => {
voiceDetails.value = res.reduce<Record<string, TtsVoice>>((acc, voice) => {
acc[voice.id] = voice
return acc
}, {})
@@ -863,44 +876,85 @@ function onPickerCancel(type: string) {
}
// 播放音频
function playAudio(voiceDemo: string, voiceId: string, event: Event) {
async function playAudio(voice: VoiceOption, event: Event) {
event.stopPropagation() // 阻止事件冒泡,防止关闭下拉框
if (!voiceDemo) {
if (!hasVoicePreview(voice)) {
return
}
// 如果正在播放同一个音频,则停止
if (playingVoiceId.value === voiceId) {
if (playingVoiceId.value === voice.value) {
stopAudio()
return
}
// 停止之前的音频
stopAudio()
const requestId = voicePreviewRequestGate.begin()
playingVoiceId.value = voice.value
// 创建新的音频实例
audioRef.value = uni.createInnerAudioContext()
audioRef.value.src = voiceDemo
playingVoiceId.value = voiceId
try {
const audioUrl = await resolveVoicePreviewUrl({
id: voice.value,
isClone: voice.isClone,
voiceDemo: voice.voiceDemo || voice.voice_demo,
}, getVoiceCloneAudioId, getEnvBaseUrl())
// 监听播放结束
audioRef.value.onEnded(() => {
// 用户可能在等待克隆音色临时地址时取消或切换了音色。
if (!voicePreviewRequestGate.isCurrent(requestId) || playingVoiceId.value !== voice.value) {
return
}
if (!audioUrl) {
toast.error(t('voiceprint.getAudioFailed'))
playingVoiceId.value = ''
return
}
// 创建新的音频实例
const audio = uni.createInnerAudioContext()
audioRef.value = audio
audio.src = audioUrl
// 监听播放结束
audio.onEnded(() => {
if (
voicePreviewRequestGate.isCurrent(requestId)
&& audioRef.value === audio
&& playingVoiceId.value === voice.value
) {
playingVoiceId.value = ''
}
})
// 监听播放错误
audio.onError(() => {
if (
voicePreviewRequestGate.isCurrent(requestId)
&& audioRef.value === audio
&& playingVoiceId.value === voice.value
) {
toast.error(t('voiceprint.audioPlayFailed'))
playingVoiceId.value = ''
}
})
// 播放音频
audio.play()
}
catch (error) {
if (!voicePreviewRequestGate.isCurrent(requestId) || playingVoiceId.value !== voice.value) {
return
}
console.error('获取克隆音色试听地址失败:', error)
toast.error(t('voiceprint.getAudioFailed'))
playingVoiceId.value = ''
})
// 监听播放错误
audioRef.value.onError(() => {
toast.error('音频播放失败')
playingVoiceId.value = ''
})
// 播放音频
audioRef.value.play()
}
}
// 停止音频
function stopAudio() {
voicePreviewRequestGate.invalidate()
if (audioRef.value) {
audioRef.value.stop()
audioRef.value.destroy()
@@ -1592,10 +1646,10 @@ onMounted(async () => {
class="flex items-center justify-between border-b border-[#f5f5f5] p-[32rpx] transition-all active:bg-[#f5f7fb]"
@click="onPickerConfirm('voiceprint', voice.value, voice.name)"
>
<text :class="`flex-1 text-[28rpx] text-[#232338] ${(voice.voiceDemo || voice.voice_demo) ? '' : 'text-center'}`">
<text :class="`flex-1 text-[28rpx] text-[#232338] ${hasVoicePreview(voice) ? '' : 'text-center'}`">
{{ voice.name }}
</text>
<view v-if="voice.voiceDemo || voice.voice_demo" class="ml-[20rpx]" @click.stop="playAudio(voice.voiceDemo || voice.voice_demo, voice.value, $event)">
<view v-if="hasVoicePreview(voice)" class="ml-[20rpx]" @click.stop="playAudio(voice, $event)">
<wd-icon
:name="playingVoiceId === voice.value ? 'pause-circle' : 'play-circle'"
size="24px"
@@ -0,0 +1,14 @@
/** @param {unknown} timestamp */
export function parseDeviceLastConnectedAtTimestamp(timestamp) {
if (typeof timestamp !== 'string' || !timestamp.trim()) {
return null
}
const milliseconds = Number(timestamp)
if (!Number.isFinite(milliseconds)) {
return null
}
const date = new Date(milliseconds)
return Number.isNaN(date.getTime()) ? null : date
}
@@ -0,0 +1,15 @@
/* eslint-disable test/no-import-node-test -- this zero-dependency gate intentionally uses Node's built-in runner */
import assert from 'node:assert/strict'
import test from 'node:test'
import { parseDeviceLastConnectedAtTimestamp } from './deviceTimeUtils.mjs'
test('parses the backend Long timestamp serialized as a string', () => {
const timestamp = '1783689702000'
assert.equal(parseDeviceLastConnectedAtTimestamp(timestamp)?.getTime(), Number(timestamp))
})
test('rejects missing and malformed device timestamps', () => {
assert.equal(parseDeviceLastConnectedAtTimestamp(null), null)
assert.equal(parseDeviceLastConnectedAtTimestamp(''), null)
assert.equal(parseDeviceLastConnectedAtTimestamp('not-a-timestamp'), null)
})
@@ -5,6 +5,7 @@ import { useMessage } from 'wot-design-uni/components/wd-message-box'
import { bindDevice, bindDeviceManual, getBindDevices, getFirmwareTypes, unbindDevice, updateDeviceAutoUpdate } from '@/api/device'
import { t } from '@/i18n'
import { toast } from '@/utils/toast'
import { parseDeviceLastConnectedAtTimestamp } from './deviceTimeUtils.mjs'
defineOptions({
name: 'DeviceManage',
@@ -131,10 +132,10 @@ function getDeviceTypeName(boardKey: string): string {
}
// 格式化时间
function formatTime(timeStr: string) {
if (!timeStr)
function formatTime(timestamp: string | null) {
const date = parseDeviceLastConnectedAtTimestamp(timestamp)
if (!date)
return t('device.neverConnected')
const date = new Date(timeStr)
const now = new Date()
const diff = now.getTime() - date.getTime()
@@ -410,7 +411,7 @@ defineExpose({
{{ t('device.firmwareVersion') }}:{{ device.appVersion }}
</text>
<text class="block text-[28rpx] text-[#65686f] leading-[1.4]">
{{ t('device.lastConnection') }}:{{ formatTime(device.lastConnectedAt) }}
{{ t('device.lastConnection') }}:{{ formatTime(device.lastConnectedAtTimestamp) }}
</text>
</view>
@@ -265,7 +265,7 @@ function showAbout() {
title: t('settings.aboutApp', { appName: import.meta.env.VITE_APP_TITLE }),
content: t('settings.aboutContent', {
appName: import.meta.env.VITE_APP_TITLE,
version: '0.9.6',
version: '0.9.8',
}),
showCancel: false,
confirmText: t('common.confirm'),
+3025 -17
View File
File diff suppressed because it is too large. Load diff
+9 -1
View File
@@ -7,7 +7,9 @@
"check:i18n": "node scripts/check-i18n.js",
"test:snapshot": "node --test src/components/agentSnapshotDisplayUtils.test.mjs src/apis/module/agentSnapshotApi.test.mjs",
"build": "vue-cli-service build",
"test:unit": "node --test tests/*.test.mjs",
"test:contract": "node --test tests/*Contract.test.mjs",
"test:unit": "vitest run",
"test": "npm run test:contract && npm run test:unit",
"analyze": "cross-env ANALYZE=true vue-cli-service build"
},
"dependencies": {
@@ -16,10 +18,12 @@
"dotenv": "^16.5.0",
"element-ui": "^2.15.14",
"flyio": "^0.6.14",
"highlight.js": "^11.12.0",
"normalize.css": "^8.0.1",
"opus-decoder": "^0.7.7",
"opus-recorder": "^8.0.5",
"sm-crypto": "^0.3.13",
"v-code-diff": "^1.16.0",
"vue": "^2.6.14",
"vue-axios": "^3.5.2",
"vue-i18n": "^8.28.2",
@@ -30,12 +34,16 @@
"devDependencies": {
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-runtime": "^7.26.10",
"@vitest/coverage-v8": "^1.6.0",
"@vue/cli-plugin-router": "~5.0.0",
"@vue/cli-plugin-vuex": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"@vue/test-utils": "^1.3.6",
"compression-webpack-plugin": "^11.1.0",
"jsdom": "^24.1.0",
"sass": "^1.32.7",
"sass-loader": "^12.0.0",
"vitest": "^1.6.0",
"vue-template-compiler": "^2.6.14",
"webpack-bundle-analyzer": "^4.10.2",
"workbox-webpack-plugin": "^7.3.0"
+120 -23
View File
@@ -1,6 +1,7 @@
import Fly from 'flyio/dist/npm/fly';
import store from '../store/index';
import Constant from '../utils/constant';
import { getServiceUrl } from '../apis/api';
import { goToPage, isNotNull, showDanger, showWarning } from '../utils/index';
import i18n from '../i18n/index';
@@ -8,13 +9,98 @@ const fly = new Fly()
// 设置超时
fly.config.timeout = 30000
// 独立 fly 实例给 silent refresh 用,避免任何回调链回到 sendRequest
const refreshFly = new Fly()
refreshFly.config.timeout = 30000
// silent refresh:剩 1h 触发换 token,单飞锁避免并发
const REFRESH_AHEAD_MS = 60 * 60 * 1000
let refreshPromise = null
function getTokenExpireAt() {
// 与 persistTokenExpireAt 同源读 localStorage(store 走的是 state.token 缓存,
// 刚写入 localStorage 后再走 store 拿不到补写的 _localStoredAt)
const raw = localStorage.getItem('token')
if (!raw) return 0
try {
const parsed = JSON.parse(raw)
if (!parsed || !parsed.expire) return 0
// login.vue 写入 store 时是 setToken(now),token 对象里没有 createdAt;
// 用 _localStoredAt 作为本地落地时间基准
return (parsed._localStoredAt || 0) + parsed.expire * 1000
} catch (e) {
return 0
}
}
// 首次见到该 token 时记下落地时间;同步刷新 localStorage 与 store,保证两端一致
function persistTokenExpireAt() {
const raw = localStorage.getItem('token')
if (!raw) return
try {
const parsed = JSON.parse(raw)
if (parsed && parsed.expire && !parsed._localStoredAt) {
parsed._localStoredAt = Date.now()
const next = JSON.stringify(parsed)
localStorage.setItem('token', next)
store.commit('setToken', next)
}
} catch (e) { /* ignore */ }
}
function shouldRefresh() {
persistTokenExpireAt()
const exp = getTokenExpireAt()
if (!exp) return false
return exp - Date.now() < REFRESH_AHEAD_MS
}
function doRefresh() {
// 直接走 fly.request,绕过 sendRequest,避免死循环触发 silent refresh
const raw = localStorage.getItem('token')
if (!raw) return Promise.reject(new Error('no token'))
let bearer
try {
bearer = JSON.parse(raw).token
} catch (e) {
return Promise.reject(e)
}
return refreshFly.request(getServiceUrl() + '/user/refresh', {}, {
method: 'POST',
headers: { Authorization: 'Bearer ' + bearer }
}).then((res) => {
const body = res && res.data
if (!body || (body.code !== 0 && body.code !== 'success')) {
throw new Error('refresh failed: ' + (body && body.msg))
}
const newDto = body.data
newDto._localStoredAt = Date.now()
store.commit('setToken', JSON.stringify(newDto))
return newDto
})
}
function triggerSilentRefresh() {
if (!refreshPromise) {
refreshPromise = doRefresh().catch((err) => {
store.commit('clearAuth')
goToPage(Constant.PAGE.LOGIN, true)
throw err
}).finally(() => {
refreshPromise = null
})
}
return refreshPromise
}
/**
* Request服务封装
*/
export default {
sendRequest,
reAjaxFun,
clearRequestTime
clearRequestTime,
triggerSilentRefresh
}
function sendRequest() {
@@ -37,30 +123,41 @@ function sendRequest() {
acceptLanguage = 'en-US';
}
this._header['Accept-Language'] = acceptLanguage;
if (isNotNull(store.getters.getToken)) {
this._header.Authorization = 'Bearer ' + (JSON.parse(store.getters.getToken)).token
const doSend = () => {
if (isNotNull(store.getters.getToken)) {
this._header.Authorization = 'Bearer ' + (JSON.parse(store.getters.getToken)).token
}
// 打印请求信息
fly.request(this._url, this._data, {
method: this._method,
headers: this._header,
responseType: this._responseType
}).then((res) => {
const error = httpHandlerError(res, this._failCallback, this._networkFailCallback);
if (error) {
return
}
if (this._sucCallback) {
this._sucCallback(res)
}
}).catch((res) => {
// 打印失败响应
console.log('catch', res)
httpHandlerError(res, this._failCallback, this._networkFailCallback)
})
}
// 打印请求信息
fly.request(this._url, this._data, {
method: this._method,
headers: this._header,
responseType: this._responseType
}).then((res) => {
const error = httpHandlerError(res, this._failCallback, this._networkFailCallback);
if (error) {
return
}
if (this._sucCallback) {
this._sucCallback(res)
}
}).catch((res) => {
// 打印失败响应
console.log('catch', res)
httpHandlerError(res, this._failCallback, this._networkFailCallback)
})
// 剩 1h 内过期就先 silent refresh 再发,确保请求带新 token
if (shouldRefresh()) {
triggerSilentRefresh().then(doSend).catch(() => {
// 触发 refresh 后被拦截跳登录,这里什么都不用做
})
} else {
doSend()
}
return this
},
'success'(callback) {
@@ -27,7 +27,7 @@ export default {
getFileList(params, callback) {
const queryParams = new URLSearchParams({
page: params.page,
pageSize: params.pageSize
limit: params.pageSize
}).toString();
RequestService.sendRequest()
+48 -15
View File
@@ -3,7 +3,7 @@ import RequestService from '../httpRequest';
export default {
// 已绑设备
getAgentBindDevices(agentId, callback) {
getAgentBindDevices(agentId, callback, failCallback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/device/bind/${agentId}`)
.method('GET')
@@ -11,15 +11,21 @@ export default {
RequestService.clearRequestTime();
callback(res);
})
.fail((err) => {
RequestService.clearRequestTime();
if (failCallback) {
failCallback(err);
}
})
.networkFail((err) => {
console.error('获取设备列表失败:', err);
RequestService.reAjaxFun(() => {
this.getAgentBindDevices(agentId, callback);
this.getAgentBindDevices(agentId, callback, failCallback);
});
}).send();
},
// 解绑设备
unbindDevice(device_id, callback) {
unbindDevice(device_id, callback, failCallback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/device/unbind`)
.method('POST')
@@ -28,15 +34,21 @@ export default {
RequestService.clearRequestTime();
callback(res);
})
.fail((err) => {
RequestService.clearRequestTime();
if (failCallback) {
failCallback(err);
}
})
.networkFail((err) => {
console.error('解绑设备失败:', err);
RequestService.reAjaxFun(() => {
this.unbindDevice(device_id, callback);
this.unbindDevice(device_id, callback, failCallback);
});
}).send();
},
// 绑定设备
bindDevice(agentId, deviceCode, callback) {
bindDevice(agentId, deviceCode, callback, failCallback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/device/bind/${agentId}/${deviceCode}`)
.method('POST')
@@ -44,14 +56,20 @@ export default {
RequestService.clearRequestTime();
callback(res);
})
.fail((err) => {
RequestService.clearRequestTime();
if (failCallback) {
failCallback(err);
}
})
.networkFail((err) => {
console.error('绑定设备失败:', err);
RequestService.reAjaxFun(() => {
this.bindDevice(agentId, deviceCode, callback);
this.bindDevice(agentId, deviceCode, callback, failCallback);
});
}).send();
},
updateDeviceInfo(id, payload, callback) {
updateDeviceInfo(id, payload, callback, failCallback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/device/update/${id}`)
.method('PUT')
@@ -60,16 +78,22 @@ export default {
RequestService.clearRequestTime()
callback(res)
})
.fail((err) => {
RequestService.clearRequestTime();
if (failCallback) {
failCallback(err);
}
})
.networkFail((err) => {
console.error('更新OTA状态失败:', err)
this.$message.error(err.msg || '更新OTA状态失败')
RequestService.reAjaxFun(() => {
this.updateDeviceInfo(id, payload, callback)
this.updateDeviceInfo(id, payload, callback, failCallback)
})
}).send()
},
// 手动添加设备
manualAddDevice(params, callback) {
manualAddDevice(params, callback, failCallback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/device/manual-add`)
.method('POST')
@@ -78,15 +102,21 @@ export default {
RequestService.clearRequestTime();
callback(res);
})
.fail((err) => {
RequestService.clearRequestTime();
if (failCallback) {
failCallback(err);
}
})
.networkFail((err) => {
console.error('手动添加设备失败:', err);
RequestService.reAjaxFun(() => {
this.manualAddDevice(params, callback);
this.manualAddDevice(params, callback, failCallback);
});
}).send();
},
// 获取设备状态
getDeviceStatus(agentId, callback) {
getDeviceStatus(agentId, callback, failCallback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/device/bind/${agentId}`)
.method('POST')
@@ -95,14 +125,17 @@ export default {
RequestService.clearRequestTime();
callback(res);
})
.fail((res) => {
callback(res);
.fail((err) => {
RequestService.clearRequestTime();
if (failCallback) {
failCallback(err);
}
})
.networkFail((err) => {
console.error('获取设备状态失败:', err);
RequestService.reAjaxFun(() => {
this.getDeviceStatus(agentId, callback);
this.getDeviceStatus(agentId, callback, failCallback);
});
}).send();
},
}
}
+2 -1
View File
@@ -79,6 +79,7 @@ export default {
remark: params.remark,
referenceAudio: params.referenceAudio,
referenceText: params.referenceText,
sort: params.sort,
ttsModelId: params.ttsModelId,
ttsVoice: params.voiceCode,
voiceDemo: params.voiceDemo || ''
@@ -93,4 +94,4 @@ export default {
});
}).send();
}
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ export default {
})
.networkFail(() => {
RequestService.reAjaxFun(() => {
this.login(loginForm, callback)
this.login(loginForm, callback, failCallback)
})
}).send()
},
@@ -1,39 +1,37 @@
<template>
<el-dialog :visible="visible" @close="handleClose" width="24%" center>
<div
style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
<div
style="width: 40px;height: 40px;border-radius: 50%;background: #5778ff;display: flex;align-items: center;justify-content: center;">
<img src="@/assets/home/equipment.png" alt="" style="width: 18px;height: 15px;" />
<CustomDialog
:title="$t('device.dialogTitle')"
:visible.sync="visible"
width="420px"
:confirmLoading="loading"
@confirm="confirm"
@cancel="cancel"
@close="handleClose"
:confirmText="$t('device.confirmButton')"
:cancelText="$t('device.cancelButton')"
>
<div class="add-device-body">
<div class="add-device-label">
<span class="required">*</span>
{{ $t('device.verificationCode') }}
</div>
{{ $t('device.dialogTitle') }}
<el-input
:placeholder="$t('device.verificationCodePlaceholder')"
v-model="deviceCode"
class="add-device-input"
@keyup.enter.native="confirm"
/>
</div>
<div style="height: 1px;background: #e8f0ff;" />
<div style="margin: 22px 15px;">
<div style="font-weight: 400;font-size: 14px;text-align: left;color: #3d4566;">
<div style="color: red;display: inline-block;">*</div>
<span style="font-size: 11px"> {{ $t('device.verificationCode') }}</span>
</div>
<div class="input-46" style="margin-top: 12px;">
<el-input :placeholder="$t('device.verificationCodePlaceholder')" v-model="deviceCode" @keyup.enter.native="confirm" />
</div>
</div>
<div style="display: flex;margin: 15px 15px;gap: 7px;">
<div class="dialog-btn" @click="confirm">
{{ $t('device.confirmButton') }}
</div>
<div class="dialog-btn" style="background: #e6ebff;border: 1px solid #adbdff;color: #5778ff;" @click="cancel">
{{ $t('device.cancelButton') }}
</div>
</div>
</el-dialog>
</CustomDialog>
</template>
<script>
import Api from '@/apis/api';
import CustomDialog from './CustomDialog.vue';
export default {
name: 'AddDeviceDialog',
components: { CustomDialog },
props: {
visible: { type: Boolean, required: true },
agentId: { type: String, required: true }
@@ -53,7 +51,8 @@ export default {
this.loading = true;
Api.device.bindDevice(
this.agentId,
this.deviceCode, ({ data }) => {
this.deviceCode,
({ data }) => {
this.loading = false;
if (data.code === 0) {
this.$emit('refresh');
@@ -68,59 +67,53 @@ export default {
showClose: true
});
}
},
(err) => {
this.loading = false;
const msg = err && err.data && err.data.msg;
this.$message.error({
message: msg || this.$t('device.bindFailed'),
showClose: true
});
}
);
},
closeDialog() {
this.$emit('update:visible', false);
this.loading = false;
this.deviceCode = '';
this.$emit('update:visible', false);
},
cancel() {
this.$emit('update:visible', false)
this.deviceCode = ""
this.closeDialog();
},
handleClose() {
this.$emit('update:visible', false);
},
this.closeDialog();
}
}
}
</script>
<style scoped>
.input-46 {
border: 1px solid #e4e6ef;
background: #f6f8fb;
border-radius: 10px;
.add-device-body {
padding: 6px 4px 4px;
}
.dialog-btn {
cursor: pointer;
flex: 1;
border-radius: 23px;
background: #5778ff;
.add-device-label {
font-size: 14px;
color: #475569;
text-align: left;
margin-bottom: 12px;
}
.add-device-label .required {
color: #f56c6c;
margin-right: 2px;
}
.add-device-input ::v-deep(.el-input__inner) {
height: 40px;
font-weight: 500;
font-size: 12px;
color: #fff;
line-height: 40px;
text-align: center;
}
::v-deep .el-dialog {
border-radius: 15px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
::v-deep .el-dialog__headerbtn {
display: none;
}
::v-deep .el-dialog__body {
padding: 4px 6px;
}
::v-deep .el-dialog__header {
padding: 10px;
border-radius: 8px;
border-color: #e2e8f0;
font-size: 14px;
}
</style>
@@ -2,7 +2,8 @@
<CustomDialog
:visible.sync="dialogVisible"
:title="$t('modelConfigDialog.addModel')"
width="57%"
width="960px"
size="large"
class="add-model-dialog"
:confirmLoading="saving"
@close="handleClose"
@@ -76,7 +77,10 @@
<el-form :model="formData.configJson" label-width="auto" label-position="left" class="custom-form">
<div v-for="(row, rowIndex) in chunkedCallInfoFields" :key="rowIndex" class="form-row">
<el-form-item v-for="field in row" :key="field.prop" :label="field.label" :prop="field.prop" style="flex: 1;">
<el-input v-model="formData.configJson[field.prop]" :placeholder="field.placeholder"
<el-input v-if="field.type === 'json-textarea'" v-model="formData.configJson[field.prop]" type="textarea"
:rows="3" :placeholder="field.placeholder" class="custom-input-bg">
</el-input>
<el-input v-else v-model="formData.configJson[field.prop]" :placeholder="field.placeholder"
:type="field.type || 'text'" class="custom-input-bg" :show-password="field.type === 'password'">
</el-input>
</el-form-item>
@@ -160,7 +164,7 @@ export default {
fields: JSON.parse(item.fields || '[]').map(f => ({
label: f.label,
prop: f.key,
type: f.type === 'password' ? 'password' : 'text',
type: f.type === 'dict' ? 'json-textarea' : f.type === 'password' ? 'password' : 'text',
placeholder: `请输入${f.key}`
}))
}))
@@ -203,6 +207,24 @@ export default {
return;
}
// dict 字段必须解析为 JSON 对象再提交,否则会以字符串入库
for (const field of this.providerFields) {
if (field.type !== 'json-textarea') continue;
const raw = this.formData.configJson[field.prop];
if (!raw || typeof raw === 'object') continue;
try {
const parsed = JSON.parse(raw);
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('not object');
}
this.formData.configJson[field.prop] = parsed;
} catch (e) {
this.$message.error('必须输入字典格式(如 {"key":"value"})');
this.saving = false;
return;
}
}
const submitData = {
id: this.formData.id || '',
modelName: this.formData.modelName || '',
@@ -1,51 +1,53 @@
<template>
<el-dialog :visible="visible" @close="handleClose" width="25%" center @open="handleOpen">
<div
style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
<div
style="width: 40px;height: 40px;border-radius: 50%;background: #5778ff;display: flex;align-items: center;justify-content: center;">
<img loading="lazy" src="@/assets/home/equipment.png" alt="" style="width: 18px;height: 15px;" />
<CustomDialog
:title="$t('addAgentDialog.title')"
:visible.sync="visible"
width="420px"
@confirm="confirm"
@cancel="cancel"
@close="handleClose"
:confirmText="$t('addAgentDialog.confirm')"
:cancelText="$t('addAgentDialog.cancel')"
@open="handleOpen"
>
<div class="add-agent-body">
<div class="add-agent-label">
<span class="required">*</span>
{{ $t('addAgentDialog.agentName') }}
</div>
{{ $t('addAgentDialog.title') }}
<el-input
maxLength="64"
ref="inputRef"
:placeholder="$t('addAgentDialog.placeholder')"
v-model="wisdomBodyName"
class="add-agent-input"
@keyup.enter.native="confirm"
/>
</div>
<div style="height: 1px;background: #e8f0ff;" />
<div style="margin: 22px 15px;">
<div style="font-weight: 400;text-align: left;color: #3d4566;">
<div style="color: red;display: inline-block;">*</div> {{ $t('addAgentDialog.agentName') }}:
</div>
<div class="input-46" style="margin-top: 12px;">
<el-input maxLength="64" ref="inputRef" :placeholder="$t('addAgentDialog.placeholder')" v-model="wisdomBodyName" @keyup.enter.native="confirm" />
</div>
</div>
<div style="display: flex;margin: 15px 15px;gap: 7px;">
<div class="dialog-btn" @click="confirm">
{{ $t('addAgentDialog.confirm') }}
</div>
<div class="dialog-btn" style="background: #e6ebff;border: 1px solid #adbdff;color: #5778ff;" @click="cancel">
{{ $t('addAgentDialog.cancel') }}
</div>
</div>
</el-dialog>
</CustomDialog>
</template>
<script>
import Api from '@/apis/api';
import CustomDialog from './CustomDialog.vue';
export default {
name: 'AddWisdomBodyDialog',
components: { CustomDialog },
props: {
visible: { type: Boolean, required: true }
},
data() {
return {
wisdomBodyName: "",
inputRef: null
wisdomBodyName: ""
}
},
methods: {
handleOpen() {
this.$nextTick(() => {
this.$refs.inputRef.focus();
if (this.$refs.inputRef && this.$refs.inputRef.focus) {
this.$refs.inputRef.focus();
}
});
},
confirm() {
@@ -64,50 +66,37 @@ export default {
});
},
cancel() {
this.$emit('update:visible', false)
this.wisdomBodyName = ""
},
handleClose() {
this.wisdomBodyName = "";
this.$emit('update:visible', false);
},
handleClose() {
this.cancel();
}
}
}
</script>
<style scoped>
.input-46 {
border: 1px solid #e4e6ef;
background: #f6f8fb;
border-radius: 15px;
.add-agent-body {
padding: 6px 4px 4px;
}
.dialog-btn {
cursor: pointer;
flex: 1;
border-radius: 23px;
background: #5778ff;
.add-agent-label {
font-size: 14px;
color: #475569;
text-align: left;
margin-bottom: 12px;
}
.add-agent-label .required {
color: #f56c6c;
margin-right: 2px;
}
.add-agent-input ::v-deep(.el-input__inner) {
height: 40px;
font-weight: 500;
font-size: 12px;
color: #fff;
line-height: 40px;
text-align: center;
}
::v-deep .el-dialog {
border-radius: 15px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
::v-deep .el-dialog__headerbtn {
display: none;
}
::v-deep .el-dialog__body {
padding: 4px 6px;
}
::v-deep .el-dialog__header {
padding: 10px;
border-radius: 8px;
border-color: #e2e8f0;
font-size: 14px;
}
</style>
@@ -3,7 +3,7 @@
<el-dialog
:title="$t('agentSnapshot.title')"
:visible="visible"
width="860px"
width="960px"
class="agent-snapshot-dialog"
:before-close="guardRestoreInFlightClose"
:close-on-click-modal="!restoring"
@@ -126,7 +126,7 @@
<el-dialog
:title="detailDialogTitle"
:visible.sync="detailVisible"
width="860px"
width="960px"
class="snapshot-detail-dialog"
>
<template slot="title">
@@ -269,8 +269,21 @@
</div>
<div v-else class="value-empty">{{ $t('agentSnapshot.noFunctionChange') }}</div>
</div>
<div v-else-if="shouldUseCodeDiff(item)" class="diff-code-wrapper">
<CodeDiff
:old-string="item.beforeText"
:new-string="item.afterText"
:language="codeDiffLanguage(item)"
output-format="side-by-side"
diff-style="word"
:context="8"
max-height="320px"
:filename="item.beforeTitle"
:new-filename="item.afterTitle"
/>
</div>
<div v-else class="diff-values" :class="{ 'is-complex': item.complex }">
<div class="diff-pane diff-before">
<div class="diff-pane diff-before 1">
<div class="diff-pane-title">{{ item.beforeTitle }}</div>
<div class="diff-value" :class="valueClass(item)">
<div
@@ -306,7 +319,7 @@
<el-dialog
:title="restorePreviewTitle"
:visible.sync="restorePreviewVisible"
width="860px"
width="960px"
class="snapshot-detail-dialog"
:before-close="guardRestoreInFlightClose"
:close-on-click-modal="!restoring"
@@ -401,8 +414,21 @@
</div>
<div v-else class="value-empty">{{ $t('agentSnapshot.noFunctionChange') }}</div>
</div>
<div v-else-if="shouldUseCodeDiff(item)" class="diff-code-wrapper">
<CodeDiff
:old-string="item.beforeText"
:new-string="item.afterText"
:language="codeDiffLanguage(item)"
output-format="side-by-side"
diff-style="word"
:context="8"
max-height="320px"
:filename="item.beforeTitle"
:new-filename="item.afterTitle"
/>
</div>
<div v-else class="diff-values" :class="{ 'is-complex': item.complex }">
<div class="diff-pane diff-before">
<div class="diff-pane diff-before 2">
<div class="diff-pane-title">{{ item.beforeTitle }}</div>
<div class="diff-value" :class="valueClass(item)">
<div
@@ -485,6 +511,8 @@
import Api from "@/apis/api";
import correctWord from "@/apis/module/correctWord";
import { formatDate } from "@/utils/date";
import { CodeDiff, hljs } from "v-code-diff";
import markdown from "highlight.js/lib/languages/markdown";
import {
hasValidCurrentStateToken,
normalizeSnapshotOrderedValue,
@@ -492,13 +520,14 @@ import {
SNAPSHOT_SECRET_REDACTED
} from "./agentSnapshotDisplayUtils.mjs";
hljs.registerLanguage("markdown", markdown);
const FALLBACK_PLUGIN_NAME_KEYS = {
SYSTEM_PLUGIN_WEATHER: "agentSnapshot.plugin.SYSTEM_PLUGIN_WEATHER",
SYSTEM_PLUGIN_MUSIC: "agentSnapshot.plugin.SYSTEM_PLUGIN_MUSIC",
SYSTEM_PLUGIN_NEWS_CHINANEWS: "agentSnapshot.plugin.SYSTEM_PLUGIN_NEWS_CHINANEWS",
SYSTEM_PLUGIN_NEWS_NEWSNOW: "agentSnapshot.plugin.SYSTEM_PLUGIN_NEWS_NEWSNOW",
SYSTEM_PLUGIN_HA_GET_STATE: "agentSnapshot.plugin.SYSTEM_PLUGIN_HA_GET_STATE",
SYSTEM_PLUGIN_HA_SET_STATE: "agentSnapshot.plugin.SYSTEM_PLUGIN_HA_SET_STATE",
SYSTEM_PLUGIN_HA_STATE: "agentSnapshot.plugin.SYSTEM_PLUGIN_HA_STATE",
SYSTEM_PLUGIN_HA_PLAY_MUSIC: "agentSnapshot.plugin.SYSTEM_PLUGIN_HA_PLAY_MUSIC",
SYSTEM_PLUGIN_WEB_SEARCH: "agentSnapshot.plugin.SYSTEM_PLUGIN_WEB_SEARCH",
SYSTEM_PLUGIN_CALL_DEVICE: "agentSnapshot.plugin.SYSTEM_PLUGIN_CALL_DEVICE"
@@ -560,6 +589,9 @@ const CHAT_HISTORY_CONF_LABEL_KEYS = {
};
export default {
name: "AgentSnapshotDialog",
components: {
CodeDiff
},
props: {
visible: {
type: Boolean,
@@ -1544,6 +1576,18 @@ export default {
}
return "text";
},
shouldUseCodeDiff(item) {
return Boolean(
item &&
!item.single &&
(item.displayType === "markdown" || item.complex || item.field === "summaryMemory")
);
},
codeDiffLanguage(item) {
return item?.displayType === "markdown" || item?.field === "summaryMemory"
? "markdown"
: "json";
},
valueClass(item) {
return [`is-${item.displayType || "text"}`];
},
@@ -2068,10 +2112,21 @@ export default {
@import '@/styles/global.scss';
::v-deep .el-dialog {
margin-top: 6vh !important;
border-radius: 10px;
display: flex;
flex-direction: column;
overflow: hidden;
margin: 10px auto !important;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
height: calc(100vh - 20px);
max-height: calc(100vh - 20px);
}
::v-deep .el-dialog__wrapper {
display: flex;
align-items: stretch;
justify-content: center;
overflow: hidden;
}
::v-deep .el-dialog__header {
@@ -2140,6 +2195,7 @@ export default {
}
::v-deep .el-dialog__body {
overflow-y: auto;
padding: 20px;
}
@@ -2227,9 +2283,7 @@ export default {
}
.snapshot-table-wrapper {
max-height: 62vh;
overflow: auto;
@include scrollbar-style;
min-height: 420px;
}
.version-cell {
@@ -2273,11 +2327,8 @@ export default {
}
.snapshot-diff {
min-height: 220px;
max-height: 62vh;
overflow: auto;
min-height: 420px;
padding-right: 2px;
@include scrollbar-style;
}
.restore-risk-alert {
@@ -2342,6 +2393,12 @@ export default {
border-left: 0;
}
.diff-code-wrapper {
padding: 0 14px 1px;
text-align: left;
background: #fff;
}
.diff-pane-title {
margin-bottom: 8px;
color: #3d4566;
@@ -2389,6 +2446,43 @@ export default {
text-align: left;
}
::v-deep .code-diff-view,
::v-deep .code-diff-view .blob-num,
::v-deep .code-diff-view .blob-code-inner,
::v-deep .code-diff-view .file-header {
font-family: Avenir, Helvetica, Arial, sans-serif;
}
::v-deep .code-diff-view::-webkit-scrollbar {
width: 6px;
}
::v-deep .code-diff-view::-webkit-scrollbar-thumb {
background: #a1c9fd;
border-radius: 3px;
}
::v-deep .code-diff-view::-webkit-scrollbar-track {
background: #f0f3fe;
border-radius: 3px;
}
::v-deep .code-diff-view .blob-code-inner {
font-size: 13px;
line-height: 1.45;
font-style: normal;
}
::v-deep .code-diff-view .hljs-emphasis,
::v-deep .code-diff-view em {
font-style: normal !important;
}
::v-deep .code-diff-view .diff-table .blob-code-deletion .x,
::v-deep .code-diff-view .diff-table .blob-code-addition .x {
background-color: transparent;
}
.function-change-view {
padding: 16px;
background: #fff;
@@ -2,7 +2,7 @@
<el-dialog
:title="$t('cache.dialogTitle')"
:visible.sync="visible"
width="70%"
width="80%"
:before-close="handleClose"
>
<div v-if="isLoading" class="loading-container">
@@ -204,4 +204,24 @@ h3 {
margin-bottom: 10px;
font-weight: 500;
}
::v-deep .el-dialog {
margin: 10px auto !important;
height: calc(100vh - 20px);
max-height: calc(100vh - 20px);
border-radius: 10px;
display: flex;
flex-direction: column;
max-width: 1280px;
}
::v-deep .el-dialog__wrapper {
align-items: stretch;
}
::v-deep .el-dialog__body {
flex: 1;
max-height: calc(100vh - 20px - 130px);
overflow-y: auto;
}
</style>
@@ -3,6 +3,7 @@
:title="$t('chatHistory.with') + agentName + $t('chatHistory.dialogTitle')"
:visible.sync="dialogVisible"
width="80%"
size="large"
:footer="false"
:close-on-click-modal="false"
custom-class="chat-history-dialog">
@@ -413,7 +414,8 @@ export default {
<style scoped>
.chat-container {
display: flex;
height: 100%;
flex: 1;
min-height: 0;
}
.session-list {
@@ -610,10 +612,6 @@ export default {
display: flex;
gap: 10px;
border-top: 1px solid #eee;
position: absolute;
bottom: 0;
left: 0;
right: 0;
background-color: white;
}
@@ -624,24 +622,18 @@ export default {
<style>
.chat-history-dialog {
display: flex;
flex-direction: column;
min-width: 700px;
margin: 0 !important;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
height: 98vh;
max-width: 85vw;
border-radius: 12px;
overflow: hidden;
/* 兜底,防止 size="large" 那个 height 没有生效时弹框缩成内容高度 */
height: calc(100vh - 20px) !important;
}
.chat-history-dialog .el-dialog__body {
padding: 0;
overflow: hidden;
height: calc(90vh - 54px);
/* 减去标题栏的高度 */
display: flex;
flex-direction: column;
/* 顶开 size="large" 默认的 100vh - 130px,让 body 真正撑到弹框底部,flex: 1 会自动填满 */
max-height: calc(100vh - 74px) !important;
}
</style>
@@ -1,11 +1,14 @@
<template>
<el-dialog
:visible.sync="dialogVisible"
width="900px"
<CustomDialog
:title="$t('contextProviderDialog.title')"
:close-on-click-modal="false"
custom-class="context-provider-dialog"
append-to-body
:visible.sync="dialogVisible"
width="960px"
size="large"
class="context-provider-dialog"
:closeOnClickModal="false"
:confirmText="$t('contextProviderDialog.confirm')"
:cancelText="$t('contextProviderDialog.cancel')"
@confirm="handleConfirm"
>
<div class="dialog-content">
<el-empty v-if="localProviders.length === 0" :description="$t('contextProviderDialog.noContextApi')">
@@ -51,7 +54,7 @@
size="small"
class="flex-1"
></el-input>
<div class="row-controls">
<el-button
type="primary"
@@ -104,17 +107,15 @@
</div>
</div>
</div>
<span slot="footer" class="dialog-footer">
<el-button @click="dialogVisible = false">{{ $t('contextProviderDialog.cancel') }}</el-button>
<el-button type="primary" @click="handleConfirm">{{ $t('contextProviderDialog.confirm') }}</el-button>
</span>
</el-dialog>
</CustomDialog>
</template>
<script>
import CustomDialog from './CustomDialog.vue';
export default {
name: 'ContextProviderDialog',
components: { CustomDialog },
props: {
visible: {
type: Boolean,
@@ -157,7 +158,7 @@ export default {
headers: Object.entries(headers).map(([key, value]) => ({ key, value }))
};
});
// 如果为空,添加一个默认块
if (this.localProviders.length === 0) {
this.localProviders.push({ url: '', headers: [{ key: '', value: '' }] });
@@ -193,19 +194,22 @@ export default {
headers: headersObj
};
});
this.$emit('confirm', result);
this.dialogVisible = false;
this.$emit('update:visible', false);
}
}
};
</script>
<style scoped>
.context-provider-dialog ::v-deep .el-dialog__body {
padding: 16px 20px;
}
.dialog-content {
max-height: 60vh;
max-height: calc(100vh - 280px);
overflow-y: auto;
padding: 20px 25px;
padding: 0 5px;
}
.dialog-content::-webkit-scrollbar {
@@ -230,7 +234,7 @@ export default {
flex: 1;
border-radius: 12px;
border: 1px solid #e4e7ed;
border-left: 4px solid #409EFF; /* 左侧强调色 */
border-left: 4px solid #409EFF;
background-color: #fff;
transition: all 0.3s ease;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
@@ -261,7 +265,7 @@ export default {
text-align: right;
font-size: 13px;
white-space: nowrap;
line-height: 32px; /* 垂直居中对齐 */
line-height: 32px;
}
.flex-1 {
@@ -327,3 +331,4 @@ export default {
margin-right: 8px;
}
</style>
+132 -94
View File
@@ -7,7 +7,7 @@
:close-on-press-escape="closeOnPressEscape"
:show-close="showClose"
:destroy-on-close="destroyOnClose"
:custom-class="customClass"
:custom-class="dialogCustomClass"
class="custom-dialog"
@close="handleClose"
@open="handleOpen"
@@ -54,7 +54,7 @@ export default {
},
width: {
type: String,
default: "600px"
default: "720px"
},
footer: {
type: Boolean,
@@ -80,6 +80,11 @@ export default {
type: String,
default: ""
},
size: {
type: String,
default: "small",
validator: (val) => ["small", "large"].includes(val),
},
cancelText: {
type: String,
default: "取消"
@@ -94,6 +99,15 @@ export default {
dialogVisible: this.visible
};
},
computed: {
dialogCustomClass() {
const classes = ["custom-dialog"];
if (this.customClass) classes.push(this.customClass);
if (this.size === "large") classes.push("custom-dialog--large");
if (String(this.width).includes("%")) classes.push("custom-dialog--fluid");
return classes.join(" ");
},
},
components: {
CustomButton
},
@@ -124,110 +138,134 @@ export default {
</script>
<style lang="scss" scoped>
.custom-dialog {
::v-deep .el-dialog {
border-radius: 10px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* 大档与小档通过 .custom-dialog--large 区分 */
::v-deep .el-dialog.custom-dialog {
border-radius: 10px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin: 5vh auto !important;
display: flex;
flex-direction: column;
max-height: calc(100vh - 10vh);
}
::v-deep .el-dialog__header {
padding: 16px 20px 12px;
background: linear-gradient(135deg, #e2eeff, #edeafe);
text-align: left;
}
::v-deep .el-dialog__body {
max-height: calc(100vh - 220px);
overflow-y: auto;
padding: 20px;
}
::v-deep .el-dialog__title {
font-size: 16px;
/* .v-modal 样式由 global.scss 全局定义 */
::v-deep .el-dialog__header {
padding: 16px 20px 12px;
background: linear-gradient(135deg, #e2eeff, #edeafe);
text-align: left;
}
::v-deep .el-dialog__title {
font-size: 16px;
font-weight: 500;
color: #1a1a1a;
}
::v-deep .dialog-title {
font-size: 18px;
display: inline-flex;
align-items: center;
> span {
line-height: 18px;
font-weight: 500;
color: #1a1a1a;
}
}
.dialog-title {
::v-deep .title-icon {
width: 24px;
height: 24px;
margin-right: 8px;
}
::v-deep .el-dialog__headerbtn {
top: 12px;
right: 16px;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: #fff;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
display: flex;
align-items: center;
justify-content: center;
.el-dialog__close {
font-size: 18px;
display: inline-flex;
align-items: center;
> span {
line-height: 18px;
font-weight: 500;
}
color: #666;
position: static;
transform: none;
}
.title-icon {
width: 24px;
height: 24px;
margin-right: 8px;
}
::v-deep .el-dialog__headerbtn {
top: 12px;
right: 16px;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
&:hover {
background: #fff;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
.el-dialog__close {
font-size: 18px;
color: #666;
position: static;
transform: none;
}
&:hover {
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
.el-dialog__close {
color: #333;
}
}
}
::v-deep .el-dialog__body {
padding: 20px;
}
::v-deep .el-dialog__footer {
padding: 12px 20px 16px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
.el-button {
padding: 10px 20px;
display: flex;
align-items: center;
}
.el-button--primary {
background: linear-gradient(to right, #4a7cfd, #8154fc);
border: none;
&:hover,
&:focus {
background: linear-gradient(to right, #4a7cfd, #8154fc);
opacity: 0.85;
}
}
.confirm-inner {
display: inline-flex;
align-items: center;
}
.confirm-icon {
width: 16px;
height: 16px;
margin-right: 4px;
color: #333;
}
}
}
::v-deep .el-dialog__footer {
padding: 12px 20px 16px;
}
::v-deep .dialog-footer {
display: flex;
justify-content: flex-end;
.el-button {
padding: 10px 20px;
display: flex;
align-items: center;
}
.el-button--primary {
background: linear-gradient(to right, #4a7cfd, #8154fc);
border: none;
&:hover,
&:focus {
background: linear-gradient(to right, #4a7cfd, #8154fc);
opacity: 0.85;
}
}
.confirm-inner {
display: inline-flex;
align-items: center;
}
.confirm-icon {
width: 16px;
height: 16px;
margin-right: 4px;
}
}
/* 大档:上下各留 10px 让边框和圆角可见 */
::v-deep .el-dialog.custom-dialog.custom-dialog--large {
margin: 10px auto !important;
height: calc(100vh - 20px);
max-height: calc(100vh - 20px);
border-radius: 10px;
}
::v-deep .el-dialog.custom-dialog.custom-dialog--large.custom-dialog--fluid {
max-width: var(--dialog-table-max);
}
::v-deep .el-dialog.custom-dialog--large .el-dialog__body {
max-height: calc(100vh - 20px - 110px);
flex: 1;
min-height: 0;
}
</style>
@@ -111,6 +111,10 @@ export default {
this.saving = true
// 提交成功后将关闭对话框的逻辑交给父组件处理
this.$emit('submit', this.form)
// 兜底:父组件若未在回调内重置 saving,3 秒后强制释放加载状态
setTimeout(() => {
this.saving = false
}, 3000)
}
})
},
@@ -656,6 +656,7 @@ export default {
.drawer-footer {
position: absolute;
bottom: 0;
z-index: 2;
width: 100%;
border-top: 1px solid #e8e8e8;
padding: 10px 16px;
@@ -894,4 +895,4 @@ export default {
padding: 10px 0;
}
}
</style>
</style>
@@ -9,7 +9,7 @@
>
<el-form :model="deviceForm" :rules="rules" ref="deviceForm" label-width="auto">
<el-form-item :label="$t('manualAddDeviceDialog.deviceType')" prop="board">
<el-select v-model="deviceForm.board" :placeholder="$t('manualAddDeviceDialog.deviceTypePlaceholder')" style="width: 100%">
<el-select v-model="deviceForm.board" :placeholder="$t('manualAddDeviceDialog.deviceTypePlaceholder')" style="width: 100%" filterable>
<el-option
v-for="item in firmwareTypes"
:key="item.key"
@@ -2,14 +2,14 @@
<CustomDialog
:title="$t('modelConfigDialog.editModel')"
:visible.sync="dialogVisible"
width="57%"
width="960px"
size="large"
class="model-edit-dialog"
:confirmLoading="saving"
@confirm="handleSave"
@close="handleClose"
@open="handleOpen"
>
<div class="dialog-scroll-body">
<div class="header-row">
<div class="section-title">{{ $t("modelConfigDialog.modelInfo") }}</div>
<div class="switch-group">
@@ -93,7 +93,6 @@
</div>
</template>
</el-form>
</div>
</CustomDialog>
</template>
@@ -331,6 +330,17 @@ export default {
if (!configJson.hasOwnProperty(field.prop)) {
configJson[field.prop] = "";
} else if (field.type === "json-textarea") {
// 兼容已保存为 JSON 字符串的字典,避免再次序列化后无法编辑。
if (typeof configJson[field.prop] === "string") {
try {
const parsed = JSON.parse(configJson[field.prop]);
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
configJson[field.prop] = parsed;
}
} catch {
// 保留非法值的显示,由现有校验提示用户修正。
}
}
this.$set(
this.fieldJsonMap,
field.prop,
@@ -466,19 +476,16 @@ export default {
<style lang="scss" scoped>
@import '@/styles/global.scss';
::v-deep .el-dialog {
margin-top: 6vh !important;
}
::v-deep .el-dialog__body {
max-height: 60vh;
overflow-y: auto;
@include scrollbar-style;
}
.model-edit-dialog {
::v-deep .el-dialog__body {
padding: 24px 40px;
}
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.section-title {
@@ -511,7 +518,7 @@ export default {
.section-divider {
height: 1px;
background: #e9e9e9;
margin-bottom: 16px;
margin-bottom: 18px;
}
.form-row {
@@ -521,10 +528,13 @@ export default {
}
::v-deep .el-input__inner {
height: 32px;
height: 38px;
}
::v-deep .el-form-item {
margin-bottom: 10px;
margin-bottom: 14px;
}
::v-deep .el-form-item__label {
line-height: 38px;
}
}
</style>
+11 -29
View File
@@ -2,11 +2,13 @@
<CustomDialog
:title="title"
:visible.sync="visible"
width="600px"
width="720px"
class="param-dialog-wrapper"
@confirm="submit"
@close="cancel"
:confirmLoading="saving"
:confirmText="$t('button.ok')"
:cancelText="$t('button.cancel')"
>
<div class="dialog-container">
<el-form :model="form" :rules="rules" ref="form" label-width="auto" label-position="left" class="param-form">
@@ -147,6 +149,7 @@ export default {
cancel() {
this.saving = false; // 取消时重置状态
this.dialogKey = Date.now();
this.$emit('update:visible', false);
this.$emit('cancel');
},
@@ -183,36 +186,19 @@ export default {
};
</script>
<style>
.custom-param-dialog {
border-radius: 16px !important;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15) !important;
border: none !important;
.el-dialog__header {
display: none;
}
.el-dialog__body {
padding: 0 !important;
border-radius: 16px;
}
}
</style>
<style scoped lang="scss">
.param-dialog-wrapper {
.param-form {
.form-item {
margin-bottom: 20px;
margin-bottom: 28px;
:deep(.el-form-item__label) {
color: #475569;
font-weight: 500;
padding-right: 12px;
padding-right: 14px;
text-align: right;
font-size: 14px;
font-size: 15px;
letter-spacing: 0.2px;
line-height: 44px;
}
}
@@ -221,7 +207,7 @@ export default {
background-color: #ffffff;
border-radius: 8px;
border: 1px solid #e2e8f0;
height: 42px;
height: 44px;
padding: 0 14px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 14px;
@@ -248,7 +234,7 @@ export default {
background-color: #ffffff;
border-radius: 8px;
border: 1px solid #e2e8f0;
height: 42px;
height: 44px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 14px;
color: #334155;
@@ -272,7 +258,7 @@ export default {
background-color: #ffffff;
border-radius: 8px;
border: 1px solid #e2e8f0;
padding: 12px 14px;
padding: 10px 14px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 14px;
color: #334155;
@@ -291,10 +277,6 @@ export default {
}
}
}
.remark-item :deep(.el-form-item__label) {
margin-top: -4px;
}
}
}
</style>
@@ -2,7 +2,8 @@
<CustomDialog
:title="title"
:visible.sync="visible"
width="57%"
width="960px"
size="large"
class="provider-dialog-wrapper"
@confirm="submit"
@close="handleClose"
@@ -308,6 +309,11 @@ export default {
this.resetForm();
}
});
// 兜底:父组件若未在回调内清 saving,3 秒后强制释放加载状态
setTimeout(() => {
this.saving = false;
}, 3000);
}
});
},
@@ -75,6 +75,8 @@
<el-select
v-model="replacementWordIds"
multiple
filterable
collapse-tags
:placeholder="$t('replacementDialog.selectPlaceholder')"
class="replacement-word-select"
>
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<CustomDialog :visible.sync="localVisible" :title="$t('modelConfig.voiceManagement')" width="90%"
<CustomDialog :visible.sync="localVisible" :title="$t('modelConfig.voiceManagement')" width="80%" size="large"
:close-on-click-modal="true" :destroy-on-close="false" :footer="false" :append-to-body="true"
@close="handleClose">
<div class="scroll-wrapper">
Loaded 100 of 221 files, more files were not shown because too many files have changed in this diff. Show more