在前两篇文章里,我们完成了从零到一:第一篇打通了移动端与眼镜端的消息通道,第二篇在眼镜上展示了天气卡片并加入了 TTS 语音播报。

但还有三个短板没有解决:说话要带城市名(不能说「这里天气」)、每次都是全新查询(没有上下文记忆)、播完天气就结束了(没有进一步建议)。本篇解决这三件事,同时也是整个系列的收官实战篇。

如果您有任何疑问、对文章写的不满意、发现错误或者有更好的方法,如果你想支持下一期请务必点赞~,欢迎在评论、私信或邮件中提出,非常感谢您的支持。🙏


那么你将获得

  • GPS 自动定位:说「这里天气」自动获取位置,不用报城市名
  • 多轮对话:说「上海呢」「那边呢」「再查一次」接续上轮查询
  • AI 穿衣建议:接入 Claude API,天气播报后自动追加穿衣/出行建议
  • 可直接复制的 Kotlin 代码(LocationHelper、ConversationContext、AiSuggestionHelper)
  • 踩坑:直辖市 adcode、续播语义识别、LLM 延迟控制

一、总体流程

本篇在已有的 AiWeatherActivity(AI 语音查天气)基础上扩展,整体数据流如下:

Feature Provider Assembly-2026-03-02-091159

新增三个辅助类,原有文件做对应改造:

新建文件 职责
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 实现路径

Feature Provider Assembly-2026-03-02-091258

为什么优先缓存? 用户说完「这里天气」不想等 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) 做实时翻译
  • 时间维度续播:「那明天呢」处理预报字段

没了。如果你想支持下一期请务必点赞~,欢迎在评论、私信或邮件中提出任何问题,这对我真的很重要,非常感谢您的支持。🙏

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐