Commit e89c0c404fbc6f1cae4e53bfe7f86c7c237d2ffd

Authored by yibo
1 parent ca6773d7

feat(chat): Tavily web_search 工具 + 设置页 API Key 配置

对话工具模式新增 web_search 工具,调用 Tavily Search API 搜索公开
互联网;ModelConfig 加 tavilyKey/saveTavilyKey;SettingsScreen 加
「全网搜索(Tavily)」配置区。Key 留空时工具返回提示而非报错。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/SettingsScreen.kt
@@ -89,6 +89,9 @@ fun SettingsScreen(activity: Context) { @@ -89,6 +89,9 @@ fun SettingsScreen(activity: Context) {
89 var hermesModel by remember(configRev) { mutableStateOf(ModelConfig.hermesProvider(activity).chatModel) } 89 var hermesModel by remember(configRev) { mutableStateOf(ModelConfig.hermesProvider(activity).chatModel) }
90 var hermesSaveMsg by remember { mutableStateOf("") } 90 var hermesSaveMsg by remember { mutableStateOf("") }
91 91
  92 + var tavilyKey by remember(configRev) { mutableStateOf(ModelConfig.tavilyKey(activity)) }
  93 + var tavilySaveMsg by remember { mutableStateOf("") }
  94 +
92 var editingProvider by remember { mutableStateOf<LlmProvider?>(null) } 95 var editingProvider by remember { mutableStateOf<LlmProvider?>(null) }
93 var isNewProvider by remember { mutableStateOf(false) } 96 var isNewProvider by remember { mutableStateOf(false) }
94 97
@@ -182,6 +185,20 @@ fun SettingsScreen(activity: Context) { @@ -182,6 +185,20 @@ fun SettingsScreen(activity: Context) {
182 } 185 }
183 186
184 HorizontalDivider() 187 HorizontalDivider()
  188 + Text("全网搜索(Tavily)", style = MaterialTheme.typography.titleMedium)
  189 + Text("对话工具模式(🔧)中可用 web_search 工具搜索互联网。",
  190 + style = MaterialTheme.typography.labelSmall)
  191 + OutlinedTextField(value = tavilyKey, onValueChange = { tavilyKey = it },
  192 + label = { Text("Tavily API Key") }, singleLine = true, modifier = Modifier.fillMaxWidth())
  193 + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
  194 + Button(onClick = {
  195 + ModelConfig.saveTavilyKey(activity, tavilyKey)
  196 + tavilySaveMsg = "已保存"
  197 + }) { Text("保存") }
  198 + if (tavilySaveMsg.isNotEmpty()) Text(tavilySaveMsg, style = MaterialTheme.typography.labelSmall)
  199 + }
  200 +
  201 + HorizontalDivider()
185 Text("远程服务商", style = MaterialTheme.typography.titleMedium) 202 Text("远程服务商", style = MaterialTheme.typography.titleMedium)
186 Text("添加任意 OpenAI 兼容 API(通义千问 / Kimi / MiniMax 等),对话模型名不为空则出现在聊天列表。", 203 Text("添加任意 OpenAI 兼容 API(通义千问 / Kimi / MiniMax 等),对话模型名不为空则出现在聊天列表。",
187 style = MaterialTheme.typography.labelSmall) 204 style = MaterialTheme.typography.labelSmall)
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatViewModel.kt
@@ -217,7 +217,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { @@ -217,7 +217,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
217 toolStatus = "⚙ $name" 217 toolStatus = "⚙ $name"
218 streamingText = "⚙ 调用:$name…" 218 streamingText = "⚙ 调用:$name…"
219 Log.d(TAG, "exec react tool $name args=$args") 219 Log.d(TAG, "exec react tool $name args=$args")
220 - val result = withContext(Dispatchers.IO) { LifelogTools.execute(repo, name, args) } 220 + val result = withContext(Dispatchers.IO) { LifelogTools.execute(repo, getApplication(), name, args) }
221 Log.d(TAG, "react tool $name result=${result.take(120)}") 221 Log.d(TAG, "react tool $name result=${result.take(120)}")
222 toolLog.appendLine("**[$name]** ${result.take(600)}") 222 toolLog.appendLine("**[$name]** ${result.take(600)}")
223 resultBlock.appendLine("[$name 结果]") 223 resultBlock.appendLine("[$name 结果]")
@@ -275,26 +275,36 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { @@ -275,26 +275,36 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
275 private val REACT_SYSTEM_PROMPT = """ 275 private val REACT_SYSTEM_PROMPT = """
276 你是用户的 lifelog AI 助手,可访问以下工具查询真实数据(禁止凭空编造): 276 你是用户的 lifelog AI 助手,可访问以下工具查询真实数据(禁止凭空编造):
277 277
278 - 【录音 & 纪要】 278 + 【录音 & 纪要 & 时间】
279 ① search_transcripts — 搜录音转写(args: query, speaker可选, limit默认12) 279 ① search_transcripts — 搜录音转写(args: query, speaker可选, limit默认12)
280 ② get_summaries — 最近会话纪要(args: limit默认5) 280 ② get_summaries — 最近会话纪要(args: limit默认5)
  281 + ③ search_by_time — 按时间段查录音(args: start_ts, end_ts 毫秒, limit默认20)
  282 + ④ get_conversation_context — 某段上下文(args: segment_id或timestamp_ms, window_minutes默认5)
281 283
282 - 【联系人 & 关系】  
283 - ③ get_person — 查联系人档案(args: name)  
284 - ④ list_persons — 列 CRM 人物(args: managed_only默认true)  
285 - ⑤ get_relations — 某人的共现关系图(args: person_name)  
286 - ⑥ get_mentions — 录音中提到某人的片段(args: person_name, limit默认10) 284 + 【联系人 & 关系 & 时间线】
  285 + ⑤ get_person — 查联系人档案(args: name)
  286 + ⑥ list_persons — 列 CRM 人物(args: managed_only默认true)
  287 + ⑦ get_relations — 某人共现关系图(args: person_name)
  288 + ⑧ get_mentions — 录音中提到某人的片段(args: person_name, limit默认10)
  289 + ⑨ get_person_timeline — 某人全部事件时间线(args: person_name, limit默认10)
  290 + ⑩ update_person_profile — 更新联系人档案字段(args: name, field, value;field可为 mbti/bazi/github 等)
  291 +
  292 + 【话题实体】
  293 + ⑪ get_topics — 话题/组织/项目/事件(args: kind可选org|project|event, limit默认20)
287 294
288 【微信聊天 & 朋友圈】 295 【微信聊天 & 朋友圈】
289 - ⑦ search_wechat_chat — 搜微信私聊(args: query, person_name可选, limit默认10)  
290 - ⑧ search_group_chat — 搜微信群聊(args: query, limit默认10)  
291 - ⑨ get_moments — 某人朋友圈文字(args: person_name, keyword可选, limit默认20)  
292 - ⑩ search_moments — 全局搜朋友圈(args: query, limit默认10) 296 + ⑫ search_wechat_chat — 搜微信私聊(args: query, person_name可选, limit默认10)
  297 + ⑬ search_group_chat — 搜微信群聊(args: query, limit默认10)
  298 + ⑭ get_moments — 某人朋友圈文字(args: person_name, keyword可选, limit默认20)
  299 + ⑮ search_moments — 全局搜朋友圈(args: query, limit默认10)
293 300
294 【相册 & 待跟进】 301 【相册 & 待跟进】
295 - ⑪ search_photos — 关键词搜相册(args: query, limit默认8)  
296 - ⑫ get_person_photos — 某人出现的照片(args: person_name, limit默认10)  
297 - ⑬ get_followups — 待跟进事项(args: 无) 302 + ⑯ search_photos — 关键词搜相册(args: query, limit默认8)
  303 + ⑰ get_person_photos — 某人出现的照片(args: person_name, limit默认10)
  304 + ⑱ get_followups — 待跟进事项(args: 无)
  305 +
  306 + 【全网搜索】
  307 + ⑲ web_search — Tavily 搜索互联网(args: query, max_results默认5;需设置中配置 Key)
298 308
299 需要查询时,输出(每个工具独占一行,严格 JSON): 309 需要查询时,输出(每个工具独占一行,严格 JSON):
300 TOOL_CALL: {"name": "工具名", "args": {"参数": "值"}} 310 TOOL_CALL: {"name": "工具名", "args": {"参数": "值"}}
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogTools.kt
1 package com.lemon.lifelog.chat 1 package com.lemon.lifelog.chat
2 2
  3 +import android.content.Context
3 import com.lemon.lifelog.data.Repository 4 import com.lemon.lifelog.data.Repository
4 import kotlinx.coroutines.Dispatchers 5 import kotlinx.coroutines.Dispatchers
5 import kotlinx.coroutines.flow.first 6 import kotlinx.coroutines.flow.first
6 import kotlinx.coroutines.withContext 7 import kotlinx.coroutines.withContext
  8 +import okhttp3.MediaType.Companion.toMediaType
  9 +import okhttp3.OkHttpClient
  10 +import okhttp3.Request
  11 +import okhttp3.RequestBody.Companion.toRequestBody
7 import org.json.JSONArray 12 import org.json.JSONArray
8 import org.json.JSONObject 13 import org.json.JSONObject
9 import java.text.SimpleDateFormat 14 import java.text.SimpleDateFormat
10 import java.util.Date 15 import java.util.Date
11 import java.util.Locale 16 import java.util.Locale
  17 +import java.util.concurrent.TimeUnit
12 18
13 /** 19 /**
14 * lifelog 工具集:OpenAI function calling schema + 本地执行。 20 * lifelog 工具集:OpenAI function calling schema + 本地执行。
@@ -28,6 +34,10 @@ object LifelogTools { @@ -28,6 +34,10 @@ object LifelogTools {
28 34
29 private val TS = SimpleDateFormat("MM-dd HH:mm", Locale.US) 35 private val TS = SimpleDateFormat("MM-dd HH:mm", Locale.US)
30 private val TSL = SimpleDateFormat("MM-dd HH:mm:ss", Locale.US) 36 private val TSL = SimpleDateFormat("MM-dd HH:mm:ss", Locale.US)
  37 + private val http = OkHttpClient.Builder()
  38 + .connectTimeout(15, TimeUnit.SECONDS)
  39 + .readTimeout(30, TimeUnit.SECONDS)
  40 + .build()
31 41
32 // ── Schema(OpenAI function calling 格式)──────────────────────────────── 42 // ── Schema(OpenAI function calling 格式)────────────────────────────────
33 43
@@ -193,6 +203,15 @@ object LifelogTools { @@ -193,6 +203,15 @@ object LifelogTools {
193 ), 203 ),
194 required = listOf("person_name"), 204 required = listOf("person_name"),
195 )) 205 ))
  206 + put(schema(
  207 + name = "web_search",
  208 + desc = "用 Tavily 搜索公开互联网内容(需在设置中配置 Tavily API Key)",
  209 + props = mapOf(
  210 + "query" to prop("string", "搜索关键词(中英文均可)"),
  211 + "max_results" to prop("integer", "返回结果数,默认 5,最多 10"),
  212 + ),
  213 + required = listOf("query"),
  214 + ))
196 } 215 }
197 } 216 }
198 217
@@ -215,7 +234,7 @@ object LifelogTools { @@ -215,7 +234,7 @@ object LifelogTools {
215 234
216 // ── 执行(在 IO 线程调)────────────────────────────────────────────────── 235 // ── 执行(在 IO 线程调)──────────────────────────────────────────────────
217 236
218 - suspend fun execute(repo: Repository, name: String, args: JSONObject): String = 237 + suspend fun execute(repo: Repository, ctx: Context, name: String, args: JSONObject): String =
219 withContext(Dispatchers.IO) { 238 withContext(Dispatchers.IO) {
220 try { 239 try {
221 when (name) { 240 when (name) {
@@ -237,6 +256,7 @@ object LifelogTools { @@ -237,6 +256,7 @@ object LifelogTools {
237 "search_by_time" -> searchByTime(repo, args) 256 "search_by_time" -> searchByTime(repo, args)
238 "get_conversation_context" -> getConversationContext(repo, args) 257 "get_conversation_context" -> getConversationContext(repo, args)
239 "get_person_timeline" -> getPersonTimeline(repo, args) 258 "get_person_timeline" -> getPersonTimeline(repo, args)
  259 + "web_search" -> webSearch(ctx, args)
240 else -> "未知工具:$name" 260 else -> "未知工具:$name"
241 } 261 }
242 } catch (t: Throwable) { 262 } catch (t: Throwable) {
@@ -607,6 +627,50 @@ object LifelogTools { @@ -607,6 +627,50 @@ object LifelogTools {
607 } 627 }
608 } 628 }
609 629
  630 + // ── 全网搜索(Tavily)────────────────────────────────────────────────────
  631 +
  632 + private fun webSearch(ctx: Context, args: JSONObject): String {
  633 + val query = args.optString("query", "").ifBlank { return "请提供搜索关键词" }
  634 + val maxResults = args.optInt("max_results", 5).coerceIn(1, 10)
  635 + val key = ModelConfig.tavilyKey(ctx).ifBlank {
  636 + return "未配置 Tavily API Key,请在「设置 → Tavily API Key」中填写。"
  637 + }
  638 + val body = JSONObject().apply {
  639 + put("api_key", key)
  640 + put("query", query)
  641 + put("max_results", maxResults)
  642 + put("search_depth", "basic")
  643 + put("include_answer", true)
  644 + }
  645 + val request = Request.Builder()
  646 + .url("https://api.tavily.com/search")
  647 + .header("Content-Type", "application/json")
  648 + .post(body.toString().toRequestBody("application/json".toMediaType()))
  649 + .build()
  650 + http.newCall(request).execute().use { resp ->
  651 + val bodyStr = resp.body?.string().orEmpty()
  652 + if (!resp.isSuccessful) return "Tavily 搜索失败:${resp.code} ${bodyStr.take(200)}"
  653 + val json = JSONObject(bodyStr)
  654 + return buildString {
  655 + json.optString("answer").takeIf { it.isNotBlank() }?.let {
  656 + appendLine("摘要:$it")
  657 + appendLine()
  658 + }
  659 + val results = json.optJSONArray("results")
  660 + if (results != null && results.length() > 0) {
  661 + appendLine("搜索结果(关键词:「$query」):")
  662 + for (i in 0 until results.length()) {
  663 + val r = results.getJSONObject(i)
  664 + appendLine("${i + 1}. ${r.optString("title")}")
  665 + appendLine(" ${r.optString("url")}")
  666 + appendLine(" ${r.optString("content").take(300)}")
  667 + appendLine()
  668 + }
  669 + }
  670 + }
  671 + }
  672 + }
  673 +
610 // ── 人物时间线 ─────────────────────────────────────────────────────────── 674 // ── 人物时间线 ───────────────────────────────────────────────────────────
611 675
612 private suspend fun getPersonTimeline(repo: Repository, args: JSONObject): String { 676 private suspend fun getPersonTimeline(repo: Repository, args: JSONObject): String {
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ModelConfig.kt
@@ -41,6 +41,7 @@ object ModelConfig { @@ -41,6 +41,7 @@ object ModelConfig {
41 private const val K_HERMES_URL = "hermes_url" 41 private const val K_HERMES_URL = "hermes_url"
42 private const val K_HERMES_KEY = "hermes_key" 42 private const val K_HERMES_KEY = "hermes_key"
43 private const val K_HERMES_MODEL = "hermes_model" 43 private const val K_HERMES_MODEL = "hermes_model"
  44 + private const val K_TAVILY_KEY = "tavily_key"
44 45
45 private fun prefs(ctx: Context) = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE) 46 private fun prefs(ctx: Context) = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
46 47
@@ -66,6 +67,13 @@ object ModelConfig { @@ -66,6 +67,13 @@ object ModelConfig {
66 .apply() 67 .apply()
67 } 68 }
68 69
  70 + fun tavilyKey(ctx: Context): String =
  71 + prefs(ctx).getString(K_TAVILY_KEY, null)?.trim().orEmpty()
  72 +
  73 + fun saveTavilyKey(ctx: Context, key: String) {
  74 + prefs(ctx).edit().putString(K_TAVILY_KEY, key.trim()).apply()
  75 + }
  76 +
69 // ── 内置:本地 llama-swap ───────────────────────────────────────────────── 77 // ── 内置:本地 llama-swap ─────────────────────────────────────────────────
70 78
71 fun localSwapProvider(ctx: Context) = LlmProvider( 79 fun localSwapProvider(ctx: Context) = LlmProvider(