diff --git a/lang/en-us/ai-chat/index.js b/lang/en-us/ai-chat/index.js index 388f2e0..75a0e10 100644 --- a/lang/en-us/ai-chat/index.js +++ b/lang/en-us/ai-chat/index.js @@ -141,6 +141,13 @@ export const lang = { chooseVideoFail: 'Failed to select video', audioNotReady: 'Audio not ready', location: 'Location', + // PR-AUTO-LOCATION (2026-09-28): automatic location permission prompts + locationPermissionTitle: 'Location Permission Required', + locationPermissionH5: 'Please enable location permission via the lock icon in your browser address bar, then resend the message.', + locationPermissionMp: 'To help the AI better answer location-related questions, please enable location permission.\n\nTap "Open Settings" to enable it in your system settings, then return to this page and resend the message.', + locationSettingBtn: 'Open Settings', + locationSkipBtn: 'Not Now', + locationH5GuideBtn: 'Got it', // ====== ai-chat-voice-panel ====== voiceInputTitle: 'Voice Input', diff --git a/lang/zh-cn/ai-chat/index.js b/lang/zh-cn/ai-chat/index.js index 220fdbb..1961f96 100644 --- a/lang/zh-cn/ai-chat/index.js +++ b/lang/zh-cn/ai-chat/index.js @@ -140,6 +140,13 @@ export const lang = { chooseVideoFail: '选择视频失败', audioNotReady: '音频尚未就绪', location: '位置', + // PR-AUTO-LOCATION (2026-09-28): 自动定位权限提示 + locationPermissionTitle: '需要位置权限', + locationPermissionH5: '请在浏览器地址栏左侧的锁形图标中开启位置权限,然后重新发送消息。', + locationPermissionMp: '为了让 AI 更好地回答位置相关问题,需要您开启位置权限。\n\n点击"去设置"在系统设置中开启后,请回到本页面再次发送消息。', + locationSettingBtn: '去设置', + locationSkipBtn: '暂不开启', + locationH5GuideBtn: '我知道了', // ====== ai-chat-voice-panel ====== voiceInputTitle: '语音输入', diff --git a/pages_tool/ai-chat/ai-chat-message.vue b/pages_tool/ai-chat/ai-chat-message.vue index b251d2a..d35e2f7 100644 --- a/pages_tool/ai-chat/ai-chat-message.vue +++ b/pages_tool/ai-chat/ai-chat-message.vue @@ -1252,6 +1252,28 @@ export default { // 5. metadata - 会话级设备/平台信息 (platform/screen/locale), 一次性建立 // 跨平台兼容: H5 用 Intl/navigator, MP/鸿蒙用 uni.getSystemInfoSync 兜底 _buildSendContext({ locationFiles }) { + const ctx = this._initContext({ locationFiles }) + // 异步探测, 不阻塞消息渲染; 在发送前 await 拿到坐标再补到 ctx + this._fillAutoLocation(ctx).catch(() => {}) + return ctx + }, + + // 异步版本: 等自动定位完成 (或 8 秒超时) 再返回 + // 适用于 sendMessage 路径, 因为 context 会立即发给后端 + // 8s 给首次弹原生权限框 + 弹模态引导去设置 留够时间 + // 极端情况 (用户在系统设置里徘徊超过 8s) 才走超时, 此时 ctx.location 仍为 null, + // 后续消息发送前会复用 5 分钟缓存, 不会反复打扰用户 + async _buildSendContextAsync({ locationFiles }) { + const ctx = this._initContext({ locationFiles }) + await Promise.race([ + this._fillAutoLocation(ctx).catch(() => {}), + new Promise((r) => setTimeout(r, 8000)) + ]) + return ctx + }, + + // 构建基础 context (timestamp/timezone/page/metadata + 手动位置) + _initContext({ locationFiles }) { const ctx = { timestamp: Date.now(), timezone: this._detectTimezone(), @@ -1259,19 +1281,39 @@ export default { page: this._getCurrentPageRoute(), metadata: this._getSessionMetadata() } - // 位置: 只取第一个, 多位置场景后续再加 + // 位置优先级: 用户手动选的 > 自动探测 + // 手动选的位置一定有 name/address, 自动探测只有坐标 if (locationFiles && locationFiles.length) { const loc = locationFiles[0] ctx.location = { latitude: loc.latitude, longitude: loc.longitude, address: loc.address || '', - name: loc.name || '' + name: loc.name || '', + source: 'manual' } } return ctx }, + // 异步把自动探测到的位置合并到 ctx (不影响 sendMessage 的同步流程) + // 不会覆盖手动选的位置 (source === 'manual' 时跳过) + async _fillAutoLocation(ctx) { + try { + const loc = await this._tryAutoLocation() + if (!loc) return + if (ctx.location && ctx.location.source === 'manual') return + ctx.location = { + latitude: loc.latitude, + longitude: loc.longitude, + address: '', + name: '', + source: 'auto', + accuracy: loc.accuracy || 0 + } + } catch (e) { /* 静默: 失败不进 location, 不打扰用户 */ } + }, + // 检测客户端时区 (H5 优先用 Intl, MP/鸿蒙走 fallback) _detectTimezone() { try { @@ -1365,6 +1407,243 @@ export default { return this._sessionMetaCache }, + // ========== PR-AUTO-LOCATION (2026-09-28): 自动位置获取 ========== + // 隐私敏感, 必须用户授权. 流程: + // 1. 检查权限状态 (granted/denied/undetermined) + // 2. granted → 直接 getLocation, 缓存 5 分钟 + // 3. denied → 弹模态引导去设置页 (uni.openSetting) + // 4. undetermined → 直接调 authorize 弹原生权限框 + // 拒绝一次后不再静默弹窗, 必须走"去设置"模态. 用户感受: + // 首次进入聊天 → 不打扰 + // 发消息时 → 静默获取 (首次弹原生权限框) + // 用户拒绝过 → 弹"去设置"模态, 取消则不进 context.location + // 跨平台兼容: + // H5 : navigator.permissions + navigator.geolocation + // MP/鸿蒙 : uni.getSetting + uni.authorize + uni.openSetting + uni.getLocation + // APP : 同 MP, manifest 需声明 ACCESS_FINE_LOCATION + + // 自动位置缓存: 5 分钟内复用, 避免重复获取 + _getCachedAutoLocation() { + const cache = this._autoLocationCache + if (!cache) return null + const FIVE_MIN = 5 * 60 * 1000 + if (Date.now() - cache.ts > FIVE_MIN) return null + return cache.data + }, + _setCachedAutoLocation(data) { + this._autoLocationCache = { data, ts: Date.now() } + }, + _clearCachedAutoLocation() { + this._autoLocationCache = null + }, + + // 主入口: 尝试获取用户当前位置, 不打扰的前提下 + // 返回: { latitude, longitude, accuracy } 或 null + async _tryAutoLocation() { + // 1. 优先用缓存 (5 分钟内有效) + const cached = this._getCachedAutoLocation() + if (cached) return cached + + // 2. 检查权限状态 + let status = 'undetermined' + try { status = await this._getLocationAuthStatus() } catch (e) { /* ignore */ } + + // 3. 已拒绝过 → 弹"去设置"模态 + if (status === 'denied') { + const granted = await this._promptOpenLocationSetting() + if (!granted) return null + // 用户去设置后回到页面, 重新检测状态 + try { status = await this._getLocationAuthStatus() } catch (e) {} + if (status !== 'granted') return null + // 用户在设置里开了权限, 重置"尝试过"标志, 下次回到 undetermined 路径 + this.triedLocationAuth = false + } else if (status === 'undetermined') { + // 4. 首次: 调 authorize 弹原生权限框 + // MP/鸿蒙/App 行为: 若用户曾在原生弹窗里点过拒绝, + // 后续 authorize 不再弹窗, 直接 fail, 此时 status 仍是 undetermined + // 用 triedLocationAuth 标记位区分"首次进入"与"曾拒绝过": + // false → 首次, 调 authorize 弹原生框, 不管成功失败都置 true + // true → 跳到 denied 路径, 弹"去设置"模态 + if (this.triedLocationAuth) { + const granted = await this._promptOpenLocationSetting() + if (!granted) return null + try { status = await this._getLocationAuthStatus() } catch (e) {} + if (status !== 'granted') return null + this.triedLocationAuth = false + } else { + try { + await this._authorizeLocation() + this.triedLocationAuth = true + } catch (e) { + // 首次拒绝: 标记已尝试, 下次直接走"去设置"模态, 不再弹原生框 + this.triedLocationAuth = true + return null + } + } + } + + // 5. 已授权 → 获取坐标 + const loc = await this._getCurrentLocation() + if (loc) this._setCachedAutoLocation(loc) + return loc + }, + + // 检测位置权限状态: 'granted' | 'denied' | 'undetermined' + // H5: navigator.permissions.query → 'prompt' 当 undetermined 处理 + // MP/鸿蒙/APP: uni.getSetting 看 scope.userLocation + _getLocationAuthStatus() { + // 用 var 是因为 ESLint 不识别 uni-app 的条件编译注释 + var isH5 = false + // #ifdef H5 + isH5 = true + // #endif + if (isH5) { + return new Promise((resolve) => { + if (typeof navigator !== 'undefined' + && navigator.permissions + && navigator.permissions.query) { + navigator.permissions.query({ name: 'geolocation' }) + .then((r) => resolve(r.state === 'granted' ? 'granted' : (r.state === 'denied' ? 'denied' : 'undetermined'))) + .catch(() => resolve('undetermined')) + } else { + resolve('undetermined') + } + }) + } + return new Promise((resolve) => { + if (typeof uni !== 'undefined' && uni.getSetting) { + uni.getSetting({ + success: (res) => { + const scope = res.authSetting && res.authSetting['scope.userLocation'] + if (scope === true) resolve('granted') + else if (scope === false) resolve('denied') + else resolve('undetermined') + }, + fail: () => resolve('undetermined') + }) + } else { + resolve('undetermined') + } + }) + }, + + // 申请位置权限 (调原生权限框) + // H5: 没单独的 authorize, getCurrentPosition 时浏览器自己弹 + // MP/鸿蒙/APP: uni.authorize(scope.userLocation) + _authorizeLocation() { + return new Promise((resolve, reject) => { + if (typeof uni !== 'undefined' && uni.authorize) { + uni.authorize({ + scope: 'scope.userLocation', + success: resolve, + fail: reject + }) + } else { + // H5 直接通过, 浏览器会在 getCurrentPosition 时弹权限框 + resolve() + } + }) + }, + + // 获取当前位置坐标 + // H5: navigator.geolocation.getCurrentPosition + // MP/鸿蒙/APP: uni.getLocation (gcj02 国内坐标系) + _getCurrentLocation() { + var isH5 = false + // #ifdef H5 + isH5 = true + // #endif + if (isH5) { + return new Promise((resolve) => { + const ok = (loc) => resolve(loc) + const fail = () => resolve(null) + if (typeof navigator !== 'undefined' && navigator.geolocation) { + navigator.geolocation.getCurrentPosition( + (pos) => ok({ + latitude: pos.coords.latitude, + longitude: pos.coords.longitude, + accuracy: pos.coords.accuracy + }), + fail, + { enableHighAccuracy: false, timeout: 8000, maximumAge: 60000 } + ) + } else { + fail() + } + }) + } + return new Promise((resolve) => { + const ok = (loc) => resolve(loc) + const fail = () => resolve(null) + if (typeof uni !== 'undefined' && uni.getLocation) { + uni.getLocation({ + type: 'gcj02', // 国内坐标系 (腾讯/高德), 海外用 'wgs84' + geocode: false, + success: (res) => ok({ + latitude: res.latitude, + longitude: res.longitude, + accuracy: res.accuracy || 0 + }), + fail + }) + } else { + fail() + } + }) + }, + + // 弹"去设置"模态, 用户确认后调 uni.openSetting 引导去系统设置 + // 返回: true = 用户在设置里开了权限, false = 取消或开了又关 + _promptOpenLocationSetting() { + // H5 没 openSetting, 浏览器权限只能去 chrome://settings 手动开 + // 用 var 是因为 ESLint 不识别 uni-app 的条件编译注释, + // 两个分支都会进 AST, 不能重复 const 声明 + var isH5 = false + // #ifdef H5 + isH5 = true + // #endif + const $t = (k, fallback) => { + try { + const v = this.$lang && this.$lang(k) + return v || fallback + } catch (e) { return fallback } + } + const title = $t('ai-chat.index.locationPermissionTitle', '需要位置权限') + if (isH5) { + return new Promise((resolve) => { + uni.showModal({ + title, + content: $t('ai-chat.index.locationPermissionH5', '请在浏览器地址栏左侧的锁形图标中开启位置权限,然后重新发送消息。'), + showCancel: false, + confirmText: $t('ai-chat.index.locationH5GuideBtn', '我知道了'), + success: () => resolve(false) + }) + }) + } + return new Promise((resolve) => { + uni.showModal({ + title, + content: $t('ai-chat.index.locationPermissionMp', '为了让 AI 更好地回答位置相关问题,需要您开启位置权限。\n\n点击"去设置"在系统设置中开启后,请回到本页面再次发送消息。'), + confirmText: $t('ai-chat.index.locationSettingBtn', '去设置'), + cancelText: $t('ai-chat.index.locationSkipBtn', '暂不开启'), + success: (res) => { + if (!res.confirm) { + resolve(false) + return + } + uni.openSetting({ + success: (sres) => { + const granted = sres.authSetting && sres.authSetting['scope.userLocation'] === true + resolve(granted) + }, + fail: () => resolve(false) + }) + }, + fail: () => resolve(false) + }) + }) + }, + onLocationSelected(locationMessage) { // PR-LOCATION-PENDING (2026-09-28): 位置消息走 pending 流程 // 原实现直接 push 到 messages, 用户点发送后位置根本不会传给 WB @@ -2112,7 +2391,7 @@ async loadChatHistoryIfExist() { // 让 AI 知道用户在哪、什么时候发的、当前在哪个页面、设备/平台信息 // context 字段在 ai-service.js 三个发送路径(sendMessage/sendStreamMessage/sendHttpStream) // 都已添加, 缺失则后端拿到 null, 不影响旧版后端兼容 - const context = this._buildSendContext({ locationFiles }) + const context = await this._buildSendContextAsync({ locationFiles }) if (this.enableStreaming) { await this.sendStreamMessage(enhancedMessage, readyAttachments, context) } else { @@ -3648,6 +3927,8 @@ _handleStreamError(msg) { this._aiRequestStart = Date.now() // PR-BASIC-CONTEXT-TO-WB (2026-09-28): 重发/商品页也透传 context (无位置) + // 用同步版: fire-and-forget, 立即返回, 重发不阻塞 + // 自动定位失败 (用户授权) 也无所谓, 重试主要是文本/AI 没听懂, 跟位置关系不大 const context = this._buildSendContext({ locationFiles: [] }) const userMessage = {