本文我将解决这三件事,将天气应用升级为AI旅游规划助手,基于Rokid 眼镜 的AI天气应用+GPS定位+AI旅游规划的实现。🙏

本文选用的技术包括 : GPS 自动定位 :说「这里天气」自动获取位置,不用报城市名 多轮对话 :说「上海呢」「那边呢」「再查一次」接续上轮查询 AI 旅游规划 :接入 Claude API,天气播报后自动生成个性化旅游建议和行程规划 可直接复制的 Kotlin 代码 (LocationHelper、ConversationContext、AiTravelPlanHelper) 踩坑经验:直辖市 adcode、续播语义识别、LLM 延迟控制
一、主要流程
本篇在 AiWeatherActivity(AI 语音查天气)基础上扩展,整体数据流如下:
新增三个辅助类,原有文件做对应改造:
| 新建文件 | 职责 |
|---|---|
| LocationHelper.kt | GPS + 高德逆地理编码 |
| ConversationContext.kt | 多轮对话上下文(含5分钟TTL) |
| AiTravelPlanHelper.kt | Claude API 旅游规划 |
| 文件 | 创新点 |
|---|---|
| AiIntentParser.kt | + GPS触发词 + 续播意图解析 + 城市库扩充 |
| WeatherViewHelper.kt | + tv_travel_plan 控件 + generateTravelPlanUpdateJson() |
| AiWeatherActivity.kt | 串联 GPS / Context / TravelPlan 完整调用链 |

二、功能 A:GPS 自动定位
2.1 实现路径
用户说完"这里的天气"不想等 5 秒。缓存位置最多偏差几公里,对天气查询完全够用。
2.2 核心代码:LocationHelper.kt
kotlin
class LocationHelper(private val context: Context) {
interface LocationCallback {
fun onCityCode(adcode: String, cityName: String, districtName: String)
fun onError(reason: String)
}
fun getCurrentCityCode(callback: LocationCallback) {
if (!hasLocationPermission()) {
callback.onError("缺少定位权限")
return
}
val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
val lastKnown = getLastKnownLocation(manager)
if (lastKnown != null) {
reverseGeocode(lastKnown.latitude, lastKnown.longitude, callback)
} else {
requestSingleUpdate(manager, callback)
}
}
@SuppressLint("MissingPermission")
private fun getLastKnownLocation(manager: LocationManager): Location? =
listOf(GPS_PROVIDER, NETWORK_PROVIDER, PASSIVE_PROVIDER)
.mapNotNull { runCatching { manager.getLastKnownLocation(it) }.getOrNull() }
.maxByOrNull { it.time }
private fun reverseGeocode(lat: Double, lon: Double, callback: LocationCallback) {
// 注意:高德 API 格式是 "经度,纬度"(lon在前)
val url = "$REGEO_URL?location=$lon,$lat&key=$API_KEY&extensions=base&output=JSON"
// OkHttp 调用 ...
// 解析响应
val component = json
.getJSONObject("regeocode")
.getJSONObject("addressComponent")
val adcode = component.optString("adcode")
// 坑:直辖市 city 字段为空,需取 province
val city = component.optString("city").ifEmpty {
component.optString("province")
}
val district = component.optString("district")
callback.onCityCode(adcode, city, district)
}
}
2.3 意图识别:我们添加 GPS 的关键词
在 AiIntentParser 里加一批触发词,识别「天气」类意图:
kotlin
private val LOCATION_KEYWORDS = listOf(
"这里", "附近", "当前", "我这", "这边", "当前位置", "我在哪", "这里的"
)
// 返回特殊常量 INTENT_LOCATION,交给 Activity 分支处理
const val INTENT_LOCATION = "__LOCATION__"
fun isLocationIntent(text: String): Boolean {
val hasLocation = LOCATION_KEYWORDS.any { text.contains(it) }
val hasWeather = text.contains("天气") || WEATHER_KEYWORDS.any { text.contains(it) }
return hasLocation && hasWeather
}
Activity 侧处理分支:
kotlin
private fun processRecognizedText(text: String) {
val intent = intentParser.parseWeatherIntent(text, conversationContext)
when {
intent == null -> {
updateStatus("未识别到查询意图,请说「XXX天气」或「这里天气」")
notifyAiError()
}
intent == AiIntentParser.INTENT_LOCATION -> handleLocationIntent()
else -> queryWeather(intent, intentParser.getCityNameByCode(intent))
}
}
private fun handleLocationIntent() {
checkLocationPermission {
locationHelper.getCurrentCityCode(object : LocationHelper.LocationCallback {
override fun onCityCode(adcode: String, cityName: String, districtName: String) {
val name = if (districtName.isNotBlank()) "$cityName$districtName" else cityName
queryWeather(adcode, name)
}
override fun onError(reason: String) { notifyAiError() }
})
}
}
三、功能 B:对话上下文工程
3.1 核心数据结构
kotlin
data class ConversationContext(
val lastCityCode: String? = null,
val lastCityName: String? = null,
val turnCount: Int = 0,
val lastQueryTimeMs: Long = 0L
) {
companion object {
private const val CONTEXT_TTL_MS = 5 * 60 * 1000L // 5分钟
}
fun isValid(): Boolean =
lastCityCode != null &&
(System.currentTimeMillis() - lastQueryTimeMs) < CONTEXT_TTL_MS
fun advance(cityCode: String, cityName: String): ConversationContext =
copy(
lastCityCode = cityCode,
lastCityName = cityName,
turnCount = turnCount + 1,
lastQueryTimeMs = System.currentTimeMillis()
)
}
为什么设 5 分钟 TTL? 其实就是经验估计:5 分钟内的续问大概率是连续对话;超过 5 分钟放下手机再拿起来,基本是新话题,不应复用旧上下文。
3.2 续播意图的两种形态
kotlin
private val CONTINUATION_KEYWORDS = listOf(
"那呢", "那边", "那里呢", "那边呢", "再查", "继续", "再来一次", "重新查"
)
private fun parseContinuationIntent(text: String, ctx: ConversationContext): String? {
// 形态1:续播词 → 直接复用上次城市
if (CONTINUATION_KEYWORDS.any { text.contains(it) }) return ctx.lastCityCode
// 形态2:只有城市名,没有天气关键词(「福州呢」)→ 切换城市
val hasWeather = WEATHER_KEYWORDS.any { text.contains(it) }
if (!hasWeather) {
val cityCode = extractCityCode(text)
if (cityCode != null) return cityCode
}
return null
}
三种典型场景对照:
| 用户说 | 解析结果 |
|---|---|
| 「福州呢」 | 形态2:切换到福州 |
| 「那边呢」 | 形态1:复用上次城市 |
| 「再查一次」 | 形态1:同城市重查 |
| 「明天北京天气」 | 正常解析:北京(不走续播) |
Activity 侧每次成功查询后更新上下文:
kotlin
// queryWeather 成功回调中:
conversationContext = conversationContext.advance(cityCode, cityName)
四、功能 C:AI 旅游规划
4.1 为什么用 LLM, 而不是规则
用规则也能生成简单建议:
kotlin
if (temp < 10) "适合室内景点"
else if (weather.contains("雨")) "建议带伞,推荐室内博物馆"
else "户外景点和公园都适合"
问题在于这是死的。同样是 25 度、晴天:北京故宫需要建议避开人流高峰;杭州西湖需要推荐骑行路线;三亚应该提醒防晒。LLM 能感知城市的旅游特色、气候背景,给出有地域差异的个性化旅游建议,这是规则系统做不到的。
4.2 核心代码:AiTravelPlanHelper.kt
kotlin
class AiTravelPlanHelper {
companion object {
private const val API_URL = "https://api.anthropic.com/v1/messages"
private const val CLAUDE_API_KEY = "YOUR_CLAUDE_API_KEY"
private const val MODEL = "claude-haiku-4-5-20251001"
}
interface TravelPlanCallback {
fun onTravelPlan(plan: String)
fun onError(reason: String)
}
fun getTravelPlan(
city: String, temp: String, weather: String,
wind: String, humidity: String,
callback: TravelPlanCallback
) {
val systemPrompt = "你是一个专业的旅游规划助手,根据天气数据为用户生成简洁的中文旅游建议。" +
"要求:语气自然友好,不超过80字,直接给建议,包含1-2个当地特色景点推荐,不要重复天气数据。"
val userMessage = "城市:$city,气温:${temp}°C,天气:$weather," +
"风力:$wind,湿度:${humidity}%,请给出旅游建议。"
val requestBody = JSONObject().apply {
put("model", MODEL)
put("max_tokens", 300)
put("system", systemPrompt)
put("messages", JSONArray().apply {
put(JSONObject().apply {
put("role", "user")
put("content", userMessage)
})
})
}.toString()
val request = Request.Builder()
.url(API_URL)
.addHeader("x-api-key", CLAUDE_API_KEY)
.addHeader("anthropic-version", "2023-06-01")
.addHeader("content-type", "application/json")
.post(requestBody.toRequestBody("application/json".toMediaType()))
.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
val body = response.body?.string() ?: return
val plan = JSONObject(body)
.getJSONArray("content")
.getJSONObject(0)
.optString("text")?.trim()
if (plan != null) callback.onTravelPlan(plan)
else callback.onError("解析失败")
}
override fun onFailure(call: Call, e: IOException) {
callback.onError("网络请求失败: ${e.message}")
}
})
}
}
4.3 Prompt 设计要点
「不要重复天气数据」这条约束很关键------用户刚听完 TTS 播报了天气,建议里再说「当前北京25度晴天,推荐去故宫」是纯粹的信息冗余。选 claude4.5而不是更强的模型,是因为这个场景对「聪明程度」要求不高,对延迟的要求更高:用户说完天气查询,天气 TTS 结束后 2 秒内最好就能听到旅游建议。
4.4 与天气查询的串联时序
kotlin
private fun queryWeather(cityCode: String, cityName: String) {
weatherApiHelper.getWeatherForecast(cityCode, object : WeatherApiHelper.WeatherCallback {
override fun onSuccess(response: WeatherApiResponse) {
val live = response.lives?.firstOrNull()
val forecast = response.forecasts?.firstOrNull()
// 1. 打开眼镜端 Custom View(旅游规划区初始显示「规划获取中...」)
openGlassCustomView(weatherViewHelper.generateWeatherViewJson(live, forecast))
// 2. TTS 播报天气摘要
sendWeatherTts(weatherViewHelper.generateWeatherTtsText(live, forecast))
// 3. 更新多轮上下文
conversationContext = conversationContext.advance(cityCode, cityName)
// 4. 异步获取 AI 旅游规划(不阻塞天气播报)
if (live != null) fetchAiTravelPlan(live, cityName)
}
override fun onError(error: String) { notifyAiError() }
})
}
private fun fetchAiTravelPlan(live: Live, cityName: String) {
val wind = "${live.winddirection ?: ""} ${live.windpower ?: ""}".trim()
travelPlanHelper.getTravelPlan(
city = cityName, temp = live.temperature ?: "--",
weather = live.weather ?: "--", wind = wind,
humidity = live.humidity ?: "--",
callback = object : AiTravelPlanHelper.TravelPlanCallback {
override fun onTravelPlan(plan: String) {
// 更新眼镜端旅游规划控件
updateGlassCustomView(weatherViewHelper.generateTravelPlanUpdateJson(plan))
// 延迟 2 秒播报,避免与天气 TTS 重叠
Handler(Looper.getMainLooper()).postDelayed({
sendGlobalTtsContent(plan)
}, 2000L)
}
override fun onError(reason: String) {
updateGlassCustomView(
weatherViewHelper.generateTravelPlanUpdateJson("旅游规划暂时无法获取")
)
}
}
)
}
4.5 眼镜端 Custom View 新增旅游规划区
WeatherViewHelper 在原有天气卡片末尾追加分割线和旅游规划控件:
kotlin
// 分割线
children.put(createTextView(
id = "tv_divider",
text = "─────────────────",
textSize = "10sp",
textColor = "#FF444444",
marginTop = "12dp",
marginBottom = "8dp"
))
// AI 旅游规划占位(成功后 updateCustomView 更新)
children.put(createTextView(
id = ViewIds.TV_TRAVEL_PLAN,
text = "规划获取中...",
textSize = "14sp",
textColor = "#FFFFCC00" // 金色,区别于普通信息
))
仅更新旅游规划的方法:
kotlin
fun generateTravelPlanUpdateJson(plan: String): String {
val updates = JSONArray()
updates.put(createUpdateAction(ViewIds.TV_TRAVEL_PLAN, "text", plan))
return updates.toString()
}
五、踩坑与排错速查
直辖市逆地理编码返回城市名为空
高德 regeo 接口,北京/上海/天津/重庆的 city 字段是空字符串,城市信息在 province 里:
kotlin
// 错误写法:
val city = component.optString("city") // 北京返回 ""
// 正确写法:
val city = component.optString("city").ifEmpty {
component.optString("province")
}
续播语义识别错误
判断关键是「有没有天气关键词」:
- 有天气关键词(「北京天气」)→ 走正常解析,不走续播
- 无天气关键词(「北京呢」)+ 有城市名 → 走续播,切换城市
- 续播词(「那边呢」)→ 复用上次城市
AI 旅游规划延迟太长/播报重叠
Claude Haiku 响应通常在 1-2 秒。fetchAiTravelPlan 在天气查询成功后立即异步发起,规划播报延迟 2 秒,基本不会与天气 TTS 重叠。如果网络慢可以加 OkHttp 超时:
kotlin
OkHttpClient.Builder()
.readTimeout(10, TimeUnit.SECONDS)
.build()
requestSingleUpdate 废弃警告
LocationManager.requestSingleUpdate() 在 API 30+ 被标记废弃,但本项目 minSdk=28,功能完全正常,用 @Suppress("DEPRECATION") 压警告即可。
六、完整调用示意
erlang
用户:「这里天气」
→ isLocationIntent → INTENT_LOCATION
→ checkLocationPermission → LocationHelper.getCurrentCityCode
→ 高德 regeo → adcode=110105(朝阳区)
→ queryWeather("110105", "北京市朝阳区")
→ openCustomView(天气卡片,旅游规划区显示「获取中...」)
→ sendTtsContent(「北京市朝阳区当前天气,温度25度,晴...」)
→ context.advance("110105", "北京市朝阳区")
→ AiTravelPlanHelper.getTravelPlan → Claude API
→ updateCustomView(「今天天气舒适,推荐去朝阳公园散步,傍晚可以去三里屯逛逛」)
→ 2秒后 sendGlobalTtsContent(「今天天气舒适,推荐去朝阳公园散步,傍晚可以去三里屯逛逛」)
用户:「福州呢」
→ parseContinuationIntent → 形态2,切换到福州
→ queryWeather("310101", "福州") ...(同上流程)
用户:「那边呢」
→ parseContinuationIntent → 形态1,复用福州
→ queryWeather("310101", "福州") ...
七、其他功能
做完这篇,其实有一个更大的问题浮现:眼镜应该做什么?
手机是工具------你主动去用它。眼镜是助手------它在你需要的时候说一句话,然后闭上嘴。
天气+旅游是最安全的起点:不打扰、有明确答案、TTS 一句话说完。但如果你想继续探索,以下方向都在这套框架上可以直接延伸:
-
景点导览:到达景点后自动识别位置,推送景点介绍和历史背景
-
行程提醒:结合日历,提前推送目的地天气和出行建议
-
实时路况:结合地图数据,提供出行路线和实时交通信息
-
多日规划:「那明天呢」处理预报字段,生成多天旅游行程
-
美食推荐:结合当地特色美食,根据天气推荐适合的餐厅
🙏🙏🙏🙏