Commit d79111e01f2f1b982ccbd935582a49799e7af34c
1 parent
efa98ccf
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,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( |