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 authorId: Int? = null
): ListContainer<AgentEntity>? ): 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( class AgentRemoteDataSource(
private val agentService: AgentService, private val agentService: AgentService,
@@ -103,6 +132,16 @@ class AgentRemoteDataSource(
authorId = authorId authorId = authorId
) )
} }
suspend fun searchAgentByTitle(
pageNumber: Int,
title: String
): ListContainer<AgentEntity>? {
return agentService.searchAgentByTitle(
pageNumber = pageNumber,
title = title
)
}
} }
class AgentServiceImpl() : AgentService { class AgentServiceImpl() : AgentService {
@@ -118,6 +157,17 @@ class AgentServiceImpl() : AgentService {
authorId = authorId authorId = authorId
) )
} }
override suspend fun searchAgentByTitle(
pageNumber: Int,
pageSize: Int,
title: String
): ListContainer<AgentEntity>? {
return agentBackend.searchAgentByTitle(
pageNumber = pageNumber,
title = title
)
}
} }
class AgentBackend { 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( data class AgentEntity(

View File

@@ -1,6 +1,7 @@
package com.aiosman.ravenow.ui.index.tabs.search package com.aiosman.ravenow.ui.index.tabs.search
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.Button
import androidx.compose.material.ButtonDefaults import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Icon import androidx.compose.material.Icon
import androidx.compose.material.Tab
import androidx.compose.material.TabRow
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect 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.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -85,10 +87,8 @@ fun SearchScreen() {
val AppColors = LocalAppTheme.current val AppColors = LocalAppTheme.current
val context = LocalContext.current val context = LocalContext.current
val model = SearchViewModel val model = SearchViewModel
val categories = listOf(context.getString(R.string.moment), context.getString(R.string.users))
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState(pageCount = { categories.size }) val pagerState = rememberPagerState(pageCount = { 3 })
val selectedTabIndex = remember { derivedStateOf { pagerState.currentPage } }
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
val systemUiController = rememberSystemUiController() val systemUiController = rememberSystemUiController()
val statusBarPaddingValues = WindowInsets.systemBars.asPaddingValues() val statusBarPaddingValues = WindowInsets.systemBars.asPaddingValues()
@@ -100,11 +100,19 @@ fun SearchScreen() {
systemUiController.setStatusBarColor(Color.Transparent, darkIcons = !AppState.darkMode) systemUiController.setStatusBarColor(Color.Transparent, darkIcons = !AppState.darkMode)
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
model.ensureInit(context)
if (model.requestFocus) { if (model.requestFocus) {
focusRequester.requestFocus() focusRequester.requestFocus()
model.requestFocus = false model.requestFocus = false
} }
} }
DisposableEffect(Unit) {
onDispose {
// 离开页面时重置搜索状态与文本
model.searchText = ""
model.ResetModel()
}
}
Column( Column(
modifier = Modifier modifier = Modifier
@@ -130,7 +138,7 @@ fun SearchScreen() {
.weight(1f), .weight(1f),
text = model.searchText, text = model.searchText,
onTextChange = { onTextChange = {
model.searchText = it model.onTextChanged(it)
}, },
onSearch = { onSearch = {
model.search() model.search()
@@ -144,75 +152,81 @@ fun SearchScreen() {
stringResource(R.string.cancel), stringResource(R.string.cancel),
fontSize = 16.sp, fontSize = 16.sp,
modifier = Modifier.noRippleClickable { modifier = Modifier.noRippleClickable {
// 退出时也重置,确保返回后显示历史而不是上次结果
model.searchText = ""
model.ResetModel()
navController.navigateUp() navController.navigateUp()
}, },
color = AppColors.text 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标签页 // 添加user、dynamic和ai标签页
Row( if (model.showResult) {
modifier = Modifier Row(
.fillMaxWidth() modifier = Modifier
.wrapContentHeight() .fillMaxWidth()
.padding(start = 16.dp, top = 16.dp), .wrapContentHeight()
horizontalArrangement = Arrangement.Start, .padding(start = 16.dp, top = 16.dp),
verticalAlignment = Alignment.Bottom horizontalArrangement = Arrangement.Start,
) { verticalAlignment = Alignment.Bottom
Box { ) {
TabItem( Box {
text = stringResource(R.string.moment), TabItem(
isSelected = pagerState.currentPage == 0, text = stringResource(R.string.moment),
onClick = { isSelected = pagerState.currentPage == 0,
coroutineScope.launch { onClick = {
pagerState.animateScrollToPage(0) coroutineScope.launch {
pagerState.animateScrollToPage(0)
}
} }
} )
) }
} TabSpacer()
TabSpacer() Box {
Box { TabItem(
TabItem( text = stringResource(R.string.users),
text = stringResource(R.string.users), isSelected = pagerState.currentPage == 1,
isSelected = pagerState.currentPage == 1, onClick = {
onClick = { coroutineScope.launch {
coroutineScope.launch { pagerState.animateScrollToPage(1)
pagerState.animateScrollToPage(1) }
} }
} )
) }
} TabSpacer()
TabSpacer() Box {
Box { TabItem(
TabItem( text = stringResource(R.string.chat_ai),
text = stringResource(R.string.chat_ai), isSelected = pagerState.currentPage == 2,
isSelected = pagerState.currentPage == 2, onClick = {
onClick = { coroutineScope.launch {
// TODO: 实现点击逻辑 pagerState.animateScrollToPage(2)
} }
) }
)
}
} }
} }
} }
if (model.showResult) { 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( SearchPager(
pagerState = pagerState 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 @Composable
fun SearchInput( fun SearchInput(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -300,6 +375,7 @@ fun SearchPager(
when (page) { when (page) {
0 -> MomentResultTab() 0 -> MomentResultTab()
1 -> UserResultTab() 1 -> UserResultTab()
2 -> AiResultTab()
} }
} }
} }
@@ -566,6 +642,118 @@ fun UserItem(
} }
} }
@Composable @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( fun ReloadButton(
onClick: () -> Unit onClick: () -> Unit
) { ) {

View File

@@ -1,5 +1,6 @@
package com.aiosman.ravenow.ui.index.tabs.search package com.aiosman.ravenow.ui.index.tabs.search
import android.content.Context
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue 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.MomentPagingSource
import com.aiosman.ravenow.entity.MomentRemoteDataSource import com.aiosman.ravenow.entity.MomentRemoteDataSource
import com.aiosman.ravenow.entity.MomentServiceImpl 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.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
@@ -32,12 +38,37 @@ object SearchViewModel : ViewModel() {
private val userService = UserServiceImpl() private val userService = UserServiceImpl()
private val _usersFlow = MutableStateFlow<PagingData<AccountProfileEntity>>(PagingData.empty()) private val _usersFlow = MutableStateFlow<PagingData<AccountProfileEntity>>(PagingData.empty())
val usersFlow = _usersFlow.asStateFlow() 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 showResult by mutableStateOf(false)
var requestFocus 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() { fun search() {
if (searchText.isEmpty()) { if (searchText.isEmpty()) {
return return
} }
// 记录历史
if (::historyStore.isInitialized) {
historyStore.add(searchText)
_historyFlow.value = historyStore.getHistory()
}
viewModelScope.launch { viewModelScope.launch {
Pager( Pager(
config = PagingConfig(pageSize = 5, enablePlaceholders = false), config = PagingConfig(pageSize = 5, enablePlaceholders = false),
@@ -64,9 +95,34 @@ object SearchViewModel : ViewModel() {
_usersFlow.value = it _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 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){ suspend fun followUser(id:Int){
userService.followUser(id.toString()) userService.followUser(id.toString())
val currentPagingData = _usersFlow.value val currentPagingData = _usersFlow.value
@@ -96,6 +152,7 @@ object SearchViewModel : ViewModel() {
fun ResetModel(){ fun ResetModel(){
_momentsFlow.value = PagingData.empty() _momentsFlow.value = PagingData.empty()
_usersFlow.value = PagingData.empty() _usersFlow.value = PagingData.empty()
_agentsFlow.value = PagingData.empty()
showResult = false 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
}
}