Browse Source

client:EMAI 耳机与车载共用位置日期,定位只有市名时用高德补路名;Xcode 打开前把生成的 SPM 包对齐到 iOS 16.0,避免反复报平台版本不够。构建号 1.0.2+26

Co-authored-by: Cursor <cursoragent@cursor.com>
along-test
Rodger-Wang 2 days ago
parent
commit
f2dfa09d97
  1. 19
      apps/client/ios/Podfile
  2. 8
      apps/client/ios/Runner.xcodeproj/project.pbxproj
  3. 2
      apps/client/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
  4. 118
      apps/client/ios/sync_spm_platform.sh
  5. 48
      apps/client/lib/data/services/bailian_multimodal_service.dart
  6. 20
      apps/client/lib/data/services/bailian_native_dialog.dart
  7. 43
      apps/client/lib/data/services/device_location_service.dart
  8. 4
      apps/client/lib/modules/eaicar/module.dart
  9. 8
      apps/client/lib/modules/emai/module.dart
  10. 5
      apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt
  11. 36
      apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/BailianDialogClient.kt
  12. 7
      apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift
  13. 39
      apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/BailianDialogClient.swift
  14. 48
      apps/client/local_plugins/location_service/android/build.gradle
  15. 100
      apps/client/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/PlaceLocator.kt
  16. 36
      apps/client/local_plugins/location_service/ios/location_service/Sources/location_service/PlaceLocator.swift
  17. 5
      apps/client/local_plugins/location_service/lib/location_service.dart
  18. 488
      apps/client/pubspec.lock
  19. 2
      apps/client/pubspec.yaml
  20. 24
      apps/client/test/bailian_native_parity_test.dart
  21. 33
      apps/client/test/device_location_date_test.dart

19
apps/client/ios/Podfile

@ -63,20 +63,15 @@ post_install do |installer|
end end
end end
# ---- Auto patch: bump FlutterGeneratedPluginSwiftPackage iOS platform ---- # ---- Auto patch: bump generated SPM packages to the app deployment target ----
# Flutter 生成的中介包总是写死 .iOS("12.0"),与 Podfile 中的 # Flutter 生成的中介包写死 .iOS("13.0"),本地插件要求 16.0,会报
# platform :ios, '16.0' 不一致,会报 # "requires minimum platform version 16.0 ... but this target supports 13.0/15.0"。
# "requires minimum platform version 15.0/16.0 ... but this target supports 12.0"。 # 与 Xcode scheme 里同一份脚本,pod install 时也对齐一次。
# 这里自动同步为 16.0。
generated_pkg = File.expand_path( generated_pkg = File.expand_path(
'Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Package.swift', __dir__) 'Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Package.swift', __dir__)
if File.exist?(generated_pkg) sync_spm = File.expand_path('sync_spm_platform.sh', __dir__)
content = File.read(generated_pkg) if File.exist?(generated_pkg) && File.exist?(sync_spm)
new_content = content.gsub(/\.iOS\("\d+\.\d+"\)/, '.iOS("16.0")') system(sync_spm) or raise '[Podfile patch] sync_spm_platform.sh 失败'
if new_content != content
File.write(generated_pkg, new_content)
puts '[Podfile patch] Bumped FlutterGeneratedPluginSwiftPackage iOS platform to 16.0'
end
end end
# ---- Auto patch: comment out illegal `<netinet6/in6.h>` imports in all Pods sources ---- # ---- Auto patch: comment out illegal `<netinet6/in6.h>` imports in all Pods sources ----

8
apps/client/ios/Runner.xcodeproj/project.pbxproj

@ -431,7 +431,7 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; shellScript = "\"$SRCROOT/sync_spm_platform.sh\"\n/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
}; };
B6CA63E72817925CE1A152C3 /* [CP] Embed Pods Frameworks */ = { B6CA63E72817925CE1A152C3 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
@ -649,7 +649,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 24; CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = B8AJQ7ACU7; DEVELOPMENT_TEAM = B8AJQ7ACU7;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@ -843,7 +843,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 24; CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = B8AJQ7ACU7; DEVELOPMENT_TEAM = B8AJQ7ACU7;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@ -877,7 +877,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 24; CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = B8AJQ7ACU7; DEVELOPMENT_TEAM = B8AJQ7ACU7;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;

2
apps/client/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme

@ -10,7 +10,7 @@
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction"> ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent <ActionContent
title = "Run Prepare Flutter Framework Script" title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;"> scriptText = "&quot;$SRCROOT/sync_spm_platform.sh&quot;&#10;/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable> <EnvironmentBuildable>
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"

118
apps/client/ios/sync_spm_platform.sh

@ -24,10 +24,12 @@
# ./ios/sync_spm_platform.sh # 在 apps/client 下随时手动跑,幂等 # ./ios/sync_spm_platform.sh # 在 apps/client 下随时手动跑,幂等
# ./ios/sync_spm_platform.sh --check # 只检查不改写,不一致时退出码 1(给 CI 用) # ./ios/sync_spm_platform.sh --check # 只检查不改写,不一致时退出码 1(给 CI 用)
# #
# 挂进 Xcode(推荐,省得每次记着手动跑): # Xcode 在构建进程启动时就读完 Package.swift,scheme 里的 pre-action 改文件
# Product → Scheme → Edit Scheme → Build → Pre-actions → + New Run Script Action # 已经晚了(当前 Xcode 还会把 Flutter 写的 13.0 抬成 15.0 再去比插件的 16.0)。
# Provide build settings from: Runner # 所以除了构建前再跑一遍,脚本还会装一个只盯这份生成包的 LaunchAgent:
# 脚本内容: "$SRCROOT/sync_spm_platform.sh" # flutter pub get 一把文件写回 13.0,它就立刻改回工程的 deployment target。
# 标题必须保持 "Run Prepare Flutter Framework Script",Flutter 靠这串判断
# scheme 已迁移;改掉的话下次 flutter pub get 会再插一条、把这里冲乱。
# #
# ⚠️ 不依赖 flutter 命令:Xcode 的 pre-action 环境 PATH 很干净,调 flutter 经常找不到。 # ⚠️ 不依赖 flutter 命令:Xcode 的 pre-action 环境 PATH 很干净,调 flutter 经常找不到。
# 这里只做纯文本改写,几毫秒完成。代价是生成包必须已经存在(跑过一次 # 这里只做纯文本改写,几毫秒完成。代价是生成包必须已经存在(跑过一次
@ -44,11 +46,65 @@ set -euo pipefail
IOS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" IOS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CLIENT_DIR="$(cd "$IOS_DIR/.." && pwd)" CLIENT_DIR="$(cd "$IOS_DIR/.." && pwd)"
PBXPROJ="$IOS_DIR/Runner.xcodeproj/project.pbxproj" PBXPROJ="$IOS_DIR/Runner.xcodeproj/project.pbxproj"
MANIFEST="$IOS_DIR/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Package.swift" MANIFESTS=(
"$IOS_DIR/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Package.swift"
"$IOS_DIR/Flutter/ephemeral/Packages/.packages/FlutterFramework/Package.swift"
)
CHECK_ONLY=0 CHECK_ONLY=0
[[ "${1:-}" == "--check" ]] && CHECK_ONLY=1 [[ "${1:-}" == "--check" ]] && CHECK_ONLY=1
# flutter pub get 会把生成包写回 13.0。Xcode 读这份文件的时机早于任何构建脚本,
# 所以要在写回的那一下就改回来,而不是等下一次点构建。
install_watcher() {
[[ $CHECK_ONLY -eq 1 ]] && return 0
local uid dir label plist tmp
uid="$(id -u)"
dir="$IOS_DIR/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage"
[[ -d "$dir" ]] || return 0
label="com.yimai.eaimar.sync-spm-platform"
plist="$HOME/Library/LaunchAgents/${label}.plist"
mkdir -p "$HOME/Library/LaunchAgents"
tmp="$(mktemp)"
cat > "$tmp" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${label}</string>
<key>ProgramArguments</key>
<array>
<string>${IOS_DIR}/sync_spm_platform.sh</string>
<string>--from-watcher</string>
</array>
<key>WatchPaths</key>
<array>
<string>${dir}</string>
</array>
<key>ThrottleInterval</key>
<integer>1</integer>
</dict>
</plist>
EOF
if [[ -f "$plist" ]] && cmp -s "$tmp" "$plist"; then
rm -f "$tmp"
else
mv "$tmp" "$plist"
launchctl bootout "gui/${uid}/${label}" >/dev/null 2>&1 || true
launchctl bootstrap "gui/${uid}" "$plist" >/dev/null 2>&1 || true
fi
# 已经在跑就别再 bootstrap;没加载过则补一次
if ! launchctl print "gui/${uid}/${label}" >/dev/null 2>&1; then
launchctl bootstrap "gui/${uid}" "$plist" >/dev/null 2>&1 || true
fi
}
# 监视器回调自己时不要再去装卸监视器,否则写文件会把自己叫醒。
if [[ "${1:-}" != "--from-watcher" ]]; then
install_watcher || true
fi
red() { printf "\033[31m%s\033[0m\n" "$*"; } red() { printf "\033[31m%s\033[0m\n" "$*"; }
green() { printf "\033[32m%s\033[0m\n" "$*"; } green() { printf "\033[32m%s\033[0m\n" "$*"; }
blue() { printf "\033[34m%s\033[0m\n" "$*"; } blue() { printf "\033[34m%s\033[0m\n" "$*"; }
@ -95,6 +151,8 @@ if ! le "$FLOOR" "$CEILING"; then
fi fi
# ── 4) 对齐生成包 ─────────────────────────────────────────────────────────── # ── 4) 对齐生成包 ───────────────────────────────────────────────────────────
# 插件包必须存在。FlutterFramework 是可选的,在才改。
MANIFEST="${MANIFESTS[0]}"
if [[ ! -f "$MANIFEST" ]]; then if [[ ! -f "$MANIFEST" ]]; then
red "✗ 生成包还不存在:${MANIFEST#"$CLIENT_DIR"/}" red "✗ 生成包还不存在:${MANIFEST#"$CLIENT_DIR"/}"
red " 先在 apps/client 下跑一次 \`flutter pub get\`(或 \`flutter build ios --config-only\`)再执行本脚本。" red " 先在 apps/client 下跑一次 \`flutter pub get\`(或 \`flutter build ios --config-only\`)再执行本脚本。"
@ -123,24 +181,34 @@ ensure_local_plugin_links() {
} }
ensure_local_plugin_links ensure_local_plugin_links
CURRENT_RAW="$(grep -o '\.iOS([^)]*)' "$MANIFEST" | head -1 | sed 's/^\.iOS(//; s/)$//')" align_one() {
CURRENT="$(normalize "$CURRENT_RAW")" local manifest="$1"
[[ -f "$manifest" ]] || return 0
if [[ "$CURRENT" == "$CEILING" ]]; then local current_raw current
green "✓ 生成包已是 iOS ${CURRENT},无需改动" current_raw="$(grep -o '\.iOS([^)]*)' "$manifest" | head -1 | sed 's/^\.iOS(//; s/)$//')"
exit 0 current="$(normalize "$current_raw")"
fi local label="${manifest#"$CLIENT_DIR"/}"
if [[ "$current" == "$CEILING" ]]; then
if [[ $CHECK_ONLY -eq 1 ]]; then green "✓ ${label} 已是 iOS ${current}"
red "✗ 生成包是 iOS ${CURRENT},应为 ${CEILING}(跑 ./ios/sync_spm_platform.sh 修)" return 0
exit 1 fi
fi if [[ $CHECK_ONLY -eq 1 ]]; then
red "✗ ${label} 是 iOS ${current},应为 ${CEILING}(跑 ./ios/sync_spm_platform.sh 修)"
# 只替换 platforms 段里的第一个 .iOS(...),不碰文件其余内容 return 1
/usr/bin/sed -i '' "s/\.iOS($(printf '%s' "$CURRENT_RAW" | sed 's/[.[\*^$\/]/\\&/g'))/.iOS(\"$CEILING\")/" "$MANIFEST" fi
# 只替换 platforms 段里的第一个 .iOS(...),不碰文件其余内容
/usr/bin/sed -i '' "s/\.iOS($(printf '%s' "$current_raw" | sed 's/[.[\*^$\/]/\\&/g'))/.iOS(\"$CEILING\")/" "$manifest"
local new
new="$(normalize "$(grep -o '\.iOS([^)]*)' "$manifest" | head -1 | sed 's/^\.iOS(//; s/)$//')")"
if [[ "$new" != "$CEILING" ]]; then
red "✗ 改写失败 ${label},现在是 ${new}"
return 1
fi
green "✓ ${label} 平台版本 ${current} → ${CEILING}"
}
NEW="$(normalize "$(grep -o '\.iOS([^)]*)' "$MANIFEST" | head -1 | sed 's/^\.iOS(//; s/)$//')")" fail=0
if [[ "$NEW" != "$CEILING" ]]; then for m in "${MANIFESTS[@]}"; do
red "✗ 改写失败,现在是 ${NEW}"; exit 1 align_one "$m" || fail=1
fi done
green "✓ 生成包平台版本 ${CURRENT} → ${CEILING},可以回 Xcode 构建了" exit "$fail"

48
apps/client/lib/data/services/bailian_multimodal_service.dart

@ -202,10 +202,35 @@ class BailianMultimodalService extends GetxService {
/// 服务是单例、两条链路轮流用,所以在 [start] 时定,不由调用方每次传。 /// 服务是单例、两条链路轮流用,所以在 [start] 时定,不由调用方每次传。
String directiveSource = 'emai'; String directiveSource = 'emai';
/// 需要在 Start 时带上「位置 + 日期」的百炼应用 ID(由模块在 registerServices 里登记, /// 额外需要在 Start 时带上「位置 + 日期」的百炼应用 ID。
/// 目前只有车载香薰)。其它应用(EMAI 耳机)的 Start 报文一个字节不变。 /// 车载助手在自己的模块里登记。EMAI 不进这个集合:它就是 [resolveAppId] 的默认应用
/// (含后台覆盖后的 id),由 [wantsClientContext] 认出来。
static final Set<String> clientContextAppIds = <String>{}; static final Set<String> clientContextAppIds = <String>{};
/// EMAI 与登记过的应用在 Start 时带位置、日期。其它应用一个字节不变。
static bool wantsClientContext(String appId) {
if (clientContextAppIds.contains(appId)) return true;
return appId.isNotEmpty && appId == resolveAppId();
}
/// `${location}` / `${date}` 与 `client_info.location`。只读缓存,不等定位。
/// 这个应用不需要时返回 null。没有缓存时地址是「未知」,日期照样给。
static Map<String, dynamic>? promptContext(String appId) {
if (!wantsClientContext(appId)) return null;
DeviceLocationService? loc;
try {
loc = DeviceLocationService.maybe;
} catch (_) {
loc = null;
}
final clientLocation = loc?.clientLocation();
return {
'location': loc?.placeText() ?? '未知',
'date': DeviceLocationService.dateText(),
if (clientLocation != null) 'clientLocation': clientLocation,
};
}
Future<bool> start({ Future<bool> start({
String mode = 'push2talk', String mode = 'push2talk',
String? voice, String? voice,
@ -227,16 +252,15 @@ class BailianMultimodalService extends GetxService {
directiveSource = source; directiveSource = source;
_taskId = _uuid(); _taskId = _uuid();
// 提示词「系统条件」里的 ${location} / ${date}:只读缓存,不等定位 // 提示词「系统条件」里的 ${location} / ${date}:只读缓存,不等定位
final withContext = clientContextAppIds.contains(_appId); final ctx = promptContext(_appId);
final loc = withContext ? DeviceLocationService.maybe : null; final clientLocation = ctx?['clientLocation'] as Map<String, String>?;
final clientLocation = loc?.clientLocation(); final promptParams = ctx == null
final promptParams = withContext ? const <String, String>{}
? <String, String>{ : <String, String>{
'location': loc?.placeText() ?? '未知', 'location': '${ctx['location']}',
'date': DeviceLocationService.dateText(), 'date': '${ctx['date']}',
} };
: const <String, String>{}; if (ctx != null) {
if (withContext) {
Logger.i( Logger.i(
_tag, '会话上下文: $promptParams, client_info.location=$clientLocation'); _tag, '会话上下文: $promptParams, client_info.location=$clientLocation');
} }

20
apps/client/lib/data/services/bailian_native_dialog.dart

@ -119,9 +119,22 @@ class BailianNativeDialog {
}, },
); );
try { try {
final appId = BailianMultimodalService.resolveAppId(_appIdOverride);
// 地址和日期的文案只在 Dart 算一份。原生负责写进 Start,不在这里等定位。
final ctx = BailianMultimodalService.promptContext(appId);
final rawLoc = ctx?['clientLocation'];
final locMap = rawLoc is Map
? rawLoc.map((k, v) => MapEntry('$k', '$v'))
: null;
if (ctx != null) {
Logger.i(
_tag,
'会话上下文: location=${ctx['location']}, date=${ctx['date']}, '
'client_info.location=${locMap == null ? '无' : '有'}');
}
final ok = await _channel.invokeMethod<bool>('start', { final ok = await _channel.invokeMethod<bool>('start', {
'apiKey': BailianMultimodalService.resolveApiKey(), 'apiKey': BailianMultimodalService.resolveApiKey(),
'appId': BailianMultimodalService.resolveAppId(_appIdOverride), 'appId': appId,
'workspaceId': 'workspaceId':
BailianMultimodalService.resolveWorkspaceId(_workspaceIdOverride), BailianMultimodalService.resolveWorkspaceId(_workspaceIdOverride),
'mode': mode, 'mode': mode,
@ -132,6 +145,11 @@ class BailianNativeDialog {
if (mcpToken != null && mcpToken.trim().isNotEmpty) 'mcpToken': mcpToken, if (mcpToken != null && mcpToken.trim().isNotEmpty) 'mcpToken': mcpToken,
// 工具名只在 Dart 维护一份:服务端加工具时改那一处就够了 // 工具名只在 Dart 维护一份:服务端加工具时改那一处就够了
'mcpAuthTools': BailianMultimodalService.mcpAuthTools, 'mcpAuthTools': BailianMultimodalService.mcpAuthTools,
if (ctx != null) 'promptLocation': ctx['location'],
if (ctx != null) 'promptDate': ctx['date'],
if (locMap != null) 'latitude': locMap['latitude'],
if (locMap != null) 'longitude': locMap['longitude'],
if ((locMap?['city_name'] ?? '').isNotEmpty) 'cityName': locMap!['city_name'],
}); });
_running = ok == true; _running = ok == true;
if (!_running) await _cancelSub(); if (!_running) await _cancelSub();

43
apps/client/lib/data/services/device_location_service.dart

@ -10,18 +10,20 @@ import '../../core/utils/logger.dart';
import '../../devices/device_hub.dart'; import '../../devices/device_hub.dart';
import '../../devices/device_vendors.dart'; import '../../devices/device_vendors.dart';
/// 给百炼会话提供「用户在哪、现在几点」(2026-09-25,车载香薰)。 /// 给百炼会话提供「用户在哪、现在几点」(2026-09-25 车载香薰,2026-10-09 起 EMAI 耳机共用)。
/// ///
/// 车载助手的提示词里有「系统条件」:`${location}`(用户当前位置)、`${date}`(今天的日期)。 /// 车载助手和 EMAI 的提示词里有「系统条件」:`${location}`(用户当前位置)、`${date}`(今天的日期)。
/// 以前一个都没传,AI 问天气只能反问「你要查哪里」。现在两条通道一起给 /// 以前一个都没传,AI 问天气只能反问「你要查哪里」。现在两条通道一起给
/// (见 [BailianMultimodalService.start]): /// (见 [BailianMultimodalService.start]):
/// - `client_info.location` = {latitude, longitude, city_name}(lumi 也是这么传经纬度的); /// - `client_info.location` = {latitude, longitude, city_name}(lumi 也是这么传经纬度的);
/// - `biz_params.user_prompt_params` = {location: 省市区, date: 年月日 星期 时:分}。 /// - `biz_params.user_prompt_params` = {location: 完整地址, date: 年月日 星期 时:分}。
/// `${location}` 优先高德返回的详细地址(路名、门牌),没有再退回省市区加街道。
/// 经纬度只进 `client_info.location`,不进这段要念出来的文案。
/// ///
/// 节奏照 lumi 的 LocationService:启动 / 回到前台时刷新,10 分钟内不重复,结果落盘; /// 节奏照 lumi 的 LocationService:启动 / 回到前台时刷新,10 分钟内不重复,结果落盘;
/// **建会话时只读缓存,从不等定位**,所以不拖慢唤醒。 /// **建会话时只读缓存,从不等定位**,所以不拖慢唤醒。
/// ///
/// 权限:车载香薰第一次连上(且 App 在前台)时申请一次「使用期间」定位权限, /// 权限:恒玄耳机或车载香薰第一次连上(且 App 在前台)时申请一次「使用期间」定位权限,
/// 用户拒绝后不再追问——没有权限就只是不带位置。 /// 用户拒绝后不再追问——没有权限就只是不带位置。
class DeviceLocationService extends GetxService with WidgetsBindingObserver { class DeviceLocationService extends GetxService with WidgetsBindingObserver {
static const String _tag = 'DeviceLocation'; static const String _tag = 'DeviceLocation';
@ -67,7 +69,8 @@ class DeviceLocationService extends GetxService with WidgetsBindingObserver {
} }
void _checkSessions(List list) { void _checkSessions(List list) {
if (list.any((s) => s.vendor == DeviceVendors.eaicar)) { if (list.any((s) =>
s.vendor == DeviceVendors.eaicar || s.vendor == DeviceVendors.bes)) {
unawaited(askPermissionOnce()); unawaited(askPermissionOnce());
} }
} }
@ -92,7 +95,7 @@ class DeviceLocationService extends GetxService with WidgetsBindingObserver {
if (state == AppLifecycleState.resumed) refresh(); if (state == AppLifecycleState.resumed) refresh();
} }
/// 车载香薰第一次连上时申请一次定位权限。只问一次,拒绝后不再打扰。 /// 耳机或车载香薰第一次连上时申请一次定位权限。只问一次,拒绝后不再打扰。
Future<void> askPermissionOnce() async { Future<void> askPermissionOnce() async {
if (_storage.read<bool>(_askedKey) == true) return; if (_storage.read<bool>(_askedKey) == true) return;
// 权限框要在前台弹;在后台(被动回连)时先不问,下次前台连上再说 // 权限框要在前台弹;在后台(被动回连)时先不问,下次前台连上再说
@ -103,7 +106,7 @@ class DeviceLocationService extends GetxService with WidgetsBindingObserver {
await _storage.write(_askedKey, true); await _storage.write(_askedKey, true);
try { try {
final st = await Permission.locationWhenInUse.request(); final st = await Permission.locationWhenInUse.request();
Logger.i(_tag, '车载香薰首次连接,申请定位权限 → $st'); Logger.i(_tag, '设备首次连接,申请定位权限 → $st');
if (st.isGranted || st.isLimited) await refresh(force: true); if (st.isGranted || st.isLimited) await refresh(force: true);
} catch (e) { } catch (e) {
Logger.w(_tag, '申请定位权限失败: $e'); Logger.w(_tag, '申请定位权限失败: $e');
@ -144,24 +147,20 @@ class DeviceLocationService extends GetxService with WidgetsBindingObserver {
String _s(String k) => (_place?[k] ?? '').toString().trim(); String _s(String k) => (_place?[k] ?? '').toString().trim();
/// `${location}` 的取值:省市区(去掉重复的直辖市名),拿不到就是「未知」。 /// `${location}` 的取值。优先完整地址;没有再拼省市区加街道。不含经纬度。
String placeText() { String placeText() => formatPlaceText(_place);
/// 纯函数,方便单测。`place` 的键与 [LocationService.currentPlace] 一致。
static String formatPlaceText(Map<String, dynamic>? place) {
String field(String k) => (place?[k] ?? '').toString().trim();
final address = field('address');
if (address.isNotEmpty) return address;
final parts = <String>[]; final parts = <String>[];
for (final k in ['province', 'city', 'district']) { for (final k in ['province', 'city', 'district', 'street']) {
final v = _s(k); final v = field(k);
if (v.isNotEmpty && !parts.contains(v)) parts.add(v); if (v.isNotEmpty && !parts.contains(v)) parts.add(v);
} }
if (parts.isEmpty) { if (parts.isEmpty) return '未知';
final addr = _s('address');
if (addr.isNotEmpty) return addr;
// 反查不到地名时用坐标描述,模型/插件还能据此判断城市,比「未知」强
final lat = _place?['latitude'];
final lng = _place?['longitude'];
if (lat is num && lng is num) {
return '经纬度 ${lat.toStringAsFixed(4)},${lng.toStringAsFixed(4)}';
}
return '未知';
}
return parts.join(); return parts.join();
} }

4
apps/client/lib/modules/eaicar/module.dart

@ -35,8 +35,8 @@ final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor(
entryRoute: Routes.eaicarChat, entryRoute: Routes.eaicarChat,
// 设备按键唤醒时接的就是这个应用(不是 EMAI)——见 AgentModuleDescriptor.bailianAppId // 设备按键唤醒时接的就是这个应用(不是 EMAI)——见 AgentModuleDescriptor.bailianAppId
bailianAppId: kEaiCarBailianAppId, bailianAppId: kEaiCarBailianAppId,
// 车载助手提示词要 ${location} / ${date}:登记本应用,Start 时带上位置与日期; // 车载助手提示词要 ${location} / ${date}:登记本应用,Start 时带上位置与日期。
// 位置服务也只为它常驻(EMAI 耳机的会话不带、不受影响)。 // 位置服务两边共用;EMAI 自己的应用 ID 不进这个集合,由 wantsClientContext 认。
registerServices: () { registerServices: () {
BailianMultimodalService.clientContextAppIds.add(kEaiCarBailianAppId); BailianMultimodalService.clientContextAppIds.add(kEaiCarBailianAppId);
if (!Get.isRegistered<DeviceLocationService>()) { if (!Get.isRegistered<DeviceLocationService>()) {

8
apps/client/lib/modules/emai/module.dart

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../../core/utils/ai_navigation.dart'; import '../../core/utils/ai_navigation.dart';
import '../../data/services/device_location_service.dart';
import '../../routes/app_routes.dart'; import '../../routes/app_routes.dart';
import '../module_descriptor.dart'; import '../module_descriptor.dart';
import 'bindings/emai_binding.dart'; import 'bindings/emai_binding.dart';
@ -16,6 +17,13 @@ final AgentModuleDescriptor emaiModule = AgentModuleDescriptor(
gradient: const [Color(0xFF5B8DEF), Color(0xFF7E57C2)], gradient: const [Color(0xFF5B8DEF), Color(0xFF7E57C2)],
// 按 agentType 走移动精灵或 EMAI 页 // 按 agentType 走移动精灵或 EMAI 页
onLaunch: navigateToAIAssistant, onLaunch: navigateToAIAssistant,
// 位置缓存给手机页和耳机唤醒共用。手机页在 Dart Start 里读,
// 耳机按键唤醒由原生 Start 写入同一份缓存,这里只保证服务在。
registerServices: () {
if (!Get.isRegistered<DeviceLocationService>()) {
Get.put<DeviceLocationService>(DeviceLocationService(), permanent: true);
}
},
pages: [ pages: [
GetPage( GetPage(
name: Routes.emai, name: Routes.emai,

5
apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt

@ -263,6 +263,11 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
downstreamRate = call.argument<Int>("downstreamRate") ?: 16000, downstreamRate = call.argument<Int>("downstreamRate") ?: 16000,
mcpToken = call.argument<String>("mcpToken"), mcpToken = call.argument<String>("mcpToken"),
mcpAuthTools = call.argument<List<String>>("mcpAuthTools") ?: emptyList(), mcpAuthTools = call.argument<List<String>>("mcpAuthTools") ?: emptyList(),
promptLocation = call.argument<String>("promptLocation"),
promptDate = call.argument<String>("promptDate"),
latitude = call.argument<String>("latitude"),
longitude = call.argument<String>("longitude"),
cityName = call.argument<String>("cityName"),
) )
val ok = client.start(cfg) val ok = client.start(cfg)
if (ok) bridge.enable() if (ok) bridge.enable()

36
apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/BailianDialogClient.kt

@ -73,6 +73,14 @@ class BailianDialogClient(
* 服务端加工具时只改 Dart 那一处,漏改的表现是那个工具永远「身份校验失败」。 * 服务端加工具时只改 Dart 那一处,漏改的表现是那个工具永远「身份校验失败」。
*/ */
val mcpAuthTools: List<String>, val mcpAuthTools: List<String>,
/** `${location}`:完整地址。空则不写 user_prompt_params.location。 */
val promptLocation: String? = null,
/** `${date}`:年月日 星期 时:分。空则不写。 */
val promptDate: String? = null,
/** `client_info.location`。纬度或经度空着就整块不发。 */
val latitude: String? = null,
val longitude: String? = null,
val cityName: String? = null,
) )
private val exec = Executors.newSingleThreadExecutor { r -> Thread(r, "azure_speech.bailian") } private val exec = Executors.newSingleThreadExecutor { r -> Thread(r, "azure_speech.bailian") }
@ -212,6 +220,15 @@ class BailianDialogClient(
// 不传 voice:音色属于百炼应用的配置,硬编码只会跟应用实际用的 TTS 模型对不上 // 不传 voice:音色属于百炼应用的配置,硬编码只会跟应用实际用的 TTS 模型对不上
if (!c.voice.isNullOrBlank()) downstream.put("voice", c.voice) if (!c.voice.isNullOrBlank()) downstream.put("voice", c.voice)
val clientInfo = JSONObject().put("user_id", safeUserId(c.userId))
if (!c.latitude.isNullOrBlank() && !c.longitude.isNullOrBlank()) {
val loc = JSONObject()
.put("latitude", c.latitude)
.put("longitude", c.longitude)
if (!c.cityName.isNullOrBlank()) loc.put("city_name", c.cityName)
clientInfo.put("location", loc)
}
val parameters = JSONObject() val parameters = JSONObject()
.put("upstream", JSONObject() .put("upstream", JSONObject()
.put("type", "AudioOnly") .put("type", "AudioOnly")
@ -219,11 +236,22 @@ class BailianDialogClient(
.put("audio_format", "pcm") .put("audio_format", "pcm")
.put("sample_rate", UPSTREAM_SAMPLE_RATE)) .put("sample_rate", UPSTREAM_SAMPLE_RATE))
.put("downstream", downstream) .put("downstream", downstream)
.put("client_info", JSONObject().put("user_id", safeUserId(c.userId))) .put("client_info", clientInfo)
val prompt = JSONObject()
if (!c.promptLocation.isNullOrBlank()) prompt.put("location", c.promptLocation)
if (!c.promptDate.isNullOrBlank()) prompt.put("date", c.promptDate)
// biz_params 是可选块,两件事都没有时整块不发——万一应用侧没开这些能力, // biz_params 是可选块,三件事都没有时整块不发——万一应用侧没开这些能力,
// 多带一个字段可能让 Start 直接被拒,那样整个功能都起不来 // 多带一个字段可能让 Start 直接被拒,那样整个功能都起不来
val hasToken = !c.mcpToken.isNullOrBlank() val hasToken = !c.mcpToken.isNullOrBlank()
if (c.enableWebSearch || hasToken || prompt.length() > 0) {
val biz = JSONObject()
if (prompt.length() > 0) {
biz.put("user_prompt_params", prompt)
Log.i(TAG, "会话上下文 location=${c.promptLocation} date=${c.promptDate} " +
"coords=${!c.latitude.isNullOrBlank()}")
}
if (c.enableWebSearch || hasToken) { if (c.enableWebSearch || hasToken) {
val userDefined = JSONObject() val userDefined = JSONObject()
if (hasToken) { if (hasToken) {
@ -235,7 +263,9 @@ class BailianDialogClient(
if (c.enableWebSearch) { if (c.enableWebSearch) {
userDefined.put("extra_config", JSONObject().put("enable_web_search", true)) userDefined.put("extra_config", JSONObject().put("enable_web_search", true))
} }
parameters.put("biz_params", JSONObject().put("user_defined_params", userDefined)) biz.put("user_defined_params", userDefined)
}
parameters.put("biz_params", biz)
} }
return JSONObject() return JSONObject()

7
apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift

@ -497,7 +497,12 @@ internal func handleBailianDialogCall(_ call: FlutterMethodCall, result: @escapi
enableWebSearch: (a["enableWebSearch"] as? Bool) ?? true, enableWebSearch: (a["enableWebSearch"] as? Bool) ?? true,
downstreamRate: (a["downstreamRate"] as? Int) ?? 16000, downstreamRate: (a["downstreamRate"] as? Int) ?? 16000,
mcpToken: a["mcpToken"] as? String, mcpToken: a["mcpToken"] as? String,
mcpAuthTools: (a["mcpAuthTools"] as? [String]) ?? [] mcpAuthTools: (a["mcpAuthTools"] as? [String]) ?? [],
promptLocation: a["promptLocation"] as? String,
promptDate: a["promptDate"] as? String,
latitude: a["latitude"] as? String,
longitude: a["longitude"] as? String,
cityName: a["cityName"] as? String
) )
let ok = client.start(config: cfg) let ok = client.start(config: cfg)
if ok { bridge.enable() } if ok { bridge.enable() }

39
apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/BailianDialogClient.swift

@ -45,6 +45,14 @@ final class BailianDialogClient: NSObject {
/// 需要用户身份的 MCP 工具名。**由 Dart 传下来**,不在原生再抄一份: /// 需要用户身份的 MCP 工具名。**由 Dart 传下来**,不在原生再抄一份:
/// 服务端加工具时只改 Dart 那一处,漏改的表现是那个工具永远「身份校验失败」。 /// 服务端加工具时只改 Dart 那一处,漏改的表现是那个工具永远「身份校验失败」。
let mcpAuthTools: [String] let mcpAuthTools: [String]
/// `${location}`:完整地址。空则不写 user_prompt_params.location。
let promptLocation: String?
/// `${date}`:年月日 星期 时:分。空则不写。
let promptDate: String?
/// `client_info.location`。纬度或经度空着就整块不发。
let latitude: String?
let longitude: String?
let cityName: String?
} }
private static let endpoint = "wss://dashscope.aliyuncs.com/api-ws/v1/inference" private static let endpoint = "wss://dashscope.aliyuncs.com/api-ws/v1/inference"
@ -212,6 +220,16 @@ final class BailianDialogClient: NSObject {
downstream["voice"] = v downstream["voice"] = v
} }
var clientInfo: [String: Any] = ["user_id": Self.safeUserId(c.userId)]
let lat = c.latitude?.trimmingCharacters(in: .whitespaces) ?? ""
let lng = c.longitude?.trimmingCharacters(in: .whitespaces) ?? ""
if !lat.isEmpty && !lng.isEmpty {
var loc: [String: Any] = ["latitude": lat, "longitude": lng]
let city = c.cityName?.trimmingCharacters(in: .whitespaces) ?? ""
if !city.isEmpty { loc["city_name"] = city }
clientInfo["location"] = loc
}
var parameters: [String: Any] = [ var parameters: [String: Any] = [
"upstream": [ "upstream": [
"type": "AudioOnly", "type": "AudioOnly",
@ -220,12 +238,25 @@ final class BailianDialogClient: NSObject {
"sample_rate": Self.upstreamSampleRate, "sample_rate": Self.upstreamSampleRate,
], ],
"downstream": downstream, "downstream": downstream,
"client_info": ["user_id": Self.safeUserId(c.userId)], "client_info": clientInfo,
] ]
// biz_params 是可选块,两件事都没有时整块不发——万一应用侧没开这些能力, var prompt: [String: String] = [:]
let spoken = c.promptLocation?.trimmingCharacters(in: .whitespaces) ?? ""
let day = c.promptDate?.trimmingCharacters(in: .whitespaces) ?? ""
if !spoken.isEmpty { prompt["location"] = spoken }
if !day.isEmpty { prompt["date"] = day }
// biz_params 是可选块,三件事都没有时整块不发——万一应用侧没开这些能力,
// 多带一个字段可能让 Start 直接被拒,那样整个功能都起不来 // 多带一个字段可能让 Start 直接被拒,那样整个功能都起不来
let hasToken = !(c.mcpToken ?? "").trimmingCharacters(in: .whitespaces).isEmpty let hasToken = !(c.mcpToken ?? "").trimmingCharacters(in: .whitespaces).isEmpty
if c.enableWebSearch || hasToken || !prompt.isEmpty {
var biz: [String: Any] = [:]
if !prompt.isEmpty {
biz["user_prompt_params"] = prompt
NSLog("[BailianDialog] 会话上下文 location=%@ date=%@ coords=%@",
spoken, day, lat.isEmpty ? "0" : "1")
}
if c.enableWebSearch || hasToken { if c.enableWebSearch || hasToken {
var userDefined: [String: Any] = [:] var userDefined: [String: Any] = [:]
if hasToken, let tok = c.mcpToken { if hasToken, let tok = c.mcpToken {
@ -237,7 +268,9 @@ final class BailianDialogClient: NSObject {
if c.enableWebSearch { if c.enableWebSearch {
userDefined["extra_config"] = ["enable_web_search": true] userDefined["extra_config"] = ["enable_web_search": true]
} }
parameters["biz_params"] = ["user_defined_params": userDefined] biz["user_defined_params"] = userDefined
}
parameters["biz_params"] = biz
} }
return [ return [

48
apps/client/local_plugins/location_service/android/build.gradle

@ -24,6 +24,52 @@ allprojects {
apply plugin: "com.android.library" apply plugin: "com.android.library"
apply plugin: "kotlin-android" apply plugin: "kotlin-android"
// 搜索 SDK 和导航 SDK 各带一份 AMapUtilCoreApi,导航那份是超集。
// 直接 implementation search 会在打包时 Duplicate class。这里先剥掉那两个类,运行时用导航 SDK 的。
configurations {
amapSearch
}
dependencies {
amapSearch('com.amap.api:search:9.7.1')
}
def stripAmapSearchJar = tasks.register("stripAmapSearchJar") {
def cfg = configurations.amapSearch
inputs.files(cfg)
def out = layout.buildDirectory.file("amap/search-stripped.jar")
outputs.file(out)
doLast {
def jar = cfg.singleFile
def dest = out.get().asFile
dest.parentFile.mkdirs()
def drop = [
"com/amap/apis/utils/core/api/AMapUtilCoreApi.class",
"com/amap/apis/utils/core/api/NetProxy.class",
] as Set
def zin = new java.util.zip.ZipFile(jar)
dest.withOutputStream { os ->
def zout = new java.util.zip.ZipOutputStream(os)
zin.entries().each { entry ->
if (drop.contains(entry.name)) return
zout.putNextEntry(new java.util.zip.ZipEntry(entry.name))
if (!entry.isDirectory()) {
zin.getInputStream(entry).withCloseable { ins ->
byte[] buf = new byte[8192]
int n
while ((n = ins.read(buf)) > 0) {
zout.write(buf, 0, n)
}
}
}
zout.closeEntry()
}
zout.close()
}
zin.close()
}
}
android { android {
namespace = "com.yunqiinnovation.location_service" namespace = "com.yunqiinnovation.location_service"
@ -50,6 +96,8 @@ android {
// ✅ dependencies 应该写在这里(android {} 外面) // ✅ dependencies 应该写在这里(android {} 外面)
dependencies { dependencies {
implementation('com.amap.api:navi-3dmap:latest.integration') implementation('com.amap.api:navi-3dmap:latest.integration')
// 定位 SDK 在部分机型(华为无 GMS)只回坐标和市名,路名要单独走搜索 SDK 的逆地理。
implementation(files(stripAmapSearchJar))
testImplementation("org.jetbrains.kotlin:kotlin-test") testImplementation("org.jetbrains.kotlin:kotlin-test")
testImplementation("org.mockito:mockito-core:5.0.0") testImplementation("org.mockito:mockito-core:5.0.0")

100
apps/client/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/PlaceLocator.kt

@ -9,42 +9,82 @@ import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.amap.api.location.AMapLocationClient import com.amap.api.location.AMapLocationClient
import com.amap.api.location.AMapLocationClientOption import com.amap.api.location.AMapLocationClientOption
import com.amap.api.services.core.LatLonPoint
import com.amap.api.services.core.ServiceSettings
import com.amap.api.services.geocoder.GeocodeSearch
import com.amap.api.services.geocoder.RegeocodeQuery
/** /**
* 取一次「当前位置 + 省市区」,给百炼会话的 `${location}` 用(2026-09-25,车载香薰)。 * 取一次「当前位置 + 详细地址」,给百炼会话的 `${location}` 用(2026-09-25,车载香薰)。
* *
* 刻意不复用 [LocationService]:那套只收 50 米内的高精度结果、超时 60 秒,是给导航/签到用的; * 刻意不复用 [LocationService]:那套只收 50 米内的高精度结果、超时 60 秒,是给导航/签到用的;
* 这里只要知道「在哪个城市哪个区」,网络定位就够,要的是快。 * 这里要路名和门牌,高德逆地理一次拿全,要的是快。
* `address` 优先用高德格式化地址;它为空时用省市区、街道、门牌拼一条,供上层直接念。
* *
* - 没有定位权限直接回 `no_permission`,**不在这里弹权限框**(由 Dart 侧决定何时申请); * - 没有定位权限直接回 `no_permission`,**不在这里弹权限框**(由 Dart 侧决定何时申请);
* - 高德定位自带逆地理(setNeedAddress),省/市/区一次拿全,华为无 GMS 的机器上也能用; * - 定位回调经常只有市名(华为 ALN-AL80 上是「深圳市」)。没有路名时,用手里的经纬度
* - 坐标是高德的 GCJ-02,只用于城市级描述,偏移无关紧要。 * 再调一次高德逆地理,把格式化地址填进 `address`;
* - 坐标是高德的 GCJ-02,只放进 client_info,不放进要念出来的地址。
*/ */
object PlaceLocator { object PlaceLocator {
/** 系统 Geocoder 反查省市区;机器不支持 / 没网 / 出错都返回 null。在后台线程调用。 */ /** 定位结果里已经有路名或门牌,就不必再打一次逆地理。 */
private fun systemGeocode(context: Context, lat: Double, lng: Double): Map<String, Any?>? { private fun hasRoad(street: String?, streetNum: String?, address: String?): Boolean {
return try { if (!street.isNullOrBlank() || !streetNum.isNullOrBlank()) return true
if (!android.location.Geocoder.isPresent()) { val a = address?.trim().orEmpty()
Log.w(TAG, "系统 Geocoder 不可用") return a.contains('路') || a.contains('巷') || a.contains('号') || a.contains("大道")
return null
} }
@Suppress("DEPRECATION")
val list = android.location.Geocoder(context, java.util.Locale.CHINA).getFromLocation(lat, lng, 1) /**
val a = list?.firstOrNull() ?: return null * 用定位拿到的经纬度向高德要格式化地址。
Log.i(TAG, "系统 Geocoder: 省=${a.adminArea} 市=${a.locality} 区=${a.subLocality} 地址=${a.getAddressLine(0)}") * 必须在后台线程调用:`getFromLocation` 是同步网络请求。
* 失败返回 null,调用方保留定位回调里已有的市名。
*/
private fun amapRegeocode(context: Context, lat: Double, lng: Double): Map<String, Any?>? {
return try {
ServiceSettings.updatePrivacyShow(context, true, true)
ServiceSettings.updatePrivacyAgree(context, true)
val search = GeocodeSearch(context)
val query = RegeocodeQuery(LatLonPoint(lat, lng), 200f, GeocodeSearch.AMAP)
val addr = search.getFromLocation(query) ?: return null
val sn = addr.streetNumber
val street = sn?.street?.trim().orEmpty()
val number = sn?.number?.trim().orEmpty()
val spoken = spokenAddress(
addr.formatAddress, addr.province, addr.city, addr.district, street, number,
)
if (spoken.isEmpty()) return null
Log.i(TAG, "高德逆地理补全: $spoken")
mapOf( mapOf(
"province" to (a.adminArea ?: ""), "province" to addr.province.orEmpty(),
"city" to (a.locality ?: a.adminArea ?: ""), "city" to addr.city.orEmpty().ifBlank { addr.province.orEmpty() },
"district" to (a.subLocality ?: ""), "district" to addr.district.orEmpty(),
"street" to (a.thoroughfare ?: ""), "street" to listOf(street, number).filter { it.isNotEmpty() }.joinToString(""),
"address" to (a.getAddressLine(0) ?: ""), "address" to spoken,
) )
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "系统 Geocoder 失败: ${e.message}") Log.w(TAG, "高德逆地理失败: ${e.message}")
null null
} }
} }
/** 高德格式化地址优先;为空时用省市区、街道、门牌拼一条。不含经纬度。 */
private fun spokenAddress(
amapAddress: String?,
province: String?,
city: String?,
district: String?,
street: String?,
streetNum: String?,
): String {
val formatted = amapAddress?.trim().orEmpty()
if (formatted.isNotEmpty()) return formatted
return listOf(province, city, district, street, streetNum)
.map { it?.trim().orEmpty() }
.filter { it.isNotEmpty() }
.distinct()
.joinToString("")
}
private const val TAG = "PlaceLocator" private const val TAG = "PlaceLocator"
private val main = Handler(Looper.getMainLooper()) private val main = Handler(Looper.getMainLooper())
@ -59,6 +99,8 @@ object PlaceLocator {
main.post { main.post {
var done = false var done = false
var client: AMapLocationClient? = null var client: AMapLocationClient? = null
// 已经拿到坐标、正在补逆地理时,超时把这份粗地址交出去,而不是整次失败。
var held: Map<String, Any?>? = null
fun finish(result: Map<String, Any?>?, error: String?) { fun finish(result: Map<String, Any?>?, error: String?) {
if (done) return if (done) return
done = true done = true
@ -93,6 +135,9 @@ object PlaceLocator {
} else { } else {
Log.i(TAG, "定位成功 type=${loc.locationType} lat=${loc.latitude} lng=${loc.longitude} " + Log.i(TAG, "定位成功 type=${loc.locationType} lat=${loc.latitude} lng=${loc.longitude} " +
"省=${loc.province} 市=${loc.city} 区=${loc.district} 地址=${loc.address}") "省=${loc.province} 市=${loc.city} 区=${loc.district} 地址=${loc.address}")
val spoken = spokenAddress(
loc.address, loc.province, loc.city, loc.district, loc.street, loc.streetNum,
)
val base = mapOf<String, Any?>( val base = mapOf<String, Any?>(
"latitude" to loc.latitude, "latitude" to loc.latitude,
"longitude" to loc.longitude, "longitude" to loc.longitude,
@ -100,21 +145,24 @@ object PlaceLocator {
"city" to loc.city, "city" to loc.city,
"district" to loc.district, "district" to loc.district,
"street" to loc.street, "street" to loc.street,
"address" to loc.address, "address" to spoken,
) )
if (!loc.city.isNullOrBlank() || !loc.address.isNullOrBlank()) { if (hasRoad(loc.street, loc.streetNum, loc.address)) {
finish(base, null) finish(base, null)
} else { } else {
// 2026-09-25 华为 ALN-AL80 实测:高德只回了坐标(type=12)、省市区全空。 // 市名有了但没有路名(华为上常见只回「深圳市」)。
// 退一步用系统 Geocoder 反查;再不行就只回坐标,由上层用坐标描述位置。 // 用同一对经纬度再向高德要一次格式化地址。
held = base
Thread { Thread {
val geo = systemGeocode(app, loc.latitude, loc.longitude) val geo = amapRegeocode(app, loc.latitude, loc.longitude)
main.post { finish(if (geo != null) base + geo else base, null) } main.post { finish(if (geo != null) base + geo else base, null) }
}.start() }.start()
} }
} }
} }
main.postDelayed({ finish(null, "timeout") }, timeoutMs) main.postDelayed({
finish(held, if (held == null) "timeout" else null)
}, timeoutMs)
c.startLocation() c.startLocation()
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "定位异常: ${e.message}") Log.w(TAG, "定位异常: ${e.message}")

36
apps/client/local_plugins/location_service/ios/location_service/Sources/location_service/PlaceLocator.swift

@ -1,14 +1,15 @@
import Foundation import Foundation
import CoreLocation import CoreLocation
/// 取一次「当前位置 + 省市区」,给百炼会话的 `${location}` 用(2026-09-25,车载香薰)。 /// 取一次「当前位置 + 详细地址」,给百炼会话的 `${location}` 用(2026-09-25,车载香薰)。
/// ///
/// 刻意不复用 `LocationService`:那套带缓存/预热/权限请求,是给别的业务用的; /// 刻意不复用 `LocationService`:那套带缓存/预热/权限请求,是给别的业务用的;
/// 这里只要城市级描述,要快、要可控。 /// 这里要路名和门牌,要快、要可控。
/// ///
/// - 没有定位权限直接回 `no_permission`,**不在这里弹权限框**(由 Dart 侧决定何时申请); /// - 没有定位权限直接回 `no_permission`,**不在这里弹权限框**(由 Dart 侧决定何时申请);
/// - 逆地理用系统 `CLGeocoder`(国内是高德数据),省/市/区取 administrativeArea/locality/subLocality; /// - 逆地理用系统 `CLGeocoder`(国内是高德数据)。`address` 拼成省市区 + 路名 + 门牌,
/// 直辖市 locality 可能为空,退回 administrativeArea; /// 与 Android 高德 `address` 同一口径,上层直接拿来念;
/// - 直辖市 locality 可能为空,退回 administrativeArea;
/// - 逆地理失败时仍回经纬度(地名留空),经纬度本身对 client_info 也有用。 /// - 逆地理失败时仍回经纬度(地名留空),经纬度本身对 client_info 也有用。
final class PlaceLocator: NSObject, CLLocationManagerDelegate { final class PlaceLocator: NSObject, CLLocationManagerDelegate {
static let shared = PlaceLocator() static let shared = PlaceLocator()
@ -60,11 +61,17 @@ final class PlaceLocator: NSObject, CLLocationManagerDelegate {
DispatchQueue.main.async { DispatchQueue.main.async {
if let p = marks?.first { if let p = marks?.first {
let province = p.administrativeArea ?? "" let province = p.administrativeArea ?? ""
let city = (p.locality?.isEmpty == false) ? p.locality! : province
let district = p.subLocality ?? ""
let street = p.thoroughfare ?? ""
let number = p.subThoroughfare ?? ""
base["province"] = province base["province"] = province
base["city"] = (p.locality?.isEmpty == false) ? p.locality! : province base["city"] = city
base["district"] = p.subLocality ?? "" base["district"] = district
base["street"] = p.thoroughfare ?? "" base["street"] = street
base["address"] = p.name ?? "" base["address"] = Self.spokenAddress(
province: province, city: city, district: district,
street: street, number: number)
} else if let err = err { } else if let err = err {
NSLog("[PlaceLocator] 逆地理失败: \(err.localizedDescription)") NSLog("[PlaceLocator] 逆地理失败: \(err.localizedDescription)")
} }
@ -76,4 +83,17 @@ final class PlaceLocator: NSObject, CLLocationManagerDelegate {
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
finish(nil, "location_error: \(error.localizedDescription)") finish(nil, "location_error: \(error.localizedDescription)")
} }
/// 省市区 + 路名 + 门牌,去掉重复的直辖市名。不含经纬度。
static func spokenAddress(
province: String, city: String, district: String, street: String, number: String
) -> String {
var parts: [String] = []
for raw in [province, city, district, street, number] {
let v = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if v.isEmpty || parts.contains(v) { continue }
parts.append(v)
}
return parts.joined()
}
} }

5
apps/client/local_plugins/location_service/lib/location_service.dart

@ -55,11 +55,12 @@ class LocationService {
} }
} }
/// 取一次当前位置和省市区(2026-09-25 新增,给百炼 `${location}` 用)。 /// 取一次当前位置和详细地址(2026-09-25 新增,给百炼 `${location}` 用)。
/// ///
/// **不申请权限**:没权限、超时、定位失败都返回 `{'error': 原因}`,由调用方决定何时申请权限。 /// **不申请权限**:没权限、超时、定位失败都返回 `{'error': 原因}`,由调用方决定何时申请权限。
/// 成功返回键:latitude / longitude(double)、province / city / district / street / address /// 成功返回键:latitude / longitude(double)、province / city / district / street / address
/// (String,可能为空)。 /// (String,可能为空)。`address` 是路名加门牌的完整地址。Android 定位回调没有路名时,
/// 用经纬度再向高德逆地理要一次格式化地址;iOS 用省市区、路名、门牌拼成同一口径。
Future<Map<String, dynamic>> currentPlace({int timeoutMs = 15000}) async { Future<Map<String, dynamic>> currentPlace({int timeoutMs = 15000}) async {
try { try {
final r = await _methodChannel.invokeMethod<Map>( final r = await _methodChannel.invokeMethod<Map>(

488
apps/client/pubspec.lock

File diff suppressed because it is too large

2
apps/client/pubspec.yaml

@ -1,7 +1,7 @@
name: eaimar name: eaimar
description: "Eaimar - AI Voice Assistant." description: "Eaimar - AI Voice Assistant."
publish_to: "none" publish_to: "none"
version: 1.0.2+25 version: 1.0.2+26
environment: environment:
sdk: ">=3.3.0 <4.0.0" sdk: ">=3.3.0 <4.0.0"

24
apps/client/test/bailian_native_parity_test.dart

@ -35,6 +35,30 @@ void main() {
expect(BailianMultimodalService.resolveWorkspaceId('ws-x'), 'ws-x'); expect(BailianMultimodalService.resolveWorkspaceId('ws-x'), 'ws-x');
}); });
test('EMAI 带位置和日期,未登记的应用不带', () {
final emai = BailianMultimodalService.resolveAppId();
expect(BailianMultimodalService.wantsClientContext(emai), isTrue);
expect(BailianMultimodalService.wantsClientContext('not-an-app'), isFalse);
final ctx = BailianMultimodalService.promptContext(emai);
expect(ctx, isNotNull);
expect(ctx!['location'], '未知');
expect(
ctx['date'],
matches(RegExp(r'^\d{4}年\d{1,2}月\d{1,2}日 星期[一二三四五六日] \d{2}:\d{2}$')),
);
expect(ctx.containsKey('clientLocation'), isFalse);
expect(BailianMultimodalService.promptContext('not-an-app'), isNull);
});
test('登记进 clientContextAppIds 的应用也带位置和日期', () {
const id = 'mm_test_context';
BailianMultimodalService.clientContextAppIds.add(id);
addTearDown(() => BailianMultimodalService.clientContextAppIds.remove(id));
expect(BailianMultimodalService.wantsClientContext(id), isTrue);
expect(BailianMultimodalService.promptContext(id)?['location'], '未知');
});
test('MCP 工具名只有一份,且非空', () { test('MCP 工具名只有一份,且非空', () {
// 原生不再抄一份:服务端新增需要身份的工具时只改这里。 // 原生不再抄一份:服务端新增需要身份的工具时只改这里。
// 漏了那个工具永远「身份校验失败」,而且客户端不报任何错。 // 漏了那个工具永远「身份校验失败」,而且客户端不报任何错。

33
apps/client/test/device_location_date_test.dart

@ -8,4 +8,37 @@ void main() {
expect(DeviceLocationService.dateText(DateTime(2026, 9, 27, 9, 0)), expect(DeviceLocationService.dateText(DateTime(2026, 9, 27, 9, 0)),
'2026年9月27日 星期日 09:00'); '2026年9月27日 星期日 09:00');
}); });
test('百炼 \${location}:完整地址优先,否则省市区加街道,不带经纬度', () {
expect(
DeviceLocationService.formatPlaceText({
'province': '广东省',
'city': '深圳市',
'district': '福田区',
'street': '华强北路',
'address': '广东省深圳市福田区华强北路1002号',
'latitude': 22.5431,
'longitude': 114.0579,
}),
'广东省深圳市福田区华强北路1002号',
);
expect(
DeviceLocationService.formatPlaceText({
'province': '北京市',
'city': '北京市',
'district': '东城区',
'street': '王府井大街',
'address': '',
}),
'北京市东城区王府井大街',
);
expect(
DeviceLocationService.formatPlaceText({
'latitude': 22.54,
'longitude': 114.05,
}),
'未知',
);
expect(DeviceLocationService.formatPlaceText(null), '未知');
});
} }

Loading…
Cancel
Save