diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/SettingsScreen.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/SettingsScreen.kt index 619a71d..5c46837 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/SettingsScreen.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/SettingsScreen.kt @@ -89,6 +89,9 @@ fun SettingsScreen(activity: Context) { var hermesModel by remember(configRev) { mutableStateOf(ModelConfig.hermesProvider(activity).chatModel) } var hermesSaveMsg by remember { mutableStateOf("") } + var tavilyKey by remember(configRev) { mutableStateOf(ModelConfig.tavilyKey(activity)) } + var tavilySaveMsg by remember { mutableStateOf("") } + var editingProvider by remember { mutableStateOf(null) } var isNewProvider by remember { mutableStateOf(false) } @@ -182,6 +185,20 @@ fun SettingsScreen(activity: Context) { } HorizontalDivider() + Text("全网搜索(Tavily)", style = MaterialTheme.typography.titleMedium) + Text("对话工具模式(🔧)中可用 web_search 工具搜索互联网。", + style = MaterialTheme.typography.labelSmall) + OutlinedTextField(value = tavilyKey, onValueChange = { tavilyKey = it }, + label = { Text("Tavily API Key") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { + ModelConfig.saveTavilyKey(activity, tavilyKey) + tavilySaveMsg = "已保存" + }) { Text("保存") } + if (tavilySaveMsg.isNotEmpty()) Text(tavilySaveMsg, style = MaterialTheme.typography.labelSmall) + } + + HorizontalDivider() Text("远程服务商", style = MaterialTheme.typography.titleMedium) Text("添加任意 OpenAI 兼容 API(通义千问 / Kimi / MiniMax 等),对话模型名不为空则出现在聊天列表。", style = MaterialTheme.typography.labelSmall) diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatViewModel.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatViewModel.kt index d167c0b..c6a8f92 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatViewModel.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatViewModel.kt @@ -217,7 +217,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { toolStatus = "⚙ $name" streamingText = "⚙ 调用:$name…" Log.d(TAG, "exec react tool $name args=$args") - val result = withContext(Dispatchers.IO) { LifelogTools.execute(repo, name, args) } + val result = withContext(Dispatchers.IO) { LifelogTools.execute(repo, getApplication(), name, args) } Log.d(TAG, "react tool $name result=${result.take(120)}") toolLog.appendLine("**[$name]** ${result.take(600)}") resultBlock.appendLine("[$name 结果]") @@ -275,26 +275,36 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { private val REACT_SYSTEM_PROMPT = """ 你是用户的 lifelog AI 助手,可访问以下工具查询真实数据(禁止凭空编造): - 【录音 & 纪要】 + 【录音 & 纪要 & 时间】 ① search_transcripts — 搜录音转写(args: query, speaker可选, limit默认12) ② get_summaries — 最近会话纪要(args: limit默认5) + ③ search_by_time — 按时间段查录音(args: start_ts, end_ts 毫秒, limit默认20) + ④ get_conversation_context — 某段上下文(args: segment_id或timestamp_ms, window_minutes默认5) - 【联系人 & 关系】 - ③ get_person — 查联系人档案(args: name) - ④ list_persons — 列 CRM 人物(args: managed_only默认true) - ⑤ get_relations — 某人的共现关系图(args: person_name) - ⑥ get_mentions — 录音中提到某人的片段(args: person_name, limit默认10) + 【联系人 & 关系 & 时间线】 + ⑤ get_person — 查联系人档案(args: name) + ⑥ list_persons — 列 CRM 人物(args: managed_only默认true) + ⑦ get_relations — 某人共现关系图(args: person_name) + ⑧ get_mentions — 录音中提到某人的片段(args: person_name, limit默认10) + ⑨ get_person_timeline — 某人全部事件时间线(args: person_name, limit默认10) + ⑩ update_person_profile — 更新联系人档案字段(args: name, field, value;field可为 mbti/bazi/github 等) + + 【话题实体】 + ⑪ get_topics — 话题/组织/项目/事件(args: kind可选org|project|event, limit默认20) 【微信聊天 & 朋友圈】 - ⑦ search_wechat_chat — 搜微信私聊(args: query, person_name可选, limit默认10) - ⑧ search_group_chat — 搜微信群聊(args: query, limit默认10) - ⑨ get_moments — 某人朋友圈文字(args: person_name, keyword可选, limit默认20) - ⑩ search_moments — 全局搜朋友圈(args: query, limit默认10) + ⑫ search_wechat_chat — 搜微信私聊(args: query, person_name可选, limit默认10) + ⑬ search_group_chat — 搜微信群聊(args: query, limit默认10) + ⑭ get_moments — 某人朋友圈文字(args: person_name, keyword可选, limit默认20) + ⑮ search_moments — 全局搜朋友圈(args: query, limit默认10) 【相册 & 待跟进】 - ⑪ search_photos — 关键词搜相册(args: query, limit默认8) - ⑫ get_person_photos — 某人出现的照片(args: person_name, limit默认10) - ⑬ get_followups — 待跟进事项(args: 无) + ⑯ search_photos — 关键词搜相册(args: query, limit默认8) + ⑰ get_person_photos — 某人出现的照片(args: person_name, limit默认10) + ⑱ get_followups — 待跟进事项(args: 无) + + 【全网搜索】 + ⑲ web_search — Tavily 搜索互联网(args: query, max_results默认5;需设置中配置 Key) 需要查询时,输出(每个工具独占一行,严格 JSON): TOOL_CALL: {"name": "工具名", "args": {"参数": "值"}} diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogTools.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogTools.kt index 8163898..9ccf226 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogTools.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogTools.kt @@ -1,14 +1,20 @@ package com.lemon.lifelog.chat +import android.content.Context import com.lemon.lifelog.data.Repository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONArray import org.json.JSONObject import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import java.util.concurrent.TimeUnit /** * lifelog 工具集:OpenAI function calling schema + 本地执行。 @@ -28,6 +34,10 @@ object LifelogTools { private val TS = SimpleDateFormat("MM-dd HH:mm", Locale.US) private val TSL = SimpleDateFormat("MM-dd HH:mm:ss", Locale.US) + private val http = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() // ── Schema(OpenAI function calling 格式)──────────────────────────────── @@ -193,6 +203,15 @@ object LifelogTools { ), required = listOf("person_name"), )) + put(schema( + name = "web_search", + desc = "用 Tavily 搜索公开互联网内容(需在设置中配置 Tavily API Key)", + props = mapOf( + "query" to prop("string", "搜索关键词(中英文均可)"), + "max_results" to prop("integer", "返回结果数,默认 5,最多 10"), + ), + required = listOf("query"), + )) } } @@ -215,7 +234,7 @@ object LifelogTools { // ── 执行(在 IO 线程调)────────────────────────────────────────────────── - suspend fun execute(repo: Repository, name: String, args: JSONObject): String = + suspend fun execute(repo: Repository, ctx: Context, name: String, args: JSONObject): String = withContext(Dispatchers.IO) { try { when (name) { @@ -237,6 +256,7 @@ object LifelogTools { "search_by_time" -> searchByTime(repo, args) "get_conversation_context" -> getConversationContext(repo, args) "get_person_timeline" -> getPersonTimeline(repo, args) + "web_search" -> webSearch(ctx, args) else -> "未知工具:$name" } } catch (t: Throwable) { @@ -607,6 +627,50 @@ object LifelogTools { } } + // ── 全网搜索(Tavily)──────────────────────────────────────────────────── + + private fun webSearch(ctx: Context, args: JSONObject): String { + val query = args.optString("query", "").ifBlank { return "请提供搜索关键词" } + val maxResults = args.optInt("max_results", 5).coerceIn(1, 10) + val key = ModelConfig.tavilyKey(ctx).ifBlank { + return "未配置 Tavily API Key,请在「设置 → Tavily API Key」中填写。" + } + val body = JSONObject().apply { + put("api_key", key) + put("query", query) + put("max_results", maxResults) + put("search_depth", "basic") + put("include_answer", true) + } + val request = Request.Builder() + .url("https://api.tavily.com/search") + .header("Content-Type", "application/json") + .post(body.toString().toRequestBody("application/json".toMediaType())) + .build() + http.newCall(request).execute().use { resp -> + val bodyStr = resp.body?.string().orEmpty() + if (!resp.isSuccessful) return "Tavily 搜索失败:${resp.code} ${bodyStr.take(200)}" + val json = JSONObject(bodyStr) + return buildString { + json.optString("answer").takeIf { it.isNotBlank() }?.let { + appendLine("摘要:$it") + appendLine() + } + val results = json.optJSONArray("results") + if (results != null && results.length() > 0) { + appendLine("搜索结果(关键词:「$query」):") + for (i in 0 until results.length()) { + val r = results.getJSONObject(i) + appendLine("${i + 1}. ${r.optString("title")}") + appendLine(" ${r.optString("url")}") + appendLine(" ${r.optString("content").take(300)}") + appendLine() + } + } + } + } + } + // ── 人物时间线 ─────────────────────────────────────────────────────────── private suspend fun getPersonTimeline(repo: Repository, args: JSONObject): String { diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ModelConfig.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ModelConfig.kt index 42f27a4..0c5bb57 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ModelConfig.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ModelConfig.kt @@ -41,6 +41,7 @@ object ModelConfig { private const val K_HERMES_URL = "hermes_url" private const val K_HERMES_KEY = "hermes_key" private const val K_HERMES_MODEL = "hermes_model" + private const val K_TAVILY_KEY = "tavily_key" private fun prefs(ctx: Context) = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -66,6 +67,13 @@ object ModelConfig { .apply() } + fun tavilyKey(ctx: Context): String = + prefs(ctx).getString(K_TAVILY_KEY, null)?.trim().orEmpty() + + fun saveTavilyKey(ctx: Context, key: String) { + prefs(ctx).edit().putString(K_TAVILY_KEY, key.trim()).apply() + } + // ── 内置:本地 llama-swap ───────────────────────────────────────────────── fun localSwapProvider(ctx: Context) = LlmProvider(