【征文计划】基于Rokid 眼镜的AI天气应用+GPS定位+AI旅游规划

本文我将解决这三件事,将天气应用升级为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 一句话说完。但如果你想继续探索,以下方向都在这套框架上可以直接延伸:

  • 景点导览:到达景点后自动识别位置,推送景点介绍和历史背景

  • 行程提醒:结合日历,提前推送目的地天气和出行建议

  • 实时路况:结合地图数据,提供出行路线和实时交通信息

  • 多日规划:「那明天呢」处理预报字段,生成多天旅游行程

  • 美食推荐:结合当地特色美食,根据天气推荐适合的餐厅

    🙏🙏🙏🙏

相关推荐
王码码203516 小时前
Go语言的测试:从单元测试到集成测试
后端·golang·go·接口
王码码203516 小时前
Go语言中的测试:从单元测试到集成测试
后端·golang·go·接口
嵌入式×边缘AI:打怪升级日志17 小时前
使用JsonRPC实现前后台
前端·后端
小码哥_常17 小时前
从0到1:Spring Boot 中WebSocket实战揭秘,开启实时通信新时代
后端
lolo大魔王18 小时前
Go语言的异常处理
开发语言·后端·golang
IT_陈寒20 小时前
Python多进程共享变量那个坑,我差点没爬出来
前端·人工智能·后端
码事漫谈20 小时前
2026软考高级·系统架构设计师备考指南
后端
AI茶水间管理员21 小时前
如何让LLM稳定输出 JSON 格式结果?
前端·人工智能·后端
其实是白羊21 小时前
我用 Vibe Coding 搓了一个 IDEA 插件,复制URI 再也不用手动拼了
后端·intellij idea
用户83562907805121 小时前
Python 操作 Word 文档节与页面设置
后端·python