Rokid 眼镜 AI 天气应用迭代:GPS定位 + 多轮对话 + AI穿衣建议
在前两篇文章里,我们完成了从零到一:第一篇打通了移动端与眼镜端的消息通道,第二篇在眼镜上展示了天气卡片并加入了 TTS 语音播报。但还有三个短板没有解决:说话要带城市名(不能说「这里天气」)、每次都是全新查询(没有上下文记忆)、播完天气就结束了(没有进一步建议)。本篇解决这三件事,同时也是整个系列的收官实战篇。🙏。
在前两篇文章里,我们完成了从零到一:第一篇打通了移动端与眼镜端的消息通道,第二篇在眼镜上展示了天气卡片并加入了 TTS 语音播报。
但还有三个短板没有解决:说话要带城市名(不能说「这里天气」)、每次都是全新查询(没有上下文记忆)、播完天气就结束了(没有进一步建议)。本篇解决这三件事,同时也是整个系列的收官实战篇。
如果您有任何疑问、对文章写的不满意、发现错误或者有更好的方法,如果你想支持下一期请务必点赞~,欢迎在评论、私信或邮件中提出,非常感谢您的支持。🙏
那么你将获得
- GPS 自动定位:说「这里天气」自动获取位置,不用报城市名
- 多轮对话:说「上海呢」「那边呢」「再查一次」接续上轮查询
- AI 穿衣建议:接入 Claude API,天气播报后自动追加穿衣/出行建议
- 可直接复制的 Kotlin 代码(LocationHelper、ConversationContext、AiSuggestionHelper)
- 踩坑:直辖市 adcode、续播语义识别、LLM 延迟控制
一、总体流程
本篇在已有的 AiWeatherActivity(AI 语音查天气)基础上扩展,整体数据流如下:

新增三个辅助类,原有文件做对应改造:
| 新建文件 | 职责 |
|---|---|
LocationHelper.kt |
GPS + 高德逆地理编码 |
ConversationContext.kt |
多轮对话上下文(含5分钟TTL) |
AiSuggestionHelper.kt |
Claude API 穿衣建议 |
| 改动文件 | 改动点 |
|---|---|
AiIntentParser.kt |
+ GPS触发词 + 续播意图解析 + 城市库扩充 |
WeatherViewHelper.kt |
+ tv_suggestion 控件 + generateSuggestionUpdateJson() |
AiWeatherActivity.kt |
串联 GPS / Context / Suggestion 完整调用链 |
二、功能 A:GPS 自动定位
2.1 实现路径

为什么优先缓存? 用户说完「这里天气」不想等 5 秒。缓存位置最多偏差几公里,对天气查询完全够用。
2.2 核心代码:LocationHelper.kt
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 里加一批触发词,识别「这里天气」类意图:
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 侧处理分支:
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 核心数据结构
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 续播意图的两种形态
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 侧每次成功查询后更新上下文:
// queryWeather 成功回调中:
conversationContext = conversationContext.advance(cityCode, cityName)
四、功能 C:AI 穿衣建议(Claude API)
4.1 为什么用 LLM 而不是规则
用规则也能生成建议:
if (temp < 10) "建议穿厚外套"
else if (weather.contains("雨")) "建议带伞"
else "穿着舒适即可"
问题在于这是死的。同样是 10 度、小雨:北京会建议穿棉服;深圳的 10 度已经算相对冷了,建议和北京截然不同。LLM 能感知城市的气候背景,给出有地域差异的自然建议,这是规则系统做不到的。
4.2 核心代码:AiSuggestionHelper.kt
class AiSuggestionHelper {
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 SuggestionCallback {
fun onSuggestion(suggestion: String)
fun onError(reason: String)
}
fun getSuggestion(
city: String, temp: String, weather: String,
wind: String, humidity: String,
callback: SuggestionCallback
) {
val systemPrompt = "你是一个天气助手,根据天气数据生成简洁的中文穿衣和出行建议。" +
"要求:语气自然友好,不超过50字,直接给建议,不要重复天气数据。"
val userMessage = "城市:$city,气温:${temp}°C,天气:$weather," +
"风力:$wind,湿度:${humidity}%,请给出建议。"
val requestBody = JSONObject().apply {
put("model", MODEL)
put("max_tokens", 200)
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 suggestion = JSONObject(body)
.getJSONArray("content")
.getJSONObject(0)
.optString("text")?.trim()
if (suggestion != null) callback.onSuggestion(suggestion)
else callback.onError("解析失败")
}
override fun onFailure(call: Call, e: IOException) {
callback.onError("网络请求失败: ${e.message}")
}
})
}
}
4.3 Prompt 设计要点
「不要重复天气数据」这条约束很关键——用户刚听完 TTS 播报了天气,建议里再说「当前北京25度晴天,建议穿短袖」是纯粹的信息冗余。
选 claude-haiku-4-5-20251001 而不是更强的模型,是因为这个场景对「聪明程度」要求不高,对延迟的要求更高:用户说完天气查询,天气 TTS 结束后 2 秒内最好就能听到建议。
4.4 与天气查询的串联时序
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) fetchAiSuggestion(live, cityName)
}
override fun onError(error: String) { notifyAiError() }
})
}
private fun fetchAiSuggestion(live: Live, cityName: String) {
val wind = "${live.winddirection ?: ""} ${live.windpower ?: ""}".trim()
suggestionHelper.getSuggestion(
city = cityName, temp = live.temperature ?: "--",
weather = live.weather ?: "--", wind = wind,
humidity = live.humidity ?: "--",
callback = object : AiSuggestionHelper.SuggestionCallback {
override fun onSuggestion(suggestion: String) {
// 更新眼镜端建议控件
updateGlassCustomView(weatherViewHelper.generateSuggestionUpdateJson(suggestion))
// 延迟 2 秒播报,避免与天气 TTS 重叠
Handler(Looper.getMainLooper()).postDelayed({
sendGlobalTtsContent(suggestion)
}, 2000L)
}
override fun onError(reason: String) {
updateGlassCustomView(
weatherViewHelper.generateSuggestionUpdateJson("建议暂时无法获取")
)
}
}
)
}
4.5 眼镜端 Custom View 新增建议区
WeatherViewHelper 在原有天气卡片末尾追加分割线和建议控件:
// 分割线
children.put(createTextView(
id = "tv_divider",
text = "─────────────────",
textSize = "10sp",
textColor = "#FF444444",
marginTop = "12dp",
marginBottom = "8dp"
))
// AI 建议占位(成功后 updateCustomView 更新)
children.put(createTextView(
id = ViewIds.TV_SUGGESTION,
text = "建议获取中...",
textSize = "14sp",
textColor = "#FFFFCC00" // 金色,区别于普通信息
))
仅更新建议的方法:
fun generateSuggestionUpdateJson(suggestion: String): String {
val updates = JSONArray()
updates.put(createUpdateAction(ViewIds.TV_SUGGESTION, "text", suggestion))
return updates.toString()
}
五、踩坑与排错速查
直辖市逆地理编码返回城市名为空
高德 regeo 接口,北京/上海/天津/重庆的 city 字段是空字符串,城市信息在 province 里:
// 错误写法:
val city = component.optString("city") // 北京返回 ""
// 正确写法:
val city = component.optString("city").ifEmpty {
component.optString("province")
}
续播语义识别错误
判断关键是「有没有天气关键词」:
- 有天气关键词(「北京天气」)→ 走正常解析,不走续播
- 无天气关键词(「北京呢」)+ 有城市名 → 走续播,切换城市
- 续播词(「那边呢」)→ 复用上次城市
AI 建议延迟太长/播报重叠
Claude Haiku 响应通常在 1-2 秒。fetchAiSuggestion 在天气查询成功后立即异步发起,建议播报延迟 2 秒,基本不会与天气 TTS 重叠。如果网络慢可以加 OkHttp 超时:
OkHttpClient.Builder()
.readTimeout(10, TimeUnit.SECONDS)
.build()
requestSingleUpdate 废弃警告
LocationManager.requestSingleUpdate() 在 API 30+ 被标记废弃,但本项目 minSdk=28,功能完全正常,用 @Suppress("DEPRECATION") 压警告即可。
六、完整调用示意(三功能汇总)
用户:「这里天气」
→ isLocationIntent → INTENT_LOCATION
→ checkLocationPermission → LocationHelper.getCurrentCityCode
→ 高德 regeo → adcode=110105(朝阳区)
→ queryWeather("110105", "北京市朝阳区")
→ openCustomView(天气卡片,建议区显示「获取中...」)
→ sendTtsContent(「北京市朝阳区当前天气,温度25度,晴...」)
→ context.advance("110105", "北京市朝阳区")
→ AiSuggestionHelper.getSuggestion → Claude API
→ updateCustomView(「今天天气舒适,建议穿薄外套出行」)
→ 2秒后 sendGlobalTtsContent(「今天天气舒适,建议穿薄外套出行」)
用户:「上海呢」
→ parseContinuationIntent → 形态2,切换到上海
→ queryWeather("310101", "上海") ...(同上流程)
用户:「那边呢」
→ parseContinuationIntent → 形态1,复用上海
→ queryWeather("310101", "上海") ...
七、需要替换的 Key
| 文件 | 常量 | 说明 |
|---|---|---|
WeatherApiHelper.kt |
API_KEY |
高德天气 Key |
LocationHelper.kt |
API_KEY |
同一个高德 Key |
AiSuggestionHelper.kt |
CLAUDE_API_KEY |
Anthropic API Key |
高德天气和逆地理编码共用同一个 Key(高德开放平台同一应用下权限打通即可)。
八、从这里出发,还能做什么
做完这篇,其实有一个更大的问题浮现:眼镜应该做什么?
手机是工具——你主动去用它。眼镜是助手——它在你需要的时候说一句话,然后闭上嘴。
天气是最安全的起点:不打扰、有明确答案、TTS 一句话说完。但如果你想继续探索,以下方向都在这套框架上可以直接延伸:
- 路况播报:早晨出门前说「今天路况」,调高德交通 API + TTS
- 会议提醒:接入日历权限,到点眼镜自动提醒「15 分钟后有会议」
- 实时翻译:利用
controlScene(CxrSceneType.TRANSLATION)做实时翻译 - 时间维度续播:「那明天呢」处理预报字段
没了。如果你想支持下一期请务必点赞~,欢迎在评论、私信或邮件中提出任何问题,这对我真的很重要,非常感谢您的支持。🙏
更多推荐



所有评论(0)