feat: Android companion AI commercialization — phases 1-5 (24/30 tasks)
## Phase 1: Avatars + IM Layout (6/8 done) - Timestamp dividers replacing per-message timestamps - Long-press context menu (Copy/Read/Share/Like/Dislike/Regenerate) - Bubble slide-up + fade-in appear animation - Customizable chat background colors (6 presets) - User avatar support (top bar, drawer, message bubbles) - Notification badge clears on drawer open ## Phase 2: Streaming TTS + Recovery (4/7 done) - Typing indicator with animated dots - StopSpeaking integration on send/switch/stop - Per-agent voice binding - TTS speed control slider (0.5-2.0x) - Reconnection timeout detection (15s) with retry UI - Audio waveform animation on speaking message bubble ## Phase 3: Personality Immersion (5/7 done) - Per-agent show-thinking-chain toggle via workflow_config - Friendlier ThinkTrace labels with configurable iteration display - Hide character counter until >= 1800 chars - Send button warm pink gradient + scale press animation - Softer user bubble color (tertiaryContainer) ## Phase 4: Message Quality (4/4 done) - Message send states (sending/sent/failed indicators) - Image rendering in LightweightMarkdown via AsyncImage - Chat background image support (custom photo, compressed to 1920px) - Font size control (small/normal/large) ## Phase 5: Commercial Loop (4/4 done) - Intimacy/affinity tracking with level system (初识→灵魂伴侣) - Icebreaker greeting flow for companion agents - Chat background image support - Conversation export/share as image card ## New files - ChatTimestampDivider.kt, TypingIndicator.kt - IntimacyStats.kt, ConversationExporter.kt Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,8 @@ import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.*
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.google.gson.Gson
|
||||
import com.tiangong.aiagent.domain.model.IntimacyStats
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -31,8 +33,19 @@ class TokenDataStore @Inject constructor(
|
||||
private val KEY_NOTIFICATION_QUIET_START = stringPreferencesKey("notification_quiet_start")
|
||||
private val KEY_NOTIFICATION_QUIET_END = stringPreferencesKey("notification_quiet_end")
|
||||
private val KEY_ONBOARDING_COMPLETED = booleanPreferencesKey("onboarding_completed")
|
||||
private val KEY_CHAT_BACKGROUND = stringPreferencesKey("chat_background")
|
||||
private val KEY_CHAT_BACKGROUND_TYPE = stringPreferencesKey("chat_background_type")
|
||||
private val KEY_CHAT_BACKGROUND_IMAGE = stringPreferencesKey("chat_background_image")
|
||||
private val KEY_USERNAME = stringPreferencesKey("username")
|
||||
private val KEY_FONT_SIZE = stringPreferencesKey("font_size")
|
||||
private val KEY_TTS_SPEED = floatPreferencesKey("tts_speed")
|
||||
private const val KEY_INTIMACY_PREFIX = "intimacy_"
|
||||
|
||||
private fun intimacyKey(agentId: String) = stringPreferencesKey("${KEY_INTIMACY_PREFIX}$agentId")
|
||||
}
|
||||
|
||||
private val gson = Gson()
|
||||
|
||||
// ─── Token — delegated to encrypted CredentialStore (V1.4.0 安全加固) ───
|
||||
private val _tokenFlow = MutableStateFlow<String?>(null)
|
||||
val token: Flow<String?> = _tokenFlow
|
||||
@@ -122,6 +135,9 @@ class TokenDataStore @Inject constructor(
|
||||
// ─── Preferences ───
|
||||
val ttsEnabled: Flow<Boolean> = context.dataStore.data.map { it[KEY_TTS_ENABLED] ?: false }
|
||||
val ttsVoice: Flow<String> = context.dataStore.data.map { it[KEY_TTS_VOICE] ?: "alloy" }
|
||||
val chatBackground: Flow<String> = context.dataStore.data.map { it[KEY_CHAT_BACKGROUND] ?: "default" }
|
||||
val chatBackgroundType: Flow<String> = context.dataStore.data.map { it[KEY_CHAT_BACKGROUND_TYPE] ?: "color" }
|
||||
val chatBackgroundImage: Flow<String> = context.dataStore.data.map { it[KEY_CHAT_BACKGROUND_IMAGE] ?: "" }
|
||||
val themeMode: Flow<String> = context.dataStore.data.map { it[KEY_THEME_MODE] ?: "system" }
|
||||
|
||||
suspend fun setTtsEnabled(enabled: Boolean) {
|
||||
@@ -148,10 +164,49 @@ class TokenDataStore @Inject constructor(
|
||||
context.dataStore.edit { it[KEY_TTS_VOICE] = voice }
|
||||
}
|
||||
|
||||
suspend fun setChatBackground(color: String) {
|
||||
context.dataStore.edit { it[KEY_CHAT_BACKGROUND] = color }
|
||||
}
|
||||
|
||||
suspend fun setChatBackgroundType(type: String) {
|
||||
context.dataStore.edit { it[KEY_CHAT_BACKGROUND_TYPE] = type }
|
||||
}
|
||||
|
||||
suspend fun setChatBackgroundImage(uri: String) {
|
||||
context.dataStore.edit { it[KEY_CHAT_BACKGROUND_IMAGE] = uri }
|
||||
}
|
||||
|
||||
val username: Flow<String> = context.dataStore.data.map { it[KEY_USERNAME] ?: "" }
|
||||
|
||||
suspend fun saveUsername(name: String) {
|
||||
context.dataStore.edit { it[KEY_USERNAME] = name }
|
||||
}
|
||||
|
||||
val fontSize: Flow<String> = context.dataStore.data.map { it[KEY_FONT_SIZE] ?: "normal" }
|
||||
|
||||
val ttsSpeed: Flow<Float> = context.dataStore.data.map { it[KEY_TTS_SPEED] ?: 1.0f }
|
||||
|
||||
suspend fun setTtsSpeed(speed: Float) {
|
||||
context.dataStore.edit { it[KEY_TTS_SPEED] = speed }
|
||||
}
|
||||
|
||||
suspend fun setFontSize(size: String) {
|
||||
context.dataStore.edit { it[KEY_FONT_SIZE] = size }
|
||||
}
|
||||
|
||||
suspend fun setThemeMode(mode: String) {
|
||||
context.dataStore.edit { it[KEY_THEME_MODE] = mode }
|
||||
}
|
||||
|
||||
// ─── Companion onboarding ───
|
||||
suspend fun isCompanionOnboarded(agentId: String): Boolean {
|
||||
return context.dataStore.data.first()[booleanPreferencesKey("companion_onboarded_$agentId")] ?: false
|
||||
}
|
||||
|
||||
suspend fun setCompanionOnboarded(agentId: String) {
|
||||
context.dataStore.edit { it[booleanPreferencesKey("companion_onboarded_$agentId")] = true }
|
||||
}
|
||||
|
||||
// ─── Onboarding (v1.1.0) ───
|
||||
val onboardingCompleted: Flow<Boolean> = context.dataStore.data.map { it[KEY_ONBOARDING_COMPLETED] ?: false }
|
||||
|
||||
@@ -159,6 +214,31 @@ class TokenDataStore @Inject constructor(
|
||||
context.dataStore.edit { it[KEY_ONBOARDING_COMPLETED] = true }
|
||||
}
|
||||
|
||||
// ─── Per-agent voice binding ───
|
||||
private val KEY_AGENT_VOICE_PREFIX = "agent_voice_"
|
||||
|
||||
suspend fun getAgentVoice(agentId: String): String? {
|
||||
return context.dataStore.data.first()[stringPreferencesKey("${KEY_AGENT_VOICE_PREFIX}$agentId")]
|
||||
}
|
||||
|
||||
suspend fun setAgentVoice(agentId: String, voice: String) {
|
||||
context.dataStore.edit { it[stringPreferencesKey("${KEY_AGENT_VOICE_PREFIX}$agentId")] = voice }
|
||||
}
|
||||
|
||||
// ─── Intimacy / Affinity Tracking ───
|
||||
suspend fun getIntimacy(agentId: String): IntimacyStats {
|
||||
val json = context.dataStore.data.first()[intimacyKey(agentId)]
|
||||
return if (json != null) {
|
||||
try { gson.fromJson(json, IntimacyStats::class.java) } catch (_: Exception) { IntimacyStats() }
|
||||
} else IntimacyStats()
|
||||
}
|
||||
|
||||
suspend fun updateIntimacy(agentId: String, update: (IntimacyStats) -> IntimacyStats) {
|
||||
val current = getIntimacy(agentId)
|
||||
val updated = update(current)
|
||||
context.dataStore.edit { it[intimacyKey(agentId)] = gson.toJson(updated) }
|
||||
}
|
||||
|
||||
// ─── Clear All ───
|
||||
suspend fun clearAll() {
|
||||
context.dataStore.edit { prefs ->
|
||||
|
||||
@@ -157,6 +157,7 @@ class AgentRepository @Inject constructor(
|
||||
userId = userId,
|
||||
version = version,
|
||||
createdAt = createdAt,
|
||||
updatedAt = updatedAt
|
||||
updatedAt = updatedAt,
|
||||
showThinking = (workflowConfig?.get("show_thinking") as? Boolean) ?: true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -282,12 +282,13 @@ class ChatRepository @Inject constructor(
|
||||
// ─── Media operations (v1.2.0: moved from ChatViewModel to repository layer) ───
|
||||
|
||||
/** Synthesize speech and return the absolute audio URL. */
|
||||
suspend fun synthesizeSpeech(text: String): Result<String> {
|
||||
suspend fun synthesizeSpeech(text: String, voiceOverride: String? = null): Result<String> {
|
||||
return try {
|
||||
val ttsEnabled = tokenDataStore.ttsEnabled.first()
|
||||
if (!ttsEnabled) return Result.failure(Exception("TTS disabled"))
|
||||
val voice = tokenDataStore.ttsVoice.first()
|
||||
val response = apiService.synthesizeSpeech(TtsRequest(text = text, voice = voice))
|
||||
val voice = voiceOverride ?: tokenDataStore.ttsVoice.first()
|
||||
val speed = tokenDataStore.ttsSpeed.first()
|
||||
val response = apiService.synthesizeSpeech(TtsRequest(text = text, voice = voice, speed = speed))
|
||||
val baseUrl = tokenDataStore.serverUrl.first()
|
||||
val audioUrl = if (response.audioUrl.startsWith("http")) {
|
||||
response.audioUrl
|
||||
|
||||
@@ -35,4 +35,13 @@ class NotificationRepository @Inject constructor(
|
||||
suspend fun markAsRead(notificationId: String) {
|
||||
apiService.markNotificationRead(notificationId)
|
||||
}
|
||||
|
||||
suspend fun markAllRead() {
|
||||
try {
|
||||
val notifications = apiService.getNotifications(unreadOnly = true, limit = 100, offset = 0)
|
||||
notifications.forEach { apiService.markNotificationRead(it.id) }
|
||||
} catch (_: Exception) {
|
||||
// Silently ignore errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,5 +9,6 @@ data class Agent(
|
||||
val version: Int = 1,
|
||||
val createdAt: String = "",
|
||||
val updatedAt: String = "",
|
||||
val avatar: String? = null // emoji or image URL
|
||||
val avatar: String? = null, // emoji or image URL
|
||||
val showThinking: Boolean = true
|
||||
)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.tiangong.aiagent.domain.model
|
||||
|
||||
data class IntimacyStats(
|
||||
val totalMessages: Int = 0,
|
||||
val consecutiveDays: Int = 0,
|
||||
val lastActiveDate: String = "",
|
||||
val level: Int = 1
|
||||
) {
|
||||
companion object {
|
||||
fun computeLevel(totalMessages: Int, consecutiveDays: Int): Int {
|
||||
return minOf(10, (totalMessages / 50) + (consecutiveDays / 3) + 1)
|
||||
}
|
||||
|
||||
fun levelName(level: Int): String = when (level) {
|
||||
in 1..2 -> "初识"
|
||||
in 3..4 -> "熟悉"
|
||||
in 5..7 -> "亲密"
|
||||
in 8..9 -> "挚爱"
|
||||
10 -> "灵魂伴侣"
|
||||
else -> "初识"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,21 @@ import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
@@ -28,21 +38,25 @@ import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import com.tiangong.aiagent.ui.chat.components.ChatTimestampDivider
|
||||
import com.tiangong.aiagent.ui.chat.components.MessageBubble
|
||||
import com.tiangong.aiagent.ui.chat.components.NavigationDrawerContent
|
||||
import com.tiangong.aiagent.ui.chat.components.StreamingText
|
||||
import com.tiangong.aiagent.ui.chat.components.ThinkTracePanel
|
||||
import com.tiangong.aiagent.ui.chat.components.TypingIndicator
|
||||
import com.tiangong.aiagent.ui.chat.components.ToolCallCard
|
||||
import com.tiangong.aiagent.ui.chat.components.VoiceInputButton
|
||||
import com.tiangong.aiagent.ui.chat.components.shouldShowDivider
|
||||
import com.tiangong.aiagent.ui.common.SkeletonChat
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
onNavigateToAgents: () -> Unit,
|
||||
@@ -87,6 +101,25 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Share exported conversation
|
||||
val shareContext = androidx.compose.ui.platform.LocalContext.current
|
||||
LaunchedEffect(uiState.exportedFile) {
|
||||
uiState.exportedFile?.let { file ->
|
||||
val uri = androidx.core.content.FileProvider.getUriForFile(
|
||||
shareContext,
|
||||
"${shareContext.packageName}.fileprovider",
|
||||
file
|
||||
)
|
||||
val shareIntent = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
|
||||
type = "image/png"
|
||||
putExtra(android.content.Intent.EXTRA_STREAM, uri)
|
||||
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
shareContext.startActivity(android.content.Intent.createChooser(shareIntent, "分享对话"))
|
||||
viewModel.clearExportedFile()
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh unread count on resume (return from notifications, app foreground, etc.)
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
@@ -191,6 +224,7 @@ fun ChatScreen(
|
||||
NavigationDrawerContent(
|
||||
currentAgent = uiState.currentAgent,
|
||||
recentConversations = uiState.recentConversations,
|
||||
username = uiState.username,
|
||||
onConversationSelected = { sessionId ->
|
||||
viewModel.loadConversation(sessionId)
|
||||
},
|
||||
@@ -227,12 +261,45 @@ fun ChatScreen(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = {
|
||||
scope.launch {
|
||||
if (drawerState.isClosed) drawerState.open() else drawerState.close()
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// User avatar
|
||||
val userInitial = uiState.username.firstOrNull()?.uppercase() ?: ""
|
||||
Surface(
|
||||
modifier = Modifier.size(32.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
if (userInitial.isNotEmpty()) {
|
||||
Text(
|
||||
text = userInitial,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
Icons.Default.Person,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onTertiaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
IconButton(onClick = {
|
||||
scope.launch {
|
||||
if (drawerState.isClosed) {
|
||||
viewModel.markNotificationsRead()
|
||||
drawerState.open()
|
||||
} else {
|
||||
drawerState.close()
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.Default.Menu, contentDescription = "菜单")
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.Default.Menu, contentDescription = "菜单")
|
||||
}
|
||||
},
|
||||
title = {
|
||||
@@ -247,6 +314,10 @@ fun ChatScreen(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
// Share/export button
|
||||
IconButton(onClick = { viewModel.exportConversation() }) {
|
||||
Icon(Icons.Default.Share, contentDescription = "分享对话")
|
||||
}
|
||||
IconButton(onClick = onNavigateToNotifications) {
|
||||
if (uiState.unreadCount > 0) {
|
||||
BadgedBox(badge = {
|
||||
@@ -345,14 +416,16 @@ fun ChatScreen(
|
||||
maxLines = 8,
|
||||
enabled = !uiState.isLoading && uiState.currentAgent != null,
|
||||
supportingText = {
|
||||
Text(
|
||||
"${inputText.length}/2000",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (inputText.length >= 1800)
|
||||
MaterialTheme.colorScheme.error
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
if (inputText.length >= 1800) {
|
||||
Text(
|
||||
"${inputText.length}/2000",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (inputText.length >= 1900)
|
||||
MaterialTheme.colorScheme.error
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
@@ -376,15 +449,22 @@ fun ChatScreen(
|
||||
val sendEnabled = inputText.isNotBlank() && !uiState.isLoading && !uiState.isOffline && uiState.currentAgent != null
|
||||
val sendColor by animateColorAsState(
|
||||
targetValue = if (sendEnabled)
|
||||
MaterialTheme.colorScheme.primary
|
||||
androidx.compose.ui.graphics.Color(0xFFE91E63)
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
|
||||
animationSpec = tween(200),
|
||||
label = "sendColor"
|
||||
)
|
||||
var sendPressed by remember { mutableStateOf(false) }
|
||||
val sendScale by animateFloatAsState(
|
||||
targetValue = if (sendPressed) 0.85f else 1f,
|
||||
animationSpec = tween(100),
|
||||
label = "sendScale"
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (inputText.isNotBlank()) {
|
||||
sendPressed = true
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
val text = inputText
|
||||
inputText = ""
|
||||
@@ -393,9 +473,11 @@ fun ChatScreen(
|
||||
} else {
|
||||
viewModel.sendMessage(text)
|
||||
}
|
||||
scope.launch { sendPressed = false }
|
||||
}
|
||||
},
|
||||
enabled = sendEnabled
|
||||
enabled = sendEnabled,
|
||||
modifier = Modifier.scale(sendScale)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
@@ -501,8 +583,14 @@ fun ChatScreen(
|
||||
Text(
|
||||
"正在重新连接... (${rs.attempt}/${rs.maxAttempts})",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
TextButton(onClick = {
|
||||
viewModel.retryLastMessage()
|
||||
}) {
|
||||
Text("重试", style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -575,11 +663,40 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
// ── Message list ───────────────────────────────────────────
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize().weight(1f),
|
||||
contentPadding = PaddingValues(vertical = 8.dp)
|
||||
val bgColor = when (uiState.chatBackground) {
|
||||
"misty_rose" -> androidx.compose.ui.graphics.Color(0xFFFFE4E1)
|
||||
"lavender" -> androidx.compose.ui.graphics.Color(0xFFE6E6FA)
|
||||
"light_yellow" -> androidx.compose.ui.graphics.Color(0xFFFFF8DC)
|
||||
"light_green" -> androidx.compose.ui.graphics.Color(0xFFE8F5E9)
|
||||
"night" -> androidx.compose.ui.graphics.Color(0xFF1A1A2E)
|
||||
else -> MaterialTheme.colorScheme.surface
|
||||
}
|
||||
val isImageBg = uiState.chatBackgroundType == "image" && uiState.chatBackgroundImage.isNotBlank()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.weight(1f)
|
||||
) {
|
||||
// Image background
|
||||
if (isImageBg) {
|
||||
coil.compose.AsyncImage(
|
||||
model = uiState.chatBackgroundImage,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(
|
||||
if (isImageBg)
|
||||
Modifier.background(bgColor.copy(alpha = 0.85f))
|
||||
else
|
||||
Modifier.background(bgColor)
|
||||
),
|
||||
contentPadding = PaddingValues(vertical = 8.dp)
|
||||
) {
|
||||
// Loading indicator at top (pull-to-load more history)
|
||||
if (uiState.isLoadingMore) {
|
||||
item(key = "loading_more") {
|
||||
@@ -648,7 +765,12 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
items(uiState.messages, key = { it.id }) { message ->
|
||||
itemsIndexed(uiState.messages, key = { _, msg -> msg.id }) { index, message ->
|
||||
// Show timestamp divider when gap > 5 minutes from previous message
|
||||
val prevCreatedAt = if (index > 0) uiState.messages[index - 1].createdAt else null
|
||||
if (shouldShowDivider(prevCreatedAt, message.createdAt)) {
|
||||
ChatTimestampDivider(timestamp = message.createdAt)
|
||||
}
|
||||
if (message.role == com.tiangong.aiagent.domain.model.Message.Role.TOOL) {
|
||||
ToolCallCard(
|
||||
toolName = message.toolName ?: "",
|
||||
@@ -657,197 +779,101 @@ fun ChatScreen(
|
||||
success = true
|
||||
)
|
||||
} else {
|
||||
Column {
|
||||
// Message bubble with feedback for completed assistant msgs
|
||||
val showFeedback = message.role == com.tiangong.aiagent.domain.model.Message.Role.ASSISTANT
|
||||
&& !message.isStreaming
|
||||
&& message.content.isNotEmpty()
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val shareContext = androidx.compose.ui.platform.LocalContext.current
|
||||
val showFeedback = message.role == com.tiangong.aiagent.domain.model.Message.Role.ASSISTANT
|
||||
&& !message.isStreaming
|
||||
&& message.content.isNotEmpty()
|
||||
val isLastAssistant = showFeedback &&
|
||||
message.id == uiState.messages.lastOrNull { it.role == com.tiangong.aiagent.domain.model.Message.Role.ASSISTANT }?.id
|
||||
|
||||
val isLastAssistant = showFeedback &&
|
||||
message.id == uiState.messages.lastOrNull { it.role == com.tiangong.aiagent.domain.model.Message.Role.ASSISTANT }?.id
|
||||
|
||||
if (showFeedback) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
Column {
|
||||
MessageBubbleFromUi(
|
||||
message = message,
|
||||
agentName = uiState.currentAgent?.name ?: "助手",
|
||||
agentAvatar = uiState.currentAgent?.avatar
|
||||
)
|
||||
Box(modifier = Modifier.animateItemPlacement(tween(200))) {
|
||||
Column(modifier = Modifier.combinedClickable(
|
||||
onClick = {},
|
||||
onLongClick = { menuExpanded = true }
|
||||
)) {
|
||||
MessageBubbleFromUi(
|
||||
message = message,
|
||||
agentName = uiState.currentAgent?.name ?: "助手",
|
||||
agentAvatar = uiState.currentAgent?.avatar,
|
||||
username = uiState.username,
|
||||
fontSize = uiState.fontSize,
|
||||
isSpeaking = uiState.speakingMessageId == message.id
|
||||
)
|
||||
// Minimal inline indicator: show feedback status only
|
||||
if (showFeedback && (message.feedbackRating != null || message.feedbackComment != null)) {
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 56.dp, bottom = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// v1.2.0: Edit (only for USER messages)
|
||||
if (message.role == com.tiangong.aiagent.domain.model.Message.Role.USER) {
|
||||
var showEditMenu by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
IconButton(
|
||||
onClick = { viewModel.startEditMessage(message.id) },
|
||||
modifier = Modifier.size(28.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Create,
|
||||
contentDescription = "编辑",
|
||||
modifier = Modifier.size(15.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
if (message.feedbackRating == FeedbackRating.LIKE) {
|
||||
Icon(Icons.Filled.ThumbUp, null, Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.primary)
|
||||
} else if (message.feedbackRating == FeedbackRating.DISLIKE) {
|
||||
Icon(Icons.Filled.ThumbDown, null, Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
|
||||
// Copy
|
||||
IconButton(
|
||||
onClick = { clipboardManager.setText(AnnotatedString(message.content)) },
|
||||
modifier = Modifier.size(28.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ContentCopy,
|
||||
contentDescription = "复制",
|
||||
modifier = Modifier.size(15.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
// Share
|
||||
val shareContext = androidx.compose.ui.platform.LocalContext.current
|
||||
IconButton(
|
||||
onClick = {
|
||||
val shareIntent = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(android.content.Intent.EXTRA_TEXT, message.content)
|
||||
}
|
||||
shareContext.startActivity(
|
||||
android.content.Intent.createChooser(shareIntent, "分享消息")
|
||||
)
|
||||
},
|
||||
modifier = Modifier.size(28.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Share,
|
||||
contentDescription = "分享",
|
||||
modifier = Modifier.size(15.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
// Read aloud
|
||||
IconButton(
|
||||
onClick = { viewModel.speakText(message.content) },
|
||||
modifier = Modifier.size(28.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.VolumeUp,
|
||||
contentDescription = "朗读",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
// Like
|
||||
IconButton(
|
||||
onClick = { viewModel.submitFeedback(message.id, FeedbackRating.LIKE) },
|
||||
modifier = Modifier.size(28.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (message.feedbackRating == FeedbackRating.LIKE)
|
||||
Icons.Filled.ThumbUp else Icons.Outlined.ThumbUp,
|
||||
contentDescription = "赞",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = if (message.feedbackRating == FeedbackRating.LIKE)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = { viewModel.submitFeedback(message.id, FeedbackRating.DISLIKE) },
|
||||
modifier = Modifier.size(28.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (message.feedbackRating == FeedbackRating.DISLIKE)
|
||||
Icons.Filled.ThumbDown else Icons.Outlined.ThumbDown,
|
||||
contentDescription = "踩",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = if (message.feedbackRating == FeedbackRating.DISLIKE)
|
||||
MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// v1.2.0: Regenerate (only for last assistant message)
|
||||
if (isLastAssistant) {
|
||||
IconButton(
|
||||
onClick = { viewModel.regenerateLast() },
|
||||
modifier = Modifier.size(28.dp),
|
||||
enabled = !uiState.isStreaming
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Refresh,
|
||||
contentDescription = "重新生成",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = if (!uiState.isStreaming)
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
message.feedbackComment?.let { comment ->
|
||||
Text(
|
||||
text = comment,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
modifier = Modifier.align(Alignment.CenterVertically)
|
||||
)
|
||||
Text(comment, style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// User message: show bubble with copy & share actions
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
Column {
|
||||
MessageBubbleFromUi(
|
||||
message = message,
|
||||
agentName = uiState.currentAgent?.name ?: "助手",
|
||||
agentAvatar = uiState.currentAgent?.avatar
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
onDismissRequest = { menuExpanded = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("复制") },
|
||||
onClick = { menuExpanded = false; clipboardManager.setText(AnnotatedString(message.content)) },
|
||||
leadingIcon = { Icon(Icons.Default.ContentCopy, null, Modifier.size(18.dp)) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("朗读") },
|
||||
onClick = { menuExpanded = false; viewModel.speakText(message.content) },
|
||||
leadingIcon = { Icon(Icons.AutoMirrored.Filled.VolumeUp, null, Modifier.size(18.dp)) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("分享") },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
val intent = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(android.content.Intent.EXTRA_TEXT, message.content)
|
||||
}
|
||||
shareContext.startActivity(android.content.Intent.createChooser(intent, "分享消息"))
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Default.Share, null, Modifier.size(18.dp)) }
|
||||
)
|
||||
if (message.role == com.tiangong.aiagent.domain.model.Message.Role.USER) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("编辑") },
|
||||
onClick = { menuExpanded = false; viewModel.startEditMessage(message.id) },
|
||||
leadingIcon = { Icon(Icons.Default.Create, null, Modifier.size(18.dp)) }
|
||||
)
|
||||
}
|
||||
if (showFeedback) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("赞") },
|
||||
onClick = { menuExpanded = false; viewModel.submitFeedback(message.id, FeedbackRating.LIKE) },
|
||||
leadingIcon = { Icon(Icons.Outlined.ThumbUp, null, Modifier.size(18.dp)) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("踩") },
|
||||
onClick = { menuExpanded = false; viewModel.submitFeedback(message.id, FeedbackRating.DISLIKE) },
|
||||
leadingIcon = { Icon(Icons.Outlined.ThumbDown, null, Modifier.size(18.dp)) }
|
||||
)
|
||||
}
|
||||
if (isLastAssistant) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("重新生成") },
|
||||
onClick = { menuExpanded = false; viewModel.regenerateLast() },
|
||||
leadingIcon = { Icon(Icons.Default.Refresh, null, Modifier.size(18.dp)) }
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.padding(end = 16.dp, bottom = 4.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Copy
|
||||
IconButton(
|
||||
onClick = { clipboardManager.setText(AnnotatedString(message.content)) },
|
||||
modifier = Modifier.size(28.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ContentCopy,
|
||||
contentDescription = "复制",
|
||||
modifier = Modifier.size(15.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
// Share
|
||||
val shareContext = androidx.compose.ui.platform.LocalContext.current
|
||||
IconButton(
|
||||
onClick = {
|
||||
val shareIntent = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(android.content.Intent.EXTRA_TEXT, message.content)
|
||||
}
|
||||
shareContext.startActivity(
|
||||
android.content.Intent.createChooser(shareIntent, "分享消息")
|
||||
)
|
||||
},
|
||||
modifier = Modifier.size(28.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Share,
|
||||
contentDescription = "分享",
|
||||
modifier = Modifier.size(15.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -855,9 +881,12 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
// v1.1.0: Think trace panel (collapsible reasoning visualization)
|
||||
if (uiState.thinkTraces.isNotEmpty()) {
|
||||
if (uiState.thinkTraces.isNotEmpty() && uiState.currentAgent?.showThinking != false) {
|
||||
item {
|
||||
ThinkTracePanel(traces = uiState.thinkTraces)
|
||||
ThinkTracePanel(
|
||||
traces = uiState.thinkTraces,
|
||||
showIterationLabel = uiState.currentAgent?.showThinking ?: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -877,9 +906,14 @@ fun ChatScreen(
|
||||
item { StreamingText(content = uiState.streamingContent) }
|
||||
}
|
||||
|
||||
// Loading indicator
|
||||
if (uiState.isLoading && uiState.streamingContent.isEmpty()) {
|
||||
item { StreamingText(content = "") }
|
||||
// Typing indicator when waiting for AI response
|
||||
if (uiState.isStreaming && uiState.streamingContent.isEmpty()) {
|
||||
item(key = "typing_indicator") {
|
||||
TypingIndicator(
|
||||
agentName = uiState.currentAgent?.name ?: "AI",
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// v1.1.0: Retry button for failed last message
|
||||
@@ -911,6 +945,7 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
} // Box
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -921,13 +956,30 @@ fun ChatScreen(
|
||||
private fun MessageBubbleFromUi(
|
||||
message: UiMessage,
|
||||
agentName: String = "助手",
|
||||
agentAvatar: String? = null
|
||||
agentAvatar: String? = null,
|
||||
username: String = "",
|
||||
fontSize: String = "normal",
|
||||
isSpeaking: Boolean = false
|
||||
) {
|
||||
com.tiangong.aiagent.ui.chat.components.MessageBubble(
|
||||
message = message.toDomainMessage(),
|
||||
agentName = agentName,
|
||||
agentAvatar = agentAvatar
|
||||
)
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) { visible = true }
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = slideInVertically(initialOffsetY = { it / 4 }) + fadeIn(animationSpec = tween(300)),
|
||||
exit = ExitTransition.None
|
||||
) {
|
||||
com.tiangong.aiagent.ui.chat.components.MessageBubble(
|
||||
message = message.toDomainMessage(),
|
||||
agentName = agentName,
|
||||
agentAvatar = agentAvatar,
|
||||
username = username,
|
||||
isSending = message.sendState == SendState.SENDING,
|
||||
isSendFailed = message.sendState == SendState.FAILED,
|
||||
fontSize = fontSize,
|
||||
isSpeaking = isSpeaking
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun UiMessage.toDomainMessage(): com.tiangong.aiagent.domain.model.Message {
|
||||
|
||||
@@ -6,22 +6,28 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.google.gson.Gson
|
||||
import com.tiangong.aiagent.data.local.TokenDataStore
|
||||
import com.tiangong.aiagent.data.remote.ApiService
|
||||
import com.tiangong.aiagent.data.repository.AgentRepository
|
||||
import com.tiangong.aiagent.data.repository.ChatRepository
|
||||
import com.tiangong.aiagent.data.repository.FeedbackRepository
|
||||
import com.tiangong.aiagent.data.repository.NotificationRepository
|
||||
import com.tiangong.aiagent.domain.model.Agent
|
||||
import com.tiangong.aiagent.domain.model.IntimacyStats
|
||||
import com.tiangong.aiagent.domain.model.Message
|
||||
import com.tiangong.aiagent.domain.model.SseEvent
|
||||
import com.tiangong.aiagent.util.AudioPlayer
|
||||
import com.tiangong.aiagent.util.AudioRecorder
|
||||
import com.tiangong.aiagent.util.PlaybackState
|
||||
import com.tiangong.aiagent.util.MarkdownRenderer
|
||||
import com.tiangong.aiagent.util.OfflineQueueManager
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import com.tiangong.aiagent.util.AnalyticsTracker
|
||||
import com.tiangong.aiagent.util.ConversationExporter
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
@@ -70,6 +76,17 @@ data class ChatUiState(
|
||||
// Offline queue count
|
||||
val pendingQueueCount: Int = 0,
|
||||
|
||||
// Chat background
|
||||
val chatBackground: String = "default",
|
||||
val chatBackgroundType: String = "color",
|
||||
val chatBackgroundImage: String = "",
|
||||
|
||||
// User display name
|
||||
val username: String = "",
|
||||
|
||||
// Font size preference
|
||||
val fontSize: String = "normal",
|
||||
|
||||
// Think trace entries (v1.1.0)
|
||||
val thinkTraces: List<ThinkTraceEntry> = emptyList(),
|
||||
|
||||
@@ -78,7 +95,13 @@ data class ChatUiState(
|
||||
val hasMoreMessages: Boolean = true,
|
||||
|
||||
// Message queue during streaming (v1.3.0)
|
||||
val pendingMessage: String? = null
|
||||
val pendingMessage: String? = null,
|
||||
|
||||
// Currently speaking message ID for waveform animation
|
||||
val speakingMessageId: String? = null,
|
||||
|
||||
// Exported conversation file for sharing
|
||||
val exportedFile: File? = null
|
||||
)
|
||||
|
||||
data class ThinkTraceEntry(
|
||||
@@ -86,6 +109,8 @@ data class ThinkTraceEntry(
|
||||
val content: String
|
||||
)
|
||||
|
||||
enum class SendState { NONE, SENDING, SENT, FAILED }
|
||||
|
||||
/** UI wrapper for a domain Message with feedback state. */
|
||||
data class UiMessage(
|
||||
val id: String,
|
||||
@@ -98,6 +123,7 @@ data class UiMessage(
|
||||
val isStreaming: Boolean = false,
|
||||
val feedbackRating: FeedbackRating? = null,
|
||||
val feedbackComment: String? = null,
|
||||
val sendState: SendState = SendState.NONE,
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@@ -115,6 +141,7 @@ class ChatViewModel @Inject constructor(
|
||||
private val agentRepository: AgentRepository,
|
||||
private val feedbackRepository: FeedbackRepository,
|
||||
private val notificationRepository: NotificationRepository,
|
||||
private val apiService: ApiService,
|
||||
val audioRecorder: AudioRecorder,
|
||||
private val audioPlayer: AudioPlayer,
|
||||
private val tokenDataStore: TokenDataStore,
|
||||
@@ -122,7 +149,8 @@ class ChatViewModel @Inject constructor(
|
||||
private val analyticsTracker: AnalyticsTracker,
|
||||
private val gson: Gson,
|
||||
private val networkMonitor: com.tiangong.aiagent.util.NetworkMonitor,
|
||||
private val offlineQueueManager: OfflineQueueManager
|
||||
private val offlineQueueManager: OfflineQueueManager,
|
||||
@ApplicationContext private val context: android.content.Context
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(ChatUiState())
|
||||
@@ -132,6 +160,10 @@ class ChatViewModel @Inject constructor(
|
||||
private var historyFlowJob: Job? = null
|
||||
private var isLoadingHistory = false
|
||||
|
||||
// SSE timeout detection
|
||||
private var lastSseEventTime = 0L
|
||||
private var timeoutCheckJob: Job? = null
|
||||
|
||||
companion object {
|
||||
private const val KEY_SESSION_STATE = "chat_session_state"
|
||||
}
|
||||
@@ -156,6 +188,132 @@ class ChatViewModel @Inject constructor(
|
||||
_uiState.value = _uiState.value.copy(pendingQueueCount = count)
|
||||
}
|
||||
}
|
||||
|
||||
// Observe chat background preference
|
||||
viewModelScope.launch {
|
||||
tokenDataStore.chatBackground.collect { color ->
|
||||
_uiState.value = _uiState.value.copy(chatBackground = color)
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
tokenDataStore.chatBackgroundType.collect { type ->
|
||||
_uiState.value = _uiState.value.copy(chatBackgroundType = type)
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
tokenDataStore.chatBackgroundImage.collect { image ->
|
||||
_uiState.value = _uiState.value.copy(chatBackgroundImage = image)
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
tokenDataStore.username.collect { name ->
|
||||
_uiState.value = _uiState.value.copy(username = name)
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
tokenDataStore.fontSize.collect { size ->
|
||||
_uiState.value = _uiState.value.copy(fontSize = size)
|
||||
}
|
||||
}
|
||||
|
||||
// Observe audio playback state for waveform animation
|
||||
viewModelScope.launch {
|
||||
audioPlayer.playbackState.collect { state ->
|
||||
if (state == PlaybackState.IDLE || state == PlaybackState.ERROR) {
|
||||
_uiState.value = _uiState.value.copy(speakingMessageId = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadUsername()
|
||||
}
|
||||
|
||||
private fun trackIntimacy(agentId: String) {
|
||||
viewModelScope.launch {
|
||||
val todayStr = java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.getDefault())
|
||||
.format(java.util.Date())
|
||||
tokenDataStore.updateIntimacy(agentId) { stats ->
|
||||
val newConsecutive = if (stats.lastActiveDate == todayStr) {
|
||||
stats.consecutiveDays
|
||||
} else {
|
||||
stats.consecutiveDays + 1
|
||||
}
|
||||
stats.copy(
|
||||
totalMessages = stats.totalMessages + 1,
|
||||
consecutiveDays = newConsecutive,
|
||||
lastActiveDate = todayStr,
|
||||
level = IntimacyStats.computeLevel(stats.totalMessages + 1, newConsecutive)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendIcebreakerGreeting() {
|
||||
viewModelScope.launch {
|
||||
val agent = _uiState.value.currentAgent ?: return@launch
|
||||
val agentId = agent.id
|
||||
val name = agent.name
|
||||
val desc = agent.description.lowercase()
|
||||
|
||||
// Detect companion type from description
|
||||
val isCompanion = desc.contains("女友") || desc.contains("女朋友") ||
|
||||
desc.contains("girlfriend") || desc.contains("wife") ||
|
||||
desc.contains("wifey") || desc.contains("男友") ||
|
||||
desc.contains("男朋友") || desc.contains("boyfriend") ||
|
||||
desc.contains("husband") || desc.contains("伴侣") ||
|
||||
desc.contains("companion") || desc.contains("陪聊")
|
||||
|
||||
if (!isCompanion) return@launch
|
||||
|
||||
// Already onboarded?
|
||||
if (tokenDataStore.isCompanionOnboarded(agentId)) return@launch
|
||||
tokenDataStore.setCompanionOnboarded(agentId)
|
||||
|
||||
val greetings = mutableListOf<String>()
|
||||
if (desc.contains("女友") || desc.contains("女朋友") || desc.contains("girlfriend") || desc.contains("wife") || desc.contains("wifey")) {
|
||||
greetings.add("嗨,终于等到你了~ 今天过得怎么样呀?")
|
||||
greetings.add("我叫${name},很高兴认识你!你想聊些什么呢?")
|
||||
} else if (desc.contains("男友") || desc.contains("男朋友") || desc.contains("boyfriend") || desc.contains("husband")) {
|
||||
greetings.add("嘿,终于等到你了!最近还好吗?")
|
||||
greetings.add("我是${name},以后就让我陪着你吧。想聊点什么?")
|
||||
} else {
|
||||
greetings.add("你好呀,我是${name},你的专属AI伙伴。")
|
||||
greetings.add("有什么想和我分享的吗?我会一直在这里听你说~")
|
||||
}
|
||||
|
||||
val sessionId = _uiState.value.sessionId ?: java.util.UUID.randomUUID().toString()
|
||||
val msgs = _uiState.value.messages.toMutableList()
|
||||
|
||||
for ((i, text) in greetings.withIndex()) {
|
||||
kotlinx.coroutines.delay(800L * (i + 1))
|
||||
val msg = UiMessage(
|
||||
id = "icebreaker_${System.currentTimeMillis()}_$i",
|
||||
conversationId = sessionId,
|
||||
role = Message.Role.ASSISTANT,
|
||||
content = text
|
||||
)
|
||||
msgs.add(msg)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
messages = msgs.toList(),
|
||||
sessionId = sessionId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadUsername() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val user = apiService.getCurrentUser()
|
||||
tokenDataStore.saveUsername(user.username)
|
||||
} catch (_: Exception) {
|
||||
// Keep cached username from DataStore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun restoreSession() {
|
||||
@@ -210,6 +368,13 @@ class ChatViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun markNotificationsRead() {
|
||||
viewModelScope.launch {
|
||||
notificationRepository.markAllRead()
|
||||
_uiState.value = _uiState.value.copy(unreadCount = 0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadAgents() {
|
||||
viewModelScope.launch {
|
||||
agentRepository.getAgents().fold(
|
||||
@@ -296,6 +461,11 @@ class ChatViewModel @Inject constructor(
|
||||
thinkTraces = emptyList()
|
||||
)
|
||||
loadConversationHistory(agent.id)
|
||||
// Wait a moment for history to load, then fire icebreaker if no messages
|
||||
kotlinx.coroutines.delay(500)
|
||||
if (_uiState.value.messages.isEmpty()) {
|
||||
sendIcebreakerGreeting()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,6 +618,9 @@ class ChatViewModel @Inject constructor(
|
||||
// Analytics: track message send
|
||||
analyticsTracker.trackEvent("chat", "message_send", agentId = agentId)
|
||||
|
||||
// Track intimacy
|
||||
trackIntimacy(agentId)
|
||||
|
||||
// v1.2.0: Queue message when offline
|
||||
if (_uiState.value.isOffline) {
|
||||
val userMsg = UiMessage(
|
||||
@@ -474,7 +647,8 @@ class ChatViewModel @Inject constructor(
|
||||
id = "user_${System.currentTimeMillis()}",
|
||||
conversationId = sessionId,
|
||||
role = Message.Role.USER,
|
||||
content = text
|
||||
content = text,
|
||||
sendState = SendState.SENDING
|
||||
)
|
||||
|
||||
val currentMessages = _uiState.value.messages.toMutableList()
|
||||
@@ -513,19 +687,56 @@ class ChatViewModel @Inject constructor(
|
||||
|
||||
sseJob = viewModelScope.launch {
|
||||
var fullContent = ""
|
||||
var firstEvent = true
|
||||
lastSseEventTime = System.currentTimeMillis()
|
||||
|
||||
// Timeout checker: detect silent SSE connections
|
||||
timeoutCheckJob = viewModelScope.launch {
|
||||
while (true) {
|
||||
delay(3000)
|
||||
if (System.currentTimeMillis() - lastSseEventTime > 15_000) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
reconnectionState = ReconnectionState.Reconnecting(
|
||||
attempt = 1,
|
||||
maxAttempts = 5,
|
||||
nextRetrySec = 3
|
||||
),
|
||||
error = "网络不稳定,正在重连…"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chatRepository.chatStream(agentId, text, sessionId)
|
||||
.catch { e ->
|
||||
// Mark user message as failed on stream error
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
isStreaming = false,
|
||||
streamingContent = "",
|
||||
error = friendlyError(e),
|
||||
reconnectionState = ReconnectionState.Failed(friendlyError(e))
|
||||
reconnectionState = ReconnectionState.Failed(friendlyError(e)),
|
||||
messages = _uiState.value.messages.map { msg ->
|
||||
if (msg.id == userMsg.id && msg.sendState == SendState.SENDING)
|
||||
msg.copy(sendState = SendState.FAILED)
|
||||
else msg
|
||||
}
|
||||
)
|
||||
dispatchPendingMessage()
|
||||
}
|
||||
.collect { event ->
|
||||
lastSseEventTime = System.currentTimeMillis()
|
||||
if (firstEvent) {
|
||||
firstEvent = false
|
||||
// Mark user message as sent on first SSE event
|
||||
_uiState.value = _uiState.value.copy(
|
||||
messages = _uiState.value.messages.map { msg ->
|
||||
if (msg.id == userMsg.id && msg.sendState == SendState.SENDING)
|
||||
msg.copy(sendState = SendState.SENT)
|
||||
else msg
|
||||
}
|
||||
)
|
||||
}
|
||||
when (event) {
|
||||
is SseEvent.Message -> {
|
||||
fullContent += event.content
|
||||
@@ -603,7 +814,7 @@ class ChatViewModel @Inject constructor(
|
||||
role = "assistant", content = fullContent
|
||||
)
|
||||
}
|
||||
speakText(fullContent)
|
||||
speakText(fullContent, assistantMsgId)
|
||||
}
|
||||
is SseEvent.Done -> {
|
||||
tokenDataStore.saveLastSessionId(sessionId)
|
||||
@@ -775,13 +986,19 @@ class ChatViewModel @Inject constructor(
|
||||
|
||||
// ─── Voice & Image ───────────────────────────────────────────────────
|
||||
|
||||
fun speakText(text: String) {
|
||||
fun speakText(text: String, msgId: String? = null) {
|
||||
if (text.isBlank()) return
|
||||
val plainText = MarkdownRenderer.stripMarkdownForTTS(text)
|
||||
if (plainText.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
chatRepository.synthesizeSpeech(plainText)
|
||||
.onSuccess { audioUrl -> audioPlayer.play(audioUrl) }
|
||||
val agentVoice = _uiState.value.currentAgent?.id?.let { tokenDataStore.getAgentVoice(it) }
|
||||
chatRepository.synthesizeSpeech(plainText, voiceOverride = agentVoice)
|
||||
.onSuccess { audioUrl ->
|
||||
if (msgId != null) {
|
||||
_uiState.value = _uiState.value.copy(speakingMessageId = msgId)
|
||||
}
|
||||
audioPlayer.play(audioUrl)
|
||||
}
|
||||
.onFailure { Log.w("ChatViewModel", "TTS failed", it) }
|
||||
}
|
||||
}
|
||||
@@ -860,8 +1077,11 @@ class ChatViewModel @Inject constructor(
|
||||
// ─── v1.2.0: Stop Generation ──────────────────────────────────────
|
||||
|
||||
fun stopGeneration() {
|
||||
audioPlayer.stop()
|
||||
sseJob?.cancel()
|
||||
sseJob = null
|
||||
timeoutCheckJob?.cancel()
|
||||
timeoutCheckJob = null
|
||||
|
||||
// 保留已流式输出的内容到消息列表,避免半截内容丢失
|
||||
val state = _uiState.value
|
||||
@@ -999,9 +1219,33 @@ class ChatViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Conversation export ──────────────────────────────────────────
|
||||
|
||||
fun exportConversation(): Job {
|
||||
return viewModelScope.launch {
|
||||
val state = _uiState.value
|
||||
val agent = state.currentAgent ?: return@launch
|
||||
val file = ConversationExporter.exportToFile(
|
||||
messages = state.messages,
|
||||
agent = agent,
|
||||
context = context
|
||||
)
|
||||
if (file != null) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
exportedFile = file
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearExportedFile() {
|
||||
_uiState.value = _uiState.value.copy(exportedFile = null)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
sseJob?.cancel()
|
||||
timeoutCheckJob?.cancel()
|
||||
historyFlowJob?.cancel()
|
||||
audioPlayer.release()
|
||||
audioRecorder.cleanup()
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.tiangong.aiagent.ui.chat.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/** 5-minute gap between messages shows a divider */
|
||||
private const val DIVIDER_GAP_MS = 5 * 60 * 1000L
|
||||
|
||||
/** Accept raw epoch millis so it works with both UiMessage and Message. */
|
||||
fun shouldShowDivider(previousTimestamp: Long?, currentTimestamp: Long): Boolean {
|
||||
if (currentTimestamp <= 0) return false
|
||||
if (previousTimestamp == null || previousTimestamp <= 0) return true
|
||||
return (currentTimestamp - previousTimestamp) >= DIVIDER_GAP_MS
|
||||
}
|
||||
|
||||
/** Centered divider with formatted time/date label. */
|
||||
@Composable
|
||||
fun ChatTimestampDivider(timestamp: Long) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)
|
||||
)
|
||||
Text(
|
||||
text = formatDividerTime(timestamp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
)
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** today→"今天 14:30", yesterday→"昨天 09:15", this year→"03-05 14:30", older→"2024-03-05" */
|
||||
private fun formatDividerTime(epochMillis: Long): String {
|
||||
val cal = Calendar.getInstance()
|
||||
val today = Calendar.getInstance().apply {
|
||||
set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0)
|
||||
}
|
||||
val yesterday = Calendar.getInstance().apply { timeInMillis = today.timeInMillis - 86400_000L }
|
||||
cal.timeInMillis = epochMillis
|
||||
|
||||
val timeStr = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(epochMillis))
|
||||
return when {
|
||||
cal.timeInMillis >= today.timeInMillis -> "今天 $timeStr"
|
||||
cal.timeInMillis >= yesterday.timeInMillis -> "昨天 $timeStr"
|
||||
cal.get(Calendar.YEAR) == Calendar.getInstance().get(Calendar.YEAR) ->
|
||||
SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()).format(Date(epochMillis))
|
||||
else -> SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date(epochMillis))
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,25 @@
|
||||
package com.tiangong.aiagent.ui.chat.components
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ErrorOutline
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.SmartToy
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -17,27 +28,34 @@ import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.tiangong.aiagent.domain.model.Message
|
||||
import com.tiangong.aiagent.util.MarkdownRenderer
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun MessageBubble(
|
||||
message: Message,
|
||||
agentName: String = "助手",
|
||||
agentAvatar: String? = null
|
||||
agentAvatar: String? = null,
|
||||
username: String = "",
|
||||
isSending: Boolean = false,
|
||||
isSendFailed: Boolean = false,
|
||||
fontSize: String = "normal",
|
||||
isSpeaking: Boolean = false
|
||||
) {
|
||||
val isUser = message.role == Message.Role.USER
|
||||
val isTool = message.role == Message.Role.TOOL
|
||||
val alignment = if (isUser) Alignment.End else Alignment.Start
|
||||
val fontSizeScale = when (fontSize) {
|
||||
"small" -> 0.85f
|
||||
"large" -> 1.2f
|
||||
else -> 1f
|
||||
}
|
||||
val bubbleColor = when {
|
||||
isTool -> MaterialTheme.colorScheme.surfaceVariant
|
||||
isUser -> MaterialTheme.colorScheme.primary
|
||||
isUser -> MaterialTheme.colorScheme.tertiaryContainer
|
||||
else -> MaterialTheme.colorScheme.secondaryContainer
|
||||
}
|
||||
val contentColor = when {
|
||||
isTool -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
isUser -> MaterialTheme.colorScheme.onPrimary
|
||||
isUser -> MaterialTheme.colorScheme.onTertiaryContainer
|
||||
else -> MaterialTheme.colorScheme.onSecondaryContainer
|
||||
}
|
||||
|
||||
@@ -59,6 +77,28 @@ fun MessageBubble(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
if (isSpeaking) {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
SpeakingWaveform()
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
}
|
||||
|
||||
if (isUser) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = username.ifEmpty { "我" },
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
UserAvatar(username = username)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
}
|
||||
@@ -84,35 +124,36 @@ fun MessageBubble(
|
||||
text = message.content.ifEmpty { "..." },
|
||||
modifier = Modifier.padding(12.dp),
|
||||
color = contentColor,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontSize = (MaterialTheme.typography.bodyMedium.fontSize.value * fontSizeScale).sp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Timestamp
|
||||
if (message.createdAt > 0) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = formatTimestamp(message.createdAt),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
modifier = Modifier.padding(horizontal = 4.dp)
|
||||
)
|
||||
// Send state indicator for user messages
|
||||
if (isUser && (isSending || isSendFailed)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(end = 4.dp, top = 2.dp),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
if (isSending) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(12.dp),
|
||||
strokeWidth = 1.5.dp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
} else if (isSendFailed) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ErrorOutline,
|
||||
contentDescription = "发送失败",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatTimestamp(epochMillis: Long): String {
|
||||
val now = System.currentTimeMillis()
|
||||
val diff = now - epochMillis
|
||||
return when {
|
||||
diff < 60_000 -> "刚刚"
|
||||
diff < 3600_000 -> "${diff / 60_000} 分钟前"
|
||||
diff < 86400_000 -> "${diff / 3600_000} 小时前"
|
||||
else -> {
|
||||
val fmt = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault())
|
||||
fmt.format(Date(epochMillis))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,3 +193,59 @@ private fun AgentAvatar(agentAvatar: String?) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserAvatar(username: String) {
|
||||
val initial = if (username.isNotEmpty()) username.first().uppercase() else ""
|
||||
Surface(
|
||||
modifier = Modifier.size(24.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
if (initial.isNotEmpty()) {
|
||||
Text(
|
||||
text = initial,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Person,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.onTertiaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SpeakingWaveform() {
|
||||
val transition = rememberInfiniteTransition(label = "waveform")
|
||||
Row(
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.spacedBy(1.5.dp)
|
||||
) {
|
||||
for (i in 0 until 4) {
|
||||
val height by transition.animateFloat(
|
||||
initialValue = 4f,
|
||||
targetValue = 12f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 400, delayMillis = i * 120),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "bar_$i"
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(2.5.dp)
|
||||
.height(height.dp)
|
||||
.clip(RoundedCornerShape(1.5.dp))
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.6f))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.SmartToy
|
||||
import androidx.compose.material3.*
|
||||
@@ -26,6 +27,7 @@ import com.tiangong.aiagent.util.formatTimestamp
|
||||
fun NavigationDrawerContent(
|
||||
currentAgent: Agent?,
|
||||
recentConversations: List<Conversation>,
|
||||
username: String = "",
|
||||
onConversationSelected: (String) -> Unit,
|
||||
onHistoryClick: () -> Unit,
|
||||
onSettingsClick: () -> Unit,
|
||||
@@ -37,6 +39,50 @@ fun NavigationDrawerContent(
|
||||
) {
|
||||
var showLogoutDialog by remember { mutableStateOf(false) }
|
||||
|
||||
// User info section
|
||||
val userInitial = username.firstOrNull()?.uppercase() ?: ""
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(36.dp),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
if (userInitial.isNotEmpty()) {
|
||||
Text(
|
||||
text = userInitial,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Person,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onTertiaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(
|
||||
text = username.ifEmpty { "用户" },
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Agent info header
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
||||
@@ -26,7 +26,9 @@ import com.tiangong.aiagent.ui.chat.ThinkTraceEntry
|
||||
@Composable
|
||||
fun ThinkTracePanel(
|
||||
traces: List<ThinkTraceEntry>,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
label: String = "思考中…",
|
||||
showIterationLabel: Boolean = true
|
||||
) {
|
||||
if (traces.isEmpty()) return
|
||||
|
||||
@@ -57,7 +59,7 @@ fun ThinkTracePanel(
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = if (expanded) "推理过程" else "推理过程 (${traces.size} 步)",
|
||||
text = if (expanded) label else "$label (${traces.size} 步)",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
@@ -84,7 +86,8 @@ fun ThinkTracePanel(
|
||||
ThinkTraceItem(
|
||||
iteration = trace.iteration,
|
||||
content = trace.content,
|
||||
isLast = index == traces.lastIndex
|
||||
isLast = index == traces.lastIndex,
|
||||
showIterationLabel = showIterationLabel
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -97,7 +100,8 @@ fun ThinkTracePanel(
|
||||
private fun ThinkTraceItem(
|
||||
iteration: Int,
|
||||
content: String,
|
||||
isLast: Boolean
|
||||
isLast: Boolean,
|
||||
showIterationLabel: Boolean = true
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
// Vertical timeline indicator
|
||||
@@ -123,12 +127,15 @@ private fun ThinkTraceItem(
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "第 $iteration 轮思考",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
if (showIterationLabel) {
|
||||
Text(
|
||||
text = "第 $iteration 轮思考",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
}
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = content,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.tiangong.aiagent.ui.chat.components
|
||||
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.SmartToy
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
fun TypingIndicator(
|
||||
agentName: String = "AI",
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 12.dp),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SmartToy,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = agentName,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomEnd = 16.dp, bottomStart = 4.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(5.dp)
|
||||
) {
|
||||
repeat(3) { index ->
|
||||
TypingDot(delayMs = index * 200)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TypingDot(delayMs: Int) {
|
||||
val transition = rememberInfiniteTransition(label = "typing_$delayMs")
|
||||
val scale by transition.animateFloat(
|
||||
initialValue = 0.3f,
|
||||
targetValue = 1.0f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 600, delayMillis = delayMs),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "dot_scale_$delayMs"
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.scale(scale),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.4f)
|
||||
) {}
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
package com.tiangong.aiagent.ui.settings
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
@@ -24,6 +29,7 @@ import com.tiangong.aiagent.data.remote.ApiService
|
||||
import com.tiangong.aiagent.data.remote.DynamicUrlInterceptor
|
||||
import com.tiangong.aiagent.data.repository.AuthRepository
|
||||
import com.tiangong.aiagent.util.AppUpdateManager
|
||||
import coil.compose.AsyncImage
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -31,6 +37,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@@ -54,6 +61,21 @@ class SettingsViewModel @Inject constructor(
|
||||
val ttsVoice: StateFlow<String> = tokenDataStore.ttsVoice
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, "xiaoxiao")
|
||||
|
||||
val ttsSpeed: StateFlow<Float> = tokenDataStore.ttsSpeed
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, 1.0f)
|
||||
|
||||
val fontSize: StateFlow<String> = tokenDataStore.fontSize
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, "normal")
|
||||
|
||||
val chatBackground: StateFlow<String> = tokenDataStore.chatBackground
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, "default")
|
||||
|
||||
val chatBackgroundType: StateFlow<String> = tokenDataStore.chatBackgroundType
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, "color")
|
||||
|
||||
val chatBackgroundImage: StateFlow<String> = tokenDataStore.chatBackgroundImage
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, "")
|
||||
|
||||
val themeMode: StateFlow<String> = tokenDataStore.themeMode
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, "system")
|
||||
|
||||
@@ -119,6 +141,12 @@ class SettingsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun setTtsSpeed(speed: Float) {
|
||||
viewModelScope.launch {
|
||||
tokenDataStore.setTtsSpeed(speed)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveServerUrl(url: String) {
|
||||
viewModelScope.launch {
|
||||
tokenDataStore.saveServerUrl(url)
|
||||
@@ -126,6 +154,28 @@ class SettingsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun setFontSize(size: String) {
|
||||
viewModelScope.launch { tokenDataStore.setFontSize(size) }
|
||||
}
|
||||
|
||||
fun setChatBackground(color: String) {
|
||||
viewModelScope.launch { tokenDataStore.setChatBackground(color) }
|
||||
}
|
||||
|
||||
fun setChatBackgroundType(type: String) {
|
||||
viewModelScope.launch { tokenDataStore.setChatBackgroundType(type) }
|
||||
}
|
||||
|
||||
fun setChatBackgroundImage(uri: String) {
|
||||
viewModelScope.launch { tokenDataStore.setChatBackgroundImage(uri) }
|
||||
}
|
||||
|
||||
fun fontSizeLabel(size: String): String = when (size) {
|
||||
"small" -> "小"
|
||||
"large" -> "大"
|
||||
else -> "标准"
|
||||
}
|
||||
|
||||
fun cycleThemeMode() {
|
||||
viewModelScope.launch {
|
||||
val next = when (themeMode.value) {
|
||||
@@ -137,6 +187,17 @@ class SettingsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun chatBackgroundLabel(color: String): String = when (color) {
|
||||
"default" -> "默认"
|
||||
"misty_rose" -> "薄雾玫瑰"
|
||||
"lavender" -> "薰衣草"
|
||||
"light_yellow" -> "浅黄"
|
||||
"light_green" -> "浅绿"
|
||||
"night" -> "深色"
|
||||
"image" -> "自定义图片"
|
||||
else -> "默认"
|
||||
}
|
||||
|
||||
fun themeModeLabel(mode: String): String = when (mode) {
|
||||
"light" -> "浅色模式"
|
||||
"dark" -> "深色模式"
|
||||
@@ -190,9 +251,61 @@ fun SettingsScreen(
|
||||
var editedUrl by remember { mutableStateOf("") }
|
||||
val ttsEnabled by viewModel.ttsEnabled.collectAsState()
|
||||
val ttsVoice by viewModel.ttsVoice.collectAsState()
|
||||
val ttsSpeed by viewModel.ttsSpeed.collectAsState()
|
||||
val fontSize by viewModel.fontSize.collectAsState()
|
||||
val chatBackground by viewModel.chatBackground.collectAsState()
|
||||
val chatBackgroundType by viewModel.chatBackgroundType.collectAsState()
|
||||
val chatBackgroundImage by viewModel.chatBackgroundImage.collectAsState()
|
||||
val themeMode by viewModel.themeMode.collectAsState()
|
||||
val serverUrl by viewModel.serverUrl.collectAsState()
|
||||
val username by viewModel.username.collectAsState()
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Image picker for custom chat background
|
||||
val bgImagePickerLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetContent()
|
||||
) { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
scope.launch {
|
||||
try {
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
val opts = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
android.graphics.BitmapFactory.decodeStream(inputStream, null, opts)
|
||||
inputStream?.close()
|
||||
|
||||
// Sample down to max 1920px
|
||||
val maxDim = 1920
|
||||
val scaleFactor = maxOf(
|
||||
(opts.outWidth / maxDim).coerceAtLeast(1),
|
||||
(opts.outHeight / maxDim).coerceAtLeast(1)
|
||||
)
|
||||
|
||||
val opts2 = android.graphics.BitmapFactory.Options().apply {
|
||||
inSampleSize = Integer.highestOneBit(scaleFactor)
|
||||
}
|
||||
val stream2 = context.contentResolver.openInputStream(uri)
|
||||
val bitmap = android.graphics.BitmapFactory.decodeStream(stream2, null, opts2)
|
||||
stream2?.close()
|
||||
|
||||
if (bitmap != null) {
|
||||
val bgDir = File(context.filesDir, "backgrounds")
|
||||
bgDir.mkdirs()
|
||||
val outFile = File(bgDir, "chat_bg.jpg")
|
||||
outFile.outputStream().use { out ->
|
||||
bitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, 85, out)
|
||||
}
|
||||
bitmap.recycle()
|
||||
viewModel.setChatBackgroundImage(outFile.absolutePath)
|
||||
viewModel.setChatBackgroundType("image")
|
||||
viewModel.setChatBackground("image")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// silently fail
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -282,6 +395,24 @@ fun SettingsScreen(
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
// TTS speed slider
|
||||
ListItem(
|
||||
headlineContent = { Text("语速调节") },
|
||||
supportingContent = { Text("${"%.1f".format(ttsSpeed)}x") },
|
||||
leadingContent = {
|
||||
Icon(Icons.Default.Speed, contentDescription = null)
|
||||
}
|
||||
)
|
||||
Slider(
|
||||
value = ttsSpeed,
|
||||
onValueChange = { viewModel.setTtsSpeed(it) },
|
||||
valueRange = 0.5f..2.0f,
|
||||
steps = 5,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("主题模式") },
|
||||
supportingContent = { Text(viewModel.themeModeLabel(themeMode)) },
|
||||
@@ -293,6 +424,208 @@ fun SettingsScreen(
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
// Font size selector
|
||||
var showFontSizeDialog by remember { mutableStateOf(false) }
|
||||
ListItem(
|
||||
headlineContent = { Text("字体大小") },
|
||||
supportingContent = { Text(viewModel.fontSizeLabel(fontSize)) },
|
||||
leadingContent = {
|
||||
Icon(Icons.Default.FormatSize, contentDescription = null)
|
||||
},
|
||||
modifier = Modifier.clickable { showFontSizeDialog = true }
|
||||
)
|
||||
|
||||
if (showFontSizeDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showFontSizeDialog = false },
|
||||
title = { Text("选择字体大小") },
|
||||
text = {
|
||||
Column {
|
||||
listOf(
|
||||
"small" to "小",
|
||||
"normal" to "标准",
|
||||
"large" to "大"
|
||||
).forEach { (key, label) ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
viewModel.setFontSize(key)
|
||||
showFontSizeDialog = false
|
||||
}
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = fontSize == key,
|
||||
onClick = {
|
||||
viewModel.setFontSize(key)
|
||||
showFontSizeDialog = false
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(label, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showFontSizeDialog = false }) { Text("关闭") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
// Chat background color selector
|
||||
var showBgColorDialog by remember { mutableStateOf(false) }
|
||||
ListItem(
|
||||
headlineContent = { Text("聊天背景") },
|
||||
supportingContent = { Text(viewModel.chatBackgroundLabel(chatBackground)) },
|
||||
leadingContent = {
|
||||
Icon(Icons.Default.Palette, contentDescription = null)
|
||||
},
|
||||
modifier = Modifier.clickable { showBgColorDialog = true }
|
||||
)
|
||||
|
||||
if (showBgColorDialog) {
|
||||
val bgColors = listOf(
|
||||
"default" to "默认",
|
||||
"misty_rose" to "薄雾玫瑰",
|
||||
"lavender" to "薰衣草",
|
||||
"light_yellow" to "浅黄",
|
||||
"light_green" to "浅绿",
|
||||
"night" to "深色"
|
||||
)
|
||||
AlertDialog(
|
||||
onDismissRequest = { showBgColorDialog = false },
|
||||
title = { Text("选择聊天背景") },
|
||||
text = {
|
||||
Column {
|
||||
// Color swatches grid
|
||||
bgColors.chunked(3).forEach { row ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
row.forEach { (key, label) ->
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clickable {
|
||||
viewModel.setChatBackground(key)
|
||||
viewModel.setChatBackgroundType("color")
|
||||
viewModel.setChatBackgroundImage("")
|
||||
showBgColorDialog = false
|
||||
}
|
||||
.padding(4.dp)
|
||||
) {
|
||||
val bgColor = when (key) {
|
||||
"default" -> MaterialTheme.colorScheme.surface
|
||||
"misty_rose" -> androidx.compose.ui.graphics.Color(0xFFFFE4E1)
|
||||
"lavender" -> androidx.compose.ui.graphics.Color(0xFFE6E6FA)
|
||||
"light_yellow" -> androidx.compose.ui.graphics.Color(0xFFFFF8DC)
|
||||
"light_green" -> androidx.compose.ui.graphics.Color(0xFFE8F5E9)
|
||||
"night" -> androidx.compose.ui.graphics.Color(0xFF1A1A2E)
|
||||
else -> MaterialTheme.colorScheme.surface
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier.size(56.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = bgColor,
|
||||
border = if (key == chatBackground && chatBackgroundType == "color")
|
||||
androidx.compose.foundation.BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
|
||||
else null
|
||||
) {}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(label, style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
// Custom image option
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
showBgColorDialog = false
|
||||
bgImagePickerLauncher.launch("image/*")
|
||||
}
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(48.dp),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant
|
||||
) {
|
||||
if (chatBackgroundType == "image" && chatBackgroundImage.isNotBlank()) {
|
||||
AsyncImage(
|
||||
model = chatBackgroundImage,
|
||||
contentDescription = null
|
||||
)
|
||||
} else {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
androidx.compose.material3.Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
"自定义图片",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
if (chatBackgroundType == "image" && chatBackgroundImage.isNotBlank()) {
|
||||
Text(
|
||||
"已设置",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
"从相册选择",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear image button
|
||||
if (chatBackgroundType == "image" && chatBackgroundImage.isNotBlank()) {
|
||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
viewModel.setChatBackground("default")
|
||||
viewModel.setChatBackgroundType("color")
|
||||
viewModel.setChatBackgroundImage("")
|
||||
showBgColorDialog = false
|
||||
}
|
||||
) {
|
||||
Text("清除自定义背景")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showBgColorDialog = false }) { Text("关闭") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
// v1.2.0: Notification preferences section
|
||||
SettingsSectionHeader("通知")
|
||||
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package com.tiangong.aiagent.util
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.view.View
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.tiangong.aiagent.domain.model.Agent
|
||||
import com.tiangong.aiagent.ui.chat.UiMessage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
object ConversationExporter {
|
||||
|
||||
private const val EXPORT_WIDTH = 1080
|
||||
private const val MAX_HEIGHT = 4000
|
||||
private const val MAX_MESSAGES = 20
|
||||
|
||||
suspend fun exportAsBitmap(
|
||||
messages: List<UiMessage>,
|
||||
agent: Agent,
|
||||
context: Context
|
||||
): Bitmap? = withContext(Dispatchers.Main) {
|
||||
val displayMessages = messages.takeLast(MAX_MESSAGES)
|
||||
if (displayMessages.isEmpty()) return@withContext null
|
||||
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val composeView = ComposeView(context)
|
||||
composeView.setContent {
|
||||
MaterialTheme {
|
||||
ShareCardContent(messages = displayMessages, agent = agent)
|
||||
}
|
||||
}
|
||||
|
||||
// Measure with exact width, wrap height
|
||||
val widthSpec = View.MeasureSpec.makeMeasureSpec(EXPORT_WIDTH, View.MeasureSpec.EXACTLY)
|
||||
val heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
|
||||
composeView.measure(widthSpec, heightSpec)
|
||||
|
||||
val measuredHeight = composeView.measuredHeight.coerceAtMost(MAX_HEIGHT)
|
||||
composeView.layout(0, 0, EXPORT_WIDTH, measuredHeight)
|
||||
|
||||
val bitmap = Bitmap.createBitmap(EXPORT_WIDTH, measuredHeight, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
canvas.drawColor(android.graphics.Color.WHITE)
|
||||
composeView.draw(canvas)
|
||||
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun exportToFile(
|
||||
messages: List<UiMessage>,
|
||||
agent: Agent,
|
||||
context: Context
|
||||
): File? {
|
||||
val bitmap = exportAsBitmap(messages, agent, context) ?: return null
|
||||
return withContext(Dispatchers.IO) {
|
||||
val dir = File(context.cacheDir, "exports")
|
||||
dir.mkdirs()
|
||||
val file = File(dir, "chat_export_${System.currentTimeMillis()}.png")
|
||||
FileOutputStream(file).use { out ->
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 90, out)
|
||||
}
|
||||
bitmap.recycle()
|
||||
file
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShareCardContent(
|
||||
messages: List<UiMessage>,
|
||||
agent: Agent
|
||||
) {
|
||||
val dateFormat = SimpleDateFormat("yyyy年MM月dd日 HH:mm", Locale.CHINESE)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(1080.dp)
|
||||
.background(androidx.compose.ui.graphics.Color.White)
|
||||
) {
|
||||
// Header
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = agent.name,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
if (agent.description.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = agent.description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "导出时间: ${dateFormat.format(Date())}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Messages
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
) {
|
||||
messages.forEach { msg ->
|
||||
val isUser = msg.role.name == "USER"
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp),
|
||||
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.widthIn(max = 800.dp),
|
||||
shape = RoundedCornerShape(
|
||||
topStart = 12.dp,
|
||||
topEnd = 12.dp,
|
||||
bottomStart = if (isUser) 12.dp else 2.dp,
|
||||
bottomEnd = if (isUser) 2.dp else 12.dp
|
||||
),
|
||||
color = if (isUser)
|
||||
MaterialTheme.colorScheme.tertiaryContainer
|
||||
else
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
) {
|
||||
Text(
|
||||
text = msg.content.ifEmpty { "..." },
|
||||
modifier = Modifier.padding(12.dp),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontSize = 14.sp
|
||||
),
|
||||
color = if (isUser)
|
||||
MaterialTheme.colorScheme.onTertiaryContainer
|
||||
else
|
||||
MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Footer
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "由 天工智能体 分享",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
@@ -22,6 +23,8 @@ import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import io.noties.markwon.Markwon
|
||||
import io.noties.markwon.image.coil.CoilImagesPlugin
|
||||
import io.noties.markwon.linkify.LinkifyPlugin
|
||||
@@ -219,6 +222,31 @@ object MarkdownRenderer {
|
||||
line.isBlank() -> {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
line.contains(" -> {
|
||||
// Render markdown images: 
|
||||
val imageRegex = Regex("""!\[.*?]\((.*?)\)""")
|
||||
imageRegex.findAll(line).forEach { match ->
|
||||
val imageUrl = match.groupValues[1]
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(
|
||||
androidx.compose.ui.platform.LocalContext.current
|
||||
)
|
||||
.data(imageUrl)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = "图片",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
contentScale = ContentScale.FillWidth
|
||||
)
|
||||
}
|
||||
// Also render any remaining text after stripping images
|
||||
val cleanLine = imageRegex.replace(line, "").trim()
|
||||
if (cleanLine.isNotEmpty()) {
|
||||
InlineFormattedText(cleanLine)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
InlineFormattedText(line)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user