Skip to content

Commit ccc484b

Browse files
committed
fix:适配三方联网搜索
1 parent 14deb86 commit ccc484b

14 files changed

Lines changed: 1082 additions & 127 deletions

File tree

‎app1/app/src/main/java/com/android/everytalk/data/database/RoomDataSource.kt‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import com.android.everytalk.data.database.entities.toApiConfig
1515
import com.android.everytalk.data.database.entities.toEntity
1616
import com.android.everytalk.data.database.entities.toMessage
1717
import com.android.everytalk.data.database.entities.toVoiceBackendConfig
18+
import com.android.everytalk.data.network.ExternalWebSearchProviderConfig
1819
import com.android.everytalk.util.ConversationNameHelper
1920
import kotlinx.coroutines.runBlocking
2021
import java.util.UUID
@@ -108,6 +109,31 @@ class RoomDataSource(context: Context) {
108109
saveSetting("custom_providers", jsonString)
109110
}
110111

112+
suspend fun loadExternalWebSearchConfigs(): List<ExternalWebSearchProviderConfig> {
113+
val jsonString = settingsDao.getValue("external_web_search_configs")
114+
return if (jsonString != null) {
115+
try {
116+
json.decodeFromString<List<ExternalWebSearchProviderConfig>>(jsonString)
117+
} catch (_: Exception) {
118+
emptyList()
119+
}
120+
} else {
121+
emptyList()
122+
}
123+
}
124+
125+
suspend fun saveExternalWebSearchConfigs(configs: List<ExternalWebSearchProviderConfig>) {
126+
val jsonString = json.encodeToString(configs)
127+
saveSetting("external_web_search_configs", jsonString)
128+
}
129+
130+
suspend fun loadSelectedExternalWebSearchProviderId(): String? =
131+
settingsDao.getValue("selected_external_web_search_provider_id")
132+
133+
suspend fun saveSelectedExternalWebSearchProviderId(providerId: String?) {
134+
saveSetting("selected_external_web_search_provider_id", providerId)
135+
}
136+
111137
private suspend fun saveSetting(key: String, value: String?) {
112138
if (value == null) {
113139
settingsDao.deleteSetting(key)
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package com.android.everytalk.data.network
2+
3+
import androidx.compose.ui.graphics.Color
4+
import kotlinx.serialization.Serializable
5+
6+
@Serializable
7+
data class ExternalWebSearchProviderConfig(
8+
val providerId: String,
9+
val apiKey: String = "",
10+
)
11+
12+
enum class ExternalWebSearchProvider(
13+
val providerId: String,
14+
val displayName: String,
15+
val description: String,
16+
val baseUrl: String,
17+
val apiKeyPlaceholder: String,
18+
val accentColorHex: Long,
19+
) {
20+
TAVILY(
21+
providerId = "tavily",
22+
displayName = "Tavily",
23+
description = "面向 AI 助手优化的实时联网搜索",
24+
baseUrl = "https://api.tavily.com/search",
25+
apiKeyPlaceholder = "tvly-xxxxxxxx",
26+
accentColorHex = 0xFF14B8A6,
27+
),
28+
EXA(
29+
providerId = "exa",
30+
displayName = "Exa",
31+
description = "AI 原生语义搜索,适合高质量网页检索",
32+
baseUrl = "https://api.exa.ai/search",
33+
apiKeyPlaceholder = "exa_xxxxxxxxxxxx",
34+
accentColorHex = 0xFF6366F1,
35+
),
36+
BOCHA(
37+
providerId = "bocha",
38+
displayName = "Bocha",
39+
description = "更适合中文场景的联网搜索服务",
40+
baseUrl = "https://api.bochaai.com/v1/web-search",
41+
apiKeyPlaceholder = "bocha-xxxxxxxx",
42+
accentColorHex = 0xFFF97316,
43+
),
44+
SERPAPI(
45+
providerId = "serpapi",
46+
displayName = "SerpAPI",
47+
description = "聚合多搜索源,接入简单稳定",
48+
baseUrl = "https://serpapi.com/search.json",
49+
apiKeyPlaceholder = "serpapi_xxxxxxxxxxxx",
50+
accentColorHex = 0xFFEF4444,
51+
);
52+
53+
val accentColor: Color
54+
get() = Color(accentColorHex)
55+
56+
companion object {
57+
val defaultProvider: ExternalWebSearchProvider = TAVILY
58+
59+
fun fromId(providerId: String?): ExternalWebSearchProvider? {
60+
if (providerId.isNullOrBlank()) return null
61+
return entries.firstOrNull { it.providerId.equals(providerId, ignoreCase = true) }
62+
}
63+
}
64+
}
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
package com.android.everytalk.data.network
2+
3+
import android.util.Log
4+
import com.android.everytalk.data.DataClass.WebSearchResult
5+
import io.ktor.client.HttpClient
6+
import io.ktor.client.engine.okhttp.OkHttp
7+
import io.ktor.client.plugins.HttpRedirect
8+
import io.ktor.client.plugins.HttpTimeout
9+
import io.ktor.client.request.post
10+
import io.ktor.client.request.header
11+
import io.ktor.client.request.setBody
12+
import io.ktor.client.statement.bodyAsText
13+
import io.ktor.http.ContentType
14+
import io.ktor.http.HttpHeaders
15+
import io.ktor.http.isSuccess
16+
import io.ktor.http.contentType
17+
import kotlinx.coroutines.Dispatchers
18+
import kotlinx.coroutines.withContext
19+
import kotlinx.serialization.json.Json
20+
import kotlinx.serialization.json.JsonPrimitive
21+
import kotlinx.serialization.json.JsonElement
22+
import kotlinx.serialization.json.buildJsonObject
23+
import kotlinx.serialization.json.contentOrNull
24+
import kotlinx.serialization.json.jsonArray
25+
import kotlinx.serialization.json.jsonObject
26+
import kotlinx.serialization.json.jsonPrimitive
27+
28+
data class ExternalWebSearchResponse(
29+
val provider: ExternalWebSearchProvider,
30+
val results: List<WebSearchResult>,
31+
)
32+
33+
object ExternalWebSearchService {
34+
private const val TAG = "ExternalWebSearchService"
35+
private const val DEFAULT_TIMEOUT_MS = 30_000L
36+
private const val DEFAULT_RESULT_LIMIT = 5
37+
38+
private val json = Json { ignoreUnknownKeys = true }
39+
40+
private val client by lazy {
41+
HttpClient(OkHttp) {
42+
install(HttpTimeout) {
43+
requestTimeoutMillis = DEFAULT_TIMEOUT_MS
44+
connectTimeoutMillis = DEFAULT_TIMEOUT_MS
45+
socketTimeoutMillis = DEFAULT_TIMEOUT_MS
46+
}
47+
install(HttpRedirect)
48+
}
49+
}
50+
51+
suspend fun search(
52+
provider: ExternalWebSearchProvider,
53+
apiKey: String,
54+
query: String,
55+
): Result<ExternalWebSearchResponse> = withContext(Dispatchers.IO) {
56+
runCatching {
57+
val trimmedQuery = query.trim()
58+
require(trimmedQuery.isNotBlank()) { "搜索内容不能为空" }
59+
require(apiKey.isNotBlank()) { "${provider.displayName} API Key 未配置" }
60+
61+
val response = client.post(provider.baseUrl) {
62+
contentType(ContentType.Application.Json)
63+
when (provider) {
64+
ExternalWebSearchProvider.TAVILY -> {
65+
header(HttpHeaders.Authorization, "Bearer $apiKey")
66+
setBody(
67+
buildJsonObject {
68+
put("query", JsonPrimitive(trimmedQuery))
69+
put("search_depth", JsonPrimitive("basic"))
70+
put("max_results", JsonPrimitive(DEFAULT_RESULT_LIMIT))
71+
put("include_answer", JsonPrimitive(false))
72+
put("include_images", JsonPrimitive(false))
73+
}.toString()
74+
)
75+
}
76+
77+
ExternalWebSearchProvider.EXA -> {
78+
header("x-api-key", apiKey)
79+
setBody(
80+
buildJsonObject {
81+
put("query", JsonPrimitive(trimmedQuery))
82+
put("type", JsonPrimitive("auto"))
83+
put("numResults", JsonPrimitive(DEFAULT_RESULT_LIMIT))
84+
put(
85+
"contents",
86+
buildJsonObject {
87+
put(
88+
"highlights",
89+
buildJsonObject {
90+
put("maxCharacters", JsonPrimitive(1200))
91+
}
92+
)
93+
}
94+
)
95+
}.toString()
96+
)
97+
}
98+
99+
ExternalWebSearchProvider.BOCHA -> {
100+
header(HttpHeaders.Authorization, "Bearer $apiKey")
101+
setBody(
102+
buildJsonObject {
103+
put("query", JsonPrimitive(trimmedQuery))
104+
put("freshness", JsonPrimitive("oneYear"))
105+
put("summary", JsonPrimitive(true))
106+
put("count", JsonPrimitive(DEFAULT_RESULT_LIMIT))
107+
}.toString()
108+
)
109+
}
110+
111+
ExternalWebSearchProvider.SERPAPI -> {
112+
setBody(
113+
buildJsonObject {
114+
put("q", JsonPrimitive(trimmedQuery))
115+
put("api_key", JsonPrimitive(apiKey))
116+
put("engine", JsonPrimitive("google"))
117+
put("num", JsonPrimitive(DEFAULT_RESULT_LIMIT))
118+
put("hl", JsonPrimitive("zh-cn"))
119+
put("google_domain", JsonPrimitive("google.com"))
120+
}.toString()
121+
)
122+
}
123+
}
124+
}
125+
126+
if (!response.status.isSuccess()) {
127+
throw IllegalStateException("${provider.displayName} 搜索失败: HTTP ${response.status.value}")
128+
}
129+
130+
val body = response.bodyAsText()
131+
val results = parseResults(provider, body)
132+
if (results.isEmpty()) {
133+
Log.w(TAG, "provider=${provider.providerId} empty results body=${body.take(1000)}")
134+
throw IllegalStateException("${provider.displayName} 未返回可用搜索结果")
135+
}
136+
ExternalWebSearchResponse(provider = provider, results = results)
137+
}.onFailure {
138+
Log.e(TAG, "搜索失败 provider=${provider.providerId}", it)
139+
}
140+
}
141+
142+
private fun parseResults(
143+
provider: ExternalWebSearchProvider,
144+
body: String,
145+
): List<WebSearchResult> {
146+
val root = json.parseToJsonElement(body).jsonObject
147+
return when (provider) {
148+
ExternalWebSearchProvider.TAVILY -> root["results"]?.jsonArray.orEmpty().mapIndexedNotNull { index, element ->
149+
val item = element.jsonObject
150+
val href = item["url"]?.jsonPrimitive?.contentOrNull.orEmpty()
151+
if (href.isBlank()) return@mapIndexedNotNull null
152+
WebSearchResult(
153+
index = index,
154+
title = item["title"]?.jsonPrimitive?.contentOrNull.orEmpty().ifBlank { href },
155+
href = href,
156+
snippet = item["content"]?.jsonPrimitive?.contentOrNull.orEmpty(),
157+
)
158+
}
159+
160+
ExternalWebSearchProvider.EXA -> root["results"]?.jsonArray.orEmpty().mapIndexedNotNull { index, element ->
161+
val item = element.asJsonObjectOrNull() ?: return@mapIndexedNotNull null
162+
val href = item["url"]?.jsonPrimitive?.contentOrNull.orEmpty()
163+
if (href.isBlank()) return@mapIndexedNotNull null
164+
WebSearchResult(
165+
index = index,
166+
title = item["title"]?.jsonPrimitive?.contentOrNull.orEmpty().ifBlank { href },
167+
href = href,
168+
snippet = item["text"]?.jsonPrimitive?.contentOrNull
169+
?: extractExaHighlightText(item["highlights"])
170+
?: item["summary"]?.jsonPrimitive?.contentOrNull
171+
?: "",
172+
)
173+
}
174+
175+
ExternalWebSearchProvider.BOCHA -> root["data"]?.jsonObject?.get("webPages")?.jsonObject
176+
?.get("value")?.jsonArray.orEmpty().mapIndexedNotNull { index, element ->
177+
val item = element.jsonObject
178+
val href = item["url"]?.jsonPrimitive?.contentOrNull.orEmpty()
179+
if (href.isBlank()) return@mapIndexedNotNull null
180+
WebSearchResult(
181+
index = index,
182+
title = item["name"]?.jsonPrimitive?.contentOrNull.orEmpty().ifBlank { href },
183+
href = href,
184+
snippet = item["snippet"]?.jsonPrimitive?.contentOrNull.orEmpty(),
185+
)
186+
}
187+
188+
ExternalWebSearchProvider.SERPAPI -> root["organic_results"]?.jsonArray.orEmpty().mapIndexedNotNull { index, element ->
189+
val item = element.jsonObject
190+
val href = item["link"]?.jsonPrimitive?.contentOrNull.orEmpty()
191+
if (href.isBlank()) return@mapIndexedNotNull null
192+
WebSearchResult(
193+
index = index,
194+
title = item["title"]?.jsonPrimitive?.contentOrNull.orEmpty().ifBlank { href },
195+
href = href,
196+
snippet = item["snippet"]?.jsonPrimitive?.contentOrNull.orEmpty(),
197+
)
198+
}
199+
}
200+
}
201+
202+
private fun JsonElement.asJsonObjectOrNull() = runCatching { jsonObject }.getOrNull()
203+
204+
private fun extractExaHighlightText(highlightsElement: JsonElement?): String? {
205+
val highlights = runCatching { highlightsElement?.jsonArray }.getOrNull().orEmpty()
206+
if (highlights.isEmpty()) return null
207+
208+
return highlights.firstNotNullOfOrNull { highlight ->
209+
highlight.jsonPrimitive.contentOrNull
210+
?: highlight.asJsonObjectOrNull()
211+
?.get("text")?.jsonPrimitive?.contentOrNull
212+
}
213+
}
214+
}

‎app1/app/src/main/java/com/android/everytalk/data/network/WebSearchSupport.kt‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ package com.android.everytalk.data.network
33
import com.android.everytalk.data.DataClass.ApiConfig
44

55
object WebSearchSupport {
6+
data class WebSearchRouting(
7+
val useNativeWebSearch: Boolean,
8+
val externalProvider: ExternalWebSearchProvider? = null,
9+
)
10+
611
fun supportsNativeWebSearch(config: ApiConfig?): Boolean {
712
if (config == null) return false
813
return isGeminiNativeSearch(config) || isQwenNativeSearch(config)
@@ -22,4 +27,25 @@ object WebSearchSupport {
2227
fun shouldEnableQwenNativeSearch(config: ApiConfig?, isWebSearchEnabled: Boolean): Boolean {
2328
return isWebSearchEnabled && isQwenNativeSearch(config)
2429
}
30+
31+
fun resolveWebSearchRouting(
32+
config: ApiConfig?,
33+
isWebSearchEnabled: Boolean,
34+
selectedExternalProvider: ExternalWebSearchProvider?,
35+
selectedExternalProviderApiKey: String,
36+
): WebSearchRouting {
37+
if (!isWebSearchEnabled) {
38+
return WebSearchRouting(useNativeWebSearch = false)
39+
}
40+
if (supportsNativeWebSearch(config)) {
41+
return WebSearchRouting(useNativeWebSearch = true)
42+
}
43+
if (selectedExternalProvider != null && selectedExternalProviderApiKey.isNotBlank()) {
44+
return WebSearchRouting(
45+
useNativeWebSearch = false,
46+
externalProvider = selectedExternalProvider,
47+
)
48+
}
49+
return WebSearchRouting(useNativeWebSearch = false)
50+
}
2551
}

0 commit comments

Comments
 (0)