You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
367 lines
16 KiB
367 lines
16 KiB
import 'package:get/get.dart';
|
|
import 'package:get_storage/get_storage.dart';
|
|
|
|
import '../models/agent_module.dart';
|
|
|
|
/// AI 页 / 广场页共享的模块编排状态。
|
|
///
|
|
/// 只保存"AI 页启用了哪些模块、按什么顺序",广场页展示的是全集,
|
|
/// 通过 [isEnabled] 反查某个模块是否已添加。用户的编排结果持久化在
|
|
/// GetStorage,卸载重装才会回到默认值。
|
|
class AgentModuleService extends GetxService {
|
|
static AgentModuleService get to => Get.find();
|
|
|
|
static const String _storageKey = 'ai_tab_module_ids';
|
|
|
|
/// 已经应用过默认编排的设备品类 id,保证「第一次连接」只铺一次
|
|
static const String _appliedCategoriesKey = 'ai_tab_device_defaults_applied';
|
|
|
|
/// 上一次**自动铺**下去的编排清单。用来判断用户有没有自己动过,
|
|
/// 见 [applyDeviceDefaults]。
|
|
static const String _lastAppliedKey = 'ai_tab_device_defaults_last';
|
|
|
|
final GetStorage _storage = GetStorage();
|
|
|
|
/// AI 页当前展示的模块 id,顺序即展示顺序
|
|
final RxList<String> enabledIds = <String>[].obs;
|
|
|
|
@override
|
|
void onInit() {
|
|
super.onInit();
|
|
_load();
|
|
}
|
|
|
|
void _load() {
|
|
final dynamic raw = _storage.read(_storageKey);
|
|
if (raw is List && raw.isNotEmpty) {
|
|
// 过滤掉注册表里已不存在的 id(版本升级删过模块的情况)
|
|
final list = raw
|
|
.map((e) => e.toString())
|
|
.where((id) => AgentModules.byId(id) != null)
|
|
.toList();
|
|
enabledIds.assignAll(list);
|
|
|
|
// ⚠️ 当前这份编排如果是**设备默认铺下来的**(用户没动过),三个一次性迁移
|
|
// 一律跳过。它们是给「用户自己的编排」补新功能用的,而设备编排本来就该
|
|
// 只有那几项 —— 尤其 _migrateDefaultLayout 会把 defaultEnabled 里缺的全部
|
|
// 追加回来,等于把「连上香薰只显示 EaiCar/现场录音/语音纪要」直接抹掉。
|
|
//
|
|
// 2026-09-14 线上就是这么坏的:香薰编排铺成 3 项 → 重启 App → 迁移追加成
|
|
// 11 项 → 而基线 _lastAppliedKey 还停在 3 项 → 之后 applyDeviceDefaults
|
|
// 一比较发现「对不上」,判成用户动过编排,**从此再也不铺**。表现就是
|
|
// 连着香薰却显示全量功能,且怎么重连都恢复不了。
|
|
if (_matchesLastApplied()) return;
|
|
|
|
_migrateEmaiAssistant();
|
|
_migrateDefaultLayout();
|
|
_migrateDefaultOrder();
|
|
_rebaseDeviceDefaultsBaseline();
|
|
} else {
|
|
enabledIds.assignAll(AgentModules.defaultEnabled);
|
|
}
|
|
}
|
|
|
|
/// 当前编排是否与「上一次自动铺下去的那份」逐项相同 —— 即用户没动过。
|
|
bool _matchesLastApplied() {
|
|
if (!_storage.hasData(_lastAppliedKey)) return false;
|
|
final last = (_storage.read<List>(_lastAppliedKey) ?? const [])
|
|
.map((e) => e.toString())
|
|
.toList();
|
|
return last.isNotEmpty && _sameOrder(enabledIds, last);
|
|
}
|
|
|
|
/// 一次性纠正:把已经被 [_migrateDefaultLayout] 追加过的设备编排重新认作基线。
|
|
///
|
|
/// 修上面那条时序 bug 之前装过的机器,storage 里已经是「enabledIds=11 项、
|
|
/// 基线=3 项」的坏状态,而迁移带 migratedKey 不会再跑一次,光修 _load 救不回来。
|
|
///
|
|
/// 判据收得很紧,必须**同时**满足:基线存在、当前编排以基线为前缀、多出来的项
|
|
/// 全部来自 defaultEnabled —— 这正是 _migrateDefaultLayout「保持原序、把缺的
|
|
/// append 到尾部」留下的形态。
|
|
///
|
|
/// 纠正的是**基线**不是编排:把基线抬到当前值,界面此刻不变,等下次连上设备时
|
|
/// 由 applyDeviceDefaults 正常铺成该设备的编排 —— 那本来就是用户预期的时机。
|
|
/// (代价:用户如果**恰好**手动加的也全是 defaultEnabled 里的项、且加在尾部,
|
|
/// 会被误判成迁移污染,下次连设备时编排被重铺一次。两种误判范围相同,
|
|
/// 选这个是因为它不会在启动瞬间凭空改变用户看到的界面。)
|
|
void _rebaseDeviceDefaultsBaseline() {
|
|
const rebaseKey = 'ai_tab_device_defaults_rebase_v1';
|
|
if (_storage.read<bool>(rebaseKey) == true) return;
|
|
_storage.write(rebaseKey, true);
|
|
if (!_storage.hasData(_lastAppliedKey)) return;
|
|
final last = (_storage.read<List>(_lastAppliedKey) ?? const [])
|
|
.map((e) => e.toString())
|
|
.toList();
|
|
if (!isLayoutMigrationArtifact(
|
|
enabled: enabledIds,
|
|
lastApplied: last,
|
|
defaults: AgentModules.defaultEnabled,
|
|
)) {
|
|
return;
|
|
}
|
|
_storage.write(_lastAppliedKey, enabledIds.toList());
|
|
}
|
|
|
|
/// 当前编排看起来是不是「设备默认编排 + 被一次性迁移追加的全量项」。
|
|
///
|
|
/// 形态要求([_migrateDefaultLayout] 的行为特征):
|
|
/// 1. [lastApplied] 非空,且原样是 [enabled] 的**前缀**(顺序都没动);
|
|
/// 2. [enabled] 确实更长;
|
|
/// 3. 多出来的项**全部**出自 [defaults] —— 迁移只会补这些。
|
|
///
|
|
/// 抽成纯函数是为了能脱离 GetStorage 测试,见 test/agent_module_defaults_test.dart。
|
|
static bool isLayoutMigrationArtifact({
|
|
required List<String> enabled,
|
|
required List<String> lastApplied,
|
|
required List<String> defaults,
|
|
}) {
|
|
if (lastApplied.isEmpty || enabled.length <= lastApplied.length) {
|
|
return false;
|
|
}
|
|
for (var i = 0; i < lastApplied.length; i++) {
|
|
if (enabled[i] != lastApplied[i]) return false;
|
|
}
|
|
for (final id in enabled.sublist(lastApplied.length)) {
|
|
if (!defaults.contains(id)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// 一次性迁移:默认排序调整(语音记录助手→现场录音→通话录音→…)。
|
|
///
|
|
/// 只在用户**没自己排过**时才动——即当前启用集合与默认集合完全一致,
|
|
/// 只是顺序不同。用户手动拖过或增删过的编排一律不碰。
|
|
void _migrateDefaultOrder() {
|
|
const migratedKey = 'ai_tab_order_v6_migrated';
|
|
if (_storage.read<bool>(migratedKey) == true) return;
|
|
_storage.write(migratedKey, true);
|
|
final current = enabledIds.toSet();
|
|
final wanted = AgentModules.defaultEnabled
|
|
.where((id) => AgentModules.byId(id) != null)
|
|
.toList(growable: false);
|
|
if (current.length != wanted.length) return;
|
|
if (!current.containsAll(wanted)) return;
|
|
if (_sameOrder(enabledIds, wanted)) return;
|
|
enabledIds.assignAll(wanted);
|
|
_persist();
|
|
}
|
|
|
|
/// 一次性迁移:默认编排从 3 个扩到 10 个(补上通话录音/通话翻译/翻译类)。
|
|
/// 老用户存的还是旧的三件套,不补的话新功能他们看不到;只补一次,
|
|
/// 之后用户自己删掉就不再塞回来。
|
|
void _migrateDefaultLayout() {
|
|
const migratedKey = 'ai_tab_layout_v2_migrated';
|
|
if (_storage.read<bool>(migratedKey) == true) return;
|
|
_storage.write(migratedKey, true);
|
|
var changed = false;
|
|
for (final id in AgentModules.defaultEnabled) {
|
|
if (AgentModules.byId(id) == null) continue;
|
|
if (enabledIds.contains(id)) continue;
|
|
enabledIds.add(id);
|
|
changed = true;
|
|
}
|
|
if (changed) _persist();
|
|
}
|
|
|
|
/// 一次性迁移:AI 页底部那颗 AI 球下线了,入口改成「EMAI助手」模块。
|
|
/// 老用户的编排里没有这个 id,不补的话他们就彻底进不去 AI 对话了。
|
|
/// 只补一次,用户之后自己删掉就不再塞回来。
|
|
void _migrateEmaiAssistant() {
|
|
const migratedKey = 'ai_tab_emai_migrated';
|
|
if (_storage.read<bool>(migratedKey) == true) return;
|
|
_storage.write(migratedKey, true);
|
|
if (enabledIds.contains(AgentModules.emaiAssistant)) return;
|
|
// 放在「语音记录助手」后面,与默认编排的位置一致
|
|
final at = enabledIds.indexOf(AgentModules.meeting);
|
|
enabledIds.insert(at >= 0 ? at + 1 : 0, AgentModules.emaiAssistant);
|
|
_persist();
|
|
}
|
|
|
|
void _persist() => _storage.write(_storageKey, enabledIds.toList());
|
|
|
|
/// AI 页要渲染的模块(已按用户顺序展开)
|
|
List<AgentModule> get enabledModules => enabledIds
|
|
.map(AgentModules.byId)
|
|
.whereType<AgentModule>()
|
|
.toList(growable: false);
|
|
|
|
/// 对话助手类模块(2026-09-24):统一由底部中间的「AI」按钮进入,**首页网格不再展示**。
|
|
/// 以后新增设备专属助手(像 EaiCar 这种)加到这里,AI 按钮会按设备自动接上。
|
|
///
|
|
/// ⚠️ 只是不在首页**显示**,编排里的 id 原样保留:设备默认编排、持久化、广场都不受影响,
|
|
/// 设备按键唤醒找 agent(AgentDefaults.primaryModuleForVendor)也照旧能找到它们。
|
|
static const Set<String> assistantIds = {
|
|
AgentModules.emaiAssistant,
|
|
AgentModules.eaicarChat,
|
|
};
|
|
|
|
/// 首页网格展示的模块:[enabledModules] 去掉对话助手。
|
|
List<AgentModule> get homeModules => enabledModules
|
|
.where((m) => !assistantIds.contains(m.id))
|
|
.toList(growable: false);
|
|
|
|
/// 首页拖拽排序。[from]/[to] 是 [homeModules] 里的下标,换算成 [enabledIds] 的下标再挪,
|
|
/// 这样被隐藏的助手在编排里的相对位置不变。越界的语义与 [move] 一致:直接忽略。
|
|
void moveOnHome(int from, int to) {
|
|
final ids = homeModules.map((m) => m.id).toList(growable: false);
|
|
if (from == to) return;
|
|
if (from < 0 || from >= ids.length) return;
|
|
if (to < 0 || to >= ids.length) return;
|
|
move(enabledIds.indexOf(ids[from]), enabledIds.indexOf(ids[to]));
|
|
}
|
|
|
|
/// 广场页展示的全集
|
|
List<AgentModule> get allModules => AgentModules.all;
|
|
|
|
/// 广场搜索:标题/副标题/id 命中即可,支持模糊匹配。
|
|
///
|
|
/// 三档依次放宽:
|
|
/// 1) 子串命中("翻译" → 同声翻译、面对面翻译…)
|
|
/// 2) 按空格拆词后全部命中("英文 学习" → 英文学习)
|
|
/// 3) 子序列命中("英学" → 英文学习;"tran" → translation 类 id)
|
|
/// 空关键词返回全集。
|
|
static List<AgentModule> search(List<AgentModule> source, String rawQuery) {
|
|
final q = rawQuery.trim().toLowerCase();
|
|
if (q.isEmpty) return source;
|
|
|
|
bool matches(AgentModule m) {
|
|
final haystack =
|
|
'${m.title} ${m.subtitle ?? ''} ${m.id}'.toLowerCase();
|
|
if (haystack.contains(q)) return true;
|
|
|
|
final terms = q.split(RegExp(r'\s+')).where((t) => t.isNotEmpty);
|
|
if (terms.isNotEmpty && terms.every(haystack.contains)) return true;
|
|
|
|
return _isSubsequence(q.replaceAll(RegExp(r'\s+'), ''), haystack);
|
|
}
|
|
|
|
return source.where(matches).toList(growable: false);
|
|
}
|
|
|
|
/// query 的字符能否按顺序(可跳过)在 text 里找到
|
|
static bool _isSubsequence(String query, String text) {
|
|
if (query.isEmpty) return true;
|
|
var i = 0;
|
|
for (var j = 0; j < text.length && i < query.length; j++) {
|
|
if (text[j] == query[i]) i++;
|
|
}
|
|
return i == query.length;
|
|
}
|
|
|
|
/// 连上某品类的设备时,把 AI 页铺成这个品类的默认编排。
|
|
///
|
|
/// 规则只有一条:**只要用户没自己动过编排,AI 页就跟着当前设备走**。
|
|
/// 于是「连上车载香薰 → 只剩 EaiCar / 现场录音 / 语音纪要」「换回耳机 → 恢复全量」
|
|
/// 都成立,而用户一旦增删或排序过,就再也不会被自动改动。
|
|
///
|
|
/// 判据是 [_lastAppliedKey]:上一次**自动铺**下去的那份清单。
|
|
/// 当前编排和它逐项相同 = 用户没动过;不同 = 动过,直接放手。
|
|
///
|
|
/// ⚠️ 2026-09-11 之前的规则是「每个品类只铺一次」,切设备时编排不跟着换——
|
|
/// 表现是先连耳机(铺 11 个)再连香薰,香薰的三个编排永远铺不上。
|
|
/// 别改回去。
|
|
///
|
|
/// ⚠️ 老装机没有 [_lastAppliedKey] 这条记录,无法判断有没有动过,
|
|
/// 按「允许铺一次」处理:升级后第一次连设备会按品类重铺一次 AI 页。
|
|
///
|
|
/// 采用**整套替换**而不是追加:支架/香薰只有录音与开聊,AI 页就该只剩那几个;
|
|
/// 耳机能跑通话和翻译,就铺全量。追加式做不到「只显示这几个」。
|
|
bool applyDeviceDefaults(String? categoryId) {
|
|
final defaults = AgentModules.defaultsForCategory(categoryId);
|
|
if (defaults.isEmpty || categoryId == null) return false;
|
|
|
|
final next = defaults
|
|
.where((id) => AgentModules.byId(id) != null)
|
|
.toList(growable: false);
|
|
if (next.isEmpty) return false;
|
|
|
|
final hasRecord = _storage.hasData(_lastAppliedKey);
|
|
if (hasRecord) {
|
|
final last = (_storage.read<List>(_lastAppliedKey) ?? const [])
|
|
.map((e) => e.toString())
|
|
.toList();
|
|
// 用户动过编排 → 不再自动改动
|
|
if (!_sameOrder(enabledIds, last)) return false;
|
|
}
|
|
|
|
final changed = !_sameOrder(enabledIds, next);
|
|
if (changed) enabledIds.assignAll(next);
|
|
_storage.write(_lastAppliedKey, next);
|
|
|
|
final applied = (_storage.read<List>(_appliedCategoriesKey) ?? const [])
|
|
.map((e) => e.toString())
|
|
.toList();
|
|
if (!applied.contains(categoryId)) {
|
|
applied.add(categoryId);
|
|
_storage.write(_appliedCategoriesKey, applied);
|
|
}
|
|
if (changed) _persist();
|
|
return changed;
|
|
}
|
|
|
|
static bool _sameOrder(List<String> a, List<String> b) {
|
|
if (a.length != b.length) return false;
|
|
for (var i = 0; i < a.length; i++) {
|
|
if (a[i] != b[i]) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// 该品类的默认编排是否已经铺过(调试/重置用)
|
|
bool hasAppliedDefaults(String categoryId) =>
|
|
(_storage.read<List>(_appliedCategoriesKey) ?? const [])
|
|
.map((e) => e.toString())
|
|
.contains(categoryId);
|
|
|
|
bool isEnabled(String id) => enabledIds.contains(id);
|
|
|
|
/// 从广场添加到 AI 页:新模块一律追加到最后,符合"新添加的放在最下面"
|
|
void add(String id) {
|
|
if (AgentModules.byId(id) == null || enabledIds.contains(id)) return;
|
|
enabledIds.add(id);
|
|
_persist();
|
|
}
|
|
|
|
/// 从 AI 页移除,模块回到广场(广场是全集,移除后 isEnabled 变 false 即可)
|
|
void remove(String id) {
|
|
if (enabledIds.remove(id)) _persist();
|
|
}
|
|
|
|
void toggle(String id) => isEnabled(id) ? remove(id) : add(id);
|
|
|
|
/// 拖拽排序。newIndex 采用 ReorderableListView 的语义:
|
|
/// 往下拖时它给的是"插入前"的下标,需要先减一。
|
|
void reorder(int oldIndex, int newIndex) {
|
|
if (oldIndex < 0 || oldIndex >= enabledIds.length) return;
|
|
var target = newIndex;
|
|
if (target > oldIndex) target -= 1;
|
|
if (target < 0) target = 0;
|
|
if (target >= enabledIds.length) target = enabledIds.length - 1;
|
|
if (target == oldIndex) return;
|
|
final id = enabledIds.removeAt(oldIndex);
|
|
enabledIds.insert(target, id);
|
|
_persist();
|
|
}
|
|
|
|
/// 把 from 位置的模块直接挪到 to 位置(拖到哪个槽就占哪个槽)。
|
|
///
|
|
/// 与 [reorder] 的区别:这里的 to 是「目标槽位的真实下标」,不需要做
|
|
/// ReorderableListView 那种减一修正。AI 页混排布局(前三个是网格、
|
|
/// 后面是横条)用的是这个。
|
|
void move(int from, int to) {
|
|
if (from == to) return;
|
|
if (from < 0 || from >= enabledIds.length) return;
|
|
if (to < 0 || to >= enabledIds.length) return;
|
|
final id = enabledIds.removeAt(from);
|
|
enabledIds.insert(to, id);
|
|
_persist();
|
|
}
|
|
|
|
/// 恢复默认编排(连同「已铺过哪些品类」的记录一起清掉,
|
|
/// 这样下次连上设备会重新按该设备的默认功能铺开)
|
|
void resetToDefault() {
|
|
enabledIds.assignAll(AgentModules.defaultEnabled);
|
|
_storage.remove(_appliedCategoriesKey);
|
|
_persist();
|
|
}
|
|
}
|
|
|