智能体会话开发

This commit is contained in:
weber
2025-08-07 19:03:05 +08:00
parent 38759eb3e4
commit f6a796e2bc
17 changed files with 1084 additions and 45 deletions

View File

@@ -1,6 +1,7 @@
package com.aiosman.ravenow.data package com.aiosman.ravenow.data
import com.aiosman.ravenow.AppState import com.aiosman.ravenow.AppState
import com.aiosman.ravenow.AppStore
import com.aiosman.ravenow.data.api.ApiClient import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.ravenow.data.api.AppConfig import com.aiosman.ravenow.data.api.AppConfig
import com.aiosman.ravenow.data.api.CaptchaInfo import com.aiosman.ravenow.data.api.CaptchaInfo
@@ -53,6 +54,8 @@ data class AccountProfile(
val banner: String?, val banner: String?,
// trtcUserId // trtcUserId
val trtcUserId: String, val trtcUserId: String,
// aiAccount true:ai false:普通用户
val aiAccount: Boolean
) { ) {
/** /**
* 转换为Entity * 转换为Entity
@@ -63,7 +66,13 @@ data class AccountProfile(
followerCount = followerCount, followerCount = followerCount,
followingCount = followingCount, followingCount = followingCount,
nickName = nickname, nickName = nickname,
avatar = "${ApiClient.BASE_SERVER}$avatar", avatar = if (aiAccount) {
// 对于AI账户直接使用原始头像URL不添加服务器前缀
"${ApiClient.BASE_API_URL+"/outside"}$avatar"+"?token="+"Bearer ${AppStore.token}"
} else {
// 对于普通用户,添加服务器前缀
"${ApiClient.BASE_SERVER}$avatar"
},
bio = bio, bio = bio,
country = "Worldwide", country = "Worldwide",
isFollowing = isFollowing, isFollowing = isFollowing,
@@ -74,6 +83,7 @@ data class AccountProfile(
null null
}, },
trtcUserId = trtcUserId, trtcUserId = trtcUserId,
aiAccount = aiAccount,
rawAvatar = avatar rawAvatar = avatar
) )
} }

View File

@@ -48,8 +48,23 @@ interface UserService {
followingId: Int? = null followingId: Int? = null
): ListContainer<AccountProfileEntity> ): ListContainer<AccountProfileEntity>
/**
* 获取用户信息
* @param id 用户ID
* @return 用户信息
*/
suspend fun getUserProfileByTrtcUserId(id: String):AccountProfileEntity suspend fun getUserProfileByTrtcUserId(id: String):AccountProfileEntity
/**
* 获取用户信息
* @param id 用户ID
* @return 用户信息
*/
suspend fun getUserProfileByOpenId(id: String):AccountProfileEntity
} }
class UserServiceImpl : UserService { class UserServiceImpl : UserService {
@@ -97,4 +112,10 @@ class UserServiceImpl : UserService {
val body = resp.body() ?: throw ServiceException("Failed to get account") val body = resp.body() ?: throw ServiceException("Failed to get account")
return body.data.toAccountProfileEntity() return body.data.toAccountProfileEntity()
} }
override suspend fun getUserProfileByOpenId(id: String): AccountProfileEntity {
val resp = ApiClient.api.getAccountProfileByOpenId(id)
val body = resp.body() ?: throw ServiceException("Failed to get account")
return body.data.toAccountProfileEntity()
}
} }

View File

@@ -373,6 +373,12 @@ interface RaveNowAPI {
@Path("id") id: String @Path("id") id: String
): Response<DataContainer<AccountProfile>> ): Response<DataContainer<AccountProfile>>
@GET("profile/aichat/profile/{id}")
suspend fun getAccountProfileByOpenId(
@Path("id") id: String
): Response<DataContainer<AccountProfile>>
@POST("user/{id}/follow") @POST("user/{id}/follow")
suspend fun followUser( suspend fun followUser(
@Path("id") id: Int @Path("id") id: Int

View File

@@ -61,6 +61,8 @@ data class AccountProfileEntity(
val banner: String?, val banner: String?,
// trtcUserId // trtcUserId
val trtcUserId: String, val trtcUserId: String,
val aiAccount: Boolean,
val rawAvatar: String val rawAvatar: String
) )

View File

@@ -59,7 +59,7 @@ data class AgentEntity(
//val profile: ProfileEntity, //val profile: ProfileEntity,
val title: String, val title: String,
val updatedAt: String, val updatedAt: String,
val useCount: Int val useCount: Int,
) )
data class ProfileEntity( data class ProfileEntity(

View File

@@ -32,6 +32,7 @@ import com.aiosman.ravenow.ui.account.AccountEditScreen2
import com.aiosman.ravenow.ui.account.AccountSetting import com.aiosman.ravenow.ui.account.AccountSetting
import com.aiosman.ravenow.ui.account.ResetPasswordScreen import com.aiosman.ravenow.ui.account.ResetPasswordScreen
import com.aiosman.ravenow.ui.agent.AddAgentScreen import com.aiosman.ravenow.ui.agent.AddAgentScreen
import com.aiosman.ravenow.ui.chat.ChatAiScreen
import com.aiosman.ravenow.ui.chat.ChatScreen import com.aiosman.ravenow.ui.chat.ChatScreen
import com.aiosman.ravenow.ui.comment.CommentsScreen import com.aiosman.ravenow.ui.comment.CommentsScreen
import com.aiosman.ravenow.ui.comment.notice.CommentNoticeScreen import com.aiosman.ravenow.ui.comment.notice.CommentNoticeScreen
@@ -91,6 +92,7 @@ sealed class NavigationRoute(
data object ResetPassword : NavigationRoute("ResetPassword") data object ResetPassword : NavigationRoute("ResetPassword")
data object FavouriteList : NavigationRoute("FavouriteList") data object FavouriteList : NavigationRoute("FavouriteList")
data object Chat : NavigationRoute("Chat/{id}") data object Chat : NavigationRoute("Chat/{id}")
data object ChatAi : NavigationRoute("ChatAi/{id}")
data object CommentNoticeScreen : NavigationRoute("CommentNoticeScreen") data object CommentNoticeScreen : NavigationRoute("CommentNoticeScreen")
data object ImageCrop : NavigationRoute("ImageCrop") data object ImageCrop : NavigationRoute("ImageCrop")
data object AccountSetting : NavigationRoute("AccountSetting") data object AccountSetting : NavigationRoute("AccountSetting")
@@ -369,6 +371,19 @@ fun NavigationController(
ChatScreen(it.arguments?.getString("id")!!) ChatScreen(it.arguments?.getString("id")!!)
} }
} }
composable(
route = NavigationRoute.ChatAi.route,
arguments = listOf(navArgument("id") { type = NavType.StringType })
) {
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
ChatAiScreen(it.arguments?.getString("id")!!)
}
}
composable(route = NavigationRoute.CommentNoticeScreen.route) { composable(route = NavigationRoute.CommentNoticeScreen.route) {
CompositionLocalProvider( CompositionLocalProvider(
LocalAnimatedContentScope provides this, LocalAnimatedContentScope provides this,
@@ -457,6 +472,15 @@ fun NavHostController.navigateToChat(id: String) {
) )
} }
fun NavHostController.navigateToChatnavigateToChatAi(id: String) {
navigate(
route = NavigationRoute.Chat.route
.replace("{id}", id)
)
}
fun NavHostController.goTo( fun NavHostController.goTo(
route: NavigationRoute route: NavigationRoute
) { ) {

View File

@@ -0,0 +1,652 @@
package com.aiosman.ravenow.ui.chat
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Icon
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.SoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R
import com.aiosman.ravenow.entity.ChatItem
import com.aiosman.ravenow.exp.formatChatTime
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import com.aiosman.ravenow.ui.composables.DropdownMenu
import com.aiosman.ravenow.ui.composables.MenuItem
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.tencent.imsdk.v2.V2TIMMessage
import kotlinx.coroutines.launch
@Composable
fun ChatAiScreen(userId: String) {
var isMenuExpanded by remember { mutableStateOf(false) }
val navController = LocalNavController.current
val context = LocalNavController.current.context
val AppColors = LocalAppTheme.current
var goToNewCount by remember { mutableStateOf(0) }
val viewModel = viewModel<ChatAiViewModel>(
key = "ChatViewModel_$userId",
factory = object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ChatViewModel(userId) as T
}
}
)
var isLoadingMore by remember { mutableStateOf(false) } // Add a state for loading
LaunchedEffect(Unit) {
viewModel.init(context = context)
}
DisposableEffect(Unit) {
onDispose {
viewModel.UnRegistListener()
viewModel.clearUnRead()
}
}
val listState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
val navigationBarHeight = with(LocalDensity.current) {
WindowInsets.navigationBars.getBottom(this).toDp()
}
var inBottom by remember { mutableStateOf(true) }
// 监听滚动状态,触发加载更多
LaunchedEffect(listState) {
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index }
.collect { index ->
Log.d("ChatScreen", "lastVisibleItemIndex: ${index}")
if (index == listState.layoutInfo.totalItemsCount - 1) {
coroutineScope.launch {
viewModel.onLoadMore(context)
}
}
}
}
// 监听滚动状态,触发滚动到底部
LaunchedEffect(listState) {
snapshotFlow { listState.layoutInfo.visibleItemsInfo.firstOrNull()?.index }
.collect { index ->
inBottom = index == 0
if (index == 0) {
goToNewCount = 0
}
}
}
// 监听是否需要滚动到最新消息
LaunchedEffect(viewModel.goToNew) {
if (viewModel.goToNew) {
if (inBottom) {
listState.scrollToItem(0)
} else {
goToNewCount++
}
viewModel.goToNew = false
}
}
Scaffold(
modifier = Modifier
.fillMaxSize(),
topBar = {
Column(
modifier = Modifier
.fillMaxWidth()
.background(AppColors.background)
) {
StatusBarSpacer()
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp, horizontal = 16.dp),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(R.drawable.rider_pro_back_icon),
modifier = Modifier
.size(28.dp)
.noRippleClickable {
navController.navigateUp()
},
contentDescription = null,
colorFilter = ColorFilter.tint(
AppColors.text)
)
Spacer(modifier = Modifier.width(16.dp))
CustomAsyncImage(
imageUrl = viewModel.userProfile?.avatar ?: "",
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(40.dp)),
contentDescription = "avatar"
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = viewModel.userProfile?.nickName ?: "",
modifier = Modifier.weight(1f),
style = TextStyle(
color = AppColors.text,
fontSize = 18.sp,
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold
)
)
Spacer(modifier = Modifier.weight(1f))
Box {
Image(
painter = painterResource(R.drawable.rider_pro_more_horizon),
modifier = Modifier
.size(28.dp)
.noRippleClickable {
isMenuExpanded = true
},
contentDescription = null,
colorFilter = ColorFilter.tint(
AppColors.text)
)
DropdownMenu(
expanded = isMenuExpanded,
onDismissRequest = {
isMenuExpanded = false
},
menuItems = listOf(
MenuItem(
title = if (viewModel.notificationStrategy == "mute") "Unmute" else "Mute",
icon = if (viewModel.notificationStrategy == "mute") R.drawable.rider_pro_notice_mute else R.drawable.rider_pro_notice_active,
) {
isMenuExpanded = false
viewModel.viewModelScope.launch {
if (viewModel.notificationStrategy == "mute") {
viewModel.updateNotificationStrategy("active")
} else {
viewModel.updateNotificationStrategy("mute")
}
}
}
),
)
}
}
}
},
bottomBar = {
Column(
modifier = Modifier
.fillMaxWidth()
.imePadding()
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(
AppColors.decentBackground)
)
Spacer(modifier = Modifier.height(8.dp))
ChatInput(
onSendImage = {
it?.let {
viewModel.sendImageMessage(it, context)
}
},
) {
viewModel.sendMessage(it, context)
}
}
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.background(AppColors.decentBackground)
.padding(paddingValues)
) {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize(),
reverseLayout = true,
verticalArrangement = Arrangement.Top
) {
val chatList = groupMessagesByTime(viewModel.getDisplayChatList(), viewModel)
items(chatList.size, key = { index -> chatList[index].msgId }) { index ->
val item = chatList[index]
if (item.showTimeDivider) {
val calendar = java.util.Calendar.getInstance()
calendar.timeInMillis = item.timestamp
Text(
text = calendar.time.formatChatTime(context), // Format the timestamp
style = TextStyle(
color = AppColors.secondaryText,
fontSize = 14.sp,
textAlign = TextAlign.Center
),
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
)
}
ChatItem(item = item, viewModel.myProfile?.trtcUserId!!)
}
// item {
// Spacer(modifier = Modifier.height(72.dp))
// }
}
if (goToNewCount > 0) {
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(bottom = 16.dp, end = 16.dp)
.shadow(4.dp, shape = RoundedCornerShape(16.dp))
.clip(RoundedCornerShape(16.dp))
.background(AppColors.background)
.padding(8.dp)
.noRippleClickable {
coroutineScope.launch {
listState.scrollToItem(0)
}
},
) {
Text(
text = "${goToNewCount} New Message",
style = TextStyle(
color = AppColors.text,
fontSize = 16.sp,
),
)
}
}
}
}
}
@Composable
fun ChatAiSelfItem(item: ChatItem) {
val context = LocalContext.current
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth()
) {
Column(
horizontalAlignment = androidx.compose.ui.Alignment.End,
) {
Box(
modifier = Modifier
.widthIn(
min = 20.dp,
max = (if (item.messageType == V2TIMMessage.V2TIM_ELEM_TYPE_TEXT) 250.dp else 150.dp)
)
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFF000000))
.padding(
vertical = (if (item.messageType == V2TIMMessage.V2TIM_ELEM_TYPE_TEXT) 8.dp else 0.dp),
horizontal = (if (item.messageType == V2TIMMessage.V2TIM_ELEM_TYPE_TEXT) 16.dp else 0.dp)
)
.padding(bottom = (if (item.messageType == V2TIMMessage.V2TIM_ELEM_TYPE_TEXT) 3.dp else 0.dp))
) {
when (item.messageType) {
V2TIMMessage.V2TIM_ELEM_TYPE_TEXT -> {
Text(
text = item.message,
style = TextStyle(
color = Color.White,
fontSize = 16.sp,
),
textAlign = TextAlign.Start
)
}
V2TIMMessage.V2TIM_ELEM_TYPE_IMAGE -> {
CustomAsyncImage(
imageUrl = item.imageList[1].url,
modifier = Modifier.fillMaxSize(),
contentDescription = "image"
)
}
else -> {
Text(
text = "Unsupported message type",
style = TextStyle(
color = Color.White,
fontSize = 16.sp,
)
)
}
}
}
}
Spacer(modifier = Modifier.width(12.dp))
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(40.dp))
) {
CustomAsyncImage(
imageUrl = item.avatar,
modifier = Modifier.fillMaxSize(),
contentDescription = "avatar"
)
}
}
}
}
@Composable
fun ChatAiOtherItem(item: ChatItem) {
val AppColors = LocalAppTheme.current
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Row(
horizontalArrangement = Arrangement.Start,
modifier = Modifier.fillMaxWidth()
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(40.dp))
) {
CustomAsyncImage(
imageUrl = item.avatar,
modifier = Modifier.fillMaxSize(),
contentDescription = "avatar"
)
}
Spacer(modifier = Modifier.width(12.dp))
Column {
Box(
modifier = Modifier
.widthIn(
min = 20.dp,
max = (if (item.messageType == V2TIMMessage.V2TIM_ELEM_TYPE_TEXT) 250.dp else 150.dp)
)
.clip(RoundedCornerShape(8.dp))
.background(AppColors.background)
.padding(
vertical = (if (item.messageType == V2TIMMessage.V2TIM_ELEM_TYPE_TEXT) 8.dp else 0.dp),
horizontal = (if (item.messageType == V2TIMMessage.V2TIM_ELEM_TYPE_TEXT) 16.dp else 0.dp)
)
.padding(bottom = (if (item.messageType == V2TIMMessage.V2TIM_ELEM_TYPE_TEXT) 3.dp else 0.dp))
) {
when (item.messageType) {
V2TIMMessage.V2TIM_ELEM_TYPE_TEXT -> {
Text(
text = item.message,
style = TextStyle(
color = AppColors.text,
fontSize = 16.sp,
),
textAlign = TextAlign.Start
)
}
V2TIMMessage.V2TIM_ELEM_TYPE_IMAGE -> {
CustomAsyncImage(
imageUrl = item.imageList[1].url,
modifier = Modifier.fillMaxSize(),
contentDescription = "image"
)
}
else -> {
Text(
text = "Unsupported message type",
style = TextStyle(
color = AppColors.text,
fontSize = 16.sp,
)
)
}
}
}
}
}
}
}
@Composable
fun ChatAiItem(item: ChatItem, currentUserId: String) {
val isCurrentUser = item.userId == currentUserId
if (isCurrentUser) {
ChatAiSelfItem(item)
} else {
ChatAiOtherItem(item)
}
}
@Composable
fun ChatAiInput(
onSendImage: (Uri?) -> Unit = {},
onSend: (String) -> Unit = {},
) {
val navigationBarHeight = with(LocalDensity.current) {
WindowInsets.navigationBars.getBottom(this).toDp()
}
var keyboardController by remember { mutableStateOf<SoftwareKeyboardController?>(null) }
var isKeyboardOpen by remember { mutableStateOf(false) }
var text by remember { mutableStateOf("") }
val appColors = LocalAppTheme.current
val inputBarHeight by animateDpAsState(
targetValue = if (isKeyboardOpen) 8.dp else (navigationBarHeight + 8.dp),
animationSpec = tween(
durationMillis = 300,
easing = androidx.compose.animation.core.LinearEasing
), label = ""
)
// 在 isKeyboardOpen 变化时立即更新 inputBarHeight 的动画目标值
LaunchedEffect(isKeyboardOpen) {
inputBarHeight // 触发 inputBarHeight 的重组
}
val focusManager = LocalFocusManager.current
val windowInsets = WindowInsets.ime
val density = LocalDensity.current
val softwareKeyboardController = LocalSoftwareKeyboardController.current
val currentDensity by rememberUpdatedState(density)
LaunchedEffect(windowInsets.getBottom(currentDensity)) {
if (windowInsets.getBottom(currentDensity) <= 0) {
focusManager.clearFocus()
}
}
val imagePickUpLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) {
if (it.resultCode == Activity.RESULT_OK) {
val uri = it.data?.data
onSendImage(uri)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = inputBarHeight)
) {
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(16.dp))
.background(appColors.background)
.padding(horizontal = 16.dp),
contentAlignment = Alignment.CenterStart,
) {
BasicTextField(
value = text,
onValueChange = {
text = it
},
textStyle = TextStyle(
color = appColors.text,
fontSize = 16.sp
),
cursorBrush = SolidColor(appColors.text),
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.onFocusChanged { focusState ->
isKeyboardOpen = focusState.isFocused
}
.pointerInput(Unit) {
awaitPointerEventScope {
keyboardController = softwareKeyboardController
awaitFirstDown().also {
keyboardController?.show()
}
}
},
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(
onDone = {
keyboardController?.hide()
}
)
)
}
Spacer(modifier = Modifier.width(16.dp))
Icon(
painter = painterResource(id = R.drawable.rider_pro_camera),
contentDescription = "Emoji",
modifier = Modifier
.size(30.dp)
.noRippleClickable {
imagePickUpLauncher.launch(
Intent.createChooser(
Intent(Intent.ACTION_GET_CONTENT).apply {
type = "image/*"
},
"Select Image"
)
)
},
tint = appColors.chatActionColor
)
Spacer(modifier = Modifier.width(8.dp))
Crossfade(
targetState = text.isNotEmpty(), animationSpec = tween(500),
label = ""
) { isNotEmpty ->
Icon(
painter = painterResource(id = R.drawable.rider_pro_video_share),
contentDescription = "Emoji",
modifier = Modifier
.size(32.dp)
.noRippleClickable {
if (text.isNotEmpty()) {
onSend(text)
text = ""
}
},
tint = if (isNotEmpty) appColors.main else appColors.chatActionColor
)
}
}
}
fun groupMessagesByTime(chatList: List<ChatItem>, viewModel: ChatAiViewModel): List<ChatItem> {
for (i in chatList.indices) { // Iterate in normal order
if (i == 0) {
viewModel.showTimestampMap[chatList[i].msgId] = false
chatList[i].showTimeDivider = false
continue
}
val currentMessage = chatList[i]
val timeDiff = currentMessage.timestamp - chatList[i - 1].timestamp
// 时间间隔大于 3 分钟,显示时间戳
if (-timeDiff > 30 * 60 * 1000) {
viewModel.showTimestampMap[currentMessage.msgId] = true
currentMessage.showTimeDivider = true
}
}
return chatList
}

View File

@@ -0,0 +1,271 @@
package com.aiosman.ravenow.ui.chat
import android.content.Context
import android.net.Uri
import android.provider.MediaStore
import android.util.Log
import android.webkit.MimeTypeMap
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.ChatState
import com.aiosman.ravenow.data.AccountService
import com.aiosman.ravenow.data.AccountServiceImpl
import com.aiosman.ravenow.data.UserService
import com.aiosman.ravenow.data.UserServiceImpl
import com.aiosman.ravenow.entity.AccountProfileEntity
import com.aiosman.ravenow.entity.ChatItem
import com.aiosman.ravenow.entity.ChatNotification
import com.tencent.imsdk.v2.V2TIMAdvancedMsgListener
import com.tencent.imsdk.v2.V2TIMCallback
import com.tencent.imsdk.v2.V2TIMManager
import com.tencent.imsdk.v2.V2TIMMessage
import com.tencent.imsdk.v2.V2TIMSendCallback
import com.tencent.imsdk.v2.V2TIMValueCallback
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
class ChatAiViewModel(
val userId: String,
) : ViewModel() {
var chatData by mutableStateOf<List<ChatItem>>(emptyList())
var userProfile by mutableStateOf<AccountProfileEntity?>(null)
var myProfile by mutableStateOf<AccountProfileEntity?>(null)
val userService: UserService = UserServiceImpl()
val accountService: AccountService = AccountServiceImpl()
var textMessageListener: V2TIMAdvancedMsgListener? = null
var hasMore by mutableStateOf(true)
var isLoading by mutableStateOf(false)
var lastMessage: V2TIMMessage? = null
val showTimestampMap = mutableMapOf<String, Boolean>() // Add this map
var chatNotification by mutableStateOf<ChatNotification?>(null)
var goToNew by mutableStateOf(false)
fun init(context: Context) {
// 获取用户信息
viewModelScope.launch {
val resp = userService.getUserProfile(userId)
userProfile = resp
myProfile = accountService.getMyAccountProfile()
RegistListener(context)
fetchHistoryMessage(context)
// 获取通知信息
val notiStrategy = ChatState.getStrategyByTargetTrtcId(resp.trtcUserId)
chatNotification = notiStrategy
}
}
fun RegistListener(context: Context) {
textMessageListener = object : V2TIMAdvancedMsgListener() {
override fun onRecvNewMessage(msg: V2TIMMessage?) {
super.onRecvNewMessage(msg)
msg?.let {
val chatItem = ChatItem.convertToChatItem(msg, context, avatar = userProfile?.avatar)
chatItem?.let {
chatData = listOf(it) + chatData
goToNew = true
}
}
}
}
V2TIMManager.getMessageManager().addAdvancedMsgListener(textMessageListener);
}
fun UnRegistListener() {
V2TIMManager.getMessageManager().removeAdvancedMsgListener(textMessageListener);
}
fun clearUnRead() {
val conversationID = "c2c_${userProfile?.trtcUserId}"
V2TIMManager.getConversationManager()
.cleanConversationUnreadMessageCount(conversationID, 0, 0, object : V2TIMCallback {
override fun onSuccess() {
Log.i("imsdk", "success")
}
override fun onError(code: Int, desc: String) {
Log.i("imsdk", "failure, code:$code, desc:$desc")
}
})
}
fun onLoadMore(context: Context) {
if (!hasMore || isLoading) {
return
}
isLoading = true
viewModelScope.launch {
V2TIMManager.getMessageManager().getC2CHistoryMessageList(
userProfile?.trtcUserId!!,
20,
lastMessage,
object : V2TIMValueCallback<List<V2TIMMessage>> {
override fun onSuccess(p0: List<V2TIMMessage>?) {
chatData = chatData + (p0 ?: emptyList()).map {
var avatar = userProfile?.avatar
if (it.isSelf) {
avatar = myProfile?.avatar
}
ChatItem.convertToChatItem(it, context,avatar)
}.filterNotNull()
if ((p0?.size ?: 0) < 20) {
hasMore = false
}
lastMessage = p0?.lastOrNull()
isLoading = false
Log.d("ChatViewModel", "fetch history message success")
}
override fun onError(p0: Int, p1: String?) {
Log.e("ChatViewModel", "fetch history message error: $p1")
isLoading = false
}
}
)
}
}
fun sendMessage(message: String, context: Context) {
V2TIMManager.getInstance().sendC2CTextMessage(
message,
userProfile?.trtcUserId!!,
object : V2TIMSendCallback<V2TIMMessage> {
override fun onProgress(p0: Int) {
}
override fun onError(p0: Int, p1: String?) {
Log.e("ChatViewModel", "send message error: $p1")
}
override fun onSuccess(p0: V2TIMMessage?) {
Log.d("ChatViewModel", "send message success")
val chatItem = ChatItem.convertToChatItem(p0!!, context, avatar = myProfile?.avatar)
chatItem?.let {
chatData = listOf(it) + chatData
goToNew = true
}
}
}
)
}
fun sendImageMessage(imageUri: Uri, context: Context) {
val tempFile = createTempFile(context, imageUri)
val imagePath = tempFile?.path
if (imagePath != null) {
val v2TIMMessage = V2TIMManager.getMessageManager().createImageMessage(imagePath)
V2TIMManager.getMessageManager().sendMessage(
v2TIMMessage,
userProfile?.trtcUserId!!,
null,
V2TIMMessage.V2TIM_PRIORITY_NORMAL,
false,
null,
object : V2TIMSendCallback<V2TIMMessage> {
override fun onProgress(p0: Int) {
Log.d("ChatViewModel", "send image message progress: $p0")
}
override fun onError(p0: Int, p1: String?) {
Log.e("ChatViewModel", "send image message error: $p1")
}
override fun onSuccess(p0: V2TIMMessage?) {
Log.d("ChatViewModel", "send image message success")
val chatItem = ChatItem.convertToChatItem(p0!!, context, avatar = myProfile?.avatar)
chatItem?.let {
chatData = listOf(it) + chatData
goToNew = true
}
}
}
)
}
}
fun createTempFile(context: Context, uri: Uri): File? {
return try {
val projection = arrayOf(MediaStore.Images.Media.DATA)
val cursor = context.contentResolver.query(uri, projection, null, null, null)
cursor?.use {
if (it.moveToFirst()) {
val columnIndex = it.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
val filePath = it.getString(columnIndex)
val inputStream: InputStream? = context.contentResolver.openInputStream(uri)
val mimeType = context.contentResolver.getType(uri)
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)
val tempFile =
File.createTempFile("temp_image", ".$extension", context.cacheDir)
val outputStream = FileOutputStream(tempFile)
inputStream?.use { input ->
outputStream.use { output ->
input.copyTo(output)
}
}
tempFile
} else {
null
}
}
} catch (e: Exception) {
e.printStackTrace()
null
}
}
fun fetchHistoryMessage(context: Context) {
V2TIMManager.getMessageManager().getC2CHistoryMessageList(
userProfile?.trtcUserId!!,
20,
null,
object : V2TIMValueCallback<List<V2TIMMessage>> {
override fun onSuccess(p0: List<V2TIMMessage>?) {
chatData = (p0 ?: emptyList()).mapNotNull {
var avatar = userProfile?.avatar
if (it.isSelf) {
avatar = myProfile?.avatar
}
ChatItem.convertToChatItem(it, context,avatar)
}
if ((p0?.size ?: 0) < 20) {
hasMore = false
}
lastMessage = p0?.lastOrNull()
Log.d("ChatViewModel", "fetch history message success")
}
override fun onError(p0: Int, p1: String?) {
Log.e("ChatViewModel", "fetch history message error: $p1")
}
}
)
}
fun getDisplayChatList(): List<ChatItem> {
val list = chatData
// Update showTimestamp for each message
for (item in list) {
item.showTimestamp = showTimestampMap.getOrDefault(item.msgId, false)
}
return list
}
suspend fun updateNotificationStrategy(strategy: String) {
userProfile?.let {
val result = ChatState.updateChatNotification(it.id, strategy)
chatNotification = result
}
}
val notificationStrategy get() = chatNotification?.strategy ?: "default"
}

View File

@@ -34,12 +34,14 @@ import com.aiosman.ravenow.entity.AgentEntity
import com.aiosman.ravenow.entity.MomentEntity import com.aiosman.ravenow.entity.MomentEntity
import com.aiosman.ravenow.exp.timeAgo import com.aiosman.ravenow.exp.timeAgo
import com.aiosman.ravenow.ui.NavigationRoute import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.index.tabs.message.MessageListViewModel
import com.aiosman.ravenow.ui.modifiers.noRippleClickable import com.aiosman.ravenow.ui.modifiers.noRippleClickable
@Composable @Composable
fun AgentCard( fun AgentCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
agentEntity: AgentEntity, agentEntity: AgentEntity,
onClick: () -> Unit = {},
) { ) {
val AppColors = LocalAppTheme.current val AppColors = LocalAppTheme.current
val context = LocalContext.current val context = LocalContext.current
@@ -50,6 +52,9 @@ fun AgentCard(
) { ) {
Box( Box(
modifier = Modifier.padding(start = 0.dp, end = 0.dp, top = 16.dp, bottom = 8.dp) modifier = Modifier.padding(start = 0.dp, end = 0.dp, top = 16.dp, bottom = 8.dp)
.noRippleClickable {
onClick ()
}
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier

View File

@@ -27,14 +27,19 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewModelScope
import com.aiosman.ravenow.LocalAppTheme import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.composables.AgentCard import com.aiosman.ravenow.ui.composables.AgentCard
import com.aiosman.ravenow.ui.navigateToChat
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
fun MineAgent() { fun MineAgent() {
val AppColors = LocalAppTheme.current val AppColors = LocalAppTheme.current
val navController = LocalNavController.current
val model = MineAgentViewModel val model = MineAgentViewModel
var agentList = model.agentList var agentList = model.agentList
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -108,7 +113,11 @@ fun MineAgent() {
key = { idx -> idx } key = { idx -> idx }
) { idx -> ) { idx ->
val agentItem = agentList[idx] val agentItem = agentList[idx]
AgentCard(agentEntity = agentItem) AgentCard(agentEntity = agentItem,
onClick = {
//model.createSingleChat(agentItem.openId)
model.goToChatAi(agentItem.openId,navController)
})
} }
// 加载更多指示器 // 加载更多指示器

View File

@@ -104,12 +104,12 @@ object MineAgentViewModel : ViewModel() {
return body.toString() return body.toString()
} }
fun goToChat( fun goToChatAi(
conversation: Conversation, openId: String,
navController: NavHostController navController: NavHostController
) { ) {
viewModelScope.launch { viewModelScope.launch {
val profile = userService.getUserProfileByTrtcUserId(conversation.trtcUserId) val profile = userService.getUserProfileByOpenId(openId)
navController.navigateToChat(profile.id.toString()) navController.navigateToChat(profile.id.toString())
} }
} }

View File

@@ -279,10 +279,11 @@ fun NotificationsScreen() {
} }
} }
/*Box( Box(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxWidth(), .fillMaxWidth()
,
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
if (AppState.enableChat){ if (AppState.enableChat){
@@ -303,7 +304,7 @@ fun NotificationsScreen() {
) )
} }
}*/ }
} }
PullRefreshIndicator( PullRefreshIndicator(
MessageListViewModel.isLoading, MessageListViewModel.isLoading,

View File

@@ -63,38 +63,40 @@ fun TimelineMomentsList() {
) { ) {
items( items(
moments.size, moments.size,
key = { idx -> moments[idx].id } key = { idx -> moments.getOrNull(idx)?.id ?: idx }
) { idx -> ) { idx ->
val momentItem = moments[idx] moments.getOrNull(idx)?.let { momentItem ->
MomentCard(momentEntity = momentItem, MomentCard(
onAddComment = { momentEntity = momentItem,
scope.launch { onAddComment = {
model.onAddComment(momentItem.id) scope.launch {
} model.onAddComment(momentItem.id)
},
onLikeClick = {
scope.launch {
if (momentItem.liked) {
model.dislikeMoment(momentItem.id)
} else {
model.likeMoment(momentItem.id)
} }
} },
}, onLikeClick = {
onFavoriteClick = { scope.launch {
scope.launch { if (momentItem.liked) {
if (momentItem.isFavorite) { model.dislikeMoment(momentItem.id)
model.unfavoriteMoment(momentItem.id) } else {
} else { model.likeMoment(momentItem.id)
model.favoriteMoment(momentItem.id) }
} }
} },
}, onFavoriteClick = {
onFollowClick = { scope.launch {
model.followAction(momentItem) if (momentItem.isFavorite) {
}, model.unfavoriteMoment(momentItem.id)
showFollowButton = false } else {
) model.favoriteMoment(momentItem.id)
}
}
},
onFollowClick = {
model.followAction(momentItem)
},
showFollowButton = false
)
}
} }
} }
PullRefreshIndicator(model.refreshing, state, Modifier.align(Alignment.TopCenter)) PullRefreshIndicator(model.refreshing, state, Modifier.align(Alignment.TopCenter))

View File

@@ -233,7 +233,7 @@ fun ProfileV3(
.fillMaxWidth() .fillMaxWidth()
.height(bannerHeight.dp - 24.dp) .height(bannerHeight.dp - 24.dp)
.let { .let {
if (isSelf) { if (isSelf&&isMain) {
it.noRippleClickable { it.noRippleClickable {
Intent(Intent.ACTION_PICK).apply { Intent(Intent.ACTION_PICK).apply {
type = "image/*" type = "image/*"
@@ -383,8 +383,11 @@ fun ProfileV3(
Row( Row(
modifier = Modifier.padding( modifier = Modifier.padding(
horizontal = 16.dp, horizontal = 16.dp,
vertical = 8.dp vertical = 8.dp,
), ).noRippleClickable {
},
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (!isMain) { if (!isMain) {

View File

@@ -1391,12 +1391,12 @@ fun PostMenuModal(
.background(AppColors.background) .background(AppColors.background)
.padding(vertical = 47.dp, horizontal = 20.dp) .padding(vertical = 47.dp, horizontal = 20.dp)
) { ) {
if(AppState.UserId == userId){
Row( Row(
modifier = Modifier modifier = Modifier
.size(60.dp), .size(60.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if(AppState.UserId == userId){
Column( Column(
modifier = Modifier.padding(end = 16.dp), modifier = Modifier.padding(end = 16.dp),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
@@ -1435,7 +1435,6 @@ fun PostMenuModal(
.size(60.dp), .size(60.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if(AppState.UserId == userId){
Column( Column(
modifier = Modifier.padding(end = 16.dp), modifier = Modifier.padding(end = 16.dp),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
@@ -1467,7 +1466,7 @@ fun PostMenuModal(
) )
} }
} }
}
} }
} }

View File

@@ -50,7 +50,30 @@
<string name="empty_my_post_content">发布一个动态吧</string> <string name="empty_my_post_content">发布一个动态吧</string>
<string name="edit_profile">编辑个人资料</string> <string name="edit_profile">编辑个人资料</string>
<string name="logout">登出</string> <string name="logout">登出</string>
<string name="change_password">变更密码</string> <string name="current_password_tip1">请输入当前密码</string>
<string name="current_password_tip2">密码长度必须至少为 8 个字符</string>
<string name="current_password_tip3">密码必须至少包含一位数字</string>
<string name="current_password_tip4">密码必须至少包含一个大写字母</string>
<string name="change_password_tip1">密码不匹配</string>
<string name="current_password">当前密码</string>
<string name="current_password_tip5">请输入当前密码</string>
<string name="new_password">新密码</string>
<string name="new_password_tip1">请输入新密码</string>
<string name="confirm_new_password">确认新密码</string>
<string name="confirm_new_password_tip1">请确认新密码</string>
<string name="change_password">修改密码</string>
<string name="please_enter_current_password">请输入当前密码</string>
<string name="password_length_tip">密码长度至少8位</string>
<string name="password_digit_tip">密码必须包含至少一个数字</string>
<string name="password_uppercase_tip">密码必须包含至少一个大写字母</string>
<string name="password_lowercase_tip">密码必须包含至少一个小写字母</string>
<string name="password_not_match">两次输入的密码不一致</string>
<string name="enter_current_password">请输入当前密码</string>
<string name="enter_new_password">请输入新密码</string>
<string name="enter_new_password_again">请再次输入新密码</string>
<string name="confirm_change">确认修改</string>
<string name="cancel">取消</string> <string name="cancel">取消</string>
<string name="bio">个性签名</string> <string name="bio">个性签名</string>
<string name="nickname">昵称</string> <string name="nickname">昵称</string>

View File

@@ -50,6 +50,17 @@
<string name="edit_profile">Edit profile</string> <string name="edit_profile">Edit profile</string>
<string name="logout">Logout</string> <string name="logout">Logout</string>
<string name="change_password">Change password</string> <string name="change_password">Change password</string>
<string name="current_password_tip1">Please enter your current password</string>
<string name="current_password_tip2">Password must be at least 8 characters long</string>
<string name="current_password_tip3">Password must contain at least one digit</string>
<string name="current_password_tip4">Password must contain at least one uppercase letter</string>
<string name="change_password_tip1">Passwords do not match</string>
<string name="current_password">Current password</string>
<string name="current_password_tip5">Enter your current password</string>
<string name="new_password">New password</string>
<string name="new_password_tip1">Enter your new password</string>
<string name="confirm_new_password">Confirm new password</string>
<string name="confirm_new_password_tip1">Confirm new password</string>
<string name="cancel">Cancel</string> <string name="cancel">Cancel</string>
<string name="bio">Signature</string> <string name="bio">Signature</string>
<string name="nickname">Name</string> <string name="nickname">Name</string>