feat: 新增AI智能体编辑功能和群聊搜索

- **AI智能体编辑**
  - 新增AI智能体编辑页面(`AiPromptEditScreen`),允许创建者修改智能体的头像、名称、描述和公开/私有状态。
  - 在AI个人主页为创建者添加入口,可进入编辑页面。
  - 新增`updatePrompt`和`getPromptDetail`接口,用于获取和更新智能体信息。
  - 完善头像裁剪逻辑,使其同时支持创建和编辑两种模式。

- **群聊搜索**
  - 在全局搜索中新增“群聊”分类,用户可以搜索公开群聊。

- **优化**
  - AI个人主页(`AiProfileV3`)数据加载逻辑优化,以正确获取创建者信息。
  - 修复了当群聊头像为空时,无法正确显示默认头像的问题。
This commit is contained in:
2025-11-12 14:19:26 +08:00
parent 6ba3e5c4b3
commit 4135583758
15 changed files with 1228 additions and 15 deletions

View File

@@ -69,6 +69,10 @@ data class AccountProfile(
val aiRoleAvatar: String? = null,
val aiRoleAvatarMedium: String? = null,
val aiRoleAvatarLarge: String? = null,
// 创建者信息仅AI账号有
@SerializedName("creatorProfile")
val creatorProfile: com.aiosman.ravenow.data.CreatorProfile? = null,
) {
/**
* 转换为Entity
@@ -103,7 +107,8 @@ data class AccountProfile(
},
aiRoleAvatarLarge = aiRoleAvatarLarge?.let {
if (it.isNotEmpty()) "${ApiClient.BASE_SERVER}$it" else null
}
},
creatorProfile = creatorProfile?.toCreatorProfileEntity()
)
}
}

View File

@@ -331,3 +331,21 @@ class RecommendationServiceImpl : RecommendationService {
}
}
/**
* CreatorProfile 扩展函数,转换为 CreatorProfileEntity
*/
fun CreatorProfile.toCreatorProfileEntity(): com.aiosman.ravenow.entity.CreatorProfileEntity {
return com.aiosman.ravenow.entity.CreatorProfileEntity(
id = id,
username = username,
nickname = nickname,
avatar = avatar?.let {
if (it.isNotEmpty()) "${ApiClient.BASE_SERVER}$it" else null
},
bio = bio,
trtcUserId = trtcUserId,
chatAIId = chatAIId,
aiAccount = aiAccount
)
}

View File

@@ -1500,6 +1500,35 @@ interface RaveNowAPI {
@Query("pageSize") pageSize: Int? = null
): Response<ListContainer<Agent>>
/**
* 获取Prompt详情支持ID或OpenId
* @param promptId Prompt ID或OpenIdUUID格式
*/
@GET("outside/prompt/{promptId}")
suspend fun getPromptDetail(
@Path("promptId") promptId: String
): Response<DataContainer<Agent>>
/**
* 更新Prompt支持ID或OpenId
* @param promptId Prompt ID或OpenIdUUID格式
* @param avatar 头像文件(可选)
* @param title 标题(可选)
* @param desc 描述(可选)
* @param value 内容(可选)
* @param isPublic 是否公开(可选)
*/
@Multipart
@PATCH("outside/prompt/{promptId}")
suspend fun updatePrompt(
@Path("promptId") promptId: String,
@Part avatar: MultipartBody.Part?,
@Part("title") title: RequestBody?,
@Part("desc") desc: RequestBody?,
@Part("value") value: RequestBody?,
@Part("public") isPublic: RequestBody?,
): Response<DataContainer<Agent>>
// ========== Agent Rule API ==========
/**

View File

@@ -72,6 +72,9 @@ data class AccountProfileEntity(
val aiRoleAvatar: String? = null,
val aiRoleAvatarMedium: String? = null,
val aiRoleAvatarLarge: String? = null,
// 创建者信息仅AI账号有
val creatorProfile: CreatorProfileEntity? = null,
)
/**
@@ -115,6 +118,28 @@ data class NoticeUserEntity(
val avatar: String,
)
/**
* 创建者信息
*/
data class CreatorProfileEntity(
// 用户ID
val id: Long,
// 用户名
val username: String? = null,
// 昵称
val nickname: String,
// 头像
val avatar: String? = null,
// 个人简介
val bio: String? = null,
// trtcUserId
val trtcUserId: String? = null,
// chatAIId
val chatAIId: String? = null,
// 是否为AI账号
val aiAccount: Boolean = false,
)
/**
* 用户点赞消息分页数据加载器
*/

View File

@@ -1,8 +1,12 @@
package com.aiosman.ravenow.entity
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.aiosman.ravenow.data.ListContainer
import com.aiosman.ravenow.data.Room
import com.aiosman.ravenow.data.ServiceException
import com.aiosman.ravenow.data.api.ApiClient
import java.io.IOException
/**
* 群聊房间
@@ -253,3 +257,58 @@ class RoomLoader : DataLoader<AgentEntity,AgentLoaderExtraArgs>() {
}
/**
* 房间远程数据源
*/
class RoomRemoteDataSource {
suspend fun searchRooms(
pageNumber: Int,
pageSize: Int = 20,
search: String
): ListContainer<RoomEntity>? {
val resp = ApiClient.api.getRooms(
page = pageNumber,
pageSize = pageSize,
search = search,
roomType = "public" // 搜索时只显示公有房间
)
val body = resp.body() ?: return null
return ListContainer(
total = body.total,
page = pageNumber,
pageSize = pageSize,
list = body.list.map { it.toRoomtEntity() }
)
}
}
/**
* 房间搜索分页加载器
*/
class RoomSearchPagingSource(
private val roomRemoteDataSource: RoomRemoteDataSource,
private val keyword: String,
) : PagingSource<Int, RoomEntity>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, RoomEntity> {
return try {
val currentPage = params.key ?: 1
val rooms = roomRemoteDataSource.searchRooms(
pageNumber = currentPage,
pageSize = params.loadSize,
search = keyword
)
LoadResult.Page(
data = rooms?.list ?: listOf(),
prevKey = if (currentPage == 1) null else currentPage - 1,
nextKey = if (rooms?.list?.isNotEmpty() == true) currentPage + 1 else null
)
} catch (exception: IOException) {
LoadResult.Error(exception)
}
}
override fun getRefreshKey(state: PagingState<Int, RoomEntity>): Int? {
return state.anchorPosition
}
}

View File

@@ -41,6 +41,7 @@ import com.aiosman.ravenow.ui.account.ResetPasswordScreen
import com.aiosman.ravenow.ui.account.ZodiacSelectScreen
import com.aiosman.ravenow.ui.agent.AddAgentScreen
import com.aiosman.ravenow.ui.agent.AgentImageCropScreen
import com.aiosman.ravenow.ui.agent.AiPromptEditScreen
import com.aiosman.ravenow.ui.group.CreateGroupChatScreen
import com.aiosman.ravenow.ui.chat.ChatAiScreen
import com.aiosman.ravenow.ui.chat.ChatSettingScreen
@@ -133,6 +134,7 @@ sealed class NavigationRoute(
data object MbtiSelect : NavigationRoute("MbtiSelect")
data object ZodiacSelect : NavigationRoute("ZodiacSelect")
data object ScanQr : NavigationRoute("ScanQr")
data object AiPromptEdit : NavigationRoute("AiPromptEdit/{chatAIId}")
}
@@ -684,6 +686,18 @@ fun NavigationController(
}
}
composable(
route = NavigationRoute.AiPromptEdit.route,
arguments = listOf(navArgument("chatAIId") { type = NavType.StringType })
) {
val chatAIId = it.arguments?.getString("chatAIId") ?: ""
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
AiPromptEditScreen(chatAIId = chatAIId)
}
}
}
}

View File

@@ -59,6 +59,7 @@ import java.io.InputStream
/**
* 专门用于智能体头像裁剪的页面
* 支持创建和编辑两种模式
*/
@Composable
fun AgentImageCropScreen() {
@@ -71,6 +72,14 @@ fun AgentImageCropScreen() {
val density = LocalDensity.current
val navController = LocalNavController.current
// 检查是否在编辑模式通过检查是否有编辑ViewModel的实例
val isEditMode = remember {
// 通过检查导航栈或使用其他方式判断
// 暂时使用一个简单的方法检查AddAgentViewModel是否正在选择头像
// 如果不是,则可能是编辑模式
!AddAgentViewModel.isSelectingAvatar
}
val imagePickLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri: Uri? ->
@@ -84,7 +93,9 @@ fun AgentImageCropScreen() {
}
if (uri == null) {
// 用户取消选择图片,重置标志
if (!isEditMode) {
AddAgentViewModel.isSelectingAvatar = false
}
navController.popBackStack()
}
}
@@ -122,7 +133,9 @@ fun AgentImageCropScreen() {
contentDescription = null,
modifier = Modifier.clickable {
// 用户取消头像选择,重置标志
if (!isEditMode) {
AddAgentViewModel.isSelectingAvatar = false
}
navController.popBackStack()
},
colorFilter = ColorFilter.tint(Color.White)
@@ -137,6 +150,12 @@ fun AgentImageCropScreen() {
modifier = Modifier.clickable {
if (croppedBitmap != null) {
// 如果已经有裁剪结果,直接返回
if (isEditMode) {
// 编辑模式需要找到当前的编辑ViewModel实例
// 由于无法直接访问,我们使用一个全局状态或者通过其他方式传递
// 暂时先保存到AddAgentViewModel编辑页面会检查
AddAgentViewModel.croppedBitmap = croppedBitmap
} else {
AddAgentViewModel.croppedBitmap = croppedBitmap
// 重置头像选择标志
AddAgentViewModel.isSelectingAvatar = false
@@ -144,6 +163,8 @@ fun AgentImageCropScreen() {
AddAgentViewModel.updateAgentAvatar(context)
navController.popBackStack()
}
}
navController.popBackStack()
} else {
// 进行裁剪
imageCrop?.let {

View File

@@ -0,0 +1,525 @@
package com.aiosman.ravenow.ui.agent
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import com.aiosman.ravenow.LocalNavController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.composables.ActionButton
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
import com.aiosman.ravenow.ui.composables.form.FormTextInput
import com.aiosman.ravenow.ui.composables.form.FormTextInput2
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import kotlinx.coroutines.launch
/**
* AI Prompt 编辑页面
*/
@Composable
fun AiPromptEditScreen(
chatAIId: String,
viewModel: AiPromptEditViewModel = viewModel()
) {
val context = LocalContext.current
val navController = LocalNavController.current
val appColors = LocalAppTheme.current
// 加载Prompt详情
LaunchedEffect(chatAIId) {
viewModel.loadPromptDetail(chatAIId)
}
// 监听头像裁剪结果从AgentImageCropScreen返回
LaunchedEffect(viewModel.isSelectingAvatar) {
if (!viewModel.isSelectingAvatar && AddAgentViewModel.croppedBitmap != null) {
// 从裁剪页面返回,检查是否有新的裁剪结果
viewModel.croppedBitmap = AddAgentViewModel.croppedBitmap
// 清空AddAgentViewModel的裁剪结果避免影响创建页面
AddAgentViewModel.croppedBitmap = null
}
}
// 状态
var showPrivacyConfirmDialog by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
// 计算是否需要付费
val needsPayment = viewModel.needsPrivacyPayment()
val privacyCost = 100 // 默认100钥匙后续可以从PointService获取
Column(
modifier = Modifier
.fillMaxSize()
.background(color = appColors.background),
horizontalAlignment = Alignment.CenterHorizontally
) {
StatusBarSpacer()
// 顶部导航栏
Box(
modifier = Modifier
.fillMaxWidth()
.background(color = appColors.background)
.padding(horizontal = 14.dp, vertical = 16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = painterResource(id = R.drawable.rider_pro_back_icon),
contentDescription = "返回",
modifier = Modifier
.size(24.dp)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
navController.navigateUp()
},
colorFilter = ColorFilter.tint(appColors.text)
)
Spacer(modifier = Modifier.size(12.dp))
Text(
"编辑Ai",
fontWeight = FontWeight.W600,
modifier = Modifier.weight(1f),
fontSize = 17.sp,
color = appColors.text
)
}
}
Spacer(modifier = Modifier.height(1.dp))
// 内容区域
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(appColors.background)
) {
// 头像选择
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 18.dp),
horizontalAlignment = Alignment.Start
) {
Text(
text = stringResource(R.string.avatar),
fontSize = 12.sp,
color = appColors.text,
fontWeight = FontWeight.W600
)
Spacer(modifier = Modifier.height(4.dp))
Box(
modifier = Modifier
.size(72.dp)
.clip(CircleShape)
.background(
brush = Brush.linearGradient(
colors = listOf(
Color(0x777c45ed),
Color(0x777c68ef),
Color(0x557bd8f8)
)
)
)
.noRippleClickable {
viewModel.isSelectingAvatar = true
// 标记为编辑模式
AddAgentViewModel.isSelectingAvatar = false
navController.navigate(NavigationRoute.AgentImageCrop.route)
},
contentAlignment = Alignment.Center
) {
when {
viewModel.croppedBitmap != null -> {
Image(
bitmap = viewModel.croppedBitmap!!.asImageBitmap(),
contentDescription = "Avatar",
modifier = Modifier
.size(72.dp)
.clip(CircleShape),
contentScale = ContentScale.Crop
)
}
viewModel.avatarUrl != null -> {
CustomAsyncImage(
context = context,
imageUrl = viewModel.avatarUrl!!,
contentDescription = "Avatar",
modifier = Modifier
.size(72.dp)
.clip(CircleShape),
contentScale = ContentScale.Crop
)
}
else -> {
Image(
painter = painterResource(id = R.mipmap.icons_infor_edit),
contentDescription = "Edit",
colorFilter = ColorFilter.tint(Color.White),
modifier = Modifier.size(20.dp),
)
}
}
}
}
Spacer(modifier = Modifier.height(18.dp))
// 名称输入
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
Text(
text = stringResource(R.string.agent_name),
fontSize = 12.sp,
color = appColors.text,
fontWeight = FontWeight.W600
)
Spacer(modifier = Modifier.height(4.dp))
FormTextInput(
value = viewModel.title,
hint = stringResource(R.string.agent_name_hint_1),
background = appColors.inputBackground2,
modifier = Modifier.fillMaxWidth(),
) { value ->
viewModel.title = value
}
}
Spacer(modifier = Modifier.height(18.dp))
// 描述输入
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
Text(
text = stringResource(R.string.agent_desc),
fontSize = 12.sp,
color = appColors.text,
fontWeight = FontWeight.W600
)
Spacer(modifier = Modifier.height(4.dp))
FormTextInput2(
value = viewModel.desc,
hint = stringResource(R.string.agent_desc_hint),
background = appColors.inputBackground2,
modifier = Modifier.fillMaxWidth(),
) { value ->
viewModel.desc = value
}
}
Spacer(modifier = Modifier.height(18.dp))
// 设定权限区域
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
Text(
text = "设定权限",
fontSize = 12.sp,
color = appColors.text,
fontWeight = FontWeight.W600
)
Spacer(modifier = Modifier.height(8.dp))
// 公开/私有切换
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(appColors.inputBackground2)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = if (viewModel.isPublic) "公开" else "私有",
fontSize = 14.sp,
color = appColors.text,
fontWeight = FontWeight.W500
)
Switch(
checked = viewModel.isPublic,
onCheckedChange = { checked ->
if (!checked && needsPayment && !viewModel.paidForPrivacyEdit) {
// 需要付费,显示确认对话框
showPrivacyConfirmDialog = true
} else {
viewModel.isPublic = checked
}
},
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = appColors.brandColorsColor,
uncheckedThumbColor = Color.White,
uncheckedTrackColor = appColors.brandColorsColor.copy(alpha = 0.5f),
uncheckedBorderColor = Color.Transparent
),
modifier = Modifier.size(width = 64.dp, height = 28.dp)
)
}
// 首次解锁AI权限提示
if (needsPayment && !viewModel.paidForPrivacyEdit) {
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(appColors.inputBackground2.copy(alpha = 0.9f))
.padding(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = "首次解锁 AI 权限",
fontSize = 13.sp,
color = appColors.text,
fontWeight = FontWeight.W500
)
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "将消耗",
fontSize = 12.sp,
color = appColors.text.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "$privacyCost",
fontSize = 12.sp,
color = appColors.brandColorsColor,
fontWeight = FontWeight.W500
)
Spacer(modifier = Modifier.width(2.dp))
Text(
text = "钥匙",
fontSize = 12.sp,
color = appColors.brandColorsColor,
fontWeight = FontWeight.W500
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "解锁后可随时切换",
fontSize = 12.sp,
color = appColors.text.copy(alpha = 0.6f)
)
}
}
}
}
}
}
Spacer(modifier = Modifier.height(18.dp))
// 添加智能体记忆按钮
AddAgentMemoryButton(
onAddMemoryClick = {
// TODO: 导航到记忆管理页面
// navController.navigate(NavigationRoute.AgentMemoryManage.route.replace("{chatAIId}", chatAIId))
}
)
}
// 底部保存按钮
Box(
modifier = Modifier
.fillMaxWidth()
.background(color = appColors.background)
.padding(horizontal = 16.dp, vertical = 16.dp)
) {
ActionButton(
text = "保存",
enabled = !viewModel.isUpdating && !viewModel.isLoading,
isLoading = viewModel.isUpdating,
modifier = Modifier.fillMaxWidth()
) {
// 验证输入
val validationError = viewModel.validate()
if (validationError != null) {
errorMessage = validationError
return@ActionButton
}
// 检查是否需要付费确认
if (needsPayment && !viewModel.paidForPrivacyEdit) {
showPrivacyConfirmDialog = true
return@ActionButton
}
// 执行保存
scope.launch {
try {
viewModel.updatePrompt(context)
navController.navigateUp()
} catch (e: Exception) {
errorMessage = e.message ?: "保存失败"
}
}
}
}
}
// 隐私权限付费确认对话框
if (showPrivacyConfirmDialog) {
// TODO: 实现付费确认对话框
// 暂时直接切换,后续可以添加积分检查和扣减逻辑
androidx.compose.material3.AlertDialog(
onDismissRequest = { showPrivacyConfirmDialog = false },
title = { Text("升级隐私权限") },
text = { Text("首次切换智能体的公开/私有状态需要支付一次性费用。支付后可自由在公有/私有之间切换,后续不再扣费。") },
confirmButton = {
androidx.compose.material3.TextButton(
onClick = {
showPrivacyConfirmDialog = false
scope.launch {
try {
viewModel.isPublic = false
viewModel.updatePrompt(context)
viewModel.paidForPrivacyEdit = true
navController.navigateUp()
} catch (e: Exception) {
errorMessage = e.message ?: "保存失败"
}
}
}
) {
Text("确认支付")
}
},
dismissButton = {
androidx.compose.material3.TextButton(
onClick = { showPrivacyConfirmDialog = false }
) {
Text("取消")
}
}
)
}
// 错误提示
errorMessage?.let { error ->
LaunchedEffect(error) {
kotlinx.coroutines.delay(3000)
errorMessage = null
}
// TODO: 显示Toast或Snackbar
}
}
@Composable
private fun AddAgentMemoryButton(
onAddMemoryClick: () -> Unit
) {
val appColors = LocalAppTheme.current
// 定义渐变边框颜色:紫色到蓝色
val borderGradient = Brush.horizontalGradient(
colors = listOf(
Color(0xFF7C45ED), // 紫色
Color(0xFF4A90E2) // 蓝色
)
)
// 浅紫色背景
val lightPurpleBackground = Color(0xFFF5F0FF)
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
// 使用两层Box来实现渐变边框效果
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(brush = borderGradient)
.padding(1.5.dp) // 边框宽度
) {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(lightPurpleBackground)
.padding(horizontal = 16.dp, vertical = 14.dp)
.noRippleClickable {
onAddMemoryClick()
},
contentAlignment = Alignment.Center
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
// 脑部图标
Image(
painter = painterResource(id = R.drawable.ic_brain_add),
contentDescription = "添加智能体记忆",
modifier = Modifier.size(20.dp),
colorFilter = ColorFilter.tint(Color(0xFF7C45ED))
)
Spacer(modifier = Modifier.width(8.dp))
// 文字
Text(
text = "添加智能体记忆",
fontSize = 14.sp,
fontWeight = FontWeight.W600,
color = Color(0xFF7C45ED)
)
}
}
}
}
}

View File

@@ -0,0 +1,166 @@
package com.aiosman.ravenow.ui.agent
import android.content.Context
import android.graphics.Bitmap
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.aiosman.ravenow.data.UploadImage
import com.aiosman.ravenow.data.ServiceException
import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.ravenow.entity.AgentEntity
import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File
class AiPromptEditViewModel : ViewModel() {
var chatAIId by mutableStateOf("")
var title by mutableStateOf("")
var desc by mutableStateOf("")
var isPublic by mutableStateOf(true)
var originalIsPublic by mutableStateOf(true)
var paidForPrivacyEdit by mutableStateOf(false)
var avatarUrl by mutableStateOf<String?>(null)
var croppedBitmap by mutableStateOf<Bitmap?>(null)
var isUpdating by mutableStateOf(false)
var isLoading by mutableStateOf(false)
var errorMessage by mutableStateOf<String?>(null)
var isSelectingAvatar by mutableStateOf(false)
/**
* 加载Prompt详情
*/
fun loadPromptDetail(chatAIId: String) {
viewModelScope.launch {
try {
isLoading = true
errorMessage = null
this@AiPromptEditViewModel.chatAIId = chatAIId
val response = ApiClient.api.getPromptDetail(chatAIId)
val body = response.body()?.data ?: throw ServiceException("Failed to get prompt detail")
// 填充数据
title = body.title
desc = body.desc
isPublic = body.isPublic
originalIsPublic = body.isPublic
avatarUrl = "${ApiClient.BASE_API_URL}/outside${body.avatar}?token=${com.aiosman.ravenow.AppStore.token}"
// 注意Agent数据模型可能没有paidForPrivacyEdit字段需要从其他地方获取
// 暂时设为false后续可以根据实际API响应调整
paidForPrivacyEdit = false
} catch (e: Exception) {
Log.e("AiPromptEditViewModel", "Error loading prompt detail", e)
errorMessage = "加载失败: ${e.message}"
} finally {
isLoading = false
}
}
}
/**
* 更新Prompt
*/
suspend fun updatePrompt(context: Context): AgentEntity? {
try {
isUpdating = true
errorMessage = null
// 准备头像文件
val avatarFile = if (croppedBitmap != null) {
val file = File(context.cacheDir, "agent_avatar_edit.jpg")
croppedBitmap!!.compress(Bitmap.CompressFormat.JPEG, 100, file.outputStream())
UploadImage(file, "agent_avatar_edit.jpg", "", "jpg")
} else {
null
}
// 准备请求参数
val textTitle = title.trim().toRequestBody("text/plain".toMediaTypeOrNull())
val textDesc = desc.trim().toRequestBody("text/plain".toMediaTypeOrNull())
val textValue = desc.trim().toRequestBody("text/plain".toMediaTypeOrNull()) // value通常和desc相同
val isPublicBody = isPublic.toString().toRequestBody("text/plain".toMediaTypeOrNull())
val avatarPart: MultipartBody.Part? = avatarFile?.let {
val requestFile = it.file.asRequestBody("image/*".toMediaTypeOrNull())
MultipartBody.Part.createFormData("avatar", it.filename, requestFile)
}
// 调用更新API
val response = ApiClient.api.updatePrompt(
promptId = chatAIId,
avatar = avatarPart,
title = textTitle,
desc = textDesc,
value = textValue,
isPublic = isPublicBody
)
val body = response.body()?.data ?: throw ServiceException("Failed to update prompt")
// 更新本地状态
originalIsPublic = isPublic
return body.toAgentEntity()
} catch (e: Exception) {
Log.e("AiPromptEditViewModel", "Error updating prompt", e)
errorMessage = "更新失败: ${e.message}"
throw e
} finally {
isUpdating = false
}
}
/**
* 验证输入
*/
fun validate(): String? {
return when {
title.trim().isEmpty() -> "智能体名称不能为空"
title.trim().length < 2 -> "智能体名称长度不能少于2个字符"
title.trim().length > 20 -> "智能体名称长度不能超过20个字符"
desc.trim().isEmpty() -> "智能体描述不能为空"
desc.trim().length > 512 -> "智能体描述长度不能超过512个字符"
else -> null
}
}
/**
* 判断是否需要付费解锁隐私切换
*/
fun needsPrivacyPayment(): Boolean {
// 如果已经解锁过,则不需要付费
if (paidForPrivacyEdit) {
return false
}
// 只有从公开true切换到私有false才需要付费
return originalIsPublic == true && isPublic == false
}
/**
* 清空数据
*/
fun clearData() {
chatAIId = ""
title = ""
desc = ""
isPublic = true
originalIsPublic = true
paidForPrivacyEdit = false
avatarUrl = null
croppedBitmap = null
isUpdating = false
isLoading = false
errorMessage = null
isSelectingAvatar = false
}
}

View File

@@ -54,6 +54,8 @@ import com.aiosman.ravenow.ui.index.tabs.profile.MyProfileViewModel
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.aiosman.ravenow.ui.navigateToGroupChat
import com.aiosman.ravenow.AppStore
import com.aiosman.ravenow.data.api.ApiClient
import android.util.Base64
@OptIn(ExperimentalMaterialApi::class)
@Composable
@@ -209,7 +211,12 @@ fun RoomItem(
val avatarUrl = if (room.avatar.isNotEmpty()) {
"${ConstVars.BASE_SERVER}/api/v1/outside/${room.avatar}?token=${AppStore.token}"
} else {
""
// 如果头像为空,使用群头像接口
val groupIdBase64 = Base64.encodeToString(
room.trtcType.toByteArray(),
Base64.NO_WRAP
)
"${ApiClient.RETROFIT_URL}group/avatar?groupIdBase64=${groupIdBase64}&token=${AppStore.token}"
}
Row(

View File

@@ -74,7 +74,9 @@ import com.aiosman.ravenow.ui.composables.MomentCard
import com.aiosman.ravenow.ui.composables.TabItem
import com.aiosman.ravenow.ui.composables.TabSpacer
import com.aiosman.ravenow.ui.index.tabs.message.tab.AgentChatListViewModel
import com.aiosman.ravenow.ui.index.tabs.profile.composable.RoomItem
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.aiosman.ravenow.ui.navigateToGroupChat
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import kotlinx.coroutines.launch
import com.aiosman.ravenow.utils.NetworkUtils
@@ -88,7 +90,7 @@ fun SearchScreen() {
val context = LocalContext.current
val model = SearchViewModel
val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState(pageCount = { 3 })
val pagerState = rememberPagerState(pageCount = { 4 })
val keyboardController = LocalSoftwareKeyboardController.current
val systemUiController = rememberSystemUiController()
val statusBarPaddingValues = WindowInsets.systemBars.asPaddingValues()
@@ -168,8 +170,9 @@ fun SearchScreen() {
onClick = { term ->
coroutineScope.launch {
keyboardController?.hide()
model.searchText = term
model.onTextChanged(term)
pagerState.scrollToPage(0)
pagerState.animateScrollToPage(0)
model.search()
}
},
@@ -222,6 +225,18 @@ fun SearchScreen() {
}
)
}
TabSpacer()
Box {
TabItem(
text = stringResource(R.string.chat_group),
isSelected = pagerState.currentPage == 3,
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(3)
}
}
)
}
}
}
}
@@ -376,6 +391,7 @@ fun SearchPager(
0 -> MomentResultTab()
1 -> UserResultTab()
2 -> AiResultTab()
3 -> RoomResultTab()
}
}
}
@@ -753,6 +769,100 @@ fun AiResultTab() {
}
}
}
@Composable
fun RoomResultTab() {
val model = SearchViewModel
val rooms = model.roomsFlow.collectAsLazyPagingItems()
val AppColors = LocalAppTheme.current
val context = LocalContext.current
val navController = LocalNavController.current
Box(
modifier = Modifier
.fillMaxSize()
.background(AppColors.background)
) {
if (rooms.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(rooms.itemCount) { idx ->
val roomItem = rooms[idx] ?: return@items
RoomItem(
room = roomItem,
onRoomClick = { roomEntity ->
navController.navigateToGroupChat(
id = roomEntity.trtcRoomId,
name = roomEntity.name,
avatar = roomEntity.avatar
)
}
)
}
}
}
}
}
@Composable
fun ReloadButton(
onClick: () -> Unit

View File

@@ -23,6 +23,9 @@ 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.entity.RoomEntity
import com.aiosman.ravenow.entity.RoomRemoteDataSource
import com.aiosman.ravenow.entity.RoomSearchPagingSource
import com.aiosman.ravenow.utils.SearchHistoryStore
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -40,6 +43,8 @@ object SearchViewModel : ViewModel() {
val usersFlow = _usersFlow.asStateFlow()
private val _agentsFlow = MutableStateFlow<PagingData<AgentEntity>>(PagingData.empty())
val agentsFlow = _agentsFlow.asStateFlow()
private val _roomsFlow = MutableStateFlow<PagingData<RoomEntity>>(PagingData.empty())
val roomsFlow = _roomsFlow.asStateFlow()
private lateinit var historyStore: SearchHistoryStore
private val _historyFlow = MutableStateFlow<List<String>>(emptyList())
val historyFlow = _historyFlow.asStateFlow()
@@ -108,6 +113,19 @@ object SearchViewModel : ViewModel() {
_agentsFlow.value = it
}
}
viewModelScope.launch {
Pager(
config = PagingConfig(pageSize = 5, enablePlaceholders = false),
pagingSourceFactory = {
RoomSearchPagingSource(
RoomRemoteDataSource(),
keyword = searchText
)
}
).flow.cachedIn(viewModelScope).collectLatest {
_roomsFlow.value = it
}
}
showResult = true
}
@@ -153,6 +171,7 @@ object SearchViewModel : ViewModel() {
_momentsFlow.value = PagingData.empty()
_usersFlow.value = PagingData.empty()
_agentsFlow.value = PagingData.empty()
_roomsFlow.value = PagingData.empty()
showResult = false
}

View File

@@ -1,5 +1,6 @@
package com.aiosman.ravenow.ui.profile
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
@@ -10,16 +11,21 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -55,7 +61,16 @@ fun AiProfileV3(
onComment: (MomentEntity) -> Unit = {},
) {
val appColors = LocalAppTheme.current
val navController = LocalNavController.current
val numberFormat = remember { NumberFormat.getNumberInstance(Locale.getDefault()) }
var showMenu by remember { mutableStateOf(false) }
// 判断是否是创建者
val isCreator = remember(profile) {
profile?.creatorProfile?.id?.let { creatorId ->
AppState.UserId?.toLong() == creatorId
} ?: false
}
Box(
modifier = Modifier
@@ -140,12 +155,42 @@ fun AiProfileV3(
}
// 顶部返回按钮
TopBar()
TopBar(
onMenuClick = { showMenu = true }
)
}
// 底部菜单
if (showMenu) {
AiProfileMenuModal(
onDismiss = { showMenu = false },
onChatClick = {
showMenu = false
onChatClick()
},
onShareClick = {
showMenu = false
onShareClick()
},
onEditClick = {
showMenu = false
// 导航到编辑页面
profile?.chatAIId?.let { chatAIId ->
navController.navigate(
NavigationRoute.AiPromptEdit.route.replace("{chatAIId}", chatAIId)
)
}
},
profile = profile,
showEdit = isCreator
)
}
}
@Composable
private fun TopBar() {
private fun TopBar(
onMenuClick: () -> Unit = {}
) {
val navController = LocalNavController.current
val appColors = LocalAppTheme.current
@@ -182,6 +227,25 @@ private fun TopBar() {
fontWeight = FontWeight.Bold
)
}
// 菜单按钮
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.3f))
.noRippleClickable {
onMenuClick()
},
contentAlignment = Alignment.Center
) {
Image(
painter = painterResource(R.drawable.rider_pro_more_horizon),
contentDescription = "Menu",
modifier = Modifier.size(24.dp),
colorFilter = ColorFilter.tint(Color.White)
)
}
}
}
}
@@ -454,3 +518,130 @@ private fun AiProfileActions(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AiProfileMenuModal(
onDismiss: () -> Unit,
onChatClick: () -> Unit,
onShareClick: () -> Unit,
onEditClick: () -> Unit,
showEdit: Boolean = false,
profile: AccountProfileEntity? = null
) {
val appColors = LocalAppTheme.current
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = appColors.background,
dragHandle = {},
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(if (showEdit) 240.dp else 160.dp)
.background(appColors.background)
.padding(vertical = 47.dp, horizontal = 20.dp)
) {
// 私信选项
Column(
modifier = Modifier
.weight(1f)
.padding(end = 16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.clip(CircleShape)
.noRippleClickable {
onChatClick()
}
) {
Image(
painter = painterResource(id = R.drawable.rider_pro_message),
contentDescription = "",
modifier = Modifier.size(24.dp),
colorFilter = ColorFilter.tint(appColors.text)
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.chat_upper),
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = appColors.text
)
}
// 分享选项
Column(
modifier = Modifier
.weight(1f)
.padding(end = if (showEdit) 16.dp else 0.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.clip(CircleShape)
.noRippleClickable {
onShareClick()
}
) {
Image(
painter = painterResource(id = R.drawable.rider_pro_share),
contentDescription = "",
modifier = Modifier.size(24.dp),
colorFilter = ColorFilter.tint(appColors.text)
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.share),
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = appColors.text
)
}
// 编辑选项(仅创建者可见)
if (showEdit) {
Column(
modifier = Modifier
.weight(1f),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.clip(CircleShape)
.noRippleClickable {
onEditClick()
}
) {
Image(
painter = painterResource(id = R.drawable.group_info_edit),
contentDescription = "",
modifier = Modifier.size(24.dp),
colorFilter = ColorFilter.tint(appColors.text)
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.edit_profile),
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = appColors.text
)
}
}
}
}
}

View File

@@ -44,8 +44,12 @@ class AiProfileViewModel : ViewModel() {
}
try {
profile = userService.getUserProfile(id)
// 先通过用户ID获取基本信息获取chatAIId
val basicProfile = userService.getUserProfile(id)
profileId = id.toInt()
// 使用chatAIId通过getUserProfileByOpenId获取完整信息包含creatorProfile
profile = userService.getUserProfileByOpenId(basicProfile.chatAIId)
} catch (e: Exception) {
Log.e("AiProfileViewModel", "Error loading profile", e)
e.printStackTrace()

View File

@@ -0,0 +1,20 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- 脑部轮廓 - 更简洁的设计 -->
<path
android:pathData="M12,4C9.2,4 7,6.2 7,9C7,10.2 7.4,11.3 8,12.1C7.6,12.7 7.3,13.4 7.3,14.1C7.3,15.7 8.6,17 10.2,17C10.6,17 11,16.9 11.3,16.7C11.7,17.3 12.4,17.7 13.2,17.7C14.5,17.7 15.5,16.7 15.5,15.4C15.5,15 15.4,14.6 15.2,14.3C15.7,14 16,13.4 16,12.8C16,11.8 15.4,11 14.5,10.7C14.7,10.2 14.8,9.6 14.8,9C14.8,6.2 12.6,4 9.8,4H12Z"
android:strokeLineJoin="round"
android:strokeWidth="1.8"
android:fillColor="#00000000"
android:strokeColor="#7C45ED"
android:fillType="evenOdd"
android:strokeLineCap="round"/>
<!-- 加号 - 位于右上角 -->
<path
android:pathData="M17,7h-1.5v1.5h-1.5v1.5h1.5v1.5h1.5v-1.5h1.5v-1.5h-1.5v-1.5z"
android:fillColor="#7C45ED"
android:fillType="evenOdd"/>
</vector>