跳到主要内容基于 Rokid 眼镜的 AI 天气与旅游规划应用实战 | 极客日志KotlinAI大前端
基于 Rokid 眼镜的 AI 天气与旅游规划应用实战
综述由AI生成分享在 Rokid 智能眼镜上开发 AI 天气应用的实战经验,涵盖 GPS 自动定位、多轮对话上下文管理以及接入 LLM 进行个性化旅游规划的核心实现。重点解决了直辖市逆地理编码异常、续播语义识别逻辑、LLM 响应延迟控制等常见问题,提供了完整的 Kotlin 代码示例与调用时序分析,帮助开发者快速构建语音交互场景下的智能服务。
魔尊11 浏览 基于 Rokid 眼镜的 AI 天气与旅游规划应用实战
本文分享在 Rokid 智能眼镜上开发 AI 天气应用的实战经验,涵盖 GPS 自动定位、多轮对话上下文管理以及接入 LLM 进行个性化旅游规划的核心实现。
技术栈概览
- GPS 自动定位:说「这里天气」自动获取位置,无需报城市名
- 多轮对话:支持「上海呢」「那边呢」接续上轮查询
- AI 旅游规划:接入 Claude API,天气播报后自动生成个性化建议
- 核心代码:LocationHelper、ConversationContext、AiTravelPlanHelper
一、主要流程
本应用在 AiWeatherActivity(AI 语音查天气)基础上扩展,整体数据流如下:
1. 新增辅助类
| 新建文件 | 职责 |
|---|
| 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 实现路径
用户说完'这里的天气'不想等太久。缓存位置最多偏差几公里,对天气查询完全够用。
2.2 核心代码:LocationHelper.kt
class LocationHelper(private val context: Context) {
interface LocationCallback {
fun onCityCode(adcode: String, cityName: String, districtName: String)
fun onError(reason: String)
}
{
(!hasLocationPermission()) {
callback.onError()
}
manager = context.getSystemService(Context.LOCATION_SERVICE) LocationManager
lastKnown = getLastKnownLocation(manager)
(lastKnown != ) {
reverseGeocode(lastKnown.latitude, lastKnown.longitude, callback)
} {
requestSingleUpdate(manager, callback)
}
}
: Location? = listOf(
GPS_PROVIDER, NETWORK_PROVIDER, PASSIVE_PROVIDER
).mapNotNull { runCatching { manager.getLastKnownLocation(it) }.getOrNull() }
.maxByOrNull { it.time }
{
url =
component = json.getJSONObject().getJSONObject()
adcode = component.optString()
city = component.optString().ifEmpty { component.optString() }
district = component.optString()
callback.onCityCode(adcode, city, district)
}
}
fun getCurrentCityCode(callback: LocationCallback)
if
"缺少定位权限"
return
val
as
val
if
null
else
@SuppressLint("MissingPermission")
private
fun getLastKnownLocation(manager: LocationManager)
private
fun reverseGeocode(lat: Double, lon: Double, callback: LocationCallback)
val
"$REGEO_URL?location=$lon,$lat&key=$API_KEY&extensions=base&output=JSON"
val
"regeocode"
"addressComponent"
val
"adcode"
val
"city"
"province"
val
"district"
2.3 意图识别:添加 GPS 关键词
在 AiIntentParser 里加一批触发词,识别「天气」类意图:
private val LOCATION_KEYWORDS = listOf("这里", "附近", "当前", "我这", "这边", "当前位置", "我在哪", "这里的")
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
}
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
}
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? {
if (CONTINUATION_KEYWORDS.any { text.contains(it) }) return ctx.lastCityCode
val hasWeather = WEATHER_KEYWORDS.any { text.contains(it) }
if (!hasWeather) {
val cityCode = extractCityCode(text)
if (cityCode != null) return cityCode
}
return null
}
| 用户说 | 解析结果 |
|---|
| 「福州呢」 | 形态 2:切换到福州 |
| 「那边呢」 | 形态 1:复用上次城市 |
| 「再查一次」 | 形态 1:同城市重查 |
| 「明天北京天气」 | 正常解析:北京(不走续播) |
conversationContext = conversationContext.advance(cityCode, cityName)
四、功能 C:AI 旅游规划
4.1 为什么用 LLM,而不是规则
if (temp < 10) "适合室内景点"
else if (weather.contains("雨")) "建议带伞,推荐室内博物馆"
else "户外景点和公园都适合"
问题在于这是死的。同样是 25 度、晴天:北京故宫需要建议避开人流高峰;杭州西湖需要推荐骑行路线;三亚应该提醒防晒。LLM 能感知城市的旅游特色、气候背景,给出有地域差异的个性化旅游建议,这是规则系统做不到的。
4.2 核心代码:AiTravelPlanHelper.kt
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 与天气查询的串联时序
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()
openGlassCustomView(weatherViewHelper.generateWeatherViewJson(live, forecast))
sendWeatherTts(weatherViewHelper.generateWeatherTtsText(live, forecast))
conversationContext = conversationContext.advance(cityCode, cityName)
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))
Handler(Looper.getMainLooper()).postDelayed({
sendGlobalTtsContent(plan)
}, 2000L)
}
override fun onError(reason: String) {
updateGlassCustomView(
weatherViewHelper.generateTravelPlanUpdateJson("旅游规划暂时无法获取")
)
}
}
)
}
4.5 眼镜端 Custom View 新增旅游规划区
WeatherViewHelper 在原有天气卡片末尾追加分割线和旅游规划控件:
children.put(createTextView(
id = "tv_divider",
text = "─────────────────",
textSize = "10sp",
textColor = "#FF444444",
marginTop = "12dp",
marginBottom = "8dp"
))
children.put(createTextView(
id = ViewIds.TV_TRAVEL_PLAN,
text = "规划获取中...",
textSize = "14sp",
textColor = "#FFFFCC00"
))
fun generateTravelPlanUpdateJson(plan: String): String {
val updates = JSONArray()
updates.put(createUpdateAction(ViewIds.TV_TRAVEL_PLAN, "text", plan))
return updates.toString()
}
五、踩坑与排错速查
直辖市逆地理编码返回城市名为空
高德 regeo 接口,北京/上海/天津/重庆的 city 字段是空字符串,城市信息在 province 里:
续播语义识别错误
- 有天气关键词(「北京天气」)→ 走正常解析,不走续播
- 无天气关键词(「北京呢」)+ 有城市名 → 走续播,切换城市
- 续播词(「那边呢」)→ 复用上次城市
AI 旅游规划延迟太长/播报重叠
Claude Haiku 响应通常在 1-2 秒。fetchAiTravelPlan 在天气查询成功后立即异步发起,规划播报延迟 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", "北京市朝阳区")
→ AiTravelPlanHelper.getTravelPlan
→ Claude API
→ updateCustomView(「今天天气舒适,推荐去朝阳公园散步,傍晚可以去三里屯逛逛」)
→ 2 秒后 sendGlobalTtsContent(「今天天气舒适,推荐去朝阳公园散步,傍晚可以去三里屯逛逛」)
用户:「福州呢」
→ parseContinuationIntent
→ 形态 2,切换到福州
→ queryWeather("310101", "福州") ...(同上流程)
用户:「那边呢」
→ parseContinuationIntent
→ 形态 1,复用福州
→ queryWeather("310101", "福州") ...
七、其他功能延伸
做完这篇,其实有一个更大的问题浮现:眼镜应该做什么?
手机是工具——你主动去用它。眼镜是助手——它在你需要的时候说一句话,然后闭上嘴。
天气 + 旅游是最安全的起点:不打扰、有明确答案、TTS 一句话说完。但如果你想继续探索,以下方向都在这套框架上可以直接延伸:
- 景点导览:到达景点后自动识别位置,推送景点介绍和历史背景
- 行程提醒:结合日历,提前推送目的地天气和出行建议
- 实时路况:结合地图数据,提供出行路线和实时交通信息
- 多日规划:「那明天呢」处理预报字段,生成多天旅游行程
- 美食推荐:结合当地特色美食,根据天气推荐适合的餐厅
相关免费在线工具
- RSA密钥对生成器
生成新的随机RSA私钥和公钥pem证书。 在线工具,RSA密钥对生成器在线工具,online
- Mermaid 预览与可视化编辑
基于 Mermaid.js 实时预览流程图、时序图等图表,支持源码编辑与即时渲染。 在线工具,Mermaid 预览与可视化编辑在线工具,online
- 随机西班牙地址生成器
随机生成西班牙地址(支持马德里、加泰罗尼亚、安达卢西亚、瓦伦西亚筛选),支持数量快捷选择、显示全部与下载。 在线工具,随机西班牙地址生成器在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown转HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online