Commit e89c0c404fbc6f1cae4e53bfe7f86c7c237d2ffd
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>
Showing
4 changed files
with
114 additions
and
15 deletions
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/SettingsScreen.kt
| ... | ... | @@ -89,6 +89,9 @@ fun SettingsScreen(activity: Context) { |
| 89 | 89 | var hermesModel by remember(configRev) { mutableStateOf(ModelConfig.hermesProvider(activity).chatModel) } |
| 90 | 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 | 95 | var editingProvider by remember { mutableStateOf<LlmProvider?>(null) } |
| 93 | 96 | var isNewProvider by remember { mutableStateOf(false) } |
| 94 | 97 | |
| ... | ... | @@ -182,6 +185,20 @@ fun SettingsScreen(activity: Context) { |
| 182 | 185 | } |
| 183 | 186 | |
| 184 | 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 | 202 | Text("远程服务商", style = MaterialTheme.typography.titleMedium) |
| 186 | 203 | Text("添加任意 OpenAI 兼容 API(通义千问 / Kimi / MiniMax 等),对话模型名不为空则出现在聊天列表。", |
| 187 | 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 | 217 | toolStatus = "⚙ $name" |
| 218 | 218 | streamingText = "⚙ 调用:$name…" |
| 219 | 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 | 221 | Log.d(TAG, "react tool $name result=${result.take(120)}") |
| 222 | 222 | toolLog.appendLine("**[$name]** ${result.take(600)}") |
| 223 | 223 | resultBlock.appendLine("[$name 结果]") |
| ... | ... | @@ -275,26 +275,36 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { |
| 275 | 275 | private val REACT_SYSTEM_PROMPT = """ |
| 276 | 276 | 你是用户的 lifelog AI 助手,可访问以下工具查询真实数据(禁止凭空编造): |
| 277 | 277 | |
| 278 | - 【录音 & 纪要】 | |
| 278 | + 【录音 & 纪要 & 时间】 | |
| 279 | 279 | ① search_transcripts — 搜录音转写(args: query, speaker可选, limit默认12) |
| 280 | 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 | 309 | 需要查询时,输出(每个工具独占一行,严格 JSON): |
| 300 | 310 | TOOL_CALL: {"name": "工具名", "args": {"参数": "值"}} | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogTools.kt
| 1 | 1 | package com.lemon.lifelog.chat |
| 2 | 2 | |
| 3 | +import android.content.Context | |
| 3 | 4 | import com.lemon.lifelog.data.Repository |
| 4 | 5 | import kotlinx.coroutines.Dispatchers |
| 5 | 6 | import kotlinx.coroutines.flow.first |
| 6 | 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 | 12 | import org.json.JSONArray |
| 8 | 13 | import org.json.JSONObject |
| 9 | 14 | import java.text.SimpleDateFormat |
| 10 | 15 | import java.util.Date |
| 11 | 16 | import java.util.Locale |
| 17 | +import java.util.concurrent.TimeUnit | |
| 12 | 18 | |
| 13 | 19 | /** |
| 14 | 20 | * lifelog 工具集:OpenAI function calling schema + 本地执行。 |
| ... | ... | @@ -28,6 +34,10 @@ object LifelogTools { |
| 28 | 34 | |
| 29 | 35 | private val TS = SimpleDateFormat("MM-dd HH:mm", Locale.US) |
| 30 | 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 | 42 | // ── Schema(OpenAI function calling 格式)──────────────────────────────── |
| 33 | 43 | |
| ... | ... | @@ -193,6 +203,15 @@ object LifelogTools { |
| 193 | 203 | ), |
| 194 | 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 | 234 | |
| 216 | 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 | 238 | withContext(Dispatchers.IO) { |
| 220 | 239 | try { |
| 221 | 240 | when (name) { |
| ... | ... | @@ -237,6 +256,7 @@ object LifelogTools { |
| 237 | 256 | "search_by_time" -> searchByTime(repo, args) |
| 238 | 257 | "get_conversation_context" -> getConversationContext(repo, args) |
| 239 | 258 | "get_person_timeline" -> getPersonTimeline(repo, args) |
| 259 | + "web_search" -> webSearch(ctx, args) | |
| 240 | 260 | else -> "未知工具:$name" |
| 241 | 261 | } |
| 242 | 262 | } catch (t: Throwable) { |
| ... | ... | @@ -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 | 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 | 41 | private const val K_HERMES_URL = "hermes_url" |
| 42 | 42 | private const val K_HERMES_KEY = "hermes_key" |
| 43 | 43 | private const val K_HERMES_MODEL = "hermes_model" |
| 44 | + private const val K_TAVILY_KEY = "tavily_key" | |
| 44 | 45 | |
| 45 | 46 | private fun prefs(ctx: Context) = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE) |
| 46 | 47 | |
| ... | ... | @@ -66,6 +67,13 @@ object ModelConfig { |
| 66 | 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 | 77 | // ── 内置:本地 llama-swap ───────────────────────────────────────────────── |
| 70 | 78 | |
| 71 | 79 | fun localSwapProvider(ctx: Context) = LlmProvider( | ... | ... |