mirror of
https://github.com/google-ai-edge/gallery.git
synced 2025-07-16 11:16:43 -04:00
Merge 010601f4ad
into ebb605131d
This commit is contained in:
commit
5dbda9e39b
29 changed files with 1920 additions and 1967 deletions
26
.github/workflows/android.yml
vendored
Normal file
26
.github/workflows/android.yml
vendored
Normal file
|
@ -0,0 +1,26 @@
|
|||
name: Android CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: set up JDK 11
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '11'
|
||||
distribution: 'temurin'
|
||||
cache: gradle
|
||||
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew build
|
|
@ -47,10 +47,14 @@ import androidx.compose.ui.res.stringResource
|
|||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.google.ai.edge.gallery.data.AppContainer // Needed for type
|
||||
import com.google.ai.edge.gallery.data.AppBarAction
|
||||
import com.google.ai.edge.gallery.data.AppBarActionType
|
||||
import com.google.ai.edge.gallery.ui.common.LocalAppContainer
|
||||
import com.google.ai.edge.gallery.ui.navigation.GalleryNavHost
|
||||
|
||||
/**
|
||||
|
@ -58,7 +62,10 @@ import com.google.ai.edge.gallery.ui.navigation.GalleryNavHost
|
|||
*/
|
||||
@Composable
|
||||
fun GalleryApp(navController: NavHostController = rememberNavController()) {
|
||||
GalleryNavHost(navController = navController)
|
||||
val appContainer = (LocalContext.current.applicationContext as GalleryApplication).container
|
||||
CompositionLocalProvider(LocalAppContainer provides appContainer) {
|
||||
GalleryNavHost(navController = navController)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -42,6 +42,6 @@ interface AppContainer {
|
|||
class DefaultAppContainer(ctx: Context, dataStore: DataStore<Preferences>) : AppContainer {
|
||||
override val context = ctx
|
||||
override val lifecycleProvider = GalleryLifecycleProvider()
|
||||
override val dataStoreRepository = DefaultDataStoreRepository(dataStore)
|
||||
override val dataStoreRepository = DefaultDataStoreRepository(dataStore, ctx) // Pass context here
|
||||
override val downloadRepository = DefaultDownloadRepository(ctx, lifecycleProvider)
|
||||
}
|
|
@ -16,9 +16,11 @@
|
|||
|
||||
package com.google.ai.edge.gallery.data
|
||||
|
||||
import android.content.Context // Added import
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import com.google.ai.edge.gallery.R // Added import for R class
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
|
@ -27,9 +29,13 @@ import androidx.datastore.preferences.core.stringPreferencesKey
|
|||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.google.ai.edge.gallery.ui.theme.THEME_AUTO
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.security.KeyStore
|
||||
import java.util.UUID
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
|
@ -50,6 +56,30 @@ interface DataStoreRepository {
|
|||
fun readAccessTokenData(): AccessTokenData?
|
||||
fun saveImportedModels(importedModels: List<ImportedModelInfo>)
|
||||
fun readImportedModels(): List<ImportedModelInfo>
|
||||
|
||||
fun saveUserProfile(userProfile: UserProfile)
|
||||
fun readUserProfile(): UserProfile?
|
||||
fun savePersonas(personas: List<Persona>)
|
||||
fun readPersonas(): List<Persona>
|
||||
fun addPersona(persona: Persona)
|
||||
fun updatePersona(persona: Persona)
|
||||
fun deletePersona(personaId: String)
|
||||
fun saveConversations(conversations: List<Conversation>)
|
||||
fun readConversations(): List<Conversation>
|
||||
fun getConversationById(conversationId: String): Conversation?
|
||||
fun addConversation(conversation: Conversation)
|
||||
fun updateConversation(conversation: Conversation)
|
||||
fun deleteConversation(conversationId: String)
|
||||
|
||||
fun saveActivePersonaId(personaId: String?)
|
||||
fun readActivePersonaId(): Flow<String?>
|
||||
|
||||
fun saveUserDocuments(documents: List<UserDocument>)
|
||||
fun readUserDocuments(): Flow<List<UserDocument>>
|
||||
fun addUserDocument(document: UserDocument)
|
||||
fun updateUserDocument(document: UserDocument)
|
||||
fun deleteUserDocument(documentId: String)
|
||||
fun getUserDocumentById(documentId: String): Flow<UserDocument?>
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -62,7 +92,8 @@ interface DataStoreRepository {
|
|||
* DataStore is used to persist data as JSON strings under specified keys.
|
||||
*/
|
||||
class DefaultDataStoreRepository(
|
||||
private val dataStore: DataStore<Preferences>
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
private val context: Context // Added context
|
||||
) :
|
||||
DataStoreRepository {
|
||||
|
||||
|
@ -85,6 +116,13 @@ class DefaultDataStoreRepository(
|
|||
|
||||
// Data for all imported models.
|
||||
val IMPORTED_MODELS = stringPreferencesKey("imported_models")
|
||||
|
||||
val ENCRYPTED_USER_PROFILE = stringPreferencesKey("encrypted_user_profile")
|
||||
val USER_PROFILE_IV = stringPreferencesKey("user_profile_iv")
|
||||
val PERSONAS_LIST = stringPreferencesKey("personas_list")
|
||||
val CONVERSATIONS_LIST = stringPreferencesKey("conversations_list")
|
||||
val ACTIVE_PERSONA_ID = stringPreferencesKey("active_persona_id")
|
||||
val USER_DOCUMENTS_LIST = stringPreferencesKey("user_documents_list")
|
||||
}
|
||||
|
||||
private val keystoreAlias: String = "com_google_aiedge_gallery_access_token_key"
|
||||
|
@ -189,7 +227,8 @@ class DefaultDataStoreRepository(
|
|||
val infosStr = preferences[PreferencesKeys.IMPORTED_MODELS] ?: "[]"
|
||||
val gson = Gson()
|
||||
val listType = object : TypeToken<List<ImportedModelInfo>>() {}.type
|
||||
gson.fromJson(infosStr, listType)
|
||||
// Ensure to return emptyList() if fromJson returns null
|
||||
return gson.fromJson(infosStr, listType) ?: emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -197,7 +236,8 @@ class DefaultDataStoreRepository(
|
|||
val infosStr = preferences[PreferencesKeys.TEXT_INPUT_HISTORY] ?: "[]"
|
||||
val gson = Gson()
|
||||
val listType = object : TypeToken<List<String>>() {}.type
|
||||
return gson.fromJson(infosStr, listType)
|
||||
// Ensure to return emptyList() if fromJson returns null
|
||||
return gson.fromJson(infosStr, listType) ?: emptyList()
|
||||
}
|
||||
|
||||
private fun getOrCreateSecretKey(): SecretKey {
|
||||
|
@ -243,4 +283,239 @@ class DefaultDataStoreRepository(
|
|||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun saveUserProfile(userProfile: UserProfile) {
|
||||
runBlocking {
|
||||
val gson = Gson()
|
||||
val jsonString = gson.toJson(userProfile)
|
||||
val (encryptedProfile, iv) = encrypt(jsonString)
|
||||
dataStore.edit { preferences ->
|
||||
preferences[PreferencesKeys.ENCRYPTED_USER_PROFILE] = encryptedProfile
|
||||
preferences[PreferencesKeys.USER_PROFILE_IV] = iv
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun readUserProfile(): UserProfile? {
|
||||
return runBlocking {
|
||||
val preferences = dataStore.data.first()
|
||||
val encryptedProfile = preferences[PreferencesKeys.ENCRYPTED_USER_PROFILE]
|
||||
val iv = preferences[PreferencesKeys.USER_PROFILE_IV]
|
||||
|
||||
if (encryptedProfile != null && iv != null) {
|
||||
try {
|
||||
val decryptedJson = decrypt(encryptedProfile, iv)
|
||||
if (decryptedJson != null) {
|
||||
Gson().fromJson(decryptedJson, UserProfile::class.java)
|
||||
} else {
|
||||
UserProfile() // Return default if decryption fails
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
UserProfile() // Return default on error
|
||||
}
|
||||
} else {
|
||||
UserProfile() // Return default if not found
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun savePersonas(personas: List<Persona>) {
|
||||
runBlocking {
|
||||
dataStore.edit { preferences ->
|
||||
val gson = Gson()
|
||||
val jsonString = gson.toJson(personas)
|
||||
preferences[PreferencesKeys.PERSONAS_LIST] = jsonString
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun readPersonas(): List<Persona> {
|
||||
return runBlocking {
|
||||
val preferences = dataStore.data.first()
|
||||
val jsonString = preferences[PreferencesKeys.PERSONAS_LIST]
|
||||
val gson = Gson()
|
||||
val listType = object : TypeToken<List<Persona>>() {}.type
|
||||
var personas: List<Persona> = if (jsonString != null) {
|
||||
try {
|
||||
gson.fromJson(jsonString, listType) ?: emptyList<Persona>()
|
||||
} catch (e: Exception) {
|
||||
emptyList<Persona>() // Return empty list on deserialization error
|
||||
}
|
||||
} else {
|
||||
emptyList<Persona>()
|
||||
}
|
||||
|
||||
if (personas.isEmpty()) {
|
||||
personas = listOf(
|
||||
Persona(
|
||||
id = UUID.randomUUID().toString(),
|
||||
name = context.getString(R.string.persona_add_edit_dialog_name_default_assist),
|
||||
prompt = context.getString(R.string.persona_add_edit_dialog_prompt_default_assist),
|
||||
isDefault = true
|
||||
),
|
||||
Persona(
|
||||
id = UUID.randomUUID().toString(),
|
||||
name = context.getString(R.string.persona_add_edit_dialog_name_default_creative),
|
||||
prompt = context.getString(R.string.persona_add_edit_dialog_prompt_default_creative),
|
||||
isDefault = true
|
||||
)
|
||||
)
|
||||
// Save these default personas back to DataStore
|
||||
savePersonas(personas)
|
||||
}
|
||||
personas
|
||||
}
|
||||
}
|
||||
|
||||
override fun addPersona(persona: Persona) {
|
||||
val currentPersonas = readPersonas().toMutableList()
|
||||
currentPersonas.add(persona)
|
||||
savePersonas(currentPersonas)
|
||||
}
|
||||
|
||||
override fun updatePersona(persona: Persona) {
|
||||
val currentPersonas = readPersonas().toMutableList()
|
||||
val index = currentPersonas.indexOfFirst { it.id == persona.id }
|
||||
if (index != -1) {
|
||||
currentPersonas[index] = persona
|
||||
savePersonas(currentPersonas)
|
||||
}
|
||||
}
|
||||
|
||||
override fun deletePersona(personaId: String) {
|
||||
val currentPersonas = readPersonas().toMutableList()
|
||||
currentPersonas.removeAll { it.id == personaId }
|
||||
savePersonas(currentPersonas)
|
||||
}
|
||||
|
||||
override fun saveConversations(conversations: List<Conversation>) {
|
||||
runBlocking {
|
||||
dataStore.edit { preferences ->
|
||||
val gson = Gson()
|
||||
val jsonString = gson.toJson(conversations)
|
||||
preferences[PreferencesKeys.CONVERSATIONS_LIST] = jsonString
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun readConversations(): List<Conversation> {
|
||||
return runBlocking {
|
||||
val preferences = dataStore.data.first()
|
||||
val jsonString = preferences[PreferencesKeys.CONVERSATIONS_LIST]
|
||||
val gson = Gson()
|
||||
val listType = object : TypeToken<List<Conversation>>() {}.type
|
||||
if (jsonString != null) {
|
||||
try {
|
||||
gson.fromJson(jsonString, listType) ?: emptyList<Conversation>()
|
||||
} catch (e: Exception) {
|
||||
emptyList<Conversation>() // Return empty list on deserialization error
|
||||
}
|
||||
} else {
|
||||
emptyList<Conversation>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getConversationById(conversationId: String): Conversation? {
|
||||
return readConversations().firstOrNull { it.id == conversationId }
|
||||
}
|
||||
|
||||
override fun addConversation(conversation: Conversation) {
|
||||
val currentConversations = readConversations().toMutableList()
|
||||
currentConversations.add(conversation)
|
||||
saveConversations(currentConversations)
|
||||
}
|
||||
|
||||
override fun updateConversation(conversation: Conversation) {
|
||||
val currentConversations = readConversations().toMutableList()
|
||||
val index = currentConversations.indexOfFirst { it.id == conversation.id }
|
||||
if (index != -1) {
|
||||
currentConversations[index] = conversation
|
||||
saveConversations(currentConversations)
|
||||
}
|
||||
}
|
||||
|
||||
override fun deleteConversation(conversationId: String) {
|
||||
val currentConversations = readConversations().toMutableList()
|
||||
currentConversations.removeAll { it.id == conversationId }
|
||||
saveConversations(currentConversations)
|
||||
}
|
||||
|
||||
override fun saveActivePersonaId(personaId: String?) {
|
||||
runBlocking {
|
||||
dataStore.edit { preferences ->
|
||||
if (personaId == null) {
|
||||
preferences.remove(PreferencesKeys.ACTIVE_PERSONA_ID)
|
||||
} else {
|
||||
preferences[PreferencesKeys.ACTIVE_PERSONA_ID] = personaId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun readActivePersonaId(): Flow<String?> {
|
||||
return dataStore.data.map { preferences ->
|
||||
preferences[PreferencesKeys.ACTIVE_PERSONA_ID]
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
|
||||
override fun saveUserDocuments(documents: List<UserDocument>) {
|
||||
runBlocking {
|
||||
dataStore.edit { preferences ->
|
||||
val gson = Gson()
|
||||
val jsonString = gson.toJson(documents)
|
||||
preferences[PreferencesKeys.USER_DOCUMENTS_LIST] = jsonString
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun readUserDocuments(): Flow<List<UserDocument>> {
|
||||
return dataStore.data.map { preferences ->
|
||||
val jsonString = preferences[PreferencesKeys.USER_DOCUMENTS_LIST]
|
||||
if (jsonString != null) {
|
||||
val gson = Gson()
|
||||
val type = object : TypeToken<List<UserDocument>>() {}.type
|
||||
gson.fromJson(jsonString, type) ?: emptyList<UserDocument>()
|
||||
} else {
|
||||
emptyList<UserDocument>()
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
|
||||
override fun addUserDocument(document: UserDocument) {
|
||||
runBlocking { // Consider making these suspend functions if runBlocking becomes an issue
|
||||
val currentDocuments = readUserDocuments().first().toMutableList()
|
||||
currentDocuments.removeAll { it.id == document.id } // Remove if already exists by ID, then add
|
||||
currentDocuments.add(document)
|
||||
saveUserDocuments(currentDocuments)
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateUserDocument(document: UserDocument) {
|
||||
runBlocking {
|
||||
val currentDocuments = readUserDocuments().first().toMutableList()
|
||||
val index = currentDocuments.indexOfFirst { it.id == document.id }
|
||||
if (index != -1) {
|
||||
currentDocuments[index] = document
|
||||
saveUserDocuments(currentDocuments)
|
||||
} else {
|
||||
// Optionally add if not found, or log an error
|
||||
addUserDocument(document) // Or handle error: document to update not found
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun deleteUserDocument(documentId: String) {
|
||||
runBlocking {
|
||||
val currentDocuments = readUserDocuments().first().toMutableList()
|
||||
currentDocuments.removeAll { it.id == documentId }
|
||||
saveUserDocuments(currentDocuments)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getUserDocumentById(documentId: String): Flow<UserDocument?> {
|
||||
return readUserDocuments().map { documents ->
|
||||
documents.find { it.id == documentId }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,110 @@
|
|||
package com.google.ai.edge.gallery.data
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Represents a user's profile information.
|
||||
*
|
||||
* @property name The name of the user.
|
||||
* @property summary A brief summary or bio of the user.
|
||||
* @property skills A list of the user's skills.
|
||||
* @property experience A list of strings, where each string can represent a job or project description.
|
||||
*/
|
||||
data class UserProfile(
|
||||
val name: String? = null,
|
||||
val summary: String? = null,
|
||||
val skills: List<String> = emptyList(),
|
||||
val experience: List<String> = emptyList()
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents an AI persona that can be used in conversations.
|
||||
*
|
||||
* @property id A unique identifier for the persona (e.g., a UUID).
|
||||
* @property name The name of the persona.
|
||||
* @property prompt The system prompt associated with this persona, defining its behavior and responses.
|
||||
* @property isDefault Indicates if this is a default persona.
|
||||
*/
|
||||
data class Persona(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val name: String,
|
||||
val prompt: String,
|
||||
val isDefault: Boolean = false
|
||||
)
|
||||
|
||||
/**
|
||||
* Defines the role of the sender of a chat message.
|
||||
*/
|
||||
enum class ChatMessageRole {
|
||||
/** The message is from the end-user. */
|
||||
USER,
|
||||
/** The message is from the AI assistant. */
|
||||
ASSISTANT,
|
||||
/** The message is a system instruction or context. */
|
||||
SYSTEM
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a single message within a chat conversation.
|
||||
*
|
||||
* @property id A unique identifier for the message (e.g., a UUID).
|
||||
* @property conversationId The ID of the conversation this message belongs to.
|
||||
* @property timestamp The time the message was created, in epoch milliseconds.
|
||||
* @property role The role of the message sender (user, assistant, or system).
|
||||
* @property content The textual content of the message.
|
||||
* @property personaUsedId The ID of the Persona active when this message was generated or sent, if applicable.
|
||||
*/
|
||||
data class ChatMessage(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val conversationId: String,
|
||||
val timestamp: Long,
|
||||
val role: ChatMessageRole,
|
||||
val content: String,
|
||||
val personaUsedId: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents a chat conversation.
|
||||
*
|
||||
* @property id A unique identifier for the conversation (e.g., a UUID).
|
||||
* @property title An optional user-defined title for the conversation.
|
||||
* @property creationTimestamp The time the conversation was created, in epoch milliseconds.
|
||||
* @property lastModifiedTimestamp The time the conversation was last modified, in epoch milliseconds.
|
||||
* @property initialSystemPrompt A custom system prompt for this specific conversation, which might override a Persona's default prompt.
|
||||
* @property messages A list of chat messages in this conversation. For local storage, embedding can work. For cloud sync, separate storage of messages linked by ID is better.
|
||||
* @property activePersonaId The ID of the persona primarily used in this conversation.
|
||||
* @property modelIdUsed The ID (e.g. name) of the AI model used in this conversation.
|
||||
*/
|
||||
data class Conversation(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
var title: String? = null,
|
||||
val creationTimestamp: Long,
|
||||
var lastModifiedTimestamp: Long,
|
||||
val initialSystemPrompt: String? = null,
|
||||
val modelIdUsed: String? = null, // Add this
|
||||
val messages: List<ChatMessage> = emptyList(),
|
||||
val activePersonaId: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents a document imported or managed by the user.
|
||||
*
|
||||
* @property id Unique identifier for the document (e.g., UUID).
|
||||
* @property fileName Original name of the file.
|
||||
* @property localPath Path to the locally stored copy of the document, if applicable.
|
||||
* @property originalSource Indicates where the document came from (e.g., "local", a URL for Google Docs).
|
||||
* @property fileType The MIME type or a simple extension string (e.g., "txt", "pdf", "docx").
|
||||
* @property extractedText The text content extracted from the document. Null if not yet extracted or not applicable.
|
||||
* @property importTimestamp Timestamp when the document was imported.
|
||||
* @property lastAccessedTimestamp Timestamp when the document was last used in a chat (optional).
|
||||
*/
|
||||
data class UserDocument(
|
||||
val id: String = java.util.UUID.randomUUID().toString(),
|
||||
val fileName: String,
|
||||
val localPath: String? = null,
|
||||
val originalSource: String, // e.g., "local", "google_drive_id:<id>"
|
||||
val fileType: String, // e.g., "text/plain", "application/pdf"
|
||||
var extractedText: String? = null,
|
||||
val importTimestamp: Long = System.currentTimeMillis(),
|
||||
var lastAccessedTimestamp: Long? = null
|
||||
)
|
|
@ -33,9 +33,7 @@ enum class TaskType(val label: String, val id: String) {
|
|||
IMAGE_CLASSIFICATION(label = "Image Classification", id = "image_classification"),
|
||||
IMAGE_GENERATION(label = "Image Generation", id = "image_generation"),
|
||||
LLM_CHAT(label = "AI Chat", id = "llm_chat"),
|
||||
LLM_PROMPT_LAB(label = "Prompt Lab", id = "llm_prompt_lab"),
|
||||
LLM_ASK_IMAGE(label = "Ask Image", id = "llm_ask_image"),
|
||||
|
||||
// LLM_PROMPT_LAB and LLM_ASK_IMAGE removed from enum
|
||||
TEST_TASK_1(label = "Test task 1", id = "test_task_1"),
|
||||
TEST_TASK_2(label = "Test task 2", id = "test_task_2")
|
||||
}
|
||||
|
@ -100,25 +98,8 @@ val TASK_LLM_CHAT = Task(
|
|||
textInputPlaceHolderRes = R.string.text_input_placeholder_llm_chat
|
||||
)
|
||||
|
||||
val TASK_LLM_PROMPT_LAB = Task(
|
||||
type = TaskType.LLM_PROMPT_LAB,
|
||||
icon = Icons.Outlined.Widgets,
|
||||
models = mutableListOf(),
|
||||
description = "Single turn use cases with on-device large language model",
|
||||
docUrl = "https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference/android",
|
||||
sourceCodeUrl = "https://github.com/google-ai-edge/gallery/blob/main/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatModelHelper.kt",
|
||||
textInputPlaceHolderRes = R.string.text_input_placeholder_llm_chat
|
||||
)
|
||||
|
||||
val TASK_LLM_ASK_IMAGE = Task(
|
||||
type = TaskType.LLM_ASK_IMAGE,
|
||||
icon = Icons.Outlined.Mms,
|
||||
models = mutableListOf(),
|
||||
description = "Ask questions about images with on-device large language models",
|
||||
docUrl = "https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference/android",
|
||||
sourceCodeUrl = "https://github.com/google-ai-edge/gallery/blob/main/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/llmchat/LlmChatModelHelper.kt",
|
||||
textInputPlaceHolderRes = R.string.text_input_placeholder_llm_chat
|
||||
)
|
||||
// TASK_LLM_PROMPT_LAB definition removed
|
||||
// TASK_LLM_ASK_IMAGE definition removed
|
||||
|
||||
val TASK_IMAGE_GENERATION = Task(
|
||||
type = TaskType.IMAGE_GENERATION,
|
||||
|
@ -132,9 +113,12 @@ val TASK_IMAGE_GENERATION = Task(
|
|||
|
||||
/** All tasks. */
|
||||
val TASKS: List<Task> = listOf(
|
||||
TASK_LLM_ASK_IMAGE,
|
||||
TASK_LLM_PROMPT_LAB,
|
||||
TASK_LLM_CHAT,
|
||||
TASK_TEXT_CLASSIFICATION,
|
||||
TASK_IMAGE_CLASSIFICATION,
|
||||
TASK_IMAGE_GENERATION,
|
||||
TASK_LLM_CHAT
|
||||
// TASK_LLM_ASK_IMAGE removed
|
||||
// TASK_LLM_PROMPT_LAB removed
|
||||
)
|
||||
|
||||
fun getModelByName(name: String): Model? {
|
||||
|
|
|
@ -22,13 +22,17 @@ import androidx.lifecycle.viewmodel.CreationExtras
|
|||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.google.ai.edge.gallery.GalleryApplication
|
||||
import com.google.ai.edge.gallery.data.TASK_LLM_CHAT // Import TASK_LLM_CHAT
|
||||
import com.google.ai.edge.gallery.ui.imageclassification.ImageClassificationViewModel
|
||||
import com.google.ai.edge.gallery.ui.imagegeneration.ImageGenerationViewModel
|
||||
import com.google.ai.edge.gallery.ui.llmchat.LlmChatViewModel
|
||||
import com.google.ai.edge.gallery.ui.llmchat.LlmAskImageViewModel
|
||||
import com.google.ai.edge.gallery.ui.llmsingleturn.LlmSingleTurnViewModel
|
||||
// import com.google.ai.edge.gallery.ui.llmchat.LlmAskImageViewModel // Removed
|
||||
// import com.google.ai.edge.gallery.ui.llmsingleturn.LlmSingleTurnViewModel // Removed
|
||||
import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel
|
||||
import com.google.ai.edge.gallery.ui.textclassification.TextClassificationViewModel
|
||||
import com.google.ai.edge.gallery.ui.userprofile.UserProfileViewModel
|
||||
import com.google.ai.edge.gallery.ui.persona.PersonaViewModel
|
||||
import com.google.ai.edge.gallery.ui.conversationhistory.ConversationHistoryViewModel // Added import
|
||||
|
||||
object ViewModelProvider {
|
||||
val Factory = viewModelFactory {
|
||||
|
@ -55,23 +59,35 @@ object ViewModelProvider {
|
|||
|
||||
// Initializer for LlmChatViewModel.
|
||||
initializer {
|
||||
LlmChatViewModel()
|
||||
val dataStoreRepository = galleryApplication().container.dataStoreRepository
|
||||
LlmChatViewModel(dataStoreRepository = dataStoreRepository, curTask = TASK_LLM_CHAT)
|
||||
}
|
||||
|
||||
// Initializer for LlmSingleTurnViewModel..
|
||||
initializer {
|
||||
LlmSingleTurnViewModel()
|
||||
}
|
||||
|
||||
// Initializer for LlmAskImageViewModel.
|
||||
initializer {
|
||||
LlmAskImageViewModel()
|
||||
}
|
||||
// Initializer for LlmSingleTurnViewModel.. - REMOVED
|
||||
// Initializer for LlmAskImageViewModel. - REMOVED
|
||||
|
||||
// Initializer for ImageGenerationViewModel.
|
||||
initializer {
|
||||
ImageGenerationViewModel()
|
||||
}
|
||||
|
||||
// Initializer for UserProfileViewModel.
|
||||
initializer {
|
||||
val dataStoreRepository = galleryApplication().container.dataStoreRepository
|
||||
UserProfileViewModel(dataStoreRepository = dataStoreRepository)
|
||||
}
|
||||
|
||||
// Initializer for PersonaViewModel.
|
||||
initializer {
|
||||
val dataStoreRepository = galleryApplication().container.dataStoreRepository
|
||||
PersonaViewModel(dataStoreRepository = dataStoreRepository)
|
||||
}
|
||||
|
||||
// Initializer for ConversationHistoryViewModel.
|
||||
initializer {
|
||||
val dataStoreRepository = galleryApplication().container.dataStoreRepository
|
||||
ConversationHistoryViewModel(dataStoreRepository = dataStoreRepository)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
package com.google.ai.edge.gallery.ui.common
|
||||
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import com.google.ai.edge.gallery.data.AppContainer
|
||||
|
||||
val LocalAppContainer = compositionLocalOf<AppContainer> { error("AppContainer not provided") }
|
|
@ -0,0 +1,146 @@
|
|||
package com.google.ai.edge.gallery.ui.conversationhistory
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.res.stringResource // Added import
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.google.ai.edge.gallery.R // Added import for R class
|
||||
import com.google.ai.edge.gallery.data.Conversation
|
||||
// import com.google.ai.edge.gallery.data.getModelByName // Not strictly needed if modelName is just a string
|
||||
import com.google.ai.edge.gallery.ui.ViewModelProvider // For ViewModelProvider.Factory
|
||||
import com.google.ai.edge.gallery.ui.navigation.LlmChatDestination // For navigation route
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ConversationHistoryScreen(
|
||||
navController: NavController,
|
||||
viewModelFactory: ViewModelProvider.Factory, // Ensure this matches the actual factory name
|
||||
viewModel: ConversationHistoryViewModel = viewModel(factory = viewModelFactory)
|
||||
) {
|
||||
val conversations by viewModel.conversations.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.conversation_history_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.popBackStack() }) {
|
||||
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.user_profile_back_button_desc)) // Reused
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
if (conversations.isEmpty()) {
|
||||
Box(modifier = Modifier.fillMaxSize().padding(paddingValues), contentAlignment = Alignment.Center) {
|
||||
Text(stringResource(R.string.conversation_history_no_conversations))
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = paddingValues,
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(conversations, key = { it.id }) { conversation ->
|
||||
ConversationHistoryItem(
|
||||
conversation = conversation,
|
||||
onItemClick = {
|
||||
val modelName = conversation.modelIdUsed
|
||||
if (modelName != null) {
|
||||
// Navigate using the route that includes conversationId and modelName
|
||||
navController.navigate(
|
||||
"${LlmChatDestination.routeTemplate}/conversation/${conversation.id}?modelName=${modelName}"
|
||||
)
|
||||
} else {
|
||||
// Fallback or error: modelIdUsed should ideally not be null for conversations created post-update.
|
||||
// Consider navigating to a generic chat or showing an error.
|
||||
// For now, this click might do nothing if modelIdUsed is null.
|
||||
android.util.Log.w("ConvHistory", "modelIdUsed is null for conversation: ${conversation.id}")
|
||||
}
|
||||
},
|
||||
onDeleteClick = { viewModel.deleteConversation(conversation.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConversationHistoryItem(
|
||||
conversation: Conversation,
|
||||
onItemClick: () -> Unit,
|
||||
onDeleteClick: () -> Unit
|
||||
) {
|
||||
var showDeleteConfirmDialog by remember { mutableStateOf(false) }
|
||||
val dateFormatter = remember { SimpleDateFormat("MMM dd, yyyy hh:mm a", Locale.getDefault()) }
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onItemClick),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
conversation.title ?: stringResource(R.string.conversation_history_item_title_prefix, dateFormatter.format(Date(conversation.creationTimestamp))),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"Model: ${conversation.modelIdUsed ?: stringResource(R.string.chat_default_agent_name)}", // Display model ID or default
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.conversation_history_last_activity_prefix, dateFormatter.format(Date(conversation.lastModifiedTimestamp))),
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
conversation.messages.lastOrNull()?.let {
|
||||
Text(
|
||||
"${it.role}: ${it.content.take(80)}${if (it.content.length > 80) "..." else ""}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { showDeleteConfirmDialog = true }) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.persona_item_delete_desc)) // Reused
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showDeleteConfirmDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteConfirmDialog = false },
|
||||
title = { Text(stringResource(R.string.conversation_history_delete_dialog_title)) },
|
||||
text = { Text(stringResource(R.string.conversation_history_delete_dialog_message)) },
|
||||
confirmButton = { Button(onClick = { onDeleteClick(); showDeleteConfirmDialog = false }) { Text(stringResource(R.string.conversation_history_delete_dialog_confirm_button)) } },
|
||||
dismissButton = { Button(onClick = { showDeleteConfirmDialog = false }) { Text(stringResource(R.string.dialog_cancel_button)) } }
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
package com.google.ai.edge.gallery.ui.conversationhistory
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.google.ai.edge.gallery.data.Conversation
|
||||
import com.google.ai.edge.gallery.data.DataStoreRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ConversationHistoryViewModel(private val dataStoreRepository: DataStoreRepository) : ViewModel() {
|
||||
|
||||
private val _conversations = MutableStateFlow<List<Conversation>>(emptyList())
|
||||
val conversations: StateFlow<List<Conversation>> = _conversations.asStateFlow()
|
||||
|
||||
init {
|
||||
loadConversations()
|
||||
}
|
||||
|
||||
fun loadConversations() {
|
||||
viewModelScope.launch {
|
||||
_conversations.value = dataStoreRepository.readConversations().sortedByDescending { it.lastModifiedTimestamp }
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteConversation(conversationId: String) {
|
||||
viewModelScope.launch {
|
||||
dataStoreRepository.deleteConversation(conversationId)
|
||||
loadConversations() // Refresh the list
|
||||
}
|
||||
}
|
||||
}
|
|
@ -130,10 +130,13 @@ object HomeScreenDestination {
|
|||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
import androidx.navigation.NavController
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
modelManagerViewModel: ModelManagerViewModel,
|
||||
navigateToTaskScreen: (Task) -> Unit,
|
||||
navController: NavController, // Add NavController
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
|
@ -210,6 +213,7 @@ fun HomeScreen(
|
|||
SettingsDialog(
|
||||
curThemeOverride = modelManagerViewModel.readThemeOverride(),
|
||||
modelManagerViewModel = modelManagerViewModel,
|
||||
navController = navController, // Pass NavController
|
||||
onDismissed = { showSettingsDialog = false },
|
||||
)
|
||||
}
|
||||
|
@ -571,10 +575,16 @@ fun getFileName(context: Context, uri: Uri): String? {
|
|||
@Composable
|
||||
fun HomeScreenPreview(
|
||||
) {
|
||||
// Preview will not have a real NavController, so this might need adjustment
|
||||
// if SettingsDialog is to be previewed from here. For now, focusing on functionality.
|
||||
// For a simple preview, one might pass a dummy NavController or conditional logic.
|
||||
val context = LocalContext.current
|
||||
val dummyNavController = remember { NavController(context) }
|
||||
GalleryTheme {
|
||||
HomeScreen(
|
||||
modelManagerViewModel = PreviewModelManagerViewModel(context = LocalContext.current),
|
||||
modelManagerViewModel = PreviewModelManagerViewModel(context = context),
|
||||
navigateToTaskScreen = {},
|
||||
navController = dummyNavController, // Pass dummy NavController for preview
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -60,8 +60,12 @@ import androidx.compose.ui.text.TextStyle
|
|||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.navigation.NavController
|
||||
import com.google.ai.edge.gallery.R // Added import for R class
|
||||
import com.google.ai.edge.gallery.BuildConfig
|
||||
import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel
|
||||
import com.google.ai.edge.gallery.ui.navigation.PersonaManagementDestination // For the route
|
||||
import com.google.ai.edge.gallery.ui.navigation.UserProfileDestination // For the route
|
||||
import com.google.ai.edge.gallery.ui.theme.THEME_AUTO
|
||||
import com.google.ai.edge.gallery.ui.theme.THEME_DARK
|
||||
import com.google.ai.edge.gallery.ui.theme.THEME_LIGHT
|
||||
|
@ -79,6 +83,7 @@ private val THEME_OPTIONS = listOf(THEME_AUTO, THEME_LIGHT, THEME_DARK)
|
|||
fun SettingsDialog(
|
||||
curThemeOverride: String,
|
||||
modelManagerViewModel: ModelManagerViewModel,
|
||||
navController: NavController, // Add NavController
|
||||
onDismissed: () -> Unit,
|
||||
) {
|
||||
var selectedTheme by remember { mutableStateOf(curThemeOverride) }
|
||||
|
@ -255,6 +260,46 @@ fun SettingsDialog(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Personal Profile Section
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.settings_personal_profile_title),
|
||||
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold),
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
navController.navigate(UserProfileDestination.route)
|
||||
onDismissed() // Optionally dismiss settings dialog after navigation
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(stringResource(R.string.settings_edit_profile_button))
|
||||
}
|
||||
}
|
||||
|
||||
// Persona Management Section
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.settings_persona_management_title),
|
||||
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold),
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp) // Add top padding
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
navController.navigate(PersonaManagementDestination.route)
|
||||
onDismissed() // Optionally dismiss settings dialog
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(stringResource(R.string.settings_manage_personas_button))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -39,6 +39,17 @@ object LlmChatModelHelper {
|
|||
// Indexed by model name.
|
||||
private val cleanUpListeners: MutableMap<String, CleanUpListener> = mutableMapOf()
|
||||
|
||||
fun primeSessionWithSystemPrompt(model: Model, systemPrompt: String) {
|
||||
val instance = model.instance as? LlmModelInstance ?: return
|
||||
try {
|
||||
instance.session.addQueryChunk(systemPrompt)
|
||||
Log.d(TAG, "Session primed with system prompt.")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error priming session with system prompt: ", e)
|
||||
// Consider how to handle this error, maybe throw or callback
|
||||
}
|
||||
}
|
||||
|
||||
fun initialize(
|
||||
context: Context, model: Model, onDone: (String) -> Unit
|
||||
) {
|
||||
|
|
|
@ -17,10 +17,18 @@
|
|||
package com.google.ai.edge.gallery.ui.llmchat
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.foundation.layout.* // For Column, Row, Spacer, padding, etc.
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material3.* // For TextField, TopAppBar, etc.
|
||||
import androidx.compose.runtime.* // For remember, mutableStateOf, collectAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource // For string resources
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.google.ai.edge.gallery.data.Model // Ensure Model is imported
|
||||
import com.google.ai.edge.gallery.ui.ViewModelProvider
|
||||
import com.google.ai.edge.gallery.ui.common.chat.ChatMessageImage
|
||||
import com.google.ai.edge.gallery.ui.common.chat.ChatMessageText
|
||||
|
@ -30,115 +38,157 @@ import kotlinx.serialization.Serializable
|
|||
|
||||
/** Navigation destination data */
|
||||
object LlmChatDestination {
|
||||
@Serializable
|
||||
val route = "LlmChatRoute"
|
||||
}
|
||||
|
||||
object LlmAskImageDestination {
|
||||
@Serializable
|
||||
val route = "LlmAskImageRoute"
|
||||
@Serializable // Keep serializable if used in NavType directly, though we'll use String for nav args
|
||||
const val routeTemplate = "LlmChatRoute"
|
||||
const val conversationIdArg = "conversationId"
|
||||
// Route for opening an existing conversation
|
||||
val routeForConversation = "$routeTemplate/conversation/{$conversationIdArg}"
|
||||
// Route for starting a new chat, potentially with a pre-selected model
|
||||
const val modelNameArg = "modelName"
|
||||
val routeForNewChatWithModel = "$routeTemplate/new/{$modelNameArg}"
|
||||
val routeForNewChat = routeTemplate // General new chat
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LlmChatScreen(
|
||||
modelManagerViewModel: ModelManagerViewModel,
|
||||
navigateUp: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: LlmChatViewModel = viewModel(
|
||||
factory = ViewModelProvider.Factory
|
||||
),
|
||||
modelManagerViewModel: ModelManagerViewModel,
|
||||
navigateUp: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: LlmChatViewModel = viewModel(factory = ViewModelProvider.Factory),
|
||||
conversationId: String? = null // New parameter
|
||||
) {
|
||||
ChatViewWrapper(
|
||||
viewModel = viewModel,
|
||||
modelManagerViewModel = modelManagerViewModel,
|
||||
navigateUp = navigateUp,
|
||||
modifier = modifier,
|
||||
)
|
||||
var customSystemPromptInput by remember { mutableStateOf("") }
|
||||
val currentConversation by viewModel.currentConversation.collectAsState()
|
||||
val activePersona by viewModel.activePersona.collectAsState()
|
||||
val uiMessages by viewModel.uiMessages.collectAsState() // Observe uiMessages
|
||||
|
||||
// Prioritize model from conversation if available, then from ModelManagerViewModel
|
||||
val selectedModel: Model? = remember(currentConversation, modelManagerViewModel.getSelectedModel(viewModel.task.type)) {
|
||||
modelManagerViewModel.getSelectedModel(viewModel.task.type)
|
||||
}
|
||||
|
||||
LaunchedEffect(conversationId, selectedModel) {
|
||||
if (selectedModel == null) {
|
||||
android.util.Log.e("LlmChatScreen", "No model selected for chat.")
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (conversationId != null) {
|
||||
viewModel.loadConversation(conversationId, selectedModel)
|
||||
} else {
|
||||
if (currentConversation == null || (currentConversation?.id == null && currentConversation?.messages.isNullOrEmpty())) {
|
||||
// Let user type system prompt. startNewConversation will be called on first send.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ChatViewWrapper(
|
||||
viewModel = viewModel,
|
||||
modelManagerViewModel = modelManagerViewModel,
|
||||
navigateUp = navigateUp,
|
||||
navController = navController, // Pass navController
|
||||
modifier = modifier,
|
||||
customSystemPromptInput = customSystemPromptInput,
|
||||
onCustomSystemPromptChange = { customSystemPromptInput = it },
|
||||
activePersonaName = activePersona?.name ?: currentConversation?.activePersonaId?.let { "ID: $it" } // Fallback to ID if name not loaded
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LlmAskImageScreen(
|
||||
modelManagerViewModel: ModelManagerViewModel,
|
||||
navigateUp: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: LlmAskImageViewModel = viewModel(
|
||||
factory = ViewModelProvider.Factory
|
||||
),
|
||||
) {
|
||||
ChatViewWrapper(
|
||||
viewModel = viewModel,
|
||||
modelManagerViewModel = modelManagerViewModel,
|
||||
navigateUp = navigateUp,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
// Removed duplicated ChatViewWrapper call and imports that were above it.
|
||||
// The ExperimentalMaterial3Api annotation is kept for the actual ChatViewWrapper below.
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChatViewWrapper(
|
||||
viewModel: LlmChatViewModel,
|
||||
modelManagerViewModel: ModelManagerViewModel,
|
||||
navigateUp: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
viewModel: LlmChatViewModel,
|
||||
modelManagerViewModel: ModelManagerViewModel,
|
||||
navigateUp: () -> Unit,
|
||||
navController: NavController, // Added navController parameter
|
||||
modifier: Modifier = Modifier,
|
||||
customSystemPromptInput: String,
|
||||
onCustomSystemPromptChange: (String) -> Unit,
|
||||
activePersonaName: String?
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val context = LocalContext.current
|
||||
val currentConvo by viewModel.currentConversation.collectAsState()
|
||||
val messagesForUi by viewModel.uiMessages.collectAsState()
|
||||
val selectedModel = modelManagerViewModel.getSelectedModel(viewModel.task.type)
|
||||
|
||||
ChatView(
|
||||
task = viewModel.task,
|
||||
viewModel = viewModel,
|
||||
modelManagerViewModel = modelManagerViewModel,
|
||||
onSendMessage = { model, messages ->
|
||||
for (message in messages) {
|
||||
viewModel.addMessage(
|
||||
model = model,
|
||||
message = message,
|
||||
// Show system prompt input if no conversation has started or if it's a new, empty conversation
|
||||
val showSystemPromptInput = currentConvo == null || (currentConvo?.id != null && currentConvo!!.messages.isEmpty())
|
||||
|
||||
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(activePersonaName ?: stringResource(viewModel.task.agentNameRes))
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = navigateUp) {
|
||||
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.user_profile_back_button_desc)) // Reused
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { navController.navigate(ConversationHistoryDestination.route) }) {
|
||||
Icon(Icons.Filled.History, contentDescription = stringResource(R.string.chat_history_button_desc))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var text = ""
|
||||
var image: Bitmap? = null
|
||||
var chatMessageText: ChatMessageText? = null
|
||||
for (message in messages) {
|
||||
if (message is ChatMessageText) {
|
||||
chatMessageText = message
|
||||
text = message.content
|
||||
} else if (message is ChatMessageImage) {
|
||||
image = message.bitmap
|
||||
if (showSystemPromptInput) {
|
||||
OutlinedTextField(
|
||||
value = customSystemPromptInput,
|
||||
onValueChange = onCustomSystemPromptChange,
|
||||
label = { Text(stringResource(R.string.chat_custom_system_prompt_label)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
maxLines = 3
|
||||
)
|
||||
}
|
||||
}
|
||||
if (text.isNotEmpty() && chatMessageText != null) {
|
||||
modelManagerViewModel.addTextInputHistory(text)
|
||||
viewModel.generateResponse(model = model, input = text, image = image, onError = {
|
||||
viewModel.handleError(
|
||||
context = context,
|
||||
model = model,
|
||||
|
||||
ChatView(
|
||||
task = viewModel.task,
|
||||
viewModel = viewModel,
|
||||
modelManagerViewModel = modelManagerViewModel,
|
||||
triggeredMessage = chatMessageText,
|
||||
)
|
||||
})
|
||||
}
|
||||
},
|
||||
onRunAgainClicked = { model, message ->
|
||||
if (message is ChatMessageText) {
|
||||
viewModel.runAgain(model = model, message = message, onError = {
|
||||
viewModel.handleError(
|
||||
context = context,
|
||||
model = model,
|
||||
modelManagerViewModel = modelManagerViewModel,
|
||||
triggeredMessage = message,
|
||||
)
|
||||
})
|
||||
}
|
||||
},
|
||||
onBenchmarkClicked = { _, _, _, _ ->
|
||||
},
|
||||
onResetSessionClicked = { model ->
|
||||
viewModel.resetSession(model = model)
|
||||
},
|
||||
showStopButtonInInputWhenInProgress = true,
|
||||
onStopButtonClicked = { model ->
|
||||
viewModel.stopResponse(model = model)
|
||||
},
|
||||
navigateUp = navigateUp,
|
||||
modifier = modifier,
|
||||
)
|
||||
messages = messagesForUi,
|
||||
onSendMessage = { modelFromChatView, userMessages ->
|
||||
val userInputMessage = userMessages.firstNotNullOfOrNull { it as? ChatMessageText }?.content ?: ""
|
||||
val imageBitmap = userMessages.firstNotNullOfOrNull { it as? ChatMessageImage }?.bitmap
|
||||
|
||||
if (userInputMessage.isNotBlank() || imageBitmap != null) {
|
||||
selectedModel?.let { validSelectedModel ->
|
||||
modelManagerViewModel.addTextInputHistory(userInputMessage)
|
||||
|
||||
if (currentConvo == null || (currentConvo?.id != null && currentConvo!!.messages.isEmpty() && !currentConvo!!.initialSystemPrompt.isNullOrEmpty().not() && customSystemPromptInput.isBlank())) {
|
||||
viewModel.startNewConversation(
|
||||
customSystemPrompt = if (customSystemPromptInput.isNotBlank()) customSystemPromptInput else null,
|
||||
selectedPersonaId = viewModel.activePersona.value?.id,
|
||||
title = userInputMessage.take(30).ifBlank { stringResource(R.string.chat_new_conversation_title_prefix) }, // Use string resource
|
||||
selectedModel = validSelectedModel
|
||||
)
|
||||
}
|
||||
viewModel.generateChatResponse(model = validSelectedModel, input = userInputMessage, image = imageBitmap)
|
||||
} ?: run {
|
||||
android.util.Log.e("ChatViewWrapper", "No model selected, cannot send message.")
|
||||
// Potentially show error to user
|
||||
}
|
||||
}
|
||||
},
|
||||
// TODO: Add confirmation dialog for reset session
|
||||
onResetSessionClicked = { model ->
|
||||
selectedModel?.let {
|
||||
onCustomSystemPromptChange("")
|
||||
viewModel.startNewConversation(
|
||||
customSystemPrompt = null,
|
||||
selectedPersonaId = viewModel.activePersona.value?.id,
|
||||
title = stringResource(R.string.chat_new_conversation_title_prefix), // Use string resource
|
||||
selectedModel = it
|
||||
)
|
||||
}
|
||||
},
|
||||
showStopButtonInInputWhenInProgress = true,
|
||||
onStopButtonClicked = { model -> viewModel.stopResponse(model) },
|
||||
navigateUp = navigateUp
|
||||
)
|
||||
}
|
||||
}
|
|
@ -21,10 +21,16 @@ import android.graphics.Bitmap
|
|||
import android.util.Log
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.google.ai.edge.gallery.data.ConfigKey
|
||||
import com.google.ai.edge.gallery.data.Conversation
|
||||
import com.google.ai.edge.gallery.data.DataStoreRepository
|
||||
import com.google.ai.edge.gallery.data.Model
|
||||
import com.google.ai.edge.gallery.data.Persona
|
||||
import com.google.ai.edge.gallery.data.TASK_LLM_CHAT
|
||||
import com.google.ai.edge.gallery.data.TASK_LLM_ASK_IMAGE
|
||||
// import com.google.ai.edge.gallery.data.TASK_LLM_ASK_IMAGE // Removed
|
||||
import com.google.ai.edge.gallery.data.Task
|
||||
import com.google.ai.edge.gallery.data.UserProfile
|
||||
import com.google.ai.edge.gallery.data.ChatMessage // Assuming ChatMessage is in data package from PersonalAppModels.kt
|
||||
import com.google.ai.edge.gallery.data.ChatMessageRole
|
||||
import com.google.ai.edge.gallery.ui.common.chat.ChatMessageBenchmarkLlmResult
|
||||
import com.google.ai.edge.gallery.ui.common.chat.ChatMessageLoading
|
||||
import com.google.ai.edge.gallery.ui.common.chat.ChatMessageText
|
||||
|
@ -36,7 +42,9 @@ import com.google.ai.edge.gallery.ui.common.chat.Stat
|
|||
import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
|
||||
private const val TAG = "AGLlmChatViewModel"
|
||||
private val STATS = listOf(
|
||||
|
@ -46,203 +54,265 @@ private val STATS = listOf(
|
|||
Stat(id = "latency", label = "Latency", unit = "sec")
|
||||
)
|
||||
|
||||
open class LlmChatViewModel(curTask: Task = TASK_LLM_CHAT) : ChatViewModel(task = curTask) {
|
||||
fun generateResponse(model: Model, input: String, image: Bitmap? = null, onError: () -> Unit) {
|
||||
val accelerator = model.getStringConfigValue(key = ConfigKey.ACCELERATOR, defaultValue = "")
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
setInProgress(true)
|
||||
setPreparing(true)
|
||||
open class LlmChatViewModel(
|
||||
private val dataStoreRepository: DataStoreRepository, // Add this
|
||||
curTask: Task = TASK_LLM_CHAT // Keep if still relevant, or simplify if chat is the only focus
|
||||
) : ChatViewModel(task = curTask) { // ChatViewModel base class might need review
|
||||
|
||||
// Loading.
|
||||
addMessage(
|
||||
model = model,
|
||||
message = ChatMessageLoading(accelerator = accelerator),
|
||||
)
|
||||
private val _currentConversation = MutableStateFlow<Conversation?>(null)
|
||||
val currentConversation: StateFlow<Conversation?> = _currentConversation.asStateFlow()
|
||||
|
||||
// Wait for instance to be initialized.
|
||||
while (model.instance == null) {
|
||||
delay(100)
|
||||
}
|
||||
delay(500)
|
||||
val userProfile: StateFlow<UserProfile?> = flow {
|
||||
emit(dataStoreRepository.readUserProfile())
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||
|
||||
// Run inference.
|
||||
val instance = model.instance as LlmModelInstance
|
||||
var prefillTokens = instance.session.sizeInTokens(input)
|
||||
if (image != null) {
|
||||
prefillTokens += 257
|
||||
}
|
||||
|
||||
var firstRun = true
|
||||
var timeToFirstToken = 0f
|
||||
var firstTokenTs = 0L
|
||||
var decodeTokens = 0
|
||||
var prefillSpeed = 0f
|
||||
var decodeSpeed: Float
|
||||
val start = System.currentTimeMillis()
|
||||
|
||||
try {
|
||||
LlmChatModelHelper.runInference(model = model,
|
||||
input = input,
|
||||
image = image,
|
||||
resultListener = { partialResult, done ->
|
||||
val curTs = System.currentTimeMillis()
|
||||
|
||||
if (firstRun) {
|
||||
firstTokenTs = System.currentTimeMillis()
|
||||
timeToFirstToken = (firstTokenTs - start) / 1000f
|
||||
prefillSpeed = prefillTokens / timeToFirstToken
|
||||
firstRun = false
|
||||
setPreparing(false)
|
||||
} else {
|
||||
decodeTokens++
|
||||
// Get active persona ID from repo, then fetch the full Persona object
|
||||
val activePersona: StateFlow<Persona?> = dataStoreRepository.readActivePersonaId().flatMapLatest { activeId ->
|
||||
if (activeId == null) {
|
||||
flowOf(null)
|
||||
} else {
|
||||
flow {
|
||||
val personas = dataStoreRepository.readPersonas()
|
||||
emit(personas.find { it.id == activeId })
|
||||
}
|
||||
|
||||
// Remove the last message if it is a "loading" message.
|
||||
// This will only be done once.
|
||||
val lastMessage = getLastMessage(model = model)
|
||||
if (lastMessage?.type == ChatMessageType.LOADING) {
|
||||
removeLastMessage(model = model)
|
||||
|
||||
// Add an empty message that will receive streaming results.
|
||||
addMessage(
|
||||
model = model,
|
||||
message = ChatMessageText(
|
||||
content = "",
|
||||
side = ChatSide.AGENT,
|
||||
accelerator = accelerator
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Incrementally update the streamed partial results.
|
||||
val latencyMs: Long = if (done) System.currentTimeMillis() - start else -1
|
||||
updateLastTextMessageContentIncrementally(
|
||||
model = model, partialContent = partialResult, latencyMs = latencyMs.toFloat()
|
||||
)
|
||||
|
||||
if (done) {
|
||||
setInProgress(false)
|
||||
|
||||
decodeSpeed = decodeTokens / ((curTs - firstTokenTs) / 1000f)
|
||||
if (decodeSpeed.isNaN()) {
|
||||
decodeSpeed = 0f
|
||||
}
|
||||
|
||||
if (lastMessage is ChatMessageText) {
|
||||
updateLastTextMessageLlmBenchmarkResult(
|
||||
model = model, llmBenchmarkResult = ChatMessageBenchmarkLlmResult(
|
||||
orderedStats = STATS,
|
||||
statValues = mutableMapOf(
|
||||
"prefill_speed" to prefillSpeed,
|
||||
"decode_speed" to decodeSpeed,
|
||||
"time_to_first_token" to timeToFirstToken,
|
||||
"latency" to (curTs - start).toFloat() / 1000f,
|
||||
),
|
||||
running = false,
|
||||
latencyMs = -1f,
|
||||
accelerator = accelerator,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
cleanUpListener = {
|
||||
setInProgress(false)
|
||||
setPreparing(false)
|
||||
})
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error occurred while running inference", e)
|
||||
setInProgress(false)
|
||||
setPreparing(false)
|
||||
onError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopResponse(model: Model) {
|
||||
Log.d(TAG, "Stopping response for model ${model.name}...")
|
||||
if (getLastMessage(model = model) is ChatMessageLoading) {
|
||||
removeLastMessage(model = model)
|
||||
}
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
setInProgress(false)
|
||||
val instance = model.instance as LlmModelInstance
|
||||
instance.session.cancelGenerateResponseAsync()
|
||||
}
|
||||
}
|
||||
|
||||
fun resetSession(model: Model) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
setIsResettingSession(true)
|
||||
clearAllMessages(model = model)
|
||||
stopResponse(model = model)
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
LlmChatModelHelper.resetSession(model = model)
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Failed to reset session. Trying again")
|
||||
}
|
||||
delay(200)
|
||||
}
|
||||
setIsResettingSession(false)
|
||||
}
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||
|
||||
fun runAgain(model: Model, message: ChatMessageText, onError: () -> Unit) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
// Wait for model to be initialized.
|
||||
while (model.instance == null) {
|
||||
delay(100)
|
||||
}
|
||||
// This replaces messagesByModel for the new conversation-centric approach
|
||||
private val _uiMessages = MutableStateFlow<List<com.google.ai.edge.gallery.ui.common.chat.ChatMessage>>(emptyList())
|
||||
val uiMessages: StateFlow<List<com.google.ai.edge.gallery.ui.common.chat.ChatMessage>> = _uiMessages.asStateFlow()
|
||||
|
||||
// Clone the clicked message and add it.
|
||||
addMessage(model = model, message = message.clone())
|
||||
// TODO: Review how ChatViewModel's messagesByModel and related methods
|
||||
// (addMessage, getLastMessage, removeLastMessage, clearAllMessages) are used.
|
||||
// They might need to be overridden or adapted to work with _currentConversation.messages
|
||||
// and update _uiMessages. For now, new methods will manage _currentConversation.
|
||||
|
||||
// Run inference.
|
||||
generateResponse(
|
||||
model = model, input = message.content, onError = onError
|
||||
)
|
||||
}
|
||||
}
|
||||
fun startNewConversation(customSystemPrompt: String?, selectedPersonaId: String?, title: String? = null, selectedModel: Model) {
|
||||
viewModelScope.launch {
|
||||
val newConversationId = UUID.randomUUID().toString()
|
||||
val newConversation = Conversation(
|
||||
id = newConversationId,
|
||||
title = title,
|
||||
creationTimestamp = System.currentTimeMillis(),
|
||||
lastModifiedTimestamp = System.currentTimeMillis(),
|
||||
initialSystemPrompt = customSystemPrompt,
|
||||
activePersonaId = selectedPersonaId,
|
||||
modelIdUsed = selectedModel.name, // Store the model name or a unique ID
|
||||
messages = mutableListOf() // Start with empty messages
|
||||
)
|
||||
dataStoreRepository.addConversation(newConversation)
|
||||
_currentConversation.value = newConversation
|
||||
_uiMessages.value = emptyList() // Clear UI messages for the new chat
|
||||
|
||||
fun handleError(
|
||||
context: Context,
|
||||
model: Model,
|
||||
modelManagerViewModel: ModelManagerViewModel,
|
||||
triggeredMessage: ChatMessageText,
|
||||
) {
|
||||
// Clean up.
|
||||
modelManagerViewModel.cleanupModel(task = task, model = model)
|
||||
// Reset LLM session for the selected model
|
||||
LlmChatModelHelper.resetSession(selectedModel) // Ensure model instance is ready
|
||||
|
||||
// Remove the "loading" message.
|
||||
if (getLastMessage(model = model) is ChatMessageLoading) {
|
||||
removeLastMessage(model = model)
|
||||
// Prime with system prompt immediately if starting a new conversation
|
||||
val systemPromptParts = mutableListOf<String>()
|
||||
customSystemPrompt?.let { systemPromptParts.add(it) }
|
||||
activePersona.value?.prompt?.let { systemPromptParts.add(it) }
|
||||
userProfile.value?.summary?.let { if(it.isNotBlank()) systemPromptParts.add("User Profile Summary: $it") }
|
||||
// Add other profile details as needed, e.g., skills
|
||||
|
||||
if (systemPromptParts.isNotEmpty()) {
|
||||
val fullSystemPrompt = systemPromptParts.joinToString("\n\n")
|
||||
// Ensure model instance is available and ready before priming
|
||||
if (selectedModel.instance == null) {
|
||||
Log.e(TAG, "Model instance is null before priming. Initialize model first.")
|
||||
// Potentially trigger model initialization via ModelManagerViewModel if not already done.
|
||||
// For now, we assume the model selected for chat is already initialized by ModelManager.
|
||||
return@launch
|
||||
}
|
||||
LlmChatModelHelper.primeSessionWithSystemPrompt(selectedModel, fullSystemPrompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the last Text message.
|
||||
if (getLastMessage(model = model) == triggeredMessage) {
|
||||
removeLastMessage(model = model)
|
||||
fun loadConversation(conversationId: String, selectedModel: Model) {
|
||||
viewModelScope.launch {
|
||||
val conversation = dataStoreRepository.getConversationById(conversationId)
|
||||
_currentConversation.value = conversation
|
||||
if (conversation != null) {
|
||||
// Convert stored ChatMessage to UI ChatMessage
|
||||
_uiMessages.value = conversation.messages.map { convertToUiChatMessage(it, selectedModel) }
|
||||
|
||||
// Reset session and re-prime with history
|
||||
LlmChatModelHelper.resetSession(selectedModel)
|
||||
|
||||
val systemPromptParts = mutableListOf<String>()
|
||||
conversation.initialSystemPrompt?.let { systemPromptParts.add(it) }
|
||||
// Need to fetch persona for this conversation
|
||||
val personas = dataStoreRepository.readPersonas()
|
||||
val personaForLoadedConv = personas.find { it.id == conversation.activePersonaId }
|
||||
personaForLoadedConv?.prompt?.let { systemPromptParts.add(it) }
|
||||
userProfile.value?.summary?.let { if(it.isNotBlank()) systemPromptParts.add("User Profile Summary: $it") }
|
||||
// Add other profile details
|
||||
|
||||
if (systemPromptParts.isNotEmpty()) {
|
||||
LlmChatModelHelper.primeSessionWithSystemPrompt(selectedModel, systemPromptParts.joinToString("\n\n"))
|
||||
}
|
||||
// Replay message history into the session
|
||||
conversation.messages.forEach { msg ->
|
||||
if (msg.role == ChatMessageRole.USER) {
|
||||
(selectedModel.instance as? LlmModelInstance)?.session?.addQueryChunk(msg.content)
|
||||
} else if (msg.role == ChatMessageRole.ASSISTANT) {
|
||||
// If LLM Inference API supports adding assistant messages to context, do it here.
|
||||
// For now, assume addQueryChunk is for user input primarily to prompt next response.
|
||||
(selectedModel.instance as? LlmModelInstance)?.session?.addQueryChunk(msg.content) // Or format appropriately
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_uiMessages.value = emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add a warning message for re-initializing the session.
|
||||
addMessage(
|
||||
model = model,
|
||||
message = ChatMessageWarning(content = "Error occurred. Re-initializing the session.")
|
||||
)
|
||||
// Helper to convert your data model ChatMessage to the UI model ChatMessage
|
||||
private fun convertToUiChatMessage(appMessage: com.google.ai.edge.gallery.data.ChatMessage, model: Model): com.google.ai.edge.gallery.ui.common.chat.ChatMessage {
|
||||
val side = if (appMessage.role == ChatMessageRole.USER) ChatSide.USER else ChatSide.AGENT
|
||||
// This is a simplified conversion. You might need more fields.
|
||||
// The existing ChatMessage in common.chat seems to be an interface/sealed class.
|
||||
// We need to map to ChatMessageText or other appropriate types.
|
||||
return ChatMessageText(content = appMessage.content, side = side, accelerator = model.getStringConfigValue(ConfigKey.ACCELERATOR, ""))
|
||||
}
|
||||
|
||||
// Add the triggered message back.
|
||||
addMessage(model = model, message = triggeredMessage)
|
||||
|
||||
// Re-initialize the session/engine.
|
||||
modelManagerViewModel.initializeModel(
|
||||
context = context, task = task, model = model
|
||||
)
|
||||
// Override or adapt ChatViewModel's addMessage
|
||||
override fun addMessage(model: Model, message: com.google.ai.edge.gallery.ui.common.chat.ChatMessage) {
|
||||
val conversation = _currentConversation.value ?: return
|
||||
val role = if (message.side == ChatSide.USER) ChatMessageRole.USER else ChatMessageRole.ASSISTANT
|
||||
|
||||
// Re-generate the response automatically.
|
||||
generateResponse(model = model, input = triggeredMessage.content, onError = {})
|
||||
}
|
||||
}
|
||||
// Only add ChatMessageText for now to conversation history. Loading/Error messages are transient.
|
||||
if (message is ChatMessageText) {
|
||||
val appChatMessage = com.google.ai.edge.gallery.data.ChatMessage(
|
||||
id = UUID.randomUUID().toString(),
|
||||
conversationId = conversation.id,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
role = role,
|
||||
content = message.content,
|
||||
personaUsedId = conversation.activePersonaId
|
||||
)
|
||||
val updatedMessages = conversation.messages.toMutableList().apply { add(appChatMessage) }
|
||||
_currentConversation.value = conversation.copy(
|
||||
messages = updatedMessages,
|
||||
lastModifiedTimestamp = System.currentTimeMillis()
|
||||
)
|
||||
// Save updated conversation
|
||||
viewModelScope.launch {
|
||||
_currentConversation.value?.let { dataStoreRepository.updateConversation(it) }
|
||||
}
|
||||
}
|
||||
// Update the UI-specific message list
|
||||
_uiMessages.value = _uiMessages.value.toMutableList().apply { add(message) }
|
||||
}
|
||||
|
||||
class LlmAskImageViewModel : LlmChatViewModel(curTask = TASK_LLM_ASK_IMAGE)
|
||||
// Adapt generateResponse
|
||||
fun generateChatResponse(model: Model, userInput: String, image: Bitmap? = null) { // Renamed to avoid conflict if base still used
|
||||
val currentConvo = _currentConversation.value
|
||||
if (currentConvo == null) {
|
||||
Log.e(TAG, "Cannot generate response, no active conversation.")
|
||||
// Optionally, start a new conversation implicitly or show an error
|
||||
return
|
||||
}
|
||||
|
||||
// Add user's message to conversation and UI
|
||||
val userUiMessage = ChatMessageText(content = userInput, side = ChatSide.USER, accelerator = model.getStringConfigValue(ConfigKey.ACCELERATOR, ""))
|
||||
addMessage(model, userUiMessage) // This will also save it to DataStore
|
||||
|
||||
// The rest is similar to original generateResponse, but uses currentConvo
|
||||
val accelerator = model.getStringConfigValue(key = ConfigKey.ACCELERATOR, defaultValue = "")
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
setInProgress(true) // From base ChatViewModel
|
||||
setPreparing(true) // From base ChatViewModel
|
||||
|
||||
addMessage(model, ChatMessageLoading(accelerator = accelerator)) // Show loading in UI
|
||||
|
||||
while (model.instance == null) { delay(100) } // Wait for model instance
|
||||
delay(500)
|
||||
|
||||
val instance = model.instance as LlmModelInstance
|
||||
// History is now part of the session, primed by startNewConversation or loadConversation.
|
||||
// LlmChatModelHelper.runInference will just add the latest userInput.
|
||||
|
||||
try {
|
||||
LlmChatModelHelper.runInference(
|
||||
model = model,
|
||||
input = userInput, // Just the new input
|
||||
image = image, // Handle image if provided
|
||||
resultListener = { partialResult, done ->
|
||||
// UI update logic for streaming response - largely same as original
|
||||
// Ensure to use 'addMessage' or similar to update _uiMessages
|
||||
// and save assistant's final message to _currentConversation
|
||||
val lastUiMsg = _uiMessages.value.lastOrNull()
|
||||
if (lastUiMsg?.type == ChatMessageType.LOADING) {
|
||||
_uiMessages.value = _uiMessages.value.dropLast(1)
|
||||
// Add an empty message that will receive streaming results for UI
|
||||
addMessage(model, ChatMessageText(content = "", side = ChatSide.AGENT, accelerator = accelerator))
|
||||
}
|
||||
|
||||
val currentAgentMessage = _uiMessages.value.lastOrNull() as? ChatMessageText
|
||||
if (currentAgentMessage != null) {
|
||||
_uiMessages.value = _uiMessages.value.dropLast(1) + currentAgentMessage.copy(content = currentAgentMessage.content + partialResult)
|
||||
}
|
||||
|
||||
|
||||
if (done) {
|
||||
setInProgress(false)
|
||||
val finalAssistantContent = (_uiMessages.value.lastOrNull() as? ChatMessageText)?.content ?: ""
|
||||
// Add final assistant message to DataStore Conversation
|
||||
val assistantAppMessage = com.google.ai.edge.gallery.data.ChatMessage(
|
||||
id = UUID.randomUUID().toString(),
|
||||
conversationId = currentConvo.id,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
role = ChatMessageRole.ASSISTANT,
|
||||
content = finalAssistantContent,
|
||||
personaUsedId = currentConvo.activePersonaId
|
||||
)
|
||||
val updatedMessages = currentConvo.messages.toMutableList().apply { add(assistantAppMessage) }
|
||||
_currentConversation.value = currentConvo.copy(
|
||||
messages = updatedMessages,
|
||||
lastModifiedTimestamp = System.currentTimeMillis()
|
||||
)
|
||||
viewModelScope.launch { _currentConversation.value?.let { dataStoreRepository.updateConversation(it) } }
|
||||
// Update benchmark results if necessary (code omitted for brevity, but similar logic as original generateResponse)
|
||||
val lastMessage = _uiMessages.value.lastOrNull { it.side == ChatSide.AGENT } // get the agent's message
|
||||
if (lastMessage is ChatMessageText && STATS.isNotEmpty()) { // Assuming STATS is defined
|
||||
// This part needs to be adapted. The original `generateResponse` calculated these.
|
||||
// For now, we'll omit direct benchmark updates here to simplify,
|
||||
// as they depend on variables (timeToFirstToken, prefillSpeed, etc.)
|
||||
// not directly available in this refactored `generateChatResponse` structure
|
||||
// without significant further adaptation of the LlmChatModelHelper.runInference callback.
|
||||
// A simpler approach might be to just mark the message as not running.
|
||||
val updatedAgentMessage = lastMessage.copy(
|
||||
llmBenchmarkResult = lastMessage.llmBenchmarkResult?.copy(running = false)
|
||||
?: ChatMessageBenchmarkLlmResult(orderedStats = STATS, statValues = mutableMapOf(), running = false, latencyMs = -1f, accelerator = accelerator)
|
||||
)
|
||||
val finalUiMessages = _uiMessages.value.toMutableList()
|
||||
val agentMsgIndex = finalUiMessages.indexOfLast { it.id == lastMessage.id }
|
||||
if(agentMsgIndex != -1) {
|
||||
finalUiMessages[agentMsgIndex] = updatedAgentMessage
|
||||
_uiMessages.value = finalUiMessages
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
cleanUpListener = {
|
||||
setInProgress(false)
|
||||
setPreparing(false)
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error in generateChatResponse: ", e)
|
||||
setInProgress(false)
|
||||
setPreparing(false)
|
||||
// Add error message to UI
|
||||
addMessage(model, ChatMessageWarning(content = "Error: ${e.message}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Override/adapt clearAllMessages, stopResponse, runAgain, handleError from base ChatViewModel
|
||||
// to work with the new currentConversation model and _uiMessages.
|
||||
// For example, clearAllMessages should clear _uiMessages and potentially currentConvo.messages then save.
|
||||
// resetSession should re-prime with system prompt and history if currentConvo exists.
|
||||
}
|
|
@ -1,218 +0,0 @@
|
|||
/*
|
||||
* Copyright 2025 Google LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.ai.edge.gallery.ui.llmsingleturn
|
||||
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.google.ai.edge.gallery.data.ModelDownloadStatusType
|
||||
import com.google.ai.edge.gallery.ui.ViewModelProvider
|
||||
import com.google.ai.edge.gallery.ui.common.ErrorDialog
|
||||
import com.google.ai.edge.gallery.ui.common.ModelPageAppBar
|
||||
import com.google.ai.edge.gallery.ui.common.chat.ModelDownloadStatusInfoPanel
|
||||
import com.google.ai.edge.gallery.ui.modelmanager.ModelInitializationStatusType
|
||||
import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel
|
||||
import com.google.ai.edge.gallery.ui.preview.PreviewLlmSingleTurnViewModel
|
||||
import com.google.ai.edge.gallery.ui.preview.PreviewModelManagerViewModel
|
||||
import com.google.ai.edge.gallery.ui.theme.GalleryTheme
|
||||
import com.google.ai.edge.gallery.ui.theme.customColors
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** Navigation destination data */
|
||||
object LlmSingleTurnDestination {
|
||||
@Serializable
|
||||
val route = "LlmSingleTurnRoute"
|
||||
}
|
||||
|
||||
private const val TAG = "AGLlmSingleTurnScreen"
|
||||
|
||||
@Composable
|
||||
fun LlmSingleTurnScreen(
|
||||
modelManagerViewModel: ModelManagerViewModel,
|
||||
navigateUp: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: LlmSingleTurnViewModel = viewModel(
|
||||
factory = ViewModelProvider.Factory
|
||||
),
|
||||
) {
|
||||
val task = viewModel.task
|
||||
val modelManagerUiState by modelManagerViewModel.uiState.collectAsState()
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val selectedModel = modelManagerUiState.selectedModel
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
var navigatingUp by remember { mutableStateOf(false) }
|
||||
var showErrorDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val handleNavigateUp = {
|
||||
navigatingUp = true
|
||||
navigateUp()
|
||||
|
||||
// clean up all models.
|
||||
scope.launch(Dispatchers.Default) {
|
||||
for (model in task.models) {
|
||||
modelManagerViewModel.cleanupModel(task = task, model = model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle system's edge swipe.
|
||||
BackHandler {
|
||||
handleNavigateUp()
|
||||
}
|
||||
|
||||
// Initialize model when model/download state changes.
|
||||
val curDownloadStatus = modelManagerUiState.modelDownloadStatus[selectedModel.name]
|
||||
LaunchedEffect(curDownloadStatus, selectedModel.name) {
|
||||
if (!navigatingUp) {
|
||||
if (curDownloadStatus?.status == ModelDownloadStatusType.SUCCEEDED) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"Initializing model '${selectedModel.name}' from LlmsingleTurnScreen launched effect"
|
||||
)
|
||||
modelManagerViewModel.initializeModel(context, task = task, model = selectedModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val modelInitializationStatus = modelManagerUiState.modelInitializationStatus[selectedModel.name]
|
||||
LaunchedEffect(modelInitializationStatus) {
|
||||
showErrorDialog = modelInitializationStatus?.status == ModelInitializationStatusType.ERROR
|
||||
}
|
||||
|
||||
Scaffold(modifier = modifier, topBar = {
|
||||
ModelPageAppBar(
|
||||
task = task,
|
||||
model = selectedModel,
|
||||
modelManagerViewModel = modelManagerViewModel,
|
||||
inProgress = uiState.inProgress,
|
||||
modelPreparing = uiState.preparing,
|
||||
onConfigChanged = { _, _ -> },
|
||||
onBackClicked = { handleNavigateUp() },
|
||||
onModelSelected = { newSelectedModel ->
|
||||
scope.launch(Dispatchers.Default) {
|
||||
// Clean up current model.
|
||||
modelManagerViewModel.cleanupModel(task = task, model = selectedModel)
|
||||
|
||||
// Update selected model.
|
||||
modelManagerViewModel.selectModel(model = newSelectedModel)
|
||||
}
|
||||
}
|
||||
)
|
||||
}) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(
|
||||
top = innerPadding.calculateTopPadding(),
|
||||
start = innerPadding.calculateStartPadding(LocalLayoutDirection.current),
|
||||
end = innerPadding.calculateStartPadding(LocalLayoutDirection.current),
|
||||
)
|
||||
) {
|
||||
ModelDownloadStatusInfoPanel(
|
||||
model = selectedModel,
|
||||
task = task,
|
||||
modelManagerViewModel = modelManagerViewModel
|
||||
)
|
||||
|
||||
// Main UI after model is downloaded.
|
||||
val modelDownloaded = curDownloadStatus?.status == ModelDownloadStatusType.SUCCEEDED
|
||||
Box(
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
// Just hide the UI without removing it from the screen so that the scroll syncing
|
||||
// from ResponsePanel still works.
|
||||
.alpha(if (modelDownloaded) 1.0f else 0.0f)
|
||||
) {
|
||||
VerticalSplitView(modifier = Modifier.fillMaxSize(),
|
||||
topView = {
|
||||
PromptTemplatesPanel(
|
||||
model = selectedModel,
|
||||
viewModel = viewModel,
|
||||
modelManagerViewModel = modelManagerViewModel,
|
||||
onSend = { fullPrompt ->
|
||||
viewModel.generateResponse(model = selectedModel, input = fullPrompt)
|
||||
},
|
||||
onStopButtonClicked = { model ->
|
||||
viewModel.stopResponse(model = model)
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
},
|
||||
bottomView = {
|
||||
Box(
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.customColors.agentBubbleBgColor)
|
||||
) {
|
||||
ResponsePanel(
|
||||
model = selectedModel,
|
||||
viewModel = viewModel,
|
||||
modelManagerViewModel = modelManagerViewModel,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = innerPadding.calculateBottomPadding())
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (showErrorDialog) {
|
||||
ErrorDialog(error = modelInitializationStatus?.error ?: "", onDismiss = {
|
||||
showErrorDialog = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun LlmSingleTurnScreenPreview() {
|
||||
val context = LocalContext.current
|
||||
GalleryTheme {
|
||||
LlmSingleTurnScreen(
|
||||
modelManagerViewModel = PreviewModelManagerViewModel(context = context),
|
||||
viewModel = PreviewLlmSingleTurnViewModel(),
|
||||
navigateUp = {},
|
||||
)
|
||||
}
|
||||
}
|
|
@ -1,221 +0,0 @@
|
|||
/*
|
||||
* Copyright 2025 Google LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.ai.edge.gallery.ui.llmsingleturn
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.google.ai.edge.gallery.data.Model
|
||||
import com.google.ai.edge.gallery.data.TASK_LLM_PROMPT_LAB
|
||||
import com.google.ai.edge.gallery.data.Task
|
||||
import com.google.ai.edge.gallery.ui.common.chat.ChatMessageBenchmarkLlmResult
|
||||
import com.google.ai.edge.gallery.ui.common.chat.Stat
|
||||
import com.google.ai.edge.gallery.ui.common.processLlmResponse
|
||||
import com.google.ai.edge.gallery.ui.llmchat.LlmChatModelHelper
|
||||
import com.google.ai.edge.gallery.ui.llmchat.LlmModelInstance
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val TAG = "AGLlmSingleTurnViewModel"
|
||||
|
||||
data class LlmSingleTurnUiState(
|
||||
/**
|
||||
* Indicates whether the runtime is currently processing a message.
|
||||
*/
|
||||
val inProgress: Boolean = false,
|
||||
|
||||
/**
|
||||
* Indicates whether the model is preparing (before outputting any result and after initializing).
|
||||
*/
|
||||
val preparing: Boolean = false,
|
||||
|
||||
// model -> <template label -> response>
|
||||
val responsesByModel: Map<String, Map<String, String>>,
|
||||
|
||||
// model -> <template label -> benchmark result>
|
||||
val benchmarkByModel: Map<String, Map<String, ChatMessageBenchmarkLlmResult>>,
|
||||
|
||||
/** Selected prompt template type. */
|
||||
val selectedPromptTemplateType: PromptTemplateType = PromptTemplateType.entries[0],
|
||||
)
|
||||
|
||||
private val STATS = listOf(
|
||||
Stat(id = "time_to_first_token", label = "1st token", unit = "sec"),
|
||||
Stat(id = "prefill_speed", label = "Prefill speed", unit = "tokens/s"),
|
||||
Stat(id = "decode_speed", label = "Decode speed", unit = "tokens/s"),
|
||||
Stat(id = "latency", label = "Latency", unit = "sec")
|
||||
)
|
||||
|
||||
open class LlmSingleTurnViewModel(val task: Task = TASK_LLM_PROMPT_LAB) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(createUiState(task = task))
|
||||
val uiState = _uiState.asStateFlow()
|
||||
|
||||
fun generateResponse(model: Model, input: String) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
setInProgress(true)
|
||||
setPreparing(true)
|
||||
|
||||
// Wait for instance to be initialized.
|
||||
while (model.instance == null) {
|
||||
delay(100)
|
||||
}
|
||||
|
||||
LlmChatModelHelper.resetSession(model = model)
|
||||
delay(500)
|
||||
|
||||
// Run inference.
|
||||
val instance = model.instance as LlmModelInstance
|
||||
val prefillTokens = instance.session.sizeInTokens(input)
|
||||
|
||||
var firstRun = true
|
||||
var timeToFirstToken = 0f
|
||||
var firstTokenTs = 0L
|
||||
var decodeTokens = 0
|
||||
var prefillSpeed = 0f
|
||||
var decodeSpeed: Float
|
||||
val start = System.currentTimeMillis()
|
||||
var response = ""
|
||||
var lastBenchmarkUpdateTs = 0L
|
||||
LlmChatModelHelper.runInference(model = model,
|
||||
input = input,
|
||||
resultListener = { partialResult, done ->
|
||||
val curTs = System.currentTimeMillis()
|
||||
|
||||
if (firstRun) {
|
||||
setPreparing(false)
|
||||
firstTokenTs = System.currentTimeMillis()
|
||||
timeToFirstToken = (firstTokenTs - start) / 1000f
|
||||
prefillSpeed = prefillTokens / timeToFirstToken
|
||||
firstRun = false
|
||||
} else {
|
||||
decodeTokens++
|
||||
}
|
||||
|
||||
// Incrementally update the streamed partial results.
|
||||
response = processLlmResponse(response = "$response$partialResult")
|
||||
|
||||
// Update response.
|
||||
updateResponse(
|
||||
model = model,
|
||||
promptTemplateType = uiState.value.selectedPromptTemplateType,
|
||||
response = response
|
||||
)
|
||||
|
||||
// Update benchmark (with throttling).
|
||||
if (curTs - lastBenchmarkUpdateTs > 200) {
|
||||
decodeSpeed = decodeTokens / ((curTs - firstTokenTs) / 1000f)
|
||||
if (decodeSpeed.isNaN()) {
|
||||
decodeSpeed = 0f
|
||||
}
|
||||
val benchmark = ChatMessageBenchmarkLlmResult(
|
||||
orderedStats = STATS,
|
||||
statValues = mutableMapOf(
|
||||
"prefill_speed" to prefillSpeed,
|
||||
"decode_speed" to decodeSpeed,
|
||||
"time_to_first_token" to timeToFirstToken,
|
||||
"latency" to (curTs - start).toFloat() / 1000f,
|
||||
),
|
||||
running = !done,
|
||||
latencyMs = -1f,
|
||||
)
|
||||
updateBenchmark(
|
||||
model = model,
|
||||
promptTemplateType = uiState.value.selectedPromptTemplateType,
|
||||
benchmark = benchmark
|
||||
)
|
||||
lastBenchmarkUpdateTs = curTs
|
||||
}
|
||||
|
||||
if (done) {
|
||||
setInProgress(false)
|
||||
}
|
||||
},
|
||||
cleanUpListener = {
|
||||
setPreparing(false)
|
||||
setInProgress(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun selectPromptTemplate(model: Model, promptTemplateType: PromptTemplateType) {
|
||||
Log.d(TAG, "selecting prompt template: ${promptTemplateType.label}")
|
||||
|
||||
// Clear response.
|
||||
updateResponse(model = model, promptTemplateType = promptTemplateType, response = "")
|
||||
|
||||
this._uiState.update { this.uiState.value.copy(selectedPromptTemplateType = promptTemplateType) }
|
||||
}
|
||||
|
||||
fun setInProgress(inProgress: Boolean) {
|
||||
_uiState.update { _uiState.value.copy(inProgress = inProgress) }
|
||||
}
|
||||
|
||||
fun setPreparing(preparing: Boolean) {
|
||||
_uiState.update { _uiState.value.copy(preparing = preparing) }
|
||||
}
|
||||
|
||||
fun updateResponse(model: Model, promptTemplateType: PromptTemplateType, response: String) {
|
||||
_uiState.update { currentState ->
|
||||
val currentResponses = currentState.responsesByModel
|
||||
val modelResponses = currentResponses[model.name]?.toMutableMap() ?: mutableMapOf()
|
||||
modelResponses[promptTemplateType.label] = response
|
||||
val newResponses = currentResponses.toMutableMap()
|
||||
newResponses[model.name] = modelResponses
|
||||
currentState.copy(responsesByModel = newResponses)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBenchmark(
|
||||
model: Model, promptTemplateType: PromptTemplateType, benchmark: ChatMessageBenchmarkLlmResult
|
||||
) {
|
||||
_uiState.update { currentState ->
|
||||
val currentBenchmark = currentState.benchmarkByModel
|
||||
val modelBenchmarks = currentBenchmark[model.name]?.toMutableMap() ?: mutableMapOf()
|
||||
modelBenchmarks[promptTemplateType.label] = benchmark
|
||||
val newBenchmarks = currentBenchmark.toMutableMap()
|
||||
newBenchmarks[model.name] = modelBenchmarks
|
||||
currentState.copy(benchmarkByModel = newBenchmarks)
|
||||
}
|
||||
}
|
||||
|
||||
fun stopResponse(model: Model) {
|
||||
Log.d(TAG, "Stopping response for model ${model.name}...")
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
setInProgress(false)
|
||||
val instance = model.instance as LlmModelInstance
|
||||
instance.session.cancelGenerateResponseAsync()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createUiState(task: Task): LlmSingleTurnUiState {
|
||||
val responsesByModel: MutableMap<String, Map<String, String>> = mutableMapOf()
|
||||
val benchmarkByModel: MutableMap<String, Map<String, ChatMessageBenchmarkLlmResult>> =
|
||||
mutableMapOf()
|
||||
for (model in task.models) {
|
||||
responsesByModel[model.name] = mutableMapOf()
|
||||
benchmarkByModel[model.name] = mutableMapOf()
|
||||
}
|
||||
return LlmSingleTurnUiState(
|
||||
responsesByModel = responsesByModel,
|
||||
benchmarkByModel = benchmarkByModel,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -1,185 +0,0 @@
|
|||
/*
|
||||
* Copyright 2025 Google LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.ai.edge.gallery.ui.llmsingleturn
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.graphics.Brush.Companion.linearGradient
|
||||
|
||||
enum class PromptTemplateInputEditorType {
|
||||
SINGLE_SELECT
|
||||
}
|
||||
|
||||
enum class RewriteToneType(val label: String) {
|
||||
FORMAL(label = "Formal"), CASUAL(label = "Casual"), FRIENDLY(label = "Friendly"), POLITE(label = "Polite"), ENTHUSIASTIC(
|
||||
label = "Enthusiastic"
|
||||
),
|
||||
CONCISE(label = "Concise"),
|
||||
}
|
||||
|
||||
enum class SummarizationType(val label: String) {
|
||||
KEY_BULLET_POINT(label = "Key bullet points (3-5)"),
|
||||
SHORT_PARAGRAPH(label = "Short paragraph (1-2 sentences)"),
|
||||
CONCISE_SUMMARY(label = "Concise summary (~50 words)"),
|
||||
HEADLINE_TITLE(label = "Headline / title"),
|
||||
ONE_SENTENCE_SUMMARY(label = "One-sentence summary"),
|
||||
}
|
||||
|
||||
enum class LanguageType(val label: String) {
|
||||
CPP(label = "C++"),
|
||||
JAVA(label = "Java"),
|
||||
JAVASCRIPT(label = "JavaScript"),
|
||||
KOTLIN(label = "Kotlin"),
|
||||
PYTHON(label = "Python"),
|
||||
SWIFT(label = "Swift"),
|
||||
TYPESCRIPT(label = "TypeScript"),
|
||||
}
|
||||
|
||||
enum class InputEditorLabel(val label: String) {
|
||||
TONE(label = "Tone"),
|
||||
STYLE(label = "Style"),
|
||||
LANGUAGE(label = "Language"),
|
||||
}
|
||||
|
||||
open class PromptTemplateInputEditor(
|
||||
open val label: String,
|
||||
open val type: PromptTemplateInputEditorType,
|
||||
open val defaultOption: String = "",
|
||||
)
|
||||
|
||||
/** Single select that shows options in bottom sheet. */
|
||||
class PromptTemplateSingleSelectInputEditor(
|
||||
override val label: String,
|
||||
val options: List<String> = listOf(),
|
||||
override val defaultOption: String = "",
|
||||
) : PromptTemplateInputEditor(
|
||||
label = label, type = PromptTemplateInputEditorType.SINGLE_SELECT, defaultOption = defaultOption
|
||||
)
|
||||
|
||||
data class PromptTemplateConfig(val inputEditors: List<PromptTemplateInputEditor> = listOf())
|
||||
|
||||
private val GEMINI_GRADIENT_STYLE = SpanStyle(
|
||||
brush = linearGradient(
|
||||
colors = listOf(Color(0xFF4285f4), Color(0xFF9b72cb), Color(0xFFd96570))
|
||||
)
|
||||
)
|
||||
|
||||
enum class PromptTemplateType(
|
||||
val label: String,
|
||||
val config: PromptTemplateConfig,
|
||||
val genFullPrompt: (userInput: String, inputEditorValues: Map<String, Any>) -> AnnotatedString = { _, _ ->
|
||||
AnnotatedString("")
|
||||
},
|
||||
val examplePrompts: List<String> = listOf(),
|
||||
) {
|
||||
FREE_FORM(
|
||||
label = "Free form",
|
||||
config = PromptTemplateConfig(),
|
||||
genFullPrompt = { userInput, _ -> AnnotatedString(userInput) },
|
||||
examplePrompts = listOf(
|
||||
"Suggest 3 topics for a podcast about \"Friendships in your 20s\".",
|
||||
"Outline the key sections needed in a basic logo design brief.",
|
||||
"List 3 pros and 3 cons to consider before buying a smart watch.",
|
||||
"Write a short, optimistic quote about the future of technology.",
|
||||
"Generate 3 potential names for a mobile app that helps users identify plants.",
|
||||
"Explain the difference between AI and machine learning in 2 sentences.",
|
||||
"Create a simple haiku about a cat sleeping in the sun.",
|
||||
"List 3 ways to make instant noodles taste better using common kitchen ingredients."
|
||||
)
|
||||
),
|
||||
REWRITE_TONE(
|
||||
label = "Rewrite tone", config = PromptTemplateConfig(
|
||||
inputEditors = listOf(
|
||||
PromptTemplateSingleSelectInputEditor(
|
||||
label = InputEditorLabel.TONE.label,
|
||||
options = RewriteToneType.entries.map { it.label },
|
||||
defaultOption = RewriteToneType.FORMAL.label
|
||||
)
|
||||
)
|
||||
), genFullPrompt = { userInput, inputEditorValues ->
|
||||
val tone = inputEditorValues[InputEditorLabel.TONE.label] as String
|
||||
buildAnnotatedString {
|
||||
withStyle(GEMINI_GRADIENT_STYLE) {
|
||||
append("Rewrite the following text using a ${tone.lowercase()} tone: ")
|
||||
}
|
||||
append(userInput)
|
||||
}
|
||||
}, examplePrompts = listOf(
|
||||
"Hey team, just wanted to remind everyone about the meeting tomorrow @ 10. Be there!",
|
||||
"Our new software update includes several bug fixes and performance improvements.",
|
||||
"Due to the fact that the weather was bad, we decided to postpone the event.",
|
||||
"Please find attached the requested documentation for your perusal.",
|
||||
"Welcome to the team. Review the onboarding materials.",
|
||||
)
|
||||
),
|
||||
SUMMARIZE_TEXT(
|
||||
label = "Summarize text",
|
||||
config = PromptTemplateConfig(
|
||||
inputEditors = listOf(
|
||||
PromptTemplateSingleSelectInputEditor(
|
||||
label = InputEditorLabel.STYLE.label,
|
||||
options = SummarizationType.entries.map { it.label },
|
||||
defaultOption = SummarizationType.KEY_BULLET_POINT.label
|
||||
)
|
||||
)
|
||||
),
|
||||
genFullPrompt = { userInput, inputEditorValues ->
|
||||
val style = inputEditorValues[InputEditorLabel.STYLE.label] as String
|
||||
buildAnnotatedString {
|
||||
withStyle(GEMINI_GRADIENT_STYLE) {
|
||||
append("Please summarize the following in ${style.lowercase()}: ")
|
||||
}
|
||||
append(userInput)
|
||||
}
|
||||
},
|
||||
examplePrompts = listOf(
|
||||
"The new Pixel phone features an advanced camera system with improved low-light performance and AI-powered editing tools. The display is brighter and more energy-efficient. It runs on the latest Tensor chip, offering faster processing and enhanced security features. Battery life has also been extended, providing all-day power for most users.",
|
||||
"Beginning this Friday, January 24, giant pandas Bao Li and Qing Bao are officially on view to the public at the Smithsonian’s National Zoo and Conservation Biology Institute (NZCBI). The 3-year-old bears arrived in Washington this past October, undergoing a quarantine period before making their debut. Under NZCBI’s new agreement with the CWCA, Qing Bao and Bao Li will remain in the United States for ten years, until April 2034, in exchange for an annual fee of \$1 million. The pair are still too young to breed, as pandas only reach sexual maturity between ages 4 and 7. “Kind of picture them as like awkward teenagers right now,” Lally told WUSA9. “We still have about two years before we would probably even see signs that they’re ready to start mating.”",
|
||||
),
|
||||
),
|
||||
CODE_SNIPPET(
|
||||
label = "Code snippet",
|
||||
config = PromptTemplateConfig(
|
||||
inputEditors = listOf(
|
||||
PromptTemplateSingleSelectInputEditor(
|
||||
label = InputEditorLabel.LANGUAGE.label,
|
||||
options = LanguageType.entries.map { it.label },
|
||||
defaultOption = LanguageType.JAVASCRIPT.label
|
||||
)
|
||||
)
|
||||
),
|
||||
genFullPrompt = { userInput, inputEditorValues ->
|
||||
val language = inputEditorValues[InputEditorLabel.LANGUAGE.label] as String
|
||||
buildAnnotatedString {
|
||||
withStyle(GEMINI_GRADIENT_STYLE) {
|
||||
append("Write a $language code snippet to ")
|
||||
}
|
||||
append(userInput)
|
||||
}
|
||||
},
|
||||
examplePrompts = listOf(
|
||||
"Create an alert box that says \"Hello, World!\"",
|
||||
"Declare an immutable variable named 'appName' with the value \"AI Gallery\"",
|
||||
"Print the numbers from 1 to 5 using a for loop.",
|
||||
"Write a function that returns the square of an integer input.",
|
||||
),
|
||||
),
|
||||
}
|
||||
|
|
@ -1,491 +0,0 @@
|
|||
/*
|
||||
* Copyright 2025 Google LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.ai.edge.gallery.ui.llmsingleturn
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.Send
|
||||
import androidx.compose.material.icons.outlined.ContentCopy
|
||||
import androidx.compose.material.icons.outlined.Description
|
||||
import androidx.compose.material.icons.outlined.ExpandLess
|
||||
import androidx.compose.material.icons.outlined.ExpandMore
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.Stop
|
||||
import androidx.compose.material.icons.rounded.Visibility
|
||||
import androidx.compose.material.icons.rounded.VisibilityOff
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedIconButton
|
||||
import androidx.compose.material3.PrimaryScrollableTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.ai.edge.gallery.R
|
||||
import com.google.ai.edge.gallery.data.Model
|
||||
import com.google.ai.edge.gallery.ui.common.chat.MessageBubbleShape
|
||||
import com.google.ai.edge.gallery.ui.modelmanager.ModelInitializationStatusType
|
||||
import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel
|
||||
import com.google.ai.edge.gallery.ui.theme.customColors
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private val promptTemplateTypes: List<PromptTemplateType> = PromptTemplateType.entries
|
||||
private val TAB_TITLES = PromptTemplateType.entries.map { it.label }
|
||||
private val ICON_BUTTON_SIZE = 42.dp
|
||||
|
||||
const val FULL_PROMPT_SWITCH_KEY = "full_prompt"
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PromptTemplatesPanel(
|
||||
model: Model,
|
||||
viewModel: LlmSingleTurnViewModel,
|
||||
modelManagerViewModel: ModelManagerViewModel,
|
||||
onSend: (fullPrompt: String) -> Unit,
|
||||
onStopButtonClicked: (Model) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val modelManagerUiState by modelManagerViewModel.uiState.collectAsState()
|
||||
val selectedPromptTemplateType = uiState.selectedPromptTemplateType
|
||||
val inProgress = uiState.inProgress
|
||||
var selectedTabIndex by remember { mutableIntStateOf(0) }
|
||||
var curTextInputContent by remember { mutableStateOf("") }
|
||||
val inputEditorValues: SnapshotStateMap<String, Any> = remember {
|
||||
mutableStateMapOf(FULL_PROMPT_SWITCH_KEY to false)
|
||||
}
|
||||
val fullPrompt by remember {
|
||||
derivedStateOf {
|
||||
uiState.selectedPromptTemplateType.genFullPrompt(curTextInputContent, inputEditorValues)
|
||||
}
|
||||
}
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val focusManager = LocalFocusManager.current
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val expandedStates = remember { mutableStateMapOf<String, Boolean>() }
|
||||
val modelInitializationStatus =
|
||||
modelManagerUiState.modelInitializationStatus[model.name]
|
||||
|
||||
// Update input editor values when prompt template changes.
|
||||
LaunchedEffect(selectedPromptTemplateType) {
|
||||
for (config in selectedPromptTemplateType.config.inputEditors) {
|
||||
inputEditorValues[config.label] = config.defaultOption
|
||||
}
|
||||
expandedStates.clear()
|
||||
}
|
||||
|
||||
var showExamplePromptBottomSheet by remember { mutableStateOf(false) }
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val bubbleBorderRadius = dimensionResource(R.dimen.chat_bubble_corner_radius)
|
||||
|
||||
Column(modifier = modifier) {
|
||||
// Scrollable tab row for all prompt templates.
|
||||
PrimaryScrollableTabRow(
|
||||
selectedTabIndex = selectedTabIndex
|
||||
) {
|
||||
TAB_TITLES.forEachIndexed { index, title ->
|
||||
Tab(selected = selectedTabIndex == index,
|
||||
enabled = !inProgress,
|
||||
onClick = {
|
||||
// Clear input when tab changes.
|
||||
curTextInputContent = ""
|
||||
// Reset full prompt switch.
|
||||
inputEditorValues[FULL_PROMPT_SWITCH_KEY] = false
|
||||
|
||||
selectedTabIndex = index
|
||||
viewModel.selectPromptTemplate(
|
||||
model = model,
|
||||
promptTemplateType = promptTemplateTypes[index]
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = title,
|
||||
modifier = Modifier.alpha(if (inProgress) 0.5f else 1f)
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Content.
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
// Input editor row.
|
||||
if (selectedPromptTemplateType.config.inputEditors.isNotEmpty()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerLow)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||
) {
|
||||
// Input editors.
|
||||
for (inputEditor in selectedPromptTemplateType.config.inputEditors) {
|
||||
when (inputEditor.type) {
|
||||
PromptTemplateInputEditorType.SINGLE_SELECT -> SingleSelectButton(config = inputEditor as PromptTemplateSingleSelectInputEditor,
|
||||
onSelected = { option ->
|
||||
inputEditorValues[inputEditor.label] = option
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Text input box.
|
||||
Box(contentAlignment = Alignment.BottomCenter, modifier = Modifier.weight(1f)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null // Disable the ripple effect
|
||||
) {
|
||||
// Request focus on the TextField when the Column is clicked
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
) {
|
||||
if (inputEditorValues[FULL_PROMPT_SWITCH_KEY] as Boolean) {
|
||||
Text(
|
||||
fullPrompt,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
.padding(bottom = 40.dp)
|
||||
.clip(MessageBubbleShape(radius = bubbleBorderRadius))
|
||||
.background(MaterialTheme.customColors.agentBubbleBgColor)
|
||||
.padding(16.dp)
|
||||
.focusRequester(focusRequester)
|
||||
)
|
||||
} else {
|
||||
TextField(
|
||||
value = curTextInputContent,
|
||||
onValueChange = { curTextInputContent = it },
|
||||
colors = TextFieldDefaults.colors(
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
focusedContainerColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
disabledIndicatorColor = Color.Transparent,
|
||||
disabledContainerColor = Color.Transparent,
|
||||
),
|
||||
textStyle = MaterialTheme.typography.bodyLarge,
|
||||
placeholder = { Text("Enter content") },
|
||||
modifier = Modifier
|
||||
.padding(bottom = 40.dp)
|
||||
.focusRequester(focusRequester)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Text action row.
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 16.dp)
|
||||
) {
|
||||
// Full prompt switch.
|
||||
if (selectedPromptTemplateType != PromptTemplateType.FREE_FORM && curTextInputContent.isNotEmpty()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.background(if (inputEditorValues[FULL_PROMPT_SWITCH_KEY] as Boolean) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.customColors.agentBubbleBgColor)
|
||||
.clickable {
|
||||
inputEditorValues[FULL_PROMPT_SWITCH_KEY] =
|
||||
!(inputEditorValues[FULL_PROMPT_SWITCH_KEY] as Boolean)
|
||||
}
|
||||
.height(40.dp)
|
||||
.border(
|
||||
width = 1.dp, color = MaterialTheme.colorScheme.surface, shape = CircleShape
|
||||
)
|
||||
.padding(horizontal = 12.dp)) {
|
||||
if (inputEditorValues[FULL_PROMPT_SWITCH_KEY] as Boolean) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Visibility,
|
||||
contentDescription = "",
|
||||
modifier = Modifier.size(FilterChipDefaults.IconSize),
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.VisibilityOff,
|
||||
contentDescription = "",
|
||||
modifier = Modifier
|
||||
.size(FilterChipDefaults.IconSize)
|
||||
.alpha(0.3f),
|
||||
)
|
||||
}
|
||||
Text("Preview prompt", style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// Button to copy full prompt.
|
||||
if (curTextInputContent.isNotEmpty()) {
|
||||
OutlinedIconButton(
|
||||
onClick = {
|
||||
val clipData = fullPrompt
|
||||
clipboardManager.setText(clipData)
|
||||
},
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = MaterialTheme.customColors.agentBubbleBgColor,
|
||||
disabledContainerColor = MaterialTheme.customColors.agentBubbleBgColor.copy(alpha = 0.4f),
|
||||
contentColor = MaterialTheme.colorScheme.primary,
|
||||
disabledContentColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
|
||||
),
|
||||
border = BorderStroke(width = 1.dp, color = MaterialTheme.colorScheme.surface),
|
||||
modifier = Modifier.size(ICON_BUTTON_SIZE)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.ContentCopy, contentDescription = "", modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Add example prompt button.
|
||||
OutlinedIconButton(
|
||||
enabled = !inProgress,
|
||||
onClick = { showExamplePromptBottomSheet = true },
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = MaterialTheme.customColors.agentBubbleBgColor,
|
||||
disabledContainerColor = MaterialTheme.customColors.agentBubbleBgColor.copy(alpha = 0.4f),
|
||||
contentColor = MaterialTheme.colorScheme.primary,
|
||||
disabledContentColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
|
||||
),
|
||||
border = BorderStroke(width = 1.dp, color = MaterialTheme.colorScheme.surface),
|
||||
modifier = Modifier.size(ICON_BUTTON_SIZE)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Rounded.Add,
|
||||
contentDescription = "",
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
|
||||
val modelInitializing =
|
||||
modelInitializationStatus?.status == ModelInitializationStatusType.INITIALIZING
|
||||
if (inProgress && !modelInitializing && !uiState.preparing) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
onStopButtonClicked(model)
|
||||
},
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
),
|
||||
modifier = Modifier.size(ICON_BUTTON_SIZE)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Rounded.Stop,
|
||||
contentDescription = "",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Send button
|
||||
OutlinedIconButton(
|
||||
enabled = !inProgress && curTextInputContent.isNotEmpty(),
|
||||
onClick = {
|
||||
focusManager.clearFocus()
|
||||
onSend(fullPrompt.text)
|
||||
},
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
disabledContainerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f),
|
||||
contentColor = MaterialTheme.colorScheme.primary,
|
||||
disabledContentColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
|
||||
),
|
||||
border = BorderStroke(width = 1.dp, color = MaterialTheme.colorScheme.surface),
|
||||
modifier = Modifier.size(ICON_BUTTON_SIZE)
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Rounded.Send,
|
||||
contentDescription = "",
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.offset(x = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showExamplePromptBottomSheet) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showExamplePromptBottomSheet = false },
|
||||
sheetState = sheetState,
|
||||
modifier = Modifier.wrapContentHeight(),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(bottom = 16.dp)) {
|
||||
// Title
|
||||
Text(
|
||||
"Select an example",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
|
||||
// Examples
|
||||
for (prompt in selectedPromptTemplateType.examplePrompts) {
|
||||
var textLayoutResultState by remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
val hasOverflow = remember(textLayoutResultState) {
|
||||
textLayoutResultState?.hasVisualOverflow ?: false
|
||||
}
|
||||
val isExpanded = expandedStates[prompt] ?: false
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
curTextInputContent = prompt
|
||||
scope.launch {
|
||||
// Give it sometime to show the click effect.
|
||||
delay(200)
|
||||
showExamplePromptBottomSheet = false
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(Icons.Outlined.Description, contentDescription = "")
|
||||
Text(prompt,
|
||||
maxLines = if (isExpanded) Int.MAX_VALUE else 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
onTextLayout = { textLayoutResultState = it }
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
if (hasOverflow && !isExpanded) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 2.dp),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
Box(modifier = Modifier
|
||||
.padding(end = 16.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||
.clickable {
|
||||
expandedStates[prompt] = true
|
||||
}
|
||||
.padding(vertical = 1.dp, horizontal = 6.dp)) {
|
||||
Icon(
|
||||
Icons.Outlined.ExpandMore,
|
||||
contentDescription = "",
|
||||
modifier = Modifier.size(12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (isExpanded) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 2.dp),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
Box(modifier = Modifier
|
||||
.padding(end = 16.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||
.clickable {
|
||||
expandedStates[prompt] = false
|
||||
}
|
||||
.padding(vertical = 1.dp, horizontal = 6.dp)) {
|
||||
Icon(
|
||||
Icons.Outlined.ExpandLess,
|
||||
contentDescription = "",
|
||||
modifier = Modifier.size(12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,262 +0,0 @@
|
|||
/*
|
||||
* Copyright 2025 Google LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.ai.edge.gallery.ui.llmsingleturn
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.foundation.text.TextAutoSize
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.AutoAwesome
|
||||
import androidx.compose.material.icons.outlined.ContentCopy
|
||||
import androidx.compose.material.icons.outlined.Timer
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.PrimaryTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.google.ai.edge.gallery.data.ConfigKey
|
||||
import com.google.ai.edge.gallery.data.Model
|
||||
import com.google.ai.edge.gallery.data.TASK_LLM_PROMPT_LAB
|
||||
import com.google.ai.edge.gallery.ui.common.chat.MarkdownText
|
||||
import com.google.ai.edge.gallery.ui.common.chat.MessageBodyBenchmarkLlm
|
||||
import com.google.ai.edge.gallery.ui.common.chat.MessageBodyLoading
|
||||
import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel
|
||||
import com.google.ai.edge.gallery.ui.modelmanager.PagerScrollState
|
||||
|
||||
private val OPTIONS = listOf("Response", "Benchmark")
|
||||
private val ICONS = listOf(Icons.Outlined.AutoAwesome, Icons.Outlined.Timer)
|
||||
private const val TAG = "AGResponsePanel"
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ResponsePanel(
|
||||
model: Model,
|
||||
viewModel: LlmSingleTurnViewModel,
|
||||
modelManagerViewModel: ModelManagerViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val task = TASK_LLM_PROMPT_LAB
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val modelManagerUiState by modelManagerViewModel.uiState.collectAsState()
|
||||
val inProgress = uiState.inProgress
|
||||
val initializing = uiState.preparing
|
||||
val selectedPromptTemplateType = uiState.selectedPromptTemplateType
|
||||
val responseScrollState = rememberScrollState()
|
||||
var selectedOptionIndex by remember { mutableIntStateOf(0) }
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = task.models.indexOf(model),
|
||||
pageCount = { task.models.size })
|
||||
val accelerator = model.getStringConfigValue(key = ConfigKey.ACCELERATOR, defaultValue = "")
|
||||
|
||||
// Select the "response" tab when prompt template changes.
|
||||
LaunchedEffect(selectedPromptTemplateType) {
|
||||
selectedOptionIndex = 0
|
||||
}
|
||||
|
||||
// Update selected model and clean up previous model when page is settled on a model page.
|
||||
LaunchedEffect(pagerState.settledPage) {
|
||||
val curSelectedModel = task.models[pagerState.settledPage]
|
||||
Log.d(
|
||||
TAG,
|
||||
"Pager settled on model '${curSelectedModel.name}' from '${model.name}'. Updating selected model."
|
||||
)
|
||||
if (curSelectedModel.name != model.name) {
|
||||
modelManagerViewModel.cleanupModel(task = task, model = model)
|
||||
}
|
||||
modelManagerViewModel.selectModel(curSelectedModel)
|
||||
}
|
||||
|
||||
// Trigger scroll sync.
|
||||
LaunchedEffect(pagerState) {
|
||||
snapshotFlow {
|
||||
PagerScrollState(
|
||||
page = pagerState.currentPage,
|
||||
offset = pagerState.currentPageOffsetFraction
|
||||
)
|
||||
}.collect { scrollState ->
|
||||
modelManagerViewModel.pagerScrollState.value = scrollState
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll pager when selected model changes.
|
||||
LaunchedEffect(modelManagerUiState.selectedModel) {
|
||||
pagerState.animateScrollToPage(task.models.indexOf(model))
|
||||
}
|
||||
|
||||
HorizontalPager(state = pagerState) { pageIndex ->
|
||||
val curPageModel = task.models[pageIndex]
|
||||
|
||||
val response =
|
||||
uiState.responsesByModel[curPageModel.name]?.get(selectedPromptTemplateType.label) ?: ""
|
||||
val benchmark =
|
||||
uiState.benchmarkByModel[curPageModel.name]?.get(selectedPromptTemplateType.label)
|
||||
|
||||
// Scroll to bottom when response changes.
|
||||
LaunchedEffect(response) {
|
||||
if (inProgress) {
|
||||
responseScrollState.animateScrollTo(responseScrollState.maxValue)
|
||||
}
|
||||
}
|
||||
|
||||
if (initializing) {
|
||||
Box(
|
||||
contentAlignment = Alignment.TopStart,
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
MessageBodyLoading()
|
||||
}
|
||||
} else {
|
||||
// Message when response is empty.
|
||||
if (response.isEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
"Response will appear here",
|
||||
modifier = Modifier.alpha(0.5f),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Response markdown.
|
||||
else {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 4.dp)
|
||||
) {
|
||||
// Response/benchmark switch.
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
PrimaryTabRow(
|
||||
selectedTabIndex = selectedOptionIndex,
|
||||
containerColor = Color.Transparent,
|
||||
) {
|
||||
OPTIONS.forEachIndexed { index, title ->
|
||||
Tab(selected = selectedOptionIndex == index, onClick = {
|
||||
selectedOptionIndex = index
|
||||
}, text = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Icon(
|
||||
ICONS[index],
|
||||
contentDescription = "",
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.alpha(0.7f)
|
||||
)
|
||||
var curTitle = title
|
||||
if (accelerator.isNotEmpty()) {
|
||||
curTitle = "$curTitle on $accelerator"
|
||||
}
|
||||
val titleColor = MaterialTheme.colorScheme.primary
|
||||
BasicText(
|
||||
text = curTitle,
|
||||
maxLines = 1,
|
||||
color = { titleColor },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
autoSize = TextAutoSize.StepBased(
|
||||
minFontSize = 9.sp,
|
||||
maxFontSize = 14.sp,
|
||||
stepSize = 1.sp
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (selectedOptionIndex == 0) {
|
||||
Box(
|
||||
contentAlignment = Alignment.BottomEnd,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(responseScrollState)
|
||||
) {
|
||||
MarkdownText(
|
||||
text = response,
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 40.dp)
|
||||
)
|
||||
}
|
||||
// Copy button.
|
||||
IconButton(
|
||||
onClick = {
|
||||
val clipData = AnnotatedString(response)
|
||||
clipboardManager.setText(clipData)
|
||||
},
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
contentColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.ContentCopy,
|
||||
contentDescription = "",
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (selectedOptionIndex == 1) {
|
||||
if (benchmark != null) {
|
||||
MessageBodyBenchmarkLlm(message = benchmark, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,90 +0,0 @@
|
|||
/*
|
||||
* Copyright 2025 Google LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.ai.edge.gallery.ui.llmsingleturn
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.ArrowDropDown
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun SingleSelectButton(
|
||||
config: PromptTemplateSingleSelectInputEditor,
|
||||
onSelected: (String) -> Unit
|
||||
) {
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
var selectedOption by remember { mutableStateOf(config.defaultOption) }
|
||||
|
||||
LaunchedEffect(config) {
|
||||
selectedOption = config.defaultOption
|
||||
}
|
||||
|
||||
Box {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer)
|
||||
.clickable {
|
||||
showMenu = true
|
||||
}
|
||||
.padding(vertical = 4.dp, horizontal = 6.dp)
|
||||
.padding(start = 8.dp)
|
||||
) {
|
||||
Text("${config.label}: $selectedOption", style = MaterialTheme.typography.labelLarge)
|
||||
Icon(Icons.Rounded.ArrowDropDown, contentDescription = "")
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false }
|
||||
) {
|
||||
// Options
|
||||
for (option in config.options) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(option) },
|
||||
onClick = {
|
||||
selectedOption = option
|
||||
showMenu = false
|
||||
onSelected(option)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,133 +0,0 @@
|
|||
/*
|
||||
* Copyright 2025 Google LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.ai.edge.gallery.ui.llmsingleturn
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.ai.edge.gallery.ui.theme.GalleryTheme
|
||||
import com.google.ai.edge.gallery.ui.theme.customColors
|
||||
|
||||
@Composable
|
||||
fun VerticalSplitView(
|
||||
topView: @Composable () -> Unit,
|
||||
bottomView: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
initialRatio: Float = 0.5f,
|
||||
minTopHeight: Dp = 250.dp,
|
||||
minBottomHeight: Dp = 200.dp,
|
||||
handleThickness: Dp = 20.dp
|
||||
) {
|
||||
var splitRatio by remember { mutableFloatStateOf(initialRatio) }
|
||||
var columnHeightPx by remember {
|
||||
mutableFloatStateOf(0f)
|
||||
}
|
||||
var columnHeightDp by remember {
|
||||
mutableStateOf(0.dp)
|
||||
}
|
||||
val localDensity = LocalDensity.current
|
||||
|
||||
Column(modifier = modifier
|
||||
.fillMaxSize()
|
||||
.onGloballyPositioned { coordinates ->
|
||||
// Set column height using the LayoutCoordinates
|
||||
columnHeightPx = coordinates.size.height.toFloat()
|
||||
columnHeightDp = with(localDensity) { coordinates.size.height.toDp() }
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(splitRatio)
|
||||
) {
|
||||
topView()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(handleThickness)
|
||||
.background(MaterialTheme.customColors.agentBubbleBgColor)
|
||||
.pointerInput(Unit) {
|
||||
detectDragGestures { change, dragAmount ->
|
||||
val newTopHeightPx = columnHeightPx * splitRatio + dragAmount.y
|
||||
var newTopHeightDp = with(localDensity) { newTopHeightPx.toDp() }
|
||||
if (newTopHeightDp < minTopHeight) {
|
||||
newTopHeightDp = minTopHeight
|
||||
}
|
||||
if (columnHeightDp - newTopHeightDp < minBottomHeight) {
|
||||
newTopHeightDp = columnHeightDp - minBottomHeight
|
||||
}
|
||||
splitRatio = newTopHeightDp / columnHeightDp
|
||||
change.consume()
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(32.dp)
|
||||
.height(4.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f - splitRatio)
|
||||
) {
|
||||
bottomView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun VerticalSplitViewPreview() {
|
||||
GalleryTheme {
|
||||
VerticalSplitView(topView = {
|
||||
Text("top")
|
||||
}, bottomView = {
|
||||
Text("bottom")
|
||||
})
|
||||
}
|
||||
}
|
|
@ -46,8 +46,8 @@ import com.google.ai.edge.gallery.data.Model
|
|||
import com.google.ai.edge.gallery.data.TASK_IMAGE_CLASSIFICATION
|
||||
import com.google.ai.edge.gallery.data.TASK_IMAGE_GENERATION
|
||||
import com.google.ai.edge.gallery.data.TASK_LLM_CHAT
|
||||
import com.google.ai.edge.gallery.data.TASK_LLM_ASK_IMAGE
|
||||
import com.google.ai.edge.gallery.data.TASK_LLM_PROMPT_LAB
|
||||
// import com.google.ai.edge.gallery.data.TASK_LLM_ASK_IMAGE // Removed
|
||||
// import com.google.ai.edge.gallery.data.TASK_LLM_PROMPT_LAB // Removed
|
||||
import com.google.ai.edge.gallery.data.TASK_TEXT_CLASSIFICATION
|
||||
import com.google.ai.edge.gallery.data.Task
|
||||
import com.google.ai.edge.gallery.data.TaskType
|
||||
|
@ -58,19 +58,53 @@ import com.google.ai.edge.gallery.ui.imageclassification.ImageClassificationDest
|
|||
import com.google.ai.edge.gallery.ui.imageclassification.ImageClassificationScreen
|
||||
import com.google.ai.edge.gallery.ui.imagegeneration.ImageGenerationDestination
|
||||
import com.google.ai.edge.gallery.ui.imagegeneration.ImageGenerationScreen
|
||||
import com.google.ai.edge.gallery.ui.llmchat.LlmChatDestination
|
||||
// LlmChatDestination will be defined in this file now
|
||||
import com.google.ai.edge.gallery.ui.llmchat.LlmChatScreen
|
||||
import com.google.ai.edge.gallery.ui.llmchat.LlmAskImageDestination
|
||||
import com.google.ai.edge.gallery.ui.llmchat.LlmAskImageScreen
|
||||
import com.google.ai.edge.gallery.ui.llmsingleturn.LlmSingleTurnDestination
|
||||
import com.google.ai.edge.gallery.ui.llmsingleturn.LlmSingleTurnScreen
|
||||
// import com.google.ai.edge.gallery.ui.llmchat.LlmAskImageDestination // Removed
|
||||
// import com.google.ai.edge.gallery.ui.llmchat.LlmAskImageScreen // Removed
|
||||
// import com.google.ai.edge.gallery.ui.llmsingleturn.LlmSingleTurnDestination // Removed
|
||||
// import com.google.ai.edge.gallery.ui.llmsingleturn.LlmSingleTurnScreen // Removed
|
||||
import com.google.ai.edge.gallery.ui.modelmanager.ModelManager
|
||||
import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel
|
||||
import com.google.ai.edge.gallery.ui.textclassification.TextClassificationDestination
|
||||
import com.google.ai.edge.gallery.ui.textclassification.TextClassificationScreen
|
||||
|
||||
import com.google.ai.edge.gallery.ui.userprofile.UserProfileScreen
|
||||
import com.google.ai.edge.gallery.ui.persona.PersonaManagementScreen // Added import
|
||||
import com.google.ai.edge.gallery.ui.conversationhistory.ConversationHistoryScreen // Added import
|
||||
// ViewModelProvider.Factory will provide UserProfileViewModel
|
||||
// import com.google.ai.edge.gallery.ui.common.LocalAppContainer // Not needed if VM is through factory
|
||||
private const val TAG = "AGGalleryNavGraph"
|
||||
private const val ROUTE_PLACEHOLDER = "placeholder"
|
||||
internal const val UserProfileRoute = "userProfile" // As per subtask for direct use
|
||||
|
||||
object UserProfileDestination {
|
||||
const val route = "userProfile"
|
||||
}
|
||||
|
||||
object PersonaManagementDestination {
|
||||
const val route = "personaManagement"
|
||||
}
|
||||
|
||||
// Define LlmChatDestination here as per structure in LlmChatScreen.kt modifications
|
||||
object LlmChatDestination {
|
||||
const val routeTemplate = "LlmChatRoute" // Base for non-conversation specific chat
|
||||
const val conversationIdArg = "conversationId"
|
||||
const val modelNameArg = "modelName" // Added modelNameArg for clarity
|
||||
|
||||
// Route for opening an existing conversation: LlmChatRoute/conversation/{conversationId}?modelName={modelName}
|
||||
val routeForConversation = "$routeTemplate/conversation/{$conversationIdArg}?$modelNameArg={$modelNameArg}"
|
||||
|
||||
// Route for starting a new chat with a pre-selected model: LlmChatRoute/new/{modelName}
|
||||
val routeForNewChatWithModel = "$routeTemplate/new/{$modelNameArg}"
|
||||
|
||||
// General route for new chat (model selected elsewhere or default): LlmChatRoute
|
||||
val routeForNewChat = routeTemplate
|
||||
}
|
||||
|
||||
object ConversationHistoryDestination {
|
||||
const val route = "conversationHistory"
|
||||
}
|
||||
|
||||
private const val ENTER_ANIMATION_DURATION_MS = 500
|
||||
private val ENTER_ANIMATION_EASING = EaseOutExpo
|
||||
private const val ENTER_ANIMATION_DELAY_MS = 100
|
||||
|
@ -122,6 +156,7 @@ fun GalleryNavHost(
|
|||
pickedTask = task
|
||||
showModelManager = true
|
||||
},
|
||||
navController = navController // Pass NavController to HomeScreen
|
||||
)
|
||||
|
||||
// Model manager.
|
||||
|
@ -210,54 +245,98 @@ fun GalleryNavHost(
|
|||
}
|
||||
|
||||
// LLM chat demos.
|
||||
|
||||
// Route for starting a new chat with a selected model (current primary way to enter chat)
|
||||
composable(
|
||||
route = "${LlmChatDestination.route}/{modelName}",
|
||||
arguments = listOf(navArgument("modelName") { type = NavType.StringType }),
|
||||
route = LlmChatDestination.routeForNewChatWithModel,
|
||||
arguments = listOf(navArgument(LlmChatDestination.modelNameArg) { type = NavType.StringType }),
|
||||
enterTransition = { slideEnter() },
|
||||
exitTransition = { slideExit() },
|
||||
) {
|
||||
getModelFromNavigationParam(it, TASK_LLM_CHAT)?.let { defaultModel ->
|
||||
modelManagerViewModel.selectModel(defaultModel)
|
||||
|
||||
LlmChatScreen(
|
||||
modelManagerViewModel = modelManagerViewModel,
|
||||
navigateUp = { navController.navigateUp() },
|
||||
)
|
||||
) { navBackStackEntry ->
|
||||
val modelName = navBackStackEntry.arguments?.getString(LlmChatDestination.modelNameArg)
|
||||
getModelByName(modelName ?: "")?.let { model -> // Use getModelByName from Tasks.kt
|
||||
modelManagerViewModel.selectModel(model)
|
||||
LlmChatScreen(
|
||||
modelManagerViewModel = modelManagerViewModel,
|
||||
navigateUp = { navController.navigateUp() },
|
||||
conversationId = null // Explicitly null for new chat
|
||||
)
|
||||
} ?: run {
|
||||
// Handle model not found, perhaps navigate back or show error
|
||||
Text("Model $modelName not found.")
|
||||
}
|
||||
}
|
||||
|
||||
// LLM single turn.
|
||||
// Route for opening an existing conversation
|
||||
composable(
|
||||
route = "${LlmSingleTurnDestination.route}/{modelName}",
|
||||
arguments = listOf(navArgument("modelName") { type = NavType.StringType }),
|
||||
route = LlmChatDestination.routeForConversation,
|
||||
arguments = listOf(
|
||||
navArgument(LlmChatDestination.conversationIdArg) { type = NavType.StringType },
|
||||
navArgument(LlmChatDestination.modelNameArg) { type = NavType.StringType } // Model name is mandatory here
|
||||
),
|
||||
enterTransition = { slideEnter() },
|
||||
exitTransition = { slideExit() },
|
||||
) {
|
||||
getModelFromNavigationParam(it, TASK_LLM_PROMPT_LAB)?.let { defaultModel ->
|
||||
modelManagerViewModel.selectModel(defaultModel)
|
||||
) { navBackStackEntry ->
|
||||
val conversationId = navBackStackEntry.arguments?.getString(LlmChatDestination.conversationIdArg)
|
||||
val modelName = navBackStackEntry.arguments?.getString(LlmChatDestination.modelNameArg)
|
||||
|
||||
LlmSingleTurnScreen(
|
||||
modelManagerViewModel = modelManagerViewModel,
|
||||
navigateUp = { navController.navigateUp() },
|
||||
)
|
||||
getModelByName(modelName ?: "")?.let { model ->
|
||||
modelManagerViewModel.selectModel(model) // Ensure this model is selected
|
||||
LlmChatScreen(
|
||||
modelManagerViewModel = modelManagerViewModel,
|
||||
navigateUp = { navController.navigateUp() },
|
||||
conversationId = conversationId
|
||||
)
|
||||
} ?: run {
|
||||
Text("Model $modelName not found for conversation $conversationId.")
|
||||
}
|
||||
}
|
||||
|
||||
// LLM image to text.
|
||||
// Optional: General route for new chat if model is already selected in ViewModel (less explicit)
|
||||
// composable(
|
||||
// route = LlmChatDestination.routeForNewChat,
|
||||
// enterTransition = { slideEnter() },
|
||||
// exitTransition = { slideExit() },
|
||||
// ) {
|
||||
// // This assumes a model is already selected in modelManagerViewModel for TASK_LLM_CHAT
|
||||
// // Or LlmChatScreen/ViewModel can handle model selection if none is active
|
||||
// LlmChatScreen(
|
||||
// modelManagerViewModel = modelManagerViewModel,
|
||||
// navigateUp = { navController.navigateUp() },
|
||||
// conversationId = null
|
||||
// )
|
||||
// }
|
||||
|
||||
// LLM single turn. - REMOVED
|
||||
|
||||
// LLM image to text. - REMOVED
|
||||
|
||||
// User Profile Screen
|
||||
composable(
|
||||
route = "${LlmAskImageDestination.route}/{modelName}",
|
||||
arguments = listOf(navArgument("modelName") { type = NavType.StringType }),
|
||||
route = UserProfileDestination.route,
|
||||
enterTransition = { slideEnter() },
|
||||
exitTransition = { slideExit() },
|
||||
) {
|
||||
getModelFromNavigationParam(it, TASK_LLM_ASK_IMAGE)?.let { defaultModel ->
|
||||
modelManagerViewModel.selectModel(defaultModel)
|
||||
// val appContainer = LocalAppContainer.current // Not strictly needed if factory handles it
|
||||
// val factory = ViewModelProvider.Factory(appContainer) // Factory is global
|
||||
UserProfileScreen(
|
||||
navController = navController,
|
||||
viewModelFactory = ViewModelProvider.Factory
|
||||
)
|
||||
}
|
||||
|
||||
LlmAskImageScreen(
|
||||
modelManagerViewModel = modelManagerViewModel,
|
||||
navigateUp = { navController.navigateUp() },
|
||||
)
|
||||
}
|
||||
composable(PersonaManagementDestination.route) {
|
||||
PersonaManagementScreen(
|
||||
navController = navController,
|
||||
viewModelFactory = ViewModelProvider.Factory
|
||||
)
|
||||
}
|
||||
|
||||
composable(ConversationHistoryDestination.route) {
|
||||
ConversationHistoryScreen(
|
||||
navController = navController,
|
||||
viewModelFactory = ViewModelProvider.Factory
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -284,12 +363,24 @@ fun navigateToTaskScreen(
|
|||
) {
|
||||
val modelName = model?.name ?: ""
|
||||
when (taskType) {
|
||||
// Removed UserProfileRoute from here as it's not a task-based navigation
|
||||
TaskType.TEXT_CLASSIFICATION -> navController.navigate("${TextClassificationDestination.route}/${modelName}")
|
||||
TaskType.IMAGE_CLASSIFICATION -> navController.navigate("${ImageClassificationDestination.route}/${modelName}")
|
||||
TaskType.LLM_CHAT -> navController.navigate("${LlmChatDestination.route}/${modelName}")
|
||||
TaskType.LLM_ASK_IMAGE -> navController.navigate("${LlmAskImageDestination.route}/${modelName}")
|
||||
TaskType.LLM_PROMPT_LAB -> navController.navigate("${LlmSingleTurnDestination.route}/${modelName}")
|
||||
TaskType.LLM_CHAT -> {
|
||||
// This is for starting a new chat from task selection, so use modelName
|
||||
if (modelName.isNotEmpty()) {
|
||||
navController.navigate("${LlmChatDestination.routeTemplate}/new/${modelName}")
|
||||
} else {
|
||||
// Fallback or error: model name is expected here
|
||||
Log.e(TAG, "LLM_CHAT navigation attempted without a model name.")
|
||||
// Optionally navigate to a generic new chat route if one exists and handles model selection
|
||||
// navController.navigate(LlmChatDestination.routeForNewChat)
|
||||
}
|
||||
}
|
||||
// TaskType.LLM_ASK_IMAGE removed
|
||||
// TaskType.LLM_PROMPT_LAB removed
|
||||
TaskType.IMAGE_GENERATION -> navController.navigate("${ImageGenerationDestination.route}/${modelName}")
|
||||
// TaskType.USER_PROFILE -> navController.navigate(UserProfileDestination.route) // Example if it were a task
|
||||
TaskType.TEST_TASK_1 -> {}
|
||||
TaskType.TEST_TASK_2 -> {}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,197 @@
|
|||
package com.google.ai.edge.gallery.ui.persona
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource // Added import
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.google.ai.edge.gallery.R // Added import for R class
|
||||
import com.google.ai.edge.gallery.data.Persona
|
||||
import com.google.ai.edge.gallery.ui.ViewModelProvider // For ViewModelProvider.Factory
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PersonaManagementScreen(
|
||||
navController: NavController,
|
||||
viewModelFactory: ViewModelProvider.Factory, // Changed from ViewModelProviderFactory
|
||||
viewModel: PersonaViewModel = viewModel(factory = viewModelFactory)
|
||||
) {
|
||||
val personas by viewModel.personas.collectAsState()
|
||||
val activePersonaId by viewModel.activePersonaId.collectAsState()
|
||||
var showAddPersonaDialog by remember { mutableStateOf(false) }
|
||||
var editingPersona by remember { mutableStateOf<Persona?>(null) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.persona_management_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.popBackStack() }) {
|
||||
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.user_profile_back_button_desc)) // Reusing from user profile
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = { editingPersona = null; showAddPersonaDialog = true }) {
|
||||
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.persona_management_add_fab_desc))
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
LazyColumn(
|
||||
contentPadding = paddingValues,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
if (personas.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.persona_management_no_personas),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(16.dp).fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
items(personas, key = { it.id }) { persona ->
|
||||
PersonaItem(
|
||||
persona = persona,
|
||||
isActive = persona.id == activePersonaId,
|
||||
onSetAsActive = { viewModel.setActivePersona(persona.id) },
|
||||
onEdit = { editingPersona = persona; showAddPersonaDialog = true },
|
||||
onDelete = { viewModel.deletePersona(persona.id) },
|
||||
isDefault = persona.isDefault // Pass isDefault
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showAddPersonaDialog) {
|
||||
AddEditPersonaDialog(
|
||||
personaToEdit = editingPersona,
|
||||
onDismiss = { showAddPersonaDialog = false },
|
||||
onConfirm = { name, prompt ->
|
||||
if (editingPersona == null) {
|
||||
viewModel.addPersona(name, prompt)
|
||||
} else {
|
||||
viewModel.updatePersona(editingPersona!!.copy(name = name, prompt = prompt))
|
||||
}
|
||||
showAddPersonaDialog = false
|
||||
editingPersona = null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PersonaItem(
|
||||
persona: Persona,
|
||||
isActive: Boolean,
|
||||
onSetAsActive: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
isDefault: Boolean // Added isDefault
|
||||
) {
|
||||
var showDeleteConfirmDialog by remember { mutableStateOf(false) }
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onSetAsActive() },
|
||||
elevation = CardDefaults.cardElevation(if (isActive) 4.dp else 1.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isActive) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isActive) Icons.Filled.Star else Icons.Filled.Person,
|
||||
contentDescription = stringResource(if (isActive) R.string.persona_item_active_desc else R.string.persona_item_inactive_desc),
|
||||
tint = if (isActive) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(end = 16.dp)
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = if (isDefault) "${persona.name} ${stringResource(id = R.string.persona_item_default_suffix)}" else persona.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(persona.prompt.take(100) + if (persona.prompt.length > 100) "..." else "", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
if (!isDefault) { // Only show edit/delete for non-default personas
|
||||
IconButton(onClick = onEdit) {
|
||||
Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.persona_item_edit_desc))
|
||||
}
|
||||
IconButton(onClick = { showDeleteConfirmDialog = true }) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.persona_item_delete_desc))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showDeleteConfirmDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteConfirmDialog = false },
|
||||
title = { Text(stringResource(R.string.persona_delete_dialog_title)) },
|
||||
text = { Text(stringResource(R.string.persona_delete_dialog_message, persona.name)) },
|
||||
confirmButton = { Button(onClick = { onDelete(); showDeleteConfirmDialog = false }) { Text(stringResource(R.string.persona_delete_dialog_confirm_button)) } },
|
||||
dismissButton = { Button(onClick = { showDeleteConfirmDialog = false }) { Text(stringResource(R.string.persona_delete_dialog_cancel_button)) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AddEditPersonaDialog(
|
||||
personaToEdit: Persona?,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (name: String, prompt: String) -> Unit
|
||||
) {
|
||||
var name by remember { mutableStateOf(personaToEdit?.name ?: "") }
|
||||
var prompt by remember { mutableStateOf(personaToEdit?.prompt ?: "") }
|
||||
val isEditMode = personaToEdit != null
|
||||
val isDefaultPersona = personaToEdit?.isDefault ?: false
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(if (isEditMode) R.string.persona_add_edit_dialog_edit_title else R.string.persona_add_edit_dialog_add_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text(stringResource(R.string.persona_add_edit_dialog_name_label)) },
|
||||
singleLine = true,
|
||||
readOnly = isDefaultPersona // Name is read-only for default personas
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = prompt,
|
||||
onValueChange = { prompt = it },
|
||||
label = { Text(stringResource(R.string.persona_add_edit_dialog_prompt_label)) },
|
||||
modifier = Modifier.heightIn(min = 150.dp, max = 300.dp) // Allow prompt to grow
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = { if (name.isNotBlank() && prompt.isNotBlank()) onConfirm(name, prompt) },
|
||||
enabled = name.isNotBlank() && prompt.isNotBlank()
|
||||
) { Text(stringResource(if (isEditMode) R.string.persona_add_edit_dialog_save_button else R.string.persona_add_edit_dialog_add_button)) }
|
||||
},
|
||||
dismissButton = { Button(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel_button)) } }
|
||||
)
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
package com.google.ai.edge.gallery.ui.persona
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.google.ai.edge.gallery.data.DataStoreRepository
|
||||
import com.google.ai.edge.gallery.data.Persona
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
|
||||
class PersonaViewModel(private val dataStoreRepository: DataStoreRepository) : ViewModel() {
|
||||
|
||||
private val _personas = MutableStateFlow<List<Persona>>(emptyList())
|
||||
val personas: StateFlow<List<Persona>> = _personas.asStateFlow()
|
||||
|
||||
val activePersonaId: StateFlow<String?> = dataStoreRepository.readActivePersonaId()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
|
||||
|
||||
init {
|
||||
loadPersonas()
|
||||
}
|
||||
|
||||
fun loadPersonas() {
|
||||
viewModelScope.launch {
|
||||
_personas.value = dataStoreRepository.readPersonas()
|
||||
}
|
||||
}
|
||||
|
||||
fun addPersona(name: String, prompt: String) {
|
||||
viewModelScope.launch {
|
||||
val newPersona = Persona(
|
||||
id = UUID.randomUUID().toString(),
|
||||
name = name,
|
||||
prompt = prompt
|
||||
)
|
||||
dataStoreRepository.addPersona(newPersona)
|
||||
loadPersonas() // Refresh list
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePersona(persona: Persona) {
|
||||
viewModelScope.launch {
|
||||
dataStoreRepository.updatePersona(persona)
|
||||
loadPersonas() // Refresh list
|
||||
}
|
||||
}
|
||||
|
||||
fun deletePersona(personaId: String) {
|
||||
viewModelScope.launch {
|
||||
dataStoreRepository.deletePersona(personaId)
|
||||
// If the deleted persona was active, clear the active state
|
||||
if (activePersonaId.value == personaId) {
|
||||
setActivePersona(null)
|
||||
}
|
||||
loadPersonas() // Refresh list
|
||||
}
|
||||
}
|
||||
|
||||
fun setActivePersona(personaId: String?) {
|
||||
viewModelScope.launch {
|
||||
dataStoreRepository.saveActivePersonaId(personaId)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,177 @@
|
|||
package com.google.ai.edge.gallery.ui.userprofile
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AddCircle
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Save // Changed from AddCircle for FAB
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource // Added import
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.google.ai.edge.gallery.R // Added import for R class
|
||||
// import com.google.ai.edge.gallery.data.UserProfile // Not directly used in this file
|
||||
import com.google.ai.edge.gallery.ui.ViewModelProvider // Assuming ViewModelProvider.Factory
|
||||
// import com.google.ai.edge.gallery.ui.common.LocalAppContainer // Not used
|
||||
// import com.google.ai.edge.gallery.ui.navigation.GalleryDestinations // Not used here
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun UserProfileScreen(
|
||||
navController: NavController,
|
||||
viewModelFactory: ViewModelProvider.Factory, // Adjusted to typical factory naming
|
||||
viewModel: UserProfileViewModel = viewModel(factory = viewModelFactory)
|
||||
) {
|
||||
val userProfile by viewModel.userProfile.collectAsState()
|
||||
val scrollState = rememberScrollState()
|
||||
var showSaveConfirmation by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.user_profile_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.popBackStack() }) {
|
||||
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.user_profile_back_button_desc))
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = {
|
||||
viewModel.saveUserProfile()
|
||||
showSaveConfirmation = true
|
||||
},
|
||||
icon = { Icon(Icons.Filled.Save, contentDescription = stringResource(R.string.user_profile_save_button)) },
|
||||
text = { Text(stringResource(R.string.user_profile_save_button)) }
|
||||
)
|
||||
},
|
||||
floatingActionButtonPosition = FabPosition.Center
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.padding(16.dp)
|
||||
.verticalScroll(scrollState)
|
||||
.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Text("Your Information", style = MaterialTheme.typography.headlineSmall) // This was not in strings.xml, can be added if needed
|
||||
|
||||
OutlinedTextField(
|
||||
value = userProfile.name ?: "",
|
||||
onValueChange = { viewModel.updateName(it) },
|
||||
label = { Text(stringResource(R.string.user_profile_name_label)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = userProfile.summary ?: "",
|
||||
onValueChange = { viewModel.updateSummary(it) },
|
||||
label = { Text(stringResource(R.string.user_profile_summary_label)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 3
|
||||
)
|
||||
|
||||
// Skills Section
|
||||
EditableListSection(
|
||||
title = stringResource(R.string.user_profile_skills_title),
|
||||
items = userProfile.skills,
|
||||
onAddItem = { viewModel.addSkill() },
|
||||
onRemoveItem = { index -> viewModel.removeSkill(index) },
|
||||
onUpdateItem = { index, text -> viewModel.updateSkill(index, text) },
|
||||
itemLabel = { index -> stringResource(R.string.user_profile_skill_label_prefix, index + 1) },
|
||||
addButtonText = stringResource(R.string.user_profile_add_skill_button),
|
||||
emptyListText = stringResource(R.string.user_profile_no_skills),
|
||||
removeItemDesc = stringResource(R.string.user_profile_remove_item_desc)
|
||||
)
|
||||
|
||||
// Experience Section
|
||||
EditableListSection(
|
||||
title = stringResource(R.string.user_profile_experience_title),
|
||||
items = userProfile.experience,
|
||||
onAddItem = { viewModel.addExperience() },
|
||||
onRemoveItem = { index -> viewModel.removeExperience(index) },
|
||||
onUpdateItem = { index, text -> viewModel.updateExperience(index, text) },
|
||||
itemLabel = { index -> stringResource(R.string.user_profile_experience_label_prefix, index + 1) },
|
||||
isMultiLine = true,
|
||||
addButtonText = stringResource(R.string.user_profile_add_experience_button),
|
||||
emptyListText = stringResource(R.string.user_profile_no_experience),
|
||||
removeItemDesc = stringResource(R.string.user_profile_remove_item_desc)
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(60.dp)) // Space for FAB
|
||||
}
|
||||
|
||||
if (showSaveConfirmation) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showSaveConfirmation = false },
|
||||
title = { Text(stringResource(R.string.user_profile_saved_dialog_title)) },
|
||||
text = { Text(stringResource(R.string.user_profile_saved_dialog_message)) },
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
showSaveConfirmation = false
|
||||
// Consider navigating back or giving other options
|
||||
// navController.popBackStack()
|
||||
}) { Text(stringResource(R.string.dialog_ok_button)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditableListSection(
|
||||
title: String,
|
||||
items: List<String>,
|
||||
onAddItem: () -> Unit,
|
||||
onRemoveItem: (Int) -> Unit,
|
||||
onUpdateItem: (Int, String) -> Unit,
|
||||
itemLabel: (Int) -> String,
|
||||
isMultiLine: Boolean = false,
|
||||
addButtonText: String,
|
||||
emptyListText: String,
|
||||
removeItemDesc: String
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
Button(onClick = onAddItem) {
|
||||
Icon(Icons.Filled.AddCircle, contentDescription = addButtonText)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(addButtonText)
|
||||
}
|
||||
}
|
||||
items.forEachIndexed { index, item ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = item,
|
||||
onValueChange = { onUpdateItem(index, it) },
|
||||
label = { Text(itemLabel(index)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
minLines = if (isMultiLine) 2 else 1,
|
||||
maxLines = if (isMultiLine) 5 else 1
|
||||
)
|
||||
IconButton(onClick = { onRemoveItem(index) }) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = removeItemDesc)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (items.isEmpty()) {
|
||||
Text(emptyListText, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,87 @@
|
|||
package com.google.ai.edge.gallery.ui.userprofile
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.google.ai.edge.gallery.data.DataStoreRepository
|
||||
import com.google.ai.edge.gallery.data.UserProfile
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
|
||||
class UserProfileViewModel(private val dataStoreRepository: DataStoreRepository) : ViewModel() {
|
||||
|
||||
private val _userProfile = MutableStateFlow(UserProfile()) // Initialize with default
|
||||
val userProfile: StateFlow<UserProfile> = _userProfile.asStateFlow()
|
||||
|
||||
init {
|
||||
loadUserProfile()
|
||||
}
|
||||
|
||||
fun loadUserProfile() {
|
||||
viewModelScope.launch {
|
||||
_userProfile.value = dataStoreRepository.readUserProfile() ?: UserProfile()
|
||||
}
|
||||
}
|
||||
|
||||
fun saveUserProfile() {
|
||||
viewModelScope.launch {
|
||||
dataStoreRepository.saveUserProfile(_userProfile.value)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateName(name: String) {
|
||||
_userProfile.value = _userProfile.value.copy(name = name)
|
||||
}
|
||||
|
||||
fun updateSummary(summary: String) {
|
||||
_userProfile.value = _userProfile.value.copy(summary = summary)
|
||||
}
|
||||
|
||||
// Skills management
|
||||
fun addSkill(skill: String = "") { // Add empty skill by default
|
||||
val currentSkills = _userProfile.value.skills.toMutableList()
|
||||
currentSkills.add(skill)
|
||||
_userProfile.value = _userProfile.value.copy(skills = currentSkills)
|
||||
}
|
||||
|
||||
fun updateSkill(index: Int, skill: String) {
|
||||
val currentSkills = _userProfile.value.skills.toMutableList()
|
||||
if (index >= 0 && index < currentSkills.size) {
|
||||
currentSkills[index] = skill
|
||||
_userProfile.value = _userProfile.value.copy(skills = currentSkills)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeSkill(index: Int) {
|
||||
val currentSkills = _userProfile.value.skills.toMutableList()
|
||||
if (index >= 0 && index < currentSkills.size) {
|
||||
currentSkills.removeAt(index)
|
||||
_userProfile.value = _userProfile.value.copy(skills = currentSkills)
|
||||
}
|
||||
}
|
||||
|
||||
// Experience management
|
||||
fun addExperience(experienceItem: String = "") { // Add empty experience by default
|
||||
val currentExperience = _userProfile.value.experience.toMutableList()
|
||||
currentExperience.add(experienceItem)
|
||||
_userProfile.value = _userProfile.value.copy(experience = currentExperience)
|
||||
}
|
||||
|
||||
fun updateExperience(index: Int, experienceItem: String) {
|
||||
val currentExperience = _userProfile.value.experience.toMutableList()
|
||||
if (index >= 0 && index < currentExperience.size) {
|
||||
currentExperience[index] = experienceItem
|
||||
_userProfile.value = _userProfile.value.copy(experience = currentExperience)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeExperience(index: Int) {
|
||||
val currentExperience = _userProfile.value.experience.toMutableList()
|
||||
if (index >= 0 && index < currentExperience.size) {
|
||||
currentExperience.removeAt(index)
|
||||
_userProfile.value = _userProfile.value.copy(experience = currentExperience)
|
||||
}
|
||||
}
|
||||
}
|
72
Android/src/app/src/main/res/values-ko/strings.xml
Normal file
72
Android/src/app/src/main/res/values-ko/strings.xml
Normal file
|
@ -0,0 +1,72 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- UserProfileScreen.kt -->
|
||||
<string name="user_profile_title">개인 프로필</string>
|
||||
<string name="user_profile_name_label">이름</string>
|
||||
<string name="user_profile_summary_label">요약 / 소개</string>
|
||||
<string name="user_profile_skills_title">기술</string>
|
||||
<string name="user_profile_experience_title">경험</string>
|
||||
<string name="user_profile_add_skill_button">기술 추가</string>
|
||||
<string name="user_profile_add_experience_button">경험 추가</string>
|
||||
<string name="user_profile_skill_label_prefix">기술 %d</string>
|
||||
<string name="user_profile_experience_label_prefix">경험 %d</string>
|
||||
<string name="user_profile_no_skills">아직 추가된 기술이 없습니다.</string>
|
||||
<string name="user_profile_no_experience">아직 추가된 경험이 없습니다.</string>
|
||||
<string name="user_profile_save_button">프로필 저장</string>
|
||||
<string name="user_profile_saved_dialog_title">프로필 저장됨</string>
|
||||
<string name="user_profile_saved_dialog_message">프로필 정보가 성공적으로 저장되었습니다.</string>
|
||||
<string name="user_profile_back_button_desc">뒤로</string>
|
||||
<string name="user_profile_remove_item_desc">항목 삭제</string>
|
||||
|
||||
<!-- PersonaManagementScreen.kt -->
|
||||
<string name="persona_management_title">페르소나 관리</string>
|
||||
<string name="persona_management_add_fab_desc">페르소나 추가</string>
|
||||
<string name="persona_management_no_personas">아직 생성된 페르소나가 없습니다. '+' 버튼을 클릭하여 추가하세요.</string>
|
||||
<string name="persona_item_active_desc">활성 페르소나</string>
|
||||
<string name="persona_item_inactive_desc">페르소나</string>
|
||||
<string name="persona_item_edit_desc">페르소나 수정</string>
|
||||
<string name="persona_item_delete_desc">페르소나 삭제</string>
|
||||
<string name="persona_delete_dialog_title">페르소나 삭제</string>
|
||||
<string name="persona_delete_dialog_message">'%s' 페르소나를 정말 삭제하시겠습니까?</string>
|
||||
<string name="persona_delete_dialog_confirm_button">삭제</string>
|
||||
<string name="persona_delete_dialog_cancel_button">취소</string>
|
||||
<string name="persona_add_edit_dialog_add_title">새 페르소나 추가</string>
|
||||
<string name="persona_add_edit_dialog_edit_title">페르소나 수정</string>
|
||||
<string name="persona_add_edit_dialog_name_label">페르소나 이름</string>
|
||||
<string name="persona_add_edit_dialog_prompt_label">페르소나 프롬프트 (시스템 프롬프트)</string>
|
||||
<string name="persona_add_edit_dialog_add_button">페르소나 추가</string>
|
||||
<string name="persona_add_edit_dialog_save_button">변경사항 저장</string>
|
||||
<string name="persona_add_edit_dialog_name_default_assist">도움이 되는 어시스턴트</string>
|
||||
<string name="persona_add_edit_dialog_prompt_default_assist">당신은 도움이 되고 친절한 AI 어시스턴트입니다. 간결하고 정확한 정보를 제공하려고 노력합니다.</string>
|
||||
<string name="persona_add_edit_dialog_name_default_creative">창의적인 브레인스토머</string>
|
||||
<string name="persona_add_edit_dialog_prompt_default_creative">당신은 창의적인 브레인스토밍 파트너입니다. 열정적이고 다양한 아이디어를 생성하며 여러 각도에서 탐색하도록 장려합니다.</string>
|
||||
<string name="persona_item_default_suffix">(기본)</string>
|
||||
|
||||
<!-- LlmChatScreen.kt (ChatViewWrapper) -->
|
||||
<string name="chat_custom_system_prompt_label">사용자 정의 시스템 프롬프트 입력 (선택 사항)</string>
|
||||
<string name="chat_default_agent_name">AI 어시스턴트</string>
|
||||
<string name="chat_history_button_desc">대화 기록</string>
|
||||
<string name="chat_new_conversation_title_prefix">다음 날짜의 채팅</string>
|
||||
<string name="chat_reset_session_confirm_title">채팅 초기화?</string>
|
||||
<string name="chat_reset_session_confirm_message">현재 설정으로 새 채팅 세션을 시작합니다. 현재 메시지는 화면에서 지워지지만 메시지가 있는 경우 대화는 저장됩니다. 확실합니까?</string>
|
||||
<string name="chat_reset_session_confirm_button">초기화</string>
|
||||
|
||||
<!-- ConversationHistoryScreen.kt -->
|
||||
<string name="conversation_history_title">대화 기록</string>
|
||||
<string name="conversation_history_no_conversations">아직 대화가 없습니다.</string>
|
||||
<string name="conversation_history_item_title_prefix">%s 대화</string>
|
||||
<string name="conversation_history_last_activity_prefix">마지막 활동: %s</string>
|
||||
<string name="conversation_history_delete_dialog_title">대화 삭제</string>
|
||||
<string name="conversation_history_delete_dialog_message">이 대화를 정말 삭제하시겠습니까?</string>
|
||||
<string name="conversation_history_delete_dialog_confirm_button">삭제</string>
|
||||
|
||||
<!-- SettingsDialog.kt -->
|
||||
<string name="settings_personal_profile_title">개인 프로필</string>
|
||||
<string name="settings_edit_profile_button">프로필 수정</string>
|
||||
<string name="settings_persona_management_title">페르소나 관리</string>
|
||||
<string name="settings_manage_personas_button">페르소나 관리</string>
|
||||
|
||||
<!-- Common -->
|
||||
<string name="dialog_ok_button">확인</string>
|
||||
<string name="dialog_cancel_button">취소</string>
|
||||
</resources>
|
|
@ -1,3 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2025 Google LLC
|
||||
|
||||
|
@ -18,8 +19,8 @@
|
|||
<string name="app_name">Google AI Edge Gallery</string>
|
||||
<string name="model_manager">Model Manager</string>
|
||||
<string name="downloaded_size">%1$s downloaded</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
<string name="ok">OK</string>
|
||||
<!-- <string name="cancel">Cancel</string> Replaced by dialog_cancel_button -->
|
||||
<!-- <string name="ok">OK</string> Replaced by dialog_ok_button -->
|
||||
<string name="confirm_delete_model_dialog_title">Delete download</string>
|
||||
<string name="confirm_delete_model_dialog_content">Are you sure you want to delete the downloaded model \"%s\"?</string>
|
||||
<string name="notification_title_success">Model download succeeded</string>
|
||||
|
@ -29,7 +30,7 @@
|
|||
<string name="chat_textinput_placeholder">Type message…</string>
|
||||
<string name="chat_you">You</string>
|
||||
<string name="chat_llm_agent_name">LLM</string>
|
||||
<string name="chat_generic_agent_name">Model</string>
|
||||
<string name="chat_generic_agent_name">Model</string> <!-- Consider using chat_default_agent_name for new features -->
|
||||
<string name="chat_generic_result_name">Result</string>
|
||||
<string name="model_not_downloaded_msg">Model not downloaded yet</string>
|
||||
<string name="model_is_initializing_msg">Initializing model…</string>
|
||||
|
@ -40,4 +41,75 @@
|
|||
<string name="benchmark">Run benchmark</string>
|
||||
<string name="warming_up">warming up…</string>
|
||||
<string name="running">running</string>
|
||||
</resources>
|
||||
|
||||
<!-- UserProfileScreen.kt -->
|
||||
<string name="user_profile_title">Personal Profile</string>
|
||||
<string name="user_profile_name_label">Name</string>
|
||||
<string name="user_profile_summary_label">Summary / Bio</string>
|
||||
<string name="user_profile_skills_title">Skills</string>
|
||||
<string name="user_profile_experience_title">Experience</string>
|
||||
<string name="user_profile_add_skill_button">Add Skill</string>
|
||||
<string name="user_profile_add_experience_button">Add Experience</string>
|
||||
<string name="user_profile_skill_label_prefix">Skill %d</string>
|
||||
<string name="user_profile_experience_label_prefix">Experience %d</string>
|
||||
<string name="user_profile_no_skills">No skills added yet.</string>
|
||||
<string name="user_profile_no_experience">No experience added yet.</string>
|
||||
<string name="user_profile_save_button">Save Profile</string>
|
||||
<string name="user_profile_saved_dialog_title">Profile Saved</string>
|
||||
<string name="user_profile_saved_dialog_message">Your profile information has been saved successfully.</string>
|
||||
<string name="user_profile_back_button_desc">Back</string>
|
||||
<string name="user_profile_remove_item_desc">Remove item</string>
|
||||
|
||||
<!-- PersonaManagementScreen.kt -->
|
||||
<string name="persona_management_title">Manage Personas</string>
|
||||
<string name="persona_management_add_fab_desc">Add Persona</string>
|
||||
<string name="persona_management_no_personas">No personas created yet. Click the '+' button to add one.</string>
|
||||
<string name="persona_item_active_desc">Active Persona</string>
|
||||
<string name="persona_item_inactive_desc">Persona</string>
|
||||
<string name="persona_item_edit_desc">Edit Persona</string>
|
||||
<string name="persona_item_delete_desc">Delete Persona</string>
|
||||
<string name="persona_delete_dialog_title">Delete Persona</string>
|
||||
<string name="persona_delete_dialog_message">Are you sure you want to delete \'%s\'?</string> <!-- %s is persona name -->
|
||||
<string name="persona_delete_dialog_confirm_button">Delete</string>
|
||||
<!-- <string name="persona_delete_dialog_cancel_button">Cancel</string> Using common dialog_cancel_button -->
|
||||
<string name="persona_add_edit_dialog_add_title">Add New Persona</string>
|
||||
<string name="persona_add_edit_dialog_edit_title">Edit Persona</string>
|
||||
<string name="persona_add_edit_dialog_name_label">Persona Name</string>
|
||||
<string name="persona_add_edit_dialog_prompt_label">Persona Prompt (System Prompt)</string>
|
||||
<string name="persona_add_edit_dialog_add_button">Add Persona</string>
|
||||
<string name="persona_add_edit_dialog_save_button">Save Changes</string>
|
||||
<string name="persona_add_edit_dialog_name_default_assist">Helpful Assistant</string>
|
||||
<string name="persona_add_edit_dialog_prompt_default_assist">You are a helpful and friendly AI assistant. You are concise and try to provide accurate information.</string>
|
||||
<string name="persona_add_edit_dialog_name_default_creative">Creative Brainstormer</string>
|
||||
<string name="persona_add_edit_dialog_prompt_default_creative">You are a creative brainstorming partner. You are enthusiastic and generate diverse ideas, encouraging exploration of different angles.</string>
|
||||
<string name="persona_item_default_suffix">(Default)</string>
|
||||
|
||||
<!-- LlmChatScreen.kt (ChatViewWrapper) -->
|
||||
<string name="chat_custom_system_prompt_label">Enter a custom system prompt (optional)</string>
|
||||
<string name="chat_default_agent_name">AI Assistant</string> <!-- Replaces R.string.chat_generic_agent_name for new features -->
|
||||
<string name="chat_history_button_desc">Conversation History</string>
|
||||
<string name="chat_new_conversation_title_prefix">Chat on</string> <!-- For auto-title if user doesn't set one -->
|
||||
<string name="chat_reset_session_confirm_title">Reset Chat?</string>
|
||||
<string name="chat_reset_session_confirm_message">This will start a new chat session with the current settings. The current messages will be cleared from view but the conversation will be saved if it has messages. Are you sure?</string>
|
||||
<string name="chat_reset_session_confirm_button">Reset</string>
|
||||
|
||||
<!-- ConversationHistoryScreen.kt -->
|
||||
<string name="conversation_history_title">Conversation History</string>
|
||||
<string name="conversation_history_no_conversations">No conversations yet.</string>
|
||||
<string name="conversation_history_item_title_prefix">Conversation on %s</string> <!-- %s is date -->
|
||||
<string name="conversation_history_last_activity_prefix">Last activity: %s</string> <!-- %s is date -->
|
||||
<string name="conversation_history_delete_dialog_title">Delete Conversation</string>
|
||||
<string name="conversation_history_delete_dialog_message">Are you sure you want to delete this conversation?</string>
|
||||
<string name="conversation_history_delete_dialog_confirm_button">Delete</string>
|
||||
|
||||
<!-- SettingsDialog.kt -->
|
||||
<string name="settings_personal_profile_title">Personal Profile</string>
|
||||
<string name="settings_edit_profile_button">Edit Your Profile</string>
|
||||
<string name="settings_persona_management_title">Persona Management</string>
|
||||
<string name="settings_manage_personas_button">Manage Personas</string>
|
||||
|
||||
<!-- Common -->
|
||||
<string name="dialog_ok_button">OK</string>
|
||||
<string name="dialog_cancel_button">Cancel</string>
|
||||
|
||||
</resources>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue