feat: 新增搜索历史与AI智能体搜索功能

- 新增搜索历史记录功能,使用 SharedPreferences + JSON 进行本地存储。
- 搜索页在无搜索结果时展示历史记录,支持点击搜索、长按删除单个记录和清空全部历史。
- 新增 "AI" 搜索标签页,用于根据关键字搜索智能体(Agent)。
- 搜索页离开时自动重置搜索状态和文本,返回后显示历史记录。
- 优化了搜索逻辑,在输入文本为空时自动隐藏搜索结果并显示历史记录。
This commit is contained in:
2025-11-11 00:24:09 +08:00
parent 2f41c61b7e
commit 803b14139f
5 changed files with 445 additions and 62 deletions

View File

@@ -108,6 +108,15 @@ interface AgentService {
authorId: Int? = null
): ListContainer<AgentEntity>?
/**
* 根据标题关键字搜索智能体
*/
suspend fun searchAgentByTitle(
pageNumber: Int,
pageSize: Int = 20,
title: String
): ListContainer<AgentEntity>?
}

View File

@@ -90,6 +90,35 @@ class AgentPagingSource(
}
/**
* 智能体搜索分页加载器(按标题关键字)
*/
class AgentSearchPagingSource(
private val agentRemoteDataSource: AgentRemoteDataSource,
private val keyword: String,
) : PagingSource<Int, AgentEntity>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, AgentEntity> {
return try {
val currentPage = params.key ?: 1
val agents = agentRemoteDataSource.searchAgentByTitle(
pageNumber = currentPage,
title = keyword
)
LoadResult.Page(
data = agents?.list ?: listOf(),
prevKey = if (currentPage == 1) null else currentPage - 1,
nextKey = if (agents?.list?.isNotEmpty() == true) currentPage + 1 else null
)
} catch (exception: IOException) {
LoadResult.Error(exception)
}
}
override fun getRefreshKey(state: PagingState<Int, AgentEntity>): Int? {
return state.anchorPosition
}
}
class AgentRemoteDataSource(
private val agentService: AgentService,
@@ -103,6 +132,16 @@ class AgentRemoteDataSource(
authorId = authorId
)
}
suspend fun searchAgentByTitle(
pageNumber: Int,
title: String
): ListContainer<AgentEntity>? {
return agentService.searchAgentByTitle(
pageNumber = pageNumber,
title = title
)
}
}
class AgentServiceImpl() : AgentService {
@@ -118,6 +157,17 @@ class AgentServiceImpl() : AgentService {
authorId = authorId
)
}
override suspend fun searchAgentByTitle(
pageNumber: Int,
pageSize: Int,
title: String
): ListContainer<AgentEntity>? {
return agentBackend.searchAgentByTitle(
pageNumber = pageNumber,
title = title
)
}
}
class AgentBackend {
@@ -175,6 +225,27 @@ class AgentBackend {
)
}
}
suspend fun searchAgentByTitle(
pageNumber: Int,
title: String
): ListContainer<AgentEntity>? {
val resp = ApiClient.api.getAgent(
page = pageNumber,
pageSize = DataBatchSize,
withWorkflow = 1,
title = title
)
val body = resp.body() ?: return null
val dataContainer = body as DataContainer<ListContainer<Agent>>
val listContainer = dataContainer.data
return ListContainer(
total = listContainer.total,
page = pageNumber,
pageSize = DataBatchSize,
list = listContainer.list.map { it.toAgentEntity() }
)
}
}
data class AgentEntity(

View File

@@ -1,6 +1,7 @@
package com.aiosman.ravenow.ui.index.tabs.search
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -10,6 +11,8 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -32,14 +35,13 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Icon
import androidx.compose.material.Tab
import androidx.compose.material.TabRow
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
@@ -85,10 +87,8 @@ fun SearchScreen() {
val AppColors = LocalAppTheme.current
val context = LocalContext.current
val model = SearchViewModel
val categories = listOf(context.getString(R.string.moment), context.getString(R.string.users))
val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState(pageCount = { categories.size })
val selectedTabIndex = remember { derivedStateOf { pagerState.currentPage } }
val pagerState = rememberPagerState(pageCount = { 3 })
val keyboardController = LocalSoftwareKeyboardController.current
val systemUiController = rememberSystemUiController()
val statusBarPaddingValues = WindowInsets.systemBars.asPaddingValues()
@@ -100,11 +100,19 @@ fun SearchScreen() {
systemUiController.setStatusBarColor(Color.Transparent, darkIcons = !AppState.darkMode)
}
LaunchedEffect(Unit) {
model.ensureInit(context)
if (model.requestFocus) {
focusRequester.requestFocus()
model.requestFocus = false
}
}
DisposableEffect(Unit) {
onDispose {
// 离开页面时重置搜索状态与文本
model.searchText = ""
model.ResetModel()
}
}
Column(
modifier = Modifier
@@ -130,7 +138,7 @@ fun SearchScreen() {
.weight(1f),
text = model.searchText,
onTextChange = {
model.searchText = it
model.onTextChanged(it)
},
onSearch = {
model.search()
@@ -144,13 +152,33 @@ fun SearchScreen() {
stringResource(R.string.cancel),
fontSize = 16.sp,
modifier = Modifier.noRippleClickable {
// 退出时也重置,确保返回后显示历史而不是上次结果
model.searchText = ""
model.ResetModel()
navController.navigateUp()
},
color = AppColors.text
)
}
// 历史搜索(当不展示结果时)
if (!model.showResult) {
HistorySection(
onClear = { model.clearHistory() },
onClick = { term ->
coroutineScope.launch {
keyboardController?.hide()
model.onTextChanged(term)
pagerState.scrollToPage(0)
model.search()
}
},
onRemove = { term -> model.removeHistoryItem(term) }
)
}
// 添加user、dynamic和ai标签页
if (model.showResult) {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -188,31 +216,17 @@ fun SearchScreen() {
text = stringResource(R.string.chat_ai),
isSelected = pagerState.currentPage == 2,
onClick = {
// TODO: 实现点击逻辑
coroutineScope.launch {
pagerState.animateScrollToPage(2)
}
}
)
}
}
}
}
if (model.showResult) {
TabRow(
selectedTabIndex = selectedTabIndex.value,
backgroundColor = AppColors.background,
contentColor = AppColors.text,
) {
categories.forEachIndexed { index, category ->
Tab(
selected = selectedTabIndex.value == index,
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(index)
}
},
text = { Text(category, color = AppColors.text) }
)
}
}
SearchPager(
pagerState = pagerState
)
@@ -220,6 +234,67 @@ fun SearchScreen() {
}
}
@OptIn(ExperimentalLayoutApi::class, ExperimentalFoundationApi::class)
@Composable
fun HistorySection(
onClear: () -> Unit,
onClick: (String) -> Unit,
onRemove: (String) -> Unit
) {
val AppColors = LocalAppTheme.current
val model = SearchViewModel
val items = model.historyFlow.collectAsState(initial = emptyList()).value
if (items.isEmpty()) return
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "历史搜索",
color = AppColors.text,
fontSize = 16.sp,
fontWeight = FontWeight.W600
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = "清空",
color = AppColors.secondaryText,
fontSize = 14.sp,
modifier = Modifier.noRippleClickable { onClear() }
)
}
Spacer(modifier = Modifier.height(12.dp))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items.forEach { term ->
Box(
modifier = Modifier
.background(AppColors.inputBackground, RoundedCornerShape(16.dp))
.combinedClickable(
onClick = { onClick(term) },
onLongClick = { onRemove(term) }
)
.padding(horizontal = 12.dp, vertical = 8.dp)
) {
Text(
text = term,
color = AppColors.text,
fontSize = 14.sp
)
}
}
}
}
}
@Composable
fun SearchInput(
modifier: Modifier = Modifier,
@@ -300,6 +375,7 @@ fun SearchPager(
when (page) {
0 -> MomentResultTab()
1 -> UserResultTab()
2 -> AiResultTab()
}
}
}
@@ -566,6 +642,118 @@ fun UserItem(
}
}
@Composable
fun AiResultTab() {
val AppColors = LocalAppTheme.current
val context = LocalContext.current
val model = SearchViewModel
val agents = model.agentsFlow.collectAsLazyPagingItems()
Box(
modifier = Modifier
.fillMaxSize()
.background(AppColors.background)
) {
if (agents.itemCount == 0 && model.showResult) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
val isNetworkAvailable = NetworkUtils.isNetworkAvailable(context)
if (isNetworkAvailable) {
androidx.compose.foundation.Image(
painter = painterResource(
id = if (AppState.darkMode) R.mipmap.syss_yh_qs_as_img
else R.mipmap.invalid_name_1
),
contentDescription = "No Result",
modifier = Modifier.size(140.dp)
)
Text(
text = "咦,什么都没找到...",
color = LocalAppTheme.current.text,
fontSize = 16.sp,
fontWeight = FontWeight.W600
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "换个关键词试试吧,也许会有新发现!",
color = LocalAppTheme.current.secondaryText,
fontSize = 14.sp,
fontWeight = FontWeight.W400
)
} else {
androidx.compose.foundation.Image(
painter = painterResource(id = R.mipmap.invalid_name_10),
contentDescription = "network error",
modifier = Modifier.size(140.dp)
)
Text(
text = stringResource(R.string.friend_chat_no_network_title),
color = LocalAppTheme.current.text,
fontSize = 16.sp,
fontWeight = FontWeight.W600
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.friend_chat_no_network_subtitle),
color = LocalAppTheme.current.secondaryText,
fontSize = 14.sp,
fontWeight = FontWeight.W400
)
Spacer(modifier = Modifier.size(16.dp))
ReloadButton(
onClick = {
SearchViewModel.ResetModel()
SearchViewModel.search()
}
)
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(agents.itemCount) { idx ->
val agent = agents[idx] ?: return@items
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
CustomAsyncImage(
context,
imageUrl = agent.avatar,
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(24.dp)),
contentDescription = null
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = agent.title,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = AppColors.text
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = agent.desc,
fontSize = 14.sp,
color = AppColors.secondaryText,
maxLines = 1
)
}
}
}
}
}
}
}
@Composable
fun ReloadButton(
onClick: () -> Unit
) {

View File

@@ -1,5 +1,6 @@
package com.aiosman.ravenow.ui.index.tabs.search
import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -18,6 +19,11 @@ import com.aiosman.ravenow.entity.MomentEntity
import com.aiosman.ravenow.entity.MomentPagingSource
import com.aiosman.ravenow.entity.MomentRemoteDataSource
import com.aiosman.ravenow.entity.MomentServiceImpl
import com.aiosman.ravenow.entity.AgentEntity
import com.aiosman.ravenow.entity.AgentRemoteDataSource
import com.aiosman.ravenow.entity.AgentSearchPagingSource
import com.aiosman.ravenow.entity.AgentServiceImpl
import com.aiosman.ravenow.utils.SearchHistoryStore
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
@@ -32,12 +38,37 @@ object SearchViewModel : ViewModel() {
private val userService = UserServiceImpl()
private val _usersFlow = MutableStateFlow<PagingData<AccountProfileEntity>>(PagingData.empty())
val usersFlow = _usersFlow.asStateFlow()
private val _agentsFlow = MutableStateFlow<PagingData<AgentEntity>>(PagingData.empty())
val agentsFlow = _agentsFlow.asStateFlow()
private lateinit var historyStore: SearchHistoryStore
private val _historyFlow = MutableStateFlow<List<String>>(emptyList())
val historyFlow = _historyFlow.asStateFlow()
var showResult by mutableStateOf(false)
var requestFocus by mutableStateOf(false)
fun ensureInit(context: Context) {
if (!::historyStore.isInitialized) {
historyStore = SearchHistoryStore(context.applicationContext)
_historyFlow.value = historyStore.getHistory()
}
}
fun onTextChanged(newText: String) {
searchText = newText
if (newText.isBlank()) {
showResult = false
}
}
fun search() {
if (searchText.isEmpty()) {
return
}
// 记录历史
if (::historyStore.isInitialized) {
historyStore.add(searchText)
_historyFlow.value = historyStore.getHistory()
}
viewModelScope.launch {
Pager(
config = PagingConfig(pageSize = 5, enablePlaceholders = false),
@@ -64,9 +95,34 @@ object SearchViewModel : ViewModel() {
_usersFlow.value = it
}
}
viewModelScope.launch {
Pager(
config = PagingConfig(pageSize = 5, enablePlaceholders = false),
pagingSourceFactory = {
AgentSearchPagingSource(
AgentRemoteDataSource(AgentServiceImpl()),
keyword = searchText
)
}
).flow.cachedIn(viewModelScope).collectLatest {
_agentsFlow.value = it
}
}
showResult = true
}
fun removeHistoryItem(term: String) {
if (!::historyStore.isInitialized) return
historyStore.remove(term)
_historyFlow.value = historyStore.getHistory()
}
fun clearHistory() {
if (!::historyStore.isInitialized) return
historyStore.clear()
_historyFlow.value = emptyList()
}
suspend fun followUser(id:Int){
userService.followUser(id.toString())
val currentPagingData = _usersFlow.value
@@ -96,6 +152,7 @@ object SearchViewModel : ViewModel() {
fun ResetModel(){
_momentsFlow.value = PagingData.empty()
_usersFlow.value = PagingData.empty()
_agentsFlow.value = PagingData.empty()
showResult = false
}

View File

@@ -0,0 +1,58 @@
package com.aiosman.ravenow.utils
import android.content.Context
import android.content.SharedPreferences
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
/**
* 搜索历史存储SharedPreferences + JSON
* - 最多保留 maxSize 条
* - 新记录放首位,去重(忽略前后空格)
*/
class SearchHistoryStore(context: Context) {
private val prefs: SharedPreferences =
context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
private val gson = Gson()
fun getHistory(): List<String> {
val json = prefs.getString(KEY_HISTORY, "[]") ?: "[]"
return runCatching {
val type = object : TypeToken<List<String>>() {}.type
gson.fromJson<List<String>>(json, type) ?: emptyList()
}.getOrDefault(emptyList())
}
fun add(term: String) {
val normalized = term.trim()
if (normalized.isEmpty()) return
val current = getHistory().toMutableList()
current.removeAll { it.equals(normalized, ignoreCase = true) }
current.add(0, normalized)
while (current.size > MAX_SIZE) current.removeLast()
save(current)
}
fun remove(term: String) {
val normalized = term.trim()
val current = getHistory().toMutableList()
current.removeAll { it.equals(normalized, ignoreCase = true) }
save(current)
}
fun clear() {
save(emptyList())
}
private fun save(list: List<String>) {
prefs.edit().putString(KEY_HISTORY, gson.toJson(list)).apply()
}
companion object {
private const val PREF_NAME = "search_history_pref"
private const val KEY_HISTORY = "search_history_v1"
private const val MAX_SIZE = 10
}
}