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:
2026-08-05 10:41:25 +08:00
parent b20bafa598
commit 52d8dabf9b
16 changed files with 1536 additions and 266 deletions

View File

@@ -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 ->

View File

@@ -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
)
}

View File

@@ -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

View File

@@ -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
}
}
}

View File

@@ -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
)

View File

@@ -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 -> "初识"
}
}
}

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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))
}
}

View File

@@ -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))
)
}
}
}

View File

@@ -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(),

View File

@@ -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,

View File

@@ -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)
) {}
}

View File

@@ -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("通知")

View File

@@ -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
)
}
}
}

View File

@@ -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("![") && line.contains("](") -> {
// Render markdown images: ![alt](url)
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)
}